// PlanFlow — schéma de données. Voir PLAN.md §4. // // Prisma 7 : l'URL de connexion vit dans prisma.config.ts, plus ici. generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" } // ============================================================================ // Tenancy — PLAN.md §4.1 // ============================================================================ model Account { id String @id @default(cuid()) name String siren String? apeCode String? collectiveAgreementId String? /// Surcharges d'accord d'entreprise (PLAN.md §6.3). Vide aujourd'hui : /// l'organisation auditée n'a pas d'accord d'entreprise. agreementOverrides Json? createdAt DateTime @default(now()) locations Location[] memberships Membership[] roles Role[] auditLogs AuditLog[] retention RetentionPolicy[] featureFlags FeatureFlag[] jobTitles JobTitle[] labels Label[] absenceTypes AbsenceType[] legalConfig LegalConfigEntry[] installation Installation? } /// Marqueur d'installation de l'instance — une ligne, jamais deux. /// /// Il répond à « cette instance a-t-elle déjà un compte ? » **avant** qu'un /// compte soit connu. La question ne peut pas être posée à `Account` : sa /// politique RLS ne laisse voir que le compte courant, si bien qu'une instance /// déjà installée paraîtrait vierge à qui n'a pas de session — et l'écran /// d'installation, qui crée un propriétaire, se rouvrirait à tout venant. /// /// Hors RLS à dessein : la seule chose qu'il divulgue est l'existence d'une /// installation et son identifiant de compte, que le lien d'invitation porte /// déjà en clair (voir `composeInvitationToken`). /// /// Ni modifiable ni supprimable, imposé par un trigger : une instance installée /// ne redevient pas vierge sur une requête de l'application. Remettre une /// instance à zéro est un geste d'exploitant, depuis la base. model Installation { /// Constante, contrainte en base : c'est elle qui rend le doublon impossible /// plutôt que seulement improbable. id String @id @default("singleton") accountId String @unique installedAt DateTime @default(now()) account Account @relation(fields: [accountId], references: [id], onDelete: Cascade) } model Location { id String @id @default(cuid()) accountId String name String siret String? timezone String @default("Europe/Paris") /// Taux moyen de cotisations patronales, en pourcentage. employerContributionRate Decimal @default(0) @db.Decimal(5, 2) silaeDossier String? archivedAt DateTime? account Account @relation(fields: [accountId], references: [id], onDelete: Cascade) teams Team[] scopes MembershipScope[] @@index([accountId]) } model Team { id String @id @default(cuid()) accountId String locationId String name String position Int @default(0) archivedAt DateTime? location Location @relation(fields: [locationId], references: [id], onDelete: Cascade) scopes MembershipScope[] members TeamMember[] @@index([accountId]) @@index([locationId]) } /// Rattachement d'un salarié à une équipe. /// /// Distinct de `MembershipScope`, qui dit ce qu'un manager a le droit de voir. /// Celui-ci dit où un salarié **apparaît dans la grille** — y compris une /// semaine où il n'a aucun créneau, ce qui est justement l'état de départ /// quand on construit un planning. model TeamMember { id String @id @default(cuid()) accountId String teamId String membershipId String /// Équipe d'affectation principale ; les autres sont des renforts. isPrimary Boolean @default(true) position Int @default(0) team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) membership Membership @relation(fields: [membershipId], references: [id], onDelete: Cascade) @@unique([teamId, membershipId]) @@index([accountId]) @@index([membershipId]) } // ============================================================================ // Identité // ============================================================================ model User { id String @id @default(cuid()) email String @unique passwordHash String? firstName String lastName String locale String @default("fr") /// Deuxième facteur, exigé des rôles administrateur et RH (matrice n° 15). mfaSecretEnc Bytes? mfaEnrolledAt DateTime? /// Dernier pas TOTP employé. Interdit le rejeu d'un code dans sa fenêtre : /// sans lui, le facteur protège du mot de passe volé, pas du code lu /// par-dessus l'épaule. mfaLastStep Int? lastSignInAt DateTime? failedAttempts Int @default(0) lockedUntil DateTime? createdAt DateTime @default(now()) memberships Membership[] sessions Session[] recoveryCodes MfaRecoveryCode[] } /// Code de secours à usage unique. Perdre son téléphone ne doit pas fermer /// définitivement l'accès — et la parade ne doit pas être de désactiver le /// second facteur par un simple courriel au support. model MfaRecoveryCode { id String @id @default(cuid()) userId String codeHash String @unique usedAt DateTime? createdAt DateTime @default(now()) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId]) } /// Lien User ↔ Account. Porte le salarié : `userId` est nullable, car tous les /// salariés ne se connectent pas — ils doivent rester plannifiables et /// exportables sans compte (PLAN.md §4.1). model Membership { id String @id @default(cuid()) accountId String userId String? roleId String lineManagerId String? employeeNumber String silaeMatricule String? status MembershipStatus @default(INVITED) invitedAt DateTime? archivedAt DateTime? createdAt DateTime @default(now()) account Account @relation(fields: [accountId], references: [id], onDelete: Cascade) user User? @relation(fields: [userId], references: [id], onDelete: SetNull) role Role @relation(fields: [roleId], references: [id]) lineManager Membership? @relation("LineManager", fields: [lineManagerId], references: [id], onDelete: SetNull) reports Membership[] @relation("LineManager") scopes MembershipScope[] invitations Invitation[] auditLogs AuditLog[] profile EmployeeProfile? contracts UserContract[] workPermits WorkPermit[] teams TeamMember[] documents Document[] @@unique([accountId, employeeNumber]) @@index([accountId]) @@index([userId]) } enum MembershipStatus { INVITED ACTIVE ARCHIVED } /// Périmètre d'un membership. `allLocations` évite d'énumérer 34 établissements /// pour un directeur — et de rater le 35ᵉ le jour de son ouverture. model MembershipScope { id String @id @default(cuid()) accountId String membershipId String allLocations Boolean @default(false) locationId String? teamId String? membership Membership @relation(fields: [membershipId], references: [id], onDelete: Cascade) location Location? @relation(fields: [locationId], references: [id], onDelete: Cascade) team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade) @@index([accountId]) @@index([membershipId]) } /// Session serveur. En base plutôt qu'en JWT : la matrice n° 23 impose de /// pouvoir révoquer une session, ce qu'un jeton signé ne permet pas. model Session { id String @id @default(cuid()) userId String tokenHash String @unique expiresAt DateTime createdAt DateTime @default(now()) lastSeenAt DateTime @default(now()) ip String? userAgent String? revokedAt DateTime? revokedBy String? /// Faux tant que le second facteur n'a pas été présenté. Une session en /// attente ne donne accès à rien : elle sert seulement à porter le défi. mfaSatisfied Boolean @default(true) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId]) @@index([expiresAt]) } /// Invitation à créer un accès. Le jeton n'existe en clair que dans le message /// envoyé : seule son empreinte est conservée, comme pour une session. model Invitation { id String @id @default(cuid()) accountId String membershipId String tokenHash String @unique email String expiresAt DateTime acceptedAt DateTime? /// Révocation explicite. Distincte de l'expiration : « je retire cet accès » /// et « personne n'a répondu » ne se lisent pas de la même façon dans un /// dossier. revokedAt DateTime? revokedBy String? createdBy String createdAt DateTime @default(now()) membership Membership @relation(fields: [membershipId], references: [id], onDelete: Cascade) @@index([accountId]) @@index([membershipId]) } // ============================================================================ // Autorisation — PLAN.md §4.2 et §5 // ============================================================================ /// Rôle configurable par le client. Le code référence `key`, jamais `name` : /// renommer « Manager » en « Responsable » ne doit rien casser. model Role { id String @id @default(cuid()) accountId String key String name String isSystem Boolean @default(false) account Account @relation(fields: [accountId], references: [id], onDelete: Cascade) permissions RolePermission[] memberships Membership[] @@unique([accountId, key]) @@index([accountId]) } /// Capacité stable, nommée `ressource.action.qualificatif`. Référentiel global : /// les capacités sont définies par le produit, seule leur attribution varie. model Permission { id String @id @default(cuid()) code String @unique category String label String roles RolePermission[] } model RolePermission { roleId String permissionId String role Role @relation(fields: [roleId], references: [id], onDelete: Cascade) permission Permission @relation(fields: [permissionId], references: [id], onDelete: Cascade) @@id([roleId, permissionId]) } // ============================================================================ // Traçabilité — PLAN.md §3.5 // ============================================================================ /// Journal d'audit. Append-only, imposé par un trigger PostgreSQL : une piste /// qu'on peut réécrire ne prouve rien. 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()) account Account @relation(fields: [accountId], references: [id], onDelete: Cascade) actor Membership? @relation(fields: [actorMembershipId], references: [id], onDelete: SetNull) @@index([accountId, entityType, entityId]) @@index([accountId, occurredAt]) } /// Durées de conservation par objet — PLAN.md §12.5. /// « 5 ans partout » est explicitement proscrit : chaque durée porte son point /// de départ et sa justification. model RetentionPolicy { id String @id @default(cuid()) accountId String objectType String durationMonths Int startPoint String justification String legalHold Boolean @default(false) effectiveFrom DateTime @db.Date account Account @relation(fields: [accountId], references: [id], onDelete: Cascade) @@unique([accountId, objectType, effectiveFrom]) @@index([accountId]) } /// Verrou de conformité — PLAN.md §12.4. /// Une fonctionnalité de contrôle reste inactive tant que la notice au salarié /// et l'avis du CSE ne sont pas enregistrés. model FeatureFlag { id String @id @default(cuid()) accountId String key String enabled Boolean @default(false) noticeDocumentRef String? noticeDeliveredAt DateTime? cseOpinionAt DateTime? activatedAt DateTime? account Account @relation(fields: [accountId], references: [id], onDelete: Cascade) @@unique([accountId, key]) } // ============================================================================ // Référentiels — PLAN.md §4.3 et WP-02 // ============================================================================ /// Intitulé d'emploi. Distinct du poste de planning : l'emploi qualifie le /// contrat, le poste qualifie une occupation dans la journée. model JobTitle { id String @id @default(cuid()) accountId String name String archivedAt DateTime? account Account @relation(fields: [accountId], references: [id], onDelete: Cascade) @@unique([accountId, name]) @@index([accountId]) } /// Étiquette de planning — le « poste » coloré de la grille. model Label { id String @id @default(cuid()) accountId String code String name String /// Code de la palette catégorielle (voir src/lib/design/postes.ts). paletteKey String position Int @default(0) archivedAt DateTime? account Account @relation(fields: [accountId], references: [id], onDelete: Cascade) @@unique([accountId, code]) @@index([accountId]) } /// Type d'absence. `isSocialSecurity` isole maladie, maternité et AT : le /// journal des absences les filtre séparément, et ce sont des données de santé. model AbsenceType { id String @id @default(cuid()) accountId String code String name String colorKey String isPaid Boolean @default(true) countsAsWorkTime Boolean @default(false) affectsPaidLeaveAccrual Boolean @default(true) isSocialSecurity Boolean @default(false) requiresJustification Boolean @default(false) minNoticeDays Int? /// Partie de AB- à l'export Silae. Null tant qu'elle n'a pas /// été fournie par le dossier du client (PLAN.md §8.2). silaeCode String? archivedAt DateTime? account Account @relation(fields: [accountId], references: [id], onDelete: Cascade) timeOffs TimeOff[] @@unique([accountId, code]) @@index([accountId]) } /// Registre de paramétrage juridique — PLAN.md §12.7. /// /// La matrice impose de faire **signer** chaque paramètre avant migration, avec /// sa valeur, sa source, sa date d'effet, sa population et son approbateur. Un /// paramètre sans cette traçabilité n'est pas opposable : c'est ce registre qui /// distingue une configuration justifiée d'une valeur recopiée d'un autre /// logiciel. model LegalConfigEntry { id String @id @default(cuid()) accountId String domain String key String value String source String effectiveFrom DateTime @db.Date population String approvedBy String? approvedAt DateTime? attachmentRef String? createdAt DateTime @default(now()) account Account @relation(fields: [accountId], references: [id], onDelete: Cascade) @@unique([accountId, domain, key, effectiveFrom]) @@index([accountId]) } // ============================================================================ // Dossier salarié et contrats — PLAN.md §4.3, WP-03 // ============================================================================ /// Dossier personnel. NIR, IBAN et BIC sont chiffrés au repos (PLAN.md §3.6) : /// une sauvegarde volée ne doit pas suffire à les lire. model EmployeeProfile { membershipId String @id accountId String /// Nom d'état civil, porté par le dossier et non par le compte utilisateur : /// la plupart des salariés n'ont pas de compte, et le registre unique du /// personnel exige leur nom. firstName String lastName String birthDate DateTime? @db.Date birthPlace String? nationality String? addressLine1 String? postalCode String? city String? country String? phone String? personalEmail String? socialSecurityNumberEnc Bytes? ibanEnc Bytes? bicEnc Bytes? emergencyContactName String? emergencyContactPhone String? updatedAt DateTime @updatedAt membership Membership @relation(fields: [membershipId], references: [id], onDelete: Cascade) @@index([accountId]) } /// Titre de séjour et son échéance. Le tableau de bord RH surveille les /// expirations : un titre périmé interdit l'emploi. model WorkPermit { id String @id @default(cuid()) accountId String membershipId String permitType String reference String issuedAt DateTime? @db.Date expiresAt DateTime @db.Date membership Membership @relation(fields: [membershipId], references: [id], onDelete: Cascade) @@index([accountId]) @@index([membershipId]) } model UserContract { id String @id @default(cuid()) accountId String membershipId String locationId String contractType ContractType startDate DateTime @db.Date endDate DateTime? @db.Date trialEndDate DateTime? @db.Date /// Organisation du temps. Le forfait jours exclut le décompte horaire (§6.4). workTimeArrangement WorkTimeArrangement @default(HOURLY) weeklyHours Decimal @default(35) @db.Decimal(5, 2) forfaitDaysPerYear Decimal? @db.Decimal(5, 1) /// Convention individuelle écrite. Sans elle le forfait est inopposable : /// l'activation est refusée. forfaitAgreementRef String? forfaitAgreedAt DateTime? 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) createdAt DateTime @default(now()) membership Membership @relation(fields: [membershipId], references: [id], onDelete: Cascade) amendments Amendment[] @@index([accountId]) @@index([membershipId]) } /// Liste exhaustive relevée dans le filtre « Tous les types de contrats ». enum ContractType { APPRENTISSAGE CDD CDI DIRIGEANT_ASSIMILE_SALARIE DIRIGEANT_NON_SALARIE EXTRA INTERIM STAGIAIRE SAISONNIER } enum ContractStatus { DRAFT ACTIVE ENDED } enum WorkTimeArrangement { HOURLY FORFAIT_JOURS } /// Avenant. Conserve l'historique plutôt que d'écraser le contrat : un contrôle /// demande l'état du contrat au moment des faits, pas son état actuel. model Amendment { id String @id @default(cuid()) accountId String userContractId String effectiveDate DateTime @db.Date changes Json reason String? createdBy String createdAt DateTime @default(now()) contract UserContract @relation(fields: [userContractId], references: [id], onDelete: Cascade) @@index([accountId]) @@index([userContractId]) } /// Décompte des jours d'un salarié au forfait. Conservation 3 ans (§12.5). model ForfaitDayEntry { id String @id @default(cuid()) accountId String userContractId String localDate DateTime @db.Date quantity Decimal @db.Decimal(2, 1) createdBy String createdAt DateTime @default(now()) @@unique([userContractId, localDate]) @@index([accountId]) } /// Entretien annuel de charge, obligatoire au forfait jours (matrice n° 7). model WorkloadReview { id String @id @default(cuid()) accountId String userContractId String heldAt DateTime @db.Date summary String actions String? @@index([accountId]) @@index([userContractId]) } // ============================================================================ // Planning — PLAN.md §4.4, WP-04 // ============================================================================ /// Semaine de planning d'une équipe. /// /// La publication est **par équipe** : l'audit montre un bouton « Dépublier » /// sur chaque section de la grille, pas un seul pour l'établissement. model WeeklySchedule { id String @id @default(cuid()) accountId String locationId String teamId String isoYear Int isoWeek Int status ScheduleStatus @default(DRAFT) publishedAt DateTime? publishedBy String? /// Verrou optimiste : la grille s'édite à plusieurs (PLAN.md §3.4). version Int @default(0) shifts Shift[] rests Rest[] dailyNotes DailyNote[] violations ComplianceViolation[] @@unique([teamId, isoYear, isoWeek]) @@index([accountId]) @@index([locationId, isoYear, isoWeek]) } enum ScheduleStatus { DRAFT VALIDATED PUBLISHED } model Shift { id String @id @default(cuid()) accountId String weeklyScheduleId String /// Null = besoin non couvert, affiché sur sa propre ligne. membershipId String? /// Regroupement dans la grille uniquement. Les durées se calculent depuis /// les instants — la nuit du changement d'heure, 22 h–06 h dure 7 h ou 9 h. 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) schedule WeeklySchedule @relation(fields: [weeklyScheduleId], references: [id], onDelete: Cascade) @@index([accountId]) @@index([weeklyScheduleId, localDate]) @@index([membershipId, localDate]) } /// Repos de journée — hebdomadaire ou compensateur. Les pauses *dans* un /// créneau sont `Shift.breakMinutes`. model Rest { id String @id @default(cuid()) accountId String weeklyScheduleId String membershipId String localDate DateTime @db.Date restType RestType minutes Int? schedule WeeklySchedule @relation(fields: [weeklyScheduleId], references: [id], onDelete: Cascade) @@index([accountId]) } enum RestType { WEEKLY_REST COMPENSATORY_REST } /// Ligne « Notes et événements » de la grille. model DailyNote { id String @id @default(cuid()) accountId String weeklyScheduleId String localDate DateTime @db.Date content String schedule WeeklySchedule @relation(fields: [weeklyScheduleId], references: [id], onDelete: Cascade) @@index([accountId]) } // ============================================================================ // Conformité conventionnelle — PLAN.md §6 et §12.2, WP-05 // ============================================================================ /// Convention collective, **effectif-datée**. /// /// Les versions coexistent et ne se remplacent pas : la matrice n° 1 impose de /// pouvoir reproduire à l'identique une paie antérieure après un changement de /// règle. Une mise à jour en place rendrait cette reproduction impossible, et /// l'impossibilité ne se découvrirait qu'au premier contrôle. /// Pièce du dossier salarié — PLAN.md §4.7. /// /// Le contenu vit sur disque, **chiffré**, jamais dans la base : une pièce /// jointe de santé chiffrée au repos est une exigence du plan (§3.6), et /// stocker des mégaoctets en base rendrait toute sauvegarde impraticable. /// `fileKey` est tiré au sort — jamais dérivé du nom déposé, qui ne doit pas /// pouvoir désigner un emplacement. model Document { id String @id @default(cuid()) accountId String membershipId String? category DocumentCategory name String fileKey String @unique mimeType String sizeBytes Int /// Empreinte du clair, pour détecter une altération du fichier stocké. checksum String /// Donnée de santé : sa lecture est journalisée (§3.6). isSensitive Boolean @default(false) /// Échéance de conservation. Nulle tant qu'aucune politique ne l'a fixée : /// le plan interdit d'appliquer une durée par défaut (§12.5). retentionUntil DateTime? uploadedBy String uploadedAt DateTime @default(now()) deletedAt DateTime? deletedBy String? membership Membership? @relation(fields: [membershipId], references: [id], onDelete: Cascade) @@index([accountId]) @@index([membershipId]) @@index([retentionUntil]) } enum DocumentCategory { IDENTITY BANK CONTRACT AMENDMENT SICK_NOTE WORK_PERMIT REGISTER OTHER } model CollectiveAgreement { id String @id @default(cuid()) accountId String idcc String name String /// Jeu de paramètres validé par `agreementParametersSchema`. parameters Json version Int effectiveFrom DateTime @db.Date /// Origine du texte : URL Legifrance, référence de PDF, note d'entretien. source String? approvedBy String? approvedAt DateTime? createdAt DateTime @default(now()) @@unique([accountId, idcc, version]) @@index([accountId, idcc, effectiveFrom]) @@index([accountId]) } /// Constat produit par le moteur de règles. /// /// Reconstruit à chaque évaluation d'une semaine : ce n'est pas un journal mais /// l'état courant. L'acquittement, lui, survit — c'est la décision d'un manager /// de publier malgré une alerte, et elle doit rester opposable. model ComplianceViolation { id String @id @default(cuid()) accountId String weeklyScheduleId String membershipId String? ruleCode String severity Severity localDate DateTime? @db.Date message String context Json /// Créneaux mis en cause, pour poser le badge sur la bonne cellule. shiftIds String[] /// Version de convention appliquée : sans elle, un constat ancien devient /// inexplicable dès que les paramètres changent. agreementId String? detectedAt DateTime @default(now()) acknowledgedBy String? acknowledgedAt DateTime? acknowledgementReason String? schedule WeeklySchedule @relation(fields: [weeklyScheduleId], references: [id], onDelete: Cascade) @@index([accountId]) @@index([weeklyScheduleId]) @@index([membershipId, localDate]) } enum Severity { INFO WARNING BLOCKING } /// Jour férié d'un établissement. /// /// Par établissement et non par compte : l'Alsace-Moselle a deux jours fériés /// de plus, et une chaîne peut ouvrir un magasin de l'autre côté de la limite. model Holiday { id String @id @default(cuid()) accountId String locationId String localDate DateTime @db.Date name String /// Chômé par décision de l'employeur, au titre des jours garantis. isPaidOff Boolean @default(false) @@unique([locationId, localDate]) @@index([accountId]) } /// Dimanche du maire autorisé — L3132-26. /// /// La liste est arrêtée avant le 31 décembre pour l'année suivante. La stocker /// rend le quota opposable au lieu de purement indicatif. model AuthorisedSunday { id String @id @default(cuid()) accountId String locationId String localDate DateTime @db.Date /// Arrêté municipal ou décision de référence. reference String? @@unique([locationId, localDate]) @@index([accountId]) } // ============================================================================ // Export de paie — PLAN.md §8, WP-08 // ============================================================================ /// Correspondance entre un élément calculé par PlanFlow et un code du dossier /// Silae. /// /// En base et non dans le code : les codes appartiennent au dossier du client /// et se lisent dans « Saisie des éléments variables ». Deux clients du même /// cabinet n'ont pas nécessairement les mêmes. model SilaeCodeMapping { id String @id @default(cuid()) accountId String /// Élément calculé — voir `PayrollElementKey`. sourceKey String silaeCode String label String kind SilaeMappingKind /// Faux tant que la correspondance n'a pas été confirmée par le gestionnaire /// de paie. Un export refuse de tourner sur une correspondance non confirmée. confirmed Boolean @default(false) createdAt DateTime @default(now()) @@unique([accountId, sourceKey]) @@index([accountId]) } enum SilaeMappingKind { /// Décompte de service, sans préfixe : jours travaillés, heures travaillées. SERVICE /// Heures supplémentaires, préfixe `HS-`. OVERTIME /// Absences, préfixe `AB-`. ABSENCE /// Éléments variables, préfixe `EV-`. VARIABLE } /// Trace d'une génération d'export. /// /// Le contenu n'est **pas** conservé : il porte les heures et les absences de /// salariés identifiables, et le regénérer est déterministe. L'empreinte suffit /// à prouver qu'un réexport est identique. model PayrollExport { id String @id @default(cuid()) accountId String locationId String? /// Période couverte. La péremption se **déduit** de /// `generatedAt < PayPeriod.unlockedAt` : la stocker obligerait à réécrire /// une trace qui doit rester append-only. payPeriodId String? periodStart DateTime @db.Date periodEnd DateTime @db.Date checksum String lineCount Int generatedBy String? generatedAt DateTime @default(now()) @@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]) } // ============================================================================ // Périodes de paie — PLAN.md §4.6 et §7.3, WP-07 // ============================================================================ /// Période de paie d'un établissement. /// /// Le **verrouillage** fige les instantanés et interdit toute mutation dont la /// date tombe dans la période. Le **déverrouillage** existe et rouvre les /// mutations : c'est une décision qui exige une justification, pas un bouton /// anodin — tout export produit avant devient périmé. model PayPeriod { id String @id @default(cuid()) accountId String locationId String label String startDate DateTime @db.Date endDate DateTime @db.Date kind PayPeriodKind @default(MAIN) /// Types de contrat inclus. Vide = tous. populations ContractType[] status PayPeriodStatus @default(OPEN) lockedAt DateTime? lockedBy String? /// Dernier déverrouillage : la péremption des exports s'en **déduit**, elle /// n'est jamais stockée, pour que `PayrollExport` reste append-only. unlockedAt DateTime? unlockedBy String? version Int @default(0) createdAt DateTime @default(now()) snapshots PayPeriodSnapshot[] @@unique([locationId, startDate, endDate, kind]) @@index([accountId]) @@index([accountId, startDate]) } enum PayPeriodKind { MAIN ALTERNATIVE } enum PayPeriodStatus { OPEN LOCKED } /// Instantané figé au verrouillage. /// /// Recalculé intégralement à chaque nouveau verrouillage : il n'est donc pas /// immuable au sens strict. C'est le couple (instantané, `PayrollExport`) qui /// porte la preuve — et `PayrollExport`, lui, est append-only. model PayPeriodSnapshot { id String @id @default(cuid()) accountId String payPeriodId String membershipId String plannedMinutes Int actualMinutes Int absenceMinutes Int workedDays Int /// [{ ratePercent, minutes }] overtimeByBracket Json /// [{ absenceTypeId, code, days, minutes }] absenceBreakdown Json /// [{ key, value, unit }] — les éléments prêts pour l'export. variables Json /// Version de convention appliquée : sans elle, un instantané ancien devient /// inexplicable dès que les paramètres changent. agreementId String? computedAt DateTime @default(now()) period PayPeriod @relation(fields: [payPeriodId], references: [id], onDelete: Cascade) @@unique([payPeriodId, membershipId]) @@index([accountId]) } // ============================================================================ // Envoi d'e-mails — configuration par compte // ============================================================================ /// Serveur d'envoi du compte. /// /// En base et non dans les variables d'environnement : chaque client /// auto-hébergé a sa propre boîte, et la changer ne doit pas exiger un accès au /// serveur ni un redémarrage. /// /// Le mot de passe est **chiffré au repos** avec la même clé que le NIR et /// l'IBAN (PLAN.md §3.6). Un accès SMTP volé permet d'écrire au nom de /// l'entreprise : c'est un secret, pas un réglage. model EmailSettings { id String @id @default(cuid()) accountId String @unique host String port Int @default(587) /// STARTTLS sur 587, TLS implicite sur 465. secure Boolean @default(false) username String? passwordEnc Bytes? /// Expéditeur affiché. Doit appartenir au domaine authentifié, sinon les /// messages partent en indésirable. fromName String fromAddress String replyTo String? /// Faux tant qu'un envoi de test n'a pas abouti : un réglage non éprouvé /// n'est pas un réglage, c'est une intention. verifiedAt DateTime? lastError String? updatedAt DateTime @updatedAt createdAt DateTime @default(now()) @@index([accountId]) } /// Trace d'un message émis. /// /// Sans elle, « le salarié dit qu'il n'a rien reçu » est indécidable. Le corps /// n'est pas conservé : il porte des données personnelles et se régénère. model EmailLog { id String @id @default(cuid()) accountId String to String subject String kind EmailKind status EmailStatus error String? /// Identifiant rendu par le serveur d'envoi, utile au dépannage. messageId String? membershipId String? sentAt DateTime @default(now()) @@index([accountId, sentAt]) } enum EmailKind { TEST INVITATION PASSWORD_RESET PLANNING_PUBLISHED TIMEOFF_DECISION LEAVE_NOTICE } enum EmailStatus { SENT FAILED }