diff --git a/apps/ops/src/pages/EventsPage.vue b/apps/ops/src/pages/EventsPage.vue index b63735c..1024523 100644 --- a/apps/ops/src/pages/EventsPage.vue +++ b/apps/ops/src/pages/EventsPage.vue @@ -159,7 +159,7 @@ - {{ audPreview.count }} destinataire(s) · {{ audPreview.dropped_no_email }} sans courriel écarté(s) + {{ audPreview.count }} destinataire(s) · {{ audPreview.dropped_no_email }} sans courriel écarté(s) · {{ audPreview.resiliated_excluded }} ex-client(s) résilié(s) exclus
ex. {{ audPreview.sample.slice(0, 4).map(s => s.firstname || s.email).join(', ') }}…
diff --git a/services/targo-hub/lib/events.js b/services/targo-hub/lib/events.js index 48e35ed..ef2492c 100644 --- a/services/targo-hub/lib/events.js +++ b/services/targo-hub/lib/events.js @@ -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' })