Compare commits

..

9 Commits

Author SHA1 Message Date
Matthieu
f82a79b2aa fix: correct DEFAULT_ORIENTATIONS for _ProductConstructeurs
After migration, the table orientation is now A=constructeur, B=product.
Update the fallback orientation to match the new schema.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-22 12:06:08 +01:00
Matthieu
208d49aac8 fix: use DELETE instead of TRUNCATE for migration
Use DELETE instead of TRUNCATE to avoid requiring table ownership.
Wrap in DO block for better error handling and logging.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-22 11:51:57 +01:00
Matthieu
ff278f5549 fix: correct Product-Constructeur join table orientation
The _ProductConstructeurs table was created with wrong column order:
- Before: A=product, B=constructeur
- After: A=constructeur, B=product (alphabetical order expected by Prisma)

This caused Prisma to fail loading constructeurs relations, resulting in empty constructeurs arrays in API responses.

Changes:
- Added migration to swap A/B columns and recreate foreign keys
- Added debug logs in products.service.ts and constructeur-link.util.ts

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-22 11:48:06 +01:00
Matthieu
8cfe48e0f5 Hydrate constructeur links for products and pieces 2025-12-03 11:35:09 +01:00
Matthieu
ead5d98e61 Test CI OK 2025-12-03 11:35:09 +01:00
Matthieu
82fa6589f2 Test CI Woodpecker 2025-12-03 11:35:09 +01:00
1d3d606bd6 Actualiser .github/workflows/ci.yml 2025-11-21 23:37:14 +00:00
baa855c7a1 Actualiser .github/workflows/ci.yml 2025-11-21 22:29:10 +00:00
fb0a18cb87 Actualiser .github/workflows/ci.yml
Some checks failed
test-ci-simple / test (push) Has been cancelled
2025-11-21 22:18:09 +00:00
6 changed files with 211 additions and 18 deletions

View File

@@ -5,20 +5,30 @@ on:
branches:
- main
- develop
- test-ci
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run unit tests
run: npm test -- --runInBand
- name: Run e2e tests
run: npm run test:e2e -- --runInBand

8
.woodpecker.yml Normal file
View File

@@ -0,0 +1,8 @@
steps:
test:
image: alpine
commands:
- echo "Woodpecker CI fonctionne parfaitement !"
- uname -a
- ls -la

View File

@@ -0,0 +1,49 @@
-- Fix _ProductConstructeurs table orientation
-- Issue: Table was created with A=product, B=constructeur
-- But Prisma expects alphabetical order: A=constructeur, B=product
-- This migration swaps the A and B columns to fix the orientation
DO $$
DECLARE
backup_count INTEGER;
BEGIN
-- Step 1: Create temp table with swapped data
CREATE TEMP TABLE IF NOT EXISTS _ProductConstructeurs_backup AS
SELECT "B" as new_A, "A" as new_B FROM "_ProductConstructeurs";
GET DIAGNOSTICS backup_count = ROW_COUNT;
RAISE NOTICE 'Backed up % rows', backup_count;
-- Step 2: Drop foreign key constraints
ALTER TABLE "_ProductConstructeurs" DROP CONSTRAINT IF EXISTS "_ProductConstructeurs_A_fkey";
ALTER TABLE "_ProductConstructeurs" DROP CONSTRAINT IF EXISTS "_ProductConstructeurs_B_fkey";
RAISE NOTICE 'Dropped foreign key constraints';
-- Step 3: Clear the table
DELETE FROM "_ProductConstructeurs";
RAISE NOTICE 'Cleared table';
-- Step 4: Reinsert data with swapped columns
INSERT INTO "_ProductConstructeurs" ("A", "B")
SELECT new_A, new_B FROM _ProductConstructeurs_backup;
GET DIAGNOSTICS backup_count = ROW_COUNT;
RAISE NOTICE 'Reinserted % rows with swapped columns', backup_count;
-- Step 5: Recreate foreign key constraints with correct orientation
ALTER TABLE "_ProductConstructeurs"
ADD CONSTRAINT "_ProductConstructeurs_A_fkey"
FOREIGN KEY ("A") REFERENCES "constructeurs"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "_ProductConstructeurs"
ADD CONSTRAINT "_ProductConstructeurs_B_fkey"
FOREIGN KEY ("B") REFERENCES "products"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
RAISE NOTICE 'Recreated foreign key constraints';
RAISE NOTICE 'Migration completed successfully';
END $$;

View File

