From 15116807ce150b00a6e80e36bbddfab12ce64576 Mon Sep 17 00:00:00 2001 From: jason Date: Sun, 15 Mar 2026 15:45:29 -0500 Subject: [PATCH] demand planning --- AGENTS.md | 5 +- CHANGELOG.md | 6 +- INSTRUCTIONS.md | 5 +- README.md | 10 +- ROADMAP.md | 29 +- client/src/lib/api.ts | 4 + client/src/modules/sales/SalesDetailPage.tsx | 129 +++++- server/src/modules/sales/planning.ts | 461 +++++++++++++++++++ server/src/modules/sales/router.ts | 15 + server/tests/sales-planning.test.ts | 137 ++++++ shared/src/sales/types.ts | 73 +++ 11 files changed, 859 insertions(+), 15 deletions(-) create mode 100644 server/src/modules/sales/planning.ts create mode 100644 server/tests/sales-planning.test.ts diff --git a/AGENTS.md b/AGENTS.md index c2369c1..6a585ea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,7 @@ MRP Codex is a modular Manufacturing Resource Planning platform intended to be a - projects with customer/commercial/shipment linkage, owners, due dates, notes, and attachments - manufacturing work orders with project linkage, station master data, item operation templates, auto-generated work-order operations, and attachments - planning gantt timelines backed by live project and manufacturing schedule data +- sales-order demand planning with multi-level BOM explosion, stock/open-supply netting, and build/buy recommendations - admin diagnostics with runtime footprint, record counts, and persisted audit-trail visibility - admin user management with account creation, activation, role assignment, and role-permission editing - CRM/shipping audit coverage and startup validation surfaced through the admin diagnostics workflow @@ -125,8 +126,8 @@ If implementation changes invalidate those docs, update them in the same change Near-term priorities are: -1. Better user and session visibility for operational admins -2. Safer destructive-action confirmations and recovery messaging +1. Convert demand-planning recommendations into work orders and purchase orders +2. Shared shortage and readiness rollups across planning, purchasing, manufacturing, and projects When adding new modules, preserve the ability to extend the system without refactoring the existing app shell. diff --git a/CHANGELOG.md b/CHANGELOG.md index 277579b..54cde5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ This file is the running release and change log for MRP Codex. Keep it updated w ### Added +- Sales-order demand planning with multi-level BOM explosion across manufactured and assembly children +- Netting of sales-order demand against available stock, active reservations, open work orders, and open purchase orders +- Build and buy recommendations surfaced directly on sales-order detail pages - Support-log capture for startup warnings, HTTP failures, and server errors, surfaced through admin diagnostics - Exportable support bundles that now include backup guidance and recent support logs for support handoff - Deeper startup diagnostics with writable-path checks, database-file validation, startup timing, and pass/warn/fail rollups @@ -52,8 +55,9 @@ This file is the running release and change log for MRP Codex. Keep it updated w - Company settings now acts as the staging area for admin surfaces while user administration lives on its own dedicated page instead of inside the company-profile form - Admin diagnostics now includes startup-readiness status alongside runtime footprint and recent audit activity - Admin diagnostics now includes structured startup summaries and a dedicated support-log view for faster debugging +- Roadmap and project docs now treat demand planning and supply generation as its own phase ahead of the deferred admin QOL work - Roadmap and project docs now treat backup verification checklist and restore drill guidance as the next active priority after the backup/support-tooling slice -- Roadmap and project docs now treat user/session visibility and destructive-action safety as the next active priorities after the diagnostics/support-debugging slices +- Roadmap and project docs now treat recommendation-to-WO/PO generation and cross-module shortage rollups as the next active priorities after the first demand-planning slice ## 2026-03-15 diff --git a/INSTRUCTIONS.md b/INSTRUCTIONS.md index 996095d..56b32f5 100644 --- a/INSTRUCTIONS.md +++ b/INSTRUCTIONS.md @@ -27,6 +27,7 @@ This repository implements the platform foundation milestone: - projects with customer/commercial/shipment linkage, owners, due dates, notes, attachments, and dashboard visibility - manufacturing work orders with project linkage, station master data, item operation templates, auto-generated work-order operations, attachments, and dashboard visibility - planning gantt timelines backed by live project and manufacturing schedule data +- sales-order demand planning with multi-level BOM explosion, net stock/open-supply coverage, and build/buy recommendations - admin diagnostics with runtime footprint, storage visibility, record counts, and recent audit activity - admin user management with account creation, activation, role assignment, and role-permission editing - CRM/shipping audit coverage and startup validation surfaced through diagnostics @@ -68,5 +69,5 @@ This repository implements the platform foundation milestone: ## Next roadmap candidates -- better user and session visibility for operational admins -- safer destructive-action confirmations and recovery messaging +- convert demand-planning recommendations into work orders and purchase orders +- shared shortage and readiness rollups across planning, purchasing, manufacturing, and projects diff --git a/README.md b/README.md index 98b1508..d4e6e81 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ Current foundation scope includes: - projects with customer/commercial/shipment linkage, owners, due dates, notes, and attachments - manufacturing work orders with project linkage, station-based operation templates, material issue posting, completion posting, and work-order attachments - planning gantt timelines with live project and manufacturing schedule data +- sales-order demand planning with multi-level BOM explosion, stock/open-supply netting, and build/buy recommendations - admin diagnostics with runtime footprint, record counts, and recent audit-trail visibility - admin user management with account creation, activation, role assignment, and role-permission editing - CRM and shipping audit coverage plus startup validation surfaced through the admin diagnostics page @@ -53,8 +54,8 @@ Current completed foundation areas: Near-term priorities: -1. Better user and session visibility for operational admins -2. Safer destructive-action confirmations and recovery messaging +1. Convert demand-planning recommendations into work orders and purchase orders +2. Shared shortage and readiness rollups across planning, purchasing, manufacturing, and projects Revisit / deferred items: @@ -351,6 +352,7 @@ The current admin operations slice supports: - persisted audit events for core settings, inventory, purchasing, sales, project, and manufacturing write actions - an admin diagnostics page for runtime footprint, data/storage path visibility, key record counts, and recent audit activity +- a sales-order demand-planning view with multi-level BOM netting and build/buy recommendations - a dedicated user-management page for account creation, activation, role assignment, password reset-style updates, and role-permission administration - CRM customer/vendor changes and shipping mutations now flow into the shared audit trail - startup validation now checks storage paths, writable storage readiness, database connectivity, client bundle readiness, Chromium availability, and risky production defaults during server boot @@ -361,8 +363,8 @@ The current admin operations slice supports: Current follow-up direction: -- better user and session visibility for operational admins -- safer destructive-action confirmations and recovery messaging +- convert demand-planning recommendations into work orders and purchase orders +- shared shortage and readiness rollups across planning, purchasing, manufacturing, and projects ## UI Notes diff --git a/ROADMAP.md b/ROADMAP.md index f783f35..bc2df25 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -60,6 +60,7 @@ MRP Codex is being built as a streamlined, modular manufacturing resource planni - Theme persistence fixes and denser responsive workspace layouts - Full-site density normalization pass across active CRM, inventory, settings, dashboard, and login screens - Live planning gantt timelines driven by project and manufacturing data +- Sales-order demand planning with multi-level BOM explosion, stock/open-supply netting, and build/buy recommendations - Multi-stage Docker packaging and migration-aware entrypoint - Docker image validated locally with successful app startup and login flow - Core project documentation in `README.md`, `INSTRUCTIONS.md`, and `STRUCTURE.md` @@ -253,7 +254,29 @@ QOL subfeatures: - Better mobile and tablet behavior for shop-floor lookups - Faster filtering by project, customer, work center, and status -### Phase 8: Security, audit, and operations maturity +### Phase 8: Demand planning and supply generation + +Foundation slice shipped: + +- Sales-order demand planning from approved or active demand records +- Multi-level BOM explosion from sales-order lines through manufactured and assembly children +- Netting against available stock, active reservations, open work orders, and open purchase orders +- Build and buy recommendations surfaced directly from the sales-order workflow + +- Shared MRP demand engine across sales, inventory, purchasing, manufacturing, projects, and planning +- Planned work-order and purchase-order recommendation generation +- Coverage, shortage, and lateness rollups from customer demand down through supply layers +- Cross-module shortage visibility on sales orders, projects, work orders, purchasing, and dashboard widgets + +QOL subfeatures: + +- One-click conversion of planning recommendations into work orders and purchase orders +- Better shortage and substitute-part guidance during planning review +- Saved planning views by customer, project, item family, and shortage state +- Planner-focused drilldowns from demand source to buy/build action without re-keying data +- More explicit pegging between parent demand and generated supply actions + +### Phase 9: Security, audit, and operations maturity Foundation slice shipped: @@ -297,5 +320,5 @@ QOL subfeatures: ## Near-term priority order -1. Better user and session visibility for operational admins -2. Safer destructive-action confirmations and recovery messaging +1. Convert demand-planning recommendations into work orders and purchase orders +2. Shared shortage and readiness rollups across planning, purchasing, manufacturing, and projects diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 99acda6..6ee59ed 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -70,6 +70,7 @@ import type { SalesCustomerOptionDto, SalesDocumentDetailDto, SalesDocumentInput, + SalesOrderPlanningDto, SalesDocumentRevisionDto, SalesDocumentStatus, SalesDocumentSummaryDto, @@ -631,6 +632,9 @@ export const api = { getSalesOrder(token: string, orderId: string) { return request(`/api/v1/sales/orders/${orderId}`, undefined, token); }, + getSalesOrderPlanning(token: string, orderId: string) { + return request(`/api/v1/sales/orders/${orderId}/planning`, undefined, token); + }, createSalesOrder(token: string, payload: SalesDocumentInput) { return request("/api/v1/sales/orders", { method: "POST", body: JSON.stringify(payload) }, token); }, diff --git a/client/src/modules/sales/SalesDetailPage.tsx b/client/src/modules/sales/SalesDetailPage.tsx index ac81815..7b7dc33 100644 --- a/client/src/modules/sales/SalesDetailPage.tsx +++ b/client/src/modules/sales/SalesDetailPage.tsx @@ -1,5 +1,5 @@ import { permissions } from "@mrp/shared"; -import type { SalesDocumentDetailDto, SalesDocumentStatus } from "@mrp/shared/dist/sales/types.js"; +import type { SalesDocumentDetailDto, SalesDocumentStatus, SalesOrderPlanningDto, SalesOrderPlanningNodeDto } from "@mrp/shared/dist/sales/types.js"; import type { ShipmentSummaryDto } from "@mrp/shared/dist/shipping/types.js"; import { useEffect, useState } from "react"; import { Link, useNavigate, useParams } from "react-router-dom"; @@ -10,6 +10,39 @@ import { salesConfigs, salesStatusOptions, type SalesDocumentEntity } from "./co import { SalesStatusBadge } from "./SalesStatusBadge"; import { ShipmentStatusBadge } from "../shipping/ShipmentStatusBadge"; +function PlanningNodeCard({ node }: { node: SalesOrderPlanningNodeDto }) { + return ( +
+
+
+
+ {node.itemSku} {node.itemName} +
+
+ Demand {node.grossDemand} {node.unitOfMeasure} · Type {node.itemType} + {node.bomQuantityPerParent !== null ? ` · Qty/parent ${node.bomQuantityPerParent}` : ""} +
+
+
+
Stock {node.supplyFromStock}
+
Open WO {node.supplyFromOpenWorkOrders}
+
Open PO {node.supplyFromOpenPurchaseOrders}
+
Build {node.recommendedBuildQuantity}
+
Buy {node.recommendedPurchaseQuantity}
+ {node.uncoveredQuantity > 0 ?
Uncovered {node.uncoveredQuantity}
: null} +
+
+ {node.children.length > 0 ? ( +
+ {node.children.map((child) => ( + + ))} +
+ ) : null} +
+ ); +} + export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) { const { token, user } = useAuth(); const navigate = useNavigate(); @@ -23,6 +56,7 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) { const [isOpeningPdf, setIsOpeningPdf] = useState(false); const [isApproving, setIsApproving] = useState(false); const [shipments, setShipments] = useState([]); + const [planning, setPlanning] = useState(null); const canManage = user?.permissions.includes(permissions.salesWrite) ?? false; const canManageShipping = user?.permissions.includes(permissions.shippingWrite) ?? false; @@ -34,9 +68,11 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) { } const loader = entity === "quote" ? api.getQuote(token, documentId) : api.getSalesOrder(token, documentId); - loader - .then((nextDocument) => { + const planningLoader = entity === "order" ? api.getSalesOrderPlanning(token, documentId) : Promise.resolve(null); + Promise.all([loader, planningLoader]) + .then(([nextDocument, nextPlanning]) => { setDocument(nextDocument); + setPlanning(nextPlanning); setStatus(`${config.singularLabel} loaded.`); if (entity === "order" && canReadShipping) { api.getShipments(token, { salesOrderId: nextDocument.id }).then(setShipments).catch(() => setShipments([])); @@ -352,6 +388,93 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) { )} + {entity === "order" && planning ? ( +
+
+
+

Demand Planning

+

Net build and buy requirements

+

+ Sales-order demand is netted against available stock, active reservations, open work orders, and open purchase orders before new build or buy quantities are recommended. +

+
+
+
Generated {new Date(planning.generatedAt).toLocaleString()}
+
Status {planning.status}
+
+
+
+
+

Build Recommendations

+
{planning.summary.totalBuildQuantity}
+
{planning.summary.buildRecommendationCount} items
+
+
+

Purchase Recommendations

+
{planning.summary.totalPurchaseQuantity}
+
{planning.summary.purchaseRecommendationCount} items
+
+
+

Uncovered

+
{planning.summary.totalUncoveredQuantity}
+
{planning.summary.uncoveredItemCount} items
+
+
+

Planned Items

+
{planning.summary.itemCount}
+
{planning.summary.lineCount} sales lines
+
+
+
+ + + + + + + + + + + + + + + {planning.items.map((item) => ( + + + + + + + + + + + ))} + +
ItemGrossAvailableOpen WOOpen POBuildBuyUncovered
+
{item.itemSku}
+
{item.itemName}
+
{item.grossDemand}{item.availableQuantity}{item.openWorkOrderSupply}{item.openPurchaseSupply}{item.recommendedBuildQuantity}{item.recommendedPurchaseQuantity}{item.uncoveredQuantity}
+
+
+ {planning.lines.map((line) => ( +
+
+
+ {line.itemSku} {line.itemName} +
+
+ Sales-order line demand: {line.quantity} {line.unitOfMeasure} +
+
+ +
+ ))} +
+
+ ) : null} {entity === "order" && canReadShipping ? (
diff --git a/server/src/modules/sales/planning.ts b/server/src/modules/sales/planning.ts new file mode 100644 index 0000000..69ca5ac --- /dev/null +++ b/server/src/modules/sales/planning.ts @@ -0,0 +1,461 @@ +import type { SalesDocumentStatus, SalesOrderPlanningDto, SalesOrderPlanningItemDto, SalesOrderPlanningNodeDto } from "@mrp/shared/dist/sales/types.js"; +import { prisma } from "../../lib/prisma.js"; + +type PlanningItemSnapshot = { + id: string; + sku: string; + name: string; + type: string; + unitOfMeasure: string; + isPurchasable: boolean; + bomLines: Array<{ + componentItemId: string; + quantity: number; + unitOfMeasure: string; + }>; +}; + +type PlanningLineSnapshot = { + id: string; + itemId: string; + itemSku: string; + itemName: string; + quantity: number; + unitOfMeasure: string; +}; + +type PlanningSupplySnapshot = { + onHandQuantity: number; + reservedQuantity: number; + availableQuantity: number; + openWorkOrderSupply: number; + openPurchaseSupply: number; +}; + +export type SalesOrderPlanningSnapshot = { + orderId: string; + documentNumber: string; + status: SalesDocumentStatus; + lines: PlanningLineSnapshot[]; + itemsById: Record; + supplyByItemId: Record; +}; + +type MutableSupplyState = { + remainingAvailableQuantity: number; + remainingOpenWorkOrderSupply: number; + remainingOpenPurchaseSupply: number; +}; + +function createEmptySupplySnapshot(): PlanningSupplySnapshot { + return { + onHandQuantity: 0, + reservedQuantity: 0, + availableQuantity: 0, + openWorkOrderSupply: 0, + openPurchaseSupply: 0, + }; +} + +function createMutableSupplyState(snapshot: PlanningSupplySnapshot): MutableSupplyState { + return { + remainingAvailableQuantity: Math.max(snapshot.availableQuantity, 0), + remainingOpenWorkOrderSupply: Math.max(snapshot.openWorkOrderSupply, 0), + remainingOpenPurchaseSupply: Math.max(snapshot.openPurchaseSupply, 0), + }; +} + +function isBuildItem(type: string) { + return type === "ASSEMBLY" || type === "MANUFACTURED"; +} + +function shouldBuyItem(item: PlanningItemSnapshot) { + return item.type === "PURCHASED" || item.isPurchasable; +} + +export function buildSalesOrderPlanning(snapshot: SalesOrderPlanningSnapshot): SalesOrderPlanningDto { + const mutableSupplyByItemId = new Map(); + const aggregatedByItemId = new Map(); + + function getMutableSupply(itemId: string) { + const existing = mutableSupplyByItemId.get(itemId); + if (existing) { + return existing; + } + + const next = createMutableSupplyState(snapshot.supplyByItemId[itemId] ?? createEmptySupplySnapshot()); + mutableSupplyByItemId.set(itemId, next); + return next; + } + + function getOrCreateAggregate(item: PlanningItemSnapshot) { + const existing = aggregatedByItemId.get(item.id); + if (existing) { + return existing; + } + + const supply = snapshot.supplyByItemId[item.id] ?? createEmptySupplySnapshot(); + const created: SalesOrderPlanningItemDto = { + itemId: item.id, + itemSku: item.sku, + itemName: item.name, + itemType: item.type, + unitOfMeasure: item.unitOfMeasure as SalesOrderPlanningItemDto["unitOfMeasure"], + grossDemand: 0, + onHandQuantity: supply.onHandQuantity, + reservedQuantity: supply.reservedQuantity, + availableQuantity: supply.availableQuantity, + openWorkOrderSupply: supply.openWorkOrderSupply, + openPurchaseSupply: supply.openPurchaseSupply, + supplyFromStock: 0, + supplyFromOpenWorkOrders: 0, + supplyFromOpenPurchaseOrders: 0, + recommendedBuildQuantity: 0, + recommendedPurchaseQuantity: 0, + uncoveredQuantity: 0, + }; + aggregatedByItemId.set(item.id, created); + return created; + } + + function planItemDemand(itemId: string, grossDemand: number, level: number, bomQuantityPerParent: number | null, ancestry: Set): SalesOrderPlanningNodeDto { + const item = snapshot.itemsById[itemId]; + if (!item) { + return { + itemId, + itemSku: "UNKNOWN", + itemName: "Missing item", + itemType: "UNKNOWN", + unitOfMeasure: "EA", + level, + grossDemand, + availableBefore: 0, + availableAfter: 0, + supplyFromStock: 0, + openWorkOrderSupply: 0, + openPurchaseSupply: 0, + supplyFromOpenWorkOrders: 0, + supplyFromOpenPurchaseOrders: 0, + recommendedBuildQuantity: 0, + recommendedPurchaseQuantity: 0, + uncoveredQuantity: grossDemand, + bomQuantityPerParent, + children: [], + }; + } + + const aggregate = getOrCreateAggregate(item); + const mutableSupply = getMutableSupply(itemId); + const availableBefore = mutableSupply.remainingAvailableQuantity; + const openWorkOrderSupply = mutableSupply.remainingOpenWorkOrderSupply; + const openPurchaseSupply = mutableSupply.remainingOpenPurchaseSupply; + + let remainingDemand = grossDemand; + const supplyFromStock = Math.min(availableBefore, remainingDemand); + mutableSupply.remainingAvailableQuantity -= supplyFromStock; + remainingDemand -= supplyFromStock; + + let supplyFromOpenWorkOrders = 0; + let supplyFromOpenPurchaseOrders = 0; + let recommendedBuildQuantity = 0; + let recommendedPurchaseQuantity = 0; + let uncoveredQuantity = 0; + + if (isBuildItem(item.type)) { + supplyFromOpenWorkOrders = Math.min(mutableSupply.remainingOpenWorkOrderSupply, remainingDemand); + mutableSupply.remainingOpenWorkOrderSupply -= supplyFromOpenWorkOrders; + remainingDemand -= supplyFromOpenWorkOrders; + recommendedBuildQuantity = remainingDemand; + } else if (shouldBuyItem(item)) { + supplyFromOpenPurchaseOrders = Math.min(mutableSupply.remainingOpenPurchaseSupply, remainingDemand); + mutableSupply.remainingOpenPurchaseSupply -= supplyFromOpenPurchaseOrders; + remainingDemand -= supplyFromOpenPurchaseOrders; + recommendedPurchaseQuantity = remainingDemand; + } else { + uncoveredQuantity = remainingDemand; + } + + aggregate.grossDemand += grossDemand; + aggregate.supplyFromStock += supplyFromStock; + aggregate.supplyFromOpenWorkOrders += supplyFromOpenWorkOrders; + aggregate.supplyFromOpenPurchaseOrders += supplyFromOpenPurchaseOrders; + aggregate.recommendedBuildQuantity += recommendedBuildQuantity; + aggregate.recommendedPurchaseQuantity += recommendedPurchaseQuantity; + aggregate.uncoveredQuantity += uncoveredQuantity; + + const children: SalesOrderPlanningNodeDto[] = []; + if (recommendedBuildQuantity > 0 && item.bomLines.length > 0 && !ancestry.has(item.id)) { + const nextAncestry = new Set(ancestry); + nextAncestry.add(item.id); + + for (const bomLine of item.bomLines) { + children.push( + planItemDemand(bomLine.componentItemId, recommendedBuildQuantity * bomLine.quantity, level + 1, bomLine.quantity, nextAncestry) + ); + } + } + + return { + itemId: item.id, + itemSku: item.sku, + itemName: item.name, + itemType: item.type, + unitOfMeasure: item.unitOfMeasure as SalesOrderPlanningNodeDto["unitOfMeasure"], + level, + grossDemand, + availableBefore, + availableAfter: mutableSupply.remainingAvailableQuantity, + supplyFromStock, + openWorkOrderSupply, + openPurchaseSupply, + supplyFromOpenWorkOrders, + supplyFromOpenPurchaseOrders, + recommendedBuildQuantity, + recommendedPurchaseQuantity, + uncoveredQuantity, + bomQuantityPerParent, + children, + }; + } + + const lines = snapshot.lines.map((line) => ({ + lineId: line.id, + itemId: line.itemId, + itemSku: line.itemSku, + itemName: line.itemName, + quantity: line.quantity, + unitOfMeasure: line.unitOfMeasure as SalesOrderPlanningDto["lines"][number]["unitOfMeasure"], + rootNode: planItemDemand(line.itemId, line.quantity, 0, null, new Set()), + })); + + const items = [...aggregatedByItemId.values()].sort((left, right) => left.itemSku.localeCompare(right.itemSku)); + + return { + orderId: snapshot.orderId, + documentNumber: snapshot.documentNumber, + status: snapshot.status, + generatedAt: new Date().toISOString(), + summary: { + lineCount: lines.length, + itemCount: items.length, + buildRecommendationCount: items.filter((item) => item.recommendedBuildQuantity > 0).length, + purchaseRecommendationCount: items.filter((item) => item.recommendedPurchaseQuantity > 0).length, + uncoveredItemCount: items.filter((item) => item.uncoveredQuantity > 0).length, + totalBuildQuantity: items.reduce((sum, item) => sum + item.recommendedBuildQuantity, 0), + totalPurchaseQuantity: items.reduce((sum, item) => sum + item.recommendedPurchaseQuantity, 0), + totalUncoveredQuantity: items.reduce((sum, item) => sum + item.uncoveredQuantity, 0), + }, + lines, + items, + }; +} + +async function collectPlanningItems(rootItemIds: string[]) { + const itemsById = new Map(); + let pendingItemIds = [...new Set(rootItemIds.filter((itemId) => itemId.trim().length > 0))]; + + while (pendingItemIds.length > 0) { + const batch = await prisma.inventoryItem.findMany({ + where: { + id: { + in: pendingItemIds, + }, + }, + select: { + id: true, + sku: true, + name: true, + type: true, + unitOfMeasure: true, + isPurchasable: true, + bomLines: { + select: { + componentItemId: true, + quantity: true, + unitOfMeasure: true, + }, + orderBy: [{ position: "asc" }, { createdAt: "asc" }], + }, + }, + }); + + const nextPending = new Set(); + + for (const item of batch) { + itemsById.set(item.id, item); + for (const bomLine of item.bomLines) { + if (!itemsById.has(bomLine.componentItemId)) { + nextPending.add(bomLine.componentItemId); + } + } + } + + pendingItemIds = [...nextPending]; + } + + return itemsById; +} + +export async function getSalesOrderPlanningById(orderId: string): Promise { + const order = await prisma.salesOrder.findUnique({ + where: { id: orderId }, + select: { + id: true, + documentNumber: true, + status: true, + lines: { + select: { + id: true, + quantity: true, + unitOfMeasure: true, + item: { + select: { + id: true, + sku: true, + name: true, + }, + }, + }, + orderBy: [{ position: "asc" }, { createdAt: "asc" }], + }, + }, + }); + + if (!order) { + return null; + } + + const itemsById = await collectPlanningItems(order.lines.map((line) => line.item.id)); + const itemIds = [...itemsById.keys()]; + + const [transactions, reservations, workOrders, purchaseOrderLines] = await Promise.all([ + prisma.inventoryTransaction.findMany({ + where: { + itemId: { + in: itemIds, + }, + }, + select: { + itemId: true, + transactionType: true, + quantity: true, + }, + }), + prisma.inventoryReservation.findMany({ + where: { + itemId: { + in: itemIds, + }, + status: "ACTIVE", + }, + select: { + itemId: true, + quantity: true, + }, + }), + prisma.workOrder.findMany({ + where: { + itemId: { + in: itemIds, + }, + status: { + notIn: ["CANCELLED", "COMPLETE"], + }, + }, + select: { + itemId: true, + quantity: true, + completedQuantity: true, + }, + }), + prisma.purchaseOrderLine.findMany({ + where: { + itemId: { + in: itemIds, + }, + purchaseOrder: { + status: { + not: "CLOSED", + }, + }, + }, + select: { + itemId: true, + quantity: true, + receiptLines: { + select: { + quantity: true, + }, + }, + }, + }), + ]); + + const supplyByItemId = Object.fromEntries(itemIds.map((itemId) => [itemId, createEmptySupplySnapshot()])) as Record; + + for (const transaction of transactions) { + const supply = supplyByItemId[transaction.itemId]; + if (!supply) { + continue; + } + + const signedQuantity = + transaction.transactionType === "RECEIPT" || transaction.transactionType === "ADJUSTMENT_IN" ? transaction.quantity : -transaction.quantity; + supply.onHandQuantity += signedQuantity; + } + + for (const reservation of reservations) { + const supply = supplyByItemId[reservation.itemId]; + if (!supply) { + continue; + } + + supply.reservedQuantity += reservation.quantity; + } + + for (const workOrder of workOrders) { + const supply = supplyByItemId[workOrder.itemId]; + if (!supply) { + continue; + } + + supply.openWorkOrderSupply += Math.max(workOrder.quantity - workOrder.completedQuantity, 0); + } + + for (const line of purchaseOrderLines) { + const supply = supplyByItemId[line.itemId]; + if (!supply) { + continue; + } + + supply.openPurchaseSupply += Math.max( + line.quantity - line.receiptLines.reduce((sum, receiptLine) => sum + receiptLine.quantity, 0), + 0 + ); + } + + for (const itemId of itemIds) { + const supply = supplyByItemId[itemId]; + if (!supply) { + continue; + } + + supply.availableQuantity = supply.onHandQuantity - supply.reservedQuantity; + } + + return buildSalesOrderPlanning({ + orderId: order.id, + documentNumber: order.documentNumber, + status: order.status as SalesDocumentStatus, + lines: order.lines.map((line) => ({ + id: line.id, + itemId: line.item.id, + itemSku: line.item.sku, + itemName: line.item.name, + quantity: line.quantity, + unitOfMeasure: line.unitOfMeasure, + })), + itemsById: Object.fromEntries(itemsById.entries()), + supplyByItemId, + }); +} diff --git a/server/src/modules/sales/router.ts b/server/src/modules/sales/router.ts index ddef425..ef91e7f 100644 --- a/server/src/modules/sales/router.ts +++ b/server/src/modules/sales/router.ts @@ -18,6 +18,7 @@ import { updateSalesDocumentStatus, updateSalesDocument, } from "./service.js"; +import { getSalesOrderPlanningById } from "./planning.js"; const salesLineSchema = z.object({ itemId: z.string().trim().min(1), @@ -216,6 +217,20 @@ salesRouter.get("/orders/:orderId", requirePermissions([permissions.salesRead]), return ok(response, order); }); +salesRouter.get("/orders/:orderId/planning", requirePermissions([permissions.salesRead]), async (request, response) => { + const orderId = getRouteParam(request.params.orderId); + if (!orderId) { + return fail(response, 400, "INVALID_INPUT", "Sales order id is invalid."); + } + + const planning = await getSalesOrderPlanningById(orderId); + if (!planning) { + return fail(response, 404, "SALES_ORDER_NOT_FOUND", "Sales order was not found."); + } + + return ok(response, planning); +}); + salesRouter.post("/orders", requirePermissions([permissions.salesWrite]), async (request, response) => { const parsed = orderSchema.safeParse(request.body); if (!parsed.success) { diff --git a/server/tests/sales-planning.test.ts b/server/tests/sales-planning.test.ts new file mode 100644 index 0000000..11bb28e --- /dev/null +++ b/server/tests/sales-planning.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from "vitest"; + +import { buildSalesOrderPlanning, type SalesOrderPlanningSnapshot } from "../src/modules/sales/planning.js"; + +describe("sales order planning", () => { + it("nets stock and open supply before cascading build demand into child components", () => { + const snapshot: SalesOrderPlanningSnapshot = { + orderId: "order-1", + documentNumber: "SO-00001", + status: "APPROVED", + lines: [ + { + id: "line-1", + itemId: "assembly-1", + itemSku: "ASSY-100", + itemName: "Assembly", + quantity: 5, + unitOfMeasure: "EA", + }, + ], + itemsById: { + "assembly-1": { + id: "assembly-1", + sku: "ASSY-100", + name: "Assembly", + type: "ASSEMBLY", + unitOfMeasure: "EA", + isPurchasable: false, + bomLines: [ + { + componentItemId: "mfg-1", + quantity: 2, + unitOfMeasure: "EA", + }, + { + componentItemId: "buy-1", + quantity: 3, + unitOfMeasure: "EA", + }, + ], + }, + "mfg-1": { + id: "mfg-1", + sku: "MFG-200", + name: "Manufactured Child", + type: "MANUFACTURED", + unitOfMeasure: "EA", + isPurchasable: false, + bomLines: [ + { + componentItemId: "buy-2", + quantity: 4, + unitOfMeasure: "EA", + }, + ], + }, + "buy-1": { + id: "buy-1", + sku: "BUY-300", + name: "Purchased Child", + type: "PURCHASED", + unitOfMeasure: "EA", + isPurchasable: true, + bomLines: [], + }, + "buy-2": { + id: "buy-2", + sku: "BUY-400", + name: "Raw Material", + type: "PURCHASED", + unitOfMeasure: "EA", + isPurchasable: true, + bomLines: [], + }, + }, + supplyByItemId: { + "assembly-1": { + onHandQuantity: 1, + reservedQuantity: 0, + availableQuantity: 1, + openWorkOrderSupply: 1, + openPurchaseSupply: 0, + }, + "mfg-1": { + onHandQuantity: 2, + reservedQuantity: 0, + availableQuantity: 2, + openWorkOrderSupply: 1, + openPurchaseSupply: 0, + }, + "buy-1": { + onHandQuantity: 4, + reservedQuantity: 1, + availableQuantity: 3, + openWorkOrderSupply: 0, + openPurchaseSupply: 5, + }, + "buy-2": { + onHandQuantity: 1, + reservedQuantity: 0, + availableQuantity: 1, + openWorkOrderSupply: 0, + openPurchaseSupply: 2, + }, + }, + }; + + const planning = buildSalesOrderPlanning(snapshot); + + expect(planning.summary.totalBuildQuantity).toBe(6); + expect(planning.summary.totalPurchaseQuantity).toBe(10); + + const assembly = planning.items.find((item) => item.itemId === "assembly-1"); + const manufacturedChild = planning.items.find((item) => item.itemId === "mfg-1"); + const purchasedChild = planning.items.find((item) => item.itemId === "buy-1"); + const rawMaterial = planning.items.find((item) => item.itemId === "buy-2"); + + expect(assembly?.recommendedBuildQuantity).toBe(3); + expect(assembly?.supplyFromStock).toBe(1); + expect(assembly?.supplyFromOpenWorkOrders).toBe(1); + + expect(manufacturedChild?.grossDemand).toBe(6); + expect(manufacturedChild?.recommendedBuildQuantity).toBe(3); + expect(manufacturedChild?.supplyFromStock).toBe(2); + expect(manufacturedChild?.supplyFromOpenWorkOrders).toBe(1); + + expect(purchasedChild?.grossDemand).toBe(9); + expect(purchasedChild?.recommendedPurchaseQuantity).toBe(1); + expect(purchasedChild?.supplyFromStock).toBe(3); + expect(purchasedChild?.supplyFromOpenPurchaseOrders).toBe(5); + + expect(rawMaterial?.grossDemand).toBe(12); + expect(rawMaterial?.recommendedPurchaseQuantity).toBe(9); + expect(rawMaterial?.supplyFromStock).toBe(1); + expect(rawMaterial?.supplyFromOpenPurchaseOrders).toBe(2); + }); +}); diff --git a/shared/src/sales/types.ts b/shared/src/sales/types.ts index 8f029ec..41e691e 100644 --- a/shared/src/sales/types.ts +++ b/shared/src/sales/types.ts @@ -64,6 +64,79 @@ export interface SalesDocumentDetailDto extends SalesDocumentSummaryDto { revisions: SalesDocumentRevisionDto[]; } +export interface SalesOrderPlanningNodeDto { + itemId: string; + itemSku: string; + itemName: string; + itemType: string; + unitOfMeasure: InventoryUnitOfMeasure; + level: number; + grossDemand: number; + availableBefore: number; + availableAfter: number; + supplyFromStock: number; + openWorkOrderSupply: number; + openPurchaseSupply: number; + supplyFromOpenWorkOrders: number; + supplyFromOpenPurchaseOrders: number; + recommendedBuildQuantity: number; + recommendedPurchaseQuantity: number; + uncoveredQuantity: number; + bomQuantityPerParent: number | null; + children: SalesOrderPlanningNodeDto[]; +} + +export interface SalesOrderPlanningLineDto { + lineId: string; + itemId: string; + itemSku: string; + itemName: string; + quantity: number; + unitOfMeasure: InventoryUnitOfMeasure; + rootNode: SalesOrderPlanningNodeDto; +} + +export interface SalesOrderPlanningItemDto { + itemId: string; + itemSku: string; + itemName: string; + itemType: string; + unitOfMeasure: InventoryUnitOfMeasure; + grossDemand: number; + onHandQuantity: number; + reservedQuantity: number; + availableQuantity: number; + openWorkOrderSupply: number; + openPurchaseSupply: number; + supplyFromStock: number; + supplyFromOpenWorkOrders: number; + supplyFromOpenPurchaseOrders: number; + recommendedBuildQuantity: number; + recommendedPurchaseQuantity: number; + uncoveredQuantity: number; +} + +export interface SalesOrderPlanningSummaryDto { + lineCount: number; + itemCount: number; + buildRecommendationCount: number; + purchaseRecommendationCount: number; + uncoveredItemCount: number; + totalBuildQuantity: number; + totalPurchaseQuantity: number; + totalUncoveredQuantity: number; +} + +export interface SalesOrderPlanningDto { + orderId: string; + documentNumber: string; + status: SalesDocumentStatus; + generatedAt: string; + summary: SalesOrderPlanningSummaryDto; + lines: SalesOrderPlanningLineDto[]; + items: SalesOrderPlanningItemDto[]; +} + export interface SalesDocumentInput { customerId: string; status: SalesDocumentStatus;