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
|
||||
- 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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
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
|
||||
- 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
|
||||
|
||||
|
||||
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
|
||||
- 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
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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">
|
||||
|
||||
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,
|
||||
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) {
|
||||
|
||||
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[];
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user