import { ok, type CommandContext } from "@tailor-platform/erp-kit/core"; import { Decimal } from "decimal.js"; import { AcquisitionCostAdjustmentAllocationVarianceKind } from "../../../generated/enums"; import type { Selectable, Transaction } from "../../../generated/kysely-tailordb"; import type { FinancialAccountingCommands, FinancialAccountingQueries } from "../../../module"; import { createCostingJournalEntry, type CostingFact, type CostingRole } from "../journalEntry"; import { restateAverageLayer } from "./average"; import { restateFifoLayer } from "./fifo"; import { restateStandardLayer } from "./standard"; const FACT_ROLE_ORDER: CostingRole[] = [ "INVENTORY", "COGS", "ADJUSTMENT", "CONSUMED_PRICE_VARIANCE", "PPV", "INVOICE_PRICE_VARIANCE", "ACCRUAL", ]; // Each variance kind washes against its own clearing account. const KIND_WASH_ROLE: Record = { INVOICE_PRICE: "INVOICE_PRICE_VARIANCE", ORDER_PRICE: "ACCRUAL", }; export async function resolveOrderReferenceLayers( db: Transaction, purchaseOrderId: string, purchaseOrderLineId: string, ) { return db .selectFrom("CostLayer") .innerJoin("InventoryLedger", "InventoryLedger.id", "CostLayer.ledgerEntryId") .selectAll("CostLayer") .where("InventoryLedger.direction", "=", "IN") .where("InventoryLedger.orderDocumentType", "=", "PURCHASE_ORDER") .where("InventoryLedger.orderDocumentId", "=", purchaseOrderId) .where("InventoryLedger.orderDocumentLineId", "=", purchaseOrderLineId) .orderBy("CostLayer.effectiveDate", "asc") .orderBy("CostLayer.createdAt", "asc") .execute(); } // Each layer's delta is its received-quantity share of the cumulative variance // minus what its allocations already carry. Each kind's wash is its register // sum minus what its allocations already distributed. async function computeDistributionState( db: Transaction, purchaseOrderId: string, purchaseOrderLineId: string, layers: Selectable<"CostLayer">[], ) { const events = await db .selectFrom("AcquisitionCostAdjustment") .select(["amount", "kind"]) .where("purchaseOrderId", "=", purchaseOrderId) .where("purchaseOrderLineId", "=", purchaseOrderLineId) .execute(); const variance = events.reduce((sum, event) => sum.plus(event.amount), new Decimal(0)); const varianceByKind = new Map(); for (const event of events) { // REDISTRIBUTION anchors declare nothing. if (event.kind === "REDISTRIBUTION") continue; varianceByKind.set( event.kind, (varianceByKind.get(event.kind) ?? new Decimal(0)).plus(event.amount), ); } const allocations = await db .selectFrom("AcquisitionCostAdjustmentAllocation") .select(["costLayerId", "amount", "varianceKind"]) .where( "costLayerId", "in", layers.map((layer) => layer.id), ) .execute(); const distributedByLayerId = new Map(); const distributedByKind = new Map(); for (const allocation of allocations) { distributedByLayerId.set( allocation.costLayerId, (distributedByLayerId.get(allocation.costLayerId) ?? new Decimal(0)).plus(allocation.amount), ); distributedByKind.set( allocation.varianceKind, (distributedByKind.get(allocation.varianceKind) ?? new Decimal(0)).plus(allocation.amount), ); } const washByKind = new Map( Object.values(AcquisitionCostAdjustmentAllocationVarianceKind).map((kind) => [ kind, (varianceByKind.get(kind) ?? new Decimal(0)).minus( distributedByKind.get(kind) ?? new Decimal(0), ), ]), ); const totalReceivedQuantity = layers.reduce( (sum, layer) => sum.plus(layer.quantity), new Decimal(0), ); let varianceToAllocate = variance; const layerDeltas = layers.map((layer, index) => { // Multiply before dividing; the last layer takes the exact remainder so // targets sum to the variance. const target = index === layers.length - 1 ? varianceToAllocate : variance.mul(layer.quantity).div(totalReceivedQuantity); varianceToAllocate = varianceToAllocate.minus(target); return { layer, delta: target.minus(distributedByLayerId.get(layer.id) ?? new Decimal(0)), }; }); return { layerDeltas, washByKind }; } // Splits each layer's delta into per-kind allocation amounts whose per-layer // sums equal the layer deltas and whose per-kind sums equal the washes; the // per-layer kind mix is bookkeeping, not economics — only the two sums matter. function splitLayerDeltasByKind( layerDeltas: { layer: Selectable<"CostLayer">; delta: Decimal }[], washByKind: Map, ) { const totalDelta = layerDeltas.reduce((sum, { delta }) => sum.plus(delta), new Decimal(0)); const invoiceWash = washByKind.get("INVOICE_PRICE") ?? new Decimal(0); let invoiceLeft = invoiceWash; return layerDeltas.map(({ layer, delta }, index) => { const invoiceShare = index === layerDeltas.length - 1 ? invoiceLeft : totalDelta.isZero() ? new Decimal(0) : invoiceWash.mul(delta).div(totalDelta); invoiceLeft = invoiceLeft.minus(invoiceShare); return { layer, delta, shares: new Map([ ["INVOICE_PRICE", invoiceShare], ["ORDER_PRICE", delta.minus(invoiceShare)], ]), }; }); } /** * Redistributes the order line's cumulative acquisition-cost variance over its * receipt cost layers, posting each layer's delta by the costing method of * the item's policy and recording the allocations under the given adjustment. */ export async function redistributeAcquisitionCost( db: Transaction, adjustment: Selectable<"AcquisitionCostAdjustment">, itemId: string, companyId: string, ctx: CommandContext, financialAccountingCommands: Pick< FinancialAccountingCommands, "createJournalEntry" | "postJournalEntry" >, financialAccountingQueries: Pick, ) { // Lock before reading so the read-then-write serializes against concurrent // postings for the same item and company. Layers exist, so the costed receipt // that created them already assigned this item's valuation policy. const itemValuation = await db .selectFrom("ItemValuation") .selectAll() .where("itemId", "=", itemId) .where("companyId", "=", companyId) .forUpdate() .executeTakeFirstOrThrow(); const policy = await db .selectFrom("ValuationPolicy") .selectAll() .where("id", "=", itemValuation.valuationPolicyId) .executeTakeFirstOrThrow(); // Re-read the layers under the lock; the caller's read may be stale. const layers = await resolveOrderReferenceLayers( db, adjustment.purchaseOrderId, adjustment.purchaseOrderLineId, ); const { layerDeltas, washByKind } = await computeDistributionState( db, adjustment.purchaseOrderId, adjustment.purchaseOrderLineId, layers, ); if ( layerDeltas.every(({ delta }) => delta.isZero()) && [...washByKind.values()].every((wash) => wash.isZero()) ) { return ok({}); } const signedByRole = new Map(); for (const { layer, delta } of layerDeltas) { if (delta.isZero()) continue; // Each entry is a signed share of the layer's delta; positive debits the role. let contributions: { role: CostingRole; amount: Decimal }[]; switch (policy.costingMethod) { case "STANDARD": contributions = restateStandardLayer(delta); break; case "FIFO": { const result = await restateFifoLayer(db, { layer, delta }); if (!result.ok) return result; contributions = result.value; break; } case "AVERAGE": { const result = await restateAverageLayer(db, { layer, delta }); if (!result.ok) return result; contributions = result.value; break; } default: return policy.costingMethod satisfies never; } for (const { role, amount } of contributions) { signedByRole.set(role, (signedByRole.get(role) ?? new Decimal(0)).plus(amount)); } } // The washes sum to the layer deltas, so the entry stays balanced. for (const [kind, wash] of washByKind) { const role = KIND_WASH_ROLE[kind]; signedByRole.set(role, (signedByRole.get(role) ?? new Decimal(0)).minus(wash)); } const allocations = splitLayerDeltasByKind(layerDeltas, washByKind).flatMap(({ layer, shares }) => [...shares].flatMap(([kind, amount]) => amount.isZero() ? [] : [{ costLayerId: layer.id, varianceKind: kind, amount }], ), ); await db .insertInto("AcquisitionCostAdjustmentAllocation") .values( allocations.map((allocation) => ({ acquisitionCostAdjustmentId: adjustment.id, costLayerId: allocation.costLayerId, amount: allocation.amount.toString(), varianceKind: allocation.varianceKind, })), ) .execute(); const facts: CostingFact[] = []; for (const role of FACT_ROLE_ORDER) { const signed = signedByRole.get(role); if (!signed || signed.isZero()) continue; facts.push({ role, dcIndicator: signed.gt(0) ? "DR" : "CR", amount: signed.abs().toString(), }); } if (facts.length === 0) return ok({}); const journalResult = await createCostingJournalEntry( db, { entryDate: adjustment.effectiveDate, description: `Acquisition cost adjustment: ${adjustment.sourceType} ${adjustment.sourceId}`, sourceDocumentType: "ACQUISITION_COST_ADJUSTMENT", sourceDocumentId: adjustment.id, facts, policy, }, ctx, financialAccountingCommands, financialAccountingQueries, ); if (!journalResult.ok) return journalResult; return ok({}); } /** * Receipt-side entry point: when a purchase receipt posts, any variance not * yet sitting where the new layer set demands is redistributed under a * zero-amount adjustment anchored to the receipt ledger entry. */ export async function redistributePendingAcquisitionCost( db: Transaction, args: { purchaseOrderId: string; purchaseOrderLineId: string; ledgerEntryId: string; effectiveDate: Date; }, ctx: CommandContext, financialAccountingCommands: Pick< FinancialAccountingCommands, "createJournalEntry" | "postJournalEntry" >, financialAccountingQueries: Pick, ) { const layers = await resolveOrderReferenceLayers( db, args.purchaseOrderId, args.purchaseOrderLineId, ); if (layers.length === 0) return ok({}); const { layerDeltas, washByKind } = await computeDistributionState( db, args.purchaseOrderId, args.purchaseOrderLineId, layers, ); if ( layerDeltas.every(({ delta }) => delta.isZero()) && [...washByKind.values()].every((wash) => wash.isZero()) ) { return ok({}); } const adjustment = await db .insertInto("AcquisitionCostAdjustment") .values({ sourceType: "INVENTORY_LEDGER", sourceId: args.ledgerEntryId, sourceLineId: null, purchaseOrderId: args.purchaseOrderId, purchaseOrderLineId: args.purchaseOrderLineId, amount: "0", kind: "REDISTRIBUTION", effectiveDate: args.effectiveDate, }) .returningAll() .executeTakeFirstOrThrow(); return redistributeAcquisitionCost( db, adjustment, layers[0].itemId, layers[0].companyId, ctx, financialAccountingCommands, financialAccountingQueries, ); }