import { err, ok, type CommandContext } from "@tailor-platform/erp-kit/core"; import { Decimal } from "decimal.js"; import type { InventoryLedgerAction, InventoryLedgerOrderDocumentType, InventoryLedgerSourceType, InventoryLedgerStockType, } from "../generated/enums"; import type { Selectable, Transaction } from "../generated/kysely-tailordb"; import { EmptyMovementLinesError, InsufficientStockError, InvalidQuantityError, InvalidUnitCostError, ItemNotFoundError, StorageLocationNotFoundError, } from "../lib/errors.generated"; import type { FinancialAccountingCommands, FinancialAccountingQueries, ItemManagementQueries, OrganizationQueries, } from "../module"; import { redistributePendingAcquisitionCost } from "./costing/adjustment"; import { applyCosting } from "./costing/movement"; import { consumeInboundSupplyPlansInternal } from "./inventorySupplyPlan"; import { consumeOutboundReservationsInternal } from "./stockReservation"; export interface PostInventoryMovementLine { direction: "IN" | "OUT"; /** What the movement changes; with sourceType and direction it determines the costing treatment. */ action: InventoryLedgerAction; itemId: string; storageLocationId: string; stockType: InventoryLedgerStockType; /** Quantity in the item's primary unit; must be positive. */ quantity: string; /** Actual acquisition cost for inbound QUANTITY_CHANGE lines; feeds purchase price variance. */ unitCost?: string; /** Cause-document line ID recorded on the ledger row. */ sourceLineId?: string; /** * The demand/supply document this movement fulfills. Matched against supply * plans (IN) and reservations (OUT), and persisted on the ledger row. */ orderDocumentType?: InventoryLedgerOrderDocumentType; orderDocumentId?: string; orderDocumentLineId?: string; } export interface PostInventoryLedgerInternalInput { sourceType: InventoryLedgerSourceType; sourceId: string; effectiveDate: Date; lines: PostInventoryMovementLine[]; } export async function postInventoryLedgerInternal( db: Transaction, input: PostInventoryLedgerInternalInput, ctx: CommandContext, itemManagementQueries: Pick, organizationQueries: Pick, financialAccountingCommands: Pick< FinancialAccountingCommands, "createJournalEntry" | "postJournalEntry" >, financialAccountingQueries: Pick, ) { if (input.lines.length === 0) { return err(new EmptyMovementLinesError(input.sourceId)); } const inventoryLedgerEntries: Selectable<"InventoryLedger">[] = []; const now = new Date(); for (const line of input.lines) { const quantity = new Decimal(line.quantity); if (quantity.lte(0)) { return err(new InvalidQuantityError(line.quantity)); } const unitCost = line.unitCost === undefined ? undefined : new Decimal(line.unitCost); if (unitCost?.lt(0)) { return err(new InvalidUnitCostError(unitCost.toString())); } const { item } = (await itemManagementQueries.getItem(db, { id: line.itemId }, ctx)).value; if (!item) { return err(new ItemNotFoundError(line.itemId)); } const location = await db .selectFrom("StorageLocation") .selectAll() .where("id", "=", line.storageLocationId) .executeTakeFirst(); if (!location) { return err(new StorageLocationNotFoundError(line.storageLocationId)); } let stockLevelWrite: { id: string | null; nextQuantity: Decimal }; // AVAILABLE OUT always runs the ATP check: no posting may draw stock out // from under another demand's open reservation. if (line.direction === "OUT" && line.stockType === "AVAILABLE") { const reservationResult = await consumeOutboundReservationsInternal(db, { itemId: line.itemId, siteId: location.siteId, storageLocationId: line.storageLocationId, stockType: line.stockType, quantity: line.quantity, sourceDocumentType: line.orderDocumentType ?? null, sourceDocumentId: line.orderDocumentId ?? null, sourceLineId: line.orderDocumentLineId ?? null, }); if (!reservationResult.ok) { return reservationResult; } const { stockLevel, locationStockQuantity } = reservationResult.value; stockLevelWrite = { id: stockLevel.id, nextQuantity: locationStockQuantity.minus(quantity), }; } else { const signedDelta = line.direction === "IN" ? quantity : quantity.neg(); const existing = await db .selectFrom("StockLevel") .selectAll() .where("itemId", "=", line.itemId) .where("storageLocationId", "=", line.storageLocationId) .where("stockType", "=", line.stockType) .forUpdate() .executeTakeFirst(); const nextQuantity = new Decimal(existing?.quantity ?? 0).plus(signedDelta); if (nextQuantity.lt(0)) { return err(new InsufficientStockError(`${line.itemId}:${line.storageLocationId}`)); } stockLevelWrite = { id: existing?.id ?? null, nextQuantity }; } if (line.direction === "IN" && line.orderDocumentType) { const supplyPlanResult = await consumeInboundSupplyPlansInternal(db, { itemId: line.itemId, siteId: location.siteId, quantity: line.quantity, sourceDocumentType: line.orderDocumentType, sourceDocumentId: line.orderDocumentId ?? null, sourceLineId: line.orderDocumentLineId ?? null, }); if (!supplyPlanResult.ok) { return supplyPlanResult; } } const ledgerEntry = await db .insertInto("InventoryLedger") .values({ sourceType: input.sourceType, sourceId: input.sourceId, sourceLineId: line.sourceLineId ?? null, orderDocumentType: line.orderDocumentType ?? null, orderDocumentId: line.orderDocumentId ?? null, orderDocumentLineId: line.orderDocumentLineId ?? null, direction: line.direction, action: line.action, itemId: line.itemId, storageLocationId: line.storageLocationId, stockType: line.stockType, quantity: line.quantity, executedAt: now, effectiveDate: input.effectiveDate, }) .returningAll() .executeTakeFirstOrThrow(); inventoryLedgerEntries.push(ledgerEntry); if (stockLevelWrite.id) { await db .updateTable("StockLevel") .set({ quantity: stockLevelWrite.nextQuantity.toString() }) .where("id", "=", stockLevelWrite.id) .execute(); } else { await db .insertInto("StockLevel") .values({ itemId: line.itemId, storageLocationId: line.storageLocationId, stockType: line.stockType, quantity: stockLevelWrite.nextQuantity.toString(), }) .execute(); } const costingResult = await applyCosting( db, { itemId: line.itemId, sourceType: input.sourceType, action: line.action, direction: line.direction, quantity, unitCost, effectiveDate: input.effectiveDate, siteId: location.siteId, sourceId: input.sourceId, ledgerEntryId: ledgerEntry.id, }, ctx, organizationQueries, financialAccountingCommands, financialAccountingQueries, ); if (!costingResult.ok) { return costingResult; } // A new purchase receipt changes every layer's share of the order line's // invoice price variance, so redistribute anything already declared. if ( line.direction === "IN" && line.orderDocumentType === "PURCHASE_ORDER" && line.orderDocumentId && line.orderDocumentLineId ) { const redistributionResult = await redistributePendingAcquisitionCost( db, { purchaseOrderId: line.orderDocumentId, purchaseOrderLineId: line.orderDocumentLineId, ledgerEntryId: ledgerEntry.id, effectiveDate: input.effectiveDate, }, ctx, financialAccountingCommands, financialAccountingQueries, ); if (!redistributionResult.ok) { return redistributionResult; } } } return ok({ inventoryLedgerEntries }); }