refactor: prepare multi-machine inventory associations

This commit is contained in:
Matthieu
2025-10-08 16:23:49 +02:00
parent c23ba3a587
commit 48a74b74d7
19 changed files with 1166 additions and 297 deletions

View File

@@ -0,0 +1,42 @@
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
async function main () {
try {
console.log('Starting custom fields cleanup...')
const deletedValues = await prisma.customFieldValue.deleteMany({
where: {
customField: {
OR: [
{ typeComposantId: { not: null } },
{ typePieceId: { not: null } },
{ typeMachineId: { not: null } }
]
}
}
})
console.log(`Deleted ${deletedValues.count} custom field values linked to type-level definitions.`)
const deletedFields = await prisma.customField.deleteMany({
where: {
OR: [
{ typeComposantId: { not: null } },
{ typePieceId: { not: null } },
{ typeMachineId: { not: null } }
]
}
})
console.log(`Deleted ${deletedFields.count} custom field definitions linked to model types.`)
console.log('Cleanup complete.')
} catch (error) {
console.error('Cleanup failed:', error)
process.exitCode = 1
} finally {
await prisma.$disconnect()
}
}
main()

View File

@@ -0,0 +1,593 @@
import { Prisma, PrismaClient, ModelCategory } from '@prisma/client';
const prisma = new PrismaClient();
type CustomFieldInput = {
name: string;
type: 'text' | 'number' | 'select';
required?: boolean;
options?: readonly string[];
};
type ModelTypeSeed = {
code: string;
name: string;
description: string;
customFields: readonly CustomFieldInput[];
};
type ComponentRequirementSeed = {
typeCode: string;
label: string;
minCount: number;
maxCount?: number | null;
required?: boolean;
allowNewModels?: boolean;
};
type PieceRequirementSeed = {
typeCode: string;
label: string;
minCount: number;
maxCount?: number | null;
required?: boolean;
allowNewModels?: boolean;
};
const componentTypes: readonly ModelTypeSeed[] = [
{
code: 'drive-module',
name: 'Module d entrainement',
description: 'Sous-ensemble moteur et reducteur pour entrainements principaux.',
customFields: [
{ name: 'Puissance nominale (kW)', type: 'number', required: true },
{ name: 'Indice de protection', type: 'select', options: ['IP55', 'IP65', 'IP66'] },
],
},
{
code: 'sensor-array',
name: 'Chaine de capteurs',
description: 'Groupe de capteurs industriels (temperature, vibration, debit).',
customFields: [
{ name: 'Type principal', type: 'select', options: ['Temperature', 'Vibration', 'Debit'] },
{ name: 'Plage de mesure', type: 'text' },
],
},
{
code: 'control-cabinet',
name: 'Armoire de controle',
description: 'Armoire electrique avec automate, protection et distribution.',
customFields: [
{ name: 'Tension alimentation (V)', type: 'number' },
{ name: 'Nombre de departs', type: 'number' },
],
},
{
code: 'hydraulic-pack',
name: 'Groupe hydraulique',
description: 'Bloc hydraulique complet (pompes, accumulateurs, filtration).',
customFields: [
{ name: 'Pression nominale (bar)', type: 'number', required: true },
{ name: 'Debit nominal (L/min)', type: 'number' },
],
},
{
code: 'structure-frame',
name: 'Chassis structurel',
description: 'Structure porteuse ou chassis mecano-soude.',
customFields: [
{ name: 'Matiere', type: 'select', options: ['Acier', 'Inox', 'Aluminium'] },
{ name: 'Charge admissible (kg)', type: 'number' },
],
},
];
const pieceTypes: readonly ModelTypeSeed[] = [
{
code: 'belt-kit',
name: 'Kit courroie',
description: 'Courroie et accessoires pour entrainements.',
customFields: [
{ name: 'Type', type: 'select', options: ['Poly-V', 'Trapezoidale', 'Synchronisee'] },
{ name: 'Longueur (mm)', type: 'number' },
],
},
{
code: 'bearing-set',
name: 'Jeu de roulements',
description: 'Paire de roulements avec bagues et graisse.',
customFields: [
{ name: 'Diametre interieur (mm)', type: 'number', required: true },
{ name: 'Classe', type: 'select', options: ['P0', 'P6', 'P5'] },
],
},
{
code: 'filter-cartridge',
name: 'Cartouche filtrante',
description: 'Element filtrant pour fluides ou air.',
customFields: [
{ name: 'Grade de filtration (um)', type: 'number' },
{ name: 'Type de media', type: 'select', options: ['Cellulose', 'Synthetique', 'Inox'] },
],
},
{
code: 'sensor-probe',
name: 'Sonde de mesure',
description: 'Sonde ou capteur unitaire avec cable.',
customFields: [
{ name: 'Signal de sortie', type: 'select', options: ['4-20 mA', '0-10 V', 'PT100'] },
{ name: 'Indice IP', type: 'select', options: ['IP67', 'IP68'] },
],
},
{
code: 'maintenance-kit',
name: 'Kit maintenance',
description: 'Ensemble de pieces pour maintenance planifiee.',
customFields: [
{ name: 'Niveau de maintenance', type: 'select', options: ['Preventif', 'Correctif', 'Lourde'] },
{ name: 'Duree estimee (h)', type: 'number' },
],
},
];
const constructors = [
{ name: 'ElectroMec Industrie', email: 'contact@electromec.fr', phone: '+33 4 72 00 11 22' },
{ name: 'Hydraulic Systems Europe', email: 'sales@hydraulics-eu.com', phone: '+33 5 56 12 34 56' },
{ name: 'Automation Lyon', email: 'support@automation-lyon.fr', phone: '+33 4 37 50 60 70' },
{ name: 'ThermoTech Solutions', email: 'info@thermotech.eu', phone: '+33 1 44 55 66 77' },
{ name: 'BearingWorks', email: 'service@bearingworks.com', phone: '+33 3 88 90 12 45' },
] as const;
const machineCustomFields: readonly CustomFieldInput[] = [
{ name: 'Reference installation', type: 'text' },
{ name: 'Puissance installee (kW)', type: 'number' },
{ name: 'Zone critique', type: 'select', options: ['Zone A', 'Zone B', 'Zone C'] },
];
const componentRequirementSeeds: readonly ComponentRequirementSeed[] = [
{
typeCode: 'structure-frame',
label: 'Chassis principal',
minCount: 1,
maxCount: 1,
required: true,
allowNewModels: false,
},
{
typeCode: 'drive-module',
label: 'Module d entrainement principal',
minCount: 1,
maxCount: 1,
required: true,
allowNewModels: false,
},
{
typeCode: 'control-cabinet',
label: 'Armoire de controle',
minCount: 1,
maxCount: 1,
required: true,
allowNewModels: false,
},
{
typeCode: 'sensor-array',
label: 'Capteurs de surveillance',
minCount: 1,
maxCount: 3,
required: true,
allowNewModels: true,
},
{
typeCode: 'hydraulic-pack',
label: 'Groupe hydraulique auxiliaire',
minCount: 0,
maxCount: 1,
required: false,
allowNewModels: true,
},
];
const pieceRequirementSeeds: readonly PieceRequirementSeed[] = [
{
typeCode: 'belt-kit',
label: 'Kit courroie de rechange',
minCount: 1,
maxCount: 2,
required: true,
allowNewModels: true,
},
{
typeCode: 'bearing-set',
label: 'Roulements de secours',
minCount: 1,
maxCount: 2,
required: true,
allowNewModels: true,
},
{
typeCode: 'filter-cartridge',
label: 'Cartouches de filtration',
minCount: 0,
maxCount: 4,
required: false,
allowNewModels: true,
},
{
typeCode: 'maintenance-kit',
label: 'Kit maintenance planifiee',
minCount: 0,
maxCount: 1,
required: false,
allowNewModels: true,
},
{
typeCode: 'sensor-probe',
label: 'Sondes de rechange',
minCount: 1,
maxCount: 4,
required: true,
allowNewModels: true,
},
];
function mapCustomFields(fields: readonly CustomFieldInput[]) {
if (!fields.length) {
return undefined;
}
return {
create: fields.map((field) => ({
name: field.name,
type: field.type,
required: field.required ?? false,
options: field.options ? [...field.options] : [],
})),
} as const;
}
async function upsertComponentType(type: ModelTypeSeed) {
const customFields = mapCustomFields(type.customFields);
await prisma.modelType.upsert({
where: { code: type.code },
update: {
name: type.name,
description: type.description,
notes: type.description,
customFields: {
deleteMany: {},
...(customFields ?? {}),
},
},
create: {
code: type.code,
name: type.name,
description: type.description,
notes: type.description,
category: ModelCategory.COMPONENT,
...(customFields ? { customFields } : {}),
},
});
}
async function upsertPieceType(type: ModelTypeSeed) {
const customFields = mapCustomFields(type.customFields);
await prisma.modelType.upsert({
where: { code: type.code },
update: {
name: type.name,
description: type.description,
notes: type.description,
pieceCustomFields: {
deleteMany: {},
...(customFields ?? {}),
},
},
create: {
code: type.code,
name: type.name,
description: type.description,
notes: type.description,
category: ModelCategory.PIECE,
...(customFields ? { pieceCustomFields: customFields } : {}),
},
});
}
async function applyPieceSkeletons(pieceMap: Map<string, { id: string }>) {
type PieceSkeleton = {
customFields?: Array<{ name: string; value?: unknown; type?: string; required?: boolean; options?: unknown }>;
[key: string]: unknown;
};
const definitions: Record<string, PieceSkeleton> = {
'belt-kit': {
customFields: [
{ name: 'Type', value: 'Poly-V' },
{ name: 'Longueur (mm)', value: 1800 },
],
remplacementHeures: 1500,
stockageRecommande: 'Local sec et tempere',
},
'bearing-set': {
customFields: [
{ name: 'Diametre interieur (mm)', value: 45 },
{ name: 'Classe', value: 'P6' },
],
graisseRecommandee: 'Lithium NLGI2',
},
'filter-cartridge': {
customFields: [
{ name: 'Grade de filtration (um)', value: 10 },
{ name: 'Type de media', value: 'Synthetique' },
],
remplacementMensuel: true,
},
'sensor-probe': {
customFields: [
{ name: 'Signal de sortie', value: '4-20 mA' },
{ name: 'Indice IP', value: 'IP67' },
],
calibrationIntervalJours: 180,
},
'maintenance-kit': {
customFields: [
{ name: 'Niveau de maintenance', value: 'Preventif' },
{ name: 'Duree estimee (h)', value: 4 },
],
contenuStandard: ['Filtres', 'Joints', 'Visserie'],
},
};
for (const [code, structure] of Object.entries(definitions)) {
const record = pieceMap.get(code);
if (!record) {
continue;
}
await prisma.modelType.update({
where: { id: record.id },
data: {
pieceSkeleton: structure as Prisma.InputJsonValue,
},
});
}
}
async function applyComponentSkeletons(
componentMap: Map<string, { id: string }>,
pieceMap: Map<string, { id: string }>,
) {
const pieceRef = (code: string, role?: string) => {
const piece = pieceMap.get(code);
if (!piece) {
throw new Error(`Piece type ${code} requis pour le squelette`);
}
return {
typePieceId: piece.id,
...(role ? { role } : {}),
};
};
const componentRef = (code: string, alias?: string) => {
const component = componentMap.get(code);
if (!component) {
throw new Error(`Component type ${code} requis pour le squelette`);
}
return {
typeComposantId: component.id,
...(alias ? { alias } : {}),
};
};
type ComponentSkeleton = {
pieces: Array<{ typePieceId: string; role?: string }>;
customFields: Array<{ key: string; value: unknown }>;
subcomponents: Array<{ typeComposantId?: string; alias?: string; familyCode?: string; modelId?: string }>;
};
const definitions: Record<string, ComponentSkeleton> = {
'drive-module': {
pieces: [
pieceRef('belt-kit', 'Courroie principale'),
pieceRef('bearing-set', 'Roulements de sortie'),
],
customFields: [
{ key: 'Lubrification', value: 'Graissage centralise' },
{ key: 'ControleVibration', value: 'Capteurs integres' },
],
subcomponents: [componentRef('sensor-array', 'Capteurs integres')],
},
'sensor-array': {
pieces: [pieceRef('sensor-probe', 'Sonde principale')],
customFields: [
{ key: 'Calibration', value: 'A effectuer tous les 6 mois' },
{ key: 'NombreCapteursMax', value: 6 },
],
subcomponents: [],
},
'control-cabinet': {
pieces: [
pieceRef('maintenance-kit', 'Kit rechange armoire'),
pieceRef('sensor-probe', 'Sonde ambiance'),
],
customFields: [
{ key: 'ClassementLocal', value: 'Non ATEX' },
{ key: 'RefAutomate', value: 'PLC-STD-200' },
],
subcomponents: [],
},
'hydraulic-pack': {
pieces: [
pieceRef('filter-cartridge', 'Filtre hydraulique'),
pieceRef('maintenance-kit', 'Kit joints hydrauliques'),
],
customFields: [
{ key: 'ReservoirLitres', value: 120 },
{ key: 'TypeHuile', value: 'HLP46' },
],
subcomponents: [componentRef('sensor-array', 'Capteurs pression et debit')],
},
'structure-frame': {
pieces: [],
customFields: [
{ key: 'Revêtement', value: 'Peinture epoxy' },
{ key: 'PointsLevage', value: 4 },
],
subcomponents: [componentRef('sensor-array', 'Capteurs deformation')],
},
};
for (const [code, structure] of Object.entries(definitions)) {
const record = componentMap.get(code);
if (!record) {
continue;
}
await prisma.modelType.update({
where: { id: record.id },
data: {
componentSkeleton: structure as Prisma.InputJsonValue,
},
});
}
}
function buildComponentRequirements(
componentMap: Map<string, { id: string }>,
seeds: readonly ComponentRequirementSeed[],
) {
return seeds.map((seed) => {
const type = componentMap.get(seed.typeCode);
if (!type) {
throw new Error(`Type composant ${seed.typeCode} introuvable pour le requirement`);
}
return {
label: seed.label,
minCount: seed.minCount,
maxCount: seed.maxCount ?? null,
required: seed.required ?? true,
allowNewModels: seed.allowNewModels ?? true,
typeComposant: { connect: { id: type.id } },
};
});
}
function buildPieceRequirements(
pieceMap: Map<string, { id: string }>,
seeds: readonly PieceRequirementSeed[],
) {
return seeds.map((seed) => {
const type = pieceMap.get(seed.typeCode);
if (!type) {
throw new Error(`Type piece ${seed.typeCode} introuvable pour le requirement`);
}
return {
label: seed.label,
minCount: seed.minCount,
maxCount: seed.maxCount ?? null,
required: seed.required ?? true,
allowNewModels: seed.allowNewModels ?? true,
typePiece: { connect: { id: type.id } },
};
});
}
async function seedMachineTemplate(
componentMap: Map<string, { id: string }>,
pieceMap: Map<string, { id: string }>,
) {
const name = 'Cellule Modulaire Standard';
const description = 'Module generique compose d un chassis, d un entrainement, de capteurs et d une armoire de controle.';
const componentRequirements = buildComponentRequirements(componentMap, componentRequirementSeeds);
const pieceRequirements = buildPieceRequirements(pieceMap, pieceRequirementSeeds);
await prisma.typeMachine.upsert({
where: { name },
update: {
description,
category: 'Module',
maintenanceFrequency: 'Mensuelle',
customFields: {
deleteMany: {},
...(mapCustomFields(machineCustomFields) ?? {}),
},
componentRequirements: {
deleteMany: {},
create: componentRequirements,
},
pieceRequirements: {
deleteMany: {},
create: pieceRequirements,
},
},
create: {
name,
description,
category: 'Module',
maintenanceFrequency: 'Mensuelle',
...(mapCustomFields(machineCustomFields) ? { customFields: mapCustomFields(machineCustomFields)! } : {}),
componentRequirements: {
create: componentRequirements,
},
pieceRequirements: {
create: pieceRequirements,
},
},
});
}
async function main() {
console.log('Seeding component categories...');
for (const component of componentTypes) {
await upsertComponentType(component);
}
console.log('Seeding piece categories...');
for (const piece of pieceTypes) {
await upsertPieceType(piece);
}
const componentRecords = await prisma.modelType.findMany({
where: { code: { in: componentTypes.map((type) => type.code) } },
select: { id: true, code: true },
});
const pieceRecords = await prisma.modelType.findMany({
where: { code: { in: pieceTypes.map((type) => type.code) } },
select: { id: true, code: true },
});
const componentMap = new Map(componentRecords.map((record) => [record.code, { id: record.id }]));
const pieceMap = new Map(pieceRecords.map((record) => [record.code, { id: record.id }]));
console.log('Applying piece skeletons...');
await applyPieceSkeletons(pieceMap);
console.log('Applying component skeletons...');
await applyComponentSkeletons(componentMap, pieceMap);
console.log('Seeding constructors...');
for (const constructeur of constructors) {
await prisma.constructeur.upsert({
where: { name: constructeur.name },
update: {
email: constructeur.email,
phone: constructeur.phone,
},
create: constructeur,
});
}
console.log('Configuring machine template...');
await seedMachineTemplate(componentMap, pieceMap);
}
main()
.then(() => {
console.log('Seed completed.');
})
.catch((error) => {
console.error('Seed failed:', error);
process.exitCode = 1;
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@@ -50,7 +50,9 @@ export class ModelTypeMapper {
})), })),
} }
: undefined, : undefined,
...(skeleton ? { componentSkeleton: skeleton as Prisma.InputJsonValue } : {}), ...(skeleton
? { componentSkeleton: skeleton as Prisma.InputJsonValue }
: {}),
}; };
} }

