import { err, ok } from "@tailor-platform/erp-kit/core"; import { Decimal } from "decimal.js"; import type { InventoryLedgerAction, InventoryLedgerSourceType } from "../../../generated/enums"; import type { Selectable, Transaction } from "../../../generated/kysely-tailordb"; import { ConsumptionTreatmentNotSupportedError } from "../../../lib/errors.generated"; import type { CostingRole } from "../journalEntry"; // Under FIFO the consumed share restates the account it was originally expensed // to, so each consumed slice is classified by the consuming ledger entry. type FifoPortion = "ON_HAND" | "SALES_CONSUMED" | "ADJUSTMENT_CONSUMED"; const FIFO_PORTION_ROLE: Record = { ON_HAND: "INVENTORY", SALES_CONSUMED: "COGS", ADJUSTMENT_CONSUMED: "ADJUSTMENT", }; function classifyConsumption( sourceType: InventoryLedgerSourceType, action: InventoryLedgerAction, ): FifoPortion | undefined { if (sourceType === "OUTBOUND_SHIPMENT" && action === "QUANTITY_CHANGE") return "SALES_CONSUMED"; if (sourceType === "STOCK_ADJUSTMENT" && action === "QUANTITY_CHANGE") return "ADJUSTMENT_CONSUMED"; return undefined; } // Restates a FIFO layer for its share of an invoice price variance: the on-hand // share raises the layer cost (a new FifoCost version), and each consumed share // restates the account it was expensed to. export async function restateFifoLayer( db: Transaction, args: { layer: Selectable<"CostLayer">; delta: Decimal }, ) { const { layer, delta } = args; // A costed FIFO receipt created this layer with its version-1 cost, so it exists. const price = await db .selectFrom("FifoCost") .selectAll() .where("costLayerId", "=", layer.id) .orderBy("version", "desc") .limit(1) .executeTakeFirstOrThrow(); const received = new Decimal(layer.quantity); const remaining = new Decimal(layer.remainingQuantity); const consumedByPortion = new Map(); const consumptions = await db .selectFrom("CostLayerConsumption") .innerJoin("InventoryLedger", "InventoryLedger.id", "CostLayerConsumption.ledgerEntryId") .select([ "CostLayerConsumption.quantity", "InventoryLedger.sourceType", "InventoryLedger.action", ]) .where("CostLayerConsumption.costLayerId", "=", layer.id) .orderBy("CostLayerConsumption.createdAt", "asc") .execute(); for (const consumption of consumptions) { const portion = classifyConsumption(consumption.sourceType, consumption.action); if (!portion) { return err( new ConsumptionTreatmentNotSupportedError( `${consumption.sourceType}:${consumption.action}`, ), ); } consumedByPortion.set( portion, (consumedByPortion.get(portion) ?? new Decimal(0)).plus(consumption.quantity), ); } const sources = [...consumedByPortion].map(([portion, quantity]) => ({ portion, quantity })); // ON_HAND goes last and absorbs the proration remainder. if (remaining.gt(0)) sources.push({ portion: "ON_HAND", quantity: remaining }); let amountLeft = delta; let onHandShare = new Decimal(0); const contributions: { role: CostingRole; amount: Decimal }[] = []; for (const [index, source] of sources.entries()) { const isLast = index === sources.length - 1; const amount = isLast ? amountLeft : delta.mul(source.quantity).div(received); amountLeft = amountLeft.minus(amount); if (amount.isZero()) continue; if (source.portion === "ON_HAND") onHandShare = amount; contributions.push({ role: FIFO_PORTION_ROLE[source.portion], amount }); } if (!onHandShare.isZero()) { const newLayerCost = remaining.mul(price.unitCost).plus(onHandShare).div(remaining); await db .insertInto("FifoCost") .values({ costLayerId: layer.id, version: price.version + 1, unitCost: newLayerCost.toString(), }) .execute(); } return ok(contributions); }