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:
parent
f6d06d9b34
commit
5c55087198
|
|
@ -220,6 +220,18 @@
|
||||||
il y a plus de cartes-cadeaux que de contacts. Le surplus sera perdu si
|
il y a plus de cartes-cadeaux que de contacts. Le surplus sera perdu si
|
||||||
la campagne est envoyée tel quel.
|
la campagne est envoyée tel quel.
|
||||||
</div>
|
</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>
|
</q-banner>
|
||||||
|
|
||||||
<!-- ✓ Paired recipients (will be sent) ────────────────────────── -->
|
<!-- ✓ Paired recipients (will be sent) ────────────────────────── -->
|
||||||
|
|
@ -621,6 +633,10 @@ const sending = ref(false)
|
||||||
const recipients = ref([])
|
const recipients = ref([])
|
||||||
const unpairedContacts = ref([])
|
const unpairedContacts = ref([])
|
||||||
const unusedGifts = 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
|
// 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.
|
// user to cross-reference what they see here against the file they uploaded.
|
||||||
|
|
@ -820,6 +836,7 @@ async function goPreview () {
|
||||||
recipients.value = r.recipients || []
|
recipients.value = r.recipients || []
|
||||||
unpairedContacts.value = r.unpaired_contacts || []
|
unpairedContacts.value = r.unpaired_contacts || []
|
||||||
unusedGifts.value = r.unused_gifts || []
|
unusedGifts.value = r.unused_gifts || []
|
||||||
|
parseSkipped.value = r.skipped || null
|
||||||
step.value = 2
|
step.value = 2
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
$q.notify({ type: 'negative', message: 'Erreur de parsing: ' + e.message })
|
$q.notify({ type: 'negative', message: 'Erreur de parsing: ' + e.message })
|
||||||
|
|
|
||||||
|
|
@ -390,7 +390,7 @@ function parseMapCsv (text, multi = 'first') {
|
||||||
const rows = parseCsv(text, { skipPreamble: true })
|
const rows = parseCsv(text, { skipPreamble: true })
|
||||||
const contacts = []
|
const contacts = []
|
||||||
const seen = new Set()
|
const seen = new Set()
|
||||||
let skippedNoEmail = 0, skippedNoName = 0
|
let skippedNoEmail = 0, skippedNoName = 0, skippedDuplicate = 0, skippedMultiSkip = 0
|
||||||
|
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
// Pull emails from either source column
|
// Pull emails from either source column
|
||||||
|
|
@ -404,10 +404,15 @@ function parseMapCsv (text, multi = 'first') {
|
||||||
const sendEmails = multi === 'split' ? emails
|
const sendEmails = multi === 'split' ? emails
|
||||||
: multi === 'skip' ? (emails.length > 1 ? [] : emails)
|
: multi === 'skip' ? (emails.length > 1 ? [] : emails)
|
||||||
: emails.slice(0, 1)
|
: 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()
|
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 parts = full.split(/\s+/, 2)
|
||||||
const firstnameRaw = parts[0] || ''
|
const firstnameRaw = parts[0] || ''
|
||||||
const lastnameRaw = parts.length > 1 ? full.slice(parts[0].length).trim() : ''
|
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 firstname = cleanName(firstnameRaw)
|
||||||
const lastname = cleanName(lastnameRaw)
|
const lastname = cleanName(lastnameRaw)
|
||||||
const name_warnings = {
|
const name_warnings = {
|
||||||
firstname: nameWarning(firstname),
|
firstname: !hasName ? 'pas de nom dans le CSV — utilisera "cher client" à l\'envoi' : nameWarning(firstname),
|
||||||
lastname: nameWarning(lastname),
|
lastname: nameWarning(lastname),
|
||||||
}
|
}
|
||||||
const cleaned_changed = (firstname !== firstnameRaw || lastname !== lastnameRaw)
|
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"] || '')
|
const phone = normalizePhone(r['telephone au compte'] || r["telephone à l'adresse"] || '')
|
||||||
|
|
||||||
for (const em of sendEmails) {
|
for (const em of sendEmails) {
|
||||||
if (seen.has(em)) continue
|
if (seen.has(em)) { skippedDuplicate++; continue }
|
||||||
seen.add(em)
|
seen.add(em)
|
||||||
contacts.push({
|
contacts.push({
|
||||||
firstname, lastname,
|
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 ──────────────────────────────────────────────────────
|
// ── Giftbit CSV parsing ──────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user