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:
parent
8a9df4b85e
commit
94ebb822db
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user