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:
parent
d5c43c8fd8
commit
3fa2b4eb8f
35
REPORT-36E.md
Normal file
35
REPORT-36E.md
Normal 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).
|
||||
@ -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")
|
||||
|
||||
|
||||
@ -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 ────────────────────────────────────────────
|
||||
|
||||
@ -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 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user