feat(hub): lib/vacation-import — lecteur .xlsx (ZIP/zlib, sans dép) + parseur vacances + matcher techs

Cœur de l'import du calendrier de vacances (cf. memory project_vacation_import) :
- unzip() : lecteur ZIP minimal via zlib.inflateRawSync (aucune dépendance npm, comme gmail.js évite googleapis)
- parse xlsx : sharedStrings + styles(fills→couleur) + cellules ; couleur cyan/jaune/magenta = Choix1/Choix2/Approuvé (vert=instruction, ignoré)
- parseCell() : texte de cellule → jours ISO (« 6 et 7 », « 6 au 10 », « 13,14…24 », « 21 juin au 1er juillet », listes .-) ; résolution du quantième par proximité (gère bornes de mois + listes multi-semaines)
- extractPeople() : personnes → jours par statut, plages contiguës
- matchAll() : appariement noms→techs en 2 passes (certains d'abord, puis lever ambiguïtés sur techs restants ; indice = initiale de nom)
- buildPreview() : buffer + techs → payload dry-run

Vérifié EN LOCAL (node, runtime hub) contre le vrai fichier « Vacances 2026.xlsx » : 30 semaines, 24 personnes, 24/24 appariées ; ambiguïtés Louis/Simon + cellules texte-sans-couleur correctement signalées « À classer ». Endpoints + UI OPS = étape suivante.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-04 09:26:57 -04:00
parent b7c24f58b2
commit d385367095

View File

@ -0,0 +1,144 @@
// Import du calendrier de vacances (.xlsx Google Sheets export) → absences (Tech Availability) + brouillon.
// AUCUNE dépendance npm : lit le ZIP xlsx via zlib.inflateRawSync (comme gmail.js évite googleapis).
// Voir memory project_vacation_import. Sheet = couleurs cyan/jaune/magenta = Choix1/Choix2/Approuvé.
const zlib = require('zlib')
// ── ZIP minimal : Buffer → { nom: Buffer } (via central directory, méthodes 0=stored / 8=deflate) ──
function unzip (buf) {
const files = {}
let eocd = -1
for (let i = buf.length - 22; i >= 0 && i >= buf.length - 22 - 65536; i--) { if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break } }
if (eocd < 0) throw new Error('xlsx: ZIP EOCD introuvable (fichier corrompu ?)')
const count = buf.readUInt16LE(eocd + 10)
let off = buf.readUInt32LE(eocd + 16)
for (let n = 0; n < count; n++) {
if (buf.readUInt32LE(off) !== 0x02014b50) break
const method = buf.readUInt16LE(off + 10)
const compSize = buf.readUInt32LE(off + 20)
const nameLen = buf.readUInt16LE(off + 28)
const extraLen = buf.readUInt16LE(off + 30)
const commentLen = buf.readUInt16LE(off + 32)
const localOff = buf.readUInt32LE(off + 42)
const name = buf.toString('utf8', off + 46, off + 46 + nameLen)
const lNameLen = buf.readUInt16LE(localOff + 26)
const lExtraLen = buf.readUInt16LE(localOff + 28)
const start = localOff + 30 + lNameLen + lExtraLen
const raw = buf.slice(start, start + compSize)
try { files[name] = method === 0 ? raw : zlib.inflateRawSync(raw) } catch (e) { /* entrée illisible → ignorée */ }
off += 46 + nameLen + extraLen + commentLen
}
return files
}
// ── XML helpers ──
const decode = s => String(s).replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&apos;/g, "'").replace(/&amp;/g, '&')
const colToNum = c => { let n = 0; for (const ch of c) n = n * 26 + (ch.charCodeAt(0) - 64); return n }
const numToCol = n => { let s = ''; while (n > 0) { const r = (n - 1) % 26; s = String.fromCharCode(65 + r) + s; n = Math.floor((n - 1) / 26) } return s }
function parseShared (xml) { const out = []; if (!xml) return out; const re = /<si>([\s\S]*?)<\/si>/g; let m; while ((m = re.exec(xml))) { let t = ''; const tre = /<t[^>]*>([\s\S]*?)<\/t>/g; let tm; while ((tm = tre.exec(m[1]))) t += tm[1]; out.push(decode(t)) } return out }
function parseStyles (xml) {
const fb = (xml.match(/<fills[^>]*>([\s\S]*?)<\/fills>/) || [])[1] || ''
const fills = []; const fre = /<fill>([\s\S]*?)<\/fill>/g; let fm; while ((fm = fre.exec(fb))) fills.push((fm[1].match(/<fgColor rgb="([0-9A-Fa-f]{8})"/) || [])[1] || null)
const xb = (xml.match(/<cellXfs[^>]*>([\s\S]*?)<\/cellXfs>/) || [])[1] || ''
const xfs = []; const xre = /<xf\b[^>]*\/?>/g; let xm; while ((xm = xre.exec(xb))) xfs.push(+((xm[0].match(/fillId="(\d+)"/) || [])[1] || 0))
return { fills, xfs }
}
const COLOR_STATUS = { '00FFFF': 'Choix 1', 'FFFF00': 'Choix 2', 'FF00FF': 'Approuvé' } // vert 00FF00 = instruction → ignoré
const statusOf = rgb => rgb ? (COLOR_STATUS[rgb.slice(2).toUpperCase()] || null) : null
function parseSheet (xml, shared, styles) {
const cells = {}; const cre = /<c r="([A-Z]+)(\d+)"([^>]*?)(?:\/>|>([\s\S]*?)<\/c>)/g; let cm
while ((cm = cre.exec(xml))) {
const col = cm[1]; const row = +cm[2]; const at = cm[3] || ''; const inner = cm[4] || ''
const s = (at.match(/\bs="(\d+)"/) || [])[1]; const t = (at.match(/\bt="([^"]+)"/) || [])[1]
const rgb = s != null ? styles.fills[styles.xfs[+s]] : null
let v = ''; const vm = inner.match(/<v>([\s\S]*?)<\/v>/); const im = inner.match(/<is>[\s\S]*?<t[^>]*>([\s\S]*?)<\/t>/)
if (t === 's' && vm) v = shared[+vm[1]] || ''; else if (im) v = decode(im[1]); else if (vm) v = decode(vm[1])
cells[col + row] = { v, status: statusOf(rgb), colN: colToNum(col), row }
}
return cells
}
// ── Dates ──
const isoOf = d => d.toISOString().slice(0, 10)
const serialToDate = s => new Date(Date.UTC(1899, 11, 30) + Math.round(parseFloat(s)) * 86400000) // système 1900 (avec bug)
const MONTHS_FR = { janvier: 0, fevrier: 1, mars: 2, avril: 3, mai: 4, juin: 5, juillet: 6, aout: 7, septembre: 8, octobre: 9, novembre: 10, decembre: 11 }
const norm = s => String(s || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').trim()
// numéro de jour → date la PLUS PROCHE de refDate ayant ce quantième (gère bornes de mois + listes multi-semaines)
function resolveDay (D, refDate) { let best = null; for (let dm = -1; dm <= 1; dm++) { const cand = new Date(Date.UTC(refDate.getUTCFullYear(), refDate.getUTCMonth() + dm, D)); if (cand.getUTCDate() !== D) continue; const diff = Math.abs(cand - refDate); if (!best || diff < best.diff) best = { iso: isoOf(cand), diff } } return best ? best.iso : null }
// Texte de cellule → jours ISO (ou { week:true } = semaine entière). refDate = dimanche de la colonne-semaine.
function parseCell (text, refDate) {
const raw = (text == null ? '' : String(text)).trim(); if (!raw) return { week: true, days: [] }
const low = norm(raw)
if (/^(vacances?|conges?|choix ?\d|approuve|x|oui|off)$/.test(low.replace(/\s+/g, ' '))) return { week: true, days: [] }
const rng = low.match(/(\d{1,2})\s*(?:er)?\s*([a-z]+)?\s*au\s*(\d{1,2})\s*(?:er)?\s*([a-z]+)?/)
if (rng) { const y = refDate.getUTCFullYear(); const m1 = MONTHS_FR[rng[2]] != null ? MONTHS_FR[rng[2]] : refDate.getUTCMonth(); const m2 = MONTHS_FR[rng[4]] != null ? MONTHS_FR[rng[4]] : m1; const out = []; let cur = new Date(Date.UTC(y, m1, +rng[1])); const end = new Date(Date.UTC(y, m2, +rng[3])); for (let g = 0; g < 400 && cur <= end; g++) { out.push(isoOf(cur)); cur = new Date(cur.getTime() + 86400000) } return { week: false, days: out } }
const nums = low.split(/\s*(?:,|\.|;|-|\bet\b|\s)+\s*/).filter(x => /^\d{1,2}$/.test(x)).map(Number)
if (nums.length) return { week: false, days: [...new Set(nums.map(d => resolveDay(d, refDate)).filter(Boolean))].sort() }
return { week: true, days: [] }
}
const weekDays = d => { const out = []; for (let i = 0; i < 7; i++) out.push(isoOf(new Date(d.getTime() + i * 86400000))); return out }
// jours ISO triés → plages contiguës [{from,to}]
function toRanges (isos) { const s = [...new Set(isos)].sort(); const out = []; for (const iso of s) { const last = out[out.length - 1]; const prev = last ? isoOf(new Date(Date.parse(last.to + 'T00:00:00Z') + 86400000)) : null; if (last && iso === prev) last.to = iso; else out.push({ from: iso, to: iso }) } return out }
// ── Extraction : cells → personnes [{ name, group, byStatus:{status:[iso]} }] ──
function extractPeople (cells) {
const cell = ref => cells[ref] || { v: '', status: null }
const weekCols = []
for (let n = colToNum('D'); n <= colToNum('AZ'); n++) { const c = cell(numToCol(n) + '5'); if (c.v && /^\d+(\.\d+)?$/.test(c.v)) weekCols.push({ col: numToCol(n), date: serialToDate(c.v) }) }
const people = []; let group = ''
for (let r = 6; r <= 80; r++) { const a = cell('A' + r).v; if (a) group = a; const name = cell('B' + r).v; if (!name || r < 7) continue
const byStatus = {}; const notes = []
for (const wc of weekCols) { const c = cell(wc.col + r); if (!c.status && !c.v) continue; const status = c.status || (c.v ? 'À classer' : null); if (!status) continue; const p = parseCell(c.v, wc.date); const days = p.week ? weekDays(wc.date) : p.days; if (!days.length) continue;(byStatus[status] || (byStatus[status] = [])).push(...days); if (c.v) notes.push(c.v) }
if (Object.keys(byStatus).length) people.push({ name, group, byStatus, notes })
}
return { people, weeks: weekCols.length, first: weekCols[0] && isoOf(weekCols[0].date), last: weekCols.length && isoOf(weekCols[weekCols.length - 1].date) }
}
// ── Matching global des noms → techs (2 passes : d'abord les certains, puis lever les ambiguïtés sur le reste) ──
const tokens = s => norm(s).split(/[\s\-]+/).filter(Boolean)
function matchAll (people, techs) {
const T = techs.map(t => ({ ...t, tk: tokens(t.name) })).filter(t => t.tk.length)
const taken = new Set(); const result = {}
const cands = p => { const [first, hint] = tokens(p.name); let c = T.filter(t => t.tk[0] === first || t.tk[0].startsWith(first) || first.startsWith(t.tk[0])); if (hint) { const d = c.filter(t => t.tk.slice(1).some(x => x.startsWith(hint))); if (d.length) c = d } return { first, hint, c } }
// passe 1 : match certain (1 seul candidat, OU indice a réduit à 1)
for (const p of people) { const { hint, c } = cands(p); if (c.length === 1) { result[p.name] = { tech: c[0], confidence: hint ? 'high' : 'medium' }; taken.add(c[0].id) } }
// passe 2 : ambigus → contre les techs NON réservés
for (const p of people) { if (result[p.name]) continue; const { hint, c } = cands(p); const free = c.filter(t => !taken.has(t.id)); if (free.length === 1) { result[p.name] = { tech: free[0], confidence: hint ? 'high' : 'medium' }; taken.add(free[0].id) } else if (free.length > 1) { free.sort((a, b) => a.tk.length - b.tk.length); result[p.name] = { tech: free[0], confidence: 'low', ambiguous: free.map(t => t.name) }; taken.add(free[0].id) } else result[p.name] = { tech: null, confidence: 'none' } }
return result
}
// ── Aperçu complet : buffer xlsx + techs → payload de prévisualisation ──
function buildPreview (buffer, techs, { sheetName } = {}) {
const files = unzip(buffer)
const shared = parseShared(files['xl/sharedStrings.xml'] && files['xl/sharedStrings.xml'].toString('utf8'))
const styles = parseStyles(files['xl/styles.xml'].toString('utf8'))
// choisir la feuille : par nom (workbook) sinon la 1re visible ; défaut sheet2 (« De mars à septembre 2026 »)
const wb = files['xl/workbook.xml'].toString('utf8')
const rels = files['xl/_rels/workbook.xml.rels'].toString('utf8')
const sheetsMeta = [...wb.matchAll(/<sheet[^>]*name="([^"]*)"[^>]*r:id="(rId\d+)"[^>]*\/>/g)].map(m => ({ name: decode(m[1]), rid: m[2] }))
const ridToFile = {}; for (const m of rels.matchAll(/Id="(rId\d+)"[^>]*Target="(worksheets\/sheet\d+\.xml)"/g)) ridToFile[m[1]] = 'xl/' + m[2]
let target = sheetsMeta.find(s => sheetName && s.name === sheetName) || sheetsMeta.find(s => /2026/.test(s.name) && /mars|sept/i.test(norm(s.name))) || sheetsMeta[0]
const cells = parseSheet(files[ridToFile[target.rid]].toString('utf8'), shared, styles)
const ex = extractPeople(cells)
const matches = matchAll(ex.people, techs)
const rows = ex.people.map(p => { const m = matches[p.name]; const byStatus = {}; for (const st in p.byStatus) byStatus[st] = toRanges(p.byStatus[st]); const totalDays = Object.values(p.byStatus).reduce((s, a) => s + new Set(a).size, 0); return { sheet_name: p.name, group: p.group, tech_id: m.tech ? m.tech.id : null, tech_name: m.tech ? m.tech.name : null, confidence: m.confidence, ambiguous: m.ambiguous || null, total_days: totalDays, ranges: byStatus, notes: p.notes.slice(0, 6) } })
return { sheet: target.name, sheets: sheetsMeta.map(s => s.name), weeks: ex.weeks, span: [ex.first, ex.last], count: rows.length, matched: rows.filter(r => r.tech_id).length, rows }
}
module.exports = { buildPreview, unzip, parseCell, matchAll, extractPeople, _internals: { parseShared, parseStyles, parseSheet, resolveDay, toRanges } }
// ── Test local : node lib/vacation-import.js <fichier.xlsx> ──
if (require.main === module) {
const fs = require('fs')
const path = process.argv[2]; if (!path) { console.error('usage: node vacation-import.js <fichier.xlsx>'); process.exit(1) }
// techs réels (fetch prod 2026-07-04) pour tester le matching
const TECHS = ['Anthony Dion', 'Antoine Wilson-Guay', 'Antoine-Marie Bourdon', 'Benjamin Djanpou', 'Carmen Ouellet', 'David Richer', 'Dominique Liaud', 'Fanny Laroche', 'Frederick Pruneau', 'Frédérique Soulard', 'Geneviève Bourdon', 'Gilles Drolet', 'Jean-Pierre Bourdon', 'Joseph Muzowindanga', 'Kadi Nongtodbo', 'Lion Arar', 'Louis Morneau', 'Louis-Paul Bourdon', 'Marc Leduc', 'Marc-André Henrico', 'Marcelle Sylviane Rasoarimalala', 'Matthieu Haineault', 'Maxim Murray Gendron', 'Michel Blais', 'Nathan Boucher', 'Nathan Morrisseau', 'Nicolas Drolet', 'Patrick Doucet', 'Patrick Moïse', 'Philip St-Amour', 'Philippe Bourdon', 'Pierre Brodeur', 'Rina Sylvia', 'Robinson Viaud', 'Simon Clot-Gagnon', 'Simon Goyette', 'Stéphane Beaudoin', 'Stéphane Moïse', 'Thaiz Menezes', 'Tommy Larouche Dionne', 'William Alexander Murray', 'Xavier Gilbert Blais'].map((n, i) => ({ id: 'TECH-' + i, name: n }))
const pv = buildPreview(fs.readFileSync(path), TECHS)
console.log(`Feuille « ${pv.sheet} » · ${pv.weeks} semaines (${pv.span[0]}${pv.span[1]}) · ${pv.count} personnes · ${pv.matched} appariées\n`)
for (const r of pv.rows) {
const tag = r.confidence === 'none' ? '❌ NON APPARIÉ' : (r.confidence === 'low' ? '⚠ ' + r.confidence : '✓ ' + r.confidence)
const rng = Object.entries(r.ranges).map(([st, rs]) => `${st}: ${rs.map(x => x.from === x.to ? x.from : x.from + '→' + x.to).join(', ')}`).join(' | ')
console.log(`${r.sheet_name.padEnd(14)}${(r.tech_name || '???').padEnd(26)} [${tag}] ${r.total_days}j ${rng}`)
if (r.ambiguous) console.log(` ambigu: ${r.ambiguous.join(', ')}`)
}
}