Skip to main content

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 printBinLabel on the connected ZQ511)
  • The Print All CTA in the BinLabelStep UI block (manual mass-print)
  • Browser-print fallback (per-bin window-open + JsBarcode SVG)
  • Reprint after damage / loss
  • Bin lifecycle status: PICKINGSTAGEDPACKINGCOMPLETED (or CANCELLED)

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-driven enqueuePrintBinLabel, the client-side zebraPrint.printBinLabel) are auth only — no role gate. The worker job runs in-process; no auth context.

RoleView bin labelsTrigger Print AllBrowser-print fallbackReprint 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:

StatusWhenWhat needs the label
PICKINGBin created during a multi-package init (per §4.3) before picking startsThe picker scans the bin barcode to switch active bin during pick session
STAGEDAuto-set when single-bin order finishes picking (per picking.repo.ts:467); also when multi-bin order finishes via stageBinsPack station scans the bin to start packing
PACKINGSet when the bin is scanned at the pack station (per WMS-PACK-001)Pack flow reads the contents from the bin
COMPLETEDPack finishes, contents are sealed in a shipping containerAudit only
CANCELLEDOrder cancelled with bin in flight, or bin merged into anotherAudit 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:

  1. Picker walks the pick session per WMS-PICK-001 §4.3.
  2. The last confirmPickItem call detects task complete (per picking.service.ts:286-305).
  3. The service calls createPickBin(orderId, pickTaskId, userId) inside confirmPickItem. The bin is created with status: STAGED, binNumber from generateBinNumber(), barcode derived from binNumber.
  4. The service emits a PICKLIST_COMPLETED event with the bin info (id, binNumber, barcode) in the payload.
  5. The auto-print path fires from the SSE event handler in the fulfillment page (per fulfillment/[id].tsx:540-563):
    • Listens for pickbin:label_printed SSE 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 BinLabelStep UI block per §4.5.

What writes the SSE event the page is listening for:

  • The processPrintLabel worker job (per pick-bin.processor.ts:40-98) emits the SSE event but does not actually print on a network printer. The if (printerId) { ... await sendToPrinter(...); ... } block is commented out — the printer integration is a stub. The worker stores ZPL on PickBin.labelZpl and 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 ZebraPrint plugin on the connected device.

⚠ The worker's "print" is misleading. The worker writes labelZpl to the bin row, publishes the pickbin:label_printed event, and (if printerId is passed) marks labelPrintedAt. No actual printing happens through the worker today. All physical prints route through the client-side Capacitor plugin. If a manager checks labelPrintedAt and sees a value, that's not proof a label printed — it's proof the worker ran with a printerId argument. 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):

  1. From the pick session at /fulfillment/:orderId, before scanning starts, the page detects pickBins.length === 0 and boxRecommendation.packages.length > 1.
  2. The page calls POST /fulfillment/:orderId/bins/init with { pickTaskId, boxes: [...] } (per fulfillment.routes.ts:445+ route, line 476-489 enqueues label print).
  3. The route runs service.initPickBins(orderId, pickTaskId, boxes, userId) to create one PickBin per package. Each bin gets:
    • binNumber (sequential — BIN-{orderNumber}-1, -2, etc., or however generateBinNumber formats them)
    • barcode (auto-generated, unique constraint)
    • sequence: 1, 2, 3...
    • status: PICKING (note: not STAGED yet — these are pre-pick init bins)
    • boxId and boxLabel from the box recommendation (e.g., "MED-12x9x4")
  4. For each created bin, the route enqueues a printBinLabel job (per fulfillment.routes.ts:476-489):
    enqueuePrintBinLabel({ binId, binNumber, barcode, orderId, orderNumber, sequence, totalBins })
  5. The worker processes each (per §4.2's worker behavior).
  6. The fulfillment page renders the BinLabelStep block 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:

ModeLocation in pageRenders when
pre-pickBefore picking starts (multi-bin orders)pickBins.length > 1 && step === 'awaiting_pick'
pre-packBetween pick complete and pack startAfter taskComplete: true, before pack begins

What the block shows:

  • For each bin: bin number, sequence (Box X of Y), barcode, item count, total quantity, optional boxLabel (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 printed Set state tracks which bins have been printed in the current session — Print All marks all in one go on success.

Behavior on Print All:

  1. Calls zebraPrint.printAllBinLabels(bins) per zebra-print.ts:431-460.
  2. Important: all bin ZPL strings are concatenated and sent in a single sendZPL call (per the comment at zebra-print.ts:425-429 — sending separate sendZPL calls risks the Bluetooth socket resetting between prints, silently dropping subsequent labels).
  3. On success: printed state updates to mark all bins; the Start Packing CTA enables.
  4. 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:

  1. From /fulfillment/:orderId, scroll to the BinLabelStep block.
  2. Find the bin in question.
  3. 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).
  4. The page calls zebraPrint.printBinLabel({ binNumber, barcode, orderNumber, itemCount, totalQuantity, sequence, totalBins, copies: 1, boxLabel }).
  5. The Capacitor plugin sends the ZPL to the connected ZQ511.
  6. On success, the bin's row shows a green check; the printed Set 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:

  1. From BinLabelStep, find the bin you want.
  2. Tap the per-bin secondary CTA (browser-print).
  3. 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)
  4. 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:

