diff --git a/apps/ops/src/pages/ReportInternetCherPage.vue b/apps/ops/src/pages/ReportInternetCherPage.vue
index b04c181..4b735a8 100644
--- a/apps/ops/src/pages/ReportInternetCherPage.vue
+++ b/apps/ops/src/pages/ReportInternetCherPage.vue
@@ -34,6 +34,15 @@
+
+
+
+ Données legacy au {{ formatDate(dataAsOf) }}
+ — copie figée ({{ daysOld }} jours). Les renégociations,
+ nouveaux rabais et résiliations récents ne sont pas reflétés.
+
+
@@ -109,6 +118,17 @@
+
+
+
+ {{ formatMoney(props.value) }}
+
+ Rabais expiré dans les 6 derniers mois — la facture vient d'augmenter d'autant. Candidat à la rétention.
+
+ —
+
+
+
{{ shorten(props.value) }}
@@ -146,6 +166,7 @@ const loading = ref(false)
const loaded = ref(false)
const rows = ref([])
const search = ref('')
+const dataAsOf = ref(null)
const segmentOptions = [
{ label: 'Résidentiel', value: 'residential' },
@@ -158,7 +179,8 @@ const columns = [
{ 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: 'discounts', label: 'Rabais actifs', field: 'discounts', align: 'right', sortable: true },
+ { name: 'recent_expired_discount', label: 'Rabais expirés (6 mois)', field: 'recent_expired_discount', 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' },
@@ -172,9 +194,19 @@ const csvUrl = computed(() => overpricedInternetCsvUrl({
threshold: threshold.value, segment: segment.value, addons: addons.value,
}))
+const daysOld = computed(() => {
+ if (!dataAsOf.value) return 0
+ return Math.floor((Date.now() - new Date(dataAsOf.value)) / 86400000)
+})
+const isStale = computed(() => daysOld.value > 7)
+const staleClass = computed(() => isStale.value ? 'bg-orange-2 text-orange-10' : 'bg-blue-1 text-blue-9')
+
function formatMoney (v) {
return new Intl.NumberFormat('fr-CA', { style: 'currency', currency: 'CAD' }).format(Number(v) || 0)
}
+function formatDate (iso) {
+ return iso ? new Date(iso).toLocaleDateString('fr-CA', { day: 'numeric', month: 'long', year: 'numeric' }) : ''
+}
function shorten (s) {
if (!s) return ''
return s.length > 48 ? s.slice(0, 48) + '…' : s
@@ -187,6 +219,7 @@ async function loadReport () {
threshold: threshold.value, segment: segment.value, addons: addons.value, limit: 5000,
})
rows.value = data.rows || []
+ dataAsOf.value = data.data_as_of || null
loaded.value = true
} catch (e) {
$q.notify({ type: 'negative', message: 'Erreur de chargement : ' + e.message })
diff --git a/services/targo-hub/lib/legacy-reports.js b/services/targo-hub/lib/legacy-reports.js
index 3fc1648..dee653c 100644
--- a/services/targo-hub/lib/legacy-reports.js
+++ b/services/targo-hub/lib/legacy-reports.js
@@ -128,7 +128,20 @@ function buildQuery (url) {
SUBSTRING(GROUP_CONCAT(
CONCAT(p.sku, '=', FORMAT(${EFF}, 2))
ORDER BY (${EFF}) DESC SEPARATOR ' · '
- ), 1, 500) AS detail
+ ), 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
@@ -149,6 +162,19 @@ function buildQuery (url) {
return { sql, params, meta: { threshold, segment, includeAddons, cats, limit } }
}
+// Freshness probe — the most recent invoice date in the legacy copy tells
+// us how stale the data is. The copy is a one-shot snapshot (no auto-sync),
+// so the report must advertise its as-of date to avoid misleading the
+// operator into acting on month-old prices (a re-negotiated client like
+// Julie Dupuis won't show their new discount until the copy is refreshed).
+async function fetchDataAsOf (pool) {
+ try {
+ const [r] = await pool.execute('SELECT MAX(date_orig) AS max_ts FROM invoice')
+ const ts = r?.[0]?.max_ts
+ return ts ? new Date(ts * 1000).toISOString() : null
+ } catch { return null }
+}
+
async function handleJson (req, res, url) {
const pool = getPool()
if (!pool) return json(res, 503, { error: 'mysql2 not installed on hub' })
@@ -156,10 +182,12 @@ async function handleJson (req, res, url) {
try {
const t0 = Date.now()
const [rows] = await pool.execute(sql, params)
+ const dataAsOf = 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: dataAsOf,
count: rows.length, rows,
})
} catch (e) {
@@ -175,7 +203,7 @@ async function handleCsv (req, res, 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']
+ 'address1', 'city', 'zip', 'net_internet', 'gross', 'discounts', 'recent_expired_discount', 'n_lines', 'detail']
const esc = (v) => {
if (v == null) return ''
const s = String(v)