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
This commit is contained in:
Claude committed 2026-08-08 08:31:01 +00:00
1 parent 747f0ef86f
commit 0213375a8f
18 files changed
+3160 -228

No files matched your search

+178
View File
@@ -374,6 +374,7 @@ model AbsenceType {
archivedAt DateTime?
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
timeOffs TimeOff[]
@@unique([accountId, code])
@@index([accountId])
@@ -834,3 +835,180 @@ model PayrollExport {
@@index([accountId, periodStart])
@@index([accountId])
}
// ============================================================================
// Absences et compteurs — PLAN.md §4.5 et §7, WP-06
// ============================================================================
/// Demande d'absence.
///
/// `endDate` porte le **dernier jour d'absence**, jamais la date de reprise.
/// La confusion est 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.
model TimeOff {
id String @id @default(cuid())
accountId String
membershipId String
userContractId String?
absenceTypeId String
startDate DateTime @db.Date
startHalfDay Boolean @default(false)
endDate DateTime @db.Date
endHalfDay Boolean @default(false)
/// Décompte figé à la décision : le calendrier de l'établissement peut
/// changer après coup, le droit acquis ne doit pas bouger avec lui.
countedDays Decimal? @db.Decimal(6, 2)
status TimeOffStatus @default(PENDING)
comment String?
requestedBy String
requestedAt DateTime @default(now())
decidedBy String?
decidedAt DateTime?
decisionComment String?
justificationDocumentId String?
absenceType AbsenceType @relation(fields: [absenceTypeId], references: [id])
@@index([accountId])
@@index([membershipId, startDate])
@@index([accountId, status])
}
enum TimeOffStatus {
PENDING
ACCEPTED
DECLINED
CANCELLED
EXPIRED
}
/// Compteur d'un salarié sur une période d'acquisition.
///
/// Ne porte **aucun solde** : 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.
model Counter {
id String @id @default(cuid())
accountId String
membershipId String
counterType CounterType
acquisitionPeriodStart DateTime @db.Date
acquisitionPeriodEnd DateTime @db.Date
operations LedgerOperation[]
@@unique([membershipId, counterType, acquisitionPeriodStart])
@@index([accountId])
}
enum CounterType {
PAID_LEAVE
RTT
COMPENSATORY_REST
MODULATION
OVERTIME
}
/// Écriture de compteur — **append-only**, imposé par un trigger.
///
/// Une correction s'écrit : contre-passation portant `reversesId`, puis
/// nouvelle écriture. Elle ne se réécrit pas.
model LedgerOperation {
id String @id @default(cuid())
accountId String
counterId String
kind LedgerKind
/// Signé : positif acquiert, négatif consomme.
quantity Decimal @db.Decimal(10, 4)
unit LedgerUnit
effectiveDate DateTime @db.Date
sourceType LedgerSource
sourceId String?
reason String?
reversesId String? @unique
createdBy String?
createdAt DateTime @default(now())
counter Counter @relation(fields: [counterId], references: [id], onDelete: Cascade)
@@index([accountId])
@@index([counterId, effectiveDate])
}
enum LedgerKind {
ACCRUAL
TAKEN
ADJUSTMENT
CARRY_OVER
EXPIRY
REGULARISATION
}
enum LedgerUnit {
DAY
HOUR
}
enum LedgerSource {
TIMEOFF
PAY_PERIOD
MANUAL
SYSTEM
}
model RttPolicy {
id String @id @default(cuid())
accountId String
name String
daysPerYear Decimal @db.Decimal(5, 2)
/// Début de période au format « MM-JJ ».
periodStart String
autoRenew Boolean @default(true)
status PolicyStatus @default(ACTIVE)
assignments RttPolicyAssignment[]
@@index([accountId])
}
enum PolicyStatus {
ACTIVE
ARCHIVED
}
model RttPolicyAssignment {
id String @id @default(cuid())
accountId String
rttPolicyId String
membershipId String
policy RttPolicy @relation(fields: [rttPolicyId], references: [id], onDelete: Cascade)
@@unique([rttPolicyId, membershipId])
@@index([accountId])
}
/// Information due au salarié à son retour d'arrêt — PLAN.md §12.3.
///
/// Obligation **active** et non un affichage : l'employeur doit informer des
/// droits acquis et de la date limite de prise dans le mois suivant la reprise.
/// La preuve de remise est ce qui rend l'obligation opposable.
model LeaveNotice {
id String @id @default(cuid())
accountId String
membershipId String
timeOffId String?
/// Date de reprise qui déclenche le délai d'un mois.
returnDate DateTime @db.Date
dueDate DateTime @db.Date
/// Contenu remis, figé : il fait foi.
content String
deliveredAt DateTime?
deliveryProof String?
createdAt DateTime @default(now())
@@index([accountId])
@@index([membershipId])
}