feat(dispatch): provisioning identites enrichi Employee+Authentik (multi-courriels)

extEmailIndex(): index nom->emails depuis ERPNext Employee (company/prefered/
personal/user_id) + Authentik /core/users. provisionIdentities l utilise pour:
- CREATE des identites d ex-staff sans courriel legacy (si une source externe en a un)
- ENRICH: tous les courriels decouverts s accumulent en alias_emails (union,
  jamais overwrite) -> multi-courriels par personne, resolvable par chaque alias
- skip des comptes systeme (ticket dispatch, Gestion Inventaire, Tech Targo)
Applique en prod: +1 lien, +5 identites enrichies d alias (perso+corpo). Idempotent
(re-run 0 ecriture). Les 41 restants = ex-employes absents de toute source ->
mapping manuel via l onglet Identites (liste unresolved du rapport).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-16 14:50:23 -04:00
parent e8fd69c6f8
commit 9c1b578d5d

View File

@ -2624,27 +2624,60 @@ async function techHistory ({ tech = '', limit = 200 } = {}) {
// assignations : garantit une identité canonique portant son legacy_staff_id (+ tech_id SI Dispatch Technician → les // assignations : garantit une identité canonique portant son legacy_staff_id (+ tech_id SI Dispatch Technician → les
// admins restent SANS tech_id). LINK (identité trouvée par courriel/nom → renseigne legacy_staff_id) ou CREATE (aucune → // admins restent SANS tech_id). LINK (identité trouvée par courriel/nom → renseigne legacy_staff_id) ou CREATE (aucune →
// crée depuis le staff). Idempotent. dryRun => 0 écriture. scope 'ledger' (défaut : staff du ledger) | 'active' (tout staff actif). // crée depuis le staff). Idempotent. dryRun => 0 écriture. scope 'ledger' (défaut : staff du ledger) | 'active' (tout staff actif).
// Comptes SYSTÈME du legacy staff (pas des personnes) → jamais d'identité auto.
const SYS_STAFF_RE = /ticket\s*dispatch|gestion\s*inventaire|^tech\s*targo$|^test\b/i
// Index nom→emails depuis les sources EXTERNES (ERPNext Employee + Authentik) — enrichit les staff sans courriel
// legacy (ex-employés hors staff.email) ET alimente les alias multi-courriels (cf 3 sources de noms/identité).
async function extEmailIndex () {
const byName = new Map() // norm(nom complet) → Set(emails)
const add = (name, mail) => { const k = norm(name || ''); const v = String(mail || '').trim().toLowerCase(); if (!k || !v || !v.includes('@')) return; const set = byName.get(k) || new Set(); set.add(v); byName.set(k, set) }
try {
const emps = await erp.list('Employee', { fields: ['employee_name', 'company_email', 'prefered_email', 'personal_email', 'user_id'], limit: 2000 })
for (const e of (emps || [])) for (const m of [e.company_email, e.prefered_email, e.personal_email, e.user_id]) add(e.employee_name, m)
} catch (e) {}
try {
const d = await akGet('/core/users/?page_size=500')
for (const u of ((d && d.results) || [])) add(u.name, u.email)
} catch (e) {}
return byName
}
async function provisionIdentities ({ dryRun = true, scope = 'ledger' } = {}) { async function provisionIdentities ({ dryRun = true, scope = 'ledger' } = {}) {
const p = pool(); const pg = techHistPg(); const idn = require('./identity') const p = pool(); const pg = techHistPg(); const idn = require('./identity')
let staffIds = [] let staffIds = []
if (scope === 'active') { const [r] = await p.query('SELECT id FROM staff WHERE status=1'); staffIds = r.map(x => x.id) } else { const r = await pg.query('SELECT DISTINCT to_staff_id FROM ops_tech_ticket_history WHERE to_staff_id IS NOT NULL'); staffIds = r.rows.map(x => x.to_staff_id) } if (scope === 'active') { const [r] = await p.query('SELECT id FROM staff WHERE status=1'); staffIds = r.map(x => x.id) } else { const r = await pg.query('SELECT DISTINCT to_staff_id FROM ops_tech_ticket_history WHERE to_staff_id IS NOT NULL'); staffIds = r.rows.map(x => x.to_staff_id) }
if (!staffIds.length) return { ok: true, scope, targets: 0, linked: 0, created: 0, already: 0, no_email: [], samples: [] } if (!staffIds.length) return { ok: true, scope, targets: 0, linked: 0, created: 0, enriched: 0, already: 0, system: 0, no_email: [], samples: [] }
const [staff] = await p.query('SELECT id, first_name, last_name, email, status FROM staff WHERE id IN (?)', [staffIds]) const [staff] = await p.query('SELECT id, first_name, last_name, email, status FROM staff WHERE id IN (?)', [staffIds])
const byId = new Map(staff.map(s => [String(s.id), s])) const byId = new Map(staff.map(s => [String(s.id), s]))
const techs = await erp.list('Dispatch Technician', { fields: ['name', 'technician_id'], limit: 1000 }) const techs = await erp.list('Dispatch Technician', { fields: ['name', 'technician_id'], limit: 1000 })
const techByStaff = {}; for (const t of (techs || [])) { const m = String(t.technician_id || '').match(/(\d+)$/); if (m) techByStaff[m[1]] = t.technician_id || t.name } const techByStaff = {}; for (const t of (techs || [])) { const m = String(t.technician_id || '').match(/(\d+)$/); if (m) techByStaff[m[1]] = t.technician_id || t.name }
const res = { ok: true, scope, dryRun, targets: staffIds.length, linked: 0, created: 0, already: 0, no_email: [], samples: [] } const ext = await extEmailIndex()
const res = { ok: true, scope, dryRun, targets: staffIds.length, linked: 0, created: 0, enriched: 0, already: 0, system: 0, no_email: [], samples: [] }
for (const sid of staffIds) { for (const sid of staffIds) {
const s = byId.get(String(sid)); if (!s) continue const s = byId.get(String(sid)); if (!s) continue
const full = [s.first_name, s.last_name].filter(Boolean).join(' ').trim(); const email = norm(s.email); const techId = techByStaff[String(sid)] || '' const full = [s.first_name, s.last_name].filter(Boolean).join(' ').trim(); const techId = techByStaff[String(sid)] || ''
if (SYS_STAFF_RE.test(full)) { res.system++; continue } // compte système → pas d'identité personne
// TOUS les courriels connus (legacy → primaire préféré, puis Employee/Authentik) : le 1er = primaire, le reste = alias.
const legacyMail = norm(s.email)
const allEmails = [...new Set([legacyMail, ...(ext.get(norm(full)) || [])].filter(Boolean))]
const existing = idn.resolveIdentity(String(sid)) const existing = idn.resolveIdentity(String(sid))
if (existing && String(existing.legacy_staff_id) === String(sid)) { if (existing && String(existing.legacy_staff_id) === String(sid)) {
if (techId && !existing.tech_id) { if (!dryRun) idn.upsert({ key: existing.key, label: existing.label, primary_email: existing.primary_email, legacy_staff_id: sid, tech_id: techId }); res.linked++; if (res.samples.length < 30) res.samples.push({ staff: sid, action: 'fill-tech', key: existing.key }) } else res.already++ // Déjà lié : enrichit quand même (tech_id manquant / NOUVEAUX courriels → alias, union jamais overwrite).
const have = new Set([existing.primary_email, ...(existing.alias_emails || [])].map(x => String(x || '').toLowerCase()))
const newMails = allEmails.filter(e => !have.has(e))
const needTech = techId && !existing.tech_id
if (needTech || newMails.length) {
if (!dryRun) idn.upsert({ key: existing.key, label: existing.label, primary_email: existing.primary_email, legacy_staff_id: sid, tech_id: techId || existing.tech_id, alias_emails: newMails })
res.enriched++; if (res.samples.length < 40) res.samples.push({ staff: sid, action: 'enrich', key: existing.key, add_aliases: newMails, tech: needTech ? techId : undefined })
} else res.already++
continue continue
} }
const cand = (email && idn.resolveIdentity(email)) || (full && idn.resolveIdentity(full)) || null // Identité existante par un des courriels ou par nom → LINK (renseigne legacy_staff_id + alias découverts).
if (cand) { if (!dryRun) idn.upsert({ key: cand.key, label: cand.label, primary_email: cand.primary_email, legacy_staff_id: sid, tech_id: techId || cand.tech_id }); res.linked++; if (res.samples.length < 30) res.samples.push({ staff: sid, name: full, action: 'link', key: cand.key, tech: !!techId }); continue } let cand = null
if (email) { if (!dryRun) idn.upsert({ label: full || email, primary_email: email, legacy_staff_id: sid, tech_id: techId, kind: 'employee', active: Number(s.status) === 1 }); res.created++; if (res.samples.length < 30) res.samples.push({ staff: sid, name: full, action: 'create', email, tech: !!techId }); continue } for (const e of allEmails) { cand = idn.resolveIdentity(e); if (cand) break }
if (!cand && full) cand = idn.resolveIdentity(full)
if (cand) { if (!dryRun) idn.upsert({ key: cand.key, label: cand.label, primary_email: cand.primary_email, alias_emails: allEmails, legacy_staff_id: sid, tech_id: techId || cand.tech_id }); res.linked++; if (res.samples.length < 40) res.samples.push({ staff: sid, name: full, action: 'link', key: cand.key, tech: !!techId }); continue }
// Aucune identité → CREATE avec le 1er courriel connu en primaire + les autres en alias.
if (allEmails.length) { if (!dryRun) idn.upsert({ label: full || allEmails[0], primary_email: allEmails[0], alias_emails: allEmails.slice(1), legacy_staff_id: sid, tech_id: techId, kind: 'employee', active: Number(s.status) === 1 }); res.created++; if (res.samples.length < 40) res.samples.push({ staff: sid, name: full, action: 'create', primary: allEmails[0], aliases: allEmails.slice(1), tech: !!techId }); continue }
res.no_email.push({ staff: sid, name: full }) res.no_email.push({ staff: sid, name: full })
} }
return res return res