Skip to main content

SOP: Generating & Printing Receiving Labels

Document ID: WMS-REC-005 Version: 1.0 Effective date: 04/30/2026 Owner: Warehouse Operations Manager Next review: [six months from effective date] Applies to: Floor staff who need to label received goods for downstream picking and putaway


1. Purpose

This procedure governs how receiving labels are generated and printed on the Zebra ZQ511 mobile printer for goods that arrive without a usable scan barcode — typically the case for cannabis/vape product where vendor barcodes are inconsistent, lot-tracked, or absent. A printed label gives every received unit a Code128 barcode that the WMS can scan during count, putaway, picking, and cycle counts.

Approval (per WMS-REC-003 §4.2) creates InventoryUnit rows whose subsequent operations depend on a scannable barcode. If a SKU has no upc, no barcode field on the variant, and no printed label, every downstream operation falls back to manual entry — slower and higher error rate. Labels close that gap.

⚠ CURRENT-STATE UI GAP (smaller than REC-002 and REC-004). The label generator and print pipeline are fully built — the API endpoints, the ZPL generator, the Capacitor Bluetooth plugin, and even a PrintLabelButton React component are all present. What's missing is the mount point. The receiving session page and the approval page do not currently render PrintLabelButton, so floor staff have no UI path to print a receiving label. The PrinterStatus indicator in the navbar works (connect/disconnect), but the Print button never appears next to a line. See §4.4 for the workaround. The fix is roughly an hour of UI work — see §8.

2. Scope

In scope:

  • Connecting and pairing the ZQ511 over Bluetooth via the navbar PrinterStatus button
  • Generating receiving labels via POST /receiving/:sessionId/lines/:lineId/label (single line) and POST /receiving/:sessionId/labels (batch / whole session)
  • The barcode resolution rule (upcbarcodesku) and the auto-persisted generatedBarcode field on ReceivingLine
  • The label format itself (2"×1" at 203 DPI, Code128, the field layout)
  • The current-state workaround until the print button is mounted on receiving pages

Out of scope:

  • Location/bin labels (zone, aisle, rack labels) — see WMS-INV-004 §4.4
  • Product labels for floor-count / new-SKU mapping — see WMS-INV-003 (uses a different endpoint)
  • Order pick-bin labels — see WMS-PICK-002
  • Customer return intake labels — see WMS-RET-001
  • Shipping labels (carrier labels) — see WMS-SHIP-001 (different printer, different format)

3. Roles & permissions

API enforcement: the two label endpoints (/lines/:lineId/label and /labels) check authentication only. Any authenticated user can generate label ZPL. Whether they can print depends on whether their device has the Capacitor Bluetooth plugin (Android-native build only — see §5.3).

RoleGenerate label ZPLSend to printer
READONLY✓ (if paired)
STAFF✓ (if paired)
MANAGER✓ (if paired)
ADMIN✓ (if paired)
SUPER_ADMIN✓ (if paired)

Receiving labels are not a security-sensitive operation. The role table is permissive across the board.

4. Procedures

4.1 Pairing the ZQ511 to a TC22

Use when: First time on a device, the printer was unpaired, or you've moved to a different printer.

Prerequisites:

  • ZQ511 powered on, paper loaded, Bluetooth enabled, in range (~10 m line of sight).
  • TC22 has Bluetooth enabled and the WMS Android app installed.
  • You have permission to pair on the TC22 (Android Settings, Location enabled — required for BLE discovery on Android 12+).

Steps:

  1. Open the WMS app on the TC22. The navbar shows a printer icon — red X for disconnected, green check for connected.
  2. Tap the printer icon.
  3. The app calls discoverPrinters() via the ZebraPrint Capacitor plugin. A bottom sheet appears with discovered printers.
  4. If exactly one printer is found, the app auto-connects to it.
  5. If multiple are found, tap the one labeled with your station's printer (typically XXZQ511* followed by a serial). The status flips to connected with a green check.
  6. The MAC address is persisted in Capacitor Preferences under key zebra_printer_mac so the next session auto-reconnects.

Status states (visible in the navbar icon and in the picker sheet):

StatusIconMeaning
disconnectedRed XNo printer paired or last connection lost
connectingSpinnerCurrently establishing Bluetooth
connectedGreen checkReady to print
printingSpinnerA ZPL job is being sent
errorRed X + toastLast operation failed; toast shows reason

