diff --git a/apps/ops/src/modules/dispatch/components/RightPanel.vue b/apps/ops/src/modules/dispatch/components/RightPanel.vue index 6969262..1e26295 100644 --- a/apps/ops/src/modules/dispatch/components/RightPanel.vue +++ b/apps/ops/src/modules/dispatch/components/RightPanel.vue @@ -52,8 +52,11 @@ const onDeleteTag = inject('onDeleteTag')
{{ panel.data.job.serviceLocation }}
diff --git a/apps/ops/src/pages/DispatchPage.vue b/apps/ops/src/pages/DispatchPage.vue
index 59d8feb..be3bb04 100644
--- a/apps/ops/src/pages/DispatchPage.vue
+++ b/apps/ops/src/pages/DispatchPage.vue
@@ -1677,7 +1677,7 @@ onUnmounted(() => {
👤 Voir la fiche client ({{ ctxMenu.job.customer }})
+ :href="'/app/service-location/' + ctxMenu.job.serviceLocation" @click="closeCtxMenu()">
🏠 Lieu de service ({{ ctxMenu.job.serviceLocation }})
@@ -1693,7 +1693,7 @@ onUnmounted(() => {
-
+
diff --git a/services/targo-hub/lib/address-validate.js b/services/targo-hub/lib/address-validate.js
new file mode 100644
index 0000000..7760e47
--- /dev/null
+++ b/services/targo-hub/lib/address-validate.js
@@ -0,0 +1,130 @@
+'use strict'
+// Address validation against the RQA (Répertoire des adresses du Québec).
+//
+// Why this exists: every Service Location in ERPNext should have its lat/lng
+// derived from the official Quebec civic address registry rather than from
+// human typing or Mapbox geocode. Free-text geocoding is error-prone for
+// rural areas (we just hit a case where the SL coords pointed 9 km away
+// from the real address). RQA is the authoritative source — updated every
+// 2 weeks, includes a stable `identifiant_unique_adresse` per civic address.
+//
+// This module exposes ONE public route:
+// POST /address/validate
+// body: { address_line, postal_code?, city? }
+// returns: { exact_match, best, candidates, confidence }
+//
+// The heavy lifting (Supabase REST → RQA table search) lives in
+// ./address-search.js — already used by the customer onboarding wizard.
+// We layer a confidence score + canonical formatting on top.
+
+const { json, parseBody, log } = require('./helpers')
+const { searchAddresses } = require('./address-search')
+
+// Normalize for fuzzy comparison: lowercase, strip diacritics, collapse
+// whitespace, drop punctuation. Used to score how close a typed address
+// is to a RQA result.
+function normalizeForCompare (s) {
+ return (s || '')
+ .toString()
+ .toLowerCase()
+ .normalize('NFD').replace(/[̀-ͯ]/g, '')
+ .replace(/[^a-z0-9\s]/g, ' ')
+ .replace(/\s+/g, ' ')
+ .trim()
+}
+
+// 0..1 score: civic number must match exactly, road name fuzzy-includes,
+// postal code (first 3 chars) bonus when present, city name bonus.
+function scoreMatch (typed, rqaRow) {
+ const tNorm = normalizeForCompare(typed.address_line || '')
+ const rNum = String(rqaRow.numero_municipal || '')
+ const rRoad = normalizeForCompare(rqaRow.odonyme_recompose_normal || '')
+ const rCity = normalizeForCompare(rqaRow.nom_municipalite || '')
+ const rPC = (rqaRow.code_postal || '').replace(/\s+/g, '').toLowerCase()
+
+ let score = 0
+ // Civic number — must appear in the typed string
+ if (rNum && tNorm.match(new RegExp('\\b' + rNum + '\\b'))) score += 0.4
+ // Road name fuzzy: every word of the RQA road must appear in the typed
+ const rWords = rRoad.split(/\s+/).filter(w => w.length > 1 && !['rue','chemin','rang','route','avenue','boulevard'].includes(w))
+ if (rWords.length) {
+ const matched = rWords.filter(w => tNorm.includes(w)).length
+ score += 0.3 * (matched / rWords.length)
+ }
+ // City — small bonus
+ if (typed.city && rCity.includes(normalizeForCompare(typed.city))) score += 0.1
+ // Postal code — first 3 chars (FSA) is enough; full postal code = bigger bonus
+ if (typed.postal_code) {
+ const tPC = typed.postal_code.replace(/\s+/g, '').toLowerCase()
+ if (tPC && rPC) {
+ if (rPC.startsWith(tPC.slice(0, 3))) score += 0.1
+ if (rPC === tPC) score += 0.1
+ }
+ }
+ return Math.min(1, score)
+}
+
+async function handle (req, res, method, path) {
+ // POST /address/validate — score-rank RQA results for a free-text address.
+ if (path === '/address/validate' && method === 'POST') {
+ const body = await parseBody(req)
+ const addressLine = (body.address_line || '').trim()
+ if (!addressLine) return json(res, 400, { error: 'address_line required' })
+
+ // Build a search term — RQA's full-text search prefers "civic + road"
+ const term = body.postal_code
+ ? `${addressLine} ${body.postal_code}`
+ : addressLine
+
+ let rqaResults = []
+ try {
+ rqaResults = await searchAddresses(term, 12)
+ } catch (e) {
+ log('RQA search failed:', e.message)
+ return json(res, 502, { error: 'RQA unavailable: ' + e.message })
+ }
+
+ if (!rqaResults.length) {
+ return json(res, 200, {
+ exact_match: false,
+ best: null,
+ candidates: [],
+ confidence: 0,
+ recommendation: 'unmatched',
+ })
+ }
+
+ const scored = rqaResults
+ .map(r => ({
+ aq_address_id: r.identifiant_unique_adresse,
+ formatted: r.adresse_formatee,
+ civic: r.numero_municipal,
+ unit: r.numero_unite,
+ road: r.odonyme_recompose_normal,
+ city: r.nom_municipalite,
+ postal_code: r.code_postal,
+ latitude: r.latitude ? parseFloat(r.latitude) : null,
+ longitude: r.longitude ? parseFloat(r.longitude) : null,
+ score: scoreMatch({ address_line: addressLine, postal_code: body.postal_code, city: body.city }, r),
+ }))
+ .sort((a, b) => b.score - a.score)
+
+ const best = scored[0]
+ const exactMatch = best.score >= 0.7
+ const recommendation = exactMatch
+ ? 'validated'
+ : best.score >= 0.4 ? 'review' : 'unmatched'
+
+ return json(res, 200, {
+ exact_match: exactMatch,
+ best,
+ candidates: scored.slice(0, 5),
+ confidence: best.score,
+ recommendation,
+ })
+ }
+
+ return json(res, 404, { error: 'Not found' })
+}
+
+module.exports = { handle, scoreMatch, normalizeForCompare }
diff --git a/services/targo-hub/server.js b/services/targo-hub/server.js
index fc977e2..649de5d 100644
--- a/services/targo-hub/server.js
+++ b/services/targo-hub/server.js
@@ -98,6 +98,7 @@ const server = http.createServer(async (req, res) => {
if (path.startsWith('/auth/')) return auth.handle(req, res, method, path, url)
if (path.startsWith('/conversations')) return conversation.handle(req, res, method, path, url)
if (path.startsWith('/traccar')) return traccar.handle(req, res, method, path)
+ if (path.startsWith('/address/')) return require('./lib/address-validate').handle(req, res, method, path)
if (path.startsWith('/magic-link')) return require('./lib/magic-link').handle(req, res, method, path)
if (path.startsWith('/portal/')) return require('./lib/portal-auth').handle(req, res, method, path)
// Lightweight tech mobile page: /t/{token}[/action]