import { createContext, DomainError } from "@tailor-platform/erp-kit/app"; import { createResolver, t } from "@tailor-platform/sdk"; import { getDB } from "@/generated/kysely-tailordb"; import { invModulesExport } from "@/modules"; /** * shipTransferOrder — app-layer "ship the whole order" action. * * The native TransferOrder lifecycle only has DRAFT → OPEN → CLOSED. Moving * stock happens through two OPEN-state commands that do NOT change the status: * `shipTransferOrder` (source AVAILABLE → IN_TRANSIT, sets shippedQuantity) * and `receiveTransferOrder` (IN_TRANSIT → destination AVAILABLE, sets * receivedQuantity). So a freshly opened order has shippedQuantity = 0 and * cannot be received until it has been shipped. * * erp-kit's native `shipTransferOrder` is line-explicit: * `{ id, effectiveDate, lines: [{ transferOrderLineId, sourceStorageLocationId, * transitStorageLocationId, quantity }] }`. The UI exposes a single "Ship" * click, so this resolver ships the full `orderedQuantity − shippedQuantity` * remainder of every line. For each line it picks the source-site * StorageLocation that actually holds the most AVAILABLE stock of that item * (falling back to the first ACTIVE source location when none is found, so the * native command surfaces a clean InsufficientStock error), and uses the first * ACTIVE StorageLocation under the destination site as the transit location. * Using the destination site's location for transit keeps the IN_TRANSIT stock * where `receiveTransferOrder` looks for it (that resolver uses the same * destination location as both its transit and destination). */ export default createResolver({ name: "shipTransferOrder", operation: "mutation", input: { id: t.string().description("Transfer order ID"), }, body: async (context) => { const ctx = createContext(context); const db = getDB("main-db"); return db .transaction() .execute(async (trx) => { const transferOrder = await trx .selectFrom("TransferOrder") .select(["sourceSiteId", "destinationSiteId"]) .where("id", "=", context.input.id) .executeTakeFirst(); if (!transferOrder) { throw new DomainError(`Transfer order not found: ${context.input.id}`); } const sourceLocations = await trx .selectFrom("StorageLocation") .select(["id"]) .where("siteId", "=", transferOrder.sourceSiteId) .where("status", "=", "ACTIVE") .execute(); if (sourceLocations.length === 0) { throw new DomainError( `No ACTIVE storage location found for the source site — cannot ship the transfer order.`, ); } const sourceLocationIds = sourceLocations.map((l) => l.id); const fallbackSourceLocationId = sourceLocationIds[0]; const transitLocation = await trx .selectFrom("StorageLocation") .select(["id"]) .where("siteId", "=", transferOrder.destinationSiteId) .where("status", "=", "ACTIVE") .limit(1) .executeTakeFirst(); if (!transitLocation) { throw new DomainError( `No ACTIVE storage location found for the destination site — cannot ship the transfer order.`, ); } const lines = await trx .selectFrom("TransferOrderLine") .select(["id", "itemId", "orderedQuantity", "shippedQuantity"]) .where("transferOrderId", "=", context.input.id) .execute(); // Resolve, per line, the source-site location holding the most AVAILABLE // stock of the line's item — so a multi-location source site ships from // where the goods actually are, not an arbitrary "first" location. const shipLines = []; for (const line of lines) { const quantity = Number(line.orderedQuantity) - Number(line.shippedQuantity); if (quantity <= 0) continue; const bestStock = await trx .selectFrom("StockLevel") .select(["storageLocationId"]) .where("itemId", "=", line.itemId) .where("storageLocationId", "in", sourceLocationIds) .where("stockType", "=", "AVAILABLE") .orderBy("quantity", "desc") .limit(1) .executeTakeFirst(); shipLines.push({ transferOrderLineId: line.id, sourceStorageLocationId: bestStock?.storageLocationId ?? fallbackSourceLocationId, transitStorageLocationId: transitLocation.id, quantity: String(quantity), }); } const result = await invModulesExport.commands.shipTransferOrder( trx, { id: context.input.id, effectiveDate: new Date(), lines: shipLines, }, ctx, ); if (!result.ok) { switch (result.error.code) { case "INVENTORY_TRANSFER_ORDER_NOT_FOUND": throw new DomainError(`Transfer order not found: ${context.input.id}`); case "INVENTORY_INVALID_STATE_TRANSITION": throw new DomainError( `Transfer order cannot be shipped in its current status: ${context.input.id}`, ); case "INVENTORY_EMPTY_TRANSFER_LINES": throw new DomainError( "Nothing to ship — the transfer order has no ordered quantity remaining", ); case "INVENTORY_INVALID_QUANTITY": throw new DomainError(`Invalid ship quantity: ${result.error.message}`); case "INVENTORY_INVALID_TRANSFER_LOCATIONS": throw new DomainError(`Invalid source/transit locations: ${result.error.message}`); case "INVENTORY_STORAGE_LOCATION_NOT_FOUND": throw new DomainError(`Storage location not found: ${result.error.message}`); case "INVENTORY_INSUFFICIENT_STOCK": throw new DomainError( `Insufficient available stock at the source site to ship: ${result.error.message}`, ); case "INVENTORY_TRANSFER_ORDER_LINE_NOT_FOUND": throw new DomainError(`Transfer order line not found: ${result.error.message}`); case "INVENTORY_INVENTORY_SUPPLY_PLAN_NOT_FOUND": throw new DomainError("A related supply plan was not found."); case "INVENTORY_SUPPLY_PLAN_NOT_OPEN": throw new DomainError("A related supply plan is not open."); case "INVENTORY_STOCK_RESERVATION_NOT_FOUND": throw new DomainError("A related stock reservation was not found."); case "INVENTORY_STOCK_RESERVATION_NOT_OPEN": throw new DomainError("A related stock reservation is not open."); case "INVENTORY_ACCOUNTING_PERIOD_NOT_FOUND": throw new DomainError("No accounting period covers the posting date."); case "INVENTORY_JOURNAL_ENTRY_CREATE_FAILED": throw new DomainError("Failed to create the inventory journal entry."); case "INVENTORY_JOURNAL_ENTRY_POST_FAILED": throw new DomainError("Failed to post the inventory journal entry."); case "INVENTORY_CONSUMPTION_TREATMENT_NOT_SUPPORTED": throw new DomainError("An item's consumption treatment is not supported."); case "INVENTORY_UNIT_COST_REQUIRED": throw new DomainError("A unit cost is required to ship the transfer order."); case "INVENTORY_AVERAGE_COST_NOT_FOUND": throw new DomainError("No average cost is available for an item."); case "INVENTORY_FIFO_COST_NOT_FOUND": throw new DomainError("No FIFO cost layer is available for an item."); case "INVENTORY_STANDARD_COST_NOT_FOUND": throw new DomainError("No standard cost is defined for an item."); case "INVENTORY_COSTING_EVENT_NOT_SUPPORTED": throw new DomainError( "The costing event is not supported by an item's valuation policy.", ); case "INVENTORY_SITE_NOT_FOUND": throw new DomainError("A referenced site was not found."); case "INVENTORY_DEFAULT_VALUATION_POLICY_NOT_FOUND": throw new DomainError("No default valuation policy is defined for the company."); case "INVENTORY_INVALID_SUPPLY_PLAN_QUANTITIES": throw new DomainError("Supply plan quantities are invalid."); case "INVENTORY_INVALID_RESERVATION_QUANTITIES": throw new DomainError("Reservation quantities are invalid."); case "INVENTORY_EMPTY_MOVEMENT_LINES": throw new DomainError("The shipment has no movement lines."); case "INVENTORY_INVALID_UNIT_COST": throw new DomainError("A movement line unit cost is invalid."); case "INVENTORY_ITEM_NOT_FOUND": throw new DomainError("A referenced item was not found."); case "UNAUTHENTICATED": throw new DomainError("Authentication is required"); case "INSUFFICIENT_PERMISSION": throw new DomainError("You do not have permission to perform this action"); default: throw result.error satisfies never; } } // Native ship returns the shipment `inventoryExecution`, not the TO // header — re-read the header for the response. const header = await trx .selectFrom("TransferOrder") .select(["id", "status"]) .where("id", "=", context.input.id) .executeTakeFirstOrThrow(); return { id: header.id, status: header.status }; }) .catch((err: unknown) => { if (err instanceof DomainError) throw err; throw new Error("Failed to ship the transfer order", { cause: err }); }); }, output: t .object({ id: t.string().description("Transfer order ID"), status: t.string().description("New document status"), }) .description("ShipTransferOrder response"), });