feat(reports/legacy): data-freshness banner + recently-expired-discount column

User correctly spotted that Julie Dupuis shows 114.95$ but actually pays
69.95$ — investigation revealed the legacy COPY (legacy-db container) is a
one-shot snapshot from 2026-05-05 with data through 2026-04-30 and NO
auto-sync. She renegotiated in May (a -50$ discount on service 50999) which
the copy never received. The report was correct vs the copy, but the copy
is ~1 month stale.

Two changes (data-source strategy still pending operator decision —
prod 10.100.80.100:3306 is reachable for a future live/refresh option):

1. data_as_of — the report now reports MAX(invoice.date_orig) from the
   copy and the Ops page shows a banner ("Données legacy au 30 avril —
   copie figée, N jours"). Turns orange past 7 days so nobody acts on
   stale prices unknowingly.

2. recent_expired_discount column — per-address sum of deactivated credit
   lines (status=0, price<0) whose actif_until fell in the last 180 days.
   Surfaces clients whose discount just lapsed (Julie's RAB24M -15 + RAB_X
   -35 expired 2026-03-01), i.e. the prime retention targets whose bill is
   about to jump. Shown in amber with a warning icon + tooltip; included in
   the CSV.

🤖 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:31:41 -04:00
parent 8a9df4b85e
commit 94ebb822db
2 changed files with 64 additions and 3 deletions

View File

@ -34,6 +34,15 @@
</div>
</div>
<!-- Data freshness banner the legacy copy is a snapshot, not live -->
<q-banner v-if="loaded && dataAsOf" dense rounded
:class="staleClass" class="q-mb-md">
<template #avatar><q-icon :name="isStale ? 'warning' : 'schedule'" /></template>
Données legacy au <strong>{{ formatDate(dataAsOf) }}</strong>
<span v-if="isStale"> copie figée ({{ daysOld }} jours). Les renégociations,
nouveaux rabais et résiliations récents ne sont pas reflétés.</span>
</q-banner>
<!-- Summary cards -->
<div v-if="loaded" class="row q-col-gutter-sm q-mb-md">
<div class="col-auto">
@ -109,6 +118,17 @@
</q-td>
</template>
<template #body-cell-recent_expired_discount="props">
<q-td :props="props">
<span v-if="props.value < 0" class="text-deep-orange-7 text-weight-medium">
{{ formatMoney(props.value) }}
<q-icon name="warning" size="14px" class="q-ml-xs" />
<q-tooltip>Rabais expiré dans les 6 derniers mois la facture vient d'augmenter d'autant. Candidat à la rétention.</q-tooltip>
</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>
@ -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 })

View File

@ -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 103145$). 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)