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:
parent
1f74194419
commit
5d780187b0
|
|
@ -138,9 +138,15 @@
|
||||||
:options="[{ label: 'Liste clients', value: 'list' }, { label: 'Import CSV', value: 'csv' }]" />
|
:options="[{ label: 'Liste clients', value: 'list' }, { label: 'Import CSV', value: 'csv' }]" />
|
||||||
|
|
||||||
<div v-if="audienceSource === 'list'" class="q-gutter-sm">
|
<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" />
|
<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>
|
||||||
|
|
||||||
<div v-else class="q-gutter-sm">
|
<div v-else class="q-gutter-sm">
|
||||||
|
|
@ -209,7 +215,7 @@ const sendMode = ref('test')
|
||||||
const template = ref('')
|
const template = ref('')
|
||||||
const templates = ref([{ value: '', label: 'Invitation Fête 20 ans (TARGO)' }])
|
const templates = ref([{ value: '', label: 'Invitation Fête 20 ans (TARGO)' }])
|
||||||
const audienceSource = ref('list')
|
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 csvFile = ref(null)
|
||||||
const audCsvText = ref('')
|
const audCsvText = ref('')
|
||||||
const audPreview = ref(null)
|
const audPreview = ref(null)
|
||||||
|
|
|
||||||
|
|
@ -273,24 +273,26 @@ async function resolveAudience ({ mode = 'filter', filters = {}, csv = '' } = {}
|
||||||
const f = []
|
const f = []
|
||||||
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.territory) f.push(['territory', '=', filters.territory])
|
if (filters.customer_type) f.push(['customer_type', '=', filters.customer_type]) // Individual = résidentiel · Company = commercial
|
||||||
if (filters.group) f.push(['customer_group', '=', filters.group])
|
const CFIELDS = ['name', 'customer_name', 'email_id', 'language', 'customer_type']
|
||||||
const CFIELDS = ['name', 'customer_name', 'email_id', 'language', 'territory', 'customer_group']
|
const minMonthly = Number(filters.min_monthly) || 0
|
||||||
let customers = []
|
let customers = []
|
||||||
if (filters.active_sub) {
|
if (filters.active_sub || minMonthly > 0) {
|
||||||
// Clients ayant ≥1 Service Subscription « Actif ».
|
// Agrège les abonnements ACTIFS par client (somme mensuelle) ; filtre par minimum si demandé.
|
||||||
const subs = []; let s = 0
|
const byCust = {}; 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 }
|
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 }
|
||||||
const ids = [...new Set(subs.map(x => x.customer).filter(Boolean))]
|
let ids = Object.keys(byCust)
|
||||||
|
if (minMonthly > 0) ids = ids.filter(c => byCust[c] >= minMonthly)
|
||||||
for (let i = 0; i < ids.length; i += 100) {
|
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 })
|
const b = await erp.list('Customer', { filters: [...f, ['name', 'in', ids.slice(i, i + 100)]], fields: CFIELDS, limit: 500 })
|
||||||
customers.push(...b)
|
customers.push(...b)
|
||||||
}
|
}
|
||||||
|
customers.forEach(c => { c._monthly = byCust[c.name] || 0 })
|
||||||
} else {
|
} else {
|
||||||
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 }
|
||||||
}
|
}
|
||||||
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
|
const seen = new Set(); const recipients = []; let dropped = 0
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user