feat: add product domain and machine integration

- extend Prisma schema with products, product constructs and link tables\n- introduce product service, DTOs and includes with constructeur support\n- integrate product selections across model type skeletons, composants, pièces and machines\n- validate product requirements when building machine skeletons and payloads
This commit is contained in:
Matthieu
2025-11-05 15:34:42 +01:00
parent e81f71e3e7
commit 6cf2b566ce
38 changed files with 2601 additions and 120 deletions

View File

@@ -14,6 +14,7 @@ import { ConstructeursModule } from './constructeurs/constructeurs.module';
import { ProfilesModule } from './profiles/profiles.module';
import { SessionModule } from './session/session.module';
import { ModelTypeModule } from './model-type/model-type.module';
import { ProductsModule } from './products/products.module';
@Module({
imports: [
@@ -32,6 +33,7 @@ import { ModelTypeModule } from './model-type/model-type.module';
ProfilesModule,
SessionModule,
ModelTypeModule,
ProductsModule,
],
controllers: [AppController],
providers: [AppService],

View File

@@ -23,6 +23,17 @@ export const COMPONENT_WITH_RELATIONS_INCLUDE = {
customField: { select: CUSTOM_FIELD_SELECT },
},
},
product: {
include: {
constructeurs: true,
customFieldValues: {
include: {
customField: { select: CUSTOM_FIELD_SELECT },
},
},
documents: true,
},
},
machineLinks: {
include: {
machine: true,

View File

@@ -0,0 +1,32 @@
import { Prisma } from '@prisma/client';
export const PRODUCT_WITH_RELATIONS_INCLUDE = {
typeProduct: {
include: {
productCustomFields: {
orderBy: { orderIndex: 'asc' },
},
},
},
constructeurs: true,
documents: true,
customFieldValues: {
include: {
customField: true,
},
},
pieces: {
select: {
id: true,
name: true,
reference: true,
},
},
composants: {
select: {
id: true,
name: true,
reference: true,
},
},
} satisfies Prisma.ProductInclude;

View File

@@ -26,6 +26,16 @@ const baseDto = {
typePieceId: 'piece-id',
},
],
productRequirements: [
{
label: 'Product',
minCount: 1,
maxCount: 3,
required: true,
allowNewModels: true,
typeProductId: 'product-id',
},
],
};
describe('TypeMachineMapper', () => {
@@ -52,6 +62,14 @@ describe('TypeMachineMapper', () => {
allowNewModels: true,
orderIndex: 0,
});
expect(input.productRequirements?.create?.[0]).toMatchObject({
label: 'Product',
minCount: 1,
maxCount: 3,
required: true,
allowNewModels: true,
orderIndex: 0,
});
});
it('should map custom field inputs for create many', () => {
@@ -76,6 +94,9 @@ describe('TypeMachineMapper', () => {
const piece = TypeMachineMapper.mapPieceRequirementInputs(
baseDto.pieceRequirements as any,
);
const product = TypeMachineMapper.mapProductRequirementInputs(
baseDto.productRequirements as any,
);
expect(component[0]).toMatchObject({
typeComposantId: 'comp-id',
@@ -89,5 +110,11 @@ describe('TypeMachineMapper', () => {
maxCount: 2,
orderIndex: 0,
});
expect(product[0]).toMatchObject({
typeProductId: 'product-id',
minCount: 1,
maxCount: 3,
orderIndex: 0,
});
});
});

View File

@@ -13,6 +13,7 @@ type RequirementDto = {
allowNewModels?: boolean | null;
typeComposantId?: string;
typePieceId?: string;
typeProductId?: string;
orderIndex?: number | null;
};
@@ -29,6 +30,10 @@ export const TYPE_MACHINE_DEFAULT_INCLUDE: Prisma.TypeMachineInclude = {
include: { typePiece: true },
orderBy: { orderIndex: 'asc' },
},
productRequirements: {
include: { typeProduct: true },
orderBy: { orderIndex: 'asc' },
},
};
export const TYPE_MACHINE_WITH_MACHINES_INCLUDE: Prisma.TypeMachineInclude = {
@@ -40,8 +45,13 @@ export class TypeMachineMapper {
static toCreateInput(
dto: CreateTypeMachineDto,
): Prisma.TypeMachineCreateInput {
const { customFields, componentRequirements, pieceRequirements, ...data } =
dto;
const {
customFields,
componentRequirements,
pieceRequirements,
productRequirements,
...data
} = dto;
return {
...data,
@@ -50,14 +60,20 @@ export class TypeMachineMapper {
componentRequirements,
),
pieceRequirements: this.mapPieceRequirements(pieceRequirements),
productRequirements: this.mapProductRequirements(productRequirements),
};
}
static toUpdateData(
dto: UpdateTypeMachineDto,
): Prisma.TypeMachineUpdateInput {
const { customFields, componentRequirements, pieceRequirements, ...data } =
dto;
const {
customFields,
componentRequirements,
pieceRequirements,
productRequirements,
...data
} = dto;
const payload: Prisma.TypeMachineUpdateInput = { ...data };
@@ -73,6 +89,10 @@ export class TypeMachineMapper {
payload.pieceRequirements = undefined;
}
if (productRequirements !== undefined) {
payload.productRequirements = undefined;
}
return payload;
}
@@ -199,4 +219,50 @@ export class TypeMachineMapper {
typePieceId: requirement.typePieceId!,
}));
}
static mapProductRequirements(
requirements?: RequirementDto[] | null,
):
| Prisma.TypeMachineProductRequirementCreateNestedManyWithoutTypeMachineInput
| undefined {
if (!requirements || requirements.length === 0) {
return undefined;
}
return {
create: requirements.map((requirement, index) => ({
label: requirement.label ?? null,
minCount: requirement.minCount ?? 0,
maxCount: requirement.maxCount ?? null,
required: requirement.required ?? false,
allowNewModels: requirement.allowNewModels ?? true,
orderIndex: requirement.orderIndex ?? index,
typeProduct: requirement.typeProductId
? {
connect: { id: requirement.typeProductId },
}
: (() => {
throw new Error(
'typeProductId est requis pour créer une contrainte produit.',
);
})(),
})),
};
}
static mapProductRequirementInputs(requirements?: RequirementDto[] | null) {
if (!requirements || requirements.length === 0) {
return [];
}
return requirements.map((requirement, index) => ({
label: requirement.label ?? null,
minCount: requirement.minCount ?? 0,
maxCount: requirement.maxCount ?? null,
required: requirement.required ?? false,
allowNewModels: requirement.allowNewModels ?? true,
orderIndex: requirement.orderIndex ?? index,
typeProductId: requirement.typeProductId!,
}));
}
}

View File

