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:
parent
41d9b5f316
commit
e50ea88c08
|
|
@ -91,7 +91,7 @@ GenieACS Twilio Traccar modem-bridge
|
||||||
**Frontend:** Vue 3, Quasar v2, Pinia, Vite, Mapbox GL JS
|
**Frontend:** Vue 3, Quasar v2, Pinia, Vite, Mapbox GL JS
|
||||||
**Backend:** ERPNext v16 / Frappe (Python), PostgreSQL, Node.js (targo-hub)
|
**Backend:** ERPNext v16 / Frappe (Python), PostgreSQL, Node.js (targo-hub)
|
||||||
**Infra:** Docker, Traefik v2.11, Authentik SSO, Proxmox
|
**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)
|
## Data Volumes (migrated from legacy)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,16 +16,9 @@ server {
|
||||||
proxy_set_header X-Forwarded-Proto https;
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Ollama Vision API proxy — for bill/invoice OCR (legacy, optional)
|
# NOTE: Ollama Vision proxy removed 2026-04-22 — all invoice OCR and
|
||||||
location /ollama/ {
|
# barcode/equipment scans now go directly to targo-hub (Gemini 2.5 Flash).
|
||||||
resolver 127.0.0.11 valid=10s;
|
# See docs/VISION_AND_OCR.md.
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Targo Hub API proxy — vision, devices, etc.
|
# Targo Hub API proxy — vision, devices, etc.
|
||||||
location /hub/ {
|
location /hub/ {
|
||||||
|
|
|
||||||
|
|
@ -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_URL = 'https://msg.gigafibre.ca'
|
||||||
const HUB_VISION_URL = 'https://msg.gigafibre.ca/vision/barcodes'
|
|
||||||
|
|
||||||
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`
|
||||||
|
|
||||||
{
|
function stripDataUri (base64Image) {
|
||||||
"vendor": "company name on the bill",
|
return String(base64Image || '').replace(/^data:image\/[^;]+;base64,/, '')
|
||||||
"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.)"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
If a field is not visible, set it to null. Always return valid JSON.`
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send an image to Ollama Vision for bill/invoice OCR.
|
* Send a photo to Gemini (via hub) for bill/invoice OCR.
|
||||||
* @param {string} base64Image — base64 encoded image (no data: prefix)
|
* @param {string} base64Image — base64 or data URI
|
||||||
* @returns {object} Parsed invoice data
|
* @returns {Promise<object>} Parsed invoice (see targo-hub/lib/vision.js INVOICE_SCHEMA)
|
||||||
*/
|
*/
|
||||||
export async function ocrBill (base64Image) {
|
export async function ocrBill (base64Image) {
|
||||||
// Strip data:image/...;base64, prefix if present
|
const res = await fetch(VISION_INVOICE, {
|
||||||
const clean = base64Image.replace(/^data:image\/[^;]+;base64,/, '')
|
|
||||||
|
|
||||||
const res = await authFetch(OLLAMA_URL, {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({ image: stripDataUri(base64Image) }),
|
||||||
model: 'llama3.2-vision:11b',
|
|
||||||
prompt: OCR_PROMPT,
|
|
||||||
images: [clean],
|
|
||||||
stream: false,
|
|
||||||
options: {
|
|
||||||
temperature: 0.1,
|
|
||||||
num_predict: 2048,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text()
|
const text = await res.text().catch(() => '')
|
||||||
throw new Error('OCR failed: ' + (text || res.status))
|
throw new Error('Invoice 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)
|
|
||||||
}
|
}
|
||||||
|
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
|
* @param {string} base64Image — base64 or data URI
|
||||||
* @returns {{ barcodes: string[] }}
|
* @returns {Promise<{ barcodes: string[] }>}
|
||||||
*/
|
*/
|
||||||
export async function scanBarcodes (base64Image) {
|
export async function scanBarcodes (base64Image) {
|
||||||
// Direct call to targo-hub (cross-origin, no auth needed)
|
const res = await fetch(VISION_BARCODES, {
|
||||||
const res = await fetch(HUB_VISION_URL, {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ image: base64Image }),
|
body: JSON.stringify({ image: base64Image }),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text()
|
const text = await res.text().catch(() => '')
|
||||||
throw new Error('Vision scan failed: ' + (text || res.status))
|
throw new Error('Vision scan failed: ' + (text || res.status))
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
return { barcodes: data.barcodes || [] }
|
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 () {
|
export async function checkOllamaStatus () {
|
||||||
try {
|
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 }
|
if (!res.ok) return { online: false, error: 'HTTP ' + res.status }
|
||||||
const data = await res.json()
|
return { online: true, models: ['gemini-2.5-flash'], hasVision: true }
|
||||||
const models = (data.models || []).map(m => m.name)
|
|
||||||
const hasVision = models.some(m => m.includes('llama3.2-vision'))
|
|
||||||
return { online: true, models, hasVision }
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { online: false, error: e.message }
|
return { online: false, error: e.message }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,15 +19,10 @@ server {
|
||||||
proxy_set_header X-Forwarded-Proto https;
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Ollama Vision API proxy — for bill/invoice OCR (dynamic resolve, won't crash if ollama is down)
|
# NOTE: Ollama Vision proxy removed 2026-04-22 — invoice OCR and all
|
||||||
location /ollama/ {
|
# barcode/equipment scans now go directly to targo-hub (Gemini 2.5 Flash).
|
||||||
set $ollama_upstream http://ollama:11434;
|
# See docs/VISION_AND_OCR.md. The hub handles CORS + rate-limit, so no
|
||||||
proxy_pass $ollama_upstream/;
|
# nginx pass-through is needed here.
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_read_timeout 300s;
|
|
||||||
proxy_send_timeout 300s;
|
|
||||||
client_max_body_size 20m;
|
|
||||||
}
|
|
||||||
|
|
||||||
# SPA fallback — all routes serve index.html
|
# SPA fallback — all routes serve index.html
|
||||||
location / {
|
location / {
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
"@twilio/voice-sdk": "^2.18.1",
|
"@twilio/voice-sdk": "^2.18.1",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"cytoscape": "^3.33.2",
|
"cytoscape": "^3.33.2",
|
||||||
|
"idb-keyval": "^6.2.1",
|
||||||
"lucide-vue-next": "^1.0.0",
|
"lucide-vue-next": "^1.0.0",
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
"quasar": "^2.16.10",
|
"quasar": "^2.16.10",
|
||||||
|
|
|
||||||
|
|
@ -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
|
import { HUB_URL } from 'src/config/hub'
|
||||||
// In production: /ops/ollama/... → Traefik strips /ops → nginx /ollama/ → Ollama
|
|
||||||
const BASE = import.meta.env.BASE_URL || '/'
|
|
||||||
const OLLAMA_URL = BASE + 'ollama/api/generate'
|
|
||||||
|
|
||||||
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`
|
||||||
|
|
||||||
{
|
/** Strip any `data:image/...;base64,` prefix — hub accepts either form but
|
||||||
"vendor": "company name on the bill",
|
* we normalize here so error messages + logs stay consistent. */
|
||||||
"vendor_address": "full address if visible",
|
function stripDataUri (base64Image) {
|
||||||
"invoice_number": "invoice/bill number",
|
return String(base64Image || '').replace(/^data:image\/[^;]+;base64,/, '')
|
||||||
"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.)"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
If a field is not visible, set it to null. Always return valid JSON.`
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send an image to Ollama Vision for bill/invoice OCR.
|
* Send a photo to Gemini (via hub) for bill/invoice OCR.
|
||||||
* @param {string} base64Image — base64 encoded image (no data: prefix)
|
* @param {string} base64Image — base64 or data URI
|
||||||
* @returns {object} Parsed invoice data
|
* @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) {
|
export async function ocrBill (base64Image) {
|
||||||
// Strip data:image/...;base64, prefix if present
|
const res = await fetch(VISION_INVOICE, {
|
||||||
const clean = base64Image.replace(/^data:image\/[^;]+;base64,/, '')
|
|
||||||
|
|
||||||
const res = await authFetch(OLLAMA_URL, {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({ image: stripDataUri(base64Image) }),
|
||||||
model: 'llama3.2-vision:11b',
|
|
||||||
prompt: OCR_PROMPT,
|
|
||||||
images: [clean],
|
|
||||||
stream: false,
|
|
||||||
options: {
|
|
||||||
temperature: 0.1,
|
|
||||||
num_predict: 2048,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text()
|
const text = await res.text().catch(() => '')
|
||||||
throw new Error('OCR failed: ' + (text || res.status))
|
throw new Error('Invoice 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)
|
|
||||||
}
|
}
|
||||||
|
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 () {
|
export async function checkOllamaStatus () {
|
||||||
try {
|
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 }
|
if (!res.ok) return { online: false, error: 'HTTP ' + res.status }
|
||||||
const data = await res.json()
|
return { online: true, models: ['gemini-2.5-flash'], hasVision: true }
|
||||||
const models = (data.models || []).map(m => m.name)
|
|
||||||
const hasVision = models.some(m => m.includes('llama3.2-vision'))
|
|
||||||
return { online: true, models, hasVision }
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { online: false, error: e.message }
|
return { online: false, error: e.message }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 SCAN_TIMEOUT_MS = 8000
|
||||||
const barcodes = ref([])
|
|
||||||
const scanning = ref(false)
|
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 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) {
|
async function processPhoto (file) {
|
||||||
if (!file) return []
|
if (!file) return []
|
||||||
error.value = null
|
error.value = null
|
||||||
scanning.value = true
|
scanning.value = true
|
||||||
const found = []
|
|
||||||
|
let aiImage = null
|
||||||
|
const photoIdx = photos.value.length
|
||||||
|
let found = []
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const thumbUrl = await resizeImage(file, 400)
|
const thumbUrl = await resizeImage(file, 400)
|
||||||
lastPhoto.value = thumbUrl
|
lastPhoto.value = thumbUrl
|
||||||
const aiImage = await resizeImage(file, 1600, 0.92)
|
photos.value.push({ url: thumbUrl, ts: Date.now(), codes: [], queued: false })
|
||||||
const res = await fetch(`${HUB_BASE}/vision/barcodes`, {
|
|
||||||
method: 'POST',
|
// Keep high-res for text readability (small serial fonts).
|
||||||
headers: { 'Content-Type': 'application/json' },
|
aiImage = await resizeImage(file, 1600, 0.92)
|
||||||
body: JSON.stringify({ image: aiImage }),
|
|
||||||
})
|
const result = await scanBarcodesWithTimeout(aiImage, SCAN_TIMEOUT_MS)
|
||||||
if (!res.ok) throw new Error('Vision scan failed: ' + res.status)
|
found = mergeCodes(result.barcodes || [], 'photo')
|
||||||
const data = await res.json()
|
photos.value[photoIdx].codes = found
|
||||||
const existing = new Set(barcodes.value.map(b => b.value))
|
|
||||||
for (const code of (data.barcodes || [])) {
|
if (found.length === 0) {
|
||||||
if (barcodes.value.length >= 5) break
|
error.value = 'Aucun code détecté — rapprochez-vous ou améliorez la mise au point'
|
||||||
if (!existing.has(code)) {
|
|
||||||
existing.add(code)
|
|
||||||
barcodes.value.push({ value: code })
|
|
||||||
found.push(code)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (!found.length) error.value = 'Aucun code detecte — rapprochez-vous ou ameliorez la mise au point'
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e.message || 'Erreur'
|
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 {
|
} finally {
|
||||||
scanning.value = false
|
scanning.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
return found
|
return found
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Smart equipment label scan — returns structured fields
|
* Process a photo for structured equipment-label extraction.
|
||||||
* { brand, model, serial_number, mac_address, gpon_sn, hw_version, equipment_type, barcodes }
|
*
|
||||||
|
* 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) {
|
async function scanEquipmentLabel (file) {
|
||||||
if (!file) return null
|
if (!file) return null
|
||||||
|
|
@ -54,32 +158,13 @@ export function useScanner () {
|
||||||
const thumbUrl = await resizeImage(file, 400)
|
const thumbUrl = await resizeImage(file, 400)
|
||||||
lastPhoto.value = thumbUrl
|
lastPhoto.value = thumbUrl
|
||||||
const aiImage = await resizeImage(file, 1600, 0.92)
|
const aiImage = await resizeImage(file, 1600, 0.92)
|
||||||
const res = await fetch(`${HUB_BASE}/vision/equipment`, {
|
const data = await apiScanEquipmentLabel(aiImage)
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
if (data?.barcodes?.length) mergeCodes(data.barcodes, 'equipment')
|
||||||
body: JSON.stringify({ image: aiImage }),
|
if (data?.serial_number) addCode(data.serial_number, 'equipment')
|
||||||
})
|
|
||||||
if (!res.ok) throw new Error('Vision scan failed: ' + res.status)
|
if (!data?.serial_number && !data?.barcodes?.length) {
|
||||||
const data = await res.json()
|
error.value = 'Aucun identifiant détecté — rapprochez-vous ou améliorez la mise au point'
|
||||||
// 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'
|
|
||||||
}
|
}
|
||||||
return data
|
return data
|
||||||
} catch (e) {
|
} 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) {
|
function resizeImage (file, maxDim, quality = 0.85) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const img = new Image()
|
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 () {
|
function clearBarcodes () {
|
||||||
barcodes.value = []
|
barcodes.value = []
|
||||||
error.value = null
|
error.value = null
|
||||||
lastPhoto.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,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
401
apps/ops/src/modules/tech/pages/TechDevicePage.vue
Normal file
401
apps/ops/src/modules/tech/pages/TechDevicePage.vue
Normal 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>
|
||||||
|
|
@ -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 field→ops
|
||||||
|
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>
|
<template>
|
||||||
<q-page padding class="scan-page">
|
<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 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-card-section class="q-py-sm row items-center no-wrap">
|
||||||
<q-icon name="work" color="primary" class="q-mr-sm" />
|
<q-icon name="work" color="primary" class="q-mr-sm" />
|
||||||
|
|
@ -12,49 +41,189 @@
|
||||||
</div>
|
</div>
|
||||||
<q-btn flat dense size="sm" icon="close" @click="jobContext = null" />
|
<q-btn flat dense size="sm" icon="close" @click="jobContext = null" />
|
||||||
</q-card-section>
|
</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>
|
</q-card>
|
||||||
|
|
||||||
<!-- Manual entry -->
|
<!-- Camera capture button -->
|
||||||
<q-input v-model="manualCode" label="Numero de serie / MAC" outlined dense class="q-mb-md" @keyup.enter="lookupDevice">
|
<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>
|
<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>
|
</template>
|
||||||
</q-input>
|
</q-input>
|
||||||
|
|
||||||
<!-- Results -->
|
<!-- Scanned barcodes — max 5 (see useScanner.MAX_BARCODES) -->
|
||||||
<q-card v-if="result" class="q-mb-md">
|
<div v-if="scanner.barcodes.value.length > 0" class="q-mt-md">
|
||||||
<q-card-section v-if="result.found">
|
<div class="text-subtitle2 q-mb-xs">Codes détectés ({{ scanner.barcodes.value.length }})</div>
|
||||||
<div class="row items-center q-mb-sm">
|
<q-card v-for="bc in scanner.barcodes.value" :key="bc.value" class="q-mb-sm">
|
||||||
<q-badge color="green" label="Trouve" class="q-mr-sm" />
|
<q-card-section class="q-py-sm row items-center no-wrap">
|
||||||
<span class="text-subtitle2">{{ result.eq.equipment_type }} — {{ result.eq.brand }} {{ result.eq.model }}</span>
|
<q-icon name="qr_code" class="q-mr-sm" color="primary" />
|
||||||
</div>
|
<div class="col">
|
||||||
<div class="text-caption" style="font-family:monospace">SN: {{ result.eq.serial_number }}</div>
|
<div class="text-subtitle2 mono">{{ bc.value }}</div>
|
||||||
<div v-if="result.eq.customer_name" class="text-caption">Client: {{ result.eq.customer_name }}</div>
|
</div>
|
||||||
<div v-if="!result.eq.service_location && jobContext" class="q-mt-sm">
|
<q-btn flat dense icon="search" @click="lookupDevice(bc.value)" :loading="lookingUp === bc.value" />
|
||||||
<q-btn unelevated size="sm" color="primary" label="Lier a ce job" icon="link" @click="linkToJob(result.eq)" :loading="linking" />
|
<q-btn flat dense icon="close" color="negative" @click="scanner.removeBarcode(bc.value)" />
|
||||||
</div>
|
</q-card-section>
|
||||||
</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" />
|
|
||||||
</q-card-section>
|
|
||||||
</q-card>
|
|
||||||
|
|
||||||
<!-- Create dialog -->
|
<!-- 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>
|
||||||
|
|
||||||
|
<!-- 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-dialog v-model="createDialog">
|
||||||
<q-card style="min-width: 320px">
|
<q-card style="min-width: 320px">
|
||||||
<q-card-section class="text-h6">Nouvel equipement</q-card-section>
|
|
||||||
<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-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.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.model" label="Modèle" outlined dense class="q-mb-sm" />
|
||||||
<q-input v-model="newEquip.mac_address" label="MAC (optionnel)" outlined dense />
|
<q-input v-model="newEquip.mac_address" label="MAC (optionnel)" outlined dense class="q-mb-sm" />
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
<q-card-actions align="right">
|
<q-card-actions align="right">
|
||||||
<q-btn flat label="Annuler" v-close-popup />
|
<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-actions>
|
||||||
</q-card>
|
</q-card>
|
||||||
</q-dialog>
|
</q-dialog>
|
||||||
|
|
@ -62,77 +231,350 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
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 { Notify } from 'quasar'
|
||||||
import { BASE_URL } from 'src/config/erpnext'
|
|
||||||
|
|
||||||
const route = useRoute()
|
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 manualCode = ref('')
|
||||||
const lookingUp = ref(false)
|
const lookingUp = ref(null)
|
||||||
const result = ref(null)
|
const lookupResults = ref({})
|
||||||
const linking = ref(false)
|
|
||||||
const createDialog = ref(false)
|
const createDialog = ref(false)
|
||||||
const creating = 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 ? {
|
const jobContext = ref(route.query.job ? {
|
||||||
job: route.query.job, customer: route.query.customer, customer_name: route.query.customer_name,
|
job: route.query.job,
|
||||||
location: route.query.location, location_name: route.query.location_name,
|
customer: route.query.customer,
|
||||||
|
customer_name: route.query.customer_name,
|
||||||
|
location: route.query.location,
|
||||||
|
location_name: route.query.location_name,
|
||||||
} : null)
|
} : null)
|
||||||
|
|
||||||
async function apiFetch (url) {
|
const newEquip = ref({ serial_number: '', equipment_type: 'ONT', brand: '', model: '', mac_address: '' })
|
||||||
const res = await fetch(BASE_URL + url)
|
|
||||||
if (!res.ok) throw new Error('API ' + res.status)
|
const hasUnlinked = computed(() =>
|
||||||
return (await res.json()).data || []
|
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()
|
const code = manualCode.value.trim()
|
||||||
if (!code) return
|
if (!code) return
|
||||||
lookingUp.value = true; result.value = null
|
if (!scanner.barcodes.value.find(b => b.value === code)) {
|
||||||
try {
|
scanner.barcodes.value.push({ value: code, region: 'manuel' })
|
||||||
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')
|
lookupDevice(code)
|
||||||
if (!docs.length) {
|
}
|
||||||
const norm = code.replace(/[:\-\.]/g, '').toUpperCase()
|
manualCode.value = ''
|
||||||
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')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
result.value = docs.length ? { found: true, eq: docs[0] } : { found: false }
|
|
||||||
} catch { result.value = { found: false } }
|
|
||||||
finally { lookingUp.value = false }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function linkToJob (eq) {
|
// ─── Device lookup: 3-tier fallback ──────────────────────────────────
|
||||||
if (!jobContext.value) return
|
// 1. Exact serial_number match (how 95% of scans resolve)
|
||||||
linking.value = true
|
// 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 {
|
try {
|
||||||
const updates = {}
|
const results = await listDocs('Service Equipment', {
|
||||||
if (jobContext.value.customer) updates.customer = jobContext.value.customer
|
filters: { serial_number: serial },
|
||||||
if (jobContext.value.location) updates.service_location = jobContext.value.location
|
fields: ['name', 'serial_number', 'equipment_type', 'brand', 'model', 'customer', 'customer_name',
|
||||||
await fetch(BASE_URL + '/api/resource/Service Equipment/' + encodeURIComponent(eq.name), {
|
'service_location', 'status', 'mac_address'],
|
||||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updates),
|
limit: 1,
|
||||||
})
|
})
|
||||||
eq.customer = jobContext.value.customer
|
if (results.length > 0) {
|
||||||
eq.service_location = jobContext.value.location
|
lookupResults.value[serial] = { found: true, equipment: results[0] }
|
||||||
Notify.create({ type: 'positive', message: 'Lie au job', icon: 'link' })
|
} else {
|
||||||
} catch (e) { Notify.create({ type: 'negative', message: 'Erreur: ' + e.message }) }
|
const byBarcode = await listDocs('Service Equipment', {
|
||||||
finally { linking.value = false }
|
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 () {
|
async function createEquipment () {
|
||||||
creating.value = true
|
creating.value = true
|
||||||
|
const data = {
|
||||||
|
...newEquip.value,
|
||||||
|
status: 'Actif',
|
||||||
|
customer: jobContext.value?.customer || '',
|
||||||
|
service_location: jobContext.value?.location || '',
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const data = { ...newEquip.value, status: 'Actif', customer: jobContext.value?.customer || '', service_location: jobContext.value?.location || '' }
|
if (offline.online) {
|
||||||
const res = await fetch(BASE_URL + '/api/resource/Service Equipment', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) })
|
const doc = await createDoc('Service Equipment', data)
|
||||||
if (!res.ok) throw new Error('Create failed')
|
lookupResults.value[data.serial_number] = { found: true, equipment: doc }
|
||||||
const doc = (await res.json()).data
|
Notify.create({ type: 'positive', message: 'Équipement créé' })
|
||||||
result.value = { found: true, eq: doc }
|
} 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
|
createDialog.value = false
|
||||||
Notify.create({ type: 'positive', message: 'Equipement cree' })
|
} catch (e) {
|
||||||
} catch (e) { Notify.create({ type: 'negative', message: 'Erreur: ' + e.message }) }
|
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
|
||||||
finally { creating.value = false }
|
} 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>
|
</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>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
<div class="text-h6">Scanner une facture</div>
|
<div class="text-h6">Scanner une facture</div>
|
||||||
<q-space />
|
<q-space />
|
||||||
<q-badge v-if="ollamaStatus" :color="ollamaStatus.online ? 'green' : 'red'"
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Upload / Camera -->
|
<!-- Upload / Camera -->
|
||||||
|
|
@ -23,10 +23,10 @@
|
||||||
<q-card-section v-if="preview" class="text-center">
|
<q-card-section v-if="preview" class="text-center">
|
||||||
<img :src="preview" style="max-width:100%;max-height:400px;border-radius:8px" />
|
<img :src="preview" style="max-width:100%;max-height:400px;border-radius:8px" />
|
||||||
<div class="q-mt-md">
|
<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>
|
</div>
|
||||||
<q-linear-progress v-if="processing" indeterminate color="indigo-6" class="q-mt-sm" />
|
<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-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ const routes = [
|
||||||
{ path: '', name: 'tech-tasks', component: () => import('src/modules/tech/pages/TechTasksPage.vue') },
|
{ 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: '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: '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: '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') },
|
{ 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
|
// Magic link: /j/{jwt-token} — must be LAST to not capture static paths above
|
||||||
|
|
|
||||||
236
apps/ops/src/stores/offline.js
Normal file
236
apps/ops/src/stores/offline.js
Normal 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 field→ops
|
||||||
|
* 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,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
@ -73,6 +73,12 @@ Internet
|
||||||
- **Stack:** Playwright/Chromium (`:3301` internal).
|
- **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.
|
- **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
|
## 4. Security & Authentication Flow
|
||||||
|
|
|
||||||
|
|
@ -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
|
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
|
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
|
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
|
### Infrastructure / DevOps
|
||||||
1. [ARCHITECTURE.md](ARCHITECTURE.md) — network + container map
|
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
|
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
|
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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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-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 |
|
| [Gigafibre-Billing-Handoff.pptx](Gigafibre-Billing-Handoff.pptx) | Billing deck for finance handoff | Sharing with finance team |
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@
|
||||||
- [x] Dispatch module + ticket management
|
- [x] Dispatch module + ticket management
|
||||||
- [x] Equipment tracking with OLT/SNMP diagnostics
|
- [x] Equipment tracking with OLT/SNMP diagnostics
|
||||||
- [x] SMS/Email notifications (Twilio + Mailjet)
|
- [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] Field tech mobile (/t/{token})
|
||||||
- [x] Authentik federation (staff → client SSO)
|
- [x] Authentik federation (staff → client SSO)
|
||||||
- [x] Modem-bridge (Playwright headless for TP-Link ONU diagnostics)
|
- [x] Modem-bridge (Playwright headless for TP-Link ONU diagnostics)
|
||||||
|
|
|
||||||
482
docs/VISION_AND_OCR.md
Normal file
482
docs/VISION_AND_OCR.md
Normal 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
|
|
@ -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.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/barcodes' && method === 'POST') return vision.handleBarcodes(req, res)
|
||||||
if (path === '/vision/equipment' && method === 'POST') return vision.handleEquipment(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('/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('/modem')) return require('./lib/modem-bridge').handleModemRequest(req, res, path)
|
||||||
if (path.startsWith('/network/')) return require('./lib/network-intel').handle(req, res, method, path)
|
if (path.startsWith('/network/')) return require('./lib/network-intel').handle(req, res, method, path)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user