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.

QR Code Format

Each pane has a QR code with the format:
STDPLUS:PNE-0001
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 │
└─────────┘    └─────────┘    └─────────┘    └───────┘
  1. 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).
  2. 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.
  3. 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 completescan_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:
  1. The pane must be at awaiting_scan_out and the request station must match currentStation (same as scan_out).
  2. reason is required. Allowed values: broken, chipped, dimension_wrong, scratch, stain, other.
  3. description is optional (extra text stored on the PaneLog).
  4. 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.
  5. The current pane becomes defected with currentStation: null — treat it as scrap for workflow purposes; do not scan it through production again.
  6. 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.
  7. 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

1

Scan QR Code

Worker opens the app on their phone and scans the pane’s QR code with their camera.
2

Parse Pane Number

The frontend reads STDPLUS:PNE-0001 from the QR code and extracts PNE-0001.
3

Send Scan Request

Frontend calls POST /api/panes/PNE-0001/scan with the station and action.
4

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:
  1. Validates all active sheets (!= claimed, != merged_into) are present at the lamination station
  2. 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
  3. Retired — other sheets and the dormant parent row → currentStatus: merged_into, mergedInto → survivor
  4. 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.
  5. PaneLog: laminate_complete on retired sheets and parent, laminate_start on the survivor
  6. order.stationBreakdown updated (sheets at lam consolidated to one unit)
  7. 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 EffectWhenDescription
Pane LogEvery actionA PaneLog entry is created for every scan action (including qc_pass / qc_fail with reason / description when applicable)
Material BackfillEvery actionIf the pane has no material but has an order, the order’s material is backfilled onto the pane
Order ProgressOn scan_out or qc_passThe order’s panesCompleted, progressPercent, stationBreakdown, and status are updated when the pane advances or completes
Order CompletionOn scan_out / qc_pass (last station, last pane)When all panes are completed, the order status changes to completed
NotificationOn scan_out / qc_passIf the order has an assignedTo worker, a notification is created and sent via WebSocket
QC failOn qc_failPane → defected; stationBreakdown decremented at QC station; auto remake pane; optional paneCount bump; MaterialLog qc_remake; notifications for assigned worker / station
Laminate CheckOn scan_in at lamination stationIf the scanned pane is a sheet arriving at its lamination station, checks siblings and emits laminate:ready or laminate:waiting
Sheet MergeOn laminate actionOne 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:
EventRoomsWhen
pane:updateddashboard, pane, production, station:<name>Every action (action: "scanned"); on qc_fail also action: "qc_failed" with the defected pane
log:updatedlogEvery action (action: "pane_scanned", payload includes paneLog and material)
station:pane_arrivedstation:<nextStation>, stationOn scan_out or qc_pass when the pane moves to the next station
order:updateddashboard, orderOn scan_out / qc_pass (order progress); on qc_fail after remake / paneCount update
notificationuser:<recipientId>On scan_out / qc_pass (if order has assigned worker); on qc_fail remake (type: "qc_remake") when applicable
notificationstation:<nextStation>On scan_out / qc_pass (pane arrival); on remake, station alert for the queue station
laminate:readydashboard, pane, production, station:<id>On scan_in when all active sheets have arrived at the lamination station
laminate:waitingstation:<id>On scan_in when some sheets are still missing at the lamination station
pane:laminateddashboard, 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

StatusCauseMessage example
404Pane number doesn’t existไม่พบกระจก PNE-0001
400Pane is at a different stationกระจกอยู่ที่สถานี edging ไม่ใช่ cutting
400Pane already finished or defectedMessage includes already completed or defected
400scan_out / qc_pass without complete firstThai message — must press complete before handoff / QC pass
400qc_fail without reasonValidation error (reason is required when action is qc_fail)
400Missing station fieldstation is required
400Invalid action valueInvalid action
400qc_fail but pane cannot change statee.g. already defected / completed / claimed
400qc_fail without materialCannot create remake if pane has no material (and order cannot supply it)
400laminate on wrong pane typeNot a sheet / parent in a laminate group
400laminate when not all sheets are presentNot all sheets present at lamination station (1/2 present)
400laminate via parent path without laminateSurvivorPaneNumberValidation / clear API message
400laminateSurvivorPaneNumber not an active sheet (wrong number, or parent’s own number)API error — must match a sheet at lamination
400Scan on merged_into paneerrors.code: MERGED_INTO, survivorPaneNumber in errors