import { err, ok, type Result } from "@tailor-platform/erp-kit/core"; import { Decimal } from "decimal.js"; import type { Transaction } from "../../../generated/kysely-tailordb"; import { InsufficientStockError } from "../../../lib/errors.generated"; export interface ConsumedSlice { layerId: string; quantity: Decimal; } type CostLayerChange = | { kind: "receipt"; layerId: string } | { kind: "issue"; slices: ConsumedSlice[] }; export async function applyToCostLayers( db: Transaction, args: { kind: "receipt" | "issue"; itemId: string; companyId: string; ledgerEntryId: string; effectiveDate: Date; quantity: Decimal; }, ): Promise>> { if (args.kind === "receipt") { const layerId = await createCostLayer(db, args); return ok({ kind: "receipt", layerId }); } const consumed = await consumeCostLayers(db, args); if (!consumed.ok) return consumed; return ok({ kind: "issue", slices: consumed.value }); } async function createCostLayer( db: Transaction, args: { itemId: string; companyId: string; ledgerEntryId: string; effectiveDate: Date; quantity: Decimal; }, ) { const layer = await db .insertInto("CostLayer") .values({ itemId: args.itemId, companyId: args.companyId, ledgerEntryId: args.ledgerEntryId, effectiveDate: args.effectiveDate, quantity: args.quantity.toString(), remainingQuantity: args.quantity.toString(), }) .returning("id") .executeTakeFirstOrThrow(); return layer.id; } // First-in-first-out consumption (by effectiveDate) is the accounting assumption // of the layers, not a valuation — pricing the consumed slices is the method's // concern. async function consumeCostLayers( db: Transaction, args: { itemId: string; companyId: string; ledgerEntryId: string; quantity: Decimal }, ) { const layers = await db .selectFrom("CostLayer") .selectAll() .where("itemId", "=", args.itemId) .where("companyId", "=", args.companyId) .where("remainingQuantity", ">", "0") .orderBy("effectiveDate", "asc") .orderBy("createdAt", "asc") .execute(); const slices: ConsumedSlice[] = []; let remainingToIssue = args.quantity; for (const layer of layers) { if (remainingToIssue.lte(0)) break; const available = new Decimal(layer.remainingQuantity); const consumed = Decimal.min(available, remainingToIssue); await db .updateTable("CostLayer") .set({ remainingQuantity: available.minus(consumed).toString() }) .where("id", "=", layer.id) .execute(); await db .insertInto("CostLayerConsumption") .values({ costLayerId: layer.id, ledgerEntryId: args.ledgerEntryId, quantity: consumed.toString(), }) .execute(); slices.push({ layerId: layer.id, quantity: consumed }); remainingToIssue = remainingToIssue.minus(consumed); } if (remainingToIssue.gt(0)) { return err(new InsufficientStockError(args.itemId)); } return ok(slices); }