import { createContext, DomainError } from "@tailor-platform/erp-kit/app"; import { createResolver, t } from "@tailor-platform/sdk"; import { getDB } from "@/generated/kysely-tailordb"; import { purchaseModules } from "@/modules"; /** * Update a draft PurchaseOrder. * * erp-kit 0.39 made line edits incremental: the command takes headerPatch + * addLines / updateLines (by lineId) / removeLineIds and leaves any line it * isn't told about untouched. We expose that shape on the GraphQL input so the * edit form sends explicit add / update / remove ops — a header-only save sends * no line ops, and removeLineIds is driven solely by explicit caller intent * (the lines the user removed), never derived from "existing lines minus what * was sent". That is the structural guard against the #350-class silent * deletion: once the edit form paginates lines (loads a page at a time instead * of the current first: 1000), the lines it never loaded are simply absent from * every op collection and stay put. */ export default createResolver({ name: "updatePurchaseOrder", operation: "mutation", input: { id: t.string().description("Purchase order ID"), orderDate: t.string({ optional: true }).description("Order date (ISO 8601)"), receivingSiteId: t .string({ optional: true }) .description("Default receiving site for all lines"), externalSupplierOrderReference: t .string({ optional: true }) .description("External supplier order reference"), // erp-kit 0.39: line edits are incremental, not a whole-array replace. A // header-only save sends no line ops, so lines the form never loaded (beyond // its page once paginated) are left untouched. Removals are driven only by // explicit removeLineIds, never derived from "existing minus what was sent". addLines: t .object( { itemId: t.string().description("Item ID"), quantity: t.string().description("Quantity"), unitPrice: t.string().description("Unit price"), unitId: t.string().description("Unit ID"), }, { array: true, optional: true }, ) .description("New order lines to add"), updateLines: t .object( { lineId: t.string().description("Existing line ID to patch"), itemId: t.string({ optional: true }).description("Item ID"), quantity: t.string({ optional: true }).description("Quantity"), unitPrice: t.string({ optional: true }).description("Unit price"), unitId: t.string({ optional: true }).description("Unit ID"), }, { array: true, optional: true }, ) .description("Existing order lines to patch, keyed by lineId"), removeLineIds: t .string({ array: true, optional: true }) .description("Existing order line IDs to remove"), }, body: async (context) => { const ctx = createContext(context); const db = getDB("main-db"); return db .transaction() .execute(async (trx) => { const addLines = context.input.addLines ?? []; const updateLines = context.input.updateLines ?? []; const removeLineIds = context.input.removeLineIds ?? []; // Added lines carry their full value set. const builtAddLines = addLines.map((line) => ({ itemId: line.itemId, quantity: line.quantity, unitPrice: line.unitPrice, unitId: line.unitId, })); // Each patch carries only the fields the caller changed. A changed itemId // re-snapshots name/sku in the command. const builtUpdateLines = updateLines.map((edit) => { const linePatch: { itemId?: string; quantity?: string; unitPrice?: string; unitId?: string; } = {}; if (edit.itemId != null) linePatch.itemId = edit.itemId; if (edit.quantity != null) linePatch.quantity = edit.quantity; if (edit.unitPrice != null) linePatch.unitPrice = edit.unitPrice; if (edit.unitId != null) linePatch.unitId = edit.unitId; return { lineId: edit.lineId, linePatch }; }); const result = await purchaseModules.commands.updatePurchaseOrder( trx, { id: context.input.id, headerPatch: { orderDate: context.input.orderDate ? new Date(context.input.orderDate) : undefined, receivingSiteId: context.input.receivingSiteId ?? undefined, externalSupplierOrderReference: context.input.externalSupplierOrderReference ?? undefined, }, addLines: builtAddLines.length ? builtAddLines : undefined, updateLines: builtUpdateLines.length ? builtUpdateLines : undefined, removeLineIds: removeLineIds.length ? removeLineIds : undefined, }, ctx, ); if (!result.ok) { switch (result.error.code) { case "PURCHASE_PURCHASE_ORDER_NOT_FOUND": throw new DomainError(`Purchase order not found: ${context.input.id}`); case "PURCHASE_PURCHASE_ORDER_NOT_DRAFT": throw new DomainError(`Purchase order is not in DRAFT status: ${context.input.id}`); case "PURCHASE_LINE_NOT_FOUND": throw new DomainError(`Order line not found: ${result.error.message}`); case "PURCHASE_ITEM_NOT_FOUND": throw new DomainError(`Item not found: ${result.error.message}`); case "PURCHASE_ITEM_NOT_ACTIVE": throw new DomainError(`Item is not active: ${result.error.message}`); case "PURCHASE_ITEM_NOT_PURCHASABLE": throw new DomainError(`Item has no purchasing record: ${result.error.message}`); case "PURCHASE_INVALID_QUANTITY": throw new DomainError(`Invalid quantity: ${result.error.message}`); case "PURCHASE_INVALID_UNIT_PRICE": throw new DomainError(`Invalid unit price: ${result.error.message}`); case "PURCHASE_RECEIVING_SITE_NOT_FOUND": throw new DomainError(`Receiving site not found: ${result.error.message}`); case "PURCHASE_RECEIVING_SITE_REQUIRED": throw new DomainError(`Receiving site is required for line: ${result.error.message}`); case "PURCHASE_EMPTY_PURCHASE_ORDER_LINES": throw new DomainError("The purchase order must have at least one line"); 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.purchaseOrderId }; }) .catch((err: unknown) => { if (err instanceof DomainError) throw err; throw new Error("Failed to update the purchase order", { cause: err }); }); }, output: t .object({ id: t.string().description("Updated purchase order ID"), }) .description("UpdatePurchaseOrder response"), });