Checkpoint: Fenêtre administrateur Salaires finalisée : import multipart limité, chiffrement AES-256-GCM avant stockage persistant hors conteneur, métadonnées/index métier minimisés en base, consultation PDF déchiffrée côté serveur avec no-store, filtres année/mois et comparaison M-1 expliquée. Réindexation sûre des archives partielles sans écrasement du PDF. Validation réelle sur deux liasses mensuelles : index partiel signalé sans invention de données, comparaison affichée et lecture après redémarrage. 55 tests Vitest, TypeScript et build de production validés.
Some checks failed
Validation applicative / TypeScript, tests et build (push) Failing after 1m54s
Some checks failed
Validation applicative / TypeScript, tests et build (push) Failing after 1m54s
This commit is contained in:
151
server/payrollParser.ts
Normal file
151
server/payrollParser.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user