Reviewed against docs/archive/LEGACY-ACCOUNTING-ANALYSIS.md (the migration audit) which surfaced two things to check in the overpriced-internet report: 1. service.payment_recurrence (0=annual, 2=monthly, 5=semestrial...) — checked whether per-cycle prices needed /N normalization. They do NOT: verified a semestrial FTTH1500I carries product.price=109.95, identical to the monthly one (billed 6×109.95 every 6 months). Per §6.1 "prix = quantité × prix_unitaire", product.price is already the monthly unit price. The original monthly logic was correct — no division. The SKU-LIKE-'%ANN' /12 special-case stays (true annual plans where price IS the yearly amount, e.g. FTTH_ANN @ 480$/yr). 2. Promo credits carry an actif_until end date (§10). A discount line whose actif_until is past no longer reduces today's bill, so counting it understates what the client actually pays. Now excluded. NULL-safety: the exclusion needs an explicit `actif_until IS NOT NULL` guard — without it, `NOT (price<0 AND actif_until>0 AND actif_until<now)` evaluates to NULL for permanent credits (actif_until NULL), which SQL treats as not-true and silently DROPS every permanent credit line. That briefly inflated the residential count to 3330; with the guard it's a correct 1000 (vs 983 before — +17 addresses whose only sub-90 reason was a now-expired credit). Net effect: the report reflects the *current* real monthly Internet bill. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
184 lines
7.9 KiB
JavaScript
184 lines
7.9 KiB
JavaScript
'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
|
||
* // 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
|
||
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
|
||
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 } }
|
||
}
|
||
|
||
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 }
|