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.ts—isVirtual: true, name: 'CompleteWorkOrder', no DB model. Exposeslist(custom React component) plus four invisible resource actions:markWorkOrderDone,searchUnique,searchHangTag,downloadHangTagPdf. - Action handlers:
src/routers/admin/resources/utils.ts—searchHangTagHandlercallsworkorderService.makeHangTagData(workOrderNumber);downloadHangTagPdfHandlerbuilds the PDF inline with a dynamicimport('jspdf'), converts the SVG barcode to PNG viasharponce, 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 viaworkOrderModel.findWithRelationsByIdwhich already includesfactory,purchaseOrder,customerShipment.purchaseOrder,style,color,size. ThrowsmakeNotFoundErrorif missing. ReturnsfactoryCode: string | nullanddoubleColumnHangtag: booleanalongside the label fields, wheredoubleColumnHangtagisFactoryConfigurationService.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, andHANG_TAG_TEXT_WIDTH_MM = 36constants — page format and the right-celloffsetXare derived from these, no magic numbers at the call site. Text lines (Style/Color/Size/PO/SKU) atx = offsetX + 2withystepping byHANG_TAG_LINE_STEP_MM = 2.6 mmfromy = 3; barcode image at(offsetX + 2, 14.5 + verticalShift)sized36 × 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 helperdrawHangTagCell(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 thewrapStyleNamehelper. Clamped toHANG_TAG_MAX_STYLE_LINES = 2with 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 offrequest.payload— clients can't override).false→new jsPDF({ format: [HANG_TAG_CELL_WIDTH_MM, HANG_TAG_CELL_HEIGHT_MM] })+ one cell atoffsetX = 0.true→new jsPDF({ format: [HANG_TAG_CELL_WIDTH_MM × 2 + HANG_TAG_CELL_GAP_MM, HANG_TAG_CELL_HEIGHT_MM] })+ two cells atoffsetX = 0andoffsetX = 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-SKUybaselines 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 = falsefor the factory. When ZPL is enabled, the handlergetHangTagZplruns first and the PDF path is bypassed entirely (the operator's click never reachesdownloadHangTagPdfHandler). The two configs are orthogonal:hangtag-zpl-formatpicks the pipeline (PDF vs ZPL);double-column-hangtagpicks the layout (single vs double) within whichever pipeline runs. - Handler spec pattern (
utils.spec.ts): uses realjspdf+ realsharpwithvi.useFakeTimers()+vi.setSystemTime(new Date('2026-01-01T00:00:00Z'))so jsPDF's embeddedCreationDateis stable and the resultingpdfBase64.lengthis byte-deterministic. AtoMatchSnapshot()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. TheskuBarcodefixture is a base64-encoded SVG string (matching whatbarcodeService.genBarcodereturns), 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:
- Call
GET /api/workorder/v1/:wo/hang-tag-zplvia plainfetch({ credentials: 'include', signal }). Session cookie carries auth. - 200 OK → forward
data.zplto the local Zebra Browser Print agent viasendZpl()(src/components/actions/CompleteWorkOrder/zebraPrint.ts). Modal stays closed; success notice posted. - 400 with body.message matching
/zpl hangtag format is not enabled/i→ fall through to the legacy PDF flow: openHangTagModaland call thesearchHangTagresource action (the original behavior). - 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
useCompleteWorkOrderActionshook (hooks/useCompleteWorkOrderActions.tsx). It usesnew ApiClient()fromadminjsand callsapiClient.resourceAction({ resourceId, actionName, params|data, method })for each backend action. - Each network call is wrapped in
useAdminFetch(project-local utility) forAbortController+loadingstate. Errors surface viapopupModal+showNoticeOnce. - PDF return path: response carries
pdfBase64+mimeType; helpers insrc/components/actions/CompleteWorkOrder/utils/hangTagPdf.tsprovidebase64ToBlob,openBlobWindow,triggerPrint,revokeBlobUrlLater. Print and Download share the samedownloadHangTagPdfserver action — the client chooses whether to triggerwindow.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 throughCompleteWorkOrder.tsx. - Translations live in
src/locales/{en,zh_CN}/*.json— toolbar uses thetbnamespace (tb('downloadHangTag')), modal messages usetm, content/labels usetc/tp.