Apparence
02. Architecture fonctionnelle
Les domaines métier, les règles qui structurent le modèle, et les flux qui les traversent. Le dictionnaire de données complet — chaque table, chaque colonne, ses valeurs autorisées et ses contraintes en base — est généré dans Modèle de données ; cette section dit pourquoi il a cette forme.
Les domaines
Le sens des flèches est celui de la dépendance : le dossier du personnel ne se tient pas sans le référentiel de l'organisation, et les habilitations se définissent par établissement, donc sur ce même référentiel.
Deux bibliothèques personnelles n'apparaissent pas sur ce schéma : Postes et Trames (Bibliothèque ▸ Postes/▸ Trames), qui appartiennent chacune à leur manager plutôt qu'à l'organisation, sans dépendance vers les domaines ci-dessus. Une trame compose un cycle de postes ; elle s'arrête là. C'est Planification qui la rattache à un salarié réel, daté (le wizard ouvert depuis « Enregistrer et créer un planning ») — le seul des deux sens de la flèche qui existe : Planification lit une trame par son identifiant, elle ne compose rien à sa place. Éditer une trame qui planifie encore quelqu'un la clone d'abord (Trame.parent/archived_at) pour que l'historique retrouvé plus tard reste fidèle à ce qui a réellement été planifié — une contrainte légale, pas un choix de modélisation.
Le calendrier se calcule, il ne se stocke pas
Ce que Planification produit — qui, sur quelle trame, depuis quand — se lit jour par jour par un endpoint de période, GET /api/planning/?from=&to=, qui alimente l'écran manager Activité ▸ Planning (vue Général : une ligne par salarié, une colonne par jour).
Décision structurante : une journée n'a pas de ligne en base. Elle se dérive, à la lecture, de la trame que le salarié suit à cette date — trame, puis case du cycle, puis poste — et ce qui a une ligne, c'est l'exception : la modulation (ShiftOverride), qui remplace entièrement la journée qu'elle nomme. La raison est que la trame est déjà l'expression complète du prévu : matérialiser un enregistrement par salarié et par jour créerait une seconde vérité à tenir synchronisée avec elle, pour un volume qui croît sans borne avec le temps, et qui se recalcule à coût constant. Le prix payé est que la lecture d'une période est un calcul : elle est donc plafonnée à 62 jours, ses deux bornes obligatoires.
Décision : le poste voyage avec la journée. La bibliothèque Poste appartient à son créateur — un encadrant n'accède qu'à ses propres modèles d'horaires — alors que la grille affiche les journées d'autres personnes. La réponse de période embarque donc les postes qu'elle emploie, désignés par un identifiant opaque et non par leur code : ce code se déduit de la catégorie, si bien que deux postes du matin le partagent, et une jointure sur lui afficherait les horaires de l'un pour l'autre.
Décision : la composition d'une unité vient du serveur. C'est elle qui décide qui apparaît dans la grille, donc le périmètre de lecture du manager ; la laisser au client en ferait une règle d'habilitation appliquée côté navigateur. Le modèle est le Service (Division) et son affectation datée (DivisionAssignment).
Les alertes conventionnelles (CCN-66) sont calculées dans cette même réponse, puisqu'elles portent sur des journées que seule la résolution connaît. Elles sont trois règles écrites en dur — durée hebdomadaire, repos quotidien, amplitude — et non un référentiel paramétrable : le paramétrage viendra quand un besoin réel le demandera.
L'écran garde un jeu de données simulées, activable par un drapeau (VITE_ENABLE_MOCK), qui sert les tests end-to-end et les situations que la base ne sait pas encore produire. Une seule de ses commandes écrit : l'enregistrement d'une modulation. Ne sont pas construits — les absences, qu'aucun des deux modèles ne sait produire, un repos décidé hors trame, la publication et l'export, la sélection multiple de cases, et les vues Absences et Planning réalisé.
Ce qui reste à créer et ce qui reste à trancher sont détaillés dans references/planning.md.
Le référentiel de l'organisation
Quatre niveaux, du plus large au plus fin :
| Niveau | Ce que c'est | Exemple |
|---|---|---|
| Site | Une implantation géographique | Un bâtiment, une adresse |
| Pôle | Un regroupement d'établissements par domaine d'accompagnement | Le pôle adultes |
| Établissement | L'entité qui reçoit le public, celle que la paye connaît | Un FAM |
| Service | Une subdivision interne d'un ou plusieurs établissements | Une unité de vie |
Un code de paye est un référentiel à part, rattaché à l'établissement. Il existe parce que la paye identifie un établissement par son code et non par son nom, et parce que ce code change sans que l'établissement change.
Décision : le site et le pôle sont deux axes indépendants. Un établissement a une implantation et une appartenance à un pôle, et rien ne garantit que deux établissements d'un même pôle soient sur le même site. Les modéliser comme une hiérarchie unique aurait forcé une des deux lectures à passer par l'autre.
Décision : un service peut couvrir plusieurs établissements. La plupart n'en couvrent qu'un, mais une équipe transverse (une astreinte partagée, par exemple) existe. Le code de paye et le pôle restent en revanche uniquement des attributs de l'établissement : un service qui en couvrirait plusieurs n'a plus de valeur unique à leur donner, et rien n'oblige les établissements d'un même service à partager le même code ou le même pôle.
Le dossier du personnel
Décision structurante : un salarié n'est pas un compte. Le compte (auth.User) porte l'authentification ; le salarié porte l'identité RH. Les deux sont des tables distinctes parce qu'elles répondent à deux questions différentes, et un compte peut exister sans salarié — un administrateur qui n'est pas dans l'effectif.
L'inverse, en revanche, n'est pas permis aujourd'hui : Employee.user est obligatoire, donc toute fiche salarié ouvre un compte. Ouvrir un dossier sans compte — un intérimaire dont on tient le dossier sans lui donner accès à l'application — demanderait de rendre ce lien optionnel, et n'est pas construit. Le compte créé sans mot de passe utilisable est le compromis retenu : il existe, il ne sert à rien tant que la personne n'a pas suivi le « mot de passe oublié ».
Décision structurante : trois axes de rattachement, autorisés à diverger.
- Le contrat porte l'établissement de paye. C'est lui qui détermine où la personne est payée, et dont le code de paye figure sur le bulletin.
- La fiche porte les lieux de travail — un principal et autant de secondaires que la personne en a. C'est une déclaration directe, saisie à la main, au grain de l'établissement et sans dates.
- Les affectations rattachent la personne à des services, avec des dates. Ce sont les équipes dans lesquelles elle intervient effectivement.
Les trois n'ont pas à concorder, et c'est le point : un chef de service technique peut être rattaché à l'organisation gestionnaire pour la paye tout en intervenant dans plusieurs établissements. Une règle qui exigerait la cohérence entre eux rendrait ce cas — courant — inexprimable. Le vocabulaire les sépare depuis APAJH-205 : lieu de travail pour la paire déclarative, établissement de paye pour celui du contrat.
Le périmètre d'une personne est donc calculé, pas stocké. À une date donnée, les services viennent des affectations actives et les établissements viennent à la fois de ces services et de l'établissement de paye des contrats actifs. La route /api/employees/<pk>/perimeter/ répond à cette question ; elle est une action dédiée et non un champ de la liste, parce que la réponse dépend d'une date.
Les données de famille sont réduites au strict nécessaire. Par défaut, un enfant à charge n'est connu que par son nombre et son âge. Nommer et dater précisément des enfants n'est pas utile à la gestion du temps de travail, et constitue une donnée personnelle de tiers mineurs qu'il n'y a pas de raison de détenir (§ 05).
Les fenêtres de validité
Contrats, affectations et attributions de rôle portent tous une paire date_start / date_end, et la même sémantique :
- une date de fin vide signifie « toujours en cours », pas « terminé » ;
- une date de début vide signifie « depuis toujours » ;
- « actif » se calcule à une date, jamais « en ce moment » figé en base.
Cette logique est partagée par un module unique (apps/common/periods.py) plutôt que réécrite par domaine : trois modèles répondent à la même question, et trois implémentations auraient dérivé.
Conséquence sur les habilitations : un rôle peut être attribué pour une période et pour certains établissements seulement. Un remplacement d'été se déclare une fois, avec ses dates, et cesse de donner accès de lui-même.
Les compteurs
Trois compteurs par salarié et par année : congés (en jours et en heures), compte épargne-temps, annualisation. L'année est portée par la ligne, ce qui permet de conserver l'historique et de corriger un exercice clos sans toucher au suivant.
Ce qui n'existe pas : le moteur de calcul. Les compteurs sont saisis et consultés ; l'acquisition, le report d'une année sur l'autre et les alertes de seuil ne sont pas implémentés. Un lecteur qui verrait le modèle pourrait supposer le contraire — le § 01 le dit hors périmètre pour cette raison.
Les jours fériés
Le référentiel est alimenté par le calendrier officiel de l'État (calendrier.api.gouv.fr), pour le seul territoire que l'instance dessert.
Décision : la synchronisation est quotidienne, pas annuelle. Un calendrier change une fois par an, ce qui suggère un travail annuel — mais un travail annuel est un point unique de défaillance : si l'unique exécution échoue (API indisponible, conteneur en cours de déploiement, incident réseau), personne ne le remarque et le référentiel reste faux pendant douze mois, ce qui est exactement la ressaisie manuelle que la fonction supprime. La synchronisation est une requête HTTP idempotente : la jouer chaque jour transforme toute défaillance passagère en retard de 24 h.
L'API publie une fenêtre glissante de plusieurs années, donc les années à venir apparaissent d'elles-mêmes : une équipe qui construit le planning 2027 pendant 2026 dispose déjà de 2027.
L'heure et la tâche exacte sont en annexe D.
Les flux avec l'extérieur
| Flux | Sens | Déclencheur | Sécurité |
|---|---|---|---|
| Calendrier des jours fériés | Entrant | Tâche planifiée quotidienne | HTTPS sortant, API publique sans authentification |
| Personnes et contrats CEGI | Entrant | Dépôt SFTP, collecte quotidienne | SSH/SFTP, compte de dépôt chrooté, clé hôte épinglée |
| Courriel de réinitialisation de mot de passe | Sortant | Demande d'un utilisateur | SMTP, § 05 et § 06 |
| Accès base pour l'administration | Entrant | À la demande d'un DBA | TCP filtré par IP, § 04 |
Aucun autre flux ne sort de la plateforme. Il n'y a pas d'interface avec un annuaire d'entreprise.
Conséquence sur le matricule paie. Il est généré par le logiciel de paye, hors périmètre, puis reçu par l'import CEGI ou saisi à la main. La plateforme ne peut donc pas l'attendre pour ouvrir un dossier : Employee.staff_number est facultatif, et un salarié est enregistrable — et planifiable — avant que la paie ait répondu (APAJH-205).
Deux précisions que le vocabulaire de l'interface rend nécessaires :
- l'application signale un dossier sans matricule par un badge « Non synchronisée paie ». Le mot vient de la maquette et désigne un état de la donnée — aucun matricule paie n'a encore été reçu ou saisi. Le badge disparaît dès que le matricule est renseigné ;
- un intérimaire n'a jamais de matricule, les intérims n'entrant pas dans la paie. Chez lui le champ vide est définitif, et l'application n'affiche aucun marqueur : le badge Intérim est l'énoncé exact. Sans cette exception, tout intérimaire porterait une alerte permanente, ce qui apprend à ignorer l'alerte.
L'import des personnes et contrats
Décision : le fichier est un instantané non destructif. Chaque ligne crée ou met à jour un salarié et son contrat par leurs identifiants CEGI. L'absence d'une ligne dans un fichier ultérieur ne supprime rien : un export incomplet ne doit pas effacer un historique d'emploi.
Le fichier entier est décodé et validé avant toute écriture. Une ligne invalide, un doublon interne ou un code établissement inconnu rejette donc le lot entier, sans écriture partielle. Un autre fichier du même passage continue à être traité. L'empreinte SHA-256 du contenu assure l'idempotence, même si le même fichier est redéposé sous un autre nom.
Les champs importés restent sous l'autorité de CEGI : une valeur facultative vide les efface lors d'un nouvel instantané. L'adresse personnelle reçue dans pers_email alimente seulement l'email personnel de la fiche ; elle ne remplace jamais l'email professionnel du compte de connexion.