feat: normalize and validate component model structure

This commit is contained in:
MatthieuTD
2025-10-01 11:47:45 +02:00
parent f48e7aad30
commit 1a4cedb431
8 changed files with 383 additions and 25 deletions

View File

@@ -0,0 +1,109 @@
import type { ComponentModelStructure } from '../shared/types/inventory';
type InputValue = Record<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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,
};
}

View File

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

View File

@@ -152,9 +152,3 @@ describe('CustomFieldsService', () => {
});
});
});
expect(prisma.customField.findFirst).toHaveBeenCalledWith({
where: {
name: 'Température maximale',
typeMachineId: 'type-1',
},
});

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<T extends { structure?: unknown }>(
model: T,
): T & { structure: ComponentModelStructure } {
const structure = this.parseStructure((model as any).structure);
return {
...model,
structure,
};
}
private withParsedStructure<T extends { structure?: unknown }>(
model: T | null,
): (T & { structure: ComponentModelStructure }) | null {
return model ? this.mapStructure(model) : null;
}
}