This commit is contained in:
2026-03-15 16:40:25 -05:00
parent 15116807ce
commit 59754c7657
33 changed files with 1620 additions and 49 deletions

View File

@@ -0,0 +1,13 @@
ALTER TABLE "InventoryItem" ADD COLUMN "preferredVendorId" TEXT;
ALTER TABLE "WorkOrder" ADD COLUMN "salesOrderId" TEXT;
ALTER TABLE "WorkOrder" ADD COLUMN "salesOrderLineId" TEXT;
ALTER TABLE "PurchaseOrderLine" ADD COLUMN "salesOrderId" TEXT;
ALTER TABLE "PurchaseOrderLine" ADD COLUMN "salesOrderLineId" TEXT;
CREATE INDEX "InventoryItem_preferredVendorId_idx" ON "InventoryItem"("preferredVendorId");
CREATE INDEX "WorkOrder_salesOrderId_dueDate_idx" ON "WorkOrder"("salesOrderId", "dueDate");
CREATE INDEX "WorkOrder_salesOrderLineId_dueDate_idx" ON "WorkOrder"("salesOrderLineId", "dueDate");
CREATE INDEX "PurchaseOrderLine_salesOrderId_position_idx" ON "PurchaseOrderLine"("salesOrderId", "position");
CREATE INDEX "PurchaseOrderLine_salesOrderLineId_position_idx" ON "PurchaseOrderLine"("salesOrderLineId", "position");

View File

