feat(machine): support skeleton reconfiguration

This commit is contained in:
MatthieuTD
2025-09-22 10:19:33 +02:00
parent b6ca9ae54b
commit 55b20a66b4
4 changed files with 563 additions and 326 deletions

View File

@@ -1,6 +1,10 @@
import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common';
import { MachinesService } from './machines.service';
import { CreateMachineDto, UpdateMachineDto } from '../shared/dto/machine.dto';
import {
CreateMachineDto,
UpdateMachineDto,
ReconfigureMachineDto,
} from '../shared/dto/machine.dto';
@Controller('machines')
export class MachinesController {
@@ -26,6 +30,14 @@ export class MachinesController {
return this.machinesService.update(id, updateMachineDto);
}
@Patch(':id/skeleton')
reconfigure(
@Param('id') id: string,
@Body() reconfigureMachineDto: ReconfigureMachineDto,
) {
return this.machinesService.reconfigure(id, reconfigureMachineDto);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.machinesService.remove(id);

View File

@@ -1,53 +1,125 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import {
CreateMachineDto,
import {
CreateMachineDto,
UpdateMachineDto,
ReconfigureMachineDto,
MachineComponentSelectionDto,
MachinePieceSelectionDto
MachinePieceSelectionDto,
} from '../shared/dto/machine.dto';
const TYPE_MACHINE_CONFIGURATION_INCLUDE = {
customFields: true,
componentRequirements: {
include: {
typeComposant: true,
},
},
pieceRequirements: {
include: {
typePiece: true,
},
},
};
const MACHINE_DEFAULT_INCLUDE = {
site: true,
typeMachine: {
include: TYPE_MACHINE_CONFIGURATION_INCLUDE,
},
constructeur: true,
composants: {
include: {
typeComposant: true,
composantModel: true,
typeMachineComponentRequirement: {
include: {
typeComposant: true,
},
},
sousComposants: true,
customFieldValues: {
include: {
customField: true,
},
},
constructeur: true,
pieces: {
include: {
customFieldValues: {
include: {
customField: true,
},
},
constructeur: true,
pieceModel: true,
typeMachinePieceRequirement: {
include: {
typePiece: true,
},
},
},
},
},
},
pieces: {
include: {
customFieldValues: {
include: {
customField: true,
},
},
constructeur: true,
pieceModel: true,
typeMachinePieceRequirement: {
include: {
typePiece: true,
},
},
},
},
customFieldValues: {
include: {
customField: true,
},
},
documents: true,
};
@Injectable()
export class MachinesService {
constructor(private prisma: PrismaService) {}
async create(createMachineDto: CreateMachineDto) {
const {
componentSelections = [],
pieceSelections = [],
...machineData
} = createMachineDto;
if (!machineData.typeMachineId) {
throw new Error('typeMachineId est requis pour créer une machine à partir d\'un squelette.');
}
private async getTypeMachineConfiguration(typeMachineId: string) {
const typeMachine = await this.prisma.typeMachine.findUnique({
where: { id: machineData.typeMachineId },
include: {
customFields: true,
componentRequirements: {
include: {
typeComposant: true,
},
},
pieceRequirements: {
include: {
typePiece: true,
},
},
},
where: { id: typeMachineId },
include: TYPE_MACHINE_CONFIGURATION_INCLUDE,
});
if (!typeMachine) {
throw new Error('Type de machine non trouvé');
}
return typeMachine;
}
private async buildConfigurationContext(
typeMachine: any,
componentSelections: MachineComponentSelectionDto[],
pieceSelections: MachinePieceSelectionDto[],
) {
const componentRequirements = (Array.isArray(typeMachine.componentRequirements)
? typeMachine.componentRequirements
: []) as any[];
const pieceRequirements = (Array.isArray(typeMachine.pieceRequirements)
? typeMachine.pieceRequirements
: []) as any[];
const componentRequirementMap = new Map(
typeMachine.componentRequirements.map((requirement) => [requirement.id, requirement]),
componentRequirements.map((requirement: any) => [requirement.id, requirement]),
);
const pieceRequirementMap = new Map(
typeMachine.pieceRequirements.map((requirement) => [requirement.id, requirement]),
pieceRequirements.map((requirement: any) => [requirement.id, requirement]),
);
const componentSelectionMap = new Map<string, MachineComponentSelectionDto[]>();
@@ -94,7 +166,7 @@ export class MachinesService {
: [];
const pieceModelMap = new Map(pieceModels.map((model) => [model.id, model]));
for (const requirement of typeMachine.componentRequirements) {
for (const requirement of componentRequirements) {
const selections = componentSelectionMap.get(requirement.id) ?? [];
const min = requirement.minCount ?? (requirement.required ? 1 : 0);
const max = requirement.maxCount ?? undefined;
@@ -121,7 +193,7 @@ export class MachinesService {
}
}
for (const requirement of typeMachine.pieceRequirements) {
for (const requirement of pieceRequirements) {
const selections = pieceSelectionMap.get(requirement.id) ?? [];
const min = requirement.minCount ?? (requirement.required ? 1 : 0);
const max = requirement.maxCount ?? undefined;
@@ -186,7 +258,42 @@ export class MachinesService {
}
}
return await this.prisma.$transaction(async (prisma) => {
return {
componentSelectionMap,
pieceSelectionMap,
componentModelMap,
pieceModelMap,
};
}
async create(createMachineDto: CreateMachineDto) {
const {
componentSelections = [],
pieceSelections = [],
...machineData
} = createMachineDto;
if (!machineData.typeMachineId) {
throw new Error('typeMachineId est requis pour créer une machine à partir d\'un squelette.');
}
const typeMachine = await this.getTypeMachineConfiguration(machineData.typeMachineId);
const {
componentSelectionMap,
pieceSelectionMap,
componentModelMap,
pieceModelMap,
} = await this.buildConfigurationContext(typeMachine, componentSelections, pieceSelections);
const componentRequirements = (Array.isArray(typeMachine.componentRequirements)
? typeMachine.componentRequirements
: []) as any[];
const pieceRequirements = (Array.isArray(typeMachine.pieceRequirements)
? typeMachine.pieceRequirements
: []) as any[];
return this.prisma.$transaction(async (prisma) => {
const machine = await prisma.machine.create({
data: machineData,
include: {
@@ -196,8 +303,8 @@ export class MachinesService {
},
});
if (typeMachine.componentRequirements.length > 0) {
for (const requirement of typeMachine.componentRequirements) {
if (componentRequirements.length > 0) {
for (const requirement of componentRequirements) {
const selections = componentSelectionMap.get(requirement.id) ?? [];
for (const selection of selections) {
const model = selection.componentModelId ? componentModelMap.get(selection.componentModelId) : undefined;
@@ -212,8 +319,8 @@ export class MachinesService {
}
}
if (typeMachine.pieceRequirements.length > 0) {
for (const requirement of typeMachine.pieceRequirements) {
if (pieceRequirements.length > 0) {
for (const requirement of pieceRequirements) {
const selections = pieceSelectionMap.get(requirement.id) ?? [];
for (const selection of selections) {
const model = selection.pieceModelId ? pieceModelMap.get(selection.pieceModelId) : undefined;
@@ -234,76 +341,7 @@ export class MachinesService {
return prisma.machine.findUnique({
where: { id: machine.id },
include: {
site: true,
typeMachine: {
include: {
customFields: true,
componentRequirements: {
include: {
typeComposant: true,
},
},
pieceRequirements: {
include: {
typePiece: true,
},
},
},
},
constructeur: true,
composants: {
include: {
typeComposant: true,
composantModel: true,
typeMachineComponentRequirement: {
include: {
typeComposant: true,
},
},
sousComposants: true,
pieces: {
include: {
customFieldValues: {
include: {
customField: true,
},
},
constructeur: true,
pieceModel: true,
typeMachinePieceRequirement: {
include: {
typePiece: true,
},
},
},
},
constructeur: true,
},
},
pieces: {
include: {
customFieldValues: {
include: {
customField: true,
},
},
constructeur: true,
pieceModel: true,
typeMachinePieceRequirement: {
include: {
typePiece: true,
},
},
},
},
customFieldValues: {
include: {
customField: true,
},
},
documents: true,
},
include: MACHINE_DEFAULT_INCLUDE,
});
});
}
@@ -660,239 +698,146 @@ export class MachinesService {
async findAll() {
return this.prisma.machine.findMany({
include: {
site: true,
typeMachine: {
include: {
customFields: true,
componentRequirements: {
include: {
typeComposant: true,
},
},
pieceRequirements: {
include: {
typePiece: true,
},
},
},
},
constructeur: true,
composants: {
include: {
typeComposant: true,
composantModel: true,
typeMachineComponentRequirement: {
include: {
typeComposant: true,
},
},
sousComposants: true,
customFieldValues: {
include: {
customField: true,
},
},
constructeur: true,
pieces: {
include: {
customFieldValues: {
include: {
customField: true,
},
},
constructeur: true,
pieceModel: true,
typeMachinePieceRequirement: {
include: {
typePiece: true,
},
},
},
},
},
},
pieces: {
include: {
customFieldValues: {
include: {
customField: true,
},
},
constructeur: true,
pieceModel: true,
typeMachinePieceRequirement: {
include: {
typePiece: true,
},
},
},
},
customFieldValues: {
include: {
customField: true,
},
},
documents: true,
},
include: MACHINE_DEFAULT_INCLUDE,
});
}
async findOne(id: string) {
return this.prisma.machine.findUnique({
where: { id },
include: MACHINE_DEFAULT_INCLUDE,
});
}
async reconfigure(id: string, reconfigureMachineDto: ReconfigureMachineDto) {
const {
componentSelections = [],
pieceSelections = [],
} = reconfigureMachineDto;
const machine = await this.prisma.machine.findUnique({
where: { id },
include: {
site: true,
typeMachine: {
include: {
customFields: true,
componentRequirements: {
include: {
typeComposant: true,
},
},
pieceRequirements: {
include: {
typePiece: true,
},
},
},
include: TYPE_MACHINE_CONFIGURATION_INCLUDE,
},
constructeur: true,
composants: {
include: {
typeComposant: true,
composantModel: true,
typeMachineComponentRequirement: {
include: {
typeComposant: true,
},
},
sousComposants: true,
customFieldValues: {
include: {
customField: true,
},
},
constructeur: true,
pieces: {
include: {
customFieldValues: {
include: {
customField: true,
},
},
constructeur: true,
pieceModel: true,
typeMachinePieceRequirement: {
include: {
typePiece: true,
},
},
},
},
},
},
pieces: {
include: {
customFieldValues: {
include: {
customField: true,
},
},
constructeur: true,
pieceModel: true,
typeMachinePieceRequirement: {
include: {
typePiece: true,
},
},
},
},
customFieldValues: {
include: {
customField: true,
},
},
documents: true,
},
});
if (!machine) {
throw new Error('Machine non trouvée');
}
if (!machine.typeMachineId || !machine.typeMachine) {
throw new Error('Impossible de reconfigurer une machine sans type de machine associé.');
}
const typeMachine = machine.typeMachine;
const {
componentSelectionMap,
pieceSelectionMap,
componentModelMap,
pieceModelMap,
} = await this.buildConfigurationContext(typeMachine, componentSelections, pieceSelections);
const componentRequirements = (Array.isArray(typeMachine.componentRequirements)
? typeMachine.componentRequirements
: []) as any[];
const pieceRequirements = (Array.isArray(typeMachine.pieceRequirements)
? typeMachine.pieceRequirements
: []) as any[];
return this.prisma.$transaction(async (prisma) => {
await prisma.customFieldValue.deleteMany({
where: {
OR: [
{
composant: {
machineId: id,
typeMachineComponentRequirementId: { not: null },
},
},
{
piece: {
machineId: id,
typeMachinePieceRequirementId: { not: null },
},
},
{
piece: {
composant: {
machineId: id,
typeMachineComponentRequirementId: { not: null },
},
},
},
],
},
});
await prisma.piece.deleteMany({
where: {
machineId: id,
typeMachinePieceRequirementId: { not: null },
},
});
await prisma.composant.deleteMany({
where: {
machineId: id,
typeMachineComponentRequirementId: { not: null },
},
});
if (componentRequirements.length > 0) {
for (const requirement of componentRequirements) {
const selections = componentSelectionMap.get(requirement.id) ?? [];
for (const selection of selections) {
const model = selection.componentModelId
? componentModelMap.get(selection.componentModelId)
: undefined;
const definition = this.normalizeComponentSelection(selection, requirement, model);
await this.createComponentsFromType(prisma, id, [definition]);
}
}
} else {
const legacyComponents = (typeMachine as any).components;
if (legacyComponents) {
await this.createComponentsFromType(prisma, id, legacyComponents);
}
}
if (pieceRequirements.length > 0) {
for (const requirement of pieceRequirements) {
const selections = pieceSelectionMap.get(requirement.id) ?? [];
for (const selection of selections) {
const model = selection.pieceModelId
? pieceModelMap.get(selection.pieceModelId)
: undefined;
const definition = this.normalizePieceSelection(selection, requirement, model);
await this.createMachinePiecesFromType(prisma, id, [definition]);
}
}
} else {
const legacyPieces = (typeMachine as any).machinePieces;
if (legacyPieces) {
await this.createMachinePiecesFromType(prisma, id, legacyPieces);
}
}
return prisma.machine.findUnique({
where: { id },
include: MACHINE_DEFAULT_INCLUDE,
});
});
}
async update(id: string, updateMachineDto: UpdateMachineDto) {
return this.prisma.machine.update({
where: { id },
data: updateMachineDto,
include: {
site: true,
typeMachine: {
include: {
customFields: true,
componentRequirements: {
include: {
typeComposant: true,
},
},
pieceRequirements: {
include: {
typePiece: true,
},
},
},
},
constructeur: true,
composants: {
include: {
typeComposant: true,
composantModel: true,
typeMachineComponentRequirement: {
include: {
typeComposant: true,
},
},
sousComposants: true,
constructeur: true,
pieces: {
include: {
constructeur: true,
pieceModel: true,
typeMachinePieceRequirement: {
include: {
typePiece: true,
},
},
customFieldValues: {
include: {
customField: true,
},
},
},
},
},
},
pieces: {
include: {
constructeur: true,
pieceModel: true,
typeMachinePieceRequirement: {
include: {
typePiece: true,
},
},
customFieldValues: {
include: {
customField: true,
},
},
},
},
customFieldValues: {
include: {
customField: true,
},
},
documents: true,
},
include: MACHINE_DEFAULT_INCLUDE,
});
}

View File

@@ -90,4 +90,18 @@ export class UpdateMachineDto {
@IsOptional()
@IsString()
typeMachineId?: string;
}
}
export class ReconfigureMachineDto {
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => MachineComponentSelectionDto)
componentSelections?: MachineComponentSelectionDto[];
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => MachinePieceSelectionDto)
pieceSelections?: MachinePieceSelectionDto[];
}

View File

@@ -3,17 +3,31 @@ import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { App } from 'supertest/types';
import { AppModule } from './../src/app.module';
import { PrismaService } from '../src/prisma/prisma.service';
describe('AppController (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
beforeEach(async () => {
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
prisma = app.get(PrismaService);
});
afterAll(async () => {
await app.close();
});
beforeEach(async () => {
await prisma.$executeRawUnsafe(
'TRUNCATE TABLE custom_field_values, documents, pieces, composants, machines, composant_models, piece_models, type_machine_component_requirements, type_machine_piece_requirements, custom_fields, type_machines, type_composants, type_pieces, constructeurs, sites RESTART IDENTITY CASCADE',
);
});
it('/ (GET)', () => {
@@ -22,4 +36,256 @@ describe('AppController (e2e)', () => {
.expect(200)
.expect('Hello World!');
});
describe('Machines skeleton reconfiguration', () => {
it('reconfigures machine skeleton according to updated requirements', async () => {
const site = await prisma.site.create({
data: {
name: 'Site principal',
contactName: 'Jane Doe',
contactPhone: '0102030405',
contactAddress: '1 rue Principale',
contactPostalCode: '75000',
contactCity: 'Paris',
},
});
const typeComposant = await prisma.typeComposant.create({
data: {
name: 'Module',
description: 'Module principal',
},
});
await prisma.customField.create({
data: {
name: 'Serial',
type: 'text',
required: false,
options: [],
typeComposantId: typeComposant.id,
},
});
const typePiece = await prisma.typePiece.create({
data: {
name: 'Pièce de rechange',
description: 'Pièce critique',
},
});
await prisma.customField.create({
data: {
name: 'Batch',
type: 'text',
required: false,
options: [],
typePieceId: typePiece.id,
},
});
const componentModelV1 = await prisma.composantModel.create({
data: {
name: 'Module V1',
typeComposantId: typeComposant.id,
structure: {
name: 'Module V1',
customFields: [{ name: 'Serial', defaultValue: 'SERIAL-V1' }],
},
},
});
const componentModelV2 = await prisma.composantModel.create({
data: {
name: 'Module V2',
typeComposantId: typeComposant.id,
structure: {
name: 'Module V2',
customFields: [{ name: 'Serial', defaultValue: 'SERIAL-V2' }],
},
},
});
const pieceModelV1 = await prisma.pieceModel.create({
data: {
name: 'Pièce V1',
typePieceId: typePiece.id,
structure: {
name: 'Pièce V1',
customFields: [{ name: 'Batch', defaultValue: 'BATCH-V1' }],
},
},
});
const pieceModelV2 = await prisma.pieceModel.create({
data: {
name: 'Pièce V2',
typePieceId: typePiece.id,
structure: {
name: 'Pièce V2',
customFields: [{ name: 'Batch', defaultValue: 'BATCH-V2' }],
},
},
});
const typeMachine = await prisma.typeMachine.create({
data: {
name: 'Type Machine A',
componentRequirements: {
create: [
{
label: 'Module initial',
minCount: 1,
maxCount: 1,
required: true,
allowNewModels: false,
typeComposantId: typeComposant.id,
},
],
},
pieceRequirements: {
create: [
{
label: 'Pièce initiale',
minCount: 1,
maxCount: 1,
required: true,
allowNewModels: false,
typePieceId: typePiece.id,
},
],
},
},
include: {
componentRequirements: true,
pieceRequirements: true,
},
});
const initialComponentRequirement = typeMachine.componentRequirements[0];
const initialPieceRequirement = typeMachine.pieceRequirements[0];
const createResponse = await request(app.getHttpServer())
.post('/machines')
.send({
name: 'Machine Alpha',
siteId: site.id,
typeMachineId: typeMachine.id,
componentSelections: [
{
requirementId: initialComponentRequirement.id,
componentModelId: componentModelV1.id,
},
],
pieceSelections: [
{
requirementId: initialPieceRequirement.id,
pieceModelId: pieceModelV1.id,
},
],
})
.expect(201);
const machineId = createResponse.body.id as string;
const initialComponents = await prisma.composant.findMany({ where: { machineId } });
expect(initialComponents).toHaveLength(1);
const initialComponentId = initialComponents[0].id;
const initialPieces = await prisma.piece.findMany({ where: { machineId } });
expect(initialPieces).toHaveLength(1);
const initialPieceId = initialPieces[0].id;
expect(
await prisma.customFieldValue.count({ where: { composantId: initialComponentId } }),
).toBe(1);
expect(await prisma.customFieldValue.count({ where: { pieceId: initialPieceId } })).toBe(1);
await prisma.typeMachineComponentRequirement.deleteMany({
where: { typeMachineId: typeMachine.id },
});
await prisma.typeMachinePieceRequirement.deleteMany({
where: { typeMachineId: typeMachine.id },
});
const newComponentRequirement = await prisma.typeMachineComponentRequirement.create({
data: {
label: 'Modules mis à jour',
minCount: 2,
maxCount: 2,
required: true,
allowNewModels: true,
typeMachineId: typeMachine.id,
typeComposantId: typeComposant.id,
},
});
const newPieceRequirement = await prisma.typeMachinePieceRequirement.create({
data: {
label: 'Pièce mise à jour',
minCount: 1,
maxCount: 1,
required: true,
allowNewModels: false,
typeMachineId: typeMachine.id,
typePieceId: typePiece.id,
},
});
const reconfigureResponse = await request(app.getHttpServer())
.patch(`/machines/${machineId}/skeleton`)
.send({
componentSelections: [
{
requirementId: newComponentRequirement.id,
componentModelId: componentModelV2.id,
},
{
requirementId: newComponentRequirement.id,
definition: {
name: 'Module personnalisé',
customFields: [{ name: 'Serial', defaultValue: 'SERIAL-CUSTOM' }],
},
},
],
pieceSelections: [
{
requirementId: newPieceRequirement.id,
pieceModelId: pieceModelV2.id,
},
],
})
.expect(200);
expect(reconfigureResponse.body.composants).toHaveLength(2);
expect(reconfigureResponse.body.pieces).toHaveLength(1);
const componentsAfter = await prisma.composant.findMany({ where: { machineId } });
expect(componentsAfter).toHaveLength(2);
const componentIdsAfter = componentsAfter.map((component) => component.id);
expect(componentIdsAfter).not.toContain(initialComponentId);
componentsAfter.forEach((component) => {
expect(component.typeMachineComponentRequirementId).toBe(newComponentRequirement.id);
});
const componentValueCount = await prisma.customFieldValue.count({
where: { composantId: { in: componentIdsAfter } },
});
expect(componentValueCount).toBe(2);
expect(
await prisma.customFieldValue.count({ where: { composantId: initialComponentId } }),
).toBe(0);
const piecesAfter = await prisma.piece.findMany({ where: { machineId } });
expect(piecesAfter).toHaveLength(1);
const [updatedPiece] = piecesAfter;
expect(updatedPiece.typeMachinePieceRequirementId).toBe(newPieceRequirement.id);
expect(updatedPiece.id).not.toBe(initialPieceId);
expect(
await prisma.customFieldValue.count({ where: { pieceId: updatedPiece.id } }),
).toBe(1);
expect(await prisma.customFieldValue.count({ where: { pieceId: initialPieceId } })).toBe(0);
});
});
});