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:
@@ -45,6 +45,10 @@ export class CreateComposantDto {
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
structure?: Record<string, any>;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
productId?: string;
|
||||
}
|
||||
|
||||
export class UpdateComposantDto {
|
||||
@@ -73,4 +77,9 @@ export class UpdateComposantDto {
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
structure?: Record<string, any>;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (value === '' ? null : value))
|
||||
@IsString()
|
||||
productId?: string | null;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ export enum CustomFieldEntityType {
|
||||
MACHINE = 'machine',
|
||||
COMPOSANT = 'composant',
|
||||
PIECE = 'piece',
|
||||
PRODUCT = 'product',
|
||||
}
|
||||
|
||||
export class CustomFieldEntityParamsDto {
|
||||
@@ -76,6 +77,10 @@ export class CreateCustomFieldValueDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
pieceId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
productId?: string;
|
||||
}
|
||||
|
||||
export class UpdateCustomFieldValueDto {
|
||||
|
||||
@@ -31,6 +31,10 @@ export class CreateDocumentDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
siteId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
productId?: string;
|
||||
}
|
||||
|
||||
export class UpdateDocumentDto {
|
||||
@@ -57,4 +61,20 @@ export class UpdateDocumentDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
siteId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
machineId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
composantId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
pieceId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
productId?: string;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,10 @@ export class MachineComponentLinkPayloadDto {
|
||||
@IsString()
|
||||
composantId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
productId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
componentId?: string;
|
||||
@@ -97,6 +101,10 @@ export class MachinePieceLinkPayloadDto {
|
||||
@IsString()
|
||||
composantId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
productId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
parentLinkId?: string;
|
||||
@@ -142,6 +150,59 @@ export class MachinePieceLinkPayloadDto {
|
||||
overrides?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class MachineProductLinkPayloadDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
id?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
linkId?: string;
|
||||
|
||||
@IsString()
|
||||
requirementId: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
productId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
typeProductId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
parentLinkId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
parentComponentLinkId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
parentPieceLinkId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
parentRequirementId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
parentComponentRequirementId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
parentPieceRequirementId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
parentMachineComponentRequirementId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
parentMachinePieceRequirementId?: string;
|
||||
}
|
||||
|
||||
export class CreateMachineDto {
|
||||
@IsString()
|
||||
name: string;
|
||||
@@ -177,6 +238,12 @@ export class CreateMachineDto {
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => MachinePieceLinkPayloadDto)
|
||||
pieceLinks?: MachinePieceLinkPayloadDto[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => MachineProductLinkPayloadDto)
|
||||
productLinks?: MachineProductLinkPayloadDto[];
|
||||
}
|
||||
|
||||
export class UpdateMachineDto {
|
||||
@@ -214,7 +281,14 @@ export class ReconfigureMachineDto {
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => MachinePieceLinkPayloadDto)
|
||||
pieceLinks?: MachinePieceLinkPayloadDto[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => MachineProductLinkPayloadDto)
|
||||
productLinks?: MachineProductLinkPayloadDto[];
|
||||
}
|
||||
|
||||
export type MachineComponentLinkInput = MachineComponentLinkPayloadDto;
|
||||
export type MachinePieceLinkInput = MachinePieceLinkPayloadDto;
|
||||
export type MachineProductLinkInput = MachineProductLinkPayloadDto;
|
||||
|
||||
@@ -35,6 +35,10 @@ export class CreatePieceDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
typeMachinePieceRequirementId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
productId?: string;
|
||||
}
|
||||
|
||||
export class UpdatePieceDto {
|
||||
@@ -60,4 +64,9 @@ export class UpdatePieceDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
typePieceId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (value === '' ? null : value))
|
||||
@IsString()
|
||||
productId?: string | null;
|
||||
}
|
||||
|
||||
28
src/shared/dto/product.dto.ts
Normal file
28
src/shared/dto/product.dto.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { IsArray, IsNumber, IsOptional, IsString } from 'class-validator';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
|
||||
export class CreateProductDto {
|
||||
@IsString()
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
reference?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (value === '' ? null : value))
|
||||
@IsNumber({}, { message: 'supplierPrice must be a valid number' })
|
||||
supplierPrice?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
typeProductId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
constructeurIds?: string[];
|
||||
}
|
||||
|
||||
export class UpdateProductDto extends PartialType(CreateProductDto) {}
|
||||
@@ -122,6 +122,35 @@ export class TypeMachinePieceRequirementDto {
|
||||
orderIndex?: number;
|
||||
}
|
||||
|
||||
export class TypeMachineProductRequirementDto {
|
||||
@IsString()
|
||||
typeProductId: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
label?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
minCount?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
maxCount?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
required?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
allowNewModels?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
orderIndex?: number;
|
||||
}
|
||||
|
||||
export class CreateTypeMachineDto {
|
||||
@IsString()
|
||||
name: string;
|
||||
@@ -161,6 +190,12 @@ export class CreateTypeMachineDto {
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => TypeMachinePieceRequirementDto)
|
||||
pieceRequirements?: TypeMachinePieceRequirementDto[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => TypeMachineProductRequirementDto)
|
||||
productRequirements?: TypeMachineProductRequirementDto[];
|
||||
}
|
||||
|
||||
export class UpdateTypeMachineDto {
|
||||
@@ -203,6 +238,12 @@ export class UpdateTypeMachineDto {
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => TypeMachinePieceRequirementDto)
|
||||
pieceRequirements?: TypeMachinePieceRequirementDto[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => TypeMachineProductRequirementDto)
|
||||
productRequirements?: TypeMachineProductRequirementDto[];
|
||||
}
|
||||
|
||||
export class CreateTypeComposantDto {
|
||||
|
||||
@@ -2,7 +2,9 @@ import { normalizeComponentModelStructure } from '../../component-models/structu
|
||||
import type {
|
||||
ComponentModelStructure,
|
||||
PieceModelCustomField,
|
||||
PieceModelProduct,
|
||||
PieceModelStructure,
|
||||
ProductModelStructure,
|
||||
} from '../types/inventory';
|
||||
|
||||
export class ComponentModelStructureValidationError extends Error {
|
||||
@@ -28,6 +30,67 @@ function sanitizeOptionalString(value: unknown): string | undefined {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function validateProducts(
|
||||
products: ComponentModelStructure['products'],
|
||||
): ComponentModelStructure['products'] {
|
||||
return products.map((product, index) => {
|
||||
if ('typeProductId' in product) {
|
||||
const typeProductId = assertString(
|
||||
product.typeProductId,
|
||||
`products[${index}].typeProductId`,
|
||||
).trim();
|
||||
if (!typeProductId) {
|
||||
throw new ComponentModelStructureValidationError(
|
||||
`products[${index}].typeProductId ne peut pas être vide`,
|
||||
);
|
||||
}
|
||||
const payload: ComponentModelStructure['products'][number] = {
|
||||
typeProductId,
|
||||
role: sanitizeOptionalString(product.role),
|
||||
};
|
||||
if ('familyCode' in product && product.familyCode) {
|
||||
const familyCode = assertString(
|
||||
product.familyCode,
|
||||
`products[${index}].familyCode`,
|
||||
).trim();
|
||||
if (familyCode) {
|
||||
(payload as Record<string, unknown>).familyCode = familyCode;
|
||||
}
|
||||
}
|
||||
if ('reference' in product && product.reference) {
|
||||
(payload as Record<string, unknown>).reference = sanitizeOptionalString(
|
||||
product.reference,
|
||||
);
|
||||
}
|
||||
if ('typeProductLabel' in product && product.typeProductLabel) {
|
||||
(payload as Record<string, unknown>).typeProductLabel =
|
||||
sanitizeOptionalString(product.typeProductLabel);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
if ('familyCode' in product) {
|
||||
const familyCode = assertString(
|
||||
product.familyCode,
|
||||
`products[${index}].familyCode`,
|
||||
).trim();
|
||||
if (!familyCode) {
|
||||
throw new ComponentModelStructureValidationError(
|
||||
`products[${index}].familyCode ne peut pas être vide`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
familyCode,
|
||||
role: sanitizeOptionalString(product.role),
|
||||
};
|
||||
}
|
||||
|
||||
throw new ComponentModelStructureValidationError(
|
||||
`products[${index}] doit définir "familyCode" ou "typeProductId"`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function validatePieces(
|
||||
pieces: ComponentModelStructure['pieces'],
|
||||
): ComponentModelStructure['pieces'] {
|
||||
@@ -148,6 +211,7 @@ export const ComponentModelStructureSchema = {
|
||||
const normalized = normalizeComponentModelStructure(input);
|
||||
|
||||
return {
|
||||
products: validateProducts(normalized.products),
|
||||
pieces: validatePieces(normalized.pieces),
|
||||
customFields: validateCustomFields(normalized.customFields),
|
||||
subcomponents: validateSubcomponents(normalized.subcomponents),
|
||||
@@ -230,10 +294,57 @@ function normalizePieceModelCustomFields(
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizePieceModelProducts(products: unknown): PieceModelProduct[] {
|
||||
if (!Array.isArray(products)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return products.map((entry, index) => {
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
||||
throw new PieceModelStructureValidationError(
|
||||
`products[${index}] doit être un objet`,
|
||||
);
|
||||
}
|
||||
|
||||
const record = entry as Record<string, unknown>;
|
||||
|
||||
const rawTypeProductId =
|
||||
typeof record.typeProductId === 'string'
|
||||
? record.typeProductId
|
||||
: typeof (record.typeProduct as { id?: unknown })?.id === 'string'
|
||||
? (record.typeProduct as { id: string }).id
|
||||
: undefined;
|
||||
const typeProductId = rawTypeProductId ? rawTypeProductId.trim() : '';
|
||||
|
||||
const rawFamilyCode =
|
||||
typeof record.familyCode === 'string'
|
||||
? record.familyCode
|
||||
: typeof (record.typeProduct as { code?: unknown })?.code === 'string'
|
||||
? (record.typeProduct as { code: string }).code
|
||||
: undefined;
|
||||
const familyCode = rawFamilyCode ? rawFamilyCode.trim() : '';
|
||||
|
||||
const rawRole = typeof record.role === 'string' ? record.role.trim() : '';
|
||||
const role = rawRole ? rawRole : undefined;
|
||||
|
||||
if (typeProductId) {
|
||||
return role ? { typeProductId, role } : { typeProductId };
|
||||
}
|
||||
|
||||
if (familyCode) {
|
||||
return role ? { familyCode, role } : { familyCode };
|
||||
}
|
||||
|
||||
throw new PieceModelStructureValidationError(
|
||||
`products[${index}] doit définir "familyCode" ou "typeProductId"`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export const PieceModelStructureSchema = {
|
||||
parse(input: unknown): PieceModelStructure {
|
||||
if (input === undefined || input === null) {
|
||||
return { customFields: [] };
|
||||
return { customFields: [], products: [] };
|
||||
}
|
||||
|
||||
if (typeof input !== 'object' || Array.isArray(input)) {
|
||||
@@ -250,6 +361,11 @@ export const PieceModelStructureSchema = {
|
||||
structure.customFields = customFields;
|
||||
}
|
||||
|
||||
const products = normalizePieceModelProducts(record.products);
|
||||
if (products.length > 0 || 'products' in record) {
|
||||
structure.products = products;
|
||||
}
|
||||
|
||||
const normalizedTypePiece = toStringOrNull(record.typePieceId);
|
||||
if (normalizedTypePiece) {
|
||||
structure.typePieceId = normalizedTypePiece;
|
||||
@@ -260,3 +376,34 @@ export const PieceModelStructureSchema = {
|
||||
return structure;
|
||||
},
|
||||
};
|
||||
|
||||
export class ProductModelStructureValidationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'ProductModelStructureValidationError';
|
||||
}
|
||||
}
|
||||
|
||||
export const ProductModelStructureSchema = {
|
||||
parse(input: unknown): ProductModelStructure {
|
||||
if (input === undefined || input === null) {
|
||||
return { customFields: [] };
|
||||
}
|
||||
|
||||
if (typeof input !== 'object' || Array.isArray(input)) {
|
||||
throw new ProductModelStructureValidationError(
|
||||
'La structure de produit doit être un objet JSON.',
|
||||
);
|
||||
}
|
||||
|
||||
const record = input as Record<string, unknown>;
|
||||
const structure: ProductModelStructure = { ...record };
|
||||
const customFields = normalizePieceModelCustomFields(record.customFields);
|
||||
|
||||
if (customFields.length > 0 || 'customFields' in record) {
|
||||
structure.customFields = customFields;
|
||||
}
|
||||
|
||||
return structure;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -16,6 +16,20 @@ export type ComponentModelStructure = {
|
||||
}
|
||||
>;
|
||||
|
||||
/**
|
||||
* Familles de produits autorisées (ou identifiant de famille) — pas de quantité ici.
|
||||
*/
|
||||
products: Array<
|
||||
| {
|
||||
familyCode: string;
|
||||
role?: string;
|
||||
}
|
||||
| {
|
||||
typeProductId: string;
|
||||
role?: string;
|
||||
}
|
||||
>;
|
||||
|
||||
/**
|
||||
* Valeurs par défaut au niveau "modèle" (libres, mais clé obligatoire).
|
||||
*/
|
||||
@@ -48,7 +62,25 @@ export type PieceModelCustomField = {
|
||||
options?: unknown;
|
||||
};
|
||||
|
||||
export type PieceModelProduct =
|
||||
| {
|
||||
familyCode: string;
|
||||
role?: string;
|
||||
}
|
||||
| {
|
||||
typeProductId: string;
|
||||
role?: string;
|
||||
};
|
||||
|
||||
export type PieceModelStructure = {
|
||||
customFields?: PieceModelCustomField[];
|
||||
products?: PieceModelProduct[];
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type ProductModelCustomField = PieceModelCustomField;
|
||||
|
||||
export type ProductModelStructure = {
|
||||
customFields?: ProductModelCustomField[];
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user