feat(document): EntityDocumentsService + autoTitle + soft-delete cascade

list/create/delete for Document attached to ClientContract, DeliveryLot,
ClientInvoice. Auto-titles from entity fields. Soft-delete cascades to
DocumentFile.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
louis 2026-04-21 18:20:14 +03:00
parent dc0e40a4b6
commit 08a5e36582

View File

@ -0,0 +1,109 @@
import { PrismaClient, DocumentType, DocumentDirection, Prisma } from '@prisma/client';
export type EntityKind = 'client-contract' | 'delivery-lot' | 'client-invoice';
interface CreateDto {
documentType: DocumentType;
title?: string;
direction?: DocumentDirection;
parentDocumentId?: number;
responsibleId?: string;
}
const FK_MAP: Record<EntityKind, string> = {
'client-contract': 'clientContractId',
'delivery-lot': 'deliveryLotId',
'client-invoice': 'clientInvoiceId',
};
const DEFAULT_DIRECTION: Record<EntityKind, DocumentDirection> = {
'client-contract': 'CLIENT',
'delivery-lot': 'SUPPLIER',
'client-invoice': 'CLIENT',
};
const DOCUMENT_INCLUDE = {
responsible: { select: { id: true, name: true, nameRu: true } },
files: { where: { deletedAt: null }, select: { id: true, originalName: true, version: true, isLatest: true, mimeType: true, fileSize: true, createdAt: true } },
} as const;
export function autoTitleForEntity(kind: EntityKind, entity: any): string {
switch (kind) {
case 'client-contract': return `Контракт ${entity.contractNumber ?? ''}`.trim();
case 'delivery-lot': return `УПД ${entity.updNumber ?? ''}`.trim();
case 'client-invoice': return `Счёт ${entity.invoiceNumber ?? ''}`.trim();
}
}
export class EntityDocumentsService {
constructor(private prisma: PrismaClient) {}
async list(kind: EntityKind, entityId: number) {
const fk = FK_MAP[kind];
return this.prisma.document.findMany({
where: { [fk]: entityId, deletedAt: null },
include: DOCUMENT_INCLUDE,
orderBy: { createdAt: 'desc' },
});
}
async create(kind: EntityKind, entityId: number, dto: CreateDto, userId?: string) {
const entity = await this.loadEntity(kind, entityId);
if (!entity) {
const err = new Error('Entité non trouvée') as any;
err.statusCode = 404;
throw err;
}
if (dto.parentDocumentId) {
const parent = await this.prisma.document.findFirst({
where: { id: dto.parentDocumentId, [FK_MAP[kind]]: entityId, deletedAt: null },
});
if (!parent) {
const err = new Error('Document parent introuvable ou appartient à une autre entité') as any;
err.statusCode = 400;
throw err;
}
}
const title = dto.title || autoTitleForEntity(kind, entity);
const direction = dto.direction ?? DEFAULT_DIRECTION[kind];
return this.prisma.document.create({
data: {
documentType: dto.documentType,
title,
direction,
[FK_MAP[kind]]: entityId,
responsibleId: dto.responsibleId ?? userId,
parentDocumentId: dto.parentDocumentId,
},
include: DOCUMENT_INCLUDE,
});
}
async delete(documentId: number) {
const now = new Date();
await this.prisma.$transaction([
this.prisma.document.update({
where: { id: documentId },
data: { deletedAt: now },
}),
this.prisma.documentFile.updateMany({
where: { documentId, deletedAt: null },
data: { deletedAt: now },
}),
]);
}
private async loadEntity(kind: EntityKind, entityId: number) {
switch (kind) {
case 'client-contract':
return this.prisma.clientContract.findUnique({ where: { id: entityId } });
case 'delivery-lot':
return this.prisma.deliveryLot.findUnique({ where: { id: entityId } });
case 'client-invoice':
return this.prisma.clientInvoice.findUnique({ where: { id: entityId } });
}
}
}