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"; /** * Create a draft Inbound Shipment (入庫). * * In erp-kit 0.45 the receipt side of inventory was extracted into the * inbound-shipment module: a receipt is an `InboundShipment` header + * `InboundShipmentLine` rows. `DRAFT` does not move stock; posting (see * settleInboundShipment) drives the inventory ledger and rolls up the linked PO's * receipt status. * * The frontend still calls this resolver with the flat legacy shape * `{companyId, supplierAccountId, receiptDate?, lines:[{purchaseOrderLineId, quantity, * itemId?, unitId?, destinationSite?, destinationStorageLocation?}]}`. Because * inbound-shipment lines carry the source-document link at the LINE level * (`sourceDocumentType=PURCHASE_ORDER`, `sourceDocumentId=`, * `sourceLineId=`) and all three are required, every receipt line * must reference a purchase-order line — manual non-PO receipts are not * representable in the 0.45 model and are rejected here. * * Storage location resolution (unchanged from the pre-0.45 behaviour): if a * line has no explicit `destinationStorageLocation`, fall back to its * `destinationSite`, else the PO line's effective receiving site (line ?? PO * header), and pick the first ACTIVE StorageLocation under that site. * * Unit conversion: this template records receipts in the item's primary unit, * so `primaryUnitId = Item.unitId` and `unitConversionRate = "1"`. */ export default createResolver({ name: "createInboundShipment", operation: "mutation", input: { companyId: t .string() .description("Company ID (informational; the receipt is linked via its PO)"), supplierAccountId: t .string() .description("Supplier account ID (informational; derived from the PO)"), receiptDate: t.string({ optional: true }).description("Receipt date (ISO 8601)"), lines: t .object( { purchaseOrderLineId: t.string({ optional: true }).description("PO line ID (required)"), quantity: t.string().description("Received quantity"), itemId: t.string({ optional: true }).description("Item ID"), unitId: t.string({ optional: true }).description("Unit ID"), destinationSite: t .string({ optional: true }) .description("Destination site ID for inventory handoff"), destinationStorageLocation: t .string({ optional: true }) .description("Destination storage location ID for inventory handoff"), }, { array: true }, ) .description("Receipt lines"), }, body: async (context) => { const ctx = createContext(context); const db = getDB("main-db"); return db .transaction() .execute(async (trx) => { // Resolve PO line metadata (itemId, unitId, source POId, receivingSiteId) // for every line — inbound-shipment lines require a PO source link, so a // receipt line without a PO line cannot be posted. const poLineIds = [ ...new Set( context.input.lines .map((l) => l.purchaseOrderLineId) .filter((id): id is string => id != null), ), ]; const poLineRows = poLineIds.length ? await trx .selectFrom("PurchaseOrderLine") .select(["id", "itemId", "unitId", "purchaseOrderId", "receivingSiteId"]) .where("id", "in", poLineIds) .execute() : []; const poLineMap = new Map(poLineRows.map((r) => [r.id, r])); // Header-level receivingSiteId per PO, used as the fallback when a PO // line carries no site of its own. POs created with only a header-level // site store `PurchaseOrderLine.receivingSiteId = null` (the header is // the default — see erp-kit createPurchaseOrder), so the header lookup // is the common case for PO-mode receipts, not an edge case. const poHeaderIds = [...new Set(poLineRows.map((r) => r.purchaseOrderId))]; const poHeaderSiteById = new Map(); if (poHeaderIds.length > 0) { const poHeaderRows = await trx .selectFrom("PurchaseOrder") .select(["id", "receivingSiteId"]) .where("id", "in", poHeaderIds) .execute(); for (const po of poHeaderRows) { poHeaderSiteById.set(po.id, po.receivingSiteId); } } // The effective destination site for a PO-linked line: // line.receivingSiteId ?? PO header.receivingSiteId. const poLineEffectiveSite = (poLineId: string): string | null => { const poLine = poLineMap.get(poLineId); if (!poLine) return null; return poLine.receivingSiteId ?? poHeaderSiteById.get(poLine.purchaseOrderId) ?? null; }; // Collect every site we may need a default storage location for: manual // lines' destinationSite plus PO-linked lines' effective site. Skip // lines that already carry an explicit destinationStorageLocation. const siteIds = [ ...new Set( context.input.lines .filter((l) => l.destinationStorageLocation == null) .map( (l) => l.destinationSite ?? (l.purchaseOrderLineId ? poLineEffectiveSite(l.purchaseOrderLineId) : null), ) .filter((id): id is string => id != null), ), ]; const storageLocationBySiteId = new Map(); if (siteIds.length > 0) { const locations = await trx .selectFrom("StorageLocation") .select(["StorageLocation.id", "StorageLocation.siteId"]) .where("StorageLocation.siteId", "in", siteIds) .where("StorageLocation.status", "=", "ACTIVE") .execute(); for (const loc of locations) { if (!storageLocationBySiteId.has(loc.siteId)) { storageLocationBySiteId.set(loc.siteId, loc.id); } } } // Resolve per-item primaryUnitId for lines that need it (we always emit // primaryUnitId = item.unitId, conversionRate = "1"). Batch the lookup. const lineItemIds = new Set(); for (const line of context.input.lines) { const itemId = line.itemId ?? (line.purchaseOrderLineId ? poLineMap.get(line.purchaseOrderLineId)?.itemId : undefined); if (itemId) lineItemIds.add(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])); const result = await inboundShipmentModulesExport.commands.createInboundShipment( trx, { header: { effectiveDate: context.input.receiptDate ? new Date(context.input.receiptDate) : new Date(), }, lines: context.input.lines.map((line) => { const poLine = line.purchaseOrderLineId ? poLineMap.get(line.purchaseOrderLineId) : undefined; // storageLocationId resolution, in priority order: // 1. explicit destinationStorageLocation // 2. manual line's destinationSite → first ACTIVE location // 3. PO-linked line's effective receiving site (line ?? PO // header) → first ACTIVE location. This third branch is what // the Create-GR screen's PO mode relies on: it sends neither // destinationStorageLocation nor destinationSite. const poFallbackSite = line.purchaseOrderLineId ? poLineEffectiveSite(line.purchaseOrderLineId) : null; const explicitLocation = line.destinationStorageLocation || null; const siteLocation = line.destinationSite ? (storageLocationBySiteId.get(line.destinationSite) ?? null) : null; const poLocation = poFallbackSite ? (storageLocationBySiteId.get(poFallbackSite) ?? null) : null; const resolvedStorageLocation = explicitLocation ?? siteLocation ?? poLocation; const itemId = line.itemId ?? poLine?.itemId; const unitId = line.unitId ?? poLine?.unitId; // Every receipt line must reference a PO line: inbound-shipment // records the source document at the line level and requires it. if (!poLine || !line.purchaseOrderLineId) { throw new DomainError( "Each receipt line must reference a purchase-order line — manual (non-PO) receipts are not supported.", ); } if (!itemId || !unitId) { throw new DomainError( "itemId and unitId could not be resolved for receipt line — ensure the linked PO line still exists.", ); } if (!resolvedStorageLocation) { throw new DomainError( "storageLocationId could not be resolved for receipt line — supply destinationStorageLocation or ensure the destination site has at least one ACTIVE storage location.", ); } const primaryUnitId = itemPrimaryUnitMap.get(itemId) ?? unitId; return { itemId, storageLocationId: resolvedStorageLocation, quantity: line.quantity, unitId, primaryQuantity: line.quantity, 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: poLine.purchaseOrderId, sourceLineId: line.purchaseOrderLineId, }; }), }, ctx, ); if (!result.ok) { switch (result.error.code) { case "INBOUND_SHIPMENT_EMPTY_SHIPMENT_LINES": throw new DomainError("At least one receipt line is required"); case "INBOUND_SHIPMENT_INVALID_QUANTITY": throw new DomainError(`Invalid received quantity: ${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_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 "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 create the inbound shipment", { cause: err }); }); }, output: t .object({ id: t.string().description("Inbound shipment (InboundShipment) ID"), }) .description("CreateInboundShipment response"), });