/** * Extraction volontairement minimisée des liasses de paie. Aucune coordonnée, * donnée bancaire, numéro de sécurité sociale ou rémunération nette n'est * renvoyé ni persisté par ce module. */ export type ParsedPayrollBulletin = { matricule: string; nom: string; prenom: string; poste: string; brutMensuelCents: number; brutAvecPrimesCents: number; primeAstreinteCents: number; explicationEcart: string | null; numeroPage: number; }; export type PayrollExtractionResult = { bulletins: ParsedPayrollBulletin[]; statutExtraction: "ready" | "partiel" | "a_controler"; erreurExtraction: string | null; }; const MONEY_RE = /(? Math.round(Number(raw.replace(/[ \u00a0]/g, "")) * 100)); } function firstMatchingLine(lines: string[], prefix: string): string | null { return lines.find((line) => line.trim().toLocaleLowerCase("fr-FR").startsWith(prefix)) ?? null; } function pagesForHeaders(text: string): Array<{ content: string; page: number }> { const pages = text.split("\f"); const grouped: Array<{ content: string; page: number; matricule: string }> = []; let current: { content: string; page: number; matricule: string } | null = null; pages.forEach((page, index) => { HEADER_RE.lastIndex = 0; const headers: Array<{ index: number; length: number; matricule: string }> = []; let header: RegExpExecArray | null; while ((header = HEADER_RE.exec(page)) !== null) { headers.push({ index: header.index, length: header[0].length, matricule: header[1] }); } if (headers.length === 0 && current) { current.content += `\n${page}`; return; } if (headers.length === 0) return; headers.forEach((entry, headerIndex) => { const next = headers[headerIndex + 1]; const content = page.slice(entry.index, next?.index); if (headerIndex === 0 && current?.matricule === entry.matricule) { current.content += `\n${content}`; } else { current = { content, page: index + 1, matricule: entry.matricule }; grouped.push(current); } }); }); // Certains PDF ne contiennent pas de saut de page exploitable. On tente alors // une extraction complète, qui sera marquée "à contrôler" sans en-tête fiable. return grouped.length > 0 ? grouped.map(({ content, page }) => ({ content, page })) : [{ content: text, page: 1 }]; } function getIdentity(text: string): { matricule: string; nom: string; prenom: string; poste: string } | null { HEADER_RE.lastIndex = 0; const header = HEADER_RE.exec(text); const lines = text.split(/\r?\n/); const matricule = /Matricule\s*:\s*(?:M|N|N°)?\s*(\d+)/.exec(text)?.[1] ?? header?.[1]; const emploiLine = lines.find((line) => /Emploi\s*:/i.test(line)); const poste = /Emploi\s*:\s*(.*)$/i.exec(emploiLine ?? "")?.[1] ?.replace(/\s+(?:Monsieur|Madame)\b.*$/i, "") .trim(); if (!matricule || !poste) return null; if (header?.[2] && header[3]) { return { matricule, nom: header[2].trim(), prenom: header[3].trim(), poste, }; } const displayName = /Emploi\s*:[\s\S]*?(?:Monsieur|Madame)\s+([A-ZÀ-ÖØ-Ý' -]+)/.exec(text)?.[1]?.trim(); const names = displayName?.split(/\s+/).filter(Boolean) ?? []; if (names.length < 2) return null; return { matricule, prenom: names[0], nom: names.slice(1).join(" "), poste }; } function parseOneBulletin(content: string, page: number): ParsedPayrollBulletin | null { const lines = content.split(/\r?\n/).map((line) => line.trimEnd()); const identity = getIdentity(content); const baseAmounts = amountsIn(firstMatchingLine(lines, "salaire de base") ?? ""); const grossAmounts = amountsIn(firstMatchingLine(lines, "salaire brut") ?? ""); if (!identity || baseAmounts.length === 0 || grossAmounts.length === 0) return null; const primeAstreinteCents = lines .filter((line) => line.trim().toLocaleLowerCase("fr-FR").startsWith("prime d'astreinte")) .map((line) => amountsIn(line).at(-1) ?? 0) .reduce((sum, amount) => sum + amount, 0); const brutMensuelCents = baseAmounts.at(-1)!; const brutAvecPrimesCents = grossAmounts.at(-1)!; const otherVariableCents = brutAvecPrimesCents - brutMensuelCents - primeAstreinteCents; const parts: string[] = []; if (primeAstreinteCents !== 0) parts.push("prime d’astreinte relevée"); if (otherVariableCents !== 0) parts.push("autres éléments de rémunération inclus dans le brut"); return { ...identity, brutMensuelCents, brutAvecPrimesCents, primeAstreinteCents, explicationEcart: parts.length ? parts.join(" ; ") : null, numeroPage: page, }; } /** * Transforme le texte d'une liasse PDF en index métier restreint. Toute absence * de champ indispensable est signalée sans empêcher la conservation du PDF. */ export function extractPayrollBulletins(text: string): PayrollExtractionResult { const candidates = pagesForHeaders(text); const bulletins = candidates .map(({ content, page }) => parseOneBulletin(content, page)) .filter((bulletin): bulletin is ParsedPayrollBulletin => bulletin !== null); if (bulletins.length === 0) { return { bulletins: [], statutExtraction: "a_controler", erreurExtraction: "Aucun bulletin n’a pu être indexé automatiquement. Le PDF reste conservé et consultable.", }; } if (bulletins.length !== candidates.length) { return { bulletins, statutExtraction: "partiel", erreurExtraction: "Une ou plusieurs fiches n’ont pas pu être indexées automatiquement. Le PDF reste consultable.", }; } return { bulletins, statutExtraction: "ready", erreurExtraction: null }; }