175 lines
4.8 KiB
TypeScript
175 lines
4.8 KiB
TypeScript
/**
|
||
* 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,
|
||
fileName?: string
|
||
): FreeproVentilationResult {
|
||
// Détecter si c'est un CSV (par extension ou par détection du contenu)
|
||
const isCsv = fileName
|
||
? /\.csv$/i.test(fileName)
|
||
: !buffer[0] || (buffer[0] !== 0x50 && buffer[0] !== 0xD0); // PK = xlsx, D0CF = xls
|
||
const wb = isCsv
|
||
? xlsx.read(buffer.toString("utf8"), { type: "string", cellDates: true })
|
||
: 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,
|
||
};
|
||
}
|