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 byadminBgcFactoryOpsAuth. 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)
getTrackingUrl(trackingNumber, carrierService) (src/services/workOrderShipmentLatency/trackingLink.utils.ts) is the single source of truth for tracking URLs. It dispatches on the carrier name, not on tracking-number format — deliberate, because only Yun Express has a recognizable prefix (YT); Portless and FedEx numbers are both bare digit strings and can't be told apart by shape. Empty/null tracking number short-circuits to ''; unknown/null carrier falls through to the USPS URL.
Both consumers already call it, so a new carrier branch reaches both surfaces with no further wiring:
- list view —
listActionHandler.ts:46setstrackingNumberUrlon eachBaseRecord - XLS export —
workOrderShipmentLatency.service.ts:268setsfinalRecord.trackingHyperlink.hyperlink
Adding a carrier is four coordinated edits: (1) enum member in src/constants/carrierServices.ts, (2) URL-prefix constant + if branch in getTrackingUrl before the USPS fallback, (3) an availableValues entry on the carrierService property in workOrderShipmentLatency.ts so the multi-select filter offers it, (4) specs in trackingLink.utils.spec.ts + workOrderShipmentLatency.service.spec.ts. The filter's parse path (utils.ts:43) and SQL clause (workOrderShipmentLatency.model.ts:203) are value-agnostic — they split the comma-joined string and IN-match it, so they need no change.
Who writes carrierService: two paths, and they do not agree on vocabulary. The n8n shipment-create payload passes the carrier through verbatim (shipment.service.ts → carrierService: shpmnt.carrierService, Zod-capped at 30 chars in schemas/shipment/index.ts) — this is how non-enum values like FedEx/UPS/USPS land in the column. The print-label capture path (shippinglabel.service.ts:181-188) instead derives the carrier from the tracking-number prefix and can only ever write YunExpress or Portless. So a shipment's carrier can be overwritten to Portless at print time even if n8n originally sent something else. scripts/backfill-shipment-carrier.ts applies that same prefix heuristic retroactively. Consequence: adding an enum member does not make that value appear in the data — it only teaches the report how to render rows that already carry it.
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). Compare against it with === on the raw column value. It is shared well beyond the latency report: shippinglabel.service.ts, 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), and several specs.