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

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