feat(ops/reports): "Internet trop cher" legacy report

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) <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-06-01 19:06:05 -04:00
parent 0fb9089f4e
commit 7f06c254c8
6 changed files with 404 additions and 0 deletions

View File

@ -49,3 +49,30 @@ export function fetchARReport (asOf) {
export function fetchAccounts (type = 'Income') { export function fetchAccounts (type = 'Income') {
return hubFetch(`/reports/accounts?type=${type}`) 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 <a download>). */
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}`
}

View File

@ -63,6 +63,13 @@ const financeReports = [
color: 'red-6', color: 'red-6',
route: '/rapports/ar', 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 = [ const opsReports = [

View File

@ -0,0 +1,195 @@
<template>
<q-page padding>
<div class="row items-center q-mb-md">
<q-btn flat dense icon="arrow_back" to="/rapports" class="q-mr-sm" />
<div class="text-h6 text-weight-bold">Clients qui paient cher Internet</div>
<q-space />
<q-btn flat dense icon="download" label="CSV" :href="csvUrl" :disable="!rows.length" />
</div>
<div class="text-caption text-grey-7 q-mb-md" style="max-width:780px">
Total mensuel net du service Internet (forfait + rabais récurrents, forfaits annuels
ramenés au mois) par adresse de service, depuis la base legacy. Exclut TV, téléphonie
et les frais ponctuels (équipement, installation). Sert à repérer les clients qui
gagneraient à être déplacés vers un forfait mieux adapté.
</div>
<!-- Filters -->
<div class="row q-col-gutter-sm q-mb-md items-end">
<div class="col-auto">
<q-input v-model.number="threshold" type="number" min="0" step="5" label="Seuil $/mois"
dense outlined style="width:130px" />
</div>
<div class="col-auto">
<q-select v-model="segment" :options="segmentOptions" emit-value map-options
label="Segment" dense outlined style="width:170px" />
</div>
<div class="col-auto">
<q-toggle v-model="addons" label="Inclure add-ons (IP fixe, etc.)" />
</div>
<div class="col-auto">
<q-btn color="primary" label="Générer" icon="play_arrow" @click="loadReport" :loading="loading" />
</div>
</div>
<!-- Summary cards -->
<div v-if="loaded" class="row q-col-gutter-sm q-mb-md">
<div class="col-auto">
<div class="ops-card text-center" style="min-width:140px">
<div class="text-caption text-grey-6">Adresses</div>
<div class="text-h6 text-weight-bold">{{ rows.length }}</div>
</div>
</div>
<div class="col-auto">
<div class="ops-card text-center" style="min-width:160px">
<div class="text-caption text-grey-6">Total mensuel net</div>
<div class="text-h6 text-weight-bold text-primary">{{ formatMoney(totalNet) }}</div>
</div>
</div>
<div class="col-auto">
<div class="ops-card text-center" style="min-width:140px">
<div class="text-caption text-grey-6">Moyenne / adresse</div>
<div class="text-h6 text-weight-bold">{{ formatMoney(avgNet) }}</div>
</div>
</div>
<div class="col-auto">
<div class="ops-card text-center" style="min-width:140px">
<div class="text-caption text-grey-6">Rabais mensuels</div>
<div class="text-h6 text-weight-bold text-positive">{{ formatMoney(totalDiscounts) }}</div>
</div>
</div>
</div>
<!-- Data table -->
<q-table
v-if="loaded"
:rows="rows"
:columns="columns"
row-key="delivery_id"
flat bordered
class="ops-table"
:pagination="{ rowsPerPage: 50, sortBy: 'net_internet', descending: true }"
dense
:filter="search"
>
<template #top-right>
<q-input v-model="search" dense outlined placeholder="Filtrer (nom, ville, courriel…)" clearable style="width:240px">
<template #prepend><q-icon name="search" /></template>
</q-input>
</template>
<template #body-cell-client="props">
<q-td :props="props">
<div class="text-weight-medium">{{ props.row.client_name || '(sans nom)' }}</div>
<div v-if="props.row.company" class="text-caption text-grey-7">{{ props.row.company }}</div>
</q-td>
</template>
<template #body-cell-address="props">
<q-td :props="props">
<div>{{ props.row.address1 || '—' }}</div>
<div class="text-caption text-grey-7">{{ props.row.city }} {{ props.row.zip }}</div>
</q-td>
</template>
<template #body-cell-net_internet="props">
<q-td :props="props">
<span class="text-weight-bold" :class="props.value >= 110 ? 'text-negative' : 'text-orange-9'">
{{ formatMoney(props.value) }}
</span>
</q-td>
</template>
<template #body-cell-discounts="props">
<q-td :props="props">
<span v-if="props.value < 0" class="text-positive">{{ formatMoney(props.value) }}</span>
<span v-else class="text-grey-5"></span>
</q-td>
</template>
<template #body-cell-detail="props">
<q-td :props="props">
<span class="text-caption" style="font-family:monospace">{{ shorten(props.value) }}</span>
<q-tooltip v-if="props.value" max-width="420px" class="text-body2">{{ props.value }}</q-tooltip>
</q-td>
</template>
<template #body-cell-contact="props">
<q-td :props="props">
<a v-if="props.row.email" :href="`mailto:${props.row.email}`" class="text-primary block">{{ props.row.email }}</a>
<span v-if="props.row.cell || props.row.tel_home" class="text-caption text-grey-7">
{{ props.row.cell || props.row.tel_home }}
</span>
</q-td>
</template>
</q-table>
<div v-if="loaded && !rows.length" class="text-center q-pa-xl text-grey-7">
<q-icon name="search_off" size="42px" />
<div class="q-mt-sm">Aucune adresse au-dessus de {{ formatMoney(threshold) }}/mois pour ce segment.</div>
</div>
</q-page>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useQuasar } from 'quasar'
import { fetchOverpricedInternet, overpricedInternetCsvUrl } from 'src/api/reports'
const $q = useQuasar()
const threshold = ref(90)
const segment = ref('residential')
const addons = ref(true)
const loading = ref(false)
const loaded = ref(false)
const rows = ref([])
const search = ref('')
const segmentOptions = [
{ label: 'Résidentiel', value: 'residential' },
{ label: 'Commercial', value: 'commercial' },
{ label: 'Tous', value: 'all' },
]
const columns = [
{ name: 'client', label: 'Client', field: 'client_name', align: 'left', sortable: true },
{ name: 'address', label: 'Adresse de service', field: 'address1', align: 'left', sortable: true },
{ name: 'net_internet', label: 'Net Internet /mois', field: 'net_internet', align: 'right', sortable: true },
{ name: 'gross', label: 'Brut', field: 'gross', align: 'right', sortable: true, format: v => formatMoney(v) },
{ name: 'discounts', label: 'Rabais', field: 'discounts', align: 'right', sortable: true },
{ name: 'n_lines', label: 'Lignes', field: 'n_lines', align: 'center', sortable: true },
{ name: 'detail', label: 'Détail forfait', field: 'detail', align: 'left' },
{ name: 'contact', label: 'Contact', field: 'email', align: 'left' },
]
const totalNet = computed(() => rows.value.reduce((s, r) => s + Number(r.net_internet || 0), 0))
const avgNet = computed(() => rows.value.length ? totalNet.value / rows.value.length : 0)
const totalDiscounts = computed(() => rows.value.reduce((s, r) => s + Number(r.discounts || 0), 0))
const csvUrl = computed(() => overpricedInternetCsvUrl({
threshold: threshold.value, segment: segment.value, addons: addons.value,
}))
function formatMoney (v) {
return new Intl.NumberFormat('fr-CA', { style: 'currency', currency: 'CAD' }).format(Number(v) || 0)
}
function shorten (s) {
if (!s) return ''
return s.length > 48 ? s.slice(0, 48) + '…' : s
}
async function loadReport () {
loading.value = true
try {
const data = await fetchOverpricedInternet({
threshold: threshold.value, segment: segment.value, addons: addons.value, limit: 5000,
})
rows.value = data.rows || []
loaded.value = true
} catch (e) {
$q.notify({ type: 'negative', message: 'Erreur de chargement : ' + e.message })
} finally {
loading.value = false
}
}
</script>

View File

@ -32,6 +32,7 @@ const routes = [
{ path: 'rapports/ventes', component: () => import('src/pages/ReportVentesPage.vue') }, { path: 'rapports/ventes', component: () => import('src/pages/ReportVentesPage.vue') },
{ path: 'rapports/taxes', component: () => import('src/pages/ReportTaxesPage.vue') }, { path: 'rapports/taxes', component: () => import('src/pages/ReportTaxesPage.vue') },
{ path: 'rapports/ar', component: () => import('src/pages/ReportARPage.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: 'ocr', component: () => import('src/pages/OcrPage.vue') },
{ path: 'settings', component: () => import('src/pages/SettingsPage.vue') }, { path: 'settings', component: () => import('src/pages/SettingsPage.vue') },
{ path: 'telephony', component: () => import('src/pages/TelephonyPage.vue') }, { path: 'telephony', component: () => import('src/pages/TelephonyPage.vue') },

View File

@ -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 }

View File

@ -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 (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('/dispatch')) return dispatch.handle(req, res, method, path)
if (path.startsWith('/admin/pollers')) return require('./lib/poller-control').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('/reports')) return require('./lib/reports').handle(req, res, method, path, url)
if (path.startsWith('/campaigns')) return require('./lib/campaigns').handle(req, res, method, path) if (path.startsWith('/campaigns')) return require('./lib/campaigns').handle(req, res, method, path)
// Gift redirect wrapper — short public URLs in campaign emails that // Gift redirect wrapper — short public URLs in campaign emails that