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:
67
server/db.ts
67
server/db.ts
@@ -944,3 +944,70 @@ export async function updateBapHistoryPdfUrl(id: number, pdfUrl: string): Promis
|
||||
if (!db) return;
|
||||
await db.update(bapHistory).set({ pdfUrl }).where(eq(bapHistory.id, id));
|
||||
}
|
||||
|
||||
// ============= FREEPRO VENTILATION OPERATIONS =============
|
||||
|
||||
import {
|
||||
freeproImports,
|
||||
InsertFreeproImport,
|
||||
FreeproImport,
|
||||
freeproVentilationLines,
|
||||
InsertFreeproVentilationLine,
|
||||
FreeproVentilationLine,
|
||||
} from "../drizzle/schema";
|
||||
|
||||
/** Crée un import FreePro et ses lignes de ventilation */
|
||||
export async function createFreeproImport(
|
||||
data: InsertFreeproImport,
|
||||
lines: Omit<InsertFreeproVentilationLine, "importId">[]
|
||||
): Promise<number> {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
const result = await db.insert(freeproImports).values(data);
|
||||
const importId = (result as unknown as { insertId: number }).insertId;
|
||||
if (lines.length > 0) {
|
||||
await db.insert(freeproVentilationLines).values(
|
||||
lines.map((l) => ({ ...l, importId }))
|
||||
);
|
||||
}
|
||||
return importId;
|
||||
}
|
||||
|
||||
/** Récupère tous les imports FreePro d'un utilisateur (sans les lignes) */
|
||||
export async function getFreeproImportsByUser(userId: number): Promise<FreeproImport[]> {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
return db
|
||||
.select()
|
||||
.from(freeproImports)
|
||||
.where(eq(freeproImports.userId, userId))
|
||||
.orderBy(desc(freeproImports.annee), desc(freeproImports.mois));
|
||||
}
|
||||
|
||||
/** Récupère un import FreePro avec ses lignes */
|
||||
export async function getFreeproImportWithLines(
|
||||
importId: number
|
||||
): Promise<{ import: FreeproImport; lines: FreeproVentilationLine[] } | null> {
|
||||
const db = await getDb();
|
||||
if (!db) return null;
|
||||
const imports = await db
|
||||
.select()
|
||||
.from(freeproImports)
|
||||
.where(eq(freeproImports.id, importId))
|
||||
.limit(1);
|
||||
if (!imports[0]) return null;
|
||||
const lines = await db
|
||||
.select()
|
||||
.from(freeproVentilationLines)
|
||||
.where(eq(freeproVentilationLines.importId, importId))
|
||||
.orderBy(freeproVentilationLines.structure, freeproVentilationLines.type);
|
||||
return { import: imports[0], lines };
|
||||
}
|
||||
|
||||
/** Supprime un import FreePro et ses lignes */
|
||||
export async function deleteFreeproImport(importId: number): Promise<void> {
|
||||
const db = await getDb();
|
||||
if (!db) return;
|
||||
await db.delete(freeproVentilationLines).where(eq(freeproVentilationLines.importId, importId));
|
||||
await db.delete(freeproImports).where(eq(freeproImports.id, importId));
|
||||
}
|
||||
|
||||
167
server/freeproService.ts
Normal file
167
server/freeproService.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -85,6 +85,13 @@ import { drawBapCartouche } from "./bapCartouche";
|
||||
import { startEmailImportService, stopEmailImportService, isEmailImportServiceRunning, triggerEmailCheck, testImapConnection } from "./emailImportService";
|
||||
import { startFolderImportService, stopFolderImportService, isFolderImportServiceRunning } from "./folderImportService";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { processFreeproExcel } from "./freeproService";
|
||||
import {
|
||||
createFreeproImport,
|
||||
getFreeproImportsByUser,
|
||||
getFreeproImportWithLines,
|
||||
deleteFreeproImport,
|
||||
} from "./db";
|
||||
|
||||
// Admin-only procedure
|
||||
const adminProcedure = protectedProcedure.use(({ ctx, next }) => {
|
||||
@@ -2197,5 +2204,70 @@ export const appRouter = router({
|
||||
return { success: true };
|
||||
}),
|
||||
}),
|
||||
|
||||
// ============= FREEPRO VENTILATION =============
|
||||
freepro: router({
|
||||
/** Importe un fichier Excel FreePro et calcule la ventilation */
|
||||
import: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
moisLabel: z.string().regex(/^\d{2}\/\d{4}$/, "Format MM/YYYY requis"),
|
||||
fileName: z.string(),
|
||||
fileBase64: z.string(), // fichier Excel encodé en base64
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const buffer = Buffer.from(input.fileBase64, "base64");
|
||||
const result = processFreeproExcel(buffer, input.moisLabel);
|
||||
|
||||
const importId = await createFreeproImport(
|
||||
{
|
||||
userId: ctx.user.id,
|
||||
moisLabel: result.moisLabel,
|
||||
annee: result.annee,
|
||||
mois: result.mois,
|
||||
refPiece: result.refPiece || null,
|
||||
fileName: input.fileName,
|
||||
nbLignes: result.nbLignes,
|
||||
totalTtc: result.totalTtc.toFixed(2),
|
||||
},
|
||||
result.lines.map((l) => ({
|
||||
structure: l.structure ?? null,
|
||||
type: l.type,
|
||||
montantCentimes: Math.round(l.montant * 100),
|
||||
}))
|
||||
);
|
||||
|
||||
return { importId, ...result };
|
||||
}),
|
||||
|
||||
/** Liste tous les imports FreePro de l'utilisateur */
|
||||
list: protectedProcedure.query(async ({ ctx }) => {
|
||||
return getFreeproImportsByUser(ctx.user.id);
|
||||
}),
|
||||
|
||||
/** Récupère un import FreePro avec ses lignes de ventilation */
|
||||
getById: protectedProcedure
|
||||
.input(z.object({ id: z.number() }))
|
||||
.query(async ({ input, ctx }) => {
|
||||
const data = await getFreeproImportWithLines(input.id);
|
||||
if (!data) throw new TRPCError({ code: "NOT_FOUND" });
|
||||
if (data.import.userId !== ctx.user.id)
|
||||
throw new TRPCError({ code: "FORBIDDEN" });
|
||||
return data;
|
||||
}),
|
||||
|
||||
/** Supprime un import FreePro */
|
||||
delete: protectedProcedure
|
||||
.input(z.object({ id: z.number() }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const data = await getFreeproImportWithLines(input.id);
|
||||
if (!data) throw new TRPCError({ code: "NOT_FOUND" });
|
||||
if (data.import.userId !== ctx.user.id)
|
||||
throw new TRPCError({ code: "FORBIDDEN" });
|
||||
await deleteFreeproImport(input.id);
|
||||
return { success: true };
|
||||
}),
|
||||
}),
|
||||
});
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
Reference in New Issue
Block a user