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"; /** * Delegate to erp-kit's `amendOrderedPurchaseOrder`. * * erp-kit owns the amend audit model (`PurchaseOrderRevision` envelope + * `PurchaseOrderFieldChange` per-field rows) and the incremental command input * shared with create/update PO: `headerPatch` + `addLines` / `updateLines` * (keyed by lineId) / `removeLineIds`. * * The GraphQL input keeps the 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, then re-reads * the new `revisionNumber` for the response. */ const ALLOWED_HEADER_FIELDS = [ "orderDate", "receivingSiteId", "externalSupplierOrderReference", ] as const; type AllowedHeaderField = (typeof ALLOWED_HEADER_FIELDS)[number]; const DATE_FIELDS = new Set(["orderDate"]); const NULLABLE_FIELDS = new Set([ "receivingSiteId", "externalSupplierOrderReference", ]); /** 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: "amendOrderedPurchaseOrder", operation: "mutation", input: { id: t.string().description("Purchase 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 (required for ADD)"), receivingSiteId: t.string({ optional: true }).description("Receiving site ID"), }, { array: true, optional: true }, ) .description("Line-level amendment changes"), headerChanges: t .object( { field: t .string() .description("One of: orderDate | receivingSiteId | externalSupplierOrderReference"), 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 PurchaseOrderFieldChange rows under the same PurchaseOrderRevision 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 DomainError("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 DomainError( `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 DomainError(`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 DomainError(`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 DomainError(`Invalid date for ${field}: "${raw}".`); } headerPatch[field] = new Date(anchored); continue; } // UUID / TEXT — pass through. headerPatch[field] = raw; } // --- Normalize line changes into addLines / updateLines / removeLineIds --- const required = (value: T | null | undefined, name: string): T => { if (value == null) throw new DomainError(`${name} is required`); return value; }; const addLines: { itemId: string; quantity: string; unitPrice: string; unitId: string; receivingSiteId?: string; }[] = []; const updateLines: { lineId: string; linePatch: { quantity?: string; unitPrice?: string; receivingSiteId?: string; }; }[] = []; const removeLineIds: string[] = []; for (const c of rawLineChanges) { switch (c.action) { case "ADD": addLines.push({ itemId: required(c.itemId, "itemId"), quantity: required(c.quantity, "quantity"), unitPrice: required(c.unitPrice, "unitPrice"), unitId: required(c.unitId, "unitId"), receivingSiteId: c.receivingSiteId ?? undefined, }); break; case "MODIFY": { const lineId = required(c.lineId, "lineId"); const linePatch: { quantity?: string; unitPrice?: string; receivingSiteId?: string; } = {}; if (c.quantity != null) linePatch.quantity = c.quantity; if (c.unitPrice != null) linePatch.unitPrice = c.unitPrice; if (c.receivingSiteId != null) linePatch.receivingSiteId = c.receivingSiteId; if (Object.keys(linePatch).length > 0) { updateLines.push({ lineId, linePatch }); } break; } case "REMOVE": removeLineIds.push(required(c.lineId, "lineId")); break; default: throw new DomainError(`Invalid action: "${c.action}". Must be ADD, MODIFY, or REMOVE`); } } return db .transaction() .execute(async (trx) => { const result = await purchaseModules.commands.amendOrderedPurchaseOrder( trx, { id: context.input.id, reason: context.input.reason ?? undefined, headerPatch: Object.keys(headerPatch).length > 0 ? headerPatch : undefined, addLines: addLines.length > 0 ? addLines : undefined, updateLines: updateLines.length > 0 ? updateLines : undefined, removeLineIds: removeLineIds.length > 0 ? removeLineIds : undefined, }, ctx, ); if (!result.ok) { switch (result.error.code) { case "PURCHASE_PO_NOT_FOUND": throw new DomainError(`Purchase order not found: ${context.input.id}`); case "PURCHASE_PO_NOT_ORDERED": throw new DomainError(`Purchase order is not in ORDERED status: ${context.input.id}`); case "PURCHASE_EMPTY_AMENDMENT_CHANGES": throw new DomainError("Amendment must include at least one change"); case "PURCHASE_LINE_NOT_FOUND": throw new DomainError(`Purchase order line not found: ${result.error.message}`); case "PURCHASE_MODIFY_QUANTITY_BELOW_RECEIVED": throw new DomainError( `Cannot reduce quantity below received amount: ${result.error.message}`, ); case "PURCHASE_REMOVE_LINE_HAS_RECEIPTS": throw new DomainError( `Cannot remove line with existing receipts: ${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: ${result.error.message}`); case "PURCHASE_EMPTY_PURCHASE_ORDER_LINES": throw new DomainError("The purchase order must keep at least one line."); case "PURCHASE_MODIFY_QUANTITY_BELOW_BILLED": throw new DomainError("Cannot reduce quantity below billed amount."); case "PURCHASE_REMOVE_LINE_HAS_BILLS": throw new DomainError("Cannot remove line with existing bills."); 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_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_SITE_NOT_FOUND": throw new DomainError("A referenced site was not found."); case "INVENTORY_INVALID_SUPPLY_PLAN_QUANTITIES": throw new DomainError("Supply plan quantities are invalid."); case "INVENTORY_ITEM_NOT_FOUND": throw new DomainError("A referenced item was not found."); case "INVENTORY_SUPPLY_PLAN_UNIT_MISMATCH": throw new DomainError("A line unit does not match the supply plan unit."); 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; } } // erp-kit returns only the purchaseOrderId, not the new envelope's // `revisionNumber` (which lives on PurchaseOrderRevision). Re-read it. const latestRevision = await trx .selectFrom("PurchaseOrderRevision") .select("revisionNumber") .where("purchaseOrderId", "=", context.input.id) .orderBy("revisionNumber", "desc") .limit(1) .executeTakeFirstOrThrow(); return { id: context.input.id, revisionNumber: latestRevision.revisionNumber, }; }) .catch((err: unknown) => { if (err instanceof DomainError) throw err; throw new Error("Failed to amend the purchase order", { cause: err }); }); }, output: t .object({ id: t.string().description("Purchase order ID"), revisionNumber: t.int().description("New revision number"), }) .description("AmendOrderedPurchaseOrder response"), });