feat: add product domain and machine integration
- 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
This commit is contained in:
75
prisma/migrations/20251108120000_add_products/migration.sql
Normal file
75
prisma/migrations/20251108120000_add_products/migration.sql
Normal file
@@ -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;
|
||||
@@ -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");
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
) {
|
||||
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<string, string>;
|
||||
}) {
|
||||
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',
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
|
||||
32
src/common/constants/product-includes.ts
Normal file
32
src/common/constants/product-includes.ts
Normal file
@@ -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;
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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!,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -12,6 +12,7 @@ const DEFAULT_ORIENTATIONS: Record<string, LinkOrientation> = {
|
||||
_MachineConstructeurs: { parentColumn: 'A', constructeurColumn: 'B' },
|
||||
_ComposantConstructeurs: { parentColumn: 'A', constructeurColumn: 'B' },
|
||||
_PieceConstructeurs: { parentColumn: 'A', constructeurColumn: 'B' },
|
||||
_ProductConstructeurs: { parentColumn: 'A', constructeurColumn: 'B' },
|
||||
};
|
||||
|
||||
const sanitizeTableName = (tableName: string): string => {
|
||||
@@ -22,7 +23,12 @@ const sanitizeTableName = (tableName: string): string => {
|
||||
};
|
||||
|
||||
const ORIENTATION_CACHE = new Map<string, LinkOrientation>();
|
||||
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(
|
||||
|
||||
@@ -76,6 +76,59 @@ export function normalizeComponentModelStructure(
|
||||
},
|
||||
);
|
||||
|
||||
const products = toArray((structure as any)?.products).map((product) => {
|
||||
const candidate = product as Record<string, unknown> | 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<string, unknown>).familyCode = familyCode;
|
||||
}
|
||||
}
|
||||
|
||||
if (candidate?.typeProductLabel) {
|
||||
const label = ensureString(candidate.typeProductLabel).trim();
|
||||
if (label) {
|
||||
(normalized as Record<string, unknown>).typeProductLabel = label;
|
||||
}
|
||||
}
|
||||
|
||||
if (candidate?.reference) {
|
||||
const reference = ensureString(candidate.reference).trim();
|
||||
if (reference) {
|
||||
(normalized as Record<string, unknown>).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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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<any> = {}) =>
|
||||
({
|
||||
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<string, number>,
|
||||
pieceUsage: Record<string, number>,
|
||||
) => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, CreatedComponentLinkInfo>;
|
||||
byComponentId: Map<string, CreatedComponentLinkInfo[]>;
|
||||
byRequirementId: Map<string, CreatedComponentLinkInfo[]>;
|
||||
productUsage: Map<string, number>;
|
||||
autoPieceProductUsage: Map<string, number>;
|
||||
};
|
||||
|
||||
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<string, MachinePieceLinkInput[]>();
|
||||
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<string, unknown>;
|
||||
}
|
||||
|
||||
private validateProductRequirements(
|
||||
productRequirementMap: Map<string, ProductRequirementWithType>,
|
||||
componentUsage: Map<string, number>,
|
||||
pieceUsage: Map<string, number>,
|
||||
directUsage: Map<string, number>,
|
||||
productLinksByRequirement: Map<string, MachineProductLinkInput[]>,
|
||||
) {
|
||||
if (productRequirementMap.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const totalUsage = new Map<string, number>();
|
||||
const accumulate = (source: Map<string, number>) => {
|
||||
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<string, CreatedComponentLinkInfo>,
|
||||
byComponentId: Map<string, CreatedComponentLinkInfo[]>,
|
||||
componentMap: Map<string, ComponentWithType>,
|
||||
productUsage: Map<string, number>,
|
||||
autoPieceProductUsage: Map<string, number>,
|
||||
) {
|
||||
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<string, ComponentRequirementWithType>,
|
||||
componentLinks: MachineComponentLinkInput[],
|
||||
) {
|
||||
): Promise<ComponentLinkIndex> {
|
||||
const links = Array.isArray(componentLinks) ? componentLinks : [];
|
||||
if (links.length === 0) {
|
||||
return {
|
||||
createdLinks: new Map<string, CreatedComponentLinkInfo>(),
|
||||
byComponentId: new Map<string, CreatedComponentLinkInfo[]>(),
|
||||
byRequirementId: new Map<string, CreatedComponentLinkInfo[]>(),
|
||||
productUsage: new Map<string, number>(),
|
||||
autoPieceProductUsage: new Map<string, number>(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<string, ComponentWithType>(
|
||||
components.map((component) => [component.id, component]),
|
||||
@@ -1412,6 +1653,8 @@ export class MachinesService {
|
||||
const createdLinks = new Map<string, CreatedComponentLinkInfo>();
|
||||
const byComponentId = new Map<string, CreatedComponentLinkInfo[]>();
|
||||
const byRequirementId = new Map<string, CreatedComponentLinkInfo[]>();
|
||||
const productUsage = new Map<string, number>();
|
||||
const autoPieceProductUsage = new Map<string, number>();
|
||||
|
||||
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<string, PieceRequirementWithType>,
|
||||
pieceLinks: MachinePieceLinkInput[],
|
||||
componentLinkIndex: {
|
||||
createdLinks: Map<string, CreatedComponentLinkInfo>;
|
||||
byComponentId: Map<string, CreatedComponentLinkInfo[]>;
|
||||
byRequirementId: Map<string, CreatedComponentLinkInfo[]>;
|
||||
},
|
||||
) {
|
||||
componentLinkIndex: ComponentLinkIndex,
|
||||
): Promise<{
|
||||
createdLinks: Map<string, CreatedPieceLinkInfo>;
|
||||
productUsage: Map<string, number>;
|
||||
}> {
|
||||
const links = Array.isArray(pieceLinks) ? pieceLinks : [];
|
||||
if (links.length === 0) {
|
||||
return new Map<string, CreatedPieceLinkInfo>();
|
||||
return {
|
||||
createdLinks: new Map<string, CreatedPieceLinkInfo>(),
|
||||
productUsage: new Map<string, number>(),
|
||||
};
|
||||
}
|
||||
|
||||
const pieceIds = new Set<string>();
|
||||
@@ -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<string, PieceWithType>(
|
||||
pieces.map((piece) => [piece.id, piece]),
|
||||
@@ -1643,6 +1914,7 @@ export class MachinesService {
|
||||
}
|
||||
|
||||
const createdLinks = new Map<string, CreatedPieceLinkInfo>();
|
||||
const productUsage = new Map<string, number>();
|
||||
|
||||
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<string, ProductRequirementWithType>,
|
||||
productLinks: MachineProductLinkInput[],
|
||||
): Promise<{
|
||||
createdLinks: Map<string, CreatedProductLinkInfo>;
|
||||
productUsage: Map<string, number>;
|
||||
}> {
|
||||
const links = Array.isArray(productLinks) ? productLinks : [];
|
||||
if (links.length === 0) {
|
||||
return {
|
||||
createdLinks: new Map<string, CreatedProductLinkInfo>(),
|
||||
productUsage: new Map<string, number>(),
|
||||
};
|
||||
}
|
||||
|
||||
const productIds = new Set<string>();
|
||||
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<string, CreatedProductLinkInfo>();
|
||||
const productUsage = new Map<string, number>();
|
||||
|
||||
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<string, CreatedComponentLinkInfo>;
|
||||
byComponentId: Map<string, CreatedComponentLinkInfo[]>;
|
||||
byRequirementId: Map<string, CreatedComponentLinkInfo[]>;
|
||||
},
|
||||
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<typeof machine> => Boolean(machine),
|
||||
return enriched.filter((machine): machine is NonNullable<typeof machine> =>
|
||||
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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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];
|
||||
|
||||
49
src/products/dto/list-products.dto.ts
Normal file
49
src/products/dto/list-products.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
43
src/products/products.controller.ts
Normal file
43
src/products/products.controller.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
9
src/products/products.module.ts
Normal file
9
src/products/products.module.ts
Normal file
@@ -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 {}
|
||||
240
src/products/products.service.spec.ts
Normal file
240
src/products/products.service.spec.ts
Normal file
@@ -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>(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' },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
322
src/products/products.service.ts
Normal file
322
src/products/products.service.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,10 @@ export class CreateComposantDto {
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
structure?: Record<string, any>;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
productId?: string;
|
||||
}
|
||||
|
||||
export class UpdateComposantDto {
|
||||
@@ -73,4 +77,9 @@ export class UpdateComposantDto {
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
structure?: Record<string, any>;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (value === '' ? null : value))
|
||||
@IsString()
|
||||
productId?: string | null;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
28
src/shared/dto/product.dto.ts
Normal file
28
src/shared/dto/product.dto.ts
Normal file
@@ -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) {}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string, unknown>).familyCode = familyCode;
|
||||
}
|
||||
}
|
||||
if ('reference' in product && product.reference) {
|
||||
(payload as Record<string, unknown>).reference = sanitizeOptionalString(
|
||||
product.reference,
|
||||
);
|
||||
}
|
||||
if ('typeProductLabel' in product && product.typeProductLabel) {
|
||||
(payload as Record<string, unknown>).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<string, unknown>;
|
||||
|
||||
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<string, unknown>;
|
||||
const structure: ProductModelStructure = { ...record };
|
||||
const customFields = normalizePieceModelCustomFields(record.customFields);
|
||||
|
||||
if (customFields.length > 0 || 'customFields' in record) {
|
||||
structure.customFields = customFields;
|
||||
}
|
||||
|
||||
return structure;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user