Work Order Shipment Latency Report
Virtual AdminJS report resource: raw-SQL CTE, aging buckets, filter dispatch, XLS export.
Deep-dive doc split out of
.claude/rules/architecture.md(which is now an index). Append new findings about this area here, not to the index.
Work Order Shipment Latency Report (workOrderShipmentLatency domain)
A read-only "report" surface — virtual AdminJS resource, no Prisma model; backed by a raw-SQL CTE that joins WorkOrder + CustomerShipment + PurchaseOrder and computes aging.
-
Admin resource:
src/routers/admin/resources/workOrderShipmentLatency/workOrderShipmentLatency.ts—isVirtual: true, name: 'WorkOrderShipmentLatency'. Declaresproperties,filterProperties,listProperties, and custom action handlers (listActionHandler,exportExcelActionHandler). Visibility gated byadminEditorViewerRoleAuth(role-based since INFRA-666; previously theadminBgcFactoryOpsAuthemail allowlist). Filter UI components are picked from the sharedComponentsregistry (POMultiSelectFilter,DatetimeFilter,MultiSelectFilter,AgingDaysProperty). -
Filter dispatch lives in the model (
src/models/workOrderShipmentLatency/workOrderShipmentLatency.model.ts):_makeWhereClause(where)builds aPrisma.Sqlarray — each filter (poIds,workOrderStatus,labelStatus,estimatedShipDate.{from,to},poCreateDate.{from,to},shipmentWorkOrder,agingDays) appends its own clause. Adding a new filter is: (1) add to the property list with a filter component, (2) extend theWheretype, (3) add a branch in_makeWhereClause, (4) add tofilterProperties. -
Aging is computed in SQL (not in JS) via
CASEoncs.labelStatus—Printedaging =labelPrintedAt - estimatedShipDate,Pending/Acknowledgedaging =now - estimatedShipDate, else 0. Bucket filtering (urgent/watch/safe) is applied in an outer query because the buckets reference the computedagingDayscolumn. -
Sort safety:
_makeOrderByClauseusesPrisma.raw(Prisma doesn't parameterize ORDER BY). The_canSortKeyswhitelist Set guards against injection — never bypass it. -
Factory scoping: model reads
getSelectedFactoryId()fromrequestContextdirectly (raw SQL bypassesselectedFactoryExtension), and addswo."factoryId" = $idto the CTE conditions when a session-selected factory exists. -
XLS export (
src/services/workOrderShipmentLatency/workOrderShipmentLatency.service.ts:makeExportExcel): builds anExcelJS.Workbookvialist2Excel(rows, columns). Column headers are hardcoded Chinese today; status enums are mapped through_workOrderStatus2CN/_labelStatus2CNfor display. Thelanguagequery param IS read byexportExcelActionHandlerand threaded intomakeWorkOrderShipmentLatencyWhereCondition(used for thelabelStatus.optionLanguageFilter), but it is not currently passed through tomakeExportExcel, so column headers don't switch on locale. Adding bilingual export means pipinglanguagefrom controller → service and parameterizing both column headers and the status-label maps. -
Tracking-number column is rendered via
Components.LinkPropertywithcustom.linkField: 'trackingNumberUrl'. -
API endpoint for XLS download:
GET /api/workOrderShipmentLatency/v1/exportExcel(session-authed, controllerexportExcel). Frontend opens this URL in a new tab; the controller pipesWorkOrderShipmentLatencyService.makeExportExcel(...)throughsendExcelFile(res, workbook, 'latency-dashboard-export-<date>.xlsx'). -
Frontend two-step export flow: clicking the toolbar Export button triggers a server action via
apiClient.resourceActionthat returns{ where, sortBys }. The component (src/components/actions/ExportWorkOrderShipmentLatency/ExportWorkOrderShipmentLatency.tsx) then builds a/api/.../exportExcel?data=<encoded>&timezoneOffsetHours=<n>URL and clicks an anchor to trigger the download in a new tab. This two-step indirection lets the same filter URL state survive into the new tab. -
Dead code —
completeShipmentsTracking(workOrderShipmentLatency.service.ts:31, exposed asWorkOrderShipmentLatencyService.completeShipmentsTrackingLogErrorvialogErrorServiceFunc). It fetches missing tracking numbers from Fulfil (fulfilGetShipmentsTracking), mutates the passed-inlistin place, and persists viaShipmentService.updateTrackingNumbers. Nothing insrc/orscripts/calls it — the only references are inworkOrderShipmentLatency.service.spec.ts(~7 tests). Tracking capture happens instead at print-label time (shippinglabel.service.ts#_retrieveTrackingNumber) and inscripts/backfill-shipment-tracking.ts. Note it usesupdateTrackingNumbers(tracking only) while the live print-label path usesupdateTrackingNumberCarriers(tracking + carrier), so it is stale in behaviour as well as unreachable. Removing it must also delete itsdescribeblock, or the spec fails to import.
Carrier-aware tracking links (trackingLink.utils.ts)
As of INFRA-673 the tracking URL is admin-managed, not hardcoded. resolveTrackingUrl(trackingNumber, carrierService, carrierMap) (src/services/workOrderShipmentLatency/trackingLink.utils.ts:23-32) replaced the old getTrackingUrl (which had hardcoded Yun Express/FedEx/USPS branches). It is pure + synchronous: the caller loads the carrier map once per request, then the per-row resolution is a Map.get + string build — no N+1.
- The map is
CarrierModel.findActiveCarrierMap()(models/carrier/carrier.model.ts:44-62), cached undercarrier:map. - Dispatch is on the normalized carrier name (
normalizeCarrierKey= trim + lowercase), matchingCustomerShipment.carrierServiceagainst theCarrier.carrierKeyjoin key. Empty/null tracking number or an absent carrier row short-circuits to''(plain text). - The template uses the
{tracking_number}placeholder (buildTrackingUrl, line 34-38); a URL without it falls back tourl + encodedTrackingNumber.isHttpUrl(line 40-47) re-validates http/https at resolve time.
Both consumers load the map once per request and call resolveTrackingUrl per row:
- list view —
listActionHandler.ts:43loads the map,:50setstrackingNumberUrlon eachBaseRecord - XLS export —
workOrderShipmentLatency.service.ts:249(bufferedmakeExportExcel) and:336(streaming) load the map;transformRecordToExportItem(:197-218) setsfinalRecord.trackingHyperlink.hyperlink
Adding/renaming a carrier is now an AdminJS edit (System Administration → Carrier), not a code change — no enum, no availableValues, no spec edits. The full Carrier table mechanics (writers/readers/cache/revive/XSS) are documented in carrier.md.
The carrier filter changed with it: _makeWhereClause now matches on the normalized column — lower(trim(cs."carrierService")) IN (...) (workOrderShipmentLatency.model.ts:225) — and the carrierService property's hardcoded availableValues/MultiSelectFilter were dropped in favour of Components.CarrierMultiSelectFilter (workOrderShipmentLatency.ts:160). That component fetches real Fulfil spellings (e.g. 'FedEx V2') from the Carrier resource's carrierOptions action, so the filter options are driven by the admin-managed table, not a code enum.
Who writes CustomerShipment.carrierService: two live paths plus a script, and they do not agree on vocabulary — the column holds free-form Fulfil names, which is exactly why the Carrier table keys on normalized free text rather than an enum:
- The n8n shipment-create payload passes the carrier through verbatim (
shipment.service.ts→carrierService: shpmnt.carrierService, Zod-capped at 30 chars inschemas/shipment/index.ts) — this is how non-enum values likeFedEx/UPS/USPSland in the column. - The print-label capture path (
shippinglabel.service.ts:185-189) resolves the carrier as: empty/null tracking →null;YT-prefixed tracking →CarrierService.YunExpress; otherwise →res.data[0]['carrier.rec_name'] ?? null(the actual Fulfil carrier name, since INFRA-487). So a shipment's carrier is whatever Fulfil reports at label time, which can overwrite the n8n-sent value. scripts/backfill-shipment-carrier.tsapplies the older prefix heuristic retroactively (YT%→YunExpress, else →Portless).
Carrier enum (src/constants/carrierServices.ts)
CarrierService is a plain string enum whose values are the exact strings stored in CustomerShipment.carrierService (VarChar(30)), not slugs — e.g. YunExpress = 'Yun Express' (with the space). Since INFRA-673 it is no longer used by the latency report or trackingLink.utils.ts — those go through the Carrier table instead. It survives in two remaining consumers: shippinglabel.service.ts:188 (the print-label YT% → YunExpress branch) and scripts/backfill-shipment-carrier.ts (which uses Object.values(CarrierService) to detect non-enum rows — so adding a member narrows that script's backfill target set). Compare against it with === on the raw column value.