From f6d06d9b34a3796bb4fe281e4104d6265e5b2e83 Mon Sep 17 00:00:00 2001 From: louispaulb Date: Fri, 22 May 2026 11:36:51 -0400 Subject: [PATCH] fix(campaigns/match): handle multi-email + dupe SLs + missing postal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- services/targo-hub/lib/campaigns.js | 145 ++++++++++++++++++++++++++-- 1 file changed, 136 insertions(+), 9 deletions(-) diff --git a/services/targo-hub/lib/campaigns.js b/services/targo-hub/lib/campaigns.js index 60a9067..33bd6a5 100644 --- a/services/targo-hub/lib/campaigns.js +++ b/services/targo-hub/lib/campaigns.js @@ -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 }