gigafibre-fsm/services/targo-hub/lib/legacy-reports.js
louispaulb ab57a3e135 fix(reports/legacy): freshness from service date, not invoice date
Marc Robidoux flagged as overpriced (129.95$) — he has a loyalty discount
(service id 74448) that should lower it. Investigation: 74448 doesn't
exist in the copy (its max service id is 74393), so the discount was added
after the snapshot. Same freshness issue as Julie Dupuis — not a calc bug.

But this also exposed that the freshness banner was wrong: it read the
newest INVOICE date (Apr 30) while the snapshot actually carries SERVICES
created through May 22 — May's recurring billing run simply hadn't executed
at dump time, so invoices lag services by ~3 weeks. For a report that reads
active services/plans/discounts, the service date is the right freshness
signal.

fetchDataAsOf now returns both {services, invoices}; data_as_of (shown in
the banner) is the service date (May 22), with last_invoice kept for
reference. The copy is ~10 days stale, not ~1 month. Marc's loyalty credit
still won't show until the copy is refreshed (task #38).

🤖 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:57:09 -04:00

262 lines
12 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:
* monthly: 32 Mensualités fibre, 4 Mensualités sans-fil, 23 camping
* equipment: 26/29 équipement fibre, 7/8 équipement sans-fil
* (recurring modem/router rentals + Internet discounts that
* live here, e.g. RAB_FTTH_URBA). One-time install charges
* in these cats are dropped by the price_recurr_type=1 filter.
* 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 */ }
// Internet category sets. CORE = monthly plans + Internet equipment.
// The equipment categories (26/29 fibre, 7/8 wireless) carry recurring
// modem/router rentals (FTTH_LOCMOD +10, LOC_TPL +5) AND recurring Internet
// discounts (RAB_FTTH_URBA), which are part of the real Internet bill — a
// client's net Internet cost is wrong without them. Example: Claude Bergeron
// shows 94.95 from cat 32 alone, but a -60$ RAB_FTTH_URBA discount lives in
// cat 26, so his true net is 44.95 (and he correctly drops off the report).
// The price_recurr_type=1 filter still excludes one-time install charges
// (INSTFIBRE -199, etc.) that share these equipment categories.
const CAT_INTERNET_CORE = [32, 4, 23, 26, 29, 7, 8]
const CAT_INTERNET_ADDONS = [16, 17, 21] // extra download, static IP, point-to-point
// Always excluded: 9 Téléphonie, 33 Télévision, 34 Installation/équip télé.
// 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 copy is a one-shot snapshot (no auto-sync), so the
// report advertises its as-of date so nobody acts on stale prices.
//
// This report reads SERVICES (active plans/discounts), not invoices, so the
// relevant freshness is the newest service.date_orig — NOT the newest
// invoice date. They differ: the snapshot carries services created up to
// ~May 22 but invoices only through Apr 30 (May's recurring billing run
// hadn't happened at dump time). Using the invoice date understated
// freshness by ~3 weeks. We return both; the UI shows the service one.
// (A discount added after the snapshot — e.g. Marc Robidoux's loyalty
// credit, service id 74448 > the copy's max 74393 — still won't appear
// until the copy is refreshed.)
async function fetchDataAsOf (pool) {
const out = { services: null, invoices: null }
try {
const [s] = await pool.execute('SELECT MAX(date_orig) AS max_ts FROM service')
if (s?.[0]?.max_ts) out.services = new Date(s[0].max_ts * 1000).toISOString()
} catch { /* ignore */ }
try {
const [i] = await pool.execute('SELECT MAX(date_orig) AS max_ts FROM invoice')
if (i?.[0]?.max_ts) out.invoices = new Date(i[0].max_ts * 1000).toISOString()
} catch { /* ignore */ }
return out
}
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 asOf = 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 = service freshness (the relevant one for this report);
// last_invoice kept for reference.
data_as_of: asOf.services,
last_invoice: asOf.invoices,
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 }