Files
planflow/prisma/migrations/20260808081709_absences/migration.sql
T
Claude 0213375a8f WP-06 : absences, registre de compteurs, calendrier
Le dernier module de démonstration disparaît : `src/lib/demo/` ne contient plus
que l'aperçu.

Le décompte, là où l'on se trompe
- **`endDate` est le dernier jour d'absence, pas la date de reprise.** C'est la
  confusion la plus fréquente du domaine : elle décompte un jour de trop ou de
  trop peu à chaque demande, et le salarié s'en aperçoit au solde, des mois plus
  tard. Le champ du formulaire s'appelle « Dernier jour d'absence », et un test
  couvre explicitement la confusion.
- **Un jour férié dans un congé ne se décompte pas**, sans que l'utilisateur ait
  à scinder sa demande. Exiger la scission, c'est lui déplacer la charge d'un
  calcul que l'outil sait faire.
- Le rythme du contrat est respecté : un temps partiel qui ne travaille jamais
  le mercredi ne consomme pas de congé ce jour-là.
- Ouvrables ou ouvrés est un **paramètre**, pas une constante : se tromper
  fausse tous les soldes de la même façon.

Jours fériés calculés, pas listés
Une table écrite à la main est juste l'année où on l'écrit et fausse dès la
suivante — et un férié manquant se décompte comme un jour de congé, sans que
personne ne le voie. Les onze jours légaux sont donc calculés, Pâques comprise
(algorithme grégorien anonyme, vérifié sur quatre années de référence). Quand
aucun férié n'est enregistré pour l'année demandée, la demande passe mais
l'écran le dit : mieux vaut l'annoncer que laisser croire à un décompte complet.

Le registre est la source de vérité
- Aucun solde n'est stocké : le solde est la **somme** des écritures. Un solde
  stocké se désynchronise, et la désynchronisation ne se voit qu'au moment où un
  salarié conteste.
- `UPDATE` et `DELETE` sont refusés par un trigger PostgreSQL, pas seulement par
  l'application : une règle applicative finit par être contournée par un script
  de reprise. Les tests d'intégration écrivent directement en base pour le
  prouver.
- Annuler une absence acceptée **contre-passe** la prise au lieu de l'effacer,
  à la date de la correction — antidater masquerait la correction dans les
  soldes déjà communiqués. `reversesId` est unique : contre-passer deux fois
  ferait repartir le solde dans l'autre sens.
- Un ajustement manuel sans justification est refusé.

Confidentialité du motif médical
Un manager voit qu'un salarié est absent, pas de quoi il souffre (matrice n° 9).
Le motif d'un arrêt n'est **pas chargé** pour qui n'a pas la capacité de le
lire — un champ absent de la réponse ne peut fuiter ni par le HTML ni par un
journal. Sur la grille de planning, l'absence s'affiche « Absence » : c'est
suffisant pour ne pas planifier quelqu'un.

Acquisition
2,5 jours ouvrables par mois travaillé ; 2 jours par mois d'arrêt maladie non
professionnelle, plafonnés à 24 par an — droit issu de la réforme de 2024, dont
l'oubli prive le salarié d'un droit acquis.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
2026-08-08 08:31:01 +00:00

183 lines
6.0 KiB
SQL

