feat: SPEC-36E — lot split with line-level detail (PurchaseLotLine)

- New Prisma model PurchaseLotLine (lot_id, po_line_id, quantity)
- splitLots() validates 100% coverage per PO line, creates lotLines
- deliveryDate computed from TenderOffer.deliveryDays + orderDate
- listLots() includes lotLines with poLine detail

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
louis 2026-04-10 12:28:18 +03:00
parent d5c43c8fd8
commit 3fa2b4eb8f
4 changed files with 148 additions and 27 deletions

35
REPORT-36E.md Normal file
View File

@ -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).

View File

@ -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")

View File

@ -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 ────────────────────────────────────────────

View File

@ -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<number, number>();
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 } },
},
},
},
});
});
}