diff --git a/src/composants/composants.service.spec.ts b/src/composants/composants.service.spec.ts index 1ee01cc..05ac739 100644 --- a/src/composants/composants.service.spec.ts +++ b/src/composants/composants.service.spec.ts @@ -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); }); - 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(); }); }); diff --git a/src/composants/composants.service.ts b/src/composants/composants.service.ts index 26e750c..266c69f 100644 --- a/src/composants/composants.service.ts +++ b/src/composants/composants.service.ts @@ -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 { + 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 }, diff --git a/src/custom-fields/custom-fields.controller.ts b/src/custom-fields/custom-fields.controller.ts index e7b7b08..b8e8aec 100644 --- a/src/custom-fields/custom-fields.controller.ts +++ b/src/custom-fields/custom-fields.controller.ts @@ -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, ); } -} \ No newline at end of file +} \ No newline at end of file diff --git a/src/custom-fields/custom-fields.service.spec.ts b/src/custom-fields/custom-fields.service.spec.ts new file mode 100644 index 0000000..77d3690 --- /dev/null +++ b/src/custom-fields/custom-fields.service.spec.ts @@ -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' }, + }); + }); + }); +}); diff --git a/src/custom-fields/custom-fields.service.ts b/src/custom-fields/custom-fields.service.ts index cd15efe..7e979bb 100644 --- a/src/custom-fields/custom-fields.service.ts +++ b/src/custom-fields/custom-fields.service.ts @@ -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 { }); } } -} \ No newline at end of file +} diff --git a/src/machines/machines.controller.ts b/src/machines/machines.controller.ts index 4988f4b..60dc435 100644 --- a/src/machines/machines.controller.ts +++ b/src/machines/machines.controller.ts @@ -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); diff --git a/src/machines/machines.service.ts b/src/machines/machines.service.ts index 5ea07c6..5cd4262 100644 --- a/src/machines/machines.service.ts +++ b/src/machines/machines.service.ts @@ -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(); @@ -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, }); } diff --git a/src/pieces/pieces.service.spec.ts b/src/pieces/pieces.service.spec.ts index 8fe3047..6837166 100644 --- a/src/pieces/pieces.service.spec.ts +++ b/src/pieces/pieces.service.spec.ts @@ -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); }); - 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(); }); }); diff --git a/src/pieces/pieces.service.ts b/src/pieces/pieces.service.ts index e507d98..5980c22 100644 --- a/src/pieces/pieces.service.ts +++ b/src/pieces/pieces.service.ts @@ -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 { + 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 }, diff --git a/src/shared/dto/composant.dto.ts b/src/shared/dto/composant.dto.ts index 1c9d827..ba02f9d 100644 --- a/src/shared/dto/composant.dto.ts +++ b/src/shared/dto/composant.dto.ts @@ -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; diff --git a/src/shared/dto/custom-field.dto.ts b/src/shared/dto/custom-field.dto.ts index 6d0f536..70aa9d5 100644 --- a/src/shared/dto/custom-field.dto.ts +++ b/src/shared/dto/custom-field.dto.ts @@ -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() diff --git a/src/shared/dto/machine.dto.ts b/src/shared/dto/machine.dto.ts index 01f2168..a798000 100644 --- a/src/shared/dto/machine.dto.ts +++ b/src/shared/dto/machine.dto.ts @@ -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[]; +} diff --git a/src/shared/dto/piece.dto.ts b/src/shared/dto/piece.dto.ts index 7b65e5f..b7d7d2f 100644 --- a/src/shared/dto/piece.dto.ts +++ b/src/shared/dto/piece.dto.ts @@ -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; diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index f8b9c37..f133d6f 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -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(); + + }); + }); });