@@ -17,6 +17,11 @@ type PieceRequirementInput = Omit<
'id' | 'typeMachineId'
>;
type ProductRequirementInput = Omit<
Prisma.TypeMachineProductRequirementCreateManyInput,
'id' | 'typeMachineId'
>;
@Injectable()
export class TypeMachinesRepository {
constructor(private readonly prisma: PrismaService) {}
@@ -132,6 +137,28 @@ export class TypeMachinesRepository {
});
}
async deleteProductRequirements(typeMachineId: string) {
await this.client.typeMachineProductRequirement.deleteMany({
where: { typeMachineId },
});
}
async createProductRequirements(
typeMachineId: string,
requirements: ProductRequirementInput[],
) {
if (!requirements.length) {
return;
}
await this.client.typeMachineProductRequirement.createMany({
data: requirements.map((requirement) => ({
...requirement,
typeMachineId,
})),
});
}
async findMachinesUsingType(typeMachineId: string) {
return this.client.machine.findMany({
where: { typeMachineId },

View File

@@ -12,6 +12,7 @@ const DEFAULT_ORIENTATIONS: Record<string, LinkOrientation> = {
_MachineConstructeurs: { parentColumn: 'A', constructeurColumn: 'B' },
_ComposantConstructeurs: { parentColumn: 'A', constructeurColumn: 'B' },
_PieceConstructeurs: { parentColumn: 'A', constructeurColumn: 'B' },
_ProductConstructeurs: { parentColumn: 'A', constructeurColumn: 'B' },
};
const sanitizeTableName = (tableName: string): string => {
@@ -22,7 +23,12 @@ const sanitizeTableName = (tableName: string): string => {
};
const ORIENTATION_CACHE = new Map<string, LinkOrientation>();
const KNOWN_PARENT_TABLES = new Set(['machines', 'composants', 'pieces']);
const KNOWN_PARENT_TABLES = new Set([
'machines',
'composants',
'pieces',
'products',
]);
const oppositeColumn = (column: 'A' | 'B'): 'A' | 'B' =>
column === 'A' ? 'B' : 'A';
@@ -39,11 +45,12 @@ async function resolveOrientation(
return cached;
}
if (typeof prisma.__getConstructeurLinkOrientation === 'function') {
const orientation = await prisma.__getConstructeurLinkOrientation(tableName);
ORIENTATION_CACHE.set(tableName, orientation);
return orientation;
}
if (typeof prisma.__getConstructeurLinkOrientation === 'function') {
const orientation =
await prisma.__getConstructeurLinkOrientation(tableName);
ORIENTATION_CACHE.set(tableName, orientation);
return orientation;
}
const rows = await prisma.$queryRaw<
Array<{ column_name: string; foreign_table_name: string }>
@@ -103,11 +110,10 @@ async function resolveOrientation(
if (!parentColumn || !constructeurColumn) {
const columns = rows
.map(
(row) =>
row.column_name?.toUpperCase?.() as 'A' | 'B' | undefined,
)
.filter((column): column is 'A' | 'B' => column === 'A' || column === 'B');
.map((row) => row.column_name?.toUpperCase?.() as 'A' | 'B' | undefined)
.filter(
(column): column is 'A' | 'B' => column === 'A' || column === 'B',
);
if (columns.length === 2) {
if (!parentColumn) {
@@ -204,8 +210,8 @@ export async function syncConstructeurLinks(
return [];
}
const valueTuples = targetConstructeurIds.map((constructeurId) =>
Prisma.sql`(${parentId}, ${constructeurId})`,
const valueTuples = targetConstructeurIds.map(
(constructeurId) => Prisma.sql`(${parentId}, ${constructeurId})`,
);
await prisma.$executeRaw(

View File

@@ -76,6 +76,59 @@ export function normalizeComponentModelStructure(
},
);
const products = toArray((structure as any)?.products).map((product) => {
const candidate = product as Record<string, unknown> | null | undefined;
if (candidate?.typeProductId) {
const normalized: ComponentModelStructure['products'][number] = {
typeProductId:
ensureString(candidate.typeProductId).trim() || 'UNKNOWN',
role: sanitizeRole(candidate.role),
};
if (candidate?.familyCode) {
const familyCode = ensureString(candidate.familyCode).trim();
if (familyCode) {
(normalized as Record<string, unknown>).familyCode = familyCode;
}
}
if (candidate?.typeProductLabel) {
const label = ensureString(candidate.typeProductLabel).trim();
if (label) {
(normalized as Record<string, unknown>).typeProductLabel = label;
}
}
if (candidate?.reference) {
const reference = ensureString(candidate.reference).trim();
if (reference) {
(normalized as Record<string, unknown>).reference = reference;
}
}
return normalized;
}
if (candidate?.familyCode) {
return {
familyCode: ensureString(candidate.familyCode).trim() || 'UNKNOWN',
role: sanitizeRole(candidate.role),
} as ComponentModelStructure['products'][number];
}
return {
familyCode:
ensureString(
candidate?.familyCode ??
candidate?.name ??
candidate?.typeProductLabel ??
'UNKNOWN',
).trim() || 'UNKNOWN',
role: sanitizeRole(candidate?.role),
} as ComponentModelStructure['products'][number];
});
const rawSubcomponents = toArray(
(structure as any)?.subcomponents ?? (structure as any)?.subComponents,
);
@@ -115,6 +168,7 @@ export function normalizeComponentModelStructure(
return {
pieces,
products,
customFields,
subcomponents,
};

View File

@@ -35,6 +35,7 @@ describe('ComposantsService', () => {
const dto: CreateComposantDto = {
name: 'Comp A',
typeComposantId: 'type-1',
productId: ' product-1 ',
};
prisma.composant.create.mockResolvedValue({ id: 'comp-1', name: dto.name });
@@ -42,11 +43,14 @@ describe('ComposantsService', () => {
const result = await service.create(dto);
expect(prisma.composant.create).toHaveBeenCalled();
expect(prisma.composant.create.mock.calls[0][0].data.product).toEqual({
connect: { id: 'product-1' },
});
expect(result).toMatchObject({ id: 'comp-1' });
});
it('updates a component', async () => {
const dto: UpdateComposantDto = { name: 'Updated' };
const dto: UpdateComposantDto = { name: 'Updated', productId: '' };
prisma.composant.update.mockResolvedValue({
id: 'comp-1',
@@ -56,5 +60,8 @@ describe('ComposantsService', () => {
await service.update('comp-1', dto);
expect(prisma.composant.update).toHaveBeenCalled();
expect(prisma.composant.update.mock.calls[0][0].data.product).toEqual({
disconnect: true,
});
});
});

View File

@@ -40,6 +40,15 @@ export class ComposantsService {
};
}
if (createComposantDto.productId) {
const normalizedProductId = createComposantDto.productId.trim();
if (normalizedProductId) {
data.product = {
connect: { id: normalizedProductId },
};
}
}
if (createComposantDto.structure !== undefined) {
data.structure = createComposantDto.structure as Prisma.InputJsonValue;
}
@@ -49,9 +58,8 @@ export class ComposantsService {
async create(createComposantDto: CreateComposantDto) {
try {
const { data, constructeurIds } = await this.buildCreateInput(
createComposantDto,
);
const { data, constructeurIds } =
await this.buildCreateInput(createComposantDto);
const created = await this.prisma.composant.create({
data,
include: COMPONENT_WITH_RELATIONS_INCLUDE,
@@ -73,9 +81,11 @@ export class ComposantsService {
})) as ComposantWithRelations | null;
if (refreshed && syncedConstructeurIds.length > 0) {
(refreshed as ComposantWithRelations & {
constructeurIds?: string[];
}).constructeurIds = [...syncedConstructeurIds];
(
refreshed as ComposantWithRelations & {
constructeurIds?: string[];
}
).constructeurIds = [...syncedConstructeurIds];
}
return refreshed;
@@ -118,9 +128,8 @@ export class ComposantsService {
const constructeurIds = this.normalizeConstructeurIds(
updateComposantDto.constructeurIds,
);
resolvedConstructeurIds = await this.resolveExistingConstructeurIds(
constructeurIds,
);
resolvedConstructeurIds =
await this.resolveExistingConstructeurIds(constructeurIds);
}
if (updateComposantDto.typeComposantId !== undefined) {
@@ -129,6 +138,16 @@ export class ComposantsService {
: { disconnect: true };
}
if (updateComposantDto.productId !== undefined) {
const normalizedProductId =
typeof updateComposantDto.productId === 'string'
? updateComposantDto.productId.trim()
: null;
data.product = normalizedProductId
? { connect: { id: normalizedProductId } }
: { disconnect: true };
}
if (updateComposantDto.structure !== undefined) {
data.structure = updateComposantDto.structure as Prisma.InputJsonValue;
}
@@ -157,9 +176,11 @@ export class ComposantsService {
})) as ComposantWithRelations | null;
if (refreshed && syncedConstructeurIds) {
(refreshed as ComposantWithRelations & {
constructeurIds?: string[];
}).constructeurIds = [...syncedConstructeurIds];
(
refreshed as ComposantWithRelations & {
constructeurIds?: string[];
}
).constructeurIds = [...syncedConstructeurIds];
}
return refreshed;

View File

@@ -36,6 +36,8 @@ export class CustomFieldsService {
return 'composantId' as const;
case CustomFieldEntityType.PIECE:
return 'pieceId' as const;
case CustomFieldEntityType.PRODUCT:
return 'productId' as const;
default:
throw new BadRequestException(
"Type d'entité de champ personnalisé invalide.",
@@ -114,6 +116,28 @@ export class CustomFieldsService {
valueKey: 'pieceId' as const,
};
}
case CustomFieldEntityType.PRODUCT: {
const product = await this.prisma.product.findUnique({
where: { id: entityId },
select: { typeProductId: true },
});
if (!product) {
throw new NotFoundException('Produit introuvable.');
}
if (!product.typeProductId) {
throw new BadRequestException(
'Le produit ne possède pas de type associé pour les champs personnalisés.',
);
}
return {
typeId: product.typeProductId,
customFieldTypeField: 'typeProductId' as const,
valueKey: 'productId' as const,
};
}
default:
throw new BadRequestException(
"Type d'entité de champ personnalisé invalide.",

View File

@@ -42,6 +42,11 @@ export class DocumentsController {
return this.documentsService.findByPiece(pieceId);
}
@Get('product/:productId')
findByProduct(@Param('productId') productId: string) {
return this.documentsService.findByProduct(productId);
}
@Get('site/:siteId')
findBySite(@Param('siteId') siteId: string) {
return this.documentsService.findBySite(siteId);

View File

@@ -16,6 +16,7 @@ export class DocumentsService {
machine: true,
composant: true,
piece: true,
product: true,
site: true,
},
});
@@ -27,6 +28,7 @@ export class DocumentsService {
machine: true,
composant: true,
piece: true,
product: true,
site: true,
},
});
@@ -39,6 +41,7 @@ export class DocumentsService {
machine: true,
composant: true,
piece: true,
product: true,
site: true,
},
});
@@ -51,6 +54,7 @@ export class DocumentsService {
machine: true,
composant: true,
piece: true,
product: true,
site: true,
},
});
@@ -63,6 +67,20 @@ export class DocumentsService {
machine: true,
composant: true,
piece: true,
product: true,
site: true,
},
});
}
async findByProduct(productId: string) {
return this.prisma.document.findMany({
where: { productId },
include: {
machine: true,
composant: true,
piece: true,
product: true,
site: true,
},
});
@@ -75,6 +93,7 @@ export class DocumentsService {
machine: true,
composant: true,
piece: true,
product: true,
site: true,
},
});

View File

@@ -202,4 +202,52 @@ describe('MachinesService', () => {
expect(result?.pieceLinks[0].piece.name).toBe('Root piece name');
expect(result?.pieceLinks[0].overrides.reference).toBe('RP-001');
});
describe('validateProductRequirements', () => {
const buildRequirement = (overrides: Partial<any> = {}) =>
({
id: 'req-1',
label: 'Hydraulic kits',
minCount: 1,
maxCount: 2,
required: true,
allowNewModels: true,
typeProductId: 'product-type-1',
typeProduct: { name: 'Hydraulic kit' },
...overrides,
}) as any;
const callValidate = (
requirement: any,
componentUsage: Record<string, number>,
pieceUsage: Record<string, number>,
) => {
const map = new Map([[requirement.id, requirement]]);
const componentMap = new Map(Object.entries(componentUsage));
const pieceMap = new Map(Object.entries(pieceUsage));
(service as any).validateProductRequirements(map, componentMap, pieceMap);
};
it('does nothing when usage satisfies min and max constraints', () => {
expect(() =>
callValidate(buildRequirement(), { 'product-type-1': 1 }, {}),
).not.toThrow();
});
it('throws when minimum requirement is not met', () => {
expect(() => callValidate(buildRequirement(), {}, {})).toThrow(
/requiert au moins 1 sélection/i,
);
});
it('throws when usage exceeds maximum', () => {
expect(() =>
callValidate(
buildRequirement({ maxCount: 2 }),
{ 'product-type-1': 2 },
{ 'product-type-1': 1 },
),
).toThrow(/ne peut pas dépasser 2 sélection/i);
});
});
});

View File

@@ -8,6 +8,7 @@ import {
ReconfigureMachineDto,
MachineComponentLinkInput,
MachinePieceLinkInput,
MachineProductLinkInput,
} from '../shared/dto/machine.dto';
import { buildComponentHierarchy } from '../common/utils/component-tree.util';
import {
@@ -51,6 +52,18 @@ const TYPE_MACHINE_CONFIGURATION_INCLUDE: Prisma.TypeMachineInclude = {
},
},
},
productRequirements: {
include: {
typeProduct: {
include: {
productCustomFields: {
orderBy: { orderIndex: 'asc' },
},
},
},
},
orderBy: { orderIndex: 'asc' },
},
};
const MACHINE_PIECE_LINK_INCLUDE: Prisma.MachinePieceLinkInclude = {
@@ -69,6 +82,17 @@ const MACHINE_PIECE_LINK_INCLUDE: Prisma.MachinePieceLinkInclude = {
},
},
},
product: {
include: {
constructeurs: true,
customFieldValues: {
include: {
customField: { select: CUSTOM_FIELD_SELECT },
},
},
documents: true,
},
},
documents: true,
},
},
@@ -104,6 +128,17 @@ const buildComponentLinkInclude = (
customField: { select: CUSTOM_FIELD_SELECT },
},
},
product: {
include: {
constructeurs: true,
customFieldValues: {
include: {
customField: { select: CUSTOM_FIELD_SELECT },
},
},
documents: true,
},
},
documents: true,
},
},
@@ -134,6 +169,20 @@ const buildComponentLinkInclude = (
const MACHINE_COMPONENT_LINK_INCLUDE = buildComponentLinkInclude();
const MACHINE_PRODUCT_LINK_INCLUDE = {
product: {
include: {
constructeurs: true,
typeProduct: true,
},
},
typeMachineProductRequirement: {
include: {
typeProduct: true,
},
},
} satisfies Prisma.MachineProductLinkInclude;
const MACHINE_DEFAULT_INCLUDE = {
site: true,
typeMachine: {
@@ -146,6 +195,9 @@ const MACHINE_DEFAULT_INCLUDE = {
pieceLinks: {
include: MACHINE_PIECE_LINK_INCLUDE,
},
productLinks: {
include: MACHINE_PRODUCT_LINK_INCLUDE,
},
customFieldValues: {
include: {
customField: { select: CUSTOM_FIELD_SELECT },
@@ -166,6 +218,10 @@ type MachinePieceLinkWithRelations = Prisma.MachinePieceLinkGetPayload<{
include: typeof MACHINE_PIECE_LINK_INCLUDE;
}>;
type MachineProductLinkWithRelations = Prisma.MachineProductLinkGetPayload<{
include: typeof MACHINE_PRODUCT_LINK_INCLUDE;
}>;
type LinkOverride = {
name: string | null;
reference: string | null;
@@ -215,18 +271,49 @@ type PieceRequirementWithType = Prisma.TypeMachinePieceRequirementGetPayload<{
include: { typePiece: true };
}>;
type ProductRequirementWithType =
Prisma.TypeMachineProductRequirementGetPayload<{
include: { typeProduct: true };
}>;
type ComponentWithType = Prisma.ComposantGetPayload<{
include: { typeComposant: true };
include: {
typeComposant: true;
product: {
select: {
id: true;
typeProductId: true;
};
};
};
}>;
type PieceWithType = Prisma.PieceGetPayload<{
include: { typePiece: true; constructeurs: true };
include: {
typePiece: true;
constructeurs: true;
product: {
select: {
id: true;
typeProductId: true;
};
};
};
}>;
type CreatedComponentLinkInfo = {
id: string;
composantId: string;
requirementId: string | null;
productTypeId: string | null;
};
type ComponentLinkIndex = {
createdLinks: Map<string, CreatedComponentLinkInfo>;
byComponentId: Map<string, CreatedComponentLinkInfo[]>;
byRequirementId: Map<string, CreatedComponentLinkInfo[]>;
productUsage: Map<string, number>;
autoPieceProductUsage: Map<string, number>;
};
type PendingComponentLink = {
@@ -248,6 +335,22 @@ type CreatedPieceLinkInfo = {
pieceId: string;
requirementId: string;
parentLinkId: string | null;
productTypeId: string | null;
};
type CreatedProductLinkInfo = {
id: string;
productId: string;
requirementId: string;
productTypeId: string | null;
};
type PendingProductLink = {
raw: MachineProductLinkInput;
assignedId: string;
requirement: ProductRequirementWithType;
productId: string;
position: number;
};
type PendingPieceLink = {
@@ -408,6 +511,7 @@ export class MachinesService {
pieceLinks: HydratedPieceLink[];
constructeurIds: string[];
constructeurs: MachineWithRelations['constructeurs'];
productLinks: MachineProductLinkWithRelations[];
})
| null {
if (!machine) {
@@ -431,6 +535,7 @@ export class MachinesService {
pieceLinks: HydratedPieceLink[];
constructeurIds: string[];
constructeurs: MachineWithRelations['constructeurs'];
productLinks: MachineProductLinkWithRelations[];
};
hydratedMachine.componentLinks = componentLinks;
@@ -441,6 +546,7 @@ export class MachinesService {
)
.filter((id): id is string => Boolean(id));
hydratedMachine.constructeurs = resolvedConstructeurs;
hydratedMachine.productLinks = machine.productLinks ?? [];
return hydratedMachine;
}
@@ -452,6 +558,7 @@ export class MachinesService {
pieceLinks: HydratedPieceLink[];
constructeurIds: string[];
constructeurs: MachineWithRelations['constructeurs'];
productLinks: MachineProductLinkWithRelations[];
})[] {
return machines.map((machine) => this.hydrateMachine(machine)!);
}
@@ -481,7 +588,8 @@ export class MachinesService {
.filter((id): id is string => Boolean(id));
const initialIds =
Array.isArray(machine.constructeurIds) && machine.constructeurIds.length > 0
Array.isArray(machine.constructeurIds) &&
machine.constructeurIds.length > 0
? machine.constructeurIds
: idsFromConstructeurs;
@@ -515,20 +623,15 @@ export class MachinesService {
const orderedConstructeurs = resolvedIds
.map((id) => byId.get(id))
.filter(
(
record,
): record is (typeof constructeurs)[number] =>
Boolean(record),
.filter((record): record is (typeof constructeurs)[number] =>
Boolean(record),
);
machine.constructeurs =
orderedConstructeurs as MachineWithRelations['constructeurs'];
machine.constructeurs = orderedConstructeurs;
return machine;
}
private slugifyName(name: string): string {
return name
.normalize('NFD')
@@ -576,6 +679,7 @@ export class MachinesService {
typeMachine: TypeMachineConfiguration,
componentLinks: MachineComponentLinkInput[],
pieceLinks: MachinePieceLinkInput[],
productLinks: MachineProductLinkInput[],
) {
const componentRequirements = (
Array.isArray(typeMachine.componentRequirements)
@@ -587,6 +691,11 @@ export class MachinesService {
? typeMachine.pieceRequirements
: []
) as PieceRequirementWithType[];
const productRequirements = (
Array.isArray(typeMachine.productRequirements)
? typeMachine.productRequirements
: []
) as ProductRequirementWithType[];
const componentRequirementMap = new Map(
componentRequirements.map((requirement) => [requirement.id, requirement]),
@@ -594,6 +703,9 @@ export class MachinesService {
const pieceRequirementMap = new Map(
pieceRequirements.map((requirement) => [requirement.id, requirement]),
);
const productRequirementMap = new Map(
productRequirements.map((requirement) => [requirement.id, requirement]),
);
const componentLinksByRequirement = new Map<
string,
@@ -623,6 +735,10 @@ export class MachinesService {
}
const pieceLinksByRequirement = new Map<string, MachinePieceLinkInput[]>();
const productLinksByRequirement = new Map<
string,
MachineProductLinkInput[]
>();
for (const link of pieceLinks) {
const requirement = pieceRequirementMap.get(link.requirementId);
if (!requirement) {
@@ -693,11 +809,27 @@ export class MachinesService {
}
}
for (const link of productLinks) {
const requirement = productRequirementMap.get(link.requirementId);
if (!requirement) {
throw new Error(
`Lien de produit invalide: requirementId=${link.requirementId}`,
);
}
if (!productLinksByRequirement.has(requirement.id)) {
productLinksByRequirement.set(requirement.id, []);
}
productLinksByRequirement.get(requirement.id)!.push(link);
}
return {
componentRequirementMap,
pieceRequirementMap,
productRequirementMap,
componentLinksByRequirement,
pieceLinksByRequirement,
productLinksByRequirement,
};
}
@@ -709,6 +841,69 @@ export class MachinesService {
return value as Record<string, unknown>;
}
private validateProductRequirements(
productRequirementMap: Map<string, ProductRequirementWithType>,
componentUsage: Map<string, number>,
pieceUsage: Map<string, number>,
directUsage: Map<string, number>,
productLinksByRequirement: Map<string, MachineProductLinkInput[]>,
) {
if (productRequirementMap.size === 0) {
return;
}
const totalUsage = new Map<string, number>();
const accumulate = (source: Map<string, number>) => {
for (const [typeProductId, count] of source.entries()) {
totalUsage.set(
typeProductId,
(totalUsage.get(typeProductId) ?? 0) + count,
);
}
};
accumulate(componentUsage);
accumulate(pieceUsage);
accumulate(directUsage);
for (const requirement of productRequirementMap.values()) {
const typeProductId = requirement.typeProductId;
if (!typeProductId) {
continue;
}
const directSelections =
productLinksByRequirement.get(requirement.id)?.length ?? 0;
const count = totalUsage.get(typeProductId) ?? 0;
const min = requirement.minCount ?? (requirement.required ? 1 : 0);
const max = requirement.maxCount ?? undefined;
const label =
requirement.label?.trim() ||
requirement.typeProduct?.name ||
requirement.typeProduct?.code ||
requirement.id;
if (count < min) {
throw new Error(
`Le groupe de produits "${label}" requiert au moins ${min} sélection(s) mais seulement ${count} ont été fournis.`,
);
}
if (max !== undefined && count > max) {
throw new Error(
`Le groupe de produits "${label}" ne peut pas dépasser ${max} sélection(s).`,
);
}
if (max !== undefined && directSelections > max) {
throw new Error(
`Le groupe de produits "${label}" ne peut pas dépasser ${max} sélection(s) directes.`,
);
}
}
}
private extractString(value: unknown): string | undefined {
if (typeof value !== 'string') {
return undefined;
@@ -903,12 +1098,16 @@ export class MachinesService {
}
private describeRequirement(
requirement: ComponentRequirementWithType | PieceRequirementWithType,
requirement:
| ComponentRequirementWithType
| PieceRequirementWithType
| ProductRequirementWithType,
): string {
return (
requirement.label ||
(requirement as ComponentRequirementWithType).typeComposant?.name ||
(requirement as PieceRequirementWithType).typePiece?.name ||
(requirement as ProductRequirementWithType).typeProduct?.name ||
requirement.id
);
}
@@ -1066,6 +1265,8 @@ export class MachinesService {
createdLinks: Map<string, CreatedComponentLinkInfo>,
byComponentId: Map<string, CreatedComponentLinkInfo[]>,
componentMap: Map<string, ComponentWithType>,
productUsage: Map<string, number>,
autoPieceProductUsage: Map<string, number>,
) {
if (createdLinks.size === 0) {
return;
@@ -1083,6 +1284,12 @@ export class MachinesService {
where: { id: componentId },
include: {
typeComposant: true,
product: {
select: {
id: true,
typeProductId: true,
},
},
},
});
@@ -1104,6 +1311,12 @@ export class MachinesService {
include: {
typePiece: true,
constructeurs: true,
product: {
select: {
id: true,
typeProductId: true,
},
},
},
});
@@ -1249,6 +1462,14 @@ export class MachinesService {
},
});
const pieceProductTypeId = piece.product?.typeProductId ?? null;
if (pieceProductTypeId) {
autoPieceProductUsage.set(
pieceProductTypeId,
(autoPieceProductUsage.get(pieceProductTypeId) ?? 0) + 1,
);
}
createdPieceKeys.add(pieceKey);
}
}
@@ -1307,10 +1528,20 @@ export class MachinesService {
},
});
const childProductTypeId =
childComponent.product?.typeProductId ?? null;
if (childProductTypeId) {
productUsage.set(
childProductTypeId,
(productUsage.get(childProductTypeId) ?? 0) + 1,
);
}
const created: CreatedComponentLinkInfo = {
id: assignedId,
composantId: selectedComponentId,
requirementId: null,
productTypeId: childProductTypeId,
};
createdLinks.set(assignedId, created);
@@ -1331,13 +1562,15 @@ export class MachinesService {
machineId: string,
componentRequirementMap: Map<string, ComponentRequirementWithType>,
componentLinks: MachineComponentLinkInput[],
) {
): Promise<ComponentLinkIndex> {
const links = Array.isArray(componentLinks) ? componentLinks : [];
if (links.length === 0) {
return {
createdLinks: new Map<string, CreatedComponentLinkInfo>(),
byComponentId: new Map<string, CreatedComponentLinkInfo[]>(),
byRequirementId: new Map<string, CreatedComponentLinkInfo[]>(),
productUsage: new Map<string, number>(),
autoPieceProductUsage: new Map<string, number>(),
};
}
@@ -1375,7 +1608,15 @@ export class MachinesService {
const components = await prisma.composant.findMany({
where: { id: { in: Array.from(componentIds) } },
include: { typeComposant: true },
include: {
typeComposant: true,
product: {
select: {
id: true,
typeProductId: true,
},
},
},
});
const componentMap = new Map<string, ComponentWithType>(
components.map((component) => [component.id, component]),
@@ -1412,6 +1653,8 @@ export class MachinesService {
const createdLinks = new Map<string, CreatedComponentLinkInfo>();
const byComponentId = new Map<string, CreatedComponentLinkInfo[]>();
const byRequirementId = new Map<string, CreatedComponentLinkInfo[]>();
const productUsage = new Map<string, number>();
const autoPieceProductUsage = new Map<string, number>();
while (pending.size > 0) {
let progress = false;
@@ -1454,6 +1697,7 @@ export class MachinesService {
id: entry.assignedId,
composantId: entry.componentId,
requirementId: entry.requirement.id,
productTypeId: entry.component?.product?.typeProductId ?? null,
};
createdLinks.set(entry.assignedId, created);
@@ -1468,6 +1712,14 @@ export class MachinesService {
}
byRequirementId.get(entry.requirement.id)!.push(created);
const productTypeId = entry.component?.product?.typeProductId ?? null;
if (productTypeId) {
productUsage.set(
productTypeId,
(productUsage.get(productTypeId) ?? 0) + 1,
);
}
pending.delete(id);
progress = true;
}
@@ -1485,9 +1737,17 @@ export class MachinesService {
createdLinks,
byComponentId,
componentMap,
productUsage,
autoPieceProductUsage,
);
return { createdLinks, byComponentId, byRequirementId };
return {
createdLinks,
byComponentId,
byRequirementId,
productUsage,
autoPieceProductUsage,
};
}
private resolveComponentParentReference(
@@ -1570,15 +1830,17 @@ export class MachinesService {
machineId: string,
pieceRequirementMap: Map<string, PieceRequirementWithType>,
pieceLinks: MachinePieceLinkInput[],
componentLinkIndex: {
createdLinks: Map<string, CreatedComponentLinkInfo>;
byComponentId: Map<string, CreatedComponentLinkInfo[]>;
byRequirementId: Map<string, CreatedComponentLinkInfo[]>;
},
) {
componentLinkIndex: ComponentLinkIndex,
): Promise<{
createdLinks: Map<string, CreatedPieceLinkInfo>;
productUsage: Map<string, number>;
}> {
const links = Array.isArray(pieceLinks) ? pieceLinks : [];
if (links.length === 0) {
return new Map<string, CreatedPieceLinkInfo>();
return {
createdLinks: new Map<string, CreatedPieceLinkInfo>(),
productUsage: new Map<string, number>(),
};
}
const pieceIds = new Set<string>();
@@ -1612,7 +1874,16 @@ export class MachinesService {
const pieces = await prisma.piece.findMany({
where: { id: { in: Array.from(pieceIds) } },
include: { typePiece: true, constructeurs: true },
include: {
typePiece: true,
constructeurs: true,
product: {
select: {
id: true,
typeProductId: true,
},
},
},
});
const pieceMap = new Map<string, PieceWithType>(
pieces.map((piece) => [piece.id, piece]),
@@ -1643,6 +1914,7 @@ export class MachinesService {
}
const createdLinks = new Map<string, CreatedPieceLinkInfo>();
const productUsage = new Map<string, number>();
for (const entry of pendingEntries) {
const parentId = this.resolvePieceParentReference(
@@ -1675,19 +1947,145 @@ export class MachinesService {
pieceId: entry.pieceId,
requirementId: entry.requirement.id,
parentLinkId: parentId ?? null,
productTypeId: entry.piece?.product?.typeProductId ?? null,
});
const productTypeId = entry.piece?.product?.typeProductId ?? null;
if (productTypeId) {
productUsage.set(
productTypeId,
(productUsage.get(productTypeId) ?? 0) + 1,
);
}
}
return createdLinks;
return { createdLinks, productUsage };
}
private async createProductLinksForMachine(
prisma: Prisma.TransactionClient | PrismaService,
machineId: string,
productRequirementMap: Map<string, ProductRequirementWithType>,
productLinks: MachineProductLinkInput[],
): Promise<{
createdLinks: Map<string, CreatedProductLinkInfo>;
productUsage: Map<string, number>;
}> {
const links = Array.isArray(productLinks) ? productLinks : [];
if (links.length === 0) {
return {
createdLinks: new Map<string, CreatedProductLinkInfo>(),
productUsage: new Map<string, number>(),
};
}
const productIds = new Set<string>();
const pendingEntries: PendingProductLink[] = [];
links.forEach((link, index) => {
const requirement = productRequirementMap.get(link.requirementId);
if (!requirement) {
throw new Error(
`Requirement de produit introuvable (${link.requirementId}).`,
);
}
const productId = this.extractString(link.productId);
if (!productId) {
throw new Error(
`productId manquant pour le lien de produit #${index + 1} (${this.describeRequirement(requirement)}).`,
);
}
productIds.add(productId);
pendingEntries.push({
raw: link,
assignedId: this.resolveLinkIdentifier(link) ?? randomUUID(),
requirement,
productId,
position: index,
});
});
const products = await prisma.product.findMany({
where: { id: { in: Array.from(productIds) } },
select: {
id: true,
typeProductId: true,
},
});
const productMap = new Map(
products.map((product) => [product.id, product]),
);
for (const entry of pendingEntries) {
const product = productMap.get(entry.productId);
if (!product) {
throw new Error(
`Produit introuvable (${entry.productId}) pour le lien de produit #${entry.position + 1}.`,
);
}
if (
entry.requirement.typeProductId &&
product.typeProductId &&
product.typeProductId !== entry.requirement.typeProductId
) {
throw new Error(
`Le produit sélectionné n'appartient pas à la catégorie attendue pour "${this.describeRequirement(entry.requirement)}".`,
);
}
}
const createdLinks = new Map<string, CreatedProductLinkInfo>();
const productUsage = new Map<string, number>();
for (const entry of pendingEntries) {
const product = productMap.get(entry.productId);
if (!product) {
continue;
}
const createData: Prisma.MachineProductLinkUncheckedCreateInput = {
id: entry.assignedId,
machineId,
productId: entry.productId,
typeMachineProductRequirementId: entry.requirement.id,
parentLinkId: this.extractString(entry.raw.parentLinkId),
parentComponentLinkId: this.extractString(
entry.raw.parentComponentLinkId,
),
parentPieceLinkId: this.extractString(entry.raw.parentPieceLinkId),
};
await prisma.machineProductLink.create({ data: createData });
const created: CreatedProductLinkInfo = {
id: entry.assignedId,
productId: entry.productId,
requirementId: entry.requirement.id,
productTypeId: product.typeProductId ?? null,
};
createdLinks.set(entry.assignedId, created);
const typeProductId = product.typeProductId ?? null;
if (typeProductId) {
productUsage.set(
typeProductId,
(productUsage.get(typeProductId) ?? 0) + 1,
);
}
}
return { createdLinks, productUsage };
}
private resolvePieceParentReference(
link: MachinePieceLinkInput,
componentLinkIndex: {
createdLinks: Map<string, CreatedComponentLinkInfo>;
byComponentId: Map<string, CreatedComponentLinkInfo[]>;
byRequirementId: Map<string, CreatedComponentLinkInfo[]>;
},
componentLinkIndex: ComponentLinkIndex,
): string | null {
const explicitParentId = this.extractString(
link.parentComponentLinkId ?? link.parentLinkId,
@@ -1813,6 +2211,7 @@ export class MachinesService {
const {
componentLinks = [],
pieceLinks = [],
productLinks = [],
constructeurIds,
...machineData
} = createMachineDto;
@@ -1834,8 +2233,17 @@ export class MachinesService {
machineData.typeMachineId,
);
const { componentRequirementMap, pieceRequirementMap } =
this.buildConfigurationContext(typeMachine, componentLinks, pieceLinks);
const {
componentRequirementMap,
pieceRequirementMap,
productRequirementMap,
productLinksByRequirement,
} = this.buildConfigurationContext(
typeMachine,
componentLinks,
pieceLinks,
productLinks,
);
const baseMachine = await this.prisma.machine.create({
data: machineData,
@@ -1870,13 +2278,41 @@ export class MachinesService {
componentLinks,
);
await this.createPieceLinksForMachine(
const pieceLinkResult = await this.createPieceLinksForMachine(
this.prisma,
baseMachine.id,
pieceRequirementMap,
pieceLinks,
componentIndex,
);
const productLinkResult = await this.createProductLinksForMachine(
this.prisma,
baseMachine.id,
productRequirementMap,
productLinks,
);
const combinedPieceUsage = new Map(pieceLinkResult.productUsage);
for (const [
typeProductId,
count,
] of componentIndex.autoPieceProductUsage) {
combinedPieceUsage.set(
typeProductId,
(combinedPieceUsage.get(typeProductId) ?? 0) + count,
);
}
const combinedProductUsage = new Map(productLinkResult.productUsage);
this.validateProductRequirements(
productRequirementMap,
componentIndex.productUsage,
combinedPieceUsage,
combinedProductUsage,
productLinksByRequirement,
);
} catch (error) {
await this.prisma.machine
.delete({ where: { id: baseMachine.id } })
@@ -1904,8 +2340,8 @@ export class MachinesService {
const enriched = await Promise.all(
hydrated.map((machine) => this.ensureMachineConstructeurs(machine)),
);
return enriched.filter(
(machine): machine is NonNullable<typeof machine> => Boolean(machine),
return enriched.filter((machine): machine is NonNullable<typeof machine> =>
Boolean(machine),
);
}
@@ -1919,7 +2355,11 @@ export class MachinesService {
}
async reconfigure(id: string, reconfigureMachineDto: ReconfigureMachineDto) {
const { componentLinks = [], pieceLinks = [] } = reconfigureMachineDto;
const {
componentLinks = [],
pieceLinks = [],
productLinks = [],
} = reconfigureMachineDto;
const machine = await this.prisma.machine.findUnique({
where: { id },
@@ -1942,12 +2382,22 @@ export class MachinesService {
const typeMachine = machine.typeMachine as TypeMachineConfiguration;
const { componentRequirementMap, pieceRequirementMap } =
this.buildConfigurationContext(typeMachine, componentLinks, pieceLinks);
const {
componentRequirementMap,
pieceRequirementMap,
productRequirementMap,
productLinksByRequirement,
} = this.buildConfigurationContext(
typeMachine,
componentLinks,
pieceLinks,
productLinks,
);
await this.prisma.$transaction(async (tx) => {
await tx.machinePieceLink.deleteMany({ where: { machineId: id } });
await tx.machineComponentLink.deleteMany({ where: { machineId: id } });
await tx.machineProductLink.deleteMany({ where: { machineId: id } });
const componentIndex = await this.createComponentLinksForMachine(
tx,
@@ -1956,13 +2406,41 @@ export class MachinesService {
componentLinks,
);
await this.createPieceLinksForMachine(
const pieceLinkResult = await this.createPieceLinksForMachine(
tx,
id,
pieceRequirementMap,
pieceLinks,
componentIndex,
);
const productLinkResult = await this.createProductLinksForMachine(
tx,
id,
productRequirementMap,
productLinks,
);
const combinedPieceUsage = new Map(pieceLinkResult.productUsage);
for (const [
typeProductId,
count,
] of componentIndex.autoPieceProductUsage) {
combinedPieceUsage.set(
typeProductId,
(combinedPieceUsage.get(typeProductId) ?? 0) + count,
);
}
const combinedProductUsage = new Map(productLinkResult.productUsage);
this.validateProductRequirements(
productRequirementMap,
componentIndex.productUsage,
combinedPieceUsage,
combinedProductUsage,
productLinksByRequirement,
);
});
const updatedMachine = await this.prisma.machine.findUnique({

View File

@@ -3,6 +3,7 @@ import { IsEnum, IsOptional, IsString, Length, Matches } from 'class-validator';
export enum ModelCategory {
COMPONENT = 'COMPONENT',
PIECE = 'PIECE',
PRODUCT = 'PRODUCT',
}
export class CreateModelTypeDto {

View File

@@ -11,6 +11,7 @@ import { UpdateModelTypeDto } from './dto/update-model-type.dto';
import {
ComponentModelStructureSchema,
PieceModelStructureSchema,
ProductModelStructureSchema,
} from '../shared/schemas/inventory';
type SortField = 'name' | 'code' | 'createdAt';
@@ -112,12 +113,22 @@ export class ModelTypeService {
if (normalizedStructure !== undefined) {
const skeletonValue =
normalizedStructure === null ? Prisma.JsonNull : normalizedStructure;
if (rest.category === ModelCategory.COMPONENT) {
data.componentSkeleton = skeletonValue;
data.pieceSkeleton = Prisma.JsonNull;
} else {
data.pieceSkeleton = skeletonValue;
data.componentSkeleton = Prisma.JsonNull;
switch (rest.category) {
case ModelCategory.COMPONENT:
data.componentSkeleton = skeletonValue;
data.pieceSkeleton = Prisma.JsonNull;
data.productSkeleton = Prisma.JsonNull;
break;
case ModelCategory.PIECE:
data.pieceSkeleton = skeletonValue;
data.componentSkeleton = Prisma.JsonNull;
data.productSkeleton = Prisma.JsonNull;
break;
case ModelCategory.PRODUCT:
data.productSkeleton = skeletonValue;
data.componentSkeleton = Prisma.JsonNull;
data.pieceSkeleton = Prisma.JsonNull;
break;
}
}
@@ -172,12 +183,22 @@ export class ModelTypeService {
if (normalizedStructure !== undefined) {
const skeletonValue =
normalizedStructure === null ? Prisma.JsonNull : normalizedStructure;
if (targetCategory === ModelCategory.COMPONENT) {
data.componentSkeleton = skeletonValue;
data.pieceSkeleton = Prisma.JsonNull;
} else {
data.pieceSkeleton = skeletonValue;
data.componentSkeleton = Prisma.JsonNull;
switch (targetCategory) {
case ModelCategory.COMPONENT:
data.componentSkeleton = skeletonValue;
data.pieceSkeleton = Prisma.JsonNull;
data.productSkeleton = Prisma.JsonNull;
break;
case ModelCategory.PIECE:
data.pieceSkeleton = skeletonValue;
data.componentSkeleton = Prisma.JsonNull;
data.productSkeleton = Prisma.JsonNull;
break;
case ModelCategory.PRODUCT:
data.productSkeleton = skeletonValue;
data.componentSkeleton = Prisma.JsonNull;
data.pieceSkeleton = Prisma.JsonNull;
break;
}
}
@@ -270,7 +291,12 @@ export class ModelTypeService {
structure,
) as Prisma.InputJsonValue;
}
return PieceModelStructureSchema.parse(
if (category === ModelCategory.PIECE) {
return PieceModelStructureSchema.parse(
structure,
) as Prisma.InputJsonValue;
}
return ProductModelStructureSchema.parse(
structure,
) as Prisma.InputJsonValue;
} catch (error) {
@@ -281,10 +307,24 @@ export class ModelTypeService {
}
private mapModelType(modelType: PrismaModelType) {
const structure =
modelType.category === ModelCategory.COMPONENT
? (modelType.componentSkeleton ?? null)
: (modelType.pieceSkeleton ?? null);
let structure: Prisma.InputJsonValue | null;
switch (modelType.category as ModelCategory) {
case ModelCategory.COMPONENT:
structure = (modelType.componentSkeleton ??
null) as Prisma.InputJsonValue | null;
break;
case ModelCategory.PIECE:
structure = (modelType.pieceSkeleton ??
null) as Prisma.InputJsonValue | null;
break;
case ModelCategory.PRODUCT:
structure = (modelType.productSkeleton ??
null) as Prisma.InputJsonValue | null;
break;
default:
structure = null;
break;
}
return {
...modelType,

View File

@@ -38,6 +38,7 @@ describe('PiecesService', () => {
const dto: CreatePieceDto = {
name: 'Piece A',
typePieceId: 'type-piece-1',
productId: ' product-1 ',
};
prisma.piece.create.mockResolvedValue({ id: 'piece-1', name: dto.name });
@@ -51,11 +52,14 @@ describe('PiecesService', () => {
const result = await service.create(dto);
expect(prisma.piece.create).toHaveBeenCalled();
expect(prisma.piece.create.mock.calls[0][0].data.product).toEqual({
connect: { id: 'product-1' },
});
expect(result).toMatchObject({ id: 'piece-1' });
});
it('updates a piece', async () => {
const dto: UpdatePieceDto = { name: 'Updated piece' };
const dto: UpdatePieceDto = { name: 'Updated piece', productId: '' };
prisma.piece.update.mockResolvedValue({
id: 'piece-1',
@@ -71,5 +75,8 @@ describe('PiecesService', () => {
await service.update('piece-1', dto);
expect(prisma.piece.update).toHaveBeenCalled();
expect(prisma.piece.update.mock.calls[0][0].data.product).toEqual({
disconnect: true,
});
});
});

View File

@@ -21,6 +21,18 @@ const PIECE_WITH_RELATIONS_INCLUDE = {
customField: true,
},
},
product: {
include: {
typeProduct: true,
constructeurs: true,
customFieldValues: {
include: {
customField: true,
},
},
documents: true,
},
},
machineLinks: {
include: {
machine: true,
@@ -55,43 +67,63 @@ export class PiecesService {
};
}
if (createPieceDto.productId) {
const normalizedProductId = createPieceDto.productId.trim();
if (normalizedProductId) {
data.product = {
connect: { id: normalizedProductId },
};
}
}
return { data, constructeurIds: resolvedConstructeurIds };
}
async create(createPieceDto: CreatePieceDto) {
try {
const { data, constructeurIds } = await this.buildCreateInput(
createPieceDto,
const { data, constructeurIds } =
await this.buildCreateInput(createPieceDto);
const { pieceId, syncedConstructeurIds } = await this.prisma.$transaction(
async (tx) => {
const created = await tx.piece.create({
data,
include: PIECE_WITH_RELATIONS_INCLUDE,
});
let synced: string[] = [];
if (constructeurIds.length > 0) {
synced = await syncConstructeurLinks(
tx,
'_PieceConstructeurs',
created.id,
constructeurIds,
);
}
await this.applyPieceSkeleton({
pieceId: created.id,
typePiece: created.typePiece as PieceTypeWithSkeleton | null,
product: created.product,
prisma: tx,
});
return {
pieceId: created.id,
syncedConstructeurIds: synced,
};
},
);
const created = await this.prisma.piece.create({
data,
include: PIECE_WITH_RELATIONS_INCLUDE,
});
let syncedConstructeurIds: string[] = [];
if (constructeurIds.length > 0) {
syncedConstructeurIds = await syncConstructeurLinks(
this.prisma,
'_PieceConstructeurs',
created.id,
constructeurIds,
);
}
await this.applyPieceSkeleton({
pieceId: created.id,
typePiece: created.typePiece as PieceTypeWithSkeleton | null,
prisma: this.prisma,
});
const refreshed = await this.prisma.piece.findUnique({
where: { id: created.id },
where: { id: pieceId },
include: PIECE_WITH_RELATIONS_INCLUDE,
});
if (refreshed && syncedConstructeurIds.length > 0) {
(refreshed as typeof refreshed & { constructeurIds?: string[] }).constructeurIds =
[...syncedConstructeurIds];
(
refreshed as typeof refreshed & { constructeurIds?: string[] }
).constructeurIds = [...syncedConstructeurIds];
}
return refreshed;
@@ -134,9 +166,8 @@ export class PiecesService {
const constructeurIds = this.normalizeConstructeurIds(
updatePieceDto.constructeurIds,
);
resolvedConstructeurIds = await this.resolveExistingConstructeurIds(
constructeurIds,
);
resolvedConstructeurIds =
await this.resolveExistingConstructeurIds(constructeurIds);
}
if (updatePieceDto.typePieceId !== undefined) {
@@ -145,6 +176,16 @@ export class PiecesService {
: { disconnect: true };
}
if (updatePieceDto.productId !== undefined) {
const normalizedProductId =
typeof updatePieceDto.productId === 'string'
? updatePieceDto.productId.trim()
: null;
data.product = normalizedProductId
? { connect: { id: normalizedProductId } }
: { disconnect: true };
}
let syncedConstructeurIds: string[] | undefined;
try {
await this.prisma.$transaction(async (tx) => {
@@ -166,6 +207,7 @@ export class PiecesService {
await this.applyPieceSkeleton({
pieceId: updated.id,
typePiece: updated.typePiece as PieceTypeWithSkeleton | null,
product: updated.product,
prisma: tx,
});
});
@@ -176,8 +218,9 @@ export class PiecesService {
});
if (refreshed && syncedConstructeurIds) {
(refreshed as typeof refreshed & { constructeurIds?: string[] }).constructeurIds =
[...syncedConstructeurIds];
(
refreshed as typeof refreshed & { constructeurIds?: string[] }
).constructeurIds = [...syncedConstructeurIds];
}
return refreshed;
@@ -247,10 +290,15 @@ export class PiecesService {
private async applyPieceSkeleton({
pieceId,
typePiece,
product,
prisma,
}: {
pieceId: string;
typePiece: PieceTypeWithSkeleton | null;
product: {
typeProductId: string | null;
typeProduct?: { code: string | null } | null;
} | null;
prisma: Prisma.TransactionClient | PrismaService;
}) {
if (!typePiece?.id) {
@@ -267,6 +315,13 @@ export class PiecesService {
}
const customFields = skeleton.customFields ?? [];
const productRequirements: PieceProductRequirement[] = Array.isArray(
skeleton.products,
)
? skeleton.products.filter(
(entry): entry is PieceProductRequirement => !!entry,
)
: [];
await this.ensurePieceCustomFieldDefinitions(
prisma,
@@ -279,6 +334,99 @@ export class PiecesService {
typePiece.id,
customFields,
);
if (productRequirements.length > 0) {
await this.ensurePieceProductCompliance({
prisma,
pieceId,
product,
requirements: productRequirements,
});
}
}
private async ensurePieceProductCompliance({
prisma,
pieceId,
product,
requirements,
}: {
prisma: Prisma.TransactionClient | PrismaService;
pieceId: string;
product: {
typeProductId: string | null;
typeProduct?: { code: string | null } | null;
} | null;
requirements: PieceProductRequirement[];
}) {
const effectiveProduct =
product ??
(
await prisma.piece.findUnique({
where: { id: pieceId },
select: {
product: {
select: {
typeProductId: true,
typeProduct: {
select: { code: true },
},
},
},
},
})
)?.product;
if (!effectiveProduct) {
throw new ConflictException(
'Ce type de pièce impose la sélection dun produit catalogue.',
);
}
const matches = requirements.some((requirement) =>
this.doesProductMatchRequirement(effectiveProduct, requirement),
);
if (!matches) {
throw new ConflictException(
'Le produit associé ne respecte pas les exigences définies par le squelette.',
);
}
}
private doesProductMatchRequirement(
product: {
typeProductId: string | null;
typeProduct?: { code: string | null } | null;
},
requirement: PieceProductRequirement,
): boolean {
if (!requirement) {
return false;
}
if ('typeProductId' in requirement && requirement.typeProductId) {
const expectedId = requirement.typeProductId.trim();
if (!expectedId) {
return false;
}
const currentId = product.typeProductId
? product.typeProductId.trim()
: '';
return currentId === expectedId;
}
if ('familyCode' in requirement && requirement.familyCode) {
const expectedCode = requirement.familyCode.trim().toLowerCase();
if (!expectedCode) {
return false;
}
const productCode =
product.typeProduct?.code?.trim().toLowerCase() ?? null;
return productCode === expectedCode;
}
return false;
}
private normalizeConstructeurIds(ids?: string[] | null): string[] {
@@ -529,3 +677,7 @@ type PieceTypeWithSkeleton = Prisma.ModelTypeGetPayload<{
type PieceCustomFieldEntry = NonNullable<
PieceModelStructure['customFields']
>[number];
type PieceProductRequirement = NonNullable<
PieceModelStructure['products']
>[number];

View File

@@ -0,0 +1,49 @@
import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
import { Type } from 'class-transformer';
export enum ProductSortField {
NAME = 'name',
REFERENCE = 'reference',
CREATED_AT = 'createdAt',
SUPPLIER_PRICE = 'supplierPrice',
}
export enum SortDirection {
ASC = 'asc',
DESC = 'desc',
}
export class ListProductsQueryDto {
@IsOptional()
@IsString()
q?: string;
@IsOptional()
@IsString()
typeProductId?: string;
@IsOptional()
@IsString()
constructeurId?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
offset?: number;
@IsOptional()
@IsEnum(ProductSortField)
sort?: ProductSortField;
@IsOptional()
@IsEnum(SortDirection)
dir?: SortDirection;
}

View File

@@ -0,0 +1,43 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
} from '@nestjs/common';
import { ProductsService } from './products.service';
import { CreateProductDto, UpdateProductDto } from '../shared/dto/product.dto';
import { ListProductsQueryDto } from './dto/list-products.dto';
@Controller('products')
export class ProductsController {
constructor(private readonly productsService: ProductsService) {}
@Get()
list(@Query() query: ListProductsQueryDto) {
return this.productsService.list(query);
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.productsService.findOne(id);
}
@Post()
create(@Body() createProductDto: CreateProductDto) {
return this.productsService.create(createProductDto);
}
@Patch(':id')
update(@Param('id') id: string, @Body() updateProductDto: UpdateProductDto) {
return this.productsService.update(id, updateProductDto);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.productsService.remove(id);
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { ProductsController } from './products.controller';
import { ProductsService } from './products.service';
@Module({
controllers: [ProductsController],
providers: [ProductsService],
})
export class ProductsModule {}

View File

@@ -0,0 +1,240 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ConflictException } from '@nestjs/common';
import { Prisma, Product } from '@prisma/client';
import { ProductsService } from './products.service';
import { PrismaService } from '../prisma/prisma.service';
import { syncConstructeurLinks } from '../common/utils/constructeur-link.util';
import { CreateProductDto, UpdateProductDto } from '../shared/dto/product.dto';
import { ProductSortField, SortDirection } from './dto/list-products.dto';
jest.mock('../common/utils/constructeur-link.util', () => ({
syncConstructeurLinks: jest.fn().mockResolvedValue([]),
}));
describe('ProductsService', () => {
let service: ProductsService;
let prisma: {
product: any;
constructeur: any;
piece: any;
composant: any;
document: any;
$transaction: jest.Mock;
};
const mockSyncConstructeurLinks = syncConstructeurLinks as jest.Mock;
beforeEach(async () => {
prisma = {
product: {
findMany: jest.fn(),
count: jest.fn(),
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
constructeur: {
findMany: jest.fn(),
},
piece: {
count: jest.fn(),
},
composant: {
count: jest.fn(),
},
document: {
count: jest.fn(),
},
$transaction: jest.fn((arg: any) => {
if (Array.isArray(arg)) {
return Promise.all(arg);
}
if (typeof arg === 'function') {
return arg(prisma);
}
return Promise.resolve();
}),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
ProductsService,
{ provide: PrismaService, useValue: prisma },
],
}).compile();
service = module.get<ProductsService>(ProductsService);
mockSyncConstructeurLinks.mockClear();
});
describe('list', () => {
it('returns products with mapped constructeur ids and pagination meta', async () => {
const product: Product & {
constructeurs: Array<{ id: string }>;
documents: any[];
customFieldValues: any[];
pieces: any[];
composants: any[];
typeProduct: null;
} = {
id: 'prod-1',
name: 'Product 1',
reference: 'P-001',
supplierPrice: new Prisma.Decimal(120),
createdAt: new Date(),
updatedAt: new Date(),
typeProductId: null,
constructeurs: [{ id: 'const-1' }],
documents: [],
customFieldValues: [],
pieces: [],
composants: [],
typeProduct: null,
};
prisma.product.findMany.mockResolvedValue([product]);
prisma.product.count.mockResolvedValue(1);
const result = await service.list({
q: ' Product ',
limit: 200,
sort: ProductSortField.NAME,
dir: SortDirection.ASC,
});
expect(prisma.product.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
OR: expect.any(Array),
}),
take: 100, // capped
orderBy: { name: 'asc' },
}),
);
expect(result.total).toBe(1);
expect(result.items[0]).toMatchObject({
id: 'prod-1',
constructeurIds: ['const-1'],
});
});
});
describe('create', () => {
it('persists a product and synchronizes constructeurs', async () => {
const dto: CreateProductDto = {
name: 'New Product',
supplierPrice: 150.5,
constructeurIds: ['const-1', 'const-1', ''],
typeProductId: 'type-1',
};
prisma.constructeur.findMany.mockResolvedValue([{ id: 'const-1' }]);
prisma.product.create.mockResolvedValue({
id: 'prod-1',
});
prisma.product.findUnique.mockResolvedValue({
id: 'prod-1',
name: dto.name,
reference: null,
supplierPrice: new Prisma.Decimal(150.5),
typeProductId: dto.typeProductId,
constructeurs: [{ id: 'const-1' }],
documents: [],
customFieldValues: [],
pieces: [],
composants: [],
typeProduct: null,
});
mockSyncConstructeurLinks.mockResolvedValue(['const-1']);
const created = await service.create(dto);
expect(prisma.product.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
name: 'New Product',
supplierPrice: expect.any(Prisma.Decimal),
typeProduct: { connect: { id: 'type-1' } },
}),
}),
);
expect(
String(prisma.product.create.mock.calls[0][0].data.supplierPrice),
).toBe('150.5');
expect(mockSyncConstructeurLinks).toHaveBeenCalledWith(
expect.any(Object),
'_ProductConstructeurs',
'prod-1',
['const-1'],
);
expect(created.constructeurIds).toEqual(['const-1']);
});
});
describe('update', () => {
it('updates product fields and synchronizes constructeurs when provided', async () => {
const dto: UpdateProductDto = {
supplierPrice: null,
constructeurIds: ['const-2'],
typeProductId: '',
};
prisma.constructeur.findMany.mockResolvedValue([{ id: 'const-2' }]);
prisma.product.findUnique.mockResolvedValue({
id: 'prod-1',
name: 'Existing product',
reference: null,
supplierPrice: null,
typeProductId: null,
constructeurs: [{ id: 'const-2' }],
documents: [],
customFieldValues: [],
pieces: [],
composants: [],
typeProduct: null,
});
await service.update('prod-1', dto);
expect(prisma.product.update).toHaveBeenCalledWith({
where: { id: 'prod-1' },
data: expect.objectContaining({
supplierPrice: null,
typeProduct: { disconnect: true },
}),
});
expect(mockSyncConstructeurLinks).toHaveBeenCalledWith(
expect.any(Object),
'_ProductConstructeurs',
'prod-1',
['const-2'],
);
});
});
describe('remove', () => {
it('throws when product is still referenced', async () => {
prisma.piece.count.mockResolvedValue(1);
prisma.composant.count.mockResolvedValue(0);
prisma.document.count.mockResolvedValue(2);
await expect(service.remove('prod-1')).rejects.toBeInstanceOf(
ConflictException,
);
expect(prisma.product.delete).not.toHaveBeenCalled();
});
it('deletes product when no references remain', async () => {
prisma.piece.count.mockResolvedValue(0);
prisma.composant.count.mockResolvedValue(0);
prisma.document.count.mockResolvedValue(0);
prisma.product.delete.mockResolvedValue(undefined);
await service.remove('prod-1');
expect(prisma.product.delete).toHaveBeenCalledWith({
where: { id: 'prod-1' },
});
});
});
});

View File

@@ -0,0 +1,322 @@
import {
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { CreateProductDto, UpdateProductDto } from '../shared/dto/product.dto';
import { PRODUCT_WITH_RELATIONS_INCLUDE } from '../common/constants/product-includes';
import { syncConstructeurLinks } from '../common/utils/constructeur-link.util';
import {
ListProductsQueryDto,
ProductSortField,
SortDirection,
} from './dto/list-products.dto';
type ProductWithRelations = Prisma.ProductGetPayload<{
include: typeof PRODUCT_WITH_RELATIONS_INCLUDE;
}>;
@Injectable()
export class ProductsService {
private readonly allowedSortFields: ProductSortField[] = [
ProductSortField.CREATED_AT,
ProductSortField.NAME,
ProductSortField.REFERENCE,
ProductSortField.SUPPLIER_PRICE,
];
constructor(private readonly prisma: PrismaService) {}
async list(params: ListProductsQueryDto) {
const {
q,
typeProductId,
constructeurId,
limit = 20,
offset = 0,
sort = ProductSortField.CREATED_AT,
dir = SortDirection.DESC,
} = params;
const cappedLimit = Math.min(Math.max(limit, 1), 100);
const safeOffset = Math.max(offset, 0);
const orderByField = this.allowedSortFields.includes(sort)
? sort
: ProductSortField.CREATED_AT;
const orderByDir: SortDirection =
dir === SortDirection.ASC ? SortDirection.ASC : SortDirection.DESC;
const where: Prisma.ProductWhereInput = {};
if (q?.trim()) {
const term = q.trim();
where.OR = [
{ name: { contains: term, mode: 'insensitive' } },
{ reference: { contains: term, mode: 'insensitive' } },
];
}
if (typeProductId) {
where.typeProductId = typeProductId;
}
if (constructeurId) {
where.constructeurs = {
some: { id: constructeurId },
};
}
const [items, total] = await this.prisma.$transaction([
this.prisma.product.findMany({
where,
include: PRODUCT_WITH_RELATIONS_INCLUDE,
orderBy: {
[orderByField]: orderByDir,
},
skip: safeOffset,
take: cappedLimit,
}),
this.prisma.product.count({ where }),
]);
return {
items: items.map((item) => this.mapProduct(item)),
total,
offset: safeOffset,
limit: cappedLimit,
};
}
async findOne(id: string) {
const product = await this.prisma.product.findUnique({
where: { id },
include: PRODUCT_WITH_RELATIONS_INCLUDE,
});
if (!product) {
throw new NotFoundException('Produit introuvable.');
}
return this.mapProduct(product);
}
async create(createProductDto: CreateProductDto) {
try {
const data: Prisma.ProductCreateInput = {
name: createProductDto.name,
reference: createProductDto.reference ?? null,
supplierPrice:
createProductDto.supplierPrice === undefined ||
createProductDto.supplierPrice === null
? null
: new Prisma.Decimal(createProductDto.supplierPrice),
};
if (createProductDto.typeProductId) {
data.typeProduct = {
connect: { id: createProductDto.typeProductId },
};
}
const constructeurIds = this.normalizeConstructeurIds(
createProductDto.constructeurIds,
);
const resolvedConstructeurIds =
await this.resolveExistingConstructeurIds(constructeurIds);
const created = await this.prisma.product.create({
data,
include: PRODUCT_WITH_RELATIONS_INCLUDE,
});
let syncedConstructeurIds: string[] = [];
if (resolvedConstructeurIds.length > 0) {
syncedConstructeurIds = await syncConstructeurLinks(
this.prisma,
'_ProductConstructeurs',
created.id,
resolvedConstructeurIds,
);
}
const refreshed = await this.prisma.product.findUnique({
where: { id: created.id },
include: PRODUCT_WITH_RELATIONS_INCLUDE,
});
if (!refreshed) {
return this.mapProduct(created);
}
const mapped = this.mapProduct(refreshed);
if (syncedConstructeurIds.length > 0) {
mapped.constructeurIds = [...syncedConstructeurIds];
}
return mapped;
} catch (error) {
this.handlePrismaError(error);
}
}
async update(id: string, updateProductDto: UpdateProductDto) {
try {
const data: Prisma.ProductUpdateInput = {};
if (updateProductDto.name !== undefined) {
data.name = updateProductDto.name;
}
if (updateProductDto.reference !== undefined) {
data.reference = updateProductDto.reference;
}
if (updateProductDto.supplierPrice !== undefined) {
data.supplierPrice =
updateProductDto.supplierPrice === null
? null
: new Prisma.Decimal(updateProductDto.supplierPrice);
}
if (updateProductDto.typeProductId !== undefined) {
data.typeProduct = updateProductDto.typeProductId
? { connect: { id: updateProductDto.typeProductId } }
: { disconnect: true };
}
let resolvedConstructeurIds: string[] | undefined;
if (updateProductDto.constructeurIds !== undefined) {
const constructeurIds = this.normalizeConstructeurIds(
updateProductDto.constructeurIds,
);
resolvedConstructeurIds =
await this.resolveExistingConstructeurIds(constructeurIds);
}
let syncedConstructeurIds: string[] | undefined;
await this.prisma.$transaction(async (tx) => {
await tx.product.update({
where: { id },
data,
});
if (resolvedConstructeurIds !== undefined) {
syncedConstructeurIds = await syncConstructeurLinks(
tx,
'_ProductConstructeurs',
id,
resolvedConstructeurIds,
);
}
});
const refreshed = await this.prisma.product.findUnique({
where: { id },
include: PRODUCT_WITH_RELATIONS_INCLUDE,
});
if (!refreshed) {
throw new NotFoundException('Produit introuvable.');
}
const mapped = this.mapProduct(refreshed);
if (syncedConstructeurIds) {
mapped.constructeurIds = [...syncedConstructeurIds];
}
return mapped;
} catch (error) {
this.handlePrismaError(error);
}
}
async remove(id: string) {
const [pieceCount, componentCount, documentCount] = await Promise.all([
this.prisma.piece.count({
where: { productId: id },
}),
this.prisma.composant.count({
where: { productId: id },
}),
this.prisma.document.count({
where: { productId: id },
}),
]);
const blockingReasons: string[] = [];
if (pieceCount > 0) {
blockingReasons.push(`${pieceCount} pièce${pieceCount > 1 ? 's' : ''}`);
}
if (componentCount > 0) {
blockingReasons.push(
`${componentCount} composant${componentCount > 1 ? 's' : ''}`,
);
}
if (documentCount > 0) {
blockingReasons.push(
`${documentCount} document${documentCount > 1 ? 's' : ''}`,
);
}
if (blockingReasons.length > 0) {
throw new ConflictException(
`Impossible de supprimer ce produit car il est encore lié à ${blockingReasons.join(
', ',
)}.`,
);
}
await this.prisma.product.delete({
where: { id },
});
}
private normalizeConstructeurIds(ids?: string[] | null): string[] {
if (!Array.isArray(ids)) {
return [];
}
return Array.from(
new Set(
ids
.map((value) => (typeof value === 'string' ? value.trim() : ''))
.filter((value) => value.length > 0),
),
);
}
private async resolveExistingConstructeurIds(ids: string[]) {
if (ids.length === 0) {
return [];
}
const existing = await this.prisma.constructeur.findMany({
where: { id: { in: ids } },
select: { id: true },
});
const existingIds = new Set(existing.map(({ id }) => id));
return ids.filter((id) => existingIds.has(id));
}
private mapProduct(product: ProductWithRelations) {
return {
...product,
constructeurIds: product.constructeurs.map((item) => item.id),
};
}
private handlePrismaError(error: unknown): never {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === 'P2002') {
throw new ConflictException('Un produit avec ce nom existe déjà.');
}
if (error.code === 'P2025') {
throw new NotFoundException('Produit introuvable.');
}
}
throw error;
}
}

View File

@@ -45,6 +45,10 @@ export class CreateComposantDto {
@IsOptional()
@IsObject()
structure?: Record<string, any>;
@IsOptional()
@IsString()
productId?: string;
}
export class UpdateComposantDto {
@@ -73,4 +77,9 @@ export class UpdateComposantDto {
@IsOptional()
@IsObject()
structure?: Record<string, any>;
@IsOptional()
@Transform(({ value }) => (value === '' ? null : value))
@IsString()
productId?: string | null;
}

View File

@@ -11,6 +11,7 @@ export enum CustomFieldEntityType {
MACHINE = 'machine',
COMPOSANT = 'composant',
PIECE = 'piece',
PRODUCT = 'product',
}
export class CustomFieldEntityParamsDto {
@@ -76,6 +77,10 @@ export class CreateCustomFieldValueDto {
@IsOptional()
@IsString()
pieceId?: string;
@IsOptional()
@IsString()
productId?: string;
}
export class UpdateCustomFieldValueDto {

View File

@@ -31,6 +31,10 @@ export class CreateDocumentDto {
@IsOptional()
@IsString()
siteId?: string;
@IsOptional()
@IsString()
productId?: string;
}
export class UpdateDocumentDto {
@@ -57,4 +61,20 @@ export class UpdateDocumentDto {
@IsOptional()
@IsString()
siteId?: string;
@IsOptional()
@IsString()
machineId?: string;
@IsOptional()
@IsString()
composantId?: string;
@IsOptional()
@IsString()
pieceId?: string;
@IsOptional()
@IsString()
productId?: string;
}

View File

@@ -28,6 +28,10 @@ export class MachineComponentLinkPayloadDto {
@IsString()
composantId?: string;
@IsOptional()
@IsString()
productId?: string;
@IsOptional()
@IsString()
componentId?: string;
@@ -97,6 +101,10 @@ export class MachinePieceLinkPayloadDto {
@IsString()
composantId?: string;
@IsOptional()
@IsString()
productId?: string;
@IsOptional()
@IsString()
parentLinkId?: string;
@@ -142,6 +150,59 @@ export class MachinePieceLinkPayloadDto {
overrides?: Record<string, unknown>;
}
export class MachineProductLinkPayloadDto {
@IsOptional()
@IsString()
id?: string;
@IsOptional()
@IsString()
linkId?: string;
@IsString()
requirementId: string;
@IsOptional()
@IsString()
productId?: string;
@IsOptional()
@IsString()
typeProductId?: 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;
}
export class CreateMachineDto {
@IsString()
name: string;
@@ -177,6 +238,12 @@ export class CreateMachineDto {
@ValidateNested({ each: true })
@Type(() => MachinePieceLinkPayloadDto)
pieceLinks?: MachinePieceLinkPayloadDto[];
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => MachineProductLinkPayloadDto)
productLinks?: MachineProductLinkPayloadDto[];
}
export class UpdateMachineDto {
@@ -214,7 +281,14 @@ export class ReconfigureMachineDto {
@ValidateNested({ each: true })
@Type(() => MachinePieceLinkPayloadDto)
pieceLinks?: MachinePieceLinkPayloadDto[];
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => MachineProductLinkPayloadDto)
productLinks?: MachineProductLinkPayloadDto[];
}
export type MachineComponentLinkInput = MachineComponentLinkPayloadDto;
export type MachinePieceLinkInput = MachinePieceLinkPayloadDto;
export type MachineProductLinkInput = MachineProductLinkPayloadDto;

View File

@@ -35,6 +35,10 @@ export class CreatePieceDto {
@IsOptional()
@IsString()
typeMachinePieceRequirementId?: string;
@IsOptional()
@IsString()
productId?: string;
}
export class UpdatePieceDto {
@@ -60,4 +64,9 @@ export class UpdatePieceDto {
@IsOptional()
@IsString()
typePieceId?: string;
@IsOptional()
@Transform(({ value }) => (value === '' ? null : value))
@IsString()
productId?: string | null;
}

View File

@@ -0,0 +1,28 @@
import { IsArray, IsNumber, IsOptional, IsString } from 'class-validator';
import { Transform } from 'class-transformer';
import { PartialType } from '@nestjs/mapped-types';
export class CreateProductDto {
@IsString()
name!: string;
@IsOptional()
@IsString()
reference?: string;
@IsOptional()
@Transform(({ value }) => (value === '' ? null : value))
@IsNumber({}, { message: 'supplierPrice must be a valid number' })
supplierPrice?: number | null;
@IsOptional()
@IsString()
typeProductId?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
constructeurIds?: string[];
}
export class UpdateProductDto extends PartialType(CreateProductDto) {}

View File

@@ -122,6 +122,35 @@ export class TypeMachinePieceRequirementDto {
orderIndex?: number;
}
export class TypeMachineProductRequirementDto {
@IsString()
typeProductId: string;
@IsOptional()
@IsString()
label?: string;
@IsOptional()
@IsInt()
minCount?: number;
@IsOptional()
@IsInt()
maxCount?: number | null;
@IsOptional()
@IsBoolean()
required?: boolean;
@IsOptional()
@IsBoolean()
allowNewModels?: boolean;
@IsOptional()
@IsInt()
orderIndex?: number;
}
export class CreateTypeMachineDto {
@IsString()
name: string;
@@ -161,6 +190,12 @@ export class CreateTypeMachineDto {
@ValidateNested({ each: true })
@Type(() => TypeMachinePieceRequirementDto)
pieceRequirements?: TypeMachinePieceRequirementDto[];
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => TypeMachineProductRequirementDto)
productRequirements?: TypeMachineProductRequirementDto[];
}
export class UpdateTypeMachineDto {
@@ -203,6 +238,12 @@ export class UpdateTypeMachineDto {
@ValidateNested({ each: true })
@Type(() => TypeMachinePieceRequirementDto)
pieceRequirements?: TypeMachinePieceRequirementDto[];
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => TypeMachineProductRequirementDto)
productRequirements?: TypeMachineProductRequirementDto[];
}
export class CreateTypeComposantDto {

View File

@@ -2,7 +2,9 @@ import { normalizeComponentModelStructure } from '../../component-models/structu
import type {
ComponentModelStructure,
PieceModelCustomField,
PieceModelProduct,
PieceModelStructure,
ProductModelStructure,
} from '../types/inventory';
export class ComponentModelStructureValidationError extends Error {
@@ -28,6 +30,67 @@ function sanitizeOptionalString(value: unknown): string | undefined {
return String(value);
}
function validateProducts(
products: ComponentModelStructure['products'],
): ComponentModelStructure['products'] {
return products.map((product, index) => {
if ('typeProductId' in product) {
const typeProductId = assertString(
product.typeProductId,
`products[${index}].typeProductId`,
).trim();
if (!typeProductId) {
throw new ComponentModelStructureValidationError(
`products[${index}].typeProductId ne peut pas être vide`,
);
}
const payload: ComponentModelStructure['products'][number] = {
typeProductId,
role: sanitizeOptionalString(product.role),
};
if ('familyCode' in product && product.familyCode) {
const familyCode = assertString(
product.familyCode,
`products[${index}].familyCode`,
).trim();
if (familyCode) {
(payload as Record<string, unknown>).familyCode = familyCode;
}
}
if ('reference' in product && product.reference) {
(payload as Record<string, unknown>).reference = sanitizeOptionalString(
product.reference,
);
}
if ('typeProductLabel' in product && product.typeProductLabel) {
(payload as Record<string, unknown>).typeProductLabel =
sanitizeOptionalString(product.typeProductLabel);
}
return payload;
}
if ('familyCode' in product) {
const familyCode = assertString(
product.familyCode,
`products[${index}].familyCode`,
).trim();
if (!familyCode) {
throw new ComponentModelStructureValidationError(
`products[${index}].familyCode ne peut pas être vide`,
);
}
return {
familyCode,
role: sanitizeOptionalString(product.role),
};
}
throw new ComponentModelStructureValidationError(
`products[${index}] doit définir "familyCode" ou "typeProductId"`,
);
});
}
function validatePieces(
pieces: ComponentModelStructure['pieces'],
): ComponentModelStructure['pieces'] {
@@ -148,6 +211,7 @@ export const ComponentModelStructureSchema = {
const normalized = normalizeComponentModelStructure(input);
return {
products: validateProducts(normalized.products),
pieces: validatePieces(normalized.pieces),
customFields: validateCustomFields(normalized.customFields),
subcomponents: validateSubcomponents(normalized.subcomponents),
@@ -230,10 +294,57 @@ function normalizePieceModelCustomFields(
return normalized;
}
function normalizePieceModelProducts(products: unknown): PieceModelProduct[] {
if (!Array.isArray(products)) {
return [];
}
return products.map((entry, index) => {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
throw new PieceModelStructureValidationError(
`products[${index}] doit être un objet`,
);
}
const record = entry as Record<string, unknown>;
const rawTypeProductId =
typeof record.typeProductId === 'string'
? record.typeProductId
: typeof (record.typeProduct as { id?: unknown })?.id === 'string'
? (record.typeProduct as { id: string }).id
: undefined;
const typeProductId = rawTypeProductId ? rawTypeProductId.trim() : '';
const rawFamilyCode =
typeof record.familyCode === 'string'
? record.familyCode
: typeof (record.typeProduct as { code?: unknown })?.code === 'string'
? (record.typeProduct as { code: string }).code
: undefined;
const familyCode = rawFamilyCode ? rawFamilyCode.trim() : '';
const rawRole = typeof record.role === 'string' ? record.role.trim() : '';
const role = rawRole ? rawRole : undefined;
if (typeProductId) {
return role ? { typeProductId, role } : { typeProductId };
}
if (familyCode) {
return role ? { familyCode, role } : { familyCode };
}
throw new PieceModelStructureValidationError(
`products[${index}] doit définir "familyCode" ou "typeProductId"`,
);
});
}
export const PieceModelStructureSchema = {
parse(input: unknown): PieceModelStructure {
if (input === undefined || input === null) {
return { customFields: [] };
return { customFields: [], products: [] };
}
if (typeof input !== 'object' || Array.isArray(input)) {
@@ -250,6 +361,11 @@ export const PieceModelStructureSchema = {
structure.customFields = customFields;
}
const products = normalizePieceModelProducts(record.products);
if (products.length > 0 || 'products' in record) {
structure.products = products;
}
const normalizedTypePiece = toStringOrNull(record.typePieceId);
if (normalizedTypePiece) {
structure.typePieceId = normalizedTypePiece;
@@ -260,3 +376,34 @@ export const PieceModelStructureSchema = {
return structure;
},
};
export class ProductModelStructureValidationError extends Error {
constructor(message: string) {
super(message);
this.name = 'ProductModelStructureValidationError';
}
}
export const ProductModelStructureSchema = {
parse(input: unknown): ProductModelStructure {
if (input === undefined || input === null) {
return { customFields: [] };
}
if (typeof input !== 'object' || Array.isArray(input)) {
throw new ProductModelStructureValidationError(
'La structure de produit doit être un objet JSON.',
);
}
const record = input as Record<string, unknown>;
const structure: ProductModelStructure = { ...record };
const customFields = normalizePieceModelCustomFields(record.customFields);
if (customFields.length > 0 || 'customFields' in record) {
structure.customFields = customFields;
}
return structure;
},
};

View File

@@ -16,6 +16,20 @@ export type ComponentModelStructure = {
}
>;
/**
* Familles de produits autorisées (ou identifiant de famille) — pas de quantité ici.
*/
products: Array<
| {
familyCode: string;
role?: string;
}
| {
typeProductId: string;
role?: string;
}
>;
/**
* Valeurs par défaut au niveau "modèle" (libres, mais clé obligatoire).
*/
@@ -48,7 +62,25 @@ export type PieceModelCustomField = {
options?: unknown;
};
export type PieceModelProduct =
| {
familyCode: string;
role?: string;
}
| {
typeProductId: string;
role?: string;
};
export type PieceModelStructure = {
customFields?: PieceModelCustomField[];
products?: PieceModelProduct[];
[key: string]: unknown;
};
export type ProductModelCustomField = PieceModelCustomField;
export type ProductModelStructure = {
customFields?: ProductModelCustomField[];
[key: string]: unknown;
};

View File

@@ -1,4 +1,5 @@
import { Injectable } from '@nestjs/common';
import { ConflictException, Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { TypeMachinesRepository } from '../../common/repositories/type-machines.repository';
import {
TYPE_MACHINE_DEFAULT_INCLUDE,
@@ -17,7 +18,12 @@ export class TypeMachineService {
async create(dto: CreateTypeMachineDto) {
const data = TypeMachineMapper.toCreateInput(dto);
return this.repository.create(data, TYPE_MACHINE_DEFAULT_INCLUDE);
try {
return await this.repository.create(data, TYPE_MACHINE_DEFAULT_INCLUDE);
} catch (error) {
this.handlePrismaError(error);
throw error;
}
}
async findAll() {
@@ -53,7 +59,24 @@ export class TypeMachineService {
await this.repository.createPieceRequirements(id, requirements);
}
return this.repository.update(id, updateData, TYPE_MACHINE_DEFAULT_INCLUDE);
if (dto.productRequirements !== undefined) {
await this.repository.deleteProductRequirements(id);
const requirements = TypeMachineMapper.mapProductRequirementInputs(
dto.productRequirements,
);
await this.repository.createProductRequirements(id, requirements);
}
try {
return await this.repository.update(
id,
updateData,
TYPE_MACHINE_DEFAULT_INCLUDE,
);
} catch (error) {
this.handlePrismaError(error);
throw error;
}
}
async remove(id: string) {
@@ -69,4 +92,19 @@ export class TypeMachineService {
await this.repository.deleteCustomFields(id);
return this.repository.delete(id);
}
private handlePrismaError(error: unknown): never {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (
error.code === 'P2002' &&
Array.isArray(error.meta?.target) &&
error.meta.target.includes('name')
) {
throw new ConflictException(
'Nom déjà utilisé pour un type de machine.',
);
}
}
throw error;
}
}