'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 * * Effective monthly price of a service line: * base = service.hijack=1 ? service.hijack_price : product.price * // Annual plans (SKU ends in "ANN", e.g. FTTH_ANN @ 480$/yr) are * // normalized to a monthly equivalent so the threshold compares apples * // to apples — otherwise a $480/yr (=$40/mo) plan falsely shows as $480. * 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. * * Internet product categories: * 32 Mensualités fibre, 4 Mensualités sans-fil, 23 Internet camping * (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') let mysql try { mysql = require('mysql2/promise') } catch { /* optional dep */ } const CAT_INTERNET_CORE = [32, 4, 23] // fibre, wireless, camping const CAT_INTERNET_ADDONS = [16, 17, 21] // extra download, static IP, point-to-point // 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(',') let commercialClause = '' if (segment === 'residential') commercialClause = 'AND a.commercial = 0' else if (segment === 'commercial') commercialClause = 'AND a.commercial = 1' 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 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 p.price_recurr_type = 1 ${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 } } } 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) 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, 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', '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 }) } } 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) return json(res, 404, { error: 'legacy report not found' }) } module.exports = { handle }