demand planning
This commit is contained in:
@@ -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
|
- 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
|
- 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
|
- 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 diagnostics with runtime footprint, record counts, and persisted audit-trail visibility
|
||||||
- admin user management with account creation, activation, role assignment, and role-permission editing
|
- 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
|
- 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:
|
Near-term priorities are:
|
||||||
|
|
||||||
1. Better user and session visibility for operational admins
|
1. Convert demand-planning recommendations into work orders and purchase orders
|
||||||
2. Safer destructive-action confirmations and recovery messaging
|
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.
|
When adding new modules, preserve the ability to extend the system without refactoring the existing app shell.
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ This file is the running release and change log for MRP Codex. Keep it updated w
|
|||||||
|
|
||||||
### Added
|
### 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
|
- 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
|
- 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
|
- 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
|
- 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 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
|
- 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 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
|
## 2026-03-15
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
- 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
|
- 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
|
- 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 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
|
- admin user management with account creation, activation, role assignment, and role-permission editing
|
||||||
- CRM/shipping audit coverage and startup validation surfaced through diagnostics
|
- CRM/shipping audit coverage and startup validation surfaced through diagnostics
|
||||||
@@ -68,5 +69,5 @@ This repository implements the platform foundation milestone:
|
|||||||
|
|
||||||
## Next roadmap candidates
|
## Next roadmap candidates
|
||||||
|
|
||||||
- better user and session visibility for operational admins
|
- convert demand-planning recommendations into work orders and purchase orders
|
||||||
- safer destructive-action confirmations and recovery messaging
|
- shared shortage and readiness rollups across planning, purchasing, manufacturing, and projects
|
||||||
|
|||||||
10
README.md
10
README.md
@@ -26,6 +26,7 @@ Current foundation scope includes:
|
|||||||
- projects with customer/commercial/shipment linkage, owners, due dates, notes, and attachments
|
- 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
|
- 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
|
- 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 diagnostics with runtime footprint, record counts, and recent audit-trail visibility
|
||||||
- admin user management with account creation, activation, role assignment, and role-permission editing
|
- 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
|
- 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:
|
Near-term priorities:
|
||||||
|
|
||||||
1. Better user and session visibility for operational admins
|
1. Convert demand-planning recommendations into work orders and purchase orders
|
||||||
2. Safer destructive-action confirmations and recovery messaging
|
2. Shared shortage and readiness rollups across planning, purchasing, manufacturing, and projects
|
||||||
|
|
||||||
Revisit / deferred items:
|
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
|
- 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
|
- 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
|
- 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
|
- 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
|
- 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:
|
Current follow-up direction:
|
||||||
|
|
||||||
- better user and session visibility for operational admins
|
- convert demand-planning recommendations into work orders and purchase orders
|
||||||
- safer destructive-action confirmations and recovery messaging
|
- shared shortage and readiness rollups across planning, purchasing, manufacturing, and projects
|
||||||
|
|
||||||
## UI Notes
|
## UI Notes
|
||||||
|
|
||||||
|
|||||||
29
ROADMAP.md
29
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
|
- Theme persistence fixes and denser responsive workspace layouts
|
||||||
- Full-site density normalization pass across active CRM, inventory, settings, dashboard, and login screens
|
- Full-site density normalization pass across active CRM, inventory, settings, dashboard, and login screens
|
||||||
- Live planning gantt timelines driven by project and manufacturing data
|
- 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
|
- Multi-stage Docker packaging and migration-aware entrypoint
|
||||||
- Docker image validated locally with successful app startup and login flow
|
- Docker image validated locally with successful app startup and login flow
|
||||||
- Core project documentation in `README.md`, `INSTRUCTIONS.md`, and `STRUCTURE.md`
|
- 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
|
- Better mobile and tablet behavior for shop-floor lookups
|
||||||
- Faster filtering by project, customer, work center, and status
|
- 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:
|
Foundation slice shipped:
|
||||||
|
|
||||||
@@ -297,5 +320,5 @@ QOL subfeatures:
|
|||||||
|
|
||||||
## Near-term priority order
|
## Near-term priority order
|
||||||
|
|
||||||
1. Better user and session visibility for operational admins
|
1. Convert demand-planning recommendations into work orders and purchase orders
|
||||||
2. Safer destructive-action confirmations and recovery messaging
|
2. Shared shortage and readiness rollups across planning, purchasing, manufacturing, and projects
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ import type {
|
|||||||
SalesCustomerOptionDto,
|
SalesCustomerOptionDto,
|
||||||
SalesDocumentDetailDto,
|
SalesDocumentDetailDto,
|
||||||
SalesDocumentInput,
|
SalesDocumentInput,
|
||||||
|
SalesOrderPlanningDto,
|
||||||
SalesDocumentRevisionDto,
|
SalesDocumentRevisionDto,
|
||||||
SalesDocumentStatus,
|
SalesDocumentStatus,
|
||||||
SalesDocumentSummaryDto,
|
SalesDocumentSummaryDto,
|
||||||
@@ -631,6 +632,9 @@ export const api = {
|
|||||||
getSalesOrder(token: string, orderId: string) {
|
getSalesOrder(token: string, orderId: string) {
|
||||||
return request<SalesDocumentDetailDto>(`/api/v1/sales/orders/${orderId}`, undefined, token);
|
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) {
|
createSalesOrder(token: string, payload: SalesDocumentInput) {
|
||||||
return request<SalesDocumentDetailDto>("/api/v1/sales/orders", { method: "POST", body: JSON.stringify(payload) }, token);
|
return request<SalesDocumentDetailDto>("/api/v1/sales/orders", { method: "POST", body: JSON.stringify(payload) }, token);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { permissions } from "@mrp/shared";
|
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 type { ShipmentSummaryDto } from "@mrp/shared/dist/shipping/types.js";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||||
@@ -10,6 +10,39 @@ import { salesConfigs, salesStatusOptions, type SalesDocumentEntity } from "./co
|
|||||||
import { SalesStatusBadge } from "./SalesStatusBadge";
|
import { SalesStatusBadge } from "./SalesStatusBadge";
|
||||||
import { ShipmentStatusBadge } from "../shipping/ShipmentStatusBadge";
|
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 }) {
|
export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
|
||||||
const { token, user } = useAuth();
|
const { token, user } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -23,6 +56,7 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
|
|||||||
const [isOpeningPdf, setIsOpeningPdf] = useState(false);
|
const [isOpeningPdf, setIsOpeningPdf] = useState(false);
|
||||||
const [isApproving, setIsApproving] = useState(false);
|
const [isApproving, setIsApproving] = useState(false);
|
||||||
const [shipments, setShipments] = useState<ShipmentSummaryDto[]>([]);
|
const [shipments, setShipments] = useState<ShipmentSummaryDto[]>([]);
|
||||||
|
const [planning, setPlanning] = useState<SalesOrderPlanningDto | null>(null);
|
||||||
|
|
||||||
const canManage = user?.permissions.includes(permissions.salesWrite) ?? false;
|
const canManage = user?.permissions.includes(permissions.salesWrite) ?? false;
|
||||||
const canManageShipping = user?.permissions.includes(permissions.shippingWrite) ?? 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);
|
const loader = entity === "quote" ? api.getQuote(token, documentId) : api.getSalesOrder(token, documentId);
|
||||||
loader
|
const planningLoader = entity === "order" ? api.getSalesOrderPlanning(token, documentId) : Promise.resolve(null);
|
||||||
.then((nextDocument) => {
|
Promise.all([loader, planningLoader])
|
||||||
|
.then(([nextDocument, nextPlanning]) => {
|
||||||
setDocument(nextDocument);
|
setDocument(nextDocument);
|
||||||
|
setPlanning(nextPlanning);
|
||||||
setStatus(`${config.singularLabel} loaded.`);
|
setStatus(`${config.singularLabel} loaded.`);
|
||||||
if (entity === "order" && canReadShipping) {
|
if (entity === "order" && canReadShipping) {
|
||||||
api.getShipments(token, { salesOrderId: nextDocument.id }).then(setShipments).catch(() => setShipments([]));
|
api.getShipments(token, { salesOrderId: nextDocument.id }).then(setShipments).catch(() => setShipments([]));
|
||||||
@@ -352,6 +388,93 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</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 ? (
|
{entity === "order" && canReadShipping ? (
|
||||||
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<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">
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
|||||||
461
server/src/modules/sales/planning.ts
Normal file
461
server/src/modules/sales/planning.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
updateSalesDocumentStatus,
|
updateSalesDocumentStatus,
|
||||||
updateSalesDocument,
|
updateSalesDocument,
|
||||||
} from "./service.js";
|
} from "./service.js";
|
||||||
|
import { getSalesOrderPlanningById } from "./planning.js";
|
||||||
|
|
||||||
const salesLineSchema = z.object({
|
const salesLineSchema = z.object({
|
||||||
itemId: z.string().trim().min(1),
|
itemId: z.string().trim().min(1),
|
||||||
@@ -216,6 +217,20 @@ salesRouter.get("/orders/:orderId", requirePermissions([permissions.salesRead]),
|
|||||||
return ok(response, order);
|
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) => {
|
salesRouter.post("/orders", requirePermissions([permissions.salesWrite]), async (request, response) => {
|
||||||
const parsed = orderSchema.safeParse(request.body);
|
const parsed = orderSchema.safeParse(request.body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
|
|||||||
137
server/tests/sales-planning.test.ts
Normal file
137
server/tests/sales-planning.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -64,6 +64,79 @@ export interface SalesDocumentDetailDto extends SalesDocumentSummaryDto {
|
|||||||
revisions: SalesDocumentRevisionDto[];
|
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 {
|
export interface SalesDocumentInput {
|
||||||
customerId: string;
|
customerId: string;
|
||||||
status: SalesDocumentStatus;
|
status: SalesDocumentStatus;
|
||||||
|
|||||||
Reference in New Issue
Block a user