WP-07 : heures prévu/réalisé/payé et périodes de paie verrouillables

Les trois grandeurs
Sans pointeuse, le réalisé est saisi par le manager. La matrice impose de
distinguer trois grandeurs et non deux, avec deux règles qui ne se négocient
pas :
- **Sans heures réelles, le prévu fait foi.** Attendre une saisie qui ne
  viendra pas ne produirait aucune paie. Une saisie partielle — un début sans
  fin — ne suffit pas : elle donnerait une durée fantaisiste.
- **Le paiement ne dépend jamais de la validation.** Une ligne non validée part
  en paie sur la base du réalisé. Bloquer le paiement d'heures accomplies faute
  de validation est précisément ce que la matrice interdit ; l'écran l'énonce
  pour qu'aucun manager ne croie l'inverse.

Toute correction d'heures déjà saisies exige un motif — sans lui, une
correction est indistinguable d'une erreur de saisie. Une première saisie n'en
demande pas : il n'y a rien à corriger, et exiger un motif à chaque ligne le
ferait remplir machinalement.

Le verrou de période
- Verrouiller fige les instantanés **et** ferme le mois : plus aucun créneau,
  aucune absence, aucune heure réelle ne peut être écrit sur ces dates. Le
  contrôle passe avant l'écriture, dans planning, absences et heures.
- Déverrouiller exige une justification. Rouvrir périme les fichiers déjà
  transmis au cabinet, et six mois plus tard personne ne saurait pourquoi.
- La **péremption d'un export est déduite** de `generatedAt < unlockedAt`,
  jamais stockée : la stocker obligerait à réécrire une trace qui doit rester
  append-only, et une trace réécrite ne prouve plus rien.
- Supprimer une période demande de **retaper son libellé** : un « êtes-vous
  sûr » se clique sans lire, et la suppression efface des instantanés sur
  lesquels un export a pu être bâti.

Un seul calcul pour trois écrans
Les instantanés viennent de `buildPayrollPeriod`, la même fonction que le
rapport de paie et l'export. C'est ce qui satisfait le critère croisé : trois
calculs séparés divergeraient, et l'écart ne se verrait qu'au bulletin. Un test
d'intégration compare instantané et rapport sur le même jeu de données.

Tests enfin idempotents
La suite e2e échouait au second passage : les absences acceptées d'une
exécution bloquaient les demandes de la suivante. Trois corrections, dans
l'ordre d'importance — chaque test travaille sur **son** salarié (le
chevauchement se juge par personne), chaque test libère ce qu'il a créé, et les
fenêtres de dates sont écartées d'une exécution à l'autre. Trois passages
consécutifs passent désormais.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
This commit is contained in:
Claude committed 2026-08-08 12:25:02 +00:00
1 parent 54a9f3ee2c
commit 2e9a4c5efe
20 files changed
+2837 -25

No files matched your search

