diff --git a/src/common/utils/constructeur-link.util.ts b/src/common/utils/constructeur-link.util.ts new file mode 100644 index 0000000..7ca8f26 --- /dev/null +++ b/src/common/utils/constructeur-link.util.ts @@ -0,0 +1,248 @@ +import { Prisma } from '@prisma/client'; +import type { PrismaService } from '../../prisma/prisma.service'; + +type PrismaExecutor = Prisma.TransactionClient | PrismaService; + +type LinkOrientation = { + parentColumn: 'A' | 'B'; + constructeurColumn: 'A' | 'B'; +}; + +const DEFAULT_ORIENTATIONS: Record = { + _MachineConstructeurs: { parentColumn: 'A', constructeurColumn: 'B' }, + _ComposantConstructeurs: { parentColumn: 'A', constructeurColumn: 'B' }, + _PieceConstructeurs: { parentColumn: 'A', constructeurColumn: 'B' }, +}; + +const sanitizeTableName = (tableName: string): string => { + if (!/^_[A-Za-z0-9]+$/.test(tableName)) { + throw new Error(`Invalid constructeur link table name: ${tableName}`); + } + return `"${tableName}"`; +}; + +const ORIENTATION_CACHE = new Map(); +const KNOWN_PARENT_TABLES = new Set(['machines', 'composants', 'pieces']); +const oppositeColumn = (column: 'A' | 'B'): 'A' | 'B' => + column === 'A' ? 'B' : 'A'; + +async function resolveOrientation( + prisma: PrismaExecutor & { + __getConstructeurLinkOrientation?: ( + table: string, + ) => LinkOrientation | Promise; + }, + tableName: string, +): Promise { + const cached = ORIENTATION_CACHE.get(tableName); + if (cached) { + return cached; + } + + if (typeof prisma.__getConstructeurLinkOrientation === 'function') { + const orientation = await prisma.__getConstructeurLinkOrientation(tableName); + ORIENTATION_CACHE.set(tableName, orientation); + return orientation; + } + + const rows = await prisma.$queryRaw< + Array<{ column_name: string; foreign_table_name: string }> + >(Prisma.sql` + SELECT + kcu.column_name, + ccu.table_name AS foreign_table_name + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema + JOIN information_schema.constraint_column_usage ccu + ON tc.constraint_name = ccu.constraint_name + AND tc.table_schema = ccu.table_schema + WHERE tc.constraint_type = 'FOREIGN KEY' + AND tc.table_schema = 'public' + AND tc.table_name = ${tableName} + `); + + let parentColumn: 'A' | 'B' | null = null; + let constructeurColumn: 'A' | 'B' | null = null; + + for (const row of rows) { + const column = row.column_name?.toUpperCase?.() as 'A' | 'B' | undefined; + if (column !== 'A' && column !== 'B') { + continue; + } + + if (row.foreign_table_name === 'constructeurs') { + constructeurColumn = column; + continue; + } + + if (KNOWN_PARENT_TABLES.has(row.foreign_table_name)) { + parentColumn = column; + continue; + } + + if (!parentColumn) { + parentColumn = column; + } + } + + if (parentColumn && !constructeurColumn) { + constructeurColumn = oppositeColumn(parentColumn); + } else if (!parentColumn && constructeurColumn) { + parentColumn = oppositeColumn(constructeurColumn); + } + + if (!parentColumn || !constructeurColumn) { + const fallback = DEFAULT_ORIENTATIONS[tableName]; + if (fallback) { + parentColumn ??= fallback.parentColumn; + constructeurColumn ??= fallback.constructeurColumn; + } + } + + if (!parentColumn || !constructeurColumn) { + const columns = rows + .map( + (row) => + row.column_name?.toUpperCase?.() as 'A' | 'B' | undefined, + ) + .filter((column): column is 'A' | 'B' => column === 'A' || column === 'B'); + + if (columns.length === 2) { + if (!parentColumn) { + parentColumn = columns[0]; + } + if (!constructeurColumn) { + const alternative = columns.find((column) => column !== parentColumn); + if (alternative) { + constructeurColumn = alternative; + } + } + } + } + + if (!parentColumn || !constructeurColumn) { + throw new Error( + `Impossible de déterminer l'orientation de la table ${tableName}.`, + ); + } + + const orientation: LinkOrientation = { + parentColumn, + constructeurColumn, + }; + ORIENTATION_CACHE.set(tableName, orientation); + return orientation; +} + +export async function syncConstructeurLinks( + prisma: PrismaExecutor, + tableName: string, + parentId: string, + constructeurIds: string[], +): Promise { + const executor = prisma as PrismaExecutor & { + __syncConstructeurLinks?: ( + table: string, + parent: string, + ids: string[], + ) => Promise | void; + __getConstructeurLinkOrientation?: ( + table: string, + ) => LinkOrientation | Promise; + }; + + const table = Prisma.raw(sanitizeTableName(tableName)); + const uniqueConstructeurIds = Array.from( + new Set( + constructeurIds + .map((value) => (typeof value === 'string' ? value.trim() : '')) + .filter((value) => value.length > 0), + ), + ); + let targetConstructeurIds = uniqueConstructeurIds; + + const constructeurDelegate = (executor as any)?.constructeur as + | { + findMany?: (args: { + where: { id: { in: string[] } }; + select: { id: boolean }; + }) => Promise>; + } + | undefined; + + if (targetConstructeurIds.length > 0 && constructeurDelegate?.findMany) { + const existing = await constructeurDelegate.findMany({ + where: { id: { in: targetConstructeurIds } }, + select: { id: true }, + }); + const existingIds = new Set(existing.map(({ id }) => id)); + targetConstructeurIds = targetConstructeurIds.filter((id) => + existingIds.has(id), + ); + } + + const orientation = await resolveOrientation(executor, tableName); + + if (typeof executor.__syncConstructeurLinks === 'function') { + await executor.__syncConstructeurLinks( + tableName, + parentId, + targetConstructeurIds, + ); + return targetConstructeurIds; + } + + await prisma.$executeRaw( + Prisma.sql`DELETE FROM ${table} WHERE ${Prisma.raw( + `"${orientation.parentColumn}"`, + )} = ${parentId}`, + ); + + if (targetConstructeurIds.length === 0) { + return []; + } + + const valueTuples = targetConstructeurIds.map((constructeurId) => + Prisma.sql`(${parentId}, ${constructeurId})`, + ); + + await prisma.$executeRaw( + Prisma.sql` + INSERT INTO ${table} ( + ${Prisma.raw(`"${orientation.parentColumn}"`)}, + ${Prisma.raw(`"${orientation.constructeurColumn}"`)} + ) + VALUES ${Prisma.join(valueTuples)} + ON CONFLICT DO NOTHING + `, + ); + return fetchConstructeurIds(executor, tableName, parentId, orientation); +} + +export async function fetchConstructeurIds( + prisma: PrismaExecutor, + tableName: string, + parentId: string, + orientationOverride?: LinkOrientation, +): Promise { + const orientation = + orientationOverride ?? (await resolveOrientation(prisma as any, tableName)); + const table = Prisma.raw(sanitizeTableName(tableName)); + const rows = await prisma.$queryRaw>( + Prisma.sql` + SELECT ${Prisma.raw( + `"${orientation.constructeurColumn}"`, + )} AS "constructeurId" + FROM ${table} + WHERE ${Prisma.raw(`"${orientation.parentColumn}"`)} = ${parentId} + `, + ); + + return rows + .map((row) => + typeof row.constructeurId === 'string' ? row.constructeurId : null, + ) + .filter((id): id is string => Boolean(id)); +} diff --git a/src/composants/composants.service.ts b/src/composants/composants.service.ts index faf8f01..d55d407 100644 --- a/src/composants/composants.service.ts +++ b/src/composants/composants.service.ts @@ -9,6 +9,7 @@ import { COMPONENT_WITH_RELATIONS_INCLUDE, ComposantWithRelations, } from '../common/constants/component-includes'; +import { syncConstructeurLinks } from '../common/utils/constructeur-link.util'; @Injectable() export class ComposantsService { @@ -16,7 +17,10 @@ export class ComposantsService { private async buildCreateInput( createComposantDto: CreateComposantDto, - ): Promise { + ): Promise<{ + data: Prisma.ComposantCreateInput; + constructeurIds: string[]; + }> { const data: Prisma.ComposantCreateInput = { name: createComposantDto.name, reference: createComposantDto.reference ?? null, @@ -29,11 +33,6 @@ export class ComposantsService { ); const resolvedConstructeurIds = await this.resolveExistingConstructeurIds(constructeurIds); - if (resolvedConstructeurIds.length) { - data.constructeurs = { - connect: resolvedConstructeurIds.map((id) => ({ id })), - }; - } if (createComposantDto.typeComposantId) { data.typeComposant = { @@ -45,17 +44,41 @@ export class ComposantsService { data.structure = createComposantDto.structure as Prisma.InputJsonValue; } - return data; + return { data, constructeurIds: resolvedConstructeurIds }; } async create(createComposantDto: CreateComposantDto) { try { + const { data, constructeurIds } = await this.buildCreateInput( + createComposantDto, + ); const created = await this.prisma.composant.create({ - data: await this.buildCreateInput(createComposantDto), + data, include: COMPONENT_WITH_RELATIONS_INCLUDE, }); - return created as ComposantWithRelations; + let syncedConstructeurIds: string[] = []; + if (constructeurIds.length > 0) { + syncedConstructeurIds = await syncConstructeurLinks( + this.prisma, + '_ComposantConstructeurs', + created.id, + constructeurIds, + ); + } + + const refreshed = (await this.prisma.composant.findUnique({ + where: { id: created.id }, + include: COMPONENT_WITH_RELATIONS_INCLUDE, + })) as ComposantWithRelations | null; + + if (refreshed && syncedConstructeurIds.length > 0) { + (refreshed as ComposantWithRelations & { + constructeurIds?: string[]; + }).constructeurIds = [...syncedConstructeurIds]; + } + + return refreshed; } catch (error) { this.handlePrismaError(error); } @@ -90,15 +113,14 @@ export class ComposantsService { data.prix = updateComposantDto.prix; } + let resolvedConstructeurIds: string[] | undefined; if (updateComposantDto.constructeurIds !== undefined) { const constructeurIds = this.normalizeConstructeurIds( updateComposantDto.constructeurIds, ); - const resolvedConstructeurIds = - await this.resolveExistingConstructeurIds(constructeurIds); - data.constructeurs = { - set: resolvedConstructeurIds.map((id) => ({ id })), - }; + resolvedConstructeurIds = await this.resolveExistingConstructeurIds( + constructeurIds, + ); } if (updateComposantDto.typeComposantId !== undefined) { @@ -111,12 +133,36 @@ export class ComposantsService { data.structure = updateComposantDto.structure as Prisma.InputJsonValue; } + let syncedConstructeurIds: string[] | undefined; try { - return (await this.prisma.composant.update({ + await this.prisma.$transaction(async (tx) => { + await tx.composant.update({ + where: { id }, + data, + }); + + if (resolvedConstructeurIds !== undefined) { + syncedConstructeurIds = await syncConstructeurLinks( + tx, + '_ComposantConstructeurs', + id, + resolvedConstructeurIds, + ); + } + }); + + const refreshed = (await this.prisma.composant.findUnique({ where: { id }, - data, include: COMPONENT_WITH_RELATIONS_INCLUDE, - })) as ComposantWithRelations; + })) as ComposantWithRelations | null; + + if (refreshed && syncedConstructeurIds) { + (refreshed as ComposantWithRelations & { + constructeurIds?: string[]; + }).constructeurIds = [...syncedConstructeurIds]; + } + + return refreshed; } catch (error) { this.handlePrismaError(error); } diff --git a/src/machines/machines.service.ts b/src/machines/machines.service.ts index 5044bc1..c157afa 100644 --- a/src/machines/machines.service.ts +++ b/src/machines/machines.service.ts @@ -10,6 +10,10 @@ import { MachinePieceLinkInput, } from '../shared/dto/machine.dto'; import { buildComponentHierarchy } from '../common/utils/component-tree.util'; +import { + fetchConstructeurIds, + syncConstructeurLinks, +} from '../common/utils/constructeur-link.util'; const CUSTOM_FIELD_SELECT = { id: true, @@ -402,12 +406,18 @@ export class MachinesService { | (MachineWithRelations & { componentLinks: HydratedComponentLink[]; pieceLinks: HydratedPieceLink[]; + constructeurIds: string[]; + constructeurs: MachineWithRelations['constructeurs']; }) | null { if (!machine) { return machine; } + const resolvedConstructeurs = Array.isArray(machine.constructeurs) + ? machine.constructeurs + : []; + const componentLinks = this.hydrateComponentLinks( machine.componentLinks ?? [], ); @@ -419,10 +429,18 @@ export class MachinesService { const hydratedMachine = machine as MachineWithRelations & { componentLinks: HydratedComponentLink[]; pieceLinks: HydratedPieceLink[]; + constructeurIds: string[]; + constructeurs: MachineWithRelations['constructeurs']; }; hydratedMachine.componentLinks = componentLinks; hydratedMachine.pieceLinks = rootPieceLinks; + hydratedMachine.constructeurIds = resolvedConstructeurs + .map((constructeur) => + typeof constructeur?.id === 'string' ? constructeur.id : null, + ) + .filter((id): id is string => Boolean(id)); + hydratedMachine.constructeurs = resolvedConstructeurs; return hydratedMachine; } @@ -432,10 +450,85 @@ export class MachinesService { ): (MachineWithRelations & { componentLinks: HydratedComponentLink[]; pieceLinks: HydratedPieceLink[]; + constructeurIds: string[]; + constructeurs: MachineWithRelations['constructeurs']; })[] { return machines.map((machine) => this.hydrateMachine(machine)!); } + private async ensureMachineConstructeurs< + T extends + | (MachineWithRelations & { + componentLinks: HydratedComponentLink[]; + pieceLinks: HydratedPieceLink[]; + constructeurIds: string[]; + constructeurs: MachineWithRelations['constructeurs']; + }) + | null, + >(machine: T): Promise { + if (!machine) { + return machine; + } + + const existingConstructeurs = Array.isArray(machine.constructeurs) + ? machine.constructeurs + : []; + + const idsFromConstructeurs = existingConstructeurs + .map((constructeur) => + typeof constructeur?.id === 'string' ? constructeur.id : null, + ) + .filter((id): id is string => Boolean(id)); + + const initialIds = + Array.isArray(machine.constructeurIds) && machine.constructeurIds.length > 0 + ? machine.constructeurIds + : idsFromConstructeurs; + + let resolvedIds = initialIds; + if (resolvedIds.length === 0) { + resolvedIds = await fetchConstructeurIds( + this.prisma, + '_MachineConstructeurs', + machine.id, + ); + } + + machine.constructeurIds = [...resolvedIds]; + + if ( + existingConstructeurs.length > 0 && + resolvedIds.length === existingConstructeurs.length + ) { + return machine; + } + + if (resolvedIds.length === 0) { + machine.constructeurs = []; + return machine; + } + + const constructeurs = await this.prisma.constructeur.findMany({ + where: { id: { in: resolvedIds } }, + }); + const byId = new Map(constructeurs.map((item) => [item.id, item])); + + const orderedConstructeurs = resolvedIds + .map((id) => byId.get(id)) + .filter( + ( + record, + ): record is (typeof constructeurs)[number] => + Boolean(record), + ); + + machine.constructeurs = + orderedConstructeurs as MachineWithRelations['constructeurs']; + + return machine; + } + + private slugifyName(name: string): string { return name .normalize('NFD') @@ -1744,36 +1837,27 @@ export class MachinesService { const { componentRequirementMap, pieceRequirementMap } = this.buildConfigurationContext(typeMachine, componentLinks, pieceLinks); - let machine: Awaited>; - try { - const createData: any = { - ...machineData, - }; + const baseMachine = await this.prisma.machine.create({ + data: machineData, + include: { + site: true, + typeMachine: true, + constructeurs: true, + }, + }); - if (resolvedConstructeurIds.length) { - createData.constructeurs = { - connect: resolvedConstructeurIds.map((id) => ({ id })), - }; - } - - machine = await this.prisma.machine.create({ - data: createData, - include: { - site: true, - typeMachine: true, - constructeurs: true, - }, - }); - } catch (error) { - this.handlePrismaError(error); - return; - } + const syncedConstructeurIds = await syncConstructeurLinks( + this.prisma, + '_MachineConstructeurs', + baseMachine.id, + resolvedConstructeurIds, + ); try { if (typeMachine.customFields && typeMachine.customFields.length > 0) { await this.createMachineCustomFieldsFromType( this.prisma, - machine.id, + baseMachine.id, typeMachine.customFields, typeMachine.id, ); @@ -1781,39 +1865,48 @@ export class MachinesService { const componentIndex = await this.createComponentLinksForMachine( this.prisma, - machine.id, + baseMachine.id, componentRequirementMap, componentLinks, ); await this.createPieceLinksForMachine( this.prisma, - machine.id, + baseMachine.id, pieceRequirementMap, pieceLinks, componentIndex, ); } catch (error) { await this.prisma.machine - .delete({ where: { id: machine.id } }) + .delete({ where: { id: baseMachine.id } }) .catch(() => undefined); throw error; } const createdMachine = await this.prisma.machine.findUnique({ - where: { id: machine.id }, + where: { id: baseMachine.id }, include: MACHINE_DEFAULT_INCLUDE, }); - return this.hydrateMachine(createdMachine); + const hydrated = this.hydrateMachine(createdMachine); + if (hydrated && syncedConstructeurIds.length > 0) { + hydrated.constructeurIds = [...syncedConstructeurIds]; + } + return this.ensureMachineConstructeurs(hydrated); } async findAll() { const machines = await this.prisma.machine.findMany({ include: MACHINE_DEFAULT_INCLUDE, }); - - return this.hydrateMachines(machines); + const hydrated = this.hydrateMachines(machines); + const enriched = await Promise.all( + hydrated.map((machine) => this.ensureMachineConstructeurs(machine)), + ); + return enriched.filter( + (machine): machine is NonNullable => Boolean(machine), + ); } async findOne(id: string) { @@ -1821,8 +1914,8 @@ export class MachinesService { where: { id }, include: MACHINE_DEFAULT_INCLUDE, }); - - return this.hydrateMachine(machine); + const hydrated = this.hydrateMachine(machine); + return this.ensureMachineConstructeurs(hydrated); } async reconfigure(id: string, reconfigureMachineDto: ReconfigureMachineDto) { @@ -1877,7 +1970,8 @@ export class MachinesService { include: MACHINE_DEFAULT_INCLUDE, }); - return this.hydrateMachine(updatedMachine); + const hydrated = this.hydrateMachine(updatedMachine); + return this.ensureMachineConstructeurs(hydrated); } async update(id: string, updateMachineDto: UpdateMachineDto) { @@ -1894,15 +1988,13 @@ export class MachinesService { data.reference = reference; } + let resolvedConstructeurIds: string[] | undefined; if (constructeurIds !== undefined) { const normalizedConstructeurIds = this.normalizeConstructeurIds(constructeurIds); - const resolvedConstructeurIds = await this.resolveExistingConstructeurIds( + resolvedConstructeurIds = await this.resolveExistingConstructeurIds( normalizedConstructeurIds, ); - data.constructeurs = { - set: resolvedConstructeurIds.map((id) => ({ id })), - }; } if (prix !== undefined) { @@ -1921,14 +2013,34 @@ export class MachinesService { : { disconnect: true }; } + let syncedConstructeurIds: string[] | undefined; try { - const machine = await this.prisma.machine.update({ + await this.prisma.$transaction(async (tx) => { + await tx.machine.update({ + where: { id }, + data, + }); + + if (resolvedConstructeurIds !== undefined) { + syncedConstructeurIds = await syncConstructeurLinks( + tx, + '_MachineConstructeurs', + id, + resolvedConstructeurIds, + ); + } + }); + + const refreshedMachine = await this.prisma.machine.findUnique({ where: { id }, - data, include: MACHINE_DEFAULT_INCLUDE, }); - return this.hydrateMachine(machine); + const hydrated = this.hydrateMachine(refreshedMachine); + if (hydrated && syncedConstructeurIds) { + hydrated.constructeurIds = [...syncedConstructeurIds]; + } + return this.ensureMachineConstructeurs(hydrated); } catch (error) { this.handlePrismaError(error); } diff --git a/src/pieces/pieces.service.ts b/src/pieces/pieces.service.ts index 02e120d..d3ef833 100644 --- a/src/pieces/pieces.service.ts +++ b/src/pieces/pieces.service.ts @@ -1,6 +1,7 @@ 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 { CreatePieceDto, UpdatePieceDto } from '../shared/dto/piece.dto'; import { PieceModelStructureSchema } from '../shared/schemas/inventory'; import type { PieceModelStructure } from '../shared/types/inventory'; @@ -35,7 +36,7 @@ export class PiecesService { private async buildCreateInput( createPieceDto: CreatePieceDto, - ): Promise { + ): Promise<{ data: Prisma.PieceCreateInput; constructeurIds: string[] }> { const data: Prisma.PieceCreateInput = { name: createPieceDto.name, reference: createPieceDto.reference ?? null, @@ -47,11 +48,6 @@ export class PiecesService { ); const resolvedConstructeurIds = await this.resolveExistingConstructeurIds(constructeurIds); - if (resolvedConstructeurIds.length) { - data.constructeurs = { - connect: resolvedConstructeurIds.map((id) => ({ id })), - }; - } if (createPieceDto.typePieceId) { data.typePiece = { @@ -59,25 +55,46 @@ export class PiecesService { }; } - return data; + return { data, constructeurIds: resolvedConstructeurIds }; } async create(createPieceDto: CreatePieceDto) { try { + const { data, constructeurIds } = await this.buildCreateInput( + createPieceDto, + ); const created = await this.prisma.piece.create({ - data: await this.buildCreateInput(createPieceDto), + data, include: PIECE_WITH_RELATIONS_INCLUDE, }); + let syncedConstructeurIds: string[] = []; + if (constructeurIds.length > 0) { + syncedConstructeurIds = await syncConstructeurLinks( + this.prisma, + '_PieceConstructeurs', + created.id, + constructeurIds, + ); + } + await this.applyPieceSkeleton({ pieceId: created.id, typePiece: created.typePiece as PieceTypeWithSkeleton | null, + prisma: this.prisma, }); - return this.prisma.piece.findUnique({ + const refreshed = await this.prisma.piece.findUnique({ where: { id: created.id }, include: PIECE_WITH_RELATIONS_INCLUDE, }); + + if (refreshed && syncedConstructeurIds.length > 0) { + (refreshed as typeof refreshed & { constructeurIds?: string[] }).constructeurIds = + [...syncedConstructeurIds]; + } + + return refreshed; } catch (error) { this.handlePrismaError(error); } @@ -112,15 +129,14 @@ export class PiecesService { data.prix = updatePieceDto.prix; } + let resolvedConstructeurIds: string[] | undefined; if (updatePieceDto.constructeurIds !== undefined) { const constructeurIds = this.normalizeConstructeurIds( updatePieceDto.constructeurIds, ); - const resolvedConstructeurIds = - await this.resolveExistingConstructeurIds(constructeurIds); - data.constructeurs = { - set: resolvedConstructeurIds.map((id) => ({ id })), - }; + resolvedConstructeurIds = await this.resolveExistingConstructeurIds( + constructeurIds, + ); } if (updatePieceDto.typePieceId !== undefined) { @@ -129,22 +145,42 @@ export class PiecesService { : { disconnect: true }; } + let syncedConstructeurIds: string[] | undefined; try { - const updated = await this.prisma.piece.update({ + await this.prisma.$transaction(async (tx) => { + const updated = await tx.piece.update({ + where: { id }, + data, + include: PIECE_WITH_RELATIONS_INCLUDE, + }); + + if (resolvedConstructeurIds !== undefined) { + syncedConstructeurIds = await syncConstructeurLinks( + tx, + '_PieceConstructeurs', + id, + resolvedConstructeurIds, + ); + } + + await this.applyPieceSkeleton({ + pieceId: updated.id, + typePiece: updated.typePiece as PieceTypeWithSkeleton | null, + prisma: tx, + }); + }); + + const refreshed = await this.prisma.piece.findUnique({ where: { id }, - data, include: PIECE_WITH_RELATIONS_INCLUDE, }); - await this.applyPieceSkeleton({ - pieceId: updated.id, - typePiece: updated.typePiece as PieceTypeWithSkeleton | null, - }); + if (refreshed && syncedConstructeurIds) { + (refreshed as typeof refreshed & { constructeurIds?: string[] }).constructeurIds = + [...syncedConstructeurIds]; + } - return this.prisma.piece.findUnique({ - where: { id: updated.id }, - include: PIECE_WITH_RELATIONS_INCLUDE, - }); + return refreshed; } catch (error) { this.handlePrismaError(error); } @@ -211,9 +247,11 @@ export class PiecesService { private async applyPieceSkeleton({ pieceId, typePiece, + prisma, }: { pieceId: string; typePiece: PieceTypeWithSkeleton | null; + prisma: Prisma.TransactionClient | PrismaService; }) { if (!typePiece?.id) { return; @@ -230,8 +268,13 @@ export class PiecesService { const customFields = skeleton.customFields ?? []; - await this.ensurePieceCustomFieldDefinitions(typePiece.id, customFields); + await this.ensurePieceCustomFieldDefinitions( + prisma, + typePiece.id, + customFields, + ); await this.createPieceCustomFieldValues( + prisma, pieceId, typePiece.id, customFields, @@ -275,6 +318,7 @@ export class PiecesService { } private async ensurePieceCustomFieldDefinitions( + prisma: Prisma.TransactionClient | PrismaService, typePieceId: string, customFields: PieceModelStructure['customFields'], ) { @@ -286,7 +330,7 @@ export class PiecesService { return; } - const existing = await this.prisma.customField.findMany({ + const existing = await prisma.customField.findMany({ where: { typePieceId }, select: { id: true, name: true, orderIndex: true }, }); @@ -312,7 +356,7 @@ export class PiecesService { const existingField = existingByName.get(name); if (existingField) { if (existingField.orderIndex !== index) { - await this.prisma.customField.update({ + await prisma.customField.update({ where: { id: existingField.id }, data: { orderIndex: index }, }); @@ -324,7 +368,7 @@ export class PiecesService { const required = Boolean(field.required); const options = this.normalizeOptions(field); - const created = await this.prisma.customField.create({ + const created = await prisma.customField.create({ data: { name, type, @@ -341,6 +385,7 @@ export class PiecesService { } private async createPieceCustomFieldValues( + prisma: Prisma.TransactionClient | PrismaService, pieceId: string, typePieceId: string, customFields: PieceModelStructure['customFields'], @@ -353,7 +398,7 @@ export class PiecesService { return; } - const definitions = await this.prisma.customField.findMany({ + const definitions = await prisma.customField.findMany({ where: { typePieceId }, select: { id: true, name: true }, }); @@ -369,7 +414,7 @@ export class PiecesService { ]), ); - const existingValues = await this.prisma.customFieldValue.findMany({ + const existingValues = await prisma.customFieldValue.findMany({ where: { pieceId }, select: { customFieldId: true }, }); @@ -393,7 +438,7 @@ export class PiecesService { continue; } - await this.prisma.customFieldValue.create({ + await prisma.customFieldValue.create({ data: { customFieldId: definitionId, pieceId, diff --git a/src/shared/types/constructeur-summary.type.ts b/src/shared/types/constructeur-summary.type.ts new file mode 100644 index 0000000..10a16a0 --- /dev/null +++ b/src/shared/types/constructeur-summary.type.ts @@ -0,0 +1,6 @@ +export type ConstructeurSummary = { + id: string; + name: string | null; + email: string | null; + phone: string | null; +}; diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index 2d1ba4f..847ff8f 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -109,6 +109,15 @@ type PieceRecord = { updatedAt: Date; }; +type ConstructeurRecord = { + id: string; + name: string; + email: Nullable; + phone: Nullable; + createdAt: Date; + updatedAt: Date; +}; + type MachineComponentLinkRecord = { id: string; machineId: string; @@ -202,6 +211,15 @@ class InMemoryPrismaService { private customFields: CustomFieldRecord[] = []; private customFieldValues: CustomFieldValueRecord[] = []; private profiles: ProfileRecord[] = []; + private constructeurs: ConstructeurRecord[] = []; + private readonly constructeurLinkOrientation: Record< + string, + { parentColumn: 'A' | 'B'; constructeurColumn: 'A' | 'B' } + > = { + _MachineConstructeurs: { parentColumn: 'A', constructeurColumn: 'B' }, + _ComposantConstructeurs: { parentColumn: 'A', constructeurColumn: 'B' }, + _PieceConstructeurs: { parentColumn: 'A', constructeurColumn: 'B' }, + }; async onModuleInit() {} async onModuleDestroy() {} @@ -229,6 +247,7 @@ class InMemoryPrismaService { this.customFields = []; this.customFieldValues = []; this.profiles = []; + this.constructeurs = []; } private readonly modelTypeDelegate = { @@ -984,6 +1003,120 @@ class InMemoryPrismaService { }, }; + constructeur = { + findMany: async ({ where, select, orderBy }: any = {}) => { + let records = [...this.constructeurs]; + + if (where?.id?.in) { + const ids = Array.isArray(where.id.in) ? where.id.in : []; + records = records.filter((item) => ids.includes(item.id)); + } + + if (where?.OR && Array.isArray(where.OR)) { + const termCandidate = where.OR.find( + (clause: any) => clause?.name?.contains, + ); + const term = + termCandidate?.name?.contains ?? + termCandidate?.email?.contains ?? + termCandidate?.phone?.contains ?? + ''; + const normalized = term.toLowerCase().trim(); + if (normalized) { + records = records.filter((record) => { + const nameMatch = record.name.toLowerCase().includes(normalized); + const emailMatch = + record.email?.toLowerCase().includes(normalized) ?? false; + const phoneMatch = + record.phone?.toLowerCase().includes(normalized) ?? false; + return nameMatch || emailMatch || phoneMatch; + }); + } + } + + if (orderBy?.name) { + records = [...records].sort((a, b) => + orderBy.name === 'desc' + ? b.name.localeCompare(a.name) + : a.name.localeCompare(b.name), + ); + } + + return records.map((record) => this.applySelect(record, select)); + }, + + findUnique: async ({ where, select }: any) => { + const record = this.constructeurs.find((item) => item.id === where?.id); + return record ? this.applySelect(record, select) : null; + }, + + findFirst: async ({ where, select }: any = {}) => { + if (where?.name?.equals) { + const target = where.name.equals.toLowerCase(); + const record = this.constructeurs.find( + (item) => item.name.toLowerCase() === target, + ); + return record ? this.applySelect(record, select) : null; + } + return null; + }, + + create: async ({ data, select }: any) => { + const now = new Date(); + const record: ConstructeurRecord = { + id: data.id ?? generateId('constructeur'), + name: data.name?.trim?.() ?? '', + email: data.email?.trim?.() ?? null, + phone: data.phone?.trim?.() ?? null, + createdAt: now, + updatedAt: now, + }; + this.constructeurs.push(record); + return this.applySelect(record, select); + }, + + update: async ({ where, data, select }: any) => { + const record = this.constructeurs.find((item) => item.id === where?.id); + if (!record) { + throw new Error('Constructeur not found'); + } + if (data.name !== undefined) { + record.name = + this.applyUpdateValue(data.name)?.trim?.() ?? record.name; + } + if (data.email !== undefined) { + record.email = + this.applyUpdateValue(data.email)?.trim?.() ?? null; + } + if (data.phone !== undefined) { + record.phone = + this.applyUpdateValue(data.phone)?.trim?.() ?? null; + } + record.updatedAt = new Date(); + return this.applySelect(record, select); + }, + + delete: async ({ where, select }: any) => { + const index = this.constructeurs.findIndex( + (item) => item.id === where?.id, + ); + if (index === -1) { + throw new Error('Constructeur not found'); + } + const [deleted] = this.constructeurs.splice(index, 1); + return this.applySelect(deleted, select); + }, + }; + + async __getConstructeurLinkOrientation(table: string) { + return ( + this.constructeurLinkOrientation[table] ?? { + parentColumn: 'A', + constructeurColumn: 'B', + } + ); + } + profile = { count: async ({ where }: any) => { return this.profiles.filter((profile) => { @@ -1315,6 +1448,40 @@ class InMemoryPrismaService { .map((item) => ({ ...item })); } + async __syncConstructeurLinks( + table: string, + parentId: string, + ids: string[], + ): Promise { + const filtered = ids.filter((id) => + this.constructeurs.some((item) => item.id === id), + ); + + switch (table) { + case '_MachineConstructeurs': + this.assignConstructeurs(this.machines, parentId, filtered); + break; + case '_ComposantConstructeurs': + this.assignConstructeurs(this.composants, parentId, filtered); + break; + case '_PieceConstructeurs': + this.assignConstructeurs(this.pieces, parentId, filtered); + break; + default: + throw new Error(`Unsupported constructeur link table: ${table}`); + } + } + + private assignConstructeurs< + T extends { id: string; constructeurIds: string[] }, + >(collection: T[], parentId: string, ids: string[]) { + const record = collection.find((item) => item.id === parentId); + if (!record) { + return; + } + record.constructeurIds = [...ids]; + } + private buildMachine(machine: MachineRecord, include: any) { const base: any = { ...machine };