Skip to main content

Hangtag Printing (PDF + ZPL)

Hangtag PDF layout, ZPL / Zebra Browser Print path, CompleteWorkOrder frontend pattern.

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.

Hangtag PDF Generation

End-to-end flow for the Complete Work Order toolbar's "Download Hang Tag" feature:

  • Virtual resource: src/routers/admin/resources/completeWorkOrder.tsisVirtual: true, name: 'CompleteWorkOrder', no DB model. Exposes list (custom React component) plus four invisible resource actions: markWorkOrderDone, searchUnique, searchHangTag, downloadHangTagPdf.
  • Action handlers: src/routers/admin/resources/utils.tssearchHangTagHandler calls workorderService.makeHangTagData(workOrderNumber); downloadHangTagPdfHandler builds the PDF inline with a dynamic import('jspdf'), converts the SVG barcode to PNG via sharp once, and reuses the PNG data URL across both cells of a double-column print. Returns { pdfBase64, fileName, mimeType }.
  • Data assembly: workorderService.makeHangTagData (src/services/workorder/workorder.service.ts) — single-record output (one work order → one hangtag). Loads via workOrderModel.findWithRelationsById which already includes factory, purchaseOrder, customerShipment.purchaseOrder, style, color, size. Throws makeNotFoundError if missing. Returns factoryCode: string | null and doubleColumnHangtag: boolean alongside the label fields, where doubleColumnHangtag is FactoryConfigurationService.isDoubleColumnHangtag(factoryCode) resolved — no Milly factory-identity check; the per-factory config row is the sole switch (symmetric with the ZPL controller's consumption of the same key).
  • Per-cell layout (used in both branches): cell geometry is single-sourced via the HANG_TAG_CELL_WIDTH_MM = 40, HANG_TAG_CELL_HEIGHT_MM = 30, HANG_TAG_CELL_GAP_MM = 2, HANG_TAG_CELL_PADDING_MM = 2, and HANG_TAG_TEXT_WIDTH_MM = 36 constants — page format and the right-cell offsetX are derived from these, no magic numbers at the call site. Text lines (Style/Color/Size/PO/SKU) at x = offsetX + 2 with y stepping by HANG_TAG_LINE_STEP_MM = 2.6 mm from y = 3; barcode image at (offsetX + 2, 14.5 + verticalShift) sized 36 × 10 mm; SKU repeated bold/centered at (offsetX + 20, 26.7 + verticalShift). verticalShift = HANG_TAG_LINE_STEP_MM × (styleLines.length − 1) pushes the barcode and bold SKU down when Style wraps so they don't collide with the text block. Extracted as a local helper drawHangTagCell(doc, hangTagData, img, offsetX) so the single- and double-column branches call identical drawing code per cell.
  • Style-name wrapping & clamp: long product names (e.g. "Emerald Cut Stacked Stud Earrings") wrap via doc.splitTextToSize(text, HANG_TAG_TEXT_WIDTH_MM) inside the wrapStyleName helper. Clamped to HANG_TAG_MAX_STYLE_LINES = 2 with a trailing '…' because a 3-line wrap pushes the bold-SKU baseline below the 30 mm cell (font-6 + line-step 2.6 mm × 2 extra lines = baseline y ≈ 31.9). Operators see a clipped name with an ellipsis; the bold SKU never silently overflows the bottom of the label. Only the Style name wraps — Color/Size/PO/SKU are bounded enough not to need it.
  • Single vs double column: gated by hangTagData.doubleColumnHangtag (read off the service result, never off request.payload — clients can't override). falsenew jsPDF({ format: [HANG_TAG_CELL_WIDTH_MM, HANG_TAG_CELL_HEIGHT_MM] }) + one cell at offsetX = 0. truenew jsPDF({ format: [HANG_TAG_CELL_WIDTH_MM × 2 + HANG_TAG_CELL_GAP_MM, HANG_TAG_CELL_HEIGHT_MM] }) + two cells at offsetX = 0 and offsetX = HANG_TAG_CELL_WIDTH_MM + HANG_TAG_CELL_GAP_MM. Same SKU printed twice side-by-side; no second WO, no different data per cell. Output is not byte-identical to the pre-PR single-column behavior — the barcode/bold-SKU y baselines were shifted up 0.5 mm (15 → 14.5, 27.2 → 26.7) to leave clearance for 2-line Style wraps. The committed snapshot reflects the new values.
  • Format precedence vs ZPL: the PDF double-column branch only matters when hangtag-zpl-format = false for the factory. When ZPL is enabled, the handler getHangTagZpl runs first and the PDF path is bypassed entirely (the operator's click never reaches downloadHangTagPdfHandler). The two configs are orthogonal: hangtag-zpl-format picks the pipeline (PDF vs ZPL); double-column-hangtag picks the layout (single vs double) within whichever pipeline runs.
  • Handler spec pattern (utils.spec.ts): uses real jspdf + real sharp with vi.useFakeTimers() + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')) so jsPDF's embedded CreationDate is stable and the resulting pdfBase64.length is byte-deterministic. A toMatchSnapshot() on the single-column length guards against accidental drift on the 40×30 layout; the double-column test asserts only that its length is strictly greater than the single-column length so a jsPDF version bump (which shifts both lengths together) doesn't churn the snapshot. The skuBarcode fixture is a base64-encoded SVG string (matching what barcodeService.genBarcode returns), not a raw PNG buffer — sharp transcodes SVG → PNG inside the handler.

ZPL Print (Browser Print) Integration

ZPL hangtag printing is folded into the existing Complete Work Order → Download Hang Tag button rather than living on its own page. When the operator clicks the button, handleDownloadHangTag in useCompleteWorkOrderActions.tsx runs a single dispatch:

  1. Call GET /api/workorder/v1/:wo/hang-tag-zpl via plain fetch({ credentials: 'include', signal }). Session cookie carries auth.
  2. 200 OK → forward data.zpl to the local Zebra Browser Print agent via sendZpl() (src/components/actions/CompleteWorkOrder/zebraPrint.ts). Modal stays closed; success notice posted.
  3. 400 with body.message matching /zpl hangtag format is not enabled/i → fall through to the legacy PDF flow: open HangTagModal and call the searchHangTag resource action (the original behavior).
  4. 404 / other 400 → error notice; no print, no modal.

This means the factory's hangtag-zpl-format flag is the single source of truth for the PDF-vs-ZPL split — toggling it in AdminJS swaps which path the same button executes. Within the ZPL path, the double-column-hangtag flag then selects Format A (40×30mm single column with centered barcode) vs Format B (82×30mm with same SKU duplicated side-by-side). Both flags use the same boolean-dropdown property (ConfigValueInput) in AdminJS — see the Factory Configuration section above. No separate Print Station page; no hardcoded factory identity in the controller.

Per-factory printer density (hangtag-zpl-dpmm). The ZPL template is parameterized by dpmm (one of '8' | '12' | '24' — 203/300/600 dpi). Hardware DPI is fixed per printer model; the template scales every dot literal by dpmm / 8 so the same physical layout (Format A or B) renders correctly at any tier. Today: Aolong = '8', Milly = '24' (seeded by 20260519170000_seed_hangtag_zpl_dpmm_and_darkness). Unknown values, missing rows, or factoryCode = null fall back to 8. The dropdown allow-list lives in DPMM_FACTORY_CONFIG_KEYS + DPMM_OPTIONS in src/constants/factoryConfigurationKeys.ts; the AdminJS dispatcher renders labels of the form "8 dpmm (203 dpi)" so operators don't have to translate dpmm to dpi. Coordinate base in zplTemplate.service.ts is dots-at-8dpmm (not millimeters) — scale(N, 8) = round(N * 8 / 8) = N guarantees byte-for-byte identical 8 dpmm output, preserving the existing snapshot as a regression guard.

Per-factory printer darkness (hangtag-zpl-printer-darkness). Optional integer 0–30 mapped to ZPL ^MD (Media Darkness). When the row is missing, inactive, empty, non-integer, or out of range, the renderer omits ^MD entirely and the printer's panel-saved darkness applies. Seed migration creates the row per factory with isActive=FALSE so operators have a discoverable handle in AdminJS — flip isActive and type a value once the printer is tuned. The ^MD line is emitted between ^LL and ^CI28, once per label (header-level, not per-cell).

Print transport — Zebra Browser Print. The MES backend never talks to the printer; it just returns the ZPL string. The browser hands that string to a free Zebra-published local agent (https://127.0.0.1:9101) which forwards bytes to the printer over LAN/USB. The SDK is loaded lazily by src/components/actions/CompleteWorkOrder/zebraPrint.ts — it appends a <script src="/public/vendor/BrowserPrint-3.x.x.min.js"> tag on first call, then uses window.BrowserPrint.getDefaultDevice('printer') to pick the printer the agent has marked as default (no MES-side printer picker). Operators install the agent once per workstation.

Typed error classes in zebraPrint.ts (AgentNotRunningError, NoDefaultPrinterError, PrintFailedError) let the hook surface per-failure messages via popupModal: agent missing → install instructions; no default printer → configure-in-agent message; print failed → detail string.

SDK vendoring. The Browser Print JS SDK is not committed — operators / IT need to place BrowserPrint-3.x.x.min.js at public/vendor/. The lazy loader surfaces a missing file as AgentNotRunningError. Static files under public/ are served at /public/* via app.use('/public', express.static('public')) in src/server.ts.

Testing. useCompleteWorkOrderActions.spec.tsx stubs global.fetch per test and uses vi.mock('../zebraPrint') to control the typed-error classes and sendZpl. The legacy-fallback test asserts that when the fetch returns the ZPL disabled 400, the spec sees searchHangTag called and the modal opening; the ZPL-success test asserts that sendZpl is called with the returned zpl string and the modal stays closed.

CompleteWorkOrder Frontend Pattern

  • Entry: src/components/actions/CompleteWorkOrder/CompleteWorkOrder.tsx — orchestrates toolbar + records table + hangtag modal.
  • All state and side effects live in the useCompleteWorkOrderActions hook (hooks/useCompleteWorkOrderActions.tsx). It uses new ApiClient() from adminjs and calls apiClient.resourceAction({ resourceId, actionName, params|data, method }) for each backend action.
  • Each network call is wrapped in useAdminFetch (project-local utility) for AbortController + loading state. Errors surface via popupModal + showNoticeOnce.
  • PDF return path: response carries pdfBase64 + mimeType; helpers in src/components/actions/CompleteWorkOrder/utils/hangTagPdf.ts provide base64ToBlob, openBlobWindow, triggerPrint, revokeBlobUrlLater. Print and Download share the same downloadHangTagPdf server action — the client chooses whether to trigger window.print() or an <a download> click.
  • Toolbar (CompleteWorkOrderToolbar.tsx) is presentational; it receives all handlers as props. Adding a new button there is a pure prop-drilling change — wire the new handler in the hook, surface it on the hook's return, and pass it through CompleteWorkOrder.tsx.
  • Translations live in src/locales/{en,zh_CN}/*.json — toolbar uses the tb namespace (tb('downloadHangTag')), modal messages use tm, content/labels use tc/tp.