From ab7644e6de56f72733e527e1afa3beaea51bc82a Mon Sep 17 00:00:00 2001 From: louispaulb Date: Fri, 8 May 2026 11:01:32 -0400 Subject: [PATCH] =?UTF-8?q?fix(ops/dispatch):=20/desk//=20broken?= =?UTF-8?q?=20URL=20=E2=86=92=20/app//=20+=20add=20/address/validate?= =?UTF-8?q?=20hub?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things ride together because the user noticed the URL bug while testing the work-in-progress address validation: 1. **Broken Frappe URL pattern.** Three places in the dispatch UI were generating `/desk/Service Location/` and `/desk/Dispatch Technician/` links — both return "Page not found" on Frappe v14+ (= our v16) because the modern desk URL format is `/app//` where slug is lowercase + hyphens. Fixed in: • RightPanel.vue (Lieu link in the job details panel) • DispatchPage.vue (Lieu in the job ctx menu) • DispatchPage.vue (Ouvrir dans ERPNext in the tech ctx menu) 2. **`POST /address/validate` endpoint** on the hub. Wraps the existing RQA Supabase search (`address-search.js`) with a confidence-scored output: • exact_match (boolean) — score >= 0.7 • best (the top RQA candidate with aq_address_id, lat, lng) • candidates[] (top 5 ranked) • confidence (0..1) • recommendation: validated | review | unmatched Score combines civic-number exact match, road-name fuzzy overlap, FSA+full postal-code bonuses, and city-name bonus. The endpoint is called from ops UI when adding/editing a Service Location to auto-populate aq_address_id + canonical lat/lng instead of trusting human typing or Mapbox geocode. (Custom Fields aq_address_id, address_validation_status, address_validated_at, linked_address have been added on Service Location via the Frappe REST API in a separate operation — not in this commit since they're DB-only.) --- .../dispatch/components/RightPanel.vue | 5 +- apps/ops/src/pages/DispatchPage.vue | 4 +- services/targo-hub/lib/address-validate.js | 130 ++++++++++++++++++ services/targo-hub/server.js | 1 + 4 files changed, 137 insertions(+), 3 deletions(-) create mode 100644 services/targo-hub/lib/address-validate.js 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')
Lieu + + :href="'/app/service-location/' + panel.data.job.serviceLocation"> {{ 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]