From 81f6ec5614e103bfde9512fc6bca51d53a307668 Mon Sep 17 00:00:00 2001 From: maniamartial Date: Sun, 29 Mar 2026 15:42:44 +0300 Subject: [PATCH] feat: IMplement GS1 DataMatrix for reading medical qr code --- klik_spa/src/components/BarcodeScanner.tsx | 30 +- klik_spa/src/components/RetailPOSLayout.tsx | 80 +++-- klik_spa/src/hooks/gS1parser.ts | 201 ++++++++++++ klik_spa/src/hooks/useBarcodeScanner.ts | 328 ++++++++++++++++---- 4 files changed, 546 insertions(+), 93 deletions(-) create mode 100644 klik_spa/src/hooks/gS1parser.ts diff --git a/klik_spa/src/components/BarcodeScanner.tsx b/klik_spa/src/components/BarcodeScanner.tsx index 46c890a..ce60e04 100644 --- a/klik_spa/src/components/BarcodeScanner.tsx +++ b/klik_spa/src/components/BarcodeScanner.tsx @@ -109,13 +109,16 @@ export default function BarcodeScannerModal({ onBarcodeDetected, onClose, isOpen try { type SupportedBarcodeFormat = - | 'code_128' - | 'code_39' - | 'ean_13' - | 'ean_8' - | 'upc_a' - | 'upc_e' - | 'qr_code' + | 'aztec' + | 'code_128' + | 'code_39' + | 'data_matrix' // ← THIS is what your medicine box uses + | 'ean_13' + | 'ean_8' + | 'pdf417' + | 'qr_code' + | 'upc_a' + | 'upc_e' type BarcodeDetectorType = new (options?: { formats?: SupportedBarcodeFormat[] }) => { detect(image: HTMLCanvasElement): Promise> @@ -126,10 +129,12 @@ export default function BarcodeScannerModal({ onBarcodeDetected, onClose, isOpen setError('BarcodeDetector not available in this browser.') return } - const barcodeDetector = new Detector({ - formats: ['code_128', 'code_39', 'ean_13', 'ean_8', 'upc_a', 'upc_e', 'qr_code'] - }) - + // const barcodeDetector = new Detector({ + // formats: ['code_128', 'code_39', 'ean_13', 'ean_8', 'upc_a', 'upc_e', 'qr_code'] + // }) + const barcodeDetector = new Detector({ + formats: ['aztec', 'code_128', 'code_39', 'data_matrix', 'ean_13', 'ean_8', 'pdf417', 'qr_code', 'upc_a', 'upc_e'] +}) // Start detection loop detectionIntervalRef.current = setInterval(async () => { if (videoRef.current && canvasRef.current && videoRef.current.videoWidth > 0) { @@ -208,7 +213,8 @@ export default function BarcodeScannerModal({ onBarcodeDetected, onClose, isOpen
- Barcode detected: {scannedBarcode} + Barcode detected: {scannedBarcode.length > 30 ? '(GS1 DataMatrix)' : scannedBarcode} +

diff --git a/klik_spa/src/components/RetailPOSLayout.tsx b/klik_spa/src/components/RetailPOSLayout.tsx index 0fdff4d..1098a13 100644 --- a/klik_spa/src/components/RetailPOSLayout.tsx +++ b/klik_spa/src/components/RetailPOSLayout.tsx @@ -286,29 +286,65 @@ export default function RetailPOSLayout() { // Barcode scanning functionality - moved after handleAddToCart is defined const { scanBarcode } = useBarcodeScanner(addItemToCart) - const handleBarcodeDetected = useCallback(async (barcode: string) => { - const result = await scanBarcode(barcode) - if (result) { - setShowScanner(false) - if (typeof result === 'object' && result.success && result.item_code) { - const itemCode = result.item_code - const batchId = result.matched_type === 'batch' ? result.matched_value : undefined - const serialNo = result.matched_type === 'serial' ? result.matched_value : undefined - // Add already awaited in scanBarcode; short delay so store subscribers see the new line before we apply batch/serial - setTimeout(() => { - if (batchId) { - window.dispatchEvent(new CustomEvent('cart:setBatchForItem', { - detail: { itemCode, batchId, forLastAdded: true }, - })) - } else if (serialNo) { - window.dispatchEvent(new CustomEvent('cart:setSerialForItem', { - detail: { itemCode, serialNo, forLastAdded: true }, - })) - } - }, 50) - } + // const handleBarcodeDetected = useCallback(async (barcode: string) => { + // const result = await scanBarcode(barcode) + // if (result) { + // setShowScanner(false) + // if (typeof result === 'object' && result.success && result.item_code) { + // const itemCode = result.item_code + // const batchId = result.matched_type === 'batch' ? result.matched_value : undefined + // const serialNo = result.matched_type === 'serial' ? result.matched_value : undefined + // // Add already awaited in scanBarcode; short delay so store subscribers see the new line before we apply batch/serial + // setTimeout(() => { + // if (batchId) { + // window.dispatchEvent(new CustomEvent('cart:setBatchForItem', { + // detail: { itemCode, batchId, forLastAdded: true }, + // })) + // } else if (serialNo) { + // window.dispatchEvent(new CustomEvent('cart:setSerialForItem', { + // detail: { itemCode, serialNo, forLastAdded: true }, + // })) + // } + // }, 50) + // } + // } + // }, [scanBarcode]) + + // REPLACE this entire handleBarcodeDetected function: + +const handleBarcodeDetected = useCallback(async (barcode: string) => { + const result = await scanBarcode(barcode) + if (result) { + setShowScanner(false) + if (typeof result === 'object' && result.success && result.item_code) { + const itemCode = result.item_code + + // Pull batch + serial from GS1 parsed data first (has both), + // then fall back to matched_type/matched_value for plain barcodes + const batchId = + result.gs1?.lotNumber ?? + (result.matched_type === 'batch' ? result.matched_value : undefined) + + const serialNo = + result.gs1?.serialNumber ?? + (result.matched_type === 'serial' ? result.matched_value : undefined) + + setTimeout(() => { + // Dispatch BOTH — batch first, then serial (no else if) + if (batchId) { + window.dispatchEvent(new CustomEvent('cart:setBatchForItem', { + detail: { itemCode, batchId, forLastAdded: true }, + })) + } + if (serialNo) { + window.dispatchEvent(new CustomEvent('cart:setSerialForItem', { + detail: { itemCode, serialNo, forLastAdded: true }, + })) + } + }, 50) } - }, [scanBarcode]) + } +}, [scanBarcode]) // Handle search input for both product search and barcode scanning const handleSearchInput = (query: string) => { diff --git a/klik_spa/src/hooks/gS1parser.ts b/klik_spa/src/hooks/gS1parser.ts new file mode 100644 index 0000000..047d4a2 --- /dev/null +++ b/klik_spa/src/hooks/gS1parser.ts @@ -0,0 +1,201 @@ +/** + * GS1 DataMatrix / GS1-128 Parser + * + * Handles structured barcodes like: + * 010084014965237717280131 10MP4022 211WNVHXXC68 + * (with or without FNC1 / GS separators) + */ + +export interface GS1ParsedData { + gtin?: string // AI 01 – 14 digits + expiryDate?: string // AI 17 – YYMMDD → parsed to "YYYY-MM-DD" + lotNumber?: string // AI 10 – variable length + serialNumber?: string // AI 21 – variable length + raw: string // original scanned string + isGS1: boolean +} + +/** ASCII group separator used as FNC1 in many scanners */ +const GS = '\x1d' + +/** + * Fixed-length AI definitions (AI → exact field length after the 2-digit AI). + * Variable-length AIs are handled separately. + */ +const FIXED_LENGTH_AI: Record = { + '01': 14, // GTIN + '02': 14, // GTIN of contained trade items + '11': 6, // Production date YYMMDD + '13': 6, // Packaging date YYMMDD + '15': 6, // Best before date YYMMDD + '17': 6, // Expiry date YYMMDD + '31': 8, // Net weight / variable measure + '32': 8, + '33': 8, + '34': 8, + '35': 8, + '36': 8, +} + +/** Variable-length AIs we care about (max length per GS1 spec) */ +const VARIABLE_LENGTH_AI: Record = { + '10': 20, // Batch / Lot + '21': 20, // Serial number + '240': 30, + '241': 30, + '250': 30, + '251': 30, + '253': 30, + '254': 20, + '30': 8, + '37': 8, + '400': 30, + '401': 30, + '402': 17, + '403': 30, + '410': 13, + '411': 13, + '412': 13, + '413': 13, + '414': 13, + '415': 13, + '420': 20, + '421': 15, + '422': 3, + '710': 20, + '711': 20, + '712': 20, + '713': 20, + '714': 20, +} + +function formatExpiryDate(yymmdd: string): string { + if (yymmdd.length !== 6) return yymmdd + const yy = yymmdd.substring(0, 2) + const mm = yymmdd.substring(2, 4) + const dd = yymmdd.substring(4, 6) + const year = parseInt(yy, 10) >= 50 ? `19${yy}` : `20${yy}` + return `${year}-${mm}-${dd}` +} + +/** + * Returns true when the string looks like it could be a GS1 compound code. + * Heuristic: starts with a known 2-digit AI ("01", "10", "17", "21") optionally + * preceded by "]d2" / "]C1" symbology identifiers. + */ +export function looksLikeGS1(code: string): boolean { + // Strip common symbology identifier prefixes + const stripped = code.replace(/^\]([dCeQ][0-9A-Za-z]|d[0-9])/, '') + return /^(01|10|17|21|00|02|11|13|15|30|37|240|241|250|251|253|254|400|401|402|403|410|411|412|413|414|415|420|421|422|710|711|712|713|714)/.test(stripped) +} + +export function parseGS1(raw: string): GS1ParsedData { + const result: GS1ParsedData = { raw, isGS1: false } + + if (!raw || raw.length < 4) return result + + // Strip symbology identifiers like ]d2, ]C1, ]e0, ]Q3 + let data = raw.replace(/^\]([dCeQ][0-9A-Za-z]|d[0-9])/, '') + + // Replace GS (ASCII 29) separators with a placeholder we can work with + data = data.replace(new RegExp(GS, 'g'), GS) + + if (!looksLikeGS1(data)) return result + + result.isGS1 = true + + let pos = 0 + + while (pos < data.length) { + // Skip any GS separator character + if (data[pos] === GS) { + pos++ + continue + } + + // Try 3-digit AI first (some AIs are 3 digits) + let ai = data.substring(pos, pos + 3) + let aiLength = 3 + + // Fall back to 2-digit AI + if (FIXED_LENGTH_AI[ai] === undefined && VARIABLE_LENGTH_AI[ai] === undefined) { + ai = data.substring(pos, pos + 2) + aiLength = 2 + } + + if (!ai || aiLength > data.length - pos) break + + pos += aiLength + + if (FIXED_LENGTH_AI[ai] !== undefined) { + // Fixed-length field + const fieldLen = FIXED_LENGTH_AI[ai] + const value = data.substring(pos, pos + fieldLen) + pos += fieldLen + + switch (ai) { + case '01': result.gtin = value; break + case '17': result.expiryDate = formatExpiryDate(value); break + } + } else if (VARIABLE_LENGTH_AI[ai] !== undefined) { + // Variable-length field: read until GS separator, next AI, or max length + const maxLen = VARIABLE_LENGTH_AI[ai] + const gsPos = data.indexOf(GS, pos) + let end: number + + if (gsPos !== -1 && gsPos - pos <= maxLen) { + end = gsPos + } else { + // Without a GS separator, variable-length field boundaries are + // inherently ambiguous per GS1 spec (GS separators are required). + // + // We resolve this with a whitelist of AIs that are common on product + // labels and whose 2-digit codes are unambiguous (i.e. they won't + // appear as a digit-substring inside a typical lot/serial value): + // 01 GTIN, 11 prod-date, 13 pack-date, 15 best-before, + // 17 expiry, 21 serial + // + // Crucially, we exclude AIs like 400-415, 420-422 whose codes + // are often substrings of alphanumeric lot values (e.g. "40" in "MP4022"). + // + // Scan forward; stop at the FIRST position where one of these + // unambiguous AIs begins and the remaining data satisfies its length. + const BOUNDARY_AIS: Array<[string, number | 'var']> = [ + ['01', 14], ['11', 6], ['13', 6], ['15', 6], ['17', 6], // fixed + ['10', 'var'], ['21', 'var'], ['30', 'var'], ['37', 'var'], // variable + ['240', 'var'], ['241', 'var'], ['250', 'var'], ['251', 'var'], + ] + + end = pos + maxLen + for (let look = pos + 1; look < Math.min(pos + maxLen, data.length - 1); look++) { + if (!/[0-9]/.test(data[look])) continue + + for (const [boundaryAI, len] of BOUNDARY_AIS) { + const slice = data.substring(look, look + boundaryAI.length) + if (slice !== boundaryAI) continue + + const afterAI = look + boundaryAI.length + const remaining = data.length - afterAI + const valid = len === 'var' ? remaining >= 1 : remaining >= len + if (valid) { end = look; break } + } + + if (end !== pos + maxLen) break // found boundary + } + } + + const value = data.substring(pos, end) + pos = end + + switch (ai) { + case '10': result.lotNumber = value; break + case '21': result.serialNumber = value; break + } + } else { + // Unknown AI – skip one character and try to re-sync + pos++ + } + } + + return result +} \ No newline at end of file diff --git a/klik_spa/src/hooks/useBarcodeScanner.ts b/klik_spa/src/hooks/useBarcodeScanner.ts index 3c883cb..69662bc 100644 --- a/klik_spa/src/hooks/useBarcodeScanner.ts +++ b/klik_spa/src/hooks/useBarcodeScanner.ts @@ -1,8 +1,123 @@ +// import { useState } from 'react' +// import { useProducts } from './useProducts' +// import type { MenuItem } from '../../types' + +// export type ScanResult = boolean | { success: true; item_code: string; matched_type?: string; matched_value?: string } + +// interface UseBarcodeScannerReturn { +// scanBarcode: (barcode: string) => Promise +// isScanning: boolean +// error: string | null +// clearError: () => void +// } + +// /** Callback can return a Promise so we wait for the add before applying batch/serial. */ +// export function useBarcodeScanner(onAddToCart: (item: MenuItem) => void | Promise): UseBarcodeScannerReturn { +// const [isScanning, setIsScanning] = useState(false) +// const [error, setError] = useState(null) +// const { products } = useProducts() + +// const clearError = () => setError(null) + +// const scanBarcode = async (barcode: string): Promise => { +// if (!barcode.trim()) { +// setError('Please enter a valid barcode') +// return false +// } + +// setIsScanning(true) +// setError(null) + +// try { +// const foundItem = products.find(item => { +// return item.id === barcode || +// item.name.toLowerCase().includes(barcode.toLowerCase()) +// }) + +// if (foundItem) { +// const addResult = onAddToCart(foundItem) +// if (addResult && typeof (addResult as Promise).then === 'function') { +// await (addResult as Promise) +// } +// return true +// } + +// try { +// const response = await fetch(`/api/method/klik_pos.api.item.get_item_by_identifier?code=${encodeURIComponent(barcode)}`) +// const data = await response.json() + +// if (data.message && data.message.item_code) { +// const item: MenuItem = { +// id: data.message.item_code, +// name: data.message.item_name || data.message.item_code, +// category: data.message.item_group || 'General', +// price: data.message.price || 0, +// available: data.message.available || 0, +// image: data.message.image, +// sold: 0, +// has_batch_no: data.message.has_batch_no, +// has_serial_no: data.message.has_serial_no, +// } +// // Wait for add to finish so the new line is in the cart before we dispatch batch/serial +// const addResult = onAddToCart(item) +// if (addResult && typeof (addResult as Promise).then === 'function') { +// await (addResult as Promise) +// } +// return { +// success: true, +// item_code: data.message.item_code, +// matched_type: data.message.matched_type, +// matched_value: data.message.matched_value, +// } +// } else { +// setError('Product not found for this barcode') +// return false +// } +// } catch (apiError) { +// console.error('API error:', apiError) +// setError('Product not found for this barcode') +// return false +// } +// } catch (err) { +// console.error('Barcode scanning error:', err) +// setError('Error processing barcode') +// return false +// } finally { +// setIsScanning(false) +// } +// } + +// return { +// scanBarcode, +// isScanning, +// error, +// clearError +// } +// } + import { useState } from 'react' import { useProducts } from './useProducts' +import { parseGS1, looksLikeGS1 } from './gS1parser' import type { MenuItem } from '../../types' -export type ScanResult = boolean | { success: true; item_code: string; matched_type?: string; matched_value?: string } +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface GS1ScanMeta { + gtin?: string + expiryDate?: string + lotNumber?: string + serialNumber?: string +} + +export type ScanResult = + | false + | { + success: true + item_code: string + matched_type?: string + matched_value?: string + gs1?: GS1ScanMeta // present when scanned code was a GS1 DataMatrix + } interface UseBarcodeScannerReturn { scanBarcode: (barcode: string) => Promise @@ -11,14 +126,155 @@ interface UseBarcodeScannerReturn { clearError: () => void } -/** Callback can return a Promise so we wait for the add before applying batch/serial. */ -export function useBarcodeScanner(onAddToCart: (item: MenuItem) => void | Promise): UseBarcodeScannerReturn { +// ─── Hook ───────────────────────────────────────────────────────────────────── + +/** + * Enhanced barcode scanner that handles: + * 1. Local product lookup (by id / name) + * 2. Plain barcode / batch / serial via get_item_by_identifier + * 3. GS1 DataMatrix codes (medical / retail packaging) + * → parses GTIN, Expiry, Lot, Serial from the compound string + * → looks up item by GTIN first, then falls back to lot/serial + * → returns lot + serial in the result so the caller can pre-fill + * batch-selection and serial-selection dialogs automatically + */ +export function useBarcodeScanner( + onAddToCart: (item: MenuItem) => void | Promise, +): UseBarcodeScannerReturn { const [isScanning, setIsScanning] = useState(false) - const [error, setError] = useState(null) - const { products } = useProducts() + const [error, setError] = useState(null) + const { products } = useProducts() const clearError = () => setError(null) + // ── helpers ──────────────────────────────────────────────────────────────── + + /** Wait for onAddToCart whether it returns void or a Promise. */ + async function addAndWait(item: MenuItem) { + const r = onAddToCart(item) + if (r && typeof (r as Promise).then === 'function') await r + } + + /** Call the Frappe API and return the message object, or null. */ + async function fetchItemByIdentifier(code: string) { + const url = `/api/method/klik_pos.api.item.get_item_by_identifier?code=${encodeURIComponent(code)}` + const res = await fetch(url) + const data = await res.json() + return data?.message?.item_code ? data.message : null + } + + /** Build a MenuItem from a Frappe API response object. */ + function messageToMenuItem(msg: Record): MenuItem { + return { + id: msg.item_code as string, + name: (msg.item_name as string) || (msg.item_code as string), + category: (msg.item_group as string) || 'General', + price: (msg.price as number) || 0, + available: (msg.available as number) || 0, + image: msg.image as string | undefined, + sold: 0, + has_batch_no: msg.has_batch_no as number | undefined, + has_serial_no: msg.has_serial_no as number | undefined, + } + } + + // ── GS1 path ─────────────────────────────────────────────────────────────── + + /** + * Handle a GS1 DataMatrix scan. + * + * Strategy: + * a) Look up by GTIN (most reliable – it's the item's barcode) + * b) Fall back to Lot number + * c) Fall back to Serial number + * + * Once the item is found, add it to the cart and return lot + serial + * in the result so upstream UI can auto-fill batch / serial dialogs. + */ + async function handleGS1Scan(raw: string): Promise { + const gs1 = parseGS1(raw) + + if (!gs1.isGS1) return false // caller should try plain-barcode path + + const gs1Meta: GS1ScanMeta = { + gtin: gs1.gtin, + expiryDate: gs1.expiryDate, + lotNumber: gs1.lotNumber, + serialNumber: gs1.serialNumber, + } + + // Try identifiers in priority order: GTIN → Lot → Serial + const candidates = [gs1.gtin, gs1.lotNumber, gs1.serialNumber].filter(Boolean) as string[] + + for (const candidate of candidates) { + let msg: Record | null = null + + try { + msg = await fetchItemByIdentifier(candidate) + } catch { + continue + } + + if (!msg) continue + + const item = messageToMenuItem(msg) + await addAndWait(item) + + // If the GS1 code contained a lot/serial that the backend also matched, + // prefer what the backend resolved; otherwise use what we parsed. + const matched_type = (msg.matched_type as string | undefined) ?? (gs1.lotNumber ? 'batch' : gs1.serialNumber ? 'serial' : undefined) + const matched_value = (msg.matched_value as string | undefined) ?? gs1.lotNumber ?? gs1.serialNumber + + return { + success: true, + item_code: item.id, + matched_type, + matched_value, + gs1: gs1Meta, + } + } + + setError('Product not found for this GS1 code') + return false + } + + // ── Plain barcode path ───────────────────────────────────────────────────── + + async function handlePlainScan(barcode: string): Promise { + // 1) Local product list + const foundItem = products.find( + p => p.id === barcode || p.name.toLowerCase().includes(barcode.toLowerCase()), + ) + if (foundItem) { + await addAndWait(foundItem) + return true + } + + // 2) Remote API + try { + const msg = await fetchItemByIdentifier(barcode) + if (!msg) { + setError('Product not found for this barcode') + return false + } + + const item = messageToMenuItem(msg) + await addAndWait(item) + + return { + success: true, + item_code: item.id, + matched_type: msg.matched_type as string | undefined, + matched_value: msg.matched_value as string | undefined, + } + } catch { + setError('Product not found for this barcode') + return false + } + } + + // ── Main entry point ─────────────────────────────────────────────────────── + const scanBarcode = async (barcode: string): Promise => { if (!barcode.trim()) { setError('Please enter a valid barcode') @@ -29,55 +285,14 @@ export function useBarcodeScanner(onAddToCart: (item: MenuItem) => void | Promis setError(null) try { - const foundItem = products.find(item => { - return item.id === barcode || - item.name.toLowerCase().includes(barcode.toLowerCase()) - }) - - if (foundItem) { - const addResult = onAddToCart(foundItem) - if (addResult && typeof (addResult as Promise).then === 'function') { - await (addResult as Promise) - } - return true + // Decide path: GS1 DataMatrix or plain barcode + if (looksLikeGS1(barcode)) { + const gs1Result = await handleGS1Scan(barcode) + if (gs1Result !== false) return gs1Result + // Fall through to plain scan if GS1 parse didn't find an item } - try { - const response = await fetch(`/api/method/klik_pos.api.item.get_item_by_identifier?code=${encodeURIComponent(barcode)}`) - const data = await response.json() - - if (data.message && data.message.item_code) { - const item: MenuItem = { - id: data.message.item_code, - name: data.message.item_name || data.message.item_code, - category: data.message.item_group || 'General', - price: data.message.price || 0, - available: data.message.available || 0, - image: data.message.image, - sold: 0, - has_batch_no: data.message.has_batch_no, - has_serial_no: data.message.has_serial_no, - } - // Wait for add to finish so the new line is in the cart before we dispatch batch/serial - const addResult = onAddToCart(item) - if (addResult && typeof (addResult as Promise).then === 'function') { - await (addResult as Promise) - } - return { - success: true, - item_code: data.message.item_code, - matched_type: data.message.matched_type, - matched_value: data.message.matched_value, - } - } else { - setError('Product not found for this barcode') - return false - } - } catch (apiError) { - console.error('API error:', apiError) - setError('Product not found for this barcode') - return false - } + return await handlePlainScan(barcode) } catch (err) { console.error('Barcode scanning error:', err) setError('Error processing barcode') @@ -87,10 +302,5 @@ export function useBarcodeScanner(onAddToCart: (item: MenuItem) => void | Promis } } - return { - scanBarcode, - isScanning, - error, - clearError - } -} + return { scanBarcode, isScanning, error, clearError } +} \ No newline at end of file