Overview
The QR scanning system allows workers to move panes through production stations by scanning QR codes with a mobile device. When a pane is scanned, both the mobile and desktop UIs update in real-time via WebSocket.
Each pane has a QR code with the format:
When the frontend scans this, it should strip the STDPLUS: prefix and use only the pane number (PNE-0001) in the API call.
Workflow
Standard Panes
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌───────┐
│ cutting │───▶│ edging │───▶│ qc │───▶│ ready │
└─────────┘ └─────────┘ └─────────┘ └───────┘
- Panes start at their first routing station (e.g.
cutting) with status pending. If routing is empty ([]), currentStation is null and the pane is already completed (nothing to scan).
- At each station, workers can perform these actions (see Scan a pane for the full request body):
- scan_in — Sets
currentStation to the station, currentStatus to in_progress, and startedAt (if not already set).
- start — Sets
currentStation to the station and currentStatus to in_progress.
- complete — Sets
currentStation to the station and currentStatus to awaiting_scan_out.
- scan_out — Handoff: requires status
awaiting_scan_out. Advances the pane to the next station with status pending. If it’s the last station, pane becomes completed.
- qc_pass — Same rules as scan_out (must be
awaiting_scan_out at the given station). Use at a QC station to record a QC pass in PaneLog (action: "qc_pass") while moving the pane forward or completing it.
- qc_fail — Requires
awaiting_scan_out at the station. Marks the pane defected (it must not be scanned again for production), writes a PaneLog with reason and optional description, and automatically creates a remake pane (remakeOf → failed pane) with status pending at the first routing station (or at remakeStationId if you pass it). See QC fail and remake below.
- After the last station’s scan_out or qc_pass, the pane is marked
completed with completedAt set.
Laminate Panes
Laminated glass is made from multiple raw sheets bonded together. When a request has sheetsPerPane > 1, the system automatically creates a parent pane and its child sheets:
Request (sheetsPerPane: 2)
└─ PNE-0100 (parent, laminateRole: "parent", dormant until merge)
└─ PNE-0100-A (sheet, laminateRole: "sheet")
└─ PNE-0100-B (sheet, laminateRole: "sheet")
The routing is split at the lamination station (a station with isLaminateStation: true):
- Child sheets get the pre-laminate segment of routing (up to and including the lamination station)
- Parent pane row holds post-lamination routing only (
qc, delivery, …); it stays dormant (currentStation: null, not printed on a shop QR) until merge retires it
Sheet A: cutting → … → lamination ─┐
├─ laminate → one survivor (e.g. PNE-0100-A): lamination (awaiting_scan_out) → scan_out / qc_pass → qc → …
Sheet B: cutting → … → lamination ─┘ (other sheet + parent row → merged_into; scan survivor’s QR only)
Sheets travel independently through their routing. When they arrive at the lamination station:
- The system checks if all active siblings (not
claimed, not merged_into) are present
- Emits
laminate:ready when all sheets are at the station, or laminate:waiting with a progress count
The laminate action merges sheets so one sheet keeps its pane number / QR as the physical unit going forward. After merge, that survivor is awaiting_scan_out at the lamination station — the worker scan_out / qc_pass using the survivor’s pane number (see Laminate Merge).
Two-Step Completion Flow
The complete → scan_out pattern is a deliberate two-step handoff:
scan_in → start → complete (awaiting_scan_out) → scan_out (moves to next station)
- complete confirms the worker finished their task but the pane hasn’t left the station yet.
- scan_out is the physical handoff — the pane moves to the next station (or is marked completed if it was the last one).
- qc_pass is an alternative to scan_out when you want the audit log to show a QC pass; behavior for routing and order progress is the same.
- qc_fail ends the line for this physical pane and triggers a remake (see below).
QC fail and auto-remake
When action is qc_fail:
- The pane must be at
awaiting_scan_out and the request station must match currentStation (same as scan_out).
reason is required. Allowed values: broken, chipped, dimension_wrong, scratch, stain, other.
description is optional (extra text stored on the PaneLog).
remakeStationId is optional. If omitted, the new pane is queued at the first station in routing (or the order’s station list). If set, the remake starts at that station instead.
- The current pane becomes
defected with currentStation: null — treat it as scrap for workflow purposes; do not scan it through production again.
- A new pane is created with
remakeOf pointing at the defected pane, same order/request/material/routing copy rules as other remakes, pending at the chosen first station.
- For non–sheet panes, the order’s
paneCount increases by 1 so progress still reflects the extra unit of work. Sheet panes follow the same counting rules as elsewhere (sheets are excluded from certain order counters).
The JSON response includes remadePane (the new pane). A MaterialLog with referenceType: "qc_remake" may be created for traceability.
Mobile Scanning Flow
Scan QR Code
Worker opens the app on their phone and scans the pane’s QR code with their camera.
Parse Pane Number
The frontend reads STDPLUS:PNE-0001 from the QR code and extracts PNE-0001.
Send Scan Request
Frontend calls POST /api/panes/PNE-0001/scan with the station and action.
Real-Time Update
Both mobile and desktop receive WebSocket events and update their UIs automatically.
Batch Scanning
For scanning multiple panes at the same station in one request, use the batch endpoint (full contract: Batch scan panes):
POST /api/panes/batch-scan
{
"paneNumbers": ["PNE-0001", "PNE-0002", "PNE-0003"],
"station": "<station-id>",
"action": "scan_in"
}
- Actions:
scan_in, start, and complete only — same semantics per pane as the single-pane scan API for those actions.
- Order: Panes are processed in
paneNumbers order. Unknown or duplicate numbers are skipped; if none match, the API returns 400.
- Consistency: If any pane in the batch fails validation partway through, the server rolls back earlier changes in that same request (pane fields restored,
PaneLog rows from that batch removed). This uses application-level rollback, not MongoDB transactions, so it works on standalone MongoDB without a replica set.
- Not in batch:
scan_out, qc_pass, qc_fail, and laminate — call POST /api/panes/{paneNumber}/scan per pane for those.
Laminate Merge
Call laminate once every active sheet is at the lamination station (in_progress or awaiting_scan_out). Recommended: use a sheet QR in the path (e.g. PNE-0100-A) so workers only scan printed labels.
POST /api/panes/PNE-0100-A/scan
{
"station": "<lamination-station-id>",
"action": "laminate"
}
Optional laminateSurvivorPaneNumber picks which sheet keeps its QR (defaults to the pane in the URL when the URL is a sheet). If you use the dormant parent number in the path instead, laminateSurvivorPaneNumber is required (must match an active sheet).
What happens:
- Validates all active sheets (
!= claimed, != merged_into) are present at the lamination station
- Survivor — one sheet stays active:
laminateRole becomes single, routing becomes the post-lamination route, awaiting_scan_out at the lam station, laminateMergedAt set; same paneNumber / QR as that sheet
- Retired — other sheets and the dormant parent row →
currentStatus: merged_into, mergedInto → survivor
- First scan_out / qc_pass from lamination uses survivor routing (lam station is not in that array) → moves to first post-lam station (e.g. QC). The survivor’s
laminateMergedAt flag is used for that handoff only, then cleared so later stations use normal routing index rules.
PaneLog: laminate_complete on retired sheets and parent, laminate_start on the survivor
order.stationBreakdown updated (sheets at lam consolidated to one unit)
- Emits
pane:laminated (payload includes survivor, parent alias, retiredPaneNumbers, sheets, parentPaneNumber)
Response includes:
pane — the survivor pane
mergedSheets, survivorPaneNumber, retiredPaneNumbers, parentRetired
panesCompleted and progressPercent on the order are not updated during the merge. They update when the survivor pane (the sheet that kept its QR) completes its final post-lamination station via scan_out or qc_pass.
Side Effects
Every scan triggers several side effects:
| Side Effect | When | Description |
|---|
| Pane Log | Every action | A PaneLog entry is created for every scan action (including qc_pass / qc_fail with reason / description when applicable) |
| Material Backfill | Every action | If the pane has no material but has an order, the order’s material is backfilled onto the pane |
| Order Progress | On scan_out or qc_pass | The order’s panesCompleted, progressPercent, stationBreakdown, and status are updated when the pane advances or completes |
| Order Completion | On scan_out / qc_pass (last station, last pane) | When all panes are completed, the order status changes to completed |
| Notification | On scan_out / qc_pass | If the order has an assignedTo worker, a notification is created and sent via WebSocket |
| QC fail | On qc_fail | Pane → defected; stationBreakdown decremented at QC station; auto remake pane; optional paneCount bump; MaterialLog qc_remake; notifications for assigned worker / station |
| Laminate Check | On scan_in at lamination station | If the scanned pane is a sheet arriving at its lamination station, checks siblings and emits laminate:ready or laminate:waiting |
| Sheet Merge | On laminate action | One sheet survives as single at lamination; others + parent → merged_into; stationBreakdown updated |
WebSocket Events
After a scan, the server emits these events to connected clients:
| Event | Rooms | When |
|---|
pane:updated | dashboard, pane, production, station:<name> | Every action (action: "scanned"); on qc_fail also action: "qc_failed" with the defected pane |
log:updated | log | Every action (action: "pane_scanned", payload includes paneLog and material) |
station:pane_arrived | station:<nextStation>, station | On scan_out or qc_pass when the pane moves to the next station |
order:updated | dashboard, order | On scan_out / qc_pass (order progress); on qc_fail after remake / paneCount update |
notification | user:<recipientId> | On scan_out / qc_pass (if order has assigned worker); on qc_fail remake (type: "qc_remake") when applicable |
notification | station:<nextStation> | On scan_out / qc_pass (pane arrival); on remake, station alert for the queue station |
laminate:ready | dashboard, pane, production, station:<id> | On scan_in when all active sheets have arrived at the lamination station |
laminate:waiting | station:<id> | On scan_in when some sheets are still missing at the lamination station |
pane:laminated | dashboard, pane, production, station:<id> | On laminate — survivor + retired sheet numbers (parent in payload = survivor for compatibility) |
Listening for Scan Events
import { io } from 'socket.io-client';
const socket = io('http://localhost:3000', {
path: '/api/socket-entry',
auth: { token: 'your-jwt-token' },
});
socket.on('connect', () => {
socket.emit('join_pane');
socket.emit('join_production');
socket.emit('join_station');
socket.emit('join_me');
});
socket.on('pane:updated', ({ action, data }) => {
console.log('Pane changed:', data.paneNumber, data.currentStation);
});
socket.on('station:pane_arrived', ({ paneNumber, fromStation, toStation }) => {
console.log(`${paneNumber} moved from ${fromStation} to ${toStation}`);
});
socket.on('notification', (notif) => {
console.log(`[${notif.priority}] ${notif.title}: ${notif.message}`);
});
// Laminate pairing events
socket.on('laminate:ready', ({ parentPaneNumber, sheetsPresent, sheetsTotal }) => {
console.log(`All ${sheetsTotal} sheets ready for ${parentPaneNumber} — merge now`);
});
socket.on('laminate:waiting', ({ parentPaneNumber, sheetsPresent, sheetsTotal }) => {
console.log(`${sheetsPresent}/${sheetsTotal} sheets arrived for ${parentPaneNumber}`);
});
socket.on('pane:laminated', ({ survivor, parent, sheets, retiredPaneNumbers }) => {
const live = survivor || parent;
console.log(`Survivor ${live.paneNumber}; retired: ${(retiredPaneNumbers || []).join(', ')}; sheets: ${(sheets || []).join(', ')}`);
});
Error Handling
| Status | Cause | Message example |
|---|
404 | Pane number doesn’t exist | ไม่พบกระจก PNE-0001 |
400 | Pane is at a different station | กระจกอยู่ที่สถานี edging ไม่ใช่ cutting |
400 | Pane already finished or defected | Message includes already completed or defected |
400 | scan_out / qc_pass without complete first | Thai message — must press complete before handoff / QC pass |
400 | qc_fail without reason | Validation error (reason is required when action is qc_fail) |
400 | Missing station field | station is required |
400 | Invalid action value | Invalid action |
400 | qc_fail but pane cannot change state | e.g. already defected / completed / claimed |
400 | qc_fail without material | Cannot create remake if pane has no material (and order cannot supply it) |
400 | laminate on wrong pane type | Not a sheet / parent in a laminate group |
400 | laminate when not all sheets are present | Not all sheets present at lamination station (1/2 present) |
400 | laminate via parent path without laminateSurvivorPaneNumber | Validation / clear API message |
400 | laminateSurvivorPaneNumber not an active sheet (wrong number, or parent’s own number) | API error — must match a sheet at lamination |
400 | Scan on merged_into pane | errors.code: MERGED_INTO, survivorPaneNumber in errors |