Page Ops « Conformité des adresses » — source de vérité unique pour résoudre le backlog
Plus besoin de re-chercher avec un processus complexe : une page liste les adresses de service non conformes (review/unmatched) avec leur proposition AQ canonique, et permet de RÉSOUDRE une fois (persisté) : - Approuver : la proposition AQ devient officielle (validated, coords RQA). - Corriger : recherche AQ locale (rqa_addresses + fibre) → lier la bonne adresse. - GPS : saisir/coller lat,long (relevé sur map.targointernet.com qui a la géoloc des unités de camping) + lien direct « voir sur la carte » par ligne. - Rejeter : pas d'adresse civique (boîte postale/hors-QC) → 'no_address'. Tri par type (camping / civique à corriger / à confirmer / non-adresse) + stats + recherche + pagination. Backend : lib/address-conformity.js (GET stats|list|candidates, POST resolve) sur le Postgres LOCAL, routé /address/conformity/* (server.js). Front : api/address.js + pages/AddressConformityPage.vue + route /conformite-adresses + entrée nav « Conformité adresses » (icône MapPinned, requires view_settings). État courant : validated 15 195 · review 1 366 · unmatched 550 (camping 540 / civique 333 / non-adresse 93). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0edf2fe3df
commit
27bbcf43d0
22
apps/ops/src/api/address.js
Normal file
22
apps/ops/src/api/address.js
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
/**
|
||||||
|
* API Conformité des adresses — appelle targo-hub /address/conformity/*.
|
||||||
|
* Source de vérité : Service Location (lien AQ local rqa_addresses). Voir services/targo-hub/lib/address-conformity.js.
|
||||||
|
*/
|
||||||
|
import { HUB_URL as HUB } from 'src/config/hub'
|
||||||
|
|
||||||
|
async function jget (path) {
|
||||||
|
const r = await fetch(HUB + path)
|
||||||
|
if (!r.ok) throw new Error('Address API ' + r.status)
|
||||||
|
return r.json()
|
||||||
|
}
|
||||||
|
async function jpost (path, body) {
|
||||||
|
const r = await fetch(HUB + path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) })
|
||||||
|
if (!r.ok) throw new Error('Address API ' + r.status)
|
||||||
|
return r.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const conformityStats = () => jget('/address/conformity/stats')
|
||||||
|
export const conformityList = (p) => jget('/address/conformity/list?' + new URLSearchParams(p).toString())
|
||||||
|
export const conformityCandidates = (q) => jget('/address/conformity/candidates?q=' + encodeURIComponent(q))
|
||||||
|
// action: approve | correct | gps | reject (+ aq_address_id/linked_address/latitude/longitude selon l'action)
|
||||||
|
export const conformityResolve = (body) => jpost('/address/conformity/resolve', body)
|
||||||
|
|
@ -11,6 +11,7 @@ export const navItems = [
|
||||||
{ path: '/equipe', icon: 'UsersRound', label: 'Équipe', requires: 'manage_users' },
|
{ path: '/equipe', icon: 'UsersRound', label: 'Équipe', requires: 'manage_users' },
|
||||||
{ path: '/rapports', icon: 'BarChart3', label: 'Rapports', requires: 'view_dashboard_kpi' },
|
{ path: '/rapports', icon: 'BarChart3', label: 'Rapports', requires: 'view_dashboard_kpi' },
|
||||||
{ path: '/campaigns', icon: 'Gift', label: 'Campagnes', requires: 'manage_users' },
|
{ path: '/campaigns', icon: 'Gift', label: 'Campagnes', requires: 'manage_users' },
|
||||||
|
{ path: '/conformite-adresses', icon: 'MapPinned', label: 'Conformité adresses', requires: 'view_settings' },
|
||||||
{ path: '/email-queue', icon: 'Mail', label: 'File courriels', requires: 'view_settings' },
|
{ path: '/email-queue', icon: 'Mail', label: 'File courriels', requires: 'view_settings' },
|
||||||
{ path: '/settings', icon: 'Settings', label: 'Paramètres', requires: 'view_settings' },
|
{ path: '/settings', icon: 'Settings', label: 'Paramètres', requires: 'view_settings' },
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -124,13 +124,13 @@ import { navItems as allNavItems } from 'src/config/nav'
|
||||||
import {
|
import {
|
||||||
LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3,
|
LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3,
|
||||||
Gift, Settings, LogOut, PanelLeftOpen, PanelLeftClose, Mail,
|
Gift, Settings, LogOut, PanelLeftOpen, PanelLeftClose, Mail,
|
||||||
CalendarRange, CalendarClock, Sparkles,
|
CalendarRange, CalendarClock, Sparkles, MapPinned,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import ConversationPanel from 'src/components/shared/ConversationPanel.vue'
|
import ConversationPanel from 'src/components/shared/ConversationPanel.vue'
|
||||||
import { useConversations } from 'src/composables/useConversations'
|
import { useConversations } from 'src/composables/useConversations'
|
||||||
import FlowEditorDialog from 'src/components/flow-editor/FlowEditorDialog.vue'
|
import FlowEditorDialog from 'src/components/flow-editor/FlowEditorDialog.vue'
|
||||||
|
|
||||||
const icons = { LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3, Gift, Settings, LogOut, PanelLeftOpen, PanelLeftClose, Mail, CalendarRange, CalendarClock, Sparkles }
|
const icons = { LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3, Gift, Settings, LogOut, PanelLeftOpen, PanelLeftClose, Mail, CalendarRange, CalendarClock, Sparkles, MapPinned }
|
||||||
|
|
||||||
const { panelOpen, activeCount: convCount } = useConversations()
|
const { panelOpen, activeCount: convCount } = useConversations()
|
||||||
function toggleConvPanel () { panelOpen.value = !panelOpen.value }
|
function toggleConvPanel () { panelOpen.value = !panelOpen.value }
|
||||||
|
|
|
||||||
207
apps/ops/src/pages/AddressConformityPage.vue
Normal file
207
apps/ops/src/pages/AddressConformityPage.vue
Normal file
|
|
@ -0,0 +1,207 @@
|
||||||
|
<template>
|
||||||
|
<q-page class="q-pa-md ops-page">
|
||||||
|
<div class="row items-center q-mb-sm">
|
||||||
|
<div class="text-h6 text-weight-bold">Conformité des adresses</div>
|
||||||
|
<q-space />
|
||||||
|
<q-btn flat dense round icon="refresh" @click="reload" :loading="loading"><q-tooltip>Rafraîchir</q-tooltip></q-btn>
|
||||||
|
</div>
|
||||||
|
<div class="text-caption text-grey-7 q-mb-md">
|
||||||
|
Source de vérité : adresse canonique Adresses Québec (RQA locale) liée à chaque emplacement de service.
|
||||||
|
Résous ici les adresses non conformes — la décision est persistée (plus de re-recherche).
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Statistiques -->
|
||||||
|
<div class="row q-col-gutter-sm q-mb-md">
|
||||||
|
<div v-for="s in statusCards" :key="s.key" class="col-6 col-sm-3">
|
||||||
|
<q-card flat bordered class="text-center q-pa-sm">
|
||||||
|
<div class="text-h6 text-weight-bold" :class="s.cls">{{ s.n }}</div>
|
||||||
|
<div class="text-caption text-grey-7">{{ s.label }}</div>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filtres par type -->
|
||||||
|
<div class="row items-center q-gutter-sm q-mb-sm">
|
||||||
|
<q-chip v-for="t in typeChips" :key="t.key" clickable :selected="filter.type === t.key"
|
||||||
|
:color="filter.type === t.key ? 'primary' : 'grey-3'" :text-color="filter.type === t.key ? 'white' : 'grey-9'"
|
||||||
|
@click="setType(t.key)">{{ t.label }} <q-badge v-if="t.n != null" color="white" text-color="primary" class="q-ml-xs">{{ t.n }}</q-badge></q-chip>
|
||||||
|
<q-space />
|
||||||
|
<q-input dense outlined v-model="filter.q" debounce="400" placeholder="Rechercher adresse / ville / client" style="min-width:260px" @update:model-value="reload">
|
||||||
|
<template #prepend><q-icon name="search" /></template>
|
||||||
|
</q-input>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Liste -->
|
||||||
|
<q-table flat bordered :rows="rows" :columns="columns" row-key="name" :loading="loading"
|
||||||
|
v-model:pagination="pagination" :rows-per-page-options="[25,50,100]" @request="onRequest" :rows-number="total" binary-state-sort>
|
||||||
|
<template #body-cell-original="props">
|
||||||
|
<q-td :props="props">
|
||||||
|
<div class="text-weight-medium">{{ props.row.address_line }}</div>
|
||||||
|
<div class="text-caption text-grey-7">{{ props.row.city }} {{ props.row.postal_code }} · <span class="text-grey-6">{{ props.row.customer }}</span></div>
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
<template #body-cell-type="props">
|
||||||
|
<q-td :props="props"><q-chip dense square :color="typeColor(props.row.type)" text-color="white" class="text-caption">{{ typeLabel(props.row.type) }}</q-chip></q-td>
|
||||||
|
</template>
|
||||||
|
<template #body-cell-proposition="props">
|
||||||
|
<q-td :props="props">
|
||||||
|
<div v-if="props.row.linked_address">{{ props.row.linked_address }}
|
||||||
|
<q-badge v-if="hasCoord(props.row)" color="green-6" class="q-ml-xs">GPS</q-badge>
|
||||||
|
</div>
|
||||||
|
<span v-else class="text-grey-5">— (aucune ; utiliser Corriger)</span>
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
<template #body-cell-actions="props">
|
||||||
|
<q-td :props="props" class="text-right" style="white-space:nowrap">
|
||||||
|
<q-btn v-if="props.row.linked_address" dense flat round color="positive" icon="check" @click="resolve(props.row,'approve')"><q-tooltip>Approuver la proposition</q-tooltip></q-btn>
|
||||||
|
<q-btn dense flat round color="primary" icon="edit_location_alt" @click="openCorrect(props.row)"><q-tooltip>Corriger (chercher la bonne adresse AQ)</q-tooltip></q-btn>
|
||||||
|
<q-btn dense flat round color="teal" icon="my_location" @click="openGps(props.row)"><q-tooltip>Saisir/relever le GPS</q-tooltip></q-btn>
|
||||||
|
<q-btn dense flat round color="grey-7" icon="map" type="a" :href="mapUrl(props.row)" target="_blank"><q-tooltip>Voir sur map.targointernet.com (géoloc des unités)</q-tooltip></q-btn>
|
||||||
|
<q-btn dense flat round color="negative" icon="block" @click="resolve(props.row,'reject')"><q-tooltip>Rejeter (pas d'adresse civique)</q-tooltip></q-btn>
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
<template #no-data><div class="full-width text-center q-pa-md text-grey-6">Aucune adresse à traiter dans ce filtre 🎉</div></template>
|
||||||
|
</q-table>
|
||||||
|
|
||||||
|
<!-- Dialogue Corriger : recherche AQ locale -->
|
||||||
|
<q-dialog v-model="correct.open">
|
||||||
|
<q-card style="min-width:420px;max-width:560px">
|
||||||
|
<q-card-section class="q-pb-none">
|
||||||
|
<div class="text-subtitle1 text-weight-bold">Corriger l'adresse</div>
|
||||||
|
<div class="text-caption text-grey-7">Originale : {{ correct.row && correct.row.address_line }}, {{ correct.row && correct.row.city }}</div>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section>
|
||||||
|
<q-input dense outlined autofocus v-model="correct.q" debounce="350" placeholder="Chercher l'adresse Adresses Québec…" @update:model-value="searchCandidates" :loading="correct.loading">
|
||||||
|
<template #prepend><q-icon name="search" /></template>
|
||||||
|
</q-input>
|
||||||
|
<q-list separator class="q-mt-sm" style="max-height:300px;overflow:auto">
|
||||||
|
<q-item v-for="c in correct.candidates" :key="c.id" clickable v-ripple @click="applyCorrect(c)">
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>{{ c.address_full }}</q-item-label>
|
||||||
|
<q-item-label caption>{{ Math.round((c.similarity_score||0)*100) }}% · {{ c.latitude && (+c.latitude).toFixed(5) }}, {{ c.longitude && (+c.longitude).toFixed(5) }}</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section side><q-badge v-if="c.fiber_available" color="green-6">FIBRE</q-badge></q-item-section>
|
||||||
|
</q-item>
|
||||||
|
<q-item v-if="correct.q && !correct.loading && !correct.candidates.length"><q-item-section class="text-grey-6">Aucun résultat — affine la recherche.</q-item-section></q-item>
|
||||||
|
</q-list>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-actions align="right"><q-btn flat label="Fermer" v-close-popup /></q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
|
||||||
|
<!-- Dialogue GPS manuel -->
|
||||||
|
<q-dialog v-model="gps.open">
|
||||||
|
<q-card style="min-width:360px">
|
||||||
|
<q-card-section class="q-pb-none">
|
||||||
|
<div class="text-subtitle1 text-weight-bold">Position GPS</div>
|
||||||
|
<div class="text-caption text-grey-7">{{ gps.row && gps.row.address_line }}, {{ gps.row && gps.row.city }}</div>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section class="q-gutter-sm">
|
||||||
|
<q-banner dense class="bg-grey-2 text-caption">Astuce : relève la coordonnée de l'unité sur
|
||||||
|
<a :href="gps.row && mapUrl(gps.row)" target="_blank">map.targointernet.com</a> puis colle « lat, long » ci-dessous.</q-banner>
|
||||||
|
<q-input dense outlined v-model="gps.paste" label="Coller « lat, long »" @update:model-value="parsePaste" />
|
||||||
|
<div class="row q-col-gutter-sm">
|
||||||
|
<q-input class="col" dense outlined type="number" step="any" v-model.number="gps.lat" label="Latitude" />
|
||||||
|
<q-input class="col" dense outlined type="number" step="any" v-model.number="gps.lon" label="Longitude" />
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-actions align="right">
|
||||||
|
<q-btn flat label="Annuler" v-close-popup />
|
||||||
|
<q-btn unelevated color="teal" label="Enregistrer" :disable="!gpsValid" @click="saveGps" />
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</q-page>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, computed, onMounted } from 'vue'
|
||||||
|
import { useQuasar } from 'quasar'
|
||||||
|
import * as addr from 'src/api/address'
|
||||||
|
|
||||||
|
const $q = useQuasar()
|
||||||
|
const loading = ref(false)
|
||||||
|
const rows = ref([])
|
||||||
|
const total = ref(0)
|
||||||
|
const stats = reactive({ by_status: [], by_type: [] })
|
||||||
|
const filter = reactive({ type: '', q: '' })
|
||||||
|
const pagination = ref({ page: 1, rowsPerPage: 50, rowsNumber: 0 })
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ name: 'original', label: 'Adresse (originale)', field: 'address_line', align: 'left' },
|
||||||
|
{ name: 'type', label: 'Type', field: 'type', align: 'left' },
|
||||||
|
{ name: 'proposition', label: 'Proposition AQ (canonique)', field: 'linked_address', align: 'left' },
|
||||||
|
{ name: 'status', label: 'Statut', field: 'status', align: 'left' },
|
||||||
|
{ name: 'actions', label: '', field: 'actions', align: 'right' },
|
||||||
|
]
|
||||||
|
const TYPES = { camping: { label: 'Camping', color: 'deep-orange-5' }, civique: { label: 'Civique à corriger', color: 'blue-6' }, review: { label: 'À confirmer', color: 'amber-7' }, non_adresse: { label: 'Non-adresse', color: 'grey-6' } }
|
||||||
|
const typeLabel = (t) => (TYPES[t] || {}).label || t
|
||||||
|
const typeColor = (t) => (TYPES[t] || {}).color || 'grey-6'
|
||||||
|
const hasCoord = (r) => r.latitude != null && Math.abs(parseFloat(r.latitude)) > 0.0001
|
||||||
|
|
||||||
|
const statN = (k) => { const x = stats.by_status.find(s => s.s === k); return x ? Number(x.n) : 0 }
|
||||||
|
const typeN = (k) => { const x = stats.by_type.find(s => s.t === k); return x ? Number(x.n) : 0 }
|
||||||
|
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: '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' },
|
||||||
|
])
|
||||||
|
const typeChips = computed(() => [
|
||||||
|
{ key: '', label: 'Tout', n: statN('review') + statN('unmatched') },
|
||||||
|
{ key: 'civique', label: 'Civique à corriger', n: typeN('civique') },
|
||||||
|
{ key: 'camping', label: 'Camping', n: typeN('camping') },
|
||||||
|
{ key: 'review', label: 'À confirmer', n: typeN('review') },
|
||||||
|
{ key: 'non_adresse', label: 'Non-adresse', n: typeN('non_adresse') },
|
||||||
|
])
|
||||||
|
|
||||||
|
function mapUrl (row) {
|
||||||
|
const q = encodeURIComponent([row.address_line, row.city].filter(Boolean).join(', '))
|
||||||
|
return 'https://map.targointernet.com/infrastructure/map.php?q=' + q
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStats () { try { const r = await addr.conformityStats(); stats.by_status = r.by_status || []; stats.by_type = r.by_type || [] } catch (e) {} }
|
||||||
|
async function loadList () {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const offset = (pagination.value.page - 1) * pagination.value.rowsPerPage
|
||||||
|
const r = await addr.conformityList({ type: filter.type, q: filter.q, limit: pagination.value.rowsPerPage, offset })
|
||||||
|
rows.value = r.rows || []; total.value = r.total || 0; pagination.value.rowsNumber = r.total || 0
|
||||||
|
} catch (e) { $q.notify({ type: 'negative', message: 'Chargement échoué: ' + e.message }) } finally { loading.value = false }
|
||||||
|
}
|
||||||
|
function onRequest (props) { pagination.value.page = props.pagination.page; pagination.value.rowsPerPage = props.pagination.rowsPerPage; loadList() }
|
||||||
|
function setType (t) { filter.type = t; pagination.value.page = 1; loadList() }
|
||||||
|
function reload () { pagination.value.page = 1; loadStats(); loadList() }
|
||||||
|
|
||||||
|
async function resolve (row, action, extra) {
|
||||||
|
try {
|
||||||
|
await addr.conformityResolve({ name: row.name, action, ...(extra || {}) })
|
||||||
|
rows.value = rows.value.filter(r => r.name !== row.name); total.value = Math.max(0, total.value - 1)
|
||||||
|
loadStats()
|
||||||
|
$q.notify({ type: 'positive', message: action === 'reject' ? 'Marquée sans adresse' : 'Adresse validée ✓', timeout: 1200 })
|
||||||
|
} catch (e) { $q.notify({ type: 'negative', message: 'Échec: ' + e.message }) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Corriger ──
|
||||||
|
const correct = reactive({ open: false, row: null, q: '', candidates: [], loading: false })
|
||||||
|
function openCorrect (row) { correct.row = row; correct.q = [row.address_line, row.city].filter(Boolean).join(' '); correct.candidates = []; correct.open = true; searchCandidates() }
|
||||||
|
async function searchCandidates () {
|
||||||
|
if (!correct.q || correct.q.length < 3) { correct.candidates = []; return }
|
||||||
|
correct.loading = true
|
||||||
|
try { const r = await addr.conformityCandidates(correct.q); correct.candidates = r.results || [] } catch (e) {} finally { correct.loading = false }
|
||||||
|
}
|
||||||
|
function applyCorrect (c) {
|
||||||
|
const row = correct.row
|
||||||
|
resolve(row, 'correct', { aq_address_id: String(c.id), linked_address: c.address_full, latitude: c.latitude, longitude: c.longitude })
|
||||||
|
correct.open = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GPS manuel ──
|
||||||
|
const gps = reactive({ open: false, row: null, lat: null, lon: null, paste: '' })
|
||||||
|
const gpsValid = computed(() => isFinite(gps.lat) && isFinite(gps.lon) && Math.abs(gps.lat) > 0.0001 && Math.abs(gps.lon) > 0.0001)
|
||||||
|
function openGps (row) { gps.row = row; gps.lat = row.latitude ? +row.latitude : null; gps.lon = row.longitude ? +row.longitude : null; gps.paste = ''; gps.open = true }
|
||||||
|
function parsePaste () { const m = String(gps.paste).match(/(-?\d+\.\d+)[ ,]+(-?\d+\.\d+)/); if (m) { gps.lat = +m[1]; gps.lon = +m[2] } }
|
||||||
|
function saveGps () { if (!gpsValid.value) return; resolve(gps.row, 'gps', { latitude: gps.lat, longitude: gps.lon }); gps.open = false }
|
||||||
|
|
||||||
|
onMounted(() => { loadStats(); loadList() })
|
||||||
|
</script>
|
||||||
|
|
@ -39,6 +39,7 @@ const routes = [
|
||||||
{ path: 'telephony', component: () => import('src/pages/TelephonyPage.vue') },
|
{ path: 'telephony', component: () => import('src/pages/TelephonyPage.vue') },
|
||||||
{ path: 'dispatch', component: () => import('src/pages/DispatchPage.vue') },
|
{ path: 'dispatch', component: () => import('src/pages/DispatchPage.vue') },
|
||||||
{ path: 'planification', component: () => import('src/pages/PlanificationPage.vue') },
|
{ path: 'planification', component: () => import('src/pages/PlanificationPage.vue') },
|
||||||
|
{ path: 'conformite-adresses', component: () => import('src/pages/AddressConformityPage.vue') },
|
||||||
{ path: 'rdv', component: () => import('src/pages/RendezVousPage.vue') },
|
{ path: 'rdv', component: () => import('src/pages/RendezVousPage.vue') },
|
||||||
{ path: 'copilote', component: () => import('src/pages/CopilotePage.vue') },
|
{ path: 'copilote', component: () => import('src/pages/CopilotePage.vue') },
|
||||||
{ path: 'agent-flows', component: () => import('src/pages/AgentFlowsPage.vue') },
|
{ path: 'agent-flows', component: () => import('src/pages/AgentFlowsPage.vue') },
|
||||||
|
|
|
||||||
127
services/targo-hub/lib/address-conformity.js
Normal file
127
services/targo-hub/lib/address-conformity.js
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
'use strict'
|
||||||
|
/**
|
||||||
|
* address-conformity.js — back-end de la page Ops « Conformité des adresses ».
|
||||||
|
*
|
||||||
|
* Source de vérité : Service Location ERPNext (tabService Location) avec son lien AQ
|
||||||
|
* (aq_address_id → rqa_addresses, linked_address canonique, address_validation_status). Cette page
|
||||||
|
* permet de RÉSOUDRE une fois pour toutes les adresses non conformes (review/unmatched) :
|
||||||
|
* - Approuver : la proposition AQ devient officielle (statut validated, coords RQA).
|
||||||
|
* - Corriger : chercher la bonne adresse AQ (recherche locale) et la lier.
|
||||||
|
* - GPS manuel : poser lat/long (ex. relevé sur map.targointernet.com qui a la géoloc des unités).
|
||||||
|
* - Rejeter : pas d'adresse AQ (boîte postale, hors-QC…) → statut 'no_address'.
|
||||||
|
*
|
||||||
|
* Tout est LOCAL (Postgres ERPNext). Routes sous /address/conformity/* (cf. server.js).
|
||||||
|
*/
|
||||||
|
const { Pool } = require('pg')
|
||||||
|
const { json, parseBody, log } = require('./helpers')
|
||||||
|
const { searchRaw } = require('./address-db')
|
||||||
|
|
||||||
|
let _pool
|
||||||
|
function pool () {
|
||||||
|
if (!_pool) {
|
||||||
|
_pool = new Pool({
|
||||||
|
host: process.env.ADDR_DB_HOST || 'erpnext-db-1', port: +(process.env.ADDR_DB_PORT || 5432),
|
||||||
|
user: process.env.ADDR_DB_USER || 'postgres', password: process.env.ADDR_DB_PASS || '123',
|
||||||
|
database: process.env.ADDR_DB_NAME || '_eb65bdc0c4b1b2d6', max: 4, idleTimeoutMillis: 30000,
|
||||||
|
})
|
||||||
|
_pool.on('error', e => log('address-conformity pool:', e.message))
|
||||||
|
}
|
||||||
|
return _pool
|
||||||
|
}
|
||||||
|
function cors (res) {
|
||||||
|
res.setHeader('Access-Control-Allow-Origin', '*')
|
||||||
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
||||||
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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'
|
||||||
|
WHEN sl.address_validation_status='unmatched' AND sl.address_line ~ '^[0-9]' AND sl.city NOT IN ('','N/A','Ville','x','-') THEN 'civique'
|
||||||
|
WHEN sl.address_validation_status='unmatched' THEN 'non_adresse'
|
||||||
|
ELSE 'review' END`
|
||||||
|
|
||||||
|
async function handle (req, res, method, path) {
|
||||||
|
try {
|
||||||
|
if (method === 'OPTIONS') { cors(res); res.writeHead(204); res.end(); return }
|
||||||
|
|
||||||
|
// Compteurs par statut + par type (file de travail).
|
||||||
|
if (path === '/address/conformity/stats' && method === 'GET') {
|
||||||
|
const p = pool()
|
||||||
|
const byStatus = (await p.query('SELECT address_validation_status s, count(*) n FROM "tabService Location" GROUP BY 1 ORDER BY 2 DESC')).rows
|
||||||
|
const byType = (await p.query(`SELECT ${TYPE_SQL} t, count(*) n FROM "tabService Location" sl WHERE address_validation_status IN ('review','unmatched') GROUP BY 1 ORDER BY 2 DESC`)).rows
|
||||||
|
cors(res); return json(res, 200, { ok: true, by_status: byStatus, by_type: byType })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Liste paginée des adresses à traiter (review/unmatched), filtrable par type + recherche texte.
|
||||||
|
if (path === '/address/conformity/list' && method === 'GET') {
|
||||||
|
const u = new URL(req.url, 'http://localhost')
|
||||||
|
const type = u.searchParams.get('type') || ''
|
||||||
|
const q = (u.searchParams.get('q') || '').trim()
|
||||||
|
const limit = Math.min(parseInt(u.searchParams.get('limit')) || 50, 200)
|
||||||
|
const offset = Math.max(parseInt(u.searchParams.get('offset')) || 0, 0)
|
||||||
|
const where = [`sl.address_validation_status IN ('review','unmatched')`]
|
||||||
|
const params = []
|
||||||
|
if (type) { params.push(type); where.push(`${TYPE_SQL} = $${params.length}`) }
|
||||||
|
if (q) { params.push('%' + q + '%'); where.push(`(sl.address_line ILIKE $${params.length} OR sl.city ILIKE $${params.length} OR sl.customer ILIKE $${params.length} OR sl.name ILIKE $${params.length})`) }
|
||||||
|
const whereSql = where.join(' AND ')
|
||||||
|
const p = pool()
|
||||||
|
const total = (await p.query(`SELECT count(*) n FROM "tabService Location" sl WHERE ${whereSql}`, params)).rows[0].n
|
||||||
|
params.push(limit); params.push(offset)
|
||||||
|
const rows = (await p.query(
|
||||||
|
`SELECT sl.name, sl.customer, sl.address_line, sl.city, sl.postal_code,
|
||||||
|
sl.address_validation_status AS status, sl.aq_address_id, sl.linked_address,
|
||||||
|
sl.latitude, sl.longitude, ${TYPE_SQL} AS type
|
||||||
|
FROM "tabService Location" sl WHERE ${whereSql}
|
||||||
|
ORDER BY ${TYPE_SQL}, sl.city, sl.address_line
|
||||||
|
LIMIT $${params.length - 1} OFFSET $${params.length}`, params)).rows
|
||||||
|
cors(res); return json(res, 200, { ok: true, total: Number(total), limit, offset, rows })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Candidats AQ pour l'action « Corriger » (recherche locale rqa_addresses + fibre).
|
||||||
|
if (path === '/address/conformity/candidates' && method === 'GET') {
|
||||||
|
const u = new URL(req.url, 'http://localhost')
|
||||||
|
const q = (u.searchParams.get('q') || '').trim()
|
||||||
|
let results = []
|
||||||
|
try { results = await searchRaw(q, 8) } catch (e) { log('conformity/candidates:', e.message) }
|
||||||
|
cors(res); return json(res, 200, { ok: true, results })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Résoudre une adresse : approve | correct | gps | reject. Écrit le lien/coords/statut (source de vérité).
|
||||||
|
if (path === '/address/conformity/resolve' && method === 'POST') {
|
||||||
|
const b = await parseBody(req)
|
||||||
|
const name = b.name
|
||||||
|
if (!name) { cors(res); return json(res, 400, { ok: false, error: 'name requis' }) }
|
||||||
|
const p = pool()
|
||||||
|
const set = ['address_validated_at = NOW()', 'modified = NOW()']
|
||||||
|
const params = []
|
||||||
|
const add = (frag, val) => { params.push(val); set.push(`${frag} = $${params.length}`) }
|
||||||
|
if (b.action === 'approve') {
|
||||||
|
// garde la proposition existante (aq_address_id/linked_address) + coords déjà raffinées → validated
|
||||||
|
set.push(`address_validation_status = 'validated'`)
|
||||||
|
} else if (b.action === 'correct') {
|
||||||
|
if (b.aq_address_id != null) add('aq_address_id', String(b.aq_address_id))
|
||||||
|
if (b.linked_address != null) add('linked_address', b.linked_address)
|
||||||
|
if (b.latitude != null) add('latitude', Number(b.latitude))
|
||||||
|
if (b.longitude != null) add('longitude', Number(b.longitude))
|
||||||
|
set.push(`address_validation_status = 'validated'`)
|
||||||
|
} else if (b.action === 'gps') {
|
||||||
|
if (b.latitude == null || b.longitude == null) { cors(res); return json(res, 400, { ok: false, error: 'latitude/longitude requis' }) }
|
||||||
|
add('latitude', Number(b.latitude)); add('longitude', Number(b.longitude))
|
||||||
|
set.push(`address_validation_status = 'validated'`) // position confirmée manuellement
|
||||||
|
} else if (b.action === 'reject') {
|
||||||
|
set.push(`address_validation_status = 'no_address'`) // pas d'adresse civique (boîte postale, hors-QC…)
|
||||||
|
} else { cors(res); return json(res, 400, { ok: false, error: 'action inconnue' }) }
|
||||||
|
params.push(name)
|
||||||
|
const r = await p.query(`UPDATE "tabService Location" SET ${set.join(', ')} WHERE name = $${params.length}`, params)
|
||||||
|
cors(res); return json(res, 200, { ok: true, updated: r.rowCount, name, action: b.action })
|
||||||
|
}
|
||||||
|
|
||||||
|
cors(res); return json(res, 404, { ok: false, error: 'route conformité inconnue' })
|
||||||
|
} catch (e) {
|
||||||
|
log('address-conformity error:', e.message)
|
||||||
|
cors(res); return json(res, 500, { ok: false, error: String(e.message || e) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { handle }
|
||||||
|
|
@ -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/conformity')) return require('./lib/address-conformity').handle(req, res, method, path)
|
||||||
if (path.startsWith('/address/') || path.startsWith('/rpc/')) return require('./lib/address-validate').handle(req, res, method, path)
|
if (path.startsWith('/address/') || path.startsWith('/rpc/')) 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)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user