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>
128 lines
7.1 KiB
JavaScript
128 lines
7.1 KiB
JavaScript
'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 }
|