diff --git a/src/modules/planning/planning-overview.service.ts b/src/modules/planning/planning-overview.service.ts index 5987963..3a06622 100644 --- a/src/modules/planning/planning-overview.service.ts +++ b/src/modules/planning/planning-overview.service.ts @@ -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(); 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(); 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(); 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>(); + const globalSeenLotIds = new Set(); 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(); - 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 { + private buildPositionEventsV2(pr: any, qtyReceived: number): Array { 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(); + + 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));