gigafibre-fsm/services/targo-hub/lib/legacy-reports.js
louispaulb 512c4a5f1b feat(ops): dispatch auto complet + perf Boîte/rapports + fix session
Planificateur « Suggérer » : 4 stratégies (smart / meilleurs d'abord / équilibré /
juste ce qu'il faut), compétences+niveaux par tech (édition inline), niveau requis
par compétence + par job, carte des tournées (1 couleur/tech, domicile→arrêts,
sélecteur de jour), fenêtre de dispatch auj.+demain (dates sélectionnées), règle
week-end + placeholder « en attente du quart », clustering + lasso + filtre-date
sur la carte, accès rapide « À assigner » (badge).

Boîte : liste /conversations allégée (45 Mo → ~1 Mo, 17×) + messages chargés à
l'ouverture. Rapports : cache SWR sur revenue-explorer (22×). Session : keep-alive
+ timeout fetch global + authFetch durci → fin des rechargements manuels.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 08:49:35 -04:00

491 lines
25 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use strict'
/**
* legacy-reports.js — analytical reports that query the LEGACY MariaDB
* (gestionclient) directly, read-only. Distinct from lib/reports.js which
* targets the migrated ERPNext PostgreSQL data.
*
* Connection reuses cfg.LEGACY_DB_* (same vars as auth.js sync-legacy).
* A small pool is kept alive across requests for snappy report loads.
*
* Routes:
* GET /reports/legacy/overpriced-internet?threshold=90&segment=residential&addons=1
* → service addresses whose net MONTHLY Internet bill exceeds a
* threshold, discounts included. Used by Ops to spot clients who
* could be moved to a better-fit plan.
* GET /reports/legacy/overpriced-internet.csv (same params)
*
* Data model recap (validated 2026-06):
* account (commercial: 0=residential, 1=commercial)
* → delivery (account_id) one row per service address
* → service (delivery_id, status=1) active service line
* → product (category, price) the plan; price<0 = recurring discount
*
* Account-level filters (mirror the legacy recurring-billing logic in
* LEGACY-ACCOUNTING-ANALYSIS.md §6.1, which bills only when BOTH
* service.status=1 AND account.status=1):
* account.status = 1 → active account. status=4 is terminated
* (8602 accounts, most with a terminate_date,
* e.g. Or Viande Inc closed in 2014 but still
* carrying an orphan service.status=1 line).
* account.group_id = 5 → "Client" per the account_group table. Excludes
* 6 Prospect, 7 Fournisseur, 8 Relais (network
* infrastructure accounts like a tower-host),
* 10 Équipement motorisé — none are billable
* residential/commercial customers.
* customer_id NOT LIKE → 59 "PROPRIO*" accounts sit inside group 5 but
* 'PROPRIO%' are landowners hosting our relay/antenna gear
* under a special arrangement (e.g. Denis
* Henderson "PROPRIOH_STCHARLES"), not regular
* paying customers. Excluded by their id prefix.
*
* Effective monthly price of a service line:
* base = service.hijack=1 ? service.hijack_price : product.price
* // product.price is ALREADY the monthly unit price regardless of the
* // service's billing frequency — a semestrial FTTH1500I has price
* // 109.95 (same as the monthly one) and is billed 6×109.95 every 6
* // months. Verified against the legacy billing logic (LEGACY-
* // ACCOUNTING-ANALYSIS.md §6.1: "prix = quantité × prix_unitaire").
* // So we do NOT divide by service.payment_recurrence.
* // The one exception: true annual plans (SKU ends in "ANN", e.g.
* // FTTH_ANN @ 480$/yr where price IS the yearly amount) → normalize /12.
* monthly = base / (sku LIKE '%ANN' ? 12 : 1)
*
* Only recurring lines count: product.price_recurr_type = 1. Type 0 is
* one-time charges (equipment, installation) which don't belong on a
* recurring monthly bill.
*
* Expired credits excluded: a discount line (price<0) whose service
* actif_until is in the past no longer reduces the real bill — counting
* it would understate what the client actually pays today. (Per
* LEGACY-ACCOUNTING-ANALYSIS.md §10: promo credits carry an actif_until
* end date.)
*
* Internet product categories:
* monthly: 32 Mensualités fibre, 4 Mensualités sans-fil, 23 camping
* equipment: 26/29 équipement fibre, 7/8 équipement sans-fil
* (recurring modem/router rentals + Internet discounts that
* live here, e.g. RAB_FTTH_URBA). One-time install charges
* in these cats are dropped by the price_recurr_type=1 filter.
* add-ons: 16 Téléch. supp, 17 IP fixe, 21 Location point-à-point
* Excluded entirely: 9 Téléphonie, 33 Télévision, 34 Install télé
*/
const cfg = require('./config')
const { log, json } = require('./helpers')
const muni = require('./municipality') // résolveur de villes canoniques QC (nom officiel + code + MRC)
let mysql
try { mysql = require('mysql2/promise') } catch { /* optional dep */ }
// Internet category sets. CORE = monthly plans + Internet equipment.
// The equipment categories (26/29 fibre, 7/8 wireless) carry recurring
// modem/router rentals (FTTH_LOCMOD +10, LOC_TPL +5) AND recurring Internet
// discounts (RAB_FTTH_URBA), which are part of the real Internet bill — a
// client's net Internet cost is wrong without them. Example: Claude Bergeron
// shows 94.95 from cat 32 alone, but a -60$ RAB_FTTH_URBA discount lives in
// cat 26, so his true net is 44.95 (and he correctly drops off the report).
// The price_recurr_type=1 filter still excludes one-time install charges
// (INSTFIBRE -199, etc.) that share these equipment categories.
const CAT_INTERNET_CORE = [32, 4, 23, 26, 29, 7, 8]
const CAT_INTERNET_ADDONS = [16, 17, 21] // extra download, static IP, point-to-point
// Always excluded: 9 Téléphonie, 33 Télévision, 34 Installation/équip télé.
// Monthly-normalized effective price expression (see header comment).
const EFF = `((CASE WHEN s.hijack=1 THEN s.hijack_price ELSE p.price END) / (CASE WHEN p.sku LIKE '%ANN' THEN 12 ELSE 1 END))`
let _pool = null
function getPool () {
if (!mysql) return null
if (!_pool) {
_pool = mysql.createPool({
host: cfg.LEGACY_DB_HOST, user: cfg.LEGACY_DB_USER,
password: cfg.LEGACY_DB_PASS, database: cfg.LEGACY_DB_NAME,
connectionLimit: 3, waitForConnections: true, queueLimit: 0,
idleTimeout: 60000, enableKeepAlive: true,
})
}
return _pool
}
// Build the shared SQL + params from the request's query string. Used by
// both the JSON and CSV handlers so the two never drift apart.
function buildQuery (url) {
const threshold = Math.max(0, parseFloat(url.searchParams.get('threshold') || '90'))
const segment = url.searchParams.get('segment') || 'residential' // residential | commercial | all
const includeAddons = url.searchParams.get('addons') !== '0'
const limit = Math.min(5000, parseInt(url.searchParams.get('limit') || '2000', 10))
const cats = includeAddons ? [...CAT_INTERNET_CORE, ...CAT_INTERNET_ADDONS] : CAT_INTERNET_CORE
const catPlaceholders = cats.map(() => '?').join(',')
// A non-empty company name means a business even when the legacy `commercial`
// flag wasn't set — 98 such accounts (Ferme X Inc., Assurances …, Ville de
// Farnham, Les Jardins Sorel) leaked into "residential". So the rule is:
// commercial = (commercial flag set) OR (company name present). Residential
// therefore excludes any account carrying a company name.
const HAS_COMPANY = "(a.company IS NOT NULL AND TRIM(a.company) <> '')"
let commercialClause = ''
if (segment === 'residential') commercialClause = `AND a.commercial = 0 AND NOT ${HAS_COMPANY}`
else if (segment === 'commercial') commercialClause = `AND (a.commercial = 1 OR ${HAS_COMPANY})`
const sql = `
SELECT
a.id AS account_id,
a.customer_id AS customer_id,
TRIM(CONCAT(COALESCE(a.first_name,''),' ',COALESCE(a.last_name,''))) AS client_name,
a.company AS company,
a.email AS email,
a.cell AS cell,
a.tel_home AS tel_home,
a.commercial AS commercial,
d.id AS delivery_id,
d.address1 AS address1,
d.city AS city,
d.zip AS zip,
ROUND(SUM(${EFF}), 2) AS net_internet,
ROUND(SUM(CASE WHEN (${EFF}) > 0 THEN (${EFF}) ELSE 0 END), 2) AS gross,
ROUND(SUM(CASE WHEN (${EFF}) < 0 THEN (${EFF}) ELSE 0 END), 2) AS discounts,
COUNT(*) AS n_lines,
SUBSTRING(GROUP_CONCAT(
CONCAT(p.sku, '=', FORMAT(${EFF}, 2))
ORDER BY (${EFF}) DESC SEPARATOR ' · '
), 1, 500) AS detail,
-- Recently-expired discounts on this address: deactivated credit
-- lines (status=0, price<0) whose actif_until fell in the last 180
-- days. Explains a sudden bill jump (e.g. Julie Dupuis lost
-- RAB24M -15 + RAB_X -35 on 2026-03-01 → bill went 103→145$). The
-- prime retention signal — these clients are about to see the
-- increase and may shop around.
(SELECT ROUND(SUM(CASE WHEN s2.hijack=1 THEN s2.hijack_price ELSE p2.price END), 2)
FROM service s2 JOIN product p2 ON p2.id = s2.product_id
WHERE s2.delivery_id = d.id AND s2.status = 0 AND p2.price < 0
AND s2.actif_until IS NOT NULL
AND s2.actif_until < UNIX_TIMESTAMP()
AND s2.actif_until > (UNIX_TIMESTAMP() - 15552000)
) AS recent_expired_discount
FROM account a
JOIN delivery d ON d.account_id = a.id
JOIN service s ON s.delivery_id = d.id AND s.status = 1
JOIN product p ON p.id = s.product_id
WHERE p.category IN (${catPlaceholders})
AND a.status = 1
AND a.group_id = 5
AND a.customer_id NOT LIKE 'PROPRIO%'
AND p.price_recurr_type = 1
AND NOT (p.price < 0 AND s.actif_until IS NOT NULL AND s.actif_until > 0 AND s.actif_until < UNIX_TIMESTAMP())
${commercialClause}
GROUP BY d.id
HAVING net_internet > ?
ORDER BY net_internet DESC
LIMIT ?`
const params = [...cats, threshold, limit]
return { sql, params, meta: { threshold, segment, includeAddons, cats, limit } }
}
// Freshness probe — the copy is a one-shot snapshot (no auto-sync), so the
// report advertises its as-of date so nobody acts on stale prices.
//
// This report reads SERVICES (active plans/discounts), not invoices, so the
// relevant freshness is the newest service.date_orig — NOT the newest
// invoice date. They differ: the snapshot carries services created up to
// ~May 22 but invoices only through Apr 30 (May's recurring billing run
// hadn't happened at dump time). Using the invoice date understated
// freshness by ~3 weeks. We return both; the UI shows the service one.
// (A discount added after the snapshot — e.g. Marc Robidoux's loyalty
// credit, service id 74448 > the copy's max 74393 — still won't appear
// until the copy is refreshed.)
async function fetchDataAsOf (pool) {
const out = { services: null, invoices: null }
try {
const [s] = await pool.execute('SELECT MAX(date_orig) AS max_ts FROM service')
if (s?.[0]?.max_ts) out.services = new Date(s[0].max_ts * 1000).toISOString()
} catch { /* ignore */ }
try {
const [i] = await pool.execute('SELECT MAX(date_orig) AS max_ts FROM invoice')
if (i?.[0]?.max_ts) out.invoices = new Date(i[0].max_ts * 1000).toISOString()
} catch { /* ignore */ }
return out
}
async function handleJson (req, res, url) {
const pool = getPool()
if (!pool) return json(res, 503, { error: 'mysql2 not installed on hub' })
const { sql, params, meta } = buildQuery(url)
try {
const t0 = Date.now()
const [rows] = await pool.execute(sql, params)
const asOf = await fetchDataAsOf(pool)
log(`legacy report overpriced-internet: ${rows.length} rows in ${Date.now() - t0}ms (threshold=${meta.threshold}, segment=${meta.segment}, addons=${meta.includeAddons})`)
return json(res, 200, {
threshold: meta.threshold, segment: meta.segment,
include_addons: meta.includeAddons, categories: meta.cats,
// data_as_of = service freshness (the relevant one for this report);
// last_invoice kept for reference.
data_as_of: asOf.services,
last_invoice: asOf.invoices,
count: rows.length, rows,
})
} catch (e) {
log(`legacy report error: ${e.message}`)
return json(res, 500, { error: 'legacy query failed: ' + e.message })
}
}
async function handleCsv (req, res, url) {
const pool = getPool()
if (!pool) return json(res, 503, { error: 'mysql2 not installed on hub' })
const { sql, params, meta } = buildQuery(url)
try {
const [rows] = await pool.execute(sql, params)
const headers = ['account_id', 'customer_id', 'client_name', 'company', 'email', 'cell', 'tel_home',
'address1', 'city', 'zip', 'net_internet', 'gross', 'discounts', 'recent_expired_discount', 'n_lines', 'detail']
const esc = (v) => {
if (v == null) return ''
const s = String(v)
return /[",\r\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s
}
const lines = [headers.join(',')]
for (const r of rows) lines.push(headers.map(h => esc(r[h])).join(','))
const csv = '' + lines.join('\r\n') + '\r\n'
res.writeHead(200, {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': `attachment; filename="internet-cher-${meta.segment}-${meta.threshold}.csv"`,
'Cache-Control': 'no-store',
})
return res.end(csv)
} catch (e) {
return json(res, 500, { error: 'legacy query failed: ' + e.message })
}
}
// ─────────────────────────────────────────────────────────────────────────────
// EXPLORATEUR DE REVENUS — un seul rapport « revenu récurrent (MRR) par X »,
// où X (la DIMENSION) est choisie côté client. Remplace les rapports F fixes
// séparés (report_client_revenu / rapport_ville / report_count_service /
// report_count_res_com / rapport_poste …) par un group-by paramétrable.
//
// GET /reports/legacy/revenue-explorer
// ?dimension = category | zip | city | segment | plan (défaut category)
// &segment = residential | commercial | all (défaut all)
// &type = internet | tv | phone | all (défaut all)
// &active = 1 (défaut) | 0
// &threshold = 0 (net MRR minimum par groupe pour l'inclure)
// &limit = 300
// GET /reports/legacy/revenue-explorer.csv (mêmes params)
//
// Métrique = MRR net = Σ prix effectif mensuel (EFF) des lignes de service
// récurrentes actives (mêmes exclusions que le rapport internet-cher :
// group_id=5, pas PROPRIO, price_recurr_type=1, rabais expirés écartés).
// ─────────────────────────────────────────────────────────────────────────────
// Noms de catégories F (product.category est numérique). Source : en-tête de ce
// fichier + LEGACY-ACCOUNTING-ANALYSIS. Inconnues → « Catégorie N ».
const CATEGORY_NAMES = {
32: 'Mensualités fibre', 4: 'Mensualités sans fil', 23: 'Internet camping',
26: 'Équipement internet fibre', 29: 'Équipement internet fibre',
7: 'Équipement internet sans fil', 8: 'Équipement internet sans fil',
16: 'Téléchargement supplémentaire', 17: 'Adresse IP fixe', 21: 'Location point à point',
9: 'Téléphonie', 33: 'Mensualités télévision', 34: 'Installation/équipement télé',
}
// Exceptions de libellé de ville (« fichier de traduction ») : quand le nom officiel/dominant
// n'est pas celui qu'on veut afficher. Clé = libellé calculé, valeur = libellé voulu. À enrichir au besoin.
const CITY_LABEL_OVERRIDE = {
'Sainte-Clotilde-de-Châteauguay': 'Sainte-Clotilde',
}
const HAS_COMPANY = "(a.company IS NOT NULL AND TRIM(a.company) <> '')"
const EXPLORER_DIMS = {
category: 'p.category',
zip: "COALESCE(NULLIF(TRIM(d.zip),''),'(sans code postal)')",
city: "COALESCE(NULLIF(TRIM(d.city),''),'(sans ville)')",
segment: `CASE WHEN (a.commercial = 1 OR ${HAS_COMPANY}) THEN 'Commercial' ELSE 'Résidentiel' END`,
plan: "COALESCE(NULLIF(TRIM(p.name),''),p.sku,'(sans nom)')",
}
const DIM_LABELS = { category: 'Catégorie', zip: 'Code postal', city: 'Ville', segment: 'Segment', plan: 'Plan', mrc: 'MRC' }
// city + mrc : regroupés en SQL par d.city (ville brute), PUIS canonicalisés/agrégés en JS via le résolveur muni
// (unifie « Saint-Anicet » / « St-Anicet » / « Sainte Clotilde » / « Ste-Clotilde »… → un seul nom officiel).
const VALID_DIMS = ['category', 'zip', 'city', 'segment', 'plan', 'mrc']
const TYPE_CATS = {
internet: [...CAT_INTERNET_CORE, ...CAT_INTERNET_ADDONS],
tv: [33, 34],
phone: [9],
}
function buildExplorerQuery (url) {
const dimParam = url.searchParams.get('dimension')
const dimension = VALID_DIMS.includes(dimParam) ? dimParam : 'category'
const dimCol = EXPLORER_DIMS[dimension === 'mrc' ? 'city' : dimension] // mrc/city → SQL par ville, canonicalisé en JS
const segment = url.searchParams.get('segment') || 'all'
const type = url.searchParams.get('type') || 'all'
const active = url.searchParams.get('active') !== '0'
const threshold = Math.max(0, parseFloat(url.searchParams.get('threshold') || '0') || 0)
const limit = Math.min(1000, parseInt(url.searchParams.get('limit') || '300', 10))
let segmentClause = ''
if (segment === 'residential') segmentClause = `AND a.commercial = 0 AND NOT ${HAS_COMPANY}`
else if (segment === 'commercial') segmentClause = `AND (a.commercial = 1 OR ${HAS_COMPANY})`
const typeCats = TYPE_CATS[type] || null
const typeClause = typeCats ? `AND p.category IN (${typeCats.map(() => '?').join(',')})` : ''
const WHERE = `
FROM account a
JOIN delivery d ON d.account_id = a.id
JOIN service s ON s.delivery_id = d.id ${active ? 'AND s.status = 1' : ''}
JOIN product p ON p.id = s.product_id
WHERE a.group_id = 5
AND a.customer_id NOT LIKE 'PROPRIO%'
${active ? 'AND a.status = 1' : ''}
AND p.price_recurr_type = 1
AND NOT (p.price < 0 AND s.actif_until IS NOT NULL AND s.actif_until > 0 AND s.actif_until < UNIX_TIMESTAMP())
${typeClause} ${segmentClause}`
const sql = `
SELECT ${dimCol} AS dim_key,
ROUND(SUM(${EFF}), 2) AS net,
ROUND(SUM(CASE WHEN (${EFF}) > 0 THEN (${EFF}) ELSE 0 END), 2) AS gross,
ROUND(SUM(CASE WHEN (${EFF}) < 0 THEN (${EFF}) ELSE 0 END), 2) AS discounts,
COUNT(DISTINCT d.id) AS n_addresses,
COUNT(*) AS n_lines
${WHERE}
GROUP BY dim_key
${threshold > 0 ? 'HAVING net >= ?' : ''}
ORDER BY net DESC
LIMIT ?`
const totalSql = `
SELECT ROUND(SUM(${EFF}), 2) AS net,
ROUND(SUM(CASE WHEN (${EFF}) > 0 THEN (${EFF}) ELSE 0 END), 2) AS gross,
ROUND(SUM(CASE WHEN (${EFF}) < 0 THEN (${EFF}) ELSE 0 END), 2) AS discounts,
COUNT(DISTINCT d.id) AS n_addresses, COUNT(*) AS n_lines
${WHERE}`
const baseParams = typeCats ? [...typeCats] : []
const params = [...baseParams]
if (threshold > 0) params.push(threshold)
params.push(limit)
return { sql, params, totalSql, totalParams: baseParams, meta: { dimension, segment, type, active, threshold, limit } }
}
function labelOf (meta, key) {
return meta.dimension === 'category' ? (CATEGORY_NAMES[key] || ('Catégorie ' + key)) : String(key)
}
function round2 (x) { return Math.round((Number(x) || 0) * 100) / 100 }
// Finalise les groupes. Pour city/mrc : canonicalise chaque ville brute via le résolveur muni
// (nom officiel QC + MRC) et RÉAGRÈGE — les variantes d'un même nom fusionnent en une seule ligne.
// Pour les autres dimensions : applique simplement le libellé.
function titleCase (s) {
return String(s || '').toLowerCase().replace(/\b([a-zàâäéèêëîïôöùûüç])/g, c => c.toUpperCase())
}
async function finalizeRows (rows, meta) {
if (meta.dimension !== 'city' && meta.dimension !== 'mrc') {
return rows.map(r => ({ ...r, dim_label: labelOf(meta, r.dim_key) }))
}
try { await muni.load() } catch { /* résolveur indispo → on garde les villes brutes */ }
const merged = {}
for (const r of rows) {
let res = null
try { res = muni.resolveSync(r.dim_key) } catch { res = null }
const raw = String(r.dim_key || '').trim()
let key
if (meta.dimension === 'mrc') key = (res && res.matched && res.mrc) ? res.mrc : '(hors MRC)'
else key = (res && res.matched && res.canonical) ? res.canonical : (raw || '(sans ville)')
const g = merged[key] || (merged[key] = { dim_key: key, dim_label: null, _bestN: -1, net: 0, gross: 0, discounts: 0, n_addresses: 0, n_lines: 0 })
// Libellé ville = la graphie RÉELLE dominante du groupe (ex. « Saint-Anicet », pas « ST ANICET »).
const n = Number(r.n_addresses || 0)
if (meta.dimension === 'city' && raw && n > g._bestN) { g._bestN = n; g.dim_label = raw }
g.net += Number(r.net || 0); g.gross += Number(r.gross || 0); g.discounts += Number(r.discounts || 0)
g.n_addresses += n; g.n_lines += Number(r.n_lines || 0)
}
return Object.values(merged)
.map(g => ({
dim_key: g.dim_key,
dim_label: meta.dimension === 'mrc'
? titleCase(g.dim_key)
: (CITY_LABEL_OVERRIDE[g.dim_label || g.dim_key] || g.dim_label || g.dim_key),
net: round2(g.net), gross: round2(g.gross), discounts: round2(g.discounts),
n_addresses: g.n_addresses, n_lines: g.n_lines,
}))
.sort((a, b) => b.net - a.net)
}
// Cache stale-while-revalidate : la requête d'agrégation F (~2 s) est chère mais les revenus bougent lentement (sync horaire).
// Clé = signature (dimension + filtres). Frais → instantané ; périmé → sert + rafraîchit en fond ; à froid → requête live.
const _explorerCache = new Map()
const EXPLORER_TTL = 10 * 60 * 1000 // 10 min
async function runExplorer (url) {
const { sql, params, totalSql, totalParams, meta } = buildExplorerQuery(url)
const pool = getPool()
const t0 = Date.now()
const [rows] = await pool.execute(sql, params)
const [totalRows] = await pool.execute(totalSql, totalParams)
const asOf = await fetchDataAsOf(pool)
const labelled = await finalizeRows(rows, meta)
log(`revenue-explorer dim=${meta.dimension} seg=${meta.segment} type=${meta.type}: ${rows.length} raw → ${labelled.length} groups in ${Date.now() - t0}ms`)
return {
dimension: meta.dimension, dimension_label: DIM_LABELS[meta.dimension],
segment: meta.segment, type: meta.type, active: meta.active, threshold: meta.threshold,
data_as_of: asOf.services, count: labelled.length,
total: totalRows[0] || null, rows: labelled,
}
}
async function handleExplorer (req, res, url) {
const pool = getPool()
if (!pool) return json(res, 503, { error: 'mysql2 not installed on hub' })
const key = url.search || '?' // signature de la requête (dim + filtres)
const now = Date.now(); const hit = _explorerCache.get(key)
if (hit) {
if (now - hit.ts > EXPLORER_TTL && !hit.refreshing) { // périmé → rafraîchit en fond, sert le cache tout de suite
hit.refreshing = true
runExplorer(url).then(d => _explorerCache.set(key, { data: d, ts: Date.now() })).catch(() => {}).finally(() => { hit.refreshing = false })
}
return json(res, 200, { ...hit.data, cached: true })
}
try {
const data = await runExplorer(url)
_explorerCache.set(key, { data, ts: Date.now() })
if (_explorerCache.size > 60) { const oldest = [..._explorerCache.entries()].sort((a, b) => a[1].ts - b[1].ts)[0]; if (oldest) _explorerCache.delete(oldest[0]) } // borne le cache
return json(res, 200, data)
} catch (e) {
log(`revenue-explorer error: ${e.message}`)
return json(res, 500, { error: 'legacy query failed: ' + e.message })
}
}
async function handleExplorerCsv (req, res, url) {
const pool = getPool()
if (!pool) return json(res, 503, { error: 'mysql2 not installed on hub' })
const { sql, params, meta } = buildExplorerQuery(url)
try {
const [rawRows] = await pool.execute(sql, params)
const rows = await finalizeRows(rawRows, meta)
const headers = [DIM_LABELS[meta.dimension], 'net_mrr', 'brut', 'rabais', 'n_adresses', 'n_lignes']
const esc = (v) => { if (v == null) return ''; const s = String(v); return /[",\r\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s }
const lines = [headers.join(',')]
for (const r of rows) lines.push([esc(r.dim_label), r.net, r.gross, r.discounts, r.n_addresses, r.n_lines].join(','))
const csv = '' + lines.join('\r\n') + '\r\n'
res.writeHead(200, {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': `attachment; filename="revenus-${meta.dimension}-${meta.segment}.csv"`,
'Cache-Control': 'no-store',
})
return res.end(csv)
} catch (e) {
return json(res, 500, { error: 'legacy query failed: ' + e.message })
}
}
async function handle (req, res, method, path, url) {
if (path === '/reports/legacy/overpriced-internet' && method === 'GET') return handleJson(req, res, url)
if (path === '/reports/legacy/overpriced-internet.csv' && method === 'GET') return handleCsv(req, res, url)
if (path === '/reports/legacy/revenue-explorer' && method === 'GET') return handleExplorer(req, res, url)
if (path === '/reports/legacy/revenue-explorer.csv' && method === 'GET') return handleExplorerCsv(req, res, url)
return json(res, 404, { error: 'legacy report not found' })
}
module.exports = { handle }