import { err, ok } from "@tailor-platform/erp-kit/core"; import { Decimal } from "decimal.js"; import { mergeCustomFields, type CustomFields } from "../../../shared/domain"; import { purchaseOrderLifecycle } from "../db/purchaseOrder.lifecycle.generated"; import type { PurchaseOrderBillingStatus, PurchaseOrderFieldChangeChangeKind, PurchaseOrderFieldChangeRecordType, PurchaseOrderOrderStatus, PurchaseOrderReceiptStatus, } from "../generated/enums"; import { CloseReasonRequiredError, CompanyNotFoundError, CurrencyMismatchError, EmptyAmendmentChangesError, EmptyPurchaseOrderLinesError, InvalidQuantityError, InvalidUnitPriceError, ItemNotActiveError, ItemNotFoundError, ItemNotPurchasableError, LineNotFoundError, ModifyQuantityBelowBilledError, ModifyQuantityBelowReceivedError, NegativeBilledQuantityError, NegativeReceivedQuantityError, OpenQuantityRemainsError, SupplierAccountCompanyMismatchError, PoNotOrderedError, PurchaseOrderAlreadyFulfilledError, PurchaseOrderNotCancellableError, PurchaseOrderNotClosableError, PurchaseOrderNotDraftError, PurchaseOrderNotSubmittedError, ReceivingSiteRequiredError, RejectionReasonRequiredError, RemoveLineHasBillsError, RemoveLineHasReceiptsError, SupplierNotActiveError, SupplierNotFoundError, } from "../lib/errors.generated"; // ===== Aggregate state ===== export type PurchaseOrderHeader = { readonly companyId: string; readonly supplierAccountId: string; readonly currencyId: string; readonly receivingSiteId: string | null; readonly orderStatus: PurchaseOrderOrderStatus; readonly receiptStatus: PurchaseOrderReceiptStatus | null; readonly billingStatus: PurchaseOrderBillingStatus | null; readonly orderDate: Date; readonly externalSupplierOrderReference: string | null; readonly supplierSnapshotName: string | null; readonly rejectionReason: string | null; readonly closeReason: string | null; readonly customFields: CustomFields; }; export type PurchaseOrderLine = { readonly id: string; readonly itemId: string; readonly itemSnapshotName: string | null; readonly itemSnapshotSku: string | null; readonly quantity: string; readonly unitPrice: string; readonly unitId: string; readonly receivingSiteId: string | null; readonly requiresPhysicalReceipt: boolean; readonly billedQuantity: string; readonly receivedQuantity: string; readonly customFields: CustomFields; }; export type PurchaseOrderFieldChange = { readonly recordType: PurchaseOrderFieldChangeRecordType; readonly recordId: string; readonly fieldName: string; readonly changeKind: PurchaseOrderFieldChangeChangeKind; readonly oldValue: string | null; readonly newValue: string | null; }; /** An immutable amendment audit fact; appended by amendOrdered, never edited. */ export type PurchaseOrderRevision = { readonly id: string; readonly revisionNumber: number; readonly reason: string | null; // Null only on rows written before the column existed. readonly amendedByUserId: string | null; readonly fieldChanges: readonly PurchaseOrderFieldChange[]; }; declare const invariantsSatisfied: unique symbol; export type PurchaseOrder = { readonly id: string; readonly header: PurchaseOrderHeader; readonly lines: readonly PurchaseOrderLine[]; readonly revisions: readonly PurchaseOrderRevision[]; readonly [invariantsSatisfied]: true; }; export type UncheckedPurchaseOrder = Omit; // Verifies nothing. The caller is responsible for the invariants. export function markInvariantsSatisfied(order: UncheckedPurchaseOrder): PurchaseOrder { return order as PurchaseOrder; } // ===== Referenced master-data rules ===== declare const supplierValidated: unique symbol; declare const itemValidated: unique symbol; export type SupplierSnapshot = { readonly supplierAccountId: string; readonly name: string; readonly [supplierValidated]: true; }; /** Everything a line freezes from master data the moment the item is chosen. */ export type ItemSnapshot = { readonly itemId: string; readonly name: string; readonly sku: string; readonly requiresPhysicalReceipt: boolean; readonly [itemValidated]: true; }; export function validateSupplier( supplierAccountId: string, companyId: string, account: { companyId: string; accountStatus: string; accountName: string; } | null, ) { if (!account) return err(new SupplierNotFoundError(supplierAccountId)); if (account.companyId !== companyId) return err(new SupplierAccountCompanyMismatchError(supplierAccountId)); if (account.accountStatus !== "ACTIVE") return err(new SupplierNotActiveError(supplierAccountId)); return ok({ supplierAccountId, name: account.accountName } as SupplierSnapshot); } // The purchasing record is what makes an item orderable at all, and it owns the // receipt expectation the line freezes — so both are resolved here, together. export function validateOrderItem( itemId: string, refs: { item: { status: string; name: string; sku: string } | null; purchaseItem: { requiresPhysicalReceipt: boolean } | null; }, ) { if (!refs.item) { return err(new ItemNotFoundError(itemId)); } if (refs.item.status !== "ACTIVE") { return err(new ItemNotActiveError(itemId)); } if (!refs.purchaseItem) { return err(new ItemNotPurchasableError(itemId)); } return ok({ itemId, name: refs.item.name, sku: refs.item.sku, requiresPhysicalReceipt: refs.purchaseItem.requiresPhysicalReceipt, } as ItemSnapshot); } // ===== Creation ===== export interface NewPurchaseOrderLine { item: ItemSnapshot; quantity: string; unitPrice: string; unitId: string; receivingSiteId?: string; customFields: CustomFields; } export interface NewPurchaseOrder { companyId: string; supplier: SupplierSnapshot; currencyId: string; orderDate: Date; receivingSiteId?: string; externalSupplierOrderReference?: string; customFields: CustomFields; lines: NewPurchaseOrderLine[]; } export interface CreateDraftRefs { company: { id: string; baseCurrencyId: string | null } | null; } // The kit does not handle FX, so a non-base currency is rejected rather than converted. function checkCompanyCurrency(order: UncheckedPurchaseOrder, refs: CreateDraftRefs) { const { companyId, currencyId } = order.header; if (!refs.company || refs.company.id !== companyId) { return new CompanyNotFoundError(companyId); } if (currencyId !== refs.company.baseCurrencyId) { return new CurrencyMismatchError(currencyId); } return null; } function toPurchaseOrderLine(line: NewPurchaseOrderLine): PurchaseOrderLine { return { id: crypto.randomUUID(), itemId: line.item.itemId, itemSnapshotName: line.item.name, itemSnapshotSku: line.item.sku, quantity: line.quantity, unitPrice: line.unitPrice, unitId: line.unitId, receivingSiteId: line.receivingSiteId ?? null, requiresPhysicalReceipt: line.item.requiresPhysicalReceipt, billedQuantity: "0", receivedQuantity: "0", customFields: line.customFields, }; } function checkFieldInvariants(order: UncheckedPurchaseOrder) { if (order.lines.length === 0) { return new EmptyPurchaseOrderLinesError(order.id); } for (const line of order.lines) { if (new Decimal(line.quantity).lte(0)) { return new InvalidQuantityError(line.itemId); } if (new Decimal(line.unitPrice).lt(0)) { return new InvalidUnitPriceError(line.itemId); } if (line.requiresPhysicalReceipt && !line.receivingSiteId && !order.header.receivingSiteId) { return new ReceivingSiteRequiredError(line.itemId); } } return null; } export function createDraft(params: NewPurchaseOrder, refs: CreateDraftRefs) { const header: PurchaseOrderHeader = { companyId: params.companyId, supplierAccountId: params.supplier.supplierAccountId, currencyId: params.currencyId, receivingSiteId: params.receivingSiteId ?? null, orderStatus: "DRAFT", receiptStatus: null, billingStatus: null, orderDate: params.orderDate, externalSupplierOrderReference: params.externalSupplierOrderReference ?? null, supplierSnapshotName: params.supplier.name, rejectionReason: null, closeReason: null, customFields: params.customFields, }; const order: UncheckedPurchaseOrder = { id: crypto.randomUUID(), header, lines: params.lines.map(toPurchaseOrderLine), revisions: [], }; const violation = checkCompanyCurrency(order, refs) ?? checkFieldInvariants(order); if (violation) { return err(violation); } return ok(markInvariantsSatisfied(order)); } // ===== Draft update ===== export interface PurchaseOrderHeaderPatch { orderDate?: Date; receivingSiteId?: string | null; externalSupplierOrderReference?: string | null; customFields?: CustomFields; } export interface PurchaseOrderLinePatch { item?: ItemSnapshot; quantity?: string; unitPrice?: string; unitId?: string; receivingSiteId?: string | null; customFields?: CustomFields; } // Edits beyond DRAFT go through the amendment commands instead. export function updateDraft( order: PurchaseOrder, changes: { headerPatch: PurchaseOrderHeaderPatch; addLines: NewPurchaseOrderLine[]; updateLines: { lineId: string; patch: PurchaseOrderLinePatch }[]; removeLineIds: string[]; }, ) { const { headerPatch, addLines, updateLines, removeLineIds } = changes; if (order.header.orderStatus !== "DRAFT") { return err(new PurchaseOrderNotDraftError(order.id)); } const currentLineIds = new Set(order.lines.map((line) => line.id)); const patchByLineId = new Map(updateLines.map((edit) => [edit.lineId, edit.patch])); for (const lineId of [...patchByLineId.keys(), ...removeLineIds]) { if (!currentLineIds.has(lineId)) { return err(new LineNotFoundError(lineId)); } } const mergedLines: PurchaseOrderLine[] = []; for (const line of order.lines) { if (removeLineIds.includes(line.id)) { continue; } const patch = patchByLineId.get(line.id); if (!patch) { mergedLines.push(line); continue; } mergedLines.push({ ...line, itemId: patch.item === undefined ? line.itemId : patch.item.itemId, itemSnapshotName: patch.item === undefined ? line.itemSnapshotName : patch.item.name, itemSnapshotSku: patch.item === undefined ? line.itemSnapshotSku : patch.item.sku, quantity: patch.quantity === undefined ? line.quantity : patch.quantity, unitPrice: patch.unitPrice === undefined ? line.unitPrice : patch.unitPrice, unitId: patch.unitId === undefined ? line.unitId : patch.unitId, receivingSiteId: patch.receivingSiteId === undefined ? line.receivingSiteId : patch.receivingSiteId, // Re-resolved with the item, since the receipt expectation belongs to it. requiresPhysicalReceipt: patch.item === undefined ? line.requiresPhysicalReceipt : patch.item.requiresPhysicalReceipt, customFields: mergeCustomFields(line.customFields, patch.customFields), }); } mergedLines.push(...addLines.map(toPurchaseOrderLine)); const mergedHeader: PurchaseOrderHeader = { ...order.header, orderDate: headerPatch.orderDate === undefined ? order.header.orderDate : headerPatch.orderDate, receivingSiteId: headerPatch.receivingSiteId === undefined ? order.header.receivingSiteId : headerPatch.receivingSiteId, externalSupplierOrderReference: headerPatch.externalSupplierOrderReference === undefined ? order.header.externalSupplierOrderReference : headerPatch.externalSupplierOrderReference, customFields: mergeCustomFields(order.header.customFields, headerPatch.customFields), }; const updated: UncheckedPurchaseOrder = { id: order.id, header: mergedHeader, lines: mergedLines, revisions: order.revisions, }; const violation = checkFieldInvariants(updated); if (violation) { return err(violation); } return ok(markInvariantsSatisfied(updated)); } // ===== Submission ===== export function submit(order: PurchaseOrder) { const nextStatus = purchaseOrderLifecycle.tryTransition(order.header.orderStatus, "submit"); if (!nextStatus) { return err(new PurchaseOrderNotDraftError(order.id)); } if (order.lines.length === 0) { return err(new EmptyPurchaseOrderLinesError(order.id)); } return ok({ ...order, header: { ...order.header, orderStatus: nextStatus } }); } // ===== Approval ===== export function approve(order: PurchaseOrder) { const nextStatus = purchaseOrderLifecycle.tryTransition(order.header.orderStatus, "approve"); if (!nextStatus) { return err(new PurchaseOrderNotSubmittedError(order.id)); } return ok({ ...order, header: { ...order.header, orderStatus: nextStatus, receiptStatus: "NOT_RECEIVED", billingStatus: "NOT_BILLED", }, } satisfies PurchaseOrder); } // ===== Rejection ===== export function reject(order: PurchaseOrder, reason: string) { const nextStatus = purchaseOrderLifecycle.tryTransition(order.header.orderStatus, "reject"); if (!nextStatus) { return err(new PurchaseOrderNotSubmittedError(order.id)); } if (!reason.trim()) { return err(new RejectionReasonRequiredError(order.id)); } return ok({ ...order, header: { ...order.header, orderStatus: nextStatus, rejectionReason: reason }, }); } // ===== Cancellation ===== export function cancel(order: PurchaseOrder) { const nextStatus = purchaseOrderLifecycle.tryTransition(order.header.orderStatus, "cancel"); if (!nextStatus) { return err(new PurchaseOrderNotCancellableError(order.id)); } const fulfilledLine = order.lines.find( (line) => new Decimal(line.receivedQuantity).gt(0) || new Decimal(line.billedQuantity).gt(0), ); if (fulfilledLine) { return err(new PurchaseOrderAlreadyFulfilledError(order.id)); } return ok({ ...order, header: { ...order.header, orderStatus: nextStatus } }); } // ===== Closure ===== export function close( order: PurchaseOrder, input: { closeReason?: string; writeOffRemaining?: boolean }, ) { const nextStatus = purchaseOrderLifecycle.tryTransition(order.header.orderStatus, "close"); if (!nextStatus) { return err(new PurchaseOrderNotClosableError(order.id)); } const hasUnresolvedOpenQuantity = order.lines.some( (line) => new Decimal(line.receivedQuantity).lt(line.quantity) && new Decimal(line.billedQuantity).lt(line.quantity), ); const writeOffRequested = input.writeOffRemaining === true; if (hasUnresolvedOpenQuantity && !writeOffRequested) { return err(new OpenQuantityRemainsError(order.id)); } if (hasUnresolvedOpenQuantity && writeOffRequested && !input.closeReason?.trim()) { return err(new CloseReasonRequiredError(order.id)); } return ok({ ...order, header: { ...order.header, orderStatus: nextStatus, closeReason: input.closeReason ?? null }, }); } // ===== Progress statuses ===== function summarizeProgress(lines: readonly PurchaseOrderLine[]) { let totalOrdered = new Decimal(0); let totalReceived = new Decimal(0); let totalBilled = new Decimal(0); for (const line of lines) { totalOrdered = totalOrdered.plus(line.quantity); totalReceived = totalReceived.plus(line.receivedQuantity); totalBilled = totalBilled.plus(line.billedQuantity); } return { totalOrdered, totalReceived, totalBilled }; } function deriveReceiptStatus(lines: readonly PurchaseOrderLine[]): PurchaseOrderReceiptStatus { const { totalOrdered, totalReceived } = summarizeProgress(lines); if (totalOrdered.isZero()) return "NOT_RECEIVED"; if (totalReceived.gte(totalOrdered)) return "RECEIVED"; if (totalReceived.gt(0)) return "PARTIALLY_RECEIVED"; return "NOT_RECEIVED"; } function deriveBillingStatus(lines: readonly PurchaseOrderLine[]): PurchaseOrderBillingStatus { const { totalOrdered, totalBilled } = summarizeProgress(lines); if (totalOrdered.isZero()) return "NOT_BILLED"; if (totalBilled.gte(totalOrdered)) return "BILLED"; if (totalBilled.gt(0)) return "PARTIALLY_BILLED"; return "NOT_BILLED"; } // ===== Amendment ===== export interface AmendedPurchaseOrderHeaderPatch { orderDate?: Date; receivingSiteId?: string | null; externalSupplierOrderReference?: string | null; customFields?: CustomFields; } // The item and unit cannot change on an ordered line, and neither can the // receipt expectation the item froze onto it; remove + add instead. export interface AmendedPurchaseOrderLinePatch { quantity?: string; unitPrice?: string; receivingSiteId?: string | null; customFields?: CustomFields; } function serializeAuditValue(value: unknown): string | null { if (value === null || value === undefined) return null; if (value instanceof Date) return value.toISOString(); if (typeof value === "string") return value; if (typeof value === "boolean") return value ? "true" : "false"; if (typeof value === "number" || typeof value === "bigint") return String(value); return JSON.stringify(value) ?? null; } // Statuses and snapshots are derived, never amended, so they stay out of the audit. // Custom fields first so domain-owned fields always win. function headerAuditFields(header: PurchaseOrderHeader): Record { return { ...header.customFields, orderDate: header.orderDate, receivingSiteId: header.receivingSiteId, externalSupplierOrderReference: header.externalSupplierOrderReference, }; } function lineAuditFields(line: PurchaseOrderLine): Record { return { ...line.customFields, itemId: line.itemId, quantity: line.quantity, unitPrice: line.unitPrice, unitId: line.unitId, receivingSiteId: line.receivingSiteId, requiresPhysicalReceipt: line.requiresPhysicalReceipt, billedQuantity: line.billedQuantity, receivedQuantity: line.receivedQuantity, }; } function diffAuditFields( recordType: PurchaseOrderFieldChangeRecordType, recordId: string, before: Record, after: Record, ): PurchaseOrderFieldChange[] { const changes: PurchaseOrderFieldChange[] = []; for (const fieldName of new Set([...Object.keys(before), ...Object.keys(after)])) { const oldValue = serializeAuditValue(before[fieldName]); const newValue = serializeAuditValue(after[fieldName]); if (oldValue !== newValue) { changes.push({ recordType, recordId, fieldName, changeKind: "MODIFIED", oldValue, newValue }); } } return changes; } // Snapshot each non-null field so an added or removed line's state survives. function snapshotAuditFields( recordId: string, fields: Record, changeKind: "ADDED" | "REMOVED", ): PurchaseOrderFieldChange[] { const changes: PurchaseOrderFieldChange[] = []; for (const [fieldName, raw] of Object.entries(fields)) { const value = serializeAuditValue(raw); if (value === null) { continue; } changes.push({ recordType: "LINE", recordId, fieldName, changeKind, oldValue: changeKind === "REMOVED" ? value : null, newValue: changeKind === "ADDED" ? value : null, }); } return changes; } function deriveAmendmentFieldChanges( before: UncheckedPurchaseOrder, after: UncheckedPurchaseOrder, ): PurchaseOrderFieldChange[] { const changes = diffAuditFields( "HEADER", after.id, headerAuditFields(before.header), headerAuditFields(after.header), ); const afterLineById = new Map(after.lines.map((line) => [line.id, line])); for (const beforeLine of before.lines) { const afterLine = afterLineById.get(beforeLine.id); if (afterLine) { changes.push( ...diffAuditFields( "LINE", beforeLine.id, lineAuditFields(beforeLine), lineAuditFields(afterLine), ), ); } else { changes.push(...snapshotAuditFields(beforeLine.id, lineAuditFields(beforeLine), "REMOVED")); } } const beforeLineIds = new Set(before.lines.map((line) => line.id)); for (const afterLine of after.lines) { if (!beforeLineIds.has(afterLine.id)) { changes.push(...snapshotAuditFields(afterLine.id, lineAuditFields(afterLine), "ADDED")); } } return changes; } export function amendOrdered( order: PurchaseOrder, amendment: { headerPatch: AmendedPurchaseOrderHeaderPatch; addLines: NewPurchaseOrderLine[]; updateLines: { lineId: string; patch: AmendedPurchaseOrderLinePatch }[]; removeLineIds: string[]; reason?: string; amendedByUserId: string; }, ) { const { headerPatch, addLines, updateLines, removeLineIds } = amendment; if (order.header.orderStatus !== "ORDERED") { return err(new PoNotOrderedError(order.id)); } const currentLineById = new Map(order.lines.map((line) => [line.id, line])); const patchByLineId = new Map(updateLines.map((edit) => [edit.lineId, edit.patch])); for (const lineId of [...patchByLineId.keys(), ...removeLineIds]) { if (!currentLineById.has(lineId)) { return err(new LineNotFoundError(lineId)); } } // Every referenced lineId was checked above, so this lookup never misses. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const lineFor = (lineId: string) => currentLineById.get(lineId)!; // A reduced quantity cannot drop below what is already received or billed. for (const { lineId, patch } of updateLines) { if (patch.quantity === undefined) { continue; } if (new Decimal(patch.quantity).lt(lineFor(lineId).receivedQuantity)) { return err(new ModifyQuantityBelowReceivedError(lineId)); } if (new Decimal(patch.quantity).lt(lineFor(lineId).billedQuantity)) { return err(new ModifyQuantityBelowBilledError(lineId)); } } // A line still tied to received or billed quantity cannot be removed. for (const lineId of removeLineIds) { if (new Decimal(lineFor(lineId).receivedQuantity).gt(0)) { return err(new RemoveLineHasReceiptsError(lineId)); } if (new Decimal(lineFor(lineId).billedQuantity).gt(0)) { return err(new RemoveLineHasBillsError(lineId)); } } const mergedLines: PurchaseOrderLine[] = []; for (const line of order.lines) { if (removeLineIds.includes(line.id)) { continue; } const patch = patchByLineId.get(line.id); if (!patch) { mergedLines.push(line); continue; } mergedLines.push({ ...line, quantity: patch.quantity === undefined ? line.quantity : patch.quantity, unitPrice: patch.unitPrice === undefined ? line.unitPrice : patch.unitPrice, receivingSiteId: patch.receivingSiteId === undefined ? line.receivingSiteId : patch.receivingSiteId, customFields: mergeCustomFields(line.customFields, patch.customFields), }); } mergedLines.push(...addLines.map(toPurchaseOrderLine)); const mergedHeader: PurchaseOrderHeader = { ...order.header, orderDate: headerPatch.orderDate === undefined ? order.header.orderDate : headerPatch.orderDate, receivingSiteId: headerPatch.receivingSiteId === undefined ? order.header.receivingSiteId : headerPatch.receivingSiteId, externalSupplierOrderReference: headerPatch.externalSupplierOrderReference === undefined ? order.header.externalSupplierOrderReference : headerPatch.externalSupplierOrderReference, receiptStatus: deriveReceiptStatus(mergedLines), billingStatus: deriveBillingStatus(mergedLines), customFields: mergeCustomFields(order.header.customFields, headerPatch.customFields), }; const amended: UncheckedPurchaseOrder = { id: order.id, header: mergedHeader, lines: mergedLines, revisions: order.revisions, }; const violation = checkFieldInvariants(amended); if (violation) { return err(violation); } // Every amendment carries its own audit revision; one that changes nothing // must not open a revision envelope. const fieldChanges = deriveAmendmentFieldChanges(order, amended); if (fieldChanges.length === 0) { return err(new EmptyAmendmentChangesError(order.id)); } const revision: PurchaseOrderRevision = { id: crypto.randomUUID(), revisionNumber: order.revisions.reduce((max, prior) => Math.max(max, prior.revisionNumber), 0) + 1, reason: amendment.reason ?? null, amendedByUserId: amendment.amendedByUserId, fieldChanges, }; return ok({ order: markInvariantsSatisfied({ ...amended, revisions: [...order.revisions, revision] }), revisionId: revision.id, }); } // ===== Receipt recording ===== // Receipts post incrementally from inbound shipments, so each posting carries // a delta. Entries for other orders' lines are left to their own aggregate. export function recordReceipts( order: PurchaseOrder, lineReceipts: { purchaseOrderLineId: string; receivedQuantityDelta: string }[], ) { const currentLineIds = new Set(order.lines.map((line) => line.id)); const deltaByLineId = new Map(); for (const receipt of lineReceipts) { if (!currentLineIds.has(receipt.purchaseOrderLineId)) { continue; } const delta = new Decimal(receipt.receivedQuantityDelta); if (delta.lt(0)) { return err(new NegativeReceivedQuantityError(receipt.purchaseOrderLineId)); } deltaByLineId.set( receipt.purchaseOrderLineId, delta.plus(deltaByLineId.get(receipt.purchaseOrderLineId) ?? 0), ); } const lines = order.lines.map((line) => { const delta = deltaByLineId.get(line.id); if (delta === undefined) { return line; } return { ...line, receivedQuantity: delta.plus(line.receivedQuantity).toString() }; }); return ok({ ...order, header: { ...order.header, receiptStatus: deriveReceiptStatus(lines) }, lines, }); } // ===== Billing recording ===== // Billing is an absolute projection recomputed by account payable, so each // sync carries totals; the last entry per line wins. export function recordBillings( order: PurchaseOrder, lineBillings: { purchaseOrderLineId: string; billedQuantity: string }[], ) { const currentLineIds = new Set(order.lines.map((line) => line.id)); const billedByLineId = new Map(); for (const billing of lineBillings) { if (!currentLineIds.has(billing.purchaseOrderLineId)) { continue; } const billedQuantity = new Decimal(billing.billedQuantity); if (billedQuantity.lt(0)) { return err(new NegativeBilledQuantityError(billing.purchaseOrderLineId)); } billedByLineId.set(billing.purchaseOrderLineId, billedQuantity.toString()); } const lines = order.lines.map((line) => { const billedQuantity = billedByLineId.get(line.id); if (billedQuantity === undefined) { return line; } return { ...line, billedQuantity }; }); return ok({ ...order, header: { ...order.header, billingStatus: deriveBillingStatus(lines) }, lines, }); } // ===== Acquisition cost adjustments ===== export interface PurchaseOrderPriceAdjustment { purchaseOrderLineId: string; amount: string; } // The billed quantity owes the price it was matched at, so a price change // moves what is owed only for the quantity still unbilled. export function deriveAcquisitionCostAdjustments( before: PurchaseOrder, after: PurchaseOrder, ): PurchaseOrderPriceAdjustment[] { const beforeLineById = new Map(before.lines.map((line) => [line.id, line])); const adjustments: PurchaseOrderPriceAdjustment[] = []; for (const line of after.lines) { const beforeLine = beforeLineById.get(line.id); if (!beforeLine) { continue; } const perUnit = new Decimal(line.unitPrice).minus(beforeLine.unitPrice); // Negative when more is billed than received: that quantity was invoiced at // the old price, so when it arrives at the new price the negative amount // cancels the difference. const unbilledReceived = new Decimal(beforeLine.receivedQuantity).minus( beforeLine.billedQuantity, ); const amount = perUnit.mul(unbilledReceived); if (amount.isZero()) { continue; } adjustments.push({ purchaseOrderLineId: line.id, amount: amount.toString() }); } return adjustments; } // ===== Supply plans ===== export interface DesiredSupplyPlan { sourceLineId: string; itemId: string; siteId: string; expectedQuantity: string; unitId: string; expectedDate: Date; } function deriveSupplyPlan(order: PurchaseOrder, line: PurchaseOrderLine): DesiredSupplyPlan | null { if (!line.requiresPhysicalReceipt) { return null; } const siteId = line.receivingSiteId ?? order.header.receivingSiteId; if (!siteId) { return null; } return { sourceLineId: line.id, itemId: line.itemId, siteId, expectedQuantity: line.quantity, unitId: line.unitId, expectedDate: order.header.orderDate, }; } export function deriveSupplyPlans(order: PurchaseOrder): DesiredSupplyPlan[] { return order.lines.flatMap((line) => deriveSupplyPlan(order, line) ?? []); } function isSameSupplyPlan(a: DesiredSupplyPlan, b: DesiredSupplyPlan) { return ( a.sourceLineId === b.sourceLineId && a.itemId === b.itemId && a.siteId === b.siteId && a.expectedQuantity === b.expectedQuantity && a.unitId === b.unitId && a.expectedDate.getTime() === b.expectedDate.getTime() ); } export interface SupplyPlanChanges { upserts: DesiredSupplyPlan[]; closeLineIds: string[]; } // A surviving line never loses its plan: the receipt expectation is frozen at // entry, and a physical line always has a receiving site to fall back on. So the // only terminal case is removal (close). export function deriveSupplyPlanChanges( before: PurchaseOrder, after: PurchaseOrder, ): SupplyPlanChanges { const changes: SupplyPlanChanges = { upserts: [], closeLineIds: [] }; const beforePlanByLineId = new Map( before.lines.map((line) => [line.id, deriveSupplyPlan(before, line)]), ); for (const line of after.lines) { const desiredPlan = deriveSupplyPlan(after, line); if (!desiredPlan) { continue; } const priorPlan = beforePlanByLineId.get(line.id) ?? null; if (!priorPlan || !isSameSupplyPlan(priorPlan, desiredPlan)) { changes.upserts.push(desiredPlan); } } const afterLineIds = new Set(after.lines.map((line) => line.id)); changes.closeLineIds = before.lines .filter((line) => !afterLineIds.has(line.id)) .map((line) => line.id); return changes; }