gigafibre-fsm/services/targo-hub/lib/legacy-reports.js
louispaulb 94ebb822db feat(reports/legacy): data-freshness banner + recently-expired-discount column
User correctly spotted that Julie Dupuis shows 114.95$ but actually pays
69.95$ — investigation revealed the legacy COPY (legacy-db container) is a
one-shot snapshot from 2026-05-05 with data through 2026-04-30 and NO
auto-sync. She renegotiated in May (a -50$ discount on service 50999) which
the copy never received. The report was correct vs the copy, but the copy
is ~1 month stale.

Two changes (data-source strategy still pending operator decision —
prod 10.100.80.100:3306 is reachable for a future live/refresh option):

1. data_as_of — the report now reports MAX(invoice.date_orig) from the
   copy and the Ops page shows a banner ("Données legacy au 30 avril —
   copie figée, N jours"). Turns orange past 7 days so nobody acts on
   stale prices unknowingly.

2. recent_expired_discount column — per-address sum of deactivated credit
   lines (status=0, price<0) whose actif_until fell in the last 180 days.
   Surfaces clients whose discount just lapsed (Julie's RAB24M -15 + RAB_X
   -35 expired 2026-03-01), i.e. the prime retention targets whose bill is
   about to jump. Shown in amber with a warning icon + tooltip; included in
   the CSV.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 19:31:41 -04:00

233 lines
11 KiB
JavaScript
Raw 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:
* 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,
-- 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 most recent invoice date in the legacy copy tells
// us how stale the data is. The copy is a one-shot snapshot (no auto-sync),
// so the report must advertise its as-of date to avoid misleading the
// operator into acting on month-old prices (a re-negotiated client like
// Julie Dupuis won't show their new discount until the copy is refreshed).
async function fetchDataAsOf (pool) {
try {
const [r] = await pool.execute('SELECT MAX(date_orig) AS max_ts FROM invoice')
const ts = r?.[0]?.max_ts
return ts ? new Date(ts * 1000).toISOString() : null
} catch { return null }
}
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 dataAsOf = 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: dataAsOf,
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 })
}
}
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 }