Files
Khalid Abdi 0754ce96a4 inventory: barcode scanner in Add-item dialog
Add a camera barcode/QR scanner (components/scan/barcode-scanner.tsx,
@zxing/browser lazy-loaded) and a Barcode/NDC field to the pharmacy
Add-item dialog. Scanning a GS1 DataMatrix auto-fills expiry (AI 17) and
lot (AI 10 -> notes), with the GTIN (AI 01) as the barcode; plain 1D
barcodes drop straight into the field. Persists a nullable inventory
barcode column (migration 0036) through the type/validation/service, and
surfaces it on the item detail. i18n added to all locales.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 23:32:45 +03:00

86 lines
2.6 KiB
TypeScript

// Minimal GS1 Application Identifier parser for medication barcodes (the GS1
// DataMatrix printed on drug packaging). We only care about the AIs a pharmacy
// add-item flow can use: 01 GTIN, 17 expiry, 10 lot/batch, 21 serial. Variable
// -length fields are terminated by the FNC1/GS separator (ASCII 29); the AIs
// below have fixed lengths per the GS1 General Specifications.
export type Gs1Fields = {
gtin?: string;
/** AI 17 normalised to YYYY-MM-DD. */
expiry?: string;
lot?: string;
serial?: string;
};
const GS = "\x1d";
// value length (excluding the 2-digit AI) for the fixed-length AIs we handle.
const FIXED_LEN: Record<string, number> = {
"00": 18,
"01": 14,
"02": 14,
"11": 6,
"12": 6,
"13": 6,
"15": 6,
"16": 6,
"17": 6,
"20": 2,
};
function yymmddToIso(v: string): string | undefined {
if (!/^\d{6}$/.test(v)) return undefined;
const year = 2000 + Number(v.slice(0, 2));
const month = Number(v.slice(2, 4));
let day = Number(v.slice(4, 6));
if (month < 1 || month > 12) return undefined;
// GS1 allows DD=00 to mean "end of the month".
if (day === 0) day = new Date(year, month, 0).getDate();
const pad = (n: number) => String(n).padStart(2, "0");
return `${year}-${pad(month)}-${pad(day)}`;
}
// Parse a scanned string as GS1. Returns null when it isn't GS1-structured (a
// plain EAN-13 / Code128 barcode), so callers can fall back to the raw value.
export function parseGs1(raw: string): Gs1Fields | null {
if (!raw) return null;
let s = raw;
// Strip a leading symbology identifier (e.g. "]d2", "]C1", "]e0").
if (s.startsWith("]")) s = s.slice(3);
if (s.startsWith(GS)) s = s.slice(1);
// GS1 medication codes lead with (01) GTIN; bail early otherwise so a plain
// numeric barcode isn't mis-parsed.
if (!s.startsWith("01")) return null;
const out: Gs1Fields = {};
let i = 0;
let matched = false;
while (i + 2 <= s.length) {
const ai = s.slice(i, i + 2);
i += 2;
const fixed = FIXED_LEN[ai];
let value: string;
if (fixed != null) {
value = s.slice(i, i + fixed);
i += fixed;
} else {
const gsIdx = s.indexOf(GS, i);
if (gsIdx === -1) {
value = s.slice(i);
i = s.length;
} else {
value = s.slice(i, gsIdx);
i = gsIdx + 1;
}
}
if (s[i] === GS) i += 1; // consume a separator trailing a fixed field
if (!value) break;
matched = true;
if (ai === "01") out.gtin = value;
else if (ai === "17") out.expiry = yymmddToIso(value);
else if (ai === "10") out.lot = value;
else if (ai === "21") out.serial = value;
}
return matched ? out : null;
}