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>
237 lines
8.2 KiB
JavaScript
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,
|
|
}
|
|
}
|