gigafibre-fsm/apps/ops/src/composables/useScanner.js
louispaulb beb6ddc5e5 docs: reorganize into architecture/features/reference/archive folders
All docs moved with git mv so --follow preserves history. Flattens the
single-folder layout into goal-oriented folders and adds a README.md index
at every level.

- docs/README.md — new landing page with "I want to…" intent table
- docs/architecture/ — overview, data-model, app-design
- docs/features/ — billing-payments, cpe-management, vision-ocr, flow-editor
- docs/reference/ — erpnext-item-diff, legacy-wizard/
- docs/archive/ — HANDOFF-2026-04-18, MIGRATION, status-snapshots/
- docs/assets/ — pptx sources, build scripts (fixed hardcoded path)
- roadmap.md gains a "Modules in production" section with clickable
  URLs for every ops/tech/portal route and admin surface
- Phase 4 (Customer Portal) flipped to "Largely Shipped" based on
  audit of services/targo-hub/lib/payments.js (16 endpoints, webhook,
  PPA cron, Klarna BNPL all live)
- Archive files get an "ARCHIVED" banner so stale links inside them
  don't mislead readers

Code comments + nginx configs rewritten to use new doc paths. Root
README.md documentation table replaced with intent-oriented index.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 11:51:33 -04:00

237 lines
8.2 KiB
JavaScript

/**
* 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,
}
}