From 7f06c254c8e44ef12ac19cfad803437916cfee1b Mon Sep 17 00:00:00 2001 From: louispaulb Date: Mon, 1 Jun 2026 19:06:05 -0400 Subject: [PATCH] feat(ops/reports): "Internet trop cher" legacy report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New Ops report to surface clients whose net monthly Internet bill exceeds a threshold — for spotting plans that should be revised. Hub (lib/legacy-reports.js — new module, read-only MariaDB): - GET /reports/legacy/overpriced-internet (+ .csv variant) - Queries the legacy gestionclient DB directly via a small mysql2 pool (reuses cfg.LEGACY_DB_* — same vars as auth.js sync-legacy; added LEGACY_DB_PASS to the hub .env which was previously unset). - Grain = delivery (service address), NOT account: a multi-unit building (account 13166 has 82 doors / 205 services) would otherwise show a single bogus $2117 line instead of ~45 per door. - Net monthly Internet = SUM of effective per-line price across Internet categories (32 fibre, 4 wireless, 23 camping + optional add-ons 16/17/21), discounts included (products with price<0 are recurring credits like RAB24M -15$). - Effective price = service.hijack ? hijack_price : product.price. - Only recurring lines (product.price_recurr_type=1) — excludes one-time equipment/install charges. - Annual plans (SKU LIKE '%ANN', e.g. FTTH_ANN @ 480$/yr) normalized /12 so they compare correctly against a monthly threshold (was falsely showing $480 → now $40, drops below 90$). - Excludes TV (33,34) and téléphonie (9) entirely. Validated counts at 90$/mo: 983 residential, 297 commercial addresses. Ops UI: - src/pages/ReportInternetCherPage.vue — threshold/segment/add-ons filters, summary cards (count, total monthly, avg, discounts), sortable+filterable table (client, address, net, gross, discount, plan detail with full tooltip, contact), CSV download. - Card on the Rapports hub + route /rapports/internet-cher. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/ops/src/api/reports.js | 27 +++ apps/ops/src/pages/RapportsPage.vue | 7 + apps/ops/src/pages/ReportInternetCherPage.vue | 195 ++++++++++++++++++ apps/ops/src/router/index.js | 1 + services/targo-hub/lib/legacy-reports.js | 171 +++++++++++++++ services/targo-hub/server.js | 3 + 6 files changed, 404 insertions(+) create mode 100644 apps/ops/src/pages/ReportInternetCherPage.vue create mode 100644 services/targo-hub/lib/legacy-reports.js diff --git a/apps/ops/src/api/reports.js b/apps/ops/src/api/reports.js index 1536038..c551e93 100644 --- a/apps/ops/src/api/reports.js +++ b/apps/ops/src/api/reports.js @@ -49,3 +49,30 @@ export function fetchARReport (asOf) { export function fetchAccounts (type = 'Income') { return hubFetch(`/reports/accounts?type=${type}`) } + +// ── Legacy MariaDB analytical reports ────────────────────────────────────── + +/** + * Overpriced-Internet report — residential/commercial service addresses whose + * net MONTHLY Internet bill (plan + recurring discounts, annual plans + * normalized /12) exceeds a threshold. Queries the legacy gestionclient DB. + * + * @param {object} opts + * @param {number} opts.threshold $ floor (default 90) + * @param {string} opts.segment 'residential' | 'commercial' | 'all' + * @param {boolean} opts.addons include IP fixe / extra download / point-to-point + */ +export function fetchOverpricedInternet ({ threshold = 90, segment = 'residential', addons = true, limit = 2000 } = {}) { + const qs = new URLSearchParams({ + threshold: String(threshold), segment, addons: addons ? '1' : '0', limit: String(limit), + }) + return hubFetch(`/reports/legacy/overpriced-internet?${qs}`) +} + +/** Direct CSV download URL for the same report (browser ). */ +export function overpricedInternetCsvUrl ({ threshold = 90, segment = 'residential', addons = true } = {}) { + const qs = new URLSearchParams({ + threshold: String(threshold), segment, addons: addons ? '1' : '0', + }) + return `${HUB}/reports/legacy/overpriced-internet.csv?${qs}` +} diff --git a/apps/ops/src/pages/RapportsPage.vue b/apps/ops/src/pages/RapportsPage.vue index c2bee49..df99212 100644 --- a/apps/ops/src/pages/RapportsPage.vue +++ b/apps/ops/src/pages/RapportsPage.vue @@ -63,6 +63,13 @@ const financeReports = [ color: 'red-6', route: '/rapports/ar', }, + { + title: 'Clients qui paient cher — Internet', + description: 'Adresses dont la facture Internet mensuelle (forfait + rabais, hors TV/téléphonie) dépasse un seuil. Pour cibler les forfaits à réviser.', + icon: 'trending_up', + color: 'deep-orange-6', + route: '/rapports/internet-cher', + }, ] const opsReports = [ diff --git a/apps/ops/src/pages/ReportInternetCherPage.vue b/apps/ops/src/pages/ReportInternetCherPage.vue new file mode 100644 index 0000000..6a8459e --- /dev/null +++ b/apps/ops/src/pages/ReportInternetCherPage.vue @@ -0,0 +1,195 @@ + + + diff --git a/apps/ops/src/router/index.js b/apps/ops/src/router/index.js index b261b8d..0b32f05 100644 --- a/apps/ops/src/router/index.js +++ b/apps/ops/src/router/index.js @@ -32,6 +32,7 @@ const routes = [ { path: 'rapports/ventes', component: () => import('src/pages/ReportVentesPage.vue') }, { path: 'rapports/taxes', component: () => import('src/pages/ReportTaxesPage.vue') }, { path: 'rapports/ar', component: () => import('src/pages/ReportARPage.vue') }, + { path: 'rapports/internet-cher', component: () => import('src/pages/ReportInternetCherPage.vue') }, { path: 'ocr', component: () => import('src/pages/OcrPage.vue') }, { path: 'settings', component: () => import('src/pages/SettingsPage.vue') }, { path: 'telephony', component: () => import('src/pages/TelephonyPage.vue') }, diff --git a/services/targo-hub/lib/legacy-reports.js b/services/targo-hub/lib/legacy-reports.js new file mode 100644 index 0000000..179dc26 --- /dev/null +++ b/services/targo-hub/lib/legacy-reports.js @@ -0,0 +1,171 @@ +'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 } diff --git a/services/targo-hub/server.js b/services/targo-hub/server.js index 34d083e..1efd19a 100644 --- a/services/targo-hub/server.js +++ b/services/targo-hub/server.js @@ -118,6 +118,9 @@ const server = http.createServer(async (req, res) => { if (icalMatch && method === 'GET') return ical.handleCalendar(req, res, icalMatch[1], url.searchParams) if (path.startsWith('/dispatch')) return dispatch.handle(req, res, method, path) if (path.startsWith('/admin/pollers')) return require('./lib/poller-control').handle(req, res, method, path) + // Legacy-MariaDB analytical reports — must be checked BEFORE the ERPNext + // /reports handler so the /reports/legacy/* prefix isn't swallowed. + if (path.startsWith('/reports/legacy')) return require('./lib/legacy-reports').handle(req, res, method, path, url) if (path.startsWith('/reports')) return require('./lib/reports').handle(req, res, method, path, url) if (path.startsWith('/campaigns')) return require('./lib/campaigns').handle(req, res, method, path) // Gift redirect wrapper — short public URLs in campaign emails that