⚠ Bluetooth pairing is per-device, per-OS-build. A web-browser session of the WMS (not the Capacitor APK) cannot print — Capacitor.isNativePlatform() returns false and the ZebraPrint plugin returns null. If PrinterStatus shows permanently red on what looks like the right device, you're probably on the web app, not the Android APK. Open the installed app icon, not the browser shortcut.

4.2 Printing a single line label (target flow — UI not mounted today)

This describes the intended UX. As of release, no receiving page mounts the PrintLabelButton component. Use §4.4 for the workaround until it ships.

Use when: A specific received SKU on the current session needs a printed barcode — vendor sent unlabeled stock, the existing barcode is damaged, or you need extra copies to label individual units in a case.

Prerequisites:

  • Printer is connected per §4.1.
  • The line exists in the session and you know which line.

Steps (target):

  1. From the session page (/receiving/session/:sessionId), expand the line.
  2. Tap the Print Label button (compact: printer icon; full: button labeled Print Label). Both are rendered by PrintLabelButton.
  3. The button calls POST /receiving/:sessionId/lines/:lineId/label with optional { copies: 1..10 } (default 1).
  4. The API returns ZPL. The Capacitor plugin sends it to the connected ZQ511 via sendZPL.
  5. The button shows a green check for 3 seconds on success, or a red error message for 4 seconds on failure.

What the API does:

  • Resolves the barcode using resolveBarcode(variant):
    1. variant.upc (trimmed, if non-empty)
    2. variant.barcode (trimmed, if non-empty)
    3. variant.sku (trimmed)
  • If the line's generatedBarcode is null, persists the resolved value to ReceivingLine.generatedBarcode. From this point forward, every other operation on this line uses the same barcode value.
  • Generates ZPL via generateReceivingLabel({ productName, sku, barcode, poReference, quantity, vendor?, lotNumber?, expiryDate? }). If copies > 1, uses generateBatchLabels(Array(min(copies, 10)).fill(labelData)) — batch print is hard-capped at 10 copies per call.
  • Returns { zpl, barcode, productName, sku }.

Common errors:

HTTPAPI messageWhat it means
404"Receiving line not found"lineId doesn't exist or doesn't belong to sessionId. Refresh.
500"Failed to generate label"Server error in ZPL generation (rare). Check API logs.
(UI)"No label data returned"API succeeded but zpl field was empty. Retry.
(UI)"No printer connected"The Capacitor Bluetooth state is disconnected. Tap the printer icon to reconnect.
(UI)"Print failed" (generic)The ZPL bytes did not transmit. Check printer is on, has paper, and is in range.

4.3 Batch labels for a whole session (target flow — no UI client today)

Use when: You want to pre-print labels for every line on a PO before counting begins, so the labels are ready to apply as you count.

Prerequisites:

  • Printer is connected per §4.1.
  • Session exists and has line items.

Steps (target):

  1. (No UI exists for this today.) The endpoint POST /receiving/:sessionId/labels exists with optional { lineIds?: string[] } body to filter to specific lines. Manual call only.
  2. The API returns { zpl, count } — a single ZPL document containing all labels concatenated.
  3. The Capacitor plugin sends the whole ZPL to the printer in one call. The printer prints each label in sequence.

What the API does:

  • Loads all line items (or only the supplied lineIds) ordered by sku ASC.
  • For every line missing generatedBarcode, runs resolveBarcode and persists the result. This happens in a single prisma.$transaction([...updates]) so partial failures roll back.
  • Builds a ReceivingLabelData[] array and calls generateBatchLabels(labels).
  • Returns { zpl, count }. The count is the number of lines included.

Common errors:

HTTPAPI messageWhat it means
404"Session not found"Session ID is wrong. Refresh.
500"Failed to generate labels"Server error in ZPL or in the generated-barcode update transaction.

4.4 Current-state workaround for printing labels

Use when: §4.2 / §4.3 don't work because the Print Label button is not mounted on the session or approval pages today.

Two options, in order of preference:

Option A — print location labels via floor-count (works today, slightly off-purpose):

