diff --git a/src/component-models/structure.normalizer.ts b/src/component-models/structure.normalizer.ts new file mode 100644 index 0000000..75bff4f --- /dev/null +++ b/src/component-models/structure.normalizer.ts @@ -0,0 +1,109 @@ +import type { ComponentModelStructure } from '../shared/types/inventory'; + +type InputValue = Record | null | undefined; + +const toArray = (value: unknown): unknown[] => { + if (Array.isArray(value)) { + return value; + } + if (value === undefined || value === null) { + return []; + } + return [value]; +}; + +const sanitizeRole = (role: unknown): string | undefined => { + if (role === undefined || role === null) { + return undefined; + } + const stringValue = String(role).trim(); + return stringValue ? stringValue : undefined; +}; + +const sanitizeAlias = (alias: unknown): string | undefined => { + if (alias === undefined || alias === null) { + return undefined; + } + const stringValue = String(alias).trim(); + return stringValue ? stringValue : undefined; +}; + +const ensureString = (value: unknown): string => String(value ?? ''); + +export function normalizeComponentModelStructure( + input: unknown, +): ComponentModelStructure { + const structure = (input ?? {}) as InputValue; + + const pieces = toArray((structure as any)?.pieces).map((piece) => { + const candidate = piece as Record | null | undefined; + if (candidate?.familyCode) { + return { + familyCode: ensureString(candidate.familyCode).trim() || 'UNKNOWN', + role: sanitizeRole(candidate.role), + } as ComponentModelStructure['pieces'][number]; + } + if (candidate?.typePieceId) { + return { + typePieceId: ensureString(candidate.typePieceId).trim() || 'UNKNOWN', + role: sanitizeRole(candidate.role), + } as ComponentModelStructure['pieces'][number]; + } + + return { + familyCode: + ensureString( + candidate?.familyCode ?? candidate?.name ?? candidate?.typePieceLabel ?? 'UNKNOWN', + ).trim() || 'UNKNOWN', + role: sanitizeRole(candidate?.role), + } as ComponentModelStructure['pieces'][number]; + }); + + const customFields = toArray((structure as any)?.customFields).map((field) => { + const candidate = field as Record | null | undefined; + const key = ensureString(candidate?.key ?? candidate?.name ?? 'unknown').trim(); + + return { + key: key || 'unknown', + value: candidate?.value ?? null, + }; + }); + + const rawSubcomponents = toArray( + (structure as any)?.subcomponents ?? (structure as any)?.subComponents, + ); + + const subcomponents = rawSubcomponents.map((subcomponent) => { + const candidate = subcomponent as Record | null | undefined; + + if (candidate?.modelId) { + return { + modelId: ensureString(candidate.modelId).trim() || 'UNKNOWN', + alias: sanitizeAlias(candidate?.alias ?? candidate?.name), + } as ComponentModelStructure['subcomponents'][number]; + } + if (candidate?.familyCode) { + return { + familyCode: ensureString(candidate.familyCode).trim() || 'UNKNOWN', + alias: sanitizeAlias(candidate?.alias ?? candidate?.name), + } as ComponentModelStructure['subcomponents'][number]; + } + if (candidate?.typeComposantId) { + return { + typeComposantId: ensureString(candidate.typeComposantId).trim() || 'UNKNOWN', + alias: sanitizeAlias(candidate?.alias ?? candidate?.name), + } as ComponentModelStructure['subcomponents'][number]; + } + + return { + familyCode: ensureString(candidate?.name ?? 'UNKNOWN').trim() || 'UNKNOWN', + alias: sanitizeAlias(candidate?.alias ?? candidate?.name), + } as ComponentModelStructure['subcomponents'][number]; + }); + + return { + pieces, + customFields, + subcomponents, + }; +} diff --git a/src/composants/composants.service.ts b/src/composants/composants.service.ts index 6887f73..0a4cf84 100644 --- a/src/composants/composants.service.ts +++ b/src/composants/composants.service.ts @@ -123,7 +123,8 @@ export class ComposantsService { include: COMPONENT_WITH_RELATIONS_INCLUDE, })) as ComposantWithRelations; - return this.getComponentWithHierarchy(created.id); + const component = await this.getComponentWithHierarchy(created.id); + return component ?? created; } async findAll() { @@ -244,9 +245,13 @@ export class ComposantsService { } } - const subComponents = Array.isArray(structure?.subComponents) - ? structure.subComponents - : []; + const rawSubcomponents = + (structure as any)?.subcomponents ?? structure?.subComponents; + const subComponents = Array.isArray(rawSubcomponents) + ? rawSubcomponents + : rawSubcomponents + ? [rawSubcomponents] + : []; for (const sub of subComponents) { const subTypeId = this.extractTypeComposantId(sub); if (!subTypeId) { diff --git a/src/custom-fields/custom-fields.service.spec.ts b/src/custom-fields/custom-fields.service.spec.ts index da568f1..50b6f5b 100644 --- a/src/custom-fields/custom-fields.service.spec.ts +++ b/src/custom-fields/custom-fields.service.spec.ts @@ -152,9 +152,3 @@ describe('CustomFieldsService', () => { }); }); }); - expect(prisma.customField.findFirst).toHaveBeenCalledWith({ - where: { - name: 'Température maximale', - typeMachineId: 'type-1', - }, - }); diff --git a/src/machines/machines.service.ts b/src/machines/machines.service.ts index e9227f3..65d2e1b 100644 --- a/src/machines/machines.service.ts +++ b/src/machines/machines.service.ts @@ -537,11 +537,19 @@ export class MachinesService { : prepared.pieces ? [prepared.pieces] : []; - prepared.subComponents = Array.isArray(prepared.subComponents) - ? prepared.subComponents - : prepared.subComponents - ? [prepared.subComponents] + const rawSubcomponents = + (definition as any)?.subcomponents ?? + (definition as any)?.subComponents ?? + prepared.subcomponents ?? + prepared.subComponents ?? + []; + const subcomponents = Array.isArray(rawSubcomponents) + ? rawSubcomponents + : rawSubcomponents + ? [rawSubcomponents] : []; + prepared.subcomponents = subcomponents; + prepared.subComponents = subcomponents; prepared.typeComposantId = prepared.typeComposantId || @@ -608,9 +616,13 @@ export class MachinesService { const componentPieces = Array.isArray(component.pieces) ? component.pieces : []; - const subComponents = Array.isArray(component.subComponents) - ? component.subComponents - : []; + const rawSubcomponents = + component.subcomponents ?? component.subComponents ?? []; + const subComponents = Array.isArray(rawSubcomponents) + ? rawSubcomponents + : rawSubcomponents + ? [rawSubcomponents] + : []; const componentModelId = component.__componentModelId ?? null; const requirementId = component.__requirementId ?? null; diff --git a/src/shared/dto/type.dto.ts b/src/shared/dto/type.dto.ts index 994e82d..8837535 100644 --- a/src/shared/dto/type.dto.ts +++ b/src/shared/dto/type.dto.ts @@ -9,6 +9,7 @@ import { } from 'class-validator'; import { Type } from 'class-transformer'; import { ValidateNested } from 'class-validator'; +import type { ComponentModelStructure } from '../types/inventory'; export enum CustomFieldType { TEXT = 'text', @@ -251,7 +252,7 @@ export class CreateComposantModelDto { typeComposantId: string; @IsOptional() - structure?: any; + structure?: ComponentModelStructure; } export class UpdateComposantModelDto { @@ -268,7 +269,7 @@ export class UpdateComposantModelDto { typeComposantId?: string; @IsOptional() - structure?: any; + structure?: ComponentModelStructure; } export class CreatePieceModelDto { diff --git a/src/shared/schemas/inventory.ts b/src/shared/schemas/inventory.ts new file mode 100644 index 0000000..6750f25 --- /dev/null +++ b/src/shared/schemas/inventory.ts @@ -0,0 +1,152 @@ +import { normalizeComponentModelStructure } from '../../component-models/structure.normalizer'; +import type { ComponentModelStructure } from '../types/inventory'; + +export class ComponentModelStructureValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'ComponentModelStructureValidationError'; + } +} + +function assertString(value: unknown, context: string): string { + if (typeof value !== 'string') { + throw new ComponentModelStructureValidationError( + `${context} doit être une chaîne de caractères`, + ); + } + return value; +} + +function sanitizeOptionalString(value: unknown): string | undefined { + if (value === undefined || value === null) { + return undefined; + } + return String(value); +} + +function validatePieces( + pieces: ComponentModelStructure['pieces'], +): ComponentModelStructure['pieces'] { + return pieces.map((piece, index) => { + if ('familyCode' in piece) { + const familyCode = assertString( + piece.familyCode, + `pieces[${index}].familyCode`, + ).trim(); + if (!familyCode) { + throw new ComponentModelStructureValidationError( + `pieces[${index}].familyCode ne peut pas être vide`, + ); + } + return { + familyCode, + role: sanitizeOptionalString(piece.role), + }; + } + + if ('typePieceId' in piece) { + const typePieceId = assertString( + piece.typePieceId, + `pieces[${index}].typePieceId`, + ).trim(); + if (!typePieceId) { + throw new ComponentModelStructureValidationError( + `pieces[${index}].typePieceId ne peut pas être vide`, + ); + } + return { + typePieceId, + role: sanitizeOptionalString(piece.role), + }; + } + + throw new ComponentModelStructureValidationError( + `pieces[${index}] doit définir "familyCode" ou "typePieceId"`, + ); + }); +} + +function validateCustomFields( + customFields: ComponentModelStructure['customFields'], +): ComponentModelStructure['customFields'] { + return customFields.map((field, index) => { + const key = assertString(field.key, `customFields[${index}].key`).trim(); + if (!key) { + throw new ComponentModelStructureValidationError( + `customFields[${index}].key ne peut pas être vide`, + ); + } + + return { key, value: field.value }; + }); +} + +function validateSubcomponents( + subcomponents: ComponentModelStructure['subcomponents'], +): ComponentModelStructure['subcomponents'] { + return subcomponents.map((subcomponent, index) => { + if ('modelId' in subcomponent) { + const modelId = assertString( + subcomponent.modelId, + `subcomponents[${index}].modelId`, + ).trim(); + if (!modelId) { + throw new ComponentModelStructureValidationError( + `subcomponents[${index}].modelId ne peut pas être vide`, + ); + } + return { + modelId, + alias: sanitizeOptionalString(subcomponent.alias), + }; + } + + if ('familyCode' in subcomponent) { + const familyCode = assertString( + subcomponent.familyCode, + `subcomponents[${index}].familyCode`, + ).trim(); + if (!familyCode) { + throw new ComponentModelStructureValidationError( + `subcomponents[${index}].familyCode ne peut pas être vide`, + ); + } + return { + familyCode, + alias: sanitizeOptionalString(subcomponent.alias), + }; + } + + if ('typeComposantId' in subcomponent) { + const typeComposantId = assertString( + subcomponent.typeComposantId, + `subcomponents[${index}].typeComposantId`, + ).trim(); + if (!typeComposantId) { + throw new ComponentModelStructureValidationError( + `subcomponents[${index}].typeComposantId ne peut pas être vide`, + ); + } + return { + typeComposantId, + alias: sanitizeOptionalString(subcomponent.alias), + }; + } + + throw new ComponentModelStructureValidationError( + `subcomponents[${index}] doit définir "modelId", "familyCode" ou "typeComposantId"`, + ); + }); +} + +export const ComponentModelStructureSchema = { + parse(input: unknown): ComponentModelStructure { + const normalized = normalizeComponentModelStructure(input); + + return { + pieces: validatePieces(normalized.pieces), + customFields: validateCustomFields(normalized.customFields), + subcomponents: validateSubcomponents(normalized.subcomponents), + }; + }, +}; diff --git a/src/shared/types/inventory.ts b/src/shared/types/inventory.ts new file mode 100644 index 0000000..ff5f000 --- /dev/null +++ b/src/shared/types/inventory.ts @@ -0,0 +1,41 @@ +/** + * Structure canonique d'un ComponentModel. + */ +export type ComponentModelStructure = { + /** + * Familles de pièces autorisées (ou identifiant de famille) — pas de quantité ici. + */ + pieces: Array< + | { + familyCode: string; + role?: string; + } + | { + typePieceId: string; + role?: string; + } + >; + + /** + * Valeurs par défaut au niveau "modèle" (libres, mais clé obligatoire). + */ + customFields: Array<{ key: string; value: unknown }>; + + /** + * Sous-composants : soit on pointe un modèle précis, soit on autorise une famille. + */ + subcomponents: Array< + | { + modelId: string; + alias?: string; + } + | { + familyCode: string; + alias?: string; + } + | { + typeComposantId: string; + alias?: string; + } + >; +}; diff --git a/src/types/services/composant-model.service.ts b/src/types/services/composant-model.service.ts index 61c4bac..ca5142e 100644 --- a/src/types/services/composant-model.service.ts +++ b/src/types/services/composant-model.service.ts @@ -1,9 +1,12 @@ import { Injectable } from '@nestjs/common'; import { ComposantModelsRepository } from '../../common/repositories/composant-models.repository'; +import type { Prisma } from '@prisma/client'; import { CreateComposantModelDto, UpdateComposantModelDto, } from '../../shared/dto/type.dto'; +import { ComponentModelStructureSchema } from '../../shared/schemas/inventory'; +import type { ComponentModelStructure } from '../../shared/types/inventory'; const COMPOSANT_MODEL_INCLUDE = { typeComposant: true, @@ -14,40 +17,81 @@ export class ComposantModelService { constructor(private readonly repository: ComposantModelsRepository) {} async create(dto: CreateComposantModelDto) { - const { typeComposantId, ...data } = dto; - return this.repository.create( + const { typeComposantId, structure, ...data } = dto; + const parsedStructure = this.parseStructure(structure); + + const created = await this.repository.create( { ...data, + structure: parsedStructure as Prisma.InputJsonValue, typeComposant: { connect: { id: typeComposantId } }, }, COMPOSANT_MODEL_INCLUDE, ); + + return this.withParsedStructure(created); } async findAll(typeComposantId?: string) { - return this.repository.findAll(typeComposantId, COMPOSANT_MODEL_INCLUDE); + const models = await this.repository.findAll( + typeComposantId, + COMPOSANT_MODEL_INCLUDE, + ); + + return models.map((model) => this.mapStructure(model)); } async findOne(id: string) { - return this.repository.findOne(id, COMPOSANT_MODEL_INCLUDE); + const model = await this.repository.findOne(id, COMPOSANT_MODEL_INCLUDE); + return this.withParsedStructure(model); } async update(id: string, dto: UpdateComposantModelDto) { - const { typeComposantId, ...data } = dto; + const { typeComposantId, structure, ...data } = dto; - return this.repository.update( + const parsedStructure = + structure !== undefined ? this.parseStructure(structure) : undefined; + + const updated = await this.repository.update( id, { ...data, + ...(parsedStructure + ? { structure: parsedStructure as Prisma.InputJsonValue } + : {}), ...(typeComposantId ? { typeComposant: { connect: { id: typeComposantId } } } : {}), }, COMPOSANT_MODEL_INCLUDE, ); + + return this.withParsedStructure(updated); } async remove(id: string) { return this.repository.delete(id); } + + private parseStructure( + structure: unknown | undefined, + ): ComponentModelStructure { + return ComponentModelStructureSchema.parse(structure); + } + + private mapStructure( + model: T, + ): T & { structure: ComponentModelStructure } { + const structure = this.parseStructure((model as any).structure); + return { + ...model, + structure, + }; + } + + private withParsedStructure( + model: T | null, + ): (T & { structure: ComponentModelStructure }) | null { + return model ? this.mapStructure(model) : null; + } }