feat: unify vision on Gemini + port field tech scan/device into /j

- Invoice OCR migrated from Ollama (GPU-bound, local) to Gemini 2.5
  Flash via new targo-hub /vision/invoice endpoint with responseSchema
  enforcement. Ops VM no longer needs a GPU.
- Ops /j/* now has full camera scanner (TechScanPage) ported from
  apps/field with 8s timeout + offline queue + auto-link to Dispatch
  Job context on serial/barcode/MAC 3-tier lookup.
- New TechDevicePage reached via /j/device/:serial showing every
  ERPNext entity related to a scanned device: Service Equipment,
  Customer, Service Location, active Subscription, open Issues,
  upcoming Dispatch Jobs, OLT info.
- New docs/VISION_AND_OCR.md (full pipeline + §10 relationship graph
  + §8.1 secrets/rotation policy). Cross-linked from ARCHITECTURE,
  ROADMAP, HANDOFF, README.
- Nginx /ollama/ proxy blocks removed from both ops + field.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-04-22 11:26:01 -04:00
parent 41d9b5f316
commit e50ea88c08
18 changed files with 2017 additions and 284 deletions

View File

@ -91,7 +91,7 @@ GenieACS Twilio Traccar modem-bridge
**Frontend:** Vue 3, Quasar v2, Pinia, Vite, Mapbox GL JS
**Backend:** ERPNext v16 / Frappe (Python), PostgreSQL, Node.js (targo-hub)
**Infra:** Docker, Traefik v2.11, Authentik SSO, Proxmox
**Integrations:** Twilio (SMS), Mailjet (email), Stripe (payments), Traccar (GPS), GenieACS (TR-069), Ollama (OCR)
**Integrations:** Twilio (SMS), Mailjet (email), Stripe (payments), Traccar (GPS), GenieACS (TR-069), Gemini 2.5 Flash via targo-hub (vision/OCR — see [docs/VISION_AND_OCR.md](docs/VISION_AND_OCR.md))
## Data Volumes (migrated from legacy)

View File

@ -16,16 +16,9 @@ server {
proxy_set_header X-Forwarded-Proto https;
}
# Ollama Vision API proxy for bill/invoice OCR (legacy, optional)
location /ollama/ {
resolver 127.0.0.11 valid=10s;
set $ollama_upstream http://ollama:11434;
proxy_pass $ollama_upstream/;
proxy_set_header Host $host;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
client_max_body_size 20m;
}
# NOTE: Ollama Vision proxy removed 2026-04-22 all invoice OCR and
# barcode/equipment scans now go directly to targo-hub (Gemini 2.5 Flash).
# See docs/VISION_AND_OCR.md.
# Targo Hub API proxy vision, devices, etc.
location /hub/ {

View File

@ -1,105 +1,73 @@
import { authFetch } from './auth'
/**
* OCR / Vision client (field app).
*
* All calls go through targo-hub, which runs Gemini 2.5 Flash. We used to
* hit a local Ollama (llama3.2-vision) for invoice OCR, but that required
* a GPU on the serving VM ops doesn't have one, so we centralized every
* vision model behind the hub.
*
* NOTE: apps/field is being folded into apps/ops under /j (see
* docs/ARCHITECTURE.md §"Legacy Retirement Plan"). During the transition
* we keep this file in sync with apps/ops/src/api/ocr.js so no surprises
* when code moves over.
*/
const OLLAMA_URL = '/ollama/api/generate'
const HUB_VISION_URL = 'https://msg.gigafibre.ca/vision/barcodes'
const HUB_URL = 'https://msg.gigafibre.ca'
const OCR_PROMPT = `You are an invoice/bill OCR assistant. Extract the following fields from this image of a bill or invoice. Return ONLY valid JSON, no markdown, no explanation.
const VISION_BARCODES = `${HUB_URL}/vision/barcodes`
const VISION_INVOICE = `${HUB_URL}/vision/invoice`
{
"vendor": "company name on the bill",
"vendor_address": "full address if visible",
"invoice_number": "invoice/bill number",
"date": "YYYY-MM-DD format",
"due_date": "YYYY-MM-DD if visible, null otherwise",
"subtotal": 0.00,
"tax_gst": 0.00,
"tax_qst": 0.00,
"total": 0.00,
"currency": "CAD",
"items": [
{ "description": "line item description", "qty": 1, "rate": 0.00, "amount": 0.00 }
],
"notes": "any other relevant text (account number, payment terms, etc.)"
function stripDataUri (base64Image) {
return String(base64Image || '').replace(/^data:image\/[^;]+;base64,/, '')
}
If a field is not visible, set it to null. Always return valid JSON.`
/**
* Send an image to Ollama Vision for bill/invoice OCR.
* @param {string} base64Image base64 encoded image (no data: prefix)
* @returns {object} Parsed invoice data
* Send a photo to Gemini (via hub) for bill/invoice OCR.
* @param {string} base64Image base64 or data URI
* @returns {Promise<object>} Parsed invoice (see targo-hub/lib/vision.js INVOICE_SCHEMA)
*/
export async function ocrBill (base64Image) {
// Strip data:image/...;base64, prefix if present
const clean = base64Image.replace(/^data:image\/[^;]+;base64,/, '')
const res = await authFetch(OLLAMA_URL, {
const res = await fetch(VISION_INVOICE, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'llama3.2-vision:11b',
prompt: OCR_PROMPT,
images: [clean],
stream: false,
options: {
temperature: 0.1,
num_predict: 2048,
},
}),
body: JSON.stringify({ image: stripDataUri(base64Image) }),
})
if (!res.ok) {
const text = await res.text()
throw new Error('OCR failed: ' + (text || res.status))
}
const data = await res.json()
const raw = data.response || ''
// Extract JSON from response (model might wrap it in markdown)
const jsonMatch = raw.match(/\{[\s\S]*\}/)
if (!jsonMatch) throw new Error('No JSON in OCR response')
try {
return JSON.parse(jsonMatch[0])
} catch (e) {
throw new Error('Invalid JSON from OCR: ' + e.message)
const text = await res.text().catch(() => '')
throw new Error('Invoice OCR failed: ' + (text || res.status))
}
return res.json()
}
/**
* Send image to Gemini Vision (via targo-hub) for barcode/serial extraction.
* Send a photo to Gemini (via hub) for barcode / serial extraction.
* @param {string} base64Image base64 or data URI
* @returns {{ barcodes: string[] }}
* @returns {Promise<{ barcodes: string[] }>}
*/
export async function scanBarcodes (base64Image) {
// Direct call to targo-hub (cross-origin, no auth needed)
const res = await fetch(HUB_VISION_URL, {
const res = await fetch(VISION_BARCODES, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image: base64Image }),
})
if (!res.ok) {
const text = await res.text()
const text = await res.text().catch(() => '')
throw new Error('Vision scan failed: ' + (text || res.status))
}
const data = await res.json()
return { barcodes: data.barcodes || [] }
}
/**
* Check if Ollama is running and the vision model is available.
* Vision service health probe. Pings the hub's /health endpoint.
* Kept under the legacy name `checkOllamaStatus` for backward compat with
* any caller still referencing it ops uses the same name.
*/
export async function checkOllamaStatus () {
try {
const res = await authFetch('/ollama/api/tags')
const res = await fetch(`${HUB_URL}/health`, { method: 'GET' })
if (!res.ok) return { online: false, error: 'HTTP ' + res.status }
const data = await res.json()
const models = (data.models || []).map(m => m.name)
const hasVision = models.some(m => m.includes('llama3.2-vision'))
return { online: true, models, hasVision }
return { online: true, models: ['gemini-2.5-flash'], hasVision: true }
} catch (e) {
return { online: false, error: e.message }
}

View File

@ -19,15 +19,10 @@ server {
proxy_set_header X-Forwarded-Proto https;
}
# Ollama Vision API proxy for bill/invoice OCR (dynamic resolve, won't crash if ollama is down)
location /ollama/ {
set $ollama_upstream http://ollama:11434;
proxy_pass $ollama_upstream/;
proxy_set_header Host $host;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
client_max_body_size 20m;
}
# NOTE: Ollama Vision proxy removed 2026-04-22 invoice OCR and all
# barcode/equipment scans now go directly to targo-hub (Gemini 2.5 Flash).
# See docs/VISION_AND_OCR.md. The hub handles CORS + rate-limit, so no
# nginx pass-through is needed here.
# SPA fallback all routes serve index.html
location / {

View File

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

View File

@ -1,85 +1,103 @@
import { authFetch } from './auth'
/**
* OCR / Vision client all calls go through targo-hub, which runs Gemini
* 2.5 Flash. We deliberately do NOT call Ollama from the ops SPA because the
* ops/ERPNext VM has no GPU; invoice OCR used to hit a local Ollama vision
* model (llama3.2-vision), but that's now centralized in the hub so every
* app (ops, field-as-ops `/j`, future client portal) gets the same model,
* same prompt, same normalization.
*
* Endpoints used:
* POST {HUB_URL}/vision/barcodes { barcodes: string[] }
* POST {HUB_URL}/vision/equipment { brand, model, serial_number, mac_address, gpon_sn, hw_version, equipment_type, barcodes }
* POST {HUB_URL}/vision/invoice { vendor, vendor_address, invoice_number, date, due_date, subtotal, tax_gst, tax_qst, total, currency, items[], notes }
*
* All three are public (no Authentik header) the hub rate-limits and logs.
*/
// Use the Vite base path so requests route through ops-frontend nginx
// In production: /ops/ollama/... → Traefik strips /ops → nginx /ollama/ → Ollama
const BASE = import.meta.env.BASE_URL || '/'
const OLLAMA_URL = BASE + 'ollama/api/generate'
import { HUB_URL } from 'src/config/hub'
const OCR_PROMPT = `You are an invoice/bill OCR assistant. Extract the following fields from this image of a bill or invoice. Return ONLY valid JSON, no markdown, no explanation.
const VISION_BARCODES = `${HUB_URL}/vision/barcodes`
const VISION_EQUIPMENT = `${HUB_URL}/vision/equipment`
const VISION_INVOICE = `${HUB_URL}/vision/invoice`
{
"vendor": "company name on the bill",
"vendor_address": "full address if visible",
"invoice_number": "invoice/bill number",
"date": "YYYY-MM-DD format",
"due_date": "YYYY-MM-DD if visible, null otherwise",
"subtotal": 0.00,
"tax_gst": 0.00,
"tax_qst": 0.00,
"total": 0.00,
"currency": "CAD",
"items": [
{ "description": "line item description", "qty": 1, "rate": 0.00, "amount": 0.00 }
],
"notes": "any other relevant text (account number, payment terms, etc.)"
/** Strip any `data:image/...;base64,` prefix — hub accepts either form but
* we normalize here so error messages + logs stay consistent. */
function stripDataUri (base64Image) {
return String(base64Image || '').replace(/^data:image\/[^;]+;base64,/, '')
}
If a field is not visible, set it to null. Always return valid JSON.`
/**
* Send an image to Ollama Vision for bill/invoice OCR.
* @param {string} base64Image base64 encoded image (no data: prefix)
* @returns {object} Parsed invoice data
* Send a photo to Gemini (via hub) for bill/invoice OCR.
* @param {string} base64Image base64 or data URI
* @returns {Promise<object>} Parsed invoice data (schema in targo-hub/lib/vision.js)
* @throws {Error} on network/API failure caller decides whether to retry
*/
export async function ocrBill (base64Image) {
// Strip data:image/...;base64, prefix if present
const clean = base64Image.replace(/^data:image\/[^;]+;base64,/, '')
const res = await authFetch(OLLAMA_URL, {
const res = await fetch(VISION_INVOICE, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'llama3.2-vision:11b',
prompt: OCR_PROMPT,
images: [clean],
stream: false,
options: {
temperature: 0.1,
num_predict: 2048,
},
}),
body: JSON.stringify({ image: stripDataUri(base64Image) }),
})
if (!res.ok) {
const text = await res.text()
throw new Error('OCR failed: ' + (text || res.status))
}
const data = await res.json()
const raw = data.response || ''
// Extract JSON from response (model might wrap it in markdown)
const jsonMatch = raw.match(/\{[\s\S]*\}/)
if (!jsonMatch) throw new Error('No JSON in OCR response')
try {
return JSON.parse(jsonMatch[0])
} catch (e) {
throw new Error('Invalid JSON from OCR: ' + e.message)
const text = await res.text().catch(() => '')
throw new Error('Invoice OCR failed: ' + (text || res.status))
}
return res.json()
}
/**
* Check if Ollama is running and the vision model is available.
* Send a photo to Gemini (via hub) for generic barcode / serial extraction.
* @param {string} base64Image base64 or data URI
* @returns {Promise<{ barcodes: string[] }>}
* @throws {Error} on network/API failure `useScanner` uses this signature
* to decide whether to queue the photo for retry (see isRetryable()).
*/
export async function scanBarcodes (base64Image) {
const res = await fetch(VISION_BARCODES, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image: base64Image }),
})
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error('Vision scan failed: ' + (text || res.status))
}
const data = await res.json()
return { barcodes: data.barcodes || [] }
}
/**
* Structured equipment label scan richer schema than scanBarcodes for
* ONT/ONU/router labels. Unique to ops (was not in the old field client).
* @param {string} base64Image base64 or data URI
* @returns {Promise<object>} See EQUIP_SCHEMA in targo-hub/lib/vision.js
*/
export async function scanEquipmentLabel (base64Image) {
const res = await fetch(VISION_EQUIPMENT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image: base64Image }),
})
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error('Equipment scan failed: ' + (text || res.status))
}
return res.json()
}
/**
* Vision service health probe.
*
* Historically this pinged `/ollama/api/tags` to confirm the local vision
* model was warm. Now that everything is on Gemini via the hub, we just
* check the hub is reachable the hub itself validates AI_API_KEY on
* startup, so if it's up, Gemini works.
*/
export async function checkOllamaStatus () {
try {
const res = await authFetch(BASE + 'ollama/api/tags')
const res = await fetch(`${HUB_URL}/health`, { method: 'GET' })
if (!res.ok) return { online: false, error: 'HTTP ' + res.status }
const data = await res.json()
const models = (data.models || []).map(m => m.name)
const hasVision = models.some(m => m.includes('llama3.2-vision'))
return { online: true, models, hasVision }
return { online: true, models: ['gemini-2.5-flash'], hasVision: true }
} catch (e) {
return { online: false, error: e.message }
}

View File

@ -1,50 +1,154 @@
import { ref } from 'vue'
/**
* 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.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 { HUB_URL as HUB_BASE } from 'src/config/hub'
import { ref, watch } from 'vue'
import { scanBarcodes, scanEquipmentLabel as apiScanEquipmentLabel } from 'src/api/ocr'
import { useOfflineStore } from 'src/stores/offline'
export function useScanner () {
const barcodes = ref([])
const scanning = ref(false)
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)
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
const found = []
let aiImage = null
const photoIdx = photos.value.length
let found = []
try {
const thumbUrl = await resizeImage(file, 400)
lastPhoto.value = thumbUrl
const aiImage = await resizeImage(file, 1600, 0.92)
const res = await fetch(`${HUB_BASE}/vision/barcodes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image: aiImage }),
})
if (!res.ok) throw new Error('Vision scan failed: ' + res.status)
const data = await res.json()
const existing = new Set(barcodes.value.map(b => b.value))
for (const code of (data.barcodes || [])) {
if (barcodes.value.length >= 5) break
if (!existing.has(code)) {
existing.add(code)
barcodes.value.push({ value: code })
found.push(code)
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'
}
}
if (!found.length) error.value = 'Aucun code detecte — rapprochez-vous ou ameliorez 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
}
/**
* Smart equipment label scan returns structured fields
* { brand, model, serial_number, mac_address, gpon_sn, hw_version, equipment_type, barcodes }
* 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
@ -54,32 +158,13 @@ export function useScanner () {
const thumbUrl = await resizeImage(file, 400)
lastPhoto.value = thumbUrl
const aiImage = await resizeImage(file, 1600, 0.92)
const res = await fetch(`${HUB_BASE}/vision/equipment`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image: aiImage }),
})
if (!res.ok) throw new Error('Vision scan failed: ' + res.status)
const data = await res.json()
// Also populate barcodes list for display
if (data.barcodes?.length) {
const existing = new Set(barcodes.value.map(b => b.value))
for (const code of data.barcodes) {
if (barcodes.value.length >= 5) break
if (!existing.has(code)) {
existing.add(code)
barcodes.value.push({ value: code })
}
}
}
if (data.serial_number) {
const existing = new Set(barcodes.value.map(b => b.value))
if (!existing.has(data.serial_number)) {
barcodes.value.push({ value: data.serial_number })
}
}
if (!data.serial_number && !data.barcodes?.length) {
error.value = 'Aucun identifiant detecte — rapprochez-vous ou ameliorez la mise au point'
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) {
@ -90,6 +175,28 @@ export function useScanner () {
}
}
/** 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()
@ -111,11 +218,19 @@ export function useScanner () {
})
}
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, processPhoto, scanEquipmentLabel, clearBarcodes }
return {
barcodes, scanning, error, lastPhoto, photos,
processPhoto, scanEquipmentLabel, removeBarcode, clearBarcodes,
}
}

View File

@ -0,0 +1,401 @@
<!--
TechDevicePage full device detail view for a tech at /j/device/:serial.
Reached from TechScanPage's "Détails" button (router `{ name: 'tech-device' }`)
or directly via URL when a tech pastes a serial they got off the phone.
Unlike the field-app predecessor, this page surfaces EVERY entity touching
the scanned device so the tech has full context on site:
Équipement serial, MAC, brand, model, firmware, status
Client customer name + link to ClientDetailPage (ops)
Adresse de service address, city, postal, connection type, OLT port
Abonnement actif subscription status, plan, billing window
Tickets ouverts open Issues at this Service Location
Interventions upcoming Dispatch Jobs at this Service Location
Info OLT frame/slot/port/ontid if this is an ONT
See docs/VISION_AND_OCR.md §10 for the full relationship map. The data
loads lazily (equipment first, then location + related entities in
parallel) if the network drops halfway, the first card still renders.
All reads use `listDocs` / `getDoc` from src/api/erp (same proxy-with-
token pattern as the rest of the ops app). Writes (customer re-link)
use `updateDoc` and bypass the offline queue intentionally re-linking
is a deliberate, visible operation that needs to fail fast.
-->
<template>
<q-page padding class="tech-device-page">
<q-btn flat icon="arrow_back" label="Retour" @click="$router.back()" class="q-mb-sm" />
<q-spinner v-if="loading" size="lg" class="block q-mx-auto q-mt-xl" />
<template v-if="device">
<!-- Core equipment card -->
<q-card class="q-mb-md">
<q-card-section>
<div class="row items-center q-mb-sm">
<q-badge :color="statusColor" class="q-mr-sm" :label="device.status || 'Inconnu'" />
<div class="text-h6">{{ device.equipment_type || 'Équipement' }}</div>
</div>
<q-list dense>
<q-item>
<q-item-section avatar><q-icon name="tag" /></q-item-section>
<q-item-section>
<q-item-label caption>Numéro de série</q-item-label>
<q-item-label class="mono">{{ device.serial_number }}</q-item-label>
</q-item-section>
</q-item>
<q-item v-if="device.mac_address">
<q-item-section avatar><q-icon name="router" /></q-item-section>
<q-item-section>
<q-item-label caption>MAC</q-item-label>
<q-item-label class="mono">{{ device.mac_address }}</q-item-label>
</q-item-section>
</q-item>
<q-item v-if="device.brand || device.model">
<q-item-section avatar><q-icon name="devices" /></q-item-section>
<q-item-section>
<q-item-label caption>Marque / Modèle</q-item-label>
<q-item-label>{{ device.brand }} {{ device.model }}</q-item-label>
</q-item-section>
</q-item>
<q-item v-if="device.ip_address">
<q-item-section avatar><q-icon name="language" /></q-item-section>
<q-item-section>
<q-item-label caption>IP</q-item-label>
<q-item-label class="mono">{{ device.ip_address }}</q-item-label>
</q-item-section>
</q-item>
<q-item v-if="device.firmware_version">
<q-item-section avatar><q-icon name="system_update" /></q-item-section>
<q-item-section>
<q-item-label caption>Firmware</q-item-label>
<q-item-label>{{ device.firmware_version }}</q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-card-section>
</q-card>
<!-- Customer card -->
<q-card class="q-mb-md">
<q-card-section>
<div class="text-subtitle1 q-mb-sm">
<q-icon name="person" class="q-mr-xs" /> Compte client
</div>
<div v-if="device.customer" class="text-body1">
{{ device.customer_name || device.customer }}
</div>
<div v-else class="text-grey">Aucun client associé</div>
<q-input v-model="customerSearch" label="Rechercher un client" outlined dense class="q-mt-sm"
@update:model-value="searchCustomer" debounce="400">
<template v-slot:append><q-icon name="search" /></template>
</q-input>
<q-list v-if="customerResults.length > 0" dense bordered class="q-mt-xs" style="max-height: 200px; overflow: auto">
<q-item v-for="c in customerResults" :key="c.name" clickable @click="linkToCustomer(c)">
<q-item-section>
<q-item-label>{{ c.customer_name }}</q-item-label>
<q-item-label caption>{{ c.name }}</q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-card-section>
</q-card>
<!-- Service Location card -->
<q-card v-if="location" class="q-mb-md">
<q-card-section>
<div class="text-subtitle1 q-mb-sm">
<q-icon name="place" class="q-mr-xs" /> Adresse de service
</div>
<div class="text-body1">{{ location.location_name || location.name }}</div>
<div class="text-caption q-mt-xs">
{{ location.address_line }}<br>
{{ location.city }} {{ location.postal_code }}
</div>
<div v-if="location.connection_type" class="text-caption text-grey q-mt-xs">
<q-icon name="cable" size="xs" /> Connexion: {{ location.connection_type }}
</div>
<div v-if="location.contact_phone" class="text-caption q-mt-xs">
<q-icon name="phone" size="xs" /> {{ location.contact_phone }}
</div>
<q-btn v-if="location.latitude && location.longitude"
flat dense size="sm" icon="directions" label="Navigation GPS"
class="q-mt-sm"
@click="openGps(location)" />
</q-card-section>
</q-card>
<!-- Active subscription card -->
<q-card v-if="subscription" class="q-mb-md">
<q-card-section>
<div class="text-subtitle1 q-mb-sm">
<q-icon name="receipt_long" class="q-mr-xs" /> Abonnement
</div>
<q-badge :color="subscription.status === 'Active' ? 'green' : 'orange'" :label="subscription.status" class="q-mb-xs" />
<div class="text-caption">Depuis le {{ subscription.start_date }}</div>
<div v-if="subscription.current_invoice_end" class="text-caption text-grey">
Prochaine facturation: {{ subscription.current_invoice_end }}
</div>
</q-card-section>
</q-card>
<!-- Open tickets at this location -->
<q-card v-if="tickets.length" class="q-mb-md">
<q-card-section>
<div class="text-subtitle1 q-mb-sm">
<q-icon name="confirmation_number" class="q-mr-xs" />
Tickets ouverts ({{ tickets.length }})
</div>
<q-list separator>
<q-item v-for="t in tickets" :key="t.name">
<q-item-section>
<q-item-label>
<q-badge :color="priorityColor(t.priority)" :label="t.priority || 'Normal'" class="q-mr-xs" />
{{ t.subject }}
</q-item-label>
<q-item-label caption>
{{ t.name }} · Ouvert le {{ t.opening_date }}
</q-item-label>
</q-item-section>
<q-item-section side>
<q-badge outline :color="t.status === 'Open' ? 'red' : 'orange'" :label="t.status" />
</q-item-section>
</q-item>
</q-list>
</q-card-section>
</q-card>
<!-- Upcoming Dispatch Jobs at this location -->
<q-card v-if="upcomingJobs.length" class="q-mb-md">
<q-card-section>
<div class="text-subtitle1 q-mb-sm">
<q-icon name="schedule" class="q-mr-xs" />
Interventions prévues ({{ upcomingJobs.length }})
</div>
<q-list separator>
<q-item v-for="j in upcomingJobs" :key="j.name" clickable
@click="$router.push({ name: 'tech-job', params: { name: j.name } })">
<q-item-section>
<q-item-label>{{ j.subject || j.job_type || j.name }}</q-item-label>
<q-item-label caption>
{{ j.scheduled_date }} · {{ j.technician || 'Non assigné' }}
</q-item-label>
</q-item-section>
<q-item-section side>
<q-badge :color="j.status === 'Planned' ? 'blue' : 'grey'" :label="j.status" />
</q-item-section>
</q-item>
</q-list>
</q-card-section>
</q-card>
<!-- OLT provisioning info (ops-specific 'olt_*' fields, not 'custom_olt_*') -->
<q-card v-if="device.olt_name" class="q-mb-md">
<q-card-section>
<div class="text-subtitle1 q-mb-sm">
<q-icon name="lan" class="q-mr-xs" /> Info OLT
</div>
<q-list dense>
<q-item>
<q-item-section>
<q-item-label caption>OLT</q-item-label>
<q-item-label>{{ device.olt_name }} <span class="text-grey">({{ device.olt_ip }})</span></q-item-label>
</q-item-section>
</q-item>
<q-item v-if="device.olt_frame !== undefined">
<q-item-section>
<q-item-label caption>Port</q-item-label>
<q-item-label class="mono">
{{ device.olt_frame }}/{{ device.olt_slot }}/{{ device.olt_port }} · ONT {{ device.olt_ontid }}
</q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-card-section>
</q-card>
</template>
<div v-if="!loading && !device" class="text-center text-grey q-mt-xl">
<q-icon name="search_off" size="48px" class="block q-mx-auto q-mb-sm" />
Équipement non trouvé pour <span class="mono">{{ props.serial }}</span>
</div>
</q-page>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { listDocs, getDoc, updateDoc } from 'src/api/erp'
import { Notify } from 'quasar'
const props = defineProps({ serial: String })
const loading = ref(true)
const device = ref(null)
const location = ref(null)
const subscription = ref(null)
const tickets = ref([])
const upcomingJobs = ref([])
const customerSearch = ref('')
const customerResults = ref([])
const statusColor = computed(() => {
const s = device.value?.status
if (s === 'Actif') return 'green'
if (s === 'Défectueux' || s === 'Perdu') return 'red'
return 'grey'
})
function priorityColor (p) {
if (p === 'High' || p === 'Urgent') return 'red'
if (p === 'Medium') return 'orange'
return 'grey'
}
/**
* Primary lookup: resolve the :serial route param to a Service Equipment
* name, then fetch the full doc + every related entity in parallel.
*
* Tolerant of failures: if any related fetch blows up (RLS, race, stale
* foreign key), we swallow and render the main card anyway. The tech
* needs to see the device even if e.g. the Dispatch Job fetch times out.
*/
async function loadDevice () {
loading.value = true
try {
// Step 1: find the Service Equipment name by any of 3 identifiers
let eqName = null
const results = await listDocs('Service Equipment', {
filters: { serial_number: props.serial },
fields: ['name'],
limit: 1,
})
if (results.length > 0) {
eqName = results[0].name
} else {
const byBarcode = await listDocs('Service Equipment', {
filters: { barcode: props.serial },
fields: ['name'],
limit: 1,
})
if (byBarcode.length > 0) eqName = byBarcode[0].name
}
if (!eqName) { loading.value = false; return }
// Step 2: fetch full device doc
device.value = await getDoc('Service Equipment', eqName)
// Step 3: fan out to related entities. Run in parallel each one is
// independently optional so a single failure doesn't cascade.
const promises = []
if (device.value.service_location) {
promises.push(loadLocation(device.value.service_location))
promises.push(loadTicketsAt(device.value.service_location))
promises.push(loadJobsAt(device.value.service_location))
}
if (device.value.customer) {
promises.push(loadActiveSubscription(device.value.customer))
}
await Promise.allSettled(promises)
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur chargement: ' + e.message })
} finally {
loading.value = false
}
}
async function loadLocation (locationName) {
try {
location.value = await getDoc('Service Location', locationName)
} catch { /* stale FK — ignore */ }
}
async function loadActiveSubscription (customerId) {
try {
// Subscriptions are tied to the customer, not the location. We show
// the most recent active one multi-location customers may have
// several and the UI only picks the freshest for the chip.
const subs = await listDocs('Subscription', {
filters: { party_type: 'Customer', party: customerId, status: 'Active' },
fields: ['name', 'status', 'start_date', 'current_invoice_end'],
limit: 1,
orderBy: 'start_date desc',
})
if (subs.length) subscription.value = subs[0]
} catch { /* no subscription → card hidden */ }
}
async function loadTicketsAt (locationName) {
try {
tickets.value = await listDocs('Issue', {
filters: { service_location: locationName, status: ['in', ['Open', 'In Progress', 'On Hold']] },
fields: ['name', 'subject', 'status', 'priority', 'opening_date'],
limit: 10,
orderBy: 'opening_date desc',
})
} catch { tickets.value = [] }
}
async function loadJobsAt (locationName) {
try {
upcomingJobs.value = await listDocs('Dispatch Job', {
filters: {
service_location: locationName,
status: ['in', ['Planned', 'Scheduled', 'En Route', 'In Progress']],
},
fields: ['name', 'subject', 'job_type', 'status', 'scheduled_date', 'technician'],
limit: 5,
orderBy: 'scheduled_date asc',
})
} catch { upcomingJobs.value = [] }
}
async function searchCustomer (text) {
if (!text || text.length < 2) { customerResults.value = []; return }
try {
customerResults.value = await listDocs('Customer', {
filters: { customer_name: ['like', '%' + text + '%'] },
fields: ['name', 'customer_name'],
limit: 10,
})
} catch { customerResults.value = [] }
}
async function linkToCustomer (customer) {
try {
await updateDoc('Service Equipment', device.value.name, { customer: customer.name })
device.value.customer = customer.name
device.value.customer_name = customer.customer_name
customerResults.value = []
customerSearch.value = ''
// Re-load subscription for the new customer
subscription.value = null
loadActiveSubscription(customer.name)
Notify.create({ type: 'positive', message: 'Lié à ' + customer.customer_name })
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
}
}
function openGps (loc) {
const dest = loc.latitude && loc.longitude
? `${loc.latitude},${loc.longitude}`
: encodeURIComponent(`${loc.address_line || ''} ${loc.city || ''}`)
window.open(`https://www.google.com/maps/dir/?api=1&destination=${dest}`, '_blank')
}
onMounted(loadDevice)
</script>
<style lang="scss" scoped>
.tech-device-page {
padding-bottom: 80px !important; /* TechLayout bottom tabs */
}
.mono {
font-family: monospace;
}
</style>

View File

@ -1,6 +1,35 @@
<!--
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.
ERPNext relationships touched (see docs/VISION_AND_OCR.md §10 for the full
data-model diagram):
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
auto-patch customer + service_location on that row so the device is
provably tied to that install address before the tech leaves.
Subscription + Issue are NOT updated here directly: they link to the
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.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()
-->
<template>
<q-page padding class="scan-page">
<!-- Job context banner -->
<!-- Job context banner set by TechJobDetailPage via ?job=&customer=&location= -->
<q-card v-if="jobContext" flat bordered class="q-mb-md bg-blue-1">
<q-card-section class="q-py-sm row items-center no-wrap">
<q-icon name="work" color="primary" class="q-mr-sm" />
@ -12,49 +41,189 @@
</div>
<q-btn flat dense size="sm" icon="close" @click="jobContext = null" />
</q-card-section>
<q-card-section class="q-pt-none q-pb-sm text-caption text-blue-grey">
Les équipements scannés seront automatiquement liés à ce client et cette adresse.
</q-card-section>
</q-card>
<!-- Manual entry -->
<q-input v-model="manualCode" label="Numero de serie / MAC" outlined dense class="q-mb-md" @keyup.enter="lookupDevice">
<!-- Camera capture button -->
<div class="text-center">
<q-btn
color="primary" icon="photo_camera" label="Scanner"
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>
<!-- 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>
</div>
</div>
<!-- Error / status -->
<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="search" @click="lookupDevice" :disable="!manualCode.trim()" :loading="lookingUp" />
<q-btn flat dense icon="add" @click="addManual" :disable="!manualCode.trim()" />
</template>
</q-input>
<!-- Results -->
<q-card v-if="result" class="q-mb-md">
<q-card-section v-if="result.found">
<div class="row items-center q-mb-sm">
<q-badge color="green" label="Trouve" class="q-mr-sm" />
<span class="text-subtitle2">{{ result.eq.equipment_type }} {{ result.eq.brand }} {{ result.eq.model }}</span>
</div>
<div class="text-caption" style="font-family:monospace">SN: {{ result.eq.serial_number }}</div>
<div v-if="result.eq.customer_name" class="text-caption">Client: {{ result.eq.customer_name }}</div>
<div v-if="!result.eq.service_location && jobContext" class="q-mt-sm">
<q-btn unelevated size="sm" color="primary" label="Lier a ce job" icon="link" @click="linkToJob(result.eq)" :loading="linking" />
<!-- Scanned barcodes max 5 (see useScanner.MAX_BARCODES) -->
<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>
<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>
<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)" />
</q-card-section>
<q-card-section v-else>
<q-badge color="orange" label="Non trouve" class="q-mb-sm" />
<div class="text-caption">Aucun equipement avec ce code.</div>
<q-btn flat size="sm" color="primary" label="Creer un equipement" icon="add" @click="createDialog = true" class="q-mt-xs" />
<!-- Lookup result -->
<q-card-section v-if="lookupResults[bc.value]" class="q-pt-none">
<div v-if="lookupResults[bc.value].found">
<q-badge color="green" label="Trouvé" class="q-mb-xs" />
<div class="text-caption">
{{ lookupResults[bc.value].equipment.equipment_type }}
{{ lookupResults[bc.value].equipment.brand }} {{ lookupResults[bc.value].equipment.model }}
</div>
<div class="text-caption">
Client: {{ lookupResults[bc.value].equipment.customer_name || lookupResults[bc.value].equipment.customer || 'Aucun' }}
</div>
<div v-if="!lookupResults[bc.value].equipment.service_location && !jobContext" class="q-mt-xs">
<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-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>
<q-btn flat dense size="sm" label="Détails" icon="open_in_new" class="q-mt-xs"
@click="$router.push({ name: 'tech-device', params: { serial: lookupResults[bc.value].equipment.serial_number || bc.value } })" />
</div>
<div v-else>
<q-badge color="orange" label="Non trouvé" class="q-mb-xs" />
<q-btn flat dense size="sm" color="primary" label="Créer équipement" icon="add"
@click="openCreateDialog(bc.value)" />
</div>
</q-card-section>
</q-card>
</div>
<!-- Create dialog -->
<!-- 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"
@click="openLinkDialogForAll" outline class="full-width" />
</div>
<!-- Full photo viewer -->
<q-dialog v-model="showFullPhoto" maximized>
<q-card class="bg-black column">
<q-card-section class="col-auto row items-center">
<div class="text-white text-subtitle2 col">Photo</div>
<q-btn flat round icon="close" color="white" v-close-popup />
</q-card-section>
<q-card-section class="col column items-center justify-center">
<img :src="fullPhotoUrl" style="max-width:100%; max-height:80vh; object-fit:contain" />
</q-card-section>
</q-card>
</q-dialog>
<!-- Create equipment dialog -->
<q-dialog v-model="createDialog">
<q-card style="min-width: 320px">
<q-card-section class="text-h6">Nouvel equipement</q-card-section>
<q-card-section>
<q-input v-model="newEquip.serial_number" label="Numero de serie" outlined dense class="q-mb-sm" />
<div class="text-h6">Nouvel équipement</div>
</q-card-section>
<q-card-section>
<q-input v-model="newEquip.serial_number" label="Numéro de série" outlined dense readonly class="q-mb-sm" />
<q-select v-model="newEquip.equipment_type" :options="eqTypes" label="Type" outlined dense class="q-mb-sm" />
<q-input v-model="newEquip.brand" label="Marque" outlined dense class="q-mb-sm" />
<q-input v-model="newEquip.model" label="Modele" outlined dense class="q-mb-sm" />
<q-input v-model="newEquip.mac_address" label="MAC (optionnel)" outlined dense />
<q-input v-model="newEquip.model" label="Modèle" outlined dense class="q-mb-sm" />
<q-input v-model="newEquip.mac_address" label="MAC (optionnel)" outlined dense class="q-mb-sm" />
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Annuler" v-close-popup />
<q-btn color="primary" label="Creer" @click="createEquipment" :loading="creating" />
<q-btn color="primary" label="Créer" @click="createEquipment" :loading="creating" />
</q-card-actions>
</q-card>
</q-dialog>
<!-- Link device to service dialog (no job context path) -->
<q-dialog v-model="linkDialog">
<q-card style="min-width: 340px">
<q-card-section>
<div class="text-h6">Lier à un service</div>
<div class="text-caption text-grey mono">{{ linkTarget?.serial_number }}</div>
</q-card-section>
<q-card-section>
<q-input v-model="linkSearch" label="Rechercher client" outlined dense class="q-mb-sm"
@update:model-value="searchCustomers" debounce="400">
<template v-slot:append><q-icon name="search" /></template>
</q-input>
<q-list v-if="customerResults.length" bordered separator class="q-mb-sm" style="max-height: 150px; overflow-y: auto">
<q-item v-for="c in customerResults" :key="c.name" clickable @click="selectCustomer(c)">
<q-item-section>
<q-item-label>{{ c.customer_name || c.name }}</q-item-label>
<q-item-label caption>{{ c.name }}</q-item-label>
</q-item-section>
</q-item>
</q-list>
<div v-if="selectedCustomer">
<div class="text-subtitle2 q-mb-xs">{{ selectedCustomer.customer_name || selectedCustomer.name }}</div>
<div v-if="loadingLocations" class="text-center q-py-sm"><q-spinner size="sm" /></div>
<q-list v-else-if="serviceLocations.length" bordered separator>
<q-item v-for="loc in serviceLocations" :key="loc.name" clickable
:class="{ 'bg-blue-1': selectedLocation?.name === loc.name }"
@click="selectedLocation = loc">
<q-item-section>
<q-item-label>{{ loc.location_name || loc.name }}</q-item-label>
<q-item-label caption>{{ loc.address_line }} {{ loc.city }}</q-item-label>
</q-item-section>
<q-item-section side v-if="selectedLocation?.name === loc.name">
<q-icon name="check_circle" color="primary" />
</q-item-section>
</q-item>
</q-list>
<div v-else class="text-caption text-grey">Aucune adresse de service</div>
</div>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Annuler" v-close-popup />
<q-btn color="primary" label="Lier" :disable="!selectedCustomer || !selectedLocation"
@click="linkDeviceToService" :loading="linkingSingle" />
</q-card-actions>
</q-card>
</q-dialog>
@ -62,77 +231,350 @@
</template>
<script setup>
import { ref } from 'vue'
import { ref, computed } from 'vue'
import { useRoute } from 'vue-router'
import { useScanner } from 'src/composables/useScanner'
import { listDocs, createDoc, updateDoc } from 'src/api/erp'
import { useOfflineStore } from 'src/stores/offline'
import { Notify } from 'quasar'
import { BASE_URL } from 'src/config/erpnext'
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.
const scanner = useScanner({
onNewCode: (code) => {
Notify.create({ type: 'positive', message: code, timeout: 2500, icon: 'qr_code', color: 'deep-purple' })
lookupDevice(code)
},
})
const cameraInput = ref(null)
const manualCode = ref('')
const lookingUp = ref(false)
const result = ref(null)
const linking = ref(false)
const lookingUp = ref(null)
const lookupResults = ref({})
const createDialog = ref(false)
const creating = ref(false)
const eqTypes = ['ONT', 'Modem', 'Routeur', 'Decodeur TV', 'VoIP', 'Amplificateur', 'Autre']
const newEquip = ref({ serial_number: '', equipment_type: 'ONT', brand: '', model: '', mac_address: '' })
// Photo viewer
const showFullPhoto = ref(false)
const fullPhotoUrl = ref('')
// Link dialog
const linkDialog = ref(false)
const linkTarget = ref(null)
const linkTargetBarcode = ref('')
const linkSearch = ref('')
const customerResults = ref([])
const selectedCustomer = ref(null)
const serviceLocations = ref([])
const selectedLocation = ref(null)
const loadingLocations = ref(false)
const linkingSingle = ref(false)
const eqTypes = ['ONT', 'Modem', 'Routeur', 'Décodeur TV', 'VoIP', 'Amplificateur', 'Splitter', 'Câble', 'Autre']
// jobContext is passed through query params by TechJobDetailPage.goScan().
// If absent, the page still works the tech has to manually pick a
// customer + service_location before any link happens.
const jobContext = ref(route.query.job ? {
job: route.query.job, customer: route.query.customer, customer_name: route.query.customer_name,
location: route.query.location, location_name: route.query.location_name,
job: route.query.job,
customer: route.query.customer,
customer_name: route.query.customer_name,
location: route.query.location,
location_name: route.query.location_name,
} : null)
async function apiFetch (url) {
const res = await fetch(BASE_URL + url)
if (!res.ok) throw new Error('API ' + res.status)
return (await res.json()).data || []
const newEquip = ref({ serial_number: '', equipment_type: 'ONT', brand: '', model: '', mac_address: '' })
const hasUnlinked = computed(() =>
scanner.barcodes.value.some(bc => {
const r = lookupResults.value[bc.value]
return r?.found && !r.equipment.service_location
})
)
// Camera capture
function takePhoto () {
// Reset so the same file re-triggers change when tech scans, undoes, scans
if (cameraInput.value) cameraInput.value.value = ''
cameraInput.value?.click()
}
async function lookupDevice () {
async function onPhoto (e) {
const file = e.target.files?.[0]
if (!file) return
// The scanner's onNewCode callback handles toasts + lookups, for both
// synchronous and queued scans. We just await to make scanning.value work.
await scanner.processPhoto(file)
}
function viewPhoto (photo) {
fullPhotoUrl.value = photo.url
showFullPhoto.value = true
}
// Manual entry
function addManual () {
const code = manualCode.value.trim()
if (!code) return
lookingUp.value = true; result.value = null
try {
let docs = await apiFetch('/api/resource/Service Equipment?filters=' + encodeURIComponent(JSON.stringify({ serial_number: code })) + '&fields=["name","serial_number","equipment_type","brand","model","customer","customer_name","service_location","status","mac_address"]&limit_page_length=1')
if (!docs.length) {
const norm = code.replace(/[:\-\.]/g, '').toUpperCase()
if (norm.length >= 6) {
docs = await apiFetch('/api/resource/Service Equipment?filters=' + encodeURIComponent(JSON.stringify({ mac_address: ['like', '%' + norm.slice(-6) + '%'] })) + '&fields=["name","serial_number","equipment_type","brand","model","customer","customer_name","service_location","status","mac_address"]&limit_page_length=1')
if (!scanner.barcodes.value.find(b => b.value === code)) {
scanner.barcodes.value.push({ value: code, region: 'manuel' })
lookupDevice(code)
}
}
result.value = docs.length ? { found: true, eq: docs[0] } : { found: false }
} catch { result.value = { found: false } }
finally { lookingUp.value = false }
manualCode.value = ''
}
async function linkToJob (eq) {
if (!jobContext.value) return
linking.value = true
// Device lookup: 3-tier fallback
// 1. Exact serial_number match (how 95% of scans resolve)
// 2. Barcode field match (for legacy equipment imported with only
// the barcode sticker, no manufacturer SN)
// 3. MAC-suffix match (when Gemini reads the MAC off the label
// instead of the SN last 6 chars are
// unique within our installed base)
async function lookupDevice (serial) {
lookingUp.value = serial
try {
const updates = {}
if (jobContext.value.customer) updates.customer = jobContext.value.customer
if (jobContext.value.location) updates.service_location = jobContext.value.location
await fetch(BASE_URL + '/api/resource/Service Equipment/' + encodeURIComponent(eq.name), {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updates),
const results = await listDocs('Service Equipment', {
filters: { serial_number: serial },
fields: ['name', 'serial_number', 'equipment_type', 'brand', 'model', 'customer', 'customer_name',
'service_location', 'status', 'mac_address'],
limit: 1,
})
eq.customer = jobContext.value.customer
eq.service_location = jobContext.value.location
Notify.create({ type: 'positive', message: 'Lie au job', icon: 'link' })
} catch (e) { Notify.create({ type: 'negative', message: 'Erreur: ' + e.message }) }
finally { linking.value = false }
if (results.length > 0) {
lookupResults.value[serial] = { found: true, equipment: results[0] }
} else {
const byBarcode = await listDocs('Service Equipment', {
filters: { barcode: serial },
fields: ['name', 'serial_number', 'equipment_type', 'brand', 'model', 'customer', 'customer_name',
'service_location', 'status', 'mac_address'],
limit: 1,
})
if (byBarcode.length > 0) {
lookupResults.value[serial] = { found: true, equipment: byBarcode[0] }
} else {
const normalized = serial.replace(/[:\-.]/g, '').toUpperCase()
if (normalized.length === 12 && /^[A-F0-9]+$/.test(normalized)) {
const byMac = await listDocs('Service Equipment', {
filters: { mac_address: ['like', `%${normalized.slice(-6)}%`] },
fields: ['name', 'serial_number', 'equipment_type', 'brand', 'model', 'customer', 'customer_name',
'service_location', 'status', 'mac_address'],
limit: 1,
})
if (byMac.length > 0) {
lookupResults.value[serial] = { found: true, equipment: byMac[0] }
} else {
lookupResults.value[serial] = { found: false }
}
} else {
lookupResults.value[serial] = { found: false }
}
}
}
} catch {
lookupResults.value[serial] = { found: false }
} finally {
lookingUp.value = null
}
// Auto-link path: device exists but has no service_location, and the
// tech opened the page from a Dispatch Job patch customer + location
// so the device is provably tied to this install address.
const result = lookupResults.value[serial]
if (result?.found && jobContext.value?.customer && !result.equipment.service_location) {
await autoLinkToJob(serial, result.equipment)
}
}
// Auto-link device to job context
async function autoLinkToJob (serial, equipment) {
if (!jobContext.value?.customer) return
const updates = { customer: jobContext.value.customer }
if (jobContext.value.location) updates.service_location = jobContext.value.location
try {
await updateDoc('Service Equipment', equipment.name, updates)
equipment.customer = jobContext.value.customer
equipment.customer_name = jobContext.value.customer_name
if (jobContext.value.location) equipment.service_location = jobContext.value.location
if (lookupResults.value[serial]) {
lookupResults.value[serial].equipment = { ...equipment }
}
Notify.create({
type: 'positive',
message: 'Lié à ' + (jobContext.value.customer_name || jobContext.value.customer),
caption: jobContext.value.location_name || undefined,
icon: 'link',
})
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur liaison: ' + e.message })
}
}
// Create equipment (offline-aware)
function openCreateDialog (serial) {
newEquip.value = { serial_number: serial, equipment_type: 'ONT', brand: '', model: '', mac_address: '' }
createDialog.value = true
}
async function createEquipment () {
creating.value = true
const data = {
...newEquip.value,
status: 'Actif',
customer: jobContext.value?.customer || '',
service_location: jobContext.value?.location || '',
}
try {
const data = { ...newEquip.value, status: 'Actif', customer: jobContext.value?.customer || '', service_location: jobContext.value?.location || '' }
const res = await fetch(BASE_URL + '/api/resource/Service Equipment', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) })
if (!res.ok) throw new Error('Create failed')
const doc = (await res.json()).data
result.value = { found: true, eq: doc }
if (offline.online) {
const doc = await createDoc('Service Equipment', data)
lookupResults.value[data.serial_number] = { found: true, equipment: doc }
Notify.create({ type: 'positive', message: 'Équipement créé' })
} else {
// No connectivity queue the mutation for later replay
await offline.enqueue({ type: 'create', doctype: 'Service Equipment', data })
Notify.create({ type: 'info', message: 'Sauvegardé hors ligne' })
}
createDialog.value = false
Notify.create({ type: 'positive', message: 'Equipement cree' })
} catch (e) { Notify.create({ type: 'negative', message: 'Erreur: ' + e.message }) }
finally { creating.value = false }
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
} finally {
creating.value = false
}
}
// Manual link flow (no job context)
function openLinkDialogForAll () {
for (const bc of scanner.barcodes.value) {
const r = lookupResults.value[bc.value]
if (r?.found && !r.equipment.service_location) {
openLinkDialog(bc.value, r.equipment)
return
}
}
}
function openLinkDialog (barcode, equipment) {
linkTarget.value = equipment
linkTargetBarcode.value = barcode
linkSearch.value = ''
customerResults.value = []
selectedCustomer.value = null
serviceLocations.value = []
selectedLocation.value = null
// Pre-select the customer if the device already has one, so the tech
// only has to pick the address.
if (equipment.customer) {
selectedCustomer.value = { name: equipment.customer, customer_name: equipment.customer_name }
loadServiceLocations(equipment.customer)
}
linkDialog.value = true
}
async function searchCustomers (text) {
if (!text || text.length < 2) { customerResults.value = []; return }
try {
customerResults.value = await listDocs('Customer', {
filters: { customer_name: ['like', `%${text}%`] },
fields: ['name', 'customer_name'],
limit: 10,
})
} catch { customerResults.value = [] }
}
async function selectCustomer (customer) {
selectedCustomer.value = customer
customerResults.value = []
linkSearch.value = ''
selectedLocation.value = null
await loadServiceLocations(customer.name)
}
async function loadServiceLocations (customerId) {
loadingLocations.value = true
try {
serviceLocations.value = await listDocs('Service Location', {
filters: { customer: customerId },
fields: ['name', 'location_name', 'address_line', 'city', 'connection_type'],
limit: 50,
})
} catch { serviceLocations.value = [] }
finally { loadingLocations.value = false }
}
async function linkDeviceToService () {
if (!linkTarget.value || !selectedCustomer.value || !selectedLocation.value) return
linkingSingle.value = true
try {
await updateDoc('Service Equipment', linkTarget.value.name, {
customer: selectedCustomer.value.name,
service_location: selectedLocation.value.name,
})
linkTarget.value.customer = selectedCustomer.value.name
linkTarget.value.customer_name = selectedCustomer.value.customer_name
linkTarget.value.service_location = selectedLocation.value.name
if (lookupResults.value[linkTargetBarcode.value]) {
lookupResults.value[linkTargetBarcode.value].equipment = { ...linkTarget.value }
}
Notify.create({ type: 'positive', message: 'Lié à ' + (selectedLocation.value.location_name || selectedLocation.value.name) })
linkDialog.value = false
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
} finally {
linkingSingle.value = false
}
}
</script>
<style lang="scss" scoped>
.scan-page {
padding-bottom: 80px !important; /* TechLayout has a bottom tab bar */
}
.photo-preview {
position: relative;
text-align: center;
}
.preview-img {
max-width: 100%;
max-height: 250px;
border-radius: 12px;
cursor: pointer;
}
.preview-overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
border-radius: 12px;
}
.photo-thumb {
position: relative;
width: 60px;
height: 60px;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.mono {
font-family: monospace;
}
</style>

View File

@ -5,7 +5,7 @@
<div class="text-h6">Scanner une facture</div>
<q-space />
<q-badge v-if="ollamaStatus" :color="ollamaStatus.online ? 'green' : 'red'"
:label="ollamaStatus.online ? 'Ollama en ligne' : 'Ollama hors ligne'" />
:label="ollamaStatus.online ? 'Gemini en ligne' : 'Vision hors ligne'" />
</div>
<!-- Upload / Camera -->
@ -23,10 +23,10 @@
<q-card-section v-if="preview" class="text-center">
<img :src="preview" style="max-width:100%;max-height:400px;border-radius:8px" />
<div class="q-mt-md">
<q-btn color="indigo-6" icon="document_scanner" label="Analyser avec Ollama Vision" :loading="processing" @click="runOcr" size="lg" />
<q-btn color="indigo-6" icon="document_scanner" label="Analyser avec Gemini Vision" :loading="processing" @click="runOcr" size="lg" />
</div>
<q-linear-progress v-if="processing" indeterminate color="indigo-6" class="q-mt-sm" />
<div v-if="processing" class="text-caption text-grey q-mt-xs">Analyse en cours... (peut prendre 30-60s sur CPU)</div>
<div v-if="processing" class="text-caption text-grey q-mt-xs">Analyse en cours... (habituellement 2-5s)</div>
</q-card-section>
</q-card>

View File

@ -10,6 +10,7 @@ const routes = [
{ path: '', name: 'tech-tasks', component: () => import('src/modules/tech/pages/TechTasksPage.vue') },
{ path: 'job/:name', name: 'tech-job', component: () => import('src/modules/tech/pages/TechJobDetailPage.vue'), props: true },
{ path: 'scan', name: 'tech-scan', component: () => import('src/modules/tech/pages/TechScanPage.vue') },
{ path: 'device/:serial', name: 'tech-device', component: () => import('src/modules/tech/pages/TechDevicePage.vue'), props: true },
{ path: 'diagnostic', name: 'tech-diag', component: () => import('src/modules/tech/pages/TechDiagnosticPage.vue') },
{ path: 'more', name: 'tech-more', component: () => import('src/modules/tech/pages/TechMorePage.vue') },
// Magic link: /j/{jwt-token} — must be LAST to not capture static paths above

View File

@ -0,0 +1,236 @@
/**
* Offline store mutation queue + vision (Gemini) retry queue.
*
* This store is the backbone of the tech `/j` (mobile) workflow: techs work
* in basements, elevators, and under couches where LTE drops for seconds to
* minutes. We can't afford to lose a scan or a "job completed" tap, so both
* mutations AND vision photos are persisted to IndexedDB and retried in the
* background when connectivity returns.
*
* Two queues, different retry strategies:
*
* queue (ERPNext mutations)
* { type: 'create'|'update', doctype, name?, data, ts, id }
* flush on `online` event replay createDoc/updateDoc.
* Failed items stay queued until next online flip.
*
*
* visionQueue (Gemini photo OCR)
* { id, image (base64), ts, status }
* Retries are time-driven (scheduleVisionRetry), not connectivity
* -driven, because `navigator.onLine` lies in weak-signal zones
* (reports true on a captive 2-bar LTE that can't actually
* reach msg.gigafibre.ca). First retry at 5s, backoff to 30s.
*
* Successful scans land in `scanResults` and the `useScanner`
* composable merges them back into the UI via a watcher.
*
*
* IndexedDB keys (idb-keyval, no schema):
* - `offline-queue` mutation queue
* - `vision-queue` pending photos
* - `vision-results` completed scans waiting for the UI to consume
* - `cache-{key}` generic read cache (used for read-through patterns)
*
* Ported from apps/field/src/stores/offline.js as part of the fieldops
* unification (see docs/ARCHITECTURE.md §"Legacy Retirement Plan").
*/
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { get, set } from 'idb-keyval'
import { createDoc, updateDoc } from 'src/api/erp'
import { scanBarcodes } from 'src/api/ocr'
export const useOfflineStore = defineStore('offline', () => {
// ─── Mutation queue ──────────────────────────────────────────────
const queue = ref([])
const syncing = ref(false)
const online = ref(navigator.onLine)
const pendingCount = computed(() => queue.value.length)
// ─── Vision queue ────────────────────────────────────────────────
const visionQueue = ref([]) // { id, image (base64), ts, status }
const scanResults = ref([]) // { id, barcodes: string[], ts }
const pendingVisionCount = computed(() => visionQueue.value.length)
let retryTimer = null
let visionSyncing = false
// Listen to connectivity changes. We kick off BOTH queues on `online`
// because a reconnect is the cheapest signal we have that things might
// work now — worst case the retries fail again and we stay queued.
window.addEventListener('online', () => {
online.value = true
syncQueue()
syncVisionQueue()
})
window.addEventListener('offline', () => { online.value = false })
async function loadQueue () {
try {
const stored = await get('offline-queue')
queue.value = stored || []
} catch { queue.value = [] }
}
async function saveQueue () {
// Pinia refs aren't structured-clonable directly (proxies); JSON
// round-trip is the simplest way to get a plain copy for IndexedDB.
await set('offline-queue', JSON.parse(JSON.stringify(queue.value)))
}
async function loadVisionQueue () {
try {
visionQueue.value = (await get('vision-queue')) || []
scanResults.value = (await get('vision-results')) || []
} catch {
visionQueue.value = []
scanResults.value = []
}
// If we're restoring a non-empty queue (app was closed with pending
// scans), give the network 5s to settle before the first retry.
if (visionQueue.value.length) scheduleVisionRetry(5000)
}
async function saveVisionQueue () {
await set('vision-queue', JSON.parse(JSON.stringify(visionQueue.value)))
}
async function saveScanResults () {
await set('vision-results', JSON.parse(JSON.stringify(scanResults.value)))
}
/**
* Enqueue a mutation to be synced later.
* @param {{ type: 'create'|'update', doctype: string, name?: string, data: object }} action
*/
async function enqueue (action) {
action.ts = Date.now()
action.id = action.ts + '-' + Math.random().toString(36).slice(2, 8)
queue.value.push(action)
await saveQueue()
if (online.value) syncQueue()
return action
}
async function syncQueue () {
if (syncing.value || queue.value.length === 0) return
syncing.value = true
const failed = []
for (const action of [...queue.value]) {
try {
if (action.type === 'create') {
await createDoc(action.doctype, action.data)
} else if (action.type === 'update') {
await updateDoc(action.doctype, action.name, action.data)
}
} catch {
failed.push(action)
}
}
queue.value = failed
await saveQueue()
syncing.value = false
}
/**
* Enqueue a photo whose Gemini scan couldn't complete (timeout / offline).
* Called by useScanner when scanBarcodes exceeds SCAN_TIMEOUT_MS or throws
* a network error. Returns the queued entry so the caller can display a
* "scan en attente" chip in the UI.
*
* @param {{ image: string }} opts base64 (data URI) of the optimized image
*/
async function enqueueVisionScan ({ image }) {
const entry = {
id: Date.now() + '-' + Math.random().toString(36).slice(2, 8),
image,
ts: Date.now(),
status: 'queued',
}
visionQueue.value.push(entry)
await saveVisionQueue()
scheduleVisionRetry(5000)
return entry
}
/**
* Retry each queued photo. Success move to scanResults, fail stay
* queued with a bumped retry schedule. We drive retries off the queue
* itself, not off `online`, because navigator.onLine can report true
* even on weak LTE that can't reach the hub.
*/
async function syncVisionQueue () {
if (visionSyncing) return
if (retryTimer) { clearTimeout(retryTimer); retryTimer = null }
if (visionQueue.value.length === 0) return
visionSyncing = true
const remaining = []
try {
for (const entry of [...visionQueue.value]) {
try {
entry.status = 'syncing'
const result = await scanBarcodes(entry.image)
scanResults.value.push({
id: entry.id,
barcodes: result.barcodes || [],
ts: Date.now(),
})
} catch {
entry.status = 'queued'
remaining.push(entry)
}
}
visionQueue.value = remaining
await Promise.all([saveVisionQueue(), saveScanResults()])
if (remaining.length) scheduleVisionRetry(30000)
} finally {
visionSyncing = false
}
}
function scheduleVisionRetry (delay) {
if (retryTimer) return
retryTimer = setTimeout(() => {
retryTimer = null
syncVisionQueue()
}, delay)
}
/**
* Consumer (ScanPage / TechScanPage) calls this after merging a result
* into the UI so the same serial doesn't reappear next time the page
* mounts from persisted state.
*/
async function consumeScanResult (id) {
scanResults.value = scanResults.value.filter(r => r.id !== id)
await saveScanResults()
}
// ─── Generic read cache (used by list pages for offline browse) ──
async function cacheData (key, data) {
await set('cache-' + key, { data, ts: Date.now() })
}
async function getCached (key) {
try {
const entry = await get('cache-' + key)
return entry?.data || null
} catch { return null }
}
// Kick off initial loads (fire-and-forget — refs start empty and fill
// in once IndexedDB resolves, which is fine for the UI).
loadQueue()
loadVisionQueue()
return {
// mutation queue
queue, syncing, online, pendingCount, enqueue, syncQueue,
// vision queue
visionQueue, scanResults, pendingVisionCount,
enqueueVisionScan, syncVisionQueue, consumeScanResult,
// read cache
cacheData, getCached, loadQueue,
}
})

View File

@ -73,6 +73,12 @@ Internet
- **Stack:** Playwright/Chromium (`:3301` internal).
- **Purpose:** Allows reading encrypted TR-181 parameters from TP-Link XX230v modems by leveraging the modem's native JS cryptography. Exposes a simple JSON REST API locally to targo-hub.
### Vision / OCR (Gemini via targo-hub)
- **Model:** Gemini 2.5 Flash (Google) — no local GPU, all inference remote.
- **Endpoints (hub):** `/vision/barcodes`, `/vision/equipment`, `/vision/invoice`.
- **Why centralized:** ops VM has no GPU, so the legacy Ollama `llama3.2-vision` install was retired. All three frontends (ops, field-as-ops `/j`, future client portal) hit the hub, which enforces JSON `responseSchema` per endpoint.
- **Client-side resilience:** barcode scans use an 8s timeout + IndexedDB retry queue so techs in weak-LTE zones don't lose data. See [VISION_AND_OCR.md](VISION_AND_OCR.md) for the full pipeline.
---
## 4. Security & Authentication Flow

View File

@ -42,11 +42,13 @@ If you only have 15 minutes, read those three.
1. [STATUS_2026-04-18.md](STATUS_2026-04-18.md) §"Features inventory" — Ops, Dispatch, Field
2. [DATA_AND_FLOWS.md](DATA_AND_FLOWS.md) — Issue → Job → Technician flow
3. [CPE_MANAGEMENT.md](CPE_MANAGEMENT.md) — CPE lifecycle, GenieACS, modem-bridge
4. [VISION_AND_OCR.md](VISION_AND_OCR.md) — camera scanning workflow (barcodes, equipment labels, invoices) and offline queue
### Infrastructure / DevOps
1. [ARCHITECTURE.md](ARCHITECTURE.md) — network + container map
2. [STATUS_2026-04-18.md](STATUS_2026-04-18.md) §"Integrations" — external services and credentials location
3. [CPE_MANAGEMENT.md](CPE_MANAGEMENT.md) — GenieACS + OLT + SNMP
4. [VISION_AND_OCR.md](VISION_AND_OCR.md) — Gemini pipeline, AI_API_KEY config, hub `/vision/*` endpoints
---
@ -60,6 +62,7 @@ If you only have 15 minutes, read those three.
| [DATA_AND_FLOWS.md](DATA_AND_FLOWS.md) | Data model and user/workflow flows | Building features that touch ERPNext |
| [BILLING_AND_PAYMENTS.md](BILLING_AND_PAYMENTS.md) | Subscription lifecycle, invoice generation, Stripe, payment reconciliation | Billing work |
| [CPE_MANAGEMENT.md](CPE_MANAGEMENT.md) | CPE database, GenieACS, provisioning, diagnostics | CPE or network work |
| [VISION_AND_OCR.md](VISION_AND_OCR.md) | Gemini-via-hub pipeline: barcode/equipment/invoice endpoints, scanner composable, offline retry queue | Camera/scan/OCR work, onboarding anyone who'll touch `/vision/*` |
| [APP_DESIGN_GUIDELINES.md](APP_DESIGN_GUIDELINES.md) | UI tokens, theming, component conventions | Frontend work |
| [Gigafibre-FSM-Features.pptx](Gigafibre-FSM-Features.pptx) | Feature deck for demo / training | Sharing with non-engineers |
| [Gigafibre-Billing-Handoff.pptx](Gigafibre-Billing-Handoff.pptx) | Billing deck for finance handoff | Sharing with finance team |

View File

@ -19,7 +19,7 @@
- [x] Dispatch module + ticket management
- [x] Equipment tracking with OLT/SNMP diagnostics
- [x] SMS/Email notifications (Twilio + Mailjet)
- [x] Invoice OCR (Ollama Vision)
- [x] Invoice OCR — originally Ollama Vision, migrated to Gemini 2.5 Flash via targo-hub (2026-04-22, no GPU on ops VM). See [VISION_AND_OCR.md](VISION_AND_OCR.md).
- [x] Field tech mobile (/t/{token})
- [x] Authentik federation (staff → client SSO)
- [x] Modem-bridge (Playwright headless for TP-Link ONU diagnostics)

482
docs/VISION_AND_OCR.md Normal file
View File

@ -0,0 +1,482 @@
# Vision & OCR Pipeline
> **All vision runs on Gemini 2.5 Flash via `targo-hub`.** No local Ollama. The
> ops/ERPNext VM has no GPU, so every vision request — bills, barcodes,
> equipment labels — goes to Google's Gemini API from a single backend
> service and gets normalized before hitting the frontend.
**Last refreshed:** 2026-04-22 (cutover from Ollama → Gemini)
---
## 1. Architecture at a glance
```text
┌──────────────────┐ ┌───────────────────────┐
│ apps/ops (PWA) │ │ apps/field (PWA) │
│ /ops/* │ │ /field/* (retiring) │
└────────┬─────────┘ └──────────┬────────────┘
│ │
│ src/api/ocr.js │ src/api/ocr.js
│ {ocrBill, scanBarcodes, │ {ocrBill, scanBarcodes,
│ scanEquipmentLabel} │ checkOllamaStatus}
│ │
└──────────────┬──────────────┘
│ POST https://msg.gigafibre.ca/vision/*
┌───────────────────────┐
│ targo-hub │
│ lib/vision.js │
│ ├─ /vision/barcodes │
│ ├─ /vision/equipment│
│ └─ /vision/invoice │
└──────────┬────────────┘
│ generativelanguage.googleapis.com
┌───────────────────────┐
│ Gemini 2.5 Flash │
│ (text + image, JSON │
│ responseSchema) │
└───────────────────────┘
```
**Why route everything through the hub:**
1. **No GPU on ops VM.** The only machine with a local Ollama was retired
in Phase 2.5. Centralizing on Gemini means the frontend stops caring
where inference happens.
2. **Single AI_API_KEY rotation surface.** Key lives in the hub env only.
3. **Schema guarantees.** Gemini supports `responseSchema` in the v1beta
API — the hub enforces it per endpoint, so the frontend can trust
the JSON shape without defensive parsing.
4. **Observability.** Every call is logged in the hub with image size,
model, latency, output preview (first 300 chars).
---
## 2. Hub endpoints (`services/targo-hub/lib/vision.js`)
All three endpoints:
- are `POST` with JSON body `{ image: <base64 or data URI> }`,
- return structured JSON (see per-endpoint schemas below),
- require `AI_API_KEY` in the hub environment,
- are unauthenticated from the browser (rate-limiting is the hub's job).
### `POST /vision/barcodes`
Extracts up to 3 identifiers (serials, MACs, GPON SNs, barcodes).
```json
{
"barcodes": ["1608K44D9E79FAFF5", "0418D6A1B2C3", "TPLG-A1B2C3D4"]
}
```
Used by: tech scan page, equipment link dialog, invoice scan (fallback).
### `POST /vision/equipment`
Structured equipment-label parse (ONT/ONU/router/modem).
```json
{
"brand": "TP-Link",
"model": "XX230v",
"serial_number": "2234567890ABCD",
"mac_address": "0418D6A1B2C3",
"gpon_sn": "TPLGA1B2C3D4",
"hw_version": "1.0",
"equipment_type": "ont",
"barcodes": ["..."]
}
```
Post-processing: `mac_address` stripped of separators + uppercased;
`serial_number` trimmed of whitespace.
Used by: `useEquipmentActions` in the ops client detail page to pre-fill
a "create Service Equipment" dialog.
### `POST /vision/invoice`
Structured invoice/bill OCR. Canadian-tax-aware (GST/TPS + QST/TVQ).
```json
{
"vendor": "Acme Fibre Supplies",
"vendor_address": "123 rue Somewhere, Montréal, QC",
"invoice_number": "INV-2026-0042",
"date": "2026-04-18",
"due_date": "2026-05-18",
"subtotal": 1000.00,
"tax_gst": 50.00,
"tax_qst": 99.75,
"total": 1149.75,
"currency": "CAD",
"items": [
{ "description": "OLT SFP+ module", "qty": 4, "rate": 250.00, "amount": 1000.00 }
],
"notes": "Payment terms: net 30"
}
```
Post-processing: string-shaped numbers (e.g. `"1,234.56"`) are coerced to
floats, both at the invoice level and per line item.
Used by: `apps/ops/src/pages/OcrPage.vue` (invoice intake), future
supplier-bill wizard.
---
## 3. Frontend surface (`apps/ops/src/api/ocr.js`)
Thin wrapper over the hub. Same signatures for ops and field during the
migration window (see `apps/field/src/api/ocr.js` — same file, different
HUB_URL source).
| Function | Endpoint | Error behavior |
|---|---|---|
| `ocrBill(image)` | `/vision/invoice` | Throws on non-2xx — caller shows Notify |
| `scanBarcodes(image)` | `/vision/barcodes` | Throws on non-2xx — **`useScanner` catches + queues** |
| `scanEquipmentLabel(image)` | `/vision/equipment` | Throws on non-2xx |
| `checkOllamaStatus()` | `/health` | Returns `{online, models, hasVision}`. Name kept for back-compat. |
The `checkOllamaStatus` name is a leftover from the Ollama era — it now
pings the hub's health endpoint and reports `models: ['gemini-2.5-flash']`
so existing callers (status chips, diagnostics panels) keep working. The
name will be renamed to `checkVisionStatus` once no page references the
old symbol.
---
## 4. Scanner composable (`apps/ops/src/composables/useScanner.js`)
Wraps the API with camera capture and resilience. Two modes on one
composable:
### Mode A — `processPhoto(file)` (barcodes, resilient)
1. Resize the `File` twice:
- 400px thumbnail for on-screen preview
- 1600px @ q=0.92 for Gemini (text must stay readable)
2. Race `scanBarcodes(aiImage)` against an **8s timeout** (`SCAN_TIMEOUT_MS`).
3. On timeout / network error, if the error is retryable
(ScanTimeout | Failed to fetch | NetworkError | TypeError):
- persist `{ id, image, ts, status: 'queued' }` to IndexedDB via
`useOfflineStore.enqueueVisionScan`,
- flag `photos[idx].queued = true` for the UI chip,
- show "Réseau faible — scan en attente. Reprise automatique au
retour du signal."
4. Otherwise, show the raw error.
On success, newly found codes are merged into `barcodes.value` (capped at
`MAX_BARCODES = 5`, dedup by value), and the optional `onNewCode(code)`
callback fires for each one.
### Mode B — `scanEquipmentLabel(file)` (structured, synchronous)
No timeout, no queue. Returns the full Gemini response. Auto-merges any
`serial_number` + `barcodes[]` into the same `barcodes.value` list so a
page using both modes shares one visible list. Used in desktop/wifi flows
where callers want a sync answer to pre-fill a form.
### Late-delivered results
The composable runs a `watch(() => offline.scanResults.length)` so that
when the offline store later completes a queued scan (tech walks out of
the basement, signal returns), the codes appear in the UI *as if* they
had come back synchronously. `onNewCode` fires for queued codes too, so
lookup-and-notify side-effects happen regardless of path.
It also drains `offline.scanResults` once at mount, to catch the case
where a scan completed while the page was unmounted (phone locked, app
backgrounded, queue sync ran, user reopens ScanPage).
---
## 5. Offline store (`apps/ops/src/stores/offline.js`)
Pinia store, two queues, IndexedDB (`idb-keyval`):
### Mutation queue
`{ type: 'create'|'update', doctype, name?, data, ts, id }` — ERPNext
mutations. Flushed when `window` emits `online`. Failed items stay
queued across reconnects. Keyed under `offline-queue`.
### Vision queue
`{ id, image (base64), ts, status }` — photos whose Gemini call timed
out or failed. Keyed under `vision-queue`.
**Retries are time-driven, not event-driven.** We don't trust
`navigator.onLine` because it reports `true` on 2-bar LTE that can't
actually reach msg.gigafibre.ca. First retry at 5s, back off to 30s on
repeated failure. A reconnect (online event) also triggers an
opportunistic immediate sync.
Successful scans land in `scanResults` (keyed `vision-results`) and the
scanner composable consumes them via watcher + `consumeScanResult(id)`
to avoid duplicates.
### Generic cache
`cacheData(key, data)` / `getCached(key)` — plain read cache used by
list pages for offline browsing. Keyed under `cache-{key}`.
---
## 6. Data flow example (tech scans an ONT in a basement)
```
[1] Tech taps "Scan" in /j/ScanPage (camera opens)
[2] Tech takes photo (File → input.change)
[3] useScanner.processPhoto(file)
→ resizeImage(file, 400) (thumbnail shown immediately)
→ resizeImage(file, 1600, 0.92)
→ Promise.race([scanBarcodes(ai), timeout(8s)])
CASE A — signal ok:
[4a] Gemini responds in 2s → barcodes[] merged → onNewCode fires
→ ERPNext lookup → Notify "ONT lié au client Untel"
CASE B — weak signal / timeout:
[4b] 8s timeout fires → isRetryable('ScanTimeout') → true
→ offline.enqueueVisionScan({ image: aiImage })
→ photos[idx].queued = true (chip "scan en attente")
→ tech keeps scanning next device
[5b] Tech walks out of basement — window.online fires
→ syncVisionQueue() retries the queued photo
→ Gemini responds → scanResults.push({id, barcodes, ts})
[6b] useScanner watcher on scanResults.length fires
→ mergeCodes(barcodes, 'queued') → onNewCode fires (late)
→ Notify arrives while tech is walking back to the truck
→ consumeScanResult(id) (removed from persistent queue)
```
---
## 7. Changes from the previous (Ollama) pipeline
| Aspect | Before (Phase 2) | After (Phase 2.5) |
|---|---|---|
| Invoice OCR | Ollama `llama3.2-vision:11b` on the serving VM | Gemini 2.5 Flash via `/vision/invoice` |
| Barcode scan | Hub `/vision/barcodes` (already Gemini) | Unchanged |
| Equipment label | Hub `/vision/equipment` (already Gemini) | Unchanged |
| GPU requirement | Yes (11GB VRAM for vision model) | None — all inference remote |
| Offline resilience | Only barcode mode, only in apps/field | Now in apps/ops too (ready for /j) |
| Schema validation | Hand-parsed from prompt-constrained JSON | Gemini `responseSchema` enforces shape |
| Frontend import path | `'src/api/ocr'` (both apps) | Unchanged — same symbols |
---
## 8. Where to look next
- **Hub implementation:** `services/targo-hub/lib/vision.js`,
`services/targo-hub/server.js` (routes: `/vision/barcodes`,
`/vision/equipment`, `/vision/invoice`).
- **Frontend API client:** `apps/ops/src/api/ocr.js` (+
`apps/field/src/api/ocr.js` kept in sync during migration).
- **Scanner composable:** `apps/ops/src/composables/useScanner.js`.
- **Offline store:** `apps/ops/src/stores/offline.js`.
### 8.1 Secrets, keys and rotation
The only secret this pipeline needs is the Gemini API key. Everything
else (models, base URL, hub public URL) is non-sensitive config.
| Variable | Where it's read | Default | Notes |
|---|---|---|---|
| `AI_API_KEY` | `services/targo-hub/lib/config.js:38` | *(none — required)* | Google AI Studio key for `generativelanguage.googleapis.com`. **Server-side only**, never reaches the browser bundle. |
| `AI_MODEL` | `config.js:39` | `gemini-2.5-flash` | Primary vision model. |
| `AI_FALLBACK_MODEL` | `config.js:40` | `gemini-2.5-flash-lite-preview` | Used by text-only calls (not vision) when primary rate-limits. |
| `AI_BASE_URL` | `config.js:41` | `https://generativelanguage.googleapis.com/v1beta/openai/` | OpenAI-compatible endpoint used by agent code. Vision bypasses this and talks to the native `/v1beta/models/...:generateContent` URL. |
**Storage policy.** The repo is private and follows the same posture as
the ERPNext service token already hardcoded in
`apps/ops/infra/nginx.conf:15` and `apps/field/infra/nginx.conf:13`. The
Gemini key can live in any of three places, in increasing order of
"checked into git":
1. **Prod VM env only** (status quo): key is in the `environment:` block
of the `targo-hub` service in `/opt/targo-hub/docker-compose.yml` on
`96.125.196.67`. `config.js:38` reads it via `process.env.AI_API_KEY`.
Rotation = edit that one line + `docker compose restart targo-hub`.
2. **In-repo fallback in `config.js`**: change line 38 to
`AI_API_KEY: env('AI_API_KEY', 'AIzaSy...')` — the env var still wins
when set, so prod doesn't break, but a fresh clone Just Works. Same
pattern as nginx's ERPNext token.
3. **Hardcoded constant** (not recommended): replace `env(...)` entirely.
Loses the ability to override per environment (dev, staging).
If/when option 2 is chosen, the literal value should also be recorded
in `MEMORY.md` (`reference_google_ai.md`) so that's the rotation source
of truth — not scattered across the codebase.
**Browser exposure.** Zero. The ops nginx config proxies `/hub/*` to
targo-hub on an internal Docker network; the hub injects the key before
calling Google. `apps/ops/src/api/ocr.js` just does
`fetch('/hub/vision/barcodes', ...)` — no key in the bundle, no key in
DevTools, no key in the browser's `Network` tab.
---
## 9. Related
- [ARCHITECTURE.md](ARCHITECTURE.md) — the full service map this lives in.
- [CPE_MANAGEMENT.md](CPE_MANAGEMENT.md) — how scanned serials flow into
the TR-069/TR-369 device management plane.
- [APP_DESIGN_GUIDELINES.md](APP_DESIGN_GUIDELINES.md) — frontend
conventions (Vue 3 Composition API, feature folders).
---
## 10. Data-model relationships triggered by a scan
A scan is never just "identify a barcode." Every successful lookup fans
out into the ERPNext graph: the scanned Service Equipment is the entry
point, and the tech page (`/j/device/:serial`) surfaces everything tied
to the same Customer and Service Location. This section documents that
graph, the exact fields read per entity, and the write rules.
### 10.1 Graph (Service Equipment is the anchor)
```text
┌─────────────────────────┐
│ Service Equipment │
│ EQP-##### │◀───── scanned serial / MAC / barcode
│ │ (3-tier lookup in TechScanPage)
│ • serial_number │
│ • mac_address │
│ • barcode │
│ • equipment_type (ONT) │
│ • brand / model │
│ • firmware / hw_version│
│ • status │
│ │
│ FK → customer ─────────┼───┐
│ FK → service_location ─┼─┐ │
│ FK → olt / port │ │ │ (ONT-specific, TR-069 bind)
└─────────────────────────┘ │ │
│ │
┌─────────────────────────────────┘ │
│ │
▼ ▼
┌───────────────────┐ ┌───────────────────┐
│ Service Location │ │ Customer │
│ LOC-##### │ │ CUST-##### │
│ • address │ │ • customer_name │
│ • city │ │ • stripe_id │
│ • postal_code │ │ • ppa_enabled │
│ • connection_type │ │ • legacy_*_id │
│ • olt_port │ └────────┬──────────┘
│ • gps lat/lng │ │
└───┬──────────┬────┘ │
│ │ │
inbound│ │inbound inbound│
│ │ │
▼ ▼ ▼
┌────────────┐ ┌──────────────┐ ┌──────────────┐
│ Issue │ │ Dispatch Job │ │ Subscription │
│ TCK-##### │ │ DJ-##### │ │ SUB-##### │
│ │ │ │ │ │
│ open │ │ upcoming │ │ active plan │
│ tickets │ │ installs / │ │ billing │
│ │ │ repairs │ │ RADIUS creds │
└────────────┘ └──────────────┘ └──────────────┘
FK: service_location FK: party_type='Customer', party=<cust>
```
**Two FK axes, not one.** Tickets + Dispatch Jobs pivot on *where* the
problem is (Service Location). Subscriptions pivot on *who owns the
account* (Customer). A customer can have multiple locations (duplex,
rental, commercial); the scan page shows the subscription freshest for
the customer, even if the scanned device is at a secondary address.
### 10.2 Exact reads issued from `TechDevicePage.vue`
| Step | Call | Filter | Fields read | Purpose |
|------|------|--------|-------------|---------|
| 1 | `listDocs('Service Equipment')` | `serial_number = :serial` | `name` | Exact-serial lookup |
| 1 | `listDocs('Service Equipment')` | `barcode = :serial` | `name` | Fallback if serial missed |
| 2 | `getDoc('Service Equipment', name)` | — | full doc | Device card: brand/model/MAC/firmware/customer/service_location/olt_* |
| 3 | `getDoc('Service Location', loc)` | — | full doc | Address, GPS, connection_type, olt_port |
| 4 | `listDocs('Subscription')` | `party_type='Customer', party=<cust>, status='Active'` | `name, status, start_date, current_invoice_end` | Active plan chip |
| 5 | `listDocs('Issue')` | `service_location=<loc>, status ∈ {Open, In Progress, On Hold}` | `name, subject, status, priority, opening_date` | Open tickets list |
| 6 | `listDocs('Dispatch Job')` | `service_location=<loc>, status ∈ {Planned, Scheduled, En Route, In Progress}` | `name, subject, job_type, status, scheduled_date, technician` | Upcoming interventions |
All five fan-out queries run in parallel via `Promise.allSettled`, so a
permission error on any single doctype (e.g. tech role can't read
`Subscription` in some envs) doesn't block the page render — just that
card is omitted.
### 10.3 Writes issued from `TechScanPage.vue`
The scan page writes to **exactly one doctype**`Service Equipment`
never to Customer, Location, Subscription, Issue, or Dispatch Job. All
relationship changes happen via FK updates on the equipment row:
| Trigger | Write | Why |
|---------|-------|-----|
| Auto-link from job context | `updateDoc('Service Equipment', name, { customer, service_location })` | Tech opened Scan from a Dispatch Job (`?job=&customer=&location=`) and the scanned equipment has no location yet — this "claims" the device for the install. |
| Manual link dialog | `updateDoc('Service Equipment', name, { customer, service_location })` | Tech searched customer + picked one of the customer's locations. |
| Create new device | `createDoc('Service Equipment', data)` | 3-tier lookup came up empty — create a stub and tie it to the current job if available. |
| Customer re-link (from TechDevicePage) | `updateDoc('Service Equipment', name, { customer })` | Tech realized the device is at the wrong account; re-linking the customer auto-reloads the subscription card. |
**Subscription / Issue / Dispatch Job are read-only in the scan flow.**
The tech app never creates a ticket from a scan — that's the job of the
ops dispatcher in `DispatchPage.vue` + `ClientDetailPage.vue`. The scan
page's contribution is to make the FK (`service_location` on the
equipment) accurate so those downstream cards light up correctly when
the dispatcher or the next tech opens the page.
### 10.4 Auto-link rule (the one piece of scan-time "business logic")
When TechScanPage is opened from a Dispatch Job (`goScan` on
TechJobDetailPage propagates `?job=<name>&customer=<id>&location=<loc>`),
each successful lookup runs:
```js
if (result.found && jobContext.customer && !result.equipment.service_location) {
await updateDoc('Service Equipment', result.equipment.name, {
customer: jobContext.customer,
service_location: jobContext.location, // only if the job has one
})
}
```
**Why gated on "no existing service_location":** a device that's already
tied to address A should never silently move to address B just because
a tech scanned it on a job ticket. If the location is already set, the
tech has to use the "Re-link" action in TechDevicePage, which is
explicit and logged. This prevents swap-out scenarios (tech brings a
tested spare ONT from another install and scans it to confirm serial)
from corrupting address ownership.
### 10.5 Why this matters for offline mode
The offline store (`stores/offline.js`) queues `updateDoc` calls under
the mutation queue, not the vision queue. That means:
- **Scan photo** → offline → `vision-queue` → retries against Gemini when
signal returns.
- **Auto-link / create-equipment** → offline → `offline-queue` → retries
against ERPNext when signal returns.
Because both queues drain time-driven, a tech who scans 6 ONTs in a
no-signal basement comes back to the truck and the phone silently:
1. Sends the 6 photos to Gemini (vision queue)
2. Receives the 6 barcode lists
3. Fans each one through `lookupInERPNext` (the scan page watcher)
4. For found + unlinked devices, enqueues 6 `updateDoc` calls
5. Drains the mutation queue → all 6 devices now carry
`customer + service_location` FKs
6. Next time dispatcher opens the Dispatch Job, all 6 equipment rows
appear in the equipment list (via reverse FK query from the job page)
The FK write on Service Equipment is what "connects" the scan to every
downstream card (ticket list, subscription chip, dispatch job list).
Everything else is a read on those FKs.
---

View File

@ -96,4 +96,75 @@ async function handleEquipment (req, res) {
}
}
module.exports = { handleBarcodes, extractBarcodes, handleEquipment }
// ─── Invoice / bill OCR ────────────────────────────────────────────────
// We run this on Gemini (not on Ollama) because the ops VM has no GPU —
// ops must not depend on a local vision model. The schema matches what
// the ops InvoiceScanPage expects so switching away from Ollama is a
// drop-in replacement on the frontend.
const INVOICE_PROMPT = `You are an invoice/bill OCR assistant. Extract structured data from this photo of a vendor invoice or bill.
Return ONLY valid JSON that matches the provided schema. No prose, no markdown.
Rules:
- "date" / "due_date" must be ISO YYYY-MM-DD. If the date is MM/DD/YYYY or DD/MM/YYYY and ambiguous, prefer YYYY-MM-DD with the most likely interpretation for Canadian/Québec invoices.
- "currency" is a 3-letter code (CAD, USD, EUR). Default to CAD if not visible.
- "tax_gst" = GST/TPS/HST (Canadian federal tax); "tax_qst" = QST/TVQ (Québec provincial tax).
- "items" is a line-by-line list; keep description as printed, collapse whitespace.
- Missing fields null for strings, 0 for numbers, [] for items.`
const INVOICE_SCHEMA = {
type: 'object',
properties: {
vendor: { type: 'string', nullable: true },
vendor_address: { type: 'string', nullable: true },
invoice_number: { type: 'string', nullable: true },
date: { type: 'string', nullable: true },
due_date: { type: 'string', nullable: true },
subtotal: { type: 'number', nullable: true },
tax_gst: { type: 'number', nullable: true },
tax_qst: { type: 'number', nullable: true },
total: { type: 'number', nullable: true },
currency: { type: 'string', nullable: true },
items: {
type: 'array',
items: {
type: 'object',
properties: {
description: { type: 'string', nullable: true },
qty: { type: 'number', nullable: true },
rate: { type: 'number', nullable: true },
amount: { type: 'number', nullable: true },
},
},
},
notes: { type: 'string', nullable: true },
},
required: ['vendor', 'total'],
}
async function handleInvoice (req, res) {
const body = await parseBody(req)
const check = extractBase64(req, body, 'invoice')
if (check.error) return json(res, check.status, { error: check.error })
try {
const parsed = await geminiVision(check.base64, INVOICE_PROMPT, INVOICE_SCHEMA)
if (!parsed) return json(res, 200, { vendor: null, total: null, items: [] })
// Normalize: trim + coerce numbers (model sometimes returns "1,234.56" as string)
for (const k of ['subtotal', 'tax_gst', 'tax_qst', 'total']) {
if (typeof parsed[k] === 'string') parsed[k] = Number(parsed[k].replace(/[^0-9.\-]/g, '')) || 0
}
if (Array.isArray(parsed.items)) {
for (const it of parsed.items) {
for (const k of ['qty', 'rate', 'amount']) {
if (typeof it[k] === 'string') it[k] = Number(it[k].replace(/[^0-9.\-]/g, '')) || 0
}
}
}
log(`Vision invoice: vendor=${parsed.vendor} total=${parsed.total} items=${(parsed.items || []).length}`)
return json(res, 200, parsed)
} catch (e) {
log('Vision invoice error:', e.message)
return json(res, 500, { error: 'Vision extraction failed: ' + e.message })
}
}
module.exports = { handleBarcodes, extractBarcodes, handleEquipment, handleInvoice }

View File

@ -105,6 +105,7 @@ const server = http.createServer(async (req, res) => {
if (path.startsWith('/payments') || path === '/webhook/stripe') return require('./lib/payments').handle(req, res, method, path, url)
if (path === '/vision/barcodes' && method === 'POST') return vision.handleBarcodes(req, res)
if (path === '/vision/equipment' && method === 'POST') return vision.handleEquipment(req, res)
if (path === '/vision/invoice' && method === 'POST') return vision.handleInvoice(req, res)
if (path.startsWith('/ai/')) return require('./lib/ai').handle(req, res, method, path)
if (path.startsWith('/modem')) return require('./lib/modem-bridge').handleModemRequest(req, res, path)
if (path.startsWith('/network/')) return require('./lib/network-intel').handle(req, res, method, path)