diff --git a/apps/ops/src/api/address.js b/apps/ops/src/api/address.js new file mode 100644 index 0000000..5347925 --- /dev/null +++ b/apps/ops/src/api/address.js @@ -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) diff --git a/apps/ops/src/config/nav.js b/apps/ops/src/config/nav.js index db73e21..b5583e9 100644 --- a/apps/ops/src/config/nav.js +++ b/apps/ops/src/config/nav.js @@ -11,6 +11,7 @@ export const navItems = [ { path: '/equipe', icon: 'UsersRound', label: 'Équipe', requires: 'manage_users' }, { path: '/rapports', icon: 'BarChart3', label: 'Rapports', requires: 'view_dashboard_kpi' }, { 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: '/settings', icon: 'Settings', label: 'Paramètres', requires: 'view_settings' }, ] diff --git a/apps/ops/src/layouts/MainLayout.vue b/apps/ops/src/layouts/MainLayout.vue index 5ecf02f..9b76cd5 100644 --- a/apps/ops/src/layouts/MainLayout.vue +++ b/apps/ops/src/layouts/MainLayout.vue @@ -124,13 +124,13 @@ import { navItems as allNavItems } from 'src/config/nav' import { LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3, Gift, Settings, LogOut, PanelLeftOpen, PanelLeftClose, Mail, - CalendarRange, CalendarClock, Sparkles, + CalendarRange, CalendarClock, Sparkles, MapPinned, } from 'lucide-vue-next' import ConversationPanel from 'src/components/shared/ConversationPanel.vue' import { useConversations } from 'src/composables/useConversations' 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() function toggleConvPanel () { panelOpen.value = !panelOpen.value } diff --git a/apps/ops/src/pages/AddressConformityPage.vue b/apps/ops/src/pages/AddressConformityPage.vue new file mode 100644 index 0000000..97dfd28 --- /dev/null +++ b/apps/ops/src/pages/AddressConformityPage.vue @@ -0,0 +1,207 @@ + + + diff --git a/apps/ops/src/router/index.js b/apps/ops/src/router/index.js index f9da577..7abbb6d 100644 --- a/apps/ops/src/router/index.js +++ b/apps/ops/src/router/index.js @@ -39,6 +39,7 @@ const routes = [ { path: 'telephony', component: () => import('src/pages/TelephonyPage.vue') }, { path: 'dispatch', component: () => import('src/pages/DispatchPage.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: 'copilote', component: () => import('src/pages/CopilotePage.vue') }, { path: 'agent-flows', component: () => import('src/pages/AgentFlowsPage.vue') }, diff --git a/services/targo-hub/lib/address-conformity.js b/services/targo-hub/lib/address-conformity.js new file mode 100644 index 0000000..c20d48a --- /dev/null +++ b/services/targo-hub/lib/address-conformity.js @@ -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 } diff --git a/services/targo-hub/server.js b/services/targo-hub/server.js index 98f9222..cbd78d2 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/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('/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)