feat(events): filtres audience précis — type (résidentiel/commercial) + total mensuel min

Ajout au sélecteur d'audience (mode masse) :
- Type de client : Résidentiel (customer_type=Individual) / Commercial (Company) / Tous.
- Total mensuel minimum ($/mois) : somme des Service Subscription ACTIFS (monthly_price)
  par client ≥ seuil. resolveAudience agrège les abonnements actifs quand active_sub OU
  min_monthly>0 ; le total est renvoyé par destinataire (monthly).

Vérifié sur données prod réelles : Résidentiel 7099, Commercial 928, total mensuel ≥50$ =
2739 clients (montants agrégés réels). Champs bien peuplés (contrairement à territoire, retiré).
32/32 tests unitaires. Déployé hub + SPA. Le blast réel reste gaté.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-06 14:25:05 -04:00
parent 1f74194419
commit 5d780187b0
2 changed files with 20 additions and 12 deletions

View File

@ -138,9 +138,15 @@
:options="[{ label: 'Liste clients', value: 'list' }, { label: 'Import CSV', value: 'csv' }]" />
<div v-if="audienceSource === 'list'" class="q-gutter-sm">
<q-select v-model="audFilters.statut" :options="[{ label: 'Clients actifs', value: 'active' }, { label: 'Tous', value: '' }]" dense outlined emit-value map-options label="Statut" @update:model-value="audPreview = null" />
<div class="row q-col-gutter-sm">
<q-select class="col-12 col-sm-6" v-model="audFilters.statut" :options="[{ label: 'Clients actifs', value: 'active' }, { label: 'Tous', value: '' }]" dense outlined emit-value map-options label="Statut" @update:model-value="audPreview = null" />
<q-select class="col-12 col-sm-6" v-model="audFilters.customer_type" :options="[{ label: 'Tous types', value: '' }, { label: 'Résidentiel', value: 'Individual' }, { label: 'Commercial', value: 'Company' }]" dense outlined emit-value map-options label="Type de client" @update:model-value="audPreview = null" />
</div>
<q-toggle v-model="audFilters.active_sub" label="Abonnement actif seulement" @update:model-value="audPreview = null" />
<div class="text-caption text-grey-6">Astuce : « Clients actifs » = ~7900 clients avec courriel. « Abonnement actif » restreint à ceux ayant un service en cours.</div>
<q-input v-model.number="audFilters.min_monthly" type="number" min="0" dense outlined label="Total mensuel minimum ($/mois)" @update:model-value="audPreview = null">
<template #prepend><q-icon name="attach_money" /></template>
</q-input>
<div class="text-caption text-grey-6">« Total mensuel » = somme des abonnements actifs du client (0 = aucun filtre). « Résidentiel/Commercial » = type de compte.</div>
</div>
<div v-else class="q-gutter-sm">
@ -209,7 +215,7 @@ const sendMode = ref('test')
const template = ref('')
const templates = ref([{ value: '', label: 'Invitation Fête 20 ans (TARGO)' }])
const audienceSource = ref('list')
const audFilters = ref({ statut: 'active', active_sub: false })
const audFilters = ref({ statut: 'active', customer_type: '', active_sub: false, min_monthly: 0 })
const csvFile = ref(null)
const audCsvText = ref('')
const audPreview = ref(null)

View File

@ -273,24 +273,26 @@ async function resolveAudience ({ mode = 'filter', filters = {}, csv = '' } = {}
const f = []
if (filters.statut === 'active') f.push(['disabled', '=', 0])
else if (filters.statut === 'disabled') f.push(['disabled', '=', 1])
if (filters.territory) f.push(['territory', '=', filters.territory])
if (filters.group) f.push(['customer_group', '=', filters.group])
const CFIELDS = ['name', 'customer_name', 'email_id', 'language', 'territory', 'customer_group']
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 minMonthly = Number(filters.min_monthly) || 0
let customers = []
if (filters.active_sub) {
// Clients ayant ≥1 Service Subscription « Actif ».
const subs = []; let s = 0
for (let p = 0; p < 30; p++) { const b = await erp.list('Service Subscription', { filters: [['status', '=', 'Actif']], fields: ['customer'], limit: 500, start: s }); subs.push(...b); if (b.length < 500) break; s += 500 }
const ids = [...new Set(subs.map(x => x.customer).filter(Boolean))]
if (filters.active_sub || minMonthly > 0) {
// Agrège les abonnements ACTIFS par client (somme mensuelle) ; filtre par minimum si demandé.
const byCust = {}; let s = 0
for (let p = 0; p < 40; p++) { const b = await erp.list('Service Subscription', { filters: [['status', '=', 'Actif']], fields: ['customer', 'monthly_price'], limit: 500, start: s }); b.forEach(x => { if (x.customer) byCust[x.customer] = (byCust[x.customer] || 0) + (Number(x.monthly_price) || 0) }); if (b.length < 500) break; s += 500 }
let ids = Object.keys(byCust)
if (minMonthly > 0) ids = ids.filter(c => byCust[c] >= minMonthly)
for (let i = 0; i < ids.length; i += 100) {
const b = await erp.list('Customer', { filters: [...f, ['name', 'in', ids.slice(i, i + 100)]], fields: CFIELDS, limit: 500 })
customers.push(...b)
}
customers.forEach(c => { c._monthly = byCust[c.name] || 0 })
} else {
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 }
}
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 } })
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 = []; let dropped = 0
for (const r of rows) {