fix(events): garde-fou audience — exclut les comptes F résiliés (abos fantômes)

A (garde-fou immédiat, sans écriture) : resolveAudience exclut les clients dont le compte F
est résilié (fResiliatedAccountIds, caché 30 min) — évite d'inviter d'ex-clients tant que la
donnée n'est pas nettoyée (cf feedback_ghost_active_subscriptions). L'aperçu affiche « N
ex-client(s) résilié(s) exclus ».

Vérifié prod : « clients actifs » 7943→5943 (2663 exclus) ; « abonnement actif ≥40$ »
3102→2213 (1125 ex-clients exclus). 33/33 tests. Déployé hub.

Ne corrige PAS la donnée sous-jacente (abos 'Actif' fantômes + MRR gonflé) ni la logique de
sync — voir tâche #25 (dry-run prêt : 3873 abos / 2663 clients / ~150K$ MRR fantôme).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-06 15:09:52 -04:00
parent 1ab899388c
commit 1b6abc43f2
2 changed files with 18 additions and 4 deletions

View File

@ -159,7 +159,7 @@
<q-btn outline no-caps color="primary" icon="groups" label="Aperçu de l'audience" :loading="previewing" @click="doPreview" /> <q-btn outline no-caps color="primary" icon="groups" label="Aperçu de l'audience" :loading="previewing" @click="doPreview" />
<q-banner v-if="audPreview" dense rounded class="bg-blue-1 text-blue-10"> <q-banner v-if="audPreview" dense rounded class="bg-blue-1 text-blue-10">
<b>{{ audPreview.count }}</b> destinataire(s)<span v-if="audPreview.dropped_no_email"> · {{ audPreview.dropped_no_email }} sans courriel écarté(s)</span> <b>{{ audPreview.count }}</b> destinataire(s)<span v-if="audPreview.dropped_no_email"> · {{ audPreview.dropped_no_email }} sans courriel écarté(s)</span><span v-if="audPreview.resiliated_excluded" class="text-orange-9"> · {{ audPreview.resiliated_excluded }} ex-client(s) résilié(s) exclus</span>
<div v-if="audPreview.sample && audPreview.sample.length" class="text-caption q-mt-xs" style="opacity:.85"> <div v-if="audPreview.sample && audPreview.sample.length" class="text-caption q-mt-xs" style="opacity:.85">
ex. {{ audPreview.sample.slice(0, 4).map(s => s.firstname || s.email).join(', ') }} ex. {{ audPreview.sample.slice(0, 4).map(s => s.firstname || s.email).join(', ') }}
</div> </div>

View File

