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"; /** * updateTransferOrder — app-layer GraphQL mutation wrapping erp-kit native * TransferOrder.update. Allowed only while the order is in DRAFT; replaces * the line set entirely. * * erp-kit made native line edits incremental (`addLines` / `updateLines` keyed * by `lineId` / `removeLineIds`) instead of a whole-array replace. The edit * form still sends a full replacement line set and tracks no lineIds, so this * resolver translates that into "remove every existing line, add every * submitted line" — equivalent to the old whole-array replace. This is safe * only because update is DRAFT-only: every line's `shippedQuantity` / * `receivedQuantity` is always "0" at that stage, so recreating line rows loses * no shipment/receipt progress. */ export default createResolver({ name: "updateTransferOrder", operation: "mutation", input: { id: t.string().description("Transfer order ID"), sourceSiteId: t .string({ optional: true }) .description("Site the goods are dispatched from. Omit to leave unchanged."), destinationSiteId: t .string({ optional: true }) .description("Site the goods are received at. Omit to leave unchanged."), plannedShipmentDate: t .date({ optional: true }) .description("Planned source-site shipping date. Omit to leave unchanged."), expectedReceiptDate: t .date({ optional: true }) .description("Planned destination-site receiving date. Omit to leave unchanged."), notes: t .string({ optional: true }) .description("Operator notes. Omit to leave unchanged, empty string to clear."), lines: t .object( { itemId: t.string().description("Item ID"), orderedQuantity: t.string().description("Ordered quantity (decimal string, > 0)"), }, { array: true }, ) .description("Replacement transfer order lines"), }, body: async (context) => { const ctx = createContext(context); const db = getDB("main-db"); return db .transaction() .execute(async (trx) => { // erp-kit ≥ 0.39 made the native command's line edits incremental // (addLines / removeLineIds). The edit form tracks no line IDs and submits // the full set, so express the edit as "remove every existing line, re-add // every submitted line" — equivalent to the old whole-array replace. Safe // only on this DRAFT-only edit path, where every line's shipped/received // quantity is still 0, so recreating the rows loses no progress. const existingLines = await trx .selectFrom("TransferOrderLine") .select(["id"]) .where("transferOrderId", "=", context.input.id) .execute(); const lineOps = { removeLineIds: existingLines.map((line) => line.id), addLines: context.input.lines.map((line) => ({ itemId: line.itemId, orderedQuantity: line.orderedQuantity, })), }; const result = await invModulesExport.commands.updateTransferOrder( trx, { id: context.input.id, headerPatch: { sourceSiteId: context.input.sourceSiteId ?? undefined, destinationSiteId: context.input.destinationSiteId ?? undefined, // Anchor date-only inputs at UTC noon (same convention as // updatePurchaseOrder) so client-side TZ rendering doesn't shift // the calendar date by ±1 day. plannedShipmentDate: context.input.plannedShipmentDate ? new Date(`${context.input.plannedShipmentDate}T12:00:00.000Z`) : undefined, expectedReceiptDate: context.input.expectedReceiptDate ? new Date(`${context.input.expectedReceiptDate}T12:00:00.000Z`) : undefined, // Extension-field patch: undefined leaves unchanged, "" clears. notes: context.input.notes === undefined ? undefined : context.input.notes === "" ? null : context.input.notes, }, ...lineOps, }, 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 is not in DRAFT and cannot be edited: ${context.input.id}`, ); case "INVENTORY_INVALID_TRANSFER_LOCATIONS": throw new DomainError( `Source and destination Sites must differ: ${result.error.message}`, ); case "INVENTORY_EMPTY_TRANSFER_LINES": throw new DomainError("Transfer order must have at least one line"); case "INVENTORY_INVALID_QUANTITY": throw new DomainError(`Invalid quantity: ${result.error.message}`); case "INVENTORY_ITEM_NOT_FOUND": throw new DomainError(`Item not found: ${result.error.message}`); case "INVENTORY_SITE_NOT_FOUND": throw new DomainError(`Site not found: ${result.error.message}`); case "INVENTORY_TRANSFER_ORDER_LINE_NOT_FOUND": throw new DomainError(`Transfer order line not found: ${result.error.message}`); case "INVENTORY_CROSS_COMPANY_TRANSFER_NOT_SUPPORTED": throw new DomainError("Cross-company transfers are not supported"); 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.transferOrder.id }; }) .catch((err: unknown) => { if (err instanceof DomainError) throw err; throw new Error("Failed to update the transfer order", { cause: err }); }); }, output: t .object({ id: t.string().description("Updated transfer order ID"), }) .description("UpdateTransferOrder response"), });