-- CreateEnum
CREATE TYPE "TimeOffStatus" AS ENUM ('PENDING', 'ACCEPTED', 'DECLINED', 'CANCELLED', 'EXPIRED');
-- CreateEnum
CREATE TYPE "CounterType" AS ENUM ('PAID_LEAVE', 'RTT', 'COMPENSATORY_REST', 'MODULATION', 'OVERTIME');
-- CreateEnum
CREATE TYPE "LedgerKind" AS ENUM ('ACCRUAL', 'TAKEN', 'ADJUSTMENT', 'CARRY_OVER', 'EXPIRY', 'REGULARISATION');
-- CreateEnum
CREATE TYPE "LedgerUnit" AS ENUM ('DAY', 'HOUR');
-- CreateEnum
CREATE TYPE "LedgerSource" AS ENUM ('TIMEOFF', 'PAY_PERIOD', 'MANUAL', 'SYSTEM');
-- CreateEnum
CREATE TYPE "PolicyStatus" AS ENUM ('ACTIVE', 'ARCHIVED');
-- CreateTable
CREATE TABLE "TimeOff" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"membershipId" TEXT NOT NULL,
"userContractId" TEXT,
"absenceTypeId" TEXT NOT NULL,
"startDate" DATE NOT NULL,
"startHalfDay" BOOLEAN NOT NULL DEFAULT false,
"endDate" DATE NOT NULL,
"endHalfDay" BOOLEAN NOT NULL DEFAULT false,
"countedDays" DECIMAL(6,2),
"status" "TimeOffStatus" NOT NULL DEFAULT 'PENDING',
"comment" TEXT,
"requestedBy" TEXT NOT NULL,
"requestedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"decidedBy" TEXT,
"decidedAt" TIMESTAMP(3),
"decisionComment" TEXT,
"justificationDocumentId" TEXT,
CONSTRAINT "TimeOff_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Counter" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"membershipId" TEXT NOT NULL,
"counterType" "CounterType" NOT NULL,
"acquisitionPeriodStart" DATE NOT NULL,
"acquisitionPeriodEnd" DATE NOT NULL,
CONSTRAINT "Counter_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "LedgerOperation" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"counterId" TEXT NOT NULL,
"kind" "LedgerKind" NOT NULL,
"quantity" DECIMAL(10,4) NOT NULL,
"unit" "LedgerUnit" NOT NULL,
"effectiveDate" DATE NOT NULL,
"sourceType" "LedgerSource" NOT NULL,
"sourceId" TEXT,
"reason" TEXT,
"reversesId" TEXT,
"createdBy" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "LedgerOperation_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RttPolicy" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"daysPerYear" DECIMAL(5,2) NOT NULL,
"periodStart" TEXT NOT NULL,
"autoRenew" BOOLEAN NOT NULL DEFAULT true,
"status" "PolicyStatus" NOT NULL DEFAULT 'ACTIVE',
CONSTRAINT "RttPolicy_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RttPolicyAssignment" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"rttPolicyId" TEXT NOT NULL,
"membershipId" TEXT NOT NULL,
CONSTRAINT "RttPolicyAssignment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "LeaveNotice" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"membershipId" TEXT NOT NULL,
"timeOffId" TEXT,
"returnDate" DATE NOT NULL,
"dueDate" DATE NOT NULL,
"content" TEXT NOT NULL,
"deliveredAt" TIMESTAMP(3),
"deliveryProof" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "LeaveNotice_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "TimeOff_accountId_idx" ON "TimeOff"("accountId");
-- CreateIndex
CREATE INDEX "TimeOff_membershipId_startDate_idx" ON "TimeOff"("membershipId", "startDate");
-- CreateIndex
CREATE INDEX "TimeOff_accountId_status_idx" ON "TimeOff"("accountId", "status");
-- CreateIndex
CREATE INDEX "Counter_accountId_idx" ON "Counter"("accountId");
-- CreateIndex
CREATE UNIQUE INDEX "Counter_membershipId_counterType_acquisitionPeriodStart_key" ON "Counter"("membershipId", "counterType", "acquisitionPeriodStart");
-- CreateIndex
CREATE UNIQUE INDEX "LedgerOperation_reversesId_key" ON "LedgerOperation"("reversesId");
-- CreateIndex
CREATE INDEX "LedgerOperation_accountId_idx" ON "LedgerOperation"("accountId");
-- CreateIndex
CREATE INDEX "LedgerOperation_counterId_effectiveDate_idx" ON "LedgerOperation"("counterId", "effectiveDate");
-- CreateIndex
CREATE INDEX "RttPolicy_accountId_idx" ON "RttPolicy"("accountId");
-- CreateIndex
CREATE INDEX "RttPolicyAssignment_accountId_idx" ON "RttPolicyAssignment"("accountId");
-- CreateIndex
CREATE UNIQUE INDEX "RttPolicyAssignment_rttPolicyId_membershipId_key" ON "RttPolicyAssignment"("rttPolicyId", "membershipId");
-- CreateIndex
CREATE INDEX "LeaveNotice_accountId_idx" ON "LeaveNotice"("accountId");
-- CreateIndex
CREATE INDEX "LeaveNotice_membershipId_idx" ON "LeaveNotice"("membershipId");
-- AddForeignKey
ALTER TABLE "TimeOff" ADD CONSTRAINT "TimeOff_absenceTypeId_fkey" FOREIGN KEY ("absenceTypeId") REFERENCES "AbsenceType"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LedgerOperation" ADD CONSTRAINT "LedgerOperation_counterId_fkey" FOREIGN KEY ("counterId") REFERENCES "Counter"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RttPolicyAssignment" ADD CONSTRAINT "RttPolicyAssignment_rttPolicyId_fkey" FOREIGN KEY ("rttPolicyId") REFERENCES "RttPolicy"("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['TimeOff','Counter','LedgerOperation','RttPolicy','RttPolicyAssignment','LeaveNotice']
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 $$;
-- Le registre est append-only : une correction s'écrit — contre-passation puis
-- nouvelle écriture — elle ne se réécrit pas. La règle est en base et non
-- seulement dans l'application, parce qu'une règle applicative finit par être
-- contournée par un script de reprise.
CREATE TRIGGER ledger_operation_append_only
BEFORE UPDATE OR DELETE ON "LedgerOperation"
FOR EACH ROW EXECUTE FUNCTION planflow_deny_write();