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"; /** * BFF (backend-for-frontend) resolver: a composite endpoint shaped for the UI, * not a plain module CRUD command. (Lived under resolver/bff/ previously.) * * Creates a draft inbound shipment for the remaining (unreceived) quantities on a PO. * * Calculates: ordered qty − sum(received qty from POSTED GRs) per line. * Only includes lines with remaining > 0. * Returns { id: "", docNumber: "", created: "false" } if the PO is fully received. * * In erp-kit 0.45 an inbound shipment is an InboundShipment; its lines live in * InboundShipmentLine and carry the source-document link at the line level * (`sourceDocumentType=PURCHASE_ORDER`, `sourceDocumentId=`, * `sourceLineId=`). Each line also requires `itemId`, * `storageLocationId`, `primaryQuantity`, `primaryUnitId`, and * `unitConversionRate` — resolved from the linked PO line (item/unit), the PO * line's `receivingSiteId` → first ACTIVE StorageLocation, and rate=1 in the * item's primary unit. */ export default createResolver({ name: "createRemainingInboundShipment", operation: "mutation", input: { purchaseOrderId: t.string().description("Purchase order ID"), }, body: async (context) => { const ctx = createContext(context); const db = getDB("main-db"); // 1. Fetch the PO const po = await db .selectFrom("PurchaseOrder") .selectAll() .where("id", "=", context.input.purchaseOrderId) .executeTakeFirst(); if (!po) { throw new DomainError(`Purchase order not found: ${context.input.purchaseOrderId}`); } if (po.orderStatus === "CANCELLED" || po.orderStatus === "CLOSED") { throw new DomainError(`Purchase order is not receivable: ${context.input.purchaseOrderId}`); } // 2. Fetch PO lines const poLines = await db .selectFrom("PurchaseOrderLine") .selectAll() .where("purchaseOrderId", "=", po.id) .execute(); if (poLines.length === 0) { return { id: "", docNumber: "", created: "false" }; } // 3. Sum received quantities from POSTED GRs for this PO's lines only const poLineIds = poLines.map((l) => l.id); const receivedByPoLine: Record = {}; const grLines = await db .selectFrom("InboundShipmentLine") .innerJoin("InboundShipment", "InboundShipment.id", "InboundShipmentLine.inboundShipmentId") .select([ "InboundShipmentLine.sourceLineId as purchaseOrderLineId", "InboundShipmentLine.quantity", ]) .where("InboundShipment.status", "=", "POSTED") .where("InboundShipmentLine.sourceLineId", "in", poLineIds) .execute(); for (const grLine of grLines) { if (grLine.purchaseOrderLineId) { receivedByPoLine[grLine.purchaseOrderLineId] = (receivedByPoLine[grLine.purchaseOrderLineId] ?? 0) + Number(grLine.quantity); } } // 4. Compute remaining per line (carry through PO-line metadata // needed for the new createInboundShipment line input). const remainingLines = poLines .map((line) => { const ordered = Number(line.quantity); const received = receivedByPoLine[line.id] ?? 0; const remaining = ordered - received; return { poLine: line, remaining }; }) .filter((l) => l.remaining > 0); if (remainingLines.length === 0) { return { id: "", docNumber: "", created: "false" }; } // 5. Resolve a default storage location per receiving site, so each // remaining line gets a concrete `storageLocationId`. We pick the // first ACTIVE location on the line's receiving site, falling back // to the PO's receivingSiteId for lines that don't carry one. const candidateSiteIds = [ ...new Set( remainingLines .map((l) => l.poLine.receivingSiteId ?? po.receivingSiteId ?? null) .filter((id): id is string => id != null), ), ]; const storageLocations = candidateSiteIds.length ? await db .selectFrom("StorageLocation") .select(["id", "siteId"]) .where("siteId", "in", candidateSiteIds) .where("status", "=", "ACTIVE") .execute() : []; const storageBySite = new Map(); for (const loc of storageLocations) { if (!storageBySite.has(loc.siteId)) storageBySite.set(loc.siteId, loc.id); } // Per-item primary unit lookup. const itemIds = [...new Set(remainingLines.map((l) => l.poLine.itemId))]; const itemRows = itemIds.length ? await db.selectFrom("Item").select(["id", "unitId"]).where("id", "in", itemIds).execute() : []; const itemPrimaryUnitMap = new Map(itemRows.map((r) => [r.id, r.unitId])); // 6. Create draft GR for remaining quantities const grResult = await db .transaction() .execute(async (trx) => { const result = await inboundShipmentModulesExport.commands.createInboundShipment( trx, { header: { effectiveDate: new Date(), }, lines: remainingLines.map((l) => { const siteId = l.poLine.receivingSiteId ?? po.receivingSiteId ?? null; const storageLocationId = siteId ? storageBySite.get(siteId) : undefined; if (!storageLocationId) { throw new DomainError( `No ACTIVE storage location found for receiving site ${siteId ?? "(none)"} on PO line ${l.poLine.id} — configure a destination before creating remaining receipts.`, ); } const primaryUnitId = itemPrimaryUnitMap.get(l.poLine.itemId) ?? l.poLine.unitId; return { itemId: l.poLine.itemId, storageLocationId, quantity: String(l.remaining), unitId: l.poLine.unitId, primaryQuantity: String(l.remaining), primaryUnitId, unitConversionRate: "1", stockType: "AVAILABLE" as const, // Line-level source-document link (0.45 moved it off the header). sourceDocumentType: "PURCHASE_ORDER" as const, sourceDocumentId: po.id, sourceLineId: l.poLine.id, }; }), }, ctx, ); if (!result.ok) { // Propagate erp-kit's typed error as-is so callers can match on // `error.code` and the GraphQL layer can surface stack / message // / extensions. throw result.error; } return result.value.inboundShipmentId; }) .catch((err: unknown) => { if (err instanceof DomainError) throw err; throw new Error("Failed to create the remaining inbound shipment", { cause: err }); }); const gr = await db .selectFrom("InboundShipment") .select(["id", "docNumber"]) .where("id", "=", grResult) .executeTakeFirstOrThrow(); return { id: gr.id, docNumber: gr.docNumber, created: "true", }; }, output: t .object({ id: t.string().description("Created inbound shipment ID, empty string if fully received"), docNumber: t .string() .description("Created inbound shipment document number, empty string if fully received"), created: t.string().description("Whether a GR was created ('true' or 'false')"), }) .description("CreateRemainingInboundShipment response"), });