fix(campaigns/match): handle multi-email + dupe SLs + missing postal

Three independent bugs surfaced while debugging why Alexandre Duval
showed as "non lié" in a campaign:

1. ERPNext Customer.email_id can hold multiple addresses joined by
   ';' or ',' (211 records inherited from Legacy migration). The
   exact-match filter missed them. Now LIKE-searches a window then
   validates locally by splitting on ; , or whitespace.

2. Service Locations have duplicates at the same address — the same
   "7 Rue des Merles" exists 3 times, linked to 3 different customers
   (legacy migration artifact). The civic+postal strategy was taking
   the first hit which could be the wrong household. Added name-aware
   disambiguation: when the recipient has a name, walk the candidates
   and pick the one whose linked Customer name plausibly matches.

3. New 4th matching strategy "name+civic" — kicks in when the CSV
   row has no postal_code (most common Map export failure mode). Does
   a street-word filtered SL search and accepts only candidates whose
   Customer name plausibly matches. Confidence 0.65 (vs 0.85 for
   civic+postal).

Also: SQL filter for both civic+postal and name+civic now includes a
street-word LIKE constraint so the result set isn't dominated by
unrelated "7 ..." addresses, bumped limits to 50/100. The SL
denormalized customer_name field is often empty post-import — we now
fall back to a Customer lookup for the name check.

Verified end-to-end against live ERPNext: Alexandre Duval at
7 Rue des Merles now matches correctly via email (multi-value field),
via civic+postal (despite 3 dupe SLs), and via name+civic (no postal).
Gaëtan David at the same address also matches correctly without
collision.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-05-22 11:36:51 -04:00
parent 85ad66f103
commit f6d06d9b34

View File

