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 { ComposantsService } from './composants.service';
import { PrismaService } from '../prisma/prisma.service';
import { CreateComposantDto } from '../shared/dto/composant.dto';
describe('ComposantsService', () => {
let service: ComposantsService;
let prisma: any;
beforeEach(async () => {
prisma = {
composant: {
create: jest.fn(),
findUnique: jest.fn(),
},
machine: {
findUnique: jest.fn(),
},
};
const module: TestingModule = await Test.createTestingModule({
providers: [ComposantsService, PrismaService],
providers: [ComposantsService, { provide: PrismaService, useValue: prisma }],
}).compile();
service = module.get<ComposantsService>(ComposantsService);
});
it('should be defined', () => {
expect(service).toBeDefined();
afterEach(() => {
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 { CreateComposantDto, UpdateComposantDto } from '../shared/dto/composant.dto';
@@ -7,8 +7,75 @@ export class ComposantsService {
constructor(private prisma: PrismaService) {}
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({
data: createComposantDto,
data,
include: {
machine: 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) {
return this.prisma.composant.delete({
where: { id },

View File

@@ -1,6 +1,11 @@
import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common';
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')
export class CustomFieldsController {
@@ -12,11 +17,11 @@ export class CustomFieldsController {
}
@Get('values/:entityType/:entityId')
findCustomFieldValuesByEntity(
@Param('entityType') entityType: string,
@Param('entityId') entityId: string,
) {
return this.customFieldsService.findCustomFieldValuesByEntity(entityType, entityId);
findCustomFieldValuesByEntity(@Param() params: CustomFieldEntityParamsDto) {
return this.customFieldsService.findCustomFieldValuesByEntity(
params.entityType,
params.entityId,
);
}
@Get('values/:id')
@@ -38,12 +43,7 @@ export class CustomFieldsController {
}
@Post('values/upsert')
upsertCustomFieldValue(@Body() body: {
customFieldId: string;
entityType: string;
entityId: string;
value: string;
}) {
upsertCustomFieldValue(@Body() body: UpsertCustomFieldValueDto) {
return this.customFieldsService.upsertCustomFieldValue(
body.customFieldId,
body.entityType,
@@ -51,4 +51,4 @@ export class CustomFieldsController {
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 { CreateCustomFieldValueDto, UpdateCustomFieldValueDto } from '../shared/dto/custom-field.dto';
import {
CreateCustomFieldValueDto,
UpdateCustomFieldValueDto,
CustomFieldEntityType,
} from '../shared/dto/custom-field.dto';
@Injectable()
export class CustomFieldsService {
@@ -17,9 +21,99 @@ export class CustomFieldsService {
}
// 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 = {
[entityType + 'Id']: entityId,
[key]: entityId,
};
return this.prisma.customFieldValue.findMany({
@@ -59,12 +153,35 @@ export class CustomFieldsService {
}
// 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
const existingValue = await this.prisma.customFieldValue.findFirst({
where: {
customFieldId,
[entityType + 'Id']: entityId,
[valueKey]: entityId,
},
});
@@ -83,7 +200,7 @@ export class CustomFieldsService {
data: {
customFieldId,
value,
[entityType + 'Id']: entityId,
[valueKey]: entityId,
},
include: {
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 { 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

@@ -1,19 +1,85 @@
import { BadRequestException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { PiecesService } from './pieces.service';
import { PrismaService } from '../prisma/prisma.service';
import { CreatePieceDto } from '../shared/dto/piece.dto';
describe('PiecesService', () => {
let service: PiecesService;
let prisma: any;
beforeEach(async () => {
prisma = {
piece: {
create: jest.fn(),
},
machine: {
findUnique: jest.fn(),
},
composant: {
findUnique: jest.fn(),
},
};
const module: TestingModule = await Test.createTestingModule({
providers: [PiecesService, PrismaService],
providers: [PiecesService, { provide: PrismaService, useValue: prisma }],
}).compile();
service = module.get<PiecesService>(PiecesService);
});
it('should be defined', () => {
expect(service).toBeDefined();
afterEach(() => {
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 { CreatePieceDto, UpdatePieceDto } from '../shared/dto/piece.dto';
@@ -7,8 +7,74 @@ export class PiecesService {
constructor(private prisma: PrismaService) {}
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({
data: createPieceDto,
data,
include: {
machine: 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) {
return this.prisma.piece.findMany({
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';
export class CreateComposantDto {
@IsString()
name: string;
@IsOptional()
@ValidateIf((dto) => !dto.parentComposantId)
@IsString()
machineId?: string;
@IsOptional()
@ValidateIf((dto) => !dto.machineId)
@IsString()
parentComposantId?: string;
@@ -34,6 +34,9 @@ export class CreateComposantDto {
@IsString()
typeComposantId?: string;
@IsString()
typeMachineComponentRequirementId: string;
@IsOptional()
@IsString()
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 {
@IsString()

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

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

View File

@@ -902,6 +902,7 @@ describe('Inventory flow (e2e)', () => {
beforeAll(async () => {
prismaStub = new InMemoryPrismaService();
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
})
@@ -911,6 +912,22 @@ describe('Inventory flow (e2e)', () => {
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',
);
});
afterEach(async () => {
await app.close();
});
afterAll(async () => {
@@ -1070,4 +1087,109 @@ describe('Inventory flow (e2e)', () => {
const refreshedComponent = refreshedMachineResponse.body.composants[0];
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();
});
});
});