demand planning
This commit is contained in:
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user