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:
Matthieu
2025-11-05 15:34:42 +01:00
parent e81f71e3e7
commit 6cf2b566ce
38 changed files with 2601 additions and 120 deletions

View 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;

View File

@@ -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");

View File

@@ -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")
}

View File

@@ -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 dapprovisionnement (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 dapprovisionnement (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 dapprovisionnement (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',

View File

@@ -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],

View File

@@ -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,

View 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;

View File

@@ -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,
});
});
});

View File

@@ -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!,
}));
}
}

View File

@@ -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 },

View File

@@ -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(

View File

@@ -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,
};

View File

@@ -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,
});
});
});

View File

@@ -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;

View File

@@ -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.",

View File

@@ -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);

View File

@@ -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,
},
});

View File

@@ -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);
});
});
});

View File

@@ -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({

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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,
});
});
});

View File

@@ -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 dun 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];

View 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;
}

View 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);
}
}

View 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 {}

View 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' },
});
});
});
});

View 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;
}
}

View File

@@ -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;
}

View File

@@ -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 {

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;
}

View 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) {}

View File

@@ -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 {

View File

@@ -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;
},
};

View File

@@ -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;
};

View File

@@ -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;
}
}