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:
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 $$;
|
||||
@@ -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])
|
||||
}
|
||||
Reference in new issue
Block a user