SOFTIC Academy

Architecture de SOFTIC Intranet

Mis à jour le 06/11/2026

1. Vue d'ensemble

   Navigateurs (web, PWA)            Application mobile (lot 3)        Produits SOFTIC
   circuit Blazor Server             REST + SignalR                    GED, GMAO, Tender, WRDPA…
          │ WebSocket /_blazor              │ /api/v1  /hubs/realtime         │ /api/integration/v1 (X-Api-Key)
          ▼                                 ▼                                 ▼
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ Softic.Intranet.Web — UN SEUL PROCESSUS (IIS in-process)                                    │
│   Shell + modules Blazor ─────┐      Softic.Intranet.Api (contrôleurs, hub, relais)         │
│                               ▼                         │                                   │
│                   Softic.Intranet.Engine  ◄─────────────┘                                   │
│                   services métier sans état  ──publient──►  IRealtimeBus (mémoire)          │
│                               │                                  │            │             │
│                               ▼                                  ▼            ▼             │
│                   Softic.Intranet.Data (EF Core)        circuits Blazor   relais SignalR    │
└───────────────────────────────┬─────────────────────────────────────────────────────────────┘
                                ▼
                        SQL Server : Softic_Intranet

Pourquoi un seul processus ? Le temps réel est l'exigence n° 1. Avec l'interface, l'API et le hub dans le même processus, un simple bus en mémoire suffit : pas de backplane Redis, pas de file de messages, aucune latence. Une action (web, mobile ou produit externe) → le service métier enregistre → publie sur le bus → tous les écrans concernés se redessinent dans la même milliseconde.

2. Le cadre « application de bureau »

Élément Fichier Rôle
Barre de titre Shell/TitleBar.razor logo, recherche Ctrl+K, société, cloche, menu utilisateur
Volet de navigation Shell/NavPane.razor modules selon les permissions, pastilles vivantes, repliable
Onglets Shell/TabStrip.razor + Services/WorkspaceService.cs un onglet par module, état conservé, clic molette, menu contextuel
Barre d'état Shell/StatusBar.razor liaison temps réel, société, personnes en ligne, heure de la société
Palette de commandes Shell/CommandPalette.razor modules, personnes, tâches, actualités, applications
Notifications Shell/NotificationPanel.razor, Shell/ToastHost.razor panneau latéral, toasts, son, notifications du système

Onglets conservés en mémoire. Une seule page Razor (Workspace.razor, route /{*Path}) reçoit toutes les adresses. MainLayout résout l'adresse en module (ModuleCatalog.Resolve) et l'ouvre dans un onglet. Tous les onglets ouverts restent instanciés (masqués en CSS) : filtres, saisie en cours et position de défilement survivent au changement d'onglet. Chaque onglet est isolé dans un ErrorBoundary : une erreur ne fait tomber que lui.

Ajouter un module = une ligne dans Services/ModuleCatalog.cs + un composant héritant de ModulePageBase. Navigation, onglets, palette et liens profonds suivent automatiquement.

Raccourcis : Ctrl+K recherche · Alt+W fermer l'onglet · Alt+N notifications · Échap fermer.

3. Temps réel

  • IRealtimeBus (Engine/Realtime/RealtimeBus.cs) : Publish(RealtimeMessage) / Subscribe(...).
  • Un message cible : des utilisateurs nommés (UserIds), une société (CompanyId), les détenteurs d'une permission dans la société (Permission), ou tout le groupe. Une seule règle de routage : RealtimeBus.IsRecipient, partagée par le web et le mobile.
  • Web : UserSession (service par circuit) s'abonne, met à jour les compteurs, relaie aux composants ; ModulePageBase.Listen(...) + OnRealtimeAsync redessinent dans le bon contexte.
  • Mobile : RealtimeHubRelay pousse les mêmes événements vers /hubs/realtime (méthode cliente event).
  • Présence : PresenceTracker — en ligne tant qu'il reste une connexion active ; liaison coupée = « absent du réseau ».
  • Reconnexion : après un recyclage du pool IIS ou une mise à jour, wwwroot/js/intranet.js attend que /health réponde puis recharge la page tout seul.

Sujets : notification.created|changed, task.changed|deleted, news.changed, presence.changed, directory.changed, apps.changed, session.revoked ; lot 2 : chat.message, chat.conversation, chat.read, chat.typing, calendar.changed, space.changed.

4. Multi-sociétés

  • Company (hiérarchie, fuseau IANA, langue, logo) → OrgUnit (arbre) → UserCompany (un rôle par société).
  • UserContext = un utilisateur dans une société, avec les permissions de son rôle dans cette société. Il est passé explicitement à tous les services : aucun état ambiant, aucun filtre global caché.
  • Changer de société = réémission du cookie (ou du jeton mobile) puis rechargement du poste de travail.
  • Super-administrateur : entre dans toutes les sociétés avec toutes les permissions. Il en reste toujours un.
  • Actualités : par société ou « tout le groupe ». Notifications : par utilisateur, toutes sociétés confondues.
  • Dates : stockées en UTC, affichées dans le fuseau de la société de travail (Tunis, Luanda…).

5. Sécurité

Sujet Choix
Mots de passe PBKDF2-HMAC-SHA512, 210 000 itérations, sel aléatoire ; verrouillage après N échecs (paramétrable)
Web cookie softic_intranet (HttpOnly, SameSite Lax), revalidé à chaque requête et toutes les 2 min dans le circuit
Mobile JWT HS256 court + jeton de rafraîchissement haché, en rotation ; rejeu d'un ancien jeton = toutes les sessions mobiles fermées
Produits clé sk_préfixe_secret : seule l'empreinte SHA-256 est stockée, comparaison à temps constant
Secrets en base chiffrés par ASP.NET Core Data Protection ({Storage:Root}\keys, DPAPI machine sous Windows)
API Bearer uniquement (jamais le cookie) → pas de CSRF ; limitation de débit sur les points d'entrée d'authentification
Sessions « Fermer les sessions » change le tampon de sécurité : cookies, circuits ouverts et jetons tombent immédiatement
Audit connexions, administration, lancements d'applications, appels d'intégration

