Events: remove RSVP customer-number field; fuzzy multi-field name audience filter

- RSVP form: drop the "Votre numéro client (optionnel)" field (and its submit
  wiring). Magic-link tokens still resolve the customer; matching falls back to
  name/email/phone.
- Audience name filter: was a case-sensitive ERPNext `like` on customer_name only,
  so "lpb" matched nothing (the account is C-LPB4 / "Louis-Paul Bourdon", matched via
  account ID / mandataire). Now resolved via nameLikeCustomerIds — accent/case-
  insensitive across customer_name + account name + mandataire + contact_name_legacy
  (PG pool, REST fallback), intersected with the other filters. Confirms "Tous"
  includes résiliés (include_resiliated) works end to end.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-09 11:26:59 -04:00
parent d26722f048
commit 0853947c43

View File

@ -507,6 +507,32 @@ async function cityCustomerIds (city) {
for (let p = 0; p < 80; p++) { const b = await erp.list('Service Location', { filters: [['city', 'like', '%' + term + '%']], fields: ['customer'], limit: 500, start: s }); b.forEach(x => { if (x.customer) ids.add(x.customer) }); if (b.length < 500) break; s += 500 } for (let p = 0; p < 80; p++) { const b = await erp.list('Service Location', { filters: [['city', 'like', '%' + term + '%']], fields: ['customer'], limit: 500, start: s }); b.forEach(x => { if (x.customer) ids.add(x.customer) }); if (b.length < 500) break; s += 500 }
return ids return ids
} }
// Filtre « nom » de l'audience — FUZZY comme la recherche app-wide : accent/casse-insensible ET multi-champs
// (customer_name + ID de compte `name` + mandataire + contact_name_legacy). Ex. « lpb » → C-LPB4 (Louis-Paul
// Bourdon, matché par ID/mandataire, pas par customer_name). Via PG ; repli REST (customer_name, sensible à la casse).
async function nameLikeCustomerIds (term) {
const t = String(term || '').trim(); const ids = new Set()
if (!t) return ids
let pool = null
try { pool = require('./address-db').pool() } catch { pool = null }
if (pool) {
const like = '%' + t + '%'
try {
const r = await pool.query("SELECT name FROM \"tabCustomer\" WHERE unaccent(lower(coalesce(customer_name,''))) LIKE unaccent(lower($1)) OR lower(coalesce(name,'')) LIKE lower($1)", [like])
r.rows.forEach(x => { if (x.name) ids.add(x.name) })
} catch (e) { log('nameLikeCustomerIds pg: ' + e.message) }
// Champs legacy (colonnes custom possibles) — try/catch séparé pour ne pas casser si absentes.
try {
const r = await pool.query("SELECT name FROM \"tabCustomer\" WHERE unaccent(lower(coalesce(mandataire,''))) LIKE unaccent(lower($1)) OR unaccent(lower(coalesce(contact_name_legacy,''))) LIKE unaccent(lower($1))", [like])
r.rows.forEach(x => { if (x.name) ids.add(x.name) })
} catch { /* colonnes absentes → ignore */ }
return ids
}
const erp = require('./erp'); let s = 0
for (let p = 0; p < 80; p++) { const b = await erp.list('Customer', { filters: [['customer_name', 'like', '%' + t + '%']], fields: ['name'], limit: 500, start: s }); b.forEach(x => { if (x.name) ids.add(x.name) }); if (b.length < 500) break; s += 500 }
return ids
}
async function resolveAudience ({ mode = 'filter', filters = {}, csv = '' } = {}) { async function resolveAudience ({ mode = 'filter', filters = {}, csv = '' } = {}) {
let rows = [] let rows = []
let resiliatedExcluded = 0 let resiliatedExcluded = 0
@ -529,13 +555,14 @@ 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
if (filters.name_like) f.push(['customer_name', 'like', '%' + String(filters.name_like).trim() + '%']) // ex. « Proprio » → comptes dont le nom le contient
const minMonthly = Number(filters.min_monthly) || 0 const minMonthly = Number(filters.min_monthly) || 0
// Ensembles d'IDs à INTERSECTER (ville via Service Location, abonnements via Service Subscription). // Ensembles d'IDs à INTERSECTER (nom fuzzy, ville via Service Location, abonnements via Service Subscription).
let restrict = null // null = pas de restriction ; sinon Set d'IDs Customer let restrict = null // null = pas de restriction ; sinon Set d'IDs Customer
const intersect = (set) => { restrict = restrict ? new Set([...restrict].filter(x => set.has(x))) : set } const intersect = (set) => { restrict = restrict ? new Set([...restrict].filter(x => set.has(x))) : set }
// NOM (fuzzy, multi-champs, insensible casse/accents) — ex. « lpb » → C-LPB4.
if (filters.name_like && String(filters.name_like).trim()) intersect(await nameLikeCustomerIds(String(filters.name_like)))
// VILLE → Service Location.customer (champ `city` peuplé, contrairement à `territory`). // VILLE → Service Location.customer (champ `city` peuplé, contrairement à `territory`).
if (filters.city && String(filters.city).trim()) intersect(await cityCustomerIds(String(filters.city))) if (filters.city && String(filters.city).trim()) intersect(await cityCustomerIds(String(filters.city)))
@ -629,7 +656,6 @@ function page (event, lang, prefill = {}, opts = {}) {
lang = normLang(lang) lang = normLang(lang)
const t = event[lang] const t = event[lang]
const other = lang === 'fr' ? 'en' : 'fr' const other = lang === 'fr' ? 'en' : 'fr'
const known = !!prefill.customer_id
const full = !!opts.full const full = !!opts.full
const greet = t.greet(firstName(prefill.name)) const greet = t.greet(firstName(prefill.name))
const program = (t.program || []).map(p => const program = (t.program || []).map(p =>
@ -726,7 +752,6 @@ button:hover{filter:brightness(1.05)}button:active{transform:translateY(1px)}but
${field('nm', 'name', esc(t.f_name), `<input id="nm" name="name" value="${esc(prefill.name || '')}" autocomplete="name" placeholder="Jean Tremblay">`, 'e_nm', t.err_name)} ${field('nm', 'name', esc(t.f_name), `<input id="nm" name="name" value="${esc(prefill.name || '')}" autocomplete="name" placeholder="Jean Tremblay">`, 'e_nm', t.err_name)}
${field('em', 'email', esc(t.f_email), `<input id="em" name="email" type="email" value="${esc(prefill.email || '')}" autocomplete="email" placeholder="vous@exemple.com">`, 'e_em', t.err_email)} ${field('em', 'email', esc(t.f_email), `<input id="em" name="email" type="email" value="${esc(prefill.email || '')}" autocomplete="email" placeholder="vous@exemple.com">`, 'e_em', t.err_email)}
${field('ph', 'phone', optlbl(t.f_phone), `<input id="ph" name="phone" type="tel" autocomplete="tel" placeholder="819 555-1234">`)} ${field('ph', 'phone', optlbl(t.f_phone), `<input id="ph" name="phone" type="tel" autocomplete="tel" placeholder="819 555-1234">`)}
${field('cn', 'customer_number', optlbl(t.f_number), `<input id="cn" name="customer_number" value="${esc(prefill.customer_number || '')}" ${known ? 'readonly' : ''} autocomplete="off" placeholder="C-12345">`)}
${field('ps', 'party_size', esc(t.f_party), `<input id="ps" name="party_size" type="number" min="1" max="30" inputmode="numeric" placeholder="${esc(t.f_party_ph)}">`, 'e_ps', t.err_party)} ${field('ps', 'party_size', esc(t.f_party), `<input id="ps" name="party_size" type="number" min="1" max="30" inputmode="numeric" placeholder="${esc(t.f_party_ph)}">`, 'e_ps', t.err_party)}
${field('al', 'allergies', optlbl(t.f_allerg), `<textarea id="al" name="allergies" maxlength="500"></textarea>`)} ${field('al', 'allergies', optlbl(t.f_allerg), `<textarea id="al" name="allergies" maxlength="500"></textarea>`)}
<button type="submit" id="sub">${esc(t.submit)}</button> <button type="submit" id="sub">${esc(t.submit)}</button>
@ -769,7 +794,7 @@ function submitRsvp(e){
if(!ok)return false; if(!ok)return false;
var b=document.getElementById('sub');b.disabled=true; var b=document.getElementById('sub');b.disabled=true;
fetch('/rsvp/'+encodeURIComponent(EVENT)+'/submit',{method:'POST',headers:{'Content-Type':'application/json'}, fetch('/rsvp/'+encodeURIComponent(EVENT)+'/submit',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({token:TOKEN,name:nm,email:em,phone:(f.phone.value||'').trim(),customer_number:(f.customer_number.value||'').trim(),party_size:ps,allergies:(f.allergies.value||'').trim(),lang:LANG})}) body:JSON.stringify({token:TOKEN,name:nm,email:em,phone:(f.phone.value||'').trim(),party_size:ps,allergies:(f.allergies.value||'').trim(),lang:LANG})})
.then(function(r){return r.json()}) .then(function(r){return r.json()})
.then(function(d){ .then(function(d){
if(d&&d.ok){ if(d&&d.ok){