import { BadRequestException, ConflictException, Injectable, NotFoundException, } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import type { PurchaseOrderCreate, PurchaseOrderDto, PurchaseOrdersResponse, PurchaseOrderStatus, PurchaseOrderTransition, } from '@siop/shared'; import type { AuthenticatedUser } from '../auth/current-user.decorator'; import { PrismaService } from '../prisma/prisma.service'; const poInclude = { supplier: true, lines: { include: { part: true } }, } satisfies Prisma.PurchaseOrderInclude; type Row = Prisma.PurchaseOrderGetPayload<{ include: typeof poInclude }>; /** Machine à états du BC : la réception est le seul chemin vers le stock. */ const TRANSITIONS: Record = { DRAFT: ['SENT', 'CANCELLED'], SENT: ['RECEIVED', 'CANCELLED'], RECEIVED: [], CANCELLED: [], }; @Injectable() export class PurchaseOrdersService { constructor(private readonly prisma: PrismaService) {} async list(): Promise { const rows = await this.prisma.purchaseOrder.findMany({ include: poInclude, orderBy: { createdAt: 'desc' }, }); return { purchaseOrders: rows.map((r) => this.toDto(r)) }; } async get(id: string): Promise { const row = await this.prisma.purchaseOrder.findUnique({ where: { id }, include: poInclude, }); if (!row) throw new NotFoundException('BC inconnu'); return this.toDto(row); } async create(dto: PurchaseOrderCreate, user: AuthenticatedUser): Promise { const supplier = await this.prisma.partner.findUnique({ where: { id: dto.supplierId }, }); if (!supplier || supplier.kind !== 'SUPPLIER' || !supplier.isActive) { throw new BadRequestException('Fournisseur inconnu ou inactif'); } const parts = await this.prisma.part.findMany({ where: { id: { in: dto.lines.map((l) => l.partId) } }, }); if (parts.length !== new Set(dto.lines.map((l) => l.partId)).size) { throw new BadRequestException('Pièce inconnue dans les lignes'); } for (let essai = 0; ; essai++) { try { const created = await this.prisma.purchaseOrder.create({ data: { reference: await this.nextReference(), supplierId: dto.supplierId, createdById: user.userId, lines: { create: dto.lines }, }, select: { id: true }, }); return this.get(created.id); } catch (e) { if ( e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002' && essai < 3 ) { continue; } throw e; } } } async transition( id: string, dto: PurchaseOrderTransition, user: AuthenticatedUser, ): Promise { const row = await this.prisma.purchaseOrder.findUnique({ where: { id }, include: poInclude, }); if (!row) throw new NotFoundException('BC inconnu'); if (!TRANSITIONS[row.status].includes(dto.to)) { throw new ConflictException(`Transition interdite : ${row.status} → ${dto.to}`); } if (dto.to === 'RECEIVED') { // LA règle : la réception crée les entrées de stock et FIGE les PU await this.prisma.$transaction(async (tx) => { for (const line of row.lines) { await tx.stockMovement.create({ data: { partId: line.partId, kind: 'RECEIPT', quantity: line.quantity, unitPrice: line.unitPrice, purchaseOrderId: row.id, byId: user.userId, }, }); await tx.part.update({ where: { id: line.partId }, data: { lastUnitPrice: line.unitPrice }, }); } await tx.purchaseOrder.update({ where: { id }, data: { status: 'RECEIVED', receivedAt: new Date() }, }); }); } else { await this.prisma.purchaseOrder.update({ where: { id }, data: { status: dto.to, sentAt: dto.to === 'SENT' ? new Date() : undefined, cancelledAt: dto.to === 'CANCELLED' ? new Date() : undefined, }, }); } return this.get(id); } private async nextReference(): Promise { const [{ nextval }] = await this.prisma.$queryRaw<[{ nextval: bigint }]>` SELECT nextval('purchase_order_ref_seq')`; return `BC-${new Date().getFullYear()}-${String(nextval).padStart(4, '0')}`; } private toDto(row: Row): PurchaseOrderDto { const lines = row.lines.map((l) => ({ id: l.id, partId: l.partId, partReference: l.part.reference, designation: l.part.designation, quantity: l.quantity, unitPrice: Number(l.unitPrice), })); return { id: row.id, reference: row.reference, status: row.status, supplierId: row.supplierId, supplierName: row.supplier.name, lines, total: lines.reduce((s, l) => s + l.quantity * l.unitPrice, 0), sentAt: row.sentAt?.toISOString() ?? null, receivedAt: row.receivedAt?.toISOString() ?? null, createdAt: row.createdAt.toISOString(), }; } }