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:
parent
0fb9089f4e
commit
7f06c254c8
|
|
@ -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}`
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 = [
|
||||||
|
|
|
||||||
195
apps/ops/src/pages/ReportInternetCherPage.vue
Normal file
195
apps/ops/src/pages/ReportInternetCherPage.vue
Normal 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>
|
||||||
|
|
@ -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') },
|
||||||
|
|
|
||||||
171
services/targo-hub/lib/legacy-reports.js
Normal file
171
services/targo-hub/lib/legacy-reports.js
Normal 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 }
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user