Checkpoint: Module Ventilation FreePro complet : import Excel FreePro, transformation automatique (script/TTC/type XOAUTH2), tableau de ventilation par structure+type, historique par mois, export PDF. Menu Ventilations ajouté dans la navigation.

This commit is contained in:
Manus
2026-06-05 08:02:29 -04:00
parent 4ef130f21d
commit 7f56fa7045
13 changed files with 3123 additions and 3 deletions

167
server/freeproService.ts Normal file
View File

@@ -0,0 +1,167 @@
/**
* FreePro Ventilation Service
* Transforms a FreePro Excel invoice file into a ventilation summary (structure × type → TTC)
*
* Transformation rules (from Process Scripting facture freepro.txt):
* 1. Colonne "script": =SI(OU(F2="Offre Freebox Pro";F2="Support Premium";ESTNUM(TROUVE("Remise première année";F2)));"Lien fibre";"Tél. mobile")
* 2. Colonne "ttc": =ARRONDI(total_ht * 1.2, 2)
* 3. Colonne "type": =SI(ESTNUM(TROUVE("Routeur 5G"; label_client)); "Lien 5G"; script)
* 4. TCD: agrégation par (structure, type) → somme des TTC
*/
import * as xlsx from "xlsx";
export interface FreeproSourceRow {
ref_piece: string;
date: Date | null;
reference_service: string;
label_client: string;
structure: string;
description: string;
debut_factu: Date | null;
fin_factu: Date | null;
quantite: number;
prix: number;
total: number;
}
export interface FreeproVentilationResult {
refPiece: string;
moisLabel: string;
annee: number;
mois: number;
nbLignes: number;
totalTtc: number;
lines: Array<{
structure: string | null;
type: string;
montant: number; // TTC en euros
}>;
}
/**
* Détermine la colonne "script" selon la description
*/
function getScript(description: string): string {
if (!description) return "Tél. mobile";
if (
description === "Offre Freebox Pro" ||
description === "Support Premium" ||
description.includes("Remise première année")
) {
return "Lien fibre";
}
return "Tél. mobile";
}
/**
* Calcule le TTC : arrondi(total_ht * 1.2, 2)
*/
function getTtc(totalHt: number): number {
return Math.round(totalHt * 1.2 * 100) / 100;
}
/**
* Détermine le "type" final selon label_client et script
*/
function getType(labelClient: string, script: string): string {
if (labelClient && labelClient.includes("Routeur 5G")) {
return "Lien 5G";
}
return script;
}
/**
* Parse et transforme un fichier Excel FreePro (Buffer)
* Retourne les données de ventilation agrégées
*/
export function processFreeproExcel(
buffer: Buffer,
moisLabel: string
): FreeproVentilationResult {
const wb = xlsx.read(buffer, { type: "buffer", cellDates: true });
const ws = wb.Sheets[wb.SheetNames[0]];
const rawRows = xlsx.utils.sheet_to_json<Record<string, unknown>>(ws, {
header: [
"ref_piece",
"date",
"reference_service",
"label_client",
"structure",
"description",
"debut_factu",
"fin_factu",
"quantite",
"prix",
"total",
],
range: 1, // skip header row
defval: null,
});
if (rawRows.length === 0) {
throw new Error("Le fichier Excel est vide ou ne contient pas de données.");
}
// Extraire ref_piece et date du mois depuis la première ligne
const firstRow = rawRows[0];
const refPiece = String(firstRow.ref_piece || "");
// Parser moisLabel "MM/YYYY"
const [moisStr, anneeStr] = moisLabel.split("/");
const mois = parseInt(moisStr, 10);
const annee = parseInt(anneeStr, 10);
// Agrégation par (structure, type) → somme TTC en centimes (pour éviter les flottants)
const aggregation = new Map<string, number>();
let nbLignes = 0;
for (const row of rawRows) {
if (!row.ref_piece) continue;
nbLignes++;
const description = String(row.description || "");
const labelClient = String(row.label_client || "");
const structure = row.structure ? String(row.structure) : null;
const totalHt = parseFloat(String(row.total || "0").replace(",", ".")) || 0;
const script = getScript(description);
const ttc = getTtc(totalHt);
const type = getType(labelClient, script);
const key = `${structure ?? ""}|||${type}`;
aggregation.set(key, (aggregation.get(key) || 0) + Math.round(ttc * 100));
}
// Construire les lignes de résultat
const lines: FreeproVentilationResult["lines"] = [];
for (const [key, centimes] of Array.from(aggregation.entries())) {
const [structureRaw, type] = key.split("|||");
lines.push({
structure: structureRaw === "" ? null : structureRaw,
type,
montant: centimes / 100,
});
}
// Trier par structure (null en dernier) puis type
lines.sort((a, b) => {
const sa = a.structure ?? "\uFFFF"; // null → fin de liste
const sb = b.structure ?? "\uFFFF";
if (sa !== sb) return sa.localeCompare(sb);
return a.type.localeCompare(b.type);
});
const totalTtc =
Math.round(lines.reduce((sum, l) => sum + l.montant, 0) * 100) / 100;
return {
refPiece,
moisLabel,
annee,
mois,
nbLignes,
totalTtc,
lines,
};
}