@@ -0,0 +1,81 @@
-- CreateEnum
CREATE TYPE "PayPeriodKind" AS ENUM ('MAIN', 'ALTERNATIVE');
-- CreateEnum
CREATE TYPE "PayPeriodStatus" AS ENUM ('OPEN', 'LOCKED');
-- AlterTable
ALTER TABLE "PayrollExport" ADD COLUMN "payPeriodId" TEXT;
-- CreateTable
CREATE TABLE "PayPeriod" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"locationId" TEXT NOT NULL,
"label" TEXT NOT NULL,
"startDate" DATE NOT NULL,
"endDate" DATE NOT NULL,
"kind" "PayPeriodKind" NOT NULL DEFAULT 'MAIN',
"populations" "ContractType"[],
"status" "PayPeriodStatus" NOT NULL DEFAULT 'OPEN',
"lockedAt" TIMESTAMP(3),
"lockedBy" TEXT,
"unlockedAt" TIMESTAMP(3),
"unlockedBy" TEXT,
"version" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PayPeriod_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PayPeriodSnapshot" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"payPeriodId" TEXT NOT NULL,
"membershipId" TEXT NOT NULL,
"plannedMinutes" INTEGER NOT NULL,
"actualMinutes" INTEGER NOT NULL,
"absenceMinutes" INTEGER NOT NULL,
"workedDays" INTEGER NOT NULL,
"overtimeByBracket" JSONB NOT NULL,
"absenceBreakdown" JSONB NOT NULL,
"variables" JSONB NOT NULL,
"agreementId" TEXT,
"computedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PayPeriodSnapshot_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "PayPeriod_accountId_idx" ON "PayPeriod"("accountId");
-- CreateIndex
CREATE INDEX "PayPeriod_accountId_startDate_idx" ON "PayPeriod"("accountId", "startDate");
-- CreateIndex
CREATE UNIQUE INDEX "PayPeriod_locationId_startDate_endDate_kind_key" ON "PayPeriod"("locationId", "startDate", "endDate", "kind");
-- CreateIndex
CREATE INDEX "PayPeriodSnapshot_accountId_idx" ON "PayPeriodSnapshot"("accountId");
-- CreateIndex
CREATE UNIQUE INDEX "PayPeriodSnapshot_payPeriodId_membershipId_key" ON "PayPeriodSnapshot"("payPeriodId", "membershipId");
-- AddForeignKey
ALTER TABLE "PayPeriodSnapshot" ADD CONSTRAINT "PayPeriodSnapshot_payPeriodId_fkey" FOREIGN KEY ("payPeriodId") REFERENCES "PayPeriod"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Isolation : toute table portant accountId doit porter sa politique.
DO $$
DECLARE t text;
BEGIN
FOREACH t IN ARRAY ARRAY['PayPeriod','PayPeriodSnapshot']
LOOP
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', t);
EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY', t);
EXECUTE format(
'CREATE POLICY tenant_isolation ON %I USING ("accountId" = planflow_current_account())', t);
EXECUTE format(
'CREATE POLICY tenant_insert ON %I FOR INSERT WITH CHECK ("accountId" = planflow_current_account())', t);
END LOOP;
END $$;
+82
View File
@@ -825,6 +825,10 @@ model PayrollExport {
id String @id @default(cuid())
accountId String
locationId String?
/// Période couverte. La péremption se **déduit** de
/// `generatedAt < PayPeriod.unlockedAt` : la stocker obligerait à réécrire
/// une trace qui doit rester append-only.
payPeriodId String?
periodStart DateTime @db.Date
periodEnd DateTime @db.Date
checksum String
@@ -1012,3 +1016,81 @@ model LeaveNotice {
@@index([accountId])
@@index([membershipId])
}
// ============================================================================
// Périodes de paie — PLAN.md §4.6 et §7.3, WP-07
// ============================================================================
/// Période de paie d'un établissement.
///
/// Le **verrouillage** fige les instantanés et interdit toute mutation dont la
/// date tombe dans la période. Le **déverrouillage** existe et rouvre les
/// mutations : c'est une décision qui exige une justification, pas un bouton
/// anodin — tout export produit avant devient périmé.
model PayPeriod {
id String @id @default(cuid())
accountId String
locationId String
label String
startDate DateTime @db.Date
endDate DateTime @db.Date
kind PayPeriodKind @default(MAIN)
/// Types de contrat inclus. Vide = tous.
populations ContractType[]
status PayPeriodStatus @default(OPEN)
lockedAt DateTime?
lockedBy String?
/// Dernier déverrouillage : la péremption des exports s'en **déduit**, elle
/// n'est jamais stockée, pour que `PayrollExport` reste append-only.
unlockedAt DateTime?
unlockedBy String?
version Int @default(0)
createdAt DateTime @default(now())
snapshots PayPeriodSnapshot[]
@@unique([locationId, startDate, endDate, kind])
@@index([accountId])
@@index([accountId, startDate])
}
enum PayPeriodKind {
MAIN
ALTERNATIVE
}
enum PayPeriodStatus {
OPEN
LOCKED
}
/// Instantané figé au verrouillage.
///
/// Recalculé intégralement à chaque nouveau verrouillage : il n'est donc pas
/// immuable au sens strict. C'est le couple (instantané, `PayrollExport`) qui
/// porte la preuve — et `PayrollExport`, lui, est append-only.
model PayPeriodSnapshot {
id String @id @default(cuid())
accountId String
payPeriodId String
membershipId String
plannedMinutes Int
actualMinutes Int
absenceMinutes Int
workedDays Int
/// [{ ratePercent, minutes }]
overtimeByBracket Json
/// [{ absenceTypeId, code, days, minutes }]
absenceBreakdown Json
/// [{ key, value, unit }] — les éléments prêts pour l'export.
variables Json
/// Version de convention appliquée : sans elle, un instantané ancien devient
/// inexplicable dès que les paramètres changent.
agreementId String?
computedAt DateTime @default(now())
period PayPeriod @relation(fields: [payPeriodId], references: [id], onDelete: Cascade)
@@unique([payPeriodId, membershipId])
@@index([accountId])
}