fix(campaigns/parse): keep no-name rows + surface skip breakdown

Map CSV rows that had a valid email but no name in the source column
were silently dropped at parsing — that's why a campaign would end up
with N unpaired Giftbit shortlinks for N "missing" contacts that
weren't actually missing, just nameless.

The send worker already handles a missing firstname by substituting
"cher client" / "dear customer", so dropping the row was wasteful.
Now we keep the contact and surface a name_warning on the row so the
operator can either edit the firstname in Step 2 or accept the
default.

Also added counters for previously-silent skip paths:
- duplicate: row's email was already seen above (1 gift / household
  consolidation, depending on the multi setting)
- multi_skip: couple skipped because multi='skip' was selected

Wizard Step 2 imbalance banner now ventilates the skip breakdown so
the operator understands exactly where the N "missing" contacts went:

  Ventilation des contacts droppés au parsing du Map CSV (sur 213
  lignes brutes) : 8 sans email valide · 5 emails en double · 0
  couples ignorés · 3 sans nom (gardés, utilisent "cher client" à
  l'envoi)

Unrelated reassurance on the question that triggered this: language
fallback to French is already in place (matchCustomer returns
language:'fr' on miss, worker reads (r.language || 'fr')) so any
unmatched recipient gets the FR template, never an English one by
default.

🤖 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:43:22 -04:00
parent f6d06d9b34
commit 5c55087198
2 changed files with 37 additions and 6 deletions

View File

@ -220,6 +220,18 @@
il y a plus de cartes-cadeaux que de contacts. Le surplus sera perdu si
la campagne est envoyée tel quel.
</div>
<!-- Parser skip breakdown: tells the operator exactly WHERE the missing
contacts went (no_email = lost forever; duplicate = couple/foyer
de-duped; multi_skip = couple skipped by their multi setting). -->
<div v-if="parseSkipped && (parseSkipped.no_email || parseSkipped.duplicate || parseSkipped.multi_skip)"
class="q-mt-sm text-caption" style="color:#7C3F00">
<q-icon name="info" size="14px" /> Ventilation des contacts droppés au parsing du Map CSV
(sur {{ parseSkipped.total_rows }} lignes brutes) :
<span v-if="parseSkipped.no_email"> <strong>{{ parseSkipped.no_email }}</strong> sans email valide ·</span>
<span v-if="parseSkipped.duplicate"> <strong>{{ parseSkipped.duplicate }}</strong> emails en double (déjà vu plus haut) ·</span>
<span v-if="parseSkipped.multi_skip"> <strong>{{ parseSkipped.multi_skip }}</strong> couples ignorés (selon le réglage "Emails multiples") ·</span>
<span v-if="parseSkipped.no_name"> <strong>{{ parseSkipped.no_name }}</strong> sans nom (gardés, utilisent "cher client" à l'envoi)</span>
</div>
</q-banner>
<!-- Paired recipients (will be sent) -->
@ -621,6 +633,10 @@ const sending = ref(false)
const recipients = ref([])
const unpairedContacts = ref([])
const unusedGifts = ref([])
// Map CSV parse skip counters surfaced from the hub so the operator can see
// exactly which rows didn't make it into the pairing (no email, duplicates,
// multi-skip).
const parseSkipped = ref(null)
// row_index (#1, #2, ...) is the source-CSV position invaluable for the
// user to cross-reference what they see here against the file they uploaded.
@ -820,6 +836,7 @@ async function goPreview () {
recipients.value = r.recipients || []
unpairedContacts.value = r.unpaired_contacts || []
unusedGifts.value = r.unused_gifts || []
parseSkipped.value = r.skipped || null
step.value = 2
} catch (e) {
$q.notify({ type: 'negative', message: 'Erreur de parsing: ' + e.message })

View File

@ -390,7 +390,7 @@ function parseMapCsv (text, multi = 'first') {
const rows = parseCsv(text, { skipPreamble: true })
const contacts = []
const seen = new Set()
let skippedNoEmail = 0, skippedNoName = 0
let skippedNoEmail = 0, skippedNoName = 0, skippedDuplicate = 0, skippedMultiSkip = 0
for (const r of rows) {
// Pull emails from either source column
@ -404,10 +404,15 @@ function parseMapCsv (text, multi = 'first') {
const sendEmails = multi === 'split' ? emails
: multi === 'skip' ? (emails.length > 1 ? [] : emails)
: emails.slice(0, 1)
if (!sendEmails.length) continue
if (!sendEmails.length) { skippedMultiSkip++; continue }
// We used to skip rows without a name here. That dropped the contact AND
// wasted its paired Giftbit shortlink — the worker already defaults
// firstname to "cher client" / "dear customer" when missing, so we now
// keep the row and just flag it with a name warning.
const full = (r['nom au compte'] || r["nom à l'adresse"] || '').trim()
if (!full) { skippedNoName++; continue }
const hasName = !!full
if (!hasName) skippedNoName++ // informational counter, NOT a continue
const parts = full.split(/\s+/, 2)
const firstnameRaw = parts[0] || ''
const lastnameRaw = parts.length > 1 ? full.slice(parts[0].length).trim() : ''
@ -419,7 +424,7 @@ function parseMapCsv (text, multi = 'first') {
const firstname = cleanName(firstnameRaw)
const lastname = cleanName(lastnameRaw)
const name_warnings = {
firstname: nameWarning(firstname),
firstname: !hasName ? 'pas de nom dans le CSV — utilisera "cher client" à l\'envoi' : nameWarning(firstname),
lastname: nameWarning(lastname),
}
const cleaned_changed = (firstname !== firstnameRaw || lastname !== lastnameRaw)
@ -429,7 +434,7 @@ function parseMapCsv (text, multi = 'first') {
const phone = normalizePhone(r['telephone au compte'] || r["telephone à l'adresse"] || '')
for (const em of sendEmails) {
if (seen.has(em)) continue
if (seen.has(em)) { skippedDuplicate++; continue }
seen.add(em)
contacts.push({
firstname, lastname,
@ -443,7 +448,16 @@ function parseMapCsv (text, multi = 'first') {
})
}
}
return { contacts, skipped: { no_email: skippedNoEmail, no_name: skippedNoName, total_rows: rows.length } }
return {
contacts,
skipped: {
no_email: skippedNoEmail,
no_name: skippedNoName, // informational only — these rows are KEPT now
duplicate: skippedDuplicate,
multi_skip: skippedMultiSkip,
total_rows: rows.length,
},
}
}
// ── Giftbit CSV parsing ──────────────────────────────────────────────────────