Migrate away from legacy component and piece models

This commit is contained in:
MatthieuTD
2025-10-02 15:44:02 +02:00
parent 44fd4bb8c7
commit c23ba3a587
34 changed files with 1821 additions and 1825 deletions

View File

@@ -16,7 +16,6 @@ export const COMPONENT_WITH_RELATIONS_INCLUDE = {
customFields: true,
},
},
composantModel: true,
typeMachineComponentRequirement: {
include: {
typeComposant: {
@@ -40,7 +39,6 @@ export const COMPONENT_WITH_RELATIONS_INCLUDE = {
},
},
constructeur: true,
pieceModel: true,
typeMachinePieceRequirement: {
include: {
typePiece: {

View File

@@ -1,4 +1,8 @@
import { ModelTypeMapper } from './model-type.mapper';
import {
ComponentModelStructureSchema,
PieceModelStructureSchema,
} from '../../shared/schemas/inventory';
describe('ModelTypeMapper', () => {
it('should map component create input', () => {
@@ -8,9 +12,30 @@ describe('ModelTypeMapper', () => {
customFields: [
{ name: 'Field', type: 'string', required: false, options: [] },
],
structure: {
pieces: [
{
familyCode: 'bolt',
role: 'Fixation',
},
],
customFields: [
{
key: 'color',
value: 'red',
},
],
subcomponents: [
{
familyCode: 'sub-family',
alias: 'Secondary',
},
],
},
} as any;
const input = ModelTypeMapper.toComponentCreateInput(dto, 'code');
const skeleton = ComponentModelStructureSchema.parse(dto.structure);
const input = ModelTypeMapper.toComponentCreateInput(dto, 'code', skeleton);
expect(input).toMatchObject({
name: 'Comp',
@@ -19,6 +44,72 @@ describe('ModelTypeMapper', () => {
notes: 'Desc',
});
expect(input.customFields?.create?.[0]).toMatchObject({ name: 'Field' });
expect((input as any).componentSkeleton).toEqual({
pieces: [
{
familyCode: 'bolt',
role: 'Fixation',
},
],
customFields: [
{
key: 'color',
value: 'red',
},
],
subcomponents: [
{
familyCode: 'sub-family',
alias: 'Secondary',
},
],
});
});
it('should map piece create input with skeleton', () => {
const dto = {
name: 'Piece type',
description: 'Desc',
customFields: [],
structure: {
customFields: [
{
name: 'Length',
value: 12,
type: 'number',
required: true,
},
{
key: 'color',
value: 'blue',
optionsText: 'blue\nred',
},
],
typePieceId: ' piece-id ',
standard: 'ISO',
},
} as any;
const skeleton = PieceModelStructureSchema.parse(dto.structure);
const input = ModelTypeMapper.toPieceCreateInput(dto, 'code', skeleton);
expect((input as any).pieceSkeleton).toEqual({
customFields: [
{
name: 'Length',
value: 12,
type: 'number',
required: true,
},
{
name: 'color',
value: 'blue',
options: ['blue', 'red'],
},
],
typePieceId: 'piece-id',
standard: 'ISO',
});
});
it('should map piece model type to DTO shape', () => {
@@ -26,7 +117,7 @@ describe('ModelTypeMapper', () => {
id: '1',
name: 'Piece',
pieceCustomFields: [{ id: 'cf' }],
pieceModels: [{ id: 'model' }],
pieceSkeleton: { customFields: [{ name: 'Length' }] },
pieceRequirements: [{ id: 'req' }],
pieces: [{ id: 'piece' }],
});
@@ -34,18 +125,21 @@ describe('ModelTypeMapper', () => {
expect(mapped).toMatchObject({
id: '1',
customFields: [{ id: 'cf' }],
models: [{ id: 'model' }],
pieceRequirements: [{ id: 'req' }],
pieces: [{ id: 'piece' }],
structure: { customFields: [{ name: 'Length' }] },
});
});
it('should map piece update input', () => {
const input = ModelTypeMapper.toPieceUpdateInput({
const dto: any = {
name: 'New',
description: 'D',
customFields: [],
} as any);
structure: { customFields: [{ name: 'Length' }] },
};
const skeleton = PieceModelStructureSchema.parse(dto.structure);
const input = ModelTypeMapper.toPieceUpdateInput(dto, skeleton);
expect(input).toMatchObject({
name: 'New',
@@ -53,5 +147,36 @@ describe('ModelTypeMapper', () => {
notes: 'D',
});
expect(input.pieceCustomFields).toBeUndefined();
expect((input as any).pieceSkeleton).toEqual({
customFields: [
{
name: 'Length',
},
],
});
});
it('should map component update input with skeleton', () => {
const dto: any = {
name: 'Updated',
structure: {
pieces: [{ typePieceId: 'piece-1' }],
customFields: [],
subcomponents: [],
},
};
const skeleton = ComponentModelStructureSchema.parse(dto.structure);
const input = ModelTypeMapper.toComponentUpdateInput(dto, skeleton);
expect(input).toMatchObject({ name: 'Updated' });
expect((input as any).componentSkeleton).toEqual({
pieces: [
{
typePieceId: 'piece-1',
},
],
customFields: [],
subcomponents: [],
});
});
});

View File

@@ -5,17 +5,19 @@ import {
UpdateTypeComposantDto,
UpdateTypePieceDto,
} from '../../shared/dto/type.dto';
import type {
ComponentModelStructure,
PieceModelStructure,
} from '../../shared/types/inventory';
import { CUSTOM_FIELD_SELECT } from '../constants/custom-field.constant';
export const COMPONENT_TYPE_INCLUDE: Prisma.ModelTypeInclude = {
customFields: { select: CUSTOM_FIELD_SELECT },
composants: true,
models: true,
};
export const PIECE_TYPE_INCLUDE: Prisma.ModelTypeInclude = {
pieceCustomFields: { select: CUSTOM_FIELD_SELECT },
pieceModels: true,
pieceRequirements: true,
pieces: true,
};
@@ -29,6 +31,7 @@ export class ModelTypeMapper {
static toComponentCreateInput(
dto: CreateTypeComposantDto,
code: string,
skeleton?: ComponentModelStructure,
): ModelTypeCreateWithoutCategory {
const { customFields, description, name } = dto;
@@ -47,11 +50,13 @@ export class ModelTypeMapper {
})),
}
: undefined,
...(skeleton ? { componentSkeleton: skeleton as Prisma.InputJsonValue } : {}),
};
}
static toComponentUpdateInput(
dto: UpdateTypeComposantDto,
skeleton?: ComponentModelStructure,
): Prisma.ModelTypeUpdateInput {
const { customFields, description, name } = dto;
const data: Prisma.ModelTypeUpdateInput = {};
@@ -69,12 +74,17 @@ export class ModelTypeMapper {
data.customFields = undefined;
}
if (skeleton !== undefined) {
data.componentSkeleton = skeleton as Prisma.InputJsonValue;
}
return data;
}
static toPieceCreateInput(
dto: CreateTypePieceDto,
code: string,
skeleton?: PieceModelStructure,
): ModelTypeCreateWithoutCategory {
const { customFields, description, name } = dto;
@@ -93,11 +103,13 @@ export class ModelTypeMapper {
})),
}
: undefined,
...(skeleton ? { pieceSkeleton: skeleton as Prisma.InputJsonValue } : {}),
};
}
static toPieceUpdateInput(
dto: UpdateTypePieceDto,
skeleton?: PieceModelStructure,
): Prisma.ModelTypeUpdateInput {
const { customFields, description, name } = dto;
const data: Prisma.ModelTypeUpdateInput = {};
@@ -115,6 +127,10 @@ export class ModelTypeMapper {
data.pieceCustomFields = undefined;
}
if (skeleton !== undefined) {
data.pieceSkeleton = skeleton as Prisma.InputJsonValue;
}
return data;
}
@@ -125,18 +141,18 @@ export class ModelTypeMapper {
const {
pieceCustomFields,
pieceModels,
pieceRequirements,
pieces,
pieceSkeleton,
...rest
} = modelType;
return {
...rest,
customFields: pieceCustomFields ?? [],
models: pieceModels ?? [],
pieceRequirements: pieceRequirements ?? [],
pieces: pieces ?? [],
structure: pieceSkeleton ?? null,
};
}

View File

@@ -1,58 +0,0 @@
import { Injectable } from '@nestjs/common';
import { Prisma, PrismaClient } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class ComposantModelsRepository {
constructor(private readonly prisma: PrismaService) {}
private get client(): PrismaClient {
return this.prisma;
}
async create(
data: Prisma.ComposantModelCreateInput,
include?: Prisma.ComposantModelInclude,
) {
return this.client.composantModel.create({
data,
include,
});
}
async findAll(
typeComposantId?: string,
include?: Prisma.ComposantModelInclude,
) {
return this.client.composantModel.findMany({
where: typeComposantId ? { typeComposantId } : undefined,
include,
orderBy: { name: 'asc' },
});
}
async findOne(id: string, include?: Prisma.ComposantModelInclude) {
return this.client.composantModel.findUnique({
where: { id },
include,
});
}
async update(
id: string,
data: Prisma.ComposantModelUpdateInput,
include?: Prisma.ComposantModelInclude,
) {
return this.client.composantModel.update({
where: { id },
data,
include,
});
}
async delete(id: string) {
return this.client.composantModel.delete({
where: { id },
});
}
}

View File

@@ -1,55 +0,0 @@
import { Injectable } from '@nestjs/common';
import { Prisma, PrismaClient } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class PieceModelsRepository {
constructor(private readonly prisma: PrismaService) {}
private get client(): PrismaClient {
return this.prisma;
}
async create(
data: Prisma.PieceModelCreateInput,
include?: Prisma.PieceModelInclude,
) {
return this.client.pieceModel.create({
data,
include,
});
}
async findAll(typePieceId?: string, include?: Prisma.PieceModelInclude) {
return this.client.pieceModel.findMany({
where: typePieceId ? { typePieceId } : undefined,
include,
orderBy: { name: 'asc' },
});
}
async findOne(id: string, include?: Prisma.PieceModelInclude) {
return this.client.pieceModel.findUnique({
where: { id },
include,
});
}
async update(
id: string,
data: Prisma.PieceModelUpdateInput,
include?: Prisma.PieceModelInclude,
) {
return this.client.pieceModel.update({
where: { id },
data,
include,
});
}
async delete(id: string) {
return this.client.pieceModel.delete({
where: { id },
});
}
}

View File

@@ -13,10 +13,21 @@ describe('ComposantsService', () => {
composant: {
create: 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(),
},
};
const module: TestingModule = await Test.createTestingModule({
@@ -45,15 +56,48 @@ describe('ComposantsService', () => {
id: 'machine-1',
typeMachine: {
componentRequirements: [
{ id: 'req-1', typeComposantId: 'type-comp-1' },
{
id: 'req-1',
typeComposantId: 'type-comp-1',
typeComposant: {
id: 'type-comp-1',
name: 'Comp type',
code: 'comp-type',
componentSkeleton: null,
},
},
],
pieceRequirements: [],
},
});
const created = { id: 'component-1' };
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.toEqual(created);
await expect(service.create(dto)).resolves.toMatchObject({ id: 'component-1' });
expect(prisma.composant.create).toHaveBeenCalled();
expect(prisma.composant.create.mock.calls[0][0].data.typeComposantId).toBe(
@@ -75,6 +119,7 @@ describe('ComposantsService', () => {
componentRequirements: [
{ id: 'req-1', typeComposantId: 'type-comp-1' },
],
pieceRequirements: [],
},
});
@@ -84,4 +129,155 @@ describe('ComposantsService', () => {
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',
});
});
});

View File

@@ -1,4 +1,5 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import {
CreateComposantDto,
@@ -12,6 +13,19 @@ 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 {
@@ -80,7 +94,16 @@ export class ComposantsService {
include: {
typeMachine: {
include: {
componentRequirements: true,
componentRequirements: {
include: {
typeComposant: true,
},
},
pieceRequirements: {
include: {
typePiece: true,
},
},
},
},
},
@@ -92,7 +115,12 @@ export class ComposantsService {
);
}
const requirement = machine.typeMachine.componentRequirements.find(
const componentRequirements =
(machine.typeMachine.componentRequirements as ComponentRequirementWithType[]) ?? [];
const pieceRequirements =
(machine.typeMachine.pieceRequirements as PieceRequirementWithType[]) ?? [];
const requirement = componentRequirements.find(
(componentRequirement) => componentRequirement.id === requirementId,
);
@@ -111,20 +139,38 @@ export class ComposantsService {
);
}
const data = {
...createComposantDto,
machineId,
typeComposantId:
createComposantDto.typeComposantId ?? requirement.typeComposantId,
};
const typeComposantId =
createComposantDto.typeComposantId ?? requirement.typeComposantId;
const created = (await this.prisma.composant.create({
data,
const created = await this.prisma.composant.create({
data: {
...createComposantDto,
machineId,
typeComposantId,
},
include: COMPONENT_WITH_RELATIONS_INCLUDE,
})) as ComposantWithRelations;
});
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 (component as ComposantWithRelations | null) ?? (created as ComposantWithRelations);
}
async findAll() {
@@ -156,11 +202,379 @@ export class ComposantsService {
include: COMPONENT_WITH_RELATIONS_INCLUDE,
})) as ComposantWithRelations;
await this.syncComponentModelCustomFields(updated);
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 as ModelTypeWithSkeleton,
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> {
@@ -197,155 +611,4 @@ export class ComposantsService {
where: { id },
});
}
private async syncComponentModelCustomFields(
component: ComposantWithRelations,
) {
const { composantModelId, typeComposantId } = component;
if (!composantModelId || !typeComposantId) {
return;
}
const model = await this.prisma.composantModel.findUnique({
where: { id: composantModelId },
select: { structure: true },
});
if (!model?.structure) {
return;
}
await this.syncComponentStructureCustomFields(
model.structure,
typeComposantId,
);
}
private async syncComponentStructureCustomFields(
structure: any,
typeComposantId: string | null,
) {
if (typeComposantId) {
await this.ensureCustomFieldsForType(
'typeComposantId',
typeComposantId,
structure?.customFields,
);
}
const pieces = Array.isArray(structure?.pieces) ? structure.pieces : [];
for (const piece of pieces) {
const typePieceId = this.extractTypePieceId(piece);
if (typePieceId) {
await this.ensureCustomFieldsForType(
'typePieceId',
typePieceId,
piece?.customFields,
);
}
}
const rawSubcomponents =
(structure as any)?.subcomponents ?? structure?.subComponents;
const subComponents = Array.isArray(rawSubcomponents)
? rawSubcomponents
: rawSubcomponents
? [rawSubcomponents]
: [];
for (const sub of subComponents) {
const subTypeId = this.extractTypeComposantId(sub);
if (!subTypeId) {
continue;
}
await this.syncComponentStructureCustomFields(sub, subTypeId);
}
}
private extractTypePieceId(entry: any): string | null {
if (!entry || typeof entry !== 'object') {
return null;
}
return (
entry.typePieceId ||
entry.typePiece?.id ||
null
);
}
private extractTypeComposantId(entry: any): string | null {
if (!entry || typeof entry !== 'object') {
return null;
}
return (
entry.typeComposantId ||
entry.typeComposant?.id ||
null
);
}
private async ensureCustomFieldsForType(
typeKey: 'typeComposantId' | 'typePieceId',
typeId: string | null,
fields: any,
) {
if (!typeId || !Array.isArray(fields)) {
return;
}
for (const field of fields) {
if (!field || typeof field !== 'object') {
continue;
}
const name = typeof field.name === 'string' ? field.name.trim() : '';
if (!name) {
continue;
}
const type = typeof field.type === 'string' && field.type.trim()
? field.type.trim()
: 'text';
const required = !!field.required;
const options = this.normalizeOptions(field);
const existing = await this.prisma.customField.findFirst({
where: {
name,
type,
[typeKey]: typeId,
},
});
if (!existing) {
await this.prisma.customField.create({
data: {
name,
type,
required,
options,
[typeKey]: typeId,
},
});
}
}
}
private normalizeOptions(field: any): string[] | undefined {
if (Array.isArray(field?.options)) {
const options = field.options
.map((option: any) =>
typeof option === 'string' ? option.trim() : '',
)
.filter((option: string) => option.length > 0);
return options.length ? options : undefined;
}
if (typeof field?.optionsText === 'string') {
const options = field.optionsText
.split(/\r?\n/)
.map((option: string) => option.trim())
.filter((option: string) => option.length > 0);
return options.length ? options : undefined;
}
return undefined;
}
}

View File

@@ -2,14 +2,24 @@ import { Test, TestingModule } from '@nestjs/testing';
import { MachinesController } from './machines.controller';
import { MachinesService } from './machines.service';
import { PrismaService } from '../prisma/prisma.service';
import { ComposantsService } from '../composants/composants.service';
import { PiecesService } from '../pieces/pieces.service';
describe('MachinesController', () => {
let controller: MachinesController;
beforeEach(async () => {
const mockComposantsService = { create: jest.fn() } as Partial<ComposantsService>;
const mockPiecesService = { create: jest.fn() } as Partial<PiecesService>;
const module: TestingModule = await Test.createTestingModule({
controllers: [MachinesController],
providers: [MachinesService, PrismaService],
providers: [
MachinesService,
PrismaService,
{ provide: ComposantsService, useValue: mockComposantsService },
{ provide: PiecesService, useValue: mockPiecesService },
],
}).compile();
controller = module.get<MachinesController>(MachinesController);

View File

@@ -1,9 +1,11 @@
import { Module } from '@nestjs/common';
import { MachinesController } from './machines.controller';
import { MachinesService } from './machines.service';
import { ComposantsService } from '../composants/composants.service';
import { PiecesService } from '../pieces/pieces.service';
@Module({
controllers: [MachinesController],
providers: [MachinesService],
providers: [MachinesService, ComposantsService, PiecesService],
})
export class MachinesModule {}

View File

@@ -1,13 +1,23 @@
import { Test, TestingModule } from '@nestjs/testing';
import { MachinesService } from './machines.service';
import { PrismaService } from '../prisma/prisma.service';
import { ComposantsService } from '../composants/composants.service';
import { PiecesService } from '../pieces/pieces.service';
describe('MachinesService', () => {
let service: MachinesService;
beforeEach(async () => {
const mockComposantsService = { create: jest.fn() } as Partial<ComposantsService>;
const mockPiecesService = { create: jest.fn() } as Partial<PiecesService>;
const module: TestingModule = await Test.createTestingModule({
providers: [MachinesService, PrismaService],
providers: [
MachinesService,
PrismaService,
{ provide: ComposantsService, useValue: mockComposantsService },
{ provide: PiecesService, useValue: mockPiecesService },
],
}).compile();
service = module.get<MachinesService>(MachinesService);

File diff suppressed because it is too large Load Diff

View File

@@ -12,6 +12,10 @@ describe('PiecesService', () => {
prisma = {
piece: {
create: jest.fn(),
findMany: jest.fn(),
findUnique: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
machine: {
findUnique: jest.fn(),
@@ -19,6 +23,14 @@ describe('PiecesService', () => {
composant: {
findUnique: jest.fn(),
},
customField: {
findMany: jest.fn(),
create: jest.fn(),
},
customFieldValue: {
findMany: jest.fn(),
create: jest.fn(),
},
};
const module: TestingModule = await Test.createTestingModule({
@@ -43,18 +55,94 @@ describe('PiecesService', () => {
prisma.machine.findUnique.mockResolvedValue({
id: 'machine-1',
typeMachine: {
pieceRequirements: [{ id: 'req-1', typePieceId: 'type-piece-1' }],
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' };
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);
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',
);
prisma.customField.findMany
.mockResolvedValueOnce([])
.mockResolvedValueOnce([
{ id: 'field-1', name: 'Numéro de série' },
]);
prisma.customField.create.mockResolvedValue({ id: 'field-1' });
prisma.customFieldValue.findMany.mockResolvedValue([]);
prisma.customFieldValue.create.mockResolvedValue({
id: 'value-1',
});
const finalPiece = { ...created, customFieldValues: [] };
prisma.piece.findUnique.mockResolvedValue(finalPiece);
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),
});
});
it('should refuse creation when requirement does not belong to machine skeleton', async () => {

View File

@@ -1,23 +1,25 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { CreatePieceDto, UpdatePieceDto } from '../shared/dto/piece.dto';
import { PieceModelStructureSchema } from '../shared/schemas/inventory';
import type { PieceModelStructure } from '../shared/types/inventory';
const PIECE_WITH_RELATIONS_INCLUDE = {
machine: true,
composant: true,
typePiece: {
include: {
customFields: true,
pieceCustomFields: true,
},
},
documents: true,
constructeur: true,
pieceModel: true,
typeMachinePieceRequirement: {
include: {
typePiece: {
include: {
customFields: true,
pieceCustomFields: true,
},
},
},
@@ -63,7 +65,11 @@ export class PiecesService {
include: {
typeMachine: {
include: {
pieceRequirements: true,
pieceRequirements: {
include: {
typePiece: true,
},
},
},
},
},
@@ -100,73 +106,35 @@ export class PiecesService {
typePieceId: createPieceDto.typePieceId ?? requirement.typePieceId,
};
return this.prisma.piece.create({
const created = await this.prisma.piece.create({
data,
include: {
machine: true,
composant: true,
typePiece: true,
documents: true,
constructeur: true,
pieceModel: true,
typeMachinePieceRequirement: {
include: {
typePiece: true,
},
},
customFieldValues: {
include: {
customField: true,
},
},
},
include: PIECE_WITH_RELATIONS_INCLUDE,
});
await this.applyPieceSkeleton({
pieceId: created.id,
typePiece:
(requirement.typePiece as PieceTypeWithSkeleton | null) ??
(created.typePiece as PieceTypeWithSkeleton | null) ??
null,
});
return this.prisma.piece.findUnique({
where: { id: created.id },
include: PIECE_WITH_RELATIONS_INCLUDE,
});
}
async findAll() {
return this.prisma.piece.findMany({
include: {
machine: true,
composant: true,
typePiece: true,
documents: true,
constructeur: true,
pieceModel: true,
typeMachinePieceRequirement: {
include: {
typePiece: true,
},
},
customFieldValues: {
include: {
customField: true,
},
},
},
include: PIECE_WITH_RELATIONS_INCLUDE,
});
}
async findOne(id: string) {
return this.prisma.piece.findUnique({
where: { id },
include: {
machine: true,
composant: true,
typePiece: true,
documents: true,
constructeur: true,
pieceModel: true,
typeMachinePieceRequirement: {
include: {
typePiece: true,
},
},
customFieldValues: {
include: {
customField: true,
},
},
},
include: PIECE_WITH_RELATIONS_INCLUDE,
});
}
@@ -220,9 +188,15 @@ export class PiecesService {
include: PIECE_WITH_RELATIONS_INCLUDE,
});
await this.syncPieceModelCustomFields(updated);
await this.applyPieceSkeleton({
pieceId: updated.id,
typePiece: updated.typePiece as PieceTypeWithSkeleton | null,
});
return updated;
return this.prisma.piece.findUnique({
where: { id: updated.id },
include: PIECE_WITH_RELATIONS_INCLUDE,
});
}
async remove(id: string) {
@@ -231,136 +205,213 @@ export class PiecesService {
});
}
private async syncPieceModelCustomFields(piece: any) {
const pieceModelId = piece?.pieceModelId;
if (!pieceModelId) {
private async applyPieceSkeleton({
pieceId,
typePiece,
}: {
pieceId: string;
typePiece: PieceTypeWithSkeleton | null;
}) {
if (!typePiece?.id) {
return;
}
const model = await this.prisma.pieceModel.findUnique({
where: { id: pieceModelId },
select: { structure: true },
});
const skeleton = this.parsePieceSkeleton(
(typePiece as { pieceSkeleton?: Prisma.JsonValue | null } | null)?.
pieceSkeleton,
);
if (!model?.structure) {
if (!skeleton) {
return;
}
const structure = this.asRecord(model.structure);
const customFields = this.extractCustomFields(structure);
const customFields = skeleton.customFields ?? [];
const targetTypePieceId = this.getTypePieceIdForPiece(piece, structure);
if (!targetTypePieceId) {
return;
}
await this.ensurePieceCustomFieldDefinitions(
typePiece.id,
customFields,
);
await this.ensureCustomFieldsForType(
targetTypePieceId,
await this.createPieceCustomFieldValues(
pieceId,
typePiece.id,
customFields,
);
}
private async ensureCustomFieldsForType(
private parsePieceSkeleton(value: unknown): PieceModelStructure | null {
if (!value) {
return null;
}
try {
return PieceModelStructureSchema.parse(value);
} catch (error) {
return null;
}
}
private async ensurePieceCustomFieldDefinitions(
typePieceId: string,
fields: any,
customFields: PieceModelStructure['customFields'],
) {
if (!typePieceId || !Array.isArray(fields)) {
if (!typePieceId || !Array.isArray(customFields) || customFields.length === 0) {
return;
}
for (const field of fields) {
if (!field || typeof field !== 'object') {
const existing = await this.prisma.customField.findMany({
where: { typePieceId },
select: { id: true, name: true },
});
const existingByName = new Map(
existing.map((field) => [this.normalizeIdentifier(field.name) ?? field.name, field.id]),
);
for (const field of customFields) {
if (!field) {
continue;
}
const name = typeof field.name === 'string' ? field.name.trim() : '';
const name = this.normalizeIdentifier(field.name);
if (!name) {
continue;
}
const type = typeof field.type === 'string' && field.type.trim()
? field.type.trim()
: 'text';
const required = !!field.required;
if (existingByName.has(name)) {
continue;
}
const type = this.normalizeIdentifier(field.type) ?? 'text';
const required = Boolean(field.required);
const options = this.normalizeOptions(field);
const existing = await this.prisma.customField.findFirst({
where: {
const created = await this.prisma.customField.create({
data: {
name,
type,
required,
options,
typePieceId,
},
select: { id: true },
});
if (!existing) {
await this.prisma.customField.create({
data: {
name,
type,
required,
options,
typePieceId,
},
});
}
existingByName.set(name, created.id);
}
}
private normalizeOptions(field: any): string[] | undefined {
if (Array.isArray(field?.options)) {
const normalized = field.options
.map((option: any) =>
typeof option === 'string' ? option.trim() : '',
)
.filter((option: string) => option.length > 0);
return normalized.length ? normalized : undefined;
private async createPieceCustomFieldValues(
pieceId: string,
typePieceId: string,
customFields: PieceModelStructure['customFields'],
) {
if (!typePieceId || !Array.isArray(customFields) || customFields.length === 0) {
return;
}
if (typeof field?.optionsText === 'string') {
const normalized = field.optionsText
const definitions = await this.prisma.customField.findMany({
where: { typePieceId },
select: { id: true, name: true },
});
if (definitions.length === 0) {
return;
}
const definitionMap = new Map(
definitions.map((field) => [this.normalizeIdentifier(field.name) ?? field.name, field.id]),
);
const existingValues = await this.prisma.customFieldValue.findMany({
where: { pieceId },
select: { customFieldId: true },
});
const existingIds = new Set(existingValues.map((value) => value.customFieldId));
for (const field of customFields) {
if (!field) {
continue;
}
const name = this.normalizeIdentifier(field.name);
if (!name) {
continue;
}
const definitionId = definitionMap.get(name);
if (!definitionId || existingIds.has(definitionId)) {
continue;
}
await this.prisma.customFieldValue.create({
data: {
customFieldId: definitionId,
pieceId,
value: this.toCustomFieldValue(field.value),
},
});
existingIds.add(definitionId);
}
}
private normalizeOptions(
field: PieceCustomFieldEntry | undefined,
): string[] | undefined {
const rawOptions = field?.options;
if (Array.isArray(rawOptions)) {
const normalized = rawOptions
.map((option) =>
typeof option === 'string' ? option.trim() : '',
)
.filter((option) => option.length > 0);
return normalized.length > 0 ? normalized : undefined;
}
const optionsTextValue =
field !== undefined
? (field as unknown as { optionsText?: unknown }).optionsText
: undefined;
if (typeof optionsTextValue === 'string') {
const normalized = optionsTextValue
.split(/\r?\n/)
.map((option: string) => option.trim())
.filter((option: string) => option.length > 0);
return normalized.length ? normalized : undefined;
return normalized.length > 0 ? normalized : undefined;
}
return undefined;
}
private getTypePieceIdForPiece(
piece: any,
modelStructure: Record<string, any> | null,
): string | null {
const structure = this.asRecord(modelStructure);
const structureTypePiece = this.asRecord(structure?.typePiece ?? null);
return (
piece?.typePieceId ||
piece?.typePiece?.id ||
piece?.typeMachinePieceRequirement?.typePieceId ||
piece?.typeMachinePieceRequirement?.typePiece?.id ||
structure?.typePieceId ||
structureTypePiece?.id ||
null
);
}
private asRecord(value: unknown): Record<string, any> | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
private normalizeIdentifier(value: unknown): string | null {
if (typeof value !== 'string') {
return null;
}
return value as Record<string, any>;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
private extractCustomFields(structure: Record<string, any> | null): any[] {
if (!structure) {
return [];
private toCustomFieldValue(value: unknown): string {
if (value === undefined || value === null) {
return '';
}
const { customFields } = structure;
return Array.isArray(customFields) ? customFields : [];
return String(value);
}
}
type PieceRequirementWithType = Prisma.TypeMachinePieceRequirementGetPayload<{
include: { typePiece: true };
}>;
type PieceTypeWithSkeleton = PieceRequirementWithType['typePiece'];
type PieceCustomFieldEntry = NonNullable<
PieceModelStructure['customFields']
>[number];

View File

@@ -32,10 +32,6 @@ export class CreateComposantDto {
@IsString()
typeMachineComponentRequirementId: string;
@IsOptional()
@IsString()
composantModelId?: string;
}
export class UpdateComposantDto {
@@ -59,8 +55,4 @@ export class UpdateComposantDto {
@IsOptional()
@IsString()
typeComposantId?: string;
@IsOptional()
@IsString()
composantModelId?: string;
}

View File

@@ -8,7 +8,7 @@ export class MachineComponentSelectionDto {
@IsOptional()
@IsString()
componentModelId?: string;
typeComposantId?: string;
@IsOptional()
definition?: any;
@@ -18,8 +18,12 @@ export class MachinePieceSelectionDto {
@IsString()
requirementId: string;
@IsOptional()
@IsString()
pieceModelId: string;
typePieceId?: string;
@IsOptional()
definition?: any;
}
export class CreateMachineDto {

View File

@@ -32,10 +32,6 @@ export class CreatePieceDto {
@IsString()
typeMachinePieceRequirementId: string;
@IsOptional()
@IsString()
pieceModelId?: string;
}
export class UpdatePieceDto {
@@ -59,8 +55,4 @@ export class UpdatePieceDto {
@IsOptional()
@IsString()
typePieceId?: string;
@IsOptional()
@IsString()
pieceModelId?: string;
}

View File

@@ -9,7 +9,10 @@ import {
} from 'class-validator';
import { Type } from 'class-transformer';
import { ValidateNested } from 'class-validator';
import type { ComponentModelStructure } from '../types/inventory';
import type {
ComponentModelStructure,
PieceModelStructure,
} from '../types/inventory';
export enum CustomFieldType {
TEXT = 'text',
@@ -197,6 +200,10 @@ export class CreateTypeComposantDto {
@IsOptional()
@IsArray()
customFields?: CreateCustomFieldDto[];
@IsOptional()
@IsObject()
structure?: ComponentModelStructure;
}
export class UpdateTypeComposantDto {
@@ -211,6 +218,10 @@ export class UpdateTypeComposantDto {
@IsOptional()
@IsArray()
customFields?: CreateCustomFieldDto[];
@IsOptional()
@IsObject()
structure?: ComponentModelStructure;
}
export class CreateTypePieceDto {
@@ -224,6 +235,10 @@ export class CreateTypePieceDto {
@IsOptional()
@IsArray()
customFields?: CreateCustomFieldDto[];
@IsOptional()
@IsObject()
structure?: PieceModelStructure;
}
export class UpdateTypePieceDto {
@@ -238,68 +253,9 @@ export class UpdateTypePieceDto {
@IsOptional()
@IsArray()
customFields?: CreateCustomFieldDto[];
@IsOptional()
@IsObject()
structure?: PieceModelStructure;
}
export class CreateComposantModelDto {
@IsString()
name: string;
@IsOptional()
@IsString()
description?: string;
@IsString()
typeComposantId: string;
@IsOptional()
structure?: ComponentModelStructure;
}
export class UpdateComposantModelDto {
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsString()
typeComposantId?: string;
@IsOptional()
structure?: ComponentModelStructure;
}
export class CreatePieceModelDto {
@IsString()
name: string;
@IsOptional()
@IsString()
description?: string;
@IsString()
typePieceId: string;
@IsOptional()
structure?: any;
}
export class UpdatePieceModelDto {
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsString()
typePieceId?: string;
@IsOptional()
structure?: any;
}

View File

@@ -1,5 +1,9 @@
import { normalizeComponentModelStructure } from '../../component-models/structure.normalizer';
import type { ComponentModelStructure } from '../types/inventory';
import type {
ComponentModelStructure,
PieceModelCustomField,
PieceModelStructure,
} from '../types/inventory';
export class ComponentModelStructureValidationError extends Error {
constructor(message: string) {
@@ -150,3 +154,109 @@ export const ComponentModelStructureSchema = {
};
},
};
export class PieceModelStructureValidationError extends Error {
constructor(message: string) {
super(message);
this.name = 'PieceModelStructureValidationError';
}
}
function toStringOrNull(value: unknown): string | null {
if (value === undefined || value === null) {
return null;
}
const trimmed = String(value).trim();
return trimmed ? trimmed : null;
}
function normalizePieceModelCustomFields(
customFields: unknown,
): PieceModelCustomField[] {
if (!Array.isArray(customFields)) {
return [];
}
const normalized: PieceModelCustomField[] = [];
customFields.forEach((entry, index) => {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
return;
}
const record = entry as Record<string, unknown>;
const rawName =
(typeof record.name === 'string' ? record.name : undefined) ??
(typeof record.key === 'string' ? record.key : undefined) ??
undefined;
const name = rawName ? rawName.trim() : '';
if (!name) {
throw new PieceModelStructureValidationError(
`customFields[${index}].name doit être une chaîne non vide`,
);
}
const field: PieceModelCustomField = { name };
if ('value' in record) {
field.value = record.value;
}
if (typeof record.type === 'string') {
field.type = record.type;
}
if ('required' in record) {
field.required = Boolean(record.required);
}
if (Array.isArray(record.options)) {
field.options = record.options;
} else if (typeof record.optionsText === 'string') {
const options = record.optionsText
.split(/\r?\n/)
.map((option) => option.trim())
.filter((option) => option.length > 0);
if (options.length > 0) {
field.options = options;
}
}
normalized.push(field);
});
return normalized;
}
export const PieceModelStructureSchema = {
parse(input: unknown): PieceModelStructure {
if (input === undefined || input === null) {
return { customFields: [] };
}
if (typeof input !== 'object' || Array.isArray(input)) {
throw new PieceModelStructureValidationError(
'La structure de pièce doit être un objet JSON.',
);
}
const record = input as Record<string, unknown>;
const structure: PieceModelStructure = { ...record };
const customFields = normalizePieceModelCustomFields(record.customFields);
if (customFields.length > 0 || 'customFields' in record) {
structure.customFields = customFields;
}
const normalizedTypePiece = toStringOrNull(record.typePieceId);
if (normalizedTypePiece) {
structure.typePieceId = normalizedTypePiece;
} else if ('typePieceId' in record) {
delete (structure as Record<string, unknown>).typePieceId;
}
return structure;
},
};

View File

@@ -39,3 +39,16 @@ export type ComponentModelStructure = {
}
>;
};
export type PieceModelCustomField = {
name: string;
value?: unknown;
type?: string;
required?: boolean;
options?: unknown;
};
export type PieceModelStructure = {
customFields?: PieceModelCustomField[];
[key: string]: unknown;
};

View File

@@ -1,97 +0,0 @@
import { Injectable } from '@nestjs/common';
import { ComposantModelsRepository } from '../../common/repositories/composant-models.repository';
import type { Prisma } from '@prisma/client';
import {
CreateComposantModelDto,
UpdateComposantModelDto,
} from '../../shared/dto/type.dto';
import { ComponentModelStructureSchema } from '../../shared/schemas/inventory';
import type { ComponentModelStructure } from '../../shared/types/inventory';
const COMPOSANT_MODEL_INCLUDE = {
typeComposant: true,
} as const;
@Injectable()
export class ComposantModelService {
constructor(private readonly repository: ComposantModelsRepository) {}
async create(dto: CreateComposantModelDto) {
const { typeComposantId, structure, ...data } = dto;
const parsedStructure = this.parseStructure(structure);
const created = await this.repository.create(
{
...data,
structure: parsedStructure as Prisma.InputJsonValue,
typeComposant: { connect: { id: typeComposantId } },
},
COMPOSANT_MODEL_INCLUDE,
);
return this.withParsedStructure(created);
}
async findAll(typeComposantId?: string) {
const models = await this.repository.findAll(
typeComposantId,
COMPOSANT_MODEL_INCLUDE,
);
return models.map((model) => this.mapStructure(model));
}
async findOne(id: string) {
const model = await this.repository.findOne(id, COMPOSANT_MODEL_INCLUDE);
return this.withParsedStructure(model);
}
async update(id: string, dto: UpdateComposantModelDto) {
const { typeComposantId, structure, ...data } = dto;
const parsedStructure =
structure !== undefined ? this.parseStructure(structure) : undefined;
const updated = await this.repository.update(
id,
{
...data,
...(parsedStructure
? { structure: parsedStructure as Prisma.InputJsonValue }
: {}),
...(typeComposantId
? { typeComposant: { connect: { id: typeComposantId } } }
: {}),
},
COMPOSANT_MODEL_INCLUDE,
);
return this.withParsedStructure(updated);
}
async remove(id: string) {
return this.repository.delete(id);
}
private parseStructure(
structure: unknown | undefined,
): ComponentModelStructure {
return ComponentModelStructureSchema.parse(structure);
}
private mapStructure<T extends { structure?: unknown }>(
model: T,
): T & { structure: ComponentModelStructure } {
const structure = this.parseStructure((model as any).structure);
return {
...model,
structure,
};
}
private withParsedStructure<T extends { structure?: unknown }>(
model: T | null,
): (T & { structure: ComponentModelStructure }) | null {
return model ? this.mapStructure(model) : null;
}
}

View File

@@ -1,52 +0,0 @@
import { Injectable } from '@nestjs/common';
import { PieceModelsRepository } from '../../common/repositories/piece-models.repository';
import {
CreatePieceModelDto,
UpdatePieceModelDto,
} from '../../shared/dto/type.dto';
const PIECE_MODEL_INCLUDE = {
typePiece: true,
} as const;
@Injectable()
export class PieceModelService {
constructor(private readonly repository: PieceModelsRepository) {}
async create(dto: CreatePieceModelDto) {
const { typePieceId, ...data } = dto;
return this.repository.create(
{
...data,
typePiece: { connect: { id: typePieceId } },
},
PIECE_MODEL_INCLUDE,
);
}
async findAll(typePieceId?: string) {
return this.repository.findAll(typePieceId, PIECE_MODEL_INCLUDE);
}
async findOne(id: string) {
return this.repository.findOne(id, PIECE_MODEL_INCLUDE);
}
async update(id: string, dto: UpdatePieceModelDto) {
const { typePieceId, ...data } = dto;
return this.repository.update(
id,
{
...data,
...(typePieceId ? { typePiece: { connect: { id: typePieceId } } } : {}),
},
PIECE_MODEL_INCLUDE,
);
}
async remove(id: string) {
return this.repository.delete(id);
}
}

View File

@@ -8,6 +8,7 @@ import {
CreateTypeComposantDto,
UpdateTypeComposantDto,
} from '../../shared/dto/type.dto';
import { ComponentModelStructureSchema } from '../../shared/schemas/inventory';
@Injectable()
export class TypeComponentService {
@@ -15,7 +16,11 @@ export class TypeComponentService {
async create(dto: CreateTypeComposantDto) {
const code = await this.repository.generateUniqueCode(dto.name);
const data = ModelTypeMapper.toComponentCreateInput(dto, code);
const skeleton =
dto.structure !== undefined
? ComponentModelStructureSchema.parse(dto.structure)
: undefined;
const data = ModelTypeMapper.toComponentCreateInput(dto, code, skeleton);
return this.repository.createComponentType(data, COMPONENT_TYPE_INCLUDE);
}
@@ -37,7 +42,11 @@ export class TypeComponentService {
await this.repository.createComponentTypeCustomFields(id, fields);
}
const data = ModelTypeMapper.toComponentUpdateInput(dto);
const skeleton =
dto.structure !== undefined
? ComponentModelStructureSchema.parse(dto.structure)
: undefined;
const data = ModelTypeMapper.toComponentUpdateInput(dto, skeleton);
return this.repository.updateComponentType(
id,
data,

View File

@@ -8,6 +8,7 @@ import {
CreateTypePieceDto,
UpdateTypePieceDto,
} from '../../shared/dto/type.dto';
import { PieceModelStructureSchema } from '../../shared/schemas/inventory';
@Injectable()
export class TypePieceService {
@@ -15,7 +16,11 @@ export class TypePieceService {
async create(dto: CreateTypePieceDto) {
const code = await this.repository.generateUniqueCode(dto.name);
const data = ModelTypeMapper.toPieceCreateInput(dto, code);
const skeleton =
dto.structure !== undefined
? PieceModelStructureSchema.parse(dto.structure)
: undefined;
const data = ModelTypeMapper.toPieceCreateInput(dto, code, skeleton);
const created = await this.repository.createPieceType(
data,
@@ -43,7 +48,11 @@ export class TypePieceService {
await this.repository.createPieceTypeCustomFields(id, fields);
}
const data = ModelTypeMapper.toPieceUpdateInput(dto);
const skeleton =
dto.structure !== undefined
? PieceModelStructureSchema.parse(dto.structure)
: undefined;
const data = ModelTypeMapper.toPieceUpdateInput(dto, skeleton);
const updated = await this.repository.updatePieceType(
id,
data,

View File

@@ -5,12 +5,8 @@ import { PrismaService } from '../prisma/prisma.service';
import { TypeMachineService } from './services/type-machine.service';
import { TypeComponentService } from './services/type-component.service';
import { TypePieceService } from './services/type-piece.service';
import { ComposantModelService } from './services/composant-model.service';
import { PieceModelService } from './services/piece-model.service';
import { TypeMachinesRepository } from '../common/repositories/type-machines.repository';
import { ModelTypesRepository } from '../common/repositories/model-types.repository';
import { ComposantModelsRepository } from '../common/repositories/composant-models.repository';
import { PieceModelsRepository } from '../common/repositories/piece-models.repository';
describe('TypesController', () => {
let controller: TypesController;
@@ -23,12 +19,8 @@ describe('TypesController', () => {
TypeMachineService,
TypeComponentService,
TypePieceService,
ComposantModelService,
PieceModelService,
TypeMachinesRepository,
ModelTypesRepository,
ComposantModelsRepository,
PieceModelsRepository,
PrismaService,
],
}).compile();

View File

@@ -1,13 +1,4 @@
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
Query,
} from '@nestjs/common';
import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common';
import { TypesService } from './types.service';
import {
CreateTypeMachineDto,
@@ -16,10 +7,6 @@ import {
UpdateTypeComposantDto,
CreateTypePieceDto,
UpdateTypePieceDto,
CreateComposantModelDto,
UpdateComposantModelDto,
CreatePieceModelDto,
UpdatePieceModelDto,
} from '../shared/dto/type.dto';
@Controller('types')
@@ -66,37 +53,6 @@ export class TypesController {
return this.typesService.findAllTypeComposants();
}
// ComposantModel routes
@Post('composants/models')
createComposantModel(
@Body() createComposantModelDto: CreateComposantModelDto,
) {
return this.typesService.createComposantModel(createComposantModelDto);
}
@Get('composants/models')
findAllComposantModels(@Query('typeComposantId') typeComposantId?: string) {
return this.typesService.findAllComposantModels(typeComposantId);
}
@Get('composants/models/:id')
findOneComposantModel(@Param('id') id: string) {
return this.typesService.findOneComposantModel(id);
}
@Patch('composants/models/:id')
updateComposantModel(
@Param('id') id: string,
@Body() updateComposantModelDto: UpdateComposantModelDto,
) {
return this.typesService.updateComposantModel(id, updateComposantModelDto);
}
@Delete('composants/models/:id')
removeComposantModel(@Param('id') id: string) {
return this.typesService.removeComposantModel(id);
}
@Get('composants/:id')
findOneTypeComposant(@Param('id') id: string) {
return this.typesService.findOneTypeComposant(id);
@@ -126,35 +82,6 @@ export class TypesController {
return this.typesService.findAllTypePieces();
}
// PieceModel routes
@Post('pieces/models')
createPieceModel(@Body() createPieceModelDto: CreatePieceModelDto) {
return this.typesService.createPieceModel(createPieceModelDto);
}
@Get('pieces/models')
findAllPieceModels(@Query('typePieceId') typePieceId?: string) {
return this.typesService.findAllPieceModels(typePieceId);
}
@Get('pieces/models/:id')
findOnePieceModel(@Param('id') id: string) {
return this.typesService.findOnePieceModel(id);
}
@Patch('pieces/models/:id')
updatePieceModel(
@Param('id') id: string,
@Body() updatePieceModelDto: UpdatePieceModelDto,
) {
return this.typesService.updatePieceModel(id, updatePieceModelDto);
}
@Delete('pieces/models/:id')
removePieceModel(@Param('id') id: string) {
return this.typesService.removePieceModel(id);
}
@Get('pieces/:id')
findOneTypePiece(@Param('id') id: string) {
return this.typesService.findOneTypePiece(id);

View File

@@ -1,12 +1,8 @@
import { Module } from '@nestjs/common';
import { ComposantModelsRepository } from '../common/repositories/composant-models.repository';
import { ModelTypesRepository } from '../common/repositories/model-types.repository';
import { PieceModelsRepository } from '../common/repositories/piece-models.repository';
import { TypeMachinesRepository } from '../common/repositories/type-machines.repository';
import { TypesController } from './types.controller';
import { TypesService } from './types.service';
import { ComposantModelService } from './services/composant-model.service';
import { PieceModelService } from './services/piece-model.service';
import { TypeComponentService } from './services/type-component.service';
import { TypeMachineService } from './services/type-machine.service';
import { TypePieceService } from './services/type-piece.service';
@@ -18,12 +14,8 @@ import { TypePieceService } from './services/type-piece.service';
TypeMachineService,
TypeComponentService,
TypePieceService,
ComposantModelService,
PieceModelService,
TypeMachinesRepository,
ModelTypesRepository,
ComposantModelsRepository,
PieceModelsRepository,
],
})
export class TypesModule {}

View File

@@ -4,12 +4,8 @@ import { PrismaService } from '../prisma/prisma.service';
import { TypeMachineService } from './services/type-machine.service';
import { TypeComponentService } from './services/type-component.service';
import { TypePieceService } from './services/type-piece.service';
import { ComposantModelService } from './services/composant-model.service';
import { PieceModelService } from './services/piece-model.service';
import { TypeMachinesRepository } from '../common/repositories/type-machines.repository';
import { ModelTypesRepository } from '../common/repositories/model-types.repository';
import { ComposantModelsRepository } from '../common/repositories/composant-models.repository';
import { PieceModelsRepository } from '../common/repositories/piece-models.repository';
describe('TypesService', () => {
let service: TypesService;
@@ -21,12 +17,8 @@ describe('TypesService', () => {
TypeMachineService,
TypeComponentService,
TypePieceService,
ComposantModelService,
PieceModelService,
TypeMachinesRepository,
ModelTypesRepository,
ComposantModelsRepository,
PieceModelsRepository,
PrismaService,
],
}).compile();

View File

@@ -1,6 +1,4 @@
import { Injectable } from '@nestjs/common';
import { ComposantModelService } from './services/composant-model.service';
import { PieceModelService } from './services/piece-model.service';
import { TypeComponentService } from './services/type-component.service';
import { TypeMachineService } from './services/type-machine.service';
import { TypePieceService } from './services/type-piece.service';
@@ -11,10 +9,6 @@ import {
UpdateTypeComposantDto,
CreateTypePieceDto,
UpdateTypePieceDto,
CreateComposantModelDto,
UpdateComposantModelDto,
CreatePieceModelDto,
UpdatePieceModelDto,
} from '../shared/dto/type.dto';
@Injectable()
@@ -23,8 +17,6 @@ export class TypesService {
private readonly typeMachineService: TypeMachineService,
private readonly typeComponentService: TypeComponentService,
private readonly typePieceService: TypePieceService,
private readonly composantModelService: ComposantModelService,
private readonly pieceModelService: PieceModelService,
) {}
// TypeMachine
@@ -89,46 +81,4 @@ export class TypesService {
removeTypePiece(id: string) {
return this.typePieceService.remove(id);
}
// ComposantModel
createComposantModel(dto: CreateComposantModelDto) {
return this.composantModelService.create(dto);
}
findAllComposantModels(typeComposantId?: string) {
return this.composantModelService.findAll(typeComposantId);
}
findOneComposantModel(id: string) {
return this.composantModelService.findOne(id);
}
updateComposantModel(id: string, dto: UpdateComposantModelDto) {
return this.composantModelService.update(id, dto);
}
removeComposantModel(id: string) {
return this.composantModelService.remove(id);
}
// PieceModel
createPieceModel(dto: CreatePieceModelDto) {
return this.pieceModelService.create(dto);
}
findAllPieceModels(typePieceId?: string) {
return this.pieceModelService.findAll(typePieceId);
}
findOnePieceModel(id: string) {
return this.pieceModelService.findOne(id);
}
updatePieceModel(id: string, dto: UpdatePieceModelDto) {
return this.pieceModelService.update(id, dto);
}
removePieceModel(id: string) {
return this.pieceModelService.remove(id);
}
}