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

119 lines
5.1 KiB
TypeScript

import type { MasseSalarialeImportRemuneration, MasseSalarialeImportSalarie } from "./db";
import XLSX from "xlsx";
type SpreadsheetValue = string | number | Date | null | undefined;
function cents(value: SpreadsheetValue): number {
if (typeof value === "number") return Math.round(value * 100);
if (typeof value !== "string") return 0;
const normalized = value.replace(/[\s\u00a0]/g, "").replace(",", ".");
const parsed = Number(normalized);
return Number.isFinite(parsed) ? Math.round(parsed * 100) : 0;
}
function bps(value: SpreadsheetValue): number | null {
if (value === null || value === undefined || value === "") return null;
const numeric = typeof value === "number" ? value : Number(String(value).replace(",", "."));
return Number.isFinite(numeric) ? Math.round(numeric * 10_000) : null;
}
function isoDate(value: SpreadsheetValue): string | null {
if (value instanceof Date && !Number.isNaN(value.valueOf())) return value.toISOString().slice(0, 10);
if (typeof value === "number" && Number.isFinite(value)) {
const excelDate = new Date(Date.UTC(1899, 11, 30) + Math.round(value * 86_400_000));
return Number.isNaN(excelDate.valueOf()) ? null : excelDate.toISOString().slice(0, 10);
}
if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value)) return value;
if (typeof value === "string" && /^\d{2}\/\d{2}\/\d{4}$/.test(value)) {
const [day, month, year] = value.split("/");
return `${year}-${month}-${day}`;
}
return null;
}
function text(value: SpreadsheetValue): string {
return typeof value === "string" || typeof value === "number" ? String(value).trim() : "";
}
export type MasseSalarialeWorkbookImport = {
salaries: MasseSalarialeImportSalarie[];
remunerations: MasseSalarialeImportRemuneration[];
};
/**
* Convertit les lignes de la feuille modèle (index Excel 1, en-tête sur la
* ligne 5) sans évaluer de formules côté application : les valeurs calculées
* et sauvegardées par le fichier fourni servent de référence importée.
*/
export function parseMasseSalarialeWorkbookRows(rows: SpreadsheetValue[][]): MasseSalarialeWorkbookImport {
const salaries: MasseSalarialeImportSalarie[] = [];
const remunerations: MasseSalarialeImportRemuneration[] = [];
for (const row of rows.slice(5)) {
// SheetJS supprime la cellule A vide de certaines lignes. On repère donc
// la présence effective de cette cellule plutôt que de supposer un index.
const originalColumn = (excelColumn: number) => row[excelColumn + (row[0] === null || row[0] === undefined ? 0 : -1)];
const matricule = text(originalColumn(1));
const nom = text(originalColumn(2));
const prenom = text(originalColumn(3));
const poste = text(originalColumn(4));
const dateEmbauche = isoDate(originalColumn(5));
if (!matricule || !nom || !prenom || !poste || !dateEmbauche) continue;
salaries.push({ matricule, nom, prenom, poste, dateEmbauche });
const addForecast = (annee: number, startIndex: number, tauxChargeBps: number | null, periodeReference: string) => {
const brutHorsAstreinte = cents(originalColumn(startIndex));
const brutAvecPrimes = cents(originalColumn(startIndex + 1));
const primeAstreinte = cents(originalColumn(startIndex + 2));
const brutMensuel = cents(originalColumn(startIndex + 3));
const evolution = cents(originalColumn(startIndex + 5));
const prime = annee === 2027 ? cents(originalColumn(startIndex + 6)) : 0;
if (brutHorsAstreinte === 0 && brutAvecPrimes === 0 && brutMensuel === 0) return;
remunerations.push({
matricule,
annee,
nature: "previsionnel",
salaireAnnuelBrutHorsPrimesCents: brutHorsAstreinte,
salaireAnnuelBrutAvecPrimesCents: brutAvecPrimes,
salaireMensuelBrutHorsPrimesCents: brutMensuel,
tauxChargeBps,
primeAstreinteCents: primeAstreinte,
primeCents: prime,
evolutionCents: evolution,
primeReferenceCents: prime,
evolutionReferenceCents: evolution,
periodeReference,
statut: annee === 2025 ? "annuel" : "previsionnel",
source: "fichier_modele_2026-08-28",
});
};
addForecast(2025, 6, bps(originalColumn(10)), "2025-12");
addForecast(2026, 12, bps(originalColumn(16)), "2026-08");
addForecast(2027, 18, null, "2027-01");
}
return { salaries, remunerations };
}
/**
* Lit le fichier exclusivement côté serveur, afin que les données de masse
* salariale ne soient ni transformées ni renvoyées par le navigateur.
*/
export function parseMasseSalarialeWorkbookBuffer(buffer: Buffer): MasseSalarialeWorkbookImport {
const workbook = XLSX.read(buffer, { type: "buffer", cellDates: true });
const sheet = workbook.Sheets["Masse salariale"];
if (!sheet) throw new Error("Feuille Masse salariale introuvable");
const rows = XLSX.utils.sheet_to_json<SpreadsheetValue[]>(sheet, {
header: 1,
raw: true,
defval: null,
});
const parsed = parseMasseSalarialeWorkbookRows(rows);
if (parsed.salaries.length === 0 || parsed.remunerations.length === 0) {
throw new Error("Le fichier ne contient aucune prévision exploitable");
}
return parsed;
}