View File

@@ -53,28 +53,38 @@ export function normalizeComponentModelStructure(
return { return {
familyCode: familyCode:
ensureString( ensureString(
candidate?.familyCode ?? candidate?.name ?? candidate?.typePieceLabel ?? 'UNKNOWN', candidate?.familyCode ??
candidate?.name ??
candidate?.typePieceLabel ??
'UNKNOWN',
).trim() || 'UNKNOWN', ).trim() || 'UNKNOWN',
role: sanitizeRole(candidate?.role), role: sanitizeRole(candidate?.role),
} as ComponentModelStructure['pieces'][number]; } as ComponentModelStructure['pieces'][number];
}); });
const customFields = toArray((structure as any)?.customFields).map((field) => { const customFields = toArray((structure as any)?.customFields).map(
const candidate = field as Record<string, unknown> | null | undefined; (field) => {
const key = ensureString(candidate?.key ?? candidate?.name ?? 'unknown').trim(); const candidate = field as Record<string, unknown> | null | undefined;
const key = ensureString(
candidate?.key ?? candidate?.name ?? 'unknown',
).trim();
return { return {
key: key || 'unknown', key: key || 'unknown',
value: candidate?.value ?? null, value: candidate?.value ?? null,
}; };
}); },
);
const rawSubcomponents = toArray( const rawSubcomponents = toArray(
(structure as any)?.subcomponents ?? (structure as any)?.subComponents, (structure as any)?.subcomponents ?? (structure as any)?.subComponents,
); );
const subcomponents = rawSubcomponents.map((subcomponent) => { const subcomponents = rawSubcomponents.map((subcomponent) => {
const candidate = subcomponent as Record<string, unknown> | null | undefined; const candidate = subcomponent as
| Record<string, unknown>
| null
| undefined;
if (candidate?.modelId) { if (candidate?.modelId) {
return { return {
@@ -90,13 +100,15 @@ export function normalizeComponentModelStructure(
} }
if (candidate?.typeComposantId) { if (candidate?.typeComposantId) {
return { return {
typeComposantId: ensureString(candidate.typeComposantId).trim() || 'UNKNOWN', typeComposantId:
ensureString(candidate.typeComposantId).trim() || 'UNKNOWN',
alias: sanitizeAlias(candidate?.alias ?? candidate?.name), alias: sanitizeAlias(candidate?.alias ?? candidate?.name),
} as ComponentModelStructure['subcomponents'][number]; } as ComponentModelStructure['subcomponents'][number];
} }
return { return {
familyCode: ensureString(candidate?.name ?? 'UNKNOWN').trim() || 'UNKNOWN', familyCode:
ensureString(candidate?.name ?? 'UNKNOWN').trim() || 'UNKNOWN',
alias: sanitizeAlias(candidate?.alias ?? candidate?.name), alias: sanitizeAlias(candidate?.alias ?? candidate?.name),
} as ComponentModelStructure['subcomponents'][number]; } as ComponentModelStructure['subcomponents'][number];
}); });

View File

@@ -97,7 +97,9 @@ describe('ComposantsService', () => {
}); });
prisma.composant.findMany.mockResolvedValue([]); prisma.composant.findMany.mockResolvedValue([]);
await expect(service.create(dto)).resolves.toMatchObject({ id: 'component-1' }); await expect(service.create(dto)).resolves.toMatchObject({
id: 'component-1',
});
expect(prisma.composant.create).toHaveBeenCalled(); expect(prisma.composant.create).toHaveBeenCalled();
expect(prisma.composant.create.mock.calls[0][0].data.typeComposantId).toBe( expect(prisma.composant.create.mock.calls[0][0].data.typeComposantId).toBe(
@@ -193,7 +195,9 @@ describe('ComposantsService', () => {
}, },
}); });
prisma.customField.findMany.mockResolvedValue([{ id: 'cf-color', name: 'color' }]); prisma.customField.findMany.mockResolvedValue([
{ id: 'cf-color', name: 'color' },
]);
prisma.customFieldValue.findMany.mockResolvedValue([]); prisma.customFieldValue.findMany.mockResolvedValue([]);
const rootComponent = { const rootComponent = {

View File

@@ -20,10 +20,9 @@ type ComponentRequirementWithType =
Prisma.TypeMachineComponentRequirementGetPayload<{ Prisma.TypeMachineComponentRequirementGetPayload<{
include: { typeComposant: true }; include: { typeComposant: true };
}>; }>;
type PieceRequirementWithType = type PieceRequirementWithType = Prisma.TypeMachinePieceRequirementGetPayload<{
Prisma.TypeMachinePieceRequirementGetPayload<{ include: { typePiece: true };
include: { typePiece: true }; }>;
}>;
type ModelTypeWithSkeleton = ComponentRequirementWithType['typeComposant']; type ModelTypeWithSkeleton = ComponentRequirementWithType['typeComposant'];
type PieceTypeWithSkeleton = PieceRequirementWithType['typePiece']; type PieceTypeWithSkeleton = PieceRequirementWithType['typePiece'];
@@ -65,112 +64,137 @@ export class ComposantsService {
} }
async create(createComposantDto: CreateComposantDto) { async create(createComposantDto: CreateComposantDto) {
const requirementId = createComposantDto.typeMachineComponentRequirementId; const requirementId =
createComposantDto.typeMachineComponentRequirementId ?? null;
let machineId = createComposantDto.machineId; if (requirementId && !createComposantDto.machineId) {
throw new BadRequestException(
'Un requirement ne peut pas être utilisé sans machine ciblée.',
);
}
let machineId = createComposantDto.machineId ?? null;
if (createComposantDto.parentComposantId) { if (createComposantDto.parentComposantId) {
const parentMachineId = await this.resolveMachineIdFromComposant( const parentMachineId = await this.resolveMachineIdFromComposant(
createComposantDto.parentComposantId, createComposantDto.parentComposantId,
); );
if (machineId && machineId !== parentMachineId) { if (machineId && parentMachineId && machineId !== parentMachineId) {
throw new BadRequestException( throw new BadRequestException(
'Le composant parent ne correspond pas à la machine ciblée.', 'Le composant parent ne correspond pas à la machine ciblée.',
); );
} }
machineId = parentMachineId; machineId = parentMachineId ?? machineId;
} }
if (!machineId) { let requirement: ComponentRequirementWithType | null = null;
throw new BadRequestException( let componentRequirements: ComponentRequirementWithType[] = [];
'Un machineId ou un parentComposantId valide est requis pour créer un composant.', let pieceRequirements: PieceRequirementWithType[] = [];
);
}
const machine = await this.prisma.machine.findUnique({ if (machineId) {
where: { id: machineId }, const machine = await this.prisma.machine.findUnique({
include: { where: { id: machineId },
typeMachine: { include: {
include: { typeMachine: {
componentRequirements: { include: {
include: { componentRequirements: {
typeComposant: true, include: {
typeComposant: true,
},
}, },
}, pieceRequirements: {
pieceRequirements: { include: {
include: { typePiece: true,
typePiece: true, },
}, },
}, },
}, },
}, },
}, });
});
if (!machine || !machine.typeMachine) { if (!machine || !machine.typeMachine) {
throw new BadRequestException( throw new BadRequestException(
'La machine ciblée doit être associée à un type de machine pour valider les requirements.', 'La machine ciblée doit être associée à un type de machine pour valider les requirements.',
); );
} }
const componentRequirements = componentRequirements =
(machine.typeMachine.componentRequirements as ComponentRequirementWithType[]) ?? []; (machine.typeMachine
const pieceRequirements = .componentRequirements as ComponentRequirementWithType[]) ?? [];
(machine.typeMachine.pieceRequirements as PieceRequirementWithType[]) ?? []; pieceRequirements =
(machine.typeMachine.pieceRequirements as PieceRequirementWithType[]) ??
[];
const requirement = componentRequirements.find( if (requirementId) {
(componentRequirement) => componentRequirement.id === requirementId, requirement =
); componentRequirements.find(
(componentRequirement) => componentRequirement.id === requirementId,
) ?? null;
if (!requirement) { if (!requirement) {
throw new BadRequestException( throw new BadRequestException(
'Le requirement de composant fourni ne correspond pas au squelette de la machine.', 'Le requirement de composant fourni ne correspond pas au squelette de la machine.',
); );
} }
if ( if (
createComposantDto.typeComposantId && createComposantDto.typeComposantId &&
createComposantDto.typeComposantId !== requirement.typeComposantId createComposantDto.typeComposantId !== requirement.typeComposantId
) { ) {
throw new BadRequestException( throw new BadRequestException(
'Le type de composant fourni ne correspond pas au requirement pour cette machine.', 'Le type de composant fourni ne correspond pas au requirement pour cette machine.',
); );
}
}
} }
const typeComposantId = const typeComposantId =
createComposantDto.typeComposantId ?? requirement.typeComposantId; createComposantDto.typeComposantId ??
requirement?.typeComposantId ??
null;
const created = await this.prisma.composant.create({ const data: Prisma.ComposantUncheckedCreateInput = {
data: { name: createComposantDto.name,
...createComposantDto, reference: createComposantDto.reference ?? null,
machineId, constructeurId: createComposantDto.constructeurId ?? null,
typeComposantId, prix:
}, createComposantDto.prix !== undefined ? createComposantDto.prix : null,
include: COMPONENT_WITH_RELATIONS_INCLUDE,
});
const componentRequirementUsage = new Map<string, number>();
componentRequirementUsage.set(requirement.id, 1);
const pieceRequirementUsage = new Map<string, number>();
await this.populateComponentFromSkeleton({
componentId: created.id,
componentName: created.name,
componentType:
(requirement.typeComposant as ModelTypeWithSkeleton | null) ??
(created.typeComposant as ModelTypeWithSkeleton | null) ??
null,
machineId, machineId,
componentRequirements, parentComposantId: createComposantDto.parentComposantId ?? null,
pieceRequirements, typeComposantId,
componentRequirementUsage, typeMachineComponentRequirementId:
pieceRequirementUsage, requirement?.id ?? requirementId ?? null,
}); };
const created = (await this.prisma.composant.create({
data,
include: COMPONENT_WITH_RELATIONS_INCLUDE,
})) as ComposantWithRelations;
if (machineId && requirement?.id) {
const componentRequirementUsage = new Map<string, number>();
componentRequirementUsage.set(requirement.id, 1);
const pieceRequirementUsage = new Map<string, number>();
await this.populateComponentFromSkeleton({
componentId: created.id,
componentName: created.name,
componentType:
(requirement.typeComposant as ModelTypeWithSkeleton | null) ??
(created.typeComposant as ModelTypeWithSkeleton | null) ??
null,
machineId,
componentRequirements,
pieceRequirements,
componentRequirementUsage,
pieceRequirementUsage,
});
}
const component = await this.getComponentWithHierarchy(created.id); const component = await this.getComponentWithHierarchy(created.id);
return (component as ComposantWithRelations | null) ?? (created as ComposantWithRelations); return component ?? created;
} }
async findAll() { async findAll() {
@@ -225,8 +249,8 @@ export class ComposantsService {
pieceRequirementUsage: Map<string, number>; pieceRequirementUsage: Map<string, number>;
}) { }) {
const skeleton = this.parseComponentSkeleton( const skeleton = this.parseComponentSkeleton(
(componentType as { componentSkeleton?: Prisma.JsonValue | null } | null)?. (componentType as { componentSkeleton?: Prisma.JsonValue | null } | null)
componentSkeleton, ?.componentSkeleton,
); );
if (!skeleton) { if (!skeleton) {
return; return;
@@ -274,15 +298,12 @@ export class ComposantsService {
}, },
}); });
this.incrementRequirementUsage( this.incrementRequirementUsage(componentRequirementUsage, requirement.id);
componentRequirementUsage,
requirement.id,
);
await this.populateComponentFromSkeleton({ await this.populateComponentFromSkeleton({
componentId: createdChild.id, componentId: createdChild.id,
componentName: createdChild.name, componentName: createdChild.name,
componentType: requirement.typeComposant as ModelTypeWithSkeleton, componentType: requirement.typeComposant,
machineId, machineId,
componentRequirements, componentRequirements,
pieceRequirements, pieceRequirements,
@@ -311,7 +332,11 @@ export class ComposantsService {
typeComposantId: string | null, typeComposantId: string | null,
customFields: ComponentModelStructure['customFields'], customFields: ComponentModelStructure['customFields'],
) { ) {
if (!typeComposantId || !Array.isArray(customFields) || customFields.length === 0) { if (
!typeComposantId ||
!Array.isArray(customFields) ||
customFields.length === 0
) {
return; return;
} }
@@ -324,12 +349,16 @@ export class ComposantsService {
return; return;
} }
const definitionMap = new Map(definitions.map((field) => [field.name, field.id])); const definitionMap = new Map(
definitions.map((field) => [field.name, field.id]),
);
const existingValues = await this.prisma.customFieldValue.findMany({ const existingValues = await this.prisma.customFieldValue.findMany({
where: { composantId: componentId }, where: { composantId: componentId },
select: { customFieldId: true }, select: { customFieldId: true },
}); });
const existingIds = new Set(existingValues.map((value) => value.customFieldId)); const existingIds = new Set(
existingValues.map((value) => value.customFieldId),
);
for (const field of customFields) { for (const field of customFields) {
const key = this.normalizeIdentifier(field?.key); const key = this.normalizeIdentifier(field?.key);
@@ -384,7 +413,11 @@ export class ComposantsService {
continue; continue;
} }
const name = this.buildPieceName(entry, requirement.typePiece, componentName); const name = this.buildPieceName(
entry,
requirement.typePiece,
componentName,
);
await this.prisma.piece.create({ await this.prisma.piece.create({
data: { data: {
@@ -418,7 +451,9 @@ export class ComposantsService {
} }
if (familyCode && requirement.typeComposant?.code) { if (familyCode && requirement.typeComposant?.code) {
return this.normalizeCode(requirement.typeComposant.code) === familyCode; return (
this.normalizeCode(requirement.typeComposant.code) === familyCode
);
} }
return false; return false;
@@ -516,7 +551,9 @@ export class ComposantsService {
typeComposant: ModelTypeWithSkeleton | null, typeComposant: ModelTypeWithSkeleton | null,
parentName?: string, parentName?: string,
): string { ): string {
const alias = this.normalizeIdentifier((subcomponent as { alias?: string }).alias); const alias = this.normalizeIdentifier(
(subcomponent as { alias?: string }).alias,
);
if (alias) { if (alias) {
return alias; return alias;
} }

View File

@@ -170,9 +170,7 @@ export class CustomFieldsService {
} }
// Créer ou mettre à jour une valeur de champ personnalisé // Créer ou mettre à jour une valeur de champ personnalisé
async upsertCustomFieldValue( async upsertCustomFieldValue(dto: UpsertCustomFieldValueDto) {
dto: UpsertCustomFieldValueDto,
) {
const { const {
customFieldId: rawCustomFieldId, customFieldId: rawCustomFieldId,
customFieldName, customFieldName,

View File

@@ -9,7 +9,9 @@ describe('MachinesController', () => {
let controller: MachinesController; let controller: MachinesController;
beforeEach(async () => { beforeEach(async () => {
const mockComposantsService = { create: jest.fn() } as Partial<ComposantsService>; const mockComposantsService = {
create: jest.fn(),
} as Partial<ComposantsService>;
const mockPiecesService = { create: jest.fn() } as Partial<PiecesService>; const mockPiecesService = { create: jest.fn() } as Partial<PiecesService>;
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({

View File

@@ -8,7 +8,9 @@ describe('MachinesService', () => {
let service: MachinesService; let service: MachinesService;
beforeEach(async () => { beforeEach(async () => {
const mockComposantsService = { create: jest.fn() } as Partial<ComposantsService>; const mockComposantsService = {
create: jest.fn(),
} as Partial<ComposantsService>;
const mockPiecesService = { create: jest.fn() } as Partial<PiecesService>; const mockPiecesService = { create: jest.fn() } as Partial<PiecesService>;
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({

View File

@@ -15,8 +15,6 @@ import {
import { buildComponentHierarchy } from '../common/utils/component-tree.util'; import { buildComponentHierarchy } from '../common/utils/component-tree.util';
import { ComposantsService } from '../composants/composants.service'; import { ComposantsService } from '../composants/composants.service';
import { PiecesService } from '../pieces/pieces.service'; import { PiecesService } from '../pieces/pieces.service';
import { CreateComposantDto } from '../shared/dto/composant.dto';
import { CreatePieceDto } from '../shared/dto/piece.dto';
const CUSTOM_FIELD_SELECT = { const CUSTOM_FIELD_SELECT = {
id: true, id: true,
@@ -103,10 +101,9 @@ type ComponentRequirementWithType =
include: { typeComposant: true }; include: { typeComposant: true };
}>; }>;
type PieceRequirementWithType = type PieceRequirementWithType = Prisma.TypeMachinePieceRequirementGetPayload<{
Prisma.TypeMachinePieceRequirementGetPayload<{ include: { typePiece: true };
include: { typePiece: true }; }>;
}>;
@Injectable() @Injectable()
export class MachinesService { export class MachinesService {
@@ -238,7 +235,10 @@ export class MachinesService {
); );
} }
if (selection.typePieceId && selection.typePieceId !== requirement.typePieceId) { if (
selection.typePieceId &&
selection.typePieceId !== requirement.typePieceId
) {
throw new Error( throw new Error(
`Le type de pièce sélectionné ne correspond pas au requirement ${requirement.id}.`, `Le type de pièce sélectionné ne correspond pas au requirement ${requirement.id}.`,
); );
@@ -258,7 +258,9 @@ export class MachinesService {
if (selections.length < min) { if (selections.length < min) {
throw new Error( throw new Error(
`Le groupe de composants "${ `Le groupe de composants "${
requirement.label || requirement.typeComposant?.name || requirement.id requirement.label ||
requirement.typeComposant?.name ||
requirement.id
}" requiert au moins ${min} sélection(s).`, }" requiert au moins ${min} sélection(s).`,
); );
} }
@@ -266,7 +268,9 @@ export class MachinesService {
if (max !== undefined && selections.length > max) { if (max !== undefined && selections.length > max) {
throw new Error( throw new Error(
`Le groupe de composants "${ `Le groupe de composants "${
requirement.label || requirement.typeComposant?.name || requirement.id requirement.label ||
requirement.typeComposant?.name ||
requirement.id
}" ne peut pas dépasser ${max} sélection(s).`, }" ne peut pas dépasser ${max} sélection(s).`,
); );
} }
@@ -404,86 +408,82 @@ export class MachinesService {
return undefined; return undefined;
} }
private async buildComponentCreationDto( private async attachExistingComponentToMachine(
machineId: string, machineId: string,
requirement: ComponentRequirementWithType, requirement: ComponentRequirementWithType,
selection: MachineComponentSelectionDto, selection: MachineComponentSelectionDto,
): Promise<CreateComposantDto> { ) {
const definition = this.ensurePlainObject(selection.definition); const componentId = selection.composantId;
const dto: CreateComposantDto = { if (!componentId) {
name: this.resolveName( throw new Error('composantId manquant pour la sélection.');
definition.name,
requirement.label,
requirement.typeComposant?.name,
'Composant',
),
machineId,
typeMachineComponentRequirementId: requirement.id,
};
if (selection.typeComposantId) {
dto.typeComposantId = selection.typeComposantId;
} }
const reference = this.extractString(definition.reference); const component = await this.prisma.composant.findUnique({
if (reference) { where: { id: componentId },
dto.reference = reference; include: { typeComposant: true },
});
if (!component) {
throw new Error(`Composant introuvable (${componentId}).`);
} }
const constructeurId = await this.resolveConstructeurId( if (
definition.constructeurId ?? definition.constructeur, requirement.typeComposantId &&
); component.typeComposantId &&
if (constructeurId) { component.typeComposantId !== requirement.typeComposantId
dto.constructeurId = constructeurId; ) {
throw new Error(
`Le composant sélectionné (${component.name || component.id}) n'appartient pas à la famille attendue pour ce requirement.`,
);
} }
const prix = this.normalizePrice(definition.prix); await this.prisma.composant.update({
if (prix !== undefined) { where: { id: component.id },
dto.prix = prix; data: {
} machineId,
parentComposantId: null,
return dto; typeMachineComponentRequirementId: requirement.id,
},
});
} }
private async buildPieceCreationDto( private async attachExistingPieceToMachine(
machineId: string, machineId: string,
requirement: PieceRequirementWithType, requirement: PieceRequirementWithType,
selection: MachinePieceSelectionDto, selection: MachinePieceSelectionDto,
): Promise<CreatePieceDto> { ) {
const definition = this.ensurePlainObject(selection.definition); const pieceId = selection.pieceId;
const dto: CreatePieceDto = { if (!pieceId) {
name: this.resolveName( throw new Error('pieceId manquant pour la sélection.');
definition.name,
requirement.label,
requirement.typePiece?.name,
'Pièce',
),
machineId,
typeMachinePieceRequirementId: requirement.id,
};
if (selection.typePieceId) {
dto.typePieceId = selection.typePieceId;
} }
const reference = this.extractString(definition.reference); const piece = await this.prisma.piece.findUnique({
if (reference) { where: { id: pieceId },
dto.reference = reference; include: { typePiece: true },
});
if (!piece) {
throw new Error(`Pièce introuvable (${pieceId}).`);
} }
const constructeurId = await this.resolveConstructeurId( if (
definition.constructeurId ?? definition.constructeur, requirement.typePieceId &&
); piece.typePieceId &&
if (constructeurId) { piece.typePieceId !== requirement.typePieceId
dto.constructeurId = constructeurId; ) {
throw new Error(
`La pièce sélectionnée (${piece.name || piece.id}) n'appartient pas à la famille attendue pour ce requirement.`,
);
} }
const prix = this.normalizePrice(definition.prix); await this.prisma.piece.update({
if (prix !== undefined) { where: { id: piece.id },
dto.prix = prix; data: {
} machineId,
composantId: null,
return dto; typeMachinePieceRequirementId: requirement.id,
},
});
} }
private async createComponentsForMachine( private async createComponentsForMachine(
@@ -500,12 +500,20 @@ export class MachinesService {
for (const requirement of requirements) { for (const requirement of requirements) {
const selections = selectionMap.get(requirement.id) ?? []; const selections = selectionMap.get(requirement.id) ?? [];
for (const selection of selections) { for (const selection of selections) {
const dto = await this.buildComponentCreationDto( if (selection.composantId) {
machineId, await this.attachExistingComponentToMachine(
requirement, machineId,
selection, requirement,
selection,
);
continue;
}
throw new Error(
`Aucun composant existant fourni pour le requirement "${
requirement.label || requirement.typeComposant?.name || requirement.id
}".`,
); );
await this.composantsService.create(dto);
} }
} }
} }
@@ -524,12 +532,20 @@ export class MachinesService {
for (const requirement of requirements) { for (const requirement of requirements) {
const selections = selectionMap.get(requirement.id) ?? []; const selections = selectionMap.get(requirement.id) ?? [];
for (const selection of selections) { for (const selection of selections) {
const dto = await this.buildPieceCreationDto( if (selection.pieceId) {
machineId, await this.attachExistingPieceToMachine(
requirement, machineId,
selection, requirement,
selection,
);
continue;
}
throw new Error(
`Aucune pièce existante fournie pour le requirement "${
requirement.label || requirement.typePiece?.name || requirement.id
}".`,
); );
await this.piecesService.create(dto);
} }
} }
} }
@@ -1048,12 +1064,13 @@ export class MachinesService {
}); });
for (const customField of pieceCustomFields) { for (const customField of pieceCustomFields) {
const existingValue = await this.prisma.customFieldValue.findFirst({ const existingValue =
where: { await this.prisma.customFieldValue.findFirst({
customFieldId: customField.id, where: {
pieceId: piece.id, customFieldId: customField.id,
}, pieceId: piece.id,
}); },
});
if (!existingValue) { if (!existingValue) {
const providedValue = this.extractCustomFieldValue( const providedValue = this.extractCustomFieldValue(
@@ -1076,10 +1093,12 @@ export class MachinesService {
} }
for (const piece of machine.pieces) { for (const piece of machine.pieces) {
const typePiece = machinePieces.find( const typePiece = machinePieces.find((p: any) => p.name === piece.name);
(p: any) => p.name === piece.name, if (
); typePiece &&
if (typePiece && typePiece.customFields && typePiece.customFields.length > 0) { typePiece.customFields &&
typePiece.customFields.length > 0
) {
const typePieceFields = Array.isArray(typePiece.customFields) const typePieceFields = Array.isArray(typePiece.customFields)
? typePiece.customFields ? typePiece.customFields
: []; : [];

View File

@@ -25,4 +25,7 @@ export class CreateModelTypeDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
description?: string; description?: string;
@IsOptional()
structure?: any;
} }

View File

@@ -1,4 +1,5 @@
import { import {
BadRequestException,
ConflictException, ConflictException,
Injectable, Injectable,
NotFoundException, NotFoundException,
@@ -7,6 +8,10 @@ import { ModelType as PrismaModelType, Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { CreateModelTypeDto, ModelCategory } from './dto/create-model-type.dto'; import { CreateModelTypeDto, ModelCategory } from './dto/create-model-type.dto';
import { UpdateModelTypeDto } from './dto/update-model-type.dto'; import { UpdateModelTypeDto } from './dto/update-model-type.dto';
import {
ComponentModelStructureSchema,
PieceModelStructureSchema,
} from '../shared/schemas/inventory';
type SortField = 'name' | 'code' | 'createdAt'; type SortField = 'name' | 'code' | 'createdAt';
type SortDirection = 'asc' | 'desc'; type SortDirection = 'asc' | 'desc';
@@ -31,7 +36,7 @@ export class ModelTypeService {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
async list(params: ListParams): Promise<{ async list(params: ListParams): Promise<{
items: PrismaModelType[]; items: ReturnType<ModelTypeService['mapModelType']>[];
total: number; total: number;
offset: number; offset: number;
limit: number; limit: number;
@@ -78,41 +83,110 @@ export class ModelTypeService {
]); ]);
return { return {
items, items: items.map((item) => this.mapModelType(item)),
total, total,
offset: safeOffset, offset: safeOffset,
limit: cappedLimit, limit: cappedLimit,
}; };
} }
async create(dto: CreateModelTypeDto): Promise<PrismaModelType> { async create(
dto: CreateModelTypeDto,
): Promise<ReturnType<ModelTypeService['mapModelType']>> {
try { try {
return await this.prisma.modelType.create({ const { structure, ...rest } = dto;
data: {
name: dto.name, const data: Prisma.ModelTypeCreateInput = {
code: dto.code, name: rest.name,
category: dto.category, code: rest.code,
notes: dto.notes, category: rest.category,
description: dto.description ?? null, notes: rest.notes,
}, description: rest.description ?? null,
}); };
const normalizedStructure = this.normalizeStructure(
rest.category,
structure,
);
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;
}
}
const created = await this.prisma.modelType.create({ data });
return this.mapModelType(created);
} catch (error) { } catch (error) {
this.handlePrismaError(error); this.handlePrismaError(error);
} }
} }
async update(id: string, dto: UpdateModelTypeDto): Promise<PrismaModelType> { async update(
id: string,
dto: UpdateModelTypeDto,
): Promise<ReturnType<ModelTypeService['mapModelType']>> {
try { try {
return await this.prisma.modelType.update({ const existing = await this.prisma.modelType.findUnique({
where: { id }, where: { id },
data: {
...dto,
description:
dto.description === undefined
? undefined
: (dto.description ?? null),
},
}); });
if (!existing) {
throw new NotFoundException('Type de modèle introuvable.');
}
const targetCategory =
dto.category ?? (existing.category as ModelCategory);
const data: Prisma.ModelTypeUpdateInput = {};
if (dto.name !== undefined) {
data.name = dto.name;
}
if (dto.code !== undefined) {
data.code = dto.code;
}
if (dto.category !== undefined) {
data.category = dto.category;
}
if (dto.notes !== undefined) {
data.notes = dto.notes;
}
data.description =
dto.description === undefined ? undefined : (dto.description ?? null);
const normalizedStructure = this.normalizeStructure(
targetCategory,
dto.structure,
);
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;
}
}
const updated = await this.prisma.modelType.update({
where: { id },
data,
});
return this.mapModelType(updated);
} catch (error) { } catch (error) {
this.handlePrismaError(error); this.handlePrismaError(error);
} }
@@ -126,12 +200,14 @@ export class ModelTypeService {
} }
} }
async findOne(id: string): Promise<PrismaModelType> { async findOne(
id: string,
): Promise<ReturnType<ModelTypeService['mapModelType']>> {
const modelType = await this.prisma.modelType.findUnique({ where: { id } }); const modelType = await this.prisma.modelType.findUnique({ where: { id } });
if (!modelType) { if (!modelType) {
throw new NotFoundException('Type de modèle introuvable.'); throw new NotFoundException('Type de modèle introuvable.');
} }
return modelType; return this.mapModelType(modelType);
} }
private handlePrismaError(error: unknown): never { private handlePrismaError(error: unknown): never {
@@ -158,4 +234,44 @@ export class ModelTypeService {
} }
return false; return false;
} }
private normalizeStructure(
category: ModelCategory,
structure: unknown,
): Prisma.InputJsonValue | null | undefined {
if (structure === undefined) {
return undefined;
}
if (structure === null) {
return null;
}
try {
if (category === ModelCategory.COMPONENT) {
return ComponentModelStructureSchema.parse(
structure,
) as Prisma.InputJsonValue;
}
return PieceModelStructureSchema.parse(
structure,
) as Prisma.InputJsonValue;
} catch (error) {
const message =
error instanceof Error ? error.message : 'Structure invalide.';
throw new BadRequestException(message);
}
}
private mapModelType(modelType: PrismaModelType) {
const structure =
modelType.category === ModelCategory.COMPONENT
? (modelType.componentSkeleton ?? null)
: (modelType.pieceSkeleton ?? null);
return {
...modelType,
structure,
} as PrismaModelType & { structure: Prisma.InputJsonValue | null };
}
} }

View File

@@ -98,9 +98,7 @@ describe('PiecesService', () => {
prisma.customField.findMany prisma.customField.findMany
.mockResolvedValueOnce([]) .mockResolvedValueOnce([])
.mockResolvedValueOnce([ .mockResolvedValueOnce([{ id: 'field-1', name: 'Numéro de série' }]);
{ id: 'field-1', name: 'Numéro de série' },
]);
prisma.customField.create.mockResolvedValue({ id: 'field-1' }); prisma.customField.create.mockResolvedValue({ id: 'field-1' });
prisma.customFieldValue.findMany.mockResolvedValue([]); prisma.customFieldValue.findMany.mockResolvedValue([]);
prisma.customFieldValue.create.mockResolvedValue({ prisma.customFieldValue.create.mockResolvedValue({

View File

@@ -36,9 +36,15 @@ export class PiecesService {
constructor(private prisma: PrismaService) {} constructor(private prisma: PrismaService) {}
async create(createPieceDto: CreatePieceDto) { async create(createPieceDto: CreatePieceDto) {
const requirementId = createPieceDto.typeMachinePieceRequirementId; const requirementId = createPieceDto.typeMachinePieceRequirementId ?? null;
let machineId = createPieceDto.machineId; if (requirementId && !createPieceDto.machineId) {
throw new BadRequestException(
'Un requirement ne peut pas être utilisé sans machine ciblée.',
);
}
let machineId = createPieceDto.machineId ?? null;
if (createPieceDto.composantId) { if (createPieceDto.composantId) {
const composantMachineId = await this.resolveMachineIdFromComposant( const composantMachineId = await this.resolveMachineIdFromComposant(
@@ -51,59 +57,69 @@ export class PiecesService {
); );
} }
machineId = composantMachineId; machineId = composantMachineId ?? machineId;
} }
if (!machineId) { let requirement: PieceRequirementWithType | null = null;
throw new BadRequestException(
'Un machineId ou un composantId valide est requis pour créer une pièce.',
);
}
const machine = await this.prisma.machine.findUnique({ if (machineId) {
where: { id: machineId }, const machine = await this.prisma.machine.findUnique({
include: { where: { id: machineId },
typeMachine: { include: {
include: { typeMachine: {
pieceRequirements: { include: {
include: { pieceRequirements: {
typePiece: true, include: {
typePiece: true,
},
}, },
}, },
}, },
}, },
}, });
});
if (!machine || !machine.typeMachine) { if (!machine || !machine.typeMachine) {
throw new BadRequestException( throw new BadRequestException(
'La machine ciblée doit être associée à un type de machine pour valider les requirements.', 'La machine ciblée doit être associée à un type de machine pour valider les requirements.',
); );
}
if (requirementId) {
requirement =
(
machine.typeMachine.pieceRequirements as PieceRequirementWithType[]
).find((pieceRequirement) => pieceRequirement.id === requirementId) ??
null;
if (!requirement) {
throw new BadRequestException(
'Le requirement de pièce fourni ne correspond pas au squelette de la machine.',
);
}
if (
createPieceDto.typePieceId &&
createPieceDto.typePieceId !== requirement.typePieceId
) {
throw new BadRequestException(
'Le type de pièce fourni ne correspond pas au requirement pour cette machine.',
);
}
}
} }
const requirement = machine.typeMachine.pieceRequirements.find( const typePieceId =
(pieceRequirement) => pieceRequirement.id === requirementId, createPieceDto.typePieceId ?? requirement?.typePieceId ?? null;
);
if (!requirement) { const data: Prisma.PieceUncheckedCreateInput = {
throw new BadRequestException( name: createPieceDto.name,
'Le requirement de pièce fourni ne correspond pas au squelette de la machine.', reference: createPieceDto.reference ?? null,
); constructeurId: createPieceDto.constructeurId ?? null,
} prix: createPieceDto.prix !== undefined ? createPieceDto.prix : null,
if (
createPieceDto.typePieceId &&
createPieceDto.typePieceId !== requirement.typePieceId
) {
throw new BadRequestException(
'Le type de pièce fourni ne correspond pas au requirement pour cette machine.',
);
}
const data = {
...createPieceDto,
machineId, machineId,
typePieceId: createPieceDto.typePieceId ?? requirement.typePieceId, composantId: createPieceDto.composantId ?? null,
typePieceId,
typeMachinePieceRequirementId: requirement?.id ?? requirementId ?? null,
}; };
const created = await this.prisma.piece.create({ const created = await this.prisma.piece.create({
@@ -113,10 +129,7 @@ export class PiecesService {
await this.applyPieceSkeleton({ await this.applyPieceSkeleton({
pieceId: created.id, pieceId: created.id,
typePiece: typePiece: created.typePiece as PieceTypeWithSkeleton | null,
(requirement.typePiece as PieceTypeWithSkeleton | null) ??
(created.typePiece as PieceTypeWithSkeleton | null) ??
null,
}); });
return this.prisma.piece.findUnique({ return this.prisma.piece.findUnique({
@@ -217,8 +230,8 @@ export class PiecesService {
} }
const skeleton = this.parsePieceSkeleton( const skeleton = this.parsePieceSkeleton(
(typePiece as { pieceSkeleton?: Prisma.JsonValue | null } | null)?. (typePiece as { pieceSkeleton?: Prisma.JsonValue | null } | null)
pieceSkeleton, ?.pieceSkeleton,
); );
if (!skeleton) { if (!skeleton) {
@@ -227,10 +240,7 @@ export class PiecesService {
const customFields = skeleton.customFields ?? []; const customFields = skeleton.customFields ?? [];
await this.ensurePieceCustomFieldDefinitions( await this.ensurePieceCustomFieldDefinitions(typePiece.id, customFields);
typePiece.id,
customFields,
);
await this.createPieceCustomFieldValues( await this.createPieceCustomFieldValues(
pieceId, pieceId,
@@ -255,7 +265,11 @@ export class PiecesService {
typePieceId: string, typePieceId: string,
customFields: PieceModelStructure['customFields'], customFields: PieceModelStructure['customFields'],
) { ) {
if (!typePieceId || !Array.isArray(customFields) || customFields.length === 0) { if (
!typePieceId ||
!Array.isArray(customFields) ||
customFields.length === 0
) {
return; return;
} }
@@ -265,7 +279,10 @@ export class PiecesService {
}); });
const existingByName = new Map( const existingByName = new Map(
existing.map((field) => [this.normalizeIdentifier(field.name) ?? field.name, field.id]), existing.map((field) => [
this.normalizeIdentifier(field.name) ?? field.name,
field.id,
]),
); );
for (const field of customFields) { for (const field of customFields) {
@@ -306,7 +323,11 @@ export class PiecesService {
typePieceId: string, typePieceId: string,
customFields: PieceModelStructure['customFields'], customFields: PieceModelStructure['customFields'],
) { ) {
if (!typePieceId || !Array.isArray(customFields) || customFields.length === 0) { if (
!typePieceId ||
!Array.isArray(customFields) ||
customFields.length === 0
) {
return; return;
} }
@@ -320,7 +341,10 @@ export class PiecesService {
} }
const definitionMap = new Map( const definitionMap = new Map(
definitions.map((field) => [this.normalizeIdentifier(field.name) ?? field.name, field.id]), definitions.map((field) => [
this.normalizeIdentifier(field.name) ?? field.name,
field.id,
]),
); );
const existingValues = await this.prisma.customFieldValue.findMany({ const existingValues = await this.prisma.customFieldValue.findMany({
@@ -328,7 +352,9 @@ export class PiecesService {
select: { customFieldId: true }, select: { customFieldId: true },
}); });
const existingIds = new Set(existingValues.map((value) => value.customFieldId)); const existingIds = new Set(
existingValues.map((value) => value.customFieldId),
);
for (const field of customFields) { for (const field of customFields) {
if (!field) { if (!field) {
@@ -363,9 +389,7 @@ export class PiecesService {
const rawOptions = field?.options; const rawOptions = field?.options;
if (Array.isArray(rawOptions)) { if (Array.isArray(rawOptions)) {
const normalized = rawOptions const normalized = rawOptions
.map((option) => .map((option) => (typeof option === 'string' ? option.trim() : ''))
typeof option === 'string' ? option.trim() : '',
)
.filter((option) => option.length > 0); .filter((option) => option.length > 0);
return normalized.length > 0 ? normalized : undefined; return normalized.length > 0 ? normalized : undefined;

View File

@@ -1,15 +1,15 @@
import { IsString, IsOptional, IsNumber, ValidateIf } from 'class-validator'; import { IsString, IsOptional, IsNumber } from 'class-validator';
import { Transform } from 'class-transformer'; import { Transform } from 'class-transformer';
export class CreateComposantDto { export class CreateComposantDto {
@IsString() @IsString()
name: string; name: string;
@ValidateIf((dto) => !dto.parentComposantId) @IsOptional()
@IsString() @IsString()
machineId?: string; machineId?: string;
@ValidateIf((dto) => !dto.machineId) @IsOptional()
@IsString() @IsString()
parentComposantId?: string; parentComposantId?: string;
@@ -30,8 +30,9 @@ export class CreateComposantDto {
@IsString() @IsString()
typeComposantId?: string; typeComposantId?: string;
@IsOptional()
@IsString() @IsString()
typeMachineComponentRequirementId: string; typeMachineComponentRequirementId?: string;
} }
export class UpdateComposantDto { export class UpdateComposantDto {

View File

@@ -10,6 +10,10 @@ export class MachineComponentSelectionDto {
@IsString() @IsString()
typeComposantId?: string; typeComposantId?: string;
@IsOptional()
@IsString()
composantId?: string;
@IsOptional() @IsOptional()
definition?: any; definition?: any;
} }
@@ -22,6 +26,10 @@ export class MachinePieceSelectionDto {
@IsString() @IsString()
typePieceId?: string; typePieceId?: string;
@IsOptional()
@IsString()
pieceId?: string;
@IsOptional() @IsOptional()
definition?: any; definition?: any;
} }

View File

@@ -1,15 +1,15 @@
import { IsString, IsOptional, IsNumber, ValidateIf } from 'class-validator'; import { IsString, IsOptional, IsNumber } from 'class-validator';
import { Transform } from 'class-transformer'; import { Transform } from 'class-transformer';
export class CreatePieceDto { export class CreatePieceDto {
@IsString() @IsString()
name: string; name: string;
@ValidateIf((dto) => !dto.composantId) @IsOptional()
@IsString() @IsString()
machineId?: string; machineId?: string;
@ValidateIf((dto) => !dto.machineId) @IsOptional()
@IsString() @IsString()
composantId?: string; composantId?: string;
@@ -30,8 +30,9 @@ export class CreatePieceDto {
@IsString() @IsString()
typePieceId?: string; typePieceId?: string;
@IsOptional()
@IsString() @IsString()
typeMachinePieceRequirementId: string; typeMachinePieceRequirementId?: string;
} }
export class UpdatePieceDto { export class UpdatePieceDto {

View File

@@ -258,4 +258,3 @@ export class UpdateTypePieceDto {
@IsObject() @IsObject()
structure?: PieceModelStructure; structure?: PieceModelStructure;
} }

View File

@@ -1,4 +1,12 @@
import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common'; import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
} from '@nestjs/common';
import { TypesService } from './types.service'; import { TypesService } from './types.service';
import { import {
CreateTypeMachineDto, CreateTypeMachineDto,