import { createContext, DomainError } from "@tailor-platform/erp-kit/app"; import { createResolver, t } from "@tailor-platform/sdk"; import { getDB } from "@/generated/kysely-tailordb"; import { inboundShipmentModulesExport } from "@/modules"; /** * Update a draft Inbound Shipment (InboundShipment). * * erp-kit makes line edits incremental: the command takes headerPatch + * updateLines (by lineId) / removeLineIds and leaves any line it isn't told * about untouched. We mirror that in the GraphQL input so a header-only save * sends no line ops and never disturbs lines the form didn't load. * removeLineIds is driven solely by explicit caller intent; it is never derived * from "existing lines minus what was sent". * * Unlike the pre-0.45 model, inbound-shipment lines must carry a purchase-order * source link (sourceDocumentType/sourceDocumentId/sourceLineId are required), * so this resolver does not support adding brand-new manual lines on edit — new * receipt lines are created via createInboundShipment against a PO. Edits here are * limited to header date, per-line quantity/item/unit patches, and removals. */ export default createResolver({ name: "updateInboundShipment", operation: "mutation", input: { id: t.string().description("Inbound shipment (InboundShipment) ID"), receiptDate: t.string({ optional: true }).description("Receipt date (ISO 8601)"), // 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) // are left untouched. Removals are driven only by explicit removeLineIds, // never derived from "existing minus what was sent". updateLines: t .object( { lineId: t.string().description("Existing receipt line ID to patch"), quantity: t.string({ optional: true }).description("Received quantity"), itemId: t.string({ optional: true }).description("Item ID"), unitId: t.string({ optional: true }).description("Unit ID"), }, { array: true, optional: true }, ) .description("Existing receipt lines to patch, keyed by lineId"), removeLineIds: t .string({ array: true, optional: true }) .description("Existing receipt line IDs to remove"), }, body: async (context) => { const ctx = createContext(context); const db = getDB("main-db"); return db .transaction() .execute(async (trx) => { const updateLines = context.input.updateLines ?? []; const removeLineIds = context.input.removeLineIds ?? []; // Per-item primary unit lookup (for primaryUnitId) across patched lines // that change the item. const lineItemIds = new Set(); for (const edit of updateLines) { if (edit.itemId) lineItemIds.add(edit.itemId); } const itemRows = lineItemIds.size ? await trx .selectFrom("Item") .select(["id", "unitId"]) .where("id", "in", [...lineItemIds]) .execute() : []; const itemPrimaryUnitMap = new Map(itemRows.map((r) => [r.id, r.unitId])); // Each patch carries only the fields the caller changed: primaryQuantity // tracks quantity, and a changed item re-derives primaryUnitId from the // item's primary unit. const builtUpdateLines = updateLines.map((edit) => { const linePatch: { itemId?: string; quantity?: string; unitId?: string; primaryQuantity?: string; primaryUnitId?: string; } = {}; if (edit.quantity != null) { linePatch.quantity = edit.quantity; linePatch.primaryQuantity = edit.quantity; } if (edit.itemId != null) { linePatch.itemId = edit.itemId; const primaryUnitId = itemPrimaryUnitMap.get(edit.itemId); if (primaryUnitId) linePatch.primaryUnitId = primaryUnitId; } if (edit.unitId != null) { linePatch.unitId = edit.unitId; } return { lineId: edit.lineId, linePatch }; }); const result = await inboundShipmentModulesExport.commands.updateInboundShipment( trx, { id: context.input.id, headerPatch: context.input.receiptDate ? { effectiveDate: new Date(context.input.receiptDate) } : undefined, updateLines: builtUpdateLines.length ? builtUpdateLines : undefined, removeLineIds: removeLineIds.length ? removeLineIds : undefined, }, ctx, ); if (!result.ok) { switch (result.error.code) { case "INBOUND_SHIPMENT_INVALID_STATUS": throw new DomainError( `Inbound shipment is not in DRAFT status: ${result.error.message}`, ); case "INBOUND_SHIPMENT_ITEM_NOT_FOUND": throw new DomainError(`Item not found: ${result.error.message}`); case "INBOUND_SHIPMENT_STORAGE_LOCATION_NOT_FOUND": throw new DomainError(`Storage location not found: ${result.error.message}`); case "INBOUND_SHIPMENT_INVALID_QUANTITY": throw new DomainError(`Invalid received quantity: ${result.error.message}`); case "INBOUND_SHIPMENT_PRIMARY_UNIT_MISMATCH": throw new DomainError(`Primary unit mismatch: ${result.error.message}`); case "INBOUND_SHIPMENT_INVALID_UNIT_CONVERSION_RATE": throw new DomainError(`Invalid unit conversion rate: ${result.error.message}`); case "INBOUND_SHIPMENT_EMPTY_SHIPMENT_LINES": throw new DomainError("At least one receipt line is required"); case "INBOUND_SHIPMENT_SHIPMENT_LINE_NOT_FOUND": throw new DomainError(`Receipt line not found: ${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.inboundShipmentId }; }) .catch((err: unknown) => { if (err instanceof DomainError) throw err; throw new Error("Failed to update the inbound shipment", { cause: err }); }); }, output: t .object({ id: t.string().description("Inbound shipment (InboundShipment) ID"), }) .description("UpdateInboundShipment response"), });