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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
143
server/db.ts
143
server/db.ts
@@ -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),
|
||||
}))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user