Checkpoint: Audit de robustesse et maintenabilité : calcul dynamique des tendances OPEX par véritable année N-1, filtrage/validation centralisés des codes établissement, transactions d'import et d'écriture, contraintes uniques CAPEX/OPEX avec déduplication, suppression effective de postes OPEX, nettoyage des gabarits et scripts morts, chargement différé XLSX, documentation métier et 22 tests passants.

This commit is contained in:
Manus
2026-08-17 20:50:33 +00:00
parent 31cdb257fb
commit 615dd46059
29 changed files with 1449 additions and 3307 deletions

View File

@@ -68,6 +68,9 @@ vi.mock("./db", async (importOriginal) => {
listUsers: vi.fn(),
listEtablissements: vi.fn(),
getParametres: vi.fn(),
upsertOpexBaseRepartition: vi.fn(),
setOpexMontantsEtabBatch: vi.fn(),
deleteOpexPoste: vi.fn(),
};
});
@@ -268,3 +271,74 @@ describe("parametres.get", () => {
expect(result).toEqual({ seuil_fixes_ans: "5", cout_fixe: "850" });
});
});
// ─── Tests robustesse OPEX ────────────────────────────────────────────────────
describe("opex.setBaseRepartition", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("normalise le code établissement avant de l'enregistrer", async () => {
const { db } = await import("./db").then(m => ({ db: m }));
const { ctx } = createAuthCtx({ role: "standard" });
const caller = appRouter.createCaller(ctx);
await caller.opex.setBaseRepartition({
annee: 2026,
etablissementCode: " 1001bpt ",
etablissementNom: "FAM Saint Joseph",
baseRepartition: 100,
baseRepartitionHep: 0,
modeManuel: false,
});
expect(db.upsertOpexBaseRepartition).toHaveBeenCalledWith(expect.objectContaining({
etablissementCode: "1001BPT",
}));
});
it("rejette les lignes de synthèse avant tout accès à la base", async () => {
const { db } = await import("./db").then(m => ({ db: m }));
const { ctx } = createAuthCtx({ role: "standard" });
const caller = appRouter.createCaller(ctx);
await expect(caller.opex.setBaseRepartition({
annee: 2026,
etablissementCode: "TOTAL",
baseRepartition: 1,
baseRepartitionHep: 0,
modeManuel: false,
})).rejects.toThrow("Code établissement OPEX invalide");
expect(db.upsertOpexBaseRepartition).not.toHaveBeenCalled();
});
it("interdit toute écriture au profil readonly", async () => {
const { ctx } = createAuthCtx({ role: "readonly" });
const caller = appRouter.createCaller(ctx);
await expect(caller.opex.setBaseRepartition({
annee: 2026,
etablissementCode: "1001BPT",
baseRepartition: 1,
baseRepartitionHep: 0,
modeManuel: false,
})).rejects.toThrow("Accès en lecture seule");
});
});
describe("opex.deletePoste", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("supprime un poste et renvoie le résultat de la transaction métier", async () => {
const { db } = await import("./db").then(m => ({ db: m }));
(db.deleteOpexPoste as ReturnType<typeof vi.fn>).mockResolvedValue(true);
const { ctx } = createAuthCtx({ role: "standard" });
const caller = appRouter.createCaller(ctx);
await expect(caller.opex.deletePoste({ annee: 2027, id: 42 })).resolves.toEqual({ success: true });
expect(db.deleteOpexPoste).toHaveBeenCalledWith(2027, 42);
});
});

View File

