This repository has been archived on 2026-04-01. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Inventory_backend/src/pieces/pieces.service.ts

736 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { ConflictException, Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import {
fetchConstructeurIds,
syncConstructeurLinks,
} from '../common/utils/constructeur-link.util';
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 = {
typePiece: {
include: {
pieceCustomFields: {
orderBy: { orderIndex: 'asc' },
},
},
},
constructeurs: true,
documents: true,
customFieldValues: {
include: {
customField: true,
},
},
product: {
include: {
typeProduct: true,
constructeurs: true,
customFieldValues: {
include: {
customField: true,
},
},
documents: true,
},
},
machineLinks: {
include: {
machine: true,
typeMachinePieceRequirement: true,
parentLink: true,
},
},
} as const;
@Injectable()
export class PiecesService {
constructor(private prisma: PrismaService) {}
private async buildCreateInput(
createPieceDto: CreatePieceDto,
): Promise<{ data: Prisma.PieceCreateInput; constructeurIds: string[] }> {
const data: Prisma.PieceCreateInput = {
name: createPieceDto.name,
reference: createPieceDto.reference ?? null,
prix: createPieceDto.prix !== undefined ? createPieceDto.prix : null,
};
const constructeurIds = this.normalizeConstructeurIds(
createPieceDto.constructeurIds,
);
const resolvedConstructeurIds =
await this.resolveExistingConstructeurIds(constructeurIds);
if (createPieceDto.typePieceId) {
data.typePiece = {
connect: { id: createPieceDto.typePieceId },
};
}
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 { 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 refreshed = await this.prisma.piece.findUnique({
where: { id: pieceId },
include: PIECE_WITH_RELATIONS_INCLUDE,
});
if (!refreshed) {
return null;
}
const mapped = await this.mapPiece(refreshed);
if (syncedConstructeurIds.length > 0) {
mapped.constructeurIds = [...syncedConstructeurIds];
}
return mapped;
} catch (error) {
this.handlePrismaError(error);
}
}
async findAll() {
const items = await this.prisma.piece.findMany({
include: PIECE_WITH_RELATIONS_INCLUDE,
orderBy: { name: 'asc' },
});
const hydrated = await Promise.all(items.map((piece) => this.mapPiece(piece)));
return hydrated;
}
async findOne(id: string) {
const piece = await this.prisma.piece.findUnique({
where: { id },
include: PIECE_WITH_RELATIONS_INCLUDE,
});
if (!piece) {
return null;
}
return this.mapPiece(piece);
}
async update(id: string, updatePieceDto: UpdatePieceDto) {
const data: Prisma.PieceUpdateInput = {};
if (updatePieceDto.name !== undefined) {
data.name = updatePieceDto.name;
}
if (updatePieceDto.reference !== undefined) {
data.reference = updatePieceDto.reference;
}
if (updatePieceDto.prix !== undefined) {
data.prix = updatePieceDto.prix;
}
let resolvedConstructeurIds: string[] | undefined;
if (updatePieceDto.constructeurIds !== undefined) {
const constructeurIds = this.normalizeConstructeurIds(
updatePieceDto.constructeurIds,
);
resolvedConstructeurIds =
await this.resolveExistingConstructeurIds(constructeurIds);
}
if (updatePieceDto.typePieceId !== undefined) {
data.typePiece = updatePieceDto.typePieceId
? { connect: { id: updatePieceDto.typePieceId } }
: { 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) => {
const updated = await tx.piece.update({
where: { id },
data,
include: PIECE_WITH_RELATIONS_INCLUDE,
});
if (resolvedConstructeurIds !== undefined) {
syncedConstructeurIds = await syncConstructeurLinks(
tx,
'_PieceConstructeurs',
id,
resolvedConstructeurIds,
);
}
await this.applyPieceSkeleton({
pieceId: updated.id,
typePiece: updated.typePiece as PieceTypeWithSkeleton | null,
product: updated.product,
prisma: tx,
});
});
const refreshed = await this.prisma.piece.findUnique({
where: { id },
include: PIECE_WITH_RELATIONS_INCLUDE,
});
if (!refreshed) {
return null;
}
const mapped = await this.mapPiece(refreshed);
if (syncedConstructeurIds) {
mapped.constructeurIds = [...syncedConstructeurIds];
}
return mapped;
} catch (error) {
this.handlePrismaError(error);
}
}
async remove(id: string) {
const [machineLinksCount, documentsCount, customFieldValuesCount] =
await Promise.all([
this.prisma.machinePieceLink.count({
where: { pieceId: id },
}),
this.prisma.document.count({
where: { pieceId: id },
}),
this.prisma.customFieldValue.count({
where: { pieceId: id },
}),
]);
const blockingReasons: string[] = [];
if (machineLinksCount > 0) {
blockingReasons.push(
`${machineLinksCount} liaison${machineLinksCount > 1 ? 's' : ''} machine`,
);
}
if (documentsCount > 0) {
blockingReasons.push(
`${documentsCount} document${documentsCount > 1 ? 's' : ''}`,
);
}
if (blockingReasons.length > 0) {
const messageParts = [
`Impossible de supprimer cette pièce car elle possède encore: ${blockingReasons.join(
', ',
)}.`,
];
if (customFieldValuesCount > 0) {
messageParts.push(
`Les ${customFieldValuesCount} valeur${
customFieldValuesCount > 1 ? 's' : ''
} de champ personnalisé seront supprimées automatiquement une fois ces éléments détachés.`,
);
}
throw new ConflictException(
`${messageParts.join(' ')} Supprimez ou détachez les éléments indiqués avant de réessayer.`,
);
}
if (customFieldValuesCount > 0) {
await this.prisma.customFieldValue.deleteMany({
where: { pieceId: id },
});
}
return this.prisma.piece.delete({
where: { id },
});
}
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) {
return;
}
const skeleton = this.parsePieceSkeleton(
(typePiece as { pieceSkeleton?: Prisma.JsonValue | null } | null)
?.pieceSkeleton,
);
if (!skeleton) {
return;
}
const customFields = skeleton.customFields ?? [];
const productRequirements: PieceProductRequirement[] = Array.isArray(
skeleton.products,
)
? skeleton.products.filter(
(entry): entry is PieceProductRequirement => !!entry,
)
: [];
await this.ensurePieceCustomFieldDefinitions(
prisma,
typePiece.id,
customFields,
);
await this.createPieceCustomFieldValues(
prisma,
pieceId,
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[] {
if (!Array.isArray(ids)) {
return [];
}
const cleaned = ids
.map((item) => (typeof item === 'string' ? item.trim() : ''))
.filter((item) => item.length > 0);
return Array.from(new Set(cleaned));
}
private async resolveExistingConstructeurIds(
ids: string[],
): Promise<string[]> {
if (!ids.length) {
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 parsePieceSkeleton(value: unknown): PieceModelStructure | null {
if (!value) {
return null;
}
try {
return PieceModelStructureSchema.parse(value);
} catch (error) {
return null;
}
}
private async ensurePieceCustomFieldDefinitions(
prisma: Prisma.TransactionClient | PrismaService,
typePieceId: string,
customFields: PieceModelStructure['customFields'],
) {
if (
!typePieceId ||
!Array.isArray(customFields) ||
customFields.length === 0
) {
return;
}
const existing = await prisma.customField.findMany({
where: { typePieceId },
select: { id: true, name: true, orderIndex: true },
});
const existingByName = new Map(
existing.map((field) => [
this.normalizeIdentifier(field.name) ?? field.name,
field,
]),
);
for (let index = 0; index < customFields.length; index += 1) {
const field = customFields[index];
if (!field) {
continue;
}
const name = this.normalizeIdentifier(field.name);
if (!name) {
continue;
}
const existingField = existingByName.get(name);
if (existingField) {
if (existingField.orderIndex !== index) {
await prisma.customField.update({
where: { id: existingField.id },
data: { orderIndex: index },
});
}
continue;
}
const type = this.normalizeIdentifier(field.type) ?? 'text';
const required = Boolean(field.required);
const options = this.normalizeOptions(field);
const created = await prisma.customField.create({
data: {
name,
type,
required,
options,
orderIndex: index,
typePieceId,
},
select: { id: true, name: true, orderIndex: true },
});
existingByName.set(name, created);
}
}
private async createPieceCustomFieldValues(
prisma: Prisma.TransactionClient | PrismaService,
pieceId: string,
typePieceId: string,
customFields: PieceModelStructure['customFields'],
) {
if (
!typePieceId ||
!Array.isArray(customFields) ||
customFields.length === 0
) {
return;
}
const definitions = await 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 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 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 > 0 ? normalized : undefined;
}
return undefined;
}
private normalizeIdentifier(value: unknown): string | null {
if (typeof value !== 'string') {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
private toCustomFieldValue(value: unknown): string {
if (value === undefined || value === null) {
return '';
}
if (typeof value === 'string') {
return value;
}
return JSON.stringify(value);
}
private handlePrismaError(error: unknown): never {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === 'P2002' && this.isNameConstraint(error)) {
throw new ConflictException('Une pièce avec ce nom existe déjà.');
}
}
throw error;
}
private isNameConstraint(error: Prisma.PrismaClientKnownRequestError) {
const { target } = error.meta ?? {};
if (Array.isArray(target)) {
return target.includes('name');
}
if (typeof target === 'string') {
return target === 'name';
}
return false;
}
private async mapPiece(piece: any) {
const idsFromConstructeurs = Array.isArray(piece.constructeurs)
? piece.constructeurs
.map((c) => (c && typeof c.id === 'string' ? c.id : null))
.filter((id): id is string => Boolean(id))
: [];
const idsFromPayload = Array.isArray(piece.constructeurIds)
? piece.constructeurIds
.map((value) => (typeof value === 'string' ? value.trim() : ''))
.filter((value) => value.length > 0)
: [];
let ids = Array.from(new Set([...idsFromConstructeurs, ...idsFromPayload]));
if (!ids.length) {
ids = await fetchConstructeurIds(
this.prisma,
'_PieceConstructeurs',
piece.id,
);
}
let constructeurs = piece.constructeurs;
if ((!constructeurs || !constructeurs.length) && ids.length) {
constructeurs = await this.prisma.constructeur.findMany({
where: { id: { in: ids } },
});
}
return {
...piece,
constructeurs,
constructeurIds: ids,
};
}
}
type PieceTypeWithSkeleton = Prisma.ModelTypeGetPayload<{
include: { pieceCustomFields: true };
}>;
type PieceCustomFieldEntry = NonNullable<
PieceModelStructure['customFields']
>[number];
type PieceProductRequirement = NonNullable<
PieceModelStructure['products']
>[number];