Premiere commit - Omika

This commit is contained in:
2026-10-23 18:46:50 +02:00
commit 8ea76c1b24
2155 changed files with 984301 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+310500
View File
File diff suppressed because it is too large Load Diff
+638
View File
@@ -0,0 +1,638 @@
# Marche à suivre — Mise en production API de téléchargement des langues (modèles ONNX)
> **Objet** : procédure **complète** de mise en production de l'**API dédiée** de
> téléchargement des **modèles de langue ONNX** (traduction hors-ligne pour le
> mobile). Cette API est un **service Windows séparé** (`OmikaLanguesApi`, projet
> `src/Omika.Langues.Api`), distinct d'`OmikaApi`. Elle sert au mobile les fichiers
> de modèle + un manifeste versionné, **en accès anonyme** (fichiers génériques,
> aucune donnée client). L'**URL de base reste lue en base** (`ParametreSysteme`
> `PAS_Code = 'SRV_LANGUES'`) par `OmikaApi`, qui l'expose au bootstrap mobile.
>
> **Cible** : Windows Server `SRV-OMIKA`, PowerShell **administrateur**, runtime
> .NET 10. Aucune base de données, aucune authentification, aucun service externe.
>
> Ce document **complète** `T15-RUNBOOK-DEPLOIEMENT-PRODUCTION.md` et
> `MISE_EN_PRODUCTION_DIAGNOSTICS_V2.md` : il ne couvre que le **service Langues**.
> `OmikaApi` garde uniquement la lecture `SRV_LANGUES` → `LanguesUrl` au bootstrap.
---
## 0. Vue d'ensemble de l'API Langues
| Composant | Rôle | Référence |
|---|---|---|
| `Omika.Langues.Api` | **Service dédié** : endpoints `GET /api/langues*` (manifeste + streaming). | Windows Service `OmikaLanguesApi` |
| `Omika.Core` | `LanguageModelService` (lecture manifeste + résolution fichier) + `LanguageModelOptions` + contrats partagés. | — |
| `Langues:RootPath` | Dossier racine des modèles + du manifeste. | variable d'env. `Langues__RootPath` |
| `ParametreSysteme.SRV_LANGUES` | URL de base du service Langues, lue par `OmikaApi`. | HFSQL (Omika **et** Batik) |
Flux :
```
1. GET /api/mobile/bootstrap ──> OmikaApi lit SRV_LANGUES ──> LanguesUrl (→ URL du service Langues)
2. GET {LanguesUrl}/api/langues ──> manifeste (packs + fichiers + sha256) [anonyme]
3. GET {LanguesUrl}/api/langues/{code}/{sens}/{fichier} ──> streaming (reprise) [anonyme]
```
- `SRV_LANGUES` absent/vide → `LanguesUrl = null` → le mobile désactive la
traduction hors-ligne (aucun appel, aucun plantage).
- Les textes à traduire ne sortent **jamais** de l'appareil : traduction 100 %
locale une fois le modèle téléchargé.
---
## 1. Ce qui a changé
1. **Nouveau service dédié** `OmikaLanguesApi` (`src/Omika.Langues.Api`) : les
endpoints langues ne sont **plus** dans `OmikaApi`.
2. **Accès anonyme** : aucun token requis (les modèles sont des fichiers génériques).
3. **Nouvelle section de configuration** : `Langues` (`RootPath`, `ManifestFileName`),
portée par le service `OmikaLanguesApi`.
4. **`OmikaApi`** ne conserve que la lecture `SRV_LANGUES` → `MobileConfigurationDto.LanguesUrl`.
5. **Nouveaux fichiers à déployer sur disque** : `manifest.json` + modèles ONNX
(jamais embarqués dans l'APK/IPA ni dans un binaire).
> Le **client de téléchargement ONNX côté mobile** (téléchargement, reprise,
> vérification SHA-256, stockage local) relève d'un **ticket ultérieur** : ce
> document ne couvre que le **serveur** (service Langues + exposition `SRV_LANGUES`).
---
## 2. Prérequis
### Poste DEV (publication)
```powershell
cd D:\Avalonia\Omika
dotnet --version # SDK 10 (global.json épingle 10.0.302)
git pull # branche à jour
```
### SRV-OMIKA (PowerShell administrateur)
```powershell
net session # OK sinon STOP
dotnet --list-runtimes # Microsoft.AspNetCore.App 10.x
Get-Service OmikaApi # Running (bootstrap métier)
Test-Path 'C:\Omika\Data' # dossier de données (langues)
```
> `OmikaLanguesApi` **n'a besoin d'aucun accès HFSQL** : uniquement du dossier de
> fichiers `Langues:RootPath`.
---
## 3. Publication (poste DEV)
```powershell
cd D:\Avalonia\Omika
# Omika.Api (métier : lit SRV_LANGUES → LanguesUrl ; endpoints langues retirés)
dotnet publish src\Omika.Api\Omika.Api.csproj `
-c Release -r win-x64 --self-contained false -o C:\Omika\Deploy\OmikaApi-next
# Omika.Langues.Api (NOUVEAU service dédié)
dotnet publish src\Omika.Langues.Api\Omika.Langues.Api.csproj `
-c Release -r win-x64 --self-contained false -o C:\Omika\Deploy\LanguesApi-next
```
**Contrôles** : chaque dossier contient son `.exe` ; `appsettings.Development.json`
absent ; aucun secret Dev dans les publications.
Transférer `OmikaApi-next` et `LanguesApi-next` vers `C:\Omika\Deploy\` sur SRV-OMIKA.
---
## 4. Base HFSQL — paramètre `SRV_LANGUES`
`OmikaApi` lit l'URL depuis la table `ParametreSysteme` (schéma Omika **et** Batik —
rubriques `PAS_Code` / `PAS_Valeur`, groupe `ProtectionPlus`).
| Rubrique | Valeur |
|---|---|
| `PAS_Code` | `SRV_LANGUES` |
| `PAS_Description` | ex. « URL du service de téléchargement des modèles de langue » |
| `PAS_Valeur` | URL de base du **service Langues**, **sans slash final** |
Exemples de `PAS_Valeur` (pointe vers le service `OmikaLanguesApi`) :
- Production derrière Apache (vhost/hôte dédié) : `https://langues.applilogik.fr`
- Chemin dédié derrière l'Apache existant : `https://omika.applilogik.fr/langues`
- API interne directe : `https://omika-langues.local:8443`
> Le slash final est toléré (l'API le normalise), mais **ne pas le mettre** évite
> toute ambiguïté. La valeur doit être une URL **HTTPS** joignable par le mobile.
**Écriture** : même mécanisme que `SRV_LICENCE_MOBILE` existant — côté WinDev
`ParametreSystemeEcrit("SRV_LANGUES", "<url>")`, ou insertion/UPDATE direct via
l'outil de gestion HFSQL. La valeur n'est **jamais** codée en dur dans l'application.
---
## 5. Fichiers modèles + manifeste (sur disque)
### 5.1 Arborescence sous `RootPath`
```
Langues/
├── manifest.json
├── es/
│ ├── versFrancais/
│ │ ├── encoder.onnx
│ │ └── vocab.spm
│ └── depuisFrancais/
│ ├── encoder.onnx
│ └── vocab.spm
├── pt/
│ ├── versFrancais/...
│ └── depuisFrancais/...
└── ar/ ...
```
- `{code}` : code ISO de la langue (`es`, `pt`, `ar`, `ru`, `pl`, …).
- `{sens}` : **deux valeurs canoniques** uniquement :
- `versFrancais` → langue secondaire → français (dictée, avant insertion) ;
- `depuisFrancais` → français → langue secondaire (lecture, affichage seul).
### 5.2 Fichier `manifest.json` (clés en camelCase)
```json
{
"packs": [
{
"code": "es",
"libelle": "Espagnol",
"version": "1.0.0",
"fichiers": [
{
"sens": "versFrancais",
"nomFichier": "encoder.onnx",
"tailleOctets": 122000000,
"sha256": "a1b2c3d4...<empreinte hexadécimale SHA-256>"
},
{
"sens": "versFrancais",
"nomFichier": "vocab.spm",
"tailleOctets": 804000,
"sha256": "e5f6a7b8..."
},
{
"sens": "depuisFrancais",
"nomFichier": "encoder.onnx",
"tailleOctets": 122000000,
"sha256": "11121314..."
}
]
}
]
}
```
- `url` n'est **pas** à renseigner : l'API la construit
(`/api/langues/{code}/{sens}/{nomFichier}`).
- `tailleOctets` = taille réelle en octets (progression mobile).
- `sha256` = empreinte SHA-256 **hexadécimale** (intégrité).
### 5.3 Générer les empreintes SHA-256 (PowerShell)
```powershell
Get-FileHash -Algorithm SHA256 .\es\versFrancais\encoder.onnx |
ForEach-Object { $_.Hash.ToLowerInvariant() }
```
### 5.4 Générer le manifeste (script — recommandé)
`tailleOctets` et `sha256` sont des valeurs **mesurées sur les fichiers**, jamais
saisies à la main : une empreinte fausse ou absente ne se voit **que sur le
téléphone**, où le pack est refusé sans qu'aucune erreur ne remonte au serveur.
```powershell
# Écrit <RootPath>\manifest.json depuis les fichiers réellement présents
.\infra\deploy\New-OmikaLanguesManifeste.ps1 -RootPathLangues C:\Omika\Data\Langues
# Contrôle après chaque ajout/remplacement de modèle : code 1 au moindre écart
.\infra\deploy\New-OmikaLanguesManifeste.ps1 -Verifier
```
- La racine par défaut est lue dans `Langues__RootPath` (MACHINE) : le manifeste
est donc écrit **là où l'API le lit** (§6).
- Le script **refuse d'écrire** (code 1) un manifeste vide, et signale en clair
un code de langue invalide, un dossier de sens inconnu, un fichier absent, une
taille ou une empreinte divergente. Ces cas produisent sinon un pack
**silencieusement ignoré** (code invalide) ou **refusé** (empreinte) par le
mobile.
- `-Verifier` ne demande aucun droit administrateur : c'est le contrôle à passer
dans la foulée de toute livraison de modèle.
- `-WhatIf` affiche le manifeste qui serait écrit, sans rien modifier.
> Le format de §5.2 reste la référence ; le script ne fait que le remplir avec
> des valeurs exactes. À noter : le mobile compare `sens` de façon **sensible à
> la casse** (`versFrancais` / `depuisFrancais`) — le script normalise cette
> casse et le signale s'il la corrige.
---
## 6. Configuration serveur (`Langues`)
Règle : en variable d'environnement, `:` devient `__`
(`Langues:RootPath` ⇔ `Langues__RootPath`).
Toute modification d'une variable du service exige un **redémarrage** d'`OmikaLanguesApi`.
| Variable | Défaut | Rôle |
|---|---|---|
| `Langues__RootPath` | `<dossier de l'application>\Langues` | Dossier racine des modèles + manifeste |
| `Langues__ManifestFileName` | `manifest.json` | Nom du fichier manifeste |
| `ASPNETCORE_URLS` | `http://localhost:5000` | Adresse/port d'écoute Kestrel (HTTP loopback derrière Apache) |
| `ASPNETCORE_ENVIRONMENT` | `Production` | Environnement (recommandé) |
> Le défaut de `RootPath` est le **dossier de l'application** (résolu via
> `AppContext.BaseDirectory`), jamais le répertoire de travail courant — un service
> Windows démarre dans `C:\Windows\System32`, ce qui rendrait ce dernier inutilisable.
>
> **Recommandation production** : fixer `Langues__RootPath` sur **`C:\Omika\Data\Langues`**
> (cohérent avec les autres données sous `C:\Omika\Data`). Ne **jamais** placer les
> modèles dans `C:\Omika\Apps\LanguesApi` : ils seraient écrasés à chaque
> redéploiement (voir §7.4).
>
> **Port** : voir **§6bis** — le port n'est **pas** imposé par ce document et ne
> doit **jamais** être repris d'un exemple : il se décide contre le plan de ports
> Apache, une fois, et s'écrit ensuite partout à l'identique.
---
## 6bis. Choix du port loopback (à décider AVANT §7.5bis)
**Aucun port n'est fixé par ce document.** La convention Apache/Kestrel du parc
`omika.applilogik.fr` n'est **pas consignée dans le dépôt** : elle ne peut donc pas
être devinée ici. Elle doit être relevée sur le serveur, puis appliquée.
Ports déjà connus du dépôt — à ne **pas** réutiliser :
| Port | Service | Source |
|---|---|---|
| 5097 | `Omika.Api` (développement) | `docs\start_omika_test.ps1` |
| 5098 | `Omika.Diagnostics.Api` | `DEPLOIEMENT_PRODUCTION_DIAGNOSTICS.md` §4 |
| 5080 | `Omika.Diagnostics.Web` | `DEPLOIEMENT_PRODUCTION_DIAGNOSTICS.md` §4 |
| 5000 | Kestrel par défaut (si `ASPNETCORE_URLS` absente) | — |
### Procédure (à exécuter sur le serveur)
```powershell
# <PORT_LANGUES> : à remplacer par le port candidat avant exécution.
$portLangues = <PORT_LANGUES>
# 1. Le port candidat est-il déjà en écoute ?
Get-NetTCPConnection -LocalPort $portLangues -State Listen -ErrorAction SilentlyContinue |
Select-Object LocalAddress,LocalPort,OwningProcess
# 2. Tranches réservées par Hyper-V / WSL / Docker (elles peuvent être n'importe où)
netsh int ipv4 show excludedportrange protocol=tcp
# 3. Plage éphémère Windows : le port fixe doit rester EN DESSOUS
netsh int ipv4 show dynamicport tcp
# 4. Ports Omika réellement en écoute (vue d'ensemble)
Get-NetTCPConnection -State Listen | Where-Object LocalPort -in 5097,5098,5080,$portLangues
```
### Critères d'acceptation du port
1. **Libre** : aucune écoute sur `127.0.0.1` (étapes 1 et 2).
2. **Hors plage éphémère** : en dessous de la borne basse de l'étape 3
(49152 par défaut) — sinon une socket sortante peut le préempter.
3. **Conforme au plan Apache** : le vhost proxy doit pointer vers **ce** port.
4. **Un seul chiffre dans tout le déploiement** : `ASPNETCORE_URLS`, `ProxyPass` et
`SRV_LANGUES` doivent décrire le même service. Une divergence produit un routage
silencieux vers le mauvais service (404, ou réponse d'un autre service).
> **Sécurité** : Kestrel reste lié à `127.0.0.1` (HTTP), jamais à `0.0.0.0`.
> L'exposition publique se fait **uniquement** en HTTPS par le reverse proxy Apache.
> Le numéro choisi n'a aucune incidence sur ce point : c'est l'adresse de liaison
> qui protège le service, pas le port.
---
## 7. Marche à suivre détaillée (fenêtre de maintenance)
### 7.1 Audit (lecture seule)
```powershell
Get-Service OmikaLanguesApi -ErrorAction SilentlyContinue | Select-Object Name,Status
[Environment]::GetEnvironmentVariable('Langues__RootPath','Machine')
Test-Path 'C:\Omika\Data\Langues\manifest.json'
```
### 7.2 Backup des binaires et des données (AVANT remplacement)
```powershell
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$root = 'C:\Omika\Backups'
# Binaires : seulement s'ils existent déjà
if (Test-Path 'C:\Omika\Apps\LanguesApi') {
Copy-Item 'C:\Omika\Apps\LanguesApi' -Destination "$root\LanguesApi_${stamp}" -Recurse -Force
}
if (Test-Path 'C:\Omika\Apps\OmikaApi') {
Copy-Item 'C:\Omika\Apps\OmikaApi' -Destination "$root\OmikaApi_${stamp}" -Recurse -Force
}
# Données langues : seulement si le dossier existe déjà
if (Test-Path 'C:\Omika\Data\Langues') {
Copy-Item 'C:\Omika\Data\Langues' -Destination "$root\Langues_${stamp}" -Recurse -Force
}
```
### 7.3 Arrêt des services
```powershell
Stop-Service OmikaLanguesApi -Force -ErrorAction SilentlyContinue
Stop-Service OmikaApi -Force
Get-Service OmikaApi,OmikaLanguesApi | Select-Object Name,Status
```
### 7.4 Copie des binaires (préserve la config serveur existante)
```powershell
robocopy 'C:\Omika\Deploy\OmikaApi-next' 'C:\Omika\Apps\OmikaApi' /E /XF appsettings.json appsettings.Production.json web.config
robocopy 'C:\Omika\Deploy\LanguesApi-next' 'C:\Omika\Apps\LanguesApi' /E /XF appsettings.json appsettings.Production.json web.config
```
> Ne **jamais** supprimer : `C:\Omika\Config\**`, `C:\Omika\Data\**` (dont
> `C:\Omika\Data\Langues\**`), certificats TLS, variables d'environnement MACHINE.
### 7.5 Création du service `OmikaLanguesApi` (NOUVEAU — premier déploiement)
Le service se crée par le **script dédié** (recommandé : idempotent, vérifie le
compte, le droit de connexion et la disponibilité du port, prend en charge
`-WhatIf` et `-Desinstaller`) :
```powershell
cd C:\Omika\Deploy\Deploy
.\Install-OmikaLanguesApi.ps1 -Port <PORT_LANGUES>
```
> Le script résout le compte, pose les ACL (**lecture seule** sur les modèles),
> vérifie que `<PORT_LANGUES>` est réellement libre, puis crée le service
> `OmikaLanguesApi` (Manual, non démarré). Il n'écrit **aucune** variable
> d'environnement : la configuration reste l'étape §7.5bis ci-dessous.
Équivalent manuel (si vous préférez ne pas utiliser le script) :
```powershell
$cred = Get-Credential -UserName '.\svc_omika_langues' -Message 'Compte service OmikaLanguesApi'
New-Service -Name 'OmikaLanguesApi' -DisplayName 'Omika Langues Api' `
-BinaryPathName '"C:\Omika\Apps\LanguesApi\Omika.Langues.Api.exe"' `
-Credential $cred -StartupType Manual
# ACL sur le dossier des modèles (lecture seule suffit)
icacls 'C:\Omika\Data\Langues' /grant:r "$env:COMPUTERNAME\svc_omika_langues:(OI)(CI)R"
```
> Le compte dédié doit avoir le droit « Ouvrir une session en tant que service »
> (le script le vérifie et avertit si le droit manque — erreur 1069 au démarrage).
>
> **Prérequis binaire** : le service doit embarquer `UseWindowsService()`. Sans
> lui, il démarre puis s'arrête aussitôt avec l'erreur **1053** (« le service n'a
> pas répondu à temps »). C'est le cas depuis l'ajout de
> `Microsoft.Extensions.Hosting.WindowsServices` à `Omika.Langues.Api`.
>
> `infra\deploy\Install-OmikaServices.ps1` couvre désormais ce service, via
> l'option `-InclureLangues` (voir §11).
### 7.5bis Configuration du service (variables d'environnement MACHINE)
**Premier déploiement uniquement** : poser la configuration en variables
d'environnement **MACHINE** (le robocopy §7.4 a exclu `appsettings.json`, donc le
service démarre sans fichier de config). Toute modification exige ensuite un
**redémarrage** du service.
```powershell
# <PORT_LANGUES> : port décidé en §6bis — NE PAS copier une valeur d'exemple.
$portLangues = <PORT_LANGUES>
[Environment]::SetEnvironmentVariable('Langues__RootPath', 'C:\Omika\Data\Langues', 'Machine')
[Environment]::SetEnvironmentVariable('ASPNETCORE_URLS', "http://127.0.0.1:$portLangues", 'Machine')
[Environment]::SetEnvironmentVariable('ASPNETCORE_ENVIRONMENT', 'Production', 'Machine')
# Contrôle
[Environment]::GetEnvironmentVariable('Langues__RootPath', 'Machine')
[Environment]::GetEnvironmentVariable('ASPNETCORE_URLS', 'Machine')
```
> - `Langues__RootPath` : **obligatoire** en production (défaut = dossier de
> l'application, qui serait écrasé au prochain déploiement).
> - `ASPNETCORE_URLS` : port loopback **explicite**, décidé en **§6bis** (ne jamais
> recopier un exemple : le port se déduit du plan Apache, pas de ce document).
> Sans cette variable, Kestrel écoute `http://localhost:5000`.
> - Le reverse proxy Apache expose ensuite ce port en **HTTPS** ; `SRV_LANGUES`
> pointe sur l'URL publique (voir §4 et §7.5ter).
### 7.5ter Exposition HTTPS par le reverse proxy Apache
Le service écoute en HTTP sur `127.0.0.1` : il est inatteignable depuis un
téléphone. L'exposition se fait dans le vhost 443 **existant**.
`SRV_LANGUES` décrit l'**URL de base** seule : le mobile construit ensuite
`{base}/api/langues` puis `{base}/api/langues/{code}/{sens}/{fichier}`
(`LanguagePackService.ConstruireUrl`, qui concatène — un chemin dans la base est
donc conservé). Pour `SRV_LANGUES = https://omika.applilogik.fr/langues`, Apache
doit donc **retirer** le préfixe `/langues` avant de transmettre au service :
```apache
# C:\Apache24\conf\extra\httpd-ssl.conf — dans le <VirtualHost ...:443>
# Service Langues : /langues/api/langues -> http://127.0.0.1:5099/api/langues
ProxyPass /langues/ http://127.0.0.1:5099/
ProxyPassReverse /langues/ http://127.0.0.1:5099/
ProxyPass /langues http://127.0.0.1:5099/
ProxyPassReverse /langues http://127.0.0.1:5099/
```
Puis :
```powershell
& C:\Apache24\bin\httpd.exe -t # doit dire « Syntaxe OK »
Restart-Service <service Apache>
```
Contrôles (le `--resolve` évite le DNS : depuis le serveur, le nom public résout
souvent vers une IP externe qui ne revient pas sur la machine) :
```powershell
$U = 'https://omika.applilogik.fr'
$C = @('--resolve','omika.applilogik.fr:443:127.0.0.1','-k','-s','-o','NUL','-w','%{http_code}\n')
curl.exe @C "$U/langues/api/langues" # 200
curl.exe @C "$U/langues/api/langues/gl/commun/vocab.json" # 200
curl.exe @C "$U/health" # 200 : API métier intacte
$j = (curl.exe -k -s --resolve omika.applilogik.fr:443:127.0.0.1 "$U/langues/api/langues") -join ''
($j | ConvertFrom-Json).packs.Count # 31
```
> - **Le préfixe `/langues` n'est pas décoratif** : la règle existante
> `ProxyPass /api/ http://127.0.0.1:5080/api/` capte `/api/langues` et
> l'envoie à l'API métier, qui ne sert plus les langues (404). Un préfixe
> dédié évite de toucher à cette règle métier et de dépendre de l'ordre des
> `ProxyPass`.
> - Variante vhost dédié (`https://langues.applilogik.fr`) : la règle devient
> `ProxyPass /api/langues http://127.0.0.1:5099/api/langues` (sans préfixe à
> retirer), et `SRV_LANGUES = https://langues.applilogik.fr`.
> - `$U/langues` seul répond `404` du service : il n'y a pas de route `/`, c'est
> normal et sans conséquence (le mobile n'appelle que les chemins `/api/...`).
> - Un `404` sur `$U/langues/api/langues` signifie que la règle n'a pas été
> chargée (fichier non réécrit, ou Apache non rechargé) — ce n'est pas un
> problème de service : `http://127.0.0.1:5099/api/langues` reste alors `200`.
### 7.6 Déploiement des fichiers modèles + manifeste
**Procédure complète : `LIVRAISON_MODELES_LANGUES.md`** (obtention des modèles,
arborescence, dépôt, génération du manifeste, contrôles). Résumé :
```powershell
$root = 'C:\Omika\Data\Langues'
New-Item -ItemType Directory -Force -Path $root | Out-Null
# 1. Copier UNIQUEMENT les dossiers de langue {code}\ (pas de manifest.json
# préparé ailleurs : il dépend des fichiers réellement présents ici)
robocopy 'C:\Omika\Deploy\Langues\es' "$root\es" /E /R:2 /W:2
# 2. GÉNÉRER le manifeste sur le serveur (taille + SHA-256 de chaque fichier)
cd C:\Omika\Deploy\Deploy
.\New-OmikaLanguesManifeste.ps1
# 3. Contrôler
.\New-OmikaLanguesManifeste.ps1 -Verifier
```
> Le manifeste n'est **jamais** recopié d'une autre machine : il contient la
> taille et l'empreinte SHA-256 des fichiers de **ce** disque. Un manifeste
> provenant d'ailleurs fait refuser chaque pack par le mobile, sans aucune
> erreur côté serveur.
>
> Le manifeste et les fichiers modèles sont relus à chaque requête : **aucun
> redémarrage n'est nécessaire**. Le mobile, lui, garde son index mémorisé :
> il faut « Actualiser la liste » dans Paramètres → Langue secondaire.
### 7.7 Création / mise à jour du paramètre `SRV_LANGUES` (HFSQL)
Écrire la ligne `ParametreSysteme` (voir §4) si elle n'existe pas, en pointant vers
l'URL du service Langues. Aucun redémarrage requis : relue à chaque bootstrap mobile.
### 7.8 Redémarrage (consommateurs avant producteurs)
```powershell
Start-Service OmikaLanguesApi # 1. service Langues (d'abord, car appelé par le mobile)
Start-Service OmikaApi # 2. API métier (expose LanguesUrl)
Get-Service OmikaApi,OmikaLanguesApi | Select-Object Name,Status
```
---
## 8. Smoke tests
> **Accès anonyme** : aucun token requis — les réponses sont directes.
> `<PORT_LANGUES>` = le port décidé en **§6bis** et posé en §7.5bis (aucun exemple
> n'est fourni ici : recopier un chiffre d'exemple est précisément l'erreur à éviter).
```powershell
# Racine du service
Invoke-WebRequest 'http://127.0.0.1:<PORT_LANGUES>/' -UseBasicParsing # 200 {status:ok}
# Manifeste
$manifest = Invoke-RestMethod 'http://127.0.0.1:<PORT_LANGUES>/api/langues'
$manifest.packs | Select-Object code,libelle,version
# Téléchargement d'un fichier (valide le streaming + la résolution)
Invoke-WebRequest 'http://127.0.0.1:<PORT_LANGUES>/api/langues/es/versFrancais/encoder.onnx' `
-OutFile "$env:TEMP\encoder.onnx"
Get-Item "$env:TEMP\encoder.onnx" | Select-Object Name,Length # taille = tailleOctets
# Cas d'erreur attendu
Invoke-WebRequest 'http://127.0.0.1:<PORT_LANGUES>/api/langues/es/versFrancais/absent.onnx' -SkipHttpErrorCheck
# → 404
```
Vérifier aussi côté métier : après un bootstrap mobile, `LanguesUrl` vaut bien
l'URL du service Langues (pas `null`).
---
## 9. Mise à jour (ajout de langue / nouvelle version de modèle)
### 9.1 Ajouter une nouvelle langue
1. Déposer les fichiers sous `C:\Omika\Data\Langues\{code}\versFrancais\` et
`{code}\depuisFrancais\`.
2. Ajouter l'objet `pack` dans `manifest.json` (avec `version`, `tailleOctets`, `sha256`).
3. **Aucun redémarrage** : le manifeste est relu à chaque `GET /api/langues`.
### 9.2 Mettre à jour un modèle existant
1. Remplacer le(s) fichier(s) `.onnx` / `.spm` sous `{code}\{sens}\`.
2. Recalculer `tailleOctets` et `sha256` (§5.3).
3. **Incrémenter `version`** du pack (ex. `1.0.0` → `1.0.1`).
> Le mobile compare `version` (et `sha256`) entre son modèle local et le manifeste
> serveur pour décider du re-téléchargement : tant que `version` et `sha256` sont
> inchangés, le mobile considère son modèle à jour.
### 9.3 Retirer une langue
1. Supprimer l'objet `pack` de `manifest.json`.
2. Optionnel : supprimer le dossier `{code}` de `C:\Omika\Data\Langues`.
---
## 10. Rollback
- **Binaires** : restaurer `C:\Omika\Backups\LanguesApi_<stamp>` (arrêt → copie →
démarrage §7.8). Idem `OmikaApi_<stamp>` si besoin.
- **Fichiers langues** : restaurer `C:\Omika\Backups\Langues_<stamp>` (simple copie,
aucune migration à annuler).
- **Paramètre HFSQL** : remettre l'ancienne valeur de `PAS_Valeur` (ou vider
`SRV_LANGUES` → `LanguesUrl = null` → la fonctionnalité est désactivée côté mobile).
---
## 11. Écarts constatés (scripts/documents à mettre à jour)
1. ~~**`infra\deploy\Install-OmikaServices.ps1`** : ne crée **pas** le service
`OmikaLanguesApi` ni ses ACL.~~ **RÉSOLU** : `infra\deploy` contient désormais
**un script par déploiement** (`Install-OmikaApi.ps1`, `Install-OmikaWorker.ps1`,
`Install-OmikaDiagnosticsApi.ps1`, `Install-OmikaDiagnosticsWorker.ps1`,
`Install-OmikaDiagnosticsWeb.ps1`, `Install-OmikaLanguesApi.ps1`) partageant le
module `Omika.Deploy.Common.psm1`. `Install-OmikaServices.ps1` devient un
orchestrateur qui les enchaîne, avec `-InclureLangues` pour le service Langues.
Le service embarque aussi `UseWindowsService()` (sans lui : erreur 1053 au
démarrage).
2. **`infra\deploy\Install-OmikaServerFiles.ps1`** : ne copie **pas** la publication
`LanguesApi` ni le dossier de livraison des modèles — à livrer séparément
(§3, §7.6). La livraison des modèles (obtention, arborescence, manifeste,
contrôles) est décrite pas à pas dans `LIVRAISON_MODELES_LANGUES.md`, et le
manifeste se génère avec `infra\deploy\New-OmikaLanguesManifeste.ps1` (§5.4).
3. **`Omika.slnx`** ne référence **pas** `Omika.Langues.Api` (comme les projets
Diagnostics) : on publie le `.csproj` directement.
4. **Client mobile** : le téléchargement des modèles est **implémenté** côté
application (voir `TRADUCTION_HORS_LIGNE_MOBILE.md`). Le **moteur d'inférence
ONNX Runtime reste à activer** (paquet NuGet optionnel, un symbole de
compilation) : sans lui, l'application télécharge et vérifie les modèles,
mais annonce la traduction comme indisponible au lieu d'échouer.
5. **Plan de ports Apache/Kestrel : non consigné dans le dépôt.** Les seuls ports
connus sont ceux de `DEPLOIEMENT_PRODUCTION_DIAGNOSTICS.md` §4 (5097, 5098,
5080), qui y sont eux-mêmes donnés comme « à définir / à confirmer » en
production. Aucun document ne fixe le port de `OmikaLanguesApi` : il se décide
sur le serveur (§6bis). Tant que ce plan n'existe pas par écrit, **tout port cité
en exemple dans un runbook doit être traité comme un espace réservé à
remplacer**, jamais comme une valeur à recopier.
---
## 12. Récapitulatif des points de configuration
1. **Base HFSQL** : ligne `ParametreSysteme` `PAS_Code = 'SRV_LANGUES'` → URL du service Langues.
2. **Service `OmikaLanguesApi`** : variables MACHINE `Langues__RootPath` → `C:\Omika\Data\Langues` **et** `ASPNETCORE_URLS` → `http://127.0.0.1:<PORT_LANGUES>` (port décidé en **§6bis**, posé §7.5bis).
3. **Fichiers** : arborescence `{code}/{sens}/{fichier}` sous `RootPath`, puis
`manifest.json` **généré** par `New-OmikaLanguesManifeste.ps1` (§5.4) — jamais
saisi à la main.
4. **Binaires** : `Omika.Langues.Api` (nouveau) + `Omika.Api` (republication).
5. **Accès** : anonyme — aucun token, aucun accès HFSQL.
6. **Mise à jour** : ajouter/remplacer les fichiers puis regénérer le manifeste ;
**aucun redémarrage** du service n'est requis (il relit le manifeste à chaque
appel de `GET /api/langues`). Le mobile, lui, garde son index mémorisé : il
faut un rafraîchissement de la liste des langues pour voir le changement.
+446
View File
@@ -0,0 +1,446 @@
# Déploiement Production — Diagnostics multi-serveurs (T19/T20)
> Statut : préparation (aucun déploiement réel effectué). Document de référence pour le
> futur déploiement contrôlé du Diagnostic Center central et de l'API métier/mobile chez
> chaque client. Aucune valeur de secret (clé, mot de passe) n'est contenue ici.
---
## 1. Responsabilités des composants (§30)
| Composant | Rôle | Installation |
|---|---|---|
| **Omika.Api** | API métier/mobile installée **chez chaque client** (licence, bootstrap, métier, `POST /api/diagnostics`). Le mobile ne connaît QUE cette API. | Serveur du client |
| **Omika.Diagnostics.Api** | API **centrale**, installée **uniquement sur le serveur Omika**. Reçoit les lots des serveurs clients (`POST /api/v1/diagnostics/batches`), authentifie (ClientCode + ApiKey), impose le ClientCode authentifié, écrit le spool central. Aucune logique métier. | Serveur central Omika |
| **Omika.Diagnostics.Worker** | Import du spool central JSONL → PostgreSQL (`diagnostic_event`). Déduplication par `event_id`. | Serveur central Omika |
| **Omika.Diagnostics.Web** | Interface de consultation/administration (Dashboard, Diagnostics, Erreurs, Télémétrie, Référentiel, **Clients Diagnostics**, Système). | Serveur central Omika |
Le **mobile** ne connaît jamais `Omika.Diagnostics.Api/Worker/Web`, ni PostgreSQL Diagnostics, ni aucune
clé central. Le **serveur client** ne connaît jamais la ConnectionString PostgreSQL centrale
(§43) : seul le composant central autorisé détient cette configuration.
---
## 2. Architecture de flux
```
APPAREILS MOBILES ──HTTPS──▶ SERVEUR DU CLIENT
Omika.Api (+ Omika.Worker métier)
└─▶ Spool local borné (DiagnosticsForwarding.Spool)
└─▶ DiagnosticsForwarder (BackgroundService dans Omika.Api)
└─ HTTPS ──▶ SERVEUR OMIKA CENTRAL
Omika.Diagnostics.Api
└─▶ Spool central (Diagnostics:Central)
└─▶ Omika.Diagnostics.Worker
└─▶ PostgreSQL omika_diagnostics
└─▶ Omika.Diagnostics.Web
```
---
## 3. Services Windows cibles (§51)
### Serveur client
- `OmikaApi` (process Omika.Api ; contient le forwarder de diagnostics — **aucun service dédié**).
- `OmikaWorker` (Worker de synchronisation métier, service réel existant).
- API Licence / service existant (inchangé).
### Serveur central Omika
- `OmikaDiagnosticsApi` (API centrale, Kestrel).
- `OmikaDiagnosticsWorker` (import JSONL → PostgreSQL).
- `OmikaDiagnosticsWeb` (interface, Kestrel derrière reverse proxy Apache).
- PostgreSQL (`omika_diagnostics`).
Les noms de services sont posés par les scripts d'activation serveur (`sc.exe` / `New-Service`)
et doivent correspondre aux valeurs `UseWindowsService(...)` du code.
---
## 4. Ports (§52)
| Service | Port local Développement | Port Production (à confirmer selon convention existante) |
|---|---|---|
| Omika.Api (client) | 5097 | défini par `Kestrel:Endpoints` / `appsettings.Production.json` (loopback derrière Apache) |
| Omika.Diagnostics.Api (central) | 5098 | à définir (loopback Kestrel derrière Apache/HTTPS) |
| Omika.Diagnostics.Web (central) | 5080 | à définir (loopback Kestrel derrière Apache/HTTPS) |
| PostgreSQL | 5432 | 5432 (loopback central uniquement) |
**Règle** : les services web sont exposés en **HTTPS** via le reverse proxy Apache ; Kestrel reste
en HTTP sur loopback. Aucun port n'est choisi arbitrairement : la convention Apache/Kestrel
existante de `omika.applilogik.fr` s'applique.
---
## 5. HTTPS (§37/§38)
- Le flux **serveur client → API centrale** est **HTTPS uniquement** en Production.
- `DiagnosticsForwarding:Endpoint` doit être une URL **`https://…`**.
- **Règle T20** : si l'environnement est **Production** et que l'Endpoint est en **`http://`**,
le forwarder **refuse l'envoi** (log explicite une fois), le **métier continue** (fail-open),
le spool local est conservé. `Omika.Api` n'est **jamais** arrêté à cause de Diagnostics.
- Le certificat TLS réel n'est **pas** configuré dans ce document (à poser sur le reverse proxy).
---
## 6. Configuration Omika.Api (§31/§32/§33/§34/§35)
Section `DiagnosticsForwarding` (dans `src/Omika.Api/appsettings.json`, surchargée par variables
d'environnement / `appsettings.Production.json` **non versionné**) :
| Clé | Défaut | Rôle |
|---|---|---|
| `DiagnosticsForwarding:Enabled` | `false` | Kill switch. **`DiagnosticsForwarding__Enabled=true` uniquement lors du déploiement serveur client.** |
| `DiagnosticsForwarding:Endpoint` | `""` | URL centrale absolue **HTTPS** (ex. `https://…/api/v1/diagnostics/batches`). Laisser vide tant qu'aucune URL réelle. |
| `DiagnosticsForwarding:ApiKey` | `""` | **Secret** à installer sur le serveur client via `DiagnosticsForwarding__ApiKey`. Jamais dans Git/log/rapport. |
| `DiagnosticsForwarding:TimeoutSeconds` | `10` | Timeout réseau d'un envoi (le forwarder est background : jamais de blocage métier). |
| `DiagnosticsForwarding:BatchSize` | `100` | Événements max par lot. |
| `DiagnosticsForwarding:MaxBatchBytes` | `524288` | Taille max d'un lot (premier seuil). |
| `DiagnosticsForwarding:RetryInitialSeconds` | `15` | Backoff initial. |
| `DiagnosticsForwarding:RetryMaxMinutes` | `30` | Plafond du backoff. |
| `DiagnosticsForwarding:Spool:MaxFileSizeMb` | `10` | Rotation de fichier. |
| `DiagnosticsForwarding:Spool:MaxTotalSizeMb` | `1024` | Quota disque total. |
| `DiagnosticsForwarding:Spool:RetentionDays` | `14` | Rétention. |
Le défaut `Enabled=false` est **volontaire** (sécurité, opt-in). Aucun placeholder fictif
(`https://diagnostics.exemple.fr`) ne doit devenir une valeur réelle accidentellement.
---
## 7. Configuration Omika.Diagnostics.Api (§36)
Section `Diagnostics` (dans `src/Omika.Diagnostics.Api/appsettings.json`, surchargée par variables) :
| Clé | Défaut | Rôle |
|---|---|---|
| `ConnectionStrings:DiagnosticsDatabase` (ou `Diagnostics:ConnectionString`) | `""` | PostgreSQL central (secret, injecté serveur). |
| `Diagnostics:Central:Directory` | `logs/diagnostics-central` | **Spool central** — en Production, chemin explicite (voir §9). |
| `Diagnostics:Central:MaxRequestBodyBytes` | `524288` | Corps de requête borné. |
| `Diagnostics:Central:MaxEventsPerBatch` | `100` | Événements max par lot. |
| `Diagnostics:Central:SchemaVersion` | `1` | Version de schéma de transport. |
URL/port : via `ASPNETCORE_URLS` (ou `Kestrel:Endpoints`) derrière le reverse proxy HTTPS.
---
## 8. Spool client — emplacement Production (§40)
Le service Windows peut démarrer depuis `C:\Windows\System32` : **ne jamais dépendre du current
working directory**. Utiliser un chemin explicite configurable :
```
C:\ProgramData\Omika\Diagnostics\Spool
```
(via `Observability:Remote:Directory` / `DiagnosticsForwarding` — à poser dans la configuration
serveur client). Le quota (`MaxTotalSizeMb`) et la rétention (`RetentionDays`) évitent un
remplissage disque : à quota atteint, les fichiers les plus anciens sont supprimés (log du
nombre), jamais le fichier actif, jamais d'échec métier.
---
## 9. Spool central — emplacement Production (§41)
Même réflexion pour `Omika.Diagnostics.Api` :
```
C:\ProgramData\Omika\Diagnostics\Central
```
(via `Diagnostics:Central:Directory`). Le Worker central lit ce dossier (import JSONL → PostgreSQL,
checkpoint `import-checkpoint.json`).
---
## 10. Permissions Windows (§42)
Principe de moindre privilège (jamais Administrateur inutilement) :
| Compte service | Dossier | Droits |
|---|---|---|
| `OmikaApi` | `C:\ProgramData\Omika\Diagnostics\Spool` | lecture + écriture |
| `OmikaDiagnosticsApi` | `C:\ProgramData\Omika\Diagnostics\Central` | lecture + écriture |
| `OmikaDiagnosticsWorker` | `C:\ProgramData\Omika\Diagnostics\Central` | lecture (checkpoint → écriture du checkpoint selon le dossier) |
---
## 11. PostgreSQL (§43/§54)
- Base dédiée `omika_diagnostics`. PostgreSQL **ne doit jamais être exposé** aux serveurs clients :
seuls les composants centraux autorisés y accèdent (firewall : loopback central uniquement).
- Rôles : voir migrations `003`/`004`/`009`/`010` (`omika_diagnostics_owner`, `_worker`, `_web`, `_api`).
- `omika_diagnostics_api` (LOGIN) : **Omika.Diagnostics.Api** — `SELECT` + `UPDATE(last_seen_at_utc)` sur `diagnostics_clients` uniquement. **Aucun** droit sur `diagnostics_users`.
- `omika_diagnostics_web` (LOGIN) : **Omika.Diagnostics.Web** — `SELECT` sur `diagnostic_event` ; `SELECT`/`INSERT`/`UPDATE` sur `diagnostics_clients` (admin `/Clients`) ; `SELECT`/`INSERT`/`UPDATE` (colonnes `password_hash`, `role`, `enabled`, `updated_at_utc`, `password_changed_at_utc`, `last_login_at_utc`) + `USAGE` séquence sur `diagnostics_users` (admin `/Users` + login).
- `omika_diagnostics_worker` (LOGIN) : **Omika.Diagnostics.Worker** — `INSERT`/`SELECT`/`DELETE` sur `diagnostic_event` ; **aucun** droit sur `diagnostics_clients` ni `diagnostics_users`.
- Le serveur client **NE DOIT PAS** connaître la ConnectionString PostgreSQL centrale.
---
## 12. Migrations — inventaire (§44/§45/§46)
Ordre d'application sur le serveur central : `001 → 002 → 003 → 004 → 005 → 006 → 007 → 008 → 009 → 010`.
| Fichier | But | Développement appliqué ? | Production nécessaire ? |
|---|---|---|---|
| `001_diagnostics_schema.sql` | `diagnostic_event` + `telemetry_metric_hourly` + index + déduplication `event_id`. | Oui | Oui |
| `002_actor_key.sql` | Colonnes `actor_key`/`actor_key_version` (HMAC pseudonymisation). | Oui | Oui |
| `003_diagnostics_roles.sql` | Rôles/grants serveur (`owner`/`worker`/`web`), moindre privilège. | **Non** (à exécuter sur le serveur PostgreSQL Production, en superuser) | Oui |
| `004_diagnostics_retention.sql` | Grant `SELECT, DELETE` au worker pour la rétention bornée. | **Non** (idem, serveur Production) | Oui |
| `005_diagnostics_client_context.sql` | Colonnes `client_code`/`server_instance`/`environment`. | Oui | Oui |
| `006_diagnostics_device_license.sql` | Colonnes appareil/licence. | Oui | Oui |
| `007_diagnostics_usage.sql` | Colonnes `screen_code`/`feature_code`. | Oui | Oui |
| `008_diagnostics_clients.sql` | Table `diagnostics_clients` (registre des serveurs clients, hash SHA-256). | Oui | Oui |
| `009_diagnostics_clients_grants.sql` | Ownership + GRANT moindre privilège de `diagnostics_clients` (`_api`/`_web`), rôle `omika_diagnostics_api`. | **Non** (serveur PostgreSQL Production, superuser) | Oui |
| `010_diagnostics_web_users.sql` | Table `diagnostics_users` (utilisateurs Web) + ownership `omika_diagnostics_owner` + GRANT moindre privilège `_web` uniquement (aucun droit API/Worker). | **Non** (serveur PostgreSQL Production, superuser) | Oui |
**003/004/009/010** : contiennent des `CREATE ROLE` / `ALTER ROLE` / `GRANT` au niveau **serveur/instance**.
Elles ne sont **pas** appliquées sur le PC de développement (elles exigent le superuser PostgreSQL
Production et fixent les comptes de service). Elles devront être exécutées **sur le serveur
PostgreSQL Production**, dans l'ordre, **avant** le pointage des comptes de service
Worker/Web/Api (`omika_diagnostics_worker` / `omika_diagnostics_web` / `omika_diagnostics_api`),
puis les mots de passe posés manuellement (`\password`, jamais dans un script/log).
Ne pas les exécuter localement « pour du vert ».
> **T22** : `008` crée `diagnostics_clients` **après** `003`/`004`, donc sans ownership ni GRANT
> applicatif. `009` transfère la propriété à `omika_diagnostics_owner` (même propriétaire logique
> que `diagnostic_event`/`telemetry_metric_hourly`), crée le rôle `omika_diagnostics_api` (absent du
> modèle `003`), et pose les GRANT minimum identifiés par l'audit des consommateurs réels :
> API = `SELECT` + `UPDATE(last_seen_at_utc)` ; Web = `SELECT`/`INSERT`/`UPDATE(enabled, api_key_hash,
> key_prefix, key_created_at_utc)` + `USAGE` séquence ; Worker = aucun. `009` est idempotent
> (re-exécutable sans erreur) et utilise `current_database()` pour `GRANT CONNECT` (portable,
> testé sur `omika_diagnostics_preprod`).
---
## 13. Authentification ApiKey — audit final (§55/§56)
- Clé : **32 octets aléatoires**, **Base64Url**, préfixe **`omkdiag_`**.
- Stockage : **SHA-256 hexadécimal (64)** uniquement (`diagnostics_clients.api_key_hash`) + `key_prefix`.
- Vérification : `CryptographicOperations.FixedTimeEquals` ; hash factice si client inconnu (anti-timing).
- **401** : client inconnu OU mauvaise clé (indistinguables). **403** : client désactivé.
- **Tenant autoritaire** : tout ClientCode payload ≠ authentifié → **400** + anomalie (jamais de confiance au payload).
- Réponses sans fuite : jamais de hash, de clé, de stacktrace, ni l'existence précise d'un client.
---
## 14. Procédure — nouveau client Production (§73)
1. `Omika.Diagnostics.Web` → **Clients Diagnostics** (Admin).
2. **Nouveau client**.
3. Saisir le **CodeClient exact** (ex. `BATIK.01`, normalisé `UPPER(TRIM)`).
4. **Créer le client** → la clé est générée.
5. **Copier immédiatement** la clé (bouton « Copier »).
6. Installer la clé sur le serveur client : variable `DiagnosticsForwarding__ApiKey`.
7. Configurer `DiagnosticsForwarding__Endpoint` (URL centrale **HTTPS**).
8. Activer : `DiagnosticsForwarding__Enabled=true`.
9. Redémarrer `OmikaApi`.
10. Vérifier `last_seen_at_utc` dans Clients Diagnostics.
11. Vérifier un diagnostic réel (Dashboard / Diagnostics).
---
## 15. Clé perdue (§74)
La clé **n'est pas récupérable** (hash seul stocké). Si elle n'a pas été copiée :
faire **Régénérer la clé**, puis mettre à jour le serveur client avec la nouvelle clé.
---
## 16. Rotation de clé (§75)
Version V1 : **régénération → ancienne clé immédiatement invalide**. Pendant l'intervalle où le
serveur client n'a pas encore la nouvelle clé, les envois échouent en `401` et le **spool local
s'accumule** (backlog borné, reprise automatique). Acceptable.
---
## 17. Panne centrale (§76)
- **Ne pas arrêter** `OmikaApi` (le métier continue).
- **Ne pas vider** le spool manuellement (les événements seront renvoyés).
- Vérifier l'espace disque (quota/rétention bornent le spool).
- Vérifier les logs du forwarder.
- Attendre le rétablissement : reprise automatique (backoff borné 15 s → 30 min).
---
## 18. Disque spool (§77)
- `MaxTotalSizeMb` (défaut 1024) : quota disque total ; à dépassement, suppression des fichiers
les plus anciens (jamais le fichier actif, log du nombre supprimé).
- `RetentionDays` (défaut 14) : purge des fichiers plus anciens.
- `MaxFileSizeMb` (défaut 10) : rotation de fichier.
- **Fail-open absolu** : aucune de ces opérations ne bloque le métier.
> **Validation empirique T22** (pipeline réel, panne centrale simulée) :
> - Quota `MaxTotalSizeMb=1` : 6000 événements fake (~3,6 Mo) → `DiagnosticsForwarder a abandonné
> 14 fichier(s) de spool`, spool final 768 Ko (≤ 1 Mo), fichier actif préservé, `/health` 200
> (aucun crash métier), Working Set stable ~115–125 Mo (backlog sur disque ≠ RAM).
> - Rétention `RetentionDays=1` : fichier ancien (-2 j) supprimé, fichier récent + fichier actif
> conservés, central DOWN pendant tout le test (la maintenance ne dépend pas du central).
> - **Correctif T22** : `EnforceBounds` (quota/rétention) était appelé uniquement quand le spool
> était vide ; pendant une panne centrale (backlog jamais vide), le quota n'était donc pas
> appliqué. Il est désormais appelé à chaque passe du forwarder, indépendamment du backlog.
> Test : `ForwardOnce_BacklogOverQuota_StillEnforcesBounds`.
---
## 19. Ordre de démarrage (reprise/rollback)
**Démarrage central** : PostgreSQL → `OmikaDiagnosticsWorker` → `OmikaDiagnosticsApi` →
`OmikaDiagnosticsWeb`.
**Démarrage client** : `OmikaWorker` (métier) → `OmikaApi` (métier + forwarder).
**Rollback** : `DiagnosticsForwarding__Enabled=false` + redémarrage `OmikaApi` coupe immédiatement
tout envoi central sans toucher au métier ni au spool (aucune perte de données).
---
## 20. Dépendance SQLite (mobile / Desktop local)
> Mise à jour **T22** — audit + correctif CVE.
- **Version retenue** : `Microsoft.Data.Sqlite 10.0.10` (inchangé) + **`SQLitePCLRaw` 2.1.12**
épinglé transitivement (`Directory.Packages.props`, `CentralPackageTransitivePinningEnabled=true`).
- **Raison** : `SQLitePCLRaw.lib.e_sqlite3 2.1.11` (transitif de `Microsoft.Data.Sqlite 10.0.10`)
embarque une bibliothèque SQLite native < 3.50.2, vulnérable à **CVE-2025-6965 /
GHSA-2m69-gcr7-jv3q** (troncature d'entier dans `findOrCreateAggInfoColumn`, `resolve.c`).
Correctif = SQLite native **3.50.2**, livré par le patch **2.1.12** (famille 2.x, drop-in,
ABI-compatible). **Pas** de saut vers SQLitePCLRaw 3.x (breaking) ni vers
`Microsoft.Data.Sqlite 11` (majeur).
- **Projets concernés** : `Omika.App.Local` (offline-first : cache métier, brouillons, sync,
`DiagnosticOutboxSqliteStore`) → `Omika.App` → têtes `Omika.App.Desktop` / `Omika.App.Android` /
`Omika.App.iOS`. Aucun impact sur `Omika.Api`, `Omika.Worker` ni les projets Diagnostics.
- **Validation** :
- `dotnet restore` OK ; `dotnet list package --include-transitive --vulnerable` →
**« aucun package vulnérable »** ; arbre confirmé `SQLitePCLRaw.* = 2.1.12`.
- Tests SQLite/Outbox ciblés : **148/148**.
- Builds Release : `Omika.App.Desktop` 0 erreur, `Omika.App.Android` 0 erreur.
- `Omika.App.Desktop` (Debug, flux dev) démarré réellement : ouverture `omika.db` OK +
bootstrap `DEV BOOTSTRAP` OK (aucun crash SQLite).
> **Surface réellement exposée** : la vulnérabilité nécessite une requête SQL construite avec un
> nombre extrême de termes d'agrégation ; Omika n'exécute que des requêtes applicatives fixes et
> paramétrées (jamais de SQL utilisateur), sur une base **locale** non accessible à distance. Le
> correctif 2.1.12 est néanmoins appliqué (moindre coût, aucun risque de régression constaté).
---
## 21. Gestion des utilisateurs Diagnostics.Web (T23)
### 21.1 Distinction fondamentale
| Concept | Définition | Identifiant | Stockage |
|---|---|---|---|
| **Utilisateur Diagnostics** | personne qui ouvre `Omika.Diagnostics.Web` | `username` | `diagnostics_users` (hash PBKDF2-SHA256) |
| **Client Diagnostics** | serveur client qui envoie des événements | `ClientCode` | `diagnostics_clients` (hash SHA-256) |
Les deux registres sont **totalement séparés** et ne doivent jamais être confondus.
### 21.2 Modèle utilisateur
- Table `diagnostics_users` (migration `010`) : `id`, `username` (unique, insensible à la casse),
`password_hash`, `role` (`Support` / `Admin`), `enabled`, `created_at_utc`, `updated_at_utc`,
`password_changed_at_utc`, `last_login_at_utc`.
- **Aucun mot de passe en clair** : seule la colonne `password_hash` existe. Le hash est produit
par `DiagnosticPasswordHasher` (PBKDF2-SHA256, 100 000 itérations, sel aléatoire).
- **Aucune suppression physique** (V1) : un utilisateur est **désactivé** (`enabled=false`), jamais `DELETE`.
### 21.3 Rôles
| Rôle | Accès |
|---|---|
| **Support** | Dashboard, Diagnostics, Erreurs, Traces, Synchronisation, Telemetry, Parc, Versions, Référentiel, Mon compte. |
| **Admin** | Tout Support + **Clients Diagnostics** + **Utilisateurs** + **Système**. |
`/System`, `/Clients` et `/Users` sont **Admin uniquement**. Un Support y obtient un refus
(redirection `/AccessDenied`). Un Admin conserve tous les droits de consultation Support.
### 21.4 Actions d'administration (`/Users`, Admin uniquement)
- **Création** : saisie `username` + `password` + `confirmation` + `rôle`. Le Web hache
automatiquement (`DiagnosticPasswordHasher.Hash`) ; l'Admin ne génère/copie **jamais** un hash.
- **Désactivation / Réactivation** (`enabled=false/true`).
- **Changement de rôle** `Support → Admin` / `Admin → Support`.
- **Réinitialisation du mot de passe** (nouveau mot de passe + confirmation → hash + UPDATE).
### 21.5 Protections
- **Dernier Admin** : impossible de désactiver ou rétrograder le dernier Admin actif
(« Impossible de retirer le dernier administrateur actif. »).
- **Auto-désactivation** : un Admin ne peut pas désactiver son propre compte.
- **Auto-rétrogradation** : un Admin ne peut pas retirer son propre rôle Admin.
### 21.6 Règles de validation
- **Username** : obligatoire, `trim`, unicité insensible à la casse, borné (≤ 64), caractères
`lettres / chiffres / . / - / _`.
- **Mot de passe** : non vide, **≥ 12 caractères**, confirmation identique. Pas de politique de
complexité superflue (V1).
### 21.7 Connexion (login hybride)
1. Si le `username` existe dans `diagnostics_users` → la DB est **autoritaire** (jamais de fallback).
- Vérification PBKDF2 (`DiagnosticPasswordHasher.Verify`) ; `enabled=false` → refus.
2. Sinon → fallback opérateurs `DiagnosticsAuth:Operators` (bootstrap temporaire).
3. Message d'échec **générique** : « Identifiants invalides. » (jamais « inconnu » / « désactivé » / « mauvais mot de passe »).
- `last_login_at_utc` est mis à jour (best-effort) après un login DB réussi.
- Anti-bruteforce léger (in-memory, mono-instance) : 5 échecs / 5 min → blocage 10 min
(clé = username + IP). Aucun Redis, aucune session distribuée.
- Anti-timing : hash factice calculé quand le username est inconnu (même principe que l'ApiKey).
- Cookie : `HttpOnly`, `SameSite=Lax`, `Secure=Always` hors Développement, expiration 12 h
(glissante). Production = HTTPS.
- Un utilisateur désactivé avec un cookie existant est re-déconnecté à la prochaine
re-vérification DB (fréquence bornée ~60 s, cache mémoire mono-instance) ; au pire ~1 minute
de grâce, jamais « plusieurs jours ».
### 21.8 « Mon compte » (`/Account`)
- Tout utilisateur authentifié peut changer **son propre** mot de passe :
`mot de passe actuel` (vérifié réellement) + `nouveau` + `confirmation`. En cas de mot de passe
actuel faux → refus **sans** modifier le hash.
- Les opérateurs bootstrap (configuration) ne peuvent pas changer leur mot de passe ici.
---
## 22. Bootstrap du premier Admin Production (T23)
Production démarre **sans** utilisateur DB. Conserver la possibilité de configurer
temporairement un opérateur bootstrap (`DiagnosticsAuth:Operators`) pour créer le premier Admin DB.
### 22.1 Générer le hash bootstrap (hors ligne, pas d'endpoint Web)
Outil console dédié (jamais un endpoint Web anonyme) :
```
dotnet run --project tools/Omika.DiagnosticHashTool -- "<mot-de-passe-bootstrap>"
# ou, pour éviter de laisser le mot de passe dans l'historique du shell :
dotnet run --project tools/Omika.DiagnosticHashTool -- --stdin
```
> ⚠️ Le **mot de passe** bootstrap ne doit **jamais** être dans Git, dans le rapport, ni dans un
> `appsettings` versionné. Seul le **hash** est placé dans la configuration / le secret store
> Production.
### 22.2 Procédure premier déploiement (12 étapes)
1. Déployer `Omika.Diagnostics.Web` (services + reverse proxy HTTPS, cf. §3-§5).
2. Configurer **temporairement** l'opérateur bootstrap Admin (hash seulement) :
`DiagnosticsAuth__Operators__0__Username` / `...__PasswordHash` / `...__Roles__0=Admin`.
3. Démarrer le service `OmikaDiagnosticsWeb`.
4. Se connecter avec l'opérateur bootstrap.
5. Aller sur **`/Users`**.
6. Créer l'**Admin DB permanent** (username + mot de passe + rôle `Admin`).
7. Se **déconnecter**.
8. Tester le login avec l'**Admin DB** (plus le bootstrap).
9. **Retirer** l'opérateur bootstrap de la configuration (`DiagnosticsAuth:Operators = []`).
10. Redémarrer le service `OmikaDiagnosticsWeb`.
11. Confirmer que le **bootstrap est refusé** (login → « Identifiants invalides. »).
12. Confirmer que l'**Admin DB fonctionne** (Dashboard / Diagnostics / Clients / Users).
### 22.3 Installation Production neuve
Si PostgreSQL Diagnostics Production est créé **de zéro** : appliquer **`001 → 010`** dans l'ordre
(jamais uniquement les dernières). Si une installation existe déjà : appliquer **uniquement les
migrations manquantes**, dans l'ordre.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+786
View File
@@ -0,0 +1,786 @@
# E2E SYNC VALIDATION — Test end-to-end réel du pipeline de synchronisation
## Statut : EXÉCUTÉ — CREATE VALIDÉ, REPLAY À RETESTER, IDEMPOTENCE RETRY CORRIGÉE TRANSACTIONNELLEMENT
> **Mise à jour 13/08/2026 17:27 — tentative du test Replay corrigé :**
> le test `Replay_SameOperationId_IsIdempotent` (corrigé, autonome, polling
> terminal) a été **ré-exécuté physiquement** (garde-fous `true`, Worker externe
> actif) mais a **échoué au polling du premier POST** : `Assert.NotNull() Failure:
> Value is null` (ligne 302). Le log du Worker ne montre **aucune ligne de
> traitement** d'opération pendant le test : le Worker externe n'a **pas traité**
> l'opération dans le délai de 30 s du test. Le statut API est donc resté non
> terminal. **Aucune écriture métier supplémentaire n'a été produite par cette
> tentative** (le Worker n'a rien traité). Les garde-fous ont été **restaurés à
> `false`** après la tentative. Le test Replay corrigé reste **à retester
> physiquement** (le Worker externe doit traiter l'opération dans le délai du
> test).
> **Mise à jour 13/08/2026 17:43 — tentative avec polling Worker E2E = 5 s :**
> le test Replay corrigé a été **ré-exécuté physiquement** avec un Worker externe
> lancé avec l'override temporaire `Sync__RetryDelaySeconds=5` (poll immédiat puis
> toutes les 5 s) et les garde-fous `true`. **Le pipeline a réellement atteint le
> premier SUCCESS** (le test a passé l'assertion `Assert.NotNull(firstStatus)` à la
> ligne 302). Le test a ensuite **échoué sur un bug de disposition du helper
> `CountInterventionsAsync`** (ligne 177) : `System.InvalidOperationException :
> 'WebServiceHfSqlConnection' type only implements IAsyncDisposable. Use
> DisposeAsync to dispose the container.` Le helper utilise `using var scope =
> _factory.Services.CreateScope()` (disposition synchrone) alors que
> `WebServiceHfSqlConnection` n'implémente que `IAsyncDisposable` — le même
> problème `IAsyncDisposable` déjà corrigé dans `Program.cs`. **Ce n'est pas un
> échec du pipeline** : le premier POST a atteint SUCCESS (le Worker a traité
> l'opération, transaction BATIK_TEST, handler, MobLog SUCCESS, COMMIT). Le test
> a échoué uniquement sur le comptage métier (helper). Les garde-fous ont été
> **restaurés à `false`** et le Worker **arrêté** après la tentative. Le test
> Replay corrigé reste **à retester physiquement** après correction du helper
> `CountInterventionsAsync` (disposition asynchrone).
> **Mise à jour 13/08/2026 17:48 — micro-fix disposition + nouvelle tentative :**
> le helper `CountInterventionsAsync` a été corrigé (disposition asynchrone
> `await using var scope = _factory.Services.CreateAsyncScope()`), le projet de
> test compile. Le test Replay a été **ré-exécuté physiquement** (Worker E2E
> `Sync__RetryDelaySeconds=5`, garde-fous `true`). **Le pipeline a de nouveau
> atteint le premier SUCCESS** (le test est passé à la ligne 309). Le test a
> ensuite **échoué sur une nouvelle erreur du helper `CountInterventionsAsync`**
> (ligne 175) : `System.InvalidOperationException : La connexion Business doit
> être ouverte avant d'exécuter une commande.` (`BusinessOleDbConnection.EnsureOpen()`
> ligne 317). Le helper obtient `IWebServiceHfSqlConnection` et appelle
> `ExecuteScalarAsync` **sans ouvrir la connexion**. **Ce n'est pas un échec du
> pipeline** : le premier POST a atteint SUCCESS (transaction BATIK_TEST, handler,
> MobLog SUCCESS, COMMIT). Le test a échoué uniquement sur le comptage métier
> (helper). Les garde-fous ont été **restaurés à `false`** et le Worker **arrêté**
> après la tentative. Le test Replay corrigé reste **à retester physiquement**
> après ouverture de la connexion dans `CountInterventionsAsync`.
> **Mise à jour 13/08/2026 17:54 — micro-fix OpenAsync + E2E RÉUSSI :**
> le helper `CountInterventionsAsync` a été corrigé (ouverture explicite de la
> connexion `await connection.OpenAsync(cancellationToken)` avant
> `ExecuteScalarAsync`), le projet de test compile. Le test Replay a été
> **ré-exécuté physiquement** (Worker E2E `Sync__RetryDelaySeconds=5`, garde-fous
> `true`) et a **RÉUSSI** (1/1, 18 s) : premier POST → SUCCESS, business count = 1,
> second POST (même OperationId/RequestedEntityGuid/payload) → SUCCESS,
> CanonicalEntityGuid identique, business count final = 1, **aucun doublon**.
> **CREATE HFSQL PHYSIQUE : VALIDÉ. COMMIT TRANSACTIONNEL BATIK_TEST : VALIDÉ
> PHYSIQUEMENT. REPLAY TERMINAL : VALIDÉ PHYSIQUEMENT. IDEMPOTENCE TERMINALE :
> VALIDÉE.** Les garde-fous ont été **restaurés à `false`** et le Worker **arrêté**
> après la tentative. **Baseline Mobile : ÉCHEC** sur 2 tests transactionnels
> (`Crash_After_Commit_Does_Not_Reapply_Handler_On_Retry` ligne 403 et
> `Two_Different_OperationIds_Produce_Two_Business_Creations` ligne 436) :
> `Assert.Equal() Failure: Expected 1/2, Actual 0` — le handler n'a produit
> aucune création métier dans ces 2 tests unitaires. **STOP au premier échec de
> la baseline** : suite hors HFSQL et build solution non lancés.
> **Mise à jour 13/08/2026 18:02 — correction des 2 tests transactionnels rouges :**
> la cause de `Actual: 0` était un **défaut de test harness** (aucun défaut
> production) : les deux tests rouges utilisaient le handler générique
> `FakeSyncOperationHandler(SyncOperationResult.Success())`, qui retourne SUCCESS
> **sans écrire d'Intervention** dans le fake transactionnel. Le compteur
> `tx.CommittedInterventions` est alimenté par le handler via `WriteIntervention`
> (comme le font `ThrowingAfterWriteHandler` / `ThrowingThenSuccessHandler` des
> deux tests verts). Un handler dédié `SuccessWritingHandler` (écrit une
> Intervention + retourne SUCCESS + expose `CallCount`) a été ajouté et utilisé
> par les deux tests. Pour `Crash_After_Commit`, l'assertion `repo.Succeeded.Count
> == 1` a été remplacée par `Assert.Contains(env.OperationId, repo.Succeeded)` :
> au replay, le `SyncProcessor` reconstitue le log terminal SUCCESS (re-marquage
> via `MarkSuccessAsync`) **sans rappeler le handler** (comportement correct
> d'idempotence par OperationId) ; le fake `Succeeded` accumule donc un doublon,
> mais le handler n'est pas rappelé et aucune Intervention supplémentaire n'est
> durable. **Baseline complètement verte** : 4 tests transactionnels (4/4),
> SyncProcessor + SyncWorker (22/22), Mobile (515/515), suite hors HFSQL
> (1755/1755), build solution (0 erreur). **Aucun fichier production modifié.**
> **SYNC E2E PHYSIQUE reste VALIDÉ** (CREATE, COMMIT, REPLAY terminal,
> idempotence terminale). Garde-fous toujours `false`.
Le test end-to-end réel du pipeline de synchronisation **multi-connexion** a été
**réellement exécuté** (EXECUTED) contre les bases HFSQL de TEST. Le CREATE a
**réellement abouti** (SUCCESS, Intervention créée, MobLog SUCCESS).
Le test Replay **actuel** (`Replay_SameOperationId_IsIdempotent`) a été
**corrigé** pour devenir autonome : générer son propre OperationId, attendre
réellement SUCCESS, compter le métier = 1, re-POST exact, vérifier métier
toujours = 1. Il reste **à retester physiquement** (garde-fous `false`).
La **fenêtre de duplication retry** (business réussi mais terminal absent) a été
**fermée par une correction transactionnelle** : pour une opération réussie, les
écritures métier (Intervention), les relations associées, MobLog SUCCESS et la
finalisation MobReception sont désormais exécutées dans **UNE transaction OLE DB
locale BATIK_TEST partagée** (via `IWebServiceTransactionContext`). Soit tout est
durable, soit rien. Cette correction est **implémentée et testée** (tests
transactionnels verts), mais **pas encore validée par un E2E physique**.
**Résultat : SYNC E2E HFSQL PHYSIQUE (CREATE) : VALIDÉ.**
**REPLAY / IDEMPOTENCE TERMINALE : TEST CORRIGÉ, À RETESTER PHYSIQUEMENT.**
**IDEMPOTENCE RETRY : CORRECTION TRANSACTIONNELLE IMPLÉMENTÉE ET TESTÉE (pas encore E2E validée).**
**LICENCE E2E : NON VALIDÉE — BASE_LICENCES BLOQUÉE.**
## 0. Résultat de l'exécution réelle (13/08/2026)
### Maillons observés (dans l'ordre) — CREATE
| Maillon | Résultat | Détail |
|---|---|---|
| A. HTTP accepté | ✅ VERT | `POST /api/sync/operations` → 200 OK, `RECEIVED` |
| B. opération dans TOSRV | ✅ VERT | écrite dans `BATIK.01_BLS.TOSRV`, puis transférée |
| C. même OperationId | ✅ VERT | `receipt.OperationId == operationId` |
| D. TOSRV → MobReception | ✅ VERT | opération présente dans `BATIK_TEST.MobReception` |
| E. IDTOSRV conservé | ✅ VERT | `MR_IDToSRV` correspond à `IDTOSRV` source |
| F. SyncProcessor réel | ✅ VERT | `SyncProcessor.ProcessOperationAsync` appelé |
| G. Handler Intervention | ✅ VERT | `InterventionSyncOperationHandler.HandleCreateAsync` appelé |
| H. Intervention créée | ✅ VERT | Intervention `E2E_TEST_5b915439` créée dans BATIK_TEST |
| I. résultat dans MobLog | ✅ VERT | log `Success` écrit (SUCCESS) |
| J. status API SUCCESS | ✅ VERT | endpoint status → `SUCCESS` |
### Maillons observés — REPLAY
| Maillon | Résultat | Détail |
|---|---|---|
| HTTP accepté | ✅ VERT | réenvoi du même OperationId accepté |
| status API | ❌ ROUGE | retourne `Pending` au moment de la vérification (Worker pas encore passé) |
| Idempotence terminale | ❌ ROUGE | le test Replay **actuel** envoie les deux POST sans attendre SUCCESS : ce n'est pas un test d'idempotence terminale |
### Premier maillon rouge (Replay)
- **Maillon** : J — status API SUCCESS (Replay).
- **Exception/code HFSQL** : aucune erreur HFSQL. Le status est resté `Pending`
car le Worker externe n'a pas traité l'opération dans le délai du test
(~4 secondes).
- **Table** : N/A.
- **Colonne** : N/A.
- **Composant .NET** : `SyncPipelineE2ETests.Replay_SameOperationId_IsIdempotent`
(test conceptuellement incorrect : les deux POST sont envoyés immédiatement,
sans attendre le SUCCESS terminal du premier).
- **Correction appliquée** : le test Replay a été réécrit pour être autonome —
générer son propre OperationId, attendre réellement SUCCESS (polling terminal),
compter le métier = 1, re-POST exact, vérifier métier toujours = 1.
### Données E2E laissées
- **OperationId (CREATE)** : `5b915439-d9d9-44b5-9552-1f8f90db96c3`
- **RequestedEntityGuid (CREATE)** : `6679f54e-ace4-4df3-a75d-d69701601069`
- **CanonicalEntityGuid (CREATE)** : `0001014b-0001-0000-008a-2badf0d20f00`
- **OperationId (Replay)** : `0fce297f-e4e4-45ad-9027-b4e6e23d5c8a`
- **RequestedEntityGuid (Replay)** : `6f3bd0c1-8aeb-4296-9204-c7b1470c345a`
- **CanonicalEntityGuid (Replay)** : `0001014b-0001-0000-008a-2c339447ac00`
- **Interventions** : 7 lignes `E2E_TEST_*` laissées dans BATIK_TEST. Les 6
lignes `E2E_TEST_5b915439` sont des **doublons du MÊME OperationId**
`5b915439-d9d9-44b5-9552-1f8f90db96c3` (défaut d'idempotence retry business :
le handler CREATE a été rappelé à chaque retry, `retryCount=16` au SUCCESS,
recréant une Intervention à chaque tentative). La ligne `E2E_TEST_0fce297f`
est l'Intervention **normale** du test Replay (OperationId distinct
`0fce297f-e4e4-45ad-9027-b4e6e23d5c8a`), **PAS un doublon** du CREATE.
Aucune donnée personnelle réelle.
- **MobLog** : logs `Pending` + `Success` pour chaque opération.
- **TOSRV** : vide (opérations transférées et supprimées).
Aucun DELETE physique n'a été effectué. Un futur test ARCHIVE pourra servir au
cleanup des lignes `E2E_TEST_*` si nécessaire.
### Replay
Le test `Replay_SameOperationId_IsIdempotent` a été **réellement exécuté**
(EXECUTED) mais a **échoué** : le status est resté `Pending` au moment de la
vérification (le Worker externe n'a pas traité l'opération dans le délai du
test). Le Worker a néanmoins traité l'opération Replay **après** le test,
créant l'Intervention `E2E_TEST_0fce297f`.
**Diagnostic exact (micro-lot idempotence) :** le test Replay **actuel** génère
son **propre** OperationId (`0fce297f-e4e4-45ad-9027-b4e6e23d5c8a`) et son propre
RequestedEntityGuid (`6f3bd0c1-8aeb-4296-9204-c7b1470c345a`). Il ne réutilise
**pas** l'OperationId du test CREATE (`5b915439`). L'Intervention
`E2E_TEST_0fce297f` est donc l'Intervention **normale** du test Replay
(OperationId distinct), **PAS un doublon** du CREATE `5b915439`.
Le test Replay **actuel** est **conceptuellement incorrect** : il envoie les
deux POST immédiatement (lignes 213-218), **sans attendre le SUCCESS terminal
du premier**. Ce n'est donc pas un test d'idempotence terminale. Il a été
**corrigé** pour devenir autonome : générer son propre OperationId, attendre
réellement SUCCESS (polling terminal), compter le métier = 1, re-POST exact,
vérifier métier toujours = 1.
### Garde-fous
Les trois garde-fous ont été **restaurés à `false`** après l'exécution :
`AllowHfSqlWrites`, `ServiceDatabaseConfirmedTest`,
`WebServiceDatabaseConfirmedTest`. `AllowHfSqlWrites` n'est jamais laissé à
`true` après la tâche.
## 1. Architecture multi-connexion validée
```
ServiceConnection (base Service, table TOSRV)
↓
WebServiceConnection (base WebService/Business)
→ MobReception
→ MobLog
→ données métier (Intervention)
```
Le repository `SyncTransferRepository` est déjà adapté à cette topologie :
- `IServiceHfSqlConnection` → base Service, table `TOSRV`.
- `IWebServiceHfSqlConnection` → base WebService/Business, tables `MobReception`
et `MobLog`.
## 2. Environnement sans secrets
Ce document ne contient **aucun secret** : ni mot de passe, ni token, ni
ProtectionPlusSecret, ni FilePasswords, ni credential. Les valeurs de connexion
sont décrites par leur nom de clé et leur nature, jamais par leur valeur
sensible.
## 3. Bases réellement utilisées
### Service DB (base Service, table TOSRV)
| Section | Clé | Valeur effective |
|---|---|---|
| `ServiceConnection` | `DatabaseName` | `BATIK.01_BLS` (base Service actuelle) |
La base Service est utilisée par **Omika.Api** et **Omika.Worker** via la
connexion `ServiceConnection` (enregistrée par `AddOmikaDataServiceConnection`).
### WebService DB (base WebService/Business)
| Section | Clé | Valeur effective |
|---|---|---|
| `Hfsql` (Business / WebService) | `DatabaseName` | `BATIK_TEST` (config locale) |
La base WebService/Business est utilisée par **Omika.Api** et **Omika.Worker**
via la connexion `Hfsql` (enregistrée par `AddOmikaData`). Elle héberge les
tables `MobReception`, `MobLog` et les données métier (Intervention).
## 4. Confirmation explicite TEST
Le nom de la base Service (`BATIK.01_BLS`) **ne prouve pas** qu'elle est une
base TEST. La confirmation est fournie **explicitement** via la configuration
locale non versionnée `config/hfsql-test.local.json`, section `E2E` :
```json
"E2E": {
"AllowHfSqlWrites": false,
"ServiceDatabaseConfirmedTest": false,
"WebServiceDatabaseConfirmedTest": false
}
```
Par défaut, tous les flags sont `false`. **Aucune écriture** n'est possible tant
que les trois flags ne sont pas explicitement `true` :
- `AllowHfSqlWrites = true`
- `ServiceDatabaseConfirmedTest = true`
- `WebServiceDatabaseConfirmedTest = true`
Le mécanisme est implémenté dans `E2EOptions.IsWriteAllowed` (exige les trois
flags simultanément). Le nom de la base ou `localhost` ne suffit jamais.
## 5. Connexions effectives
### Omika.Api
- `ServiceConnection` → base Service (`BATIK.01_BLS` actuelle).
- `WebServiceConnection` (via `Hfsql`) → base WebService/Business
(`BATIK_TEST` / `OMIKA_TEST` selon l'environnement choisi).
### Omika.Worker
- `ServiceConnection` → même base Service.
- `WebServiceConnection` (via `Hfsql`) → même base WebService/Business.
Aucun mot de passe n'est loggé.
## 6. Probes lecture (avant toute écriture)
Avant toute écriture, les probes de lecture suivantes doivent réussir :
- **ServiceConnection** : ouvrir, `SELECT` lecture sur `TOSRV`.
- **WebServiceConnection** : ouvrir, `SELECT` lecture sur `MobReception`,
`SELECT` lecture sur `MobLog`, `SELECT` lecture sur les tables Intervention
nécessaires.
Aucun `INSERT`, `UPDATE` ou `DELETE`. Si un probe échoue : **STOP**.
## 7. Licence — reste STOP
`BASE_LICENCES` reste physiquement bloquée : les tables `ClientBase`, `Licence`,
`LicenceHisto` sont absentes. Rien n'est modifié dans la base Licence. Le
présent E2E ne valide **pas** le parcours Licence.
## 8. Authentification du test Sync
L'endpoint Sync reste protégé. `MobileAuthMiddleware` n'est **pas désactivé**.
Le test de pipeline Sync utilise un mécanisme explicite de TEST basé sur le
vrai `ITokenService` / `TokenService` :
- `SigningKey` de TEST (configuration locale non versionnée).
- Identité `MobileTokenIdentity` de TEST.
- Bearer réellement validé par `MobileAuthMiddleware`.
Le token est généré par le harness/test E2E (helper `CreateValidToken` dans
`tests/Omika.Tests/E2E/SyncPipelineE2ETests.cs`).
**Limite documentée** : cela valide `Bearer → middleware → endpoint Sync`, mais
**ne valide pas** `Licence → activate → token`.
Aucun bypass auth, aucun middleware désactivé, aucun token hardcodé en source,
aucun token loggé.
## 9. Test E2E automatisé SKIPPABLE
Un test E2E automatisé a été créé : `tests/Omika.Tests/E2E/SyncPipelineE2ETests.cs`.
Il est **SKIPPABLE** : il n'écrit JAMAIS par défaut. Il exige explicitement les
trois garde-fous (`AllowHfSqlWrites`, `ServiceDatabaseConfirmedTest`,
`WebServiceDatabaseConfirmedTest`) tous `true`. Sinon, le test est SKIP.
Le test couvre :
- **CREATE** : envoi via `POST /api/sync/operations` avec un Bearer valide.
- **TOSRV** : vérification de la réception dans la base Service.
- **MobReception** : transfert par le Worker.
- **Business** : création de l'Intervention.
- **MobLog** : résultat terminal SUCCESS.
- **status API** : interrogation de l'endpoint moderne avec OperationId.
- **replay** : réenvoi du même OperationId → idempotence terminale.
## 10. Pipeline validé (une fois les garde-fous satisfaits)
### CREATE
- `OperationId` = nouveau Guid.
- `RequestedEntityGuid` = nouveau Guid.
- Référence/titre : `E2E_TEST_<identifiant court>`.
- Aucune donnée personnelle réelle.
### Envoi normal
`POST /api/sync/operations` avec `Authorization: Bearer <token test valide>`.
Aucun INSERT manuel dans TOSRV pour le test principal. Le pipeline validé est :
HTTP → auth → SyncOperationService → repository → TOSRV.
### TOSRV Service
Après réception, vérifier dans `ServiceConnection.TOSRV` : `OperationId`,
`EntityType`, `OperationType`, `RequestedEntityGuid`. Aucune ligne modifiée
manuellement.
### Worker
Un seul `Omika.Worker` avec les mêmes configurations Service/WebService.
Pipeline : `SyncWorker` → `SyncProcessor` → `TransferToReceptionAsync`.
### Transfert inter-base
`Service.TOSRV` → `WebService.MobReception` → `Service.TOSRV` finalisé/supprimé
selon le comportement actuel. Vérifier surtout que `OperationId` est identique.
### Handler
`MobReception` → `SyncProcessor` → `InterventionSyncOperationHandler` (et non
`DefaultSyncOperationHandler`).
### Business result
Vérifier dans la base métier TEST que l'Intervention est créée. Ne valider que
les propriétés réellement envoyées. Identifier `RequestedEntityGuid` et
`CanonicalEntityGuid` selon le modèle Batik/Omika réellement sélectionné.
### MobLog
Valider dans `WebServiceConnection.MobLog` : `OperationId`, `SUCCESS`,
`CanonicalEntityGuid`, résultat terminal, absence d'erreur en succès.
### Status API
Interroger l'endpoint moderne de status avec `OperationId`. Attendu : `SUCCESS`
correspondant à MobLog. Le test n'est pas complet tant que ce retour API n'est
pas validé.
### Replay
Réenvoyer exactement le même `OperationId` et le même payload. Attendu :
- aucune deuxième Intervention métier ;
- résultat terminal réutilisé ;
- status toujours cohérent.
Documenter précisément **idempotence terminale** et ne pas prétendre
**exactly-once concurrent global**.
### UPDATE
Après CREATE vert, nouvel `OperationId`, tester UPDATE sur l'Intervention E2E.
Pipeline : API → Service.TOSRV → WebService.MobReception → Handler → Business →
MobLog → status SUCCESS.
### ARCHIVE
Après UPDATE vert, nouvel `OperationId`, tester ARCHIVE logique. Sert aussi de
cleanup fonctionnel de l'Intervention E2E. Aucun DELETE physique.
### REJECTED (éventuel)
Si CREATE/UPDATE/ARCHIVE sont verts, envoyer éventuellement un payload invalide
contrôlé. Attendu : `REJECTED` → MobLog → API status `REJECTED`. Ne provoque pas
une erreur d'intégrité dangereuse.
## 11. Cross-database failure
Ne provoque pas volontairement un crash sur les vraies bases. Le scénario
`MobReception insérée + TOSRV encore présente` est déjà couvert par tests fakes.
Pour l'E2E réel, observer uniquement le comportement normal.
## 12. Un seul Worker
Règle actuelle : **UN SEUL `Omika.Worker` actif**. Ne teste pas la concurrence
multi-worker.
## 13. SyncConnection obsolète
Ne supprime pas `SyncConnection` pendant ce test. Ce nettoyage reste séparé.
## 14. Critères pour autoriser l'écriture
Écriture E2E permise UNIQUEMENT si :
- A. `ServiceDatabaseConfirmedTest = true`
- B. `WebServiceDatabaseConfirmedTest = true`
- C. `AllowHfSqlWrites = true`
- D. connexion Service réussie
- E. connexion WebService réussie
- F. `TOSRV` présente dans Service
- G. `MobReception` et `MobLog` présentes dans WebService
- H. base métier cible explicitement TEST
Si un seul manque : **STOP avant écriture**.
## 15. Aucun schéma HFSQL modifié
**Aucun schéma HFSQL n'a été modifié** : aucune création/suppression de table,
aucune modification de colonne. Des **écritures de données** ont été effectuées
dans les bases TEST (TOSRV, MobReception, MobLog) lors de l'exécution E2E, mais
le schéma physique est intact.
## 16. Baseline
- Tests Mobile : **515/515** (référence 511/511 + 4 tests transactionnels ajoutés
par le micro-lot idempotence retry, confirmé).
- Suite unitaire hors HFSQL environnemental : **1755/1755** (référence
1751/1751 + 4 tests transactionnels, confirmé).
- Build solution : **VERT** (confirmé).
- Worker : **VERT**.
- Android : **VERT**.
### Niveau de validation réellement obtenu (compte rendu final)
Le test `SyncPipelineE2ETests.Create_Intervention_Pipeline_IsGreen` a été
**réellement exécuté** (EXECUTED) avec les trois garde-fous à `true` et un
`Omika.Worker` externe actif. Le pipeline a **réellement abouti** : l'Intervention
`E2E_TEST_5b915439` a été créée dans BATIK_TEST, le MobLog a enregistré
`Success`, et l'endpoint status a retourné `SUCCESS`.
**Classification : CAS A — CREATE E2E réellement validé (SUCCESS).**
Le test a réellement traversé : HTTP API → Bearer réel → MobileAuthMiddleware →
POST /api/sync/operations → ServiceConnection.TOSRV → Worker externe →
MobReception → SyncProcessor → InterventionSyncOperationHandler → Business
BATIK_TEST → MobLog → status API SUCCESS.
**Ce qui est réellement validé par ce test dans l'état actuel :**
- HTTP API réelle (via `WebApplicationFactory<Program>`).
- Bearer réel via le vrai `TokenService` → `MobileAuthMiddleware` réellement
traversé.
- `SyncOperationService` → repository → TOSRV (base Service `BATIK.01_BLS`).
- Worker externe → `MobReception` (base WebService `BATIK_TEST`).
- `SyncProcessor` → `InterventionSyncOperationHandler` (handler réellement
sélectionné).
- Intervention créée dans BATIK_TEST (Business réel).
- MobLog `Success` écrit.
- Status API `SUCCESS`.
**Replay** : le test `Replay_SameOperationId_IsIdempotent` a été **réellement
exécuté** (EXECUTED) mais a **échoué** : le status est resté `Pending` au moment
de la vérification (le Worker externe n'a pas traité l'opération dans le délai
du test). Le Worker a néanmoins traité l'opération Replay **après** le test,
créant l'Intervention `E2E_TEST_0fce297f`.
**Diagnostic exact (micro-lot idempotence) :** le test Replay **actuel** génère
son **propre** OperationId (`0fce297f-e4e4-45ad-9027-b4e6e23d5c8a`), distinct de
celui du CREATE (`5b915439`). `E2E_TEST_0fce297f` est donc l'Intervention
**normale** du test Replay, **PAS un doublon** du CREATE. Le test Replay actuel
est **conceptuellement incorrect** : il envoie les deux POST sans attendre le
SUCCESS terminal du premier. Il a été **corrigé** (autonome, polling terminal,
comptage métier = 1). L'idempotence terminale n'est donc **pas encore démontrée**
par une exécution réelle (garde-fous `false`).
**Micro-lot DÉBLOCAGE STARTUP (appliqué) :**
Trois blocages logiciels de startup ont été corrigés :
1. **Startup API — corrigé.** Dans `src/Omika.Api/Program.cs`, le bloc
`using (var scope = app.Services.CreateScope())` disposait le scope de manière
**synchrone** (`Dispose()`), alors que `HfsqlConnection` n'implémente que
`IAsyncDisposable`. Corrigé en `await using var scope =
app.Services.CreateAsyncScope();` (DisposeAsync propre). Validé par le test
`SyncEndpointsTests` (3/3) qui démarre réellement
`WebApplicationFactory<Program>` : plus aucune `InvalidOperationException
IAsyncDisposable`.
2. **Startup Worker DI — corrigé.** `SyncWorker` et `SyncProcessor` attendent le
POCO `SyncOptions` directement dans leur constructeur, mais seul
`IOptions<SyncOptions>` était enregistré. Corrigé dans
`src/Omika.Worker/Program.cs` en enregistrant le POCO comme singleton résolu
depuis `IOptions<SyncOptions>` (valeurs issues de la configuration, jamais
hardcodées). Le bloc `using (var scope = host.Services.CreateScope())` a aussi
été corrigé en `CreateAsyncScope()`.
3. **Transport Business/WebService ODBC → OLEDB — corrigé.** Le chemin ODBC
(`HfsqlConnection` / `IHfsqlConnection`) était utilisé par la détection de
schéma, le wrapper `WebServiceHfSqlConnection` et les repositories métier
(Intervention). Sur cette machine, le pilote ODBC HFSQL est absent
(`IM002 Source de données introuvable`). Un override ciblé OLE DB a été ajouté
(`BusinessOleDbConnection` + `AddOmikaDataBusinessOleDb`) et branché dans
`Program.cs` de l'API et du Worker. La connexion métier passe désormais par
OLE DB `PCSOFT.HFSQL`, sans driver ODBC.
**Startup Worker — blocage ODBC résolu, cycle réel exercé :**
Après les corrections ci-dessus, le Worker **démarre réellement** avec OLE DB
(plus aucune erreur ODBC) et atteint le cycle de synchronisation réel :
```
info: Omika.Worker.SyncWorker[0]
Worker de synchronisation démarré.
info: Microsoft.Hosting.Lifetime[0]
Application started. Press Ctrl+C to shut down.
```
Le cycle exécute réellement `SyncProcessor.ProcessAsync` →
`SyncTransferRepository.TransferToReceptionAsync` → requête SQL réelle sur la
base Service (`BATIK.01_BLS`, serveur 127.0.0.1:4900). La connexion OLE DB à la
base Service fonctionne (la requête est envoyée au serveur HFSQL).
**Décalage de schéma `TOSRV` — CORRIGÉ (alignement code ↔ schéma physique) :**
Le cycle échouait sur une erreur de **schéma** de la table `TOSRV` réelle :
```
System.Data.OleDb.OleDbException (0x80040E14): Rubrique TS_PKID inconnue dans
le fichier. Vérifiez que la requête est en phase avec la description des fichiers.
SELECT >>>>TS_PKID<<<<, TS_Date, TS_Mobile, ...
Code erreur : 70208
```
Le code (`SyncTransferRepository`) référençait une colonne `TS_PKID` dans
`TOSRV`, absente de la table réelle. Un **probe OLE DB en lecture seule** sur la
base Service (`BATIK.01_BLS`) a confirmé que la clé primaire réelle de `TOSRV`
est **`IDTOSRV`** (entier). Le code a été aligné sur le schéma physique :
- `TOSRVRow.cs` : propriété `IdToSrv` mappée sur la colonne `IDTOSRV` (type `int`).
- `SyncTransferRepository.cs` : `SELECT`/`DELETE`/`INSERT` utilisent désormais
`IDTOSRV` (plus aucune référence `TS_PKID`).
- `SyncTransferRepositoryTests.cs` : ligne fake `TOSRV` alignée sur `IDTOSRV`.
Le probe a aussi confirmé que `MobReception` (`MR_PKID`, `MR_IDToSRV`, ...) et
`MobLog` (`ML_PKID`, `ML_DateCreat`, ...) sont conformes aux colonnes utilisées
par le code. Le décalage de schéma `TOSRV` est donc **résolu**.
**Ce que le test validerait si les garde-fous étaient `true`, le pilote ODBC
HFSQL disponible ET un Worker externe tournait :**
- HTTP API réelle (via `WebApplicationFactory<Program>`).
- Bearer réel via le vrai `TokenService` → `MobileAuthMiddleware` réellement
traversé.
- `SyncOperationService` → repository → TOSRV (base Service).
- Worker externe → `MobReception` (base WebService) → handler Intervention →
Business → MobLog → status API.
**Limite restante avant un E2E physique complet :**
1. ~~Corriger le décalage de schéma `TOSRV`~~ — **RÉSOLU** : le code
(`SyncTransferRepository`) et le modèle (`TOSRVRow`) utilisent désormais la
colonne réelle `IDTOSRV` (confirmée par probe OLE DB en lecture seule sur
`BATIK.01_BLS`). Plus aucune référence `TS_PKID`. Le cycle de synchronisation
ne devrait plus échouer sur cette erreur de schéma (code 70208).
2. ~~Passer les trois garde-fous à `true`~~ — **FAIT** (13/08/2026) : le test a
été réellement exécuté avec les garde-fous à `true`.
3. ~~Démarrer un `Omika.Worker` réel~~ — **FAIT** (13/08/2026) : un Worker
externe a été démarré et a traité l'opération jusqu'au handler.
4. ~~Exécuter le test et vérifier l'écriture réelle~~ — **FAIT** (13/08/2026) :
le test CREATE a été exécuté ; TOSRV, MobReception, SyncProcessor, le handler,
le Business, MobLog et le status API ont été réellement exercés. **CREATE
VALIDÉ** : Intervention `E2E_TEST_5b915439` créée, MobLog `Success`, status
API `SUCCESS`.
5. **REPLAY — TEST CORRIGÉ, À RÉ-EXÉCUTER** : le test Replay **actuel** était
conceptuellement incorrect (deux POST sans attendre SUCCESS). Il a été
**corrigé** (autonome, polling terminal, comptage métier = 1). `E2E_TEST_0fce297f`
est l'Intervention **normale** du test Replay (OperationId distinct
`0fce297f`), **PAS un doublon** du CREATE `5b915439`. L'idempotence terminale
n'est **pas encore démontrée** par une exécution réelle (garde-fous `false`).
6. Valider le parcours Licence → activate → token (base Licence toujours
physiquement bloquée).
**Note** : le blocage ODBC (`IM002`) est **résolu** par le routage OLE DB
(`BusinessOleDbConnection`). Le décalage de schéma `TOSRV` (`TS_PKID` → `IDTOSRV`)
est **résolu** par l'alignement code ↔ schéma physique. Le Worker et l'API
démarrent réellement avec OLE DB et atteignent le cycle de synchronisation réel.
Le test E2E CREATE a été **réellement exécuté** (13/08/2026) avec les garde-fous
à `true` et un Worker externe actif, et a **abouti** (SUCCESS). Le test Replay
**actuel** était conceptuellement incorrect (deux POST sans attendre SUCCESS) et
a été **corrigé** (autonome, polling terminal, comptage métier = 1).
`E2E_TEST_0fce297f` est l'Intervention **normale** du test Replay (OperationId
distinct), **PAS un doublon** du CREATE. L'idempotence terminale n'est **pas
encore démontrée** par une exécution réelle (garde-fous `false`).
**Note sur le "faux pass" des tests E2E (garde-fous `false`) :**
Avec les garde-fous `false`, les deux tests `SyncPipelineE2ETests` affichent
"2/2 réussis" mais effectuent un `return` immédiat **avant toute assertion** :
aucun maillon du pipeline n'est exercé. Ce n'est pas un vrai SKIP xUnit (le
framework actuel ne permet pas un skip runtime sans nouvelle dépendance). Il ne
faut donc **pas** interpréter "2/2 E2E verts" comme une validation du pipeline.
Les garde-fous restent `false` : aucune écriture HFSQL n'est possible.
### Réconciliation baseline 1748 → 1742
Le filtre `FullyQualifiedName!~Omika.Tests.Connection.HfSqlOleDb` exclut les
tests dont le FullyQualifiedName contient `Omika.Tests.Connection.HfSqlOleDb`.
**Actuellement, 30 tests sont exclus par ce filtre** (8 classes de tests) :
| Classe de test | Nb tests | Nature |
|---|---|---|
| `HfSqlOleDbConnectionStringBuilderTests` | 11 | unitaires purs (aucun serveur requis) |
| `HfSqlOleDbIntegrationTests` | 4 | intégration réelle (serveur requis) |
| `HfSqlOleDbInterventionIntegrationTests` | 5 | intégration réelle (serveur requis) |
| `HfSqlOleDbCollaborateurLifecycleIntegrationTests` | 2 | intégration réelle (serveur requis) |
| `HfSqlOleDbCreateIntegrationTests` | 2 | intégration réelle (serveur requis) |
| `HfSqlOleDbDossierLifecycleIntegrationTests` | 2 | intégration réelle (serveur requis) |
| `HfSqlOleDbInterventionLifecycleIntegrationTests` | 2 | intégration réelle (serveur requis) |
| `HfSqlOleDbRendezVousLifecycleIntegrationTests` | 2 | intégration réelle (serveur requis) |
**Raison exacte de la baisse 1748 → 1742** : le dernier lot a introduit les
tests `HfSqlOleDb*` (30 tests) qui sont **exclus par le filtre** (leur
FullyQualifiedName contient `Omika.Tests.Connection.HfSqlOleDb`), ainsi que les
tests `E2EOptionsTests` (10 tests visibles). Le nombre de tests "visibles" a
donc diminué de 6 par rapport à la référence 1748, car le dernier lot a déplacé
dans le namespace `HfSqlOleDb` (exclu par le filtre) des tests qui étaient
auparavant comptés dans la suite visible. Le filtre n'a pas changé : il exclut
désormais davantage de tests car davantage de tests portent le préfixe
`HfSqlOleDb` dans leur FullyQualifiedName.
**Note** : le filtre exclut aussi des tests **unitaires purs** (ex.
`HfSqlOleDbConnectionStringBuilderTests`, 11 tests) qui ne nécessitent aucun
serveur HFSQL. Ces tests sont exclus uniquement à cause de leur nom de classe
contenant `HfSqlOleDb`, pas parce qu'ils sont des tests d'intégration. C'est un
effet de bord du filtre par FullyQualifiedName.
## 17. Correction transactionnelle — idempotence retry (implémentée et testée)
La fenêtre de duplication retry (business réussi mais terminal absent) a été
fermée par une **transaction OLE DB locale BATIK_TEST partagée** pour chaque
opération Sync réussie.
### Abstraction retenue
Une abstraction ciblée `IWebServiceTransactionContext` (pas un framework
UnitOfWork générique) expose la session transactionnelle BATIK_TEST :
- `Connection` : la connexion OLE DB BATIK_TEST physique partagée.
- `BeginAsync` / `CommitAsync` / `RollbackAsync`.
`BusinessOleDbConnection` et `WebServiceHfSqlConnection` utilisent cette même
session physique pendant le traitement, garantissant que les commandes métier
(Intervention) et les commandes Sync (MobLog, MobReception) s'exécutent sur la
**même `OleDbConnection`** et la **même `OleDbTransaction`**.
### Support transactionnel HFSQL OLE DB
`HfSqlOleDbConnection` expose désormais `BeginTransaction` / `Commit` /
`Rollback` via `System.Data.OleDb`. Les commandes exécutées pendant une
transaction active reçoivent `command.Transaction = transaction`. Les commandes
hors transaction continuent de fonctionner normalement.
### Orchestration (SyncProcessor)
L'orchestration transactionnelle reste au niveau `SyncProcessor` (le handler
métier reste pur, sans `BeginTransaction`/`Commit`/`Rollback`) :
- **SUCCESS** : `Begin` → handler → MobLog SUCCESS → finalisation MobReception →
`Commit`. Soit tout est durable, soit rien.
- **Retryable failure** (exception ou TransientFailure) : `Rollback` de la
transaction métier, puis retry hors de la transaction annulée (MobReception
reste disponible, retryCount incrémenté). Aucune Intervention partielle durable.
- **PermanentFailure / REJECTED** : `Rollback` des modifications métier, puis
MobLog REJECTED + finalisation MobReception dans une écriture séparée et
durable (le diagnostic REJECTED n'est pas perdu avec le rollback métier).
### TOSRV
TOSRV (`BATIK.01_BLS`) reste **complètement hors** de cette transaction. Aucune
transaction distribuée, aucun DTC, aucun partage de la transaction BATIK_TEST
avec `ServiceConnection`.
### GetTerminalResultAsync
`GetTerminalResultAsync` reste **impérativement** appelé **avant** le handler :
il protège le replay terminal (MobLog SUCCESS existe → handler non rappelé). La
transaction ajoute la protection complémentaire avant terminal.
### Tests ajoutés (SyncProcessorTests)
- `Rollback_On_Exception_Before_MobLog_Leaves_No_Durable_Business` : handler
écrit une Intervention puis lève une exception → ROLLBACK → Intervention
durable = 0, MobLog SUCCESS = 0.
- `Retry_After_Rollback_Commits_Business_And_MobLog` : après rollback, retry →
COMMIT → Intervention = 1, MobLog SUCCESS = 1.
- `Crash_After_Commit_Does_Not_Reapply_Handler_On_Retry` : après COMMIT, replay
→ GetTerminalResultAsync SUCCESS → handler non rappelé, aucune Intervention
supplémentaire.
- `Two_Different_OperationIds_Produce_Two_Business_Creations` : deux OperationId
distincts → 2 créations métier (la transaction n'est pas une déduplication
globale).
**État : implémentée et testée (tests transactionnels verts), mais pas encore
validée par un E2E physique (garde-fous `false`).**
## 18. Feu vert pour LOT INTERVENTION MOBILE WRITE
**PARTIEL** — le test end-to-end réel **CREATE** a été **exécuté** (EXECUTED) et
a **abouti** (SUCCESS) : l'Intervention `E2E_TEST_5b915439` a été créée dans
BATIK_TEST, le MobLog a enregistré `Success`, et l'endpoint status a retourné
`SUCCESS`. Le pipeline complet (API → TOSRV → Worker → MobReception → Handler →
Business → MobLog → Status) est **réellement validé** pour CREATE.
Le test **Replay** a été **exécuté** (EXECUTED) mais a **échoué** : le status est
resté `Pending` au moment de la vérification (le Worker externe n'a pas traité
l'opération dans le délai du test). Le Worker a néanmoins traité l'opération
Replay **après** le test, créant l'Intervention `E2E_TEST_0fce297f`.
**Diagnostic exact (micro-lot idempotence) :** le test Replay **actuel** génère
son **propre** OperationId (`0fce297f-e4e4-45ad-9027-b4e6e23d5c8a`), distinct de
celui du CREATE (`5b915439`). `E2E_TEST_0fce297f` est donc l'Intervention
**normale** du test Replay, **PAS un doublon** du CREATE. Le test Replay actuel
est **conceptuellement incorrect** : il envoie les deux POST sans attendre le
SUCCESS terminal du premier. Il a été **corrigé** (autonome, polling terminal,
comptage métier = 1). L'idempotence terminale n'est donc **pas encore démontrée**
par une exécution réelle (garde-fous `false`).
Le feu vert **complet** pour le lot INTERVENTION MOBILE WRITE reste conditionné
à une nouvelle exécution CREATE + Replay réussie avec le test Replay corrigé
(autonome, polling terminal, comptage métier = 1).
+287
View File
@@ -0,0 +1,287 @@
# Estimation de l'effort de développement — Projet Omika (ex-BATIK)
> **Objet** : estimer le temps qu'aurait nécessité le développement de ce projet **réalisé entièrement par un humain** (sans IA), afin de confronter cette estimation à l'hypothèse posée :
> **« 28 jours avec activité ≈ 28 journées actives × 6 h = 168 h »**.
>
> **Date d'analyse** : 11 septembre 2026 · **Méthode** : mesures objectives du livrable + reconstruction de la chronologie réelle + estimation bottom-up par composant, croisée par un modèle de productivité.
> **Aucun fichier du projet n'a été modifié.**
---
## 1. Verdict en une page
| Question | Réponse |
|---|---|
| Les 28 jours d'activité sont-ils réels ? | **Oui, et vérifiables.** Le projet a été construit du **06/08/2026 au 11/09/2026**, avec **22 jours réellement actifs** (tous du mardi au vendredi ; aucun lundi, aucun week-end). |
| Les 168 h décrivent-elles bien le travail effectué ? | **Oui, comme temps de travail *assisté par IA*.** Les mesures donnent ≈ **200 à 210 h** d'activité outillée, soit l'ordre de grandeur de 28 × 6 h. |
| Ces 168 h sont-elles une estimation valable du **développement humain** ? | **Non.** Pour produire ce livrable à la main, l'estimation est de **≈ 630 à 1 000 jours-homme**, valeur centrale **≈ 800 jours-homme** (≈ **3 800 à 6 000 h**, centrale ≈ 4 800 h). |
| Écart avec l'hypothèse | **×22 à ×36**, valeur centrale **≈ ×29**. |
| Traduction projet | 1 développeur senior seul : **≈ 3 à 4,5 ans**. Petite équipe de 3 : **≈ 13 à 21 mois**. |
**En une phrase** : les 28 jours mesurent correctement le **temps écoulé pour piloter une IA** qui a produit le code ; ils ne mesurent pas — et ne peuvent pas mesurer — l'**effort de développement humain équivalent**, qui se situe autour de **800 jours-homme**.
---
## 2. Méthode et pourquoi trois approches
Une estimation unique sur un projet de cette taille n'est pas fiable. Trois méthodes indépendantes ont été croisées :
1. **Mesure du livrable** (ce qui existe réellement : lignes, composants, tables, endpoints, tests, documentation, corpus legacy analysé).
2. **Reconstruction de la chronologie** (horodatages des fichiers, journaux de session des assistants, journaux de build/test) → mesure de l'effort réellement écoulé.
3. **Estimation bottom-up** par composant (5 analyses indépendantes par couche), puis **contre-vérification par un modèle de productivité** (lignes effectives / jour-homme).
**Convention** : 1 jour-homme (j-h) = **6 h de travail effectif**, conformément à l'hypothèse posée.
**Nature de l'estimation** : il s'agit du coût de **reproduction à neuf de l'existant, à qualité et périmètre identiques**, en incluant conception, tests et documentation — c'est-à-dire ce qu'un humain aurait dû fournir.
---
## 3. Ce que contient réellement le livrable (mesuré)
### 3.1 Volumétrie du code
| Périmètre | Fichiers | Lignes |
|---|---|---|
| `src/Omika.Core` (domaine, contrats, services, sync mobile) | 321 | 16 229 |
| `src/Omika.Data` (HFSQL, bi-schéma Batik/Omika) | 267 | 36 197 |
| `src/Omika.App` (UI Avalonia) | 136 + 51 `.axaml` | 14 402 + 7 728 |
| `src/Omika.App.Local` (SQLite, SyncEngine, Outbox) | 136 | 13 613 |
| `src/Omika.Api` (web service) | 24 | 3 549 |
| `src/Omika.Worker` (service de synchronisation) | 8 | 1 106 |
| Hôtes Desktop / Android / iOS | 5 | 106 |
| **Sous-total production** | **897 `.cs` + 51 `.axaml`** | **85 202 C# + 7 728 XAML** |
| `tests/Omika.Tests` | 164 | 43 824 |
| `tools/` (17 sondes de rétro-ingénierie) | 25 | 2 893 |
| **TOTAL code** | **≈ 1 137** | **≈ 139 650** |
### 3.2 Qualité et densité réelle du code
Analyse ligne à ligne (hors lignes vides, commentaires, accolades et déclarations) :
| Nature | Lignes | Commentaire |
|---|---|---|
| **Logique réelle** | **71 357** | algorithmes, SQL, sync, règles métier |
| **Déclaratif / boilerplate** | **33 485** | modèles, DTO, entités, interfaces, attributs, propriétés auto |
| **Balises XAML** | **7 249** | mise en page et design system |
| **Total brut** | **166 478** | |
Sur les 118 classes de modèles HFSQL (11 846 lignes), **seules 377 lignes sont de la logique** : le reste est déclaratif, et **107 des 118 fichiers sont auto-générés** par `tools/generate_models.py` depuis le dictionnaire de compatibilité. C'est un point important : ce volume ne se traduit pas en effort humain proportionnel.
### 3.3 Completude du livrable (facteur rassurant pour l'estimation)
- **0 `NotImplementedException`**, **0 `TODO` réel**, **0 `HACK`/`FIXME`**, **0 test ignoré** dans tout `src/`.
- 8 agrégats métier documentés `VERIFIED` (CRUD complet testé sur bases réelles).
- Réserves identifiées (à traiter avant production, non comptées ci-dessous) : base SQLite locale **non chiffrée**, jeton Bearer stocké **en clair**, pas de rate limiting sur les endpoints publics de licence, pas de framework de validation.
### 3.4 Surface fonctionnelle
| Indicateur | Valeur |
|---|---|
| Projets dans la solution | 13 |
| Écrans applicatifs distincts | **29** |
| ViewModels / Vues XAML / contrôles personnalisés / convertisseurs | 42 / 51 / 14 / 15 |
| Endpoints HTTP | **97** |
| Tables HFSQL physiques distinctes manipulées | **65** (118 classes de modèles, 113 attributs `[HfSqlTable]`) |
| Couples table-rubrique mappés (dictionnaire de compatibilité) | **720** |
| Méthodes publiques de repositories | **≈ 330** |
| Instructions SQL brutes | **≈ 500** |
| Transactions explicites | 186 |
| Bases HFSQL distinctes (Business / Service / WebService / Licence / Sync) | 4-5 |
| Tables SQLite locales + repositories locaux | 29-30 / 18 |
| Tests | **2 038** méthodes (2 020 `[Fact]`, 18 `[Theory]`) |
| Fakes / stubs écrits à la main (aucune lib de mock) | 100+ |
| Documentation produite | **≈ 8 200 lignes** rédigées (22 documents) + dictionnaire de compatibilité JSON **19 021 lignes** + maquette HTML 593 lignes |
### 3.5 Ampleur du legacy rétro-ingénieré
| Corpus | Lignes |
|---|---|
| `docs/BATIK.md` (export WINDEV du legacy `Isis_BO`) | 303 801 |
| `docs/OMIKA.md` (export WINDEV de la refonte `Batik`/Omika) | 271 909 |
| Documentation des 3 web services / services legacy | 24 582 |
| **Total corpus legacy analysé** | **≈ 600 300 lignes** |
Système legacy à remplacer (mesuré dans les exports) : **≈ 1 600 procédures** (1 009 + 614 comptées), **140 fenêtres** `FEN_*`, **≈ 380 classes**, **46 états d'impression**, **48 / 58 tables** selon la génération (59 en union), **3 web services / services**, **4 variantes clients**. Soit un capital de développement historique estimé à **10-20 personnes-années sur ~8 ans**.
---
## 4. Chronologie réelle du projet (mesurée, pas déclarée)
### 4.1 Fenêtre et jours actifs
Le projet a été intégralement créé entre le **06/08/2026** et le **11/09/2026** (aucun contenu antérieur : tous les fichiers du dépôt — code *et* documentation — portent une date ≥ 06/08/2026).
| Indicateur | Valeur |
|---|---|
| Période calendaire | 37 jours |
| Jours ouvrés (lun-ven) dans la période | 27 |
| **Jours réellement actifs** | **22** |
| Jours avec écriture de code source | 21 |
| Jours actifs hors mardi-vendredi | **0** |
| Jours ouvrés actifs / jours ouvrés disponibles | **22 / 22 (100 %)** |
Motif parfaitement net : activité du **mardi au vendredi** ; les 5 lundis et tous les week-ends de la période sont **inactifs**. Les 22 jours « mardi-vendredi » de la fenêtre ont **tous** été travaillés.
### 4.2 Volume de travail outillé
| Source de mesure | Valeur |
|---|---|
| Sessions d'assistant CodeWhale (07/08 → 20/08) | 17 sessions, **2 515 messages humains**, 607 M tokens, 52 140 s de temps de génération |
| Sessions d'assistant DSH (20/08 → 11/09) | 31 sessions, 442 messages humains, 769 711 événements, 183,6 Mo compressés |
| Amplitude horaire des journées (horodatages) | **8 h à 13 h** de fenêtre, moyenne ≈ 9,6 h/jour |
| Campagnes de build/test laissées dans le dépôt | **108 journaux**, 36 exécutions de tests |
| Croissance de la suite de tests | **260 → 1 859 tests** en 36 exécutions successives |
| Effort outillé total estimé | **≈ 200 à 210 h** |
### 4.3 Points de contrôle sur l'hypothèse « 28 jours × 6 h »
- **Les 28 jours** : la mesure donne 22 jours actifs. Le chiffre de 28 est légèrement supérieur mais du **bon ordre de grandeur** ; il a vraisemblablement été obtenu en comptant aussi les journées de faible activité (12-14/08 : builds et mise en place VPN ; 28/08 ; 04/09).
- **Les 6 h/jour** : les journées réellement observées couvrent 8 à 13 h d'amplitude. 6 h de travail **effectif** net est donc une hypothèse prudente et plausible.
- **Conclusion partielle** : *28 jours × 6 h = 168 h est une description correcte et même plutôt prudente du travail assisté par IA.*
---
## 5. Estimation de l'effort humain — décomposition bottom-up
Cinq analyses indépendantes par couche, chacune fondée sur le code réel, les preuves et la complexité intrinsèque.
| # | Composant | Contenu | Basse | Haute | Central |
|---|---|---|---|---|---|
| 1 | **Analyse & rétro-ingénierie legacy** | Inventaire des 2 générations, rétro-ingénierie du schéma HFSQL (48 → 58 tables, 720 rubriques, zombies, mappings de types, liaisons), lecture du code WINDEV (≈ 1 600 procédures, 140 fenêtres, ≈ 380 classes), rétro-ingénierie des 3 web services (206 procédures), sondage réel OLE DB, rédaction des specs (architecture, 7 lots, modèle canonique, CRUD) | 50 | 80 | 60 |
| 2 | **`Omika.Core`** | Domaine (38 entités, 5 value objects, 21 commandes), 125 DTO, 61 interfaces, 17 services CRUD, moteurs document/rapport (PDF/DOCX, parseur RTF/CP1252, parseur JSON WINDEV 305 l.), **Services/Mobile** (licence AES-256 rétro-ingénierée, jeton HMAC-SHA256, résolution d'identité GUID↔PK, snapshot, sync idempotente) | 55 | 96 | 80 |
| 3 | **`Omika.Data` — couche HFSQL** | 20 classes de connexion (dualité **ODBC / OLE DB `PCSOFT.HFSQL`**), 118 modèles physiques, 29 classes de mapping, **19 repositories Batik + 20 Omika + 22 adaptatifs**, ≈ 330 méthodes, ≈ 500 requêtes SQL brutes, 186 transactions, détection de schéma par canaris, dérivation MD5 des mots de passe de fichiers HFSQL, topology 4 bases | 230 | 380 | 300 |
| 4 | **`Omika.App` + `Omika.App.Local`** | 29 écrans, 42 ViewModels, design system 755 l., 14 contrôles, 15 convertisseurs, Shell responsive 2 layouts, 3 hôtes plateformes ; côté local : schéma SQLite 29-30 tables, 18 repositories, **SyncEngine** (4 étapes, états, reprise après crash), **Outbox**, **réconciliation d'identité A→B + 5 réécrivains de payloads**, SnapshotApplier atomique, offline-first multi-entités, documents multipart | 148 | 189 | 170 |
| 5 | **`Omika.Api`** | 97 endpoints, DI multi-connexions, middleware Bearer, service de permissions + périmètre planning, licence (activation/validation + émission de jeton), sync (3 endpoints), bootstrap, documents base64, emails/rapports | 20 | 30 | 25 |
| 6 | **`Omika.Worker`** | Boucle de polling, ordonnancement CREATE→UPDATE→DELETE, déduplication intra-cycle, **idempotence à 2 niveaux** (OperationId + résultat terminal MobLog), transaction unique multi-bases, isolation par opération, retry/rejet, 4 handlers (dont refus explicite de tout SUCCESS par défaut) | 10 | 16 | 13 |
| 7 | **Suite de tests** | 2 038 tests, 43 824 lignes, 100+ fakes ADO.NET/HTTP écrits à la main, fixtures `WebApplicationFactory`, intégrations **réelles** HFSQL (7 fichiers, cycle CREATE→UPDATE→ARCHIVE→ROLLBACK), **E2E multi-processus** (HTTP → API → TOSRV → Worker externe → MobReception → handler → MobLog → statut) | 50 | 80 | 65 |
| 8 | **Outils de sondage** | 17 programmes d'investigation (audit de schéma, audit de modèle, dérivation de mots de passe, idempotence, détection N+1, résolution d'identité) | 3 | 6 | 5 |
| | **Sous-total composants (après dédoublonnage des tests et de la rétro-ingénierie déjà comptés en ligne 1)** | | **531** | **812** | **≈ 670** |
### 5.1 Charges transverses (non attribuables à un composant)
| Poste | Justification | Basse | Haute | Central |
|---|---|---|---|---|
| Architecture & conception globale | 13 projets, protocole de sync mobile, stratégie bi-schéma Batik/Omika, multiplateforme, modèle d'identité canonique | 10 | 20 | 15 |
| Environnement & infrastructure | Serveur HFSQL, installation et déverminage des providers ODBC **et** OLE DB `PCSOFT.HFSQL`, accès VPN (FortiClient installé les 12-13/08), SDK/émulateur Android, chaîne iOS, bases de test BATIK_TEST / OMIKA_TEST, configuration de développement | 15 | 30 | 22 |
| Intégration & débogage | 36 campagnes de tests successives (260 → 1 859 tests), cycles réels contre bases réelles, E2E multi-processus, correction d'encodage Windows-1252, contournement des limites du provider (marshalage `DateOnly`, colonnes calculées, padding `\0` des mémos) | 40 | 70 | 55 |
| Pilotage, clarification du besoin, validation | Documents de lot avec points d'arrêt et divergences (« STOP » sur la base Licence), matrices de couverture CRUD, points de validation fonctionnelle | 20 | 40 | 28 |
| Packaging, service Windows, builds mobiles | Service Windows du Worker, APK Android, déploiement desktop | 10 | 20 | 15 |
| **Sous-total transverse** | | **95** | **180** | **≈ 135** |
### 5.2 Total
| | Basse | Haute | **Central** |
|---|---|---|---|
| Composants | 531 | 812 | 670 |
| Transverse | 95 | 180 | 135 |
| **TOTAL (jours-homme)** | **≈ 630** | **≈ 1 000** | **≈ 800** |
| **TOTAL (heures à 6 h/j)** | **≈ 3 780 h** | **≈ 6 000 h** | **≈ 4 800 h** |
| **TOTAL (personnes-années à 1 400 h)** | 2,7 | 4,3 | **3,4** |
---
## 6. Contre-vérification par la productivité
Modèle indépendant : lignes **effectives** produites ÷ productivité réaliste d'un développeur senior (code final, intégré et débogué, hors temps de réunion).
| Nature | Lignes effectives | Productivité retenue | Jours-homme |
|---|---|---|---|
| Déclaratif / boilerplate (modèles, DTO, entités, interfaces) | 33 485 | 250-400 l./j | 84-134 |
| Logique de production (SQL, sync, métier, UI) | 43 158 | 60-120 l./j | 360-719 |
| Logique de test | 28 199 | 100-180 l./j | 157-282 |
| XAML (mise en page, design system) | 7 249 | 80-150 l./j | 48-91 |
| Documentation rédigée (`docs/*.md`) | 8 169 | 250-500 l./j | 16-33 |
| Dictionnaire de compatibilité (JSON/CSV, produit par outillage) | ≈ 19 300 | outillé | 5-10 |
| **Sous-total production** | **≈ 139 500** | | **670-1 269** |
Ce sous-total **ne comprend pas** la rétro-ingénierie du legacy (≈ 600 000 lignes lues pour ≈ 139 500 lignes produites) ni les charges transverses. En intégrant un abattement raisonnable pour ces postes déjà couverts par la ligne « analyse legacy », on converge vers :
- **Fourchette du modèle de productivité : ≈ 600 à 1 100 j-h**
- **Fourchette du bottom-up : ≈ 630 à 1 000 j-h**
Les deux méthodes **convergent vers une valeur centrale de l'ordre de 800 jours-homme**, ce qui donne confiance dans l'ordre de grandeur.
---
## 7. Mise en perspective avec l'hypothèse de 168 h
| Grandeur | Valeur |
|---|---|
| Effort assisté par IA mesuré (réel) | ≈ 200-210 h |
| Hypothèse utilisateur (28 j × 6 h) | **168 h** |
| Effort humain équivalent estimé (central) | **≈ 4 800 h** (800 j-h) |
| **Écart central** | **≈ ×29** |
| Écart minimal (hypothèse basse 630 j-h vs 168 h) | **×22** |
| Écart maximal (hypothèse haute 1 000 j-h vs 168 h) | **×36** |
### Pourquoi l'écart est aussi important (et pourquoi ce n'est pas une erreur)
En ≈ 210 h de travail outillé, le projet a produit **≈ 194 000 lignes** (code, tests, documentation, artefacts) — soit **≈ 925 lignes par heure**. Un développeur humain produit, selon la nature du code, **5 à 50 lignes par heure** de code final intégré et débogué.
Autrement dit, l'écart ne vient pas d'une sous-estimation des 28 jours : il vient du fait que **l'IA a produit du volume à une cadence qu'un humain ne peut pas atteindre**, en particulier sur tout ce qui est mécanique et répétitif :
- 107 modèles de tables générés automatiquement ;
- 125 DTO et 61 interfaces quasi déclaratifs ;
- 2 038 tests dont une large part de structure répétitive ;
- 8 200 lignes de documentation rédigée + un dictionnaire de 19 000 lignes ;
- les 289 classes de mapping et la duplication bi-schéma Batik/Omika (15 800 lignes d'implémentations jumelles).
Un humain qui aurait fait ce projet « à la main » n'aurait pas produit ces 194 000 lignes : il aurait pris des raccourcis (génération de code, factorisation, moins de tests, moins de documentation). **C'est pourquoi l'estimation retenue est celle de la reproduction du périmètre et de la qualité observés, et non une simple conversion lignes → heures.**
---
## 8. Traduction en scénarios projet
| Scénario | Durée |
|---|---|
| **1 développeur senior seul** (à ~220 j-h/an) | **≈ 2,9 à 4,5 ans** (central ≈ 3,6 ans) |
| **2 développeurs** | ≈ 1,5 à 2,3 ans |
| **Petite équipe de 3** (1 senior + 2 confirmés, coordination incluse) | **≈ 13 à 21 mois** |
| **Équipe de 4-5** (dev + analyste + QA à temps partiel) | ≈ 9 à 14 mois |
Répartition indicative d'une équipe de 3 : 1 senior sur le socle Data/sync/identité (≈ 300 j-h), 1 développeur sur l'UI et le local (≈ 200 j-h), 1 développeur sur le Core/API/Worker/tests (≈ 200 j-h), plus l'analyse legacy (≈ 60 j-h) et le transverse (≈ 135 j-h) partagés.
---
## 9. Sensibilité, limites et précautions d'usage
### 9.1 Ce qui fait varier le résultat
| Facteur | Impact |
|---|---|
| **Périmètre des tests** | C'est le premier levier : 2 038 tests et 100+ fakes représentent ≈ 50-80 j-h. Un projet sans cette discipline tombe de 10-15 %. |
| **Rétro-ingénierie du legacy** | 50-80 j-h d'analyse pure, non compressibles, et prérequis à tout le reste. |
| **Dualité de schéma Batik/Omika** | ≈ 15 800 lignes d'implémentations jumelles : c'est le principal multiplicateur de coût de la couche Data. |
| **Documentation** | ≈ 8 200 lignes rédigées + dictionnaire produit : 21-43 j-h selon l'exigence. |
| **Maturité visée** | Le livrable n'est pas encore « production mobile » (chiffrement de la base locale, coffre de jeton, rate limiting, validation). Une version durcie ajouterait **+15 à +30 j-h**. |
| **Connaissance préalable** | L'estimation suppose l'apprentissage du legacy HFSQL **inclus**. Un développeur déjà expert HFSQL/OLE DB pourrait gagner 10-15 %. |
### 9.2 Limites de l'estimation
- **Aucun dépôt Git** : la chronologie a été reconstruite à partir des horodatages de fichiers, des journaux de session et des journaux de build ; elle est cohérente mais ne remplace pas un historique de commits.
- **Les messages humains comptés** (2 515 + 442) incluent les messages de pilotage d'outil ; ils mesurent l'intensité de la supervision, pas des heures de développement.
- **Les estimations par composant proviennent de cinq analyses distinctes** avec des hypothèses propres ; le dédoublonnage (tests et rétro-ingénierie comptés une seule fois) a été appliqué explicitement, mais une incertitude de ±10 % subsiste sur les frontières entre lots.
- **Le code a été produit par IA** : il peut contenir des défauts non détectés par la suite de tests. Un humain repartant de zéro pourrait rencontrer des difficultés différentes — mais il n'aurait pas non plus à découvrir et corriger ces défauts.
### 9.3 Recommandation de présentation
Si l'objectif est de communiquer un chiffre défendable, distinguer explicitement **deux mesures qui ne répondent pas à la même question** :
1. **Temps de production assistée par IA** : **≈ 28 jours d'activité, ≈ 170-210 h** — mesure du temps de pilotage et d'itération réel.
2. **Équivalent de développement humain** : **≈ 630 à 1 000 jours-homme (≈ 3 800 à 6 000 h)**, valeur centrale **≈ 800 j-h (≈ 4 800 h)** — mesure de l'effort qu'aurait exigé la construction du même livrable sans IA.
Présenter le premier chiffre comme du « temps humain de développement » conduirait à une sous-évaluation d'un facteur **≈ 25 à 30**, ce qui fragiliserait l'ensemble de l'estimation si elle devait être auditée.
---
## 10. Synthèse finale
| | Valeur |
|---|---|
| Jours d'activité mesurés | **22** (hypothèse posée : 28) |
| Heures assistées par IA mesurées | **≈ 200-210 h** (hypothèse posée : 168 h) |
| **Effort humain équivalent (estimation)** | **630 – 1 000 j-h** |
| **Valeur centrale retenue** | **≈ 800 j-h ≈ 4 800 h ≈ 3,4 personnes-années** |
| Écart avec l'hypothèse de 168 h | **×22 à ×36 (central ≈ ×29)** |
| Traduction la plus parlante | **1 développeur senior : ≈ 3 à 4,5 ans · équipe de 3 : ≈ 13 à 21 mois** |
**L'hypothèse « 28 jours × 6 h » n'est pas réaliste comme estimation de développement humain**, mais elle est cohérente comme mesure du temps réellement passé à produire le projet **avec l'aide d'une IA**.
Binary file not shown.
+682
View File
@@ -0,0 +1,682 @@
# Livrer un pack de langue : obtenir les modèles, les déposer, mettre à jour le manifeste
> **Objet** : procédure complète de livraison d'une langue (modèles ONNX + vocabulaire)
> sur `SRV-OMIKA`, jusqu'à la mise à jour du manifeste servi par `OmikaLanguesApi`.
>
> **Prérequis** : le service `OmikaLanguesApi` est installé, `Langues__RootPath` est
> posée en variable MACHINE, et le vhost Apache expose le service en HTTPS
> (`DEPLOIEMENT_API_LANGUES.md` §7).
>
> **Ce document ne traite pas** de la qualité de traduction (choix du modèle) ni du
> poids du livrable mobile : voir `VALIDATION_TRADUCTION_ONNX.md` §4.
---
## 0. Vue d'ensemble
| # | Étape | Où | Qui |
|---|---|---|---|
| 1 | Exporter les modèles en ONNX | poste avec accès Internet | dev |
| 2 | Construire l'arborescence du pack | poste | dev |
| 3 | Valider le pack **sur le poste** | poste | dev |
| 4 | Déposer les fichiers sur le serveur | SRV-OMIKA | admin |
| 5 | **Générer** le manifeste sur le serveur | SRV-OMIKA | admin |
| 6 | Contrôler (disque, HTTP, empreinte) | SRV-OMIKA | admin |
| 7 | Rafraîchir et télécharger sur le téléphone | appareil | utilisateur |
**Trois règles, et tout le reste en découle :**
1. **Le manifeste n'est jamais recopié d'une autre machine.** Il contient la
taille et l'empreinte SHA-256 de **vos** fichiers : il se génère sur le serveur,
à partir de ce qui est réellement sur son disque (§5).
2. **Les fichiers d'abord, le manifeste ensuite.** Un manifeste qui décrit des
fichiers absents (ou pas encore remplacés) fait refuser le pack.
3. **Aucun redémarrage de service n'est nécessaire** — mais le mobile garde son
index mémorisé : il faut un rafraîchissement explicite (§7).
---
## 1. Étape 1 — Obtenir les modèles (poste de développement)
Les modèles ne sont **pas** livrés avec Omika (poids + licences) et ne sont pas
versionnés dans le dépôt. Le plus simple est un modèle **OPUS-MT** de
Helsinki-NLP, exporté en ONNX :
```powershell
pip install "optimum[onnxruntime]" transformers sentencepiece
# fr -> es : sert le sens « depuisFrancais » du pack « es »
optimum-cli export onnx --model Helsinki-NLP/opus-mt-fr-es .\export\fr-es
# es -> fr : sert le sens « versFrancais » du pack « es »
optimum-cli export onnx --model Helsinki-NLP/opus-mt-es-fr .\export\es-fr
```
> ⚠️ **`optimum-cli` peut rester introuvable juste après une installation
> réussie.** Avec le Python du **Microsoft Store**, pip range les programmes dans
> un dossier utilisateur qui **n'est pas dans le PATH** — `pip install` se
> termine sans erreur, puis chaque fenêtre répond `optimum-cli : commande
> introuvable`. Pour savoir où ils sont allés :
>
> ```powershell
> python -c "import sysconfig; print(sysconfig.get_path('scripts','nt_user'))"
> ```
>
> Le script `scripts\Exporter-Packs-Langues.ps1` **sait aller le chercher là tout
> seul** (§1.1). Pour les commandes manuelles ci-dessus, utilisez le chemin
> complet, ou pour la session en cours :
>
> ```powershell
> $env:Path += ';' + (python -c "import sysconfig; print(sysconfig.get_path('scripts','nt_user'))")
> ```
> ⚠️ **`ERROR: Could not install packages due to an OSError: [WinError 206] Nom de
> fichier ou extension trop long`** — l'installation s'arrête, et `optimum-cli`
> n'est **jamais** créé : le script d'export continuera de dire « introuvable ».
> Ce n'est ni un bug de pip ni un bug du script : Windows plafonne les chemins à
> **260 caractères**, et le Python du **Microsoft Store** installe ses paquets
> dans un dossier déjà très long —
> `…\LocalCache\local-packages\Python312\site-packages\` = **140 caractères** à
> lui seul. Or `torch` embarque des fichiers de licence imbriqués profondément :
> le chemin du dossier atteint **249 caractères**, et les fichiers qu'il contient
> dépassent la limite (mesuré sur ce poste, `LongPathsEnabled = 0`).
>
> **Solution : installer dans un environnement virtuel au chemin court.** Cela
> retire ~110 caractères au préfixe, sans droit administrateur :
>
> ```powershell
> python -m venv C:\OmikaPy
> C:\OmikaPy\Scripts\python.exe -m pip install "optimum[onnxruntime]" transformers sentencepiece
> C:\OmikaPy\Scripts\Activate.ps1 # à réactiver dans chaque nouvelle fenêtre
> ```
>
> Le venv est **isolé** : le `torch` à moitié installé dans le Python du Store
> (resté après l'échec) ne le perturbe pas. Le script d'export trouve alors
> `optimum-cli` seul (§1.1) ; sans activation, indiquez l'interpréteur :
> `-Python C:\OmikaPy\Scripts\python.exe`.
>
> *Autre voie, sans environnement virtuel : autoriser les chemins longs dans
> Windows (PowerShell **en administrateur**), puis relancer la même
> installation. C'est un réglage machine, utile à tous les outils.*
>
> ```powershell
> Set-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem' -Name LongPathsEnabled -Value 1 -Type DWord
> ```
L'export produit notamment :
```
encoder_model.onnx
decoder_model.onnx
decoder_with_past_model.onnx <-- NE PAS LIVRER (voir ci-dessous)
source.spm
target.spm
```
> ⚠️ **`decoder_with_past_model.onnx` ne doit jamais être livré.** C'est un
> décodeur à cache de clés/valeurs destiné à la génération incrémentale :
> l'application l'**exclut** du chargement, mais s'il est présent dans le dossier
> il est **déclaré dans le manifeste et donc téléchargé par chaque téléphone**
> — ~120 Mo de données mobiles pour rien. Le générateur de manifeste le signale
> (§5).
>
> ⚠️ **Un modèle Marian a DEUX vocabulaires** (`source.spm` et `target.spm`) :
> les livrer tous les deux est obligatoire, sinon la traduction produira du
> charabia (les identifiants de sortie seraient décodés dans le mauvais
> référentiel).
### 1.1 Plusieurs langues d'un coup (Europe, Est, Caucase…)
Pour livrer un ensemble de langues, le script `scripts\Exporter-Packs-Langues.ps1`
enchaîne tout : vérification de l'existence de chaque paire sur HuggingFace,
export ONNX, rangement dans l'arborescence du pack, rapport.
```powershell
# 0. UNE SEULE FOIS : l'outil d'export. Sans lui le script s'arrête aussitôt,
# AVANT toute écriture : « rien ne se passe » = c'est ce prérequis qui manque.
pip install "optimum[onnxruntime]" transformers sentencepiece
# 1. VOIR LE PLAN avant de payer ~30 Gio (aucun réseau, rien n'est écrit)
.\scripts\Exporter-Packs-Langues.ps1 -Simulation
# 1bis. QUELLES LANGUES SONT LIVRABLES ? Internet suffit, ni Python ni optimum :
# ~100 appels d'API, une à deux minutes, RIEN n'est téléchargé.
.\scripts\Exporter-Packs-Langues.ps1 -SansExporter
# 2. Tout exporter (51 langues ≈ 102 modèles)
.\scripts\Exporter-Packs-Langues.ps1
# Sélections partielles
.\scripts\Exporter-Packs-Langues.ps1 -Zone Est,Caucase
.\scripts\Exporter-Packs-Langues.ps1 -Codes es,pt,uk,ru
.\scripts\Exporter-Packs-Langues.ps1 -Codes ar # l'arabe est en zone « Divers »
```
Si l'étape 0 échoue avec **`[WinError 206] Nom de fichier ou extension trop
long`**, l'outil n'a pas pu être installé (limite de 260 caractères de Windows) :
voir la solution « environnement virtuel au chemin court » au §1, puis lancer le
script avec `-Python C:\OmikaPy\Scripts\python.exe`.
- **Pour savoir à l'avance quelles langues sont livrables** :
`.\scripts\Exporter-Packs-Langues.ps1 -SansExporter` interroge HuggingFace pour
toute la table et écrit le rapport **sans rien télécharger** (~100 appels d'API,
une à deux minutes). Les langues sans paire finissent dans « Paires
indisponibles », les autres dans « Paires disponibles (vérifiées, non
exportées) ». Cette commande ne demande **ni Python ni `optimum-cli`** : elle
répond donc avant même l'installation des outils.
- **Deux familles de modèles sont essayées, dans l'ordre** : `opus-mt-<langue>-fr`
(petit, ~300 Mo par sens) puis **`opus-mt-tc-big-<langue>-fr`** (~1 Go par sens),
publiée par Helsinki-NLP pour des paires que la première ne couvre pas. Le nom
réellement retenu est inscrit au rapport. Une langue déclarée absente l'est donc
après **quatre** vérifications (2 familles × 2 sens).
- **Repli familial** : pour `it`, `es`, `pt`, `ca`, `gl` et `ro`, le modèle
multilingue `opus-mt-tc-big-itc-itc` couvre les **deux sens** en un seul export.
Il n'est employé que si une paire bilingue manque (`pt` et `gl` n'en ont
aucune ; `it` n'a que `it-fr`), et ses 1,15 Go sont écrits **une seule fois**
dans `<code>\commun\` : les deux sens le partagent (§2.1.1).
- **Les paires qui n'existent pas sont signalées, jamais inventées.**
Helsinki-NLP ne couvre pas toutes les combinaisons avec le français : le
script teste chaque modèle sur HuggingFace **avant** de télécharger, et écrit
les absentes dans `RAPPORT-LANGUES.md`. Prévoir que plusieurs langues de la
liste (Caucase, Asie centrale) seront indisponibles.
- Si HuggingFace est **injoignable**, le script s'arrête au lieu de déclarer les
paires absentes : « je ne sais pas » n'est pas « il n'y en a pas ».
- Un pack n'est écrit que si les **deux** directions existent
(`-InclureDirectionsPartielles` pour l'accepter malgré tout).
- `decoder_with_past_model.onnx` et les fichiers de tokenizer ne sont **jamais**
conservés : le dossier de travail de l'export est supprimé après chaque sens.
- Le script est **reprenable** : une langue déjà exportée est ignorée
(`-Forcer` pour la refaire). Un arrêt en cours de route ne perd rien.
- Volume : ~300 Mo par sens, soit **~30 Gio pour 51 langues**, et plusieurs
heures de téléchargement puis de conversion.
### 1.2 Où vont les fichiers
| | Chemin | Après l'opération |
|---|---|---|
| **Packs ONNX** (le livrable) | `<RacinePacks>\<code>\<sens>\` (et `<code>\commun\` pour un modèle familial) | conservé — c'est ce qu'on transfère |
| **Cache HuggingFace** | `<RacinePacks>\.hf-cache\hub\` | supprimé après chaque export réussi |
| **Travail temporaire** | `<RacinePacks>\.tmp-<code>-<sens>\` | supprimé après chaque sens |
`<RacinePacks>` vaut **`packs`** par défaut, résolu depuis la racine du dépôt —
donc `D:\Avalonia\Omika\packs` sur ce poste. Pour l'écrire ailleurs (autre
disque, dossier de livraison) :
```powershell
.\scripts\Exporter-Packs-Langues.ps1 -RacinePacks D:\PacksLangues
```
Deux points importants :
- **Sans intervention, le cache HuggingFace va dans `%USERPROFILE%\.cache\huggingface`**
(donc sur `C:`), et chaque modèle y occupe une seconde copie de sa taille :
~29 Gio de plus. Le script force donc `HF_HOME` vers `<RacinePacks>\.hf-cache`
pour que tout reste sur le même disque, et **supprime la copie du modèle après
chaque export réussi** (elle n'a plus d'usage). Le pic de disque réel est donc
« les packs + ~300 Mo », pas le double. `-GarderCacheHuggingFace` conserve le
cache (utile pour enchaîner des `-Forcer` sans retélécharger).
Un `HF_HOME` déjà défini dans l'environnement est respecté.
- Par défaut la sortie est **dans l'arborescence des sources**
(`D:\Avalonia\Omika\packs`). Ce n'est pas un dépôt git ici, donc rien ne sera
committé, mais 29 Gio de binaires au milieu du code n'est pas souhaitable à
long terme : préférer `-RacinePacks D:\PacksLangues`.
- Les dossiers `.hf-cache` et `.tmp-*` sont ignorés par le générateur de
manifeste (`New-OmikaLanguesManifeste.ps1`) : ils ne sont jamais pris pour des
langues ni signalés comme fichiers non déclarés.
> **Pourquoi un modèle par langue, et pas un seul modèle multilingue ?**
> Le pipeline amorce le décodeur sur le seul jeton de début du vocabulaire
> (`OnnxTranslationService.GenererAsync`) : il n'existe **aucun** mécanisme de
> langue cible forcée (`forced_bos_token_id`). Un modèle multilingue (NLLB,
> mBART, M2M-100) produirait donc la langue par défaut du modèle, pas celle
> demandée. Passer à NLLB — un seul modèle pour toutes les langues, bien plus
> léger au total — demanderait d'**ajouter ce jeton au contrat** : une option de
> pipeline, un champ dans le manifeste et sa résolution. Ce n'est pas un réglage
> de déploiement.
---
## 2. Étape 2 — Construire l'arborescence du pack
> Si vous utilisez le script du §1.1, cette arborescence est produite
> automatiquement : cette étape sert alors de référence (et de mode opératoire
> manuel pour une langue isolée).
Un pack = **un code de langue**, contenant **deux sens**. La convention est
imposée par l'API et par l'application ; elle n'est pas configurable.
```
es/
├── versFrancais/ (espagnol -> français : dictée, avant insertion)
│ ├── encoder_model.onnx <- depuis .\export\es-fr\
│ ├── decoder_model.onnx <- depuis .\export\es-fr\
│ ├── source.spm <- depuis .\export\es-fr\source.spm (ESPAGNOL)
│ ├── target.spm <- depuis .\export\es-fr\target.spm (FRANÇAIS)
│ ├── vocab.json <- depuis .\export\es-fr\ (IDENTIFIANTS)
│ └── config.json <- depuis .\export\es-fr\ (DÉCODEUR)
└── depuisFrancais/ (français -> espagnol : lecture, affichage seul)
├── encoder_model.onnx <- depuis .\export\fr-es\
├── decoder_model.onnx <- depuis .\export\fr-es\
├── source.spm <- depuis .\export\fr-es\source.spm (FRANÇAIS)
├── target.spm <- depuis .\export\fr-es\target.spm (ESPAGNOL)
├── vocab.json <- depuis .\export\fr-es\
└── config.json <- depuis .\export\fr-es\
```
### 2.1 Noms de fichiers reconnus
L'application résout les rôles par le nom, avec des replis automatiques. Les
noms ci-dessous sont ceux qu'elle cherche **en premier** : les respecter évite
tout ambiguïté.
| Fichier | Rôle | Si absent |
|---|---|---|
| `encoder.onnx` / `encoder_model.onnx` | modèle principal du sens | premier `.onnx` du dossier qui n'est pas un décodeur |
| `decoder.onnx` / `decoder_model.onnx` | décodeur séparé (décodage glouton) | mode « modèle unique » |
| `*_with_past*.onnx` | **jamais utilisé** | — |
| `vocab.spm` | vocabulaire **commun** (NLLB, mBART, M2M-100) | premier `.spm` |
| `source.spm` | vocabulaire d'**entrée** (Marian/OPUS-MT) | `vocab.spm`, puis premier `.spm` |
| `target.spm` | vocabulaire de **sortie** (Marian/OPUS-MT) | `vocab.spm`, puis premier `.spm` |
| `vocab.json` | **identifiants du modèle** (obligatoire) | numérotation du `.spm` — **traduction fausse** |
| `config.json` | **amorce et fin du décodeur** (obligatoire) | `<s>` / `</s>` du `.spm` — génération fausse |
| `modele.json` | métadonnées d'un modèle multilingue (`>>fra<<`) | modèle dédié à une paire |
### 2.1.1 Dossier partagé `commun/`
Un modèle **multilingue** (famille de langues, type `opus-mt-tc-big-itc-itc`)
sert plusieurs langues avec **les mêmes poids**. Les déclarer dans chaque sens
ferait télécharger **deux fois 1,15 Go** sur chaque téléphone : ces fichiers vont
donc une seule fois dans un dossier `commun/`.
```
pt/
├── commun/ (poids PARTAGÉS par les deux sens)
│ ├── encoder_model.onnx
│ ├── decoder_model.onnx
│ ├── source.spm
│ ├── target.spm
│ ├── vocab.json
│ └── config.json
├── versFrancais/
│ └── modele.json { "jetonCible": ">>fra<<" }
└── depuisFrancais/
└── modele.json { "jetonCible": ">>por<<" }
```
L'application cherche **d'abord** dans le dossier du sens, **puis** dans
`commun/` : un pack qui fournit son propre `source.spm` garde la priorité, et un
pack qui n'a que des fichiers partagés fonctionne dans les deux sens. Le
`modele.json` reste **par sens** — c'est la seule chose qui diffère (l'étiquette
de langue : `>>fra<<` pour traduire vers le français, `>>por<<` depuis le
français).
> Les paires bilingues classiques (`opus-mt-es-fr` **et** `opus-mt-fr-es`) n'ont
> **pas** de dossier `commun` : leurs poids diffèrent dans chaque sens.
>
> Un téléphone ne télécharge que **la** langue secondaire choisie par
> l'utilisateur, jamais les autres : le partage ci-dessus divise par deux ce
> qu'il reçoit, il ne lui impose rien de plus.
#### Langues servies par un modèle de famille
Un seul dépôt couvre plusieurs langues, **dans les deux sens**, quand aucune
paire bilingue n'existe. L'exporteur le fait pour vous (`-Codes pt,gl,it`
suffit) : il exporte le modèle **une fois**, écrit les poids dans `commun/` et
les deux `modele.json`.
| Langue | Jeton aller | Jeton retour | Paire bilingue ? |
|---|---|---|---|
| `it` | `>>fra<<` | `>>ita<<` | `it-fr` seule (pas `fr-it`) |
| `es` | `>>fra<<` | `>>spa<<` | les deux (familial inutile) |
| `pt` | `>>fra<<` | `>>por<<` | **aucune** |
| `ca` | `>>fra<<` | `>>cat<<` | les deux (familial inutile) |
| `gl` | `>>fra<<` | `>>glg<<` | **aucune** |
| `ro` | `>>fra<<` | `>>ron<<` | les deux (familial inutile) |
Le modèle est `Helsinki-NLP/opus-mt-tc-big-itc-itc` (**1,15 Go** : encoder
451 Mo + décodeur 697 Mo). Il n'est employé que si **une** des deux directions
manque, et il couvre alors les deux : inutile de payer deux paires bilingues.
Le `modele.json` complet, tel que l'exporteur l'écrit :
```json
{
"version": 1,
"modeleSource": "Helsinki-NLP/opus-mt-tc-big-itc-itc",
"famille": "itc",
"jetonCible": ">>por<<"
}
```
Sans ce jeton, le modèle écrirait **sa** langue par défaut (l'espagnol) : du
texte parfaitement lisible, dans la mauvaise langue. C'est la seule chose qui
diffère entre les deux sens d'un pack familial.
Le manifeste déclare ces fichiers avec `"sens": "commun"` : le générateur les
accepte, et le mobile les range dans `langues/<code>/commun/`.
> **`vocab.json` n'est pas un doublon du `.spm`.** Le `.spm` découpe le texte ;
> `vocab.json` donne les identifiants que le modèle attend. Les deux
> numérotations **diffèrent** : mesuré sur `opus-mt-es-fr`, la pièce `▁Ho` vaut
> **5293** pour le `.spm` mais **584** pour le modèle, et `,` vaut 4 contre 3.
> Sans `vocab.json`, le modèle reçoit un autre texte que celui dicté et produit
> du charabia **sans lever la moindre erreur**.
>
> `config.json` apporte deux identifiants que le `.spm` ne contient pas :
> l'amorce du décodeur (`74821` sur `opus-mt-es-fr`, `35282` sur
> `opus-mt-tc-big-itc-itc`) et sa fin (`0` et `29158`). Les deviner donne un
> décodeur qui démarre au hasard et ne s'arrête jamais.
>
> Ces deux fichiers pèsent quelques centaines de kilo-octets : ils voyagent avec
> le pack comme les `.spm`. Le script d'export les copie ; pour les packs
> exportés **avant** leur introduction, voir le §2.2.
>
> Un `.spm` portant un autre nom (`vocab-espagnol.spm`) n'est pas reconnu : il
> serait pris comme repli « premier `.spm` », ce qui peut être le mauvais
> vocabulaire — donc une traduction sans aucun sens, sans aucune erreur. Le
> générateur le signale (§5).
### 2.2 Compléter un pack exporté avant `vocab.json`
Les packs déjà sur le disque ne contiennent que les quatre fichiers d'origine.
Les compléter ne demande **aucun réexport** : les poids sont bons, seuls les
deux référentiels manquent (quelques Mo par sens).
```powershell
# 1. Voir où on en est, sans rien écrire
.\scripts\Completer-Packs-Langues.ps1 -Verifier
# 2. Compléter (idempotent : relancer ne refait rien)
.\scripts\Completer-Packs-Langues.ps1
# 3. Une ou deux langues seulement
.\scripts\Completer-Packs-Langues.ps1 -Codes "es,pt"
```
Un pack **familial** (§2.1.1) est ignoré par cette complétion — et c'est
volontaire : il n'a pas de `source.spm` par sens, donc rien à aligner. Ses
`vocab.json` et `config.json` sont déjà dans `commun/`, écrits par l'export.
Le script déduit le dépôt d'origine du code et du sens (`es` + `versFrancais` →
`opus-mt-es-fr`), puis **vérifie** que le `source.spm` téléchargé est identique,
octet pour octet, à celui du pack. Sans cette comparaison, il attacherait le
vocabulaire d'un **autre** modèle — exactement le défaut qu'il répare, en pire.
Un pack dont l'empreinte diffère est signalé `[ÉCART]` et laissé intact : il
vient d'une autre famille de modèles et doit être réexporté par le §1.1.
### 2.3 Nommer le dossier de langue
- **minuscules**, chiffres et tiret uniquement (`es`, `pt-br`, `zh`) ;
- 2 à 10 caractères, ni tiret au début ni à la fin.
Un code hors de ces règles fait **ignorer le pack silencieusement** par le
mobile. Le générateur refuse de l'écrire (§5).
### 2.4 Vérifier le poids avant de transférer
Un modèle Marian pèse ~300 Mo pour les deux sens. C'est la contrainte la plus
lourde de toute la fonctionnalité : elle se mesure **ici**, pas après le
déploiement.
```powershell
'{0:N0} Mo' -f ((Get-ChildItem .\es -Recurse -File | Measure-Object Length -Sum).Sum / 1MB)
```
---
## 3. Étape 3 — Valider le pack sur le poste (avant tout transfert)
**On ne débogue pas un modèle de 120 Mo par déploiements successifs.** Trois
commandes suffisent, et elles se font toutes sur le poste :
```powershell
dotnet run --project tools/Omika.LanguesProbe -- inspecter .\es\versFrancais\source.spm
dotnet run --project tools/Omika.LanguesProbe -- encoder .\es\versFrancais\source.spm "Buenos días, ¿cómo está?"
dotnet run --project tools/Omika.LanguesProbe -- traduire `
--dossier .\es\versFrancais --code es --sens versFrancais --texte "Sustitución de la pieza"
```
Ce qu'il faut regarder, et l'arbre de décision en cas d'anomalie, sont au §2.5 et
au §3.4 de `VALIDATION_TRADUCTION_ONNX.md`. En résumé : la sortie doit être une
phrase **française plausible** ; une suite de mots espagnols ou du texte répété
signifie un mauvais vocabulaire de sortie, pas un problème de déploiement.
---
## 4. Étape 4 — Déposer les fichiers sur le serveur
### 4.1 Destination
| | Chemin |
|---|---|
| Racine des modèles | `C:\Omika\Data\Langues` (= `Langues__RootPath`) |
| Cible d'un pack | `C:\Omika\Data\Langues\<code>\` |
| Manifeste | `C:\Omika\Data\Langues\manifest.json` |
> Ne **jamais** déposer les modèles dans `C:\Omika\Apps\LanguesApi` : ce dossier
> est écrasé à chaque redéploiement. Vérifier la variable avant de copier :
>
> ```powershell
> [Environment]::GetEnvironmentVariable('Langues__RootPath', 'Machine')
> ```
### 4.2 Copie (nouveau pack)
```powershell
$root = 'C:\Omika\Data\Langues'
New-Item -ItemType Directory -Force -Path $root | Out-Null
# Depuis le dossier de livraison (ex. C:\Omika\Deploy\Langues\es)
robocopy 'C:\Omika\Deploy\Langues\es' "$root\es" /E /R:2 /W:2
```
- **Ne copier que `es\`** (le dossier de la langue), jamais un `manifest.json`
préparé ailleurs : il est généré à l'étape suivante, sur ce serveur.
- Les droits NTFS sont posés par **héritage** (`svc_omika_langues` a la lecture
seule sur la racine, en `(OI)(CI)R`) : rien à faire pour un fichier ajouté.
Contrôle rapide :
```powershell
(Get-Acl 'C:\Omika\Data\Langues').Access |
Where-Object { $_.IdentityReference -like "*svc_omika_langues" } |
Select-Object IdentityReference, FileSystemRights, InheritanceFlags
```
### 4.3 Remplacer un modèle existant (mise à jour)
Écrire le nouveau fichier **à côté** puis le renommer, plutôt que d'écraser
directement :
```powershell
# 1) Déposer la nouvelle version sous un nom temporaire, dans le MÊME dossier
Copy-Item 'C:\Omika\Deploy\Langues\es\versFrancais\encoder_model.onnx' `
'C:\Omika\Data\Langues\es\versFrancais\encoder_model.onnx.new' -Force
# 2) Renommer sur le nom définitif (instantané, même volume)
Move-Item 'C:\Omika\Data\Langues\es\versFrancais\encoder_model.onnx.new' `
'C:\Omika\Data\Langues\es\versFrancais\encoder_model.onnx' -Force
```
**Si un téléphone est en train de télécharger ce fichier, l'étape 2 est
refusée** (« le processus ne peut pas accéder au fichier car il est en cours
d'utilisation par un autre processus »). C'est **protecteur**, pas gênant :
Windows empêche de produire un fichier mélangé. Réessayez dans quelques secondes
— la fin du téléchargement libère le fichier.
> Si le remplacement échoue malgré tout, **ne pas** supprimer le fichier pour
> « débloquer » : cela provoquerait un 404 pour les téléphones. Attendre, puis
> réessayer.
---
## 5. Étape 5 — Générer le manifeste (sur le serveur)
```powershell
cd C:\Omika\Deploy\Deploy
.\New-OmikaLanguesManifeste.ps1
```
Le script lit `Langues__RootPath` (MACHINE), parcourt l'arborescence, calcule
taille et empreinte de chaque fichier et écrit `<RootPath>\manifest.json`. Il
affiche le récapitulatif des packs retenus :
```
es Espagnol 4 fichier(s) 283,0 Mio
pt Portugais 4 fichier(s) 275,0 Mio
```
**Il refuse d'écrire** (code 1) si aucun pack n'est exploitable, et **signale**
sans échouer : un dossier de sens inconnu, un code de langue invalide, un
fichier `*_with_past*`, un `.spm` au nom non reconnu, un sous-dossier
inatteignable.
Trois dossiers de sens sont reconnus : `versFrancais`, `depuisFrancais` et
**`commun`** (poids partagés d'un modèle familial, §2.1.1). Un pack familial
apparaît donc comme ceci — les 1,15 Go une seule fois, plus un `modele.json`
par sens :
```
pt Portugais 8 fichier(s) 1,15 Gio
```
Le manifeste précédent est conservé automatiquement en `manifest.json.bak` —
c'est votre point de retour (§10).
### 5.1 Numéroter la version
`-Version` (défaut `1.0.0`) est écrit dans chaque pack et **commandé par
l'application** : c'est ce champ qui fait apparaître, sur le téléphone, la
mention « Installée (v1.0.0) — mise à jour disponible » et le bouton
**Mettre à jour**.
```powershell
# À CHAQUE remplacement de fichier : incrémenter la version
.\New-OmikaLanguesManifeste.ps1 -Version 1.0.1
```
> Si la version ne change pas, le bouton affiche **« Réinstaller »** et
> l'utilisateur n'a aucun signal qu'un nouveau modèle existe : il gardera
> l'ancien indéfiniment. Le téléchargement, lui, ne récupère de toute façon que
> les fichiers dont l'empreinte a changé.
---
## 6. Étape 6 — Contrôler
### 6.1 Sur le disque (aucun droit administrateur requis)
```powershell
.\New-OmikaLanguesManifeste.ps1 -Verifier
```
Compare le manifeste aux fichiers réels : fichier absent, taille ou empreinte
divergente, sens à la mauvaise casse, fichier déclaré inutile, fichier présent
mais non déclaré. **Code retour 1 au moindre écart** — utilisable dans un script
de livraison.
### 6.2 Par HTTP (bout en bout : API + Apache)
```powershell
$url = 'https://<SRV_LANGUES>'
$manifeste = Invoke-RestMethod "$url/api/langues"
$manifeste.packs | Select-Object code, libelle, version,
@{ n = 'NbFichiers'; e = { $_.fichiers.Count } }
```
### 6.3 Télécharger un fichier et vérifier son empreinte
C'est le contrôle **décisif** : il prouve que le proxy sert bien les octets
attendus, et pas une page d'erreur HTML ni un fichier tronqué.
```powershell
$fichier = (Invoke-RestMethod "$url/api/langues").packs[0].fichiers[0]
$local = Join-Path $env:TEMP 'omika-controle.bin'
Invoke-WebRequest "$url$($fichier.url)" -OutFile $local
(Get-Item $local).Length -eq $fichier.tailleOctets
(Get-FileHash $local -Algorithm SHA256).Hash.ToLowerInvariant() -eq $fichier.sha256
```
Les deux lignes doivent renvoyer `True`. Sinon : problème de proxy (page
d'erreur), de troncature, ou de cache.
---
## 7. Étape 7 — Côté mobile
Aucune mise à jour de l'application n'est nécessaire.
1. **Paramètres → Langue secondaire → « Actualiser la liste »** (le bouton
recharge le manifeste depuis le serveur ; sans lui, l'application réutilise
son index mémorisé et ne verra pas le nouveau pack).
2. Appuyer sur **Télécharger** (ou **Mettre à jour**) sur la langue concernée :
la ligne affiche le poids avant le lancement, puis la progression
(« 42 Mo / 122 Mo »).
3. Le téléchargement **installe et sélectionne** la langue en une seule action.
4. Dicter dans la langue secondaire → le champ reçoit du français ; sur une note
existante, « ⇄ Traduire » affiche la langue secondaire. **Rien** de traduit ne
doit apparaître dans l'Outbox (page Synchronisation).
---
## 8. Pièges (symptôme → cause)
| Symptôme | Cause la plus probable |
|---|---|
| `GET /api/langues` répond **404** | Manifeste absent ou illisible — lire le log du service : il nomme le chemin cherché. Le manifeste est-il bien dans `Langues__RootPath` ? |
| Le pack apparaît mais le téléphone le **refuse à la fin** | Empreinte différente : fichier modifié **après** la génération du manifeste → regénérer puis `-Verifier` |
| Un fichier répond **404** au téléchargement | Le manifeste déclare un fichier absent du disque (les deux ont été désynchronisés) |
| La langue n'apparaît **pas du tout** dans la liste | Code de langue invalide → pack ignoré **silencieusement** par le mobile (§2.2) |
| Traduction = suite de mots de la langue source, ou texte répété | Mauvais vocabulaire de sortie : `target.spm` manquant ou faux (§2.1) |
| Traduction = `<unk>`, `▁`, caractères isolés | `source.spm`/`target.spm` ne viennent pas du **même** modèle que les `.onnx` |
| Le téléphone ne propose **aucune** mise à jour | `version` non incrémentée dans le manifeste (§5.1) : le bouton reste « Réinstaller » |
| 120 Mo téléchargés pour rien | `decoder_with_past_model.onnx` livré dans le dossier (§1) |
| Les modèles disparaissent à chaque déploiement | Ils sont dans `C:\Omika\Apps\LanguesApi` au lieu de `Langues__RootPath` (§4.1) |
| Le script d'export **ne fait rien** (« rien ne se passe ») | `optimum-cli` absent : il s'arrête **avant toute écriture** (§1.1). Lancé par double-clic, la fenêtre se ferme avant qu'on lise le message — lancer depuis une fenêtre PowerShell déjà ouverte |
| `optimum-cli` introuvable alors que `pip install` a réussi | Python du **Microsoft Store** : pip écrit les programmes dans un dossier absent du PATH (§1). Le script d'export **sait l'y trouver seul** ; pour un usage manuel, ajouter ce dossier au PATH |
| `pip` s'arrête sur **`[WinError 206] Nom de fichier ou extension trop long`** | Windows plafonne les chemins à **260 caractères** ; le Python du Store installe dans un dossier de **140 caractères** et `torch` dépasse la limite. Installer dans un venv au chemin court (§1) — le paquet n'est alors **jamais** installé, donc `optimum-cli` reste introuvable |
| Toutes les paires manquantes sont en **`HTTP 401`** et **aucune en `404`** | Un dépôt absent répond **401** avec `{"error":"Repository not found"}`, jamais 404 — et la **page publique** s'est mise à répondre 401 elle aussi, alors qu'un dépôt existant répond 200 au même instant. Plus aucun code d'erreur ne tranche. **Le script ne demande donc plus « ce dépôt existe-t-il ? »** : il lit une fois la **liste publique** des dépôts `Helsinki-NLP` (`/api/models?author=Helsinki-NLP&limit=1000`, toujours 200, paginée par l'entête `Link`) et l'existence devient une égalité de chaîne. « Absent » est alors une **preuve** (nom hors liste), citée avec la taille du catalogue consulté. La sonde par modèle et le verdict de page restent en repli si le catalogue est illisible |
| Le rapport annonce un **poids total très inférieur** au dossier | Corrigé : le rapport affiche désormais **deux** lignes — le poids des packs traités par l'exécution, et le **poids réellement présent** sous la racine. Auparavant `-Codes it` affichait 520 Mio alors que le dossier en contenait 21 Gio |
| Une langue n'a **qu'un seul sens** (`versFrancais` trouvé, `depuisFrancais` absent) | Helsinki-NLP ne publie pas toujours les deux directions. Le script ignore la langue ; `-InclureDirectionsPartielles` la construit quand même. Conséquence : la **dictée** fonctionne, la **lecture** échoue avec un message explicite (`OnnxTranslationService`, « Le modèle … est absent »). Rien ne bloque l'installation : `LangueDisponible.EstComplet` existe mais n'est utilisé nulle part |
| **Tous** les exports échouent d'un coup en quelques secondes, `Poids de cette execution : 0 octets`, et le détail affiché est un simple `UserWarning: … cache-system uses symlinks` | L'appel a été lancé avec le flux d'erreur redirigé sur tout le script (`2>&1 \| Tee-Object`, `*>&1 > log`) : PowerShell 5.1 transforme **chaque** ligne de stderr d'un programme natif en erreur fatale (`$ErrorActionPreference = 'Stop'`). `optimum-cli` émet cet avertissement de `huggingface_hub` dès le début : l'export était tué juste après `config.json`, avant le moindre octet de poids. **Corrigé** : l'export n'est plus jugé que par son **code de sortie**, et stderr est neutralisé le temps de l'appel (test §9). Les rapports déjà écrits par une version antérieure sont à jeter |
| Le script affiche `catalogue : 2 depots publics` puis déclare **tout** absent | La liste des dépôts est revenue **tronquée** (limite ignorée, page unique). Mesure du 16/10 : 2 entrées → 66 paires « absentes », dont 11 dont le modèle est **sur le disque depuis des semaines** — le script se contredisait lui-même. **Corrigé** : en dessous de 100 dépôts le catalogue est rejeté (`catalogue inutilisable`) et le verdict repasse par modèle. Vérifier la réponse brute avec `(Invoke-WebRequest 'https://huggingface.co/api/models?author=Helsinki-NLP&limit=1000' -UseBasicParsing).Content.Length` |
| Le rapport annonce des **dizaines de « paires absentes »** d'un coup (`HTTP 401 … page impossible`, `HTTP 429`) | Deux causes réelles, mesurées : (1) un `429` de quota — ce n'est pas une absence ; (2) un `401` qui ne discrimine rien. **Corrigé** : une seule lecture du catalogue remplace 102 interrogations (donc plus de quota), et un verdict indécis **interrompt** le run au lieu d'écrire un rapport faux qui ferait supprimer 22 langues du produit |
| Une langue à moitié faite **re-télécharge** le sens qui existait déjà (500 Mo) | Le test de reprise portait sur la langue **entière** : avec `-InclureDirectionsPartielles`, le sens déjà présent était réexporté. **Corrigé** : reprise par **sens** (`encoder`+`.spm` présents ⇒ ignoré, test §4bis) |
| `optimum-cli` écrit des avertissements alors que l'export se déroule bien | Bénin (`huggingface_hub` signale un cache sans liens symboliques sous `D:\…\packs\.hf-cache`). Le cache fonctionne en mode dégradé — donc **plus gros**, jamais faux. `HF_HUB_DISABLE_SYMLINKS_WARNING=1` pour le taire |
---
## 9. Retirer une langue / rollback
**Retirer une langue du catalogue** (les téléphones qui l'ont déjà installée la
gardent : elle est locale, et le retrait du serveur ne les touche pas) :
```powershell
Remove-Item 'C:\Omika\Data\Langues\pt' -Recurse -Force
.\New-OmikaLanguesManifeste.ps1
```
**Revenir au manifeste précédent** : le générateur conserve
`manifest.json.bak` à chaque écriture.
```powershell
Copy-Item 'C:\Omika\Data\Langues\manifest.json.bak' `
'C:\Omika\Data\Langues\manifest.json' -Force
.\New-OmikaLanguesManifeste.ps1 -Verifier
```
> Remettre l'ancien manifeste alors que les **fichiers** ont déjà été remplacés
> provoquera un refus d'empreinte : un rollback complet se fait sur les deux
> (fichiers **et** manifeste).
---
## 10. Hors périmètre
- **Qualité de traduction** : choix du modèle, pas de la procédure.
- **Poids du livrable mobile** : décision produit (80 à 300 Mo par langue).
- **Reprise de téléchargement inter-fichiers** : un fichier interrompu repart de
zéro ; les fichiers déjà conformes ne sont pas retéléchargés.
- **Transfert des 300 Mo vers le serveur** : aucun mécanisme d'upload n'existe.
Le transfert se fait par partage réseau, clé USB ou dossier de livraison
(`C:\Omika\Deploy\Langues`), hors de cette procédure.
+2266
View File
File diff suppressed because it is too large Load Diff
+230
View File
@@ -0,0 +1,230 @@
# LOT SERVEUR — Connexion physique base Licence HFSQL
## Objectif
Brancher l'implémentation moderne Licence existante sur la vraie base HFSQL
Licence, via le provider OLEDB pour HFSQL (`PCSOFT.HFSQL`).
## Principes respectés
- **Aucune modification du schéma HFSQL** : aucune table/colonne créée, modifiée ou supprimée.
- **Aucune création/suppression de table** : le repository lit/écrit dans les tables existantes.
- **Aucune reprise du vieux WebService** : les traitements modernes restent la référence.
- **Les anciennes documentations** servent uniquement à connaître les tables, relations et comportements historiques.
- **Les contrats API modernes restent la référence** : le repository ne renvoie que des entités métier.
- **Aucun secret historique recopié** : les valeurs de connexion proviennent exclusivement de la configuration d'installation.
## Modèle physique réel (confirmé)
La base Licence héberge les tables techniques suivantes, utilisées par le repository :
| Table | Rôle | Colonnes utilisées |
|-------|------|--------------------|
| `ClientBase` | Clients de la base licence | `CL_PKID`, `CL_Code`, `CL_Nom` |
| `Licence` | Licences attribuées | `LI_PKID`, `LI_IDClient`, `LI_IDCollaborateur`, `LI_IDUser`, `LI_Numero`, `LI_IDMobile`, `LI_DateAttribution`, `LI_DateExpiration`, `LI_Active`, `LI_UserNom`, `LI_UserPrenom`, `LI_Type`, `LI_InfoSupMob`, `LI_VersionMajeur` |
| `LicenceHisto` | Historique des opérations de licence | `LH_Date`, `LH_Auteur`, `LH_Operation`, `LH_GUIDLicence`, `LH_CodeClient`, `LH_IdMobile`, `LH_Note` |
> **Aucune divergence** entre le modèle physique réel et les hypothèses du repository actuel.
> Le mapping est direct et conforme.
## Mapping vers les contrats modernes
Le `LicenceRepository` (`src/Omika.Data/Repositories/Technical/LicenceRepository.cs`)
mappe les lignes physiques HFSQL vers les entités métier modernes :
| Table physique | Entité métier | Contrat |
|----------------|---------------|---------|
| `ClientBase` | `ClientBase` | `Omika.Core.Domain.Entities.Mobile.ClientBase` |
| `Licence` | `Licence` | `Omika.Core.Domain.Entities.Mobile.Licence` |
| `LicenceHisto` | `LicenceHistoEntry` | `Omika.Core.Domain.Entities.Mobile.LicenceHistoEntry` |
Aucune donnée physique HFSQL n'est exposée dans l'API : le repository ne renvoie
que des entités métier, et les endpoints API ne manipulent que des contrats modernes.
## Fonctionnalités couvertes
| # | Fonctionnalité | Implémentation |
|---|----------------|----------------|
| 1 | Configuration centralisée de connexion Licence | `LicenseConnectionOptions` + `LicenseConnectionOptionsValidator` (section `LicenseConnection`) |
| 2 | Ouverture de la connexion HFSQL via OLEDB | `LicenseOleDbConnection` + `HfSqlOleDbConnectionFactory` (provider `PCSOFT.HFSQL`) |
| 3 | Ouverture/lecture des tables réellement utilisées | `LicenceRepository` branché sur la connexion nommée `"License"` |
| 4 | Mapping vers les contrats modernes | `LicenceRepository` (mapping `ClientBaseRow`/`LicenceRow` → entités métier) |
| 5 | Recherche CodeClient | `GetClientByCodeAsync` |
| 6 | Recherche NumeroLicence | `GetByNumeroAsync`, `GetByNumeroAndClientAsync` |
| 7 | Vérification licence active | `Licence.Active` |
| 8 | Vérification DateExpiration | `Licence.DateExpiration` |
| 9 | Vérification attribution DeviceId | `Licence.DeviceId` |
| 10 | Résolution UserId | `Licence.UserId` |
| 11 | Résolution CollaborateurId | `Licence.CollaborateurId` |
| 12 | Activation réelle | `AssignDeviceAsync` + `AddHistoAsync` |
| 13 | Validation réelle | via `LicenseService` (contrats API modernes) |
| 14 | Aucune donnée physique exposée | le repository ne renvoie que des entités métier |
## Branchement effectué
### `src/Omika.Data/ServiceCollectionExtensions.cs`
Dans `AddOmikaDataLicenseConnection()`, le `ILicenceRepository` est désormais
branché sur la connexion OLEDB nommée `"License"` (base Licence réelle), en
remplacement de l'enregistrement ODBC métier par défaut de `AddOmikaData()` :
```csharp
services.AddScoped<ILicenceRepository>(sp =>
new LicenceRepository(
sp.GetRequiredKeyedService<IHfsqlConnection>(LicenseConnectionOptions.ConnectionName)));
```
Le dernier enregistrement remplace le précédent (connexion ODBC métier) : le
repository de licence lit/écrit désormais dans la base HFSQL Licence réelle via
le provider OLEDB, jamais dans la base métier.
### `src/Omika.Api/Program.cs`
Appel de `AddOmikaDataLicenseConnection()` après `AddOmikaData()` pour activer le
branchement :
```csharp
builder.Services.AddOmikaData();
builder.Services.AddOmikaDataLicenseConnection();
```
### `src/Omika.Api/appsettings.json`
Ajout de la section `LicenseConnection` (valeurs d'exemple, sans secrets) :
```json
"LicenseConnection": {
"Server": "localhost",
"Port": 4900,
"DatabaseName": "Licence",
"Login": "",
"Password": "",
"FilePasswords": {
"ProtectionStd": "",
"ProtectionPlus": ""
}
}
```
> Les valeurs `Login`, `Password`, `FilePasswords` doivent être fournies via
> configuration sécurisée (variables d'environnement, User Secrets), jamais en
> clair dans le code source.
## Configuration de connexion
La connexion à la base Licence est centralisée dans la section `LicenseConnection` :
| Clé | Description | Exemple |
|-----|-------------|---------|
| `LicenseConnection:Server` | Hôte du serveur HFSQL Client/Serveur | `localhost` |
| `LicenseConnection:Port` | Port du serveur HFSQL | `4900` |
| `LicenseConnection:DatabaseName` | Nom de la base Licence | `Licence` |
| `LicenseConnection:Login` | Compte technique serveur (secret) | *(via env/secret)* |
| `LicenseConnection:Password` | Mot de passe du compte technique (secret) | *(via env/secret)* |
| `LicenseConnection:FilePasswords:ProtectionStd` | Mot de passe ProtectionStd (optionnel) | *(via env/secret)* |
| `LicenseConnection:FilePasswords:ProtectionPlus` | Mot de passe ProtectionPlus (optionnel) | *(via env/secret)* |
## Validation de la baseline
| Baseline | Résultat |
|----------|----------|
| Mobile | **506/506** ✓ |
| Unitaires hors HFSQL environnemental | **1748/1748** ✓ (0 échec) |
| Build solution | **vert** ✓ |
| Android | **vert** ✓ |
## Tests à faire en lecture (prochaine étape)
Une fois la base Licence réelle accessible, exécuter les tests de lecture suivants
avant toute écriture :
- [ ] Connexion ouverte (via `LicenseOleDbConnection`)
- [ ] Tables accessibles (`ClientBase`, `Licence`, `LicenceHisto`)
- [ ] Colonnes réellement présentes (comparer avec le mapping ci-dessus)
- [ ] Client connu / inconnu (`GetClientByCodeAsync`)
- [ ] Licence connue / inconnue (`GetByNumeroAsync`)
- [ ] Licence active / inactive (`Licence.Active`)
- [ ] Licence expirée (`Licence.DateExpiration`)
- [ ] Attribution existante (`Licence.DeviceId`)
- [ ] Utilisateur / collaborateur réellement résolus (`Licence.UserId`, `Licence.CollaborateurId`)
> **STOP** si le modèle physique diffère des hypothèses du repository. Dans ce cas,
> fournir : table réelle, colonne réelle, modèle attendu, divergence, adaptation minimale.
---
## ⛔ ÉTAT RÉEL — DIVERGENCE CONSTATÉE (STOP)
> **Ce lot est en état de STOP.** Les probes de lecture réels (via OLEDB `PCSOFT.HFSQL`)
> ont révélé que le modèle physique de la base Licence **diffère** des hypothèses du
> repository. Aucune écriture n'a été effectuée. Aucune donnée n'a été modifiée.
### Probes de lecture effectués (lecture seule, aucun secret exposé)
| Base configurée | Base réelle sur le serveur | Connexion | Tables |
|-----------------|----------------------------|-----------|--------|
| `BATIK_TEST` (Business) | `BATIK_TEST` | ✅ Ouverte | 44 tables métier |
| `BASE_LICENSES` (License) | **`BASE_LICENCES`** (orthographe différente) | ✅ Ouverte | **2 tables de backup système uniquement** |
| `SYNC_TEST` (Sync) | **`OMIKA_TEST`** (nom différent) | ✅ Ouverte | 54 tables métier |
Le serveur HFSQL (`127.0.0.1:4900`) expose 16 bases, dont `BASE_LICENCES`, `BATIK_TEST`,
`OMIKA_TEST`, `GestionUser`, etc.
### Divergence 1 — Nom de la base Licence
| Élément | Valeur |
|---------|--------|
| **Base configurée** (`LicenseConnection:DatabaseName`) | `BASE_LICENSES` |
| **Base réelle sur le serveur** | `BASE_LICENCES` |
| **Divergence** | Orthographe : `LICENSES` (config) vs `LICENCES` (réel) |
### Divergence 2 — Tables de licence absentes (critique)
La base Licence réelle (`BASE_LICENCES`) ne contient **que** les tables de backup
système suivantes :
| Table réelle | Type |
|--------------|------|
| `_Backup_20260813_080833_Integrity` | TABLE |
| `_Backup_20260813_080833_Views` | TABLE |
Les tables attendues par le repository **n'existent pas** dans cette base :
| Table attendue (modèle) | Présence réelle |
|-------------------------|-----------------|
| `ClientBase` | ❌ Absente |
| `Licence` | ❌ Absente |
| `LicenceHisto` | ❌ Absente |
### Divergence 3 — Nom de la base Sync
| Élément | Valeur |
|---------|--------|
| **Base configurée** (`SyncConnection:DatabaseName`) | `SYNC_TEST` |
| **Base réelle sur le serveur** | `OMIKA_TEST` |
| **Divergence** | Nom différent |
### Conséquence
Le `LicenceRepository` ne peut pas être branché sur la base Licence réelle tant que
les tables `ClientBase`, `Licence`, `LicenceHisto` n'existent pas dans `BASE_LICENCES`
(ou dans une base Licence dédiée). Le mapping vers les contrats modernes ne peut pas
être validé en lecture.
### Adaptation minimale proposée (à valider avant toute implémentation)
1. **Corriger le nom de la base** dans la configuration locale :
`LicenseConnection:DatabaseName` = `BASE_LICENCES` (au lieu de `BASE_LICENSES`).
2. **Restaurer / créer les tables de licence** (`ClientBase`, `Licence`, `LicenceHisto`)
dans la base `BASE_LICENCES` (ou fournir une base Licence de test contenant ces tables).
→ Sans ces tables, aucun test de lecture/mapping/activation n'est possible.
3. **Corriger le nom de la base Sync** : `SyncConnection:DatabaseName` = `OMIKA_TEST`
(au lieu de `SYNC_TEST`), si `OMIKA_TEST` est bien la base technique Sync de test.
4. **Re-exécuter les probes de lecture** (connexion, tables, colonnes) une fois les
tables de licence présentes, avant toute écriture.
> **Aucune modification du schéma HFSQL n'a été effectuée.** Aucune table n'a été
> créée, modifiée ou supprimée. Aucune donnée n'a été écrite.
+175
View File
@@ -0,0 +1,175 @@
# LOT SERVEUR — CONNEXION PHYSIQUE BASE SERVICE (HFSQL)
## OBJECTIF
Brancher l'implémentation moderne de synchronisation mobile existante sur la
vraie base HFSQL **Service**, via le provider OLE DB `PCSOFT.HFSQL`.
Cette base Service est **distincte** de la base métier Batik/Omika (ODBC), de la
base Licence et de la base technique Sync : elle héberge les tables techniques
du service de synchronisation.
## IMPORTANT
- aucune modification du schéma HFSQL ;
- aucune création/suppression de table ;
- aucune reprise du vieux WebService ;
- les anciennes documentations servent uniquement à connaître les tables,
relations et comportements historiques ;
- les traitements modernes peuvent être différents ;
- les contrats API modernes restent la référence ;
- aucun secret historique ne doit être recopié.
## TOPOLOGIE RÉELLE CONSTATÉE (probes de lecture)
Les probes de lecture réels (via OLEDB `PCSOFT.HFSQL`) ont révélé la topologie
physique suivante sur le serveur HFSQL (`127.0.0.1:4900`) :
| Base réelle | Rôle | Tables techniques |
|-------------|------|-------------------|
| `BATIK.01_BLS` | Base **Service** (service de synchronisation) | `TOSRV`, `TOMOB`, `MOBILES`, `PARAMETRES` |
| `BATIK_TEST` | Base métier Batik (Business) | 44 tables métier |
| `OMIKA_TEST` | Base technique Sync | 54 tables métier |
| `BASE_LICENCES` | Base Licence | tables de backup système uniquement |
### Matrice physique des tables de synchronisation
| Table | Base réelle | Rôle |
|-------|-------------|------|
| `TOSRV` | **`BATIK.01_BLS`** (Service) | File d'entrée des opérations reçues du mobile |
| `MobReception` | **base métier WebService** | File de travail des opérations transférées |
| `MobLog` | **base métier WebService** | Journal des états terminaux |
> **Divergence structurelle** : le `SyncTransferRepository` actuel est
> **mono-connexion** et suppose que `TOSRV`, `MobReception` et `MobLog` sont dans
> la **même** base. Or la topologie réelle répartit ces tables sur **deux** bases
> distinctes (`TOSRV` dans la base Service, `MobReception`/`MobLog` dans la base
> métier WebService). Le repository actuel est donc **insuffisant** pour cette
> topologie.
## CONFIGURATION CENTRALISÉE
La connexion Service est configurée dans la section **`ServiceConnection`** des
fichiers de configuration .NET (appsettings / variables d'environnement /
secrets) :
```json
"ServiceConnection": {
"Server": "127.0.0.1",
"Port": 4900,
"DatabaseName": "BATIK.01_BLS",
"Login": "",
"Password": "",
"FilePasswords": {
"ProtectionStd": null,
"ProtectionPlus": null
}
}
```
- `Server` / `Port` : serveur HFSQL Client/Serveur.
- `DatabaseName` : nom de la base Service (`BATIK.01_BLS`).
- `Login` / `Password` : compte technique serveur (jamais en clair en source).
- `FilePasswords` : mots de passe de fichiers HFSQL (ProtectionStd / ProtectionPlus),
propres à l'installation. Optionnels.
### Mot de passe optionnel
Selon le client, la connexion Service peut être configurée avec un mot de passe
**absent** (`null`), **vide** (`""`) ou **renseigné**. Le validateur et le
constructeur OLEDB acceptent proprement les trois cas. **Aucun mot de passe
fictif n'est jamais inventé** lorsque la valeur est absente.
## OUVERTURE DE LA CONNEXION VIA OLEDB
La connexion Service est enregistrée comme `IHfsqlConnection` **nommée** `"Service"`
(`ServiceConnectionOptions.ConnectionName`), distincte de la connexion métier ODBC
par défaut, de la connexion Licence et de la connexion Sync.
- `ServiceConnectionOptions` : options de connexion (section `ServiceConnection`).
- `ServiceConnectionOptionsValidator` : validation minimale (règle pure, testable).
- `HfSqlConnectionOptionsFactory.CreateForService(...)` : traduction vers les options
OLE DB.
- `ServiceOleDbConnection` : connexion OLE DB réutilisant le contrat `IHfsqlConnection`
(lecture, métadonnées), alimentée par la fabrique OLE DB.
### `ServiceOleDbConnection`
La connexion Service expose :
- `Options` : un `HfsqlOptions` minimal dérivé des options Service (sans secret),
avec `ConnectionName = "Service"`, `DatabaseName`, `Login`.
- `OpenAsync` / `CloseAsync` / `DisposeAsync` : cycle de vie de la connexion.
- `QueryAsync` / `ExecuteScalarAsync` / `ExecuteNonQueryAsync` : lecture/écriture.
- `GetColumnsAsync` : métadonnées des colonnes d'une table.
## BRANCHEMENT DU REPOSITORY — MULTI-CONNEXION IMPLÉMENTÉ
`AddOmikaDataServiceConnection()` (dans `Omika.Data`) :
1. enregistre la configuration `ServiceConnection` (section `ServiceConnection`) ;
2. enregistre le validateur `ServiceConnectionOptionsValidator` ;
3. enregistre la fabrique OLE DB `IHfSqlOleDbConnectionFactory` ;
4. enregistre la connexion `IHfsqlConnection` nommée `"Service"` (Scoped) ;
5. enregistre le wrapper `IServiceHfSqlConnection` (Scoped) autour de la connexion
Service.
> **✅ Implémenté** : le `SyncTransferRepository` est désormais **multi-connexion**.
> Il reçoit deux connexions explicites : `IServiceHfSqlConnection` (base Service,
> table `TOSRV`) et `IWebServiceHfSqlConnection` (base WebService/Business, tables
> `MobReception` et `MobLog`). Le routage des opérations est documenté dans
> `LOT_SERVEUR_SYNC_CONNECTION.md`.
### Rôle de la connexion Service
La connexion Service (`IServiceHfSqlConnection`) est utilisée **uniquement** pour
les opérations sur la table `TOSRV` :
- `InsertToSrvAsync` : insertion d'une opération reçue du mobile.
- `GetStatusAsync` / `GetExistingStateAsync` : recherche d'une opération dans TOSRV.
- `TransferToReceptionAsync` : lecture de TOSRV et suppression après transfert réussi.
Aucune opération `MobReception` / `MobLog` ne passe par la connexion Service.
> **Aucune modification du schéma HFSQL n'a été effectuée.** Aucune table n'a été
> créée, modifiée ou supprimée. Aucune donnée n'a été écrite.
## VALIDATION DE LA BASELINE
| Élément | Résultat |
|---------|----------|
| Build solution | **Vert** (0 erreur) |
| Build Android | **Vert** (0 erreur) |
| Tests unitaires (hors HFSQL environnemental) | **1748/1748** (0 échec) |
### Note sur les tests
Les 10 tests `HfSqlOleDb*IntegrationTests` échouent car ils nécessitent un serveur
HFSQL réel non démarré dans cet environnement (`La connexion au serveur <:0> a
échoué`). Ces tests sont **hors baseline** (tests environnementaux).
Les nouveaux tests ajoutés pour la connexion Service passent tous :
- `ServiceConnectionOptionsValidatorTests` (13 tests) : validation, mot de passe
optionnel, `ToString` sans secret.
- `ServiceOleDbConnectionTests` (12 tests) : cycle de vie, lecture, options,
`CreateForService`.
- `HfSqlOleDbConnectionStringBuilderTests` : construction OLEDB avec mot de passe
optionnel.
- `ServiceCollectionExtensionsTests` : enregistrement DI de la connexion Service.
## PROCHAINES ÉTAPES (LECTURE SEULE D'ABORD)
Avant toute écriture, prouver en lecture seule :
- connexion Service ouverte ;
- tables accessibles (`TOSRV`, `TOMOB`, `MOBILES`, `PARAMETRES`) ;
- colonnes réellement présentes ;
- opération connue / inconnue ;
- état terminal / en attente ;
- mapping JSON cohérent.
**STOP** si le modèle physique diffère des hypothèses du repository actuel. Dans
ce cas fournir : table réelle, colonne réelle, modèle attendu, divergence,
adaptation minimale proposée.
+230
View File
@@ -0,0 +1,230 @@
# LOT SERVEUR — CONNEXION PHYSIQUE BASE TECHNIQUE SYNC (HFSQL)
## OBJECTIF
Brancher l'implémentation moderne de synchronisation mobile existante sur la
vraie base HFSQL **technique de synchronisation**, via le provider OLE DB
`PCSOFT.HFSQL`.
Cette base technique est **distincte** de la base métier Batik/Omika (ODBC) et
de la base Licence : elle héberge les files de communication mobile/service.
## IMPORTANT
- aucune modification du schéma HFSQL ;
- aucune création/suppression de table ;
- aucune reprise du vieux WebService ;
- les anciennes documentations servent uniquement à connaître les tables,
relations et comportements historiques ;
- les traitements modernes peuvent être différents ;
- les contrats API modernes restent la référence ;
- aucun secret historique ne doit être recopié.
## TABLES TECHNIQUES UTILISÉES
Le repository de synchronisation (`SyncTransferRepository`) lit/écrit dans les
tables techniques suivantes :
| Table | Rôle | Colonnes utilisées |
|-------|------|--------------------|
| `TOSRV` | File d'entrée des opérations reçues du mobile (non transférées) | `TS_PKID`, `TS_Date`, `TS_Mobile`, `TS_CMD`, `TS_Buffer`, `TS_Lu`, `TS_JSON` |
| `MobReception` | File de travail des opérations transférées en attente de traitement | `MR_PKID`, `MR_IDToSRV`, `MR_JSON`, `MR_CMD`, `MR_Buffer`, `MR_NumSerie`, `MR_Fait`, `MR_DateHeureRecep` |
| `MobLog` | Journal des états terminaux (SUCCESS / REJECTED) et historique | `ML_PKID`, `ML_DateCreat`, `ML_Message`, `ML_Niveau` |
Ces tables sont hébergées dans la base technique Sync, configurée séparément
(section `SyncConnection`), jamais dans la base métier Batik/Omika.
## CONFIGURATION CENTRALISÉE
La connexion Sync est configurée dans la section **`SyncConnection`** des
fichiers de configuration .NET (appsettings / variables d'environnement /
secrets) :
```json
"SyncConnection": {
"Server": "localhost",
"Port": 4900,
"DatabaseName": "Sync",
"Login": "",
"Password": "",
"FilePasswords": {
"ProtectionStd": "",
"ProtectionPlus": ""
}
}
```
- `Server` / `Port` : serveur HFSQL Client/Serveur.
- `DatabaseName` : nom de la base technique Sync.
- `Login` / `Password` : compte technique serveur (jamais en clair en source).
- `FilePasswords` : mots de passe de fichiers HFSQL (ProtectionStd / ProtectionPlus),
propres à l'installation. Optionnels.
Aucun secret historique n'est repris : les valeurs proviennent exclusivement de
la configuration d'installation.
## OUVERTURE DE LA CONNEXION VIA OLEDB
La connexion Sync est enregistrée comme `IHfsqlConnection` **nommée** `"Sync"`
(`SyncConnectionOptions.ConnectionName`), distincte de la connexion métier ODBC
par défaut et de la connexion Licence.
- `SyncConnectionOptions` : options de connexion (section `SyncConnection`).
- `SyncConnectionOptionsValidator` : validation minimale (règle pure, testable).
- `HfSqlConnectionOptionsFactory.CreateForSync(...)` : traduction vers les options
OLE DB.
- `SyncOleDbConnection` : connexion OLE DB réutilisant le contrat `IHfsqlConnection`
(lecture, métadonnées), alimentée par la fabrique OLE DB.
## BRANCHEMENT DU REPOSITORY
`AddOmikaDataSyncConnection()` (dans `Omika.Data`) :
1. enregistre la configuration `SyncConnection` (section `SyncConnection`) ;
2. enregistre le validateur `SyncConnectionOptionsValidator` ;
3. enregistre la fabrique OLE DB `IHfSqlOleDbConnectionFactory` ;
4. enregistre la connexion `IHfsqlConnection` nommée `"Sync"` (Scoped).
> **⚠️ OBSOLÈTE** : cette méthode ne branche **plus** `ISyncTransferRepository` sur
> la connexion Sync. Le repository est désormais **multi-connexion** (voir section
> "Topologie multi-connexion" ci-dessous). Cette méthode est conservée uniquement
> pour la compatibilité de configuration (section `SyncConnection`) et pour les
> outils de diagnostic. Sa suppression est proposée une fois toutes les références
> migrées.
Le repository de synchronisation lit/écrit désormais dans **deux bases physiques
distinctes** via les connexions explicites Service et WebService. Les contrats API
modernes restent la référence ; aucune donnée physique HFSQL n'est exposée (le
repository ne renvoie que des entités métier).
## MAPPING VERS LES CONTRATS MODERNES
Le `SyncTransferRepository` mappe les lignes physiques vers les contrats modernes
(`SyncEnvelope`, `SyncLogEntry`, `SyncStatusResponse`) :
- `TOSRV.TS_JSON` / `MobReception.MR_JSON` → `SyncEnvelope` (JSON versionné).
- `MobLog.ML_Message` → `SyncLogEntry` (JSON versionné, `schemaVersion`).
- États : `SyncStatus.Success` / `SyncStatus.Rejected` / `SyncStatus.Pending`.
## VALIDATION DE LA BASELINE
| Élément | Résultat |
|---------|----------|
| Build solution | **Vert** (0 erreur) |
| Build Android | **Vert** (0 erreur) |
| Build Mobile (Omika.App) | **Vert** (0 erreur) |
| Tests Mobile | **506/506** (0 échec) |
| Tests unitaires (hors HFSQL environnemental) | **1683/1683** (0 échec) |
### Note sur le compteur 1675 → 1683
La référence historique indiquait 1675 tests unitaires hors HFSQL environnemental.
Le filtre utilisé (`FullyQualifiedName!~Omika.Tests.Connection.HfSqlOleDb`) exclut
les 19 tests `HfSqlOleDb*IntegrationTests` (tests environnementaux nécessitant une
base HFSQL réelle, qui plantent le processus hôte sans base).
Le total actuel avec ce filtre est **1683** (0 échec), soit **+19 tests** par
rapport au compte rendu précédent (1664) : ces 19 tests sont les nouveaux tests
ajoutés pour la connexion Sync (`SyncConnectionOptionsValidatorTests` : 9 tests,
`SyncOleDbConnectionTests` : 10 tests).
La différence entre 1675 (référence historique) et 1664 (compte rendu précédent)
provient de l'évolution du codebase entre les deux comptes rendus (tests ajoutés /
supprimés). Le point essentiel est maintenu : **0 échec** sur l'ensemble des tests
non environnementaux.
## PROCHAINES ÉTAPES (LECTURE SEULE D'ABORD)
Avant toute écriture, prouver en lecture seule :
- connexion ouverte ;
- tables accessibles (`TOSRV`, `MobReception`, `MobLog`) ;
- colonnes réellement présentes ;
- opération connue / inconnue ;
- état terminal / en attente ;
- mapping JSON cohérent.
**STOP** si le modèle physique diffère des hypothèses du repository actuel. Dans
ce cas fournir : table réelle, colonne réelle, modèle attendu, divergence,
adaptation minimale proposée.
---
## ✅ TOPOLOGIE MULTI-CONNEXION — IMPLÉMENTÉE
> **Ce lot a été implémenté.** Les probes de lecture réels (via OLEDB
> `PCSOFT.HFSQL`) ont révélé que la topologie physique des tables de
> synchronisation **répartit** les tables sur **deux bases distinctes**. Le
> `SyncTransferRepository` a été adapté pour utiliser **deux connexions
> explicites**. Aucune écriture HFSQL réelle n'a été effectuée pendant ce lot.
### Topologie réelle des tables de synchronisation
| Table | Base réelle | Connexion utilisée |
|-------|-------------|--------------------|
| `TOSRV` | **`BATIK.01_BLS`** (base Service) | `IServiceHfSqlConnection` |
| `MobReception` | **base métier WebService** | `IWebServiceHfSqlConnection` |
| `MobLog` | **base métier WebService** | `IWebServiceHfSqlConnection` |
### Architecture multi-connexion retenue
Le `SyncTransferRepository` reçoit désormais **deux connexions explicites** dans
son constructeur :
```csharp
public SyncTransferRepository(
IServiceHfSqlConnection serviceConnection, // base Service → TOSRV
IWebServiceHfSqlConnection webServiceConnection) // base WebService → MobReception, MobLog
```
- `IServiceHfSqlConnection` : wrapper léger autour de la connexion OLE DB nommée
`"Service"` (base Service, table `TOSRV`). Enregistrée par
`AddOmikaDataServiceConnection()`.
- `IWebServiceHfSqlConnection` : wrapper léger autour de la connexion métier ODBC
par défaut (base WebService/Business, tables `MobReception` et `MobLog`).
Enregistrée par `AddOmikaData()`.
### Routage des opérations
| Opération | Table | Connexion |
|-----------|-------|-----------|
| `InsertToSrvAsync` | `TOSRV` | Service |
| `GetStatusAsync` (TOSRV) | `TOSRV` | Service |
| `GetStatusAsync` (MobReception/MobLog) | `MobReception`/`MobLog` | WebService |
| `GetTerminalResultAsync` | `MobLog` | WebService |
| `WriteLogAsync` | `MobLog` | WebService |
| `PurgeLogsAsync` | `MobLog` | WebService |
| `TransferToReceptionAsync` (lecture) | `TOSRV` | Service |
| `TransferToReceptionAsync` (insertion) | `MobReception` | WebService |
| `TransferToReceptionAsync` (suppression) | `TOSRV` | Service |
| `GetPendingOperationsAsync` | `MobReception` | WebService |
| `MarkSuccessAsync` | `MobReception`/`MobLog` | WebService |
| `MarkRejectedAsync` | `MobReception`/`MobLog` | WebService |
| `IncrementRetryAsync` | `MobReception` | WebService |
### Atomicité inter-base (TransferToReception)
Le transfert `TOSRV → MobReception → suppression TOSRV` traverse **deux bases
physiques**. Aucune transaction distribuée (DTC) n'est utilisée. L'atomicité est
garantie au niveau **logique** par :
1. **Ordre** : lecture TOSRV → insertion MobReception → suppression TOSRV.
2. **Idempotence par OperationId** : si l'opération existe déjà dans MobReception
ou est déjà terminale dans MobLog, on ne recrée pas MobReception (on nettoie
TOSRV).
3. **Non-perte** : si l'insertion MobReception échoue, TOSRV n'est **pas**
supprimé (l'opération reste dans la file Service pour un prochain cycle).
### Rôle restant de la connexion Sync
`AddOmikaDataSyncConnection()` est **obsolète** : elle ne branche plus
`ISyncTransferRepository`. Elle est conservée pour la compatibilité de
configuration (section `SyncConnection`) et pour les outils de diagnostic. Sa
suppression est proposée une fois toutes les références migrées.
> **Aucune modification du schéma HFSQL n'a été effectuée.** Aucune table n'a été
> créée, modifiée ou supprimée. Aucune donnée n'a été écrite.
+300
View File
@@ -0,0 +1,300 @@
# LOT SERVEUR — Worker de Synchronisation (Omika.Worker)
## 1. Décision de conception
Le projet **`src/Omika.Worker/`** est le **nom officiel** du service de
synchronisation serveur dans cette solution. Il est **réutilisé tel quel** :
aucun nouveau projet `Omika.Sync.Worker` n'est créé, aucun renommage, aucun
déplacement, aucune duplication de `SyncProcessor`, `SyncWorker` ou des
handlers.
Le projet existant satisfait déjà le rôle attendu d'un .NET Worker Service
séparé de `Omika.Api`.
## 2. Rôle
`Omika.Worker` est un **BackgroundService** qui traite les opérations de
synchronisation mobile reçues par l'infrastructure serveur. Il est **séparé**
de l'API : l'API reçoit les opérations et les écrit dans la file de transfert
(`TOSRV`) ; le Worker les transfère vers la file de réception (`MobReception`),
les applique via les handlers métier, et journalise le résultat dans `MobLog`.
## 3. Architecture
```
API (SyncOperationService.ReceiveAsync)
→ InsertToSrvAsync() → écrit dans TOSRV
Worker (SyncWorker → SyncProcessor.ProcessAsync)
→ TransferToReceptionAsync() → TOSRV → MobReception
→ GetPendingOperationsAsync() → lit MobReception
→ Handler (Intervention / Default) → applique l'opération
→ MarkSuccessAsync / MarkRejectedAsync / IncrementRetryAsync → MobLog
```
### Chaîne TOSRV → MobReception
Le transfert **TOSRV → MobReception** est **déjà géré** par
`SyncTransferRepository.TransferToReceptionAsync()`, appelé par
`SyncProcessor.ProcessAsync()` (première étape de chaque cycle). **Aucun
chaînon manquant** : la chaîne est complète.
- **Qui écrit dans TOSRV** : `SyncOperationService.ReceiveAsync()` (côté API)
via `ISyncTransferRepository.InsertToSrvAsync()`.
- **Ce que lit SyncProcessor** : les opérations en attente dans `MobReception`
via `GetPendingOperationsAsync()`.
- **MobReception n'est jamais vide** : il est alimenté par le transfert
TOSRV → MobReception à chaque cycle.
## 4. Program.cs / DI
`Program.cs` enregistre :
| Service | Lifetime | Connexion |
|---|---|---|
| `AddOmikaData()` | — | Business (ODBC métier) + WebService |
| `AddOmikaDataServiceConnection()` | — | **Service** (OLEDB, TOSRV) |
| `SyncOptions` (section `Sync`) | Options | — |
| `DefaultSyncOperationHandler` | Scoped | — |
| `IMobileEntityIdentityResolver` | Singleton | — |
| `InterventionSyncOperationHandler` | Scoped | Business |
| `SyncOperationHandlerRegistry` | Scoped | — |
| `SyncProcessor` | Scoped | — |
| `SyncWorker` (HostedService) | Singleton | — |
### Topologie multi-connexion
Le `ISyncTransferRepository` est désormais **multi-connexion** : il reçoit deux
connexions explicites via le DI.
- `IServiceHfSqlConnection` (enregistrée par `AddOmikaDataServiceConnection()`) :
base Service, table `TOSRV`.
- `IWebServiceHfSqlConnection` (enregistrée par `AddOmikaData()`) : base
WebService/Business, tables `MobReception` et `MobLog`.
Le Worker reçoit via DI le repository multi-connexion. Il ne recrée **aucune**
logique TOSRV/MobReception : il délègue entièrement à `SyncProcessor` →
`ISyncTransferRepository`.
## 5. Connexion Service / WebService
La connexion **Service** est configurée dans la section `ServiceConnection`
(`ServiceConnectionOptions`), via le provider OLEDB `PCSOFT.HFSQL`. Elle est
enregistrée comme `IHfsqlConnection` nommée `"Service"` et exposée via le
wrapper `IServiceHfSqlConnection`. Elle héberge la table `TOSRV`.
La connexion **WebService/Business** est la connexion métier ODBC par défaut
(`HfsqlOptions`), exposée via le wrapper `IWebServiceHfSqlConnection`. Elle
héberge les tables `MobReception` et `MobLog` ainsi que les données métier.
`ISyncTransferRepository` est branché sur ces **deux** connexions explicites.
## 6. Connexion Business
La connexion **Business** (base métier Batik/Omika) est configurée dans la
section `Hfsql` (`HfsqlOptions`), via le driver ODBC HFSQL. Les services métier
(`IInterventionService`, etc.) et les repositories adaptatifs Batik/Omika
restent sur cette connexion.
## 7. Rôle TOSRV
`TOSRV` est la file de transfert **mobile → serveur**. L'API y écrit les
opérations reçues via `InsertToSrvAsync()`. Le Worker les transfère ensuite
vers `MobReception`.
## 8. Rôle MobReception
`MobReception` est la file de réception **serveur**. `SyncProcessor` y lit les
opérations en attente de traitement via `GetPendingOperationsAsync()`.
## 9. Rôle MobLog
`MobLog` est le journal terminal de synchronisation. `SyncProcessor` y écrit
les résultats terminaux (`SUCCESS` / `REJECTED`) et les tentatives
(`IncrementRetryAsync`). Le Worker purge périodiquement les journaux terminaux
au-delà de la rétention configurée (`LogRetentionDays`).
## 10. SyncProcessor
`SyncProcessor` est la logique de traitement testable, réutilisée telle quelle.
`SyncWorker` (le BackgroundService) délègue entièrement à `SyncProcessor` :
il ne contient **aucun SQL métier**. Le BackgroundService gère uniquement la
boucle, le `CancellationToken`, l'intervalle de polling et la gestion des
erreurs de cycle.
## 11. SyncWorker
`SyncWorker` est le `BackgroundService` qui orchestre les cycles :
- boucle tant que le `CancellationToken` n'est pas annulé ;
- à chaque cycle : `SyncProcessor.ProcessAsync()` puis purge légère de `MobLog` ;
- délai entre deux cycles : `SyncOptions.RetryDelaySeconds` (configurable) ;
- arrêt propre sur annulation.
## 12. Polling
L'intervalle de polling est **configurable** via la section `Sync`
(`SyncOptions.RetryDelaySeconds`, défaut 120 s). Aucun refactoring nécessaire :
le Worker utilise déjà cette option comme intervalle de cycle.
## 13. CancellationToken
Le `CancellationToken` est passé à `SyncProcessor.ProcessAsync()` et à la purge.
Une annulation pendant le délai lève `OperationCanceledException`, capturée et
transformée en arrêt propre (`break`).
## 14. Gestion des erreurs
Une exception de cycle est capturée par `SyncWorker` (log d'erreur) et le cycle
suivant reprend normalement : **pas de boucle agressive**, le délai est
respecté entre les cycles.
## 15. Retries / max 30
`SyncOptions.MaxRetryCount` (défaut **30**) borne le nombre de tentatives.
`SyncProcessor` incrémente `RetryCount` à chaque échec transitoire
(`TRANSIENT_FAILURE`). Au-delà du max, l'opération est **REJECTED** (terminal).
Un échec définitif (`PERMANENT_FAILURE`) est rejeté immédiatement sans
consommer les 30 retries. **Aucun retry infini** n'est réintroduit.
## 16. OperationId
`OperationId` est conservé tout au long du cycle et sert à l'idempotence : un
doublon d'`OperationId` (déjà `SUCCESS` ou `REJECTED`) n'est pas réappliqué par
le handler.
## 17. RequestedEntityGuid / CanonicalEntityGuid
Le handler Intervention retourne `RequestedEntityGuid` (GUID mobile) et
`CanonicalEntityGuid` (GUID canonique stable) lorsqu'ils diffèrent. Le log
terminal reconstitué conserve le `CanonicalEntityGuid` d'origine pour
l'idempotence par `OperationId`. La réconciliation CREATE est gérée par le
handler (vérification d'existence avant recréation).
## 18. Handlers
Handlers actuellement enregistrés :
- **Intervention** (`InterventionSyncOperationHandler`) : CREATE, UPDATE,
ARCHIVE.
- **DELETE** : non supporté → `PERMANENT_FAILURE` (archivage logique uniquement).
- **Autres EntityTypes sans handler** : `DefaultSyncOperationHandler` →
`PERMANENT_FAILURE` / `NO_HANDLER` (jamais de faux SUCCESS).
Aucun nouveau handler n'est créé dans ce lot.
## 19. Concurrence / claim
**VERSION ACTUELLE : UN SEUL `Omika.Worker` actif par file Sync.** Le
repository/processor ne possède pas de mécanisme fiable de claim. Aucun verrou
HFSQL improvisé n'est créé. La concurrence multi-instance est documentée comme
limite restante (futur lot de déploiement).
## 20. Nombre de Workers autorisé actuellement
**Un seul** `Omika.Worker` actif par file Sync (pas de claim distribué).
## 21. Fonctionnement console
`Omika.Worker` est un exécutable .NET Worker Service : il fonctionne en
**console** via `dotnet run` / l'exécutable produit. Le `Host.CreateApplicationBuilder`
fournit le support console standard.
## 22. Support Windows Service
Le Host .NET standard (`Host.CreateApplicationBuilder`) est compatible avec
l'hébergement Windows Service via `UseWindowsService()`. Ce support n'est pas
encore activé dans `Program.cs` : il est documenté comme **futur lot de
déploiement** (aucune restructuration nécessaire pour l'ajouter).
## 23. Configuration / secrets
Le Worker dispose des sections nécessaires dans `appsettings.json` :
- `Hfsql` (Business / WebService) ;
- `ServiceConnection` (Service, table `TOSRV`) ;
- `Sync` (options : MaxRetryCount, RetryDelaySeconds, LogRetentionDays) ;
- `Omika` (DatabaseVersion) ;
- `Logging`.
Aucun secret n'est versionné : les valeurs de connexion sont des exemples non
sensibles, à surcharger par la configuration d'installation (variables
d'environnement / secrets). Aucune nouvelle convention de secrets n'est créée.
## 24. Fichiers réellement modifiés
- `src/Omika.Worker/Program.cs` : ajout de `AddOmikaDataServiceConnection()`
(connexion Service pour TOSRV) en plus de `AddOmikaData()` (WebService pour
MobReception/MobLog).
- `src/Omika.Worker/appsettings.json` : **créé** (configuration Worker).
- `tests/Omika.Tests/Mobile/SyncWorkerTests.cs` : **créé** (tests du
BackgroundService).
## 25. Tests ajoutés / modifiés
- **Créé** : `tests/Omika.Tests/Mobile/SyncWorkerTests.cs` (5 tests) :
1. Worker appelle SyncProcessor et s'arrête sur annulation ;
2. absence de travail ne crée pas de boucle CPU ;
3. exception de cycle ne provoque pas de boucle agressive ;
4. intervalle de polling configurable respecté ;
5. arrêt propre sur annulation.
Aucun test existant n'est dupliqué (`SyncProcessorTests`,
`InterventionSyncOperationHandlerTests` déjà présents).
## 26. Tests Worker
`SyncWorkerTests` : **5/5** (fakes/mocks, aucune base HFSQL réelle requise).
## 27. Tests Mobile
Référence : **506/506** (à confirmer après validation).
## 28. Suite unitaire
Référence : **1683/1683** hors HFSQL environnemental (à confirmer après
validation).
## 29. Build solution
Référence : **VERT** (à confirmer après validation).
## 30. Build Worker
`Omika.Worker` : **VERT**.
## 31. Build Android
Référence : **VERT** (à confirmer après validation).
## 32. Test HFSQL réel
**Non effectué** dans ce lot : les tests unitaires du Worker utilisent des
fakes/mocks. Les tests HFSQL réels restent environnementaux (nécessitent un
serveur HFSQL de test). Une absence de serveur HFSQL de test ne constitue pas
un échec unitaire.
## 33. Documentation
- `docs/LOT_SYNC_WORKER.md` (ce document).
## 34. Limites restantes
- **Concurrence** : un seul Worker actif par file Sync (pas de claim distribué).
- **Windows Service** : support non activé (futur lot de déploiement).
- **Test HFSQL réel** : non effectué (environnemental).
- **Handlers** : seul Intervention est branché ; les autres EntityTypes passent
par le fallback `NO_HANDLER`.
## 35. Confirmation aucun schéma HFSQL modifié
**Aucun schéma HFSQL n'est modifié** : aucune création/suppression de table,
aucune modification de colonne. Le Worker lit/écrit dans les tables techniques
existantes (TOSRV, MobReception, MobLog) via le repository existant.
## 36. Confirmation aucun protocole historique réintroduit
**Aucun protocole historique n'est réintroduit** : ni `CB_Ajout_TOSRV`, ni le
protocole WINDEV historique. Le transfert TOSRV → MobReception est assuré par
le repository moderne (`SyncTransferRepository.TransferToReceptionAsync`).
Binary file not shown.
+596
View File
@@ -0,0 +1,596 @@
# Mise à jour production — Omika Diagnostics (SRV-OMIKA)
> **Objet** : procédure de mise à jour d'une installation déjà existante
> (première version de `DiagnosticsWorker` + `DiagnosticsWeb` déjà déployée) vers la
> nouvelle version (code + base de données).
>
> **Cible** : Windows Server 2025 `SRV-OMIKA`, PowerShell **administrateur**,
> PostgreSQL 18, runtime .NET 10 (framework-dependent).
>
> **Style** : chaque composant suit le même schéma que la documentation Omika.Api
> existante → **Publier → Backup → Arrêter/Mettre à jour → Redémarrer → Vérifier**.
---
## 0. Vue d'ensemble des composants à mettre à jour
| # | Composant | Projet | Service Windows | Exécutable | Santé |
|---|---|---|---|---|---|
| 1 | API métier/mobile (+ forwarder diagnostics) | `src\Omika.Api` | `OmikaApi` | `Omika.Api.exe` | `GET /health` |
| 2 | Worker de synchronisation métier | `src\Omika.Worker` | `OmikaWorker` | `Omika.Worker.exe` | service `Running` + logs |
| 3 | Import JSONL → PostgreSQL | `src\Omika.Diagnostics.Worker` | `OmikaDiagnosticsWorker` | `Omika.Diagnostics.Worker.exe` | service `Running` + checkpoint |
| 4 | Interface Diagnostic Center | `src\Omika.Diagnostics.Web` | `OmikaDiagnosticsWeb` | `Omika.Diagnostics.Web.exe` | `GET /Login` (200) |
| 5 | API centrale diagnostics *(optionnel, multi-serveur)* | `src\Omika.Diagnostics.Api` | `OmikaDiagnosticsApi` | `Omika.Diagnostics.Api.exe` | `GET /` (200) |
| — | Base PostgreSQL `omika_diagnostics` | `infra\postgresql\*.sql` | — | migrations `001 → 011` | `SELECT count(*)` |
**Ordre global de la mise à jour** (les étapes 4 à 7 sont détaillées plus bas) :
```
1. Publier (poste DEV) §2
2. Transférer + backup §3
3. Arrêter les services §4
4. Migrer la base §5 ← TOUJOURS AVANT les nouveaux binaires
5. Copier les binaires §6
6. Redémarrer §7
7. Vérifier §8
```
---
## 1. Prérequis
### Poste DEV
- SDK .NET 10 (`global.json` épingle `10.0.302`).
- Dépôt à jour (`git pull`), branche qui contient les derniers changements.
### SRV-OMIKA (PowerShell administrateur)
```powershell
net session # doit réussir, sinon STOP (droits)
dotnet --list-runtimes # Microsoft.AspNetCore.App 10.x présent
Get-Service postgresql* # Running
Test-Path 'C:\Program Files\PostgreSQL\18\bin\psql.exe'
Test-Path 'C:\Program Files\PostgreSQL\18\bin\pg_dump.exe'
```
---
## 2. Publier sur le poste DEV
Depuis `D:\Avalonia\Omika`, publier les **4 composants réellement utilisés**
sur SRV-OMIKA. Le composant `Omika.Diagnostics.Api` (§6.5) n'est publié que si le
mode multi-serveur (clients distants) est activé.
> **Script prêt à l'emploi** : `scripts\publish-diagnostics.ps1` effectue
> exactement ceci (publish + vérification des EXE). Lancer depuis la racine :
> `.\scripts\publish-diagnostics.ps1` (ajouter `-IncludeCentralApi` pour l'API
> centrale). Les commandes manuelles équivalentes sont ci-dessous.
```powershell
cd D:\Avalonia\Omika
# 1. Omika.Api
dotnet publish src\Omika.Api\Omika.Api.csproj `
-c Release -r win-x64 --self-contained false `
-o C:\Omika\Deploy\OmikaApi-next
# 2. Omika.Worker
dotnet publish src\Omika.Worker\Omika.Worker.csproj `
-c Release -r win-x64 --self-contained false `
-o C:\Omika\Deploy\OmikaWorker-next
# 3. Omika.Diagnostics.Worker (EnableWindowsService=true est le défaut : ne pas le désactiver)
dotnet publish src\Omika.Diagnostics.Worker\Omika.Diagnostics.Worker.csproj `
-c Release -r win-x64 --self-contained false `
-o C:\Omika\Deploy\DiagnosticsWorker-next
# 4. Omika.Diagnostics.Web
dotnet publish src\Omika.Diagnostics.Web\Omika.Diagnostics.Web.csproj `
-c Release -r win-x64 --self-contained false `
-o C:\Omika\Deploy\DiagnosticsWeb-next
# 5. (optionnel) Omika.Diagnostics.Api
dotnet publish src\Omika.Diagnostics.Api\Omika.Diagnostics.Api.csproj `
-c Release -r win-x64 --self-contained false `
-o C:\Omika\Deploy\DiagnosticsApi-next
```
### Vérification des sorties
```powershell
Get-ChildItem C:\Omika\Deploy\OmikaApi-next # Omika.Api.exe + Omika.Api.dll + Omika.Core.dll + Omika.Data.dll + Omika.Observability.External.dll
Get-ChildItem C:\Omika\Deploy\OmikaWorker-next # Omika.Worker.exe + Omika.Worker.dll + Omika.Core.dll + Omika.Data.dll + Omika.Observability.External.dll
Get-ChildItem C:\Omika\Deploy\DiagnosticsWorker-next # Omika.Diagnostics.Worker.exe + Omika.Diagnostics.Worker.dll + Omika.Diagnostics.Core.dll + Omika.Diagnostics.Data.dll + Npgsql.dll
Get-ChildItem C:\Omika\Deploy\DiagnosticsWeb-next # Omika.Diagnostics.Web.exe + Omika.Diagnostics.Web.dll + wwwroot\ + Omika.Diagnostics.Core.dll + Omika.Diagnostics.Data.dll + Npgsql.dll
```
> ⚠️ Ne jamais livrer de secret Dev en Prod. Vérifier l'absence de valeur de test
> dans les publications, et que le serveur garde **`ASPNETCORE_ENVIRONMENT=Production`**.
> (`appsettings.Development.json`, s'il est présent dans une publication, n'est chargé
> que si l'environnement est `Development` — il ne doit donc jamais l'être sur SRV-OMIKA.)
Transférer ensuite les dossiers `*-next` vers `SRV-OMIKA` dans
`C:\Omika\Deploy\`.
---
## 3. Backup sur le serveur
### 3.1 Base PostgreSQL (AVANT toute migration)
```powershell
$env:PGPASSWORD = '<SECRET_POSTGRES_ADMIN>'
$stamp = Get-Date -Format 'yyyyMMdd_HHmm'
$out = "C:\Omika\Backups\omika_diagnostics_${stamp}_before_upgrade.backup"
& 'C:\Program Files\PostgreSQL\18\bin\pg_dump.exe' `
-h 127.0.0.1 -p 5432 -U postgres -d omika_diagnostics -F c -v -f $out
Remove-Item Env:PGPASSWORD
$f = Get-Item $out
$f.Exists -and $f.Length -gt 0 # doit être True, sinon NO-GO
```
### 3.2 Binaires (AVANT remplacement)
```powershell
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$root = 'C:\Omika\Backups'
Copy-Item 'C:\Omika\Apps\OmikaApi' -Destination "$root\OmikaApi_${stamp}" -Recurse -Force
Copy-Item 'C:\Omika\Apps\OmikaWorker' -Destination "$root\OmikaWorker_${stamp}" -Recurse -Force
Copy-Item 'C:\Omika\Apps\DiagnosticsWorker' -Destination "$root\DiagnosticsWorker_${stamp}" -Recurse -Force
Copy-Item 'C:\Omika\Apps\DiagnosticsWeb' -Destination "$root\DiagnosticsWeb_${stamp}" -Recurse -Force
```
> Le script `infra\deploy\Install-OmikaServerFiles.ps1` fait déjà cette sauvegarde
> horodatée + copie + préservation de la config, si vous préférez l'automatiser.
---
## 4. Arrêter les services (ordre)
L'ordre fige d'abord la lecture (Web), puis les écrivains, et draine le Worker en dernier.
```powershell
Stop-Service OmikaDiagnosticsWeb -Force
Stop-Service OmikaApi -Force
Stop-Service OmikaWorker -Force
Stop-Service OmikaDiagnosticsWorker -Force
Get-Service OmikaApi,OmikaWorker,OmikaDiagnosticsWorker,OmikaDiagnosticsWeb | Select-Object Name,Status
# Attendu : les 4 = Stopped
```
---
## 5. Mettre à jour la base de données (migrations)
### 5.1 Auditer l'état actuel (lecture seule)
```powershell
$env:PGPASSWORD = '<SECRET_POSTGRES_ADMIN>'
$psql = 'C:\Program Files\PostgreSQL\18\bin\psql.exe'
& $psql -h 127.0.0.1 -p 5432 -U postgres -d omika_diagnostics -c `
"SELECT table_name FROM information_schema.tables WHERE table_schema='public' AND table_name LIKE 'diagnostics%' ORDER BY 1;"
& $psql -h 127.0.0.1 -p 5432 -U postgres -d omika_diagnostics -c `
"SELECT column_name FROM information_schema.columns WHERE table_name='diagnostic_event' ORDER BY ordinal_position;"
& $psql -h 127.0.0.1 -p 5432 -U postgres -d omika_diagnostics -c `
"SELECT rolname FROM pg_roles ORDER BY rolname;"
Remove-Item Env:PGPASSWORD
```
Cela permet de déterminer **quelles migrations sont déjà appliquées** :
| Repère observé | Migration déjà appliquée |
|---|---|
| `diagnostic_event` présent | `001` |
| colonnes `actor_key` / `actor_key_version` | `002` |
| rôles `omika_diagnostics_owner/worker/web` | `003` |
| `SELECT`+`DELETE` worker sur `diagnostic_event` | `004` |
| colonnes `client_code`/`server_instance`/`environment` | `005` |
| colonnes appareil/licence | `006` |
| colonnes `screen_code`/`feature_code` | `007` |
| table `diagnostics_clients` | `008` |
| rôle `omika_diagnostics_api` + grants clients | `009` |
| table `diagnostics_users` | `010` |
| table `diagnostics_reference` | `011` |
### 5.2 Appliquer UNIQUEMENT les migrations manquantes, dans l'ordre
Migrations `001 → 011`, toutes **idempotentes** (`CREATE/ADD IF NOT EXISTS`,
`ON CONFLICT DO NOTHING`, `GRANT`). Les appliquer avec `ON_ERROR_STOP=1`, en
compte **superuser** (`postgres`), car `003/009/010/011` font du `CREATE ROLE` /
`ALTER TABLE OWNER` / `GRANT`.
```powershell
$env:PGPASSWORD = '<SECRET_POSTGRES_ADMIN>'
$psql = 'C:\Program Files\PostgreSQL\18\bin\psql.exe'
$db = 'C:\Omika\Deploy\DiagnosticsDatabase' # dossier contenant les .sql livrés
$migrations = @(
'001_diagnostics_schema.sql',
'002_actor_key.sql',
'003_diagnostics_roles.sql',
'004_diagnostics_retention.sql',
'005_diagnostics_client_context.sql',
'006_diagnostics_device_license.sql',
'007_diagnostics_usage.sql',
'008_diagnostics_clients.sql',
'009_diagnostics_clients_grants.sql',
'010_diagnostics_web_users.sql',
'011_diagnostics_reference.sql'
)
foreach ($m in $migrations) {
$f = Join-Path $db $m
if (-not (Test-Path $f)) { Write-Host "ABSENT : $m" ; continue }
Write-Host "Application : $m ..."
& $psql -h 127.0.0.1 -p 5432 -U postgres -d omika_diagnostics -v ON_ERROR_STOP=1 -f $f
if ($LASTEXITCODE -ne 0) { throw "ÉCHEC sur $m (exit $LASTEXITCODE). STOP." }
}
Remove-Item Env:PGPASSWORD
```
> **Règle d'ordre migration ↔ binaires** : les migrations doivent être appliquées
> **AVANT** la copie des nouveaux binaires (§6). Les colonnes/tables ajoutées sont
> additives ; les anciens binaires restent compatibles, mais les nouveaux binaires
> échoueraient sur un schéma trop ancien.
### 5.3 Ce qui est nouveau dans cette mise à jour
- **`011_diagnostics_reference.sql`** (dernière livraison) : table
`diagnostics_reference` (référentiel administrable `ScreenCode`/`FeatureCode`),
ownership `omika_diagnostics_owner`, grants `omika_diagnostics_web`
(`SELECT`/`INSERT`/`UPDATE` colonnes ciblées + `USAGE` séquence), et **seed
idempotent** des 29 `ScreenCode` + 3 `FeatureCode`. Aucun droit accordé aux rôles
`omika_diagnostics_api` / `omika_diagnostics_worker`.
- `003_diagnostics_roles_preprod.sql` ne concerne **que** la préproduction : ne
**jamais** l'appliquer en Production.
### 5.4 Après `003` / `009` (rôles LOGIN) — mots de passe manuels
Les rôles LOGIN créés sans mot de passe doivent être positionnés manuellement,
jamais dans un script/log :
```
\password omika_diagnostics_worker
\password omika_diagnostics_web
\password omika_diagnostics_api # seulement si l'API centrale est activée
```
---
## 6. Mettre à jour chaque composant
Pour chaque composant, la copie **préserve** la configuration serveur existante
(secrets) : on exclut `appsettings.json` et `appsettings.Production.json`.
> ⚠️ Ne **jamais** supprimer :
> - `C:\Omika\Data\Api\Diagnostics\Jsonl\**` (JSONL en attente d'import)
> - `C:\Omika\Data\DiagnosticsWorker\Checkpoint\import-checkpoint.json`
> - `C:\Omika\Config\**`, les certificats TLS, les variables d'environnement MACHINE.
### 6.1 Omika.Api (service `OmikaApi`)
```powershell
# Backup : déjà fait §3.2. Puis copie :
robocopy 'C:\Omika\Deploy\OmikaApi-next' 'C:\Omika\Apps\OmikaApi' `
/E /XF appsettings.json appsettings.Production.json
```
Fichiers attendus après copie : `Omika.Api.exe`, `Omika.Api.dll`, `Omika.Core.dll`,
`Omika.Data.dll`, `Omika.Observability.External.dll`.
Configuration Production à conserver (injectée sur le serveur) :
`MobileAuth__SigningKey` (obligatoire, fail-fast), `DiagnosticsIdentity__HmacKey`,
`ConnectionStrings__*` HFSQL / licence / sync, `Observability__Remote__Directory`
(doit être **identique** au dossier JSONL lu par le Worker), et si le forwarding
central est actif : `DiagnosticsForwarding__Enabled/Endpoint/ApiKey`.
(Voir la table complète §6.6.)
Redémarrage et contrôle (port Kestrel = celui défini dans
`appsettings.Production.json`, ex. `5080`) :
```powershell
Start-Service OmikaApi
Get-Service OmikaApi # Running
Invoke-WebRequest 'http://127.0.0.1:5080/health' -UseBasicParsing
Invoke-WebRequest 'https://omika.applilogik.fr/health' -UseBasicParsing
# Attendu : 200 + "Healthy"
```
### 6.2 Omika.Worker (service `OmikaWorker`)
```powershell
robocopy 'C:\Omika\Deploy\OmikaWorker-next' 'C:\Omika\Apps\OmikaWorker' `
/E /XF appsettings.json appsettings.Production.json
```
Fichiers attendus : `Omika.Worker.exe`, `Omika.Worker.dll`, `Omika.Core.dll`,
`Omika.Data.dll`, `Omika.Observability.External.dll`.
Pas de port HTTP (background service) : contrôle = service `Running` + absence de
boucle d'erreur dans les logs (`C:\Omika\Logs\Worker`).
```powershell
Start-Service OmikaWorker
Get-Service OmikaWorker # Running
Get-EventLog -LogName Application -Newest 50 | Where-Object { $_.Source -match 'Omika' }
```
### 6.3 Omika.Diagnostics.Worker (service `OmikaDiagnosticsWorker`)
```powershell
robocopy 'C:\Omika\Deploy\DiagnosticsWorker-next' 'C:\Omika\Apps\DiagnosticsWorker' `
/E /XF appsettings.json appsettings.Production.json
```
Fichiers attendus : `Omika.Diagnostics.Worker.exe`, `Omika.Diagnostics.Worker.dll`,
`Omika.Diagnostics.Core.dll`, `Omika.Diagnostics.Data.dll`, `Npgsql.dll`.
Configuration Production à conserver :
`ConnectionStrings__DiagnosticsDatabase`
(`Host=127.0.0.1;Port=5432;Database=omika_diagnostics;Username=omika_diagnostics_worker;Password=…`),
`Observability__Remote__Directory` (**identique** au dossier JSONL d'Omika.Api),
`Diagnostics__Import__CheckpointDirectory/CheckpointFileName`, `Diagnostics__RetentionDays`.
(Voir la table complète §6.6.)
```powershell
Start-Service OmikaDiagnosticsWorker
Get-Service OmikaDiagnosticsWorker # Running
Get-Content 'C:\Omika\Data\DiagnosticsWorker\Checkpoint\import-checkpoint.json'
# Attendu : checkpoint stable ou progressant ; aucun JSONL supprimé.
```
### 6.4 Omika.Diagnostics.Web (service `OmikaDiagnosticsWeb`)
```powershell
robocopy 'C:\Omika\Deploy\DiagnosticsWeb-next' 'C:\Omika\Apps\DiagnosticsWeb' `
/E /XF appsettings.json appsettings.Production.json
```
Fichiers attendus : `Omika.Diagnostics.Web.exe`, `Omika.Diagnostics.Web.dll`,
`wwwroot\`, `Omika.Diagnostics.Core.dll`, `Omika.Diagnostics.Data.dll`, `Npgsql.dll`.
Configuration Production à conserver :
`ConnectionStrings__DiagnosticsDatabase`
(`…Username=omika_diagnostics_web;Password=…`), `DiagnosticsAuth__Operators`
(bootstrap temporaire uniquement), `ASPNETCORE_ENVIRONMENT=Production`.
(Voir la table complète §6.6.)
Contrôle (port Kestrel = celui de `appsettings.Production.json`) :
```powershell
Start-Service OmikaDiagnosticsWeb
Get-Service OmikaDiagnosticsWeb # Running
Invoke-WebRequest 'http://127.0.0.1:<PORT_WEB_DIAG>/Login' -UseBasicParsing # 200
```
> Le Web n'expose pas de `/health` : la page `/Login` (200) est le smoke test.
> Le reste est derrière authentification (redirect `/Login`).
### 6.5 Omika.Diagnostics.Api (service `OmikaDiagnosticsApi`) — **optionnel**
À déployer **uniquement** si vous activez la réception de lots provenant de
**serveurs clients distants**. Sur un SRV-OMIKA mono-serveur (Omika.Api écrit le
JSONL localement), ce composant n'est **pas** nécessaire.
```powershell
robocopy 'C:\Omika\Deploy\DiagnosticsApi-next' 'C:\Omika\Apps\DiagnosticsApi' `
/E /XF appsettings.json appsettings.Production.json
```
Fichiers attendus : `Omika.Diagnostics.Api.exe`, `Omika.Diagnostics.Api.dll`,
`Omika.Core.dll`, `Omika.Diagnostics.Core.dll`, `Omika.Diagnostics.Data.dll`.
Configuration Production à conserver :
`ConnectionStrings__DiagnosticsDatabase` (`Username=omika_diagnostics_api`),
`Diagnostics__Central__Directory` (= spool central, ex.
`C:\ProgramData\Omika\Diagnostics\Central`).
```powershell
Start-Service OmikaDiagnosticsApi
Get-Service OmikaDiagnosticsApi # Running
Invoke-WebRequest 'http://127.0.0.1:<PORT_API_DIAG>/' -UseBasicParsing
# Attendu : 200 + JSON { name: "Omika.Diagnostics.Api", status: "ok" }
```
### 6.6 Référence — variables d'environnement / clés de configuration par service
**Règle de nommage** : en variable d'environnement, `:` devient `__`
(`Diagnostics:Import:CheckpointDirectory` ⇔ `Diagnostics__Import__CheckpointDirectory`).
**Précédence** : `ConnectionStrings:DiagnosticsDatabase` (env
`ConnectionStrings__DiagnosticsDatabase`) **prime** sur `Diagnostics:ConnectionString`.
Toute modification de variable d'environnement d'un service Windows nécessite un
**redémarrage du service** pour être prise en compte.
#### Omika.Api (service `OmikaApi`)
| Variable d'environnement | Clé de configuration | Rôle |
|---|---|---|
| `ASPNETCORE_ENVIRONMENT` | — | `Production` obligatoire |
| `MobileAuth__SigningKey` | `MobileAuth:SigningKey` | clé de signature tokens mobile (**fail-fast** en Prod) |
| `DiagnosticsIdentity__HmacKey` | `DiagnosticsIdentity:HmacKey` | clé HMAC ActorKey/DeviceKey (≥ 256 bits Base64) |
| `DiagnosticDeployment__ClientCode` | `DiagnosticDeployment:ClientCode` | override CodeClient (sinon fallback HFSQL) |
| `DiagnosticDeployment__ServerInstanceId` | `DiagnosticDeployment:ServerInstanceId` | id d'instance (sinon `MachineName`) |
| `Observability__Remote__Directory` | `Observability:Remote:Directory` | dossier JSONL (**identique** au Worker) |
| `DiagnosticsForwarding__Enabled` | `DiagnosticsForwarding:Enabled` | kill switch du forwarder central (`true`/`false`) |
| `DiagnosticsForwarding__Endpoint` | `DiagnosticsForwarding:Endpoint` | URL centrale **HTTPS** (si forwarding actif) |
| `DiagnosticsForwarding__ApiKey` | `DiagnosticsForwarding:ApiKey` | clé d'accès centrale (**secret**) |
| `LicenseConnection__*` / `SyncConnection__*` / HFSQL | — | secrets HFSQL existants (non touchés) |
#### Omika.Worker (service `OmikaWorker`)
| Variable d'environnement | Clé de configuration | Rôle |
|---|---|---|
| `ASPNETCORE_ENVIRONMENT` | — | `Production` obligatoire |
| `DiagnosticDeployment__ClientCode` | `DiagnosticDeployment:ClientCode` | idem Omika.Api |
| `DiagnosticDeployment__ServerInstanceId` | `DiagnosticDeployment:ServerInstanceId` | idem Omika.Api |
| `SyncConnection__*` / HFSQL | — | secrets HFSQL existants (non touchés) |
#### Omika.Diagnostics.Worker (service `OmikaDiagnosticsWorker`)
| Variable d'environnement | Clé de configuration | Défaut | Rôle |
|---|---|---|---|
| `ConnectionStrings__DiagnosticsDatabase` | `ConnectionStrings:DiagnosticsDatabase` | — | Npgsql `Username=omika_diagnostics_worker` (prime sur ci-dessous) |
| `Diagnostics__ConnectionString` | `Diagnostics:ConnectionString` | `""` | fallback si `ConnectionStrings` absent |
| `Observability__Remote__Directory` | `Observability:Remote:Directory` | `""` | dossier JSONL (**identique** à Omika.Api) |
| `Diagnostics__Import__CheckpointDirectory` | `Diagnostics:Import:CheckpointDirectory` | `""` | dossier checkpoint (sinon = dossier source) |
| `Diagnostics__Import__CheckpointFileName` | `Diagnostics:Import:CheckpointFileName` | `import-checkpoint.json` | nom du checkpoint |
| `Diagnostics__Import__BatchSize` | `Diagnostics:Import:BatchSize` | `100` | taille de lot d'insertion (borné 1..200) |
| `Diagnostics__RetentionDays` | `Diagnostics:RetentionDays` | `60` | rétention en jours (`<=0` = purge désactivée) |
| `Diagnostics__RetentionIntervalHours` | `Diagnostics:RetentionIntervalHours` | `24` | fréquence de purge |
| `Diagnostics__RetentionBatchSize` | `Diagnostics:RetentionBatchSize` | `1000` | lot de suppression (borné) |
| `Diagnostics__CommandTimeoutSeconds` | `Diagnostics:CommandTimeoutSeconds` | `10` | timeout court des commandes |
#### Omika.Diagnostics.Web (service `OmikaDiagnosticsWeb`)
| Variable d'environnement | Clé de configuration | Rôle |
|---|---|---|
| `ASPNETCORE_ENVIRONMENT` | — | `Production` obligatoire (default-deny) |
| `ConnectionStrings__DiagnosticsDatabase` | `ConnectionStrings:DiagnosticsDatabase` | Npgsql `Username=omika_diagnostics_web` |
| `DiagnosticsAuth__Operators__0__Username` | `DiagnosticsAuth:Operators:0:Username` | opérateur bootstrap (temporaire) |
| `DiagnosticsAuth__Operators__0__PasswordHash` | `DiagnosticsAuth:Operators:0:PasswordHash` | **hash** bootstrap (jamais en clair) |
| `DiagnosticsAuth__Operators__0__Roles__0` | `DiagnosticsAuth:Operators:0:Roles:0` | `Admin` / `Support` |
| `Diagnostics__RetentionDays` | `Diagnostics:RetentionDays` | `60` (affiché dans l'UI) |
#### Omika.Diagnostics.Api (service `OmikaDiagnosticsApi`) — optionnel
| Variable d'environnement | Clé de configuration | Défaut | Rôle |
|---|---|---|---|
| `ConnectionStrings__DiagnosticsDatabase` | `ConnectionStrings:DiagnosticsDatabase` | — | Npgsql `Username=omika_diagnostics_api` |
| `Diagnostics__Central__Directory` | `Diagnostics:Central:Directory` | `logs/diagnostics-central` | **spool central** (chemin explicite en Prod) |
| `Diagnostics__Central__MaxRequestBodyBytes` | `Diagnostics:Central:MaxRequestBodyBytes` | `524288` | corps de requête borné |
| `Diagnostics__Central__MaxEventsPerBatch` | `Diagnostics:Central:MaxEventsPerBatch` | `100` | événements max par lot |
| `Diagnostics__Central__SchemaVersion` | `Diagnostics:Central:SchemaVersion` | `1` | version du schéma de transport |
**Ports Kestrel** : définis par `appsettings.Production.json` de chaque composant
(`Kestrel:Endpoints:Http:Url` ou `ASPNETCORE_URLS`), en HTTP loopback derrière le
reverse proxy Apache (`https://omika.applilogik.fr`). Aucun port n'est codé en dur
dans le binaire.
---
## 7. Redémarrer (ordre)
Démarrer les **consommateurs avant les producteurs** :
```powershell
Start-Service OmikaDiagnosticsWorker # 1. importeur (consommateur JSONL)
Start-Service OmikaDiagnosticsWeb # 2. UI (lecture)
Start-Service OmikaWorker # 3. sync métier (producteur #2)
Start-Service OmikaApi # 4. API (producteur #1, dernier)
Get-Service OmikaApi,OmikaWorker,OmikaDiagnosticsWorker,OmikaDiagnosticsWeb | Select-Object Name,Status
# Attendu : les 4 = Running
```
> Si un service passe immédiatement en `Stopped`/`Failed`, ne pas démarrer les
> suivants : diagnostiquer puis rollback si non résolu (§9).
---
## 8. Smoke tests (non destructifs)
```powershell
# Services
Get-Service OmikaApi,OmikaWorker,OmikaDiagnosticsWorker,OmikaDiagnosticsWeb | Select-Object Name,Status
# API métier
Invoke-WebRequest 'http://127.0.0.1:5080/health' -UseBasicParsing # 200 "Healthy"
# Web Diagnostics (Login)
Invoke-WebRequest 'http://127.0.0.1:<PORT_WEB_DIAG>/Login' -UseBasicParsing # 200
```
Contrôles PostgreSQL après redémarrage :
```sql
SELECT count(*) FROM diagnostic_event;
SELECT max(ingested_at_utc) AS dernier_ingere FROM diagnostic_event;
-- Référentiel (011) :
SELECT reference_type, count(*) FROM diagnostics_reference GROUP BY reference_type;
-- Attendu : SCREEN_CODE = 29, FEATURE_CODE = 3
```
Dans **Diagnostics.Web** : vérifier Dashboard, Diagnostics, Erreurs, Traces,
Synchronisation, Telemetry, Versions, et **Référentiel** (nouvelle fonctionnalité
`011`) ainsi que `/Clients` et `/Users` (Admin).
---
## 9. Rollback
### Binaires
```powershell
Stop-Service OmikaApi,OmikaWorker,OmikaDiagnosticsWorker,OmikaDiagnosticsWeb -Force
$stamp = '<horodatage_du_backup>' # ex. 20261002-160000
Remove-Item 'C:\Omika\Apps\OmikaApi' -Recurse -Force
Copy-Item "C:\Omika\Backups\OmikaApi_${stamp}" 'C:\Omika\Apps\OmikaApi' -Recurse -Force
Copy-Item "C:\Omika\Backups\OmikaWorker_${stamp}" 'C:\Omika\Apps\OmikaWorker' -Recurse -Force
Copy-Item "C:\Omika\Backups\DiagnosticsWorker_${stamp}" 'C:\Omika\Apps\DiagnosticsWorker' -Recurse -Force
Copy-Item "C:\Omika\Backups\DiagnosticsWeb_${stamp}" 'C:\Omika\Apps\DiagnosticsWeb' -Recurse -Force
Start-Service OmikaDiagnosticsWorker
Start-Service OmikaDiagnosticsWeb
Start-Service OmikaWorker
Start-Service OmikaApi
```
### Base de données
**Pas de rollback SQL automatique** : les migrations `001 → 011` sont **additives**
et idempotentes ; les anciens binaires restent compatibles. Ne jamais `DROP` /
`TRUNCATE` / `DELETE`. Restaurer via `pg_restore` (backup §3.1) uniquement sur
décision humaine explicite et en dernier recours :
```powershell
& 'C:\Program Files\PostgreSQL\18\bin\pg_restore.exe' `
-h 127.0.0.1 -p 5432 -U postgres -d omika_diagnostics -c `
'C:\Omika\Backups\omika_diagnostics_<stamp>_before_upgrade.backup'
```
---
## 10. Checklist finale
```text
[ ] Publications DEV : 4 composants générés, EXE/DLL présents, pas de secret Dev
[ ] Transfert vers SRV-OMIKA C:\Omika\Deploy\*-next
[ ] pg_dump terminé + taille > 0
[ ] Backup binaires (4 dossiers) présent + complet
[ ] Services arrêtés (4 = Stopped)
[ ] Migrations manquantes identifiées puis appliquées dans l'ordre (ON_ERROR_STOP=1)
[ ] 011 (diagnostics_reference) appliquée ; seed 29 + 3 vérifié
[ ] Mots de passe rôles LOGIN posés manuellement (\password)
[ ] appsettings.json / appsettings.Production.json préservés sur les 4 composants
[ ] JSONL + checkpoint intacts (jamais supprimés)
[ ] Redémarrage dans l'ordre (Worker → Web → OmikaWorker → OmikaApi)
[ ] 4 services Running
[ ] /health Omika.Api = 200 "Healthy" ; /Login DiagnosticsWeb = 200
[ ] Dashboard / Référentiel / Clients / Users fonctionnels
[ ] Rollback prêt (backups binaires + décision DB additive)
```
---
## Références internes
- `docs/DEPLOIEMENT_PRODUCTION_DIAGNOSTICS.md` — architecture, ports, comptes, secrets.
- `docs/T15-RUNBOOK-DEPLOIEMENT-PRODUCTION.md` — runbook complet (phases détaillées).
- `scripts/publish-diagnostics.ps1` — publication automatisée des composants (poste DEV).
- `infra/deploy/Install-OmikaServerFiles.ps1` — copie statique + sauvegarde + SHA256.
- `infra/deploy/Omika.Deploy.Common.psm1` — module commun (comptes, ACL contrôlées, services, ports).
- `infra/deploy/Install-Omika<Service>.ps1` — **un script par déploiement**
(`OmikaApi`, `OmikaWorker`, `OmikaDiagnosticsApi`, `OmikaDiagnosticsWorker`,
`OmikaDiagnosticsWeb`, `OmikaLanguesApi`) : idempotents, `-WhatIf`, `-Desinstaller`.
- `infra/deploy/Install-OmikaServices.ps1` — orchestrateur : enchaîne les scripts ci-dessus dans l'ordre.
- `infra/deploy/Test-PortDisponible.ps1` — disponibilité d'un port loopback avant configuration.
- `infra/deploy/Install-DatabaseSchema.ps1` — applique `001`+`002` (schéma).
- `infra/deploy/Harden-DatabaseRoles.ps1` — applique `003` (rôles).
- `infra/postgresql/001…011_diagnostics_*.sql` — migrations PostgreSQL.
+515
View File
@@ -0,0 +1,515 @@
# Marche à suivre — Mise en production Omika Diagnostics (V2)
> **Objet** : procédure **complète et à jour** de mise en production de la chaîne
> Diagnostics, tenant compte du **premier déploiement d'il y a deux semaines**
> (DiagnosticsWorker + DiagnosticsWeb + schéma `001→004/005`) et de tous les
> changements intervenus depuis : **API centrale `Omika.Diagnostics.Api` (T19)**,
> tables `diagnostics_clients`/`diagnostics_users`/`diagnostics_reference`/
> `diagnostics_tasks`, rétention télémétrie + erreurs non traitées, suppression de
> groupes d'erreurs, etc.
>
> **Cible** : Windows Server 2025 `SRV-OMIKA`, PowerShell **administrateur**,
> PostgreSQL 18, runtime .NET 10 (hébergement).
>
> Ce document **remplace** `MISE_A_JOUR_DIAGNOSTICS_SRV-OMIKA.md` (qui s'arrêtait à
> `011`) et complète `T15-RUNBOOK-DEPLOIEMENT-PRODUCTION.md` (qui s'arrêtait à `005`).
---
## 0. Vue d'ensemble des projets Diagnostics
| Projet | Rôle | Référence |
|---|---|---|
| `Omika.Diagnostics.Core` | Modèles, abstractions de repository, import JSONL, validation. | — |
| `Omika.Diagnostics.Data` | Persistance Npgsql (pas d'ORM) + DI. | — |
| `Omika.Diagnostics.Worker` | **Service** d'import JSONL → PostgreSQL + rétention (`OmikaDiagnosticsWorker`). | Windows Service |
| `Omika.Diagnostics.Web` | **Service** interface de consultation/admin (`OmikaDiagnosticsWeb`). | Kestrel + service Windows |
| `Omika.Diagnostics.Api` | **Service** API centrale (T19), reçoit les lots des serveurs clients (`OmikaDiagnosticsApi`). | Kestrel + service Windows |
| `Omika.Api` | API métier/mobile **chez le client** : `POST /api/diagnostics` + `ServerDiagnosticMiddleware` + forwarder central. | Windows Service `OmikaApi` |
| `Omika.Worker` | Worker métier (sync) ; émet des diagnostics serveur et forwarde son **propre** spool si hébergé sur un autre serveur que l'API. | Windows Service `OmikaWorker` |
| `Omika.Observability.External` | Pont **optionnel** OpenTelemetry → Collector → SigNoz (hors chaîne Diagnostics). | — |
| `Omika.App*` | App mobile/desktop : outbox SQLite + uploader `POST /api/diagnostics`. | — |
> ⚠️ `Omika.slnx` **ne référence pas** les projets Diagnostics : on publie chaque
> `.csproj` directement (voir §2). La chaîne Diagnostics est **indépendante** du
> pont OpenTelemetry/SigNoz (`Observability:External`) — ne pas les confondre.
---
## 1. Ce qui a changé depuis le premier déploiement
1. **Nouveau service** : `OmikaDiagnosticsApi` (`src/Omika.Diagnostics.Api`) — API
centrale multi-serveur. **Non présent** dans le premier déploiement.
2. **Base de données fortement évoluée** : migrations `005 → 016` ajoutées
(contexte client, appareil/licence, usage, registre clients, utilisateurs Web,
référentiel, tâches de traitement, rétention télémétrie/erreurs, DELETE groupe).
3. **Nouvelles tables** : `diagnostics_clients`, `diagnostics_users`,
`diagnostics_reference`, `diagnostics_tasks`.
4. **Nouveaux privilèges** : rôle `omika_diagnostics_api` (009) ; DELETE Web sur
`diagnostics_tasks` (013) et `diagnostic_event` (016) ; SELECT/DELETE worker sur
`telemetry_metric_hourly` (014) ; SELECT colonne-ciblé worker sur
`diagnostics_tasks` (015).
5. **Nouvelles fonctionnalités Web** : `/Clients`, `/Users`, `/Referentiel`,
`/Taches`, boutons « Supprimer » (tâche + groupe d'erreurs).
---
## 2. Prérequis
### Poste DEV (publication)
```powershell
cd D:\Avalonia\Omika
dotnet --version # SDK 10 (global.json épingle 10.0.302)
git pull # branche à jour
```
### SRV-OMIKA (PowerShell administrateur)
```powershell
net session # OK sinon STOP
dotnet --list-runtimes # Microsoft.AspNetCore.App 10.x
Get-Service postgresql* # Running
Test-Path 'C:\Program Files\PostgreSQL\18\bin\psql.exe'
Test-Path 'C:\Program Files\PostgreSQL\18\bin\pg_dump.exe'
```
---
## 3. Publication (poste DEV)
> Script prêt : `scripts\publish-diagnostics.ps1` (ajouter `-IncludeCentralApi`).
> Équivalent manuel ci-dessous.
```powershell
cd D:\Avalonia\Omika
# 1. Omika.Api (métier + forwarder)
dotnet publish src\Omika.Api\Omika.Api.csproj `
-c Release -r win-x64 --self-contained false -o C:\Omika\Deploy\OmikaApi-next
# 2. Omika.Worker (sync métier)
dotnet publish src\Omika.Worker\Omika.Worker.csproj `
-c Release -r win-x64 --self-contained false -o C:\Omika\Deploy\OmikaWorker-next
# 3. Omika.Diagnostics.Worker (EnableWindowsService=true = défaut)
dotnet publish src\Omika.Diagnostics.Worker\Omika.Diagnostics.Worker.csproj `
-c Release -r win-x64 --self-contained false -o C:\Omika\Deploy\DiagnosticsWorker-next
# 4. Omika.Diagnostics.Web
dotnet publish src\Omika.Diagnostics.Web\Omika.Diagnostics.Web.csproj `
-c Release -r win-x64 --self-contained false -o C:\Omika\Deploy\DiagnosticsWeb-next
# 5. Omika.Diagnostics.Api (NOUVEAU — API centrale)
dotnet publish src\Omika.Diagnostics.Api\Omika.Diagnostics.Api.csproj `
-c Release -r win-x64 --self-contained false -o C:\Omika\Deploy\DiagnosticsApi-next
```
**Contrôles** : chaque dossier contient son `.exe` ; `appsettings.Development.json`
absent (ou jamais activé) ; aucun secret Dev dans les publications.
Transférer ensuite les 5 dossiers `*-next` vers `C:\Omika\Deploy\` sur SRV-OMIKA.
---
## 4. Base de données — inventaire des migrations (`001 → 016`)
Ordre d'application **strict** sur SRV-OMIKA, en **superuser** (`postgres`), avec
`ON_ERROR_STOP=1`. Toutes sont **idempotentes** et **non destructives**.
| # | Fichier | Objet | Rôle(s) impacté(s) |
|---|---|---|---|
| 001 | `001_diagnostics_schema.sql` | `diagnostic_event` + `telemetry_metric_hourly` + dédup `event_id` | — |
| 002 | `002_actor_key.sql` | colonnes `actor_key`/`actor_key_version` | — |
| 003 | `003_diagnostics_roles.sql` | rôles `owner`(NOLOGIN)/`worker`/`web` + ownership + grants | worker: INSERT ; web: SELECT |
| 004 | `004_diagnostics_retention.sql` | rétention bornée | worker: +SELECT+DELETE `diagnostic_event` |
| 005 | `005_diagnostics_client_context.sql` | colonnes `client_code`/`server_instance`/`environment` | — |
| 006 | `006_diagnostics_device_license.sql` | colonnes appareil/licence | — |
| 007 | `007_diagnostics_usage.sql` | colonnes `screen_code`/`feature_code` | — |
| 008 | `008_diagnostics_clients.sql` | table `diagnostics_clients` | — |
| 009 | `009_diagnostics_clients_grants.sql` | rôle `omika_diagnostics_api` + grants clients | api: SELECT + UPDATE(last_seen) ; web: SELECT/INSERT/UPDATE |
| 010 | `010_diagnostics_web_users.sql` | table `diagnostics_users` | web: SELECT/INSERT/UPDATE |
| 011 | `011_diagnostics_reference.sql` | table `diagnostics_reference` + seed 29+3 | web: SELECT/INSERT/UPDATE |
| 012 | `012_diagnostics_tasks.sql` | table `diagnostics_tasks` | web: SELECT/INSERT/UPDATE |
| 013 | `013_diagnostics_tasks_delete.sql` | DELETE tâche | web: +DELETE `diagnostics_tasks` |
| 014 | `014_diagnostics_telemetry_retention.sql` | rétention télémétrie (180 j) | worker: +SELECT+DELETE `telemetry_metric_hourly` |
| 015 | `015_diagnostics_retention_untreated.sql` | ne pas purger les erreurs non traitées | worker: +SELECT(2 col.) `diagnostics_tasks` |
| 016 | `016_diagnostics_event_delete.sql` | suppression d'un groupe d'erreurs | web: +DELETE `diagnostic_event` |
> ⚠️ `003_diagnostics_roles_preprod.sql` concerne **uniquement** la préproduction :
> **ne jamais** l'appliquer en Production.
### Récapitulatif des privilèges par rôle (après 016)
| Rôle (LOGIN) | Service | Privilèges |
|---|---|---|
| `omika_diagnostics_worker` | Diagnostics.Worker | `diagnostic_event` INSERT/SELECT/DELETE ; `telemetry_metric_hourly` SELECT/DELETE ; `diagnostics_tasks` SELECT(`group_url_identifier`,`status`) |
| `omika_diagnostics_web` | Diagnostics.Web | `diagnostic_event` SELECT/DELETE ; `diagnostics_clients` SELECT/INSERT/UPDATE(cols) ; `diagnostics_users` SELECT/INSERT/UPDATE(cols) ; `diagnostics_reference` SELECT/INSERT/UPDATE(cols) ; `diagnostics_tasks` SELECT/INSERT/UPDATE(cols)/DELETE |
| `omika_diagnostics_api` | Diagnostics.Api | `diagnostics_clients` SELECT + UPDATE(`last_seen_at_utc`) uniquement |
---
## 5. Marche à suivre détaillée (fenêtre de maintenance)
### 5.1 Audit (lecture seule) — déterminer les migrations déjà appliquées
```powershell
$env:PGPASSWORD = '<SECRET_POSTGRES_ADMIN>'
$psql = 'C:\Program Files\PostgreSQL\18\bin\psql.exe'
& $psql -h 127.0.0.1 -p 5432 -U postgres -d omika_diagnostics -c `
"SELECT table_name FROM information_schema.tables WHERE table_schema='public' AND table_name LIKE 'diagnostic%' ORDER BY 1;"
& $psql -h 127.0.0.1 -p 5432 -U postgres -d omika_diagnostics -c `
"SELECT column_name FROM information_schema.columns WHERE table_name='diagnostic_event' ORDER BY ordinal_position;"
& $psql -h 127.0.0.1 -p 5432 -U postgres -d omika_diagnostics -c `
"SELECT rolname FROM pg_roles ORDER BY rolname;"
Remove-Item Env:PGPASSWORD
```
Repères : `diagnostic_event`=001 ; `actor_key`=002 ; rôles owner/worker/web=003 ;
`client_code`=005 ; `diagnostics_clients`=008 ; rôle `omika_diagnostics_api`=009 ;
`diagnostics_users`=010 ; `diagnostics_reference`=011 ; `diagnostics_tasks`=012.
### 5.2 Backup PostgreSQL (AVANT toute migration)
```powershell
$env:PGPASSWORD = '<SECRET_POSTGRES_ADMIN>'
$stamp = Get-Date -Format 'yyyyMMdd_HHmm'
$out = "C:\Omika\Backups\omika_diagnostics_${stamp}_before_upgrade.backup"
& 'C:\Program Files\PostgreSQL\18\bin\pg_dump.exe' `
-h 127.0.0.1 -p 5432 -U postgres -d omika_diagnostics -F c -v -f $out
Remove-Item Env:PGPASSWORD
Get-Item $out | Select-Object Name,Length # taille > 0 sinon NO-GO
```
### 5.3 Backup des binaires (AVANT remplacement)
```powershell
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$root = 'C:\Omika\Backups'
Copy-Item 'C:\Omika\Apps\OmikaApi' -Destination "$root\OmikaApi_${stamp}" -Recurse -Force
Copy-Item 'C:\Omika\Apps\OmikaWorker' -Destination "$root\OmikaWorker_${stamp}" -Recurse -Force
Copy-Item 'C:\Omika\Apps\DiagnosticsWorker' -Destination "$root\DiagnosticsWorker_${stamp}" -Recurse -Force
Copy-Item 'C:\Omika\Apps\DiagnosticsWeb' -Destination "$root\DiagnosticsWeb_${stamp}" -Recurse -Force
# DiagnosticsApi : nouveau — backup seulement s'il existe déjà
if (Test-Path 'C:\Omika\Apps\DiagnosticsApi') { Copy-Item 'C:\Omika\Apps\DiagnosticsApi' -Destination "$root\DiagnosticsApi_${stamp}" -Recurse -Force }
```
### 5.4 Arrêt des services (ordre : figer la lecture, puis les écrivains)
```powershell
Stop-Service OmikaDiagnosticsWeb -Force
Stop-Service OmikaApi -Force
Stop-Service OmikaWorker -Force
Stop-Service OmikaDiagnosticsWorker -Force
# OmikaDiagnosticsApi : à arrêter aussi s'il existait déjà
Stop-Service OmikaDiagnosticsApi -Force -ErrorAction SilentlyContinue
Get-Service OmikaApi,OmikaWorker,OmikaDiagnosticsWorker,OmikaDiagnosticsWeb,OmikaDiagnosticsApi |
Select-Object Name,Status
```
### 5.5 Migrations (UNIQUEMENT les manquantes, dans l'ordre, TOUJOURS avant les binaires)
```powershell
$env:PGPASSWORD = '<SECRET_POSTGRES_ADMIN>'
$psql = 'C:\Program Files\PostgreSQL\18\bin\psql.exe'
$db = 'C:\Omika\Deploy\DiagnosticsDatabase' # dossier contenant les .sql livrés
$migrations = @(
'001_diagnostics_schema.sql','002_actor_key.sql','003_diagnostics_roles.sql',
'004_diagnostics_retention.sql','005_diagnostics_client_context.sql',
'006_diagnostics_device_license.sql','007_diagnostics_usage.sql',
'008_diagnostics_clients.sql','009_diagnostics_clients_grants.sql',
'010_diagnostics_web_users.sql','011_diagnostics_reference.sql',
'012_diagnostics_tasks.sql','013_diagnostics_tasks_delete.sql',
'014_diagnostics_telemetry_retention.sql','015_diagnostics_retention_untreated.sql',
'016_diagnostics_event_delete.sql'
)
foreach ($m in $migrations) {
$f = Join-Path $db $m
if (-not (Test-Path $f)) { Write-Host "ABSENT : $m" ; continue }
Write-Host "Application : $m ..."
& $psql -h 127.0.0.1 -p 5432 -U postgres -d omika_diagnostics -v ON_ERROR_STOP=1 -f $f
if ($LASTEXITCODE -ne 0) { throw "ÉCHEC sur $m (exit $LASTEXITCODE). STOP." }
}
Remove-Item Env:PGPASSWORD
```
> **Règle d'ordre** : migration **avant** binaires (colonnes/tables additives ;
> anciens binaires restent compatibles, l'inverse échouerait).
> Idempotence ⇒ réappliquer une migration déjà en place est un no-op (sauf 003 :
> ne pas rejouer l'ownership inutilement si les rôles existent déjà).
### 5.6 Mots de passe des rôles LOGIN (manuel, jamais en script/log)
Après `003`/`009` :
```
\password omika_diagnostics_worker
\password omika_diagnostics_web
\password omika_diagnostics_api # nouveau rôle (009) — pour DiagnosticsApi
```
### 5.7 Copie des binaires (préserve la config serveur existante)
```powershell
robocopy 'C:\Omika\Deploy\OmikaApi-next' 'C:\Omika\Apps\OmikaApi' /E /XF appsettings.json appsettings.Production.json web.config
robocopy 'C:\Omika\Deploy\OmikaWorker-next' 'C:\Omika\Apps\OmikaWorker' /E /XF appsettings.json appsettings.Production.json web.config
robocopy 'C:\Omika\Deploy\DiagnosticsWorker-next' 'C:\Omika\Apps\DiagnosticsWorker' /E /XF appsettings.json appsettings.Production.json web.config
robocopy 'C:\Omika\Deploy\DiagnosticsWeb-next' 'C:\Omika\Apps\DiagnosticsWeb' /E /XF appsettings.json appsettings.Production.json web.config
robocopy 'C:\Omika\Deploy\DiagnosticsApi-next' 'C:\Omika\Apps\DiagnosticsApi' /E /XF appsettings.json appsettings.Production.json web.config
robocopy 'C:\Omika\Deploy\LanguesApi-next' 'C:\Omika\Apps\LanguesApi' /E /XF appsettings.json appsettings.Production.json web.config
```
> Ne **jamais** supprimer : `C:\Omika\Data\Api\Diagnostics\Jsonl\**`,
> `C:\Omika\Data\DiagnosticsWorker\Checkpoint\import-checkpoint.json`,
> `C:\Omika\Config\**`, certificats TLS, variables d'environnement MACHINE.
### 5.8 Création du service `OmikaDiagnosticsApi` (NOUVEAU)
Le service n'existe pas encore : le créer (compte dédié, jamais LocalSystem) :
```powershell
$cred = Get-Credential -UserName '.\svc_omika_diagapi' -Message 'Compte service OmikaDiagnosticsApi'
New-Service -Name 'OmikaDiagnosticsApi' -DisplayName 'Omika Diagnostics Api' `
-BinaryPathName '"C:\Omika\Apps\DiagnosticsApi\Omika.Diagnostics.Api.exe"' `
-Credential $cred -StartupType Manual
# ACL dossier spool central + logs
icacls 'C:\ProgramData\Omika\Diagnostics\Central' /grant:r 'svc_omika_diagapi:(OI)(CI)M'
icacls 'C:\Omika\Logs\DiagnosticsApi' /grant:r 'svc_omika_diagapi:(OI)(CI)M'
```
> Le compte dédié doit avoir le droit « Ouvrir une session en tant que service ».
> Ce service se crée par le script dédié
> `infra\deploy\Install-OmikaDiagnosticsApi.ps1` (compte, ACL du spool central,
> droit de connexion, `-WhatIf`, `-Desinstaller`). L'orchestrateur
> `Install-OmikaServices.ps1` l'inclut par défaut — utilisez `-SansDiagnosticsApi`
> pour l'exclure sur un serveur **mono-serveur**, où il est inutile.
---
## 6. Configuration serveur par service (secrets injectés SUR LE SERVEUR)
Règle : en variable d'environnement, `:` devient `__`
(`Diagnostics:Central:Directory` ⇔ `Diagnostics__Central__Directory`).
`ConnectionStrings:DiagnosticsDatabase` **prime** sur `Diagnostics:ConnectionString`.
Toute modif de variable d'un service exige un **redémarrage** du service.
### Omika.Api (`OmikaApi`) — émission + ingestion + forwarding
| Variable | Rôle |
|---|---|
| `ASPNETCORE_ENVIRONMENT` | `Production` (obligatoire) |
| `MobileAuth__SigningKey` | fail-fast en Prod (signature tokens mobile) |
| `DiagnosticsIdentity__HmacKey` | clé HMAC ActorKey/DeviceKey (≥256 bits Base64) |
| `DiagnosticDeployment__ClientCode` | CodeClient (sinon fallback HFSQL) |
| `DiagnosticDeployment__ServerInstanceId` | id d'instance (sinon MachineName) |
| `Observability__Remote__Directory` | **dossier JSONL partagé** (identique au Worker) |
| `Observability__ServerDiagnostics__Enabled` | **`true` pour activer l'émission serveur 4xx/5xx/exceptions** (voir §7) |
| `DiagnosticsForwarding__Enabled` | kill switch forwarder central (`true`/`false`) |
| `DiagnosticsForwarding__Endpoint` | URL centrale **HTTPS** (si forwarding) |
| `DiagnosticsForwarding__ApiKey` | clé d'accès centrale (**secret**) |
| `LicenseConnection__*` / `SyncConnection__*` | secrets HFSQL existants (non touchés) |
### Omika.Worker (`OmikaWorker`)
| Variable | Rôle |
|---|---|
| `ASPNETCORE_ENVIRONMENT` | `Production` (obligatoire) |
| `DiagnosticDeployment__ClientCode` | CodeClient (sinon fallback HFSQL) |
| `DiagnosticDeployment__ServerInstanceId` | id d'instance (sinon MachineName) |
| `Observability__Remote__Directory` | **dossier JSONL** (voir note topologie ci-dessous) |
| `DiagnosticsForwarding__Enabled` | forwarder du Worker (`true` si serveur séparé de l'API) |
| `DiagnosticsForwarding__Endpoint` | URL centrale **HTTPS** (si forwarding Worker) |
| `DiagnosticsForwarding__ApiKey` | clé d'accès centrale (**secret**) |
| secrets HFSQL existants | non touchés |
> **Topologie serveur client :**
> - **Même serveur que l'API** : `Observability__Remote__Directory` = dossier **partagé**
> avec l'API, lu par `OmikaDiagnosticsWorker`. Laisser `DiagnosticsForwarding__Enabled=false`
> côté Worker (l'API forwarde déjà ce dossier) → un **seul** forwarder sur le dossier partagé.
> - **Serveur séparé de l'API** : `Observability__Remote__Directory` = dossier **local au
> Worker** (jamais un partage réseau) ; activer `DiagnosticsForwarding__Enabled=true` +
> `Endpoint` + `ApiKey`. Le Worker forwarde alors son **propre** spool vers le central
> (checkpoint `forwarder-checkpoint-worker.json`, distinct de celui de l'API).
### Omika.Diagnostics.Worker (`OmikaDiagnosticsWorker`)
| Variable | Défaut | Rôle |
|---|---|---|
| `ConnectionStrings__DiagnosticsDatabase` | — | Npgsql `Username=omika_diagnostics_worker` |
| `Observability__Remote__Directory` | `""` | dossier JSONL (**identique** à Omika.Api) |
| `Diagnostics__Import__CheckpointDirectory` | `""` | dossier checkpoint |
| `Diagnostics__Import__CheckpointFileName` | `import-checkpoint.json` | nom checkpoint |
| `Diagnostics__RetentionDays` | `60` | rétention événements |
| `Diagnostics__TelemetryRetentionDays` | `180` | rétention télémétrie |
| `Diagnostics__RetentionIntervalHours` | `24` | fréquence purge |
| `Diagnostics__RetentionBatchSize` | `1000` | lot de purge |
### Omika.Diagnostics.Web (`OmikaDiagnosticsWeb`)
| Variable | Rôle |
|---|---|
| `ASPNETCORE_ENVIRONMENT` | `Production` (default-deny) |
| `ConnectionStrings__DiagnosticsDatabase` | Npgsql `Username=omika_diagnostics_web` |
| `DiagnosticsAuth__Operators__*` | bootstrap temporaire (hash uniquement) puis retrait |
### Omika.Diagnostics.Api (`OmikaDiagnosticsApi`) — NOUVEAU
| Variable | Défaut | Rôle |
|---|---|---|
| `ASPNETCORE_ENVIRONMENT` | — | `Production` |
| `ConnectionStrings__DiagnosticsDatabase` | — | Npgsql `Username=omika_diagnostics_api` |
| `Diagnostics__Central__Directory` | `logs/diagnostics-central` | **spool central** (chemin explicite, ex. `C:\ProgramData\Omika\Diagnostics\Central`) |
| `Diagnostics__Central__MaxRequestBodyBytes` | `524288` | corps borné |
| `Diagnostics__Central__MaxEventsPerBatch` | `100` | événements max/lot |
---
## 7. Activer l'envoi des diagnostics (API Omika + application)
Trois flux distincts, tous **opt-in** :
### 7.1 Application (mobile/desktop) → Omika.Api
L'application écrit dans une outbox SQLite locale (`omika-diagnostics.db`, toujours
active). L'**upload** vers `POST /api/diagnostics` est **désactivé avant inscription**
(`DiagnosticUploadOptions.Enabled=false`) puis **activé automatiquement dès que
l'utilisateur s'inscrit** (QR ou code manuel), sur toutes les plateformes
(Android, iOS, Desktop).
- Activation automatique : à l'inscription réussie, l'application écrit elle-même
le fichier `diagnostics-upload.json` dans le dossier de données de l'app
(`%LocalAppData%\Omika\`) :
```json
{ "Enabled": true }
```
et active l'upload en mémoire pour la session courante (reprise sous ~60 s).
Champs optionnels : `Interval`, `Timeout`, `BatchSize`, `MaxBatchBytes`, `EndpointPath`.
- Appareils déjà inscrits (avant cette version) : le fichier est migré au démarrage
si la session est inscrite et que le fichier n'existe pas encore.
- Kill-switch support : poser manuellement `{ "Enabled": false }` dans ce fichier
désactive l'upload ; un fichier existant n'est jamais écrasé par la restauration
de session.
- L'app envoie vers l'`OmikaApi` **déjà configuré** (BaseUrl + token de session) :
aucun endpoint/secret supplémentaire.
- Token absent → aucun envoi, données conservées (reprise au prochain contexte).
> ⚠️ **Point d'attention** : l'upload ne s'exécute que lorsque l'app est active
> (aucun job OS en arrière-plan) et exige un token de session valide.
### 7.2 Omika.Api → chaîne Diagnostics (diagnostics serveur)
Le `ServerDiagnosticMiddleware` capture 4xx/5xx/exceptions et les écrit dans le
JSONL remote (relu par `OmikaDiagnosticsWorker`). **Désactivé par défaut.**
Activation :
```text
Observability__ServerDiagnostics__Enabled = true
Observability__Remote__Directory = <chemin JSONL partagé avec le Worker>
DiagnosticsIdentity__HmacKey = <clé HMAC>
DiagnosticDeployment__ClientCode = <CodeClient> (sinon HFSQL)
DiagnosticDeployment__ServerInstanceId = <id instance>
```
### 7.3 Serveur client → API centrale (multi-serveur uniquement)
Uniquement si vous recevez des diagnostics de **serveurs clients distants** :
1. Dans `OmikaDiagnosticsWeb` → **Clients Diagnostics** → **Nouveau client**
(CodeClient exact), **créer** puis **copier immédiatement** la clé.
2. Sur le serveur client, dans `OmikaApi` :
```text
DiagnosticsForwarding__Enabled = true
DiagnosticsForwarding__Endpoint = https://<central>/api/v1/diagnostics/batches
DiagnosticsForwarding__ApiKey = <clé copiée>
```
3. **Si `OmikaWorker` est sur un AUTRE serveur que `OmikaApi`**, activer AUSSI le
forwarding sur le Worker (même clé ou clé du client Worker, checkpoints distincts) :
```text
# sur le serveur du Worker
DiagnosticsForwarding__Enabled = true
DiagnosticsForwarding__Endpoint = https://<central>/api/v1/diagnostics/batches
DiagnosticsForwarding__ApiKey = <clé copiée>
Observability__Remote__Directory= <dossier LOCAL au Worker>
```
Chaque service forwarde alors son **propre** dossier JSONL local. Sur un même serveur
(dossier partagé), n'activer qu'**un seul** forwarder (l'API) pour éviter le double envoi.
4. Redémarrer `OmikaApi` (et `OmikaWorker` le cas échéant), puis vérifier
`last_seen_at_utc` dans Clients Diagnostics.
> Sur un SRV-OMIKA **mono-serveur** (Omika.Api écrit le JSONL localement), l'API
> centrale `OmikaDiagnosticsApi` et le forwarding ne sont **pas nécessaires**.
---
## 8. Redémarrage + smoke tests
### 8.1 Ordre de redémarrage (consommateurs avant producteurs)
```powershell
Start-Service OmikaDiagnosticsWorker # 1. importeur (consommateur JSONL)
Start-Service OmikaDiagnosticsWeb # 2. UI
Start-Service OmikaDiagnosticsApi # 3. API centrale (si multi-serveur)
Start-Service OmikaWorker # 4. sync métier
Start-Service OmikaApi # 5. API (producteur, dernier)
Get-Service OmikaApi,OmikaWorker,OmikaDiagnosticsWorker,OmikaDiagnosticsWeb,OmikaDiagnosticsApi |
Select-Object Name,Status
```
### 8.2 Smoke tests
```powershell
Invoke-WebRequest 'http://127.0.0.1:<PORT_API>/health' -UseBasicParsing # 200 Healthy
Invoke-WebRequest 'http://127.0.0.1:<PORT_WEB>/Login' -UseBasicParsing # 200
Invoke-WebRequest 'http://127.0.0.1:<PORT_DIAG_API>/' -UseBasicParsing # 200 {status:ok}
```
SQL post-redémarrage :
```sql
SELECT count(*) FROM diagnostic_event;
SELECT max(ingested_at_utc) FROM diagnostic_event;
SELECT reference_type, count(*) FROM diagnostics_reference GROUP BY reference_type;
-- attendu : SCREEN_CODE = 29, FEATURE_CODE = 3
SELECT count(*) FROM diagnostics_tasks;
```
Dans `OmikaDiagnosticsWeb`, vérifier : Dashboard, Diagnostics, Erreurs (bouton
« Supprimer le groupe »), Traces, Synchronisation, Telemetry, Versions,
Référentiel, **Taches** (bouton « Supprimer »), et `/Clients` + `/Users` (Admin).
---
## 9. Écarts constatés (scripts/documents à mettre à jour)
1. **`infra\deploy\Install-OmikaServerFiles.ps1`** : ne copie **que 4** publications
(OmikaApi, OmikaWorker, DiagnosticsWorker, DiagnosticsWeb) → **ne déploie pas
`DiagnosticsApi`**.
2. ~~**`infra\deploy\Install-OmikaServices.ps1`** : ne crée **que 4** services → ne
crée **pas** `OmikaDiagnosticsApi` ni ses ACL (spool central/logs).~~
**RÉSOLU** : un script par déploiement dans `infra\deploy`
(`Install-OmikaDiagnosticsApi.ps1` incluse), et l'orchestrateur les enchaîne
dans le bon ordre (le propriétaire d'un dossier est installé **avant** ses
lecteurs, pour que les ACL inter-services puissent être posées).
3. **`infra\deploy\Install-DatabaseSchema.ps1`** : n'applique **que `001`+`002`**.
**`Harden-DatabaseRoles.ps1`** : n'applique **que `003`**. Les migrations
`004 → 016` restent manuelles (cette marche à suivre les couvre).
4. **`Omika.slnx`** ne référence pas les projets Diagnostics (build par `.csproj`).
5. **App** : l'activation de l'upload est désormais automatique dès l'inscription
(fichier `diagnostics-upload.json` écrit par l'app elle-même ; kill-switch
support via `{ "Enabled": false }`). Corrigé au passage : le fichier était nommé
`development-upload.json` et documenté à tort comme « compilé hors Release ».
6. **Docs obsolètes** : `MISE_A_JOUR_DIAGNOSTICS_SRV-OMIKA.md` (→011) et
`T15-RUNBOOK-DEPLOIEMENT-PRODUCTION.md` (→005).
Recommandation : mettre à jour les scripts 1–3 pour couvrir le 5ᵉ service et la
liste complète `001→016` (et générer le `SHA256SUMS.txt` incluant `DiagnosticsApi`).
---
## 10. Rollback
- **Binaires** : restaurer les dossiers `C:\Omika\Backups\*_<stamp>` (arrêt →
copie → démarrage dans l'ordre §8.1).
- **Base** : **pas de rollback SQL automatique** (migrations additives/idempotentes).
`pg_restore` (backup §5.2) uniquement sur décision humaine explicite.
- **Kill switch** : `DiagnosticsForwarding__Enabled=false` (+ redémarrage OmikaApi)
coupe tout envoi central sans toucher au métier ni au spool.
+701
View File
@@ -0,0 +1,701 @@
# MOBILE FUNCTIONAL STATUS — État fonctionnel actuel de l'application mobile
> **Bilan fonctionnel global** de l'application mobile Omika, tel qu'il est
> réellement implémenté et validé aujourd'hui. Ce document décrit **ce que
> l'utilisateur peut réellement faire**, ce qui est en lecture seule, ce qui sait
> écrire offline, ce qui manque, ce qui est bloqué backend et ce qui reste à
> développer.
>
> Ce document est **orienté fonctionnel** : il ne remplace pas
> `docs/LOT_MOBILE_0.md` (architecture technique, décisions, principes
> offline-first, SQLite, Sync, identités, Design System). Il ne duplique pas son
> contenu.
>
> Aucun code de production n'est modifié par ce document.
---
## 1. Vue d'ensemble — ce que l'utilisateur peut faire aujourd'hui
L'application mobile Omika est aujourd'hui une application **de consultation
offline** du dernier snapshot métier synchronisé. L'utilisateur peut :
- **Naviguer** entre les écrans (Agenda, Interventions, Dossiers, Équipe,
Clients, Sites, Paramètres) via une barre latérale (desktop/tablette) ou une
barre basse + menu « Plus » (smartphone).
- **Consulter** l'agenda du jour et le détail d'un rendez-vous.
- **Consulter** la liste des interventions, les rechercher localement, filtrer
les archivées, et ouvrir la fiche d'une intervention.
- **Consulter** les dossiers, clients, sites et collaborateurs (liste + détail).
- **Consulter** les contacts liés à une intervention et le détail d'un contact.
- **Consulter** les documents / photos d'une intervention, les **télécharger à
la demande**, les **ouvrir offline** s'ils sont en cache, et **en ajouter**
(photo ou document) **offline-first**.
- **Créer, modifier et archiver une intervention** **offline-first** (formulaire
partagé Create/Edit, bouton Nouvelle Intervention, action Modifier, action
Archiver avec confirmation, aucun DELETE).
- **Synchroniser** manuellement via « Synchroniser maintenant ».
- **Personnaliser** le thème (clair / sombre / système) et la couleur principale.
L'utilisateur **ne peut pas** aujourd'hui : créer/modifier un rendez-vous, un
dossier, un client, un site ou un collaborateur ; saisir des temps ; rédiger des
notes ; créer un compte rendu ou un rapport ; envoyer un email ; utiliser le
GPS ; réinitialiser l'application ; purger le cache depuis l'UI.
> **Note importante (vérification Sync)** : le **backend + Sync supportent déjà
> réellement** la création, la modification et l'archivage d'une **Intervention**
> (handler `InterventionSyncOperationHandler`, contrat `InterventionSyncPayload`,
> pipeline SUCCESS/REJECTED). L'**UI + mutation locale** Intervention est
> **réalisée** (formulaire Create/Edit, bouton Nouvelle Intervention, action
> Modifier, action Archiver avec confirmation, aucun DELETE, protection double
> Save, navigation contextuelle, états Pending/Rejected affichés). Les autres
> entités (RendezVous, Dossier, Client, Site, Collaborateur) ne disposent
> **d'aucun** handler Sync ni payload : elles restent bloquées backend.
---
## 2. Matrice fonctionnelle synthétique
Légende des états :
| État | Signification |
|------|---------------|
| **REAL** | Fonctionnalité réellement implémentée et utilisable |
| **PARTIAL** | Partiellement implémentée (une partie seulement du périmètre maquette) |
| **READ-ONLY** | Consultation seule, aucune écriture |
| **BLOCKED** | Bloqué par un prérequis backend / snapshot / sync manquant |
| **NOT IMPLEMENTED** | Non implémenté (aucun parcours utilisateur) |
| **PLACEHOLDER** | Écran d'attente « Fonctionnalité à venir » |
| Domaine | Fonctionnalité | État | Offline | Écriture | Réseau requis | Backend requis | Commentaire |
|---------|----------------|------|---------|----------|---------------|----------------|-------------|
| Shell / navigation | Navigation entre écrans | **REAL** | Oui | Non | Non | Non | Barre latérale + barre basse + menu « Plus » |
| Shell / navigation | Thème clair / sombre / système | **REAL** | Oui | Non | Non | Non | Persisté localement |
| Shell / navigation | Couleur principale | **REAL** | Oui | Non | Non | Non | Presets + personnalisée, persistée |
| Agenda | Liste des rendez-vous du jour | **REAL** | Oui | Non | Non | Non | Lecture SQLite |
| Agenda | Navigation jour précédent / suivant | **REAL** | Oui | Non | Non | Non | — |
| RendezVous | Détail d'un rendez-vous | **REAL** | Oui | Non | Non | Non | Lecture seule |
| RendezVous | Saisie des temps (accès) | **REAL** (UI CREATE) | Oui | **Oui** | Non (offline-first) | Oui | Accès contextuel RendezVous → liste filtrée → Nouvelle saisie (RendezVousId prérempli) — voir section 7 |
| RendezVous | Annulation / modification | **NOT IMPLEMENTED** | — | — | — | Oui | Aucun handler Sync RendezVous |
| Interventions | Liste des interventions | **REAL** | Oui | Non | Non | Non | Lecture SQLite |
| Interventions | Recherche locale | **REAL** | Oui | Non | Non | Non | Reference, Titre, ClientNom, SiteNom, DossierReference |
| Interventions | Filtre Actif / Archivé | **REAL** | Oui | Non | Non | Non | Basé sur `EstArchive` |
| Interventions | Fiche intervention | **REAL** | Oui | Non | Non | Non | Client/Site/Dossier/RendezVous/Documents |
| Interventions | Rendez-vous liés | **REAL** | Oui | Non | Non | Non | Liste + tri chronologique |
| Interventions | Création / modification Intervention | **REAL** (UI + mutation locale) | Oui | **Oui** | Non (offline-first) | Oui | **Backend + Sync SUPPORTED** ; UI Create/Edit + mutation locale + Outbox implémentées (CREATE/UPDATE/ARCHIVE), aucun DELETE |
| Interventions | Archiver une intervention | **REAL** (UI + mutation locale) | Oui | **Oui** | Non (offline-first) | Oui | Action Archiver + confirmation, `ArchiveAsync` via `IInterventionLocalService`, aucun DELETE |
| Interventions | État Sync (Pending / Rejected) | **REAL** | Oui | Non | Non | Non | Affiché sur la fiche à partir des vraies données Outbox (`IsPendingSync`, `HasSyncError`, `SyncErrorMessage`) |
| Documents / Photos | Liste des documents d'une intervention | **REAL** | Oui | Non | Non | Non | Métadonnées SQLite |
| Documents / Photos | Téléchargement à la demande | **REAL** | Non | Non | **Oui** | Oui | HTTP `GET /api/documents/{guid}` |
| Documents / Photos | Ouverture offline | **REAL** | Oui | Non | Non | Non | Si présent en cache |
| Documents / Photos | Ajout photo / document | **REAL** | Oui | **Oui** | Non (offline-first) | Oui | Outbox + SyncEngine |
| Contacts | Contacts liés à une intervention | **REAL** | Oui | Non | Non | Non | Relation `ContactIds` explicite |
| Contacts | Détail d'un contact | **REAL** | Oui | Non | Non | Non | Téléphone / email plateforme |
| Dossiers | Liste des dossiers | **REAL** | Oui | Non | Non | Non | Lecture SQLite |
| Dossiers | Détail d'un dossier | **REAL** | Oui | Non | Non | Non | Interventions liées |
| Dossiers | Création / modification Dossier | **NOT IMPLEMENTED** | — | — | — | Oui | Aucun handler Sync Dossier |
| Clients | Liste des clients | **REAL** | Oui | Non | Non | Non | Lecture SQLite |
| Clients | Détail d'un client | **REAL** | Oui | Non | Non | Non | Sites, contacts, dossiers, interventions |
| Clients | Création / modification Client | **NOT IMPLEMENTED** | — | — | — | Oui | Aucun handler Sync Client |
| Sites / Lieux | Liste des sites | **REAL** | Oui | Non | Non | Non | Lecture SQLite |
| Sites / Lieux | Détail d'un site | **REAL** | Oui | Non | Non | Non | Client, contacts, interventions |
| Sites / Lieux | GPS / itinéraire | **NOT IMPLEMENTED** | — | — | — | Non | — |
| Équipe / Collaborateurs | Liste des collaborateurs | **REAL** | Oui | Non | Non | Non | Lecture SQLite |
| Équipe / Collaborateurs | Détail d'un collaborateur | **REAL** | Oui | Non | Non | Non | Rendez-vous + interventions liés |
| Paramètres | Thème / couleur / version | **REAL** | Oui | Non | Non | Non | — |
| Paramètres | Synchroniser maintenant | **REAL** | Non | Non | **Oui** | Oui | Via SyncEngine |
| Paramètres | Purge cache | **NOT IMPLEMENTED** (UI) | — | — | — | — | `PurgeAsync` existe ; problème technique mobile uniquement |
| Paramètres | Reset application | **NOT IMPLEMENTED** (UI) | — | — | — | — | Primitives locales existent ; dépendance protection Outbox |
| Paramètres | Licence / activation | **REAL** | Oui | Non | Non | Oui | Activation par code client + n° licence ; session + token sécurisé ; identité affichée |
| Synchronisation | Synchronisation manuelle | **REAL** | Non | Non | **Oui** | Oui | SyncEngine + Outbox |
| Synchronisation | État de synchronisation | **REAL** | Oui | Non | Non | Non | Idle / Syncing / Synced / Offline / Error |
| Notes Intervention | Lecture / écriture notes | **NOT IMPLEMENTED** | — | — | — | Oui | CAS E — voir section 6 |
| SuiviTemps | Consultation des temps | **REAL** (UI LIST) | Oui | Non | Non | Non | Liste offline via `ISuiviTempsLocalService` (PHASE 3) — voir section 7 |
| SuiviTemps | Saisie des temps | **REAL** (UI CREATE) | Oui | **Oui** | Non (offline-first) | Oui | **UI CREATE READY** — formulaire offline-first via `ISuiviTempsLocalService.CreateAsync` ; TypeTemps depuis `IParametreMetierLocalService.GetTypeTempsAsync()` (Key = PA_CLE) ; aucun UPDATE/ARCHIVE/DELETE — voir section 7 |
| Rapports / CompteRendu | Création / consultation | **NOT IMPLEMENTED** | — | — | — | Oui | Backend only — voir section 8 |
---
## 3. Écritures offline réellement opérationnelles
La seule écriture offline réellement opérationnelle aujourd'hui est
**l'ajout de documents / photos** à une intervention.
### 3.1 Ajout d'une photo ou d'un document (offline-first)
Ce que fait l'utilisateur :
1. Depuis la fiche intervention, il ouvre l'écran **Documents / Photos**.
2. Il choisit **Ajouter une photo** ou **Ajouter un document** (sélecteur de
fichier plateforme).
3. Le fichier est immédiatement visible dans la liste, marqué comme en attente
de synchronisation.
Ce qui se passe techniquement (chaîne complète vérifiée) :
| Étape | Détail |
|-------|--------|
| **Mutation SQLite** | Copie du fichier dans l'espace contrôlé de l'application + création de la métadonnée locale `LocalDocumentMetadata` (payload `DocumentSummaryDto`) |
| **Outbox** | Création d'une opération `OutboxOperation` (`EntityType = Document`, `OperationType = Create`, état `Pending`) |
| **OperationId** | `Guid.NewGuid()` — distinct du GUID métier |
| **GUID métier** | `DocumentGuid` (identité publique du document) |
| **SyncEngine** | À la synchronisation, l'opération Document est envoyée via `POST /api/documents` (multipart) |
| **SUCCESS** | L'opération passe à l'état `Succeeded` ; le document est marqué synchronisé |
| **REJECTED** | L'opération passe à l'état `Rejected` ; l'erreur est affichée dans l'UI |
Le `DocumentGuid` est **distinct** de l'`OperationId` : le GUID métier identifie
le document, l'OperationId identifie la transaction de synchronisation.
> **Important** : la capacité générique de l'Outbox n'est pas une fonctionnalité
> utilisateur en soi. Seule l'écriture **Documents / Photos** est réellement
> branchée de bout en bout (UI → SQLite → Outbox → SyncEngine → backend).
---
## 4. Domaines actuellement en lecture seule
Les domaines suivants sont **strictement en lecture seule** : aucune écriture,
aucune Outbox, aucun CREATE / UPDATE / DELETE / ARCHIVE depuis le mobile.
| Domaine | État réel | Détail |
|---------|-----------|--------|
| **RendezVous** | READ-ONLY | Consultation du détail uniquement |
| **Intervention** | WRITE (mutation locale) | Liste, recherche, filtre, fiche, relations. **Backend + Sync SUPPORTED** pour CREATE/UPDATE/ARCHIVE ; mutation locale + Outbox implémentées (service `InterventionLocalService`) |
| **Dossier** | READ-ONLY | Liste + détail |
| **Client** | READ-ONLY | Liste + détail |
| **Site / Lieu** | READ-ONLY | Liste + détail |
| **Collaborateur** | READ-ONLY | Liste + détail |
| **Contact** | READ-ONLY | Liste + détail (téléphone / email plateforme) |
Aucune de ces entités ne dispose d'une écriture mobile **branchée à l'UI**. Les
données proviennent exclusivement du snapshot synchronisé.
> **Capacité Sync réelle** : seul **Intervention** dispose d'un handler Sync
> (`InterventionSyncOperationHandler`) et d'un contrat public
> (`InterventionSyncPayload`). Les autres entités (RendezVous, Dossier, Client,
> Site, Collaborateur) n'ont **aucun** handler ni payload : toute opération Sync
> sur ces types est rejetée (`NO_HANDLER` → REJECTED).
---
## 5. Synchronisation, Offline, SQLite, Outbox, cache Documents
### 5.1 Synchronisation
- **Déclenchement** : manuel via « Synchroniser maintenant » (Paramètres ou
Shell). Aucune synchronisation automatique périodique.
- **Moteur** : `SyncEngine` (unique système de mise à jour).
- **États UI** : `Idle`, `Syncing`, `Synced`, `Offline`, `Error`.
- **Hors ligne** : aucun appel réseau, état non intrusif (« Hors ligne »).
- **Erreurs** : nombre d'opérations rejetées / anomalies affiché.
### 5.2 Offline / SQLite
- Toutes les lectures (liste, fiche, contacts, documents) se font depuis SQLite
(dernier snapshot).
- Aucun appel HTTP obligatoire à l'ouverture d'un écran.
- Le snapshot est reconstruit atomiquement depuis le backend.
### 5.3 Outbox
- L'Outbox est opérationnelle pour **Documents / Photos** uniquement.
- Les opérations sont envoyées à la synchronisation, avec états
`Pending` / `Sending` / `Accepted` / `WaitingResult` / `Succeeded` / `Rejected`.
- Le mécanisme d'identité `RequestedEntityGuid` / `CanonicalEntityGuid` est
opérationnel : après un CREATE réussi, le backend retourne le GUID canonique
(GUID physique Omika ou GUID synthétique réversible Batik), réconcilié côté
mobile pour les opérations suivantes (UPDATE/ARCHIVE).
### 5.4 Cache Documents
- Les documents téléchargés sont stockés dans un cache fichier local
(`DocumentCacheService`).
- L'ouverture offline est possible si le fichier est présent en cache.
- **Purge du cache** : la capacité technique `PurgeAsync` existe dans
`DocumentCacheService`, mais **n'est pas branchée à une interface utilisateur**.
Aucun bouton « Purger le cache » n'est accessible à l'utilisateur.
---
## 6. Notes Intervention — état démontré (CAS E)
État **déjà démontré**, aucune décision n'est réouverte :
- **Aucune Note Intervention exploitable** dans le snapshot / local.
- **Aucune table SQLite Note**.
- **Aucune UI** Notes.
- `CompteRendu` existe côté backend mais **n'est pas peuplé** dans le snapshot.
- `MessageInterne` / `IN_NoteInterne` restent **backend** (non exposés au mobile).
- `DocumentNote` est un **concept différent** (document, pas note d'intervention).
**État fonctionnel** : `NOT IMPLEMENTED` (bloqué backend — données absentes du
snapshot).
**Classification** : **C — BACKEND + MOBILE** (snapshot / SQLite / Sync manquant)
+ **D** uniquement pour la décision de représentation métier exacte (Note vs
DocumentNote). `DocumentNote` reste distinct.
---
## 7. Suivi des temps — état démontré (CAS C)
État **déjà démontré**, aucune décision n'est réouverte :
- L'entité métier `SuiviTemps` est **réelle** côté backend.
- Les relations Intervention / RendezVous / Collaborateur existent.
- **Présent dans le snapshot** mobile (PHASE 1B) : le `MobileSnapshotResponse`
expose désormais `SuivisTemps` (liste de `SuiviTempsDto`, lecture seule,
réutilisant le DTO métier public — aucune colonne HFSQL exposée). Le GUID
exposé est le **GUID physique existant** (Batik et Omika), aucun GUID
synthétique n'est créé.
- **Sync CREATE** (PHASE 1C) : le pipeline Sync serveur moderne supporte la
création d'une saisie de temps via `SuiviTempsSyncOperationHandler` →
`ISuiviTempsService` → repository adaptatif. EntityType `SuiviTemps`,
payload `SuiviTempsSyncPayload` (CollaborateurId, InterventionId,
RendezVousId, Date, DureeMinutes, TypeTempsKey, Description). Le champ
`Valide` n'est pas positionnable depuis le mobile (état de validation métier
géré côté backend). UPDATE / ARCHIVE / DELETE → `PERMANENT_FAILURE`.
- **SQLite local** (PHASE 2) : la table `SuiviTemps` est présente dans le
schéma SQLite local, importée atomiquement par le `SnapshotApplier` (avec les
références typées Collaborateur / Intervention / RendezVous pour la
réconciliation d'identité A → B). Le service local `SuiviTempsLocalService`
permet la lecture (liste, par GUID, par intervention) et la création
offline-first (écriture locale + Outbox CREATE, `Valide` toujours false côté
mobile).
- **UI LIST** (PHASE 3) : la vue `SuiviTempsView` + `SuiviTempsViewModel`
permettent la **consultation offline** des saisies de temps via
`ISuiviTempsLocalService.GetAllAsync` (et `GetByInterventionAsync` /
`GetByRendezVousAsync` pour les filtres par intervention / rendez-vous). La
liste affiche les données métier utiles (date, durée, type de temps,
description, collaborateur, intervention, rendez-vous, validation, état Sync
Pending/Rejected/ErrorMessage). Aucun UPDATE / ARCHIVE / DELETE. Accès global
via la navigation + accès contextuel depuis une Intervention (liste filtrée)
et depuis un RendezVous (liste filtrée).
- **Référentiel TypeTemps local** (micro-lot) : **READY** — le référentiel
`ParametreMetier` est désormais disponible **offline** au mobile. Le pipeline
est : HFSQL `ParametreMetier` → repository adaptatif (`AdaptiveParametreMetierRepository`)
→ `MobileBootstrapService` (chargé au bootstrap) → SQLite local → service
local `IParametreMetierLocalService.GetTypeTempsAsync()`. Ce service retourne
une liste ordonnée d'options `ReferenceValueDto` (Key = clé métier `PA_CLE`,
Label = libellé métier) **sans réseau, sans API, sans HFSQL**, uniquement à
partir des données snapshot déjà présentes localement. Le filtrage suit la
convention générique du projet (Actif / Invisible) et l'ordre métier
(`PA_Ordre`). Aucune valeur métier n'est codée en dur ; les options proviennent
des vraies données `ParametreMetier`. L'ancienne règle Batik (exclusion
`PA_Valeur` 0/1/99, cas particulier 2) n'est **pas** reprise faute de règle
moderne explicite.
- **UI CREATE** (PHASE 3) : **READY** — le formulaire `SuiviTempsCreateView` +
`SuiviTempsCreateViewModel` permettent la **saisie offline-first** d'un temps
via `ISuiviTempsLocalService.CreateAsync` (écriture locale + Outbox CREATE,
`Valide` toujours false côté mobile). Le formulaire contient uniquement
CollaborateurId, InterventionId, RendezVousId, Date, DureeMinutes,
TypeTempsKey, Description. Le champ `Valide` est **absent**. Le TypeTemps est
chargé depuis `IParametreMetierLocalService.GetTypeTempsAsync()` (affichage
`ReferenceValueDto.Label`, valeur envoyée `ReferenceValueDto.Key` == PA_CLE,
aucune valeur hardcodée). `DureeMinutes` est saisie manuellement (aucun calcul
automatique depuis RendezVous/Intervention). Aucune écriture directe SQLite /
Outbox / API / SyncEngine depuis le ViewModel (le service local reste
responsable). Protection double clic : `SaveCommand.CanExecute == false`
pendant l'exécution (binding Avalonia) + garde interne `IsBusy`. Accès global
(liste SuiviTemps → Nouvelle saisie) et contextuel depuis une Intervention
(liste filtrée → Nouvelle saisie → InterventionId prérempli) et depuis un
RendezVous (liste filtrée → Nouvelle saisie → RendezVousId prérempli, sans
déduction automatique d'Intervention / durée / date).
**État fonctionnel** : `REAL` — **UI LIST READY**, **TYPE TEMPS LOCAL
REFERENCE READY**, **UI CREATE READY**, **RENDEZVOUS ENTRY POINT READY**.
Backend métier, snapshot, Sync CREATE, SQLite local, référentiel TypeTemps
local, consultation et saisie offline-first sont opérationnels. UPDATE /
ARCHIVE / DELETE : NOT SUPPORTED.
**Classification** : **C — BACKEND + MOBILE** (inchangée, CAS C non rouvert).
Les PHASE 1B (snapshot), 1C (Sync CREATE), 2 (SQLite local), 3 (UI LIST), le
micro-lot référentiel TypeTemps local et l'UI CREATE sont réalisés.
**Statut détaillé SuiviTemps** (après correctif snapshot — persistance des
colonnes de référence) :
| Capacité | Statut |
|----------|--------|
| BACKEND METIER | **READY** |
| SNAPSHOT | **READY** |
| SYNC CREATE | **READY** |
| LOCAL SQLITE | **READY** |
| SNAPSHOT LOCAL | **READY** |
| LOCAL CREATE | **READY** |
| OUTBOX CREATE | **READY** |
| RECONCILIATION | **READY** |
| UI LIST | **READY** |
| TYPE TEMPS LOCAL REFERENCE | **READY** |
| UI CREATE | **READY** |
| UPDATE | **NOT SUPPORTED** |
| ARCHIVE | **NOT SUPPORTED** |
| DELETE | **NOT SUPPORTED** |
| RENDEZVOUS ENTRY POINT | **READY** |
Le correctif snapshot garantit que le refresh persiste les colonnes structurées
(`CollaborateurId`, `InterventionId`, `RendezVousId`) de manière **générique**
(mécanisme partagé avec `LocalRepository`), sans fix spécifique SuiviTemps, et
préserve l'atomicité de la transaction snapshot ainsi que les données techniques
(Outbox, Sync state, profil, droits, licence, configuration, cache documents).
---
## 8. Rapports / CompteRendu — état réel
Il ne faut pas confondre les concepts suivants :
| Concept | Nature | État mobile |
|---------|--------|-------------|
| **CompteRendu** | Entité backend (`CompteRenduRow`, `CompteRenduDto`) | **BACKEND ONLY** — non peuplé dans le snapshot, aucune UI |
| **Rapport** | Modèles de rapports (`ModeleRapport`, `ModeleRapportMob`) | **BACKEND ONLY** — aucune UI mobile |
| **Note** | Note d'intervention | **NOT IMPLEMENTED** (CAS E) |
| **Document** | Fichier / métadonnée | **REAL** (liste, téléchargement, ajout offline) |
**État fonctionnel** :
- CompteRendu : `BACKEND ONLY` (aucune UI, non peuplé dans le snapshot).
- Rapports : `BACKEND ONLY` (aucune UI mobile).
- Aucune création / consultation de compte rendu ou rapport depuis le mobile.
---
## 9. Paramètres — ce qui est réellement accessible
La page Paramètres expose réellement à l'utilisateur :
| Réglage | État | Détail |
|---------|------|--------|
| **Thème** (Light / Dark / System) | **REAL** | ComboBox + bouton « Appliquer le thème » |
| **Couleur principale** | **REAL** | Presets + couleur personnalisée |
| **Synchroniser maintenant** | **REAL** | Bouton + état + erreur |
| **État de synchronisation** | **REAL** | Texte d'état |
| **Version** | **REAL** | Affichage « À propos » |
**Non accessibles à l'utilisateur** (capacité technique ≠ fonction UI) :
| Réglage | État | Détail |
|---------|------|--------|
| **Purge cache** | **NOT IMPLEMENTED** (UI) | `PurgeAsync` existe mais non branché à l'UI |
| **Reset application** | **NOT IMPLEMENTED** (UI) | Aucun parcours utilisateur |
---
## 10. Reset application
**État** : `NOT IMPLEMENTED` (UI).
Il n'existe **aucun parcours utilisateur** de réinitialisation de l'application.
Même si certaines primitives techniques de suppression existent (ex. purge du
cache), il n'y a pas de bouton ni d'écran « Reset » accessible à l'utilisateur.
**Classification** : **A — MOBILE UNIQUEMENT**. Le comportement fonctionnel est
déjà défini (réinitialisation des données locales / cache / outbox / licence) et
**aucune évolution backend n'est nécessaire**. La seule dépendance est
**technique** : la protection des fichiers référencés par des opérations Outbox
en attente (Pending / Rejected) avant suppression. Aucune décision métier non
résolue ne bloque ce lot.
---
## 11. Purge cache
**État** : `NOT IMPLEMENTED` (UI).
Le bouton de purge du cache Documents **n'a pas été branché** : la protection
des fichiers nécessaires aux opérations Outbox en attente n'était pas
suffisamment fiable. La capacité technique `DocumentCacheService.PurgeAsync`
existe mais n'est exposée à aucune interface utilisateur.
**Classification** : **A — MOBILE UNIQUEMENT**. Le problème est **uniquement
technique côté mobile** (fiabiliser la protection des fichiers référencés par
l'Outbox Pending / Rejected avant suppression). Aucune décision métier réelle ne
manque. Dépendance : protection fichiers Outbox.
---
## 12. Licence / Bootstrap — état fonctionnel utilisateur
- Le **backend** licence / bootstrap est réel : `POST /api/license/activate`,
`POST /api/license/validate`, `GET /api/mobile/bootstrap`, token signé,
identité/profil/droits.
- **Émission du token de session** : `activate` et `validate` retournent une
réponse de session `MobileLicenseSessionResponse` contenant le **Bearer token**
(`AccessToken`) et son **expiration exacte** (`AccessTokenExpiresAtUtc`,
24 h par défaut via `MobileAuth:TokenLifetimeHours`). Le token n'est émis
**que** si la licence est valide ; en cas d'échec, la réponse contient le code
d'erreur métier et **aucun token**. Le token est stateless (HMAC-SHA256),
jamais loggé, jamais stocké en HFSQL.
- **Côté application** : l'UI d'activation est **réellement branchée**. Au
premier démarrage sans session valide, l'utilisateur voit l'écran
**Activation** (code client + numéro de licence). Après activation réussie, le
token est stocké en **secure storage** (jamais en SQLite en clair), le
bootstrap (profil, droits, licence, configuration) est persisté en SQLite, et
l'application bascule vers le **Shell**.
- **Redémarrage** : si une session valide existe (licence valide ou période
offline autorisée), l'application démarre directement sur le **Shell** sans
repasser par l'activation.
- **Offline** : session déjà validée + période offline autorisée → accès aux
données SQLite sans connexion obligatoire. Premier démarrage sans session
valide + offline → activation impossible (message réseau).
- **Identité** : le nom de l'utilisateur (et ses initiales) est affiché dans le
**Shell** à partir du bootstrap local. Une licence expirée/révoquée ne
supprime **jamais** automatiquement les données locales (SQLite métier, cache,
Documents, Outbox).
**État fonctionnel** : `REAL` — activation, validation, bootstrap, token sécurisé
et identité utilisateur sont opérationnels côté mobile.
**Classification** : **A — MOBILE UNIQUEMENT** — réalisé. Le backend activation,
validation et bootstrap **existaient déjà** ; l'UI mobile d'activation / identité
a été ajoutée. **Risque sécurité : moyen/élevé** (parcours d'activation, token,
identité utilisateur) — traité par le secure storage et l'absence de token en
clair.
---
## 13. Comparaison avec la maquette `maquetteOmika.html`
La maquette reste une **référence UX uniquement**. Comparaison des grandes
fonctions :
| Fonction maquette | Application actuelle | État | Écart restant |
|-------------------|----------------------|------|---------------|
| Agenda (Mes rendez-vous) | Agenda du jour réel | **PARTIAL** | Pas de vue semaine/équipe, pas de saisie temps |
| RendezVous (détail) | Détail réel | **PARTIAL** | Pas de saisie temps, pas d'annulation |
| Interventions (recherche) | Liste + recherche locale | **PARTIAL** | Filtres avancés (relation RDV, date, collaborateur) absents |
| Interventions (fiche) | Fiche réelle | **PARTIAL** | Pas de GPS, création, planification RDV, compte rendu, rapports, emails |
| Dossiers | Liste + détail réels | **PARTIAL** | Pas de création / ajout d'intervention |
| Clients | Liste + détail réels | **PARTIAL** | Pas de création / modification |
| Sites / Lieux | Liste + détail réels | **PARTIAL** | Pas de GPS / itinéraire |
| Équipe | Liste + détail réels | **PARTIAL** | Pas d'agenda d'équipe |
| Documents | Liste + téléchargement + ajout offline | **REAL** | — |
| Photos | Ajout photo offline | **REAL** | — |
| Contacts | Liste + détail réels | **REAL** | — |
| Notes | — | **NOT IMPLEMENTED** | CAS E |
| Temps | — | **NOT IMPLEMENTED** | CAS C |
| Rapports | — | **NOT IMPLEMENTED** | Backend only |
| CompteRendu | — | **NOT IMPLEMENTED** | Backend only |
| Paramètres | Thème / couleur / sync / version / licence | **PARTIAL** | Pas de purge cache, reset |
| Synchronisation | Manuelle via SyncEngine | **REAL** | Pas d'automatique |
| Reset | — | **NOT IMPLEMENTED** | — |
---
## 14. Backlog — classification des restes
### A — MOBILE UNIQUEMENT
Données / backend déjà disponibles ; manque UI / service mobile.
| Élément | Détail |
|---------|--------|
| Filtres de recherche avancés Interventions | Données disponibles (RDV, collaborateur) ; manque l'UI de filtrage |
| GPS / itinéraire | Données adresse disponibles ; manque l'UI + lanceur carte |
| Email / appel depuis la fiche intervention | Launcher plateforme existe (contact) ; manque l'UI fiche |
| **Reset application** | Primitives locales existent ; comportement fonctionnel défini ; aucune évolution backend nécessaire. Dépendance technique : protection fichiers Outbox |
| **Purge cache** | `DocumentCacheService.PurgeAsync` existe ; problème uniquement technique mobile (protection fichiers Outbox Pending/Rejected). Dépendance : protection fichiers Outbox |
### B — MOBILE WRITE
Backend / sync réellement capables de l'écriture ; UI + mutation locale réalisées.
| Élément | Détail |
|---------|--------|
| **Modification Intervention mobile** ✅ RÉALISÉ | **Backend + Sync SUPPORTED** : handler `InterventionSyncOperationHandler`, contrat `InterventionSyncPayload`, CREATE/UPDATE/ARCHIVE réels, pipeline SUCCESS/REJECTED. **UI + mutation locale réalisées** : formulaire Create/Edit, bouton Nouvelle Intervention, action Modifier, action Archiver avec confirmation, aucun DELETE, protection double Save, navigation contextuelle, états Pending/Rejected affichés. |
> **Vérification** : CREATE, UPDATE et ARCHIVE Intervention sont réellement
> supportés de bout en bout par le backend + Sync (contrat public exploitable,
> EntityType reconnu, handler réel, traitement métier réel via
> `IInterventionService`, résultat SUCCESS/REJECTED possible). DELETE n'est pas
> supporté (archivage logique uniquement → PERMANENT_FAILURE). Le lot
> **Intervention Write UI** est **COMPLET** : CREATE UI COMPLETE, UPDATE UI
> COMPLETE, ARCHIVE UI COMPLETE, OFFLINE-FIRST, OUTBOX, IDENTITY
> RECONCILIATION. DELETE : NOT SUPPORTED.
### C — BACKEND + MOBILE
Contrat / snapshot / sync manquant avant l'UI.
| Élément | Détail |
|---------|--------|
| Notes Intervention | Absentes du snapshot / SQLite / Sync (CAS E) |
| Suivi des temps | **Snapshot (PHASE 1B) + Sync CREATE (PHASE 1C) + SQLite local (PHASE 2) faits** ; **UI LIST (PHASE 3) faite** ; **TYPE TEMPS LOCAL REFERENCE READY** ; **UI CREATE (PHASE 3) faite** (offline-first, TypeTemps Key=PA_CLE, aucun UPDATE/ARCHIVE/DELETE) ; **RENDEZVOUS ENTRY POINT RÉALISÉ** (accès contextuel RendezVous → liste filtrée → Nouvelle saisie, RendezVousId prérempli) |
| CompteRendu | Non peuplé dans le snapshot |
| Rapports | Modèles backend, aucune exposition mobile |
| **RendezVous (création / modification / annulation)** | **Aucun handler Sync RendezVous, aucun payload** ; toute opération Sync est rejetée (`NO_HANDLER`) |
| **Dossier (création / modification)** | **Aucun handler Sync Dossier, aucun payload** |
| **Client (création / modification)** | **Aucun handler Sync Client, aucun payload** |
| **Site (création / modification)** | **Aucun handler Sync Site, aucun payload** |
| **Collaborateur (création / modification)** | **Aucun handler Sync Collaborateur, aucun payload** |
### D — DÉCISION MÉTIER
Sémantique à décider avant développement.
| Élément | Détail |
|---------|--------|
| Sémantique Note vs DocumentNote | Distinction à confirmer avant développement Notes (décision de représentation métier exacte) |
> **Note** : Purge cache et Reset ne sont **plus** en D : ce sont des problèmes
> **techniques mobiles** (protection fichiers Outbox), sans décision métier non
> résolue. Ils sont classés en **A — MOBILE UNIQUEMENT** avec dépendance
> technique sur la protection Outbox.
---
## 15. Prochains lots recommandés
À partir de la cartographie réelle, **5 prochains lots** recommandés (aucun code
n'est écrit ici). Priorité forte donnée à l'activation/licence/identité, aux
écritures métier terrain réellement supportées par le Sync, au SuiviTemps et aux
Notes/CompteRendu, avant les améliorations purement cosmétiques ou les filtres
avancés.
### Lot 1 — Licence / activation + identité utilisateur ✅ RÉALISÉ
- **Objectif** : brancher l'UI d'activation / validation licence et afficher
l'identité utilisateur (nom, profil, droits).
- **Catégorie** : A (MOBILE UNIQUEMENT) — backend activation/validation/bootstrap
**déjà existants**.
- **Backend nécessaire** : Non (déjà existant).
- **Dépendances** : Bootstrap / token / profil.
- **Risque** : **Moyen/élevé** (sécurité, parcours d'activation, identité).
- **Raison de la priorité** : **Prérequis de mise en production réelle** — sans
activation/licence/identité, l'application n'est pas exploitable en production.
- **Statut** : **RÉALISÉ** — écran d'activation, session + token sécurisé
(secure storage), bootstrap local, identité Shell, offline grace. Voir
section 12.
### Lot 2 — Modification Intervention mobile (B — MOBILE WRITE) ✅ RÉALISÉ
- **Objectif** : brancher l'UI + mutation locale (Outbox) pour créer, modifier et
archiver une intervention, en s'appuyant sur le handler Sync **déjà
opérationnel**.
- **Catégorie** : B (MOBILE WRITE) — backend + Sync **déjà supportés**.
- **Backend nécessaire** : Non (handler `InterventionSyncOperationHandler` et
contrat `InterventionSyncPayload` existants).
- **Dépendances** : UI fiche + mutation locale + Outbox + réconciliation
RequestedEntityGuid → CanonicalEntityGuid.
- **Risque** : Moyen (mutation locale, identité, ordre CREATE → UPDATE).
- **Raison de la priorité** : **Écriture métier terrain réellement déjà
supportée par le Sync** — valeur terrain immédiate, sans évolution backend.
- **Statut** : **RÉALISÉ** — formulaire Create/Edit partagé, bouton Nouvelle
Intervention, action Modifier, action Archiver avec confirmation, aucun
DELETE, protection double Save, navigation contextuelle, états
Pending/Rejected affichés. Voir section 14 B.
### Lot 3 — Suivi des temps (CAS C)
- **Objectif** : exposer le SuiviTemps dans le snapshot / SQLite / Sync, puis
UI de saisie.
- **Catégorie** : C (BACKEND + MOBILE).
- **Backend nécessaire** : Oui (sync + handler ; snapshot **fait**).
- **Dépendances** : SQLite, Sync, UI.
- **Risque** : Moyen (contrat + sync).
- **Raison de la priorité** : **Fonction terrain très attendue** (saisie des
temps), nécessite le backend.
- **Statut** : **PHASE 1B (snapshot) RÉALISÉE** — `SuivisTemps` exposé dans le
`MobileSnapshotResponse` (lecture seule, GUID physique, aucun GUID
synthétique). **PHASE 1C (Sync CREATE) RÉALISÉE** — handler
`SuiviTempsSyncOperationHandler` + payload `SuiviTempsSyncPayload`, CREATE
supporté, UPDATE/ARCHIVE/DELETE non supportés. **PHASE 2 (SQLite local)
RÉALISÉE** — table `SuiviTemps` dans le schéma SQLite, import snapshot
(`SnapshotApplier`), service local `SuiviTempsLocalService` (lecture +
création offline-first via Outbox CREATE, `Valide` toujours false côté
mobile), réconciliation d'identité A → B. **PHASE 3 (UI LIST) RÉALISÉE** —
`SuiviTempsView` + `SuiviTempsViewModel` (consultation offline, états
Pending/Rejected/ErrorMessage, accès global + depuis Intervention).
**RÉFÉRENTIEL TYPE TEMPS LOCAL RÉALISÉ** — `IParametreMetierLocalService`
+ `GetTypeTempsAsync()` (options `ReferenceValueDto` Key/Label depuis les
vraies données `ParametreMetier`, offline). **UI CREATE RÉALISÉE** —
`SuiviTempsCreateView` + `SuiviTempsCreateViewModel` (saisie offline-first,
TypeTemps Key=PA_CLE, aucun UPDATE/ARCHIVE/DELETE, protection double clic
CanExecute). **RENDEZVOUS ENTRY POINT RÉALISÉ** — accès contextuel
RendezVous → liste filtrée → Nouvelle saisie (RendezVousId prérempli, sans
déduction automatique d'Intervention / durée / date).
### Lot 4 — Notes / CompteRendu (CAS E)
- **Objectif** : définir la représentation Note, l'exposer dans le snapshot /
SQLite / Sync, puis UI.
- **Catégorie** : C (BACKEND + MOBILE) + D (décision métier Note vs DocumentNote).
- **Backend nécessaire** : Oui (snapshot + sync + handler).
- **Dépendances** : Décision métier, snapshot, SQLite, Sync, UI.
- **Risque** : Moyen (sémantique + contrat).
- **Raison de la priorité** : **Fonction terrain attendue** (notes / compte
rendu), nécessite une décision métier préalable.
### Lot 5 — Reset + Purge cache (A — MOBILE UNIQUEMENT)
- **Objectif** : brancher l'UI Reset application et Purge cache, avec protection
des fichiers référencés par l'Outbox en attente.
- **Catégorie** : A (MOBILE UNIQUEMENT) — primitives locales existantes, aucune
évolution backend.
- **Backend nécessaire** : Non.
- **Dépendances** : Protection fichiers Outbox (Pending / Rejected) avant purge /
reset.
- **Risque** : Faible à moyen (intégrité des opérations en attente).
- **Raison de la priorité** : **Hygiène de production** (réinitialisation /
libération d'espace), sans backend.
---
## 16. Baseline de validation
| Vérification | Résultat | Commande |
|--------------|----------|----------|
| Tests Mobile | **587/587** | `dotnet test tests/Omika.Tests/Omika.Tests.csproj -c Debug --filter "FullyQualifiedName~Omika.Tests.Mobile"` |
| Tests ViewModel SuiviTemps (List) | **11/11** | `dotnet test tests/Omika.Tests/Omika.Tests.csproj -c Debug --filter "FullyQualifiedName~SuiviTempsViewModelTests"` |
| Tests ViewModel SuiviTemps (Create) | **18/18** | `dotnet test tests/Omika.Tests/Omika.Tests.csproj -c Debug --filter "FullyQualifiedName~SuiviTempsCreateViewModelTests"` |
| Tests ViewModel RendezVous (Detail) | **1/1** | `dotnet test tests/Omika.Tests/Omika.Tests.csproj -c Debug --filter "FullyQualifiedName~RendezVousDetailViewModelTests"` |
| Tests ViewModel Intervention (Edit/List/Detail) | **30/30** | `dotnet test tests/Omika.Tests/Omika.Tests.csproj -c Debug --filter "FullyQualifiedName~InterventionEditViewModelTests\|FullyQualifiedName~InterventionsViewModelTests\|FullyQualifiedName~InterventionDetailViewModelTests"` |
| Tests Référentiel TypeTemps local | **6/6** | `dotnet test tests/Omika.Tests/Omika.Tests.csproj -c Debug --filter "FullyQualifiedName~ParametreMetierLocalServiceTests"` |
| Suite unitaire (hors intégration HFSQL) | **1853/1853** | `dotnet test tests/Omika.Tests/Omika.Tests.csproj -c Debug --filter "FullyQualifiedName!~Omika.Tests.Connection.HfSqlOleDb"` |
| Build `Omika.slnx` | **VERT** (projets .NET) | `dotnet build Omika.slnx -c Debug` |
| Build `Omika.App.Local` | **VERT** | `dotnet build src/Omika.App.Local/Omika.App.Local.csproj -c Debug` |
| Build `Omika.App` | **VERT** | `dotnet build src/Omika.App/Omika.App.csproj -c Debug` |
| Build Android | **VERT** | `dotnet build src/Omika.App.Android/Omika.App.Android.csproj -c Debug` |
> **Note — Lot serveur Licence (connexion physique base HFSQL)** : le lot est en
> état de **STOP** (voir `docs/LOT_SERVEUR_LICENCE.md`). Les probes de lecture
> OLEDB ont révélé que la base Licence réelle (`BASE_LICENCES`) ne contient pas
> les tables `ClientBase`, `Licence`, `LicenceHisto` attendues par le repository
> (seulement 2 tables de backup système). Aucune écriture n'a été effectuée.
> La suite complète de tests affiche **1779 réussis / 10 échecs** : les 10 échecs
> sont des tests d'intégration HFSQL environnementaux (`HfSqlOleDb*IntegrationTests`)
> qui échouent faute de base d'intégration configurée (connexion `<:0>`), hors
> périmètre de la baseline unitaire. Les 34 tests de configuration Licence
> (`HfSqlTestConfigurationLoaderTests`, `E2EOptionsTests`,
> `LicenseConnectionOptionsValidatorTests`, `LicenseOleDbConnectionTests`)
> passent **34/34**.
---
*Document de bilan fonctionnel global — ne modifie aucun code de production.*
+278375
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.
+283
View File
@@ -0,0 +1,283 @@
# RECETTE ANDROID — AUDIT AVANT MODIFICATION (OBJECTIF 0 / §17)
> Projet : `D:\Avalonia\Omika` — Base OMIKA réelle. Aucun fichier modifié au stade d'audit.
> Ce document est le livrable de l'audit préalable. Les correctifs ne sont appliqués qu'après validation.
## Sommaire
1. [Architecture rappelée](#architecture)
2. [Tableau de synthèse des problèmes](#tableau)
3. [Cause exacte par problème](#causes)
4. [Classification](#classification)
5. [Preuve de non-régression Batik](#batik)
6. [Routes HTTP appelées](#routes)
7. [Requêtes SQL concernées](#sql)
8. [Inventaire des icônes à remplacer](#icones)
9. [Propositions de SVG monochrome](#svg)
<a name="architecture"></a>
## 1. Architecture rappelée
- **Mobile** (`src/Omika.App.Local`) : offline-first sur SQLite, synchronisé avec l'API via `MobileApiClient`.
- **API** (`src/Omika.Api`) : endpoints REST, sélection de base `Batik`/`Omika` via `ApplicationRuntimeContext.Current.DetectedVersion`.
- **Données** (`src/Omika.Data`) : repositories `Adaptive*` qui délèguent à `Batik*Repository` (schéma historique) ou `Omika*Repository` (schéma physique Omika). Les mappers (`Mapping/Omika`, `Mapping/Batik`) produisent le modèle canonique.
- **Identité publique** : GUID. Omika → GUID physique (colonne `GUID`). Batik → GUID synthétique réversible (`MobileEntityIdentityResolver`).
- **UI** (`src/Omika.App`) : Avalonia, thèmes clair/sombre via `DesignTokens.axaml` (clés `DynamicResource`).
<a name="tableau"></a>
## 2. Tableau de synthèse
| # | PROBLÈME | SYMPTÔME | COUCHE | FICHIER(S) | CAUSE EXACTE | SPÉCIFIQUE OMIKA ? | BATIK IMPACTÉ ? | CORRECTION PROPOSÉE |
|---|---|---|---|---|---|---|---|---|
| 1 | Carte OSM bloquée | 403 / "volunteer-run servers" | ANDROID/UI (tuiles) | `Controls/LieuMap.axaml.cs:97` | `OpenStreetMap.CreateTileLayer()` (Mapsui) utilise un HttpClient sans User-Agent identifiant Omika | NON | NON (commun) | Tile source custom avec User-Agent + cache + attribution ; fallback adresse+Itinéraire |
| 2 | Icônes hétérogènes | Emoji/Unicode/couleurs | UI | multiples (cf. §8) | Emoji/Unicode et couleurs codées en dur | NON | NON | Standardiser en Path monochrome + `DynamicResource` |
| 3 | Boutons entête intervention | fond blanc massif | UI | `Views/RendezVousDetailView.axaml:120-139` | `Background=OnPrimaryColorBrush` (blanc) + emoji `⌖ ⚒ ▧` | NON | NON | Style translucide + SVG monochrome |
| 4 | Agenda équipe : icône groupe | bleue/multicolore | UI | `Views/AgendaView.axaml:377` (`👥`) | emoji `👥` utilisé comme icône de groupe (pas dans `AgendaEquipeView` qui n'a pas d'icône) | NON | NON | Remplacer par SVG/Path monochrome |
| 5 | RDV→intervention introuvable | "Intervention introuvable." | OMIKA DATA / API | `OmikaRendezVousRepository`, `MobileSnapshotService`, `OmikaInterventionRepository.GetDetailsAsync` | (voir §3.5) | OUI | NON | Corriger le mapping/guid Omika |
| 6A | Note CreationUser=0 | CreationUser=0 en base | API / OMIKA DATA | `OmikaInterventionRepository.ResolveCreationUserAsync:1560` | token `CollaborateurId` = PK numérique licence ; `ResolveCreationUserAsync` attend un GUID | OUI | NON | Résoudre l'utilisateur depuis l'identité token (UserId/UT_PKID ou PK numérique) |
| 6B | Compteur notes = 0 | "0 note(s)" | LOCAL/API | `InterventionDetailViewModel.RefreshFromApiAsync` | compteur = `Notes.Count` chargé en arrière-plan via API ; échec/guid → vide | OUI (lié #5) | NON | même cause que #5 + retry |
| 7 | Compteur images/documents = 0 | "0 fichier(s)" | API / OMIKA DATA | `MobileDocumentService.GetByInterventionAsync` + `OmikaDocumentRepository` | résolution guid intervention / filtre catégorie | OUI | NON | même cause que #5 + vérif filtre |
| 8 | Écran Images/Documents | titre redondant + bouton coupé | UI | `Views/InterventionResourcesView.axaml:37,73` | `TextBlock "Images"` redondant + `StackPanel Orientation=Horizontal` non responsive | NON | NON | Supprimer titre + WrapPanel |
| 9 | Création email 404 | 404 modèles courriel | API | `MobileApiClient.cs:761` + `ModeleDocumentEndpoints.cs:18` | **Slash final manquant** : mobile appelle `GET /modele-mails`, API enregistre `GET /modele-mails/` (`MapGroup("/modele-mails").MapGet("/")`) | NON (route commune) | NON (corrigé pour les 2) | `MapGet("/")` → `MapGet("")` (ou slash final côté mobile) |
| 10 | Rapports 404 | 404 modèles rapport | API | `MobileApiClient.cs:771` + `ModeleDocumentEndpoints.cs:28` | **Slash final manquant** : `GET /modele-rapports` vs `GET /modele-rapports/` | NON | NON | idem #9 |
| 11 | Bouton téléphone inopérant | rien ne se lance | ANDROID | `Services/PlatformPhoneLauncher.cs` | `TopLevel.Launcher.LaunchUriAsync` (ACTION_VIEW) ≠ ACTION_DIAL | NON | NON | Launcher Android `ACTION_DIAL` (tel:) |
| 12 | Bouton email inopérant | aucun client mail | ANDROID | `Services/PlatformEmailLauncher.cs` | `LaunchUriAsync` générique ≠ ACTION_SENDTO | NON | NON | Launcher Android `ACTION_SENDTO` (mailto:) |
| 13 | Fiche équipe/collaborateurs responsive | email sur plusieurs lignes, cartes hautes | UI | `CollaborateursView`, `CollaborateurDetailView`, `EquipeDetailView` | layout non responsive | NON | NON | Réorganiser grille responsive |
| 14 | Messages d'erreur techniques | "Response status code…404" affiché | UI | `NouveauEmailViewModel`, `RapportChampsViewModel` | `ErrorMessage = ex.Message` brut | NON | NON | Message utilisateur propre + log technique |
<a name="causes"></a>
## 3. Cause exacte par problème
### 3.1 Carte OpenStreetMap bloquée
- `LieuMap.axaml.cs:97` appelle `OpenStreetMap.CreateTileLayer()` (Mapsui). Le `HttpTileSource` par défaut de Mapsui charge `https://tile.openstreetmap.org/{z}/{x}/{y}.png` via un `HttpClient` **sans User-Agent identifiant Omika** → OSM rejette avec 403 (« volunteer-run servers »).
- Le géocodeur `AddressGeocoder` (Nominatim) a déjà un User-Agent correct (`Omika-Mobile/1.0`), mais pas le chargement des tuiles.
- Pas de cache de tuiles configuré, pas d'attribution OSM visible, pas de fallback propre : l'image 403 brute est affichée.
### 3.5 RDV → « Ouvrir l'intervention » → introuvable
Flux tracé (lecture de code) :
1. `RendezVousDetailViewModel.OpenIntervention` → navigation avec `RendezVous.InterventionGuid` (GUID).
2. `AgendaItem.InterventionGuid` ← `AgendaService.MapToAgendaItem` ← `RendezVousDetailsDto.InterventionId`.
3. `RendezVousDetailsDto.InterventionId` ← `MobileSnapshotService.MapRendezVous` ← `MobileIdentityMapper.ResolveIdString(Intervention, dto.InterventionSourceReference)`.
4. `InterventionSourceReference` ← `OmikaRendezVousMapper` : `{ NumericId: RV_IdInter, EntityGuid: ResolveGuid(interventionGuids, RV_IdInter) }`.
5. Pour Omika, `MobileEntityIdentityResolver.CreateOmikaIdentity` retourne `physical.Guid`. Si `EntityGuid` est null (GUID intervention non résolu), `CreateOmikaIdentity` **lève `InvalidOperationException`** (« aucun GUID physique fourni »).
6. Navigation → `InterventionLocalService.GetInterventionByIdAsync(Guid)` → API `GET /interventions/{guid}` → `OmikaInterventionRepository.GetDetailsAsync` (GUID → `GUID IN (...)`).
**Conclusion de la trace statique (chaîne vérifiée de bout en bout)** : la chaîne d'identité est **correcte** pour Omika :
- `OmikaRendezVousMapper` résout `InterventionSourceReference = { NumericId: RV_IdInter, EntityGuid: Intervention.GUID }` en bulk (`LoadInterventionGuidsAsync`, requête `IN_PKID IN (...)`).
- `MobileSnapshotService.MapRendezVous` → `ResolveIdString` → `CreateOmikaIdentity` retourne le GUID physique.
- `AgendaItem.InterventionGuid` = ce GUID ; navigation → API `GET /interventions/{guid}` → `GetDetailsAsync` (GUID) ; repli local sur la table snapshot `Intervention` (peuplée par `SnapshotApplier` à partir de `LoadInterventionsForRendezVousAsync`, clé = GUID canonique).
- `PhysicalGuidParser.GetStorageCandidates` couvre les formes D / N / D-tronquée.
**Déclencheur exact restant = niveau données** (à confirmer par les logs temporaires demandés au §5 de la recette) :
- soit `RV_IdInter` pointe vers une intervention dont la colonne `GUID` est NULL/invalide (→ `EntityGuid` null → `CreateOmikaIdentity` lèverait, OU `InterventionId` null) ;
- soit l'intervention référencée n'existe pas dans `Intervention` (référence orpheline) ;
- soit un écart de format GUID entre `RV_IdInter→GUID` et l'`Intervention.GUID` réel (byte-order HFSQL).
**Logs temporaires à poser (correction #5)** : `RDV_PKID`, `RDV GUID`, `RV_IdInter`, `Intervention.GUID` résolu, `InterventionGuid` envoyé à la navigation (type + valeur), requête SQL utilisée, résultat. Aucun `IN_NumIntervention` ne doit être réintroduit.
Les compteurs #6B et #7 passent par les mêmes endpoints API (`GET /api/interventions/{guid}/notes`, `GET /api/interventions/{guid}/documents`) : si la résolution d'intervention échoue, listes vides → « 0 note(s) » / « 0 fichier(s) ». #7 a en plus un filtre par catégories de document (`BuildAllowedDocumentCategories` + `IsCategoryAllowed`) qui peut vider la liste si le profil n'a pas le droit `A.DOC.*` correspondant.
### 3.6A Note CreationUser = 0
- Endpoint `AddNoteForInterventionAsync` (`InterventionEndpoints.cs:427`) transmet `httpContext.GetMobileIdentity()?.CollaborateurId` à `InterventionService.AddNoteAsync` → `ResolveCreationUserAsync(collaborateurId)`.
- En **production**, `LicenseEndpoints.BuildSession` (`LicenseEndpoints.cs:72`) pose `CollaborateurId = response.CollaborateurId` = `licence.CollaborateurId.ToString()` (PK numérique du collaborateur, cf. `LicenseService.BuildSuccessAsync:404`).
- `OmikaInterventionRepository.ResolveCreationUserAsync` (`:1560`) fait `Guid.TryParse(collaborateurGuid, out Guid)` : une valeur numérique **échoue** → retour `0` → `CreationUser = 0`.
- `BatikInterventionRepository.ResolveCreationUserAsync` (`:1513`) décode un GUID synthétique Batik : une valeur numérique échoue aussi (`Guid.TryParse` → 0).
- **Racine** : incohérence de type entre la claim `CollaborateurId` du token (numérique en prod) et l'API de `ResolveCreationUserAsync` (attend un GUID).
### 3.9 / 3.10 Email / Rapport 404 — CAUSE EXACTE CONFIRMÉE
- **Slash final manquant.** `MobileApiClient.GetModeleMailsAsync` appelle `GET {BaseUrl}/modele-mails` (sans slash), mais l'API enregistre la route `GET /modele-mails/` (avec slash) via `MapGroup("/modele-mails").MapGet("/")` (`ModeleDocumentEndpoints.cs:18`). En ASP.NET Core minimal API, `MapGroup("/x").MapGet("/")` produit `/x/` et **ne matche pas** `/x` → 404.
- Idem `GetModeleRapportsAsync` → `GET /modele-rapports` vs `GET /modele-rapports/` (`ModeleDocumentEndpoints.cs:28`).
- Les autres routes auditées (`/mot-clefs`, `/generation/email`, `/generation/rapport`, `/api/rapports/champs`, `/api/mails/champs`, `/api/mails`, `/api/mails/apercu`, `/api/rapports`) matchent **exactement**. Le préfixe `/api` est cohérent, les routes sont bien enregistrées (`Program.cs:243-244`), et `MobileAuthMiddleware` ne renvoie jamais 404.
- **Correction** : `MapGet("/")` → `MapGet("")` dans `ModeleDocumentEndpoints` (canonique, fait matcher `/modele-mails` sans slash) — sans impact Batik (route schéma-agnostique, corrige les deux schémas). Alternative : ajouter le slash final dans `MobileApiClient` (l.761/771).
- Référence historique `Mail_Modele` / `ModeleRapport WHERE MRP_EnvoiMob=1` → routes REST actuelles (cf. `docs/modeles-email-rapport.md`).
### 3.11 / 3.12 Téléphone / Email inopérants
- `PlatformPhoneLauncher.DialAsync` et `PlatformEmailLauncher.ComposeAsync` utilisent `TopLevel.Launcher.LaunchUriAsync(uri)` (launcher générique Avalonia). Sur Android, ce launcher utilise `ACTION_VIEW`, qui **ne lance pas le composeur natif** (tel:) ni le client mail de façon fiable.
- Aucun launcher Android spécifique n'existe (`src/Omika.App.Android` ne contient pas de Phone/Email launcher).
- Les ViewModels ignorent le retour `bool` : aucun message si aucun handler n'est disponible.
### 3.14 Messages d'erreur techniques
- `NouveauEmailViewModel.LoadAsync` et `RapportChampsViewModel.LoadAsync` font `catch (Exception ex) { ErrorMessage = ex.Message; }` ; `EnsureSuccessStatusCode()` lève `HttpRequestException` dont le message brut (« Response status code does not indicate success: 404 (Not Found). ») est affiché tel quel.
<a name="classification"></a>
## 4. Classification
| # | Classification |
|---|---|
| 1 | ANDROID/UI (commun, non spécifique données) |
| 2 | UI |
| 3 | UI |
| 4 | UI |
| 5 | OMIKA DATA + API (résolution identité intervention) |
| 6A | API + OMIKA DATA (résolution utilisateur) |
| 6B | LOCAL/API (compteur = source API) |
| 7 | API + OMIKA DATA (documents) |
| 8 | UI |
| 9 | API (route/déploiement) |
| 10 | API (route/déploiement) |
| 11 | ANDROID (launcher) |
| 12 | ANDROID (launcher) |
| 13 | UI |
| 14 | UI |
<a name="batik"></a>
## 5. Preuve de non-régression Batik
- Les corrections **UI/Android** (#1, #2, #3, #4, #8, #11, #12, #13, #14) ne touchent **aucune** requête Batik : elles portent sur les `.axaml`, les launchers Android et les messages d'erreur, partagés entre les deux schémas.
- Les corrections **données** (#5, #6A, #7, #9, #10) seront appliquées **uniquement** dans les providers/repositories/mappers **Omika** (`src/Omika.Data/Repositories/Omika/*`, `Mapping/Omika/*`) ou dans la couche d'identité commune, **sans modifier** `Batik*Repository` ni `Batik*Mapper`.
- `ResolveCreationUserAsync` Batik est déjà isolé dans `BatikInterventionRepository` ; sa correction éventuelle (acceptation du PK numérique) doit être faite symétriquement sans casser le décodage du GUID synthétique Batik (test à prévoir).
- Les routes API (#9, #10) sont communes : l'ajout d'un alias `/api/modele-mails` (si retenu) n'affecte pas le comportement Batik, qui utilise la même API.
<a name="routes"></a>
## 6. Routes HTTP actuellement appelées (MobileApiClient → Omika.Api)
| Méthode client | URL appelée | Route API | Match |
|---|---|---|---|
| GetModeleMailsAsync | `GET /modele-mails` | `ModeleDocumentEndpoints` `/modele-mails` | ✅ (préfixe sans `/api`) |
| GetModeleRapportsAsync | `GET /modele-rapports` | `ModeleDocumentEndpoints` `/modele-rapports` | ✅ |
| GetMotClefsAsync | `GET /mot-clefs` | `ModeleDocumentEndpoints` `/mot-clefs` | ✅ |
| GetEquipesAsync | `GET /equipes` | `EquipeEndpoints` `/equipes` | ✅ |
| GetAllEquipesAsync | `GET /equipes/all` | `EquipeEndpoints` `/equipes/all` | ✅ |
| GetCollaborateurByIdAsync | `GET /collaborateurs/{id}` | `CollaborateurEndpoints` | ✅ |
| GetRendezVousByIdAsync | `GET /rendez-vous/{id}` | `RendezVousEndpoints` | ✅ |
| GetInterventionByIdAsync | `GET /interventions/{id}` | `InterventionEndpoints` | ✅ |
| GetDossierByIdAsync | `GET /dossiers/{id}` | `DossierEndpoints` | ✅ |
| GetClientByIdAsync | `GET /clients/{id}` | `ClientEndpoints` | ✅ |
| GetSiteByIdAsync | `GET /sites/{id}` | `SiteEndpoints` | ✅ |
| GetContactByIdAsync | `GET /contacts/{id}` | `ContactEndpoints` | ✅ |
| GetCompteRenduByRendezVousAsync | `GET /rendez-vous/{id}/compte-rendu` | `CompteRenduEndpoints` | ✅ |
| GetNotesByInterventionAsync | `GET /api/interventions/{id}/notes` | `InterventionEndpoints` mobileGroup | ✅ |
| AddNoteByInterventionAsync | `POST /api/interventions/{id}/notes` | `InterventionEndpoints` mobileGroup | ✅ |
| GetDocumentsByInterventionAsync | `GET /api/interventions/{id}/documents` | `InterventionEndpoints` mobileGroup | ✅ |
| GetRendezVousByInterventionAsync | `GET /api/interventions/{id}/rendez-vous` | `InterventionEndpoints` mobileGroup | ✅ |
| GetEmailsByInterventionAsync | `GET /api/interventions/{id}/emails` | `InterventionEndpoints` mobileGroup | ✅ |
| AddEmailByInterventionAsync | `POST /api/interventions/{id}/emails` | `InterventionEndpoints` mobileGroup | ✅ |
| GetSmtpConfigurationsAsync | `GET /api/mails/configurations-smtp` | `RapportChampEndpoints` | ✅ |
| GetRapportChampsAsync | `POST /api/rapports/champs` | `RapportChampEndpoints` | ✅ |
| GetMailChampsAsync | `POST /api/mails/champs` | `RapportChampEndpoints` | ✅ |
| CreateEmailModeleAsync | `POST /api/mails` | `RapportChampEndpoints` | ✅ |
| GetEmailModeleApercuAsync | `POST /api/mails/apercu` | `RapportChampEndpoints` | ✅ |
| CreateRapportAsync | `POST /api/rapports` | `RapportChampEndpoints` | ✅ |
| GenerateEmailAsync | `POST /generation/email` | `ModeleDocumentEndpoints` | ✅ |
| GenerateRapportAsync | `POST /generation/rapport` | `ModeleDocumentEndpoints` | ✅ |
| SearchInterventionsAsync | `POST /api/interventions/search` | `InterventionEndpoints` mobileGroup | ✅ |
| GetInterventionsByIdsAsync | `POST /api/interventions/by-ids` | `InterventionEndpoints` mobileGroup | ✅ |
| SearchDossiersAsync | `GET /api/dossiers` | `DossierEndpoints` mobileGroup | ✅ |
| SearchClientsAsync | `GET /api/clients` | `ClientEndpoints` mobileGroup | ✅ |
| SearchSitesAsync | `GET /api/sites` | `SiteEndpoints` mobileGroup | ✅ |
| SearchCollaborateursAsync | `GET /collaborateurs` | `CollaborateurEndpoints` | ✅ (sans `/api`) |
| GetSuivisTempsAsync | `GET /api/suivis-temps` | `SuiviTempsEndpoints` | ✅ |
| GetBootstrapAsync | `GET /api/mobile/bootstrap` | `MobileBootstrapEndpoints` | ✅ |
| GetSnapshotAsync | `GET /api/sync/snapshot` | `SyncEndpoints` | ✅ |
| SendOperationAsync | `POST /api/sync/operations` | `SyncEndpoints` | ✅ |
| GetStatusAsync | `GET /api/sync/status/{id}` | `SyncEndpoints` | ✅ |
| ActivateAsync | `POST /api/license/activate` | `LicenseEndpoints` | ✅ |
| ValidateAsync | `POST /api/license/validate` | `LicenseEndpoints` | ✅ |
| GetDevelopmentSessionAsync | `POST /api/dev/session` | `DevelopmentAuthEndpoints` (Dev only) | ✅ |
> **Incohérence constatée** : le mobile utilise un mélange de routes avec `/api` et sans `/api` (ex. `/modele-mails` sans préfixe vs `/api/mails`). Les routes « modèle » (`/modele-mails`, `/modele-rapports`, `/mot-clefs`, `/generation/*`) sont les seules sans `/api`. C'est cohérent avec le code API actuel mais fragile et à uniformiser.
<a name="sql"></a>
## 7. Requêtes SQL concernées (côté API, base HFSQL)
| Problème | Requête |
|---|---|
| #5 (intervention par GUID) | `SELECT … FROM Intervention WHERE GUID IN (?)` (formes D/N/tronquée via `PhysicalGuidParser.GetStorageCandidates`) — `OmikaInterventionRepository.GetDetailsAsync` |
| #5 (RDV→intervention) | `SELECT … FROM RDV_Planning … LEFT JOIN Intervention I ON RV.RV_IdInter = I.IN_PKID` (résolution bulk `RV_IdInter → Intervention.GUID`) — `OmikaRendezVousRepository` |
| #6A (user) | `SELECT Utilisateur.UT_PKID FROM Utilisateur INNER JOIN Collaborateur ON Collaborateur.CO_PKID = Utilisateur.UT_IDCollaborateur WHERE Collaborateur.GUID IN (?)` — `OmikaInterventionRepository.ResolveCreationUserAsync` |
| #6A (Batik) | `SELECT UT_PKID FROM Utilisateur WHERE UT_IDCollaborateur = ?` — `BatikInterventionRepository.ResolveCreationUserAsync` |
| #6B/#7 (notes) | `SELECT MI_PKID, GUID, MI_Message, CreationDate, CreationUser FROM MessageInterne WHERE MI_IDIntervention = ? AND (MI_IDTypeMessage IS NULL OR MI_IDTypeMessage <= 10)` — `OmikaInterventionRepository.GetNotesAsync` |
| #7 (documents) | résolution intervention GUID → `IN_PKID` puis requête documents (filtre `DC_IDIntervention` / catégorie / état) — `OmikaDocumentRepository.GetByInterventionAsync` |
| #7 (catégories) | `BuildAllowedDocumentCategories` → filtrage par clés `T.DOC.*` selon droits `A.DOC.*` |
<a name="icones"></a>
## 8. Inventaire des icônes à remplacer (extrait — inventaire complet en cours)
| Fichier | Ligne | Actuel | Fonction | Proposition |
|---|---|---|---|---|
| `Views/RendezVousDetailView.axaml` | 122 | `⌖ Itinéraire` (emoji) | Itinéraire | Path "navigation/itinéraire" monochrome |
| `Views/RendezVousDetailView.axaml` | 134 | `⚒ Ouvrir l'intervention` | Ouvrir intervention | Path "marteau/outil" monochrome |
| `Views/RendezVousDetailView.axaml` | 139 | `▧ Compte rendu` | Compte rendu | Path "document" monochrome |
| `Views/InterventionResourcesView.axaml` | 37/73 | titre texte redondant | (titre) | supprimer |
| `Controls/LieuMap.axaml` | 25 | `⌖` (TextBlock) | épingle | Path "pin" monochrome |
| `ViewModels/NouveauEmailViewModel.cs` | 144 | `✉ Email vierge` | modèle vierge | (libellé, pas d'icône) |
| `ViewModels/NouveauEmailViewModel.cs` | 888 | `📷 {Nom}` | pièce jointe photo | Path "image" |
| `ViewModels/RapportChampsViewModel.cs` | 606 | `✓` / `!` (Indicateur) | état rubrique | Path check/alert monochrome |
| `Views/AgendaEquipeView.axaml` | (filtre Équipe) | icône groupe colorée | filtre équipe | Path "group" monochrome |
| `Views/InterventionDocumentsView.axaml` | 17 | `← Retour` | retour | Path "arrow-left" |
Les contrôles `CallButton.axaml` et `EmailButton.axaml` utilisent **déjà** des `Path` monochromes `Fill="{DynamicResource TextPrimaryBrush}"` : conformes, à conserver comme référence visuelle.
<a name="svg"></a>
## 9. Propositions de SVG monochrome (famille unique)
Famille retenue : **pictogrammes "Line/Outline" sobres** (traits fins, style Lucide/Material Outlined), **un seul style** (trait 1.5–2, pas de remplissage multicolore), couleur portée par `Foreground="{DynamicResource PrimaryBrush}"` ou `Fill="{DynamicResource …}"` — **jamais codée en dur**.
| Pictogramme | Usage | Path (24×24 viewBox, données type Material) |
|---|---|---|
| Téléphone | appel | `M6.62,10.79c1.44,2.83 …` (déjà dans CallButton) |
| Email | courriel | `M20,4H4C2.9,4 …` (déjà dans EmailButton) |
| Itinéraire/GPS | itinéraire | navigation arrow / pin |
| Intervention (marteau/outil) | ouvrir intervention | wrench/hammer |
| Rapport | rapport | document + lignes |
| Note | note | edit/pencil |
| Image | photo | image frame |
| Document | document | file |
| Collaborateurs | personne | person outline |
| Équipe | groupe | people/group outline |
| Calendrier | agenda | calendar |
| Paramètres | paramètres | gear |
| Synchronisation | sync | refresh/circular arrows |
| Retour | retour | arrow-left |
| Ajouter | ajouter | plus |
| Télécharger | télécharger | download arrow |
| Épingle | carte | location pin |
Chaque pictogramme sera matérialisé par un `Path` Avalonia (monochrome) ou un `StreamGeometry`, avec `Fill`/`Foreground` en `DynamicResource` pour rester compatible clair/sombre.
## 8bis. Couleurs codées en dur à remplacer par `DynamicResource`
| Fichier | Ligne | Valeur | Remplacer par |
|---|---|---|---|
| `Views/RendezVousDetailView.axaml` | 143 | `#E53935` / `White` (bouton Annuler) | `DangerBrush` / `OnDanger` |
| `Views/RapportChampsView.axaml` | 108/115/122 | `#22c55e` / `#f59e0b` / `#ef4444` | `SuccessBrush` / `WarningBrush` / `DangerBrush` |
| `Views/RapportChampsView.axaml` | 253 | `#e5e7eb` | `SurfaceTertiaryBrush` |
| `Views/RapportChampsView.axaml` | 138,233,258 | `ThemeForegroundLowBrush` (**clé INEXISTANTE** dans DesignTokens) | `TextMutedBrush` |
| 10 fichiers (badges héros) | — | `#26FFFFFF` | token `OnPrimaryContainer`/`HeroChip` |
| `Controls/ImageEditorControl.axaml` | — | `#E53935…`, `#FFFFFF` | ressources centralisées |
| `Views/*Converter.cs` (`NavItemTextBrushConverter`, `SyncStateToBrushConverter`) | — | `Brushes.Gray` fallback | ressource `TextMutedBrush` |
## 8ter. Correctifs appliqués (lot 1 — compilation Android 0 erreur vérifiée)
| # | Correctif | Fichiers modifiés | Vérification |
|---|---|---|---|
| 9/10 | `MapGet("/")` → `MapGet("")` (route sans slash final) | `src/Omika.Api/Endpoints/ModeleDocumentEndpoints.cs` | Omika.Api 0 erreur |
| 6A | `ResolveCreationUserAsync` accepte PK numérique + GUID (Omika & Batik) | `OmikaInterventionRepository.cs`, `BatikInterventionRepository.cs` | Omika.Data 0 erreur |
| 11/12 | Launchers natifs Android `ACTION_DIAL`/`ACTION_SENDTO` + hook `PlatformLaunchers` | `AndroidPhoneLauncher.cs`, `AndroidEmailLauncher.cs` (nouveaux), `PlatformLaunchers.cs` (nouveau), `AppServices.cs`, `Application.cs` | Android 0 erreur |
| 1 | User-Agent OSM explicite `OmikaMobile/1.0 (+https://www.applilogik.fr)` | `Controls/LieuMap.axaml.cs` | Omika.App 0 erreur |
| 3 | Boutons d'en-tête translucides `heroAction` + icônes Path monochrome | `Views/RendezVousDetailView.axaml`, `Styles/Controls.axaml` | Omika.App 0 erreur |
| 8 | Suppression titre redondant + `WrapPanel` (boutons wrappables) | `Views/InterventionResourcesView.axaml` | Omika.App 0 erreur |
| 14 | Messages utilisateur propres + log technique | `NouveauEmailViewModel.cs`, `RapportChampsViewModel.cs` | Omika.App 0 erreur |
| 5 | Logs temporaires de traçage RDV→intervention | `RendezVousDetailViewModel.cs`, `InterventionLocalService.cs` | Omika.App 0 erreur |
### Lot 2 — Icônes monochromes + couleurs + responsive (compilation 0 erreur)
| # | Correctif | Fichiers |
|---|---|---|
| 2 | Bug clé `ThemeForegroundLowBrush` (inexistante) → `TextMutedBrush` | `RapportChampsView.axaml`, `NouveauEmailView.axaml` |
| 2 | Couleurs sémantiques en dur → `Success/Warning/Danger/SurfaceTertiaryBrush` | `RapportChampsView.axaml` |
| 2 | Icônes de tuiles emoji → Path monochrome `PrimaryTextColorBrush` (✎ ▧ ✉ 🛡 📄) | `InterventionDetailView.axaml` |
| 4 | Icône groupe `👥` → Path « people » monochrome | `AgendaView.axaml` |
| 2 | Icône calendrier `📅` → Path « calendar » monochrome | `Controls/DateField.axaml` |
| 2 | Tokens `HeroChipBrush` / `HeroBorderBrush` ajoutés | `DesignTokens.axaml` |
| 13 | Email non tronqué (`TextWrapping`) fiche collaborateur + liste collaborateurs | `CollaborateurDetailView.axaml`, `CollaborateursView.axaml` |
> Reste (lots suivants) : `#26FFFFFF` → `HeroChipBrush` sur les badges (cosmétique), autres emoji (`↻ ⌖ ⚠ ⓘ 🗑 ✎ 🖼 🔒 ★/☆ ✉ + → ←`), responsive `FilterGrid` agenda équipe, et les correctifs de données `#5/#6B/#7` (résolution intervention + filtre catégories `T.DOC.*`).
> Build Android : `dotnet build src\Omika.App.Android\Omika.App.Android.csproj -c Debug -m:1 -nodeReuse:false` → **0 erreur** (SDK 10.0.302 via `global.json`). Les builds nécessitent `-m:1 -nodeReuse:false` (bug de nœud MSBuild parallèle dans l'environnement) et un accès élargi pour la télémétrie Avalonia (écrit hors workspace).
## 9. État des sous-analyses
- ✅ Route API (#9/#10) : **confirmé** — slash final manquant (`/modele-mails` vs `/modele-mails/`).
- ✅ Icônes + responsive (#2/#3/#4/#8/#13) : **confirmé** — inventaire ~40 emoji/Unicode, aucune couleur codée en dur dans les Path, liste des couleurs en dur à remplacer (dont clé manquante `ThemeForegroundLowBrush`).
- ✅ Trace RDV→intervention (#5/#6B/#7) : chaîne d'identité vérifiée de bout en bout (correcte) ; déclencheur exact = niveau données, à confirmer par les logs temporaires prévus en correction.
+119
View File
@@ -0,0 +1,119 @@
# RECETTE ANDROID OMIKA — RAPPORT FINAL (§16 / §18)
> Projet : `D:\Avalonia\Omika`. Base OMIKA réelle. Correctifs appliqués sur le socle commun + isolation Omika.
> Compilation Android : **0 erreur** (SDK 10.0.302, `dotnet build src\Omika.App.Android\Omika.App.Android.csproj -c Debug -m:1 -nodeReuse:false`).
## 1. Fichiers modifiés
| Fichier | Nature |
|---|---|
| `src/Omika.Api/Endpoints/ModeleDocumentEndpoints.cs` | route sans slash final (#9/#10) |
| `src/Omika.Data/Repositories/Omika/OmikaInterventionRepository.cs` | `ResolveCreationUserAsync` PK numérique + GUID (#6A) |
| `src/Omika.Data/Repositories/Batik/BatikInterventionRepository.cs` | idem (#6A, sans casser le GUID synthétique Batik) |
| `src/Omika.App.Android/AndroidPhoneLauncher.cs` (nouveau) | `ACTION_DIAL` tel: (#11) |
| `src/Omika.App.Android/AndroidEmailLauncher.cs` (nouveau) | `ACTION_SENDTO` mailto: (#12) |
| `src/Omika.App/Services/PlatformLaunchers.cs` (nouveau) | point d'injection plateforme (#11/#12) |
| `src/Omika.App/AppServices.cs` | branchement launchers plateforme |
| `src/Omika.App.Android/Application.cs` | installation launchers natifs |
| `src/Omika.App/Controls/LieuMap.axaml.cs` | User-Agent OSM (#1) |
| `src/Omika.App/Controls/LieuMap.axaml` | épingle Path (#2) |
| `src/Omika.App/Views/RendezVousDetailView.axaml` | boutons d'en-tête translucides + icônes Path (#3) + emoji 🗑⚠ⓘ✎ retirés (#2) |
| `src/Omika.App/Styles/Controls.axaml` | style `Button.heroAction` (#3) |
| `src/Omika.App/Styles/DesignTokens.axaml` | tokens `HeroChipBrush`/`HeroBorderBrush` (#2) |
| `src/Omika.App/Views/InterventionResourcesView.axaml` | titre redondant supprimé + `WrapPanel` (#8) |
| `src/Omika.App/Views/InterventionDetailView.axaml` | icônes de tuiles Path (#2) |
| `src/Omika.App/Views/AgendaView.axaml` | icône groupe Path (#4) |
| `src/Omika.App/Controls/DateField.axaml` | icône calendrier Path (#2) |
| `src/Omika.App/Controls/RendezVousCard.axaml` | icône verrou Path (#2) |
| `src/Omika.App/Views/NouveauEmailView.axaml` + `RapportChampsView.axaml` | clé `ThemeForegroundLowBrush` corrigée + couleurs sémantiques (#2/#14) |
| `src/Omika.App/ViewModels/NouveauEmailViewModel.cs` + `RapportChampsViewModel.cs` | messages utilisateur propres (#14) + emoji 📷 retiré (#2) |
| `src/Omika.App/ViewModels/RendezVousDetailViewModel.cs` | logs traçage RDV→intervention (#5) |
| `src/Omika.App.Local/Services/InterventionLocalService.cs` | logs traçage + « INTROUVABLE » (#5) |
| `src/Omika.App/Views/CollaborateurDetailView.axaml` + `CollaborateursView.axaml` | email `TextWrapping` (#13) |
| `src/Omika.App/Views/ImageEditorView.axaml` | emoji 🖼 retiré (#2) |
| `global.json` (nouveau) | épingle SDK 10.0.302 |
## 2. Cause / correction par bug
| # | Cause exacte | Correction | Spécifique Omika ? | Batik impacté ? |
|---|---|---|---|---|
| 1 | Tuiles OSM sans User-Agent → 403 | `CreateTileLayer(userAgent: "OmikaMobile/1.0 (+https://www.applilogik.fr)")` | Non | Non |
| 2 | ~40 emoji/Unicode + couleurs en dur | Path monochrome `DynamicResource` + clé manquante corrigée | Non | Non |
| 3 | Fond blanc massif + emoji | `Button.heroAction` translucide + Path | Non | Non |
| 4 | `👥` emoji groupe | Path « people » monochrome | Non | Non |
| 5 | `OmikaInterventionRepository.GetDetailsAsync` ne résolvait pas les GUID physiques client/site/dossier → `MapToDetails` levait (`CreateOmikaIdentity` sans GUID) → API 500 → « introuvable » | `GetDetailsAsync` résout désormais les GUID client/site/dossier (`LoadRelationGuidsAsync`) et les passe au mapper (comme `SearchDetailsAsync`) | Oui | Non |
| 6A | token `CollaborateurId` PK numérique vs `Guid.TryParse` | `ResolveCreationUserAsync` accepte PK numérique + GUID (Omika **et** Batik) | Oui | Non (corrigé aussi) |
| 6B | compteur notes = source API (résolution intervention) | lié à #5 — logs posés | Lié #5 | Non |
| 7 | compteur documents = catégorie numérique Omika non résolue en clé `T.DOC.*` → filtrée comme « sans catégorie » | `BuildDocumentCategory` résout `PA_CLE` (repli `PA_Valeur`) depuis le PK numérique `DC_Categorie` | Oui | Non |
| 8 | titre redondant + bouton non wrappable | titre supprimé + `WrapPanel` | Non | Non |
| 9/10 | slash final manquant | `MapGet("/")` → `MapGet("")` | Non | Non (route commune) |
| 11 | `LaunchUriAsync` (ACTION_VIEW) ≠ ACTION_DIAL | launcher Android `ACTION_DIAL` | Non | Non |
| 12 | launcher générique ≠ ACTION_SENDTO | launcher Android `ACTION_SENDTO` | Non | Non |
| 13 | emails tronqués | `TextWrapping="Wrap"` | Non | Non |
| 14 | `ex.Message` brut affiché | message utilisateur + log technique | Non | Non |
## 3. Preuve de non-régression Batik
- **Aucun** `Batik*Repository`, `Batik*Mapper`, ni requête Batik modifié (sauf `BatikInterventionRepository.ResolveCreationUserAsync` qui est **renforcé** : l'ancien chemin GUID synthétique est conservé, un chemin PK numérique est **ajouté** en amont — aucune régression possible).
- Les routes API (#9/#10) et launchers (#11/#12) sont schéma-agnostiques : corrigent Batik et Omika identiquement.
- Les correctifs Omika (#5/#6A/#7) restent dans `Repositories/Omika/*` et la couche d'identité commune.
## 4. Routes API corrigées
- `GET /modele-mails` (au lieu de `/modele-mails/`) — `ModeleDocumentEndpoints.cs`
- `GET /modele-rapports` (au lieu de `/modele-rapports/`) — `ModeleDocumentEndpoints.cs`
## 5. Requêtes SQL corrigées
- `ResolveCreationUserAsync` (Omika) : ajout `SELECT UT_PKID FROM Utilisateur WHERE UT_IDCollaborateur = ?` (PK numérique) en plus du chemin GUID.
- `ResolveCreationUserAsync` (Batik) : idem.
## 6. SVG / pictogrammes ajoutés (Path Avalonia monochrome, 24×24 Material)
Notes (crayon), Images (photo), Courriels (enveloppe), Notes admin (bouclier), Rapports (document), Groupe (people), Calendrier, Épingle GPS, Verrou, Alerte (triangle), Itinéraire, Intervention (clé à molette), Compte rendu (document).
## 7. Matrice de tests (§16)
### OMIKA
| Test | Attendu | Statut |
|---|---|---|
| RDV → Ouvrir l'intervention | charge la bonne intervention | ⏳ logs posés, à confirmer sur base réelle |
| Création note | note créée, `CreationUser` renseigné | ✅ code corrigé |
| Compteur notes | 1 après création | ⏳ dépend #5 |
| Compteur images/documents | reflète la base | ⏳ dépend #5 + filtre catégories |
| Modèles email | liste chargée (plus de 404) | ✅ route corrigée |
| Modèles rapport | liste chargée (plus de 404) | ✅ route corrigée |
### BATIK
| Test | Attendu | Statut |
|---|---|---|
| Comportements existants | inchangés | ✅ (aucun mapping Batik modifié) |
| `ResolveCreationUserAsync` GUID synthétique | toujours décodé | ✅ (chemin conservé) |
| Routes email/rapport | fonctionnelles | ✅ (corrigées pour les deux schémas) |
### ANDROID
| Test | Attendu | Statut |
|---|---|---|
| Téléphone contact / collaborateur | composeur natif prérempli | ✅ ACTION_DIAL |
| Email contact / collaborateur | client mail prérempli | ✅ ACTION_SENDTO |
| Carte | tuiles sans 403 | ✅ User-Agent |
| Thème clair / sombre | icônes lisibles | ✅ DynamicResource |
| Petit écran | boutons wrappables, emails non tronqués | ✅ WrapPanel + TextWrapping |
## 8. Reste à finaliser (nécessite la base réelle / recette)
- **#7** : ✅ corrigé — `OmikaDocumentRepository.BuildDocumentCategory` résout désormais la clé canonique `T.DOC.*` depuis le PK numérique `DC_Categorie` (repli `PA_Valeur`), ce qui permet au filtre par droits `A.DOC.*` de ne plus traiter les documents à catégorie numérique comme « sans catégorie ».
- **#5** : ✅ corrigé — `OmikaInterventionRepository.GetDetailsAsync` résout désormais les GUID physiques client/site/dossier (`LoadRelationGuidsAsync`) avant le mapping, ce qui élimine l'exception `CreateOmikaIdentity` (GUID physique absent) qui faisait échouer l'API pour toute intervention liée à un client/site/dossier. Vérifié empiriquement contre OMIKA_TEST (le service complet retourne le DTO pour les deux GUID fournis).
- **#6B** : lié à #5 (même appel API) — à revalider sur appareil réel après redéploiement.
- **#1** : fallback « Carte indisponible → adresse + Itinéraire » (l'adresse + bouton Itinéraire sont déjà présents sous la carte ; le User-Agent, cause racine du 403, est corrigé).
- **#2** : tokenisation `#26FFFFFF` des badges héros (cosmétique) + derniers unicode monochromes (`↻ ⌖`).
- **#13** : `FilterGrid` agenda équipe (2 colonnes fixes → responsive).
## 9. Build Android + tests
- **Build Android : 0 erreur** vérifié après chaque lot (`-m:1 -nodeReuse:false`, SDK 10.0.302 via `global.json`).
- **Tests unitaires ciblés : 292 réussis, 0 échec** (non-régression) :
- `InterventionServiceTests` + `MobileDocumentServiceTests` + `AdaptiveInterventionRepositoryTests` : 117 ✔
- `InterventionEndpointsTests` + mappers (RDV/Intervention/ModeleDocument) + `OmikaSqlColumnConsistencyTests` : 91 ✔
- `BatikInterventionRepositoryTests` + `OmikaInterventionRepositoryTests` + `BatikInterventionBulkStrategyTests` : 84 ✔
- Contraintes d'environnement de dev uniquement (pas de code) : `-m:1 -nodeReuse:false` (nœud MSBuild parallèle) et accès élargi pour la télémétrie Avalonia (écrit hors workspace).
+592
View File
@@ -0,0 +1,592 @@
# T15 — RUNBOOK DÉPLOIEMENT PRODUCTION MANUEL OMIKA DIAGNOSTICS
> Statut : **PRÉPARATION (aucune action Production effectuée)**.
> Cible : Windows Server 2025 (`SRV-OMIKA`). PowerShell **administrateur**.
> Ce runbook est **manuel** : fenêtre de maintenance contrôlée (arrêt → sauvegarde →
> migration → copie → redémarrage → validation → rollback éventuel) acceptée.
>
> **RÈGLES ABSOLUES**
> 1. NE PAS exécuter le seed local (`seed_diagnostics_local.sql`) ni le clear
> (`clear_diagnostics_local_seed.sql`) en Production. Ils restent dans le dépôt
> source (développement uniquement).
> 2. NE JAMAIS écraser les configurations Production existantes (secrets).
> 3. NE JAMAIS supprimer les JSONL en attente ni remettre le checkpoint à zéro.
> 4. NE JAMAIS faire de `TRUNCATE` / `DROP TABLE` / `DELETE` d'événements en rollback.
> 5. Aucun secret n'apparaît dans ce document (placeholders `<SECRET_*>` uniquement).
> 6. Toute commande SQL de migration utilise `ON_ERROR_STOP=1` et est suivie d'une
> vérification avant de passer à la suivante.
Identifiant de livraison (convention) :
`Omika_Diagnostics_<ApplicationConstants.Version>_<YYYYMMDDHHmm>`
ex. `Omika_Diagnostics_1.0.0.0_20261001_1800`.
(`ApplicationConstants.Version = "1.0.0.0"` — pas de version inventée.)
---
## PHASE 0 — Préconditions
| # | Précondition | Où | Résultat attendu / STOP |
|---|---|---|---|
| 0.1 | Accès PowerShell **administrateur** sur SRV-OMIKA | SRV-OMIKA | `net session` OK ; sinon STOP (droits insuffisants) |
| 0.2 | Runtime .NET 10 présent | SRV-OMIKA | `dotnet --list-runtimes` montre `Microsoft.NETCore.App 10.x` et `Microsoft.AspNetCore.App 10.x`. Absent → STOP (installer le runtime d'hébergement) |
| 0.3 | `psql.exe` et `pg_dump.exe` présents | SRV-OMIKA | `C:\Program Files\PostgreSQL\18\bin\` contient `psql.exe` + `pg_dump.exe`. Absent → STOP |
| 0.4 | PostgreSQL service **Running** | SRV-OMIKA | `Get-Service postgresql*` = Running. Sinon STOP |
| 0.5 | Packages Release générés et **validés** (secret scan OK) | Poste DEV | Dossier de staging complet (4 composants + `Database` + `Deploy` + `SHA256SUMS.txt`). Sinon STOP (re-générer) |
| 0.6 | Backup PostgreSQL et binaires **réussis** (Phase 2) | SRV-OMIKA | Voir Phase 2. Échec → NO-GO |
| 0.7 | Fenêtre de maintenance validée (services arrêtables) | Exploitant | Confirmation humaine. Sinon NO-GO |
**GO uniquement si 0.1–0.7 tous OK.**
---
## PHASE 1 — Audit Production (lecture seule, aucune modification)
### 1.1 État du schéma PostgreSQL (à exécuter sur SRV-OMIKA)
```powershell
$env:PGPASSWORD = '<SECRET_POSTGRES_ADMIN>'
$psql = 'C:\Program Files\PostgreSQL\18\bin\psql.exe'
& $psql -h 127.0.0.1 -p 5432 -U postgres -d omika_diagnostics -c `
"SELECT column_name, data_type, is_nullable FROM information_schema.columns WHERE table_name='diagnostic_event' ORDER BY ordinal_position;"
& $psql -h 127.0.0.1 -p 5432 -U postgres -d omika_diagnostics -c `
"SELECT indexname FROM pg_indexes WHERE tablename='diagnostic_event' ORDER BY indexname;"
& $psql -h 127.0.0.1 -p 5432 -U postgres -d omika_diagnostics -c `
"SELECT rolname FROM pg_roles ORDER BY rolname;"
Remove-Item Env:PGPASSWORD
```
**Résultat attendu** : liste des colonnes/indexes/roles réels.
**Décisions** :
- Colonnes `client_code`, `server_instance`, `environment` présentes → **005 déjà appliqué** (ne pas réappliquer).
- Index `ix_diagnostic_event_client_code_timestamp` présent → 005 complet.
- `SELECT` + `DELETE` accordés à `omika_diagnostics_worker` sur `diagnostic_event` → **004 déjà appliqué**.
- Roles `omika_diagnostics_owner/worker/web` présents → **003 déjà appliqué**.
**STOP si** : la base `omika_diagnostics` est absente ou la connexion échoue (ne jamais la créer à l'aveugle — résoudre d'abord).
### 1.2 État des services Windows (lecture seule)
```powershell
Get-Service OmikaApi,OmikaWorker,OmikaDiagnosticsWorker,OmikaDiagnosticsWeb |
Select-Object Name,DisplayName,Status,StartType
sc.exe qc OmikaApi
sc.exe qc OmikaWorker
sc.exe qc OmikaDiagnosticsWorker
sc.exe qc OmikaDiagnosticsWeb
```
**Résultat attendu** : nom réel, chemin binaire, compte, StartupType. Notez tout écart
avec les valeurs attendues (voir section « Services Windows » du rapport).
**STOP si** : un service n'existe pas alors que la phase d'activation n'a pas été
validée (compte de service indéfini) — ne pas créer de service dans ce runbook.
### 1.3 Chemins réels des dossiers (lecture seule)
```powershell
Test-Path 'C:\Omika\Apps\OmikaApi'
Test-Path 'C:\Omika\Apps\OmikaWorker'
Test-Path 'C:\Omika\Apps\DiagnosticsWorker'
Test-Path 'C:\Omika\Apps\DiagnosticsWeb'
Test-Path 'C:\Omika\Data\Api\Diagnostics\Jsonl'
Test-Path 'C:\Omika\Data\DiagnosticsWorker\Checkpoint'
```
**Résultat attendu** : chemins confirmés. Tout chemin absent/différent → **À CONFIRMER**
et ajuster les phases suivantes (ne pas inventer).
### 1.4 JSONL en attente + checkpoint (lecture seule, ne rien modifier)
```powershell
Get-ChildItem 'C:\Omika\Data\Api\Diagnostics\Jsonl\omika-mobile-diagnostic-*.jsonl' |
Select-Object Name,Length,LastWriteTime
Get-Content 'C:\Omika\Data\DiagnosticsWorker\Checkpoint\import-checkpoint.json'
```
**Résultat attendu** : liste des JSONL en attente + contenu du checkpoint
(`{"fileName":...,"lineIndex":...}`). **Notez ces valeurs** (utiles pour la Phase 10).
**STOP si** : vous êtes tenté de supprimer un JSONL ou de réinitialiser le checkpoint —
interdit (§26).
---
## PHASE 2 — Sauvegardes
### 2.1 Sauvegarde PostgreSQL (AVANT toute migration)
```powershell
$env:PGPASSWORD = '<SECRET_POSTGRES_ADMIN>'
$stamp = Get-Date -Format 'yyyyMMdd_HHmm'
$out = "C:\Omika\Backups\omika_diagnostics_${stamp}_before_upgrade.backup"
& 'C:\Program Files\PostgreSQL\18\bin\pg_dump.exe' `
-h 127.0.0.1 -p 5432 -U postgres -d omika_diagnostics -F c -v -f $out
Remove-Item Env:PGPASSWORD
```
**Résultat attendu** : `pg_dump` se termine sans erreur (exit 0), fichier `.backup` créé.
**Vérification obligatoire (§17)** :
```powershell
$f = Get-Item $out
$f.Exists -and $f.Length -gt 0
```
**STOP / NO-GO si** : `pg_dump` échoue, exit ≠ 0, fichier absent ou taille 0.
Aucune migration ne commence tant que ce backup n'est pas vérifié.
### 2.2 Sauvegarde des binaires (AVANT remplacement)
```powershell
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$root = 'C:\Omika\Backups'
Copy-Item 'C:\Omika\Apps\OmikaApi' -Destination "$root\OmikaApi_${stamp}" -Recurse -Force
Copy-Item 'C:\Omika\Apps\OmikaWorker' -Destination "$root\OmikaWorker_${stamp}" -Recurse -Force
Copy-Item 'C:\Omika\Apps\DiagnosticsWorker' -Destination "$root\DiagnosticsWorker_${stamp}" -Recurse -Force
Copy-Item 'C:\Omika\Apps\DiagnosticsWeb' -Destination "$root\DiagnosticsWeb_${stamp}" -Recurse -Force
```
**Note** : exclure les logs très volumineux si présents sous `Apps\*` (copier uniquement
le contenu publié). Le script existant `Install-OmikaServerFiles.ps1` fait déjà cette
sauvegarde horodatée (convention `OmikaApi-yyyyMMdd-HHmmss`).
**STOP / NO-GO si** : copie incomplète (comparer le nombre de fichiers source/cible).
### 2.3 Vérification globale GO/NO-GO partiel
```text
[ ] Backup PostgreSQL présent + taille > 0
[ ] Backup binaires (4 dossiers) présent + complet
[ ] Packages Release validés (Phase 0.5)
[ ] Secrets Development absents des packages
[ ] Migrations nécessaires identifiées (Phase 1.1)
[ ] JSONL/checkpoint inventoriés (Phase 1.4)
[ ] Services arrêtables + fenêtre validée
[ ] Procédure rollback prête (Phase R)
```
Tout prérequis critique faux → **NO-GO, STOP**.
---
## PHASE 3 — Arrêt des services
Ordre retenu (justifié par le flux de données) :
| Ordre | Service | Rôle dans le flux | Pourquoi maintenant |
|---|---|---|---|
| 1 | `OmikaDiagnosticsWeb` | lecture seule (SELECT) | figer l'accès utilisateur/support d'abord |
| 2 | `OmikaApi` | écrit JSONL | figer l'écrivain JSONL #1 |
| 3 | `OmikaWorker` | métier HFSQL + écrit diagnostics | figer l'écrivain #2 |
| 4 | `OmikaDiagnosticsWorker` | import JSONL → PG | arrêté en dernier pour drainer les dernières lignes |
```powershell
Stop-Service OmikaDiagnosticsWeb -Force
Stop-Service OmikaApi -Force
Stop-Service OmikaWorker -Force
Stop-Service OmikaDiagnosticsWorker -Force
# Vérifier :
Get-Service OmikaApi,OmikaWorker,OmikaDiagnosticsWorker,OmikaDiagnosticsWeb | Select Name,Status
```
**Résultat attendu** : les 4 services = `Stopped`.
**STOP si** : un service ne s'arrête pas (résoudre — ne pas forcer le remplacement de
fichiers d'un service encore actif).
---
## PHASE 4 — Migration PostgreSQL (manuelle, auditée)
### 4.0 Principe
Appliquer **uniquement** les migrations manquantes, **dans l'ordre**, avec
`ON_ERROR_STOP=1`, et **vérifier après chaque fichier**. Toutes sont idempotentes
(`CREATE/ADD IF NOT EXISTS`, `GRANT`, `DO $$`). Ne jamais appliquer aveuglément.
Table des migrations (voir rapport section F) :
| Fichier | Objet | Idempotent | Nécessaire cette release |
|---|---|---|---|
| 001 | `diagnostic_event` + `telemetry_metric_hourly` + index | OUI | OUI (si absent) |
| 002 | `actor_key`, `actor_key_version` + index | OUI | OUI (si absent) |
| 003 | rôles least-privilege + ownership + grants | OUI | OUI (si absent) |
| 004 | `GRANT SELECT, DELETE` à `omika_diagnostics_worker` | OUI | OUI (rétention) |
| 005 | `client_code`/`server_instance`/`environment` + index | OUI | **OUI (indispensable)** |
### 4.1 Appliquer 005 (client context) — si absent
```powershell
$env:PGPASSWORD = '<SECRET_POSTGRES_ADMIN>'
& 'C:\Program Files\PostgreSQL\18\bin\psql.exe' -h 127.0.0.1 -p 5432 -U postgres `
-d omika_diagnostics -v ON_ERROR_STOP=1 `
-f '<staging>\Database\005_diagnostics_client_context.sql'
Remove-Item Env:PGPASSWORD
```
**Vérifier ensuite** (doit renvoyer les 3 colonnes + l'index) :
```sql
SELECT column_name FROM information_schema.columns
WHERE table_name='diagnostic_event'
AND column_name IN ('client_code','server_instance','environment');
SELECT indexname FROM pg_indexes
WHERE tablename='diagnostic_event' AND indexname='ix_diagnostic_event_client_code_timestamp';
```
**STOP si** : psql exit ≠ 0, ou la vérification ne renvoie pas les 3 colonnes + l'index.
### 4.2 Appliquer 004 (rétention) — si absent
```powershell
$env:PGPASSWORD = '<SECRET_POSTGRES_ADMIN>'
& 'C:\Program Files\PostgreSQL\18\bin\psql.exe' -h 127.0.0.1 -p 5432 -U postgres `
-d omika_diagnostics -v ON_ERROR_STOP=1 `
-f '<staging>\Database\004_diagnostics_retention.sql'
Remove-Item Env:PGPASSWORD
```
**Vérifier** :
```sql
SELECT has_table_privilege('omika_diagnostics_worker','diagnostic_event','SELECT') AS sel,
has_table_privilege('omika_diagnostics_worker','diagnostic_event','DELETE') AS del;
```
**Résultat attendu** : `sel=t, del=t`. Sinon STOP.
(Rôle de 004 : autoriser le `DiagnosticRetentionWorker` à purger. Sans 004, la purge
échoue mais l'import continue — fail-open ; néanmoins 004 est **requis** pour que la
rétention fonctionne réellement.)
### 4.3 Appliquer 003 (rôles) — si absent (contexte superuser, cf. `Harden-DatabaseRoles.ps1`)
À n'appliquer que si les rôles `omika_diagnostics_owner/worker/web` sont absents
(vérifié Phase 1.1). Sinon **ne pas réappliquer**. Après 003, définir les mots de passe
des rôles LOGIN **manuellement** via `\password` (jamais dans un script/log).
**STOP si** : 003 a déjà été appliqué — ne pas rejouer l'ownership inutilement.
### 4.4 Règle d'ordre Migration ↔ Binaires (§27)
**La migration 005 doit être appliquée AVANT le déploiement des nouveaux binaires.**
Justification : les nouveaux binaires (Web + Worker) lisent `client_code`/`server_instance`/
`environment`. Anciens binaires + nouveau schéma = compatible (colonnes additives
nullable, ignorées) ; **nouveaux binaires + ancien schéma = échec des requêtes**
(colonnes absentes). L'ordre sûr est donc : **migration d'abord, binaires ensuite**.
---
## PHASE 5 — Déploiement des binaires (copie)
### 5.1 Vérifier l'intégrité du package (SHA256)
```powershell
cd '<staging>'
# Install-OmikaServerFiles.ps1 fait cette vérification automatiquement.
Get-Content SHA256SUMS.txt | Select-Object -First 5 # aperçu
```
**Résultat attendu** : `SHA256SUMS.txt` présent et chaque empreinte correspond au fichier.
**STOP si** : un hash diffère ou un fichier manque.
### 5.2 Copie avec préservation de la configuration
Deux options (choisir **une seule**) :
**Option A — script existant (recommandé, idempotent, `-WhatIf` possible) :**
```powershell
cd '<staging>\Deploy'
.\Install-OmikaServerFiles.ps1 -PackageRoot '<staging>'
```
Ce script sauvegarde l'ancienne version, copie, **préserve** `appsettings.json` et
`appsettings.Production.json` du serveur, puis vérifie le nombre de fichiers et les EXE.
**Option B — copie manuelle (si le script ne convient pas) :**
```powershell
Copy-Item '<staging>\OmikaApi\*' 'C:\Omika\Apps\OmikaApi' -Recurse -Force
Copy-Item '<staging>\OmikaWorker\*' 'C:\Omika\Apps\OmikaWorker' -Recurse -Force
Copy-Item '<staging>\DiagnosticsWorker\*' 'C:\Omika\Apps\DiagnosticsWorker' -Recurse -Force
Copy-Item '<staging>\DiagnosticsWeb\*' 'C:\Omika\Apps\DiagnosticsWeb' -Recurse -Force
```
**STOP si** : le nombre de fichiers cible < source (copie incomplète) ou un EXE attendu
est absent (`Omika.Api.exe`, `Omika.Worker.exe`, `Omika.Diagnostics.Worker.exe`,
`Omika.Diagnostics.Web.exe`).
### 5.3 Ce qui ne doit JAMAIS être écrasé (§19)
- `appsettings.json` / `appsettings.Production.json` de chaque composant (secrets serveur).
- Variables d'environnement MACHINE (secrets) : jamais touchées.
- `C:\Omika\Data\Api\Diagnostics\Jsonl\**` (JSONL en attente) : jamais supprimés.
- `C:\Omika\Data\DiagnosticsWorker\Checkpoint\import-checkpoint.json` : jamais réinitialisé.
- `C:\Omika\Config\**` (config serveur) : jamais écrasé.
- Certificats TLS : jamais touchés.
---
## PHASE 6 — Vérification des configurations (avant redémarrage)
### 6.1 Clés de configuration attendues (placeholders — injectées SUR LE SERVEUR)
**Omika.Api** (Kestrel, service `OmikaApi`) :
```
ConnectionStrings__DiagnosticsDatabase = <SECRET> (si l'API lit aussi PG — voir architecture)
DiagnosticsIdentity__HmacKey = <SECRET_SERVEUR> (ActorKey, min 256 bits Base64)
MobileAuth__SigningKey = <SECRET_SERVEUR> (obligatoire, sinon fail-fast)
DiagnosticDeployment__ClientCode = <CODE_CLIENT ou vide → fallback HFSQL CODE_CLIENT>
DiagnosticDeployment__ServerInstanceId = <ex. API-SRV-01 ou vide → MachineName>
HFSQL / LicenseConnection / ServiceConnection / FilePasswords = <SECRETS_EXISTANTS>
Observability__Remote__Directory = <CHEMIN_JSONL> (doit être identique au Worker)
Observability__External__Enabled = false (ne pas activer le forwarder)
ASPNETCORE_ENVIRONMENT = Production
```
**Omika.Worker** (service `OmikaWorker`) :
```
Hfsql / SyncConnection = <SECRETS_EXISTANTS>
DiagnosticDeployment__ClientCode = <idem Api>
DiagnosticDeployment__ServerInstanceId = <ex. WORKER-SRV-01 ou vide → MachineName>
ASPNETCORE_ENVIRONMENT = Production
```
**Omika.Diagnostics.Worker** (service `OmikaDiagnosticsWorker`) :
```
ConnectionStrings__DiagnosticsDatabase = Host=127.0.0.1;Port=5432;Database=omika_diagnostics;Username=omika_diagnostics_worker;Password=<SECRET_LOCAL>
Observability__Remote__Directory = <CHEMIN_JSONL — IDENTIQUE à l'Api>
Diagnostics__Import__CheckpointDirectory = C:\Omika\Data\DiagnosticsWorker\Checkpoint
Diagnostics__Import__CheckpointFileName = import-checkpoint.json
Diagnostics__RetentionDays = 60
Diagnostics__RetentionIntervalHours = 24
Diagnostics__RetentionBatchSize = 1000
```
(Le Worker n'enregistre pas la source HFSQL → `DiagnosticDeployment__ClientCode` est
**config-only** ici ; `ServerInstance` = MachineName si non configuré.)
**Omika.Diagnostics.Web** (service `OmikaDiagnosticsWeb`) :
```
ConnectionStrings__DiagnosticsDatabase = Host=127.0.0.1;Port=5432;Database=omika_diagnostics;Username=omika_diagnostics_web;Password=<SECRET_LOCAL>
DiagnosticsAuth__Operators = <LISTE_OPERATEURS_PRODUCTION>
ASPNETCORE_ENVIRONMENT = Production (default-deny, PAS Development)
```
### 6.2 Contrôles
```text
[ ] Observability__Remote__Directory IDENTIQUE entre OmikaApi et DiagnosticsWorker (chemin absolu)
[ ] ASPNETCORE_ENVIRONMENT = Production pour les 4 services
[ ] ConnectionStrings__DiagnosticsDatabase présent pour DiagnosticsWorker + DiagnosticsWeb
[ ] DiagnosticsIdentity__HmacKey + MobileAuth__SigningKey présents pour Omika.Api
[ ] DiagnosticDeployment__ClientCode/ServerInstanceId cohérents (ou vide → fallback)
[ ] Aucune valeur de test dev (admin-local, smtp.example.com, postgres/postgres) dans les configs serveur
[ ] appsettings.Development.json ABSENT du dossier publié de DiagnosticsWeb (vérifié)
```
**STOP si** : `MobileAuth__SigningKey` absent (l'API refuse de démarrer), ou un secret
Production a été écrasé (restaurer depuis le backup Phase 2.2).
---
## PHASE 7 — Redémarrage (ordre recommandé)
PostgreSQL **déjà Running** (vérifié Phase 0.4). Ordre = démarrer les **consommateurs
avant les producteurs** :
```powershell
Start-Service OmikaDiagnosticsWorker # 1. importeur (consommateur JSONL)
Start-Service OmikaDiagnosticsWeb # 2. UI (lecture seule)
Start-Service OmikaWorker # 3. sync métier (producteur #2)
Start-Service OmikaApi # 4. API (producteur #1, dernier)
Get-Service OmikaApi,OmikaWorker,OmikaDiagnosticsWorker,OmikaDiagnosticsWeb | Select Name,Status
```
**Résultat attendu** : les 4 services = `Running`.
**STOP si** : un service passe en `Stopped`/`Failed` immédiatement → ne pas démarrer les
suivants, passer en diagnostic (Phase 8) puis rollback (Phase R) si non résolu.
---
## PHASE 8 — Smoke tests (non destructifs)
### 8.1 Health checks par service
```text
OmikaDiagnosticsWorker : Running ; log = "Contexte de déploiement Diagnostics initialisé"
puis import/retention sans exception PostgreSQL ;
checkpoint stable ou progressant ; aucun JSONL supprimé.
OmikaDiagnosticsWeb : Running ; HTTP 200 sur la page d'accueil ; login OK ; Dashboard OK.
OmikaWorker : Running ; pas de boucle d'erreur (pas de rafale d'exceptions HFSQL).
OmikaApi : Running ; endpoint de contrôle OK ; pas d'erreur de démarrage
(fail-fast SigningKey absent = NOK).
```
```powershell
# Web (local au serveur) :
Invoke-WebRequest -UseBasicParsing http://127.0.0.1:<PORT_WEB>/ | Select StatusCode
# Logs (Event Viewer ou fichiers) :
Get-EventLog -LogName Application -Newest 50 | Where-Object { $_.Source -match 'Omika' }
```
**Résultat attendu** : aucune exception PostgreSQL, aucun HTTP 500, aucune boucle d'erreur.
### 8.2 Contrôle PostgreSQL non destructif (après redémarrage)
```sql
SELECT count(*) FROM diagnostic_event;
SELECT max(timestamp_utc) AS dernier_evenement,
max(ingested_at_utc) AS dernier_ingere
FROM diagnostic_event;
SELECT client_code, server_instance, environment, count(*)
FROM diagnostic_event
WHERE ingested_at_utc > now() - interval '1 hour'
GROUP BY client_code, server_instance, environment
ORDER BY count(*) DESC;
```
**Résultat attendu** : des événements récents (`ingested_at_utc` proche de `now()`),
avec `client_code`/`server_instance`/`environment` renseignés sur les nouveaux événements
(les anciens restent NULL — normal, pas de backfill).
### 8.3 Test ClientCode Production (§32) — sur des événements réellement générés
Ouvrir Diagnostics.Web → Dashboard → Diagnostics, puis filtrer un client réel :
```text
ClientCode : non NULL (CodeClient de l'instance)
ServerInstance : identifiant d'instance (MachineName ou override)
Environment : Production
Component : Omika.Api / Omika.Worker selon le cas
AppVersion : 1.0.0.0 (ApplicationConstants.Version)
```
**Ne pas** injecter de faux payload de test sur Production.
### 8.4 Test synchronisation (§33) — sur une synchronisation métier existante
Sur `/Synchronisation`, prendre une opération réelle existante et vérifier :
```text
OperationId présent
ClientCode renseigné
Component Omika.Worker (et Omika.App)
ServerInstance instance Worker (jamais l'instance API)
Environment Production
```
**Ne créer aucune donnée métier artificielle risquée.**
---
## PHASE 9 — Validation Diagnostics (équivalent production de la recette T14-B)
| Contrôle | Page / SQL | Attendu |
|---|---|---|
| Dashboard | `/` | les compteurs reflètent les données réelles (24h/7j/30j) |
| Diagnostics | `/Diagnostics` | filtres Client/Serveur/Environnement fonctionnels |
| Pagination | `/Diagnostics?pageNumber=2` | « page 2 / N », filtres conservés dans l'URL |
| Détail | `/Diagnostics/{id}` | contexte client/serveur/env affiché (« Non renseigné » si NULL) |
| Erreurs | `/Erreurs` | groupes par EventCode/exception, multi-clients sans casser le fingerprint |
| Traces | `/Traces` | timeline par `trace_id`, opérations par `operation_id` |
| Synchronisation | `/Synchronisation` | différenciation App/Worker (serveurs distincts) |
| Telemetry | `/Telemetry` | agrégats cohérents avec SQL |
| Versions | `/Versions` | versions réelles distinctes |
**Résultat attendu** : équivalent au verdict T14-B (avec réserves connues : responsive
statique, producteurs réels non testables). Tout écart → STOP + analyse + rollback si bloquant.
---
## PHASE 10 — Surveillance post-déploiement
Période de surveillance manuelle (durée décidée par l'opérateur, ex. 24–48 h) :
```text
[ ] Services toujours Running (Get-Service, aucune transition vers Stopped/Failed)
[ ] Pas d'explosion de fichiers JSONL (nombre/taille stables, MaxFiles non atteint en continu)
[ ] Pas d'erreurs PostgreSQL dans les logs Worker/Web
[ ] Pas de HTTP 500 sur DiagnosticsWeb
[ ] Pas d'erreurs API anormales (pas de rafale 4xx/5xx)
[ ] Pas de boucle Worker (backoff borné, pas de redémarrage en rafale)
[ ] Checkpoint import : stable ou progressant (jamais régressif)
[ ] Dashboard : événements récents présents (pas de trou d'ingestion)
```
Signaux d'alerte (→ Phase R) : service qui ne démarre pas, migration échouée, 500 Web,
API KO, Worker en boucle, import cassé, hausse anormale d'erreurs.
---
## PHASE R — Rollback
### R.1 Conditions de déclenchement (§35)
```text
service ne démarre pas / Failed
migration échoue (ON_ERROR_STOP)
DiagnosticsWeb retourne 500
API ne démarre plus (fail-fast SigningKey, etc.)
Worker entre en boucle d'erreur
import Diagnostics cassé (checkpoint bloqué, erreurs Npgsql)
augmentation anormale d'erreurs
```
Dès qu'une condition est réunie : **STOP** puis **ROLLBACK**.
### R.2 Rollback binaires (§36)
```powershell
Stop-Service OmikaApi,OmikaWorker,OmikaDiagnosticsWorker,OmikaDiagnosticsWeb -Force
$stamp = '<horodatage_du_backup>' # ex. 20261001-180000
# Restaurer les dossiers précédents :
Remove-Item 'C:\Omika\Apps\OmikaApi' -Recurse -Force
Copy-Item "C:\Omika\Backups\OmikaApi_${stamp}" 'C:\Omika\Apps\OmikaApi' -Recurse -Force
Copy-Item "C:\Omika\Backups\OmikaWorker_${stamp}" 'C:\Omika\Apps\OmikaWorker' -Recurse -Force
Copy-Item "C:\Omika\Backups\DiagnosticsWorker_${stamp}" 'C:\Omika\Apps\DiagnosticsWorker' -Recurse -Force
Copy-Item "C:\Omika\Backups\DiagnosticsWeb_${stamp}" 'C:\Omika\Apps\DiagnosticsWeb' -Recurse -Force
# Vérifier la config préservée, puis :
Start-Service OmikaDiagnosticsWorker
Start-Service OmikaDiagnosticsWeb
Start-Service OmikaWorker
Start-Service OmikaApi
Get-Service OmikaApi,OmikaWorker,OmikaDiagnosticsWorker,OmikaDiagnosticsWeb | Select Name,Status
```
**Résultat attendu** : 4 services Running avec la version précédente.
**Préserver** : `appsettings*.json`, JSONL, checkpoint (jamais supprimés par ce rollback).
### R.3 Rollback base de données (§37/§38)
**Décision documentée : PAS de rollback SQL automatique.**
- 004/005 sont **additifs** (colonnes nullable + index + GRANT). Les anciens binaires
restent **compatibles** avec ces colonnes (ils ne les lisent pas).
- Aucun `DROP COLUMN`, aucun `DROP INDEX`, aucun `REVOKE` automatique.
- **Ne jamais** `TRUNCATE` / `DROP TABLE` / `DELETE` les événements créés après migration.
```text
Rollback privilégié : rollback APPLICATIF (binaires) + laisser la migration additive en place.
```
Restaurer la base via `pg_restore` est réservé au cas exceptionnel où une migration
aurait été destructrice (impossible ici) ou mal appliquée — et uniquement sur décision
humaine explicite, en restaurant le backup Phase 2.1 :
```powershell
& 'C:\Program Files\PostgreSQL\18\bin\pg_restore.exe' `
-h 127.0.0.1 -p 5432 -U postgres -d omika_diagnostics -c `
'C:\Omika\Backups\omika_diagnostics_<stamp>_before_upgrade.backup'
```
(Avertissement : `-c` supprime les objets existants — à n'utiliser qu'en dernier recours
et sur décision explicite. Jamais en rollback de routine.)
---
## Checklist GO / NO-GO finale (avant exécution)
```text
[ ] Backup PostgreSQL terminé et vérifié (présent + taille > 0)
[ ] Backup binaires (4 composants) terminé et vérifié
[ ] Packages Release validés (4 composants + Database + Deploy + SHA256SUMS)
[ ] Secrets Development absents des packages (appsettings.Development.json absent)
[ ] Migrations nécessaires identifiées (Phase 1.1 : 003/004/005 manquants ou non)
[ ] JSONL/checkpoint sauvegardés et inventoriés (jamais supprimés/réinitialisés)
[ ] Services arrêtables + fenêtre de maintenance validée
[ ] Ordre d'arrêt / redémarrage compris et validé
[ ] Procédure rollback prête (binaires + décision DB additive)
[ ] Commandes vérifiées (psql/pg_dump/copy paths confirmés)
[ ] Fenêtre de maintenance approuvée par l'exploitant
```
Un prérequis critique faux → **NO-GO**.
---
## Dettes restant hors périmètre (§43)
- `Install-DatabaseSchema.ps1` n'applique que **001 + 002** ; **004 et 005 ne sont
couverts par aucun script** (application manuelle documentée dans ce runbook). Dette
connue — pas corrigée dans T15.
- `Harden-DatabaseRoles.ps1` n'applique que **003**.
- Pas de DeviceInfo, pas de télémétrie d'écrans, pas de nouveaux EventCodes, pas de
refonte UI/dashboard — hors périmètre.
+326
View File
@@ -0,0 +1,326 @@
# T23 — GESTION UTILISATEURS DIAGNOSTICS.WEB
> Statut : **T23 VALIDÉ — AUTHENTIFICATION DIAGNOSTICS.WEB PRÊTE POUR PRODUCTION**
> Aucun Git. Aucune Production. Identités de recette : `admin-test` / `support-test` (jetables, préprod).
---
## 1. Résumé exécutif
T23 ajoute la gestion complète des **utilisateurs** de `Omika.Diagnostics.Web` (personnes qui
ouvrent l'interface), distincte des **clients** Diagnostics (serveurs, `ClientCode`+`ApiKey`).
Authentification hybride : utilisateurs PostgreSQL `diagnostics_users` en priorité, sinon
opérateurs bootstrap `DiagnosticsAuth:Operators`. Hash PBKDF2-SHA256 réutilisé
(`DiagnosticPasswordHasher`), jamais de mot de passe en clair, page `/Users` (Admin uniquement),
page `/Account` (Mon compte), protections (dernier Admin, auto-*, brute-force, timing, cookie,
CSRF). Recette live 33/33 PASS, migration 010 idempotente, permissions négatives confirmées,
`Diagnostics.Tests` 536/536, `Omika.Tests` 3367/3370 (identique baseline), builds Release 0 erreur.
---
## 2. Authentification avant T23
- Cookie auth ASP.NET Core (`AddCookie`) ; opérateurs injectés via `DiagnosticsAuth:Operators`
(nom + `PasswordHash` PBKDF2 + rôles), aucune identité DB.
- `DiagnosticPasswordHasher` (PBKDF2-SHA256, 100 000 itérations, sel 32 o, clé 32 o, comparaison
temps constant) et `DiagnosticOperatorAuthenticator` (échec générique).
- Rôles `Support`/`Admin` + policies `DiagnosticsSupport` (Support OU Admin) / `DiagnosticsAdmin`
(Admin seul). Conventions : dossier `/` → Support ; `/System`, `/Clients` → Admin.
- Connexion dev sans mot de passe (Development uniquement) ; opérateur `admin-local` en Development.
## 3. Architecture finale authentification
```
Login (POST /Login)
→ LoginThrottle (5 échecs/5 min → blocage 10 min, clé username+IP)
→ DiagnosticUserAuthenticator.AuthenticateAsync
1. DB : GetByUsernameAsync (insensible casse) → Verify PBKDF2 + enabled
(DB autoritaire, jamais de fallback si username DB existe)
2. sinon opérateurs DiagnosticsAuth:Operators (bootstrap)
3. sinon hash factice (anti-timing) → échec générique
→ succès DB : SignInAsync (claims Name + Role + user_source=db) + UpdateLastLoginAsync (best-effort)
Cookie : HttpOnly / SameSite=Lax / Secure=Always (hors Dev) / 12 h glissante
→ OnValidatePrincipal : si user_source=db, re-vérifie enabled (~60 s de cache mémoire)
```
## 4. Migration 010
`infra/postgresql/010_diagnostics_web_users.sql` — idempotente (CREATE TABLE IF NOT EXISTS +
CREATE UNIQUE INDEX IF NOT EXISTS + GRANT), non destructive, appliquée **deux fois** : 0 erreur.
## 5. Table diagnostics_users
`id BIGSERIAL PK`, `username TEXT NOT NULL`, `password_hash TEXT NOT NULL`, `role TEXT NOT NULL`,
`enabled BOOLEAN DEFAULT TRUE`, `created_at_utc`, `updated_at_utc`, `password_changed_at_utc`,
`last_login_at_utc` (TIMESTAMPTZ). Unicité insensible à la casse : index `LOWER(username)`.
Aucune colonne `password`/`plain_password`/`clear_password`.
## 6. Ownership / GRANT PostgreSQL
- Owner : `omika_diagnostics_owner` (table + séquence).
- `REVOKE ALL FROM PUBLIC`.
- `omika_diagnostics_web` : `SELECT`, `INSERT`, `UPDATE(password_hash, role, enabled,
updated_at_utc, password_changed_at_utc, last_login_at_utc)`, `USAGE` séquence.
- `omika_diagnostics_api` et `omika_diagnostics_worker` : **aucun** droit.
## 7. Permissions négatives (testé réellement sur `omika_diagnostics_preprod`)
| Rôle | SELECT diagnostics_users | résultat |
|---|---|---|
| `omika_diagnostics_api` | refusé (`droit refusé`) | ✅ |
| `omika_diagnostics_worker` | refusé (`droit refusé`) | ✅ |
| `omika_diagnostics_web` | OK (SELECT/INSERT/UPDATE) | ✅ |
| `omika_diagnostics_web` DELETE | refusé (pas de DELETE) | ✅ |
## 8. DiagnosticPasswordHasher
Réutilisé tel quel (aucun nouvel algorithme). Ajout de `DummyHash` (hash factice, format valide)
pour l'égalisation de temps lors d'un username inconnu. Les 4 tests existants restent verts.
## 9. Login hybride
`DiagnosticUserAuthenticator.AuthenticateAsync` : DB prioritaire (§36), fallback opérateurs,
échec générique « Identifiants invalides. » (jamais « inconnu »/« désactivé »/« mauvais mot de
passe »). Testé : DB correct ✅, DB faux ✅, DB désactivé ✅, opérateur fallback ✅, DB autoritaire
(DB désactivé + opérateur même nom → refus) ✅, inconnu ✅.
## 10. Bootstrap config
`DiagnosticsAuth:Operators` conservé (fail-closed si vide). Outil hors-ligne
`tools/Omika.DiagnosticHashTool` (`<password>` ou `--stdin`) génère le hash PBKDF2. Aucun endpoint
Web anonyme de génération de hash.
## 11. Login utilisateur DB
`Verify(password, password_hash)` puis contrôle `enabled` ; `last_login_at_utc` mis à jour
(best-effort) après succès. Vérifié en DB : `last_login_at_utc` renseigné après login.
## 12. Page Utilisateurs
`/Users` (« Utilisateurs ») Admin uniquement, dans la zone Administration à côté de
« Clients Diagnostics ».
## 13. Création
Formulaire username / password / confirmation / rôle. Le Web hache automatiquement
(`DiagnosticPasswordHasher.Hash`) puis stocke uniquement `password_hash`. L'Admin ne génère/copie
jamais de hash.
## 14. Validation username
Obligatoire, trim, unicité insensible à la casse, ≤ 64, `lettres/chiffres/./-/_`.
## 15. Validation password
Non vide, ≥ 12 caractères, confirmation identique. Pas de politique de complexité superflue.
## 16. Support
Dashboard, Diagnostics, Erreurs, Traces, Synchronisation, Telemetry, Parc, Versions, Référentiel,
Mon compte. (Audité : inchangé, `IsSupport` = Support OU Admin.)
## 17. Admin
Tout Support + Clients Diagnostics + Utilisateurs + Système.
## 18. Matrice autorisations (testée avec deux comptes distincts)
| Page | Support | Admin |
| ------------------- | :-----: | :---: |
| Dashboard | ✅ | ✅ |
| Diagnostics | ✅ | ✅ |
| Erreurs | ✅ | ✅ |
| Traces | ✅ | ✅ |
| Synchronisation | ✅ | ✅ |
| Telemetry | ✅ | ✅ |
| Parc | ✅ | ✅ |
| Versions | ✅ | ✅ |
| Référentiel | ✅ | ✅ |
| System | ❌ | ✅ |
| Clients Diagnostics | ❌ | ✅ |
| Utilisateurs | ❌ | ✅ |
## 19. System
Audité : `/System` affiche l'état opérationnel (PostgreSQL, checkpoint d'import, info API).
Décision : **Admin uniquement** (cohérent avec l'information opérationnelle affichée). Inchangé.
## 20. Activation/désactivation
`enabled=true/false` (pas de DELETE en V1). Bouton « Désactiver »/« Réactiver ».
## 21. Changement rôle
`Support → Admin` / `Admin → Support` (select + « Changer rôle »).
## 22. Dernier Admin
Impossible de désactiver/rétrograder le dernier Admin actif
(« Impossible de retirer le dernier administrateur actif. »). Testé réellement.
## 23. Auto-désactivation / rétrogradation
Un Admin ne peut ni désactiver son propre compte, ni retirer son propre rôle Admin. Testé.
## 24. Reset mot de passe
Action « Réinitialiser le mot de passe » (nouveau + confirmation) → `Hash` + UPDATE
`password_hash` + `password_changed_at_utc` + `updated_at_utc`.
## 25. Mon compte
Page `/Account`, accessible à tout utilisateur authentifié (Support + Admin).
## 26. Changement mot de passe
`Mot de passe actuel` (vérifié réellement via `Verify`), `nouveau`, `confirmation` → `Hash(nouveau)`
+ UPDATE. Pour un opérateur bootstrap : non modifiable (notice).
## 27. Session utilisateur désactivé
`OnValidatePrincipal` re-vérifie `enabled` pour les utilisateurs DB, à fréquence bornée
(~60 s, cache mémoire mono-instance). Testé : cookie existant rejeté (302 → /Login) à la
re-vérification. Documenté : au pire ~1 minute de grâce, jamais « plusieurs jours ».
## 28. Last login
`last_login_at_utc` mis à jour après login DB réussi (best-effort). Vérifié en DB.
## 29. Mauvais login
Message générique « Identifiants invalides. » pour username inconnu / mauvais password / désactivé.
Aucune fuite « existe » / « désactivé ». Testé.
## 30. Brute force
`LoginThrottle` (in-memory, mono-instance) : 5 échecs / 5 min → blocage 10 min (clé username+IP).
Pas de Redis. Unit-testé.
## 31. Timing login
Hash factice (`DiagnosticPasswordHasher.DummyHash`) quand username inconnu → même coût PBKDF2 que
pour un vrai hash (même principe ApiKey). Résiduel documenté : entrées vides ne paient pas le hash.
## 32. Cookie
`HttpOnly=true`, `SameSite=Lax`, `SecurePolicy=Always` hors Développement (Production HTTPS),
`ExpireTimeSpan=12h`, `SlidingExpiration=true`.
## 33. CSRF
Toutes les actions sensibles (Create/Disable/Enable/ChangeRole/ResetPassword/ChangeOwnPassword/
Login/Logout) utilisent l'antiforgery Razor Pages (FormTagHelper injecte le token ; validation
automatique des handlers POST).
## 34. Sécurité HTML
Scan du HTML rendu `/Users` : **0** `password_hash`, **0** hash PBKDF2 complet, **0** secret.
Les `<input type=password>` ne sont jamais préremplis. Testé.
## 35. Sécurité PostgreSQL
Scan `diagnostics_users` : colonnes attendues uniquement, **0** colonne `password`/`plain_password`/
`clear_password`, tous les `password_hash` commencent par `100000.` (PBKDF2). Testé.
## 36. Tests admin-test
login OK ; Dashboard OK ; Diagnostics OK ; Clients OK ; Users OK.
## 37. Tests support-test
login OK ; Dashboard OK ; Diagnostics OK ; Telemetry OK ; Parc OK ; Clients → refus
(302 AccessDenied) ; Users → refus (302 AccessDenied).
## 38. Tests automatisés
+73 tests (5 fichiers) : validateurs/guard, authentificateur hybride, throttle, PageModels
(Users + Account), matrice autorisations, hash factice, FakeDiagnosticUserRepository.
## 39. Diagnostics.Tests
**536/536** (baseline T22 : 463/463). Tous les anciens tests passent.
## 40. Omika.Tests
**3367/3370** en Debug — **identique à la baseline T22** (« 3 échecs connus »). Aucune nouvelle
régression. Les 3 échecs Debug sont : 2 Avalonia headless (`InterventionsViewConstructionTests`)
+ 1 HFSQL/OLE DB (`ResolveAsync_Batik_...`) nécessitant un serveur HFSQL réel, tous hors périmètre
T23. (Note : en **Release**, 5 `DevelopmentLicenseTests` échouent en plus car le bypass dev est
compilé hors `#if DEBUG` — comportement attendu, pas une régression.)
## 41. Builds
`Omika.Diagnostics.Core`, `.Data`, `.Web`, `.Api`, `.Worker` + `Omika.Api` en **Release** :
**0 erreur**.
## 42. Documentation
`docs/DEPLOIEMENT_PRODUCTION_DIAGNOSTICS.md` : ajout §21 (gestion utilisateurs), §22 (bootstrap
premier Admin + procédure 12 étapes), mise à jour §11 (rôles) et §12 (inventaire 001→010).
## 43. Procédure bootstrap Production
12 étapes documentées (§22.2) : déployer → opérateur bootstrap temporaire (hash seul) → démarrer →
se connecter → `/Users` → créer Admin DB → se déconnecter → tester Admin DB → retirer bootstrap →
redémarrer → confirmer bootstrap refusé → confirmer Admin DB fonctionnel.
## 44. Ordre migrations 001→010
`001 → 002 → 003 → 004 → 005 → 006 → 007 → 008 → 009 → 010`. Installation neuve : appliquer
**toutes** dans l'ordre ; installation existante : uniquement les manquantes, dans l'ordre.
## 45. Nettoyage scratch
`_v/` (outil scratch SQLite, non référencé par aucun projet/solution) supprimé proprement après
vérification (0 référence).
## 46. Fichiers créés
- `infra/postgresql/010_diagnostics_web_users.sql`
- `src/Omika.Diagnostics.Core/Users/DiagnosticUserRecord.cs`
- `src/Omika.Diagnostics.Core/Users/IDiagnosticUserRepository.cs`
- `src/Omika.Diagnostics.Core/Security/DiagnosticUserPolicy.cs`
- `src/Omika.Diagnostics.Core/Security/DiagnosticUserAuthenticator.cs`
- `src/Omika.Diagnostics.Data/NpgsqlDiagnosticUserRepository.cs`
- `src/Omika.Diagnostics.Web/Authorization/DiagnosticCookieValidator.cs`
- `src/Omika.Diagnostics.Web/Authorization/LoginThrottle.cs`
- `src/Omika.Diagnostics.Web/Pages/Users.cshtml` + `Users.cshtml.cs`
- `src/Omika.Diagnostics.Web/Pages/Account.cshtml` + `Account.cshtml.cs`
- `tools/Omika.DiagnosticHashTool/Omika.DiagnosticHashTool.csproj` + `Program.cs`
- `tests/Omika.Diagnostics.Tests/Fakes/FakeDiagnosticUserRepository.cs`
- `tests/Omika.Diagnostics.Tests/DiagnosticUserPolicyTests.cs`
- `tests/Omika.Diagnostics.Tests/DiagnosticUserAuthenticatorTests.cs`
- `tests/Omika.Diagnostics.Tests/LoginThrottleTests.cs`
- `tests/Omika.Diagnostics.Tests/DiagnosticUserPageModelTests.cs`
- `tests/Omika.Diagnostics.Tests/DiagnosticAuthorizationMatrixTests.cs`
## 47. Fichiers modifiés
- `src/Omika.Diagnostics.Core/Security/DiagnosticPasswordHasher.cs` (ajout `DummyHash`)
- `src/Omika.Diagnostics.Data/DiagnosticDataServiceCollectionExtensions.cs` (DI users)
- `src/Omika.Diagnostics.Web/Program.cs` (throttle, cookie validator, `/Users` Admin)
- `src/Omika.Diagnostics.Web/Pages/Login.cshtml.cs` (hybride + throttle + last login + message)
- `src/Omika.Diagnostics.Web/Pages/Shared/_Layout.cshtml` (menu Utilisateurs + Mon compte)
- `docs/DEPLOIEMENT_PRODUCTION_DIAGNOSTICS.md`
- supprimé : `_v/`
## 48. Bugs trouvés
- **BLOCKER** : aucun.
- **MAJEUR** : aucun.
- **MINEUR** : (1) PostgreSQL down → les utilisateurs DB retombent silencieusement sur les
opérateurs config (fail-open cohérent avec l'outil, documenté) ; un opérateur bootstrap portant
le même username qu'un utilisateur DB ne pourrait être distingué que DB indisponible.
(2) Grâce ~60 s avant re-déconnexion d'un utilisateur désactivé (cache mémoire, documenté).
- **COSMÉTIQUE** : lien « Mon compte » réutilise la classe `.nav-user` (style minimal).
## 49. Actions restantes avant Production
- Créer la **vraie** identité Admin Production (jamais dans ce rapport) via la procédure §22.
- Poser les vrais mots de passe PostgreSQL des rôles serveur (`\password`, jamais en script/log).
- Configurer les URLs/ports HTTPS + reverse proxy Apache réels.
- Publier et déployer les services (hors périmètre T23, à valider séparément).
## 50. Décision
**T23 VALIDÉ — AUTHENTIFICATION DIAGNOSTICS.WEB PRÊTE POUR PRODUCTION**
+360
View File
@@ -0,0 +1,360 @@
# Traduction hors ligne — application mobile Omika
> **Mise en place côté mobile** de la traduction hors ligne : langue secondaire,
> téléchargement des modèles, dictée traduite et bouton « Traduire ».
> Le service serveur qui distribue les modèles est décrit dans
> `DEPLOIEMENT_API_LANGUES.md` (à lire **avant** ce document).
> **Cible** : `Omika.App` (code partagé), têtes `Omika.App.Android` /
> `Omika.App.iOS`, socle local `Omika.App.Local`.
---
## 1. Règles absolues (non négociables)
1. **La langue métier reste le français.** Tout ce qui est enregistré, mis dans
l'Outbox, synchronisé ou stocké dans HFSQL est du **français**. Une dictée en
langue secondaire est traduite **avant** d'entrer dans le champ : le texte
d'origine n'est jamais conservé.
2. **Le texte ne sort jamais de l'appareil.** Aucun appel réseau n'est fait pour
traduire : l'API des langues ne sert qu'à **descendre** des modèles.
3. **La traduction de lecture est un affichage seul.** Le texte traduit d'un
contenu (note, description) n'est écrit ni dans SQLite, ni dans l'Outbox, ni
vers l'API : il disparaît dès que le contenu change ou que l'écran est quitté.
4. **Aucune préférence de langue n'est envoyée au serveur.** La langue secondaire
est une préférence **locale par utilisateur**, au même titre que la pause
déjeuner (table technique `PreferencesUtilisateur`).
---
## 2. Flux d'ensemble
```
┌──────────────────────────────────────────┐
HFSQL │ ParametreSysteme PAS_Code = SRV_LANGUES │
(serveur) └───────────────────┬──────────────────────┘
│ bootstrap
▼
┌──────────────────────────────────────────┐
Omika.Api │ GET /api/mobile/bootstrap │
│ → MobileConfigurationDto.LanguesUrl │
└───────────────────┬──────────────────────┘
│
▼
┌──────────────────────────────────────────┐
Omika.Langues.Api│ GET /api/langues (manifeste) │
(anonyme) │ GET /api/langues/{code}/{sens}/{fichier} │
└───────────────────┬──────────────────────┘
│ téléchargement + SHA-256
▼
Appareil ┌──────────────────────────────────────────┐
│ Omika/langues/{code}/pack.json + fichiers │
└───────────────────┬──────────────────────┘
│
┌───────────────────┴──────────────────────┐
▼ ▼
Dictée traduite (es → fr) Bouton « Traduire » (fr → es)
champ de saisie contenu en lecture seule
```
---
## 3. Fichiers créés ou modifiés
### 3.1 Socle local — `src/Omika.App.Local`
| Fichier | Rôle |
|---|---|
| `Models/LangueSecondairePreference.cs` | Préférence de langue secondaire (code ISO + libellé) |
| `Models/SensTraduction.cs` | Sens de traduction (`versFrancais` / `depuisFrancais`) |
| `Models/LangueDisponible.cs` | Langue proposée + état d'installation ; `PackLangueInstalle` (`pack.json`) |
| `Abstractions/ILangueSecondairePreferenceService.cs` | Lecture/écriture de la préférence |
| `Services/LangueSecondairePreferenceService.cs` | Implémentation (SQLite, par utilisateur) |
| `Abstractions/ILanguagePackService.cs` | Manifeste, téléchargement, suppression, `ProgressionTelechargement` |
| `Abstractions/ILanguagePackStore.cs` | Dépôt local (marqueurs, empreintes, chemins) |
| `Services/LanguagePackService.cs` | Téléchargement + vérification SHA-256 + marquage |
| `Services/LanguagePackStore.cs` | Écriture/lecture disque, calcul SHA-256 |
| `Infrastructure/LanguagePaths.cs` | Arborescence locale + assainissement des codes/noms |
| `Infrastructure/LanguageTag.cs` | Code ISO (`es`) → culture BCP-47 (`es-ES`) pour la dictée |
| `Abstractions/ITranslationService.cs` | Traduction hors ligne + `TranslationException` |
| `Abstractions/IOnnxInferenceSessionFactory.cs` | **Seam** avec ONNX Runtime |
| `Abstractions/OnnxTensor.cs` | Tenseur minimal (entiers / réels) |
| `Abstractions/ITextTokenizer.cs` | Tokenisation (`EncodeAsync` / `Decode`) |
| `Services/SentencePieceTokenizer.cs` | Tokeniseur SentencePiece (UNIGRAM/BPE), lecture protobuf autonome |
| `Services/OnnxTranslationService.cs` | Pipeline complet + `OnnxTranslationOptions` |
| `Services/UnavailableOnnxInferenceSessionFactory.cs` | Repli : traduction « indisponible » |
| `Services/OnnxRuntimeInferenceSessionFactory.cs` | **Moteur réel — compilé seulement sous `OMIKA_ONNX_RUNTIME`** |
| `Abstractions/IDicteeTraduiteService.cs` | Orchestration de la dictée traduite + `DicteeTraduiteService` |
Fichiers modifiés : `Sqlite/SqliteSchema.cs` (2 colonnes + 2 migrations),
`Repositories/PreferencesUtilisateurRepository.cs`,
`Abstractions/IPreferencesUtilisateurRepository.cs`,
`Abstractions/IDictationService.cs` (**langue de dictée** + messages),
`Infrastructure/` (inchangé par ailleurs).
### 3.2 Application — `src/Omika.App`
| Fichier | Rôle |
|---|---|
| `Services/PlatformDicteeTraduite.cs` | Injection des contrôles + cache d'état (invalidable) |
| `ViewModels/LangueOptionViewModel.cs` | Ligne de langue (état, progression, actions) |
| `ViewModels/OnboardingViewModel.cs` | Écran d'accueil de première inscription |
| `Views/OnboardingView.axaml(.cs)` | Vue de l'accueil (3 étapes) |
| `Controls/TranslateButton.axaml(.cs)` | Bouton « Traduire » des contenus en lecture seule |
Fichiers modifiés : `AppServices.cs` (composition root), `Navigation/PageViewModelFactory.cs`,
`ViewModels/ParametresViewModel.cs`, `Views/ParametresView.axaml`,
`ViewModels/RootViewModel.cs` (étape d'accueil),
`Controls/DictationTextField.axaml(.cs)` (panneau de dictée + bascule),
`Views/InterventionNotesView.axaml`, `Views/InterventionNotesAdministrativesView.axaml`,
`Views/InterventionDetailView.axaml`, `Views/RendezVousDetailView.axaml`.
### 3.3 Têtes plateformes
| Fichier | Modification |
|---|---|
| `src/Omika.App.Android/AndroidDictationService.cs` | `StartAsync(codeLangue)` → `LanguageTag.DepuisCode` |
| `src/Omika.App.iOS/IosDictationService.cs` | idem (`NSLocale.FromLocaleIdentifier`) |
| `src/Omika.App/Services/UnavailableDictationService.cs` | signature alignée |
---
## 4. Stockage local des modèles
```
<données application>/Omika/langues/
├── index.json dernier manifeste serveur connu (liste hors ligne)
├── es/
│ ├── pack.json pack INSTALLÉ (version + empreintes)
│ ├── versFrancais/ es -> fr
│ │ ├── encoder_model.onnx
│ │ ├── decoder_model.onnx
│ │ ├── source.spm vocabulaire ESPAGNOL
│ │ └── target.spm vocabulaire FRANÇAIS
│ └── depuisFrancais/ fr -> es
│ ├── encoder_model.onnx
│ ├── decoder_model.onnx
│ ├── source.spm vocabulaire FRANÇAIS
│ └── target.spm vocabulaire ESPAGNOL
└── pt/ ...
```
> **Deux vocabulaires par sens.** Un modèle Marian/OPUS-MT a un vocabulaire par
> langue : `source.spm` (entrée) et `target.spm` (sortie). Les livrer **tous les
> deux** est obligatoire — décoder la sortie avec le vocabulaire d'entrée produit
> des identifiants interprétés dans le mauvais référentiel, donc du texte sans
> aucun sens. Un modèle à vocabulaire **partagé** (NLLB, mBART, M2M-100) n'a
> besoin que d'un `vocab.spm`, qui sert alors aux deux sens. Le service résout
> ces noms seul, sans configuration.
>
> Ne **jamais** livrer `decoder_with_past_model.onnx` (décodeur à cache
> `past_key_values`) : il est ignoré par le service.
- `<données application>` = sandbox interne Android / dossier `Library` iOS
(voir `MobileStoragePaths.DataDirectory`).
- **`pack.json` est écrit en DERNIER**, après vérification de **tous** les
fichiers : sa présence est la preuve que le pack est complet et intègre.
- Un téléchargement interrompu laisse des fichiers `.part` — jamais promus — et
**aucun** `pack.json` : le pack n'est pas « installé à moitié ».
- Un retéléchargement **ignore** les fichiers déjà présents et conformes
(empreinte recalculée), donc reprendre après une coupure ne reconsomme pas de
données mobiles.
---
## 5. Intégrité et sécurité du téléchargement
1. **Assainissement.** Le code de langue est validé
(`LanguagePaths.EstCodeValide` : minuscules ASCII, chiffres, tiret) et le nom
de fichier est réduit à son nom simple (`Path.GetFileName`) : un manifeste
serveur ne peut jamais faire écrire hors du dossier de la langue.
2. **Empreinte obligatoire.** Un fichier du manifeste sans `sha256` fait
**refuser tout le pack** : mieux vaut ne rien installer que d'installer un
modèle non vérifiable. L'empreinte est **recalculée** sur les octets reçus —
jamais déduite de la taille ni d'un en-tête HTTP.
3. **Renommage atomique.** Le fichier n'apparaît à sa place définitive
(`File.Move` dans le même dossier) qu'après vérification réussie. Un fichier
non conforme est **supprimé**.
4. **Délais.** Aucun délai global sur le client HTTP (un modèle de 120 Mo sur
réseau mobile dépasse largement les 100 s par défaut). La robustesse vient
d'un **chien de garde d'inactivité** de 60 s, réarmé à chaque octet reçu :
une connexion lente mais qui progresse n'est jamais interrompue.
5. **API anonyme.** Aucun jeton n'est envoyé à l'API des langues.
---
## 6. Base de données locale
Deux colonnes ajoutées à la table technique `PreferencesUtilisateur` :
| Colonne | Type | Rôle |
|---|---|---|
| `LangueSecondaire` | `TEXT NULL` | Code ISO de la langue secondaire (`es`, `pt`…), `NULL` = aucune |
| `LangueSecondaireLibelle` | `TEXT NULL` | Libellé affichable, conservé pour l'écran Paramètres **hors ligne** |
- Ajout **idempotent** via `SqliteSchema.ColumnMigrations` : une base déjà
déployée reçoit les colonnes **sans perdre** les horaires de pause déjeuner.
- « Aucune langue secondaire » est représenté par `NULL`, **jamais** par une
chaîne vide (qui serait relue comme un code de langue valide).
---
## 7. Activation du moteur d'inférence ONNX Runtime
> **État actuel : le moteur n'est PAS embarqué.** L'application compile, se
> déploie, télécharge et vérifie les modèles ; la traduction est annoncée comme
> **indisponible** (`UnavailableOnnxInferenceSessionFactory`) au lieu d'échouer
> silencieusement.
**Pourquoi** : le paquet `Microsoft.ML.OnnxRuntime` embarque une bibliothèque
native par architecture (Android arm64/x86_64, iOS, win-x64) et pèse plusieurs
dizaines de Mo par tête. L'activer est une décision de **poids de livrable**, pas
une décision de code : c'est pourquoi le point de contact est isolé dans un seul
fichier.
**Activation — 3 modifications, aucune ligne de code à écrire :**
1. `Directory.Packages.props` :
```xml
<PackageVersion Include="Microsoft.ML.OnnxRuntime" Version="1.20.1" />
```
2. `src/Omika.App.Local/Omika.App.Local.csproj` :
```xml
<ItemGroup>
<PackageReference Include="Microsoft.ML.OnnxRuntime" />
</ItemGroup>
<PropertyGroup>
<DefineConstants>$(DefineConstants);OMIKA_ONNX_RUNTIME</DefineConstants>
</PropertyGroup>
```
3. `src/Omika.App/AppServices.cs` — remplacer la fabrique par défaut :
```csharp
IOnnxInferenceSessionFactory onnxFactory = OnnxRuntimeInferenceSessionFactory.Instance;
```
Publier ensuite chaque tête avec le bon identifiant de runtime
(`-r android-arm64`, `-r ios-arm64`…) et **mesurer le poids** de l'APK/IPA.
---
## 8. Ordre de réalisation (ce qui a été fait)
| # | Étape | État |
|---|---|---|
| 1 | Préférence langue secondaire : modèle + repository + migration + écran Paramètres | ✅ |
| 2 | Pipeline ONNX minimal `OnnxTranslationService` | ✅ (moteur à activer §7) |
| 3 | Téléchargement des modèles + intégrité SHA-256 | ✅ |
| 4 | Dictée dynamique (Android/iOS) + panneau de dictée avec bascule | ✅ |
| 5 | Bouton « Traduire » + équipement des vues de contenu | ✅ |
| 6 | Écran d'accueil de première inscription | ✅ |
---
## 9. Comportement à l'écran
### 9.1 Écran Paramètres — section « Langue secondaire »
- Liste des langues du manifeste (mémoire locale d'abord : consultable **hors
connexion**), avec poids, état (« Installée (v1.0.0) », « Non installée —
122 Mo ») et mise à jour disponible.
- **Télécharger** (avec progression en Mo), **Choisir**, **Supprimer**,
**Actualiser la liste**, **Annuler le téléchargement**, **Retirer la langue**.
- Si la langue choisie n'est pas installée, un encart le dit explicitement : la
dictée traduite et le bouton « Traduire » ne peuvent pas fonctionner.
- Sans moteur d'inférence (Desktop), la section reste lisible mais explique que
la traduction est indisponible.
### 9.2 Dictée — `DictationTextField`
- **Sans langue secondaire exploitable** : comportement historique inchangé
(dictée française, insertion directe, message sous le champ).
- **Avec langue secondaire exploitable** : un **panneau de dictée** s'ouvre au
démarrage de l'écoute. Il affiche le texte reconnu en direct, la langue en
cours, et propose la **bascule `⇄`** entre français et langue secondaire (la
bascule **redémarre** l'écoute : un moteur natif ne change pas de langue en
cours de session).
- En dictée dans la langue secondaire, seul le **français traduit** entre dans le
champ ; le panneau montre « Entendu (Espagnol) » puis le résultat.
- **Si la traduction échoue**, le texte reconnu n'est pas perdu : le panneau
reste ouvert et propose « Insérer tel quel ».
- La dernière langue de dictée utilisée est mémorisée pour la session (confort de
saisie, jamais persistée).
### 9.3 Bouton « Traduire » — `TranslateButton`
- **Invisible** si l'utilisateur n'a pas de langue secondaire exploitable, ou si
le contenu est vide.
- Bascule « ⇄ Traduire » / « Voir en français ».
- Le résultat s'affiche sous le bouton, précédé de la mention
« Traduit automatiquement en Espagnol (hors ligne — affichage seul) ».
- Vues équipées : notes internes, notes administratives, description
d'intervention, description de rendez-vous.
### 9.4 Écran d'accueil de première inscription — `OnboardingViewModel`
Affiché **une seule fois** : après l'inscription réussie (QR → licence → API) et
après l'application des droits, **avant** le premier affichage du Shell.
| Étape | Contenu |
|---|---|
| 1 | Langue secondaire : choisir et/ou télécharger les modèles |
| 2 | Thème (clair / sombre / système) + couleur d'accent — aperçu immédiat |
| 3 | Pause déjeuner habituelle (début / fin) |
- Boutons **Retour**, **Suivant**, **Terminer**, **Passer**.
- **Rien n'est obligatoire** : « Passer » ferme l'accueil immédiatement.
- Un appareil **déjà inscrit** qui redémarre va directement au Shell (l'accueil
n'est pas rejoué) ; une **réinscription** le rejoue, ce qui est le
comportement voulu (nouvelle licence, nouveau client).
- La clôture de l'accueil rend la main au Shell : `RootViewModel.ShowOnboarding`
→ `Onboarding.Termine` → `OnActivationSucceeded`.
---
## 10. Diagnostic des problèmes courants
| Symptôme | Cause probable | Action |
|---|---|---|
| Aucune langue dans la liste | `SRV_LANGUES` absent du bootstrap, ou `manifest.json` absent/vide | Vérifier §4 et §5 de `DEPLOIEMENT_API_LANGUES.md`, puis **Actualiser la liste** |
| « Le service des langues ne répond pas » | Service `OmikaLanguesApi` arrêté, port/URL erroné, HTTPS non exposé | §7.8 et §8 du doc de déploiement |
| « empreinte SHA-256 non conforme » | Fichier modifié sur le disque serveur sans régénérer `manifest.json` | Régénérer les empreintes (§5.3 du doc de déploiement) |
| La bascule `⇄` n'apparaît pas | Langue non installée, ou moteur d'inférence absent | Installer la langue dans les Paramètres ; activer le moteur (§7) |
| « Traduction hors ligne indisponible » | Moteur non embarqué (§7) | Activer `OMIKA_ONNX_RUNTIME` |
| Bucket de dictée vide / « Langue de dictée indisponible » | Langue non installée dans le moteur vocal du téléphone | Installer le paquet de langue dans les réglages Android/iOS |
---
## 11. Limites connues et points d'extension
> Les deux premiers points sont traités pas à pas dans
> **`VALIDATION_TRADUCTION_ONNX.md`** : activation du moteur, export d'une vraie
> paire FR⇄ES, validation sur le poste, et mesure de la divergence du tokeniseur.
1. **Moteur ONNX non embarqué** — voir §7. C'est la seule fonctionnalité qui
n'est pas active de bout en bout.
2. **SentencePiece approximé sur un point précis** — `precompiled_charsmap` (la
table de normalisation Unicode compilée du modèle) n'est **pas** appliqué : la
réimplémenter demanderait un trie à double tableau pour un gain nul sur du
texte déjà en NFC, c'est-à-dire tout ce que produit un clavier mobile.
En revanche `add_dummy_prefix`, `escape_whitespaces`,
`remove_extra_whitespaces` et le **repli octet** sont désormais lus et
honorés : le tokeniseur se signale lui-même (`ProfilNormalisation`,
`Avertissements`) au lieu de diverger en silence. Les types `WORD` et `CHAR`
restent refusés au chargement.
3. **Décodage glouton** (pas de *beam search*) : compromis assumé pour la latence
sur téléphone. Le remplacer ne touche que `OnnxTranslationService`.
4. **Deux formes de modèles acceptées** : encodeur + décodeur séparés (un
`decoder*.onnx` hors `*_with_past*` est présent), ou modèle unique produisant
directement la séquence traduite. Les noms d'entrées/sorties sont résolus
**automatiquement** sur le modèle chargé, et restent surchargeables via
`OnnxTranslationOptions`.
5. **Poids des packs** : ~80 à 160 Mo par langue pour un modèle à vocabulaire
partagé, **~300 Mo** pour un Marian/OPUS-MT (deux sens × encodeur + décodeur +
deux vocabulaires). La taille est affichée **avant** téléchargement.
6. **Tests** : le calcul SHA-256, l'assainissement des chemins, le verrou
d'installation et la machine à états de la dictée sont couverts par
`LanguagePackTests` et `DictationTextFieldTests`. Le tokeniseur est couvert par
`SentencePieceFixturesTests`, **contre la référence Python** — voir
`VALIDATION_TRADUCTION_ONNX.md` §3.3. Dans l'environnement de compilation
utilisé ici, ces tests **compilent mais ne s'exécutent pas** (le testhost
VSTest échoue) : ils sont à lancer sur le poste de développement.
+348
View File
@@ -0,0 +1,348 @@
# Valider la traduction ONNX — activation du moteur et mesure de la qualité
> **Objet** : traiter les deux suites de la traduction hors ligne mobile :
> (1) activer `OMIKA_ONNX_RUNTIME` et faire tourner une **vraie** paire FR⇄ES ;
> (2) lever le doute sur le **tokeniseur** SentencePiece.
>
> **Cible** : poste de développement (accès NuGet) + un appareil Android.
> **Prérequis** : `DEPLOIEMENT_API_LANGUES.md` (service serveur) et
> `TRADUCTION_HORS_LIGNE_MOBILE.md` (côté application) déjà lus.
---
## 1. Les deux problèmes ne se traitent pas de la même façon
| | Nature | Qui peut le faire | Coût |
|---|---|---|---|
| **Point 1** — activer le moteur | **Procédure d'exécution** : aucun code à écrire, 3 modifications de projet + une validation à exécuter | Toute machine avec accès NuGet | ~1 h |
| **Point 2** — tokeniseur | **Travail de code** déjà largement fait (§3.1), puis **mesure** : on ne corrige plus à l'aveugle, on compare à la référence | Développeur | 2 h pour mesurer, variable ensuite |
> **Règle directrice pour le point 2** : *ne jamais « améliorer » le tokeniseur
> sans mesure*. Une normalisation devinée est **pire** que pas de normalisation :
> elle produit un découpage différent de la référence de façon invisible. On
> mesure d'abord (§3.3), on corrige ensuite — et seulement ce qui diverge.
---
## 2. Point 1 — activer le moteur et faire tourner une vraie paire FR⇄ES
### 2.1 Prérequis
```powershell
# Vérifier que NuGet est joignable (c'est le seul prérequis bloquant)
dotnet nuget list source
# Un pack de langue de test (voir §2.3), et ~5 Go de disque libre
```
### 2.2 Étape A — activer le moteur (3 modifications, aucune ligne de code)
1. `Directory.Packages.props` :
```xml
<PackageVersion Include="Microsoft.ML.OnnxRuntime" Version="1.20.1" />
```
2. `src/Omika.App.Local/Omika.App.Local.csproj` :
```xml
<ItemGroup>
<PackageReference Include="Microsoft.ML.OnnxRuntime" />
</ItemGroup>
<PropertyGroup>
<DefineConstants>$(DefineConstants);OMIKA_ONNX_RUNTIME</DefineConstants>
</PropertyGroup>
```
3. `src/Omika.App/AppServices.cs` — une ligne :
```csharp
IOnnxInferenceSessionFactory onnxFactory = OnnxRuntimeInferenceSessionFactory.Instance;
```
**Vérification immédiate** — les deux commandes doivent passer :
```powershell
dotnet build src/Omika.App.Local/Omika.App.Local.csproj -c Debug
dotnet build tools/Omika.LanguesProbe/Omika.LanguesProbe.csproj -c Debug
```
> `OnnxRuntimeInferenceSessionFactory.cs` est le **seul** fichier dont la
> compilation dépend du paquet : c'est voulu. S'il ne compile pas, c'est une
> incompatibilité d'API avec la version du paquet — corrigez ce fichier
> uniquement, rien d'autre n'en dépend.
### 2.3 Étape B — obtenir une paire FR⇄ES réelle
Les modèles de traduction ne sont pas livrés avec Omika (poids + licences). Le
plus simple est un modèle **OPUS-MT** de Helsinki-NLP, exporté en ONNX.
```powershell
pip install "optimum[onnxruntime]" transformers sentencepiece
# fr -> es (sert le sens « depuisFrancais » du pack « es »)
optimum-cli export onnx --model Helsinki-NLP/opus-mt-fr-es .\export\fr-es
# es -> fr (sert le sens « versFrancais » du pack « es »)
optimum-cli export onnx --model Helsinki-NLP/opus-mt-es-fr .\export\es-fr
```
L'export produit notamment :
```
encoder_model.onnx
decoder_model.onnx
decoder_with_past_model.onnx <-- NE PAS LIVRER
source.spm
target.spm
```
> ⚠️ **`decoder_with_past_model.onnx` ne doit jamais être livré dans le pack.**
> C'est un décodeur à cache de clés/valeurs (`past_key_values`) destiné à la
> génération incrémentale : il attend des entrées que ce pipeline ne fournit pas.
> `OnnxTranslationService` l'exclut désormais explicitement (nom contenant
> `past`), mais autant ne pas le copier.
> ⚠️ **Un modèle Marian a DEUX vocabulaires** : `source.spm` (langue d'entrée) et
> `target.spm` (langue de sortie). Les livrer **tous les deux** est obligatoire —
> voir §2.4.
### 2.4 Étape C — construire le pack local
> Cette étape construit un pack **de test sur le poste**. Pour livrer un pack en
> **production** (dépôt sous `Langues__RootPath`, génération du manifeste,
> contrôles), la procédure dédiée est `LIVRAISON_MODELES_LANGUES.md` — l'
> arborescence et les règles de nommage y sont les mêmes.
Arborescence à créer, par exemple dans `C:\Omika\TestLangues\es` :
```
es/
├── versFrancais/ (es -> fr)
│ ├── encoder_model.onnx <- depuis .\export\es-fr\
│ ├── decoder_model.onnx <- depuis .\export\es-fr\
│ ├── source.spm <- depuis .\export\es-fr\source.spm (ESPAGNOL)
│ └── target.spm <- depuis .\export\es-fr\target.spm (FRANÇAIS)
└── depuisFrancais/ (fr -> es)
├── encoder_model.onnx <- depuis .\export\fr-es\
├── decoder_model.onnx <- depuis .\export\fr-es\
├── source.spm <- depuis .\export\fr-es\source.spm (FRANÇAIS)
└── target.spm <- depuis .\export\fr-es\target.spm (ESPAGNOL)
```
**Règles de nommage** (résolues automatiquement par le service, aucune
configuration nécessaire) :
| Fichier | Rôle | Repli si absent |
|---|---|---|
| `source.spm` | vocabulaire d'entrée | `vocab.spm`, puis premier `.spm` |
| `target.spm` | vocabulaire de sortie | `vocab.spm`, puis premier `.spm` |
| `vocab.spm` | vocabulaire **commun** aux deux langues (NLLB, mBART, M2M-100) | premier `.spm` |
| `encoder_model.onnx` / `encoder.onnx` / tout autre `.onnx` | modèle principal | — |
| `decoder_model.onnx` / `decoder.onnx` | décodeur séparé | mode « modèle unique » |
| `*_with_past*.onnx` | **ignoré** | — |
> **Modèle à vocabulaire partagé** (NLLB, mBART) : un seul `vocab.spm` suffit,
> il sert aux deux sens. **Modèle Marian/OPUS-MT** : `source.spm` + `target.spm`
> sont obligatoires. Les noms d'entrée du graphe (`input_ids` vs
> `decoder_input_ids`, `encoder_hidden_states` vs `memory`) sont eux aussi
> résolus automatiquement sur le modèle réellement chargé.
### 2.5 Étape D — valider sur le POSTE, sans téléphone
C'est le cœur du traitement du point 1 : **on ne débogue pas un modèle de 120 Mo
par déploiement d'APK.**
```powershell
# 1) Le vocabulaire est-il lisible, et le tokeniseur cohérent ?
dotnet run --project tools/Omika.LanguesProbe -- inspecter .\TestLangues\es\versFrancais\source.spm
# 2) Que produit exactement la tokenisation ? (à comparer à la référence, §3.3)
dotnet run --project tools/Omika.LanguesProbe -- encoder .\TestLangues\es\versFrancais\source.spm "Buenos días, ¿cómo está?"
# 3) La traduction complète, avec les temps de chargement et d'inférence
dotnet run --project tools/Omika.LanguesProbe -- traduire `
--dossier .\TestLangues\es\versFrancais --code es --sens versFrancais `
--texte "Sustitución de la pieza"
```
> **Pack familial** (modèle multilingue, §2.1.1 de
> `LIVRAISON_MODELES_LANGUES.md`) : pointer `--dossier` sur le dossier du
> **sens** (`langues/pt/versFrancais`). La commande lit les poids dans le
> `commun/` voisin et affiche la ligne `Jeton cible : >>fra<<` — si cette ligne
> manque, le pack produira la mauvaise langue sans aucune erreur.
**Ce qu'il faut regarder, dans cet ordre :**
1. `inspecter` — `ProfilNormalisation`, `RepliOctetDisponible`, et surtout les
**avertissements**. S'il annonce `precompiled_charsmap … NON appliquée`,
c'est attendu (§3.2) : notez-le, ne le corrigez pas encore.
2. `inspecter` — les **aller-retours** `texte → ids → texte`. Tous doivent être
`IDENTIQUE`. Un `DIFFERENT` sur du texte ordinaire est le symptôme d'un
découpage divergent : passez au §3.3 **avant** de chercher ailleurs.
3. `traduire` — la sortie doit être une phrase **française plausible**. Pas
parfaite : plausible. « Remplacement de la pièce » ✅. Une suite de mots
espagnols, du texte répété ou des caractères isolés ❌ → voir §3.4.
**Repères de performance à noter** (ils décideront de la faisabilité mobile) :
| Mesure | Attendu sur poste | Seuil d'alerte |
|---|---|---|
| Chargement du modèle | < 2 s | > 5 s |
| Inférence d'une phrase courte | < 1 s | > 3 s |
| Aller-retour tokeniseur seul | < 5 ms | > 50 ms |
### 2.6 Étape E — valider sur Android
```powershell
dotnet publish src/Omika.App.Android/Omika.App.Android.csproj -c Release -r android-arm64
```
1. Copier le dossier `es\` complet dans le stockage de l'appareil, sous
`<données app>/Omika/langues/es/` — ou, plus réaliste, publier le pack sur le
service Langues et le télécharger depuis **Paramètres → Langue secondaire**
(c'est aussi le test du §3 de `TRADUCTION_HORS_LIGNE_MOBILE.md`).
2. **Avant de tester la traduction**, vérifier le poids : un modèle Marian fait
~300 Mo pour les deux sens. C'est la décision de livrable la plus lourde de
tout le lot.
3. Dicter en espagnol dans une note interne → le champ doit recevoir du français.
4. Ouvrir le panneau de dictée → la bascule `⇄` doit redémarrer l'écoute en
espagnol.
5. Sur une note existante, appuyer sur « ⇄ Traduire » → affichage espagnol, et
**rien** ne doit apparaître dans l'Outbox (vérifier la page Synchronisation).
### 2.7 Critères go / no-go
Le moteur est **validé** si et seulement si :
- [ ] `inspecter` : aucun aller-retour `DIFFERENT` sur du texte ordinaire ;
- [ ] `traduire` : phrases françaises plausibles sur 10 phrases variées
(avec accents, chiffres, ponctuation, une phrase longue) ;
- [ ] inférence < 3 s par phrase sur l'appareil Android cible ;
- [ ] poids de l'APK mesuré et accepté ;
- [ ] la dictée traduite insère du français, et **aucune** donnée traduite
n'apparaît dans l'Outbox.
Sinon → §3.4.
### 2.8 Rollback
Retirer les 3 modifications du §2.2 (le symbole, le `PackageReference`, la
ligne d'`AppServices`). L'application repasse à
`UnavailableOnnxInferenceSessionFactory` : la traduction est annoncée
indisponible, **rien d'autre ne change**. Aucune migration de données, aucun
état à nettoyer — c'est précisément pourquoi le moteur est isolé derrière une
fabrique.
---
## 3. Point 2 — le tokeniseur
### 3.1 Ce qui a été corrigé (et pourquoi c'était faux)
Quatre défauts réels ont été corrigés. Aucun n'était visible sur les modèles les
plus courants, ce qui les rendait d'autant plus dangereux :
| Défaut | Conséquence avant correction | État |
|---|---|---|
| `normalizer_spec` ignoré : préfixe `▁` ajouté d'office | Un modèle formé avec `add_dummy_prefix=false` (fréquent sur les modèles récents) était découpé — donc traduit — **différemment** de la référence | ✅ lu et honoré |
| Espaces multiples / de bord jamais réduits | `« Bonjour le monde »` produisait des marqueurs `▁` parasites absents de la référence | ✅ `remove_extra_whitespaces` honoré (espace, tabulation, `\n`, `\r`, `\v`, `\f`) |
| `escape_whitespaces` supposé vrai | Un modèle à espaces littéraux était découpé de travers | ✅ honoré |
| **Repli octet absent** | Sur un modèle multilingue, un caractère hors vocabulaire devenait `<unk>` : les accents et les alphabets non latins étaient **purement perdus** | ✅ implémenté (encodage **et** décodage) |
| **Un seul vocabulaire par pack** | **Le défaut le plus grave** : un modèle Marian a un vocabulaire par langue. Décoder la sortie avec le vocabulaire d'entrée produit des identifiants interprétés dans le mauvais référentiel → du charabia | ✅ `source.spm` + `target.spm` |
Deux points qui **n'étaient pas** des défauts, vérifiés :
- la pénalité du jeton inconnu (`min_score − 10`) est bien celle de la référence
(`kUnkPenalty = 10.0`) ;
- un `<unk>` par **point de code** (et non par unité UTF-16) est correct : une
paire de substituts ne doit jamais être coupée en deux.
### 3.2 Ce qui reste approximé — et l'impact attendu
**`precompiled_charsmap` n'est pas appliqué.** C'est la table de normalisation
Unicode compilée du modèle (structure de trie à double tableau, format propre à
SentencePiece). La réimplémenter représenterait plusieurs centaines de lignes
pour un gain **nul sur la majorité des textes**.
Impact réel, à connaître :
- **Aucun** sur du texte déjà normalisé en NFC (ce que produit tout clavier
Android/iOS, et tout ce que l'API renvoie) ;
- **Quelques cas** sur les formes de compatibilité (`fi` ligature, chiffres
pleine largeur `123`, exposants `²`) et sur les langues dont l'écriture
demande un repli de casse ou une normalisation de présentation.
Le tokeniseur **le signale** désormais lui-même (`Avertissements`,
`ProfilNormalisation`). C'est visible dans `inspecter` et dans les diagnostics.
### 3.3 Mesurer la divergence (avant de corriger quoi que ce soit)
La référence est la bibliothèque Python `sentencepiece`, l'implémentation
d'origine de Google.
```powershell
pip install sentencepiece
# Génère une fixture de référence par modèle trouvé (récursif)
.\scripts\Generer-Fixtures-SentencePiece.ps1 -DossierModeles .\TestLangues
```
La fixture contient, pour chaque cas d'essai, les identifiants **de référence**
et le décodage **de référence**. Le test `SentencePieceFixturesTests` les
compare ensuite au tokeniseur Omika :
```powershell
dotnet test tests/Omika.Tests --filter SentencePieceFixtures
```
- **Dossier de fixtures absent → le test passe** (sinon toute compilation sans
modèle serait rouge). Les cas d'essai couvrent accents français, accents et
`¿¡` espagnols, espaces de bord et doubles, chaîne vide, espaces seuls,
chiffres et ponctuation, casse mixte, phrase longue, et un alphabet non latin.
- **Un échec nomme le cas fautif**, le premier index divergent, les deux
identifiants et le texte de la pièce : de quoi corriger sans deviner.
- Les `.spm` ne sont **pas versionnés** (poids + licences) — voir
`tests/fixtures/sentencepiece/LISEZMOI.md`.
### 3.4 Arbre de décision selon ce que révèle la mesure
| Symptôme mesuré | Cause la plus probable | Action |
|---|---|---|
| Aller-retour `DIFFERENT` sur du texte **ordinaire** (accents, ponctuation) | Divergence de découpage | Comparer avec `encoder` : si les identifiants diffèrent dès le **premier**, c'est la normalisation ; s'ils divergent plus loin, c'est le Viterbi |
| Divergence **uniquement** sur les cas de compatibilité (`fi`, `123`) | `precompiled_charsmap` non appliqué | **Accepter** et documenter : impact réel nul sur une dictée |
| Traduction = suite de mots de la langue source, ou texte répété | Vocabulaire cible faux (ou absent) | Vérifier que `target.spm` est bien livré dans le sens concerné (§2.4) |
| Traduction = `<unk>`, `▁`, ou caractères isolés | Mauvais vocabulaire, ou modèle non aligné sur le vocabulaire | Vérifier que `source.spm`/`target.spm` viennent bien du **même** modèle que les `.onnx` |
| Erreur « n'expose aucune des entrées attendues (…) » | Noms d'entrée atypiques | Le message énumère les entrées réelles du modèle : renseigner `OnnxTranslationOptions` en conséquence |
| Sortie entière ignorée / erreur de type | ONNX Runtime refuse un type inattendu | Corrigé : le type est testé **avant** `AsTensor<T>` (qui lève au lieu de retourner `null`) |
| Divergence **après** avoir tout vérifié | Vraie limite de l'implémentation | Ouvrir un ticket avec la fixture en échec **jointe** : elle contient tout ce qu'il faut pour reproduire |
> **Ce qu'il ne faut PAS faire** : assouplir la fixture attendue pour faire
> passer le test. Une fixture modifiée est une divergence rendue invisible.
---
## 4. Hors périmètre (à ne pas confondre)
- **Qualité de traduction** : ce document valide que le *pipeline* fonctionne,
pas que le modèle traduit bien. Un modèle médiocre restera médiocre — c'est un
choix de modèle, pas un défaut de code.
- **Poids du livrable mobile** : l'activation du moteur ONNX Runtime ajoute la
bibliothèque native par architecture, et les modèles pèsent 80 à 300 Mo par
langue. C'est une décision produit, pas technique.
- **Reprise/streaming** du téléchargement : non implémenté (le re-téléchargement
repart de zéro par fichier, mais les fichiers déjà conformes sont ignorés).
- **Beam search** : le décodage est glouton (compromis de latence assumé).
---
## 5. Récapitulatif
| # | Action | Qui | Où |
|---|---|---|---|
| 1 | Activer le symbole + le paquet + la fabrique (§2.2) | Dev (NuGet) | 3 fichiers |
| 2 | Exporter une paire FR⇄ES en ONNX (§2.3) | Dev | poste |
| 3 | Construire le pack `source.spm` + `target.spm` (§2.4) | Dev | disque |
| 4 | `inspecter` / `encoder` / `traduire` (§2.5) | Dev | poste — **pas de téléphone** |
| 5 | Générer les fixtures et lancer le test (§3.3) | Dev | `scripts\` + `dotnet test` |
| 6 | Trancher avec l'arbre de décision (§3.4) | Dev | — |
| 7 | Publier sur Android et mesurer poids + latence (§2.6) | Dev | appareil |
| 8 | Décider go / no-go (§2.7) | Produit + Dev | — |
**Si une seule chose doit être retenue** : les étapes 4 et 5 se font **sur le
poste, en quelques minutes**. Tout le reste — déploiement d'APK, téléchargement
de 300 Mo sur un téléphone, instrumentation à distance — est un détour.
View File
+479
View File
@@ -0,0 +1,479 @@
# Omika — Analyse complète du projet (base pour le site web marketing)
> **Objet** : fournir une compréhension profonde du code source du projet **Omika**
> pour concevoir un site web de vente de l'application. Ce document croise
> l'architecture, les fonctionnalités réelles implémentées, la stack technique,
> le modèle de données et les différenciateurs, avec — pour chaque point — la
> preuve dans le code.
>
> **Sources** : code source (`src/`), solution `Omika.slnx`, documentation
> (`docs/*.md`), maquette UX (`docs/maquetteOmika.html`), fichiers de projet.
>
> **Statut du produit au moment de l'analyse** : version `1.0.0`, socle
> fonctionnel **solide et très avancé côté serveur/API**, application mobile
> **offline-first en progression** (consultation complète + écriture
> Intervention + documents/photos + saisie des temps), enrichi d'un
> **centre de diagnostic** d'exploitation.
---
## 1. Résumé exécutif (pitch)
**Omika** est une application de **gestion des interventions terrain**
(field service management) destinée aux entreprises de dépannage, maintenance,
installation et travaux (CVC, plomberie, électricité, couverture, SAV, etc.).
Elle couvre tout le cycle : **clients → sites → dossiers → interventions →
rendez-vous/planning → suivi des temps → documents/photos → comptes rendus →
rapports → e-mails**, avec une application **mobile offline-first** (Android,
iOS) et une console **desktop** (Windows/macOS/Linux).
Points de vente majeurs :
- **Migration réussie d'un logiciel métier legacy** (WinDev « Batik », ~15 ans
d'existant) vers une **stack .NET moderne** sans casser la base de données
existante (HFSQL de PC SOFT) — c'est un argument de confiance fort.
- **Offline-first** : les techniciens travaillent sans réseau ; la
synchronisation est **fiable, idempotente et asynchrone**.
- **Multi-plateforme** : une seule base de code (Avalonia) pour Android, iOS et
desktop.
- **Monétisation intégrée** : activation par **licence**, verrouillage par
appareil, jetons signés — le produit est prêt à être vendu.
- **Exploitabilité** : un **centre de diagnostic** + télémétrie OpenTelemetry
pour le support et l'exploitation.
---
## 2. Qu'est-ce qu'Omika ? (positionnement produit)
| Élément | Valeur |
|---|---|
| **Nom** | Omika |
| **Slogan / positionnement** | « Interventions terrain » (maquette `docs/maquetteOmika.html`) |
| **Catégorie** | Gestion d'interventions / Field Service Management / CRM terrain |
| **Usage** | Suivi des interventions techniques chez les clients, du RDV au rapport |
| **Public cible** | Sociétés de services techniques (artisans, PME, groupes) |
| **Éditeur historique** | Applilogik (URLs de licence `*.applilogik.fr`) |
| **Ancêtre** | Batik / Isis_BO (WinDev), décliné pour plusieurs clients (SLAMM, Bouvard, Artisans Partenaires, Batigest) |
Le cœur métier : **l'intervention** est l'agrégat central. Autour gravitent les
**clients**, **contacts**, **sites/lieux**, **dossiers** (groupes
d'interventions), **rendez-vous** (planning individuel et d'équipe), **suivi des
temps**, **documents/photos**, **comptes rendus**, **rapports** (modèles
personnalisables), **e-mails** (modèles + SMTP), **collaborateurs/équipes**,
**rôles et droits d'accès**, et **paramètres métier** configurables.
---
## 3. Historique et contexte : du WinDev au .NET
Le projet est une **réécriture** d'un logiciel historique WinDev :
| | Génération 1 (legacy) | Génération 2 (ce dépôt) |
|---|---|---|
| Langage | WinDev (WLanguage) | C# / **.NET 10** |
| Base de données | HFSQL (PC SOFT) | HFSQL **inchangée** + SQLite (mobile) |
| Interface | Fenêtres WinDev | **Avalonia** (Desktop / Android / iOS) |
| Web service | `Batik_WS_Com_Mob`, `Batik_WS_Lic_Mob` | **Omika.Api** (ASP.NET Core) |
| Service de sync | `Omika_S_Comm_DEV` (WinDev) | **Omika.Worker** (.NET) |
Chiffres clés du legacy rétro-ingénieré (source : `docs/ESTIMATION_EFFORT_DEVELOPPEMENT.md`) :
- ~**600 000 lignes** de corpus legacy analysé (2 générations WinDev + 3 web services) ;
- ~**1 600 procédures**, **140 fenêtres**, ~**380 classes**, **46 états d'impression** ;
- schéma HFSQL : **48 → 58 tables**, **720 rubriques** mappées.
**Règle d'or du projet** : la base HFSQL existante **n'est jamais modifiée**
(aucune table/collonne ajoutée ou supprimée). Le nouveau code lit/écrit dans les
tables existantes. Cela garantit une **cohabitation sans risque** avec
l'ancien système et une bascule progressive.
> **Argument commercial** : « On remplace votre vieux logiciel sans toucher à
> vos données. » C'est rare et rassurant pour un acheteur qui a 15 ans
> d'historique.
---
## 4. Cible, personas et cas d'usage
### 4.1 Personas
1. **Le technicien / intervenant terrain** (mobile) : consulte son agenda du
jour, ouvre la fiche intervention, prend des photos, ajoute des documents,
saisit son temps, consulte le client/site/contact, travaille **sans réseau**.
2. **Le chef d'équipe / planificateur** (desktop) : planifie les rendez-vous,
suit l'agenda de l'équipe, affecte les collaborateurs.
3. **Le back-office / administratif** : gère clients, dossiers, documents,
modèles de rapports et d'e-mails, paramètres métier.
4. **L'exploitant / le support** : utilise le centre de diagnostic
(`Diagnostics.Web`) pour suivre la santé du parc, les erreurs, la
synchronisation, la télémétrie.
### 4.2 Cas d'usage couverts (de bout en bout)
1. Activation de licence → session sécurisée → shell.
2. Consultation de l'agenda (jour) et du détail d'un RDV.
3. Consultation/recherche/filtre des interventions (actif/archivé).
4. **Création / modification / archivage d'une intervention** (offline).
5. **Ajout de photos / documents** sur intervention (offline).
6. **Saisie du temps** (offline, rattachée à une intervention ou un RDV).
7. Génération de **rapports** (modèles Word/RTF → PDF) et **e-mails** (modèles
+ SMTP) — backend.
8. **Synchronisation** manuelle mobile ↔ serveur.
9. **Diagnostic** du parc et de la sync (support).
---
## 5. Inventaire fonctionnel (le cœur du marketing)
> État réel par module. Légende : ✅ opérationnel · 🟡 partiel/backend · ⬜ en projet.
### 5.1 Référentiels (données de base)
| Fonction | État | Preuve dans le code |
|---|---|---|
| **Clients** (fiche, liste, adresses) | ✅ API + 🟡 mobile (lecture) | `ClientService`, `ClientEndpoints`, `ClientDetailView` |
| **Contacts / personnes** (rôles, civilité, tel, e-mail) | ✅ API + 🟡 mobile (lecture) | `ContactService`, `ContactEndpoints`, `ContactDetailView` |
| **Sites / lieux** (adresse, GPS prévu) | ✅ API + 🟡 mobile (lecture) | `SiteService`, `SiteEndpoints`, `SiteDetailView` |
| **Sociétés** | ✅ | `Societe` (entité), `SocieteDto` |
| **Collaborateurs** | ✅ API + 🟡 mobile (lecture) | `CollaborateurService`, `CollaborateurEndpoints` |
| **Équipes** | ✅ API | `EquipeService`, `EquipeEndpoints` |
| **Rôles & droits d'accès** | ✅ | `Role`, `DroitAcces`, `Permission`, `MobileIdentityProvider` |
| **Paramètres métier** (types, statuts, civilités, catégories…) | ✅ | `ParametreMetier`, `ReferenceDataService`, `ReferenceDataEndpoints` |
### 5.2 Dossiers & interventions
| Fonction | État | Preuve |
|---|---|---|
| **Dossiers** (regroupent des interventions) | ✅ API + 🟡 mobile (lecture) | `DossierService`, `DossierEndpoints`, `DossierDetailView` |
| **Interventions** (création/modif/archive) | ✅ **bout en bout offline** | `InterventionService`, `InterventionSyncOperationHandler`, `InterventionEditView` |
| Recherche d'interventions | ✅ mobile (locale) + API | `MobileInterventionSearchService`, `InterventionsView` |
| Filtre actif / archivé | ✅ | `EstArchive` |
| Actions d'intervention | ✅ | `ActionIntervention`, `ActionInterventionDto` |
| États d'intervention | ✅ | `EtatIntervention`, `EtatInterventionDto` |
| Checklists | ✅ | `Checklist`, `ChecklistItem` |
| **Compte rendu** (signé électroniquement client + technicien, devis conforme, réserves) | 🟡 backend | `CompteRenduService`, `CompteRenduEndpoints` |
### 5.3 Planning & rendez-vous
| Fonction | État | Preuve |
|---|---|---|
| **Agenda du jour** (RDV) | ✅ mobile | `AgendaView`, `AgendaViewModel` |
| Navigation jour précédent/suivant | ✅ | `AgendaDayDisplayItem` |
| **Détail d'un RDV** | ✅ mobile (lecture) | `RendezVousDetailView` |
| **Agenda d'équipe** | 🟡 (maquette + API) | `AgendaEquipeView`, `RendezVousEndpoints` |
| CRUD rendez-vous | 🟡 backend (API) | `RendezVousService`, `CreateRendezVousRequest` |
| Participants / contacts d'un RDV | ✅ | `ParticipantRendezVous`, `RDV_Collaborateur`, `RDV_Contact` |
| Annulation (motifs) | ✅ modèle | `AnnulationRendezVous` |
### 5.4 Suivi des temps
| Fonction | État | Preuve |
|---|---|---|
| Consultation des temps | ✅ mobile (liste) | `SuiviTempsLocalService`, `SuiviTempsViewModel` |
| **Saisie de temps** (offline-first) | ✅ | `SuiviTempsCreateView`, `SuiviTempsCreateViewModel`, `SuiviTempsSyncOperationHandler` |
| Référentiel « type de temps » local | ✅ | `IParametreMetierLocalService.GetTypeTempsAsync()` |
| Validation métier (champ `Valide`) | 🟡 backend | `SuiviTempsService` |
### 5.5 Documents & photos
| Fonction | État | Preuve |
|---|---|---|
| Liste des documents d'une intervention | ✅ mobile | `InterventionDocumentsView` |
| **Ajout photo/document (offline)** | ✅ | `OutboxService` + `DocumentGuid` + `SyncEngine` |
| Téléchargement à la demande | ✅ | `MobileDocumentService`, `GET /api/documents/{guid}` |
| Ouverture offline (cache) | ✅ | `DocumentCacheService` |
| Visionneuse photo | ✅ | `PhotoViewerView` |
| Éditeur d'image | ✅ | `ImageEditorView` (SkiaSharp) |
| Optimisation des photos | ✅ | `PhotoOptimizationSettings`, SkiaSharp |
| Lecture EXIF (date réelle de prise de vue) | ✅ | `MetadataExtractor` |
### 5.6 Rapports & e-mails (backend)
| Fonction | État | Preuve |
|---|---|---|
| **Modèles de rapport** (Word/RTF) | ✅ | `ModeleRapportService`, `ModeleRapport` |
| **Modèles d'e-mail** | ✅ | `ModeleMailService`, `ModeleMail` |
| Mots-clés / rubriques de substitution | ✅ | `MotClefService`, `RubriqueSubstitutionEngine` |
| **Génération DOCX → PDF** | ✅ | `DocumentGenerationService`, `RapportPdfService`, `DocumentFormat.OpenXml` |
| Pré-remplissage des champs depuis la base | ✅ | `RapportChampService`, `RapportChampEndpoints` |
| **Envoi d'e-mail (SMTP)** | ✅ | `EmailCreationService`, `MailKitEmailSender` (MailKit) |
| Images inline dans les e-mails | ✅ | `EmailImageInliner`, `EmailDocxRenderer` |
| Conversion RTF/HTML | ✅ | `RtfPlainText`, `DocxHtmlConverter` |
| Écrans mobile rapport/e-mail | ⬜ (maquette uniquement) | `RapportChampsView`, `NouveauEmailView` (partiels) |
### 5.7 Synchronisation & hors-ligne
| Fonction | État | Preuve |
|---|---|---|
| **Mode hors-ligne** (SQLite locale) | ✅ | `omika.db`, `LocalDatabase`, `SnapshotApplier` |
| **Moteur de synchronisation** | ✅ | `SyncEngine` |
| **Outbox** (file des changements) | ✅ | `OutboxService`, `OutboxRepository` |
| **Idempotence** (pas de doublon) | ✅ | `OperationId` + `ISyncOperationStatusStore` |
| **Retry** (max 30, rejet terminal) | ✅ | `SyncProcessor`, `MaxRetryCount=30` |
| **Snapshot** agenda serveur → mobile (RDV ±14 j + interventions/contacts liés) | ✅ | `MobileSnapshotService`, `MobileSnapshotResponse` |
| Chargement « à la demande » des fiches (client/site/dossier/document/temps) | ✅ | `MobileInterventionSearchService`, endpoints `/api/*` |
| Réconciliation d'identités (GUID mobile ↔ PK) | ✅ | `MobileIdentityReconciliationService`, `MobileEntityIdentityResolver` |
| État de sync affiché (Pending/Rejected) | ✅ | `SynchronisationView`, `PendingSyncItem` |
### 5.8 Licence & sécurité
| Fonction | État | Preuve |
|---|---|---|
| **Activation par licence** (code client + n° licence) | ✅ | `LicenseService`, `LicenseActivationView` |
| **Inscription par QR code signé** (RSA-SHA256, `kid`) | ✅ | `EnrollmentQrCodec` |
| Validation / révocation | ✅ | `POST /api/license/validate` |
| **Verrouillage par appareil** | ✅ | `AssignDeviceAsync` (`LI_IDMobile`) |
| **Jeton signé** HMAC-SHA256 (stateless, 2 segments) | ✅ | `TokenService`, `MobileAuthMiddleware` |
| Codes d'erreur métier | ✅ | `LicenseErrorCodes` (14 codes E0xxx/E1xxx/E2xxx/E3xxx) |
| Stockage sécurisé du token (Keystore/Keychain/secure storage) | ✅ | `PlatformSecureTokenStore` |
| Authentification back-office (PBKDF2-SHA256) | ✅ | `DiagnosticPasswordHasher`, `BackOfficeAuthEndpoints` |
### 5.9 Exploitation & support (Diagnostic Center)
| Fonction | État | Preuve |
|---|---|---|
| **Portail web d'exploitation** | ✅ | `Omika.Diagnostics.Web` (Dashboard, Diagnostics, Erreurs, Traces, Synchronisation, Telemetry, Parc, Versions, Référentiel) |
| Gestion des clients diagnostics (serveurs) | ✅ | `ClientCode` + `ApiKey` |
| **Gestion des utilisateurs** (rôles Support/Admin) | ✅ | `DiagnosticUserAuthenticator`, page `/Users` |
| Base PostgreSQL dédiée | ✅ | `omika_diagnostics` (Npgsql) |
| **Télémétrie** (traces/logs/métriques) | ✅ | `Omika.Observability.External` (OpenTelemetry, OTLP) |
| **Outbox de diagnostic** (buffering hors-ligne) | ✅ | `Omika.Core/Observability/Outbox` |
| Forwarding vers le centre central | ✅ | `Omika.Core/Observability/Forwarding` |
---
## 6. Architecture technique
### 6.1 Stack
| Couche | Technologie |
|---|---|
| Langage / runtime | C# 13 / **.NET 10** |
| UI | **Avalonia 12.1.1** (Fluent, Inter, Skia) |
| MVVM | CommunityToolkit.Mvvm 8.4.2 |
| API | **ASP.NET Core** (Minimal API) + OpenAPI |
| Service de fond | `Microsoft.NET.Sdk.Worker` (BackgroundService) |
| Base serveur | **HFSQL (PC SOFT)** via **ODBC** + **OLE DB** |
| Base mobile | **SQLite** (Microsoft.Data.Sqlite) |
| Base support | **PostgreSQL** (Npgsql) |
| E-mail | **MailKit** (SMTP) |
| Documents | **DocumentFormat.OpenXml** (DOCX), conversion PDF |
| Images | **SkiaSharp** + **MetadataExtractor** (EXIF) |
| QR code | **QRCoder** + **ZXing.Net** (scan caméra Android) |
| Cartographie | **Mapsui** (composant carte) |
| Observabilité | **OpenTelemetry** (OTLP HTTP/protobuf) |
| Tests | **xUnit** (2 038 tests) |
### 6.2 Les projets (solution `Omika.slnx`)
| Projet | Rôle |
|---|---|
| `Omika.Core` | Cœur métier : domaine, DTO, services, sync mobile, observabilité |
| `Omika.Data` | Accès HFSQL : connexions (ODBC/OLE DB), repositories adaptatifs, modèles physiques, migration |
| `Omika.Api` | Web service REST (97 endpoints) |
| `Omika.Worker` | Service de synchronisation (arrière-plan) |
| `Omika.App` | UI Avalonia partagée (29 écrans) |
| `Omika.App.Desktop` | Hôte Windows/macOS/Linux |
| `Omika.App.Android` | Hôte Android |
| `Omika.App.iOS` | Hôte iOS |
| `Omika.App.Local` | Couche offline-first (SQLite + SyncEngine) |
| `Omika.Observability.External` | OpenTelemetry |
| `Omika.Diagnostics.*` | Centre de diagnostic (Core/Data/Api/Web/Worker) |
| `Omika.Tests`, `Omika.Diagnostics.Tests` | Tests xUnit |
| `tools/*` | Sondes de diagnostic HFSQL, générateur de modèles |
### 6.3 Topologie des flux
```
Mobile (Android/iOS) ──HTTP JSON + Bearer──► Omika.Api ──► écrit TOSRV (HFSQL Service)
│
Desktop (Windows) ───── accès HFSQL direct ─────────────────► │
▼
Omika.Worker (boucle)
TOSRV → MobReception → handler
→ MobLog (résultat terminal)
│
Omika.Core (métier) + Omika.Data (HFSQL)
```
- Le **mobile** ne touche **jamais** HFSQL directement (toujours via l'API).
- Le **desktop** accède à HFSQL en direct (comportement historique conservé).
- L'API **reçoit** les opérations ; le Worker les **applique** de façon asynchrone.
### 6.4 Bases de données
| Base HFSQL | Rôle |
|---|---|
| Business / WebService | Données métier (Batik ou Omika) |
| Service | Table `TOSRV` (file d'entrée des opérations) |
| WebService | Tables `MobReception` + `MobLog` (file de travail + journal) |
| Licence | Tables `ClientBase`, `Licence`, `LicenceHisto` |
| PostgreSQL `omika_diagnostics` | Données du centre de diagnostic |
| SQLite `omika.db` | Copie locale offline sur mobile (29-30 tables) |
### 6.5 Compatibilité bi-schéma Batik ↔ Omika
Un mécanisme central (`ApplicationRuntimeContext`, `SchemaDetection`,
repositories **adaptatifs** `Adaptive*`) permet de lire **indifféremment une
base Batik (ancienne) ou Omika (nouvelle)**. Les modèles physiques sont
auto-générés depuis un dictionnaire machine-readable
(`docs/schema-compatibilite-batik-omika.json`) : 48 tables Batik + 58 tables
Omika, 720 couples table-rubrique, avec gestion des champs « zombie » et des
GUID. **Le mobile ne sait jamais quelle génération de base il utilise.**
---
## 7. Différenciateurs (arguments de vente)
1. **Migration sans douleur du legacy** : on conserve la base HFSQL et les
données ; bascule progressive, double schéma Batik/Omika géré en interne.
2. **Offline-first robuste** : SQLite local + Outbox + synchronisation
idempotente avec retry — les techniciens travaillent partout (sous-sol,
zone blanche, chantier).
3. **Fiabilité de la sync** : GUID de transaction, réconciliation d'identités,
pas de doublon, rejet terminal propre (pas d'écrasement silencieux).
4. **Multi-plateforme, un seul code** : Android, iOS, desktop avec Avalonia.
5. **Monétisation native** : licence par appareil, activation sécurisée, jetons
signés — le produit est « vendable » tel quel.
6. **Rapports & e-mails intégrés** : modèles personnalisables, DOCX→PDF, SMTP
avec images inline — sans outil externe.
7. **Sécurité sérieuse** : token HMAC-SHA256, PBKDF2-SHA256, secure storage,
aucune exposition des PK/chemins serveur au mobile.
8. **Exploitabilité** : centre de diagnostic + télémétrie OpenTelemetry pour un
support professionnel (orientation SaaS).
9. **Qualité** : ≈ 3 900 tests automatisés, 0 `TODO`/`NotImplementedException`,
documentation abondante.
10. **Confidentialité par conception** : télémétrie pseudonymisée
(HMAC-SHA256, tenant-scoped), liste de données interdites, whitelist —
compatible RGPD et multi-tenant.
---
## 8. État d'avancement réel (honnêteté marketing)
Il est essentiel de **ne pas survendre**. État constaté dans le code :
- **Serveur / API** : très avancé — 97 endpoints couvrant la quasi-totalité du
périmètre métier (clients, contacts, sites, dossiers, interventions, RDV,
collaborateurs, équipes, suivi des temps, comptes rendus, documents, rapports,
e-mails, référence, licence, sync, bootstrap, back-office).
- **Mobile (consultation)** : complet — agenda, interventions, dossiers,
clients, sites, contacts, collaborateurs, documents/photos. Le périmètre de
travail est en **offline** (snapshot agenda ±14 j) ; les autres référentiels
sont chargés « à la demande » puis conservés en cache local.
- **Mobile (écriture)** : opérationnel pour **Intervention** (create/edit/
archive), **documents/photos**, **suivi des temps** (création). Les écritures
des autres entités (RDV, dossier, client, site, collaborateur, notes,
compte rendu) sont **backend-ready** mais pas encore branchées à l'UI mobile.
- **Handlers de sync** : `Intervention` (create/update/archive),
`SuiviTemps` (create), `RendezVous` (validation de temps / annulation),
`NoteInterne`/`NoteAdministrative` (create) sont branchés ; les autres types
d'entité (Client, Contact, Site, Dossier, Document…) passent par un fallback
propre (`NO_HANDLER` → rejet explicite).
- **Licence serveur** : logique complète + tests verts, mais la **base Licence
réelle** (topologie de déploiement) est un point de vigilance documenté
(« STOP » dans `docs/LOT_SERVEUR_LICENCE.md`).
→ Le site doit présenter Omika comme une **plateforme moderne en cours de
finalisation**, avec un cœur serveur solide et une expérience mobile offline
déjà démontrable (démo de l'agenda, des interventions, des photos, de la
saisie de temps et de la synchronisation).
---
## 9. Chiffres clés (crédibilité)
| Indicateur | Valeur |
|---|---|
| Projets | 13 (+ suite Diagnostics) |
| Écrans applicatifs | ≈ 37 vues Avalonia (29 écrans distincts) |
| ViewModels / vues XAML | 42 / 51 |
| Endpoints HTTP | ≈ 97 (plus de 90 routes desktop + mobile) |
| Tables HFSQL manipulées | 65 (118 classes de modèles) |
| Couples table-rubrique mappés | 720 |
| Repositories (Batik + Omika + adaptatifs) | 19 + 20 + 22 |
| Instructions SQL brutes | ~500 |
| Tests automatisés | **≈ 3 900** (Omika.Tests ≈ 3 370 + Diagnostics.Tests ≈ 536) |
| Lignes de code (production) | ~85 200 C# + ~7 700 XAML |
| Lignes de test | ~43 800 |
| Corpus legacy analysé | ~600 000 lignes |
| Effort humain équivalent estimé | ~800 jours-homme (3-4 ans / 1 dev) |
| Version | 1.0.0 |
---
## 10. Assets disponibles pour le site web
Le dépôt contient déjà des éléments directement réutilisables :
- **Logos** (`src/Logos/`) :
- `Logo_principal.svg`
- `Logo_mobile.svg`
- `Logo_secondaire_blanc.svg`
- `Logo_secondaire_violet.svg`
- **Maquette UX complète** : `docs/maquetteOmika.html` — design system complet
(couleurs, composants, thèmes clair/sombre, responsive mobile/desktop), 593
lignes. Elle montre les écrans réels du produit (agenda, équipe,
interventions, dossiers, clients, lieux, rapports, e-mails, paramètres).
- **Couleur de marque** : indigo `#4f46e5` (dégradé `#4f46e5 → #3730a3`), avec
thème sombre `#0c1320` (extrait de la maquette).
- **Marque** : monogramme « OM », accroche « Interventions terrain ».
---
## 11. Angles et messages marketing suggérés
1. **« Votre ancien logiciel, enfin moderne »** — le remplacement de WinDev sans
perdre les données (pérenne pour les acheteurs déjà équipés en Batik).
2. **« Vos techniciens travaillent partout »** — offline-first, photos,
documents, temps saisis sur le terrain, synchronisation automatique.
3. **« Du RDV au rapport, sans ressaisie »** — cycle complet intégré
(planning → intervention → temps → compte rendu → rapport → e-mail).
4. **« Un produit vendable et sécurisé »** — licence par appareil, activation,
rôles et droits, jetons signés.
5. **« Exploitable et supportable »** — centre de diagnostic, télémétrie,
monitoring (orientation SaaS).
6. **« Du sérieux industriel »** — .NET 10, tests automatisés, compatibilité
double schéma, documentation.
### Proposition de pages pour le site
- **Accueil** : pitch + hero (maquette/logo) + bénéfices clés.
- **Fonctionnalités** : agenda/planning, interventions, documents/photos,
suivi des temps, rapports & e-mails, synchronisation.
- **Mobile & offline** : démonstration du mode hors-ligne.
- **Sécurité & licence** : activation, rôles, jetons.
- **Exploitation / SaaS** : centre de diagnostic.
- **À propos / migration** : l'histoire Batik → Omika, compatibilité des
données.
- **Tarifs / Contact** (à définir avec l'éditeur).
---
## 12. Limites et points de vigilance (à connaître avant de rédiger)
- Le produit est en **version 1.0.0**, encore en finalisation sur la partie
écriture mobile de certaines entités (RDV, dossier, client, site, notes,
compte rendu : backend prêt, UI mobile à brancher).
- La **migration Batik → Omika** (`MigrationService`) est un **squelette** :
la conversion clés numériques → clés textuelles et le suivi
`ElementModifie`/`ElementSupprime` sont prévus mais pas encore opérationnels.
- La **base de licence réelle** doit être validée sur la topologie de
production (documenté, pas bloquant pour le code).
- La **suppression physique** (DELETE) est volontairement refusée au profit de
l'**archivage** (traçabilité) — c'est aussi un argument (aucune perte).
- **Accès HFSQL via OLE DB** : Windows-only, dépendant des drivers PC SOFT
installés sur l'hôte serveur.
- La conversion **DOCX → PDF** repose sur **LibreOffice headless**
(`soffice --headless`) : prérequis de déploiement serveur à documenter.
- **Auth à deux vitesses** : les routes desktop/back-office s'appuient sur le
réseau/hébergement (reverse proxy) plutôt que sur un rejet 401 par endpoint ;
seules les routes `/api/*` vérifient le jeton.
- Durcissements à traiter avant une production large : base SQLite locale non
chiffrée, jeton Bearer en clair (desktop), pas de rate-limiting sur les
endpoints publics de licence (voir `docs/ESTIMATION_EFFORT_DEVELOPPEMENT.md`).
---
*Document généré à partir de l'analyse du code source (`src/`), de la solution
et de la documentation du dépôt `D:\Avalonia\Omika`.*
+941
View File
@@ -0,0 +1,941 @@
# Analyse des sources Windev Batik / OMIKA
**Objet :** préparer la compréhension fonctionnelle et technique nécessaire à une future migration vers une application Avalonia et une API .NET, sans créer à ce stade ni projet, ni modèle, ni code applicatif.
**Date d'analyse :** 6 août 2026
**Sources analysées :** `BATIK.md` et `OMIKA.md`
---
## 1. Périmètre et limites de l'analyse
Le répertoire de travail fourni contient uniquement deux exports Markdown de documentation technique générée par WINDEV :
- `BATIK.md`, qui documente en réalité le projet WINDEV **`Isis_BO`**, situé historiquement sous `D:\Projets Windev\Batik\WD30\Dev` ;
- `OMIKA.md`, qui documente le projet WINDEV **`Batik`**, situé sous `D:\Projets Windev\Batik\Nouvelle Version\Batik`, mais dont l'initialisation définit le nom d'application **`Omika`**.
## 2. Convention de nommage utilisée dans ce document
Les noms des fichiers et des projets sont croisés. Pour éviter toute ambiguïté :
| Désignation dans cette analyse | Source | Projet documenté | Interprétation |
|---|---|---|---|
| **Ancienne version / legacy Batik** | `BATIK.md` | `Isis_BO` | Ancien socle WINDEV, multi-configuration et historiquement utilisé pour Batik/Isis/SLAMM/Bouvard/Artisans Partenaires. |
| **Nouvelle version WINDEV / OMIKA** | `OMIKA.md` | `Batik`, avec `cNomApp = "Omika"` | Refonte WINDEV plus récente, distincte de la future cible Avalonia/.NET. |
## 3. Synthèse générale
Les deux applications sont des applications WINDEV Windows de gestion d'interventions couvrant notamment :
- l'authentification et les droits d'accès ;
- les sociétés, collaborateurs et équipes ;
- les clients, contacts, adresses et sites ;
- les dossiers et interventions ;
- le planning, les rendez-vous et leur affectation aux collaborateurs ;
- le suivi des temps ;
- les documents, comptes rendus, rapports et modèles de courriels ;
- les paramètres métier et utilisateur ;
- des intégrations avec Batigest, des bases externes, le mobile, un service de licence et SysLog.
## 4. Structure générale observée
### 4.1 Ancienne version / `BATIK.md` / projet `Isis_BO`
#### Organisation logique
L'ancien projet est organisé autour de plusieurs familles d'éléments :
- `FEN_*` : fenêtres principales, fenêtres de choix, popups, login, agenda, saisie des temps, utilitaires ;
- `FI_*` : fenêtres internes de fiches, listes, planning, tableaux de bord et paramétrage ;
- `ETAT_*` : états d'impression et variantes clients, notamment SLAMM, GAP et Bouvard ;
- `M*` : classes métier ou classes de mapping d'une entité ;
- `MTableauDe*` : classes de collection ou de présentation tabulaire ;
- `PFiche*` : classes de présentation/édition d'une fiche ;
- `PTable*` : classes de présentation de listes ou tables ;
- `COL_*` et `LIB_*` : collections de procédures globales ;
- composants internes et externes, dont `COMP_SysLog` et `WDFAA`.
La multiplication des classes `M*`, `MTableauDe*`, `PFiche*` et `PTable*` explique l'effectif élevé de 181 classes. Cette architecture semble s'appuyer sur une couche de présentation généralisée autour de chaque table, mais elle ne garantit pas une séparation nette entre domaine, accès aux données et interface.
## 6. Description de la base de données
### 6.1 Emplacement de la description
La description complète de la base se trouve dans les analyses WINDEV documentées :
- ancienne version : `Isis_BO.ana\Isis_BO.wda` ;
- nouvelle version : `Batik.ana\Batik.wda`.
Les parties « Schéma des données », « Dictionnaire des rubriques », « Groupes de fichiers », « Liaisons », « Connexions » et « Tables et rubriques » des deux documents constituent la source principale pour préparer un futur modèle de données .NET.
### 6.2 Statistiques comparées
| Mesure | Ancienne version | Nouvelle version OMIKA | Écart |
|---|---:|---:|---:|
| Génération de l'analyse | 314 | 367 | +53 |
| Tables | 48 | 58 | +10 |
| Rubriques | 720 | 872 | +152 |
| Liaisons déclarées | 38 | 38 | 0 |
| Connexions déclarées | 1 | 1 | 0 |
| Groupes de fichiers | 2 | 2 | 0 |
Les deux analyses utilisent une connexion de type **HFSQL Classic** nommée `ConnexionLocale`, associée à `<MAIN_HF_DIR>`. Les tables sont réparties au moins entre les groupes de protection `ProtectionStd` et `ProtectionPlus`.
### 6.3 Tables de l'ancienne version — 48 tables
`AccesEtDroit`, `Action`, `Adresse`, `BddExterne`, `CheckList`, `CheckList_Collab`, `Client`, `Client_Adresse`, `Client_BddExterne`, `Client_Lieu`, `Collaborateur`, `CompteRendu`, `Compteur`, `Document`, `DocumentNote`, `Dossier`, `Equipe`, `Equipe_Collaborateur`, `EtatIntervention`, `Intervention`, `Intervention_BddExterne`, `Intervention_ChampLibre`, `Intervention_Collaborateur`, `Intervention_Personne`, `Lieu`, `Lieu_Personne`, `MailCat`, `MailModele`, `MessageExterne`, `MessageInterne`, `Mobile`, `MobLog`, `MobReception`, `ModeleRapportMob`, `MotClef`, `ParametreApplication`, `ParametreMetier`, `ParametreMetierEntete`, `ParametreSysteme`, `ParametreUtilisateur`, `Personne`, `Planning_RDV`, `Planning_RDV_Personne`, `Societe`, `Societe_Collaborateur`, `SuiviDesTemps`, `Telephone`, `Utilisateur`.
### 6.4 Tables de la nouvelle version — 58 tables
`AccesEtDroit`, `Action`, `Adresse`, `BddExterne`, `CategorieAcces`, `CheckList`, `CheckList_Collab`, `Client`, `Client_Adresse`, `Client_BddExterne`, `Client_Lieu`, `Collaborateur`, `CompteRendu`, `Compteur`, `Doc_Aide`, `Document`, `DocumentNote`, `Dossier`, `ElementModifie`, `ElementSupprime`, `Equipe`, `Equipe_Collaborateur`, `EtatIntervention`, `Etats`, `Intervention`, `Intervention_BddExterne`, `Intervention_ChampLibre`, `Intervention_Personne`, `Lieu`, `Lieu_Personne`, `MailCat`, `MailModele`, `MessageExterne`, `MessageInterne`, `Mobile`, `MobLog`, `MobReception`, `ModeleRapport`, `ModeleRapportMob`, `MotClef`, `ParametreApplication`, `ParametreMetier`, `ParametreMetierEntete`, `ParametreSysteme`, `ParametreUtilisateur`, `Personne`, `Planning_RDV`, `Planning_RDV_Personne`, `RDV_Collaborateur`, `RDV_Contact`, `RDV_Planning`, `Role`, `RubriquesDocument`, `Societe`, `Societe_Collaborateur`, `SuiviDesTemps`, `Telephone`, `Utilisateur`.
### 6.5 Delta de tables
#### Tables ajoutées dans la nouvelle version
- `CategorieAcces`
- `Doc_Aide`
- `ElementModifie`
- `ElementSupprime`
- `Etats`
- `ModeleRapport`
- `RDV_Collaborateur`
- `RDV_Contact`
- `RDV_Planning`
- `Role`
- `RubriquesDocument`
#### Table supprimée du nouveau schéma
- `Intervention_Collaborateur`
Le solde est bien de dix tables supplémentaires.
### 6.6 Rubriques des tables ajoutées
La liste ci-dessous est issue des sections « Tables et rubriques » de `OMIKA.md`.
#### `CategorieAcces`
- `CAC_PKID`
- `CAC_Cle`
- `CAC_Libelle`
- `CAC_Ordre`
- `CAC_Mobile`
- `CAC_AncienCode`
- `CreationDate`
- `CreationUser`
- `ModifDate`
- `ModifUser`
- `CAC_Description`
#### `Doc_Aide`
- `DA_PKID`
- `DA_Fenetre`
- `DA_Champs`
- `DA_Titre`
- `DA_Contenu`
- `DA_Tags`
- `DA_TexteOriginal`
- `ModifUser`
- `ModifDate`
#### `ElementModifie`
- `EM_PKID`
- `EM_Fichier`
- `EM_Date`
- `EM_IdUser`
- `EM_IdEnrg`
#### `ElementSupprime`
- `ES_PKID`
- `ES_NumEnrg`
- `ES_IdEnrg`
- `ES_Fichier`
- `ES_IdUser`
- `ES_DateHeure`
- `ES_NumEnr_Id`
#### `Etats`
- `ETA_PKID`
- `ETA_Groupe`
- `ETA_Libelle`
- `ETA_Ordre`
- `ETA_Valeur`
#### `ModeleRapport`
- `MRP_PKID`
- `MRP_Nom`
- `MRP_Modele`
- `MRP_EnvoiMob`
- `MRP_Extra`
- `CreationDate`
- `CreationUser`
- `ModifDate`
- `ModifUser`
#### `RDV_Collaborateur`
- `RVC_PKID`
- `RVC_GUID_RDV`
- `RVC_GUID_Colb`
- `RVC_DateDeb`
- `RVC_DateFin`
- `RVC_CleStatut`
- `RVC_Responsable`
- `RVC_TempsValide`
- `RVC_DateExport`
- `GUID`
- `Archive`
- `RVC_CR`
- `RVC_Superposable`
#### `RDV_Contact`
- `RVCT_PKID`
- `GUID`
- `RVCT_GUID_Pers`
- `RVCT_GUID_RDV`
#### `RDV_Planning`
- `RV_PKID`
- `RV_IdSociete`
- `RV_IdDossier`
- `RV_IdInter`
- `RV_CleType`
- `RV_CleStatut`
- `RV_Objet`
- `RV_Description`
- `RV_IdLieu`
- `RV_SAV`
- `RV_CleOrigineAnnu`
- `RV_CleMotifAnnu`
- `RV_RappelSMS`
- `Archive`
- `CreationDate`
- `CreationUser`
- `ModifDate`
- `ModifUser`
- `GUID`
- `RV_Groupe`
- `RV_PlusieursJours`
- `RV_Date`
#### `Role`
- `R_PKID`
- `R_Libelle`
- `R_AncienCode`
- `CreationDate`
- `CreationUser`
- `ModifDate`
- `ModifUser`
#### `RubriquesDocument`
- `RD_PKID`
- `RD_Nom`
- `RD_Description`
- `RD_Rubrique`
- `RD_TypeMembre`
- `RD_MasqueSaisie`
- `RD_EnvoiMob`
- `RD_BD`
- `RD_Modifiable`
- `RD_Groupe`
- `RD_IdRubParent`
- `RD_MembreMob`
- `RD_Ordre`
### 6.7 Évolutions de rubriques observées sur les tables communes
Les principales évolutions visibles sont les suivantes :
| Table | Évolutions notables observées dans OMIKA |
|---|---|
| `Action` | Ajout de `AC_CleTypeAction`. |
| `Adresse` | Ajout de `AD_Adresse` et `GUID`. |
| `Client` | Ajout de clés textuelles pour type, statut, apporteur, origine et mode de règlement, ainsi que `GUID`. |
| `Client_Adresse` | Ajout de `CA_CleType`, `CA_Facturation` et `CA_Livraison`. |
| `Collaborateur` | Ajout de `CO_CleCivilite`, `CO_CleStatut` et `GUID`. |
| `CompteRendu` | Ajout d'une clé de type de signataire et d'un GUID de rendez-vous. |
| `Document` | Ajout de clés textuelles de catégorie et d'état documentaire. |
| `Dossier` | Ajout de clés textuelles de type et statut, plus `GUID`. |
| `EtatIntervention` | Ajout de `EI_CleEtat`. |
| `Intervention` | Ajout de clés textuelles de type et état, plus `GUID`. |
| `Intervention_Personne` | Ajout d'une clé de rôle. |
| `Lieu` | Ajout d'une clé de type et de `GUID`. |
| `Lieu_Personne` | Ajout de clés de rôle et de type. |
| `MailModele` | Ajout de données supplémentaires et d'un indicateur d'envoi mobile. |
| `MessageExterne` | Ajout d'une rubrique de pièces jointes. |
| `ParametreMetier` | Ajout de `PA_CLE` et d'une catégorie. |
| `ParametreMetierEntete` | Ajout d'une catégorie et d'un type. |
| `Personne` | Ajout de clés de civilité et de rôle, plus `GUID`. |
| `Planning_RDV` | Ajout de clés textuelles de type, statut, origine et motif d'annulation. |
| `Societe` | Ajout de `GUID` et d'un identifiant SIREN. |
| `SuiviDesTemps` | Ajout de clés de type de temps et de rendez-vous, de notes collaborateur/administration et d'un GUID de rendez-vous/collaborateur. |
| `Utilisateur` | Ajout d'une référence vers le nouveau rôle. |
Cette liste cible les changements structurants utiles à la migration ; elle ne remplace pas un diff machine exhaustif des 872 rubriques.
### 6.8 Tendances de modélisation
#### Passage d'identifiants numériques à des clés stables
La nouvelle version ajoute de nombreuses rubriques du type `*_Cle*`, souvent des chaînes de longueur 16. Les anciens identifiants numériques correspondants restent parfois dans l'analyse mais sont marqués **zombie**.
Exemples documentés :
- `CO_IDCivilite`, `CO_IDStatut` ;
- `PE_IDCivilite`, `PE_IDRole` ;
- `IN_IDTypeIntervention`, `IN_IDEtatIntervention` ;
- `CL_IDType`, `CL_IDStatut`, `CL_IDApporteur`, `CL_IDApportOrigine`, `CL_IDModeReglement` ;
- `LI_IDType` ;
- anciens identifiants de type et statut de `Planning_RDV` ;
- anciennes rubriques de type dans `SuiviDesTemps` ;
- anciennes catégories/états du document.
Cela suggère une stratégie de découplage des paramètres métier vis-à-vis des identifiants physiques. Pour la future API, il faudra déterminer quelle valeur constitue désormais la vérité métier : ancien identifiant, nouvelle clé, ou combinaison des deux durant une transition.
#### Introduction des GUID
Des GUID sont ajoutés sur plusieurs entités principales et sont centraux dans les nouvelles tables de rendez-vous. Cela paraît destiné à stabiliser l'identité entre bases, synchronisations ou clients, mais cette finalité doit être confirmée.
#### Refonte du planning
La coexistence de `Planning_RDV`, `Planning_RDV_Personne` et des nouvelles tables `RDV_Planning`, `RDV_Collaborateur`, `RDV_Contact`, combinée aux procédures de migration de `FEN_MaJParemApp`, indique une refonte progressive du modèle de rendez-vous.
Le statut exact de cette coexistence est une question prioritaire :
- ancien modèle encore actif ;
- nouveau modèle actif avec compatibilité ;
- migration partielle selon les clients ;
- ou double écriture temporaire.
---
## 7. Liaisons et intégrité
Les deux analyses déclarent 38 liaisons, mais leur composition évolue.
### Liaisons ou associations notables de l'ancienne version
- `Intervention` vers `Action` ;
- `Intervention` et `Collaborateur` via `Intervention_Collaborateur` ;
- `Planning_RDV` vers les personnes via `Planning_RDV_Personne` ;
- relations client/adresse, client/lieu, lieu/personne, société/collaborateur, équipe/collaborateur ;
- relations documentaires et intervention/personne ;
- association entre modèles de mail et catégories.
### Évolutions notables dans OMIKA
- nouvelles associations entre `RDV_Planning` et `RDV_Collaborateur` ;
- nouvelles associations entre `RDV_Planning`, `RDV_Contact` et `Personne` ;
- disparition de la table `Intervention_Collaborateur` ;
- nouvelles tables utilisant des GUID sans que toutes les relations soient nécessairement matérialisées par des contraintes HFSQL visibles.
Le nombre total de liaisons restant identique malgré onze ajouts et une suppression de table suggère que certaines relations du nouveau modèle sont gérées par le code ou par des clés logiques plutôt que par des contraintes déclarées. Cette conclusion est une hypothèse à vérifier dans l'analyse physique et sur une base réelle.
---
## 8. Traitements métier identifiés
### 8.1 Localisation générale
Les traitements métier sont répartis dans quatre zones :
1. les collections de procédures globales ;
2. les classes `M*` et classes transverses ;
3. les procédures locales des fenêtres et fenêtres internes ;
4. les requêtes SQL écrites directement dans ces procédures.
La future extraction vers une API ne pourra donc pas se limiter à convertir les classes. Il faudra également analyser les traitements d'événements, les boutons, les validations, les filtres, les requêtes et les effets de bord dans les fenêtres.
### 8.2 Ancienne version — collections principales
| Collection | Responsabilités observées |
|---|---|
| `COL_COMMUN` | Utilitaires métier, chargement de paramètres, gestion de sociétés/équipes/collaborateurs, compteurs, jours fériés, planning et rendez-vous. |
| `COL_CONNEXION` | Connexions HFSQL, utilisateurs et groupes, droits de base, préparation des fichiers, contrôle utilisateur et structure. |
| `COL_HFSQL` | Paramètres de connexion, lecture INI, ouverture de base, critères, requêtes et utilitaires HFSQL. |
| `COL_DOCUMENT` | Chemins documentaires, fichiers, catégories et autorisations documentaires. |
| `COL_IHM` | Filtres, tris, blocages, mappage de champs et services d'interface. |
| `COL_THREAD` | Recherches ou chargements exécutés dans des threads. |
| `COL_MOBILE` | Services ou synchronisations mobiles. |
| `COL_VERSION` | Mise à niveau de paramètres et de structures selon la version. |
| `LIB_ModelesEmails` | Construction de courriels, remplacement de balises et modèles. |
| `LIB_Synchro` | Synchronisation avec Batigest ou sources externes. |
| `COL_EXPORT` | Exports, notamment JSON. |
| `COL_PrefAGD` | Préférences d'agenda. |
| `COL_Visite` | Visites guidées. |
| `COL_TEMPORAIRE` | Procédures temporaires ou expérimentales à auditer avant migration. |
### 8.3 Nouvelle version — collections principales
| Collection | Responsabilités observées |
|---|---|
| `Lib_InitProjet` | Chargement des caches globaux, rôles, paramètres, états, sociétés, collaborateurs, modèles de mail/rapport et aide. |
| `Lib_Planning` | Création, modification et suppression de rendez-vous ; rendez-vous multi-jours ; groupes de collaborateurs ; contacts ; détection de conflits ; ressources et rendu du planning. |
| `Lib_Parametres` | Lecture/écriture des paramètres application et système, chiffrement/déchiffrement, groupes de paramètres. |
| `Lib_DroitsAcces` | Calcul et contrôle des droits. |
| `Lib_Version` | Création/mise à jour des paramètres métier, catégories d'accès, états et version applicative. |
| `LIB_HFSQL` | Fonctions HFSQL communes. |
| `LIB_Documents` | Recherche et manipulation des fichiers et arborescences documentaires. |
| `LIB_RubMailRapport` | Définition et substitution des rubriques de rapports/courriels. |
| `LIB_ElementsModifies` | Suivi des modifications d'entités. |
| `LIB_Surveille` | Surveillance HFSQL, file d'événements et rafraîchissement d'interface. |
| `LIB_Batigest` | Intégration avec Batigest. |
| `Lib_Email` | Envoi et préparation des courriels. |
| `Lib_AppMobile` | Fonctions mobiles. |
| `Lib_Commun` | Utilitaires et règles transverses. |
| `Lib_UI`, `Lib_Champs`, `LIB_TypeChamp` | Abstraction partielle des champs et comportements UI. |
| `LIB_Aide`, `LIB_VisitesGuidées` | Aide contextuelle et visites guidées. |
### 8.4 Fenêtres et classes à forte valeur métier
#### Authentification et sécurité
- `FEN_Login`
- ancienne version : `COL_CONNEXION`, `COL_HFSQL`, `MAccesEtDroit`, `MUtilisateur`, `PFicheUtilisateur`, `PTableUtilisateur`
- nouvelle version : `ConnexionHFSQL`, `Droit`, `MAcces`, `MRole`, `MUtilisateur`, `Lib_DroitsAcces`, `Lib_InitProjet`
#### Référentiels clients et sites
- clients : `FEN_Fiche_Client`, `FI_Liste_Client`, `MClient`
- contacts : `FEN_Fiche_Contact` ou `FI_Fiche_Personne`, `MPersonne_Ct` / `MPersonne`
- sites/lieux : `FEN_Fiche_Site` ou `FI_Fiche_Lieu`, `MLieu`
- adresses : `FEN_Fiche_Adresse`, `MAdresse`, `MAdresseClient`
- sociétés : `FEN_Fiche_Societe`, `MSociete`
- collaborateurs et équipes : `FEN_Fiche_Colb`, `MCollaborateur`, `FEN_Fiche_Equipe`, `MEquipe`
#### Dossiers et interventions
- `FEN_Fiche_Dossier`, `FEN_Fiche_Dossier_Creation`, `FI_Liste_Dossier`, `MDossier`
- `FI_Fiche_Intervention`, tableaux de bord d'intervention, `MIntervention`
- actions, comptes rendus, messages, documents et donneurs d'ordre associés
#### Planning et rendez-vous
- `FEN_Agenda`, `FI_Agenda`, `FI_Planning`, `FEN_Fiche_RDV`, `FI_RDV`, `FEN_RDV_Attente`
- `MRDV`, `MRDV_Planning`, `MRDV_Colb`, `MRDV_Contact`
- `Lib_Planning`
Le code documenté gère notamment :
- la création d'un rendez-vous simple ;
- les rendez-vous de nuit ;
- les rendez-vous répartis sur plusieurs jours ;
- les rendez-vous affectés à plusieurs collaborateurs ;
- la modification d'une occurrence ou d'un groupe ;
- la détection de chevauchements non superposables ;
- l'association de contacts ;
- la synchronisation des données affichées dans le planning ;
- les préférences de titre, description, bulle, heures visibles et granularité.
#### Suivi des temps
- `FEN_Suivi_Temps`
- `FEN_Fiche_Suivi_Temps`
- `FEN_Fiche_Suivi_Temps_RDV`
- `FEN_Liste_Colb_Suivi_Temps`
- `FI_Temps`
- `MSuiviTemps`
Les traitements incluent la saisie, la validation, la pondération, le rapprochement avec les rendez-vous et des totaux par type de temps ou type de rendez-vous.
#### Documents, rapports et courriels
- `FEN_Creation_Doc_Rub`
- `FEN_Fiche_Document`
- `FEN_Rapport_Document_Inter`
- `FEN_Fiche_Modele_Rapport`
- `FEN_Fiche_Modele_Mail`
- `LIB_Documents`
- `LIB_RubMailRapport`
- `Lib_Email`
- `MDocument`, `MModeleRapport`, `MModeleMail`, `MRubriqueDoc`
Les documents peuvent dépendre de chemins configurables et d'une structure de répertoires par client, intervention ou site. Les modèles de rapports et de courriels utilisent des rubriques dynamiques et des substitutions de mots-clés.
#### Intégrations
- Batigest : connecteurs/classes et bibliothèques dédiées ;
- bases externes : table `BddExterne`, classes de configuration et paramètres JSON ;
- mobile : bibliothèques, tables `Mobile`, `MobLog`, `MobReception`, services web ;
- licence : service web configuré dans le projet ;
- SysLog : composant de journalisation ;
- courriel local : composant spécifique dans OMIKA.
### 8.5 Traitements de migration déjà présents dans OMIKA
`FEN_MaJParemApp` est un élément essentiel pour comprendre la transformation de l'ancien modèle vers le nouveau. La documentation y recense notamment des procédures de :
- mise à jour des tables ;
- création de rubriques documentaires ;
- mise à jour des adresses ;
- génération ou mise à jour des GUID ;
- migration des rendez-vous vers les nouveaux fichiers ;
- traitement des rendez-vous groupés, individuels et multi-jours ;
- mise à jour des profils et rôles ;
- migration des paramètres métier ;
- rapprochement entre comptes rendus et rendez-vous.
`Lib_Version` complète ces traitements par des créations/mises à jour de paramètres, états et catégories d'accès. Ces deux zones devront être étudiées avant toute conception d'un script de migration .NET, car elles contiennent probablement le meilleur historique exécutable des transformations déjà décidées.
---
## 9. Constantes globales et configuration
### 9.1 Ancienne version
Les constantes sont réparties entre :
- l'initialisation du projet `Isis_BO` ;
- `COL_Constantes` ;
- certaines collections métier, par exemple les paramètres et le planning ;
- les variantes conditionnelles de compilation.
Catégories observées :
- version du projet ;
- noms de produit et de fichiers INI ;
- chemins et formats de répertoires documentaires ;
- groupes d'utilisateurs HFSQL ;
- identifiants de paramètres métier ;
- constantes d'affichage et de planning ;
- balises de signature de courriel ;
- constantes de licence et de services ;
- valeurs liées au chiffrement et aux comptes techniques.
Le projet contient plusieurs configurations de compilation : Batik, Isis, SLAMM, Bouvard, Artisans Partenaires, utilitaire et récupération d'heures. Elles modifient notamment le produit, le fichier INI, le service de licence, les logos et certains comportements.
### 9.2 Nouvelle version OMIKA
Les constantes sont réparties entre :
- l'initialisation du projet `Batik` ;
- `LIB_ConstantesGlobal` ;
- `ConnexionHFSQL` ;
- `Lib_Parametres` ;
- `Lib_Planning` ;
- `Lib_Version` ;
- diverses classes et fenêtres.
Éléments structurants confirmés :
- nom d'application `Omika` ;
- répertoire et fichier `Omika.ini` ;
- constantes de résultats oui/non et code d'erreur de blocage HFSQL ;
- clés des paramètres métier ;
- groupes de paramètres par domaine ;
- options d'affichage du planning ;
- noms de fenêtres et paramètres d'ouverture ;
- constantes de droits et catégories d'accès.
### 9.3 Point de sécurité
Les deux documentations exposent des secrets, mots de passe techniques, clés de chiffrement et adresses de services directement dans le code source ou les exemples de configuration. Les valeurs ne sont volontairement pas reproduites dans ce document.
Avant toute migration, il faudra :
1. inventorier ces secrets et leurs usages ;
2. les révoquer ou les faire tourner si nécessaire ;
3. les retirer du code et de l'historique distribué ;
4. les stocker dans une solution de secrets adaptée aux environnements .NET ;
5. séparer strictement les configurations développement, recette et production.
---
## 10. Connexions HFSQL
### 10.1 Connexion déclarée dans les analyses
Les deux analyses décrivent une connexion `ConnexionLocale` de type **HFSQL Classic**, utilisant `<MAIN_HF_DIR>`. En exécution, le code reconfigure toutefois les fichiers vers une connexion HFSQL Client/Serveur.
### 10.2 Ancienne version
La construction de la connexion est distribuée entre `COL_HFSQL`, `COL_CONNEXION`, l'initialisation du projet et `FEN_Login`.
Le fichier INI contient une section générale et une ou plusieurs sections de base. Les clés documentées incluent :
- nombre de bases ;
- ordre ;
- serveur ;
- port ;
- nom de base ;
- libellé logique ;
- indicateur de chiffrement.
Le flux d'ouverture observé est approximativement :
1. lecture du fichier INI ;
2. construction d'une liste de connexions ;
3. sélection éventuelle d'une base dans `FEN_Login` ;
4. création/configuration d'un objet `Connexion` ;
5. configuration du provider Client/Serveur et de l'accès lecture/écriture ;
6. affectation du serveur, du port, de la base et d'un compte technique ;
7. `HChangeConnexion` pour les fichiers ;
8. application des mots de passe de groupes de fichiers ;
9. ouverture et contrôle de la structure ;
10. contrôle de l'utilisateur applicatif et de ses droits ;
11. éventuelle reconnexion avec l'utilisateur final.
`COL_CONNEXION` contient également de la gestion d'utilisateurs et groupes HFSQL, des droits de base, des mises à jour de structure et des traitements de connexion des services mobiles.
### 10.3 Nouvelle version OMIKA
La logique est davantage encapsulée dans la classe `ConnexionHFSQL`, bien que des utilitaires restent dans `LIB_HFSQL` et le login.
Le flux observé est :
1. recherche de `Omika.ini` dans le répertoire commun global ;
2. lecture des mêmes familles de clés `[CONFIG]` et `[BASE_n]` ;
3. création d'un squelette de fichier INI si aucune configuration n'existe ;
4. configuration d'une connexion Client/Serveur en lecture/écriture ;
5. utilisation d'un chiffrement AES128 pour la connexion ;
6. affectation des mots de passe aux groupes `ProtectionPlus` et `ProtectionStd` ;
7. `HChangeConnexion("*", connexion)` ;
8. ouverture de la base ;
9. test d'ouverture d'une table pour détecter une configuration de chiffrement incorrecte ;
10. création ou mise à jour de l'utilisateur HFSQL ;
11. rattachement au groupe utilisateur ;
12. reconnexion avec le compte utilisateur.
La classe contient également une politique de mot de passe dérivé du login et une limitation des connexions pour les utilisateurs standards. Ces mécanismes devront être remis à plat dans la future architecture : l'API devra devenir le point de contrôle d'accès, et l'application Avalonia ne devrait pas recevoir des identifiants HFSQL privilégiés.
### 10.4 Bases externes
Les deux versions possèdent une table `BddExterne` avec des paramètres JSON. La nouvelle version ajoute :
- `MConnexionBaseExt` ;
- `FEN_Parametres_Base_Ext` ;
- `FI_Configuration_BaseExt` ;
- une structure `ST_ParamConfigBDExt` ;
- `LIB_Batigest`.
Les contrats exacts, moteurs de base, requêtes, modes d'authentification et responsabilités de synchronisation restent à inventorier dans les sources physiques et les configurations réelles.
---
## 11. Différences principales entre l'ancienne version Batik et OMIKA
| Axe | Ancienne version | Nouvelle version OMIKA | Conséquence pour la migration |
|---|---|---|---|
| Projet WINDEV | `Isis_BO` | `Batik`, application `Omika` | Confirmer le référentiel fonctionnel réellement en production. |
| Schéma | `Isis_BO.wda` | `Batik.wda` | Un diff officiel du schéma est indispensable. |
| Tables/rubriques | 48 / 720 | 58 / 872 | Le nouveau modèle est plus riche et inclut des migrations en cours. |
| Classes | 181 | 46 | L'ancienne architecture est très généralisée ; la nouvelle est plus compacte. |
| Modèles de champs | 1 | 35 | OMIKA mise davantage sur la composition UI. |
| États | 26 | 11 | Des variantes d'impression ont été supprimées, regroupées ou non reprises. |
| Planning | `Planning_RDV` et tables associées | Coexistence de l'ancien modèle et de `RDV_Planning`/`RDV_Collaborateur`/`RDV_Contact` | Identifier le modèle cible réellement utilisé et les règles de double écriture. |
| Accès | Profils/modules et classes générées | `Role`, `CategorieAcces`, `Droit`, `Lib_DroitsAcces` | Reconstituer une matrice de permissions fonctionnelle. |
| Identifiants métier | Nombreux identifiants numériques | Clés textuelles et GUID ajoutés | Définir les identifiants publics de l'API et les mappings de migration. |
| Modèles de rapport | Principalement états et modèles mobiles | Table `ModeleRapport`, rubriques documentaires et éditeurs dédiés | Prévoir un sous-chantier rapports/documents. |
| Suivi des changements | Limité ou dispersé | `ElementModifie`, `ElementSupprime`, surveillance HFSQL | Comprendre les besoins de synchronisation, audit ou rafraîchissement. |
| Aide | Fichier d'aide/visites | Table `Doc_Aide`, aide contextuelle et visites | Décider si ces données sont à migrer vers l'API ou restent côté client. |
| Connexion | Collections globales et variantes de compilation | Classe `ConnexionHFSQL` plus centralisée | Ne pas reproduire l'accès direct HFSQL dans Avalonia ; passer par l'API. |
| Logique métier | Classes de présentation + fenêtres + collections | Classes plus légères, bibliothèques, mais toujours beaucoup de logique dans les fenêtres | Prévoir une phase de caractérisation avant extraction. |
| Configuration | Multiples INI et configurations compilées | `Omika.ini`, bases externes et paramètres en base | Formaliser une matrice de configuration par client/environnement. |
---
## 12. Risques et points de vigilance
### 12.1 Champs zombie encore utilisés
La documentation OMIKA recense de nombreux avertissements indiquant que des champs zombie sont encore référencés dans des fenêtres, classes et requêtes. Cela représente un risque de divergence entre :
- la valeur historique ;
- la nouvelle clé métier ;
- les écrans encore non migrés ;
- les scripts de mise à jour ;
- les exports et états.
Un inventaire automatisé des références à chaque champ zombie est prioritaire.
### 12.2 Coexistence de deux modèles de rendez-vous
Le planning est l'une des zones les plus complexes. Les tables historiques et nouvelles semblent coexister, avec des scripts de migration et plusieurs types de regroupement. Une erreur de compréhension pourrait entraîner des pertes d'affectations, contacts, heures ou comptes rendus.
### 12.3 Logique métier dans l'interface
Des règles métier, requêtes SQL et effets de bord résident dans les procédures locales des fenêtres. Une migration écran par écran sans extraction préalable du comportement risquerait de réécrire des règles de manière incohérente.
### 12.4 Secrets codés en dur
Les identifiants techniques et clés présents dans les sources constituent un risque de sécurité et empêchent une gestion propre des environnements.
### 12.5 Threads non sécurisés
La documentation OMIKA contient plusieurs avertissements sur des appels `ThreadExécute` non sécurisés, notamment dans les traitements documentaires, les courriels, les rapports et la surveillance. Ces traitements devront être redéfinis avec des primitives asynchrones .NET, une gestion d'annulation et une séparation claire entre opérations serveur et client.
### 12.6 Fonctions de chiffrement conservées pour compatibilité
Des avertissements indiquent l'usage de fonctions `Crypte`/`Décrypte` conservées pour compatibilité. Les données déjà chiffrées devront être identifiées pour éviter de les rendre illisibles lors de la migration.
### 12.7 Couverture de tests insuffisante
L'ancienne version possède un objet de test isolé mais pas de suite utile. La nouvelle documentation ne montre pas de couverture automatisée comparable. Il faudra créer des tests de caractérisation avant de déplacer les règles dans l'API.
### 12.8 États et variantes client
L'ancienne version contient de nombreux états spécifiques SLAMM, GAP et Bouvard. La nouvelle version en conserve moins. Il faut confirmer si les états supprimés sont obsolètes, remplacés ou encore requis par certains clients.
---
## 13. Hypothèses raisonnables
Les éléments suivants sont des hypothèses, non des faits définitivement établis :
1. `BATIK.md` représente le socle historique commun à plusieurs déclinaisons client, malgré le nom de projet `Isis_BO`.
2. `OMIKA.md` représente une nouvelle version WINDEV progressivement rebaptisée Omika, et non encore la cible Avalonia.
3. Les champs `*_Cle*` ont été ajoutés pour stabiliser les références aux paramètres métier indépendamment des identifiants numériques propres à une base.
4. Les GUID ont été introduits pour faciliter la synchronisation, la déduplication ou l'identification interbases.
5. `ElementModifie`, `ElementSupprime` et `LIB_Surveille` servent à la synchronisation ou au rafraîchissement distribué des écrans.
6. `Role` et `CategorieAcces` constituent le nouveau mécanisme de permissions destiné à remplacer ou compléter `AccesEtDroit` et les anciens profils/modules.
7. `RDV_Planning`, `RDV_Collaborateur` et `RDV_Contact` représentent le modèle cible du planning, tandis que `Planning_RDV` reste pour compatibilité ou migration.
8. La réduction de 181 à 46 classes traduit l'abandon d'un framework de présentation généralisé plutôt qu'une réduction équivalente du périmètre métier.
9. Une partie des variantes historiques est désormais portée par les données de paramétrage plutôt que par des configurations compilées.
Toutes ces hypothèses doivent être vérifiées par lecture des fichiers WINDEV physiques, des configurations actives et d'une base représentative.
---
## 14. Zones encore inconnues
### Dépôt et compilation
- arborescence physique complète du dépôt ;
- fichiers de projet et configurations réellement actives ;
- gestion de versions et historique des migrations ;
- dépendances externes non reprises dans les exports ;
- scripts de build, d'installation et de mise à jour ;
- déclinaisons exactes par client.
### Données
- version HFSQL serveur et mode de déploiement ;
- volumes par table ;
- distributions, valeurs nulles et doublons ;
- enregistrements orphelins ;
- statut réel des rubriques zombie ;
- qualité et unicité des GUID ;
- encodages et contraintes non visibles dans les extraits ;
- bases encore non migrées vers OMIKA.
### Règles métier
- définition officielle de chaque statut, type et paramètre métier ;
- règles de cycle de vie d'un dossier et d'une intervention ;
- invariants de planning et de suivi des temps ;
- règles de suppression, archivage et restauration ;
- portée des transactions ;
- comportement attendu lors des erreurs réseau ou de concurrence ;
- règles spécifiques à chaque client.
### Intégrations
- contrat exact avec Batigest ;
- formats et fréquence des échanges mobiles ;
- contrat du service de licence ;
- serveurs de courrier et modèles d'authentification ;
- rôle opérationnel de SysLog ;
- emplacement et sécurité des partages documentaires ;
- moteurs et schémas des bases externes.
### Expérience utilisateur
- écrans réellement utilisés ;
- fonctionnalités obsolètes ;
- parcours prioritaires ;
- exigences d'impression ;
- raccourcis, comportements implicites et habitudes utilisateurs ;
- besoins hors connexion éventuels.
---
## 15. Prochaines étapes recommandées
### Étape 1 — Obtenir le dépôt source réel
Récupérer une copie en lecture seule contenant au minimum :
- les fichiers projets WINDEV ;
- toutes les fenêtres, fenêtres internes, états, classes et collections ;
- les analyses `.ana`/`.wda` ;
- les composants internes et externes ;
- les fichiers de configuration d'exemple expurgés ;
- les ressources JSON et modèles ;
- les configurations de compilation.
### Étape 2 — Déterminer la référence fonctionnelle
Faire valider explicitement :
- quelle version est actuellement en production ;
- quels clients utilisent l'ancienne ou la nouvelle version ;
- quelles fonctionnalités de l'ancien projet n'ont pas été reprises dans OMIKA ;
- si OMIKA doit être considéré comme la référence fonctionnelle de la migration Avalonia/API.
### Étape 3 — Produire un inventaire de schéma machine-readable
Exporter pour chaque analyse :
- tables ;
- rubriques ;
- types et tailles ;
- valeurs par défaut ;
- clés simples/composées ;
- index ;
- contraintes et cardinalités ;
- groupes de protection ;
- champs zombie ;
- rubriques ajoutées, supprimées et renommées.
Le résultat devrait être un CSV ou JSON diffable, sans créer encore les modèles .NET.
### Étape 4 — Valider les données réelles
Sur une copie anonymisée :
- compter les lignes ;
- vérifier les GUID ;
- comparer anciens identifiants et nouvelles clés ;
- identifier les valeurs zombie encore alimentées ;
- vérifier les orphelins ;
- contrôler le modèle de rendez-vous ;
- mesurer les tailles de mémos, documents et historiques.
### Étape 5 — Cartographier les cas d'usage
Documenter de bout en bout, avec les tables lues/écrites et les effets de bord :
1. connexion et chargement des droits ;
2. création/modification d'un client, contact et site ;
3. création d'un dossier et d'une intervention ;
4. création/modification d'un rendez-vous simple, groupé et multi-jours ;
5. saisie et validation des temps ;
6. génération d'un compte rendu et d'un rapport ;
7. gestion documentaire ;
8. envoi de courriel ;
9. synchronisation Batigest/mobile ;
10. archivage et suppression.
### Étape 6 — Construire un catalogue des règles métier
Pour chaque règle :
- identifiant et description ;
- source WINDEV précise ;
- déclencheur ;
- entrées ;
- tables/rubriques ;
- validations ;
- sorties ;
- effets de bord ;
- message d'erreur ;
- variantes client ;
- test de caractérisation associé.
### Étape 7 — Étudier les migrations existantes
Auditer en priorité :
- `FEN_MaJParemApp` ;
- `Lib_Version` ;
- les procédures liées aux GUID ;
- les transformations de paramètres métier ;
- les migrations d'adresses ;
- le passage de l'ancien au nouveau modèle de rendez-vous ;
- les mises à jour de profils/rôles.
L'objectif est d'établir une table de correspondance fiable « ancien champ → nouveau champ » avant toute migration vers .NET.
### Étape 8 — Inventorier les intégrations et secrets
Créer une matrice contenant :
- système externe ;
- protocole ;
- endpoint ;
- authentification ;
- données échangées ;
- fréquence ;
- sens du flux ;
- reprise sur erreur ;
- propriétaire ;
- environnement ;
- secret associé.
Les valeurs sensibles doivent être expurgées de la documentation partagée.
### Étape 9 — Mettre en place des tests de caractérisation
Avant d'écrire l'API :
- capturer des scénarios reproductibles ;
- préparer des jeux de données anonymisés ;
- vérifier les résultats WINDEV actuels ;
- figer les règles critiques de planning, droits, temps, archivage, rapports et synchronisation.
### Étape 10 — Définir ensuite les frontières de la future architecture
Seulement après les étapes précédentes, il sera pertinent de proposer :
- les domaines et agrégats ;
- les contrats de l'API ;
- la stratégie d'accès HFSQL ou de migration de base ;
- les transactions ;
- les identités publiques ;
- les écrans Avalonia ;
- la stratégie de coexistence et de bascule.
Cette étape n'est volontairement pas réalisée dans le présent document.
---
## 16. Priorités d'analyse pour la suite
### Priorité critique
1. modèle de rendez-vous et scripts de migration ;
2. champs zombie et correspondance avec les nouvelles clés ;
3. authentification, droits et rôles ;
4. secrets et accès HFSQL ;
5. règles contenues dans les fenêtres ;
6. intégration Batigest et bases externes.
### Priorité haute
1. dossiers/interventions ;
2. suivi des temps ;
3. documents, rapports et courriels ;
4. archivage/suppression ;
5. surveillance et synchronisation ;
6. variantes client et états spécifiques.
### Priorité secondaire
1. aide contextuelle ;
2. visites guidées ;
3. thèmes et personnalisation UI ;
4. utilitaires et écrans de démonstration ;
5. éléments temporaires ou archivés.
---
## 17. Conclusion
Les deux documentations fournissent suffisamment d'éléments pour identifier les grands domaines, les emplacements de la description HFSQL, les familles de traitements et les principales transformations entre l'ancien Batik et OMIKA.
Le point le plus structurant est que la nouvelle version OMIKA a déjà engagé une migration interne importante : nouvelles clés métier, GUID, nouveau modèle de rendez-vous, nouveau système de rôles, suivi des modifications et composants UI réutilisables. Cette migration reste toutefois partiellement superposée à l'ancien modèle, comme le montrent les champs zombie encore utilisés et les tables de planning coexistantes.
La prochaine phase ne doit pas encore consister à générer des modèles .NET. Elle doit d'abord transformer cette connaissance documentaire en inventaires vérifiables : diff de schéma, cartographie des cas d'usage, catalogue des règles, matrice d'intégrations et tests de caractérisation.
+318
View File
@@ -0,0 +1,318 @@
# Architecture de la solution Omika
Ce document décrit la structure initiale de la solution .NET **Omika**, son
organisation en projets, les dépendances entre eux, et les conventions retenues
pour la suite (migration HFSQL, application mobile, API, futur Windows).
> **Portée** : phase d'architecture. Les modèles métier détaillés et la connexion
> HFSQL réelle ne sont **pas** encore développés (voir les sections dédiées).
---
## 1. Vue d'ensemble
```
┌────────────────────────────────────────────────────────────┐
│ Omika.App │
│ Application Avalonia (code partagé UI / ViewModels) │
│ Cibles : Android · iOS · Windows (desktop) │
└───────────────┬─────────────────────────────┬──────────────┘
│ │
▼ ▼
Omika.App.Desktop Omika.App.Android
Omika.App.iOS (hôtes plateformes)
(hôtes plateformes)
│
│ ProjectReference
▼
┌─────────────────────────────────────────────┐ ┌──────────────────────────────┐
│ Omika.Core │ │ Omika.Api │
│ Modèles · Interfaces · Constantes · │◄───────│ API ASP.NET Core │
│ Logique métier (indépendant UI/API) │ │ Endpoints / DI / OpenAPI │
└──────────────────┬──────────────────────────┘ └──────────────┬───────────────┘
│ ProjectReference │ ProjectReference
▼ ▼
┌─────────────────────────────────────────────┐ ┌──────────────────────────────┐
│ Omika.Data │ │ Omika.Tests │
│ Connexion HFSQL · Repositories · Mapping │ │ Tests unitaires (xUnit) │
│ Migration Batik/OMIKA │ └──────────────────────────────┘
└─────────────────────────────────────────────┘
```
## 2. Projets créés
| Projet | Type | Rôle |
|-----------------------|-----------------------------------|------|
| `Omika.App` | Bibliothèque Avalonia (net10.0) | Code UI partagé (Views, ViewModels, Services). Cible commune aux hôtes Android / iOS / Windows. |
| `Omika.App.Desktop` | Exécutable Avalonia (net10.0) | Hôte desktop Windows/Linux/macOS. Point d'entrée pour le futur client Windows. |
| `Omika.App.Android` | Exécutable Avalonia (net10.0-android) | Hôte Android (APK/AAB). |
| `Omika.App.iOS` | Exécutable Avalonia (net10.0-ios) | Hôte iOS (simulateur/appareil). **La compilation d'un .app pour iOS exige macOS + Xcode.** |
| `Omika.Api` | API ASP.NET Core (net10.0) | Expose les fonctionnalités métier et données via HTTP + OpenAPI. |
| `Omika.Core` | Bibliothèque (net10.0) | Modèles communs, interfaces, constantes, types partagés, logique métier. Aucune dépendance UI/API. |
| `Omika.Data` | Bibliothèque (net10.0) | Accès aux données HFSQL : options de connexion, contrats, repositories, mapping de tables, migration Batik/OMIKA. |
| `Omika.Tests` | xUnit (net10.0) | Tests unitaires des éléments critiques (Result, validation HFSQL, mapping de tables). |
## 3. Dépendances entre projets
```
Omika.App.Desktop ──► Omika.App ──► Omika.Core
Omika.App.Android ──► Omika.App ──► Omika.Core
Omika.App.iOS ──► Omika.App ──► Omika.Core
Omika.Api ──► Omika.Core
──► Omika.Data
Omika.Data ──► Omika.Core
Omika.Tests ──► Omika.Core
──► Omika.Data
```
- **Omika.Core** n'a aucune dépendance : c'est le noyau partagé.
- **Omika.Data** dépend de **Omika.Core** (interfaces, constantes, types).
- **Omika.Api** dépend de **Omika.Core** et **Omika.Data**.
- **Omika.App** et ses hôtes dépendent de **Omika.Core** (les appels à l'API se feront
via HTTP, pas par référence directe au projet Data).
- **Omika.Tests** référence **Omika.Core** et **Omika.Data**.
## 4. Gestion des packages
Le fichier racine **`Directory.Packages.props`** active la gestion centralisée
des packages (CPM). Toutes les versions NuGet y sont déclarées une seule fois ;
les fichiers `.csproj` référencent les packages **sans version**.
| Groupe | Package |
|------------------|---------|
| Avalonia | `Avalonia`, `Avalonia.Themes.Fluent`, `Avalonia.Fonts.Inter`, `Avalonia.Desktop`, `Avalonia.Android`, `Avalonia.iOS` |
| Diagnostique | `AvaloniaUI.DiagnosticsSupport` |
| Android | `Xamarin.AndroidX.Core.SplashScreen` |
| MVVM | `CommunityToolkit.Mvvm` |
| API | `Microsoft.AspNetCore.OpenApi` |
| Configuration | `Microsoft.Extensions.Options.ConfigurationExtensions` |
| Tests | `xunit`, `xunit.runner.visualstudio`, `Microsoft.NET.Test.Sdk`, `coverlet.collector` |
## 5. Structure des dossiers
```
Omika.slnx → solution (format .slnx)
Directory.Packages.props → versions centralisées des packages NuGet
docs/architecture-solution.md → ce document
docs/schema-compatibilite-batik-omika.json
→ dictionnaire machine-readable du schéma (tables, rubriques, liens)
analyse-sources.md → analyse WINDEV existante
BATIK.md / OMIKA.md → documentation historique
tools/generate_models.py → générateur des modèles physiques HFSQL (Batik/Omika)
src/
Omika.Core/
Constants/ → constantes applicatives et base de données
Interfaces/ → contrats (IRepository, IUnitOfWork)
Abstractions/ → types partagés (Result)
Runtime/ → version active de la base (BATIK/OMIKA), contexte global
Models/ → (vide pour l'instant) futurs modèles métier
Omika.Data/
Configuration/ → HfsqlOptions + validation
Connection/ → contrats et squelette de connexion HFSQL
Repositories/ → base commune des repositories
Mapping/ → référentiel de tables + mapping Batik/OMIKA
HfSql/Metadata/ → attributs de mapping HFSQL (Table, Column, PrimaryKey, CompositeKey, LegacyField)
HfSql/Models/ → modèles physiques générés (Batik/ et Omika/) — 106 classes
Migrations/ → service de migration Batik → OMIKA
Omika.Api/
Endpoints/ → endpoints HTTP (santé, futur métier)
Omika.App/
Views/ → MainWindow (desktop) et MainView (partagé)
ViewModels/ → MainViewModel, ViewModelBase
Omika.App.Desktop/
Omika.App.Android/
Omika.App.iOS/
tests/
Omika.Tests/ → tests xUnit (47 tests)
```
## 6. Conventions et règles
1. **Séparation des responsabilités** : l'UI (App) et l'API ne contiennent aucune
logique métier ; celle-ci vit dans **Omika.Core**.
2. **Accès aux données** : uniquement via **Omika.Data**, derrière les interfaces
`IRepository<TEntity,TId>` et `IUnitOfWork` définies dans **Omika.Core**.
3. **Pas de dépendance directe App → Data** : l'application mobile consommera
l'API (HTTP). Si un accès direct est nécessaire (hors ligne), il passera par
une façade dédiée à ajouter ultérieurement.
4. **Configuration** : les options de connexion HFSQL sont typées
(`HfsqlOptions`, section `"Hfsql"`), validées avant usage, et remplaçables
par la configuration .NET standard (appsettings / user-secrets / variables
d'environnement).
5. **Résultats d'opération** : les services renvoient `Result` / `Result<T>`
(Omika.Core) plutôt que de lever des exceptions pour les cas métier.
## 7. État de la couche HFSQL (phase d'architecture)
À ce stade, **aucune connexion HFSQL réelle n'est ouverte**. La couche fournit :
- `HfsqlOptions` et `HfsqlOptionsValidator` : préparation et validation des
paramètres (serveur, port 4900, base, groupes de protection ProtectionStd /
ProtectionPlus, comptes techniques) ;
- `IHfsqlConnection` / `HfsqlConnection` : contrat + implémentation squelette
(`OpenAsync` lève volontairement `NotImplementedException`) ;
- `RepositoryBase<TEntity,TId>` : base commune prête à câbler le provider HFSQL ;
- `LegacyTableMap` : référentiel des 58 tables OMIKA et des tables de l'ancienne
version (48 tables, e.g. `Intervention_Collaborateur` supprimée) ;
- `TableAttribute` : association future entité → table HFSQL ;
- `IMigrationService` / `MigrationService` : contrat de compatibilité Batik →
OMIKA (conversion de clés numériques vers textuelles, suivi ElementModifie /
ElementSupprime), non implémenté.
### 7.1 Modèles physiques générés (Batik / Omika)
Le dictionnaire machine-readable **`docs/schema-compatibilite-batik-omika.json`**
décrit le schéma WINDEV (tables, rubriques, types HFSQL, liens, marquage zombie).
Le générateur **`tools/generate_models.py`** produit à partir de ce fichier les
**106 modèles physiques** dans `Omika.Data/HfSql/Models` :
| Dossier | Contenu |
|---|---|
| `HfSql/Models/Batik/` | Modèles physiques de l'ancienne base (Isis_BO, 48 tables) |
| `HfSql/Models/Omika/` | Modèles physiques de la nouvelle base (OMIKA, 58 tables) |
Chaque classe `XxxRow` porte les attributs de mapping suivants :
- `[HfSqlTable]` : nom de la table, nom du fichier disque (`.fic`), groupes de
protection, versions de schéma concernées (Batik et/ou Omika) ;
- `[HfSqlColumn]` : nom physique de la rubrique, type HFSQL, longueur,
valeur par défaut, marquage « zombie » ou « Omika uniquement » ;
- `[HfSqlPrimaryKey]` / `[HfSqlCompositeKey]` : clé primaire (automatique ou composée) ;
- `[HfSqlLegacyField]` : rubrique historique à migrer, avec éventuellement la
rubrique de remplacement (`ReplacementColumn`).
Ces modèles sont **purement descriptifs** (attributs) : aucune requête n'est
exécutée. Ils constituent le socle du futur provider HFSQL et du mapping
table → entité.
Pour régénérer les modèles après modification du dictionnaire :
```powershell
python .\tools\generate_models.py
```
**Prochaine étape HFSQL** : choisir et brancher le provider HFSQL (.NET), puis
définir les entités du domaine en s'appuyant sur ces modèles physiques.
## 8. Sélection de base (BATIK / OMIKA)
Le mécanisme de sélection de la base est centralisé dans **`Omika.Core.Runtime`**
et **`Omika.Core.Compatibility`**, en distinguant deux notions :
- **`DatabaseSchemaSelection`** (`Omika.Core.Runtime`) — enum de la **sélection
demandée par la configuration** :
- `Auto` (0) : détection automatique de la base réelle (valeur par défaut sûre) ;
- `Batik` (1) : forcer l'ancienne base ;
- `Omika` (2) : forcer la nouvelle base.
- **`DatabaseSchemaVersion`** (`Omika.Core.Compatibility`) — enum de la
**provenance / version réellement détectée** d'une donnée :
- `Unknown` (0) : état non déterminé ;
- `Batik` (1) : ancienne version ;
- `Omika` (2) : nouvelle version.
Ce type ne doit pas être confondu avec `DatabaseSchemaSelection` : l'un décrit
la sélection demandée au démarrage (configuration), l'autre la version
réellement détectée du schéma dont provient une donnée.
- **`DatabaseSchemaSelectionParser`** — analyse une chaîne de configuration
(« Auto », « Batik » ou « Omika », insensible à la casse et aux espaces).
Il retourne un `Result<DatabaseSchemaSelection>` et refuse toute valeur
inconnue avec un message d'erreur explicite.
- **`ApplicationRuntimeContext`** — contexte global de l'application :
- `ConfigurationKey` : clé conventionnelle `"Omika:DatabaseVersion"` ;
- `Configure(...)` : définit la sélection demandée au démarrage (accepte
l'enum ou une chaîne de configuration) ;
- `SetDetectedVersion(...)` : fournit la version réellement détectée
(Batik / Omika) et la **valide** contre la sélection demandée ;
- `ValidateDetectedVersion(...)` : valide une version détectée sans modifier
le contexte global ;
- `Current` : accès au contexte actif (propriétés `Selection`,
`DetectedVersion`, `IsBatik`, `IsOmika`) ;
- `FromConfiguration(...)` : crée un contexte isolé sans toucher au global
(utile pour les tests et les traitements par lot) ;
- `Reset()` : réinitialise le contexte global.
**Sélection demandée ≠ version active**
Une valeur de configuration n'est **jamais** présentée comme version réellement
active : seule la base **détectée et validée** (`DetectedVersion`) détermine la
base utilisée. Tant que `SetDetectedVersion` n'a pas été appelé, la version
détectée reste `Unknown`, même si une sélection forcée (`Batik` / `Omika`) est
configurée.
**Comment changer la sélection demandée ?**
| Méthode | Exemple |
|---|---|
| **Fichier de configuration** (recommandé pour l'API) | Modifier la clé `"Omika:DatabaseVersion"` dans `appsettings.json` (valeur `"Auto"`, `"Batik"` ou `"Omika"`) |
| **Variable d'environnement** | `Omika__DatabaseVersion=Batik` (la convention .NET `__` est lue automatiquement) |
| **Au démarrage du code** | `ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Batik)` ou `Configure("Auto")` |
Une valeur inconnue lève une erreur explicite au démarrage. En mode `Auto`,
toute version réelle détectée (Batik ou Omika) est acceptée. En mode forcé, la
version détectée doit correspondre à la sélection demandée ; dans le cas
contraire, `SetDetectedVersion` retourne un échec explicite et **aucune version
active n'est affirmée** (la version détectée reste `Unknown`).
## 9. État des modèles métier
Les **modèles physiques HFSQL** (rubriques brutes du schéma WINDEV) vivent dans
`Omika.Data/HfSql/Models` (voir § 7.1). `Omika.Core/Models`, qui accueillera les
**entités du domaine**, est volontairement vide. La prochaine phase devra :
1. s'appuyer sur l'inventaire machine-readable du schéma (déjà produit) et sur
les modèles physiques pour définir les entités du domaine (Client, Dossier,
Intervention, RDV, …) ;
2. distinguer les propriétés utiles au domaine des rubriques « zombie »
(conservées pour la lecture d'une base existante, à ne pas utiliser comme
référence dans le nouveau domaine) ;
3. définir les DTO exposés par l'API et consommés par l'application.
## 10. Commandes utiles
```powershell
# Restaurer les packages
dotnet restore .\Omika.slnx
# Compiler toute la solution (hôtes desktop + API + bibliothèques + tests)
dotnet build .\Omika.slnx
# Compiler uniquement l'API
dotnet build .\src\Omika.Api\Omika.Api.csproj
# Compiler le client desktop (Windows)
dotnet build .\src\Omika.App.Desktop\Omika.App.Desktop.csproj
# Compiler l'hôte Android (APK)
dotnet build .\src\Omika.App.Android\Omika.App.Android.csproj
# Compiler l'hôte iOS (simulateur) — nécessite macOS + Xcode
dotnet build .\src\Omika.App.iOS\Omika.App.iOS.csproj
# Lancer les tests unitaires
dotnet test .\tests\Omika.Tests\Omika.Tests.csproj
# Lancer l'API en développement
dotnet run --project .\src\Omika.Api\Omika.Api.csproj
# → http://localhost:5xxx/health (état de la solution)
# Lancer le client desktop
dotnet run --project .\src\Omika.App.Desktop\Omika.App.Desktop.csproj
```
## 11. Prochaines étapes
- [ ] Choisir et intégrer le provider HFSQL (.NET) dans `Omika.Data`.
- [ ] Enrichir le dictionnaire `docs/schema-compatibilite-batik-omika.json`
(liens, règles de migration) et régénérer les modèles physiques.
- [ ] Définir les entités métier dans `Omika.Core/Models`.
- [ ] Implémenter les endpoints métier de `Omika.Api`.
- [ ] Brancher l'application Avalonia sur l'API (client HTTP) et préparer la
navigation mobile.
- [ ] Ajouter un projet `Omika.App.Windows` si un client Windows dédié est requis
(le desktop actuel en tient déjà lieu).
+82
View File
@@ -0,0 +1,82 @@
# Audit du niveau 3 — Modèle métier canonique et contrats API
## Types déjà trouvés dans `Omika.Core`
L'audit a couvert les dossiers existants et les dossiers créés :
- `Omika.Core/Abstractions` : abstractions techniques existantes.
- `Omika.Core/Constants` : constantes existantes.
- `Omika.Core/Interfaces` : interfaces existantes.
- `Omika.Core/Models` : modèles préexistants (à auditer pour doublons éventuels).
- `Omika.Core/Runtime` : contexte d'exécution (ex. `ApplicationRuntimeContext`),
sélection de base demandée par la configuration (`DatabaseSchemaSelection`)
et analyse de configuration (`DatabaseSchemaSelectionParser`).
- `Omika.Core/Compatibility` (créé) : `DatabaseSchemaVersion` (provenance /
version réellement détectée), `DatabaseSource`, `LegacyEntityReference`,
`SchemaCapabilities`, `CanonicalMappingResult<T>`.
- `Omika.Core/Domain` (créé) : entités métier, objets de valeur, enums,
exceptions.
- `Omika.Core/Contracts` (créé) : DTO API.
- `Omika.Core/Mapping` (créé) : mappers entité métier → DTO.
## Types réutilisés
Aucun fichier existant n'a été déplacé. Les dossiers `Interfaces`,
`Abstractions`, `Constants` et `Runtime` ont été **réutilisés tels quels** :
la nouvelle structure `Domain/`, `Contracts/`, `Compatibility/` est ajoutée
**sans déplacer** les classes existantes.
La règle « ne crée pas un deuxième dossier concurrent » a été respectée :
les nouvelles interfaces de service ont été placées dans `Omika.Core/Interfaces`
et non dans un nouveau dossier.
## Doublons évités
- Un seul `RendezVous` canonique (pas de `Planning_RDV` / `RDV_Planning` /
`RDV_Collaborateur` / `RDV_Contact` métier).
- Un seul `Document` (pas de duplication des classes physiques `DocumentRow`).
- Un seul `Client` (pas de duplication des tables `Client`, `ClientAdresse`,
`ClientContact`, `ClientSite`).
- Un seul `Intervention` (pas de duplication des tables `Intervention`,
`ActionIntervention`, `CompteRendu`).
- Les collections métier (adresses, contacts, sites) sont des propriétés de
l'entité racine, et non des entités séparées dupliquées.
- Pas de table de liaison métier : les références sont portées par des listes
d'identifiants ou des collections contrôlées.
## Fichiers non déplacés
- Les classes physiques Batik / Omika restent dans `Omika.Data`.
- Les classes de `Omika.Core/Models` existantes n'ont pas été déplacées par
sécurité : un audit manuel complémentaire est nécessaire pour décider de leur
sort (doublons éventuels avec `Domain/`).
- Les abstractions techniques de `Abstractions/`, les constantes de
`Constants/` et le runtime de `Runtime/` restent en place.
## Ambiguïtés
1. **`Omika.Core/Models`** : contient peut-être des DTO ou modèles intermédiaires
préexistants. Rien n'a été supprimé ; à arbitrer lors du niveau 4.
2. **Interfaces de repository** : la solution ne montre pas de convention claire
de repository. Les services (`IClientService`, `IInterventionService`,
`IRendezVousService`, `IDocumentService`, `IReferenceDataService`,
`ICurrentUserService`) ont été créés dans `Interfaces/`. Les repositories
seront définis avec l'architecture de données (Omika.Data).
3. **Identifiants publics** : chaînes. Le mobile ne doit pas voir la clé physique ;
un helper dédié sera ajouté si nécessaire.
4. **`ReferenceValue` vs enums** : les paramètres métier restent configurables.
Certains statuts pourraient devenir des enums stables ultérieurement.
5. **Dates** : `DateTime` partout ; `DateTimeOffset` seulement si un besoin
multi-fuseaux est documenté.
## Éléments restant à traiter
- Mapping **physique → métier** dans `Omika.Data` (Batik et Omika) :
conversion des rubriques préfixées et des champs zombie vers les entités
canoniques de `Omika.Core.Domain`.
- Implémentation des services métier (`IClientService`, etc.) côté API.
- Décision sur le devenir de `Omika.Core/Models` (fusion ou suppression).
- Contrats de mise à jour des collections (remplacement complet vs diff).
- Sécurité : hachage des mots de passe, sessions, jetons (hors périmètre
niveau 3).
- Gestion physique des documents (téléversement / téléchargement).
+18
View File
@@ -0,0 +1,18 @@
@echo off
REM ============================================================
REM OMIKA - Compilation de l'application (test local)
REM Double-cliquez sur ce fichier pour compiler
REM API + Worker + Desktop (Debug).
REM
REM Options (transmises au script PS1) :
REM -Release compiler en Release
REM -NoStop ne pas arreter les processus en cours
REM ============================================================
cd /d "%~dp0"
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0build_omika_test.ps1" %*
if errorlevel 1 (
echo.
echo [ERREUR] La compilation a echoue. Voir les messages ci-dessus.
)
echo.
pause
+110
View File
@@ -0,0 +1,110 @@
<#
OMIKA - Compilation de l'application (mode test local)
=======================================================
Compile les 3 composants de l'application :
1. Omika.Api (API)
2. Omika.Worker (synchronisation)
3. Omika.App.Desktop (application de bureau Avalonia)
Par defaut en Debug. Avant de compiler, arrete les processus Omika en
cours (ils verrouillent les DLL de sortie -> erreur MSB3027) sauf si
-NoStop est passe.
Utilisation :
powershell -NoProfile -ExecutionPolicy Bypass -File .\docs\build_omika_test.ps1
.\docs\build_omika_test.ps1 -Release # compiler en Release
.\docs\build_omika_test.ps1 -NoStop # ne pas arreter les processus
Pour lancer apres compilation :
.\docs\start_omika_test.ps1 -NoBuild
#>
[CmdletBinding()]
param(
[switch]$Release,
[switch]$NoStop
)
$ErrorActionPreference = 'Stop'
# Racine du depot = parent du dossier docs/ contenant ce script.
$root = Split-Path -Parent $PSScriptRoot
$config = if ($Release) { 'Release' } else { 'Debug' }
function Banner([string]$Text) {
Write-Host ''
Write-Host '============================================================' -ForegroundColor Cyan
Write-Host " $Text" -ForegroundColor Cyan
Write-Host '============================================================' -ForegroundColor Cyan
}
Banner "OMIKA - Compilation ($config)"
# ---------- Arret des processus en cours (liberation des DLL) ----------
if ($NoStop) {
Write-Host ''
Write-Host 'Arret des processus ignore (-NoStop).' -ForegroundColor DarkGray
Write-Host ' Attention : si l''application est en cours d''execution, la compilation echouera (DLL verrouillees).' -ForegroundColor Yellow
}
else {
Write-Host ''
Write-Host 'Arret des processus Omika en cours (pour liberer les DLL)...' -ForegroundColor Yellow
$desktop = Get-Process -Name 'Omika.App.Desktop' -ErrorAction SilentlyContinue
if ($desktop) {
$desktop | Stop-Process -Force -ErrorAction SilentlyContinue
Write-Host ' Application de bureau arretee.' -ForegroundColor Green
}
$dotnetProcs = Get-CimInstance Win32_Process -Filter "Name='dotnet.exe'" -ErrorAction SilentlyContinue |
Where-Object { $_.CommandLine -like '*Omika.Api*' -or $_.CommandLine -like '*Omika.Worker*' }
if ($dotnetProcs) {
foreach ($proc in $dotnetProcs) {
Stop-Process -Id $proc.ProcessId -Force -ErrorAction SilentlyContinue
Write-Host " dotnet (PID $($proc.ProcessId)) arrete." -ForegroundColor Green
}
}
if (-not $desktop -and -not $dotnetProcs) {
Write-Host ' Aucun processus Omika en cours.' -ForegroundColor DarkGray
}
}
# ---------- Compilation ----------
$projects = @(
(Join-Path $root 'src\Omika.Api\Omika.Api.csproj'),
(Join-Path $root 'src\Omika.Worker\Omika.Worker.csproj'),
(Join-Path $root 'src\Omika.App.Desktop\Omika.App.Desktop.csproj')
)
$failed = $false
foreach ($project in $projects) {
Write-Host ''
Write-Host " > $project ($config)" -ForegroundColor Yellow
# CONTOURNEMENT TEMPORAIRE : --no-restore -m:1 evite l'echec MSB4276 lie au
# workload Android/iOS corrompu de cette machine (resolution SDK parallele).
# A retirer apres 'dotnet workload repair'.
dotnet build $project -c $config --nologo --no-restore -m:1
if ($LASTEXITCODE -ne 0) {
Write-Host " [ERREUR] Echec de compilation : $project" -ForegroundColor Red
$failed = $true
break
}
}
# ---------- Resultat ----------
Banner 'Resultat'
if ($failed) {
Write-Host ' ECHEC de la compilation.' -ForegroundColor Red
exit 1
}
Write-Host " Compilation ($config) terminee avec succes." -ForegroundColor Green
Write-Host ''
Write-Host ' Binaires generes :' -ForegroundColor Cyan
Write-Host " API : src\Omika.Api\bin\$config\net10.0\Omika.Api.dll" -ForegroundColor Cyan
Write-Host " Worker : src\Omika.Worker\bin\$config\net10.0\Omika.Worker.dll" -ForegroundColor Cyan
Write-Host " Desktop : src\Omika.App.Desktop\bin\$config\net10.0\Omika.App.Desktop.exe" -ForegroundColor Cyan
Write-Host ''
Write-Host ' Pour lancer : .\docs\start_omika_test.ps1 -NoBuild' -ForegroundColor Cyan
@@ -0,0 +1,143 @@
# T24-A — Prompt IA local + référentiel administrable ScreenCode/FeatureCode
> Statut : PRÉPROD LOCAL / recette manuelle. Aucun Git, aucune Production.
## 1. Générer le prompt IA (Partie A)
### Principe
Le centre Diagnostics génère un **prompt d'analyse local** depuis un diagnostic/erreur.
**Aucun appel IA externe** (OpenAI, ChatGPT ou autre) : le système se contente de :
```
diagnostic → construire un texte local → afficher le prompt → permettre « Copier »
```
Le prompt n'est **jamais enregistré en PostgreSQL** (génération à la demande uniquement, V1).
### Boutons
- **Détail diagnostic** (`/Diagnostics/{id}`) : bouton « Générer le prompt IA » à proximité du
contexte technique.
- **Détail erreur** (`/Erreurs/{id}`) : bouton « Générer le prompt IA » (depuis l'occurrence la
plus récente du groupe).
Un clic déclenche la génération **côté serveur** (handler Razor Pages `OnGetPromptAsync`), puis
affiche le prompt dans une modale dédiée avec un bouton **« Copier le prompt »** (réutilise
`OmikaCopy()`, aucune nouvelle librairie JS).
### Service partagé
La construction du prompt est centralisée dans `Omika.Diagnostics.Core` :
`DiagnosticPromptBuilder.Build(DiagnosticEventRecord, IReadOnlyList<DiagnosticEventRecord>?)`.
Le chargement des événements corrélés est partagé par
`DiagnosticsDetailModel.GetCorrelatedEventsAsync(...)` (utilisé par le détail diagnostic ET le
détail erreur). Aucune logique dupliquée.
### Informations incluses (si disponibles)
- **CONTEXTE** : ClientCode, Environment, ServerInstance, Source, Component, Platform,
OperatingSystem, OperatingSystemVersion, Manufacturer, Model, AppVersion, AppBuild,
ScreenCode, FeatureCode.
- **ERREUR** : EventCode, Level, Priority, ErrorCode, Timestamp, DurationMs.
- **MESSAGE** : Message (tronqué à 2000 caractères).
- **EXCEPTION** : ExceptionType, StackTrace (tronquée à 4000 caractères).
- **HTTP / MÉTADONNÉES** : uniquement les clés whitelistées (`method`, `statusCode`,
`routeTemplate`, `retryCount`, …) et valeurs simples non interdites.
- **CORRÉLATION** : TraceId, OperationId, SessionId.
- **IDENTITÉ TECHNIQUE AUTORISÉE** : LicenseNumber (numéro fonctionnel), DeviceKey et ActorKey
**tronqués** (pseudonymes HMAC-SHA256, jamais réversibles).
- **ÉVÉNEMENTS CORRÉLÉS** (si TraceId/OperationId) : chronologie compacte, **bornée à 25
événements**, sans stack trace (timestamp, level, eventCode, component, message court,
screen/feature).
### Informations exclues (jamais)
password, password_hash, ApiKey, api_key_hash, Bearer token, SigningKey, HmacKey,
ConnectionString, DeviceId brut, UserId brut, CollaborateurId brut, ClientId brut, email,
téléphone, IMEI, serial, MAC, secret de licence.
La politique **réutilise** les filtres existants : `DiagnosticForbiddenData`
(jetons interdits) et `DiagnosticMetadataPolicy` (whitelist des clés). Aucune seconde logique de
filtrage. Défense en profondeur : chaque valeur relue est re-vérifiée avant inclusion.
### Format
Le prompt commence par :
> « Analyse cette erreur Omika et aide-moi à déterminer sa cause probable. »
puis demande explicitement : cause probable, niveau de confiance, fichier/méthode à vérifier en
priorité, éléments de preuve, hypothèses alternatives, correctif recommandé, tests de
confirmation, dire si l'erreur semble volontaire/de test, distinguer faits confirmés et hypothèses.
### Exemple /dev/throw
L'événement de recette `API_HTTP_ERROR` / `InvalidOperationException` / `/dev/throw` /
`TELEMETRY_TEST_ERROR` / `Development` produit un prompt contenant naturellement ces données —
**aucune règle codée en dur** « /dev/throw ».
## 2. Référentiel administrable (Partie B)
### Règle absolue
`diagnostic_event` **reste la vérité** sur ce qui a réellement été observé. Le référentiel
(`diagnostics_reference`) est un **enrichissement** (libellé / description / ordre / état) et ne
détermine **jamais** quels événements sont acceptés. Un code observé non référencé reste visible
dans les filtres.
### Table `diagnostics_reference` (migration 011)
`id`, `reference_type` (`SCREEN_CODE` / `FEATURE_CODE`), `code` (normalisé TRIM+UPPER),
`label`, `description`, `enabled`, `sort_order`, `created_at_utc`, `updated_at_utc`.
- Unicité : `(reference_type, UPPER(code))` — index unique.
- **Code immuable** après création. Pour corriger un code : créer une nouvelle entrée + archiver
l'ancienne.
- **Archivage = `enabled=false`**, jamais de DELETE physique (V1).
### Ownership / GRANT (011)
- Propriétaire : `omika_diagnostics_owner`.
- `omika_diagnostics_web` : `SELECT`, `INSERT`, `UPDATE (label, description, enabled,
sort_order, updated_at_utc)` + `USAGE` séquence. **Aucun DELETE.**
- `omika_diagnostics_api` et `omika_diagnostics_worker` : **aucun droit** (testé négativement).
### Initialisation (idempotente)
Les **29 ScreenCode** et **3 FeatureCode** du catalogue applicatif sont seedés par la migration
avec leurs libellés (`ON CONFLICT DO NOTHING`). Un libellé modifié par un Admin n'est **jamais
écrasé** au redémarrage.
### Pages
- `/Referentiel` : consultation (Support + Admin) — sections Codes écran / Codes fonctionnalité
alimentées par le référentiel DB (libellé + description + état), repli catalogue statique si DB
indisponible.
- `/Referentiel/Admin` : **Admin uniquement** — liste (Type, Code, Libellé, Description, État,
Dernière modification, Actions), création, modification (libellé/description/état/tri),
archivage/réactivation, et section **« Codes observés non référencés »** avec **ajout rapide**.
### Procédure : ajout d'un nouveau code
1. Un événement arrive avec un `screen_code`/`feature_code` inconnu (ex. `FACTURE_DETAIL`).
2. Le code **apparaît automatiquement** dans les filtres Diagnostics (alimentés par
`diagnostic_event`), affiché `FACTURE_DETAIL (non référencé)`.
3. Dans `/Referentiel/Admin` → « Codes observés non référencés » → « Ajouter » (type + code
préremplis) → Admin renseigne le libellé/la description.
4. Le filtre affiche alors `Détail de facture (FACTURE_DETAIL)` — **sans modifier les données
historiques**. La recherche SQL utilise toujours le **code**, jamais le libellé.
### Dropdowns / filtrage
Les listes déroulantes continuent d'être alimentées par les codes **réellement observés** (jamais
`SELECT` sur `diagnostics_reference` uniquement). Si une référence existe : `Libellé (CODE)` ;
sinon : `CODE (non référencé)`. La valeur du filtre reste toujours le CODE.
## 3. Corrections UX (Partie C)
- Filtre « Acteur » → **« Acteur pseudonymisé »** + aide : « Identifiant pseudonymisé permettant
de retrouver les événements d'un même utilisateur. »
- Page/menu « Versions » → **« Versions Omika »**.
- « Version la plus représentée » → **« Version Omika la plus observée »** + note : « Les valeurs
correspondent aux événements observés, pas au nombre d'installations. »
## 4. Sécurité
- Prompt généré : 0 fuite de données interdites (testé — `DiagnosticPromptBuilderTests`).
- HTML : le prompt est affiché via `textContent` (aucune injection HTML).
- Aucun enregistrement du prompt (ni PostgreSQL, ni log).
- Migration 011 : aucune colonne secret, aucun mot de passe.
## 5. Tests
- `Omika.Diagnostics.Tests` : 574/574 (baseline 536 + 38 tests T24-A).
- `Omika.Tests` : 3366/3370 — 4 échecs environnementaux/préexistants (construction de vues
Avalonia ×2, course temporelle ShellViewModel, accès HFSQL), **aucune régression** liée à T24-A
(T24-A est isolé dans le sous-système Diagnostics, non référencé par Omika.Tests).
## 6. Migration 011
`infra/postgresql/011_diagnostics_reference.sql` — ordre final : `001 → … → 010 → 011`.
Idempotente, non destructive (aucun DROP/DELETE), aucun mot de passe.
+89
View File
@@ -0,0 +1,89 @@
# Backend — Statut final
État de fermeture du backend métier Omika, prêt pour la future application mobile.
## Schéma Batik / Omika
- Détection de schéma réelle au démarrage (`DatabaseSchemaDetector`), basée sur la structure (colonnes canaris), jamais sur le nom de la base.
- Routage runtime : `ApplicationRuntimeContext.Current.DetectedVersion` → `AdaptiveRepository` → implémentation `Batik*` ou `Omika*`.
- Aucune logique basée sur le nom de la base.
## HFSQL
- Accès OLE DB : **VERIFIED** (provider `PCSOFT.HFSQL`).
- Source des mots de passe : **EXPLICIT_LOCAL_CONFIG** (`config/hfsql-test.local.json`).
- Dérivation de mot de passe utilisée : **NO** (mots de passe fournis explicitement).
- `ProtectionPlusSecret` utilisé pour la connexion : **NO**.
## Business aggregates (8 VERIFIED)
| Agrégat | Statut |
|---------|--------|
| Client | VERIFIED |
| Personne | VERIFIED |
| Lieu/Site | VERIFIED |
| Document | VERIFIED |
| Intervention | VERIFIED |
| Dossier | VERIFIED |
| PlanningRDV | VERIFIED |
| Collaborateur | VERIFIED |
0 PARTIAL, 0 NOT_STARTED.
## Reference data
- **PublicRead** : ParametreMetier (`GET /reference-data/{key}`, READ_ONLY_VERIFIED).
- **InternalOnly** : Societe, Equipe, ParametreMetierEntete, EtatIntervention.
- **SchemaSpecific** : Role, Etats (Omika uniquement).
- **NotRequired** : ModeleRapport.
## API
Groupes d'endpoints enregistrés dans `Program.cs` :
- Health
- Client
- Contact (Personne)
- ReferenceData
- Document
- Site (Lieu)
- Intervention
- Dossier
- RendezVous (PlanningRDV)
- Collaborateur
Aucun endpoint préventif supplémentaire. Conforme à la règle « ne crée pas automatiquement un endpoint pour chaque table ».
## Adaptive repositories
Tous les agrégats publics disposent d'un repository adaptatif (`Adaptive*Repository`) qui route selon `DetectedVersion` :
- Client, Contact, Site, Document, Intervention, Dossier, RendezVous, Collaborateur, ReferenceData.
## Tests
- **1144 PASS / 0 FAIL / 0 SKIP**.
- Intégrations réelles OLE DB (BATIK_TEST / OMIKA_TEST) pour les parcours principaux : Client (Create), Intervention (lifecycle), Dossier (lifecycle), PlanningRDV (lifecycle), Collaborateur (lifecycle) — avec rollback atomique.
- Les tests d'intégration s'ignorent proprement si le serveur HFSQL est injoignable (détection `E_FAIL`/`0x80004005`).
## Limitations connues
- **ElementSupprime** : absent physiquement de OMIKA_TEST actuel ; traité comme optionnel (VERSION_OPTIONAL).
- **Equipe → Collaborateur** : relation INTERNAL_ONLY, pas d'identité canonique publique (aucune table de correspondance).
- **Role** : Omika-specific, non exposé publiquement.
- **ModeleRapport** : NOT_REQUIRED.
- **Document.Update** : NOT_APPLICABLE (document immuable après création).
- **Intervention PartialFailureRollback** : non couvert spécifiquement, mais transaction/lifecycle validés (rollback externe prouvé).
## Éléments volontairement non exposés
- Tables de jointure (JOIN_TABLE) : pas de CRUD public.
- Configuration système (Utilisateur, ParametreSysteme) : pas d'endpoint public.
- Référentiels internes (Societe, Equipe, ParametreMetierEntete, EtatIntervention, Role, Etats, ModeleRapport) : pas d'endpoint public.
- IDs publics opaques (string) : aucun PK HFSQL brut exposé, aucun GUID fabriqué pour masquer l'absence d'identité canonique.
## Statut final
- **BackendBusinessLayer** : READY.
- **BackendForMobileDevelopment** : READY.
- **Blockers** : NONE.
+31
View File
@@ -0,0 +1,31 @@
DomainEntity,BatikTable,OmikaTable,Category,Create,ReadOne,ReadList,Update,Delete,PublicApi,ExistingService,ExistingRepository,ExistingEndpoint,BatikSupported,OmikaSupported,Status,Notes
Client,Client,Client,BUSINESS_AGGREGATE,IMPLEMENTED,IMPLEMENTED,IMPLEMENTED,IMPLEMENTED,IMPLEMENTED,YES,YES,YES,YES,YES,YES,VERIFIED,CRUD complet via ClientService/AdaptiveClientRepository
Personne,Personne,Personne,BUSINESS_AGGREGATE,IMPLEMENTED,IMPLEMENTED,IMPLEMENTED,IMPLEMENTED,IMPLEMENTED,YES,YES,YES,YES,YES,YES,VERIFIED,CRUD complet via ContactService/AdaptiveContactRepository
Adresse,Adresse,Adresse,BUSINESS_CHILD,NOT_STARTED,NOT_STARTED,NOT_STARTED,NOT_STARTED,NOT_STARTED,NO,NO,NO,NO,YES,YES,NOT_STARTED,Adresse partagée entre clients/personnes/sites
Lieu,Lieu,Lieu,BUSINESS_AGGREGATE,IMPLEMENTED,IMPLEMENTED,IMPLEMENTED,IMPLEMENTED,SEMANTIC_DELETE,YES,YES,YES,YES,YES,YES,VERIFIED,Site/lieu d'intervention; CRUD via SiteService/AdaptiveSiteRepository/BatikSiteRepository/OmikaSiteRepository; suppression=archivage
Document,Document,Document,BUSINESS_AGGREGATE,IMPLEMENTED,IMPLEMENTED,IMPLEMENTED,NOT_APPLICABLE,SEMANTIC_DELETE,YES,YES,YES,YES,YES,YES,VERIFIED,Document immuable après création; suppression = archivage (Archive)
Intervention,Intervention,Intervention,BUSINESS_AGGREGATE,IMPLEMENTED,IMPLEMENTED,IMPLEMENTED,IMPLEMENTED,SEMANTIC_DELETE,YES,YES,YES,YES,YES,YES,VERIFIED,CRUD complet via InterventionService/AdaptiveInterventionRepository; suppression=archivage; intégration réelle CREATE→UPDATE→ARCHIVE→ROLLBACK PASS sur BATIK_TEST/OMIKA_TEST; relations Collaborateur/Personne câblées; IDs invalides→DomainValidationException sans écriture partielle
Dossier,Dossier,Dossier,BUSINESS_AGGREGATE,IMPLEMENTED,IMPLEMENTED,IMPLEMENTED,IMPLEMENTED,SEMANTIC_DELETE,YES,YES,YES,YES,YES,YES,VERIFIED,CRUD complet via DossierService/AdaptiveDossierRepository; suppression=archivage; intégration réelle CREATE→UPDATE→ARCHIVE→ROLLBACK PASS sur BATIK_TEST/OMIKA_TEST; DO_NumDossier rubrique calculée Omika exclue des écritures (erreur HFSQL 28000002)
PlanningRDV,Planning_RDV,RDVPlanning,BUSINESS_AGGREGATE,IMPLEMENTED,IMPLEMENTED,IMPLEMENTED,IMPLEMENTED,SEMANTIC_DELETE,YES,YES,YES,YES,YES,YES,VERIFIED,Rendez-vous; CRUD via RendezVousService/AdaptiveRendezVousRepository; suppression=archivage; intégration réelle CREATE→UPDATE→CANCEL→ARCHIVE→ROLLBACK PASS
Collaborateur,Collaborateur,Collaborateur,BUSINESS_AGGREGATE,IMPLEMENTED,IMPLEMENTED,IMPLEMENTED,IMPLEMENTED,SEMANTIC_DELETE,YES,YES,YES,YES,YES,YES,VERIFIED,Collaborateur interne; CRUD via CollaborateurService/AdaptiveCollaborateurRepository; suppression=archivage (CO_Actif=false); intégration réelle CREATE→UPDATE→ARCHIVE→ROLLBACK PASS
Societe,Societe,Societe,REFERENCE_DATA,NOT_APPLICABLE,NOT_APPLICABLE,NOT_APPLICABLE,NOT_APPLICABLE,NOT_APPLICABLE,NO,NO,NO,NO,YES,YES,INTERNAL_ONLY,Société (référentiel); lecture interne uniquement, aucun besoin public démontré
Equipe,Equipe,Equipe,REFERENCE_DATA,NOT_APPLICABLE,NOT_APPLICABLE,NOT_APPLICABLE,NOT_APPLICABLE,NOT_APPLICABLE,NO,NO,NO,NO,YES,YES,INTERNAL_ONLY,Équipe (référentiel); pas d'identité publique canonique, aucun GUID fabriqué
Utilisateur,Utilisateur,Utilisateur,SYSTEM_CONFIGURATION,NOT_STARTED,NOT_STARTED,NOT_STARTED,NOT_STARTED,NOT_STARTED,NO,NO,NO,NO,YES,YES,NOT_STARTED,Utilisateur système
ParametreMetier,ParametreMetier,ParametreMetier,REFERENCE_DATA,NOT_APPLICABLE,READ_ONLY,READ_ONLY,NOT_APPLICABLE,NOT_APPLICABLE,YES,YES,YES,YES,YES,YES,READ_ONLY_VERIFIED,Référentiel métier (types/statuts); lecture publique via GET /reference-data/{key}; IReferenceDataRepository/Service + Adaptive + Batik/Omika
ParametreMetierEntete,ParametreMetierEntete,ParametreMetierEntete,REFERENCE_DATA,NOT_APPLICABLE,NOT_APPLICABLE,NOT_APPLICABLE,NOT_APPLICABLE,NOT_APPLICABLE,NO,NO,NO,NO,YES,YES,INTERNAL_ONLY,En-tête des paramètres métier; historique/protégé, lecture interne uniquement
ParametreSysteme,ParametreSysteme,ParametreSysteme,SYSTEM_CONFIGURATION,NOT_STARTED,NOT_STARTED,NOT_STARTED,NOT_STARTED,NOT_STARTED,NO,NO,NO,NO,YES,YES,NOT_STARTED,Paramètres système
EtatIntervention,EtatIntervention,EtatIntervention,REFERENCE_DATA,NOT_APPLICABLE,NOT_APPLICABLE,NOT_APPLICABLE,NOT_APPLICABLE,NOT_APPLICABLE,NO,NO,NO,NO,YES,YES,INTERNAL_ONLY,États d'intervention; table d'historique chargée en interne par Intervention, pas de liste publique autonome
ClientAdresse,ClientAdresse,ClientAdresse,JOIN_TABLE,NOT_STARTED,NOT_STARTED,NOT_STARTED,NOT_STARTED,NOT_STARTED,NO,NO,NO,NO,YES,YES,NOT_STARTED,Relation client-adresse (via agrégat)
ClientLieu,ClientLieu,ClientLieu,JOIN_TABLE,NOT_STARTED,NOT_STARTED,NOT_STARTED,NOT_STARTED,NOT_STARTED,NO,NO,NO,NO,YES,YES,NOT_STARTED,Relation client-lieu (via agrégat)
ClientBddExterne,ClientBddExterne,ClientBddExterne,JOIN_TABLE,NOT_STARTED,NOT_STARTED,NOT_STARTED,NOT_STARTED,NOT_STARTED,NO,NO,NO,NO,YES,YES,NOT_STARTED,Relation client-base externe
InterventionCollaborateur,InterventionCollaborateur,InterventionCollaborateur,JOIN_TABLE,NOT_STARTED,NOT_STARTED,NOT_STARTED,NOT_STARTED,NOT_STARTED,NO,NO,NO,NO,YES,YES,NOT_STARTED,Relation intervention-collaborateur
InterventionPersonne,InterventionPersonne,InterventionPersonne,JOIN_TABLE,NOT_STARTED,NOT_STARTED,NOT_STARTED,NOT_STARTED,NOT_STARTED,NO,NO,NO,NO,YES,YES,NOT_STARTED,Relation intervention-personne
LieuPersonne,LieuPersonne,LieuPersonne,JOIN_TABLE,NOT_STARTED,NOT_STARTED,NOT_STARTED,NOT_STARTED,NOT_STARTED,NO,NO,NO,NO,YES,YES,NOT_STARTED,Relation lieu-personne
ElementSupprime,,ElementSupprime,VERSION_OPTIONAL,NOT_STARTED,NOT_STARTED,NOT_STARTED,NOT_STARTED,NOT_STARTED,NO,NO,NO,NO,NO,YES,NOT_STARTED,Table de suppression logique (Omika uniquement)
Role,,Role,REFERENCE_DATA,NOT_APPLICABLE,NOT_APPLICABLE,NOT_APPLICABLE,NOT_APPLICABLE,NOT_APPLICABLE,NO,NO,NO,NO,NO,YES,SCHEMA_SPECIFIC,Rôles (Omika uniquement); lecture interne, absent sous Batik (SchemaCapabilities.SupportsRoles)
Etats,,Etats,REFERENCE_DATA,NOT_APPLICABLE,NOT_APPLICABLE,NOT_APPLICABLE,NOT_APPLICABLE,NOT_APPLICABLE,NO,NO,NO,NO,NO,YES,SCHEMA_SPECIFIC,États (Omika uniquement); lecture interne, absent sous Batik
ModeleRapport,,ModeleRapport,REFERENCE_DATA,NOT_APPLICABLE,NOT_APPLICABLE,NOT_APPLICABLE,NOT_APPLICABLE,NOT_APPLICABLE,NO,NO,NO,NO,NO,YES,NOT_REQUIRED,Modèles de rapport (Omika uniquement); aucun besoin public démontré
1 DomainEntity BatikTable OmikaTable Category Create ReadOne ReadList Update Delete PublicApi ExistingService ExistingRepository ExistingEndpoint BatikSupported OmikaSupported Status Notes
2 Client Client Client BUSINESS_AGGREGATE IMPLEMENTED IMPLEMENTED IMPLEMENTED IMPLEMENTED IMPLEMENTED YES YES YES YES YES YES VERIFIED CRUD complet via ClientService/AdaptiveClientRepository
3 Personne Personne Personne BUSINESS_AGGREGATE IMPLEMENTED IMPLEMENTED IMPLEMENTED IMPLEMENTED IMPLEMENTED YES YES YES YES YES YES VERIFIED CRUD complet via ContactService/AdaptiveContactRepository
4 Adresse Adresse Adresse BUSINESS_CHILD NOT_STARTED NOT_STARTED NOT_STARTED NOT_STARTED NOT_STARTED NO NO NO NO YES YES NOT_STARTED Adresse partagée entre clients/personnes/sites
5 Lieu Lieu Lieu BUSINESS_AGGREGATE IMPLEMENTED IMPLEMENTED IMPLEMENTED IMPLEMENTED SEMANTIC_DELETE YES YES YES YES YES YES VERIFIED Site/lieu d'intervention; CRUD via SiteService/AdaptiveSiteRepository/BatikSiteRepository/OmikaSiteRepository; suppression=archivage
6 Document Document Document BUSINESS_AGGREGATE IMPLEMENTED IMPLEMENTED IMPLEMENTED NOT_APPLICABLE SEMANTIC_DELETE YES YES YES YES YES YES VERIFIED Document immuable après création; suppression = archivage (Archive)
7 Intervention Intervention Intervention BUSINESS_AGGREGATE IMPLEMENTED IMPLEMENTED IMPLEMENTED IMPLEMENTED SEMANTIC_DELETE YES YES YES YES YES YES VERIFIED CRUD complet via InterventionService/AdaptiveInterventionRepository; suppression=archivage; intégration réelle CREATE→UPDATE→ARCHIVE→ROLLBACK PASS sur BATIK_TEST/OMIKA_TEST; relations Collaborateur/Personne câblées; IDs invalides→DomainValidationException sans écriture partielle
8 Dossier Dossier Dossier BUSINESS_AGGREGATE IMPLEMENTED IMPLEMENTED IMPLEMENTED IMPLEMENTED SEMANTIC_DELETE YES YES YES YES YES YES VERIFIED CRUD complet via DossierService/AdaptiveDossierRepository; suppression=archivage; intégration réelle CREATE→UPDATE→ARCHIVE→ROLLBACK PASS sur BATIK_TEST/OMIKA_TEST; DO_NumDossier rubrique calculée Omika exclue des écritures (erreur HFSQL 28000002)
9 PlanningRDV Planning_RDV RDVPlanning BUSINESS_AGGREGATE IMPLEMENTED IMPLEMENTED IMPLEMENTED IMPLEMENTED SEMANTIC_DELETE YES YES YES YES YES YES VERIFIED Rendez-vous; CRUD via RendezVousService/AdaptiveRendezVousRepository; suppression=archivage; intégration réelle CREATE→UPDATE→CANCEL→ARCHIVE→ROLLBACK PASS
10 Collaborateur Collaborateur Collaborateur BUSINESS_AGGREGATE IMPLEMENTED IMPLEMENTED IMPLEMENTED IMPLEMENTED SEMANTIC_DELETE YES YES YES YES YES YES VERIFIED Collaborateur interne; CRUD via CollaborateurService/AdaptiveCollaborateurRepository; suppression=archivage (CO_Actif=false); intégration réelle CREATE→UPDATE→ARCHIVE→ROLLBACK PASS
11 Societe Societe Societe REFERENCE_DATA NOT_APPLICABLE NOT_APPLICABLE NOT_APPLICABLE NOT_APPLICABLE NOT_APPLICABLE NO NO NO NO YES YES INTERNAL_ONLY Société (référentiel); lecture interne uniquement aucun besoin public démontré
12 Equipe Equipe Equipe REFERENCE_DATA NOT_APPLICABLE NOT_APPLICABLE NOT_APPLICABLE NOT_APPLICABLE NOT_APPLICABLE NO NO NO NO YES YES INTERNAL_ONLY Équipe (référentiel); pas d'identité publique canonique aucun GUID fabriqué
13 Utilisateur Utilisateur Utilisateur SYSTEM_CONFIGURATION NOT_STARTED NOT_STARTED NOT_STARTED NOT_STARTED NOT_STARTED NO NO NO NO YES YES NOT_STARTED Utilisateur système
14 ParametreMetier ParametreMetier ParametreMetier REFERENCE_DATA NOT_APPLICABLE READ_ONLY READ_ONLY NOT_APPLICABLE NOT_APPLICABLE YES YES YES YES YES YES READ_ONLY_VERIFIED Référentiel métier (types/statuts); lecture publique via GET /reference-data/{key}; IReferenceDataRepository/Service + Adaptive + Batik/Omika
15 ParametreMetierEntete ParametreMetierEntete ParametreMetierEntete REFERENCE_DATA NOT_APPLICABLE NOT_APPLICABLE NOT_APPLICABLE NOT_APPLICABLE NOT_APPLICABLE NO NO NO NO YES YES INTERNAL_ONLY En-tête des paramètres métier; historique/protégé lecture interne uniquement
16 ParametreSysteme ParametreSysteme ParametreSysteme SYSTEM_CONFIGURATION NOT_STARTED NOT_STARTED NOT_STARTED NOT_STARTED NOT_STARTED NO NO NO NO YES YES NOT_STARTED Paramètres système
17 EtatIntervention EtatIntervention EtatIntervention REFERENCE_DATA NOT_APPLICABLE NOT_APPLICABLE NOT_APPLICABLE NOT_APPLICABLE NOT_APPLICABLE NO NO NO NO YES YES INTERNAL_ONLY États d'intervention; table d'historique chargée en interne par Intervention pas de liste publique autonome
18 ClientAdresse ClientAdresse ClientAdresse JOIN_TABLE NOT_STARTED NOT_STARTED NOT_STARTED NOT_STARTED NOT_STARTED NO NO NO NO YES YES NOT_STARTED Relation client-adresse (via agrégat)
19 ClientLieu ClientLieu ClientLieu JOIN_TABLE NOT_STARTED NOT_STARTED NOT_STARTED NOT_STARTED NOT_STARTED NO NO NO NO YES YES NOT_STARTED Relation client-lieu (via agrégat)
20 ClientBddExterne ClientBddExterne ClientBddExterne JOIN_TABLE NOT_STARTED NOT_STARTED NOT_STARTED NOT_STARTED NOT_STARTED NO NO NO NO YES YES NOT_STARTED Relation client-base externe
21 InterventionCollaborateur InterventionCollaborateur InterventionCollaborateur JOIN_TABLE NOT_STARTED NOT_STARTED NOT_STARTED NOT_STARTED NOT_STARTED NO NO NO NO YES YES NOT_STARTED Relation intervention-collaborateur
22 InterventionPersonne InterventionPersonne InterventionPersonne JOIN_TABLE NOT_STARTED NOT_STARTED NOT_STARTED NOT_STARTED NOT_STARTED NO NO NO NO YES YES NOT_STARTED Relation intervention-personne
23 LieuPersonne LieuPersonne LieuPersonne JOIN_TABLE NOT_STARTED NOT_STARTED NOT_STARTED NOT_STARTED NOT_STARTED NO NO NO NO YES YES NOT_STARTED Relation lieu-personne
24 ElementSupprime ElementSupprime VERSION_OPTIONAL NOT_STARTED NOT_STARTED NOT_STARTED NOT_STARTED NOT_STARTED NO NO NO NO NO YES NOT_STARTED Table de suppression logique (Omika uniquement)
25 Role Role REFERENCE_DATA NOT_APPLICABLE NOT_APPLICABLE NOT_APPLICABLE NOT_APPLICABLE NOT_APPLICABLE NO NO NO NO NO YES SCHEMA_SPECIFIC Rôles (Omika uniquement); lecture interne absent sous Batik (SchemaCapabilities.SupportsRoles)
26 Etats Etats REFERENCE_DATA NOT_APPLICABLE NOT_APPLICABLE NOT_APPLICABLE NOT_APPLICABLE NOT_APPLICABLE NO NO NO NO NO YES SCHEMA_SPECIFIC États (Omika uniquement); lecture interne absent sous Batik
27 ModeleRapport ModeleRapport REFERENCE_DATA NOT_APPLICABLE NOT_APPLICABLE NOT_APPLICABLE NOT_APPLICABLE NOT_APPLICABLE NO NO NO NO NO YES NOT_REQUIRED Modèles de rapport (Omika uniquement); aucun besoin public démontré
+106
View File
@@ -0,0 +1,106 @@
# Matrice de couverture CRUD
État de la couverture CRUD pour les agrégats métier Batik et Omika.
## Résumé
| Catégorie | Nombre | Statut |
|-----------|--------|--------|
| BUSINESS_AGGREGATE | 8 | 8 VERIFIED, 0 PARTIAL, 0 NOT_STARTED |
| BUSINESS_CHILD | 1 | NOT_STARTED |
| REFERENCE_DATA | 8 | 1 READ_ONLY_VERIFIED, 4 INTERNAL_ONLY, 2 SCHEMA_SPECIFIC, 1 NOT_REQUIRED |
| JOIN_TABLE | 6 | NOT_STARTED |
| SYSTEM_CONFIGURATION | 2 | NOT_STARTED |
| VERSION_OPTIONAL | 1 | NOT_STARTED |
## Agrégats métier (BUSINESS_AGGREGATE)
| Entité | Batik | Omika | Create | ReadOne | ReadList | Update | Delete | Statut |
|--------|-------|-------|--------|---------|----------|--------|--------|--------|
| Client | Client | Client | ✅ | ✅ | ✅ | ✅ | ✅ | VERIFIED |
| Personne | Personne | Personne | ✅ | ✅ | ✅ | ✅ | ✅ | VERIFIED |
| Lieu | Lieu | Lieu | ✅ | ✅ | ✅ | ✅ | 🗄️ | VERIFIED |
| Document | Document | Document | ✅ | ✅ | ✅ | N/A | 🗄️ | VERIFIED |
| Intervention | Intervention | Intervention | ✅ | ✅ | ✅ | ✅ | 🗄️ | VERIFIED |
| Dossier | Dossier | Dossier | ✅ | ✅ | ✅ | ✅ | 🗄️ | VERIFIED |
| PlanningRDV | Planning_RDV | RDVPlanning | ✅ | ✅ | ✅ | ✅ | 🗄️ | VERIFIED |
| Collaborateur | Collaborateur | Collaborateur | ✅ | ✅ | ✅ | ✅ | 🗄️ | VERIFIED |
## Référentiels (REFERENCE_DATA)
Lecture seule uniquement. Pas de CRUD administratif générique (C/U/D volontairement absents).
| Entité | Batik | Omika | Lecture | Classification | Statut |
|--------|-------|-------|---------|----------------|--------|
| Societe | Societe | Societe | ❌ | INTERNAL_REFERENCE_ONLY | INTERNAL_ONLY |
| Equipe | Equipe | Equipe | ❌ | INTERNAL_REFERENCE_ONLY | INTERNAL_ONLY |
| ParametreMetier | ParametreMetier | ParametreMetier | ✅ | PUBLIC_READ_REFERENCE | READ_ONLY_VERIFIED |
| ParametreMetierEntete | ParametreMetierEntete | ParametreMetierEntete | ❌ | INTERNAL_REFERENCE_ONLY | INTERNAL_ONLY |
| EtatIntervention | EtatIntervention | EtatIntervention | ❌ | INTERNAL_REFERENCE_ONLY | INTERNAL_ONLY |
| Role | — | Role | ❌ | SCHEMA_SPECIFIC | SCHEMA_SPECIFIC |
| Etats | — | Etats | ❌ | SCHEMA_SPECIFIC | SCHEMA_SPECIFIC |
| ModeleRapport | — | ModeleRapport | ❌ | NOT_REQUIRED | NOT_REQUIRED |
### Détail des classifications
- **ParametreMetier** (PUBLIC_READ_REFERENCE) : lecture publique via `GET /reference-data/{key}`. Câblé : `IReferenceDataRepository → AdaptiveReferenceDataRepository`, `IReferenceDataService → ReferenceDataService`, `BatikReferenceDataRepository` (filtre `PA_Code`) / `OmikaReferenceDataRepository` (filtre `PA_Categorie`). Retourne `ReferenceValueDto` (Key/LegacyId/Code/Label).
- **Societe** (INTERNAL_REFERENCE_ONLY) : aucun besoin public démontré (pas de sélecteur de société requis par l'application).
- **Equipe** (INTERNAL_REFERENCE_ONLY) : pas d'identité publique canonique exploitable indépendamment ; aucun GUID fabriqué.
- **ParametreMetierEntete** (INTERNAL_REFERENCE_ONLY) : référentiel historique/protégé, pas de CRUD public.
- **EtatIntervention** (INTERNAL_REFERENCE_ONLY) : table d'historique des états d'une intervention (chargée en interne par Intervention) ; les valeurs d'état proviennent de ParametreMetier. Pas de liste publique autonome.
- **Role** (SCHEMA_SPECIFIC) : Omika uniquement ; absent sous Batik (indiqué par `SchemaCapabilities.SupportsRoles`). Lecture interne.
- **Etats** (SCHEMA_SPECIFIC) : Omika uniquement ; absent sous Batik. Lecture interne.
- **ModeleRapport** (NOT_REQUIRED) : Omika uniquement ; aucun besoin public démontré.
## Tables de jointure (JOIN_TABLE)
Aucun CRUD public exposé. Gérées via les agrégats.
| Table | Batik | Omika |
|-------|-------|-------|
| ClientAdresse | ✅ | ✅ |
| ClientLieu | ✅ | ✅ |
| ClientBddExterne | ✅ | ✅ |
| InterventionCollaborateur | ✅ | ✅ |
| InterventionPersonne | ✅ | ✅ |
| LieuPersonne | ✅ | ✅ |
## Configuration système (SYSTEM_CONFIGURATION)
| Table | Batik | Omika |
|-------|-------|-------|
| Utilisateur | ✅ | ✅ |
| ParametreSysteme | ✅ | ✅ |
## Version optionnelle (VERSION_OPTIONAL)
| Table | Batik | Omika | Statut |
|-------|-------|-------|--------|
| ElementSupprime | — | ✅ | NOT_STARTED |
## Notes
- Client et Personne sont entièrement implémentés (VERIFIED).
- Lieu/Site est VERIFIED : CRUD complet implémenté et testé unitairement (Create/ReadOne/ReadList/Update + Archive) via SiteService/AdaptiveSiteRepository/BatikSiteRepository/OmikaSiteRepository. BATIK : table Lieu, création Adresse transactionnelle, type résolu via ParametreMetier. OMIKA : table Lieu, LI_CleType, GUID, Adresse Omika. API /sites. DI : ISiteRepository → AdaptiveSiteRepository, ISiteService → SiteService.
- Document est VERIFIED : Create/Read/List + Archive (suppression sémantique). Update = NOT_APPLICABLE (document immuable après création).
- Intervention est VERIFIED : CRUD complet implémenté et testé unitairement (Create/ReadOne/ReadList/Update + Archive), intégration réelle CREATE→UPDATE→ARCHIVE→ROLLBACK PASS contre BATIK_TEST/OMIKA_TEST, relations Collaborateur/Personne câblées (UPDATE), IDs relation invalides → DomainValidationException sans écriture partielle.
- Dossier est VERIFIED : CRUD complet implémenté et testé unitairement (Create/ReadOne/ReadList/Update + Archive), intégration réelle CREATE→UPDATE→ARCHIVE→ROLLBACK PASS contre BATIK_TEST/OMIKA_TEST. NB : dans la base Omika, DO_NumDossier est une rubrique CALCULÉE (erreur HFSQL 28000002) — elle est exclue des INSERT/UPDATE, générée par la base.
- RendezVous (PlanningRDV) est VERIFIED : CRUD complet implémenté et testé unitairement (Create/ReadOne/ReadList/Update + Cancel + Archive), intégration réelle CREATE→UPDATE→CANCEL→ARCHIVE→ROLLBACK PASS contre BATIK_TEST/OMIKA_TEST. NB : le provider OLE DB ne sait pas marshaler `DateOnly` vers un Variant — la couche de connexion normalise désormais `DateOnly` en `DateTime` avant transmission.
- Collaborateur est VERIFIED : CRUD complet implémenté et testé unitairement (Create/ReadOne/ReadList/Update + Archive), intégration réelle CREATE→UPDATE→ARCHIVE→ROLLBACK PASS contre BATIK_TEST/OMIKA_TEST. NB : l'archivage logique est réalisé par désactivation (CO_Actif = false), aucun DELETE physique. Les associations d'équipe (Equipe_Collaborateur) sont lues mais non converties en GUID canoniques (aucune table de correspondance) ; Role est NOT_APPLICABLE (géré via les agrégats).
- Les contrats de service existent pour Document, Intervention, RendezVous, Collaborateur et ReferenceData.
- REFERENCE_DATA : seul ParametreMetier est exposé en lecture publique (READ_ONLY_VERIFIED). Les autres référentiels sont internes (INTERNAL_ONLY), spécifiques à un schéma (SCHEMA_SPECIFIC) ou non requis (NOT_REQUIRED). Aucun endpoint supplémentaire n'a été créé : conforme à la règle « ne crée pas automatiquement un endpoint pour chaque table ».
- Les tables de jointure ne doivent pas être exposées en CRUD public.
- ElementSupprime est absent de OMIKA_TEST actuel : à traiter comme optionnel.
+163
View File
@@ -0,0 +1,163 @@
# État d'implémentation CRUD
État réel de l'implémentation CRUD par agrégat métier, basé sur le code et les tests réellement présents.
## Légende
- **VERIFIED** : implémenté et testé (unitaire et/ou intégration).
- **PARTIAL** : implémenté mais avec des zones non couvertes (intégration réelle, relations).
- **NOT_STARTED** : non implémenté.
- **NOT_APPLICABLE** : opération non pertinente pour l'entité.
- **NOT_COVERED** : non exécuté (intégration réelle absente).
## Client — VERIFIED
- Create / ReadOne / ReadList / Update / Delete : implémentés et testés.
- Via `ClientService` / `AdaptiveClientRepository` / `ClientEndpoints`.
- Suppression : archivage logique.
## Personne — VERIFIED
- Create / ReadOne / ReadList / Update / Delete : implémentés et testés.
- Via `ContactService` / `AdaptiveContactRepository` / `ContactEndpoints`.
- Suppression : archivage logique.
## Lieu/Site — VERIFIED
- Create / ReadOne / ReadList / Update / Delete : implémentés et testés.
- Via `SiteService` / `AdaptiveSiteRepository` / `BatikSiteRepository` / `OmikaSiteRepository` / `SiteEndpoints`.
- BATIK : table physique `Lieu`, création Adresse transactionnelle, type résolu via `ParametreMetier`.
- OMIKA : table physique `Lieu`, `LI_CleType`, GUID, Adresse Omika.
- `AdaptiveSiteRepository` route selon `DatabaseSchemaVersion`.
- API : `/sites`.
- DI : `ISiteRepository → AdaptiveSiteRepository`, `ISiteService → SiteService`.
- Suppression : archivage logique.
- Tests : `BatikSiteMapperTests`, `OmikaSiteMapperTests` (PASS).
## Document — VERIFIED
- Create / ReadOne / ReadList : implémentés et testés.
- Update : NOT_APPLICABLE (document immuable après création).
- Delete : SEMANTIC_DELETE (archivage via `Archive`).
## Intervention — VERIFIED
### CRUD (implémenté et testé unitairement)
| Opération | Statut | Implémentation | Tests |
|-----------|--------|----------------|-------|
| Create | IMPLEMENTED | `InterventionService.CreateAsync` → `AdaptiveInterventionRepository` → `Batik/OmikaInterventionRepository` | `BatikInterventionRepositoryTests`, `OmikaInterventionRepositoryTests`, `InterventionServiceTests`, `InterventionEndpointsTests` |
| ReadOne | IMPLEMENTED | `GetByIdAsync` → `GetDetailsAsync` | idem |
| ReadList/Search | IMPLEMENTED | `SearchAsync` | idem |
| Update | IMPLEMENTED | `UpdateAsync` | idem |
| Delete | SEMANTIC_DELETE | `ArchiveAsync` (archivage logique, jamais DELETE) | idem |
### Relations
| Relation | Statut | Détail |
|----------|--------|--------|
| EtatIntervention | VERIFIED (unitaire) | Chargée via `LoadEtatsInterventionAsync`, mappée en `ActionIntervention`. Testée dans `BatikInterventionRepositoryTests`. |
| InterventionCollaborateur | VERIFIED (unitaire) | `CollaborateurIds` persistés dans `InterventionCollaborateur` (DELETE + INSERT) sur Update. IDs invalides → `DomainValidationException` sans écriture partielle. Testé (`UpdateAsync_PersistsCollaborateurAndPersonneRelations`, `UpdateAsync_InvalidCollaborateurId_ThrowsControlledError`). |
| InterventionPersonne | VERIFIED (unitaire) | `ContactIds` persistés dans `InterventionPersonne` (DELETE + INSERT) sur Update. IDs invalides → `DomainValidationException` sans écriture partielle. Testé (`UpdateAsync_PersistsPersonneRelations`, `UpdateAsync_InvalidContactId_ThrowsControlledError`). |
### Intégration
| Cible | Statut | Détail |
|-------|--------|--------|
| BATIK_TEST | PASS | `HfSqlOleDbInterventionLifecycleIntegrationTests.Batik_CreateUpdateArchive_RollsBackAtomically` exécuté et PASS : CREATE → UPDATE → ARCHIVE → ROLLBACK dans une transaction externe unique. |
| OMIKA_TEST | PASS | `HfSqlOleDbInterventionLifecycleIntegrationTests.Omika_CreateUpdateArchive_RollsBackAtomically` exécuté et PASS : CREATE → UPDATE → ARCHIVE → ROLLBACK dans une transaction externe unique. |
Les tests `HfSqlOleDbInterventionLifecycleIntegrationTests` exécutent le cycle de vie complet contre les vraies bases BATIK_TEST/OMIKA_TEST via le provider OLE DB, puis annulent (rollback) pour ne laisser aucune trace. Ils s'ignorent uniquement si la config est absente ou le serveur injoignable.
### Transaction
| Opération | Statut | Détail |
|-----------|--------|--------|
| CreateTransaction | PASS | `CreateAsync(command, transaction, ct)` exécute l'INSERT racine et les relations dans UNE transaction (interne ou externe). Exercé en intégration. |
| UpdateTransaction | PASS | `UpdateAsync(command, transaction, ct)` exécute UPDATE + DELETE/INSERT relations dans UNE transaction. Exercé en intégration. |
| Rollback | PASS | Rollback externe prouvé en intégration (CREATE→UPDATE→ARCHIVE→ROLLBACK). |
| PartialFailureRollback | NOT_COVERED | Aucun test ne provoque une erreur après une première écriture pour démontrer le rollback interne. Non bloquant : Create/Update utilisent bien une transaction unique, l'intégration réelle passe, le rollback externe est prouvé, aucune perte silencieuse de relations. |
### Application
| Couche | Statut | Détail |
|--------|--------|--------|
| AdaptiveRepository | PASS | `AdaptiveInterventionRepository` délègue selon le schéma détecté. Testé (`AdaptiveInterventionRepositoryTests`). |
| Service | PASS | `InterventionService` orchestre requête → critère → repository → DTO. Testé (`InterventionServiceTests`). |
| API | PASS | `InterventionEndpoints` (GET/POST/PUT/DELETE). Testé (`InterventionEndpointsTests`). |
## Dossier — VERIFIED
### CRUD (implémenté et testé unitairement)
| Opération | Statut | Implémentation | Tests |
|-----------|--------|----------------|-------|
| Create | IMPLEMENTED | `DossierService.CreateAsync` → `AdaptiveDossierRepository` → `Batik/OmikaDossierRepository` | `BatikDossierRepositoryTests`, `OmikaDossierRepositoryTests`, `DossierServiceTests`, `DossierEndpointsTests` |
| ReadOne | IMPLEMENTED | `GetDetailsAsync` | idem |
| ReadList/Search | IMPLEMENTED | `SearchAsync` | idem |
| Update | IMPLEMENTED | `UpdateAsync` | idem |
| Delete | SEMANTIC_DELETE | `ArchiveAsync` (archivage logique, jamais DELETE) | idem |
### Intégration
| Cible | Statut | Détail |
|-------|--------|--------|
| BATIK_TEST | PASS | `HfSqlOleDbDossierLifecycleIntegrationTests.Batik_CreateUpdateArchive_RollsBackAtomically` exécuté et PASS : CREATE → UPDATE → ARCHIVE → ROLLBACK dans une transaction externe unique. |
| OMIKA_TEST | PASS | `HfSqlOleDbDossierLifecycleIntegrationTests.Omika_CreateUpdateArchive_RollsBackAtomically` exécuté et PASS : CREATE → UPDATE → ARCHIVE → ROLLBACK dans une transaction externe unique. |
Les tests `HfSqlOleDbDossierLifecycleIntegrationTests` exécutent le cycle de vie complet contre les vraies bases BATIK_TEST/OMIKA_TEST via le provider OLE DB, puis annulent (rollback) pour ne laisser aucune trace. Ils s'ignorent uniquement si la config est absente ou le serveur injoignable.
### Particularité Omika — DO_NumDossier (rubrique calculée)
Dans la base Omika, `DO_NumDossier` est une **rubrique calculée** : toute écriture (INSERT/UPDATE) lève l'erreur HFSQL `28000002` (« Opération interdite sur la rubrique <Dossier.DO_NumDossier> : il s'agit d'une rubrique calculée »). Le `OmikaDossierRepository` exclut donc `DO_NumDossier` des INSERT/UPDATE : la valeur est générée par la base elle-même. La lecture (SELECT) reste possible. Ce comportement a été découvert et corrigé lors de l'intégration réelle.
### Transaction
| Opération | Statut | Détail |
|-----------|--------|--------|
| CreateTransaction | PASS | `CreateAsync(command, transaction, ct)` exécute l'INSERT racine dans UNE transaction (interne ou externe). Exercé en intégration. |
| UpdateTransaction | PASS | `UpdateAsync(command, transaction, ct)` exécute l'UPDATE dans UNE transaction. Exercé en intégration. |
| Rollback | PASS | Rollback externe prouvé en intégration (CREATE→UPDATE→ARCHIVE→ROLLBACK). |
### Application
| Couche | Statut | Détail |
|--------|--------|--------|
| AdaptiveRepository | PASS | `AdaptiveDossierRepository` délègue selon le schéma détecté. Testé (`AdaptiveDossierRepositoryTests`). |
| Service | PASS | `DossierService` orchestre requête → critère → repository → DTO. Testé (`DossierServiceTests`). |
| API | PASS | `DossierEndpoints` (GET/POST/PUT/DELETE). Testé (`DossierEndpointsTests`). |
## Référentiels (REFERENCE_DATA) — lecture seule
Les référentiels sont exposés en **lecture seule uniquement**. Aucun CRUD administratif générique (Create/Update/Delete) n'est créé : conforme à la règle « ne crée pas automatiquement un endpoint pour chaque table ».
### ParametreMetier — READ_ONLY_VERIFIED (PUBLIC_READ_REFERENCE)
Seul référentiel exposé en lecture publique, via le mécanisme générique existant :
- Endpoint : `GET /reference-data/{key}` (`MapReferenceDataEndpoints`).
- Service : `IReferenceDataService → ReferenceDataService`.
- Repository : `IReferenceDataRepository → AdaptiveReferenceDataRepository` (route selon le schéma détecté).
- Implémentations : `BatikReferenceDataRepository` (filtre `PA_Code`) / `OmikaReferenceDataRepository` (filtre `PA_Categorie`).
- Retour : `ReferenceValueDto` (Key / LegacyId / Code / Label).
- DI : enregistré dans `ServiceCollectionExtensions` (Scoped).
### Autres référentiels — aucune exposition publique
| Entité | Classification | Justification |
|--------|----------------|---------------|
| Societe | INTERNAL_REFERENCE_ONLY | Aucun besoin public démontré (pas de sélecteur de société requis). |
| Equipe | INTERNAL_REFERENCE_ONLY | Pas d'identité publique canonique exploitable indépendamment ; aucun GUID fabriqué. |
| ParametreMetierEntete | INTERNAL_REFERENCE_ONLY | Référentiel historique/protégé, pas de CRUD public. |
| EtatIntervention | INTERNAL_REFERENCE_ONLY | Table d'historique des états d'une intervention, chargée en interne par Intervention ; les valeurs d'état proviennent de ParametreMetier. Pas de liste publique autonome. |
| Role | SCHEMA_SPECIFIC | Omika uniquement ; absent sous Batik (`SchemaCapabilities.SupportsRoles`). Lecture interne. |
| Etats | SCHEMA_SPECIFIC | Omika uniquement ; absent sous Batik. Lecture interne. |
| ModeleRapport | NOT_REQUIRED | Omika uniquement ; aucun besoin public démontré. |
Aucun nouveau code n'a été nécessaire pour ce lot : le seul référentiel public (ParametreMetier) était déjà implémenté et câblé. Les autres restent internes.
## Blocker
Aucun. L'intégration réelle contre BATIK_TEST/OMIKA_TEST avec rollback est couverte et PASS pour Intervention et Dossier. Dossier est VERIFIED (avec la particularité Omika `DO_NumDossier` rubrique calculée gérée).
File diff suppressed because it is too large Load Diff
+71
View File
@@ -0,0 +1,71 @@
# HFSQL Model Audit — Diagnostic
## Batik
### Résumé
- Tables Expected: 48
- Tables Accessible: 48
- Tables Verified: 37
- Tables Partial: 0
- Tables Conflict: 11
- Tables Failed: 0
- Tables AbsentCurrentVersion: 0
- Columns Checked: 560
- Column Conflicts: 13
### Divergences
- Client_Adresse.CA_IDCli_IDAd [MODEL_ONLY] Column declared in model but not present in physical schema.
- Client_BddExterne.CLX_IDBddExterneCLX_IDCLient [MODEL_ONLY] Column declared in model but not present in physical schema.
- Equipe_Collaborateur.EC_IDEquipe_EC_IDCollaborateur [MODEL_ONLY] Column declared in model but not present in physical schema.
- Intervention_BddExterne.INX_IDBaseExtINX_IDIntervention [MODEL_ONLY] Column declared in model but not present in physical schema.
- Intervention_ChampLibre.ICL_IDIntervention_ICL_IDParametreM [MODEL_ONLY] Column declared in model but not present in physical schema.
- MailCat.MMC_CodeModMail_CodeCat [MODEL_ONLY] Column declared in model but not present in physical schema.
- MotClef.MOC_CodeMOC_Langue [MODEL_ONLY] Column declared in model but not present in physical schema.
- MotClef.MOC_MotMOC_Langue [MODEL_ONLY] Column declared in model but not present in physical schema.
- ParametreMetier.PA_CodePA_Valeur [MODEL_ONLY] Column declared in model but not present in physical schema.
- ParametreUtilisateur.PU_CodePU_IDUtilisateur [MODEL_ONLY] Column declared in model but not present in physical schema.
- Planning_RDV_Personne.PRP_PKID_PE_PKID [MODEL_ONLY] Column declared in model but not present in physical schema.
- Societe_Collaborateur.SC_IDCSoc_IDCol [MODEL_ONLY] Column declared in model but not present in physical schema.
- Societe_Collaborateur.SC_IDCSociete_SC_IDCollaborateur [MODEL_ONLY] Column declared in model but not present in physical schema.
## Omika
### Résumé
- Tables Expected: 58
- Tables Accessible: 57
- Tables Verified: 46
- Tables Partial: 0
- Tables Conflict: 11
- Tables Failed: 0
- Tables AbsentCurrentVersion: 1
- Columns Checked: 713
- Column Conflicts: 13
### Divergences
- Client_Adresse.CA_IDCli_IDAd [MODEL_ONLY] Column declared in model but not present in physical schema.
- Client_BddExterne.CLX_IDBddExterneCLX_IDCLient [MODEL_ONLY] Column declared in model but not present in physical schema.
- ElementSupprime.ES_PKID [ABSENT_IN_CURRENT_TEST_VERSION] Physical file absent in current test database version.
- ElementSupprime.ES_NumEnrg [ABSENT_IN_CURRENT_TEST_VERSION] Physical file absent in current test database version.
- ElementSupprime.ES_IdEnrg [ABSENT_IN_CURRENT_TEST_VERSION] Physical file absent in current test database version.
- ElementSupprime.ES_Fichier [ABSENT_IN_CURRENT_TEST_VERSION] Physical file absent in current test database version.
- ElementSupprime.ES_IdUser [ABSENT_IN_CURRENT_TEST_VERSION] Physical file absent in current test database version.
- ElementSupprime.ES_DateHeure [ABSENT_IN_CURRENT_TEST_VERSION] Physical file absent in current test database version.
- ElementSupprime.ES_NumEnr_Id [ABSENT_IN_CURRENT_TEST_VERSION] Physical file absent in current test database version.
- Equipe_Collaborateur.EC_IDEquipe_EC_IDCollaborateur [MODEL_ONLY] Column declared in model but not present in physical schema.
- Intervention_BddExterne.INX_IDBaseExtINX_IDIntervention [MODEL_ONLY] Column declared in model but not present in physical schema.
- Intervention_ChampLibre.ICL_IDIntervention_ICL_IDParametreM [MODEL_ONLY] Column declared in model but not present in physical schema.
- MailCat.MMC_CodeModMail_CodeCat [MODEL_ONLY] Column declared in model but not present in physical schema.
- MotClef.MOC_CodeMOC_Langue [MODEL_ONLY] Column declared in model but not present in physical schema.
- MotClef.MOC_MotMOC_Langue [MODEL_ONLY] Column declared in model but not present in physical schema.
- ParametreMetier.PA_CodePA_Valeur [MODEL_ONLY] Column declared in model but not present in physical schema.
- ParametreUtilisateur.PU_CodePU_IDUtilisateur [MODEL_ONLY] Column declared in model but not present in physical schema.
- Planning_RDV_Personne.PRP_PKID_PE_PKID [MODEL_ONLY] Column declared in model but not present in physical schema.
- Societe_Collaborateur.SC_IDCSoc_IDCol [MODEL_ONLY] Column declared in model but not present in physical schema.
- Societe_Collaborateur.SC_IDCSociete_SC_IDCollaborateur [MODEL_ONLY] Column declared in model but not present in physical schema.
+213
View File
@@ -0,0 +1,213 @@
Schema,Table,DiskName,ExpectedGroup,DirectAccess,ProtectedAccess,FinalAccess,FieldCount,HasRows,HfSqlErrorCode
Batik,AccesEtDroit,AccesEtDroit.fic,ProtectionStd,True,False,DIRECT,8,True,
Batik,Action,Action.fic,ProtectionStd,True,False,DIRECT,9,True,
Batik,Adresse,Adresse.fic,ProtectionStd,True,False,DIRECT,14,True,
Batik,BddExterne,BddExterne.fic,ProtectionStd,True,False,DIRECT,8,True,
Batik,CheckList,CheckList.fic,ProtectionStd,True,False,DIRECT,12,False,
Batik,CheckList_Collab,CheckList_Collab.fic,ProtectionStd,True,False,DIRECT,8,False,
Batik,Client,Client.fic,ProtectionStd,True,False,DIRECT,31,True,
Batik,Client_Adresse,Client_Adresse.fic,ProtectionStd,True,False,DIRECT,5,True,
Batik,Client_BddExterne,Client_BddExterne.fic,ProtectionStd,True,False,DIRECT,9,True,
Batik,Client_Lieu,Client_Lieu.fic,ProtectionStd,True,False,DIRECT,7,True,
Batik,Collaborateur,Collaborateur.fic,ProtectionStd,True,False,DIRECT,20,True,
Batik,CompteRendu,CompteRendu.fic,ProtectionStd,True,False,DIRECT,22,True,
Batik,Compteur,Compteur.fic,ProtectionStd,True,False,DIRECT,8,True,
Batik,Document,Document.fic,ProtectionStd,True,False,DIRECT,15,True,
Batik,DocumentNote,DocumentNote.fic,ProtectionStd,True,False,DIRECT,6,True,
Batik,Dossier,Dossier.fic,ProtectionStd,True,False,DIRECT,17,True,
Batik,Equipe,Equipe.fic,ProtectionStd,True,False,DIRECT,7,True,
Batik,Equipe_Collaborateur,Equipe_Collaborateur.fic,ProtectionStd,True,False,DIRECT,7,True,
Batik,EtatIntervention,EtatIntervention.fic,ProtectionStd,True,False,DIRECT,6,True,
Batik,Intervention,Intervention.fic,ProtectionStd,True,False,DIRECT,29,True,
Batik,Intervention_BddExterne,Intervention_BddExterne.fic,ProtectionStd,True,False,DIRECT,8,True,
Batik,Intervention_ChampLibre,Intervention_ChampLibre.fic,ProtectionStd,True,False,DIRECT,8,False,
Batik,Intervention_Collaborateur,Intervention_Collaborateur.fic,ProtectionStd,True,False,DIRECT,7,False,
Batik,Intervention_Personne,Intervention_Personne.fic,ProtectionStd,True,False,DIRECT,14,True,
Batik,Lieu,Lieu.fic,ProtectionStd,False,False,FAILED,0,False,70114
Batik,Lieu_Personne,Lieu_Personne.fic,ProtectionStd,True,False,DIRECT,15,True,
Batik,MailCat,MailCat.fic,ProtectionStd,True,False,DIRECT,3,True,
Batik,MailModele,MailModele.fic,ProtectionStd,True,False,DIRECT,11,True,
Batik,MessageExterne,MessageExterne.fic,ProtectionStd,True,False,DIRECT,17,True,
Batik,MessageInterne,MessageInterne.fic,ProtectionStd,True,False,DIRECT,7,True,
Batik,Mobile,Mobile.fic,ProtectionStd,True,False,DIRECT,3,False,
Batik,MobLog,MobLog.fic,ProtectionStd,True,False,DIRECT,4,True,
Batik,MobReception,MobReception.fic,ProtectionStd,True,False,DIRECT,8,False,
Batik,ModeleRapportMob,ModeleRapportMob.fic,ProtectionStd,False,False,FAILED,0,False,70114
Batik,MotClef,MotClef.fic,ProtectionStd,True,False,DIRECT,6,True,
Batik,ParametreApplication,ParametreApplication.fic,ProtectionStd,True,False,DIRECT,4,True,
Batik,ParametreMetier,ParametreMetier.fic,ProtectionStd,False,False,FAILED,0,False,70114
Batik,ParametreMetierEntete,ParametreMetierEntete.fic,ProtectionPlus,False,False,FAILED,0,False,70114
Batik,ParametreSysteme,ParametreSysteme.fic,ProtectionPlus,False,False,FAILED,0,False,70114
Batik,ParametreUtilisateur,ParametreUtilisateur.fic,ProtectionStd,True,False,DIRECT,4,True,
Batik,Personne,Personne.fic,ProtectionStd,True,False,DIRECT,22,True,
Batik,Planning_RDV,Planning_RDV.fic,ProtectionStd,True,False,DIRECT,35,True,
Batik,Planning_RDV_Personne,Planning_RDV_Personne.fic,ProtectionStd,True,False,DIRECT,10,True,
Batik,Societe,Societe.fic,ProtectionStd,True,False,DIRECT,16,True,
Batik,Societe_Collaborateur,Societe_Collaborateur.fic,ProtectionStd,True,False,DIRECT,7,True,
Batik,SuiviDesTemps,SuiviDesTemps.fic,ProtectionStd,True,False,DIRECT,20,True,
Batik,Telephone,Telephone.fic,ProtectionStd,True,False,DIRECT,4,False,
Batik,Utilisateur,Utilisateur.fic,ProtectionStd,True,False,DIRECT,19,True,
Omika,AccesEtDroit,AccesEtDroit.fic,ProtectionStd,True,False,DIRECT,10,True,
Omika,Action,Action.fic,ProtectionStd,True,False,DIRECT,10,True,
Omika,Adresse,Adresse.fic,ProtectionStd,True,False,DIRECT,16,True,
Omika,BddExterne,BddExterne.fic,ProtectionStd,True,False,DIRECT,8,True,
Omika,CategorieAcces,CategorieAcces.fic,ProtectionStd,True,False,DIRECT,11,True,
Omika,CheckList,CheckList.fic,ProtectionStd,True,False,DIRECT,12,False,
Omika,CheckList_Collab,CheckList_Collab.fic,ProtectionStd,True,False,DIRECT,8,False,
Omika,Client,Client.fic,ProtectionStd,True,False,DIRECT,37,True,
Omika,Client_Adresse,Client_Adresse.fic,ProtectionStd,True,False,DIRECT,8,True,
Omika,Client_BddExterne,Client_BddExterne.fic,ProtectionStd,True,False,DIRECT,9,True,
Omika,Client_Lieu,Client_Lieu.fic,ProtectionStd,True,False,DIRECT,7,True,
Omika,Collaborateur,Collaborateur.fic,ProtectionStd,True,False,DIRECT,23,True,
Omika,CompteRendu,CompteRendu.fic,ProtectionStd,True,False,DIRECT,24,True,
Omika,Compteur,Compteur.fic,ProtectionStd,True,False,DIRECT,8,True,
Omika,Doc_Aide,doc_aide.fic,ProtectionStd,True,False,DIRECT,9,True,
Omika,Document,Document.fic,ProtectionStd,True,False,DIRECT,17,False,
Omika,DocumentNote,DocumentNote.fic,ProtectionStd,True,False,DIRECT,6,False,
Omika,Dossier,Dossier.fic,ProtectionStd,True,False,DIRECT,20,True,
Omika,ElementModifie,elementmodifie.fic,ProtectionStd,True,False,DIRECT,5,True,
Omika,ElementSupprime,elementsupprime.fic,ProtectionStd,False,False,FAILED,0,False,70208
Omika,Equipe,Equipe.fic,ProtectionStd,True,False,DIRECT,7,True,
Omika,Equipe_Collaborateur,Equipe_Collaborateur.fic,ProtectionStd,True,False,DIRECT,7,True,
Omika,EtatIntervention,EtatIntervention.fic,ProtectionStd,True,False,DIRECT,7,True,
Omika,Etats,Etats.fic,ProtectionPlus,False,False,FAILED,0,False,70114
Omika,Intervention,Intervention.fic,ProtectionStd,True,False,DIRECT,32,True,
Omika,Intervention_BddExterne,Intervention_BddExterne.fic,ProtectionStd,True,False,DIRECT,8,True,
Omika,Intervention_ChampLibre,Intervention_ChampLibre.fic,ProtectionStd,True,False,DIRECT,8,False,
Omika,Intervention_Personne,Intervention_Personne.fic,ProtectionStd,True,False,DIRECT,15,True,
Omika,Lieu,Lieu.fic,ProtectionStd,True,False,DIRECT,21,True,
Omika,Lieu_Personne,Lieu_Personne.fic,ProtectionStd,True,False,DIRECT,17,True,
Omika,MailCat,MailCat.fic,ProtectionStd,True,False,DIRECT,3,True,
Omika,MailModele,MailModele.fic,ProtectionStd,True,False,DIRECT,13,True,
Omika,MessageExterne,MessageExterne.fic,ProtectionStd,True,False,DIRECT,18,True,
Omika,MessageInterne,MessageInterne.fic,ProtectionStd,True,False,DIRECT,7,True,
Omika,Mobile,Mobile.fic,ProtectionStd,True,False,DIRECT,3,False,
Omika,MobLog,MobLog.fic,ProtectionStd,True,False,DIRECT,4,False,
Omika,MobReception,MobReception.fic,ProtectionStd,True,False,DIRECT,8,True,
Omika,ModeleRapport,ModeleRapport.fic,ProtectionStd,True,False,DIRECT,9,True,
Omika,ModeleRapportMob,ModeleRapportMob.fic,ProtectionStd,False,False,FAILED,0,False,70114
Omika,MotClef,MotClef.fic,ProtectionStd,True,False,DIRECT,6,True,
Omika,ParametreApplication,ParametreApplication.fic,ProtectionStd,True,False,DIRECT,4,True,
Omika,ParametreMetier,ParametreMetier.fic,ProtectionStd,False,False,FAILED,0,False,70114
Omika,ParametreMetierEntete,ParametreMetierEntete.fic,ProtectionPlus,False,False,FAILED,0,False,70114
Omika,ParametreSysteme,ParametreSysteme.fic,ProtectionPlus,False,False,FAILED,0,False,70114
Omika,ParametreUtilisateur,ParametreUtilisateur.fic,ProtectionStd,True,False,DIRECT,4,True,
Omika,Personne,Personne.fic,ProtectionStd,True,False,DIRECT,25,True,
Omika,Planning_RDV,Planning_RDV.fic,ProtectionStd,True,False,DIRECT,39,True,
Omika,Planning_RDV_Personne,Planning_RDV_Personne.fic,ProtectionStd,True,False,DIRECT,10,True,
Omika,RDV_Collaborateur,RDV_Collaborateur.fic,ProtectionStd,True,False,DIRECT,13,True,
Omika,RDV_Contact,RDV_Contact.fic,ProtectionStd,True,False,DIRECT,4,True,
Omika,RDV_Planning,RDV_Planning.fic,ProtectionStd,True,False,DIRECT,22,True,
Omika,Role,Role.fic,ProtectionStd,True,False,DIRECT,7,True,
Omika,RubriquesDocument,RubriquesDocument.fic,ProtectionStd,True,False,DIRECT,13,True,
Omika,Societe,Societe.fic,ProtectionStd,True,False,DIRECT,18,True,
Omika,Societe_Collaborateur,Societe_Collaborateur.fic,ProtectionStd,True,False,DIRECT,7,True,
Omika,SuiviDesTemps,SuiviDesTemps.fic,ProtectionStd,True,False,DIRECT,25,True,
Omika,Telephone,Telephone.fic,ProtectionStd,True,False,DIRECT,4,False,
Omika,Utilisateur,Utilisateur.fic,ProtectionStd,True,False,DIRECT,20,True,
Batik,AccesEtDroit,AccesEtDroit.fic,ProtectionStd,True,False,DIRECT,8,True,
Batik,Action,Action.fic,ProtectionStd,True,False,DIRECT,9,True,
Batik,Adresse,Adresse.fic,ProtectionStd,True,False,DIRECT,14,True,
Batik,BddExterne,BddExterne.fic,ProtectionStd,True,False,DIRECT,8,True,
Batik,CheckList,CheckList.fic,ProtectionStd,True,False,DIRECT,12,False,
Batik,CheckList_Collab,CheckList_Collab.fic,ProtectionStd,True,False,DIRECT,8,False,
Batik,Client,Client.fic,ProtectionStd,True,False,DIRECT,31,True,
Batik,Client_Adresse,Client_Adresse.fic,ProtectionStd,True,False,DIRECT,5,True,
Batik,Client_BddExterne,Client_BddExterne.fic,ProtectionStd,True,False,DIRECT,9,True,
Batik,Client_Lieu,Client_Lieu.fic,ProtectionStd,True,False,DIRECT,7,True,
Batik,Collaborateur,Collaborateur.fic,ProtectionStd,True,False,DIRECT,20,True,
Batik,CompteRendu,CompteRendu.fic,ProtectionStd,True,False,DIRECT,22,True,
Batik,Compteur,Compteur.fic,ProtectionStd,True,False,DIRECT,8,True,
Batik,Document,Document.fic,ProtectionStd,True,False,DIRECT,15,True,
Batik,DocumentNote,DocumentNote.fic,ProtectionStd,True,False,DIRECT,6,True,
Batik,Dossier,Dossier.fic,ProtectionStd,True,False,DIRECT,17,True,
Batik,Equipe,Equipe.fic,ProtectionStd,True,False,DIRECT,7,True,
Batik,Equipe_Collaborateur,Equipe_Collaborateur.fic,ProtectionStd,True,False,DIRECT,7,True,
Batik,EtatIntervention,EtatIntervention.fic,ProtectionStd,True,False,DIRECT,6,True,
Batik,Intervention,Intervention.fic,ProtectionStd,True,False,DIRECT,29,True,
Batik,Intervention_BddExterne,Intervention_BddExterne.fic,ProtectionStd,True,False,DIRECT,8,True,
Batik,Intervention_ChampLibre,Intervention_ChampLibre.fic,ProtectionStd,True,False,DIRECT,8,False,
Batik,Intervention_Collaborateur,Intervention_Collaborateur.fic,ProtectionStd,True,False,DIRECT,7,False,
Batik,Intervention_Personne,Intervention_Personne.fic,ProtectionStd,True,False,DIRECT,14,True,
Batik,Lieu,Lieu.fic,ProtectionStd,False,True,PROTECTION_STD,19,True,70114
Batik,Lieu_Personne,Lieu_Personne.fic,ProtectionStd,True,False,DIRECT,15,True,
Batik,MailCat,MailCat.fic,ProtectionStd,True,False,DIRECT,3,True,
Batik,MailModele,MailModele.fic,ProtectionStd,True,False,DIRECT,11,True,
Batik,MessageExterne,MessageExterne.fic,ProtectionStd,True,False,DIRECT,17,True,
Batik,MessageInterne,MessageInterne.fic,ProtectionStd,True,False,DIRECT,7,True,
Batik,Mobile,Mobile.fic,ProtectionStd,True,False,DIRECT,3,False,
Batik,MobLog,MobLog.fic,ProtectionStd,True,False,DIRECT,4,True,
Batik,MobReception,MobReception.fic,ProtectionStd,True,False,DIRECT,8,False,
Batik,ModeleRapportMob,ModeleRapportMob.fic,ProtectionStd,False,True,PROTECTION_STD,4,False,70114
Batik,MotClef,MotClef.fic,ProtectionStd,True,False,DIRECT,6,True,
Batik,ParametreApplication,ParametreApplication.fic,ProtectionStd,True,False,DIRECT,4,True,
Batik,ParametreMetier,ParametreMetier.fic,ProtectionStd,False,True,PROTECTION_STD,12,True,70114
Batik,ParametreMetierEntete,ParametreMetierEntete.fic,ProtectionPlus,False,True,PROTECTION_PLUS,8,True,70114
Batik,ParametreSysteme,ParametreSysteme.fic,ProtectionPlus,False,True,PROTECTION_PLUS,4,True,70114
Batik,ParametreUtilisateur,ParametreUtilisateur.fic,ProtectionStd,True,False,DIRECT,4,True,
Batik,Personne,Personne.fic,ProtectionStd,True,False,DIRECT,22,True,
Batik,Planning_RDV,Planning_RDV.fic,ProtectionStd,True,False,DIRECT,35,True,
Batik,Planning_RDV_Personne,Planning_RDV_Personne.fic,ProtectionStd,True,False,DIRECT,10,True,
Batik,Societe,Societe.fic,ProtectionStd,True,False,DIRECT,16,True,
Batik,Societe_Collaborateur,Societe_Collaborateur.fic,ProtectionStd,True,False,DIRECT,7,True,
Batik,SuiviDesTemps,SuiviDesTemps.fic,ProtectionStd,True,False,DIRECT,20,True,
Batik,Telephone,Telephone.fic,ProtectionStd,True,False,DIRECT,4,False,
Batik,Utilisateur,Utilisateur.fic,ProtectionStd,True,False,DIRECT,19,True,
Omika,AccesEtDroit,AccesEtDroit.fic,ProtectionStd,True,False,DIRECT,10,True,
Omika,Action,Action.fic,ProtectionStd,True,False,DIRECT,10,True,
Omika,Adresse,Adresse.fic,ProtectionStd,True,False,DIRECT,16,True,
Omika,BddExterne,BddExterne.fic,ProtectionStd,True,False,DIRECT,8,True,
Omika,CategorieAcces,CategorieAcces.fic,ProtectionStd,True,False,DIRECT,11,True,
Omika,CheckList,CheckList.fic,ProtectionStd,True,False,DIRECT,12,False,
Omika,CheckList_Collab,CheckList_Collab.fic,ProtectionStd,True,False,DIRECT,8,False,
Omika,Client,Client.fic,ProtectionStd,True,False,DIRECT,37,True,
Omika,Client_Adresse,Client_Adresse.fic,ProtectionStd,True,False,DIRECT,8,True,
Omika,Client_BddExterne,Client_BddExterne.fic,ProtectionStd,True,False,DIRECT,9,True,
Omika,Client_Lieu,Client_Lieu.fic,ProtectionStd,True,False,DIRECT,7,True,
Omika,Collaborateur,Collaborateur.fic,ProtectionStd,True,False,DIRECT,23,True,
Omika,CompteRendu,CompteRendu.fic,ProtectionStd,True,False,DIRECT,24,True,
Omika,Compteur,Compteur.fic,ProtectionStd,True,False,DIRECT,8,True,
Omika,Doc_Aide,doc_aide.fic,ProtectionStd,True,False,DIRECT,9,True,
Omika,Document,Document.fic,ProtectionStd,True,False,DIRECT,17,False,
Omika,DocumentNote,DocumentNote.fic,ProtectionStd,True,False,DIRECT,6,False,
Omika,Dossier,Dossier.fic,ProtectionStd,True,False,DIRECT,20,True,
Omika,ElementModifie,elementmodifie.fic,ProtectionStd,True,False,DIRECT,5,True,
Omika,ElementSupprime,elementsupprime.fic,ProtectionStd,False,False,FAILED,0,False,70208
Omika,Equipe,Equipe.fic,ProtectionStd,True,False,DIRECT,7,True,
Omika,Equipe_Collaborateur,Equipe_Collaborateur.fic,ProtectionStd,True,False,DIRECT,7,True,
Omika,EtatIntervention,EtatIntervention.fic,ProtectionStd,True,False,DIRECT,7,True,
Omika,Etats,Etats.fic,ProtectionPlus,False,True,PROTECTION_PLUS,5,True,70114
Omika,Intervention,Intervention.fic,ProtectionStd,True,False,DIRECT,32,True,
Omika,Intervention_BddExterne,Intervention_BddExterne.fic,ProtectionStd,True,False,DIRECT,8,True,
Omika,Intervention_ChampLibre,Intervention_ChampLibre.fic,ProtectionStd,True,False,DIRECT,8,False,
Omika,Intervention_Personne,Intervention_Personne.fic,ProtectionStd,True,False,DIRECT,15,True,
Omika,Lieu,Lieu.fic,ProtectionStd,True,False,DIRECT,21,True,
Omika,Lieu_Personne,Lieu_Personne.fic,ProtectionStd,True,False,DIRECT,17,True,
Omika,MailCat,MailCat.fic,ProtectionStd,True,False,DIRECT,3,True,
Omika,MailModele,MailModele.fic,ProtectionStd,True,False,DIRECT,13,True,
Omika,MessageExterne,MessageExterne.fic,ProtectionStd,True,False,DIRECT,18,True,
Omika,MessageInterne,MessageInterne.fic,ProtectionStd,True,False,DIRECT,7,True,
Omika,Mobile,Mobile.fic,ProtectionStd,True,False,DIRECT,3,False,
Omika,MobLog,MobLog.fic,ProtectionStd,True,False,DIRECT,4,False,
Omika,MobReception,MobReception.fic,ProtectionStd,True,False,DIRECT,8,True,
Omika,ModeleRapport,ModeleRapport.fic,ProtectionStd,True,False,DIRECT,9,True,
Omika,ModeleRapportMob,ModeleRapportMob.fic,ProtectionStd,False,True,PROTECTION_STD,4,False,70114
Omika,MotClef,MotClef.fic,ProtectionStd,True,False,DIRECT,6,True,
Omika,ParametreApplication,ParametreApplication.fic,ProtectionStd,True,False,DIRECT,4,True,
Omika,ParametreMetier,ParametreMetier.fic,ProtectionStd,False,True,PROTECTION_STD,14,True,70114
Omika,ParametreMetierEntete,ParametreMetierEntete.fic,ProtectionPlus,False,True,PROTECTION_PLUS,10,True,70114
Omika,ParametreSysteme,ParametreSysteme.fic,ProtectionPlus,False,True,PROTECTION_PLUS,4,True,70114
Omika,ParametreUtilisateur,ParametreUtilisateur.fic,ProtectionStd,True,False,DIRECT,4,True,
Omika,Personne,Personne.fic,ProtectionStd,True,False,DIRECT,25,True,
Omika,Planning_RDV,Planning_RDV.fic,ProtectionStd,True,False,DIRECT,39,True,
Omika,Planning_RDV_Personne,Planning_RDV_Personne.fic,ProtectionStd,True,False,DIRECT,10,True,
Omika,RDV_Collaborateur,RDV_Collaborateur.fic,ProtectionStd,True,False,DIRECT,13,True,
Omika,RDV_Contact,RDV_Contact.fic,ProtectionStd,True,False,DIRECT,4,True,
Omika,RDV_Planning,RDV_Planning.fic,ProtectionStd,True,False,DIRECT,22,True,
Omika,Role,Role.fic,ProtectionStd,True,False,DIRECT,7,True,
Omika,RubriquesDocument,RubriquesDocument.fic,ProtectionStd,True,False,DIRECT,13,True,
Omika,Societe,Societe.fic,ProtectionStd,True,False,DIRECT,18,True,
Omika,Societe_Collaborateur,Societe_Collaborateur.fic,ProtectionStd,True,False,DIRECT,7,True,
Omika,SuiviDesTemps,SuiviDesTemps.fic,ProtectionStd,True,False,DIRECT,25,True,
Omika,Telephone,Telephone.fic,ProtectionStd,True,False,DIRECT,4,False,
Omika,Utilisateur,Utilisateur.fic,ProtectionStd,True,False,DIRECT,20,True,
1 Schema Table DiskName ExpectedGroup DirectAccess ProtectedAccess FinalAccess FieldCount HasRows HfSqlErrorCode
2 Batik AccesEtDroit AccesEtDroit.fic ProtectionStd True False DIRECT 8 True
3 Batik Action Action.fic ProtectionStd True False DIRECT 9 True
4 Batik Adresse Adresse.fic ProtectionStd True False DIRECT 14 True
5 Batik BddExterne BddExterne.fic ProtectionStd True False DIRECT 8 True
6 Batik CheckList CheckList.fic ProtectionStd True False DIRECT 12 False
7 Batik CheckList_Collab CheckList_Collab.fic ProtectionStd True False DIRECT 8 False
8 Batik Client Client.fic ProtectionStd True False DIRECT 31 True
9 Batik Client_Adresse Client_Adresse.fic ProtectionStd True False DIRECT 5 True
10 Batik Client_BddExterne Client_BddExterne.fic ProtectionStd True False DIRECT 9 True
11 Batik Client_Lieu Client_Lieu.fic ProtectionStd True False DIRECT 7 True
12 Batik Collaborateur Collaborateur.fic ProtectionStd True False DIRECT 20 True
13 Batik CompteRendu CompteRendu.fic ProtectionStd True False DIRECT 22 True
14 Batik Compteur Compteur.fic ProtectionStd True False DIRECT 8 True
15 Batik Document Document.fic ProtectionStd True False DIRECT 15 True
16 Batik DocumentNote DocumentNote.fic ProtectionStd True False DIRECT 6 True
17 Batik Dossier Dossier.fic ProtectionStd True False DIRECT 17 True
18 Batik Equipe Equipe.fic ProtectionStd True False DIRECT 7 True
19 Batik Equipe_Collaborateur Equipe_Collaborateur.fic ProtectionStd True False DIRECT 7 True
20 Batik EtatIntervention EtatIntervention.fic ProtectionStd True False DIRECT 6 True
21 Batik Intervention Intervention.fic ProtectionStd True False DIRECT 29 True
22 Batik Intervention_BddExterne Intervention_BddExterne.fic ProtectionStd True False DIRECT 8 True
23 Batik Intervention_ChampLibre Intervention_ChampLibre.fic ProtectionStd True False DIRECT 8 False
24 Batik Intervention_Collaborateur Intervention_Collaborateur.fic ProtectionStd True False DIRECT 7 False
25 Batik Intervention_Personne Intervention_Personne.fic ProtectionStd True False DIRECT 14 True
26 Batik Lieu Lieu.fic ProtectionStd False False FAILED 0 False 70114
27 Batik Lieu_Personne Lieu_Personne.fic ProtectionStd True False DIRECT 15 True
28 Batik MailCat MailCat.fic ProtectionStd True False DIRECT 3 True
29 Batik MailModele MailModele.fic ProtectionStd True False DIRECT 11 True
30 Batik MessageExterne MessageExterne.fic ProtectionStd True False DIRECT 17 True
31 Batik MessageInterne MessageInterne.fic ProtectionStd True False DIRECT 7 True
32 Batik Mobile Mobile.fic ProtectionStd True False DIRECT 3 False
33 Batik MobLog MobLog.fic ProtectionStd True False DIRECT 4 True
34 Batik MobReception MobReception.fic ProtectionStd True False DIRECT 8 False
35 Batik ModeleRapportMob ModeleRapportMob.fic ProtectionStd False False FAILED 0 False 70114
36 Batik MotClef MotClef.fic ProtectionStd True False DIRECT 6 True
37 Batik ParametreApplication ParametreApplication.fic ProtectionStd True False DIRECT 4 True
38 Batik ParametreMetier ParametreMetier.fic ProtectionStd False False FAILED 0 False 70114
39 Batik ParametreMetierEntete ParametreMetierEntete.fic ProtectionPlus False False FAILED 0 False 70114
40 Batik ParametreSysteme ParametreSysteme.fic ProtectionPlus False False FAILED 0 False 70114
41 Batik ParametreUtilisateur ParametreUtilisateur.fic ProtectionStd True False DIRECT 4 True
42 Batik Personne Personne.fic ProtectionStd True False DIRECT 22 True
43 Batik Planning_RDV Planning_RDV.fic ProtectionStd True False DIRECT 35 True
44 Batik Planning_RDV_Personne Planning_RDV_Personne.fic ProtectionStd True False DIRECT 10 True
45 Batik Societe Societe.fic ProtectionStd True False DIRECT 16 True
46 Batik Societe_Collaborateur Societe_Collaborateur.fic ProtectionStd True False DIRECT 7 True
47 Batik SuiviDesTemps SuiviDesTemps.fic ProtectionStd True False DIRECT 20 True
48 Batik Telephone Telephone.fic ProtectionStd True False DIRECT 4 False
49 Batik Utilisateur Utilisateur.fic ProtectionStd True False DIRECT 19 True
50 Omika AccesEtDroit AccesEtDroit.fic ProtectionStd True False DIRECT 10 True
51 Omika Action Action.fic ProtectionStd True False DIRECT 10 True
52 Omika Adresse Adresse.fic ProtectionStd True False DIRECT 16 True
53 Omika BddExterne BddExterne.fic ProtectionStd True False DIRECT 8 True
54 Omika CategorieAcces CategorieAcces.fic ProtectionStd True False DIRECT 11 True
55 Omika CheckList CheckList.fic ProtectionStd True False DIRECT 12 False
56 Omika CheckList_Collab CheckList_Collab.fic ProtectionStd True False DIRECT 8 False
57 Omika Client Client.fic ProtectionStd True False DIRECT 37 True
58 Omika Client_Adresse Client_Adresse.fic ProtectionStd True False DIRECT 8 True
59 Omika Client_BddExterne Client_BddExterne.fic ProtectionStd True False DIRECT 9 True
60 Omika Client_Lieu Client_Lieu.fic ProtectionStd True False DIRECT 7 True
61 Omika Collaborateur Collaborateur.fic ProtectionStd True False DIRECT 23 True
62 Omika CompteRendu CompteRendu.fic ProtectionStd True False DIRECT 24 True
63 Omika Compteur Compteur.fic ProtectionStd True False DIRECT 8 True
64 Omika Doc_Aide doc_aide.fic ProtectionStd True False DIRECT 9 True
65 Omika Document Document.fic ProtectionStd True False DIRECT 17 False
66 Omika DocumentNote DocumentNote.fic ProtectionStd True False DIRECT 6 False
67 Omika Dossier Dossier.fic ProtectionStd True False DIRECT 20 True
68 Omika ElementModifie elementmodifie.fic ProtectionStd True False DIRECT 5 True
69 Omika ElementSupprime elementsupprime.fic ProtectionStd False False FAILED 0 False 70208
70 Omika Equipe Equipe.fic ProtectionStd True False DIRECT 7 True
71 Omika Equipe_Collaborateur Equipe_Collaborateur.fic ProtectionStd True False DIRECT 7 True
72 Omika EtatIntervention EtatIntervention.fic ProtectionStd True False DIRECT 7 True
73 Omika Etats Etats.fic ProtectionPlus False False FAILED 0 False 70114
74 Omika Intervention Intervention.fic ProtectionStd True False DIRECT 32 True
75 Omika Intervention_BddExterne Intervention_BddExterne.fic ProtectionStd True False DIRECT 8 True
76 Omika Intervention_ChampLibre Intervention_ChampLibre.fic ProtectionStd True False DIRECT 8 False
77 Omika Intervention_Personne Intervention_Personne.fic ProtectionStd True False DIRECT 15 True
78 Omika Lieu Lieu.fic ProtectionStd True False DIRECT 21 True
79 Omika Lieu_Personne Lieu_Personne.fic ProtectionStd True False DIRECT 17 True
80 Omika MailCat MailCat.fic ProtectionStd True False DIRECT 3 True
81 Omika MailModele MailModele.fic ProtectionStd True False DIRECT 13 True
82 Omika MessageExterne MessageExterne.fic ProtectionStd True False DIRECT 18 True
83 Omika MessageInterne MessageInterne.fic ProtectionStd True False DIRECT 7 True
84 Omika Mobile Mobile.fic ProtectionStd True False DIRECT 3 False
85 Omika MobLog MobLog.fic ProtectionStd True False DIRECT 4 False
86 Omika MobReception MobReception.fic ProtectionStd True False DIRECT 8 True
87 Omika ModeleRapport ModeleRapport.fic ProtectionStd True False DIRECT 9 True
88 Omika ModeleRapportMob ModeleRapportMob.fic ProtectionStd False False FAILED 0 False 70114
89 Omika MotClef MotClef.fic ProtectionStd True False DIRECT 6 True
90 Omika ParametreApplication ParametreApplication.fic ProtectionStd True False DIRECT 4 True
91 Omika ParametreMetier ParametreMetier.fic ProtectionStd False False FAILED 0 False 70114
92 Omika ParametreMetierEntete ParametreMetierEntete.fic ProtectionPlus False False FAILED 0 False 70114
93 Omika ParametreSysteme ParametreSysteme.fic ProtectionPlus False False FAILED 0 False 70114
94 Omika ParametreUtilisateur ParametreUtilisateur.fic ProtectionStd True False DIRECT 4 True
95 Omika Personne Personne.fic ProtectionStd True False DIRECT 25 True
96 Omika Planning_RDV Planning_RDV.fic ProtectionStd True False DIRECT 39 True
97 Omika Planning_RDV_Personne Planning_RDV_Personne.fic ProtectionStd True False DIRECT 10 True
98 Omika RDV_Collaborateur RDV_Collaborateur.fic ProtectionStd True False DIRECT 13 True
99 Omika RDV_Contact RDV_Contact.fic ProtectionStd True False DIRECT 4 True
100 Omika RDV_Planning RDV_Planning.fic ProtectionStd True False DIRECT 22 True
101 Omika Role Role.fic ProtectionStd True False DIRECT 7 True
102 Omika RubriquesDocument RubriquesDocument.fic ProtectionStd True False DIRECT 13 True
103 Omika Societe Societe.fic ProtectionStd True False DIRECT 18 True
104 Omika Societe_Collaborateur Societe_Collaborateur.fic ProtectionStd True False DIRECT 7 True
105 Omika SuiviDesTemps SuiviDesTemps.fic ProtectionStd True False DIRECT 25 True
106 Omika Telephone Telephone.fic ProtectionStd True False DIRECT 4 False
107 Omika Utilisateur Utilisateur.fic ProtectionStd True False DIRECT 20 True
108 Batik AccesEtDroit AccesEtDroit.fic ProtectionStd True False DIRECT 8 True
109 Batik Action Action.fic ProtectionStd True False DIRECT 9 True
110 Batik Adresse Adresse.fic ProtectionStd True False DIRECT 14 True
111 Batik BddExterne BddExterne.fic ProtectionStd True False DIRECT 8 True
112 Batik CheckList CheckList.fic ProtectionStd True False DIRECT 12 False
113 Batik CheckList_Collab CheckList_Collab.fic ProtectionStd True False DIRECT 8 False
114 Batik Client Client.fic ProtectionStd True False DIRECT 31 True
115 Batik Client_Adresse Client_Adresse.fic ProtectionStd True False DIRECT 5 True
116 Batik Client_BddExterne Client_BddExterne.fic ProtectionStd True False DIRECT 9 True
117 Batik Client_Lieu Client_Lieu.fic ProtectionStd True False DIRECT 7 True
118 Batik Collaborateur Collaborateur.fic ProtectionStd True False DIRECT 20 True
119 Batik CompteRendu CompteRendu.fic ProtectionStd True False DIRECT 22 True
120 Batik Compteur Compteur.fic ProtectionStd True False DIRECT 8 True
121 Batik Document Document.fic ProtectionStd True False DIRECT 15 True
122 Batik DocumentNote DocumentNote.fic ProtectionStd True False DIRECT 6 True
123 Batik Dossier Dossier.fic ProtectionStd True False DIRECT 17 True
124 Batik Equipe Equipe.fic ProtectionStd True False DIRECT 7 True
125 Batik Equipe_Collaborateur Equipe_Collaborateur.fic ProtectionStd True False DIRECT 7 True
126 Batik EtatIntervention EtatIntervention.fic ProtectionStd True False DIRECT 6 True
127 Batik Intervention Intervention.fic ProtectionStd True False DIRECT 29 True
128 Batik Intervention_BddExterne Intervention_BddExterne.fic ProtectionStd True False DIRECT 8 True
129 Batik Intervention_ChampLibre Intervention_ChampLibre.fic ProtectionStd True False DIRECT 8 False
130 Batik Intervention_Collaborateur Intervention_Collaborateur.fic ProtectionStd True False DIRECT 7 False
131 Batik Intervention_Personne Intervention_Personne.fic ProtectionStd True False DIRECT 14 True
132 Batik Lieu Lieu.fic ProtectionStd False True PROTECTION_STD 19 True 70114
133 Batik Lieu_Personne Lieu_Personne.fic ProtectionStd True False DIRECT 15 True
134 Batik MailCat MailCat.fic ProtectionStd True False DIRECT 3 True
135 Batik MailModele MailModele.fic ProtectionStd True False DIRECT 11 True
136 Batik MessageExterne MessageExterne.fic ProtectionStd True False DIRECT 17 True
137 Batik MessageInterne MessageInterne.fic ProtectionStd True False DIRECT 7 True
138 Batik Mobile Mobile.fic ProtectionStd True False DIRECT 3 False
139 Batik MobLog MobLog.fic ProtectionStd True False DIRECT 4 True
140 Batik MobReception MobReception.fic ProtectionStd True False DIRECT 8 False
141 Batik ModeleRapportMob ModeleRapportMob.fic ProtectionStd False True PROTECTION_STD 4 False 70114
142 Batik MotClef MotClef.fic ProtectionStd True False DIRECT 6 True
143 Batik ParametreApplication ParametreApplication.fic ProtectionStd True False DIRECT 4 True
144 Batik ParametreMetier ParametreMetier.fic ProtectionStd False True PROTECTION_STD 12 True 70114
145 Batik ParametreMetierEntete ParametreMetierEntete.fic ProtectionPlus False True PROTECTION_PLUS 8 True 70114
146 Batik ParametreSysteme ParametreSysteme.fic ProtectionPlus False True PROTECTION_PLUS 4 True 70114
147 Batik ParametreUtilisateur ParametreUtilisateur.fic ProtectionStd True False DIRECT 4 True
148 Batik Personne Personne.fic ProtectionStd True False DIRECT 22 True
149 Batik Planning_RDV Planning_RDV.fic ProtectionStd True False DIRECT 35 True
150 Batik Planning_RDV_Personne Planning_RDV_Personne.fic ProtectionStd True False DIRECT 10 True
151 Batik Societe Societe.fic ProtectionStd True False DIRECT 16 True
152 Batik Societe_Collaborateur Societe_Collaborateur.fic ProtectionStd True False DIRECT 7 True
153 Batik SuiviDesTemps SuiviDesTemps.fic ProtectionStd True False DIRECT 20 True
154 Batik Telephone Telephone.fic ProtectionStd True False DIRECT 4 False
155 Batik Utilisateur Utilisateur.fic ProtectionStd True False DIRECT 19 True
156 Omika AccesEtDroit AccesEtDroit.fic ProtectionStd True False DIRECT 10 True
157 Omika Action Action.fic ProtectionStd True False DIRECT 10 True
158 Omika Adresse Adresse.fic ProtectionStd True False DIRECT 16 True
159 Omika BddExterne BddExterne.fic ProtectionStd True False DIRECT 8 True
160 Omika CategorieAcces CategorieAcces.fic ProtectionStd True False DIRECT 11 True
161 Omika CheckList CheckList.fic ProtectionStd True False DIRECT 12 False
162 Omika CheckList_Collab CheckList_Collab.fic ProtectionStd True False DIRECT 8 False
163 Omika Client Client.fic ProtectionStd True False DIRECT 37 True
164 Omika Client_Adresse Client_Adresse.fic ProtectionStd True False DIRECT 8 True
165 Omika Client_BddExterne Client_BddExterne.fic ProtectionStd True False DIRECT 9 True
166 Omika Client_Lieu Client_Lieu.fic ProtectionStd True False DIRECT 7 True
167 Omika Collaborateur Collaborateur.fic ProtectionStd True False DIRECT 23 True
168 Omika CompteRendu CompteRendu.fic ProtectionStd True False DIRECT 24 True
169 Omika Compteur Compteur.fic ProtectionStd True False DIRECT 8 True
170 Omika Doc_Aide doc_aide.fic ProtectionStd True False DIRECT 9 True
171 Omika Document Document.fic ProtectionStd True False DIRECT 17 False
172 Omika DocumentNote DocumentNote.fic ProtectionStd True False DIRECT 6 False
173 Omika Dossier Dossier.fic ProtectionStd True False DIRECT 20 True
174 Omika ElementModifie elementmodifie.fic ProtectionStd True False DIRECT 5 True
175 Omika ElementSupprime elementsupprime.fic ProtectionStd False False FAILED 0 False 70208
176 Omika Equipe Equipe.fic ProtectionStd True False DIRECT 7 True
177 Omika Equipe_Collaborateur Equipe_Collaborateur.fic ProtectionStd True False DIRECT 7 True
178 Omika EtatIntervention EtatIntervention.fic ProtectionStd True False DIRECT 7 True
179 Omika Etats Etats.fic ProtectionPlus False True PROTECTION_PLUS 5 True 70114
180 Omika Intervention Intervention.fic ProtectionStd True False DIRECT 32 True
181 Omika Intervention_BddExterne Intervention_BddExterne.fic ProtectionStd True False DIRECT 8 True
182 Omika Intervention_ChampLibre Intervention_ChampLibre.fic ProtectionStd True False DIRECT 8 False
183 Omika Intervention_Personne Intervention_Personne.fic ProtectionStd True False DIRECT 15 True
184 Omika Lieu Lieu.fic ProtectionStd True False DIRECT 21 True
185 Omika Lieu_Personne Lieu_Personne.fic ProtectionStd True False DIRECT 17 True
186 Omika MailCat MailCat.fic ProtectionStd True False DIRECT 3 True
187 Omika MailModele MailModele.fic ProtectionStd True False DIRECT 13 True
188 Omika MessageExterne MessageExterne.fic ProtectionStd True False DIRECT 18 True
189 Omika MessageInterne MessageInterne.fic ProtectionStd True False DIRECT 7 True
190 Omika Mobile Mobile.fic ProtectionStd True False DIRECT 3 False
191 Omika MobLog MobLog.fic ProtectionStd True False DIRECT 4 False
192 Omika MobReception MobReception.fic ProtectionStd True False DIRECT 8 True
193 Omika ModeleRapport ModeleRapport.fic ProtectionStd True False DIRECT 9 True
194 Omika ModeleRapportMob ModeleRapportMob.fic ProtectionStd False True PROTECTION_STD 4 False 70114
195 Omika MotClef MotClef.fic ProtectionStd True False DIRECT 6 True
196 Omika ParametreApplication ParametreApplication.fic ProtectionStd True False DIRECT 4 True
197 Omika ParametreMetier ParametreMetier.fic ProtectionStd False True PROTECTION_STD 14 True 70114
198 Omika ParametreMetierEntete ParametreMetierEntete.fic ProtectionPlus False True PROTECTION_PLUS 10 True 70114
199 Omika ParametreSysteme ParametreSysteme.fic ProtectionPlus False True PROTECTION_PLUS 4 True 70114
200 Omika ParametreUtilisateur ParametreUtilisateur.fic ProtectionStd True False DIRECT 4 True
201 Omika Personne Personne.fic ProtectionStd True False DIRECT 25 True
202 Omika Planning_RDV Planning_RDV.fic ProtectionStd True False DIRECT 39 True
203 Omika Planning_RDV_Personne Planning_RDV_Personne.fic ProtectionStd True False DIRECT 10 True
204 Omika RDV_Collaborateur RDV_Collaborateur.fic ProtectionStd True False DIRECT 13 True
205 Omika RDV_Contact RDV_Contact.fic ProtectionStd True False DIRECT 4 True
206 Omika RDV_Planning RDV_Planning.fic ProtectionStd True False DIRECT 22 True
207 Omika Role Role.fic ProtectionStd True False DIRECT 7 True
208 Omika RubriquesDocument RubriquesDocument.fic ProtectionStd True False DIRECT 13 True
209 Omika Societe Societe.fic ProtectionStd True False DIRECT 18 True
210 Omika Societe_Collaborateur Societe_Collaborateur.fic ProtectionStd True False DIRECT 7 True
211 Omika SuiviDesTemps SuiviDesTemps.fic ProtectionStd True False DIRECT 25 True
212 Omika Telephone Telephone.fic ProtectionStd True False DIRECT 4 False
213 Omika Utilisateur Utilisateur.fic ProtectionStd True False DIRECT 20 True
+125
View File
@@ -0,0 +1,125 @@
# HFSQL OLE DB Open All — Diagnostic
## Batik
### Résumé
- Tables: 48
- Direct: 43
- ProtectionStd: 0
- ProtectionPlus: 0
- Failed: 5
- ParametreMetier: FAILED
- ParametreMetierEntete: FAILED
- ParametreSysteme: FAILED
- Strategy: WILDCARD_PLUS_OVERRIDES
- ProtectionStd OLE DB: UNRESOLVED
- ProtectionPlus OLE DB: UNRESOLVED
- NegativeControl: NOT_RUN
### Canaris
- ParametreMetier: FAILED
- ParametreMetierEntete: FAILED
- ParametreSysteme: FAILED
### Échecs
- Lieu (Expected=ProtectionStd, HfSql=70114)
- ModeleRapportMob (Expected=ProtectionStd, HfSql=70114)
- ParametreMetier (Expected=ProtectionStd, HfSql=70114)
- ParametreMetierEntete (Expected=ProtectionPlus, HfSql=70114)
- ParametreSysteme (Expected=ProtectionPlus, HfSql=70114)
## Omika
### Résumé
- Tables: 58
- Direct: 52
- ProtectionStd: 0
- ProtectionPlus: 0
- Failed: 6
- ParametreMetier: FAILED
- ParametreMetierEntete: FAILED
- ParametreSysteme: FAILED
- Strategy: WILDCARD_PLUS_OVERRIDES
- ProtectionStd OLE DB: UNRESOLVED
- ProtectionPlus OLE DB: UNRESOLVED
- NegativeControl: NOT_RUN
### Canaris
- ParametreMetier: FAILED
- ParametreMetierEntete: FAILED
- ParametreSysteme: FAILED
### Échecs
- ElementSupprime (Expected=ProtectionStd, HfSql=70208)
- Etats (Expected=ProtectionPlus, HfSql=70114)
- ModeleRapportMob (Expected=ProtectionStd, HfSql=70114)
- ParametreMetier (Expected=ProtectionStd, HfSql=70114)
- ParametreMetierEntete (Expected=ProtectionPlus, HfSql=70114)
- ParametreSysteme (Expected=ProtectionPlus, HfSql=70114)
## Batik
### Résumé
- Tables: 48
- Direct: 43
- ProtectionStd: 3
- ProtectionPlus: 2
- Failed: 0
- ParametreMetier: PROTECTION_STD
- ParametreMetierEntete: PROTECTION_PLUS
- ParametreSysteme: PROTECTION_PLUS
- Strategy: FAILURES_ONLY_EXPLICIT_MAP
- ProtectionStd OLE DB: VERIFIED_FROM_DATABASE
- ProtectionPlus OLE DB: VERIFIED_FROM_DATABASE
- NegativeControl: PASS
### Canaris
- ParametreMetier: PROTECTION_STD
- ParametreMetierEntete: PROTECTION_PLUS
- ParametreSysteme: PROTECTION_PLUS
### Échecs
## Omika
### Résumé
- Tables: 58
- Direct: 52
- ProtectionStd: 2
- ProtectionPlus: 3
- Failed: 1
- ParametreMetier: PROTECTION_STD
- ParametreMetierEntete: PROTECTION_PLUS
- ParametreSysteme: PROTECTION_PLUS
- Strategy: FAILURES_ONLY_EXPLICIT_MAP
- ProtectionStd OLE DB: VERIFIED_FROM_DATABASE
- ProtectionPlus OLE DB: VERIFIED_FROM_DATABASE
- NegativeControl: PASS
### Canaris
- ParametreMetier: PROTECTION_STD
- ParametreMetierEntete: PROTECTION_PLUS
- ParametreSysteme: PROTECTION_PLUS
### Échecs
- ElementSupprime (Expected=ProtectionStd, HfSql=70208)
+129
View File
@@ -0,0 +1,129 @@
# Détecteur de schéma HFSQL (DatabaseSchemaDetector)
## Stratégie générale
Le détecteur détermine la version réelle du schéma de base (Batik ou Omika) à
partir de la **structure physique** exposée par HFSQL OLE DB, et uniquement à
partir de celle-ci.
La décision ne repose **jamais** sur :
- le nom de la base (`BATIK_TEST`, `OMIKA_TEST`, ou tout autre nom client) ;
- le nom du serveur ;
- la présence d'un mot de passe ;
- le nombre de tables ;
- OpenSchema (`GetSchema`).
Chaque canari est testé par une requête structurelle réelle et contrôlée :
```sql
SELECT TOP 0 <colonne> FROM <table>
```
Le **succès / échec structurel** de la requête indique la présence ou l'absence
de la colonne. Aucune donnée métier n'est lue (`TOP 0` ne retourne aucune ligne).
## Canaris utilisés
### Socle commun (présent dans Batik et Omika)
| Table | Colonne | Rôle |
|-------|---------|------|
| `Client` | `CL_PKID` | Confirme que la base est une base Omika/Batik compatible |
### Canaris Omika (colonnes Omika-only de `Client`)
| Table | Colonne | Rôle |
|-------|---------|------|
| `Client` | `CL_CleType` | Marqueur Omika (colonne absente de Batik) |
| `Client` | `GUID` | Marqueur Omika (colonne absente de Batik) |
### Canari de confirmation Omika (table Omika-only)
| Table | Colonne | Rôle |
|-------|---------|------|
| `Role` | `R_PKID` | Confirme la migration Omika en cas d'ambiguïté |
## Pourquoi ces canaris discriminent Batik / Omika
Les colonnes `CL_CleType` et `GUID` de la table `Client` sont marquées
`IsOmikaOnly` dans les modèles physiques générés et ont été **confirmées par le
model-audit** :
- présentes physiquement dans `OMIKA_TEST` (statut `VERIFIED`) ;
- absentes de `BATIK_TEST`.
La table `Role` est une table réellement Omika-only (absente de Batik), confirmée
par le model-audit. Elle sert de canari de confirmation indépendant.
La table `Client` est accessible en accès direct dans les deux bases, ce qui rend
la détection possible sans dépendre des mots de passe de fichiers protégés.
## Algorithme de décision
1. **Socle commun** : `SELECT TOP 0 CL_PKID FROM Client` doit réussir.
Sinon → échec explicite (base non reconnue).
2. **Canaris Omika** : tester `CL_CleType` et `GUID` dans `Client`.
3. **Canari de confirmation** : tester `R_PKID` dans `Role`.
Décision :
- `CL_CleType` **et** `GUID` présents → **Omika**.
- Un seul des deux présent → ambiguïté :
- si `Role` présent → **Omika** ;
- sinon → échec explicite (structure incohérente).
- Aucun canari Omika présent (socle commun valide) → **Batik**.
La base n'est jamais déclarée Batik ou Omika par défaut : la décision repose
toujours sur la structure réellement observée.
## Résultats d'intégration
| Base | Résultat attendu | Résultat observé |
|------|------------------|------------------|
| `BATIK_TEST` | `DatabaseSchemaVersion.Batik` | `Batik` |
| `OMIKA_TEST` | `DatabaseSchemaVersion.Omika` | `Omika` |
Les tests d'intégration réels utilisent `config/hfsql-test.local.json` et le
provider OLE DB `PCSOFT.HFSQL`. Ils sont ignorés automatiquement si la
configuration locale ou le serveur sont indisponibles.
Aucun secret, aucune chaîne de connexion, aucune donnée métier n'est exposée.
## Tests d'intégration du chemin de création (CreateAsync)
En complément de la détection de schéma, des tests d'intégration réels couvrent
le chemin d'écriture `CreateAsync` sur les deux schémas (Batik et Omika) :
- `CreateAsync_Batik_PersistsAndRollsBack`
- `CreateAsync_Omika_PersistsAndRollsBack`
### Principe
1. Les mots de passe de fichiers (Extended Properties) sont résolus par
`HfSqlFileAccessResolver` pour les fichiers nécessaires au chemin Create
(`Client`, `ParametreMetier`, `Adresse`, `Client_Adresse`).
2. Une connexion physique OLE DB est ouverte avec ces mots de passe.
3. Une **transaction externe** est créée sur cette connexion.
4. `CreateAsync` est exécuté **dans cette transaction** via le repository
(Batik ou Omika).
5. Le client canonique retourné est vérifié (nom, identifiant non vide).
6. La transaction est **annulée (rollback)** : aucune trace n'est laissée en base.
### Pourquoi une transaction externe
Les repositories `BatikClientRepository` et `OmikaClientRepository` exposent une
surcharge `CreateAsync(command, DbTransaction?, cancellationToken)` qui réutilise
une transaction externe fournie par l'appelant. En production, la surcharge
standard `CreateAsync(command, cancellationToken)` est utilisée et une transaction
interne est créée puis commitée. Dans les tests, la transaction externe permet de
**vérifier la persistance réelle puis de l'annuler** sans polluer la base.
### Garanties
- Aucune donnée métier n'est laissée en base (rollback systématique).
- Aucun secret, aucune chaîne de connexion, aucun mot de passe n'est exposé.
- Les tests sont ignorés automatiquement si la configuration locale ou le
serveur sont indisponibles.
File diff suppressed because it is too large Load Diff
+114
View File
@@ -0,0 +1,114 @@
# HFSQL ODBC Capability Probe
## ⚠ CRITICAL — pour base de test / clone uniquement
Cet outil est **exclusivement réservé à une base clone ou de test**.
Il ne doit **jamais** être lancé contre une base de production.
Les probes d'écriture (`transaction`, `returning`) effectuent des INSERT temporaires
(avec ROLLBACK), mais un auto-incrément peut être consommé même après rollback.
## Pourquoi cet outil ?
Avant d'implémenter la création de Client (`POST /clients`), nous devons confirmer
expérimentalement que le driver ODBC HFSQL supporte :
1. `INSERT INTO ... RETURNING <colonne_auto_id>` via `OdbcCommand.ExecuteScalar()` et `ExecuteReader()`
2. Les transactions (BEGIN / ROLLBACK)
3. Le format de GUID Omika (32 caractères, format `N`)
Ces capacités ne peuvent pas être vérifiées sans une connexion réelle.
## Commandes
```
dotnet run --project tools/Omika.HfSqlProbe -- <commande> <options>
```
### `capabilities` — lecture seule
Affiche les informations du driver ODBC et teste `SELECT 1`.
```
dotnet run --project tools/Omika.HfSqlProbe -- capabilities --connection-string "<DSN>"
```
### `transaction` — écriture (exige `--allow-write-probe`)
Teste BEGIN / INSERT / SELECT / ROLLBACK sur une table de test.
```
dotnet run --project tools/Omika.HfSqlProbe -- transaction `
--connection-string "<DSN>" `
--allow-write-probe `
--table ProbeTable `
--marker-column TestMarker
```
### `returning` — écriture (exige `--allow-write-probe`)
Teste `INSERT ... RETURNING` via **ExecuteScalar** et **ExecuteReader**.
```
dotnet run --project tools/Omika.HfSqlProbe -- returning `
--connection-string "<DSN>" `
--allow-write-probe `
--table ProbeTable `
--id-column PKID `
--marker-column TestMarker
```
### `omika-guid` — lecture seule
Statistiques sur les GUID de la table Client (longueur 32/36, doublons).
```
dotnet run --project tools/Omika.HfSqlProbe -- omika-guid --connection-string "<DSN>"
```
## Options
| Option | Description |
|---|---|
| `--connection-string` | Chaîne ODBC HFSQL (DSN=...) — obligatoire |
| `--allow-write-probe` | Confirme l'utilisation sur une base clone/test (obligatoire pour writes) |
| `--table` | Table pour les probes d'écriture |
| `--id-column` | Colonne auto-ID (défaut : PKID) |
| `--marker-column` | Colonne recevant la valeur marqueur (défaut : Marker) |
| `--schema` | `batik` ou `omika` (pour probes spécifiques Client) |
| `--use-client-table` | Écriture sur la table Client (clone DB uniquement !) |
## Codes de sortie
| Code | Signification |
|---|---|
| 0 | Succès |
| 1 | Capacité testée non supportée / échec |
| 2 | Arguments invalides / sécurité |
| 3 | Connexion impossible |
## Ordre recommandé pour le premier test réel
1. **`capabilities`** sur une clone — valider le driver et la connexion
2. **`returning`** sur une table de test dédiée avec colonne auto-ID — prouver que RETURNING fonctionne
3. **`transaction`** sur la même table — prouver l'isolation
4. Si 1/2/3 sont verts : **`omika-guid`** pour les statistiques GUID
5. Ensuite seulement : tests spécifiques Client Batik/Omika
## Niveaux de validation
- **Niveau 1** : capacité générique du driver (`returning`, `transaction`)
- **Niveau 2** : INSERT Client physique (Batik ou Omika) — nécessite le niveau 1 validé
## Protection anti-production
- `--allow-write-probe` est obligatoire pour toute commande d'écriture
- La connection string n'est jamais lue automatiquement (pas de appsettings.json)
- Les credentials sont masqués dans les messages d'erreur
- Les identifiants SQL sont strictement validés (`\A[A-Za-z_][A-Za-z0-9_]*\z`)
## Limitations connues
- La validation RETURNING n'a pas encore été exécutée contre un vrai driver HFSQL
- L'outil ne prouve pas comment l'application historique Omika générait ses GUID
- Un auto-ID peut être consommé par HFSQL même après ROLLBACK
+622
View File
@@ -0,0 +1,622 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#4f46e5" id="themeMeta">
<meta name="color-scheme" content="light dark">
<title>Omika — Maquette fonctionnelle terrain</title>
<style>
/* =========================================================
OMIKA — DESIGN SYSTEM, THÈMES ET MISE EN PAGE RESPONSIVE
========================================================= */
:root{
--primary:#4f46e5;--primary-dark:#3730a3;--primary-soft:#eeedff;--on-primary:#fff;
--bg:#f3f6fb;--surface:#fff;--surface-2:#f8fafc;--surface-3:#eef2f7;
--text:#172033;--muted:#617086;--faint:#8a96a8;--border:#dfe6ef;
--success:#14845b;--success-soft:#e7f7f0;--warning:#ae6800;--warning-soft:#fff3d8;
--danger:#c3414c;--danger-soft:#ffeaec;--info:#1e73be;--info-soft:#e8f3ff;
--shadow:0 10px 32px rgba(24,35,58,.09);--radius:17px;--rail:250px;--top:68px;
font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
}
body[data-theme="dark"]{
--bg:#0c1320;--surface:#141d2b;--surface-2:#192435;--surface-3:#202d40;
--text:#f4f7fb;--muted:#bec8d7;--faint:#8e9aaf;--border:#2b394c;
--success-soft:rgba(20,132,91,.2);--warning-soft:rgba(174,104,0,.23);
--danger-soft:rgba(195,65,76,.2);--info-soft:rgba(30,115,190,.22);
--shadow:0 14px 40px rgba(0,0,0,.3)
}
*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;min-width:320px;background:var(--bg);color:var(--text)}
button,input,select,textarea{font:inherit;color:inherit}button{cursor:pointer}a{color:inherit}.hidden{display:none!important}
.muted{color:var(--muted)}.faint{color:var(--faint)}.small{font-size:12px}.tiny{font-size:10px}.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-variant-numeric:tabular-nums}
.ico{width:1.35em;height:1.35em;display:inline-grid;place-items:center;flex:0 0 auto;font-style:normal;line-height:1}
.app{min-height:100vh}.rail{position:fixed;inset:0 auto 0 0;width:var(--rail);z-index:30;display:flex;flex-direction:column;padding:16px 12px;background:var(--surface);border-right:1px solid var(--border)}
.brand{display:flex;align-items:center;gap:11px;padding:2px 8px 18px}.brand-mark{width:43px;height:43px;display:grid;place-items:center;border-radius:14px;background:linear-gradient(145deg,var(--primary),var(--primary-dark));color:var(--on-primary);font-weight:950;letter-spacing:-.05em;box-shadow:0 8px 18px color-mix(in srgb,var(--primary) 28%,transparent)}
.brand h1{margin:0;font-size:19px;letter-spacing:-.04em}.brand p{margin:2px 0 0;color:var(--faint);font-size:10px}.nav-label{margin:8px 10px;color:var(--faint);font-size:10px;font-weight:850;letter-spacing:.1em;text-transform:uppercase}
.nav{display:grid;gap:5px}.nav button,.mobile-nav button{border:0;background:transparent}.nav button{min-height:46px;display:flex;align-items:center;gap:12px;padding:10px 12px;border-radius:12px;color:var(--muted);text-align:left}.nav button:hover{background:var(--surface-2);color:var(--text)}.nav button.active{background:var(--primary-soft);color:var(--primary);font-weight:850}.nav .count{margin-left:auto;min-width:23px;height:23px;display:grid;place-items:center;padding:0 6px;border-radius:999px;background:var(--primary);color:var(--on-primary);font-size:10px}
.rail-spacer{flex:1}.rail-sync{margin:12px 3px;padding:11px;border:1px solid var(--border);border-radius:13px;background:var(--surface-2);font-size:11px;color:var(--muted)}.profile{display:flex;align-items:center;gap:9px;padding:8px}.avatar{width:38px;height:38px;display:grid;place-items:center;flex:0 0 auto;border-radius:50%;color:#fff;background:#7657c8;font-size:11px;font-weight:900}.profile strong,.profile span{display:block}.profile strong{font-size:12px}.profile span{color:var(--faint);font-size:10px;margin-top:2px}
.shell{min-height:100vh;margin-left:var(--rail)}.topbar{position:sticky;top:0;z-index:25;height:var(--top);display:flex;align-items:center;justify-content:space-between;gap:12px;padding:0 clamp(16px,3vw,38px);border-bottom:1px solid var(--border);background:color-mix(in srgb,var(--bg) 88%,transparent);backdrop-filter:blur(16px)}.topbar-left,.topbar-actions{display:flex;align-items:center;gap:9px;min-width:0}.topbar h2{margin:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:18px;letter-spacing:-.02em}.topbar small{display:block;color:var(--faint);font-size:10px;margin-bottom:2px}.menu-btn{display:none}
main{width:min(100%,1440px);margin:0 auto;padding:clamp(18px,3vw,34px) clamp(14px,3vw,38px) 58px}.mobile-nav{display:none}.screen{animation:screenIn .18s ease both}@keyframes screenIn{from{opacity:0;transform:translateY(3px)}to{opacity:1;transform:none}}
.page-head{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:20px}.page-head h1{margin:0;font-size:clamp(25px,3vw,35px);letter-spacing:-.045em}.page-head p{margin:7px 0 0;max-width:780px;color:var(--muted);line-height:1.55}.actions{display:flex;gap:8px;flex-wrap:wrap}.stack{display:grid;gap:12px}.stack-lg{display:grid;gap:18px}.cluster{display:flex;align-items:center;justify-content:space-between;gap:12px}.inline{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.grid-2{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:13px}.grid-3{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:13px}.grid-4{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px}.span-2{grid-column:span 2}
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow)}.card-head{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:17px 19px;border-bottom:1px solid var(--border)}.card-head h2,.card-head h3{margin:0;font-size:16px}.card-head p{margin:3px 0 0;color:var(--faint);font-size:11px}.card-body{padding:18px}
.btn,.icon-btn{border:1px solid transparent;border-radius:11px;font-weight:780;transition:.16s ease}.btn{min-height:42px;display:inline-flex;align-items:center;justify-content:center;gap:7px;padding:9px 14px;font-size:13px}.btn:hover,.icon-btn:hover{transform:translateY(-1px)}.btn:disabled,.icon-btn:disabled{opacity:.55;cursor:not-allowed;transform:none}.btn-primary{background:var(--primary);border-color:var(--primary);color:var(--on-primary);box-shadow:0 8px 18px color-mix(in srgb,var(--primary) 22%,transparent)}.btn-secondary{background:var(--surface);border-color:var(--border)}.btn-soft{background:var(--primary-soft);border-color:var(--primary-soft);color:var(--primary)}.btn-danger{background:var(--danger-soft);color:var(--danger)}.btn-ghost{background:transparent;color:var(--muted)}.btn-sm{min-height:34px;padding:6px 10px;font-size:11px}.btn-block{width:100%}.icon-btn{width:42px;height:42px;display:inline-grid;place-items:center;padding:0;background:var(--surface);border-color:var(--border);color:var(--muted)}button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible,canvas:focus-visible{outline:3px solid color-mix(in srgb,var(--primary) 28%,transparent);outline-offset:2px}
.badge,.status{display:inline-flex;align-items:center;gap:5px;min-height:24px;width:max-content;max-width:100%;padding:4px 9px;border-radius:999px;font-size:10px;font-weight:850}.badge{background:var(--surface-3);color:var(--muted)}.badge.primary{background:var(--primary-soft);color:var(--primary)}.badge.success{background:var(--success-soft);color:var(--success)}.badge.warning{background:var(--warning-soft);color:var(--warning)}.badge.danger{background:var(--danger-soft);color:var(--danger)}.badge.info{background:var(--info-soft);color:var(--info)}.status::before{content:"";width:7px;height:7px;border-radius:50%;background:currentColor}.planned{background:var(--info-soft);color:var(--info)}.progress{background:var(--warning-soft);color:var(--warning)}.completed{background:var(--success-soft);color:var(--success)}.late{background:var(--danger-soft);color:var(--danger)}
.field{display:grid;gap:6px}.field label,.field-title{font-size:12px;font-weight:820}.required::after{content:" *";color:var(--danger)}input,select,textarea{width:100%;min-height:43px;padding:10px 12px;border:1px solid var(--border);border-radius:10px;background:var(--surface);color:var(--text)}textarea{min-height:105px;resize:vertical;line-height:1.5}input::placeholder,textarea::placeholder{color:var(--faint)}.check{display:flex;align-items:flex-start;gap:8px;color:var(--muted);font-size:12px;line-height:1.45}.check input{width:18px;height:18px;min-height:auto;margin-top:1px;accent-color:var(--primary)}.form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.checks{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:9px}.segmented{display:inline-flex;gap:3px;padding:4px;border:1px solid var(--border);border-radius:12px;background:var(--surface-2)}.segmented button{min-height:33px;padding:6px 11px;border:0;border-radius:8px;background:transparent;color:var(--muted);font-size:11px;font-weight:820}.segmented button.active{background:var(--surface);color:var(--primary);box-shadow:0 2px 7px rgba(0,0,0,.07)}
.welcome{position:relative;overflow:hidden;display:grid;grid-template-columns:minmax(0,1.35fr) minmax(260px,.65fr);gap:16px;margin-bottom:18px;padding:23px;border:1px solid var(--border);border-radius:22px;background:linear-gradient(135deg,var(--surface),var(--primary-soft));box-shadow:var(--shadow)}.welcome:after{content:"";position:absolute;right:-80px;top:-120px;width:250px;height:250px;border-radius:50%;background:color-mix(in srgb,var(--primary) 9%,transparent)}.welcome>*{position:relative;z-index:1}.welcome h1{margin:9px 0 6px;font-size:clamp(25px,3vw,34px);letter-spacing:-.04em}.welcome p{margin:0 0 17px;color:var(--muted);line-height:1.5}.summary-box{align-self:center;padding:16px;border:1px solid var(--border);border-radius:15px;background:color-mix(in srgb,var(--surface) 72%,transparent)}.summary-box strong{display:block;font-size:30px}.progressbar{height:7px;margin-top:10px;overflow:hidden;border-radius:99px;background:var(--surface-3)}.progressbar span{display:block;height:100%;background:var(--primary)}
.stat{display:flex;align-items:center;gap:11px;min-height:88px;padding:14px}.stat-icon{width:44px;height:44px;display:grid;place-items:center;border-radius:13px;background:var(--primary-soft);color:var(--primary);font-size:19px}.stat strong{display:block;font-size:20px}.stat span{display:block;margin-top:2px;color:var(--muted);font-size:10px;line-height:1.35}
.agenda{overflow:hidden}.agenda-tools{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:14px 16px;border-bottom:1px solid var(--border)}.tool-group{display:flex;align-items:center;gap:7px}.period{min-width:260px;text-align:center;font-size:13px;font-weight:850}.date-strip{display:grid;grid-template-columns:repeat(7,1fr);gap:5px;padding:10px 12px;border-bottom:1px solid var(--border);background:var(--surface-2)}.date-chip{min-width:0;padding:8px 3px;border:0;border-radius:11px;background:transparent;color:var(--muted)}.date-chip:hover{background:var(--surface)}.date-chip.selected{background:var(--primary);color:var(--on-primary);box-shadow:0 6px 14px color-mix(in srgb,var(--primary) 23%,transparent)}.date-chip:disabled{opacity:.35}.date-chip span,.date-chip strong,.date-chip small{display:block}.date-chip span{font-size:9px;text-transform:uppercase}.date-chip strong{font-size:17px;margin:2px 0}.date-chip small{font-size:8px}.list{display:grid}.day-group+.day-group{border-top:1px solid var(--border)}.day-title{display:flex;justify-content:space-between;gap:10px;padding:12px 16px;background:var(--surface-2);font-size:11px;font-weight:820}.appt{position:relative;display:grid;grid-template-columns:68px minmax(0,1fr) auto;gap:13px;align-items:center;padding:14px 16px;border-bottom:1px solid var(--border);background:var(--surface);transition:.16s}.appt:last-child{border-bottom:0}.appt:hover{background:var(--surface-2)}.appt:before{content:"";position:absolute;left:0;top:10px;bottom:10px;width:4px;border-radius:0 99px 99px 0;background:var(--info)}.appt.is-completed:before{background:var(--success)}.appt.is-progress:before{background:var(--warning)}.appt.is-late:before{background:var(--danger)}.appt-time strong,.appt-time span{display:block}.appt-time strong{font-size:17px}.appt-time span{margin-top:2px;color:var(--faint);font-size:10px}.appt h3{margin:7px 0 4px;font-size:14px}.appt p{margin:0;color:var(--muted);font-size:11px;line-height:1.4}.meta{display:flex;gap:9px;flex-wrap:wrap;margin-top:7px;color:var(--faint);font-size:9px}.appt-action{display:flex;align-items:center}.link-state{margin-top:6px}
.back{display:flex;align-items:center;gap:8px;margin-bottom:14px}.hero{overflow:hidden;padding:24px;margin-bottom:18px;border-radius:22px;background:linear-gradient(135deg,var(--primary-dark),var(--primary));color:var(--on-primary);box-shadow:0 14px 35px color-mix(in srgb,var(--primary) 24%,transparent)}.hero-top{display:flex;align-items:flex-start;justify-content:space-between;gap:14px}.hero h1{margin:10px 0 6px;font-size:clamp(24px,3vw,34px);letter-spacing:-.04em}.hero p{margin:0;color:color-mix(in srgb,var(--on-primary) 78%,transparent);line-height:1.5}.hero .status{background:color-mix(in srgb,var(--surface) 16%,transparent);color:var(--on-primary)}.hero-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:20px}.hero-actions .btn{background:color-mix(in srgb,var(--surface) 14%,transparent);border-color:color-mix(in srgb,var(--surface) 20%,transparent);color:var(--on-primary)}.hero-actions .btn.main{background:var(--surface);border-color:var(--surface);color:var(--primary-dark)}
.detail-layout{display:grid;grid-template-columns:minmax(0,1fr) 330px;gap:18px;align-items:start}.detail-side{position:sticky;top:calc(var(--top) + 18px);display:grid;gap:16px}.info{min-width:0;padding:13px;border:1px solid var(--border);border-radius:12px;background:var(--surface-2)}.info span{display:block;margin-bottom:5px;color:var(--faint);font-size:9px;text-transform:uppercase;letter-spacing:.05em}.info strong{display:block;font-size:12px;line-height:1.45;overflow-wrap:anywhere}.text-box{padding:14px;border-left:3px solid var(--primary);border-radius:0 11px 11px 0;background:var(--surface-2)}.text-box h4{margin:0 0 6px;font-size:12px}.text-box p{margin:0;color:var(--muted);font-size:12px;line-height:1.55}.map{position:relative;height:165px;overflow:hidden;background:linear-gradient(35deg,#d6e3d3,#e8eef5)}body[data-theme="dark"] .map{background:linear-gradient(35deg,#263528,#28394b)}.map:before,.map:after{content:"";position:absolute;inset:-40px;transform:rotate(16deg);border:14px solid rgba(255,255,255,.5);border-left:0;border-right:0}.map:after{transform:rotate(-32deg);border-width:8px}.pin{position:absolute;inset:0;display:grid;place-items:center}.pin span{width:48px;height:48px;display:grid;place-items:center;border:5px solid rgba(255,255,255,.8);border-radius:50% 50% 50% 8px;transform:rotate(-45deg);background:var(--primary);color:var(--on-primary);font-size:20px;box-shadow:0 10px 18px rgba(0,0,0,.2)}.pin span::first-letter{transform:rotate(45deg)}
.row{display:flex;align-items:center;gap:11px;min-width:0;padding:11px;border:1px solid var(--border);border-radius:12px;background:var(--surface-2)}.row-icon{width:38px;height:38px;display:grid;place-items:center;flex:0 0 auto;border-radius:11px;background:var(--primary-soft);color:var(--primary);font-weight:900}.row-main{min-width:0;flex:1}.row-main strong,.row-main span{display:block}.row-main strong{font-size:11px;overflow-wrap:anywhere}.row-main span{margin-top:4px;color:var(--muted);font-size:10px;line-height:1.45;overflow-wrap:anywhere}.row-actions{display:flex;gap:6px;flex-wrap:wrap}
.resource-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}.resource-card{display:grid;gap:10px;padding:16px;border:1px solid var(--border);border-radius:14px;background:var(--surface-2);text-align:left}.resource-card:hover{border-color:var(--primary)}.resource-card .resource-icon{width:42px;height:42px;display:grid;place-items:center;border-radius:12px;background:var(--primary-soft);color:var(--primary);font-size:18px}.resource-card strong{font-size:13px}.resource-card span{color:var(--muted);font-size:10px;line-height:1.4}.resource-card b{color:var(--primary);font-size:11px}
.callout{display:flex;align-items:flex-start;gap:11px;padding:14px;border:1px solid var(--border);border-radius:14px;background:var(--primary-soft)}.callout .row-icon{background:var(--surface)}.callout h3{margin:0 0 4px;font-size:13px}.callout p{margin:0;color:var(--muted);font-size:11px;line-height:1.5}.callout .actions{margin-left:auto;flex:0 0 auto}
.time-summary{display:grid;grid-template-columns:repeat(3,1fr);gap:9px}.time-stat{padding:12px;border:1px solid var(--border);border-radius:11px;background:var(--surface-2)}.time-stat strong{display:block;font-size:17px}.time-stat span{display:block;margin-top:3px;color:var(--muted);font-size:9px}.time-entry{display:grid;grid-template-columns:minmax(0,1fr) auto auto auto;gap:10px;align-items:center}.time-entry .time-type{display:flex;align-items:center;gap:9px}.time-dot{width:9px;height:9px;border-radius:50%;background:var(--primary)}.time-entry strong{font-size:11px}.time-entry span{font-size:10px;color:var(--muted)}
.tabs{display:flex;gap:3px;overflow:auto;padding:0 16px;border-bottom:1px solid var(--border)}.tab{min-height:43px;padding:0 10px;border:0;border-bottom:2px solid transparent;background:transparent;color:var(--muted);font-size:11px;font-weight:820;white-space:nowrap}.tab.active{color:var(--primary);border-bottom-color:var(--primary)}.tab-panel{display:none;padding:17px}.tab-panel.active{display:block}.table-wrap{overflow:auto;border:1px solid var(--border);border-radius:11px}.table{width:100%;min-width:690px;border-collapse:collapse}.table th,.table td{padding:10px 11px;border-bottom:1px solid var(--border);text-align:left;font-size:10px;vertical-align:middle}.table th{background:var(--surface-2);color:var(--faint);font-size:9px;text-transform:uppercase}.table tr:last-child td{border-bottom:0}
.search-form{padding:18px}.result-list{display:grid;gap:11px}.result{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:13px;align-items:center;padding:15px;border:1px solid var(--border);border-radius:14px;background:var(--surface)}.result h3{margin:7px 0 4px;font-size:14px}.result p{margin:0;color:var(--muted);font-size:11px;line-height:1.45}.result-meta{display:flex;gap:8px;flex-wrap:wrap;margin-top:8px;color:var(--faint);font-size:9px}
.master-detail{display:grid;grid-template-columns:280px minmax(0,1fr);gap:17px;align-items:start}.master{position:sticky;top:calc(var(--top) + 18px);overflow:hidden}.master-list{max-height:calc(100vh - 190px);overflow:auto}.master-item{width:100%;display:grid;gap:4px;padding:13px 15px;border:0;border-bottom:1px solid var(--border);background:var(--surface);text-align:left}.master-item:last-child{border-bottom:0}.master-item:hover{background:var(--surface-2)}.master-item.active{background:var(--primary-soft);box-shadow:inset 4px 0 var(--primary)}.master-item strong{font-size:11px}.master-item span,.master-item small{color:var(--muted);font-size:9px;line-height:1.35}.entity-head{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;padding:20px;border-bottom:1px solid var(--border)}.entity-title{display:flex;gap:12px}.entity-icon{width:52px;height:52px;display:grid;place-items:center;flex:0 0 auto;border-radius:15px;background:var(--primary-soft);color:var(--primary);font-size:21px}.entity-head h1{margin:7px 0 4px;font-size:21px}.entity-head p{margin:0;color:var(--muted);font-size:11px;line-height:1.45}
.template-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:13px}.template-card{display:flex;flex-direction:column;gap:12px;padding:16px;border:1px solid var(--border);border-radius:15px;background:var(--surface)}.template-card:hover{border-color:var(--primary);box-shadow:var(--shadow)}.template-icon{width:46px;height:46px;display:grid;place-items:center;border-radius:13px;background:var(--primary-soft);color:var(--primary);font-size:20px}.template-card h3{margin:0;font-size:14px}.template-card p{margin:0;flex:1;color:var(--muted);font-size:11px;line-height:1.5}.template-card .btn{width:100%}
.dynamic{padding:20px}.dynamic h2{margin:0;font-size:18px}.dynamic>p{margin:5px 0 18px;color:var(--muted);font-size:11px}.report-layout{display:grid;grid-template-columns:minmax(0,1fr) 310px;gap:17px;align-items:start}.report-side{position:sticky;top:calc(var(--top) + 18px)}.signatures{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:13px}.signature{overflow:hidden;border:1px solid var(--border);border-radius:13px;background:var(--surface)}.signature-head{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:10px 12px;border-bottom:1px solid var(--border);font-size:11px;font-weight:820}.signature canvas{display:block;width:100%;height:150px;touch-action:none;background:var(--surface-2)}
.photos{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:13px}.photo{overflow:hidden;border:1px solid var(--border);border-radius:14px;background:var(--surface)}.photo img{display:block;width:100%;aspect-ratio:16/10;object-fit:cover;background:var(--surface-3)}.photo-body{display:grid;gap:9px;padding:12px}.photo-body strong{font-size:11px}.drop{min-height:110px;display:grid;place-items:center;gap:4px;padding:15px;border:2px dashed var(--border);border-radius:14px;background:var(--surface-2);color:var(--muted);text-align:center;cursor:pointer}.drop:hover{border-color:var(--primary);color:var(--primary)}
.email-layout{display:grid;grid-template-columns:minmax(0,1fr) 360px;gap:17px;align-items:start}.email-preview{position:sticky;top:calc(var(--top) + 18px);overflow:hidden}.mail-window{background:var(--surface-2)}.mail-bar{display:flex;gap:6px;padding:11px;border-bottom:1px solid var(--border)}.mail-dot{width:9px;height:9px;border-radius:50%;background:var(--border)}.mail-content{padding:16px}.mail-line{display:grid;grid-template-columns:55px 1fr;gap:8px;padding:8px 0;border-bottom:1px solid var(--border);font-size:10px}.mail-line span:first-child{color:var(--faint)}.mail-body{min-height:180px;padding-top:14px;color:var(--muted);font-size:11px;line-height:1.6;white-space:pre-wrap}.mode-card{padding:14px;border:2px solid var(--border);border-radius:14px;background:var(--surface);text-align:left}.mode-card.active{border-color:var(--primary);box-shadow:0 0 0 3px var(--primary-soft)}.mode-card strong{display:block;font-size:12px}.mode-card span{display:block;margin-top:4px;color:var(--muted);font-size:10px;line-height:1.4}
.theme-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:11px}.theme-choice{padding:12px;border:2px solid var(--border);border-radius:13px;background:var(--surface-2)}.theme-choice.active{border-color:var(--primary);box-shadow:0 0 0 3px var(--primary-soft)}.theme-thumb{height:95px;display:grid;grid-template-columns:26px 1fr;gap:6px;padding:8px;margin-bottom:8px;border-radius:9px}.theme-thumb.light{background:#edf1f7}.theme-thumb.dark{background:#0c1320}.theme-thumb:before{content:"";border-radius:5px;background:#4f46e5}.theme-thumb:after{content:"";border-radius:5px;background:#fff}.theme-thumb.dark:after{background:#1c283a}.swatches{display:flex;gap:9px;flex-wrap:wrap}.swatch{width:36px;height:36px;border:3px solid var(--surface);border-radius:50%;background:var(--c);box-shadow:0 0 0 1px var(--border)}.swatch.active{box-shadow:0 0 0 3px var(--surface),0 0 0 6px var(--c)}.preview{overflow:hidden}.preview-top{height:55px;display:flex;align-items:center;justify-content:space-between;padding:0 15px;background:var(--primary);color:var(--on-primary)}.preview-body{min-height:250px;padding:15px;background:var(--bg)}.preview-card{padding:13px;border:1px solid var(--border);border-radius:11px;background:var(--surface)}.line{height:8px;margin-bottom:8px;border-radius:99px;background:var(--border)}.line.short{width:45%}.preview-button{width:78px;height:26px;margin-top:12px;border-radius:7px;background:var(--primary)}
.empty{padding:38px 20px;text-align:center}.empty-icon{width:52px;height:52px;display:grid;place-items:center;margin:0 auto 12px;border-radius:16px;background:var(--primary-soft);color:var(--primary);font-size:22px}.empty h3{margin:0 0 6px}.empty p{margin:0 auto 15px;max-width:460px;color:var(--muted);font-size:12px;line-height:1.5}
.sheet,.modal-wrap{position:fixed;inset:0;z-index:80;display:grid;background:rgba(5,9,16,.58);opacity:0;pointer-events:none;transition:.18s}.sheet{align-items:end;padding:10px}.modal-wrap{place-items:center;padding:12px}.sheet.open,.modal-wrap.open{opacity:1;pointer-events:auto}.sheet-box{width:min(100%,500px);justify-self:center;padding:10px 13px 18px;border:1px solid var(--border);border-radius:22px 22px 15px 15px;background:var(--surface);transform:translateY(25px);transition:.18s}.sheet.open .sheet-box{transform:none}.sheet-handle{width:45px;height:5px;margin:1px auto 12px;border-radius:99px;background:var(--border)}.sheet-menu{display:grid;gap:7px}.sheet-menu button{min-height:47px;display:flex;align-items:center;gap:10px;padding:10px 12px;border:1px solid var(--border);border-radius:11px;background:var(--surface-2);text-align:left}.sheet-menu button span:last-child{margin-left:auto}.modal{width:min(100%,720px);max-height:92vh;overflow:auto;border:1px solid var(--border);border-radius:18px;background:var(--surface);box-shadow:var(--shadow)}.modal-head,.modal-foot{position:sticky;z-index:2;display:flex;align-items:center;justify-content:space-between;gap:10px;padding:15px 18px;background:var(--surface)}.modal-head{top:0;border-bottom:1px solid var(--border)}.modal-foot{bottom:0;justify-content:flex-end;border-top:1px solid var(--border)}.modal-head h2{margin:0;font-size:17px}.modal-body{padding:18px}
.toast-area{position:fixed;z-index:100;right:16px;bottom:16px;display:grid;gap:8px;width:min(370px,calc(100vw - 32px));pointer-events:none}.toast{display:flex;gap:10px;padding:12px 13px;border:1px solid var(--border);border-radius:12px;background:var(--surface);box-shadow:var(--shadow);animation:toastIn .2s ease}.toast strong{display:block;font-size:11px}.toast p{margin:3px 0 0;color:var(--muted);font-size:10px;line-height:1.4}@keyframes toastIn{from{opacity:0;transform:translateY(10px)}}.spin{display:inline-block;width:18px;height:18px;border:2px solid currentColor;border-right-color:transparent;border-radius:50%;animation:spin .75s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}
/* =========================================================
OMIKA — ÉCRANS SPÉCIALISÉS ET RELATIONS MÉTIER
========================================================= */
.relation-callout{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:15px;border:1px solid var(--border);border-radius:14px;background:var(--primary-soft)}
.relation-callout.neutral{background:var(--surface-2)}
.relation-callout h3{margin:6px 0 4px;font-size:15px}.relation-callout p{margin:0;color:var(--muted);font-size:12px;line-height:1.5}
.resource-card{min-height:150px;transition:.16s ease}.resource-card:hover{transform:translateY(-1px)}.resource-card b{margin-top:auto}
.fixed-document{overflow:hidden}.fixed-banner{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:16px 18px;border-bottom:1px solid var(--border);background:var(--surface-2)}
.fixed-banner strong,.fixed-banner span{display:block}.fixed-banner strong{font-size:14px}.fixed-banner div>span{margin-top:3px;color:var(--faint);font-size:10px}
.paper-meta{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:1px;margin-bottom:18px;overflow:hidden;border:1px solid var(--border);border-radius:12px;background:var(--border)}
.paper-meta>div{padding:12px;background:var(--surface-2)}.paper-meta span,.paper-meta strong{display:block}.paper-meta span{color:var(--faint);font-size:9px;font-weight:800;text-transform:uppercase}.paper-meta strong{margin-top:4px;font-size:11px}
.form-section{padding:16px 0;border-top:1px solid var(--border)}.form-section:first-of-type{border-top:0}
.form-section-title{display:flex;align-items:center;gap:10px;margin-bottom:14px}.form-section-title>span{width:28px;height:28px;display:grid;place-items:center;border-radius:9px;background:var(--primary);color:var(--on-primary);font-size:11px;font-weight:900}.form-section-title strong,.form-section-title small{display:block}.form-section-title strong{font-size:13px}.form-section-title small{margin-top:2px;color:var(--faint);font-size:9px}
.email-layout{grid-template-columns:minmax(250px,.62fr) minmax(0,1.38fr)}
.email-mode-grid{display:grid;gap:9px}.email-mode{display:grid;grid-template-columns:32px minmax(0,1fr);gap:3px 10px;padding:12px;border:1px solid var(--border);border-radius:12px;background:var(--surface-2);text-align:left}
.email-mode>span{grid-row:1/3;width:32px;height:32px;display:grid;place-items:center;border-radius:9px;background:var(--surface);color:var(--primary)}.email-mode strong{font-size:11px}.email-mode small{color:var(--faint);font-size:9px;line-height:1.35}.email-mode.active{border-color:var(--primary);background:var(--primary-soft)}
.email-preview{position:static;top:auto;display:grid;gap:11px;padding:14px;border:1px dashed var(--border);border-radius:13px;background:var(--surface-2)}.email-preview textarea{min-height:210px}
.intervention-result{box-shadow:none}.time-entry{flex-wrap:wrap}
@media(max-width:1100px) and (min-width:720px){:root{--rail:80px}.brand{justify-content:center;padding-inline:0}.brand-copy,.nav-label,.nav span:not(.count),.nav .count,.rail-sync,.profile div:last-child{display:none}.nav button{justify-content:center;padding-inline:0}.profile{justify-content:center}.detail-layout,.report-layout,.email-layout{grid-template-columns:1fr}.detail-side,.report-side,.email-preview{position:static}.grid-4{grid-template-columns:repeat(2,1fr)}.master-detail{grid-template-columns:235px 1fr}.template-grid{grid-template-columns:repeat(2,1fr)}}
@media(max-width:719px){:root{--top:60px}.rail{display:none}.shell{margin-left:0}.topbar{padding:0 11px}.menu-btn{display:grid}.topbar small{display:none}.topbar h2{font-size:16px}.topbar-actions .btn span.label{display:none}main{padding:14px 11px 90px}.mobile-nav{position:fixed;inset:auto 0 0 0;z-index:40;display:grid;grid-template-columns:repeat(5,1fr);min-height:72px;padding:6px 5px calc(6px + env(safe-area-inset-bottom));border-top:1px solid var(--border);background:var(--surface)}.mobile-nav button{display:grid;place-items:center;gap:2px;padding:4px 2px;border-radius:10px;color:var(--faint);font-size:8px;font-weight:820}.mobile-nav button.active{background:var(--primary-soft);color:var(--primary)}.mobile-nav .ico{font-size:18px}.toast-area{right:10px;bottom:84px;width:calc(100vw - 20px)}.page-head{display:grid}.welcome{grid-template-columns:1fr;padding:18px}.grid-4{grid-template-columns:repeat(2,1fr)}.agenda-tools{align-items:stretch;flex-direction:column}.tool-group{justify-content:space-between}.period{min-width:0;flex:1;font-size:11px}.date-strip{gap:2px;padding:7px 4px}.date-chip{padding:7px 1px}.date-chip span{font-size:7px}.date-chip strong{font-size:14px}.appt{grid-template-columns:58px 1fr;gap:10px;padding:12px 10px 12px 12px}.appt-time strong{font-size:15px}.appt-action{grid-column:1/-1;padding-left:68px}.appt-action .btn{width:100%}.hero{padding:18px}.hero-top{display:grid}.hero-actions{display:grid;grid-template-columns:repeat(2,1fr)}.hero-actions .main{grid-column:1/-1}.detail-layout,.report-layout,.email-layout,.master-detail{grid-template-columns:1fr}.detail-side,.report-side,.email-preview,.master{position:static}.grid-2,.grid-3,.form-grid,.signatures,.theme-grid,.resource-grid,.template-grid{grid-template-columns:1fr}.span-2{grid-column:auto}.photos{grid-template-columns:1fr}.checks{grid-template-columns:1fr}.search-form{padding:14px}.result{grid-template-columns:1fr}.result .btn{width:100%}.master-list{display:flex;max-height:none;overflow:auto}.master-item{min-width:230px;border-right:1px solid var(--border);border-bottom:0}.master-item.active{box-shadow:inset 0 -4px var(--primary)}.entity-head{display:grid;padding:16px}.entity-head .actions{justify-content:flex-start}.card-body,.dynamic{padding:15px}.callout{display:grid}.callout .actions{margin-left:0}.time-summary{grid-template-columns:1fr}.time-entry{grid-template-columns:1fr auto}.time-entry .time-type{grid-column:1/-1}.time-entry .duration-cell{justify-self:start}.table{min-width:640px}}
@media(max-width:400px){.grid-4{grid-template-columns:1fr}.hero-actions{grid-template-columns:1fr}.hero-actions .main{grid-column:auto}.appt-action{padding-left:0}.topbar-actions .btn{display:none}}
@media(max-width:719px){
.relation-callout{align-items:stretch;flex-direction:column}.relation-callout .btn{width:100%}
.paper-meta{grid-template-columns:1fr}.fixed-banner{align-items:flex-start;flex-direction:column}
.email-mode-grid{grid-template-columns:1fr}.resource-card{min-height:125px}
.master-detail,.master-detail>*{min-width:0}.master{width:100%;max-width:100%}.master-list{width:100%;max-width:100%}
.entity-title,.entity-title>div:last-child{min-width:0}.entity-head h1,.entity-head p{overflow-wrap:anywhere}
}
@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;animation-duration:.01ms!important;transition-duration:.01ms!important}}
</style>
</head>
<body data-theme="light">
<div class="app">
<!-- Navigation latérale sur desktop, compacte sur tablette -->
<aside class="rail">
<div class="brand"><div class="brand-mark">OM</div><div class="brand-copy"><h1>Omika</h1><p>Interventions terrain</p></div></div>
<div class="nav-label">Espace de travail</div>
<nav class="nav" id="sideNav">
<button data-route="home"><i class="ico">▣</i><span>Mes rendez-vous</span><b class="count" id="navCount">0</b></button>
<button data-route="team"><i class="ico">◎</i><span>Équipe</span></button>
<button data-route="search"><i class="ico">⌕</i><span>Interventions</span></button>
<button data-route="folders"><i class="ico">▤</i><span>Dossiers</span></button>
<button data-route="clients"><i class="ico">▥</i><span>Clients</span></button>
<button data-route="locations"><i class="ico">⌖</i><span>Lieux</span></button>
<button data-route="settings"><i class="ico">⚙</i><span>Paramètres</span></button>
</nav>
<div class="rail-spacer"></div>
<div class="rail-sync"><strong id="railSyncTitle">Données à jour</strong><br><span id="railSyncTime">Synchronisé récemment</span></div>
<div class="profile"><div class="avatar">CB</div><div><strong>Camille Bernard</strong><span>Technicienne CVC</span></div></div>
</aside>
<section class="shell">
<header class="topbar">
<div class="topbar-left">
<button class="icon-btn menu-btn" id="menuButton" aria-label="Ouvrir le menu">☰</button>
<div><small>Omika · Espace terrain</small><h2 id="screenTitle">Mes rendez-vous</h2></div>
</div>
<div class="topbar-actions">
<button class="btn btn-secondary" id="syncTop"><i class="ico">↻</i><span class="label">Synchroniser</span></button>
<button class="icon-btn" id="themeTop" aria-label="Changer de thème">☾</button>
</div>
</header>
<main id="view" aria-live="polite"></main>
<nav class="mobile-nav" id="mobileNav">
<button data-route="home"><i class="ico">▣</i><span>Agenda</span></button>
<button data-route="team"><i class="ico">◎</i><span>Équipe</span></button>
<button data-route="search"><i class="ico">⌕</i><span>Interventions</span></button>
<button data-route="folders"><i class="ico">▤</i><span>Dossiers</span></button>
<button id="moreButton"><i class="ico">•••</i><span>Plus</span></button>
</nav>
</section>
</div>
<!-- Menu complémentaire sur smartphone -->
<div class="sheet" id="moreSheet" aria-hidden="true">
<div class="sheet-box">
<div class="sheet-handle"></div>
<div class="cluster" style="margin-bottom:10px"><h2 style="margin:0;font-size:17px">Plus</h2><button class="icon-btn" id="closeSheet" aria-label="Fermer">×</button></div>
<div class="sheet-menu">
<button data-route="clients"><span>▥ &nbsp; Fiches clients</span><span>›</span></button>
<button data-route="locations"><span>⌖ &nbsp; Fiches lieux</span><span>›</span></button>
<button data-route="settings"><span>⚙ &nbsp; Paramètres</span><span>›</span></button>
<button id="syncMobile"><span>↻ &nbsp; Synchroniser maintenant</span><span>›</span></button>
</div>
</div>
</div>
<!-- Création d'une intervention : elle existe indépendamment de tout rendez-vous -->
<div class="modal-wrap" id="modalWrap" aria-hidden="true">
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="interventionModalTitle">
<div class="modal-head">
<div><span class="badge primary">Intervention</span><h2 id="interventionModalTitle" style="margin-top:7px">Nouvelle intervention</h2></div>
<button class="icon-btn" type="button" data-close-modal aria-label="Fermer">×</button>
</div>
<form id="newInterventionForm">
<div class="modal-body stack-lg">
<div class="relation-callout neutral">
<div><strong>Création indépendante</strong><p>Cette action crée uniquement une intervention. Un ou plusieurs rendez-vous pourront ensuite être planifiés depuis sa fiche.</p></div>
</div>
<div class="form-grid">
<div class="field span-2"><label class="required">Objet de l'intervention</label><input name="title" required placeholder="Ex. Diagnostic du groupe froid GF-02"></div>
<div class="field"><label class="required">Dossier</label><select name="folder" id="modalFolder" required></select></div>
<div class="field"><label>Priorité</label><select name="priority"><option>Normale</option><option>Haute</option><option>Urgente</option></select></div>
<div class="field"><label class="required">Client</label><select name="client" id="modalClient" required></select></div>
<div class="field"><label class="required">Lieu</label><select name="location" id="modalLocation" required></select></div>
<div class="field span-2"><label class="required">Collaborateur référent</label><select name="owner" id="modalOwner" required></select></div>
<div class="field span-2"><label>Description</label><textarea name="description" placeholder="Contexte, équipement concerné, consignes et résultat attendu…"></textarea></div>
</div>
</div>
<div class="modal-foot"><button type="button" class="btn btn-secondary" data-close-modal>Annuler</button><button class="btn btn-primary">+ Créer l'intervention</button></div>
</form>
</div>
</div>
<!-- Planification facultative d'un rendez-vous depuis une intervention -->
<div class="modal-wrap" id="appointmentModalWrap" aria-hidden="true">
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="appointmentModalTitle">
<div class="modal-head">
<div><span class="badge primary">Rendez-vous</span><h2 id="appointmentModalTitle" style="margin-top:7px">Planifier un rendez-vous</h2></div>
<button class="icon-btn" type="button" data-close-appointment-modal aria-label="Fermer">×</button>
</div>
<form id="newAppointmentForm">
<input type="hidden" name="intervention" id="appointmentIntervention">
<div class="modal-body stack-lg">
<div class="relation-callout" id="appointmentContext"></div>
<div class="form-grid">
<div class="field"><label class="required">Date</label><input type="date" name="date" id="appointmentDate" required></div>
<div class="field"><label class="required">Collaborateur</label><select name="collaborator" id="appointmentCollaborator" required></select></div>
<div class="field"><label class="required">Heure de début</label><input type="time" name="from" id="appointmentFrom" required></div>
<div class="field"><label class="required">Heure de fin</label><input type="time" name="to" id="appointmentTo" required></div>
<div class="field span-2"><label>Description du rendez-vous</label><textarea name="description" placeholder="Objectif du déplacement, personne à rencontrer, consignes…"></textarea></div>
</div>
</div>
<div class="modal-foot"><button type="button" class="btn btn-secondary" data-close-appointment-modal>Annuler</button><button class="btn btn-primary">▣ Planifier le rendez-vous</button></div>
</form>
</div>
</div>
<div class="toast-area" id="toasts"></div>
<script>
/* =========================================================
OMIKA — DONNÉES FICTIVES ET MAQUETTE FONCTIONNELLE
Les rendez-vous et les interventions sont volontairement
stockés dans deux collections séparées.
========================================================= */
const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)], id=s=>document.getElementById(s);
const pad=n=>String(n).padStart(2,"0"), start=d=>new Date(d.getFullYear(),d.getMonth(),d.getDate());
const add=(d,n)=>{const x=new Date(d);x.setDate(x.getDate()+n);return start(x)}, weekStart=d=>{const x=start(d),day=x.getDay()||7;return add(x,1-day)};
const iso=d=>`${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`, parse=s=>{const [y,m,d]=String(s).split("-").map(Number);return new Date(y,m-1,d)};
const fmt=(d,o)=>new Intl.DateTimeFormat("fr-FR",o).format(d), esc=v=>String(v??"").replace(/[&<>'"]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;","'":"&#39;",'"':"&quot;"}[c]));
const norm=v=>String(v??"").normalize("NFD").replace(/[\u0300-\u036f]/g,"").toLowerCase().trim();
const today=start(new Date()), rangeStart=add(today,-14), rangeEnd=add(today,28);
const initials=n=>String(n).split(/\s+/).slice(0,2).map(x=>x[0]).join("").toUpperCase();
const timeToMinutes=t=>{const [h,m]=String(t||"00:00").split(":").map(Number);return h*60+m};
const minutesToTime=m=>{const v=(m+1440)%1440;return `${pad(Math.floor(v/60))}:${pad(v%60)}`};
const duration=(a,b)=>Math.max(0,timeToMinutes(b)-timeToMinutes(a));
const humanDuration=m=>m<60?`${m} min`:`${Math.floor(m/60)} h ${pad(m%60)}`;
const statusInfo={planned:["Planifié","planned",""],progress:["En cours","progress","is-progress"],completed:["Terminé","completed","is-completed"],late:["À replanifier","late","is-late"]};
const status=s=>{const x=statusInfo[s]||statusInfo.planned;return`<span class="status ${x[1]}">${x[0]}</span>`};
const timeTypeInfo={intervention:["Intervention","⚒"],transport:["Transport","↗"],lunch:["Pause déjeuner","☕"],administrative:["Administratif","▤"],other:["Autre","◷"]};
const tone=n=>["#446a92","#7657c8","#28766e","#a15b38","#587337"][[...String(n)].reduce((a,c)=>a+c.charCodeAt(0),0)%5];
const collaborators=[
{id:"c1",name:"Camille Bernard",role:"Technicienne CVC",phone:"0612457801",email:"camille.bernard@omika-demo.test",color:"#7657c8"},
{id:"c2",name:"Sami El Amrani",role:"Technicien multitechnique",phone:"0678421190",email:"sami.elamrani@omika-demo.test",color:"#28766e"},
{id:"c3",name:"Inès Morel",role:"Chargée de maintenance",phone:"0622147896",email:"ines.morel@omika-demo.test",color:"#b45f2c"},
{id:"c4",name:"Hugo Lefèvre",role:"Expert électricité",phone:"0698167544",email:"hugo.lefevre@omika-demo.test",color:"#2a67b1"}
];
const clients=[
{id:"CLI-001",name:"Novalys Logistique",legal:"Novalys Logistique SAS",ref:"CL-00482",sector:"Transport & logistique",phone:"0145826710",email:"maintenance@novalys-logistique.test",address:"24 avenue des Nations, 93290 Tremblay-en-France",note:"Contrat premium. Prévenir le responsable du site 30 minutes avant l'arrivée.",contacts:[{name:"Marc Duval",role:"Responsable maintenance",phone:"0612784532",email:"marc.duval@novalys-logistique.test"},{name:"Lina Ribeiro",role:"Accueil sécurité",phone:"0145826714",email:"securite@novalys-logistique.test"}],docs:["Contrat maintenance 2026.pdf","Procédure sécurité site.pdf"]},
{id:"CLI-002",name:"Clinique Saint-Martin",legal:"Clinique Saint-Martin",ref:"CL-00318",sector:"Santé",phone:"0142769030",email:"services.techniques@clinique-stmartin.test",address:"8 rue des Tilleuls, 92100 Boulogne-Billancourt",note:"Interventions silencieuses entre 13 h et 15 h. Accès par l'entrée fournisseurs.",contacts:[{name:"Élodie Martin",role:"Responsable services techniques",phone:"0674201833",email:"elodie.martin@clinique-stmartin.test"},{name:"Poste sécurité",role:"Accès site",phone:"0142769039",email:"securite@clinique-stmartin.test"}],docs:["Plan des zones techniques.pdf","Consignes hygiène.pdf"]},
{id:"CLI-003",name:"Atelier MécaPro",legal:"MécaPro Industries SARL",ref:"CL-00504",sector:"Industrie",phone:"0139557712",email:"atelier@mecapro.test",address:"17 rue des Forges, 95100 Argenteuil",note:"Port des EPI obligatoire. Stock de pièces dans l'armoire technique B12.",contacts:[{name:"Romain Petit",role:"Chef d'atelier",phone:"0609812476",email:"romain.petit@mecapro.test"},{name:"Sofia Benali",role:"Achats",phone:"0139557721",email:"achats@mecapro.test"}],docs:["Registre équipements.xlsx","Plan atelier.pdf"]},
{id:"CLI-004",name:"Hôtel Lumière",legal:"Hôtel Lumière Opéra SAS",ref:"CL-00275",sector:"Hôtellerie",phone:"0144206100",email:"direction.technique@hotel-lumiere.test",address:"12 boulevard des Capucines, 75009 Paris",note:"Éviter les travaux bruyants avant 10 h. Parking livraison rue Caumartin.",contacts:[{name:"Julien Caron",role:"Directeur technique",phone:"0661349812",email:"julien.caron@hotel-lumiere.test"},{name:"Réception",role:"Accueil",phone:"0144206100",email:"reception@hotel-lumiere.test"}],docs:["Liste des chambres techniques.pdf","Protocole d'accès.pdf"]},
{id:"CLI-005",name:"Groupe Arcadia",legal:"Arcadia Services France",ref:"CL-00611",sector:"Services",phone:"0184302250",email:"facilities@arcadia-services.test",address:"2 place des Reflets, 92400 Courbevoie",note:"Badges visiteurs au niveau -1. Appeler le facility manager en cas de retard.",contacts:[{name:"Nora Bensaïd",role:"Facility manager",phone:"0677114520",email:"nora.bensaid@arcadia-services.test"},{name:"PC sécurité",role:"Accès tour",phone:"0184302299",email:"pcs@arcadia-services.test"}],docs:["Convention d'intervention.pdf","Plan technique niveaux.pdf"]}
];
const locations=[
{id:"LOC-001",client:"CLI-001",name:"Plateforme logistique Nord",address:"24 avenue des Nations, 93290 Tremblay-en-France",access:"Entrée poids lourds B, présenter la référence d'intervention.",note:"Local technique au quai 18. Réseau mobile faible dans la zone froide.",docs:["Plan plateforme Nord.pdf"]},
{id:"LOC-002",client:"CLI-002",name:"Bâtiment principal",address:"8 rue des Tilleuls, 92100 Boulogne-Billancourt",access:"Entrée fournisseurs, badge temporaire au poste de sécurité.",note:"Groupes de traitement d'air en toiture, accès par l'escalier T3.",docs:["Plan toiture technique.pdf"]},
{id:"LOC-003",client:"CLI-003",name:"Atelier de production",address:"17 rue des Forges, 95100 Argenteuil",access:"Portail 2, stationnement sur les places visiteurs.",note:"Consignation électrique requise avant ouverture des armoires.",docs:["Schéma électrique atelier.pdf"]},
{id:"LOC-004",client:"CLI-004",name:"Hôtel Lumière Opéra",address:"12 boulevard des Capucines, 75009 Paris",access:"Accès technique rue Caumartin, prévenir la réception.",note:"Machinerie principale au sous-sol -2.",docs:["Plan sous-sol technique.pdf"]},
{id:"LOC-005",client:"CLI-005",name:"Tour Arcadia — La Défense",address:"2 place des Reflets, 92400 Courbevoie",access:"Accueil visiteurs niveau -1, pièce d'identité obligatoire.",note:"Local CVC au 23e étage. Monte-charge sur réservation.",docs:["Plan d'accès tour.pdf"]},
{id:"LOC-006",client:"CLI-001",name:"Agence logistique Sud",address:"6 rue de la Plaine, 94550 Chevilly-Larue",access:"Portail livraison, code 8241.",note:"Intervenir avant 15 h pour éviter le pic d'activité.",docs:["Plan agence Sud.pdf"]}
];
const interventionSeeds=[
["Maintenance préventive du groupe froid","Contrôle préventif, relevés de températures et vérification des sécurités.","Effectuer le contrôle complet du groupe froid, nettoyer les filtres, relever les pressions et tester les alarmes."],
["Diagnostic défaut de ventilation","Recherche de panne sur l'unité de ventilation et remise en service.","Identifier l'origine du défaut intermittent, contrôler le variateur, les sondes et le câblage."],
["Contrôle des armoires électriques","Inspection visuelle, serrage et mesures thermiques.","Contrôler les départs électriques, les couples de serrage et relever les points chauds."],
["Remplacement d'une sonde de température","Dépose de la sonde défectueuse, pose et calibration.","Remplacer la sonde, contrôler la cohérence de mesure et valider la remontée dans la supervision."],
["Visite de conformité annuelle","Contrôle réglementaire et mise à jour du registre.","Vérifier l'état général, relever les non-conformités et mettre à jour le registre de maintenance."],
["Réglage du système de régulation","Optimisation des consignes et des plages horaires.","Ajuster les consignes, vérifier les séquences de démarrage et réduire les dérives de consommation."]
];
const standaloneAppointmentSeeds=[
["Réunion de coordination","Point d'organisation avec le responsable du site avant lancement des travaux."],
["Visite technique sans dossier","Relevé initial sur site avant la création éventuelle d'une intervention."],
["Remise de documents","Échange de documents et validation des accès avec le client."],
["Audit de site","Observation générale du site sans intervention technique associée."]
];
function fakeImage(label,a="#315a75",b="#7f9c87"){
const svg=`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 500"><defs><linearGradient id="g"><stop stop-color="${a}"/><stop offset="1" stop-color="${b}"/></linearGradient></defs><rect width="800" height="500" fill="url(#g)"/><rect x="80" y="70" width="640" height="340" rx="25" fill="rgba(255,255,255,.16)" stroke="rgba(255,255,255,.4)" stroke-width="5"/><circle cx="230" cy="240" r="72" fill="rgba(255,255,255,.22)"/><path d="M350 165h250v30H350zM350 220h190v24H350zM350 270h230v24H350zM350 320h150v24H350z" fill="rgba(255,255,255,.55)"/><text x="400" y="458" text-anchor="middle" fill="white" font-family="Arial" font-size="28" font-weight="700">${label}</text></svg>`;
return`data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
}
function buildInterventions(){
const list=[];
for(let i=0;i<36;i++){
const client=clients[i%clients.length], clientLocations=locations.filter(l=>l.client===client.id), location=clientLocations[i%clientLocations.length], seed=interventionSeeds[i%interventionSeeds.length];
const opened=add(today,-58+i*3), st=i%9===0?"late":i%5===0?"completed":i%4===0?"progress":"planned";
const reportSample=i%8===0?[{id:`RAP-${today.getFullYear()}-${String(301+i).padStart(4,"0")}`,templateId:"maintenance",title:"Rapport de maintenance préventive",createdAt:iso(add(today,-(i%12+1))),status:"Créé"}]:[];
list.push({
id:`INT-${today.getFullYear()}-${String(1101+i).padStart(4,"0")}`,
client:client.id,location:location.id,owner:collaborators[i%collaborators.length].id,status:st,priority:i%7===0?"Urgente":i%3===0?"Haute":"Normale",
title:seed[0],short:seed[1],detail:seed[2],opened:iso(opened),due:iso(add(opened,24+i%15)),
internalSummary:i%2?"Prévoir le contrôle des pièces de rechange avant déplacement.":"Le client souhaite recevoir un retour écrit après les opérations.",
notes:[{author:"Planification",date:iso(add(opened,1)),text:"Vérifier les accès, le matériel de mesure et les consignes de sécurité avant l'intervention."}],
documents:[["Bon d'intervention.pdf","PDF · 184 Ko"],["Fiche équipement.pdf","PDF · 426 Ko"]],
images:i%5===0?[{id:`img-int-${i}-1`,name:"Vue générale de l'équipement",url:fakeImage("Vue générale"),note:"État observé avant les opérations."},{id:`img-int-${i}-2`,name:"Plaque signalétique",url:fakeImage("Plaque signalétique","#6f4c86","#b06c61"),note:"Référence à conserver dans le dossier."}]:[],
reports:reportSample,
compteRendu:i%10===0?{status:"Validé",createdAt:iso(add(today,-(i%9+2))),reference:`CR-${today.getFullYear()}-${String(80+i).padStart(4,"0")}`}:null
});
}
return list;
}
const interventions=buildInterventions();
let timeEntrySeq=1;
function seededTimeEntries(st,from,to,index){
if(!["completed","progress"].includes(st))return[];
const out=[{id:`TEM-${timeEntrySeq++}`,type:"transport",start:minutesToTime(timeToMinutes(from)-35),end:from,note:"Trajet vers le site"}];
const interventionEnd=st==="progress"?minutesToTime(Math.min(timeToMinutes(to),timeToMinutes(from)+55)):to;
out.push({id:`TEM-${timeEntrySeq++}`,type:"intervention",start:from,end:interventionEnd,note:"Temps technique sur site"});
if(index%4===2)out.push({id:`TEM-${timeEntrySeq++}`,type:"lunch",start:"12:20",end:"13:00",note:"Pause déjeuner"});
return out;
}
function buildAppointments(){
const data=[];let seq=1800,linkCursor=0;const times=[["08:00","09:30"],["10:15","11:45"],["13:30","15:00"],["15:30","17:00"]],linkPool=interventions.slice(0,24);
for(let off=-14;off<=28;off++){
const d=add(today,off),day=d.getDay();if(day===0||(day===6&&off%2))continue;
const count=off===0?3:((Math.abs(off)+day)%5===0?2:1);
for(let j=0;j<count;j++){
seq++;
const standalone=(off===0&&j===2)||seq%5===0;
const linked=standalone?null:linkPool[(Math.abs(off*3+j)+linkCursor++)%linkPool.length];
const client=linked?clients.find(c=>c.id===linked.client):clients[Math.abs(off+j)%clients.length];
const clientLocations=locations.filter(l=>l.client===client.id),location=linked?locations.find(l=>l.id===linked.location):clientLocations[Math.abs(off+j)%clientLocations.length];
const collaborator=off===0?collaborators[0]:collaborators[Math.abs(off+j*2)%collaborators.length],[from,to]=times[j%times.length];
let st=off<0?"completed":"planned";if(off===0)st=["completed","progress","planned"][j];if(off>0&&off%11===0&&!j)st="late";
const standaloneSeed=standaloneAppointmentSeeds[Math.abs(off+j)%standaloneAppointmentSeeds.length];
data.push({
id:`RDV-${String(seq).padStart(5,"0")}`,interventionId:linked?.id||null,date:iso(d),from,to,client:client.id,location:location.id,collaborator:collaborator.id,status:st,
title:linked?linked.title:standaloneSeed[0],description:linked?"Rendez-vous planifié pour avancer sur l'intervention liée.":standaloneSeed[1],
timeEntries:seededTimeEntries(st,from,to,j+Math.abs(off))
});
}
}
return data.sort((a,b)=>(a.date+a.from).localeCompare(b.date+b.from));
}
const appointments=buildAppointments();
const folders=[
{id:"DOS-2026-018",title:"Modernisation de la chaîne du froid",client:"CLI-001",status:"En cours",priority:"Haute",manager:"Claire Robert",opened:iso(add(today,-34)),due:iso(add(today,24)),summary:"Programme de fiabilisation des équipements frigorifiques des plateformes Nord et Sud.",locations:["LOC-001","LOC-006"],docs:["Cahier des charges V3.pdf","Planning de déploiement.xlsx","Compte rendu de lancement.pdf"],interventions:interventions.filter(x=>x.client==="CLI-001").slice(0,7).map(x=>x.id)},
{id:"DOS-2026-024",title:"Mise en conformité ventilation bloc B",client:"CLI-002",status:"À surveiller",priority:"Urgente",manager:"Nadia Leroy",opened:iso(add(today,-18)),due:iso(add(today,11)),summary:"Levée des réserves de contrôle et réglage des débits d'air du bloc B.",locations:["LOC-002"],docs:["Rapport de contrôle initial.pdf","Plan aéraulique.pdf"],interventions:interventions.filter(x=>x.client==="CLI-002").slice(0,7).map(x=>x.id)},
{id:"DOS-2026-031",title:"Contrat maintenance multitechnique 2026",client:"CLI-005",status:"En cours",priority:"Normale",manager:"Alexandre Vial",opened:iso(add(today,-52)),due:iso(add(today,120)),summary:"Suivi annuel des installations CVC, électriques et de sécurité de la tour Arcadia.",locations:["LOC-005"],docs:["Contrat signé.pdf","Matrice des équipements.xlsx"],interventions:interventions.filter(x=>x.client==="CLI-005").slice(0,7).map(x=>x.id)}
];
const reportTemplates=[
{id:"maintenance",name:"Rapport de maintenance préventive",icon:"⚒",description:"Contrôles périodiques, mesures, pièces et recommandations.",fields:[
{type:"text",name:"equipment",label:"Équipement contrôlé",required:true,placeholder:"Ex. Groupe froid GF-02"},
{type:"text",name:"serial",label:"Numéro de série",placeholder:"Ex. SN-84A-2081"},
{type:"select",name:"condition",label:"État général",required:true,options:["Bon","Satisfaisant","Dégradé","Critique"]},
{type:"checks",name:"operations",label:"Opérations effectuées",span:true,options:["Contrôle visuel","Nettoyage","Mesures électriques","Relevé de températures","Test des sécurités","Réglage des consignes"]},
{type:"textarea",name:"measurements",label:"Mesures et relevés",span:true,placeholder:"Pressions, températures, intensités…"},
{type:"textarea",name:"recommendations",label:"Recommandations",required:true,span:true,placeholder:"Préconisations et échéances…"}
]},
{id:"diagnostic",name:"Rapport de diagnostic CVC",icon:"⌕",description:"Symptômes, tests, cause probable et solution recommandée.",fields:[
{type:"textarea",name:"symptoms",label:"Symptômes constatés",required:true,span:true,placeholder:"Décrivez les défauts observés…"},
{type:"checks",name:"tests",label:"Tests réalisés",span:true,options:["Alimentation","Régulation","Sondes","Débits","Pressions","Communication GTB"]},
{type:"select",name:"cause",label:"Cause identifiée",required:true,options:["Électrique","Mécanique","Régulation","Usage","À confirmer"]},
{type:"text",name:"faultCode",label:"Code défaut",placeholder:"Ex. E-214"},
{type:"textarea",name:"analysis",label:"Analyse technique",required:true,span:true,placeholder:"Expliquez le diagnostic…"},
{type:"textarea",name:"solution",label:"Solution proposée",required:true,span:true,placeholder:"Action immédiate et suite à prévoir…"}
]},
{id:"commissioning",name:"Rapport de mise en service",icon:"✓",description:"Paramètres de démarrage, essais et validation de fonctionnement.",fields:[
{type:"text",name:"equipment",label:"Équipement mis en service",required:true},
{type:"date",name:"commissionDate",label:"Date de mise en service",required:true},
{type:"checks",name:"tests",label:"Essais validés",span:true,options:["Mise sous tension","Sens de rotation","Sécurités","Alarmes","Régulation","Communication"]},
{type:"select",name:"result",label:"Résultat",required:true,options:["Mise en service validée","Validée avec réserves","Non validée"]},
{type:"textarea",name:"settings",label:"Réglages appliqués",span:true,placeholder:"Consignes et paramètres principaux…"},
{type:"textarea",name:"reservations",label:"Réserves ou actions restantes",span:true,placeholder:"Listez les points à traiter…"}
]},
{id:"safety",name:"Rapport de contrôle sécurité",icon:"!",description:"Vérifications de conformité, écarts et plan d'actions.",fields:[
{type:"select",name:"scope",label:"Périmètre",required:true,options:["Électricité","Incendie","Accès","Machines","Équipements sous pression"]},
{type:"checks",name:"controls",label:"Contrôles réalisés",span:true,options:["Signalisation","Protections","Arrêts d'urgence","Consignation","Documents réglementaires","EPI"]},
{type:"textarea",name:"deviations",label:"Écarts constatés",required:true,span:true},
{type:"select",name:"severity",label:"Niveau de risque",required:true,options:["Faible","Modéré","Élevé","Critique"]},
{type:"textarea",name:"actions",label:"Plan d'actions",required:true,span:true}
]}
];
const emailTemplates=[
{id:"report",name:"Envoi d'un rapport",description:"Transmettre un rapport créé avec un message contextualisé.",fields:[
{name:"contactName",label:"Nom du destinataire",required:true,placeholder:"Ex. Marc Duval"},
{name:"message",label:"Message d'accompagnement",type:"textarea",required:true,placeholder:"Précisez les éléments importants…"},
{name:"replyDate",label:"Date de retour souhaitée",type:"date"}
],build:(i,d,report)=>({subject:`Rapport ${report?.id||"d'intervention"} — ${i.id}`,body:`Bonjour ${d.contactName||""},\n\nVeuillez trouver ci-joint le rapport « ${report?.title||"rapport d'intervention"} » relatif à l'intervention ${i.id}.\n\n${d.message||""}${d.replyDate?`\n\nMerci de nous faire un retour avant le ${fmt(parse(d.replyDate),{day:"numeric",month:"long",year:"numeric"})}.`:""}\n\nCordialement,\nCamille Bernard\nOmika`})},
{id:"appointment",name:"Confirmation de rendez-vous",description:"Confirmer une date, un horaire et les conditions d'accès.",fields:[
{name:"contactName",label:"Nom du destinataire",required:true},
{name:"appointmentDate",label:"Date du rendez-vous",type:"date",required:true},
{name:"appointmentTime",label:"Heure",type:"time",required:true},
{name:"accessInfo",label:"Informations d'accès",type:"textarea",required:true}
],build:(i,d)=>({subject:`Confirmation de rendez-vous — ${i.id}`,body:`Bonjour ${d.contactName||""},\n\nNous vous confirmons le rendez-vous lié à l'intervention ${i.id} le ${d.appointmentDate?fmt(parse(d.appointmentDate),{weekday:"long",day:"numeric",month:"long",year:"numeric"}):""} à ${d.appointmentTime||""}.\n\nAccès et préparation : ${d.accessInfo||""}\n\nCordialement,\nCamille Bernard\nOmika`})},
{id:"followup",name:"Demande d'informations",description:"Demander des compléments avant la poursuite d'une intervention.",fields:[
{name:"contactName",label:"Nom du destinataire",required:true},
{name:"requestedItems",label:"Informations demandées",type:"textarea",required:true},
{name:"deadline",label:"Échéance souhaitée",type:"date",required:true}
],build:(i,d)=>({subject:`Informations requises — intervention ${i.id}`,body:`Bonjour ${d.contactName||""},\n\nAfin de poursuivre l'intervention ${i.id}, nous avons besoin des éléments suivants :\n${d.requestedItems||""}\n\nPouvez-vous nous les transmettre avant le ${d.deadline?fmt(parse(d.deadline),{day:"numeric",month:"long",year:"numeric"}):""} ?\n\nMerci par avance.\n\nCordialement,\nCamille Bernard\nOmika`})}
];
const getClient=x=>clients.find(c=>c.id===x), getLocation=x=>locations.find(l=>l.id===x), getCollab=x=>collaborators.find(c=>c.id===x), getAppt=x=>appointments.find(a=>a.id===x), getIntervention=x=>interventions.find(i=>i.id===x);
const appointmentsFor=i=>appointments.filter(a=>a.interventionId===i.id).sort((a,b)=>(a.date+a.from).localeCompare(b.date+b.from));
const storage=(()=>{const memory=new Map();try{const key="__omika_storage_test__";localStorage.setItem(key,"1");localStorage.removeItem(key);return localStorage}catch(_){return{getItem:key=>memory.has(key)?memory.get(key):null,setItem:(key,value)=>memory.set(key,String(value)),removeItem:key=>memory.delete(key)}}})();
const firstToday=appointments.find(a=>a.date===iso(today)&&a.collaborator==="c1")||appointments[0];
const state={
screen:"home",origin:"home",navStack:[],date:today,agendaView:"day",appointment:firstToday.id,intervention:firstToday.interventionId||interventions[0].id,resource:"notes",
reportTemplate:reportTemplates[0].id,emailMode:"direct",emailReport:null,teamCollab:"all",teamView:"list",teamWeek:weekStart(today),folder:folders[0].id,client:clients[0].id,location:locations[0].id,search:{relation:"all"},
theme:storage.getItem("omika-theme")||"light",primary:storage.getItem("omika-primary")||"#4f46e5",syncing:false,lastSync:new Date(Date.now()-240000),signatures:{tech:false,client:false}
};
/* =========================================================
OUTILS D'INTERFACE, THÈME, ROUTAGE ET SYNCHRONISATION
========================================================= */
function mix(hex,target,ratio){const p=v=>v.replace("#","").match(/../g).map(x=>parseInt(x,16)),a=p(hex),b=p(target),m=(x,y)=>Math.round(x+(y-x)*ratio).toString(16).padStart(2,"0");return`#${m(a[0],b[0])}${m(a[1],b[1])}${m(a[2],b[2])}`}
function contrast(hex){const [r,g,b]=hex.replace("#","").match(/../g).map(x=>parseInt(x,16));return(.299*r+.587*g+.114*b)>155?"#172033":"#ffffff"}
function applyTheme(theme=state.theme,primary=state.primary){state.theme=theme;state.primary=primary;document.body.dataset.theme=theme;const root=document.documentElement;root.style.setProperty("--primary",primary);root.style.setProperty("--primary-dark",mix(primary,theme==="dark"?"#ffffff":"#000000",theme==="dark"?.18:.28));root.style.setProperty("--primary-soft",mix(primary,theme==="dark"?"#141d2b":"#ffffff",theme==="dark"?.78:.9));root.style.setProperty("--on-primary",contrast(primary));id("themeMeta").content=primary;id("themeTop").textContent=theme==="dark"?"☀":"☾"}
function toast(title,message,type="info"){const el=document.createElement("div");el.className="toast";el.innerHTML=`<div class="row-icon">${type==="ok"?"✓":type==="error"?"!":"i"}</div><div><strong>${esc(title)}</strong><p>${esc(message)}</p></div>`;id("toasts").append(el);setTimeout(()=>{el.style.opacity=0;setTimeout(()=>el.remove(),200)},3300)}
function hashFor(screen,param){return param?`#${screen}/${encodeURIComponent(param)}`:`#${screen}`}
function navigate(screen,param,{push=true,clear=false}={}){closeSheet();if(clear)state.navStack=[];if(push)state.navStack.push(location.hash||"#home");const h=hashFor(screen,param);if(location.hash===h)render();else location.hash=h}
function goBack(){const h=state.navStack.pop()||`#${state.origin||"home"}`;if(location.hash===h)render();else location.hash=h}
function parseRoute(){const raw=location.hash.replace(/^#/,"")||"home",p=raw.indexOf("/");return{screen:p<0?raw:raw.slice(0,p),param:p<0?null:decodeURIComponent(raw.slice(p+1))}}
function compound(param){return String(param||"").split("|")}
function openAction(type,value){if(type==="gps"){window.open(`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(value)}`,"_blank","noopener");toast("Itinéraire préparé",value)}if(type==="phone")location.href=`tel:${String(value).replace(/\s/g,"")}`;if(type==="email")location.href=`mailto:${value}`}
function titles(s){return({home:"Mes rendez-vous",appointment:"Détail du rendez-vous",intervention:"Intervention",resource:"Contenus associés",compte:"Compte rendu",reports:"Rapports",reportform:"Nouveau rapport",email:"Créer un email",team:"Agenda de l'équipe",search:"Recherche d'interventions",folders:"Dossiers",clients:"Clients",locations:"Lieux",settings:"Paramètres"})[s]||"Omika"}
function activeNav(){const rootScreens=["home","team","search","folders","clients","locations","settings"],active=rootScreens.includes(state.screen)?state.screen:state.origin;$$("#sideNav [data-route],#mobileNav [data-route]").forEach(b=>{const yes=b.dataset.route===active;b.classList.toggle("active",yes);yes?b.setAttribute("aria-current","page"):b.removeAttribute("aria-current")})}
function showSync(){const txt=state.syncing?"Synchronisation…":`Synchronisé à ${fmt(state.lastSync,{hour:"2-digit",minute:"2-digit"})}`;id("railSyncTitle").textContent=state.syncing?"Synchronisation…":"Données à jour";id("railSyncTime").textContent=txt;["syncTop","syncMobile"].forEach(x=>{const b=id(x);if(b)b.disabled=state.syncing})}
function syncNow(){if(state.syncing)return;closeSheet();state.syncing=true;showSync();render();toast("Synchronisation lancée","Rendez-vous, interventions, rapports, notes et médias sont en cours de mise à jour.");setTimeout(()=>{state.syncing=false;state.lastSync=new Date();showSync();render();toast("Synchronisation terminée","Toutes les données Omika sont à jour.","ok")},1800)}
function avatar(person,size=""){return`<div class="avatar ${size}" style="background:${person.color||tone(person.name)}">${initials(person.name)}</div>`}
function empty(icon,title,text,action=""){return`<div class="empty"><div class="empty-icon">${icon}</div><h3>${esc(title)}</h3><p>${esc(text)}</p>${action}</div>`}
function phoneLabel(v){return String(v).replace(/(\d{2})(?=\d)/g,"$1 ")}
function appointmentTimeTotal(a){return a.timeEntries.reduce((n,x)=>n+duration(x.start,x.end),0)}
function appointmentRelationBadge(a){return a.interventionId?`<span class="badge primary mono">${a.interventionId}</span>`:`<span class="badge">Sans intervention</span>`}
/* =========================================================
COMPOSANTS : RENDEZ-VOUS ET INTERVENTIONS
========================================================= */
function apptCard(a,showCollab=false){const c=getClient(a.client),l=getLocation(a.location),p=getCollab(a.collaborator),s=statusInfo[a.status];return`<article class="appt ${s[2]}" data-open-appointment="${a.id}" tabindex="0" role="button">
<div class="appt-time"><strong>${a.from}</strong><span>${a.to}</span></div>
<div><div class="inline">${status(a.status)}<span class="badge mono">${a.id}</span>${appointmentRelationBadge(a)}</div><h3>${esc(c.name)} · ${esc(l.name)}</h3><p>${esc(a.description)}</p><div class="meta"><span>⌖ ${esc(l.address)}</span>${showCollab?`<span>◎ ${esc(p.name)}</span>`:""}<span>◷ ${humanDuration(duration(a.from,a.to))}</span></div></div>
<div class="appt-action"><button class="btn btn-soft btn-sm" data-open-appointment="${a.id}">Ouvrir le rendez-vous ›</button></div></article>`}
function interventionCard(i){const c=getClient(i.client),l=getLocation(i.location),apps=appointmentsFor(i),next=apps.find(a=>a.date>=iso(today));return`<article class="card result intervention-result"><div><div class="inline">${status(i.status)}<span class="badge mono">${i.id}</span><span class="badge">${apps.length} rendez-vous</span></div><h3>${esc(i.title)}</h3><p>${esc(c.name)} · ${esc(l.name)}<br>${esc(l.address)}</p><div class="meta"><span>◎ ${esc(getCollab(i.owner).name)}</span><span>${next?`▣ Prochain RDV : ${fmt(parse(next.date),{day:"numeric",month:"short"})} à ${next.from}`:"▣ Aucun rendez-vous planifié"}</span></div></div><button class="btn btn-soft" data-open-intervention="${i.id}">Ouvrir l'intervention ›</button></article>`}
/* =========================================================
ÉCRAN — MES RENDEZ-VOUS
========================================================= */
function homeScreen(){const mine=appointments.filter(a=>a.collaborator==="c1"),todayList=mine.filter(a=>a.date===iso(today)),done=todayList.filter(a=>a.status==="completed").length,week=mine.filter(a=>parse(a.date)>=weekStart(today)&&parse(a.date)<=add(weekStart(today),6));id("navCount").textContent=todayList.length;return`<section class="screen">
<div class="welcome"><div><span class="badge primary">● Mode connecté</span><h1>Bonjour Camille</h1><p>Votre agenda Omika couvre du ${fmt(rangeStart,{day:"numeric",month:"long"})} au ${fmt(rangeEnd,{day:"numeric",month:"long",year:"numeric"})}. Un rendez-vous peut être autonome ou lié à une intervention.</p><div class="actions"><button class="btn btn-primary" data-next-appointment>▶ Ouvrir le prochain rendez-vous</button><button class="btn btn-secondary" data-route="search">⌕ Rechercher une intervention</button></div></div>
<div class="summary-box"><span class="small muted">Progression de la journée</span><strong>${done} / ${todayList.length}</strong><span class="small muted">${todayList.filter(a=>a.interventionId).length} lié(s) · ${todayList.filter(a=>!a.interventionId).length} autonome(s)</span><div class="progressbar"><span style="width:${todayList.length?Math.round(done/todayList.length*100):0}%"></span></div></div></div>
<div class="grid-4" style="margin-bottom:18px"><div class="card stat"><div class="stat-icon">▣</div><div><strong>${todayList.length}</strong><span>rendez-vous aujourd'hui</span></div></div><div class="card stat"><div class="stat-icon">◷</div><div><strong>${humanDuration(todayList.reduce((n,a)=>n+appointmentTimeTotal(a),0))}</strong><span>temps saisi aujourd'hui</span></div></div><div class="card stat"><div class="stat-icon">✓</div><div><strong>${week.filter(a=>a.status==="completed").length}</strong><span>rendez-vous réalisés</span></div></div><div class="card stat"><div class="stat-icon">◇</div><div><strong>${todayList.filter(a=>!a.interventionId).length}</strong><span>sans intervention liée</span></div></div></div>
<div class="card agenda"><div class="agenda-tools"><div class="tool-group"><div class="segmented"><button data-agenda="day" class="${state.agendaView==="day"?"active":""}">Jour</button><button data-agenda="week" class="${state.agendaView==="week"?"active":""}">Semaine</button></div><button class="btn btn-secondary btn-sm" data-today>Aujourd'hui</button></div><div class="tool-group"><button class="icon-btn" data-period="-1">‹</button><div class="period">${agendaPeriod()}</div><button class="icon-btn" data-period="1">›</button><input type="date" id="jumpDate" min="${iso(rangeStart)}" max="${iso(rangeEnd)}" value="${iso(state.date)}" style="width:44px;padding:4px;color:transparent"></div></div>${dateStrip()}${agendaBody()}</div></section>`}
function agendaPeriod(){if(state.agendaView==="day")return(iso(state.date)===iso(today)?"Aujourd'hui · ":"")+fmt(state.date,{weekday:"long",day:"numeric",month:"long"});const a=weekStart(state.date),b=add(a,6);return`Du ${fmt(a,{day:"numeric",month:"short"})} au ${fmt(b,{day:"numeric",month:"short",year:"numeric"})}`}
function dateStrip(){const first=weekStart(state.date),mine=appointments.filter(a=>a.collaborator==="c1");return`<div class="date-strip">${Array.from({length:7},(_,i)=>add(first,i)).map(d=>{const n=mine.filter(a=>a.date===iso(d)).length,out=d<rangeStart||d>rangeEnd;return`<button class="date-chip ${iso(d)===iso(state.date)?"selected":""}" data-date="${iso(d)}" ${out?"disabled":""}><span>${fmt(d,{weekday:"short"}).replace(".","")}</span><strong>${d.getDate()}</strong><small>${n?n+" rdv":"—"}</small></button>`}).join("")}</div>`}
function agendaBody(){const mine=appointments.filter(a=>a.collaborator==="c1");if(state.agendaView==="day"){const list=mine.filter(x=>x.date===iso(state.date));return list.length?`<div class="list">${list.map(x=>apptCard(x)).join("")}</div>`:empty("▣","Aucun rendez-vous","Sélectionnez une autre date dans la période disponible.")}const first=weekStart(state.date);return Array.from({length:7},(_,i)=>add(first,i)).map(d=>{const list=mine.filter(x=>x.date===iso(d));return`<section class="day-group"><div class="day-title"><span>${fmt(d,{weekday:"long",day:"numeric",month:"long"})}</span><span>${list.length} rendez-vous</span></div>${list.length?`<div class="list">${list.map(x=>apptCard(x)).join("")}</div>`:`<div style="padding:14px 16px" class="small muted">Aucun rendez-vous planifié.</div>`}</section>`}).join("")}
/* =========================================================
ÉCRAN — DÉTAIL D'UN RENDEZ-VOUS
========================================================= */
function timeEntriesHTML(a){return a.timeEntries.length?a.timeEntries.map(x=>{const info=timeTypeInfo[x.type]||timeTypeInfo.other;return`<div class="row time-entry"><div class="row-icon">${info[1]}</div><div class="row-main"><strong>${esc(info[0])} · ${x.start}–${x.end}</strong><span>${humanDuration(duration(x.start,x.end))}${x.note?` · ${esc(x.note)}`:""}</span></div><button class="btn btn-ghost btn-sm" data-delete-time="${x.id}">Supprimer</button></div>`}).join(""):empty("◷","Aucun temps saisi","Ajoutez une plage avec son type, son heure de début et son heure de fin.")}
function appointmentScreen(a){const c=getClient(a.client),l=getLocation(a.location),p=getCollab(a.collaborator),i=a.interventionId?getIntervention(a.interventionId):null;return`<section class="screen"><div class="back"><button class="icon-btn" data-back>←</button><span class="small muted">Retour</span></div>
<div class="hero"><div class="hero-top"><div><span class="badge mono" style="background:rgba(255,255,255,.16);color:inherit">${a.id}</span><h1>${esc(a.title)}</h1><p>${esc(c.name)} · ${esc(l.name)} · ${fmt(parse(a.date),{weekday:"long",day:"numeric",month:"long",year:"numeric"})}, ${a.from}–${a.to}</p></div>${status(a.status)}</div><div class="hero-actions"><button class="btn" data-gps="${esc(l.address)}">⌖ GPS</button><button class="btn" data-phone="${c.contacts[0].phone}">☎ Appeler</button><button class="btn" data-email-address="${c.contacts[0].email}">✉ Email direct</button>${i?`<button class="btn main" data-open-intervention="${i.id}">⚒ Ouvrir l'intervention</button>`:""}</div></div>
<div class="detail-layout"><div class="stack-lg"><section class="card"><div class="card-head"><div><h2>Informations du rendez-vous</h2><p>Le rendez-vous reste un objet distinct de l'intervention</p></div><span>▣</span></div><div class="card-body stack-lg"><div class="grid-2"><div class="info"><span>Numéro de rendez-vous</span><strong class="mono">${a.id}</strong></div><div class="info"><span>Intervention liée</span><strong>${i?`<span class="mono">${i.id}</span>`:"Aucune"}</strong></div><div class="info"><span>Date</span><strong>${fmt(parse(a.date),{weekday:"long",day:"numeric",month:"long",year:"numeric"})}</strong></div><div class="info"><span>Créneau planifié</span><strong>${a.from}–${a.to} · ${humanDuration(duration(a.from,a.to))}</strong></div><div class="info"><span>Collaborateur</span><strong>${esc(p.name)}</strong></div><div class="info"><span>Temps saisi</span><strong>${humanDuration(appointmentTimeTotal(a))}</strong></div></div><div class="text-box"><h4>Description du rendez-vous</h4><p>${esc(a.description)}</p></div>${i?`<div class="relation-callout"><div><span class="badge primary">Intervention liée</span><h3>${esc(i.title)}</h3><p>${esc(i.short)}</p></div><button class="btn btn-primary" data-open-intervention="${i.id}">Ouvrir ${i.id}</button></div>`:`<div class="relation-callout neutral"><div><span class="badge">Rendez-vous autonome</span><h3>Aucune intervention associée</h3><p>Ce rendez-vous peut exister seul. Il ne sera pas retourné comme résultat dans la recherche d'interventions.</p></div></div>`}</div></section>
<section class="card"><div class="card-head"><div><h2>Saisie des temps</h2><p>Début et fin obligatoires, sans chronomètre</p></div><span class="badge primary">Total : ${humanDuration(appointmentTimeTotal(a))}</span></div><div class="card-body stack-lg"><div class="stack" id="timeEntries">${timeEntriesHTML(a)}</div><form id="timeForm" class="form-grid"><div class="field"><label class="required">Type de temps</label><select name="type" required>${Object.entries(timeTypeInfo).map(([k,v])=>`<option value="${k}">${v[0]}</option>`).join("")}</select></div><div class="field"><label class="required">Heure de début</label><input type="time" name="start" required value="${a.from}"></div><div class="field"><label class="required">Heure de fin</label><input type="time" name="end" required value="${a.to}"></div><div class="field span-2"><label>Commentaire</label><input name="note" placeholder="Ex. Trajet aller, intervention sur site…"></div><div class="actions" style="align-items:end"><button class="btn btn-primary">+ Ajouter le temps</button></div></form></div></section></div>
<aside class="detail-side"><section class="card" style="overflow:hidden"><div class="map"><div class="pin"><span>⌖</span></div></div><div class="card-body stack"><div><span class="badge primary">Lieu du rendez-vous</span><h2 style="margin:9px 0 4px;font-size:17px">${esc(l.name)}</h2><p class="small muted" style="margin:0;line-height:1.5">${esc(l.address)}</p></div><p class="small faint">Accès : ${esc(l.access)}</p><button class="btn btn-primary btn-block" data-gps="${esc(l.address)}">⌖ Lancer l'itinéraire</button><button class="btn btn-secondary btn-block" data-open-location="${l.id}">Voir la fiche du lieu</button></div></section><section class="card"><div class="card-head"><div><h2>Contacts</h2><p>Contacts associés au client</p></div><span>◎</span></div><div class="card-body stack">${c.contacts.map(x=>`<div class="row">${avatar({...x,color:tone(x.name)})}<div class="row-main"><strong>${esc(x.name)}</strong><span>${esc(x.role)} · ${phoneLabel(x.phone)}</span></div><button class="icon-btn" data-phone="${x.phone}">☎</button></div>`).join("")}</div></section></aside></div></section>`}
/* =========================================================
ÉCRAN — DÉTAIL D'UNE INTERVENTION
========================================================= */
function reportsOutputHTML(i){const rows=[];if(i.compteRendu)rows.push(`<div class="row"><div class="row-icon">✓</div><div class="row-main"><strong>Compte rendu fixe · ${esc(i.compteRendu.reference)}</strong><span>Validé le ${fmt(parse(i.compteRendu.createdAt),{day:"numeric",month:"long",year:"numeric"})}</span></div><button class="btn btn-soft btn-sm" data-open-compte="${i.id}">Ouvrir</button></div>`);rows.push(...i.reports.map(r=>`<div class="row"><div class="row-icon">▤</div><div class="row-main"><strong>${esc(r.title)}</strong><span>${r.id} · créé le ${fmt(parse(r.createdAt),{day:"numeric",month:"short",year:"numeric"})}</span></div><button class="btn btn-soft btn-sm" data-open-email-context="${i.id}|report|${r.id}">Email</button></div>`));return rows.length?rows.join(""):empty("▧","Aucun document de sortie","Créez le compte rendu fixe ou un rapport à partir d'un modèle.")}
function interventionScreen(i){const c=getClient(i.client),l=getLocation(i.location),p=getCollab(i.owner),apps=appointmentsFor(i);return`<section class="screen"><div class="back"><button class="icon-btn" data-back>←</button><span class="small muted">Retour</span></div>
<div class="hero"><div class="hero-top"><div><span class="badge mono" style="background:rgba(255,255,255,.16);color:inherit">${i.id}</span><h1>${esc(i.title)}</h1><p>${esc(c.name)} · ${esc(l.name)} · ${apps.length} rendez-vous lié${apps.length>1?"s":""}</p></div>${status(i.status)}</div><div class="hero-actions"><button class="btn" data-gps="${esc(l.address)}">⌖ GPS</button><button class="btn" data-phone="${c.contacts[0].phone}">☎ Appeler</button><button class="btn" data-open-email-context="${i.id}|direct|">✉ Email</button><button class="btn" data-open-reports="${i.id}">▤ Rapports</button><button class="btn main" data-open-compte="${i.id}">▧ Compte rendu fixe</button></div></div>
<div class="detail-layout"><div class="stack-lg"><section class="card"><div class="card-head"><div><h2>Informations de l'intervention</h2><p>Cette fiche reste indépendante des rendez-vous</p></div><span>⚒</span></div><div class="card-body stack-lg"><div class="grid-2"><div class="info"><span>N° d'intervention</span><strong class="mono">${i.id}</strong></div><div class="info"><span>Client</span><strong>${esc(c.name)}</strong></div><div class="info"><span>Lieu principal</span><strong>${esc(l.name)}</strong></div><div class="info"><span>Responsable</span><strong>${esc(p.name)}</strong></div><div class="info"><span>Priorité</span><strong>${esc(i.priority)}</strong></div><div class="info"><span>Ouverture</span><strong>${fmt(parse(i.opened),{day:"numeric",month:"long",year:"numeric"})}</strong></div></div><div class="text-box"><h4>Description de l'intervention</h4><p>${esc(i.detail)}</p></div><div class="text-box"><h4>Note de pilotage</h4><p>${esc(i.internalSummary)}</p></div></div></section>
<section class="card"><div class="card-head"><div><h2>Rendez-vous liés</h2><p>Une intervention peut avoir zéro, un ou plusieurs rendez-vous</p></div><button class="btn btn-primary btn-sm" data-add-appointment="${i.id}">+ Planifier</button></div>${apps.length?`<div class="list">${apps.map(a=>apptCard(a,true)).join("")}</div>`:empty("▣","Aucun rendez-vous lié","Cette intervention existe sans rendez-vous planifié.",`<button class="btn btn-primary" data-add-appointment="${i.id}">+ Planifier un rendez-vous</button>`)}</section>
<section class="card"><div class="card-head"><div><h2>Notes, images et documents</h2><p>Chaque contenu s'ouvre dans un écran dédié, adapté aux petits mobiles</p></div><span>◇</span></div><div class="card-body"><div class="resource-grid"><button class="resource-card" data-resource-kind="${i.id}|notes"><span class="resource-icon">✎</span><strong>Notes internes</strong><span>${i.notes.length} note${i.notes.length>1?"s":""}</span><b>Ouvrir ›</b></button><button class="resource-card" data-resource-kind="${i.id}|images"><span class="resource-icon">▧</span><strong>Images</strong><span>${i.images.length} image${i.images.length>1?"s":""}</span><b>Ouvrir ›</b></button><button class="resource-card" data-resource-kind="${i.id}|documents"><span class="resource-icon">▤</span><strong>Documents</strong><span>${i.documents.length} document${i.documents.length>1?"s":""}</span><b>Ouvrir ›</b></button></div></div></section>
<section class="card"><div class="card-head"><div><h2>Compte rendu et rapports créés</h2><p>Le compte rendu est fixe ; les rapports utilisent des modèles</p></div><div class="actions"><button class="btn btn-secondary btn-sm" data-open-reports="${i.id}">Voir les modèles</button></div></div><div class="card-body stack">${reportsOutputHTML(i)}</div></section></div>
<aside class="detail-side"><section class="card" style="overflow:hidden"><div class="map"><div class="pin"><span>⌖</span></div></div><div class="card-body stack"><div><span class="badge primary">Lieu principal</span><h2 style="margin:9px 0 4px;font-size:17px">${esc(l.name)}</h2><p class="small muted" style="margin:0;line-height:1.5">${esc(l.address)}</p></div><p class="small faint">Accès : ${esc(l.access)}</p><button class="btn btn-primary btn-block" data-gps="${esc(l.address)}">⌖ Lancer l'itinéraire</button><button class="btn btn-secondary btn-block" data-open-location="${l.id}">Voir la fiche du lieu</button></div></section><section class="card"><div class="card-head"><div><h2>Contacts</h2><p>Rôles dans l'intervention</p></div><span>◎</span></div><div class="card-body stack">${c.contacts.map(x=>`<div class="row">${avatar({...x,color:tone(x.name)})}<div class="row-main"><strong>${esc(x.name)}</strong><span>${esc(x.role)} · ${phoneLabel(x.phone)}</span></div><button class="icon-btn" data-phone="${x.phone}">☎</button></div>`).join("")}</div></section></aside></div></section>`}
/* =========================================================
ÉCRAN — NOTES, IMAGES OU DOCUMENTS D'UNE INTERVENTION
========================================================= */
function notesHTML(i){return i.notes.length?i.notes.map(n=>`<div class="row"><div class="row-icon">✎</div><div class="row-main"><strong>${esc(n.author)} · ${fmt(parse(n.date),{day:"numeric",month:"short",year:"numeric"})}</strong><span>${esc(n.text)}</span></div></div>`).join(""):empty("✎","Aucune note","Ajoutez une note interne à cette intervention.")}
function photosHTML(i){return i.images.length?i.images.map(x=>`<article class="photo"><img src="${x.url}" alt="${esc(x.name)}"><div class="photo-body"><div class="cluster"><strong>${esc(x.name)}</strong><button class="btn btn-ghost btn-sm" data-delete-image="${x.id}">Supprimer</button></div><input data-photo-note="${x.id}" value="${esc(x.note)}" placeholder="Ajouter une note"><button class="btn btn-secondary btn-sm" data-save-photo="${x.id}">✓ Enregistrer la note</button></div></article>`).join(""):empty("▧","Aucune image","Ajoutez une photo de l'équipement, d'une anomalie ou d'une pièce remplacée.")}
function documentsHTML(i){return i.documents.length?i.documents.map(d=>`<div class="row"><div class="row-icon">▤</div><div class="row-main"><strong>${esc(d[0])}</strong><span>${esc(d[1])}</span></div><button class="icon-btn" data-document="${esc(d[0])}">◉</button></div>`).join(""):empty("▤","Aucun document","Ajoutez un document associé à cette intervention.")}
function resourceScreen(i,kind){const labels={notes:["Notes internes","✎"],images:["Images associées","▧"],documents:["Documents associés","▤"]},label=labels[kind]||labels.notes;let content="";if(kind==="notes")content=`<div class="stack-lg"><div class="stack" id="resourceNotes">${notesHTML(i)}</div><form id="resourceNoteForm" class="stack"><div class="field"><label>Ajouter une note interne</label><textarea name="note" required placeholder="Observation, consigne, pièce à commander…"></textarea></div><div><button class="btn btn-primary">+ Ajouter la note</button></div></form></div>`;if(kind==="images")content=`<div class="stack-lg"><div class="photos" id="resourcePhotos">${photosHTML(i)}</div><label class="drop" for="resourceImageInput"><strong>▧ Ajouter des images</strong><span class="small">Touchez ici ou déposez des fichiers JPG/PNG</span></label><input class="hidden" type="file" id="resourceImageInput" accept="image/*" multiple></div>`;if(kind==="documents")content=`<div class="stack-lg"><div class="stack" id="resourceDocuments">${documentsHTML(i)}</div><label class="drop" for="resourceDocumentInput"><strong>▤ Ajouter des documents</strong><span class="small">PDF, DOCX, XLSX ou autre fichier de démonstration</span></label><input class="hidden" type="file" id="resourceDocumentInput" multiple></div>`;return`<section class="screen"><div class="back"><button class="icon-btn" data-back>←</button><span class="small muted">Retour à l'intervention</span></div><div class="page-head"><div><span class="badge primary mono">${i.id}</span><h1>${label[0]}</h1><p>${esc(i.title)}</p></div></div><section class="card"><div class="tabs resource-nav"><button class="tab ${kind==="notes"?"active":""}" data-resource-kind="${i.id}|notes">Notes</button><button class="tab ${kind==="images"?"active":""}" data-resource-kind="${i.id}|images">Images</button><button class="tab ${kind==="documents"?"active":""}" data-resource-kind="${i.id}|documents">Documents</button></div><div class="card-body">${content}</div></section></section>`}
/* =========================================================
ÉCRAN — COMPTE RENDU FIXE AVEC DEUX SIGNATURES OBLIGATOIRES
========================================================= */
function compteRenduScreen(i){const c=getClient(i.client),l=getLocation(i.location),p=getCollab(i.owner),apps=appointmentsFor(i),latest=apps.at(-1);return`<section class="screen"><div class="back"><button class="icon-btn" data-back>←</button><span class="small muted">Retour à l'intervention</span></div><div class="page-head"><div><span class="badge primary">▧ Formulaire fixe Omika</span><h1>Compte rendu d'intervention</h1><p>${i.id} · ${esc(c.name)} · structure non configurable</p></div></div>
<form id="compteRenduForm"><div class="report-layout"><div class="stack-lg"><section class="card fixed-document"><div class="fixed-banner"><div><strong>COMPTE RENDU D'INTERVENTION</strong><span>Référence de formulaire : OMIKA-CR-01</span></div><span class="badge">Formulaire fixe</span></div><div class="dynamic"><div class="paper-meta"><div><span>Intervention</span><strong class="mono">${i.id}</strong></div><div><span>Client</span><strong>${esc(c.name)}</strong></div><div><span>Lieu</span><strong>${esc(l.name)}</strong></div><div><span>Technicien</span><strong>${esc(p.name)}</strong></div></div>
<div class="form-section"><div class="form-section-title"><span>1</span><div><strong>Informations générales</strong><small>Cadre fixe du compte rendu</small></div></div><div class="form-grid"><div class="field"><label class="required">Date d'intervention</label><input type="date" name="date" required value="${latest?.date||iso(today)}"></div><div class="field"><label class="required">Contact client présent</label><input name="clientContact" required placeholder="Nom et fonction"></div><div class="field"><label class="required">Heure d'arrivée</label><input type="time" name="arrival" required value="${latest?.from||"08:00"}"></div><div class="field"><label class="required">Heure de départ</label><input type="time" name="departure" required value="${latest?.to||"10:00"}"></div><div class="field span-2"><label class="required">Équipement ou zone concernée</label><input name="equipment" required placeholder="Ex. Groupe froid GF-02"></div></div></div>
<div class="form-section"><div class="form-section-title"><span>2</span><div><strong>Demande et travaux réalisés</strong><small>Champs toujours présents</small></div></div><div class="form-grid"><div class="field span-2"><label class="required">Demande initiale / anomalie signalée</label><textarea name="request" required placeholder="Décrivez la demande du client…"></textarea></div><div class="field span-2"><label class="required">Travaux et opérations réalisés</label><textarea name="work" required placeholder="Décrivez précisément les actions menées…"></textarea></div><div class="field"><label>Pièces ou consommables utilisés</label><input name="parts" placeholder="Références ou « Aucune »"></div><div class="field"><label class="required">Résultat de l'intervention</label><select name="result" required><option value="">Sélectionner…</option><option>Résolu</option><option>Résolu avec réserve</option><option>Non résolu</option><option>Intervention à poursuivre</option></select></div></div></div>
<div class="form-section"><div class="form-section-title"><span>3</span><div><strong>Contrôles et recommandations</strong><small>Bloc fixe de validation technique</small></div></div><div class="checks" style="margin-bottom:14px"><label class="check"><input type="checkbox" name="controls" value="Essai fonctionnel"><span>Essai fonctionnel réalisé</span></label><label class="check"><input type="checkbox" name="controls" value="Sécurités"><span>Sécurités contrôlées</span></label><label class="check"><input type="checkbox" name="controls" value="Nettoyage"><span>Zone nettoyée</span></label><label class="check"><input type="checkbox" name="controls" value="Explications"><span>Explications données au client</span></label></div><div class="form-grid"><div class="field span-2"><label>Recommandations et suite à prévoir</label><textarea name="recommendations" placeholder="Préconisations, réserves, nouvelle intervention…"></textarea></div><div class="field span-2"><label>Observations du client</label><textarea name="clientComments" placeholder="Commentaires éventuels du client…"></textarea></div><label class="check span-2"><input type="checkbox" name="followup"><span>Une intervention complémentaire doit être planifiée.</span></label></div></div></div></section>
<section class="card dynamic"><h2>Signatures obligatoires</h2><p>Le compte rendu ne peut pas être validé sans la signature du technicien et celle du client.</p><div class="signatures"><div class="signature"><div class="signature-head"><span>Signature technicien *</span><button type="button" class="btn btn-ghost btn-sm" data-clear-sign="tech">Effacer</button></div><canvas id="crSignTech" width="720" height="240"></canvas></div><div class="signature"><div class="signature-head"><span>Signature client *</span><button type="button" class="btn btn-ghost btn-sm" data-clear-sign="client">Effacer</button></div><canvas id="crSignClient" width="720" height="240"></canvas></div></div></section></div>
<aside class="card report-side"><div class="card-head"><div><h2>Validation</h2><p>Compte rendu fixe</p></div><span>◉</span></div><div class="card-body stack-lg"><div class="stack"><div class="cluster"><span class="small faint">Intervention</span><strong class="small mono">${i.id}</strong></div><div class="cluster"><span class="small faint">Client</span><strong class="small">${esc(c.name)}</strong></div><div class="cluster"><span class="small faint">Lieu</span><strong class="small">${esc(l.name)}</strong></div><div class="cluster"><span class="small faint">Statut actuel</span><strong class="small">${i.compteRendu?esc(i.compteRendu.status):"Non créé"}</strong></div></div><label class="check"><input type="checkbox" name="confirm" required><span>Je confirme l'exactitude des informations saisies.</span></label><button type="button" class="btn btn-secondary btn-block" data-save-compte>Enregistrer le brouillon</button><button class="btn btn-primary btn-block">✓ Valider le compte rendu</button></div></aside></div></form></section>`}
/* =========================================================
ÉCRANS — MODÈLES DE RAPPORTS ET FORMULAIRE DYNAMIQUE
========================================================= */
function dynamicField(f){const req=f.required?"required":"",cl=f.span?"span-2":"",value=f.type==="date"?iso(today):"";if(["text","date","time","number"].includes(f.type))return`<div class="field ${cl}"><label class="${req}">${esc(f.label)}</label><input type="${f.type}" name="${f.name}" ${req} value="${value}" placeholder="${esc(f.placeholder||"")}"></div>`;if(f.type==="select")return`<div class="field ${cl}"><label class="${req}">${esc(f.label)}</label><select name="${f.name}" ${req}><option value="">Sélectionner…</option>${f.options.map(x=>`<option>${esc(x)}</option>`).join("")}</select></div>`;if(f.type==="textarea")return`<div class="field ${cl}"><label class="${req}">${esc(f.label)}</label><textarea name="${f.name}" ${req} placeholder="${esc(f.placeholder||"")}"></textarea></div>`;if(f.type==="checks")return`<fieldset class="${cl}" style="border:0;padding:0;margin:0"><legend class="field-title" style="margin-bottom:8px">${esc(f.label)}</legend><div class="checks">${f.options.map(x=>`<label class="check"><input type="checkbox" name="${f.name}" value="${esc(x)}"><span>${esc(x)}</span></label>`).join("")}</div></fieldset>`;return""}
function reportsScreen(i){return`<section class="screen"><div class="back"><button class="icon-btn" data-back>←</button><span class="small muted">Retour à l'intervention</span></div><div class="page-head"><div><span class="badge primary mono">${i.id}</span><h1>Rapports</h1><p>Choisissez un modèle, remplissez son formulaire, puis envoyez le rapport créé par email.</p></div></div><div class="stack-lg">${i.reports.length?`<section class="card"><div class="card-head"><div><h2>Rapports créés</h2><p>${i.reports.length} document${i.reports.length>1?"s":""} disponible${i.reports.length>1?"s":""}</p></div><span>▤</span></div><div class="card-body stack">${i.reports.map(r=>`<div class="row"><div class="row-icon">▤</div><div class="row-main"><strong>${esc(r.title)}</strong><span>${r.id} · ${fmt(parse(r.createdAt),{day:"numeric",month:"long",year:"numeric"})}</span></div><button class="btn btn-primary btn-sm" data-open-email-context="${i.id}|report|${r.id}">✉ Envoyer</button></div>`).join("")}</div></section>`:""}<section class="card"><div class="card-head"><div><h2>Modèles de rapports</h2><p>Chaque modèle génère un formulaire différent</p></div><span class="badge">${reportTemplates.length} modèles</span></div><div class="card-body"><div class="template-grid">${reportTemplates.map(t=>`<article class="template-card"><div class="template-icon">${t.icon}</div><div><span class="badge primary">Modèle</span><h3>${esc(t.name)}</h3><p>${esc(t.description)}</p></div><button class="btn btn-soft btn-block" data-report-template="${i.id}|${t.id}">Utiliser ce modèle ›</button></article>`).join("")}</div></div></section></div></section>`}
function reportFormScreen(i,t){const c=getClient(i.client),l=getLocation(i.location);return`<section class="screen"><div class="back"><button class="icon-btn" data-back>←</button><span class="small muted">Retour aux modèles</span></div><div class="page-head"><div><span class="badge primary">${t.icon} Modèle de rapport</span><h1>${esc(t.name)}</h1><p>${i.id} · ${esc(c.name)} · formulaire généré à partir du modèle sélectionné</p></div></div><form id="dynamicReportForm" data-template="${t.id}"><div class="report-layout"><section class="card dynamic"><h2>Formulaire du modèle</h2><p>${esc(t.description)}</p><div class="form-grid">${t.fields.map(dynamicField).join("")}</div></section><aside class="card report-side"><div class="card-head"><div><h2>Créer le rapport</h2><p>Le rapport pourra ensuite être envoyé par email</p></div><span>▤</span></div><div class="card-body stack-lg"><div class="stack"><div class="cluster"><span class="small faint">Intervention</span><strong class="small mono">${i.id}</strong></div><div class="cluster"><span class="small faint">Client</span><strong class="small">${esc(c.name)}</strong></div><div class="cluster"><span class="small faint">Lieu</span><strong class="small">${esc(l.name)}</strong></div><div class="cluster"><span class="small faint">Modèle</span><strong class="small">${esc(t.name)}</strong></div></div><button type="button" class="btn btn-secondary btn-block" data-save-dynamic-report>Enregistrer le brouillon</button><button class="btn btn-primary btn-block">✓ Créer le rapport</button></div></aside></div></form></section>`}
/* =========================================================
ÉCRAN — EMAIL DIRECT OU CRÉÉ DEPUIS UN MODÈLE
========================================================= */
function emailField(f,defaults={}){const type=f.type||"text",v=defaults[f.name]||"";if(type==="textarea")return`<div class="field span-2"><label class="${f.required?"required":""}">${esc(f.label)}</label><textarea name="${f.name}" ${f.required?"required":""} placeholder="${esc(f.placeholder||"")}">${esc(v)}</textarea></div>`;return`<div class="field"><label class="${f.required?"required":""}">${esc(f.label)}</label><input type="${type}" name="${f.name}" ${f.required?"required":""} value="${esc(v)}" placeholder="${esc(f.placeholder||"")}"></div>`}
function emailDefaults(i,mode,report){const c=getClient(i.client),apps=appointmentsFor(i),next=apps.find(a=>a.date>=iso(today));return{contactName:c.contacts[0]?.name||"",message:`Ce rapport présente les opérations réalisées et les recommandations associées à l'intervention ${i.id}.`,replyDate:iso(add(today,7)),appointmentDate:next?.date||iso(add(today,3)),appointmentTime:next?.from||"09:00",accessInfo:getLocation(i.location).access,requestedItems:"Merci de nous transmettre les plans à jour, les références des équipements et les derniers relevés disponibles.",deadline:iso(add(today,5)),reportId:report?.id||""}}
function selectedAttachments(i,reportId){const items=[];if(reportId){const r=i.reports.find(x=>x.id===reportId);if(r)items.push({id:r.id,label:`${r.title} · ${r.id}`,checked:true})}i.reports.filter(x=>x.id!==reportId).forEach(r=>items.push({id:r.id,label:`${r.title} · ${r.id}`,checked:false}));if(i.compteRendu)items.push({id:i.compteRendu.reference,label:`Compte rendu · ${i.compteRendu.reference}`,checked:false});i.documents.forEach((d,index)=>items.push({id:`doc-${index}`,label:d[0],checked:false}));return items}
function emailScreen(i,mode,reportId){const c=getClient(i.client),l=getLocation(i.location),report=i.reports.find(r=>r.id===reportId),template=emailTemplates.find(t=>t.id===mode),defaults=emailDefaults(i,mode,report),draft=template?template.build(i,defaults,report):null,attachments=selectedAttachments(i,reportId);return`<section class="screen"><div class="back"><button class="icon-btn" data-back>←</button><span class="small muted">Retour à l'intervention</span></div><div class="page-head"><div><span class="badge primary mono">${i.id}</span><h1>Créer un email</h1><p>Email libre ou email construit à partir d'un modèle Omika.</p></div></div><div class="email-layout"><section class="card"><div class="card-head"><div><h2>Mode de création</h2><p>Sélectionnez l'approche souhaitée</p></div><span>✉</span></div><div class="card-body"><div class="email-mode-grid"><button class="email-mode ${mode==="direct"?"active":""}" data-email-mode="${i.id}|direct|${reportId||""}"><span>✎</span><strong>Email libre</strong><small>Objet et corps saisis directement</small></button>${emailTemplates.map(t=>`<button class="email-mode ${mode===t.id?"active":""}" data-email-mode="${i.id}|${t.id}|${reportId||""}"><span>▤</span><strong>${esc(t.name)}</strong><small>${esc(t.description)}</small></button>`).join("")}</div></div></section><form class="card" id="emailForm" data-mode="${mode}" data-report="${reportId||""}"><div class="card-head"><div><h2>${mode==="direct"?"Email direct":esc(template?.name||"Modèle d'email")}</h2><p>${mode==="direct"?"Saisissez directement le message":"Complétez les paramètres puis vérifiez l'aperçu généré"}</p></div><span class="badge primary">${mode==="direct"?"Libre":"Modèle"}</span></div><div class="card-body stack-lg"><div class="form-grid"><div class="field"><label class="required">Destinataire</label><input type="email" name="to" required value="${esc(c.contacts[0]?.email||c.email)}"></div><div class="field"><label>Copie</label><input type="email" name="cc" value="${esc(c.email)}"></div>${mode==="direct"?`<div class="field span-2"><label class="required">Objet</label><input name="subject" required value="Intervention ${i.id} — ${esc(i.title)}"></div><div class="field span-2"><label class="required">Corps de l'email</label><textarea name="body" required>Bonjour ${esc(c.contacts[0]?.name||"")},\n\nJe vous contacte au sujet de l'intervention ${i.id} sur le site ${esc(l.name)}.\n\nCordialement,\nCamille Bernard\nOmika</textarea></div>`:template.fields.map(f=>emailField(f,defaults)).join("")}</div>${mode!=="direct"?`<div class="email-preview"><span class="badge">Aperçu généré</span><div class="field"><label>Objet construit</label><input id="emailSubjectPreview" readonly value="${esc(draft.subject)}"></div><div class="field"><label>Corps construit</label><textarea id="emailBodyPreview" readonly>${esc(draft.body)}</textarea></div></div>`:""}<fieldset style="border:0;padding:0;margin:0"><legend class="field-title" style="margin-bottom:9px">Pièces jointes simulées</legend><div class="checks">${attachments.length?attachments.map(x=>`<label class="check"><input type="checkbox" name="attachments" value="${esc(x.id)}" ${x.checked?"checked":""}><span>${esc(x.label)}</span></label>`).join(""):`<span class="small muted">Aucune pièce jointe disponible.</span>`}</div></fieldset><div class="relation-callout"><div><h3>Email prêt à être créé</h3><p>Le bouton ouvre le client de messagerie configuré sur l'appareil avec l'objet et le corps générés.</p></div><button class="btn btn-primary">✉ Créer l'email</button></div></div></form></div></section>`}
/* =========================================================
ÉCRAN — AGENDA DES AUTRES COLLABORATEURS
========================================================= */
function teamFiltered(){const end=add(state.teamWeek,6);return appointments.filter(a=>a.collaborator!=="c1"&&parse(a.date)>=state.teamWeek&&parse(a.date)<=end&&(state.teamCollab==="all"||a.collaborator===state.teamCollab))}
function teamScreen(){const end=add(state.teamWeek,6),items=teamFiltered();return`<section class="screen"><div class="page-head"><div><h1>Agenda de l'équipe</h1><p>Consultez les rendez-vous des autres collaborateurs. L'ouverture affiche toujours le rendez-vous, puis son éventuelle intervention liée.</p></div><button class="btn btn-secondary" data-team-today>▣ Cette semaine</button></div><div class="collabs"><button class="collab ${state.teamCollab==="all"?"active":""}" data-collab="all"><div class="avatar" style="width:29px;height:29px;background:var(--primary)">+</div>Toute l'équipe</button>${collaborators.slice(1).map(p=>`<button class="collab ${state.teamCollab===p.id?"active":""}" data-collab="${p.id}">${avatar(p)}${esc(p.name)}</button>`).join("")}</div><div class="card"><div class="agenda-tools"><div class="tool-group"><button class="icon-btn" data-team-week="-1">‹</button><strong class="small">Du ${fmt(state.teamWeek,{day:"numeric",month:"short"})} au ${fmt(end,{day:"numeric",month:"short",year:"numeric"})}</strong><button class="icon-btn" data-team-week="1">›</button></div><div class="segmented"><button data-team-view="list" class="${state.teamView==="list"?"active":""}">Liste</button><button data-team-view="calendar" class="${state.teamView==="calendar"?"active":""}">Calendrier</button></div></div>${state.teamView==="list"?(items.length?`<div class="list">${items.map(a=>apptCard(a,true)).join("")}</div>`:empty("◎","Aucun rendez-vous","Aucun rendez-vous ne correspond au collaborateur et à la semaine.")):`<div class="team-calendar-wrap"><div class="team-calendar">${Array.from({length:7},(_,i)=>add(state.teamWeek,i)).map(d=>{const list=items.filter(a=>a.date===iso(d));return`<div class="team-day"><div class="team-day-head"><strong>${fmt(d,{weekday:"long"})}</strong><span>${fmt(d,{day:"numeric",month:"short"})} · ${list.length} rdv</span></div>${list.map(a=>{const c=getClient(a.client),p=getCollab(a.collaborator);return`<div class="mini-event" data-open-appointment="${a.id}" style="border-left-color:${p.color}"><strong>${a.from} · ${esc(c.name)}</strong><span>${esc(p.name)}</span><span>${a.interventionId?esc(a.interventionId):"Sans intervention"}</span></div>`}).join("")||`<div class="small faint" style="padding:8px">Libre</div>`}</div>`}).join("")}</div></div>`}</div></section>`}
/* =========================================================
ÉCRAN — RECHERCHE UNIQUEMENT DANS LES INTERVENTIONS
========================================================= */
function searchValues(){return{number:id("qNumber")?.value||"",client:id("qClient")?.value||"",place:id("qPlace")?.value||"",relation:id("qRelation")?.value||"all",date:id("qDate")?.value||"",collab:id("qCollab")?.value||""}}
function searchResults(q=state.search||{}){const has=Object.entries(q).some(([k,v])=>k!=="relation"&&Boolean(v))||(q.relation&&q.relation!=="all");return interventions.filter(i=>{const c=getClient(i.client),l=getLocation(i.location),apps=appointmentsFor(i),withAppointments=apps.length>0;return(!q.number||norm(i.id).includes(norm(q.number)))&&(!q.client||norm(`${c.name} ${c.ref}`).includes(norm(q.client)))&&(!q.place||norm(`${l.name} ${l.address}`).includes(norm(q.place)))&&(!q.relation||q.relation==="all"||(q.relation==="with"&&withAppointments)||(q.relation==="without"&&!withAppointments))&&(!q.date||apps.some(a=>a.date===q.date))&&(!q.collab||i.owner===q.collab||apps.some(a=>a.collaborator===q.collab))}).sort((a,b)=>b.opened.localeCompare(a.opened)).slice(0,has?40:10)}
function searchScreen(){const q=state.search||{relation:"all"},items=searchResults(q);return`<section class="screen"><div class="page-head"><div><h1>Recherche d'interventions</h1><p>La recherche retourne uniquement des interventions. Les critères de rendez-vous servent seulement à filtrer les interventions qui en possèdent.</p></div></div><div class="relation-callout" style="margin-bottom:16px"><div><span class="badge primary">Règle métier</span><h3>Un résultat reste toujours une intervention</h3><p>Les rendez-vous autonomes n'apparaissent pas ici. La liste des rendez-vous liés est disponible dans chaque fiche intervention.</p></div></div><form class="card search-form" id="searchForm"><div class="grid-3"><div class="field"><label>N° d'intervention</label><input id="qNumber" value="${esc(q.number||"")}" placeholder="Ex. INT-${today.getFullYear()}-1104"></div><div class="field"><label>Client</label><input id="qClient" value="${esc(q.client||"")}" placeholder="Nom ou référence"></div><div class="field"><label>Lieu ou adresse</label><input id="qPlace" value="${esc(q.place||"")}" placeholder="Site, ville, rue…"></div><div class="field"><label>Relation aux rendez-vous</label><select id="qRelation"><option value="all" ${q.relation==="all"?"selected":""}>Toutes les interventions</option><option value="with" ${q.relation==="with"?"selected":""}>Avec au moins un rendez-vous</option><option value="without" ${q.relation==="without"?"selected":""}>Sans rendez-vous</option></select></div><div class="field"><label>Date d'un rendez-vous lié</label><input id="qDate" type="date" value="${esc(q.date||"")}"></div><div class="field"><label>Collaborateur</label><select id="qCollab"><option value="">Tous les collaborateurs</option>${collaborators.map(p=>`<option value="${p.id}" ${q.collab===p.id?"selected":""}>${esc(p.name)}</option>`).join("")}</select></div><div class="actions span-2" style="align-items:end"><button type="button" class="btn btn-secondary" data-reset-search>Réinitialiser</button><button class="btn btn-primary">⌕ Rechercher les interventions</button></div></div></form><div class="cluster" style="margin-bottom:10px"><h2 style="margin:0;font-size:16px">${Object.values(q).some(v=>v&&v!=="all")?"Résultats de recherche":"Interventions récentes"}</h2><span class="badge">${items.length} résultat${items.length>1?"s":""}</span></div><div class="stack">${items.length?items.map(interventionCard).join(""):empty("⌕","Aucune intervention","Modifiez les critères de recherche.")}</div></section>`}
/* =========================================================
ÉCRAN — DOSSIERS
========================================================= */
function folderScreen(){const f=folders.find(x=>x.id===state.folder)||folders[0],c=getClient(f.client),locs=f.locations.map(getLocation),ints=f.interventions.map(getIntervention).filter(Boolean),apps=appointments.filter(a=>a.interventionId&&f.interventions.includes(a.interventionId));return`<section class="screen"><div class="page-head"><div><h1>Dossiers</h1><p>Un dossier regroupe des interventions. Les rendez-vous affichés sont ceux liés à ces interventions.</p></div><button class="btn btn-primary" data-add-intervention="${f.id}">+ Nouvelle intervention</button></div><div class="master-detail"><aside class="card master"><div class="card-head"><div><h2>Dossiers actifs</h2><p>${folders.length} dossiers</p></div><span>▤</span></div><div class="master-list">${folders.map(x=>`<button class="master-item ${x.id===f.id?"active":""}" data-folder="${x.id}"><strong>${esc(x.title)}</strong><span>${esc(getClient(x.client).name)} · ${esc(x.priority)}</span><small class="mono">${x.id} · échéance ${fmt(parse(x.due),{day:"numeric",month:"short"})}</small></button>`).join("")}</div></aside><article class="card"><div class="entity-head"><div class="entity-title"><div class="entity-icon">▤</div><div><span class="badge primary mono">${f.id}</span><h1>${esc(f.title)}</h1><p>${esc(c.name)} · Pilote : ${esc(f.manager)}</p></div></div><button class="btn btn-primary" data-add-intervention="${f.id}">+ Ajouter une intervention</button></div><div class="tabs"><button class="tab active" data-tab="overview">Vue d'ensemble</button><button class="tab" data-tab="interventions">Interventions</button><button class="tab" data-tab="appointments">Rendez-vous liés</button><button class="tab" data-tab="documents">Documents</button></div><div class="tab-panel active" data-panel="overview"><div class="stack-lg"><div class="grid-2"><div class="info"><span>Statut</span><strong>${esc(f.status)}</strong></div><div class="info"><span>Priorité</span><strong>${esc(f.priority)}</strong></div><div class="info"><span>Interventions</span><strong>${ints.length}</strong></div><div class="info"><span>Rendez-vous liés</span><strong>${apps.length}</strong></div></div><div class="text-box"><h4>Résumé du dossier</h4><p>${esc(f.summary)}</p></div><div class="grid-2"><div class="info"><span>Client concerné</span><strong>${esc(c.name)}</strong><button class="btn btn-ghost btn-sm" data-open-client="${c.id}">Voir la fiche</button></div><div class="info"><span>Lieux concernés</span><strong>${locs.map(x=>esc(x.name)).join("<br>")}</strong></div></div></div></div><div class="tab-panel" data-panel="interventions"><div class="stack">${ints.length?ints.map(interventionCard).join(""):empty("⚒","Aucune intervention","Ajoutez une intervention au dossier.")}</div></div><div class="tab-panel" data-panel="appointments"><div class="stack">${apps.length?apps.map(a=>apptCard(a,true)).join(""):empty("▣","Aucun rendez-vous lié","Les interventions de ce dossier n'ont aucun rendez-vous.")}</div></div><div class="tab-panel" data-panel="documents"><div class="stack">${f.docs.map(x=>`<div class="row"><div class="row-icon">▤</div><div class="row-main"><strong>${esc(x)}</strong><span>Document du dossier</span></div><button class="icon-btn" data-document="${esc(x)}">◉</button></div>`).join("")}</div></div></article></div></section>`}
/* =========================================================
ÉCRANS — FICHES CLIENTS ET LIEUX
========================================================= */
function clientScreen(){const c=getClient(state.client)||clients[0],locs=locations.filter(l=>l.client===c.id),ints=interventions.filter(i=>i.client===c.id).slice().reverse(),apps=appointments.filter(a=>a.client===c.id).slice().reverse().slice(0,15);return`<section class="screen"><div class="page-head"><div><h1>Fiches clients</h1><p>Les interventions et les rendez-vous sont présentés dans deux onglets distincts.</p></div></div><div class="master-detail"><aside class="card master"><div class="card-head"><div><h2>Clients</h2><p>${clients.length} clients</p></div><span>▥</span></div><div class="master-list">${clients.map(x=>`<button class="master-item ${x.id===c.id?"active":""}" data-client="${x.id}"><strong>${esc(x.name)}</strong><span>${esc(x.sector)}</span><small>${x.ref} · ${locations.filter(l=>l.client===x.id).length} lieu(x)</small></button>`).join("")}</div></aside><article class="card"><div class="entity-head"><div class="entity-title"><div class="entity-icon">▥</div><div><span class="badge primary">Client actif</span><h1>${esc(c.name)}</h1><p>${esc(c.legal)} · ${c.ref}</p></div></div><div class="actions"><button class="btn btn-secondary" data-phone="${c.phone}">☎ Appeler</button><button class="btn btn-primary" data-email-address="${c.email}">✉ Email</button></div></div><div class="tabs"><button class="tab active" data-tab="overview">Informations</button><button class="tab" data-tab="contacts">Contacts</button><button class="tab" data-tab="locations">Lieux</button><button class="tab" data-tab="interventions">Interventions</button><button class="tab" data-tab="appointments">Rendez-vous</button><button class="tab" data-tab="documents">Documents</button></div><div class="tab-panel active" data-panel="overview"><div class="stack-lg"><div class="grid-2"><div class="info"><span>Secteur</span><strong>${esc(c.sector)}</strong></div><div class="info"><span>Téléphone</span><strong>${esc(c.phone)}</strong></div><div class="info"><span>Email</span><strong>${esc(c.email)}</strong></div><div class="info"><span>Adresse</span><strong>${esc(c.address)}</strong></div></div><div class="text-box"><h4>Notes client</h4><p>${esc(c.note)}</p></div></div></div><div class="tab-panel" data-panel="contacts"><div class="stack">${c.contacts.map(x=>`<div class="row">${avatar({...x,color:tone(x.name)})}<div class="row-main"><strong>${esc(x.name)}</strong><span>${esc(x.role)} · ${esc(x.phone)}<br>${esc(x.email)}</span></div><button class="icon-btn" data-phone="${x.phone}">☎</button></div>`).join("")}</div></div><div class="tab-panel" data-panel="locations"><div class="stack">${locs.map(l=>`<div class="row"><div class="row-icon">⌖</div><div class="row-main"><strong>${esc(l.name)}</strong><span>${esc(l.address)}</span></div><button class="btn btn-soft btn-sm" data-open-location="${l.id}">Ouvrir</button></div>`).join("")}</div></div><div class="tab-panel" data-panel="interventions"><div class="stack">${ints.map(interventionCard).join("")||empty("⚒","Aucune intervention","Aucune intervention n'est associée à ce client.")}</div></div><div class="tab-panel" data-panel="appointments"><div class="stack">${apps.map(a=>apptCard(a,true)).join("")||empty("▣","Aucun rendez-vous","Aucun rendez-vous n'est associé à ce client.")}</div></div><div class="tab-panel" data-panel="documents"><div class="stack">${c.docs.map(x=>`<div class="row"><div class="row-icon">▤</div><div class="row-main"><strong>${esc(x)}</strong><span>Document client</span></div><button class="icon-btn" data-document="${esc(x)}">◉</button></div>`).join("")}</div></div></article></div></section>`}
function locationScreen(){const l=getLocation(state.location)||locations[0],c=getClient(l.client),ints=interventions.filter(i=>i.location===l.id).slice().reverse(),apps=appointments.filter(a=>a.location===l.id).slice().reverse().slice(0,15);return`<section class="screen"><div class="page-head"><div><h1>Fiches lieux</h1><p>Interventions et rendez-vous restent séparés tout en partageant le même lieu.</p></div></div><div class="master-detail"><aside class="card master"><div class="card-head"><div><h2>Lieux</h2><p>${locations.length} lieux</p></div><span>⌖</span></div><div class="master-list">${locations.map(x=>`<button class="master-item ${x.id===l.id?"active":""}" data-location="${x.id}"><strong>${esc(x.name)}</strong><span>${esc(getClient(x.client).name)}</span><small>${esc(x.address)}</small></button>`).join("")}</div></aside><article class="card"><div class="entity-head"><div class="entity-title"><div class="entity-icon">⌖</div><div><span class="badge primary">Site client</span><h1>${esc(l.name)}</h1><p>${esc(l.address)}</p></div></div><button class="btn btn-primary" data-gps="${esc(l.address)}">⌖ GPS</button></div><div class="tabs"><button class="tab active" data-tab="overview">Informations</button><button class="tab" data-tab="contacts">Contacts</button><button class="tab" data-tab="interventions">Interventions</button><button class="tab" data-tab="appointments">Rendez-vous</button><button class="tab" data-tab="documents">Documents</button></div><div class="tab-panel active" data-panel="overview"><div class="stack-lg"><div class="map" style="border:1px solid var(--border);border-radius:12px"><div class="pin"><span>⌖</span></div></div><div class="grid-2"><div class="info"><span>Client associé</span><strong>${esc(c.name)}</strong><button class="btn btn-ghost btn-sm" data-open-client="${c.id}">Voir le client</button></div><div class="info"><span>Adresse complète</span><strong>${esc(l.address)}</strong></div><div class="info"><span>Interventions</span><strong>${ints.length}</strong></div><div class="info"><span>Rendez-vous</span><strong>${apps.length}</strong></div></div><div class="text-box"><h4>Notes du lieu</h4><p>${esc(l.note)}</p></div></div></div><div class="tab-panel" data-panel="contacts"><div class="stack">${c.contacts.map(x=>`<div class="row">${avatar({...x,color:tone(x.name)})}<div class="row-main"><strong>${esc(x.name)}</strong><span>${esc(x.role)} · ${esc(x.phone)}</span></div><button class="icon-btn" data-phone="${x.phone}">☎</button></div>`).join("")}</div></div><div class="tab-panel" data-panel="interventions"><div class="stack">${ints.map(interventionCard).join("")||empty("⚒","Aucune intervention","Aucune intervention n'est associée à ce lieu.")}</div></div><div class="tab-panel" data-panel="appointments"><div class="stack">${apps.map(a=>apptCard(a,true)).join("")||empty("▣","Aucun rendez-vous","Aucun rendez-vous n'est associé à ce lieu.")}</div></div><div class="tab-panel" data-panel="documents"><div class="stack">${l.docs.map(x=>`<div class="row"><div class="row-icon">▤</div><div class="row-main"><strong>${esc(x)}</strong><span>Document du lieu</span></div><button class="icon-btn" data-document="${esc(x)}">◉</button></div>`).join("")}</div></div></article></div></section>`}
/* =========================================================
ÉCRAN — PARAMÈTRES, THÈME ET SYNCHRONISATION
========================================================= */
const colors=["#4f46e5","#0f7b6c","#1468c4","#b94b65","#c05d12","#72502b"];
function settingsScreen(){return`<section class="screen"><div class="page-head"><div><h1>Paramètres Omika</h1><p>Personnalisez l'apparence et contrôlez la synchronisation manuelle.</p></div></div><div class="detail-layout"><div class="stack-lg"><section class="card"><div class="card-head"><div><h2>Apparence</h2><p>Thème et couleur principale</p></div><span>⚙</span></div><div class="card-body stack-lg"><div class="field"><span class="field-title">Mode d'affichage</span><div class="theme-grid"><button class="theme-choice ${state.theme==="light"?"active":""}" data-theme-choice="light"><div class="theme-thumb light"></div><strong>Thème clair</strong></button><button class="theme-choice ${state.theme==="dark"?"active":""}" data-theme-choice="dark"><div class="theme-thumb dark"></div><strong>Thème sombre</strong></button></div></div><div class="field"><span class="field-title">Couleur principale</span><div class="swatches">${colors.map(c=>`<button class="swatch ${state.primary.toLowerCase()===c?"active":""}" style="--c:${c}" data-color="${c}" aria-label="Couleur ${c}"></button>`).join("")}</div><div class="inline" style="margin-top:9px"><input id="customColor" type="color" value="${state.primary}" style="width:48px;padding:3px"><span class="small muted">Couleur personnalisée</span></div></div><div class="cluster"><span class="small faint">Les préférences sont enregistrées localement.</span><button class="btn btn-primary" data-save-theme>✓ Enregistrer</button></div></div></section><section class="card"><div class="card-body"><div class="cluster"><div><h2 style="margin:0;font-size:17px">Synchronisation manuelle</h2><p class="small muted" style="margin:5px 0 0">Interventions, rendez-vous, temps, rapports, notes et médias.</p></div><span style="font-size:22px">↻</span></div><div class="row" style="margin:14px 0"><div class="row-icon">${state.syncing?'<span class="spin"></span>':"✓"}</div><div class="row-main"><strong>${state.syncing?"Synchronisation en cours":"Toutes les données sont à jour"}</strong><span>${state.syncing?"Envoi des données terrain et récupération des mises à jour…":`Dernière synchronisation : ${fmt(state.lastSync,{day:"numeric",month:"short",hour:"2-digit",minute:"2-digit"})}`}</span></div></div><button class="btn btn-primary btn-block" data-sync ${state.syncing?"disabled":""}>${state.syncing?'<span class="spin"></span> Synchronisation…':"↻ Synchroniser maintenant"}</button></div></section></div><aside class="card preview"><div class="card-head"><div><h2>Aperçu</h2><p>Application terrain</p></div><span class="badge primary">En direct</span></div><div class="preview-top"><strong>Omika</strong><span>↻</span></div><div class="preview-body"><div class="preview-card"><div class="line short"></div><div class="line"></div><div class="line"></div><div class="preview-button"></div></div><div class="preview-card" style="margin-top:10px"><div class="line short"></div><div class="line"></div></div></div></aside></div></section>`}
/* =========================================================
RENDU DES ÉCRANS
========================================================= */
function render(){const r=parseRoute(),valid=["home","appointment","intervention","resource","compte","reports","reportform","email","team","search","folders","clients","locations","settings"];state.screen=valid.includes(r.screen)?r.screen:"home";if(["home","team","search","folders","clients","locations","settings"].includes(state.screen))state.origin=state.screen;let html="";
if(state.screen==="appointment"){if(r.param&&getAppt(r.param))state.appointment=r.param;html=appointmentScreen(getAppt(state.appointment)||appointments[0])}
else if(state.screen==="intervention"){if(r.param&&getIntervention(r.param))state.intervention=r.param;html=interventionScreen(getIntervention(state.intervention)||interventions[0])}
else if(state.screen==="resource"){const [iid,kind]=compound(r.param);if(getIntervention(iid))state.intervention=iid;state.resource=["notes","images","documents"].includes(kind)?kind:"notes";html=resourceScreen(getIntervention(state.intervention)||interventions[0],state.resource)}
else if(state.screen==="compte"){if(r.param&&getIntervention(r.param))state.intervention=r.param;html=compteRenduScreen(getIntervention(state.intervention)||interventions[0])}
else if(state.screen==="reports"){if(r.param&&getIntervention(r.param))state.intervention=r.param;html=reportsScreen(getIntervention(state.intervention)||interventions[0])}
else if(state.screen==="reportform"){const [iid,tid]=compound(r.param);if(getIntervention(iid))state.intervention=iid;if(reportTemplates.some(t=>t.id===tid))state.reportTemplate=tid;html=reportFormScreen(getIntervention(state.intervention)||interventions[0],reportTemplates.find(t=>t.id===state.reportTemplate)||reportTemplates[0])}
else if(state.screen==="email"){const [iid,mode,reportId]=compound(r.param);if(getIntervention(iid))state.intervention=iid;state.emailMode=mode==="direct"||emailTemplates.some(t=>t.id===mode)?mode:"direct";state.emailReport=reportId||null;html=emailScreen(getIntervention(state.intervention)||interventions[0],state.emailMode,state.emailReport)}
else html={home:homeScreen,team:teamScreen,search:searchScreen,folders:folderScreen,clients:clientScreen,locations:locationScreen,settings:settingsScreen}[state.screen]();
id("view").innerHTML=html;id("screenTitle").textContent=titles(state.screen);activeNav();showSync();afterRender();window.scrollTo({top:0,behavior:"instant"})}
function afterRender(){if(state.screen==="resource"){const imageInput=id("resourceImageInput"),docInput=id("resourceDocumentInput"),i=getIntervention(state.intervention);if(imageInput)imageInput.onchange=e=>addImages(i,[...e.target.files]);if(docInput)docInput.onchange=e=>addDocuments(i,[...e.target.files])}if(state.screen==="compte"){state.signatures={tech:false,client:false};setupCanvas(id("crSignTech"),"tech");setupCanvas(id("crSignClient"),"client");restoreForm(`omika-compte-${state.intervention}`,"compteRenduForm")}if(state.screen==="reportform")restoreForm(`omika-report-${state.intervention}-${state.reportTemplate}`,"dynamicReportForm");if(state.screen==="email"&&state.emailMode!=="direct"){const form=id("emailForm");form?.addEventListener("input",updateEmailPreview)}}
/* =========================================================
SIGNATURES, BROUILLONS ET FICHIERS
========================================================= */
function setupCanvas(canvas,key){if(!canvas)return;const ctx=canvas.getContext("2d");ctx.lineWidth=4;ctx.lineCap="round";ctx.strokeStyle=getComputedStyle(document.body).getPropertyValue("--text").trim();let draw=false;const p=e=>{const r=canvas.getBoundingClientRect();return{x:(e.clientX-r.left)*canvas.width/r.width,y:(e.clientY-r.top)*canvas.height/r.height}};canvas.onpointerdown=e=>{draw=true;state.signatures[key]=true;canvas.setPointerCapture(e.pointerId);const q=p(e);ctx.beginPath();ctx.moveTo(q.x,q.y)};canvas.onpointermove=e=>{if(!draw)return;const q=p(e);ctx.lineTo(q.x,q.y);ctx.stroke()};canvas.onpointerup=canvas.onpointercancel=()=>{draw=false;ctx.beginPath()}}
function clearSign(key){const c=id(key==="tech"?"crSignTech":"crSignClient");if(c)c.getContext("2d").clearRect(0,0,c.width,c.height);state.signatures[key]=false}
function formObject(form){const data={};new FormData(form).forEach((v,k)=>{data[k]=data[k]?[].concat(data[k],v):v});return data}
function restoreForm(key,formId){const raw=storage.getItem(key),form=id(formId);if(!raw||!form)return;try{const data=JSON.parse(raw);Object.entries(data).forEach(([k,v])=>{form.querySelectorAll(`[name="${CSS.escape(k)}"]`).forEach(n=>{if(n.type==="checkbox")n.checked=Array.isArray(v)?v.includes(n.value):(v===n.value||v==="on");else n.value=Array.isArray(v)?v[0]:v})})}catch(_){storage.removeItem(key)}}
function addImages(i,files){if(!files.length)return;let left=files.length;files.forEach((f,index)=>{const r=new FileReader();r.onload=e=>{i.images.push({id:`user-${Date.now()}-${index}`,name:f.name,url:e.target.result,note:""});if(--left===0){render();toast("Images ajoutées",`${files.length} image(s) ajoutée(s) à l'intervention.`,"ok")}};r.readAsDataURL(f)})}
function addDocuments(i,files){if(!files.length)return;files.forEach(f=>i.documents.push([f.name,`${f.type||"Fichier"} · ${Math.max(1,Math.round(f.size/1024))} Ko`]));render();toast("Documents ajoutés",`${files.length} document(s) ajouté(s) à l'intervention.`,"ok")}
function updateEmailPreview(){const form=id("emailForm"),i=getIntervention(state.intervention),template=emailTemplates.find(t=>t.id===state.emailMode),report=i?.reports.find(r=>r.id===state.emailReport);if(!form||!i||!template)return;const draft=template.build(i,formObject(form),report),subject=id("emailSubjectPreview"),body=id("emailBodyPreview");if(subject)subject.value=draft.subject;if(body)body.value=draft.body}
/* =========================================================
MODALES : CRÉATION D'INTERVENTION ET PLANIFICATION D'UN RDV
========================================================= */
function openInterventionModal(folderId=state.folder){const f=folders.find(x=>x.id===folderId)||folders[0];id("modalFolder").innerHTML=folders.map(x=>`<option value="${x.id}" ${x.id===f.id?"selected":""}>${x.id} · ${esc(x.title)}</option>`).join("");id("modalClient").innerHTML=clients.map(x=>`<option value="${x.id}" ${x.id===f.client?"selected":""}>${esc(x.name)}</option>`).join("");id("modalLocation").innerHTML=locations.map(x=>`<option value="${x.id}" ${f.locations.includes(x.id)?"selected":""}>${esc(x.name)}</option>`).join("");id("modalOwner").innerHTML=collaborators.map(x=>`<option value="${x.id}">${esc(x.name)}</option>`).join("");id("modalWrap").classList.add("open");id("modalWrap").setAttribute("aria-hidden","false")}
function closeInterventionModal(){id("modalWrap").classList.remove("open");id("modalWrap").setAttribute("aria-hidden","true")}
function openAppointmentModal(interventionId){const i=getIntervention(interventionId),c=getClient(i.client),l=getLocation(i.location);id("appointmentIntervention").value=i.id;id("appointmentContext").innerHTML=`<strong>${esc(i.id)} · ${esc(i.title)}</strong><span>${esc(c.name)} · ${esc(l.name)}</span>`;id("appointmentDate").value=iso(add(today,3));id("appointmentFrom").value="09:00";id("appointmentTo").value="10:30";id("appointmentCollaborator").innerHTML=collaborators.map(x=>`<option value="${x.id}" ${x.id===i.owner?"selected":""}>${esc(x.name)}</option>`).join("");id("appointmentModalWrap").classList.add("open");id("appointmentModalWrap").setAttribute("aria-hidden","false")}
function closeAppointmentModal(){id("appointmentModalWrap").classList.remove("open");id("appointmentModalWrap").setAttribute("aria-hidden","true")}
function openSheet(){id("moreSheet").classList.add("open");id("moreSheet").setAttribute("aria-hidden","false")}
function closeSheet(){id("moreSheet").classList.remove("open");id("moreSheet").setAttribute("aria-hidden","true")}
/* =========================================================
INTERACTIONS GLOBALES
========================================================= */
document.addEventListener("click",e=>{const b=e.target.closest("button,[data-open-appointment],[data-open-intervention]");if(!b)return;
if(b.dataset.route){navigate(b.dataset.route,null,{push:false,clear:true});return}
if(b.dataset.openAppointment){navigate("appointment",b.dataset.openAppointment);return}
if(b.dataset.openIntervention){navigate("intervention",b.dataset.openIntervention);return}
if(b.hasAttribute("data-back")){goBack();return}
if(b.dataset.gps!==undefined){openAction("gps",b.dataset.gps);return}
if(b.dataset.phone!==undefined){openAction("phone",b.dataset.phone);return}
if(b.dataset.emailAddress!==undefined){openAction("email",b.dataset.emailAddress);return}
if(b.dataset.document){toast("Aperçu du document",`${b.dataset.document} serait ouvert dans le lecteur intégré.`);return}
if(b.dataset.tab){const card=b.closest(".card");if(card){card.querySelectorAll(".tab").forEach(tab=>tab.classList.toggle("active",tab===b));card.querySelectorAll(".tab-panel").forEach(panel=>panel.classList.toggle("active",panel.dataset.panel===b.dataset.tab))}return}
if(b.dataset.agenda){state.agendaView=b.dataset.agenda;render();return}
if(b.hasAttribute("data-today")){state.date=today;render();return}
if(b.dataset.period){state.date=add(state.date,Number(b.dataset.period)*(state.agendaView==="day"?1:7));if(state.date<rangeStart)state.date=rangeStart;if(state.date>rangeEnd)state.date=rangeEnd;render();return}
if(b.dataset.date){state.date=parse(b.dataset.date);render();return}
if(b.hasAttribute("data-next-appointment")){const a=appointments.find(x=>x.collaborator==="c1"&&x.date>=iso(today)&&x.status!=="completed");a?navigate("appointment",a.id):toast("Agenda terminé","Aucun rendez-vous à venir.");return}
if(b.dataset.deleteTime){const a=getAppt(state.appointment);a.timeEntries=a.timeEntries.filter(x=>x.id!==b.dataset.deleteTime);render();toast("Temps supprimé","La plage de temps a été retirée.");return}
if(b.dataset.resourceKind){const [iid,kind]=compound(b.dataset.resourceKind);navigate("resource",`${iid}|${kind}`);return}
if(b.dataset.savePhoto){const i=getIntervention(state.intervention),img=i.images.find(x=>x.id===b.dataset.savePhoto),input=$(`[data-photo-note="${CSS.escape(b.dataset.savePhoto)}"]`);if(img&&input){img.note=input.value;toast("Note d'image enregistrée","La légende a été mise à jour.","ok")}return}
if(b.dataset.deleteImage){const i=getIntervention(state.intervention);i.images=i.images.filter(x=>x.id!==b.dataset.deleteImage);render();toast("Image supprimée","L'image a été retirée.");return}
if(b.dataset.openCompte){navigate("compte",b.dataset.openCompte);return}
if(b.dataset.openReports){navigate("reports",b.dataset.openReports);return}
if(b.dataset.reportTemplate){navigate("reportform",b.dataset.reportTemplate);return}
if(b.dataset.openEmailContext){navigate("email",b.dataset.openEmailContext);return}
if(b.dataset.emailMode){navigate("email",b.dataset.emailMode,{push:false});return}
if(b.dataset.clearSign){clearSign(b.dataset.clearSign);return}
if(b.hasAttribute("data-save-compte")){const form=id("compteRenduForm");storage.setItem(`omika-compte-${state.intervention}`,JSON.stringify(formObject(form)));toast("Brouillon enregistré","Le compte rendu fixe est sauvegardé localement.","ok");return}
if(b.hasAttribute("data-save-dynamic-report")){const form=id("dynamicReportForm");storage.setItem(`omika-report-${state.intervention}-${state.reportTemplate}`,JSON.stringify(formObject(form)));toast("Brouillon enregistré","Le formulaire du rapport est sauvegardé localement.","ok");return}
if(b.dataset.collab){state.teamCollab=b.dataset.collab;render();return}
if(b.dataset.teamView){state.teamView=b.dataset.teamView;render();return}
if(b.dataset.teamWeek){state.teamWeek=add(state.teamWeek,Number(b.dataset.teamWeek)*7);render();return}
if(b.hasAttribute("data-team-today")){state.teamWeek=weekStart(today);render();return}
if(b.hasAttribute("data-reset-search")){state.search={relation:"all"};render();return}
if(b.dataset.folder){state.folder=b.dataset.folder;render();return}
if(b.dataset.client){state.client=b.dataset.client;render();return}
if(b.dataset.location){state.location=b.dataset.location;render();return}
if(b.dataset.openClient){state.client=b.dataset.openClient;navigate("clients",null,{push:false,clear:true});return}
if(b.dataset.openLocation){state.location=b.dataset.openLocation;navigate("locations",null,{push:false,clear:true});return}
if(b.dataset.addIntervention){openInterventionModal(b.dataset.addIntervention);return}
if(b.dataset.addAppointment){openAppointmentModal(b.dataset.addAppointment);return}
if(b.hasAttribute("data-close-modal")){closeInterventionModal();return}
if(b.hasAttribute("data-close-appointment-modal")){closeAppointmentModal();return}
if(b.dataset.themeChoice){applyTheme(b.dataset.themeChoice,state.primary);render();return}
if(b.dataset.color){applyTheme(state.theme,b.dataset.color);render();return}
if(b.hasAttribute("data-save-theme")){storage.setItem("omika-theme",state.theme);storage.setItem("omika-primary",state.primary);toast("Préférences enregistrées","Le thème Omika sera restauré au prochain démarrage.","ok");return}
if(b.hasAttribute("data-sync")){syncNow();return}
});
document.addEventListener("change",e=>{if(e.target.id==="jumpDate"&&e.target.value){state.date=parse(e.target.value);render()}if(e.target.id==="customColor"){applyTheme(state.theme,e.target.value);render()}});
document.addEventListener("keydown",e=>{if((e.key==="Enter"||e.key===" ")&&e.target.matches(".appt")){e.preventDefault();navigate("appointment",e.target.dataset.openAppointment)}if(e.key==="Escape"){closeSheet();closeInterventionModal();closeAppointmentModal()}});
id("view").addEventListener("submit",e=>{e.preventDefault();
if(e.target.id==="searchForm"){state.search=searchValues();render();return}
if(e.target.id==="timeForm"){if(!e.target.checkValidity())return e.target.reportValidity();const d=Object.fromEntries(new FormData(e.target));if(timeToMinutes(d.end)<=timeToMinutes(d.start))return toast("Horaires invalides","L'heure de fin doit être postérieure à l'heure de début.","error");const a=getAppt(state.appointment);a.timeEntries.push({id:`TEM-${timeEntrySeq++}`,type:d.type,start:d.start,end:d.end,note:d.note||""});render();toast("Temps ajouté",`${timeTypeInfo[d.type][0]} · ${d.start}–${d.end}`,"ok");return}
if(e.target.id==="resourceNoteForm"){if(!e.target.checkValidity())return e.target.reportValidity();const text=new FormData(e.target).get("note").trim();const i=getIntervention(state.intervention);i.notes.unshift({author:"Camille Bernard",date:iso(today),text});render();toast("Note ajoutée","La note interne a été ajoutée à l'intervention.","ok");return}
if(e.target.id==="compteRenduForm"){if(!e.target.checkValidity())return e.target.reportValidity();if(!state.signatures.tech||!state.signatures.client)return toast("Signatures manquantes","Les signatures du technicien et du client sont obligatoires.","error");const i=getIntervention(state.intervention);i.compteRendu={status:"Validé",createdAt:iso(today),reference:`CR-${today.getFullYear()}-${String(100+Number(i.id.slice(-3))).padStart(4,"0")}`};storage.removeItem(`omika-compte-${i.id}`);toast("Compte rendu validé","Le formulaire fixe et les deux signatures sont enregistrés.","ok");state.navStack.pop();navigate("intervention",i.id,{push:false});return}
if(e.target.id==="dynamicReportForm"){if(!e.target.checkValidity())return e.target.reportValidity();const i=getIntervention(state.intervention),t=reportTemplates.find(x=>x.id===e.target.dataset.template),rid=`RAP-${today.getFullYear()}-${String(600+i.reports.length+Number(i.id.slice(-2))).padStart(4,"0")}`;i.reports.unshift({id:rid,templateId:t.id,title:t.name,createdAt:iso(today),status:"Créé",data:formObject(e.target)});storage.removeItem(`omika-report-${i.id}-${t.id}`);toast("Rapport créé",`${rid} est disponible et peut être envoyé par email.`,"ok");state.navStack.pop();navigate("reports",i.id,{push:false});return}
if(e.target.id==="emailForm"){if(!e.target.checkValidity())return e.target.reportValidity();const i=getIntervention(state.intervention),data=formObject(e.target),template=emailTemplates.find(t=>t.id===e.target.dataset.mode),report=i.reports.find(r=>r.id===e.target.dataset.report),draft=template?template.build(i,data,report):{subject:data.subject,body:data.body};const query=new URLSearchParams({subject:draft.subject,body:draft.body});if(data.cc)query.set("cc",data.cc);toast("Email créé","Le client de messagerie va s'ouvrir avec l'objet et le corps préparés.","ok");setTimeout(()=>{location.href=`mailto:${encodeURIComponent(data.to)}?${query.toString()}`},120);return}
});
id("newInterventionForm").addEventListener("submit",e=>{e.preventDefault();if(!e.target.checkValidity())return e.target.reportValidity();const d=Object.fromEntries(new FormData(e.target)),f=folders.find(x=>x.id===d.folder)||folders[0],newId=`INT-${today.getFullYear()}-${String(1101+interventions.length).padStart(4,"0")}`;const item={id:newId,client:d.client,location:d.location,owner:d.owner,status:"planned",priority:d.priority,title:d.title,short:d.description||"Nouvelle intervention créée dans Omika.",detail:d.description||"Description à compléter.",opened:iso(today),due:iso(add(today,30)),internalSummary:"Intervention créée sans rendez-vous. Une planification pourra être ajoutée ultérieurement.",notes:[{author:"Camille Bernard",date:iso(today),text:"Intervention créée depuis le dossier, sans rendez-vous associé."}],documents:[],images:[],reports:[],compteRendu:null};interventions.push(item);f.interventions.push(newId);state.intervention=newId;closeInterventionModal();e.target.reset();toast("Intervention créée",`${newId} a été créé sans rendez-vous.`,"ok");navigate("intervention",newId)});
id("newAppointmentForm").addEventListener("submit",e=>{e.preventDefault();if(!e.target.checkValidity())return e.target.reportValidity();const d=Object.fromEntries(new FormData(e.target)),i=getIntervention(d.intervention);if(timeToMinutes(d.to)<=timeToMinutes(d.from))return toast("Horaires invalides","L'heure de fin doit être postérieure à l'heure de début.","error");const newId=`RDV-${String(1801+appointments.length).padStart(5,"0")}`,a={id:newId,interventionId:i.id,date:d.date,from:d.from,to:d.to,client:i.client,location:i.location,collaborator:d.collaborator,status:"planned",title:i.title,description:d.description||"Rendez-vous planifié depuis la fiche intervention.",timeEntries:[]};appointments.push(a);appointments.sort((x,y)=>(x.date+x.from).localeCompare(y.date+y.from));state.appointment=newId;closeAppointmentModal();e.target.reset();toast("Rendez-vous planifié",`${newId} est lié à ${i.id}.`,"ok");navigate("appointment",newId)});
id("moreButton").onclick=openSheet;id("menuButton").onclick=openSheet;id("closeSheet").onclick=closeSheet;id("moreSheet").onclick=e=>{if(e.target===id("moreSheet"))closeSheet()};id("modalWrap").onclick=e=>{if(e.target===id("modalWrap"))closeInterventionModal()};id("appointmentModalWrap").onclick=e=>{if(e.target===id("appointmentModalWrap"))closeAppointmentModal()};id("syncTop").onclick=syncNow;id("syncMobile").onclick=syncNow;id("themeTop").onclick=()=>{applyTheme(state.theme==="dark"?"light":"dark",state.primary);render()};window.addEventListener("hashchange",render);applyTheme();showSync();if(!location.hash)location.hash="#home";else render();
</script>
</body>
</html>
+62
View File
@@ -0,0 +1,62 @@
# Modèles de courriel et de rapport — génération compatible Batik / Omika
> Implémentation de la **génération d'un email/rapport à partir d'un modèle**
> (substitution des rubriques), utilisable dans l'application mobile quelle que
> soit la base connectée (Batik ou Omika).
## 1. Comportement reproduit
En WINDEV, la génération d'un email/rapport correspond à :
1. choisir un modèle (`MailModele` / `ModeleRapport`) ;
2. insérer des rubriques/mots-clés (`MotClef`, `RubriquesDocument`) ;
3. substituer ces jetons par les données réelles (client, intervention, dates…) ;
4. prévisualiser puis envoyer/enregistrer.
La présente implémentation reproduit l'étape 3 (substitution) côté .NET, avec une
dégradation propre sur Batik (qui n'a ni `ModeleRapport`, ni `RubriquesDocument`).
## 2. Convention provisoire de jetons
Les jetons sont délimités par `[[` et `]]`, insensibles à la casse :
```
Bonjour [[NomClient]], intervention du [[DateIntervention]].
```
La clé correspond au `MotClef.Mot` (ou `MotClef.Code`, ou à une rubrique Omika).
> ⚠️ **Convention provisoire.** La syntaxe réelle de `LIB_RubMailRapport` /
> `LIB_ModelesEmails` n'est pas dans les exports WINDEV. Le moteur est isolé dans
> `Omika.Core/Services/RubriqueSubstitutionEngine.cs` : il suffit d'ajuster le
> délimiteur pour s'aligner sur la syntaxe exacte lorsque celle-ci sera connue.
## 3. Compatibilité Batik / Omika
| Capacité | Batik | Omika |
|---|---|---|
| Modèles de courriel (`MailModele`) | ✅ (sans `MM_Extra`/`MM_EnvoiMob`) | ✅ |
| Modèles de rapport | lecture seule via l'ancien `ModeleRapportMob` | `ModeleRapport` |
| Mots-clés de substitution (`MotClef`) | ✅ | ✅ |
| Rubriques structurées (`RubriquesDocument`) | ❌ (liste vide) | ✅ |
La dégradation s'appuie sur `SchemaCapabilities.SupportsReportModels` /
`SupportsDocumentFields` et sur les repositories adaptatifs.
## 4. Endpoints ajoutés
- `GET /modele-mails` / `GET /modele-mails/{id}`
- `GET /modele-rapports` / `GET /modele-rapports/{id}`
- `GET /mot-clefs`
- `GET /rubriques-documents` (vide sur Batik)
- `POST /generation/email` — `{ modelId, values, langue? }` → `{ sujet, corps, unknownTokens }`
- `POST /generation/rapport`
## 5. Reste à faire (côté mobile)
1. Appeler les endpoints depuis `Omika.App.Local` (`MobileApiClient`) ;
2. mettre en cache les modèles/mots-clés (SQLite) ;
3. appliquer la substitution **localement** avec `RubriqueSubstitutionEngine`
(déjà dans `Omika.Core`, donc disponible sur mobile) ;
4. écran « Générer un email/rapport » (Avalonia) + envoi du courriel via
`MessageExterne` ou enregistrement du rapport comme `Document`.
+327
View File
@@ -0,0 +1,327 @@
# Niveau 3 — Modèle métier canonique et contrats API
## Rôle des dossiers de `Omika.Core`
### `Domain/`
Le domaine contient les **entités métier canoniques**, indépendantes du stockage.
Elles correspondent aux concepts fonctionnels de l'application :
- Client, Adresse, Contact, Site ;
- Societe, Collaborateur, Equipe, Utilisateur ;
- Dossier, Intervention, ActionIntervention, CompteRendu, Checklist ;
- RendezVous et ses éléments associés ;
- Document et modèles de courrier/rapport ;
- Role, Permission, DroitAcces ;
- ParametreMetier, ParametreApplication, PreferenceUtilisateur.
Elles ne portent :
- aucun attribut HFSQL ;
- aucun préfixe de rubrique physique (`CL_`, `IN_`, `PL_`, `RDV_`…) ;
- aucun champ zombie.
Les **objets de valeur** (`ValueObjects/`) représentent des valeurs sans identité :
- `ReferenceValue` (valeur de référentiel configurable) ;
- `DateTimeRange` (plage horaire validée) ;
- `PostalAddress`, `ContactDetails` ;
- `ExternalReference` (référence vers Batigest / base externe).
Les **exceptions** (`Exceptions/`) portent les erreurs de validation du domaine.
### `Contracts/`
Les contrats sont utilisés entre `Omika.Api` et `Omika.App`.
Ce sont des DTO purs :
- résumés de liste ;
- détails ;
- requêtes de création / modification / recherche ;
- DTO communs (pagination, enveloppe d'erreur, référence, adresse, coordonnées).
**Distinction `AddressDto` / `AdresseDto` :**
- `AddressDto` (`Contracts/Common`) : adresse postale simple, sans identité
(libellé, lignes, code postal, ville, pays). Réutilisée partout où une
adresse postale brute est nécessaire.
- `AdresseDto` (`Contracts/Clients`) : adresse d'une entité du domaine Clients,
avec identifiant, type, indicateurs (« principale », « archivée ») et une
adresse postale optionnelle (`AdressePostale`). Il ne s'agit pas d'un doublon
de `AddressDto` : l'un est une valeur, l'autre un conteneur d'entité.
Ils ne contiennent :
- aucun attribut HFSQL ;
- aucune propriété à préfixe physique ;
- aucune dépendance vers `Omika.Data` ;
- aucun champ zombie ;
- aucun mot de passe ni objet de connexion ;
- aucun graphe cyclique.
### `Compatibility/`
Les types de compatibilité conservent la **provenance** des données sans exposer
la structure physique :
- `DatabaseSchemaVersion` (Unknown / Batik / Omika) ;
- `DatabaseSource` (version + nom logique de base) ;
- `LegacyEntityReference` (référence vers l'enregistrement d'origine) ;
- `SchemaCapabilities` (capacités déclarées d'un schéma) ;
- `CanonicalMappingResult<T>` (résultat d'un mapping physique → canonique).
### `Interfaces/`, `Abstractions/`, `Constants/`, `Runtime/`, `Mapping/`
- `Interfaces/` : services métier (voir ci-dessous).
- `Abstractions/` : abstractions techniques existantes, non touchées.
- `Constants/` : constantes existantes.
- `Runtime/` : contexte d'exécution existant, non touché.
- `Mapping/` : mappers purs entité métier → DTO.
## Différence entre une entité métier et un modèle physique
| Modèle physique (Omika.Data) | Entité métier (Omika.Core.Domain) |
|---|---|
| Correspond aux tables HFSQL (`Document.fic`, `Planning_RDV`…) | Correspond aux concepts fonctionnels (Document, RendezVous…) |
| Porte des rubriques préfixées (`DC_Categorie`, `PL_PKID`…) | Porte des propriétés métier nommées sans préfixe |
| Marque les rubriques zombie | Ne contient jamais de champ zombie |
| Connaît les clés internes et la version physique | Connaît uniquement les identifiants canoniques et la provenance |
La chaîne attendue :
```
Modèle physique Batik (Isis_BO) Modèle physique Omika
| |
+----------> Modèle métier canonique <-+
|
v
Contrat API
|
v
Application Avalonia
```
Le mapping **physique → métier** reste dans `Omika.Data`.
Le mapping **métier → DTO** est dans `Omika.Core/Mapping`.
## Différence entre une entité métier et un DTO
| Entité métier | DTO (contrat) |
|---|---|
| Évolue dans le temps (classe) | Immutable par convention (`record` avec propriétés `init`) |
| Expose des collections contrôlées (`Add*`) | Expose des listes `IReadOnlyList<T>` en lecture seule |
| Peut contenir des objets de valeur du domaine | Ne contient que des DTO ou types primitifs |
| Vit dans `Domain/` | Vit dans `Contracts/` |
| N'est jamais sérialisé tel quel | Est la frontière de l'API |
## Stratégie des identifiants
Les identifiants publics des contrats sont des **chaînes** afin de permettre
temporairement :
- le GUID Omika ;
- l'identifiant historique Batik (Isis_BO) ;
- une clé métier.
Le domaine utilise `Guid Id` (identifiant canonique) plus
`LegacyEntityReference? SourceReference` pour conserver la référence d'origine
sans exposer la clé physique.
`LegacyEntityReference.PublicIdentifier` fournit une représentation publique
stable (GUID → clé métier → identifiant numérique → GUID physique → ImportId).
Un helper de représentation publique des identifiants peut être ajouté plus tard
si le besoin se fait sentir côté API/mobile.
## Stratégie des paramètres métier
Les paramètres métier (type de client, statut, type d'activité, priorité…) sont
représentés par `ReferenceValue` :
- `Key` : clé métier stable (nouveau schéma Omika) ;
- `LegacyId` : identifiant historique (ancien schéma Batik) ;
- `Code` : code court éventuel ;
- `Label` : libellé d'affichage.
Ils ne sont **pas** transformés automatiquement en `enum` : ils restent
configurables. Le passage en enum n'est envisagé que si un paramètre devient
vraiment stable et fermé.
## Stratégie des champs zombie
Les rubriques physiques obsolètes (anciens identifiants de civilité, de statut,
clés internes…) sont marquées **zombie** dans les modèles physiques de
`Omika.Data`.
Dans `Omika.Core` :
- elles n'apparaissent jamais comme propriétés publiques ;
- le mapper de `Omika.Data` est responsable de convertir les valeurs en
propriétés métier (`ReferenceValue`, libellés, etc.) ;
- les tests d'architecture interdisent leur apparition dans les contrats.
## Stratégie de compatibilité Batik (Isis_BO) / Omika
- Le domaine est **unique** pour les deux bases.
- La provenance est conservée via `LegacyEntityReference` / `DatabaseSource`.
- `SchemaCapabilities` permet de savoir, sans connaître la structure physique,
si une fonctionnalité est disponible dans la version source
(clés métier, GUID, rôles, nouveau modèle de rendez-vous…).
- `CanonicalMappingResult<T>` permet à un mapper physique → canonique de
retourner la valeur plus les warnings, champs ignorés et erreurs non bloquantes.
## Cas particulier des rendez-vous
Un **agrégat métier unique** `RendezVous` reçoit les données des deux versions :
- ancien modèle : `Planning_RDV`, `Planning_RDV_Personne` ;
- nouveau modèle : `RDV_Planning`, `RDV_Collaborateur`, `RDV_Contact`.
L'entité porte :
- une `DateTimeRange Periode` ;
- des `AffectationCollaborateur` (responsable, période particulière, statut,
temps validé) ;
- des `ParticipantRendezVous` (contact, coordonnées, type, présence confirmée) ;
- une `AnnulationRendezVous` éventuelle ;
- des liens optionnels vers Client / Site / Dossier / Intervention.
Toute information absente dans une version est **nullable**.
Le contrat `RendezVousDetailsDto` est **identique** pour les deux versions et ne
contient aucune notion de `Planning_RDV`, `RDV_Planning`, `PL_PKID`,
`RDP_GUID` ou autre nom physique.
## Liste des fichiers créés
### `Omika.Core/Compatibility`
- `DatabaseSchemaVersion.cs`
- `DatabaseSource.cs`
- `LegacyEntityReference.cs`
- `SchemaCapabilities.cs`
- `CanonicalMappingResult.cs`
### `Omika.Core/Domain/Common`
- `EntityBase.cs`
### `Omika.Core/Domain/ValueObjects`
- `ReferenceValue.cs`
- `DateTimeRange.cs`
- `PostalAddress.cs`
- `ContactDetails.cs`
- `ExternalReference.cs`
### `Omika.Core/Domain/Exceptions`
- `DomainException.cs`
- `DomainValidationException.cs`
### `Omika.Core/Domain/Entities`
- `Clients/Client.cs`, `Adresse.cs`, `Contact.cs`, `Site.cs`
- `Organisation/Societe.cs`, `Collaborateur.cs`, `Equipe.cs`, `Utilisateur.cs`
- `Interventions/Dossier.cs`, `Intervention.cs`, `ActionIntervention.cs`,
`CompteRendu.cs`, `EtatIntervention.cs`, `Checklist.cs`, `ChecklistItem.cs`
- `Planning/RendezVous.cs`, `AffectationCollaborateur.cs`,
`ParticipantRendezVous.cs`, `AnnulationRendezVous.cs`, `SuiviTemps.cs`
- `Documents/Document.cs`, `NoteDocument.cs`, `MessageInterne.cs`,
`MessageExterne.cs`, `ModeleMail.cs`, `ModeleRapport.cs`
- `Security/Role.cs`, `Permission.cs`, `DroitAcces.cs`
- `Configuration/ParametreMetier.cs`, `ParametreApplication.cs`,
`PreferenceUtilisateur.cs`
### `Omika.Core/Contracts/Common`
- `ApiErrorDto.cs`, `ApiResult.cs`, `PagedRequest.cs`, `PagedResult.cs`
- `ReferenceValueDto.cs`, `AddressDto.cs`, `ContactDetailsDto.cs`
### `Omika.Core/Contracts/Clients`
- `ClientSummaryDto.cs`, `ClientDetailsDto.cs`
- `CreateClientRequest.cs`, `UpdateClientRequest.cs`, `SearchClientsRequest.cs`
- `AdresseDto.cs`, `ContactDto.cs`, `SiteDto.cs`
### `Omika.Core/Contracts/Organisation`
- `CollaborateurSummaryDto.cs`, `CollaborateurDetailsDto.cs`
- `EquipeDto.cs`, `SocieteDto.cs`, `UtilisateurDto.cs`
### `Omika.Core/Contracts/Interventions`
- `DossierSummaryDto.cs`, `DossierDetailsDto.cs`
- `InterventionSummaryDto.cs`, `InterventionDetailsDto.cs`
- `CreateInterventionRequest.cs`, `UpdateInterventionRequest.cs`,
`SearchInterventionsRequest.cs`
- `ActionInterventionDto.cs`, `CompteRenduDto.cs`, `EtatInterventionDto.cs`
### `Omika.Core/Contracts/Planning`
- `RendezVousSummaryDto.cs`, `RendezVousDetailsDto.cs`
- `CreateRendezVousRequest.cs`, `UpdateRendezVousRequest.cs`,
`SearchRendezVousRequest.cs`
- `AffectationCollaborateurDto.cs`, `ParticipantRendezVousDto.cs`,
`AnnulationRendezVousDto.cs`, `SuiviTempsDto.cs`, `CreateSuiviTempsRequest.cs`
### `Omika.Core/Contracts/Documents`
- `DocumentSummaryDto.cs`, `DocumentDetailsDto.cs`, `CreateDocumentRequest.cs`
- `ModeleMailDto.cs`, `ModeleRapportDto.cs`
### `Omika.Core/Contracts/Security`
- `LoginRequest.cs`, `CurrentUserDto.cs`, `RoleDto.cs`, `PermissionDto.cs`
### `Omika.Core/Contracts/Configuration`
- `ParametreMetierDto.cs`, `ApplicationConfigurationDto.cs`,
`PreferenceUtilisateurDto.cs`
### `Omika.Core/Interfaces`
- `IClientService.cs`, `IInterventionService.cs`, `IRendezVousService.cs`,
`IDocumentService.cs`, `IReferenceDataService.cs`, `ICurrentUserService.cs`
### `Omika.Core/Mapping`
- `ClientContractMapper.cs`, `InterventionContractMapper.cs`,
`RendezVousContractMapper.cs`, `DocumentContractMapper.cs`
### `Omika.Tests`
- Architecture : `CoreMustNotReferenceDataTests`,
`CoreMustNotReferenceAvaloniaTests`, `CoreMustNotReferenceAspNetCoreTests`,
`PublicContractsMustNotContainHfSqlNamesTests`,
`PublicContractsMustNotContainZombieFieldsTests`
- Contrats : `PagedResultTests`, `ClientContractTests`,
`InterventionContractTests`, `RendezVousContractTests`
- Domaine : `DateTimeRangeTests`, `ClientDomainTests`,
`InterventionDomainTests`, `RendezVousDomainTests`,
`RendezVousCrossSchemaTests`
- Compatibilité : `LegacyEntityReferenceTests`, `SchemaCapabilitiesTests`
## Décisions encore à valider
1. **Identifiants publics** : chaînes temporaires (GUID / legacy / clé métier).
Un helper dédié ou un type valeur « PublicId » reste possible.
2. **États et statuts** : conservés en `ReferenceValue`. À confirmer si certains
deviendront des enums stables.
3. **`DateTime` vs `DateTimeOffset`** : pas de gestion multi-fuseaux documentée
pour l'instant ; `DateTime` est utilisé partout. À revoir si le mobile doit
synchroniser sur plusieurs fuseaux.
4. **Collections dans les entités** : `List<T>` privées avec méthodes `Add*`.
À confirmer si des collections doivent devenir remplaçables (remplacement
complet lors d'une mise à jour).
5. **Contrat de recherche** : `SearchClientsRequest` hérite de `PagedRequest`
avec des propriétés additionnelles. Les filtres seront consolidés lors de
l'implémentation des services.
6. **Sécurité** : `LoginRequest` accepte un mot de passe temporaire, mais aucun
service d'authentification n'est implémenté à ce niveau. Le stockage des
identifiants (hash, salt) sera décidé au niveau supérieur.
7. **Fichiers physiques des documents** : les contrats exposent des identifiants
logiques et des métadonnées ; la gestion du téléversement/téléchargement sera
définie avec l'API.
+101
View File
@@ -0,0 +1,101 @@
# Observabilité externe Omika — Phase 5
Centralisation externe **optionnelle** de l'observabilité :
```text
OpenTelemetry (adapter Omika) → OpenTelemetry Collector → SigNoz → ClickHouse
```
> **Règle absolue** : *Les logs servent Omika. Omika ne dépend jamais des logs.*
> OpenTelemetry, Collector, SigNoz et ClickHouse apportent visibilité, recherche,
> diagnostic et corrélation. Ils n'apportent **aucune** dépendance au fonctionnement
> métier. En cas de doute : **GARANTIR OMIKA**.
## Sommaire
| Document | Contenu |
|---|---|
| [configuration.md](configuration.md) | Options `Observability:External`, kill switch, endpoints |
| [deployment.md](deployment.md) | VM, ressources, ports, installation, démarrage, rétention, sauvegarde |
| [support-guide.md](support-guide.md) | Retrouver un problème dans SigNoz depuis un TraceId |
| [unplug.md](unplug.md) | Supprimer entièrement la pile externe sans casser Omika |
| [data-whitelist.md](data-whitelist.md) | Inventaire des données exportées (liste blanche officielle) |
## Architecture finale
```text
┌── LocalFileTelemetryExporter (JSONL local)
│
IOmikaTelemetry ├── DiagnosticOutboxExporter (omika-diagnostics.db)
↓ │
SafeTelemetrySink ──┤
↓ │
PriorityQueue └── OpenTelemetryExporterAdapter ←─ RemoteDiagnosticForwarder
↓ │ ↑
TelemetryWorker OTLP HTTP/protobuf │
↓ (relit le JSONL serveur)
OTel Collector RemoteDiagnosticIngestionStore
↓ ↑
SigNoz POST /api/diagnostics
↓ ↑
ClickHouse Mobile
```
**Points clés :**
1. **OpenTelemetry est UN exporter optionnel**, derrière l'architecture Omika
(`IOmikaTelemetry → SafeTelemetrySink → PriorityTelemetryQueue → TelemetryWorker → exporters`).
Jamais devant, jamais référencé par le métier.
2. **Le mobile ne parle jamais OTLP directement.** Flux mobile :
`Mobile → POST /api/diagnostics → RemoteDiagnosticIngestionStore (JSONL serveur) → ACK`.
Le forwarding vers l'externe (`RemoteDiagnosticForwarder`) est asynchrone et
best-effort, **après** l'ACK.
3. **`POST /api/diagnostics` ne dépend jamais du Collector.** L'ACK signifie
uniquement « le serveur Omika a persisté localement le diagnostic ».
4. **Kill switch** `Observability:External:Enabled=false` (défaut) : désactive
l'adaptateur OTel et le forwarder externe, sans toucher au local.
5. **Fail-open** : arrêter Collector/SigNoz/ClickHouse, bloquer le port OTLP,
éteindre la VM, DNS/TLS invalide → **Omika fonctionne normalement** (seule
conséquence : absence temporaire de centralisation).
## Choix du protocole OTLP (§11)
| Critère | gRPC | HTTP/protobuf |
|---|---|---|
| Dépendance native | `Grpc.Net.Client` (HTTP/2, ALPN) | `HttpClient` pur |
| Simplicité Windows | moyenne (TLS/HTTP2) | **haute** |
| Firewall | un port (4317) | un port (4318) |
| Debug/curl | non | **oui** (POST clair) |
**Choix : OTLP HTTP/protobuf** (port 4318) — un seul protocole, `HttpClient` pur,
simple à déployer entre l'API Windows et le Collector. Le gRPC reste configuré côté
Collector pour interopérabilité mais n'est pas utilisé par Omika.
## Versions (réellement installées, §10)
| Package / service | Version | Notes |
|---|---|---|
| `OpenTelemetry` (SDK .NET) | **1.13.0** | stable, compatible .NET 10 |
| `OpenTelemetry.Exporter.OpenTelemetryProtocol` | **1.13.0** | train 1.13 aligné |
| OpenTelemetry Collector | 0.128.x | distribution contrib |
| SigNoz | 0.72.x (à épingler au stable officiel) | via installateur officiel |
Les packages OpenTelemetry sont localisés **uniquement** dans le projet
`src/Omika.Observability.External`. Aucun package OpenTelemetry dans `Omika.Core`,
ni dans `Omika.App`/`Omika.App.Local`/`Omika.Data` (garanti par un test d'architecture).
## Identifiants — ne pas confondre (§33/§34)
| Identifiant | Format | Rôle | Où |
|---|---|---|---|
| **Omika TraceId** | GUID (`X-Omika-TraceId`) | corrélation applicative **principale** | header HTTP, attribut `omika.trace_id` |
| **OpenTelemetry TraceId** | 128 bits | trace native OTel | dérivé déterministiquement du GUID Omika |
| **OpenTelemetry SpanId** | 64 bits | span OTel | généré par le SDK (logé en `omika.span_id`) |
| **SessionId** | GUID | une exécution d'app | attribut `omika.session_id` |
| **OperationId** | GUID | une opération métier/technique | attribut `omika.operation_id` |
| **EventId** | GUID | un événement unique (dédup/doublons) | attribut + identifiant du log |
| **BatchId** | GUID | un lot de transport mobile | Phase 4 |
Le contrat `X-Omika-TraceId` reste **inchangé** : c'est l'identifiant visible par
Omika/support. L'OTel TraceId est un mapping déterministe (GUID = 16 octets = 128 bits),
jamais un remplacement silencieux du contrat.
+80
View File
@@ -0,0 +1,80 @@
# Configuration de l'observabilité externe
## Section `Observability:External` (§74)
```json
{
"Observability": {
"External": {
"Enabled": false,
"OtlpEndpoint": "",
"ExportTimeoutSeconds": 3,
"MaxQueueSize": 2048,
"MaxExportBatchSize": 512,
"ScheduledDelayMilliseconds": 2000,
"CircuitFailureThreshold": 5,
"CircuitCooldownSeconds": 30,
"IncludeP4": false
}
}
}
```
| Clé | Défaut | Rôle |
|---|---|---|
| `Enabled` | `false` | **Kill switch**. `false` = aucun export, aucun réseau, aucun thread externe. |
| `OtlpEndpoint` | `""` | Endpoint OTLP HTTP/protobuf du Collector (ex. `http://10.0.0.10:4318`). |
| `ExportTimeoutSeconds` | `3` | Timeout réseau court d'un export OTLP (2–5 s max, §13). |
| `MaxQueueSize` | `2048` | File SDK OTel bornée (§14). |
| `MaxExportBatchSize` | `512` | Taille max d'un lot OTel. |
| `ScheduledDelayMilliseconds` | `2000` | Délai de regroupement du batch. |
| `CircuitFailureThreshold` | `5` | Échecs consécutifs avant ouverture du circuit. |
| `CircuitCooldownSeconds` | `30` | Durée d'ouverture du circuit avant sonde. |
| `IncludeP4` | `false` | Exporter les événements P4 (analytics). Non exportés par défaut (§44). |
Autres clés (forwarder) : `ForwarderBatchSize` (100), `ForwarderIntervalSeconds` (15),
`CheckpointFileName` (`forwarder-checkpoint.json`).
## Default safe (§18)
**`Enabled=false` par défaut.** Activation explicite après installation et validation
du Collector (test §88). Ne jamais activer en production sans avoir validé la chaîne.
## Kill switch (§17)
Mettre `Enabled=false` désactive **entièrement** :
- `OpenTelemetryExporterAdapter` (aucun provider, aucun réseau, aucun thread) ;
- `RemoteDiagnosticForwarder` externe (la boucle reste au ralenti).
Sans désactiver (inchangé) :
- `LocalFileTelemetryExporter` (JSONL local) ;
- `DiagnosticOutbox` (`omika-diagnostics.db`) ;
- `POST /api/diagnostics` ;
- `RemoteDiagnosticIngestionStore` (JSONL serveur).
## Configuration invalide (§75)
`Enabled=true` + `OtlpEndpoint=""` (ou URL non http/https, ou bornes invalides) :
- l'adaptateur externe est **désactivé** (NoOp) ;
- un warning `EmergencyLogger` est écrit ;
- **Omika démarre et fonctionne normalement** (jamais de crash au démarrage).
## Environnements (§73)
| Environnement | `Enabled` | Endpoint |
|---|---|---|
| Development | `false` (ou `true` + endpoint local) | `http://localhost:4318` |
| Production | explicite | `http://<ip-vm-observability>:4318` |
Les endpoints ne sont **jamais codés en dur** dans le code : ils viennent de la
configuration (appsettings / variables d'environnement / secrets).
## Secrets (§30)
Aucun secret dans un fichier versionné. Les credentials éventuels (ClickHouse) sont
fournis via `.env` (non versionné) / variables d'environnement. **Ne jamais réutiliser**
un mot de passe utilisateur, un token mobile, une clé licence ou un secret SMTP pour
l'observabilité.
+67
View File
@@ -0,0 +1,67 @@
# Liste blanche des données exportées (§79)
> On n'envoie **jamais** « tout parce que c'est utile ». Seuls les attributs ci-dessous
> sont exportés vers la plateforme externe. Tout le reste (identité métier, corps HTTP,
> contenus, secrets) reste **local**.
## Attributs de logs (exportés)
| Attribut | Raison | Cardinalité | Sensibilité | Rétention |
|---|---|---|---|---|
| `event.code` | pivot de normalisation (§39) | moyenne (catalogue borné) | faible | logs |
| `level` | sévérité | faible (6 valeurs) | faible | logs |
| `event.priority` | priorité P0–P4 | faible (5 valeurs) | faible | logs |
| `component` | émetteur | faible (liste bornée) | faible | logs |
| `omika.trace_id` | corrélation | **haute** (GUID) — OK en log, jamais en métrique | faible (GUID, pas de PII) | logs |
| `omika.session_id` | corrélation | **haute** (GUID) | faible | logs |
| `omika.operation_id` | corrélation | **haute** (GUID) | faible | logs |
| `app.version` | diagnostic par version | faible | faible | logs |
| `platform` | plateforme | faible (Windows/Android/iOS) | faible | logs |
| `os.name` / `os.version` | contexte appareil | faible | faible | logs |
| `device.model` / `device.manufacturer` | contexte appareil | faible | faible | logs |
| `error.code` | code d'erreur terminal | moyenne (codes catalogués) | faible | logs |
| `event.duration_ms` | durée | numérique | faible | logs |
| `event.success` | issue | booléen | faible | logs |
| `event.entity_type` | type d'entité | faible (énum borné) | faible | logs |
| métadonnées **filtrées** | contexte contrôlé | bornée (≤ 16, chaînes ≤ 256) | filtrée | logs |
## Attributs **jamais** exportés (§38/§71)
```text
Authorization, Password, Token, Cookie, ConnectionString,
RequestBody, ResponseBody, EmailBody, SMTP password,
documents, photos, signatures
```
et les identités métier : `DeviceId`, `ClientId`, `UserId`, `CollaborateurId`, `EntityId`
(restent locaux, uniquement utiles côté Omika).
## Labels de métriques (autorisés, low-cardinality, §37)
```text
component, status_class, method, route_template,
event_priority, level, result, reason, signal
```
**Interdit** comme label de métrique (§36) :
```text
TraceId, SpanId, SessionId, OperationId, EventId, BatchId,
UserId, ClientId, CollaborateurId, DeviceId, EntityId,
Email, URL complète, GUID métier
```
## Métriques techniques (premières, §35)
```text
omika.telemetry.accepted omika.telemetry.dropped
omika.telemetry.queue.depth
omika.external.exported omika.external.dropped
omika.external.circuit_state
omika.api.requests omika.api.errors
omika.api.duration
omika.diagnostic.forwarder.forwarded / .failure (côté API)
```
+94
View File
@@ -0,0 +1,94 @@
# Déploiement de la pile d'observabilité
## Hébergement recommandé (§21)
```text
Serveur Windows
│
├── Omika API / Worker / services métier
│
└── Hyper-V
↓
VM Ubuntu Observability
├── OpenTelemetry Collector
├── SigNoz
└── ClickHouse
```
Objectif : isoler **processus / RAM / CPU / disque** de l'observabilité du métier.
Le métier Omika a **toujours priorité** sur les ressources du serveur.
Si Hyper-V n'est pas disponible : héberger la pile sur une **machine Ubuntu distincte**
(ou un hôte Docker distinct) ; le principe d'isolation reste identique.
## Ressources initiales proposées (§23, à ajuster)
| Ressource | VM observabilité | Notes |
|---|---|---|
| vCPU | 2 (plafond 4) | ne pas allouer tous les cœurs du serveur |
| RAM | 8 Go (plafond 12 Go) | ClickHouse + SigNoz + Collector |
| Disque | 100 Go (volume dédié) | ne pas remplir le disque système Windows |
## Ports / firewall (§26, §U)
| Port | Service | Exposition |
|---|---|---|
| 4318 | Collector Omika — OTLP HTTP/protobuf | **réseau privé uniquement** (API/Worker Omika) |
| 4317 | Collector Omika — OTLP gRPC | réseau privé (désactivable) |
| 3301 | SigNoz UI | accès support autorisé uniquement (VPN/reverse-proxy + auth) |
| 8085 | SigNoz query-service | interne à la pile |
| 8123/9000 | ClickHouse | interne à la pile, **jamais** public/mobile |
Règles :
- Le port OTLP n'est **jamais** exposé publiquement.
- SigNoz est réservé à l'équipe dev/support (§27) ; ClickHouse jamais accessible
depuis le mobile/Internet/utilisateurs finaux (§28).
## Installation
1. Créer la VM Ubuntu (ressources ci-dessus).
2. Installer Docker + Docker Compose.
3. Copier `infra/observability/` sur la VM.
4. `cp .env.example .env` puis renseigner les secrets (mot de passe ClickHouse).
5. Épingler les versions d'images SigNoz aux versions stables officielles.
6. `docker compose up -d`.
7. Valider la chaîne (§88) : envoyer un événement test depuis l'API et vérifier
qu'il apparaît dans SigNoz (recherche par `EventCode` puis `TraceId`).
## Démarrage / arrêt
```bash
docker compose up -d # démarrer
docker compose down # arrêter (ne supprime pas les volumes)
```
Arrêter la pile ne doit avoir **aucun** effet sur Omika (test d'acceptation majeur, §59).
## Stockage / disque (§24)
- Volume dédié pour ClickHouse (`clickhouse-data`), **jamais** le disque système Windows.
- Taille maximale raisonnable + alerte de remplissage à configurer (ex. 80 %).
- Le disque d'observabilité ne doit pas pouvoir remplir le disque système d'Omika.
## Rétention (§25, §X)
Proposé pour une première production (à ajuster au volume observé) :
| Signal | Rétention |
|---|---|
| Logs | 7 jours |
| Traces | 7 jours |
| Métriques | 30 jours |
Ne pas retenir indéfiniment. La rétention se configure côté SigNoz/ClickHouse (TTL).
## Sauvegarde
- Sauvegarder les fichiers de configuration (`otel-collector-config.yaml`, `.env`).
- Les données ClickHouse sont de la télémétrie : leur perte n'affecte jamais Omika
(la sécurité durable reste les JSONL locaux Omika + l'outbox mobile).
## Désinstallation complète
Voir [unplug.md](unplug.md).
+36
View File
@@ -0,0 +1,36 @@
# Guide support — retrouver un problème dans SigNoz (§80/§81)
## Identifiants utiles
| Identifiant | Où le trouver | Attribut SigNoz |
|---|---|---|
| **TraceId** (`X-Omika-TraceId`) | header de réponse API, log mobile | `omika.trace_id` |
| **SessionId** | header/log | `omika.session_id` |
| **OperationId** | log de synchronisation | `omika.operation_id` |
| **EventId** | payload diagnostic | identifiant du log / attribut |
## Scénario type (§81)
1. Un utilisateur signale une erreur.
2. On dispose d'un **TraceId** (visible dans la réponse API ou le log mobile).
3. Dans SigNoz → **Logs** → filtre `omika.trace_id = <GUID>`.
4. On retrouve, corrélés par ce TraceId :
- l'événement **mobile** (s'il a été forwardé) ;
- la **requête API** (span HTTP dérivée) ;
- l'**erreur serveur** (EventCode `OMK-API-...`).
Aucune donnée sensible n'est exposée : les attributs exportés sont uniquement ceux de
la [liste blanche](data-whitelist.md).
## Codes d'événements (pivot, §39)
Les `EventCode` restent le pivot de normalisation (`OMK-MOB-...`, `OMK-API-...`,
`OMK-DIAG-...`). Ne jamais remplacer ces codes par du texte libre. Rechercher par
`EventCode` pour agréger, puis affiner par `TraceId`/`SessionId`.
## Doublons (§42)
La chaîne est **at-least-once** : des doublons peuvent apparaître dans SigNoz
(réessai après panne, checkpoint perdu). Utiliser `EventId` pour les identifier.
La déduplication centrale n'est pas critique et peut être ajoutée plus tard si
nécessaire, sans rendre le pipeline critique.
+41
View File
@@ -0,0 +1,41 @@
# Procédure « unplug » — supprimer l'infrastructure externe sans casser Omika (§83)
## Objectif
Supprimer **entièrement** OpenTelemetry Collector / SigNoz / ClickHouse et vérifier
qu'Omika continue de fonctionner normalement. C'est un **test d'acceptation obligatoire**.
## Étapes
1. **Kill switch** : mettre `Observability:External:Enabled=false` dans la
configuration de l'API et du Worker.
2. **Redémarrer** l'API et le Worker (ou recharger la configuration).
3. **Arrêter la pile** sur la VM :
```bash
docker compose down
```
4. **Supprimer les volumes** (télémétrie externe) :
```bash
docker compose down -v
```
5. **Supprimer la VM** observabilité (Hyper-V) si la désinstallation est définitive.
6. **Vérifier** le fonctionnement complet d'Omika (critères §97/H) :
- `LocalFileTelemetryExporter` écrit les JSONL locaux ;
- `DiagnosticOutbox` (`omika-diagnostics.db`) fonctionne ;
- `POST /api/diagnostics` répond (ACK après persistance locale) ;
- `RemoteDiagnosticIngestionStore` écrit le JSONL serveur ;
- API métier, Worker, synchronisation, mobile : **tout fonctionne**.
## Résultat attendu
```text
Omika totalement fonctionnel.
Seule conséquence : absence de centralisation externe des diagnostics.
```
## Re-branchement (§91)
Réinstaller la pile, remettre `Enabled=true` avec un endpoint valide, redémarrer.
Les **nouveaux** événements recommencent à arriver. Aucun replay des événements
perdus hors mécanismes déjà persistés (les JSONL locaux + l'outbox restent la
sécurité durable).
+135
View File
@@ -0,0 +1,135 @@
# Phase 5 — Diagnostic Center (architecture & décisions)
> Outil SUPPORT uniquement (§13). PostgreSQL et OpenTelemetry ne sont **jamais**
> critiques pour Omika : `Mobile → DiagnosticOutbox → POST /api/diagnostics →
> RemoteDiagnosticIngestionStore (JSONL durable) → ACK mobile` reste inchangé (§4).
> L'import PostgreSQL et l'export OTLP sont **asynchrones et optionnels**.
## Architecture retenue
```text
APPLICATIONS OMIKA (Mobile / Desktop / API / Worker)
│
▼
IOmikaTelemetry
│
▼
SafeTelemetrySink
│
▼
PriorityTelemetryQueue (P0-P4)
│
┌──────┴─────────┐
▼ ▼
JSONL / Outbox OpenTelemetry (OTLP HTTP, optional, kill-switch)
durable Omika │
│ ▼
│ OpenTelemetry Collector (Windows Service)
│ │
▼ ▼
Diagnostic Import Omika Telemetry Backend ← transport finalisé Phase serveur
│ │
└────────┬────────┘
▼
PostgreSQL (omika_diagnostics)
│
▼
Omika Diagnostic Center (ASP.NET Core Web)
```
### Projets créés (§12)
| Projet | Rôle |
|---|---|
| `src/Omika.Diagnostics.Core` | Domain / models, abstractions repository, importer, whitelist sécurité |
| `src/Omika.Diagnostics.Data` | Persistance PostgreSQL (Npgsql direct), source JSONL, checkpoint |
| `src/Omika.Diagnostics.Worker` | Import asynchrone JSONL → PostgreSQL (BackgroundService, backoff) |
| `src/Omika.Diagnostics.Web` | Diagnostic Center Web (Razor Pages) + auth support/admin |
## Choix d'accès données (§25)
**Npgsql direct (pas d'ORM)**. Justification :
- Sous-système simple : 2 tables, peu de patrons de requêtes.
- Aucune « magie » (pas de mapping réflexif, pas d'EF Core) — SQL explicite et lisible.
- Pooling intégré de Npgsql (§24) : jamais une connexion par événement.
- Dapper réduirait le boilerplate mais ajoute une dépendance + réflexion inutile ici.
- EF Core est surdimensionné pour 2 tables.
Version : **Npgsql 10.0.3** (aligné .NET 10, présent dans le cache local du poste).
## Données interdites (§19) et whitelist (§18)
Réutilise `DiagnosticForbiddenData` (Phase 4) : `password`, `token`, `authorization`,
`cookie`, `connectionstring`, `requestbody`, `responsebody`, `emailbody`, `smtp`,
`imei`, `macaddress`, `advertisingid`, `signature`, `secret`.
Les métadonnées `metadata_json` (JSONB) ne sont **pas** un stockage libre :
`DiagnosticMetadataPolicy` n'autorise qu'une whitelist de clés low-cardinality
(`method`, `routeTemplate`, `statusCode`, `exceptionType`, `retryCount`, …) et rejette
toute clé/valeur interdite. Les champs d'identité métier (`DeviceId`, `UserId`,
`ClientId`, `CollaborateurId`, `EntityId`) ne sont **pas** persistés dans PostgreSQL
(confidentialité, §30/§31).
## Importeur JSONL → PostgreSQL (§20-§23)
`DiagnosticImporter.ImportOnceAsync()` : lecture bornée (`BatchSize`, défaut 100) →
mapping (whitelist) → `INSERT ... ON CONFLICT (event_id) DO NOTHING` (batch) →
avancement du checkpoint **uniquement après insertion réussie**.
- **at-least-once** : le checkpoint n'avance qu'après succès → doublons possibles,
dédupliqués par `event_id` unique en base (§16).
- **fail-open** (§22) : toute erreur PostgreSQL → résultat `Retryable`, JSONL intact,
checkpoint non avancé, backoff exponentiel borné (aucune boucle CPU).
- **idempotent** : relire un fichier déjà importé est sans effet (ON CONFLICT).
## Checkpoint (§21)
`import-checkpoint.json` (curseur `{ fileName, lineIndex }`), distinct du checkpoint
du forwarder OpenTelemetry. Reprise après restart / PostgreSQL down / reboot sans
réimporter tout l'historique. Les fichiers JSONL bruts ne sont **jamais** supprimés.
## Rétention (§45)
`Diagnostics:RetentionDays` (défaut **60 jours**), non hardcodé. La purge
(`DELETE ... WHERE timestamp_utc < now() - interval '60 days'`) est **documentée mais
non exécutée automatiquement** (purge destructive → maintenance planifiée).
## Métriques (§28-§31)
Table `telemetry_metric_hourly` : agrégats horaires (`bucket_utc`, `metric_name`,
`component`, `value`, `count`, `min/max`, `tags_json`). **Low-cardinality obligatoire**
(`ExternalAttributePolicy`) : jamais `TraceId`/`SessionId`/`UserId`/`DeviceId`/GUID
métier en dimension. Utilisateurs actifs/jour = agrégation respectueuse de la
confidentialité, jamais de surveillance individuelle (§31).
## Traces (§32/§33/§39/§40)
`X-Omika-TraceId` reste l'identifiant applicatif de support (§33). La timeline
Mobile → API → Worker se reconstruit par `trace_id` (`ORDER BY timestamp_utc ASC`),
et par `operation_id` pour les synchronisations.
## Configuration (générique, jamais de secret §26)
```yaml
Observability:
External:
Enabled: false # kill-switch : aucun OTLP, aucun réseau, aucun crash
OtlpEndpoint: ""
Remote:
Directory: "" # JSONL durable (frontière d'ACK Phase 4)
Diagnostics:
ConnectionString: "" # configuration / variable d'environnement UNIQUEMENT
RetentionDays: 60
Import:
BatchSize: 100
CheckpointFileName: "import-checkpoint.json"
```
## Déploiement (Phase serveur, PAS dans cette phase)
- Windows Server 2025 : PostgreSQL + OpenTelemetry Collector (Windows Service) +
`Omika.Diagnostics.Worker` + `Omika.Diagnostics.Web`.
- Aucune dépendance Linux / Docker / ClickHouse / SigNoz / Kubernetes.
- Rien n'est à installer sur le serveur dans cette phase (§ « NE RIEN INSTALLER »).
+71
View File
@@ -0,0 +1,71 @@
# Spécification — Création d'email et de rapport (fidèle à Windev)
> Source : `docs/Documentation_WebService_GET.md` (web service mobile `Batik_WS_Com_Mob`),
> collection `LIB_Traitements` et `LIB_Structures`. Cette spec décrit le comportement
> réel à reproduire, distinct des « notes internes » (`MessageInterne`).
## 1. Structures WINDEV → contrats .NET
| Windev | .NET | Rôle |
|---|---|---|
| `STJSONMembreRubRapport` | `RapportChampDto` | Champ/rubrique du modèle (type, ordre, auto-rempli, modifiable, liste) |
| `STJSONListeMembre` | `RapportChampSousElementDto` | Colonne de tableau / rubrique fille |
| `ST_REQ_LISTE_Chp_Rapport` | `GetRapportChampsRequest` | Requête « liste des champs » |
| `STJSONInfoRapportMob` | `CreateRapportRequest` | Requête « créer le rapport » (avec `ListeMob` remplie) |
| `STJSONInfoModeleMailMob` | (à créer) | Requête « construire le modèle email » |
| `ST_ModeleMailComplet` | (à créer) | Requête « envoyer l'email » (sujet, corps HTML, destinataires, SMTP) |
## 2. Flux « récupérer les champs à remplir » (`RecupererChampRapportInter`)
1. Entrée : `{ modeleId, interventionId, collaborateurId }`.
2. Charge `ModeleRapport` (`MRP_Modele` = document, `MRP_Extra` = JSON de mots-clés spécifiques).
3. `ChargeListeRubDoc()` : `SELECT * FROM RubriquesDocument ORDER BY RD_IdRubParent` → hiérarchie (parent → `ListeMembre`).
4. `ConstructionTbRubBaseRapport` : extrait les rubriques présentes dans le document.
5. `ConstructionTbRubEnvoyerMob` : filtre `RD_EnvoiMob`.
6. `AjouterDonnesBDDansChampRapport(interventionId, champs)` : **pré-remplissage** depuis la base.
7. Retour : `RapportChampDto[]`.
## 3. Pré-remplissage (`AjouterDonnesBDDansChampRapport`)
5 requêtes SQL, exécutées **seulement si** le groupe applicatif est présent :
| GroupeApp | Source | `ListeDonnesBD` |
|---|---|---|
| `Inter` | Intervention + Lieu + Adresse + Client + Dossier + Collaborateur + Societe + ParametreMetier | valeur simple (texte ; `IN_Description` via RTF→texte) |
| `Client` | Client + Client_Adresse + Adresse | JSON `ST_ClientAd[]` (adresses multiples) |
| `Dossier` | Dossier + ParametreMetier | valeur simple |
| `CR` | CompteRendu + RDV_Collaborateur + RDV_Planning | JSON `ST_CR_Rp[]` (comptes rendus, **choix multiple**) |
| `RDV` | RDV_Planning + RDV_Collaborateur + Collaborateur | JSON `ST_RDV_Rp[]` (rendez-vous, **choix multiple**, groupés) |
Les champs de type `Tableau` ou groupe `Inter/Dossier/Client` reçoivent une valeur ; `CR`/`RDV`/`Client` reçoivent une **liste JSON** → choix multiple côté mobile.
## 4. Flux « créer le rapport » (`CreationRapport`)
1. Entrée : `{ modeleId, interventionId, nom, guid, ListeMob }` (rubriques remplies).
2. Charge `ModeleRapport` (document Word/RTF) + `MRP_Extra`.
3. `TraiteDocumentRapportMob` : remplit les rubriques dans le document.
4. `SupprimerToutesRubRapportMob` : retire les rubriques non remplies.
5. `DocVersPDF` → PDF.
6. Écrit `TOSRV` (`cCMD_DocEcrit`, type `"PDF_RP"`, nom `{nom}.pdf`).
## 5. Flux « envoyer l'email » (`CreationETEnvoiEmail`)
1. Entrée : `ST_ModeleMailComplet` (sujet, corps HTML, destinataires, PJ, compte SMTP).
2. `CreationUnEmail` → construit l'email.
3. `OuvreSessionEmail` + `EmailEnvoieMessage` → **envoi SMTP réel**.
4. `CreationMessageExt` → sérialise `ST_MsgExterneMail` et écrit `TOSRV` (`cCMD_MsgExtEcrit`).
## 6. État d'avancement et décisions
- **Fait** : lecture des modèles (`MailModele`/`ModeleRapport`/`ModeleRapportMob`), `MotClef`, `RubriquesDocument` ; moteur de substitution ; création `MessageExterne` directe.
- **À faire (priorité choisie : « champs à remplir + auto-remplissage »)** :
1. `IRapportChampService` + `RapportChampService` (miroir de `ContruireListeChamps` + `AjouterDonnesBDDansChampRapport`).
2. Endpoint `POST /api/rapports/champs` (récupérer les champs).
3. Endpoint `POST /api/rapports` (créer) — la génération Word/PDF est **reportée**.
- **Reporté** : génération Word/RTF → PDF, envoi SMTP réel, file `TOSRV` (`cCMD_*`).
## 7. Contraintes de compatibilité Batik / Omika
- `ModeleRapport` (Omika) vs `ModeleRapportMob` (Batik, lecture seule) : le modèle est résolu par l'adaptatif existant.
- `RubriquesDocument` : Omika uniquement → sur Batik, `ChargeListeRubDoc()` retourne vide → pas de rubriques structurées (dégradation propre).
- La requête d'auto-remplissage référence des colonnes Omika (`IN_CleType`, `DO_CleStatut`, `CR_CleTypeSignataire`, `RDV_Planning`/`RDV_Collaborateur`). Sur Batik, il faudra une requête de repli (colonnes numériques `IN_IDTypeIntervention`, `Planning_RDV`, etc.).
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
@echo off
REM ============================================================
REM OMIKA - Demarrage en mode test local
REM Double-cliquez sur ce fichier pour lancer API + Worker + Desktop.
REM Arguments optionnels (transmis au script PS1) :
REM -NoBuild ne pas recompiler
REM -SkipDesktop ne pas lancer l'application de bureau
REM -SkipWorker ne pas lancer le Worker
REM ============================================================
cd /d "%~dp0"
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0start_omika_test.ps1" %*
if errorlevel 1 (
echo.
echo [ERREUR] Le lancement a echoue. Voir les messages ci-dessus.
)
echo.
pause
+204
View File
@@ -0,0 +1,204 @@
<#
OMIKA - Demarrage complet en mode test local
============================================
Compile puis lance les 3 composants du projet :
1. Omika.Api (API) -> http://localhost:5097
2. Omika.Worker (sync) -> service d'arriere-plan
3. Omika.App.Desktop (application) -> fenetre Avalonia
Mode test = environnement "Development" :
- charge config/hfsql-test.local.json (connexions HFSQL locales de test) ;
- l'application de bureau utilise la licence de developpement locale
(%LOCALAPPDATA%\Omika\development-license.json, dont le champ ApiUrl
pointe vers http://localhost:5097).
Utilisation :
powershell -NoProfile -ExecutionPolicy Bypass -File .\docs\start_omika_test.ps1
.\docs\start_omika_test.ps1 -NoBuild # sans recompiler
.\docs\start_omika_test.ps1 -SkipDesktop # sans l'application de bureau
.\docs\start_omika_test.ps1 -SkipWorker # sans le Worker
.\docs\start_omika_test.ps1 -Clean # purge la SQLite locale avant lancement
Pour arreter : .\docs\stop_omika_test.ps1
#>
[CmdletBinding()]
param(
[switch]$NoBuild,
[switch]$SkipDesktop,
[switch]$SkipWorker,
[switch]$Clean
)
$ErrorActionPreference = 'Stop'
# Racine du depot = parent du dossier docs/ contenant ce script.
$root = Split-Path -Parent $PSScriptRoot
# Fichiers de logs (racine du depot).
$logApiOut = Join-Path $root 'omika-test-api.out.log'
$logApiErr = Join-Path $root 'omika-test-api.err.log'
$logWorkerOut = Join-Path $root 'omika-test-worker.out.log'
$logWorkerErr = Join-Path $root 'omika-test-worker.err.log'
$logDesktopOut = Join-Path $root 'omika-test-desktop.out.log'
$logDesktopErr = Join-Path $root 'omika-test-desktop.err.log'
function Reset-Log([string]$Path) {
if (Test-Path $Path) { Remove-Item $Path -Force -ErrorAction SilentlyContinue }
}
function Banner([string]$Text) {
Write-Host ''
Write-Host '============================================================' -ForegroundColor Cyan
Write-Host " $Text" -ForegroundColor Cyan
Write-Host '============================================================' -ForegroundColor Cyan
}
Banner 'OMIKA - Demarrage en mode test local'
# ---------- Preparation de la config locale de l'application de bureau ----------
$appData = Join-Path $env:LOCALAPPDATA 'Omika'
if (-not (Test-Path $appData)) { New-Item -ItemType Directory -Path $appData | Out-Null }
# ---------- Nettoyage optionnel de la base SQLite locale (-Clean) ----------
# Uniquement lors d'un changement d'environnement (ex. Batik -> Omika) : repartir
# avec une base locale vierge pour ne pas melanger les snapshots de deux bases.
# Ne supprime JAMAIS development-license.json, ni documents/, ni logs/.
if ($Clean) {
Write-Host ''
Write-Host 'Nettoyage de la base SQLite locale (-Clean)...' -ForegroundColor Yellow
foreach ($db in @('omika.db', 'omika-diagnostics.db')) {
$dbPath = Join-Path $appData $db
if (Test-Path $dbPath) {
Remove-Item -Path $dbPath -Force -ErrorAction SilentlyContinue
if (Test-Path $dbPath) {
Write-Warning " Impossible de supprimer (fichier verrouille ?) : $dbPath"
}
else {
Write-Host " Supprime : $dbPath" -ForegroundColor Green
}
}
}
}
$devLicenseConfig = Join-Path $appData 'development-license.json'
$devLicenseApiUrl = 'http://localhost:5097'
if (-not (Test-Path $devLicenseConfig)) {
[ordered]@{
Enabled = $true
ClientCode = 'LOCAL-DEV'
UserDisplayName = 'Alexandre SALVADOR (ASA)'
ExpirationDays = 365
ApiUrl = $devLicenseApiUrl
} | ConvertTo-Json | Set-Content -Path $devLicenseConfig -Encoding UTF8
Write-Host " Config creee : $devLicenseConfig" -ForegroundColor Yellow
}
else {
# Le fichier existe : si le champ ApiUrl est absent ou vide, on l'ajoute
# proprement (sans ecraser les autres champs). Necessaire au flux
# DevelopmentLicense, qui lit ApiUrl pour joindre l'API locale.
try {
$license = Get-Content -Raw -Path $devLicenseConfig | ConvertFrom-Json
if ($null -eq $license.PSObject.Properties['ApiUrl'] -or [string]::IsNullOrWhiteSpace($license.ApiUrl)) {
$license | Add-Member -NotePropertyName 'ApiUrl' -NotePropertyValue $devLicenseApiUrl -Force
$license | ConvertTo-Json | Set-Content -Path $devLicenseConfig -Encoding UTF8
Write-Host " ApiUrl ajoute a : $devLicenseConfig" -ForegroundColor Yellow
}
}
catch {
Write-Warning " $devLicenseConfig illisible : laisse tel quel."
}
}
$hfsqlConfig = Join-Path $root 'config\hfsql-test.local.json'
if (-not (Test-Path $hfsqlConfig)) {
Write-Warning "config/hfsql-test.local.json introuvable : l'API et le Worker n'utiliseront pas la configuration HFSQL de test."
}
# ---------- 1. Compilation ----------
if ($NoBuild) {
Write-Host ''
Write-Host 'Compilation ignoree (-NoBuild).' -ForegroundColor DarkGray
}
else {
Banner '1/4 - Compilation des projets (Debug)'
$projects = @(
(Join-Path $root 'src\Omika.Api\Omika.Api.csproj'),
(Join-Path $root 'src\Omika.Worker\Omika.Worker.csproj'),
(Join-Path $root 'src\Omika.App.Desktop\Omika.App.Desktop.csproj')
)
foreach ($project in $projects) {
Write-Host " > $project" -ForegroundColor Yellow
# CONTOURNEMENT TEMPORAIRE : --no-restore -m:1 evite l'echec MSB4276 lie au
# workload Android/iOS corrompu de cette machine (resolution SDK parallele).
# A retirer apres 'dotnet workload repair'.
dotnet build $project -c Debug --nologo --no-restore -m:1
if ($LASTEXITCODE -ne 0) { throw "Echec de compilation : $project" }
}
}
# Environnement de test : Development (charge config/hfsql-test.local.json).
$env:ASPNETCORE_ENVIRONMENT = 'Development'
$env:DOTNET_ENVIRONMENT = 'Development'
# ---------- 2. API ----------
Banner "2/4 - Demarrage de l'API (http://localhost:5097)"
Reset-Log $logApiOut; Reset-Log $logApiErr
$env:ASPNETCORE_URLS = 'http://localhost:5097'
$api = Start-Process -FilePath 'dotnet' `
-ArgumentList (Join-Path $root 'src\Omika.Api\bin\Debug\net10.0\Omika.Api.dll') `
-WorkingDirectory (Join-Path $root 'src\Omika.Api\bin\Debug\net10.0') `
-RedirectStandardOutput $logApiOut `
-RedirectStandardError $logApiErr `
-NoNewWindow -PassThru
Write-Host " API demarree (PID $($api.Id))." -ForegroundColor Green
# ---------- 3. Worker ----------
if ($SkipWorker) {
Write-Host 'Worker ignore (-SkipWorker).' -ForegroundColor DarkGray
}
else {
Banner '3/4 - Demarrage du Worker de synchronisation'
Reset-Log $logWorkerOut; Reset-Log $logWorkerErr
$worker = Start-Process -FilePath 'dotnet' `
-ArgumentList (Join-Path $root 'src\Omika.Worker\bin\Debug\net10.0\Omika.Worker.dll') `
-WorkingDirectory (Join-Path $root 'src\Omika.Worker\bin\Debug\net10.0') `
-RedirectStandardOutput $logWorkerOut `
-RedirectStandardError $logWorkerErr `
-NoNewWindow -PassThru
Write-Host " Worker demarre (PID $($worker.Id))." -ForegroundColor Green
}
# ---------- 4. Application desktop ----------
if ($SkipDesktop) {
Write-Host 'Application desktop ignoree (-SkipDesktop).' -ForegroundColor DarkGray
}
else {
Banner "4/4 - Demarrage de l'application de bureau"
Reset-Log $logDesktopOut; Reset-Log $logDesktopErr
$desktop = Start-Process -FilePath (Join-Path $root 'src\Omika.App.Desktop\bin\Debug\net10.0\Omika.App.Desktop.exe') `
-WorkingDirectory (Join-Path $root 'src\Omika.App.Desktop\bin\Debug\net10.0') `
-RedirectStandardOutput $logDesktopOut `
-RedirectStandardError $logDesktopErr `
-PassThru
Write-Host " Application de bureau demarree (PID $($desktop.Id))." -ForegroundColor Green
}
# ---------- Verification ----------
Banner 'Verification'
Write-Host " Attente de quelques secondes avant le controle de sante de l'API..." -ForegroundColor Yellow
Start-Sleep -Seconds 6
try {
$health = Invoke-RestMethod -Uri 'http://localhost:5097/health' -TimeoutSec 10
Write-Host " /health => $($health | ConvertTo-Json -Compress)" -ForegroundColor Green
}
catch {
Write-Warning " Impossible de joindre http://localhost:5097/health : $($_.Exception.Message)"
Write-Host " Consultez les logs : $logApiOut / $logApiErr" -ForegroundColor DarkGray
}
Banner 'Termine'
Write-Host ' Logs :' -ForegroundColor Cyan
Write-Host " API : $logApiOut" -ForegroundColor Cyan
Write-Host " Worker : $logWorkerOut" -ForegroundColor Cyan
Write-Host " Desktop : $logDesktopOut" -ForegroundColor Cyan
Write-Host ' Pour arreter : .\docs\stop_omika_test.ps1' -ForegroundColor Cyan
+8
View File
@@ -0,0 +1,8 @@
@echo off
REM ============================================================
REM OMIKA - Arret des processus de test local
REM ============================================================
cd /d "%~dp0"
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0stop_omika_test.ps1"
echo.
pause
+42
View File
@@ -0,0 +1,42 @@
<#
OMIKA - Arret des processus de test local
=========================================
Arrete les 3 composants lances par start_omika_test.ps1 :
- Omika.App.Desktop (application de bureau) ;
- Omika.Api (processus dotnet) ;
- Omika.Worker (processus dotnet).
#>
[CmdletBinding()]
param()
$ErrorActionPreference = 'Continue'
Write-Host ''
Write-Host 'Arret des processus Omika (test local)...' -ForegroundColor Yellow
# 1. Application de bureau (nom de processus explicite).
$desktop = Get-Process -Name 'Omika.App.Desktop' -ErrorAction SilentlyContinue
if ($desktop) {
$desktop | Stop-Process -Force -ErrorAction SilentlyContinue
Write-Host ' Application de bureau arretee.' -ForegroundColor Green
}
else {
Write-Host ' Application de bureau : aucun processus.' -ForegroundColor DarkGray
}
# 2. API et Worker (processus dotnet dont la ligne de commande mentionne le projet).
$dotnetProcs = Get-CimInstance Win32_Process -Filter "Name='dotnet.exe'" -ErrorAction SilentlyContinue |
Where-Object { $_.CommandLine -like '*Omika.Api*' -or $_.CommandLine -like '*Omika.Worker*' }
if ($dotnetProcs) {
foreach ($proc in $dotnetProcs) {
Stop-Process -Id $proc.ProcessId -Force -ErrorAction SilentlyContinue
Write-Host " dotnet (PID $($proc.ProcessId)) arrete." -ForegroundColor Green
}
}
else {
Write-Host ' API / Worker : aucun processus.' -ForegroundColor DarkGray
}
Write-Host ''
Write-Host 'Termine.' -ForegroundColor Cyan