import { createResolver, t } from "@tailor-platform/sdk"; import { getDB } from "@/generated/kysely-tailordb"; type MainDbHandle = ReturnType>; /** * BFF (backend-for-frontend) resolver: a composite endpoint shaped for the UI, * not a plain module CRUD query. (Lived under resolver/bff/ previously.) * * Per-inbound-shipment open (remaining) quantity for the inbound-shipment list * screen. For each requested shipment, summed over the distinct PO lines it * references: * * openQuantity = Σ max(0, ordered − receivedFromPostedShipments − ownDraftQty) * * where `ownDraftQty` is this shipment's own to-receive quantity on that PO line * and is only subtracted when the shipment is still DRAFT (a POSTED shipment's * own quantity is already inside `receivedFromPostedShipments`). This mirrors * the detail screen's per-line formula `ordered − totalPosted − (isPosted ? 0 * : thisReceipt)` so the list and detail agree (#550). The received-side sum * considers every POSTED shipment line referencing the same PO line — not just * lines on the requested shipments — so the result is page-size independent. */ interface OpenQuantity { id: string; openQuantity: string; } async function computeOpenQuantities(db: MainDbHandle, ids: string[]): Promise { // Requested shipments' own lines, with each shipment's status so we know which // are still DRAFT. An inbound shipment is an InboundShipment (+ // InboundShipmentLine) whose lines link back to a PO line via `sourceLineId`. const requestedShipmentLines = await db .selectFrom("InboundShipmentLine") .innerJoin("InboundShipment", "InboundShipment.id", "InboundShipmentLine.inboundShipmentId") .select([ "InboundShipmentLine.inboundShipmentId as inboundShipmentId", "InboundShipmentLine.sourceLineId as purchaseOrderLineId", "InboundShipmentLine.quantity as quantity", "InboundShipment.status as status", ]) .where("InboundShipmentLine.inboundShipmentId", "in", ids) .execute(); const poLineIds = [ ...new Set( requestedShipmentLines .map((l) => l.purchaseOrderLineId) .filter((id): id is string => id != null), ), ]; if (poLineIds.length === 0) { return ids.map((id) => ({ id, openQuantity: "0" })); } const poLines = await db .selectFrom("PurchaseOrderLine") .select(["id", "quantity"]) .where("id", "in", poLineIds) .execute(); const orderedByPoLine: Record = {}; for (const pol of poLines) orderedByPoLine[pol.id] = Number(pol.quantity); const postedReceiptLines = await db .selectFrom("InboundShipmentLine") .innerJoin("InboundShipment", "InboundShipment.id", "InboundShipmentLine.inboundShipmentId") .select([ "InboundShipmentLine.sourceLineId as purchaseOrderLineId", "InboundShipmentLine.quantity", ]) .where("InboundShipmentLine.sourceLineId", "in", poLineIds) .where("InboundShipment.status", "=", "POSTED") .execute(); const postedReceivedByPoLine: Record = {}; for (const line of postedReceiptLines) { if (!line.purchaseOrderLineId) continue; postedReceivedByPoLine[line.purchaseOrderLineId] = (postedReceivedByPoLine[line.purchaseOrderLineId] ?? 0) + Number(line.quantity); } // A single shipment can carry multiple lines that reference the same PO line; // dedupe the (shipment, PO line) pair so each contributes once. Track this // shipment's own to-receive quantity per PO line (used only for drafts). const distinctPoLinesByShipment = new Map>(); const ownQtyByShipmentPoLine = new Map>(); const isDraftByShipment = new Map(); for (const line of requestedShipmentLines) { isDraftByShipment.set(line.inboundShipmentId, line.status === "DRAFT"); if (!line.purchaseOrderLineId) continue; if (!distinctPoLinesByShipment.has(line.inboundShipmentId)) { distinctPoLinesByShipment.set(line.inboundShipmentId, new Set()); } distinctPoLinesByShipment.get(line.inboundShipmentId)!.add(line.purchaseOrderLineId); if (!ownQtyByShipmentPoLine.has(line.inboundShipmentId)) { ownQtyByShipmentPoLine.set(line.inboundShipmentId, new Map()); } const perPoLine = ownQtyByShipmentPoLine.get(line.inboundShipmentId)!; perPoLine.set( line.purchaseOrderLineId, (perPoLine.get(line.purchaseOrderLineId) ?? 0) + Number(line.quantity), ); } const openByShipment: Record = {}; for (const id of ids) openByShipment[id] = 0; for (const [shipmentId, poLineSet] of distinctPoLinesByShipment) { const isDraft = isDraftByShipment.get(shipmentId) ?? false; let openForShipment = 0; for (const poLineId of poLineSet) { const ordered = orderedByPoLine[poLineId] ?? 0; const received = postedReceivedByPoLine[poLineId] ?? 0; // Drafts aren't in the POSTED sum yet, so subtract their own qty; posted // shipments are already in `received`, so subtracting again double-counts. const ownDraftQty = isDraft ? (ownQtyByShipmentPoLine.get(shipmentId)?.get(poLineId) ?? 0) : 0; openForShipment += Math.max(0, ordered - received - ownDraftQty); } openByShipment[shipmentId] = openForShipment; } return ids.map((id) => ({ id, openQuantity: String(openByShipment[id] ?? 0) })); } export default createResolver({ name: "inboundShipmentOpenQuantities", operation: "query", input: { ids: t.string({ array: true }).description("Inbound shipment IDs"), }, body: async (context) => { const ids = context.input.ids ?? []; if (ids.length === 0) return { items: [] }; const db = getDB("main-db"); const items = await computeOpenQuantities(db, ids); return { items }; }, output: t .object({ items: t .object( { id: t.string().description("Inbound shipment ID"), openQuantity: t .string() .description("Total open (remaining) quantity across all PO-linked lines"), }, { array: true }, ) .description("Per-GR open quantity"), }) .description("InboundShipmentOpenQuantities response"), });