Some checks failed
Validation applicative / TypeScript, tests et build (push) Failing after 1m54s
152 lines
5.8 KiB
TypeScript
152 lines
5.8 KiB
TypeScript
/**
|
||
* 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 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 };
|
||
}
|