@ -524,14 +524,23 @@ async function matchCustomer (recipient) {
// 1) Email match on Customer.email_id (most reliable)
if (recipient.email) {
try {
// ERPNext's Customer.email_id can hold multiple addresses joined by
// ';' or ',' (211 records as of 2026-05 — legacy migration artifact).
// An exact-equality filter misses those, so we LIKE-search and then
// validate locally by splitting on the common delimiters.
const needle = recipient.email.toLowerCase()
const r = await erp.list('Customer', {
fields: ['name', 'customer_name', 'email_id', 'mobile_no', 'language'],
filters: [['email_id', '=', recipient.email]],
limit: 1,
filters: [['email_id', 'like', '%' + needle + '%']],
limit: 5,
})
if (r && r.length) {
return { customer_id: r[0].name, customer_name: r[0].customer_name,
language: r[0].language || 'fr',
const hit = (r || []).find(c => {
const list = (c.email_id || '').toLowerCase().split(/[;,\s]+/).filter(Boolean)
return list.includes(needle)
})
if (hit) {
return { customer_id: hit.name, customer_name: hit.customer_name,
language: hit.language || 'fr',
match_method: 'email', match_confidence: 1.0 }
}
} catch (e) { log('match email error:', e.message) }
@ -560,21 +569,55 @@ async function matchCustomer (recipient) {
const civic = normalizeCivic(recipient.civic_address)
if (civic) {
const [num, streetPrefix] = civic.split('|')
// Add a street-word filter so the 10-row default limit doesn't
// miss the right SL when many addresses share the same street
// number in the postal code. Same approach as name+civic below.
const streetWord = (streetPrefix.split(/\s+/).find(w => w.length >= 4) || streetPrefix).slice(0, 8)
const r = await erp.list('Service Location', {
fields: ['name', 'address_line', 'postal_code', 'customer', 'customer_name'],
filters: [
['postal_code', '=', recipient.postal_code],
['address_line', 'like', num + '%'],
['address_line', 'like', '%' + streetWord + '%'],
],
limit: 10,
limit: 50,
})
const hit = (r || []).find(sl => {
// Address-shape filter — same logic as before
const candidates = (r || []).filter(sl => {
const slCivic = normalizeCivic(sl.address_line)
return slCivic && slCivic.startsWith(num + '|') &&
slCivic.split('|')[1]?.startsWith(streetPrefix.slice(0, 5))
})
// When recipient has a name AND we have multiple candidates,
// disambiguate by name. Falls back to the first candidate if
// no name is provided or no name match found (legacy behaviour).
const tgtFirst = (recipient.firstname || '').toLowerCase().trim()
const tgtLast = (recipient.lastname || '').toLowerCase().trim()
const looksLikeMatch = async (sl) => {
if (!tgtFirst && !tgtLast) return true // no name → trust civic
let custName = sl.customer_name || ''
if (!custName && sl.customer) {
try {
const cc = await erp.list('Customer', {
fields: ['customer_name'], filters: [['name', '=', sl.customer]], limit: 1,
})
if (cc?.[0]) { custName = cc[0].customer_name; sl.customer_name = custName }
} catch {}
}
const n = (custName || '').toLowerCase()
if (!n) return false
if (tgtFirst && tgtLast && n.includes(tgtFirst) && n.includes(tgtLast)) return true
if (tgtLast.length >= 4 && n.includes(tgtLast)) return true
return false
}
let hit = null
for (const sl of candidates) {
if (await looksLikeMatch(sl)) { hit = sl; break }
}
// If name disambiguation found nothing AND no name was provided,
// accept the first civic-shape candidate (legacy single-pass behaviour).
if (!hit && !tgtFirst && !tgtLast) hit = candidates[0]
if (hit && hit.customer) {
// Service Location doesn't carry language — fetch it from the linked Customer
let language = 'fr'
try {
const c = await erp.list('Customer', {
@ -584,12 +627,96 @@ async function matchCustomer (recipient) {
} catch {}
return { customer_id: hit.customer, customer_name: hit.customer_name || hit.customer,
language,
match_method: 'civic', match_confidence: 0.8 }
match_method: 'civic', match_confidence: 0.85 }
}
}
} catch (e) { log('match civic error:', e.message) }
}
// 4) Name + civic_address fallback — for CSV rows without postal_code,
// when the Map export gave a partner's email that doesn't match ERPNext.
// We scan Service Locations whose civic_address starts with the same
// street number+street prefix, fetch each linked Customer's actual name,
// and accept the hit only if the names plausibly match. This refuses to
// link when the address is shared with a different household (e.g.
// previous occupant) — better unmatched than wrongly matched.
if (recipient.civic_address && (recipient.firstname || recipient.lastname)) {
try {
const civic = normalizeCivic(recipient.civic_address)
if (civic) {
const [num, streetPrefix] = civic.split('|')
// Narrow the SQL search with one street-name word from the
// normalized prefix (skip "des"/"de"/"la" style stopwords by
// picking the first ≥ 4-char word). Postgres LIKE is case-
// sensitive but Service Location address_line is typically
// stored in mixed/title case, so we cast both ends to lowercase
// by using the normalized prefix's lowercase output literally.
// limit bumped from 20 → 100 to cover edge cases on common
// streets like "des Merles" that have hundreds of addresses
// starting with the same digit.
const streetWord = (streetPrefix.split(/\s+/).find(w => w.length >= 4) || streetPrefix).slice(0, 8)
const r = await erp.list('Service Location', {
fields: ['name', 'address_line', 'postal_code', 'customer', 'customer_name'],
// Two filters AND'd: street number prefix + a contains-word
// narrowing. The lowercased streetWord works because Frappe
// wraps LIKE with case-insensitive collation on postgres.
filters: [
['address_line', 'like', num + '%'],
['address_line', 'like', '%' + streetWord + '%'],
],
limit: 100,
})
// Pre-filter by civic shape before doing per-row Customer lookups
// (which are network round-trips — keep their count small).
const candidates = (r || []).filter(sl => {
const slCivic = normalizeCivic(sl.address_line)
if (!slCivic || !slCivic.startsWith(num + '|')) return false
return slCivic.split('|')[1]?.startsWith(streetPrefix.slice(0, 5))
})
const tgtFirst = (recipient.firstname || '').toLowerCase().trim()
const tgtLast = (recipient.lastname || '').toLowerCase().trim()
const nameMatches = (custName) => {
const n = (custName || '').toLowerCase()
if (!n) return false
if (tgtFirst && tgtLast && n.includes(tgtFirst) && n.includes(tgtLast)) return true
if (tgtLast.length >= 4 && n.includes(tgtLast)) return true
return false
}
for (const sl of candidates) {
if (!sl.customer) continue
// First try the SL's denormalized customer_name (cheap), fall
// back to a Customer lookup if empty (common — denorm is often
// missing post-import).
let custName = sl.customer_name || ''
let language = 'fr'
if (!custName) {
try {
const c = await erp.list('Customer', {
fields: ['customer_name', 'language'],
filters: [['name', '=', sl.customer]], limit: 1,
})
if (c?.[0]) { custName = c[0].customer_name; language = c[0].language || 'fr' }
} catch {}
}
if (nameMatches(custName)) {
// We don't have language yet if denorm hit — fetch it once.
if (sl.customer_name) {
try {
const c = await erp.list('Customer', {
fields: ['language'], filters: [['name', '=', sl.customer]], limit: 1,
})
if (c?.[0]?.language) language = c[0].language
} catch {}
}
return { customer_id: sl.customer, customer_name: custName,
language,
match_method: 'name+civic', match_confidence: 0.65 }
}
}
}
} catch (e) { log('match name+civic error:', e.message) }
}
// Unmatched: default to French (93% of customer base)
return { customer_id: null, customer_name: null, language: 'fr',
match_method: null, match_confidence: 0 }