/** * useScanner — camera-capture + Gemini Vision composable. * * Two capture modes, one pipeline: * - processPhoto(file) → barcode/serial extraction (ScanPage, /j) * - scanEquipmentLabel(file) → structured ONT/ONU label (equipment * linking, ClientDetailPage photos) * * Both resize the photo twice: * - 400px for the on-screen thumbnail * - 1600px @ q=0.92 for Gemini (text readability > filesize) * * Resilience (barcode mode only): * If Gemini doesn't answer within SCAN_TIMEOUT_MS (weak LTE, basement, * service cold-start), the photo is queued in IndexedDB via the offline * store and retried in the background. The tech sees a "scan en attente" * chip, keeps scanning the next equipment, and the late result is pushed * back into `barcodes` via a reactive watcher on `offline.scanResults`. * * Equipment-label mode does NOT queue — it's typically invoked on a desktop * or strong wifi (indoor install, office) where the extra complexity of * background retry isn't worth it, and callers want a synchronous answer * (to pre-fill an equipment form). * * Merged from apps/ops/src/composables/useScanner.js (which had the * equipment-label branch) and apps/field/src/composables/useScanner.js * (which had the resilient timeout + offline queue). See * docs/architecture/overview.md §"Legacy Retirement Plan" — field is being folded * into ops at /j and must not lose offline capability in the process. * * @param {object} options * @param {(code: string) => void} [options.onNewCode] — fires for each * newly detected code, whether the scan was synchronous OR delivered * later from the offline queue. Typical use: trigger an ERPNext lookup * and Quasar notify. */ import { ref, watch } from 'vue' import { scanBarcodes, scanEquipmentLabel as apiScanEquipmentLabel } from 'src/api/ocr' import { useOfflineStore } from 'src/stores/offline' const SCAN_TIMEOUT_MS = 8000 export function useScanner (options = {}) { const onNewCode = options.onNewCode || (() => {}) const barcodes = ref([]) // { value, region }[] — max MAX_BARCODES const scanning = ref(false) // true while a Gemini call is in flight const error = ref(null) const lastPhoto = ref(null) // data URI of last thumbnail (400px) const photos = ref([]) // { url, ts, codes, queued }[] — full history // Field's default cap was 3 (phone screen estate); ops historically // allowed 5 (equipment labels have more identifiers). Keep 5 here // since equipment-label mode is an ops-only feature. const MAX_BARCODES = 5 const offline = useOfflineStore() // Pick up any scans that completed while the composable was unmounted // (e.g. tech queued a photo in the basement, phone locked, signal // returned while the page was gone, now they reopen ScanPage). for (const result of offline.scanResults) { mergeCodes(result.barcodes || [], 'queued') offline.consumeScanResult(result.id) } // Watch for sync completions during the lifetime of this scanner. // Vue auto-disposes the watcher when the host component unmounts. watch( () => offline.scanResults.length, () => { for (const result of [...offline.scanResults]) { mergeCodes(result.barcodes || [], 'queued') offline.consumeScanResult(result.id) } } ) function addCode (code, region) { if (barcodes.value.length >= MAX_BARCODES) return false if (barcodes.value.find(b => b.value === code)) return false barcodes.value.push({ value: code, region }) onNewCode(code) return true } function mergeCodes (codes, region) { const added = [] for (const code of codes) { if (addCode(code, region)) added.push(code) } return added } /** * Process a photo for generic barcode/serial extraction. * Resilient: on timeout/network error the photo is queued for retry. */ async function processPhoto (file) { if (!file) return [] error.value = null scanning.value = true let aiImage = null const photoIdx = photos.value.length let found = [] try { const thumbUrl = await resizeImage(file, 400) lastPhoto.value = thumbUrl photos.value.push({ url: thumbUrl, ts: Date.now(), codes: [], queued: false }) // Keep high-res for text readability (small serial fonts). aiImage = await resizeImage(file, 1600, 0.92) const result = await scanBarcodesWithTimeout(aiImage, SCAN_TIMEOUT_MS) found = mergeCodes(result.barcodes || [], 'photo') photos.value[photoIdx].codes = found if (found.length === 0) { error.value = 'Aucun code détecté — rapprochez-vous ou améliorez la mise au point' } } catch (e) { if (aiImage && isRetryable(e)) { await offline.enqueueVisionScan({ image: aiImage }) if (photos.value[photoIdx]) photos.value[photoIdx].queued = true error.value = 'Réseau faible — scan en attente. Reprise automatique au retour du signal.' } else { error.value = e.message || 'Erreur' } } finally { scanning.value = false } return found } /** * Process a photo for structured equipment-label extraction. * * Returns the Gemini response directly: * { brand, model, serial_number, mac_address, gpon_sn, hw_version, * equipment_type, barcodes: string[] } * * Side-effect: pushes `serial_number` + any `barcodes` into the same * `barcodes` ref as processPhoto(), so a UI that uses both modes shares * one list. * * Intentionally NOT resilient (no timeout, no queue) — equipment * linking is a desktop/wifi flow, and callers want a sync answer. */ async function scanEquipmentLabel (file) { if (!file) return null error.value = null scanning.value = true try { const thumbUrl = await resizeImage(file, 400) lastPhoto.value = thumbUrl const aiImage = await resizeImage(file, 1600, 0.92) const data = await apiScanEquipmentLabel(aiImage) if (data?.barcodes?.length) mergeCodes(data.barcodes, 'equipment') if (data?.serial_number) addCode(data.serial_number, 'equipment') if (!data?.serial_number && !data?.barcodes?.length) { error.value = 'Aucun identifiant détecté — rapprochez-vous ou améliorez la mise au point' } return data } catch (e) { error.value = e.message || 'Erreur' return null } finally { scanning.value = false } } /** Race scanBarcodes against a timeout. Used only for barcode mode. */ async function scanBarcodesWithTimeout (image, ms) { return await Promise.race([ scanBarcodes(image), new Promise((_, reject) => setTimeout( () => reject(new Error('ScanTimeout')), ms, )), ]) } /** Retryable = worth queueing in IndexedDB for later. */ function isRetryable (e) { const msg = (e?.message || '').toLowerCase() return msg.includes('scantimeout') || msg.includes('failed to fetch') || msg.includes('networkerror') || msg.includes('load failed') || e?.name === 'TypeError' // fetch throws TypeError on network error } /** Resize a File to a max dimension, return as base64 data URI. */ function resizeImage (file, maxDim, quality = 0.85) { return new Promise((resolve, reject) => { const img = new Image() img.onload = () => { let { width, height } = img if (width > maxDim || height > maxDim) { const ratio = Math.min(maxDim / width, maxDim / height) width = Math.round(width * ratio) height = Math.round(height * ratio) } const canvas = document.createElement('canvas') canvas.width = width canvas.height = height canvas.getContext('2d').drawImage(img, 0, 0, width, height) resolve(canvas.toDataURL('image/jpeg', quality)) } img.onerror = reject img.src = URL.createObjectURL(file) }) } function removeBarcode (value) { barcodes.value = barcodes.value.filter(b => b.value !== value) } function clearBarcodes () { barcodes.value = [] error.value = null lastPhoto.value = null photos.value = [] } return { barcodes, scanning, error, lastPhoto, photos, processPhoto, scanEquipmentLabel, removeBarcode, clearBarcodes, } }