Files
itinova-budget-si/server/routers.ts

367 lines
18 KiB
TypeScript

import { COOKIE_NAME, ONE_YEAR_MS } from "@shared/const";
import { TRPCError } from "@trpc/server";
import bcrypt from "bcryptjs";
import { z } from "zod";
import * as db from "./db";
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") {
throw new TRPCError({ code: "FORBIDDEN", message: "Accès en lecture seule" });
}
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,
auth: router({
login: publicProcedure
.input(z.object({ login: z.string().min(1), password: z.string().min(1) }))
.mutation(async ({ input, ctx }) => {
const user = await db.getUserByLogin(input.login);
if (!user || !user.isActive) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Identifiants invalides" });
}
const valid = await bcrypt.compare(input.password, user.passwordHash);
if (!valid) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Identifiants invalides" });
}
await db.updateLastSignedIn(user.id);
const token = await sdk.createSessionToken(user.id, user.login, user.role);
const cookieOptions = getSessionCookieOptions(ctx.req);
ctx.res.cookie(COOKIE_NAME, token, { ...cookieOptions, maxAge: ONE_YEAR_MS });
return { id: user.id, login: user.login, email: user.email, firstName: user.firstName, lastName: user.lastName, role: user.role };
}),
me: publicProcedure.query((opts) => {
const u = opts.ctx.user;
if (!u) return null;
return { id: u.id, login: u.login, email: u.email, firstName: u.firstName, lastName: u.lastName, role: u.role, isActive: u.isActive };
}),
logout: publicProcedure.mutation(({ ctx }) => {
const cookieOptions = getSessionCookieOptions(ctx.req);
ctx.res.clearCookie(COOKIE_NAME, { ...cookieOptions, maxAge: -1 });
return { success: true } as const;
}),
}),
users: router({
list: adminProcedure.query(async () => {
const list = await db.listUsers();
return list.map((u) => ({ id: u.id, login: u.login, email: u.email, firstName: u.firstName, lastName: u.lastName, role: u.role, isActive: u.isActive, createdAt: u.createdAt, lastSignedIn: u.lastSignedIn }));
}),
create: adminProcedure
.input(z.object({ login: z.string().min(1), password: z.string().min(6), email: z.string().email().optional().nullable(), firstName: z.string().optional().nullable(), lastName: z.string().optional().nullable(), role: z.enum(["admin", "standard", "readonly"]).default("standard") }))
.mutation(async ({ input }) => {
const existing = await db.getUserByLogin(input.login);
if (existing) throw new TRPCError({ code: "CONFLICT", message: "Ce login existe déjà" });
const passwordHash = await bcrypt.hash(input.password, 10);
await db.createUser({ login: input.login, passwordHash, email: input.email ?? null, firstName: input.firstName ?? null, lastName: input.lastName ?? null, role: input.role, isActive: true });
return { success: true };
}),
update: adminProcedure
.input(z.object({ id: z.number(), email: z.string().email().optional().nullable(), firstName: z.string().optional().nullable(), lastName: z.string().optional().nullable(), role: z.enum(["admin", "standard", "readonly"]).optional(), isActive: z.boolean().optional(), password: z.string().min(6).optional() }))
.mutation(async ({ input }) => {
const { id, password, ...rest } = input;
const data: Record<string, unknown> = { ...rest };
if (password) data.passwordHash = await bcrypt.hash(password, 10);
await db.updateUser(id, data as Parameters<typeof db.updateUser>[1]);
return { success: true };
}),
delete: adminProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => { await db.deleteUser(input.id); return { success: true }; }),
importBulk: adminProcedure
.input(z.array(z.object({ login: z.string().min(1), password: z.string().min(6), email: z.string().email().optional().nullable(), firstName: z.string().optional().nullable(), lastName: z.string().optional().nullable(), role: z.enum(["admin", "standard", "readonly"]).default("standard") })))
.mutation(async ({ input }) => {
let created = 0; let skipped = 0;
for (const u of input) {
const existing = await db.getUserByLogin(u.login);
if (existing) { skipped++; continue; }
const passwordHash = await bcrypt.hash(u.password, 10);
await db.createUser({ login: u.login, passwordHash, email: u.email ?? null, firstName: u.firstName ?? null, lastName: u.lastName ?? null, role: u.role, isActive: true });
created++;
}
return { created, skipped };
}),
}),
etablissements: router({
list: protectedProcedure.query(async () => db.listEtablissements()),
upsert: adminProcedure
.input(z.object({ code: z.string().min(1), nom: z.string().min(1), groupe: z.string().optional().nullable(), ville: z.string().optional().nullable(), actif: z.boolean().default(true) }))
.mutation(async ({ input }) => { await db.upsertEtablissement(input); return { success: true }; }),
delete: adminProcedure
.input(z.object({ code: z.string() }))
.mutation(async ({ input }) => { await db.deleteEtablissement(input.code); return { success: true }; }),
}),
parametres: router({
get: protectedProcedure.query(async () => {
const rows = await db.getParametres();
return Object.fromEntries(rows.map((r) => [r.cle, r.valeur]));
}),
set: adminProcedure
.input(z.object({ cle: z.string(), valeur: z.string() }))
.mutation(async ({ input }) => { await db.setParametre(input.cle, input.valeur); return { success: true }; }),
setBulk: adminProcedure
.input(z.record(z.string(), z.string()))
.mutation(async ({ input }) => {
for (const [cle, valeur] of Object.entries(input)) await db.setParametre(cle, valeur);
return { success: true };
}),
}),
opex: router({
getPostes: protectedProcedure
.input(z.object({ annee: z.number() }))
.query(async ({ input }) => db.getOpexPostes(input.annee)),
upsertPoste: writeProcedure
.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)),
setMontantEtab: writeProcedure
.input(z.object({ annee: z.number(), etablissementCode: z.string(), libellePoste: z.string(), montant: z.string().nullable() }))
.mutation(async ({ input }) => { await db.setOpexMontantEtab(input as Parameters<typeof db.setOpexMontantEtab>[0]); return { success: true }; }),
// Sauvegarder en masse les montants par établissement (utilisé lors du passage en mode tout manuel)
batchSetMontantsEtab: writeProcedure
.input(z.object({
annee: z.number(),
etablissementCode: z.string(),
montants: z.array(z.object({
libellePoste: z.string(),
montant: z.string(),
}))
}))
.mutation(async ({ input }) => {
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 };
}),
getValidated: protectedProcedure
.input(z.object({ annee: z.number() }))
.query(async ({ input }) => db.getOpexValidated(input.annee)),
validate: adminProcedure
.input(z.object({ annee: z.number() }))
.mutation(async ({ input, ctx }) => { await db.setOpexValidated(input.annee, ctx.user.id); return { success: true }; }),
// Initialiser depuis les données sources JSON (seed initial)
initFromSource: writeProcedure
.input(z.object({
annee: z.number(),
postes: z.array(z.object({
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.insertOpexPostes(input.postes.map(p => ({ ...p, annee: input.annee })));
return { success: true };
}),
// Bases de répartition par établissement (charges classe 6)
getBasesRepartition: protectedProcedure
.input(z.object({ annee: z.number() }))
.query(async ({ input }) => db.getOpexBasesRepartition(input.annee)),
// Upsert une base de répartition pour un établissement (standard + HEP)
setBaseRepartition: writeProcedure
.input(z.object({
annee: z.number(),
etablissementCode: opexEtablissementCodeSchema,
etablissementNom: z.string().optional().nullable(),
baseRepartition: z.number(),
baseRepartitionHep: z.number().optional().default(0),
modeManuel: z.boolean().optional().default(false),
}))
.mutation(async ({ input }) => { await db.upsertOpexBaseRepartition(input); return { success: true }; }),
// Import en masse des bases de répartition (depuis fichier Excel/CSV)
importBasesRepartition: writeProcedure
.input(z.object({
annee: z.number(),
rows: z.array(z.object({
etablissementCode: opexEtablissementCodeSchema,
etablissementNom: z.string().optional().nullable(),
baseRepartition: z.number(),
baseRepartitionHep: z.number().optional().default(0),
})),
}))
.mutation(async ({ input }) => {
await db.importOpexBasesRepartition(input.annee, input.rows);
return { success: true, count: input.rows.length };
}),
// Supprimer toutes les lignes OPEX d'une année
deleteAll: writeProcedure
.input(z.object({ annee: z.number() }))
.mutation(async ({ input }) => {
await db.deleteOpexPostes(input.annee);
await db.deleteOpexMontantsEtab(input.annee);
return { success: true };
}),
}),
inventaire: router({
get: protectedProcedure
.input(z.object({ annee: z.number() }))
.query(async ({ input }) => {
const postes = await db.getInventaire(input.annee);
const meta = await db.getInventaireMeta(input.annee);
return { postes, meta };
}),
// Retourne les stats calculées par établissement (pour useBudgetData)
getStats: protectedProcedure
.input(z.object({
annee: z.number(),
seuilFixesAns: z.number().default(5),
seuilPortablesAns: z.number().default(4),
coutFixe: z.number().default(850),
coutPortable: z.number().default(950),
}))
.query(async ({ input }) => {
const postes = await db.getInventaire(input.annee);
const meta = await db.getInventaireMeta(input.annee);
if (!meta) return { meta: null, etablissements: [] };
// Charger les noms des établissements
const etabList = await db.listEtablissements();
const etabNoms = new Map(etabList.map(e => [e.code, e.nom]));
// Grouper par établissement
const byEtab = new Map<string, { fixes: typeof postes; portables: typeof postes }>();
for (const p of postes) {
if (!byEtab.has(p.etablissementCode)) byEtab.set(p.etablissementCode, { fixes: [], portables: [] });
const e = byEtab.get(p.etablissementCode)!;
if (p.typePoste === 'fixe') e.fixes.push(p);
else e.portables.push(p);
}
const etablissements = Array.from(byEtab.entries()).map(([code, { fixes, portables }]) => {
const nbFixesRenouveler = fixes.filter(f => f.ageAns !== null && parseFloat(f.ageAns) >= input.seuilFixesAns).length;
const nbPortablesRenouveler = portables.filter(p => p.ageAns !== null && parseFloat(p.ageAns) >= input.seuilPortablesAns).length;
const nbFixesSansDate = fixes.filter(f => f.ageAns === null).length;
const nbPortablesSansDate = portables.filter(p => p.ageAns === null).length;
const budgetFixes = nbFixesRenouveler * input.coutFixe;
const budgetPortables = nbPortablesRenouveler * input.coutPortable;
return {
code,
nom: etabNoms.get(code) ?? code,
fixes: fixes.map(f => ({ libelle: f.libelle ?? '', date_achat: f.dateRef ?? '', age_ans: f.ageAns !== null ? parseFloat(f.ageAns) : null, a_renouveler: f.ageAns !== null && parseFloat(f.ageAns) >= input.seuilFixesAns, modele: f.modele ?? '', fabricant: f.fabricant ?? '' })),
portables: portables.map(p => ({ libelle: p.libelle ?? '', date_achat: p.dateRef ?? '', age_ans: p.ageAns !== null ? parseFloat(p.ageAns) : null, a_renouveler: p.ageAns !== null && parseFloat(p.ageAns) >= input.seuilPortablesAns, modele: p.modele ?? '', fabricant: p.fabricant ?? '' })),
stats: {
nb_fixes_total: fixes.length,
nb_portables_total: portables.length,
nb_fixes_renouveler: nbFixesRenouveler,
nb_portables_renouveler: nbPortablesRenouveler,
nb_fixes_sans_date: nbFixesSansDate,
nb_portables_sans_date: nbPortablesSansDate,
budget_fixes: budgetFixes,
budget_portables: budgetPortables,
budget_total: budgetFixes + budgetPortables,
},
};
});
const totalFixesR = etablissements.reduce((s, e) => s + e.stats.nb_fixes_renouveler, 0);
const totalPortablesR = etablissements.reduce((s, e) => s + e.stats.nb_portables_renouveler, 0);
const totalFixes = etablissements.reduce((s, e) => s + e.stats.nb_fixes_total, 0);
const totalPortables = etablissements.reduce((s, e) => s + e.stats.nb_portables_total, 0);
const budgetGlobal = etablissements.reduce((s, e) => s + e.stats.budget_total, 0);
return {
meta: {
annee: input.annee,
dateImport: meta.dateImport,
nb_etablissements: etablissements.length,
total_fixes: totalFixes,
total_portables: totalPortables,
total_fixes_renouveler: totalFixesR,
total_portables_renouveler: totalPortablesR,
budget_global: budgetGlobal,
seuil_fixes_ans: input.seuilFixesAns,
seuil_portables_ans: input.seuilPortablesAns,
cout_fixe: input.coutFixe,
cout_portable: input.coutPortable,
},
etablissements,
};
}),
listMeta: protectedProcedure
.query(async () => {
const rows = await db.listInventaireMeta();
return rows;
}),
import: writeProcedure
.input(z.object({ annee: z.number(), filename: z.string(), postes: z.array(z.object({ etablissementCode: z.string(), libelle: z.string().optional().nullable(), typePoste: z.enum(["fixe", "portable"]), dateRef: z.string().optional().nullable(), ageAns: z.string().optional().nullable(), modele: z.string().optional().nullable(), fabricant: z.string().optional().nullable() })) }))
.mutation(async ({ input }) => {
const nbFixes = input.postes.filter((p) => p.typePoste === "fixe").length;
const nbPortables = input.postes.filter((p) => p.typePoste === "portable").length;
const etabSet = new Set(input.postes.map((p) => p.etablissementCode));
await db.importInventaire(input.annee, input.postes.map((p) => ({ ...p, annee: input.annee })), { filename: input.filename, nbEtablissements: etabSet.size, nbFixes, nbPortables });
return { success: true, nbFixes, nbPortables, nbEtablissements: etabSet.size };
}),
}),
capex: router({
get: protectedProcedure
.input(z.object({ annee: z.number(), etablissementCode: z.string() }))
.query(async ({ input }) => db.getCapexLignes(input.annee, input.etablissementCode)),
save: writeProcedure
.input(z.object({ annee: z.number(), etablissementCode: z.string(), lignes: z.array(z.object({ cle: z.string(), montant: z.string().nullable() })) }))
.mutation(async ({ input }) => { await db.saveCapexLignes(input.annee, input.etablissementCode, input.lignes); return { success: true }; }),
}),
});
export type AppRouter = typeof appRouter;