import { createContext } from "@tailor-platform/erp-kit/app"; import { createResolver, t } from "@tailor-platform/sdk"; import { getDB } from "@/generated/kysely-tailordb"; import { salesModulesExport } from "@/modules"; /** * Delegate to erp-kit's `amendConfirmedSalesOrder`. * * erp-kit owns the amend audit model (`SalesOrderRevision` envelope + * `SalesOrderFieldChange` per-field rows) and the incremental command input * shared with create/update SO: `headerPatch` + `addLines` / `updateLines` * (keyed by lineId) / `removeLineIds`. * * The GraphQL input keeps flat `changes` / `headerChanges` arrays (to keep the * edit form simple); this resolver reshapes them into erp-kit's keyed * `headerPatch` object + incremental line ops before delegating. The new * `revisionNumber` is re-read for the response. */ const ALLOWED_HEADER_FIELDS = ["orderDate", "shippingAddress", "billingAddress"] as const; type AllowedHeaderField = (typeof ALLOWED_HEADER_FIELDS)[number]; const DATE_FIELDS = new Set(["orderDate"]); // orderDate is required; the others may be cleared to NULL. const NULLABLE_FIELDS = new Set(["shippingAddress", "billingAddress"]); /** Strict YYYY-MM-DD; rejects `2026/01/01`, `Jan 1 2026`, etc. */ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; export default createResolver({ name: "amendConfirmedSalesOrder", operation: "mutation", input: { id: t.string().description("Sales order ID"), reason: t.string({ optional: true }).description("Amendment reason"), changes: t .object( { action: t.string().description("Change action: ADD, MODIFY, or REMOVE"), lineId: t .string({ optional: true }) .description("Existing line ID (required for MODIFY and REMOVE)"), itemId: t.string({ optional: true }).description("Item ID (required for ADD)"), quantity: t.string({ optional: true }).description("Quantity"), unitPrice: t.string({ optional: true }).description("Unit price"), unitId: t.string({ optional: true }).description("Unit ID (for ADD)"), }, { array: true, optional: true }, ) .description("Line-level amendment changes"), headerChanges: t .object( { field: t.string().description("One of: orderDate | shippingAddress | billingAddress"), value: t .string({ optional: true }) .description("Post-amendment value; empty string clears to NULL on nullable fields"), }, { array: true, optional: true }, ) .description( "Header-field amendments. Recorded as SalesOrderFieldChange rows under the same SalesOrderRevision as line changes from the same call.", ), }, body: async (context) => { const ctx = createContext(context); const db = getDB("main-db"); const rawLineChanges = context.input.changes ?? []; const rawHeaderChanges = context.input.headerChanges ?? []; if (rawLineChanges.length === 0 && rawHeaderChanges.length === 0) { throw new Error("Amendment must include at least one change"); } // --- Normalize header changes into erp-kit's keyed headerPatch --- const headerPatch: Record = {}; for (const hc of rawHeaderChanges) { if (!(ALLOWED_HEADER_FIELDS as readonly string[]).includes(hc.field)) { throw new Error( `Invalid header field: ${hc.field}. Must be one of ${ALLOWED_HEADER_FIELDS.join(", ")}.`, ); } const field = hc.field as AllowedHeaderField; const raw = hc.value; // Omit / null / undefined → no-op. if (raw == null) continue; // Empty string → explicit clear (nullable fields only). if (raw === "") { if (!NULLABLE_FIELDS.has(field)) { throw new Error(`Field ${field} is required and cannot be cleared.`); } headerPatch[field] = null; continue; } // Date fields: strict YYYY-MM-DD, anchored at noon UTC. if (DATE_FIELDS.has(field)) { if (!DATE_RE.test(raw)) { throw new Error(`Invalid date for ${field}: "${raw}". Expected YYYY-MM-DD.`); } const anchored = `${raw}T12:00:00.000Z`; if (Number.isNaN(new Date(anchored).getTime())) { throw new Error(`Invalid date for ${field}: "${raw}".`); } headerPatch[field] = new Date(anchored); continue; } headerPatch[field] = raw; } // --- Normalize line changes into addLines / updateLines / removeLineIds --- const required = (value: T | null | undefined, name: string): T => { if (value == null) throw new Error(`${name} is required`); return value; }; const addLineInputs: { itemId: string; quantity: string; unitPrice: string; unitId?: string; }[] = []; const updateLines: { lineId: string; linePatch: { quantity?: string; unitPrice?: string }; }[] = []; const removeLineIds: string[] = []; for (const c of rawLineChanges) { switch (c.action) { case "ADD": addLineInputs.push({ itemId: required(c.itemId, "itemId"), quantity: required(c.quantity, "quantity"), unitPrice: required(c.unitPrice, "unitPrice"), unitId: c.unitId ?? undefined, }); break; case "MODIFY": { const lineId = required(c.lineId, "lineId"); const linePatch: (typeof updateLines)[number]["linePatch"] = {}; if (c.quantity != null) linePatch.quantity = c.quantity; if (c.unitPrice != null) linePatch.unitPrice = c.unitPrice; if (Object.keys(linePatch).length > 0) { updateLines.push({ lineId, linePatch }); } break; } case "REMOVE": removeLineIds.push(required(c.lineId, "lineId")); break; default: throw new Error(`Invalid action: "${c.action}". Must be ADD, MODIFY, or REMOVE`); } } return db.transaction().execute(async (trx) => { const result = await salesModulesExport.commands.amendConfirmedSalesOrder( trx, { id: context.input.id, reason: context.input.reason ?? undefined, headerPatch: Object.keys(headerPatch).length > 0 ? headerPatch : undefined, addLines: addLineInputs.length > 0 ? addLineInputs : undefined, updateLines: updateLines.length > 0 ? updateLines : undefined, removeLineIds: removeLineIds.length > 0 ? removeLineIds : undefined, }, ctx, ); if (!result.ok) { switch (result.error.code) { case "SALES_SALES_ORDER_NOT_FOUND": throw new Error(`Sales order not found: ${context.input.id}`); case "SALES_SALES_ORDER_NOT_CONFIRMED": throw new Error(`Sales order is not in CONFIRMED status: ${context.input.id}`); case "SALES_EMPTY_AMENDMENT_CHANGES": throw new Error("Amendment must include at least one change"); case "SALES_LINE_NOT_FOUND": throw new Error(`Sales order line not found: ${result.error.message}`); case "SALES_MODIFY_QUANTITY_BELOW_FULFILLED": throw new Error( `Cannot reduce quantity below fulfilled amount: ${result.error.message}`, ); case "SALES_MODIFY_QUANTITY_BELOW_BILLED": throw new Error(`Cannot reduce quantity below billed amount: ${result.error.message}`); case "SALES_REMOVE_LINE_HAS_FULFILLMENTS": throw new Error(`Cannot remove a line with fulfillments: ${result.error.message}`); case "SALES_REMOVE_LINE_HAS_BILLS": throw new Error(`Cannot remove a line with bills: ${result.error.message}`); case "SALES_EMPTY_SALES_ORDER_LINES": throw new Error("A sales order must keep at least one line"); case "SALES_ITEM_NOT_FOUND": throw new Error(`Item not found: ${result.error.message}`); case "SALES_ITEM_NOT_SELLABLE": throw new Error(`Item has no selling record: ${result.error.message}`); case "SALES_INVALID_QUANTITY": throw new Error(`Invalid quantity: ${result.error.message}`); case "SALES_INVALID_UNIT_PRICE": throw new Error(`Invalid unit price: ${result.error.message}`); default: throw result.error; } } // erp-kit returns only the ids, not the new envelope's `revisionNumber` // (which lives on SalesOrderRevision). Re-read it. const latestRevision = await trx .selectFrom("SalesOrderRevision") .select("revisionNumber") .where("id", "=", result.value.revisionId) .executeTakeFirstOrThrow(); return { id: context.input.id, revisionNumber: latestRevision.revisionNumber, }; }); }, output: t .object({ id: t.string().description("Sales order ID"), revisionNumber: t.int().description("New revision number"), }) .description("AmendConfirmedSalesOrder response"), });