Files
itinova-budget-si/server/payrollParser.ts

152 lines
5.8 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 = /(?<![\d.])-?\s*\d{1,3}(?:[ \u00a0]\d{3})*\.\d{2}(?!\d)/g;
const HEADER_RE = /##BULLETIN##\d{2}-\d{4}##(\d+)##([^#]+)##([^#]+)##/g;
function amountsIn(line: string): number[] {
return (line.match(MONEY_RE) ?? []).map((raw) => 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 dastreinte 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 na pu être indexé automatiquement. Le PDF reste conservé et consultable.",
};
}
if (bulletins.length !== candidates.length) {
return {
bulletins,
statutExtraction: "partiel",
erreurExtraction: "Une ou plusieurs fiches nont pas pu être indexées automatiquement. Le PDF reste consultable.",
};
}
return { bulletins, statutExtraction: "ready", erreurExtraction: null };
}