diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..6cb3da3 --- /dev/null +++ b/PLAN.md @@ -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 de AB- + 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.