@@ -1,4 +1,4 @@
import { and, eq } from "drizzle-orm";
import { and, asc, eq } from "drizzle-orm";
import { drizzle } from "drizzle-orm/mysql2";
import {
capexLignes,
@@ -15,9 +15,9 @@ import {
opexPostes,
opexValidated,
parametresApp,
userEtablissements,
users,
} from "../drizzle/schema";
import { isOpexEtablissementCode } from "../shared/opexValidation";
let _db: ReturnType<typeof drizzle> | null = null;
@@ -97,7 +97,7 @@ export async function updateLastSignedIn(id: number) {
export async function listEtablissements() {
const db = await getDb();
if (!db) return [];
return db.select().from(etablissements);
return db.select().from(etablissements).orderBy(asc(etablissements.code));
}
export async function upsertEtablissement(etab: InsertEtablissement) {
@@ -151,7 +151,8 @@ export async function getOpexPostes(annee: number) {
return db
.select()
.from(opexPostes)
.where(eq(opexPostes.annee, annee));
.where(eq(opexPostes.annee, annee))
.orderBy(asc(opexPostes.colIdx));
}
export async function upsertOpexPoste(poste: InsertOpexPoste) {
@@ -161,7 +162,9 @@ export async function upsertOpexPoste(poste: InsertOpexPoste) {
await db
.update(opexPostes)
.set(poste)
.where(eq(opexPostes.id, poste.id));
// L'année fait partie de la clé fonctionnelle : elle empêche un client
// de modifier un poste d'un autre exercice avec un identifiant forgé.
.where(and(eq(opexPostes.id, poste.id), eq(opexPostes.annee, poste.annee)));
} else {
await db.insert(opexPostes).values(poste);
}
@@ -196,10 +199,29 @@ export async function insertOpexMontantsEtab(rows: InsertOpexMontantEtab[]) {
const db = await getDb();
if (!db) throw new Error("Database not available");
if (rows.length === 0) return;
// Insert par batch de 100
for (let i = 0; i < rows.length; i += 100) {
await db.insert(opexMontantsEtab).values(rows.slice(i, i + 100));
}
await db.transaction(async (tx) => {
// Insert par batch de 100. L'index métier garantit que la même cellule ne
// peut pas être dupliquée lors d'une reprise d'import.
for (let i = 0; i < rows.length; i += 100) {
await tx.insert(opexMontantsEtab).values(rows.slice(i, i + 100));
}
});
}
/** Sauvegarde atomiquement plusieurs valeurs manuelles d'un même établissement. */
export async function setOpexMontantsEtabBatch(rows: InsertOpexMontantEtab[]) {
const db = await getDb();
if (!db) throw new Error("Database not available");
if (rows.length === 0) return;
await db.transaction(async (tx) => {
for (const row of rows) {
await tx
.insert(opexMontantsEtab)
.values(row)
.onDuplicateKeyUpdate({ set: { montant: row.montant } });
}
});
}
export async function deleteOpexPostes(annee: number) {
@@ -208,6 +230,33 @@ export async function deleteOpexPostes(annee: number) {
await db.delete(opexPostes).where(eq(opexPostes.annee, annee));
}
/**
* Supprime un poste OPEX précis et ses surcharges manuelles associées.
* La sélection et les deux suppressions se font dans une transaction afin
* d'empêcher la persistance de montants orphelins.
*/
export async function deleteOpexPoste(annee: number, id: number): Promise<boolean> {
const db = await getDb();
if (!db) throw new Error("Database not available");
return db.transaction(async (tx) => {
const poste = await tx
.select({ libelle: opexPostes.libelle })
.from(opexPostes)
.where(and(eq(opexPostes.id, id), eq(opexPostes.annee, annee)))
.limit(1);
const libelle = poste[0]?.libelle;
if (!libelle) return false;
await tx
.delete(opexMontantsEtab)
.where(and(eq(opexMontantsEtab.annee, annee), eq(opexMontantsEtab.libellePoste, libelle)));
await tx.delete(opexPostes).where(and(eq(opexPostes.id, id), eq(opexPostes.annee, annee)));
return true;
});
}
export async function deleteOpexMontantsEtab(annee: number) {
const db = await getDb();
if (!db) return;
@@ -271,19 +320,20 @@ export async function importInventaire(
) {
const db = await getDb();
if (!db) throw new Error("Database not available");
// Supprimer l'inventaire existant pour cette année
await db.delete(inventairePostes).where(eq(inventairePostes.annee, annee));
// Insérer les nouveaux postes par batch
if (postes.length > 0) {
for (let i = 0; i < postes.length; i += 200) {
await db.insert(inventairePostes).values(postes.slice(i, i + 200));
await db.transaction(async (tx) => {
// L'import remplace l'inventaire annuel en une seule transaction : une
// erreur de lecture ou d'insertion ne laisse jamais l'année partiellement vide.
await tx.delete(inventairePostes).where(eq(inventairePostes.annee, annee));
if (postes.length > 0) {
for (let i = 0; i < postes.length; i += 200) {
await tx.insert(inventairePostes).values(postes.slice(i, i + 200));
}
}
}
// Upsert meta
await db
.insert(inventaireMeta)
.values({ annee, ...meta })
.onDuplicateKeyUpdate({ set: { ...meta, dateImport: new Date() } });
await tx
.insert(inventaireMeta)
.values({ annee, ...meta })
.onDuplicateKeyUpdate({ set: { ...meta, dateImport: new Date() } });
});
}
// ─────────────────────────────────────────────────────────────────────────────
@@ -311,12 +361,14 @@ export async function saveCapexLignes(
) {
const db = await getDb();
if (!db) throw new Error("Database not available");
for (const ligne of lignes) {
await db
.insert(capexLignes)
.values({ annee, etablissementCode, cle: ligne.cle, montant: ligne.montant })
.onDuplicateKeyUpdate({ set: { montant: ligne.montant } });
}
await db.transaction(async (tx) => {
for (const ligne of lignes) {
await tx
.insert(capexLignes)
.values({ annee, etablissementCode, cle: ligne.cle, montant: ligne.montant })
.onDuplicateKeyUpdate({ set: { montant: ligne.montant } });
}
});
}
export async function insertCapexLignes(rows: InsertCapexLigne[]) {
@@ -332,10 +384,15 @@ export async function insertCapexLignes(rows: InsertCapexLigne[]) {
export async function getOpexBasesRepartition(annee: number) {
const db = await getDb();
if (!db) throw new Error("Database not available");
return db
const rows = await db
.select()
.from(opexBasesRepartition)
.where(eq(opexBasesRepartition.annee, annee));
.where(eq(opexBasesRepartition.annee, annee))
.orderBy(asc(opexBasesRepartition.etablissementCode));
// Tolérance de lecture pour les imports historiques : les lignes non
// établissement restent conservées en BDD pour audit mais ne polluent pas la vue.
return rows.filter((row) => isOpexEtablissementCode(row.etablissementCode));
}
export async function upsertOpexBaseRepartition(input: {
@@ -375,17 +432,19 @@ export async function importOpexBasesRepartition(
) {
const db = await getDb();
if (!db) throw new Error("Database not available");
// Supprimer les données existantes pour l'année
await db.delete(opexBasesRepartition).where(eq(opexBasesRepartition.annee, annee));
if (rows.length === 0) return;
// Insérer en batch
await db.insert(opexBasesRepartition).values(
rows.map(r => ({
annee,
etablissementCode: r.etablissementCode,
etablissementNom: r.etablissementNom ?? null,
baseRepartition: String(r.baseRepartition),
baseRepartitionHep: String(r.baseRepartitionHep ?? 0),
}))
);
await db.transaction(async (tx) => {
// Remplacement atomique : l'ancienne base n'est supprimée que si la nouvelle
// série complète peut être enregistrée.
await tx.delete(opexBasesRepartition).where(eq(opexBasesRepartition.annee, annee));
if (rows.length === 0) return;
await tx.insert(opexBasesRepartition).values(
rows.map(r => ({
annee,
etablissementCode: r.etablissementCode,
etablissementNom: r.etablissementNom ?? null,
baseRepartition: String(r.baseRepartition),
baseRepartitionHep: String(r.baseRepartitionHep ?? 0),
}))
);
});
}

View File

@@ -1,33 +0,0 @@
import express from "express";
import { createServer } from "http";
import path from "path";
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
async function startServer() {
const app = express();
const server = createServer(app);
// Serve static files from dist/public in production
const staticPath =
process.env.NODE_ENV === "production"
? path.resolve(__dirname, "public")
: path.resolve(__dirname, "..", "dist", "public");
app.use(express.static(staticPath));
// Handle client-side routing - serve index.html for all routes
app.get("*", (_req, res) => {
res.sendFile(path.join(staticPath, "index.html"));
});
const port = process.env.PORT || 3000;
server.listen(port, () => {
console.log(`Server running on http://localhost:${port}/`);
});
}
startServer().catch(console.error);

View File

@@ -7,6 +7,7 @@ import { getSessionCookieOptions } from "./_core/cookies";
import { sdk } from "./_core/sdk";
import { systemRouter } from "./_core/systemRouter";
import { adminProcedure, protectedProcedure, publicProcedure, router } from "./_core/trpc";
import { isOpexEtablissementCode, normalizeOpexEtablissementCode } from "../shared/opexValidation";
const writeProcedure = protectedProcedure.use(({ ctx, next }) => {
if (ctx.user.role === "readonly") {
@@ -15,6 +16,12 @@ const writeProcedure = protectedProcedure.use(({ ctx, next }) => {
return next({ ctx });
});
/** Normalise et valide la clé métier d'une base de répartition OPEX. */
const opexEtablissementCodeSchema = z
.string()
.transform(normalizeOpexEtablissementCode)
.refine(isOpexEtablissementCode, { message: "Code établissement OPEX invalide" });
export const appRouter = router({
system: systemRouter,
@@ -132,6 +139,10 @@ export const appRouter = router({
.input(z.object({ id: z.number().optional(), annee: z.number(), colIdx: z.number(), libelle: z.string(), libelleCourt: z.string().optional().nullable(), libelleDetail: z.string().optional().nullable(), fournisseur: z.string().optional().nullable(), categorie: z.string().optional().nullable(), type: z.string().optional().nullable(), facturation: z.string().optional().nullable(), modeVentilation: z.string().optional().nullable(), compte: z.string().optional().nullable(), detail: z.string().optional().nullable(), budgetN1: z.string().optional().nullable(), montant: z.string().optional().nullable(), isCustom: z.boolean().optional() }))
.mutation(async ({ input }) => { await db.upsertOpexPoste(input as Parameters<typeof db.upsertOpexPoste>[0]); return { success: true }; }),
deletePoste: writeProcedure
.input(z.object({ annee: z.number(), id: z.number().int().positive() }))
.mutation(async ({ input }) => ({ success: await db.deleteOpexPoste(input.annee, input.id) })),
getMontantsEtab: protectedProcedure
.input(z.object({ annee: z.number() }))
.query(async ({ input }) => db.getOpexMontantsEtab(input.annee)),
@@ -151,14 +162,12 @@ export const appRouter = router({
}))
}))
.mutation(async ({ input }) => {
for (const m of input.montants) {
await db.setOpexMontantEtab({
await db.setOpexMontantsEtabBatch(input.montants.map((m) => ({
annee: input.annee,
etablissementCode: input.etablissementCode,
libellePoste: m.libellePoste,
montant: m.montant,
});
}
})));
return { success: true, count: input.montants.length };
}),
@@ -205,7 +214,7 @@ export const appRouter = router({
setBaseRepartition: writeProcedure
.input(z.object({
annee: z.number(),
etablissementCode: z.string(),
etablissementCode: opexEtablissementCodeSchema,
etablissementNom: z.string().optional().nullable(),
baseRepartition: z.number(),
baseRepartitionHep: z.number().optional().default(0),
@@ -218,7 +227,7 @@ export const appRouter = router({
.input(z.object({
annee: z.number(),
rows: z.array(z.object({
etablissementCode: z.string(),
etablissementCode: opexEtablissementCodeSchema,
etablissementNom: z.string().optional().nullable(),
baseRepartition: z.number(),
baseRepartitionHep: z.number().optional().default(0),