6. Traductions (fr, en, ar, pt)

  • Aucune chaîne en dur : tout passe par Softic.Intranet.Shared/Resources/SharedResource*.resx (@L["Clé"] dans l'interface, Texts.Get(clé, langue) côté serveur).
  • Les notifications sont rédigées dans la langue du destinataire, pas de celui qui agit.
  • Arabe : dir="rtl" posé par _Host.cshtml ; les feuilles de style n'utilisent que des propriétés logiques.
  • Les erreurs métier sont des clés (BusinessException("Error.LoginTaken")) traduites par l'interface ou l'API.
  • Les textes fournis par DevExpress lui-même (pagination des grilles…) restent en anglais dans ce lot.

7. Paramètres

Tout se règle dans Administration → Paramètres (effet immédiat). Seuls restent en fichier : la chaîne de connexion, Storage:Root et le mot de passe d'amorçage (appsettings.Production.json, jamais publié).

8. Lot 2 — messagerie, agenda, réunions, espaces d'équipe, fichiers

Même découpage que le socle : entités dans Domain/Entities/Communication.cs, services sans état dans Engine (Chat, Calendar, Meetings, Spaces, Files), contrôleurs dans Api, modules dans Web/Modules. Aucune nouvelle permission : les salles se gèrent avec admin.companies ; tout le reste découle de l'appartenance (à une conversation, à un espace, à la liste des invités).

Messagerie (/chat)

  • Trois sortes de conversation : directe (clé unique petitId:grandId + index unique = un seul fil par paire, même si les deux personnes cliquent en même temps), groupe (propriétaire, renommage, ajout/retrait, quitter avec transmission du rôle au plus ancien membre), espace (membres alignés sur ceux de l'espace).
  • Transverse au groupe : un collaborateur de Tunis écrit à un collaborateur de Luanda.
  • Non-lus : ConversationMember.LastReadMessageId suffit aux pastilles ET aux accusés de lecture ; la messagerie ne crée pas de notification persistante (le centre de notifications reste lisible). Hors du fil affiché : toast + son, sauf conversation en sourdine ou statut « ne pas déranger ».
  • « écrit… » est un événement éphémère, jamais stocké. Message supprimé = texte effacé + pièce jointe supprimée du disque.
  • Interface : ChatThread.razor est réutilisable (module Messagerie et onglet Discussion d'un espace). Le champ de saisie est piloté par wwwroot/js/intranet.js (bindComposer : Entrée envoie, Maj+Entrée va à la ligne). Chaque fil affiché inscrit une « sonde » dans UserSession : le cadre sait ainsi si un message a besoin d'un toast.

Agenda (/calendar)

  • Dates en UTC. Une série récurrente est stockée une fois puis développée à la lecture par OccurrenceExpander (fonction pure, testée) dans le fuseau de la société : une réunion de 9 h 30 reste à 9 h 30 été comme hiver. Modifier = toute la série ; supprimer une occurrence = une ligne EventSkip.
  • Invités de tout le groupe, réponses (accepté / peut-être / refusé) ; changer l'horaire remet les réponses en attente et prévient les invités. Pastille du module = invitations sans réponse.
  • Salles et ressources (/admin/rooms) : détection de conflit à l'enregistrement, séries comprises (180 jours).
  • Confidentialité : un événement privé d'autrui n'est qu'un créneau « Occupé » — le moteur ne renvoie aucun détail, l'interface ne peut donc pas en fuiter.
  • Rappels : ReminderWorker (chaque minute) + journal EventReminderLog = un seul rappel par occurrence, même après un redémarrage.
  • Vues jour / semaine / mois / liste écrites en HTML/CSS (CalendarPage.razor) : glisser-déposer, superposition d'agendas de collègues et de salles, bande de disponibilités dans la fiche. Les formulaires utilisent les éditeurs DevExpress déjà employés au lot 1. DxScheduler n'est volontairement pas utilisé dans ce lot (voir FEUILLE-DE-ROUTE).

Réunions en un clic (WMeet / WMeet)

  • Une réunion = un nom de salle impossible à deviner (softic-{société}-{12 caractères}) gardé dans l'événement ou le message. L'adresse est construite au moment du clic, pour la personne qui entre, par MeetingLinkService :
    • mode simple : gabarit {baseUrl}/{room} (jokers {name}, {email}, {subject}) ;
    • mode jeton : si Meetings.JwtAppSecret est renseigné, jeton JWT WMeet standard (HS256) : iss = app_id, aud, sub = domaine, room, context.user (8 heures).
  • Côté web, « Rejoindre » est un vrai lien /meet/{salle} ouvert dans un nouvel onglet (page Razor qui redirige) : aucun blocage de fenêtre surgissante, et la caméra / le micro ne sont pas bridés par l'en-tête Permissions-Policy de l'intranet comme ils le seraient dans un cadre intégré.
  • Étape suivante possible (non codée, faute de contrat) : si WMeet expose une API « créer une réunion » (POST authentifié par X-Api-Key rendant l'adresse et l'identifiant), il suffira de l'appeler dans MeetingLinkService pour retrouver comptes rendus et indice TAHLIGA rattachés à l'événement de l'intranet.

Espaces d'équipe (/spaces)

  • Un espace = des membres (de toutes les sociétés), un fil de discussion (conversation synchronisée par ChatService.SyncSpaceConversationAsync), des tâches partagées (TaskItem.SpaceId), des fichiers, des événements.
  • Privé = visible de ses seuls membres ; ouvert = découvrable et rejoignable dans sa société. Archivé = lecture seule.
  • Gestion : propriétaires de l'espace, ou administrateur de la société (admin.companies).

Fichiers

  • Contenu sur disque : {Storage:Root}\files\aaaa\MM\{guid}.bin — le nom d'origine n'est jamais un chemin ; la base ne garde que la fiche. À sauvegarder avec la base.
  • Droit de lecture = appartenance à la conversation ou à l'espace (sinon 404, pas 403 : on ne confirme pas l'existence).
  • Web : GET /files/{id}/{nom} (cookie). Seuls images et PDF s'affichent dans le navigateur ; le reste est proposé en téléchargement, avec X-Content-Type-Options: nosniff et un bac à sable CSP. Extensions interdites et taille maximale paramétrables.

9. Lot 3 — Softic IA partout, coûts et revente ; mode intégré des applications

Le principe commercial, inscrit dans le moteur

SOFTIC paie le fournisseur d'IA et refacture la consommation à chaque société avec sa marge. Tout appel à l'IA, d'où qu'il vienne (assistant, messagerie, tâches, agenda, actualités, espaces, API mobile), passe par un seul service, Engine/Ai/AiService, qui l'autorise, le borne, le compte, le valorise et le journalise :

 écran / API ──► AiService.EstimateAsync ──► fourchette du prix de vente AVANT exécution (+ confirmation au-delà du seuil)
             ──► AiService.CompleteAsync
                   1. autorisation : IA activée · clé présente · permission ai.use · société activée · budgets du mois
                   2. choix du modèle par GAMME (Rapide / Standard / Expert) — la fonction appelante décide, pas l'utilisateur seul
                   3. IAiProvider.CompleteAsync  ── flux SSE ──► texte affiché au fil de l'eau
                   4. jetons annoncés par le fournisseur ──► coût fournisseur ──► prix de vente ──► ligne AiUsages
                   5. sujet temps réel ai.usage ──► jauges et tableau de bord à jour
Notion Règle
Coût fournisseur jetons entrants × prix entrée + jetons sortants × prix sortie (prix en $ par million de jetons, table AiModels)
Prix de vente coût × taux de change × (1 + marge %), arrondi au-dessus : jamais vendu sous le prix visé
Montants entiers en millionièmes de devise (…Micro) : aucune erreur d'arrondi, sommes exactes sur tous les moteurs de base
Marge paramètre général Ai.MarginPercent, ou marge propre à une société (AiCompanyPlans)
Budgets par société et par utilisateur, au prix de vente, par mois civil (UTC) ; atteint = IA en pause, rien n'est facturé au-delà
Échecs journalisés sans montant (panne, quota du fournisseur…) : ils expliquent un trou dans les statistiques
Figé à l'appel chaque ligne garde sa marge, son taux de change et sa devise : modifier un tarif ne réécrit jamais l'historique

Cloisonnement (couvert par un test) : un utilisateur et l'administrateur d'une société ne voient que le prix de vente ; le coût fournisseur, la marge réalisée, les tarifs et l'activation par société sont réservés au super-administrateur, c'est-à-dire SOFTIC. Le relevé CSV suit la même règle (colonnes de coût pour SOFTIC seulement).

Marque blanche : l'interface, les messages et la consigne donnée au modèle disent « Softic IA ». Le nom du fournisseur n'existe que dans les paramètres Ai.Provider.* et dans les identifiants techniques de la table AiModels (amorcés par DbSeeder.SeedAiAsync, modifiables dans l'application). Le libellé d'un modèle (« Softic IA · Standard ») est le seul nom montré aux utilisateurs.

Fournisseur remplaçable : le moteur ne connaît que l'interface IAiProvider. MessagesApiProvider parle l'API « Messages » en flux d'événements ; un autre fournisseur (ou un modèle local) s'ajoute par une classe et une ligne d'enregistrement (TryAddSingleton<IAiProvider, …> avant AddIntranetEngine), sans toucher aux coûts ni aux écrans.

« L'IA partout » : deux composants, un catalogue

Élément Rôle
Modules/Ai/AiPanel.razor l'assistant : panneau latéral ouvert depuis la barre de titre (✨) sur tous les écrans ; conversation en direct, prix de chaque réponse, gamme, consommation du mois, avertissement « à vérifier »
Modules/Ai/AiAction.razor le bouton d'IA d'un écran : l'écran fournit la demande (BuildRequest) et reçoit le texte accepté (OnAccept) ; le composant gère estimation, confirmation, flux, prix réel, copie, relance
Engine/Ai/AiPrompts.cs catalogue des fonctions : clé de suivi des coûts, gamme, consigne, longueur maximale

Fonctions livrées : résumer une conversation et proposer une réponse (messagerie, gamme Rapide), rédiger la description d'une tâche, proposer un ordre du jour (agenda), améliorer et traduire une actualité (fr, en, ar, pt), synthèse d'un espace d'équipe. Ajouter l'IA à un écran = une méthode dans AiPrompts + une balise <AiAction …>. L'IA propose, l'utilisateur dispose : aucun texte n'est envoyé ni enregistré sans son accord (la réponse proposée arrive dans la zone de saisie, la description remplace le texte seulement après « Remplacer le texte »).

Données envoyées au fournisseur : uniquement ce que la fonction rassemble sous les yeux de l'utilisateur (les 60 derniers messages du fil ouvert, le titre de la tâche, le texte de l'actualité…), jamais une base entière. La consigne interdit d'inventer noms, chiffres, dates ou engagements. Les conversations de l'assistant ne sont pas conservées (seul le journal de consommation l'est).

Mode intégré des applications : un seul en-tête

Quand un produit s'ouvre dans un onglet de l'intranet, le volet de navigation s'efface (is-nav-hidden, réglage Workspace.AutoHideNavForApps) et le produit masque son propre en-tête : langue, affichage et authentification sont ceux de l'intranet. Le ticket SSO porte culture, theme, embedded, intranet ; un pont postMessage restreint aux deux origines déclarées transporte le thème en direct, la déconnexion et le titre de la page. Côté produit, le projet Softic.Intranet.Client.AspNetCore fait le travail en trois appels — voir INTEGRATION-PRODUITS.md § 4.

10. Ce qui a été vérifié avant livraison

  • compilation complète de la solution (0 erreur, 0 avertissement) contre le vrai EF Core 8 ;
  • modèle validé avec le fournisseur SQL Server : 21 tables, aucune cascade multiple ;
  • exécution réelle sur SQLite : connexion, rendu de tous les modules, 4 langues, contrôle d'accès, API mobile, API d'intégration ;
  • 17 tests automatisés (sécurité, routage temps réel, multi-sociétés, tâches, actualités, jetons, intégration, traductions) ;
  • DevExpress n'étant pas disponible hors de votre poste, ses composants ont été remplacés par des bouchons : 907 attributs ont été contrôlés contre la liste des paramètres 24.1 utilisés, mais c'est le point à surveiller à la première compilation.

Lot 2, mêmes moyens :

  • compilation complète à 0 erreur / 0 avertissement ; modèle SQL Server : 32 tables, 17 cascades, aucun chemin multiple ;
  • 26 tests automatisés (9 nouveaux : fil direct unique, groupe et rôles, réunion / jeton WMeet, récurrence et changement d'heure, invitations / salle / confidentialité / série, rappel unique, espace d'équipe, fichiers, cohérence des données de démonstration) ;
  • exécution réelle sur SQLite : rendu des nouveaux écrans (4 vues de l'agenda, 4 langues, RTL), et scénario complet de l'API : messagerie, pièces jointes, droits, agenda, conflit de salle, réponses, occurrences, réunions, espaces, recherche ;
  • 1 355 attributs DevExpress contrôlés — aucun nouveau composant DevExpress n'a été introduit dans ce lot ;
  • non vérifiable hors navigateur : le comportement interactif (glisser-déposer de l'agenda, compositeur de message, dépôt de fichiers par InputFile, défilement du fil). C'est le scénario de recette de PROCEDURE-MISE-A-JOUR.md.

Lot 3, mêmes moyens :

  • compilation complète des 9 projets à 0 erreur / 0 avertissement ; modèle SQL Server : 35 tables, aucun chemin de cascade multiple ;
  • 31 tests automatisés (5 nouveaux : calcul du coût et du prix de vente, IA éteinte → activée → comptée → refacturée, budgets société / utilisateur et estimation, cloisonnement coût / marge, ticket SSO du mode intégré) ;
  • le vrai client HTTP du moteur a été exercé de bout en bout contre un faux fournisseur qui parle le même format d'événements : en-têtes, flux, jetons, prix (7 020 et 3 510 millionièmes attendus et obtenus), réponse coupée, erreurs 401 / 429 / 529, erreur en plein flux, journal des échecs. Aucun appel au vrai fournisseur n'a pu être fait ici (pas d'accès sortant ni de clé) : c'est le premier point de la recette ;
  • la bibliothèque Softic.Intranet.Client.AspNetCore a été branchée dans un mini-produit d'essai : arrivée SSO, cookies de langue et de thème, en-tête frame-ancestors, distinction cadre / onglet direct, rejeu, mauvais destinataire et ticket falsifié refusés, adresse de retour externe ignorée ;
  • exécution réelle sur SQLite : nouveaux écrans, rôles (SOFTIC / administrateur de société / utilisateur), 4 langues ;
  • 1 562 attributs DevExpress contrôlés ; nouveaux usages à surveiller à la première compilation : DxSpinEdit sur un decimal? avec NullText, DxTab.Visible, DxGridDataColumn.Visible ;
  • non vérifiable hors navigateur : le pont de messages entre l'intranet et un produit réel (thème en direct, déconnexion, titre d'onglet), le panneau de l'assistant en interaction, le téléchargement du relevé CSV.

Courrier (lot 4A)

L'intranet n'héberge aucune boîte aux lettres : il se branche sur la messagerie de l'organisation, comme le fait Outlook. Chaque utilisateur relie une ou plusieurs boîtes ; le module Courrier les lit et y écrit en direct, sans copie locale des messages.

 Modules/Mail (Web)                      Engine/Mail                                   Messagerie de l'organisation
 ┌───────────────────────────┐   ┌──────────────────────────────────────┐   ┌──────────────────────────────────────┐
 │ MailPage  (3 volets)      │   │ MailService    boîtes, droits, envoi │   │ Microsoft 365                        │
 │ MailCompose               │──►│ IMailConnector ──► GraphConnector ───┼──►│   Microsoft Graph v1.0 (OAuth2)      │
 │ MailAccountsDialog        │   │                └─► ImapSmtpConnector ┼──►│ IMAP + SMTP : HCL Domino, Exchange   │
 │ Admin/MailServersAdminPage│──►│ MailAdminService  serveurs, essais   │   │   sur site, Gmail, Zimbra… (MailKit) │
 └───────────────────────────┘   │ MailTokenService  jetons Microsoft   │   └──────────────────────────────────────┘
   /mail/oauth/start|callback    │ MailNotificationSink ─► MailOutbox ──┼──► expéditeur système (SMTP ou Graph)
   /mail/attachment              │ MailOutboxProcessor  (reprises)      │
                                 └──────────────────────────────────────┘
Sujet Décision
Fournisseurs IMailConnector a deux implémentations : GraphConnector (Microsoft 365, HttpClient + JSON, aucun SDK) et ImapSmtpConnector (MailKit, licence MIT). Un troisième connecteur (API REST de Domino, EWS…) n'a rien d'autre à modifier
Exchange ActiveSync (1.4.1) Mail/ActiveSync : la voie d'Outlook et des téléphones, vers HCL Traveler (Domino) ou Exchange — même serveur, mêmes identifiants, rien à ouvrir côté serveur. Wbxml (codage binaire MS-ASWBXML, vérifié sur les trames de référence de la spécification), EasClient (HTTPS + authentification de base ; découverte du point d'entrée /Microsoft-Server-ActiveSync puis /traveler/… ; version commune 14.1 → 12.0 ; redirections suivies à la main, dont 451 ; approvisionnement à la demande sur 449 ou statuts 142-144), EasConnector. Comme Outlook, le connecteur tient une copie de travail par dossier (clé de synchronisation + résumés) mise à jour par différences ; elle vit en mémoire (EasStateCache, partagée entre les écrans d'un utilisateur, jamais écrite en base) et se reconstruit seule si le serveur déclare la clé périmée. Lecture par le MIME complet du message (mêmes images incorporées et pièces jointes qu'en IMAP), envoi par SendMail, lu/drapeau/suppression par Sync, déplacement par MoveItems. Aucune colonne ajoutée : l'hôte occupe ImapHost
Agenda Microsoft 365 (1.4.2) ExternalCalendarService : l'agenda Outlook de l'utilisateur (Graph calendarView, séries déjà développées, heures en UTC) est superposé à son agenda de l'intranet — jamais à celui d'un collègue. Les événements externes sont des CalendarEventDto d'identifiant négatif (ExternalSource = "m365", non modifiables) : les vues existantes les affichent sans changement. Réponse aux invitations (accept / tentativelyAccept / decline), publication d'un événement de l'intranet dans Outlook avec réunion Teams (isOnlineMeeting), marquée de la catégorie « SOFTIC Intranet » pour ne pas revenir en double. Lecture mise en mémoire 45 s ; une panne de Microsoft n'empêche jamais l'agenda de s'afficher
Surveillance du courrier (1.4.3) MailWatchService, exécuté par le service de fond du courrier toutes les 20 s : pour chaque utilisateur connecté (PresenceTracker.OnlineUserIds), la boîte par défaut est relevée au rythme de Relève automatique (30 s au moins) — non-lus pour la pastille du menu (CountersDto.UnreadMail, sujet mail.changed), derniers non-lus pour la cloche (IMailConnector.ListUnreadAsync, notification de catégorie mail, jamais renvoyée par courriel). Premier passage silencieux (apprentissage), un courriel vu n'est jamais notifié deux fois, boîte en panne relevée de moins en moins souvent, boîte d'un utilisateur parti oubliée. Rien en base. Préférence M'avertir des nouveaux courriels (la pastille reste)
Réunions natives (1.6.0, lot 4B-1) Meetings/MeetingRoomService + wwwroot/js/meet.js + Modules/Meet/MeetRoomPage : audio, vidéo et partage d'écran de navigateur à navigateur (WebRTC en maillage, sans serveur de réunion), dans un onglet de l'intranet. L'intranet tient les salles en mémoire (participants par onglet = PeerId), relaie la signalisation (offre, réponse, candidats ICE, « bye ») par son bus temps réel — jamais les flux — et fournit les serveurs ICE : STUN, et TURN avec identifiants temporaires (schéma TURN REST : expiration:softic-<id> + HMAC-SHA1 du secret coturn) ou fixes. Négociation « polie » côté navigateur (le pair au plus petit identifiant cède en cas de collision), candidats mis en attente avant la description distante, partage d'écran par remplacement de piste (pas de renégociation). Capacité Meetings.Native.Max (6) : au-delà la salle est pleine et, si Meetings.BaseUrl (WMeet) est renseigné, la grande salle s'ouvre dans le même onglet (iframe avec allow="camera; microphone; display-capture"). Appels : sonnerie sur toutes les connexions de l'appelé (meet.ring), acceptation → ouverture de la salle, refus → meet.answer, raccroché sans réponse → meet.cancel + notification « appel manqué » (catégorie call). Permissions-Policy : micro, caméra et partage pour self et l'origine WMeet
Arabe dans les PDF, circuits fonctionnels (1.16.0, lot 7-3) Requests/Pdf/TrueTypeFont (cmap 4/12, hmtx, métriques), ArabicText (formes de présentation FE70-FEFF, lam-alef, ordre visuel bidi simplifié), MiniPdf en CIDFontType2 / Identity-H avec ToUnicode, polices Fonts/SofticSans*.ttf incorporées (DejaVu, licence libre). WorkflowTemplate / WorkflowRule, RequestType.TemplateId, RequestStep.PositionId, ApproverKind 6-9, RequestService.ResolveWorkflowAsync et EnsureBuiltInTemplatesAsync
Push et PDF natif (1.15.0, lot 7-2) Push/WebPushProtocol (VAPID ES256, chiffrement aes128gcm : ECDH P-256, HKDF, AES-GCM du framework ; déchiffrement miroir pour les tests), Push/PushService (abonnements PushSubscriptions, envoi en tâche de fond à chaque notification via NotificationService, 404/410 retirés), API /api/v1/push/*, sw.js du mobile (push, notificationclick). Requests/Pdf/MiniPdf (PDF 1.4 : pages A4, Helvetica WinAnsi, retour à la ligne, filets, xref, Info en UTF-16) et RequestPdfService ; archive .pdf à l'approbation ; GET /requests/{id}/pdf (site) et /api/v1/requests/{id}/pdf
Application mobile (1.14.0, lot 7) wwwroot/m/ : PWA autonome (index, app.js écrans et navigation par ancre, api.js jeton + rafraîchissement + cache + file, store.js IndexedDB, i18n.js, sw.js coquille en cache, lib/jsQR.js). Idempotence côté serveur : TimeEntry.ClientId, Request.ClientRef, heure réelle bornée (ClockRequest.AtUtc). Route /m servie hors du repli Blazor. Détail : docs/MOBILE.md
Sondages et pointage (1.13.0, lot 6-3) Engagement/SurveyService (cibles par unité et sous-unités, participation séparée de la réponse pour l'anonymat, résultats agrégés), Engagement/AttendanceService (sites avec rayon et secret QR, jeton du jour HMAC-SHA256, distance haversine, règles arrivée/départ, journées et anomalies calculées à la lecture avec fériés et congés, équipe via N+1, saisie manuelle auditée, export). Modules Sondages et Pointage, softicGeolocate dans intranet.js, API /api/v1/surveys/* et /api/v1/attendance/*
Notes de frais, impression, congés (1.12.0, lot 6-2) Requests/LeaveService (jours ouvrés, soldes LeaveBalances), RequestPrintService (document HTML autonome, échappement lisible), champ de formulaire lines (ExpenseLines, total = montant), type livré EXPENSE avec étape finance.approve, RequestService.OnApprovedAsync (archive StoredFile.RequestId + événement d'absence via CalendarService au nom de la personne, UserContextFactory), ExportExpensesCsvAsync. Onglet Congés du dossier ; softicPrint / softicDownloadText dans intranet.js
Demandes et circuits (1.11.0, lot 6-1) Requests/RequestService : types par société (FieldsJson, StepsJson, seuils de montant), brouillon validé contre le formulaire, dépôt avec circuit instancié (N+1 / N+2 par OrganizationService.ManagerChainAsync, RH et droits par RolePermissions, personne nommée, délégations ApprovalDelegation, étapes sautées si sans approbateur ou si l'approbateur est le demandeur), décision, refus motivé, annulation, commentaire, réattribution, rappels quotidiens (Requests.ReminderDays), notifications à chaque transition, temps réel request.changed, pièces jointes StoredFile.RequestId. Droit requests.manage. Module Demandes avec formulaire généré depuis le type et éditeur de types ; pastille « à valider » (CountersDto.RequestsToApprove) ; API /api/v1/requests/*. Détail : docs/DEMANDES.md
Compétences et performance (1.10.0, lot 5-3) HumanResources/CompetenceService : référentiels par société (compétences, certifications avec validité et caractère obligatoire), niveaux évalués par le N+1 ou la RH, certifications détenues avec expiration calculée, états valide / bientôt expirée / expirée, alertes quotidiennes à seuils (Hr.AlertDays) vers la personne, le N+1 et la RH, objectifs pondérés, entretiens (auto-évaluation → évaluation → clôture). EmployeeService.SyncFromUsersAsync : dossiers créés d'office pour les utilisateurs internes (Hr.AutoEmployees, Hr.ExternalRoles, AppUser.IsExternal, Hr.NumberFormat), à la mise à niveau, à chaque enregistrement d'utilisateur et une fois par jour (MailOutboxWorker). Onglets Compétences et Objectifs et entretiens du dossier, Référentiels pour la RH, API /api/v1/hr/employees/{id}/skills|certifications|objectives|reviews, /api/v1/hr/certifications/expiring
Dossier employé (1.9.0, lot 5-2) HumanResources/EmployeeService : dossier par personne et société, contrats, documents (StoredFile.EmployeeId, lecture réservée à l'employé et à la RH), photo, sortie qui clôt affectations et contrat, effectifs. Niveaux d'accès Full / Self / Reduced / None calculés avec le N+1 de l'organisation ; audit hr.view et hr.reveal ; champs sensibles chiffrés par ISecretProtector. Module Ressources humaines, API /api/v1/hr/*. Détail : docs/RH.md
Organisation (1.8.0, lot 5-1) Organization/OrganizationService : unités typées (OrgUnitKind), postes (JobPosition, ligne hiérarchique des postes), affectations datées (Assignment : principale, N+1 explicite, clôture automatique de la précédente, recopie du rattachement et de l'intitulé pour l'annuaire), organigramme (ChartAsync), résolution du N+1 et de la chaîne hiérarchique (ManagerChainAsync), équipe d'un responsable ; droit hr.manage ; sujet temps réel org.changed ; API /api/v1/organization/* en lecture pour les produits SOFTIC. Module Organisation : organigramme en boîtes, fiche d'unité, postes, parcours. Plan complet du lot 5 : docs/RH.md
Téléphone SIP (1.7.0, lot 4B-2) Meetings/TelephonyService + wwwroot/js/sip.js + wwwroot/lib/jssip (JsSIP 3.13.8, MIT, bundle esbuild) + Shell/Softphone. Poste, identifiant et mot de passe SIP dans les préférences utilisateur (mot de passe chiffré par ISecretProtector), profil servi au navigateur avec les serveurs ICE des réunions ; enregistrement, appel, réponse, sourdine, attente, DTMF dans le navigateur ; un appel à la fois (second entrant → 486 occupé). Journal des appels : table CallLogs (une ligne par participant), alimentée par le combiné et par les appels internes du lot 4B-1 (refusé, manqué, annulé, répondu + durée). Relais TURN loué : Meetings.TurnProvider = cloudflare (/v1/turn/keys/{id}/credentials/generate-ice-servers) ou metered (/api/v1/turn/credentials), identifiants obtenus par le serveur et gardés une heure
Planner, OneDrive, SharePoint (1.7.0, lot 4D-2) Mail/GraphClient (appel Graph au nom de l'utilisateur, permission demandée à part, ETag/If-Match, erreurs traduites), PlannerService (/me/planner/tasks, plans et compartiments en cache 5 min, PATCH avec If-Match, création assignée via m365.id), CloudFilesService (/me/drive, sharedWithMe avec remoteItem, recent, search, /sites, /drives). Modules : volet Planner dans Tâches, page Fichiers 365
Connexion Microsoft (1.5.0) MicrosoftSignInService : /account/microsoft/start → autorisation Microsoft (locataire de la fiche, mêmes permissions que la boîte) → /account/microsoft/callback → échange du code, /me (identifiant d'objet, adresse, UPN) → utilisateur de l'intranet retrouvé par la préférence m365.id, sinon par l'adresse → cookie de session habituel (AccountSignIn), boîte reliée du même coup, audit auth.login. État chiffré + aléatoire en cookie (10 min) ; aucun compte créé ; mot de passe de l'intranet toujours accepté
Présence Teams (1.5.0) TeamsPresenceService : getPresencesByUserId avec le jeton du visiteur, permission Presence.Read.All demandée à part de celles du courrier (un refus ne prive jamais le courrier), correspondance par m365.id, cache d'une minute, silence de dix minutes après une erreur de Microsoft
Protocoles facultatifs réception (IMAP) et envoi (SMTP) sont indépendants : un hôte vide = protocole ignoré. Serveur sans IMAP → boîtes en envoi seul (aucune tentative IMAP, vérification du compte par authentification SMTP) ; serveur sans SMTP → lecture seule, et il ne peut pas être l'expéditeur des notifications
HCL / Lotus Domino par IMAP + SMTP : tâches imap et smtp actives sur le serveur, TLS, mot de passe Internet de la fiche Personne. Dossiers repérés par leur nom quand le serveur n'annonce pas SPECIAL-USE
Connexion IMAP gardée ouverte le temps de l'écran (une ouverture coûte près d'une seconde), une commande à la fois, reprise automatique si le serveur l'a coupée
Microsoft 365 consentement OAuth2 de chaque utilisateur (/mail/oauth/start → Microsoft → /mail/oauth/callback). L'état, chiffré, lie le retour à l'utilisateur parti. Seul le jeton de rafraîchissement est stocké (chiffré) ; le jeton d'accès reste en mémoire. Réponses et transferts passent par les brouillons de Graph : Exchange construit la citation et le fil de discussion
Secrets mots de passe IMAP, jetons, secret client, mot de passe de l'expéditeur : ISecretProtector (Data Protection), jamais renvoyés à l'interface
Affichage du HTML règle de sécurité : le HTML d'un message n'entre jamais dans le document de l'intranet. Il est rendu dans un cadre isolé (iframe sandbox sans scripts ni origine, document autonome passé par srcdoc, politique default-src 'none'). Images distantes bloquées jusqu'à la demande de l'utilisateur ; images incorporées (cid:) converties en data:. En réponse ou transfert, la citation est ajoutée par le serveur : l'éditeur ne contient que le texte de l'utilisateur
Pièces jointes servies par /mail/attachment, en téléchargement, sous Content-Security-Policy: sandbox ; une boîte n'est ouverte que par son propriétaire
Courriels de notification INotificationSink branche des canaux sur le centre de notifications. MailNotificationSink écrit en file (MailOutbox) selon les réglages et les préférences de chacun ; MailOutboxProcessor (service de fond, 20 s) envoie avec reprises à 1, 5, 15, 60, 240 et 720 minutes. Un canal défaillant ne bloque jamais la notification
Agenda les notifications d'invitation portent une pièce invitation.ics (RFC 5545, METHOD:PUBLISH, même UID et SEQUENCE croissant pour une mise à jour) : l'événement s'inscrit dans Outlook, Notes, Gmail

Lot 8 — Application native et API sortante

  • src\Softic.Intranet.Mobile : application .NET MAUI (Android, iOS), hors de la solution principale, cliente de api/v1 comme la PWA ; téléphone SIP par SIPSorcery sur WebSocket avec audio natif (Services\Telephony, Platforms\Android, Platforms\iOS) ; MVVM CommunityToolkit ; SQLite local (cache, file d'actions) ; voir MOBILE-NATIF.md.
  • Engine\Integration\IntegrationReadService : lectures pour les produits (unités avec chemin et responsable, postes, effectifs avec affectation principale du jour et responsable, certifications avec état) ; exposé par IntegrationController sous clé d'intégration avec les portées organization et certifications ; contrats dans Shared\Contracts\IntegrationContracts.cs ; bibliothèque Softic.Intranet.Client 1.4.0 (GetOrganizationAsync, GetPeopleAsync, GetCertificationsAsync, HasValidCertificationAsync).
  • Api\Controllers\MeetingsController : telephony/profile et telephony/calls pour l'application native.

Lot 10 : facturation électronique (v1.19.0)

  • Domaine : InvoicingProfile (paramètres par société, secrets chiffrés), InvoiceTaxRate, InvoiceCustomer, InvoiceItem, Invoice (brouillon → émise → signée → déposée → acceptée / rejetée / annulée), InvoiceLine, InvoiceDocument (XML légaux + SHA-256), InvoiceEvent (historique). Droits invoicing.use, invoicing.issue, invoicing.manage.
  • Moteur (Engine/Invoicing) : modèle pivot EInvoice (EN 16931), InvoiceCalculator (fonction unique de calcul), AmountInWords, InvoiceHtml (impression, QR TTN), packs pays IInvoiceCountryPack (TunisiaPack : contrôles, TEIF 1.8.8, noms de fichiers, lecture de l'accusé), IInvoiceSigner (XmlSignerClient, XAdES), IInvoicePlatform (ElFatouraClient, SOAP), InvoicingService (cycle de vie, numérotation, chronologie, documents, diagnostic) et InvoicingWorker (suivi des dépôts à délais croissants).
  • API : api/v1/invoicing (création idempotente par externalRef, émission, dépôt, suivi, documents, impression).
  • Web : module /invoicing (factures, clients, articles, paramètres) ; documents servis par /invoicing/{id}/document/{docId} (un document précis, l'historique restant consultable après une remise en brouillon) ou /invoicing/{id}/document/{kind} (le dernier de cette nature). Ces points d'entrée répondent 403 ou 409 (empreinte altérée, journalisée), jamais Results.Forbid(), qui renverrait en boucle un utilisateur connecté vers la page de connexion.
  • Temps réel : sujet invoice.changed ; notifications d'acceptation et de rejet à l'émetteur. Détails : docs/FACTURATION.md.

Lot 11 : menu général par processus (v1.20.0)

  • Principe : le menu présente d'abord les services communs (visibles de tous), puis un groupe repliable par processus métier (Administration, Qualité, Ressources humaines, Finance, Études…) visible des seuls rôles qui lui sont rattachés. Un processus regroupe des modules de l'intranet et des applications du lanceur, chacun partagé (reste aussi un service commun) ou réservé.
  • Domaine : BusinessProcess (code, nom en 4 langues, icône, ordre, actif), BusinessProcessRole, BusinessProcessEntry (clé de module ModuleCatalog ou application, Exclusive). Processus communs à toutes les sociétés, comme les rôles.
  • Moteur : ProcessService porte la règle unique (MenuForAsync, ReservedAppsAsync), appliquée aussi par AppService (lanceur), SearchService et SsoService (/launch/{id}). UserContext.RoleId : rôle de la société de travail.
  • Web : UserSession.Menu / IsModuleVisible (menu, palette Ctrl+K, WorkspaceService.Visibility pour les liens directs), NavPane réécrit (groupes repliables mémorisés dans ui.navClosed, pastilles cumulées), écran Administration → Processus (droit admin.roles). Sujet temps réel processes.changed : chaque fenêtre recalcule son menu sans recharger.
  • Mise à niveau : processus livrés créés une fois (System.Upgrade.Lot11Processes), rôles rattachés d'après leurs droits existants. Détails : docs/MENU-PROCESSUS.md.

Lot 12 : pont ERP d'EPPM, côté intranet (v1.21.0)

  • Principe. L'ERP d'EPPM (IES) reste maître des employés et de l'organisation jusqu'à la bascule. L'agent Softic.Intranet.Agent, en lecture seule chez EPPM, pousse l'organisation, les employés et la présence.

    • L'intranet les reçoit sous api/integration/v1/hr/import/*, avec une clé hr.import exclusive et un seul client autorisé.
    • Il les rattache à une seule société, celle de Integrations:Erp:CompanyCode, marquée « gérée par IES ».
    • Contrat : docs/INTEGRATION-ERP.md.
  • Domaine.

    • Company.HrManagedBy : marquage de la société, posé par le seul super-administrateur.
    • ExternalCode et ManagedBy sur OrgUnit, JobPosition et Assignment, avec des index uniques filtrés.
    • Sur Employee : SiteCode et SiteCodeSource, ManagedBy, ExitDateEstimated (P2), ErpMissingSince (présence confirmée sur deux listes).
    • IntegrationBatch : journal des lots, avec empreinte, approbation et références d'audit.
  • Moteur. HrImportService porte tout le contrat :

    • lecture du corps élément par élément, et rejets ligne par ligne avec leur rang et leur motif ;
    • upsert sans écriture quand rien ne change, et un seul SaveChanges par ligne (ou par instantané), sans transaction explicite, ce qui est compatible avec EnableRetryOnFailure ;
    • verrou d'un lot à la fois, pris après les contrôles en lecture seule (corps, configuration, marquage) ;
    • journal tenu ligne à ligne : un lot interrompu est journalisé « cancelled » avec ce qu'il a écrit ;
    • garde-fous (racine d'organigramme, sorties du jour, changements de site) avec approbation « à l'identique » par le super-administrateur ;
    • sortie comptée une seule fois ; accès coupé le jour où elle prend effet, sans toucher IsActive, que Badge lit, et sessions ouvertes fermées (SessionRevoked).

    La règle de verrouillage des fiches est ErpManaged.IsLocked, appliquée par EmployeeService, UserService, OrganizationService et CompanyService. Les services gardent la valeur importée au lieu de refuser l'enregistrement, et refusent les suppressions. Un dossier géré sorti ferme la société au compte (UserContextFactory.ExitedCompanyIdsAsync, même après la bascule) et le retire de l'annuaire (DirectoryService) ; il ne verrouille plus le compte.

  • Web.

    • Écran Administration → Intégrations : état du pont, journal, approbation.
    • Case « Référentiel RH géré par IES » dans Sociétés, et portée Import du référentiel RH dans Clients d'intégration.
    • Bandeau « Géré par IES » et champs importés en lecture seule dans la RH, l'organigramme, les utilisateurs et le profil.
    • Politique de limitation de débit integration.
  • Configuration. Section Integrations:Erp d'appsettings.Production.json, relue à chaud (IOptionsMonitor) : CompanyCode, ClientAppCode, AllowedIps, RoleName, ImportEmails, SiteCodes, SiteMap (ports, plages, racines).

Pont ERP, étape 2 : agent Softic.Intranet.Agent (agent 1.0.0, intranet v1.21.1)

Cette section décrit l'agent 1.0.0. Il est remplacé par l'agent 1.1.0 (lot 13, étape 5 : section « Lot 13 », plus bas), seul compatible avec l'intranet 1.22.0.

  • Place. Service Windows installé chez EPPM, sur VMS-INTRANETAPP, à côté de l'agent Softic.Badge, dont il reprend la conception : hôte .NET 8 minimal, instance unique, state.json, installeur PowerShell, paquet autonome avec empreinte.
    • Il ne référence aucun projet de la solution : il ne connaît que le contrat JSON (docs/INTEGRATION-ERP.md), dont il redéclare les DTO (Relay/ImportContracts.cs).
    • Notice d'installation et d'exploitation : docs/AGENT-ERP.md.
  • Couches.
    • Erp/ErpSource.cs : seules requêtes SQL de l'agent. Ce sont des SELECT constants et paramétrés, préfixés de READ COMMITTED, LOCK_TIMEOUT 5000 et DEADLOCK_PRIORITY LOW, sur les colonnes de la liste blanche du login (docs/SQL-LOGIN-LECTURE-ERP.sql). On y trouve aussi le contrôle des droits du login (PermissionsSql), refait à chaque cycle.
    • Erp/ErpRules.cs : règles du relevé (docs/ERP-SCHEMA-RH.md § 9) → instantané au format du contrat :
      • clés externes P7, affectation principale P8, périmètre P3 monotone, sentinelles ;
      • site dominant d'après les ports de pointage et la table sitePorts publiée par le serveur ;
      • détection d'une lecture instable.
    • Relay/* : client HTTP (clé X-Api-Key, aucune redirection suivie, certificat toujours vérifié) et sérialisation. L'empreinte d'un lot est le SHA-256 des octets envoyés, celle que calcule le serveur.
    • State/AgentStateStore.cs : état versionné, enregistré atomiquement avec une copie .bak. Il contient les empreintes par ligne, les lots retenus, la référence du garde-fou de variation, les curseurs de pointage et les sites. Un état d'une version plus récente que l'agent est refusé.
    • Sync/ErpSync.cs : cycle (état du pont, droits, lecture, organisation, employés, signal de vie, présence) et garde-fous de l'agent :
      • variation de plus de 10 % des clés, ou de plus de 50 affectations ;
      • clôture d'affectation confirmée par deux lectures ;
      • organisation relue 75 secondes après un changement.
    • Erp/ErpCheck.cs : commande erp-check. Elle lit, compte et compare aux PIN de Softic.Badge, sans rien envoyer.
  • Serveur (v1.21.1). GET hr/import/status publie la table des ports de Integrations:Erp:SiteMap (sitePorts) quand elle est valide. L'agent calcule ainsi le site dominant par site et non par port, sans dupliquer la correspondance chez EPPM. Le journal des lots (IntegrationBatchDto.PayloadHash) et l'écran Intégrations montrent l'empreinte que cite l'agent.
  • Décisions du 5 octobre (v1.21.2).
    • P31. HrImportService.PredictClosuresAsync rejoue SyncAssignments sur des copies non suivies, avant toute écriture, pour compter les clôtures d'affectation d'employés actifs. Au-delà de MaxClosuresPerDay (50) par jour, cumulées par IntegrationBatch.Deactivated des lots d'employés, le lot est retenu.
    • P22. DirectoryService.Present, partagé avec SearchService, écarte les comptes gérés jamais ouverts. Le paramètre includeUnopened des listes de choix les garde pour les écrans de gestion. PersonDto.Login est vide pour un compte géré dont l'identifiant est le matricule.
    • P23. Permission hr.accounts. ManagedAccounts.RefusalAsync porte la règle unique d'ouverture : dossier, compte, rattachements, compte déjà ouvert, et rôle contenu dans les droits de l'appelant. Elle est partagée par le drapeau EmployeeDto.AccountOpenable (bouton de la fiche RH) et par UserService.OpenManagedAccountAsync, qui ouvre le compte par une écriture conditionnelle, avec un mot de passe PasswordPolicy.Generate et MustChangePassword.
  • Suite de la v1.21.2 (v1.21.3).
    • P34, changement du mot de passe imposé partout.
      • AuthService.CheckSessionAsync renvoie SessionState (Invalid, Valid, PasswordChangeRequired) en une requête. Elle est appelée à chaque requête web (OnValidatePrincipal) et à chaque appel de l'API (SofticBearerHandler). UserContext.MustChangePassword porte l'indicateur.
      • Web : OnValidatePrincipal pose la marque PasswordChangeGate.Item. Un intergiciel placé après UseAuthorization renvoie alors tout chemin hors de PasswordChangeGate.IsAllowed vers /Account/ChangePassword (401 pour /_blazor). La page n'a plus « Annuler », seulement la déconnexion (formulaire asp-page, jeton anti-falsification).
      • Fenêtres déjà ouvertes : la case cochée par UserService.SaveAsync publie SessionRevoked (rechargement immédiat). Le changement du mot de passe ne publie rien, pour ne pas faire recharger un onglet voisin avec l'ancien cookie. À défaut de signal, RevalidatingAuthStateProvider échoue en 2 minutes au plus ; une erreur de la base n'est pas un échec. MainLayout, qui n'affiche pas le Body d'AuthorizeRouteView, recharge alors la page (AuthenticationStateChanged). intranet.js recharge aussi une fenêtre dont la reconnexion est refusée alors que /health répond.
      • API : aucun jeton. login, exchange et refresh (TokenService.RenewAsync) répondent 403 password_change_required. IssueAsync le refuse aussi, par garde-fou. Un jeton déjà délivré est refusé, et le hub aussi.
      • Microsoft 365 : la revendication amr = microsoft du cookie (AccountSignIn.MethodMicrosoft) permet à UserService.ChangeOwnPasswordAsync(..., provenByMicrosoft) de remplacer un mot de passe imposé sans l'ancien, pour un compte lié à Microsoft. Le nouveau mot de passe doit différer de l'actuel (Password.SameAsCurrent).
      • Clients : PWA 1.21.3 et application native 1.21.3 (code 6). Ils affichent le motif et un lien vers la page web, gardent la file hors ligne et y mettent l'action refusée ; le rejeu s'arrête quand la session tombe. PWA : aucun écran ne recouvre la connexion sans jeton ; un refus ordinaire du rafraîchissement reste sans statut (mise en file, comme avant) ; store.js rend undefined pour une clé absente (défaut antérieur : la requête IndexedDB passait pour des jetons et la PWA restait sur « Chargement »).
    • P38. ManagedAccounts.RefusalAsync(..., regenerate: true) accepte un compte ouvert par un mot de passe initial jamais utilisé : aucune connexion, changement encore dû, aucune liaison Microsoft. UserService.RegenerateInitialPasswordAsync le remplace par la même écriture conditionnelle que l'ouverture. Le drapeau EmployeeDto.AccountPasswordRegenerable affiche le bouton de la fiche RH.
    • P37. TaskEditor garde le responsable actuel d'une tâche hors espace quand la liste ne le propose plus.
  • Tests. Softic.Intranet.Agent.Tests (xUnit), avec des doubles de l'ERP, de l'intranet et de l'horloge :
    • ReadOnlyGuardTests : SELECT seulement, colonnes et fonctions en liste blanche, aucune commande SQL hors ErpSource, quatre routes, aucun contournement de certificat, aucun secret dans les messages ;
    • règles et contrat ;
    • scénarios de cycle (lots retenus, garde-fous, présence, reprise) ;
    • options, verrou et état.

Lot 13 : socle RH d'entreprise (v1.22.0, livrable après la recette ProdEssai)

Mission : docs/MISSION-LOT13-SOCLE-RH.md ; décisions et étapes : docs/COMPTE-RENDU-LOT13.md.

  • Trois niveaux. Person (table People, identité, une fois par groupe), Employee (relation d'emploi, une par personne et par société), AppUser (compte facultatif). Le groupe d'une personne est sa société racine (Person.TenantCompanyId, société sans ParentCompanyId).
  • Étape 2 : modèle et migration additive Lot13_SocleRh.
    • Person : identité du § 1.1 de la mission, UserId (compte de la personne dans ce groupe, unique par groupe : un même compte peut avoir une personne dans deux groupes), ExternalCode (IES:M<matricule>) et NationalId uniques par groupe, index de rapprochement (groupe, nom, prénom, naissance).
    • Employee.PersonId, ExternalCode (unique par société), JobTitle ; Assignment.EmployeeId, ManagerEmployeeId ; OrgUnit.ManagerEmployeeId ; Company.ExternalCode ; EmploymentContract.CompanyId et ExternalCode (unique par société) ; ApiClient.TenantCompanyId (clé bornée à un groupe).
    • Comptes repris : AppUser.LegacySource, LegacyUserId (uniques ensemble), LegacyImportedUtc (premier import), LegacyActive (état dans IES, distinct d'IsActive), LegacyPasswordChangedUtc (reprise de l'empreinte seulement si le mot de passe a changé dans IES), LegacyLastLoginUtc (comptes dormants). Ouverture par société : UserCompany.PendingSinceUtc (appartenance créée par l'import et pas encore ouverte ; nul = ouverte).
    • Employee.UserId et Assignment.UserId deviennent facultatifs (employé sans compte) ; leurs index uniques ne portent plus que sur les lignes qui en ont un. Les champs d'identité sont restés aussi sur Employee jusqu'à Lot13b_SocleRh_Nettoyage (lot 14, étape 2, ci-dessous), qui les a supprimés avec ces deux colonnes.
    • Toutes les nouvelles relations sont en Restrict : supprimer une société, un compte ou un fichier ne supprime jamais une personne. Jusqu'à Lot13b (1.22.0), supprimer un compte supprimait encore, par la cascade du lot 5-2 sur UserId, ses dossiers Employees (avec contrats, certifications et documents) et ses Assignments : avant de supprimer un compte, il fallait poser UserId = NULL sur ses employés, ses affectations et sa personne (People.UserId). Depuis Lot13b, seul People.UserId (relation Restrict) relie un compte à ses dossiers.
    • Sociétés : un seul groupe EPPM ; les sociétés hors arbre d'IES (sans parent, autres que la racine) qui ont un actif ou une organisation en vigueur sont rattachées à « Groupe EPPM » (attached_to_root), les autres sont ignorées (ALGALIA) : décision du 6 octobre.
    • Les services n'utilisent pas encore ces colonnes, mais tolèrent un employé ou une affectation sans compte : listes et dictionnaires filtrés sur UserId, sortie limitée aux affectations de l'employé, liste RH au nom de la personne. Un dossier sans compte n'est visible que de la RH (EmployeeService.AccessAsync) ; les DTO gardent leur forme (UserId = 0 pour un employé sans compte). Règle : jamais a.UserId == b.UserId entre deux colonnes facultatives (EF le traduit en « tous les NULL »).
  • Étape 3 : amorçage, services et écrans (détail et relectures : docs/COMPTE-RENDU-LOT13.md, § 3.1 à 3.6).
    • Utilitaires. Companies/CompanyTree : racine d'une société (son groupe), groupe d'une société (GroupOfAsync), détection de cycle. HumanResources/PersonLinks : personne d'un compte dans un groupe. Organization/OrgHierarchy : instantané hiérarchique d'une société (unités, employés avec personne et compte, affectations principales du jour, appartenances, comptes cités hors appartenance), chargé en cinq requêtes au plus, puis N+1, chaînes, équipes et responsables d'unité calculés en mémoire.
    • Droits. hr.people (identité des personnes, vue Personnes) et hr.contracts (contrats, vue Contrats), donnés par la mise à niveau System.Upgrade.Lot13Permissions aux rôles qui ont hr.manage. Sans eux, la RH garde le dossier d'emploi (EmployeeDto.IdentityVisible, ContractsVisible).
    • Amorçage System.Upgrade.Lot13 (Seed/Lot13Upgrade, supprimé à l'étape 2 du lot 14 une fois joué partout où la 1.22.0 est passée, la clé restant posée par DbSeeder ; une fois, dans une transaction, avec la stratégie de reprise) : une personne par compte et par groupe, une par dossier sans compte ; affectations reliées à leur employé (orphelines internes : employé créé ; orphelines externes : unité et intitulé reportés sur l'appartenance, puis supprimées) ; responsables en employés ; intitulés et société des contrats ; comptes « !ERP! » du lot 12 jamais utilisés et non référencés supprimés ; contrôles finaux (aucun employé sans personne, aucune affectation sans employé, groupe de la personne = racine, compte du dossier = compte de la personne), sinon arrêt.
    • Services par personne et employé. EmployeeService : identité sur la personne (maître des noms, recopiés sur le compte), trois modes de création (compte, personne du groupe, personne nouvelle), fiche personne, vue Contrats, accès par employé (AccessToAsync). UserService : « Ouvrir le compte » d'un dossier sans compte (identifiant = matricule), liaison et déliaison compte ↔ personne, ouverture d'une appartenance en attente, externes (rôle externe et unité). UserCompany.PendingSinceUtc non nul : la société ne s'ouvre pas au compte et il n'apparaît nulle part hors des écrans de gestion.
    • N+1 par employé (remplace la règle « dossier sans compte visible de la RH seule » de l'étape 2 : son N+1 le voit aussi). Façades par compte gardées (ManagerChainAsync, TeamAsync, HistoryAsync) ; ManagerChainDto.ManagerUserId = compte utilisable du N+1 (actif, appartenance ouverte, employé non sorti), Levels = tous les niveaux. Approbations : un responsable prévu présent sans compte utilisable (sauf délégation en cours vers un compte utilisable, suivie d'abord) fait aller l'étape à la RH (hr.manage), à défaut de RH autre que le demandeur au gestionnaire des demandes (requests.manage), son nom en tête du commentaire, sans seconde décision du même droit ; elle n'est sautée que si personne ne porte ni l'un ni l'autre droit, ou sans responsable (sommet). L'API d'intégration et les alertes de certification prennent le même N+1.
    • Groupe. Annuaire (avec les employés sans compte), recherche, listes de choix et API directory limités au groupe de la société courante ; messagerie, agenda, invitations, espaces et appels n'atteignent que les comptes joignables du groupe (DirectoryService.ReachableAsync) ; le super-administrateur voit tout. Arbre des sociétés : pas de cycle, pas de changement de groupe pour une société qui a des employés ou des personnes.
    • Écrans. Organisation (responsables et personnes affectées par employé, avec ou sans compte), RH (vues Dossiers, Personnes, Contrats ; fiche personne ; création en trois modes), annuaire (personnes sans compte), administration des comptes (externe, appartenance en attente, personne liée), demandes (étape repliée).
  • Étape 4 : import IES révisé (aucune migration ; conception docs/LOT13-ETAPE4-CONCEPTION.md, contrat docs/INTEGRATION-ERP.md, réalisation : compte rendu, § 4.0 à 4.9).
    • HrImportService, classe partielle (Engine/HumanResources) : fichier principal (adresses, client, journal et approbation, plafonds du groupe, cadre RunBatchAsync, outils de lecture) et une partie par sujet : Scope (groupe de la clé, société de l'appel, configuration commune, société par défaut), Companies, Organization, People, Employees (avec la coupure d'accès), Presence, Contracts, Accounts, Opening (ouverture groupée des comptes importés) et Status (état du groupe et de chaque société). Contrat partagé : Shared/Contracts/HrImportContracts.cs ; contrôleur : Api/Controllers/HrImportController.cs.
    • Routes sous api/integration/v1/hr/import/ : routes de groupe companies, people, accounts, employees/presence (journalisées sous la racine) ; routes par société organization, employees (contrat v2, sans identité ni compte) et contracts (société du contrat), avec ?companyExternalCode=IES:S<Id> obligatoire ; GET status. La clé hr.import est bornée à une racine (ApiClient.TenantCompanyId, posée par le super-administrateur, ApiClientService.BindAsync) : c'est le groupe de tous ses appels. L'ouverture des comptes importés n'a pas de route : elle se fait dans Administration > Intégrations (OpenImportedAccountsAsync) ou une par une (UserService.OpenMembershipAsync).
    • Règle « lecture par la personne » (généralisée à tout le code par Lot13b) : le code de l'étape 4 lit le compte d'un dossier par sa personne (People.UserId de Employee.PersonId) et les dossiers d'un compte par ses personnes ; jusqu'à Lot13b, Employee.UserId et Assignment.UserId n'y étaient plus qu'écrits, en copie, par un point unique, Engine/Users/PersonAccounts.MirrorAsync (appelé par UserService et par les routes employees et accounts), parce que UserContext, l'annuaire et le verrou E9 les lisaient encore. Lot13b a supprimé ces colonnes sans casser l'import ; MirrorAsync ne tient plus que le miroir OrgUnit.ManagerUser.
    • Coupure d'accès commune (CutAccessAsync, HrImportService.Employees.cs), appelée à toute sortie effective par employees et presence, dans le SaveChanges de la sortie, décidée par l'état : le compte n'est coupé que si la personne n'a plus aucun dossier non sorti dans le groupe (une mutation garde l'accès), qu'il ne lui reste aucune société ouvrable selon la règle de UserContext et qu'il reste quelque chose à couper ; IsActive n'est jamais touché. Elle remplace la règle du lot 12, qui ne coupait jamais un compte rattaché à une autre société.
    • Autres règles transverses : plafonds des garde-fous comptés sur le groupe, à l'heure de la racine, par type de lot ; un SaveChanges par ligne (un seul pour companies et organization), lectures préchargées par lot ; aucune ligne inchangée n'écrit ; Companies/CompanyTree.ProjectedRoots (arbre projeté de la route companies) ; PasswordHasher.IsWellFormed (empreinte reçue d'IES) ; comptes importés (AppUser.Legacy*) créés inactifs, avec des appartenances en attente (UserCompany.PendingSinceUtc) ; l'amorçage Lot13Upgrade (1.22.0) ne supprimait jamais un compte repris.
    • Écrans : Intégrations (état du groupe, grille par société, ouverture groupée avec aperçu, journal avec la colonne des lignes ignorées) ; Clients d'intégration (colonne Groupe) ; Utilisateurs (dernière connexion à IES, refus de déliaison d'un compte partagé entre groupes, Error.UnlinkSharedAccount).
    • Hors étape 4 : agent 1.1.0 (étape 5, ci-dessous ; l'agent 1.0.0 n'envoie pas ce contrat) ; lecture de Softic.Badge par groupe (reportée à la liaison Badge du lot 15) et Lot13b (livrée avec le lot 14, étape 2 : décision du 10 octobre, deux livraisons).
  • Étape 5 : agent ERP Softic.Intranet.Agent 1.1.0 (conception docs/LOT13-ETAPE5-CONCEPTION.md, notice docs/AGENT-ERP.md, contrat docs/INTEGRATION-ERP.md § 17). Projet toujours autonome (aucune référence à la solution ; copie du contrat v2 dans Relay/ImportContracts.cs, concordance vérifiée par réflexion avec Shared).
    • Sources, lecture seule : Erp/SqlReadOnly.cs (seul point d'exécution SQL, session imposée, un seul CommandText), Erp/ErpSource.cs (bases erp et Pointeuse) et Erp/DnnSource.cs (base INTRANET de DNN, facultative) ; liste blanche, droits et colonnes sentinelles contrôlés par base ; ReadOnlyGuardTests par base.
    • Règles : Erp/ErpRules.cs (règles du lot 12), Erp/GroupRules.cs (arbre et sociétés envoyables, société d'emploi de Q2, dossiers monotones, mutations, contrats ; fonctions pures), Erp/AccountRules.cs et Erp/DnnAccountScan.cs (comptes DNN : rapprochement par matricule, externes, techniques prédits, courriels).
    • Cryptographie : Crypto/ (lecture de la machineKey dans le web.config d'IES, dont seul le chemin est en configuration, décision E5-Q2 ; déchiffrement 3DES et AES du SqlMembershipProvider en tampons épinglés ; empreinte PBKDF2-SHA512 au format de l'intranet). Sans journal ni écriture de fichier ; servi par erp-check seulement : la reprise des mots de passe se fait hors cycle, par accounts-check et accounts-migrate sur le module Security/ (fourni par Claude Fable, étape 6.1), qui écrit un fichier d'empreintes consommé par la route accounts (Sync/HashFileAccountPasswordSource.cs, Agent:AccountsHashFile).
    • Cycle : Sync/ErpSync*.cs, classe partielle, une partie par route : companies, organization par société, people, employees par société en deux passes (sortie d'origine d'une mutation après l'arrivée), contracts, accounts (envoi réel avec empreintes quand Agent:AccountsHashFile est renseigné, simulation seulement sinon ; IAccountPasswordSource fourni par HashFileAccountPasswordSource), présence de groupe et signal de vie people [] ; classement des réponses (cycle, société sautée, accounts seule, lot retenu) ; paliers once --routes … [--company …] (code 5) et dry-run --routes ….
    • Débit : Relay/IntranetClient.cs, huit routes, porte unique (un appel à la fois, au moins une seconde entre deux départs, horloge monotone) ; 83 appels mesurés pour un premier envoi complet sans accounts.
    • État et configuration : state.json au format 2 (sociétés, organisations, personnes, dossiers par société, contrats, comptes, lots retenus ; rien de dérivé d'un mot de passe), état 1.0.0 mis de côté en state.json.v1 ; configuration au format 2 (DnnConnectionString, IesWebConfigPath, DnnPortalId, TechnicalLogins, MinRequestIntervalMs), clé de déchiffrement refusée en configuration ; installeur -IesWebConfig, -GrantIesWebConfigRead.

Lot 14 : compléments du socle RH (v1.23.0)

Mission : docs/MISSION-LOT14-COMPLEMENTS-SOCLE-RH.md ; conception : docs/LOT14-CONCEPTION.md ; décisions et étapes : docs/COMPTE-RENDU-LOT14.md. Lot terminé côté Claude le 11 octobre 2026 (étapes 1 à 7) ; la 1.23.0 se livre après la 1.22.0 recettée sur ProdEssai (décision « deux livraisons » du 10 octobre), les trois migrations de la version s'appliquant au démarrage dans l'ordre Lot13b_SocleRh_Nettoyage, Lot14_ComplementsRh, Lot14_JournalContratsIndex, suivies de l'amorçage System.Upgrade.Lot14.

Ce que le lot apporte, en une page. Des référentiels RH du groupe (catégories, grades par société, qualifications, collèges, types d'employé, motifs de sortie, natures de contrat), un classement daté des dossiers, des réglages par société (Setting.CompanyId, repli global), des documents de voyage et de séjour typés avec alertes d'expiration, le self-service de la fiche avec validation RH, des instantanés de fiche (historique et comparaison), des tâches à la DSI pour les sociétés gérées par l'intranet, le circuit des contrats (relances paramétrables, demandes CONTRACT_RENEWAL / CONTRACT_TRIAL, décisions appliquées ou renvoyées à IES) et le contrat d'import v3 porté par l'agent 1.2.0 (references, documents, classements et motif codé sur employees, active sur contracts). Chiffres de la version : 84 DbSet (77 → 84), 23 migrations (Lot1_Initial à Lot14_JournalContratsIndex), 2 615 clés par langue dans les quatre .resx (2 447 après l'étape 2, 2 615 au terme du lot), 365 tests serveur et 286 tests de l'agent ; liste blanche erp de 27 tables et 101 colonnes, douze sentinelles, dix routes d'import.

  • Étape 2 : Lot13b_SocleRh_Nettoyage (migration 20261010233324_Lot13b_SocleRh_Nettoyage, séparée de Lot14_ComplementsRh ; s'applique après la recette de la 1.22.0 en production ; Down interdit en production : docs/PROCEDURE-MISE-A-JOUR.md, 1.22.0, point 15).
    • Modèle. Employee : PersonId NOT NULL (toute relation d'emploi a une personne), suppression de UserId et des 21 colonnes d'identité reprises par Person (Civility … SocialSecurityProtected, PhotoFileId) ; BankAccountProtected reste sur l'emploi. Assignment : EmployeeId NOT NULL, suppression de UserId et de ManagerUserId ; ManagerEmployeeId est le seul N+1 d'une affectation. OrgUnit.ManagerUserId conservé (responsable d'unité par compte, externes). Index : (PersonId, CompanyId) unique sans filtre ; (EmployeeId, ExternalCode) filtré sur ExternalCode seul ; index et clés étrangères des colonnes supprimées retirés (4 DropForeignKey, 7 DropIndex, 24 DropColumn, 2 AlterColumn, 2 CreateIndex).
    • Code. Le compte d'un dossier se lit partout par Employee.Person.UserId (Include(Person).ThenInclude(User) ou projection ; jamais une requête par ligne) : UserContext.ExitedCompanyIdsAsync (coupure d'accès des sortis, sécurité), annuaire, recherche, verrou IES, services RH, demandes, compétences, fichiers, lecture d'intégration (contrat Badge / GMAO inchangé). OrganizationService.AssignAsync n'écrit que ManagerEmployeeId : un N+1 saisi par compte est converti en son dossier présent dans la société, un compte sans dossier est refusé (Error.ManagerEmployeeRequired) ; les unités gardent le repli par compte. PersonAccounts.MirrorAsync ne tient plus que OrgUnit.ManagerUser. Seed/Lot13Upgrade supprimé ; DbSeeder pose la clé System.Upgrade.Lot13 et SeedOrganisationAsync relie les affectations de reprise par la personne. DTO conservés (EmployeeDto.UserId, PhotoFileId, bloc identité, AssignmentDto.ManagerUserId = compte de l'employé responsable), alimentés depuis Person : Web, Api, Shared, PWA et MAUI inchangés.
    • Garde-fous. Contrôles de données avant la mise à jour (conception § 3.4, section « Lot13b » de deploy/Lot13-Recette-Comptages.ps1), rejoués par Feki en production après la recette de la 1.22.0 ; tests Lot13bTests (coupure IES par la personne, sortie par EmployeeId, N+1 refusé, unité par compte, amorçage par la personne, modèle SQL Server, invariants sur jeu amorcé) ; une base qui saute la 1.22.0 échoue dans la migration (UPDATE … = 0 des lignes nulles refusé par les clés étrangères vers People / Employees ; retour arrière = sauvegarde, docs/PROCEDURE-MISE-A-JOUR.md point 15).
  • Étape 3 : réglages par société, référentiels RH, classement daté (migration 20261011002237_Lot14_ComplementsRh, additive, appliquée après Lot13b ; amorçage System.Upgrade.Lot14 au premier démarrage).
    • Réglages par société. Setting.CompanyId (nul = valeur globale) ; index unique Key seul remplacé par (CompanyId, Key) filtré [CompanyId] IS NOT NULL et Key filtré [CompanyId] IS NULL. SettingsService.GetAsync(key, companyId) lit la valeur de la société, puis la valeur globale, puis le défaut de la définition ; SetAsync(key, value, companyId) n'accepte une société que pour une clé déclarée PerCompany (Error.SettingNotPerCompany) ; une valeur vide pour une société supprime sa ligne (repli global) ; cache (CompanyId, Key). Clés PerCompany : Hr.DocumentAlertDays, Hr.DocumentAlertRecipients, Hr.SelfServiceFields, Hr.SelfServiceValidation, Hr.SnapshotRetentionMonths, Hr.DsiTaskAssignee ; Attendance.*, Hr.LeaveDaysPerYear, Hr.Holidays et Hr.Weekend restent globaux (leurs lecteurs lisent la valeur globale) et ne seront réglables par société que quand leurs lecteurs seront basculés, lots 16 à 18 ; les gardes *.LastRun et System.Upgrade.* restent globales. Écran Administration → Paramètres : onglets Global et Par société (chip « hérité » tant qu'aucune valeur propre n'est enregistrée). Les lecteurs existants passent par la valeur globale tant que la clé n'a pas été basculée sur la société du contexte (étapes 4 et lots 16 à 18).
    • Référentiels RH. HrReference (table HrReferences) : nomenclature du groupe (TenantCompanyId = société racine, comme Person), item propre à une société par CompanyId (grades d'IES par société) ; Kind : Category, Grade, Qualification, College, EmployeeType, ExitReason, ContractNature ; Code majuscules sans accent (HrReferenceCodes.Normalize : accents détachés, espaces et ponctuation en _, 64 caractères), unique par (groupe, genre, portée) grâce à deux index filtrés ; ExternalCode (« IES:: ») unique par groupe ; Label et LabelsJson (en, ar, pt) ; ValidFrom / ValidTo / SortOrder / IsActive. Un item fermé (ValidTo) n'est plus proposé mais reste lisible dans l'historique des dossiers ; aucune suppression. Un item importé d'IES (ExternalCode) est verrouillé (code et libellé) quand il est propre à une société gérée par IES ou quand son groupe compte une société gérée par IES ; fin de validité, ordre et traductions restent modifiables. Service HumanResources/HrReferenceService (ListAsync, SaveAsync, CloseAsync, ChoicesAsync groupe + société en vigueur, ResolveAsync pour l'import et l'amorçage, ClassifyAsync, HistoryAsync) ; droit hr.references.manage ; vue Références du module RH (HrReferencesPanel, sans route propre).
    • Classement daté. EmployeeClassification (table EmployeeClassifications) : EmployeeId, Kind, HrReferenceId, StartDate, EndDate (nul = en vigueur), Source (« IES » = ligne importée, verrouillée), ExternalCode (« IES:CE »), Note. En vigueur = EndDate nul ou ≥ aujourd'hui ; une seule ligne en vigueur par (EmployeeId, Kind) (index unique filtré [EndDate] IS NULL et contrôle de l'amorçage) ; ClassifyAsync (hr.manage, société du dossier) clôt la veille la ligne en vigueur du même genre et refuse une ligne IES d'une société gérée (Error.ReferenceLocked). Employee.ExitReasonId (FK Restrict vers HrReferences, genre ExitReason) s'ajoute au texte ExitReason, conservé ; la fiche montre ExitReasonLabel ?? ExitReason. Bloc Classement de la fiche (lignes en vigueur, historique, bouton Classer) ; EmployeeDto.Classifications, ExitReasonId, ExitReasonLabel.
    • Tables posées pour les étapes 4 et 5 (aucune migration ensuite) : EmployeeDocuments enrichie (DocumentType, Status, CountryCode, Zone, MultipleEntries, IssuedOn, AlertDaysOverride, ExternalCode, FileId facultatif), EmployeeChangeRequests, EmployeeSnapshots (projection EmployeeSnapshotContent : emploi et identité, jamais les champs chiffrés ni les numéros de pièces ; empreinte SHA-256), EmployeeDocumentAlerts, ContractReminderRules, ContractReminderLogs. Types de demande livrés CONTRACT_RENEWAL (N+1 puis RH) et CONTRACT_TRIAL (RH seule) dans RequestService.BuiltIn().
    • Amorçage System.Upgrade.Lot14 (Seed/Lot14Upgrade, une fois, transaction, rejouable après échec partiel : chaque étape teste l'existence) : motifs de sortie par groupe depuis Employees.ExitReason et résolution de Employees.ExitReasonId ; natures de contrat depuis EmploymentContract.Classification ; types CONTRACT_* et deux règles de relance (« 45, 32 » ; « 10, 7, 4 ») pour chaque société active ; un instantané « Lot14 » par dossier ; contrôle final (motifs non résolus, codes en double, classements multiples en vigueur, sociétés sans règles, dossiers sans instantané, sociétés sans types) : un échec annule tout et arrête le démarrage. Permissions (System.Upgrade.Lot14Permissions) : hr.references.manage et hr.documents aux rôles porteurs de hr.manage, hr.selfservice à tous les rôles non externes (Hr.ExternalRoles).
    • Convention Company.HrManagedBy (L14-Q18) : nul ou vide = société gérée par l'intranet ; « IES » (comparaison insensible à la casse) = gérée par IES (dossiers, items et classements importés verrouillés ; décisions de contrat renvoyées à IES par une tâche).
    • Tests Lot14ComplementsRhTests (17) : réglages (repli, refus, cache), référentiels (fermé lisible, code normalisé, verrou IES, choix groupe + société), classements (une ligne en vigueur, clôture la veille), motif de sortie de la fiche, amorçage (rejeu, casse et accents, natures, types et règles, permissions, instantanés sans champ chiffré), modèle SQL Server (index filtrés, Restrict, aucun decimal).
    • Étape 4 : documents du dossier, alertes d'expiration, self-service, instantanés, tâches DSI, passage quotidien (aucune migration : tables et colonnes posées à l'étape 3).
      • Documents. EmployeeDocument porte une pièce typée (DocumentType passeport, visa, titre de séjour, permis de travail, autre ; Category reste le classement du fichier), un statut stocké (Valid, Renewing, Cancelled ; Expiring et Expired calculés des dates à la lecture, EmployeeDocumentDto.DisplayStatus), pays ISO 2, zone, entrées simples / multiples, délivré le, seuils propres (AlertDaysOverride), clé d'import (ExternalCode « IES:P » / « IES:DS ») ; FileId facultatif (HasFile). Aucun numéro de pièce (M § 0.4). Service HumanResources/HrDocumentService (AddAsync : titulaire ou hr.documents ; UpdateAsync, RemoveAsync : hr.documents ; ListAsync) : un document importé d'IES dans une société gérée par IES est verrouillé (statut, seuils et libellé seuls modifiables, jamais supprimé : Error.DocumentImported) ; audit hr.document.* à compteurs (type, catégorie, présence d'un fichier), jamais le libellé ni le pays. Onglet Documents de la fiche (DxGrid, saisie typée avec fichier facultatif, verrou visible).
      • Alertes d'expiration (HrDocumentService.AlertPassAsync, appelée par DailyAsync une fois par jour, Hr.DocumentAlertLastRun). Par société : seuils Hr.DocumentAlertDays par type (défaut passeport 180 / 90 / 30 ; visa, séjour, permis 60 / 30 / 7 ; autre 30) ou AlertDaysOverride du document ; documents non annulés d'un dossier présent ; seuil notifié = plus petit seuil ≥ jours restants (0 si expiré) ; une ligne EmployeeDocumentAlerts par document et seuil (index unique), jamais répétée, écrite même sans destinataire résolu ; destinataires Hr.DocumentAlertRecipients (employee, manager, hr) résolus par OrgHierarchy (titulaire, N+1, porteurs actifs de hr.manage) ; notifications SourceApp = HR, ExternalId = doc:{id}:{seuil} (idempotentes), Hr.Notif.DocExpiring, avertissement si expiré.
      • Self-service de la fiche (EmployeeService.UpdateMineAsync, droit hr.selfservice, dossier du compte connecté dans sa société). Champs ouverts par société (Hr.SelfServiceFields : adresse, ville, pays, courriel et téléphone personnels, contact d'urgence, situation familiale, enfants, photo) ; les champs d'état civil importés d'IES (EmployeeService.ImportedSelfServiceFields : civilité, noms, naissance, nationalité) restent verrouillés sur un dossier géré, le téléphone personnel jamais importé reste saisi dans l'intranet (M § 0.10) ; un champ fermé ou importé modifié → Error.SelfServiceFieldLocked. Avec Hr.SelfServiceValidation (défaut vrai) : EmployeeChangeRequest (FieldsJson = champ, avant, après ; une seule en attente par dossier, index filtré, Error.ChangeRequestPending), notification Hr.Notif.ChangeRequested aux porteurs de hr.people, décision DecideChangeAsync (hr.people : approuvée → valeurs écrites et instantané « Self-service » au nom du demandeur ; refusée → motif), notification Hr.Notif.ChangeDecided ; sans validation : écriture immédiate, audit hr.selfservice.update (fields=<nombre>). Écrans : bouton Modifier mes informations de Mon dossier (DxPopup aux champs du réglage), bandeau de la demande en attente, vue RH Demandes de modification (approuver / refuser avec motif) ; lien « Mon dossier RH » du profil.
      • Instantanés de fiche (EmployeeSnapshot, projection EmployeeSnapshotContent : emploi, identité, classements en vigueur ; jamais CIN, passeport, sécurité sociale, coordonnées bancaires, expiration du passeport, fichier de contrat). Pris après chaque SaveAsync (« RH », compte de la RH), self-service accepté (« Self-service », compte du titulaire), import (« Import »), amorçage (« Lot14 ») ; empreinte SHA-256 : un contenu identique au précédent n'est pas réécrit. SnapshotsAsync, CompareSnapshotsAsync (champ en PascalCase, avant, après ; HrNotes masqué pour le titulaire) ; onglet Historique de la fiche (RH et titulaire). Purge PurgeSnapshotsAsync (Hr.SnapshotRetentionMonths par société, défaut 60 ; le dernier instantané de chaque dossier est toujours gardé), appelée par HrDocumentService.DailyAsync.
      • Tâches à la DSI (M § 0.3 : Domino, Active Directory, AX sans écriture). Pour une société gérée par l'intranet (HrManagedBy nul ou vide), SaveAsync ouvre une tâche HR idempotente (employee:{id}:dsi:created|leaving|reactivated, Hr.DsiTask.Title.*, liste de contrôle Hr.DsiTask.Body, échéance +3 jours, lien /hr?employee=<id>) assignée selon Hr.DsiTaskAssignee (login, ou role:<nom du rôle> = premier membre actif du rôle dans la société) ; aucune tâche sans assigné membre actif (avertissement journalisé) ; jamais pour une société gérée par IES ni depuis l'import.
      • Passage quotidien (Web/Services/MailOutboxWorker, toutes les 15 minutes, une passe par jour chacune) : employés automatiques, alertes de certification, demandes, alertes de documents et purge des instantanés ; un try/catch par passe, une passe en échec ne saute plus les suivantes.
      • Tests Lot14ComplementsRhTests.Etape4.cs (13) : alertes (une fois par seuil, destinataires du réglage, document annulé), document importé (verrou, suppression refusée, droits du titulaire), self-service (champ importé et champ fermé, écriture auditée, demande acceptée puis refusée, une seule en attente), instantanés (pris et comparés, sans champ chiffré ni numéro, non réécrits, purgés), tâche DSI (société intranet seulement).
    • Étape 5 : circuit des contrats (M § 0.6 et § 1.6, conception § 2.7, 2.8, 6.1, 6.4 ; une migration d'index 20261011063401_Lot14_JournalContratsIndex, tables posées à l'étape 3). Service HumanResources/ContractService (singleton, IDbContextFactory), IApprovalHandler enregistré en DI sur la même instance et résolu par RequestService en IEnumerable (L14-Q17 : aucun cycle de dépendance, ContractService ne connaît pas RequestService).
      • Règles de relance ContractReminderRule (une par société et genre ContractEnd / TrialEnd : seuils « 45, 32 » et « 10, 7, 4 » par défaut, N+1 et RH informés, employé non, tâche créée) : RulesAsync (hr.contracts ou hr.manage, défauts rendus pour un genre sans ligne), SaveRuleAsync (hr.contracts, liste normalisée, Error.DaysBeforeInvalid, audit hr.contract.rule à compteurs). Bloc Règles de relance de la vue Contrats du module RH (HrContractRulesPanel, sans route).
      • Passe de relances ReminderPassAsync (appelée par DailyAsync une fois par jour, Hr.ContractReminderLastRun, après les alertes de documents dans MailOutboxWorker). Par société aux règles actives : contrats des dossiers présents, sans contrat postérieur ni décision appliquée ; fin de contrat (non CDI, EndDate du jour ou à venir) et fin d'essai (TrialEndDate du jour ou à venir) ; une échéance passée n'est jamais relancée (anomalie signalée par l'import). Une ligne ContractReminderLogs par seuil atteint (index unique (ContractId, Kind, DaysBefore) filtré sur les genres 1 et 2) ; notifications (destinataires de la règle ; SourceApp = HR, ExternalId = contract:{id}:end|trial:{seuil}, Hr.Notif.ContractEnding / TrialEnding), tâche HR idempotente au premier porteur actif de hr.manage (à défaut au N+1) si CreateTask, et demande CONTRACT_RENEWAL / CONTRACT_TRIAL au premier seuil franchi seulement (une par contrat) : brouillon créé directement en base au nom du N+1 utilisable (à défaut de la RH ; sans l'un ni l'autre, tâche seule), OnBehalfOfUserId = compte de l'employé, DataJson.contractId = contrat visé, Number par RequestService.NextNumberAsync. Seuils atteints ensemble (premier passage) : toutes les lignes sont écrites, mais notifications, tâche et demande ne partent qu'une fois, au plus petit seuil (pas de rafale). Le demandeur du brouillon est toujours notifié ; le lien de la notification et de la tâche est la demande (/requests/{id}), sinon le dossier.
      • Demandes CONTRACT_* (types livrés à l'étape 3 : CONTRACT_RENEWAL N+1 puis RH, CONTRACT_TRIAL RH seule). Le brouillon s'ouvre depuis la notification : le N+1 saisit la décision (decision : renouveler, laisser expirer, passer en CDI ; newEndDate ; trialDecision : confirmer, prolonger, mettre fin ; newTrialEndDate) et dépose ; comme demandeur, son étape N+1 est sautée, la RH valide. RequestService.SaveDraftAsync verrouille contractId : la valeur enregistrée est conservée, celle du formulaire ignorée, aucune à la création manuelle (une demande liée à un contrat ne naît que par la passe ou par Décider). Bouton Décider de l'onglet Contrats (hr.contracts, OpenDecisionRequestAsync) : rend la demande en cours du contrat et du genre, sinon ouvre un brouillon au nom du gestionnaire (la ligne de relance qui portait une demande close le suit) ; refus Error.ContractDecisionApplied si une décision est déjà appliquée. Journal des relances et décisions du contrat (RemindersAsync, une requête : genre, seuil, date, demande avec numéro et statut, tâche) affiché dans la fiche.
      • Décision appliquée (ApplyDecisionAsync, crochet IApprovalHandler.OnApprovedAsync à l'approbation finale et rejeu ApplyApprovedDecisionsAsync par DailyAsync pour les demandes Approved sans ligne de genre 3 / 4 : marqueur persistant, aucune exception du crochet ne perd une décision). Société gérée par l'intranet : renouveler → contrat courant clos à son EndDate, contrat suivant (StartDate = EndDate + 1, EndDate = newEndDate ou même durée, Kind et Classification recopiés, Note = numéro) ; laisser expirer → note ; CDI → contrat suivant Permanent ; essai : confirmer → note, prolonger → TrialEndDate, mettre fin → EndDate du jour (la sortie du dossier reste à la RH). Société gérée par IES : aucune écriture (M § 0.6) : tâche HR contract:{id}:decision:{requestId} au premier porteur de hr.manage et notification à toute la RH « à saisir dans IES, référence <numéro> » ; le contrat revient par l'import. Dans les deux cas une ligne de genre 3 / 4 (RequestId unique parmi les lignes de décision, index filtré par 20261011063401_Lot14_JournalContratsIndex ; la relance garde sa demande) et un audit hr.contract.decision à compteurs. Un contrat déjà décidé qui reçoit une seconde demande approuvée ne reçoit qu'un marqueur (la première décision reste, le rejeu ne boucle pas).
      • Tests Lot14ComplementsRhTests.Etape5.cs (11 : 8 de la conception, 3 de la critique) : relance de contrat et d'essai une fois par seuil (exclusions, seuils simultanés, règle modifiée ou désactivée, journal de l'onglet, index unique, passage quotidien), relance sans N+1 à la RH (et société sans type actif), demande créée au premier seuil (verrou contractId, dépôt, Décider, annulation), décision société intranet (renouveler, CDI, essai prolongé, essai terminé), décision société IES (tâche, notification, aucune écriture), décision appliquée une fois (rejeu, marqueur, demande sans contrat), SaveTypeAsync ; test des types livrés de l'étape 3 complété.
    • Étape 6 : agent 1.2.0 et import v3 (M § 3, conception § 5 ; aucune migration ; contrat additif : docs/INTEGRATION-ERP.md § 17 bis, notice docs/AGENT-ERP.md, liste blanche docs/ERP-SCHEMA-RH.md § 10). Un agent 1.1.0 reste accepté par la 1.23.0 ; l'agent 1.2.0 exige la 1.23.0 (404 sur references face à une 1.22.0 : cycle arrêté).
      • Routes et DTO (Shared/Contracts/HrImportContracts.cs, miroir Agent/Relay/ImportContracts.cs, comparés par réflexion) : POST hr/import/references?dryRun= (groupe, lot hr.references, 500 : HrImportReference — externalCode « IES:
    • : », kind, code, label, companyExternalCode pour un grade, labels, sortOrder, isActive) ; POST hr/import/documents?companyExternalCode=&dryRun= (société, lot hr.documents, 500 : HrImportDocument — type, statut, pays, zone, entrées, dates, libellé ; jamais un numéro) ; HrImportEmployee.Classifications[] (HrImportClassification : genre, référence, code externe « IES:CE » pour une ligne datée, dates) et HrImportEmployee.ExitReasonExternalCode ; HrImportContract.Active. Codes de rejet et d'avertissement v3 dans HrImportReasons (25). Dix routes d'import au total ; prérequis de l'agent : references après companies, avant people et employees ; documents après employees.
    • Référentiels importés (HrImportService.References.cs) : rapprochement par code externe, puis par libellé normalisé (HrReferenceCodes.Normalize) d'un item sans code externe de même genre et même portée (adoption : reference_adopted ; les motifs de sortie de l'amorçage System.Upgrade.Lot14 reçoivent ainsi leur clé IES), sinon création (code_taken si le code proposé est pris) ; un item fermé (ValidTo) n'est ni rouvert ni modifié (reference_closed) ; un seul SaveChanges ; rien n'est supprimé. L'item importé est verrouillé (code, libellé) quand sa portée est gérée par IES (étape 3).
    • Documents importés (HrImportService.Documents.cs) : garde-fou serveur 400 unexpected_field sur le JSON brut (toute propriété dont le nom contient « number » ou « numero », objets imbriqués compris) avant toute lecture en base ; dossier de la société de l'appel par code externe (employee_unknown à renvoyer, erp_not_master si non géré par IES) ; EmployeeDocument de catégorie Identity, sans fichier, déposé par le compte 0 à l'heure de l'import ; mise à jour du type, du statut, du pays, de la zone, des entrées et des dates, du libellé seulement s'il est reçu ; AlertDaysOverride et le journal EmployeeDocumentAlerts jamais touchés ; jamais supprimé par l'import (M § 0.9), verrouillé tant que la société est gérée par IES (étape 4) ; dates avant 1900 ignorées (date_out_of_range), dates_inverted et country_code_suspect signalés, jamais corrigés.
    • Classements importés (HrImportService.Employees.cs) : lignes datées (Grade, « IES:CE ») créées ou mises à jour telles que reçues, Source = IES ; lignes non datées (Category, Qualification, College, EmployeeType) : la ligne en vigueur du genre est gardée si elle vise le même item, sinon close la veille et remplacée ; filet de l'index « une ligne ouverte par (dossier, genre) » ; rien n'est supprimé ; unknown_reference (à renvoyer) ignore la ligne et importe le reste du dossier ; classification_overlap signalé, jamais corrigé. ExitReasonId résolu par exitReasonExternalCode (aucun repli par libellé). Instantané « Import » (EmployeeService.TakeSnapshotAsync, EmployeeService injecté en paramètre facultatif de HrImportService) quand la fiche change.
    • Contrats (HrImportService.Contracts.cs) : active lu tel quel ; contract_expired_active (actif à fin passée) et contract_overlap (périodes croisées avec les contrats existants du dossier ou les lignes du lot) comptés dans les avertissements, jamais corrigés (M § 0.6) ; l'état intranet reste déduit des dates.
    • Agent 1.2.0 (src/Softic.Intranet.Agent, version 1.2.0, format de configuration 2 inchangé) : liste blanche erp de 27 tables et 101 colonnes (ReadOnlyGuardTests.Whitelists["erp"] = @lectures de docs/SQL-LOGIN-LECTURE-ERP.sql, test Script_sql_egal_liste_blanche) ; douze sentinelles (ErpSource.ErpSentinels, L14-Q14) contrôlées à chaque cycle, repli L14-Q21 par SELECT COL_LENGTH(@table, @name) (seule lecture du catalogue admise) quand HAS_PERMS_BY_NAME rend 0 ; neuf requêtes nouvelles (une par table, jamais COUT_HEURE, ID_Echelon, Echelon, un numéro, un lieu de délivrance ni un téléphone : test Aucune_colonne_de_numero_ni_telephone) ; règles pures GroupRules (codes externes, codes courts CAT/GRD/QUAL/COL/TYP/MOT, correspondances de types et de statuts L14-Q11, classements par dossier, documents par société du dossier, anomalies de contrats) ; cycle : references (temps 4 bis) et documents (temps 9 bis) ; erp-check : compteurs du § 5.5 de la conception ; état state.json format 2 enrichi des dictionnaires References et Documents. Dix routes dans Relay/IntranetClient.cs.
    • Tests : serveur Lot14ComplementsRhTests.Etape6.cs (12 : référentiels deux fois unchanged, adoption par libellé, fermé non rouvert ; documents deux fois unchanged, numéro refusé, dossier inconnu à renvoyer, verrou et non-suppression ; classement daté et chevauchement, référence inconnue à renvoyer, ExitReasonId, instantané ; contrats expired_active et overlap) ; agent ErpSyncLot14Tests (6), ContractV3Tests (2), ReadOnlyGuardTests (+3), OptionsLockStateTests (+1), GroupRulesTests (+1, complément : classement_societe_sans_dossier). Suites : 365 et 286.
    • Complément de l'étape 6 (recette de l'agent sur les copies locales) : un grade daté dont la société (EIF_RH_Categorie_Employe.IdSociete) n'a aucun dossier envoyé pour la personne, ou dont le catalogue (EIF_RH_GradeCategory.Id_Societe, ReferencePlan.GradeCompanies) n'est pas la société du dossier cible, n'est pas envoyé par l'agent (compteur classement_societe_sans_dossier) : le serveur le refuserait (unknown_reference, règle « même portée » du référentiel), et la ligne reviendrait « à renvoyer » à chaque cycle.
    • Étape 7 : recette et documentation (aucune migration ; Directory.Build.props 1.23.0 ; docs/COMPTE-RENDU-LOT14.md, étape 7).
      • Recettes ClaudeTest jouées (11 octobre 2026, instance locale softic-web-claudetest, copies locales ERP / INTRANET) : migrations appliquées au démarrage ; amorçage System.Upgrade.Lot14 (8 référentiels, 3 motifs résolus, 56 types, 56 règles, 1 660 instantanés) ; passe quotidienne en situation (40 relances pour 20 contrats de 3 sociétés, sans demande ni tâche faute de compte utilisable) ; agent 1.2.0 (erp-check, dry-run, once par paliers puis ×2 : références 507 créées + 1 adoptée, 811 dossiers classés avec leur instantané « Import », 52 documents créés ; second passage sans écriture, 0 ligne à renvoyer).
      • Finitions de code (sans effet fonctionnel) : EmployeeService.AddDocumentAsync (lot 5) retirée, ses appelants (tests des lots 5 et 13) basculés sur HrDocumentService.AddAsync ; HasSentinel posé sur les défauts EmployeeDocument.DocumentType / Status (avertissement EF 20601 au démarrage éteint, capture du modèle inchangée) ; en-tête Hr.Contract.State de la colonne d'état de la grille Contrats (clé nouvelle × 4 langues).
      • Script de recette deploy/Lot13-Recette-Comptages.ps1, section « Lot 14 » : 70 contrôles (SELECT seulement, « non disponible (colonne absente) » avant la migration) sur les référentiels, classements, motifs de sortie, documents et alertes, instantanés, demandes de modification, règles et journal des contrats, réglages par société et types CONTRACT_*.
      • Décision sur la Note du contrat importé (conception § 6.4, écart de l'étape 6 ; à confirmer par Feki) : en 1.23.0 l'import ne touche pas la Note d'un contrat importé d'IES ; la relance et la décision restent visibles par le journal ContractReminderLogs de la fiche (demande liée, tâche). Si la référence de la demande doit figurer sur le contrat revenu d'IES, la règle recommandée est le rapprochement par dates (même dossier, StartDate = newEndDate + 1 de la décision), dans un lot ultérieur ; IES ne porte aucune référence de demande.
      • Reste hors lot : réglables par société seulement avec la bascule de leurs lecteurs, Attendance.*, Hr.LeaveDaysPerYear, Hr.Holidays, Hr.Weekend (lots 16 à 18) ; TimeEntry.EmployeeId (lot 15, liaison Badge) ; PUT api/v1/hr/me (PWA / MAUI).
    • Lot 15 : liaison Softic.Badge et pointage physique (v1.24.0)

      Mission docs/MISSION-LIAISON-SOFTIC-BADGE.md v3.0 (décisions intangibles § 0 : l'intranet est la référence du pointage ; Badge ne pilote aucun appareil tant que ZKAccess est en place ; rien n'est supprimé ; agent de site en lecture seule), conception docs/LOT15-CONCEPTION.md, suivi docs/COMPTE-RENDU-LOT15.md. Lot terminé côté Claude le 11 octobre 2026 en méthode allégée (code inline, relecture avant chaque commit).

      • Modèle (migration Lot15_LiaisonBadge, amorçage System.Upgrade.Lot15) : TimeEntry porté par le dossier (EmployeeId, UserId facultatif), colonnes des appareils (EmployeeNumber, ExternalId unique, DeviceSerial, DeviceName, SiteCode, VerifyMode, Direction, Collector, IgnoredUtc), ClockKind.Pass, WorkSite.Code (neuf sites EPPM amorcés), AppRegistration.SsoRoleRules.
      • API d'intégration (Api/Controllers/AttendanceIntegrationController.cs, portée attendance, client Integrations:Badge) : POST attendance/punches (lots de 500, idempotence par externalId puis événement physique, rattachement par matricule sur le groupe de la clé, non rattachés conservés, dryRun) et GET hr/employees (format Badge, sans numéro de carte) ; route POST hr/import/punches de l'agent ERP, même réception (Collector = ies).
      • Services (Engine/Engagement/AttendancePunchService.cs) : réception, non rattachés (lier, ignorer, audités), rapprochement quotidien, état de la liaison ; AttendanceService par dossier (présence d'un jour avec appareils = premier et dernier passage) ; SsoService.RoleOf (revendication role du ticket par règles d'application).
      • Écrans : Pointage (source, appareil, sens, filtre, onglet Non rattachés), Intégrations (bloc Softic.Badge), Applications (Rôles SSO).
      • Softic.Badge 1.1.0 (dépôt Badge) : chemins api/integration/v1, réponse du lot ligne à ligne, synchronisation du personnel sans réécriture du PIN ni commande vers les appareils (D13), curseur updatedSince, remise en file des pointages ignorés, SSO par ticket (/sso, /sso/status), agent de site : pointeuse DIT exclue, verifyMode 2 ; migration Lot15Liaison à générer par Feki.
      • Agent ERP 1.3.0 : route punches (badgeages d'IES par pages d'identifiants, dédoublonnage, sens par RH_Ports_Pointeuses.Type, état PunchExport, once --routes punches pour l'historique 2024-09-01 → 2026-09-01, 1 203 lots), erp-check « Badgeages IES ».
      • Tests : intranet 384, agent 291, Badge 148 ; recettes ClaudeTest (migration, API par curl, écrans, agent 1.3.0 sur un mois réel : 28 195 pointages, 1 903 non rattachés, 0 rejet).
      • Reste à Feki : fenêtre de déploiement de Badge 1.1.0 (L15-Q13), sites des filiales et couverture (L15-Q14), migration Badge, liste blanche Type chez EPPM, client et application BADGE, paquets ; sens des tourniquets (P17), alimentation d'IES après ZKAccess (P19).
      Softic.Academy 1.0.0, compilée le 07/11/2026 20:47 Flux RSS · SOFTIC — Software Intelligence Company