fix(ops/dispatch): /desk/<DocType>/ broken URL → /app/<slug>/ + add /address/validate hub
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/<id>` and
`/desk/Dispatch Technician/<id>` links — both return "Page not
found" on Frappe v14+ (= our v16) because the modern desk URL
format is `/app/<slug>/<id>` 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.)
This commit is contained in:
parent
f4ae023302
commit
ab7644e6de
|
|
@ -52,8 +52,11 @@ const onDeleteTag = inject('onDeleteTag')
|
||||||
</div>
|
</div>
|
||||||
<div v-if="panel.data?.job?.serviceLocation" class="sb-rp-field">
|
<div v-if="panel.data?.job?.serviceLocation" class="sb-rp-field">
|
||||||
<span class="sb-rp-lbl">Lieu</span>
|
<span class="sb-rp-lbl">Lieu</span>
|
||||||
|
<!-- Frappe v14+ desk URL: /app/<slug>/<name>, slug = lowercase
|
||||||
|
with hyphens. The legacy /desk/<DocType>/<name> format with
|
||||||
|
spaces returns "Page not found" on v16. -->
|
||||||
<a class="sb-rp-link" target="_blank" rel="noopener"
|
<a class="sb-rp-link" target="_blank" rel="noopener"
|
||||||
:href="'/desk/Service Location/' + panel.data.job.serviceLocation">
|
:href="'/app/service-location/' + panel.data.job.serviceLocation">
|
||||||
<code>{{ panel.data.job.serviceLocation }}</code>
|
<code>{{ panel.data.job.serviceLocation }}</code>
|
||||||
<span class="sb-rp-link-icon">↗</span>
|
<span class="sb-rp-link-icon">↗</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
|
|
@ -1677,7 +1677,7 @@ onUnmounted(() => {
|
||||||
👤 Voir la fiche client ({{ ctxMenu.job.customer }})
|
👤 Voir la fiche client ({{ ctxMenu.job.customer }})
|
||||||
</a>
|
</a>
|
||||||
<a v-if="ctxMenu?.job?.serviceLocation" class="sb-ctx-item sb-ctx-link" target="_blank" rel="noopener"
|
<a v-if="ctxMenu?.job?.serviceLocation" class="sb-ctx-item sb-ctx-link" target="_blank" rel="noopener"
|
||||||
:href="'/desk/Service Location/' + ctxMenu.job.serviceLocation" @click="closeCtxMenu()">
|
:href="'/app/service-location/' + ctxMenu.job.serviceLocation" @click="closeCtxMenu()">
|
||||||
🏠 Lieu de service ({{ ctxMenu.job.serviceLocation }})
|
🏠 Lieu de service ({{ ctxMenu.job.serviceLocation }})
|
||||||
</a>
|
</a>
|
||||||
<div class="sb-ctx-sep"></div>
|
<div class="sb-ctx-sep"></div>
|
||||||
|
|
@ -1693,7 +1693,7 @@ onUnmounted(() => {
|
||||||
<button class="sb-ctx-item" @click="openTechHomeDialog(techCtx.tech); techCtx=null">📍 Adresse de départ…</button>
|
<button class="sb-ctx-item" @click="openTechHomeDialog(techCtx.tech); techCtx=null">📍 Adresse de départ…</button>
|
||||||
<button class="sb-ctx-item" @click="startTechGeoFix(techCtx.tech); techCtx=null">🎯 Choisir sur la carte</button>
|
<button class="sb-ctx-item" @click="startTechGeoFix(techCtx.tech); techCtx=null">🎯 Choisir sur la carte</button>
|
||||||
<div class="sb-ctx-sep"></div>
|
<div class="sb-ctx-sep"></div>
|
||||||
<button class="sb-ctx-item" @click="window.open('/desk/Dispatch Technician/'+techCtx.tech.name,'_blank'); techCtx=null">↗ Ouvrir dans ERPNext</button>
|
<button class="sb-ctx-item" @click="window.open('/app/dispatch-technician/'+techCtx.tech.name,'_blank'); techCtx=null">↗ Ouvrir dans ERPNext</button>
|
||||||
</SbContextMenu>
|
</SbContextMenu>
|
||||||
|
|
||||||
<!-- Banner shown while in tech-home pick mode. ESC cancels. -->
|
<!-- Banner shown while in tech-home pick mode. ESC cancels. -->
|
||||||
|
|
|
||||||
130
services/targo-hub/lib/address-validate.js
Normal file
130
services/targo-hub/lib/address-validate.js
Normal file
|
|
@ -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 }
|
||||||
|
|
@ -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('/auth/')) return auth.handle(req, res, method, path, url)
|
||||||
if (path.startsWith('/conversations')) return conversation.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('/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('/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)
|
if (path.startsWith('/portal/')) return require('./lib/portal-auth').handle(req, res, method, path)
|
||||||
// Lightweight tech mobile page: /t/{token}[/action]
|
// Lightweight tech mobile page: /t/{token}[/action]
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user