434 lines
11 KiB
TypeScript
434 lines
11 KiB
TypeScript
import { ConflictException, Injectable } from '@nestjs/common';
|
|
import { Prisma } from '@prisma/client';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import { CreatePieceDto, UpdatePieceDto } from '../shared/dto/piece.dto';
|
|
import { PieceModelStructureSchema } from '../shared/schemas/inventory';
|
|
import type { PieceModelStructure } from '../shared/types/inventory';
|
|
|
|
const PIECE_WITH_RELATIONS_INCLUDE = {
|
|
typePiece: {
|
|
include: {
|
|
pieceCustomFields: true,
|
|
},
|
|
},
|
|
constructeur: true,
|
|
documents: true,
|
|
customFieldValues: {
|
|
include: {
|
|
customField: true,
|
|
},
|
|
},
|
|
machineLinks: {
|
|
include: {
|
|
machine: true,
|
|
typeMachinePieceRequirement: true,
|
|
parentLink: true,
|
|
},
|
|
},
|
|
} as const;
|
|
|
|
@Injectable()
|
|
export class PiecesService {
|
|
constructor(private prisma: PrismaService) {}
|
|
|
|
private buildCreateInput(createPieceDto: CreatePieceDto): Prisma.PieceCreateInput {
|
|
const data: Prisma.PieceCreateInput = {
|
|
name: createPieceDto.name,
|
|
reference: createPieceDto.reference ?? null,
|
|
prix: createPieceDto.prix !== undefined ? createPieceDto.prix : null,
|
|
};
|
|
|
|
if (createPieceDto.constructeurId) {
|
|
data.constructeur = {
|
|
connect: { id: createPieceDto.constructeurId },
|
|
};
|
|
}
|
|
|
|
if (createPieceDto.typePieceId) {
|
|
data.typePiece = {
|
|
connect: { id: createPieceDto.typePieceId },
|
|
};
|
|
}
|
|
|
|
return data;
|
|
}
|
|
|
|
async create(createPieceDto: CreatePieceDto) {
|
|
try {
|
|
const created = await this.prisma.piece.create({
|
|
data: this.buildCreateInput(createPieceDto),
|
|
include: PIECE_WITH_RELATIONS_INCLUDE,
|
|
});
|
|
|
|
await this.applyPieceSkeleton({
|
|
pieceId: created.id,
|
|
typePiece: created.typePiece as PieceTypeWithSkeleton | null,
|
|
});
|
|
|
|
return this.prisma.piece.findUnique({
|
|
where: { id: created.id },
|
|
include: PIECE_WITH_RELATIONS_INCLUDE,
|
|
});
|
|
} catch (error) {
|
|
this.handlePrismaError(error);
|
|
}
|
|
}
|
|
|
|
async findAll() {
|
|
return this.prisma.piece.findMany({
|
|
include: PIECE_WITH_RELATIONS_INCLUDE,
|
|
orderBy: { name: 'asc' },
|
|
});
|
|
}
|
|
|
|
async findOne(id: string) {
|
|
return this.prisma.piece.findUnique({
|
|
where: { id },
|
|
include: PIECE_WITH_RELATIONS_INCLUDE,
|
|
});
|
|
}
|
|
|
|
async update(id: string, updatePieceDto: UpdatePieceDto) {
|
|
const data: Prisma.PieceUpdateInput = {};
|
|
|
|
if (updatePieceDto.name !== undefined) {
|
|
data.name = updatePieceDto.name;
|
|
}
|
|
|
|
if (updatePieceDto.reference !== undefined) {
|
|
data.reference = updatePieceDto.reference;
|
|
}
|
|
|
|
if (updatePieceDto.prix !== undefined) {
|
|
data.prix = updatePieceDto.prix;
|
|
}
|
|
|
|
if (updatePieceDto.constructeurId !== undefined) {
|
|
data.constructeur = updatePieceDto.constructeurId
|
|
? { connect: { id: updatePieceDto.constructeurId } }
|
|
: { disconnect: true };
|
|
}
|
|
|
|
if (updatePieceDto.typePieceId !== undefined) {
|
|
data.typePiece = updatePieceDto.typePieceId
|
|
? { connect: { id: updatePieceDto.typePieceId } }
|
|
: { disconnect: true };
|
|
}
|
|
|
|
try {
|
|
const updated = await this.prisma.piece.update({
|
|
where: { id },
|
|
data,
|
|
include: PIECE_WITH_RELATIONS_INCLUDE,
|
|
});
|
|
|
|
await this.applyPieceSkeleton({
|
|
pieceId: updated.id,
|
|
typePiece: updated.typePiece as PieceTypeWithSkeleton | null,
|
|
});
|
|
|
|
return this.prisma.piece.findUnique({
|
|
where: { id: updated.id },
|
|
include: PIECE_WITH_RELATIONS_INCLUDE,
|
|
});
|
|
} 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,
|
|
}: {
|
|
pieceId: string;
|
|
typePiece: PieceTypeWithSkeleton | null;
|
|
}) {
|
|
if (!typePiece?.id) {
|
|
return;
|
|
}
|
|
|
|
const skeleton = this.parsePieceSkeleton(
|
|
(typePiece as { pieceSkeleton?: Prisma.JsonValue | null } | null)
|
|
?.pieceSkeleton,
|
|
);
|
|
|
|
if (!skeleton) {
|
|
return;
|
|
}
|
|
|
|
const customFields = skeleton.customFields ?? [];
|
|
|
|
await this.ensurePieceCustomFieldDefinitions(typePiece.id, customFields);
|
|
await this.createPieceCustomFieldValues(
|
|
pieceId,
|
|
typePiece.id,
|
|
customFields,
|
|
);
|
|
}
|
|
|
|
private parsePieceSkeleton(value: unknown): PieceModelStructure | null {
|
|
if (!value) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
return PieceModelStructureSchema.parse(value);
|
|
} catch (error) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private async ensurePieceCustomFieldDefinitions(
|
|
typePieceId: string,
|
|
customFields: PieceModelStructure['customFields'],
|
|
) {
|
|
if (
|
|
!typePieceId ||
|
|
!Array.isArray(customFields) ||
|
|
customFields.length === 0
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const existing = await this.prisma.customField.findMany({
|
|
where: { typePieceId },
|
|
select: { id: true, name: true },
|
|
});
|
|
|
|
const existingByName = new Map(
|
|
existing.map((field) => [
|
|
this.normalizeIdentifier(field.name) ?? field.name,
|
|
field.id,
|
|
]),
|
|
);
|
|
|
|
for (const field of customFields) {
|
|
if (!field) {
|
|
continue;
|
|
}
|
|
|
|
const name = this.normalizeIdentifier(field.name);
|
|
if (!name || existingByName.has(name)) {
|
|
continue;
|
|
}
|
|
|
|
const type = this.normalizeIdentifier(field.type) ?? 'text';
|
|
const required = Boolean(field.required);
|
|
const options = this.normalizeOptions(field);
|
|
|
|
const created = await this.prisma.customField.create({
|
|
data: {
|
|
name,
|
|
type,
|
|
required,
|
|
options,
|
|
typePieceId,
|
|
},
|
|
select: { id: true },
|
|
});
|
|
|
|
existingByName.set(name, created.id);
|
|
}
|
|
}
|
|
|
|
private async createPieceCustomFieldValues(
|
|
pieceId: string,
|
|
typePieceId: string,
|
|
customFields: PieceModelStructure['customFields'],
|
|
) {
|
|
if (
|
|
!typePieceId ||
|
|
!Array.isArray(customFields) ||
|
|
customFields.length === 0
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const definitions = await this.prisma.customField.findMany({
|
|
where: { typePieceId },
|
|
select: { id: true, name: true },
|
|
});
|
|
|
|
if (definitions.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const definitionMap = new Map(
|
|
definitions.map((field) => [
|
|
this.normalizeIdentifier(field.name) ?? field.name,
|
|
field.id,
|
|
]),
|
|
);
|
|
|
|
const existingValues = await this.prisma.customFieldValue.findMany({
|
|
where: { pieceId },
|
|
select: { customFieldId: true },
|
|
});
|
|
|
|
const existingIds = new Set(
|
|
existingValues.map((value) => value.customFieldId),
|
|
);
|
|
|
|
for (const field of customFields) {
|
|
if (!field) {
|
|
continue;
|
|
}
|
|
|
|
const name = this.normalizeIdentifier(field.name);
|
|
if (!name) {
|
|
continue;
|
|
}
|
|
|
|
const definitionId = definitionMap.get(name);
|
|
if (!definitionId || existingIds.has(definitionId)) {
|
|
continue;
|
|
}
|
|
|
|
await this.prisma.customFieldValue.create({
|
|
data: {
|
|
customFieldId: definitionId,
|
|
pieceId,
|
|
value: this.toCustomFieldValue(field.value),
|
|
},
|
|
});
|
|
|
|
existingIds.add(definitionId);
|
|
}
|
|
}
|
|
|
|
private normalizeOptions(
|
|
field: PieceCustomFieldEntry | undefined,
|
|
): string[] | undefined {
|
|
const rawOptions = field?.options;
|
|
if (Array.isArray(rawOptions)) {
|
|
const normalized = rawOptions
|
|
.map((option) => (typeof option === 'string' ? option.trim() : ''))
|
|
.filter((option) => option.length > 0);
|
|
|
|
return normalized.length > 0 ? normalized : undefined;
|
|
}
|
|
|
|
const optionsTextValue =
|
|
field !== undefined
|
|
? (field as unknown as { optionsText?: unknown }).optionsText
|
|
: undefined;
|
|
|
|
if (typeof optionsTextValue === 'string') {
|
|
const normalized = optionsTextValue
|
|
.split(/\r?\n/)
|
|
.map((option: string) => option.trim())
|
|
.filter((option: string) => option.length > 0);
|
|
|
|
return normalized.length > 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;
|
|
}
|
|
}
|
|
|
|
type PieceTypeWithSkeleton = Prisma.ModelTypeGetPayload<{
|
|
include: { pieceCustomFields: true };
|
|
}>;
|
|
|
|
type PieceCustomFieldEntry = NonNullable<PieceModelStructure['customFields']>[number];
|