import { createContext, DomainError } from "@tailor-platform/erp-kit/app"; import { createResolver, t } from "@tailor-platform/sdk"; import { Decimal } from "decimal.js"; import { getDB } from "@/generated/kysely-tailordb"; import { outboundShipmentModulesExport } from "@/modules"; /** * createOutboundShipment — raise a DRAFT outbound shipment (出庫) against a sales order. * * Mirrors createInboundShipment on the purchase side. In erp-kit 0.45 the issue * side of inventory is the outbound-shipment module: an outbound shipment is an * `OutboundShipment` header + `OutboundShipmentLine` rows, with the source * document recorded at the LINE level (sourceDocumentType = SALES_ORDER, * sourceDocumentId = , sourceLineId = ). Lines default to * the sales order's open (unshipped) quantity per line, all issued from the * chosen storage location. This scaffold treats every sales-order line as * requiring physical fulfillment. Nothing leaves stock until the issue is * settled (posted) — see settleOutboundShipment. */ export default createResolver({ name: "createOutboundShipment", operation: "mutation", input: { salesOrderId: t .string() .description("Source sales order (must be CONFIRMED / partially shipped)"), storageLocationId: t.string().description("Storage location to issue stock from"), effectiveDate: t .string({ optional: true }) .description("Issue date (YYYY-MM-DD); defaults to today"), lines: t .object( { salesOrderLineId: t.string().description("Sales order line to fulfil"), quantity: t.string().description("Quantity to issue"), }, { array: true, optional: true }, ) .description("Lines to issue; omit to issue every line's open quantity"), }, body: async (context) => { const ctx = createContext(context); const db = getDB("main-db"); return db .transaction() .execute(async (trx) => { const so = await trx .selectFrom("SalesOrder") .select(["id"]) .where("id", "=", context.input.salesOrderId) .executeTakeFirst(); if (!so) { throw new DomainError(`Sales order not found: ${context.input.salesOrderId}`); } const soLines = await trx .selectFrom("SalesOrderLine") .select([ "id", "itemId", "unitId", "quantity", "fulfilledQuantity", "requiresPhysicalFulfillment", ]) .where("salesOrderId", "=", context.input.salesOrderId) .execute(); const soLineById = new Map(soLines.map((l) => [l.id, l])); // A line whose item is sold without a shipment has nothing to issue. // Reject the whole order instead of silently omitting those lines. const unsupportedLine = soLines.find((line) => !line.requiresPhysicalFulfillment); if (unsupportedLine) { throw new DomainError( `Sales order line ${unsupportedLine.id} requires no physical fulfillment, which this resolver does not support.`, ); } // Requested lines, or every line's open (unshipped) quantity. // Keep quantities as decimal strings so no precision is lost before persistence. const inputLines = context.input.lines ?? []; const requested = inputLines.length > 0 ? inputLines.map((l) => ({ salesOrderLineId: l.salesOrderLineId, quantity: new Decimal(l.quantity).toString(), })) : soLines .map((l) => ({ salesOrderLineId: l.id, quantity: new Decimal(l.quantity).minus(l.fulfilledQuantity).toString(), })) .filter((l) => new Decimal(l.quantity).gt(0)); if (requested.length === 0) { throw new DomainError("Nothing to issue — every line is already fully shipped."); } // Item unit drives the primary unit; this template issues in the item's // base unit (conversion rate 1). const itemUnitById = new Map(); for (const l of soLines) { const item = await trx .selectFrom("Item") .select(["id", "unitId"]) .where("id", "=", l.itemId) .executeTakeFirst(); if (item) itemUnitById.set(l.itemId, item.unitId); } const lines = requested.map((r) => { const soLine = soLineById.get(r.salesOrderLineId); if (!soLine) { throw new DomainError(`Sales order line not found: ${r.salesOrderLineId}`); } if (!new Decimal(r.quantity).gt(0)) { throw new DomainError( `Issue quantity must be positive for line ${r.salesOrderLineId}.`, ); } const unitId = soLine.unitId ?? itemUnitById.get(soLine.itemId); if (!unitId) { throw new DomainError(`Could not resolve a unit for item ${soLine.itemId}.`); } const qty = r.quantity; return { itemId: soLine.itemId, storageLocationId: context.input.storageLocationId, quantity: qty, unitId, primaryUnitId: unitId, primaryQuantity: qty, unitConversionRate: "1", stockType: "AVAILABLE" as const, // Line-level source-document link (0.45 moved it off the header). sourceDocumentType: "SALES_ORDER" as const, sourceDocumentId: context.input.salesOrderId, sourceLineId: r.salesOrderLineId, }; }); const result = await outboundShipmentModulesExport.commands.createOutboundShipment( trx, { header: { effectiveDate: context.input.effectiveDate ? new Date(context.input.effectiveDate) : new Date(), }, lines, }, ctx, ); if (!result.ok) { switch (result.error.code) { case "OUTBOUND_SHIPMENT_EMPTY_SHIPMENT_LINES": throw new DomainError("At least one line is required."); case "OUTBOUND_SHIPMENT_INVALID_QUANTITY": throw new DomainError(`Invalid quantity: ${result.error.message}`); case "OUTBOUND_SHIPMENT_ITEM_NOT_FOUND": throw new DomainError(`Item not found: ${result.error.message}`); case "OUTBOUND_SHIPMENT_STORAGE_LOCATION_NOT_FOUND": throw new DomainError( `Storage location not found: ${context.input.storageLocationId}`, ); case "OUTBOUND_SHIPMENT_PRIMARY_UNIT_MISMATCH": throw new DomainError(`Unit mismatch for an item: ${result.error.message}`); case "OUTBOUND_SHIPMENT_INVALID_UNIT_CONVERSION_RATE": throw new DomainError(`Invalid unit conversion rate: ${result.error.message}`); 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; } } return { id: result.value.outboundShipmentId, // A freshly created shipment is always a draft. status: "DRAFT", }; }) .catch((err: unknown) => { if (err instanceof DomainError) throw err; throw new Error("Failed to create the outbound shipment", { cause: err }); }); }, output: t .object({ id: t.string().description("Outbound shipment (OutboundShipment) ID"), status: t.string().description("Document status (DRAFT)"), }) .description("CreateOutboundShipment response"), });