import { ConflictException, Injectable } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; 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'; const PIECE_WITH_RELATIONS_INCLUDE = { typePiece: { include: { pieceCustomFields: { orderBy: { orderIndex: 'asc' }, }, }, }, constructeurs: true, documents: true, customFieldValues: { include: { customField: true, }, }, product: { include: { typeProduct: true, constructeurs: true, customFieldValues: { include: { customField: true, }, }, documents: true, }, }, machineLinks: { include: { machine: true, typeMachinePieceRequirement: true, parentLink: true, }, }, } as const; @Injectable() export class PiecesService { constructor(private prisma: PrismaService) {} private async buildCreateInput( createPieceDto: CreatePieceDto, ): Promise<{ data: Prisma.PieceCreateInput; constructeurIds: string[] }> { const data: Prisma.PieceCreateInput = { name: createPieceDto.name, reference: createPieceDto.reference ?? null, prix: createPieceDto.prix !== undefined ? createPieceDto.prix : null, }; const constructeurIds = this.normalizeConstructeurIds( createPieceDto.constructeurIds, ); const resolvedConstructeurIds = await this.resolveExistingConstructeurIds(constructeurIds); if (createPieceDto.typePieceId) { data.typePiece = { connect: { id: createPieceDto.typePieceId }, }; } if (createPieceDto.productId) { const normalizedProductId = createPieceDto.productId.trim(); if (normalizedProductId) { data.product = { connect: { id: normalizedProductId }, }; } } return { data, constructeurIds: resolvedConstructeurIds }; } async create(createPieceDto: CreatePieceDto) { try { const { data, constructeurIds } = await this.buildCreateInput(createPieceDto); const { pieceId, syncedConstructeurIds } = await this.prisma.$transaction( async (tx) => { const created = await tx.piece.create({ data, include: PIECE_WITH_RELATIONS_INCLUDE, }); let synced: string[] = []; if (constructeurIds.length > 0) { synced = await syncConstructeurLinks( tx, '_PieceConstructeurs', created.id, constructeurIds, ); } await this.applyPieceSkeleton({ pieceId: created.id, typePiece: created.typePiece as PieceTypeWithSkeleton | null, product: created.product, prisma: tx, }); return { pieceId: created.id, syncedConstructeurIds: synced, }; }, ); const refreshed = await this.prisma.piece.findUnique({ where: { id: pieceId }, include: PIECE_WITH_RELATIONS_INCLUDE, }); if (!refreshed) { return null; } const mapped = await this.mapPiece(refreshed); if (syncedConstructeurIds.length > 0) { mapped.constructeurIds = [...syncedConstructeurIds]; } return mapped; } catch (error) { this.handlePrismaError(error); } } async findAll() { 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) { 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) { const data: Prisma.PieceUpdateInput = {}; if (updatePieceDto.name !== undefined) { data.name = updatePieceDto.name; } if (updatePieceDto.reference !== undefined) { data.reference = updatePieceDto.reference; } if (updatePieceDto.prix !== undefined) { data.prix = updatePieceDto.prix; } let resolvedConstructeurIds: string[] | undefined; if (updatePieceDto.constructeurIds !== undefined) { const constructeurIds = this.normalizeConstructeurIds( updatePieceDto.constructeurIds, ); resolvedConstructeurIds = await this.resolveExistingConstructeurIds(constructeurIds); } if (updatePieceDto.typePieceId !== undefined) { data.typePiece = updatePieceDto.typePieceId ? { connect: { id: updatePieceDto.typePieceId } } : { disconnect: true }; } if (updatePieceDto.productId !== undefined) { const normalizedProductId = typeof updatePieceDto.productId === 'string' ? updatePieceDto.productId.trim() : null; data.product = normalizedProductId ? { connect: { id: normalizedProductId } } : { disconnect: true }; } let syncedConstructeurIds: string[] | undefined; try { 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, product: updated.product, prisma: tx, }); }); const refreshed = await this.prisma.piece.findUnique({ where: { id }, include: PIECE_WITH_RELATIONS_INCLUDE, }); if (!refreshed) { return null; } const mapped = await this.mapPiece(refreshed); if (syncedConstructeurIds) { mapped.constructeurIds = [...syncedConstructeurIds]; } return mapped; } catch (error) { this.handlePrismaError(error); } } async remove(id: string) { const [machineLinksCount, documentsCount, customFieldValuesCount] = await Promise.all([ this.prisma.machinePieceLink.count({ where: { pieceId: id }, }), this.prisma.document.count({ where: { pieceId: id }, }), this.prisma.customFieldValue.count({ where: { pieceId: id }, }), ]); const blockingReasons: string[] = []; if (machineLinksCount > 0) { blockingReasons.push( `${machineLinksCount} liaison${machineLinksCount > 1 ? 's' : ''} machine`, ); } if (documentsCount > 0) { blockingReasons.push( `${documentsCount} document${documentsCount > 1 ? 's' : ''}`, ); } if (blockingReasons.length > 0) { const messageParts = [ `Impossible de supprimer cette pièce car elle possède encore: ${blockingReasons.join( ', ', )}.`, ]; if (customFieldValuesCount > 0) { messageParts.push( `Les ${customFieldValuesCount} valeur${ customFieldValuesCount > 1 ? 's' : '' } de champ personnalisé seront supprimées automatiquement une fois ces éléments détachés.`, ); } throw new ConflictException( `${messageParts.join(' ')} Supprimez ou détachez les éléments indiqués avant de réessayer.`, ); } if (customFieldValuesCount > 0) { await this.prisma.customFieldValue.deleteMany({ where: { pieceId: id }, }); } return this.prisma.piece.delete({ where: { id }, }); } private async applyPieceSkeleton({ pieceId, typePiece, product, prisma, }: { pieceId: string; typePiece: PieceTypeWithSkeleton | null; product: { typeProductId: string | null; typeProduct?: { code: string | null } | null; } | null; prisma: Prisma.TransactionClient | PrismaService; }) { if (!typePiece?.id) { return; } const skeleton = this.parsePieceSkeleton( (typePiece as { pieceSkeleton?: Prisma.JsonValue | null } | null) ?.pieceSkeleton, ); if (!skeleton) { return; } const customFields = skeleton.customFields ?? []; const productRequirements: PieceProductRequirement[] = Array.isArray( skeleton.products, ) ? skeleton.products.filter( (entry): entry is PieceProductRequirement => !!entry, ) : []; await this.ensurePieceCustomFieldDefinitions( prisma, typePiece.id, customFields, ); await this.createPieceCustomFieldValues( prisma, pieceId, typePiece.id, customFields, ); if (productRequirements.length > 0) { await this.ensurePieceProductCompliance({ prisma, pieceId, product, requirements: productRequirements, }); } } private async ensurePieceProductCompliance({ prisma, pieceId, product, requirements, }: { prisma: Prisma.TransactionClient | PrismaService; pieceId: string; product: { typeProductId: string | null; typeProduct?: { code: string | null } | null; } | null; requirements: PieceProductRequirement[]; }) { const effectiveProduct = product ?? ( await prisma.piece.findUnique({ where: { id: pieceId }, select: { product: { select: { typeProductId: true, typeProduct: { select: { code: true }, }, }, }, }, }) )?.product; if (!effectiveProduct) { throw new ConflictException( 'Ce type de pièce impose la sélection d’un produit catalogue.', ); } const matches = requirements.some((requirement) => this.doesProductMatchRequirement(effectiveProduct, requirement), ); if (!matches) { throw new ConflictException( 'Le produit associé ne respecte pas les exigences définies par le squelette.', ); } } private doesProductMatchRequirement( product: { typeProductId: string | null; typeProduct?: { code: string | null } | null; }, requirement: PieceProductRequirement, ): boolean { if (!requirement) { return false; } if ('typeProductId' in requirement && requirement.typeProductId) { const expectedId = requirement.typeProductId.trim(); if (!expectedId) { return false; } const currentId = product.typeProductId ? product.typeProductId.trim() : ''; return currentId === expectedId; } if ('familyCode' in requirement && requirement.familyCode) { const expectedCode = requirement.familyCode.trim().toLowerCase(); if (!expectedCode) { return false; } const productCode = product.typeProduct?.code?.trim().toLowerCase() ?? null; return productCode === expectedCode; } return false; } private normalizeConstructeurIds(ids?: string[] | null): string[] { if (!Array.isArray(ids)) { return []; } const cleaned = ids .map((item) => (typeof item === 'string' ? item.trim() : '')) .filter((item) => item.length > 0); return Array.from(new Set(cleaned)); } private async resolveExistingConstructeurIds( ids: string[], ): Promise { if (!ids.length) { return []; } const existing = await this.prisma.constructeur.findMany({ where: { id: { in: ids } }, select: { id: true }, }); const existingIds = new Set(existing.map(({ id }) => id)); return ids.filter((id) => existingIds.has(id)); } private parsePieceSkeleton(value: unknown): PieceModelStructure | null { if (!value) { return null; } try { return PieceModelStructureSchema.parse(value); } catch (error) { return null; } } private async ensurePieceCustomFieldDefinitions( prisma: Prisma.TransactionClient | PrismaService, typePieceId: string, customFields: PieceModelStructure['customFields'], ) { if ( !typePieceId || !Array.isArray(customFields) || customFields.length === 0 ) { return; } const existing = await prisma.customField.findMany({ where: { typePieceId }, select: { id: true, name: true, orderIndex: true }, }); const existingByName = new Map( existing.map((field) => [ this.normalizeIdentifier(field.name) ?? field.name, field, ]), ); for (let index = 0; index < customFields.length; index += 1) { const field = customFields[index]; if (!field) { continue; } const name = this.normalizeIdentifier(field.name); if (!name) { continue; } const existingField = existingByName.get(name); if (existingField) { if (existingField.orderIndex !== index) { await prisma.customField.update({ where: { id: existingField.id }, data: { orderIndex: index }, }); } continue; } const type = this.normalizeIdentifier(field.type) ?? 'text'; const required = Boolean(field.required); const options = this.normalizeOptions(field); const created = await prisma.customField.create({ data: { name, type, required, options, orderIndex: index, typePieceId, }, select: { id: true, name: true, orderIndex: true }, }); existingByName.set(name, created); } } private async createPieceCustomFieldValues( prisma: Prisma.TransactionClient | PrismaService, pieceId: string, typePieceId: string, customFields: PieceModelStructure['customFields'], ) { if ( !typePieceId || !Array.isArray(customFields) || customFields.length === 0 ) { return; } const definitions = await prisma.customField.findMany({ where: { typePieceId }, select: { id: true, name: true }, }); if (definitions.length === 0) { return; } const definitionMap = new Map( definitions.map((field) => [ this.normalizeIdentifier(field.name) ?? field.name, field.id, ]), ); const existingValues = await prisma.customFieldValue.findMany({ where: { pieceId }, select: { customFieldId: true }, }); const existingIds = new Set( existingValues.map((value) => value.customFieldId), ); for (const field of customFields) { if (!field) { continue; } const name = this.normalizeIdentifier(field.name); if (!name) { continue; } const definitionId = definitionMap.get(name); if (!definitionId || existingIds.has(definitionId)) { continue; } await prisma.customFieldValue.create({ data: { customFieldId: definitionId, pieceId, value: this.toCustomFieldValue(field.value), }, }); existingIds.add(definitionId); } } private normalizeOptions( field: PieceCustomFieldEntry | undefined, ): string[] | undefined { const rawOptions = field?.options; if (Array.isArray(rawOptions)) { const normalized = rawOptions .map((option) => (typeof option === 'string' ? option.trim() : '')) .filter((option) => option.length > 0); return normalized.length > 0 ? normalized : undefined; } const optionsTextValue = field !== undefined ? (field as unknown as { optionsText?: unknown }).optionsText : undefined; if (typeof optionsTextValue === 'string') { const normalized = optionsTextValue .split(/\r?\n/) .map((option: string) => option.trim()) .filter((option: string) => option.length > 0); return normalized.length > 0 ? normalized : undefined; } return undefined; } private normalizeIdentifier(value: unknown): string | null { if (typeof value !== 'string') { return null; } const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : null; } private toCustomFieldValue(value: unknown): string { if (value === undefined || value === null) { return ''; } if (typeof value === 'string') { return value; } return JSON.stringify(value); } private handlePrismaError(error: unknown): never { if (error instanceof Prisma.PrismaClientKnownRequestError) { if (error.code === 'P2002' && this.isNameConstraint(error)) { throw new ConflictException('Une pièce avec ce nom existe déjà.'); } } throw error; } private isNameConstraint(error: Prisma.PrismaClientKnownRequestError) { const { target } = error.meta ?? {}; if (Array.isArray(target)) { return target.includes('name'); } if (typeof target === 'string') { return target === 'name'; } 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<{ include: { pieceCustomFields: true }; }>; type PieceCustomFieldEntry = NonNullable< PieceModelStructure['customFields'] >[number]; type PieceProductRequirement = NonNullable< PieceModelStructure['products'] >[number];