From 6cf2b566ce4808d82b8874681984f7f67d6534ff Mon Sep 17 00:00:00 2001 From: Matthieu Date: Wed, 5 Nov 2025 15:34:42 +0100 Subject: [PATCH] feat: add product domain and machine integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - extend Prisma schema with products, product constructs and link tables\n- introduce product service, DTOs and includes with constructeur support\n- integrate product selections across model type skeletons, composants, pièces and machines\n- validate product requirements when building machine skeletons and payloads --- .../20251108120000_add_products/migration.sql | 75 +++ .../migration.sql | 26 + prisma/schema.prisma | 89 +++ scripts/seed-sample-data.ts | 198 +++++++ src/app.module.ts | 2 + src/common/constants/component-includes.ts | 11 + src/common/constants/product-includes.ts | 32 + .../mappers/type-machine.mapper.spec.ts | 27 + src/common/mappers/type-machine.mapper.ts | 74 ++- .../repositories/type-machines.repository.ts | 27 + src/common/utils/constructeur-link.util.ts | 32 +- src/component-models/structure.normalizer.ts | 54 ++ src/composants/composants.service.spec.ts | 9 +- src/composants/composants.service.ts | 45 +- src/custom-fields/custom-fields.service.ts | 24 + src/documents/documents.controller.ts | 5 + src/documents/documents.service.ts | 19 + src/machines/machines.service.spec.ts | 48 ++ src/machines/machines.service.ts | 554 ++++++++++++++++-- src/model-type/dto/create-model-type.dto.ts | 1 + src/model-type/model-type.service.ts | 74 ++- src/pieces/pieces.service.spec.ts | 9 +- src/pieces/pieces.service.ts | 212 ++++++- src/products/dto/list-products.dto.ts | 49 ++ src/products/products.controller.ts | 43 ++ src/products/products.module.ts | 9 + src/products/products.service.spec.ts | 240 ++++++++ src/products/products.service.ts | 322 ++++++++++ src/shared/dto/composant.dto.ts | 9 + src/shared/dto/custom-field.dto.ts | 5 + src/shared/dto/document.dto.ts | 20 + src/shared/dto/machine.dto.ts | 74 +++ src/shared/dto/piece.dto.ts | 9 + src/shared/dto/product.dto.ts | 28 + src/shared/dto/type.dto.ts | 41 ++ src/shared/schemas/inventory.ts | 149 ++++- src/shared/types/inventory.ts | 32 + src/types/services/type-machine.service.ts | 44 +- 38 files changed, 2601 insertions(+), 120 deletions(-) create mode 100644 prisma/migrations/20251108120000_add_products/migration.sql create mode 100644 prisma/migrations/20251108131000_add_machine_product_links/migration.sql create mode 100644 src/common/constants/product-includes.ts create mode 100644 src/products/dto/list-products.dto.ts create mode 100644 src/products/products.controller.ts create mode 100644 src/products/products.module.ts create mode 100644 src/products/products.service.spec.ts create mode 100644 src/products/products.service.ts create mode 100644 src/shared/dto/product.dto.ts diff --git a/prisma/migrations/20251108120000_add_products/migration.sql b/prisma/migrations/20251108120000_add_products/migration.sql new file mode 100644 index 0000000..4e87526 --- /dev/null +++ b/prisma/migrations/20251108120000_add_products/migration.sql @@ -0,0 +1,75 @@ +-- AlterEnum +ALTER TYPE "ModelCategory" ADD VALUE IF NOT EXISTS 'PRODUCT'; + +-- AlterTable +ALTER TABLE "ModelType" ADD COLUMN "productSkeleton" JSONB; + +ALTER TABLE "composants" ADD COLUMN "productId" TEXT; + +ALTER TABLE "pieces" ADD COLUMN "productId" TEXT; + +ALTER TABLE "documents" ADD COLUMN "productId" TEXT; + +ALTER TABLE "custom_fields" ADD COLUMN "typeProductId" TEXT; + +ALTER TABLE "custom_field_values" ADD COLUMN "productId" TEXT; + +-- CreateTable +CREATE TABLE "products" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "reference" TEXT, + "supplierPrice" DECIMAL(10,2), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "typeProductId" TEXT, + + CONSTRAINT "products_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "type_machine_product_requirements" ( + "id" TEXT NOT NULL, + "label" TEXT, + "minCount" INTEGER NOT NULL DEFAULT 0, + "maxCount" INTEGER, + "required" BOOLEAN NOT NULL DEFAULT false, + "allowNewModels" BOOLEAN NOT NULL DEFAULT true, + "orderIndex" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "typeMachineId" TEXT NOT NULL, + "typeProductId" TEXT NOT NULL, + + CONSTRAINT "type_machine_product_requirements_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "_ProductConstructeurs" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "products_name_key" ON "products"("name"); + +CREATE UNIQUE INDEX "_ProductConstructeurs_AB_unique" ON "_ProductConstructeurs"("A", "B"); +CREATE INDEX "_ProductConstructeurs_B_index" ON "_ProductConstructeurs"("B"); + +-- AddForeignKey +ALTER TABLE "products" ADD CONSTRAINT "products_typeProductId_fkey" FOREIGN KEY ("typeProductId") REFERENCES "ModelType"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "composants" ADD CONSTRAINT "composants_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "pieces" ADD CONSTRAINT "pieces_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "documents" ADD CONSTRAINT "documents_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "custom_fields" ADD CONSTRAINT "custom_fields_typeProductId_fkey" FOREIGN KEY ("typeProductId") REFERENCES "ModelType"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "custom_field_values" ADD CONSTRAINT "custom_field_values_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "type_machine_product_requirements" ADD CONSTRAINT "type_machine_product_requirements_typeMachineId_fkey" FOREIGN KEY ("typeMachineId") REFERENCES "type_machines"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "type_machine_product_requirements" ADD CONSTRAINT "type_machine_product_requirements_typeProductId_fkey" FOREIGN KEY ("typeProductId") REFERENCES "ModelType"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "_ProductConstructeurs" ADD CONSTRAINT "_ProductConstructeurs_A_fkey" FOREIGN KEY ("A") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "_ProductConstructeurs" ADD CONSTRAINT "_ProductConstructeurs_B_fkey" FOREIGN KEY ("B") REFERENCES "constructeurs"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20251108131000_add_machine_product_links/migration.sql b/prisma/migrations/20251108131000_add_machine_product_links/migration.sql new file mode 100644 index 0000000..76afb82 --- /dev/null +++ b/prisma/migrations/20251108131000_add_machine_product_links/migration.sql @@ -0,0 +1,26 @@ +-- CreateTable +CREATE TABLE "machine_product_links" ( + "id" TEXT NOT NULL, + "machineId" TEXT NOT NULL, + "productId" TEXT NOT NULL, + "typeMachineProductRequirementId" TEXT, + "parentLinkId" TEXT, + "parentComponentLinkId" TEXT, + "parentPieceLinkId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "machine_product_links_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "machine_product_links" ADD CONSTRAINT "machine_product_links_machineId_fkey" FOREIGN KEY ("machineId") REFERENCES "machines"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "machine_product_links" ADD CONSTRAINT "machine_product_links_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "machine_product_links" ADD CONSTRAINT "machine_product_links_typeMachineProductRequirementId_fkey" FOREIGN KEY ("typeMachineProductRequirementId") REFERENCES "type_machine_product_requirements"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "machine_product_links" ADD CONSTRAINT "machine_product_links_parentLinkId_fkey" FOREIGN KEY ("parentLinkId") REFERENCES "machine_product_links"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "machine_product_links" ADD CONSTRAINT "machine_product_links_parentComponentLinkId_fkey" FOREIGN KEY ("parentComponentLinkId") REFERENCES "machine_component_links"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "machine_product_links" ADD CONSTRAINT "machine_product_links_parentPieceLinkId_fkey" FOREIGN KEY ("parentPieceLinkId") REFERENCES "machine_piece_links"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- CreateIndex +CREATE INDEX "machine_product_links_machineId_idx" ON "machine_product_links"("machineId"); +CREATE INDEX "machine_product_links_productId_idx" ON "machine_product_links"("productId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 24e9801..12c5435 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -47,6 +47,7 @@ model TypeMachine { customFields CustomField[] @relation("TypeMachineCustomFields") componentRequirements TypeMachineComponentRequirement[] pieceRequirements TypeMachinePieceRequirement[] + productRequirements TypeMachineProductRequirement[] @@map("type_machines") } @@ -70,6 +71,7 @@ model Machine { componentLinks MachineComponentLink[] pieceLinks MachinePieceLink[] + productLinks MachineProductLink[] documents Document[] @relation("MachineDocuments") customFieldValues CustomFieldValue[] @relation("MachineCustomFieldValues") @@ -88,6 +90,9 @@ model Composant { typeComposantId String? typeComposant ModelType? @relation("ModelTypeComponentAssignments", fields: [typeComposantId], references: [id]) + productId String? + product Product? @relation(fields: [productId], references: [id], onDelete: SetNull) + constructeurs Constructeur[] @relation("ComposantConstructeurs") documents Document[] @relation("ComposantDocuments") @@ -108,6 +113,9 @@ model Piece { typePieceId String? typePiece ModelType? @relation("ModelTypePieceAssignments", fields: [typePieceId], references: [id]) + productId String? + product Product? @relation(fields: [productId], references: [id], onDelete: SetNull) + constructeurs Constructeur[] @relation("PieceConstructeurs") documents Document[] @relation("PieceDocuments") @@ -117,6 +125,27 @@ model Piece { @@map("pieces") } +model Product { + id String @id @default(cuid()) + name String @unique + reference String? + supplierPrice Decimal? @db.Decimal(10, 2) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + typeProductId String? + typeProduct ModelType? @relation("ModelTypeProductAssignments", fields: [typeProductId], references: [id]) + + constructeurs Constructeur[] @relation("ProductConstructeurs") + documents Document[] @relation("ProductDocuments") + customFieldValues CustomFieldValue[] @relation("ProductCustomFieldValues") + pieces Piece[] + composants Composant[] + machineLinks MachineProductLink[] + + @@map("products") +} + model MachineComponentLink { id String @id @default(cuid()) machineId String @@ -135,6 +164,7 @@ model MachineComponentLink { childLinks MachineComponentLink[] @relation("MachineComponentLinkHierarchy") typeMachineComponentRequirement TypeMachineComponentRequirement? @relation("ComponentRequirementLinks", fields: [typeMachineComponentRequirementId], references: [id], onDelete: SetNull) pieceLinks MachinePieceLink[] @relation("ComponentLinkPieceLinks") + productLinks MachineProductLink[] @relation("ComponentLinkProductLinks") @@map("machine_component_links") } @@ -155,13 +185,37 @@ model MachinePieceLink { piece Piece @relation(fields: [pieceId], references: [id], onDelete: Cascade) parentLink MachineComponentLink? @relation("ComponentLinkPieceLinks", fields: [parentLinkId], references: [id], onDelete: Cascade) typeMachinePieceRequirement TypeMachinePieceRequirement? @relation("PieceRequirementLinks", fields: [typeMachinePieceRequirementId], references: [id], onDelete: SetNull) + productLinks MachineProductLink[] @relation("PieceLinkProductLinks") @@map("machine_piece_links") } +model MachineProductLink { + id String @id @default(cuid()) + machineId String + productId String + typeMachineProductRequirementId String? + parentLinkId String? + parentComponentLinkId String? + parentPieceLinkId String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + machine Machine @relation(fields: [machineId], references: [id], onDelete: Cascade) + product Product @relation(fields: [productId], references: [id], onDelete: Cascade) + typeMachineProductRequirement TypeMachineProductRequirement? @relation("ProductRequirementLinks", fields: [typeMachineProductRequirementId], references: [id], onDelete: SetNull) + parentLink MachineProductLink? @relation("MachineProductLinkHierarchy", fields: [parentLinkId], references: [id], onDelete: Cascade) + childLinks MachineProductLink[] @relation("MachineProductLinkHierarchy") + parentComponentLink MachineComponentLink? @relation("ComponentLinkProductLinks", fields: [parentComponentLinkId], references: [id], onDelete: Cascade) + parentPieceLink MachinePieceLink? @relation("PieceLinkProductLinks", fields: [parentPieceLinkId], references: [id], onDelete: Cascade) + + @@map("machine_product_links") +} + enum ModelCategory { COMPONENT PIECE + PRODUCT } model ModelType { @@ -173,15 +227,19 @@ model ModelType { description String? @db.Text componentSkeleton Json? pieceSkeleton Json? + productSkeleton Json? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt composants Composant[] @relation("ModelTypeComponentAssignments") componentRequirements TypeMachineComponentRequirement[] @relation("ModelTypeComponentRequirements") customFields CustomField[] @relation("ModelTypeCustomFields") + productCustomFields CustomField[] @relation("ModelTypeProductCustomFields") pieceRequirements TypeMachinePieceRequirement[] @relation("ModelTypePieceRequirements") pieces Piece[] @relation("ModelTypePieceAssignments") pieceCustomFields CustomField[] @relation("ModelTypePieceCustomFields") + products Product[] @relation("ModelTypeProductAssignments") + productRequirements TypeMachineProductRequirement[] @relation("ModelTypeProductRequirements") @@unique([category, name]) } @@ -197,6 +255,7 @@ model Constructeur { machines Machine[] @relation("MachineConstructeurs") composants Composant[] @relation("ComposantConstructeurs") pieces Piece[] @relation("PieceConstructeurs") + products Product[] @relation("ProductConstructeurs") @@map("constructeurs") } @@ -232,6 +291,9 @@ model Document { pieceId String? piece Piece? @relation("PieceDocuments", fields: [pieceId], references: [id], onDelete: Cascade) + productId String? + product Product? @relation("ProductDocuments", fields: [productId], references: [id], onDelete: Cascade) + siteId String? site Site? @relation("SiteDocuments", fields: [siteId], references: [id], onDelete: Cascade) @@ -259,6 +321,9 @@ model CustomField { typePieceId String? typePiece ModelType? @relation("ModelTypePieceCustomFields", fields: [typePieceId], references: [id], onDelete: Cascade) + typeProductId String? + typeProduct ModelType? @relation("ModelTypeProductCustomFields", fields: [typeProductId], references: [id], onDelete: Cascade) + // Relations avec les valeurs customFieldValues CustomFieldValue[] @@ -284,6 +349,9 @@ model CustomFieldValue { pieceId String? piece Piece? @relation("PieceCustomFieldValues", fields: [pieceId], references: [id], onDelete: Cascade) + productId String? + product Product? @relation("ProductCustomFieldValues", fields: [productId], references: [id], onDelete: Cascade) + @@map("custom_field_values") } @@ -330,3 +398,24 @@ model TypeMachinePieceRequirement { @@map("type_machine_piece_requirements") } + +model TypeMachineProductRequirement { + id String @id @default(cuid()) + label String? + minCount Int @default(0) + maxCount Int? + required Boolean @default(false) + allowNewModels Boolean @default(true) + orderIndex Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + typeMachineId String + typeMachine TypeMachine @relation(fields: [typeMachineId], references: [id], onDelete: Cascade) + + typeProductId String + typeProduct ModelType @relation("ModelTypeProductRequirements", fields: [typeProductId], references: [id]) + machineProductLinks MachineProductLink[] @relation("ProductRequirementLinks") + + @@map("type_machine_product_requirements") +} diff --git a/scripts/seed-sample-data.ts b/scripts/seed-sample-data.ts index a4eabae..86bbbf6 100644 --- a/scripts/seed-sample-data.ts +++ b/scripts/seed-sample-data.ts @@ -9,8 +9,10 @@ async function deleteExistingData() { await prisma.machinePieceLink.deleteMany(); await prisma.machine.deleteMany(); await prisma.customFieldValue.deleteMany(); + await prisma.product.deleteMany(); await prisma.composant.deleteMany(); await prisma.piece.deleteMany(); + await prisma.typeMachineProductRequirement.deleteMany(); await prisma.modelType.deleteMany({ where: { @@ -22,6 +24,7 @@ async function deleteExistingData() { 'cooling-module', 'structural-frame', 'hydraulic-power-unit', + 'hydraulic-product', ], }, }, @@ -239,6 +242,135 @@ async function createComponent(options: { }); } +async function createProductType( + name: string, + code: string, + description: string, + fields: Array<{ + name: string; + type: 'text' | 'number' | 'select' | 'boolean' | 'date'; + required?: boolean; + options?: string[]; + }>, + skeleton?: Record, +) { + const type = await prisma.modelType.create({ + data: { + name, + code, + category: 'PRODUCT', + description, + productSkeleton: skeleton + ? (skeleton as Prisma.InputJsonValue) + : Prisma.JsonNull, + productCustomFields: { + create: fields.map((field, index) => ({ + name: field.name, + type: field.type, + required: field.required ?? false, + options: field.options ?? [], + orderIndex: index, + })), + }, + }, + }); + + const customFields = await prisma.customField.findMany({ + where: { typeProductId: type.id }, + }); + + const fieldMap: CreatedFields = {}; + customFields.forEach((field) => { + fieldMap[field.name] = field.id; + }); + + return { type, fieldMap }; +} + +async function createProduct(options: { + name: string; + reference?: string; + supplierPrice?: number | null; + constructeurIds?: string[] | null; + typeId?: string | null; + fieldValues?: Record; +}) { + const fieldValues = options.fieldValues ?? {}; + + const customFields = options.typeId + ? await prisma.customField.findMany({ + where: { typeProductId: options.typeId }, + }) + : []; + + const customFieldValues = Object.entries(fieldValues).flatMap( + ([fieldName, value]) => { + if (typeof value !== 'string') { + return []; + } + + const target = customFields.find((field) => field.name === fieldName); + if (!target) { + return []; + } + + return [ + { + value, + customFieldId: target.id, + }, + ]; + }, + ); + + const constructeurIds = Array.isArray(options.constructeurIds) + ? Array.from( + new Set( + options.constructeurIds + .filter((value): value is string => typeof value === 'string') + .map((value) => value.trim()) + .filter((value) => value.length > 0), + ), + ) + : []; + + const data: Prisma.ProductCreateInput = { + name: options.name, + reference: options.reference ?? null, + supplierPrice: + options.supplierPrice === undefined || options.supplierPrice === null + ? null + : new Prisma.Decimal(options.supplierPrice), + }; + + if (options.typeId) { + data.typeProduct = { + connect: { id: options.typeId }, + }; + } + + if (constructeurIds.length) { + data.constructeurs = { + connect: constructeurIds.map((id) => ({ id })), + }; + } + + if (customFieldValues.length) { + data.customFieldValues = { + create: customFieldValues.map((entry) => ({ + value: entry.value, + customField: { + connect: { id: entry.customFieldId }, + }, + })), + }; + } + + return prisma.product.create({ + data, + }); +} + async function main() { console.log('Nettoyage des données existantes…'); await deleteExistingData(); @@ -371,6 +503,63 @@ async function main() { }, }); + console.log('Création des types de produits…'); + const hydraulicProductFields: { + name: string; + type: 'text' | 'number' | 'select' | 'boolean' | 'date'; + required?: boolean; + options?: string[]; + }[] = [ + { name: 'Fournisseur', type: 'text', required: true }, + { name: 'Garantie (mois)', type: 'number', required: true }, + { + name: 'Délai d’approvisionnement (jours)', + type: 'number', + }, + ]; + + const hydraulicProductType = await createProductType( + 'Produit hydraulique standard', + 'hydraulic-product', + 'Produits compatibles avec les centrales hydrauliques', + hydraulicProductFields, + ); + + console.log('Création des produits…'); + const pumpProduct = await createProduct({ + name: 'Pompe PX-300 Fournisseur A', + reference: 'PRD-PX-300-A', + supplierPrice: 1520, + typeId: hydraulicProductType.type.id, + fieldValues: { + Fournisseur: 'HydrauParts', + 'Garantie (mois)': '24', + 'Délai d’approvisionnement (jours)': '21', + }, + }); + + const coolingProduct = await createProduct({ + name: 'Module de refroidissement AC-50 - OEM', + reference: 'PRD-AC-50', + supplierPrice: 1980, + typeId: hydraulicProductType.type.id, + fieldValues: { + Fournisseur: 'ThermoTech', + 'Garantie (mois)': '18', + 'Délai d’approvisionnement (jours)': '28', + }, + }); + + console.log('Association des produits aux pièces…'); + await prisma.piece.update({ + where: { id: pumpPiece.id }, + data: { + product: { + connect: { id: pumpProduct.id }, + }, + }, + }); + console.log('Création des types de composants…'); const coolingComponentFields: { name: string; @@ -509,6 +698,15 @@ async function main() { } as Prisma.InputJsonValue, }); + await prisma.composant.update({ + where: { id: coolingModule.id }, + data: { + product: { + connect: { id: coolingProduct.id }, + }, + }, + }); + const structuralFrame = await createComponent({ name: 'Châssis structurel XC-800', reference: 'FRAME-XC800', diff --git a/src/app.module.ts b/src/app.module.ts index 3fd6ec0..ef281f0 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -14,6 +14,7 @@ import { ConstructeursModule } from './constructeurs/constructeurs.module'; import { ProfilesModule } from './profiles/profiles.module'; import { SessionModule } from './session/session.module'; import { ModelTypeModule } from './model-type/model-type.module'; +import { ProductsModule } from './products/products.module'; @Module({ imports: [ @@ -32,6 +33,7 @@ import { ModelTypeModule } from './model-type/model-type.module'; ProfilesModule, SessionModule, ModelTypeModule, + ProductsModule, ], controllers: [AppController], providers: [AppService], diff --git a/src/common/constants/component-includes.ts b/src/common/constants/component-includes.ts index 77d5149..4f052d3 100644 --- a/src/common/constants/component-includes.ts +++ b/src/common/constants/component-includes.ts @@ -23,6 +23,17 @@ export const COMPONENT_WITH_RELATIONS_INCLUDE = { customField: { select: CUSTOM_FIELD_SELECT }, }, }, + product: { + include: { + constructeurs: true, + customFieldValues: { + include: { + customField: { select: CUSTOM_FIELD_SELECT }, + }, + }, + documents: true, + }, + }, machineLinks: { include: { machine: true, diff --git a/src/common/constants/product-includes.ts b/src/common/constants/product-includes.ts new file mode 100644 index 0000000..c41aa2d --- /dev/null +++ b/src/common/constants/product-includes.ts @@ -0,0 +1,32 @@ +import { Prisma } from '@prisma/client'; + +export const PRODUCT_WITH_RELATIONS_INCLUDE = { + typeProduct: { + include: { + productCustomFields: { + orderBy: { orderIndex: 'asc' }, + }, + }, + }, + constructeurs: true, + documents: true, + customFieldValues: { + include: { + customField: true, + }, + }, + pieces: { + select: { + id: true, + name: true, + reference: true, + }, + }, + composants: { + select: { + id: true, + name: true, + reference: true, + }, + }, +} satisfies Prisma.ProductInclude; diff --git a/src/common/mappers/type-machine.mapper.spec.ts b/src/common/mappers/type-machine.mapper.spec.ts index 013919a..df82935 100644 --- a/src/common/mappers/type-machine.mapper.spec.ts +++ b/src/common/mappers/type-machine.mapper.spec.ts @@ -26,6 +26,16 @@ const baseDto = { typePieceId: 'piece-id', }, ], + productRequirements: [ + { + label: 'Product', + minCount: 1, + maxCount: 3, + required: true, + allowNewModels: true, + typeProductId: 'product-id', + }, + ], }; describe('TypeMachineMapper', () => { @@ -52,6 +62,14 @@ describe('TypeMachineMapper', () => { allowNewModels: true, orderIndex: 0, }); + expect(input.productRequirements?.create?.[0]).toMatchObject({ + label: 'Product', + minCount: 1, + maxCount: 3, + required: true, + allowNewModels: true, + orderIndex: 0, + }); }); it('should map custom field inputs for create many', () => { @@ -76,6 +94,9 @@ describe('TypeMachineMapper', () => { const piece = TypeMachineMapper.mapPieceRequirementInputs( baseDto.pieceRequirements as any, ); + const product = TypeMachineMapper.mapProductRequirementInputs( + baseDto.productRequirements as any, + ); expect(component[0]).toMatchObject({ typeComposantId: 'comp-id', @@ -89,5 +110,11 @@ describe('TypeMachineMapper', () => { maxCount: 2, orderIndex: 0, }); + expect(product[0]).toMatchObject({ + typeProductId: 'product-id', + minCount: 1, + maxCount: 3, + orderIndex: 0, + }); }); }); diff --git a/src/common/mappers/type-machine.mapper.ts b/src/common/mappers/type-machine.mapper.ts index 3e9a662..da061f0 100644 --- a/src/common/mappers/type-machine.mapper.ts +++ b/src/common/mappers/type-machine.mapper.ts @@ -13,6 +13,7 @@ type RequirementDto = { allowNewModels?: boolean | null; typeComposantId?: string; typePieceId?: string; + typeProductId?: string; orderIndex?: number | null; }; @@ -29,6 +30,10 @@ export const TYPE_MACHINE_DEFAULT_INCLUDE: Prisma.TypeMachineInclude = { include: { typePiece: true }, orderBy: { orderIndex: 'asc' }, }, + productRequirements: { + include: { typeProduct: true }, + orderBy: { orderIndex: 'asc' }, + }, }; export const TYPE_MACHINE_WITH_MACHINES_INCLUDE: Prisma.TypeMachineInclude = { @@ -40,8 +45,13 @@ export class TypeMachineMapper { static toCreateInput( dto: CreateTypeMachineDto, ): Prisma.TypeMachineCreateInput { - const { customFields, componentRequirements, pieceRequirements, ...data } = - dto; + const { + customFields, + componentRequirements, + pieceRequirements, + productRequirements, + ...data + } = dto; return { ...data, @@ -50,14 +60,20 @@ export class TypeMachineMapper { componentRequirements, ), pieceRequirements: this.mapPieceRequirements(pieceRequirements), + productRequirements: this.mapProductRequirements(productRequirements), }; } static toUpdateData( dto: UpdateTypeMachineDto, ): Prisma.TypeMachineUpdateInput { - const { customFields, componentRequirements, pieceRequirements, ...data } = - dto; + const { + customFields, + componentRequirements, + pieceRequirements, + productRequirements, + ...data + } = dto; const payload: Prisma.TypeMachineUpdateInput = { ...data }; @@ -73,6 +89,10 @@ export class TypeMachineMapper { payload.pieceRequirements = undefined; } + if (productRequirements !== undefined) { + payload.productRequirements = undefined; + } + return payload; } @@ -199,4 +219,50 @@ export class TypeMachineMapper { typePieceId: requirement.typePieceId!, })); } + + static mapProductRequirements( + requirements?: RequirementDto[] | null, + ): + | Prisma.TypeMachineProductRequirementCreateNestedManyWithoutTypeMachineInput + | undefined { + if (!requirements || requirements.length === 0) { + return undefined; + } + + return { + create: requirements.map((requirement, index) => ({ + label: requirement.label ?? null, + minCount: requirement.minCount ?? 0, + maxCount: requirement.maxCount ?? null, + required: requirement.required ?? false, + allowNewModels: requirement.allowNewModels ?? true, + orderIndex: requirement.orderIndex ?? index, + typeProduct: requirement.typeProductId + ? { + connect: { id: requirement.typeProductId }, + } + : (() => { + throw new Error( + 'typeProductId est requis pour créer une contrainte produit.', + ); + })(), + })), + }; + } + + static mapProductRequirementInputs(requirements?: RequirementDto[] | null) { + if (!requirements || requirements.length === 0) { + return []; + } + + return requirements.map((requirement, index) => ({ + label: requirement.label ?? null, + minCount: requirement.minCount ?? 0, + maxCount: requirement.maxCount ?? null, + required: requirement.required ?? false, + allowNewModels: requirement.allowNewModels ?? true, + orderIndex: requirement.orderIndex ?? index, + typeProductId: requirement.typeProductId!, + })); + } } diff --git a/src/common/repositories/type-machines.repository.ts b/src/common/repositories/type-machines.repository.ts index 9e59bd4..f4c39eb 100644 --- a/src/common/repositories/type-machines.repository.ts +++ b/src/common/repositories/type-machines.repository.ts @@ -17,6 +17,11 @@ type PieceRequirementInput = Omit< 'id' | 'typeMachineId' >; +type ProductRequirementInput = Omit< + Prisma.TypeMachineProductRequirementCreateManyInput, + 'id' | 'typeMachineId' +>; + @Injectable() export class TypeMachinesRepository { constructor(private readonly prisma: PrismaService) {} @@ -132,6 +137,28 @@ export class TypeMachinesRepository { }); } + async deleteProductRequirements(typeMachineId: string) { + await this.client.typeMachineProductRequirement.deleteMany({ + where: { typeMachineId }, + }); + } + + async createProductRequirements( + typeMachineId: string, + requirements: ProductRequirementInput[], + ) { + if (!requirements.length) { + return; + } + + await this.client.typeMachineProductRequirement.createMany({ + data: requirements.map((requirement) => ({ + ...requirement, + typeMachineId, + })), + }); + } + async findMachinesUsingType(typeMachineId: string) { return this.client.machine.findMany({ where: { typeMachineId }, diff --git a/src/common/utils/constructeur-link.util.ts b/src/common/utils/constructeur-link.util.ts index 7ca8f26..dad9c67 100644 --- a/src/common/utils/constructeur-link.util.ts +++ b/src/common/utils/constructeur-link.util.ts @@ -12,6 +12,7 @@ const DEFAULT_ORIENTATIONS: Record = { _MachineConstructeurs: { parentColumn: 'A', constructeurColumn: 'B' }, _ComposantConstructeurs: { parentColumn: 'A', constructeurColumn: 'B' }, _PieceConstructeurs: { parentColumn: 'A', constructeurColumn: 'B' }, + _ProductConstructeurs: { parentColumn: 'A', constructeurColumn: 'B' }, }; const sanitizeTableName = (tableName: string): string => { @@ -22,7 +23,12 @@ const sanitizeTableName = (tableName: string): string => { }; const ORIENTATION_CACHE = new Map(); -const KNOWN_PARENT_TABLES = new Set(['machines', 'composants', 'pieces']); +const KNOWN_PARENT_TABLES = new Set([ + 'machines', + 'composants', + 'pieces', + 'products', +]); const oppositeColumn = (column: 'A' | 'B'): 'A' | 'B' => column === 'A' ? 'B' : 'A'; @@ -39,11 +45,12 @@ async function resolveOrientation( return cached; } - if (typeof prisma.__getConstructeurLinkOrientation === 'function') { - const orientation = await prisma.__getConstructeurLinkOrientation(tableName); - ORIENTATION_CACHE.set(tableName, orientation); - return orientation; - } + 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 }> @@ -103,11 +110,10 @@ async function resolveOrientation( 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'); + .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) { @@ -204,8 +210,8 @@ export async function syncConstructeurLinks( return []; } - const valueTuples = targetConstructeurIds.map((constructeurId) => - Prisma.sql`(${parentId}, ${constructeurId})`, + const valueTuples = targetConstructeurIds.map( + (constructeurId) => Prisma.sql`(${parentId}, ${constructeurId})`, ); await prisma.$executeRaw( diff --git a/src/component-models/structure.normalizer.ts b/src/component-models/structure.normalizer.ts index 1b3fa40..bafbe3b 100644 --- a/src/component-models/structure.normalizer.ts +++ b/src/component-models/structure.normalizer.ts @@ -76,6 +76,59 @@ export function normalizeComponentModelStructure( }, ); + const products = toArray((structure as any)?.products).map((product) => { + const candidate = product as Record | null | undefined; + + if (candidate?.typeProductId) { + const normalized: ComponentModelStructure['products'][number] = { + typeProductId: + ensureString(candidate.typeProductId).trim() || 'UNKNOWN', + role: sanitizeRole(candidate.role), + }; + + if (candidate?.familyCode) { + const familyCode = ensureString(candidate.familyCode).trim(); + if (familyCode) { + (normalized as Record).familyCode = familyCode; + } + } + + if (candidate?.typeProductLabel) { + const label = ensureString(candidate.typeProductLabel).trim(); + if (label) { + (normalized as Record).typeProductLabel = label; + } + } + + if (candidate?.reference) { + const reference = ensureString(candidate.reference).trim(); + if (reference) { + (normalized as Record).reference = reference; + } + } + + return normalized; + } + + if (candidate?.familyCode) { + return { + familyCode: ensureString(candidate.familyCode).trim() || 'UNKNOWN', + role: sanitizeRole(candidate.role), + } as ComponentModelStructure['products'][number]; + } + + return { + familyCode: + ensureString( + candidate?.familyCode ?? + candidate?.name ?? + candidate?.typeProductLabel ?? + 'UNKNOWN', + ).trim() || 'UNKNOWN', + role: sanitizeRole(candidate?.role), + } as ComponentModelStructure['products'][number]; + }); + const rawSubcomponents = toArray( (structure as any)?.subcomponents ?? (structure as any)?.subComponents, ); @@ -115,6 +168,7 @@ export function normalizeComponentModelStructure( return { pieces, + products, customFields, subcomponents, }; diff --git a/src/composants/composants.service.spec.ts b/src/composants/composants.service.spec.ts index 6f4cc2a..5d58ac7 100644 --- a/src/composants/composants.service.spec.ts +++ b/src/composants/composants.service.spec.ts @@ -35,6 +35,7 @@ describe('ComposantsService', () => { const dto: CreateComposantDto = { name: 'Comp A', typeComposantId: 'type-1', + productId: ' product-1 ', }; prisma.composant.create.mockResolvedValue({ id: 'comp-1', name: dto.name }); @@ -42,11 +43,14 @@ describe('ComposantsService', () => { const result = await service.create(dto); expect(prisma.composant.create).toHaveBeenCalled(); + expect(prisma.composant.create.mock.calls[0][0].data.product).toEqual({ + connect: { id: 'product-1' }, + }); expect(result).toMatchObject({ id: 'comp-1' }); }); it('updates a component', async () => { - const dto: UpdateComposantDto = { name: 'Updated' }; + const dto: UpdateComposantDto = { name: 'Updated', productId: '' }; prisma.composant.update.mockResolvedValue({ id: 'comp-1', @@ -56,5 +60,8 @@ describe('ComposantsService', () => { await service.update('comp-1', dto); expect(prisma.composant.update).toHaveBeenCalled(); + expect(prisma.composant.update.mock.calls[0][0].data.product).toEqual({ + disconnect: true, + }); }); }); diff --git a/src/composants/composants.service.ts b/src/composants/composants.service.ts index d55d407..1dc9e5f 100644 --- a/src/composants/composants.service.ts +++ b/src/composants/composants.service.ts @@ -40,6 +40,15 @@ export class ComposantsService { }; } + if (createComposantDto.productId) { + const normalizedProductId = createComposantDto.productId.trim(); + if (normalizedProductId) { + data.product = { + connect: { id: normalizedProductId }, + }; + } + } + if (createComposantDto.structure !== undefined) { data.structure = createComposantDto.structure as Prisma.InputJsonValue; } @@ -49,9 +58,8 @@ export class ComposantsService { async create(createComposantDto: CreateComposantDto) { try { - const { data, constructeurIds } = await this.buildCreateInput( - createComposantDto, - ); + const { data, constructeurIds } = + await this.buildCreateInput(createComposantDto); const created = await this.prisma.composant.create({ data, include: COMPONENT_WITH_RELATIONS_INCLUDE, @@ -73,9 +81,11 @@ export class ComposantsService { })) as ComposantWithRelations | null; if (refreshed && syncedConstructeurIds.length > 0) { - (refreshed as ComposantWithRelations & { - constructeurIds?: string[]; - }).constructeurIds = [...syncedConstructeurIds]; + ( + refreshed as ComposantWithRelations & { + constructeurIds?: string[]; + } + ).constructeurIds = [...syncedConstructeurIds]; } return refreshed; @@ -118,9 +128,8 @@ export class ComposantsService { const constructeurIds = this.normalizeConstructeurIds( updateComposantDto.constructeurIds, ); - resolvedConstructeurIds = await this.resolveExistingConstructeurIds( - constructeurIds, - ); + resolvedConstructeurIds = + await this.resolveExistingConstructeurIds(constructeurIds); } if (updateComposantDto.typeComposantId !== undefined) { @@ -129,6 +138,16 @@ export class ComposantsService { : { disconnect: true }; } + if (updateComposantDto.productId !== undefined) { + const normalizedProductId = + typeof updateComposantDto.productId === 'string' + ? updateComposantDto.productId.trim() + : null; + data.product = normalizedProductId + ? { connect: { id: normalizedProductId } } + : { disconnect: true }; + } + if (updateComposantDto.structure !== undefined) { data.structure = updateComposantDto.structure as Prisma.InputJsonValue; } @@ -157,9 +176,11 @@ export class ComposantsService { })) as ComposantWithRelations | null; if (refreshed && syncedConstructeurIds) { - (refreshed as ComposantWithRelations & { - constructeurIds?: string[]; - }).constructeurIds = [...syncedConstructeurIds]; + ( + refreshed as ComposantWithRelations & { + constructeurIds?: string[]; + } + ).constructeurIds = [...syncedConstructeurIds]; } return refreshed; diff --git a/src/custom-fields/custom-fields.service.ts b/src/custom-fields/custom-fields.service.ts index ccac487..39ac442 100644 --- a/src/custom-fields/custom-fields.service.ts +++ b/src/custom-fields/custom-fields.service.ts @@ -36,6 +36,8 @@ export class CustomFieldsService { return 'composantId' as const; case CustomFieldEntityType.PIECE: return 'pieceId' as const; + case CustomFieldEntityType.PRODUCT: + return 'productId' as const; default: throw new BadRequestException( "Type d'entité de champ personnalisé invalide.", @@ -114,6 +116,28 @@ export class CustomFieldsService { valueKey: 'pieceId' as const, }; } + case CustomFieldEntityType.PRODUCT: { + const product = await this.prisma.product.findUnique({ + where: { id: entityId }, + select: { typeProductId: true }, + }); + + if (!product) { + throw new NotFoundException('Produit introuvable.'); + } + + if (!product.typeProductId) { + throw new BadRequestException( + 'Le produit ne possède pas de type associé pour les champs personnalisés.', + ); + } + + return { + typeId: product.typeProductId, + customFieldTypeField: 'typeProductId' as const, + valueKey: 'productId' as const, + }; + } default: throw new BadRequestException( "Type d'entité de champ personnalisé invalide.", diff --git a/src/documents/documents.controller.ts b/src/documents/documents.controller.ts index 47797c8..fdd4a27 100644 --- a/src/documents/documents.controller.ts +++ b/src/documents/documents.controller.ts @@ -42,6 +42,11 @@ export class DocumentsController { return this.documentsService.findByPiece(pieceId); } + @Get('product/:productId') + findByProduct(@Param('productId') productId: string) { + return this.documentsService.findByProduct(productId); + } + @Get('site/:siteId') findBySite(@Param('siteId') siteId: string) { return this.documentsService.findBySite(siteId); diff --git a/src/documents/documents.service.ts b/src/documents/documents.service.ts index bae2249..3c0ea1c 100644 --- a/src/documents/documents.service.ts +++ b/src/documents/documents.service.ts @@ -16,6 +16,7 @@ export class DocumentsService { machine: true, composant: true, piece: true, + product: true, site: true, }, }); @@ -27,6 +28,7 @@ export class DocumentsService { machine: true, composant: true, piece: true, + product: true, site: true, }, }); @@ -39,6 +41,7 @@ export class DocumentsService { machine: true, composant: true, piece: true, + product: true, site: true, }, }); @@ -51,6 +54,7 @@ export class DocumentsService { machine: true, composant: true, piece: true, + product: true, site: true, }, }); @@ -63,6 +67,20 @@ export class DocumentsService { machine: true, composant: true, piece: true, + product: true, + site: true, + }, + }); + } + + async findByProduct(productId: string) { + return this.prisma.document.findMany({ + where: { productId }, + include: { + machine: true, + composant: true, + piece: true, + product: true, site: true, }, }); @@ -75,6 +93,7 @@ export class DocumentsService { machine: true, composant: true, piece: true, + product: true, site: true, }, }); diff --git a/src/machines/machines.service.spec.ts b/src/machines/machines.service.spec.ts index 588ee7c..23847ba 100644 --- a/src/machines/machines.service.spec.ts +++ b/src/machines/machines.service.spec.ts @@ -202,4 +202,52 @@ describe('MachinesService', () => { expect(result?.pieceLinks[0].piece.name).toBe('Root piece name'); expect(result?.pieceLinks[0].overrides.reference).toBe('RP-001'); }); + + describe('validateProductRequirements', () => { + const buildRequirement = (overrides: Partial = {}) => + ({ + id: 'req-1', + label: 'Hydraulic kits', + minCount: 1, + maxCount: 2, + required: true, + allowNewModels: true, + typeProductId: 'product-type-1', + typeProduct: { name: 'Hydraulic kit' }, + ...overrides, + }) as any; + + const callValidate = ( + requirement: any, + componentUsage: Record, + pieceUsage: Record, + ) => { + const map = new Map([[requirement.id, requirement]]); + const componentMap = new Map(Object.entries(componentUsage)); + const pieceMap = new Map(Object.entries(pieceUsage)); + (service as any).validateProductRequirements(map, componentMap, pieceMap); + }; + + it('does nothing when usage satisfies min and max constraints', () => { + expect(() => + callValidate(buildRequirement(), { 'product-type-1': 1 }, {}), + ).not.toThrow(); + }); + + it('throws when minimum requirement is not met', () => { + expect(() => callValidate(buildRequirement(), {}, {})).toThrow( + /requiert au moins 1 sélection/i, + ); + }); + + it('throws when usage exceeds maximum', () => { + expect(() => + callValidate( + buildRequirement({ maxCount: 2 }), + { 'product-type-1': 2 }, + { 'product-type-1': 1 }, + ), + ).toThrow(/ne peut pas dépasser 2 sélection/i); + }); + }); }); diff --git a/src/machines/machines.service.ts b/src/machines/machines.service.ts index c157afa..a99dbc9 100644 --- a/src/machines/machines.service.ts +++ b/src/machines/machines.service.ts @@ -8,6 +8,7 @@ import { ReconfigureMachineDto, MachineComponentLinkInput, MachinePieceLinkInput, + MachineProductLinkInput, } from '../shared/dto/machine.dto'; import { buildComponentHierarchy } from '../common/utils/component-tree.util'; import { @@ -51,6 +52,18 @@ const TYPE_MACHINE_CONFIGURATION_INCLUDE: Prisma.TypeMachineInclude = { }, }, }, + productRequirements: { + include: { + typeProduct: { + include: { + productCustomFields: { + orderBy: { orderIndex: 'asc' }, + }, + }, + }, + }, + orderBy: { orderIndex: 'asc' }, + }, }; const MACHINE_PIECE_LINK_INCLUDE: Prisma.MachinePieceLinkInclude = { @@ -69,6 +82,17 @@ const MACHINE_PIECE_LINK_INCLUDE: Prisma.MachinePieceLinkInclude = { }, }, }, + product: { + include: { + constructeurs: true, + customFieldValues: { + include: { + customField: { select: CUSTOM_FIELD_SELECT }, + }, + }, + documents: true, + }, + }, documents: true, }, }, @@ -104,6 +128,17 @@ const buildComponentLinkInclude = ( customField: { select: CUSTOM_FIELD_SELECT }, }, }, + product: { + include: { + constructeurs: true, + customFieldValues: { + include: { + customField: { select: CUSTOM_FIELD_SELECT }, + }, + }, + documents: true, + }, + }, documents: true, }, }, @@ -134,6 +169,20 @@ const buildComponentLinkInclude = ( const MACHINE_COMPONENT_LINK_INCLUDE = buildComponentLinkInclude(); +const MACHINE_PRODUCT_LINK_INCLUDE = { + product: { + include: { + constructeurs: true, + typeProduct: true, + }, + }, + typeMachineProductRequirement: { + include: { + typeProduct: true, + }, + }, +} satisfies Prisma.MachineProductLinkInclude; + const MACHINE_DEFAULT_INCLUDE = { site: true, typeMachine: { @@ -146,6 +195,9 @@ const MACHINE_DEFAULT_INCLUDE = { pieceLinks: { include: MACHINE_PIECE_LINK_INCLUDE, }, + productLinks: { + include: MACHINE_PRODUCT_LINK_INCLUDE, + }, customFieldValues: { include: { customField: { select: CUSTOM_FIELD_SELECT }, @@ -166,6 +218,10 @@ type MachinePieceLinkWithRelations = Prisma.MachinePieceLinkGetPayload<{ include: typeof MACHINE_PIECE_LINK_INCLUDE; }>; +type MachineProductLinkWithRelations = Prisma.MachineProductLinkGetPayload<{ + include: typeof MACHINE_PRODUCT_LINK_INCLUDE; +}>; + type LinkOverride = { name: string | null; reference: string | null; @@ -215,18 +271,49 @@ type PieceRequirementWithType = Prisma.TypeMachinePieceRequirementGetPayload<{ include: { typePiece: true }; }>; +type ProductRequirementWithType = + Prisma.TypeMachineProductRequirementGetPayload<{ + include: { typeProduct: true }; + }>; + type ComponentWithType = Prisma.ComposantGetPayload<{ - include: { typeComposant: true }; + include: { + typeComposant: true; + product: { + select: { + id: true; + typeProductId: true; + }; + }; + }; }>; type PieceWithType = Prisma.PieceGetPayload<{ - include: { typePiece: true; constructeurs: true }; + include: { + typePiece: true; + constructeurs: true; + product: { + select: { + id: true; + typeProductId: true; + }; + }; + }; }>; type CreatedComponentLinkInfo = { id: string; composantId: string; requirementId: string | null; + productTypeId: string | null; +}; + +type ComponentLinkIndex = { + createdLinks: Map; + byComponentId: Map; + byRequirementId: Map; + productUsage: Map; + autoPieceProductUsage: Map; }; type PendingComponentLink = { @@ -248,6 +335,22 @@ type CreatedPieceLinkInfo = { pieceId: string; requirementId: string; parentLinkId: string | null; + productTypeId: string | null; +}; + +type CreatedProductLinkInfo = { + id: string; + productId: string; + requirementId: string; + productTypeId: string | null; +}; + +type PendingProductLink = { + raw: MachineProductLinkInput; + assignedId: string; + requirement: ProductRequirementWithType; + productId: string; + position: number; }; type PendingPieceLink = { @@ -408,6 +511,7 @@ export class MachinesService { pieceLinks: HydratedPieceLink[]; constructeurIds: string[]; constructeurs: MachineWithRelations['constructeurs']; + productLinks: MachineProductLinkWithRelations[]; }) | null { if (!machine) { @@ -431,6 +535,7 @@ export class MachinesService { pieceLinks: HydratedPieceLink[]; constructeurIds: string[]; constructeurs: MachineWithRelations['constructeurs']; + productLinks: MachineProductLinkWithRelations[]; }; hydratedMachine.componentLinks = componentLinks; @@ -441,6 +546,7 @@ export class MachinesService { ) .filter((id): id is string => Boolean(id)); hydratedMachine.constructeurs = resolvedConstructeurs; + hydratedMachine.productLinks = machine.productLinks ?? []; return hydratedMachine; } @@ -452,6 +558,7 @@ export class MachinesService { pieceLinks: HydratedPieceLink[]; constructeurIds: string[]; constructeurs: MachineWithRelations['constructeurs']; + productLinks: MachineProductLinkWithRelations[]; })[] { return machines.map((machine) => this.hydrateMachine(machine)!); } @@ -481,7 +588,8 @@ export class MachinesService { .filter((id): id is string => Boolean(id)); const initialIds = - Array.isArray(machine.constructeurIds) && machine.constructeurIds.length > 0 + Array.isArray(machine.constructeurIds) && + machine.constructeurIds.length > 0 ? machine.constructeurIds : idsFromConstructeurs; @@ -515,20 +623,15 @@ export class MachinesService { const orderedConstructeurs = resolvedIds .map((id) => byId.get(id)) - .filter( - ( - record, - ): record is (typeof constructeurs)[number] => - Boolean(record), + .filter((record): record is (typeof constructeurs)[number] => + Boolean(record), ); - machine.constructeurs = - orderedConstructeurs as MachineWithRelations['constructeurs']; + machine.constructeurs = orderedConstructeurs; return machine; } - private slugifyName(name: string): string { return name .normalize('NFD') @@ -576,6 +679,7 @@ export class MachinesService { typeMachine: TypeMachineConfiguration, componentLinks: MachineComponentLinkInput[], pieceLinks: MachinePieceLinkInput[], + productLinks: MachineProductLinkInput[], ) { const componentRequirements = ( Array.isArray(typeMachine.componentRequirements) @@ -587,6 +691,11 @@ export class MachinesService { ? typeMachine.pieceRequirements : [] ) as PieceRequirementWithType[]; + const productRequirements = ( + Array.isArray(typeMachine.productRequirements) + ? typeMachine.productRequirements + : [] + ) as ProductRequirementWithType[]; const componentRequirementMap = new Map( componentRequirements.map((requirement) => [requirement.id, requirement]), @@ -594,6 +703,9 @@ export class MachinesService { const pieceRequirementMap = new Map( pieceRequirements.map((requirement) => [requirement.id, requirement]), ); + const productRequirementMap = new Map( + productRequirements.map((requirement) => [requirement.id, requirement]), + ); const componentLinksByRequirement = new Map< string, @@ -623,6 +735,10 @@ export class MachinesService { } const pieceLinksByRequirement = new Map(); + const productLinksByRequirement = new Map< + string, + MachineProductLinkInput[] + >(); for (const link of pieceLinks) { const requirement = pieceRequirementMap.get(link.requirementId); if (!requirement) { @@ -693,11 +809,27 @@ export class MachinesService { } } + for (const link of productLinks) { + const requirement = productRequirementMap.get(link.requirementId); + if (!requirement) { + throw new Error( + `Lien de produit invalide: requirementId=${link.requirementId}`, + ); + } + + if (!productLinksByRequirement.has(requirement.id)) { + productLinksByRequirement.set(requirement.id, []); + } + productLinksByRequirement.get(requirement.id)!.push(link); + } + return { componentRequirementMap, pieceRequirementMap, + productRequirementMap, componentLinksByRequirement, pieceLinksByRequirement, + productLinksByRequirement, }; } @@ -709,6 +841,69 @@ export class MachinesService { return value as Record; } + private validateProductRequirements( + productRequirementMap: Map, + componentUsage: Map, + pieceUsage: Map, + directUsage: Map, + productLinksByRequirement: Map, + ) { + if (productRequirementMap.size === 0) { + return; + } + + const totalUsage = new Map(); + const accumulate = (source: Map) => { + for (const [typeProductId, count] of source.entries()) { + totalUsage.set( + typeProductId, + (totalUsage.get(typeProductId) ?? 0) + count, + ); + } + }; + + accumulate(componentUsage); + accumulate(pieceUsage); + accumulate(directUsage); + + for (const requirement of productRequirementMap.values()) { + const typeProductId = requirement.typeProductId; + if (!typeProductId) { + continue; + } + + const directSelections = + productLinksByRequirement.get(requirement.id)?.length ?? 0; + const count = totalUsage.get(typeProductId) ?? 0; + const min = requirement.minCount ?? (requirement.required ? 1 : 0); + const max = requirement.maxCount ?? undefined; + + const label = + requirement.label?.trim() || + requirement.typeProduct?.name || + requirement.typeProduct?.code || + requirement.id; + + if (count < min) { + throw new Error( + `Le groupe de produits "${label}" requiert au moins ${min} sélection(s) mais seulement ${count} ont été fournis.`, + ); + } + + if (max !== undefined && count > max) { + throw new Error( + `Le groupe de produits "${label}" ne peut pas dépasser ${max} sélection(s).`, + ); + } + + if (max !== undefined && directSelections > max) { + throw new Error( + `Le groupe de produits "${label}" ne peut pas dépasser ${max} sélection(s) directes.`, + ); + } + } + } + private extractString(value: unknown): string | undefined { if (typeof value !== 'string') { return undefined; @@ -903,12 +1098,16 @@ export class MachinesService { } private describeRequirement( - requirement: ComponentRequirementWithType | PieceRequirementWithType, + requirement: + | ComponentRequirementWithType + | PieceRequirementWithType + | ProductRequirementWithType, ): string { return ( requirement.label || (requirement as ComponentRequirementWithType).typeComposant?.name || (requirement as PieceRequirementWithType).typePiece?.name || + (requirement as ProductRequirementWithType).typeProduct?.name || requirement.id ); } @@ -1066,6 +1265,8 @@ export class MachinesService { createdLinks: Map, byComponentId: Map, componentMap: Map, + productUsage: Map, + autoPieceProductUsage: Map, ) { if (createdLinks.size === 0) { return; @@ -1083,6 +1284,12 @@ export class MachinesService { where: { id: componentId }, include: { typeComposant: true, + product: { + select: { + id: true, + typeProductId: true, + }, + }, }, }); @@ -1104,6 +1311,12 @@ export class MachinesService { include: { typePiece: true, constructeurs: true, + product: { + select: { + id: true, + typeProductId: true, + }, + }, }, }); @@ -1249,6 +1462,14 @@ export class MachinesService { }, }); + const pieceProductTypeId = piece.product?.typeProductId ?? null; + if (pieceProductTypeId) { + autoPieceProductUsage.set( + pieceProductTypeId, + (autoPieceProductUsage.get(pieceProductTypeId) ?? 0) + 1, + ); + } + createdPieceKeys.add(pieceKey); } } @@ -1307,10 +1528,20 @@ export class MachinesService { }, }); + const childProductTypeId = + childComponent.product?.typeProductId ?? null; + if (childProductTypeId) { + productUsage.set( + childProductTypeId, + (productUsage.get(childProductTypeId) ?? 0) + 1, + ); + } + const created: CreatedComponentLinkInfo = { id: assignedId, composantId: selectedComponentId, requirementId: null, + productTypeId: childProductTypeId, }; createdLinks.set(assignedId, created); @@ -1331,13 +1562,15 @@ export class MachinesService { machineId: string, componentRequirementMap: Map, componentLinks: MachineComponentLinkInput[], - ) { + ): Promise { const links = Array.isArray(componentLinks) ? componentLinks : []; if (links.length === 0) { return { createdLinks: new Map(), byComponentId: new Map(), byRequirementId: new Map(), + productUsage: new Map(), + autoPieceProductUsage: new Map(), }; } @@ -1375,7 +1608,15 @@ export class MachinesService { const components = await prisma.composant.findMany({ where: { id: { in: Array.from(componentIds) } }, - include: { typeComposant: true }, + include: { + typeComposant: true, + product: { + select: { + id: true, + typeProductId: true, + }, + }, + }, }); const componentMap = new Map( components.map((component) => [component.id, component]), @@ -1412,6 +1653,8 @@ export class MachinesService { const createdLinks = new Map(); const byComponentId = new Map(); const byRequirementId = new Map(); + const productUsage = new Map(); + const autoPieceProductUsage = new Map(); while (pending.size > 0) { let progress = false; @@ -1454,6 +1697,7 @@ export class MachinesService { id: entry.assignedId, composantId: entry.componentId, requirementId: entry.requirement.id, + productTypeId: entry.component?.product?.typeProductId ?? null, }; createdLinks.set(entry.assignedId, created); @@ -1468,6 +1712,14 @@ export class MachinesService { } byRequirementId.get(entry.requirement.id)!.push(created); + const productTypeId = entry.component?.product?.typeProductId ?? null; + if (productTypeId) { + productUsage.set( + productTypeId, + (productUsage.get(productTypeId) ?? 0) + 1, + ); + } + pending.delete(id); progress = true; } @@ -1485,9 +1737,17 @@ export class MachinesService { createdLinks, byComponentId, componentMap, + productUsage, + autoPieceProductUsage, ); - return { createdLinks, byComponentId, byRequirementId }; + return { + createdLinks, + byComponentId, + byRequirementId, + productUsage, + autoPieceProductUsage, + }; } private resolveComponentParentReference( @@ -1570,15 +1830,17 @@ export class MachinesService { machineId: string, pieceRequirementMap: Map, pieceLinks: MachinePieceLinkInput[], - componentLinkIndex: { - createdLinks: Map; - byComponentId: Map; - byRequirementId: Map; - }, - ) { + componentLinkIndex: ComponentLinkIndex, + ): Promise<{ + createdLinks: Map; + productUsage: Map; + }> { const links = Array.isArray(pieceLinks) ? pieceLinks : []; if (links.length === 0) { - return new Map(); + return { + createdLinks: new Map(), + productUsage: new Map(), + }; } const pieceIds = new Set(); @@ -1612,7 +1874,16 @@ export class MachinesService { const pieces = await prisma.piece.findMany({ where: { id: { in: Array.from(pieceIds) } }, - include: { typePiece: true, constructeurs: true }, + include: { + typePiece: true, + constructeurs: true, + product: { + select: { + id: true, + typeProductId: true, + }, + }, + }, }); const pieceMap = new Map( pieces.map((piece) => [piece.id, piece]), @@ -1643,6 +1914,7 @@ export class MachinesService { } const createdLinks = new Map(); + const productUsage = new Map(); for (const entry of pendingEntries) { const parentId = this.resolvePieceParentReference( @@ -1675,19 +1947,145 @@ export class MachinesService { pieceId: entry.pieceId, requirementId: entry.requirement.id, parentLinkId: parentId ?? null, + productTypeId: entry.piece?.product?.typeProductId ?? null, }); + + const productTypeId = entry.piece?.product?.typeProductId ?? null; + if (productTypeId) { + productUsage.set( + productTypeId, + (productUsage.get(productTypeId) ?? 0) + 1, + ); + } } - return createdLinks; + return { createdLinks, productUsage }; + } + + private async createProductLinksForMachine( + prisma: Prisma.TransactionClient | PrismaService, + machineId: string, + productRequirementMap: Map, + productLinks: MachineProductLinkInput[], + ): Promise<{ + createdLinks: Map; + productUsage: Map; + }> { + const links = Array.isArray(productLinks) ? productLinks : []; + if (links.length === 0) { + return { + createdLinks: new Map(), + productUsage: new Map(), + }; + } + + const productIds = new Set(); + const pendingEntries: PendingProductLink[] = []; + + links.forEach((link, index) => { + const requirement = productRequirementMap.get(link.requirementId); + if (!requirement) { + throw new Error( + `Requirement de produit introuvable (${link.requirementId}).`, + ); + } + + const productId = this.extractString(link.productId); + if (!productId) { + throw new Error( + `productId manquant pour le lien de produit #${index + 1} (${this.describeRequirement(requirement)}).`, + ); + } + + productIds.add(productId); + + pendingEntries.push({ + raw: link, + assignedId: this.resolveLinkIdentifier(link) ?? randomUUID(), + requirement, + productId, + position: index, + }); + }); + + const products = await prisma.product.findMany({ + where: { id: { in: Array.from(productIds) } }, + select: { + id: true, + typeProductId: true, + }, + }); + + const productMap = new Map( + products.map((product) => [product.id, product]), + ); + + for (const entry of pendingEntries) { + const product = productMap.get(entry.productId); + if (!product) { + throw new Error( + `Produit introuvable (${entry.productId}) pour le lien de produit #${entry.position + 1}.`, + ); + } + + if ( + entry.requirement.typeProductId && + product.typeProductId && + product.typeProductId !== entry.requirement.typeProductId + ) { + throw new Error( + `Le produit sélectionné n'appartient pas à la catégorie attendue pour "${this.describeRequirement(entry.requirement)}".`, + ); + } + } + + const createdLinks = new Map(); + const productUsage = new Map(); + + for (const entry of pendingEntries) { + const product = productMap.get(entry.productId); + if (!product) { + continue; + } + + const createData: Prisma.MachineProductLinkUncheckedCreateInput = { + id: entry.assignedId, + machineId, + productId: entry.productId, + typeMachineProductRequirementId: entry.requirement.id, + parentLinkId: this.extractString(entry.raw.parentLinkId), + parentComponentLinkId: this.extractString( + entry.raw.parentComponentLinkId, + ), + parentPieceLinkId: this.extractString(entry.raw.parentPieceLinkId), + }; + + await prisma.machineProductLink.create({ data: createData }); + + const created: CreatedProductLinkInfo = { + id: entry.assignedId, + productId: entry.productId, + requirementId: entry.requirement.id, + productTypeId: product.typeProductId ?? null, + }; + + createdLinks.set(entry.assignedId, created); + + const typeProductId = product.typeProductId ?? null; + if (typeProductId) { + productUsage.set( + typeProductId, + (productUsage.get(typeProductId) ?? 0) + 1, + ); + } + } + + return { createdLinks, productUsage }; } private resolvePieceParentReference( link: MachinePieceLinkInput, - componentLinkIndex: { - createdLinks: Map; - byComponentId: Map; - byRequirementId: Map; - }, + componentLinkIndex: ComponentLinkIndex, ): string | null { const explicitParentId = this.extractString( link.parentComponentLinkId ?? link.parentLinkId, @@ -1813,6 +2211,7 @@ export class MachinesService { const { componentLinks = [], pieceLinks = [], + productLinks = [], constructeurIds, ...machineData } = createMachineDto; @@ -1834,8 +2233,17 @@ export class MachinesService { machineData.typeMachineId, ); - const { componentRequirementMap, pieceRequirementMap } = - this.buildConfigurationContext(typeMachine, componentLinks, pieceLinks); + const { + componentRequirementMap, + pieceRequirementMap, + productRequirementMap, + productLinksByRequirement, + } = this.buildConfigurationContext( + typeMachine, + componentLinks, + pieceLinks, + productLinks, + ); const baseMachine = await this.prisma.machine.create({ data: machineData, @@ -1870,13 +2278,41 @@ export class MachinesService { componentLinks, ); - await this.createPieceLinksForMachine( + const pieceLinkResult = await this.createPieceLinksForMachine( this.prisma, baseMachine.id, pieceRequirementMap, pieceLinks, componentIndex, ); + + const productLinkResult = await this.createProductLinksForMachine( + this.prisma, + baseMachine.id, + productRequirementMap, + productLinks, + ); + + const combinedPieceUsage = new Map(pieceLinkResult.productUsage); + for (const [ + typeProductId, + count, + ] of componentIndex.autoPieceProductUsage) { + combinedPieceUsage.set( + typeProductId, + (combinedPieceUsage.get(typeProductId) ?? 0) + count, + ); + } + + const combinedProductUsage = new Map(productLinkResult.productUsage); + + this.validateProductRequirements( + productRequirementMap, + componentIndex.productUsage, + combinedPieceUsage, + combinedProductUsage, + productLinksByRequirement, + ); } catch (error) { await this.prisma.machine .delete({ where: { id: baseMachine.id } }) @@ -1904,8 +2340,8 @@ export class MachinesService { const enriched = await Promise.all( hydrated.map((machine) => this.ensureMachineConstructeurs(machine)), ); - return enriched.filter( - (machine): machine is NonNullable => Boolean(machine), + return enriched.filter((machine): machine is NonNullable => + Boolean(machine), ); } @@ -1919,7 +2355,11 @@ export class MachinesService { } async reconfigure(id: string, reconfigureMachineDto: ReconfigureMachineDto) { - const { componentLinks = [], pieceLinks = [] } = reconfigureMachineDto; + const { + componentLinks = [], + pieceLinks = [], + productLinks = [], + } = reconfigureMachineDto; const machine = await this.prisma.machine.findUnique({ where: { id }, @@ -1942,12 +2382,22 @@ export class MachinesService { const typeMachine = machine.typeMachine as TypeMachineConfiguration; - const { componentRequirementMap, pieceRequirementMap } = - this.buildConfigurationContext(typeMachine, componentLinks, pieceLinks); + const { + componentRequirementMap, + pieceRequirementMap, + productRequirementMap, + productLinksByRequirement, + } = this.buildConfigurationContext( + typeMachine, + componentLinks, + pieceLinks, + productLinks, + ); await this.prisma.$transaction(async (tx) => { await tx.machinePieceLink.deleteMany({ where: { machineId: id } }); await tx.machineComponentLink.deleteMany({ where: { machineId: id } }); + await tx.machineProductLink.deleteMany({ where: { machineId: id } }); const componentIndex = await this.createComponentLinksForMachine( tx, @@ -1956,13 +2406,41 @@ export class MachinesService { componentLinks, ); - await this.createPieceLinksForMachine( + const pieceLinkResult = await this.createPieceLinksForMachine( tx, id, pieceRequirementMap, pieceLinks, componentIndex, ); + + const productLinkResult = await this.createProductLinksForMachine( + tx, + id, + productRequirementMap, + productLinks, + ); + + const combinedPieceUsage = new Map(pieceLinkResult.productUsage); + for (const [ + typeProductId, + count, + ] of componentIndex.autoPieceProductUsage) { + combinedPieceUsage.set( + typeProductId, + (combinedPieceUsage.get(typeProductId) ?? 0) + count, + ); + } + + const combinedProductUsage = new Map(productLinkResult.productUsage); + + this.validateProductRequirements( + productRequirementMap, + componentIndex.productUsage, + combinedPieceUsage, + combinedProductUsage, + productLinksByRequirement, + ); }); const updatedMachine = await this.prisma.machine.findUnique({ diff --git a/src/model-type/dto/create-model-type.dto.ts b/src/model-type/dto/create-model-type.dto.ts index 7454dc8..598bca2 100644 --- a/src/model-type/dto/create-model-type.dto.ts +++ b/src/model-type/dto/create-model-type.dto.ts @@ -3,6 +3,7 @@ import { IsEnum, IsOptional, IsString, Length, Matches } from 'class-validator'; export enum ModelCategory { COMPONENT = 'COMPONENT', PIECE = 'PIECE', + PRODUCT = 'PRODUCT', } export class CreateModelTypeDto { diff --git a/src/model-type/model-type.service.ts b/src/model-type/model-type.service.ts index f5c22cb..7532227 100644 --- a/src/model-type/model-type.service.ts +++ b/src/model-type/model-type.service.ts @@ -11,6 +11,7 @@ import { UpdateModelTypeDto } from './dto/update-model-type.dto'; import { ComponentModelStructureSchema, PieceModelStructureSchema, + ProductModelStructureSchema, } from '../shared/schemas/inventory'; type SortField = 'name' | 'code' | 'createdAt'; @@ -112,12 +113,22 @@ export class ModelTypeService { if (normalizedStructure !== undefined) { const skeletonValue = normalizedStructure === null ? Prisma.JsonNull : normalizedStructure; - if (rest.category === ModelCategory.COMPONENT) { - data.componentSkeleton = skeletonValue; - data.pieceSkeleton = Prisma.JsonNull; - } else { - data.pieceSkeleton = skeletonValue; - data.componentSkeleton = Prisma.JsonNull; + switch (rest.category) { + case ModelCategory.COMPONENT: + data.componentSkeleton = skeletonValue; + data.pieceSkeleton = Prisma.JsonNull; + data.productSkeleton = Prisma.JsonNull; + break; + case ModelCategory.PIECE: + data.pieceSkeleton = skeletonValue; + data.componentSkeleton = Prisma.JsonNull; + data.productSkeleton = Prisma.JsonNull; + break; + case ModelCategory.PRODUCT: + data.productSkeleton = skeletonValue; + data.componentSkeleton = Prisma.JsonNull; + data.pieceSkeleton = Prisma.JsonNull; + break; } } @@ -172,12 +183,22 @@ export class ModelTypeService { if (normalizedStructure !== undefined) { const skeletonValue = normalizedStructure === null ? Prisma.JsonNull : normalizedStructure; - if (targetCategory === ModelCategory.COMPONENT) { - data.componentSkeleton = skeletonValue; - data.pieceSkeleton = Prisma.JsonNull; - } else { - data.pieceSkeleton = skeletonValue; - data.componentSkeleton = Prisma.JsonNull; + switch (targetCategory) { + case ModelCategory.COMPONENT: + data.componentSkeleton = skeletonValue; + data.pieceSkeleton = Prisma.JsonNull; + data.productSkeleton = Prisma.JsonNull; + break; + case ModelCategory.PIECE: + data.pieceSkeleton = skeletonValue; + data.componentSkeleton = Prisma.JsonNull; + data.productSkeleton = Prisma.JsonNull; + break; + case ModelCategory.PRODUCT: + data.productSkeleton = skeletonValue; + data.componentSkeleton = Prisma.JsonNull; + data.pieceSkeleton = Prisma.JsonNull; + break; } } @@ -270,7 +291,12 @@ export class ModelTypeService { structure, ) as Prisma.InputJsonValue; } - return PieceModelStructureSchema.parse( + if (category === ModelCategory.PIECE) { + return PieceModelStructureSchema.parse( + structure, + ) as Prisma.InputJsonValue; + } + return ProductModelStructureSchema.parse( structure, ) as Prisma.InputJsonValue; } catch (error) { @@ -281,10 +307,24 @@ export class ModelTypeService { } private mapModelType(modelType: PrismaModelType) { - const structure = - modelType.category === ModelCategory.COMPONENT - ? (modelType.componentSkeleton ?? null) - : (modelType.pieceSkeleton ?? null); + let structure: Prisma.InputJsonValue | null; + switch (modelType.category as ModelCategory) { + case ModelCategory.COMPONENT: + structure = (modelType.componentSkeleton ?? + null) as Prisma.InputJsonValue | null; + break; + case ModelCategory.PIECE: + structure = (modelType.pieceSkeleton ?? + null) as Prisma.InputJsonValue | null; + break; + case ModelCategory.PRODUCT: + structure = (modelType.productSkeleton ?? + null) as Prisma.InputJsonValue | null; + break; + default: + structure = null; + break; + } return { ...modelType, diff --git a/src/pieces/pieces.service.spec.ts b/src/pieces/pieces.service.spec.ts index 563ab29..779598a 100644 --- a/src/pieces/pieces.service.spec.ts +++ b/src/pieces/pieces.service.spec.ts @@ -38,6 +38,7 @@ describe('PiecesService', () => { const dto: CreatePieceDto = { name: 'Piece A', typePieceId: 'type-piece-1', + productId: ' product-1 ', }; prisma.piece.create.mockResolvedValue({ id: 'piece-1', name: dto.name }); @@ -51,11 +52,14 @@ describe('PiecesService', () => { const result = await service.create(dto); expect(prisma.piece.create).toHaveBeenCalled(); + expect(prisma.piece.create.mock.calls[0][0].data.product).toEqual({ + connect: { id: 'product-1' }, + }); expect(result).toMatchObject({ id: 'piece-1' }); }); it('updates a piece', async () => { - const dto: UpdatePieceDto = { name: 'Updated piece' }; + const dto: UpdatePieceDto = { name: 'Updated piece', productId: '' }; prisma.piece.update.mockResolvedValue({ id: 'piece-1', @@ -71,5 +75,8 @@ describe('PiecesService', () => { await service.update('piece-1', dto); expect(prisma.piece.update).toHaveBeenCalled(); + expect(prisma.piece.update.mock.calls[0][0].data.product).toEqual({ + disconnect: true, + }); }); }); diff --git a/src/pieces/pieces.service.ts b/src/pieces/pieces.service.ts index d3ef833..ccd3466 100644 --- a/src/pieces/pieces.service.ts +++ b/src/pieces/pieces.service.ts @@ -21,6 +21,18 @@ const PIECE_WITH_RELATIONS_INCLUDE = { customField: true, }, }, + product: { + include: { + typeProduct: true, + constructeurs: true, + customFieldValues: { + include: { + customField: true, + }, + }, + documents: true, + }, + }, machineLinks: { include: { machine: true, @@ -55,43 +67,63 @@ export class PiecesService { }; } + 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 { 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 created = await this.prisma.piece.create({ - 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, - }); const refreshed = await this.prisma.piece.findUnique({ - where: { id: created.id }, + where: { id: pieceId }, include: PIECE_WITH_RELATIONS_INCLUDE, }); if (refreshed && syncedConstructeurIds.length > 0) { - (refreshed as typeof refreshed & { constructeurIds?: string[] }).constructeurIds = - [...syncedConstructeurIds]; + ( + refreshed as typeof refreshed & { constructeurIds?: string[] } + ).constructeurIds = [...syncedConstructeurIds]; } return refreshed; @@ -134,9 +166,8 @@ export class PiecesService { const constructeurIds = this.normalizeConstructeurIds( updatePieceDto.constructeurIds, ); - resolvedConstructeurIds = await this.resolveExistingConstructeurIds( - constructeurIds, - ); + resolvedConstructeurIds = + await this.resolveExistingConstructeurIds(constructeurIds); } if (updatePieceDto.typePieceId !== undefined) { @@ -145,6 +176,16 @@ export class PiecesService { : { 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) => { @@ -166,6 +207,7 @@ export class PiecesService { await this.applyPieceSkeleton({ pieceId: updated.id, typePiece: updated.typePiece as PieceTypeWithSkeleton | null, + product: updated.product, prisma: tx, }); }); @@ -176,8 +218,9 @@ export class PiecesService { }); if (refreshed && syncedConstructeurIds) { - (refreshed as typeof refreshed & { constructeurIds?: string[] }).constructeurIds = - [...syncedConstructeurIds]; + ( + refreshed as typeof refreshed & { constructeurIds?: string[] } + ).constructeurIds = [...syncedConstructeurIds]; } return refreshed; @@ -247,10 +290,15 @@ export class PiecesService { 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) { @@ -267,6 +315,13 @@ export class PiecesService { } const customFields = skeleton.customFields ?? []; + const productRequirements: PieceProductRequirement[] = Array.isArray( + skeleton.products, + ) + ? skeleton.products.filter( + (entry): entry is PieceProductRequirement => !!entry, + ) + : []; await this.ensurePieceCustomFieldDefinitions( prisma, @@ -279,6 +334,99 @@ export class PiecesService { 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[] { @@ -529,3 +677,7 @@ type PieceTypeWithSkeleton = Prisma.ModelTypeGetPayload<{ type PieceCustomFieldEntry = NonNullable< PieceModelStructure['customFields'] >[number]; + +type PieceProductRequirement = NonNullable< + PieceModelStructure['products'] +>[number]; diff --git a/src/products/dto/list-products.dto.ts b/src/products/dto/list-products.dto.ts new file mode 100644 index 0000000..d06d4f4 --- /dev/null +++ b/src/products/dto/list-products.dto.ts @@ -0,0 +1,49 @@ +import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; +import { Type } from 'class-transformer'; + +export enum ProductSortField { + NAME = 'name', + REFERENCE = 'reference', + CREATED_AT = 'createdAt', + SUPPLIER_PRICE = 'supplierPrice', +} + +export enum SortDirection { + ASC = 'asc', + DESC = 'desc', +} + +export class ListProductsQueryDto { + @IsOptional() + @IsString() + q?: string; + + @IsOptional() + @IsString() + typeProductId?: string; + + @IsOptional() + @IsString() + constructeurId?: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + offset?: number; + + @IsOptional() + @IsEnum(ProductSortField) + sort?: ProductSortField; + + @IsOptional() + @IsEnum(SortDirection) + dir?: SortDirection; +} diff --git a/src/products/products.controller.ts b/src/products/products.controller.ts new file mode 100644 index 0000000..c9940b3 --- /dev/null +++ b/src/products/products.controller.ts @@ -0,0 +1,43 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, +} from '@nestjs/common'; +import { ProductsService } from './products.service'; +import { CreateProductDto, UpdateProductDto } from '../shared/dto/product.dto'; +import { ListProductsQueryDto } from './dto/list-products.dto'; + +@Controller('products') +export class ProductsController { + constructor(private readonly productsService: ProductsService) {} + + @Get() + list(@Query() query: ListProductsQueryDto) { + return this.productsService.list(query); + } + + @Get(':id') + findOne(@Param('id') id: string) { + return this.productsService.findOne(id); + } + + @Post() + create(@Body() createProductDto: CreateProductDto) { + return this.productsService.create(createProductDto); + } + + @Patch(':id') + update(@Param('id') id: string, @Body() updateProductDto: UpdateProductDto) { + return this.productsService.update(id, updateProductDto); + } + + @Delete(':id') + remove(@Param('id') id: string) { + return this.productsService.remove(id); + } +} diff --git a/src/products/products.module.ts b/src/products/products.module.ts new file mode 100644 index 0000000..9b00f47 --- /dev/null +++ b/src/products/products.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { ProductsController } from './products.controller'; +import { ProductsService } from './products.service'; + +@Module({ + controllers: [ProductsController], + providers: [ProductsService], +}) +export class ProductsModule {} diff --git a/src/products/products.service.spec.ts b/src/products/products.service.spec.ts new file mode 100644 index 0000000..2f21204 --- /dev/null +++ b/src/products/products.service.spec.ts @@ -0,0 +1,240 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConflictException } from '@nestjs/common'; +import { Prisma, Product } from '@prisma/client'; +import { ProductsService } from './products.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { syncConstructeurLinks } from '../common/utils/constructeur-link.util'; +import { CreateProductDto, UpdateProductDto } from '../shared/dto/product.dto'; +import { ProductSortField, SortDirection } from './dto/list-products.dto'; + +jest.mock('../common/utils/constructeur-link.util', () => ({ + syncConstructeurLinks: jest.fn().mockResolvedValue([]), +})); + +describe('ProductsService', () => { + let service: ProductsService; + let prisma: { + product: any; + constructeur: any; + piece: any; + composant: any; + document: any; + $transaction: jest.Mock; + }; + const mockSyncConstructeurLinks = syncConstructeurLinks as jest.Mock; + + beforeEach(async () => { + prisma = { + product: { + findMany: jest.fn(), + count: jest.fn(), + findUnique: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + constructeur: { + findMany: jest.fn(), + }, + piece: { + count: jest.fn(), + }, + composant: { + count: jest.fn(), + }, + document: { + count: jest.fn(), + }, + $transaction: jest.fn((arg: any) => { + if (Array.isArray(arg)) { + return Promise.all(arg); + } + if (typeof arg === 'function') { + return arg(prisma); + } + return Promise.resolve(); + }), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ProductsService, + { provide: PrismaService, useValue: prisma }, + ], + }).compile(); + + service = module.get(ProductsService); + mockSyncConstructeurLinks.mockClear(); + }); + + describe('list', () => { + it('returns products with mapped constructeur ids and pagination meta', async () => { + const product: Product & { + constructeurs: Array<{ id: string }>; + documents: any[]; + customFieldValues: any[]; + pieces: any[]; + composants: any[]; + typeProduct: null; + } = { + id: 'prod-1', + name: 'Product 1', + reference: 'P-001', + supplierPrice: new Prisma.Decimal(120), + createdAt: new Date(), + updatedAt: new Date(), + typeProductId: null, + constructeurs: [{ id: 'const-1' }], + documents: [], + customFieldValues: [], + pieces: [], + composants: [], + typeProduct: null, + }; + + prisma.product.findMany.mockResolvedValue([product]); + prisma.product.count.mockResolvedValue(1); + + const result = await service.list({ + q: ' Product ', + limit: 200, + sort: ProductSortField.NAME, + dir: SortDirection.ASC, + }); + + expect(prisma.product.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + OR: expect.any(Array), + }), + take: 100, // capped + orderBy: { name: 'asc' }, + }), + ); + expect(result.total).toBe(1); + expect(result.items[0]).toMatchObject({ + id: 'prod-1', + constructeurIds: ['const-1'], + }); + }); + }); + + describe('create', () => { + it('persists a product and synchronizes constructeurs', async () => { + const dto: CreateProductDto = { + name: 'New Product', + supplierPrice: 150.5, + constructeurIds: ['const-1', 'const-1', ''], + typeProductId: 'type-1', + }; + + prisma.constructeur.findMany.mockResolvedValue([{ id: 'const-1' }]); + prisma.product.create.mockResolvedValue({ + id: 'prod-1', + }); + prisma.product.findUnique.mockResolvedValue({ + id: 'prod-1', + name: dto.name, + reference: null, + supplierPrice: new Prisma.Decimal(150.5), + typeProductId: dto.typeProductId, + constructeurs: [{ id: 'const-1' }], + documents: [], + customFieldValues: [], + pieces: [], + composants: [], + typeProduct: null, + }); + mockSyncConstructeurLinks.mockResolvedValue(['const-1']); + + const created = await service.create(dto); + + expect(prisma.product.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + name: 'New Product', + supplierPrice: expect.any(Prisma.Decimal), + typeProduct: { connect: { id: 'type-1' } }, + }), + }), + ); + expect( + String(prisma.product.create.mock.calls[0][0].data.supplierPrice), + ).toBe('150.5'); + expect(mockSyncConstructeurLinks).toHaveBeenCalledWith( + expect.any(Object), + '_ProductConstructeurs', + 'prod-1', + ['const-1'], + ); + expect(created.constructeurIds).toEqual(['const-1']); + }); + }); + + describe('update', () => { + it('updates product fields and synchronizes constructeurs when provided', async () => { + const dto: UpdateProductDto = { + supplierPrice: null, + constructeurIds: ['const-2'], + typeProductId: '', + }; + + prisma.constructeur.findMany.mockResolvedValue([{ id: 'const-2' }]); + prisma.product.findUnique.mockResolvedValue({ + id: 'prod-1', + name: 'Existing product', + reference: null, + supplierPrice: null, + typeProductId: null, + constructeurs: [{ id: 'const-2' }], + documents: [], + customFieldValues: [], + pieces: [], + composants: [], + typeProduct: null, + }); + + await service.update('prod-1', dto); + + expect(prisma.product.update).toHaveBeenCalledWith({ + where: { id: 'prod-1' }, + data: expect.objectContaining({ + supplierPrice: null, + typeProduct: { disconnect: true }, + }), + }); + expect(mockSyncConstructeurLinks).toHaveBeenCalledWith( + expect.any(Object), + '_ProductConstructeurs', + 'prod-1', + ['const-2'], + ); + }); + }); + + describe('remove', () => { + it('throws when product is still referenced', async () => { + prisma.piece.count.mockResolvedValue(1); + prisma.composant.count.mockResolvedValue(0); + prisma.document.count.mockResolvedValue(2); + + await expect(service.remove('prod-1')).rejects.toBeInstanceOf( + ConflictException, + ); + expect(prisma.product.delete).not.toHaveBeenCalled(); + }); + + it('deletes product when no references remain', async () => { + prisma.piece.count.mockResolvedValue(0); + prisma.composant.count.mockResolvedValue(0); + prisma.document.count.mockResolvedValue(0); + prisma.product.delete.mockResolvedValue(undefined); + + await service.remove('prod-1'); + + expect(prisma.product.delete).toHaveBeenCalledWith({ + where: { id: 'prod-1' }, + }); + }); + }); +}); diff --git a/src/products/products.service.ts b/src/products/products.service.ts new file mode 100644 index 0000000..ba58950 --- /dev/null +++ b/src/products/products.service.ts @@ -0,0 +1,322 @@ +import { + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; +import { CreateProductDto, UpdateProductDto } from '../shared/dto/product.dto'; +import { PRODUCT_WITH_RELATIONS_INCLUDE } from '../common/constants/product-includes'; +import { syncConstructeurLinks } from '../common/utils/constructeur-link.util'; +import { + ListProductsQueryDto, + ProductSortField, + SortDirection, +} from './dto/list-products.dto'; + +type ProductWithRelations = Prisma.ProductGetPayload<{ + include: typeof PRODUCT_WITH_RELATIONS_INCLUDE; +}>; + +@Injectable() +export class ProductsService { + private readonly allowedSortFields: ProductSortField[] = [ + ProductSortField.CREATED_AT, + ProductSortField.NAME, + ProductSortField.REFERENCE, + ProductSortField.SUPPLIER_PRICE, + ]; + + constructor(private readonly prisma: PrismaService) {} + + async list(params: ListProductsQueryDto) { + const { + q, + typeProductId, + constructeurId, + limit = 20, + offset = 0, + sort = ProductSortField.CREATED_AT, + dir = SortDirection.DESC, + } = params; + + const cappedLimit = Math.min(Math.max(limit, 1), 100); + const safeOffset = Math.max(offset, 0); + + const orderByField = this.allowedSortFields.includes(sort) + ? sort + : ProductSortField.CREATED_AT; + const orderByDir: SortDirection = + dir === SortDirection.ASC ? SortDirection.ASC : SortDirection.DESC; + + const where: Prisma.ProductWhereInput = {}; + + if (q?.trim()) { + const term = q.trim(); + where.OR = [ + { name: { contains: term, mode: 'insensitive' } }, + { reference: { contains: term, mode: 'insensitive' } }, + ]; + } + + if (typeProductId) { + where.typeProductId = typeProductId; + } + + if (constructeurId) { + where.constructeurs = { + some: { id: constructeurId }, + }; + } + + const [items, total] = await this.prisma.$transaction([ + this.prisma.product.findMany({ + where, + include: PRODUCT_WITH_RELATIONS_INCLUDE, + orderBy: { + [orderByField]: orderByDir, + }, + skip: safeOffset, + take: cappedLimit, + }), + this.prisma.product.count({ where }), + ]); + + return { + items: items.map((item) => this.mapProduct(item)), + total, + offset: safeOffset, + limit: cappedLimit, + }; + } + + async findOne(id: string) { + const product = await this.prisma.product.findUnique({ + where: { id }, + include: PRODUCT_WITH_RELATIONS_INCLUDE, + }); + + if (!product) { + throw new NotFoundException('Produit introuvable.'); + } + + return this.mapProduct(product); + } + + async create(createProductDto: CreateProductDto) { + try { + const data: Prisma.ProductCreateInput = { + name: createProductDto.name, + reference: createProductDto.reference ?? null, + supplierPrice: + createProductDto.supplierPrice === undefined || + createProductDto.supplierPrice === null + ? null + : new Prisma.Decimal(createProductDto.supplierPrice), + }; + + if (createProductDto.typeProductId) { + data.typeProduct = { + connect: { id: createProductDto.typeProductId }, + }; + } + + const constructeurIds = this.normalizeConstructeurIds( + createProductDto.constructeurIds, + ); + const resolvedConstructeurIds = + await this.resolveExistingConstructeurIds(constructeurIds); + + const created = await this.prisma.product.create({ + data, + include: PRODUCT_WITH_RELATIONS_INCLUDE, + }); + + let syncedConstructeurIds: string[] = []; + if (resolvedConstructeurIds.length > 0) { + syncedConstructeurIds = await syncConstructeurLinks( + this.prisma, + '_ProductConstructeurs', + created.id, + resolvedConstructeurIds, + ); + } + + const refreshed = await this.prisma.product.findUnique({ + where: { id: created.id }, + include: PRODUCT_WITH_RELATIONS_INCLUDE, + }); + + if (!refreshed) { + return this.mapProduct(created); + } + + const mapped = this.mapProduct(refreshed); + if (syncedConstructeurIds.length > 0) { + mapped.constructeurIds = [...syncedConstructeurIds]; + } + + return mapped; + } catch (error) { + this.handlePrismaError(error); + } + } + + async update(id: string, updateProductDto: UpdateProductDto) { + try { + const data: Prisma.ProductUpdateInput = {}; + + if (updateProductDto.name !== undefined) { + data.name = updateProductDto.name; + } + + if (updateProductDto.reference !== undefined) { + data.reference = updateProductDto.reference; + } + + if (updateProductDto.supplierPrice !== undefined) { + data.supplierPrice = + updateProductDto.supplierPrice === null + ? null + : new Prisma.Decimal(updateProductDto.supplierPrice); + } + + if (updateProductDto.typeProductId !== undefined) { + data.typeProduct = updateProductDto.typeProductId + ? { connect: { id: updateProductDto.typeProductId } } + : { disconnect: true }; + } + + let resolvedConstructeurIds: string[] | undefined; + if (updateProductDto.constructeurIds !== undefined) { + const constructeurIds = this.normalizeConstructeurIds( + updateProductDto.constructeurIds, + ); + resolvedConstructeurIds = + await this.resolveExistingConstructeurIds(constructeurIds); + } + + let syncedConstructeurIds: string[] | undefined; + + await this.prisma.$transaction(async (tx) => { + await tx.product.update({ + where: { id }, + data, + }); + + if (resolvedConstructeurIds !== undefined) { + syncedConstructeurIds = await syncConstructeurLinks( + tx, + '_ProductConstructeurs', + id, + resolvedConstructeurIds, + ); + } + }); + + const refreshed = await this.prisma.product.findUnique({ + where: { id }, + include: PRODUCT_WITH_RELATIONS_INCLUDE, + }); + + if (!refreshed) { + throw new NotFoundException('Produit introuvable.'); + } + + const mapped = this.mapProduct(refreshed); + if (syncedConstructeurIds) { + mapped.constructeurIds = [...syncedConstructeurIds]; + } + + return mapped; + } catch (error) { + this.handlePrismaError(error); + } + } + + async remove(id: string) { + const [pieceCount, componentCount, documentCount] = await Promise.all([ + this.prisma.piece.count({ + where: { productId: id }, + }), + this.prisma.composant.count({ + where: { productId: id }, + }), + this.prisma.document.count({ + where: { productId: id }, + }), + ]); + + const blockingReasons: string[] = []; + if (pieceCount > 0) { + blockingReasons.push(`${pieceCount} pièce${pieceCount > 1 ? 's' : ''}`); + } + if (componentCount > 0) { + blockingReasons.push( + `${componentCount} composant${componentCount > 1 ? 's' : ''}`, + ); + } + if (documentCount > 0) { + blockingReasons.push( + `${documentCount} document${documentCount > 1 ? 's' : ''}`, + ); + } + + if (blockingReasons.length > 0) { + throw new ConflictException( + `Impossible de supprimer ce produit car il est encore lié à ${blockingReasons.join( + ', ', + )}.`, + ); + } + + await this.prisma.product.delete({ + where: { id }, + }); + } + + private normalizeConstructeurIds(ids?: string[] | null): string[] { + if (!Array.isArray(ids)) { + return []; + } + return Array.from( + new Set( + ids + .map((value) => (typeof value === 'string' ? value.trim() : '')) + .filter((value) => value.length > 0), + ), + ); + } + + private async resolveExistingConstructeurIds(ids: string[]) { + if (ids.length === 0) { + 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 mapProduct(product: ProductWithRelations) { + return { + ...product, + constructeurIds: product.constructeurs.map((item) => item.id), + }; + } + + private handlePrismaError(error: unknown): never { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + if (error.code === 'P2002') { + throw new ConflictException('Un produit avec ce nom existe déjà.'); + } + if (error.code === 'P2025') { + throw new NotFoundException('Produit introuvable.'); + } + } + + throw error; + } +} diff --git a/src/shared/dto/composant.dto.ts b/src/shared/dto/composant.dto.ts index 9e3f37e..696365b 100644 --- a/src/shared/dto/composant.dto.ts +++ b/src/shared/dto/composant.dto.ts @@ -45,6 +45,10 @@ export class CreateComposantDto { @IsOptional() @IsObject() structure?: Record; + + @IsOptional() + @IsString() + productId?: string; } export class UpdateComposantDto { @@ -73,4 +77,9 @@ export class UpdateComposantDto { @IsOptional() @IsObject() structure?: Record; + + @IsOptional() + @Transform(({ value }) => (value === '' ? null : value)) + @IsString() + productId?: string | null; } diff --git a/src/shared/dto/custom-field.dto.ts b/src/shared/dto/custom-field.dto.ts index 8813340..ec76793 100644 --- a/src/shared/dto/custom-field.dto.ts +++ b/src/shared/dto/custom-field.dto.ts @@ -11,6 +11,7 @@ export enum CustomFieldEntityType { MACHINE = 'machine', COMPOSANT = 'composant', PIECE = 'piece', + PRODUCT = 'product', } export class CustomFieldEntityParamsDto { @@ -76,6 +77,10 @@ export class CreateCustomFieldValueDto { @IsOptional() @IsString() pieceId?: string; + + @IsOptional() + @IsString() + productId?: string; } export class UpdateCustomFieldValueDto { diff --git a/src/shared/dto/document.dto.ts b/src/shared/dto/document.dto.ts index ab0277e..8043f88 100644 --- a/src/shared/dto/document.dto.ts +++ b/src/shared/dto/document.dto.ts @@ -31,6 +31,10 @@ export class CreateDocumentDto { @IsOptional() @IsString() siteId?: string; + + @IsOptional() + @IsString() + productId?: string; } export class UpdateDocumentDto { @@ -57,4 +61,20 @@ export class UpdateDocumentDto { @IsOptional() @IsString() siteId?: string; + + @IsOptional() + @IsString() + machineId?: string; + + @IsOptional() + @IsString() + composantId?: string; + + @IsOptional() + @IsString() + pieceId?: string; + + @IsOptional() + @IsString() + productId?: string; } diff --git a/src/shared/dto/machine.dto.ts b/src/shared/dto/machine.dto.ts index 9fc77bf..38e4d0b 100644 --- a/src/shared/dto/machine.dto.ts +++ b/src/shared/dto/machine.dto.ts @@ -28,6 +28,10 @@ export class MachineComponentLinkPayloadDto { @IsString() composantId?: string; + @IsOptional() + @IsString() + productId?: string; + @IsOptional() @IsString() componentId?: string; @@ -97,6 +101,10 @@ export class MachinePieceLinkPayloadDto { @IsString() composantId?: string; + @IsOptional() + @IsString() + productId?: string; + @IsOptional() @IsString() parentLinkId?: string; @@ -142,6 +150,59 @@ export class MachinePieceLinkPayloadDto { overrides?: Record; } +export class MachineProductLinkPayloadDto { + @IsOptional() + @IsString() + id?: string; + + @IsOptional() + @IsString() + linkId?: string; + + @IsString() + requirementId: string; + + @IsOptional() + @IsString() + productId?: string; + + @IsOptional() + @IsString() + typeProductId?: string; + + @IsOptional() + @IsString() + parentLinkId?: string; + + @IsOptional() + @IsString() + parentComponentLinkId?: string; + + @IsOptional() + @IsString() + parentPieceLinkId?: string; + + @IsOptional() + @IsString() + parentRequirementId?: string; + + @IsOptional() + @IsString() + parentComponentRequirementId?: string; + + @IsOptional() + @IsString() + parentPieceRequirementId?: string; + + @IsOptional() + @IsString() + parentMachineComponentRequirementId?: string; + + @IsOptional() + @IsString() + parentMachinePieceRequirementId?: string; +} + export class CreateMachineDto { @IsString() name: string; @@ -177,6 +238,12 @@ export class CreateMachineDto { @ValidateNested({ each: true }) @Type(() => MachinePieceLinkPayloadDto) pieceLinks?: MachinePieceLinkPayloadDto[]; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => MachineProductLinkPayloadDto) + productLinks?: MachineProductLinkPayloadDto[]; } export class UpdateMachineDto { @@ -214,7 +281,14 @@ export class ReconfigureMachineDto { @ValidateNested({ each: true }) @Type(() => MachinePieceLinkPayloadDto) pieceLinks?: MachinePieceLinkPayloadDto[]; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => MachineProductLinkPayloadDto) + productLinks?: MachineProductLinkPayloadDto[]; } export type MachineComponentLinkInput = MachineComponentLinkPayloadDto; export type MachinePieceLinkInput = MachinePieceLinkPayloadDto; +export type MachineProductLinkInput = MachineProductLinkPayloadDto; diff --git a/src/shared/dto/piece.dto.ts b/src/shared/dto/piece.dto.ts index fdc6ddf..971acb7 100644 --- a/src/shared/dto/piece.dto.ts +++ b/src/shared/dto/piece.dto.ts @@ -35,6 +35,10 @@ export class CreatePieceDto { @IsOptional() @IsString() typeMachinePieceRequirementId?: string; + + @IsOptional() + @IsString() + productId?: string; } export class UpdatePieceDto { @@ -60,4 +64,9 @@ export class UpdatePieceDto { @IsOptional() @IsString() typePieceId?: string; + + @IsOptional() + @Transform(({ value }) => (value === '' ? null : value)) + @IsString() + productId?: string | null; } diff --git a/src/shared/dto/product.dto.ts b/src/shared/dto/product.dto.ts new file mode 100644 index 0000000..f506f69 --- /dev/null +++ b/src/shared/dto/product.dto.ts @@ -0,0 +1,28 @@ +import { IsArray, IsNumber, IsOptional, IsString } from 'class-validator'; +import { Transform } from 'class-transformer'; +import { PartialType } from '@nestjs/mapped-types'; + +export class CreateProductDto { + @IsString() + name!: string; + + @IsOptional() + @IsString() + reference?: string; + + @IsOptional() + @Transform(({ value }) => (value === '' ? null : value)) + @IsNumber({}, { message: 'supplierPrice must be a valid number' }) + supplierPrice?: number | null; + + @IsOptional() + @IsString() + typeProductId?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + constructeurIds?: string[]; +} + +export class UpdateProductDto extends PartialType(CreateProductDto) {} diff --git a/src/shared/dto/type.dto.ts b/src/shared/dto/type.dto.ts index 5380e58..5129750 100644 --- a/src/shared/dto/type.dto.ts +++ b/src/shared/dto/type.dto.ts @@ -122,6 +122,35 @@ export class TypeMachinePieceRequirementDto { orderIndex?: number; } +export class TypeMachineProductRequirementDto { + @IsString() + typeProductId: string; + + @IsOptional() + @IsString() + label?: string; + + @IsOptional() + @IsInt() + minCount?: number; + + @IsOptional() + @IsInt() + maxCount?: number | null; + + @IsOptional() + @IsBoolean() + required?: boolean; + + @IsOptional() + @IsBoolean() + allowNewModels?: boolean; + + @IsOptional() + @IsInt() + orderIndex?: number; +} + export class CreateTypeMachineDto { @IsString() name: string; @@ -161,6 +190,12 @@ export class CreateTypeMachineDto { @ValidateNested({ each: true }) @Type(() => TypeMachinePieceRequirementDto) pieceRequirements?: TypeMachinePieceRequirementDto[]; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => TypeMachineProductRequirementDto) + productRequirements?: TypeMachineProductRequirementDto[]; } export class UpdateTypeMachineDto { @@ -203,6 +238,12 @@ export class UpdateTypeMachineDto { @ValidateNested({ each: true }) @Type(() => TypeMachinePieceRequirementDto) pieceRequirements?: TypeMachinePieceRequirementDto[]; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => TypeMachineProductRequirementDto) + productRequirements?: TypeMachineProductRequirementDto[]; } export class CreateTypeComposantDto { diff --git a/src/shared/schemas/inventory.ts b/src/shared/schemas/inventory.ts index dd4f3bc..b59422c 100644 --- a/src/shared/schemas/inventory.ts +++ b/src/shared/schemas/inventory.ts @@ -2,7 +2,9 @@ import { normalizeComponentModelStructure } from '../../component-models/structu import type { ComponentModelStructure, PieceModelCustomField, + PieceModelProduct, PieceModelStructure, + ProductModelStructure, } from '../types/inventory'; export class ComponentModelStructureValidationError extends Error { @@ -28,6 +30,67 @@ function sanitizeOptionalString(value: unknown): string | undefined { return String(value); } +function validateProducts( + products: ComponentModelStructure['products'], +): ComponentModelStructure['products'] { + return products.map((product, index) => { + if ('typeProductId' in product) { + const typeProductId = assertString( + product.typeProductId, + `products[${index}].typeProductId`, + ).trim(); + if (!typeProductId) { + throw new ComponentModelStructureValidationError( + `products[${index}].typeProductId ne peut pas être vide`, + ); + } + const payload: ComponentModelStructure['products'][number] = { + typeProductId, + role: sanitizeOptionalString(product.role), + }; + if ('familyCode' in product && product.familyCode) { + const familyCode = assertString( + product.familyCode, + `products[${index}].familyCode`, + ).trim(); + if (familyCode) { + (payload as Record).familyCode = familyCode; + } + } + if ('reference' in product && product.reference) { + (payload as Record).reference = sanitizeOptionalString( + product.reference, + ); + } + if ('typeProductLabel' in product && product.typeProductLabel) { + (payload as Record).typeProductLabel = + sanitizeOptionalString(product.typeProductLabel); + } + return payload; + } + + if ('familyCode' in product) { + const familyCode = assertString( + product.familyCode, + `products[${index}].familyCode`, + ).trim(); + if (!familyCode) { + throw new ComponentModelStructureValidationError( + `products[${index}].familyCode ne peut pas être vide`, + ); + } + return { + familyCode, + role: sanitizeOptionalString(product.role), + }; + } + + throw new ComponentModelStructureValidationError( + `products[${index}] doit définir "familyCode" ou "typeProductId"`, + ); + }); +} + function validatePieces( pieces: ComponentModelStructure['pieces'], ): ComponentModelStructure['pieces'] { @@ -148,6 +211,7 @@ export const ComponentModelStructureSchema = { const normalized = normalizeComponentModelStructure(input); return { + products: validateProducts(normalized.products), pieces: validatePieces(normalized.pieces), customFields: validateCustomFields(normalized.customFields), subcomponents: validateSubcomponents(normalized.subcomponents), @@ -230,10 +294,57 @@ function normalizePieceModelCustomFields( return normalized; } +function normalizePieceModelProducts(products: unknown): PieceModelProduct[] { + if (!Array.isArray(products)) { + return []; + } + + return products.map((entry, index) => { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + throw new PieceModelStructureValidationError( + `products[${index}] doit être un objet`, + ); + } + + const record = entry as Record; + + const rawTypeProductId = + typeof record.typeProductId === 'string' + ? record.typeProductId + : typeof (record.typeProduct as { id?: unknown })?.id === 'string' + ? (record.typeProduct as { id: string }).id + : undefined; + const typeProductId = rawTypeProductId ? rawTypeProductId.trim() : ''; + + const rawFamilyCode = + typeof record.familyCode === 'string' + ? record.familyCode + : typeof (record.typeProduct as { code?: unknown })?.code === 'string' + ? (record.typeProduct as { code: string }).code + : undefined; + const familyCode = rawFamilyCode ? rawFamilyCode.trim() : ''; + + const rawRole = typeof record.role === 'string' ? record.role.trim() : ''; + const role = rawRole ? rawRole : undefined; + + if (typeProductId) { + return role ? { typeProductId, role } : { typeProductId }; + } + + if (familyCode) { + return role ? { familyCode, role } : { familyCode }; + } + + throw new PieceModelStructureValidationError( + `products[${index}] doit définir "familyCode" ou "typeProductId"`, + ); + }); +} + export const PieceModelStructureSchema = { parse(input: unknown): PieceModelStructure { if (input === undefined || input === null) { - return { customFields: [] }; + return { customFields: [], products: [] }; } if (typeof input !== 'object' || Array.isArray(input)) { @@ -250,6 +361,11 @@ export const PieceModelStructureSchema = { structure.customFields = customFields; } + const products = normalizePieceModelProducts(record.products); + if (products.length > 0 || 'products' in record) { + structure.products = products; + } + const normalizedTypePiece = toStringOrNull(record.typePieceId); if (normalizedTypePiece) { structure.typePieceId = normalizedTypePiece; @@ -260,3 +376,34 @@ export const PieceModelStructureSchema = { return structure; }, }; + +export class ProductModelStructureValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'ProductModelStructureValidationError'; + } +} + +export const ProductModelStructureSchema = { + parse(input: unknown): ProductModelStructure { + if (input === undefined || input === null) { + return { customFields: [] }; + } + + if (typeof input !== 'object' || Array.isArray(input)) { + throw new ProductModelStructureValidationError( + 'La structure de produit doit être un objet JSON.', + ); + } + + const record = input as Record; + const structure: ProductModelStructure = { ...record }; + const customFields = normalizePieceModelCustomFields(record.customFields); + + if (customFields.length > 0 || 'customFields' in record) { + structure.customFields = customFields; + } + + return structure; + }, +}; diff --git a/src/shared/types/inventory.ts b/src/shared/types/inventory.ts index 2b7aad0..7a12d81 100644 --- a/src/shared/types/inventory.ts +++ b/src/shared/types/inventory.ts @@ -16,6 +16,20 @@ export type ComponentModelStructure = { } >; + /** + * Familles de produits autorisées (ou identifiant de famille) — pas de quantité ici. + */ + products: Array< + | { + familyCode: string; + role?: string; + } + | { + typeProductId: string; + role?: string; + } + >; + /** * Valeurs par défaut au niveau "modèle" (libres, mais clé obligatoire). */ @@ -48,7 +62,25 @@ export type PieceModelCustomField = { options?: unknown; }; +export type PieceModelProduct = + | { + familyCode: string; + role?: string; + } + | { + typeProductId: string; + role?: string; + }; + export type PieceModelStructure = { customFields?: PieceModelCustomField[]; + products?: PieceModelProduct[]; + [key: string]: unknown; +}; + +export type ProductModelCustomField = PieceModelCustomField; + +export type ProductModelStructure = { + customFields?: ProductModelCustomField[]; [key: string]: unknown; }; diff --git a/src/types/services/type-machine.service.ts b/src/types/services/type-machine.service.ts index 355f525..511e387 100644 --- a/src/types/services/type-machine.service.ts +++ b/src/types/services/type-machine.service.ts @@ -1,4 +1,5 @@ -import { Injectable } from '@nestjs/common'; +import { ConflictException, Injectable } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; import { TypeMachinesRepository } from '../../common/repositories/type-machines.repository'; import { TYPE_MACHINE_DEFAULT_INCLUDE, @@ -17,7 +18,12 @@ export class TypeMachineService { async create(dto: CreateTypeMachineDto) { const data = TypeMachineMapper.toCreateInput(dto); - return this.repository.create(data, TYPE_MACHINE_DEFAULT_INCLUDE); + try { + return await this.repository.create(data, TYPE_MACHINE_DEFAULT_INCLUDE); + } catch (error) { + this.handlePrismaError(error); + throw error; + } } async findAll() { @@ -53,7 +59,24 @@ export class TypeMachineService { await this.repository.createPieceRequirements(id, requirements); } - return this.repository.update(id, updateData, TYPE_MACHINE_DEFAULT_INCLUDE); + if (dto.productRequirements !== undefined) { + await this.repository.deleteProductRequirements(id); + const requirements = TypeMachineMapper.mapProductRequirementInputs( + dto.productRequirements, + ); + await this.repository.createProductRequirements(id, requirements); + } + + try { + return await this.repository.update( + id, + updateData, + TYPE_MACHINE_DEFAULT_INCLUDE, + ); + } catch (error) { + this.handlePrismaError(error); + throw error; + } } async remove(id: string) { @@ -69,4 +92,19 @@ export class TypeMachineService { await this.repository.deleteCustomFields(id); return this.repository.delete(id); } + + private handlePrismaError(error: unknown): never { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + if ( + error.code === 'P2002' && + Array.isArray(error.meta?.target) && + error.meta.target.includes('name') + ) { + throw new ConflictException( + 'Nom déjà utilisé pour un type de machine.', + ); + } + } + throw error; + } }