demand planning

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

View File

@@ -0,0 +1,461 @@
import type { SalesDocumentStatus, SalesOrderPlanningDto, SalesOrderPlanningItemDto, SalesOrderPlanningNodeDto } from "@mrp/shared/dist/sales/types.js";
import { prisma } from "../../lib/prisma.js";
type PlanningItemSnapshot = {
id: string;
sku: string;
name: string;
type: string;
unitOfMeasure: string;
isPurchasable: boolean;
bomLines: Array<{
componentItemId: string;
quantity: number;
unitOfMeasure: string;
}>;
};
type PlanningLineSnapshot = {
id: string;
itemId: string;
itemSku: string;
itemName: string;
quantity: number;
unitOfMeasure: string;
};
type PlanningSupplySnapshot = {
onHandQuantity: number;
reservedQuantity: number;
availableQuantity: number;
openWorkOrderSupply: number;
openPurchaseSupply: number;
};
export type SalesOrderPlanningSnapshot = {
orderId: string;
documentNumber: string;
status: SalesDocumentStatus;
lines: PlanningLineSnapshot[];
itemsById: Record<string, PlanningItemSnapshot>;
supplyByItemId: Record<string, PlanningSupplySnapshot>;
};
type MutableSupplyState = {
remainingAvailableQuantity: number;
remainingOpenWorkOrderSupply: number;
remainingOpenPurchaseSupply: number;
};
function createEmptySupplySnapshot(): PlanningSupplySnapshot {
return {
onHandQuantity: 0,
reservedQuantity: 0,
availableQuantity: 0,
openWorkOrderSupply: 0,
openPurchaseSupply: 0,
};
}
function createMutableSupplyState(snapshot: PlanningSupplySnapshot): MutableSupplyState {
return {
remainingAvailableQuantity: Math.max(snapshot.availableQuantity, 0),
remainingOpenWorkOrderSupply: Math.max(snapshot.openWorkOrderSupply, 0),
remainingOpenPurchaseSupply: Math.max(snapshot.openPurchaseSupply, 0),
};
}
function isBuildItem(type: string) {
return type === "ASSEMBLY" || type === "MANUFACTURED";
}
function shouldBuyItem(item: PlanningItemSnapshot) {
return item.type === "PURCHASED" || item.isPurchasable;
}
export function buildSalesOrderPlanning(snapshot: SalesOrderPlanningSnapshot): SalesOrderPlanningDto {
const mutableSupplyByItemId = new Map<string, MutableSupplyState>();
const aggregatedByItemId = new Map<string, SalesOrderPlanningItemDto>();
function getMutableSupply(itemId: string) {
const existing = mutableSupplyByItemId.get(itemId);
if (existing) {
return existing;
}
const next = createMutableSupplyState(snapshot.supplyByItemId[itemId] ?? createEmptySupplySnapshot());
mutableSupplyByItemId.set(itemId, next);
return next;
}
function getOrCreateAggregate(item: PlanningItemSnapshot) {
const existing = aggregatedByItemId.get(item.id);
if (existing) {
return existing;
}
const supply = snapshot.supplyByItemId[item.id] ?? createEmptySupplySnapshot();
const created: SalesOrderPlanningItemDto = {
itemId: item.id,
itemSku: item.sku,
itemName: item.name,
itemType: item.type,
unitOfMeasure: item.unitOfMeasure as SalesOrderPlanningItemDto["unitOfMeasure"],
grossDemand: 0,
onHandQuantity: supply.onHandQuantity,
reservedQuantity: supply.reservedQuantity,
availableQuantity: supply.availableQuantity,
openWorkOrderSupply: supply.openWorkOrderSupply,
openPurchaseSupply: supply.openPurchaseSupply,
supplyFromStock: 0,
supplyFromOpenWorkOrders: 0,
supplyFromOpenPurchaseOrders: 0,
recommendedBuildQuantity: 0,
recommendedPurchaseQuantity: 0,
uncoveredQuantity: 0,
};
aggregatedByItemId.set(item.id, created);
return created;
}
function planItemDemand(itemId: string, grossDemand: number, level: number, bomQuantityPerParent: number | null, ancestry: Set<string>): SalesOrderPlanningNodeDto {
const item = snapshot.itemsById[itemId];
if (!item) {
return {
itemId,
itemSku: "UNKNOWN",
itemName: "Missing item",
itemType: "UNKNOWN",
unitOfMeasure: "EA",
level,
grossDemand,
availableBefore: 0,
availableAfter: 0,
supplyFromStock: 0,
openWorkOrderSupply: 0,
openPurchaseSupply: 0,
supplyFromOpenWorkOrders: 0,
supplyFromOpenPurchaseOrders: 0,
recommendedBuildQuantity: 0,
recommendedPurchaseQuantity: 0,
uncoveredQuantity: grossDemand,
bomQuantityPerParent,
children: [],
};
}
const aggregate = getOrCreateAggregate(item);
const mutableSupply = getMutableSupply(itemId);
const availableBefore = mutableSupply.remainingAvailableQuantity;
const openWorkOrderSupply = mutableSupply.remainingOpenWorkOrderSupply;
const openPurchaseSupply = mutableSupply.remainingOpenPurchaseSupply;
let remainingDemand = grossDemand;
const supplyFromStock = Math.min(availableBefore, remainingDemand);
mutableSupply.remainingAvailableQuantity -= supplyFromStock;
remainingDemand -= supplyFromStock;
let supplyFromOpenWorkOrders = 0;
let supplyFromOpenPurchaseOrders = 0;
let recommendedBuildQuantity = 0;
let recommendedPurchaseQuantity = 0;
let uncoveredQuantity = 0;
if (isBuildItem(item.type)) {
supplyFromOpenWorkOrders = Math.min(mutableSupply.remainingOpenWorkOrderSupply, remainingDemand);
mutableSupply.remainingOpenWorkOrderSupply -= supplyFromOpenWorkOrders;
remainingDemand -= supplyFromOpenWorkOrders;
recommendedBuildQuantity = remainingDemand;
} else if (shouldBuyItem(item)) {
supplyFromOpenPurchaseOrders = Math.min(mutableSupply.remainingOpenPurchaseSupply, remainingDemand);
mutableSupply.remainingOpenPurchaseSupply -= supplyFromOpenPurchaseOrders;
remainingDemand -= supplyFromOpenPurchaseOrders;
recommendedPurchaseQuantity = remainingDemand;
} else {
uncoveredQuantity = remainingDemand;
}
aggregate.grossDemand += grossDemand;
aggregate.supplyFromStock += supplyFromStock;
aggregate.supplyFromOpenWorkOrders += supplyFromOpenWorkOrders;
aggregate.supplyFromOpenPurchaseOrders += supplyFromOpenPurchaseOrders;
aggregate.recommendedBuildQuantity += recommendedBuildQuantity;
aggregate.recommendedPurchaseQuantity += recommendedPurchaseQuantity;
aggregate.uncoveredQuantity += uncoveredQuantity;
const children: SalesOrderPlanningNodeDto[] = [];
if (recommendedBuildQuantity > 0 && item.bomLines.length > 0 && !ancestry.has(item.id)) {
const nextAncestry = new Set(ancestry);
nextAncestry.add(item.id);
for (const bomLine of item.bomLines) {
children.push(
planItemDemand(bomLine.componentItemId, recommendedBuildQuantity * bomLine.quantity, level + 1, bomLine.quantity, nextAncestry)
);
}
}
return {
itemId: item.id,
itemSku: item.sku,
itemName: item.name,
itemType: item.type,
unitOfMeasure: item.unitOfMeasure as SalesOrderPlanningNodeDto["unitOfMeasure"],
level,
grossDemand,
availableBefore,
availableAfter: mutableSupply.remainingAvailableQuantity,
supplyFromStock,
openWorkOrderSupply,
openPurchaseSupply,
supplyFromOpenWorkOrders,
supplyFromOpenPurchaseOrders,
recommendedBuildQuantity,
recommendedPurchaseQuantity,
uncoveredQuantity,
bomQuantityPerParent,
children,
};
}
const lines = snapshot.lines.map((line) => ({
lineId: line.id,
itemId: line.itemId,
itemSku: line.itemSku,
itemName: line.itemName,
quantity: line.quantity,
unitOfMeasure: line.unitOfMeasure as SalesOrderPlanningDto["lines"][number]["unitOfMeasure"],
rootNode: planItemDemand(line.itemId, line.quantity, 0, null, new Set<string>()),
}));
const items = [...aggregatedByItemId.values()].sort((left, right) => left.itemSku.localeCompare(right.itemSku));
return {
orderId: snapshot.orderId,
documentNumber: snapshot.documentNumber,
status: snapshot.status,
generatedAt: new Date().toISOString(),
summary: {
lineCount: lines.length,
itemCount: items.length,
buildRecommendationCount: items.filter((item) => item.recommendedBuildQuantity > 0).length,
purchaseRecommendationCount: items.filter((item) => item.recommendedPurchaseQuantity > 0).length,
uncoveredItemCount: items.filter((item) => item.uncoveredQuantity > 0).length,
totalBuildQuantity: items.reduce((sum, item) => sum + item.recommendedBuildQuantity, 0),
totalPurchaseQuantity: items.reduce((sum, item) => sum + item.recommendedPurchaseQuantity, 0),
totalUncoveredQuantity: items.reduce((sum, item) => sum + item.uncoveredQuantity, 0),
},
lines,
items,
};
}
async function collectPlanningItems(rootItemIds: string[]) {
const itemsById = new Map<string, PlanningItemSnapshot>();
let pendingItemIds = [...new Set(rootItemIds.filter((itemId) => itemId.trim().length > 0))];
while (pendingItemIds.length > 0) {
const batch = await prisma.inventoryItem.findMany({
where: {
id: {
in: pendingItemIds,
},
},
select: {
id: true,
sku: true,
name: true,
type: true,
unitOfMeasure: true,
isPurchasable: true,
bomLines: {
select: {
componentItemId: true,
quantity: true,
unitOfMeasure: true,
},
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
},
},
});
const nextPending = new Set<string>();
for (const item of batch) {
itemsById.set(item.id, item);
for (const bomLine of item.bomLines) {
if (!itemsById.has(bomLine.componentItemId)) {
nextPending.add(bomLine.componentItemId);
}
}
}
pendingItemIds = [...nextPending];
}
return itemsById;
}
export async function getSalesOrderPlanningById(orderId: string): Promise<SalesOrderPlanningDto | null> {
const order = await prisma.salesOrder.findUnique({
where: { id: orderId },
select: {
id: true,
documentNumber: true,
status: true,
lines: {
select: {
id: true,
quantity: true,
unitOfMeasure: true,
item: {
select: {
id: true,
sku: true,
name: true,
},
},
},
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
},
},
});
if (!order) {
return null;
}
const itemsById = await collectPlanningItems(order.lines.map((line) => line.item.id));
const itemIds = [...itemsById.keys()];
const [transactions, reservations, workOrders, purchaseOrderLines] = await Promise.all([
prisma.inventoryTransaction.findMany({
where: {
itemId: {
in: itemIds,
},
},
select: {
itemId: true,
transactionType: true,
quantity: true,
},
}),
prisma.inventoryReservation.findMany({
where: {
itemId: {
in: itemIds,
},
status: "ACTIVE",
},
select: {
itemId: true,
quantity: true,
},
}),
prisma.workOrder.findMany({
where: {
itemId: {
in: itemIds,
},
status: {
notIn: ["CANCELLED", "COMPLETE"],
},
},
select: {
itemId: true,
quantity: true,
completedQuantity: true,
},
}),
prisma.purchaseOrderLine.findMany({
where: {
itemId: {
in: itemIds,
},
purchaseOrder: {
status: {
not: "CLOSED",
},
},
},
select: {
itemId: true,
quantity: true,
receiptLines: {
select: {
quantity: true,
},
},
},
}),
]);
const supplyByItemId = Object.fromEntries(itemIds.map((itemId) => [itemId, createEmptySupplySnapshot()])) as Record<string, PlanningSupplySnapshot>;
for (const transaction of transactions) {
const supply = supplyByItemId[transaction.itemId];
if (!supply) {
continue;
}
const signedQuantity =
transaction.transactionType === "RECEIPT" || transaction.transactionType === "ADJUSTMENT_IN" ? transaction.quantity : -transaction.quantity;
supply.onHandQuantity += signedQuantity;
}
for (const reservation of reservations) {
const supply = supplyByItemId[reservation.itemId];
if (!supply) {
continue;
}
supply.reservedQuantity += reservation.quantity;
}
for (const workOrder of workOrders) {
const supply = supplyByItemId[workOrder.itemId];
if (!supply) {
continue;
}
supply.openWorkOrderSupply += Math.max(workOrder.quantity - workOrder.completedQuantity, 0);
}
for (const line of purchaseOrderLines) {
const supply = supplyByItemId[line.itemId];
if (!supply) {
continue;
}
supply.openPurchaseSupply += Math.max(
line.quantity - line.receiptLines.reduce((sum, receiptLine) => sum + receiptLine.quantity, 0),
0
);
}
for (const itemId of itemIds) {
const supply = supplyByItemId[itemId];
if (!supply) {
continue;
}
supply.availableQuantity = supply.onHandQuantity - supply.reservedQuantity;
}
return buildSalesOrderPlanning({
orderId: order.id,
documentNumber: order.documentNumber,
status: order.status as SalesDocumentStatus,
lines: order.lines.map((line) => ({
id: line.id,
itemId: line.item.id,
itemSku: line.item.sku,
itemName: line.item.name,
quantity: line.quantity,
unitOfMeasure: line.unitOfMeasure,
})),
itemsById: Object.fromEntries(itemsById.entries()),
supplyByItemId,
});
}