@@ -12,7 +12,7 @@ const DEFAULT_ORIENTATIONS: Record<string, LinkOrientation> = {
_MachineConstructeurs: { parentColumn: 'A', constructeurColumn: 'B' },
_ComposantConstructeurs: { parentColumn: 'A', constructeurColumn: 'B' },
_PieceConstructeurs: { parentColumn: 'A', constructeurColumn: 'B' },
_ProductConstructeurs: { parentColumn: 'A', constructeurColumn: 'B' },
_ProductConstructeurs: { parentColumn: 'B', constructeurColumn: 'A' },
};
const sanitizeTableName = (tableName: string): string => {
@@ -168,6 +168,8 @@ export async function syncConstructeurLinks(
),
);
let targetConstructeurIds = uniqueConstructeurIds;
console.log('[syncConstructeurLinks] Table:', tableName, 'ParentId:', parentId);
console.log('[syncConstructeurLinks] Unique IDs:', uniqueConstructeurIds);
const constructeurDelegate = (executor as any)?.constructeur as
| {
@@ -183,13 +185,16 @@ export async function syncConstructeurLinks(
where: { id: { in: targetConstructeurIds } },
select: { id: true },
});
console.log('[syncConstructeurLinks] Existing constructeurs in DB:', existing.map(c => c.id));
const existingIds = new Set(existing.map(({ id }) => id));
targetConstructeurIds = targetConstructeurIds.filter((id) =>
existingIds.has(id),
);
console.log('[syncConstructeurLinks] Filtered target IDs:', targetConstructeurIds);
}
const orientation = await resolveOrientation(executor, tableName);
console.log('[syncConstructeurLinks] Orientation:', orientation);
if (typeof executor.__syncConstructeurLinks === 'function') {
await executor.__syncConstructeurLinks(
@@ -200,6 +205,7 @@ export async function syncConstructeurLinks(
return targetConstructeurIds;
}
console.log('[syncConstructeurLinks] Deleting old links...');
await prisma.$executeRaw(
Prisma.sql`DELETE FROM ${table} WHERE ${Prisma.raw(
`"${orientation.parentColumn}"`,
@@ -207,6 +213,7 @@ export async function syncConstructeurLinks(
);
if (targetConstructeurIds.length === 0) {
console.log('[syncConstructeurLinks] No IDs to insert, returning empty array');
return [];
}
@@ -214,6 +221,7 @@ export async function syncConstructeurLinks(
(constructeurId) => Prisma.sql`(${parentId}, ${constructeurId})`,
);
console.log('[syncConstructeurLinks] Inserting', targetConstructeurIds.length, 'new links...');
await prisma.$executeRaw(
Prisma.sql`
INSERT INTO ${table} (
@@ -224,7 +232,10 @@ export async function syncConstructeurLinks(
ON CONFLICT DO NOTHING
`,
);
return fetchConstructeurIds(executor, tableName, parentId, orientation);
console.log('[syncConstructeurLinks] Links inserted, fetching final IDs...');
const finalIds = await fetchConstructeurIds(executor, tableName, parentId, orientation);
console.log('[syncConstructeurLinks] Final fetched IDs:', finalIds);
return finalIds;
}
export async function fetchConstructeurIds(

View File

@@ -1,7 +1,10 @@
import { ConflictException, Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { syncConstructeurLinks } from '../common/utils/constructeur-link.util';
import {
fetchConstructeurIds,
syncConstructeurLinks,
} from '../common/utils/constructeur-link.util';
import { CreatePieceDto, UpdatePieceDto } from '../shared/dto/piece.dto';
import { PieceModelStructureSchema } from '../shared/schemas/inventory';
import type { PieceModelStructure } from '../shared/types/inventory';
@@ -120,30 +123,39 @@ export class PiecesService {
include: PIECE_WITH_RELATIONS_INCLUDE,
});
if (refreshed && syncedConstructeurIds.length > 0) {
(
refreshed as typeof refreshed & { constructeurIds?: string[] }
).constructeurIds = [...syncedConstructeurIds];
if (!refreshed) {
return null;
}
return refreshed;
const mapped = await this.mapPiece(refreshed);
if (syncedConstructeurIds.length > 0) {
mapped.constructeurIds = [...syncedConstructeurIds];
}
return mapped;
} catch (error) {
this.handlePrismaError(error);
}
}
async findAll() {
return this.prisma.piece.findMany({
const items = await this.prisma.piece.findMany({
include: PIECE_WITH_RELATIONS_INCLUDE,
orderBy: { name: 'asc' },
});
const hydrated = await Promise.all(items.map((piece) => this.mapPiece(piece)));
return hydrated;
}
async findOne(id: string) {
return this.prisma.piece.findUnique({
const piece = await this.prisma.piece.findUnique({
where: { id },
include: PIECE_WITH_RELATIONS_INCLUDE,
});
if (!piece) {
return null;
}
return this.mapPiece(piece);
}
async update(id: string, updatePieceDto: UpdatePieceDto) {
@@ -217,13 +229,16 @@ export class PiecesService {
include: PIECE_WITH_RELATIONS_INCLUDE,
});
if (refreshed && syncedConstructeurIds) {
(
refreshed as typeof refreshed & { constructeurIds?: string[] }
).constructeurIds = [...syncedConstructeurIds];
if (!refreshed) {
return null;
}
return refreshed;
const mapped = await this.mapPiece(refreshed);
if (syncedConstructeurIds) {
mapped.constructeurIds = [...syncedConstructeurIds];
}
return mapped;
} catch (error) {
this.handlePrismaError(error);
}
@@ -668,6 +683,43 @@ export class PiecesService {
}
return false;
}
private async mapPiece(piece: any) {
const idsFromConstructeurs = Array.isArray(piece.constructeurs)
? piece.constructeurs
.map((c) => (c && typeof c.id === 'string' ? c.id : null))
.filter((id): id is string => Boolean(id))
: [];
const idsFromPayload = Array.isArray(piece.constructeurIds)
? piece.constructeurIds
.map((value) => (typeof value === 'string' ? value.trim() : ''))
.filter((value) => value.length > 0)
: [];
let ids = Array.from(new Set([...idsFromConstructeurs, ...idsFromPayload]));
if (!ids.length) {
ids = await fetchConstructeurIds(
this.prisma,
'_PieceConstructeurs',
piece.id,
);
}
let constructeurs = piece.constructeurs;
if ((!constructeurs || !constructeurs.length) && ids.length) {
constructeurs = await this.prisma.constructeur.findMany({
where: { id: { in: ids } },
});
}
return {
...piece,
constructeurs,
constructeurIds: ids,
};
}
}
type PieceTypeWithSkeleton = Prisma.ModelTypeGetPayload<{

View File

@@ -124,22 +124,30 @@ export class ProductsService {
const constructeurIds = this.normalizeConstructeurIds(
createProductDto.constructeurIds,
);
console.log('[CREATE] Normalized constructeurIds:', constructeurIds);
const resolvedConstructeurIds =
await this.resolveExistingConstructeurIds(constructeurIds);
console.log('[CREATE] Resolved constructeurIds:', resolvedConstructeurIds);
const created = await this.prisma.product.create({
data,
include: PRODUCT_WITH_RELATIONS_INCLUDE,
});
console.log('[CREATE] Product created:', created.id);
let syncedConstructeurIds: string[] = [];
if (resolvedConstructeurIds.length > 0) {
console.log('[CREATE] Calling syncConstructeurLinks with:', resolvedConstructeurIds);
syncedConstructeurIds = await syncConstructeurLinks(
this.prisma,
'_ProductConstructeurs',
created.id,
resolvedConstructeurIds,
);
console.log('[CREATE] Synced constructeurIds:', syncedConstructeurIds);
} else {
console.log('[CREATE] No constructeurIds to sync');
}
const refreshed = await this.prisma.product.findUnique({
@@ -192,8 +200,10 @@ export class ProductsService {
const constructeurIds = this.normalizeConstructeurIds(
updateProductDto.constructeurIds,
);
console.log('[UPDATE] Normalized constructeurIds:', constructeurIds);
resolvedConstructeurIds =
await this.resolveExistingConstructeurIds(constructeurIds);
console.log('[UPDATE] Resolved constructeurIds:', resolvedConstructeurIds);
}
let syncedConstructeurIds: string[] | undefined;
@@ -203,14 +213,19 @@ export class ProductsService {
where: { id },
data,
});
console.log('[UPDATE] Product updated:', id);
if (resolvedConstructeurIds !== undefined) {
console.log('[UPDATE] Calling syncConstructeurLinks with:', resolvedConstructeurIds);
syncedConstructeurIds = await syncConstructeurLinks(
tx,
'_ProductConstructeurs',
id,
resolvedConstructeurIds,
);
console.log('[UPDATE] Synced constructeurIds:', syncedConstructeurIds);
} else {
console.log('[UPDATE] No constructeurIds to sync');
}
});
@@ -301,9 +316,57 @@ export class ProductsService {
}
private mapProduct(product: ProductWithRelations) {
const constructeurs = Array.isArray(product.constructeurs)
? product.constructeurs
.map((constructeur) => {
if (!constructeur || typeof constructeur !== 'object') {
return null;
}
const { id, name, email, phone, createdAt, updatedAt } =
constructeur as {
id?: string;
name?: string | null;
email?: string | null;
phone?: string | null;
createdAt?: Date;
updatedAt?: Date;
};
if (!id) {
return null;
}
return {
id,
name: name ?? null,
email: email ?? null,
phone: phone ?? null,
createdAt: createdAt ?? null,
updatedAt: updatedAt ?? null,
};
})
.filter(
(entry): entry is {
id: string;
name: string | null;
email: string | null;
phone: string | null;
createdAt: Date | null;
updatedAt: Date | null;
} => Boolean(entry),
)
: [];
const constructeurIds = constructeurs.map((item) => item.id);
const constructeursLabel = constructeurs
.map((item) => (item.name || '').trim())
.filter((name) => name.length > 0)
.join(', ');
return {
...product,
constructeurIds: product.constructeurs.map((item) => item.id),
constructeurs,
constructeurIds,
constructeursLabel: constructeursLabel || null,
};
}