feat: auto populate machine structures and seed sample data

This commit is contained in:
Matthieu
2025-10-13 09:01:33 +02:00
parent b7682ac312
commit dc4a12440b
21 changed files with 2218 additions and 7267 deletions

View File

@@ -9,46 +9,22 @@ const CUSTOM_FIELD_SELECT = {
} as const;
export const COMPONENT_WITH_RELATIONS_INCLUDE = {
machine: true,
parentComposant: true,
typeComposant: {
include: {
customFields: true,
},
},
typeMachineComponentRequirement: {
include: {
typeComposant: {
include: {
customFields: true,
},
},
},
},
constructeur: true,
customFieldValues: {
include: {
customField: { select: CUSTOM_FIELD_SELECT },
},
},
pieces: {
machineLinks: {
include: {
customFieldValues: {
include: {
customField: { select: CUSTOM_FIELD_SELECT },
},
},
constructeur: true,
typeMachinePieceRequirement: {
include: {
typePiece: {
include: {
customFields: true,
},
},
},
},
documents: true,
machine: true,
typeMachineComponentRequirement: true,
childLinks: true,
},
},
documents: true,

View File

@@ -27,16 +27,6 @@ export class ComposantsController {
return this.composantsService.findAll();
}
@Get('hierarchy/:machineId')
findHierarchy(@Param('machineId') machineId: string) {
return this.composantsService.findHierarchy(machineId);
}
@Get('machine/:machineId')
findByMachine(@Param('machineId') machineId: string) {
return this.composantsService.findByMachine(machineId);
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.composantsService.findOne(id);

View File

@@ -1,32 +1,20 @@
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';
import { CreateComposantDto, UpdateComposantDto } from '../shared/dto/composant.dto';
describe('ComposantsService', () => {
let service: ComposantsService;
let prisma: any;
let prisma: { composant: any };
beforeEach(async () => {
prisma = {
composant: {
create: jest.fn(),
findMany: jest.fn(),
findUnique: jest.fn(),
findMany: jest.fn(),
},
machine: {
findUnique: jest.fn(),
},
customField: {
findMany: jest.fn(),
},
customFieldValue: {
findMany: jest.fn(),
create: jest.fn(),
},
piece: {
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
};
@@ -40,248 +28,27 @@ describe('ComposantsService', () => {
service = module.get<ComposantsService>(ComposantsService);
});
afterEach(() => {
jest.clearAllMocks();
});
it('should create a component when requirement matches the machine skeleton', async () => {
it('creates a component', async () => {
const dto: CreateComposantDto = {
name: 'Comp A',
machineId: 'machine-1',
typeComposantId: 'type-comp-1',
typeMachineComponentRequirementId: 'req-1',
typeComposantId: 'type-1',
};
prisma.machine.findUnique.mockResolvedValue({
id: 'machine-1',
typeMachine: {
componentRequirements: [
{
id: 'req-1',
typeComposantId: 'type-comp-1',
typeComposant: {
id: 'type-comp-1',
name: 'Comp type',
code: 'comp-type',
componentSkeleton: null,
},
},
],
pieceRequirements: [],
},
});
prisma.composant.create.mockResolvedValue({ id: 'comp-1', name: dto.name });
const created = {
id: 'component-1',
name: 'Comp A',
machineId: 'machine-1',
typeComposantId: 'type-comp-1',
};
prisma.composant.create.mockResolvedValue(created);
prisma.composant.findUnique.mockResolvedValue({
...created,
machine: null,
parentComposant: null,
typeComposant: {
id: 'type-comp-1',
name: 'Comp type',
code: 'comp-type',
componentSkeleton: null,
customFields: [],
},
typeMachineComponentRequirement: null,
constructeur: null,
customFieldValues: [],
pieces: [],
documents: [],
});
prisma.composant.findMany.mockResolvedValue([]);
await expect(service.create(dto)).resolves.toMatchObject({
id: 'component-1',
});
const result = await service.create(dto);
expect(prisma.composant.create).toHaveBeenCalled();
expect(prisma.composant.create.mock.calls[0][0].data.typeComposantId).toBe(
'type-comp-1',
);
expect(result).toMatchObject({ id: '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',
};
it('updates a component', async () => {
const dto: UpdateComposantDto = { name: 'Updated' };
prisma.machine.findUnique.mockResolvedValue({
id: 'machine-1',
typeMachine: {
componentRequirements: [
{ id: 'req-1', typeComposantId: 'type-comp-1' },
],
pieceRequirements: [],
},
});
prisma.composant.update.mockResolvedValue({ id: 'comp-1', name: 'Updated' });
await expect(service.create(dto)).rejects.toBeInstanceOf(
BadRequestException,
);
await service.update('comp-1', dto);
expect(prisma.composant.create).not.toHaveBeenCalled();
});
it('should create nested components, pieces, and custom field values from the type skeleton', async () => {
const dto: CreateComposantDto = {
name: 'Comp B',
machineId: 'machine-1',
typeMachineComponentRequirementId: 'req-root',
} as any;
prisma.machine.findUnique.mockResolvedValue({
id: 'machine-1',
typeMachine: {
componentRequirements: [
{
id: 'req-root',
typeComposantId: 'type-root',
typeComposant: {
id: 'type-root',
name: 'Root type',
code: 'root',
componentSkeleton: {
customFields: [{ key: 'color', value: 'red' }],
pieces: [
{
typePieceId: 'type-piece',
role: 'Primary piece',
},
],
subcomponents: [
{
typeComposantId: 'type-child',
alias: 'Child component',
},
],
},
},
maxCount: null,
},
{
id: 'req-child',
typeComposantId: 'type-child',
typeComposant: {
id: 'type-child',
name: 'Child type',
code: 'child',
componentSkeleton: null,
},
maxCount: null,
},
],
pieceRequirements: [
{
id: 'req-piece',
typePieceId: 'type-piece',
typePiece: {
id: 'type-piece',
name: 'Piece type',
code: 'piece',
},
maxCount: null,
},
],
},
});
prisma.customField.findMany.mockResolvedValue([
{ id: 'cf-color', name: 'color' },
]);
prisma.customFieldValue.findMany.mockResolvedValue([]);
const rootComponent = {
id: 'component-1',
name: 'Comp B',
machineId: 'machine-1',
typeComposantId: 'type-root',
typeComposant: {
id: 'type-root',
name: 'Root type',
code: 'root',
componentSkeleton: {
customFields: [{ key: 'color', value: 'red' }],
pieces: [],
subcomponents: [],
},
customFields: [],
},
machine: null,
parentComposant: null,
typeMachineComponentRequirement: null,
constructeur: null,
customFieldValues: [],
pieces: [],
documents: [],
};
prisma.composant.create
.mockResolvedValueOnce(rootComponent)
.mockResolvedValueOnce({
id: 'component-child',
name: 'Child component',
machineId: 'machine-1',
parentComposantId: 'component-1',
typeComposantId: 'type-child',
});
prisma.composant.findUnique.mockResolvedValue(rootComponent);
prisma.composant.findMany.mockResolvedValue([
{ ...rootComponent, parentComposantId: null },
{
id: 'component-child',
name: 'Child component',
machineId: 'machine-1',
parentComposantId: 'component-1',
typeComposantId: 'type-child',
machine: null,
parentComposant: rootComponent,
typeComposant: null,
typeMachineComponentRequirement: null,
constructeur: null,
customFieldValues: [],
pieces: [],
documents: [],
},
]);
await service.create(dto);
expect(prisma.customField.findMany).toHaveBeenCalledWith({
where: { typeComposantId: 'type-root' },
select: { id: true, name: true },
});
expect(prisma.customFieldValue.create).toHaveBeenCalledWith({
data: {
customFieldId: 'cf-color',
composantId: 'component-1',
value: 'red',
},
});
expect(prisma.piece.create).toHaveBeenCalledWith({
data: {
name: 'Primary piece',
machineId: 'machine-1',
composantId: 'component-1',
typePieceId: 'type-piece',
typeMachinePieceRequirementId: 'req-piece',
},
});
expect(prisma.composant.create).toHaveBeenCalledTimes(2);
expect(prisma.composant.create.mock.calls[1][0].data).toMatchObject({
parentComposantId: 'component-1',
typeComposantId: 'type-child',
typeMachineComponentRequirementId: 'req-child',
});
expect(prisma.composant.update).toHaveBeenCalled();
});
});

View File

@@ -1,4 +1,4 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import {
@@ -9,638 +9,99 @@ import {
COMPONENT_WITH_RELATIONS_INCLUDE,
ComposantWithRelations,
} from '../common/constants/component-includes';
import {
buildComponentHierarchy,
buildComponentSubtree,
} from '../common/utils/component-tree.util';
import { ComponentModelStructureSchema } from '../shared/schemas/inventory';
import type { ComponentModelStructure } from '../shared/types/inventory';
type ComponentRequirementWithType =
Prisma.TypeMachineComponentRequirementGetPayload<{
include: { typeComposant: true };
}>;
type PieceRequirementWithType = Prisma.TypeMachinePieceRequirementGetPayload<{
include: { typePiece: true };
}>;
type ModelTypeWithSkeleton = ComponentRequirementWithType['typeComposant'];
type PieceTypeWithSkeleton = PieceRequirementWithType['typePiece'];
@Injectable()
export class ComposantsService {
constructor(private prisma: PrismaService) {}
private async fetchComponentsByMachine(
machineId: string,
): Promise<ComposantWithRelations[]> {
return this.prisma.composant.findMany({
where: { machineId },
include: COMPONENT_WITH_RELATIONS_INCLUDE,
}) as Promise<ComposantWithRelations[]>;
}
private buildCreateInput(
createComposantDto: CreateComposantDto,
): Prisma.ComposantCreateInput {
const data: Prisma.ComposantCreateInput = {
name: createComposantDto.name,
reference: createComposantDto.reference ?? null,
prix:
createComposantDto.prix !== undefined ? createComposantDto.prix : null,
};
private async getComponentWithHierarchy(
id: string,
): Promise<ComposantWithRelations | null> {
const baseComponent = (await this.prisma.composant.findUnique({
where: { id },
include: COMPONENT_WITH_RELATIONS_INCLUDE,
})) as ComposantWithRelations | null;
if (!baseComponent) {
return null;
if (createComposantDto.constructeurId) {
data.constructeur = {
connect: { id: createComposantDto.constructeurId },
};
}
if (!baseComponent.machineId) {
baseComponent.sousComposants = [];
return baseComponent;
if (createComposantDto.typeComposantId) {
data.typeComposant = {
connect: { id: createComposantDto.typeComposantId },
};
}
const components = await this.fetchComponentsByMachine(
baseComponent.machineId,
);
const subtree = buildComponentSubtree(components, id);
return subtree ?? baseComponent;
if (createComposantDto.structure !== undefined) {
data.structure = createComposantDto.structure as Prisma.InputJsonValue;
}
return data;
}
async create(createComposantDto: CreateComposantDto) {
const requirementId =
createComposantDto.typeMachineComponentRequirementId ?? null;
if (requirementId && !createComposantDto.machineId) {
throw new BadRequestException(
'Un requirement ne peut pas être utilisé sans machine ciblée.',
);
}
let machineId = createComposantDto.machineId ?? null;
if (createComposantDto.parentComposantId) {
const parentMachineId = await this.resolveMachineIdFromComposant(
createComposantDto.parentComposantId,
);
if (machineId && parentMachineId && machineId !== parentMachineId) {
throw new BadRequestException(
'Le composant parent ne correspond pas à la machine ciblée.',
);
}
machineId = parentMachineId ?? machineId;
}
let requirement: ComponentRequirementWithType | null = null;
let componentRequirements: ComponentRequirementWithType[] = [];
let pieceRequirements: PieceRequirementWithType[] = [];
if (machineId) {
const machine = await this.prisma.machine.findUnique({
where: { id: machineId },
include: {
typeMachine: {
include: {
componentRequirements: {
include: {
typeComposant: true,
},
},
pieceRequirements: {
include: {
typePiece: true,
},
},
},
},
},
});
if (!machine || !machine.typeMachine) {
throw new BadRequestException(
'La machine ciblée doit être associée à un type de machine pour valider les requirements.',
);
}
componentRequirements =
(machine.typeMachine
.componentRequirements as ComponentRequirementWithType[]) ?? [];
pieceRequirements =
(machine.typeMachine.pieceRequirements as PieceRequirementWithType[]) ??
[];
if (requirementId) {
requirement =
componentRequirements.find(
(componentRequirement) => componentRequirement.id === requirementId,
) ?? null;
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 typeComposantId =
createComposantDto.typeComposantId ??
requirement?.typeComposantId ??
null;
const data: Prisma.ComposantUncheckedCreateInput = {
name: createComposantDto.name,
reference: createComposantDto.reference ?? null,
constructeurId: createComposantDto.constructeurId ?? null,
prix:
createComposantDto.prix !== undefined ? createComposantDto.prix : null,
machineId,
parentComposantId: createComposantDto.parentComposantId ?? null,
typeComposantId,
typeMachineComponentRequirementId:
requirement?.id ?? requirementId ?? null,
};
const created = (await this.prisma.composant.create({
data,
const created = await this.prisma.composant.create({
data: this.buildCreateInput(createComposantDto),
include: COMPONENT_WITH_RELATIONS_INCLUDE,
})) as ComposantWithRelations;
});
if (machineId && requirement?.id) {
const componentRequirementUsage = new Map<string, number>();
componentRequirementUsage.set(requirement.id, 1);
const pieceRequirementUsage = new Map<string, number>();
await this.populateComponentFromSkeleton({
componentId: created.id,
componentName: created.name,
componentType:
(requirement.typeComposant as ModelTypeWithSkeleton | null) ??
(created.typeComposant as ModelTypeWithSkeleton | null) ??
null,
machineId,
componentRequirements,
pieceRequirements,
componentRequirementUsage,
pieceRequirementUsage,
});
}
const component = await this.getComponentWithHierarchy(created.id);
return component ?? created;
return created as ComposantWithRelations;
}
async findAll() {
const components = (await this.prisma.composant.findMany({
return (await this.prisma.composant.findMany({
include: COMPONENT_WITH_RELATIONS_INCLUDE,
orderBy: { name: 'asc' },
})) as ComposantWithRelations[];
return buildComponentHierarchy(components);
}
async findOne(id: string) {
return this.getComponentWithHierarchy(id);
}
async findByMachine(machineId: string) {
const components = await this.fetchComponentsByMachine(machineId);
return buildComponentHierarchy(components);
}
async findHierarchy(machineId: string) {
const components = await this.fetchComponentsByMachine(machineId);
return buildComponentHierarchy(components);
return (await this.prisma.composant.findUnique({
where: { id },
include: COMPONENT_WITH_RELATIONS_INCLUDE,
})) as ComposantWithRelations | null;
}
async update(id: string, updateComposantDto: UpdateComposantDto) {
const updated = (await this.prisma.composant.update({
const data: Prisma.ComposantUpdateInput = {};
if (updateComposantDto.name !== undefined) {
data.name = updateComposantDto.name;
}
if (updateComposantDto.reference !== undefined) {
data.reference = updateComposantDto.reference;
}
if (updateComposantDto.prix !== undefined) {
data.prix = updateComposantDto.prix;
}
if (updateComposantDto.constructeurId !== undefined) {
data.constructeur = updateComposantDto.constructeurId
? { connect: { id: updateComposantDto.constructeurId } }
: { disconnect: true };
}
if (updateComposantDto.typeComposantId !== undefined) {
data.typeComposant = updateComposantDto.typeComposantId
? { connect: { id: updateComposantDto.typeComposantId } }
: { disconnect: true };
}
if (updateComposantDto.structure !== undefined) {
data.structure = updateComposantDto.structure as Prisma.InputJsonValue;
}
return (await this.prisma.composant.update({
where: { id },
data: updateComposantDto,
data,
include: COMPONENT_WITH_RELATIONS_INCLUDE,
})) as ComposantWithRelations;
return this.getComponentWithHierarchy(updated.id);
}
private async populateComponentFromSkeleton({
componentId,
componentName,
componentType,
machineId,
componentRequirements,
pieceRequirements,
componentRequirementUsage,
pieceRequirementUsage,
}: {
componentId: string;
componentName?: string;
componentType: ModelTypeWithSkeleton | null;
machineId: string;
componentRequirements: ComponentRequirementWithType[];
pieceRequirements: PieceRequirementWithType[];
componentRequirementUsage: Map<string, number>;
pieceRequirementUsage: Map<string, number>;
}) {
const skeleton = this.parseComponentSkeleton(
(componentType as { componentSkeleton?: Prisma.JsonValue | null } | null)
?.componentSkeleton,
);
if (!skeleton) {
return;
}
await this.createComponentCustomFieldValues(
componentId,
componentType?.id ?? null,
skeleton.customFields,
);
await this.createPiecesFromSkeleton({
componentId,
componentName,
machineId,
pieces: skeleton.pieces,
pieceRequirements,
pieceRequirementUsage,
});
for (const subcomponent of skeleton.subcomponents ?? []) {
const requirement = this.resolveComponentRequirement(
subcomponent,
componentRequirements,
componentRequirementUsage,
);
if (!requirement?.typeComposant) {
continue;
}
const name = this.buildComponentName(
subcomponent,
requirement.typeComposant,
componentName,
);
const createdChild = await this.prisma.composant.create({
data: {
name,
machineId,
parentComposantId: componentId,
typeComposantId: requirement.typeComposantId,
typeMachineComponentRequirementId: requirement.id,
},
});
this.incrementRequirementUsage(componentRequirementUsage, requirement.id);
await this.populateComponentFromSkeleton({
componentId: createdChild.id,
componentName: createdChild.name,
componentType: requirement.typeComposant,
machineId,
componentRequirements,
pieceRequirements,
componentRequirementUsage,
pieceRequirementUsage,
});
}
}
private parseComponentSkeleton(
value: unknown,
): ComponentModelStructure | null {
if (!value) {
return null;
}
try {
return ComponentModelStructureSchema.parse(value);
} catch (error) {
return null;
}
}
private async createComponentCustomFieldValues(
componentId: string,
typeComposantId: string | null,
customFields: ComponentModelStructure['customFields'],
) {
if (
!typeComposantId ||
!Array.isArray(customFields) ||
customFields.length === 0
) {
return;
}
const definitions = await this.prisma.customField.findMany({
where: { typeComposantId },
select: { id: true, name: true },
});
if (definitions.length === 0) {
return;
}
const definitionMap = new Map(
definitions.map((field) => [field.name, field.id]),
);
const existingValues = await this.prisma.customFieldValue.findMany({
where: { composantId: componentId },
select: { customFieldId: true },
});
const existingIds = new Set(
existingValues.map((value) => value.customFieldId),
);
for (const field of customFields) {
const key = this.normalizeIdentifier(field?.key);
if (!key) {
continue;
}
const definitionId = definitionMap.get(key);
if (!definitionId || existingIds.has(definitionId)) {
continue;
}
await this.prisma.customFieldValue.create({
data: {
customFieldId: definitionId,
composantId: componentId,
value: this.toCustomFieldValue(field?.value),
},
});
existingIds.add(definitionId);
}
}
private async createPiecesFromSkeleton({
componentId,
componentName,
machineId,
pieces,
pieceRequirements,
pieceRequirementUsage,
}: {
componentId: string;
componentName?: string;
machineId: string;
pieces: ComponentModelStructure['pieces'];
pieceRequirements: PieceRequirementWithType[];
pieceRequirementUsage: Map<string, number>;
}) {
if (!Array.isArray(pieces) || pieces.length === 0) {
return;
}
for (const entry of pieces) {
const requirement = this.resolvePieceRequirement(
entry,
pieceRequirements,
pieceRequirementUsage,
);
if (!requirement?.typePiece) {
continue;
}
const name = this.buildPieceName(
entry,
requirement.typePiece,
componentName,
);
await this.prisma.piece.create({
data: {
name,
machineId,
composantId: componentId,
typePieceId: requirement.typePieceId,
typeMachinePieceRequirementId: requirement.id,
},
});
this.incrementRequirementUsage(pieceRequirementUsage, requirement.id);
}
}
private resolveComponentRequirement(
entry: ComponentModelStructure['subcomponents'][number],
requirements: ComponentRequirementWithType[],
usage: Map<string, number>,
): ComponentRequirementWithType | null {
const typeComposantId = this.normalizeIdentifier(
(entry as { typeComposantId?: string }).typeComposantId,
);
const familyCode = this.normalizeCode(
(entry as { familyCode?: string }).familyCode,
);
const candidates = requirements.filter((requirement) => {
if (typeComposantId && requirement.typeComposantId === typeComposantId) {
return true;
}
if (familyCode && requirement.typeComposant?.code) {
return (
this.normalizeCode(requirement.typeComposant.code) === familyCode
);
}
return false;
});
if (candidates.length === 0) {
if (typeComposantId || familyCode) {
throw new BadRequestException(
`Aucun requirement de composant ne correspond au squelette (${typeComposantId ?? familyCode}).`,
);
}
throw new BadRequestException(
'Le squelette du composant référence un sous-composant sans identifiant de type.',
);
}
for (const candidate of candidates) {
if (this.hasRequirementCapacity(candidate, usage)) {
return candidate;
}
}
throw new BadRequestException(
`La capacité maximale du requirement de composant (${typeComposantId ?? familyCode}) est atteinte pour la machine visée.`,
);
}
private resolvePieceRequirement(
entry: ComponentModelStructure['pieces'][number],
requirements: PieceRequirementWithType[],
usage: Map<string, number>,
): PieceRequirementWithType | null {
const typePieceId = this.normalizeIdentifier(
(entry as { typePieceId?: string }).typePieceId,
);
const familyCode = this.normalizeCode(
(entry as { familyCode?: string }).familyCode,
);
const candidates = requirements.filter((requirement) => {
if (typePieceId && requirement.typePieceId === typePieceId) {
return true;
}
if (familyCode && requirement.typePiece?.code) {
return this.normalizeCode(requirement.typePiece.code) === familyCode;
}
return false;
});
if (candidates.length === 0) {
if (typePieceId || familyCode) {
throw new BadRequestException(
`Aucun requirement de pièce ne correspond au squelette (${typePieceId ?? familyCode}).`,
);
}
throw new BadRequestException(
'Le squelette du composant référence une pièce sans identifiant de type.',
);
}
for (const candidate of candidates) {
if (this.hasRequirementCapacity(candidate, usage)) {
return candidate;
}
}
throw new BadRequestException(
`La capacité maximale du requirement de pièce (${typePieceId ?? familyCode}) est atteinte pour la machine visée.`,
);
}
private hasRequirementCapacity(
requirement: { id: string; maxCount: number | null | undefined },
usage: Map<string, number>,
): boolean {
const max = requirement.maxCount;
if (max === null || max === undefined) {
return true;
}
const current = usage.get(requirement.id) ?? 0;
return current < max;
}
private incrementRequirementUsage(usage: Map<string, number>, id: string) {
usage.set(id, (usage.get(id) ?? 0) + 1);
}
private buildComponentName(
subcomponent: ComponentModelStructure['subcomponents'][number],
typeComposant: ModelTypeWithSkeleton | null,
parentName?: string,
): string {
const alias = this.normalizeIdentifier(
(subcomponent as { alias?: string }).alias,
);
if (alias) {
return alias;
}
if (typeComposant?.name) {
return typeComposant.name;
}
if (parentName) {
return `${parentName} - Sous-composant`;
}
return 'Sous-composant';
}
private buildPieceName(
piece: ComponentModelStructure['pieces'][number],
typePiece: PieceTypeWithSkeleton | null,
componentName?: string,
): string {
const role = this.normalizeIdentifier((piece as { role?: string }).role);
if (role) {
return role;
}
if (typePiece?.name) {
return typePiece.name;
}
if (componentName) {
return `${componentName} - Pièce`;
}
return 'Pièce';
}
private normalizeIdentifier(value: unknown): string | null {
if (typeof value !== 'string') {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
private normalizeCode(value: unknown): string | null {
const identifier = this.normalizeIdentifier(value);
return identifier ? identifier.toLowerCase() : null;
}
private toCustomFieldValue(value: unknown): string {
if (value === undefined || value === null) {
return '';
}
return String(value);
}
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) {

File diff suppressed because it is too large Load Diff

View File

@@ -24,16 +24,6 @@ export class PiecesController {
return this.piecesService.findAll();
}
@Get('machine/:machineId')
findByMachine(@Param('machineId') machineId: string) {
return this.piecesService.findByMachine(machineId);
}
@Get('composant/:composantId')
findByComposant(@Param('composantId') composantId: string) {
return this.piecesService.findByComposant(composantId);
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.piecesService.findOne(id);

View File

@@ -1,12 +1,11 @@
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';
import { CreatePieceDto, UpdatePieceDto } from '../shared/dto/piece.dto';
describe('PiecesService', () => {
let service: PiecesService;
let prisma: any;
let prisma: { piece: any; customField: any; customFieldValue: any };
beforeEach(async () => {
prisma = {
@@ -17,12 +16,6 @@ describe('PiecesService', () => {
update: jest.fn(),
delete: jest.fn(),
},
machine: {
findUnique: jest.fn(),
},
composant: {
findUnique: jest.fn(),
},
customField: {
findMany: jest.fn(),
create: jest.fn(),
@@ -34,134 +27,42 @@ describe('PiecesService', () => {
};
const module: TestingModule = await Test.createTestingModule({
providers: [PiecesService, { provide: PrismaService, useValue: prisma }],
providers: [
PiecesService,
{ provide: PrismaService, useValue: prisma },
],
}).compile();
service = module.get<PiecesService>(PiecesService);
});
afterEach(() => {
jest.clearAllMocks();
});
it('should create a piece when requirement matches the machine skeleton', async () => {
it('creates a piece', 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',
typePiece: {
id: 'type-piece-1',
pieceSkeleton: {
customFields: [
{
name: 'Numéro de série',
value: 'AUTO',
type: 'text',
required: true,
},
],
},
},
},
],
},
});
const created = {
id: 'piece-1',
typePieceId: 'type-piece-1',
typePiece: {
id: 'type-piece-1',
pieceSkeleton: {
customFields: [
{
name: 'Numéro de série',
value: 'AUTO',
type: 'text',
required: true,
},
],
},
},
};
prisma.piece.create.mockResolvedValue(created);
prisma.customField.findMany
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{ id: 'field-1', name: 'Numéro de série' }]);
prisma.customField.create.mockResolvedValue({ id: 'field-1' });
prisma.piece.create.mockResolvedValue({ id: 'piece-1', name: dto.name });
prisma.piece.findUnique.mockResolvedValue({ id: 'piece-1', name: dto.name });
prisma.customField.findMany.mockResolvedValue([]);
prisma.customFieldValue.findMany.mockResolvedValue([]);
prisma.customFieldValue.create.mockResolvedValue({
id: 'value-1',
});
const finalPiece = { ...created, customFieldValues: [] };
prisma.piece.findUnique.mockResolvedValue(finalPiece);
const result = await service.create(dto);
await expect(service.create(dto)).resolves.toEqual(finalPiece);
expect(prisma.piece.create).toHaveBeenCalledWith({
data: expect.objectContaining({
machineId: 'machine-1',
typePieceId: 'type-piece-1',
}),
include: expect.any(Object),
});
expect(prisma.customField.create).toHaveBeenCalledWith({
data: {
name: 'Numéro de série',
type: 'text',
required: true,
options: undefined,
typePieceId: 'type-piece-1',
},
select: { id: true },
});
expect(prisma.customFieldValue.create).toHaveBeenCalledWith({
data: {
customFieldId: 'field-1',
pieceId: 'piece-1',
value: 'AUTO',
},
});
expect(prisma.piece.findUnique).toHaveBeenCalledWith({
where: { id: 'piece-1' },
include: expect.any(Object),
});
expect(prisma.piece.create).toHaveBeenCalled();
expect(result).toMatchObject({ id: 'piece-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',
};
it('updates a piece', async () => {
const dto: UpdatePieceDto = { name: 'Updated piece' };
prisma.machine.findUnique.mockResolvedValue({
id: 'machine-1',
typeMachine: {
pieceRequirements: [{ id: 'req-1', typePieceId: 'type-piece-1' }],
},
});
prisma.piece.update.mockResolvedValue({ id: 'piece-1', name: 'Updated piece' });
prisma.piece.findUnique.mockResolvedValue({ id: 'piece-1', name: 'Updated piece' });
prisma.customField.findMany.mockResolvedValue([]);
prisma.customFieldValue.findMany.mockResolvedValue([]);
await expect(service.create(dto)).rejects.toBeInstanceOf(
BadRequestException,
);
await service.update('piece-1', dto);
expect(prisma.piece.create).not.toHaveBeenCalled();
expect(prisma.piece.update).toHaveBeenCalled();
});
});

View File

@@ -1,4 +1,4 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { CreatePieceDto, UpdatePieceDto } from '../shared/dto/piece.dto';
@@ -6,124 +6,56 @@ import { PieceModelStructureSchema } from '../shared/schemas/inventory';
import type { PieceModelStructure } from '../shared/types/inventory';
const PIECE_WITH_RELATIONS_INCLUDE = {
machine: true,
composant: true,
typePiece: {
include: {
pieceCustomFields: true,
},
},
documents: true,
constructeur: true,
typeMachinePieceRequirement: {
include: {
typePiece: {
include: {
pieceCustomFields: true,
},
},
},
},
documents: true,
customFieldValues: {
include: {
customField: true,
},
},
machineLinks: {
include: {
machine: true,
typeMachinePieceRequirement: true,
parentLink: true,
},
},
} as const;
@Injectable()
export class PiecesService {
constructor(private prisma: PrismaService) {}
async create(createPieceDto: CreatePieceDto) {
const requirementId = createPieceDto.typeMachinePieceRequirementId ?? null;
if (requirementId && !createPieceDto.machineId) {
throw new BadRequestException(
'Un requirement ne peut pas être utilisé sans machine ciblée.',
);
}
let machineId = createPieceDto.machineId ?? null;
if (createPieceDto.composantId) {
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 ?? machineId;
}
let requirement: PieceRequirementWithType | null = null;
if (machineId) {
const machine = await this.prisma.machine.findUnique({
where: { id: machineId },
include: {
typeMachine: {
include: {
pieceRequirements: {
include: {
typePiece: true,
},
},
},
},
},
});
if (!machine || !machine.typeMachine) {
throw new BadRequestException(
'La machine ciblée doit être associée à un type de machine pour valider les requirements.',
);
}
if (requirementId) {
requirement =
(
machine.typeMachine.pieceRequirements as PieceRequirementWithType[]
).find((pieceRequirement) => pieceRequirement.id === requirementId) ??
null;
if (!requirement) {
throw new BadRequestException(
'Le requirement de pièce fourni ne correspond pas au squelette de la machine.',
);
}
if (
createPieceDto.typePieceId &&
createPieceDto.typePieceId !== requirement.typePieceId
) {
throw new BadRequestException(
'Le type de pièce fourni ne correspond pas au requirement pour cette machine.',
);
}
}
}
const typePieceId =
createPieceDto.typePieceId ?? requirement?.typePieceId ?? null;
const data: Prisma.PieceUncheckedCreateInput = {
private buildCreateInput(createPieceDto: CreatePieceDto): Prisma.PieceCreateInput {
const data: Prisma.PieceCreateInput = {
name: createPieceDto.name,
reference: createPieceDto.reference ?? null,
constructeurId: createPieceDto.constructeurId ?? null,
prix: createPieceDto.prix !== undefined ? createPieceDto.prix : null,
machineId,
composantId: createPieceDto.composantId ?? null,
typePieceId,
typeMachinePieceRequirementId: requirement?.id ?? requirementId ?? null,
};
if (createPieceDto.constructeurId) {
data.constructeur = {
connect: { id: createPieceDto.constructeurId },
};
}
if (createPieceDto.typePieceId) {
data.typePiece = {
connect: { id: createPieceDto.typePieceId },
};
}
return data;
}
async create(createPieceDto: CreatePieceDto) {
const created = await this.prisma.piece.create({
data,
data: this.buildCreateInput(createPieceDto),
include: PIECE_WITH_RELATIONS_INCLUDE,
});
@@ -141,6 +73,7 @@ export class PiecesService {
async findAll() {
return this.prisma.piece.findMany({
include: PIECE_WITH_RELATIONS_INCLUDE,
orderBy: { name: 'asc' },
});
}
@@ -151,53 +84,36 @@ export class PiecesService {
});
}
async findByMachine(machineId: string) {
return this.prisma.piece.findMany({
where: { machineId },
include: PIECE_WITH_RELATIONS_INCLUDE,
});
}
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 },
include: PIECE_WITH_RELATIONS_INCLUDE,
});
}
async update(id: string, updatePieceDto: UpdatePieceDto) {
const data: Prisma.PieceUpdateInput = {};
if (updatePieceDto.name !== undefined) {
data.name = updatePieceDto.name;
}
if (updatePieceDto.reference !== undefined) {
data.reference = updatePieceDto.reference;
}
if (updatePieceDto.prix !== undefined) {
data.prix = updatePieceDto.prix;
}
if (updatePieceDto.constructeurId !== undefined) {
data.constructeur = updatePieceDto.constructeurId
? { connect: { id: updatePieceDto.constructeurId } }
: { disconnect: true };
}
if (updatePieceDto.typePieceId !== undefined) {
data.typePiece = updatePieceDto.typePieceId
? { connect: { id: updatePieceDto.typePieceId } }
: { disconnect: true };
}
const updated = await this.prisma.piece.update({
where: { id },
data: updatePieceDto,
data,
include: PIECE_WITH_RELATIONS_INCLUDE,
});
@@ -241,7 +157,6 @@ export class PiecesService {
const customFields = skeleton.customFields ?? [];
await this.ensurePieceCustomFieldDefinitions(typePiece.id, customFields);
await this.createPieceCustomFieldValues(
pieceId,
typePiece.id,
@@ -291,11 +206,7 @@ export class PiecesService {
}
const name = this.normalizeIdentifier(field.name);
if (!name) {
continue;
}
if (existingByName.has(name)) {
if (!name || existingByName.has(name)) {
continue;
}
@@ -426,16 +337,16 @@ export class PiecesService {
return '';
}
return String(value);
if (typeof value === 'string') {
return value;
}
return JSON.stringify(value);
}
}
type PieceRequirementWithType = Prisma.TypeMachinePieceRequirementGetPayload<{
include: { typePiece: true };
type PieceTypeWithSkeleton = Prisma.ModelTypeGetPayload<{
include: { pieceCustomFields: true };
}>;
type PieceTypeWithSkeleton = PieceRequirementWithType['typePiece'];
type PieceCustomFieldEntry = NonNullable<
PieceModelStructure['customFields']
>[number];
type PieceCustomFieldEntry = NonNullable<PieceModelStructure['customFields']>[number];

View File

@@ -1,4 +1,4 @@
import { IsString, IsOptional, IsNumber } from 'class-validator';
import { IsString, IsOptional, IsNumber, IsObject } from 'class-validator';
import { Transform } from 'class-transformer';
export class CreateComposantDto {
@@ -33,6 +33,10 @@ export class CreateComposantDto {
@IsOptional()
@IsString()
typeMachineComponentRequirementId?: string;
@IsOptional()
@IsObject()
structure?: Record<string, any>;
}
export class UpdateComposantDto {
@@ -56,4 +60,8 @@ export class UpdateComposantDto {
@IsOptional()
@IsString()
typeComposantId?: string;
@IsOptional()
@IsObject()
structure?: Record<string, any>;
}

View File

@@ -1,8 +1,22 @@
import { IsString, IsOptional, IsDecimal, IsArray } from 'class-validator';
import {
IsString,
IsOptional,
IsDecimal,
IsArray,
IsObject,
} from 'class-validator';
import { Type } from 'class-transformer';
import { ValidateNested } from 'class-validator';
export class MachineComponentSelectionDto {
export class MachineComponentLinkPayloadDto {
@IsOptional()
@IsString()
id?: string;
@IsOptional()
@IsString()
linkId?: string;
@IsString()
requirementId: string;
@@ -15,10 +29,59 @@ export class MachineComponentSelectionDto {
composantId?: string;
@IsOptional()
definition?: any;
@IsString()
componentId?: string;
@IsOptional()
@IsString()
parentLinkId?: string;
@IsOptional()
@IsString()
parentComponentLinkId?: string;
@IsOptional()
@IsString()
parentRequirementId?: string;
@IsOptional()
@IsString()
parentComponentRequirementId?: string;
@IsOptional()
@IsString()
parentPieceRequirementId?: string;
@IsOptional()
@IsString()
parentMachineComponentRequirementId?: string;
@IsOptional()
@IsString()
parentMachinePieceRequirementId?: string;
@IsOptional()
@IsString()
parentComponentId?: string;
@IsOptional()
@IsString()
parentPieceId?: string;
@IsOptional()
@IsObject()
overrides?: Record<string, unknown>;
}
export class MachinePieceSelectionDto {
export class MachinePieceLinkPayloadDto {
@IsOptional()
@IsString()
id?: string;
@IsOptional()
@IsString()
linkId?: string;
@IsString()
requirementId: string;
@@ -31,7 +94,52 @@ export class MachinePieceSelectionDto {
pieceId?: string;
@IsOptional()
definition?: any;
@IsString()
composantId?: string;
@IsOptional()
@IsString()
parentLinkId?: string;
@IsOptional()
@IsString()
parentComponentLinkId?: string;
@IsOptional()
@IsString()
parentPieceLinkId?: string;
@IsOptional()
@IsString()
parentRequirementId?: string;
@IsOptional()
@IsString()
parentComponentRequirementId?: string;
@IsOptional()
@IsString()
parentPieceRequirementId?: string;
@IsOptional()
@IsString()
parentMachineComponentRequirementId?: string;
@IsOptional()
@IsString()
parentMachinePieceRequirementId?: string;
@IsOptional()
@IsString()
parentComponentId?: string;
@IsOptional()
@IsString()
parentPieceId?: string;
@IsOptional()
@IsObject()
overrides?: Record<string, unknown>;
}
export class CreateMachineDto {
@@ -60,14 +168,14 @@ export class CreateMachineDto {
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => MachineComponentSelectionDto)
componentSelections?: MachineComponentSelectionDto[];
@Type(() => MachineComponentLinkPayloadDto)
componentLinks?: MachineComponentLinkPayloadDto[];
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => MachinePieceSelectionDto)
pieceSelections?: MachinePieceSelectionDto[];
@Type(() => MachinePieceLinkPayloadDto)
pieceLinks?: MachinePieceLinkPayloadDto[];
}
export class UpdateMachineDto {
@@ -96,12 +204,15 @@ export class ReconfigureMachineDto {
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => MachineComponentSelectionDto)
componentSelections?: MachineComponentSelectionDto[];
@Type(() => MachineComponentLinkPayloadDto)
componentLinks?: MachineComponentLinkPayloadDto[];
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => MachinePieceSelectionDto)
pieceSelections?: MachinePieceSelectionDto[];
@Type(() => MachinePieceLinkPayloadDto)
pieceLinks?: MachinePieceLinkPayloadDto[];
}
export type MachineComponentLinkInput = MachineComponentLinkPayloadDto;
export type MachinePieceLinkInput = MachinePieceLinkPayloadDto;

View File

@@ -18,14 +18,8 @@ export class SitesService {
machines: {
include: {
typeMachine: true,
composants: {
include: {
typeComposant: true,
sousComposants: true,
pieces: true,
},
},
pieces: true,
componentLinks: true,
pieceLinks: true,
},
},
documents: true,
@@ -40,14 +34,8 @@ export class SitesService {
machines: {
include: {
typeMachine: true,
composants: {
include: {
typeComposant: true,
sousComposants: true,
pieces: true,
},
},
pieces: true,
componentLinks: true,
pieceLinks: true,
},
},
documents: true,