diff --git a/REPORT-36E.md b/REPORT-36E.md new file mode 100644 index 0000000..6e978d6 --- /dev/null +++ b/REPORT-36E.md @@ -0,0 +1,35 @@ +# RAPPORT SPEC-36E +Date : 2026-04-09 + +## 1. RESUME +Succes. 4 fichiers modifies (1 prisma, 3 backend). Le lotissement PO descend maintenant au niveau des lignes individuelles avec validation 100% de couverture. + +## 2. PRISMA +- Modele PurchaseLotLine cree : OUI (purchase_lot_lines) +- Relations ajoutees sur PurchaseLot et PurchaseOrderLine : OUI (lotLines) +- prisma db push : succes +- Table purchase_lot_lines existe : OUI + +## 3. BACKEND +- lotSplitSchema refait (orderDate + items[poLineId, quantity]) : OUI +- splitLots() refait avec validation 100% : OUI +- deliveryDays recupere depuis TenderOffer : OUI +- deliveryDate calculee (orderDate + deliveryDays) : OUI +- GET lots inclut lotLines : OUI (listLots include lotLines + poLine) +- Decimal importe : OUI (deja present) + +## 4. COMPILATION +- Backend tsc : 0 erreurs nouvelles + +## 5. TEST API +- Split PO #10 avec 2 lots, 2 lignes chacun : 200 OK + - Lot 1 (15/04) : Line A=1500, Line B=3000, total=4500 + - Lot 2 (01/05) : Line A=1500, Line B=3000, total=4500 +- Validation 100% : 400 "Ligne Test line A: reparti 2000, attendu 3000" +- GET /purchase-orders/10/lots : retourne lotLines avec poLine detail + +## 6. COMMITS +A commiter + +## 7. BUGS DECOUVERTS +- Aucun PO existant n'a de PurchaseOrderLine — tous utilisent le champ JSON legacy `items`. La migration des items vers lines sera necessaire (spec future). diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d9c3256..1f65724 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1012,6 +1012,7 @@ model PurchaseLot { purchaseOrderId Int? @map("purchase_order_id") purchaseOrder PurchaseOrder? @relation(fields: [purchaseOrderId], references: [id]) invoices POInvoice[] + lotLines PurchaseLotLine[] notes String? createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -1023,6 +1024,21 @@ model PurchaseLot { @@map("purchase_lots") } +model PurchaseLotLine { + id Int @id @default(autoincrement()) + lotId Int @map("lot_id") + lot PurchaseLot @relation(fields: [lotId], references: [id], onDelete: Cascade) + poLineId Int @map("po_line_id") + poLine PurchaseOrderLine @relation(fields: [poLineId], references: [id], onDelete: Cascade) + quantity Decimal @db.Decimal(12, 2) + createdAt DateTime @default(now()) @map("created_at") + + @@unique([lotId, poLineId]) + @@index([lotId]) + @@index([poLineId]) + @@map("purchase_lot_lines") +} + // CDC §6.1 étapes 2-5 — Mini-appel d'offres model Tender { id Int @id @default(autoincrement()) @@ -1214,6 +1230,7 @@ model PurchaseOrderLine { sourceEstimateLineId Int? @map("source_estimate_line_id") receivedQuantity Decimal @default(0) @map("received_quantity") @db.Decimal(12, 2) receivedAt DateTime? @map("received_at") + lotLines PurchaseLotLine[] createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") diff --git a/src/modules/purchase-orders/purchase-orders.schemas.ts b/src/modules/purchase-orders/purchase-orders.schemas.ts index c85bcea..06ccc7f 100644 --- a/src/modules/purchase-orders/purchase-orders.schemas.ts +++ b/src/modules/purchase-orders/purchase-orders.schemas.ts @@ -107,9 +107,12 @@ export const paymentScheduleSchema = z.object({ // ─── Lot Split ────────────────────────────────────────────────── export const lotSplitSchema = z.object({ lots: z.array(z.object({ - quantity: z.number().positive(), - expectedDeliveryDate: z.string().optional(), - })).min(1, 'At least one lot required'), + orderDate: z.string(), + items: z.array(z.object({ + poLineId: z.number().int().positive(), + quantity: z.number().min(0), + })).min(1, 'Au moins une ligne par lot'), + })).min(1, 'Au moins un lot requis'), }); // ─── Confirm Payment ──────────────────────────────────────────── diff --git a/src/modules/purchase-orders/purchase-orders.service.ts b/src/modules/purchase-orders/purchase-orders.service.ts index 4a54e9b..9cdcc45 100644 --- a/src/modules/purchase-orders/purchase-orders.service.ts +++ b/src/modules/purchase-orders/purchase-orders.service.ts @@ -1170,7 +1170,15 @@ export class PurchaseOrderService { const po = await this.getById(poId); // validate PO exists const lots = await this.prisma.purchaseLot.findMany({ where: { purchaseOrderId: poId }, - include: { invoices: true, requirement: { select: { id: true, description: true, materialCategory: true } } }, + include: { + invoices: true, + requirement: { select: { id: true, description: true, materialCategory: true } }, + lotLines: { + include: { + poLine: { select: { id: true, description: true, quantity: true, unit: true, estimatedPrice: true } }, + }, + }, + }, orderBy: { lotNumber: 'asc' }, }); // If single lot has no deliveryDate, inherit from PO expectedDeliveryDate @@ -1181,50 +1189,108 @@ export class PurchaseOrderService { } // ─── LOTS: SPLIT ────────────────────────────────────────────── - async splitLots(poId: number, lots: Array<{ quantity: number; expectedDeliveryDate?: string }>) { - const po = await this.getById(poId); + async splitLots( + poId: number, + lots: Array<{ + orderDate: string; + items: Array<{ poLineId: number; quantity: number }>; + }>, + ) { + const po = await this.prisma.purchaseOrder.findUnique({ + where: { id: poId }, + include: { lines: true }, + }); + if (!po) httpError(404, 'PO not found'); if (po.status !== 'DRAFT') { httpError(400, 'Lots can only be split on DRAFT purchase orders'); } - // Get requirement IDs from items JSON - const itemsArr = (po.items as any[]) ?? []; - const requirementIds = itemsArr - .map((i: any) => i.requirementId) - .filter((id: any) => typeof id === 'number') as number[]; + const poLines = po.lines; + + // Validation: every PO line must be covered at 100% + const totalsByLineId = new Map(); + for (const line of poLines) { + totalsByLineId.set(line.id, 0); + } + for (const lot of lots) { + for (const item of lot.items) { + const poLine = poLines.find(l => l.id === item.poLineId); + if (!poLine) { + httpError(400, `Ligne PO #${item.poLineId} introuvable dans ce PO`); + } + const current = totalsByLineId.get(item.poLineId) ?? 0; + totalsByLineId.set(item.poLineId, current + item.quantity); + } + } + for (const line of poLines) { + const allocated = totalsByLineId.get(line.id) ?? 0; + const expected = Number(line.quantity); + if (Math.abs(allocated - expected) > 0.01) { + httpError(400, `Ligne "${line.description}": réparti ${allocated}, attendu ${expected}`); + } + } + + // Determine deliveryDays from tender offer + let deliveryDays: number | null = null; + if (po.tenderId) { + const offer = await this.prisma.tenderOffer.findFirst({ + where: { tenderId: po.tenderId, supplierId: po.supplierId }, + select: { deliveryDays: true }, + }); + if (offer?.deliveryDays) deliveryDays = offer.deliveryDays; + } return this.prisma.$transaction(async (tx) => { - // Delete existing lots for this PO + // Delete existing lot lines then lots + await tx.purchaseLotLine.deleteMany({ + where: { lot: { purchaseOrderId: poId } }, + }); await tx.purchaseLot.deleteMany({ where: { purchaseOrderId: poId } }); - // Create new lots - const createdLots: any[] = []; for (let i = 0; i < lots.length; i++) { const lotInput = lots[i]; - const deliveryDate = lotInput.expectedDeliveryDate - ? new Date(lotInput.expectedDeliveryDate) - : (po.expectedDeliveryDate ? new Date(po.expectedDeliveryDate) : null); - - // If single requirement, all lots reference it. Otherwise distribute. - const reqId = requirementIds.length === 1 - ? requirementIds[0] - : requirementIds[i % requirementIds.length] ?? null; + const orderDate = new Date(lotInput.orderDate); + const deliveryDate = deliveryDays + ? new Date(orderDate.getTime() + deliveryDays * 24 * 60 * 60 * 1000) + : null; + const lotTotalQty = lotInput.items.reduce((sum, item) => sum + item.quantity, 0); const lot = await tx.purchaseLot.create({ data: { - requirementId: reqId, lotNumber: i + 1, - quantity: new Decimal(lotInput.quantity), + quantity: new Decimal(lotTotalQty), purchaseOrderId: poId, status: 'PLANNED', - plannedDate: new Date(), + plannedDate: orderDate, deliveryDate, + deliveryDays, }, }); - createdLots.push(lot); + + for (const item of lotInput.items) { + if (item.quantity > 0) { + await tx.purchaseLotLine.create({ + data: { + lotId: lot.id, + poLineId: item.poLineId, + quantity: new Decimal(item.quantity), + }, + }); + } + } } - return createdLots; + return tx.purchaseLot.findMany({ + where: { purchaseOrderId: poId }, + orderBy: { lotNumber: 'asc' }, + include: { + lotLines: { + include: { + poLine: { select: { id: true, description: true, quantity: true, unit: true } }, + }, + }, + }, + }); }); }