Merge branch 'master' into codex/add-e2e-tests-for-type-creation-and-editing

This commit is contained in:
MatthieuTD
2025-09-22 10:24:14 +02:00
committed by GitHub
14 changed files with 1040 additions and 363 deletions

View File

@@ -1,19 +1,84 @@
import { BadRequestException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { ComposantsService } from './composants.service'; import { ComposantsService } from './composants.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { CreateComposantDto } from '../shared/dto/composant.dto';
describe('ComposantsService', () => { describe('ComposantsService', () => {
let service: ComposantsService; let service: ComposantsService;
let prisma: any;
beforeEach(async () => { beforeEach(async () => {
prisma = {
composant: {
create: jest.fn(),
findUnique: jest.fn(),
},
machine: {
findUnique: jest.fn(),
},
};
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
providers: [ComposantsService, PrismaService], providers: [ComposantsService, { provide: PrismaService, useValue: prisma }],
}).compile(); }).compile();
service = module.get<ComposantsService>(ComposantsService); service = module.get<ComposantsService>(ComposantsService);
}); });
it('should be defined', () => { afterEach(() => {
expect(service).toBeDefined(); jest.clearAllMocks();
});
it('should create a component when requirement matches the machine skeleton', async () => {
const dto: CreateComposantDto = {
name: 'Comp A',
machineId: 'machine-1',
typeComposantId: 'type-comp-1',
typeMachineComponentRequirementId: 'req-1',
};
prisma.machine.findUnique.mockResolvedValue({
id: 'machine-1',
typeMachine: {
componentRequirements: [
{ id: 'req-1', typeComposantId: 'type-comp-1' },
],
},
});
const created = { id: 'component-1' };
prisma.composant.create.mockResolvedValue(created);
await expect(service.create(dto)).resolves.toEqual(created);
expect(prisma.composant.create).toHaveBeenCalled();
expect(
prisma.composant.create.mock.calls[0][0].data.typeComposantId,
).toBe('type-comp-1');
});
it('should refuse creation when requirement does not belong to machine skeleton', async () => {
const dto: CreateComposantDto = {
name: 'Comp A',
machineId: 'machine-1',
typeComposantId: 'type-comp-1',
typeMachineComponentRequirementId: 'req-2',
};
prisma.machine.findUnique.mockResolvedValue({
id: 'machine-1',
typeMachine: {
componentRequirements: [
{ id: 'req-1', typeComposantId: 'type-comp-1' },
],
},
});
await expect(service.create(dto)).rejects.toBeInstanceOf(
BadRequestException,
);
expect(prisma.composant.create).not.toHaveBeenCalled();
}); });
}); });

View File

