mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
Compare commits
14 Commits
release/r3
...
release/r5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cccfaaabc9 | ||
|
|
b69c54ed0f | ||
|
|
28eecc1fb9 | ||
|
|
45ae491827 | ||
|
|
76c2ccdfb1 | ||
|
|
d4d73a6f76 | ||
|
|
837dcba1db | ||
|
|
199fce69d0 | ||
|
|
a5e8ca6d62 | ||
|
|
c8b3c1769a | ||
|
|
a22ea60f83 | ||
|
|
514fc7c391 | ||
|
|
5c8d5aac92 | ||
|
|
2736a2f3a1 |
103
.github/workflows/ci.yml
vendored
103
.github/workflows/ci.yml
vendored
@@ -44,14 +44,15 @@ jobs:
|
|||||||
cache: pnpm
|
cache: pnpm
|
||||||
- run: pnpm install --frozen-lockfile
|
- run: pnpm install --frozen-lockfile
|
||||||
- run: pnpm --filter @siop/shared build
|
- run: pnpm --filter @siop/shared build
|
||||||
- name: Régénérer la spec et le client typé
|
- name: Régénérer la spec et les clients typés
|
||||||
run: |
|
run: |
|
||||||
pnpm --filter @siop/shared contract
|
pnpm --filter @siop/shared contract
|
||||||
pnpm --filter @siop/web generate:client
|
pnpm --filter @siop/web generate:client
|
||||||
|
pnpm --filter @siop/mobile generate:client
|
||||||
- name: Vérifier qu'aucun artefact ne dérive du contrat
|
- name: Vérifier qu'aucun artefact ne dérive du contrat
|
||||||
run: |
|
run: |
|
||||||
if ! git diff --exit-code -- docs/openapi.json apps/web/src/api/schema.d.ts; then
|
if ! git diff --exit-code -- docs/openapi.json apps/web/src/api/schema.d.ts apps/mobile/src/api/schema.d.ts; then
|
||||||
echo "::error::Contrat désynchronisé — régénérez spec et clients dans le même commit (pnpm contract && pnpm --filter @siop/web generate:client)."
|
echo "::error::Contrat désynchronisé — régénérez spec et clients dans le même commit (pnpm contract && pnpm --filter @siop/web generate:client && pnpm --filter @siop/mobile generate:client)."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -59,18 +60,8 @@ jobs:
|
|||||||
name: api (tests + couverture ≥ 70 %)
|
name: api (tests + couverture ≥ 70 %)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
services:
|
services:
|
||||||
# R1+ : la migration r1_referentiel exige PostGIS (colonne générée geography).
|
# PostgreSQL vient d'infra/postgres (pgvector + PostGIS — R5) : les
|
||||||
# pgvector arrivera en R5 (bascule alors sur l’image infra/postgres).
|
# services ne savent pas builder, il démarre donc par étape ci-dessous.
|
||||||
postgres:
|
|
||||||
image: postgis/postgis:18-3.6
|
|
||||||
env:
|
|
||||||
POSTGRES_USER: siop
|
|
||||||
POSTGRES_PASSWORD: siop
|
|
||||||
POSTGRES_DB: siop
|
|
||||||
ports: ['5432:5432']
|
|
||||||
options: >-
|
|
||||||
--health-cmd "pg_isready -U siop -d siop"
|
|
||||||
--health-interval 5s --health-timeout 3s --health-retries 10
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:7.4-alpine
|
image: redis:7.4-alpine
|
||||||
ports: ['6379:6379']
|
ports: ['6379:6379']
|
||||||
@@ -86,6 +77,15 @@ jobs:
|
|||||||
MINIO_SECRET_KEY: siop-minio
|
MINIO_SECRET_KEY: siop-minio
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v5
|
||||||
|
- name: Démarrer PostgreSQL (image infra — pgvector + PostGIS, comme partout)
|
||||||
|
run: |
|
||||||
|
docker build -t siop2/postgres infra/postgres
|
||||||
|
docker run -d --name postgres -p 5432:5432 \
|
||||||
|
-e POSTGRES_USER=siop -e POSTGRES_PASSWORD=siop -e POSTGRES_DB=siop \
|
||||||
|
siop2/postgres
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
docker exec postgres pg_isready -U siop -d siop && break; sleep 2;
|
||||||
|
done
|
||||||
- name: Démarrer MinIO (l'image officielle exige une commande — pas un service)
|
- name: Démarrer MinIO (l'image officielle exige une commande — pas un service)
|
||||||
run: |
|
run: |
|
||||||
docker run -d --name minio -p 9000:9000 \
|
docker run -d --name minio -p 9000:9000 \
|
||||||
@@ -118,20 +118,40 @@ jobs:
|
|||||||
- run: pnpm --filter @siop/web test
|
- run: pnpm --filter @siop/web test
|
||||||
- run: pnpm --filter @siop/web build
|
- run: pnpm --filter @siop/web build
|
||||||
|
|
||||||
|
mobile:
|
||||||
|
name: mobile (typecheck + tests jest-expo)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
- uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version-file: .nvmrc
|
||||||
|
cache: pnpm
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
|
- run: pnpm --filter @siop/shared build
|
||||||
|
- run: pnpm --filter @siop/mobile typecheck
|
||||||
|
- run: pnpm --filter @siop/mobile test
|
||||||
|
|
||||||
|
ai:
|
||||||
|
name: ai (pytest + ruff — uv)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
- uses: astral-sh/setup-uv@v5
|
||||||
|
- run: uv sync
|
||||||
|
working-directory: apps/ai
|
||||||
|
- run: uv run ruff check src tests
|
||||||
|
working-directory: apps/ai
|
||||||
|
- run: uv run pytest
|
||||||
|
working-directory: apps/ai
|
||||||
|
|
||||||
e2e:
|
e2e:
|
||||||
name: e2e (parcours démo Playwright)
|
name: e2e (parcours démo Playwright)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
services:
|
services:
|
||||||
postgres:
|
# PostgreSQL vient d'infra/postgres (pgvector + PostGIS — R5) : les
|
||||||
image: postgis/postgis:18-3.6
|
# services ne savent pas builder, il démarre donc par étape ci-dessous.
|
||||||
env:
|
|
||||||
POSTGRES_USER: siop
|
|
||||||
POSTGRES_PASSWORD: siop
|
|
||||||
POSTGRES_DB: siop
|
|
||||||
ports: ['5432:5432']
|
|
||||||
options: >-
|
|
||||||
--health-cmd "pg_isready -U siop -d siop"
|
|
||||||
--health-interval 5s --health-timeout 3s --health-retries 10
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:7.4-alpine
|
image: redis:7.4-alpine
|
||||||
ports: ['6379:6379']
|
ports: ['6379:6379']
|
||||||
@@ -147,6 +167,15 @@ jobs:
|
|||||||
MINIO_SECRET_KEY: siop-minio
|
MINIO_SECRET_KEY: siop-minio
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v5
|
||||||
|
- name: Démarrer PostgreSQL (image infra — pgvector + PostGIS, comme partout)
|
||||||
|
run: |
|
||||||
|
docker build -t siop2/postgres infra/postgres
|
||||||
|
docker run -d --name postgres -p 5432:5432 \
|
||||||
|
-e POSTGRES_USER=siop -e POSTGRES_PASSWORD=siop -e POSTGRES_DB=siop \
|
||||||
|
siop2/postgres
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
docker exec postgres pg_isready -U siop -d siop && break; sleep 2;
|
||||||
|
done
|
||||||
- name: Démarrer MinIO (l'image officielle exige une commande — pas un service)
|
- name: Démarrer MinIO (l'image officielle exige une commande — pas un service)
|
||||||
run: |
|
run: |
|
||||||
docker run -d --name minio -p 9000:9000 \
|
docker run -d --name minio -p 9000:9000 \
|
||||||
@@ -162,8 +191,30 @@ jobs:
|
|||||||
- run: pnpm --filter @siop/api prisma:generate
|
- run: pnpm --filter @siop/api prisma:generate
|
||||||
- run: pnpm --filter @siop/api exec prisma migrate deploy
|
- run: pnpm --filter @siop/api exec prisma migrate deploy
|
||||||
- run: pnpm --filter @siop/api build
|
- run: pnpm --filter @siop/api build
|
||||||
|
- uses: astral-sh/setup-uv@v5
|
||||||
|
- name: Démarrer le service IA (R5 — embeddeur déterministe, seuils CI)
|
||||||
|
working-directory: apps/ai
|
||||||
|
env:
|
||||||
|
AI_EMBEDDINGS: deterministe
|
||||||
|
AI_SERVICE_TOKEN: dev-only-ai-token
|
||||||
|
# L'embeddeur déterministe (tri-grammes hachés) produit des scores
|
||||||
|
# bien plus bas que le vrai modèle : seuils calibrés sur mesures
|
||||||
|
# réelles (vrai match ≈ 0,66 ; bruit de collisions ≈ 0,11 ; codes
|
||||||
|
# de bilan pertinents ≈ 0,14-0,16 ; parasites ≈ 0,08).
|
||||||
|
AI_SEUIL_PERTINENCE: '0.20'
|
||||||
|
AI_SEUIL_SUGGESTION: '0.10'
|
||||||
|
AI_SEUIL_CONFIANCE_FORTE: '0.30'
|
||||||
|
run: |
|
||||||
|
uv sync
|
||||||
|
nohup uv run uvicorn siop_ai.app:app --host 127.0.0.1 --port 8000 > /tmp/siop-ai.log 2>&1 &
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
curl -fsS http://127.0.0.1:8000/healthz && break; sleep 1;
|
||||||
|
done
|
||||||
- run: pnpm --filter @siop/web exec playwright install --with-deps chromium
|
- run: pnpm --filter @siop/web exec playwright install --with-deps chromium
|
||||||
- run: pnpm --filter @siop/web e2e
|
- run: pnpm --filter @siop/web e2e
|
||||||
|
- name: Journal du service IA en cas d'échec
|
||||||
|
if: failure()
|
||||||
|
run: cat /tmp/siop-ai.log || true
|
||||||
- name: Traces Playwright en cas d'échec
|
- name: Traces Playwright en cas d'échec
|
||||||
if: failure()
|
if: failure()
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
@@ -180,7 +231,7 @@ jobs:
|
|||||||
deploy:
|
deploy:
|
||||||
name: deploy (Dokploy — siop2.apps.enset.top)
|
name: deploy (Dokploy — siop2.apps.enset.top)
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
needs: [lint, contract, api, web, e2e]
|
needs: [lint, contract, api, web, mobile, ai, e2e]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
environment: production
|
environment: production
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -7,3 +7,9 @@ coverage/
|
|||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
test-results/
|
test-results/
|
||||||
playwright-report/
|
playwright-report/
|
||||||
|
|
||||||
|
# Python (apps/ai)
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
|||||||
13
CLAUDE.md
13
CLAUDE.md
@@ -54,5 +54,16 @@ pnpm + Turborepo. `apps/api` : NestJS, Prisma, PostgreSQL (pgvector + PostGIS),
|
|||||||
- ✅ **R3.3** : 7 écrans web fidèles à maquette-r3 (stock/alertes, fiche pièce, BC + réception, création BC préremplie depuis l'alerte, tiers, bibliothèque, statistiques) + fiche OT complète (coûts réels : consommer à prix figé / saisir temps à taux figé ; carte Documents) + fiche ascenseur (Documents) + taux horaire dans Personnes + nav Ressources/Statistiques activée par la matrice. Recette R3 automatisée (13/13 e2e Playwright).
|
- ✅ **R3.3** : 7 écrans web fidèles à maquette-r3 (stock/alertes, fiche pièce, BC + réception, création BC préremplie depuis l'alerte, tiers, bibliothèque, statistiques) + fiche OT complète (coûts réels : consommer à prix figé / saisir temps à taux figé ; carte Documents) + fiche ascenseur (Documents) + taux horaire dans Personnes + nav Ressources/Statistiques activée par la matrice. Recette R3 automatisée (13/13 e2e Playwright).
|
||||||
- ✅ **R3.4 — corrections de recette + recherche globale** (17/07) : revue pixel (artefact) → 8 écarts corrigés sur arbitrage du référent (filtre fournisseur + tri sous-seuil + entrée de stock en liste, fournisseur cliquable, sélecteur de période 3/6/12 sur les stats, rattachements syndic→site via `Location.partnerId` (migration `r3_recette_fixes`), filtre « Rattaché à » + glisser-déposer en bibliothèque, aperçus/ouverture des documents) ; **recherche globale ⌘K** (`GET /search`, 73 opérations, familles filtrées par la matrice, « voir autre » respecté). 74 tests API, 14/14 Playwright.
|
- ✅ **R3.4 — corrections de recette + recherche globale** (17/07) : revue pixel (artefact) → 8 écarts corrigés sur arbitrage du référent (filtre fournisseur + tri sous-seuil + entrée de stock en liste, fournisseur cliquable, sélecteur de période 3/6/12 sur les stats, rattachements syndic→site via `Location.partnerId` (migration `r3_recette_fixes`), filtre « Rattaché à » + glisser-déposer en bibliothèque, aperçus/ouverture des documents) ; **recherche globale ⌘K** (`GET /search`, 73 opérations, familles filtrées par la matrice, « voir autre » respecté). 74 tests API, 14/14 Playwright.
|
||||||
- 🏁 **R3 CLOSE (17/07/2026, tag `release/r3`)** : recettée par le référent (revue pixel + 8 corrections + recherche globale, CI verte). Reste : redéploiement Dokploy (manuel, webhook CD absent) et vérification en ligne.
|
- 🏁 **R3 CLOSE (17/07/2026, tag `release/r3`)** : recettée par le référent (revue pixel + 8 corrections + recherche globale, CI verte). Reste : redéploiement Dokploy (manuel, webhook CD absent) et vérification en ligne.
|
||||||
- 🔄 **Reprise ici** : redéployer sur Dokploy et vérifier en ligne (migration `r3_recette_fixes` + seed au boot) → ouverture R4 Mobile (Expo, design d'abord — maquettes avant tout code).
|
- ✅ **R4 — maquettes rédigées** (17/07) : `maquette-r4.html`, 7 écrans mobile technicien offline-first (Ma journée bi-état, fiche OT, clôture terrain avec garde, scan QR des étiquettes R1, fiche ascenseur, checklist en file, synchro & conflits à verrou optimiste) + 5 décisions à acter (offline-first en file, verrou optimiste tranché par l'humain, périmètre fermé technicien, scan local, photos en file — ni audio ni géoloc en R4).
|
||||||
|
- ✅ **R4 — maquettes + décisions D1-D5 VALIDÉES par le référent (17/07)** ; **R4.1 socle mobile** : `apps/mobile` (Expo SDK 57, TS strict), connexion + sélecteur démo ADR-002, tabbar (onglets futurs marqués), « Ma journée » triée priorité/échéance, cache TanStack persisté (lecture hors-ligne D1), jeton SecureStore, client typé du contrat, `CORS_ORIGINS` opt-in côté API (Expo web/debug seulement), 6 tests jest-expo + job CI `mobile`. Vérifié 10/10 en Expo web (connexion démo → Ma journée → hors-ligne/retour).
|
||||||
|
- ✅ **R4.2 — terrain en ligne** : scanner QR réel (expo-camera, `analyseScan` testée, résolution locale D4, QR étrangers refusés), fiche ascenseur (D3, historique scopé), fiche OT (machine à états, coûts figés, garde par `closureBlockers` API), clôture terrain (bilan 6 champs au pouce), préventif → grille cochable (appui long = N/A, `aria-checked`). Écritures en ligne assumées (bandeaux) — la file est R4.3. Vérifié 12/12 en Expo web (dont clôture complète d'un OT de test), 12 tests jest-expo.
|
||||||
|
- ✅ **R4.3 — file & verrou** : verrou optimiste serveur (`baseUpdatedAt` → 409 contextualisé, toute écriture avance la version, web inchangé, ADR-003 sécurité/routage mobile) ; file persistée rejouée dans l'ordre (propagation de version intra-lot, arrêt sur conflit, patchs optimistes, photos D5 compressées en file), écran Synchro & conflits (rejouer sur version à jour / abandonner), préchargement parc+référentiels, purge complète à la déconnexion. Recette « mode avion » 13/13 en Expo web (conflit tranché par l'humain, serveur DONE + bilan), 17 tests jest-expo, 74 tests API.
|
||||||
|
- 🏁 **R4 CLOSE (17/07/2026, tag `release/r4`)** : posé sur décision du référent avec la recette « mode avion » validée 13/13 en Expo web piloté ; **la recette sur téléphone (Expo Go — vrai mode avion, scan caméra) est reportée et reste due avant toute production client mobile**.
|
||||||
|
- ✅ **R5 — maquettes rédigées** (17/07) : `maquette-r5.html`, 6 écrans (assistant RAG à citations sources document/page/extrait, refus explicite hors corpus, suggestion de bilan web+mobile à validation humaine, corpus & ingestion administrable, voix opt-in avec purge) + 5 décisions à acter (aucune écriture automatique, sourcé ou silencieux, corpus fermé, anonymisation 09-08, voix purgée).
|
||||||
|
- ✅ **R5 — maquettes + décisions D1-D5 VALIDÉES par le référent (17/07)** ; **R5.1 socle `apps/ai`** : ADR-004 (embeddings locaux fastembed 384d, pgvector via migration Prisma `r5_ia` (`RagChunk` + corpus sur Document), génération opt-in — mode extractif par défaut, service jamais exposé joint par l'API seule), pipeline d'ingestion anonymisé D4 (fonction pure testée, PDF paginés + bilans codés), `/internal/reindex` + `/internal/search` sous jeton de service, 14 pytest + ruff + job CI `ai` (embeddeur déterministe en CI). Vérifié en réel : corpus seedé indexé en 7 s, recherche sémantique concluante, 0 identité dans les chunks.
|
||||||
|
- ✅ **R5.1+ génération opt-in** : `Generateur` ADR-004 §3 — extractif par défaut, `AI_GENERATION=api` + `AI_API_KEY` (exigée au boot, jamais loguée ; Dokploy secrets) + `AI_MODEL` (défaut claude-opus-4-8), SDK anthropic en extra optionnel, citations [n] obligatoires, repli extractif sur tout échec (dont `stop_reason=refusal`). **R5.2 assistant au contrat** : `POST /assistant/ask` (WORK_ORDERS.view) + `POST /assistant/suggest-bilan` (WORK_ORDERS.edit) proxifiés par NestJS (traduction dialecte interne → contrat, 503 propre), suggestion = codes EXISTANTS seulement (confiance + « N bilans similaires »), normalisation fastembed corrigée (débusquée en chaîne réelle), 23 pytest, tests API sur stub HTTP.
|
||||||
|
- ✅ **R5.3 — écrans IA** : page web Assistant (chat sourcé, refus honnête chiffré, « Ouvrir » vers PDF/OT), Bibliothèque = corpus administrable (statut d'indexation par document, interrupteur d'exclusion, « Réindexer tout », bandeau 09-08), suggestions fiche OT (« Appliquer » = geste humain, liseré « suggéré » retiré au choix manuel) et clôture mobile (chips, « réseau requis » hors-ligne) ; contrat 76 opérations (corpus sur Document, `PATCH /documents/{id}/corpus`, `POST /assistant/reindex` — ci-contract vérifie aussi le client mobile) ; seuils `AI_SEUIL_*` par env ; job e2e CI avec `siop2-ai` (embeddeur déterministe, seuils calibrés sur mesures réelles), 16/16 Playwright, 78 tests API ; chaîne vérifiée au vrai modèle ONNX (web 7/7, mobile Expo web 6/6, 0 erreur console).
|
||||||
|
- ✅ **Recette R5 sans clé API + durcissement** (17/07) : la recette a invalidé MiniLM-384 (page-réponse classée derrière des passages sans rapport, 0,24 vs 0,41) → **bascule mesurée vers `paraphrase-multilingual-mpnet-base-v2` 768 d** (ADR-004 amendé, migration `r5_embeddings_mpnet`, découpage ~350 car., seuils 0,45/0,40/0,55) ; recette type ✓ (réponse sourcée p. 2, refus honnête, D1-D5, tout en extractif) ; revue pixel publiée (artefact, 3 arbitrages) ; Dockerfile `siop2-ai` (modèle au build, non-root), compose Dokploy (service interne, `AI_SERVICE_TOKEN` requis, génération opt-in), runbook §5-6 (service IA, calibrage seuils client, réindexation post-déploiement).
|
||||||
|
- ✅ **Revue pixel R5 validée par le référent (17/07)** — 1 correction appliquée sur arbitrage : la Bibliothèque-corpus passe en **tableau** (Document/Rattaché à/Indexation/Corpus + actions ; interrupteur éteint pour les non-indexables), vignettes conservées sur les cartes Documents des fiches ; « Appliquer » direct et calibrage au runbook validés tels quels. 16/16 Playwright rejoués.
|
||||||
|
- 🔄 **Reprise ici** : tag `release/r5` (sur le mot du référent). Restes : recette R4 sur téléphone (Expo Go), redéploiement Dokploy (`release/r3` puis r5 avec `AI_SERVICE_TOKEN`), secret `DOKPLOY_WEBHOOK_URL`, calibrage `AI_SEUIL_*` sur corpus SPELEV réel.
|
||||||
- Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0→R5.
|
- Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0→R5.
|
||||||
|
|||||||
9
apps/ai/.dockerignore
Normal file
9
apps/ai/.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
.venv
|
||||||
|
.pytest_cache
|
||||||
|
.ruff_cache
|
||||||
|
__pycache__
|
||||||
|
tests
|
||||||
|
README.md
|
||||||
|
.env
|
||||||
|
.env.example
|
||||||
|
Dockerfile
|
||||||
20
apps/ai/.env.example
Normal file
20
apps/ai/.env.example
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Copier en .env pour le dev local (défauts alignés sur infra/docker-compose.yml).
|
||||||
|
DATABASE_URL=postgresql://siop:siop@localhost:5432/siop
|
||||||
|
MINIO_ENDPOINT=localhost
|
||||||
|
MINIO_PORT=9000
|
||||||
|
MINIO_ACCESS_KEY=siop
|
||||||
|
MINIO_SECRET_KEY=siop-minio
|
||||||
|
MINIO_BUCKET=siop2
|
||||||
|
|
||||||
|
# Seule l'API NestJS connaît ce secret (ADR-004 §4 — service jamais public).
|
||||||
|
AI_SERVICE_TOKEN=dev-only-ai-token
|
||||||
|
|
||||||
|
# Embeddings : locale (fastembed ONNX, CPU) | deterministe (tests/CI)
|
||||||
|
AI_EMBEDDINGS=locale
|
||||||
|
|
||||||
|
# Génération (ADR-004 §3) : off = mode extractif (défaut — la recette passe
|
||||||
|
# sans clé) | api = rédaction par le LLM sur textes déjà anonymisés (D4).
|
||||||
|
# AI_GENERATION=api exige AI_API_KEY (le boot refuse sinon).
|
||||||
|
AI_GENERATION=off
|
||||||
|
AI_API_KEY=
|
||||||
|
AI_MODEL=claude-opus-4-8
|
||||||
38
apps/ai/Dockerfile
Normal file
38
apps/ai/Dockerfile
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# SIOP V2 — image du service IA (siop2-ai, ADR-004).
|
||||||
|
# Contexte de build : apps/ai (le service est autonome, pas de dépendance au
|
||||||
|
# monorepo). Étage 1 : uv sync + téléchargement du modèle ONNX AU BUILD
|
||||||
|
# (ADR-004 §1 — jamais au démarrage) ; étage 2 : runtime minimal non-root.
|
||||||
|
# Ce service n'est JAMAIS exposé publiquement : seul siop2-api le contacte,
|
||||||
|
# porteur du secret AI_SERVICE_TOKEN (ADR-004 §4).
|
||||||
|
|
||||||
|
FROM ghcr.io/astral-sh/uv:python3.11-bookworm-slim AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
ENV UV_LINK_MODE=copy \
|
||||||
|
FASTEMBED_CACHE_PATH=/opt/fastembed
|
||||||
|
|
||||||
|
# Manifestes d'abord (cache de couche), puis le code. L'installation du projet
|
||||||
|
# reste éditable (.pth → /app/src) : src est donc copié dans l'image finale.
|
||||||
|
COPY pyproject.toml uv.lock ./
|
||||||
|
RUN uv sync --frozen --no-install-project --no-dev \
|
||||||
|
--extra embeddings --extra generation
|
||||||
|
COPY src src
|
||||||
|
RUN uv sync --frozen --no-dev \
|
||||||
|
--extra embeddings --extra generation
|
||||||
|
|
||||||
|
# Le modèle d'embeddings est EMBARQUÉ dans l'image : pas de téléchargement au
|
||||||
|
# boot (démarrage prévisible, marche sans accès à Hugging Face en production).
|
||||||
|
RUN uv run python -c "from siop_ai.embeddings import EmbeddeurLocal; EmbeddeurLocal()"
|
||||||
|
|
||||||
|
FROM python:3.11-slim-bookworm
|
||||||
|
WORKDIR /app
|
||||||
|
ENV PATH=/app/.venv/bin:$PATH \
|
||||||
|
FASTEMBED_CACHE_PATH=/opt/fastembed
|
||||||
|
RUN useradd --system --create-home siop
|
||||||
|
COPY --from=builder --chown=siop:siop /app/.venv /app/.venv
|
||||||
|
COPY --from=builder --chown=siop:siop /app/src /app/src
|
||||||
|
COPY --from=builder --chown=siop:siop /opt/fastembed /opt/fastembed
|
||||||
|
USER siop
|
||||||
|
EXPOSE 8000
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
|
||||||
|
CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8000/healthz', timeout=4).status==200 else 1)"
|
||||||
|
CMD ["uvicorn", "siop_ai.app:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
33
apps/ai/README.md
Normal file
33
apps/ai/README.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# @siop/ai — service IA (R5)
|
||||||
|
|
||||||
|
FastAPI + uv (ADR-001), **jamais exposé** : seule l'API NestJS le contacte avec
|
||||||
|
`X-Service-Token` (ADR-004 §4). Maquettes et décisions D1-D5 validées le 17/07/2026.
|
||||||
|
|
||||||
|
## Lancer (dev)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/ai
|
||||||
|
uv sync --extra embeddings # le vrai modèle ONNX (CPU, ~120 Mo au premier run)
|
||||||
|
uv run uvicorn siop_ai.app:app --port 8000
|
||||||
|
# CI / tests : uv sync && uv run pytest (embeddeur déterministe, aucun téléchargement)
|
||||||
|
```
|
||||||
|
|
||||||
|
Variables (défauts dev dans `config.py`, gabarit dans `.env.example`) :
|
||||||
|
`DATABASE_URL`, `MINIO_*`, `AI_SERVICE_TOKEN`, `AI_EMBEDDINGS=locale|deterministe`,
|
||||||
|
et la génération opt-in (ADR-004 §3) : `AI_GENERATION=off|api`, **`AI_API_KEY`**
|
||||||
|
(exigée en mode api — le boot refuse sinon, jamais loguée ni exposée),
|
||||||
|
`AI_MODEL` (défaut `claude-opus-4-8`). Mode api : `uv sync --extra generation`
|
||||||
|
(SDK officiel `anthropic`) ; tout échec du LLM retombe sur le mode extractif.
|
||||||
|
|
||||||
|
## Ce que porte R5.1 (socle)
|
||||||
|
|
||||||
|
- **Ingestion anonymisée (D4)** : PDF de la bibliothèque (MinIO) page par page +
|
||||||
|
bilans codés clôturés → anonymisation (e-mails, téléphones, noms connus de la
|
||||||
|
base) → découpage → embeddings locaux → `RagChunk` (pgvector, schéma Prisma).
|
||||||
|
- **Recherche sémantique** `/internal/search` : extraits sourcés (document + page
|
||||||
|
ou bilan daté) avec score — la brique de « sourcé ou silencieux » (D2).
|
||||||
|
- `/internal/reindex` idempotent ; l'exclusion de corpus (`Document.inCorpus`,
|
||||||
|
D3) s'applique à l'ingestion ET à la lecture.
|
||||||
|
|
||||||
|
La suite : R5.2 assistant (mode extractif puis génération opt-in) + suggestion de
|
||||||
|
bilan ; R5.3 écrans ; durcissement : Dockerfile + compose Dokploy (`siop2-ai`).
|
||||||
42
apps/ai/pyproject.toml
Normal file
42
apps/ai/pyproject.toml
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
[project]
|
||||||
|
name = "siop-ai"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "SIOP V2 — service IA (R5) : ingestion anonymisée, recherche sémantique, assistant sourcé (ADR-004)"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"fastapi>=0.115",
|
||||||
|
"uvicorn[standard]>=0.32",
|
||||||
|
"pydantic-settings>=2.6",
|
||||||
|
"asyncpg>=0.30",
|
||||||
|
"pypdf>=5.1",
|
||||||
|
"minio>=7.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
# Le vrai modèle (ONNX, CPU) — absent des tests/CI (embeddeur déterministe).
|
||||||
|
embeddings = ["fastembed>=0.4"]
|
||||||
|
# Génération opt-in (ADR-004 §3) — absente des tests/CI (repli extractif).
|
||||||
|
generation = ["anthropic>=0.75"]
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.3",
|
||||||
|
"pytest-asyncio>=0.24",
|
||||||
|
"httpx>=0.27",
|
||||||
|
"ruff>=0.8",
|
||||||
|
]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["src/siop_ai"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
src = ["src", "tests"]
|
||||||
1
apps/ai/src/siop_ai/__init__.py
Normal file
1
apps/ai/src/siop_ai/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""SIOP V2 — service IA (R5). L'IA propose, l'humain valide (D1)."""
|
||||||
62
apps/ai/src/siop_ai/anonymisation.py
Normal file
62
apps/ai/src/siop_ai/anonymisation.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
"""Anonymisation à l'ingestion (D4, loi 09-08) : identités et coordonnées ne
|
||||||
|
partent JAMAIS dans les index vectoriels ni dans les prompts.
|
||||||
|
|
||||||
|
Fonction pure, testée : e-mails, téléphones (formats marocains et
|
||||||
|
internationaux), et les noms de personnes CONNUS de la base (utilisateurs,
|
||||||
|
gardiens, contacts tiers) fournis par l'appelant.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
|
JETON_PERSONNE = "⟨personne⟩"
|
||||||
|
JETON_CONTACT = "⟨contact⟩"
|
||||||
|
|
||||||
|
_EMAIL = re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+")
|
||||||
|
# 06 12 34 56 78 · 0612345678 · +212 6 12 34 56 78 · 05 22-34-56-78…
|
||||||
|
_TELEPHONE = re.compile(r"(?:\+?\d{1,3}[\s.-]?)?(?:0|\(0\))?\d(?:[\s.-]?\d{2}){4}")
|
||||||
|
|
||||||
|
|
||||||
|
def _sans_accents(texte: str) -> str:
|
||||||
|
return "".join(
|
||||||
|
c for c in unicodedata.normalize("NFD", texte) if unicodedata.category(c) != "Mn"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def anonymiser(texte: str, noms_connus: list[str] | None = None) -> str:
|
||||||
|
"""Remplace coordonnées et noms connus par des jetons neutres.
|
||||||
|
|
||||||
|
Les noms sont remplacés insensiblement à la casse ET aux accents
|
||||||
|
(« Idrissi » attrape « idrissi »), prénom seul compris quand il est
|
||||||
|
assez long pour ne pas mutiler le texte technique.
|
||||||
|
"""
|
||||||
|
resultat = _EMAIL.sub(JETON_CONTACT, texte)
|
||||||
|
resultat = _TELEPHONE.sub(JETON_CONTACT, resultat)
|
||||||
|
|
||||||
|
for nom in sorted(noms_connus or [], key=len, reverse=True):
|
||||||
|
nom = nom.strip()
|
||||||
|
if len(nom) < 3:
|
||||||
|
continue
|
||||||
|
morceaux = [nom] + [m for m in nom.split() if len(m) >= 4]
|
||||||
|
for morceau in morceaux:
|
||||||
|
motif = re.compile(
|
||||||
|
r"\b" + re.escape(_sans_accents(morceau)) + r"\b", re.IGNORECASE
|
||||||
|
)
|
||||||
|
# on cherche sur une copie sans accents mais on remplace l'original
|
||||||
|
copie = _sans_accents(resultat)
|
||||||
|
sortie: list[str] = []
|
||||||
|
position = 0
|
||||||
|
for correspondance in motif.finditer(copie):
|
||||||
|
sortie.append(resultat[position : correspondance.start()])
|
||||||
|
sortie.append(JETON_PERSONNE)
|
||||||
|
position = correspondance.end()
|
||||||
|
sortie.append(resultat[position:])
|
||||||
|
resultat = "".join(sortie)
|
||||||
|
|
||||||
|
# jetons collés en double (« prénom nom » remplacés séparément)
|
||||||
|
resultat = re.sub(
|
||||||
|
rf"{re.escape(JETON_PERSONNE)}(\s+{re.escape(JETON_PERSONNE)})+",
|
||||||
|
JETON_PERSONNE,
|
||||||
|
resultat,
|
||||||
|
)
|
||||||
|
return resultat
|
||||||
115
apps/ai/src/siop_ai/app.py
Normal file
115
apps/ai/src/siop_ai/app.py
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
"""Service IA — JAMAIS exposé publiquement (ADR-004 §4) : seule l'API NestJS
|
||||||
|
le contacte, avec le secret partagé `X-Service-Token`. Les permissions des
|
||||||
|
utilisateurs restent l'affaire de l'API — ici, un seul appelant de confiance.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from dataclasses import asdict
|
||||||
|
|
||||||
|
import asyncpg
|
||||||
|
from fastapi import Depends, FastAPI, Header, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from .assistant import repondre, suggerer_bilan
|
||||||
|
from .config import Reglages, charger_reglages
|
||||||
|
from .embeddings import construire_embeddeur
|
||||||
|
from .generation import construire_generateur
|
||||||
|
from .ingestion import reindexer_tout
|
||||||
|
from .recherche import chercher
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def cycle_de_vie(app: FastAPI):
|
||||||
|
reglages = charger_reglages()
|
||||||
|
app.state.reglages = reglages
|
||||||
|
app.state.embeddeur = construire_embeddeur(reglages.ai_embeddings)
|
||||||
|
app.state.generateur = construire_generateur(
|
||||||
|
reglages.ai_generation, reglages.ai_api_key, reglages.ai_model
|
||||||
|
)
|
||||||
|
app.state.pool = await asyncpg.create_pool(reglages.database_url, min_size=1, max_size=5)
|
||||||
|
yield
|
||||||
|
await app.state.pool.close()
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="SIOP V2 — service IA (R5)", lifespan=cycle_de_vie)
|
||||||
|
|
||||||
|
|
||||||
|
def verifier_jeton(
|
||||||
|
x_service_token: str = Header(default=""),
|
||||||
|
) -> None:
|
||||||
|
reglages: Reglages = app.state.reglages
|
||||||
|
if x_service_token != reglages.ai_service_token:
|
||||||
|
raise HTTPException(status_code=401, detail="Jeton de service invalide")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/healthz")
|
||||||
|
async def sante() -> dict:
|
||||||
|
"""Sonde interne (compose/Dokploy) — ne révèle ni corpus ni secret."""
|
||||||
|
reglages = getattr(app.state, "reglages", None) or charger_reglages()
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"service": "siop2-ai",
|
||||||
|
"generation": reglages.ai_generation, # « off » = extractif — jamais la clé
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/internal/reindex", dependencies=[Depends(verifier_jeton)])
|
||||||
|
async def reindexer() -> dict:
|
||||||
|
async with app.state.pool.acquire() as cnx:
|
||||||
|
resultat = await reindexer_tout(cnx, app.state.reglages, app.state.embeddeur)
|
||||||
|
return asdict(resultat)
|
||||||
|
|
||||||
|
|
||||||
|
class RequeteRecherche(BaseModel):
|
||||||
|
question: str = Field(min_length=3, max_length=500)
|
||||||
|
limite: int = Field(default=5, ge=1, le=10)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/internal/search", dependencies=[Depends(verifier_jeton)])
|
||||||
|
async def rechercher(corps: RequeteRecherche) -> dict:
|
||||||
|
async with app.state.pool.acquire() as cnx:
|
||||||
|
extraits = await chercher(cnx, app.state.embeddeur, corps.question, corps.limite)
|
||||||
|
return {"extraits": [asdict(e) for e in extraits]}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/internal/ask", dependencies=[Depends(verifier_jeton)])
|
||||||
|
async def demander(corps: RequeteRecherche) -> dict:
|
||||||
|
"""L'assistant D2 : extraits sourcés au-dessus du seuil, ou refus honnête
|
||||||
|
(ce qui a été cherché) — la rédaction n'existe qu'en mode génératif."""
|
||||||
|
async with app.state.pool.acquire() as cnx:
|
||||||
|
reponse = await repondre(
|
||||||
|
cnx,
|
||||||
|
app.state.embeddeur,
|
||||||
|
app.state.generateur,
|
||||||
|
corps.question,
|
||||||
|
corps.limite,
|
||||||
|
seuil_pertinence=app.state.reglages.ai_seuil_pertinence,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"mode": reponse.mode,
|
||||||
|
"answer": reponse.answer,
|
||||||
|
"extraits": [asdict(e) for e in reponse.extraits],
|
||||||
|
"corpus": {
|
||||||
|
"documents": reponse.documents_corpus,
|
||||||
|
"bilans": reponse.bilans_corpus,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class RequeteSuggestion(BaseModel):
|
||||||
|
description: str = Field(min_length=10, max_length=2000)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/internal/suggest", dependencies=[Depends(verifier_jeton)])
|
||||||
|
async def suggerer(corps: RequeteSuggestion) -> dict:
|
||||||
|
"""Suggestion de codes de bilan (D1) : uniquement des codes EXISTANTS,
|
||||||
|
avec confiance et « N bilans similaires » — l'humain applique, ou pas."""
|
||||||
|
async with app.state.pool.acquire() as cnx:
|
||||||
|
suggestions = await suggerer_bilan(
|
||||||
|
cnx,
|
||||||
|
app.state.embeddeur,
|
||||||
|
corps.description,
|
||||||
|
seuil_suggestion=app.state.reglages.ai_seuil_suggestion,
|
||||||
|
seuil_confiance_forte=app.state.reglages.ai_seuil_confiance_forte,
|
||||||
|
)
|
||||||
|
return {"suggestions": [asdict(s) for s in suggestions]}
|
||||||
153
apps/ai/src/siop_ai/assistant.py
Normal file
153
apps/ai/src/siop_ai/assistant.py
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
"""L'assistant (D2 — « sourcé ou silencieux ») et la suggestion de bilan.
|
||||||
|
|
||||||
|
- `repondre` : recherche sémantique → extraits au-dessus du seuil de
|
||||||
|
pertinence, ou refus HONNÊTE qui dit ce qui a été cherché (écran 2 des
|
||||||
|
maquettes). La rédaction est déléguée au `Generateur` (opt-in ADR-004 §3) ;
|
||||||
|
sans lui, le mode extractif est la réponse.
|
||||||
|
- `suggerer_bilan` : similarité sémantique entre la description libre et les
|
||||||
|
libellés ACTIFS des référentiels (l'IA ne peut suggérer que des codes
|
||||||
|
existants) + comptage des bilans similaires du parc. Sans LLM : rapide,
|
||||||
|
déterministe, explicable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import asyncpg
|
||||||
|
|
||||||
|
from .embeddings import Embeddeur
|
||||||
|
from .generation import Generateur
|
||||||
|
from .recherche import ExtraitTrouve, chercher
|
||||||
|
|
||||||
|
# Répliques des libellés français de @siop/shared (BILAN_FIELD_LABELS) —
|
||||||
|
# utilisés pour contextualiser les embeddings des codes.
|
||||||
|
CHAMPS_BILAN = {
|
||||||
|
"DOOR_STATE": "état des portes",
|
||||||
|
"CABIN_POSITION": "position cabine",
|
||||||
|
"ANOMALY": "anomalie constatée",
|
||||||
|
"EXTERNAL_CAUSE": "cause extérieure",
|
||||||
|
"ACTION_TAKEN": "action réalisée",
|
||||||
|
"COMPONENT_CONCERNED": "élément concerné",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Défauts — surchargés par la config (AI_SEUIL_*) : calibrage en recette.
|
||||||
|
SEUIL_PERTINENCE = 0.45 # en dessous : le corpus ne porte pas la réponse
|
||||||
|
SEUIL_SUGGESTION = 0.40
|
||||||
|
SEUIL_CONFIANCE_FORTE = 0.55
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ReponseAssistant:
|
||||||
|
mode: str # « extractif » | « genere » | « refus »
|
||||||
|
answer: str | None
|
||||||
|
extraits: list[ExtraitTrouve]
|
||||||
|
documents_corpus: int
|
||||||
|
bilans_corpus: int
|
||||||
|
|
||||||
|
|
||||||
|
async def _taille_corpus(cnx: asyncpg.Connection) -> tuple[int, int]:
|
||||||
|
ligne = await cnx.fetchrow(
|
||||||
|
'''
|
||||||
|
SELECT
|
||||||
|
(SELECT count(DISTINCT "documentId") FROM "RagChunk"
|
||||||
|
WHERE "sourceType" = 'DOCUMENT') AS documents,
|
||||||
|
(SELECT count(*) FROM "RagChunk" WHERE "sourceType" = 'WORK_ORDER') AS bilans
|
||||||
|
'''
|
||||||
|
)
|
||||||
|
return ligne["documents"], ligne["bilans"]
|
||||||
|
|
||||||
|
|
||||||
|
async def repondre(
|
||||||
|
cnx: asyncpg.Connection,
|
||||||
|
embeddeur: Embeddeur,
|
||||||
|
generateur: Generateur,
|
||||||
|
question: str,
|
||||||
|
limite: int = 5,
|
||||||
|
seuil_pertinence: float = SEUIL_PERTINENCE,
|
||||||
|
) -> ReponseAssistant:
|
||||||
|
documents, bilans = await _taille_corpus(cnx)
|
||||||
|
extraits = await chercher(cnx, embeddeur, question, limite)
|
||||||
|
pertinents = [e for e in extraits if e.score >= seuil_pertinence]
|
||||||
|
|
||||||
|
if not pertinents:
|
||||||
|
# D2 : refus explicite — on dit ce qu'on a cherché, on n'invente rien.
|
||||||
|
return ReponseAssistant(
|
||||||
|
mode="refus",
|
||||||
|
answer=None,
|
||||||
|
extraits=[],
|
||||||
|
documents_corpus=documents,
|
||||||
|
bilans_corpus=bilans,
|
||||||
|
)
|
||||||
|
|
||||||
|
redige = generateur.rediger(question, pertinents)
|
||||||
|
return ReponseAssistant(
|
||||||
|
mode="genere" if redige else "extractif",
|
||||||
|
answer=redige,
|
||||||
|
extraits=pertinents,
|
||||||
|
documents_corpus=documents,
|
||||||
|
bilans_corpus=bilans,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SuggestionBilan:
|
||||||
|
field: str
|
||||||
|
value_id: str
|
||||||
|
label: str
|
||||||
|
confidence: str # « FORTE » | « MOYENNE »
|
||||||
|
similar_reports: int # bilans du parc portant déjà ce code (« 9 bilans similaires »)
|
||||||
|
score: float
|
||||||
|
|
||||||
|
|
||||||
|
def _cosinus(a: list[float], b: list[float]) -> float:
|
||||||
|
return sum(x * y for x, y in zip(a, b)) # vecteurs déjà normés
|
||||||
|
|
||||||
|
|
||||||
|
async def suggerer_bilan(
|
||||||
|
cnx: asyncpg.Connection,
|
||||||
|
embeddeur: Embeddeur,
|
||||||
|
description: str,
|
||||||
|
seuil_suggestion: float = SEUIL_SUGGESTION,
|
||||||
|
seuil_confiance_forte: float = SEUIL_CONFIANCE_FORTE,
|
||||||
|
) -> list[SuggestionBilan]:
|
||||||
|
valeurs = await cnx.fetch(
|
||||||
|
'SELECT id, field, label FROM "ReferenceValue" WHERE "isActive" ORDER BY field, label'
|
||||||
|
)
|
||||||
|
if not valeurs:
|
||||||
|
return []
|
||||||
|
|
||||||
|
textes = [description] + [
|
||||||
|
f"{CHAMPS_BILAN.get(v['field'], v['field'])} : {v['label']}" for v in valeurs
|
||||||
|
]
|
||||||
|
vecteurs = embeddeur.encoder(textes)
|
||||||
|
v_description, v_valeurs = vecteurs[0], vecteurs[1:]
|
||||||
|
|
||||||
|
# Le meilleur code par champ, au-dessus du seuil — jamais plus d'une
|
||||||
|
# suggestion par champ, jamais un code inventé.
|
||||||
|
meilleurs: dict[str, tuple[asyncpg.Record, float]] = {}
|
||||||
|
for valeur, vecteur in zip(valeurs, v_valeurs):
|
||||||
|
score = _cosinus(v_description, vecteur)
|
||||||
|
if score < seuil_suggestion:
|
||||||
|
continue
|
||||||
|
champ = valeur["field"]
|
||||||
|
if champ not in meilleurs or score > meilleurs[champ][1]:
|
||||||
|
meilleurs[champ] = (valeur, score)
|
||||||
|
|
||||||
|
suggestions: list[SuggestionBilan] = []
|
||||||
|
for valeur, score in meilleurs.values():
|
||||||
|
# « 9 bilans similaires sur ce parc » : les bilans clôturés portant ce code
|
||||||
|
similaires = await cnx.fetchval(
|
||||||
|
'SELECT count(*) FROM "RagChunk" WHERE "sourceType" = \'WORK_ORDER\' AND content ILIKE $1',
|
||||||
|
f"%{valeur['label']}%",
|
||||||
|
)
|
||||||
|
suggestions.append(
|
||||||
|
SuggestionBilan(
|
||||||
|
field=valeur["field"],
|
||||||
|
value_id=str(valeur["id"]),
|
||||||
|
label=valeur["label"],
|
||||||
|
confidence="FORTE" if score >= seuil_confiance_forte else "MOYENNE",
|
||||||
|
similar_reports=similaires,
|
||||||
|
score=round(score, 4),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
suggestions.sort(key=lambda s: s.score, reverse=True)
|
||||||
|
return suggestions
|
||||||
46
apps/ai/src/siop_ai/config.py
Normal file
46
apps/ai/src/siop_ai/config.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"""Configuration — validée au démarrage, comme l'API NestJS (même philosophie)."""
|
||||||
|
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
|
||||||
|
class Reglages(BaseSettings):
|
||||||
|
# Postgres partagé (schéma possédé par Prisma — apps/api)
|
||||||
|
database_url: str = "postgresql://siop:siop@localhost:5432/siop"
|
||||||
|
# MinIO en direct sur le réseau privé (ADR-004 §4 — jamais exposé)
|
||||||
|
minio_endpoint: str = "localhost"
|
||||||
|
minio_port: int = 9000
|
||||||
|
minio_use_ssl: bool = False
|
||||||
|
minio_access_key: str = "siop"
|
||||||
|
minio_secret_key: str = "siop-minio"
|
||||||
|
minio_bucket: str = "siop2"
|
||||||
|
# Le service n'est JAMAIS public : seul l'API NestJS le contacte,
|
||||||
|
# porteuse de ce secret partagé (ADR-004 §4).
|
||||||
|
ai_service_token: str = "dev-only-ai-token"
|
||||||
|
# Embeddings : « locale » (fastembed ONNX) ou « deterministe » (tests/CI)
|
||||||
|
ai_embeddings: str = "locale"
|
||||||
|
# Génération (ADR-004 §3) : « off » = mode extractif (défaut honnête,
|
||||||
|
# la recette passe sans clé) ; « api » = rédaction par le LLM externe,
|
||||||
|
# sur textes DÉJÀ anonymisés (D4), citations obligatoires.
|
||||||
|
ai_generation: str = "off"
|
||||||
|
ai_api_key: str = "" # requise seulement si ai_generation=api — jamais loguée
|
||||||
|
ai_model: str = "claude-opus-4-8"
|
||||||
|
# Seuils de similarité — constantes de départ, calibrables par env
|
||||||
|
# (recette sur corpus réel ; abaissés en CI e2e — embeddeur déterministe).
|
||||||
|
ai_seuil_pertinence: float = 0.45
|
||||||
|
ai_seuil_suggestion: float = 0.40
|
||||||
|
ai_seuil_confiance_forte: float = 0.55
|
||||||
|
|
||||||
|
model_config = {"env_prefix": "", "case_sensitive": False}
|
||||||
|
|
||||||
|
|
||||||
|
def charger_reglages() -> Reglages:
|
||||||
|
reglages = Reglages()
|
||||||
|
# asyncpg ne comprend pas le paramètre ?schema= de Prisma
|
||||||
|
if "?" in reglages.database_url:
|
||||||
|
reglages.database_url = reglages.database_url.split("?")[0]
|
||||||
|
# Même philosophie que l'API NestJS : une config invalide refuse de booter.
|
||||||
|
if reglages.ai_generation not in ("off", "api"):
|
||||||
|
raise ValueError("AI_GENERATION doit valoir « off » ou « api »")
|
||||||
|
if reglages.ai_generation == "api" and not reglages.ai_api_key:
|
||||||
|
raise ValueError("AI_GENERATION=api exige AI_API_KEY (voir ADR-004 §3)")
|
||||||
|
return reglages
|
||||||
57
apps/ai/src/siop_ai/decoupage.py
Normal file
57
apps/ai/src/siop_ai/decoupage.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
"""Découpage du texte en extraits indexables — pur et testé.
|
||||||
|
|
||||||
|
Paragraphes regroupés jusqu'à ~350 caractères, avec un chevauchement de
|
||||||
|
queue pour ne pas couper une prescription en deux. Un extrait trop long est
|
||||||
|
scindé sur les phrases.
|
||||||
|
|
||||||
|
Le grain est court À DESSEIN (recette R5) : sur des pages entières, la phrase
|
||||||
|
qui répond se noie dans son contexte et les scores question→passage ne
|
||||||
|
séparent plus le pertinent du voisin de domaine ; à ~350 caractères, la marge
|
||||||
|
revient — et l'extrait cité à l'écran reste lisible d'un coup d'œil.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
TAILLE_CIBLE = 350
|
||||||
|
CHEVAUCHEMENT = 80
|
||||||
|
TAILLE_MINIMALE = 40 # en deçà : bruit (titres orphelins, numéros de page)
|
||||||
|
|
||||||
|
|
||||||
|
def _phrases(texte: str) -> list[str]:
|
||||||
|
return [p.strip() for p in re.split(r"(?<=[.!?;])\s+", texte) if p.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def decouper(texte: str) -> list[str]:
|
||||||
|
paragraphes = [p.strip() for p in re.split(r"\n\s*\n", texte) if p.strip()]
|
||||||
|
extraits: list[str] = []
|
||||||
|
courant = ""
|
||||||
|
|
||||||
|
def pousser() -> None:
|
||||||
|
nonlocal courant
|
||||||
|
nettoye = courant.strip()
|
||||||
|
if len(nettoye) >= TAILLE_MINIMALE:
|
||||||
|
extraits.append(nettoye)
|
||||||
|
courant = ""
|
||||||
|
|
||||||
|
for paragraphe in paragraphes:
|
||||||
|
paragraphe = re.sub(r"\s+", " ", paragraphe)
|
||||||
|
if len(courant) + len(paragraphe) + 1 > TAILLE_CIBLE and courant:
|
||||||
|
queue = courant[-CHEVAUCHEMENT:]
|
||||||
|
pousser()
|
||||||
|
courant = queue + " "
|
||||||
|
while len(paragraphe) > TAILLE_CIBLE:
|
||||||
|
phrases = _phrases(paragraphe)
|
||||||
|
if len(phrases) <= 1:
|
||||||
|
courant += paragraphe[:TAILLE_CIBLE]
|
||||||
|
paragraphe = paragraphe[TAILLE_CIBLE - CHEVAUCHEMENT :]
|
||||||
|
pousser()
|
||||||
|
continue
|
||||||
|
morceau = ""
|
||||||
|
while phrases and len(morceau) + len(phrases[0]) + 1 <= TAILLE_CIBLE:
|
||||||
|
morceau += phrases.pop(0) + " "
|
||||||
|
courant += morceau
|
||||||
|
pousser()
|
||||||
|
paragraphe = " ".join(phrases)
|
||||||
|
courant += paragraphe + " "
|
||||||
|
pousser()
|
||||||
|
return extraits
|
||||||
65
apps/ai/src/siop_ai/embeddings.py
Normal file
65
apps/ai/src/siop_ai/embeddings.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
"""Embeddeurs (ADR-004) : le vrai modèle local ONNX, et un déterministe pour
|
||||||
|
tests/CI — même interface, mêmes dimensions (DIMENSIONS), aucun téléchargement en test.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import math
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
DIMENSIONS = 768
|
||||||
|
# mpnet remplace MiniLM-384 : décision de recette R5 (journal 17/07) — sur le
|
||||||
|
# banc français, MiniLM classait la page-réponse DERRIÈRE des passages sans
|
||||||
|
# rapport (0,24 vs 0,41) ; mpnet rétablit le classement et une marge
|
||||||
|
# signal/bruit exploitable (≥ 0,46 vs ≤ 0,42).
|
||||||
|
MODELE_LOCAL = "sentence-transformers/paraphrase-multilingual-mpnet-base-v2"
|
||||||
|
|
||||||
|
|
||||||
|
class Embeddeur(Protocol):
|
||||||
|
def encoder(self, textes: list[str]) -> list[list[float]]: ...
|
||||||
|
|
||||||
|
|
||||||
|
class EmbeddeurDeterministe:
|
||||||
|
"""Sac de tri-grammes haché puis normalisé : stable, sans réseau, et les
|
||||||
|
textes proches partagent des composantes — assez pour tester le circuit
|
||||||
|
complet (ingestion → pgvector → similarité)."""
|
||||||
|
|
||||||
|
def encoder(self, textes: list[str]) -> list[list[float]]:
|
||||||
|
return [self._un(t) for t in textes]
|
||||||
|
|
||||||
|
def _un(self, texte: str) -> list[float]:
|
||||||
|
vecteur = [0.0] * DIMENSIONS
|
||||||
|
mots = texte.lower().split()
|
||||||
|
grammes = mots + [" ".join(mots[i : i + 3]) for i in range(max(0, len(mots) - 2))]
|
||||||
|
for gramme in grammes:
|
||||||
|
empreinte = hashlib.sha256(gramme.encode()).digest()
|
||||||
|
indice = int.from_bytes(empreinte[:4], "big") % DIMENSIONS
|
||||||
|
signe = 1.0 if empreinte[4] % 2 == 0 else -1.0
|
||||||
|
vecteur[indice] += signe
|
||||||
|
norme = math.sqrt(sum(v * v for v in vecteur)) or 1.0
|
||||||
|
return [v / norme for v in vecteur]
|
||||||
|
|
||||||
|
|
||||||
|
class EmbeddeurLocal:
|
||||||
|
"""fastembed (ONNX, CPU) — chargé paresseusement, jamais importé en test.
|
||||||
|
Sortie NORMÉE : fastembed ne garantit pas des vecteurs unitaires, or la
|
||||||
|
similarité par produit scalaire (suggestions) l'exige — pgvector, lui,
|
||||||
|
normalise dans son opérateur cosinus, ce qui masquait l'écart."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
from fastembed import TextEmbedding # import différé (dépendance optionnelle)
|
||||||
|
|
||||||
|
self._modele = TextEmbedding(model_name=MODELE_LOCAL)
|
||||||
|
|
||||||
|
def encoder(self, textes: list[str]) -> list[list[float]]:
|
||||||
|
vecteurs = []
|
||||||
|
for vecteur in self._modele.embed(textes):
|
||||||
|
liste = vecteur.tolist()
|
||||||
|
norme = math.sqrt(sum(v * v for v in liste)) or 1.0
|
||||||
|
vecteurs.append([v / norme for v in liste])
|
||||||
|
return vecteurs
|
||||||
|
|
||||||
|
|
||||||
|
def construire_embeddeur(mode: str) -> Embeddeur:
|
||||||
|
if mode == "deterministe":
|
||||||
|
return EmbeddeurDeterministe()
|
||||||
|
return EmbeddeurLocal()
|
||||||
94
apps/ai/src/siop_ai/generation.py
Normal file
94
apps/ai/src/siop_ai/generation.py
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
"""Génération des réponses rédigées (ADR-004 §3) — OPT-IN par configuration.
|
||||||
|
|
||||||
|
- « off » (défaut) : mode extractif — pas de LLM, l'assistant montrera les
|
||||||
|
extraits sourcés tels quels. La recette R5 passe entièrement dans ce mode.
|
||||||
|
- « api » : rédaction par Claude (SDK officiel), sur des extraits DÉJÀ
|
||||||
|
anonymisés (D4), avec l'obligation de ne rien affirmer hors extraits (D2).
|
||||||
|
Tout échec (refus, réseau, quota) retombe sur le mode extractif — jamais
|
||||||
|
d'erreur utilisateur à cause du LLM.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
from .recherche import ExtraitTrouve
|
||||||
|
|
||||||
|
journal = logging.getLogger("siop_ai.generation")
|
||||||
|
|
||||||
|
CONSIGNE = """Tu es l'assistant SIOP d'une société de maintenance d'ascenseurs.
|
||||||
|
Tu réponds en français, UNIQUEMENT à partir des extraits fournis (notices et
|
||||||
|
historiques d'intervention du parc, déjà anonymisés).
|
||||||
|
Règles absolues :
|
||||||
|
- chaque affirmation porte sa citation [n] renvoyant à un extrait fourni ;
|
||||||
|
- si les extraits ne portent pas la réponse, dis-le et n'invente RIEN ;
|
||||||
|
- reste bref et opérationnel : un technicien te lit sur le terrain ;
|
||||||
|
- termine toujours par le rappel que l'humain vérifie avant d'agir."""
|
||||||
|
|
||||||
|
|
||||||
|
class Generateur(Protocol):
|
||||||
|
def rediger(self, question: str, extraits: list[ExtraitTrouve]) -> str | None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class GenerateurExtractif:
|
||||||
|
"""Le contrat de base : pas de rédaction — l'appelant montre les extraits.
|
||||||
|
`None` signifie « pas de texte généré », jamais une erreur."""
|
||||||
|
|
||||||
|
def rediger(self, question: str, extraits: list[ExtraitTrouve]) -> str | None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def construire_invite(question: str, extraits: list[ExtraitTrouve]) -> str:
|
||||||
|
"""L'invite utilisateur — fonction pure, testée : la question et les
|
||||||
|
extraits numérotés, rien d'autre (les textes sont déjà anonymisés)."""
|
||||||
|
blocs = [
|
||||||
|
f"[{rang}] {e.titre} · {e.locator}\n{e.content}"
|
||||||
|
for rang, e in enumerate(extraits, start=1)
|
||||||
|
]
|
||||||
|
return "Extraits du corpus :\n\n" + "\n\n".join(blocs) + f"\n\nQuestion : {question}"
|
||||||
|
|
||||||
|
|
||||||
|
class GenerateurAPI:
|
||||||
|
"""Rédaction par Claude — import différé : la dépendance `anthropic` est
|
||||||
|
optionnelle (groupe `generation`), absente des tests/CI."""
|
||||||
|
|
||||||
|
def __init__(self, api_key: str, modele: str) -> None:
|
||||||
|
from anthropic import Anthropic
|
||||||
|
|
||||||
|
self._client = Anthropic(api_key=api_key)
|
||||||
|
self._modele = modele
|
||||||
|
|
||||||
|
def rediger(self, question: str, extraits: list[ExtraitTrouve]) -> str | None:
|
||||||
|
import anthropic
|
||||||
|
|
||||||
|
if not extraits:
|
||||||
|
return None # sourcé ou silencieux (D2) : rien à citer = rien à rédiger
|
||||||
|
try:
|
||||||
|
reponse = self._client.messages.create(
|
||||||
|
model=self._modele,
|
||||||
|
max_tokens=2048, # réponses courtes et sourcées, par conception
|
||||||
|
thinking={"type": "adaptive"},
|
||||||
|
system=CONSIGNE,
|
||||||
|
messages=[
|
||||||
|
{"role": "user", "content": construire_invite(question, extraits)}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
if reponse.stop_reason == "refusal":
|
||||||
|
journal.warning("Génération refusée par le modèle — repli extractif")
|
||||||
|
return None
|
||||||
|
texte = "".join(b.text for b in reponse.content if b.type == "text").strip()
|
||||||
|
return texte or None
|
||||||
|
except anthropic.RateLimitError:
|
||||||
|
journal.warning("Quota API atteint — repli extractif")
|
||||||
|
return None
|
||||||
|
except anthropic.APIStatusError as e:
|
||||||
|
journal.warning("API génération %s — repli extractif", e.status_code)
|
||||||
|
return None
|
||||||
|
except anthropic.APIConnectionError:
|
||||||
|
journal.warning("API génération injoignable — repli extractif")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def construire_generateur(mode: str, api_key: str, modele: str) -> Generateur:
|
||||||
|
if mode == "api":
|
||||||
|
return GenerateurAPI(api_key, modele)
|
||||||
|
return GenerateurExtractif()
|
||||||
175
apps/ai/src/siop_ai/ingestion.py
Normal file
175
apps/ai/src/siop_ai/ingestion.py
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
"""Ingestion du corpus (D3) : bibliothèque R3 (PDF, MinIO) + bilans codés.
|
||||||
|
Chaque texte passe par l'anonymisation (D4) AVANT découpage et embeddings.
|
||||||
|
Les chunks vivent dans `RagChunk` (pgvector, schéma possédé par Prisma).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import asyncpg
|
||||||
|
from minio import Minio
|
||||||
|
from pypdf import PdfReader
|
||||||
|
|
||||||
|
from .anonymisation import anonymiser
|
||||||
|
from .config import Reglages
|
||||||
|
from .decoupage import decouper
|
||||||
|
from .embeddings import Embeddeur
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ResultatIngestion:
|
||||||
|
documents_indexes: int
|
||||||
|
documents_ignores: int
|
||||||
|
bilans_indexes: int
|
||||||
|
extraits: int
|
||||||
|
|
||||||
|
|
||||||
|
def _vecteur_sql(vecteur: list[float]) -> str:
|
||||||
|
return "[" + ",".join(f"{v:.6f}" for v in vecteur) + "]"
|
||||||
|
|
||||||
|
|
||||||
|
async def noms_a_anonymiser(cnx: asyncpg.Connection) -> list[str]:
|
||||||
|
"""Toutes les identités connues de la base (D4) : utilisateurs, gardiens,
|
||||||
|
contacts tiers, demandeurs du portail."""
|
||||||
|
lignes = await cnx.fetch(
|
||||||
|
'''
|
||||||
|
SELECT "displayName" AS nom FROM "User"
|
||||||
|
UNION SELECT "guardianName" FROM "Location" WHERE "guardianName" IS NOT NULL
|
||||||
|
UNION SELECT "contactName" FROM "Partner" WHERE "contactName" IS NOT NULL
|
||||||
|
'''
|
||||||
|
)
|
||||||
|
return [ligne["nom"] for ligne in lignes if ligne["nom"]]
|
||||||
|
|
||||||
|
|
||||||
|
def extraire_texte_pdf(octets: bytes) -> list[tuple[str, str]]:
|
||||||
|
"""[(texte, localisation)] par page — la citation doit pointer la page."""
|
||||||
|
lecteur = PdfReader(io.BytesIO(octets))
|
||||||
|
pages: list[tuple[str, str]] = []
|
||||||
|
for numero, page in enumerate(lecteur.pages, start=1):
|
||||||
|
texte = page.extract_text() or ""
|
||||||
|
if texte.strip():
|
||||||
|
pages.append((texte, f"p. {numero}"))
|
||||||
|
return pages
|
||||||
|
|
||||||
|
|
||||||
|
async def indexer_documents(
|
||||||
|
cnx: asyncpg.Connection,
|
||||||
|
reglages: Reglages,
|
||||||
|
embeddeur: Embeddeur,
|
||||||
|
noms: list[str],
|
||||||
|
) -> tuple[int, int, int]:
|
||||||
|
minio = Minio(
|
||||||
|
f"{reglages.minio_endpoint}:{reglages.minio_port}",
|
||||||
|
access_key=reglages.minio_access_key,
|
||||||
|
secret_key=reglages.minio_secret_key,
|
||||||
|
secure=reglages.minio_use_ssl,
|
||||||
|
)
|
||||||
|
documents = await cnx.fetch(
|
||||||
|
'SELECT id, "fileName", "storageKey", "contentType", "inCorpus" FROM "Document"'
|
||||||
|
)
|
||||||
|
indexes, ignores, total_extraits = 0, 0, 0
|
||||||
|
for doc in documents:
|
||||||
|
# réindexation idempotente : on repart de zéro pour ce document
|
||||||
|
await cnx.execute('DELETE FROM "RagChunk" WHERE "documentId" = $1', doc["id"])
|
||||||
|
if not doc["inCorpus"] or doc["contentType"] != "application/pdf":
|
||||||
|
await cnx.execute(
|
||||||
|
'UPDATE "Document" SET "indexedAt" = NULL, "chunkCount" = 0 WHERE id = $1',
|
||||||
|
doc["id"],
|
||||||
|
)
|
||||||
|
ignores += 1
|
||||||
|
continue
|
||||||
|
reponse = minio.get_object(reglages.minio_bucket, doc["storageKey"])
|
||||||
|
try:
|
||||||
|
octets = reponse.read()
|
||||||
|
finally:
|
||||||
|
reponse.close()
|
||||||
|
reponse.release_conn()
|
||||||
|
extraits: list[tuple[str, str]] = []
|
||||||
|
for texte_page, localisation in extraire_texte_pdf(octets):
|
||||||
|
texte_sur = anonymiser(texte_page, noms)
|
||||||
|
extraits += [(morceau, localisation) for morceau in decouper(texte_sur)]
|
||||||
|
if extraits:
|
||||||
|
vecteurs = embeddeur.encoder([contenu for contenu, _ in extraits])
|
||||||
|
await cnx.executemany(
|
||||||
|
'''
|
||||||
|
INSERT INTO "RagChunk"
|
||||||
|
(id, "sourceType", "documentId", locator, content, embedding)
|
||||||
|
VALUES (gen_random_uuid(), 'DOCUMENT', $1, $2, $3, $4::vector)
|
||||||
|
''',
|
||||||
|
[
|
||||||
|
(doc["id"], localisation, contenu, _vecteur_sql(vecteur))
|
||||||
|
for (contenu, localisation), vecteur in zip(extraits, vecteurs)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
await cnx.execute(
|
||||||
|
'UPDATE "Document" SET "indexedAt" = now(), "chunkCount" = $2 WHERE id = $1',
|
||||||
|
doc["id"],
|
||||||
|
len(extraits),
|
||||||
|
)
|
||||||
|
indexes += 1
|
||||||
|
total_extraits += len(extraits)
|
||||||
|
return indexes, ignores, total_extraits
|
||||||
|
|
||||||
|
|
||||||
|
async def indexer_bilans(
|
||||||
|
cnx: asyncpg.Connection, embeddeur: Embeddeur, noms: list[str]
|
||||||
|
) -> tuple[int, int]:
|
||||||
|
"""Les bilans codés clôturés — « sur votre parc, ce réglage a déjà… »."""
|
||||||
|
await cnx.execute('DELETE FROM "RagChunk" WHERE "sourceType" = \'WORK_ORDER\'')
|
||||||
|
bilans = await cnx.fetch(
|
||||||
|
'''
|
||||||
|
SELECT wo.id, wo.reference, wo.title, wo."completedAt",
|
||||||
|
a.reference AS asset_ref, a.brand, a.model,
|
||||||
|
(SELECT json_object_agg(rv.field, rv.label)
|
||||||
|
FROM "InterventionReport" ir2
|
||||||
|
JOIN "ReferenceValue" rv ON rv.id IN (
|
||||||
|
ir2."doorStateId", ir2."cabinPositionId", ir2."anomalyId",
|
||||||
|
ir2."externalCauseId", ir2."actionTakenId", ir2."componentConcernedId")
|
||||||
|
WHERE ir2."workOrderId" = wo.id) AS bilan
|
||||||
|
FROM "WorkOrder" wo
|
||||||
|
JOIN "InterventionReport" ir ON ir."workOrderId" = wo.id
|
||||||
|
JOIN "Asset" a ON a.id = wo."assetId"
|
||||||
|
WHERE wo.status = 'DONE'
|
||||||
|
'''
|
||||||
|
)
|
||||||
|
lignes = []
|
||||||
|
for bilan in bilans:
|
||||||
|
champs = json.loads(bilan["bilan"]) if bilan["bilan"] else {}
|
||||||
|
codes = " ; ".join(f"{champ} : {label}" for champ, label in champs.items())
|
||||||
|
contenu = anonymiser(
|
||||||
|
f"Intervention {bilan['reference']} — {bilan['title']}. "
|
||||||
|
f"Appareil {bilan['asset_ref']} ({bilan['brand']} {bilan['model'] or ''}). "
|
||||||
|
f"Bilan codé : {codes}.",
|
||||||
|
noms,
|
||||||
|
)
|
||||||
|
quand = bilan["completedAt"].date().isoformat() if bilan["completedAt"] else "date inconnue"
|
||||||
|
lignes.append((bilan["id"], f"bilan du {quand}", contenu))
|
||||||
|
if lignes:
|
||||||
|
vecteurs = embeddeur.encoder([contenu for _, _, contenu in lignes])
|
||||||
|
await cnx.executemany(
|
||||||
|
'''
|
||||||
|
INSERT INTO "RagChunk"
|
||||||
|
(id, "sourceType", "workOrderId", locator, content, embedding)
|
||||||
|
VALUES (gen_random_uuid(), 'WORK_ORDER', $1, $2, $3, $4::vector)
|
||||||
|
''',
|
||||||
|
[
|
||||||
|
(wo_id, localisation, contenu, _vecteur_sql(vecteur))
|
||||||
|
for (wo_id, localisation, contenu), vecteur in zip(lignes, vecteurs)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return len(lignes), len(lignes)
|
||||||
|
|
||||||
|
|
||||||
|
async def reindexer_tout(
|
||||||
|
cnx: asyncpg.Connection, reglages: Reglages, embeddeur: Embeddeur
|
||||||
|
) -> ResultatIngestion:
|
||||||
|
noms = await noms_a_anonymiser(cnx)
|
||||||
|
docs_ok, docs_non, extraits_docs = await indexer_documents(cnx, reglages, embeddeur, noms)
|
||||||
|
bilans, extraits_bilans = await indexer_bilans(cnx, embeddeur, noms)
|
||||||
|
return ResultatIngestion(
|
||||||
|
documents_indexes=docs_ok,
|
||||||
|
documents_ignores=docs_non,
|
||||||
|
bilans_indexes=bilans,
|
||||||
|
extraits=extraits_docs + extraits_bilans,
|
||||||
|
)
|
||||||
57
apps/ai/src/siop_ai/recherche.py
Normal file
57
apps/ai/src/siop_ai/recherche.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
"""Recherche sémantique dans le corpus (pgvector, distance cosinus).
|
||||||
|
Ne renvoie QUE des extraits sourcés — la brique de « sourcé ou silencieux ».
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import asyncpg
|
||||||
|
|
||||||
|
from .embeddings import Embeddeur
|
||||||
|
from .ingestion import _vecteur_sql
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ExtraitTrouve:
|
||||||
|
source_type: str
|
||||||
|
document_id: str | None
|
||||||
|
work_order_id: str | None
|
||||||
|
titre: str # nom de fichier ou référence d'OT
|
||||||
|
locator: str
|
||||||
|
content: str
|
||||||
|
score: float # similarité cosinus (0..1)
|
||||||
|
|
||||||
|
|
||||||
|
async def chercher(
|
||||||
|
cnx: asyncpg.Connection,
|
||||||
|
embeddeur: Embeddeur,
|
||||||
|
question: str,
|
||||||
|
limite: int = 5,
|
||||||
|
) -> list[ExtraitTrouve]:
|
||||||
|
vecteur = _vecteur_sql(embeddeur.encoder([question])[0])
|
||||||
|
lignes = await cnx.fetch(
|
||||||
|
'''
|
||||||
|
SELECT c."sourceType", c."documentId", c."workOrderId", c.locator, c.content,
|
||||||
|
1 - (c.embedding <=> $1::vector) AS score,
|
||||||
|
COALESCE(d."fileName", wo.reference, '?') AS titre
|
||||||
|
FROM "RagChunk" c
|
||||||
|
LEFT JOIN "Document" d ON d.id = c."documentId"
|
||||||
|
LEFT JOIN "WorkOrder" wo ON wo.id = c."workOrderId"
|
||||||
|
WHERE c."documentId" IS NULL OR d."inCorpus" -- l'exclusion D3 s'applique aussi à la lecture
|
||||||
|
ORDER BY c.embedding <=> $1::vector
|
||||||
|
LIMIT $2
|
||||||
|
''',
|
||||||
|
vecteur,
|
||||||
|
limite,
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
ExtraitTrouve(
|
||||||
|
source_type=ligne["sourceType"],
|
||||||
|
document_id=str(ligne["documentId"]) if ligne["documentId"] else None,
|
||||||
|
work_order_id=str(ligne["workOrderId"]) if ligne["workOrderId"] else None,
|
||||||
|
titre=ligne["titre"],
|
||||||
|
locator=ligne["locator"],
|
||||||
|
content=ligne["content"],
|
||||||
|
score=float(ligne["score"]),
|
||||||
|
)
|
||||||
|
for ligne in lignes
|
||||||
|
]
|
||||||
33
apps/ai/tests/test_anonymisation.py
Normal file
33
apps/ai/tests/test_anonymisation.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
"""D4 (loi 09-08) : rien d'identifiant ne doit survivre à l'ingestion."""
|
||||||
|
|
||||||
|
from siop_ai.anonymisation import JETON_CONTACT, JETON_PERSONNE, anonymiser
|
||||||
|
|
||||||
|
|
||||||
|
def test_emails_et_telephones_marocains():
|
||||||
|
texte = "Appeler M. Alami au 06 12 34 56 78 ou +212 5 22 34 56 78, sinon gardien@residence.ma"
|
||||||
|
resultat = anonymiser(texte, ["M. Alami"])
|
||||||
|
assert "06 12" not in resultat
|
||||||
|
assert "+212" not in resultat
|
||||||
|
assert "gardien@residence.ma" not in resultat
|
||||||
|
assert resultat.count(JETON_CONTACT) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_noms_connus_meme_sans_accents_ni_casse():
|
||||||
|
texte = "Intervention validée par salma idrissi puis contrôlée par AHMED BENALI."
|
||||||
|
resultat = anonymiser(texte, ["Salma Idrissi", "Ahmed Benali"])
|
||||||
|
assert "idrissi" not in resultat.lower()
|
||||||
|
assert "benali" not in resultat.lower()
|
||||||
|
assert resultat.count(JETON_PERSONNE) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_prenom_seul_est_attrape_mais_pas_les_mots_courts():
|
||||||
|
resultat = anonymiser("Vu avec Ahmed sur site.", ["Ahmed Benali"])
|
||||||
|
assert "Ahmed" not in resultat
|
||||||
|
# « NC-31 » ou « vis » ne doivent jamais être mutilés par un nom court
|
||||||
|
resultat2 = anonymiser("Contact NC-31 réglé, vis serrées.", ["N. C."])
|
||||||
|
assert "NC-31" in resultat2
|
||||||
|
|
||||||
|
|
||||||
|
def test_le_texte_technique_reste_intact():
|
||||||
|
texte = "Serrer les coulisseaux au couple de 25 N·m ; jeu latéral 0,5 mm."
|
||||||
|
assert anonymiser(texte, ["Salma Idrissi"]) == texte
|
||||||
37
apps/ai/tests/test_app.py
Normal file
37
apps/ai/tests/test_app.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
"""Le service n'est jamais public : sans le jeton de service, 401 partout
|
||||||
|
(la santé exceptée — sonde d'infra qui ne révèle rien)."""
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from siop_ai.app import app
|
||||||
|
|
||||||
|
|
||||||
|
def test_sante_publique_interne():
|
||||||
|
reponse = _client_sans_db().get("/healthz")
|
||||||
|
assert reponse.status_code == 200
|
||||||
|
assert reponse.json()["service"] == "siop2-ai"
|
||||||
|
|
||||||
|
|
||||||
|
def test_endpoints_internes_refuses_sans_jeton():
|
||||||
|
client = _client_sans_db()
|
||||||
|
assert client.post("/internal/reindex").status_code == 401
|
||||||
|
assert client.post("/internal/search", json={"question": "couple de serrage ?"}).status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_question_trop_courte_rejetee_avant_tout():
|
||||||
|
reponse = _client_sans_db().post(
|
||||||
|
"/internal/search",
|
||||||
|
json={"question": "ab"},
|
||||||
|
headers={"X-Service-Token": "dev-only-ai-token"},
|
||||||
|
)
|
||||||
|
assert reponse.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def _client_sans_db() -> TestClient:
|
||||||
|
"""TestClient HORS gestionnaire de contexte : le lifespan (pool DB,
|
||||||
|
modèle d'embeddings) ne tourne pas — on pose l'état minimal. Les tests
|
||||||
|
d'intégration DB se font en local (recette), pas en CI (convention R5.1)."""
|
||||||
|
from siop_ai.config import charger_reglages
|
||||||
|
|
||||||
|
app.state.reglages = charger_reglages()
|
||||||
|
return TestClient(app, raise_server_exceptions=False)
|
||||||
51
apps/ai/tests/test_assistant.py
Normal file
51
apps/ai/tests/test_assistant.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
"""Logique de l'assistant testée sans base : le seuil « sourcé ou silencieux »
|
||||||
|
et la sélection des suggestions — la partie SQL est couverte par la recette
|
||||||
|
réelle (convention R5.1 : pytest purs en CI)."""
|
||||||
|
|
||||||
|
from siop_ai.assistant import (
|
||||||
|
CHAMPS_BILAN,
|
||||||
|
SEUIL_CONFIANCE_FORTE,
|
||||||
|
SEUIL_PERTINENCE,
|
||||||
|
SEUIL_SUGGESTION,
|
||||||
|
_cosinus,
|
||||||
|
)
|
||||||
|
from siop_ai.embeddings import EmbeddeurDeterministe
|
||||||
|
|
||||||
|
|
||||||
|
def test_les_seuils_sont_ordonnes():
|
||||||
|
# Pertinence et suggestion vivent dans des pipelines distincts (question →
|
||||||
|
# passages vs description → libellés) : pas d'ordre imposé entre eux.
|
||||||
|
# L'invariant : une suggestion tout juste retenue n'est jamais « forte ».
|
||||||
|
assert 0 < SEUIL_PERTINENCE < 1
|
||||||
|
assert 0 < SEUIL_SUGGESTION < SEUIL_CONFIANCE_FORTE < 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_champs_bilan_couvrent_les_six_champs_du_contrat():
|
||||||
|
assert set(CHAMPS_BILAN) == {
|
||||||
|
"DOOR_STATE",
|
||||||
|
"CABIN_POSITION",
|
||||||
|
"ANOMALY",
|
||||||
|
"EXTERNAL_CAUSE",
|
||||||
|
"ACTION_TAKEN",
|
||||||
|
"COMPONENT_CONCERNED",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_similarite_discrimine_le_bon_code():
|
||||||
|
"""Le cœur de la suggestion : une description de panne de porte doit être
|
||||||
|
plus proche du code « portes » que d'un code sans rapport."""
|
||||||
|
embeddeur = EmbeddeurDeterministe()
|
||||||
|
description, porte, treuil = embeddeur.encoder(
|
||||||
|
[
|
||||||
|
"la porte cabine rebondit, cellule encrassée, nettoyage barrière porte",
|
||||||
|
"anomalie constatée : cellule ou barrière de porte encrassée",
|
||||||
|
"élément concerné : treuil et moteur de traction",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert _cosinus(description, porte) > _cosinus(description, treuil)
|
||||||
|
|
||||||
|
|
||||||
|
def test_cosinus_de_vecteurs_normes():
|
||||||
|
embeddeur = EmbeddeurDeterministe()
|
||||||
|
[v] = embeddeur.encoder(["contrôle mensuel des portes palières"])
|
||||||
|
assert abs(_cosinus(v, v) - 1.0) < 1e-6
|
||||||
24
apps/ai/tests/test_decoupage.py
Normal file
24
apps/ai/tests/test_decoupage.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
from siop_ai.decoupage import CHEVAUCHEMENT, TAILLE_CIBLE, decouper
|
||||||
|
|
||||||
|
|
||||||
|
def test_texte_court_un_seul_extrait():
|
||||||
|
extraits = decouper("Serrer les vis de fixation des coulisseaux au couple de 25 N·m.")
|
||||||
|
assert len(extraits) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_les_miettes_sont_ecartees():
|
||||||
|
assert decouper("p. 3\n\n7\n\n") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_long_texte_decoupe_avec_chevauchement():
|
||||||
|
paragraphe = "La procédure de maintenance impose un contrôle mensuel des organes. " * 40
|
||||||
|
extraits = decouper(paragraphe)
|
||||||
|
assert len(extraits) >= 2
|
||||||
|
assert all(len(e) <= TAILLE_CIBLE + CHEVAUCHEMENT for e in extraits)
|
||||||
|
|
||||||
|
|
||||||
|
def test_paragraphes_courts_regroupes():
|
||||||
|
texte = "\n\n".join(f"Étape {i} : vérifier le verrouillage de la porte palière." for i in range(6))
|
||||||
|
extraits = decouper(texte)
|
||||||
|
assert len(extraits) == 1
|
||||||
|
assert "Étape 0" in extraits[0] and "Étape 5" in extraits[0]
|
||||||
26
apps/ai/tests/test_embeddings.py
Normal file
26
apps/ai/tests/test_embeddings.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
from siop_ai.embeddings import DIMENSIONS, EmbeddeurDeterministe
|
||||||
|
|
||||||
|
|
||||||
|
def test_dimensions_et_normalisation():
|
||||||
|
[vecteur] = EmbeddeurDeterministe().encoder(["couple de serrage des guides"])
|
||||||
|
assert len(vecteur) == DIMENSIONS
|
||||||
|
assert abs(sum(v * v for v in vecteur) - 1.0) < 1e-6
|
||||||
|
|
||||||
|
|
||||||
|
def test_stable_et_discriminant():
|
||||||
|
embeddeur = EmbeddeurDeterministe()
|
||||||
|
a1 = embeddeur.encoder(["couple de serrage des coulisseaux de guides"])[0]
|
||||||
|
a2 = embeddeur.encoder(["couple de serrage des coulisseaux de guides"])[0]
|
||||||
|
b = embeddeur.encoder(["planning des congés du personnel administratif"])[0]
|
||||||
|
cosinus = lambda x, y: sum(p * q for p, q in zip(x, y)) # noqa: E731 — vecteurs normés
|
||||||
|
assert a1 == a2
|
||||||
|
assert cosinus(a1, b) < 0.35 < cosinus(a1, a1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_textes_proches_plus_similaires_que_textes_eloignes():
|
||||||
|
embeddeur = EmbeddeurDeterministe()
|
||||||
|
question = embeddeur.encoder(["quel couple de serrage pour les guides ?"])[0]
|
||||||
|
notice = embeddeur.encoder(["serrer les coulisseaux de guides au couple de 25 N·m"])[0]
|
||||||
|
horsujet = embeddeur.encoder(["le syndic organise une assemblée générale annuelle"])[0]
|
||||||
|
cosinus = lambda x, y: sum(p * q for p, q in zip(x, y)) # noqa: E731
|
||||||
|
assert cosinus(question, notice) > cosinus(question, horsujet)
|
||||||
62
apps/ai/tests/test_generation.py
Normal file
62
apps/ai/tests/test_generation.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
"""ADR-004 §3 — la génération est OPT-IN et configurable : clé, modèle, mode.
|
||||||
|
Sans clé, le mode extractif est le contrat ; en mode api sans clé, le boot
|
||||||
|
refuse (config validée au démarrage, comme l'API NestJS)."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from siop_ai.config import charger_reglages
|
||||||
|
from siop_ai.generation import GenerateurExtractif, construire_generateur, construire_invite
|
||||||
|
from siop_ai.recherche import ExtraitTrouve
|
||||||
|
|
||||||
|
|
||||||
|
def _extrait(titre: str, locator: str, content: str) -> ExtraitTrouve:
|
||||||
|
return ExtraitTrouve(
|
||||||
|
source_type="DOCUMENT",
|
||||||
|
document_id="d-1",
|
||||||
|
work_order_id=None,
|
||||||
|
titre=titre,
|
||||||
|
locator=locator,
|
||||||
|
content=content,
|
||||||
|
score=0.9,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_defaut_extractif_sans_cle():
|
||||||
|
generateur = construire_generateur("off", "", "claude-opus-4-8")
|
||||||
|
assert isinstance(generateur, GenerateurExtractif)
|
||||||
|
assert generateur.rediger("couple de serrage ?", [_extrait("n", "p. 1", "x")]) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_mode_api_sans_cle_refuse_au_boot(monkeypatch):
|
||||||
|
monkeypatch.setenv("AI_GENERATION", "api")
|
||||||
|
monkeypatch.delenv("AI_API_KEY", raising=False)
|
||||||
|
with pytest.raises(ValueError, match="AI_API_KEY"):
|
||||||
|
charger_reglages()
|
||||||
|
|
||||||
|
|
||||||
|
def test_mode_inconnu_refuse_au_boot(monkeypatch):
|
||||||
|
monkeypatch.setenv("AI_GENERATION", "toujours")
|
||||||
|
with pytest.raises(ValueError, match="AI_GENERATION"):
|
||||||
|
charger_reglages()
|
||||||
|
|
||||||
|
|
||||||
|
def test_cle_et_modele_configurables(monkeypatch):
|
||||||
|
monkeypatch.setenv("AI_GENERATION", "api")
|
||||||
|
monkeypatch.setenv("AI_API_KEY", "sk-test-123")
|
||||||
|
monkeypatch.setenv("AI_MODEL", "claude-opus-4-8")
|
||||||
|
reglages = charger_reglages()
|
||||||
|
assert reglages.ai_api_key == "sk-test-123"
|
||||||
|
assert reglages.ai_model == "claude-opus-4-8"
|
||||||
|
|
||||||
|
|
||||||
|
def test_invite_numerote_les_extraits_et_rien_d_autre():
|
||||||
|
invite = construire_invite(
|
||||||
|
"quel couple de serrage pour les guides ?",
|
||||||
|
[
|
||||||
|
_extrait("Notice Gen2.pdf", "p. 42", "Serrer à 25 N·m."),
|
||||||
|
_extrait("OT-2026-0341", "bilan du 2026-07-17", "Coulisseaux remplacés."),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert "[1] Notice Gen2.pdf · p. 42" in invite
|
||||||
|
assert "[2] OT-2026-0341 · bilan du 2026-07-17" in invite
|
||||||
|
assert invite.endswith("Question : quel couple de serrage pour les guides ?")
|
||||||
1870
apps/ai/uv.lock
generated
Normal file
1870
apps/ai/uv.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -18,3 +18,6 @@ MINIO_ENDPOINT=localhost
|
|||||||
MINIO_PORT=9000
|
MINIO_PORT=9000
|
||||||
MINIO_ACCESS_KEY=siop
|
MINIO_ACCESS_KEY=siop
|
||||||
MINIO_SECRET_KEY=siop-minio
|
MINIO_SECRET_KEY=siop-minio
|
||||||
|
|
||||||
|
# R4 — origines navigateur autorisées (Expo web / debug mobile). Vide = pas de CORS.
|
||||||
|
CORS_ORIGINS=http://localhost:8081
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
-- R5 (ADR-004) : pgvector — même patron que PostGIS en r1_referentiel
|
||||||
|
CREATE EXTENSION IF NOT EXISTS vector;
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "RagSourceType" AS ENUM ('DOCUMENT', 'WORK_ORDER');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Document" ADD COLUMN "chunkCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN "inCorpus" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
ADD COLUMN "indexedAt" TIMESTAMP(3);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "RagChunk" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"sourceType" "RagSourceType" NOT NULL,
|
||||||
|
"documentId" UUID,
|
||||||
|
"workOrderId" UUID,
|
||||||
|
"locator" TEXT NOT NULL,
|
||||||
|
"content" TEXT NOT NULL,
|
||||||
|
"embedding" vector(384) NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "RagChunk_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "RagChunk_documentId_idx" ON "RagChunk"("documentId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "RagChunk_workOrderId_idx" ON "RagChunk"("workOrderId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "RagChunk" ADD CONSTRAINT "RagChunk_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "Document"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "RagChunk" ADD CONSTRAINT "RagChunk_workOrderId_fkey" FOREIGN KEY ("workOrderId") REFERENCES "WorkOrder"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- Décision de recette R5 (journal 17/07) : le modèle d'embeddings passe de
|
||||||
|
-- MiniLM (384 dims) à paraphrase-multilingual-mpnet-base-v2 (768 dims) —
|
||||||
|
-- MiniLM classait la page-réponse derrière des passages sans rapport.
|
||||||
|
-- Les chunks sont re-dérivables : on vide l'index et on change la dimension ;
|
||||||
|
-- une réindexation (bouton « Réindexer tout » ou /assistant/reindex) reconstruit tout.
|
||||||
|
TRUNCATE "RagChunk";
|
||||||
|
ALTER TABLE "RagChunk" DROP COLUMN "embedding";
|
||||||
|
ALTER TABLE "RagChunk" ADD COLUMN "embedding" vector(768) NOT NULL;
|
||||||
@@ -233,6 +233,7 @@ model WorkOrder {
|
|||||||
completedAt DateTime?
|
completedAt DateTime?
|
||||||
cancelledAt DateTime?
|
cancelledAt DateTime?
|
||||||
events WorkOrderEvent[]
|
events WorkOrderEvent[]
|
||||||
|
ragChunks RagChunk[]
|
||||||
checklist ChecklistItem[]
|
checklist ChecklistItem[]
|
||||||
report InterventionReport?
|
report InterventionReport?
|
||||||
request Request?
|
request Request?
|
||||||
@@ -513,7 +514,36 @@ model Document {
|
|||||||
uploadedById String? @db.Uuid
|
uploadedById String? @db.Uuid
|
||||||
uploadedBy User? @relation(fields: [uploadedById], references: [id])
|
uploadedBy User? @relation(fields: [uploadedById], references: [id])
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
// R5 (D3) : le corpus est visible et réversible, document par document.
|
||||||
|
inCorpus Boolean @default(true)
|
||||||
|
indexedAt DateTime? // null = jamais indexé
|
||||||
|
chunkCount Int @default(0)
|
||||||
|
chunks RagChunk[]
|
||||||
|
|
||||||
@@index([assetId])
|
@@index([assetId])
|
||||||
@@index([workOrderId])
|
@@index([workOrderId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// R5 — extraits indexés du corpus (ADR-004) : bibliothèque + bilans codés,
|
||||||
|
/// ANONYMISÉS À L'INGESTION (D4). Écrit par apps/ai, schéma possédé par Prisma.
|
||||||
|
model RagChunk {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
sourceType RagSourceType
|
||||||
|
documentId String? @db.Uuid
|
||||||
|
document Document? @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||||||
|
workOrderId String? @db.Uuid
|
||||||
|
workOrder WorkOrder? @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
|
||||||
|
/// Repère humain de la source : « p. 42 », « bilan du 17/07/2026 »…
|
||||||
|
locator String
|
||||||
|
content String
|
||||||
|
embedding Unsupported("vector(768)")
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([documentId])
|
||||||
|
@@index([workOrderId])
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RagSourceType {
|
||||||
|
DOCUMENT
|
||||||
|
WORK_ORDER
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { DynamicModule, Module } from '@nestjs/common';
|
|||||||
import { APP_GUARD } from '@nestjs/core';
|
import { APP_GUARD } from '@nestjs/core';
|
||||||
import { AnalyticsModule } from './analytics/analytics.module';
|
import { AnalyticsModule } from './analytics/analytics.module';
|
||||||
import { SearchModule } from './search/search.module';
|
import { SearchModule } from './search/search.module';
|
||||||
|
import { AssistantModule } from './assistant/assistant.module';
|
||||||
import { AssetsModule } from './assets/assets.module';
|
import { AssetsModule } from './assets/assets.module';
|
||||||
import { DocumentsModule } from './documents/documents.module';
|
import { DocumentsModule } from './documents/documents.module';
|
||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
@@ -63,6 +64,7 @@ export class AppModule {
|
|||||||
DocumentsModule,
|
DocumentsModule,
|
||||||
AnalyticsModule,
|
AnalyticsModule,
|
||||||
SearchModule,
|
SearchModule,
|
||||||
|
AssistantModule,
|
||||||
// ADR-002 : hors DEMO_MODE, le module n'est pas enregistré → 404
|
// ADR-002 : hors DEMO_MODE, le module n'est pas enregistré → 404
|
||||||
...(demoModeEnabled() ? [DemoAuthModule] : []),
|
...(demoModeEnabled() ? [DemoAuthModule] : []),
|
||||||
],
|
],
|
||||||
|
|||||||
39
apps/api/src/assistant/assistant.controller.ts
Normal file
39
apps/api/src/assistant/assistant.controller.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { Body, Controller, HttpCode, Post } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
AssistantAskSchema,
|
||||||
|
SuggestBilanSchema,
|
||||||
|
type AssistantAsk,
|
||||||
|
type SuggestBilan,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||||
|
import { RequirePermission } from '../permissions/require-permission.decorator';
|
||||||
|
import { AssistantService } from './assistant.service';
|
||||||
|
|
||||||
|
@Controller('assistant')
|
||||||
|
export class AssistantController {
|
||||||
|
constructor(private readonly assistant: AssistantService) {}
|
||||||
|
|
||||||
|
/** Poser une question — qui lit les OT peut interroger le corpus. */
|
||||||
|
@Post('ask')
|
||||||
|
@HttpCode(200)
|
||||||
|
@RequirePermission('WORK_ORDERS', 'view')
|
||||||
|
ask(@Body(new ZodValidationPipe(AssistantAskSchema)) body: AssistantAsk) {
|
||||||
|
return this.assistant.ask(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Réindexer le corpus — même droit que la gestion du référentiel (D3). */
|
||||||
|
@Post('reindex')
|
||||||
|
@HttpCode(200)
|
||||||
|
@RequirePermission('ASSETS', 'edit')
|
||||||
|
reindex() {
|
||||||
|
return this.assistant.reindex();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Suggérer des codes — réservé à qui remplit des bilans (D1). */
|
||||||
|
@Post('suggest-bilan')
|
||||||
|
@HttpCode(200)
|
||||||
|
@RequirePermission('WORK_ORDERS', 'edit')
|
||||||
|
suggest(@Body(new ZodValidationPipe(SuggestBilanSchema)) body: SuggestBilan) {
|
||||||
|
return this.assistant.suggestBilan(body);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/api/src/assistant/assistant.module.ts
Normal file
9
apps/api/src/assistant/assistant.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AssistantController } from './assistant.controller';
|
||||||
|
import { AssistantService } from './assistant.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [AssistantController],
|
||||||
|
providers: [AssistantService],
|
||||||
|
})
|
||||||
|
export class AssistantModule {}
|
||||||
123
apps/api/src/assistant/assistant.service.ts
Normal file
123
apps/api/src/assistant/assistant.service.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
|
||||||
|
import type {
|
||||||
|
AssistantAnswer,
|
||||||
|
AssistantAsk,
|
||||||
|
BilanField,
|
||||||
|
BilanSuggestionsResponse,
|
||||||
|
ReindexResult,
|
||||||
|
SuggestBilan,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import { loadEnv } from '../config/env';
|
||||||
|
|
||||||
|
/** Proxy vers `siop2-ai` (ADR-004 §4) : le service IA n'est JAMAIS public —
|
||||||
|
* l'API porte l'auth utilisateur (matrice) et le jeton de service interne.
|
||||||
|
* Il traduit aussi le dialecte interne (français, snake_case) vers le
|
||||||
|
* contrat (@siop/shared) — une seule vérité côté clients. */
|
||||||
|
|
||||||
|
interface ExtraitInterne {
|
||||||
|
source_type: 'DOCUMENT' | 'WORK_ORDER';
|
||||||
|
document_id: string | null;
|
||||||
|
work_order_id: string | null;
|
||||||
|
titre: string;
|
||||||
|
locator: string;
|
||||||
|
content: string;
|
||||||
|
score: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MODES = { extractif: 'EXTRACTIVE', genere: 'GENERATED', refus: 'REFUSAL' } as const;
|
||||||
|
const CONFIANCES = { FORTE: 'HIGH', MOYENNE: 'MEDIUM' } as const;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AssistantService {
|
||||||
|
private readonly journal = new Logger(AssistantService.name);
|
||||||
|
private readonly env = loadEnv();
|
||||||
|
|
||||||
|
private async appeler<T>(chemin: string, corps: unknown): Promise<T> {
|
||||||
|
let reponse: Response;
|
||||||
|
try {
|
||||||
|
reponse = await fetch(`${this.env.AI_SERVICE_URL}${chemin}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Service-Token': this.env.AI_SERVICE_TOKEN,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(corps),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
this.journal.warn(`Service IA injoignable (${chemin})`);
|
||||||
|
throw new ServiceUnavailableException(
|
||||||
|
'Assistant indisponible pour le moment — réessayez dans un instant.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!reponse.ok) {
|
||||||
|
this.journal.warn(`Service IA a refusé ${chemin} (${reponse.status})`);
|
||||||
|
throw new ServiceUnavailableException(
|
||||||
|
'Assistant indisponible pour le moment — réessayez dans un instant.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (await reponse.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async ask(dto: AssistantAsk): Promise<AssistantAnswer> {
|
||||||
|
const brut = await this.appeler<{
|
||||||
|
mode: keyof typeof MODES;
|
||||||
|
answer: string | null;
|
||||||
|
extraits: ExtraitInterne[];
|
||||||
|
corpus: { documents: number; bilans: number };
|
||||||
|
}>('/internal/ask', { question: dto.question });
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode: MODES[brut.mode],
|
||||||
|
answer: brut.answer,
|
||||||
|
excerpts: brut.extraits.map((e) => ({
|
||||||
|
sourceType: e.source_type,
|
||||||
|
documentId: e.document_id,
|
||||||
|
workOrderId: e.work_order_id,
|
||||||
|
title: e.titre,
|
||||||
|
locator: e.locator,
|
||||||
|
content: e.content,
|
||||||
|
score: e.score,
|
||||||
|
})),
|
||||||
|
corpus: { documents: brut.corpus.documents, reports: brut.corpus.bilans },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async reindex(): Promise<ReindexResult> {
|
||||||
|
const brut = await this.appeler<{
|
||||||
|
documents_indexes: number;
|
||||||
|
documents_ignores: number;
|
||||||
|
bilans_indexes: number;
|
||||||
|
extraits: number;
|
||||||
|
}>('/internal/reindex', {});
|
||||||
|
return {
|
||||||
|
documentsIndexed: brut.documents_indexes,
|
||||||
|
documentsSkipped: brut.documents_ignores,
|
||||||
|
reportsIndexed: brut.bilans_indexes,
|
||||||
|
chunks: brut.extraits,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async suggestBilan(dto: SuggestBilan): Promise<BilanSuggestionsResponse> {
|
||||||
|
const brut = await this.appeler<{
|
||||||
|
suggestions: {
|
||||||
|
field: BilanField;
|
||||||
|
value_id: string;
|
||||||
|
label: string;
|
||||||
|
confidence: keyof typeof CONFIANCES;
|
||||||
|
similar_reports: number;
|
||||||
|
score: number;
|
||||||
|
}[];
|
||||||
|
}>('/internal/suggest', { description: dto.description });
|
||||||
|
|
||||||
|
return {
|
||||||
|
suggestions: brut.suggestions.map((s) => ({
|
||||||
|
field: s.field,
|
||||||
|
valueId: s.value_id,
|
||||||
|
label: s.label,
|
||||||
|
confidence: CONFIANCES[s.confidence],
|
||||||
|
similarReports: s.similar_reports,
|
||||||
|
score: s.score,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,13 @@ const EnvSchema = z.object({
|
|||||||
MINIO_ACCESS_KEY: z.string().default('siop'),
|
MINIO_ACCESS_KEY: z.string().default('siop'),
|
||||||
MINIO_SECRET_KEY: z.string().default('siop-minio'),
|
MINIO_SECRET_KEY: z.string().default('siop-minio'),
|
||||||
MINIO_BUCKET: z.string().default('siop2'),
|
MINIO_BUCKET: z.string().default('siop2'),
|
||||||
|
// R4 : origines navigateur autorisées en CORS (Expo web / debug mobile).
|
||||||
|
// Vide = aucun CORS (défaut sûr) — le web de prod passe par le proxy nginx
|
||||||
|
// même-origine, les apps natives n'envoient pas d'Origin.
|
||||||
|
CORS_ORIGINS: z.string().default(''),
|
||||||
|
// R5 (ADR-004 §4) : le service IA interne — seul l'API le contacte.
|
||||||
|
AI_SERVICE_URL: z.string().default('http://localhost:8000'),
|
||||||
|
AI_SERVICE_TOKEN: z.string().default('dev-only-ai-token'),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Env = z.infer<typeof EnvSchema>;
|
export type Env = z.infer<typeof EnvSchema>;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
HttpCode,
|
HttpCode,
|
||||||
Param,
|
Param,
|
||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
StreamableFile,
|
StreamableFile,
|
||||||
@@ -14,11 +15,17 @@ import {
|
|||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { FileInterceptor } from '@nestjs/platform-express';
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
import { DOCUMENT_MAX_BYTES } from '@siop/shared';
|
import {
|
||||||
|
DOCUMENT_MAX_BYTES,
|
||||||
|
DocumentCorpusUpdateSchema,
|
||||||
|
type DocumentCorpusUpdate,
|
||||||
|
} from '@siop/shared';
|
||||||
import {
|
import {
|
||||||
AuthenticatedUser,
|
AuthenticatedUser,
|
||||||
CurrentUser,
|
CurrentUser,
|
||||||
} from '../auth/current-user.decorator';
|
} from '../auth/current-user.decorator';
|
||||||
|
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||||
|
import { RequirePermission } from '../permissions/require-permission.decorator';
|
||||||
import { DocumentsService } from './documents.service';
|
import { DocumentsService } from './documents.service';
|
||||||
|
|
||||||
@Controller('documents')
|
@Controller('documents')
|
||||||
@@ -69,6 +76,16 @@ export class DocumentsController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Corpus IA (D3) : réservé aux gestionnaires du référentiel. */
|
||||||
|
@Patch(':id/corpus')
|
||||||
|
@RequirePermission('ASSETS', 'edit')
|
||||||
|
setCorpus(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body(new ZodValidationPipe(DocumentCorpusUpdateSchema)) body: DocumentCorpusUpdate,
|
||||||
|
) {
|
||||||
|
return this.documents.setCorpus(id, body.inCorpus);
|
||||||
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@HttpCode(204)
|
@HttpCode(204)
|
||||||
remove(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: AuthenticatedUser) {
|
remove(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: AuthenticatedUser) {
|
||||||
|
|||||||
@@ -155,6 +155,21 @@ export class DocumentsService {
|
|||||||
workOrderReference: row.workOrder?.reference ?? null,
|
workOrderReference: row.workOrder?.reference ?? null,
|
||||||
uploadedByName: row.uploadedBy?.displayName ?? null,
|
uploadedByName: row.uploadedBy?.displayName ?? null,
|
||||||
createdAt: row.createdAt.toISOString(),
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
inCorpus: row.inCorpus,
|
||||||
|
indexedAt: row.indexedAt?.toISOString() ?? null,
|
||||||
|
chunkCount: row.chunkCount,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Interrupteur corpus (D3) — effectif à la prochaine réindexation. */
|
||||||
|
async setCorpus(id: string, inCorpus: boolean): Promise<DocumentDto> {
|
||||||
|
const doc = await this.prisma.document.findUnique({ where: { id } });
|
||||||
|
if (!doc) throw new NotFoundException('Document inconnu');
|
||||||
|
const updated = await this.prisma.document.update({
|
||||||
|
where: { id },
|
||||||
|
data: { inCorpus },
|
||||||
|
include: documentInclude,
|
||||||
|
});
|
||||||
|
return this.toDto(updated);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ async function bootstrap() {
|
|||||||
assertDemoModeAllowed(); // ADR-002 — double verrou avant toute écoute réseau
|
assertDemoModeAllowed(); // ADR-002 — double verrou avant toute écoute réseau
|
||||||
|
|
||||||
const app = await NestFactory.create(AppModule.forRoot());
|
const app = await NestFactory.create(AppModule.forRoot());
|
||||||
|
const origines = env.CORS_ORIGINS.split(',').map((o) => o.trim()).filter(Boolean);
|
||||||
|
if (origines.length) app.enableCors({ origin: origines });
|
||||||
app.enableShutdownHooks();
|
app.enableShutdownHooks();
|
||||||
await app.listen(env.PORT);
|
await app.listen(env.PORT);
|
||||||
new Logger('Bootstrap').log(`API SIOP V2 démarrée sur :${env.PORT}`);
|
new Logger('Bootstrap').log(`API SIOP V2 démarrée sur :${env.PORT}`);
|
||||||
|
|||||||
@@ -199,6 +199,7 @@ export class WorkOrdersService {
|
|||||||
user: AuthenticatedUser,
|
user: AuthenticatedUser,
|
||||||
): Promise<WorkOrderDetail> {
|
): Promise<WorkOrderDetail> {
|
||||||
const detail = await this.get(id, user);
|
const detail = await this.get(id, user);
|
||||||
|
await this.assertVersion(id, dto.baseUpdatedAt);
|
||||||
if (!WORK_ORDER_TRANSITIONS[detail.status].includes(dto.to)) {
|
if (!WORK_ORDER_TRANSITIONS[detail.status].includes(dto.to)) {
|
||||||
throw new ConflictException(
|
throw new ConflictException(
|
||||||
`Transition interdite : ${WORK_ORDER_STATUS_LABELS[detail.status]} → ${WORK_ORDER_STATUS_LABELS[dto.to]}`,
|
`Transition interdite : ${WORK_ORDER_STATUS_LABELS[detail.status]} → ${WORK_ORDER_STATUS_LABELS[dto.to]}`,
|
||||||
@@ -245,6 +246,7 @@ export class WorkOrdersService {
|
|||||||
await this.prisma.workOrderEvent.create({
|
await this.prisma.workOrderEvent.create({
|
||||||
data: { workOrderId: id, kind: 'COMMENT', message, byId: user.userId },
|
data: { workOrderId: id, kind: 'COMMENT', message, byId: user.userId },
|
||||||
});
|
});
|
||||||
|
await this.toucher(id);
|
||||||
return this.get(id, user);
|
return this.get(id, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,6 +286,7 @@ export class WorkOrdersService {
|
|||||||
user: AuthenticatedUser,
|
user: AuthenticatedUser,
|
||||||
): Promise<WorkOrderDetail> {
|
): Promise<WorkOrderDetail> {
|
||||||
await this.get(id, user);
|
await this.get(id, user);
|
||||||
|
await this.assertVersion(id, dto.baseUpdatedAt);
|
||||||
// Chaque valeur fournie doit appartenir au référentiel de SON champ (et être active)
|
// Chaque valeur fournie doit appartenir au référentiel de SON champ (et être active)
|
||||||
for (const [colonne, field] of Object.entries(REPORT_FIELDS)) {
|
for (const [colonne, field] of Object.entries(REPORT_FIELDS)) {
|
||||||
const valeur = dto[colonne as keyof ReportUpsert];
|
const valeur = dto[colonne as keyof ReportUpsert];
|
||||||
@@ -310,6 +313,7 @@ export class WorkOrdersService {
|
|||||||
update: donnees,
|
update: donnees,
|
||||||
create: { workOrderId: id, ...donnees },
|
create: { workOrderId: id, ...donnees },
|
||||||
});
|
});
|
||||||
|
await this.toucher(id);
|
||||||
return this.get(id, user);
|
return this.get(id, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,6 +324,7 @@ export class WorkOrdersService {
|
|||||||
user: AuthenticatedUser,
|
user: AuthenticatedUser,
|
||||||
): Promise<ChecklistItemDto> {
|
): Promise<ChecklistItemDto> {
|
||||||
await this.get(id, user);
|
await this.get(id, user);
|
||||||
|
await this.assertVersion(id, dto.baseUpdatedAt);
|
||||||
const { count } = await this.prisma.checklistItem.updateMany({
|
const { count } = await this.prisma.checklistItem.updateMany({
|
||||||
where: { id: itemId, workOrderId: id },
|
where: { id: itemId, workOrderId: id },
|
||||||
data: {
|
data: {
|
||||||
@@ -329,6 +334,7 @@ export class WorkOrdersService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (count === 0) throw new NotFoundException('Tâche inconnue');
|
if (count === 0) throw new NotFoundException('Tâche inconnue');
|
||||||
|
await this.toucher(id);
|
||||||
const item = await this.prisma.checklistItem.findUniqueOrThrow({
|
const item = await this.prisma.checklistItem.findUniqueOrThrow({
|
||||||
where: { id: itemId },
|
where: { id: itemId },
|
||||||
include: { doneBy: true },
|
include: { doneBy: true },
|
||||||
@@ -368,6 +374,7 @@ export class WorkOrdersService {
|
|||||||
byId: user.userId,
|
byId: user.userId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
await this.toucher(id);
|
||||||
return this.get(id, user);
|
return this.get(id, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -398,6 +405,7 @@ export class WorkOrdersService {
|
|||||||
note: dto.note,
|
note: dto.note,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
await this.toucher(id);
|
||||||
return this.get(id, user);
|
return this.get(id, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -522,6 +530,43 @@ export class WorkOrdersService {
|
|||||||
},
|
},
|
||||||
allowedTransitions: WORK_ORDER_TRANSITIONS[row.status as WorkOrderStatus],
|
allowedTransitions: WORK_ORDER_TRANSITIONS[row.status as WorkOrderStatus],
|
||||||
closureBlockers: blockers,
|
closureBlockers: blockers,
|
||||||
|
updatedAt: row.updatedAt.toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Verrou optimiste (D2 mobile) : si l'appelant fournit la version lue et
|
||||||
|
* que l'OT a changé depuis, 409 avec le contexte (qui, quand) — rien
|
||||||
|
* n'est écrasé en silence. Sans version fournie (web), comportement
|
||||||
|
* inchangé. */
|
||||||
|
private async assertVersion(id: string, baseUpdatedAt?: string): Promise<void> {
|
||||||
|
if (!baseUpdatedAt) return;
|
||||||
|
const wo = await this.prisma.workOrder.findUniqueOrThrow({
|
||||||
|
where: { id },
|
||||||
|
select: {
|
||||||
|
updatedAt: true,
|
||||||
|
events: {
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 1,
|
||||||
|
include: { by: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (wo.updatedAt.getTime() > new Date(baseUpdatedAt).getTime()) {
|
||||||
|
const dernier = wo.events[0];
|
||||||
|
const qui = dernier?.by?.displayName ?? 'quelqu’un';
|
||||||
|
const quand = new Intl.DateTimeFormat('fr-FR', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}).format(wo.updatedAt);
|
||||||
|
throw new ConflictException(
|
||||||
|
`Conflit de version : cet OT a été modifié par ${qui} à ${quand} — votre saisie n'a pas été appliquée.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toute écriture « secondaire » (coche, bilan, commentaire, coûts) fait
|
||||||
|
* avancer la version de l'OT — sinon le verrou D2 ne verrait rien. */
|
||||||
|
private async toucher(id: string): Promise<void> {
|
||||||
|
await this.prisma.workOrder.update({ where: { id }, data: { updatedAt: new Date() } });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
186
apps/api/test/assistant.e2e-spec.ts
Normal file
186
apps/api/test/assistant.e2e-spec.ts
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
/**
|
||||||
|
* E2E R5.2 — assistant au contrat : le service IA reste interne, l'API porte
|
||||||
|
* l'auth et la matrice ; le dialecte interne est traduit vers @siop/shared.
|
||||||
|
* Le service IA est joué par un STUB HTTP local (la vraie chaîne se vérifie
|
||||||
|
* en recette réelle — convention R5.1).
|
||||||
|
*/
|
||||||
|
process.env.DEMO_MODE = 'true';
|
||||||
|
|
||||||
|
import { createServer, type Server } from 'node:http';
|
||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { AppModule } from '../src/app.module';
|
||||||
|
|
||||||
|
const REPONSE_ASK = {
|
||||||
|
mode: 'extractif',
|
||||||
|
answer: null,
|
||||||
|
extraits: [
|
||||||
|
{
|
||||||
|
source_type: 'DOCUMENT',
|
||||||
|
document_id: '7d7bfa5c-2f43-4f9e-9e59-3c1f0a5df001',
|
||||||
|
work_order_id: null,
|
||||||
|
titre: 'Notice Gen2.pdf',
|
||||||
|
locator: 'p. 42',
|
||||||
|
content: 'Serrer les coulisseaux au couple de 25 N·m.',
|
||||||
|
score: 0.61,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
corpus: { documents: 6, bilans: 214 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const REPONSE_SUGGEST = {
|
||||||
|
suggestions: [
|
||||||
|
{
|
||||||
|
field: 'ANOMALY',
|
||||||
|
value_id: '7d7bfa5c-2f43-4f9e-9e59-3c1f0a5df002',
|
||||||
|
label: 'Cellule/barrière encrassée',
|
||||||
|
confidence: 'FORTE',
|
||||||
|
similar_reports: 9,
|
||||||
|
score: 0.62,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const REPONSE_REINDEX = {
|
||||||
|
documents_indexes: 6,
|
||||||
|
documents_ignores: 2,
|
||||||
|
bilans_indexes: 214,
|
||||||
|
extraits: 180,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Assistant (e2e — stub du service IA)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let stub: Server;
|
||||||
|
let ahmed: string; // Technicien : view + edit sur WORK_ORDERS, ASSETS en lecture
|
||||||
|
let karim: string; // Demandeur : aucun droit WORK_ORDERS
|
||||||
|
let rachid: string; // Vue seule : view sans edit
|
||||||
|
let nadia: string; // Gestionnaire : ASSETS.edit — administre le corpus
|
||||||
|
const requetesRecues: { url: string; jeton: string | undefined }[] = [];
|
||||||
|
const http = () => request(app.getHttpServer());
|
||||||
|
const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
// Stub du service IA sur un port éphémère
|
||||||
|
stub = createServer((req, res) => {
|
||||||
|
requetesRecues.push({
|
||||||
|
url: req.url ?? '',
|
||||||
|
jeton: req.headers['x-service-token'] as string | undefined,
|
||||||
|
});
|
||||||
|
res.setHeader('Content-Type', 'application/json');
|
||||||
|
if (req.url === '/internal/ask') res.end(JSON.stringify(REPONSE_ASK));
|
||||||
|
else if (req.url === '/internal/suggest') res.end(JSON.stringify(REPONSE_SUGGEST));
|
||||||
|
else if (req.url === '/internal/reindex') res.end(JSON.stringify(REPONSE_REINDEX));
|
||||||
|
else {
|
||||||
|
res.statusCode = 404;
|
||||||
|
res.end('{}');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await new Promise<void>((resolve) => stub.listen(0, '127.0.0.1', resolve));
|
||||||
|
const adresse = stub.address();
|
||||||
|
const port = typeof adresse === 'object' && adresse ? adresse.port : 0;
|
||||||
|
process.env.AI_SERVICE_URL = `http://127.0.0.1:${port}`;
|
||||||
|
process.env.AI_SERVICE_TOKEN = 'jeton-de-test';
|
||||||
|
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
imports: [AppModule.forRoot()],
|
||||||
|
}).compile();
|
||||||
|
app = moduleRef.createNestApplication();
|
||||||
|
await app.init();
|
||||||
|
const { body } = await http().get('/auth/demo-accounts');
|
||||||
|
const login = async (roleName: string) => {
|
||||||
|
const compte = body.accounts.find((a: { roleName: string }) => a.roleName === roleName);
|
||||||
|
return (await http().post('/auth/demo-login').send({ userId: compte.id })).body
|
||||||
|
.accessToken as string;
|
||||||
|
};
|
||||||
|
ahmed = await login('Technicien');
|
||||||
|
karim = await login('Demandeur');
|
||||||
|
rachid = await login('Vue seule');
|
||||||
|
nadia = await login('Gestionnaire');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app?.close();
|
||||||
|
await new Promise<void>((resolve) => stub.close(() => resolve()));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ask : traduit le dialecte interne vers le contrat, avec le jeton de service', async () => {
|
||||||
|
const res = await http()
|
||||||
|
.post('/assistant/ask')
|
||||||
|
.set(auth(ahmed))
|
||||||
|
.send({ question: 'quel couple de serrage pour les guides ?' })
|
||||||
|
.expect(200);
|
||||||
|
expect(res.body.mode).toBe('EXTRACTIVE');
|
||||||
|
expect(res.body.excerpts[0]).toMatchObject({
|
||||||
|
sourceType: 'DOCUMENT',
|
||||||
|
title: 'Notice Gen2.pdf',
|
||||||
|
locator: 'p. 42',
|
||||||
|
});
|
||||||
|
expect(res.body.corpus).toEqual({ documents: 6, reports: 214 });
|
||||||
|
const derniere = requetesRecues.at(-1)!;
|
||||||
|
expect(derniere.url).toBe('/internal/ask');
|
||||||
|
expect(derniere.jeton).toBe('jeton-de-test'); // ADR-004 §4
|
||||||
|
});
|
||||||
|
|
||||||
|
it('suggest-bilan : codes existants traduits (FORTE → HIGH, snake → camel)', async () => {
|
||||||
|
const res = await http()
|
||||||
|
.post('/assistant/suggest-bilan')
|
||||||
|
.set(auth(ahmed))
|
||||||
|
.send({ description: 'porte cabine qui rebondit, cellule encrassée, nettoyage fait' })
|
||||||
|
.expect(200);
|
||||||
|
expect(res.body.suggestions[0]).toMatchObject({
|
||||||
|
field: 'ANOMALY',
|
||||||
|
confidence: 'HIGH',
|
||||||
|
similarReports: 9,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('la matrice s’applique : demandeur sans WORK_ORDERS → 403 sur ask', async () => {
|
||||||
|
await http()
|
||||||
|
.post('/assistant/ask')
|
||||||
|
.set(auth(karim))
|
||||||
|
.send({ question: 'où sont les notices ?' })
|
||||||
|
.expect(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('vue seule : ask autorisé (view), suggest refusé (edit requis — D1)', async () => {
|
||||||
|
await http()
|
||||||
|
.post('/assistant/ask')
|
||||||
|
.set(auth(rachid))
|
||||||
|
.send({ question: 'historique du parc ?' })
|
||||||
|
.expect(200);
|
||||||
|
await http()
|
||||||
|
.post('/assistant/suggest-bilan')
|
||||||
|
.set(auth(rachid))
|
||||||
|
.send({ description: 'une description suffisamment longue ici' })
|
||||||
|
.expect(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reindex : traduit le bilan d’indexation, réservé à ASSETS.edit (D3)', async () => {
|
||||||
|
const res = await http().post('/assistant/reindex').set(auth(nadia)).expect(200);
|
||||||
|
expect(res.body).toEqual({
|
||||||
|
documentsIndexed: 6,
|
||||||
|
documentsSkipped: 2,
|
||||||
|
reportsIndexed: 214,
|
||||||
|
chunks: 180,
|
||||||
|
});
|
||||||
|
expect(requetesRecues.at(-1)!.url).toBe('/internal/reindex');
|
||||||
|
// Ahmed (Technicien) lit le parc mais n'administre pas le corpus
|
||||||
|
await http().post('/assistant/reindex').set(auth(ahmed)).expect(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('question trop courte : 400 avant tout appel au service IA', async () => {
|
||||||
|
const avant = requetesRecues.length;
|
||||||
|
await http().post('/assistant/ask').set(auth(ahmed)).send({ question: 'ab' }).expect(400);
|
||||||
|
expect(requetesRecues.length).toBe(avant);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('service IA éteint : 503 propre, jamais un 500', async () => {
|
||||||
|
await new Promise<void>((resolve) => stub.close(() => resolve()));
|
||||||
|
await http()
|
||||||
|
.post('/assistant/ask')
|
||||||
|
.set(auth(ahmed))
|
||||||
|
.send({ question: 'le service est-il là ?' })
|
||||||
|
.expect(503);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -87,6 +87,36 @@ describe('Bibliothèque & analytics (e2e)', () => {
|
|||||||
await http().get(`/documents/${envoye.body.id}/download`).set(auth(nadia)).expect(404);
|
await http().get(`/documents/${envoye.body.id}/download`).set(auth(nadia)).expect(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('corpus (R5, D3) : nouveau document inclus par défaut, bascule réversible et gardée', async () => {
|
||||||
|
const { body: assets } = await http().get('/assets').set(auth(nadia));
|
||||||
|
const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A1');
|
||||||
|
const envoye = await http()
|
||||||
|
.post('/documents')
|
||||||
|
.set(auth(nadia))
|
||||||
|
.field('kind', 'NOTICE')
|
||||||
|
.field('assetId', a1.id)
|
||||||
|
.attach('file', PNG_1PX, { filename: `corpus-${suffix}.png`, contentType: 'image/png' })
|
||||||
|
.expect(201);
|
||||||
|
// le contrat expose l'état d'indexation — jamais indexé à la naissance
|
||||||
|
expect(envoye.body).toMatchObject({ inCorpus: true, indexedAt: null, chunkCount: 0 });
|
||||||
|
|
||||||
|
const exclu = await http()
|
||||||
|
.patch(`/documents/${envoye.body.id}/corpus`)
|
||||||
|
.set(auth(nadia))
|
||||||
|
.send({ inCorpus: false })
|
||||||
|
.expect(200);
|
||||||
|
expect(exclu.body.inCorpus).toBe(false);
|
||||||
|
|
||||||
|
// Karim (Demandeur) n'administre pas le corpus
|
||||||
|
await http()
|
||||||
|
.patch(`/documents/${envoye.body.id}/corpus`)
|
||||||
|
.set(auth(karim))
|
||||||
|
.send({ inCorpus: true })
|
||||||
|
.expect(403);
|
||||||
|
|
||||||
|
await http().delete(`/documents/${envoye.body.id}`).set(auth(nadia)).expect(204);
|
||||||
|
});
|
||||||
|
|
||||||
it('refus typés : format, rattachement manquant, cible inconnue, permission', async () => {
|
it('refus typés : format, rattachement manquant, cible inconnue, permission', async () => {
|
||||||
const { body: assets } = await http().get('/assets').set(auth(nadia));
|
const { body: assets } = await http().get('/assets').set(auth(nadia));
|
||||||
const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A1');
|
const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A1');
|
||||||
|
|||||||
@@ -139,3 +139,109 @@ describe('Recherche globale & corrections de recette R3 (e2e)', () => {
|
|||||||
.expect(400);
|
.expect(400);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Verrou optimiste D2 (e2e) — la version protège les saisies mobiles', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let salma: string;
|
||||||
|
let ahmed: string;
|
||||||
|
let otId: string;
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
const http = () => request(app.getHttpServer());
|
||||||
|
const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
|
||||||
|
const suffix = `verrou-${Date.now().toString(36)}`;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
imports: [AppModule.forRoot()],
|
||||||
|
}).compile();
|
||||||
|
app = moduleRef.createNestApplication();
|
||||||
|
await app.init();
|
||||||
|
const { body } = await http().get('/auth/demo-accounts');
|
||||||
|
const login = async (roleName: string) => {
|
||||||
|
const compte = body.accounts.find((a: { roleName: string }) => a.roleName === roleName);
|
||||||
|
return (await http().post('/auth/demo-login').send({ userId: compte.id })).body
|
||||||
|
.accessToken as string;
|
||||||
|
};
|
||||||
|
salma = await login('Dispatcher');
|
||||||
|
ahmed = await login('Technicien');
|
||||||
|
const { body: assets } = await http().get('/assets').set(auth(salma));
|
||||||
|
const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A1');
|
||||||
|
const technicien = body.accounts.find((a: { roleName: string }) => a.roleName === 'Technicien');
|
||||||
|
const cree = await http()
|
||||||
|
.post('/work-orders')
|
||||||
|
.set(auth(salma))
|
||||||
|
.send({
|
||||||
|
title: `Verrou E2E ${suffix}`,
|
||||||
|
type: 'CORRECTIVE',
|
||||||
|
assetId: a1.id,
|
||||||
|
assigneeIds: [technicien.id],
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
otId = cree.body.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.workOrder.deleteMany({ where: { title: { contains: suffix } } });
|
||||||
|
await app?.close();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('version à jour : la transition passe ; le détail expose updatedAt', async () => {
|
||||||
|
const detail = await http().get(`/work-orders/${otId}`).set(auth(ahmed)).expect(200);
|
||||||
|
expect(detail.body.updatedAt).toBeTruthy();
|
||||||
|
await http()
|
||||||
|
.post(`/work-orders/${otId}/transition`)
|
||||||
|
.set(auth(ahmed))
|
||||||
|
.send({ to: 'IN_PROGRESS', baseUpdatedAt: detail.body.updatedAt })
|
||||||
|
.expect(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('OT modifié entre-temps (commentaire) : 409 « Conflit de version » contextualisé', async () => {
|
||||||
|
const detail = await http().get(`/work-orders/${otId}`).set(auth(ahmed)).expect(200);
|
||||||
|
const versionLue = detail.body.updatedAt as string;
|
||||||
|
// Salma commente pendant qu'Ahmed est « hors-ligne » — la version avance
|
||||||
|
await http()
|
||||||
|
.post(`/work-orders/${otId}/comments`)
|
||||||
|
.set(auth(salma))
|
||||||
|
.send({ message: 'Réassigné après appel du syndic' })
|
||||||
|
.expect(201);
|
||||||
|
const refus = await http()
|
||||||
|
.post(`/work-orders/${otId}/transition`)
|
||||||
|
.set(auth(ahmed))
|
||||||
|
.send({ to: 'ON_HOLD', baseUpdatedAt: versionLue })
|
||||||
|
.expect(409);
|
||||||
|
expect(refus.body.message).toContain('Conflit de version');
|
||||||
|
expect(refus.body.message).toContain('Salma');
|
||||||
|
// L'OT n'a PAS bougé — rien d'écrasé en silence
|
||||||
|
const apres = await http().get(`/work-orders/${otId}`).set(auth(ahmed)).expect(200);
|
||||||
|
expect(apres.body.status).toBe('IN_PROGRESS');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('le bilan et la coche font avancer la version (sinon le verrou est aveugle)', async () => {
|
||||||
|
const avant = (await http().get(`/work-orders/${otId}`).set(auth(ahmed))).body.updatedAt;
|
||||||
|
const { body: refs } = await http().get('/reference-values').set(auth(ahmed));
|
||||||
|
const porte = refs.referenceValues.find(
|
||||||
|
(v: { field: string; isActive: boolean }) => v.field === 'DOOR_STATE' && v.isActive,
|
||||||
|
);
|
||||||
|
await http()
|
||||||
|
.put(`/work-orders/${otId}/report`)
|
||||||
|
.set(auth(ahmed))
|
||||||
|
.send({ doorStateId: porte.id })
|
||||||
|
.expect(200);
|
||||||
|
const apres = (await http().get(`/work-orders/${otId}`).set(auth(ahmed))).body.updatedAt;
|
||||||
|
expect(new Date(apres).getTime()).toBeGreaterThan(new Date(avant).getTime());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sans baseUpdatedAt (web) : comportement inchangé, même après modification', async () => {
|
||||||
|
await http()
|
||||||
|
.post(`/work-orders/${otId}/comments`)
|
||||||
|
.set(auth(salma))
|
||||||
|
.send({ message: 'Nouvelle note' })
|
||||||
|
.expect(201);
|
||||||
|
await http()
|
||||||
|
.post(`/work-orders/${otId}/transition`)
|
||||||
|
.set(auth(ahmed))
|
||||||
|
.send({ to: 'ON_HOLD' })
|
||||||
|
.expect(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
5
apps/mobile/.claude/settings.json
Normal file
5
apps/mobile/.claude/settings.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"enabledPlugins": {
|
||||||
|
"expo@claude-plugins-official": true
|
||||||
|
}
|
||||||
|
}
|
||||||
41
apps/mobile/.gitignore
vendored
Normal file
41
apps/mobile/.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Expo
|
||||||
|
.expo/
|
||||||
|
dist/
|
||||||
|
web-build/
|
||||||
|
expo-env.d.ts
|
||||||
|
|
||||||
|
# Native
|
||||||
|
.kotlin/
|
||||||
|
*.orig.*
|
||||||
|
*.jks
|
||||||
|
*.p8
|
||||||
|
*.p12
|
||||||
|
*.key
|
||||||
|
*.mobileprovision
|
||||||
|
|
||||||
|
# Metro
|
||||||
|
.metro-health-check*
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.*
|
||||||
|
yarn-debug.*
|
||||||
|
yarn-error.*
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env*.local
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# generated native folders
|
||||||
|
/ios
|
||||||
|
/android
|
||||||
3
apps/mobile/AGENTS.md
Normal file
3
apps/mobile/AGENTS.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Expo HAS CHANGED
|
||||||
|
|
||||||
|
Read the exact versioned docs at https://docs.expo.dev/versions/v57.0.0/ before writing any code.
|
||||||
33
apps/mobile/README.md
Normal file
33
apps/mobile/README.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# @siop/mobile — l'app du technicien (R4)
|
||||||
|
|
||||||
|
Expo (SDK 57), offline-first, périmètre fermé : **Ma journée, scan, préventif, synchro** —
|
||||||
|
la gestion reste web, le demandeur reste sur le portail QR public. Maquettes validées :
|
||||||
|
`docs/02-design/maquettes/maquette-r4.html` (+ décisions D1-D5 au journal du 17/07/2026).
|
||||||
|
|
||||||
|
## Lancer
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# API locale d'abord (apps/api : pnpm dev), puis :
|
||||||
|
pnpm --filter @siop/mobile start # QR Expo Go (téléphone sur le MÊME réseau)
|
||||||
|
EXPO_PUBLIC_API_URL=http://192.168.x.y:3000 pnpm --filter @siop/mobile start
|
||||||
|
```
|
||||||
|
|
||||||
|
- `EXPO_PUBLIC_API_URL` : URL de l'API vue **depuis le téléphone** (IP LAN, pas localhost).
|
||||||
|
Défaut : `http://localhost:3000` (suffisant pour `expo start --web`).
|
||||||
|
- Vérification navigateur : `expo start --web` + `CORS_ORIGINS=http://localhost:8081`
|
||||||
|
côté API (les apps natives n'envoient pas d'Origin — CORS ne concerne que le web).
|
||||||
|
|
||||||
|
## Ce que porte R4.1 (socle)
|
||||||
|
|
||||||
|
- Connexion e-mail/mot de passe + **sélecteur démo** (ADR-002 : n'existe que si l'API l'expose).
|
||||||
|
- Coquille tabbar (onglets à venir marqués R4.2/R4.3) + **Ma journée** : OT triés
|
||||||
|
priorité puis échéance (`src/lib/journee.ts`, testé), urgence en tête, pastille de synchro.
|
||||||
|
- **Lecture hors-ligne (D1)** : cache TanStack persisté dans AsyncStorage (7 jours),
|
||||||
|
NetInfo pilote `onlineManager` + bandeau. L'écriture en file arrive en R4.3.
|
||||||
|
- Client typé généré depuis `docs/openapi.json` (`pnpm generate:client`) — règle d'or ADR-001.
|
||||||
|
- Jeton dans SecureStore (trousseau) ; AsyncStorage en repli web de dev.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
`pnpm --filter @siop/mobile test` (jest-expo — logique pure : tri, tokens) ;
|
||||||
|
`typecheck` et le lint racine s'appliquent (job CI `mobile`).
|
||||||
31
apps/mobile/app.json
Normal file
31
apps/mobile/app.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"name": "SIOP",
|
||||||
|
"slug": "siop2-mobile",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"icon": "./assets/icon.png",
|
||||||
|
"userInterfaceStyle": "automatic",
|
||||||
|
"ios": {
|
||||||
|
"supportsTablet": true
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"adaptiveIcon": {
|
||||||
|
"backgroundColor": "#E6F4FE",
|
||||||
|
"foregroundImage": "./assets/android-icon-foreground.png",
|
||||||
|
"backgroundImage": "./assets/android-icon-background.png",
|
||||||
|
"monochromeImage": "./assets/android-icon-monochrome.png"
|
||||||
|
},
|
||||||
|
"predictiveBackGestureEnabled": false
|
||||||
|
},
|
||||||
|
"web": {
|
||||||
|
"favicon": "./assets/favicon.png"
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
"expo-router",
|
||||||
|
"expo-font",
|
||||||
|
"expo-secure-store"
|
||||||
|
],
|
||||||
|
"scheme": "siop"
|
||||||
|
}
|
||||||
|
}
|
||||||
71
apps/mobile/app/(tabs)/_layout.tsx
Normal file
71
apps/mobile/app/(tabs)/_layout.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { Tabs } from 'expo-router';
|
||||||
|
import { Text, type ColorValue } from 'react-native';
|
||||||
|
import { useAssets, useReferenceValues } from '@/api/exploitation';
|
||||||
|
import { useFile } from '@/file/store';
|
||||||
|
import { useTokens } from '@/theme/tokens';
|
||||||
|
|
||||||
|
/** La tabbar de la maquette R4. Les onglets à venir restent visibles
|
||||||
|
* (périmètre annoncé, même patron que la sidebar web) mais mènent à un
|
||||||
|
* écran « disponible en R4.x ». */
|
||||||
|
|
||||||
|
function Pic({ glyphe, couleur }: { glyphe: string; couleur: ColorValue }) {
|
||||||
|
return <Text style={{ fontSize: 17, color: couleur, lineHeight: 20 }}>{glyphe}</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CoquilleTabs() {
|
||||||
|
const t = useTokens();
|
||||||
|
const file = useFile();
|
||||||
|
const conflits = file.some((s) => s.statut === 'CONFLIT');
|
||||||
|
// D1 « lecture locale » : le parc (scan D4) et les référentiels du bilan
|
||||||
|
// se préchargent dès l'entrée — le sous-sol n'attend pas qu'on y pense.
|
||||||
|
useAssets();
|
||||||
|
useReferenceValues();
|
||||||
|
return (
|
||||||
|
<Tabs
|
||||||
|
screenOptions={{
|
||||||
|
headerShown: false,
|
||||||
|
tabBarActiveTintColor: t.primaire,
|
||||||
|
tabBarInactiveTintColor: t.encre3,
|
||||||
|
tabBarStyle: { backgroundColor: t.surface, borderTopColor: t.bordure },
|
||||||
|
tabBarLabelStyle: { fontFamily: 'Manrope_700Bold', fontSize: 10 },
|
||||||
|
sceneStyle: { backgroundColor: t.fond },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="journee"
|
||||||
|
options={{
|
||||||
|
title: 'Ma journée',
|
||||||
|
tabBarIcon: ({ color }) => <Pic glyphe="☰" couleur={color} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="scanner"
|
||||||
|
options={{
|
||||||
|
title: 'Scanner',
|
||||||
|
tabBarIcon: ({ color }) => <Pic glyphe="▣" couleur={color} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="preventif"
|
||||||
|
options={{
|
||||||
|
title: 'Préventif',
|
||||||
|
tabBarIcon: ({ color }) => <Pic glyphe="✓" couleur={color} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="synchro"
|
||||||
|
options={{
|
||||||
|
title: 'Synchro',
|
||||||
|
tabBarIcon: ({ color }) => <Pic glyphe="⇅" couleur={color} />,
|
||||||
|
tabBarBadge: file.length || undefined,
|
||||||
|
tabBarBadgeStyle: {
|
||||||
|
backgroundColor: conflits ? t.prioBloque : t.stAttente,
|
||||||
|
color: '#fff',
|
||||||
|
fontFamily: 'Manrope_800ExtraBold',
|
||||||
|
fontSize: 10,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
242
apps/mobile/app/(tabs)/journee.tsx
Normal file
242
apps/mobile/app/(tabs)/journee.tsx
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { router } from 'expo-router';
|
||||||
|
import { FlatList, Pressable, RefreshControl, Text, View } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import {
|
||||||
|
WORK_ORDER_PRIORITY_LABELS,
|
||||||
|
WORK_ORDER_STATUS_LABELS,
|
||||||
|
WORK_ORDER_TYPE_LABELS,
|
||||||
|
type WorkOrderPriority,
|
||||||
|
type WorkOrderStatus,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import { api, unwrap } from '@/api/client';
|
||||||
|
import { useHorsLigne, useLogout, useMe } from '@/auth/session';
|
||||||
|
import { triJournee } from '@/lib/journee';
|
||||||
|
import { useTokens, type Tokens } from '@/theme/tokens';
|
||||||
|
|
||||||
|
/** Écran 1 de la maquette R4 : les OT du technicien, priorité puis
|
||||||
|
* échéance, « personne bloquée » en tête. Hors-ligne : le cache persisté
|
||||||
|
* sert la liste, le bandeau l'assume (D1). */
|
||||||
|
|
||||||
|
function useWorkOrders() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['work-orders'],
|
||||||
|
queryFn: async () => (await unwrap(await api.GET('/work-orders'))).workOrders,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const STYLE_STATUT: Record<WorkOrderStatus, (t: Tokens) => [string, string]> = {
|
||||||
|
OPEN: (t) => [t.stOuvert, t.stOuvertFond],
|
||||||
|
IN_PROGRESS: (t) => [t.stEncours, t.stEncoursFond],
|
||||||
|
ON_HOLD: (t) => [t.stAttente, t.stAttenteFond],
|
||||||
|
DONE: (t) => [t.stTermine, t.stTermineFond],
|
||||||
|
CANCELLED: (t) => [t.stAnnule, t.stAnnuleFond],
|
||||||
|
};
|
||||||
|
const STRIE_PRIORITE: Record<WorkOrderPriority, (t: Tokens) => string> = {
|
||||||
|
PERSON_TRAPPED: (t) => t.prioBloque,
|
||||||
|
HIGH: (t) => t.prioHaute,
|
||||||
|
MEDIUM: (t) => t.prioMoyenne,
|
||||||
|
LOW: (t) => t.prioBasse,
|
||||||
|
NONE: () => 'transparent',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PageJournee() {
|
||||||
|
const t = useTokens();
|
||||||
|
const horsLigne = useHorsLigne();
|
||||||
|
const { data: me } = useMe();
|
||||||
|
const { data: workOrders, refetch, isFetching, dataUpdatedAt } = useWorkOrders();
|
||||||
|
const logout = useLogout();
|
||||||
|
|
||||||
|
const ots = triJournee(workOrders ?? []);
|
||||||
|
const urgences = ots.filter((o) => o.priority === 'PERSON_TRAPPED');
|
||||||
|
const jour = new Intl.DateTimeFormat('fr-FR', { weekday: 'long', day: 'numeric', month: 'short' })
|
||||||
|
.format(new Date());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||||
|
<View style={{ padding: 14, gap: 10, flex: 1 }}>
|
||||||
|
{/* Entête app : marque + pastille synchro + compte */}
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||||
|
<Text style={{ color: t.safran, fontSize: 15 }}>♦</Text>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 17, color: t.encre }}>
|
||||||
|
SIOP
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_600SemiBold', fontSize: 10.5, color: t.encre3 }}>
|
||||||
|
Technicien
|
||||||
|
</Text>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6, marginLeft: 'auto' }}>
|
||||||
|
<Text
|
||||||
|
accessibilityLabel="État de synchronisation"
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Manrope_700Bold',
|
||||||
|
fontSize: 10.5,
|
||||||
|
color: horsLigne ? t.stAttente : t.stTermine,
|
||||||
|
backgroundColor: horsLigne ? t.stAttenteFond : t.stTermineFond,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 999,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{horsLigne ? 'Hors-ligne' : 'Synchro à jour'}
|
||||||
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Se déconnecter"
|
||||||
|
onLongPress={() => {
|
||||||
|
void logout().then(() => router.replace('/connexion'));
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: 26,
|
||||||
|
height: 26,
|
||||||
|
borderRadius: 13,
|
||||||
|
backgroundColor: t.stEncours,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: '#fff', fontFamily: 'Manrope_700Bold', fontSize: 10 }}>
|
||||||
|
{(me?.displayName ?? '·')
|
||||||
|
.split(' ')
|
||||||
|
.map((m) => m[0])
|
||||||
|
.join('')
|
||||||
|
.slice(0, 2)
|
||||||
|
.toUpperCase()}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'baseline' }}>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
|
||||||
|
Ma journée
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
marginLeft: 'auto',
|
||||||
|
fontFamily: 'Manrope_600SemiBold',
|
||||||
|
fontSize: 11,
|
||||||
|
color: t.encre3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{jour}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{horsLigne ? (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: t.stAttenteFond,
|
||||||
|
borderColor: t.stAttente,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 9,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: t.stAttente, fontFamily: 'Manrope_700Bold', fontSize: 12 }}>
|
||||||
|
⚠ Hors-ligne — liste du{' '}
|
||||||
|
{dataUpdatedAt
|
||||||
|
? new Intl.DateTimeFormat('fr-FR', { hour: '2-digit', minute: '2-digit' }).format(dataUpdatedAt)
|
||||||
|
: '…'}
|
||||||
|
. Vos saisies partiront en file (R4.3).
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
{urgences.length ? (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: t.prioBloqueFond,
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 9,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: t.prioBloque }} />
|
||||||
|
<Text style={{ color: t.prioBloque, fontFamily: 'Manrope_800ExtraBold', fontSize: 12 }}>
|
||||||
|
{urgences.length === 1
|
||||||
|
? `1 personne bloquée — ${urgences[0]!.assetReference} · ${urgences[0]!.siteName}`
|
||||||
|
: `${urgences.length} personnes bloquées`}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<FlatList
|
||||||
|
data={ots}
|
||||||
|
keyExtractor={(o) => o.id}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl refreshing={isFetching} onRefresh={() => void refetch()} tintColor={t.primaire} />
|
||||||
|
}
|
||||||
|
contentContainerStyle={{ gap: 8, paddingBottom: 12 }}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<Text style={{ color: t.encre3, fontFamily: 'Manrope_600SemiBold', padding: 16, textAlign: 'center' }}>
|
||||||
|
Aucun OT en cours — tirez pour rafraîchir.
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
renderItem={({ item: o }) => {
|
||||||
|
const [enc, fond] = STYLE_STATUT[o.status](t);
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={`${o.reference} — ${o.title}`}
|
||||||
|
onPress={() => router.push(`/ot/${o.id}`)}
|
||||||
|
style={{
|
||||||
|
backgroundColor: t.surface,
|
||||||
|
borderColor: t.bordure,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderLeftWidth: 4,
|
||||||
|
borderLeftColor: STRIE_PRIORITE[o.priority](t),
|
||||||
|
padding: 11,
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Manrope_700Bold',
|
||||||
|
fontSize: 10.5,
|
||||||
|
letterSpacing: 0.6,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: t.encre2,
|
||||||
|
flex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{o.reference} · {WORK_ORDER_TYPE_LABELS[o.type]}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Manrope_700Bold',
|
||||||
|
fontSize: 10.5,
|
||||||
|
color: enc,
|
||||||
|
backgroundColor: fond,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 999,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{WORK_ORDER_STATUS_LABELS[o.status]}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 14, color: t.encre }}>
|
||||||
|
{o.title}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 12, color: t.encre2 }}>
|
||||||
|
Asc. {o.assetReference} — {o.siteName}
|
||||||
|
{o.dueDate
|
||||||
|
? ` · échéance ${new Intl.DateTimeFormat('fr-FR', { day: 'numeric', month: 'short' }).format(new Date(o.dueDate))}`
|
||||||
|
: ''}
|
||||||
|
{o.priority !== 'NONE' && o.priority !== 'PERSON_TRAPPED'
|
||||||
|
? ` · ${WORK_ORDER_PRIORITY_LABELS[o.priority]}`
|
||||||
|
: ''}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
apps/mobile/app/(tabs)/preventif.tsx
Normal file
76
apps/mobile/app/(tabs)/preventif.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { router } from 'expo-router';
|
||||||
|
import { FlatList, Pressable, Text, View } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { useWorkOrders } from '@/api/exploitation';
|
||||||
|
import { ChipStatut } from '@/composants/ui';
|
||||||
|
import { useTokens } from '@/theme/tokens';
|
||||||
|
|
||||||
|
/** Onglet Préventif : mes grilles du moment — chaque tuile mène à la
|
||||||
|
* checklist cochable (écran 6). Les OT viennent déjà scopés par l'API. */
|
||||||
|
export default function PagePreventif() {
|
||||||
|
const t = useTokens();
|
||||||
|
const { data: workOrders } = useWorkOrders();
|
||||||
|
|
||||||
|
const grilles = (workOrders ?? [])
|
||||||
|
.filter((w) => w.type === 'PREVENTIVE' && w.status !== 'DONE' && w.status !== 'CANCELLED')
|
||||||
|
.sort((a, b) => (a.dueDate ?? '9999').localeCompare(b.dueDate ?? '9999'));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||||
|
<View style={{ flex: 1, padding: 14, gap: 10 }}>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
|
||||||
|
Préventif
|
||||||
|
</Text>
|
||||||
|
<FlatList
|
||||||
|
data={grilles}
|
||||||
|
keyExtractor={(w) => w.id}
|
||||||
|
contentContainerStyle={{ gap: 8, paddingBottom: 12 }}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<Text style={{ color: t.encre3, fontFamily: 'Manrope_600SemiBold', padding: 16, textAlign: 'center' }}>
|
||||||
|
Aucune grille préventive en cours pour vous.
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
renderItem={({ item: w }) => (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={() => router.push(`/ot/${w.id}/grille`)}
|
||||||
|
style={{
|
||||||
|
backgroundColor: t.surface,
|
||||||
|
borderColor: t.bordure,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 12,
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
fontFamily: 'Manrope_700Bold',
|
||||||
|
fontSize: 10.5,
|
||||||
|
letterSpacing: 0.6,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: t.encre2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{w.reference}
|
||||||
|
</Text>
|
||||||
|
<ChipStatut statut={w.status} />
|
||||||
|
</View>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 14, color: t.encre }}>
|
||||||
|
{w.title}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 12, color: t.encre2 }}>
|
||||||
|
Asc. {w.assetReference} — {w.siteName}
|
||||||
|
{w.dueDate
|
||||||
|
? ` · pour le ${new Intl.DateTimeFormat('fr-FR', { day: 'numeric', month: 'short' }).format(new Date(w.dueDate))}`
|
||||||
|
: ''}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
158
apps/mobile/app/(tabs)/scanner.tsx
Normal file
158
apps/mobile/app/(tabs)/scanner.tsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { CameraView, useCameraPermissions } from 'expo-camera';
|
||||||
|
import { router } from 'expo-router';
|
||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { Platform, Text, TextInput, View } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { useAssets } from '@/api/exploitation';
|
||||||
|
import { useHorsLigne } from '@/auth/session';
|
||||||
|
import { BoutonTel } from '@/composants/ui';
|
||||||
|
import { analyseScan } from '@/lib/scan';
|
||||||
|
import { useTokens } from '@/theme/tokens';
|
||||||
|
|
||||||
|
/** Écran 4 de la maquette R4 (D4) : le QR de l'étiquette A6 contient l'URL
|
||||||
|
* portail `…/q/REF` — la résolution se fait D'ABORD dans le parc déjà en
|
||||||
|
* cache (donc en sous-sol aussi). Repli : saisie de la référence. */
|
||||||
|
export default function PageScanner() {
|
||||||
|
const t = useTokens();
|
||||||
|
const horsLigne = useHorsLigne();
|
||||||
|
const [permission, demanderPermission] = useCameraPermissions();
|
||||||
|
const { data: assets, refetch } = useAssets();
|
||||||
|
const [manuel, setManuel] = useState('');
|
||||||
|
const [erreur, setErreur] = useState<string | null>(null);
|
||||||
|
const dernierScan = useRef(0);
|
||||||
|
|
||||||
|
const resoudre = async (brut: string) => {
|
||||||
|
const reference = analyseScan(brut);
|
||||||
|
if (!reference) {
|
||||||
|
setErreur('Ce code n’est pas une étiquette SIOP.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 1 · le parc en cache (fonctionne hors-ligne)
|
||||||
|
let appareil = (assets ?? []).find((a) => a.reference.toUpperCase() === reference);
|
||||||
|
// 2 · sinon, un rafraîchissement si le réseau est là
|
||||||
|
if (!appareil && !horsLigne) {
|
||||||
|
const frais = await refetch();
|
||||||
|
appareil = (frais.data ?? []).find((a) => a.reference.toUpperCase() === reference);
|
||||||
|
}
|
||||||
|
if (!appareil) {
|
||||||
|
setErreur(
|
||||||
|
horsLigne
|
||||||
|
? `« ${reference} » n'est pas dans le parc synchronisé — réessayez au retour du réseau.`
|
||||||
|
: `Aucun appareil « ${reference} » dans le parc.`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setErreur(null);
|
||||||
|
setManuel('');
|
||||||
|
router.push(`/ascenseur/${appareil.id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const surScan = ({ data }: { data: string }) => {
|
||||||
|
const maintenant = Date.now();
|
||||||
|
if (maintenant - dernierScan.current < 1500) return; // anti-rafale
|
||||||
|
dernierScan.current = maintenant;
|
||||||
|
void resoudre(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cameraUtilisable = Platform.OS !== 'web' && permission?.granted;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||||
|
<View style={{ flex: 1, padding: 14, gap: 10 }}>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
|
||||||
|
Scanner
|
||||||
|
</Text>
|
||||||
|
{cameraUtilisable ? (
|
||||||
|
<View style={{ flex: 1, borderRadius: 12, overflow: 'hidden' }}>
|
||||||
|
<CameraView
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
|
||||||
|
onBarcodeScanned={surScan}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 12,
|
||||||
|
alignSelf: 'center',
|
||||||
|
color: '#dfe7f2',
|
||||||
|
fontFamily: 'Manrope_600SemiBold',
|
||||||
|
fontSize: 12.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Visez le QR de l’étiquette de cabine
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: '#131c2c',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 12,
|
||||||
|
padding: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 150,
|
||||||
|
height: 150,
|
||||||
|
borderRadius: 14,
|
||||||
|
borderWidth: 2.5,
|
||||||
|
borderColor: t.safran,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: '#dfe7f2',
|
||||||
|
fontFamily: 'Manrope_600SemiBold',
|
||||||
|
fontSize: 12.5,
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Platform.OS === 'web'
|
||||||
|
? 'Caméra indisponible sur web — saisissez la référence ci-dessous.'
|
||||||
|
: 'L’appareil photo sert uniquement à lire les étiquettes du parc.'}
|
||||||
|
</Text>
|
||||||
|
{Platform.OS !== 'web' && !permission?.granted ? (
|
||||||
|
<BoutonTel libelle="Autoriser la caméra" surAppui={() => void demanderPermission()} />
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={{ flexDirection: 'row', gap: 8 }}>
|
||||||
|
<TextInput
|
||||||
|
accessibilityLabel="Référence de l'appareil"
|
||||||
|
placeholder="Référence (A1, B2…)"
|
||||||
|
placeholderTextColor={t.encre3}
|
||||||
|
autoCapitalize="characters"
|
||||||
|
value={manuel}
|
||||||
|
onChangeText={(v) => {
|
||||||
|
setManuel(v);
|
||||||
|
setErreur(null);
|
||||||
|
}}
|
||||||
|
onSubmitEditing={() => void resoudre(manuel)}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: t.surface,
|
||||||
|
borderColor: t.bordureForte,
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderRadius: 10,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 10,
|
||||||
|
color: t.encre,
|
||||||
|
fontFamily: 'Manrope_600SemiBold',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<BoutonTel libelle="Ouvrir" desactive={!manuel.trim()} surAppui={() => void resoudre(manuel)} />
|
||||||
|
</View>
|
||||||
|
{erreur ? (
|
||||||
|
<Text style={{ color: t.danger, fontFamily: 'Manrope_600SemiBold', fontSize: 12.5 }}>
|
||||||
|
{erreur}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
182
apps/mobile/app/(tabs)/synchro.tsx
Normal file
182
apps/mobile/app/(tabs)/synchro.tsx
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { router } from 'expo-router';
|
||||||
|
import { FlatList, Pressable, Text, View } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { useHorsLigne } from '@/auth/session';
|
||||||
|
import { BoutonTel } from '@/composants/ui';
|
||||||
|
import { useFile, type Saisie } from '@/file/store';
|
||||||
|
import { abandonnerSaisie, rejouer, rejouerSurVersionAJour } from '@/file/synchro';
|
||||||
|
import { useTokens } from '@/theme/tokens';
|
||||||
|
|
||||||
|
/** Écran 7 de la maquette R4 : la file VISIBLE et honnête. Rejeu dans
|
||||||
|
* l'ordre ; un conflit (verrou D2) arrête la file sur l'élément, montre le
|
||||||
|
* message de l'API (qui, quand) et VOUS tranchez — rien n'est écrasé ni
|
||||||
|
* perdu en silence. */
|
||||||
|
|
||||||
|
const ICONES: Record<Saisie['type'], string> = {
|
||||||
|
TRANSITION: '🏁',
|
||||||
|
COCHE: '✓',
|
||||||
|
BILAN: '📋',
|
||||||
|
PHOTO: '🖼',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PageSynchro() {
|
||||||
|
const t = useTokens();
|
||||||
|
const horsLigne = useHorsLigne();
|
||||||
|
const file = useFile();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const conflit = file.find((s) => s.statut === 'CONFLIT');
|
||||||
|
const heure = (iso: string) =>
|
||||||
|
new Intl.DateTimeFormat('fr-FR', { hour: '2-digit', minute: '2-digit' }).format(new Date(iso));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||||
|
<View style={{ flex: 1, padding: 14, gap: 10 }}>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'baseline', gap: 8 }}>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
|
||||||
|
Synchro
|
||||||
|
</Text>
|
||||||
|
<Text style={{ marginLeft: 'auto', fontFamily: 'Manrope_600SemiBold', fontSize: 11, color: t.encre3 }}>
|
||||||
|
{conflit ? 'arrêtée sur le conflit' : file.length ? 'rejouée dans l’ordre' : ''}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{conflit ? (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: t.prioBloqueFond,
|
||||||
|
borderColor: t.danger,
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 12,
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: t.danger, fontFamily: 'Manrope_800ExtraBold', fontSize: 13 }}>
|
||||||
|
⚠ Conflit sur {conflit.otReference}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: t.encre, fontFamily: 'Manrope_600SemiBold', fontSize: 12.5 }}>
|
||||||
|
{conflit.erreur ?? 'L’OT a été modifié pendant que vous étiez hors-ligne.'}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: t.encre2, fontFamily: 'Manrope_400Regular', fontSize: 11.5 }}>
|
||||||
|
Votre saisie (« {conflit.libelle} », {heure(conflit.creeA)}) est conservée telle
|
||||||
|
quelle dans la file — rien n’est perdu.
|
||||||
|
</Text>
|
||||||
|
<BoutonTel
|
||||||
|
libelle="Voir l’OT à jour"
|
||||||
|
variante="contour"
|
||||||
|
surAppui={() => router.push(`/ot/${conflit.otId}`)}
|
||||||
|
/>
|
||||||
|
<BoutonTel
|
||||||
|
libelle="Rejouer ma saisie sur la version à jour"
|
||||||
|
surAppui={() => void rejouerSurVersionAJour(conflit.id, queryClient)}
|
||||||
|
/>
|
||||||
|
<BoutonTel
|
||||||
|
libelle="Abandonner ma saisie"
|
||||||
|
variante="gris"
|
||||||
|
surAppui={() => void abandonnerSaisie(conflit.id, queryClient)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<FlatList
|
||||||
|
data={file}
|
||||||
|
keyExtractor={(s) => s.id}
|
||||||
|
contentContainerStyle={{ gap: 8, paddingBottom: 12 }}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View style={{ padding: 24, alignItems: 'center', gap: 6 }}>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 15, color: t.encre }}>
|
||||||
|
Rien en attente
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Manrope_400Regular',
|
||||||
|
fontSize: 12.5,
|
||||||
|
color: t.encre2,
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Toutes vos saisies sont sur le serveur. Les gestes faits hors-ligne apparaîtront
|
||||||
|
ici, rejoués dans l’ordre au retour du réseau.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
renderItem={({ item: s }) => (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
backgroundColor: t.surface,
|
||||||
|
borderColor: s.statut === 'CONFLIT' ? t.danger : t.bordure,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 10,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: t.surface2,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 13 }}>{ICONES[s.type]}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text numberOfLines={1} style={{ fontFamily: 'Manrope_700Bold', fontSize: 12.5, color: t.encre }}>
|
||||||
|
{s.libelle}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 11, color: t.encre2 }}>
|
||||||
|
{s.otReference} · {heure(s.creeA)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Manrope_700Bold',
|
||||||
|
fontSize: 10.5,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 999,
|
||||||
|
overflow: 'hidden',
|
||||||
|
color:
|
||||||
|
s.statut === 'CONFLIT' ? t.danger : s.statut === 'ENVOI' ? t.stOuvert : t.stAttente,
|
||||||
|
backgroundColor:
|
||||||
|
s.statut === 'CONFLIT'
|
||||||
|
? t.prioBloqueFond
|
||||||
|
: s.statut === 'ENVOI'
|
||||||
|
? t.stOuvertFond
|
||||||
|
: t.stAttenteFond,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{s.statut === 'CONFLIT' ? 'conflit' : s.statut === 'ENVOI' ? 'envoi…' : 'en attente'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{file.length && !horsLigne ? (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={() => void rejouer(queryClient)}
|
||||||
|
style={{ backgroundColor: t.primaire, borderRadius: 10, padding: 12, alignItems: 'center' }}
|
||||||
|
>
|
||||||
|
<Text style={{ color: '#fff', fontFamily: 'Manrope_700Bold', fontSize: 14 }}>
|
||||||
|
Rejouer la file maintenant
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
{horsLigne ? (
|
||||||
|
<Text style={{ color: t.stAttente, fontFamily: 'Manrope_600SemiBold', fontSize: 12, textAlign: 'center' }}>
|
||||||
|
Hors-ligne — la file repartira seule au retour du réseau.
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
80
apps/mobile/app/_layout.tsx
Normal file
80
apps/mobile/app/_layout.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import {
|
||||||
|
Manrope_400Regular,
|
||||||
|
Manrope_600SemiBold,
|
||||||
|
Manrope_700Bold,
|
||||||
|
Manrope_800ExtraBold,
|
||||||
|
useFonts,
|
||||||
|
} from '@expo-google-fonts/manrope';
|
||||||
|
import NetInfo from '@react-native-community/netinfo';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister';
|
||||||
|
import { onlineManager, QueryClient } from '@tanstack/react-query';
|
||||||
|
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
|
||||||
|
import { Stack } from 'expo-router';
|
||||||
|
import { StatusBar } from 'expo-status-bar';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { HorsLigneProvider } from '@/auth/session';
|
||||||
|
import { chargerFile } from '@/file/store';
|
||||||
|
import { rejouer } from '@/file/synchro';
|
||||||
|
import { useTokens } from '@/theme/tokens';
|
||||||
|
|
||||||
|
/** Racine de l'app (D1 validée) : le cache TanStack est PERSISTÉ dans
|
||||||
|
* AsyncStorage — les OT lus restent lisibles hors-ligne, y compris après
|
||||||
|
* redémarrage. NetInfo pilote onlineManager (pas de retry dans le vide)
|
||||||
|
* et le drapeau hors-ligne des écrans. */
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 60_000,
|
||||||
|
gcTime: 7 * 24 * 3600_000, // une semaine de lecture hors-ligne
|
||||||
|
retry: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const persister = createAsyncStoragePersister({
|
||||||
|
storage: AsyncStorage,
|
||||||
|
key: 'siop.cache',
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function RacineApp() {
|
||||||
|
const t = useTokens();
|
||||||
|
const [horsLigne, setHorsLigne] = useState(false);
|
||||||
|
const [polices] = useFonts({
|
||||||
|
Manrope_400Regular,
|
||||||
|
Manrope_600SemiBold,
|
||||||
|
Manrope_700Bold,
|
||||||
|
Manrope_800ExtraBold,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// D1 : la file survit au redémarrage — on la recharge avant tout.
|
||||||
|
void chargerFile().then(() => rejouer(queryClient));
|
||||||
|
return NetInfo.addEventListener((etat) => {
|
||||||
|
const enLigne = !!etat.isConnected;
|
||||||
|
onlineManager.setOnline(enLigne);
|
||||||
|
setHorsLigne(!enLigne);
|
||||||
|
if (enLigne) void rejouer(queryClient); // le retour du réseau vide la file
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!polices) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PersistQueryClientProvider
|
||||||
|
client={queryClient}
|
||||||
|
persistOptions={{ persister, maxAge: 7 * 24 * 3600_000 }}
|
||||||
|
>
|
||||||
|
<HorsLigneProvider valeur={horsLigne}>
|
||||||
|
<StatusBar style="auto" />
|
||||||
|
<Stack
|
||||||
|
screenOptions={{
|
||||||
|
headerShown: false,
|
||||||
|
contentStyle: { backgroundColor: t.fond },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</HorsLigneProvider>
|
||||||
|
</PersistQueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
129
apps/mobile/app/ascenseur/[id].tsx
Normal file
129
apps/mobile/app/ascenseur/[id].tsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { router, useLocalSearchParams } from 'expo-router';
|
||||||
|
import { Pressable, ScrollView, Text } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { ASSET_STATUS_LABELS } from '@siop/shared';
|
||||||
|
import { useAsset, useWorkOrders } from '@/api/exploitation';
|
||||||
|
import { Carte, ChipStatut, EnteteFiche, LigneInfo } from '@/composants/ui';
|
||||||
|
import { triJournee } from '@/lib/journee';
|
||||||
|
import { useTokens } from '@/theme/tokens';
|
||||||
|
|
||||||
|
/** Écran 5 de la maquette R4 : tout ce qu'il faut DEVANT la machine.
|
||||||
|
* Consultation seule (D3) — l'historique montre ce que le rôle a le droit
|
||||||
|
* de voir (invariant « voir autre », même règle que partout). */
|
||||||
|
export default function PageAscenseur() {
|
||||||
|
const t = useTokens();
|
||||||
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
|
const { data: appareil } = useAsset(id);
|
||||||
|
const { data: workOrders } = useWorkOrders();
|
||||||
|
|
||||||
|
if (!appareil) return null;
|
||||||
|
|
||||||
|
const interventions = (workOrders ?? [])
|
||||||
|
.filter((w) => w.assetId === appareil.id)
|
||||||
|
.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
|
||||||
|
const enCours = triJournee(interventions);
|
||||||
|
const statut = ASSET_STATUS_LABELS[appareil.status];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||||
|
<ScrollView contentContainerStyle={{ padding: 14, gap: 10 }}>
|
||||||
|
<EnteteFiche
|
||||||
|
titre={`Asc. ${appareil.reference}`}
|
||||||
|
apres={
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Manrope_700Bold',
|
||||||
|
fontSize: 10.5,
|
||||||
|
color: appareil.status === 'IN_SERVICE' ? t.stTermine : t.stAttente,
|
||||||
|
backgroundColor: appareil.status === 'IN_SERVICE' ? t.stTermineFond : t.stAttenteFond,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 999,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{statut}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 16, color: t.encre }}>
|
||||||
|
{appareil.brand}
|
||||||
|
{appareil.model ? ` ${appareil.model}` : ''} — {appareil.siteName}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Carte titre="Identité">
|
||||||
|
<LigneInfo nom="Emplacement" valeur={appareil.locationName} />
|
||||||
|
<LigneInfo nom="Catégorie" valeur={appareil.categoryName} />
|
||||||
|
<LigneInfo
|
||||||
|
nom="Mise en service"
|
||||||
|
valeur={
|
||||||
|
appareil.commissionedAt
|
||||||
|
? new Intl.DateTimeFormat('fr-FR', { month: 'long', year: 'numeric' }).format(
|
||||||
|
new Date(appareil.commissionedAt),
|
||||||
|
)
|
||||||
|
: '—'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<LigneInfo
|
||||||
|
nom="Charge / niveaux"
|
||||||
|
valeur={`${appareil.loadKg != null ? `${appareil.loadKg} kg` : '—'} · ${appareil.floors != null ? `${appareil.floors} niveaux` : '—'}`}
|
||||||
|
/>
|
||||||
|
{appareil.serialNumber ? (
|
||||||
|
<LigneInfo nom="N° de série" valeur={appareil.serialNumber} />
|
||||||
|
) : null}
|
||||||
|
</Carte>
|
||||||
|
|
||||||
|
<Carte titre="Organes suivis">
|
||||||
|
{appareil.components.length ? (
|
||||||
|
appareil.components.map((c) => (
|
||||||
|
<LigneInfo key={c.id} nom={c.typeName} valeur={c.designation ?? '—'} />
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Text style={{ color: t.encre3, fontFamily: 'Manrope_400Regular', fontSize: 12.5 }}>
|
||||||
|
Aucun organe déclaré.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Carte>
|
||||||
|
|
||||||
|
<Carte titre={`Interventions visibles (${interventions.length})`}>
|
||||||
|
{interventions.slice(0, 6).map((w) => (
|
||||||
|
<Pressable
|
||||||
|
key={w.id}
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={() => router.push(`/ot/${w.id}`)}
|
||||||
|
style={{ flexDirection: 'row', alignItems: 'center', gap: 8, paddingVertical: 3 }}
|
||||||
|
>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 12.5, color: t.primaire }}>
|
||||||
|
{w.reference}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={{ flex: 1, fontFamily: 'Manrope_400Regular', fontSize: 12.5, color: t.encre }}
|
||||||
|
>
|
||||||
|
{w.title}
|
||||||
|
</Text>
|
||||||
|
<ChipStatut statut={w.status} />
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
{!interventions.length ? (
|
||||||
|
<Text style={{ color: t.encre3, fontFamily: 'Manrope_400Regular', fontSize: 12.5 }}>
|
||||||
|
Aucune intervention visible pour votre rôle.
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Carte>
|
||||||
|
|
||||||
|
{enCours.length ? (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={() => router.push(`/ot/${enCours[0]!.id}`)}
|
||||||
|
style={{ backgroundColor: t.primaire, borderRadius: 10, padding: 12, alignItems: 'center' }}
|
||||||
|
>
|
||||||
|
<Text style={{ color: '#fff', fontFamily: 'Manrope_700Bold', fontSize: 14 }}>
|
||||||
|
Ouvrir l’OT en cours ({enCours[0]!.reference})
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
150
apps/mobile/app/connexion.tsx
Normal file
150
apps/mobile/app/connexion.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import { router } from 'expo-router';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
import { useDemoAccounts, useDemoLogin, useLogin } from '@/auth/session';
|
||||||
|
import { useTokens } from '@/theme/tokens';
|
||||||
|
|
||||||
|
/** Connexion mobile — mêmes règles que le web : formulaire e-mail/mot de
|
||||||
|
* passe, et le sélecteur démo (< 3 s pour changer de rôle, ADR-002)
|
||||||
|
* UNIQUEMENT si l'API l'expose. */
|
||||||
|
export default function PageConnexion() {
|
||||||
|
const t = useTokens();
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [motDePasse, setMotDePasse] = useState('');
|
||||||
|
const { data: comptes } = useDemoAccounts();
|
||||||
|
const login = useLogin();
|
||||||
|
const demo = useDemoLogin();
|
||||||
|
|
||||||
|
const entrer = () => router.replace('/(tabs)/journee');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
style={{ flex: 1, backgroundColor: t.fond }}
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||||
|
>
|
||||||
|
<ScrollView contentContainerStyle={{ padding: 24, paddingTop: 80, gap: 12 }}>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'baseline', gap: 8 }}>
|
||||||
|
<Text style={{ color: t.safran, fontSize: 22 }}>♦</Text>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 26, color: t.encre }}>
|
||||||
|
SIOP
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_600SemiBold', fontSize: 13, color: t.encre3 }}>
|
||||||
|
Technicien
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_400Regular', color: t.encre2, marginBottom: 8 }}>
|
||||||
|
Vos ordres de travail, sur le terrain — même sans réseau.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={{ gap: 10 }}>
|
||||||
|
<TextInput
|
||||||
|
accessibilityLabel="E-mail"
|
||||||
|
placeholder="E-mail"
|
||||||
|
placeholderTextColor={t.encre3}
|
||||||
|
autoCapitalize="none"
|
||||||
|
keyboardType="email-address"
|
||||||
|
value={email}
|
||||||
|
onChangeText={setEmail}
|
||||||
|
style={{
|
||||||
|
backgroundColor: t.surface,
|
||||||
|
borderColor: t.bordureForte,
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 13,
|
||||||
|
color: t.encre,
|
||||||
|
fontFamily: 'Manrope_600SemiBold',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
accessibilityLabel="Mot de passe"
|
||||||
|
placeholder="Mot de passe"
|
||||||
|
placeholderTextColor={t.encre3}
|
||||||
|
secureTextEntry
|
||||||
|
value={motDePasse}
|
||||||
|
onChangeText={setMotDePasse}
|
||||||
|
style={{
|
||||||
|
backgroundColor: t.surface,
|
||||||
|
borderColor: t.bordureForte,
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 13,
|
||||||
|
color: t.encre,
|
||||||
|
fontFamily: 'Manrope_600SemiBold',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
disabled={!email || !motDePasse || login.isPending}
|
||||||
|
onPress={() =>
|
||||||
|
login.mutate({ email, password: motDePasse }, { onSuccess: entrer })
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
backgroundColor: !email || !motDePasse ? t.bordureForte : t.primaire,
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 14,
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: '#fff', fontFamily: 'Manrope_700Bold', fontSize: 15 }}>
|
||||||
|
{login.isPending ? 'Connexion…' : 'Se connecter'}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
{login.isError ? (
|
||||||
|
<Text style={{ color: t.danger, fontFamily: 'Manrope_600SemiBold', fontSize: 13 }}>
|
||||||
|
{login.error.message}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{comptes?.length ? (
|
||||||
|
<View style={{ marginTop: 18, gap: 8 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Manrope_700Bold',
|
||||||
|
fontSize: 11,
|
||||||
|
letterSpacing: 1,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: t.encre3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Mode démo — connexion 1 clic
|
||||||
|
</Text>
|
||||||
|
{comptes.map((c) => (
|
||||||
|
<Pressable
|
||||||
|
key={c.id}
|
||||||
|
accessibilityRole="button"
|
||||||
|
disabled={demo.isPending}
|
||||||
|
onPress={() => demo.mutate(c.id, { onSuccess: entrer })}
|
||||||
|
style={{
|
||||||
|
backgroundColor: t.surface,
|
||||||
|
borderColor: t.bordure,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 12,
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_700Bold', color: t.encre, fontSize: 14 }}>
|
||||||
|
{c.displayName}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_600SemiBold', color: t.encre3, fontSize: 12 }}>
|
||||||
|
{c.roleName}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
apps/mobile/app/index.tsx
Normal file
20
apps/mobile/app/index.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { Redirect } from 'expo-router';
|
||||||
|
import { ActivityIndicator, View } from 'react-native';
|
||||||
|
import { useMe } from '@/auth/session';
|
||||||
|
import { useTokens } from '@/theme/tokens';
|
||||||
|
|
||||||
|
/** Aiguillage : session valide → Ma journée ; sinon → connexion.
|
||||||
|
* (Hors-ligne avec cache persisté, /users/me sort du cache : on entre.) */
|
||||||
|
export default function Aiguillage() {
|
||||||
|
const t = useTokens();
|
||||||
|
const { data: me, isLoading } = useMe();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: t.fond }}>
|
||||||
|
<ActivityIndicator color={t.primaire} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return me ? <Redirect href="/(tabs)/journee" /> : <Redirect href="/connexion" />;
|
||||||
|
}
|
||||||
273
apps/mobile/app/ot/[id]/cloture.tsx
Normal file
273
apps/mobile/app/ot/[id]/cloture.tsx
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
import { router, useLocalSearchParams } from 'expo-router';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Pressable, ScrollView, Text, TextInput, View } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import {
|
||||||
|
BILAN_FIELD_LABELS,
|
||||||
|
BILAN_FIELDS,
|
||||||
|
REQUIRED_BILAN_FIELDS,
|
||||||
|
type BilanField,
|
||||||
|
type BilanSuggestion,
|
||||||
|
type ReportUpsert,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useReferenceValues, useSuggestionBilan, useWorkOrder } from '@/api/exploitation';
|
||||||
|
import { useHorsLigne } from '@/auth/session';
|
||||||
|
import { BoutonTel, Carte, ChoixTel, EnteteFiche } from '@/composants/ui';
|
||||||
|
import { enfilerBilan, enfilerTransition } from '@/file/actions';
|
||||||
|
import { useTokens } from '@/theme/tokens';
|
||||||
|
|
||||||
|
/** Écran 3 de la maquette R4 : le bilan codé R2 au pouce — 3 champs requis,
|
||||||
|
* garde visible et bloquante. Depuis R4.3, bilan et clôture partent EN
|
||||||
|
* FILE (D1) : hors-ligne, l'OT passe localement à « Terminé (en file) »
|
||||||
|
* et le serveur tranchera au rejeu (verrou D2 si l'OT a bougé). */
|
||||||
|
|
||||||
|
type ChampBilanId =
|
||||||
|
| 'doorStateId'
|
||||||
|
| 'cabinPositionId'
|
||||||
|
| 'anomalyId'
|
||||||
|
| 'externalCauseId'
|
||||||
|
| 'actionTakenId'
|
||||||
|
| 'componentConcernedId';
|
||||||
|
|
||||||
|
const CHAMP_VERS_ID: Record<BilanField, ChampBilanId> = {
|
||||||
|
DOOR_STATE: 'doorStateId',
|
||||||
|
CABIN_POSITION: 'cabinPositionId',
|
||||||
|
ANOMALY: 'anomalyId',
|
||||||
|
EXTERNAL_CAUSE: 'externalCauseId',
|
||||||
|
ACTION_TAKEN: 'actionTakenId',
|
||||||
|
COMPONENT_CONCERNED: 'componentConcernedId',
|
||||||
|
};
|
||||||
|
const CHAMP_VERS_VALEUR = {
|
||||||
|
DOOR_STATE: 'doorState',
|
||||||
|
CABIN_POSITION: 'cabinPosition',
|
||||||
|
ANOMALY: 'anomaly',
|
||||||
|
EXTERNAL_CAUSE: 'externalCause',
|
||||||
|
ACTION_TAKEN: 'actionTaken',
|
||||||
|
COMPONENT_CONCERNED: 'componentConcerned',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export default function PageCloture() {
|
||||||
|
const t = useTokens();
|
||||||
|
const horsLigne = useHorsLigne();
|
||||||
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
|
const { data: ot } = useWorkOrder(id);
|
||||||
|
const { data: valeurs } = useReferenceValues();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [choix, setChoix] = useState<Partial<Record<BilanField, string | null>>>({});
|
||||||
|
|
||||||
|
if (!ot) return null;
|
||||||
|
|
||||||
|
const optionsDe = (champ: BilanField) =>
|
||||||
|
(valeurs ?? []).filter((v: { field: string; isActive: boolean }) => v.field === champ && v.isActive);
|
||||||
|
|
||||||
|
/** Valeur affichée : le choix local s'il existe, sinon le bilan serveur. */
|
||||||
|
const valeurDe = (champ: BilanField): { id: string; label: string } | null => {
|
||||||
|
if (champ in choix) {
|
||||||
|
const vid = choix[champ];
|
||||||
|
if (!vid) return null;
|
||||||
|
const v = (valeurs ?? []).find((x) => x.id === vid);
|
||||||
|
return v ? { id: v.id, label: v.label } : null;
|
||||||
|
}
|
||||||
|
return ot.report?.[CHAMP_VERS_VALEUR[champ]] ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const enregistrer = () => {
|
||||||
|
const corps: ReportUpsert = {};
|
||||||
|
const labels: Parameters<typeof enfilerBilan>[3] = {};
|
||||||
|
for (const champ of BILAN_FIELDS) {
|
||||||
|
if (champ in choix) {
|
||||||
|
corps[CHAMP_VERS_ID[champ]] = choix[champ] ?? null;
|
||||||
|
labels[CHAMP_VERS_ID[champ]] = valeurDe(champ);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
enfilerBilan(queryClient, ot, corps, labels);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cloturer = () => {
|
||||||
|
enregistrer();
|
||||||
|
enfilerTransition(queryClient, ot, 'DONE');
|
||||||
|
router.replace(`/ot/${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const manquants = REQUIRED_BILAN_FIELDS.filter((c) => !valeurDe(c));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||||
|
<ScrollView contentContainerStyle={{ padding: 14, gap: 10 }}>
|
||||||
|
<EnteteFiche titre={`Clôturer ${ot.reference}`} />
|
||||||
|
<CarteSuggestion
|
||||||
|
horsLigne={horsLigne}
|
||||||
|
surApplication={(s) => setChoix((c) => ({ ...c, [s.field]: s.valueId }))}
|
||||||
|
/>
|
||||||
|
<Carte titre="Bilan d'intervention — requis pour clôturer">
|
||||||
|
<View style={{ gap: 10 }}>
|
||||||
|
{[0, 2, 4].map((rang) => (
|
||||||
|
<View key={rang} style={{ flexDirection: 'row', gap: 8 }}>
|
||||||
|
{[BILAN_FIELDS[rang]!, BILAN_FIELDS[rang + 1]!].map((champ) => (
|
||||||
|
<ChoixTel
|
||||||
|
key={champ}
|
||||||
|
libelle={BILAN_FIELD_LABELS[champ]}
|
||||||
|
requis={REQUIRED_BILAN_FIELDS.includes(champ)}
|
||||||
|
valeur={valeurDe(champ)}
|
||||||
|
options={optionsDe(champ)}
|
||||||
|
surChoix={(vid) => setChoix((c) => ({ ...c, [champ]: vid }))}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
{manquants.length ? (
|
||||||
|
<Text style={{ color: t.alerte, fontFamily: 'Manrope_600SemiBold', fontSize: 12 }}>
|
||||||
|
⚠ {manquants.map((c) => `« ${BILAN_FIELD_LABELS[c]} »`).join(', ')}{' '}
|
||||||
|
{manquants.length > 1 ? 'manquent' : 'manque'} — la clôture restera bloquée (même garde
|
||||||
|
que le web).
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Carte>
|
||||||
|
|
||||||
|
<BoutonTel
|
||||||
|
libelle="Enregistrer le bilan"
|
||||||
|
variante="contour"
|
||||||
|
surAppui={() => {
|
||||||
|
enregistrer();
|
||||||
|
router.replace(`/ot/${id}`);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<BoutonTel
|
||||||
|
libelle={
|
||||||
|
manquants.length
|
||||||
|
? 'Clôturer (bilan incomplet)'
|
||||||
|
: horsLigne
|
||||||
|
? 'Clôturer — partira à la synchro'
|
||||||
|
: "Clôturer l'intervention"
|
||||||
|
}
|
||||||
|
variante={manquants.length ? 'gris' : 'vert'}
|
||||||
|
desactive={manquants.length > 0}
|
||||||
|
surAppui={cloturer}
|
||||||
|
/>
|
||||||
|
{horsLigne ? (
|
||||||
|
<Text style={{ color: t.stAttente, fontFamily: 'Manrope_600SemiBold', fontSize: 12 }}>
|
||||||
|
Hors-ligne : la clôture est enregistrée sur le téléphone et sera rejouée telle quelle
|
||||||
|
au retour du réseau. Si l’OT a changé entre-temps, VOUS trancherez (écran Synchro).
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Écran 4 des maquettes R5 : décrire au pouce → chips suggérées (D1 — un
|
||||||
|
* appui = un choix humain, les chips ne font que pré-remplir les sélecteurs).
|
||||||
|
* L'IA est un service serveur : hors-ligne, la suggestion attend le réseau
|
||||||
|
* — la clôture en file R4, elle, n'en a pas besoin. */
|
||||||
|
function CarteSuggestion({
|
||||||
|
horsLigne,
|
||||||
|
surApplication,
|
||||||
|
}: {
|
||||||
|
horsLigne: boolean;
|
||||||
|
surApplication: (s: BilanSuggestion) => void;
|
||||||
|
}) {
|
||||||
|
const t = useTokens();
|
||||||
|
const suggerer = useSuggestionBilan();
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [appliquees, setAppliquees] = useState<Set<BilanField>>(new Set());
|
||||||
|
const suggestions = suggerer.data?.suggestions ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Carte titre="Décrire pour suggérer (optionnel)">
|
||||||
|
<TextInput
|
||||||
|
multiline
|
||||||
|
value={description}
|
||||||
|
onChangeText={setDescription}
|
||||||
|
maxLength={2000}
|
||||||
|
placeholder="Décrivez la panne et ce que vous avez fait…"
|
||||||
|
placeholderTextColor={t.encre3}
|
||||||
|
accessibilityLabel="Décrire pour suggérer"
|
||||||
|
style={{
|
||||||
|
minHeight: 64,
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderColor: t.bordureForte,
|
||||||
|
borderRadius: 9,
|
||||||
|
backgroundColor: t.surface,
|
||||||
|
padding: 9,
|
||||||
|
color: t.encre,
|
||||||
|
fontFamily: 'Manrope_500Medium',
|
||||||
|
fontSize: 13,
|
||||||
|
textAlignVertical: 'top',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<BoutonTel
|
||||||
|
libelle={
|
||||||
|
horsLigne
|
||||||
|
? 'Suggérer — réseau requis'
|
||||||
|
: suggerer.isPending
|
||||||
|
? 'Analyse…'
|
||||||
|
: '✨ Suggérer les codes'
|
||||||
|
}
|
||||||
|
variante="contour"
|
||||||
|
desactive={horsLigne || suggerer.isPending || description.trim().length < 10}
|
||||||
|
surAppui={() => {
|
||||||
|
setAppliquees(new Set());
|
||||||
|
suggerer.mutate(description.trim());
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{suggerer.isError ? (
|
||||||
|
<Text style={{ color: t.danger, fontFamily: 'Manrope_600SemiBold', fontSize: 12 }}>
|
||||||
|
{suggerer.error.message}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{suggerer.isSuccess && suggestions.length === 0 ? (
|
||||||
|
<Text style={{ color: t.encre2, fontFamily: 'Manrope_500Medium', fontSize: 12 }}>
|
||||||
|
Aucun code assez proche — l’IA ne devine pas : choisissez dans les sélecteurs.
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{suggestions.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6 }}>
|
||||||
|
{suggestions.map((s) => {
|
||||||
|
const faite = appliquees.has(s.field);
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={s.field}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={`Appliquer ${BILAN_FIELD_LABELS[s.field]} : ${s.label}`}
|
||||||
|
disabled={faite}
|
||||||
|
onPress={() => {
|
||||||
|
surApplication(s);
|
||||||
|
setAppliquees((avant) => new Set([...avant, s.field]));
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 5,
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderColor: t.primaire,
|
||||||
|
backgroundColor: faite ? t.primaire : t.primaireDoux,
|
||||||
|
borderRadius: 999,
|
||||||
|
paddingVertical: 6,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: faite ? '#fff' : t.primaire,
|
||||||
|
fontFamily: 'Manrope_700Bold',
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{faite ? '✓' : '✨'} {BILAN_FIELD_LABELS[s.field]} : {s.label}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
<Text style={{ color: t.encre3, fontFamily: 'Manrope_500Medium', fontSize: 11 }}>
|
||||||
|
Rien ne s’enregistre sans votre geste — les chips pré-remplissent les sélecteurs
|
||||||
|
ci-dessous.
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</Carte>
|
||||||
|
);
|
||||||
|
}
|
||||||
145
apps/mobile/app/ot/[id]/grille.tsx
Normal file
145
apps/mobile/app/ot/[id]/grille.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useLocalSearchParams } from 'expo-router';
|
||||||
|
import { Pressable, ScrollView, Text, View } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { useWorkOrder } from '@/api/exploitation';
|
||||||
|
import { useHorsLigne } from '@/auth/session';
|
||||||
|
import { EnteteFiche } from '@/composants/ui';
|
||||||
|
import { enfilerCoche } from '@/file/actions';
|
||||||
|
import { useFile } from '@/file/store';
|
||||||
|
import { prochainEtat, progression } from '@/lib/checklist';
|
||||||
|
import { useTokens } from '@/theme/tokens';
|
||||||
|
|
||||||
|
/** Écran 6 de la maquette R4 : la grille au pouce. Appui simple = coche,
|
||||||
|
* appui long = non-applicable. Chaque coche part en file INDIVIDUELLEMENT
|
||||||
|
* (D1) : 7 coches faites = 7 coches sauvées, réseau ou pas. */
|
||||||
|
export default function PageGrille() {
|
||||||
|
const t = useTokens();
|
||||||
|
const horsLigne = useHorsLigne();
|
||||||
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
|
const { data: ot } = useWorkOrder(id);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const file = useFile();
|
||||||
|
|
||||||
|
if (!ot) return null;
|
||||||
|
const { faits, total, pct } = progression(ot.checklist);
|
||||||
|
const enFile = new Set(
|
||||||
|
file.flatMap((s) => (s.otId === ot.id && s.type === 'COCHE' ? [s.payload.itemId] : [])),
|
||||||
|
);
|
||||||
|
|
||||||
|
const basculer = (item: { id: string; label: string }, etat: Parameters<typeof prochainEtat>[0], versNA = false) => {
|
||||||
|
enfilerCoche(
|
||||||
|
queryClient,
|
||||||
|
ot,
|
||||||
|
item,
|
||||||
|
versNA ? (etat === 'NA' ? 'PENDING' : 'NA') : prochainEtat(etat),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||||
|
<ScrollView contentContainerStyle={{ padding: 14, gap: 10 }}>
|
||||||
|
<EnteteFiche
|
||||||
|
titre={`Grille — ${ot.assetReference}`}
|
||||||
|
apres={
|
||||||
|
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 13, color: t.encre2 }}>
|
||||||
|
{faits}/{total}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<View style={{ height: 8, borderRadius: 999, backgroundColor: t.surface2, overflow: 'hidden' }}>
|
||||||
|
<View
|
||||||
|
style={{ width: `${Math.round(pct * 100)}%`, height: '100%', backgroundColor: t.succes }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
{horsLigne ? (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: t.stAttenteFond,
|
||||||
|
borderColor: t.stAttente,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 9,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: t.stAttente, fontFamily: 'Manrope_700Bold', fontSize: 12 }}>
|
||||||
|
⚠ Hors-ligne — chaque coche part en file et se synchronisera au retour du réseau.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
{ot.checklist.map((item) => (
|
||||||
|
<Pressable
|
||||||
|
key={item.id}
|
||||||
|
accessibilityRole="checkbox"
|
||||||
|
aria-checked={item.state === 'DONE'}
|
||||||
|
accessibilityLabel={item.label}
|
||||||
|
onPress={() => basculer(item, item.state)}
|
||||||
|
onLongPress={() => basculer(item, item.state, true)}
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
backgroundColor: t.surface,
|
||||||
|
borderColor: t.bordure,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 10,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 11,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
borderRadius: 6,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: item.state === 'DONE' ? t.succes : t.bordureForte,
|
||||||
|
backgroundColor: item.state === 'DONE' ? t.succes : 'transparent',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.state === 'DONE' ? (
|
||||||
|
<Text style={{ color: '#fff', fontSize: 13, fontFamily: 'Manrope_800ExtraBold' }}>✓</Text>
|
||||||
|
) : item.state === 'NA' ? (
|
||||||
|
<Text style={{ color: t.encre3, fontSize: 10, fontFamily: 'Manrope_800ExtraBold' }}>NA</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
fontFamily: 'Manrope_600SemiBold',
|
||||||
|
fontSize: 13,
|
||||||
|
color: item.state === 'PENDING' ? t.encre : t.encre2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Text>
|
||||||
|
{enFile.has(item.id) ? (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Manrope_800ExtraBold',
|
||||||
|
fontSize: 9,
|
||||||
|
color: t.stAttente,
|
||||||
|
backgroundColor: t.stAttenteFond,
|
||||||
|
paddingHorizontal: 6,
|
||||||
|
borderRadius: 999,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
en file
|
||||||
|
</Text>
|
||||||
|
) : item.doneBy ? (
|
||||||
|
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 10.5, color: t.encre3 }}>
|
||||||
|
{item.doneBy.displayName.split(' ')[0]}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
<Text style={{ color: t.encre3, fontFamily: 'Manrope_400Regular', fontSize: 11.5 }}>
|
||||||
|
Appui simple : cocher / décocher · appui long : non-applicable.
|
||||||
|
</Text>
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
196
apps/mobile/app/ot/[id]/index.tsx
Normal file
196
apps/mobile/app/ot/[id]/index.tsx
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { router, useLocalSearchParams } from 'expo-router';
|
||||||
|
import { ScrollView, Text, View } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import {
|
||||||
|
WORK_ORDER_PRIORITY_LABELS,
|
||||||
|
WORK_ORDER_TYPE_LABELS,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import { useWorkOrder } from '@/api/exploitation';
|
||||||
|
import { useHorsLigne } from '@/auth/session';
|
||||||
|
import { BoutonTel, Carte, ChipStatut, EnteteFiche, LigneInfo } from '@/composants/ui';
|
||||||
|
import { CartePhotos } from '@/composants/carte-photos';
|
||||||
|
import { enfilerTransition } from '@/file/actions';
|
||||||
|
import { useFile } from '@/file/store';
|
||||||
|
import { progression } from '@/lib/checklist';
|
||||||
|
import { useTokens } from '@/theme/tokens';
|
||||||
|
|
||||||
|
const fmt = new Intl.NumberFormat('fr-FR');
|
||||||
|
|
||||||
|
/** Écran 2 de la maquette R4 : la même machine à états que le web — UN
|
||||||
|
* bouton principal selon l'état. Depuis R4.3, chaque geste part en file
|
||||||
|
* (D1) : l'écran montre l'état local tout de suite, la synchro suit. */
|
||||||
|
export default function PageFicheOT() {
|
||||||
|
const t = useTokens();
|
||||||
|
const horsLigne = useHorsLigne();
|
||||||
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
|
const { data: ot } = useWorkOrder(id);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const file = useFile();
|
||||||
|
|
||||||
|
if (!ot) return null;
|
||||||
|
|
||||||
|
const grille = progression(ot.checklist);
|
||||||
|
const enFile = file.filter((s) => s.otId === ot.id);
|
||||||
|
const peutDemarrer = ot.allowedTransitions.includes('IN_PROGRESS');
|
||||||
|
const peutCloturer = ot.allowedTransitions.includes('DONE');
|
||||||
|
const peutSuspendre = ot.allowedTransitions.includes('ON_HOLD');
|
||||||
|
|
||||||
|
const demarrer = () => enfilerTransition(queryClient, ot, 'IN_PROGRESS');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||||
|
<ScrollView contentContainerStyle={{ padding: 14, gap: 10 }}>
|
||||||
|
<EnteteFiche
|
||||||
|
titre={ot.reference}
|
||||||
|
apres={
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
|
||||||
|
{enFile.length ? (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Manrope_800ExtraBold',
|
||||||
|
fontSize: 10,
|
||||||
|
color: t.stAttente,
|
||||||
|
backgroundColor: t.stAttenteFond,
|
||||||
|
paddingHorizontal: 7,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 999,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{enFile.length} en file
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<ChipStatut statut={ot.status} />
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 17, color: t.encre }}>
|
||||||
|
{ot.title}
|
||||||
|
</Text>
|
||||||
|
{horsLigne ? (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: t.stAttenteFond,
|
||||||
|
borderColor: t.stAttente,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 9,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: t.stAttente, fontFamily: 'Manrope_700Bold', fontSize: 12 }}>
|
||||||
|
⚠ Hors-ligne — vos gestes partent en file et se synchroniseront au retour du réseau.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Carte titre="Intervention">
|
||||||
|
<LigneInfo nom="Type" valeur={WORK_ORDER_TYPE_LABELS[ot.type]} />
|
||||||
|
<LigneInfo
|
||||||
|
nom="Équipement"
|
||||||
|
valeur={`Asc. ${ot.assetReference} — ${ot.siteName}`}
|
||||||
|
/>
|
||||||
|
<LigneInfo
|
||||||
|
nom="Priorité"
|
||||||
|
valeur={
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Manrope_700Bold',
|
||||||
|
fontSize: 12.5,
|
||||||
|
color: ot.priority === 'PERSON_TRAPPED' || ot.priority === 'HIGH' ? t.prioHaute : t.encre,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{WORK_ORDER_PRIORITY_LABELS[ot.priority]}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<LigneInfo
|
||||||
|
nom="Échéance"
|
||||||
|
valeur={
|
||||||
|
ot.dueDate
|
||||||
|
? new Intl.DateTimeFormat('fr-FR', { dateStyle: 'long' }).format(new Date(ot.dueDate))
|
||||||
|
: '—'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{ot.request ? (
|
||||||
|
<LigneInfo nom="Demande liée" valeur={`${ot.request.reference} · ${ot.request.requesterLabel}`} />
|
||||||
|
) : null}
|
||||||
|
</Carte>
|
||||||
|
|
||||||
|
<Carte titre={`Pièces & main-d'œuvre — ${fmt.format(ot.costs.total)} MAD`}>
|
||||||
|
{ot.costs.parts.map((p) => (
|
||||||
|
<LigneInfo key={p.id} nom={`${p.designation} × ${p.quantity}`} valeur={`${fmt.format(p.total)}`} />
|
||||||
|
))}
|
||||||
|
{ot.costs.labor.map((l) => (
|
||||||
|
<LigneInfo
|
||||||
|
key={l.id}
|
||||||
|
nom={`${l.displayName} · ${Math.floor(l.minutes / 60)} h ${String(l.minutes % 60).padStart(2, '0')} × ${fmt.format(l.hourlyRate)}/h`}
|
||||||
|
valeur={`${fmt.format(l.total)}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{!ot.costs.parts.length && !ot.costs.labor.length ? (
|
||||||
|
<Text style={{ color: t.encre3, fontFamily: 'Manrope_400Regular', fontSize: 12.5 }}>
|
||||||
|
Aucune consommation ni temps saisi. (Saisies pièces/temps : depuis le web en R4.2.)
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Carte>
|
||||||
|
|
||||||
|
{ot.checklist.length ? (
|
||||||
|
<Carte titre={`Checklist liée — ${grille.faits}/${grille.total}`}>
|
||||||
|
<BoutonTel
|
||||||
|
libelle={grille.faits === grille.total ? 'Grille complète ✓ — revoir' : 'Cocher la grille'}
|
||||||
|
variante="contour"
|
||||||
|
surAppui={() => router.push(`/ot/${ot.id}/grille`)}
|
||||||
|
/>
|
||||||
|
</Carte>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{ot.closureBlockers.length && (peutCloturer || ot.status === 'IN_PROGRESS') ? (
|
||||||
|
<Text style={{ color: t.alerte, fontFamily: 'Manrope_600SemiBold', fontSize: 12 }}>
|
||||||
|
⚠ {ot.closureBlockers.join(' · ')}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{peutDemarrer ? (
|
||||||
|
<BoutonTel
|
||||||
|
libelle={ot.status === 'ON_HOLD' ? 'Reprendre' : 'Démarrer'}
|
||||||
|
surAppui={demarrer}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{peutCloturer ? (
|
||||||
|
<BoutonTel
|
||||||
|
libelle="Clôturer l'intervention"
|
||||||
|
variante="vert"
|
||||||
|
surAppui={() => router.push(`/ot/${ot.id}/cloture`)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{peutSuspendre ? (
|
||||||
|
<BoutonTel
|
||||||
|
libelle="Mettre en attente"
|
||||||
|
variante="contour"
|
||||||
|
surAppui={() => enfilerTransition(queryClient, ot, 'ON_HOLD')}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<CartePhotos ot={ot} />
|
||||||
|
|
||||||
|
<Carte titre="Activité">
|
||||||
|
{ot.events.slice(0, 5).map((e) => (
|
||||||
|
<View key={e.id} style={{ gap: 1 }}>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 12, color: t.encre }}>
|
||||||
|
{e.kind}
|
||||||
|
{e.message ? ` — ${e.message}` : ''}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 11, color: t.encre3 }}>
|
||||||
|
{new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium', timeStyle: 'short' }).format(
|
||||||
|
new Date(e.createdAt),
|
||||||
|
)}
|
||||||
|
{e.by ? ` · ${e.by.displayName}` : ''}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</Carte>
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
BIN
apps/mobile/assets/android-icon-background.png
Normal file
BIN
apps/mobile/assets/android-icon-background.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
apps/mobile/assets/android-icon-foreground.png
Normal file
BIN
apps/mobile/assets/android-icon-foreground.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 77 KiB |
BIN
apps/mobile/assets/android-icon-monochrome.png
Normal file
BIN
apps/mobile/assets/android-icon-monochrome.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.0 KiB |
BIN
apps/mobile/assets/favicon.png
Normal file
BIN
apps/mobile/assets/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
BIN
apps/mobile/assets/icon.png
Normal file
BIN
apps/mobile/assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 384 KiB |
BIN
apps/mobile/assets/splash-icon.png
Normal file
BIN
apps/mobile/assets/splash-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
11
apps/mobile/jest.config.js
Normal file
11
apps/mobile/jest.config.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
/** jest-expo (convention CLAUDE.md) — R4.1 teste la logique pure (tri de
|
||||||
|
* Ma journée, tokens bi-thème) ; les parcours complets passeront par la
|
||||||
|
* recette sur appareil. */
|
||||||
|
module.exports = {
|
||||||
|
preset: 'jest-expo',
|
||||||
|
setupFiles: ['./jest.setup.js'],
|
||||||
|
moduleNameMapper: {
|
||||||
|
'^@/(.*)$': '<rootDir>/src/$1',
|
||||||
|
},
|
||||||
|
testMatch: ['**/*.test.ts'],
|
||||||
|
};
|
||||||
4
apps/mobile/jest.setup.js
Normal file
4
apps/mobile/jest.setup.js
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
/* AsyncStorage : mock officiel du paquet (pas de natif sous jest). */
|
||||||
|
jest.mock('@react-native-async-storage/async-storage', () =>
|
||||||
|
require('@react-native-async-storage/async-storage/jest/async-storage-mock'),
|
||||||
|
);
|
||||||
51
apps/mobile/package.json
Normal file
51
apps/mobile/package.json
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"name": "@siop/mobile",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"main": "expo-router/entry",
|
||||||
|
"dependencies": {
|
||||||
|
"@expo-google-fonts/manrope": "^0.4.2",
|
||||||
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
|
"@react-native-community/netinfo": "12.0.1",
|
||||||
|
"@siop/shared": "workspace:*",
|
||||||
|
"@tanstack/query-async-storage-persister": "^5.101.2",
|
||||||
|
"@tanstack/react-query": "^5.101.2",
|
||||||
|
"@tanstack/react-query-persist-client": "^5.101.2",
|
||||||
|
"expo": "~57.0.6",
|
||||||
|
"expo-camera": "~57.0.3",
|
||||||
|
"expo-constants": "~57.0.5",
|
||||||
|
"expo-font": "~57.0.1",
|
||||||
|
"expo-image-manipulator": "~57.0.4",
|
||||||
|
"expo-image-picker": "~57.0.4",
|
||||||
|
"expo-linking": "~57.0.3",
|
||||||
|
"expo-router": "~57.0.6",
|
||||||
|
"expo-secure-store": "~57.0.1",
|
||||||
|
"expo-status-bar": "~57.0.1",
|
||||||
|
"openapi-fetch": "^0.13.8",
|
||||||
|
"react": "19.2.3",
|
||||||
|
"react-dom": "19.2.3",
|
||||||
|
"react-native": "0.86.0",
|
||||||
|
"react-native-safe-area-context": "~5.7.0",
|
||||||
|
"react-native-screens": "4.25.2",
|
||||||
|
"react-native-web": "^0.21.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/jest": "^29.5.14",
|
||||||
|
"@types/react": "~19.2.2",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"jest-expo": "^57.0.2",
|
||||||
|
"openapi-typescript": "^7.13.0",
|
||||||
|
"react-test-renderer": "^19.2.7",
|
||||||
|
"typescript": "~6.0.3"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "expo start",
|
||||||
|
"android": "expo start --android",
|
||||||
|
"ios": "expo start --ios",
|
||||||
|
"web": "expo start --web",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
|
"test": "jest",
|
||||||
|
"generate:client": "openapi-typescript ../../docs/openapi.json -o src/api/schema.d.ts",
|
||||||
|
"lint": "eslint app src"
|
||||||
|
},
|
||||||
|
"private": true
|
||||||
|
}
|
||||||
31
apps/mobile/src/api/client.ts
Normal file
31
apps/mobile/src/api/client.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import createClient from 'openapi-fetch';
|
||||||
|
import type { paths } from './schema';
|
||||||
|
import { lireJeton } from './jeton';
|
||||||
|
|
||||||
|
/** Client typé — généré depuis docs/openapi.json (règle d'or ADR-001 :
|
||||||
|
* spec committée, clients régénérés dans le même commit).
|
||||||
|
* EXPO_PUBLIC_API_URL pointe l'API (IP LAN pour Expo Go sur téléphone). */
|
||||||
|
export const API_URL = process.env.EXPO_PUBLIC_API_URL ?? 'http://localhost:3000';
|
||||||
|
|
||||||
|
export const api = createClient<paths>({ baseUrl: API_URL });
|
||||||
|
|
||||||
|
api.use({
|
||||||
|
async onRequest({ request }) {
|
||||||
|
const jeton = await lireJeton();
|
||||||
|
if (jeton) request.headers.set('Authorization', `Bearer ${jeton}`);
|
||||||
|
return request;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function unwrap<T>(res: {
|
||||||
|
data?: T;
|
||||||
|
error?: unknown;
|
||||||
|
response: Response;
|
||||||
|
}): Promise<T> {
|
||||||
|
if (res.data !== undefined) return res.data;
|
||||||
|
const message =
|
||||||
|
res.error && typeof res.error === 'object' && 'message' in res.error
|
||||||
|
? String((res.error as { message: unknown }).message)
|
||||||
|
: `API injoignable ou refus (${res.response.status})`;
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
112
apps/mobile/src/api/exploitation.ts
Normal file
112
apps/mobile/src/api/exploitation.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import type { ChecklistState, ReportUpsert, WorkOrderStatus } from '@siop/shared';
|
||||||
|
import { api, unwrap } from './client';
|
||||||
|
|
||||||
|
/** Hooks R4.2 — mêmes opérations que le web (contrat unique). Les écritures
|
||||||
|
* restent EN LIGNE dans cette release ; la mise en file arrive en R4.3 (D1). */
|
||||||
|
|
||||||
|
export function useWorkOrders() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['work-orders'],
|
||||||
|
queryFn: async () => (await unwrap(await api.GET('/work-orders'))).workOrders,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWorkOrder(id: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['work-orders', id],
|
||||||
|
queryFn: async () =>
|
||||||
|
unwrap(await api.GET('/work-orders/{id}', { params: { path: { id } } })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAssets() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['assets'],
|
||||||
|
queryFn: async () => (await unwrap(await api.GET('/assets'))).assets,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAsset(id: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['assets', id],
|
||||||
|
queryFn: async () => unwrap(await api.GET('/assets/{id}', { params: { path: { id } } })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDocumentsOT(workOrderId: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['documents', workOrderId],
|
||||||
|
queryFn: async () =>
|
||||||
|
(
|
||||||
|
await unwrap(
|
||||||
|
await api.GET('/documents', { params: { query: { workOrderId } } }),
|
||||||
|
)
|
||||||
|
).documents,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useReferenceValues() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['reference-values'],
|
||||||
|
staleTime: 3600_000, // référentiels administrables : stables en journée
|
||||||
|
queryFn: async () => (await unwrap(await api.GET('/reference-values'))).referenceValues,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function useInvalideOT(id: string) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return () =>
|
||||||
|
Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['work-orders', id] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['work-orders'] }),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTransition(id: string) {
|
||||||
|
const invalide = useInvalideOT(id);
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (input: { to: WorkOrderStatus; comment?: string }) =>
|
||||||
|
unwrap(
|
||||||
|
await api.POST('/work-orders/{id}/transition', {
|
||||||
|
params: { path: { id } },
|
||||||
|
body: input,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
onSuccess: invalide,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCocheChecklist(otId: string) {
|
||||||
|
const invalide = useInvalideOT(otId);
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (input: { itemId: string; state: ChecklistState }) =>
|
||||||
|
unwrap(
|
||||||
|
await api.PATCH('/work-orders/{id}/checklist/{itemId}', {
|
||||||
|
params: { path: { id: otId, itemId: input.itemId } },
|
||||||
|
body: { state: input.state },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
onSuccess: invalide,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Suggestion R5 (D1) : l'IA est un service SERVEUR — la suggestion demande le
|
||||||
|
* réseau, la clôture en file R4 fonctionne sans elle. */
|
||||||
|
export function useSuggestionBilan() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (description: string) =>
|
||||||
|
unwrap(await api.POST('/assistant/suggest-bilan', { body: { description } })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBilan(otId: string) {
|
||||||
|
const invalide = useInvalideOT(otId);
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: ReportUpsert) =>
|
||||||
|
unwrap(
|
||||||
|
await api.PUT('/work-orders/{id}/report', { params: { path: { id: otId } }, body }),
|
||||||
|
),
|
||||||
|
onSuccess: invalide,
|
||||||
|
});
|
||||||
|
}
|
||||||
22
apps/mobile/src/api/jeton.ts
Normal file
22
apps/mobile/src/api/jeton.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import * as SecureStore from 'expo-secure-store';
|
||||||
|
import { Platform } from 'react-native';
|
||||||
|
|
||||||
|
/** Le jeton vit dans le trousseau du téléphone (SecureStore) ; sur web
|
||||||
|
* (vérifications de dev seulement), repli AsyncStorage. */
|
||||||
|
const CLE = 'siop.jeton';
|
||||||
|
const natif = Platform.OS !== 'web';
|
||||||
|
|
||||||
|
export async function lireJeton(): Promise<string | null> {
|
||||||
|
return natif ? SecureStore.getItemAsync(CLE) : AsyncStorage.getItem(CLE);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ecrireJeton(jeton: string): Promise<void> {
|
||||||
|
if (natif) await SecureStore.setItemAsync(CLE, jeton);
|
||||||
|
else await AsyncStorage.setItem(CLE, jeton);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function effacerJeton(): Promise<void> {
|
||||||
|
if (natif) await SecureStore.deleteItemAsync(CLE);
|
||||||
|
else await AsyncStorage.removeItem(CLE);
|
||||||
|
}
|
||||||
4363
apps/mobile/src/api/schema.d.ts
vendored
Normal file
4363
apps/mobile/src/api/schema.d.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
83
apps/mobile/src/auth/session.tsx
Normal file
83
apps/mobile/src/auth/session.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { createContext, useContext, type ReactNode } from 'react';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import { api, unwrap } from '@/api/client';
|
||||||
|
import { ecrireJeton, effacerJeton } from '@/api/jeton';
|
||||||
|
import { viderFile } from '@/file/store';
|
||||||
|
|
||||||
|
/** Session mobile — mêmes règles que le web : le sélecteur démo n'existe
|
||||||
|
* que si l'API répond (ADR-002 : 404 sinon, une seule source de vérité). */
|
||||||
|
|
||||||
|
export function useMe() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['me'],
|
||||||
|
retry: false,
|
||||||
|
queryFn: async () => unwrap(await api.GET('/users/me')),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDemoAccounts() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['demo-accounts'],
|
||||||
|
retry: false,
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await api.GET('/auth/demo-accounts');
|
||||||
|
if (res.response.status === 404) return null; // démo désactivée
|
||||||
|
return (await unwrap(res)).accounts;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function useApresConnexion() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return async (accessToken: string) => {
|
||||||
|
await ecrireJeton(accessToken);
|
||||||
|
await queryClient.invalidateQueries();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLogin() {
|
||||||
|
const apres = useApresConnexion();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (input: { email: string; password: string }) => {
|
||||||
|
const res = await unwrap(await api.POST('/auth/login', { body: input }));
|
||||||
|
await apres(res.accessToken);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDemoLogin() {
|
||||||
|
const apres = useApresConnexion();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (userId: string) => {
|
||||||
|
const res = await unwrap(await api.POST('/auth/demo-login', { body: { userId } }));
|
||||||
|
await apres(res.accessToken);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLogout() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return async () => {
|
||||||
|
// Rien ne survit à la session sur l'appareil : jeton (trousseau), file
|
||||||
|
// d'écriture, cache OT persisté (loi 09-08 — minimisation).
|
||||||
|
await effacerJeton();
|
||||||
|
viderFile();
|
||||||
|
queryClient.clear();
|
||||||
|
await AsyncStorage.removeItem('siop.cache');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drapeau hors-ligne partagé (pastille topbar + bandeaux d'écrans). */
|
||||||
|
export const HorsLigneContexte = createContext(false);
|
||||||
|
export const useHorsLigne = () => useContext(HorsLigneContexte);
|
||||||
|
|
||||||
|
export function HorsLigneProvider({
|
||||||
|
valeur,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
valeur: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return <HorsLigneContexte.Provider value={valeur}>{children}</HorsLigneContexte.Provider>;
|
||||||
|
}
|
||||||
40
apps/mobile/src/composants/a-venir.tsx
Normal file
40
apps/mobile/src/composants/a-venir.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { Text, View } from 'react-native';
|
||||||
|
import { useTokens } from '@/theme/tokens';
|
||||||
|
|
||||||
|
/** Écran futur du périmètre R4 annoncé — même patron que la sidebar web :
|
||||||
|
* visible, honnête sur sa release, jamais un cul-de-sac silencieux. */
|
||||||
|
export function AVenir({ titre, release, detail }: { titre: string; release: string; detail: string }) {
|
||||||
|
const t = useTokens();
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', padding: 32, gap: 8 }}>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 18, color: t.encre }}>
|
||||||
|
{titre}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Manrope_700Bold',
|
||||||
|
fontSize: 11,
|
||||||
|
color: t.safran,
|
||||||
|
backgroundColor: t.safranDoux,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 3,
|
||||||
|
borderRadius: 999,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Disponible en {release}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Manrope_400Regular',
|
||||||
|
fontSize: 13,
|
||||||
|
color: t.encre2,
|
||||||
|
textAlign: 'center',
|
||||||
|
maxWidth: 280,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{detail}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
apps/mobile/src/composants/carte-photos.tsx
Normal file
98
apps/mobile/src/composants/carte-photos.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import * as ImageManipulator from 'expo-image-manipulator';
|
||||||
|
import * as ImagePicker from 'expo-image-picker';
|
||||||
|
import { Alert, Pressable, Text, View } from 'react-native';
|
||||||
|
import type { WorkOrderDetail } from '@siop/shared';
|
||||||
|
import { useDocumentsOT } from '@/api/exploitation';
|
||||||
|
import { Carte } from '@/composants/ui';
|
||||||
|
import { enfilerPhoto } from '@/file/actions';
|
||||||
|
import { useFile } from '@/file/store';
|
||||||
|
import { useTokens } from '@/theme/tokens';
|
||||||
|
|
||||||
|
/** Photos d'intervention (D5) : compressées côté app (~1 600 px) puis mises
|
||||||
|
* EN FILE comme le reste — rattachées à l'OT dans la bibliothèque R3.
|
||||||
|
* Ni audio ni géolocalisation en R4 (loi 09-08, minimisation). */
|
||||||
|
export function CartePhotos({ ot }: { ot: WorkOrderDetail }) {
|
||||||
|
const t = useTokens();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const file = useFile();
|
||||||
|
const { data: documents } = useDocumentsOT(ot.id);
|
||||||
|
|
||||||
|
const photosServeur = (documents ?? []).filter((d) => d.contentType.startsWith('image/'));
|
||||||
|
const photosEnFile = file.filter((s) => s.otId === ot.id && s.type === 'PHOTO');
|
||||||
|
|
||||||
|
const prendre = async () => {
|
||||||
|
const resultat = await ImagePicker.launchImageLibraryAsync({
|
||||||
|
mediaTypes: ['images'],
|
||||||
|
quality: 0.9,
|
||||||
|
});
|
||||||
|
const image = resultat.assets?.[0];
|
||||||
|
if (!image) return;
|
||||||
|
try {
|
||||||
|
// Compression D5 : jamais un original de 12 Mo dans la file.
|
||||||
|
const contexte = ImageManipulator.ImageManipulator.manipulate(image.uri);
|
||||||
|
if (image.width > 1600) contexte.resize({ width: 1600 });
|
||||||
|
const rendu = await contexte.renderAsync();
|
||||||
|
const sauve = await rendu.saveAsync({
|
||||||
|
compress: 0.7,
|
||||||
|
format: ImageManipulator.SaveFormat.JPEG,
|
||||||
|
});
|
||||||
|
enfilerPhoto(queryClient, ot, {
|
||||||
|
uri: sauve.uri,
|
||||||
|
nom: `intervention-${ot.reference}-${Date.now().toString(36)}.jpg`,
|
||||||
|
mime: 'image/jpeg',
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
Alert.alert('Photo impossible', e instanceof Error ? e.message : 'Erreur inconnue');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Carte titre={`Photos (${photosServeur.length + photosEnFile.length})`}>
|
||||||
|
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
|
||||||
|
{photosServeur.map((d) => (
|
||||||
|
<View key={d.id} style={vignette(t.surface2, t.bordure)}>
|
||||||
|
<Text style={{ fontSize: 18 }}>🖼</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
{photosEnFile.map((s) => (
|
||||||
|
<View key={s.id} style={vignette(t.stAttenteFond, t.stAttente)}>
|
||||||
|
<Text style={{ fontSize: 18 }}>🖼</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 2,
|
||||||
|
fontFamily: 'Manrope_800ExtraBold',
|
||||||
|
fontSize: 8,
|
||||||
|
color: t.stAttente,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
en file
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Ajouter une photo"
|
||||||
|
onPress={() => void prendre()}
|
||||||
|
style={vignette('transparent', t.bordureForte, true)}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 22, color: t.primaire }}>+</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</Carte>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const vignette = (fond: string, bordure: string, pointille = false) =>
|
||||||
|
({
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
borderRadius: 9,
|
||||||
|
backgroundColor: fond,
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderColor: bordure,
|
||||||
|
borderStyle: pointille ? ('dashed' as const) : ('solid' as const),
|
||||||
|
alignItems: 'center' as const,
|
||||||
|
justifyContent: 'center' as const,
|
||||||
|
}) as const;
|
||||||
265
apps/mobile/src/composants/ui.tsx
Normal file
265
apps/mobile/src/composants/ui.tsx
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
import { router } from 'expo-router';
|
||||||
|
import { useState, type ReactNode } from 'react';
|
||||||
|
import { Modal, Pressable, ScrollView, Text, View } from 'react-native';
|
||||||
|
import { WORK_ORDER_STATUS_LABELS, type WorkOrderStatus } from '@siop/shared';
|
||||||
|
import { useTokens, type Tokens } from '@/theme/tokens';
|
||||||
|
|
||||||
|
/** Briques d'écran de la maquette R4 — cartes, chips, boutons, sélecteur. */
|
||||||
|
|
||||||
|
export function Carte({ titre, children }: { titre?: string; children: ReactNode }) {
|
||||||
|
const t = useTokens();
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: t.surface,
|
||||||
|
borderColor: t.bordure,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 12,
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{titre ? (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Manrope_700Bold',
|
||||||
|
fontSize: 10.5,
|
||||||
|
letterSpacing: 0.8,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: t.encre2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{titre}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LigneInfo({ nom, valeur }: { nom: string; valeur: ReactNode }) {
|
||||||
|
const t = useTokens();
|
||||||
|
return (
|
||||||
|
<View style={{ flexDirection: 'row', justifyContent: 'space-between', gap: 10 }}>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 12.5, color: t.encre2 }}>
|
||||||
|
{nom}
|
||||||
|
</Text>
|
||||||
|
{typeof valeur === 'string' ? (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Manrope_700Bold',
|
||||||
|
fontSize: 12.5,
|
||||||
|
color: t.encre,
|
||||||
|
flexShrink: 1,
|
||||||
|
textAlign: 'right',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{valeur}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
valeur
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const STYLE_STATUT: Record<WorkOrderStatus, (t: Tokens) => [string, string]> = {
|
||||||
|
OPEN: (t) => [t.stOuvert, t.stOuvertFond],
|
||||||
|
IN_PROGRESS: (t) => [t.stEncours, t.stEncoursFond],
|
||||||
|
ON_HOLD: (t) => [t.stAttente, t.stAttenteFond],
|
||||||
|
DONE: (t) => [t.stTermine, t.stTermineFond],
|
||||||
|
CANCELLED: (t) => [t.stAnnule, t.stAnnuleFond],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ChipStatut({ statut }: { statut: WorkOrderStatus }) {
|
||||||
|
const t = useTokens();
|
||||||
|
const [encre, fond] = STYLE_STATUT[statut](t);
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Manrope_700Bold',
|
||||||
|
fontSize: 10.5,
|
||||||
|
color: encre,
|
||||||
|
backgroundColor: fond,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 999,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{WORK_ORDER_STATUS_LABELS[statut]}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BoutonTel({
|
||||||
|
libelle,
|
||||||
|
variante = 'prim',
|
||||||
|
desactive,
|
||||||
|
surAppui,
|
||||||
|
}: {
|
||||||
|
libelle: string;
|
||||||
|
variante?: 'prim' | 'vert' | 'contour' | 'gris';
|
||||||
|
desactive?: boolean;
|
||||||
|
surAppui: () => void;
|
||||||
|
}) {
|
||||||
|
const t = useTokens();
|
||||||
|
const fonds = { prim: t.primaire, vert: t.succes, contour: t.surface, gris: t.bordureForte };
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
disabled={desactive}
|
||||||
|
onPress={surAppui}
|
||||||
|
style={{
|
||||||
|
backgroundColor: desactive ? t.bordureForte : fonds[variante],
|
||||||
|
borderWidth: variante === 'contour' ? 1.5 : 0,
|
||||||
|
borderColor: t.primaire,
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 12,
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Manrope_700Bold',
|
||||||
|
fontSize: 14,
|
||||||
|
color: variante === 'contour' && !desactive ? t.primaire : '#fff',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{libelle}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EnteteFiche({ titre, apres }: { titre: string; apres?: ReactNode }) {
|
||||||
|
const t = useTokens();
|
||||||
|
return (
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Retour"
|
||||||
|
onPress={() => (router.canGoBack() ? router.back() : router.replace('/(tabs)/journee'))}
|
||||||
|
hitSlop={10}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 22, color: t.primaire, fontFamily: 'Manrope_700Bold' }}>‹</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 17, color: t.encre, flex: 1 }}
|
||||||
|
>
|
||||||
|
{titre}
|
||||||
|
</Text>
|
||||||
|
{apres}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sélecteur au pouce (RN n'a pas de <select>) : un champ qui ouvre une
|
||||||
|
* liste plein écran — utilisé par le bilan codé. */
|
||||||
|
export function ChoixTel({
|
||||||
|
libelle,
|
||||||
|
requis,
|
||||||
|
valeur,
|
||||||
|
options,
|
||||||
|
surChoix,
|
||||||
|
}: {
|
||||||
|
libelle: string;
|
||||||
|
requis?: boolean;
|
||||||
|
valeur: { id: string; label: string } | null;
|
||||||
|
options: { id: string; label: string }[];
|
||||||
|
surChoix: (id: string | null) => void;
|
||||||
|
}) {
|
||||||
|
const t = useTokens();
|
||||||
|
const [ouvert, setOuvert] = useState(false);
|
||||||
|
return (
|
||||||
|
<View style={{ gap: 4, flex: 1 }}>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 11, color: t.encre2 }}>
|
||||||
|
{libelle} {requis ? <Text style={{ color: t.danger }}>*</Text> : null}
|
||||||
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={libelle}
|
||||||
|
onPress={() => setOuvert(true)}
|
||||||
|
style={{
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderColor: t.bordureForte,
|
||||||
|
borderRadius: 9,
|
||||||
|
backgroundColor: t.surface,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 9,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Manrope_600SemiBold',
|
||||||
|
fontSize: 13,
|
||||||
|
color: valeur ? t.encre : t.encre3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{valeur ? valeur.label : 'Sélectionner…'} ▾
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Modal visible={ouvert} animationType="slide" transparent onRequestClose={() => setOuvert(false)}>
|
||||||
|
<Pressable
|
||||||
|
style={{ flex: 1, backgroundColor: 'rgba(9,14,22,.55)' }}
|
||||||
|
onPress={() => setOuvert(false)}
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: t.surface,
|
||||||
|
borderTopLeftRadius: 16,
|
||||||
|
borderTopRightRadius: 16,
|
||||||
|
maxHeight: '70%',
|
||||||
|
padding: 14,
|
||||||
|
gap: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 15, color: t.encre }}>
|
||||||
|
{libelle}
|
||||||
|
</Text>
|
||||||
|
<ScrollView>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={() => {
|
||||||
|
surChoix(null);
|
||||||
|
setOuvert(false);
|
||||||
|
}}
|
||||||
|
style={{ paddingVertical: 11 }}
|
||||||
|
>
|
||||||
|
<Text style={{ fontFamily: 'Manrope_600SemiBold', fontSize: 14, color: t.encre3 }}>
|
||||||
|
— (laisser vide)
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
{options.map((o) => (
|
||||||
|
<Pressable
|
||||||
|
key={o.id}
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={() => {
|
||||||
|
surChoix(o.id);
|
||||||
|
setOuvert(false);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
paddingVertical: 11,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: t.bordure,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Manrope_600SemiBold',
|
||||||
|
fontSize: 14,
|
||||||
|
color: o.id === valeur?.id ? t.primaire : t.encre,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{o.label} {o.id === valeur?.id ? '✓' : ''}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
125
apps/mobile/src/file/actions.ts
Normal file
125
apps/mobile/src/file/actions.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import type { QueryClient } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
WORK_ORDER_STATUS_LABELS,
|
||||||
|
WORK_ORDER_TRANSITIONS,
|
||||||
|
type ChecklistState,
|
||||||
|
type ReportUpsert,
|
||||||
|
type WorkOrderDetail,
|
||||||
|
type WorkOrderStatus,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import { enfiler, lireFile } from './store';
|
||||||
|
import { rejouer } from './synchro';
|
||||||
|
|
||||||
|
/** Chaque geste terrain = une saisie en file + un patch OPTIMISTE du cache
|
||||||
|
* (le technicien voit son travail tout de suite, même en mode avion — le
|
||||||
|
* cache persisté garde ce patch après redémarrage). En ligne, la file se
|
||||||
|
* vide dans la foulée : le comportement R4.2 est conservé. */
|
||||||
|
|
||||||
|
function patchDetail(
|
||||||
|
queryClient: QueryClient,
|
||||||
|
otId: string,
|
||||||
|
patch: (ot: WorkOrderDetail) => WorkOrderDetail,
|
||||||
|
): void {
|
||||||
|
queryClient.setQueryData<WorkOrderDetail>(['work-orders', otId], (courant) =>
|
||||||
|
courant ? patch(courant) : courant,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enfilerTransition(
|
||||||
|
queryClient: QueryClient,
|
||||||
|
ot: WorkOrderDetail,
|
||||||
|
to: WorkOrderStatus,
|
||||||
|
): void {
|
||||||
|
enfiler({
|
||||||
|
type: 'TRANSITION',
|
||||||
|
otId: ot.id,
|
||||||
|
otReference: ot.reference,
|
||||||
|
libelle:
|
||||||
|
to === 'DONE' ? 'Clôture de l’intervention' : `Passage à « ${WORK_ORDER_STATUS_LABELS[to]} »`,
|
||||||
|
baseUpdatedAt: ot.updatedAt,
|
||||||
|
payload: { to },
|
||||||
|
});
|
||||||
|
patchDetail(queryClient, ot.id, (c) => ({
|
||||||
|
...c,
|
||||||
|
status: to,
|
||||||
|
allowedTransitions: WORK_ORDER_TRANSITIONS[to],
|
||||||
|
}));
|
||||||
|
void rejouer(queryClient);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enfilerCoche(
|
||||||
|
queryClient: QueryClient,
|
||||||
|
ot: WorkOrderDetail,
|
||||||
|
item: { id: string; label: string },
|
||||||
|
state: ChecklistState,
|
||||||
|
): void {
|
||||||
|
enfiler({
|
||||||
|
type: 'COCHE',
|
||||||
|
otId: ot.id,
|
||||||
|
otReference: ot.reference,
|
||||||
|
libelle: `Coche « ${item.label} » → ${state === 'DONE' ? 'fait' : state === 'NA' ? 'N/A' : 'à faire'}`,
|
||||||
|
baseUpdatedAt: ot.updatedAt,
|
||||||
|
payload: { itemId: item.id, state },
|
||||||
|
});
|
||||||
|
patchDetail(queryClient, ot.id, (c) => ({
|
||||||
|
...c,
|
||||||
|
checklist: c.checklist.map((i) => (i.id === item.id ? { ...i, state } : i)),
|
||||||
|
}));
|
||||||
|
void rejouer(queryClient);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enfilerBilan(
|
||||||
|
queryClient: QueryClient,
|
||||||
|
ot: WorkOrderDetail,
|
||||||
|
corps: Omit<ReportUpsert, 'baseUpdatedAt'>,
|
||||||
|
labels: Partial<Record<keyof ReportUpsert, { id: string; label: string } | null>>,
|
||||||
|
): void {
|
||||||
|
enfiler({
|
||||||
|
type: 'BILAN',
|
||||||
|
otId: ot.id,
|
||||||
|
otReference: ot.reference,
|
||||||
|
libelle: 'Bilan d’intervention codé',
|
||||||
|
baseUpdatedAt: ot.updatedAt,
|
||||||
|
payload: corps,
|
||||||
|
});
|
||||||
|
patchDetail(queryClient, ot.id, (c) => ({
|
||||||
|
...c,
|
||||||
|
report: {
|
||||||
|
note: c.report?.note ?? null,
|
||||||
|
doorState: labels.doorStateId !== undefined ? (labels.doorStateId ?? null) : (c.report?.doorState ?? null),
|
||||||
|
cabinPosition:
|
||||||
|
labels.cabinPositionId !== undefined ? (labels.cabinPositionId ?? null) : (c.report?.cabinPosition ?? null),
|
||||||
|
anomaly: labels.anomalyId !== undefined ? (labels.anomalyId ?? null) : (c.report?.anomaly ?? null),
|
||||||
|
externalCause:
|
||||||
|
labels.externalCauseId !== undefined ? (labels.externalCauseId ?? null) : (c.report?.externalCause ?? null),
|
||||||
|
actionTaken:
|
||||||
|
labels.actionTakenId !== undefined ? (labels.actionTakenId ?? null) : (c.report?.actionTaken ?? null),
|
||||||
|
componentConcerned:
|
||||||
|
labels.componentConcernedId !== undefined
|
||||||
|
? (labels.componentConcernedId ?? null)
|
||||||
|
: (c.report?.componentConcerned ?? null),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
void rejouer(queryClient);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enfilerPhoto(
|
||||||
|
queryClient: QueryClient,
|
||||||
|
ot: WorkOrderDetail,
|
||||||
|
fichier: { uri: string; nom: string; mime: string },
|
||||||
|
): void {
|
||||||
|
enfiler({
|
||||||
|
type: 'PHOTO',
|
||||||
|
otId: ot.id,
|
||||||
|
otReference: ot.reference,
|
||||||
|
libelle: `Photo — ${fichier.nom}`,
|
||||||
|
baseUpdatedAt: ot.updatedAt,
|
||||||
|
payload: fichier,
|
||||||
|
});
|
||||||
|
void rejouer(queryClient);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ce que la file porte encore pour cet OT — chips « en file » des écrans. */
|
||||||
|
export function saisiesPour(otId: string) {
|
||||||
|
return lireFile().filter((s) => s.otId === otId);
|
||||||
|
}
|
||||||
93
apps/mobile/src/file/store.ts
Normal file
93
apps/mobile/src/file/store.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import { useSyncExternalStore } from 'react';
|
||||||
|
import type { ChecklistState, ReportUpsert, WorkOrderStatus } from '@siop/shared';
|
||||||
|
|
||||||
|
/** La file d'écriture (D1) : chaque geste hors-ligne devient une saisie
|
||||||
|
* datée, persistée dans AsyncStorage — elle survit au redémarrage — et
|
||||||
|
* rejouée DANS L'ORDRE au retour du réseau. Chaque saisie porte la version
|
||||||
|
* de l'OT lue au moment du geste (D2, verrou optimiste). */
|
||||||
|
|
||||||
|
export type Saisie = {
|
||||||
|
id: string;
|
||||||
|
creeA: string; // ISO — affiché à l'écran Synchro
|
||||||
|
otId: string;
|
||||||
|
otReference: string;
|
||||||
|
libelle: string; // « Coche “Jeu des coulisseaux” »
|
||||||
|
baseUpdatedAt: string; // version lue (D2)
|
||||||
|
statut: 'EN_ATTENTE' | 'ENVOI' | 'CONFLIT';
|
||||||
|
erreur?: string; // message du 409 / refus métier — montré tel quel
|
||||||
|
} & (
|
||||||
|
| { type: 'TRANSITION'; payload: { to: WorkOrderStatus } }
|
||||||
|
| { type: 'COCHE'; payload: { itemId: string; state: ChecklistState } }
|
||||||
|
| { type: 'BILAN'; payload: Omit<ReportUpsert, 'baseUpdatedAt'> }
|
||||||
|
| { type: 'PHOTO'; payload: { uri: string; nom: string; mime: string } }
|
||||||
|
);
|
||||||
|
|
||||||
|
const CLE = 'siop.file.v1';
|
||||||
|
let file: Saisie[] = [];
|
||||||
|
const abonnes = new Set<() => void>();
|
||||||
|
|
||||||
|
function notifier(): void {
|
||||||
|
for (const a of abonnes) a();
|
||||||
|
void AsyncStorage.setItem(CLE, JSON.stringify(file));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** À l'ouverture de l'app : la file survit au redémarrage (D1). */
|
||||||
|
export async function chargerFile(): Promise<void> {
|
||||||
|
const brut = await AsyncStorage.getItem(CLE);
|
||||||
|
if (brut) {
|
||||||
|
// Un ENVOI interrompu par un crash redevient EN_ATTENTE (rejouable).
|
||||||
|
file = (JSON.parse(brut) as Saisie[]).map((s) =>
|
||||||
|
s.statut === 'ENVOI' ? { ...s, statut: 'EN_ATTENTE' } : s,
|
||||||
|
);
|
||||||
|
for (const a of abonnes) a();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const lireFile = (): Saisie[] => file;
|
||||||
|
|
||||||
|
export function enfiler(saisie: Omit<Saisie, 'id' | 'creeA' | 'statut'>): Saisie {
|
||||||
|
const complete = {
|
||||||
|
...saisie,
|
||||||
|
id: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
creeA: new Date().toISOString(),
|
||||||
|
statut: 'EN_ATTENTE',
|
||||||
|
} as Saisie;
|
||||||
|
file = [...file, complete];
|
||||||
|
notifier();
|
||||||
|
return complete;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function majSaisie(id: string, patch: Partial<Pick<Saisie, 'statut' | 'erreur' | 'baseUpdatedAt'>>): void {
|
||||||
|
file = file.map((s) => (s.id === id ? ({ ...s, ...patch } as Saisie) : s));
|
||||||
|
notifier();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function retirer(id: string): void {
|
||||||
|
file = file.filter((s) => s.id !== id);
|
||||||
|
notifier();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFile(): Saisie[] {
|
||||||
|
return useSyncExternalStore(
|
||||||
|
(cb) => {
|
||||||
|
abonnes.add(cb);
|
||||||
|
return () => abonnes.delete(cb);
|
||||||
|
},
|
||||||
|
lireFile,
|
||||||
|
lireFile,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Déconnexion : la file appartient à la SESSION — on la purge (sécurité :
|
||||||
|
* un autre compte sur le même téléphone ne doit jamais rejouer les saisies
|
||||||
|
* du précédent), avec sa persistance. */
|
||||||
|
export function viderFile(): void {
|
||||||
|
file = [];
|
||||||
|
notifier();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Réservé aux tests : repartir d'une file vide (sans persistance). */
|
||||||
|
export function _viderPourTests(): void {
|
||||||
|
file = [];
|
||||||
|
}
|
||||||
93
apps/mobile/src/file/synchro.test.ts
Normal file
93
apps/mobile/src/file/synchro.test.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { QueryClient } from '@tanstack/react-query';
|
||||||
|
import { enfiler, lireFile, retirer, _viderPourTests, type Saisie } from './store';
|
||||||
|
import { rejouer, type ResultatEnvoi } from './synchro';
|
||||||
|
|
||||||
|
/** Le contrat D1/D2 de la file, testé sans réseau : rejeu DANS L'ORDRE,
|
||||||
|
* arrêt sur conflit (l'humain tranche), reprise après coupure. */
|
||||||
|
|
||||||
|
const qc = new QueryClient();
|
||||||
|
|
||||||
|
const saisie = (libelle: string): Saisie =>
|
||||||
|
enfiler({
|
||||||
|
type: 'TRANSITION',
|
||||||
|
otId: 'ot-1',
|
||||||
|
otReference: 'OT-TEST',
|
||||||
|
libelle,
|
||||||
|
baseUpdatedAt: '2026-07-17T10:00:00.000Z',
|
||||||
|
payload: { to: 'IN_PROGRESS' },
|
||||||
|
}) && lireFile().at(-1)!;
|
||||||
|
|
||||||
|
beforeEach(() => _viderPourTests());
|
||||||
|
|
||||||
|
describe('rejouer (file D1/D2)', () => {
|
||||||
|
it('rejoue dans l’ordre et vide la file quand tout passe', async () => {
|
||||||
|
saisie('a');
|
||||||
|
saisie('b');
|
||||||
|
const envoyes: string[] = [];
|
||||||
|
await rejouer(qc, async (s) => {
|
||||||
|
envoyes.push(s.libelle);
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
expect(envoyes).toEqual(['a', 'b']);
|
||||||
|
expect(lireFile()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('conflit : la saisie passe en CONFLIT et la file S’ARRÊTE là — rien n’est perdu', async () => {
|
||||||
|
saisie('a');
|
||||||
|
saisie('b');
|
||||||
|
saisie('c');
|
||||||
|
const envoyer = async (s: Saisie): Promise<ResultatEnvoi> =>
|
||||||
|
s.libelle === 'b'
|
||||||
|
? { ok: false, reseau: false, message: 'Conflit de version : modifié par Salma à 14 h 38' }
|
||||||
|
: { ok: true };
|
||||||
|
await rejouer(qc, envoyer);
|
||||||
|
const file = lireFile();
|
||||||
|
expect(file.map((s) => [s.libelle, s.statut])).toEqual([
|
||||||
|
['b', 'CONFLIT'],
|
||||||
|
['c', 'EN_ATTENTE'], // derrière le conflit : on attend l'humain
|
||||||
|
]);
|
||||||
|
expect(file[0]!.erreur).toContain('Salma');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('coupure réseau : tout reste EN_ATTENTE, rejouable au retour', async () => {
|
||||||
|
saisie('a');
|
||||||
|
saisie('b');
|
||||||
|
await rejouer(qc, async () => ({ ok: false, reseau: true, message: 'Réseau indisponible' }));
|
||||||
|
expect(lireFile().map((s) => s.statut)).toEqual(['EN_ATTENTE', 'EN_ATTENTE']);
|
||||||
|
// le réseau revient
|
||||||
|
await rejouer(qc, async () => ({ ok: true }));
|
||||||
|
expect(lireFile()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('après « abandonner » le conflit, le reste de la file repart', async () => {
|
||||||
|
saisie('a');
|
||||||
|
saisie('b');
|
||||||
|
await rejouer(qc, async (s) =>
|
||||||
|
s.libelle === 'a' ? { ok: false, reseau: false, message: 'refus métier' } : { ok: true },
|
||||||
|
);
|
||||||
|
expect(lireFile()).toHaveLength(2);
|
||||||
|
retirer(lireFile()[0]!.id); // l'humain abandonne la saisie en conflit
|
||||||
|
await rejouer(qc, async () => ({ ok: true }));
|
||||||
|
expect(lireFile()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('propagation de version (nos écritures ne se conflictent pas entre elles)', () => {
|
||||||
|
it('au succès, la version fraîche se propage aux saisies restantes du même OT', async () => {
|
||||||
|
saisie('a');
|
||||||
|
saisie('b');
|
||||||
|
await rejouer(qc, async (s) =>
|
||||||
|
s.libelle === 'a' ? { ok: true, nouvelleVersion: '2026-07-17T11:00:00.000Z' } : { ok: true },
|
||||||
|
);
|
||||||
|
// b a été envoyée après propagation — vérifions via un rejeu espion
|
||||||
|
_viderPourTests();
|
||||||
|
saisie('c');
|
||||||
|
saisie('d');
|
||||||
|
const basesVues: string[] = [];
|
||||||
|
await rejouer(qc, async (s) => {
|
||||||
|
basesVues.push(s.baseUpdatedAt);
|
||||||
|
return { ok: true, nouvelleVersion: 'V-FRAICHE' };
|
||||||
|
});
|
||||||
|
expect(basesVues).toEqual(['2026-07-17T10:00:00.000Z', 'V-FRAICHE']);
|
||||||
|
});
|
||||||
|
});
|
||||||
160
apps/mobile/src/file/synchro.ts
Normal file
160
apps/mobile/src/file/synchro.ts
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import type { QueryClient } from '@tanstack/react-query';
|
||||||
|
import { api } from '@/api/client';
|
||||||
|
import { lireJeton } from '@/api/jeton';
|
||||||
|
import { API_URL } from '@/api/client';
|
||||||
|
import { lireFile, majSaisie, retirer, type Saisie } from './store';
|
||||||
|
|
||||||
|
/** Rejeu de la file (D1/D2) : dans l'ordre, une saisie à la fois.
|
||||||
|
* - succès → la saisie sort de la file ;
|
||||||
|
* - coupure réseau → tout reste EN_ATTENTE, on réessaiera ;
|
||||||
|
* - refus (409 verrou, garde métier) → la saisie passe en CONFLIT et la
|
||||||
|
* file S'ARRÊTE LÀ : l'humain tranche à l'écran Synchro. */
|
||||||
|
|
||||||
|
export type ResultatEnvoi =
|
||||||
|
| { ok: true; nouvelleVersion?: string }
|
||||||
|
| { ok: false; reseau: boolean; message: string };
|
||||||
|
|
||||||
|
export async function envoyerSaisie(s: Saisie): Promise<ResultatEnvoi> {
|
||||||
|
try {
|
||||||
|
let status: number;
|
||||||
|
let message = '';
|
||||||
|
let nouvelleVersion: string | undefined;
|
||||||
|
if (s.type === 'TRANSITION') {
|
||||||
|
const res = await api.POST('/work-orders/{id}/transition', {
|
||||||
|
params: { path: { id: s.otId } },
|
||||||
|
body: { to: s.payload.to, baseUpdatedAt: s.baseUpdatedAt },
|
||||||
|
});
|
||||||
|
status = res.response.status;
|
||||||
|
message = messageDe(res.error);
|
||||||
|
nouvelleVersion = res.data?.updatedAt;
|
||||||
|
} else if (s.type === 'COCHE') {
|
||||||
|
const res = await api.PATCH('/work-orders/{id}/checklist/{itemId}', {
|
||||||
|
params: { path: { id: s.otId, itemId: s.payload.itemId } },
|
||||||
|
body: { state: s.payload.state, baseUpdatedAt: s.baseUpdatedAt },
|
||||||
|
});
|
||||||
|
status = res.response.status;
|
||||||
|
message = messageDe(res.error);
|
||||||
|
} else if (s.type === 'BILAN') {
|
||||||
|
const res = await api.PUT('/work-orders/{id}/report', {
|
||||||
|
params: { path: { id: s.otId } },
|
||||||
|
body: { ...s.payload, baseUpdatedAt: s.baseUpdatedAt },
|
||||||
|
});
|
||||||
|
status = res.response.status;
|
||||||
|
message = messageDe(res.error);
|
||||||
|
nouvelleVersion = res.data?.updatedAt;
|
||||||
|
} else {
|
||||||
|
// PHOTO — multipart hors client typé (D5) : fichier compressé en file.
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('kind', 'PHOTO');
|
||||||
|
form.append('workOrderId', s.otId);
|
||||||
|
if (s.payload.uri.startsWith('http') || s.payload.uri.startsWith('blob:') || s.payload.uri.startsWith('data:')) {
|
||||||
|
const blob = await (await fetch(s.payload.uri)).blob();
|
||||||
|
form.append('file', new File([blob], s.payload.nom, { type: s.payload.mime }));
|
||||||
|
} else {
|
||||||
|
// URI de fichier natif : React Native sait téléverser {uri, name, type}
|
||||||
|
form.append('file', {
|
||||||
|
uri: s.payload.uri,
|
||||||
|
name: s.payload.nom,
|
||||||
|
type: s.payload.mime,
|
||||||
|
} as unknown as Blob);
|
||||||
|
}
|
||||||
|
const jeton = await lireJeton();
|
||||||
|
const res = await fetch(`${API_URL}/documents`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: jeton ? { Authorization: `Bearer ${jeton}` } : undefined,
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
status = res.status;
|
||||||
|
if (!res.ok) {
|
||||||
|
const corps = (await res.json().catch(() => null)) as { message?: string } | null;
|
||||||
|
message = corps?.message ?? `Téléversement refusé (${res.status})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (status < 400) {
|
||||||
|
// Coche/photo ne renvoient pas le détail : on relit la version pour
|
||||||
|
// que les saisies suivantes du lot ne se heurtent pas à NOS écritures.
|
||||||
|
if (!nouvelleVersion) {
|
||||||
|
const frais = await api.GET('/work-orders/{id}', { params: { path: { id: s.otId } } });
|
||||||
|
nouvelleVersion = frais.data?.updatedAt;
|
||||||
|
}
|
||||||
|
return { ok: true, nouvelleVersion };
|
||||||
|
}
|
||||||
|
return { ok: false, reseau: false, message: message || `Refus (${status})` };
|
||||||
|
} catch {
|
||||||
|
return { ok: false, reseau: true, message: 'Réseau indisponible' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageDe(erreur: unknown): string {
|
||||||
|
return erreur && typeof erreur === 'object' && 'message' in erreur
|
||||||
|
? String((erreur as { message: unknown }).message)
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
let enCours = false;
|
||||||
|
|
||||||
|
export async function rejouer(
|
||||||
|
queryClient: QueryClient,
|
||||||
|
envoyer: (s: Saisie) => Promise<ResultatEnvoi> = envoyerSaisie,
|
||||||
|
): Promise<void> {
|
||||||
|
if (enCours) return;
|
||||||
|
enCours = true;
|
||||||
|
try {
|
||||||
|
for (;;) {
|
||||||
|
const suivante = lireFile().find((s) => s.statut === 'EN_ATTENTE');
|
||||||
|
if (!suivante) break;
|
||||||
|
// Un conflit plus ancien barre la route : l'ordre est la promesse D1.
|
||||||
|
const conflitAvant = lireFile().some(
|
||||||
|
(s) => s.statut === 'CONFLIT' && s.creeA <= suivante.creeA,
|
||||||
|
);
|
||||||
|
if (conflitAvant) break;
|
||||||
|
|
||||||
|
majSaisie(suivante.id, { statut: 'ENVOI' });
|
||||||
|
const resultat = await envoyer(suivante);
|
||||||
|
if (resultat.ok) {
|
||||||
|
retirer(suivante.id);
|
||||||
|
// Notre propre écriture a fait avancer la version : les saisies
|
||||||
|
// restantes du même OT repartent de là (un écart ÉTRANGER ultérieur
|
||||||
|
// restera détecté par le verrou).
|
||||||
|
if (resultat.nouvelleVersion) {
|
||||||
|
for (const s of lireFile()) {
|
||||||
|
if (s.otId === suivante.otId && s.statut === 'EN_ATTENTE') {
|
||||||
|
majSaisie(s.id, { baseUpdatedAt: resultat.nouvelleVersion });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['work-orders'] });
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['documents'] });
|
||||||
|
} else if (resultat.reseau) {
|
||||||
|
majSaisie(suivante.id, { statut: 'EN_ATTENTE' });
|
||||||
|
break; // le réseau reviendra — rien n'est perdu
|
||||||
|
} else {
|
||||||
|
majSaisie(suivante.id, { statut: 'CONFLIT', erreur: resultat.message });
|
||||||
|
break; // l'humain tranche (D2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
enCours = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Résolutions du conflit (maquette écran 7). */
|
||||||
|
export async function rejouerSurVersionAJour(
|
||||||
|
saisieId: string,
|
||||||
|
queryClient: QueryClient,
|
||||||
|
): Promise<void> {
|
||||||
|
const saisie = lireFile().find((s) => s.id === saisieId);
|
||||||
|
if (!saisie) return;
|
||||||
|
const frais = await api.GET('/work-orders/{id}', { params: { path: { id: saisie.otId } } });
|
||||||
|
if (frais.data) {
|
||||||
|
majSaisie(saisieId, { statut: 'EN_ATTENTE', baseUpdatedAt: frais.data.updatedAt, erreur: undefined });
|
||||||
|
await rejouer(queryClient);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function abandonnerSaisie(saisieId: string, queryClient: QueryClient): Promise<void> {
|
||||||
|
retirer(saisieId);
|
||||||
|
// On recharge la vérité serveur : le patch optimiste local est annulé.
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['work-orders'] });
|
||||||
|
await rejouer(queryClient);
|
||||||
|
}
|
||||||
25
apps/mobile/src/lib/checklist.test.ts
Normal file
25
apps/mobile/src/lib/checklist.test.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { prochainEtat, progression } from './checklist';
|
||||||
|
|
||||||
|
describe('checklist (écran 6 de la maquette R4)', () => {
|
||||||
|
it('progression : DONE et NA comptent comme traités (règle de la garde R2)', () => {
|
||||||
|
const { faits, total, pct } = progression([
|
||||||
|
{ state: 'DONE' },
|
||||||
|
{ state: 'NA' },
|
||||||
|
{ state: 'PENDING' },
|
||||||
|
{ state: 'PENDING' },
|
||||||
|
]);
|
||||||
|
expect(faits).toBe(2);
|
||||||
|
expect(total).toBe(4);
|
||||||
|
expect(pct).toBe(0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('grille vide : pas de division par zéro', () => {
|
||||||
|
expect(progression([]).pct).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appui simple : PENDING ⇄ DONE ; depuis NA on revient à DONE', () => {
|
||||||
|
expect(prochainEtat('PENDING')).toBe('DONE');
|
||||||
|
expect(prochainEtat('DONE')).toBe('PENDING');
|
||||||
|
expect(prochainEtat('NA')).toBe('DONE');
|
||||||
|
});
|
||||||
|
});
|
||||||
19
apps/mobile/src/lib/checklist.ts
Normal file
19
apps/mobile/src/lib/checklist.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { ChecklistState } from '@siop/shared';
|
||||||
|
|
||||||
|
/** Progression d'une grille : cochée OU non-applicable = traitée (même
|
||||||
|
* règle que la garde de clôture R2). */
|
||||||
|
export function progression(items: readonly { state: ChecklistState }[]): {
|
||||||
|
faits: number;
|
||||||
|
total: number;
|
||||||
|
pct: number;
|
||||||
|
} {
|
||||||
|
const total = items.length;
|
||||||
|
const faits = items.filter((i) => i.state !== 'PENDING').length;
|
||||||
|
return { faits, total, pct: total ? faits / total : 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Le geste au pouce : appui simple PENDING ⇄ DONE ; le N/A passe par
|
||||||
|
* l'appui long (assumé à l'écran). Depuis NA, un appui revient à DONE. */
|
||||||
|
export function prochainEtat(actuel: ChecklistState): ChecklistState {
|
||||||
|
return actuel === 'PENDING' ? 'DONE' : actuel === 'DONE' ? 'PENDING' : 'DONE';
|
||||||
|
}
|
||||||
44
apps/mobile/src/lib/journee.test.ts
Normal file
44
apps/mobile/src/lib/journee.test.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { triJournee, type OtJournee } from './journee';
|
||||||
|
|
||||||
|
const ot = (sur: Partial<OtJournee>): OtJournee => ({
|
||||||
|
priority: 'NONE',
|
||||||
|
status: 'OPEN',
|
||||||
|
dueDate: null,
|
||||||
|
createdAt: '2026-07-01T08:00:00.000Z',
|
||||||
|
...sur,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('triJournee (écran 1 de la maquette R4)', () => {
|
||||||
|
it('met « personne bloquée » en tête, puis la priorité décroissante', () => {
|
||||||
|
const tri = triJournee([
|
||||||
|
ot({ priority: 'LOW' }),
|
||||||
|
ot({ priority: 'PERSON_TRAPPED' }),
|
||||||
|
ot({ priority: 'HIGH' }),
|
||||||
|
ot({ priority: 'MEDIUM' }),
|
||||||
|
]);
|
||||||
|
expect(tri.map((o) => o.priority)).toEqual(['PERSON_TRAPPED', 'HIGH', 'MEDIUM', 'LOW']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('à priorité égale : échéance la plus proche d’abord, sans échéance en dernier', () => {
|
||||||
|
const tri = triJournee([
|
||||||
|
ot({ priority: 'HIGH', dueDate: null, createdAt: '2026-07-01T08:00:00.000Z' }),
|
||||||
|
ot({ priority: 'HIGH', dueDate: '2026-07-20T00:00:00.000Z' }),
|
||||||
|
ot({ priority: 'HIGH', dueDate: '2026-07-18T00:00:00.000Z' }),
|
||||||
|
]);
|
||||||
|
expect(tri.map((o) => o.dueDate)).toEqual([
|
||||||
|
'2026-07-18T00:00:00.000Z',
|
||||||
|
'2026-07-20T00:00:00.000Z',
|
||||||
|
null,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('écarte les OT terminés et annulés — la journée ne montre que l’à-faire', () => {
|
||||||
|
const tri = triJournee([
|
||||||
|
ot({ status: 'DONE' }),
|
||||||
|
ot({ status: 'IN_PROGRESS' }),
|
||||||
|
ot({ status: 'CANCELLED' }),
|
||||||
|
ot({ status: 'ON_HOLD' }),
|
||||||
|
]);
|
||||||
|
expect(tri.map((o) => o.status)).toEqual(['IN_PROGRESS', 'ON_HOLD']);
|
||||||
|
});
|
||||||
|
});
|
||||||
32
apps/mobile/src/lib/journee.ts
Normal file
32
apps/mobile/src/lib/journee.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import type { WorkOrderPriority, WorkOrderStatus } from '@siop/shared';
|
||||||
|
|
||||||
|
/** Tri de « Ma journée » (maquette R4, écran 1) : priorité décroissante
|
||||||
|
* (personne bloquée en tête) puis échéance la plus proche ; les OT clos
|
||||||
|
* ou annulés n'apparaissent pas. Fonction pure — testée. */
|
||||||
|
|
||||||
|
const POIDS: Record<WorkOrderPriority, number> = {
|
||||||
|
PERSON_TRAPPED: 4,
|
||||||
|
HIGH: 3,
|
||||||
|
MEDIUM: 2,
|
||||||
|
LOW: 1,
|
||||||
|
NONE: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface OtJournee {
|
||||||
|
priority: WorkOrderPriority;
|
||||||
|
status: WorkOrderStatus;
|
||||||
|
dueDate: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function triJournee<T extends OtJournee>(ots: readonly T[]): T[] {
|
||||||
|
return ots
|
||||||
|
.filter((o) => o.status !== 'DONE' && o.status !== 'CANCELLED')
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
POIDS[b.priority] - POIDS[a.priority] ||
|
||||||
|
(a.dueDate ? Date.parse(a.dueDate) : Infinity) -
|
||||||
|
(b.dueDate ? Date.parse(b.dueDate) : Infinity) ||
|
||||||
|
Date.parse(a.createdAt) - Date.parse(b.createdAt),
|
||||||
|
);
|
||||||
|
}
|
||||||
21
apps/mobile/src/lib/scan.test.ts
Normal file
21
apps/mobile/src/lib/scan.test.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { analyseScan } from './scan';
|
||||||
|
|
||||||
|
describe('analyseScan (D4 — QR des étiquettes A6)', () => {
|
||||||
|
it("extrait la référence de l'URL portail, quel que soit le domaine", () => {
|
||||||
|
expect(analyseScan('https://siop2.apps.enset.top/q/A1')).toBe('A1');
|
||||||
|
expect(analyseScan('http://localhost:5173/q/B2')).toBe('B2');
|
||||||
|
expect(analyseScan('https://client.spelev.ma/q/MC-1')).toBe('MC-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepte une référence tapée à la main, normalisée en majuscules', () => {
|
||||||
|
expect(analyseScan(' a1 ')).toBe('A1');
|
||||||
|
expect(analyseScan('b2')).toBe('B2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuse ce qui n’est pas à nous — jamais d’écran blanc sur un QR étranger', () => {
|
||||||
|
expect(analyseScan('https://example.com/promo?x=1')).toBeNull();
|
||||||
|
expect(analyseScan('WIFI:T:WPA;S:box;P:secret;;')).toBeNull();
|
||||||
|
expect(analyseScan('')).toBeNull();
|
||||||
|
expect(analyseScan('référence avec espaces')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
16
apps/mobile/src/lib/scan.ts
Normal file
16
apps/mobile/src/lib/scan.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
/** Analyse du scan (D4) : le QR des étiquettes A6 (R1) encode l'URL du
|
||||||
|
* portail public `…/q/REF`. On accepte aussi une référence tapée à la main.
|
||||||
|
* Retour : la référence normalisée, ou null si le code n'est pas un nôtre. */
|
||||||
|
|
||||||
|
const REF_VALIDE = /^[A-Z0-9][A-Z0-9-]{0,11}$/;
|
||||||
|
|
||||||
|
export function analyseScan(brut: string): string | null {
|
||||||
|
const texte = brut.trim();
|
||||||
|
if (!texte) return null;
|
||||||
|
|
||||||
|
// URL d'étiquette : http(s)://…/q/A1 (quel que soit le domaine d'origine)
|
||||||
|
const url = texte.match(/^https?:\/\/[^\s]+\/q\/([^\s/?#]+)$/i);
|
||||||
|
const candidat = decodeURIComponent(url?.[1] ?? texte).toUpperCase();
|
||||||
|
|
||||||
|
return REF_VALIDE.test(candidat) ? candidat : null;
|
||||||
|
}
|
||||||
20
apps/mobile/src/theme/tokens.test.ts
Normal file
20
apps/mobile/src/theme/tokens.test.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { CLAIR, SOMBRE } from './tokens';
|
||||||
|
|
||||||
|
describe('tokens SIOP portés en RN', () => {
|
||||||
|
it('le sombre couvre exactement les clés du clair (pas de token orphelin)', () => {
|
||||||
|
expect(Object.keys(SOMBRE).sort()).toEqual(Object.keys(CLAIR).sort());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('réplique les valeurs pivots de docs/02-design/tokens.css', () => {
|
||||||
|
expect(CLAIR.primaire).toBe('#1f4fb8');
|
||||||
|
expect(CLAIR.safran).toBe('#dd8a0b');
|
||||||
|
expect(SOMBRE.fond).toBe('#0f1622');
|
||||||
|
expect(SOMBRE.primaire).toBe('#6e92e8');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('chaque valeur est une couleur hex ou transparent', () => {
|
||||||
|
for (const jeu of [CLAIR, SOMBRE]) {
|
||||||
|
for (const v of Object.values(jeu)) expect(v).toMatch(/^#[0-9a-f]{6}$/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
80
apps/mobile/src/theme/tokens.ts
Normal file
80
apps/mobile/src/theme/tokens.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { useColorScheme } from 'react-native';
|
||||||
|
|
||||||
|
/** Tokens SIOP portés en RN — répliques de docs/02-design/tokens.css
|
||||||
|
* (mêmes valeurs que le web et les maquettes R4, bi-thème). */
|
||||||
|
|
||||||
|
export const CLAIR = {
|
||||||
|
primaire: '#1f4fb8',
|
||||||
|
primaireDoux: '#eaf0fb',
|
||||||
|
safran: '#dd8a0b',
|
||||||
|
safranDoux: '#fdf3e3',
|
||||||
|
fond: '#f5f7fa',
|
||||||
|
surface: '#ffffff',
|
||||||
|
surface2: '#eef2f7',
|
||||||
|
bordure: '#dce3ec',
|
||||||
|
bordureForte: '#b9c4d4',
|
||||||
|
encre: '#1b2534',
|
||||||
|
encre2: '#55647a',
|
||||||
|
encre3: '#8494ab',
|
||||||
|
stOuvert: '#3d6fe0',
|
||||||
|
stOuvertFond: '#e9effc',
|
||||||
|
stEncours: '#6d5bd8',
|
||||||
|
stEncoursFond: '#efecfa',
|
||||||
|
stAttente: '#b96f07',
|
||||||
|
stAttenteFond: '#fbf1df',
|
||||||
|
stTermine: '#178a50',
|
||||||
|
stTermineFond: '#e6f5ec',
|
||||||
|
stAnnule: '#68788f',
|
||||||
|
stAnnuleFond: '#edf0f4',
|
||||||
|
prioBloque: '#d92626',
|
||||||
|
prioBloqueFond: '#fdeaea',
|
||||||
|
prioHaute: '#d92626',
|
||||||
|
prioMoyenne: '#b96f07',
|
||||||
|
prioBasse: '#178a50',
|
||||||
|
prioAucune: '#8494ab',
|
||||||
|
succes: '#178a50',
|
||||||
|
alerte: '#b96f07',
|
||||||
|
danger: '#d92626',
|
||||||
|
navFond: '#16233b',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const SOMBRE: Tokens = {
|
||||||
|
primaire: '#6e92e8',
|
||||||
|
primaireDoux: '#1d2c4c',
|
||||||
|
safran: '#e89a1f',
|
||||||
|
safranDoux: '#33270f',
|
||||||
|
fond: '#0f1622',
|
||||||
|
surface: '#182234',
|
||||||
|
surface2: '#1e2a40',
|
||||||
|
bordure: '#2b3850',
|
||||||
|
bordureForte: '#3d4d6b',
|
||||||
|
encre: '#e8edf5',
|
||||||
|
encre2: '#a7b4c8',
|
||||||
|
encre3: '#6d7d96',
|
||||||
|
stOuvert: '#7da2ee',
|
||||||
|
stOuvertFond: '#1c2a47',
|
||||||
|
stEncours: '#a394ec',
|
||||||
|
stEncoursFond: '#262040',
|
||||||
|
stAttente: '#e0a33c',
|
||||||
|
stAttenteFond: '#322510',
|
||||||
|
stTermine: '#4bc084',
|
||||||
|
stTermineFond: '#12301f',
|
||||||
|
stAnnule: '#93a3ba',
|
||||||
|
stAnnuleFond: '#222d3f',
|
||||||
|
prioBloque: '#f26d6d',
|
||||||
|
prioBloqueFond: '#3a1414',
|
||||||
|
prioHaute: '#f26d6d',
|
||||||
|
prioMoyenne: '#e0a33c',
|
||||||
|
prioBasse: '#4bc084',
|
||||||
|
prioAucune: '#6d7d96',
|
||||||
|
succes: '#4bc084',
|
||||||
|
alerte: '#e0a33c',
|
||||||
|
danger: '#f26d6d',
|
||||||
|
navFond: '#0c1524',
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Tokens = { [K in keyof typeof CLAIR]: string };
|
||||||
|
|
||||||
|
export function useTokens(): Tokens {
|
||||||
|
return useColorScheme() === 'dark' ? SOMBRE : CLAIR;
|
||||||
|
}
|
||||||
10
apps/mobile/tsconfig.json
Normal file
10
apps/mobile/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "expo/tsconfig.base",
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"types": ["jest"],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
149
apps/web/e2e/parcours-r5.spec.ts
Normal file
149
apps/web/e2e/parcours-r5.spec.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recette R5 (plan de releases) : la bibliothèque EST le corpus (bandeau
|
||||||
|
* 09-08, statut d'indexation, interrupteur, réindexation explicite) ; puis
|
||||||
|
* l'assistant « sourcé ou silencieux » (réponse citée depuis le PDF téléversé,
|
||||||
|
* refus honnête et chiffré sinon) ; enfin la suggestion de bilan sur un OT
|
||||||
|
* (codes existants, appliqués par le geste humain, liseré « suggéré »).
|
||||||
|
* Le service IA tourne avec l'embeddeur déterministe et des seuils abaissés
|
||||||
|
* (ci.yml) — c'est le CIRCUIT qui se recette, le vrai modèle se recette en
|
||||||
|
* local (journal R5.1).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const suffix = Date.now().toString(36);
|
||||||
|
|
||||||
|
/** PDF 1 page minimal mais VALIDE (xref calculée) — pypdf doit pouvoir en
|
||||||
|
* extraire le texte : c'est ce qui alimente l'index côté service IA. */
|
||||||
|
function pdfMinimal(texte: string): Buffer {
|
||||||
|
const contenu = `BT /F1 12 Tf 72 720 Td (${texte.replace(/[()\\]/g, '\\$&')}) Tj ET`;
|
||||||
|
const objets = [
|
||||||
|
'<< /Type /Catalog /Pages 2 0 R >>',
|
||||||
|
'<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
|
||||||
|
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R ' +
|
||||||
|
'/Resources << /Font << /F1 5 0 R >> >> >>',
|
||||||
|
`<< /Length ${contenu.length} >>\nstream\n${contenu}\nendstream`,
|
||||||
|
'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>',
|
||||||
|
];
|
||||||
|
let corps = '%PDF-1.4\n';
|
||||||
|
const offsets: number[] = [];
|
||||||
|
objets.forEach((objet, i) => {
|
||||||
|
offsets.push(corps.length);
|
||||||
|
corps += `${i + 1} 0 obj\n${objet}\nendobj\n`;
|
||||||
|
});
|
||||||
|
const debutXref = corps.length;
|
||||||
|
corps += `xref\n0 ${objets.length + 1}\n0000000000 65535 f \n`;
|
||||||
|
for (const offset of offsets) corps += `${String(offset).padStart(10, '0')} 00000 n \n`;
|
||||||
|
corps += `trailer\n<< /Size ${objets.length + 1} /Root 1 0 R >>\nstartxref\n${debutXref}\n%%EOF`;
|
||||||
|
return Buffer.from(corps, 'latin1');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connexionDemo(page: import('@playwright/test').Page, nom: RegExp) {
|
||||||
|
await page.goto('/connexion');
|
||||||
|
await page.getByRole('button', { name: nom }).click();
|
||||||
|
await expect(page.locator('.topbar')).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
test('recette R5 : corpus → réindexation → assistant sourcé → refus honnête', async ({ page }) => {
|
||||||
|
test.setTimeout(180_000); // deux réindexations complètes du corpus réel
|
||||||
|
const fichier = `e2e-notice-${suffix}.pdf`;
|
||||||
|
|
||||||
|
// 1 · Nadia (gestionnaire, ASSETS.edit) — la bibliothèque est le corpus
|
||||||
|
await connexionDemo(page, /Nadia Berrada/);
|
||||||
|
await page.getByRole('link', { name: 'Fichiers' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: /corpus de l'assistant/ })).toBeVisible();
|
||||||
|
await expect(page.locator('.avert')).toContainText('Loi 09-08'); // l'anonymisation est DITE
|
||||||
|
|
||||||
|
// 2 · Téléverser une notice PDF de test rattachée à A1
|
||||||
|
await page.getByRole('button', { name: 'Téléverser' }).click();
|
||||||
|
await page.getByLabel("Rattacher à l'appareil *").selectOption({ index: 1 });
|
||||||
|
await page.getByLabel('Choisir un fichier').setInputFiles({
|
||||||
|
name: fichier,
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
buffer: pdfMinimal(
|
||||||
|
'Couple de serrage des coulisseaux de guides : 25 Nm. Verifier le jeu lateral.',
|
||||||
|
),
|
||||||
|
});
|
||||||
|
await page.getByRole('button', { name: 'Téléverser', exact: true }).last().click();
|
||||||
|
const vignette = page.locator('tr', { hasText: fichier }); // tableau du corpus (arbitrage référent)
|
||||||
|
await expect(vignette).toBeVisible();
|
||||||
|
await expect(vignette.locator('.st')).toHaveText('à indexer'); // jamais indexé à la naissance
|
||||||
|
|
||||||
|
// 3 · Réindexer tout — geste explicite, bilan chiffré, statut à jour
|
||||||
|
await page.getByRole('button', { name: 'Réindexer tout' }).click();
|
||||||
|
// l'ingestion réelle (MinIO + pypdf + embeddings) peut dépasser les 5 s
|
||||||
|
await expect(page.getByText('Réindexation terminée')).toBeVisible({ timeout: 60_000 });
|
||||||
|
await expect(vignette.locator('.st')).toContainText('indexé ·');
|
||||||
|
await expect(vignette.locator('.st')).toContainText('extrait');
|
||||||
|
|
||||||
|
// 4 · L'assistant répond SOURCÉ depuis ce PDF (embeddeur déterministe :
|
||||||
|
// la question reprend les mots de la notice)
|
||||||
|
await page.getByRole('link', { name: 'Assistant' }).click();
|
||||||
|
await expect(page.getByText('répond UNIQUEMENT depuis votre bibliothèque')).toBeVisible();
|
||||||
|
await page
|
||||||
|
.getByLabel('Poser une question')
|
||||||
|
.fill('couple de serrage des coulisseaux de guides ?');
|
||||||
|
await page.getByRole('button', { name: 'Envoyer' }).click();
|
||||||
|
const source = page.locator('.source', { hasText: fichier });
|
||||||
|
await expect(source).toBeVisible();
|
||||||
|
await expect(source.locator('.extrait')).toContainText('25 Nm'); // l'extrait EXACT
|
||||||
|
await expect(source.locator('.ou')).toContainText('p. 1'); // la citation pointe la page
|
||||||
|
await expect(page.locator('.msg-r .avert')).toContainText('vous validez'); // pas un disclaimer caché
|
||||||
|
|
||||||
|
// 5 · Sans source au-dessus du seuil : refus honnête, chiffré, avec l'action utile
|
||||||
|
await page.getByLabel('Poser une question').fill('xylophone quantique zorglub ?');
|
||||||
|
await page.getByRole('button', { name: 'Envoyer' }).click();
|
||||||
|
const refus = page.locator('.refus');
|
||||||
|
await expect(refus).toBeVisible();
|
||||||
|
await expect(refus).toContainText('je préfère ne pas inventer');
|
||||||
|
await expect(refus).toContainText(/J'ai cherché dans \d+ documents? indexés? et \d+ bilans?/);
|
||||||
|
await expect(refus.getByRole('link', { name: /Téléverser la notice/ })).toBeVisible();
|
||||||
|
|
||||||
|
// 6 · L'interrupteur exclut la notice — réversible, effectif à la réindexation
|
||||||
|
await page.getByRole('link', { name: 'Fichiers' }).click();
|
||||||
|
await vignette.getByRole('switch').click();
|
||||||
|
await expect(vignette.locator('.st')).toHaveText('exclu du corpus');
|
||||||
|
await page.getByRole('button', { name: 'Réindexer tout' }).click();
|
||||||
|
await expect(page.getByText('Réindexation terminée')).toBeVisible({ timeout: 60_000 });
|
||||||
|
|
||||||
|
// Ménage : la notice de test sort de la bibliothèque
|
||||||
|
page.on('dialog', (d) => void d.accept());
|
||||||
|
await vignette.getByRole('button', { name: 'Supprimer' }).click();
|
||||||
|
await expect(vignette).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recette R5 : suggestion de bilan — codes existants, appliqués par l\'humain', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const titre = `E2E Suggestion ${suffix}`;
|
||||||
|
|
||||||
|
// 1 · Nadia crée un OT de dépannage sur A1
|
||||||
|
await connexionDemo(page, /Nadia Berrada/);
|
||||||
|
await page.getByRole('link', { name: 'Ordres de travail' }).click();
|
||||||
|
await page.getByRole('button', { name: '+ Nouvel OT' }).click();
|
||||||
|
await page.getByLabel('Objet *').fill(titre);
|
||||||
|
await page.getByLabel('Équipement *').selectOption({ index: 1 });
|
||||||
|
await page.getByRole('button', { name: "Créer l'OT (statut Ouvert)" }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: titre })).toBeVisible();
|
||||||
|
|
||||||
|
// 2 · Décrire pour suggérer — l'IA propose des codes EXISTANTS, justifiés
|
||||||
|
await page
|
||||||
|
.getByLabel('Décrire pour suggérer (optionnel)')
|
||||||
|
.fill('Frottement mécanique sur les portes, nettoyage et graissage effectués, essais OK.');
|
||||||
|
await page.getByRole('button', { name: /Suggérer les codes/ }).click();
|
||||||
|
const suggestions = page.locator('.suggestion');
|
||||||
|
await expect(suggestions.first()).toBeVisible();
|
||||||
|
await expect(page.locator('.confiance').first()).toContainText('confiance');
|
||||||
|
|
||||||
|
// 3 · « Appliquer les N » : les sélecteurs se pré-remplissent, liseré « suggéré »
|
||||||
|
await page.getByRole('button', { name: /Appliquer les \d/ }).click();
|
||||||
|
await expect(page.locator('.champ-b[data-suggere]').first()).toBeVisible();
|
||||||
|
const anomalie = page.locator('#bilan-ANOMALY');
|
||||||
|
await expect(anomalie).not.toHaveValue('');
|
||||||
|
|
||||||
|
// 4 · Un choix MANUEL retire le liseré du champ concerné (l'humain a repris la main)
|
||||||
|
const champAnomalie = page.locator('.champ-b', { has: anomalie });
|
||||||
|
await expect(champAnomalie).toHaveAttribute('data-suggere', 'true');
|
||||||
|
await anomalie.selectOption({ index: 1 });
|
||||||
|
await expect(champAnomalie).not.toHaveAttribute('data-suggere', 'true');
|
||||||
|
});
|
||||||
@@ -14,6 +14,10 @@ const API_ENV = {
|
|||||||
DATABASE_URL:
|
DATABASE_URL:
|
||||||
process.env.DATABASE_URL ?? 'postgresql://siop:siop@localhost:5432/siop',
|
process.env.DATABASE_URL ?? 'postgresql://siop:siop@localhost:5432/siop',
|
||||||
REDIS_URL: process.env.REDIS_URL ?? 'redis://localhost:6379',
|
REDIS_URL: process.env.REDIS_URL ?? 'redis://localhost:6379',
|
||||||
|
// R5 : le service IA écoute sur 8000 (CI : embeddeur déterministe,
|
||||||
|
// seuils abaissés — voir ci.yml ; localement : lancez apps/ai avant).
|
||||||
|
AI_SERVICE_URL: process.env.AI_SERVICE_URL ?? 'http://localhost:8000',
|
||||||
|
AI_SERVICE_TOKEN: process.env.AI_SERVICE_TOKEN ?? 'dev-only-ai-token',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Coquille } from '@/layout/coquille';
|
|||||||
import PageAchats from '@/pages/achats';
|
import PageAchats from '@/pages/achats';
|
||||||
import PageActivation from '@/pages/activation';
|
import PageActivation from '@/pages/activation';
|
||||||
import PageAscenseurs from '@/pages/ascenseurs';
|
import PageAscenseurs from '@/pages/ascenseurs';
|
||||||
|
import PageAssistant from '@/pages/assistant';
|
||||||
import PageBibliotheque from '@/pages/bibliotheque';
|
import PageBibliotheque from '@/pages/bibliotheque';
|
||||||
import PageCategories from '@/pages/categories';
|
import PageCategories from '@/pages/categories';
|
||||||
import PageCompteurs from '@/pages/compteurs';
|
import PageCompteurs from '@/pages/compteurs';
|
||||||
@@ -67,6 +68,7 @@ export default function App() {
|
|||||||
<Route path="/tiers" element={dansCoquille(<PageTiers />)} />
|
<Route path="/tiers" element={dansCoquille(<PageTiers />)} />
|
||||||
<Route path="/bibliotheque" element={dansCoquille(<PageBibliotheque />)} />
|
<Route path="/bibliotheque" element={dansCoquille(<PageBibliotheque />)} />
|
||||||
<Route path="/statistiques" element={dansCoquille(<PageStatistiques />)} />
|
<Route path="/statistiques" element={dansCoquille(<PageStatistiques />)} />
|
||||||
|
<Route path="/assistant" element={dansCoquille(<PageAssistant />)} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
|||||||
57
apps/web/src/api/assistant.ts
Normal file
57
apps/web/src/api/assistant.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import type { AssistantAsk, SuggestBilan } from '@siop/shared';
|
||||||
|
import { api } from './client';
|
||||||
|
|
||||||
|
/** Hooks R5 — l'assistant passe par l'API NestJS (le service IA n'est jamais
|
||||||
|
* appelé du navigateur, ADR-004 §4). Le 503 est un état ATTENDU du contrat
|
||||||
|
* (service éteint ou pas encore déployé) : les écrans l'affichent posément. */
|
||||||
|
|
||||||
|
async function unwrap<T>(res: { data?: T; error?: unknown; response: Response }): Promise<T> {
|
||||||
|
if (res.response.status === 503) {
|
||||||
|
throw new Error('Assistant indisponible pour le moment — réessayez dans un instant.');
|
||||||
|
}
|
||||||
|
if (res.error || res.data === undefined) {
|
||||||
|
const message =
|
||||||
|
(res.error as { message?: string } | undefined)?.message ??
|
||||||
|
`Le serveur a répondu ${res.response.status}`;
|
||||||
|
throw new Error(Array.isArray(message) ? message.join(' — ') : message);
|
||||||
|
}
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAskAssistant() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: AssistantAsk) =>
|
||||||
|
unwrap(await api.POST('/assistant/ask', { body })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSuggestBilan() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: SuggestBilan) =>
|
||||||
|
unwrap(await api.POST('/assistant/suggest-bilan', { body })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useReindexAssistant() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async () => unwrap(await api.POST('/assistant/reindex')),
|
||||||
|
// la réindexation met à jour indexedAt/chunkCount de chaque document
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['documents'] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSetDocumentCorpus() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (input: { id: string; inCorpus: boolean }) =>
|
||||||
|
unwrap(
|
||||||
|
await api.PATCH('/documents/{id}/corpus', {
|
||||||
|
params: { path: { id: input.id } },
|
||||||
|
body: { inCorpus: input.inCorpus },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['documents'] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
251
apps/web/src/api/schema.d.ts
vendored
251
apps/web/src/api/schema.d.ts
vendored
@@ -407,6 +407,23 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/documents/{id}/corpus": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
/** Inclure/exclure du corpus IA (D3 — réversible, effectif à la prochaine réindexation) */
|
||||||
|
patch: operations["updateDocumentCorpus"];
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/documents/{id}": {
|
"/documents/{id}": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -441,6 +458,57 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/assistant/ask": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/** Assistant R5 — sourcé ou silencieux : extraits cités ou refus honnête (D2) */
|
||||||
|
post: operations["askAssistant"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/assistant/suggest-bilan": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/** Suggérer des codes de bilan depuis une description libre (D1 — l’humain valide) */
|
||||||
|
post: operations["suggestBilan"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/assistant/reindex": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/** Réindexer le corpus (bibliothèque PDF + bilans clôturés, anonymisés à l’ingestion — D4) */
|
||||||
|
post: operations["reindexAssistant"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/search": {
|
"/search": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1403,6 +1471,9 @@ export interface components {
|
|||||||
uploadedByName: string | null;
|
uploadedByName: string | null;
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
inCorpus: boolean;
|
||||||
|
indexedAt: string | null;
|
||||||
|
chunkCount: number;
|
||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
Document: {
|
Document: {
|
||||||
@@ -1418,6 +1489,12 @@ export interface components {
|
|||||||
uploadedByName: string | null;
|
uploadedByName: string | null;
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
inCorpus: boolean;
|
||||||
|
indexedAt: string | null;
|
||||||
|
chunkCount: number;
|
||||||
|
};
|
||||||
|
DocumentCorpusUpdate: {
|
||||||
|
inCorpus: boolean;
|
||||||
};
|
};
|
||||||
AnalyticsSummary: {
|
AnalyticsSummary: {
|
||||||
months: number;
|
months: number;
|
||||||
@@ -1446,6 +1523,50 @@ export interface components {
|
|||||||
total: number;
|
total: number;
|
||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
|
AssistantAnswer: {
|
||||||
|
/** @enum {string} */
|
||||||
|
mode: "EXTRACTIVE" | "GENERATED" | "REFUSAL";
|
||||||
|
answer: string | null;
|
||||||
|
excerpts: {
|
||||||
|
/** @enum {string} */
|
||||||
|
sourceType: "DOCUMENT" | "WORK_ORDER";
|
||||||
|
documentId: string | null;
|
||||||
|
workOrderId: string | null;
|
||||||
|
title: string;
|
||||||
|
locator: string;
|
||||||
|
content: string;
|
||||||
|
score: number;
|
||||||
|
}[];
|
||||||
|
corpus: {
|
||||||
|
documents: number;
|
||||||
|
reports: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
AssistantAsk: {
|
||||||
|
question: string;
|
||||||
|
};
|
||||||
|
BilanSuggestions: {
|
||||||
|
suggestions: {
|
||||||
|
/** @enum {string} */
|
||||||
|
field: "DOOR_STATE" | "CABIN_POSITION" | "ANOMALY" | "EXTERNAL_CAUSE" | "ACTION_TAKEN" | "COMPONENT_CONCERNED";
|
||||||
|
/** Format: uuid */
|
||||||
|
valueId: string;
|
||||||
|
label: string;
|
||||||
|
/** @enum {string} */
|
||||||
|
confidence: "HIGH" | "MEDIUM";
|
||||||
|
similarReports: number;
|
||||||
|
score: number;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
SuggestBilan: {
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
ReindexResult: {
|
||||||
|
documentsIndexed: number;
|
||||||
|
documentsSkipped: number;
|
||||||
|
reportsIndexed: number;
|
||||||
|
chunks: number;
|
||||||
|
};
|
||||||
SearchResponse: {
|
SearchResponse: {
|
||||||
workOrders: {
|
workOrders: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
@@ -1776,6 +1897,8 @@ export interface components {
|
|||||||
};
|
};
|
||||||
allowedTransitions: ("OPEN" | "IN_PROGRESS" | "ON_HOLD" | "DONE" | "CANCELLED")[];
|
allowedTransitions: ("OPEN" | "IN_PROGRESS" | "ON_HOLD" | "DONE" | "CANCELLED")[];
|
||||||
closureBlockers: string[];
|
closureBlockers: string[];
|
||||||
|
/** Format: date-time */
|
||||||
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
ConsumePart: {
|
ConsumePart: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
@@ -1945,6 +2068,8 @@ export interface components {
|
|||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
to: "OPEN" | "IN_PROGRESS" | "ON_HOLD" | "DONE" | "CANCELLED";
|
to: "OPEN" | "IN_PROGRESS" | "ON_HOLD" | "DONE" | "CANCELLED";
|
||||||
comment?: string;
|
comment?: string;
|
||||||
|
/** Format: date-time */
|
||||||
|
baseUpdatedAt?: string;
|
||||||
};
|
};
|
||||||
CommentCreate: {
|
CommentCreate: {
|
||||||
message: string;
|
message: string;
|
||||||
@@ -1960,6 +2085,8 @@ export interface components {
|
|||||||
externalCauseId?: string | null;
|
externalCauseId?: string | null;
|
||||||
actionTakenId?: string | null;
|
actionTakenId?: string | null;
|
||||||
componentConcernedId?: string | null;
|
componentConcernedId?: string | null;
|
||||||
|
/** Format: date-time */
|
||||||
|
baseUpdatedAt?: string;
|
||||||
};
|
};
|
||||||
ChecklistItem: {
|
ChecklistItem: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
@@ -1978,6 +2105,8 @@ export interface components {
|
|||||||
ChecklistPatch: {
|
ChecklistPatch: {
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
state: "PENDING" | "DONE" | "NA";
|
state: "PENDING" | "DONE" | "NA";
|
||||||
|
/** Format: date-time */
|
||||||
|
baseUpdatedAt?: string;
|
||||||
};
|
};
|
||||||
RequestsResponse: {
|
RequestsResponse: {
|
||||||
requests: {
|
requests: {
|
||||||
@@ -2910,6 +3039,39 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
updateDocumentCorpus: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["DocumentCorpusUpdate"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Document mis à jour */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["Document"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Inconnu */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
deleteDocument: {
|
deleteDocument: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -2959,6 +3121,95 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
askAssistant: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["AssistantAsk"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Réponse sourcée ou refus */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["AssistantAnswer"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Service IA indisponible */
|
||||||
|
503: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
suggestBilan: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["SuggestBilan"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Suggestions (codes existants seulement) */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["BilanSuggestions"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Service IA indisponible */
|
||||||
|
503: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
reindexAssistant: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Bilan d’indexation */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ReindexResult"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Service IA indisponible */
|
||||||
|
503: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
globalSearch: {
|
globalSearch: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query: {
|
query: {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
type DocumentDto,
|
type DocumentDto,
|
||||||
type DocumentKind,
|
type DocumentKind,
|
||||||
} from '@siop/shared';
|
} from '@siop/shared';
|
||||||
|
import { useSetDocumentCorpus } from '@/api/assistant';
|
||||||
import {
|
import {
|
||||||
blobDocument,
|
blobDocument,
|
||||||
ouvrirDocument,
|
ouvrirDocument,
|
||||||
@@ -63,7 +64,58 @@ function ApercuVignette({ doc }: { doc: DocumentDto }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VignetteDoc({ doc, surSuppression }: { doc: DocumentDto; surSuppression?: (id: string) => void }) {
|
/** Statut corpus (R5, D3) : lisible d'un coup d'œil, jamais ambigu. */
|
||||||
|
export function StatutCorpus({ doc }: { doc: DocumentDto }) {
|
||||||
|
if (doc.contentType !== 'application/pdf') {
|
||||||
|
return <span className="st exclu">image — non indexable</span>;
|
||||||
|
}
|
||||||
|
if (!doc.inCorpus) return <span className="st exclu">exclu du corpus</span>;
|
||||||
|
if (!doc.indexedAt) return <span className="st encours">à indexer</span>;
|
||||||
|
const quand = new Intl.DateTimeFormat('fr-FR', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}).format(new Date(doc.indexedAt));
|
||||||
|
return (
|
||||||
|
<span className="st ok">
|
||||||
|
indexé · {quand} · {doc.chunkCount} extrait{doc.chunkCount > 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Interrupteur d'inclusion au corpus (R5, D3) — PDF seulement, réversible.
|
||||||
|
* Une image non indexable s'affiche ÉTEINTE quel que soit l'état stocké :
|
||||||
|
* l'interrupteur montre la réalité du corpus, pas une colonne de base. */
|
||||||
|
export function InterrupteurCorpus({ doc }: { doc: DocumentDto }) {
|
||||||
|
const bascule = useSetDocumentCorpus();
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={doc.contentType === 'application/pdf' && doc.inCorpus}
|
||||||
|
aria-label={doc.inCorpus ? 'Exclure du corpus' : 'Inclure au corpus'}
|
||||||
|
title={
|
||||||
|
doc.contentType !== 'application/pdf'
|
||||||
|
? 'Seuls les PDF sont indexables'
|
||||||
|
: doc.inCorpus
|
||||||
|
? 'Exclure du corpus (effectif à la prochaine réindexation)'
|
||||||
|
: 'Inclure au corpus (effectif à la prochaine réindexation)'
|
||||||
|
}
|
||||||
|
className="interrupteur corpus"
|
||||||
|
disabled={bascule.isPending || doc.contentType !== 'application/pdf'}
|
||||||
|
onClick={() => bascule.mutate({ id: doc.id, inCorpus: !doc.inCorpus })}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VignetteDoc({
|
||||||
|
doc,
|
||||||
|
surSuppression,
|
||||||
|
}: {
|
||||||
|
doc: DocumentDto;
|
||||||
|
surSuppression?: (id: string) => void;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="doc">
|
<div className="doc">
|
||||||
<ApercuVignette doc={doc} />
|
<ApercuVignette doc={doc} />
|
||||||
|
|||||||
@@ -82,6 +82,13 @@ export const IcoStatistiques = () => (
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const IcoAssistant = () => (
|
||||||
|
<svg {...base}>
|
||||||
|
<path d="M12 3l1.8 4.7L18.5 9l-4.7 1.8L12 15.5l-1.8-4.7L5.5 9l4.7-1.3z" />
|
||||||
|
<path d="M18.5 15l.9 2.1 2.1.9-2.1.9-.9 2.1-.9-2.1-2.1-.9 2.1-.9z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
export const IcoPersonnes = () => (
|
export const IcoPersonnes = () => (
|
||||||
<svg {...base}>
|
<svg {...base}>
|
||||||
<circle cx="9" cy="8" r="3.5" />
|
<circle cx="9" cy="8" r="3.5" />
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { api } from '@/api/client';
|
|||||||
import { usePermissions } from '@/auth/use-permissions';
|
import { usePermissions } from '@/auth/use-permissions';
|
||||||
import {
|
import {
|
||||||
IcoAscenseurs,
|
IcoAscenseurs,
|
||||||
|
IcoAssistant,
|
||||||
IcoCategories,
|
IcoCategories,
|
||||||
IcoDemandes,
|
IcoDemandes,
|
||||||
IcoFichiers,
|
IcoFichiers,
|
||||||
@@ -75,6 +76,7 @@ const NAVIGATION: { groupe: string; liens: LienNav[] }[] = [
|
|||||||
groupe: 'Pilotage',
|
groupe: 'Pilotage',
|
||||||
liens: [
|
liens: [
|
||||||
{ libelle: 'Statistiques', icone: IcoStatistiques, route: '/statistiques', permission: ['ANALYTICS', 'view'] },
|
{ libelle: 'Statistiques', icone: IcoStatistiques, route: '/statistiques', permission: ['ANALYTICS', 'view'] },
|
||||||
|
{ libelle: 'Assistant', icone: IcoAssistant, route: '/assistant', permission: ['WORK_ORDERS', 'view'] },
|
||||||
{ libelle: 'Personnes', icone: IcoPersonnes, route: '/personnes', permission: ['PEOPLE_TEAMS', 'view'] },
|
{ libelle: 'Personnes', icone: IcoPersonnes, route: '/personnes', permission: ['PEOPLE_TEAMS', 'view'] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
166
apps/web/src/pages/assistant.tsx
Normal file
166
apps/web/src/pages/assistant.tsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import type { AssistantAnswer, AssistantExcerpt } from '@siop/shared';
|
||||||
|
import { useAskAssistant } from '@/api/assistant';
|
||||||
|
import { ouvrirDocument } from '@/api/gestion';
|
||||||
|
import { usePermissions } from '@/auth/use-permissions';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
/** Écrans 1-2 des maquettes R5 : chat « sourcé ou silencieux » (D2).
|
||||||
|
* Chaque réponse cite ses extraits EXACTS ; sans source au-dessus du seuil,
|
||||||
|
* le refus est honnête et chiffré (ce qui a été cherché). L'avertissement
|
||||||
|
* « l'IA propose, vous validez » est permanent, pas un disclaimer caché. */
|
||||||
|
|
||||||
|
interface Echange {
|
||||||
|
question: string;
|
||||||
|
reponse?: AssistantAnswer;
|
||||||
|
erreur?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Source({ extrait, no }: { extrait: AssistantExcerpt; no: number }) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const ouvrir = () => {
|
||||||
|
if (extrait.sourceType === 'DOCUMENT' && extrait.documentId) {
|
||||||
|
void ouvrirDocument(extrait.documentId);
|
||||||
|
} else if (extrait.workOrderId) {
|
||||||
|
navigate(`/ot/${extrait.workOrderId}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="source">
|
||||||
|
<span className="no">{no}</span>
|
||||||
|
<div>
|
||||||
|
<b>{extrait.title}</b>
|
||||||
|
<div className="ou">
|
||||||
|
{extrait.sourceType === 'DOCUMENT' ? 'Bibliothèque' : 'Historique'} · {extrait.locator}
|
||||||
|
</div>
|
||||||
|
<div className="extrait">« {extrait.content} »</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="ouvrir" onClick={ouvrir}>
|
||||||
|
{extrait.sourceType === 'DOCUMENT' ? 'Ouvrir' : "Ouvrir l'OT"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Reponse({ reponse, surReformuler }: { reponse: AssistantAnswer; surReformuler: () => void }) {
|
||||||
|
const { can } = usePermissions();
|
||||||
|
if (reponse.mode === 'REFUSAL') {
|
||||||
|
return (
|
||||||
|
<div className="refus">
|
||||||
|
<b>Je ne trouve pas de source fiable dans votre bibliothèque — je préfère ne pas inventer.</b>
|
||||||
|
<div className="pourquoi">
|
||||||
|
J'ai cherché dans {reponse.corpus.documents} document
|
||||||
|
{reponse.corpus.documents > 1 ? 's' : ''} indexé
|
||||||
|
{reponse.corpus.documents > 1 ? 's' : ''} et {reponse.corpus.reports} bilan
|
||||||
|
{reponse.corpus.reports > 1 ? 's' : ''} d'intervention : rien d'assez proche de votre
|
||||||
|
question.
|
||||||
|
</div>
|
||||||
|
<div className="actions-sug">
|
||||||
|
{can('ASSETS', 'edit') ? (
|
||||||
|
<Link to="/bibliotheque" className="btn prim">
|
||||||
|
Téléverser la notice dans la bibliothèque
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
<Button onClick={surReformuler}>Reformuler ma question</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="msg-r">
|
||||||
|
{reponse.mode === 'GENERATED' && reponse.answer ? (
|
||||||
|
<p style={{ whiteSpace: 'pre-wrap' }}>{reponse.answer}</p>
|
||||||
|
) : (
|
||||||
|
<p>
|
||||||
|
Voici ce que portent vos sources — les extraits sont cités tels quels
|
||||||
|
{reponse.excerpts.map((_, i) => (
|
||||||
|
<span key={i} className="cite">{i + 1}</span>
|
||||||
|
))}
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="sources">
|
||||||
|
{reponse.excerpts.map((extrait, i) => (
|
||||||
|
<Source key={`${extrait.locator}-${i}`} extrait={extrait} no={i + 1} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="avert">⚠ L'IA propose, vous validez : vérifiez la notice avant d'agir sur l'appareil.</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PageAssistant() {
|
||||||
|
const ask = useAskAssistant();
|
||||||
|
const [question, setQuestion] = useState('');
|
||||||
|
const [echanges, setEchanges] = useState<Echange[]>([]);
|
||||||
|
const saisieRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const envoyer = () => {
|
||||||
|
const q = question.trim();
|
||||||
|
if (q.length < 3 || ask.isPending) return;
|
||||||
|
setQuestion('');
|
||||||
|
setEchanges((liste) => [...liste, { question: q }]);
|
||||||
|
ask.mutate(
|
||||||
|
{ question: q },
|
||||||
|
{
|
||||||
|
onSuccess: (reponse) =>
|
||||||
|
setEchanges((liste) =>
|
||||||
|
liste.map((e, i) => (i === liste.length - 1 ? { ...e, reponse } : e)),
|
||||||
|
),
|
||||||
|
onError: (erreur) =>
|
||||||
|
setEchanges((liste) =>
|
||||||
|
liste.map((e, i) => (i === liste.length - 1 ? { ...e, erreur: erreur.message } : e)),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="entete-page">
|
||||||
|
<h1>Assistant</h1>
|
||||||
|
<span className="filajout">répond UNIQUEMENT depuis votre bibliothèque et vos historiques</span>
|
||||||
|
</div>
|
||||||
|
<div className="chat" aria-live="polite">
|
||||||
|
{echanges.length === 0 ? (
|
||||||
|
<div className="carte" style={{ color: 'var(--encre-2)', maxWidth: 760 }}>
|
||||||
|
Posez une question sur vos notices, vos historiques d'intervention ou vos procédures —
|
||||||
|
chaque réponse cite ses sources (document et page, ou bilan d'OT). Quand le corpus ne
|
||||||
|
porte pas la réponse, l'assistant le dit au lieu d'inventer.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{echanges.map((echange, i) => (
|
||||||
|
<div key={i} style={{ display: 'contents' }}>
|
||||||
|
<div className="msg-q">{echange.question}</div>
|
||||||
|
{echange.reponse ? (
|
||||||
|
<Reponse reponse={echange.reponse} surReformuler={() => saisieRef.current?.focus()} />
|
||||||
|
) : echange.erreur ? (
|
||||||
|
<div className="refus" role="alert">
|
||||||
|
<b>{echange.erreur}</b>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="msg-r" style={{ color: 'var(--encre-3)' }}>Recherche dans le corpus…</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="saisie-chat">
|
||||||
|
<input
|
||||||
|
ref={saisieRef}
|
||||||
|
value={question}
|
||||||
|
maxLength={500}
|
||||||
|
placeholder="Poser une question (notices, historiques, procédures)…"
|
||||||
|
aria-label="Poser une question"
|
||||||
|
onChange={(e) => setQuestion(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') envoyer();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button variant="prim" disabled={question.trim().length < 3 || ask.isPending} onClick={envoyer}>
|
||||||
|
Envoyer
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,17 +5,26 @@ import {
|
|||||||
DOCUMENT_MAX_BYTES,
|
DOCUMENT_MAX_BYTES,
|
||||||
type DocumentKind,
|
type DocumentKind,
|
||||||
} from '@siop/shared';
|
} from '@siop/shared';
|
||||||
import { useDeleteDocument, useDocuments, useUploadDocument } from '@/api/gestion';
|
import { useReindexAssistant } from '@/api/assistant';
|
||||||
|
import {
|
||||||
|
ouvrirDocument,
|
||||||
|
telechargerDocument,
|
||||||
|
useDeleteDocument,
|
||||||
|
useDocuments,
|
||||||
|
useUploadDocument,
|
||||||
|
} from '@/api/gestion';
|
||||||
import { useAssetOptions } from '@/api/referentiel';
|
import { useAssetOptions } from '@/api/referentiel';
|
||||||
import { usePermissions } from '@/auth/use-permissions';
|
import { usePermissions } from '@/auth/use-permissions';
|
||||||
import { tailleLisible, VignetteDoc } from '@/components/carte-documents';
|
import { InterrupteurCorpus, StatutCorpus, tailleLisible } from '@/components/carte-documents';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Modale } from '@/components/ui/modale';
|
import { Modale } from '@/components/ui/modale';
|
||||||
|
|
||||||
/** Écran 7 des maquettes R3 : notices, certificats, photos — toujours
|
/** Écran 7 des maquettes R3, devenu écran 5 de R5 : la bibliothèque EST le
|
||||||
* RATTACHÉS (appareil ou OT). Ces documents nourriront le RAG en R5. */
|
* corpus de l'assistant — statut d'indexation visible, interrupteur
|
||||||
|
* d'exclusion réversible, réindexation par geste explicite (D3). */
|
||||||
export default function PageBibliotheque() {
|
export default function PageBibliotheque() {
|
||||||
const { can } = usePermissions();
|
const { can } = usePermissions();
|
||||||
|
const reindex = useReindexAssistant();
|
||||||
const [kind, setKind] = useState<DocumentKind | ''>('');
|
const [kind, setKind] = useState<DocumentKind | ''>('');
|
||||||
const [assetId, setAssetId] = useState('');
|
const [assetId, setAssetId] = useState('');
|
||||||
const { data: options } = useAssetOptions();
|
const { data: options } = useAssetOptions();
|
||||||
@@ -29,7 +38,9 @@ export default function PageBibliotheque() {
|
|||||||
const [survol, setSurvol] = useState(false);
|
const [survol, setSurvol] = useState(false);
|
||||||
|
|
||||||
const totalOctets = (documents ?? []).reduce((s, d) => s + d.size, 0);
|
const totalOctets = (documents ?? []).reduce((s, d) => s + d.size, 0);
|
||||||
|
const indexes = (documents ?? []).filter((d) => d.indexedAt && d.inCorpus).length;
|
||||||
const peutEditer = can('WORK_ORDERS', 'edit') || can('ASSETS', 'edit');
|
const peutEditer = can('WORK_ORDERS', 'edit') || can('ASSETS', 'edit');
|
||||||
|
const administreCorpus = can('ASSETS', 'edit');
|
||||||
|
|
||||||
const surDepot = (e: DragEvent) => {
|
const surDepot = (e: DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -43,16 +54,38 @@ export default function PageBibliotheque() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="entete-page">
|
<div className="entete-page">
|
||||||
<h1>Bibliothèque</h1>
|
<h1>Bibliothèque — corpus de l'assistant</h1>
|
||||||
<span className="filajout">
|
<span className="filajout">
|
||||||
{documents?.length ?? 0} documents · {tailleLisible(totalOctets)}
|
{documents?.length ?? 0} document{(documents?.length ?? 0) > 1 ? 's' : ''} · {indexes}{' '}
|
||||||
|
indexé{indexes > 1 ? 's' : ''} · {tailleLisible(totalOctets)}
|
||||||
</span>
|
</span>
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
|
{administreCorpus ? (
|
||||||
|
<Button disabled={reindex.isPending} onClick={() => reindex.mutate()}>
|
||||||
|
{reindex.isPending ? 'Réindexation…' : 'Réindexer tout'}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
{peutEditer ? (
|
{peutEditer ? (
|
||||||
<Button variant="prim" onClick={() => setModale(true)}>Téléverser</Button>
|
<Button variant="prim" onClick={() => setModale(true)}>Téléverser</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="avert">
|
||||||
|
🛡 Loi 09-08 — anonymisation à l'ingestion : noms, téléphones et e-mails des personnes ne
|
||||||
|
sont JAMAIS envoyés dans les index ni aux modèles.
|
||||||
|
</div>
|
||||||
|
{reindex.isSuccess ? (
|
||||||
|
<div className="carte" style={{ color: 'var(--encre-2)', fontSize: 13 }}>
|
||||||
|
Réindexation terminée : {reindex.data.documentsIndexed} document
|
||||||
|
{reindex.data.documentsIndexed > 1 ? 's' : ''} indexé
|
||||||
|
{reindex.data.documentsIndexed > 1 ? 's' : ''}, {reindex.data.documentsSkipped} ignoré
|
||||||
|
{reindex.data.documentsSkipped > 1 ? 's' : ''} (exclus ou non indexables),{' '}
|
||||||
|
{reindex.data.reportsIndexed} bilans, {reindex.data.chunks} extraits.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{reindex.isError ? (
|
||||||
|
<p className="erreur-form" role="alert">{reindex.error.message}</p>
|
||||||
|
) : null}
|
||||||
<div className="filtres">
|
<div className="filtres">
|
||||||
<select
|
<select
|
||||||
aria-label="Filtrer par type"
|
aria-label="Filtrer par type"
|
||||||
@@ -94,21 +127,88 @@ export default function PageBibliotheque() {
|
|||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
{documents?.length ? (
|
{documents?.length ? (
|
||||||
<div className="docs">
|
<div className="carte" style={{ padding: 0 }}>
|
||||||
{documents.map((d) => (
|
<div className="table">
|
||||||
<VignetteDoc
|
<table>
|
||||||
key={d.id}
|
<thead>
|
||||||
doc={d}
|
<tr>
|
||||||
surSuppression={peutEditer ? (id) => suppression.mutate(id) : undefined}
|
<th>Document</th>
|
||||||
/>
|
<th>Rattaché à</th>
|
||||||
))}
|
<th>Indexation</th>
|
||||||
|
{administreCorpus ? <th>Corpus</th> : null}
|
||||||
|
<th aria-label="Actions" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{documents.map((d) => (
|
||||||
|
<tr key={d.id}>
|
||||||
|
<td>
|
||||||
|
<b>{d.fileName}</b>
|
||||||
|
<span className="sous">
|
||||||
|
{DOCUMENT_KIND_LABELS[d.kind]} · {tailleLisible(d.size)} ·{' '}
|
||||||
|
{new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium' }).format(
|
||||||
|
new Date(d.createdAt),
|
||||||
|
)}
|
||||||
|
{d.uploadedByName ? ` · ${d.uploadedByName}` : ''}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{d.assetReference ? `Asc. ${d.assetReference}` : null}
|
||||||
|
{d.assetReference && d.workOrderReference ? ' · ' : null}
|
||||||
|
{d.workOrderReference ?? null}
|
||||||
|
{!d.assetReference && !d.workOrderReference ? '—' : null}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<StatutCorpus doc={d} />
|
||||||
|
</td>
|
||||||
|
{administreCorpus ? (
|
||||||
|
<td>
|
||||||
|
<InterrupteurCorpus doc={d} />
|
||||||
|
</td>
|
||||||
|
) : null}
|
||||||
|
<td style={{ whiteSpace: 'nowrap', textAlign: 'right' }}>
|
||||||
|
<span style={{ display: 'inline-flex', gap: 10 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void ouvrirDocument(d.id)}
|
||||||
|
style={{ color: 'var(--primaire)', fontWeight: 600, fontSize: 12.5 }}
|
||||||
|
>
|
||||||
|
Ouvrir
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void telechargerDocument(d.id, d.fileName)}
|
||||||
|
style={{ color: 'var(--primaire)', fontWeight: 600, fontSize: 12.5 }}
|
||||||
|
>
|
||||||
|
Télécharger
|
||||||
|
</button>
|
||||||
|
{peutEditer ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(`Supprimer « ${d.fileName} » ?`)) {
|
||||||
|
suppression.mutate(d.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{ color: 'var(--danger)', fontWeight: 600, fontSize: 12.5 }}
|
||||||
|
>
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="carte" style={{ color: 'var(--encre-2)' }}>Aucun document.</div>
|
<div className="carte" style={{ color: 'var(--encre-2)' }}>Aucun document.</div>
|
||||||
)}
|
)}
|
||||||
<div className="carte" style={{ borderStyle: 'dashed', color: 'var(--encre-2)', fontSize: 13 }}>
|
<div className="carte" style={{ borderStyle: 'dashed', color: 'var(--encre-2)', fontSize: 13 }}>
|
||||||
Ces documents nourriront l'assistant RAG en R5 (réponses citant leurs sources) —
|
Le corpus de l'assistant, c'est cette bibliothèque (PDF indexés) plus les bilans
|
||||||
le rattachement propre commence ici.
|
d'intervention codés — rien d'externe. Les réponses citent leurs sources.
|
||||||
</div>
|
</div>
|
||||||
{peutEditer ? (
|
{peutEditer ? (
|
||||||
<ModaleTeleversement
|
<ModaleTeleversement
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import {
|
|||||||
REQUIRED_BILAN_FIELDS,
|
REQUIRED_BILAN_FIELDS,
|
||||||
WORK_ORDER_TYPE_LABELS,
|
WORK_ORDER_TYPE_LABELS,
|
||||||
type BilanField,
|
type BilanField,
|
||||||
|
type BilanSuggestion,
|
||||||
type WorkOrderDetail,
|
type WorkOrderDetail,
|
||||||
type WorkOrderStatus,
|
type WorkOrderStatus,
|
||||||
} from '@siop/shared';
|
} from '@siop/shared';
|
||||||
|
import { useSuggestBilan } from '@/api/assistant';
|
||||||
import {
|
import {
|
||||||
useCommentWorkOrder,
|
useCommentWorkOrder,
|
||||||
usePatchChecklist,
|
usePatchChecklist,
|
||||||
@@ -478,10 +480,108 @@ const CHAMPS_BILAN: { champ: BilanField; cle: keyof NonNullable<WorkOrderDetail[
|
|||||||
{ champ: 'COMPONENT_CONCERNED', cle: 'componentConcerned', dto: 'componentConcernedId' },
|
{ champ: 'COMPONENT_CONCERNED', cle: 'componentConcerned', dto: 'componentConcernedId' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Écran 3 des maquettes R5 : décrire la panne en français libre → l'IA
|
||||||
|
* propose des codes EXISTANTS avec justification et confiance (D1). Rien ne
|
||||||
|
* s'écrit sans le geste humain : « Appliquer » est ce geste — chaque champ
|
||||||
|
* appliqué garde son liseré « suggéré » jusqu'à modification manuelle. */
|
||||||
|
function ZoneSuggestion({
|
||||||
|
surAppliquer,
|
||||||
|
appliqueTout,
|
||||||
|
}: {
|
||||||
|
surAppliquer: (s: BilanSuggestion) => void;
|
||||||
|
appliqueTout: (liste: BilanSuggestion[]) => void;
|
||||||
|
}) {
|
||||||
|
const suggerer = useSuggestBilan();
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [masquees, setMasquees] = useState(false);
|
||||||
|
const suggestions = masquees ? [] : (suggerer.data?.suggestions ?? []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="champ-b" style={{ gridColumn: '1 / -1' }}>
|
||||||
|
<label htmlFor="sug-description">Décrire pour suggérer (optionnel)</label>
|
||||||
|
<textarea
|
||||||
|
id="sug-description"
|
||||||
|
className="zone-libre"
|
||||||
|
placeholder="Décrivez la panne et ce que vous avez fait — l'IA proposera les codes du bilan…"
|
||||||
|
value={description}
|
||||||
|
maxLength={2000}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="actions-sug">
|
||||||
|
<Button
|
||||||
|
variant="prim"
|
||||||
|
disabled={description.trim().length < 10 || suggerer.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
setMasquees(false);
|
||||||
|
suggerer.mutate({ description: description.trim() });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{suggerer.isPending ? 'Analyse…' : '✨ Suggérer les codes'}
|
||||||
|
</Button>
|
||||||
|
<span style={{ fontSize: 11.5, color: 'var(--encre-3)', alignSelf: 'center' }}>
|
||||||
|
La description n'écrit rien toute seule — vous appliquez, ou pas.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{suggerer.isError ? (
|
||||||
|
<p className="erreur-form" role="alert">{suggerer.error.message}</p>
|
||||||
|
) : null}
|
||||||
|
{suggerer.isSuccess && suggestions.length === 0 && !masquees ? (
|
||||||
|
<p style={{ fontSize: 12.5, color: 'var(--encre-2)' }}>
|
||||||
|
Aucun code assez proche de cette description — l'IA ne devine pas : choisissez dans les
|
||||||
|
sélecteurs.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{suggestions.length > 0 ? (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: 8 }}>
|
||||||
|
{suggestions.map((s) => (
|
||||||
|
<div className="suggestion" key={s.field}>
|
||||||
|
<span className="ia">✨</span>
|
||||||
|
<div>
|
||||||
|
<b>
|
||||||
|
{BILAN_FIELD_LABELS[s.field]} → « {s.label} »
|
||||||
|
</b>
|
||||||
|
<div className="just">
|
||||||
|
{s.similarReports > 0
|
||||||
|
? `${s.similarReports} bilan${s.similarReports > 1 ? 's' : ''} similaire${s.similarReports > 1 ? 's' : ''} sur ce parc`
|
||||||
|
: 'proche de votre description'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="confiance">
|
||||||
|
confiance {s.confidence === 'HIGH' ? 'forte' : 'moyenne'}
|
||||||
|
</span>
|
||||||
|
<Button onClick={() => surAppliquer(s)}>Appliquer</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="actions-sug">
|
||||||
|
<Button variant="prim" onClick={() => appliqueTout(suggestions)}>
|
||||||
|
Appliquer les {suggestions.length} (pré-remplir)
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => setMasquees(true)}>Ignorer</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function CarteBilan({ ot, peutEditer }: { ot: WorkOrderDetail; peutEditer: boolean }) {
|
function CarteBilan({ ot, peutEditer }: { ot: WorkOrderDetail; peutEditer: boolean }) {
|
||||||
const { data: valeurs } = useReferenceValues();
|
const { data: valeurs } = useReferenceValues();
|
||||||
const maj = useUpsertReport(ot.id);
|
const maj = useUpsertReport(ot.id);
|
||||||
const bloqueurBilan = ot.closureBlockers.find((b) => b.includes('bilan'));
|
const bloqueurBilan = ot.closureBlockers.find((b) => b.includes('bilan'));
|
||||||
|
const [suggeres, setSuggeres] = useState<Set<BilanField>>(new Set());
|
||||||
|
const modifiable = peutEditer && ot.status !== 'DONE' && ot.status !== 'CANCELLED';
|
||||||
|
|
||||||
|
const appliquer = (liste: BilanSuggestion[]) => {
|
||||||
|
const corps = Object.fromEntries(
|
||||||
|
liste.map((s) => [CHAMPS_BILAN.find((c) => c.champ === s.field)!.dto, s.valueId]),
|
||||||
|
);
|
||||||
|
maj.mutate(corps, {
|
||||||
|
onSuccess: () =>
|
||||||
|
setSuggeres((avant) => new Set([...avant, ...liste.map((s) => s.field)])),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="carte">
|
<div className="carte">
|
||||||
@@ -491,13 +591,19 @@ function CarteBilan({ ot, peutEditer }: { ot: WorkOrderDetail; peutEditer: boole
|
|||||||
— requis pour clôturer
|
— requis pour clôturer
|
||||||
</span>
|
</span>
|
||||||
</h2>
|
</h2>
|
||||||
|
{modifiable ? (
|
||||||
|
<ZoneSuggestion
|
||||||
|
surAppliquer={(s) => appliquer([s])}
|
||||||
|
appliqueTout={appliquer}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<div className="bilan">
|
<div className="bilan">
|
||||||
{CHAMPS_BILAN.map(({ champ, cle, dto }) => {
|
{CHAMPS_BILAN.map(({ champ, cle, dto }) => {
|
||||||
const options = (valeurs ?? []).filter((v) => v.field === champ && v.isActive);
|
const options = (valeurs ?? []).filter((v) => v.field === champ && v.isActive);
|
||||||
const valeur = ot.report?.[cle as 'doorState'] ?? null;
|
const valeur = ot.report?.[cle as 'doorState'] ?? null;
|
||||||
const requis = REQUIRED_BILAN_FIELDS.includes(champ);
|
const requis = REQUIRED_BILAN_FIELDS.includes(champ);
|
||||||
return (
|
return (
|
||||||
<div className="champ-b" key={champ}>
|
<div className="champ-b" key={champ} data-suggere={suggeres.has(champ) || undefined}>
|
||||||
<label htmlFor={`bilan-${champ}`}>
|
<label htmlFor={`bilan-${champ}`}>
|
||||||
{BILAN_FIELD_LABELS[champ]} {requis ? <em>*</em> : null}
|
{BILAN_FIELD_LABELS[champ]} {requis ? <em>*</em> : null}
|
||||||
</label>
|
</label>
|
||||||
@@ -505,7 +611,15 @@ function CarteBilan({ ot, peutEditer }: { ot: WorkOrderDetail; peutEditer: boole
|
|||||||
id={`bilan-${champ}`}
|
id={`bilan-${champ}`}
|
||||||
disabled={!peutEditer || maj.isPending || ot.status === 'DONE' || ot.status === 'CANCELLED'}
|
disabled={!peutEditer || maj.isPending || ot.status === 'DONE' || ot.status === 'CANCELLED'}
|
||||||
value={valeur?.id ?? ''}
|
value={valeur?.id ?? ''}
|
||||||
onChange={(e) => maj.mutate({ [dto]: e.target.value || null })}
|
onChange={(e) => {
|
||||||
|
// choix manuel : le liseré « suggéré » n'a plus lieu d'être
|
||||||
|
setSuggeres((avant) => {
|
||||||
|
const suite = new Set(avant);
|
||||||
|
suite.delete(champ);
|
||||||
|
return suite;
|
||||||
|
});
|
||||||
|
maj.mutate({ [dto]: e.target.value || null });
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<option value="">Sélectionner…</option>
|
<option value="">Sélectionner…</option>
|
||||||
{options.map((o) => (
|
{options.map((o) => (
|
||||||
|
|||||||
@@ -1016,3 +1016,91 @@ table {
|
|||||||
border: 1px solid var(--bordure);
|
border: 1px solid var(--bordure);
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ═══ R5 · Assistant (chat sourcé — maquette-r5, écrans 1-2) ═══ */
|
||||||
|
.chat { display: flex; flex-direction: column; gap: 12px; max-width: 760px; }
|
||||||
|
.msg-q {
|
||||||
|
align-self: flex-end; background: var(--primaire); color: #fff;
|
||||||
|
border-radius: 14px 14px 4px 14px; padding: 10px 14px; max-width: 75%; font-size: 13.5px;
|
||||||
|
}
|
||||||
|
.msg-r {
|
||||||
|
background: var(--surface); border: 1px solid var(--bordure);
|
||||||
|
border-radius: 14px 14px 14px 4px; padding: 12px 14px; max-width: 88%;
|
||||||
|
font-size: 13.5px; display: flex; flex-direction: column; gap: 10px;
|
||||||
|
}
|
||||||
|
.msg-r p b { color: var(--encre); }
|
||||||
|
.cite {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center; min-width: 16px; height: 16px;
|
||||||
|
border-radius: 5px; background: var(--primaire-doux); color: var(--primaire);
|
||||||
|
font-size: 10.5px; font-weight: 800; vertical-align: 2px; margin: 0 1px;
|
||||||
|
}
|
||||||
|
.sources {
|
||||||
|
display: flex; flex-direction: column; gap: 6px;
|
||||||
|
border-top: 1px dashed var(--bordure); padding-top: 10px;
|
||||||
|
}
|
||||||
|
.source {
|
||||||
|
display: flex; gap: 10px; align-items: flex-start; background: var(--surface-2);
|
||||||
|
border-radius: 9px; padding: 8px 10px;
|
||||||
|
}
|
||||||
|
.source .no {
|
||||||
|
flex: none; width: 18px; height: 18px; border-radius: 5px; background: var(--primaire-doux);
|
||||||
|
color: var(--primaire); display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 11px; font-weight: 800;
|
||||||
|
}
|
||||||
|
.source b { font-size: 12.5px; }
|
||||||
|
.source .ou { color: var(--encre-2); font-size: 11.5px; }
|
||||||
|
.source .extrait {
|
||||||
|
color: var(--encre-2); font-size: 12px; font-style: italic;
|
||||||
|
border-left: 2px solid var(--safran); padding-left: 8px; margin-top: 3px;
|
||||||
|
}
|
||||||
|
.source .ouvrir {
|
||||||
|
margin-left: auto; color: var(--primaire); font-weight: 700; font-size: 12px; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.avert {
|
||||||
|
display: flex; gap: 8px; align-items: center; background: var(--safran-doux); color: var(--alerte);
|
||||||
|
border-radius: 9px; padding: 8px 10px; font-size: 12px; font-weight: 600;
|
||||||
|
}
|
||||||
|
.saisie-chat { display: flex; gap: 8px; max-width: 760px; }
|
||||||
|
.saisie-chat input {
|
||||||
|
flex: 1; border: 1.5px solid var(--bordure-forte); border-radius: 10px;
|
||||||
|
background: var(--surface); padding: 11px 13px; font: inherit; color: var(--encre);
|
||||||
|
}
|
||||||
|
.refus {
|
||||||
|
background: var(--surface); border: 1.5px dashed var(--bordure-forte); border-radius: 14px;
|
||||||
|
padding: 12px 14px; max-width: 88%; font-size: 13.5px;
|
||||||
|
display: flex; flex-direction: column; gap: 8px;
|
||||||
|
}
|
||||||
|
.refus b { color: var(--encre); }
|
||||||
|
.refus .pourquoi { color: var(--encre-2); font-size: 12.5px; }
|
||||||
|
|
||||||
|
/* ═══ R5 · Suggestion de bilan (écran 3) ═══ */
|
||||||
|
.zone-libre {
|
||||||
|
width: 100%; min-height: 74px; border: 1.5px solid var(--bordure-forte); border-radius: 10px;
|
||||||
|
background: var(--surface); padding: 10px 12px; font: inherit; font-size: 13px; color: var(--encre);
|
||||||
|
}
|
||||||
|
.suggestion {
|
||||||
|
display: flex; gap: 10px; align-items: flex-start; border: 1.5px solid var(--primaire);
|
||||||
|
background: var(--primaire-doux); border-radius: 10px; padding: 10px 12px;
|
||||||
|
}
|
||||||
|
.suggestion .ia { flex: none; font-size: 15px; }
|
||||||
|
.suggestion b { font-size: 13px; }
|
||||||
|
.suggestion .just { color: var(--encre-2); font-size: 12px; }
|
||||||
|
.confiance {
|
||||||
|
margin-left: auto; font-size: 10.5px; font-weight: 800; color: var(--primaire); white-space: nowrap;
|
||||||
|
}
|
||||||
|
.actions-sug { display: flex; gap: 8px; margin-top: 4px; flex-wrap: wrap; }
|
||||||
|
/* Liseré « suggéré » : la valeur vient d'une suggestion APPLIQUÉE par l'humain */
|
||||||
|
.champ-b[data-suggere] { position: relative; }
|
||||||
|
.champ-b[data-suggere] select { border-color: var(--primaire); background: var(--primaire-doux); }
|
||||||
|
.champ-b[data-suggere]::after {
|
||||||
|
content: 'suggéré'; position: absolute; top: 14px; right: 8px; font-size: 9px; font-weight: 800;
|
||||||
|
color: var(--primaire); background: var(--surface); padding: 0 5px; border-radius: 99px;
|
||||||
|
border: 1px solid var(--primaire); pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══ R5 · Corpus (écran 5) ═══ */
|
||||||
|
.st.ok { color: var(--st-termine); }
|
||||||
|
.st.ok::before { background: var(--st-termine); }
|
||||||
|
.st.exclu { color: var(--encre-3); }
|
||||||
|
.st.exclu::before { background: var(--encre-3); }
|
||||||
|
.interrupteur.corpus[aria-checked='true'] { background: var(--succes); }
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user