SOP: Pick Bin Labels
Document ID: WMS-PICK-002 Version: 1.0 Effective date: 04/30/2026 Owner: Warehouse Operations Manager Next review: [six months from effective date] Applies to: Pickers and packers handling pick bins; managers diagnosing label-print failures
1. Purpose
This procedure governs how pick bins (the totes / boxes / bags that staged-picked goods sit in between the floor and the pack station) get their barcodes generated, printed, and reprinted. A pick bin without a scannable barcode is a packing-station problem: every downstream operation that needs to scan the bin (pack-station bin lookup, pack item verify, stage-to-ship handoff) falls back to manual lookup, which is slow and error-prone.
The system has three different ZPL implementations for bin labels (one in the API library, one in the worker, one in the client) and a browser-print fallback for when the Zebra ZQ511 isn't paired. This SOP documents which one fires when, why the divergence exists, and how to recover from each failure mode.
2. Scope
In scope:
- Single-bin auto-creation at pick-task completion (per WMS-PICK-001 §4.7)
- Multi-bin initialization at the start of a multi-package order (
POST /fulfillment/:orderId/bins/init) - Auto-print on bin creation (the SSE-driven path that fires
printBinLabelon the connected ZQ511) - The Print All CTA in the
BinLabelStepUI block (manual mass-print) - Browser-print fallback (per-bin window-open + JsBarcode SVG)
- Reprint after damage / loss
- Bin lifecycle status:
PICKING→STAGED→PACKING→COMPLETED(orCANCELLED)
Out of scope:
- The pick session itself — see WMS-PICK-001
- Receiving labels — see WMS-REC-005 (different format, different generator, different endpoint)
- Location labels — see WMS-INV-004 §4.4 (different format too)
- Shipping carrier labels — see WMS-SHIP-001 (carrier API, not ZPL we author)
- Pack-station bin scan-and-verify flow — see WMS-PACK-001
3. Roles & permissions
API enforcement: all bin-label endpoints (
POST /fulfillment/:orderId/bins/init, the worker-drivenenqueuePrintBinLabel, the client-sidezebraPrint.printBinLabel) are auth only — no role gate. The worker job runs in-process; no auth context.
| Role | View bin labels | Trigger Print All | Browser-print fallback | Reprint a single bin |
|---|---|---|---|---|
| READONLY | ✓ | — | — | — |
| STAFF | ✓ | ✓ | ✓ | ✓ |
| MANAGER | ✓ | ✓ | ✓ | ✓ |
| ADMIN | ✓ | ✓ | ✓ | ✓ |
| SUPER_ADMIN | ✓ | ✓ | ✓ | ✓ |
Operational expectations:
- Pickers print bin labels themselves whenever the auto-print fails — no manager hand-off needed for a routine reprint.
- Browser-print fallback is the documented backup when the ZQ511 isn't paired. It works on any device with a browser and produces a 4"×6" printable label that any office laser printer can handle.
- A bin operating without a barcode is a temporary bypass, not a long-term state. Tag the bin physically with the order number on a sticky note and resolve the print failure before the next shift starts.
4. Procedures
4.1 The bin lifecycle and when labels matter
A pick bin transitions through these statuses:
| Status | When | What needs the label |
|---|---|---|
PICKING | Bin created during a multi-package init (per §4.3) before picking starts | The picker scans the bin barcode to switch active bin during pick session |
STAGED | Auto-set when single-bin order finishes picking (per picking.repo.ts:467); also when multi-bin order finishes via stageBins | Pack station scans the bin to start packing |
PACKING | Set when the bin is scanned at the pack station (per WMS-PACK-001) | Pack flow reads the contents from the bin |
COMPLETED | Pack finishes, contents are sealed in a shipping container | Audit only |
CANCELLED | Order cancelled with bin in flight, or bin merged into another | Audit only |
Implication: a bin label that doesn't print at the right time blocks the next operational step. A bin in PICKING without a label means a multi-bin picker can't switch active bin (the page expects a scan match). A bin in STAGED without a label means the pack station can't pull the order via scan and has to manually search.
4.2 Single-bin auto-creation (the common case)
Use when: Most orders. Default path. You're not actively initializing a multi-package pick — you're just picking through a single-line order or a small order where everything fits in one tote.
What happens:
- Picker walks the pick session per WMS-PICK-001 §4.3.
- The last
confirmPickItemcall detects task complete (perpicking.service.ts:286-305). - The service calls
createPickBin(orderId, pickTaskId, userId)insideconfirmPickItem. The bin is created withstatus: STAGED,binNumberfromgenerateBinNumber(),barcodederived frombinNumber. - The service emits a
PICKLIST_COMPLETEDevent with the bin info (id, binNumber, barcode) in the payload. - The auto-print path fires from the SSE event handler in the fulfillment page (per
fulfillment/[id].tsx:540-563):- Listens for
pickbin:label_printedSSE events. - On match, calls
zebraPrint.printBinLabel({...})with the bin's data. - Failure is silent — if Zebra isn't connected, the catch block does nothing. The user sees the bin appear without a label and can manually print from the
BinLabelStepUI block per §4.5.
- Listens for
What writes the SSE event the page is listening for:
- The
processPrintLabelworker job (perpick-bin.processor.ts:40-98) emits the SSE event but does not actually print on a network printer. Theif (printerId) { ... await sendToPrinter(...); ... }block is commented out — the printer integration is a stub. The worker stores ZPL onPickBin.labelZpland publishes the event; that's it. - So the actual physical print happens client-side, when the page picks up the SSE event and calls the Capacitor
ZebraPrintplugin on the connected device.
⚠ The worker's "print" is misleading. The worker writes
labelZplto the bin row, publishes thepickbin:label_printedevent, and (ifprinterIdis passed) markslabelPrintedAt. No actual printing happens through the worker today. All physical prints route through the client-side Capacitor plugin. If a manager checkslabelPrintedAtand sees a value, that's not proof a label printed — it's proof the worker ran with aprinterIdargument. The actual evidence is "the picker has a label in their hand."
⚠ The SSE-driven auto-print is silent on failure. If the picker's TC22 isn't paired with a printer, the auto-print attempt swallows the error and the bin shows up without a label. The picker has to notice and reprint manually via §4.5. If the picker doesn't notice, the bin lands at the pack station without a barcode.
4.3 Multi-package bin initialization
Use when: An order is large enough to require multiple boxes — the box-recommendation algorithm (per WMS-PACK-001 §5) returned 2+ packages, or the picker manually initialized multiple bins for a heavy order.
Steps (target — UI flow):
- From the pick session at
/fulfillment/:orderId, before scanning starts, the page detectspickBins.length === 0andboxRecommendation.packages.length > 1. - The page calls
POST /fulfillment/:orderId/bins/initwith{ pickTaskId, boxes: [...] }(perfulfillment.routes.ts:445+route, line 476-489 enqueues label print). - The route runs
service.initPickBins(orderId, pickTaskId, boxes, userId)to create onePickBinper package. Each bin gets:binNumber(sequential —BIN-{orderNumber}-1,-2, etc., or howevergenerateBinNumberformats them)barcode(auto-generated, unique constraint)sequence: 1, 2, 3...status: PICKING(note: notSTAGEDyet — these are pre-pick init bins)boxIdandboxLabelfrom the box recommendation (e.g., "MED-12x9x4")
- For each created bin, the route enqueues a
printBinLabeljob (perfulfillment.routes.ts:476-489):enqueuePrintBinLabel({ binId, binNumber, barcode, orderId, orderNumber, sequence, totalBins }) - The worker processes each (per §4.2's worker behavior).
- The fulfillment page renders the
BinLabelStepblock which is the manual print UI per §4.5.
⚠ Multi-bin init does not auto-print physically. Same as §4.2 — the worker writes ZPL to the bin row and emits an SSE event, but the physical print happens via the client-side Capacitor plugin in the SSE handler. If the printer isn't connected, the bins are created and the picker has to use Print All (§4.5) before starting picks.
4.4 Reading the BinLabelStep UI
Use when: Looking at the fulfillment page mid-flow — the BinLabelStep block appears in two places:
| Mode | Location in page | Renders when |
|---|---|---|
pre-pick | Before picking starts (multi-bin orders) | pickBins.length > 1 && step === 'awaiting_pick' |
pre-pack | Between pick complete and pack start | After taskComplete: true, before pack begins |
What the block shows:
- For each bin: bin number, sequence (
Box X of Y), barcode, item count, total quantity, optionalboxLabel(e.g., "SMALL-6x6x6"). - A Print All button at the bottom calling
zebraPrint.printAllBinLabels(bins[]). - A per-bin Browser Print secondary CTA — opens a new window, renders an HTML 4"×6" label with a JsBarcode SVG, auto-triggers
window.print()(per[id].tsx:3364-3402). - A
printedSet state tracks which bins have been printed in the current session — Print All marks all in one go on success.
Behavior on Print All:
- Calls
zebraPrint.printAllBinLabels(bins)perzebra-print.ts:431-460. - Important: all bin ZPL strings are concatenated and sent in a single
sendZPLcall (per the comment atzebra-print.ts:425-429— sending separatesendZPLcalls risks the Bluetooth socket resetting between prints, silently dropping subsequent labels). - On success:
printedstate updates to mark all bins; the Start Packing CTA enables. - On failure: an
alert()dialog shows the error; the user can retry or use browser-print.
4.5 Manual reprint of a single bin (Zebra path)
Use when: Auto-print failed (Zebra not paired), label was damaged, label was lost, the printer ran out of paper mid-batch.
Steps:
- From
/fulfillment/:orderId, scroll to theBinLabelStepblock. - Find the bin in question.
- Tap Print (the per-bin button — exact label depends on the UI build, but every bin row in the block has its own print CTA in addition to the Print All button at the bottom).
- The page calls
zebraPrint.printBinLabel({ binNumber, barcode, orderNumber, itemCount, totalQuantity, sequence, totalBins, copies: 1, boxLabel }). - The Capacitor plugin sends the ZPL to the connected ZQ511.
- On success, the bin's row shows a green check; the
printedSet state advances.
Common failures: identical to WMS-REC-005 §4.2 common failures — printer not paired, web platform (no native plugin), printer out of paper, BT socket reset. Resolution paths there apply.
4.6 Browser-print fallback
Use when: The Zebra ZQ511 isn't connected and you can't pair it (different shift, BT broken, printer in another zone). You have a regular office printer.
Steps:
- From
BinLabelStep, find the bin you want. - Tap the per-bin secondary CTA (browser-print).
- A new browser window opens with an HTML representation of the label:
- Header:
Box {sequence} of {total} - Bin number large
- Optional
boxLabel - Order number
- JsBarcode-rendered Code128 barcode
- Item list (SKU + quantity)
- Header:
- The window auto-triggers
window.print()on load. Print to any office printer.
The browser-print label is not the same as the Zebra label — it's full-letter or 4"×6" depending on browser settings, has more detail (full item list rather than just a count), uses a different layout, and is fundamentally a different artifact. It scans the same barcode (the bin's barcode field), but the physical label looks different. Pack station staff should treat both as valid; the scan is what matters.
⚠ The browser-print page assumes a JsBarcode CDN reachable from the device. It loads
https://cdn.jsdelivr.net/npm/jsbarcode@3.11.0/...from the print window. If your network blocks external CDNs (offline warehouse, locked-down infra), the print window will render without a barcode. Test once before relying on it as a fallback.
5. Reference
5.1 Three ZPL implementations — what each looks like
The codebase has three different ZPL templates for bin labels:
| Implementation | Where | Format | Used by |
|---|---|---|---|
generateBinLabel | apps/api/src/lib/zpl.ts:272-289 | 2"×1" at 203 DPI (matches receiving and location labels). Bin number in 34pt font, Code128 barcode 45 dots tall, order + item-count footer. | Not called by any current code path. Defined but unused. |
| Worker inline ZPL | apps/worker/src/processors/pick-bin.processor.ts:55-65 | 4"×6"-style template (different ^FO coordinates, larger ^A0N,50,50 font, 100-dot tall barcode). Includes timestamp footer. | The processPrintLabel worker job. Stored on PickBin.labelZpl but never sent to a physical printer (the sendToPrinter call is commented out). |
Client buildBinLabelZpl | apps/web/src/lib/zebra-print.ts:360-392 | 2"×1" 203 DPI (the "real" label that prints on a ZQ511). 28pt bin number, optional "Box X of Y" header for multi-bin, 62-dot Code128, footer with order + barcode text. | The actual printed label. Called by printBinLabel, printAllBinLabels, and the SSE-driven auto-print handler. |
The divergence is real. The label your picker actually holds in their hand was generated by buildBinLabelZpl. The string stored on PickBin.labelZpl was generated by the worker template — different layout, different size assumptions. They don't match. If a future feature ships ZPL from the database (say, "reprint a year-old bin's label"), the user would get the worker's 4"×6" template, not the 2"×1" they'd expect. See §8.
5.2 The Code128 barcode encoding choice
All three implementations use Code128. The barcode is the bin's barcode field, which is derived from binNumber at creation time. Code128 is a good fit here: the bin numbers are short (typically BIN-{orderNumber}-{seq} or similar), Code128 packs them densely, and Zebra readers handle it natively.
5.3 Pick bin field reference
Selected fields from the PickBin model (per orders.prisma:228-260):
| Field | Purpose |
|---|---|
binNumber | Human-readable identifier, globally unique |
barcode | Scannable identifier, globally unique. Same value as binNumber in many cases. |
orderId | FK to Order |
pickTaskId | FK to the picking WorkTask, nullable |
status | PICKING / STAGED / PACKING / COMPLETED / CANCELLED |
sequence | 1..n in multi-bin orders. Defaults to 1. |
boxId, boxLabel | Optional — links to a recommended box and its display name (e.g., "MED-12x9x4") |
length, width, height, dimensionUnit | Box dimensions if a recommendation was applied |
actualWeight, weightUnit | Captured at pack time |
labelZpl | The worker-generated ZPL (per §5.1 — note divergence) |
labelPrintedAt | Set by the worker if a printerId was passed; does not reliably indicate physical print |
pickedBy, pickedAt | Picker user + timestamp |
packedBy, packedAt | (See WMS-PACK-001) |
5.4 Related SOPs
- WMS-PICK-001 §4.7 — Pick bin auto-creation at task completion (the upstream)
- WMS-PICK-003 — Short-pick recovery (sometimes triggers bin reprint if disposition changes)
- WMS-PACK-001 — Packing (the immediate downstream consumer of the bin label)
- WMS-REC-005 §4.x — ZQ511 pairing and Capacitor printer setup (shared with this SOP)
- WMS-INV-004 §4.4 — Location labels (reuses the 2"×1" 203 DPI format conventions)
6. Audit & compliance
Bin label generation has partial audit data:
PickBin.labelZpl— stored once by the worker. Never updated. No history.PickBin.labelPrintedAt— set by the worker if aprinterIdwas passed. Not reliable evidence of physical print (per §4.2 callout).pickbin:label_printedSSE event — fires on every worker run. Not persisted to a database; lives only in the in-memory pubsub stream.- Browser-print — leaves no audit trail at all. The print window opens, the user prints, the WMS knows nothing.
zebraPrint.printBinLabelcalls — no audit row written. Failures are logged to console; successes are tracked only in the page's localprintedSet state, which resets on page refresh.
For high-stakes contexts (e.g., investigating a label-mismatch incident where a wrong order was packed): there is no system-of-record query to determine "which printer printed this bin's label and when." The labelPrintedAt is the closest proxy and it's unreliable.
Manager weekly review:
- Pull
PickBin WHERE labelZpl IS NULL AND status != 'CANCELLED'. Bins without a stored ZPL got skipped by the worker — investigate. Either the worker isn't running or the route isn't enqueueing. - Pull
PickBin WHERE labelPrintedAt IS NULL AND status IN ('STAGED', 'PACKING') AND createdAt < now() - 1h. Bins that have been staged for more than an hour without a print mark — possibly missed prints, possibly the missing-printerIdissue.
7. Troubleshooting
| Symptom | Cause | Resolution |
|---|---|---|
| Picker finished a task but no label printed | Auto-print path failed silently — Zebra not paired or BT dropped | Open the BinLabelStep block on the fulfillment page, tap Print on the bin. If still failing, browser-print fallback (§4.6). |
Print All button reports Print failed | Bluetooth socket reset, printer out of paper, printer paired to wrong device | Power-cycle the printer. Re-pair per WMS-REC-005 §4.1. Retry. If repeated, browser-print fallback. |
| Bin label is missing the "Box X of Y" header on what should be a multi-bin order | Multi-bin init was not run before picking — only one bin was auto-created at pick complete | The picker is operating on a single-bin assumption. If the order really needs multiple boxes, finish the current bin and use Add Bin in the UI to create more before pack. The first bin won't show the multi-bin header retroactively unless the worker is re-run for it. |
labelPrintedAt shows a value but the label never physically printed | Per §4.2 callout, the worker marks labelPrintedAt when called with printerId regardless of whether print actually succeeded | Don't rely on this field as proof. Confirm physically. Reprint via §4.5 if missing. |
| Two different physical labels exist for the same bin | One was the auto-print, one was a manual reprint with stale data (e.g., bin contents changed between prints) | The barcode is the same on both — they scan to the same bin. Trash the older one. Make sure pack-station staff use the most recent label for visual reference. |
| Browser-print window opens but shows no barcode | JsBarcode CDN unreachable from the device's network | Switch device to an unrestricted network, or install a self-hosted barcode generator. See §8. |
| Bin barcode prints but doesn't scan at the pack station | Code128 module width too small for the data, or printer toner low producing a faint print | Reprint with a darker setting on the printer if possible. If the issue persists for one specific bin, the barcode field may contain unsanitized characters — query the row and check. |
| Reprinted a bin label and now I have two stickers | Expected — the system doesn't track sticker count, only print events | Place the new label over the old one, or remove the old one. The barcode is identical so scans don't care. |
| Worker logs show prints succeeding but pickers report no labels | Worker has no real sendToPrinter integration — physical print only happens client-side | Check the SSE stream is reaching the picker's device. If the page is closed or off-screen, the auto-print event has nothing to handle. The picker must reopen and use Print All. |
Reprint button doesn't exist on the bin | The detail page may render the per-bin button only in some modes — check BinLabelStep mode | Use Print All (bins[] array). Or browser-print fallback. |
8. Escalation
- Consolidate the three ZPL implementations. This is the cleanest engineering ticket in this SOP. Either (a) delete the unused
generateBinLabelfromzpl.tsand the worker inline template, leaving only the client-sidebuildBinLabelZpl; or (b) move the canonical ZPL to a shared package and have all three callers reference it. The current divergence is technical debt — labels stored onPickBin.labelZplwill never look like what gets physically printed. Fixing this is one PR, no ops impact. - Implement actual server-side printing in the worker. Today the worker is a stub —
sendToPrinteris commented out. If you ever want a managed central printer at the pack station that prints labels automatically (without depending on the picker's TC22 being paired), the worker is where it goes. Decide first: do you need this? Most warehouses don't — the per-device Capacitor plugin works fine. But if you do, this is where to wire it. - Stop falsely setting
labelPrintedAt. The worker should not mark the field unless print actually succeeded. Today it marks based on whether aprinterIdwas passed, which doesn't mean print happened. Either implement real printing (above) and tielabelPrintedAtto that result, or stop setting the field. - Browser-print without external CDN. The fallback assumes JsBarcode loads from jsdelivr. Bundle it locally so the fallback works in network-restricted environments.
- Multi-bin auto-add when an order grows post-pick. Today, if a picker realizes mid-flow that the goods won't fit in the originally-allocated bins, they have to manually initialize a new bin and reprint. A "shoot, I need another bin" CTA that creates + prints + integrates seamlessly would close that ergonomic gap.
- Persistent bin barcode collisions (rare): the unique constraint on
barcodeshould prevent. If it happens, escalate to IT — likely agenerateBinNumberrace or schema bug. - Audit-grade print provenance: if you need to know "which printer printed this label, when, by whom" — that requires a
bin_label_printstable with rows per print attempt, status, device, timestamp. Engineering ticket, low priority unless regulatory requires it.
9. Revision history
| Version | Date | Author | Changes |
|---|---|---|---|
| 1.0 | [DATE] | [NAME] | Initial release. Documents the pick-bin label flow grounded in apps/api/src/routes/fulfillment.routes.ts:445-490 (init-bins + enqueue), apps/worker/src/processors/pick-bin.processor.ts:40-98 (worker, with the sendToPrinter stub flagged), apps/web/src/lib/zebra-print.ts:360-460 (client-side ZPL build + print + batch-print), apps/web/src/pages/fulfillment/[id].tsx:540-563, 3322-3402 (auto-print SSE handler + BinLabelStep UI + browser-print fallback), apps/api/src/lib/zpl.ts:272-289 (the unused generateBinLabel), and packages/db/prisma/schema/orders.prisma:228-260, 369-375 (PickBin model + status enum). Documents three real findings: (a) three different ZPL implementations exist — the client-side buildBinLabelZpl is the only one that physically prints today; the worker template stores divergent ZPL on PickBin.labelZpl; the API library generateBinLabel is unused. (b) The worker's sendToPrinter is commented out — physical printing happens only client-side via the Capacitor plugin. The worker emits SSE events and stores ZPL but does not print. (c) labelPrintedAt is misleading — it's set when the worker is called with a printerId, regardless of whether print actually succeeded. Documents the silent-failure auto-print path (per [id].tsx:560-562) and the browser-print fallback (per [id].tsx:3364-3402) including its JsBarcode-CDN dependency. Cross-references WMS-PICK-001 (upstream — bin auto-creation), WMS-PACK-001 (downstream — bin scan at pack), WMS-REC-005 (shared ZQ511/Capacitor pairing setup). |