mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
Compare commits
45 Commits
release/r2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a79551a4a3 | ||
|
|
39320d081e | ||
|
|
19076a91d3 | ||
|
|
ed9833e56d | ||
|
|
c9168ece16 | ||
|
|
1ae0529a6a | ||
|
|
9aaee4e358 | ||
|
|
63656565f7 | ||
|
|
6f74fd4206 | ||
|
|
be5dfb6bc7 | ||
|
|
ac8b92e9c5 | ||
|
|
700bd4e7ff | ||
|
|
4c1a2d67a0 | ||
|
|
f210d4f801 | ||
|
|
21dd6cf034 | ||
|
|
ab0f260d0f | ||
|
|
3e9e708116 | ||
|
|
4af3f5668a | ||
|
|
59ed6f3952 | ||
|
|
0730bf9dad | ||
|
|
189e8e8162 | ||
|
|
557435b81a | ||
|
|
cccfaaabc9 | ||
|
|
b69c54ed0f | ||
|
|
28eecc1fb9 | ||
|
|
45ae491827 | ||
|
|
76c2ccdfb1 | ||
|
|
d4d73a6f76 | ||
|
|
837dcba1db | ||
|
|
199fce69d0 | ||
|
|
a5e8ca6d62 | ||
|
|
c8b3c1769a | ||
|
|
a22ea60f83 | ||
|
|
514fc7c391 | ||
|
|
5c8d5aac92 | ||
|
|
2736a2f3a1 | ||
|
|
3d787a2d39 | ||
|
|
460ef4a80e | ||
|
|
c46a97ed08 | ||
|
|
c5382a5538 | ||
|
|
a4e98c2000 | ||
|
|
2ffdd13cf4 | ||
|
|
32eb20b5a0 | ||
|
|
dfdf8f7c18 | ||
|
|
d5041c6f05 |
119
.github/workflows/ci.yml
vendored
119
.github/workflows/ci.yml
vendored
@@ -44,14 +44,15 @@ jobs:
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- 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: |
|
||||
pnpm --filter @siop/shared contract
|
||||
pnpm --filter @siop/web generate:client
|
||||
pnpm --filter @siop/mobile generate:client
|
||||
- name: Vérifier qu'aucun artefact ne dérive du contrat
|
||||
run: |
|
||||
if ! git diff --exit-code -- docs/openapi.json apps/web/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)."
|
||||
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 && pnpm --filter @siop/mobile generate:client)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -59,18 +60,8 @@ jobs:
|
||||
name: api (tests + couverture ≥ 70 %)
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
# R1+ : la migration r1_referentiel exige PostGIS (colonne générée geography).
|
||||
# pgvector arrivera en R5 (bascule alors sur l’image infra/postgres).
|
||||
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
|
||||
# PostgreSQL vient d'infra/postgres (pgvector + PostGIS — R5) : les
|
||||
# services ne savent pas builder, il démarre donc par étape ci-dessous.
|
||||
redis:
|
||||
image: redis:7.4-alpine
|
||||
ports: ['6379:6379']
|
||||
@@ -81,8 +72,25 @@ jobs:
|
||||
DATABASE_URL: postgresql://siop:siop@localhost:5432/siop
|
||||
REDIS_URL: redis://localhost:6379
|
||||
JWT_SECRET: ci-only-secret-0123456789abcdef
|
||||
MINIO_ENDPOINT: localhost
|
||||
MINIO_ACCESS_KEY: siop
|
||||
MINIO_SECRET_KEY: siop-minio
|
||||
steps:
|
||||
- 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)
|
||||
run: |
|
||||
docker run -d --name minio -p 9000:9000 \
|
||||
-e MINIO_ROOT_USER=siop -e MINIO_ROOT_PASSWORD=siop-minio \
|
||||
minio/minio:RELEASE.2025-09-07T16-13-09Z server /data
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
@@ -110,20 +118,40 @@ jobs:
|
||||
- run: pnpm --filter @siop/web test
|
||||
- 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:
|
||||
name: e2e (parcours démo Playwright)
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
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
|
||||
# PostgreSQL vient d'infra/postgres (pgvector + PostGIS — R5) : les
|
||||
# services ne savent pas builder, il démarre donc par étape ci-dessous.
|
||||
redis:
|
||||
image: redis:7.4-alpine
|
||||
ports: ['6379:6379']
|
||||
@@ -134,8 +162,25 @@ jobs:
|
||||
DATABASE_URL: postgresql://siop:siop@localhost:5432/siop
|
||||
REDIS_URL: redis://localhost:6379
|
||||
JWT_SECRET: ci-only-secret-0123456789abcdef
|
||||
MINIO_ENDPOINT: localhost
|
||||
MINIO_ACCESS_KEY: siop
|
||||
MINIO_SECRET_KEY: siop-minio
|
||||
steps:
|
||||
- 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)
|
||||
run: |
|
||||
docker run -d --name minio -p 9000:9000 \
|
||||
-e MINIO_ROOT_USER=siop -e MINIO_ROOT_PASSWORD=siop-minio \
|
||||
minio/minio:RELEASE.2025-09-07T16-13-09Z server /data
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
@@ -146,8 +191,30 @@ jobs:
|
||||
- run: pnpm --filter @siop/api prisma:generate
|
||||
- run: pnpm --filter @siop/api exec prisma migrate deploy
|
||||
- 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 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
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -164,7 +231,7 @@ jobs:
|
||||
deploy:
|
||||
name: deploy (Dokploy — siop2.apps.enset.top)
|
||||
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
|
||||
environment: production
|
||||
steps:
|
||||
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -2,8 +2,17 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
# apps/mobile/.env ne porte aucun secret — un réglage de build (fetch RN
|
||||
# classique, cf. ADR-005) qui doit être le même pour tout le monde.
|
||||
!apps/mobile/.env
|
||||
.turbo/
|
||||
coverage/
|
||||
*.tsbuildinfo
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
||||
# Python (apps/ai)
|
||||
.venv/
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
|
||||
43
CLAUDE.md
43
CLAUDE.md
@@ -10,7 +10,7 @@ Projet **réel** (SPELEV, maintenance d'ascenseurs, Maroc) mené par le Pr. Daai
|
||||
|
||||
1. **Design-first** : aucune ligne de code applicatif avant validation par le référent de la charte graphique, des tokens et des maquettes HD (`docs/02-design/`). Chaque release subit une revue « pixel » écrans ↔ maquettes.
|
||||
2. **Playbook vivant** : chaque phase du cycle de vie a son dossier `docs/0X-*/` (template réutilisable + artefacts réels). Le journal quotidien vit dans `docs/journal/` — **jamais** dans le livre.
|
||||
3. **Périmètre fermé par release** : R0 Fondations → R1 Référentiel → R2 Exploitation → R3 Gestion → R4 Mobile → R5 IA. On n'ouvre pas Rn+1 avant recette **et** déploiement de Rn.
|
||||
3. **Périmètre fermé par release** : R0 Fondations → R1 Référentiel → R2 Exploitation → R3 Gestion → R4 Mobile → R5 IA → R6 Mobile tous rôles. On n'ouvre pas Rn+1 avant recette **et** déploiement de Rn.
|
||||
4. **Déployer tôt** : chaque release part sur le serveur de production (Dokploy) dès sa recette.
|
||||
5. **Le développeur est le premier utilisateur** : `DEMO_MODE=true` active un **sélecteur de compte démo** (connexion 1 clic sur les comptes seedés, endpoint `POST /auth/demo-login` strictement absent si l'env ne l'active pas). Critère : changer de rôle en < 3 s sans mot de passe.
|
||||
|
||||
@@ -48,6 +48,41 @@ pnpm + Turborepo. `apps/api` : NestJS, Prisma, PostgreSQL (pgvector + PostGIS),
|
||||
- ✅ **R2.1 — socle backend exploitation** : migration `r2_exploitation` (10 tables), 15 opérations au contrat (41 total, transitions + champs requis du bilan dans `@siop/shared`), machine à états stricte avec garde de clôture (bilan 3 champs + checklist), demande→OT 1-1, rejet à motif, scoping « voir autre » (listes + accès directs), seed maquette (31 valeurs de bilan, 8 gabarits, OT/demandes/compteurs), 45 tests (95 %/79 %).
|
||||
- ✅ **R2.2 — préventif + compteurs** : `underContract` + `periodKey` (unicité `[assetId, periodKey]` = idempotence EN BASE), génération mensuelle (ancrage mise en service, premier contrôle, gabarits administrables, statut du mois), compteurs strictement croissants ; +7 opérations (48), 50 tests (94,7 %/78 %). Automatisation cron/BullMQ notée pour le durcissement production.
|
||||
- ✅ **R2.3 — écrans web exploitation** : liste/fiche OT (transitions via `allowedTransitions`, garde visible, bilan codé, checklist cliquable, activité), demandes+approbation/rejet motivé, nouvel OT (interrupteur urgence), préventif (tuiles+générer+gabarits), compteurs, tableau de bord réel, urgence traversante (chip topbar, badges, bandeau) ; `GET /assets/options` (trou Demandeur corrigé) ; retry sur collision de référence dans la génération ; 9 Playwright verts (recette R2 complète), 50 tests API.
|
||||
- ✅ **R2.4 — portail public QR** : `/q/{réf}` sans compte (écran validé R0, mobile d'abord), 3 routes publiques `/portal` throttlées, suivi par jeton opaque (`Request.publicToken`, jamais de liste exposée), étapes Reçu → Intervention → Résolu dérivées de l'OT ; boucle produit complète en e2e (11 Playwright verts, 52 opérations).
|
||||
- 🔄 **R2 — reste pour clore** : recette R2 avec le référent (revue pixel ↔ maquettes R0+R2, sur `pnpm dev` ou l'instance en ligne après déploiement), tag `release/r2` → ouverture R3 Gestion (stock, BC, tiers, main-d'œuvre, analytics, bibliothèque — maquettes à produire d'abord).
|
||||
- Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0→R5.
|
||||
- 🏁 **R2 CLOSE (16/07/2026, tag `release/r2`)** : recettée (1 anomalie corrigée en recette : tri « Interventions récentes »), déployée et vérifiée en ligne (portail `/q/A1`, urgence en tête, préventif de juillet généré).
|
||||
- ✅ **R3 — maquettes validées** (16/07) + **R3.1 socle backend gestion** : migration `r3_gestion` (Partner, Part sans colonne de quantité, StockMovement signé/tracé/PU figé, PurchaseOrder, LaborTime taux figé, Document, User.hourlyRate) ; 66 opérations ; stock = Σ mouvements (jamais négatif, en transaction), réception BC → RECEIPT + lastUnitPrice, conso/MO à prix/taux FIGÉS, `WorkOrderDetail.costs` immuable après clôture ; seed maquette (OT-0341 = 505 MAD, testé) ; 55 tests (92 %/74,9 %). **Durcissement : références par séquences Postgres** (fin des courses max+1).
|
||||
- ✅ **R3.2 — bibliothèque + analytics** : `FileStorage` complet (put/stream/remove, bucket auto), upload multipart typé (20 Mo, rattachement requis, permission sur la cible), download **streamé par l'API**, octets vérifiés à l'identique en e2e ; `GET /analytics/summary` dérivé du réel (pannes par organe via bilans codés, coûts figés, taux préventif, top équipements) ; MinIO en CI ; 71 opérations, 58 tests (6 runs consécutifs verts).
|
||||
- ✅ **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 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.
|
||||
- ✅ **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.
|
||||
- 🏁 **R5 CLOSE (17/07/2026, tag `release/r5`)** : recettée par le référent (revue pixel + arbitrage tableau), recette passée SANS clé API, durcissement répété en local conteneurisé. **R0 → R5 : périmètre v1 couvert.**
|
||||
- ✅ **Recette terrain mobile sur iPhone 15 Pro — 7/7 validés (19/07, ADR-005)** : Expo Go bloqué par le retard d'approbation Apple (SDK 54 vs notre SDK 57) → **décision : builds natifs locaux (Xcode/Android Studio, signature gratuite) pour la vraie recette, Expo Go conservé pour l'aperçu sans installation**. 5 obstacles techniques réels corrigés et documentés (UDID plutôt que nom, ne jamais contourner `expo run:*` par un xcodebuild manuel, modules Expo/RN précompilés SDK 56/57 incompatibles avec la liaison statique du projet, `fetch` global incompatible avec l'upload multipart natif — fix permanent `EXPO_PUBLIC_USE_RN_FETCH=1` dans `apps/mobile/.env`, découverte réseau du dev client peu fiable). Recette iOS : connexion, scan caméra réel (résolution + rejet QR étranger), vrai mode avion, conflit D2 tranché par l'humain avec photo réellement téléversée, persistance à travers fermeture/reconstruction de l'app, suggestions R5 au vrai modèle, purge de sécurité au changement de compte — tout validé. Android : build natif sur émulateur, passage santé complet.
|
||||
- ✅ **Dictée implémentée (22/07, ADR-004 §5)** : écran Voix R5 (jamais construit) — `faster-whisper` local (CTranslate2, CPU, opt-in `AI_TRANSCRIPTION=off|locale`, défaut off), audio jamais persisté (purge serveur ET mobile quoi qu'il arrive), transcription → relecture humaine → suggestion immédiate et/ou sauvegarde dans `InterventionReport.note` (champ existant depuis R2, jamais eu d'écran jusqu'ici) → `note` rejoint le corpus à la clôture comme les bilans codés. Mobile : `expo-audio`/`expo-file-system`, bouton dicter sur l'écran de clôture. Docker `siop2-ai` embarque le modèle (1,54→2,19 Go). Vérifié réellement (voix de synthèse → texte fidèle, bout en bout API, conteneur Docker construit, chaîne corpus complète note→clôture→réindexation→recherche). 80 tests API, 29 pytest ai, mobile vert.
|
||||
- ✅ **Dictée validée sur iPhone physique (01/08, ADR-004 §5)** : le blocage USB du 22/07 était un faux négatif de `system_profiler` (outil de diagnostic en panne, pas le câble). Deux bugs réels trouvés et corrigés : `expo-audio` iOS exige `setAudioModeAsync({ allowsRecording: true })` avant `record()` (absent, jamais détecté hors appareil réel) ; le correctif des modules précompilés (ADR-005) n'avait jamais été rendu permanent dans `apps/mobile/.env` — corrigé. Chaîne complète vérifiée en vrai : micro → dictée → transcription fidèle → note → clôture → réindexation → **retrouvé par la recherche sémantique**.
|
||||
- fix(mobile) **déconnexion accessible depuis tous les onglets (02/08)** : bug remonté par le référent (une fois authentifié, aucun moyen de revenir en arrière ou de se déconnecter — seule « Ma journée » portait ce contrôle, via un `onLongPress` non découvrable). `EnteteTabs` (`composants/ui.tsx`) factorise une entête commune aux 4 onglets terrain, tap simple + confirmation, rôle affiché dynamique.
|
||||
- ✅ **R6 — maquette validée (02/08)** : `maquette-mobile-tous-roles.html`, 7 écrans (synthèse par rôle, Accueil adaptatif, Menu groupé, OT côté Dispatcher, Demandes, Stock, Personnes & statistiques) + 5 décisions actées — D1 barre d'onglets adaptative (Technicien/Technicien limité inchangés + onglet Menu) ; D2 le Menu reprend à l'identique les 4 groupes du web (même matrice, aucune règle nouvelle) ; D3 groupe sans lien visible masqué en entier ; D4 le mobile porte les actions courantes par famille, pas les flux de gestion les plus denses (réservés au web) ; D5 aucune logique de permission propre au mobile. Nouvelle release (R4 est close et R4.1/4.2/4.3 déjà pris par le socle mobile technicien d'origine — pas de réouverture, pas de collision de numérotation).
|
||||
- ✅ **R6.1 — socle** : `usePermissions()` mobile (calqué sur le web, lit `me.permissions`) ; barre d'onglets adaptative par rôle (`ongletsVisibles`, `href: null` masque sans retirer du navigateur) ; écrans Accueil (dashboard réel pour Administrateur/Gestionnaire/Dispatcher/Vue seule, aucun chiffre inventé), OT (liste complète `viewOther`, réutilise la fiche OT R4 telle quelle), Menu (groupes filtrés par la matrice, groupe vide masqué), Demandes (`PanneauDemandes` — un seul composant pour tous les rôles : création/suivi pour le Demandeur, approbation/rejet à motif pour Gestionnaire/Dispatcher/Administrateur, lecture seule pour Vue seule ; `GET /assets/options`, déjà ouvert à tout rôle authentifié depuis R2.3, réutilisé pour le sélecteur d'équipement). Familles pas encore portées (Sites, Ascenseurs, Stock, Tiers, Fichiers, Statistiques, Assistant, Personnes) : écran « à venir » honnête plutôt qu'un lien mort. Typecheck propre, 17 tests Jest verts, lint 5/5 paquets — pas de vérification visuelle en navigateur de mon côté (aucun outil de ce type dans cet environnement), à confirmer par le référent sur l'iPhone déjà connecté au serveur Metro.
|
||||
- ✅ **R6.2 — Parc (Sites, Ascenseurs)** : `useLocations()` mobile ; écran Sites (premier niveau, `parentId===null`) ; fiche site (identité, zones, ascenseurs du site — consultation seule, D4, pas de carte ni d'édition sur mobile) ; écran Ascenseurs (parc complet déjà préchargé, D1) ouvrant la fiche appareil R4 telle quelle (générique, aucune modification nécessaire). Menu : Ascenseurs/Sites routent réellement, Catégories reste à venir (admin, hors périmètre). Typecheck propre, 17 tests Jest, lint 5/5 — exécution directe de la maquette déjà validée, pas de nouveau tour de design.
|
||||
- ✅ **R6.3 — Ressources (Stock, Tiers, Fichiers)** : `api/ressources.ts` (usePartners/useParts/usePart/useCreatePurchaseOrder/useDocuments) ; écran Stock (sous-seuil en tête) + fiche pièce + « Commander » pré-rempli en une ligne (fournisseur figé, quantité = manquant jusqu'au seuil, prix = dernier connu — le BC multi-lignes détaillé reste au web, D4) ; Tiers en lecture seule (création/édition réservées au web) ; Fichiers en métadonnées seules — l'ouverture/téléchargement demande `expo-sharing` (dépendance native absente, donc un nouveau build) et est explicitement différée plutôt qu'ajoutée à la légère. Menu branché. Typecheck propre, 17 tests Jest, lint 5/5, contrat non touché (toutes les opérations existaient déjà depuis R3).
|
||||
- ✅ **R6.4 — Pilotage (Statistiques, Personnes)** : `EXPO_PUBLIC_WEB_URL`/`WEB_URL` (lien d'activation vers la page web, pas d'équivalent mobile) ; `api/pilotage.ts` ; écran Statistiques (période 3/6/12, coût du mois, taux préventif, pannes par organe, top équipements — cartes plutôt que graphes, D4) ; écran Personnes & équipes (liste + statut, Inviter, lien d'activation en texte sélectionnable — `expo-clipboard` différée, même raisonnement que `expo-sharing` en R6.3 ; taux horaire/rôles/équipes restent au web). **Assistant reste à venir** dans le Menu — un chat sourcé est un nouveau patron d'écran jamais maquetté sur mobile, contrairement aux autres familles qui réutilisaient Carte/LigneInfo/EnteteFiche déjà validés ; mérite son propre tour de design-first. Typecheck propre, 17 tests Jest, lint 5/5, contrat non touché.
|
||||
- ✅ **Nav adaptative + correctif déconnexion confirmés par le référent sur iPhone physique** — dernier point ouvert depuis le début de R6, clos.
|
||||
- ✅ **R6.5 — Assistant mobile (chat sourcé + dictée)** : maquette dédiée validée (3 écrans, D1-D5 — D5 ajoutée en revue : question tapée OU dictée, même pipeline que la dictée déjà livrée en clôture, transcription remplit le champ, jamais d'envoi automatique). `api/assistant.ts` (503 géré comme le web). Écran Assistant : chat un échange à la fois, citations numérotées, sources avec extrait exact (« Voir le document » → métadonnées seules, R6.3 ; « Ouvrir l'OT » → fiche R4), refus honnête chiffré + Reformuler, avertissement permanent. Fiche document (`bibliotheque/[id].tsx`, nouveau) — la liste R6.3 y mène aussi désormais. **Le Menu R6 n'a plus d'entrée « à venir »** dans les 4 groupes (Catégories exceptée, admin, hors périmètre mobile). Typecheck propre, 17 tests Jest, lint 5/5, contrat non touché.
|
||||
- 🔄 **Recette R6 en cours (Gestionnaire, partie 1)** — 2 bugs réels trouvés et corrigés sur iPhone physique : Menu non scrollable (le dernier groupe, Pilotage, était strictement inaccessible une fois les 4 groupes pleinement câblés — présent depuis R6.1, révélé seulement maintenant) ; BC créé sans confirmation visible dans l'app (vérifié côté serveur : les BC étaient bien créés, juste aucun retour affiché). Amélioration sur retour direct : Tiers gagne une fiche détail (identité/contact/BC en cours/sites rattachés) — le lecture-seule sans aucune réaction au tap se lisait comme cassé. Trois signalements vérifiés et écartés (faux positifs) : approbation de demande (a fonctionné), bibliothèque vide (confirmé côté serveur — aucun document sur cette instance, pas un bug mobile), période statistiques (transmise et traitée correctement, les indicateurs affichés ne varient juste pas avec ce jeu de données).
|
||||
- ✅ **Assistant mobile — dictée confirmée sur iPhone physique**, après 3 corrections trouvées en conditions réelles (aucune n'aurait été vue par typecheck/tests/lint) : `expo-file-system` deleteAsync déprécié SDK 57 (import `/legacy`, appliqué aussi à `cloture.tsx`) ; texte transcrit pas entièrement visible (zone multiligne pleine largeur, Envoyer en geste séparé) ; bouton Envoyer chevauchant encore le texte (ScrollView du chat sans `style={flex:1}`, TextInput à hauteur fixe plutôt que `maxHeight` seul, pas fiable sur iOS).
|
||||
- ✅ **R6.6 — Demandeur restreint à son site** : trou trouvé en recette — `GET /assets/options` n'avait aucun filtre (web ET mobile touchés, pas seulement mobile). Corrigé à la racine : relation `User↔Location` (`assignedSites`, migration `r6_demandeur_sites`, vide = aucune restriction pour les autres rôles) ; `AssetsService.allowedLocationIds()` réutilisée par `options()` et par `RequestsService.create` (défense en profondeur, 400 si hors périmètre) ; gestion des sites d'un Demandeur côté web (`personnes.tsx`, invitation + modale dédiée) ; raccourci scan QR côté mobile (`formulaire-demande.tsx`, résout uniquement contre les options déjà filtrées, jamais de repli sur le parc complet). Karim Doukkali (démo) rattaché à Tour Atlas. Bug trouvé en vérification avant tout commit (comparaison id-de-site vs id-d'appareil dans `create()`, aurait rejeté à tort tout signalement d'un Demandeur affecté) et corrigé, méthode renommée `allowedAssetIds` → `allowedLocationIds` pour que le nom dise ce qu'elle retourne. 79/80 tests API (le seul échec est le flake `monthCost` déjà connu, sans rapport) ; typecheck/tests/lint verts sur les 4 paquets.
|
||||
- ✅ **R6.7 — assignation à l'approbation (mobile)** : le Gestionnaire approuvait une demande mobile sans pouvoir assigner de technicien (l'OT partait non assigné) — « ce n'est pas à lui d'agir comme un technicien ». `PanneauDemandes` gagne un panneau d'approbation avec `ChoixTel` « Assigner à » (techniciens actifs, même filtre que le web), avant confirmation. Rien à changer côté API (`assigneeIds` déjà supporté). Vérifié de bout en bout (demande → approuvée avec assigné → OT avec `assignees` correct). Typecheck/tests/lint verts.
|
||||
- ✅ **Périmètre `WORK_ORDERS` du Gestionnaire confirmé acceptable tel quel par le référent** — l'accès complet hérité de R2/R3 (peut techniquement démarrer/clôturer n'importe quel OT) reste en l'état, aucun resserrement demandé.
|
||||
- ✅ **R6.8 — la connexion routait toujours vers « Ma journée »** : `connexion.tsx` faisait `router.replace('/(tabs)/journee')` en dur — la redirection par rôle (`ongletAccueil`) n'était branchée qu'à l'aiguillage initial (R6.1), pas au retour de connexion, le chemin réellement emprunté à chaque bascule de compte démo (pas de sélecteur de rôle en direct sur mobile, changer de compte = se déconnecter puis se reconnecter). Karim (Demandeur) atterrissait sur l'écran du Technicien. `useLogin()`/`useDemoLogin()` renvoient maintenant la réponse complète (rôle inclus) ; `entrer(role)` route vers le bon onglet. Sous-titre « Technicien »/accroche terrain de l'écran de connexion (reste de R4) généricisés au passage. Typecheck/tests/lint verts.
|
||||
- ✅ **Recette R6 confirmée sur iPhone physique** — Gestionnaire (Accueil, OT, Menu complet, Assistant+dictée) et Demandeur (Accueil, Nouvelle demande filtrée à son site, scan QR) tous deux vérifiés en conditions réelles, au-delà du typecheck/tests/lint.
|
||||
- 🔄 **Reprise ici** : à trancher avec le référent — considérer R6 close (tag `release/r6`, comme R0→R5) ou poursuivre sur un reste identifié (Vue seule/Dispatcher/Administrateur non explicitement recettés sur iPhone, `expo-sharing`/`expo-clipboard` en réserve, recette Android sur appareil physique). Restes non bloquants inchangés : redéploiement Dokploy de l'instance ENSET (`AI_SERVICE_TOKEN` à créer — runbook §2 — puis « Réindexer tout »), calibrage `AI_SEUIL_*` et qualité darija sur corpus SPELEV réel, secret `DOKPLOY_WEBHOOK_URL`, production client SPELEV (attend les accès serveur du partenaire).
|
||||
- Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0→R5 (v1) + R6 en cours.
|
||||
|
||||
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
|
||||
44
apps/ai/Dockerfile
Normal file
44
apps/ai/Dockerfile
Normal file
@@ -0,0 +1,44 @@
|
||||
# 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 \
|
||||
HF_HOME=/opt/whisper
|
||||
|
||||
# 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 --extra transcription
|
||||
COPY src src
|
||||
RUN uv sync --frozen --no-dev \
|
||||
--extra embeddings --extra generation --extra transcription
|
||||
|
||||
# Les modèles locaux (embeddings + dictée) sont EMBARQUÉS dans l'image : pas
|
||||
# de téléchargement au boot (démarrage prévisible, marche sans accès à
|
||||
# Hugging Face en production). La dictée reste opt-in (AI_TRANSCRIPTION=off
|
||||
# par défaut) — le modèle est prêt si elle est activée un jour, sans rebuild.
|
||||
RUN uv run python -c "from siop_ai.embeddings import EmbeddeurLocal; EmbeddeurLocal()"
|
||||
RUN uv run python -c "from siop_ai.transcription import TranscripteurLocal; TranscripteurLocal('small')"
|
||||
|
||||
FROM python:3.11-slim-bookworm
|
||||
WORKDIR /app
|
||||
ENV PATH=/app/.venv/bin:$PATH \
|
||||
FASTEMBED_CACHE_PATH=/opt/fastembed \
|
||||
HF_HOME=/opt/whisper
|
||||
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
|
||||
COPY --from=builder --chown=siop:siop /opt/whisper /opt/whisper
|
||||
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`).
|
||||
46
apps/ai/pyproject.toml
Normal file
46
apps/ai/pyproject.toml
Normal file
@@ -0,0 +1,46 @@
|
||||
[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",
|
||||
"python-multipart>=0.0.20",
|
||||
]
|
||||
|
||||
[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"]
|
||||
# Transcription opt-in (dictée R5, ADR-004 §5) — absente des tests/CI
|
||||
# (transcripteur déterministe). CTranslate2/CPU, licence MIT, local.
|
||||
transcription = ["faster-whisper>=1.1"]
|
||||
|
||||
[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
|
||||
133
apps/ai/src/siop_ai/app.py
Normal file
133
apps/ai/src/siop_ai/app.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""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, UploadFile
|
||||
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
|
||||
from .transcription import construire_transcripteur
|
||||
|
||||
|
||||
@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.transcripteur = construire_transcripteur(
|
||||
reglages.ai_transcription, reglages.ai_transcription_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é
|
||||
"transcription": reglages.ai_transcription,
|
||||
}
|
||||
|
||||
|
||||
@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]}
|
||||
|
||||
|
||||
@app.post("/internal/transcrire", dependencies=[Depends(verifier_jeton)])
|
||||
async def transcrire(fichier: UploadFile) -> dict:
|
||||
"""Dictée opt-in (R5 D5, loi 09-08) : l'audio ne persiste JAMAIS — un
|
||||
fichier temporaire le temps de l'inférence, supprimé aussitôt (voir
|
||||
transcription.py). 503 propre si AI_TRANSCRIPTION=off (défaut)."""
|
||||
if app.state.transcripteur is None:
|
||||
raise HTTPException(status_code=503, detail="Transcription non activée (AI_TRANSCRIPTION=off)")
|
||||
audio = await fichier.read()
|
||||
extension = (fichier.filename or "audio.m4a").rsplit(".", 1)[-1]
|
||||
texte = app.state.transcripteur.transcrire(audio, extension)
|
||||
return {"texte": texte}
|
||||
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
|
||||
52
apps/ai/src/siop_ai/config.py
Normal file
52
apps/ai/src/siop_ai/config.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""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
|
||||
# Dictée opt-in (R5 D5, ADR-004 §5) : « off » (défaut — endpoint refuse
|
||||
# proprement), « locale » (faster-whisper, CPU) ou « deterministe » (CI).
|
||||
ai_transcription: str = "off"
|
||||
ai_transcription_model: str = "small" # tiny|base|small|medium|large-v3
|
||||
|
||||
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)")
|
||||
if reglages.ai_transcription not in ("off", "locale", "deterministe"):
|
||||
raise ValueError("AI_TRANSCRIPTION doit valoir « off », « locale » ou « deterministe »")
|
||||
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()
|
||||
178
apps/ai/src/siop_ai/ingestion.py
Normal file
178
apps/ai/src/siop_ai/ingestion.py
Normal file
@@ -0,0 +1,178 @@
|
||||
"""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", ir.note,
|
||||
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())
|
||||
# note (D5) : dictée ou saisie libre relue par l'humain — même pipeline
|
||||
# d'anonymisation que le reste, aucun traitement à part.
|
||||
note = f" Description libre : {bilan['note']}." if bilan["note"] else ""
|
||||
contenu = anonymiser(
|
||||
f"Intervention {bilan['reference']} — {bilan['title']}. "
|
||||
f"Appareil {bilan['asset_ref']} ({bilan['brand']} {bilan['model'] or ''}). "
|
||||
f"Bilan codé : {codes}.{note}",
|
||||
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
|
||||
]
|
||||
54
apps/ai/src/siop_ai/transcription.py
Normal file
54
apps/ai/src/siop_ai/transcription.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""Transcription audio (dictée R5, ADR-004 §5) : l'audio ne survit JAMAIS à
|
||||
cet appel — un fichier temporaire le temps de l'inférence, supprimé aussitôt,
|
||||
quoi qu'il arrive (D5, loi 09-08). Seul le texte transcrit est retourné ; la
|
||||
relecture humaine reste le seul contenu conservé, dans l'OT (D1).
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
class Transcripteur(Protocol):
|
||||
def transcrire(self, audio: bytes, extension: str) -> str: ...
|
||||
|
||||
|
||||
class TranscripteurLocal:
|
||||
"""faster-whisper (CTranslate2, CPU, licence MIT) — chargé paresseusement,
|
||||
jamais importé en test. Modèle et langue forcée en français : le mélange
|
||||
français/darija du terrain reste un point de vigilance non calibré (pas
|
||||
de mesure préalable demandée par le référent, 22/07/2026) — à revoir sur
|
||||
échantillons réels si la qualité déçoit en recette."""
|
||||
|
||||
def __init__(self, modele: str) -> None:
|
||||
from faster_whisper import WhisperModel # import différé (dépendance optionnelle)
|
||||
|
||||
self._modele = WhisperModel(modele, device="cpu", compute_type="int8")
|
||||
|
||||
def transcrire(self, audio: bytes, extension: str) -> str:
|
||||
fd, chemin = tempfile.mkstemp(suffix=f".{extension}")
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as f:
|
||||
f.write(audio)
|
||||
segments, _ = self._modele.transcribe(chemin, language="fr", vad_filter=True)
|
||||
return " ".join(segment.text.strip() for segment in segments).strip()
|
||||
finally:
|
||||
os.remove(chemin) # D5 — l'audio ne doit JAMAIS survivre à l'appel
|
||||
|
||||
|
||||
class TranscripteurDeterministe:
|
||||
"""Tests/CI : pas de modèle, pas de dépendance audio réelle — renvoie un
|
||||
texte stable dérivé de la taille du fichier (même interface)."""
|
||||
|
||||
def transcrire(self, audio: bytes, extension: str) -> str:
|
||||
return f"transcription déterministe ({len(audio)} octets, .{extension})"
|
||||
|
||||
|
||||
def construire_transcripteur(mode: str, modele: str) -> Transcripteur | None:
|
||||
"""None si la dictée n'est pas activée (AI_TRANSCRIPTION=off, défaut) —
|
||||
l'appelant doit alors refuser proprement (503), pas planter."""
|
||||
if mode == "off":
|
||||
return None
|
||||
if mode == "deterministe":
|
||||
return TranscripteurDeterministe()
|
||||
return TranscripteurLocal(modele)
|
||||
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
|
||||
69
apps/ai/tests/test_app.py
Normal file
69
apps/ai/tests/test_app.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""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
|
||||
assert client.post("/internal/transcrire", files={"fichier": ("a.m4a", b"x")}).status_code == 401
|
||||
|
||||
|
||||
def test_transcription_off_par_defaut_503():
|
||||
"""Dictée pas activée (AI_TRANSCRIPTION=off, défaut) : refus propre,
|
||||
jamais un 500 — même contrat que le service IA éteint côté NestJS."""
|
||||
reponse = _client_sans_db().post(
|
||||
"/internal/transcrire",
|
||||
files={"fichier": ("note.m4a", b"faux-audio")},
|
||||
headers={"X-Service-Token": "dev-only-ai-token"},
|
||||
)
|
||||
assert reponse.status_code == 503
|
||||
|
||||
|
||||
def test_transcription_deterministe_retourne_un_texte():
|
||||
from siop_ai.transcription import TranscripteurDeterministe
|
||||
|
||||
client = _client_sans_db()
|
||||
app.state.transcripteur = TranscripteurDeterministe()
|
||||
reponse = client.post(
|
||||
"/internal/transcrire",
|
||||
files={"fichier": ("note.m4a", b"faux-audio")},
|
||||
headers={"X-Service-Token": "dev-only-ai-token"},
|
||||
)
|
||||
assert reponse.status_code == 200
|
||||
assert "10 octets" in reponse.json()["texte"]
|
||||
assert ".m4a" in reponse.json()["texte"]
|
||||
|
||||
|
||||
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
|
||||
from siop_ai.transcription import construire_transcripteur
|
||||
|
||||
reglages = charger_reglages()
|
||||
app.state.reglages = reglages
|
||||
app.state.transcripteur = construire_transcripteur(
|
||||
reglages.ai_transcription, reglages.ai_transcription_model
|
||||
)
|
||||
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 ?")
|
||||
36
apps/ai/tests/test_transcription.py
Normal file
36
apps/ai/tests/test_transcription.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""Dictée opt-in (R5 D5, ADR-004 §5) : off par défaut (aucune dépendance
|
||||
audio requise), déterministe en CI/tests. Le vrai moteur (faster-whisper)
|
||||
se vérifie en recette réelle (convention R5.1 — pas de mesure préalable
|
||||
demandée par le référent, 22/07/2026)."""
|
||||
|
||||
import pytest
|
||||
|
||||
from siop_ai.config import charger_reglages
|
||||
from siop_ai.transcription import TranscripteurDeterministe, construire_transcripteur
|
||||
|
||||
|
||||
def test_off_par_defaut_ne_construit_rien():
|
||||
assert construire_transcripteur("off", "small") is None
|
||||
|
||||
|
||||
def test_deterministe_stable_et_derive_la_taille():
|
||||
transcripteur = construire_transcripteur("deterministe", "small")
|
||||
assert isinstance(transcripteur, TranscripteurDeterministe)
|
||||
texte = transcripteur.transcrire(b"12345", "m4a")
|
||||
assert "5 octets" in texte
|
||||
assert ".m4a" in texte
|
||||
assert transcripteur.transcrire(b"12345", "m4a") == texte # stable
|
||||
|
||||
|
||||
def test_mode_inconnu_refuse_au_boot(monkeypatch):
|
||||
monkeypatch.setenv("AI_TRANSCRIPTION", "toujours")
|
||||
with pytest.raises(ValueError, match="AI_TRANSCRIPTION"):
|
||||
charger_reglages()
|
||||
|
||||
|
||||
def test_modele_configurable(monkeypatch):
|
||||
monkeypatch.setenv("AI_TRANSCRIPTION", "deterministe")
|
||||
monkeypatch.setenv("AI_TRANSCRIPTION_MODEL", "medium")
|
||||
reglages = charger_reglages()
|
||||
assert reglages.ai_transcription == "deterministe"
|
||||
assert reglages.ai_transcription_model == "medium"
|
||||
1974
apps/ai/uv.lock
generated
Normal file
1974
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_ACCESS_KEY=siop
|
||||
MINIO_SECRET_KEY=siop-minio
|
||||
|
||||
# R4 — origines navigateur autorisées (Expo web / debug mobile). Vide = pas de CORS.
|
||||
CORS_ORIGINS=http://localhost:8081
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
"@nestjs/testing": "^11.1.0",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/multer": "^2.2.0",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"jest": "^29.7.0",
|
||||
|
||||
@@ -6,6 +6,39 @@ import { PrismaClient } from '@prisma/client';
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
// R3 — d'abord les traces de gestion (les mouvements liés à un OT E2E
|
||||
// seraient orphelins après sa suppression et fausseraient les stocks seedés)
|
||||
await prisma.stockMovement.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ part: { designation: { contains: 'E2E' } } },
|
||||
{ workOrder: { title: { contains: 'E2E' } } },
|
||||
{ purchaseOrder: { supplier: { name: { contains: 'E2E' } } } },
|
||||
],
|
||||
},
|
||||
});
|
||||
await prisma.document.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ fileName: { contains: 'e2e' } },
|
||||
{ workOrder: { title: { contains: 'E2E' } } },
|
||||
],
|
||||
},
|
||||
});
|
||||
await prisma.purchaseOrderLine.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ part: { designation: { contains: 'E2E' } } },
|
||||
{ purchaseOrder: { supplier: { name: { contains: 'E2E' } } } },
|
||||
],
|
||||
},
|
||||
});
|
||||
await prisma.purchaseOrder.deleteMany({
|
||||
where: { supplier: { name: { contains: 'E2E' } } },
|
||||
});
|
||||
await prisma.part.deleteMany({ where: { designation: { contains: 'E2E' } } });
|
||||
await prisma.partner.deleteMany({ where: { name: { contains: 'E2E' } } });
|
||||
|
||||
await prisma.request.deleteMany({ where: { description: { contains: 'E2E' } } });
|
||||
await prisma.workOrder.deleteMany({ where: { title: { contains: 'E2E' } } });
|
||||
await prisma.meterReading.deleteMany({ where: { value: { gte: 90_000_000 } } });
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "PartnerKind" AS ENUM ('SUPPLIER', 'CLIENT');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "PurchaseOrderStatus" AS ENUM ('DRAFT', 'SENT', 'RECEIVED', 'CANCELLED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "StockMovementKind" AS ENUM ('RECEIPT', 'ENTRY', 'CONSUMPTION', 'ADJUSTMENT');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "DocumentKind" AS ENUM ('NOTICE', 'CERTIFICATE', 'PHOTO', 'OTHER');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "hourlyRate" DECIMAL(8,2);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Partner" (
|
||||
"id" UUID NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"kind" "PartnerKind" NOT NULL,
|
||||
"contactName" TEXT,
|
||||
"phone" TEXT,
|
||||
"email" TEXT,
|
||||
"city" TEXT,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Partner_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Part" (
|
||||
"id" UUID NOT NULL,
|
||||
"reference" TEXT NOT NULL,
|
||||
"designation" TEXT NOT NULL,
|
||||
"threshold" INTEGER NOT NULL DEFAULT 0,
|
||||
"lastUnitPrice" DECIMAL(10,2),
|
||||
"compatible" TEXT,
|
||||
"supplierId" UUID,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Part_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "StockMovement" (
|
||||
"id" UUID NOT NULL,
|
||||
"partId" UUID NOT NULL,
|
||||
"kind" "StockMovementKind" NOT NULL,
|
||||
"quantity" INTEGER NOT NULL,
|
||||
"unitPrice" DECIMAL(10,2),
|
||||
"reason" TEXT,
|
||||
"workOrderId" UUID,
|
||||
"purchaseOrderId" UUID,
|
||||
"byId" UUID,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "StockMovement_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "PurchaseOrder" (
|
||||
"id" UUID NOT NULL,
|
||||
"reference" TEXT NOT NULL,
|
||||
"status" "PurchaseOrderStatus" NOT NULL DEFAULT 'DRAFT',
|
||||
"supplierId" UUID NOT NULL,
|
||||
"sentAt" TIMESTAMP(3),
|
||||
"receivedAt" TIMESTAMP(3),
|
||||
"cancelledAt" TIMESTAMP(3),
|
||||
"createdById" UUID,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "PurchaseOrder_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "PurchaseOrderLine" (
|
||||
"id" UUID NOT NULL,
|
||||
"purchaseOrderId" UUID NOT NULL,
|
||||
"partId" UUID NOT NULL,
|
||||
"quantity" INTEGER NOT NULL,
|
||||
"unitPrice" DECIMAL(10,2) NOT NULL,
|
||||
|
||||
CONSTRAINT "PurchaseOrderLine_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "LaborTime" (
|
||||
"id" UUID NOT NULL,
|
||||
"workOrderId" UUID NOT NULL,
|
||||
"userId" UUID NOT NULL,
|
||||
"minutes" INTEGER NOT NULL,
|
||||
"hourlyRate" DECIMAL(8,2) NOT NULL,
|
||||
"note" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "LaborTime_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Document" (
|
||||
"id" UUID NOT NULL,
|
||||
"kind" "DocumentKind" NOT NULL,
|
||||
"fileName" TEXT NOT NULL,
|
||||
"storageKey" TEXT NOT NULL,
|
||||
"size" INTEGER NOT NULL,
|
||||
"contentType" TEXT NOT NULL,
|
||||
"assetId" UUID,
|
||||
"workOrderId" UUID,
|
||||
"uploadedById" UUID,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Document_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Partner_name_key" ON "Partner"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Part_reference_key" ON "Part"("reference");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "StockMovement_partId_idx" ON "StockMovement"("partId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "StockMovement_workOrderId_idx" ON "StockMovement"("workOrderId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "PurchaseOrder_reference_key" ON "PurchaseOrder"("reference");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "PurchaseOrderLine_purchaseOrderId_idx" ON "PurchaseOrderLine"("purchaseOrderId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "LaborTime_workOrderId_idx" ON "LaborTime"("workOrderId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Document_storageKey_key" ON "Document"("storageKey");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Document_assetId_idx" ON "Document"("assetId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Document_workOrderId_idx" ON "Document"("workOrderId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Part" ADD CONSTRAINT "Part_supplierId_fkey" FOREIGN KEY ("supplierId") REFERENCES "Partner"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "StockMovement" ADD CONSTRAINT "StockMovement_partId_fkey" FOREIGN KEY ("partId") REFERENCES "Part"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "StockMovement" ADD CONSTRAINT "StockMovement_workOrderId_fkey" FOREIGN KEY ("workOrderId") REFERENCES "WorkOrder"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "StockMovement" ADD CONSTRAINT "StockMovement_purchaseOrderId_fkey" FOREIGN KEY ("purchaseOrderId") REFERENCES "PurchaseOrder"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "StockMovement" ADD CONSTRAINT "StockMovement_byId_fkey" FOREIGN KEY ("byId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PurchaseOrder" ADD CONSTRAINT "PurchaseOrder_supplierId_fkey" FOREIGN KEY ("supplierId") REFERENCES "Partner"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PurchaseOrder" ADD CONSTRAINT "PurchaseOrder_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PurchaseOrderLine" ADD CONSTRAINT "PurchaseOrderLine_purchaseOrderId_fkey" FOREIGN KEY ("purchaseOrderId") REFERENCES "PurchaseOrder"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PurchaseOrderLine" ADD CONSTRAINT "PurchaseOrderLine_partId_fkey" FOREIGN KEY ("partId") REFERENCES "Part"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "LaborTime" ADD CONSTRAINT "LaborTime_workOrderId_fkey" FOREIGN KEY ("workOrderId") REFERENCES "WorkOrder"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "LaborTime" ADD CONSTRAINT "LaborTime_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Document" ADD CONSTRAINT "Document_assetId_fkey" FOREIGN KEY ("assetId") REFERENCES "Asset"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Document" ADD CONSTRAINT "Document_workOrderId_fkey" FOREIGN KEY ("workOrderId") REFERENCES "WorkOrder"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Document" ADD CONSTRAINT "Document_uploadedById_fkey" FOREIGN KEY ("uploadedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Références OT/DEM/BC/P : séquences Postgres — la concurrence ne peut plus
|
||||
-- produire de collision (leçon des générations parallèles).
|
||||
-- La numérotation ne se remet pas à zéro chaque année : l'unicité prime.
|
||||
CREATE SEQUENCE IF NOT EXISTS "work_order_ref_seq";
|
||||
CREATE SEQUENCE IF NOT EXISTS "request_ref_seq";
|
||||
CREATE SEQUENCE IF NOT EXISTS "purchase_order_ref_seq";
|
||||
CREATE SEQUENCE IF NOT EXISTS "part_ref_seq";
|
||||
|
||||
SELECT setval('work_order_ref_seq',
|
||||
GREATEST((SELECT COALESCE(MAX(SUBSTRING("reference" FROM '\d+$')::int), 0) FROM "WorkOrder"), 1000));
|
||||
SELECT setval('request_ref_seq',
|
||||
GREATEST((SELECT COALESCE(MAX(SUBSTRING("reference" FROM '\d+$')::int), 0) FROM "Request"), 1000));
|
||||
SELECT setval('purchase_order_ref_seq',
|
||||
GREATEST((SELECT COALESCE(MAX(SUBSTRING("reference" FROM '\d+$')::int), 0) FROM "PurchaseOrder"), 1000));
|
||||
SELECT setval('part_ref_seq',
|
||||
GREATEST((SELECT COALESCE(MAX(SUBSTRING("reference" FROM '\d+$')::int), 0) FROM "Part"), 1000));
|
||||
@@ -0,0 +1,8 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Location" ADD COLUMN "partnerId" UUID;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Location_partnerId_idx" ON "Location"("partnerId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Location" ADD CONSTRAINT "Location_partnerId_fkey" FOREIGN KEY ("partnerId") REFERENCES "Partner"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,16 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "_LocationToUser" (
|
||||
"A" UUID NOT NULL,
|
||||
"B" UUID NOT NULL,
|
||||
|
||||
CONSTRAINT "_LocationToUser_AB_pkey" PRIMARY KEY ("A","B")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "_LocationToUser_B_index" ON "_LocationToUser"("B");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_LocationToUser" ADD CONSTRAINT "_LocationToUser_A_fkey" FOREIGN KEY ("A") REFERENCES "Location"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_LocationToUser" ADD CONSTRAINT "_LocationToUser_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -49,6 +49,8 @@ model User {
|
||||
activationToken String? @unique
|
||||
activationExpiresAt DateTime?
|
||||
teams Team[]
|
||||
// R6.6 — sites autorisés en signalement (Demandeur) ; vide = aucune restriction
|
||||
assignedSites Location[]
|
||||
// R2 — exploitation
|
||||
workOrdersAssigned WorkOrder[] @relation("WorkOrderAssignees")
|
||||
workOrdersCreated WorkOrder[] @relation("WorkOrderCreator")
|
||||
@@ -56,6 +58,12 @@ model User {
|
||||
requests Request[]
|
||||
checklistDone ChecklistItem[]
|
||||
meterReadings MeterReading[]
|
||||
// R3 — gestion : taux horaire COURANT (le taux d'une saisie est figé dans LaborTime)
|
||||
hourlyRate Decimal? @db.Decimal(8, 2)
|
||||
laborTimes LaborTime[]
|
||||
stockMovements StockMovement[]
|
||||
purchaseOrders PurchaseOrder[]
|
||||
documents Document[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -101,11 +109,16 @@ model Location {
|
||||
longitude Float?
|
||||
// + colonne PostGIS générée (voir migration r1_referentiel) :
|
||||
// position geography(Point,4326) GENERATED ALWAYS AS (…) STORED
|
||||
// Client/syndic gérant le site (R3 — colonne « Rattachements » des Tiers)
|
||||
partnerId String? @db.Uuid
|
||||
partner Partner? @relation(fields: [partnerId], references: [id])
|
||||
assets Asset[]
|
||||
assignedUsers User[] // reverse de User.assignedSites (R6.6)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([parentId])
|
||||
@@index([partnerId])
|
||||
}
|
||||
|
||||
model Asset {
|
||||
@@ -127,6 +140,7 @@ model Asset {
|
||||
workOrders WorkOrder[]
|
||||
requests Request[]
|
||||
meters Meter[]
|
||||
documents Document[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -222,9 +236,13 @@ model WorkOrder {
|
||||
completedAt DateTime?
|
||||
cancelledAt DateTime?
|
||||
events WorkOrderEvent[]
|
||||
ragChunks RagChunk[]
|
||||
checklist ChecklistItem[]
|
||||
report InterventionReport?
|
||||
request Request?
|
||||
stockMovements StockMovement[]
|
||||
laborTimes LaborTime[]
|
||||
documents Document[]
|
||||
// Grille du mois : « AAAA-MM » — l'unicité [assetId, periodKey] EST
|
||||
// l'idempotence de la génération (les OT correctifs restent à null).
|
||||
periodKey String?
|
||||
@@ -358,3 +376,177 @@ model MeterReading {
|
||||
|
||||
@@index([meterId])
|
||||
}
|
||||
|
||||
// ————— R3 — Gestion (docs/03-architecture/modele-donnees.md §R3) —————
|
||||
|
||||
enum PartnerKind {
|
||||
SUPPLIER
|
||||
CLIENT // syndic / propriétaire
|
||||
}
|
||||
|
||||
enum PurchaseOrderStatus {
|
||||
DRAFT
|
||||
SENT
|
||||
RECEIVED
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
enum StockMovementKind {
|
||||
RECEIPT // réception de BC (+, PU figé)
|
||||
ENTRY // entrée manuelle (+)
|
||||
CONSUMPTION // consommation d'OT (−, PU figé)
|
||||
ADJUSTMENT // inventaire (±, motif REQUIS)
|
||||
}
|
||||
|
||||
enum DocumentKind {
|
||||
NOTICE
|
||||
CERTIFICATE
|
||||
PHOTO
|
||||
OTHER
|
||||
}
|
||||
|
||||
model Partner {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
name String @unique
|
||||
kind PartnerKind
|
||||
contactName String?
|
||||
phone String?
|
||||
email String?
|
||||
city String?
|
||||
isActive Boolean @default(true)
|
||||
parts Part[]
|
||||
purchaseOrders PurchaseOrder[]
|
||||
sites Location[] // sites gérés (kind CLIENT)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Part {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
reference String @unique // P-0113
|
||||
designation String
|
||||
threshold Int @default(0) // seuil d'alerte
|
||||
// Dernier prix d'achat — figé sur chaque mouvement au moment T.
|
||||
lastUnitPrice Decimal? @db.Decimal(10, 2)
|
||||
compatible String?
|
||||
supplierId String? @db.Uuid
|
||||
supplier Partner? @relation(fields: [supplierId], references: [id])
|
||||
isActive Boolean @default(true)
|
||||
movements StockMovement[]
|
||||
orderLines PurchaseOrderLine[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
// Le stock EST la somme des mouvements — aucune colonne de quantité.
|
||||
model StockMovement {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
partId String @db.Uuid
|
||||
part Part @relation(fields: [partId], references: [id], onDelete: Cascade)
|
||||
kind StockMovementKind
|
||||
quantity Int // signé : + entrée, − sortie
|
||||
unitPrice Decimal? @db.Decimal(10, 2) // figé (réception, consommation)
|
||||
reason String? // ajustement : motif requis (service)
|
||||
workOrderId String? @db.Uuid
|
||||
workOrder WorkOrder? @relation(fields: [workOrderId], references: [id])
|
||||
purchaseOrderId String? @db.Uuid
|
||||
purchaseOrder PurchaseOrder? @relation(fields: [purchaseOrderId], references: [id])
|
||||
byId String? @db.Uuid
|
||||
by User? @relation(fields: [byId], references: [id])
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([partId])
|
||||
@@index([workOrderId])
|
||||
}
|
||||
|
||||
model PurchaseOrder {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
reference String @unique // BC-2026-0024
|
||||
status PurchaseOrderStatus @default(DRAFT)
|
||||
supplierId String @db.Uuid
|
||||
supplier Partner @relation(fields: [supplierId], references: [id])
|
||||
lines PurchaseOrderLine[]
|
||||
movements StockMovement[]
|
||||
sentAt DateTime?
|
||||
receivedAt DateTime?
|
||||
cancelledAt DateTime?
|
||||
createdById String? @db.Uuid
|
||||
createdBy User? @relation(fields: [createdById], references: [id])
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model PurchaseOrderLine {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
purchaseOrderId String @db.Uuid
|
||||
purchaseOrder PurchaseOrder @relation(fields: [purchaseOrderId], references: [id], onDelete: Cascade)
|
||||
partId String @db.Uuid
|
||||
part Part @relation(fields: [partId], references: [id])
|
||||
quantity Int
|
||||
unitPrice Decimal @db.Decimal(10, 2)
|
||||
|
||||
@@index([purchaseOrderId])
|
||||
}
|
||||
|
||||
// Main-d'œuvre : le taux est FIGÉ à la saisie (le coût d'un OT ne bouge plus).
|
||||
model LaborTime {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
workOrderId String @db.Uuid
|
||||
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
|
||||
userId String @db.Uuid
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
minutes Int
|
||||
hourlyRate Decimal @db.Decimal(8, 2)
|
||||
note String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([workOrderId])
|
||||
}
|
||||
|
||||
model Document {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
kind DocumentKind
|
||||
fileName String
|
||||
storageKey String @unique // clé MinIO (FileStorage)
|
||||
size Int
|
||||
contentType String
|
||||
assetId String? @db.Uuid
|
||||
asset Asset? @relation(fields: [assetId], references: [id])
|
||||
workOrderId String? @db.Uuid
|
||||
workOrder WorkOrder? @relation(fields: [workOrderId], references: [id])
|
||||
uploadedById String? @db.Uuid
|
||||
uploadedBy User? @relation(fields: [uploadedById], references: [id])
|
||||
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([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
|
||||
}
|
||||
|
||||
@@ -134,6 +134,7 @@ export async function seed(prisma: PrismaClient): Promise<void> {
|
||||
await seedUsers(prisma, roleIds, passwordHash);
|
||||
await seedReferentiel(prisma);
|
||||
await seedExploitation(prisma);
|
||||
await seedGestion(prisma);
|
||||
}
|
||||
|
||||
async function seedUsers(
|
||||
@@ -461,6 +462,19 @@ async function seedExploitation(prisma: PrismaClient): Promise<void> {
|
||||
const karim = await parEmail('demandeur@demo.siop.ma');
|
||||
const annee = new Date().getFullYear();
|
||||
|
||||
// R6.6 — Karim (Demandeur) est rattaché à Tour Atlas (cohérent avec le
|
||||
// guardianName déjà seedé pour ce site) : son signalement se limite à ce
|
||||
// parc, ses demandes historiques sur d'autres sites restent visibles.
|
||||
const tourAtlas = await prisma.location.findFirst({
|
||||
where: { name: 'Tour Atlas', parentId: null },
|
||||
});
|
||||
if (tourAtlas) {
|
||||
await prisma.user.update({
|
||||
where: { id: karim },
|
||||
data: { assignedSites: { connect: [{ id: tourAtlas.id }] } },
|
||||
});
|
||||
}
|
||||
|
||||
const grilleLabels = TASK_TEMPLATES.filter((t) => t.periodMonths === 1).map(
|
||||
(t) => t.label,
|
||||
);
|
||||
@@ -580,6 +594,188 @@ async function seedExploitation(prisma: PrismaClient): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// ————— R3 — Gestion (tiers, pièces, mouvements, BC, taux — données maquette) —————
|
||||
|
||||
const PARTNERS: {
|
||||
name: string; kind: 'SUPPLIER' | 'CLIENT'; contactName?: string;
|
||||
phone?: string; email?: string; city?: string;
|
||||
}[] = [
|
||||
{ name: 'Ascentech Maroc', kind: 'SUPPLIER', contactName: 'M. Berrini', phone: '05 22 34 56 78', city: 'Casablanca' },
|
||||
{ name: 'SchindlerParts', kind: 'SUPPLIER', email: 'commandes@schindlerparts.ma' },
|
||||
{ name: 'Lubmaroc', kind: 'SUPPLIER', phone: '05 22 11 22 33' },
|
||||
{ name: 'Atlas Property Management', kind: 'CLIENT', contactName: 'Mme Zerhouni', phone: '06 61 98 76 54' },
|
||||
{ name: 'Syndic Al Manar', kind: 'CLIENT', contactName: 'M. Alami (gardien référent)' },
|
||||
];
|
||||
|
||||
const PARTS: {
|
||||
reference: string; designation: string; threshold: number;
|
||||
lastUnitPrice?: number; supplier?: string; compatible?: string;
|
||||
}[] = [
|
||||
{ reference: 'P-0019', designation: 'Cellule barrière porte (paire)', threshold: 4, lastUnitPrice: 640, supplier: 'SchindlerParts' },
|
||||
{ reference: 'P-0031', designation: 'Bouton palier lumineux Ø22', threshold: 10, lastUnitPrice: 45, supplier: 'Ascentech Maroc' },
|
||||
{ reference: 'P-0042', designation: 'Graisse guide (cartouche 400 g)', threshold: 8, lastUnitPrice: 85, supplier: 'Lubmaroc' },
|
||||
{ reference: 'P-0087', designation: 'Coulisseau de guide 16 mm', threshold: 6, lastUnitPrice: 120, supplier: 'Ascentech Maroc' },
|
||||
{ reference: 'P-0113', designation: 'Contact de porte NC-31', threshold: 5, lastUnitPrice: 85, supplier: 'Ascentech Maroc', compatible: 'Otis Gen2 · Schindler 3300' },
|
||||
];
|
||||
|
||||
async function seedGestion(prisma: PrismaClient): Promise<void> {
|
||||
const partnerIds = new Map<string, string>();
|
||||
for (const p of PARTNERS) {
|
||||
const partner = await prisma.partner.upsert({
|
||||
where: { name: p.name },
|
||||
update: {},
|
||||
create: p,
|
||||
});
|
||||
partnerIds.set(p.name, partner.id);
|
||||
}
|
||||
|
||||
// Rattachements de la maquette Tiers : les syndics gèrent leur site.
|
||||
for (const [site, partner] of [
|
||||
['Tour Atlas', 'Atlas Property Management'],
|
||||
['Résidence Al Manar', 'Syndic Al Manar'],
|
||||
] as const) {
|
||||
await prisma.location.updateMany({
|
||||
where: { name: site, parentId: null, partnerId: null },
|
||||
data: { partnerId: partnerIds.get(partner) },
|
||||
});
|
||||
}
|
||||
|
||||
const partIds = new Map<string, string>();
|
||||
for (const p of PARTS) {
|
||||
const part = await prisma.part.upsert({
|
||||
where: { reference: p.reference },
|
||||
update: {},
|
||||
create: {
|
||||
reference: p.reference,
|
||||
designation: p.designation,
|
||||
threshold: p.threshold,
|
||||
lastUnitPrice: p.lastUnitPrice,
|
||||
compatible: p.compatible,
|
||||
supplierId: p.supplier ? partnerIds.get(p.supplier) : undefined,
|
||||
},
|
||||
});
|
||||
partIds.set(p.reference, part.id);
|
||||
}
|
||||
|
||||
// Taux horaires courants (les saisies figent leur propre taux)
|
||||
for (const [email, taux] of [
|
||||
['technicien@demo.siop.ma', 120],
|
||||
['technicien-limite@demo.siop.ma', 90],
|
||||
] as const) {
|
||||
await prisma.user.update({ where: { email }, data: { hourlyRate: taux } });
|
||||
}
|
||||
|
||||
// Idempotence : si des mouvements existent déjà, l'histoire est en place
|
||||
if ((await prisma.stockMovement.count()) > 0) return;
|
||||
|
||||
const annee = new Date().getFullYear();
|
||||
const ahmed = await prisma.user.findUniqueOrThrow({ where: { email: 'technicien@demo.siop.ma' } });
|
||||
const nadia = await prisma.user.findUniqueOrThrow({ where: { email: 'gestionnaire@demo.siop.ma' } });
|
||||
|
||||
// BC reçus (historique) et BC en cours — comme la maquette
|
||||
const bc21 = await prisma.purchaseOrder.create({
|
||||
data: {
|
||||
reference: `BC-${annee}-0021`,
|
||||
status: 'RECEIVED',
|
||||
supplierId: partnerIds.get('Ascentech Maroc')!,
|
||||
createdById: nadia.id,
|
||||
sentAt: new Date(Date.now() - 40 * 86400e3),
|
||||
receivedAt: new Date(Date.now() - 34 * 86400e3),
|
||||
lines: {
|
||||
create: [
|
||||
{ partId: partIds.get('P-0113')!, quantity: 5, unitPrice: 85 },
|
||||
{ partId: partIds.get('P-0087')!, quantity: 6, unitPrice: 120 },
|
||||
{ partId: partIds.get('P-0031')!, quantity: 20, unitPrice: 45 },
|
||||
],
|
||||
},
|
||||
},
|
||||
include: { lines: true },
|
||||
});
|
||||
await prisma.purchaseOrder.create({
|
||||
data: {
|
||||
reference: `BC-${annee}-0024`,
|
||||
status: 'SENT',
|
||||
supplierId: partnerIds.get('Ascentech Maroc')!,
|
||||
createdById: nadia.id,
|
||||
sentAt: new Date(),
|
||||
lines: {
|
||||
create: [
|
||||
{ partId: partIds.get('P-0113')!, quantity: 10, unitPrice: 85 },
|
||||
{ partId: partIds.get('P-0087')!, quantity: 2, unitPrice: 127.5 },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Mouvements : réceptions du BC-0021 + entrées initiales + consommations
|
||||
for (const line of bc21.lines) {
|
||||
await prisma.stockMovement.create({
|
||||
data: {
|
||||
partId: line.partId,
|
||||
kind: 'RECEIPT',
|
||||
quantity: line.quantity,
|
||||
unitPrice: line.unitPrice,
|
||||
purchaseOrderId: bc21.id,
|
||||
byId: nadia.id,
|
||||
createdAt: new Date(Date.now() - 34 * 86400e3),
|
||||
},
|
||||
});
|
||||
}
|
||||
for (const [ref, qte] of [
|
||||
['P-0042', 19],
|
||||
['P-0019', 7],
|
||||
['P-0031', 6],
|
||||
] as const) {
|
||||
await prisma.stockMovement.create({
|
||||
data: {
|
||||
partId: partIds.get(ref)!,
|
||||
kind: 'ENTRY',
|
||||
quantity: qte,
|
||||
reason: 'Reprise de l’inventaire initial',
|
||||
byId: nadia.id,
|
||||
createdAt: new Date(Date.now() - 60 * 86400e3),
|
||||
},
|
||||
});
|
||||
}
|
||||
// P-0113 : consommations + ajustement → stock 2 (sous le seuil, maquette)
|
||||
const ot341 = await prisma.workOrder.findUnique({
|
||||
where: { reference: `OT-${annee}-0341` },
|
||||
});
|
||||
await prisma.stockMovement.createMany({
|
||||
data: [
|
||||
{ partId: partIds.get('P-0113')!, kind: 'ADJUSTMENT', quantity: -1, reason: 'Inventaire — pièce endommagée', byId: nadia.id },
|
||||
{ partId: partIds.get('P-0113')!, kind: 'CONSUMPTION', quantity: -2, unitPrice: 85, byId: ahmed.id },
|
||||
],
|
||||
});
|
||||
// P-0087 : consommation sur OT-0341 (carte maquette : 2 × 120 = 240 MAD)
|
||||
if (ot341) {
|
||||
await prisma.stockMovement.create({
|
||||
data: {
|
||||
partId: partIds.get('P-0087')!,
|
||||
kind: 'CONSUMPTION',
|
||||
quantity: -2,
|
||||
unitPrice: 120,
|
||||
workOrderId: ot341.id,
|
||||
byId: ahmed.id,
|
||||
},
|
||||
});
|
||||
await prisma.stockMovement.create({
|
||||
data: {
|
||||
partId: partIds.get('P-0042')!,
|
||||
kind: 'CONSUMPTION',
|
||||
quantity: -1,
|
||||
unitPrice: 85,
|
||||
workOrderId: ot341.id,
|
||||
byId: ahmed.id,
|
||||
},
|
||||
});
|
||||
// main-d'œuvre : 1 h 30 × 120 MAD/h = 180 (total maquette : 505 MAD)
|
||||
await prisma.laborTime.create({
|
||||
data: { workOrderId: ot341.id, userId: ahmed.id, minutes: 90, hourlyRate: 120 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* c8 ignore start — wrapper CLI */
|
||||
if (require.main === module) {
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
14
apps/api/src/analytics/analytics.controller.ts
Normal file
14
apps/api/src/analytics/analytics.controller.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { RequirePermission } from '../permissions/require-permission.decorator';
|
||||
import { AnalyticsService } from './analytics.service';
|
||||
|
||||
@Controller('analytics')
|
||||
export class AnalyticsController {
|
||||
constructor(private readonly analytics: AnalyticsService) {}
|
||||
|
||||
@Get('summary')
|
||||
@RequirePermission('ANALYTICS', 'view')
|
||||
summary(@Query('months') months?: string) {
|
||||
return this.analytics.summary(months);
|
||||
}
|
||||
}
|
||||
9
apps/api/src/analytics/analytics.module.ts
Normal file
9
apps/api/src/analytics/analytics.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AnalyticsController } from './analytics.controller';
|
||||
import { AnalyticsService } from './analytics.service';
|
||||
|
||||
@Module({
|
||||
controllers: [AnalyticsController],
|
||||
providers: [AnalyticsService],
|
||||
})
|
||||
export class AnalyticsModule {}
|
||||
201
apps/api/src/analytics/analytics.service.ts
Normal file
201
apps/api/src/analytics/analytics.service.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ANALYTICS_PERIODS, type AnalyticsPeriod, type AnalyticsSummary } from '@siop/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
const debutMois = (decalage: number): Date => {
|
||||
const d = new Date();
|
||||
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() - decalage, 1));
|
||||
};
|
||||
|
||||
/** Le tableau de la direction — tout est DÉRIVÉ des données réelles :
|
||||
* mouvements (prix figés), main-d'œuvre (taux figés), bilans codés. */
|
||||
@Injectable()
|
||||
export class AnalyticsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async summary(monthsRaw?: string): Promise<AnalyticsSummary> {
|
||||
// Période demandée par l'écran (3, 6 ou 12 mois) — 12 par défaut.
|
||||
const months: AnalyticsPeriod = ANALYTICS_PERIODS.find(
|
||||
(p) => p === Number(monthsRaw),
|
||||
) ?? 12;
|
||||
const depuisPeriode = debutMois(months - 1);
|
||||
|
||||
const [consommations, mainOeuvre, clos12m, grilles12m, correctifsClos, pannes, topDonnees] =
|
||||
await Promise.all([
|
||||
this.prisma.stockMovement.findMany({
|
||||
where: { kind: 'CONSUMPTION', createdAt: { gte: depuisPeriode } },
|
||||
select: { quantity: true, unitPrice: true, createdAt: true },
|
||||
}),
|
||||
this.prisma.laborTime.findMany({
|
||||
where: { createdAt: { gte: depuisPeriode } },
|
||||
select: { minutes: true, hourlyRate: true, createdAt: true },
|
||||
}),
|
||||
this.prisma.workOrder.groupBy({
|
||||
by: ['type'],
|
||||
where: { status: 'DONE', completedAt: { gte: depuisPeriode } },
|
||||
_count: { _all: true },
|
||||
}),
|
||||
this.prisma.workOrder.findMany({
|
||||
where: { periodKey: { not: null }, createdAt: { gte: depuisPeriode } },
|
||||
select: { status: true },
|
||||
}),
|
||||
this.prisma.workOrder.findMany({
|
||||
where: { type: 'CORRECTIVE', status: 'DONE', completedAt: { gte: depuisPeriode } },
|
||||
select: { createdAt: true, completedAt: true },
|
||||
}),
|
||||
this.prisma.interventionReport.groupBy({
|
||||
by: ['componentConcernedId'],
|
||||
where: {
|
||||
componentConcernedId: { not: null },
|
||||
workOrder: { status: 'DONE', type: 'CORRECTIVE', completedAt: { gte: depuisPeriode } },
|
||||
},
|
||||
_count: { _all: true },
|
||||
}),
|
||||
this.prisma.stockMovement.findMany({
|
||||
where: { kind: 'CONSUMPTION', workOrderId: { not: null }, createdAt: { gte: depuisPeriode } },
|
||||
select: {
|
||||
quantity: true,
|
||||
unitPrice: true,
|
||||
workOrder: {
|
||||
select: {
|
||||
type: true,
|
||||
asset: {
|
||||
select: {
|
||||
reference: true,
|
||||
location: { select: { name: true, parent: { select: { name: true } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
// Coûts par mois (6 derniers) — pièces + main-d'œuvre, prix/taux figés
|
||||
const cleMois = (d: Date) => d.toISOString().slice(0, 7);
|
||||
const parMois = new Map<string, number>();
|
||||
for (let i = months - 1; i >= 0; i--) parMois.set(cleMois(debutMois(i)), 0);
|
||||
for (const c of consommations) {
|
||||
const cle = cleMois(c.createdAt);
|
||||
if (parMois.has(cle)) {
|
||||
parMois.set(cle, parMois.get(cle)! + -c.quantity * Number(c.unitPrice ?? 0));
|
||||
}
|
||||
}
|
||||
for (const l of mainOeuvre) {
|
||||
const cle = cleMois(l.createdAt);
|
||||
if (parMois.has(cle)) {
|
||||
parMois.set(cle, parMois.get(cle)! + (l.minutes / 60) * Number(l.hourlyRate));
|
||||
}
|
||||
}
|
||||
const costsByMonth = [...parMois.entries()].map(([month, total]) => ({
|
||||
month,
|
||||
total: Math.round(total * 100) / 100,
|
||||
}));
|
||||
const moisCourant = cleMois(new Date());
|
||||
const moisPrecedent = cleMois(debutMois(1));
|
||||
|
||||
// Pannes par organe : depuis les bilans codés
|
||||
const labels = await this.prisma.referenceValue.findMany({
|
||||
where: { id: { in: pannes.map((p) => p.componentConcernedId!).filter(Boolean) } },
|
||||
});
|
||||
const labelDe = new Map(labels.map((l) => [l.id, l.label]));
|
||||
const failuresByComponent = pannes
|
||||
.map((p) => ({
|
||||
label: labelDe.get(p.componentConcernedId!) ?? '—',
|
||||
count: p._count._all,
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 6);
|
||||
|
||||
// Top équipements en coût (pièces via OT + main-d'œuvre)
|
||||
const laborParOT = await this.prisma.laborTime.findMany({
|
||||
where: { createdAt: { gte: depuisPeriode } },
|
||||
select: {
|
||||
minutes: true,
|
||||
hourlyRate: true,
|
||||
workOrder: {
|
||||
select: {
|
||||
type: true,
|
||||
asset: {
|
||||
select: {
|
||||
reference: true,
|
||||
location: { select: { name: true, parent: { select: { name: true } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
interface Cumul { siteName: string; correctives: number; partsCost: number; laborCost: number }
|
||||
const parAppareil = new Map<string, Cumul>();
|
||||
const cumul = (ref: string, siteName: string): Cumul => {
|
||||
if (!parAppareil.has(ref)) {
|
||||
parAppareil.set(ref, { siteName, correctives: 0, partsCost: 0, laborCost: 0 });
|
||||
}
|
||||
return parAppareil.get(ref)!;
|
||||
};
|
||||
for (const m of topDonnees) {
|
||||
const asset = m.workOrder!.asset;
|
||||
const c = cumul(asset.reference, asset.location.parent?.name ?? asset.location.name);
|
||||
c.partsCost += -m.quantity * Number(m.unitPrice ?? 0);
|
||||
}
|
||||
for (const l of laborParOT) {
|
||||
const asset = l.workOrder.asset;
|
||||
const c = cumul(asset.reference, asset.location.parent?.name ?? asset.location.name);
|
||||
c.laborCost += (l.minutes / 60) * Number(l.hourlyRate);
|
||||
}
|
||||
const correctivesParAppareil = await this.prisma.workOrder.groupBy({
|
||||
by: ['assetId'],
|
||||
where: { type: 'CORRECTIVE', createdAt: { gte: depuisPeriode } },
|
||||
_count: { _all: true },
|
||||
});
|
||||
const assetsRefs = await this.prisma.asset.findMany({
|
||||
where: { id: { in: correctivesParAppareil.map((c) => c.assetId) } },
|
||||
select: { id: true, reference: true },
|
||||
});
|
||||
const refDe = new Map(assetsRefs.map((a) => [a.id, a.reference]));
|
||||
for (const c of correctivesParAppareil) {
|
||||
const ref = refDe.get(c.assetId);
|
||||
if (ref && parAppareil.has(ref)) parAppareil.get(ref)!.correctives = c._count._all;
|
||||
}
|
||||
const topAssets = [...parAppareil.entries()]
|
||||
.map(([reference, c]) => ({
|
||||
reference,
|
||||
siteName: c.siteName,
|
||||
correctives: c.correctives,
|
||||
partsCost: Math.round(c.partsCost * 100) / 100,
|
||||
laborCost: Math.round(c.laborCost * 100) / 100,
|
||||
total: Math.round((c.partsCost + c.laborCost) * 100) / 100,
|
||||
}))
|
||||
.sort((a, b) => b.total - a.total)
|
||||
.slice(0, 5);
|
||||
|
||||
const totalClos = clos12m.reduce((s, g) => s + g._count._all, 0);
|
||||
const preventifsClos = clos12m.find((g) => g.type === 'PREVENTIVE')?._count._all ?? 0;
|
||||
const grillesTerminees = grilles12m.filter((g) => g.status === 'DONE').length;
|
||||
|
||||
return {
|
||||
months,
|
||||
monthCost: costsByMonth.find((c) => c.month === moisCourant)?.total ?? 0,
|
||||
previousMonthCost: costsByMonth.find((c) => c.month === moisPrecedent)?.total ?? 0,
|
||||
closed: { total: totalClos, preventive: preventifsClos },
|
||||
preventiveRate: grilles12m.length
|
||||
? Math.round((grillesTerminees / grilles12m.length) * 100) / 100
|
||||
: null,
|
||||
avgResolutionDays: correctifsClos.length
|
||||
? Math.round(
|
||||
(correctifsClos.reduce(
|
||||
(s, w) => s + (w.completedAt!.getTime() - w.createdAt.getTime()),
|
||||
0,
|
||||
) /
|
||||
correctifsClos.length /
|
||||
86400e3) *
|
||||
10,
|
||||
) / 10
|
||||
: null,
|
||||
failuresByComponent,
|
||||
costsByMonth,
|
||||
topAssets,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import { DynamicModule, Module } from '@nestjs/common';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { AnalyticsModule } from './analytics/analytics.module';
|
||||
import { SearchModule } from './search/search.module';
|
||||
import { AssistantModule } from './assistant/assistant.module';
|
||||
import { AssetsModule } from './assets/assets.module';
|
||||
import { DocumentsModule } from './documents/documents.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { DemoAuthModule } from './auth/demo/demo-auth.module';
|
||||
import { JwtAuthGuard } from './auth/jwt-auth.guard';
|
||||
@@ -10,7 +14,10 @@ import { FilesModule } from './files/files.module';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { LocationsModule } from './locations/locations.module';
|
||||
import { MetersModule } from './meters/meters.module';
|
||||
import { PartnersModule } from './partners/partners.module';
|
||||
import { PartsModule } from './parts/parts.module';
|
||||
import { PortalModule } from './portal/portal.module';
|
||||
import { PurchaseOrdersModule } from './purchase-orders/purchase-orders.module';
|
||||
import { PreventiveModule } from './preventive/preventive.module';
|
||||
import { ReferenceValuesModule } from './reference-values/reference-values.module';
|
||||
import { RequestsModule } from './requests/requests.module';
|
||||
@@ -50,6 +57,14 @@ export class AppModule {
|
||||
PreventiveModule,
|
||||
MetersModule,
|
||||
PortalModule,
|
||||
// R3 — gestion
|
||||
PartnersModule,
|
||||
PartsModule,
|
||||
PurchaseOrdersModule,
|
||||
DocumentsModule,
|
||||
AnalyticsModule,
|
||||
SearchModule,
|
||||
AssistantModule,
|
||||
// ADR-002 : hors DEMO_MODE, le module n'est pas enregistré → 404
|
||||
...(demoModeEnabled() ? [DemoAuthModule] : []),
|
||||
],
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
type AssetCreate,
|
||||
type AssetUpdate,
|
||||
} from '@siop/shared';
|
||||
import { type AuthenticatedUser, CurrentUser } from '../auth/current-user.decorator';
|
||||
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||
import { RequirePermission } from '../permissions/require-permission.decorator';
|
||||
import { AssetsService } from './assets.service';
|
||||
@@ -32,10 +33,11 @@ export class AssetsController {
|
||||
}
|
||||
|
||||
/** Avant ':id' (ordre des routes) — authentification seule : le demandeur
|
||||
* doit pouvoir désigner l'appareil qu'il signale. */
|
||||
* doit pouvoir désigner l'appareil qu'il signale (filtré à son site s'il
|
||||
* en a un, R6.6). */
|
||||
@Get('options')
|
||||
options() {
|
||||
return this.assetsService.options();
|
||||
options(@CurrentUser() user: AuthenticatedUser) {
|
||||
return this.assetsService.options(user);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
AssetsResponse,
|
||||
AssetUpdate,
|
||||
} from '@siop/shared';
|
||||
import type { AuthenticatedUser } from '../auth/current-user.decorator';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
const assetInclude = {
|
||||
@@ -29,9 +30,30 @@ type AssetRow = Prisma.AssetGetPayload<{ include: typeof assetInclude }>;
|
||||
export class AssetsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/** Options minimales pour le signalement — ouvert à tout rôle authentifié. */
|
||||
async options(): Promise<AssetOptionsResponse> {
|
||||
/** Sites + zones autorisés pour le signalement de `user`, ou `null` si aucune
|
||||
* restriction (comportement historique — tous les rôles sauf un Demandeur
|
||||
* affecté à un site, R6.6). Réutilisée par `options()` ET par
|
||||
* `RequestsService.create` pour que les deux filtres ne divergent jamais. */
|
||||
async allowedLocationIds(user: AuthenticatedUser): Promise<string[] | null> {
|
||||
const me = await this.prisma.user.findUnique({
|
||||
where: { id: user.userId },
|
||||
select: { assignedSites: { select: { id: true } } },
|
||||
});
|
||||
const siteIds = (me?.assignedSites ?? []).map((s) => s.id);
|
||||
if (siteIds.length === 0) return null;
|
||||
const zones = await this.prisma.location.findMany({
|
||||
where: { parentId: { in: siteIds } },
|
||||
select: { id: true },
|
||||
});
|
||||
return [...siteIds, ...zones.map((z) => z.id)];
|
||||
}
|
||||
|
||||
/** Options minimales pour le signalement — ouvert à tout rôle authentifié,
|
||||
* filtré au périmètre du Demandeur s'il est affecté à un site (R6.6). */
|
||||
async options(user: AuthenticatedUser): Promise<AssetOptionsResponse> {
|
||||
const allowed = await this.allowedLocationIds(user);
|
||||
const rows = await this.prisma.asset.findMany({
|
||||
where: allowed ? { locationId: { in: allowed } } : undefined,
|
||||
include: { location: { include: { parent: true } } },
|
||||
orderBy: { reference: 'asc' },
|
||||
});
|
||||
|
||||
66
apps/api/src/assistant/assistant.controller.ts
Normal file
66
apps/api/src/assistant/assistant.controller.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
HttpCode,
|
||||
Post,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
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';
|
||||
|
||||
// Dictée (D5) : un enregistrement de quelques dizaines de secondes tient
|
||||
// largement dans 15 Mo — pas besoin du plafond 20 Mo des documents R3.
|
||||
const AUDIO_MAX_BYTES = 15 * 1024 * 1024;
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
/** Dictée (D5) — même droit que la saisie du bilan qu'elle alimente. */
|
||||
@Post('transcribe')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('WORK_ORDERS', 'edit')
|
||||
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: AUDIO_MAX_BYTES } }))
|
||||
transcribe(@UploadedFile() file: Express.Multer.File | undefined) {
|
||||
if (!file) throw new BadRequestException('Aucun enregistrement reçu (champ « file »)');
|
||||
return this.assistant.transcribe({
|
||||
buffer: file.buffer,
|
||||
originalName: file.originalname,
|
||||
contentType: file.mimetype,
|
||||
});
|
||||
}
|
||||
}
|
||||
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 {}
|
||||
161
apps/api/src/assistant/assistant.service.ts
Normal file
161
apps/api/src/assistant/assistant.service.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
|
||||
import type {
|
||||
AssistantAnswer,
|
||||
AssistantAsk,
|
||||
BilanField,
|
||||
BilanSuggestionsResponse,
|
||||
ReindexResult,
|
||||
SuggestBilan,
|
||||
TranscriptionResult,
|
||||
} 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,
|
||||
};
|
||||
}
|
||||
|
||||
/** Dictée (R5 D5, opt-in) : l'audio ne transite qu'une fois vers `siop2-ai`
|
||||
* — jamais écrit ni sur disque ni en base ici, transmis tel quel en
|
||||
* multipart. Le service IA l'efface aussitôt transcrit (transcription.py). */
|
||||
async transcribe(audio: {
|
||||
buffer: Buffer;
|
||||
originalName: string;
|
||||
contentType: string;
|
||||
}): Promise<TranscriptionResult> {
|
||||
const form = new FormData();
|
||||
form.append(
|
||||
'fichier',
|
||||
new Blob([new Uint8Array(audio.buffer)], { type: audio.contentType }),
|
||||
audio.originalName,
|
||||
);
|
||||
let reponse: Response;
|
||||
try {
|
||||
reponse = await fetch(`${this.env.AI_SERVICE_URL}/internal/transcrire`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Service-Token': this.env.AI_SERVICE_TOKEN },
|
||||
body: form,
|
||||
});
|
||||
} catch {
|
||||
this.journal.warn('Service IA injoignable (/internal/transcrire)');
|
||||
throw new ServiceUnavailableException(
|
||||
'Dictée indisponible pour le moment — réessayez dans un instant.',
|
||||
);
|
||||
}
|
||||
if (!reponse.ok) {
|
||||
this.journal.warn(`Service IA a refusé /internal/transcrire (${reponse.status})`);
|
||||
throw new ServiceUnavailableException(
|
||||
'Dictée indisponible pour le moment — réessayez dans un instant.',
|
||||
);
|
||||
}
|
||||
const brut = (await reponse.json()) as { texte: string };
|
||||
return { text: brut.texte };
|
||||
}
|
||||
|
||||
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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,14 @@ const EnvSchema = z.object({
|
||||
MINIO_USE_SSL: z.string().optional(),
|
||||
MINIO_ACCESS_KEY: z.string().default('siop'),
|
||||
MINIO_SECRET_KEY: z.string().default('siop-minio'),
|
||||
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>;
|
||||
|
||||
94
apps/api/src/documents/documents.controller.ts
Normal file
94
apps/api/src/documents/documents.controller.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
StreamableFile,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import {
|
||||
DOCUMENT_MAX_BYTES,
|
||||
DocumentCorpusUpdateSchema,
|
||||
type DocumentCorpusUpdate,
|
||||
} from '@siop/shared';
|
||||
import {
|
||||
AuthenticatedUser,
|
||||
CurrentUser,
|
||||
} from '../auth/current-user.decorator';
|
||||
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||
import { RequirePermission } from '../permissions/require-permission.decorator';
|
||||
import { DocumentsService } from './documents.service';
|
||||
|
||||
@Controller('documents')
|
||||
export class DocumentsController {
|
||||
constructor(private readonly documents: DocumentsService) {}
|
||||
|
||||
/** Bibliothèque interne — authentification seule (les cartes « Documents »
|
||||
* des fiches la consomment). */
|
||||
@Get()
|
||||
list(
|
||||
@Query('assetId') assetId?: string,
|
||||
@Query('workOrderId') workOrderId?: string,
|
||||
@Query('kind') kind?: string,
|
||||
) {
|
||||
return this.documents.list({ assetId, workOrderId, kind });
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', { limits: { fileSize: DOCUMENT_MAX_BYTES } }),
|
||||
)
|
||||
upload(
|
||||
@UploadedFile() file: Express.Multer.File | undefined,
|
||||
@Body() body: { kind?: string; assetId?: string; workOrderId?: string },
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
) {
|
||||
if (!file) throw new BadRequestException('Aucun fichier reçu (champ « file »)');
|
||||
return this.documents.upload(
|
||||
{
|
||||
buffer: file.buffer,
|
||||
originalName: file.originalname,
|
||||
contentType: file.mimetype,
|
||||
size: file.size,
|
||||
kind: body.kind ?? 'OTHER',
|
||||
assetId: body.assetId || undefined,
|
||||
workOrderId: body.workOrderId || undefined,
|
||||
},
|
||||
user,
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':id/download')
|
||||
async download(@Param('id', ParseUUIDPipe) id: string): Promise<StreamableFile> {
|
||||
const { stream, fileName, contentType } = await this.documents.download(id);
|
||||
return new StreamableFile(stream as never, {
|
||||
type: contentType,
|
||||
disposition: `attachment; filename*=UTF-8''${encodeURIComponent(fileName)}`,
|
||||
});
|
||||
}
|
||||
|
||||
/** 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')
|
||||
@HttpCode(204)
|
||||
remove(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: AuthenticatedUser) {
|
||||
return this.documents.remove(id, user);
|
||||
}
|
||||
}
|
||||
9
apps/api/src/documents/documents.module.ts
Normal file
9
apps/api/src/documents/documents.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DocumentsController } from './documents.controller';
|
||||
import { DocumentsService } from './documents.service';
|
||||
|
||||
@Module({
|
||||
controllers: [DocumentsController],
|
||||
providers: [DocumentsService],
|
||||
})
|
||||
export class DocumentsModule {}
|
||||
175
apps/api/src/documents/documents.service.ts
Normal file
175
apps/api/src/documents/documents.service.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import {
|
||||
DOCUMENT_CONTENT_TYPES,
|
||||
DOCUMENT_KINDS,
|
||||
DOCUMENT_MAX_BYTES,
|
||||
type DocumentDto,
|
||||
type DocumentKind,
|
||||
type DocumentsResponse,
|
||||
} from '@siop/shared';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { AuthenticatedUser } from '../auth/current-user.decorator';
|
||||
import { FILE_STORAGE, type FileStorage } from '../files/file-storage';
|
||||
import { PermissionsService } from '../permissions/permissions.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
const documentInclude = {
|
||||
asset: { select: { reference: true } },
|
||||
workOrder: { select: { reference: true } },
|
||||
uploadedBy: { select: { displayName: true } },
|
||||
} satisfies Prisma.DocumentInclude;
|
||||
|
||||
type Row = Prisma.DocumentGetPayload<{ include: typeof documentInclude }>;
|
||||
|
||||
export interface UploadInput {
|
||||
buffer: Buffer;
|
||||
originalName: string;
|
||||
contentType: string;
|
||||
size: number;
|
||||
kind: string;
|
||||
assetId?: string;
|
||||
workOrderId?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DocumentsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissions: PermissionsService,
|
||||
@Inject(FILE_STORAGE) private readonly storage: FileStorage,
|
||||
) {}
|
||||
|
||||
async list(filtres: {
|
||||
assetId?: string;
|
||||
workOrderId?: string;
|
||||
kind?: string;
|
||||
}): Promise<DocumentsResponse> {
|
||||
const rows = await this.prisma.document.findMany({
|
||||
where: {
|
||||
assetId: filtres.assetId || undefined,
|
||||
workOrderId: filtres.workOrderId || undefined,
|
||||
kind: (filtres.kind as DocumentKind) || undefined,
|
||||
},
|
||||
include: documentInclude,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return { documents: rows.map((r) => this.toDto(r)) };
|
||||
}
|
||||
|
||||
/** Upload : types fermés, 20 Mo max, rattachement REQUIS, permission
|
||||
* d'édition sur la CIBLE (appareil → ASSETS, OT → WORK_ORDERS). */
|
||||
async upload(input: UploadInput, user: AuthenticatedUser): Promise<DocumentDto> {
|
||||
if (!(DOCUMENT_KINDS as readonly string[]).includes(input.kind)) {
|
||||
throw new BadRequestException('Type de document inconnu');
|
||||
}
|
||||
if (!(DOCUMENT_CONTENT_TYPES as readonly string[]).includes(input.contentType)) {
|
||||
throw new BadRequestException('Format refusé — PDF, JPG ou PNG uniquement');
|
||||
}
|
||||
if (input.size > DOCUMENT_MAX_BYTES) {
|
||||
throw new BadRequestException('Fichier trop lourd — 20 Mo maximum');
|
||||
}
|
||||
if (!input.assetId && !input.workOrderId) {
|
||||
throw new BadRequestException('Rattachez le document à un appareil ou à un OT');
|
||||
}
|
||||
await this.assertCible(input.assetId, input.workOrderId, user, 'edit');
|
||||
|
||||
const storageKey = `documents/${randomUUID()}/${input.originalName.replace(/[^\w.\-()À-ſ ]/g, '_')}`;
|
||||
await this.storage.putObject(storageKey, input.buffer, input.contentType);
|
||||
const created = await this.prisma.document.create({
|
||||
data: {
|
||||
kind: input.kind as DocumentKind,
|
||||
fileName: input.originalName,
|
||||
storageKey,
|
||||
size: input.size,
|
||||
contentType: input.contentType,
|
||||
assetId: input.assetId,
|
||||
workOrderId: input.workOrderId,
|
||||
uploadedById: user.userId,
|
||||
},
|
||||
include: documentInclude,
|
||||
});
|
||||
return this.toDto(created);
|
||||
}
|
||||
|
||||
async download(id: string): Promise<{
|
||||
stream: NodeJS.ReadableStream;
|
||||
fileName: string;
|
||||
contentType: string;
|
||||
}> {
|
||||
const doc = await this.prisma.document.findUnique({ where: { id } });
|
||||
if (!doc) throw new NotFoundException('Document inconnu');
|
||||
return {
|
||||
stream: await this.storage.getObjectStream(doc.storageKey),
|
||||
fileName: doc.fileName,
|
||||
contentType: doc.contentType,
|
||||
};
|
||||
}
|
||||
|
||||
async remove(id: string, user: AuthenticatedUser): Promise<void> {
|
||||
const doc = await this.prisma.document.findUnique({ where: { id } });
|
||||
if (!doc) throw new NotFoundException('Document inconnu');
|
||||
await this.assertCible(doc.assetId ?? undefined, doc.workOrderId ?? undefined, user, 'edit');
|
||||
await this.prisma.document.delete({ where: { id } });
|
||||
await this.storage.removeObject(doc.storageKey).catch(() => {
|
||||
// le stockage peut être momentanément injoignable — la base fait foi
|
||||
});
|
||||
}
|
||||
|
||||
private async assertCible(
|
||||
assetId: string | undefined,
|
||||
workOrderId: string | undefined,
|
||||
user: AuthenticatedUser,
|
||||
right: 'edit',
|
||||
): Promise<void> {
|
||||
if (assetId) {
|
||||
const asset = await this.prisma.asset.findUnique({ where: { id: assetId } });
|
||||
if (!asset) throw new BadRequestException('Appareil inconnu');
|
||||
if (!(await this.permissions.can(user.roleId, 'ASSETS', right))) {
|
||||
throw new ForbiddenException('Droit manquant : ASSETS.edit');
|
||||
}
|
||||
}
|
||||
if (workOrderId) {
|
||||
const ot = await this.prisma.workOrder.findUnique({ where: { id: workOrderId } });
|
||||
if (!ot) throw new BadRequestException('OT inconnu');
|
||||
if (!(await this.permissions.can(user.roleId, 'WORK_ORDERS', right))) {
|
||||
throw new ForbiddenException('Droit manquant : WORK_ORDERS.edit');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private toDto(row: Row): DocumentDto {
|
||||
return {
|
||||
id: row.id,
|
||||
kind: row.kind,
|
||||
fileName: row.fileName,
|
||||
size: row.size,
|
||||
contentType: row.contentType,
|
||||
assetReference: row.asset?.reference ?? null,
|
||||
workOrderReference: row.workOrder?.reference ?? null,
|
||||
uploadedByName: row.uploadedBy?.displayName ?? null,
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
/**
|
||||
* Interface de stockage de fichiers (ADR-001) : MinIO n'est JAMAIS importé
|
||||
* ailleurs que dans son implémentation — le reste de l'API dépend de cette
|
||||
* interface (règle lint à venir en R0.12). R0 : santé seulement ;
|
||||
* les opérations arrivent avec les photos/documents (R2-R3).
|
||||
* ailleurs que dans son implémentation (règle ESLint) — le reste de l'API
|
||||
* dépend de cette interface. R3 : la bibliothèque de documents l'utilise ;
|
||||
* les téléchargements sont STREAMÉS par l'API (MinIO n'est pas exposé).
|
||||
*/
|
||||
export const FILE_STORAGE = Symbol('FILE_STORAGE');
|
||||
|
||||
export interface FileStorage {
|
||||
/** Lève une exception si le stockage est injoignable. */
|
||||
healthCheck(): Promise<void>;
|
||||
putObject(key: string, body: Buffer, contentType: string): Promise<void>;
|
||||
getObjectStream(key: string): Promise<NodeJS.ReadableStream>;
|
||||
removeObject(key: string): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import * as Minio from 'minio';
|
||||
import { loadEnv } from '../config/env';
|
||||
import type { FileStorage } from './file-storage';
|
||||
|
||||
/** Seul fichier du dépôt autorisé à importer le SDK MinIO. */
|
||||
/** Seul fichier du dépôt autorisé à importer le SDK MinIO (règle ESLint). */
|
||||
@Injectable()
|
||||
export class MinioStorageService implements FileStorage {
|
||||
export class MinioStorageService implements FileStorage, OnModuleInit {
|
||||
private readonly logger = new Logger(MinioStorageService.name);
|
||||
private readonly client: Minio.Client;
|
||||
private readonly bucket: string;
|
||||
|
||||
constructor() {
|
||||
const env = loadEnv();
|
||||
this.bucket = env.MINIO_BUCKET;
|
||||
this.client = new Minio.Client({
|
||||
endPoint: env.MINIO_ENDPOINT,
|
||||
port: env.MINIO_PORT,
|
||||
@@ -19,7 +22,34 @@ export class MinioStorageService implements FileStorage {
|
||||
});
|
||||
}
|
||||
|
||||
/** Le bucket est créé au démarrage — aucune étape manuelle au déploiement. */
|
||||
async onModuleInit(): Promise<void> {
|
||||
try {
|
||||
if (!(await this.client.bucketExists(this.bucket))) {
|
||||
await this.client.makeBucket(this.bucket);
|
||||
this.logger.log(`Bucket « ${this.bucket} » créé.`);
|
||||
}
|
||||
} catch (e) {
|
||||
// Le stockage peut être en retard au boot : /health le signalera.
|
||||
this.logger.warn(`Stockage indisponible au démarrage : ${String(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<void> {
|
||||
await this.client.listBuckets();
|
||||
}
|
||||
|
||||
async putObject(key: string, body: Buffer, contentType: string): Promise<void> {
|
||||
await this.client.putObject(this.bucket, key, body, body.length, {
|
||||
'Content-Type': contentType,
|
||||
});
|
||||
}
|
||||
|
||||
async getObjectStream(key: string): Promise<NodeJS.ReadableStream> {
|
||||
return this.client.getObject(this.bucket, key);
|
||||
}
|
||||
|
||||
async removeObject(key: string): Promise<void> {
|
||||
await this.client.removeObject(this.bucket, key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,9 +21,16 @@ type LocationRow = {
|
||||
guardianPhone: string | null;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
partnerId: string | null;
|
||||
partner: { name: string } | null;
|
||||
_count: { assets: number };
|
||||
};
|
||||
|
||||
const locationInclude = {
|
||||
partner: { select: { name: true } },
|
||||
_count: { select: { assets: true } },
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export class LocationsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
@@ -31,7 +38,7 @@ export class LocationsService {
|
||||
/** Liste plate ; l'assetCount d'un SITE inclut les appareils de ses zones. */
|
||||
async list(): Promise<LocationsResponse> {
|
||||
const rows: LocationRow[] = await this.prisma.location.findMany({
|
||||
include: { _count: { select: { assets: true } } },
|
||||
include: locationInclude,
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
const childAssets = new Map<string, number>();
|
||||
@@ -52,9 +59,10 @@ export class LocationsService {
|
||||
|
||||
async create(dto: LocationCreate): Promise<LocationDto> {
|
||||
await this.assertDepth(dto.parentId);
|
||||
await this.assertClientPartner(dto.partnerId);
|
||||
const created = await this.prisma.location.create({
|
||||
data: dto,
|
||||
include: { _count: { select: { assets: true } } },
|
||||
include: locationInclude,
|
||||
});
|
||||
return this.toDto(created, 0);
|
||||
}
|
||||
@@ -76,10 +84,11 @@ export class LocationsService {
|
||||
}
|
||||
await this.assertDepth(dto.parentId);
|
||||
}
|
||||
await this.assertClientPartner(dto.partnerId);
|
||||
const updated = await this.prisma.location.update({
|
||||
where: { id },
|
||||
data: dto,
|
||||
include: { _count: { select: { assets: true } } },
|
||||
include: locationInclude,
|
||||
});
|
||||
return this.toDto(updated, updated._count.assets);
|
||||
}
|
||||
@@ -96,6 +105,16 @@ export class LocationsService {
|
||||
}
|
||||
}
|
||||
|
||||
/** R3 : seul un tiers Client/syndic peut gérer un site. */
|
||||
private async assertClientPartner(partnerId?: string | null): Promise<void> {
|
||||
if (!partnerId) return;
|
||||
const partner = await this.prisma.partner.findUnique({ where: { id: partnerId } });
|
||||
if (!partner) throw new BadRequestException('Tiers inconnu');
|
||||
if (partner.kind !== 'CLIENT') {
|
||||
throw new BadRequestException('Seul un tiers « Client / syndic » peut être rattaché à un site');
|
||||
}
|
||||
}
|
||||
|
||||
private toDto(row: Omit<LocationRow, '_count'>, assetCount: number): LocationDto {
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -107,6 +126,8 @@ export class LocationsService {
|
||||
guardianPhone: row.guardianPhone,
|
||||
latitude: row.latitude,
|
||||
longitude: row.longitude,
|
||||
partnerId: row.partnerId,
|
||||
partnerName: row.partner?.name ?? null,
|
||||
assetCount,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ async function bootstrap() {
|
||||
assertDemoModeAllowed(); // ADR-002 — double verrou avant toute écoute réseau
|
||||
|
||||
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();
|
||||
await app.listen(env.PORT);
|
||||
new Logger('Bootstrap').log(`API SIOP V2 démarrée sur :${env.PORT}`);
|
||||
|
||||
45
apps/api/src/partners/partners.controller.ts
Normal file
45
apps/api/src/partners/partners.controller.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
PartnerCreateSchema,
|
||||
PartnerUpdateSchema,
|
||||
type PartnerCreate,
|
||||
type PartnerUpdate,
|
||||
} from '@siop/shared';
|
||||
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||
import { RequirePermission } from '../permissions/require-permission.decorator';
|
||||
import { PartnersService } from './partners.service';
|
||||
|
||||
/** Les tiers vivent sous la permission PURCHASE_ORDERS (décision R3). */
|
||||
@Controller('partners')
|
||||
export class PartnersController {
|
||||
constructor(private readonly partners: PartnersService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermission('PURCHASE_ORDERS', 'view')
|
||||
list() {
|
||||
return this.partners.list();
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermission('PURCHASE_ORDERS', 'create')
|
||||
create(@Body(new ZodValidationPipe(PartnerCreateSchema)) body: PartnerCreate) {
|
||||
return this.partners.create(body);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequirePermission('PURCHASE_ORDERS', 'edit')
|
||||
update(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body(new ZodValidationPipe(PartnerUpdateSchema)) body: PartnerUpdate,
|
||||
) {
|
||||
return this.partners.update(id, body);
|
||||
}
|
||||
}
|
||||
9
apps/api/src/partners/partners.module.ts
Normal file
9
apps/api/src/partners/partners.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PartnersController } from './partners.controller';
|
||||
import { PartnersService } from './partners.service';
|
||||
|
||||
@Module({
|
||||
controllers: [PartnersController],
|
||||
providers: [PartnersService],
|
||||
})
|
||||
export class PartnersModule {}
|
||||
91
apps/api/src/partners/partners.service.ts
Normal file
91
apps/api/src/partners/partners.service.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import type {
|
||||
PartnerCreate,
|
||||
PartnerDto,
|
||||
PartnersResponse,
|
||||
PartnerUpdate,
|
||||
} from '@siop/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
const partnerInclude = {
|
||||
_count: {
|
||||
select: {
|
||||
purchaseOrders: { where: { status: { in: ['DRAFT', 'SENT'] } } },
|
||||
},
|
||||
},
|
||||
// Rattachements de la maquette : sites (jamais les zones) gérés par un client/syndic.
|
||||
sites: {
|
||||
where: { parentId: null },
|
||||
select: { name: true },
|
||||
orderBy: { name: 'asc' as const },
|
||||
},
|
||||
} satisfies Prisma.PartnerInclude;
|
||||
|
||||
type Row = Prisma.PartnerGetPayload<{ include: typeof partnerInclude }>;
|
||||
|
||||
@Injectable()
|
||||
export class PartnersService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(): Promise<PartnersResponse> {
|
||||
const rows = await this.prisma.partner.findMany({
|
||||
include: partnerInclude,
|
||||
orderBy: [{ kind: 'asc' }, { name: 'asc' }],
|
||||
});
|
||||
return { partners: rows.map((r) => this.toDto(r)) };
|
||||
}
|
||||
|
||||
async create(dto: PartnerCreate): Promise<PartnerDto> {
|
||||
try {
|
||||
const created = await this.prisma.partner.create({
|
||||
data: dto,
|
||||
include: partnerInclude,
|
||||
});
|
||||
return this.toDto(created);
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||
throw new ConflictException('Ce nom de tiers existe déjà');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async update(id: string, dto: PartnerUpdate): Promise<PartnerDto> {
|
||||
try {
|
||||
const updated = await this.prisma.partner.update({
|
||||
where: { id },
|
||||
data: dto,
|
||||
include: partnerInclude,
|
||||
});
|
||||
return this.toDto(updated);
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') {
|
||||
throw new NotFoundException('Tiers inconnu');
|
||||
}
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||
throw new ConflictException('Ce nom de tiers existe déjà');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private toDto(row: Row): PartnerDto {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
kind: row.kind,
|
||||
contactName: row.contactName,
|
||||
phone: row.phone,
|
||||
email: row.email,
|
||||
city: row.city,
|
||||
isActive: row.isActive,
|
||||
openOrders: row._count.purchaseOrders,
|
||||
siteNames: row.sites.map((s) => s.name),
|
||||
};
|
||||
}
|
||||
}
|
||||
66
apps/api/src/parts/parts.controller.ts
Normal file
66
apps/api/src/parts/parts.controller.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
PartCreateSchema,
|
||||
PartUpdateSchema,
|
||||
StockMovementCreateSchema,
|
||||
type PartCreate,
|
||||
type PartUpdate,
|
||||
type StockMovementCreate,
|
||||
} from '@siop/shared';
|
||||
import {
|
||||
AuthenticatedUser,
|
||||
CurrentUser,
|
||||
} from '../auth/current-user.decorator';
|
||||
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||
import { RequirePermission } from '../permissions/require-permission.decorator';
|
||||
import { PartsService } from './parts.service';
|
||||
|
||||
@Controller('parts')
|
||||
export class PartsController {
|
||||
constructor(private readonly parts: PartsService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermission('PARTS', 'view')
|
||||
list() {
|
||||
return this.parts.list();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermission('PARTS', 'view')
|
||||
get(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.parts.get(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermission('PARTS', 'create')
|
||||
create(@Body(new ZodValidationPipe(PartCreateSchema)) body: PartCreate) {
|
||||
return this.parts.create(body);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequirePermission('PARTS', 'edit')
|
||||
update(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body(new ZodValidationPipe(PartUpdateSchema)) body: PartUpdate,
|
||||
) {
|
||||
return this.parts.update(id, body);
|
||||
}
|
||||
|
||||
@Post(':id/movements')
|
||||
@RequirePermission('PARTS', 'edit')
|
||||
addMovement(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body(new ZodValidationPipe(StockMovementCreateSchema)) body: StockMovementCreate,
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
) {
|
||||
return this.parts.addMovement(id, body, user);
|
||||
}
|
||||
}
|
||||
10
apps/api/src/parts/parts.module.ts
Normal file
10
apps/api/src/parts/parts.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PartsController } from './parts.controller';
|
||||
import { PartsService } from './parts.service';
|
||||
|
||||
@Module({
|
||||
controllers: [PartsController],
|
||||
providers: [PartsService],
|
||||
exports: [PartsService],
|
||||
})
|
||||
export class PartsModule {}
|
||||
231
apps/api/src/parts/parts.service.ts
Normal file
231
apps/api/src/parts/parts.service.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import type {
|
||||
PartCreate,
|
||||
PartDetail,
|
||||
PartDto,
|
||||
PartsResponse,
|
||||
PartUpdate,
|
||||
StockMovementCreate,
|
||||
StockMovementDto,
|
||||
} from '@siop/shared';
|
||||
import type { AuthenticatedUser } from '../auth/current-user.decorator';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
const partInclude = { supplier: true } satisfies Prisma.PartInclude;
|
||||
type PartRow = Prisma.PartGetPayload<{ include: typeof partInclude }>;
|
||||
|
||||
const mvtInclude = {
|
||||
workOrder: { select: { reference: true } },
|
||||
purchaseOrder: { select: { reference: true } },
|
||||
by: { select: { displayName: true } },
|
||||
} satisfies Prisma.StockMovementInclude;
|
||||
type MvtRow = Prisma.StockMovementGetPayload<{ include: typeof mvtInclude }>;
|
||||
|
||||
@Injectable()
|
||||
export class PartsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/** Le stock EST la somme des mouvements — calculé, jamais stocké. */
|
||||
private async stocks(partIds?: string[]): Promise<Map<string, number>> {
|
||||
const grouped = await this.prisma.stockMovement.groupBy({
|
||||
by: ['partId'],
|
||||
where: partIds ? { partId: { in: partIds } } : undefined,
|
||||
_sum: { quantity: true },
|
||||
});
|
||||
return new Map(grouped.map((g) => [g.partId, g._sum.quantity ?? 0]));
|
||||
}
|
||||
|
||||
async list(): Promise<PartsResponse> {
|
||||
const rows = await this.prisma.part.findMany({
|
||||
include: partInclude,
|
||||
orderBy: { reference: 'asc' },
|
||||
});
|
||||
const stocks = await this.stocks();
|
||||
return { parts: rows.map((r) => this.toDto(r, stocks.get(r.id) ?? 0)) };
|
||||
}
|
||||
|
||||
async get(id: string): Promise<PartDetail> {
|
||||
const row = await this.prisma.part.findUnique({ where: { id }, include: partInclude });
|
||||
if (!row) throw new NotFoundException('Pièce inconnue');
|
||||
const [stocks, movements] = await Promise.all([
|
||||
this.stocks([id]),
|
||||
this.prisma.stockMovement.findMany({
|
||||
where: { partId: id },
|
||||
include: mvtInclude,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 50,
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
...this.toDto(row, stocks.get(id) ?? 0),
|
||||
movements: movements.map((m) => this.toMovementDto(m)),
|
||||
};
|
||||
}
|
||||
|
||||
async create(dto: PartCreate): Promise<PartDetail> {
|
||||
if (dto.supplierId) await this.assertSupplier(dto.supplierId);
|
||||
for (let essai = 0; ; essai++) {
|
||||
try {
|
||||
const created = await this.prisma.part.create({
|
||||
data: {
|
||||
reference: await this.nextReference(),
|
||||
designation: dto.designation,
|
||||
threshold: dto.threshold ?? 0,
|
||||
supplierId: dto.supplierId,
|
||||
compatible: dto.compatible,
|
||||
lastUnitPrice: dto.initialPrice,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
return this.get(created.id);
|
||||
} catch (e) {
|
||||
if (
|
||||
e instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
e.code === 'P2002' &&
|
||||
essai < 3
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async update(id: string, dto: PartUpdate): Promise<PartDetail> {
|
||||
if (dto.supplierId) await this.assertSupplier(dto.supplierId);
|
||||
try {
|
||||
await this.prisma.part.update({ where: { id }, data: dto, select: { id: true } });
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') {
|
||||
throw new NotFoundException('Pièce inconnue');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
return this.get(id);
|
||||
}
|
||||
|
||||
/** Entrée manuelle ou ajustement — TOUJOURS un mouvement tracé. */
|
||||
async addMovement(
|
||||
id: string,
|
||||
dto: StockMovementCreate,
|
||||
user: AuthenticatedUser,
|
||||
): Promise<PartDetail> {
|
||||
const part = await this.prisma.part.findUnique({ where: { id } });
|
||||
if (!part) throw new NotFoundException('Pièce inconnue');
|
||||
if (dto.kind === 'ENTRY' && dto.quantity <= 0) {
|
||||
throw new BadRequestException('Une entrée manuelle est positive');
|
||||
}
|
||||
if (dto.kind === 'ADJUSTMENT' && !dto.reason?.trim()) {
|
||||
throw new BadRequestException('Un ajustement d’inventaire exige un motif');
|
||||
}
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const somme = await tx.stockMovement.aggregate({
|
||||
where: { partId: id },
|
||||
_sum: { quantity: true },
|
||||
});
|
||||
const stock = somme._sum.quantity ?? 0;
|
||||
if (stock + dto.quantity < 0) {
|
||||
throw new ConflictException(
|
||||
`Refusé : le stock deviendrait négatif (${stock} ${dto.quantity > 0 ? '+' : ''}${dto.quantity})`,
|
||||
);
|
||||
}
|
||||
await tx.stockMovement.create({
|
||||
data: {
|
||||
partId: id,
|
||||
kind: dto.kind,
|
||||
quantity: dto.quantity,
|
||||
reason: dto.reason,
|
||||
byId: user.userId,
|
||||
},
|
||||
});
|
||||
});
|
||||
return this.get(id);
|
||||
}
|
||||
|
||||
/** Consommation d'OT — stock suffisant exigé, PRIX FIGÉ au moment T.
|
||||
* Appelé par WorkOrdersService dans le périmètre d'un OT vérifié. */
|
||||
async consume(
|
||||
partId: string,
|
||||
quantity: number,
|
||||
workOrderId: string,
|
||||
user: AuthenticatedUser,
|
||||
): Promise<{ designation: string; unitPrice: number }> {
|
||||
const part = await this.prisma.part.findUnique({ where: { id: partId } });
|
||||
if (!part || !part.isActive) throw new BadRequestException('Pièce inconnue ou désactivée');
|
||||
if (part.lastUnitPrice === null) {
|
||||
throw new ConflictException(
|
||||
'Aucun prix connu pour cette pièce — réceptionnez un BC ou renseignez un prix initial',
|
||||
);
|
||||
}
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const somme = await tx.stockMovement.aggregate({
|
||||
where: { partId },
|
||||
_sum: { quantity: true },
|
||||
});
|
||||
const stock = somme._sum.quantity ?? 0;
|
||||
if (stock < quantity) {
|
||||
throw new ConflictException(`Stock insuffisant : ${stock} en stock, ${quantity} demandé`);
|
||||
}
|
||||
await tx.stockMovement.create({
|
||||
data: {
|
||||
partId,
|
||||
kind: 'CONSUMPTION',
|
||||
quantity: -quantity,
|
||||
unitPrice: part.lastUnitPrice,
|
||||
workOrderId,
|
||||
byId: user.userId,
|
||||
},
|
||||
});
|
||||
});
|
||||
return { designation: part.designation, unitPrice: Number(part.lastUnitPrice) };
|
||||
}
|
||||
|
||||
private async assertSupplier(supplierId: string): Promise<void> {
|
||||
const supplier = await this.prisma.partner.findUnique({ where: { id: supplierId } });
|
||||
if (!supplier || supplier.kind !== 'SUPPLIER') {
|
||||
throw new BadRequestException('Fournisseur inconnu');
|
||||
}
|
||||
}
|
||||
|
||||
private async nextReference(): Promise<string> {
|
||||
const [{ nextval }] = await this.prisma.$queryRaw<[{ nextval: bigint }]>`
|
||||
SELECT nextval('part_ref_seq')`;
|
||||
return `P-${String(nextval).padStart(4, '0')}`;
|
||||
}
|
||||
|
||||
private toDto(row: PartRow, stock: number): PartDto {
|
||||
return {
|
||||
id: row.id,
|
||||
reference: row.reference,
|
||||
designation: row.designation,
|
||||
threshold: row.threshold,
|
||||
lastUnitPrice: row.lastUnitPrice === null ? null : Number(row.lastUnitPrice),
|
||||
compatible: row.compatible,
|
||||
supplierId: row.supplierId,
|
||||
supplierName: row.supplier?.name ?? null,
|
||||
isActive: row.isActive,
|
||||
stock,
|
||||
belowThreshold: stock < row.threshold,
|
||||
};
|
||||
}
|
||||
|
||||
private toMovementDto(m: MvtRow): StockMovementDto {
|
||||
return {
|
||||
id: m.id,
|
||||
kind: m.kind,
|
||||
quantity: m.quantity,
|
||||
unitPrice: m.unitPrice === null ? null : Number(m.unitPrice),
|
||||
reason: m.reason,
|
||||
workOrderReference: m.workOrder?.reference ?? null,
|
||||
purchaseOrderReference: m.purchaseOrder?.reference ?? null,
|
||||
byName: m.by?.displayName ?? null,
|
||||
createdAt: m.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -76,14 +76,9 @@ export class PortalService {
|
||||
}
|
||||
|
||||
private async nextReference(): Promise<string> {
|
||||
const annee = new Date().getFullYear();
|
||||
const dernier = await this.prisma.request.findFirst({
|
||||
where: { reference: { startsWith: `DEM-${annee}-` } },
|
||||
orderBy: { reference: 'desc' },
|
||||
select: { reference: true },
|
||||
});
|
||||
const n = dernier ? Number(dernier.reference.split('-')[2]) + 1 : 1;
|
||||
return `DEM-${annee}-${String(n).padStart(4, '0')}`;
|
||||
const [{ nextval }] = await this.prisma.$queryRaw<[{ nextval: bigint }]>`
|
||||
SELECT nextval('request_ref_seq')`;
|
||||
return `DEM-${new Date().getFullYear()}-${String(nextval).padStart(4, '0')}`;
|
||||
}
|
||||
|
||||
private toStatus(row: Row): PortalRequestStatus {
|
||||
|
||||
@@ -210,14 +210,9 @@ export class PreventiveService {
|
||||
}
|
||||
|
||||
private async nextReference(): Promise<string> {
|
||||
const annee = new Date().getFullYear();
|
||||
const dernier = await this.prisma.workOrder.findFirst({
|
||||
where: { reference: { startsWith: `OT-${annee}-` } },
|
||||
orderBy: { reference: 'desc' },
|
||||
select: { reference: true },
|
||||
});
|
||||
const n = dernier ? Number(dernier.reference.split('-')[2]) + 1 : 1;
|
||||
return `OT-${annee}-${String(n).padStart(4, '0')}`;
|
||||
const [{ nextval }] = await this.prisma.$queryRaw<[{ nextval: bigint }]>`
|
||||
SELECT nextval('work_order_ref_seq')`;
|
||||
return `OT-${new Date().getFullYear()}-${String(nextval).padStart(4, '0')}`;
|
||||
}
|
||||
|
||||
private toDto(row: TemplateRow): TaskTemplateDto {
|
||||
|
||||
59
apps/api/src/purchase-orders/purchase-orders.controller.ts
Normal file
59
apps/api/src/purchase-orders/purchase-orders.controller.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
PurchaseOrderCreateSchema,
|
||||
PurchaseOrderTransitionSchema,
|
||||
type PurchaseOrderCreate,
|
||||
type PurchaseOrderTransition,
|
||||
} from '@siop/shared';
|
||||
import {
|
||||
AuthenticatedUser,
|
||||
CurrentUser,
|
||||
} from '../auth/current-user.decorator';
|
||||
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||
import { RequirePermission } from '../permissions/require-permission.decorator';
|
||||
import { PurchaseOrdersService } from './purchase-orders.service';
|
||||
|
||||
@Controller('purchase-orders')
|
||||
export class PurchaseOrdersController {
|
||||
constructor(private readonly purchaseOrders: PurchaseOrdersService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermission('PURCHASE_ORDERS', 'view')
|
||||
list() {
|
||||
return this.purchaseOrders.list();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermission('PURCHASE_ORDERS', 'view')
|
||||
get(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.purchaseOrders.get(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermission('PURCHASE_ORDERS', 'create')
|
||||
create(
|
||||
@Body(new ZodValidationPipe(PurchaseOrderCreateSchema)) body: PurchaseOrderCreate,
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
) {
|
||||
return this.purchaseOrders.create(body, user);
|
||||
}
|
||||
|
||||
@Post(':id/transition')
|
||||
@HttpCode(200) // le contrat : 200, l'état change
|
||||
@RequirePermission('PURCHASE_ORDERS', 'edit')
|
||||
transition(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body(new ZodValidationPipe(PurchaseOrderTransitionSchema)) body: PurchaseOrderTransition,
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
) {
|
||||
return this.purchaseOrders.transition(id, body, user);
|
||||
}
|
||||
}
|
||||
9
apps/api/src/purchase-orders/purchase-orders.module.ts
Normal file
9
apps/api/src/purchase-orders/purchase-orders.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PurchaseOrdersController } from './purchase-orders.controller';
|
||||
import { PurchaseOrdersService } from './purchase-orders.service';
|
||||
|
||||
@Module({
|
||||
controllers: [PurchaseOrdersController],
|
||||
providers: [PurchaseOrdersService],
|
||||
})
|
||||
export class PurchaseOrdersModule {}
|
||||
170
apps/api/src/purchase-orders/purchase-orders.service.ts
Normal file
170
apps/api/src/purchase-orders/purchase-orders.service.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import type {
|
||||
PurchaseOrderCreate,
|
||||
PurchaseOrderDto,
|
||||
PurchaseOrdersResponse,
|
||||
PurchaseOrderStatus,
|
||||
PurchaseOrderTransition,
|
||||
} from '@siop/shared';
|
||||
import type { AuthenticatedUser } from '../auth/current-user.decorator';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
const poInclude = {
|
||||
supplier: true,
|
||||
lines: { include: { part: true } },
|
||||
} satisfies Prisma.PurchaseOrderInclude;
|
||||
|
||||
type Row = Prisma.PurchaseOrderGetPayload<{ include: typeof poInclude }>;
|
||||
|
||||
/** Machine à états du BC : la réception est le seul chemin vers le stock. */
|
||||
const TRANSITIONS: Record<PurchaseOrderStatus, PurchaseOrderStatus[]> = {
|
||||
DRAFT: ['SENT', 'CANCELLED'],
|
||||
SENT: ['RECEIVED', 'CANCELLED'],
|
||||
RECEIVED: [],
|
||||
CANCELLED: [],
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class PurchaseOrdersService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(): Promise<PurchaseOrdersResponse> {
|
||||
const rows = await this.prisma.purchaseOrder.findMany({
|
||||
include: poInclude,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return { purchaseOrders: rows.map((r) => this.toDto(r)) };
|
||||
}
|
||||
|
||||
async get(id: string): Promise<PurchaseOrderDto> {
|
||||
const row = await this.prisma.purchaseOrder.findUnique({
|
||||
where: { id },
|
||||
include: poInclude,
|
||||
});
|
||||
if (!row) throw new NotFoundException('BC inconnu');
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
async create(dto: PurchaseOrderCreate, user: AuthenticatedUser): Promise<PurchaseOrderDto> {
|
||||
const supplier = await this.prisma.partner.findUnique({
|
||||
where: { id: dto.supplierId },
|
||||
});
|
||||
if (!supplier || supplier.kind !== 'SUPPLIER' || !supplier.isActive) {
|
||||
throw new BadRequestException('Fournisseur inconnu ou inactif');
|
||||
}
|
||||
const parts = await this.prisma.part.findMany({
|
||||
where: { id: { in: dto.lines.map((l) => l.partId) } },
|
||||
});
|
||||
if (parts.length !== new Set(dto.lines.map((l) => l.partId)).size) {
|
||||
throw new BadRequestException('Pièce inconnue dans les lignes');
|
||||
}
|
||||
for (let essai = 0; ; essai++) {
|
||||
try {
|
||||
const created = await this.prisma.purchaseOrder.create({
|
||||
data: {
|
||||
reference: await this.nextReference(),
|
||||
supplierId: dto.supplierId,
|
||||
createdById: user.userId,
|
||||
lines: { create: dto.lines },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
return this.get(created.id);
|
||||
} catch (e) {
|
||||
if (
|
||||
e instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
e.code === 'P2002' &&
|
||||
essai < 3
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async transition(
|
||||
id: string,
|
||||
dto: PurchaseOrderTransition,
|
||||
user: AuthenticatedUser,
|
||||
): Promise<PurchaseOrderDto> {
|
||||
const row = await this.prisma.purchaseOrder.findUnique({
|
||||
where: { id },
|
||||
include: poInclude,
|
||||
});
|
||||
if (!row) throw new NotFoundException('BC inconnu');
|
||||
if (!TRANSITIONS[row.status].includes(dto.to)) {
|
||||
throw new ConflictException(`Transition interdite : ${row.status} → ${dto.to}`);
|
||||
}
|
||||
if (dto.to === 'RECEIVED') {
|
||||
// LA règle : la réception crée les entrées de stock et FIGE les PU
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
for (const line of row.lines) {
|
||||
await tx.stockMovement.create({
|
||||
data: {
|
||||
partId: line.partId,
|
||||
kind: 'RECEIPT',
|
||||
quantity: line.quantity,
|
||||
unitPrice: line.unitPrice,
|
||||
purchaseOrderId: row.id,
|
||||
byId: user.userId,
|
||||
},
|
||||
});
|
||||
await tx.part.update({
|
||||
where: { id: line.partId },
|
||||
data: { lastUnitPrice: line.unitPrice },
|
||||
});
|
||||
}
|
||||
await tx.purchaseOrder.update({
|
||||
where: { id },
|
||||
data: { status: 'RECEIVED', receivedAt: new Date() },
|
||||
});
|
||||
});
|
||||
} else {
|
||||
await this.prisma.purchaseOrder.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: dto.to,
|
||||
sentAt: dto.to === 'SENT' ? new Date() : undefined,
|
||||
cancelledAt: dto.to === 'CANCELLED' ? new Date() : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
return this.get(id);
|
||||
}
|
||||
|
||||
private async nextReference(): Promise<string> {
|
||||
const [{ nextval }] = await this.prisma.$queryRaw<[{ nextval: bigint }]>`
|
||||
SELECT nextval('purchase_order_ref_seq')`;
|
||||
return `BC-${new Date().getFullYear()}-${String(nextval).padStart(4, '0')}`;
|
||||
}
|
||||
|
||||
private toDto(row: Row): PurchaseOrderDto {
|
||||
const lines = row.lines.map((l) => ({
|
||||
id: l.id,
|
||||
partId: l.partId,
|
||||
partReference: l.part.reference,
|
||||
designation: l.part.designation,
|
||||
quantity: l.quantity,
|
||||
unitPrice: Number(l.unitPrice),
|
||||
}));
|
||||
return {
|
||||
id: row.id,
|
||||
reference: row.reference,
|
||||
status: row.status,
|
||||
supplierId: row.supplierId,
|
||||
supplierName: row.supplier.name,
|
||||
lines,
|
||||
total: lines.reduce((s, l) => s + l.quantity * l.unitPrice, 0),
|
||||
sentAt: row.sentAt?.toISOString() ?? null,
|
||||
receivedAt: row.receivedAt?.toISOString() ?? null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AssetsModule } from '../assets/assets.module';
|
||||
import { WorkOrdersModule } from '../work-orders/work-orders.module';
|
||||
import { RequestsController } from './requests.controller';
|
||||
import { RequestsService } from './requests.service';
|
||||
|
||||
@Module({
|
||||
imports: [WorkOrdersModule],
|
||||
imports: [WorkOrdersModule, AssetsModule],
|
||||
controllers: [RequestsController],
|
||||
providers: [RequestsService],
|
||||
})
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
import type { AuthenticatedUser } from '../auth/current-user.decorator';
|
||||
import { PermissionsService } from '../permissions/permissions.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AssetsService } from '../assets/assets.service';
|
||||
import { WorkOrdersService } from '../work-orders/work-orders.service';
|
||||
|
||||
const requestInclude = {
|
||||
@@ -32,6 +33,7 @@ export class RequestsService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissions: PermissionsService,
|
||||
private readonly workOrders: WorkOrdersService,
|
||||
private readonly assets: AssetsService,
|
||||
) {}
|
||||
|
||||
private async scope(user: AuthenticatedUser): Promise<Prisma.RequestWhereInput> {
|
||||
@@ -52,6 +54,13 @@ export class RequestsService {
|
||||
async create(dto: RequestCreate, user: AuthenticatedUser): Promise<RequestSummary> {
|
||||
const asset = await this.prisma.asset.findUnique({ where: { id: dto.assetId } });
|
||||
if (!asset) throw new BadRequestException('Équipement inconnu');
|
||||
// Défense en profondeur (R6.6) : même filtre que /assets/options, pour
|
||||
// qu'un Demandeur affecté à un site ne puisse pas contourner la liste
|
||||
// en soumettant directement un assetId hors périmètre.
|
||||
const allowed = await this.assets.allowedLocationIds(user);
|
||||
if (allowed && !allowed.includes(asset.locationId)) {
|
||||
throw new BadRequestException("Cet équipement n'est pas dans votre périmètre");
|
||||
}
|
||||
for (let essai = 0; ; essai++) {
|
||||
try {
|
||||
const created = await this.prisma.request.create({
|
||||
@@ -131,14 +140,9 @@ export class RequestsService {
|
||||
}
|
||||
|
||||
private async nextReference(): Promise<string> {
|
||||
const annee = new Date().getFullYear();
|
||||
const dernier = await this.prisma.request.findFirst({
|
||||
where: { reference: { startsWith: `DEM-${annee}-` } },
|
||||
orderBy: { reference: 'desc' },
|
||||
select: { reference: true },
|
||||
});
|
||||
const n = dernier ? Number(dernier.reference.split('-')[2]) + 1 : 1;
|
||||
return `DEM-${annee}-${String(n).padStart(4, '0')}`;
|
||||
const [{ nextval }] = await this.prisma.$queryRaw<[{ nextval: bigint }]>`
|
||||
SELECT nextval('request_ref_seq')`;
|
||||
return `DEM-${new Date().getFullYear()}-${String(nextval).padStart(4, '0')}`;
|
||||
}
|
||||
|
||||
private requesterLabel(row: RequestRow): string {
|
||||
|
||||
15
apps/api/src/search/search.controller.ts
Normal file
15
apps/api/src/search/search.controller.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { AuthenticatedUser, CurrentUser } from '../auth/current-user.decorator';
|
||||
import { SearchService } from './search.service';
|
||||
|
||||
/** Authentification seule (pas de @RequirePermission) : chaque famille de
|
||||
* résultats est filtrée par la matrice DANS le service. */
|
||||
@Controller('search')
|
||||
export class SearchController {
|
||||
constructor(private readonly search: SearchService) {}
|
||||
|
||||
@Get()
|
||||
global(@CurrentUser() user: AuthenticatedUser, @Query('q') q = '') {
|
||||
return this.search.search(user, q);
|
||||
}
|
||||
}
|
||||
9
apps/api/src/search/search.module.ts
Normal file
9
apps/api/src/search/search.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SearchController } from './search.controller';
|
||||
import { SearchService } from './search.service';
|
||||
|
||||
@Module({
|
||||
controllers: [SearchController],
|
||||
providers: [SearchService],
|
||||
})
|
||||
export class SearchModule {}
|
||||
105
apps/api/src/search/search.service.ts
Normal file
105
apps/api/src/search/search.service.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import { SEARCH_MAX_PER_KIND, SEARCH_MIN_CHARS, type SearchResponse } from '@siop/shared';
|
||||
import type { AuthenticatedUser } from '../auth/current-user.decorator';
|
||||
import { PermissionsService } from '../permissions/permissions.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
/** Recherche globale de la topbar (⌘K). Chaque famille n'est interrogée que
|
||||
* si le rôle a la permission `view` du domaine ; les OT respectent en plus
|
||||
* l'invariant « voir autre » (même règle que la liste des OT). */
|
||||
@Injectable()
|
||||
export class SearchService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissions: PermissionsService,
|
||||
) {}
|
||||
|
||||
async search(user: AuthenticatedUser, q: string): Promise<SearchResponse> {
|
||||
const terme = q.trim();
|
||||
const vide: SearchResponse = { workOrders: [], assets: [], sites: [] };
|
||||
if (terme.length < SEARCH_MIN_CHARS) return vide;
|
||||
|
||||
const [voitOT, voitParc, voitSites] = await Promise.all([
|
||||
this.permissions.can(user.roleId, 'WORK_ORDERS', 'view'),
|
||||
this.permissions.can(user.roleId, 'ASSETS', 'view'),
|
||||
this.permissions.can(user.roleId, 'LOCATIONS', 'view'),
|
||||
]);
|
||||
const contient = (champ: string): Prisma.StringFilter => ({
|
||||
contains: champ,
|
||||
mode: 'insensitive',
|
||||
});
|
||||
|
||||
const [workOrders, assets, sites] = await Promise.all([
|
||||
voitOT
|
||||
? this.prisma.workOrder.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
{ OR: [{ reference: contient(terme) }, { title: contient(terme) }] },
|
||||
await this.scopeOT(user),
|
||||
],
|
||||
},
|
||||
select: { id: true, reference: true, title: true, status: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: SEARCH_MAX_PER_KIND,
|
||||
})
|
||||
: [],
|
||||
voitParc
|
||||
? this.prisma.asset.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ reference: contient(terme) },
|
||||
{ brand: contient(terme) },
|
||||
{ model: contient(terme) },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
reference: true,
|
||||
brand: true,
|
||||
model: true,
|
||||
location: { select: { name: true, parent: { select: { name: true } } } },
|
||||
},
|
||||
orderBy: { reference: 'asc' },
|
||||
take: SEARCH_MAX_PER_KIND,
|
||||
})
|
||||
: [],
|
||||
voitSites
|
||||
? this.prisma.location.findMany({
|
||||
where: {
|
||||
parentId: null,
|
||||
OR: [{ name: contient(terme) }, { city: contient(terme) }],
|
||||
},
|
||||
select: { id: true, name: true, city: true },
|
||||
orderBy: { name: 'asc' },
|
||||
take: SEARCH_MAX_PER_KIND,
|
||||
})
|
||||
: [],
|
||||
]);
|
||||
|
||||
return {
|
||||
workOrders,
|
||||
assets: assets.map((a) => ({
|
||||
id: a.id,
|
||||
reference: a.reference,
|
||||
brand: a.brand,
|
||||
model: a.model,
|
||||
siteName: a.location.parent?.name ?? a.location.name,
|
||||
})),
|
||||
sites,
|
||||
};
|
||||
}
|
||||
|
||||
/** Même invariant que WorkOrdersService.scope — sans le droit « voir
|
||||
* autre », on ne trouve que SES OT. */
|
||||
private async scopeOT(user: AuthenticatedUser): Promise<Prisma.WorkOrderWhereInput> {
|
||||
const viewOther = await this.permissions.can(user.roleId, 'WORK_ORDERS', 'viewOther');
|
||||
if (viewOther) return {};
|
||||
return {
|
||||
OR: [
|
||||
{ assignees: { some: { id: user.userId } } },
|
||||
{ createdById: user.userId },
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
@@ -20,6 +21,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
const userInclude = {
|
||||
role: true,
|
||||
teams: { orderBy: { name: 'asc' } },
|
||||
assignedSites: { orderBy: { name: 'asc' } },
|
||||
} satisfies Prisma.UserInclude;
|
||||
|
||||
type UserRow = Prisma.UserGetPayload<{ include: typeof userInclude }>;
|
||||
@@ -46,6 +48,7 @@ export class UsersService {
|
||||
async invite(dto: InvitationCreate): Promise<InvitationResponse> {
|
||||
const role = await this.prisma.role.findUnique({ where: { id: dto.roleId } });
|
||||
if (!role) throw new NotFoundException('Rôle inconnu');
|
||||
if (dto.locationIds?.length) await this.assertTopLevelSites(dto.locationIds);
|
||||
try {
|
||||
const user = await this.prisma.user.create({
|
||||
data: {
|
||||
@@ -56,6 +59,9 @@ export class UsersService {
|
||||
teams: dto.teamIds?.length
|
||||
? { connect: dto.teamIds.map((id) => ({ id })) }
|
||||
: undefined,
|
||||
assignedSites: dto.locationIds?.length
|
||||
? { connect: dto.locationIds.map((id) => ({ id })) }
|
||||
: undefined,
|
||||
...this.freshToken(),
|
||||
},
|
||||
});
|
||||
@@ -90,6 +96,7 @@ export class UsersService {
|
||||
}
|
||||
|
||||
async update(userId: string, dto: UserUpdate): Promise<UserAdmin> {
|
||||
if (dto.locationIds) await this.assertTopLevelSites(dto.locationIds);
|
||||
try {
|
||||
const updated = await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
@@ -98,9 +105,13 @@ export class UsersService {
|
||||
phone: dto.phone,
|
||||
roleId: dto.roleId,
|
||||
isActive: dto.isActive,
|
||||
hourlyRate: dto.hourlyRate,
|
||||
teams: dto.teamIds
|
||||
? { set: dto.teamIds.map((id) => ({ id })) }
|
||||
: undefined,
|
||||
assignedSites: dto.locationIds
|
||||
? { set: dto.locationIds.map((id) => ({ id })) }
|
||||
: undefined,
|
||||
},
|
||||
include: userInclude,
|
||||
});
|
||||
@@ -113,6 +124,16 @@ export class UsersService {
|
||||
}
|
||||
}
|
||||
|
||||
/** R6.6 : un Demandeur n'est affecté qu'à des sites racine, jamais des zones
|
||||
* — même invariant que la hiérarchie site/zone (LocationsService.assertDepth). */
|
||||
private async assertTopLevelSites(ids: string[]): Promise<void> {
|
||||
const sites = await this.prisma.location.findMany({ where: { id: { in: ids } } });
|
||||
if (sites.length !== ids.length) throw new BadRequestException('Site inconnu');
|
||||
if (sites.some((s) => s.parentId)) {
|
||||
throw new BadRequestException("L'affectation d'un Demandeur se fait à un site, pas à une zone");
|
||||
}
|
||||
}
|
||||
|
||||
private freshToken() {
|
||||
return {
|
||||
activationToken: randomBytes(32).toString('base64url'),
|
||||
@@ -136,6 +157,8 @@ export class UsersService {
|
||||
? 'active'
|
||||
: 'invited',
|
||||
isDemo: row.isDemo,
|
||||
hourlyRate: row.hourlyRate === null ? null : Number(row.hourlyRate),
|
||||
assignedSites: row.assignedSites.map((s) => ({ id: s.id, name: s.name })),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,12 +13,16 @@ import {
|
||||
AssigneesUpdateSchema,
|
||||
ChecklistPatchSchema,
|
||||
CommentCreateSchema,
|
||||
ConsumePartSchema,
|
||||
LaborTimeCreateSchema,
|
||||
ReportUpsertSchema,
|
||||
TransitionRequestSchema,
|
||||
WorkOrderCreateSchema,
|
||||
type AssigneesUpdate,
|
||||
type ChecklistPatch,
|
||||
type CommentCreate,
|
||||
type ConsumePart,
|
||||
type LaborTimeCreate,
|
||||
type ReportUpsert,
|
||||
type TransitionRequest,
|
||||
type WorkOrderCreate,
|
||||
@@ -97,6 +101,26 @@ export class WorkOrdersController {
|
||||
return this.workOrders.upsertReport(id, body, user);
|
||||
}
|
||||
|
||||
@Post(':id/consume-part')
|
||||
@RequirePermission('WORK_ORDERS', 'edit')
|
||||
consumePart(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body(new ZodValidationPipe(ConsumePartSchema)) body: ConsumePart,
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
) {
|
||||
return this.workOrders.consumePart(id, body, user);
|
||||
}
|
||||
|
||||
@Post(':id/labor')
|
||||
@RequirePermission('WORK_ORDERS', 'edit')
|
||||
addLabor(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body(new ZodValidationPipe(LaborTimeCreateSchema)) body: LaborTimeCreate,
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
) {
|
||||
return this.workOrders.addLabor(id, body, user);
|
||||
}
|
||||
|
||||
@Patch(':id/checklist/:itemId')
|
||||
@RequirePermission('WORK_ORDERS', 'edit')
|
||||
patchChecklist(
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PartsModule } from '../parts/parts.module';
|
||||
import { WorkOrdersController } from './work-orders.controller';
|
||||
import { WorkOrdersService } from './work-orders.service';
|
||||
|
||||
@Module({
|
||||
imports: [PartsModule],
|
||||
controllers: [WorkOrdersController],
|
||||
providers: [WorkOrdersService],
|
||||
exports: [WorkOrdersService],
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
type WorkOrderSummary,
|
||||
} from '@siop/shared';
|
||||
import type { AuthenticatedUser } from '../auth/current-user.decorator';
|
||||
import { PartsService } from '../parts/parts.service';
|
||||
import { PermissionsService } from '../permissions/permissions.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@@ -33,6 +34,13 @@ const detailInclude = {
|
||||
request: { include: { requestedBy: true } },
|
||||
events: { include: { by: true }, orderBy: { createdAt: 'desc' as const } },
|
||||
checklist: { include: { doneBy: true }, orderBy: { label: 'asc' as const } },
|
||||
// R3 — coûts figés
|
||||
stockMovements: {
|
||||
where: { kind: 'CONSUMPTION' as const },
|
||||
include: { part: true },
|
||||
orderBy: { createdAt: 'asc' as const },
|
||||
},
|
||||
laborTimes: { include: { user: true }, orderBy: { createdAt: 'asc' as const } },
|
||||
report: {
|
||||
include: {
|
||||
doorState: true,
|
||||
@@ -71,6 +79,7 @@ export class WorkOrdersService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissions: PermissionsService,
|
||||
private readonly parts: PartsService,
|
||||
) {}
|
||||
|
||||
/** Invariant « voir autre » : sans le droit, on ne voit que SES OT. */
|
||||
@@ -177,14 +186,10 @@ export class WorkOrdersService {
|
||||
}
|
||||
|
||||
private async nextReference(): Promise<string> {
|
||||
const annee = new Date().getFullYear();
|
||||
const dernier = await this.prisma.workOrder.findFirst({
|
||||
where: { reference: { startsWith: `OT-${annee}-` } },
|
||||
orderBy: { reference: 'desc' },
|
||||
select: { reference: true },
|
||||
});
|
||||
const n = dernier ? Number(dernier.reference.split('-')[2]) + 1 : 1;
|
||||
return `OT-${annee}-${String(n).padStart(4, '0')}`;
|
||||
// Séquence Postgres : insensible à la concurrence (jamais de collision)
|
||||
const [{ nextval }] = await this.prisma.$queryRaw<[{ nextval: bigint }]>`
|
||||
SELECT nextval('work_order_ref_seq')`;
|
||||
return `OT-${new Date().getFullYear()}-${String(nextval).padStart(4, '0')}`;
|
||||
}
|
||||
|
||||
/** Machine à états stricte + garde de clôture. */
|
||||
@@ -194,6 +199,7 @@ export class WorkOrdersService {
|
||||
user: AuthenticatedUser,
|
||||
): Promise<WorkOrderDetail> {
|
||||
const detail = await this.get(id, user);
|
||||
await this.assertVersion(id, dto.baseUpdatedAt);
|
||||
if (!WORK_ORDER_TRANSITIONS[detail.status].includes(dto.to)) {
|
||||
throw new ConflictException(
|
||||
`Transition interdite : ${WORK_ORDER_STATUS_LABELS[detail.status]} → ${WORK_ORDER_STATUS_LABELS[dto.to]}`,
|
||||
@@ -240,6 +246,7 @@ export class WorkOrdersService {
|
||||
await this.prisma.workOrderEvent.create({
|
||||
data: { workOrderId: id, kind: 'COMMENT', message, byId: user.userId },
|
||||
});
|
||||
await this.toucher(id);
|
||||
return this.get(id, user);
|
||||
}
|
||||
|
||||
@@ -279,6 +286,7 @@ export class WorkOrdersService {
|
||||
user: AuthenticatedUser,
|
||||
): Promise<WorkOrderDetail> {
|
||||
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)
|
||||
for (const [colonne, field] of Object.entries(REPORT_FIELDS)) {
|
||||
const valeur = dto[colonne as keyof ReportUpsert];
|
||||
@@ -305,6 +313,7 @@ export class WorkOrdersService {
|
||||
update: donnees,
|
||||
create: { workOrderId: id, ...donnees },
|
||||
});
|
||||
await this.toucher(id);
|
||||
return this.get(id, user);
|
||||
}
|
||||
|
||||
@@ -315,6 +324,7 @@ export class WorkOrdersService {
|
||||
user: AuthenticatedUser,
|
||||
): Promise<ChecklistItemDto> {
|
||||
await this.get(id, user);
|
||||
await this.assertVersion(id, dto.baseUpdatedAt);
|
||||
const { count } = await this.prisma.checklistItem.updateMany({
|
||||
where: { id: itemId, workOrderId: id },
|
||||
data: {
|
||||
@@ -324,6 +334,7 @@ export class WorkOrdersService {
|
||||
},
|
||||
});
|
||||
if (count === 0) throw new NotFoundException('Tâche inconnue');
|
||||
await this.toucher(id);
|
||||
const item = await this.prisma.checklistItem.findUniqueOrThrow({
|
||||
where: { id: itemId },
|
||||
include: { doneBy: true },
|
||||
@@ -337,6 +348,67 @@ export class WorkOrdersService {
|
||||
};
|
||||
}
|
||||
|
||||
// ————— R3 : coûts figés —————
|
||||
|
||||
/** Consommer une pièce : stock décrémenté, PRIX FIGÉ au moment T. */
|
||||
async consumePart(
|
||||
id: string,
|
||||
dto: { partId: string; quantity: number },
|
||||
user: AuthenticatedUser,
|
||||
): Promise<WorkOrderDetail> {
|
||||
const detail = await this.get(id, user); // périmètre + existence
|
||||
if (detail.status === 'DONE' || detail.status === 'CANCELLED') {
|
||||
throw new ConflictException('OT terminé : les coûts sont figés');
|
||||
}
|
||||
const { designation, unitPrice } = await this.parts.consume(
|
||||
dto.partId,
|
||||
dto.quantity,
|
||||
id,
|
||||
user,
|
||||
);
|
||||
await this.prisma.workOrderEvent.create({
|
||||
data: {
|
||||
workOrderId: id,
|
||||
kind: 'PART_CONSUMED',
|
||||
message: `${designation} × ${dto.quantity} (${unitPrice} MAD/u, prix figé)`,
|
||||
byId: user.userId,
|
||||
},
|
||||
});
|
||||
await this.toucher(id);
|
||||
return this.get(id, user);
|
||||
}
|
||||
|
||||
/** Saisir de la main-d'œuvre : TAUX FIGÉ à la saisie. */
|
||||
async addLabor(
|
||||
id: string,
|
||||
dto: { minutes: number; userId?: string; note?: string },
|
||||
user: AuthenticatedUser,
|
||||
): Promise<WorkOrderDetail> {
|
||||
const detail = await this.get(id, user);
|
||||
if (detail.status === 'DONE' || detail.status === 'CANCELLED') {
|
||||
throw new ConflictException('OT terminé : les coûts sont figés');
|
||||
}
|
||||
const cibleId = dto.userId ?? user.userId;
|
||||
const cible = await this.prisma.user.findUnique({ where: { id: cibleId } });
|
||||
if (!cible) throw new BadRequestException('Personne inconnue');
|
||||
if (cible.hourlyRate === null) {
|
||||
throw new ConflictException(
|
||||
`Aucun taux horaire défini pour ${cible.displayName} — à renseigner dans Personnes`,
|
||||
);
|
||||
}
|
||||
await this.prisma.laborTime.create({
|
||||
data: {
|
||||
workOrderId: id,
|
||||
userId: cibleId,
|
||||
minutes: dto.minutes,
|
||||
hourlyRate: cible.hourlyRate, // figé
|
||||
note: dto.note,
|
||||
},
|
||||
});
|
||||
await this.toucher(id);
|
||||
return this.get(id, user);
|
||||
}
|
||||
|
||||
// ————— mapping —————
|
||||
|
||||
private toSummary(
|
||||
@@ -387,6 +459,22 @@ export class WorkOrdersService {
|
||||
}
|
||||
const versValeur = (v: { id: string; label: string } | null) =>
|
||||
v ? { id: v.id, label: v.label } : null;
|
||||
// Coûts figés : chaque ligne porte le prix/taux du moment T
|
||||
const partsCosts = row.stockMovements.map((m) => ({
|
||||
id: m.id,
|
||||
designation: m.part.designation,
|
||||
quantity: -m.quantity,
|
||||
unitPrice: Number(m.unitPrice ?? 0),
|
||||
total: -m.quantity * Number(m.unitPrice ?? 0),
|
||||
}));
|
||||
const laborCosts = row.laborTimes.map((l) => ({
|
||||
id: l.id,
|
||||
displayName: l.user.displayName,
|
||||
minutes: l.minutes,
|
||||
hourlyRate: Number(l.hourlyRate),
|
||||
total: Math.round((l.minutes / 60) * Number(l.hourlyRate) * 100) / 100,
|
||||
note: l.note,
|
||||
}));
|
||||
return {
|
||||
...this.toSummary(row),
|
||||
description: row.description,
|
||||
@@ -430,8 +518,55 @@ export class WorkOrdersService {
|
||||
componentConcerned: versValeur(report.componentConcerned),
|
||||
}
|
||||
: null,
|
||||
costs: {
|
||||
parts: partsCosts,
|
||||
labor: laborCosts,
|
||||
total:
|
||||
Math.round(
|
||||
(partsCosts.reduce((s, p) => s + p.total, 0) +
|
||||
laborCosts.reduce((s, l) => s + l.total, 0)) *
|
||||
100,
|
||||
) / 100,
|
||||
},
|
||||
allowedTransitions: WORK_ORDER_TRANSITIONS[row.status as WorkOrderStatus],
|
||||
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() } });
|
||||
}
|
||||
}
|
||||
|
||||
210
apps/api/test/assistant.e2e-spec.ts
Normal file
210
apps/api/test/assistant.e2e-spec.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
|
||||
const REPONSE_TRANSCRIRE = { texte: 'Porte cabine qui rebondit, cellule encrassée.' };
|
||||
|
||||
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 if (req.url === '/internal/transcrire') res.end(JSON.stringify(REPONSE_TRANSCRIRE));
|
||||
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('transcribe : dictée (D5) — texte transcrit, réservé à qui édite les OT', async () => {
|
||||
const res = await http()
|
||||
.post('/assistant/transcribe')
|
||||
.set(auth(ahmed))
|
||||
.attach('file', Buffer.from('faux-audio'), { filename: 'note.m4a', contentType: 'audio/m4a' })
|
||||
.expect(200);
|
||||
expect(res.body).toEqual({ text: 'Porte cabine qui rebondit, cellule encrassée.' });
|
||||
expect(requetesRecues.at(-1)!.url).toBe('/internal/transcrire');
|
||||
expect(requetesRecues.at(-1)!.jeton).toBe('jeton-de-test');
|
||||
// Karim (Demandeur) n'a pas WORK_ORDERS.edit
|
||||
await http()
|
||||
.post('/assistant/transcribe')
|
||||
.set(auth(karim))
|
||||
.attach('file', Buffer.from('faux-audio'), { filename: 'note.m4a', contentType: 'audio/m4a' })
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it('transcribe : refuse proprement sans fichier joint', async () => {
|
||||
await http().post('/assistant/transcribe').set(auth(ahmed)).expect(400);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
171
apps/api/test/documents-analytics.e2e-spec.ts
Normal file
171
apps/api/test/documents-analytics.e2e-spec.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* E2E R3.2 — bibliothèque (upload MinIO via FileStorage, download streamé,
|
||||
* refus typés, permission sur la cible) + analytics (dérivé du réel).
|
||||
* Nécessite MinIO (infra locale / service CI).
|
||||
*/
|
||||
process.env.DEMO_MODE = 'true';
|
||||
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import request from 'supertest';
|
||||
import { seed } from '../prisma/seed';
|
||||
import { AppModule } from '../src/app.module';
|
||||
|
||||
const PNG_1PX = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
|
||||
'base64',
|
||||
);
|
||||
|
||||
describe('Bibliothèque & analytics (e2e)', () => {
|
||||
let app: INestApplication;
|
||||
let nadia: string;
|
||||
let karim: string; // Demandeur : aucune permission d'édition
|
||||
const prisma = new PrismaClient();
|
||||
const http = () => request(app.getHttpServer());
|
||||
const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
|
||||
const suffix = `doc-${Date.now().toString(36)}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
await seed(prisma);
|
||||
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;
|
||||
};
|
||||
nadia = await login('Gestionnaire');
|
||||
karim = await login('Demandeur');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.document.deleteMany({ where: { fileName: { contains: suffix } } });
|
||||
await app?.close();
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
it('upload → liste filtrée → download identique → suppression', 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', 'CERTIFICATE')
|
||||
.field('assetId', a1.id)
|
||||
.attach('file', PNG_1PX, { filename: `certificat-${suffix}.png`, contentType: 'image/png' })
|
||||
.expect(201);
|
||||
expect(envoye.body.assetReference).toBe('A1');
|
||||
expect(envoye.body.size).toBe(PNG_1PX.length);
|
||||
|
||||
const liste = await http()
|
||||
.get(`/documents?assetId=${a1.id}&kind=CERTIFICATE`)
|
||||
.set(auth(nadia))
|
||||
.expect(200);
|
||||
expect(
|
||||
liste.body.documents.some((d: { fileName: string }) => d.fileName.includes(suffix)),
|
||||
).toBe(true);
|
||||
|
||||
const telecharge = await http()
|
||||
.get(`/documents/${envoye.body.id}/download`)
|
||||
.set(auth(nadia))
|
||||
.buffer(true)
|
||||
.parse((res, cb) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (c) => chunks.push(c));
|
||||
res.on('end', () => cb(null, Buffer.concat(chunks)));
|
||||
})
|
||||
.expect(200);
|
||||
expect(Buffer.compare(telecharge.body as Buffer, PNG_1PX)).toBe(0); // octets identiques
|
||||
|
||||
await http().delete(`/documents/${envoye.body.id}`).set(auth(nadia)).expect(204);
|
||||
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 () => {
|
||||
const { body: assets } = await http().get('/assets').set(auth(nadia));
|
||||
const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A1');
|
||||
|
||||
await http()
|
||||
.post('/documents')
|
||||
.set(auth(nadia))
|
||||
.field('kind', 'OTHER')
|
||||
.field('assetId', a1.id)
|
||||
.attach('file', Buffer.from('binaire'), { filename: `x-${suffix}.exe`, contentType: 'application/octet-stream' })
|
||||
.expect(400);
|
||||
await http()
|
||||
.post('/documents')
|
||||
.set(auth(nadia))
|
||||
.field('kind', 'PHOTO')
|
||||
.attach('file', PNG_1PX, { filename: `orphelin-${suffix}.png`, contentType: 'image/png' })
|
||||
.expect(400); // aucun rattachement
|
||||
await http()
|
||||
.post('/documents')
|
||||
.set(auth(nadia))
|
||||
.field('kind', 'PHOTO')
|
||||
.field('assetId', '00000000-0000-4000-8000-000000000000')
|
||||
.attach('file', PNG_1PX, { filename: `fantome-${suffix}.png`, contentType: 'image/png' })
|
||||
.expect(400);
|
||||
// Karim (Demandeur) : lecture de la bibliothèque OK, édition refusée
|
||||
await http().get('/documents').set(auth(karim)).expect(200);
|
||||
await http()
|
||||
.post('/documents')
|
||||
.set(auth(karim))
|
||||
.field('kind', 'PHOTO')
|
||||
.field('assetId', a1.id)
|
||||
.attach('file', PNG_1PX, { filename: `interdit-${suffix}.png`, contentType: 'image/png' })
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it('analytics : le tableau de la direction est dérivé du réel (bilans, coûts figés)', async () => {
|
||||
const res = await http().get('/analytics/summary').set(auth(nadia)).expect(200);
|
||||
const s = res.body;
|
||||
expect(s.costsByMonth).toHaveLength(12); // période par défaut
|
||||
// Le seed a consommé 325 MAD de pièces + 180 de MO sur OT-0341 ce mois-ci
|
||||
expect(s.monthCost).toBeGreaterThanOrEqual(505);
|
||||
// Pannes par organe : OT-0332 (seed) a un bilan « Guides »
|
||||
expect(
|
||||
s.failuresByComponent.some((f: { label: string }) => f.label === 'Guides'),
|
||||
).toBe(true);
|
||||
// Top équipements : A1 porte les coûts du seed
|
||||
expect(s.topAssets.some((t: { reference: string }) => t.reference === 'A1')).toBe(true);
|
||||
expect(s.closed.total).toBeGreaterThanOrEqual(1);
|
||||
// Karim (Demandeur, sans ANALYTICS) → 403
|
||||
await http().get('/analytics/summary').set(auth(karim)).expect(403);
|
||||
});
|
||||
});
|
||||
@@ -55,9 +55,10 @@ describe('Exploitation (e2e)', () => {
|
||||
const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
|
||||
|
||||
it('recette : demande → approbation → OT assigné → bilan → clôture → suivi', async () => {
|
||||
// 1. Karim (gardien) signale
|
||||
// 1. Karim (gardien) signale — sur A2, dans son site rattaché (Tour
|
||||
// Atlas, R6.6) : un Demandeur ne peut plus signaler hors périmètre.
|
||||
const { body: assets } = await http().get('/assets').set(auth(salma));
|
||||
const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A1');
|
||||
const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A2');
|
||||
const demande = await http()
|
||||
.post('/requests')
|
||||
.set(auth(karim))
|
||||
@@ -140,12 +141,20 @@ describe('Exploitation (e2e)', () => {
|
||||
.set(auth(salma))
|
||||
.expect(200);
|
||||
const position = listeSalma.workOrders.findIndex((w: { id: string }) => w.id === id);
|
||||
const urgencesActives = listeSalma.workOrders.filter(
|
||||
(w: { priority: string; status: string }) =>
|
||||
w.priority === 'PERSON_TRAPPED' && w.status !== 'DONE' && w.status !== 'CANCELLED',
|
||||
).length;
|
||||
expect(position).toBeGreaterThanOrEqual(0);
|
||||
expect(position).toBeLessThanOrEqual(urgencesActives);
|
||||
// Invariant DÉTERMINISTE (les specs parallèles créent des OT plus frais,
|
||||
// les positions absolues sont donc instables) : notre OT fraîchement
|
||||
// clôturé précède les OT du seed modifiés avant lui — avec l'ancien tri
|
||||
// « par statut », tout En cours passait devant tous les Terminés.
|
||||
const annee = new Date().getFullYear();
|
||||
const posGrilleSeed = listeSalma.workOrders.findIndex(
|
||||
(w: { reference: string }) => w.reference === `OT-${annee}-0338`, // En cours, seed
|
||||
);
|
||||
const posAncienDone = listeSalma.workOrders.findIndex(
|
||||
(w: { reference: string }) => w.reference === `OT-${annee}-0332`, // Terminé, seed
|
||||
);
|
||||
expect(position).toBeLessThan(posGrilleSeed);
|
||||
expect(position).toBeLessThan(posAncienDone);
|
||||
|
||||
// 7. Terminal : plus aucune transition
|
||||
await http()
|
||||
@@ -226,8 +235,9 @@ describe('Exploitation (e2e)', () => {
|
||||
});
|
||||
|
||||
it('rejet : motif obligatoire ; bilan : valeur hors champ refusée', async () => {
|
||||
// B1 : dans le site rattaché de Karim (Tour Atlas, R6.6).
|
||||
const { body: assets } = await http().get('/assets').set(auth(salma));
|
||||
const c1 = assets.assets.find((a: { reference: string }) => a.reference === 'C1');
|
||||
const c1 = assets.assets.find((a: { reference: string }) => a.reference === 'B1');
|
||||
const demande = await http()
|
||||
.post('/requests')
|
||||
.set(auth(karim))
|
||||
|
||||
351
apps/api/test/gestion.e2e-spec.ts
Normal file
351
apps/api/test/gestion.e2e-spec.ts
Normal file
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* E2E R3 — gestion : la recette officielle (consommer sous seuil → BC →
|
||||
* réception → réappro ; coût complet d'un OT) et les invariants comptables
|
||||
* (stock dérivé jamais négatif, prix et taux FIGÉS, ajustement motivé).
|
||||
*/
|
||||
process.env.DEMO_MODE = 'true';
|
||||
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import request from 'supertest';
|
||||
import { seed } from '../prisma/seed';
|
||||
import { AppModule } from '../src/app.module';
|
||||
|
||||
describe('Gestion (e2e)', () => {
|
||||
let app: INestApplication;
|
||||
let nadia: string; // Gestionnaire
|
||||
let salma: string; // Dispatcher
|
||||
let ahmed: string; // Technicien (PARTS view seulement)
|
||||
let admin: string;
|
||||
const prisma = new PrismaClient();
|
||||
const http = () => request(app.getHttpServer());
|
||||
const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
|
||||
const suffix = `G3-${Date.now().toString(36)}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
await seed(prisma);
|
||||
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;
|
||||
};
|
||||
nadia = await login('Gestionnaire');
|
||||
salma = await login('Dispatcher');
|
||||
ahmed = await login('Technicien');
|
||||
admin = await login('Administrateur');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.stockMovement.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ part: { designation: { contains: suffix } } },
|
||||
{ workOrder: { title: { contains: suffix } } },
|
||||
],
|
||||
},
|
||||
});
|
||||
await prisma.workOrder.deleteMany({ where: { title: { contains: suffix } } });
|
||||
await prisma.purchaseOrder.deleteMany({
|
||||
where: { lines: { some: { part: { designation: { contains: suffix } } } } },
|
||||
});
|
||||
await prisma.part.deleteMany({ where: { designation: { contains: suffix } } });
|
||||
await prisma.partner.deleteMany({ where: { name: { contains: suffix } } });
|
||||
await app?.close();
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
it('recette R3 : consommer sous seuil → BC → réception → réappro (prix figés)', async () => {
|
||||
// Pièce dédiée au test : stock 6, seuil 4, PU initial 100
|
||||
const piece = await http()
|
||||
.post('/parts')
|
||||
.set(auth(nadia))
|
||||
.send({ designation: `Pièce ${suffix}`, threshold: 4, initialPrice: 100 })
|
||||
.expect(201);
|
||||
await http()
|
||||
.post(`/parts/${piece.body.id}/movements`)
|
||||
.set(auth(nadia))
|
||||
.send({ kind: 'ENTRY', quantity: 6 })
|
||||
.expect(201);
|
||||
|
||||
// OT + consommation de 3 → stock 3, SOUS le seuil
|
||||
const { body: assets } = await http().get('/assets').set(auth(salma));
|
||||
const ot = await http()
|
||||
.post('/work-orders')
|
||||
.set(auth(salma))
|
||||
.send({
|
||||
title: `Réparation ${suffix}`,
|
||||
type: 'CORRECTIVE',
|
||||
assetId: assets.assets[0].id,
|
||||
})
|
||||
.expect(201);
|
||||
const apresConso = await http()
|
||||
.post(`/work-orders/${ot.body.id}/consume-part`)
|
||||
.set(auth(salma))
|
||||
.send({ partId: piece.body.id, quantity: 3 })
|
||||
.expect(201);
|
||||
expect(apresConso.body.costs.parts[0]).toMatchObject({
|
||||
quantity: 3,
|
||||
unitPrice: 100,
|
||||
total: 300,
|
||||
});
|
||||
const sousSeuil = await http().get(`/parts/${piece.body.id}`).set(auth(nadia));
|
||||
expect(sousSeuil.body.stock).toBe(3);
|
||||
expect(sousSeuil.body.belowThreshold).toBe(true);
|
||||
|
||||
// BC : brouillon → envoyé → reçu (PU 110) → stock 13, plus d'alerte
|
||||
const { body: partners } = await http().get('/partners').set(auth(nadia));
|
||||
const fournisseur = partners.partners.find((p: { kind: string }) => p.kind === 'SUPPLIER');
|
||||
const bc = await http()
|
||||
.post('/purchase-orders')
|
||||
.set(auth(nadia))
|
||||
.send({
|
||||
supplierId: fournisseur.id,
|
||||
lines: [{ partId: piece.body.id, quantity: 10, unitPrice: 110 }],
|
||||
})
|
||||
.expect(201);
|
||||
expect(bc.body.status).toBe('DRAFT');
|
||||
expect(bc.body.total).toBe(1100);
|
||||
await http()
|
||||
.post(`/purchase-orders/${bc.body.id}/transition`)
|
||||
.set(auth(nadia))
|
||||
.send({ to: 'SENT' })
|
||||
.expect(200);
|
||||
const recu = await http()
|
||||
.post(`/purchase-orders/${bc.body.id}/transition`)
|
||||
.set(auth(nadia))
|
||||
.send({ to: 'RECEIVED' })
|
||||
.expect(200);
|
||||
expect(recu.body.receivedAt).toBeTruthy();
|
||||
|
||||
const apresReception = await http().get(`/parts/${piece.body.id}`).set(auth(nadia));
|
||||
expect(apresReception.body.stock).toBe(13);
|
||||
expect(apresReception.body.belowThreshold).toBe(false);
|
||||
expect(apresReception.body.lastUnitPrice).toBe(110); // nouveau prix courant
|
||||
expect(
|
||||
apresReception.body.movements.some(
|
||||
(m: { kind: string; purchaseOrderReference: string | null }) =>
|
||||
m.kind === 'RECEIPT' && m.purchaseOrderReference === bc.body.reference,
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
// PRIX FIGÉ : la consommation passée reste à 100, malgré le nouveau PU 110
|
||||
const otRelu = await http().get(`/work-orders/${ot.body.id}`).set(auth(salma));
|
||||
expect(otRelu.body.costs.parts[0].unitPrice).toBe(100);
|
||||
});
|
||||
|
||||
it('coût complet d’un OT : pièces + main-d’œuvre — le TAUX est figé à la saisie', async () => {
|
||||
const { body: users } = await http().get('/users').set(auth(admin));
|
||||
const ahmedUser = users.users.find(
|
||||
(u: { email: string }) => u.email === 'technicien@demo.siop.ma',
|
||||
);
|
||||
const tauxInitial = ahmedUser.hourlyRate; // 120 (seed)
|
||||
const { body: assets } = await http().get('/assets').set(auth(salma));
|
||||
const ot = await http()
|
||||
.post('/work-orders')
|
||||
.set(auth(salma))
|
||||
.send({ title: `Coût complet ${suffix}`, type: 'CORRECTIVE', assetId: assets.assets[0].id })
|
||||
.expect(201);
|
||||
|
||||
// Salma (sans taux défini) tente de saisir pour elle-même → refus motivé
|
||||
await http()
|
||||
.post(`/work-orders/${ot.body.id}/labor`)
|
||||
.set(auth(salma))
|
||||
.send({ minutes: 30 })
|
||||
.expect(409);
|
||||
|
||||
// 1 h 30 d'Ahmed au taux courant
|
||||
const avecMO = await http()
|
||||
.post(`/work-orders/${ot.body.id}/labor`)
|
||||
.set(auth(salma))
|
||||
.send({ minutes: 90, userId: ahmedUser.id })
|
||||
.expect(201);
|
||||
expect(avecMO.body.costs.labor[0].hourlyRate).toBe(tauxInitial);
|
||||
expect(avecMO.body.costs.labor[0].total).toBe(
|
||||
Math.round((90 / 60) * tauxInitial * 100) / 100,
|
||||
);
|
||||
const totalAvant = avecMO.body.costs.total;
|
||||
|
||||
// L'admin augmente le taux courant d'Ahmed → le coût de l'OT NE BOUGE PAS
|
||||
await http()
|
||||
.patch(`/users/${ahmedUser.id}`)
|
||||
.set(auth(admin))
|
||||
.send({ hourlyRate: tauxInitial + 30 })
|
||||
.expect(200);
|
||||
const otApres = await http().get(`/work-orders/${ot.body.id}`).set(auth(salma));
|
||||
expect(otApres.body.costs.total).toBe(totalAvant);
|
||||
// remise en l'état (le seed ne réécrit pas les taux existants)
|
||||
await http()
|
||||
.patch(`/users/${ahmedUser.id}`)
|
||||
.set(auth(admin))
|
||||
.send({ hourlyRate: tauxInitial })
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
it('invariants : motif requis, stock jamais négatif, prix inconnu refusé, coûts figés après clôture', async () => {
|
||||
const piece = await http()
|
||||
.post('/parts')
|
||||
.set(auth(nadia))
|
||||
.send({ designation: `Sans prix ${suffix}`, threshold: 0 })
|
||||
.expect(201);
|
||||
|
||||
// ajustement sans motif → 400 ; stock négatif → 409
|
||||
await http()
|
||||
.post(`/parts/${piece.body.id}/movements`)
|
||||
.set(auth(nadia))
|
||||
.send({ kind: 'ADJUSTMENT', quantity: -1 })
|
||||
.expect(400);
|
||||
await http()
|
||||
.post(`/parts/${piece.body.id}/movements`)
|
||||
.set(auth(nadia))
|
||||
.send({ kind: 'ADJUSTMENT', quantity: -1, reason: 'test' })
|
||||
.expect(409);
|
||||
await http()
|
||||
.post(`/parts/${piece.body.id}/movements`)
|
||||
.set(auth(nadia))
|
||||
.send({ kind: 'ENTRY', quantity: -3 })
|
||||
.expect(400);
|
||||
|
||||
// consommation sans prix connu → 409 ; stock insuffisant → 409
|
||||
await http()
|
||||
.post(`/parts/${piece.body.id}/movements`)
|
||||
.set(auth(nadia))
|
||||
.send({ kind: 'ENTRY', quantity: 5 })
|
||||
.expect(201);
|
||||
const { body: assets } = await http().get('/assets').set(auth(salma));
|
||||
const ot = await http()
|
||||
.post('/work-orders')
|
||||
.set(auth(salma))
|
||||
.send({ title: `Invariants ${suffix}`, type: 'CORRECTIVE', assetId: assets.assets[0].id })
|
||||
.expect(201);
|
||||
await http()
|
||||
.post(`/work-orders/${ot.body.id}/consume-part`)
|
||||
.set(auth(salma))
|
||||
.send({ partId: piece.body.id, quantity: 2 })
|
||||
.expect(409); // pas de prix connu
|
||||
// Technicien : PARTS en lecture seule
|
||||
await http()
|
||||
.post('/parts')
|
||||
.set(auth(ahmed))
|
||||
.send({ designation: `Interdit ${suffix}` })
|
||||
.expect(403);
|
||||
// BC : réception d'un brouillon interdite
|
||||
const { body: partners } = await http().get('/partners').set(auth(nadia));
|
||||
const fournisseur = partners.partners.find((p: { kind: string }) => p.kind === 'SUPPLIER');
|
||||
const bc = await http()
|
||||
.post('/purchase-orders')
|
||||
.set(auth(nadia))
|
||||
.send({ supplierId: fournisseur.id, lines: [{ partId: piece.body.id, quantity: 1, unitPrice: 10 }] })
|
||||
.expect(201);
|
||||
await http()
|
||||
.post(`/purchase-orders/${bc.body.id}/transition`)
|
||||
.set(auth(nadia))
|
||||
.send({ to: 'RECEIVED' })
|
||||
.expect(409);
|
||||
await http()
|
||||
.post(`/purchase-orders/${bc.body.id}/transition`)
|
||||
.set(auth(nadia))
|
||||
.send({ to: 'CANCELLED' })
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
it('refus propres : doublons, inconnus, transitions interdites, coûts figés après clôture', async () => {
|
||||
const GHOST = '00000000-0000-4000-8000-000000000000';
|
||||
// tiers : doublon 409, 404, désactivation
|
||||
const tiers = await http()
|
||||
.post('/partners')
|
||||
.set(auth(nadia))
|
||||
.send({ name: `Tiers ${suffix}`, kind: 'SUPPLIER' })
|
||||
.expect(201);
|
||||
await http()
|
||||
.post('/partners')
|
||||
.set(auth(nadia))
|
||||
.send({ name: `Tiers ${suffix}`, kind: 'SUPPLIER' })
|
||||
.expect(409);
|
||||
await http()
|
||||
.patch(`/partners/${tiers.body.id}`)
|
||||
.set(auth(nadia))
|
||||
.send({ isActive: false })
|
||||
.expect(200);
|
||||
await http().patch(`/partners/${GHOST}`).set(auth(nadia)).send({ city: 'X' }).expect(404);
|
||||
// pièce : fournisseur inconnu 400, 404, désactivée non consommable
|
||||
await http()
|
||||
.post('/parts')
|
||||
.set(auth(nadia))
|
||||
.send({ designation: `Fournisseur fantôme ${suffix}`, supplierId: GHOST })
|
||||
.expect(400);
|
||||
await http().patch(`/parts/${GHOST}`).set(auth(nadia)).send({ threshold: 1 }).expect(404);
|
||||
await http().get(`/parts/${GHOST}`).set(auth(nadia)).expect(404);
|
||||
const desactivee = await http()
|
||||
.post('/parts')
|
||||
.set(auth(nadia))
|
||||
.send({ designation: `Désactivée ${suffix}`, initialPrice: 10 })
|
||||
.expect(201);
|
||||
await http()
|
||||
.patch(`/parts/${desactivee.body.id}`)
|
||||
.set(auth(nadia))
|
||||
.send({ isActive: false })
|
||||
.expect(200);
|
||||
const { body: assets } = await http().get('/assets').set(auth(salma));
|
||||
const ot = await http()
|
||||
.post('/work-orders')
|
||||
.set(auth(salma))
|
||||
.send({ title: `Refus ${suffix}`, type: 'CORRECTIVE', assetId: assets.assets[0].id })
|
||||
.expect(201);
|
||||
await http()
|
||||
.post(`/work-orders/${ot.body.id}/consume-part`)
|
||||
.set(auth(salma))
|
||||
.send({ partId: desactivee.body.id, quantity: 1 })
|
||||
.expect(400);
|
||||
// BC : fournisseur inactif 400, pièce inconnue 400, 404, transition sur terminal 409
|
||||
await http()
|
||||
.post('/purchase-orders')
|
||||
.set(auth(nadia))
|
||||
.send({ supplierId: tiers.body.id, lines: [{ partId: desactivee.body.id, quantity: 1, unitPrice: 5 }] })
|
||||
.expect(400); // désactivé plus haut
|
||||
await http()
|
||||
.post('/purchase-orders')
|
||||
.set(auth(nadia))
|
||||
.send({ supplierId: GHOST, lines: [{ partId: desactivee.body.id, quantity: 1, unitPrice: 5 }] })
|
||||
.expect(400);
|
||||
await http().get(`/purchase-orders/${GHOST}`).set(auth(nadia)).expect(404);
|
||||
// main-d'œuvre : personne inconnue 400 ; OT annulé → coûts figés 409
|
||||
await http()
|
||||
.post(`/work-orders/${ot.body.id}/labor`)
|
||||
.set(auth(salma))
|
||||
.send({ minutes: 10, userId: GHOST })
|
||||
.expect(400);
|
||||
await http()
|
||||
.post(`/work-orders/${ot.body.id}/transition`)
|
||||
.set(auth(salma))
|
||||
.send({ to: 'CANCELLED', comment: 'test' })
|
||||
.expect(200);
|
||||
await http()
|
||||
.post(`/work-orders/${ot.body.id}/consume-part`)
|
||||
.set(auth(salma))
|
||||
.send({ partId: desactivee.body.id, quantity: 1 })
|
||||
.expect(409);
|
||||
await http()
|
||||
.post(`/work-orders/${ot.body.id}/labor`)
|
||||
.set(auth(salma))
|
||||
.send({ minutes: 10 })
|
||||
.expect(409);
|
||||
});
|
||||
|
||||
it('le seed rejoue la carte maquette : OT-0341 coûte 505 MAD', async () => {
|
||||
const annee = new Date().getFullYear();
|
||||
const { body } = await http().get('/work-orders').set(auth(salma));
|
||||
const ot341 = body.workOrders.find(
|
||||
(w: { reference: string }) => w.reference === `OT-${annee}-0341`,
|
||||
);
|
||||
const detail = await http().get(`/work-orders/${ot341.id}`).set(auth(salma)).expect(200);
|
||||
expect(detail.body.costs.total).toBe(505); // 240 + 85 + 180
|
||||
expect(detail.body.costs.labor[0].hourlyRate).toBe(120);
|
||||
});
|
||||
});
|
||||
247
apps/api/test/recherche.e2e-spec.ts
Normal file
247
apps/api/test/recherche.e2e-spec.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* E2E R3 (recette) — recherche globale : chaque famille de résultats est
|
||||
* filtrée par la matrice, les OT respectent « voir autre » ; au passage,
|
||||
* rattachements Tiers (siteNames) et période analytics paramétrable.
|
||||
*/
|
||||
process.env.DEMO_MODE = 'true';
|
||||
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import request from 'supertest';
|
||||
import { seed } from '../prisma/seed';
|
||||
import { AppModule } from '../src/app.module';
|
||||
|
||||
describe('Recherche globale & corrections de recette R3 (e2e)', () => {
|
||||
let app: INestApplication;
|
||||
let yasmine: string; // Administrateur
|
||||
let salma: string; // Dispatcher (voir autre)
|
||||
let youssef: string; // Technicien limité : SES OT, ASSETS view, pas de LOCATIONS
|
||||
let karim: string; // Demandeur : aucune des trois familles
|
||||
let otId: string;
|
||||
const prisma = new PrismaClient();
|
||||
const http = () => request(app.getHttpServer());
|
||||
const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
|
||||
const suffix = `rech-${Date.now().toString(36)}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
await seed(prisma);
|
||||
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;
|
||||
};
|
||||
yasmine = await login('Administrateur');
|
||||
salma = await login('Dispatcher');
|
||||
youssef = await login('Technicien limité');
|
||||
karim = await login('Demandeur');
|
||||
|
||||
// Un OT au titre unique, assigné à Ahmed (PAS à Youssef).
|
||||
const { body: assets } = await http().get('/assets').set(auth(salma));
|
||||
const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A1');
|
||||
const ahmed = body.accounts.find((a: { roleName: string }) => a.roleName === 'Technicien');
|
||||
const cree = await http()
|
||||
.post('/work-orders')
|
||||
.set(auth(salma))
|
||||
.send({
|
||||
title: `Recherche E2E ${suffix}`,
|
||||
type: 'CORRECTIVE',
|
||||
priority: 'LOW',
|
||||
assetId: a1.id,
|
||||
assigneeIds: [ahmed.id],
|
||||
})
|
||||
.expect(201);
|
||||
otId = cree.body.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.workOrder.deleteMany({ where: { title: { contains: suffix } } });
|
||||
await app?.close();
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
it('dispatcher (voir autre) : OT par titre, appareil par référence, site insensible à la casse', async () => {
|
||||
const parTitre = await http().get(`/search?q=${suffix}`).set(auth(salma)).expect(200);
|
||||
expect(parTitre.body.workOrders.map((w: { id: string }) => w.id)).toContain(otId);
|
||||
|
||||
const parRef = await http().get('/search?q=A1').set(auth(salma)).expect(200);
|
||||
expect(parRef.body.assets.some((a: { reference: string }) => a.reference === 'A1')).toBe(true);
|
||||
expect(parRef.body.assets[0].siteName).toBeTruthy();
|
||||
|
||||
const parSite = await http().get('/search?q=atlas').set(auth(salma)).expect(200);
|
||||
expect(parSite.body.sites.some((s: { name: string }) => s.name === 'Tour Atlas')).toBe(true);
|
||||
});
|
||||
|
||||
it('technicien limité : ne trouve pas l’OT d’un autre, ni les sites ; le parc oui', async () => {
|
||||
const res = await http().get(`/search?q=${suffix}`).set(auth(youssef)).expect(200);
|
||||
expect(res.body.workOrders).toHaveLength(0); // pas « voir autre »
|
||||
|
||||
const parc = await http().get('/search?q=A1').set(auth(youssef)).expect(200);
|
||||
expect(parc.body.assets.length).toBeGreaterThan(0); // ASSETS view
|
||||
const sites = await http().get('/search?q=atlas').set(auth(youssef)).expect(200);
|
||||
expect(sites.body.sites).toHaveLength(0); // pas de LOCATIONS view
|
||||
});
|
||||
|
||||
it('demandeur : 200 mais aucune famille (aucune permission view)', async () => {
|
||||
const res = await http().get('/search?q=atlas').set(auth(karim)).expect(200);
|
||||
expect(res.body).toEqual({ workOrders: [], assets: [], sites: [] });
|
||||
});
|
||||
|
||||
it('moins de 2 caractères : réponse vide, pas de requête inutile', async () => {
|
||||
const res = await http().get('/search?q=a').set(auth(salma)).expect(200);
|
||||
expect(res.body).toEqual({ workOrders: [], assets: [], sites: [] });
|
||||
});
|
||||
|
||||
it('sans jeton : 401 (API fermée par défaut)', async () => {
|
||||
await http().get('/search?q=atlas').expect(401);
|
||||
});
|
||||
|
||||
it('tiers : les syndics portent leurs sites (« Rattachements » de la maquette)', async () => {
|
||||
const { body } = await http().get('/partners').set(auth(yasmine)).expect(200);
|
||||
const atlas = body.partners.find(
|
||||
(p: { name: string }) => p.name === 'Atlas Property Management',
|
||||
);
|
||||
expect(atlas.siteNames).toEqual(['Tour Atlas']);
|
||||
const fournisseur = body.partners.find((p: { name: string }) => p.name === 'Lubmaroc');
|
||||
expect(fournisseur.siteNames).toEqual([]);
|
||||
});
|
||||
|
||||
it('analytics : période 3/6/12 mois, 12 par défaut, valeur inconnue repliée sur 12', async () => {
|
||||
const trois = await http().get('/analytics/summary?months=3').set(auth(yasmine)).expect(200);
|
||||
expect(trois.body.months).toBe(3);
|
||||
expect(trois.body.costsByMonth).toHaveLength(3);
|
||||
|
||||
const defaut = await http().get('/analytics/summary').set(auth(yasmine)).expect(200);
|
||||
expect(defaut.body.months).toBe(12);
|
||||
expect(defaut.body.costsByMonth).toHaveLength(12);
|
||||
|
||||
const inconnu = await http().get('/analytics/summary?months=7').set(auth(yasmine)).expect(200);
|
||||
expect(inconnu.body.months).toBe(12);
|
||||
});
|
||||
|
||||
it('sites : le rattachement n’accepte qu’un tiers Client / syndic', async () => {
|
||||
const { body: partners } = await http().get('/partners').set(auth(yasmine));
|
||||
const fournisseur = partners.partners.find((p: { kind: string }) => p.kind === 'SUPPLIER');
|
||||
const { body: locations } = await http().get('/locations').set(auth(yasmine));
|
||||
const site = locations.locations.find(
|
||||
(l: { parentId: string | null; name: string }) => !l.parentId && l.name === 'Anfa Place',
|
||||
);
|
||||
await http()
|
||||
.patch(`/locations/${site.id}`)
|
||||
.set(auth(yasmine))
|
||||
.send({ partnerId: fournisseur.id })
|
||||
.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
|
||||
}
|
||||
}
|
||||
21
apps/mobile/.env
Normal file
21
apps/mobile/.env
Normal file
@@ -0,0 +1,21 @@
|
||||
# Depuis Expo SDK 57, le `fetch` global est `expo/fetch` (WinterCG) — qui ne
|
||||
# comprend pas le raccourci FormData natif `{uri, name, type}` utilisé pour
|
||||
# joindre un fichier au multipart (photos d'intervention, D5). Sans ce
|
||||
# réglage, l'upload échoue avec `Unsupported FormDataPart implementation`,
|
||||
# systématiquement classé à tort comme une coupure réseau (D1) et mis en
|
||||
# boucle de retry silencieuse — trouvé en recette terrain iOS (17/07/2026).
|
||||
# Voir ADR-005 et docs/journal/journal.md.
|
||||
EXPO_PUBLIC_USE_RN_FETCH=1
|
||||
|
||||
# Modules Expo/RN précompilés (XCFrameworks, SDK 56/57) incompatibles avec la
|
||||
# liaison statique de ce projet (pas de use_frameworks!) : sans ce réglage,
|
||||
# l'app compile mais CRASHE au lancement
|
||||
# (dyld: Library not loaded: @rpath/React.framework/React, signal 6).
|
||||
# Force la recompilation depuis les sources — voir ADR-005.
|
||||
EXPO_USE_PRECOMPILED_MODULES=0
|
||||
RCT_USE_PREBUILT_RNCORE=0
|
||||
|
||||
# Origine du web — R6.4, lien d'activation « Personnes » (/activation?token=…,
|
||||
# il n'existe pas d'équivalent mobile). À ajuster par environnement comme
|
||||
# EXPO_PUBLIC_API_URL (IP LAN ou domaine de prod).
|
||||
EXPO_PUBLIC_WEB_URL=http://localhost:5173
|
||||
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`).
|
||||
34
apps/mobile/app.json
Normal file
34
apps/mobile/app.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "SIOP",
|
||||
"slug": "siop2-mobile",
|
||||
"version": "0.1.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "com.anonymous.siop2-mobile"
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"backgroundColor": "#E6F4FE",
|
||||
"foregroundImage": "./assets/android-icon-foreground.png",
|
||||
"backgroundImage": "./assets/android-icon-background.png",
|
||||
"monochromeImage": "./assets/android-icon-monochrome.png"
|
||||
},
|
||||
"predictiveBackGestureEnabled": false,
|
||||
"package": "com.anonymous.siop2mobile"
|
||||
},
|
||||
"web": {
|
||||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
"expo-font",
|
||||
"expo-secure-store",
|
||||
"expo-audio"
|
||||
],
|
||||
"scheme": "siop"
|
||||
}
|
||||
}
|
||||
123
apps/mobile/app/(tabs)/_layout.tsx
Normal file
123
apps/mobile/app/(tabs)/_layout.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { Tabs } from 'expo-router';
|
||||
import { Text, type ColorValue } from 'react-native';
|
||||
import { useAssets, useReferenceValues } from '@/api/exploitation';
|
||||
import { useMe } from '@/auth/session';
|
||||
import { ongletsVisibles } from '@/auth/roles';
|
||||
import { usePermissions } from '@/auth/use-permissions';
|
||||
import { useFile } from '@/file/store';
|
||||
import { useTokens } from '@/theme/tokens';
|
||||
|
||||
/** Barre d'onglets ADAPTATIVE (maquette « mobile ouvert à tous les rôles »,
|
||||
* D1) : le Technicien/Technicien limité gardent exactement leurs 4 onglets
|
||||
* terrain R4, inchangés. Les autres rôles reçoivent Accueil/OT/Menu (ou une
|
||||
* variante plus étroite — Demandeur, Vue seule — selon leurs droits,
|
||||
* `ongletsVisibles`). Tous les écrans sont toujours déclarés : `href: null`
|
||||
* masque un onglet de la barre sans le retirer du navigateur (le Menu peut
|
||||
* toujours y pousser directement). */
|
||||
|
||||
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');
|
||||
const { data: me } = useMe();
|
||||
const { can } = usePermissions();
|
||||
const visibles = ongletsVisibles(me?.role.name);
|
||||
// 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.
|
||||
// Le Demandeur n'a pas ASSETS.view (seulement /assets/options) : inutile
|
||||
// d'appeler /assets pour lui, l'API répondrait 403.
|
||||
const peutAssets = can('ASSETS', 'view');
|
||||
useAssets({ enabled: peutAssets });
|
||||
useReferenceValues({ enabled: peutAssets });
|
||||
|
||||
const cacher = (nom: string) => (visibles.has(nom) ? undefined : null);
|
||||
|
||||
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={{
|
||||
href: cacher('journee'),
|
||||
title: 'Ma journée',
|
||||
tabBarIcon: ({ color }) => <Pic glyphe="☰" couleur={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="scanner"
|
||||
options={{
|
||||
href: cacher('scanner'),
|
||||
title: 'Scanner',
|
||||
tabBarIcon: ({ color }) => <Pic glyphe="▣" couleur={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="preventif"
|
||||
options={{
|
||||
href: cacher('preventif'),
|
||||
title: 'Préventif',
|
||||
tabBarIcon: ({ color }) => <Pic glyphe="✓" couleur={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="synchro"
|
||||
options={{
|
||||
href: cacher('synchro'),
|
||||
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.Screen
|
||||
name="accueil"
|
||||
options={{
|
||||
href: cacher('accueil'),
|
||||
title: 'Accueil',
|
||||
tabBarIcon: ({ color }) => <Pic glyphe="⌂" couleur={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="ot"
|
||||
options={{
|
||||
href: cacher('ot'),
|
||||
title: 'OT',
|
||||
tabBarIcon: ({ color }) => <Pic glyphe="☰" couleur={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="nouvelle-demande"
|
||||
options={{
|
||||
href: cacher('nouvelle-demande'),
|
||||
title: 'Nouvelle demande',
|
||||
tabBarIcon: ({ color }) => <Pic glyphe="+" couleur={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="menu"
|
||||
options={{
|
||||
href: cacher('menu'),
|
||||
title: 'Menu',
|
||||
tabBarIcon: ({ color }) => <Pic glyphe="☷" couleur={color} />,
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
179
apps/mobile/app/(tabs)/accueil.tsx
Normal file
179
apps/mobile/app/(tabs)/accueil.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import { router } from 'expo-router';
|
||||
import { Pressable, Text, View } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useRequests, useWorkOrders } from '@/api/exploitation';
|
||||
import { useMe } from '@/auth/session';
|
||||
import { usePermissions } from '@/auth/use-permissions';
|
||||
import { EnteteTabs } from '@/composants/ui';
|
||||
import { PanneauDemandes } from '@/composants/panneau-demandes';
|
||||
import { useTokens } from '@/theme/tokens';
|
||||
|
||||
/** Accueil — maquette « mobile ouvert à tous les rôles », écran 2 : le
|
||||
* Demandeur y voit directement ses demandes (son seul geste) ; les rôles
|
||||
* de gestion (Administrateur/Gestionnaire/Dispatcher/Vue seule) y trouvent
|
||||
* leurs signaux réels (urgence, OT en cours, demandes à traiter) — aucun
|
||||
* chiffre inventé : seuls WORK_ORDERS/REQUESTS sont déjà câblés sur mobile,
|
||||
* le reste (stock, préventif du mois…) attend son propre écran (Menu). */
|
||||
export default function PageAccueil() {
|
||||
const { data: me } = useMe();
|
||||
if (me?.role.name === 'Demandeur') {
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1 }} edges={['top']}>
|
||||
<View style={{ flex: 1, padding: 14, gap: 10 }}>
|
||||
<EnteteTabs />
|
||||
<PanneauDemandes />
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
return <TableauDeBordGestion />;
|
||||
}
|
||||
|
||||
function TableauDeBordGestion() {
|
||||
const t = useTokens();
|
||||
const { can } = usePermissions();
|
||||
const peutOT = can('WORK_ORDERS', 'view');
|
||||
const peutDemandes = can('REQUESTS', 'view');
|
||||
const { data: workOrders } = useWorkOrders();
|
||||
const { data: requests } = useRequests();
|
||||
|
||||
const ots = workOrders ?? [];
|
||||
const urgences = ots.filter((o) => o.priority === 'PERSON_TRAPPED' && o.status !== 'DONE' && o.status !== 'CANCELLED');
|
||||
const enCours = ots.filter((o) => o.status === 'OPEN' || o.status === 'IN_PROGRESS').length;
|
||||
const aTraiter = (requests ?? []).filter((r) => r.status === 'RECEIVED').length;
|
||||
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||
<View style={{ padding: 14, gap: 10, flex: 1 }}>
|
||||
<EnteteTabs />
|
||||
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
|
||||
Accueil
|
||||
</Text>
|
||||
|
||||
{urgences.length ? (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPress={() => router.push(`/ot/${urgences[0]!.id}`)}
|
||||
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>
|
||||
</Pressable>
|
||||
) : null}
|
||||
|
||||
{peutOT || peutDemandes ? (
|
||||
<View style={{ flexDirection: 'row', gap: 8 }}>
|
||||
{peutOT ? (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: t.surface,
|
||||
borderColor: t.bordure,
|
||||
borderWidth: 1,
|
||||
borderRadius: 10,
|
||||
padding: 10,
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.primaire }}>
|
||||
{enCours}
|
||||
</Text>
|
||||
<Text style={{ fontFamily: 'Manrope_600SemiBold', fontSize: 10.5, color: t.encre2 }}>
|
||||
OT en cours
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{peutDemandes ? (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: t.surface,
|
||||
borderColor: t.bordure,
|
||||
borderWidth: 1,
|
||||
borderRadius: 10,
|
||||
padding: 10,
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
|
||||
{aTraiter}
|
||||
</Text>
|
||||
<Text style={{ fontFamily: 'Manrope_600SemiBold', fontSize: 10.5, color: t.encre2 }}>
|
||||
Demandes reçues
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{peutDemandes && aTraiter > 0 ? (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPress={() => router.push('/demandes')}
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
backgroundColor: t.surface,
|
||||
borderColor: t.bordure,
|
||||
borderWidth: 1,
|
||||
borderRadius: 10,
|
||||
padding: 11,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 8,
|
||||
backgroundColor: t.primaireDoux,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 14, color: t.primaire }}>✎</Text>
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 12.5, color: t.encre }}>
|
||||
Demandes en attente
|
||||
</Text>
|
||||
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 11, color: t.encre2 }}>
|
||||
à approuver ou rejeter
|
||||
</Text>
|
||||
</View>
|
||||
<Text
|
||||
style={{
|
||||
fontFamily: 'Manrope_800ExtraBold',
|
||||
fontSize: 10.5,
|
||||
color: t.stAttente,
|
||||
backgroundColor: t.stAttenteFond,
|
||||
paddingHorizontal: 7,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 999,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{aTraiter}
|
||||
</Text>
|
||||
<Text style={{ color: t.encre3, fontSize: 13 }}>›</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
|
||||
{!peutOT && !peutDemandes ? (
|
||||
<Text style={{ color: t.encre3, fontFamily: 'Manrope_600SemiBold', textAlign: 'center', padding: 24 }}>
|
||||
Rien à afficher ici pour votre rôle — voir le Menu.
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
191
apps/mobile/app/(tabs)/journee.tsx
Normal file
191
apps/mobile/app/(tabs)/journee.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
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 } from '@/auth/session';
|
||||
import { EnteteTabs } from '@/composants/ui';
|
||||
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: workOrders, refetch, isFetching, dataUpdatedAt } = useWorkOrders();
|
||||
|
||||
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 }}>
|
||||
<EnteteTabs />
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
141
apps/mobile/app/(tabs)/menu.tsx
Normal file
141
apps/mobile/app/(tabs)/menu.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import { router } from 'expo-router';
|
||||
import { Pressable, ScrollView, Text, View } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import type { ObjectCategory, PermissionRight } from '@siop/shared';
|
||||
import { useMe } from '@/auth/session';
|
||||
import { estRoleTerrain } from '@/auth/roles';
|
||||
import { usePermissions } from '@/auth/use-permissions';
|
||||
import { EnteteTabs } from '@/composants/ui';
|
||||
import { useTokens, type Tokens } from '@/theme/tokens';
|
||||
|
||||
/** Menu — maquette « mobile ouvert à tous les rôles », écran 3 : reprend à
|
||||
* l'identique les 4 groupes et l'ordre de la sidebar web (Exploitation /
|
||||
* Parc / Ressources / Pilotage, coquille.tsx), filtrés par LA MÊME matrice
|
||||
* (D2 — aucune règle de droit nouvelle). Un groupe sans aucun lien visible
|
||||
* est masqué en entier (D3) : l'écran est petit, un intitulé vide n'aide
|
||||
* personne. Ce que « Ma journée »/« Préventif » couvrent déjà pour le
|
||||
* Technicien n'est pas dupliqué ici. */
|
||||
|
||||
interface LienMenu {
|
||||
libelle: string;
|
||||
permission: [ObjectCategory, PermissionRight];
|
||||
ico: string;
|
||||
cacheEnTerrain?: boolean; // déjà couvert par un onglet terrain dédié
|
||||
route?: string;
|
||||
aVenir?: string; // detail affiché sur l'écran « à venir »
|
||||
}
|
||||
|
||||
const GROUPES: { titre: string; liens: LienMenu[] }[] = [
|
||||
{
|
||||
titre: 'Exploitation',
|
||||
liens: [
|
||||
{ libelle: 'Ordres de travail', ico: '☰', permission: ['WORK_ORDERS', 'view'], cacheEnTerrain: true, route: '/(tabs)/ot' },
|
||||
{ libelle: 'Demandes', ico: '✎', permission: ['REQUESTS', 'view'], route: '/demandes' },
|
||||
{ libelle: 'Préventif', ico: '✓', permission: ['WORK_ORDERS', 'view'], cacheEnTerrain: true, aVenir: 'Les grilles préventives pour les rôles de gestion arrivent dans une prochaine étape — le Technicien les a déjà dans son onglet dédié.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
titre: 'Parc',
|
||||
liens: [
|
||||
{ libelle: 'Ascenseurs', ico: '▣', permission: ['ASSETS', 'view'], route: '/ascenseurs' },
|
||||
{ libelle: 'Sites', ico: '◫', permission: ['LOCATIONS', 'view'], route: '/sites' },
|
||||
{ libelle: 'Catégories', ico: '▤', permission: ['SETTINGS', 'view'], aVenir: 'L’administration des catégories reste sur le grand écran pour l’instant.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
titre: 'Ressources',
|
||||
liens: [
|
||||
{ libelle: 'Stock & achats', ico: '◔', permission: ['PARTS', 'view'], route: '/stock' },
|
||||
{ libelle: 'Tiers', ico: '⇄', permission: ['PURCHASE_ORDERS', 'view'], route: '/tiers' },
|
||||
{ libelle: 'Fichiers', ico: '▧', permission: ['ASSETS', 'view'], route: '/bibliotheque' },
|
||||
],
|
||||
},
|
||||
{
|
||||
titre: 'Pilotage',
|
||||
liens: [
|
||||
{ libelle: 'Statistiques', ico: '◈', permission: ['ANALYTICS', 'view'], route: '/statistiques' },
|
||||
{ libelle: 'Assistant', ico: '✦', permission: ['WORK_ORDERS', 'view'], route: '/assistant' },
|
||||
{ libelle: 'Personnes & équipes', ico: '◎', permission: ['PEOPLE_TEAMS', 'view'], route: '/personnes' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function PageMenu() {
|
||||
const t = useTokens();
|
||||
const { data: me } = useMe();
|
||||
const { can } = usePermissions();
|
||||
const terrain = estRoleTerrain(me?.role.name);
|
||||
|
||||
const groupesVisibles = GROUPES.map((g) => ({
|
||||
...g,
|
||||
liens: g.liens.filter(
|
||||
({ permission, cacheEnTerrain }) => can(...permission) && !(cacheEnTerrain && terrain),
|
||||
),
|
||||
})).filter((g) => g.liens.length > 0);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||
<View style={{ padding: 14, gap: 10, flex: 1 }}>
|
||||
<EnteteTabs />
|
||||
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>Menu</Text>
|
||||
<ScrollView contentContainerStyle={{ gap: 14, paddingBottom: 16 }} showsVerticalScrollIndicator={false}>
|
||||
{groupesVisibles.length === 0 ? (
|
||||
<Text style={{ color: t.encre3, fontFamily: 'Manrope_600SemiBold', textAlign: 'center', padding: 24 }}>
|
||||
Rien d'autre accessible à votre rôle ici.
|
||||
</Text>
|
||||
) : null}
|
||||
{groupesVisibles.map((g) => (
|
||||
<View key={g.titre} style={{ gap: 6 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontFamily: 'Manrope_800ExtraBold',
|
||||
fontSize: 10.5,
|
||||
letterSpacing: 0.8,
|
||||
textTransform: 'uppercase',
|
||||
color: t.encre3,
|
||||
}}
|
||||
>
|
||||
{g.titre}
|
||||
</Text>
|
||||
{g.liens.map((lien) => (
|
||||
<LigneMenu key={lien.libelle} lien={lien} t={t} />
|
||||
))}
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
function LigneMenu({ lien, t }: { lien: LienMenu; t: Tokens }) {
|
||||
const aller = () => {
|
||||
if (lien.route) {
|
||||
router.push(lien.route as never);
|
||||
} else {
|
||||
router.push({ pathname: '/a-venir', params: { titre: lien.libelle, detail: lien.aVenir ?? '' } });
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPress={aller}
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
backgroundColor: t.surface,
|
||||
borderColor: t.bordure,
|
||||
borderWidth: 1,
|
||||
borderRadius: 10,
|
||||
padding: 11,
|
||||
}}
|
||||
>
|
||||
<Text style={{ width: 22, textAlign: 'center', fontSize: 14, color: t.primaire }}>{lien.ico}</Text>
|
||||
<Text style={{ flex: 1, fontFamily: 'Manrope_700Bold', fontSize: 12.5, color: t.encre }}>
|
||||
{lien.libelle}
|
||||
</Text>
|
||||
<Text style={{ color: t.encre3, fontSize: 13 }}>›</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
24
apps/mobile/app/(tabs)/nouvelle-demande.tsx
Normal file
24
apps/mobile/app/(tabs)/nouvelle-demande.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { router } from 'expo-router';
|
||||
import { ScrollView, Text } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { FormulaireDemande } from '@/composants/formulaire-demande';
|
||||
import { EnteteTabs } from '@/composants/ui';
|
||||
import { useTokens } from '@/theme/tokens';
|
||||
|
||||
/** Onglet du Demandeur (maquette « mobile ouvert à tous les rôles »,
|
||||
* écran 5) : le geste qu'il fait le plus mérite son propre onglet plutôt
|
||||
* qu'un bouton caché dans l'Accueil. */
|
||||
export default function PageNouvelleDemande() {
|
||||
const t = useTokens();
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||
<ScrollView contentContainerStyle={{ padding: 14, gap: 10 }}>
|
||||
<EnteteTabs />
|
||||
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
|
||||
Nouvelle demande
|
||||
</Text>
|
||||
<FormulaireDemande surSucces={() => router.replace('/(tabs)/accueil')} />
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
154
apps/mobile/app/(tabs)/ot.tsx
Normal file
154
apps/mobile/app/(tabs)/ot.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
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 { useWorkOrders } from '@/api/exploitation';
|
||||
import { EnteteTabs } from '@/composants/ui';
|
||||
import { triJournee } from '@/lib/journee';
|
||||
import { useTokens, type Tokens } from '@/theme/tokens';
|
||||
|
||||
/** Ordres de travail — maquette « mobile ouvert à tous les rôles », écran 4 :
|
||||
* à la différence de « Ma journée » (Technicien, ses OT uniquement),
|
||||
* Administrateur/Gestionnaire/Dispatcher ont `viewOther` — l'API renvoie
|
||||
* déjà TOUS les OT (ADR-003, rien à filtrer ici). Même tri priorité puis
|
||||
* échéance que « Ma journée » ; ouvrir un OT réutilise la fiche R4 telle
|
||||
* quelle (elle affiche déjà les actions permises via `allowedTransitions`). */
|
||||
|
||||
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 PageOT() {
|
||||
const t = useTokens();
|
||||
const { data: workOrders, refetch, isFetching } = useWorkOrders();
|
||||
const ots = triJournee(workOrders ?? []);
|
||||
const urgences = ots.filter((o) => o.priority === 'PERSON_TRAPPED');
|
||||
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||
<View style={{ padding: 14, gap: 10, flex: 1 }}>
|
||||
<EnteteTabs />
|
||||
<View style={{ flexDirection: 'row', alignItems: 'baseline' }}>
|
||||
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
|
||||
Ordres de travail
|
||||
</Text>
|
||||
<Text style={{ marginLeft: 'auto', fontFamily: 'Manrope_600SemiBold', fontSize: 11, color: t.encre3 }}>
|
||||
{ots.length}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{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 — 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>
|
||||
);
|
||||
}
|
||||
77
apps/mobile/app/(tabs)/preventif.tsx
Normal file
77
apps/mobile/app/(tabs)/preventif.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
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, EnteteTabs } 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 }}>
|
||||
<EnteteTabs />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
159
apps/mobile/app/(tabs)/scanner.tsx
Normal file
159
apps/mobile/app/(tabs)/scanner.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
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, EnteteTabs } 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 }}>
|
||||
<EnteteTabs />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
183
apps/mobile/app/(tabs)/synchro.tsx
Normal file
183
apps/mobile/app/(tabs)/synchro.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
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, EnteteTabs } 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 }}>
|
||||
<EnteteTabs />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
21
apps/mobile/app/a-venir.tsx
Normal file
21
apps/mobile/app/a-venir.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { useLocalSearchParams } from 'expo-router';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { AVenir } from '@/composants/a-venir';
|
||||
import { EnteteFiche } from '@/composants/ui';
|
||||
import { useTokens } from '@/theme/tokens';
|
||||
|
||||
/** Destination générique du Menu pour les familles pas encore portées sur
|
||||
* mobile (Sites, Stock, Tiers, Personnes, Statistiques, Assistant…) — même
|
||||
* patron que la sidebar web (release annoncée, jamais un cul-de-sac
|
||||
* silencieux). `AVenir` existait déjà (a-venir.tsx) mais n'était encore
|
||||
* câblé nulle part. */
|
||||
export default function PageAVenir() {
|
||||
const t = useTokens();
|
||||
const { titre, detail } = useLocalSearchParams<{ titre: string; detail: string }>();
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||
<EnteteFiche titre={titre ?? '—'} />
|
||||
<AVenir titre={titre ?? '—'} release="une prochaine étape" detail={detail ?? ''} />
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
90
apps/mobile/app/ascenseurs/index.tsx
Normal file
90
apps/mobile/app/ascenseurs/index.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { router } from 'expo-router';
|
||||
import { FlatList, Pressable, Text, View } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { ASSET_STATUS_LABELS, type AssetStatus } from '@siop/shared';
|
||||
import { useAssets } from '@/api/exploitation';
|
||||
import { EnteteFiche } from '@/composants/ui';
|
||||
import { useTokens, type Tokens } from '@/theme/tokens';
|
||||
|
||||
/** Ascenseurs — R6.2 (Parc). Le parc complet, déjà préchargé (D1 lecture
|
||||
* locale) — même donnée que le Scanner, présentée en liste plutôt qu'en
|
||||
* résolution QR. Ouvre la fiche R4 telle quelle (écran 5). */
|
||||
export default function PageAscenseurs() {
|
||||
const t = useTokens();
|
||||
const { data: assets } = useAssets();
|
||||
const appareils = [...(assets ?? [])].sort((a, b) => a.reference.localeCompare(b.reference));
|
||||
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||
<View style={{ flex: 1, padding: 14, gap: 10 }}>
|
||||
<EnteteFiche titre="Ascenseurs" />
|
||||
<FlatList
|
||||
data={appareils}
|
||||
keyExtractor={(a) => a.id}
|
||||
contentContainerStyle={{ gap: 8, paddingBottom: 12 }}
|
||||
ListEmptyComponent={
|
||||
<Text style={{ color: t.encre3, fontFamily: 'Manrope_600SemiBold', padding: 16, textAlign: 'center' }}>
|
||||
Aucun appareil accessible.
|
||||
</Text>
|
||||
}
|
||||
renderItem={({ item: a }) => (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPress={() => router.push(`/ascenseur/${a.id}`)}
|
||||
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={{
|
||||
fontFamily: 'Manrope_700Bold',
|
||||
fontSize: 10.5,
|
||||
letterSpacing: 0.6,
|
||||
textTransform: 'uppercase',
|
||||
color: t.encre2,
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{a.reference}
|
||||
</Text>
|
||||
<ChipStatutAppareil statut={a.status} t={t} />
|
||||
</View>
|
||||
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 14, color: t.encre }}>
|
||||
{a.brand} {a.model ?? ''}
|
||||
</Text>
|
||||
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 12, color: t.encre2 }}>
|
||||
{a.siteName} — {a.locationName}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
function ChipStatutAppareil({ statut, t }: { statut: AssetStatus; t: Tokens }) {
|
||||
const enService = statut === 'IN_SERVICE';
|
||||
return (
|
||||
<Text
|
||||
style={{
|
||||
fontFamily: 'Manrope_700Bold',
|
||||
fontSize: 10.5,
|
||||
color: enService ? t.stTermine : t.stAttente,
|
||||
backgroundColor: enService ? t.stTermineFond : t.stAttenteFond,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 999,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{ASSET_STATUS_LABELS[statut]}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
380
apps/mobile/app/assistant/index.tsx
Normal file
380
apps/mobile/app/assistant/index.tsx
Normal file
@@ -0,0 +1,380 @@
|
||||
// SDK 57 : les fonctions module-level (dont deleteAsync) sont dépréciées au
|
||||
// profit des classes File/Directory et lèvent désormais en développement —
|
||||
// import explicite du sous-chemin legacy, comportement inchangé sinon.
|
||||
import * as FileSystem from 'expo-file-system/legacy';
|
||||
import { router } from 'expo-router';
|
||||
import { useRef, useState } from 'react';
|
||||
import { Pressable, ScrollView, Text, TextInput, View } from 'react-native';
|
||||
import {
|
||||
RecordingPresets,
|
||||
requestRecordingPermissionsAsync,
|
||||
setAudioModeAsync,
|
||||
useAudioRecorder,
|
||||
} from 'expo-audio';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import type { AssistantAnswer, AssistantExcerpt } from '@siop/shared';
|
||||
import { useAskAssistant } from '@/api/assistant';
|
||||
import { useTranscription } from '@/api/exploitation';
|
||||
import { BoutonTel, EnteteFiche } from '@/composants/ui';
|
||||
import { useTokens, type Tokens } from '@/theme/tokens';
|
||||
|
||||
/** Assistant — maquette « Assistant mobile » (validée 02/08) : même contenu
|
||||
* et mêmes règles que le web (D2 « sourcé ou silencieux », ADR-004 §4 —
|
||||
* le service IA n'est jamais appelé directement, toujours via l'API).
|
||||
* D5 : la question peut être tapée OU dictée — même pipeline que la
|
||||
* dictée déjà livrée en clôture (ADR-004 §5), la transcription REMPLIT le
|
||||
* champ (éditable), aucun envoi automatique. */
|
||||
|
||||
interface Echange {
|
||||
question: string;
|
||||
reponse?: AssistantAnswer;
|
||||
erreur?: string;
|
||||
}
|
||||
|
||||
export default function PageAssistant() {
|
||||
const t = useTokens();
|
||||
const ask = useAskAssistant();
|
||||
const transcrire = useTranscription();
|
||||
const recorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY);
|
||||
const [question, setQuestion] = useState('');
|
||||
const [echanges, setEchanges] = useState<Echange[]>([]);
|
||||
const [enregistrement, setEnregistrement] = useState(false);
|
||||
const saisieRef = useRef<TextInput>(null);
|
||||
|
||||
const demarrerDictee = async () => {
|
||||
const { granted } = await requestRecordingPermissionsAsync();
|
||||
if (!granted) return;
|
||||
// iOS refuse recorder.record() sans autorisation explicite de la session
|
||||
// audio (RecordingDisabledException) — même correctif qu'en clôture.
|
||||
await setAudioModeAsync({ allowsRecording: true, playsInSilentMode: true });
|
||||
await recorder.prepareToRecordAsync();
|
||||
recorder.record();
|
||||
setEnregistrement(true);
|
||||
};
|
||||
|
||||
const terminerDictee = async () => {
|
||||
await recorder.stop();
|
||||
await setAudioModeAsync({ allowsRecording: false });
|
||||
setEnregistrement(false);
|
||||
const uri = recorder.uri;
|
||||
if (!uri) return;
|
||||
transcrire.mutate(
|
||||
{ uri, nom: 'question.m4a', mime: 'audio/m4a' },
|
||||
{
|
||||
onSuccess: (resultat) => setQuestion(resultat.text),
|
||||
// Loi 09-08 (D5) : l'audio ne survit jamais à l'appel, succès ou pas.
|
||||
onSettled: () => void FileSystem.deleteAsync(uri, { idempotent: true }),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||
<View style={{ flex: 1, padding: 14, gap: 10 }}>
|
||||
<EnteteFiche titre="Assistant" />
|
||||
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ gap: 10, paddingBottom: 8 }}>
|
||||
{echanges.length === 0 ? (
|
||||
<Text style={{ color: t.encre2, fontFamily: 'Manrope_400Regular', fontSize: 12.5 }}>
|
||||
Posez une question sur vos notices, vos historiques d'intervention ou vos
|
||||
procédures — chaque réponse cite ses sources. Quand le corpus ne porte pas la
|
||||
réponse, l'assistant le dit au lieu d'inventer.
|
||||
</Text>
|
||||
) : null}
|
||||
{echanges.map((e, i) => (
|
||||
<View key={i} style={{ gap: 8 }}>
|
||||
<Text
|
||||
style={{
|
||||
alignSelf: 'flex-end',
|
||||
backgroundColor: t.primaire,
|
||||
color: '#fff',
|
||||
borderRadius: 13,
|
||||
borderBottomRightRadius: 3,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 9,
|
||||
maxWidth: '85%',
|
||||
fontFamily: 'Manrope_600SemiBold',
|
||||
fontSize: 12.5,
|
||||
}}
|
||||
>
|
||||
{e.question}
|
||||
</Text>
|
||||
{e.reponse ? (
|
||||
<Reponse reponse={e.reponse} t={t} surReformuler={() => saisieRef.current?.focus()} />
|
||||
) : e.erreur ? (
|
||||
<Text style={{ color: t.danger, fontFamily: 'Manrope_600SemiBold', fontSize: 12.5 }}>
|
||||
{e.erreur}
|
||||
</Text>
|
||||
) : (
|
||||
<Text style={{ color: t.encre3, fontFamily: 'Manrope_400Regular', fontSize: 12.5 }}>
|
||||
Recherche dans le corpus…
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
|
||||
{enregistrement ? (
|
||||
<View
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
borderWidth: 1.5,
|
||||
borderColor: t.bordure,
|
||||
borderRadius: 13,
|
||||
backgroundColor: t.surface,
|
||||
padding: 16,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
backgroundColor: t.prioBloque,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 18 }}>🎙</Text>
|
||||
</View>
|
||||
<Text style={{ color: t.encre2, fontFamily: 'Manrope_400Regular', fontSize: 11.5 }}>
|
||||
Enregistrement en cours…
|
||||
</Text>
|
||||
<BoutonTel
|
||||
libelle="■ Terminer la dictée"
|
||||
surAppui={() => void terminerDictee()}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<View
|
||||
style={{
|
||||
gap: 8,
|
||||
borderWidth: 1.5,
|
||||
borderColor: t.bordureForte,
|
||||
borderRadius: 10,
|
||||
backgroundColor: t.surface,
|
||||
padding: 10,
|
||||
}}
|
||||
>
|
||||
{/* Zone pleine largeur, multiligne : la question dictée doit se
|
||||
* lire EN ENTIER avant d'envoyer (trouvé en recette, 02/08 —
|
||||
* une barre d'une ligne masquait le texte transcrit long). */}
|
||||
<TextInput
|
||||
ref={saisieRef}
|
||||
accessibilityLabel="Poser une question"
|
||||
multiline
|
||||
scrollEnabled
|
||||
value={transcrire.isPending ? 'Transcription…' : question}
|
||||
editable={!transcrire.isPending}
|
||||
onChangeText={setQuestion}
|
||||
maxLength={500}
|
||||
placeholder="Poser une question, ou dictez avec 🎙…"
|
||||
placeholderTextColor={t.encre3}
|
||||
style={{
|
||||
// Hauteur FIXE plutôt que maxHeight seul : sur iOS, un
|
||||
// TextInput multiligne ignore parfois maxHeight et grandit
|
||||
// avec le contenu, poussant/recouvrant les boutons du
|
||||
// dessous (trouvé en recette, 02/08). Hauteur fixe +
|
||||
// scrollEnabled = défilement interne garanti au-delà.
|
||||
height: 96,
|
||||
color: t.encre,
|
||||
fontFamily: 'Manrope_600SemiBold',
|
||||
fontSize: 13.5,
|
||||
textAlignVertical: 'top',
|
||||
}}
|
||||
/>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Dicter la question"
|
||||
disabled={transcrire.isPending}
|
||||
onPress={() => void demarrerDictee()}
|
||||
style={{
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 15,
|
||||
backgroundColor: t.primaireDoux,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 14 }}>🎙</Text>
|
||||
</Pressable>
|
||||
<View style={{ flex: 1 }}>
|
||||
<BoutonTel
|
||||
libelle="Envoyer"
|
||||
desactive={question.trim().length < 3 || ask.isPending || transcrire.isPending}
|
||||
surAppui={envoyer}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
{transcrire.isError ? (
|
||||
<Text style={{ color: t.danger, fontFamily: 'Manrope_600SemiBold', fontSize: 11.5 }}>
|
||||
{transcrire.error.message}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
function Reponse({
|
||||
reponse,
|
||||
t,
|
||||
surReformuler,
|
||||
}: {
|
||||
reponse: AssistantAnswer;
|
||||
t: Tokens;
|
||||
surReformuler: () => void;
|
||||
}) {
|
||||
if (reponse.mode === 'REFUSAL') {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
borderWidth: 1.5,
|
||||
borderColor: t.bordureForte,
|
||||
borderStyle: 'dashed',
|
||||
borderRadius: 13,
|
||||
padding: 12,
|
||||
gap: 8,
|
||||
backgroundColor: t.surface,
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 12.5, color: t.encre }}>
|
||||
Je ne trouve pas de source fiable dans votre bibliothèque — je préfère ne pas inventer.
|
||||
</Text>
|
||||
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 11.5, color: t.encre2 }}>
|
||||
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.
|
||||
</Text>
|
||||
<BoutonTel libelle="Reformuler ma question" variante="contour" surAppui={surReformuler} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: t.surface,
|
||||
borderColor: t.bordure,
|
||||
borderWidth: 1,
|
||||
borderRadius: 13,
|
||||
borderBottomLeftRadius: 3,
|
||||
padding: 11,
|
||||
gap: 9,
|
||||
}}
|
||||
>
|
||||
{reponse.mode === 'GENERATED' && reponse.answer ? (
|
||||
<Text style={{ fontFamily: 'Manrope_500Medium', fontSize: 12.5, color: t.encre }}>
|
||||
{reponse.answer}
|
||||
</Text>
|
||||
) : (
|
||||
<Text style={{ fontFamily: 'Manrope_500Medium', fontSize: 12.5, color: t.encre }}>
|
||||
Voici ce que portent vos sources — les extraits sont cités tels quels.
|
||||
</Text>
|
||||
)}
|
||||
<View
|
||||
style={{ gap: 7, borderTopWidth: 1, borderTopColor: t.bordure, paddingTop: 9 }}
|
||||
>
|
||||
{reponse.excerpts.map((ex, i) => (
|
||||
<Source key={`${ex.locator}-${i}`} ex={ex} no={i + 1} t={t} />
|
||||
))}
|
||||
</View>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
gap: 6,
|
||||
backgroundColor: t.safranDoux,
|
||||
borderRadius: 9,
|
||||
padding: 8,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: t.alerte, fontSize: 10.5, fontFamily: 'Manrope_600SemiBold', flex: 1 }}>
|
||||
⚠ L'IA propose, vous validez : vérifiez la notice avant d'agir sur l'appareil.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function Source({ ex, no, t }: { ex: AssistantExcerpt; no: number; t: Tokens }) {
|
||||
const ouvrir = () => {
|
||||
if (ex.sourceType === 'DOCUMENT' && ex.documentId) router.push(`/bibliotheque/${ex.documentId}`);
|
||||
else if (ex.workOrderId) router.push(`/ot/${ex.workOrderId}`);
|
||||
};
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPress={ouvrir}
|
||||
style={{ backgroundColor: t.surface2, borderRadius: 9, padding: 9, gap: 3 }}
|
||||
>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 7 }}>
|
||||
<Text
|
||||
style={{
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: 5,
|
||||
backgroundColor: t.primaireDoux,
|
||||
color: t.primaire,
|
||||
fontSize: 10,
|
||||
fontFamily: 'Manrope_800ExtraBold',
|
||||
textAlign: 'center',
|
||||
lineHeight: 16,
|
||||
}}
|
||||
>
|
||||
{no}
|
||||
</Text>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={{ flex: 1, fontFamily: 'Manrope_700Bold', fontSize: 11.5, color: t.encre }}
|
||||
>
|
||||
{ex.title}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 10.5, color: t.encre2, marginLeft: 23 }}>
|
||||
{ex.sourceType === 'DOCUMENT' ? 'Bibliothèque' : 'Historique'} · {ex.locator}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontFamily: 'Manrope_500Medium',
|
||||
fontStyle: 'italic',
|
||||
fontSize: 11,
|
||||
color: t.encre2,
|
||||
marginLeft: 23,
|
||||
borderLeftWidth: 2,
|
||||
borderLeftColor: t.safran,
|
||||
paddingLeft: 7,
|
||||
}}
|
||||
>
|
||||
« {ex.content} »
|
||||
</Text>
|
||||
<Text style={{ marginLeft: 23, color: t.primaire, fontFamily: 'Manrope_700Bold', fontSize: 11 }}>
|
||||
{ex.sourceType === 'DOCUMENT' ? 'Voir le document' : "Ouvrir l'OT"}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user