@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { CreateComposantDto, UpdateComposantDto } from '../shared/dto/composant.dto'; import { CreateComposantDto, UpdateComposantDto } from '../shared/dto/composant.dto';
@@ -7,8 +7,75 @@ export class ComposantsService {
constructor(private prisma: PrismaService) {} constructor(private prisma: PrismaService) {}
async create(createComposantDto: CreateComposantDto) { async create(createComposantDto: CreateComposantDto) {
const requirementId = createComposantDto.typeMachineComponentRequirementId;
let machineId = createComposantDto.machineId;
if (createComposantDto.parentComposantId) {
const parentMachineId = await this.resolveMachineIdFromComposant(
createComposantDto.parentComposantId,
);
if (machineId && machineId !== parentMachineId) {
throw new BadRequestException(
'Le composant parent ne correspond pas à la machine ciblée.',
);
}
machineId = parentMachineId;
}
if (!machineId) {
throw new BadRequestException(
'Un machineId ou un parentComposantId valide est requis pour créer un composant.',
);
}
const machine = await this.prisma.machine.findUnique({
where: { id: machineId },
include: {
typeMachine: {
include: {
componentRequirements: true,
},
},
},
});
if (!machine || !machine.typeMachine) {
throw new BadRequestException(
'La machine ciblée doit être associée à un type de machine pour valider les requirements.',
);
}
const requirement = machine.typeMachine.componentRequirements.find(
(componentRequirement) => componentRequirement.id === requirementId,
);
if (!requirement) {
throw new BadRequestException(
'Le requirement de composant fourni ne correspond pas au squelette de la machine.',
);
}
if (
createComposantDto.typeComposantId &&
createComposantDto.typeComposantId !== requirement.typeComposantId
) {
throw new BadRequestException(
'Le type de composant fourni ne correspond pas au requirement pour cette machine.',
);
}
const data = {
...createComposantDto,
machineId,
typeComposantId:
createComposantDto.typeComposantId ?? requirement.typeComposantId,
};
return this.prisma.composant.create({ return this.prisma.composant.create({
data: createComposantDto, data,
include: { include: {
machine: true, machine: true,
parentComposant: true, parentComposant: true,
@@ -437,6 +504,37 @@ export class ComposantsService {
}); });
} }
private async resolveMachineIdFromComposant(
composantId: string,
): Promise<string> {
const composant = await this.prisma.composant.findUnique({
where: { id: composantId },
select: {
id: true,
machineId: true,
parentComposantId: true,
},
});
if (!composant) {
throw new BadRequestException(
'Le composant parent spécifié est introuvable.',
);
}
if (composant.machineId) {
return composant.machineId;
}
if (composant.parentComposantId) {
return this.resolveMachineIdFromComposant(composant.parentComposantId);
}
throw new BadRequestException(
'Impossible de déterminer la machine associée au composant parent.',
);
}
async remove(id: string) { async remove(id: string) {
return this.prisma.composant.delete({ return this.prisma.composant.delete({
where: { id }, where: { id },

View File

@@ -1,6 +1,11 @@
import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common'; import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common';
import { CustomFieldsService } from './custom-fields.service'; import { CustomFieldsService } from './custom-fields.service';
import { CreateCustomFieldValueDto, UpdateCustomFieldValueDto } from '../shared/dto/custom-field.dto'; import {
CreateCustomFieldValueDto,
UpdateCustomFieldValueDto,
CustomFieldEntityParamsDto,
UpsertCustomFieldValueDto,
} from '../shared/dto/custom-field.dto';
@Controller('custom-fields') @Controller('custom-fields')
export class CustomFieldsController { export class CustomFieldsController {
@@ -12,11 +17,11 @@ export class CustomFieldsController {
} }
@Get('values/:entityType/:entityId') @Get('values/:entityType/:entityId')
findCustomFieldValuesByEntity( findCustomFieldValuesByEntity(@Param() params: CustomFieldEntityParamsDto) {
@Param('entityType') entityType: string, return this.customFieldsService.findCustomFieldValuesByEntity(
@Param('entityId') entityId: string, params.entityType,
) { params.entityId,
return this.customFieldsService.findCustomFieldValuesByEntity(entityType, entityId); );
} }
@Get('values/:id') @Get('values/:id')
@@ -38,12 +43,7 @@ export class CustomFieldsController {
} }
@Post('values/upsert') @Post('values/upsert')
upsertCustomFieldValue(@Body() body: { upsertCustomFieldValue(@Body() body: UpsertCustomFieldValueDto) {
customFieldId: string;
entityType: string;
entityId: string;
value: string;
}) {
return this.customFieldsService.upsertCustomFieldValue( return this.customFieldsService.upsertCustomFieldValue(
body.customFieldId, body.customFieldId,
body.entityType, body.entityType,
@@ -51,4 +51,4 @@ export class CustomFieldsController {
body.value, body.value,
); );
} }
} }

View File

@@ -0,0 +1,103 @@
import { BadRequestException } from '@nestjs/common';
import { CustomFieldsService } from './custom-fields.service';
import { PrismaService } from '../prisma/prisma.service';
import { CustomFieldEntityType } from '../shared/dto/custom-field.dto';
describe('CustomFieldsService', () => {
let service: CustomFieldsService;
let prisma: {
machine: { findUnique: jest.Mock };
composant: { findUnique: jest.Mock };
piece: { findUnique: jest.Mock };
customField: { findFirst: jest.Mock };
customFieldValue: {
findFirst: jest.Mock;
update: jest.Mock;
create: jest.Mock;
};
};
beforeEach(() => {
prisma = {
machine: { findUnique: jest.fn() },
composant: { findUnique: jest.fn() },
piece: { findUnique: jest.fn() },
customField: { findFirst: jest.fn() },
customFieldValue: {
findFirst: jest.fn(),
update: jest.fn(),
create: jest.fn(),
},
};
service = new CustomFieldsService(prisma as unknown as PrismaService);
});
describe('upsertCustomFieldValue', () => {
it('should reject when the custom field is not allowed for the machine', async () => {
prisma.machine.findUnique.mockResolvedValue({ typeMachineId: 'type-1' });
prisma.customField.findFirst.mockResolvedValue(null);
await expect(
service.upsertCustomFieldValue(
'custom-field-1',
CustomFieldEntityType.MACHINE,
'machine-1',
'value',
),
).rejects.toBeInstanceOf(BadRequestException);
expect(prisma.customField.findFirst).toHaveBeenCalledWith({
where: {
id: 'custom-field-1',
typeMachineId: 'type-1',
},
});
expect(prisma.customFieldValue.findFirst).not.toHaveBeenCalled();
expect(prisma.customFieldValue.update).not.toHaveBeenCalled();
expect(prisma.customFieldValue.create).not.toHaveBeenCalled();
});
it('should update an existing value when the custom field is allowed', async () => {
prisma.machine.findUnique.mockResolvedValue({ typeMachineId: 'type-1' });
prisma.customField.findFirst.mockResolvedValue({ id: 'custom-field-1' });
prisma.customFieldValue.findFirst.mockResolvedValue({ id: 'value-1' });
prisma.customFieldValue.update.mockResolvedValue({
id: 'value-1',
value: 'updated',
customField: { id: 'custom-field-1' },
});
const result = await service.upsertCustomFieldValue(
'custom-field-1',
CustomFieldEntityType.MACHINE,
'machine-1',
'updated',
);
expect(prisma.customField.findFirst).toHaveBeenCalledWith({
where: {
id: 'custom-field-1',
typeMachineId: 'type-1',
},
});
expect(prisma.customFieldValue.findFirst).toHaveBeenCalledWith({
where: {
customFieldId: 'custom-field-1',
machineId: 'machine-1',
},
});
expect(prisma.customFieldValue.update).toHaveBeenCalledWith({
where: { id: 'value-1' },
data: { value: 'updated' },
include: { customField: true },
});
expect(prisma.customFieldValue.create).not.toHaveBeenCalled();
expect(result).toEqual({
id: 'value-1',
value: 'updated',
customField: { id: 'custom-field-1' },
});
});
});
});

View File

@@ -1,6 +1,10 @@
import { Injectable } from '@nestjs/common'; import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { CreateCustomFieldValueDto, UpdateCustomFieldValueDto } from '../shared/dto/custom-field.dto'; import {
CreateCustomFieldValueDto,
UpdateCustomFieldValueDto,
CustomFieldEntityType,
} from '../shared/dto/custom-field.dto';
@Injectable() @Injectable()
export class CustomFieldsService { export class CustomFieldsService {
@@ -17,9 +21,99 @@ export class CustomFieldsService {
} }
// Trouver toutes les valeurs de champs personnalisés pour une entité // Trouver toutes les valeurs de champs personnalisés pour une entité
async findCustomFieldValuesByEntity(entityType: string, entityId: string) { private getCustomFieldValueKey(entityType: CustomFieldEntityType) {
switch (entityType) {
case CustomFieldEntityType.MACHINE:
return 'machineId' as const;
case CustomFieldEntityType.COMPOSANT:
return 'composantId' as const;
case CustomFieldEntityType.PIECE:
return 'pieceId' as const;
default:
throw new BadRequestException('Type d\'entité de champ personnalisé invalide.');
}
}
private async resolveEntityContext(entityType: CustomFieldEntityType, entityId: string) {
switch (entityType) {
case CustomFieldEntityType.MACHINE: {
const machine = await this.prisma.machine.findUnique({
where: { id: entityId },
select: { typeMachineId: true },
});
if (!machine) {
throw new NotFoundException('Machine introuvable.');
}
if (!machine.typeMachineId) {
throw new BadRequestException(
'La machine ne possède pas de type associé pour les champs personnalisés.',
);
}
return {
typeId: machine.typeMachineId,
customFieldTypeField: 'typeMachineId' as const,
valueKey: 'machineId' as const,
};
}
case CustomFieldEntityType.COMPOSANT: {
const composant = await this.prisma.composant.findUnique({
where: { id: entityId },
select: { typeComposantId: true },
});
if (!composant) {
throw new NotFoundException('Composant introuvable.');
}
if (!composant.typeComposantId) {
throw new BadRequestException(
'Le composant ne possède pas de type associé pour les champs personnalisés.',
);
}
return {
typeId: composant.typeComposantId,
customFieldTypeField: 'typeComposantId' as const,
valueKey: 'composantId' as const,
};
}
case CustomFieldEntityType.PIECE: {
const piece = await this.prisma.piece.findUnique({
where: { id: entityId },
select: { typePieceId: true },
});
if (!piece) {
throw new NotFoundException('Pièce introuvable.');
}
if (!piece.typePieceId) {
throw new BadRequestException(
'La pièce ne possède pas de type associé pour les champs personnalisés.',
);
}
return {
typeId: piece.typePieceId,
customFieldTypeField: 'typePieceId' as const,
valueKey: 'pieceId' as const,
};
}
default:
throw new BadRequestException('Type d\'entité de champ personnalisé invalide.');
}
}
async findCustomFieldValuesByEntity(
entityType: CustomFieldEntityType,
entityId: string,
) {
const key = this.getCustomFieldValueKey(entityType);
const whereClause = { const whereClause = {
[entityType + 'Id']: entityId, [key]: entityId,
}; };
return this.prisma.customFieldValue.findMany({ return this.prisma.customFieldValue.findMany({
@@ -59,12 +153,35 @@ 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(customFieldId: string, entityType: string, entityId: string, value: string) { async upsertCustomFieldValue(
customFieldId: string,
entityType: CustomFieldEntityType,
entityId: string,
value: string,
) {
const { typeId, customFieldTypeField, valueKey } = await this.resolveEntityContext(
entityType,
entityId,
);
const allowedCustomField = await this.prisma.customField.findFirst({
where: {
id: customFieldId,
[customFieldTypeField]: typeId,
},
});
if (!allowedCustomField) {
throw new BadRequestException(
'Le champ personnalisé n\'est pas autorisé pour cette entité.',
);
}
// D'abord, essayer de trouver une valeur existante // D'abord, essayer de trouver une valeur existante
const existingValue = await this.prisma.customFieldValue.findFirst({ const existingValue = await this.prisma.customFieldValue.findFirst({
where: { where: {
customFieldId, customFieldId,
[entityType + 'Id']: entityId, [valueKey]: entityId,
}, },
}); });
@@ -83,7 +200,7 @@ export class CustomFieldsService {
data: { data: {
customFieldId, customFieldId,
value, value,
[entityType + 'Id']: entityId, [valueKey]: entityId,
}, },
include: { include: {
customField: true, customField: true,
@@ -91,4 +208,4 @@ export class CustomFieldsService {
}); });
} }
} }
} }

View File

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

View File

@@ -1,53 +1,125 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { import {
CreateMachineDto, CreateMachineDto,
UpdateMachineDto, UpdateMachineDto,
ReconfigureMachineDto,
MachineComponentSelectionDto, MachineComponentSelectionDto,
MachinePieceSelectionDto MachinePieceSelectionDto,
} from '../shared/dto/machine.dto'; } 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() @Injectable()
export class MachinesService { export class MachinesService {
constructor(private prisma: PrismaService) {} constructor(private prisma: PrismaService) {}
async create(createMachineDto: CreateMachineDto) { private async getTypeMachineConfiguration(typeMachineId: string) {
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.prisma.typeMachine.findUnique({ const typeMachine = await this.prisma.typeMachine.findUnique({
where: { id: machineData.typeMachineId }, where: { id: typeMachineId },
include: { include: TYPE_MACHINE_CONFIGURATION_INCLUDE,
customFields: true,
componentRequirements: {
include: {
typeComposant: true,
},
},
pieceRequirements: {
include: {
typePiece: true,
},
},
},
}); });
if (!typeMachine) { if (!typeMachine) {
throw new Error('Type de machine non trouvé'); 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( const componentRequirementMap = new Map(
typeMachine.componentRequirements.map((requirement) => [requirement.id, requirement]), componentRequirements.map((requirement: any) => [requirement.id, requirement]),
); );
const pieceRequirementMap = new Map( const pieceRequirementMap = new Map(
typeMachine.pieceRequirements.map((requirement) => [requirement.id, requirement]), pieceRequirements.map((requirement: any) => [requirement.id, requirement]),
); );
const componentSelectionMap = new Map<string, MachineComponentSelectionDto[]>(); const componentSelectionMap = new Map<string, MachineComponentSelectionDto[]>();
@@ -94,7 +166,7 @@ export class MachinesService {
: []; : [];
const pieceModelMap = new Map(pieceModels.map((model) => [model.id, model])); 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 selections = componentSelectionMap.get(requirement.id) ?? [];
const min = requirement.minCount ?? (requirement.required ? 1 : 0); const min = requirement.minCount ?? (requirement.required ? 1 : 0);
const max = requirement.maxCount ?? undefined; 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 selections = pieceSelectionMap.get(requirement.id) ?? [];
const min = requirement.minCount ?? (requirement.required ? 1 : 0); const min = requirement.minCount ?? (requirement.required ? 1 : 0);
const max = requirement.maxCount ?? undefined; 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({ const machine = await prisma.machine.create({
data: machineData, data: machineData,
include: { include: {
@@ -196,8 +303,8 @@ export class MachinesService {
}, },
}); });
if (typeMachine.componentRequirements.length > 0) { if (componentRequirements.length > 0) {
for (const requirement of typeMachine.componentRequirements) { for (const requirement of componentRequirements) {
const selections = componentSelectionMap.get(requirement.id) ?? []; const selections = componentSelectionMap.get(requirement.id) ?? [];
for (const selection of selections) { for (const selection of selections) {
const model = selection.componentModelId ? componentModelMap.get(selection.componentModelId) : undefined; const model = selection.componentModelId ? componentModelMap.get(selection.componentModelId) : undefined;
@@ -212,8 +319,8 @@ export class MachinesService {
} }
} }
if (typeMachine.pieceRequirements.length > 0) { if (pieceRequirements.length > 0) {
for (const requirement of typeMachine.pieceRequirements) { for (const requirement of pieceRequirements) {
const selections = pieceSelectionMap.get(requirement.id) ?? []; const selections = pieceSelectionMap.get(requirement.id) ?? [];
for (const selection of selections) { for (const selection of selections) {
const model = selection.pieceModelId ? pieceModelMap.get(selection.pieceModelId) : undefined; const model = selection.pieceModelId ? pieceModelMap.get(selection.pieceModelId) : undefined;
@@ -234,76 +341,7 @@ export class MachinesService {
return prisma.machine.findUnique({ return prisma.machine.findUnique({
where: { id: machine.id }, where: { id: machine.id },
include: { include: MACHINE_DEFAULT_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,
},
}); });
}); });
} }
@@ -660,239 +698,146 @@ export class MachinesService {
async findAll() { async findAll() {
return this.prisma.machine.findMany({ return this.prisma.machine.findMany({
include: { include: MACHINE_DEFAULT_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,
},
}); });
} }
async findOne(id: string) { async findOne(id: string) {
return this.prisma.machine.findUnique({ 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 }, where: { id },
include: { include: {
site: true,
typeMachine: { typeMachine: {
include: { include: TYPE_MACHINE_CONFIGURATION_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,
}, },
}); });
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) { async update(id: string, updateMachineDto: UpdateMachineDto) {
return this.prisma.machine.update({ return this.prisma.machine.update({
where: { id }, where: { id },
data: updateMachineDto, data: updateMachineDto,
include: { include: MACHINE_DEFAULT_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,
},
}); });
} }

View File

@@ -1,19 +1,85 @@
import { BadRequestException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { PiecesService } from './pieces.service'; import { PiecesService } from './pieces.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { CreatePieceDto } from '../shared/dto/piece.dto';
describe('PiecesService', () => { describe('PiecesService', () => {
let service: PiecesService; let service: PiecesService;
let prisma: any;
beforeEach(async () => { beforeEach(async () => {
prisma = {
piece: {
create: jest.fn(),
},
machine: {
findUnique: jest.fn(),
},
composant: {
findUnique: jest.fn(),
},
};
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
providers: [PiecesService, PrismaService], providers: [PiecesService, { provide: PrismaService, useValue: prisma }],
}).compile(); }).compile();
service = module.get<PiecesService>(PiecesService); service = module.get<PiecesService>(PiecesService);
}); });
it('should be defined', () => { afterEach(() => {
expect(service).toBeDefined(); jest.clearAllMocks();
});
it('should create a piece when requirement matches the machine skeleton', async () => {
const dto: CreatePieceDto = {
name: 'Piece A',
machineId: 'machine-1',
typePieceId: 'type-piece-1',
typeMachinePieceRequirementId: 'req-1',
};
prisma.machine.findUnique.mockResolvedValue({
id: 'machine-1',
typeMachine: {
pieceRequirements: [
{ id: 'req-1', typePieceId: 'type-piece-1' },
],
},
});
const created = { id: 'piece-1' };
prisma.piece.create.mockResolvedValue(created);
await expect(service.create(dto)).resolves.toEqual(created);
expect(prisma.piece.create).toHaveBeenCalled();
expect(prisma.piece.create.mock.calls[0][0].data.machineId).toBe(
'machine-1',
);
});
it('should refuse creation when requirement does not belong to machine skeleton', async () => {
const dto: CreatePieceDto = {
name: 'Piece A',
machineId: 'machine-1',
typePieceId: 'type-piece-1',
typeMachinePieceRequirementId: 'req-2',
};
prisma.machine.findUnique.mockResolvedValue({
id: 'machine-1',
typeMachine: {
pieceRequirements: [
{ id: 'req-1', typePieceId: 'type-piece-1' },
],
},
});
await expect(service.create(dto)).rejects.toBeInstanceOf(
BadRequestException,
);
expect(prisma.piece.create).not.toHaveBeenCalled();
}); });
}); });

View File

@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { CreatePieceDto, UpdatePieceDto } from '../shared/dto/piece.dto'; import { CreatePieceDto, UpdatePieceDto } from '../shared/dto/piece.dto';
@@ -7,8 +7,74 @@ export class PiecesService {
constructor(private prisma: PrismaService) {} constructor(private prisma: PrismaService) {}
async create(createPieceDto: CreatePieceDto) { async create(createPieceDto: CreatePieceDto) {
const requirementId = createPieceDto.typeMachinePieceRequirementId;
let machineId = createPieceDto.machineId;
if (createPieceDto.composantId) {
const composantMachineId = await this.resolveMachineIdFromComposant(
createPieceDto.composantId,
);
if (machineId && machineId !== composantMachineId) {
throw new BadRequestException(
'Le composant ciblé appartient à une autre machine que celle fournie.',
);
}
machineId = composantMachineId;
}
if (!machineId) {
throw new BadRequestException(
'Un machineId ou un composantId valide est requis pour créer une pièce.',
);
}
const machine = await this.prisma.machine.findUnique({
where: { id: machineId },
include: {
typeMachine: {
include: {
pieceRequirements: true,
},
},
},
});
if (!machine || !machine.typeMachine) {
throw new BadRequestException(
'La machine ciblée doit être associée à un type de machine pour valider les requirements.',
);
}
const requirement = machine.typeMachine.pieceRequirements.find(
(pieceRequirement) => pieceRequirement.id === requirementId,
);
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 data = {
...createPieceDto,
machineId,
typePieceId: createPieceDto.typePieceId ?? requirement.typePieceId,
};
return this.prisma.piece.create({ return this.prisma.piece.create({
data: createPieceDto, data,
include: { include: {
machine: true, machine: true,
composant: true, composant: true,
@@ -101,6 +167,37 @@ export class PiecesService {
}); });
} }
private async resolveMachineIdFromComposant(
composantId: string,
): Promise<string> {
const composant = await this.prisma.composant.findUnique({
where: { id: composantId },
select: {
id: true,
machineId: true,
parentComposantId: true,
},
});
if (!composant) {
throw new BadRequestException(
'Le composant spécifié est introuvable.',
);
}
if (composant.machineId) {
return composant.machineId;
}
if (composant.parentComposantId) {
return this.resolveMachineIdFromComposant(composant.parentComposantId);
}
throw new BadRequestException(
'Impossible de déterminer la machine associée à ce composant.',
);
}
async findByComposant(composantId: string) { async findByComposant(composantId: string) {
return this.prisma.piece.findMany({ return this.prisma.piece.findMany({
where: { composantId }, where: { composantId },

View File

@@ -1,15 +1,15 @@
import { IsString, IsOptional, IsNumber } from 'class-validator'; import { IsString, IsOptional, IsNumber, ValidateIf } 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;
@IsOptional() @ValidateIf((dto) => !dto.parentComposantId)
@IsString() @IsString()
machineId?: string; machineId?: string;
@IsOptional() @ValidateIf((dto) => !dto.machineId)
@IsString() @IsString()
parentComposantId?: string; parentComposantId?: string;
@@ -34,6 +34,9 @@ export class CreateComposantDto {
@IsString() @IsString()
typeComposantId?: string; typeComposantId?: string;
@IsString()
typeMachineComponentRequirementId: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
composantModelId?: string; composantModelId?: string;

View File

@@ -1,4 +1,36 @@
import { IsString, IsOptional, IsNotEmpty } from 'class-validator'; import { IsString, IsOptional, IsNotEmpty, IsEnum } from 'class-validator';
export enum CustomFieldEntityType {
MACHINE = 'machine',
COMPOSANT = 'composant',
PIECE = 'piece',
}
export class CustomFieldEntityParamsDto {
@IsEnum(CustomFieldEntityType)
entityType: CustomFieldEntityType;
@IsString()
@IsNotEmpty()
entityId: string;
}
export class UpsertCustomFieldValueDto {
@IsString()
@IsNotEmpty()
customFieldId: string;
@IsEnum(CustomFieldEntityType)
entityType: CustomFieldEntityType;
@IsString()
@IsNotEmpty()
entityId: string;
@IsString()
@IsNotEmpty()
value: string;
}
export class CreateCustomFieldValueDto { export class CreateCustomFieldValueDto {
@IsString() @IsString()

View File

@@ -90,4 +90,18 @@ export class UpdateMachineDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
typeMachineId?: string; 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

@@ -1,15 +1,15 @@
import { IsString, IsOptional, IsNumber } from 'class-validator'; import { IsString, IsOptional, IsNumber, ValidateIf } 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;
@IsOptional() @ValidateIf((dto) => !dto.composantId)
@IsString() @IsString()
machineId?: string; machineId?: string;
@IsOptional() @ValidateIf((dto) => !dto.machineId)
@IsString() @IsString()
composantId?: string; composantId?: string;
@@ -34,6 +34,9 @@ export class CreatePieceDto {
@IsString() @IsString()
typePieceId?: string; typePieceId?: string;
@IsString()
typeMachinePieceRequirementId: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
pieceModelId?: string; pieceModelId?: string;

View File

@@ -902,6 +902,7 @@ describe('Inventory flow (e2e)', () => {
beforeAll(async () => { beforeAll(async () => {
prismaStub = new InMemoryPrismaService(); prismaStub = new InMemoryPrismaService();
const moduleFixture: TestingModule = await Test.createTestingModule({ const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule], imports: [AppModule],
}) })
@@ -911,6 +912,22 @@ describe('Inventory flow (e2e)', () => {
app = moduleFixture.createNestApplication(); app = moduleFixture.createNestApplication();
await app.init(); 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',
);
});
afterEach(async () => {
await app.close();
}); });
afterAll(async () => { afterAll(async () => {
@@ -1070,4 +1087,109 @@ describe('Inventory flow (e2e)', () => {
const refreshedComponent = refreshedMachineResponse.body.composants[0]; const refreshedComponent = refreshedMachineResponse.body.composants[0];
expect(refreshedComponent.customFieldValues[0].value).toBe('8 kW'); expect(refreshedComponent.customFieldValues[0].value).toBe('8 kW');
}); });
describe('POST /composants', () => {
it('accepts creation when requirement matches the machine skeleton', async () => {
prisma.machine.findUnique.mockResolvedValue({
id: 'machine-1',
typeMachine: {
componentRequirements: [
{ id: 'req-1', typeComposantId: 'type-comp-1' },
],
},
});
const created = { id: 'component-1' };
prisma.composant.create.mockResolvedValue(created);
const response = await request(app.getHttpServer())
.post('/composants')
.send({
name: 'Comp A',
machineId: 'machine-1',
typeComposantId: 'type-comp-1',
typeMachineComponentRequirementId: 'req-1',
})
.expect(201);
expect(response.body).toEqual(created);
expect(prisma.composant.create).toHaveBeenCalled();
});
it('refuses creation when requirement is not part of the machine skeleton', async () => {
prisma.machine.findUnique.mockResolvedValue({
id: 'machine-1',
typeMachine: {
componentRequirements: [
{ id: 'req-1', typeComposantId: 'type-comp-1' },
],
},
});
await request(app.getHttpServer())
.post('/composants')
.send({
name: 'Comp A',
machineId: 'machine-1',
typeComposantId: 'type-comp-1',
typeMachineComponentRequirementId: 'req-2',
})
.expect(400);
expect(prisma.composant.create).not.toHaveBeenCalled();
});
});
describe('POST /pieces', () => {
it('accepts creation when requirement matches the machine skeleton', async () => {
prisma.machine.findUnique.mockResolvedValue({
id: 'machine-1',
typeMachine: {
pieceRequirements: [
{ id: 'req-1', typePieceId: 'type-piece-1' },
],
},
});
const created = { id: 'piece-1' };
prisma.piece.create.mockResolvedValue(created);
const response = await request(app.getHttpServer())
.post('/pieces')
.send({
name: 'Piece A',
machineId: 'machine-1',
typePieceId: 'type-piece-1',
typeMachinePieceRequirementId: 'req-1',
})
.expect(201);
expect(response.body).toEqual(created);
expect(prisma.piece.create).toHaveBeenCalled();
});
it('refuses creation when requirement is not part of the machine skeleton', async () => {
prisma.machine.findUnique.mockResolvedValue({
id: 'machine-1',
typeMachine: {
pieceRequirements: [
{ id: 'req-1', typePieceId: 'type-piece-1' },
],
},
});
await request(app.getHttpServer())
.post('/pieces')
.send({
name: 'Piece A',
machineId: 'machine-1',
typePieceId: 'type-piece-1',
typeMachinePieceRequirementId: 'req-2',
})
.expect(400);
expect(prisma.piece.create).not.toHaveBeenCalled();
});
});
}); });