import { err, ok, type CommandContext } from "@tailor-platform/erp-kit/core"; import { Decimal } from "decimal.js"; import type { Selectable, Transaction } from "../generated/kysely-tailordb"; import { postInventoryLedgerInternal, type PostInventoryMovementLine, } from "../internal/inventoryPosting"; import { EmptyTransferLinesError, InsufficientStockError, InvalidQuantityError, InvalidStateTransitionError, InvalidTransferLocationsError, StorageLocationNotFoundError, TransferOrderLineNotFoundError, TransferOrderNotFoundError, } from "../lib/errors.generated"; import type { FinancialAccountingCommands, FinancialAccountingQueries, ItemManagementQueries, OrganizationQueries, } from "../module"; export interface ReceiveTransferOrderInput { id: string; effectiveDate: Date; lines: { transferOrderLineId: string; transitStorageLocationId: string; destinationStorageLocationId: string; quantity: string; }[]; } export async function run( db: Transaction, input: ReceiveTransferOrderInput, ctx: CommandContext, itemManagementQueries: Pick, organizationQueries: Pick, financialAccountingCommands: Pick< FinancialAccountingCommands, "createJournalEntry" | "postJournalEntry" >, financialAccountingQueries: Pick, ) { const transferOrder = await db .selectFrom("TransferOrder") .selectAll() .where("id", "=", input.id) .forUpdate() .executeTakeFirst(); if (!transferOrder) { return err(new TransferOrderNotFoundError(input.id)); } if (transferOrder.status !== "OPEN") { return err(new InvalidStateTransitionError(input.id)); } if (input.lines.length === 0) { return err(new EmptyTransferLinesError(input.id)); } const validatedLines: { quantity: Decimal; line: Selectable<"TransferOrderLine">; transitLocation: Selectable<"StorageLocation">; destinationLocation: Selectable<"StorageLocation">; }[] = []; const requestedReceiveByLineId = new Map(); const requestedReceiveByLineTransitLocationId = new Map(); for (const lineInput of input.lines) { const quantity = new Decimal(lineInput.quantity); if (quantity.lte(0)) { return err(new InvalidQuantityError(lineInput.quantity)); } const line = await db .selectFrom("TransferOrderLine") .selectAll() .where("id", "=", lineInput.transferOrderLineId) .where("transferOrderId", "=", transferOrder.id) .forUpdate() .executeTakeFirst(); if (!line) { return err(new TransferOrderLineNotFoundError(lineInput.transferOrderLineId)); } const inTransitQuantity = new Decimal(line.shippedQuantity).minus(line.receivedQuantity); const requestedReceiveQuantity = (requestedReceiveByLineId.get(line.id) ?? new Decimal(0)).plus( quantity, ); if (requestedReceiveQuantity.gt(inTransitQuantity)) { return err(new InvalidQuantityError(lineInput.quantity)); } requestedReceiveByLineId.set(line.id, requestedReceiveQuantity); const transitLocation = await db .selectFrom("StorageLocation") .selectAll() .where("id", "=", lineInput.transitStorageLocationId) .executeTakeFirst(); if (!transitLocation) { return err(new StorageLocationNotFoundError(lineInput.transitStorageLocationId)); } const destinationLocation = await db .selectFrom("StorageLocation") .selectAll() .where("id", "=", lineInput.destinationStorageLocationId) .executeTakeFirst(); if (!destinationLocation) { return err(new StorageLocationNotFoundError(lineInput.destinationStorageLocationId)); } if (destinationLocation.siteId !== transferOrder.destinationSiteId) { return err(new InvalidTransferLocationsError(lineInput.destinationStorageLocationId)); } // The ledger is the source of truth for what this transfer line shipped into // this transit location; receipts must not exceed that line-level balance. const transitLedgerEntries = await db .selectFrom("InventoryLedger") .select(["direction", "quantity"]) .where("sourceType", "=", "TRANSFER_ORDER") .where("sourceId", "=", transferOrder.id) .where("sourceLineId", "=", line.id) .where("itemId", "=", line.itemId) .where("storageLocationId", "=", transitLocation.id) .where("stockType", "=", "IN_TRANSIT") .execute(); const lineTransitQuantity = transitLedgerEntries.reduce((total, ledgerEntry) => { const ledgerQuantity = new Decimal(ledgerEntry.quantity); return ledgerEntry.direction === "IN" ? total.plus(ledgerQuantity) : total.minus(ledgerQuantity); }, new Decimal(0)); const lineTransitKey = `${line.id}:${transitLocation.id}`; const requestedReceiveLineTransitQuantity = ( requestedReceiveByLineTransitLocationId.get(lineTransitKey) ?? new Decimal(0) ).plus(quantity); if (lineTransitQuantity.lt(requestedReceiveLineTransitQuantity)) { return err(new InsufficientStockError(`${line.itemId}:${transitLocation.id}`)); } requestedReceiveByLineTransitLocationId.set( lineTransitKey, requestedReceiveLineTransitQuantity, ); validatedLines.push({ quantity, line, transitLocation, destinationLocation }); } // Transit stock sufficiency, destination stock, and supply-plan consumption // are enforced per line by the posting engine. const movementLines: PostInventoryMovementLine[] = validatedLines.flatMap( ({ quantity, line, transitLocation, destinationLocation }) => [ { direction: "OUT" as const, itemId: line.itemId, storageLocationId: transitLocation.id, stockType: "IN_TRANSIT" as const, quantity: quantity.toString(), sourceLineId: line.id, action: "TRANSFER" as const, }, { direction: "IN" as const, itemId: line.itemId, storageLocationId: destinationLocation.id, stockType: "AVAILABLE" as const, quantity: quantity.toString(), sourceLineId: line.id, orderDocumentType: "TRANSFER_ORDER" as const, orderDocumentId: transferOrder.id, orderDocumentLineId: line.id, action: "TRANSFER" as const, }, ], ); const postingResult = await postInventoryLedgerInternal( db, { sourceType: "TRANSFER_ORDER", sourceId: transferOrder.id, effectiveDate: input.effectiveDate, lines: movementLines, }, ctx, itemManagementQueries, organizationQueries, financialAccountingCommands, financialAccountingQueries, ); if (!postingResult.ok) { return postingResult; } const transferOrderLines = []; const receivedQuantityByLineId = new Map(); for (const { quantity, line } of validatedLines) { const receivedQuantity = ( receivedQuantityByLineId.get(line.id) ?? new Decimal(line.receivedQuantity) ).plus(quantity); receivedQuantityByLineId.set(line.id, receivedQuantity); const transferOrderLine = await db .updateTable("TransferOrderLine") .set({ receivedQuantity: receivedQuantity.toString() }) .where("id", "=", line.id) .returningAll() .executeTakeFirstOrThrow(); transferOrderLines.push(transferOrderLine); } return ok({ inventoryLedgerEntries: postingResult.value.inventoryLedgerEntries, transferOrderLines, }); }