Skip to main content

Ship Station page (label download / print)

The AdminJS "Ship Station" page where warehouse workers scan a work order, then download + print the shipment's shipping label.

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.

File organization​

  • Virtual resource: src/routers/admin/resources/shipStation/shipStation.ts — ShipStation virtual resource; list action renders Components.ShipStation. Hidden resource actions: searchUnique, downloadShippingLabel, markLabelAsPrinted, markLabelAsShipped.
  • Handlers (same dir): searchUniqueHandler.ts (work orders for the scanned number, include: { customerShipment: true, ... }), downloadShippingLabelHandler.ts, MarkLabelAsPrinted.ts, MarkAsShipped.ts.
  • Frontend: src/components/admin/ShipStation/ — ShipStation.tsx (page), utils.ts (all handlers + hooks), ShipStationRTSModal.tsx (imperative ref.open() modal, hand-rolled Tailwind overlay), ShipStationCanceledSKUModal.tsx (controlled isOpen/onClose props, @adminjs/design-system Modal). Tests: utils.spec.ts (pure-function tests only, e.g. judgeRecordsIsReadyToPrintLabel).
  • Locale strings: components.ShipStation.* for modal copy, buttons.* for button labels (downloadShipmentLabel = "Download Shipping Label", confirm, cancel, yes, no exist).
  1. Worker scans → searchFetch(workOrderNumber) → searchUnique action → records (work orders + customerShipment). The scanned value is persisted in the URL as ?recName= and re-searched on mount.
  2. The button is enabled only when judgeRecordsIsReadyToPrintLabel(records) — every WO is Done and customerShipment.labelStatus is Acknowledged or Printed. So reprinting a Printed shipment is already allowed today, with no warning (INFRA-677 adds one).
  3. Click → handleDownloadLabel → downloadShippingLabel action. That handler short-circuits RTS shipments (isRTS, no PDF), otherwise calls the internal /shippinglabel/v1/getShippingLabel API, fetches the label URL, returns pdfBase64. A canceledWarehouse marker means no PDF (KY2) and the page re-fetches to open the canceled-SKU modal.
  4. changeUrlIsRTS → blob URL into a hidden <iframe> → triggerUpdateStatusAndPrint → handleMarkAsPrintedLabel (skips if already printed) → iframe.contentWindow.print(). There is no separate "print page" — printing is the browser print dialog over the hidden iframe.
  5. finally → refreshWorkOrders() re-runs the search, so labelStatus in records reflects Printed after the first print.

"Already printed" signal​

Use customerShipment.labelStatus === 'Printed' (compare case-insensitively, as handleMarkAsPrintedLabel does), not labelPrintedAt. labelPrintedAt is set only on the first transition (COALESCE in ShipmentModel.markPrintedManyAndReturn) and has been deliberately left NULL for some bulk-fixed Printed rows (INFRA-626), so it's not a reliable "has been printed" flag.

labelStatus in records is only as fresh as the last searchUnique call. That is sufficient because only one ship station prints shipping labels (product assumption, INFRA-677): every print on this page is followed by refreshWorkOrders(), so there is no other writer to go stale against. Revisit if a second label-printing station is added.

RTS shipments (customerShipment.isRTS, loaded on records via searchUnique's include) never produce a PDF — downloadShippingLabel short-circuits and the page just marks Printed + opens the RTS modal. Print-related guards (e.g. INFRA-677's reprint confirmation) exclude RTS.

Testing the page locally (INFRA-677)​

  • Stale components bundle. If dist/adminjs-bundles/ exists (e.g. from running scripts/build/bundle-components.ts), the dev server serves it instead of your working tree — new components silently don't appear. npm run dev deletes it first; if you start tsx src/index.ts directly, rm -rf dist/adminjs-bundles yourself. Confirm with curl -s localhost:3000/admin/frontend/assets/components.bundle.js | grep -c <newSymbol>.
  • Factory scoping. Search results are filtered by the sidebar's Current Factory; "No records" for a known work order usually means the wrong factory is selected.
  • No external side effects. Label download goes through the internal shipping-label API to Fulfil; mock downloadShippingLabel in the browser (Playwright context.route) with a tiny base64 PDF. markLabelAsPrinted is safe to let through locally with the test-mode feature flag on (it writes Printed to the local DB and skips fulfilShipmentDone); the flag is read at startup, so set it before launching the server.
  • Headless print eats input. After the hidden iframe calls print(), headless Chromium stops delivering real mouse clicks to the page (a document-level click listener records nothing). Reload the page between print scenarios, or use a DOM el.click() — this is a harness artifact, not an app bug.
  • zh-CN can't be viewed as the dev no-login user (availableLanguages: ['en']); log in as a user whose language includes zh-CN, or check window.REDUX_STATE.locale.translations['zh-CN'] for the strings.

