import { ConflictException, Injectable } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; 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: true, }, }, constructeur: true, documents: true, customFieldValues: { include: { customField: true, }, }, machineLinks: { include: { machine: true, typeMachinePieceRequirement: true, parentLink: true, }, }, } as const; @Injectable() export class PiecesService { constructor(private prisma: PrismaService) {} private buildCreateInput(createPieceDto: CreatePieceDto): Prisma.PieceCreateInput { const data: Prisma.PieceCreateInput = { name: createPieceDto.name, reference: createPieceDto.reference ?? null, prix: createPieceDto.prix !== undefined ? createPieceDto.prix : null, }; if (createPieceDto.constructeurId) { data.constructeur = { connect: { id: createPieceDto.constructeurId }, }; } if (createPieceDto.typePieceId) { data.typePiece = { connect: { id: createPieceDto.typePieceId }, }; } return data; } async create(createPieceDto: CreatePieceDto) { try { const created = await this.prisma.piece.create({ data: this.buildCreateInput(createPieceDto), include: PIECE_WITH_RELATIONS_INCLUDE, }); await this.applyPieceSkeleton({ pieceId: created.id, typePiece: created.typePiece as PieceTypeWithSkeleton | null, }); return this.prisma.piece.findUnique({ where: { id: created.id }, include: PIECE_WITH_RELATIONS_INCLUDE, }); } catch (error) { this.handlePrismaError(error); } } async findAll() { return this.prisma.piece.findMany({ include: PIECE_WITH_RELATIONS_INCLUDE, orderBy: { name: 'asc' }, }); } async findOne(id: string) { return this.prisma.piece.findUnique({ where: { id }, include: PIECE_WITH_RELATIONS_INCLUDE, }); } 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; } if (updatePieceDto.constructeurId !== undefined) { data.constructeur = updatePieceDto.constructeurId ? { connect: { id: updatePieceDto.constructeurId } } : { disconnect: true }; } if (updatePieceDto.typePieceId !== undefined) { data.typePiece = updatePieceDto.typePieceId ? { connect: { id: updatePieceDto.typePieceId } } : { disconnect: true }; } try { const updated = await this.prisma.piece.update({ where: { id }, data, include: PIECE_WITH_RELATIONS_INCLUDE, }); await this.applyPieceSkeleton({ pieceId: updated.id, typePiece: updated.typePiece as PieceTypeWithSkeleton | null, }); return this.prisma.piece.findUnique({ where: { id: updated.id }, include: PIECE_WITH_RELATIONS_INCLUDE, }); } 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, }: { pieceId: string; typePiece: PieceTypeWithSkeleton | null; }) { if (!typePiece?.id) { return; } const skeleton = this.parsePieceSkeleton( (typePiece as { pieceSkeleton?: Prisma.JsonValue | null } | null) ?.pieceSkeleton, ); if (!skeleton) { return; } const customFields = skeleton.customFields ?? []; await this.ensurePieceCustomFieldDefinitions(typePiece.id, customFields); await this.createPieceCustomFieldValues( pieceId, typePiece.id, customFields, ); } private parsePieceSkeleton(value: unknown): PieceModelStructure | null { if (!value) { return null; } try { return PieceModelStructureSchema.parse(value); } catch (error) { return null; } } private async ensurePieceCustomFieldDefinitions( typePieceId: string, customFields: PieceModelStructure['customFields'], ) { if ( !typePieceId || !Array.isArray(customFields) || customFields.length === 0 ) { return; } const existing = await this.prisma.customField.findMany({ where: { typePieceId }, select: { id: true, name: true }, }); const existingByName = new Map( existing.map((field) => [ this.normalizeIdentifier(field.name) ?? field.name, field.id, ]), ); for (const field of customFields) { if (!field) { continue; } const name = this.normalizeIdentifier(field.name); if (!name || existingByName.has(name)) { continue; } const type = this.normalizeIdentifier(field.type) ?? 'text'; const required = Boolean(field.required); const options = this.normalizeOptions(field); const created = await this.prisma.customField.create({ data: { name, type, required, options, typePieceId, }, select: { id: true }, }); existingByName.set(name, created.id); } } private async createPieceCustomFieldValues( pieceId: string, typePieceId: string, customFields: PieceModelStructure['customFields'], ) { if ( !typePieceId || !Array.isArray(customFields) || customFields.length === 0 ) { return; } const definitions = await this.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 this.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 this.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; } } type PieceTypeWithSkeleton = Prisma.ModelTypeGetPayload<{ include: { pieceCustomFields: true }; }>; type PieceCustomFieldEntry = NonNullable[number];