Merge pull request #1 from R0m1k3/claude/combohr-app-recreation-plan-yu7wre

Claude/combohr app recreation plan yu7wre
This commit is contained in:
LogiFlow authored and GitHub committed 2026-08-07 16:39:22 +02:00
commit 1efe8d80c9
1 file changed
+859
+859
View File
@@ -0,0 +1,859 @@
# PlanFlow — spécification de construction
> **Destinataire : un orchestrateur automatisé.** Ce document est la seule source d'instructions nécessaire pour construire l'application. Il est normatif : ce qui y est écrit fait foi, ce qui n'y est pas doit être demandé, jamais inventé.
>
> **Source fonctionnelle :** [`Audit Combo/`](Audit%20Combo/INDEX.md) — 58 écrans, cartographie fonctionnelle et inventaire de crawl du 7 août 2026.
>
> **Régime : clean room.** L'audit et ce plan décrivent des *capacités* et des *invariants*. Ne recopier ni code, ni CSS, ni icônes, ni illustrations, ni wording propriétaire au-delà des libellés métier nécessaires. Concevoir une API, un schéma, des textes et une interface originaux.
---
## 0. Mode d'emploi pour l'orchestrateur
1. Lire les sections 1 à 9 en entier avant d'écrire la moindre ligne. Elles définissent des invariants transverses ; les découvrir au lot 3 impose de refaire les lots 0 à 2.
2. Exécuter les lots de travail (§10) **dans l'ordre**. Chaque lot déclare ses dépendances, ses livrables et ses **critères d'acceptation**. Un lot n'est terminé que si tous ses critères passent en test automatisé.
3. **Signaux d'arrêt.** Interrompre et demander un arbitrage humain dans ces cas :
- une valeur numérique de convention collective est nécessaire (§6) ;
- un code de rubrique Silae est nécessaire (§8) ;
- une donnée personnelle réelle serait requise pour tester ;
- un point marqué **`À VALIDER`** dans ce document bloque l'avancement.
4. Ne jamais inventer de paramètre légal, de code de paie ou de règle métier. L'absence d'information est un signal d'arrêt, pas une invitation à choisir.
5. Langue : **interface et libellés en français**, **identifiants de code en anglais**, commentaires en anglais.
---
## 1. Contexte
Le dépôt ne contient que l'audit ; il n'y a aucun code. L'objectif est de construire **PlanFlow**, une application de gestion du personnel et des plannings multi-établissements, reprenant les capacités de Combo pour l'organisation auditée, avec la paie **exportée vers Silae**.
L'audit corrige trois hypothèses qu'une lecture de la documentation publique de Combo aurait induites :
**La convention collective n'est pas HCR.** Le compte audité est **FROUARD DISTRIBUTION / La Foir'Fouille**, configuré sur **« Commerces de détail non alimentaires (IDCC 1517) — JF 50 % et Dimanche 100 % »**. C'est du commerce de détail. Les durées maximales, coupures et majorations propres à l'hôtellerie-restauration ne s'appliquent pas.
**L'autorisation est par capacités, pas par rôles.** L'audit relève des permissions granulaires et des rôles **configurables par le client** (`/settings/roles-permissions/:roleKey`). `Role`, `Permission` et `Scope` sont trois notions distinctes dès le lot 0. Aucun écran ne teste un nom de rôle.
**Les compteurs de congés sont un registre d'écritures.** L'audit identifie `Counter` / `LedgerOperation` avec ajustements protégés et prévision. Un solde stocké serait un contresens : le solde est le cumul des écritures.
### Écart de documentation — `À VALIDER`
`Audit Combo/INDEX.md` référence `../../matrice-conformite-rh-france-2026.md`, absent du dépôt. Ce document conditionne §12. Le demander avant le lot 2.
---
## 2. Périmètre — décisions arrêtées
| Sujet | Décision |
|---|---|
| **Établissements** | **Multi-établissements** avec équipes. Un salarié peut être rattaché à plusieurs établissements. Reporting consolidé et par établissement. |
| **Paie** | **Export vers Silae** (§8). Aucun moteur de paie, aucun bulletin, aucune DSN dans PlanFlow. |
| **Pointeuse** | **Hors périmètre.** Pas de borne, pas de PWA kiosque, pas de pointage matériel. Les heures réelles sont saisies et validées par le manager (§7.3). |
| **Convention d'amorce** | **IDCC 1517**, moteur paramétrable pour en ajouter d'autres. |
| **Stack** | Next.js 15 (App Router, TypeScript strict) · PostgreSQL 16 + Prisma · Auth.js v5 · Tailwind + shadcn/ui · Zod · Vitest + Playwright · pnpm · `docker-compose` auto-hébergé. |
| **Mobile** | PWA installable, responsive. Pas d'application native. |
**Hors périmètre v1**, à ne pas construire : moteur de paie, DSN, bulletins de paie, distribution de bulletins, signature électronique qualifiée, transmission DPAE à l'URSSAF, connecteurs de caisse, abonnement et facturation, planning prédictif, auto-assignation.
**Conservés mais différés au lot 5** : articles, conversations, analyses RH avancées.
---
## 3. Architecture et invariants transverses
Ces sept invariants s'appliquent à tout le code. Ils ne sont pas négociables et chacun fait l'objet de tests dédiés.
### 3.1 Isolation multi-tenant
Hiérarchie `Account` → `Location` → `Team`. Base unique, **scoping par ligne**.
- Une extension Prisma injecte `accountId` et le périmètre de la session sur **chaque** requête.
- **RLS PostgreSQL** activée sur toutes les tables portant `accountId`, en défense en profondeur.
- Le périmètre ne provient **jamais** d'un paramètre client. Il est dérivé de la session serveur.
### 3.2 Autorisation
Point d'entrée unique `can(membership, permissionCode, resource?)` dans `src/domain/access/`.
- Vérification **à chaque mutation**, côté serveur, avant tout effet.
- L'affichage conditionnel est un confort, jamais une sécurité.
- Toute Server Action commence par un `can(...)` ; une action sans contrôle est un défaut bloquant.
### 3.3 Temps
- Chaque `Location` porte un fuseau IANA (défaut `Europe/Paris`).
- Instants stockés en `timestamptz`. Une colonne `localDate` dénormalisée sert **uniquement** au regroupement dans la grille.
- **Toute durée se calcule depuis les instants.** Un shift 22 h–06 h la nuit du changement d'heure dure 7 h ou 9 h, jamais 8 h.
- Les nuits traversantes appartiennent à la `localDate` de leur **début**.
- Tout module de date passe par `src/lib/datetime.ts`. Aucun appel direct à `new Date()` dans le domaine.
### 3.4 Concurrence
- Verrou optimiste `version` sur `WeeklySchedule`, `Shift`, `UserContract`, `PayPeriod`. Un `UPDATE` qui ne matche pas la version lève un conflit rendu à l'utilisateur.
- Recalcul des compteurs **dans la même transaction** que la mutation qui les affecte.
- Toute action de masse et tout export portent une **clé d'idempotence** ; un rejeu retourne le résultat initial sans réexécuter.
### 3.5 Immutabilité et audit
- `LedgerOperation`, `AuditLog` et `PayrollExport` sont **append-only**. Une correction est une écriture inverse suivie d'une nouvelle écriture, jamais un `UPDATE` ni un `DELETE`.
- `AuditLog` capture auteur, horodatage, entité, avant/après et justification pour : contrat, avenant, absence et décision, publication et dépublication de planning, validation d'heures, ouverture et verrouillage de période, export, changement de rôle ou de permission, ajustement de compteur, accès à une donnée sensible.
### 3.6 Données sensibles
- NIR, IBAN, BIC et pièces jointes de santé **chiffrés au repos** (chiffrement applicatif par colonne, clé hors base).
- Fichiers servis par **URL signée à durée courte**, jamais par chemin public.
- Toute lecture d'une donnée de catégorie particulière est journalisée.
### 3.7 États d'écran
Chaque écran implémente **chargement, vide, erreur, interdit**. Accessibilité clavier et contraste conformes WCAG 2.2 AA. Les bandeaux transverses sont dismissibles et non bloquants — l'audit relève explicitement que des surcouches masquaient des contrôles dans le produit observé ; ne pas reproduire ce défaut.
---
## 4. Modèle de données
Schéma Prisma normatif. Les noms d'agrégats reprennent ceux de l'audit ; ce n'est pas une copie de schéma propriétaire, dont l'audit ne dispose pas.
### 4.1 Tenancy et identité
```prisma
model Account {
id String @id @default(cuid())
name String
siren String?
apeCode String?
collectiveAgreementId String
createdAt DateTime @default(now())
}
model Location {
id String @id @default(cuid())
accountId String
name String
siret String?
addressLine1 String?
postalCode String?
city String?
timezone String @default("Europe/Paris")
employerContributionRate Decimal @db.Decimal(5,2) // cotisations patronales, %
addPaidLeaveUplift Boolean @default(false) // majoration forfaitaire du coût
productivityTarget Decimal? @db.Decimal(10,2)
silaeDossier String?
archivedAt DateTime?
}
model Team {
id String @id @default(cuid())
locationId String
name String
position Int
archivedAt DateTime?
}
model User {
id String @id @default(cuid())
email String @unique
passwordHash String?
firstName String
lastName String
locale String @default("fr")
}
model Membership {
id String @id @default(cuid())
accountId String
userId String? // null = salarié sans accès applicatif
roleId String
lineManagerId String?
employeeNumber String // matricule interne, unique par compte
silaeMatricule String? // requis avant export Silae
status MembershipStatus @default(INVITED)
invitedAt DateTime?
archivedAt DateTime?
@@unique([accountId, employeeNumber])
}
enum MembershipStatus { INVITED ACTIVE ARCHIVED }
model MembershipScope {
id String @id @default(cuid())
membershipId String
allLocations Boolean @default(false)
locationId String?
teamId String?
}
```
**Invariant** — un `Membership` sans `userId` est un salarié géré mais non connecté. Il doit rester plannifiable, contractualisable et exportable. Ne jamais présupposer l'existence d'un `User`.
### 4.2 Autorisation
```prisma
model Role {
id String @id @default(cuid())
accountId String
key String // stable, référencée par le code
name String // libellé modifiable par le client
isSystem Boolean @default(false) // rôles fournis, non supprimables
@@unique([accountId, key])
}
model Permission {
id String @id @default(cuid())
code String @unique // "ressource.action.qualificatif"
category String
}
model RolePermission { roleId String; permissionId String; @@id([roleId, permissionId]) }
```
Catalogue de permissions à semer : §5.
### 4.3 Personnel
```prisma
model EmployeeProfile {
membershipId String @id
birthDate DateTime?
birthPlace String?
nationality String?
addressLine1 String?
postalCode String?
city String?
country String?
phone String?
personalEmail String?
socialSecurityNumberEnc Bytes? // chiffré
ibanEnc Bytes? // chiffré
bicEnc Bytes? // chiffré
emergencyContactName String?
emergencyContactPhone String?
}
model WorkPermit {
id String @id @default(cuid())
membershipId String
permitType String
reference String
issuedAt DateTime?
expiresAt DateTime
documentId String?
}
model UserContract {
id String @id @default(cuid())
membershipId String
locationId String
contractType ContractType
startDate DateTime @db.Date
endDate DateTime? @db.Date
trialEndDate DateTime? @db.Date
weeklyHours Decimal @db.Decimal(5,2)
isModulated Boolean @default(false)
hourlyRate Decimal? @db.Decimal(10,4)
monthlySalary Decimal? @db.Decimal(10,2)
jobTitleId String?
classification String?
coefficient String?
status ContractStatus @default(ACTIVE)
endReason String?
version Int @default(0)
}
enum ContractType { CDI CDD SAISONNIER APPRENTISSAGE PROFESSIONNALISATION STAGE INTERIM EXTRA }
enum ContractStatus { DRAFT ACTIVE ENDED }
model Amendment {
id String @id @default(cuid())
userContractId String
effectiveDate DateTime @db.Date
changes Json // { champ: { before, after } }
reason String?
documentId String?
createdBy String
createdAt DateTime @default(now())
}
model JobTitle { id String @id @default(cuid()); accountId String; name String; archivedAt DateTime? }
model Label { id String @id @default(cuid()); accountId String; name String; color String; position Int; archivedAt DateTime? }
```
**Invariant contrats** — les périodes de deux contrats actifs d'un même `Membership` ne se chevauchent pas. Contrainte vérifiée en transaction, testée.
### 4.4 Planning
```prisma
model WeeklySchedule {
id String @id @default(cuid())
locationId String
teamId String
isoYear Int
isoWeek Int
status ScheduleStatus @default(DRAFT)
publishedAt DateTime?
publishedBy String?
version Int @default(0)
@@unique([teamId, isoYear, isoWeek])
}
enum ScheduleStatus { DRAFT VALIDATED PUBLISHED }
model Shift {
id String @id @default(cuid())
weeklyScheduleId String
membershipId String? // null = shift non assigné
localDate DateTime @db.Date
startAt DateTime @db.Timestamptz
endAt DateTime @db.Timestamptz
breakMinutes Int @default(0)
actualStartAt DateTime? @db.Timestamptz
actualEndAt DateTime? @db.Timestamptz
actualBreakMinutes Int?
labelId String?
mealCount Int @default(0)
isValidated Boolean @default(false)
validatedAt DateTime?
validatedBy String?
note String?
version Int @default(0)
}
model Rest {
id String @id @default(cuid())
weeklyScheduleId String
membershipId String
localDate DateTime @db.Date
restType RestType
minutes Int?
}
enum RestType { WEEKLY_REST COMPENSATORY_REST }
model DailyNote {
id String @id @default(cuid())
weeklyScheduleId String
localDate DateTime @db.Date
content String
}
model Holiday {
id String @id @default(cuid())
accountId String
locationId String? // null = tous établissements
date DateTime @db.Date
name String
isWorked Boolean @default(false)
}
```
**`À VALIDER`** — l'audit décrit `Rest` comme « pause/repos, durée théorique, extension », ce qui est ambigu. Ce plan tranche : les pauses **dans** un shift sont `Shift.breakMinutes` ; `Rest` marque un repos **de journée** (hebdomadaire ou compensateur). Faire confirmer avant le lot 2.
### 4.5 Absences et compteurs
```prisma
model AbsenceType {
id String @id @default(cuid())
accountId String
code String
name String
color String
isPaid Boolean
countsAsWorkTime Boolean
affectsPaidLeaveAccrual Boolean
silaeCode String? // partie <code> de AB-<code>
requiresJustification Boolean @default(false)
minNoticeDays Int?
@@unique([accountId, code])
}
model TimeOff {
id String @id @default(cuid())
userContractId String
absenceTypeId String
startDate DateTime @db.Date
startHalfDay Boolean @default(false)
endDate DateTime @db.Date // dernier jour d'absence
endHalfDay Boolean @default(false)
status TimeOffStatus @default(PENDING)
requestedBy String
requestedAt DateTime @default(now())
decidedBy String?
decidedAt DateTime?
decisionComment String?
justificationDocumentId String?
}
enum TimeOffStatus { PENDING ACCEPTED DECLINED CANCELLED EXPIRED }
model Counter {
id String @id @default(cuid())
userContractId String
counterType CounterType
acquisitionPeriodStart DateTime @db.Date
acquisitionPeriodEnd DateTime @db.Date
@@unique([userContractId, counterType, acquisitionPeriodStart])
}
enum CounterType { PAID_LEAVE RTT COMPENSATORY_REST MODULATION OVERTIME }
model LedgerOperation {
id String @id @default(cuid())
counterId String
kind LedgerKind
quantity Decimal @db.Decimal(10,4) // signé
unit LedgerUnit
effectiveDate DateTime @db.Date
sourceType LedgerSource
sourceId String?
reason String?
reversesId String? @unique // écriture inverse
createdBy String
createdAt DateTime @default(now())
}
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)
periodStart String // "MM-DD"
autoRenew Boolean @default(true)
status PolicyStatus @default(ACTIVE)
}
enum PolicyStatus { ACTIVE ARCHIVED }
model RttPolicyAssignment { id String @id @default(cuid()); rttPolicyId String; userContractId String }
```
**Invariant ledger** — aucune ligne n'est jamais modifiée ni supprimée. Une correction crée une écriture portant `reversesId`, puis la nouvelle écriture. Le solde est `SUM(quantity)`. Contrainte applicative testée, plus un trigger PostgreSQL interdisant `UPDATE`/`DELETE`.
### 4.6 Paie
```prisma
model PayPeriod {
id String @id @default(cuid())
locationId String
label String
startDate DateTime @db.Date
endDate DateTime @db.Date
kind PayPeriodKind @default(MAIN)
populations ContractType[]
status PayPeriodStatus @default(OPEN)
lockedAt DateTime?
lockedBy String?
version Int @default(0)
@@unique([locationId, startDate, endDate, kind])
}
enum PayPeriodKind { MAIN ALTERNATIVE }
enum PayPeriodStatus { OPEN LOCKED }
model PayPeriodSnapshot {
id String @id @default(cuid())
payPeriodId String
membershipId String
plannedMinutes Int
actualMinutes Int
absenceMinutes Int
overtimeByBracket Json // [{ fromHour, toHour, rate, minutes }]
absenceBreakdown Json // [{ absenceTypeId, days, minutes }]
variables Json // [{ code, quantity, unit }]
computedAt DateTime @default(now())
@@unique([payPeriodId, membershipId])
}
model PayrollExport {
id String @id @default(cuid())
payPeriodId String
format ExportFormat
fileKey String
checksum String
rowCount Int
idempotencyKey String @unique
generatedBy String
generatedAt DateTime @default(now())
}
enum ExportFormat { SILAE GENERIC_CSV RAW }
```
**Invariant période** — le verrouillage écrit les `PayPeriodSnapshot` et interdit toute mutation de `Shift`, `TimeOff` ou heures réelles dont la `localDate` tombe dans la période. Une correction postérieure passe par une **régularisation sur la période ouverte suivante**, jamais par réécriture.
### 4.7 Documents, conformité, transverse
```prisma
model DocumentTemplate { id String @id @default(cuid()); accountId String; name String; bodyHtml String; availableFields Json; archivedAt DateTime? }
model Document {
id String @id @default(cuid())
accountId String
membershipId String?
category DocumentCategory
name String
fileKey String
mimeType String
sizeBytes Int
checksum String
isSensitive Boolean @default(false) // santé : journalisation de lecture
retentionUntil DateTime?
templateId String?
uploadedBy String
uploadedAt DateTime @default(now())
}
enum DocumentCategory { IDENTITY BANK CONTRACT AMENDMENT SICK_NOTE WORK_PERMIT REGISTER OTHER }
model CollectiveAgreement {
id String @id @default(cuid())
idcc String
name String
parameters Json // §6
version Int
effectiveFrom DateTime @db.Date
}
model ComplianceViolation {
id String @id @default(cuid())
weeklyScheduleId String
membershipId String?
ruleCode String
severity Severity
localDate DateTime? @db.Date
message String
context Json
detectedAt DateTime @default(now())
acknowledgedBy String?
acknowledgedAt DateTime?
acknowledgementReason String?
}
enum Severity { INFO WARNING BLOCKING }
model AuditLog {
id String @id @default(cuid())
accountId String
actorMembershipId String?
action String
entityType String
entityId String
before Json?
after Json?
reason String?
ip String?
userAgent String?
occurredAt DateTime @default(now())
@@index([accountId, entityType, entityId])
}
model Integration { id String @id @default(cuid()); accountId String; provider String; credentialsEnc Bytes?; config Json; status String }
model SilaeCodeMapping { id String @id @default(cuid()); accountId String; kind SilaeCodeKind; internalRef String; silaeCode String }
enum SilaeCodeKind { HOURS ABSENCE VARIABLE }
model FeatureFlag { id String @id @default(cuid()); accountId String?; key String; enabled Boolean @default(false) }
model Notification { id String @id @default(cuid()); membershipId String; notificationType String; payload Json; readAt DateTime? }
```
---
## 5. Catalogue des permissions
À semer en base au lot 0. Les codes sont **stables** : le code les référence, jamais les libellés.
**Planning** — `planning.view`, `planning.view_unpublished`, `planning.create`, `planning.create_on_published`, `planning.edit`, `planning.edit_published`, `planning.delete`, `planning.duplicate`, `planning.publish`, `planning.unpublish`, `planning.validate`, `planning.invalidate`, `planning.bulk_actions`, `planning.unassigned.view`, `planning.alerts.view`, `planning.alerts.acknowledge`, `planning.counters.view`, `planning.labels.manage`, `planning.notes.manage`, `planning.print`
**Personnel** — `members.view`, `members.create`, `members.edit`, `members.archive`, `members.delete`, `members.salary.view`, `members.contract.create`, `members.contract.edit`, `members.contract.delete_past`, `members.documents.view`, `members.documents.manage`, `members.register.export`, `members.dpae.check`
**Heures** — `hours.view`, `hours.edit_actual`, `hours.validate`
**Absences et compteurs** — `timeoff.view_own`, `timeoff.view_others`, `timeoff.request`, `timeoff.decide`, `timeoff.delete`, `timeoff.bypass_notice`, `timeoff.forecast.view`, `counters.view_own`, `counters.view_others`, `counters.adjust`
**Paie** — `payroll.access`, `payroll.period.create`, `payroll.period.update`, `payroll.period.delete`, `payroll.period.alternative`, `payroll.period.lock`, `payroll.period.unlock`, `payroll.export`, `payroll.export.silae`, `payroll.export.raw`
**Administration** — `settings.access`, `settings.locations.manage`, `settings.teams.manage`, `settings.agreement.manage`, `settings.jobtitles.manage`, `settings.templates.manage`, `settings.integrations.manage`, `settings.notifications.manage`, `settings.roles.manage`, `role_config.assign_owner_level`, `audit.view`
**Communication (lot 5)** — `articles.view`, `articles.manage`, `conversations.access`
### Rôles semés
`employee`, `team_manager`, `hr_manager`, `payroll_manager`, `admin`, `owner`. Ces jeux de permissions sont un **point de départ modifiable par le client** ; ils ne doivent jamais être codés en dur dans les écrans. Seul `owner` détient `role_config.assign_owner_level`.
---
## 6. Moteur de règles de convention
### 6.1 Conception
`src/domain/compliance/`. Une `CollectiveAgreement` porte un `parameters: Json` **versionné**. Chaque règle est une **fonction pure** :
```ts
type Rule = (ctx: ComplianceContext) => Violation[]
```
`ComplianceContext` fournit : les shifts de la semaine évaluée **et des semaines adjacentes** (nécessaires au repos quotidien et hebdomadaire), les absences, le contrat, l'âge du salarié, le calendrier des jours fériés de l'établissement, et les paramètres de la convention.
Aucun `Date` natif : tout passe par `src/lib/datetime.ts`.
### 6.2 Codes de règles à implémenter
`MAX_DAILY_WORK` · `MAX_DAILY_AMPLITUDE` · `MIN_DAILY_REST` · `MIN_WEEKLY_REST` · `MAX_WEEKLY_WORK_ABSOLUTE` · `MAX_WEEKLY_WORK_AVERAGED` · `MAX_CONSECUTIVE_WORK_DAYS` · `MIN_BREAK_AFTER_THRESHOLD` · `PART_TIME_MIN_WEEKLY_HOURS` · `CONTRACT_HOURS_DEVIATION` · `OVERLAPPING_SHIFTS` · `SHIFT_DURING_ABSENCE` · `SUNDAY_WORK` · `HOLIDAY_WORK` · `MINOR_MAX_DAILY_WORK` · `MINOR_MIN_DAILY_REST` · `MINOR_NIGHT_WORK`
`OVERLAPPING_SHIFTS` et `SHIFT_DURING_ABSENCE` sont de sévérité **`BLOCKING`** : ce sont des incohérences de données, pas des arbitrages. Toutes les autres sont **`WARNING`**.
### 6.3 Paramètres — signal d'arrêt
> **Ce plan ne fixe aucune valeur numérique de convention.** L'audit est muet sur les paramètres réels et les sources publiques se contredisent d'une convention à l'autre. Les seules règles lisibles dans la configuration du compte audité sont les majorations **jour férié 50 %** et **dimanche 100 %**.
>
> **L'orchestrateur doit :** implémenter les règles et leurs tests avec des paramètres **de test explicitement fictifs**, livrer un écran de saisie des paramètres, et **demander les valeurs réelles de l'IDCC 1517 à un expert paie** avant toute mise en production. Ne jamais déduire une valeur d'une recherche web.
### 6.4 Exécution et restitution
- À chaque mutation de shift : revalidation **ciblée** des couples (salarié, semaine) impactés, y compris les semaines adjacentes.
- À la publication : validation complète du périmètre publié.
- Restitution **non bloquante** pour les `WARNING` : badge sur la cellule, panneau latéral listant les violations. La publication reste possible **après confirmation explicite**, qui écrit `acknowledgedBy`, `acknowledgedAt`, `acknowledgementReason` et une entrée `AuditLog`.
- Les `BLOCKING` empêchent l'enregistrement.
---
## 7. Compteurs et heures
### 7.1 Congés — registre
`LedgerOperation` est la source de vérité. Le solde est `SUM(quantity)` sur le `Counter`. La prévision projette les acquisitions restantes de la période en cours.
Écritures automatiques : acquisition à la clôture de chaque période de paie, prise à l'acceptation d'un `TimeOff`, contre-passation à l'annulation, report et expiration en fin de période d'acquisition. Ajustement manuel réservé à `counters.adjust`, **justification obligatoire**.
### 7.2 Décompte des jours d'absence
Règles normatives :
- Une absence court du premier jour **jusqu'au dernier jour d'absence inclus** — soit la veille de la reprise. Le champ `endDate` porte ce dernier jour, pas la date de retour.
- Les demi-journées sont portées par `startHalfDay` / `endHalfDay`.
- Le décompte exclut les jours non travaillés selon le calendrier de l'établissement et le rythme du contrat.
- **Un jour férié dans une période de congé n'est pas décompté** en congé payé. L'implémentation le retire du décompte ; elle n'oblige pas l'utilisateur à scinder sa demande.
- Deux absences acceptées ne peuvent pas se chevaucher pour un même contrat. Contrainte vérifiée en transaction.
### 7.3 Heures — sans pointeuse
Il n'y a **pas de pointage**. Chaque `Shift` porte des heures **prévues** (`startAt`, `endAt`, `breakMinutes`) et, optionnellement, des heures **réelles** (`actualStartAt`, `actualEndAt`, `actualBreakMinutes`).
- Heures réelles absentes → **les heures prévues font foi**.
- Un manager disposant de `hours.edit_actual` saisit un écart ; `hours.validate` valide la ligne, ce qui écrit auteur, date et écart à l'audit.
- L'écran `/reports/hours` liste par salarié : prévu, réel, écart, statut ; sélection multiple et validation groupée.
### 7.4 Bandeau de compteurs de la grille
Chaque ligne salarié affiche cinq valeurs : **heures contractuelles · planifié · absences · écart · repos compensateur**.
**Une seule implémentation** dans `src/domain/counters/` alimente la grille, le rapport d'heures et l'export de paie. Trois calculs divergents seraient un défaut majeur — c'est la première cause d'écart entre un planning et un bulletin.
### 7.5 Coût du planning
Coût prévisionnel = somme des heures × taux horaire du contrat × (1 + `employerContributionRate`), plus la majoration forfaitaire si `addPaidLeaveUplift`. Comparé à l'objectif de productivité de l'établissement. Visible sous `planning.counters.view`.
---
## 8. Export Silae
Seule intégration de paie du périmètre v1. `src/domain/payroll/adapters/silae.ts`, derrière une interface `PayrollExportAdapter` qui laisse la place à d'autres formats.
### 8.1 Format
- **CSV, UTF-8, séparateur `;`**
- En-tête : `matricule;code paie;décompte;date début;date fin;`
- Une ligne par couple (salarié, code de paie) sur la période.
### 8.2 Codes
Trois familles, préfixées :
| Famille | Préfixe | Source du code |
|---|---|---|
| Heures | `HS-` | `SilaeCodeMapping` kind `HOURS` |
| Absences | `AB-` | `AbsenceType.silaeCode` |
| Éléments variables | `EV-` | `SilaeCodeMapping` kind `VARIABLE` |
> **Signal d'arrêt.** Les codes réels appartiennent au dossier Silae du client et se lisent dans « Saisie des éléments variables ». **Ne pas les inventer.** Livrer l'écran de correspondance (`/settings/integrations/silae`) et une table vide ; demander les codes avant la première mise en production.
### 8.3 Règles d'export
- **Pré-contrôle bloquant** : tout salarié inclus doit avoir un `silaeMatricule` et tout élément exporté un code mappé. À défaut, l'export échoue en listant précisément les manques — il ne produit jamais un fichier partiel silencieux.
- L'export ne porte que sur une `PayPeriod` **verrouillée**, et lit exclusivement les `PayPeriodSnapshot`.
- **Idempotence** : un réexport de la même période produit le même fichier et le même `checksum`. L'import Silae écrase les données de la même période pour les mêmes salariés ; l'export doit donc être rejouable sans effet de bord.
- Chaque génération écrit un `PayrollExport` et une entrée d'audit.
---
## 9. Routes et écrans
Structure de navigation cible. Les routes sont propres à PlanFlow ; l'audit sert d'inventaire de capacités, pas de plan d'URL à copier.
| Zone | Routes |
|---|---|
| **Auth** | `/connexion`, `/mot-de-passe/oubli`, `/mot-de-passe/reinitialisation`, `/invitation/:token` |
| **Accueil** | `/` — suivi hebdomadaire, raccourcis, alertes |
| **Planning** | `/planning/semaine`, `/planning/jour`, `/planning/etiquettes`, `/planning/impression` |
| **Équipe** | `/equipe`, `/equipe/:id/profil`, `/contrats`, `/documents`, `/absences`, `/temps`, `/acces`, `/compteurs/:counterId` |
| **Absences** | `/absences/a-traiter`, `/calendrier`, `/traitees`, `/expirees` |
| **Rapports** | `/rapports/paies`, `/rapports/historique`, `/rapports/heures`, `/rapports/activite` |
| **RH** | `/rh` (aperçu), `/rh/entrees`, `/sorties`, `/fins-essai`, `/profils-incomplets`, `/compteurs-conges`, `/journal-absences`, `/titres-sejour`, `/modifications-contrat`, `/rh/analyses/{effectifs,heures,absences}` |
| **Réglages** | `/reglages/compte`, `/etablissements`, `/equipes`, `/convention`, `/emplois`, `/etiquettes`, `/types-absence`, `/politiques-rtt`, `/modeles-documents`, `/roles`, `/roles/:roleKey`, `/integrations/silae`, `/preferences`, `/impression`, `/productivite`, `/rgpd` |
**Écrans du produit audité volontairement absents** : pointeuse et ses réglages, bulletins de paie et distribution, signature électronique, abonnement et facturation, marketplace et connecteurs de caisse, ADP.
### Grille de planning — comportement normatif
Reproduire ces comportements observés à l'écran, avec une interface originale :
- Colonnes lundi→dimanche, numéro de semaine, jour courant distingué.
- **Regroupement par équipe**, chaque équipe portant son propre état de publication et son action publier/dépublier. La publication est **par équipe**, pas par établissement.
- Chaque équipe expose une ligne **« Notes et événements »** et une ligne **« Shifts non assignés »**.
- Shift : bloc coloré par étiquette, libellé, plage horaire, durée de pause, badge d'écart, marque de validation.
- Absences : barres continues sur plusieurs jours, portant le libellé du type et la durée en jours.
- Bandeau de cinq compteurs par ligne salarié (§7.4).
- Navigation de semaine, filtres, sélection des vues, actions de masse.
**Performance** — Server Component pour le chargement, **îlot client** pour l'interaction, mises à jour optimistes, lignes virtualisées au-delà de 50 lignes. Cible : rendu initial d'une semaine de 80 salariés sous 1,5 s, interaction de glisser-déposer sous 100 ms.
---
## 10. Lots de travail
Ordre imposé. Chaque lot est livrable, testé et mergeable seul.
### WP-00 — Socle
**Dépend de :** rien
**Livre :** `docker-compose.yml` (app + Postgres 16), projet Next.js 15 TypeScript strict, Prisma, Tailwind + shadcn/ui, Vitest, Playwright, CI GitHub Actions (lint, typecheck, test, build), `.env.example`, `README.md`.
**Critères d'acceptation**
- `docker compose up` démarre l'application et la base, migrations appliquées.
- CI verte sur un dépôt propre.
- `pnpm typecheck` sans erreur en mode strict.
### WP-01 — Tenancy, identité, autorisation
**Dépend de :** WP-00
**Livre :** modèles §4.1 et §4.2 ; extension Prisma de scoping ; RLS PostgreSQL ; Auth.js (mot de passe + invitation par e-mail) ; `can()` ; catalogue de permissions §5 semé ; rôles semés ; `AuditLog` ; chiffrement de colonnes ; stockage de fichiers à URL signée.
**Critères d'acceptation**
- Un membership scopé sur l'établissement A ne peut **lire ni écrire** une donnée de l'établissement B — testé au niveau requête, pas seulement UI.
- Une Server Action appelée sans la permission requise échoue **avant** tout effet de bord.
- Désactiver le scoping applicatif laisse la RLS bloquer l'accès (test d'intégration dédié).
- Toute mutation produit une entrée `AuditLog` avec avant/après.
- Un rôle personnalisé créé par un client modifie effectivement l'accès, sans changement de code.
### WP-02 — Organisation et référentiels
**Dépend de :** WP-01
**Livre :** établissements, équipes, jours fériés, emplois, étiquettes, types d'absence, convention collective et écran de paramètres ; réglages compte, préférences, productivité, taux de cotisations.
**Critères d'acceptation**
- Création d'un compte à deux établissements et plusieurs équipes de bout en bout.
- Fuseau par établissement effectif sur l'affichage et les calculs.
- Les paramètres de convention sont éditables et versionnés.
### WP-03 — Personnel et contrats
**Dépend de :** WP-02
**Livre :** `Membership`, `EmployeeProfile`, `UserContract`, `Amendment`, `WorkPermit` ; annuaire filtrable et trié ; dossier salarié ; invitation ; salarié sans compte ; export du registre unique du personnel.
**Critères d'acceptation**
- Un salarié **sans `userId`** est créable, plannifiable et contractualisable.
- Deux contrats actifs chevauchants sont refusés.
- Un avenant conserve l'historique et n'écrase pas le contrat.
- `members.salary.view` absente masque la rémunération **et** la refuse côté API.
- L'export du registre contient les mentions légales attendues.
### WP-04 — Planning
**Dépend de :** WP-03
**Livre :** `WeeklySchedule`, `Shift`, `Rest`, `DailyNote`, `Label` ; vues semaine, jour, étiquettes ; création, déplacement, redimensionnement, duplication, actions de masse ; shifts non assignés ; brouillon → validé → publié **par équipe** ; notifications de publication ; impression PDF.
**Critères d'acceptation**
- Grille conforme à §9, y compris lignes notes et non-assignés.
- La publication est par équipe et notifie les salariés concernés.
- `planning.view_unpublished` absente masque les semaines non publiées côté serveur.
- Deux sessions modifiant la même semaine → conflit détecté, aucune perte silencieuse.
- Un shift 22 h–06 h la nuit du changement d'heure est compté 7 h ou 9 h selon le sens.
- Semaine de 80 salariés sous les cibles de §9.
### WP-05 — Moteur de règles
**Dépend de :** WP-04
**Livre :** moteur §6, les 17 règles, `ComplianceViolation`, revalidation ciblée, panneau d'alertes, confirmation tracée à la publication, écran de paramètres.
**Critères d'acceptation**
- Chaque règle a un test aux bornes : la valeur limite exacte passe, un cran au-delà déclenche.
- Modifier un shift revalide la semaine **et ses voisines**.
- Publier malgré un `WARNING` exige une confirmation et écrit l'acquittement et l'audit.
- Un `BLOCKING` empêche l'enregistrement.
- Aucune valeur de convention n'est codée en dur — vérifié par un test qui charge deux jeux de paramètres différents.
### WP-06 — Absences et compteurs
**Dépend de :** WP-05
**Livre :** `TimeOff`, `AbsenceType`, `Counter`, `LedgerOperation`, `RttPolicy` ; demande, file à traiter, décision, calendrier, traitées, expirées ; affichage sur la grille ; soldes et prévision ; ajustement manuel ; trigger d'immutabilité du ledger.
**Critères d'acceptation**
- `endDate` porte le dernier jour d'absence ; un test couvre explicitement la confusion avec la date de reprise.
- Un jour férié dans un congé n'est pas décompté, **sans** scission manuelle.
- Deux absences acceptées chevauchantes sont refusées.
- Accepter écrit au ledger ; annuler contre-passe ; le solde reste juste après un aller-retour.
- `UPDATE` et `DELETE` sur `LedgerOperation` sont rejetés par la base.
- Un ajustement manuel sans justification est refusé.
- Une politique RTT se reconduit automatiquement ; archivée, elle cesse de le faire.
### WP-07 — Heures et périodes de paie
**Dépend de :** WP-06
**Livre :** heures réelles et validation (§7.3) ; `PayPeriod`, populations, période alternative, verrouillage ; `PayPeriodSnapshot` ; rapport d'heures ; journal d'activité.
**Critères d'acceptation**
- Sans heures réelles, le prévu fait foi partout.
- Le verrouillage fige les instantanés et **refuse** toute mutation dans la période.
- Une correction après verrouillage produit une régularisation sur la période suivante, sans réécrire le passé.
- Grille, rapport d'heures et instantané donnent des chiffres **identiques** sur un même jeu de données — test croisé obligatoire.
### WP-08 — Export Silae
**Dépend de :** WP-07
**Livre :** adaptateur §8, écran de correspondance des codes, `silaeMatricule` sur le membership, export générique CSV, historique des exports.
**Critères d'acceptation**
- Fichier conforme : UTF-8, `;`, en-tête exact, préfixes `HS-`/`AB-`/`EV-`.
- Un matricule ou un code manquant fait **échouer** l'export avec la liste des manques ; aucun fichier partiel.
- Réexport de la même période → `checksum` identique.
- Export refusé sur une période non verrouillée.
### WP-09 — Tableau de bord RH
**Dépend de :** WP-08
**Livre :** aperçu et indicateurs ; entrées, sorties, fins de période d'essai, profils incomplets, compteurs de congés, journal des absences, titres de séjour, modifications de contrat ; analyses effectifs, heures, absences.
**Critères d'acceptation**
- Chaque indicateur est **explicable** : un clic mène aux lignes sources.
- Les échéances de titres de séjour et de périodes d'essai remontent dans les fenêtres attendues.
- Les agrégats respectent le périmètre du membership.
### WP-10 — Documents
**Dépend de :** WP-09
**Livre :** GED par salarié, modèles de documents et génération, catégories, rétention, journalisation des lectures sensibles, brouillon DPAE.
**Critères d'acceptation**
- Les fichiers ne sont accessibles que par URL signée expirante.
- La lecture d'un document de santé est journalisée.
- Un modèle génère un document avec les champs du dossier.
- La DPAE est **générée**, jamais transmise (§12).
### WP-11 — Communication *(optionnel)*
**Dépend de :** WP-10
**Livre :** articles et conversations internes.
### Transverse — RGPD
À traiter **dans chaque lot**, pas en fin de projet : minimisation, chiffrement, journalisation des accès, rétention, export et suppression sur demande.
---
## 11. Stratégie de test
**Vitest — domaine pur, en tables**
- Chaque règle de convention, aux bornes.
- Ledger : acquisition, prise, ajustement, contre-passation, report, expiration, régularisation rétroactive.
- Heures : tranches d'heures supplémentaires, **changement d'heure**, **nuit traversante**, absence de valeurs réelles.
- Décompte d'absence : dernier jour vs reprise, demi-journées, jour férié inclus, chevauchement.
- Autorisation : chaque permission absente doit refuser la **mutation**.
**Tests d'intégration — base réelle**
- Scoping multi-tenant au niveau requête.
- RLS active indépendamment du code applicatif.
- Immutabilité du ledger imposée par la base.
- Conflits de version sur écriture concurrente.
**Playwright — parcours traversants**
1. Construire une semaine, déclencher une alerte, publier après confirmation → acquittement et audit écrits, salarié notifié.
2. Demander un congé → accepter → barre visible sur la grille, ledger écrit, solde et prévision à jour → annuler → contre-passation, solde restauré.
3. Saisir un écart d'heures → valider → période de paie → verrouiller → export Silae → réexport identique.
4. Deux sessions sur la même semaine → conflit rendu, pas de perte.
5. Un manager de l'établissement A tente d'atteindre une ressource de B → refus serveur.
**Jeu de données de départ** — un compte, **deux établissements**, plusieurs équipes, une trentaine de salariés mêlant CDI, CDD, temps partiels, un apprenti et **un mineur** (règles dédiées), dont au moins un salarié sans compte utilisateur et un rattaché à deux établissements ; quatre semaines publiées, des absences longues chevauchant des semaines, un jour férié en milieu de congé, des écarts d'heures, une semaine incluant un changement d'heure. **Données entièrement fictives.**
---
## 12. Conformité et risques
- **RGPD** — le dossier contient état civil, NIR, coordonnées bancaires, titres de séjour et arrêts de travail. Les arrêts sont des **données de santé, catégorie particulière**. Minimisation, chiffrement au repos, journalisation des accès, rétention, masquage, export et suppression. Traité dès WP-01.
- **Matrice de conformité manquante** — `À VALIDER`, voir §1. Cette section reste incomplète tant que le document n'est pas fourni.
- **Paramètres de convention** — signal d'arrêt §6.3.
- **Codes de paie Silae** — signal d'arrêt §8.2.
- **DPAE** — générée seulement. La transmission à l'URSSAF exige un raccordement déclaratif, hors périmètre.
- **Signature électronique** — exige un prestataire de confiance qualifié. Hors périmètre ; ne pas implémenter de substitut maison, qui n'aurait aucune valeur probante.
- **Paie et DSN** — hors périmètre. Responsabilité réglementaire majeure et veille législative permanente.
- **Conservation des bulletins** — hors périmètre : PlanFlow ne détient pas de bulletins.
- **Auditabilité** — auteur, horodatage, avant/après et justification sur toutes les opérations sensibles ; agrégats explicables depuis leurs sources.
---
## 13. Règles clean room
- Ce document et l'audit sont une **liste de capacités et d'invariants**, pas un modèle de code ou de présentation.
- API, schéma, textes et interface **originaux**.
- Ne pas reprendre noms de classes, CSS, icônes, illustrations, ni wording propriétaire au-delà des libellés métier nécessaires.
- Traçabilité : exigence observée → spécification interne → test d'acceptation.
- Ne pas supposer les règles cachées ; les faire valider avant de les coder.