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:
parent
1ab899388c
commit
1b6abc43f2
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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' })
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user