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:
parent
85ad66f103
commit
f6d06d9b34
|
|
@ -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 }
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user