View File

@@ -18,6 +18,7 @@ import {
updateSalesDocumentStatus,
updateSalesDocument,
} from "./service.js";
import { getSalesOrderPlanningById } from "./planning.js";
const salesLineSchema = z.object({
itemId: z.string().trim().min(1),
@@ -216,6 +217,20 @@ salesRouter.get("/orders/:orderId", requirePermissions([permissions.salesRead]),
return ok(response, order);
});
salesRouter.get("/orders/:orderId/planning", requirePermissions([permissions.salesRead]), async (request, response) => {
const orderId = getRouteParam(request.params.orderId);
if (!orderId) {
return fail(response, 400, "INVALID_INPUT", "Sales order id is invalid.");
}
const planning = await getSalesOrderPlanningById(orderId);
if (!planning) {
return fail(response, 404, "SALES_ORDER_NOT_FOUND", "Sales order was not found.");
}
return ok(response, planning);
});
salesRouter.post("/orders", requirePermissions([permissions.salesWrite]), async (request, response) => {
const parsed = orderSchema.safeParse(request.body);
if (!parsed.success) {

View File

@@ -0,0 +1,137 @@
import { describe, expect, it } from "vitest";
import { buildSalesOrderPlanning, type SalesOrderPlanningSnapshot } from "../src/modules/sales/planning.js";
describe("sales order planning", () => {
it("nets stock and open supply before cascading build demand into child components", () => {
const snapshot: SalesOrderPlanningSnapshot = {
orderId: "order-1",
documentNumber: "SO-00001",
status: "APPROVED",
lines: [
{
id: "line-1",
itemId: "assembly-1",
itemSku: "ASSY-100",
itemName: "Assembly",
quantity: 5,
unitOfMeasure: "EA",
},
],
itemsById: {
"assembly-1": {
id: "assembly-1",
sku: "ASSY-100",
name: "Assembly",
type: "ASSEMBLY",
unitOfMeasure: "EA",
isPurchasable: false,
bomLines: [
{
componentItemId: "mfg-1",
quantity: 2,
unitOfMeasure: "EA",
},
{
componentItemId: "buy-1",
quantity: 3,
unitOfMeasure: "EA",
},
],
},
"mfg-1": {
id: "mfg-1",
sku: "MFG-200",
name: "Manufactured Child",
type: "MANUFACTURED",
unitOfMeasure: "EA",
isPurchasable: false,
bomLines: [
{
componentItemId: "buy-2",
quantity: 4,
unitOfMeasure: "EA",
},
],
},
"buy-1": {
id: "buy-1",
sku: "BUY-300",
name: "Purchased Child",
type: "PURCHASED",
unitOfMeasure: "EA",
isPurchasable: true,
bomLines: [],
},
"buy-2": {
id: "buy-2",
sku: "BUY-400",
name: "Raw Material",
type: "PURCHASED",
unitOfMeasure: "EA",
isPurchasable: true,
bomLines: [],
},
},
supplyByItemId: {
"assembly-1": {
onHandQuantity: 1,
reservedQuantity: 0,
availableQuantity: 1,
openWorkOrderSupply: 1,
openPurchaseSupply: 0,
},
"mfg-1": {
onHandQuantity: 2,
reservedQuantity: 0,
availableQuantity: 2,
openWorkOrderSupply: 1,
openPurchaseSupply: 0,
},
"buy-1": {
onHandQuantity: 4,
reservedQuantity: 1,
availableQuantity: 3,
openWorkOrderSupply: 0,
openPurchaseSupply: 5,
},
"buy-2": {
onHandQuantity: 1,
reservedQuantity: 0,
availableQuantity: 1,
openWorkOrderSupply: 0,
openPurchaseSupply: 2,
},
},
};
const planning = buildSalesOrderPlanning(snapshot);
expect(planning.summary.totalBuildQuantity).toBe(6);
expect(planning.summary.totalPurchaseQuantity).toBe(10);
const assembly = planning.items.find((item) => item.itemId === "assembly-1");
const manufacturedChild = planning.items.find((item) => item.itemId === "mfg-1");
const purchasedChild = planning.items.find((item) => item.itemId === "buy-1");
const rawMaterial = planning.items.find((item) => item.itemId === "buy-2");
expect(assembly?.recommendedBuildQuantity).toBe(3);
expect(assembly?.supplyFromStock).toBe(1);
expect(assembly?.supplyFromOpenWorkOrders).toBe(1);
expect(manufacturedChild?.grossDemand).toBe(6);
expect(manufacturedChild?.recommendedBuildQuantity).toBe(3);
expect(manufacturedChild?.supplyFromStock).toBe(2);
expect(manufacturedChild?.supplyFromOpenWorkOrders).toBe(1);
expect(purchasedChild?.grossDemand).toBe(9);
expect(purchasedChild?.recommendedPurchaseQuantity).toBe(1);
expect(purchasedChild?.supplyFromStock).toBe(3);
expect(purchasedChild?.supplyFromOpenPurchaseOrders).toBe(5);
expect(rawMaterial?.grossDemand).toBe(12);
expect(rawMaterial?.recommendedPurchaseQuantity).toBe(9);
expect(rawMaterial?.supplyFromStock).toBe(1);
expect(rawMaterial?.supplyFromOpenPurchaseOrders).toBe(2);
});
});