refactor(planning): use S53A Option B link (PR→POLine→LotLine→Lot) instead of PR→Lot

All 3 planning query methods (getOverview, getProcurementTimeline,
getPositionsTimelineByCategory) now traverse via poLines relation
instead of the unreliable pr.lots path (which misses lots with
requirementId=NULL). Removes the orphan "PO-XXX — поставки" lane
and the 2nd-pass PO→lots workaround. Lot events are deduplicated
globally per category to prevent N× multiplication across PRs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
louis 2026-04-27 19:52:52 +03:00
parent 4ad965bb03
commit 69aeab3626

View File

@ -110,11 +110,10 @@ export class PlanningOverviewService {
where: { orderId: { in: orderIds }, archived: false, status: { not: 'CANCELLED' } },
select: {
id: true, orderId: true, materialCategory: true, quantity: true, originalQuantity: true,
lots: {
where: { status: { notIn: ['CANCELLED'] } },
poLines: {
select: {
id: true, lotNumber: true, quantity: true,
plannedDate: true, deliveryDate: true, status: true,
id: true,
purchaseOrderId: true,
purchaseOrder: {
select: {
id: true, orderCode: true, status: true,
@ -122,7 +121,15 @@ export class PlanningOverviewService {
},
},
lotLines: {
select: { poLineId: true, quantity: true },
select: {
quantity: true,
lot: {
select: {
id: true, lotNumber: true, quantity: true,
plannedDate: true, deliveryDate: true, status: true,
},
},
},
},
},
},
@ -253,20 +260,16 @@ export class PlanningOverviewService {
const deliveredLotsByMaterial = new Map<string, number>();
for (const pr of prs) {
for (const lot of pr.lots) {
if (lot.status !== 'DELIVERED') continue;
let lotHasRecs = false;
for (const ll of lot.lotLines ?? []) {
if (recs.some(r => r.poLine.id === ll.poLineId)) {
lotHasRecs = true;
break;
for (const polLine of pr.poLines ?? []) {
const hasRecs = recs.some(r => r.poLine.id === polLine.id);
if (hasRecs) continue;
for (const ll of polLine.lotLines ?? []) {
if (ll.lot.status === 'DELIVERED') {
const mat = pr.materialCategory as string;
const prev = deliveredLotsByMaterial.get(mat) ?? 0;
deliveredLotsByMaterial.set(mat, prev + Number(ll.quantity));
}
}
if (!lotHasRecs) {
const mat = pr.materialCategory as string;
const prev = deliveredLotsByMaterial.get(mat) ?? 0;
deliveredLotsByMaterial.set(mat, prev + Number(lot.quantity));
}
}
}
@ -287,10 +290,10 @@ export class PlanningOverviewService {
const poIds = new Set<number>();
let defaultSupplier: string | null = null;
for (const pr of catPrs) {
for (const lot of pr.lots) {
if (lot.purchaseOrder) {
poIds.add(lot.purchaseOrder.id);
if (!defaultSupplier) defaultSupplier = lot.purchaseOrder.supplier?.companyName ?? null;
for (const polLine of pr.poLines ?? []) {
if (polLine.purchaseOrder) {
poIds.add(polLine.purchaseOrder.id);
if (!defaultSupplier) defaultSupplier = polLine.purchaseOrder.supplier?.companyName ?? null;
}
}
}
@ -361,31 +364,38 @@ export class PlanningOverviewService {
label: 'Создан',
});
const seenLotIdsTimeline = new Set<number>();
for (const pr of prs) {
for (const lot of pr.lots) {
if (lot.plannedDate) {
events.push({
type: 'lot_planned',
date: new Date(lot.plannedDate).toISOString(),
label: `Лот ${lot.lotNumber}`,
category: pr.materialCategory,
});
}
if (lot.status === 'DELIVERED' && lot.deliveryDate) {
events.push({
type: 'reception_complete',
date: new Date(lot.deliveryDate).toISOString(),
label: `Получено Лот ${lot.lotNumber} (${Number(lot.quantity)})`,
color: '#389e0d',
category: pr.materialCategory,
});
} else if (lot.deliveryDate) {
events.push({
type: 'lot_delivered',
date: new Date(lot.deliveryDate).toISOString(),
label: `Лот ${lot.lotNumber} доставка`,
category: pr.materialCategory,
});
for (const polLine of pr.poLines ?? []) {
for (const ll of polLine.lotLines ?? []) {
const lot = ll.lot;
if (seenLotIdsTimeline.has(lot.id)) continue;
seenLotIdsTimeline.add(lot.id);
if (lot.plannedDate) {
events.push({
type: 'lot_planned',
date: new Date(lot.plannedDate).toISOString(),
label: `Лот ${lot.lotNumber}`,
category: pr.materialCategory,
});
}
if (lot.status === 'DELIVERED' && lot.deliveryDate) {
events.push({
type: 'reception_complete',
date: new Date(lot.deliveryDate).toISOString(),
label: `Получено Лот ${lot.lotNumber} (${Number(lot.quantity)})`,
color: '#389e0d',
category: pr.materialCategory,
});
} else if (lot.deliveryDate) {
events.push({
type: 'lot_delivered',
date: new Date(lot.deliveryDate).toISOString(),
label: `Лот ${lot.lotNumber} доставка`,
category: pr.materialCategory,
});
}
}
}
}
@ -444,32 +454,33 @@ export class PlanningOverviewService {
where: { orderId, archived: false, status: { not: 'CANCELLED' } },
select: {
id: true, materialCategory: true, createdAt: true, status: true, quantity: true, originalQuantity: true,
lots: {
where: { status: { notIn: ['CANCELLED'] } },
poLines: {
select: {
id: true, lotNumber: true, quantity: true,
plannedDate: true, deliveryDate: true, status: true,
id: true,
purchaseOrderId: true,
purchaseOrder: { select: { orderCode: true, id: true } },
purchaseOrder: { select: { id: true, orderCode: true, status: true } },
lotLines: {
select: {
poLineId: true,
poLine: {
quantity: true,
lot: {
select: {
receptions: {
where: { status: { not: 'CANCELLED' } },
select: { id: true, receivedQty: true, receivedAt: true },
},
id: true, lotNumber: true, quantity: true,
plannedDate: true, deliveryDate: true, status: true,
},
},
},
},
receptions: {
where: { status: { not: 'CANCELLED' as any } },
select: { id: true, receivedQty: true, receivedAt: true, lotId: true },
},
},
},
},
});
const catMap = new Map<string, Array<any>>();
const globalSeenLotIds = new Set<number>();
for (const pr of prs) {
const cat = pr.materialCategory;
const events: any[] = [];
@ -482,66 +493,58 @@ export class PlanningOverviewService {
color: '#1890ff',
});
for (const lot of pr.lots) {
if (lot.plannedDate) {
events.push({
id: `lot-${lot.id}-planned`,
type: 'lot_planned',
date: new Date(lot.plannedDate).toISOString(),
label: `Лот ${lot.lotNumber}`,
color: '#faad14',
poNumber: lot.purchaseOrder?.orderCode,
poId: lot.purchaseOrder?.id,
quantity: Number(lot.quantity),
});
}
for (const polLine of pr.poLines) {
const poCode = polLine.purchaseOrder?.orderCode;
const poId = polLine.purchaseOrder?.id;
if (lot.purchaseOrderId) {
events.push({
id: `lot-${lot.id}-ordered`,
type: 'lot_ordered',
date: new Date(lot.plannedDate ?? pr.createdAt).toISOString(),
label: `${lot.purchaseOrder?.orderCode ?? 'PO'} Лот ${lot.lotNumber}`,
color: '#722ed1',
poNumber: lot.purchaseOrder?.orderCode,
poId: lot.purchaseOrder?.id,
});
}
for (const ll of polLine.lotLines) {
const lot = ll.lot;
if (globalSeenLotIds.has(lot.id)) continue;
globalSeenLotIds.add(lot.id);
if (lot.status === 'DELIVERED' && lot.deliveryDate) {
events.push({
id: `lot-${lot.id}-delivered`,
type: 'reception_complete',
date: new Date(lot.deliveryDate).toISOString(),
label: `Получено Лот ${lot.lotNumber} (${Number(lot.quantity)})`,
color: '#389e0d',
poNumber: lot.purchaseOrder?.orderCode,
poId: lot.purchaseOrder?.id,
quantity: Number(lot.quantity),
});
} else if (lot.deliveryDate) {
events.push({
id: `lot-${lot.id}-delivered`,
type: 'lot_delivered',
date: new Date(lot.deliveryDate).toISOString(),
label: `Доставка Лот ${lot.lotNumber}`,
color: '#52c41a',
poNumber: lot.purchaseOrder?.orderCode,
poId: lot.purchaseOrder?.id,
});
}
for (const ll of lot.lotLines) {
for (const rec of ll.poLine.receptions) {
if (lot.plannedDate && lot.status !== 'DELIVERED' && lot.status !== 'CANCELLED') {
events.push({
id: `rec-${rec.id}`,
type: 'reception',
date: new Date(rec.receivedAt).toISOString(),
label: `Приёмка ${Number(rec.receivedQty)}`,
color: '#52c41a',
quantity: Number(rec.receivedQty),
id: `lot-${lot.id}-planned`,
type: 'lot_planned',
date: new Date(lot.plannedDate).toISOString(),
label: `Лот ${lot.lotNumber}`,
color: '#faad14',
poNumber: poCode, poId,
quantity: Number(lot.quantity),
});
}
if (lot.status === 'DELIVERED' && lot.deliveryDate) {
events.push({
id: `lot-${lot.id}-delivered`,
type: 'reception_complete',
date: new Date(lot.deliveryDate).toISOString(),
label: `Получено Лот ${lot.lotNumber} (${Number(lot.quantity)})`,
color: '#389e0d',
poNumber: poCode, poId,
quantity: Number(lot.quantity),
});
} else if (lot.deliveryDate) {
events.push({
id: `lot-${lot.id}-delivered`,
type: 'lot_delivered',
date: new Date(lot.deliveryDate).toISOString(),
label: `Доставка Лот ${lot.lotNumber}`,
color: '#52c41a',
poNumber: poCode, poId,
});
}
}
for (const rec of polLine.receptions) {
events.push({
id: `rec-${rec.id}`,
type: 'reception',
date: new Date(rec.receivedAt).toISOString(),
label: `Приёмка ${Number(rec.receivedQty)}`,
color: '#52c41a',
quantity: Number(rec.receivedQty),
});
}
}
@ -550,33 +553,18 @@ export class PlanningOverviewService {
catMap.set(cat, [...existing, ...events]);
}
// PO-level events (independent of lots) + PO lots without PR link
// PO-level events (creation + expected delivery) — no more lot duplication pass
const pos = await this.prisma.purchaseOrder.findMany({
where: { clientOrderId: orderId, status: { notIn: ['CANCELLED', 'DRAFT'] } },
select: {
id: true, orderCode: true, status: true, createdAt: true,
expectedDeliveryDate: true, actualDeliveryDate: true,
expectedDeliveryDate: true,
lines: { select: { material: true } },
lots: {
where: { status: { notIn: ['CANCELLED'] } },
select: {
id: true, lotNumber: true, quantity: true,
plannedDate: true, deliveryDate: true, status: true,
requirementId: true,
},
},
},
});
const prLinkedLotIds = new Set<number>();
for (const pr of prs) {
for (const lot of pr.lots) {
prLinkedLotIds.add(lot.id);
}
}
for (const po of pos) {
const categories = [...new Set(po.lines.map(l => l.material))];
const categories = Array.from(new Set(po.lines.map(l => l.material)));
for (const cat of categories) {
const poEvents: any[] = [];
poEvents.push({
@ -600,45 +588,6 @@ export class PlanningOverviewService {
});
}
for (const lot of po.lots) {
if (prLinkedLotIds.has(lot.id)) continue;
if (lot.plannedDate) {
poEvents.push({
id: `lot-${lot.id}-planned`,
type: 'lot_planned',
date: new Date(lot.plannedDate).toISOString(),
label: `Лот ${lot.lotNumber}${po.orderCode}`,
color: '#faad14',
poNumber: po.orderCode,
poId: po.id,
quantity: Number(lot.quantity),
});
}
if (lot.status === 'DELIVERED' && lot.deliveryDate) {
poEvents.push({
id: `lot-${lot.id}-delivered`,
type: 'reception_complete',
date: new Date(lot.deliveryDate).toISOString(),
label: `Получено Лот ${lot.lotNumber} (${Number(lot.quantity)}) — ${po.orderCode}`,
color: '#389e0d',
poNumber: po.orderCode,
poId: po.id,
quantity: Number(lot.quantity),
});
} else if (lot.deliveryDate) {
poEvents.push({
id: `lot-${lot.id}-delivered`,
type: 'lot_delivered',
date: new Date(lot.deliveryDate).toISOString(),
label: `Доставка Лот ${lot.lotNumber}${po.orderCode}`,
color: '#52c41a',
poNumber: po.orderCode,
poId: po.id,
});
}
}
const existing = catMap.get(cat) ?? [];
catMap.set(cat, [...existing, ...poEvents]);
}
@ -680,29 +629,34 @@ export class PlanningOverviewService {
let lastReceptionDate: string | null = null;
let lastPlannedOrDelivery: string | null = null;
for (const lot of pr.lots) {
if (lot.plannedDate) physicalDates.push(new Date(lot.plannedDate).toISOString());
if (lot.deliveryDate) physicalDates.push(new Date(lot.deliveryDate).toISOString());
const critDate = lot.deliveryDate ?? lot.plannedDate;
if (critDate) {
const iso = new Date(critDate).toISOString();
if (!lastPlannedOrDelivery || iso > lastPlannedOrDelivery) lastPlannedOrDelivery = iso;
}
let lotHasReceptions = false;
for (const ll of lot.lotLines) {
for (const rec of ll.poLine.receptions) {
qtyReceived += Number(rec.receivedQty);
physicalDates.push(new Date(rec.receivedAt).toISOString());
const recIso = new Date(rec.receivedAt).toISOString();
if (!lastReceptionDate || recIso > lastReceptionDate) lastReceptionDate = recIso;
lotHasReceptions = true;
for (const polLine of pr.poLines) {
for (const ll of polLine.lotLines) {
const lot = ll.lot;
if (lot.plannedDate) physicalDates.push(new Date(lot.plannedDate).toISOString());
if (lot.deliveryDate) physicalDates.push(new Date(lot.deliveryDate).toISOString());
const critDate = lot.deliveryDate ?? lot.plannedDate;
if (critDate) {
const iso = new Date(critDate).toISOString();
if (!lastPlannedOrDelivery || iso > lastPlannedOrDelivery) lastPlannedOrDelivery = iso;
}
}
if (!lotHasReceptions && lot.status === 'DELIVERED') {
qtyReceived += Number(lot.quantity);
if (lot.deliveryDate) {
const delIso = new Date(lot.deliveryDate).toISOString();
if (!lastReceptionDate || delIso > lastReceptionDate) lastReceptionDate = delIso;
for (const rec of polLine.receptions) {
qtyReceived += Number(rec.receivedQty);
physicalDates.push(new Date(rec.receivedAt).toISOString());
const recIso = new Date(rec.receivedAt).toISOString();
if (!lastReceptionDate || recIso > lastReceptionDate) lastReceptionDate = recIso;
}
if (polLine.receptions.length === 0) {
for (const ll of polLine.lotLines) {
if (ll.lot.status === 'DELIVERED') {
qtyReceived += Number(ll.quantity);
if (ll.lot.deliveryDate) {
const delIso = new Date(ll.lot.deliveryDate).toISOString();
if (!lastReceptionDate || delIso > lastReceptionDate) lastReceptionDate = delIso;
}
}
}
}
}
@ -790,25 +744,26 @@ export class PlanningOverviewService {
where: { orderId, materialCategory: category, archived: false, status: { not: 'CANCELLED' } },
select: {
id: true, description: true, quantity: true, originalQuantity: true, unit: true, status: true, createdAt: true,
lots: {
where: { status: { notIn: ['CANCELLED'] } },
poLines: {
select: {
id: true, lotNumber: true, quantity: true,
plannedDate: true, deliveryDate: true, status: true,
id: true,
purchaseOrderId: true,
purchaseOrder: { select: { orderCode: true, id: true } },
purchaseOrder: { select: { id: true, orderCode: true, status: true } },
lotLines: {
select: {
poLine: {
quantity: true,
lot: {
select: {
receptions: {
where: { status: { not: 'CANCELLED' as any } },
select: { id: true, receivedQty: true, receivedAt: true },
},
id: true, lotNumber: true, quantity: true,
plannedDate: true, deliveryDate: true, status: true,
},
},
},
},
receptions: {
where: { status: { not: 'CANCELLED' as any } },
select: { id: true, receivedQty: true, receivedAt: true, lotId: true },
},
},
},
},
@ -819,7 +774,7 @@ export class PlanningOverviewService {
orderId,
category,
positions: prs.map(pr => {
const qtyReceived = this.computeReceivedQty(pr);
const qtyReceived = this.computeReceivedQtyV2(pr);
return {
id: pr.id,
description: pr.description,
@ -827,30 +782,30 @@ export class PlanningOverviewService {
unit: pr.unit,
status: pr.status,
qtyReceived,
events: this.buildPositionEvents(pr, qtyReceived),
events: this.buildPositionEventsV2(pr, qtyReceived),
};
}),
};
}
private computeReceivedQty(pr: any): number {
private computeReceivedQtyV2(pr: any): number {
let total = 0;
for (const lot of pr.lots) {
let lotHasReceptions = false;
for (const ll of lot.lotLines) {
for (const rec of ll.poLine.receptions) {
total += Number(rec.receivedQty);
lotHasReceptions = true;
}
for (const polLine of pr.poLines) {
for (const rec of polLine.receptions) {
total += Number(rec.receivedQty);
}
if (!lotHasReceptions && lot.status === 'DELIVERED') {
total += Number(lot.quantity);
if (polLine.receptions.length === 0) {
for (const ll of polLine.lotLines) {
if (ll.lot.status === 'DELIVERED') {
total += Number(ll.quantity);
}
}
}
}
return total;
}
private buildPositionEvents(pr: any, qtyReceived: number): Array<any> {
private buildPositionEventsV2(pr: any, qtyReceived: number): Array<any> {
const events: any[] = [];
const qtyOrdered = Number(pr.originalQuantity ?? pr.quantity);
@ -862,85 +817,65 @@ export class PlanningOverviewService {
color: '#1890ff',
});
for (const lot of pr.lots) {
if (lot.plannedDate) {
events.push({
id: `lot-${lot.id}-planned`,
type: 'lot_planned',
date: new Date(lot.plannedDate).toISOString(),
label: `Лот ${lot.lotNumber}`,
color: '#faad14',
poNumber: lot.purchaseOrder?.orderCode,
poId: lot.purchaseOrder?.id,
quantity: Number(lot.quantity),
});
}
if (lot.purchaseOrderId) {
events.push({
id: `lot-${lot.id}-ordered`,
type: 'lot_ordered',
date: new Date(lot.plannedDate ?? pr.createdAt).toISOString(),
label: `${lot.purchaseOrder?.orderCode ?? 'PO'} Лот ${lot.lotNumber}`,
color: '#722ed1',
poNumber: lot.purchaseOrder?.orderCode,
poId: lot.purchaseOrder?.id,
});
}
let cumulativeReceived = 0;
const allReceptions: Array<{ id: number; receivedQty: any; receivedAt: Date }> = [];
for (const ll of lot.lotLines) {
allReceptions.push(...ll.poLine.receptions);
const seenLotIds = new Set<number>();
for (const polLine of pr.poLines) {
const poCode = polLine.purchaseOrder?.orderCode;
const poId = polLine.purchaseOrder?.id;
for (const ll of polLine.lotLines) {
const lot = ll.lot;
if (seenLotIds.has(lot.id)) continue;
seenLotIds.add(lot.id);
if (lot.plannedDate && lot.status !== 'DELIVERED' && lot.status !== 'CANCELLED') {
events.push({
id: `lot-${lot.id}-planned-${polLine.id}`,
type: 'forecast_delivery',
date: new Date(lot.plannedDate).toISOString(),
label: `Лот ${lot.lotNumber} (${Number(ll.quantity)} ${pr.unit})`,
color: '#fa8c16',
poNumber: poCode, poId,
quantity: Number(ll.quantity),
});
}
}
if (allReceptions.length > 0) {
if (lot.deliveryDate) {
events.push({
id: `lot-${lot.id}-delivered`,
type: 'lot_delivered',
date: new Date(lot.deliveryDate).toISOString(),
label: `Доставка Лот ${lot.lotNumber}`,
color: '#52c41a',
poNumber: lot.purchaseOrder?.orderCode,
poId: lot.purchaseOrder?.id,
});
}
allReceptions.sort((a, b) => new Date(a.receivedAt).getTime() - new Date(b.receivedAt).getTime());
for (const rec of allReceptions) {
cumulativeReceived += Number(rec.receivedQty);
const isComplete = cumulativeReceived >= qtyOrdered && qtyOrdered > 0;
events.push({
id: `rec-${rec.id}`,
type: isComplete ? 'reception_complete' : 'reception_partial',
date: new Date(rec.receivedAt).toISOString(),
label: isComplete
? `Получено полностью (${Number(rec.receivedQty)})`
: `Приёмка ${Number(rec.receivedQty)} (${Math.round(cumulativeReceived / qtyOrdered * 100)}%)`,
color: isComplete ? '#52c41a' : '#52c41a',
quantity: Number(rec.receivedQty),
});
}
} else if (lot.status === 'DELIVERED' && lot.deliveryDate) {
const sortedReceptions = [...polLine.receptions].sort(
(a: any, b: any) => new Date(a.receivedAt).getTime() - new Date(b.receivedAt).getTime()
);
let cumulative = 0;
for (const rec of sortedReceptions) {
cumulative += Number(rec.receivedQty);
const isComplete = cumulative >= qtyOrdered && qtyOrdered > 0;
events.push({
id: `lot-${lot.id}-delivered`,
type: 'reception_complete',
date: new Date(lot.deliveryDate).toISOString(),
label: `Получено Лот ${lot.lotNumber} (${Number(lot.quantity)})`,
color: '#389e0d',
poNumber: lot.purchaseOrder?.orderCode,
poId: lot.purchaseOrder?.id,
quantity: Number(lot.quantity),
});
} else if (lot.deliveryDate) {
events.push({
id: `lot-${lot.id}-delivered`,
type: 'lot_delivered',
date: new Date(lot.deliveryDate).toISOString(),
label: `Доставка Лот ${lot.lotNumber}`,
id: `rec-${rec.id}`,
type: isComplete ? 'reception_complete' : 'reception_partial',
date: new Date(rec.receivedAt).toISOString(),
label: isComplete
? `Получено полностью (${Number(rec.receivedQty)} ${pr.unit})`
: `Приёмка ${Number(rec.receivedQty)} ${pr.unit} (${Math.round((cumulative / qtyOrdered) * 100)}%)`,
color: '#52c41a',
poNumber: lot.purchaseOrder?.orderCode,
poId: lot.purchaseOrder?.id,
quantity: Number(rec.receivedQty),
poNumber: poCode, poId,
});
}
for (const ll of polLine.lotLines) {
const lot = ll.lot;
const hasRecForThisLot = polLine.receptions.some((r: any) => r.lotId === lot.id);
if (lot.status === 'DELIVERED' && !hasRecForThisLot && lot.deliveryDate) {
events.push({
id: `lot-${lot.id}-delivered-${polLine.id}`,
type: 'reception_complete',
date: new Date(lot.deliveryDate).toISOString(),
label: `Получено Лот ${lot.lotNumber} (${Number(ll.quantity)} ${pr.unit})`,
color: '#389e0d',
poNumber: poCode, poId,
quantity: Number(ll.quantity),
});
}
}
}
events.sort((a, b) => a.date.localeCompare(b.date));