diff --git a/apps/ops/src/api/address.js b/apps/ops/src/api/address.js
index 7bc28b3..60f4d1c 100644
--- a/apps/ops/src/api/address.js
+++ b/apps/ops/src/api/address.js
@@ -25,3 +25,5 @@ export const conformityResolve = (body) => jpost('/address/conformity/resolve',
export const campingsList = () => jget('/address/conformity/campings')
export const campingsUpsert = (body) => jpost('/address/conformity/campings', body) // {keyword,name,address,latitude,longitude}
export const campingsApply = () => jpost('/address/conformity/campings/apply', {})
+// Dernier repli : placer les unmatched restants au centre du code postal (sinon ville) → statut 'area'
+export const conformityApplyArea = () => jpost('/address/conformity/apply-area', {})
diff --git a/apps/ops/src/pages/AddressConformityPage.vue b/apps/ops/src/pages/AddressConformityPage.vue
index c87ad3b..b5dab33 100644
--- a/apps/ops/src/pages/AddressConformityPage.vue
+++ b/apps/ops/src/pages/AddressConformityPage.vue
@@ -3,6 +3,7 @@
Conformité des adresses
+
Placer les adresses non matchées restantes au centre de leur code postal (sinon ville) — dernier repli
Rafraîchir
@@ -171,8 +172,9 @@ const typeN = (k) => { const x = stats.by_type.find(s => s.t === k); return x ?
const statusCards = computed(() => [
{ key: 'validated', label: 'Validées (conformes)', n: statN('validated'), cls: 'text-positive' },
{ key: 'review', label: 'À confirmer', n: statN('review'), cls: 'text-amber-8' },
+ { key: 'area', label: '≈ Secteur (CP/ville)', n: statN('area'), cls: 'text-blue-grey-7' },
{ key: 'unmatched', label: 'Non matchées', n: statN('unmatched'), cls: 'text-deep-orange' },
- { key: 'no_address', label: 'Sans adresse (rejetées)', n: statN('no_address'), cls: 'text-grey-7' },
+ { key: 'no_address', label: 'Rejetées (sans adresse)', n: statN('no_address'), cls: 'text-grey-7' },
])
const typeChips = computed(() => [
{ key: '', label: 'Tout', n: statN('review') + statN('unmatched') },
@@ -254,5 +256,16 @@ async function applyCampings () {
catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { campSaving.value = false }
}
+// ── Dernier repli : centre du code postal / ville pour les unmatched restants ──
+const areaSaving = ref(false)
+async function applyArea () {
+ areaSaving.value = true
+ try {
+ const r = await addr.conformityApplyArea()
+ $q.notify({ type: 'positive', message: `${r.total || 0} placés au secteur (${r.by_postal || 0} par code postal, ${r.by_city || 0} par ville)`, timeout: 2600 })
+ reload()
+ } catch (e) { $q.notify({ type: 'negative', message: 'Échec: ' + e.message }) } finally { areaSaving.value = false }
+}
+
onMounted(() => { loadStats(); loadList(); loadCampings() })
diff --git a/services/targo-hub/lib/address-conformity.js b/services/targo-hub/lib/address-conformity.js
index edfc81d..b515ecb 100644
--- a/services/targo-hub/lib/address-conformity.js
+++ b/services/targo-hub/lib/address-conformity.js
@@ -30,6 +30,33 @@ async function applyCampings (p) {
return r.rows
}
+// Dernier repli : pour les 'unmatched' qu'on n'a pas pu géocoder, placer au CENTRE (centroïde rqa_addresses)
+// du code postal (préféré, plus précis) sinon de la ville → le job apparaît au moins dans le bon secteur.
+// Statut 'area' (approximatif). Les hors-QC/junk (pas dans rqa_addresses) restent 'unmatched'.
+async function applyAreaFallback (p) {
+ const r = await p.query(`
+ WITH um AS (
+ SELECT name, replace(upper(coalesce(postal_code,'')),' ','') cp, lower(unaccent(coalesce(city,''))) city,
+ postal_code cp_raw, city city_raw
+ FROM "tabService Location" WHERE address_validation_status='unmatched'
+ ),
+ pc AS (SELECT replace(upper(code_postal),' ','') cp, avg(latitude) lat, avg(longitude) lon FROM rqa_addresses
+ WHERE replace(upper(code_postal),' ','') IN (SELECT cp FROM um WHERE cp ~ '^[A-Z][0-9][A-Z][0-9]') GROUP BY 1),
+ cc AS (SELECT lower(unaccent(ville)) city, avg(latitude) lat, avg(longitude) lon FROM rqa_addresses
+ WHERE lower(unaccent(ville)) IN (SELECT city FROM um WHERE city NOT IN ('','n/a','ville','x','-')) GROUP BY 1),
+ upd AS (
+ UPDATE "tabService Location" sl SET
+ latitude=COALESCE(pc.lat,cc.lat), longitude=COALESCE(pc.lon,cc.lon),
+ linked_address='≈ centre ' || COALESCE(NULLIF(CASE WHEN pc.lat IS NOT NULL THEN um.cp_raw END,''), um.city_raw),
+ address_validation_status='area', address_validated_at=NOW(), modified=NOW()
+ FROM um LEFT JOIN pc ON pc.cp=um.cp LEFT JOIN cc ON cc.city=um.city
+ WHERE sl.name=um.name AND (pc.lat IS NOT NULL OR cc.lat IS NOT NULL)
+ RETURNING (pc.lat IS NOT NULL) AS by_postal)
+ SELECT count(*)::int total, count(*) FILTER (WHERE by_postal)::int by_postal,
+ count(*) FILTER (WHERE NOT by_postal)::int by_city FROM upd`)
+ return r.rows[0]
+}
+
// Type d'adresse (pour trier la file) : camping (sobriquet de lot), civique à corriger, non-adresse, review standard.
const TYPE_SQL = `CASE
WHEN sl.city ILIKE '%camping%' OR sl.address_line ILIKE '%camping%' THEN 'camping'
@@ -137,6 +164,10 @@ async function handle (req, res, method, path) {
if (path === '/address/conformity/campings/apply' && method === 'POST') {
cors(res); return json(res, 200, { ok: true, applied: await applyCampings(pool()) })
}
+ // Repli centroïde (centre du CP/ville) pour les unmatched restants
+ if (path === '/address/conformity/apply-area' && method === 'POST') {
+ cors(res); return json(res, 200, { ok: true, ...(await applyAreaFallback(pool())) })
+ }
cors(res); return json(res, 404, { ok: false, error: 'route conformité inconnue' })
} catch (e) {