736 lines
19 KiB
TypeScript
736 lines
19 KiB
TypeScript
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 d’un 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];
|