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-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">
|
||||
ex. {{ audPreview.sample.slice(0, 4).map(s => s.firstname || s.email).join(', ') }}…
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -239,6 +239,15 @@ function splitName (full) {
|
|||
}
|
||||
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.
|
||||
function parseAudienceCsv (text) {
|
||||
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).
|
||||
async function resolveAudience ({ mode = 'filter', filters = {}, csv = '' } = {}) {
|
||||
let rows = []
|
||||
let resiliatedExcluded = 0
|
||||
if (mode === 'csv') {
|
||||
const p = parseAudienceCsv(csv)
|
||||
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])
|
||||
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
|
||||
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
|
||||
let customers = []
|
||||
if (filters.active_sub || minMonthly > 0) {
|
||||
|
|
@ -292,6 +302,10 @@ async function resolveAudience ({ mode = 'filter', filters = {}, csv = '' } = {}
|
|||
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 }
|
||||
}
|
||||
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) } })
|
||||
}
|
||||
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)
|
||||
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) ────────────────────────
|
||||
|
|
@ -569,7 +583,7 @@ async function handle (req, res, method, path, url) {
|
|||
const b = await parseBody(req)
|
||||
const r = await resolveAudience({ mode: b.mode, filters: b.filters || {}, csv: b.csv || '' })
|
||||
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' })
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user