@@ -122,6 +122,7 @@ model InventoryItem {
unitOfMeasure String
isSellable Boolean @default(true)
isPurchasable Boolean @default(true)
preferredVendorId String?
defaultCost Float?
defaultPrice Float?
notes String
@@ -138,6 +139,9 @@ model InventoryItem {
operations InventoryItemOperation[]
reservations InventoryReservation[]
transfers InventoryTransfer[]
preferredVendor Vendor? @relation(fields: [preferredVendorId], references: [id], onDelete: SetNull)
@@index([preferredVendorId])
}
model Warehouse {
@@ -325,6 +329,7 @@ model Vendor {
contactEntries CrmContactEntry[]
contacts CrmContact[]
purchaseOrders PurchaseOrder[]
preferredSupplyItems InventoryItem[]
}
model CrmContactEntry {
@@ -417,6 +422,8 @@ model SalesOrder {
shipments Shipment[]
projects Project[]
revisions SalesOrderRevision[]
workOrders WorkOrder[]
purchaseOrderLines PurchaseOrderLine[]
}
model SalesOrderLine {
@@ -432,6 +439,8 @@ model SalesOrderLine {
updatedAt DateTime @updatedAt
order SalesOrder @relation(fields: [orderId], references: [id], onDelete: Cascade)
item InventoryItem @relation(fields: [itemId], references: [id], onDelete: Restrict)
workOrders WorkOrder[]
purchaseOrderLines PurchaseOrderLine[]
@@index([orderId, position])
}
@@ -519,6 +528,8 @@ model WorkOrder {
workOrderNumber String @unique
itemId String
projectId String?
salesOrderId String?
salesOrderLineId String?
warehouseId String
locationId String
status String
@@ -530,6 +541,8 @@ model WorkOrder {
updatedAt DateTime @updatedAt
item InventoryItem @relation(fields: [itemId], references: [id], onDelete: Restrict)
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
salesOrder SalesOrder? @relation(fields: [salesOrderId], references: [id], onDelete: SetNull)
salesOrderLine SalesOrderLine? @relation(fields: [salesOrderLineId], references: [id], onDelete: SetNull)
warehouse Warehouse @relation(fields: [warehouseId], references: [id], onDelete: Restrict)
location WarehouseLocation @relation(fields: [locationId], references: [id], onDelete: Restrict)
operations WorkOrderOperation[]
@@ -539,6 +552,8 @@ model WorkOrder {
@@index([itemId, createdAt])
@@index([projectId, dueDate])
@@index([salesOrderId, dueDate])
@@index([salesOrderLineId, dueDate])
@@index([status, dueDate])
@@index([warehouseId, createdAt])
}
@@ -650,6 +665,8 @@ model PurchaseOrderLine {
id String @id @default(cuid())
purchaseOrderId String
itemId String
salesOrderId String?
salesOrderLineId String?
description String
quantity Int
unitOfMeasure String
@@ -659,9 +676,13 @@ model PurchaseOrderLine {
updatedAt DateTime @updatedAt
purchaseOrder PurchaseOrder @relation(fields: [purchaseOrderId], references: [id], onDelete: Cascade)
item InventoryItem @relation(fields: [itemId], references: [id], onDelete: Restrict)
salesOrder SalesOrder? @relation(fields: [salesOrderId], references: [id], onDelete: SetNull)
salesOrderLine SalesOrderLine? @relation(fields: [salesOrderLineId], references: [id], onDelete: SetNull)
receiptLines PurchaseReceiptLine[]
@@index([purchaseOrderId, position])
@@index([salesOrderId, position])
@@index([salesOrderLineId, position])
}
model PurchaseReceipt {

View File

@@ -47,6 +47,7 @@ const inventoryItemSchema = z.object({
unitOfMeasure: z.enum(inventoryUnitsOfMeasure),
isSellable: z.boolean(),
isPurchasable: z.boolean(),
preferredVendorId: z.string().trim().min(1).nullable(),
defaultCost: z.number().nonnegative().nullable(),
defaultPrice: z.number().nonnegative().nullable(),
notes: z.string(),

View File

@@ -65,6 +65,10 @@ type InventoryDetailRecord = {
unitOfMeasure: string;
isSellable: boolean;
isPurchasable: boolean;
preferredVendor: {
id: string;
name: string;
} | null;
defaultCost: number | null;
defaultPrice: number | null;
notes: string;
@@ -392,6 +396,8 @@ function mapDetail(record: InventoryDetailRecord): InventoryItemDetailDto {
description: record.description,
defaultCost: record.defaultCost,
defaultPrice: record.defaultPrice,
preferredVendorId: record.preferredVendor?.id ?? null,
preferredVendorName: record.preferredVendor?.name ?? null,
notes: record.notes,
createdAt: record.createdAt.toISOString(),
bomLines: record.bomLines.slice().sort((a, b) => a.position - b.position).map(mapBomLine),
@@ -595,6 +601,30 @@ async function validateOperations(type: InventoryItemType, operations: Inventory
return { ok: true as const, operations: normalized };
}
async function validatePreferredVendor(isPurchasable: boolean, preferredVendorId: string | null) {
if (!preferredVendorId) {
return { ok: true as const };
}
if (!isPurchasable) {
return { ok: false as const, reason: "Only purchasable items may define a preferred vendor." };
}
const vendor = await prisma.vendor.findUnique({
where: { id: preferredVendorId },
select: {
id: true,
status: true,
},
});
if (!vendor || vendor.status === "INACTIVE") {
return { ok: false as const, reason: "Preferred vendor was not found or is inactive." };
}
return { ok: true as const };
}
async function getActiveReservedQuantity(itemId: string, warehouseId: string, locationId: string) {
const reservations = await prisma.inventoryReservation.findMany({
where: {
@@ -639,7 +669,14 @@ export async function listInventoryItemOptions() {
sku: true,
name: true,
isPurchasable: true,
defaultCost: true,
defaultPrice: true,
preferredVendor: {
select: {
id: true,
name: true,
},
},
},
orderBy: [{ sku: "asc" }],
});
@@ -649,7 +686,10 @@ export async function listInventoryItemOptions() {
sku: item.sku,
name: item.name,
isPurchasable: item.isPurchasable,
defaultCost: item.defaultCost,
defaultPrice: item.defaultPrice,
preferredVendorId: item.preferredVendor?.id ?? null,
preferredVendorName: item.preferredVendor?.name ?? null,
}));
}
@@ -681,6 +721,12 @@ export async function getInventoryItemById(itemId: string) {
},
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
},
preferredVendor: {
select: {
id: true,
name: true,
},
},
inventoryTransactions: {
include: {
warehouse: {
@@ -1027,6 +1073,10 @@ export async function createInventoryItem(payload: InventoryItemInput, actorId?:
if (!validatedOperations.ok) {
return null;
}
const validatedPreferredVendor = await validatePreferredVendor(payload.isPurchasable, payload.preferredVendorId);
if (!validatedPreferredVendor.ok) {
return null;
}
const item = await prisma.inventoryItem.create({
data: {
@@ -1038,6 +1088,7 @@ export async function createInventoryItem(payload: InventoryItemInput, actorId?:
unitOfMeasure: payload.unitOfMeasure,
isSellable: payload.isSellable,
isPurchasable: payload.isPurchasable,
preferredVendorId: payload.preferredVendorId,
defaultCost: payload.defaultCost,
defaultPrice: payload.defaultPrice,
notes: payload.notes,
@@ -1091,6 +1142,10 @@ export async function updateInventoryItem(itemId: string, payload: InventoryItem
if (!validatedOperations.ok) {
return null;
}
const validatedPreferredVendor = await validatePreferredVendor(payload.isPurchasable, payload.preferredVendorId);
if (!validatedPreferredVendor.ok) {
return null;
}
const item = await prisma.inventoryItem.update({
where: { id: itemId },
@@ -1103,6 +1158,7 @@ export async function updateInventoryItem(itemId: string, payload: InventoryItem
unitOfMeasure: payload.unitOfMeasure,
isSellable: payload.isSellable,
isPurchasable: payload.isPurchasable,
preferredVendorId: payload.preferredVendorId,
defaultCost: payload.defaultCost,
defaultPrice: payload.defaultPrice,
notes: payload.notes,

View File

@@ -30,6 +30,8 @@ const stationSchema = z.object({
const workOrderSchema = z.object({
itemId: z.string().trim().min(1),
projectId: z.string().trim().min(1).nullable(),
salesOrderId: z.string().trim().min(1).nullable(),
salesOrderLineId: z.string().trim().min(1).nullable(),
status: z.enum(workOrderStatuses),
quantity: z.number().int().positive(),
warehouseId: z.string().trim().min(1),

View File

@@ -75,6 +75,13 @@ type WorkOrderRecord = {
name: string;
};
} | null;
salesOrder: {
id: string;
documentNumber: string;
} | null;
salesOrderLine: {
id: string;
} | null;
warehouse: {
id: string;
code: string;
@@ -191,6 +198,17 @@ function buildInclude() {
},
},
},
salesOrder: {
select: {
id: true,
documentNumber: true,
},
},
salesOrderLine: {
select: {
id: true,
},
},
warehouse: {
select: {
id: true,
@@ -278,6 +296,9 @@ function mapSummary(record: WorkOrderRecord): WorkOrderSummaryDto {
projectId: record.project?.id ?? null,
projectNumber: record.project?.projectNumber ?? null,
projectName: record.project?.name ?? null,
salesOrderId: record.salesOrder?.id ?? null,
salesOrderLineId: record.salesOrderLine?.id ?? null,
salesOrderNumber: record.salesOrder?.documentNumber ?? null,
quantity: record.quantity,
completedQuantity: record.completedQuantity,
dueDate: record.dueDate ? record.dueDate.toISOString() : null,
@@ -293,7 +314,10 @@ function mapSummary(record: WorkOrderRecord): WorkOrderSummaryDto {
};
}
function mapDetail(record: WorkOrderRecord): WorkOrderDetailDto {
function mapDetail(
record: WorkOrderRecord,
componentAvailability: Map<string, { onHandQuantity: number; reservedQuantity: number }>
): WorkOrderDetailDto {
const issuedByComponent = new Map<string, number>();
for (const issue of record.materialIssues) {
@@ -325,6 +349,11 @@ function mapDetail(record: WorkOrderRecord): WorkOrderDetailDto {
materialRequirements: record.item.bomLines.map((line) => {
const requiredQuantity = line.quantity * record.quantity;
const issuedQuantity = issuedByComponent.get(line.componentItem.id) ?? 0;
const availability = componentAvailability.get(line.componentItem.id) ?? { onHandQuantity: 0, reservedQuantity: 0 };
const onHandQuantity = availability.onHandQuantity;
const reservedQuantity = availability.reservedQuantity;
const availableQuantity = onHandQuantity - reservedQuantity;
const shortageQuantity = Math.max(requiredQuantity - issuedQuantity - Math.max(availableQuantity, 0), 0);
return {
componentItemId: line.componentItem.id,
@@ -335,6 +364,10 @@ function mapDetail(record: WorkOrderRecord): WorkOrderDetailDto {
requiredQuantity,
issuedQuantity,
remainingQuantity: Math.max(requiredQuantity - issuedQuantity, 0),
onHandQuantity,
reservedQuantity,
availableQuantity,
shortageQuantity,
};
}),
materialIssues: record.materialIssues.map((issue) => ({
@@ -531,6 +564,63 @@ async function getItemLocationOnHand(itemId: string, warehouseId: string, locati
}, 0);
}
async function getComponentAvailability(workOrder: WorkOrderRecord) {
const componentItemIds = [...new Set(workOrder.item.bomLines.map((line) => line.componentItem.id))];
if (componentItemIds.length === 0) {
return new Map<string, { onHandQuantity: number; reservedQuantity: number }>();
}
const [transactions, reservations] = await Promise.all([
prisma.inventoryTransaction.findMany({
where: {
itemId: { in: componentItemIds },
warehouseId: workOrder.warehouse.id,
locationId: workOrder.location.id,
},
select: {
itemId: true,
transactionType: true,
quantity: true,
},
}),
prisma.inventoryReservation.findMany({
where: {
itemId: { in: componentItemIds },
warehouseId: workOrder.warehouse.id,
locationId: workOrder.location.id,
status: "ACTIVE",
},
select: {
itemId: true,
quantity: true,
},
}),
]);
const availability = new Map<string, { onHandQuantity: number; reservedQuantity: number }>();
for (const itemId of componentItemIds) {
availability.set(itemId, { onHandQuantity: 0, reservedQuantity: 0 });
}
for (const transaction of transactions) {
const current = availability.get(transaction.itemId);
if (!current) {
continue;
}
current.onHandQuantity += transaction.transactionType === "RECEIPT" || transaction.transactionType === "ADJUSTMENT_IN" ? transaction.quantity : -transaction.quantity;
}
for (const reservation of reservations) {
const current = availability.get(reservation.itemId);
if (!current) {
continue;
}
current.reservedQuantity += reservation.quantity;
}
return availability;
}
async function validateWorkOrderInput(payload: WorkOrderInput) {
const item = await prisma.inventoryItem.findUnique({
where: { id: payload.itemId },
@@ -573,6 +663,40 @@ async function validateWorkOrderInput(payload: WorkOrderInput) {
}
}
if (payload.salesOrderId) {
const salesOrder = await prisma.salesOrder.findUnique({
where: { id: payload.salesOrderId },
select: { id: true },
});
if (!salesOrder) {
return { ok: false as const, reason: "Linked sales order was not found." };
}
}
if (payload.salesOrderLineId) {
if (!payload.salesOrderId) {
return { ok: false as const, reason: "Linked sales-order line requires a linked sales order." };
}
const salesOrderLine = await prisma.salesOrderLine.findUnique({
where: { id: payload.salesOrderLineId },
select: {
id: true,
orderId: true,
itemId: true,
},
});
if (!salesOrderLine || salesOrderLine.orderId !== payload.salesOrderId) {
return { ok: false as const, reason: "Linked sales-order line was not found on the selected sales order." };
}
if (salesOrderLine.itemId !== payload.itemId) {
return { ok: false as const, reason: "Linked sales-order line item does not match the selected build item." };
}
}
const location = await prisma.warehouseLocation.findUnique({
where: { id: payload.locationId },
select: {
@@ -727,7 +851,11 @@ export async function getWorkOrderById(workOrderId: string) {
include: buildInclude(),
});
return workOrder ? mapDetail(workOrder as WorkOrderRecord) : null;
if (!workOrder) {
return null;
}
return mapDetail(workOrder as WorkOrderRecord, await getComponentAvailability(workOrder as WorkOrderRecord));
}
export async function createWorkOrder(payload: WorkOrderInput, actorId?: string | null) {
@@ -742,6 +870,8 @@ export async function createWorkOrder(payload: WorkOrderInput, actorId?: string
workOrderNumber,
itemId: payload.itemId,
projectId: payload.projectId,
salesOrderId: payload.salesOrderId,
salesOrderLineId: payload.salesOrderLineId,
warehouseId: payload.warehouseId,
locationId: payload.locationId,
status: payload.status,
@@ -804,6 +934,8 @@ export async function updateWorkOrder(workOrderId: string, payload: WorkOrderInp
data: {
itemId: payload.itemId,
projectId: payload.projectId,
salesOrderId: payload.salesOrderId,
salesOrderLineId: payload.salesOrderLineId,
warehouseId: payload.warehouseId,
locationId: payload.locationId,
status: payload.status,
@@ -916,7 +1048,7 @@ export async function issueWorkOrderMaterial(workOrderId: string, payload: WorkO
return { ok: false as const, reason: "Warehouse location is invalid for the selected warehouse." };
}
const currentDetail = mapDetail(workOrder as WorkOrderRecord);
const currentDetail = mapDetail(workOrder as WorkOrderRecord, await getComponentAvailability(workOrder as WorkOrderRecord));
const currentRequirement = currentDetail.materialRequirements.find(
(requirement: WorkOrderDetailDto["materialRequirements"][number]) => requirement.componentItemId === payload.componentItemId
);

View File

@@ -17,6 +17,8 @@ import {
const purchaseLineSchema = z.object({
itemId: z.string().trim().min(1),
salesOrderId: z.string().trim().min(1).nullable().optional(),
salesOrderLineId: z.string().trim().min(1).nullable().optional(),
description: z.string(),
quantity: z.number().int().positive(),
unitOfMeasure: z.enum(inventoryUnitsOfMeasure),

View File

@@ -45,6 +45,8 @@ export interface PurchaseOrderPdfData {
type PurchaseLineRecord = {
id: string;
salesOrderId: string | null;
salesOrderLineId: string | null;
description: string;
quantity: number;
unitOfMeasure: string;
@@ -58,6 +60,10 @@ type PurchaseLineRecord = {
sku: string;
name: string;
};
salesOrder: {
id: string;
documentNumber: string;
} | null;
};
type PurchaseReceiptLineRecord = {
@@ -175,6 +181,8 @@ function normalizeLines(lines: PurchaseLineInput[]) {
return lines
.map((line, index) => ({
itemId: line.itemId,
salesOrderId: line.salesOrderId ?? null,
salesOrderLineId: line.salesOrderLineId ?? null,
description: line.description.trim(),
quantity: Number(line.quantity),
unitOfMeasure: line.unitOfMeasure,
@@ -213,6 +221,63 @@ async function validateLines(lines: PurchaseLineInput[]) {
return { ok: false as const, reason: "Purchase orders can only include purchasable inventory items." };
}
const salesOrderIds = [...new Set(normalized.flatMap((line) => (line.salesOrderId ? [line.salesOrderId] : [])))];
const salesOrderLineIds = [...new Set(normalized.flatMap((line) => (line.salesOrderLineId ? [line.salesOrderLineId] : [])))];
if (normalized.some((line) => line.salesOrderLineId && !line.salesOrderId)) {
return { ok: false as const, reason: "Linked sales-order lines require a linked sales order." };
}
if (salesOrderIds.length > 0) {
const salesOrders = await prisma.salesOrder.findMany({
where: { id: { in: salesOrderIds } },
select: { id: true },
});
if (salesOrders.length !== salesOrderIds.length) {
return { ok: false as const, reason: "One or more linked sales orders do not exist." };
}
}
if (salesOrderLineIds.length > 0) {
const salesOrderLines = await prisma.salesOrderLine.findMany({
where: { id: { in: salesOrderLineIds } },
select: {
id: true,
orderId: true,
itemId: true,
},
});
const salesOrderLinesById = new Map(
salesOrderLines.map((line) => [
line.id,
{
orderId: line.orderId,
itemId: line.itemId,
},
])
);
if (salesOrderLines.length !== salesOrderLineIds.length) {
return { ok: false as const, reason: "One or more linked sales-order lines do not exist." };
}
for (const line of normalized) {
if (!line.salesOrderLineId) {
continue;
}
const salesOrderLine = salesOrderLinesById.get(line.salesOrderLineId);
if (!salesOrderLine || salesOrderLine.orderId !== line.salesOrderId) {
return { ok: false as const, reason: "Linked sales-order line does not belong to the selected sales order." };
}
if (salesOrderLine.itemId !== line.itemId) {
return { ok: false as const, reason: "Linked sales-order line item does not match the purchase item." };
}
}
}
return { ok: true as const, lines: normalized };
}
@@ -240,6 +305,9 @@ function mapPurchaseOrder(record: PurchaseOrderRecord): PurchaseOrderDetailDto {
lineTotal: line.quantity * line.unitCost,
receivedQuantity: receivedByLineId.get(line.id) ?? 0,
remainingQuantity: Math.max(0, line.quantity - (receivedByLineId.get(line.id) ?? 0)),
salesOrderId: line.salesOrderId,
salesOrderLineId: line.salesOrderLineId,
salesOrderNumber: line.salesOrder?.documentNumber ?? null,
position: line.position,
}));
const totals = calculateTotals(
@@ -305,6 +373,12 @@ const purchaseOrderInclude = Prisma.validator<Prisma.PurchaseOrderInclude>()({
name: true,
},
},
salesOrder: {
select: {
id: true,
documentNumber: true,
},
},
},
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
},

View File

@@ -1,4 +1,11 @@
import type { SalesDocumentStatus, SalesOrderPlanningDto, SalesOrderPlanningItemDto, SalesOrderPlanningNodeDto } from "@mrp/shared/dist/sales/types.js";
import type {
DemandPlanningProjectSummaryDto,
DemandPlanningRollupDto,
SalesDocumentStatus,
SalesOrderPlanningDto,
SalesOrderPlanningItemDto,
SalesOrderPlanningNodeDto,
} from "@mrp/shared/dist/sales/types.js";
import { prisma } from "../../lib/prisma.js";
type PlanningItemSnapshot = {
@@ -32,6 +39,11 @@ type PlanningSupplySnapshot = {
openPurchaseSupply: number;
};
type PlanningLinkedSupplySnapshot = {
linkedWorkOrderSupply: number;
linkedPurchaseSupply: number;
};
export type SalesOrderPlanningSnapshot = {
orderId: string;
documentNumber: string;
@@ -39,6 +51,23 @@ export type SalesOrderPlanningSnapshot = {
lines: PlanningLineSnapshot[];
itemsById: Record<string, PlanningItemSnapshot>;
supplyByItemId: Record<string, PlanningSupplySnapshot>;
orderLinkedSupplyByItemId: Record<string, PlanningLinkedSupplySnapshot>;
lineLinkedSupplyByLineId: Record<string, Record<string, PlanningLinkedSupplySnapshot>>;
};
type DemandPlanningOrderSnapshot = {
orderId: string;
documentNumber: string;
status: SalesDocumentStatus;
issueDate: string;
projectSummaries: Array<{
projectId: string;
projectNumber: string;
projectName: string;
}>;
lines: PlanningLineSnapshot[];
orderLinkedSupplyByItemId: Record<string, PlanningLinkedSupplySnapshot>;
lineLinkedSupplyByLineId: Record<string, Record<string, PlanningLinkedSupplySnapshot>>;
};
type MutableSupplyState = {
@@ -47,6 +76,11 @@ type MutableSupplyState = {
remainingOpenPurchaseSupply: number;
};
type MutableLinkedSupplyState = {
remainingLinkedWorkOrderSupply: number;
remainingLinkedPurchaseSupply: number;
};
function createEmptySupplySnapshot(): PlanningSupplySnapshot {
return {
onHandQuantity: 0,
@@ -57,6 +91,13 @@ function createEmptySupplySnapshot(): PlanningSupplySnapshot {
};
}
function createEmptyLinkedSupplySnapshot(): PlanningLinkedSupplySnapshot {
return {
linkedWorkOrderSupply: 0,
linkedPurchaseSupply: 0,
};
}
function createMutableSupplyState(snapshot: PlanningSupplySnapshot): MutableSupplyState {
return {
remainingAvailableQuantity: Math.max(snapshot.availableQuantity, 0),
@@ -65,6 +106,13 @@ function createMutableSupplyState(snapshot: PlanningSupplySnapshot): MutableSupp
};
}
function createMutableLinkedSupplyState(snapshot: PlanningLinkedSupplySnapshot): MutableLinkedSupplyState {
return {
remainingLinkedWorkOrderSupply: Math.max(snapshot.linkedWorkOrderSupply, 0),
remainingLinkedPurchaseSupply: Math.max(snapshot.linkedPurchaseSupply, 0),
};
}
function isBuildItem(type: string) {
return type === "ASSEMBLY" || type === "MANUFACTURED";
}
@@ -75,6 +123,10 @@ function shouldBuyItem(item: PlanningItemSnapshot) {
export function buildSalesOrderPlanning(snapshot: SalesOrderPlanningSnapshot): SalesOrderPlanningDto {
const mutableSupplyByItemId = new Map<string, MutableSupplyState>();
const mutableOrderLinkedSupplyByItemId = new Map(
Object.entries(snapshot.orderLinkedSupplyByItemId).map(([itemId, supply]) => [itemId, createMutableLinkedSupplyState(supply)])
);
const mutableLineLinkedSupplyByLineId = new Map<string, Map<string, MutableLinkedSupplyState>>();
const aggregatedByItemId = new Map<string, SalesOrderPlanningItemDto>();
function getMutableSupply(itemId: string) {
@@ -105,6 +157,8 @@ export function buildSalesOrderPlanning(snapshot: SalesOrderPlanningSnapshot): S
onHandQuantity: supply.onHandQuantity,
reservedQuantity: supply.reservedQuantity,
availableQuantity: supply.availableQuantity,
linkedWorkOrderSupply: 0,
linkedPurchaseSupply: 0,
openWorkOrderSupply: supply.openWorkOrderSupply,
openPurchaseSupply: supply.openPurchaseSupply,
supplyFromStock: 0,
@@ -118,7 +172,43 @@ export function buildSalesOrderPlanning(snapshot: SalesOrderPlanningSnapshot): S
return created;
}
function planItemDemand(itemId: string, grossDemand: number, level: number, bomQuantityPerParent: number | null, ancestry: Set<string>): SalesOrderPlanningNodeDto {
function getMutableOrderLinkedSupply(itemId: string) {
const existing = mutableOrderLinkedSupplyByItemId.get(itemId);
if (existing) {
return existing;
}
const next = createMutableLinkedSupplyState(createEmptyLinkedSupplySnapshot());
mutableOrderLinkedSupplyByItemId.set(itemId, next);
return next;
}
function getMutableLineLinkedSupply(lineId: string, itemId: string) {
const existingLine = mutableLineLinkedSupplyByLineId.get(lineId);
const existingItem = existingLine?.get(itemId);
if (existingItem) {
return existingItem;
}
const next = createMutableLinkedSupplyState(
snapshot.lineLinkedSupplyByLineId[lineId]?.[itemId] ?? createEmptyLinkedSupplySnapshot()
);
if (existingLine) {
existingLine.set(itemId, next);
} else {
mutableLineLinkedSupplyByLineId.set(lineId, new Map([[itemId, next]]));
}
return next;
}
function planItemDemand(
lineId: string,
itemId: string,
grossDemand: number,
level: number,
bomQuantityPerParent: number | null,
ancestry: Set<string>
): SalesOrderPlanningNodeDto {
const item = snapshot.itemsById[itemId];
if (!item) {
return {
@@ -131,6 +221,8 @@ export function buildSalesOrderPlanning(snapshot: SalesOrderPlanningSnapshot): S
grossDemand,
availableBefore: 0,
availableAfter: 0,
linkedWorkOrderSupply: 0,
linkedPurchaseSupply: 0,
supplyFromStock: 0,
openWorkOrderSupply: 0,
openPurchaseSupply: 0,
@@ -145,12 +237,37 @@ export function buildSalesOrderPlanning(snapshot: SalesOrderPlanningSnapshot): S
}
const aggregate = getOrCreateAggregate(item);
const mutableLineLinkedSupply = getMutableLineLinkedSupply(lineId, itemId);
const mutableOrderLinkedSupply = getMutableOrderLinkedSupply(itemId);
const mutableSupply = getMutableSupply(itemId);
const availableBefore = mutableSupply.remainingAvailableQuantity;
const openWorkOrderSupply = mutableSupply.remainingOpenWorkOrderSupply;
const openPurchaseSupply = mutableSupply.remainingOpenPurchaseSupply;
let remainingDemand = grossDemand;
let linkedWorkOrderSupply = 0;
let linkedPurchaseSupply = 0;
if (isBuildItem(item.type)) {
linkedWorkOrderSupply = Math.min(mutableLineLinkedSupply.remainingLinkedWorkOrderSupply, remainingDemand);
mutableLineLinkedSupply.remainingLinkedWorkOrderSupply -= linkedWorkOrderSupply;
remainingDemand -= linkedWorkOrderSupply;
const orderLinkedWorkOrderSupply = Math.min(mutableOrderLinkedSupply.remainingLinkedWorkOrderSupply, remainingDemand);
mutableOrderLinkedSupply.remainingLinkedWorkOrderSupply -= orderLinkedWorkOrderSupply;
linkedWorkOrderSupply += orderLinkedWorkOrderSupply;
remainingDemand -= orderLinkedWorkOrderSupply;
} else if (shouldBuyItem(item)) {
linkedPurchaseSupply = Math.min(mutableLineLinkedSupply.remainingLinkedPurchaseSupply, remainingDemand);
mutableLineLinkedSupply.remainingLinkedPurchaseSupply -= linkedPurchaseSupply;
remainingDemand -= linkedPurchaseSupply;
const orderLinkedPurchaseSupply = Math.min(mutableOrderLinkedSupply.remainingLinkedPurchaseSupply, remainingDemand);
mutableOrderLinkedSupply.remainingLinkedPurchaseSupply -= orderLinkedPurchaseSupply;
linkedPurchaseSupply += orderLinkedPurchaseSupply;
remainingDemand -= orderLinkedPurchaseSupply;
}
const supplyFromStock = Math.min(availableBefore, remainingDemand);
mutableSupply.remainingAvailableQuantity -= supplyFromStock;
remainingDemand -= supplyFromStock;
@@ -176,6 +293,8 @@ export function buildSalesOrderPlanning(snapshot: SalesOrderPlanningSnapshot): S
}
aggregate.grossDemand += grossDemand;
aggregate.linkedWorkOrderSupply += linkedWorkOrderSupply;
aggregate.linkedPurchaseSupply += linkedPurchaseSupply;
aggregate.supplyFromStock += supplyFromStock;
aggregate.supplyFromOpenWorkOrders += supplyFromOpenWorkOrders;
aggregate.supplyFromOpenPurchaseOrders += supplyFromOpenPurchaseOrders;
@@ -190,7 +309,7 @@ export function buildSalesOrderPlanning(snapshot: SalesOrderPlanningSnapshot): S
for (const bomLine of item.bomLines) {
children.push(
planItemDemand(bomLine.componentItemId, recommendedBuildQuantity * bomLine.quantity, level + 1, bomLine.quantity, nextAncestry)
planItemDemand(lineId, bomLine.componentItemId, recommendedBuildQuantity * bomLine.quantity, level + 1, bomLine.quantity, nextAncestry)
);
}
}
@@ -205,6 +324,8 @@ export function buildSalesOrderPlanning(snapshot: SalesOrderPlanningSnapshot): S
grossDemand,
availableBefore,
availableAfter: mutableSupply.remainingAvailableQuantity,
linkedWorkOrderSupply,
linkedPurchaseSupply,
supplyFromStock,
openWorkOrderSupply,
openPurchaseSupply,
@@ -225,7 +346,7 @@ export function buildSalesOrderPlanning(snapshot: SalesOrderPlanningSnapshot): S
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>()),
rootNode: planItemDemand(line.id, line.itemId, line.quantity, 0, null, new Set<string>()),
}));
const items = [...aggregatedByItemId.values()].sort((left, right) => left.itemSku.localeCompare(right.itemSku));
@@ -250,6 +371,353 @@ export function buildSalesOrderPlanning(snapshot: SalesOrderPlanningSnapshot): S
};
}
function createAggregateFromNode(node: SalesOrderPlanningNodeDto): SalesOrderPlanningItemDto[] {
const grouped = new Map<string, SalesOrderPlanningItemDto>();
function visit(current: SalesOrderPlanningNodeDto) {
const existing = grouped.get(current.itemId);
if (existing) {
existing.grossDemand += current.grossDemand;
existing.linkedWorkOrderSupply += current.linkedWorkOrderSupply;
existing.linkedPurchaseSupply += current.linkedPurchaseSupply;
existing.supplyFromStock += current.supplyFromStock;
existing.supplyFromOpenWorkOrders += current.supplyFromOpenWorkOrders;
existing.supplyFromOpenPurchaseOrders += current.supplyFromOpenPurchaseOrders;
existing.recommendedBuildQuantity += current.recommendedBuildQuantity;
existing.recommendedPurchaseQuantity += current.recommendedPurchaseQuantity;
existing.uncoveredQuantity += current.uncoveredQuantity;
} else {
grouped.set(current.itemId, {
itemId: current.itemId,
itemSku: current.itemSku,
itemName: current.itemName,
itemType: current.itemType,
unitOfMeasure: current.unitOfMeasure,
grossDemand: current.grossDemand,
onHandQuantity: 0,
reservedQuantity: 0,
availableQuantity: 0,
linkedWorkOrderSupply: current.linkedWorkOrderSupply,
linkedPurchaseSupply: current.linkedPurchaseSupply,
openWorkOrderSupply: current.openWorkOrderSupply,
openPurchaseSupply: current.openPurchaseSupply,
supplyFromStock: current.supplyFromStock,
supplyFromOpenWorkOrders: current.supplyFromOpenWorkOrders,
supplyFromOpenPurchaseOrders: current.supplyFromOpenPurchaseOrders,
recommendedBuildQuantity: current.recommendedBuildQuantity,
recommendedPurchaseQuantity: current.recommendedPurchaseQuantity,
uncoveredQuantity: current.uncoveredQuantity,
});
}
for (const child of current.children) {
visit(child);
}
}
visit(node);
return [...grouped.values()];
}
export function buildDemandPlanningRollup(
orders: DemandPlanningOrderSnapshot[],
itemsById: Record<string, PlanningItemSnapshot>,
supplyByItemId: Record<string, PlanningSupplySnapshot>
): DemandPlanningRollupDto {
const mutableSupplyByItemId = new Map<string, MutableSupplyState>();
const itemRollups = new Map<string, SalesOrderPlanningItemDto>();
const projectRollups = new Map<string, DemandPlanningProjectSummaryDto>();
function getMutableSupply(itemId: string) {
const existing = mutableSupplyByItemId.get(itemId);
if (existing) {
return existing;
}
const next = createMutableSupplyState(supplyByItemId[itemId] ?? createEmptySupplySnapshot());
mutableSupplyByItemId.set(itemId, next);
return next;
}
function planItemDemand(
itemId: string,
grossDemand: number,
level: number,
bomQuantityPerParent: number | null,
ancestry: Set<string>,
mutableOrderLinkedSupplyByItemId: Map<string, MutableLinkedSupplyState>,
mutableLineLinkedSupplyByLineId: Map<string, Map<string, MutableLinkedSupplyState>>,
lineId: string
): SalesOrderPlanningNodeDto {
const item = itemsById[itemId];
if (!item) {
return {
itemId,
itemSku: "UNKNOWN",
itemName: "Missing item",
itemType: "UNKNOWN",
unitOfMeasure: "EA",
level,
grossDemand,
availableBefore: 0,
availableAfter: 0,
linkedWorkOrderSupply: 0,
linkedPurchaseSupply: 0,
supplyFromStock: 0,
openWorkOrderSupply: 0,
openPurchaseSupply: 0,
supplyFromOpenWorkOrders: 0,
supplyFromOpenPurchaseOrders: 0,
recommendedBuildQuantity: 0,
recommendedPurchaseQuantity: 0,
uncoveredQuantity: grossDemand,
bomQuantityPerParent,
children: [],
};
}
const lineSupplyMap = mutableLineLinkedSupplyByLineId.get(lineId);
const mutableLineLinkedSupply =
lineSupplyMap?.get(itemId) ?? createMutableLinkedSupplyState(createEmptyLinkedSupplySnapshot());
if (lineSupplyMap) {
if (!lineSupplyMap.has(itemId)) {
lineSupplyMap.set(itemId, mutableLineLinkedSupply);
}
} else {
mutableLineLinkedSupplyByLineId.set(lineId, new Map([[itemId, mutableLineLinkedSupply]]));
}
const mutableOrderLinkedSupply =
mutableOrderLinkedSupplyByItemId.get(itemId) ?? createMutableLinkedSupplyState(createEmptyLinkedSupplySnapshot());
if (!mutableOrderLinkedSupplyByItemId.has(itemId)) {
mutableOrderLinkedSupplyByItemId.set(itemId, mutableOrderLinkedSupply);
}
const mutableSupply = getMutableSupply(itemId);
const availableBefore = mutableSupply.remainingAvailableQuantity;
const openWorkOrderSupply = mutableSupply.remainingOpenWorkOrderSupply;
const openPurchaseSupply = mutableSupply.remainingOpenPurchaseSupply;
let remainingDemand = grossDemand;
let linkedWorkOrderSupply = 0;
let linkedPurchaseSupply = 0;
if (isBuildItem(item.type)) {
linkedWorkOrderSupply = Math.min(mutableLineLinkedSupply.remainingLinkedWorkOrderSupply, remainingDemand);
mutableLineLinkedSupply.remainingLinkedWorkOrderSupply -= linkedWorkOrderSupply;
remainingDemand -= linkedWorkOrderSupply;
const orderLinkedWorkOrderSupply = Math.min(mutableOrderLinkedSupply.remainingLinkedWorkOrderSupply, remainingDemand);
mutableOrderLinkedSupply.remainingLinkedWorkOrderSupply -= orderLinkedWorkOrderSupply;
linkedWorkOrderSupply += orderLinkedWorkOrderSupply;
remainingDemand -= orderLinkedWorkOrderSupply;
} else if (shouldBuyItem(item)) {
linkedPurchaseSupply = Math.min(mutableLineLinkedSupply.remainingLinkedPurchaseSupply, remainingDemand);
mutableLineLinkedSupply.remainingLinkedPurchaseSupply -= linkedPurchaseSupply;
remainingDemand -= linkedPurchaseSupply;
const orderLinkedPurchaseSupply = Math.min(mutableOrderLinkedSupply.remainingLinkedPurchaseSupply, remainingDemand);
mutableOrderLinkedSupply.remainingLinkedPurchaseSupply -= orderLinkedPurchaseSupply;
linkedPurchaseSupply += orderLinkedPurchaseSupply;
remainingDemand -= orderLinkedPurchaseSupply;
}
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;
}
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,
mutableOrderLinkedSupplyByItemId,
mutableLineLinkedSupplyByLineId,
lineId
)
);
}
}
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,
linkedWorkOrderSupply,
linkedPurchaseSupply,
supplyFromStock,
openWorkOrderSupply,
openPurchaseSupply,
supplyFromOpenWorkOrders,
supplyFromOpenPurchaseOrders,
recommendedBuildQuantity,
recommendedPurchaseQuantity,
uncoveredQuantity,
bomQuantityPerParent,
children,
};
}
for (const order of orders.slice().sort((left, right) => new Date(left.issueDate).getTime() - new Date(right.issueDate).getTime())) {
const orderItems = new Map<string, SalesOrderPlanningItemDto>();
const mutableOrderLinkedSupplyByItemId = new Map(
Object.entries(order.orderLinkedSupplyByItemId).map(([itemId, supply]) => [itemId, createMutableLinkedSupplyState(supply)])
);
const mutableLineLinkedSupplyByLineId = new Map(
Object.entries(order.lineLinkedSupplyByLineId).map(([lineId, items]) => [
lineId,
new Map(Object.entries(items).map(([itemId, supply]) => [itemId, createMutableLinkedSupplyState(supply)])),
])
);
for (const line of order.lines) {
const rootNode = planItemDemand(
line.itemId,
line.quantity,
0,
null,
new Set<string>(),
mutableOrderLinkedSupplyByItemId,
mutableLineLinkedSupplyByLineId,
line.id
);
for (const aggregate of createAggregateFromNode(rootNode)) {
const existingItem = itemRollups.get(aggregate.itemId);
const supply = supplyByItemId[aggregate.itemId] ?? createEmptySupplySnapshot();
if (existingItem) {
existingItem.grossDemand += aggregate.grossDemand;
existingItem.linkedWorkOrderSupply += aggregate.linkedWorkOrderSupply;
existingItem.linkedPurchaseSupply += aggregate.linkedPurchaseSupply;
existingItem.supplyFromStock += aggregate.supplyFromStock;
existingItem.supplyFromOpenWorkOrders += aggregate.supplyFromOpenWorkOrders;
existingItem.supplyFromOpenPurchaseOrders += aggregate.supplyFromOpenPurchaseOrders;
existingItem.recommendedBuildQuantity += aggregate.recommendedBuildQuantity;
existingItem.recommendedPurchaseQuantity += aggregate.recommendedPurchaseQuantity;
existingItem.uncoveredQuantity += aggregate.uncoveredQuantity;
} else {
itemRollups.set(aggregate.itemId, {
...aggregate,
onHandQuantity: supply.onHandQuantity,
reservedQuantity: supply.reservedQuantity,
availableQuantity: supply.availableQuantity,
openWorkOrderSupply: supply.openWorkOrderSupply,
openPurchaseSupply: supply.openPurchaseSupply,
});
}
const existingOrderItem = orderItems.get(aggregate.itemId);
if (existingOrderItem) {
existingOrderItem.grossDemand += aggregate.grossDemand;
existingOrderItem.linkedWorkOrderSupply += aggregate.linkedWorkOrderSupply;
existingOrderItem.linkedPurchaseSupply += aggregate.linkedPurchaseSupply;
existingOrderItem.supplyFromStock += aggregate.supplyFromStock;
existingOrderItem.supplyFromOpenWorkOrders += aggregate.supplyFromOpenWorkOrders;
existingOrderItem.supplyFromOpenPurchaseOrders += aggregate.supplyFromOpenPurchaseOrders;
existingOrderItem.recommendedBuildQuantity += aggregate.recommendedBuildQuantity;
existingOrderItem.recommendedPurchaseQuantity += aggregate.recommendedPurchaseQuantity;
existingOrderItem.uncoveredQuantity += aggregate.uncoveredQuantity;
} else {
orderItems.set(aggregate.itemId, { ...aggregate });
}
}
}
for (const project of order.projectSummaries) {
const existingProject = projectRollups.get(project.projectId);
const metrics = [...orderItems.values()];
const nextProject: DemandPlanningProjectSummaryDto = existingProject ?? {
projectId: project.projectId,
projectNumber: project.projectNumber,
projectName: project.projectName,
salesOrderId: order.orderId,
salesOrderNumber: order.documentNumber,
totalBuildQuantity: 0,
totalPurchaseQuantity: 0,
totalUncoveredQuantity: 0,
buildRecommendationCount: 0,
purchaseRecommendationCount: 0,
uncoveredItemCount: 0,
};
nextProject.totalBuildQuantity += metrics.reduce((sum, item) => sum + item.recommendedBuildQuantity, 0);
nextProject.totalPurchaseQuantity += metrics.reduce((sum, item) => sum + item.recommendedPurchaseQuantity, 0);
nextProject.totalUncoveredQuantity += metrics.reduce((sum, item) => sum + item.uncoveredQuantity, 0);
nextProject.buildRecommendationCount += metrics.filter((item) => item.recommendedBuildQuantity > 0).length;
nextProject.purchaseRecommendationCount += metrics.filter((item) => item.recommendedPurchaseQuantity > 0).length;
nextProject.uncoveredItemCount += metrics.filter((item) => item.uncoveredQuantity > 0).length;
projectRollups.set(project.projectId, nextProject);
}
}
const items = [...itemRollups.values()].sort((left, right) => {
if (right.uncoveredQuantity !== left.uncoveredQuantity) {
return right.uncoveredQuantity - left.uncoveredQuantity;
}
if (right.recommendedPurchaseQuantity !== left.recommendedPurchaseQuantity) {
return right.recommendedPurchaseQuantity - left.recommendedPurchaseQuantity;
}
if (right.recommendedBuildQuantity !== left.recommendedBuildQuantity) {
return right.recommendedBuildQuantity - left.recommendedBuildQuantity;
}
return left.itemSku.localeCompare(right.itemSku);
});
const projects = [...projectRollups.values()].sort((left, right) => {
if (right.totalUncoveredQuantity !== left.totalUncoveredQuantity) {
return right.totalUncoveredQuantity - left.totalUncoveredQuantity;
}
return left.projectNumber.localeCompare(right.projectNumber);
});
return {
generatedAt: new Date().toISOString(),
summary: {
orderCount: orders.length,
projectCount: projects.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),
},
items,
projects,
};
}
async function collectPlanningItems(rootItemIds: string[]) {
const itemsById = new Map<string, PlanningItemSnapshot>();
let pendingItemIds = [...new Set(rootItemIds.filter((itemId) => itemId.trim().length > 0))];
@@ -366,6 +834,8 @@ export async function getSalesOrderPlanningById(orderId: string): Promise<SalesO
itemId: true,
quantity: true,
completedQuantity: true,
salesOrderId: true,
salesOrderLineId: true,
},
}),
prisma.purchaseOrderLine.findMany({
@@ -382,6 +852,8 @@ export async function getSalesOrderPlanningById(orderId: string): Promise<SalesO
select: {
itemId: true,
quantity: true,
salesOrderId: true,
salesOrderLineId: true,
receiptLines: {
select: {
quantity: true,
@@ -392,6 +864,12 @@ export async function getSalesOrderPlanningById(orderId: string): Promise<SalesO
]);
const supplyByItemId = Object.fromEntries(itemIds.map((itemId) => [itemId, createEmptySupplySnapshot()])) as Record<string, PlanningSupplySnapshot>;
const orderLinkedSupplyByItemId = Object.fromEntries(
itemIds.map((itemId) => [itemId, createEmptyLinkedSupplySnapshot()])
) as Record<string, PlanningLinkedSupplySnapshot>;
const lineLinkedSupplyByLineId = Object.fromEntries(
order.lines.map((line) => [line.id, {} as Record<string, PlanningLinkedSupplySnapshot>])
) as Record<string, Record<string, PlanningLinkedSupplySnapshot>>;
for (const transaction of transactions) {
const supply = supplyByItemId[transaction.itemId];
@@ -414,6 +892,27 @@ export async function getSalesOrderPlanningById(orderId: string): Promise<SalesO
}
for (const workOrder of workOrders) {
if (workOrder.salesOrderId === order.id) {
const remainingQuantity = Math.max(workOrder.quantity - workOrder.completedQuantity, 0);
if (workOrder.salesOrderLineId) {
const lineSupplyByItemId = lineLinkedSupplyByLineId[workOrder.salesOrderLineId] ?? {};
const current =
lineSupplyByItemId[workOrder.itemId] ?? createEmptyLinkedSupplySnapshot();
current.linkedWorkOrderSupply += remainingQuantity;
lineSupplyByItemId[workOrder.itemId] = current;
lineLinkedSupplyByLineId[workOrder.salesOrderLineId] = lineSupplyByItemId;
} else {
const current = orderLinkedSupplyByItemId[workOrder.itemId] ?? createEmptyLinkedSupplySnapshot();
current.linkedWorkOrderSupply += remainingQuantity;
orderLinkedSupplyByItemId[workOrder.itemId] = current;
}
continue;
}
if (workOrder.salesOrderId) {
continue;
}
const supply = supplyByItemId[workOrder.itemId];
if (!supply) {
continue;
@@ -423,15 +922,36 @@ export async function getSalesOrderPlanningById(orderId: string): Promise<SalesO
}
for (const line of purchaseOrderLines) {
const remainingQuantity = Math.max(
line.quantity - line.receiptLines.reduce((sum, receiptLine) => sum + receiptLine.quantity, 0),
0
);
if (line.salesOrderId === order.id) {
if (line.salesOrderLineId) {
const lineSupplyByItemId = lineLinkedSupplyByLineId[line.salesOrderLineId] ?? {};
const current = lineSupplyByItemId[line.itemId] ?? createEmptyLinkedSupplySnapshot();
current.linkedPurchaseSupply += remainingQuantity;
lineSupplyByItemId[line.itemId] = current;
lineLinkedSupplyByLineId[line.salesOrderLineId] = lineSupplyByItemId;
} else {
const current = orderLinkedSupplyByItemId[line.itemId] ?? createEmptyLinkedSupplySnapshot();
current.linkedPurchaseSupply += remainingQuantity;
orderLinkedSupplyByItemId[line.itemId] = current;
}
continue;
}
if (line.salesOrderId) {
continue;
}
const supply = supplyByItemId[line.itemId];
if (!supply) {
continue;
}
supply.openPurchaseSupply += Math.max(
line.quantity - line.receiptLines.reduce((sum, receiptLine) => sum + receiptLine.quantity, 0),
0
);
supply.openPurchaseSupply += remainingQuantity;
}
for (const itemId of itemIds) {
@@ -457,5 +977,229 @@ export async function getSalesOrderPlanningById(orderId: string): Promise<SalesO
})),
itemsById: Object.fromEntries(itemsById.entries()),
supplyByItemId,
orderLinkedSupplyByItemId,
lineLinkedSupplyByLineId,
});
}
export async function getDemandPlanningRollup(): Promise<DemandPlanningRollupDto> {
const orders = await prisma.salesOrder.findMany({
where: {
status: {
in: ["ISSUED", "APPROVED"],
},
},
select: {
id: true,
documentNumber: true,
status: true,
issueDate: true,
lines: {
select: {
id: true,
quantity: true,
unitOfMeasure: true,
item: {
select: {
id: true,
sku: true,
name: true,
},
},
},
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
},
projects: {
select: {
id: true,
projectNumber: true,
name: true,
},
},
},
orderBy: [{ issueDate: "asc" }, { createdAt: "asc" }],
});
const itemsById = await collectPlanningItems(orders.flatMap((order) => order.lines.map((line) => line.item.id)));
const itemIds = [...itemsById.keys()];
const orderIds = new Set(orders.map((order) => order.id));
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,
salesOrderId: true,
salesOrderLineId: true,
},
}),
prisma.purchaseOrderLine.findMany({
where: {
itemId: { in: itemIds },
purchaseOrder: {
status: {
not: "CLOSED",
},
},
},
select: {
itemId: true,
quantity: true,
salesOrderId: true,
salesOrderLineId: true,
receiptLines: {
select: {
quantity: true,
},
},
},
}),
]);
const supplyByItemId = Object.fromEntries(itemIds.map((itemId) => [itemId, createEmptySupplySnapshot()])) as Record<string, PlanningSupplySnapshot>;
const orderLinkedSupplyByOrderId = Object.fromEntries(
orders.map((order) => [
order.id,
Object.fromEntries(itemIds.map((itemId) => [itemId, createEmptyLinkedSupplySnapshot()])),
])
) as Record<string, Record<string, PlanningLinkedSupplySnapshot>>;
const lineLinkedSupplyByOrderId = Object.fromEntries(
orders.map((order) => [
order.id,
Object.fromEntries(order.lines.map((line) => [line.id, {} as Record<string, PlanningLinkedSupplySnapshot>])),
])
) as Record<string, Record<string, Record<string, PlanningLinkedSupplySnapshot>>>;
for (const transaction of transactions) {
const supply = supplyByItemId[transaction.itemId];
if (!supply) {
continue;
}
supply.onHandQuantity += transaction.transactionType === "RECEIPT" || transaction.transactionType === "ADJUSTMENT_IN" ? transaction.quantity : -transaction.quantity;
}
for (const reservation of reservations) {
const supply = supplyByItemId[reservation.itemId];
if (!supply) {
continue;
}
supply.reservedQuantity += reservation.quantity;
}
for (const workOrder of workOrders) {
const remainingQuantity = Math.max(workOrder.quantity - workOrder.completedQuantity, 0);
if (workOrder.salesOrderId && orderIds.has(workOrder.salesOrderId)) {
if (workOrder.salesOrderLineId) {
const lineSupplyByItemId = lineLinkedSupplyByOrderId[workOrder.salesOrderId]?.[workOrder.salesOrderLineId] ?? {};
const current =
lineSupplyByItemId[workOrder.itemId] ?? createEmptyLinkedSupplySnapshot();
current.linkedWorkOrderSupply += remainingQuantity;
lineSupplyByItemId[workOrder.itemId] = current;
if (!lineLinkedSupplyByOrderId[workOrder.salesOrderId]) {
lineLinkedSupplyByOrderId[workOrder.salesOrderId] = {};
}
lineLinkedSupplyByOrderId[workOrder.salesOrderId]![workOrder.salesOrderLineId] = lineSupplyByItemId;
} else {
const current =
orderLinkedSupplyByOrderId[workOrder.salesOrderId]?.[workOrder.itemId] ?? createEmptyLinkedSupplySnapshot();
current.linkedWorkOrderSupply += remainingQuantity;
if (!orderLinkedSupplyByOrderId[workOrder.salesOrderId]) {
orderLinkedSupplyByOrderId[workOrder.salesOrderId] = {};
}
orderLinkedSupplyByOrderId[workOrder.salesOrderId]![workOrder.itemId] = current;
}
continue;
}
if (workOrder.salesOrderId) {
continue;
}
const supply = supplyByItemId[workOrder.itemId];
if (!supply) {
continue;
}
supply.openWorkOrderSupply += remainingQuantity;
}
for (const line of purchaseOrderLines) {
const remainingQuantity = Math.max(line.quantity - line.receiptLines.reduce((sum, receiptLine) => sum + receiptLine.quantity, 0), 0);
if (line.salesOrderId && orderIds.has(line.salesOrderId)) {
if (line.salesOrderLineId) {
const lineSupplyByItemId = lineLinkedSupplyByOrderId[line.salesOrderId]?.[line.salesOrderLineId] ?? {};
const current = lineSupplyByItemId[line.itemId] ?? createEmptyLinkedSupplySnapshot();
current.linkedPurchaseSupply += remainingQuantity;
lineSupplyByItemId[line.itemId] = current;
if (!lineLinkedSupplyByOrderId[line.salesOrderId]) {
lineLinkedSupplyByOrderId[line.salesOrderId] = {};
}
lineLinkedSupplyByOrderId[line.salesOrderId]![line.salesOrderLineId] = lineSupplyByItemId;
} else {
const current =
orderLinkedSupplyByOrderId[line.salesOrderId]?.[line.itemId] ?? createEmptyLinkedSupplySnapshot();
current.linkedPurchaseSupply += remainingQuantity;
if (!orderLinkedSupplyByOrderId[line.salesOrderId]) {
orderLinkedSupplyByOrderId[line.salesOrderId] = {};
}
orderLinkedSupplyByOrderId[line.salesOrderId]![line.itemId] = current;
}
continue;
}
if (line.salesOrderId) {
continue;
}
const supply = supplyByItemId[line.itemId];
if (!supply) {
continue;
}
supply.openPurchaseSupply += remainingQuantity;
}
for (const itemId of itemIds) {
const supply = supplyByItemId[itemId];
if (!supply) {
continue;
}
supply.availableQuantity = supply.onHandQuantity - supply.reservedQuantity;
}
return buildDemandPlanningRollup(
orders.map((order) => ({
orderId: order.id,
documentNumber: order.documentNumber,
status: order.status as SalesDocumentStatus,
issueDate: order.issueDate.toISOString(),
projectSummaries: order.projects.map((project) => ({
projectId: project.id,
projectNumber: project.projectNumber,
projectName: project.name,
})),
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,
})),
orderLinkedSupplyByItemId:
orderLinkedSupplyByOrderId[order.id] ?? Object.fromEntries(itemIds.map((itemId) => [itemId, createEmptyLinkedSupplySnapshot()])),
lineLinkedSupplyByLineId:
lineLinkedSupplyByOrderId[order.id] ?? Object.fromEntries(order.lines.map((line) => [line.id, {} as Record<string, PlanningLinkedSupplySnapshot>])),
})),
Object.fromEntries(itemsById.entries()),
supplyByItemId
);
}

View File

@@ -18,7 +18,7 @@ import {
updateSalesDocumentStatus,
updateSalesDocument,
} from "./service.js";
import { getSalesOrderPlanningById } from "./planning.js";
import { getDemandPlanningRollup, getSalesOrderPlanningById } from "./planning.js";
const salesLineSchema = z.object({
itemId: z.string().trim().min(1),
@@ -231,6 +231,10 @@ salesRouter.get("/orders/:orderId/planning", requirePermissions([permissions.sal
return ok(response, planning);
});
salesRouter.get("/planning-rollup", requirePermissions([permissions.salesRead]), async (_request, response) => {
return ok(response, await getDemandPlanningRollup());
});
salesRouter.post("/orders", requirePermissions([permissions.salesWrite]), async (request, response) => {
const parsed = orderSchema.safeParse(request.body);
if (!parsed.success) {

View File

@@ -103,35 +103,64 @@ describe("sales order planning", () => {
openPurchaseSupply: 2,
},
},
orderLinkedSupplyByItemId: {
"assembly-1": {
linkedWorkOrderSupply: 1,
linkedPurchaseSupply: 0,
},
"mfg-1": {
linkedWorkOrderSupply: 0,
linkedPurchaseSupply: 0,
},
"buy-1": {
linkedWorkOrderSupply: 0,
linkedPurchaseSupply: 0,
},
"buy-2": {
linkedWorkOrderSupply: 0,
linkedPurchaseSupply: 3,
},
},
lineLinkedSupplyByLineId: {
"line-1": {
"buy-1": {
linkedWorkOrderSupply: 0,
linkedPurchaseSupply: 2,
},
},
},
};
const planning = buildSalesOrderPlanning(snapshot);
expect(planning.summary.totalBuildQuantity).toBe(6);
expect(planning.summary.totalPurchaseQuantity).toBe(10);
expect(planning.summary.totalBuildQuantity).toBe(3);
expect(planning.summary.totalPurchaseQuantity).toBe(0);
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?.linkedWorkOrderSupply).toBe(1);
expect(assembly?.recommendedBuildQuantity).toBe(2);
expect(assembly?.supplyFromStock).toBe(1);
expect(assembly?.supplyFromOpenWorkOrders).toBe(1);
expect(manufacturedChild?.grossDemand).toBe(6);
expect(manufacturedChild?.recommendedBuildQuantity).toBe(3);
expect(manufacturedChild?.grossDemand).toBe(4);
expect(manufacturedChild?.recommendedBuildQuantity).toBe(1);
expect(manufacturedChild?.supplyFromStock).toBe(2);
expect(manufacturedChild?.supplyFromOpenWorkOrders).toBe(1);
expect(purchasedChild?.grossDemand).toBe(9);
expect(purchasedChild?.recommendedPurchaseQuantity).toBe(1);
expect(purchasedChild?.grossDemand).toBe(6);
expect(purchasedChild?.linkedPurchaseSupply).toBe(2);
expect(purchasedChild?.recommendedPurchaseQuantity).toBe(0);
expect(purchasedChild?.supplyFromStock).toBe(3);
expect(purchasedChild?.supplyFromOpenPurchaseOrders).toBe(5);
expect(purchasedChild?.supplyFromOpenPurchaseOrders).toBe(1);
expect(rawMaterial?.grossDemand).toBe(12);
expect(rawMaterial?.recommendedPurchaseQuantity).toBe(9);
expect(rawMaterial?.grossDemand).toBe(4);
expect(rawMaterial?.linkedPurchaseSupply).toBe(3);
expect(rawMaterial?.recommendedPurchaseQuantity).toBe(0);
expect(rawMaterial?.supplyFromStock).toBe(1);
expect(rawMaterial?.supplyFromOpenPurchaseOrders).toBe(2);
expect(rawMaterial?.supplyFromOpenPurchaseOrders).toBe(0);
});
});