@ -239,6 +239,15 @@ function splitName (full) {
} }
const firstEmail = (s) => String(s || '').split(/[;,]/)[0].trim() const firstEmail = (s) => String(s || '').split(/[;,]/)[0].trim()
// Comptes F RÉSILIÉS (status 3/4/5) — à EXCLURE de l'audience (bug sync : services restent status=1 après
// résiliation du compte → abonnements 'Actif' fantômes ; cf feedback_ghost_active_subscriptions). Caché 30 min.
let _resilCache = null; let _resilTs = 0
async function resiliatedSet () {
if (_resilCache && (Date.now() - _resilTs < 30 * 60 * 1000)) return _resilCache
try { const ids = await require('./legacy-sync').fResiliatedAccountIds(); _resilCache = new Set((ids || []).map(Number)); _resilTs = Date.now() } catch (e) { log('events resiliatedSet: ' + e.message); if (!_resilCache) _resilCache = new Set() }
return _resilCache
}
// CSV : SEULE colonne obligatoire = email ; alias FR/EN acceptés ; en-tête requise. // CSV : SEULE colonne obligatoire = email ; alias FR/EN acceptés ; en-tête requise.
function parseAudienceCsv (text) { function parseAudienceCsv (text) {
const lines = String(text || '').split(/\r?\n/).filter(l => l.trim()) const lines = String(text || '').split(/\r?\n/).filter(l => l.trim())
@ -264,6 +273,7 @@ function parseAudienceCsv (text) {
// Résout l'audience → destinataires [{email,firstname,lastname,language,customer_id}] (dédupliqués, courriel requis). // Résout l'audience → destinataires [{email,firstname,lastname,language,customer_id}] (dédupliqués, courriel requis).
async function resolveAudience ({ mode = 'filter', filters = {}, csv = '' } = {}) { async function resolveAudience ({ mode = 'filter', filters = {}, csv = '' } = {}) {
let rows = [] let rows = []
let resiliatedExcluded = 0
if (mode === 'csv') { if (mode === 'csv') {
const p = parseAudienceCsv(csv) const p = parseAudienceCsv(csv)
if (p.error) return { error: p.error } if (p.error) return { error: p.error }
@ -274,7 +284,7 @@ async function resolveAudience ({ mode = 'filter', filters = {}, csv = '' } = {}
if (filters.statut === 'active') f.push(['disabled', '=', 0]) if (filters.statut === 'active') f.push(['disabled', '=', 0])
else if (filters.statut === 'disabled') f.push(['disabled', '=', 1]) else if (filters.statut === 'disabled') f.push(['disabled', '=', 1])
if (filters.customer_type) f.push(['customer_type', '=', filters.customer_type]) // Individual = résidentiel · Company = commercial if (filters.customer_type) f.push(['customer_type', '=', filters.customer_type]) // Individual = résidentiel · Company = commercial
const CFIELDS = ['name', 'customer_name', 'email_id', 'language', 'customer_type'] const CFIELDS = ['name', 'customer_name', 'email_id', 'language', 'customer_type', 'legacy_account_id']
const minMonthly = Number(filters.min_monthly) || 0 const minMonthly = Number(filters.min_monthly) || 0
let customers = [] let customers = []
if (filters.active_sub || minMonthly > 0) { if (filters.active_sub || minMonthly > 0) {
@ -292,6 +302,10 @@ async function resolveAudience ({ mode = 'filter', filters = {}, csv = '' } = {}
let s = 0 let s = 0
for (let p = 0; p < 60; p++) { const b = await erp.list('Customer', { filters: f, fields: CFIELDS, limit: 500, start: s }); customers.push(...b); if (b.length < 500) break; s += 500 } for (let p = 0; p < 60; p++) { const b = await erp.list('Customer', { filters: f, fields: CFIELDS, limit: 500, start: s }); customers.push(...b); if (b.length < 500) break; s += 500 }
} }
const resil = await resiliatedSet() // GARDE-FOU : jamais inviter un compte F résilié (abos fantômes, cf feedback_ghost_active_subscriptions)
const before = customers.length
customers = customers.filter(c => !resil.has(Number(c.legacy_account_id)))
resiliatedExcluded = before - customers.length
rows = customers.map(c => { const n = splitName(c.customer_name); return { email: firstEmail(c.email_id), firstname: n.firstname, lastname: n.lastname, language: normLang(c.language), customer_id: c.name, customer_type: c.customer_type || '', monthly: Math.round(c._monthly || 0) } }) rows = customers.map(c => { const n = splitName(c.customer_name); return { email: firstEmail(c.email_id), firstname: n.firstname, lastname: n.lastname, language: normLang(c.language), customer_id: c.name, customer_type: c.customer_type || '', monthly: Math.round(c._monthly || 0) } })
} }
const seen = new Set(); const recipients = []; const noEmail = []; let dropped = 0 const seen = new Set(); const recipients = []; const noEmail = []; let dropped = 0
@ -304,7 +318,7 @@ async function resolveAudience ({ mode = 'filter', filters = {}, csv = '' } = {}
const k = r.email.toLowerCase(); if (seen.has(k)) continue; seen.add(k) const k = r.email.toLowerCase(); if (seen.has(k)) continue; seen.add(k)
recipients.push(r) recipients.push(r)
} }
return { recipients, count: recipients.length, dropped_no_email: dropped, no_email: noEmail } return { recipients, count: recipients.length, dropped_no_email: dropped, no_email: noEmail, resiliated_excluded: resiliatedExcluded }
} }
// ── Page publique (festive, mobile-first, bilingue) ──────────────────────── // ── Page publique (festive, mobile-first, bilingue) ────────────────────────
@ -569,7 +583,7 @@ async function handle (req, res, method, path, url) {
const b = await parseBody(req) const b = await parseBody(req)
const r = await resolveAudience({ mode: b.mode, filters: b.filters || {}, csv: b.csv || '' }) const r = await resolveAudience({ mode: b.mode, filters: b.filters || {}, csv: b.csv || '' })
if (r.error) return json(res, 400, { error: r.error }) if (r.error) return json(res, 400, { error: r.error })
return json(res, 200, { count: r.count, dropped_no_email: r.dropped_no_email, sample: r.recipients.slice(0, 50), no_email: (r.no_email || []).slice(0, 300) }) return json(res, 200, { count: r.count, dropped_no_email: r.dropped_no_email, resiliated_excluded: r.resiliated_excluded || 0, sample: r.recipients.slice(0, 50), no_email: (r.no_email || []).slice(0, 300) })
} }
return json(res, 404, { error: 'not found' }) return json(res, 404, { error: 'not found' })