Checkpoint: Fenêtre administrateur Salaires finalisée : import multipart limité, chiffrement AES-256-GCM avant stockage persistant hors conteneur, métadonnées/index métier minimisés en base, consultation PDF déchiffrée côté serveur avec no-store, filtres année/mois et comparaison M-1 expliquée. Réindexation sûre des archives partielles sans écrasement du PDF. Validation réelle sur deux liasses mensuelles : index partiel signalé sans invention de données, comparaison affichée et lecture après redémarrage. 55 tests Vitest, TypeScript et build de production validés.
Some checks failed
Validation applicative / TypeScript, tests et build (push) Failing after 1m54s
Some checks failed
Validation applicative / TypeScript, tests et build (push) Failing after 1m54s
This commit is contained in:
152
server/db.ts
152
server/db.ts
@@ -1,4 +1,4 @@
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { and, asc, desc, eq } from "drizzle-orm";
|
||||
import { drizzle } from "drizzle-orm/mysql2";
|
||||
import {
|
||||
capexLignes,
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
opexPostes,
|
||||
opexValidated,
|
||||
parametresApp,
|
||||
salairesBulletins,
|
||||
salairesLiasses,
|
||||
users,
|
||||
} from "../drizzle/schema";
|
||||
import { isOpexEtablissementCode } from "../shared/opexValidation";
|
||||
@@ -559,3 +561,151 @@ export async function setMasseSalarialeEvolution(
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// SALAIRES MENSUELS — Métadonnées de liasses et index de bulletins chiffrés
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type SalaireBulletinImport = {
|
||||
matricule: string;
|
||||
nom: string;
|
||||
prenom: string;
|
||||
poste: string;
|
||||
brutMensuelCents: number;
|
||||
brutAvecPrimesCents: number;
|
||||
primeAstreinteCents: number;
|
||||
explicationEcart?: string | null;
|
||||
numeroPage?: number | null;
|
||||
};
|
||||
|
||||
export type SalaireLiasseCreateInput = {
|
||||
annee: number;
|
||||
mois: number;
|
||||
nomFichier: string;
|
||||
stockageKey: string;
|
||||
empreinteSha256: string;
|
||||
tailleOctets: number;
|
||||
ivBase64: string;
|
||||
authTagBase64: string;
|
||||
importePar: number;
|
||||
statutExtraction: "ready" | "partiel" | "a_controler";
|
||||
erreurExtraction?: string | null;
|
||||
bulletins: SalaireBulletinImport[];
|
||||
};
|
||||
|
||||
/** Retourne une liasse de période pour empêcher une importation mensuelle multiple. */
|
||||
export async function getSalaireLiasseByPeriod(annee: number, mois: number) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
const [liasse] = await db
|
||||
.select()
|
||||
.from(salairesLiasses)
|
||||
.where(and(eq(salairesLiasses.annee, annee), eq(salairesLiasses.mois, mois)));
|
||||
return liasse ?? null;
|
||||
}
|
||||
|
||||
/** Retourne une liasse par identifiant, sans jamais renvoyer de fichier déchiffré. */
|
||||
export async function getSalaireLiasseById(id: number) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
const [liasse] = await db.select().from(salairesLiasses).where(eq(salairesLiasses.id, id));
|
||||
return liasse ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persiste de façon atomique la référence chiffrée de la liasse et son index
|
||||
* métier. Les octets du PDF restent exclusivement dans le stockage persistant.
|
||||
*/
|
||||
export async function createSalaireLiasse(input: SalaireLiasseCreateInput) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
|
||||
return db.transaction(async (tx) => {
|
||||
const [existing] = await tx
|
||||
.select({ id: salairesLiasses.id })
|
||||
.from(salairesLiasses)
|
||||
.where(and(eq(salairesLiasses.annee, input.annee), eq(salairesLiasses.mois, input.mois)));
|
||||
if (existing) throw new Error("Une liasse est déjà enregistrée pour cette période");
|
||||
|
||||
await tx.insert(salairesLiasses).values({
|
||||
annee: input.annee,
|
||||
mois: input.mois,
|
||||
nomFichier: input.nomFichier,
|
||||
stockageKey: input.stockageKey,
|
||||
empreinteSha256: input.empreinteSha256,
|
||||
tailleOctets: input.tailleOctets,
|
||||
ivBase64: input.ivBase64,
|
||||
authTagBase64: input.authTagBase64,
|
||||
importePar: input.importePar,
|
||||
statutExtraction: input.statutExtraction,
|
||||
erreurExtraction: input.erreurExtraction ?? null,
|
||||
});
|
||||
|
||||
const [liasse] = await tx
|
||||
.select({ id: salairesLiasses.id })
|
||||
.from(salairesLiasses)
|
||||
.where(eq(salairesLiasses.stockageKey, input.stockageKey));
|
||||
if (!liasse) throw new Error("Liasse non retrouvée après enregistrement");
|
||||
|
||||
if (input.bulletins.length > 0) {
|
||||
await tx.insert(salairesBulletins).values(input.bulletins.map((bulletin) => ({
|
||||
...bulletin,
|
||||
liasseId: liasse.id,
|
||||
})));
|
||||
}
|
||||
return { liasseId: liasse.id, bulletins: input.bulletins.length };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour l'index métier d'une liasse déjà archivée sans modifier sa clé de
|
||||
* stockage, son empreinte ou ses paramètres de chiffrement.
|
||||
*/
|
||||
export async function replaceSalaireLiasseIndex(
|
||||
liasseId: number,
|
||||
input: Pick<SalaireLiasseCreateInput, "bulletins" | "statutExtraction" | "erreurExtraction">,
|
||||
) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
return db.transaction(async (tx) => {
|
||||
await tx.delete(salairesBulletins).where(eq(salairesBulletins.liasseId, liasseId));
|
||||
await tx.update(salairesLiasses).set({
|
||||
statutExtraction: input.statutExtraction,
|
||||
erreurExtraction: input.erreurExtraction ?? null,
|
||||
}).where(eq(salairesLiasses.id, liasseId));
|
||||
if (input.bulletins.length > 0) {
|
||||
await tx.insert(salairesBulletins).values(input.bulletins.map((bulletin) => ({ ...bulletin, liasseId })));
|
||||
}
|
||||
return { bulletins: input.bulletins.length };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste les liasses filtrées et les bulletins associés. La comparaison est
|
||||
* réalisée dans le routeur à partir de ces montants normalisés en centimes.
|
||||
*/
|
||||
export async function getSalairesMensuels(annee?: number, mois?: number) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
const filter = annee && mois
|
||||
? and(eq(salairesLiasses.annee, annee), eq(salairesLiasses.mois, mois))
|
||||
: annee
|
||||
? eq(salairesLiasses.annee, annee)
|
||||
: mois
|
||||
? eq(salairesLiasses.mois, mois)
|
||||
: undefined;
|
||||
|
||||
const liassesQuery = db.select().from(salairesLiasses);
|
||||
const liasses = filter
|
||||
? await liassesQuery.where(filter).orderBy(desc(salairesLiasses.annee), desc(salairesLiasses.mois))
|
||||
: await liassesQuery.orderBy(desc(salairesLiasses.annee), desc(salairesLiasses.mois));
|
||||
|
||||
const bulletinsQuery = db
|
||||
.select({ liasse: salairesLiasses, bulletin: salairesBulletins })
|
||||
.from(salairesBulletins)
|
||||
.innerJoin(salairesLiasses, eq(salairesBulletins.liasseId, salairesLiasses.id));
|
||||
const rows = filter
|
||||
? await bulletinsQuery.where(filter).orderBy(asc(salairesBulletins.nom), asc(salairesBulletins.prenom))
|
||||
: await bulletinsQuery.orderBy(desc(salairesLiasses.annee), desc(salairesLiasses.mois), asc(salairesBulletins.nom));
|
||||
return { liasses, rows };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user