Fulfil hold status (on_hold) — found while grooming INFRA-718​

  • Hold is a boolean flag, not a state. On Fulfil stock.shipment.out (MCP name customer_shipment), state has no on-hold value (draft | waiting | assigned | packed | done | cancel). Hold lives in on_hold: boolean ("Displays if the shipment is on hold"), plus hold_reason (FK, often null even when on_hold=true) and on_hold_until (often null). Verified against Fulfil staging on 2026-10-05.
  • on_hold is combined with state. On staging, active shipments on factory warehouses (210, 247, 284) were on_hold=true in assigned, waiting and packed. Many state=cancel shipments are also on_hold=true, so a hold check placed in front of the label flow also blocks _tryGenerateShippingLabel's canceled-shipment → n8n replacement path. INFRA-718 does this on purpose (product decision): the check ignores state.
  • MES shipmentNumber is the Fulfil id, not the number shown in Fulfil. e.g. id 4280724 ↔ CS4280727. Pick one on purpose when putting a shipment in a user-facing message. INFRA-718's hold error uses shipmentNumber, the Fulfil internal id (product decision).
  • The hold check as built (INFRA-718). _assertShipmentNotOnHold in shippinglabel.service.ts runs before any label fetch or n8n generation, on the active shipment number (newShipmentNumber for a replaced shipment), using fulfilGetShipmentHoldState (fields=id,state,on_hold, confirmed against the sandbox REST API). On hold → throws 400 with data.code = SHIPPING_LABEL_ERROR_CODE.shipmentOnHold (src/constants/shippingLabelErrorCodes.ts). A failed lookup does not block: it is reported via captureShippingLabelHoldCheckFailed and the existing flow continues (product decision). The check is time-boxed so it never holds up the label call: timeout: FULFIL_HOLD_CHECK_TIMEOUT_MS (default 3000) and retryConfig: { retryCount: 0 } on that one call, instead of the client's 60s timeout with 3 retries (up to about 4 minutes if Fulfil hangs). A timeout (ECONNABORTED) takes the same fail-open path. That was verified against a server that never responds: 3030 ms, 1 request. Measured sandbox latency on 2026-10-06 was ~0.1s on a reused connection, and 0.19–1.1s (once 2.7s) on a new one, so don't go below ~2s without checking holdCheckFailed rates in Sentry first. RTS shipments never reach the check, because the handler skips them first.
  • Where the block surfaces. downloadShippingLabelHandler matches data.code and returns the shipStation.shipmentOnHold notice with options: { shipmentNumber } (the Fulfil id, by product decision). Without waiting for them, it then sends captureShippingLabelBlockedOnHold (Sentry, with the operator from context.currentAdmin) and postShipmentOnHoldAlert (Slack): one message per blocked click, with no dedupe.

Error surfacing gaps in the label flow​

  • downloadShippingLabelHandler drops the API error body. On !response.ok it always returns the generic shipStation.shippingLabelError notice, so a specific service error (message or data) never reaches the user. errorHandler does forward err.data in the JSON body, so a stable data.code can be used to tell errors apart.
  • handleDownloadLabel drops notice options. It forwards only { type, message } to addNotice. AdminJS's notice component translates with tm(notice.message, notice.resourceId, notice.options), so a {{placeholder}} message needs options forwarded through utils.ts.
  • The API hop loses the operator's identity. The admin handler calls /shippinglabel/v1/getShippingLabel with x-api-key, so inside the service requestContext.adminUser is the fake N8N user. Anything that needs the real operator (audit logs of blocked attempts) must read context.currentAdmin in the AdminJS handler, or pass the user through explicitly.
  • Locale keys for these notices are messages.shipStation.* in src/locales/{en,zh_CN}.json. A locale-only commit doesn't trigger the translate hook, so the CN string you write is the one that ships.
  • Testing trap: don't put a new pre-check on fulfilGetShipmentState. shippinglabel.service.spec.ts sets up fulfilGetShipmentState once in beforeEach, and several tests queue mockResolvedValueOnce states (cancel, done, …) for _tryGenerateShippingLabel, _handleEmptyShipping and _checkFulfilSkuExist to use in order. If a new check calls that same function first, it uses up the first queued response, and every scenario after it gets the wrong state. Give a pre-check its own client function (INFRA-718 uses fulfilGetShipmentHoldState) so the existing call order doesn't change.

Local end-to-end test recipe (INFRA-718)​

What worked for testing the on-hold block locally, with no sandbox writes and no local DB writes:

  • Mock Fulfil by overriding FULFIL_HOST. envConfig calls dotenv.config() without override, so a shell variable wins: FULFIL_HOST=http://localhost:4010 npm run dev. Put a small Node HTTP server on :4010 that fakes the hold-state GET (fields=id,state,on_hold), get_shipping_labels (it returns a URL to a tiny PDF that the same server hosts), /done and /pack. It passes other GETs and search_read through to the sandbox, and refuses everything else. Add a /__mock/hold?id=&on=1|0 toggle to test "hold applied after page load" without refreshing the page.
  • The local DB's work orders are all Open, which keeps the Print button disabled. Don't write to the DB (the auto-mode permission classifier denies psql UPDATEs anyway). Instead, context.route the ShipStation/actions/searchUnique response so that workOrderStatus: 'Open' reads 'Done', and route markLabelAsPrinted to a canned success notice. The hold check runs on the server, so the block is tested for real.
  • The dev login is engineering@birdygrey.com, with current factory Aolong. Local Aolong shipments with one work order that worked for testing: CS2874347 (PO123456-S0001-0001) and CS2874355 (PO20265-S0001-0001).
  • Slack doesn't post locally (empty SLACK_BOT_TOKEN; see fulfil-error-alerting.md). Check the Slack payload with unit tests, and the blocked attempt in the server log line Shipment CS… (…) is on hold — Shipping Label Blocked. That line prints at INFO locally on purpose: logPinoSentry lowers local pino to info, and Sentry still receives the event at warn.
  • Pre-existing double toast, fixed in INFRA-718. handleDownloadLabel used to carry on after an error notice and add a second PDF data not found toast, for every handler error, not just on-hold. It now returns after an error notice.