demand planning

This commit is contained in:
2026-03-15 15:45:29 -05:00
parent f858fe4785
commit 15116807ce
11 changed files with 859 additions and 15 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<SalesDocumentDetailDto>(`/api/v1/sales/orders/${orderId}`, undefined, token);
},
getSalesOrderPlanning(token: string, orderId: string) {
return request<SalesOrderPlanningDto>(`/api/v1/sales/orders/${orderId}/planning`, undefined, token);
},
createSalesOrder(token: string, payload: SalesDocumentInput) {
return request<SalesDocumentDetailDto>("/api/v1/sales/orders", { method: "POST", body: JSON.stringify(payload) }, token);
},

View File

@@ -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 (
<div className="rounded-3xl border border-line/70 bg-page/60 p-3" style={{ marginLeft: node.level * 12 }}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="font-semibold text-text">
{node.itemSku} <span className="text-muted">{node.itemName}</span>
</div>
<div className="mt-1 text-xs text-muted">
Demand {node.grossDemand} {node.unitOfMeasure} · Type {node.itemType}
{node.bomQuantityPerParent !== null ? ` · Qty/parent ${node.bomQuantityPerParent}` : ""}
</div>
</div>
<div className="text-right text-xs text-muted">
<div>Stock {node.supplyFromStock}</div>
<div>Open WO {node.supplyFromOpenWorkOrders}</div>
<div>Open PO {node.supplyFromOpenPurchaseOrders}</div>
<div>Build {node.recommendedBuildQuantity}</div>
<div>Buy {node.recommendedPurchaseQuantity}</div>
{node.uncoveredQuantity > 0 ? <div>Uncovered {node.uncoveredQuantity}</div> : null}
</div>
</div>
{node.children.length > 0 ? (
<div className="mt-3 space-y-3">
{node.children.map((child) => (
<PlanningNodeCard key={`${child.itemId}-${child.level}-${child.itemSku}-${child.grossDemand}`} node={child} />
))}
</div>
) : null}
</div>
);
}
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<ShipmentSummaryDto[]>([]);
const [planning, setPlanning] = useState<SalesOrderPlanningDto | null>(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 }) {
</div>
)}
</section>
{entity === "order" && planning ? (
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Demand Planning</p>
<h3 className="mt-2 text-lg font-bold text-text">Net build and buy requirements</h3>
<p className="mt-2 max-w-3xl text-sm text-muted">
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.
</p>
</div>
<div className="text-right text-xs text-muted">
<div>Generated {new Date(planning.generatedAt).toLocaleString()}</div>
<div>Status {planning.status}</div>
</div>
</div>
<div className="mt-5 grid gap-3 xl:grid-cols-4">
<article className="rounded-[24px] border border-line/70 bg-page/70 px-3 py-3">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Build Recommendations</p>
<div className="mt-2 text-base font-bold text-text">{planning.summary.totalBuildQuantity}</div>
<div className="mt-1 text-xs text-muted">{planning.summary.buildRecommendationCount} items</div>
</article>
<article className="rounded-[24px] border border-line/70 bg-page/70 px-3 py-3">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Purchase Recommendations</p>
<div className="mt-2 text-base font-bold text-text">{planning.summary.totalPurchaseQuantity}</div>
<div className="mt-1 text-xs text-muted">{planning.summary.purchaseRecommendationCount} items</div>
</article>
<article className="rounded-[24px] border border-line/70 bg-page/70 px-3 py-3">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Uncovered</p>
<div className="mt-2 text-base font-bold text-text">{planning.summary.totalUncoveredQuantity}</div>
<div className="mt-1 text-xs text-muted">{planning.summary.uncoveredItemCount} items</div>
</article>
<article className="rounded-[24px] border border-line/70 bg-page/70 px-3 py-3">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Planned Items</p>
<div className="mt-2 text-base font-bold text-text">{planning.summary.itemCount}</div>
<div className="mt-1 text-xs text-muted">{planning.summary.lineCount} sales lines</div>
</article>
</div>
<div className="mt-5 overflow-hidden rounded-2xl border border-line/70">
<table className="min-w-full divide-y divide-line/70 text-sm">
<thead className="bg-page/80 text-left text-muted">
<tr>
<th className="px-2 py-2">Item</th>
<th className="px-2 py-2">Gross</th>
<th className="px-2 py-2">Available</th>
<th className="px-2 py-2">Open WO</th>
<th className="px-2 py-2">Open PO</th>
<th className="px-2 py-2">Build</th>
<th className="px-2 py-2">Buy</th>
<th className="px-2 py-2">Uncovered</th>
</tr>
</thead>
<tbody className="divide-y divide-line/70 bg-surface">
{planning.items.map((item) => (
<tr key={item.itemId}>
<td className="px-2 py-2">
<div className="font-semibold text-text">{item.itemSku}</div>
<div className="mt-1 text-xs text-muted">{item.itemName}</div>
</td>
<td className="px-2 py-2 text-muted">{item.grossDemand}</td>
<td className="px-2 py-2 text-muted">{item.availableQuantity}</td>
<td className="px-2 py-2 text-muted">{item.openWorkOrderSupply}</td>
<td className="px-2 py-2 text-muted">{item.openPurchaseSupply}</td>
<td className="px-2 py-2 text-muted">{item.recommendedBuildQuantity}</td>
<td className="px-2 py-2 text-muted">{item.recommendedPurchaseQuantity}</td>
<td className="px-2 py-2 text-muted">{item.uncoveredQuantity}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="mt-5 space-y-3">
{planning.lines.map((line) => (
<div key={line.lineId} className="rounded-3xl border border-line/70 bg-page/60 p-3">
<div className="mb-3">
<div className="font-semibold text-text">
{line.itemSku} <span className="text-muted">{line.itemName}</span>
</div>
<div className="mt-1 text-xs text-muted">
Sales-order line demand: {line.quantity} {line.unitOfMeasure}
</div>
</div>
<PlanningNodeCard node={line.rootNode} />
</div>
))}
</div>
</section>
) : null}
{entity === "order" && canReadShipping ? (
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="flex items-center justify-between gap-3">

View File

@@ -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<string, PlanningItemSnapshot>;
supplyByItemId: Record<string, PlanningSupplySnapshot>;
};
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<string, MutableSupplyState>();
const aggregatedByItemId = new Map<string, SalesOrderPlanningItemDto>();
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<string>): 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<string>()),
}));
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<string, PlanningItemSnapshot>();
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<string>();
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<SalesOrderPlanningDto | null> {
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<string, PlanningSupplySnapshot>;
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,
});
}

View File

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

View File

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

View File

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