Skip to content

Scanning

A warehouse operator holds a device in one hand and a carton in the other. They point and pull a trigger; nothing on screen is tapped first. @onerp/react/scan is the pipeline behind that trigger: a scan arrives from wherever it arrives, is read into the things it names, and reaches whichever screen is on top.

flowchart LR
  D[device] -->|ScanSource| R[RawScan]
  R -->|interpret| E["ScanElement[]"]
  E -->|resolve| M[ScanMatch]
  M -->|useScan| S[innermost screen]

Four seams, each replaceable without the others noticing. The reason for four rather than one is a single fact about labels: one scan can name several things. A plain code names one product; a GS1-128 label names a product, a lot and a quantity in one trigger pull. Anything shaped onScan(kind, id) cannot say that, so the pipeline carries a set of facts from end to end.

One <ScanProvider> in the application’s layout — resolution reads through the workspace’s business-object manager, which is loaded by then.

apps/mobile-warehouse/src/shell.tsx
const SCAN_RESOLVERS: ScanResolver[] = [
{ businessObject: "inventory/Location", field: "code", kind: "code" },
{ businessObject: "master/Product", field: "sku", kind: "code" },
];
<ScanProvider resolvers={SCAN_RESOLVERS}>{children}</ScanProvider>;

The resolvers are the whole vocabulary of the application: what a label can name, which field carries it, and which element kind that field answers. The package itself names no business object — an application that scans pallets and work orders passes different rows and changes nothing else.

useScan({
expect: ["inventory/Location", "master/Product"],
onScan: (match) => {
const location = match.records.get("inventory/Location");
if (location) {
navigate(`/stock/location/${location}`);
}
},
onUnresolved: (match) => setMissed(match.raw.data),
});

match.records maps a business object name to the single record its resolver found. expect orders the attempts — this is how one code lands on a location from the bin screen and on a product from the product screen. Resolvers that were not expected still run afterwards, so a scan is never dropped for being off-topic; a screen that truly accepts one kind ignores the rest in its handler.

Handlers form a stack and the innermost mounted one wins, so a sheet takes the scans while it is open and the screen beneath gets them back when it closes.

An unresolved scan never reaches onScan. The provider vibrates the device where the browser supports it, and onUnresolved gets the match so the screen can word its own message. Two records answering one code is not a match either: those business object names arrive in match.ambiguous, and nothing is picked for the operator. master/Product.sku carries no uniqueness constraint, so this is reachable with real master data, not a theoretical branch.

No device implementation ships. Codes reach the pipeline from a typed field:

const submitScan = useSubmitScan();
<form onSubmit={() => submitScan(code)}>…</form>;

This field is not a stopgap — it is the fallback every deployment needs for the device that cannot scan, the label that will not read, and the code read off a packing slip by eye. Put it in a <form>: a keyboard-wedge scanner ends its transmission with a terminator key, so the same Enter that submits the field for a human submits it for the scanner.

ScanElementKinds is an open interface. A domain adds its kinds by declaration merging, and the package learns nothing:

declare module "@onerp/react/scan" {
interface ScanElementKinds {
gtin: string;
lotCode: string;
netWeight: string;
}
}

An interpreter then produces them. Interpreters are tried in order and the first one that recognises the scan owns it; returning null passes it along.

const gs1: ScanInterpreter = (raw) =>
raw.symbology === "]C1" ? parseApplicationIdentifiers(raw.data) : null;
<ScanProvider interpreters={[gs1, plainCode]} resolvers={SCAN_RESOLVERS} />;

Every label format is one function added beside the others: GS1-128, an ISO/IEC 15434 transport label, a supplier’s own pallet tag. Elements resolve independently, so the GS1 scan above fills the product and the lot from one trigger pull, while an element that names no record — a net weight — stays on match.elements for a screen that wants it.

Where a kind is looked up is one more resolver row:

{ businessObject: "master/Product", field: "barcode", kind: "gtin" }

ScanSource is the device seam: subscribe, push raw scans, return the unsubscribe.

type ScanSource = (emit: (raw: RawScan) => void) => () => void;
<ScanProvider resolvers={SCAN_RESOLVERS} source={keyboardWedge} />;

A keyboard wedge, a camera decoder, or a vendor SDK bridge is one file and one prop. No screen, resolver or interpreter changes when one arrives — and the typed field keeps working beside it.

Two facts shape a keyboard-wedge implementation on Android. A hardware scanner in keystroke mode is usually an input method, and an input method commits text rather than pressing keys — so the source listens for input on a field it owns and keeps focused, not for keydown. Only the terminator key reliably arrives as a genuine key event. On a desktop with a USB gun in HID mode, keydown works and the accumulator distinguishes a scan from typing by speed.

RawScan.symbology carries the AIM identifier when the device sends one — the interpreter’s cheapest way to know what it is holding before it parses anything.