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, InvalidQuantityError, InvalidStateTransitionError, InvalidTransferLocationsError, StorageLocationNotFoundError, TransferOrderLineNotFoundError, TransferOrderNotFoundError, } from "../lib/errors.generated"; import type { FinancialAccountingCommands, FinancialAccountingQueries, ItemManagementQueries, OrganizationQueries, } from "../module"; export interface ShipTransferOrderInput { id: string; effectiveDate: Date; lines: { transferOrderLineId: string; sourceStorageLocationId: string; transitStorageLocationId: string; quantity: string; }[]; } export async function run( db: Transaction, input: ShipTransferOrderInput, 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">; sourceLocation: Selectable<"StorageLocation">; transitLocation: Selectable<"StorageLocation">; }[] = []; const requestedShipByLineId = 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 remainingToShip = new Decimal(line.orderedQuantity).minus(line.shippedQuantity); const requestedShipQuantity = (requestedShipByLineId.get(line.id) ?? new Decimal(0)).plus( quantity, ); if (requestedShipQuantity.gt(remainingToShip)) { return err(new InvalidQuantityError(lineInput.quantity)); } requestedShipByLineId.set(line.id, requestedShipQuantity); const sourceLocation = await db .selectFrom("StorageLocation") .selectAll() .where("id", "=", lineInput.sourceStorageLocationId) .executeTakeFirst(); if (!sourceLocation) { return err(new StorageLocationNotFoundError(lineInput.sourceStorageLocationId)); } if (sourceLocation.siteId !== transferOrder.sourceSiteId) { return err(new InvalidTransferLocationsError(lineInput.sourceStorageLocationId)); } const transitLocation = await db .selectFrom("StorageLocation") .selectAll() .where("id", "=", lineInput.transitStorageLocationId) .executeTakeFirst(); if (!transitLocation) { return err(new StorageLocationNotFoundError(lineInput.transitStorageLocationId)); } validatedLines.push({ quantity, line, sourceLocation, transitLocation }); } // Stock availability and reservation consumption are enforced per line by the // posting engine. const movementLines: PostInventoryMovementLine[] = validatedLines.flatMap( ({ quantity, line, sourceLocation, transitLocation }) => [ { direction: "OUT" as const, itemId: line.itemId, storageLocationId: sourceLocation.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, }, { direction: "IN" as const, itemId: line.itemId, storageLocationId: transitLocation.id, stockType: "IN_TRANSIT" as const, quantity: quantity.toString(), sourceLineId: 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 shippedQuantityByLineId = new Map(); for (const { quantity, line } of validatedLines) { const shippedQuantity = ( shippedQuantityByLineId.get(line.id) ?? new Decimal(line.shippedQuantity) ).plus(quantity); shippedQuantityByLineId.set(line.id, shippedQuantity); const transferOrderLine = await db .updateTable("TransferOrderLine") .set({ shippedQuantity: shippedQuantity.toString() }) .where("id", "=", line.id) .returningAll() .executeTakeFirstOrThrow(); transferOrderLines.push(transferOrderLine); } return ok({ inventoryLedgerEntries: postingResult.value.inventoryLedgerEntries, transferOrderLines, }); }