WP-08 : export Silae, format relevé sur un export réel du dossier
Le format n'est plus déduit d'une documentation. Il est vérifié octet par octet sur un export du dossier (juillet 2026, 55 lignes) : un contrôle d'aller-retour a reproduit ce fichier **sans aucune ligne divergente**. Ce que le fichier réel a corrigé dans la spécification - L'export est en **ASCII pur**, pas en UTF-8 : aucun accent, y compris dans les libellés (« Heures travaillees », « Date debut »). Le sérialiseur translittère, pour qu'un salarié nommé « Rémi » n'introduise pas le premier octet non-ASCII du fichier. - Fins de ligne **CRLF**, la dernière comprise ; ni guillemet ni point-virgule final ; décimale point. - Heures avec au moins une décimale et au plus deux — `96.0`, `52.5`, `69.67` ; jours en entier nu — `14`, `22`. L'arrondi se fait **par ligne**, au centième : recomposer un total depuis les lignes peut donc s'écarter de quelques centièmes. C'est le comportement de l'export existant, et le reproduire est délibéré. - Un salarié sous contrat sans aucun créneau planifié figure quand même, avec sa durée contractuelle entière en heures manquantes. Ce que le fichier n'a pas dit Les codes sont maintenant connus — `AB-100`, `AB-200`, `AB-300`, `AB-630`, `EV-HDimanche`, `EV-HFerie`, `HS-HS25` — mais **savoir qu'un code existe ne dit pas ce qu'il désigne**. `EV-HDimanche` se lit ; `AB-300` non. Les premiers sont proposés, les seconds restent vides, et rien n'est confirmé d'office : l'export refuse de tourner tant que le gestionnaire de paie n'a pas validé chaque correspondance. C'est le signal d'arrêt de PLAN.md §8.2, maintenu. Refus plutôt que fichier partiel Un CSV incomplet se charge sans erreur dans Silae et rend la paie fausse pour les salariés qui en sont absents — l'échec est silencieux jusqu'au bulletin. L'export liste donc les manques et ne produit rien. Données personnelles L'export de référence porte les heures et les absences de salariés identifiables. Il **n'est pas versionné**, ni comme fixture ni comme donnée de démonstration. Ce sont les règles de forme qui sont figées dans les tests, avec les valeurs observées mais sans les matricules ni les volumes réels. Le fichier produit revient dans la réponse de l'action plutôt que par une URL : un fichier de paie ne doit pas rester adressable, mis en cache ou présent dans un historique de navigation. En base, seule l'empreinte est conservée — elle suffit à prouver qu'un réexport est identique. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
This commit is contained in:
16 files changed
+2050
-15
No files matched your search
@@ -0,0 +1,64 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "SilaeMappingKind" AS ENUM ('SERVICE', 'OVERTIME', 'ABSENCE', 'VARIABLE');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SilaeCodeMapping" (
|
||||
"id" TEXT NOT NULL,
|
||||
"accountId" TEXT NOT NULL,
|
||||
"sourceKey" TEXT NOT NULL,
|
||||
"silaeCode" TEXT NOT NULL,
|
||||
"label" TEXT NOT NULL,
|
||||
"kind" "SilaeMappingKind" NOT NULL,
|
||||
"confirmed" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "SilaeCodeMapping_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "PayrollExport" (
|
||||
"id" TEXT NOT NULL,
|
||||
"accountId" TEXT NOT NULL,
|
||||
"locationId" TEXT,
|
||||
"periodStart" DATE NOT NULL,
|
||||
"periodEnd" DATE NOT NULL,
|
||||
"checksum" TEXT NOT NULL,
|
||||
"lineCount" INTEGER NOT NULL,
|
||||
"generatedBy" TEXT,
|
||||
"generatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "PayrollExport_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SilaeCodeMapping_accountId_idx" ON "SilaeCodeMapping"("accountId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "SilaeCodeMapping_accountId_sourceKey_key" ON "SilaeCodeMapping"("accountId", "sourceKey");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "PayrollExport_accountId_periodStart_idx" ON "PayrollExport"("accountId", "periodStart");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "PayrollExport_accountId_idx" ON "PayrollExport"("accountId");
|
||||
|
||||
-- Isolation : toute table portant accountId doit porter sa politique.
|
||||
ALTER TABLE "SilaeCodeMapping" ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "SilaeCodeMapping" FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY tenant_isolation ON "SilaeCodeMapping"
|
||||
USING ("accountId" = planflow_current_account());
|
||||
CREATE POLICY tenant_insert ON "SilaeCodeMapping"
|
||||
FOR INSERT WITH CHECK ("accountId" = planflow_current_account());
|
||||
|
||||
ALTER TABLE "PayrollExport" ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "PayrollExport" FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY tenant_isolation ON "PayrollExport"
|
||||
USING ("accountId" = planflow_current_account());
|
||||
CREATE POLICY tenant_insert ON "PayrollExport"
|
||||
FOR INSERT WITH CHECK ("accountId" = planflow_current_account());
|
||||
|
||||
-- Une trace d'export ne se réécrit pas : elle atteste de ce qui a été transmis
|
||||
-- au gestionnaire de paie, à une date donnée.
|
||||
CREATE TRIGGER payroll_export_append_only
|
||||
BEFORE UPDATE OR DELETE ON "PayrollExport"
|
||||
FOR EACH ROW EXECUTE FUNCTION planflow_deny_write();
|
||||
@@ -776,3 +776,61 @@ model AuthorisedSunday {
|
||||
@@unique([locationId, localDate])
|
||||
@@index([accountId])
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Export de paie — PLAN.md §8, WP-08
|
||||
// ============================================================================
|
||||
|
||||
/// Correspondance entre un élément calculé par PlanFlow et un code du dossier
|
||||
/// Silae.
|
||||
///
|
||||
/// En base et non dans le code : les codes appartiennent au dossier du client
|
||||
/// et se lisent dans « Saisie des éléments variables ». Deux clients du même
|
||||
/// cabinet n'ont pas nécessairement les mêmes.
|
||||
model SilaeCodeMapping {
|
||||
id String @id @default(cuid())
|
||||
accountId String
|
||||
/// Élément calculé — voir `PayrollElementKey`.
|
||||
sourceKey String
|
||||
silaeCode String
|
||||
label String
|
||||
kind SilaeMappingKind
|
||||
/// Faux tant que la correspondance n'a pas été confirmée par le gestionnaire
|
||||
/// de paie. Un export refuse de tourner sur une correspondance non confirmée.
|
||||
confirmed Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([accountId, sourceKey])
|
||||
@@index([accountId])
|
||||
}
|
||||
|
||||
enum SilaeMappingKind {
|
||||
/// Décompte de service, sans préfixe : jours travaillés, heures travaillées.
|
||||
SERVICE
|
||||
/// Heures supplémentaires, préfixe `HS-`.
|
||||
OVERTIME
|
||||
/// Absences, préfixe `AB-`.
|
||||
ABSENCE
|
||||
/// Éléments variables, préfixe `EV-`.
|
||||
VARIABLE
|
||||
}
|
||||
|
||||
/// Trace d'une génération d'export.
|
||||
///
|
||||
/// Le contenu n'est **pas** conservé : il porte les heures et les absences de
|
||||
/// salariés identifiables, et le regénérer est déterministe. L'empreinte suffit
|
||||
/// à prouver qu'un réexport est identique.
|
||||
model PayrollExport {
|
||||
id String @id @default(cuid())
|
||||
accountId String
|
||||
locationId String?
|
||||
periodStart DateTime @db.Date
|
||||
periodEnd DateTime @db.Date
|
||||
checksum String
|
||||
lineCount Int
|
||||
generatedBy String?
|
||||
generatedAt DateTime @default(now())
|
||||
|
||||
@@index([accountId, periodStart])
|
||||
@@index([accountId])
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
IDCC_1517_PARAMETERS,
|
||||
IDCC_1517_PROVENANCE,
|
||||
} from '../src/domain/compliance/idcc1517';
|
||||
import { PAYROLL_ELEMENT_DEFINITIONS } from '../src/domain/payroll/elements';
|
||||
import { evaluateSchedule } from '../src/server/compliance/evaluate';
|
||||
import { withTenant } from '../src/server/tenant';
|
||||
|
||||
@@ -565,6 +566,48 @@ async function main() {
|
||||
}
|
||||
console.log(` ${retention.length} politiques`);
|
||||
|
||||
|
||||
console.log('→ Correspondances Silae');
|
||||
// Semées **non confirmées**, y compris quand le code se lit dans son libellé
|
||||
// (`EV-HDimanche`). Proposer n'est pas confirmer : seul le gestionnaire de
|
||||
// paie sait si le code est le bon dans ce dossier, et l'export refuse de
|
||||
// tourner tant qu'il ne l'a pas dit.
|
||||
for (const definition of PAYROLL_ELEMENT_DEFINITIONS) {
|
||||
if (!definition.suggestedCode) continue;
|
||||
const existing = await prisma.silaeCodeMapping.findFirst({
|
||||
where: { accountId: account.id, sourceKey: definition.key },
|
||||
});
|
||||
if (existing) continue;
|
||||
|
||||
await prisma.silaeCodeMapping.create({
|
||||
data: {
|
||||
accountId: account.id,
|
||||
sourceKey: definition.key,
|
||||
silaeCode: definition.suggestedCode,
|
||||
label: definition.label,
|
||||
kind: definition.kind,
|
||||
confirmed: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
console.log(
|
||||
` ${PAYROLL_ELEMENT_DEFINITIONS.filter((d) => d.suggestedCode).length} proposées, aucune confirmée`,
|
||||
);
|
||||
|
||||
console.log('→ Matricules Silae');
|
||||
// Fictifs, à la forme observée dans le dossier : cinq chiffres cadrés à zéro.
|
||||
const withoutMatricule = await prisma.membership.findMany({
|
||||
where: { accountId: account.id, silaeMatricule: null },
|
||||
orderBy: { employeeNumber: 'asc' },
|
||||
});
|
||||
for (const [index, membership] of withoutMatricule.entries()) {
|
||||
await prisma.membership.update({
|
||||
where: { id: membership.id },
|
||||
data: { silaeMatricule: String(90_000 + index + 1).padStart(5, '0') },
|
||||
});
|
||||
}
|
||||
console.log(` ${withoutMatricule.length} matricules attribués`);
|
||||
|
||||
console.log('→ Évaluation de conformité');
|
||||
// Le seed produit des plannings, donc des constats : les laisser à calculer
|
||||
// au premier affichage donnerait une grille faussement conforme.
|
||||
|
||||
Reference in new issue
Block a user