gigafibre-fsm/services/targo-hub/lib/legacy-reports.js
louispaulb b631fabf91 fix(reports/legacy): exclude expired credits; confirm monthly price model
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>
2026-06-01 19:12:49 -04:00

184 lines
7.9 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
*
* 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 }