Checkpoint: Migration complète localStorage → BDD : useBudgetData, ParametresContext, Budget2027, DsiCapex, DsiOpex, ImportModal (indicateur inventaires + suppression double-écriture). Ajout procédures tRPC : inventaire.listMeta, inventaire.getStats, opex.initFromSource, opex.deleteAll, opex.cloneFromN1. 14 tests Vitest passants. 0 erreur TypeScript.

This commit is contained in:
Manus
2026-06-10 12:49:29 +00:00
parent caf95da1c1
commit 020156d62c
10 changed files with 1027 additions and 1088 deletions

View File

@@ -201,6 +201,18 @@ export async function insertOpexMontantsEtab(rows: InsertOpexMontantEtab[]) {
}
}
export async function deleteOpexPostes(annee: number) {
const db = await getDb();
if (!db) return;
await db.delete(opexPostes).where(eq(opexPostes.annee, annee));
}
export async function deleteOpexMontantsEtab(annee: number) {
const db = await getDb();
if (!db) return;
await db.delete(opexMontantsEtab).where(eq(opexMontantsEtab.annee, annee));
}
export async function getOpexValidated(annee: number) {
const db = await getDb();
if (!db) return null;
@@ -245,6 +257,12 @@ export async function getInventaireMeta(annee: number) {
return result.length > 0 ? result[0] : null;
}
export async function listInventaireMeta() {
const db = await getDb();
if (!db) return [];
return db.select().from(inventaireMeta).orderBy(inventaireMeta.annee);
}
export async function importInventaire(
annee: number,
postes: typeof inventairePostes.$inferInsert[],

View File

@@ -147,6 +147,41 @@ export const appRouter = router({
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 };
}),
// 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({
@@ -158,6 +193,90 @@ export const appRouter = router({
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 }) => {