'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 { json, parseBody, log, cors } = require('./helpers') const { searchRaw, pool } = require('./address-db') // réutilise le pool LOCAL partagé (rqa_addresses + fiber) // 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 }