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
|
cache: pnpm
|
||||||
- run: pnpm install --frozen-lockfile
|
- run: pnpm install --frozen-lockfile
|
||||||
- run: pnpm --filter @siop/shared build
|
- run: pnpm --filter @siop/shared build
|
||||||
- name: Régénérer la spec et le client typé
|
- name: Régénérer la spec et les clients typés
|
||||||
run: |
|
run: |
|
||||||
pnpm --filter @siop/shared contract
|
pnpm --filter @siop/shared contract
|
||||||
pnpm --filter @siop/web generate:client
|
pnpm --filter @siop/web generate:client
|
||||||
|
pnpm --filter @siop/mobile generate:client
|
||||||
- name: Vérifier qu'aucun artefact ne dérive du contrat
|
- name: Vérifier qu'aucun artefact ne dérive du contrat
|
||||||
run: |
|
run: |
|
||||||
if ! git diff --exit-code -- docs/openapi.json apps/web/src/api/schema.d.ts; then
|
if ! git diff --exit-code -- docs/openapi.json apps/web/src/api/schema.d.ts apps/mobile/src/api/schema.d.ts; then
|
||||||
echo "::error::Contrat désynchronisé — régénérez spec et clients dans le même commit (pnpm contract && pnpm --filter @siop/web generate:client)."
|
echo "::error::Contrat désynchronisé — régénérez spec et clients dans le même commit (pnpm contract && pnpm --filter @siop/web generate:client && pnpm --filter @siop/mobile generate:client)."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -59,18 +60,8 @@ jobs:
|
|||||||
name: api (tests + couverture ≥ 70 %)
|
name: api (tests + couverture ≥ 70 %)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
services:
|
services:
|
||||||
# R1+ : la migration r1_referentiel exige PostGIS (colonne générée geography).
|
# PostgreSQL vient d'infra/postgres (pgvector + PostGIS — R5) : les
|
||||||
# pgvector arrivera en R5 (bascule alors sur l’image infra/postgres).
|
# services ne savent pas builder, il démarre donc par étape ci-dessous.
|
||||||
postgres:
|
|
||||||
image: postgis/postgis:18-3.6
|
|
||||||
env:
|
|
||||||
POSTGRES_USER: siop
|
|
||||||
POSTGRES_PASSWORD: siop
|
|
||||||
POSTGRES_DB: siop
|
|
||||||
ports: ['5432:5432']
|
|
||||||
options: >-
|
|
||||||
--health-cmd "pg_isready -U siop -d siop"
|
|
||||||
--health-interval 5s --health-timeout 3s --health-retries 10
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:7.4-alpine
|
image: redis:7.4-alpine
|
||||||
ports: ['6379:6379']
|
ports: ['6379:6379']
|
||||||
@@ -81,8 +72,25 @@ jobs:
|
|||||||
DATABASE_URL: postgresql://siop:siop@localhost:5432/siop
|
DATABASE_URL: postgresql://siop:siop@localhost:5432/siop
|
||||||
REDIS_URL: redis://localhost:6379
|
REDIS_URL: redis://localhost:6379
|
||||||
JWT_SECRET: ci-only-secret-0123456789abcdef
|
JWT_SECRET: ci-only-secret-0123456789abcdef
|
||||||
|
MINIO_ENDPOINT: localhost
|
||||||
|
MINIO_ACCESS_KEY: siop
|
||||||
|
MINIO_SECRET_KEY: siop-minio
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v5
|
||||||
|
- name: Démarrer PostgreSQL (image infra — pgvector + PostGIS, comme partout)
|
||||||
|
run: |
|
||||||
|
docker build -t siop2/postgres infra/postgres
|
||||||
|
docker run -d --name postgres -p 5432:5432 \
|
||||||
|
-e POSTGRES_USER=siop -e POSTGRES_PASSWORD=siop -e POSTGRES_DB=siop \
|
||||||
|
siop2/postgres
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
docker exec postgres pg_isready -U siop -d siop && break; sleep 2;
|
||||||
|
done
|
||||||
|
- name: Démarrer MinIO (l'image officielle exige une commande — pas un service)
|
||||||
|
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: pnpm/action-setup@v4
|
||||||
- uses: actions/setup-node@v5
|
- uses: actions/setup-node@v5
|
||||||
with:
|
with:
|
||||||
@@ -110,20 +118,40 @@ jobs:
|
|||||||
- run: pnpm --filter @siop/web test
|
- run: pnpm --filter @siop/web test
|
||||||
- run: pnpm --filter @siop/web build
|
- run: pnpm --filter @siop/web build
|
||||||
|
|
||||||
|
mobile:
|
||||||
|
name: mobile (typecheck + tests jest-expo)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
- uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version-file: .nvmrc
|
||||||
|
cache: pnpm
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
|
- run: pnpm --filter @siop/shared build
|
||||||
|
- run: pnpm --filter @siop/mobile typecheck
|
||||||
|
- run: pnpm --filter @siop/mobile test
|
||||||
|
|
||||||
|
ai:
|
||||||
|
name: ai (pytest + ruff — uv)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
- uses: astral-sh/setup-uv@v5
|
||||||
|
- run: uv sync
|
||||||
|
working-directory: apps/ai
|
||||||
|
- run: uv run ruff check src tests
|
||||||
|
working-directory: apps/ai
|
||||||
|
- run: uv run pytest
|
||||||
|
working-directory: apps/ai
|
||||||
|
|
||||||
e2e:
|
e2e:
|
||||||
name: e2e (parcours démo Playwright)
|
name: e2e (parcours démo Playwright)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
services:
|
services:
|
||||||
postgres:
|
# PostgreSQL vient d'infra/postgres (pgvector + PostGIS — R5) : les
|
||||||
image: postgis/postgis:18-3.6
|
# services ne savent pas builder, il démarre donc par étape ci-dessous.
|
||||||
env:
|
|
||||||
POSTGRES_USER: siop
|
|
||||||
POSTGRES_PASSWORD: siop
|
|
||||||
POSTGRES_DB: siop
|
|
||||||
ports: ['5432:5432']
|
|
||||||
options: >-
|
|
||||||
--health-cmd "pg_isready -U siop -d siop"
|
|
||||||
--health-interval 5s --health-timeout 3s --health-retries 10
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:7.4-alpine
|
image: redis:7.4-alpine
|
||||||
ports: ['6379:6379']
|
ports: ['6379:6379']
|
||||||
@@ -134,8 +162,25 @@ jobs:
|
|||||||
DATABASE_URL: postgresql://siop:siop@localhost:5432/siop
|
DATABASE_URL: postgresql://siop:siop@localhost:5432/siop
|
||||||
REDIS_URL: redis://localhost:6379
|
REDIS_URL: redis://localhost:6379
|
||||||
JWT_SECRET: ci-only-secret-0123456789abcdef
|
JWT_SECRET: ci-only-secret-0123456789abcdef
|
||||||
|
MINIO_ENDPOINT: localhost
|
||||||
|
MINIO_ACCESS_KEY: siop
|
||||||
|
MINIO_SECRET_KEY: siop-minio
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v5
|
||||||
|
- name: Démarrer PostgreSQL (image infra — pgvector + PostGIS, comme partout)
|
||||||
|
run: |
|
||||||
|
docker build -t siop2/postgres infra/postgres
|
||||||
|
docker run -d --name postgres -p 5432:5432 \
|
||||||
|
-e POSTGRES_USER=siop -e POSTGRES_PASSWORD=siop -e POSTGRES_DB=siop \
|
||||||
|
siop2/postgres
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
docker exec postgres pg_isready -U siop -d siop && break; sleep 2;
|
||||||
|
done
|
||||||
|
- name: Démarrer MinIO (l'image officielle exige une commande — pas un service)
|
||||||
|
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: pnpm/action-setup@v4
|
||||||
- uses: actions/setup-node@v5
|
- uses: actions/setup-node@v5
|
||||||
with:
|
with:
|
||||||
@@ -146,8 +191,30 @@ jobs:
|
|||||||
- run: pnpm --filter @siop/api prisma:generate
|
- run: pnpm --filter @siop/api prisma:generate
|
||||||
- run: pnpm --filter @siop/api exec prisma migrate deploy
|
- run: pnpm --filter @siop/api exec prisma migrate deploy
|
||||||
- run: pnpm --filter @siop/api build
|
- run: pnpm --filter @siop/api build
|
||||||
|
- uses: astral-sh/setup-uv@v5
|
||||||
|
- name: Démarrer le service IA (R5 — embeddeur déterministe, seuils CI)
|
||||||
|
working-directory: apps/ai
|
||||||
|
env:
|
||||||
|
AI_EMBEDDINGS: deterministe
|
||||||
|
AI_SERVICE_TOKEN: dev-only-ai-token
|
||||||
|
# L'embeddeur déterministe (tri-grammes hachés) produit des scores
|
||||||
|
# bien plus bas que le vrai modèle : seuils calibrés sur mesures
|
||||||
|
# réelles (vrai match ≈ 0,66 ; bruit de collisions ≈ 0,11 ; codes
|
||||||
|
# de bilan pertinents ≈ 0,14-0,16 ; parasites ≈ 0,08).
|
||||||
|
AI_SEUIL_PERTINENCE: '0.20'
|
||||||
|
AI_SEUIL_SUGGESTION: '0.10'
|
||||||
|
AI_SEUIL_CONFIANCE_FORTE: '0.30'
|
||||||
|
run: |
|
||||||
|
uv sync
|
||||||
|
nohup uv run uvicorn siop_ai.app:app --host 127.0.0.1 --port 8000 > /tmp/siop-ai.log 2>&1 &
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
curl -fsS http://127.0.0.1:8000/healthz && break; sleep 1;
|
||||||
|
done
|
||||||
- run: pnpm --filter @siop/web exec playwright install --with-deps chromium
|
- run: pnpm --filter @siop/web exec playwright install --with-deps chromium
|
||||||
- run: pnpm --filter @siop/web e2e
|
- run: pnpm --filter @siop/web e2e
|
||||||
|
- name: Journal du service IA en cas d'échec
|
||||||
|
if: failure()
|
||||||
|
run: cat /tmp/siop-ai.log || true
|
||||||
- name: Traces Playwright en cas d'échec
|
- name: Traces Playwright en cas d'échec
|
||||||
if: failure()
|
if: failure()
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
@@ -164,7 +231,7 @@ jobs:
|
|||||||
deploy:
|
deploy:
|
||||||
name: deploy (Dokploy — siop2.apps.enset.top)
|
name: deploy (Dokploy — siop2.apps.enset.top)
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
needs: [lint, contract, api, web, e2e]
|
needs: [lint, contract, api, web, mobile, ai, e2e]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
environment: production
|
environment: production
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -2,8 +2,17 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
.env
|
.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/
|
.turbo/
|
||||||
coverage/
|
coverage/
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
test-results/
|
test-results/
|
||||||
playwright-report/
|
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.
|
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.
|
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.
|
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.
|
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.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.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.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 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é).
|
||||||
- 🔄 **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).
|
- ✅ **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).
|
||||||
- Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0→R5.
|
- ✅ **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_PORT=9000
|
||||||
MINIO_ACCESS_KEY=siop
|
MINIO_ACCESS_KEY=siop
|
||||||
MINIO_SECRET_KEY=siop-minio
|
MINIO_SECRET_KEY=siop-minio
|
||||||
|
|
||||||
|
# R4 — origines navigateur autorisées (Expo web / debug mobile). Vide = pas de CORS.
|
||||||
|
CORS_ORIGINS=http://localhost:8081
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
"@nestjs/testing": "^11.1.0",
|
"@nestjs/testing": "^11.1.0",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
|
"@types/multer": "^2.2.0",
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
|
|||||||
@@ -6,6 +6,39 @@ import { PrismaClient } from '@prisma/client';
|
|||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
async function main() {
|
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.request.deleteMany({ where: { description: { contains: 'E2E' } } });
|
||||||
await prisma.workOrder.deleteMany({ where: { title: { contains: 'E2E' } } });
|
await prisma.workOrder.deleteMany({ where: { title: { contains: 'E2E' } } });
|
||||||
await prisma.meterReading.deleteMany({ where: { value: { gte: 90_000_000 } } });
|
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
|
activationToken String? @unique
|
||||||
activationExpiresAt DateTime?
|
activationExpiresAt DateTime?
|
||||||
teams Team[]
|
teams Team[]
|
||||||
|
// R6.6 — sites autorisés en signalement (Demandeur) ; vide = aucune restriction
|
||||||
|
assignedSites Location[]
|
||||||
// R2 — exploitation
|
// R2 — exploitation
|
||||||
workOrdersAssigned WorkOrder[] @relation("WorkOrderAssignees")
|
workOrdersAssigned WorkOrder[] @relation("WorkOrderAssignees")
|
||||||
workOrdersCreated WorkOrder[] @relation("WorkOrderCreator")
|
workOrdersCreated WorkOrder[] @relation("WorkOrderCreator")
|
||||||
@@ -56,6 +58,12 @@ model User {
|
|||||||
requests Request[]
|
requests Request[]
|
||||||
checklistDone ChecklistItem[]
|
checklistDone ChecklistItem[]
|
||||||
meterReadings MeterReading[]
|
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())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -101,11 +109,16 @@ model Location {
|
|||||||
longitude Float?
|
longitude Float?
|
||||||
// + colonne PostGIS générée (voir migration r1_referentiel) :
|
// + colonne PostGIS générée (voir migration r1_referentiel) :
|
||||||
// position geography(Point,4326) GENERATED ALWAYS AS (…) STORED
|
// 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[]
|
assets Asset[]
|
||||||
|
assignedUsers User[] // reverse de User.assignedSites (R6.6)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@index([parentId])
|
@@index([parentId])
|
||||||
|
@@index([partnerId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Asset {
|
model Asset {
|
||||||
@@ -127,6 +140,7 @@ model Asset {
|
|||||||
workOrders WorkOrder[]
|
workOrders WorkOrder[]
|
||||||
requests Request[]
|
requests Request[]
|
||||||
meters Meter[]
|
meters Meter[]
|
||||||
|
documents Document[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -222,9 +236,13 @@ model WorkOrder {
|
|||||||
completedAt DateTime?
|
completedAt DateTime?
|
||||||
cancelledAt DateTime?
|
cancelledAt DateTime?
|
||||||
events WorkOrderEvent[]
|
events WorkOrderEvent[]
|
||||||
|
ragChunks RagChunk[]
|
||||||
checklist ChecklistItem[]
|
checklist ChecklistItem[]
|
||||||
report InterventionReport?
|
report InterventionReport?
|
||||||
request Request?
|
request Request?
|
||||||
|
stockMovements StockMovement[]
|
||||||
|
laborTimes LaborTime[]
|
||||||
|
documents Document[]
|
||||||
// Grille du mois : « AAAA-MM » — l'unicité [assetId, periodKey] EST
|
// Grille du mois : « AAAA-MM » — l'unicité [assetId, periodKey] EST
|
||||||
// l'idempotence de la génération (les OT correctifs restent à null).
|
// l'idempotence de la génération (les OT correctifs restent à null).
|
||||||
periodKey String?
|
periodKey String?
|
||||||
@@ -358,3 +376,177 @@ model MeterReading {
|
|||||||
|
|
||||||
@@index([meterId])
|
@@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 seedUsers(prisma, roleIds, passwordHash);
|
||||||
await seedReferentiel(prisma);
|
await seedReferentiel(prisma);
|
||||||
await seedExploitation(prisma);
|
await seedExploitation(prisma);
|
||||||
|
await seedGestion(prisma);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function seedUsers(
|
async function seedUsers(
|
||||||
@@ -461,6 +462,19 @@ async function seedExploitation(prisma: PrismaClient): Promise<void> {
|
|||||||
const karim = await parEmail('demandeur@demo.siop.ma');
|
const karim = await parEmail('demandeur@demo.siop.ma');
|
||||||
const annee = new Date().getFullYear();
|
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(
|
const grilleLabels = TASK_TEMPLATES.filter((t) => t.periodMonths === 1).map(
|
||||||
(t) => t.label,
|
(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 */
|
/* c8 ignore start — wrapper CLI */
|
||||||
if (require.main === module) {
|
if (require.main === module) {
|
||||||
const prisma = new PrismaClient();
|
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 { DynamicModule, Module } from '@nestjs/common';
|
||||||
import { APP_GUARD } from '@nestjs/core';
|
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 { AssetsModule } from './assets/assets.module';
|
||||||
|
import { DocumentsModule } from './documents/documents.module';
|
||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
import { DemoAuthModule } from './auth/demo/demo-auth.module';
|
import { DemoAuthModule } from './auth/demo/demo-auth.module';
|
||||||
import { JwtAuthGuard } from './auth/jwt-auth.guard';
|
import { JwtAuthGuard } from './auth/jwt-auth.guard';
|
||||||
@@ -10,7 +14,10 @@ import { FilesModule } from './files/files.module';
|
|||||||
import { HealthModule } from './health/health.module';
|
import { HealthModule } from './health/health.module';
|
||||||
import { LocationsModule } from './locations/locations.module';
|
import { LocationsModule } from './locations/locations.module';
|
||||||
import { MetersModule } from './meters/meters.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 { PortalModule } from './portal/portal.module';
|
||||||
|
import { PurchaseOrdersModule } from './purchase-orders/purchase-orders.module';
|
||||||
import { PreventiveModule } from './preventive/preventive.module';
|
import { PreventiveModule } from './preventive/preventive.module';
|
||||||
import { ReferenceValuesModule } from './reference-values/reference-values.module';
|
import { ReferenceValuesModule } from './reference-values/reference-values.module';
|
||||||
import { RequestsModule } from './requests/requests.module';
|
import { RequestsModule } from './requests/requests.module';
|
||||||
@@ -50,6 +57,14 @@ export class AppModule {
|
|||||||
PreventiveModule,
|
PreventiveModule,
|
||||||
MetersModule,
|
MetersModule,
|
||||||
PortalModule,
|
PortalModule,
|
||||||
|
// R3 — gestion
|
||||||
|
PartnersModule,
|
||||||
|
PartsModule,
|
||||||
|
PurchaseOrdersModule,
|
||||||
|
DocumentsModule,
|
||||||
|
AnalyticsModule,
|
||||||
|
SearchModule,
|
||||||
|
AssistantModule,
|
||||||
// ADR-002 : hors DEMO_MODE, le module n'est pas enregistré → 404
|
// ADR-002 : hors DEMO_MODE, le module n'est pas enregistré → 404
|
||||||
...(demoModeEnabled() ? [DemoAuthModule] : []),
|
...(demoModeEnabled() ? [DemoAuthModule] : []),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
type AssetCreate,
|
type AssetCreate,
|
||||||
type AssetUpdate,
|
type AssetUpdate,
|
||||||
} from '@siop/shared';
|
} from '@siop/shared';
|
||||||
|
import { type AuthenticatedUser, CurrentUser } from '../auth/current-user.decorator';
|
||||||
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||||
import { RequirePermission } from '../permissions/require-permission.decorator';
|
import { RequirePermission } from '../permissions/require-permission.decorator';
|
||||||
import { AssetsService } from './assets.service';
|
import { AssetsService } from './assets.service';
|
||||||
@@ -32,10 +33,11 @@ export class AssetsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Avant ':id' (ordre des routes) — authentification seule : le demandeur
|
/** 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')
|
@Get('options')
|
||||||
options() {
|
options(@CurrentUser() user: AuthenticatedUser) {
|
||||||
return this.assetsService.options();
|
return this.assetsService.options(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type {
|
|||||||
AssetsResponse,
|
AssetsResponse,
|
||||||
AssetUpdate,
|
AssetUpdate,
|
||||||
} from '@siop/shared';
|
} from '@siop/shared';
|
||||||
|
import type { AuthenticatedUser } from '../auth/current-user.decorator';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
const assetInclude = {
|
const assetInclude = {
|
||||||
@@ -29,9 +30,30 @@ type AssetRow = Prisma.AssetGetPayload<{ include: typeof assetInclude }>;
|
|||||||
export class AssetsService {
|
export class AssetsService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
/** Options minimales pour le signalement — ouvert à tout rôle authentifié. */
|
/** Sites + zones autorisés pour le signalement de `user`, ou `null` si aucune
|
||||||
async options(): Promise<AssetOptionsResponse> {
|
* 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({
|
const rows = await this.prisma.asset.findMany({
|
||||||
|
where: allowed ? { locationId: { in: allowed } } : undefined,
|
||||||
include: { location: { include: { parent: true } } },
|
include: { location: { include: { parent: true } } },
|
||||||
orderBy: { reference: 'asc' },
|
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_USE_SSL: z.string().optional(),
|
||||||
MINIO_ACCESS_KEY: z.string().default('siop'),
|
MINIO_ACCESS_KEY: z.string().default('siop'),
|
||||||
MINIO_SECRET_KEY: z.string().default('siop-minio'),
|
MINIO_SECRET_KEY: z.string().default('siop-minio'),
|
||||||
|
MINIO_BUCKET: z.string().default('siop2'),
|
||||||
|
// R4 : origines navigateur autorisées en CORS (Expo web / debug mobile).
|
||||||
|
// Vide = aucun CORS (défaut sûr) — le web de prod passe par le proxy nginx
|
||||||
|
// même-origine, les apps natives n'envoient pas d'Origin.
|
||||||
|
CORS_ORIGINS: z.string().default(''),
|
||||||
|
// R5 (ADR-004 §4) : le service IA interne — seul l'API le contacte.
|
||||||
|
AI_SERVICE_URL: z.string().default('http://localhost:8000'),
|
||||||
|
AI_SERVICE_TOKEN: z.string().default('dev-only-ai-token'),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Env = z.infer<typeof EnvSchema>;
|
export type Env = z.infer<typeof EnvSchema>;
|
||||||
|
|||||||
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é
|
* 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
|
* ailleurs que dans son implémentation (règle ESLint) — le reste de l'API
|
||||||
* interface (règle lint à venir en R0.12). R0 : santé seulement ;
|
* dépend de cette interface. R3 : la bibliothèque de documents l'utilise ;
|
||||||
* les opérations arrivent avec les photos/documents (R2-R3).
|
* les téléchargements sont STREAMÉS par l'API (MinIO n'est pas exposé).
|
||||||
*/
|
*/
|
||||||
export const FILE_STORAGE = Symbol('FILE_STORAGE');
|
export const FILE_STORAGE = Symbol('FILE_STORAGE');
|
||||||
|
|
||||||
export interface FileStorage {
|
export interface FileStorage {
|
||||||
/** Lève une exception si le stockage est injoignable. */
|
/** Lève une exception si le stockage est injoignable. */
|
||||||
healthCheck(): Promise<void>;
|
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 * as Minio from 'minio';
|
||||||
import { loadEnv } from '../config/env';
|
import { loadEnv } from '../config/env';
|
||||||
import type { FileStorage } from './file-storage';
|
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()
|
@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 client: Minio.Client;
|
||||||
|
private readonly bucket: string;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
const env = loadEnv();
|
const env = loadEnv();
|
||||||
|
this.bucket = env.MINIO_BUCKET;
|
||||||
this.client = new Minio.Client({
|
this.client = new Minio.Client({
|
||||||
endPoint: env.MINIO_ENDPOINT,
|
endPoint: env.MINIO_ENDPOINT,
|
||||||
port: env.MINIO_PORT,
|
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> {
|
async healthCheck(): Promise<void> {
|
||||||
await this.client.listBuckets();
|
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;
|
guardianPhone: string | null;
|
||||||
latitude: number | null;
|
latitude: number | null;
|
||||||
longitude: number | null;
|
longitude: number | null;
|
||||||
|
partnerId: string | null;
|
||||||
|
partner: { name: string } | null;
|
||||||
_count: { assets: number };
|
_count: { assets: number };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const locationInclude = {
|
||||||
|
partner: { select: { name: true } },
|
||||||
|
_count: { select: { assets: true } },
|
||||||
|
} as const;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class LocationsService {
|
export class LocationsService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
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. */
|
/** Liste plate ; l'assetCount d'un SITE inclut les appareils de ses zones. */
|
||||||
async list(): Promise<LocationsResponse> {
|
async list(): Promise<LocationsResponse> {
|
||||||
const rows: LocationRow[] = await this.prisma.location.findMany({
|
const rows: LocationRow[] = await this.prisma.location.findMany({
|
||||||
include: { _count: { select: { assets: true } } },
|
include: locationInclude,
|
||||||
orderBy: { name: 'asc' },
|
orderBy: { name: 'asc' },
|
||||||
});
|
});
|
||||||
const childAssets = new Map<string, number>();
|
const childAssets = new Map<string, number>();
|
||||||
@@ -52,9 +59,10 @@ export class LocationsService {
|
|||||||
|
|
||||||
async create(dto: LocationCreate): Promise<LocationDto> {
|
async create(dto: LocationCreate): Promise<LocationDto> {
|
||||||
await this.assertDepth(dto.parentId);
|
await this.assertDepth(dto.parentId);
|
||||||
|
await this.assertClientPartner(dto.partnerId);
|
||||||
const created = await this.prisma.location.create({
|
const created = await this.prisma.location.create({
|
||||||
data: dto,
|
data: dto,
|
||||||
include: { _count: { select: { assets: true } } },
|
include: locationInclude,
|
||||||
});
|
});
|
||||||
return this.toDto(created, 0);
|
return this.toDto(created, 0);
|
||||||
}
|
}
|
||||||
@@ -76,10 +84,11 @@ export class LocationsService {
|
|||||||
}
|
}
|
||||||
await this.assertDepth(dto.parentId);
|
await this.assertDepth(dto.parentId);
|
||||||
}
|
}
|
||||||
|
await this.assertClientPartner(dto.partnerId);
|
||||||
const updated = await this.prisma.location.update({
|
const updated = await this.prisma.location.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: dto,
|
data: dto,
|
||||||
include: { _count: { select: { assets: true } } },
|
include: locationInclude,
|
||||||
});
|
});
|
||||||
return this.toDto(updated, updated._count.assets);
|
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 {
|
private toDto(row: Omit<LocationRow, '_count'>, assetCount: number): LocationDto {
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
@@ -107,6 +126,8 @@ export class LocationsService {
|
|||||||
guardianPhone: row.guardianPhone,
|
guardianPhone: row.guardianPhone,
|
||||||
latitude: row.latitude,
|
latitude: row.latitude,
|
||||||
longitude: row.longitude,
|
longitude: row.longitude,
|
||||||
|
partnerId: row.partnerId,
|
||||||
|
partnerName: row.partner?.name ?? null,
|
||||||
assetCount,
|
assetCount,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ async function bootstrap() {
|
|||||||
assertDemoModeAllowed(); // ADR-002 — double verrou avant toute écoute réseau
|
assertDemoModeAllowed(); // ADR-002 — double verrou avant toute écoute réseau
|
||||||
|
|
||||||
const app = await NestFactory.create(AppModule.forRoot());
|
const app = await NestFactory.create(AppModule.forRoot());
|
||||||
|
const origines = env.CORS_ORIGINS.split(',').map((o) => o.trim()).filter(Boolean);
|
||||||
|
if (origines.length) app.enableCors({ origin: origines });
|
||||||
app.enableShutdownHooks();
|
app.enableShutdownHooks();
|
||||||
await app.listen(env.PORT);
|
await app.listen(env.PORT);
|
||||||
new Logger('Bootstrap').log(`API SIOP V2 démarrée sur :${env.PORT}`);
|
new Logger('Bootstrap').log(`API SIOP V2 démarrée sur :${env.PORT}`);
|
||||||
|
|||||||
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> {
|
private async nextReference(): Promise<string> {
|
||||||
const annee = new Date().getFullYear();
|
const [{ nextval }] = await this.prisma.$queryRaw<[{ nextval: bigint }]>`
|
||||||
const dernier = await this.prisma.request.findFirst({
|
SELECT nextval('request_ref_seq')`;
|
||||||
where: { reference: { startsWith: `DEM-${annee}-` } },
|
return `DEM-${new Date().getFullYear()}-${String(nextval).padStart(4, '0')}`;
|
||||||
orderBy: { reference: 'desc' },
|
|
||||||
select: { reference: true },
|
|
||||||
});
|
|
||||||
const n = dernier ? Number(dernier.reference.split('-')[2]) + 1 : 1;
|
|
||||||
return `DEM-${annee}-${String(n).padStart(4, '0')}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private toStatus(row: Row): PortalRequestStatus {
|
private toStatus(row: Row): PortalRequestStatus {
|
||||||
|
|||||||
@@ -210,14 +210,9 @@ export class PreventiveService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async nextReference(): Promise<string> {
|
private async nextReference(): Promise<string> {
|
||||||
const annee = new Date().getFullYear();
|
const [{ nextval }] = await this.prisma.$queryRaw<[{ nextval: bigint }]>`
|
||||||
const dernier = await this.prisma.workOrder.findFirst({
|
SELECT nextval('work_order_ref_seq')`;
|
||||||
where: { reference: { startsWith: `OT-${annee}-` } },
|
return `OT-${new Date().getFullYear()}-${String(nextval).padStart(4, '0')}`;
|
||||||
orderBy: { reference: 'desc' },
|
|
||||||
select: { reference: true },
|
|
||||||
});
|
|
||||||
const n = dernier ? Number(dernier.reference.split('-')[2]) + 1 : 1;
|
|
||||||
return `OT-${annee}-${String(n).padStart(4, '0')}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private toDto(row: TemplateRow): TaskTemplateDto {
|
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 { Module } from '@nestjs/common';
|
||||||
|
import { AssetsModule } from '../assets/assets.module';
|
||||||
import { WorkOrdersModule } from '../work-orders/work-orders.module';
|
import { WorkOrdersModule } from '../work-orders/work-orders.module';
|
||||||
import { RequestsController } from './requests.controller';
|
import { RequestsController } from './requests.controller';
|
||||||
import { RequestsService } from './requests.service';
|
import { RequestsService } from './requests.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [WorkOrdersModule],
|
imports: [WorkOrdersModule, AssetsModule],
|
||||||
controllers: [RequestsController],
|
controllers: [RequestsController],
|
||||||
providers: [RequestsService],
|
providers: [RequestsService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import type {
|
|||||||
import type { AuthenticatedUser } from '../auth/current-user.decorator';
|
import type { AuthenticatedUser } from '../auth/current-user.decorator';
|
||||||
import { PermissionsService } from '../permissions/permissions.service';
|
import { PermissionsService } from '../permissions/permissions.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { AssetsService } from '../assets/assets.service';
|
||||||
import { WorkOrdersService } from '../work-orders/work-orders.service';
|
import { WorkOrdersService } from '../work-orders/work-orders.service';
|
||||||
|
|
||||||
const requestInclude = {
|
const requestInclude = {
|
||||||
@@ -32,6 +33,7 @@ export class RequestsService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly permissions: PermissionsService,
|
private readonly permissions: PermissionsService,
|
||||||
private readonly workOrders: WorkOrdersService,
|
private readonly workOrders: WorkOrdersService,
|
||||||
|
private readonly assets: AssetsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private async scope(user: AuthenticatedUser): Promise<Prisma.RequestWhereInput> {
|
private async scope(user: AuthenticatedUser): Promise<Prisma.RequestWhereInput> {
|
||||||
@@ -52,6 +54,13 @@ export class RequestsService {
|
|||||||
async create(dto: RequestCreate, user: AuthenticatedUser): Promise<RequestSummary> {
|
async create(dto: RequestCreate, user: AuthenticatedUser): Promise<RequestSummary> {
|
||||||
const asset = await this.prisma.asset.findUnique({ where: { id: dto.assetId } });
|
const asset = await this.prisma.asset.findUnique({ where: { id: dto.assetId } });
|
||||||
if (!asset) throw new BadRequestException('Équipement inconnu');
|
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++) {
|
for (let essai = 0; ; essai++) {
|
||||||
try {
|
try {
|
||||||
const created = await this.prisma.request.create({
|
const created = await this.prisma.request.create({
|
||||||
@@ -131,14 +140,9 @@ export class RequestsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async nextReference(): Promise<string> {
|
private async nextReference(): Promise<string> {
|
||||||
const annee = new Date().getFullYear();
|
const [{ nextval }] = await this.prisma.$queryRaw<[{ nextval: bigint }]>`
|
||||||
const dernier = await this.prisma.request.findFirst({
|
SELECT nextval('request_ref_seq')`;
|
||||||
where: { reference: { startsWith: `DEM-${annee}-` } },
|
return `DEM-${new Date().getFullYear()}-${String(nextval).padStart(4, '0')}`;
|
||||||
orderBy: { reference: 'desc' },
|
|
||||||
select: { reference: true },
|
|
||||||
});
|
|
||||||
const n = dernier ? Number(dernier.reference.split('-')[2]) + 1 : 1;
|
|
||||||
return `DEM-${annee}-${String(n).padStart(4, '0')}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private requesterLabel(row: RequestRow): string {
|
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 {
|
import {
|
||||||
|
BadRequestException,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
@@ -20,6 +21,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
|||||||
const userInclude = {
|
const userInclude = {
|
||||||
role: true,
|
role: true,
|
||||||
teams: { orderBy: { name: 'asc' } },
|
teams: { orderBy: { name: 'asc' } },
|
||||||
|
assignedSites: { orderBy: { name: 'asc' } },
|
||||||
} satisfies Prisma.UserInclude;
|
} satisfies Prisma.UserInclude;
|
||||||
|
|
||||||
type UserRow = Prisma.UserGetPayload<{ include: typeof userInclude }>;
|
type UserRow = Prisma.UserGetPayload<{ include: typeof userInclude }>;
|
||||||
@@ -46,6 +48,7 @@ export class UsersService {
|
|||||||
async invite(dto: InvitationCreate): Promise<InvitationResponse> {
|
async invite(dto: InvitationCreate): Promise<InvitationResponse> {
|
||||||
const role = await this.prisma.role.findUnique({ where: { id: dto.roleId } });
|
const role = await this.prisma.role.findUnique({ where: { id: dto.roleId } });
|
||||||
if (!role) throw new NotFoundException('Rôle inconnu');
|
if (!role) throw new NotFoundException('Rôle inconnu');
|
||||||
|
if (dto.locationIds?.length) await this.assertTopLevelSites(dto.locationIds);
|
||||||
try {
|
try {
|
||||||
const user = await this.prisma.user.create({
|
const user = await this.prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -56,6 +59,9 @@ export class UsersService {
|
|||||||
teams: dto.teamIds?.length
|
teams: dto.teamIds?.length
|
||||||
? { connect: dto.teamIds.map((id) => ({ id })) }
|
? { connect: dto.teamIds.map((id) => ({ id })) }
|
||||||
: undefined,
|
: undefined,
|
||||||
|
assignedSites: dto.locationIds?.length
|
||||||
|
? { connect: dto.locationIds.map((id) => ({ id })) }
|
||||||
|
: undefined,
|
||||||
...this.freshToken(),
|
...this.freshToken(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -90,6 +96,7 @@ export class UsersService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async update(userId: string, dto: UserUpdate): Promise<UserAdmin> {
|
async update(userId: string, dto: UserUpdate): Promise<UserAdmin> {
|
||||||
|
if (dto.locationIds) await this.assertTopLevelSites(dto.locationIds);
|
||||||
try {
|
try {
|
||||||
const updated = await this.prisma.user.update({
|
const updated = await this.prisma.user.update({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
@@ -98,9 +105,13 @@ export class UsersService {
|
|||||||
phone: dto.phone,
|
phone: dto.phone,
|
||||||
roleId: dto.roleId,
|
roleId: dto.roleId,
|
||||||
isActive: dto.isActive,
|
isActive: dto.isActive,
|
||||||
|
hourlyRate: dto.hourlyRate,
|
||||||
teams: dto.teamIds
|
teams: dto.teamIds
|
||||||
? { set: dto.teamIds.map((id) => ({ id })) }
|
? { set: dto.teamIds.map((id) => ({ id })) }
|
||||||
: undefined,
|
: undefined,
|
||||||
|
assignedSites: dto.locationIds
|
||||||
|
? { set: dto.locationIds.map((id) => ({ id })) }
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
include: userInclude,
|
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() {
|
private freshToken() {
|
||||||
return {
|
return {
|
||||||
activationToken: randomBytes(32).toString('base64url'),
|
activationToken: randomBytes(32).toString('base64url'),
|
||||||
@@ -136,6 +157,8 @@ export class UsersService {
|
|||||||
? 'active'
|
? 'active'
|
||||||
: 'invited',
|
: 'invited',
|
||||||
isDemo: row.isDemo,
|
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,
|
AssigneesUpdateSchema,
|
||||||
ChecklistPatchSchema,
|
ChecklistPatchSchema,
|
||||||
CommentCreateSchema,
|
CommentCreateSchema,
|
||||||
|
ConsumePartSchema,
|
||||||
|
LaborTimeCreateSchema,
|
||||||
ReportUpsertSchema,
|
ReportUpsertSchema,
|
||||||
TransitionRequestSchema,
|
TransitionRequestSchema,
|
||||||
WorkOrderCreateSchema,
|
WorkOrderCreateSchema,
|
||||||
type AssigneesUpdate,
|
type AssigneesUpdate,
|
||||||
type ChecklistPatch,
|
type ChecklistPatch,
|
||||||
type CommentCreate,
|
type CommentCreate,
|
||||||
|
type ConsumePart,
|
||||||
|
type LaborTimeCreate,
|
||||||
type ReportUpsert,
|
type ReportUpsert,
|
||||||
type TransitionRequest,
|
type TransitionRequest,
|
||||||
type WorkOrderCreate,
|
type WorkOrderCreate,
|
||||||
@@ -97,6 +101,26 @@ export class WorkOrdersController {
|
|||||||
return this.workOrders.upsertReport(id, body, user);
|
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')
|
@Patch(':id/checklist/:itemId')
|
||||||
@RequirePermission('WORK_ORDERS', 'edit')
|
@RequirePermission('WORK_ORDERS', 'edit')
|
||||||
patchChecklist(
|
patchChecklist(
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PartsModule } from '../parts/parts.module';
|
||||||
import { WorkOrdersController } from './work-orders.controller';
|
import { WorkOrdersController } from './work-orders.controller';
|
||||||
import { WorkOrdersService } from './work-orders.service';
|
import { WorkOrdersService } from './work-orders.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [PartsModule],
|
||||||
controllers: [WorkOrdersController],
|
controllers: [WorkOrdersController],
|
||||||
providers: [WorkOrdersService],
|
providers: [WorkOrdersService],
|
||||||
exports: [WorkOrdersService],
|
exports: [WorkOrdersService],
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
type WorkOrderSummary,
|
type WorkOrderSummary,
|
||||||
} from '@siop/shared';
|
} from '@siop/shared';
|
||||||
import type { AuthenticatedUser } from '../auth/current-user.decorator';
|
import type { AuthenticatedUser } from '../auth/current-user.decorator';
|
||||||
|
import { PartsService } from '../parts/parts.service';
|
||||||
import { PermissionsService } from '../permissions/permissions.service';
|
import { PermissionsService } from '../permissions/permissions.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
@@ -33,6 +34,13 @@ const detailInclude = {
|
|||||||
request: { include: { requestedBy: true } },
|
request: { include: { requestedBy: true } },
|
||||||
events: { include: { by: true }, orderBy: { createdAt: 'desc' as const } },
|
events: { include: { by: true }, orderBy: { createdAt: 'desc' as const } },
|
||||||
checklist: { include: { doneBy: true }, orderBy: { label: 'asc' 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: {
|
report: {
|
||||||
include: {
|
include: {
|
||||||
doorState: true,
|
doorState: true,
|
||||||
@@ -71,6 +79,7 @@ export class WorkOrdersService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly permissions: PermissionsService,
|
private readonly permissions: PermissionsService,
|
||||||
|
private readonly parts: PartsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** Invariant « voir autre » : sans le droit, on ne voit que SES OT. */
|
/** Invariant « voir autre » : sans le droit, on ne voit que SES OT. */
|
||||||
@@ -177,14 +186,10 @@ export class WorkOrdersService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async nextReference(): Promise<string> {
|
private async nextReference(): Promise<string> {
|
||||||
const annee = new Date().getFullYear();
|
// Séquence Postgres : insensible à la concurrence (jamais de collision)
|
||||||
const dernier = await this.prisma.workOrder.findFirst({
|
const [{ nextval }] = await this.prisma.$queryRaw<[{ nextval: bigint }]>`
|
||||||
where: { reference: { startsWith: `OT-${annee}-` } },
|
SELECT nextval('work_order_ref_seq')`;
|
||||||
orderBy: { reference: 'desc' },
|
return `OT-${new Date().getFullYear()}-${String(nextval).padStart(4, '0')}`;
|
||||||
select: { reference: true },
|
|
||||||
});
|
|
||||||
const n = dernier ? Number(dernier.reference.split('-')[2]) + 1 : 1;
|
|
||||||
return `OT-${annee}-${String(n).padStart(4, '0')}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Machine à états stricte + garde de clôture. */
|
/** Machine à états stricte + garde de clôture. */
|
||||||
@@ -194,6 +199,7 @@ export class WorkOrdersService {
|
|||||||
user: AuthenticatedUser,
|
user: AuthenticatedUser,
|
||||||
): Promise<WorkOrderDetail> {
|
): Promise<WorkOrderDetail> {
|
||||||
const detail = await this.get(id, user);
|
const detail = await this.get(id, user);
|
||||||
|
await this.assertVersion(id, dto.baseUpdatedAt);
|
||||||
if (!WORK_ORDER_TRANSITIONS[detail.status].includes(dto.to)) {
|
if (!WORK_ORDER_TRANSITIONS[detail.status].includes(dto.to)) {
|
||||||
throw new ConflictException(
|
throw new ConflictException(
|
||||||
`Transition interdite : ${WORK_ORDER_STATUS_LABELS[detail.status]} → ${WORK_ORDER_STATUS_LABELS[dto.to]}`,
|
`Transition interdite : ${WORK_ORDER_STATUS_LABELS[detail.status]} → ${WORK_ORDER_STATUS_LABELS[dto.to]}`,
|
||||||
@@ -240,6 +246,7 @@ export class WorkOrdersService {
|
|||||||
await this.prisma.workOrderEvent.create({
|
await this.prisma.workOrderEvent.create({
|
||||||
data: { workOrderId: id, kind: 'COMMENT', message, byId: user.userId },
|
data: { workOrderId: id, kind: 'COMMENT', message, byId: user.userId },
|
||||||
});
|
});
|
||||||
|
await this.toucher(id);
|
||||||
return this.get(id, user);
|
return this.get(id, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,6 +286,7 @@ export class WorkOrdersService {
|
|||||||
user: AuthenticatedUser,
|
user: AuthenticatedUser,
|
||||||
): Promise<WorkOrderDetail> {
|
): Promise<WorkOrderDetail> {
|
||||||
await this.get(id, user);
|
await this.get(id, user);
|
||||||
|
await this.assertVersion(id, dto.baseUpdatedAt);
|
||||||
// Chaque valeur fournie doit appartenir au référentiel de SON champ (et être active)
|
// Chaque valeur fournie doit appartenir au référentiel de SON champ (et être active)
|
||||||
for (const [colonne, field] of Object.entries(REPORT_FIELDS)) {
|
for (const [colonne, field] of Object.entries(REPORT_FIELDS)) {
|
||||||
const valeur = dto[colonne as keyof ReportUpsert];
|
const valeur = dto[colonne as keyof ReportUpsert];
|
||||||
@@ -305,6 +313,7 @@ export class WorkOrdersService {
|
|||||||
update: donnees,
|
update: donnees,
|
||||||
create: { workOrderId: id, ...donnees },
|
create: { workOrderId: id, ...donnees },
|
||||||
});
|
});
|
||||||
|
await this.toucher(id);
|
||||||
return this.get(id, user);
|
return this.get(id, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,6 +324,7 @@ export class WorkOrdersService {
|
|||||||
user: AuthenticatedUser,
|
user: AuthenticatedUser,
|
||||||
): Promise<ChecklistItemDto> {
|
): Promise<ChecklistItemDto> {
|
||||||
await this.get(id, user);
|
await this.get(id, user);
|
||||||
|
await this.assertVersion(id, dto.baseUpdatedAt);
|
||||||
const { count } = await this.prisma.checklistItem.updateMany({
|
const { count } = await this.prisma.checklistItem.updateMany({
|
||||||
where: { id: itemId, workOrderId: id },
|
where: { id: itemId, workOrderId: id },
|
||||||
data: {
|
data: {
|
||||||
@@ -324,6 +334,7 @@ export class WorkOrdersService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (count === 0) throw new NotFoundException('Tâche inconnue');
|
if (count === 0) throw new NotFoundException('Tâche inconnue');
|
||||||
|
await this.toucher(id);
|
||||||
const item = await this.prisma.checklistItem.findUniqueOrThrow({
|
const item = await this.prisma.checklistItem.findUniqueOrThrow({
|
||||||
where: { id: itemId },
|
where: { id: itemId },
|
||||||
include: { doneBy: true },
|
include: { doneBy: true },
|
||||||
@@ -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 —————
|
// ————— mapping —————
|
||||||
|
|
||||||
private toSummary(
|
private toSummary(
|
||||||
@@ -387,6 +459,22 @@ export class WorkOrdersService {
|
|||||||
}
|
}
|
||||||
const versValeur = (v: { id: string; label: string } | null) =>
|
const versValeur = (v: { id: string; label: string } | null) =>
|
||||||
v ? { id: v.id, label: v.label } : 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 {
|
return {
|
||||||
...this.toSummary(row),
|
...this.toSummary(row),
|
||||||
description: row.description,
|
description: row.description,
|
||||||
@@ -430,8 +518,55 @@ export class WorkOrdersService {
|
|||||||
componentConcerned: versValeur(report.componentConcerned),
|
componentConcerned: versValeur(report.componentConcerned),
|
||||||
}
|
}
|
||||||
: null,
|
: 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],
|
allowedTransitions: WORK_ORDER_TRANSITIONS[row.status as WorkOrderStatus],
|
||||||
closureBlockers: blockers,
|
closureBlockers: blockers,
|
||||||
|
updatedAt: row.updatedAt.toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Verrou optimiste (D2 mobile) : si l'appelant fournit la version lue et
|
||||||
|
* que l'OT a changé depuis, 409 avec le contexte (qui, quand) — rien
|
||||||
|
* n'est écrasé en silence. Sans version fournie (web), comportement
|
||||||
|
* inchangé. */
|
||||||
|
private async assertVersion(id: string, baseUpdatedAt?: string): Promise<void> {
|
||||||
|
if (!baseUpdatedAt) return;
|
||||||
|
const wo = await this.prisma.workOrder.findUniqueOrThrow({
|
||||||
|
where: { id },
|
||||||
|
select: {
|
||||||
|
updatedAt: true,
|
||||||
|
events: {
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 1,
|
||||||
|
include: { by: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (wo.updatedAt.getTime() > new Date(baseUpdatedAt).getTime()) {
|
||||||
|
const dernier = wo.events[0];
|
||||||
|
const qui = dernier?.by?.displayName ?? 'quelqu’un';
|
||||||
|
const quand = new Intl.DateTimeFormat('fr-FR', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}).format(wo.updatedAt);
|
||||||
|
throw new ConflictException(
|
||||||
|
`Conflit de version : cet OT a été modifié par ${qui} à ${quand} — votre saisie n'a pas été appliquée.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toute écriture « secondaire » (coche, bilan, commentaire, coûts) fait
|
||||||
|
* avancer la version de l'OT — sinon le verrou D2 ne verrait rien. */
|
||||||
|
private async toucher(id: string): Promise<void> {
|
||||||
|
await this.prisma.workOrder.update({ where: { id }, data: { updatedAt: new Date() } });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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}` });
|
const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
|
||||||
|
|
||||||
it('recette : demande → approbation → OT assigné → bilan → clôture → suivi', async () => {
|
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 { 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()
|
const demande = await http()
|
||||||
.post('/requests')
|
.post('/requests')
|
||||||
.set(auth(karim))
|
.set(auth(karim))
|
||||||
@@ -140,12 +141,20 @@ describe('Exploitation (e2e)', () => {
|
|||||||
.set(auth(salma))
|
.set(auth(salma))
|
||||||
.expect(200);
|
.expect(200);
|
||||||
const position = listeSalma.workOrders.findIndex((w: { id: string }) => w.id === id);
|
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).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
|
// 7. Terminal : plus aucune transition
|
||||||
await http()
|
await http()
|
||||||
@@ -226,8 +235,9 @@ describe('Exploitation (e2e)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('rejet : motif obligatoire ; bilan : valeur hors champ refusée', async () => {
|
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 { 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()
|
const demande = await http()
|
||||||
.post('/requests')
|
.post('/requests')
|
||||||
.set(auth(karim))
|
.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