ImplementationWhereFormatUsed by
generateBinLabelapps/api/src/lib/zpl.ts:272-2892"×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 ZPLapps/worker/src/processors/pick-bin.processor.ts:55-654"×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 buildBinLabelZplapps/web/src/lib/zebra-print.ts:360-3922"×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):

FieldPurpose
binNumberHuman-readable identifier, globally unique
barcodeScannable identifier, globally unique. Same value as binNumber in many cases.
orderIdFK to Order
pickTaskIdFK to the picking WorkTask, nullable
statusPICKING / STAGED / PACKING / COMPLETED / CANCELLED
sequence1..n in multi-bin orders. Defaults to 1.
boxId, boxLabelOptional — links to a recommended box and its display name (e.g., "MED-12x9x4")
length, width, height, dimensionUnitBox dimensions if a recommendation was applied
actualWeight, weightUnitCaptured at pack time
labelZplThe worker-generated ZPL (per §5.1 — note divergence)
labelPrintedAtSet by the worker if a printerId was passed; does not reliably indicate physical print
pickedBy, pickedAtPicker user + timestamp
packedBy, packedAt(See WMS-PACK-001)
  • 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 a printerId was passed. Not reliable evidence of physical print (per §4.2 callout).
  • pickbin:label_printed SSE 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.printBinLabel calls — no audit row written. Failures are logged to console; successes are tracked only in the page's local printed Set 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-printerId issue.

7. Troubleshooting

SymptomCauseResolution
Picker finished a task but no label printedAuto-print path failed silently — Zebra not paired or BT droppedOpen 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 failedBluetooth socket reset, printer out of paper, printer paired to wrong devicePower-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 orderMulti-bin init was not run before picking — only one bin was auto-created at pick completeThe 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 printedPer §4.2 callout, the worker marks labelPrintedAt when called with printerId regardless of whether print actually succeededDon't rely on this field as proof. Confirm physically. Reprint via §4.5 if missing.
Two different physical labels exist for the same binOne 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 barcodeJsBarcode CDN unreachable from the device's networkSwitch device to an unrestricted network, or install a self-hosted barcode generator. See §8.
Bin barcode prints but doesn't scan at the pack stationCode128 module width too small for the data, or printer toner low producing a faint printReprint 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 stickersExpected — the system doesn't track sticker count, only print eventsPlace 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 labelsWorker has no real sendToPrinter integration — physical print only happens client-sideCheck 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 binThe detail page may render the per-bin button only in some modes — check BinLabelStep modeUse 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 generateBinLabel from zpl.ts and the worker inline template, leaving only the client-side buildBinLabelZpl; 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 on PickBin.labelZpl will 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 — sendToPrinter is 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 a printerId was passed, which doesn't mean print happened. Either implement real printing (above) and tie labelPrintedAt to 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 barcode should prevent. If it happens, escalate to IT — likely a generateBinNumber race 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_prints table with rows per print attempt, status, device, timestamp. Engineering ticket, low priority unless regulatory requires it.

9. Revision history

VersionDateAuthorChanges
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).