If the goods arriving need a bin location label (you're staging them in a fresh bin and want the bin barcoded), use WMS-INV-003 (Floor count & location discovery) to print a location label. This won't put a SKU/barcode on the goods themselves, but it ensures the staging bin is scannable.

Option B — short-term API-direct call:

If you must label the units themselves before §4.2 ships:

  1. Pair the printer per §4.1. Confirm PrinterStatus shows connected.
  2. From any developer console (DevTools while on a session page, or curl from an admin laptop with the right cookie), call:
    POST /receiving/{sessionId}/lines/{lineId}/label
    Body: { "copies": <n>, max 10 }
    Get the zpl response.
  3. Send the ZPL to the printer via the Capacitor plugin. There is no convenient UI for this on a TC22 unless a developer adds a quick print form.

This is friction-heavy. Until §4.2 is mounted, most floor staff should rely on the existing variant barcode/upc fields and only flag SKUs that genuinely have no scannable barcode for a manager to print labels for via Option B.

Until the button mounts: when a SKU arrives without a usable barcode, count it manually per WMS-REC-001 §4.3, file the issue in #warehouse-ops so a manager can either (a) print labels via Option B post-hoc and re-label the stock in the receiving zone before putaway, or (b) flag the variant for the buyer to push back on the vendor.

⚠ The auto-generated barcode persists on first label-API call. Even via Option B's developer-direct call, the first call to /lines/:lineId/label writes generatedBarcode to the row. From then on, every subsequent count, putaway move, and picking scan resolves to that value via the §5.1 resolution rule. So a single Option B call per line is enough — you don't need to re-call to "lock in" the barcode.

5. Reference

5.1 Barcode resolution rule (verbatim from resolveBarcode)

For a given variant on a receiving line, the barcode used in the printed label and persisted to generatedBarcode is the first non-empty value in this order:

  1. variant.upc — trimmed
  2. variant.barcode — trimmed
  3. variant.sku — trimmed (always non-empty by schema constraint)

Once generatedBarcode is set on the ReceivingLine, all subsequent label, scan, and event operations for that line use the persisted value. Updating the variant's upc or barcode later does not retroactively change the receiving session's barcode — that's intentional and is what makes the label scannable forever for this PO's units.

5.2 Label format (Zebra ZQ511, 2"×1", 203 DPI)

The ZPL document for a single receiving label:

^XA
^LT0 ; no top offset
^FO0,5^A0N,24,24^FB406,1,0,C^FD<NAME>^FS ; product name centered, font 24
^FO0,30^FB406,1,0,C^BY<MW>,3,50^BCN,50,Y,N,N^FD<BARCODE>^FS ; Code128 50-dot tall, MW = scaled module width
^FO10,110^A0N,20,20^FD<SKU>^FS ; SKU bottom-left, font 20
^FO330,110^A0N,20,20^FDx<QTY>^FS ; quantity bottom-right
^XZ

Fields and constraints:

FieldSourceTruncation / sanitize
productNameReceivingLine.productNameTruncated to 28 chars, ^ and ~ stripped, non-printable stripped
skuReceivingLine.skuTruncated to 18 chars, sanitized
barcodeReceivingLine.generatedBarcode (or fresh resolution if null)Sanitized; module width auto-scales to fit 406-dot label width
quantityReceivingLine.quantityCounted (or quantityExpected if 0)Number prefixed x
vendor, lotNumber, expiryDateSession/lineCurrently not rendered on the label — passed to the generator but the template omits them. See §8.

The label is 2"×1" (406×203 dots at 203 DPI). The barcode is 50 dots tall (~0.25"). This is tuned for hand-held scan distances of 4–18". For longer-range case-pack scanning, increase the ^BCN,50,... height in zpl.ts to 100 and reprint — but the label height is fixed at 1" so a 100-dot barcode will dominate the label.

5.3 Native vs. web platform

The ZQ511 is reachable only from a Capacitor Android build of the WMS (Capacitor.isNativePlatform() === true). The web app on a desktop browser, the same web app served at app.hq.team from an iPhone, or any non-native client returns null from getPlugin() and cannot send ZPL to the printer.

Practically:

  • The Android APK installed on a TC22 — works.
  • The Android APK installed on a personal Android phone — works (can pair to any ZQ511 in range; useful for ad-hoc reprints).
  • Chrome/Safari on a desktop or laptop — does not work.
  • Chrome on a tablet — does not work (web).

The PrinterStatus icon in the navbar will appear permanently red on web platforms; this is by design.

  • WMS-REC-001 — Counting (where labeled goods get scanned)
  • WMS-REC-003 — Approval (creates the inventory units that benefit from being labeled)
  • WMS-REC-004 — Putaway (where labeled goods get scanned again into bins)
  • WMS-INV-003 — Floor count & location discovery (uses a different label endpoint for location/SKU labels)
  • WMS-INV-004 — Location management (uses generateLocationLabel from zpl.ts)
  • WMS-PICK-001 — Picking (where labeled units get scanned at outbound)

6. Audit & compliance

Label generation has no audit log entries today. The /lines/:lineId/label and /labels endpoints don't write to audit_logs. The only persistent side effect is ReceivingLine.generatedBarcode being set on first call. Reprints leave no trace.

This is a small gap — labels are physical artifacts; the audit story is "did the goods get labeled" (visible in the warehouse) more than "who pressed print." But for cannabis/vape regulatory reporting, the state regulator may ask "were these specific lots labeled before they left receiving?" If yes is required, add a LABELS_PRINTED audit row with the lineIds in the same fashion as receiving session events.

Manager weekly review (current-state):

  • Cannabis/vape-product receivings (lot-tracked SKUs) — check that physical labels are visible on the receiving zone before putaway. No system query helps here.

7. Troubleshooting

SymptomCauseResolution
PrinterStatus icon stays red after tapping connectBluetooth permission denied on TC22, or printer is offTC22 Settings → Apps → WMS → Permissions → grant Location and Nearby Devices. Power-cycle the printer. Retry.
PrinterStatus shows green but print fails with "No printer connected"Stale connection state; printer dropped Bluetooth without notifying appTap the printer icon, tap Disconnect, then Reconnect.
ZPL prints but the barcode is unreadableModule width too small for the data lengthThe auto-scaler in zpl.ts (scaledBarcode) handles most cases. For a SKU >18 chars, the barcode may be too dense. Switch the variant to use a shorter barcode field via product editing, then reprint.
Label prints but truncates the product nameNames >28 chars are truncated to "26 chars + .." in truncate()This is by design for the 2"×1" label. If you need full product name, redesign the label template (4"×2") and update zpl.ts.
Vendor / lot / expiry doesn't show on the labelCurrent generateReceivingLabel template doesn't render those fields, even though the API passes themSee §8 — engineering item.
"Failed to generate labels" (HTTP 500) on batch printMost likely the prisma.$transaction([...updates]) for generatedBarcode failed (e.g., a stale line was deleted)Refresh the session and retry. If it persists, IT — there's a session integrity issue.
Reprint produces a different barcodeFirst call set generatedBarcode; later calls should return the persisted valueShould not happen. If it does, check whether the variant's upc/barcode was edited between calls — the persisted generatedBarcode should override regardless. Escalate as a possible bug.
Bluetooth keeps disconnectingTC22 power-saver killing the BT connection, or printer auto-off after inactivityTC22 Settings → Battery → WMS → Unrestricted. Set the printer's auto-off to 60+ minutes (per the printer's own UI).

8. Escalation

  • Mount PrintLabelButton on the receiving session page: this is the highest-leverage receiving feature ship after WMS-REC-002 and WMS-REC-004. The component exists, works, and is one import + one render away from being usable. Probably an hour of work plus a manager test on a TC22.
  • Surface batch print on the session page: a single button "Print labels for all unlabeled lines" calling POST /receiving/:sessionId/labels. Adds maybe two hours.
  • Render lot/expiry/vendor on the label: the API passes the fields but the ZPL template doesn't draw them. A 2"×1" label is small but lot+expiry would fit if productName is given a tighter truncation. See zpl.ts generateReceivingLabel and the layout comment block above it.
  • Add audit logging: if cannabis/vape regulatory reporting requires it, add a LABELS_PRINTED row per /labels call with the lineIds array.
  • Web/desktop print path: not currently supported; the Capacitor plugin is Android-native only. If desktop print is wanted, the cleanest path is Zebra Browser Print over the network, which would require a separate codepath alongside the Capacitor plugin.
  • Printer permanently offline / Bluetooth troubleshooting failed: IT — the ZQ511 may need a firmware update or factory reset.

9. Revision history

VersionDateAuthorChanges
1.0[DATE][NAME]Initial release. Documents the receiving label generation and print pipeline grounded in apps/api/src/routes/receiving.routes.ts (/lines/:lineId/label, /labels), apps/api/src/lib/zpl.ts (resolveBarcode, generateReceivingLabel, generateBatchLabels, the 2"×1" 203-DPI format), apps/web/src/lib/zebra-print.ts (Capacitor plugin), and apps/web/src/components/receiving/PrintLabel.tsx (PrinterStatus, PrintLabelButton). Documents the current-state UI gap: PrinterStatus is mounted in the navbar (AppLayout.tsx) but PrintLabelButton is not mounted on any receiving page. Documents the barcode-resolution rule, the auto-persist of generatedBarcode on first label call, the 10-copy hard cap, and the native-only Capacitor constraint. Provides §4.4 workaround until the button is mounted on the session page.