fix(field/ops): restore live camera + multi-barcode scanning at /j/scan

The Apr 22 refactor (41d9b5f) collapsed the tech scanner to Gemini-only
photo capture, dropping the live camera viewport and client-side multi-
barcode detection. Techs lost the fast point-and-scan flow that handles
90% of routine installs.

Restored as a hybrid: html5-qrcode as the primary path (instant, offline,
standard QR/barcode), Gemini kept as a second-chance fallback for hard
labels (damaged stickers, text-only serials, unusual symbologies). Offline
queue + scanEquipmentLabel() preserved unchanged.

Three tabs, defaulting to live camera:
  - Caméra — continuous html5-qrcode stream, detection auto-beeps
  - Photo  — native camera; full-image + 3-strip local scan, Gemini fallback
  - Manuel — plain text input

Both apps/field and apps/ops updated in lockstep so nothing drifts while
apps/field is being folded into apps/ops/j.

Run `npm install` in apps/ops/ to pull in html5-qrcode before the next build.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-04-22 13:22:36 -04:00
parent beb6ddc5e5
commit 90f5f2eaa0
5 changed files with 675 additions and 211 deletions

View File

@ -2,43 +2,61 @@ import { ref, watch } from 'vue'
import { scanBarcodes } from 'src/api/ocr'
import { useOfflineStore } from 'src/stores/offline'
const SCAN_TIMEOUT_MS = 8000
const GEMINI_TIMEOUT_MS = 8000
const MAX_CODES = 3
/**
* Barcode scanner using device camera photo capture + Gemini Vision AI.
* Hybrid barcode scanner for field techs.
*
* Strategy: Use <input type="file" capture="environment"> which triggers
* the native camera app this gives proper autofocus, tap-to-focus,
* and high-res photos. Then send to Gemini Vision for barcode extraction.
* Three modes, all writing into the same `barcodes` array (max 3):
*
* Resilience: if Gemini doesn't answer within SCAN_TIMEOUT_MS (weak LTE),
* the photo is queued in IndexedDB via the offline store and retried when
* the signal comes back. The tech gets a "scan en attente" indicator and
* can keep working; late results are delivered via onNewCode().
* 1. `startLive(elId)` continuous camera stream via html5-qrcode.
* Instant, offline-capable, works for standard QR / Code128 / EAN.
* This is the DEFAULT and fastest path.
*
* 2. `processPhoto(file)` take a picture, scan it for codes.
* Tries html5-qrcode locally first (full image + 3 horizontal strips,
* catches up to 3 barcodes per photo, offline). If nothing is found
* AND we're online, falls back to Gemini Vision which can read weird
* stuff html5-qrcode misses: damaged stickers, multi-line serials
* printed as text, unusual barcode symbologies.
* If Gemini times out on weak LTE, the photo is queued in IndexedDB
* and retried when signal returns late results come back through
* `onNewCode` via the watcher on offline.scanResults.
*
* 3. Manual entry handled by the caller, just appends via addCode().
*
* This was rewritten in 2026-04-22 after an earlier refactor replaced all
* local scanning with Gemini-only. Live camera matters for field techs:
* point-and-scan is 10× faster than take-photo-wait-for-AI on a normal QR.
* Gemini is kept as a second chance for the hard cases.
*
* @param {object} options
* @param {(code: string) => void} [options.onNewCode] called for each
* newly detected code, whether the scan was synchronous or delivered
* later from the offline queue. Typically used to trigger lookup + notify.
* @param {(code: string) => void} [options.onNewCode] fires for each
* newly added code whether it came from live, photo, queue, or manual.
* Typically used to trigger lookup + toast.
*/
export function useScanner (options = {}) {
const onNewCode = options.onNewCode || (() => {})
const barcodes = ref([]) // Array of { value, region } — max 3
const scanning = ref(false) // true while Gemini is processing
const barcodes = ref([]) // [{ value, region }], max MAX_CODES
const scanning = ref(false) // true while a photo/AI call is in-flight
const live = ref(false) // true while the live camera is running
const error = ref(null)
const lastPhoto = ref(null) // data URI of last captured photo (thumbnail)
const photos = ref([]) // all captured photo thumbnails
const lastPhoto = ref(null) // thumbnail data URI
const photos = ref([]) // [{ url, ts, codes, queued }]
const offline = useOfflineStore()
let _html5 = null // Html5Qrcode instance for live mode
// Pick up any scans that completed while the page was unmounted (e.g. tech
// queued a photo, locked phone, walked out of the basement, signal returns).
// Pick up any Gemini scans that completed while the page was unmounted
// (tech queued a photo, locked phone, walked out of the basement, signal
// returned, hub replied, we stored the result in IndexedDB).
for (const result of offline.scanResults) {
mergeCodes(result.barcodes || [], 'queued')
offline.consumeScanResult(result.id)
}
// Watch for sync completions during the lifetime of this scanner.
// Watch for sync completions during this scanner's lifetime.
// Vue auto-disposes the watcher when the host component unmounts.
watch(
() => offline.scanResults.length,
@ -51,10 +69,12 @@ export function useScanner (options = {}) {
)
function addCode (code, region) {
if (barcodes.value.length >= 3) return false
if (barcodes.value.find(b => b.value === code)) return false
barcodes.value.push({ value: code, region })
onNewCode(code)
const trimmed = String(code || '').trim()
if (!trimmed) return false
if (barcodes.value.length >= MAX_CODES) return false
if (barcodes.value.find(b => b.value === trimmed)) return false
barcodes.value.push({ value: trimmed, region })
onNewCode(trimmed)
return true
}
@ -66,44 +86,109 @@ export function useScanner (options = {}) {
return added
}
// -------------------------------------------------------------------------
// 1. Live camera — html5-qrcode continuous stream
// -------------------------------------------------------------------------
/**
* Start the live scanner in the given DOM element.
* Safe to call again after stopLive() recreates the instance.
*
* @param {string} elementId id of a div to mount the camera view into.
* Must already be in the DOM when this is called.
*/
async function startLive (elementId) {
if (live.value) return
error.value = null
try {
const { Html5Qrcode } = await import('html5-qrcode')
_html5 = new Html5Qrcode(elementId, { verbose: false })
await _html5.start(
{ facingMode: 'environment' },
{
fps: 10,
// Wider aimbox so tech doesn't have to line up perfectly.
qrbox: (w, h) => {
const side = Math.floor(Math.min(w, h) * 0.7)
return { width: side, height: Math.floor(side * 0.6) }
},
},
(decoded) => addCode(decoded, 'caméra'),
() => { /* per-frame decode miss — ignore, this is noisy by design */ },
)
live.value = true
} catch (e) {
error.value = e?.message || 'Caméra non disponible'
live.value = false
_html5 = null
}
}
async function stopLive () {
try {
if (_html5?.isScanning) await _html5.stop()
_html5?.clear()
} catch { /* ignore — teardown races are fine */ }
_html5 = null
live.value = false
}
// -------------------------------------------------------------------------
// 2. Photo scan — html5-qrcode multi-strip, Gemini fallback
// -------------------------------------------------------------------------
/**
* Process a photo file from camera input.
* Resizes for AI, keeps thumbnail, sends to Gemini with an 8s timeout.
* On timeout/failure, the photo is queued for background retry.
*
* Pass 1: html5-qrcode on the full image (cheap, offline).
* Pass 2: split into 3 horizontal strips, scan each catches stickers
* that contain multiple barcodes stacked vertically (common on
* modems: SN, MAC, GPON SN each on their own line).
* Pass 3: if still empty AND online, send to Gemini for OCR-based read.
* If Gemini times out (weak signal), queue for background retry.
*/
async function processPhoto (file) {
if (!file) return []
error.value = null
scanning.value = true
let aiImage = null
const found = []
const photoIdx = photos.value.length
let found = []
let aiImage = null
try {
// Create thumbnail for display (small)
// Thumbnail for the UI
const thumbUrl = await resizeImage(file, 400)
lastPhoto.value = thumbUrl
photos.value.push({ url: thumbUrl, ts: Date.now(), codes: [], queued: false })
// Create optimized image for AI — keep high res for text readability
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
// --- Pass 1 + 2: local html5-qrcode on full image + strips ---
const localCodes = await scanPhotoLocally(file)
found.push(...mergeCodes(localCodes, 'photo'))
// --- Pass 3: Gemini fallback if local scan came up empty ---
if (found.length === 0) {
aiImage = await resizeImage(file, 1600, 0.92)
try {
const result = await scanBarcodesWithTimeout(aiImage, GEMINI_TIMEOUT_MS)
found.push(...mergeCodes(result.barcodes || [], 'IA'))
} catch (e) {
if (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 {
throw e
}
}
}
if (photos.value[photoIdx]) photos.value[photoIdx].codes = found
if (found.length === 0 && !error.value) {
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'
}
error.value = e?.message || 'Erreur'
} finally {
scanning.value = false
}
@ -111,6 +196,69 @@ export function useScanner (options = {}) {
return found
}
/**
* Local pass: html5-qrcode on full image + 3 horizontal strips.
* Returns an array of unique decoded strings. Always safe to call
* errors at any stage are swallowed and produce an empty result.
*/
async function scanPhotoLocally (file) {
const found = new Set()
let scratch = document.getElementById('scanner-scratch')
if (!scratch) {
scratch = document.createElement('div')
scratch.id = 'scanner-scratch'
scratch.style.display = 'none'
document.body.appendChild(scratch)
}
let scanner
try {
const { Html5Qrcode } = await import('html5-qrcode')
scanner = new Html5Qrcode('scanner-scratch', { verbose: false })
// Full image first
try {
const r = await scanner.scanFileV2(file, false)
if (r?.decodedText) found.add(r.decodedText)
} catch { /* no code on full image — try strips */ }
// Split into 3 strips and scan each — only if we still have room
if (found.size < MAX_CODES) {
const img = await createImageBitmap(file)
try {
const { width, height } = img
const strips = [
{ y: 0, h: Math.floor(height / 3), label: 'haut' },
{ y: Math.floor(height / 3), h: Math.floor(height / 3), label: 'milieu' },
{ y: Math.floor(height * 2 / 3), h: height - Math.floor(height * 2 / 3), label: 'bas' },
]
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
for (const strip of strips) {
if (found.size >= MAX_CODES) break
canvas.width = width
canvas.height = strip.h
ctx.drawImage(img, 0, strip.y, width, strip.h, 0, 0, width, strip.h)
try {
const blob = await new Promise(r => canvas.toBlob(r, 'image/jpeg', 0.9))
const stripFile = new File([blob], 'strip.jpg', { type: 'image/jpeg' })
const r = await scanner.scanFileV2(stripFile, false)
if (r?.decodedText) found.add(r.decodedText)
} catch { /* strip miss — continue */ }
}
} finally {
img.close()
}
}
} catch { /* library load / instantiation failure — caller falls back to Gemini */ }
finally {
try { scanner?.clear() } catch { /* ignore */ }
}
return Array.from(found)
}
async function scanBarcodesWithTimeout (image, ms) {
return await Promise.race([
scanBarcodes(image),
@ -130,9 +278,11 @@ export function useScanner (options = {}) {
|| e?.name === 'TypeError' // fetch throws TypeError on network error
}
/**
* Resize an image file to a max dimension, return as base64 data URI.
*/
// -------------------------------------------------------------------------
// Utilities
// -------------------------------------------------------------------------
/** Resize an image 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()
@ -166,7 +316,15 @@ export function useScanner (options = {}) {
}
return {
barcodes, scanning, error, lastPhoto, photos,
processPhoto, removeBarcode, clearBarcodes,
// state
barcodes, scanning, live, error, lastPhoto, photos,
// live camera
startLive, stopLive,
// photo scan
processPhoto,
// shared
addCode, removeBarcode, clearBarcodes,
// constants
MAX_CODES,
}
}

View File

@ -17,55 +17,99 @@
</q-card-section>
</q-card>
<!-- Camera capture button -->
<div class="text-center">
<!-- Mode tabs: Caméra / Photo IA / Manuel -->
<q-tabs v-model="mode" dense no-caps active-color="primary" indicator-color="primary" align="justify"
class="q-mb-md" @update:model-value="onModeChange">
<q-tab name="live" icon="videocam" label="Caméra" />
<q-tab name="photo" icon="photo_camera" label="Photo" />
<q-tab name="manual" icon="keyboard" label="Manuel" />
</q-tabs>
<!-- ==== Live camera mode (default, fastest) ==== -->
<div v-show="mode === 'live'" class="live-wrap">
<div id="live-reader" class="live-reader" />
<div class="text-center q-mt-sm">
<q-btn v-if="!scanner.live.value" color="primary" icon="play_arrow"
label="Démarrer la caméra" @click="startLive" unelevated />
<q-btn v-else color="negative" icon="stop" label="Arrêter" @click="scanner.stopLive()" outline />
</div>
<div v-if="scanner.live.value" class="text-caption text-center text-grey q-mt-xs">
Cadrez un code-barres détection automatique
</div>
</div>
<!-- ==== Photo mode (native camera + local scan + Gemini fallback) ==== -->
<div v-show="mode === 'photo'" class="text-center">
<q-btn
color="primary" icon="photo_camera" label="Scanner"
color="primary" icon="photo_camera" label="Prendre une photo"
size="lg" rounded unelevated
@click="takePhoto"
:loading="scanner.scanning.value"
class="q-px-xl"
/>
<input ref="cameraInput" type="file" accept="image/*" capture="environment" class="hidden" @change="onPhoto" />
</div>
<div class="text-caption text-grey q-mt-sm">
Jusqu'à 3 codes par photo · IA activée si la lecture locale échoue
</div>
<!-- Pending scan indicator (signal faible) -->
<div v-if="offline.pendingVisionCount > 0" class="text-center q-mt-sm">
<q-chip icon="wifi_tethering_off" color="orange" text-color="white" dense clickable @click="offline.syncVisionQueue()">
{{ offline.pendingVisionCount }} scan{{ offline.pendingVisionCount > 1 ? 's' : '' }} en attente · toucher pour réessayer
</q-chip>
</div>
<!-- Pending scan indicator (signal faible) -->
<div v-if="offline.pendingVisionCount > 0" class="text-center q-mt-sm">
<q-chip icon="wifi_tethering_off" color="orange" text-color="white" dense clickable
@click="offline.syncVisionQueue()">
{{ offline.pendingVisionCount }} scan{{ offline.pendingVisionCount > 1 ? 's' : '' }}
en attente · toucher pour réessayer
</q-chip>
</div>
<!-- Last captured photo (thumbnail) -->
<div v-if="scanner.lastPhoto.value" class="photo-preview q-mt-md">
<img :src="scanner.lastPhoto.value" class="preview-img" @click="showFullPhoto = true" />
<div v-if="scanner.scanning.value" class="preview-overlay">
<q-spinner-dots size="32px" color="white" />
<div class="text-white text-caption q-mt-xs">Analyse Gemini...</div>
<!-- Last captured photo (thumbnail) -->
<div v-if="scanner.lastPhoto.value" class="photo-preview q-mt-md">
<img :src="scanner.lastPhoto.value" class="preview-img" @click="showFullPhoto = true" />
<div v-if="scanner.scanning.value" class="preview-overlay">
<q-spinner-dots size="32px" color="white" />
<div class="text-white text-caption q-mt-xs">Analyse...</div>
</div>
</div>
<!-- Photo history (small thumbnails) -->
<div v-if="scanner.photos.value.length > 1" class="q-mt-md">
<div class="text-caption text-grey q-mb-xs">Photos capturées</div>
<div class="row q-gutter-xs">
<div v-for="(p, i) in scanner.photos.value" :key="i" class="photo-thumb" @click="viewPhoto(p)">
<img :src="p.url" />
<q-badge v-if="p.codes.length" color="green" floating :label="p.codes.length" />
</div>
</div>
</div>
</div>
<!-- Error / status -->
<!-- ==== Manual entry ==== -->
<div v-show="mode === 'manual'">
<q-input v-model="manualCode" label="Saisie manuelle SN / MAC / code-barres"
outlined dense autofocus @keyup.enter="addManual">
<template v-slot:prepend><q-icon name="keyboard" /></template>
<template v-slot:append>
<q-btn flat dense icon="add" :disable="!manualCode.trim()" @click="addManual" />
</template>
</q-input>
</div>
<!-- ==== Error / status (all modes) ==== -->
<div v-if="scanner.error.value" class="text-caption text-center q-mt-sm text-negative">
{{ scanner.error.value }}
</div>
<!-- Manual entry -->
<q-input v-model="manualCode" label="Saisie manuelle SN / MAC" outlined dense class="q-mt-md"
@keyup.enter="addManual">
<template v-slot:append>
<q-btn flat dense icon="add" @click="addManual" :disable="!manualCode.trim()" />
</template>
</q-input>
<!-- Scanned barcodes -->
<!-- ==== Scanned barcodes (shared across all modes) ==== -->
<div v-if="scanner.barcodes.value.length > 0" class="q-mt-md">
<div class="text-subtitle2 q-mb-xs">Codes détectés ({{ scanner.barcodes.value.length }}/3)</div>
<div class="row items-center q-mb-xs">
<div class="text-subtitle2 col">Codes détectés ({{ scanner.barcodes.value.length }}/{{ scanner.MAX_CODES }})</div>
<q-btn flat dense size="sm" icon="clear_all" label="Effacer" @click="scanner.clearBarcodes()" />
</div>
<q-card v-for="bc in scanner.barcodes.value" :key="bc.value" class="q-mb-sm">
<q-card-section class="q-py-sm row items-center no-wrap">
<q-icon name="qr_code" class="q-mr-sm" color="primary" />
<div class="col">
<div class="text-subtitle2 mono">{{ bc.value }}</div>
<div class="text-caption text-grey">{{ bc.region }}</div>
</div>
<q-btn flat dense icon="search" @click="lookupDevice(bc.value)" :loading="lookingUp === bc.value" />
<q-btn flat dense icon="close" color="negative" @click="scanner.removeBarcode(bc.value)" />
@ -86,7 +130,7 @@
<q-btn flat dense size="sm" color="orange" label="Lier à un service" icon="link"
@click="openLinkDialog(bc.value, lookupResults[bc.value].equipment)" />
</div>
<div v-else class="text-caption text-green q-mt-xs">
<div v-else-if="lookupResults[bc.value].equipment.service_location" class="text-caption text-green q-mt-xs">
<q-icon name="check_circle" size="xs" class="q-mr-xs" />
{{ lookupResults[bc.value].equipment.service_location }}
</div>
@ -102,17 +146,6 @@
</q-card>
</div>
<!-- Photo history (small thumbnails) -->
<div v-if="scanner.photos.value.length > 1" class="q-mt-md">
<div class="text-caption text-grey q-mb-xs">Photos capturées</div>
<div class="row q-gutter-xs">
<div v-for="(p, i) in scanner.photos.value" :key="i" class="photo-thumb" @click="viewPhoto(p)">
<img :src="p.url" />
<q-badge v-if="p.codes.length" color="green" floating :label="p.codes.length" />
</div>
</div>
</div>
<!-- Link all to account (manual, when no job context) -->
<div v-if="scanner.barcodes.value.length > 0 && !jobContext && hasUnlinked" class="q-mt-sm">
<q-btn color="orange" icon="link" label="Lier les équipements à un service"
@ -202,7 +235,7 @@
</template>
<script setup>
import { ref, computed } from 'vue'
import { ref, computed, onBeforeUnmount, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import { useScanner } from 'src/composables/useScanner'
import { listDocs, createDoc, updateDoc } from 'src/api/erp'
@ -218,6 +251,8 @@ const scanner = useScanner({
},
})
// Default to live camera that's what techs use 90% of the time.
const mode = ref('live')
const cameraInput = ref(null)
const manualCode = ref('')
const lookingUp = ref(null)
@ -258,10 +293,30 @@ const hasUnlinked = computed(() =>
})
)
// --- Camera ---
// --- Mode switch: stop the camera when leaving Live, pause auto-restart ---
function onModeChange (newMode) {
if (newMode !== 'live' && scanner.live.value) scanner.stopLive()
}
// Make sure we don't leak the camera when the page unmounts.
onBeforeUnmount(() => {
if (scanner.live.value) scanner.stopLive()
})
// --- Live camera ---
async function startLive () {
// The reader element is inside v-show it's in the DOM from first render.
// nextTick just in case something upstream hid it.
await nextTick()
await scanner.startLive('live-reader')
}
// --- Photo camera ---
function takePhoto () {
// Reset the input so same file triggers change
// Reset the input so the same file triggers change
if (cameraInput.value) cameraInput.value.value = ''
cameraInput.value?.click()
}
@ -285,15 +340,16 @@ function viewPhoto (photo) {
function addManual () {
const code = manualCode.value.trim()
if (!code) return
if (scanner.barcodes.value.length >= 3) {
Notify.create({ type: 'warning', message: 'Maximum 3 codes' })
if (scanner.barcodes.value.length >= scanner.MAX_CODES) {
Notify.create({ type: 'warning', message: `Maximum ${scanner.MAX_CODES} codes` })
return
}
if (!scanner.barcodes.value.find(b => b.value === code)) {
scanner.barcodes.value.push({ value: code, region: 'manuel' })
lookupDevice(code)
if (scanner.addCode(code, 'manuel')) {
manualCode.value = ''
} else {
Notify.create({ type: 'info', message: 'Code déjà ajouté' })
manualCode.value = ''
}
manualCode.value = ''
}
// --- Device lookup ---
@ -496,6 +552,25 @@ async function linkDeviceToService () {
padding-bottom: 16px !important;
}
.live-wrap {
max-width: 420px;
margin: 0 auto;
}
.live-reader {
width: 100%;
min-height: 280px;
border-radius: 12px;
overflow: hidden;
background: #000;
// html5-qrcode injects a <video> make it fill the container
:deep(video) {
width: 100% !important;
height: auto !important;
display: block;
}
}
.photo-preview {
position: relative;
text-align: center;
@ -532,4 +607,8 @@ async function linkDeviceToService () {
object-fit: cover;
}
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
</style>

View File

@ -14,6 +14,7 @@
"@twilio/voice-sdk": "^2.18.1",
"chart.js": "^4.5.1",
"cytoscape": "^3.33.2",
"html5-qrcode": "^2.3.8",
"idb-keyval": "^6.2.1",
"lucide-vue-next": "^1.0.0",
"pinia": "^2.1.7",

View File

@ -1,37 +1,45 @@
/**
* useScanner camera-capture + Gemini Vision composable.
* useScanner hybrid camera + barcode composable for field techs.
*
* Two capture modes, one pipeline:
* - processPhoto(file) barcode/serial extraction (ScanPage, /j)
* - scanEquipmentLabel(file) structured ONT/ONU label (equipment
* linking, ClientDetailPage photos)
* Three capture modes, one shared `barcodes` array:
*
* Both resize the photo twice:
* - 400px for the on-screen thumbnail
* - 1600px @ q=0.92 for Gemini (text readability > filesize)
* 1. Live camera (startLive / stopLive)
* html5-qrcode continuous stream. INSTANT, OFFLINE, and the fastest
* path for normal QR codes on modem stickers. This is the default on
* the /j/scan page techs point, scan, done.
*
* 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`.
* 2. Photo scan (processPhoto)
* Take a picture with the native camera, then:
* Pass 1 html5-qrcode on the full image (cheap, offline)
* Pass 2 split into 3 horizontal strips, scan each catches up
* to 3 stacked barcodes on a single sticker (SN + MAC +
* GPON SN is a common layout)
* Pass 3 if still empty AND online, fall back to Gemini Vision
* which can read damaged stickers, multi-line text-only
* serials, and unusual symbologies
* If Gemini times out on weak LTE, photo is queued in IndexedDB
* via the offline store. Late results come back through
* `onNewCode` via the 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).
* 3. Equipment label scan (scanEquipmentLabel)
* Dedicated Gemini-only path for structured ONT/ONU label OCR.
* Used from ClientDetailPage/equipment-linking flows where the caller
* wants a sync answer ({brand, model, serial, mac, gpon_sn, ...})
* not resilient, no queue, desktop/wifi only.
*
* 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.
* History: this file originally lived in apps/field. In 2026-04-22 the
* field scanner was briefly replaced with Gemini-only capture, losing
* the live camera. This version restores live + multi-strip as the fast
* primary path and keeps Gemini as a second-chance fallback.
*
* See docs/architecture/overview.md §"Legacy Retirement Plan" apps/field
* is being folded into apps/ops at /j and must not lose offline capability
* or live scanning 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
* newly added code. Fires for live scans, photo scans, queued retries,
* and equipment-label scans. Typical use: trigger an ERPNext lookup
* and Quasar notify.
*/
@ -39,27 +47,25 @@ 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
const GEMINI_TIMEOUT_MS = 8000
const MAX_BARCODES = 5 // equipment labels have more identifiers than field cap of 3
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 scanning = ref(false) // true while a photo/AI call is in flight
const live = ref(false) // true while the live camera is running
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()
let _html5 = null // Html5Qrcode live instance
// 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).
// Pick up any Gemini scans that completed while the composable was unmounted
// (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)
@ -78,10 +84,12 @@ export function useScanner (options = {}) {
)
function addCode (code, region) {
const trimmed = String(code || '').trim()
if (!trimmed) return false
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)
if (barcodes.value.find(b => b.value === trimmed)) return false
barcodes.value.push({ value: trimmed, region })
onNewCode(trimmed)
return true
}
@ -93,42 +101,105 @@ export function useScanner (options = {}) {
return added
}
// -------------------------------------------------------------------------
// 1. Live camera
// -------------------------------------------------------------------------
/**
* Start the live scanner in the given DOM element.
* Safe to call again after stopLive(). Element must exist in the DOM.
*/
async function startLive (elementId) {
if (live.value) return
error.value = null
try {
const { Html5Qrcode } = await import('html5-qrcode')
_html5 = new Html5Qrcode(elementId, { verbose: false })
await _html5.start(
{ facingMode: 'environment' },
{
fps: 10,
// Wider aimbox so tech doesn't have to line up perfectly.
qrbox: (w, h) => {
const side = Math.floor(Math.min(w, h) * 0.7)
return { width: side, height: Math.floor(side * 0.6) }
},
},
(decoded) => addCode(decoded, 'caméra'),
() => { /* per-frame decode miss — ignore, this is noisy by design */ },
)
live.value = true
} catch (e) {
error.value = e?.message || 'Caméra non disponible'
live.value = false
_html5 = null
}
}
async function stopLive () {
try {
if (_html5?.isScanning) await _html5.stop()
_html5?.clear()
} catch { /* ignore — teardown races are fine */ }
_html5 = null
live.value = false
}
// -------------------------------------------------------------------------
// 2. Photo scan — html5-qrcode local, Gemini fallback, offline queue
// -------------------------------------------------------------------------
/**
* Process a photo for generic barcode/serial extraction.
* Resilient: on timeout/network error the photo is queued for retry.
*
* Three-pass strategy:
* 1. html5-qrcode on full image (offline, fast)
* 2. html5-qrcode on 3 horizontal strips (multi-barcode)
* 3. Gemini Vision fallback if local found nothing AND we're online
*
* Resilient: if Gemini times out the photo is queued for retry.
*/
async function processPhoto (file) {
if (!file) return []
error.value = null
scanning.value = true
let aiImage = null
const found = []
const photoIdx = photos.value.length
let found = []
let aiImage = null
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
// --- Pass 1 + 2: local html5-qrcode on full image + strips ---
const localCodes = await scanPhotoLocally(file)
found.push(...mergeCodes(localCodes, 'photo'))
// --- Pass 3: Gemini fallback if local scan came up empty ---
if (found.length === 0) {
aiImage = await resizeImage(file, 1600, 0.92)
try {
const result = await scanBarcodesWithTimeout(aiImage, GEMINI_TIMEOUT_MS)
found.push(...mergeCodes(result.barcodes || [], 'IA'))
} catch (e) {
if (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 {
throw e
}
}
}
if (photos.value[photoIdx]) photos.value[photoIdx].codes = found
if (found.length === 0 && !error.value) {
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'
}
error.value = e?.message || 'Erreur'
} finally {
scanning.value = false
}
@ -136,6 +207,72 @@ export function useScanner (options = {}) {
return found
}
/**
* Local pass: html5-qrcode on full image + 3 horizontal strips.
* Returns unique decoded strings. All failures swallowed empty array.
*/
async function scanPhotoLocally (file) {
const found = new Set()
let scratch = document.getElementById('scanner-scratch')
if (!scratch) {
scratch = document.createElement('div')
scratch.id = 'scanner-scratch'
scratch.style.display = 'none'
document.body.appendChild(scratch)
}
let scanner
try {
const { Html5Qrcode } = await import('html5-qrcode')
scanner = new Html5Qrcode('scanner-scratch', { verbose: false })
// Full image first
try {
const r = await scanner.scanFileV2(file, false)
if (r?.decodedText) found.add(r.decodedText)
} catch { /* no code on full image — try strips */ }
// Split into 3 horizontal strips — catches stacked barcodes
if (found.size < MAX_BARCODES) {
const img = await createImageBitmap(file)
try {
const { width, height } = img
const strips = [
{ y: 0, h: Math.floor(height / 3), label: 'haut' },
{ y: Math.floor(height / 3), h: Math.floor(height / 3), label: 'milieu' },
{ y: Math.floor(height * 2 / 3), h: height - Math.floor(height * 2 / 3), label: 'bas' },
]
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
for (const strip of strips) {
if (found.size >= MAX_BARCODES) break
canvas.width = width
canvas.height = strip.h
ctx.drawImage(img, 0, strip.y, width, strip.h, 0, 0, width, strip.h)
try {
const blob = await new Promise(r => canvas.toBlob(r, 'image/jpeg', 0.9))
const stripFile = new File([blob], 'strip.jpg', { type: 'image/jpeg' })
const r = await scanner.scanFileV2(stripFile, false)
if (r?.decodedText) found.add(r.decodedText)
} catch { /* strip miss — continue */ }
}
} finally {
img.close()
}
}
} catch { /* lib load / instantiation failure — caller falls back to Gemini */ }
finally {
try { scanner?.clear() } catch { /* ignore */ }
}
return Array.from(found)
}
// -------------------------------------------------------------------------
// 3. Equipment label scan (ops-only, desktop/wifi, structured Gemini output)
// -------------------------------------------------------------------------
/**
* Process a photo for structured equipment-label extraction.
*
@ -175,7 +312,10 @@ export function useScanner (options = {}) {
}
}
/** Race scanBarcodes against a timeout. Used only for barcode mode. */
// -------------------------------------------------------------------------
// Utilities
// -------------------------------------------------------------------------
async function scanBarcodesWithTimeout (image, ms) {
return await Promise.race([
scanBarcodes(image),
@ -186,7 +326,6 @@ export function useScanner (options = {}) {
])
}
/** Retryable = worth queueing in IndexedDB for later. */
function isRetryable (e) {
const msg = (e?.message || '').toLowerCase()
return msg.includes('scantimeout')
@ -196,7 +335,6 @@ export function useScanner (options = {}) {
|| 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()
@ -230,7 +368,15 @@ export function useScanner (options = {}) {
}
return {
barcodes, scanning, error, lastPhoto, photos,
processPhoto, scanEquipmentLabel, removeBarcode, clearBarcodes,
// state
barcodes, scanning, live, error, lastPhoto, photos,
// live camera
startLive, stopLive,
// photo scans
processPhoto, scanEquipmentLabel,
// shared
addCode, removeBarcode, clearBarcodes,
// constants
MAX_BARCODES,
}
}

View File

@ -1,19 +1,19 @@
<!--
TechScanPage camera-based equipment scanner for field techs at /j/scan.
What it does in one sentence: the tech points their phone at an ONT/router
label, Gemini reads the serial, and we look it up in ERPNext, optionally
auto-linking the equipment to the tech's current Dispatch Job.
Three capture modes (useScanner docs in src/composables/useScanner.js):
Caméra (live) default, html5-qrcode continuous stream, instant + offline
Photo native camera, local multi-strip + Gemini fallback, offline-queued
Manuel plain text input, for bent/unreadable stickers
ERPNext relationships touched (see docs/features/vision-ocr.md §10 for the full
data-model diagram):
ERPNext relationships touched (see docs/features/vision-ocr.md §10):
Dispatch Job Customer Service Location Service Equipment
(serial_number, barcode,
mac_address, status)
The tech arrives via `/j/scan?job=JOB-001&customer=CUST-123&location=LOC-456`
when the scanned serial matches an unlinked Service Equipment row, we
when a scanned serial matches an unlinked Service Equipment row, we
auto-patch customer + service_location on that row so the device is
provably tied to that install address before the tech leaves.
@ -21,11 +21,11 @@
same Customer + Service Location, so the scan result flows through
naturally for any downstream ticket view.
Ported from apps/field/src/pages/ScanPage.vue during the fieldops
unification (see docs/architecture/overview.md §"Legacy Retirement Plan"). Adapted
for the ops router:
- device-detail route name: 'tech-device' (was 'device' in field)
- same query-param contract from TechJobDetailPage.goScan()
History: this page started as apps/field/src/pages/ScanPage.vue (live + multi-
strip), was briefly reduced to Gemini-only (Apr 2026), and is now hybrid.
Live camera is the default because pointing-and-scanning is 10× faster than
take-photo-wait-for-AI on a normal QR code. Photo+Gemini is kept as a
second chance for damaged or text-only labels.
-->
<template>
<q-page padding class="scan-page">
@ -46,55 +46,99 @@
</q-card-section>
</q-card>
<!-- Camera capture button -->
<div class="text-center">
<!-- Mode tabs: Caméra / Photo / Manuel -->
<q-tabs v-model="mode" dense no-caps active-color="primary" indicator-color="primary" align="justify"
class="q-mb-md" @update:model-value="onModeChange">
<q-tab name="live" icon="videocam" label="Caméra" />
<q-tab name="photo" icon="photo_camera" label="Photo" />
<q-tab name="manual" icon="keyboard" label="Manuel" />
</q-tabs>
<!-- ==== Live camera mode (default, fastest) ==== -->
<div v-show="mode === 'live'" class="live-wrap">
<div id="live-reader" class="live-reader" />
<div class="text-center q-mt-sm">
<q-btn v-if="!scanner.live.value" color="primary" icon="play_arrow"
label="Démarrer la caméra" @click="startLive" unelevated />
<q-btn v-else color="negative" icon="stop" label="Arrêter" @click="scanner.stopLive()" outline />
</div>
<div v-if="scanner.live.value" class="text-caption text-center text-grey q-mt-xs">
Cadrez un code-barres détection automatique
</div>
</div>
<!-- ==== Photo mode (native camera + local scan + Gemini fallback) ==== -->
<div v-show="mode === 'photo'" class="text-center">
<q-btn
color="primary" icon="photo_camera" label="Scanner"
color="primary" icon="photo_camera" label="Prendre une photo"
size="lg" rounded unelevated
@click="takePhoto"
:loading="scanner.scanning.value"
class="q-px-xl"
/>
<input ref="cameraInput" type="file" accept="image/*" capture="environment" class="hidden" @change="onPhoto" />
</div>
<div class="text-caption text-grey q-mt-sm">
Jusqu'à {{ scanner.MAX_BARCODES }} codes par photo · IA activée si la lecture locale échoue
</div>
<!-- Pending scan indicator (signal faible queue offline) -->
<div v-if="offline.pendingVisionCount > 0" class="text-center q-mt-sm">
<q-chip icon="wifi_tethering_off" color="orange" text-color="white" dense clickable @click="offline.syncVisionQueue()">
{{ offline.pendingVisionCount }} scan{{ offline.pendingVisionCount > 1 ? 's' : '' }} en attente · toucher pour réessayer
</q-chip>
</div>
<!-- Pending scan indicator (signal faible queue offline) -->
<div v-if="offline.pendingVisionCount > 0" class="text-center q-mt-sm">
<q-chip icon="wifi_tethering_off" color="orange" text-color="white" dense clickable
@click="offline.syncVisionQueue()">
{{ offline.pendingVisionCount }} scan{{ offline.pendingVisionCount > 1 ? 's' : '' }}
en attente · toucher pour réessayer
</q-chip>
</div>
<!-- Last captured photo (thumbnail) -->
<div v-if="scanner.lastPhoto.value" class="photo-preview q-mt-md">
<img :src="scanner.lastPhoto.value" class="preview-img" @click="showFullPhoto = true" />
<div v-if="scanner.scanning.value" class="preview-overlay">
<q-spinner-dots size="32px" color="white" />
<div class="text-white text-caption q-mt-xs">Analyse Gemini...</div>
<!-- Last captured photo (thumbnail) -->
<div v-if="scanner.lastPhoto.value" class="photo-preview q-mt-md">
<img :src="scanner.lastPhoto.value" class="preview-img" @click="showFullPhoto = true" />
<div v-if="scanner.scanning.value" class="preview-overlay">
<q-spinner-dots size="32px" color="white" />
<div class="text-white text-caption q-mt-xs">Analyse...</div>
</div>
</div>
<!-- Photo history (small thumbnails) -->
<div v-if="scanner.photos.value.length > 1" class="q-mt-md">
<div class="text-caption text-grey q-mb-xs">Photos capturées</div>
<div class="row q-gutter-xs justify-center">
<div v-for="(p, i) in scanner.photos.value" :key="i" class="photo-thumb" @click="viewPhoto(p)">
<img :src="p.url" />
<q-badge v-if="p.codes.length" color="green" floating :label="p.codes.length" />
</div>
</div>
</div>
</div>
<!-- Error / status -->
<!-- ==== Manual entry ==== -->
<div v-show="mode === 'manual'">
<q-input v-model="manualCode" label="Saisie manuelle SN / MAC / code-barres"
outlined dense autofocus @keyup.enter="addManual">
<template v-slot:prepend><q-icon name="keyboard" /></template>
<template v-slot:append>
<q-btn flat dense icon="add" :disable="!manualCode.trim()" @click="addManual" />
</template>
</q-input>
</div>
<!-- ==== Error / status (all modes) ==== -->
<div v-if="scanner.error.value" class="text-caption text-center q-mt-sm text-negative">
{{ scanner.error.value }}
</div>
<!-- Manual entry fallback -->
<q-input v-model="manualCode" label="Saisie manuelle SN / MAC" outlined dense class="q-mt-md"
@keyup.enter="addManual">
<template v-slot:append>
<q-btn flat dense icon="add" @click="addManual" :disable="!manualCode.trim()" />
</template>
</q-input>
<!-- Scanned barcodes max 5 (see useScanner.MAX_BARCODES) -->
<!-- ==== Scanned barcodes (shared across all modes) ==== -->
<div v-if="scanner.barcodes.value.length > 0" class="q-mt-md">
<div class="text-subtitle2 q-mb-xs">Codes détectés ({{ scanner.barcodes.value.length }})</div>
<div class="row items-center q-mb-xs">
<div class="text-subtitle2 col">Codes détectés ({{ scanner.barcodes.value.length }}/{{ scanner.MAX_BARCODES }})</div>
<q-btn flat dense size="sm" icon="clear_all" label="Effacer" @click="scanner.clearBarcodes()" />
</div>
<q-card v-for="bc in scanner.barcodes.value" :key="bc.value" class="q-mb-sm">
<q-card-section class="q-py-sm row items-center no-wrap">
<q-icon name="qr_code" class="q-mr-sm" color="primary" />
<div class="col">
<div class="text-subtitle2 mono">{{ bc.value }}</div>
<div class="text-caption text-grey">{{ bc.region }}</div>
</div>
<q-btn flat dense icon="search" @click="lookupDevice(bc.value)" :loading="lookingUp === bc.value" />
<q-btn flat dense icon="close" color="negative" @click="scanner.removeBarcode(bc.value)" />
@ -131,17 +175,6 @@
</q-card>
</div>
<!-- Photo history (small thumbnails) -->
<div v-if="scanner.photos.value.length > 1" class="q-mt-md">
<div class="text-caption text-grey q-mb-xs">Photos capturées</div>
<div class="row q-gutter-xs">
<div v-for="(p, i) in scanner.photos.value" :key="i" class="photo-thumb" @click="viewPhoto(p)">
<img :src="p.url" />
<q-badge v-if="p.codes.length" color="green" floating :label="p.codes.length" />
</div>
</div>
</div>
<!-- Link all to account (manual, when no job context) -->
<div v-if="scanner.barcodes.value.length > 0 && !jobContext && hasUnlinked" class="q-mt-sm">
<q-btn color="orange" icon="link" label="Lier les équipements à un service"
@ -231,7 +264,7 @@
</template>
<script setup>
import { ref, computed } from 'vue'
import { ref, computed, nextTick, onBeforeUnmount } from 'vue'
import { useRoute } from 'vue-router'
import { useScanner } from 'src/composables/useScanner'
import { listDocs, createDoc, updateDoc } from 'src/api/erp'
@ -241,8 +274,8 @@ import { Notify } from 'quasar'
const route = useRoute()
const offline = useOfflineStore()
// Each new code triggers both a toast AND a silent ERPNext lookup. The
// callback fires for synchronous scans AND for scans that complete later
// from the offline vision queue the tech gets notified either way.
// callback fires for live scans, photo scans, queued retries, and manual
// entries the tech gets notified either way.
const scanner = useScanner({
onNewCode: (code) => {
Notify.create({ type: 'positive', message: code, timeout: 2500, icon: 'qr_code', color: 'deep-purple' })
@ -250,6 +283,8 @@ const scanner = useScanner({
},
})
// Default to live camera that's what techs use 90% of the time.
const mode = ref('live')
const cameraInput = ref(null)
const manualCode = ref('')
const lookingUp = ref(null)
@ -295,7 +330,28 @@ const hasUnlinked = computed(() =>
})
)
// Camera capture
// Mode switch
function onModeChange (newMode) {
// Leaving Live cut the camera so we're not streaming in the background.
if (newMode !== 'live' && scanner.live.value) scanner.stopLive()
}
// Defensive: make sure we don't leak the camera if the page unmounts while live.
onBeforeUnmount(() => {
if (scanner.live.value) scanner.stopLive()
})
// Live camera
async function startLive () {
// #live-reader is inside v-show it's always in the DOM from first render.
// nextTick is defensive in case a parent transition hid it momentarily.
await nextTick()
await scanner.startLive('live-reader')
}
// Photo camera
function takePhoto () {
// Reset so the same file re-triggers change when tech scans, undoes, scans
@ -321,11 +377,16 @@ function viewPhoto (photo) {
function addManual () {
const code = manualCode.value.trim()
if (!code) return
if (!scanner.barcodes.value.find(b => b.value === code)) {
scanner.barcodes.value.push({ value: code, region: 'manuel' })
lookupDevice(code)
if (scanner.barcodes.value.length >= scanner.MAX_BARCODES) {
Notify.create({ type: 'warning', message: `Maximum ${scanner.MAX_BARCODES} codes` })
return
}
if (scanner.addCode(code, 'manuel')) {
manualCode.value = ''
} else {
Notify.create({ type: 'info', message: 'Code déjà ajouté' })
manualCode.value = ''
}
manualCode.value = ''
}
// Device lookup: 3-tier fallback
@ -537,6 +598,25 @@ async function linkDeviceToService () {
padding-bottom: 80px !important; /* TechLayout has a bottom tab bar */
}
.live-wrap {
max-width: 480px;
margin: 0 auto;
}
.live-reader {
width: 100%;
min-height: 280px;
border-radius: 12px;
overflow: hidden;
background: #000;
// html5-qrcode injects a <video> make it fill the container
:deep(video) {
width: 100% !important;
height: auto !important;
display: block;
}
}
.photo-preview {
position: relative;
text-align: center;
@ -575,6 +655,6 @@ async function linkDeviceToService () {
}
.mono {
font-family: monospace;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
</style>