mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
Compare commits
56 Commits
release/r0
...
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 | ||
|
|
3aa7189e96 | ||
|
|
f7c0da1b0f | ||
|
|
50254daea4 | ||
|
|
6a2dae1710 | ||
|
|
902efa6192 | ||
|
|
f7702e4252 | ||
|
|
54d926e9f4 | ||
|
|
c4d1ab7225 | ||
|
|
9778629827 | ||
|
|
266ffaaf1b | ||
|
|
6d9aafdab4 |
119
.github/workflows/ci.yml
vendored
119
.github/workflows/ci.yml
vendored
@@ -44,14 +44,15 @@ jobs:
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm --filter @siop/shared build
|
||||
- name: Régénérer la spec et le client typé
|
||||
- name: Régénérer la spec et les clients typés
|
||||
run: |
|
||||
pnpm --filter @siop/shared contract
|
||||
pnpm --filter @siop/web generate:client
|
||||
pnpm --filter @siop/mobile generate:client
|
||||
- name: Vérifier qu'aucun artefact ne dérive du contrat
|
||||
run: |
|
||||
if ! git diff --exit-code -- docs/openapi.json apps/web/src/api/schema.d.ts; then
|
||||
echo "::error::Contrat désynchronisé — régénérez spec et clients dans le même commit (pnpm contract && pnpm --filter @siop/web generate:client)."
|
||||
if ! git diff --exit-code -- docs/openapi.json apps/web/src/api/schema.d.ts apps/mobile/src/api/schema.d.ts; then
|
||||
echo "::error::Contrat désynchronisé — régénérez spec et clients dans le même commit (pnpm contract && pnpm --filter @siop/web generate:client && pnpm --filter @siop/mobile generate:client)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -59,18 +60,8 @@ jobs:
|
||||
name: api (tests + couverture ≥ 70 %)
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
# R0 : PostgreSQL nu suffit (r0_identity). Dès que des tests exigeront
|
||||
# pgvector/PostGIS (R1+), passer sur l'image infra/postgres.
|
||||
postgres:
|
||||
image: postgres:18
|
||||
env:
|
||||
POSTGRES_USER: siop
|
||||
POSTGRES_PASSWORD: siop
|
||||
POSTGRES_DB: siop
|
||||
ports: ['5432:5432']
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U siop -d siop"
|
||||
--health-interval 5s --health-timeout 3s --health-retries 10
|
||||
# PostgreSQL vient d'infra/postgres (pgvector + PostGIS — R5) : les
|
||||
# services ne savent pas builder, il démarre donc par étape ci-dessous.
|
||||
redis:
|
||||
image: redis:7.4-alpine
|
||||
ports: ['6379:6379']
|
||||
@@ -81,8 +72,25 @@ jobs:
|
||||
DATABASE_URL: postgresql://siop:siop@localhost:5432/siop
|
||||
REDIS_URL: redis://localhost:6379
|
||||
JWT_SECRET: ci-only-secret-0123456789abcdef
|
||||
MINIO_ENDPOINT: localhost
|
||||
MINIO_ACCESS_KEY: siop
|
||||
MINIO_SECRET_KEY: siop-minio
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Démarrer PostgreSQL (image infra — pgvector + PostGIS, comme partout)
|
||||
run: |
|
||||
docker build -t siop2/postgres infra/postgres
|
||||
docker run -d --name postgres -p 5432:5432 \
|
||||
-e POSTGRES_USER=siop -e POSTGRES_PASSWORD=siop -e POSTGRES_DB=siop \
|
||||
siop2/postgres
|
||||
for i in $(seq 1 30); do
|
||||
docker exec postgres pg_isready -U siop -d siop && break; sleep 2;
|
||||
done
|
||||
- name: Démarrer MinIO (l'image officielle exige une commande — pas un service)
|
||||
run: |
|
||||
docker run -d --name minio -p 9000:9000 \
|
||||
-e MINIO_ROOT_USER=siop -e MINIO_ROOT_PASSWORD=siop-minio \
|
||||
minio/minio:RELEASE.2025-09-07T16-13-09Z server /data
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
@@ -110,20 +118,40 @@ jobs:
|
||||
- run: pnpm --filter @siop/web test
|
||||
- run: pnpm --filter @siop/web build
|
||||
|
||||
mobile:
|
||||
name: mobile (typecheck + tests jest-expo)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm --filter @siop/shared build
|
||||
- run: pnpm --filter @siop/mobile typecheck
|
||||
- run: pnpm --filter @siop/mobile test
|
||||
|
||||
ai:
|
||||
name: ai (pytest + ruff — uv)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: astral-sh/setup-uv@v5
|
||||
- run: uv sync
|
||||
working-directory: apps/ai
|
||||
- run: uv run ruff check src tests
|
||||
working-directory: apps/ai
|
||||
- run: uv run pytest
|
||||
working-directory: apps/ai
|
||||
|
||||
e2e:
|
||||
name: e2e (parcours démo Playwright)
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:18
|
||||
env:
|
||||
POSTGRES_USER: siop
|
||||
POSTGRES_PASSWORD: siop
|
||||
POSTGRES_DB: siop
|
||||
ports: ['5432:5432']
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U siop -d siop"
|
||||
--health-interval 5s --health-timeout 3s --health-retries 10
|
||||
# PostgreSQL vient d'infra/postgres (pgvector + PostGIS — R5) : les
|
||||
# services ne savent pas builder, il démarre donc par étape ci-dessous.
|
||||
redis:
|
||||
image: redis:7.4-alpine
|
||||
ports: ['6379:6379']
|
||||
@@ -134,8 +162,25 @@ jobs:
|
||||
DATABASE_URL: postgresql://siop:siop@localhost:5432/siop
|
||||
REDIS_URL: redis://localhost:6379
|
||||
JWT_SECRET: ci-only-secret-0123456789abcdef
|
||||
MINIO_ENDPOINT: localhost
|
||||
MINIO_ACCESS_KEY: siop
|
||||
MINIO_SECRET_KEY: siop-minio
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Démarrer PostgreSQL (image infra — pgvector + PostGIS, comme partout)
|
||||
run: |
|
||||
docker build -t siop2/postgres infra/postgres
|
||||
docker run -d --name postgres -p 5432:5432 \
|
||||
-e POSTGRES_USER=siop -e POSTGRES_PASSWORD=siop -e POSTGRES_DB=siop \
|
||||
siop2/postgres
|
||||
for i in $(seq 1 30); do
|
||||
docker exec postgres pg_isready -U siop -d siop && break; sleep 2;
|
||||
done
|
||||
- name: Démarrer MinIO (l'image officielle exige une commande — pas un service)
|
||||
run: |
|
||||
docker run -d --name minio -p 9000:9000 \
|
||||
-e MINIO_ROOT_USER=siop -e MINIO_ROOT_PASSWORD=siop-minio \
|
||||
minio/minio:RELEASE.2025-09-07T16-13-09Z server /data
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
@@ -146,8 +191,30 @@ jobs:
|
||||
- run: pnpm --filter @siop/api prisma:generate
|
||||
- run: pnpm --filter @siop/api exec prisma migrate deploy
|
||||
- run: pnpm --filter @siop/api build
|
||||
- uses: astral-sh/setup-uv@v5
|
||||
- name: Démarrer le service IA (R5 — embeddeur déterministe, seuils CI)
|
||||
working-directory: apps/ai
|
||||
env:
|
||||
AI_EMBEDDINGS: deterministe
|
||||
AI_SERVICE_TOKEN: dev-only-ai-token
|
||||
# L'embeddeur déterministe (tri-grammes hachés) produit des scores
|
||||
# bien plus bas que le vrai modèle : seuils calibrés sur mesures
|
||||
# réelles (vrai match ≈ 0,66 ; bruit de collisions ≈ 0,11 ; codes
|
||||
# de bilan pertinents ≈ 0,14-0,16 ; parasites ≈ 0,08).
|
||||
AI_SEUIL_PERTINENCE: '0.20'
|
||||
AI_SEUIL_SUGGESTION: '0.10'
|
||||
AI_SEUIL_CONFIANCE_FORTE: '0.30'
|
||||
run: |
|
||||
uv sync
|
||||
nohup uv run uvicorn siop_ai.app:app --host 127.0.0.1 --port 8000 > /tmp/siop-ai.log 2>&1 &
|
||||
for i in $(seq 1 30); do
|
||||
curl -fsS http://127.0.0.1:8000/healthz && break; sleep 1;
|
||||
done
|
||||
- run: pnpm --filter @siop/web exec playwright install --with-deps chromium
|
||||
- run: pnpm --filter @siop/web e2e
|
||||
- name: Journal du service IA en cas d'échec
|
||||
if: failure()
|
||||
run: cat /tmp/siop-ai.log || true
|
||||
- name: Traces Playwright en cas d'échec
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -164,7 +231,7 @@ jobs:
|
||||
deploy:
|
||||
name: deploy (Dokploy — siop2.apps.enset.top)
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
needs: [lint, contract, api, web, e2e]
|
||||
needs: [lint, contract, api, web, mobile, ai, e2e]
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
steps:
|
||||
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -2,8 +2,17 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
# apps/mobile/.env ne porte aucun secret — un réglage de build (fetch RN
|
||||
# classique, cf. ADR-005) qui doit être le même pour tout le monde.
|
||||
!apps/mobile/.env
|
||||
.turbo/
|
||||
coverage/
|
||||
*.tsbuildinfo
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
||||
# Python (apps/ai)
|
||||
.venv/
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
|
||||
52
CLAUDE.md
52
CLAUDE.md
@@ -10,7 +10,7 @@ Projet **réel** (SPELEV, maintenance d'ascenseurs, Maroc) mené par le Pr. Daai
|
||||
|
||||
1. **Design-first** : aucune ligne de code applicatif avant validation par le référent de la charte graphique, des tokens et des maquettes HD (`docs/02-design/`). Chaque release subit une revue « pixel » écrans ↔ maquettes.
|
||||
2. **Playbook vivant** : chaque phase du cycle de vie a son dossier `docs/0X-*/` (template réutilisable + artefacts réels). Le journal quotidien vit dans `docs/journal/` — **jamais** dans le livre.
|
||||
3. **Périmètre fermé par release** : R0 Fondations → R1 Référentiel → R2 Exploitation → R3 Gestion → R4 Mobile → R5 IA. On n'ouvre pas Rn+1 avant recette **et** déploiement de Rn.
|
||||
3. **Périmètre fermé par release** : R0 Fondations → R1 Référentiel → R2 Exploitation → R3 Gestion → R4 Mobile → R5 IA → R6 Mobile tous rôles. On n'ouvre pas Rn+1 avant recette **et** déploiement de Rn.
|
||||
4. **Déployer tôt** : chaque release part sur le serveur de production (Dokploy) dès sa recette.
|
||||
5. **Le développeur est le premier utilisateur** : `DEMO_MODE=true` active un **sélecteur de compte démo** (connexion 1 clic sur les comptes seedés, endpoint `POST /auth/demo-login` strictement absent si l'env ne l'active pas). Critère : changer de rôle en < 3 s sans mot de passe.
|
||||
|
||||
@@ -39,6 +39,50 @@ pnpm + Turborepo. `apps/api` : NestJS, Prisma, PostgreSQL (pgvector + PostGIS),
|
||||
- ✅ **R0.11 `apps/web`** : React 19 + Vite + Tailwind v4, `tokens.css` copié tel quel + classes extraites de la maquette validée, Manrope auto-hébergée ; connexion + sélecteur démo (masqué si 404 — une seule source de vérité : l'API), coquille sidebar (rail safran, écrans futurs marqués R1-R3) / topbar (thème, chip DÉMO, sélecteur de rôle), page /design ; client typé `schema.d.ts` généré depuis `docs/openapi.json` et committé ; vérifié en navigateur réel (bascule de rôle 101 ms, bi-thème, 0 erreur console).
|
||||
- ✅ **R0.12 — tests + CI** (5 jobs verts) : `lint` (ESLint 10 flat config racine, règle ADR-001 anti-import MinIO codée et vérifiée), `ci-contract` (régénération spec+client, diff bloquant), `api` (PostgreSQL 18 + Redis, migrate deploy, Jest couverture ≥ 70 % bloquante — mesurée 97,5 %), `web` (typecheck + vitest + build), `e2e` (Playwright : parcours démo, bascule < 3 s chronométrée, déconnexion). Badge au README.
|
||||
- ✅ **R0.13 — Dockerfiles + runbook Dokploy** : image api (multi-stage pnpm deploy, `prisma migrate deploy` au boot, seed optionnel `SEED_ON_START`, non-root, healthcheck ; `binaryTargets` explicites) ; image web (nginx, proxy `/api` résolu à la requête, fallback SPA, cache assets) ; `infra/docker-compose.dokploy.yml` (5 services `siop2-`, seul le web sur `dokploy-network`) ; runbook `docs/06-production/runbook-dokploy.md`. **Répétition locale complète validée** (migrate+seed au boot, parcours via nginx conteneurisé, double verrou ADR-002 observé). Le déploiement réel attend les accès au serveur du partenaire.
|
||||
- 🚀 **R0 EN PRODUCTION (16/07/2026)** : `https://siop2.apps.enset.top` (Dokploy ENSET, profil démo, vérifiée : health ok, 7 comptes, demo-login, 401 sans jeton, HTTPS). La production client SPELEV attend les accès au serveur du partenaire.
|
||||
- 🔄 **R0 — reste** : secret `DOKPLOY_WEBHOOK_URL` pour activer le CD (runbook §4), sauvegardes PostgreSQL Dokploy, recette R0 avec le référent (revue pixel écrans ↔ maquettes) → ouverture de R1 Référentiel.
|
||||
- Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0→R5.
|
||||
- ✅ **R0 CLOSE** (tag `release/r0`) — en production : `https://siop2.apps.enset.top` (Dokploy ENSET, profil démo, vérifiée en ligne). Restes non bloquants : secret `DOKPLOY_WEBHOOK_URL` (CD), sauvegardes PostgreSQL Dokploy. Production client SPELEV : attend les accès au serveur du partenaire.
|
||||
- ✅ **R1 — maquettes validées** (16/07) : `maquette-r1.html`, 7 écrans + 4 décisions (statuts d'équipement ≠ statuts OT ; organe sans emplacement par construction ; invitation par lien 7 j ; catégories jamais supprimées si utilisées).
|
||||
- ✅ **R1.1 — socle backend** : migration `r1_referentiel` (Category, Location site→zone + PostGIS générée, Asset, AssetComponent, Team, invitation User), 21 nouvelles opérations au contrat (26 total), 4 modules API + invitations/activation sous `@RequirePermission`, seed parc maquette (5 sites, 8 appareils, organes, 2 équipes), 36 tests (96 %/85 %), CI sur `postgis/postgis:18-3.6`.
|
||||
- ✅ **R1.2 — écrans web du référentiel** : 7 écrans fidèles à maquette-r1.html (Sites + carte Leaflet/OSM, fiche site, ascenseurs, fiche appareil + QR réel, création, étiquette A6 imprimable, personnes & équipes avec lien d'activation à copier, catégories) + page /activation ; navigation et actions pilotées par la matrice (`usePermissions`) ; recette R1 rejouée en e2e Playwright (dont activation d'un invité) ; alias Vite `@siop/shared` → source TS (leçon CJS/workspace).
|
||||
- 🏁 **R1 CLOSE (16/07/2026, tag `release/r1`)** : recettée par le référent, déployée et vérifiée en ligne (migration + seed au boot, 5 sites / 8 appareils sur l'instance).
|
||||
- ✅ **R2 — maquettes validées** (16/07) : `maquette-r2.html` (demandes/approbation, nouvel OT, préventif, checklist, compteurs) + 4 décisions actées.
|
||||
- ✅ **R2.1 — socle backend exploitation** : migration `r2_exploitation` (10 tables), 15 opérations au contrat (41 total, transitions + champs requis du bilan dans `@siop/shared`), machine à états stricte avec garde de clôture (bilan 3 champs + checklist), demande→OT 1-1, rejet à motif, scoping « voir autre » (listes + accès directs), seed maquette (31 valeurs de bilan, 8 gabarits, OT/demandes/compteurs), 45 tests (95 %/79 %).
|
||||
- ✅ **R2.2 — préventif + compteurs** : `underContract` + `periodKey` (unicité `[assetId, periodKey]` = idempotence EN BASE), génération mensuelle (ancrage mise en service, premier contrôle, gabarits administrables, statut du mois), compteurs strictement croissants ; +7 opérations (48), 50 tests (94,7 %/78 %). Automatisation cron/BullMQ notée pour le durcissement production.
|
||||
- ✅ **R2.3 — écrans web exploitation** : liste/fiche OT (transitions via `allowedTransitions`, garde visible, bilan codé, checklist cliquable, activité), demandes+approbation/rejet motivé, nouvel OT (interrupteur urgence), préventif (tuiles+générer+gabarits), compteurs, tableau de bord réel, urgence traversante (chip topbar, badges, bandeau) ; `GET /assets/options` (trou Demandeur corrigé) ; retry sur collision de référence dans la génération ; 9 Playwright verts (recette R2 complète), 50 tests API.
|
||||
- 🏁 **R2 CLOSE (16/07/2026, tag `release/r2`)** : recettée (1 anomalie corrigée en recette : tri « Interventions récentes »), déployée et vérifiée en ligne (portail `/q/A1`, urgence en tête, préventif de juillet généré).
|
||||
- ✅ **R3 — maquettes validées** (16/07) + **R3.1 socle backend gestion** : migration `r3_gestion` (Partner, Part sans colonne de quantité, StockMovement signé/tracé/PU figé, PurchaseOrder, LaborTime taux figé, Document, User.hourlyRate) ; 66 opérations ; stock = Σ mouvements (jamais négatif, en transaction), réception BC → RECEIPT + lastUnitPrice, conso/MO à prix/taux FIGÉS, `WorkOrderDetail.costs` immuable après clôture ; seed maquette (OT-0341 = 505 MAD, testé) ; 55 tests (92 %/74,9 %). **Durcissement : références par séquences Postgres** (fin des courses max+1).
|
||||
- ✅ **R3.2 — bibliothèque + analytics** : `FileStorage` complet (put/stream/remove, bucket auto), upload multipart typé (20 Mo, rattachement requis, permission sur la cible), download **streamé par l'API**, octets vérifiés à l'identique en e2e ; `GET /analytics/summary` dérivé du réel (pannes par organe via bilans codés, coûts figés, taux préventif, top équipements) ; MinIO en CI ; 71 opérations, 58 tests (6 runs consécutifs verts).
|
||||
- ✅ **R3.3** : 7 écrans web fidèles à maquette-r3 (stock/alertes, fiche pièce, BC + réception, création BC préremplie depuis l'alerte, tiers, bibliothèque, statistiques) + fiche OT complète (coûts réels : consommer à prix figé / saisir temps à taux figé ; carte Documents) + fiche ascenseur (Documents) + taux horaire dans Personnes + nav Ressources/Statistiques activée par la matrice. Recette R3 automatisée (13/13 e2e Playwright).
|
||||
- ✅ **R3.4 — corrections de recette + recherche globale** (17/07) : revue pixel (artefact) → 8 écarts corrigés sur arbitrage du référent (filtre fournisseur + tri sous-seuil + entrée de stock en liste, fournisseur cliquable, sélecteur de période 3/6/12 sur les stats, rattachements syndic→site via `Location.partnerId` (migration `r3_recette_fixes`), filtre « Rattaché à » + glisser-déposer en bibliothèque, aperçus/ouverture des documents) ; **recherche globale ⌘K** (`GET /search`, 73 opérations, familles filtrées par la matrice, « voir autre » respecté). 74 tests API, 14/14 Playwright.
|
||||
- 🏁 **R3 CLOSE (17/07/2026, tag `release/r3`)** : recettée par le référent (revue pixel + 8 corrections + recherche globale, CI verte). Reste : redéploiement Dokploy (manuel, webhook CD absent) et vérification en ligne.
|
||||
- ✅ **R4 — maquettes rédigées** (17/07) : `maquette-r4.html`, 7 écrans mobile technicien offline-first (Ma journée bi-état, fiche OT, clôture terrain avec garde, scan QR des étiquettes R1, fiche ascenseur, checklist en file, synchro & conflits à verrou optimiste) + 5 décisions à acter (offline-first en file, verrou optimiste tranché par l'humain, périmètre fermé technicien, scan local, photos en file — ni audio ni géoloc en R4).
|
||||
- ✅ **R4 — maquettes + décisions D1-D5 VALIDÉES par le référent (17/07)** ; **R4.1 socle mobile** : `apps/mobile` (Expo SDK 57, TS strict), connexion + sélecteur démo ADR-002, tabbar (onglets futurs marqués), « Ma journée » triée priorité/échéance, cache TanStack persisté (lecture hors-ligne D1), jeton SecureStore, client typé du contrat, `CORS_ORIGINS` opt-in côté API (Expo web/debug seulement), 6 tests jest-expo + job CI `mobile`. Vérifié 10/10 en Expo web (connexion démo → Ma journée → hors-ligne/retour).
|
||||
- ✅ **R4.2 — terrain en ligne** : scanner QR réel (expo-camera, `analyseScan` testée, résolution locale D4, QR étrangers refusés), fiche ascenseur (D3, historique scopé), fiche OT (machine à états, coûts figés, garde par `closureBlockers` API), clôture terrain (bilan 6 champs au pouce), préventif → grille cochable (appui long = N/A, `aria-checked`). Écritures en ligne assumées (bandeaux) — la file est R4.3. Vérifié 12/12 en Expo web (dont clôture complète d'un OT de test), 12 tests jest-expo.
|
||||
- ✅ **R4.3 — file & verrou** : verrou optimiste serveur (`baseUpdatedAt` → 409 contextualisé, toute écriture avance la version, web inchangé, ADR-003 sécurité/routage mobile) ; file persistée rejouée dans l'ordre (propagation de version intra-lot, arrêt sur conflit, patchs optimistes, photos D5 compressées en file), écran Synchro & conflits (rejouer sur version à jour / abandonner), préchargement parc+référentiels, purge complète à la déconnexion. Recette « mode avion » 13/13 en Expo web (conflit tranché par l'humain, serveur DONE + bilan), 17 tests jest-expo, 74 tests API.
|
||||
- 🏁 **R4 CLOSE (17/07/2026, tag `release/r4`)** : posé sur décision du référent avec la recette « mode avion » validée 13/13 en Expo web piloté ; **la recette sur téléphone (Expo Go — vrai mode avion, scan caméra) est reportée et reste due avant toute production client mobile**.
|
||||
- ✅ **R5 — maquettes rédigées** (17/07) : `maquette-r5.html`, 6 écrans (assistant RAG à citations sources document/page/extrait, refus explicite hors corpus, suggestion de bilan web+mobile à validation humaine, corpus & ingestion administrable, voix opt-in avec purge) + 5 décisions à acter (aucune écriture automatique, sourcé ou silencieux, corpus fermé, anonymisation 09-08, voix purgée).
|
||||
- ✅ **R5 — maquettes + décisions D1-D5 VALIDÉES par le référent (17/07)** ; **R5.1 socle `apps/ai`** : ADR-004 (embeddings locaux fastembed 384d, pgvector via migration Prisma `r5_ia` (`RagChunk` + corpus sur Document), génération opt-in — mode extractif par défaut, service jamais exposé joint par l'API seule), pipeline d'ingestion anonymisé D4 (fonction pure testée, PDF paginés + bilans codés), `/internal/reindex` + `/internal/search` sous jeton de service, 14 pytest + ruff + job CI `ai` (embeddeur déterministe en CI). Vérifié en réel : corpus seedé indexé en 7 s, recherche sémantique concluante, 0 identité dans les chunks.
|
||||
- ✅ **R5.1+ génération opt-in** : `Generateur` ADR-004 §3 — extractif par défaut, `AI_GENERATION=api` + `AI_API_KEY` (exigée au boot, jamais loguée ; Dokploy secrets) + `AI_MODEL` (défaut claude-opus-4-8), SDK anthropic en extra optionnel, citations [n] obligatoires, repli extractif sur tout échec (dont `stop_reason=refusal`). **R5.2 assistant au contrat** : `POST /assistant/ask` (WORK_ORDERS.view) + `POST /assistant/suggest-bilan` (WORK_ORDERS.edit) proxifiés par NestJS (traduction dialecte interne → contrat, 503 propre), suggestion = codes EXISTANTS seulement (confiance + « N bilans similaires »), normalisation fastembed corrigée (débusquée en chaîne réelle), 23 pytest, tests API sur stub HTTP.
|
||||
- ✅ **R5.3 — écrans IA** : page web Assistant (chat sourcé, refus honnête chiffré, « Ouvrir » vers PDF/OT), Bibliothèque = corpus administrable (statut d'indexation par document, interrupteur d'exclusion, « Réindexer tout », bandeau 09-08), suggestions fiche OT (« Appliquer » = geste humain, liseré « suggéré » retiré au choix manuel) et clôture mobile (chips, « réseau requis » hors-ligne) ; contrat 76 opérations (corpus sur Document, `PATCH /documents/{id}/corpus`, `POST /assistant/reindex` — ci-contract vérifie aussi le client mobile) ; seuils `AI_SEUIL_*` par env ; job e2e CI avec `siop2-ai` (embeddeur déterministe, seuils calibrés sur mesures réelles), 16/16 Playwright, 78 tests API ; chaîne vérifiée au vrai modèle ONNX (web 7/7, mobile Expo web 6/6, 0 erreur console).
|
||||
- ✅ **Recette R5 sans clé API + durcissement** (17/07) : la recette a invalidé MiniLM-384 (page-réponse classée derrière des passages sans rapport, 0,24 vs 0,41) → **bascule mesurée vers `paraphrase-multilingual-mpnet-base-v2` 768 d** (ADR-004 amendé, migration `r5_embeddings_mpnet`, découpage ~350 car., seuils 0,45/0,40/0,55) ; recette type ✓ (réponse sourcée p. 2, refus honnête, D1-D5, tout en extractif) ; revue pixel publiée (artefact, 3 arbitrages) ; Dockerfile `siop2-ai` (modèle au build, non-root), compose Dokploy (service interne, `AI_SERVICE_TOKEN` requis, génération opt-in), runbook §5-6 (service IA, calibrage seuils client, réindexation post-déploiement).
|
||||
- ✅ **Revue pixel R5 validée par le référent (17/07)** — 1 correction appliquée sur arbitrage : la Bibliothèque-corpus passe en **tableau** (Document/Rattaché à/Indexation/Corpus + actions ; interrupteur éteint pour les non-indexables), vignettes conservées sur les cartes Documents des fiches ; « Appliquer » direct et calibrage au runbook validés tels quels. 16/16 Playwright rejoués.
|
||||
- 🏁 **R5 CLOSE (17/07/2026, tag `release/r5`)** : recettée par le référent (revue pixel + arbitrage tableau), recette passée SANS clé API, durcissement répété en local conteneurisé. **R0 → R5 : périmètre v1 couvert.**
|
||||
- ✅ **Recette terrain mobile sur iPhone 15 Pro — 7/7 validés (19/07, ADR-005)** : Expo Go bloqué par le retard d'approbation Apple (SDK 54 vs notre SDK 57) → **décision : builds natifs locaux (Xcode/Android Studio, signature gratuite) pour la vraie recette, Expo Go conservé pour l'aperçu sans installation**. 5 obstacles techniques réels corrigés et documentés (UDID plutôt que nom, ne jamais contourner `expo run:*` par un xcodebuild manuel, modules Expo/RN précompilés SDK 56/57 incompatibles avec la liaison statique du projet, `fetch` global incompatible avec l'upload multipart natif — fix permanent `EXPO_PUBLIC_USE_RN_FETCH=1` dans `apps/mobile/.env`, découverte réseau du dev client peu fiable). Recette iOS : connexion, scan caméra réel (résolution + rejet QR étranger), vrai mode avion, conflit D2 tranché par l'humain avec photo réellement téléversée, persistance à travers fermeture/reconstruction de l'app, suggestions R5 au vrai modèle, purge de sécurité au changement de compte — tout validé. Android : build natif sur émulateur, passage santé complet.
|
||||
- ✅ **Dictée implémentée (22/07, ADR-004 §5)** : écran Voix R5 (jamais construit) — `faster-whisper` local (CTranslate2, CPU, opt-in `AI_TRANSCRIPTION=off|locale`, défaut off), audio jamais persisté (purge serveur ET mobile quoi qu'il arrive), transcription → relecture humaine → suggestion immédiate et/ou sauvegarde dans `InterventionReport.note` (champ existant depuis R2, jamais eu d'écran jusqu'ici) → `note` rejoint le corpus à la clôture comme les bilans codés. Mobile : `expo-audio`/`expo-file-system`, bouton dicter sur l'écran de clôture. Docker `siop2-ai` embarque le modèle (1,54→2,19 Go). Vérifié réellement (voix de synthèse → texte fidèle, bout en bout API, conteneur Docker construit, chaîne corpus complète note→clôture→réindexation→recherche). 80 tests API, 29 pytest ai, mobile vert.
|
||||
- ✅ **Dictée validée sur iPhone physique (01/08, ADR-004 §5)** : le blocage USB du 22/07 était un faux négatif de `system_profiler` (outil de diagnostic en panne, pas le câble). Deux bugs réels trouvés et corrigés : `expo-audio` iOS exige `setAudioModeAsync({ allowsRecording: true })` avant `record()` (absent, jamais détecté hors appareil réel) ; le correctif des modules précompilés (ADR-005) n'avait jamais été rendu permanent dans `apps/mobile/.env` — corrigé. Chaîne complète vérifiée en vrai : micro → dictée → transcription fidèle → note → clôture → réindexation → **retrouvé par la recherche sémantique**.
|
||||
- fix(mobile) **déconnexion accessible depuis tous les onglets (02/08)** : bug remonté par le référent (une fois authentifié, aucun moyen de revenir en arrière ou de se déconnecter — seule « Ma journée » portait ce contrôle, via un `onLongPress` non découvrable). `EnteteTabs` (`composants/ui.tsx`) factorise une entête commune aux 4 onglets terrain, tap simple + confirmation, rôle affiché dynamique.
|
||||
- ✅ **R6 — maquette validée (02/08)** : `maquette-mobile-tous-roles.html`, 7 écrans (synthèse par rôle, Accueil adaptatif, Menu groupé, OT côté Dispatcher, Demandes, Stock, Personnes & statistiques) + 5 décisions actées — D1 barre d'onglets adaptative (Technicien/Technicien limité inchangés + onglet Menu) ; D2 le Menu reprend à l'identique les 4 groupes du web (même matrice, aucune règle nouvelle) ; D3 groupe sans lien visible masqué en entier ; D4 le mobile porte les actions courantes par famille, pas les flux de gestion les plus denses (réservés au web) ; D5 aucune logique de permission propre au mobile. Nouvelle release (R4 est close et R4.1/4.2/4.3 déjà pris par le socle mobile technicien d'origine — pas de réouverture, pas de collision de numérotation).
|
||||
- ✅ **R6.1 — socle** : `usePermissions()` mobile (calqué sur le web, lit `me.permissions`) ; barre d'onglets adaptative par rôle (`ongletsVisibles`, `href: null` masque sans retirer du navigateur) ; écrans Accueil (dashboard réel pour Administrateur/Gestionnaire/Dispatcher/Vue seule, aucun chiffre inventé), OT (liste complète `viewOther`, réutilise la fiche OT R4 telle quelle), Menu (groupes filtrés par la matrice, groupe vide masqué), Demandes (`PanneauDemandes` — un seul composant pour tous les rôles : création/suivi pour le Demandeur, approbation/rejet à motif pour Gestionnaire/Dispatcher/Administrateur, lecture seule pour Vue seule ; `GET /assets/options`, déjà ouvert à tout rôle authentifié depuis R2.3, réutilisé pour le sélecteur d'équipement). Familles pas encore portées (Sites, Ascenseurs, Stock, Tiers, Fichiers, Statistiques, Assistant, Personnes) : écran « à venir » honnête plutôt qu'un lien mort. Typecheck propre, 17 tests Jest verts, lint 5/5 paquets — pas de vérification visuelle en navigateur de mon côté (aucun outil de ce type dans cet environnement), à confirmer par le référent sur l'iPhone déjà connecté au serveur Metro.
|
||||
- ✅ **R6.2 — Parc (Sites, Ascenseurs)** : `useLocations()` mobile ; écran Sites (premier niveau, `parentId===null`) ; fiche site (identité, zones, ascenseurs du site — consultation seule, D4, pas de carte ni d'édition sur mobile) ; écran Ascenseurs (parc complet déjà préchargé, D1) ouvrant la fiche appareil R4 telle quelle (générique, aucune modification nécessaire). Menu : Ascenseurs/Sites routent réellement, Catégories reste à venir (admin, hors périmètre). Typecheck propre, 17 tests Jest, lint 5/5 — exécution directe de la maquette déjà validée, pas de nouveau tour de design.
|
||||
- ✅ **R6.3 — Ressources (Stock, Tiers, Fichiers)** : `api/ressources.ts` (usePartners/useParts/usePart/useCreatePurchaseOrder/useDocuments) ; écran Stock (sous-seuil en tête) + fiche pièce + « Commander » pré-rempli en une ligne (fournisseur figé, quantité = manquant jusqu'au seuil, prix = dernier connu — le BC multi-lignes détaillé reste au web, D4) ; Tiers en lecture seule (création/édition réservées au web) ; Fichiers en métadonnées seules — l'ouverture/téléchargement demande `expo-sharing` (dépendance native absente, donc un nouveau build) et est explicitement différée plutôt qu'ajoutée à la légère. Menu branché. Typecheck propre, 17 tests Jest, lint 5/5, contrat non touché (toutes les opérations existaient déjà depuis R3).
|
||||
- ✅ **R6.4 — Pilotage (Statistiques, Personnes)** : `EXPO_PUBLIC_WEB_URL`/`WEB_URL` (lien d'activation vers la page web, pas d'équivalent mobile) ; `api/pilotage.ts` ; écran Statistiques (période 3/6/12, coût du mois, taux préventif, pannes par organe, top équipements — cartes plutôt que graphes, D4) ; écran Personnes & équipes (liste + statut, Inviter, lien d'activation en texte sélectionnable — `expo-clipboard` différée, même raisonnement que `expo-sharing` en R6.3 ; taux horaire/rôles/équipes restent au web). **Assistant reste à venir** dans le Menu — un chat sourcé est un nouveau patron d'écran jamais maquetté sur mobile, contrairement aux autres familles qui réutilisaient Carte/LigneInfo/EnteteFiche déjà validés ; mérite son propre tour de design-first. Typecheck propre, 17 tests Jest, lint 5/5, contrat non touché.
|
||||
- ✅ **Nav adaptative + correctif déconnexion confirmés par le référent sur iPhone physique** — dernier point ouvert depuis le début de R6, clos.
|
||||
- ✅ **R6.5 — Assistant mobile (chat sourcé + dictée)** : maquette dédiée validée (3 écrans, D1-D5 — D5 ajoutée en revue : question tapée OU dictée, même pipeline que la dictée déjà livrée en clôture, transcription remplit le champ, jamais d'envoi automatique). `api/assistant.ts` (503 géré comme le web). Écran Assistant : chat un échange à la fois, citations numérotées, sources avec extrait exact (« Voir le document » → métadonnées seules, R6.3 ; « Ouvrir l'OT » → fiche R4), refus honnête chiffré + Reformuler, avertissement permanent. Fiche document (`bibliotheque/[id].tsx`, nouveau) — la liste R6.3 y mène aussi désormais. **Le Menu R6 n'a plus d'entrée « à venir »** dans les 4 groupes (Catégories exceptée, admin, hors périmètre mobile). Typecheck propre, 17 tests Jest, lint 5/5, contrat non touché.
|
||||
- 🔄 **Recette R6 en cours (Gestionnaire, partie 1)** — 2 bugs réels trouvés et corrigés sur iPhone physique : Menu non scrollable (le dernier groupe, Pilotage, était strictement inaccessible une fois les 4 groupes pleinement câblés — présent depuis R6.1, révélé seulement maintenant) ; BC créé sans confirmation visible dans l'app (vérifié côté serveur : les BC étaient bien créés, juste aucun retour affiché). Amélioration sur retour direct : Tiers gagne une fiche détail (identité/contact/BC en cours/sites rattachés) — le lecture-seule sans aucune réaction au tap se lisait comme cassé. Trois signalements vérifiés et écartés (faux positifs) : approbation de demande (a fonctionné), bibliothèque vide (confirmé côté serveur — aucun document sur cette instance, pas un bug mobile), période statistiques (transmise et traitée correctement, les indicateurs affichés ne varient juste pas avec ce jeu de données).
|
||||
- ✅ **Assistant mobile — dictée confirmée sur iPhone physique**, après 3 corrections trouvées en conditions réelles (aucune n'aurait été vue par typecheck/tests/lint) : `expo-file-system` deleteAsync déprécié SDK 57 (import `/legacy`, appliqué aussi à `cloture.tsx`) ; texte transcrit pas entièrement visible (zone multiligne pleine largeur, Envoyer en geste séparé) ; bouton Envoyer chevauchant encore le texte (ScrollView du chat sans `style={flex:1}`, TextInput à hauteur fixe plutôt que `maxHeight` seul, pas fiable sur iOS).
|
||||
- ✅ **R6.6 — Demandeur restreint à son site** : trou trouvé en recette — `GET /assets/options` n'avait aucun filtre (web ET mobile touchés, pas seulement mobile). Corrigé à la racine : relation `User↔Location` (`assignedSites`, migration `r6_demandeur_sites`, vide = aucune restriction pour les autres rôles) ; `AssetsService.allowedLocationIds()` réutilisée par `options()` et par `RequestsService.create` (défense en profondeur, 400 si hors périmètre) ; gestion des sites d'un Demandeur côté web (`personnes.tsx`, invitation + modale dédiée) ; raccourci scan QR côté mobile (`formulaire-demande.tsx`, résout uniquement contre les options déjà filtrées, jamais de repli sur le parc complet). Karim Doukkali (démo) rattaché à Tour Atlas. Bug trouvé en vérification avant tout commit (comparaison id-de-site vs id-d'appareil dans `create()`, aurait rejeté à tort tout signalement d'un Demandeur affecté) et corrigé, méthode renommée `allowedAssetIds` → `allowedLocationIds` pour que le nom dise ce qu'elle retourne. 79/80 tests API (le seul échec est le flake `monthCost` déjà connu, sans rapport) ; typecheck/tests/lint verts sur les 4 paquets.
|
||||
- ✅ **R6.7 — assignation à l'approbation (mobile)** : le Gestionnaire approuvait une demande mobile sans pouvoir assigner de technicien (l'OT partait non assigné) — « ce n'est pas à lui d'agir comme un technicien ». `PanneauDemandes` gagne un panneau d'approbation avec `ChoixTel` « Assigner à » (techniciens actifs, même filtre que le web), avant confirmation. Rien à changer côté API (`assigneeIds` déjà supporté). Vérifié de bout en bout (demande → approuvée avec assigné → OT avec `assignees` correct). Typecheck/tests/lint verts.
|
||||
- ✅ **Périmètre `WORK_ORDERS` du Gestionnaire confirmé acceptable tel quel par le référent** — l'accès complet hérité de R2/R3 (peut techniquement démarrer/clôturer n'importe quel OT) reste en l'état, aucun resserrement demandé.
|
||||
- ✅ **R6.8 — la connexion routait toujours vers « Ma journée »** : `connexion.tsx` faisait `router.replace('/(tabs)/journee')` en dur — la redirection par rôle (`ongletAccueil`) n'était branchée qu'à l'aiguillage initial (R6.1), pas au retour de connexion, le chemin réellement emprunté à chaque bascule de compte démo (pas de sélecteur de rôle en direct sur mobile, changer de compte = se déconnecter puis se reconnecter). Karim (Demandeur) atterrissait sur l'écran du Technicien. `useLogin()`/`useDemoLogin()` renvoient maintenant la réponse complète (rôle inclus) ; `entrer(role)` route vers le bon onglet. Sous-titre « Technicien »/accroche terrain de l'écran de connexion (reste de R4) généricisés au passage. Typecheck/tests/lint verts.
|
||||
- ✅ **Recette R6 confirmée sur iPhone physique** — Gestionnaire (Accueil, OT, Menu complet, Assistant+dictée) et Demandeur (Accueil, Nouvelle demande filtrée à son site, scan QR) tous deux vérifiés en conditions réelles, au-delà du typecheck/tests/lint.
|
||||
- 🔄 **Reprise ici** : à trancher avec le référent — considérer R6 close (tag `release/r6`, comme R0→R5) ou poursuivre sur un reste identifié (Vue seule/Dispatcher/Administrateur non explicitement recettés sur iPhone, `expo-sharing`/`expo-clipboard` en réserve, recette Android sur appareil physique). Restes non bloquants inchangés : redéploiement Dokploy de l'instance ENSET (`AI_SERVICE_TOKEN` à créer — runbook §2 — puis « Réindexer tout »), calibrage `AI_SEUIL_*` et qualité darija sur corpus SPELEV réel, secret `DOKPLOY_WEBHOOK_URL`, production client SPELEV (attend les accès serveur du partenaire).
|
||||
- Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0→R5 (v1) + R6 en cours.
|
||||
|
||||
9
apps/ai/.dockerignore
Normal file
9
apps/ai/.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
||||
.venv
|
||||
.pytest_cache
|
||||
.ruff_cache
|
||||
__pycache__
|
||||
tests
|
||||
README.md
|
||||
.env
|
||||
.env.example
|
||||
Dockerfile
|
||||
20
apps/ai/.env.example
Normal file
20
apps/ai/.env.example
Normal file
@@ -0,0 +1,20 @@
|
||||
# Copier en .env pour le dev local (défauts alignés sur infra/docker-compose.yml).
|
||||
DATABASE_URL=postgresql://siop:siop@localhost:5432/siop
|
||||
MINIO_ENDPOINT=localhost
|
||||
MINIO_PORT=9000
|
||||
MINIO_ACCESS_KEY=siop
|
||||
MINIO_SECRET_KEY=siop-minio
|
||||
MINIO_BUCKET=siop2
|
||||
|
||||
# Seule l'API NestJS connaît ce secret (ADR-004 §4 — service jamais public).
|
||||
AI_SERVICE_TOKEN=dev-only-ai-token
|
||||
|
||||
# Embeddings : locale (fastembed ONNX, CPU) | deterministe (tests/CI)
|
||||
AI_EMBEDDINGS=locale
|
||||
|
||||
# Génération (ADR-004 §3) : off = mode extractif (défaut — la recette passe
|
||||
# sans clé) | api = rédaction par le LLM sur textes déjà anonymisés (D4).
|
||||
# AI_GENERATION=api exige AI_API_KEY (le boot refuse sinon).
|
||||
AI_GENERATION=off
|
||||
AI_API_KEY=
|
||||
AI_MODEL=claude-opus-4-8
|
||||
44
apps/ai/Dockerfile
Normal file
44
apps/ai/Dockerfile
Normal file
@@ -0,0 +1,44 @@
|
||||
# SIOP V2 — image du service IA (siop2-ai, ADR-004).
|
||||
# Contexte de build : apps/ai (le service est autonome, pas de dépendance au
|
||||
# monorepo). Étage 1 : uv sync + téléchargement du modèle ONNX AU BUILD
|
||||
# (ADR-004 §1 — jamais au démarrage) ; étage 2 : runtime minimal non-root.
|
||||
# Ce service n'est JAMAIS exposé publiquement : seul siop2-api le contacte,
|
||||
# porteur du secret AI_SERVICE_TOKEN (ADR-004 §4).
|
||||
|
||||
FROM ghcr.io/astral-sh/uv:python3.11-bookworm-slim AS builder
|
||||
WORKDIR /app
|
||||
ENV UV_LINK_MODE=copy \
|
||||
FASTEMBED_CACHE_PATH=/opt/fastembed \
|
||||
HF_HOME=/opt/whisper
|
||||
|
||||
# Manifestes d'abord (cache de couche), puis le code. L'installation du projet
|
||||
# reste éditable (.pth → /app/src) : src est donc copié dans l'image finale.
|
||||
COPY pyproject.toml uv.lock ./
|
||||
RUN uv sync --frozen --no-install-project --no-dev \
|
||||
--extra embeddings --extra generation --extra transcription
|
||||
COPY src src
|
||||
RUN uv sync --frozen --no-dev \
|
||||
--extra embeddings --extra generation --extra transcription
|
||||
|
||||
# Les modèles locaux (embeddings + dictée) sont EMBARQUÉS dans l'image : pas
|
||||
# de téléchargement au boot (démarrage prévisible, marche sans accès à
|
||||
# Hugging Face en production). La dictée reste opt-in (AI_TRANSCRIPTION=off
|
||||
# par défaut) — le modèle est prêt si elle est activée un jour, sans rebuild.
|
||||
RUN uv run python -c "from siop_ai.embeddings import EmbeddeurLocal; EmbeddeurLocal()"
|
||||
RUN uv run python -c "from siop_ai.transcription import TranscripteurLocal; TranscripteurLocal('small')"
|
||||
|
||||
FROM python:3.11-slim-bookworm
|
||||
WORKDIR /app
|
||||
ENV PATH=/app/.venv/bin:$PATH \
|
||||
FASTEMBED_CACHE_PATH=/opt/fastembed \
|
||||
HF_HOME=/opt/whisper
|
||||
RUN useradd --system --create-home siop
|
||||
COPY --from=builder --chown=siop:siop /app/.venv /app/.venv
|
||||
COPY --from=builder --chown=siop:siop /app/src /app/src
|
||||
COPY --from=builder --chown=siop:siop /opt/fastembed /opt/fastembed
|
||||
COPY --from=builder --chown=siop:siop /opt/whisper /opt/whisper
|
||||
USER siop
|
||||
EXPOSE 8000
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
|
||||
CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8000/healthz', timeout=4).status==200 else 1)"
|
||||
CMD ["uvicorn", "siop_ai.app:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
33
apps/ai/README.md
Normal file
33
apps/ai/README.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# @siop/ai — service IA (R5)
|
||||
|
||||
FastAPI + uv (ADR-001), **jamais exposé** : seule l'API NestJS le contacte avec
|
||||
`X-Service-Token` (ADR-004 §4). Maquettes et décisions D1-D5 validées le 17/07/2026.
|
||||
|
||||
## Lancer (dev)
|
||||
|
||||
```bash
|
||||
cd apps/ai
|
||||
uv sync --extra embeddings # le vrai modèle ONNX (CPU, ~120 Mo au premier run)
|
||||
uv run uvicorn siop_ai.app:app --port 8000
|
||||
# CI / tests : uv sync && uv run pytest (embeddeur déterministe, aucun téléchargement)
|
||||
```
|
||||
|
||||
Variables (défauts dev dans `config.py`, gabarit dans `.env.example`) :
|
||||
`DATABASE_URL`, `MINIO_*`, `AI_SERVICE_TOKEN`, `AI_EMBEDDINGS=locale|deterministe`,
|
||||
et la génération opt-in (ADR-004 §3) : `AI_GENERATION=off|api`, **`AI_API_KEY`**
|
||||
(exigée en mode api — le boot refuse sinon, jamais loguée ni exposée),
|
||||
`AI_MODEL` (défaut `claude-opus-4-8`). Mode api : `uv sync --extra generation`
|
||||
(SDK officiel `anthropic`) ; tout échec du LLM retombe sur le mode extractif.
|
||||
|
||||
## Ce que porte R5.1 (socle)
|
||||
|
||||
- **Ingestion anonymisée (D4)** : PDF de la bibliothèque (MinIO) page par page +
|
||||
bilans codés clôturés → anonymisation (e-mails, téléphones, noms connus de la
|
||||
base) → découpage → embeddings locaux → `RagChunk` (pgvector, schéma Prisma).
|
||||
- **Recherche sémantique** `/internal/search` : extraits sourcés (document + page
|
||||
ou bilan daté) avec score — la brique de « sourcé ou silencieux » (D2).
|
||||
- `/internal/reindex` idempotent ; l'exclusion de corpus (`Document.inCorpus`,
|
||||
D3) s'applique à l'ingestion ET à la lecture.
|
||||
|
||||
La suite : R5.2 assistant (mode extractif puis génération opt-in) + suggestion de
|
||||
bilan ; R5.3 écrans ; durcissement : Dockerfile + compose Dokploy (`siop2-ai`).
|
||||
46
apps/ai/pyproject.toml
Normal file
46
apps/ai/pyproject.toml
Normal file
@@ -0,0 +1,46 @@
|
||||
[project]
|
||||
name = "siop-ai"
|
||||
version = "0.1.0"
|
||||
description = "SIOP V2 — service IA (R5) : ingestion anonymisée, recherche sémantique, assistant sourcé (ADR-004)"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"fastapi>=0.115",
|
||||
"uvicorn[standard]>=0.32",
|
||||
"pydantic-settings>=2.6",
|
||||
"asyncpg>=0.30",
|
||||
"pypdf>=5.1",
|
||||
"minio>=7.2",
|
||||
"python-multipart>=0.0.20",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
# Le vrai modèle (ONNX, CPU) — absent des tests/CI (embeddeur déterministe).
|
||||
embeddings = ["fastembed>=0.4"]
|
||||
# Génération opt-in (ADR-004 §3) — absente des tests/CI (repli extractif).
|
||||
generation = ["anthropic>=0.75"]
|
||||
# Transcription opt-in (dictée R5, ADR-004 §5) — absente des tests/CI
|
||||
# (transcripteur déterministe). CTranslate2/CPU, licence MIT, local.
|
||||
transcription = ["faster-whisper>=1.1"]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=8.3",
|
||||
"pytest-asyncio>=0.24",
|
||||
"httpx>=0.27",
|
||||
"ruff>=0.8",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/siop_ai"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
asyncio_mode = "auto"
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
src = ["src", "tests"]
|
||||
1
apps/ai/src/siop_ai/__init__.py
Normal file
1
apps/ai/src/siop_ai/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""SIOP V2 — service IA (R5). L'IA propose, l'humain valide (D1)."""
|
||||
62
apps/ai/src/siop_ai/anonymisation.py
Normal file
62
apps/ai/src/siop_ai/anonymisation.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""Anonymisation à l'ingestion (D4, loi 09-08) : identités et coordonnées ne
|
||||
partent JAMAIS dans les index vectoriels ni dans les prompts.
|
||||
|
||||
Fonction pure, testée : e-mails, téléphones (formats marocains et
|
||||
internationaux), et les noms de personnes CONNUS de la base (utilisateurs,
|
||||
gardiens, contacts tiers) fournis par l'appelant.
|
||||
"""
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
JETON_PERSONNE = "⟨personne⟩"
|
||||
JETON_CONTACT = "⟨contact⟩"
|
||||
|
||||
_EMAIL = re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+")
|
||||
# 06 12 34 56 78 · 0612345678 · +212 6 12 34 56 78 · 05 22-34-56-78…
|
||||
_TELEPHONE = re.compile(r"(?:\+?\d{1,3}[\s.-]?)?(?:0|\(0\))?\d(?:[\s.-]?\d{2}){4}")
|
||||
|
||||
|
||||
def _sans_accents(texte: str) -> str:
|
||||
return "".join(
|
||||
c for c in unicodedata.normalize("NFD", texte) if unicodedata.category(c) != "Mn"
|
||||
)
|
||||
|
||||
|
||||
def anonymiser(texte: str, noms_connus: list[str] | None = None) -> str:
|
||||
"""Remplace coordonnées et noms connus par des jetons neutres.
|
||||
|
||||
Les noms sont remplacés insensiblement à la casse ET aux accents
|
||||
(« Idrissi » attrape « idrissi »), prénom seul compris quand il est
|
||||
assez long pour ne pas mutiler le texte technique.
|
||||
"""
|
||||
resultat = _EMAIL.sub(JETON_CONTACT, texte)
|
||||
resultat = _TELEPHONE.sub(JETON_CONTACT, resultat)
|
||||
|
||||
for nom in sorted(noms_connus or [], key=len, reverse=True):
|
||||
nom = nom.strip()
|
||||
if len(nom) < 3:
|
||||
continue
|
||||
morceaux = [nom] + [m for m in nom.split() if len(m) >= 4]
|
||||
for morceau in morceaux:
|
||||
motif = re.compile(
|
||||
r"\b" + re.escape(_sans_accents(morceau)) + r"\b", re.IGNORECASE
|
||||
)
|
||||
# on cherche sur une copie sans accents mais on remplace l'original
|
||||
copie = _sans_accents(resultat)
|
||||
sortie: list[str] = []
|
||||
position = 0
|
||||
for correspondance in motif.finditer(copie):
|
||||
sortie.append(resultat[position : correspondance.start()])
|
||||
sortie.append(JETON_PERSONNE)
|
||||
position = correspondance.end()
|
||||
sortie.append(resultat[position:])
|
||||
resultat = "".join(sortie)
|
||||
|
||||
# jetons collés en double (« prénom nom » remplacés séparément)
|
||||
resultat = re.sub(
|
||||
rf"{re.escape(JETON_PERSONNE)}(\s+{re.escape(JETON_PERSONNE)})+",
|
||||
JETON_PERSONNE,
|
||||
resultat,
|
||||
)
|
||||
return resultat
|
||||
133
apps/ai/src/siop_ai/app.py
Normal file
133
apps/ai/src/siop_ai/app.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""Service IA — JAMAIS exposé publiquement (ADR-004 §4) : seule l'API NestJS
|
||||
le contacte, avec le secret partagé `X-Service-Token`. Les permissions des
|
||||
utilisateurs restent l'affaire de l'API — ici, un seul appelant de confiance.
|
||||
"""
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import asdict
|
||||
|
||||
import asyncpg
|
||||
from fastapi import Depends, FastAPI, Header, HTTPException, UploadFile
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .assistant import repondre, suggerer_bilan
|
||||
from .config import Reglages, charger_reglages
|
||||
from .embeddings import construire_embeddeur
|
||||
from .generation import construire_generateur
|
||||
from .ingestion import reindexer_tout
|
||||
from .recherche import chercher
|
||||
from .transcription import construire_transcripteur
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def cycle_de_vie(app: FastAPI):
|
||||
reglages = charger_reglages()
|
||||
app.state.reglages = reglages
|
||||
app.state.embeddeur = construire_embeddeur(reglages.ai_embeddings)
|
||||
app.state.generateur = construire_generateur(
|
||||
reglages.ai_generation, reglages.ai_api_key, reglages.ai_model
|
||||
)
|
||||
app.state.transcripteur = construire_transcripteur(
|
||||
reglages.ai_transcription, reglages.ai_transcription_model
|
||||
)
|
||||
app.state.pool = await asyncpg.create_pool(reglages.database_url, min_size=1, max_size=5)
|
||||
yield
|
||||
await app.state.pool.close()
|
||||
|
||||
|
||||
app = FastAPI(title="SIOP V2 — service IA (R5)", lifespan=cycle_de_vie)
|
||||
|
||||
|
||||
def verifier_jeton(
|
||||
x_service_token: str = Header(default=""),
|
||||
) -> None:
|
||||
reglages: Reglages = app.state.reglages
|
||||
if x_service_token != reglages.ai_service_token:
|
||||
raise HTTPException(status_code=401, detail="Jeton de service invalide")
|
||||
|
||||
|
||||
@app.get("/healthz")
|
||||
async def sante() -> dict:
|
||||
"""Sonde interne (compose/Dokploy) — ne révèle ni corpus ni secret."""
|
||||
reglages = getattr(app.state, "reglages", None) or charger_reglages()
|
||||
return {
|
||||
"status": "ok",
|
||||
"service": "siop2-ai",
|
||||
"generation": reglages.ai_generation, # « off » = extractif — jamais la clé
|
||||
"transcription": reglages.ai_transcription,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/internal/reindex", dependencies=[Depends(verifier_jeton)])
|
||||
async def reindexer() -> dict:
|
||||
async with app.state.pool.acquire() as cnx:
|
||||
resultat = await reindexer_tout(cnx, app.state.reglages, app.state.embeddeur)
|
||||
return asdict(resultat)
|
||||
|
||||
|
||||
class RequeteRecherche(BaseModel):
|
||||
question: str = Field(min_length=3, max_length=500)
|
||||
limite: int = Field(default=5, ge=1, le=10)
|
||||
|
||||
|
||||
@app.post("/internal/search", dependencies=[Depends(verifier_jeton)])
|
||||
async def rechercher(corps: RequeteRecherche) -> dict:
|
||||
async with app.state.pool.acquire() as cnx:
|
||||
extraits = await chercher(cnx, app.state.embeddeur, corps.question, corps.limite)
|
||||
return {"extraits": [asdict(e) for e in extraits]}
|
||||
|
||||
|
||||
@app.post("/internal/ask", dependencies=[Depends(verifier_jeton)])
|
||||
async def demander(corps: RequeteRecherche) -> dict:
|
||||
"""L'assistant D2 : extraits sourcés au-dessus du seuil, ou refus honnête
|
||||
(ce qui a été cherché) — la rédaction n'existe qu'en mode génératif."""
|
||||
async with app.state.pool.acquire() as cnx:
|
||||
reponse = await repondre(
|
||||
cnx,
|
||||
app.state.embeddeur,
|
||||
app.state.generateur,
|
||||
corps.question,
|
||||
corps.limite,
|
||||
seuil_pertinence=app.state.reglages.ai_seuil_pertinence,
|
||||
)
|
||||
return {
|
||||
"mode": reponse.mode,
|
||||
"answer": reponse.answer,
|
||||
"extraits": [asdict(e) for e in reponse.extraits],
|
||||
"corpus": {
|
||||
"documents": reponse.documents_corpus,
|
||||
"bilans": reponse.bilans_corpus,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class RequeteSuggestion(BaseModel):
|
||||
description: str = Field(min_length=10, max_length=2000)
|
||||
|
||||
|
||||
@app.post("/internal/suggest", dependencies=[Depends(verifier_jeton)])
|
||||
async def suggerer(corps: RequeteSuggestion) -> dict:
|
||||
"""Suggestion de codes de bilan (D1) : uniquement des codes EXISTANTS,
|
||||
avec confiance et « N bilans similaires » — l'humain applique, ou pas."""
|
||||
async with app.state.pool.acquire() as cnx:
|
||||
suggestions = await suggerer_bilan(
|
||||
cnx,
|
||||
app.state.embeddeur,
|
||||
corps.description,
|
||||
seuil_suggestion=app.state.reglages.ai_seuil_suggestion,
|
||||
seuil_confiance_forte=app.state.reglages.ai_seuil_confiance_forte,
|
||||
)
|
||||
return {"suggestions": [asdict(s) for s in suggestions]}
|
||||
|
||||
|
||||
@app.post("/internal/transcrire", dependencies=[Depends(verifier_jeton)])
|
||||
async def transcrire(fichier: UploadFile) -> dict:
|
||||
"""Dictée opt-in (R5 D5, loi 09-08) : l'audio ne persiste JAMAIS — un
|
||||
fichier temporaire le temps de l'inférence, supprimé aussitôt (voir
|
||||
transcription.py). 503 propre si AI_TRANSCRIPTION=off (défaut)."""
|
||||
if app.state.transcripteur is None:
|
||||
raise HTTPException(status_code=503, detail="Transcription non activée (AI_TRANSCRIPTION=off)")
|
||||
audio = await fichier.read()
|
||||
extension = (fichier.filename or "audio.m4a").rsplit(".", 1)[-1]
|
||||
texte = app.state.transcripteur.transcrire(audio, extension)
|
||||
return {"texte": texte}
|
||||
153
apps/ai/src/siop_ai/assistant.py
Normal file
153
apps/ai/src/siop_ai/assistant.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""L'assistant (D2 — « sourcé ou silencieux ») et la suggestion de bilan.
|
||||
|
||||
- `repondre` : recherche sémantique → extraits au-dessus du seuil de
|
||||
pertinence, ou refus HONNÊTE qui dit ce qui a été cherché (écran 2 des
|
||||
maquettes). La rédaction est déléguée au `Generateur` (opt-in ADR-004 §3) ;
|
||||
sans lui, le mode extractif est la réponse.
|
||||
- `suggerer_bilan` : similarité sémantique entre la description libre et les
|
||||
libellés ACTIFS des référentiels (l'IA ne peut suggérer que des codes
|
||||
existants) + comptage des bilans similaires du parc. Sans LLM : rapide,
|
||||
déterministe, explicable.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import asyncpg
|
||||
|
||||
from .embeddings import Embeddeur
|
||||
from .generation import Generateur
|
||||
from .recherche import ExtraitTrouve, chercher
|
||||
|
||||
# Répliques des libellés français de @siop/shared (BILAN_FIELD_LABELS) —
|
||||
# utilisés pour contextualiser les embeddings des codes.
|
||||
CHAMPS_BILAN = {
|
||||
"DOOR_STATE": "état des portes",
|
||||
"CABIN_POSITION": "position cabine",
|
||||
"ANOMALY": "anomalie constatée",
|
||||
"EXTERNAL_CAUSE": "cause extérieure",
|
||||
"ACTION_TAKEN": "action réalisée",
|
||||
"COMPONENT_CONCERNED": "élément concerné",
|
||||
}
|
||||
|
||||
# Défauts — surchargés par la config (AI_SEUIL_*) : calibrage en recette.
|
||||
SEUIL_PERTINENCE = 0.45 # en dessous : le corpus ne porte pas la réponse
|
||||
SEUIL_SUGGESTION = 0.40
|
||||
SEUIL_CONFIANCE_FORTE = 0.55
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReponseAssistant:
|
||||
mode: str # « extractif » | « genere » | « refus »
|
||||
answer: str | None
|
||||
extraits: list[ExtraitTrouve]
|
||||
documents_corpus: int
|
||||
bilans_corpus: int
|
||||
|
||||
|
||||
async def _taille_corpus(cnx: asyncpg.Connection) -> tuple[int, int]:
|
||||
ligne = await cnx.fetchrow(
|
||||
'''
|
||||
SELECT
|
||||
(SELECT count(DISTINCT "documentId") FROM "RagChunk"
|
||||
WHERE "sourceType" = 'DOCUMENT') AS documents,
|
||||
(SELECT count(*) FROM "RagChunk" WHERE "sourceType" = 'WORK_ORDER') AS bilans
|
||||
'''
|
||||
)
|
||||
return ligne["documents"], ligne["bilans"]
|
||||
|
||||
|
||||
async def repondre(
|
||||
cnx: asyncpg.Connection,
|
||||
embeddeur: Embeddeur,
|
||||
generateur: Generateur,
|
||||
question: str,
|
||||
limite: int = 5,
|
||||
seuil_pertinence: float = SEUIL_PERTINENCE,
|
||||
) -> ReponseAssistant:
|
||||
documents, bilans = await _taille_corpus(cnx)
|
||||
extraits = await chercher(cnx, embeddeur, question, limite)
|
||||
pertinents = [e for e in extraits if e.score >= seuil_pertinence]
|
||||
|
||||
if not pertinents:
|
||||
# D2 : refus explicite — on dit ce qu'on a cherché, on n'invente rien.
|
||||
return ReponseAssistant(
|
||||
mode="refus",
|
||||
answer=None,
|
||||
extraits=[],
|
||||
documents_corpus=documents,
|
||||
bilans_corpus=bilans,
|
||||
)
|
||||
|
||||
redige = generateur.rediger(question, pertinents)
|
||||
return ReponseAssistant(
|
||||
mode="genere" if redige else "extractif",
|
||||
answer=redige,
|
||||
extraits=pertinents,
|
||||
documents_corpus=documents,
|
||||
bilans_corpus=bilans,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SuggestionBilan:
|
||||
field: str
|
||||
value_id: str
|
||||
label: str
|
||||
confidence: str # « FORTE » | « MOYENNE »
|
||||
similar_reports: int # bilans du parc portant déjà ce code (« 9 bilans similaires »)
|
||||
score: float
|
||||
|
||||
|
||||
def _cosinus(a: list[float], b: list[float]) -> float:
|
||||
return sum(x * y for x, y in zip(a, b)) # vecteurs déjà normés
|
||||
|
||||
|
||||
async def suggerer_bilan(
|
||||
cnx: asyncpg.Connection,
|
||||
embeddeur: Embeddeur,
|
||||
description: str,
|
||||
seuil_suggestion: float = SEUIL_SUGGESTION,
|
||||
seuil_confiance_forte: float = SEUIL_CONFIANCE_FORTE,
|
||||
) -> list[SuggestionBilan]:
|
||||
valeurs = await cnx.fetch(
|
||||
'SELECT id, field, label FROM "ReferenceValue" WHERE "isActive" ORDER BY field, label'
|
||||
)
|
||||
if not valeurs:
|
||||
return []
|
||||
|
||||
textes = [description] + [
|
||||
f"{CHAMPS_BILAN.get(v['field'], v['field'])} : {v['label']}" for v in valeurs
|
||||
]
|
||||
vecteurs = embeddeur.encoder(textes)
|
||||
v_description, v_valeurs = vecteurs[0], vecteurs[1:]
|
||||
|
||||
# Le meilleur code par champ, au-dessus du seuil — jamais plus d'une
|
||||
# suggestion par champ, jamais un code inventé.
|
||||
meilleurs: dict[str, tuple[asyncpg.Record, float]] = {}
|
||||
for valeur, vecteur in zip(valeurs, v_valeurs):
|
||||
score = _cosinus(v_description, vecteur)
|
||||
if score < seuil_suggestion:
|
||||
continue
|
||||
champ = valeur["field"]
|
||||
if champ not in meilleurs or score > meilleurs[champ][1]:
|
||||
meilleurs[champ] = (valeur, score)
|
||||
|
||||
suggestions: list[SuggestionBilan] = []
|
||||
for valeur, score in meilleurs.values():
|
||||
# « 9 bilans similaires sur ce parc » : les bilans clôturés portant ce code
|
||||
similaires = await cnx.fetchval(
|
||||
'SELECT count(*) FROM "RagChunk" WHERE "sourceType" = \'WORK_ORDER\' AND content ILIKE $1',
|
||||
f"%{valeur['label']}%",
|
||||
)
|
||||
suggestions.append(
|
||||
SuggestionBilan(
|
||||
field=valeur["field"],
|
||||
value_id=str(valeur["id"]),
|
||||
label=valeur["label"],
|
||||
confidence="FORTE" if score >= seuil_confiance_forte else "MOYENNE",
|
||||
similar_reports=similaires,
|
||||
score=round(score, 4),
|
||||
)
|
||||
)
|
||||
suggestions.sort(key=lambda s: s.score, reverse=True)
|
||||
return suggestions
|
||||
52
apps/ai/src/siop_ai/config.py
Normal file
52
apps/ai/src/siop_ai/config.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""Configuration — validée au démarrage, comme l'API NestJS (même philosophie)."""
|
||||
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Reglages(BaseSettings):
|
||||
# Postgres partagé (schéma possédé par Prisma — apps/api)
|
||||
database_url: str = "postgresql://siop:siop@localhost:5432/siop"
|
||||
# MinIO en direct sur le réseau privé (ADR-004 §4 — jamais exposé)
|
||||
minio_endpoint: str = "localhost"
|
||||
minio_port: int = 9000
|
||||
minio_use_ssl: bool = False
|
||||
minio_access_key: str = "siop"
|
||||
minio_secret_key: str = "siop-minio"
|
||||
minio_bucket: str = "siop2"
|
||||
# Le service n'est JAMAIS public : seul l'API NestJS le contacte,
|
||||
# porteuse de ce secret partagé (ADR-004 §4).
|
||||
ai_service_token: str = "dev-only-ai-token"
|
||||
# Embeddings : « locale » (fastembed ONNX) ou « deterministe » (tests/CI)
|
||||
ai_embeddings: str = "locale"
|
||||
# Génération (ADR-004 §3) : « off » = mode extractif (défaut honnête,
|
||||
# la recette passe sans clé) ; « api » = rédaction par le LLM externe,
|
||||
# sur textes DÉJÀ anonymisés (D4), citations obligatoires.
|
||||
ai_generation: str = "off"
|
||||
ai_api_key: str = "" # requise seulement si ai_generation=api — jamais loguée
|
||||
ai_model: str = "claude-opus-4-8"
|
||||
# Seuils de similarité — constantes de départ, calibrables par env
|
||||
# (recette sur corpus réel ; abaissés en CI e2e — embeddeur déterministe).
|
||||
ai_seuil_pertinence: float = 0.45
|
||||
ai_seuil_suggestion: float = 0.40
|
||||
ai_seuil_confiance_forte: float = 0.55
|
||||
# Dictée opt-in (R5 D5, ADR-004 §5) : « off » (défaut — endpoint refuse
|
||||
# proprement), « locale » (faster-whisper, CPU) ou « deterministe » (CI).
|
||||
ai_transcription: str = "off"
|
||||
ai_transcription_model: str = "small" # tiny|base|small|medium|large-v3
|
||||
|
||||
model_config = {"env_prefix": "", "case_sensitive": False}
|
||||
|
||||
|
||||
def charger_reglages() -> Reglages:
|
||||
reglages = Reglages()
|
||||
# asyncpg ne comprend pas le paramètre ?schema= de Prisma
|
||||
if "?" in reglages.database_url:
|
||||
reglages.database_url = reglages.database_url.split("?")[0]
|
||||
# Même philosophie que l'API NestJS : une config invalide refuse de booter.
|
||||
if reglages.ai_generation not in ("off", "api"):
|
||||
raise ValueError("AI_GENERATION doit valoir « off » ou « api »")
|
||||
if reglages.ai_generation == "api" and not reglages.ai_api_key:
|
||||
raise ValueError("AI_GENERATION=api exige AI_API_KEY (voir ADR-004 §3)")
|
||||
if reglages.ai_transcription not in ("off", "locale", "deterministe"):
|
||||
raise ValueError("AI_TRANSCRIPTION doit valoir « off », « locale » ou « deterministe »")
|
||||
return reglages
|
||||
57
apps/ai/src/siop_ai/decoupage.py
Normal file
57
apps/ai/src/siop_ai/decoupage.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""Découpage du texte en extraits indexables — pur et testé.
|
||||
|
||||
Paragraphes regroupés jusqu'à ~350 caractères, avec un chevauchement de
|
||||
queue pour ne pas couper une prescription en deux. Un extrait trop long est
|
||||
scindé sur les phrases.
|
||||
|
||||
Le grain est court À DESSEIN (recette R5) : sur des pages entières, la phrase
|
||||
qui répond se noie dans son contexte et les scores question→passage ne
|
||||
séparent plus le pertinent du voisin de domaine ; à ~350 caractères, la marge
|
||||
revient — et l'extrait cité à l'écran reste lisible d'un coup d'œil.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
TAILLE_CIBLE = 350
|
||||
CHEVAUCHEMENT = 80
|
||||
TAILLE_MINIMALE = 40 # en deçà : bruit (titres orphelins, numéros de page)
|
||||
|
||||
|
||||
def _phrases(texte: str) -> list[str]:
|
||||
return [p.strip() for p in re.split(r"(?<=[.!?;])\s+", texte) if p.strip()]
|
||||
|
||||
|
||||
def decouper(texte: str) -> list[str]:
|
||||
paragraphes = [p.strip() for p in re.split(r"\n\s*\n", texte) if p.strip()]
|
||||
extraits: list[str] = []
|
||||
courant = ""
|
||||
|
||||
def pousser() -> None:
|
||||
nonlocal courant
|
||||
nettoye = courant.strip()
|
||||
if len(nettoye) >= TAILLE_MINIMALE:
|
||||
extraits.append(nettoye)
|
||||
courant = ""
|
||||
|
||||
for paragraphe in paragraphes:
|
||||
paragraphe = re.sub(r"\s+", " ", paragraphe)
|
||||
if len(courant) + len(paragraphe) + 1 > TAILLE_CIBLE and courant:
|
||||
queue = courant[-CHEVAUCHEMENT:]
|
||||
pousser()
|
||||
courant = queue + " "
|
||||
while len(paragraphe) > TAILLE_CIBLE:
|
||||
phrases = _phrases(paragraphe)
|
||||
if len(phrases) <= 1:
|
||||
courant += paragraphe[:TAILLE_CIBLE]
|
||||
paragraphe = paragraphe[TAILLE_CIBLE - CHEVAUCHEMENT :]
|
||||
pousser()
|
||||
continue
|
||||
morceau = ""
|
||||
while phrases and len(morceau) + len(phrases[0]) + 1 <= TAILLE_CIBLE:
|
||||
morceau += phrases.pop(0) + " "
|
||||
courant += morceau
|
||||
pousser()
|
||||
paragraphe = " ".join(phrases)
|
||||
courant += paragraphe + " "
|
||||
pousser()
|
||||
return extraits
|
||||
65
apps/ai/src/siop_ai/embeddings.py
Normal file
65
apps/ai/src/siop_ai/embeddings.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""Embeddeurs (ADR-004) : le vrai modèle local ONNX, et un déterministe pour
|
||||
tests/CI — même interface, mêmes dimensions (DIMENSIONS), aucun téléchargement en test.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import math
|
||||
from typing import Protocol
|
||||
|
||||
DIMENSIONS = 768
|
||||
# mpnet remplace MiniLM-384 : décision de recette R5 (journal 17/07) — sur le
|
||||
# banc français, MiniLM classait la page-réponse DERRIÈRE des passages sans
|
||||
# rapport (0,24 vs 0,41) ; mpnet rétablit le classement et une marge
|
||||
# signal/bruit exploitable (≥ 0,46 vs ≤ 0,42).
|
||||
MODELE_LOCAL = "sentence-transformers/paraphrase-multilingual-mpnet-base-v2"
|
||||
|
||||
|
||||
class Embeddeur(Protocol):
|
||||
def encoder(self, textes: list[str]) -> list[list[float]]: ...
|
||||
|
||||
|
||||
class EmbeddeurDeterministe:
|
||||
"""Sac de tri-grammes haché puis normalisé : stable, sans réseau, et les
|
||||
textes proches partagent des composantes — assez pour tester le circuit
|
||||
complet (ingestion → pgvector → similarité)."""
|
||||
|
||||
def encoder(self, textes: list[str]) -> list[list[float]]:
|
||||
return [self._un(t) for t in textes]
|
||||
|
||||
def _un(self, texte: str) -> list[float]:
|
||||
vecteur = [0.0] * DIMENSIONS
|
||||
mots = texte.lower().split()
|
||||
grammes = mots + [" ".join(mots[i : i + 3]) for i in range(max(0, len(mots) - 2))]
|
||||
for gramme in grammes:
|
||||
empreinte = hashlib.sha256(gramme.encode()).digest()
|
||||
indice = int.from_bytes(empreinte[:4], "big") % DIMENSIONS
|
||||
signe = 1.0 if empreinte[4] % 2 == 0 else -1.0
|
||||
vecteur[indice] += signe
|
||||
norme = math.sqrt(sum(v * v for v in vecteur)) or 1.0
|
||||
return [v / norme for v in vecteur]
|
||||
|
||||
|
||||
class EmbeddeurLocal:
|
||||
"""fastembed (ONNX, CPU) — chargé paresseusement, jamais importé en test.
|
||||
Sortie NORMÉE : fastembed ne garantit pas des vecteurs unitaires, or la
|
||||
similarité par produit scalaire (suggestions) l'exige — pgvector, lui,
|
||||
normalise dans son opérateur cosinus, ce qui masquait l'écart."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
from fastembed import TextEmbedding # import différé (dépendance optionnelle)
|
||||
|
||||
self._modele = TextEmbedding(model_name=MODELE_LOCAL)
|
||||
|
||||
def encoder(self, textes: list[str]) -> list[list[float]]:
|
||||
vecteurs = []
|
||||
for vecteur in self._modele.embed(textes):
|
||||
liste = vecteur.tolist()
|
||||
norme = math.sqrt(sum(v * v for v in liste)) or 1.0
|
||||
vecteurs.append([v / norme for v in liste])
|
||||
return vecteurs
|
||||
|
||||
|
||||
def construire_embeddeur(mode: str) -> Embeddeur:
|
||||
if mode == "deterministe":
|
||||
return EmbeddeurDeterministe()
|
||||
return EmbeddeurLocal()
|
||||
94
apps/ai/src/siop_ai/generation.py
Normal file
94
apps/ai/src/siop_ai/generation.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""Génération des réponses rédigées (ADR-004 §3) — OPT-IN par configuration.
|
||||
|
||||
- « off » (défaut) : mode extractif — pas de LLM, l'assistant montrera les
|
||||
extraits sourcés tels quels. La recette R5 passe entièrement dans ce mode.
|
||||
- « api » : rédaction par Claude (SDK officiel), sur des extraits DÉJÀ
|
||||
anonymisés (D4), avec l'obligation de ne rien affirmer hors extraits (D2).
|
||||
Tout échec (refus, réseau, quota) retombe sur le mode extractif — jamais
|
||||
d'erreur utilisateur à cause du LLM.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Protocol
|
||||
|
||||
from .recherche import ExtraitTrouve
|
||||
|
||||
journal = logging.getLogger("siop_ai.generation")
|
||||
|
||||
CONSIGNE = """Tu es l'assistant SIOP d'une société de maintenance d'ascenseurs.
|
||||
Tu réponds en français, UNIQUEMENT à partir des extraits fournis (notices et
|
||||
historiques d'intervention du parc, déjà anonymisés).
|
||||
Règles absolues :
|
||||
- chaque affirmation porte sa citation [n] renvoyant à un extrait fourni ;
|
||||
- si les extraits ne portent pas la réponse, dis-le et n'invente RIEN ;
|
||||
- reste bref et opérationnel : un technicien te lit sur le terrain ;
|
||||
- termine toujours par le rappel que l'humain vérifie avant d'agir."""
|
||||
|
||||
|
||||
class Generateur(Protocol):
|
||||
def rediger(self, question: str, extraits: list[ExtraitTrouve]) -> str | None: ...
|
||||
|
||||
|
||||
class GenerateurExtractif:
|
||||
"""Le contrat de base : pas de rédaction — l'appelant montre les extraits.
|
||||
`None` signifie « pas de texte généré », jamais une erreur."""
|
||||
|
||||
def rediger(self, question: str, extraits: list[ExtraitTrouve]) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def construire_invite(question: str, extraits: list[ExtraitTrouve]) -> str:
|
||||
"""L'invite utilisateur — fonction pure, testée : la question et les
|
||||
extraits numérotés, rien d'autre (les textes sont déjà anonymisés)."""
|
||||
blocs = [
|
||||
f"[{rang}] {e.titre} · {e.locator}\n{e.content}"
|
||||
for rang, e in enumerate(extraits, start=1)
|
||||
]
|
||||
return "Extraits du corpus :\n\n" + "\n\n".join(blocs) + f"\n\nQuestion : {question}"
|
||||
|
||||
|
||||
class GenerateurAPI:
|
||||
"""Rédaction par Claude — import différé : la dépendance `anthropic` est
|
||||
optionnelle (groupe `generation`), absente des tests/CI."""
|
||||
|
||||
def __init__(self, api_key: str, modele: str) -> None:
|
||||
from anthropic import Anthropic
|
||||
|
||||
self._client = Anthropic(api_key=api_key)
|
||||
self._modele = modele
|
||||
|
||||
def rediger(self, question: str, extraits: list[ExtraitTrouve]) -> str | None:
|
||||
import anthropic
|
||||
|
||||
if not extraits:
|
||||
return None # sourcé ou silencieux (D2) : rien à citer = rien à rédiger
|
||||
try:
|
||||
reponse = self._client.messages.create(
|
||||
model=self._modele,
|
||||
max_tokens=2048, # réponses courtes et sourcées, par conception
|
||||
thinking={"type": "adaptive"},
|
||||
system=CONSIGNE,
|
||||
messages=[
|
||||
{"role": "user", "content": construire_invite(question, extraits)}
|
||||
],
|
||||
)
|
||||
if reponse.stop_reason == "refusal":
|
||||
journal.warning("Génération refusée par le modèle — repli extractif")
|
||||
return None
|
||||
texte = "".join(b.text for b in reponse.content if b.type == "text").strip()
|
||||
return texte or None
|
||||
except anthropic.RateLimitError:
|
||||
journal.warning("Quota API atteint — repli extractif")
|
||||
return None
|
||||
except anthropic.APIStatusError as e:
|
||||
journal.warning("API génération %s — repli extractif", e.status_code)
|
||||
return None
|
||||
except anthropic.APIConnectionError:
|
||||
journal.warning("API génération injoignable — repli extractif")
|
||||
return None
|
||||
|
||||
|
||||
def construire_generateur(mode: str, api_key: str, modele: str) -> Generateur:
|
||||
if mode == "api":
|
||||
return GenerateurAPI(api_key, modele)
|
||||
return GenerateurExtractif()
|
||||
178
apps/ai/src/siop_ai/ingestion.py
Normal file
178
apps/ai/src/siop_ai/ingestion.py
Normal file
@@ -0,0 +1,178 @@
|
||||
"""Ingestion du corpus (D3) : bibliothèque R3 (PDF, MinIO) + bilans codés.
|
||||
Chaque texte passe par l'anonymisation (D4) AVANT découpage et embeddings.
|
||||
Les chunks vivent dans `RagChunk` (pgvector, schéma possédé par Prisma).
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
|
||||
import asyncpg
|
||||
from minio import Minio
|
||||
from pypdf import PdfReader
|
||||
|
||||
from .anonymisation import anonymiser
|
||||
from .config import Reglages
|
||||
from .decoupage import decouper
|
||||
from .embeddings import Embeddeur
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResultatIngestion:
|
||||
documents_indexes: int
|
||||
documents_ignores: int
|
||||
bilans_indexes: int
|
||||
extraits: int
|
||||
|
||||
|
||||
def _vecteur_sql(vecteur: list[float]) -> str:
|
||||
return "[" + ",".join(f"{v:.6f}" for v in vecteur) + "]"
|
||||
|
||||
|
||||
async def noms_a_anonymiser(cnx: asyncpg.Connection) -> list[str]:
|
||||
"""Toutes les identités connues de la base (D4) : utilisateurs, gardiens,
|
||||
contacts tiers, demandeurs du portail."""
|
||||
lignes = await cnx.fetch(
|
||||
'''
|
||||
SELECT "displayName" AS nom FROM "User"
|
||||
UNION SELECT "guardianName" FROM "Location" WHERE "guardianName" IS NOT NULL
|
||||
UNION SELECT "contactName" FROM "Partner" WHERE "contactName" IS NOT NULL
|
||||
'''
|
||||
)
|
||||
return [ligne["nom"] for ligne in lignes if ligne["nom"]]
|
||||
|
||||
|
||||
def extraire_texte_pdf(octets: bytes) -> list[tuple[str, str]]:
|
||||
"""[(texte, localisation)] par page — la citation doit pointer la page."""
|
||||
lecteur = PdfReader(io.BytesIO(octets))
|
||||
pages: list[tuple[str, str]] = []
|
||||
for numero, page in enumerate(lecteur.pages, start=1):
|
||||
texte = page.extract_text() or ""
|
||||
if texte.strip():
|
||||
pages.append((texte, f"p. {numero}"))
|
||||
return pages
|
||||
|
||||
|
||||
async def indexer_documents(
|
||||
cnx: asyncpg.Connection,
|
||||
reglages: Reglages,
|
||||
embeddeur: Embeddeur,
|
||||
noms: list[str],
|
||||
) -> tuple[int, int, int]:
|
||||
minio = Minio(
|
||||
f"{reglages.minio_endpoint}:{reglages.minio_port}",
|
||||
access_key=reglages.minio_access_key,
|
||||
secret_key=reglages.minio_secret_key,
|
||||
secure=reglages.minio_use_ssl,
|
||||
)
|
||||
documents = await cnx.fetch(
|
||||
'SELECT id, "fileName", "storageKey", "contentType", "inCorpus" FROM "Document"'
|
||||
)
|
||||
indexes, ignores, total_extraits = 0, 0, 0
|
||||
for doc in documents:
|
||||
# réindexation idempotente : on repart de zéro pour ce document
|
||||
await cnx.execute('DELETE FROM "RagChunk" WHERE "documentId" = $1', doc["id"])
|
||||
if not doc["inCorpus"] or doc["contentType"] != "application/pdf":
|
||||
await cnx.execute(
|
||||
'UPDATE "Document" SET "indexedAt" = NULL, "chunkCount" = 0 WHERE id = $1',
|
||||
doc["id"],
|
||||
)
|
||||
ignores += 1
|
||||
continue
|
||||
reponse = minio.get_object(reglages.minio_bucket, doc["storageKey"])
|
||||
try:
|
||||
octets = reponse.read()
|
||||
finally:
|
||||
reponse.close()
|
||||
reponse.release_conn()
|
||||
extraits: list[tuple[str, str]] = []
|
||||
for texte_page, localisation in extraire_texte_pdf(octets):
|
||||
texte_sur = anonymiser(texte_page, noms)
|
||||
extraits += [(morceau, localisation) for morceau in decouper(texte_sur)]
|
||||
if extraits:
|
||||
vecteurs = embeddeur.encoder([contenu for contenu, _ in extraits])
|
||||
await cnx.executemany(
|
||||
'''
|
||||
INSERT INTO "RagChunk"
|
||||
(id, "sourceType", "documentId", locator, content, embedding)
|
||||
VALUES (gen_random_uuid(), 'DOCUMENT', $1, $2, $3, $4::vector)
|
||||
''',
|
||||
[
|
||||
(doc["id"], localisation, contenu, _vecteur_sql(vecteur))
|
||||
for (contenu, localisation), vecteur in zip(extraits, vecteurs)
|
||||
],
|
||||
)
|
||||
await cnx.execute(
|
||||
'UPDATE "Document" SET "indexedAt" = now(), "chunkCount" = $2 WHERE id = $1',
|
||||
doc["id"],
|
||||
len(extraits),
|
||||
)
|
||||
indexes += 1
|
||||
total_extraits += len(extraits)
|
||||
return indexes, ignores, total_extraits
|
||||
|
||||
|
||||
async def indexer_bilans(
|
||||
cnx: asyncpg.Connection, embeddeur: Embeddeur, noms: list[str]
|
||||
) -> tuple[int, int]:
|
||||
"""Les bilans codés clôturés — « sur votre parc, ce réglage a déjà… »."""
|
||||
await cnx.execute('DELETE FROM "RagChunk" WHERE "sourceType" = \'WORK_ORDER\'')
|
||||
bilans = await cnx.fetch(
|
||||
'''
|
||||
SELECT wo.id, wo.reference, wo.title, wo."completedAt", ir.note,
|
||||
a.reference AS asset_ref, a.brand, a.model,
|
||||
(SELECT json_object_agg(rv.field, rv.label)
|
||||
FROM "InterventionReport" ir2
|
||||
JOIN "ReferenceValue" rv ON rv.id IN (
|
||||
ir2."doorStateId", ir2."cabinPositionId", ir2."anomalyId",
|
||||
ir2."externalCauseId", ir2."actionTakenId", ir2."componentConcernedId")
|
||||
WHERE ir2."workOrderId" = wo.id) AS bilan
|
||||
FROM "WorkOrder" wo
|
||||
JOIN "InterventionReport" ir ON ir."workOrderId" = wo.id
|
||||
JOIN "Asset" a ON a.id = wo."assetId"
|
||||
WHERE wo.status = 'DONE'
|
||||
'''
|
||||
)
|
||||
lignes = []
|
||||
for bilan in bilans:
|
||||
champs = json.loads(bilan["bilan"]) if bilan["bilan"] else {}
|
||||
codes = " ; ".join(f"{champ} : {label}" for champ, label in champs.items())
|
||||
# note (D5) : dictée ou saisie libre relue par l'humain — même pipeline
|
||||
# d'anonymisation que le reste, aucun traitement à part.
|
||||
note = f" Description libre : {bilan['note']}." if bilan["note"] else ""
|
||||
contenu = anonymiser(
|
||||
f"Intervention {bilan['reference']} — {bilan['title']}. "
|
||||
f"Appareil {bilan['asset_ref']} ({bilan['brand']} {bilan['model'] or ''}). "
|
||||
f"Bilan codé : {codes}.{note}",
|
||||
noms,
|
||||
)
|
||||
quand = bilan["completedAt"].date().isoformat() if bilan["completedAt"] else "date inconnue"
|
||||
lignes.append((bilan["id"], f"bilan du {quand}", contenu))
|
||||
if lignes:
|
||||
vecteurs = embeddeur.encoder([contenu for _, _, contenu in lignes])
|
||||
await cnx.executemany(
|
||||
'''
|
||||
INSERT INTO "RagChunk"
|
||||
(id, "sourceType", "workOrderId", locator, content, embedding)
|
||||
VALUES (gen_random_uuid(), 'WORK_ORDER', $1, $2, $3, $4::vector)
|
||||
''',
|
||||
[
|
||||
(wo_id, localisation, contenu, _vecteur_sql(vecteur))
|
||||
for (wo_id, localisation, contenu), vecteur in zip(lignes, vecteurs)
|
||||
],
|
||||
)
|
||||
return len(lignes), len(lignes)
|
||||
|
||||
|
||||
async def reindexer_tout(
|
||||
cnx: asyncpg.Connection, reglages: Reglages, embeddeur: Embeddeur
|
||||
) -> ResultatIngestion:
|
||||
noms = await noms_a_anonymiser(cnx)
|
||||
docs_ok, docs_non, extraits_docs = await indexer_documents(cnx, reglages, embeddeur, noms)
|
||||
bilans, extraits_bilans = await indexer_bilans(cnx, embeddeur, noms)
|
||||
return ResultatIngestion(
|
||||
documents_indexes=docs_ok,
|
||||
documents_ignores=docs_non,
|
||||
bilans_indexes=bilans,
|
||||
extraits=extraits_docs + extraits_bilans,
|
||||
)
|
||||
57
apps/ai/src/siop_ai/recherche.py
Normal file
57
apps/ai/src/siop_ai/recherche.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""Recherche sémantique dans le corpus (pgvector, distance cosinus).
|
||||
Ne renvoie QUE des extraits sourcés — la brique de « sourcé ou silencieux ».
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import asyncpg
|
||||
|
||||
from .embeddings import Embeddeur
|
||||
from .ingestion import _vecteur_sql
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExtraitTrouve:
|
||||
source_type: str
|
||||
document_id: str | None
|
||||
work_order_id: str | None
|
||||
titre: str # nom de fichier ou référence d'OT
|
||||
locator: str
|
||||
content: str
|
||||
score: float # similarité cosinus (0..1)
|
||||
|
||||
|
||||
async def chercher(
|
||||
cnx: asyncpg.Connection,
|
||||
embeddeur: Embeddeur,
|
||||
question: str,
|
||||
limite: int = 5,
|
||||
) -> list[ExtraitTrouve]:
|
||||
vecteur = _vecteur_sql(embeddeur.encoder([question])[0])
|
||||
lignes = await cnx.fetch(
|
||||
'''
|
||||
SELECT c."sourceType", c."documentId", c."workOrderId", c.locator, c.content,
|
||||
1 - (c.embedding <=> $1::vector) AS score,
|
||||
COALESCE(d."fileName", wo.reference, '?') AS titre
|
||||
FROM "RagChunk" c
|
||||
LEFT JOIN "Document" d ON d.id = c."documentId"
|
||||
LEFT JOIN "WorkOrder" wo ON wo.id = c."workOrderId"
|
||||
WHERE c."documentId" IS NULL OR d."inCorpus" -- l'exclusion D3 s'applique aussi à la lecture
|
||||
ORDER BY c.embedding <=> $1::vector
|
||||
LIMIT $2
|
||||
''',
|
||||
vecteur,
|
||||
limite,
|
||||
)
|
||||
return [
|
||||
ExtraitTrouve(
|
||||
source_type=ligne["sourceType"],
|
||||
document_id=str(ligne["documentId"]) if ligne["documentId"] else None,
|
||||
work_order_id=str(ligne["workOrderId"]) if ligne["workOrderId"] else None,
|
||||
titre=ligne["titre"],
|
||||
locator=ligne["locator"],
|
||||
content=ligne["content"],
|
||||
score=float(ligne["score"]),
|
||||
)
|
||||
for ligne in lignes
|
||||
]
|
||||
54
apps/ai/src/siop_ai/transcription.py
Normal file
54
apps/ai/src/siop_ai/transcription.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""Transcription audio (dictée R5, ADR-004 §5) : l'audio ne survit JAMAIS à
|
||||
cet appel — un fichier temporaire le temps de l'inférence, supprimé aussitôt,
|
||||
quoi qu'il arrive (D5, loi 09-08). Seul le texte transcrit est retourné ; la
|
||||
relecture humaine reste le seul contenu conservé, dans l'OT (D1).
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
class Transcripteur(Protocol):
|
||||
def transcrire(self, audio: bytes, extension: str) -> str: ...
|
||||
|
||||
|
||||
class TranscripteurLocal:
|
||||
"""faster-whisper (CTranslate2, CPU, licence MIT) — chargé paresseusement,
|
||||
jamais importé en test. Modèle et langue forcée en français : le mélange
|
||||
français/darija du terrain reste un point de vigilance non calibré (pas
|
||||
de mesure préalable demandée par le référent, 22/07/2026) — à revoir sur
|
||||
échantillons réels si la qualité déçoit en recette."""
|
||||
|
||||
def __init__(self, modele: str) -> None:
|
||||
from faster_whisper import WhisperModel # import différé (dépendance optionnelle)
|
||||
|
||||
self._modele = WhisperModel(modele, device="cpu", compute_type="int8")
|
||||
|
||||
def transcrire(self, audio: bytes, extension: str) -> str:
|
||||
fd, chemin = tempfile.mkstemp(suffix=f".{extension}")
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as f:
|
||||
f.write(audio)
|
||||
segments, _ = self._modele.transcribe(chemin, language="fr", vad_filter=True)
|
||||
return " ".join(segment.text.strip() for segment in segments).strip()
|
||||
finally:
|
||||
os.remove(chemin) # D5 — l'audio ne doit JAMAIS survivre à l'appel
|
||||
|
||||
|
||||
class TranscripteurDeterministe:
|
||||
"""Tests/CI : pas de modèle, pas de dépendance audio réelle — renvoie un
|
||||
texte stable dérivé de la taille du fichier (même interface)."""
|
||||
|
||||
def transcrire(self, audio: bytes, extension: str) -> str:
|
||||
return f"transcription déterministe ({len(audio)} octets, .{extension})"
|
||||
|
||||
|
||||
def construire_transcripteur(mode: str, modele: str) -> Transcripteur | None:
|
||||
"""None si la dictée n'est pas activée (AI_TRANSCRIPTION=off, défaut) —
|
||||
l'appelant doit alors refuser proprement (503), pas planter."""
|
||||
if mode == "off":
|
||||
return None
|
||||
if mode == "deterministe":
|
||||
return TranscripteurDeterministe()
|
||||
return TranscripteurLocal(modele)
|
||||
33
apps/ai/tests/test_anonymisation.py
Normal file
33
apps/ai/tests/test_anonymisation.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""D4 (loi 09-08) : rien d'identifiant ne doit survivre à l'ingestion."""
|
||||
|
||||
from siop_ai.anonymisation import JETON_CONTACT, JETON_PERSONNE, anonymiser
|
||||
|
||||
|
||||
def test_emails_et_telephones_marocains():
|
||||
texte = "Appeler M. Alami au 06 12 34 56 78 ou +212 5 22 34 56 78, sinon gardien@residence.ma"
|
||||
resultat = anonymiser(texte, ["M. Alami"])
|
||||
assert "06 12" not in resultat
|
||||
assert "+212" not in resultat
|
||||
assert "gardien@residence.ma" not in resultat
|
||||
assert resultat.count(JETON_CONTACT) == 3
|
||||
|
||||
|
||||
def test_noms_connus_meme_sans_accents_ni_casse():
|
||||
texte = "Intervention validée par salma idrissi puis contrôlée par AHMED BENALI."
|
||||
resultat = anonymiser(texte, ["Salma Idrissi", "Ahmed Benali"])
|
||||
assert "idrissi" not in resultat.lower()
|
||||
assert "benali" not in resultat.lower()
|
||||
assert resultat.count(JETON_PERSONNE) == 2
|
||||
|
||||
|
||||
def test_prenom_seul_est_attrape_mais_pas_les_mots_courts():
|
||||
resultat = anonymiser("Vu avec Ahmed sur site.", ["Ahmed Benali"])
|
||||
assert "Ahmed" not in resultat
|
||||
# « NC-31 » ou « vis » ne doivent jamais être mutilés par un nom court
|
||||
resultat2 = anonymiser("Contact NC-31 réglé, vis serrées.", ["N. C."])
|
||||
assert "NC-31" in resultat2
|
||||
|
||||
|
||||
def test_le_texte_technique_reste_intact():
|
||||
texte = "Serrer les coulisseaux au couple de 25 N·m ; jeu latéral 0,5 mm."
|
||||
assert anonymiser(texte, ["Salma Idrissi"]) == texte
|
||||
69
apps/ai/tests/test_app.py
Normal file
69
apps/ai/tests/test_app.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""Le service n'est jamais public : sans le jeton de service, 401 partout
|
||||
(la santé exceptée — sonde d'infra qui ne révèle rien)."""
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from siop_ai.app import app
|
||||
|
||||
|
||||
def test_sante_publique_interne():
|
||||
reponse = _client_sans_db().get("/healthz")
|
||||
assert reponse.status_code == 200
|
||||
assert reponse.json()["service"] == "siop2-ai"
|
||||
|
||||
|
||||
def test_endpoints_internes_refuses_sans_jeton():
|
||||
client = _client_sans_db()
|
||||
assert client.post("/internal/reindex").status_code == 401
|
||||
assert client.post("/internal/search", json={"question": "couple de serrage ?"}).status_code == 401
|
||||
assert client.post("/internal/transcrire", files={"fichier": ("a.m4a", b"x")}).status_code == 401
|
||||
|
||||
|
||||
def test_transcription_off_par_defaut_503():
|
||||
"""Dictée pas activée (AI_TRANSCRIPTION=off, défaut) : refus propre,
|
||||
jamais un 500 — même contrat que le service IA éteint côté NestJS."""
|
||||
reponse = _client_sans_db().post(
|
||||
"/internal/transcrire",
|
||||
files={"fichier": ("note.m4a", b"faux-audio")},
|
||||
headers={"X-Service-Token": "dev-only-ai-token"},
|
||||
)
|
||||
assert reponse.status_code == 503
|
||||
|
||||
|
||||
def test_transcription_deterministe_retourne_un_texte():
|
||||
from siop_ai.transcription import TranscripteurDeterministe
|
||||
|
||||
client = _client_sans_db()
|
||||
app.state.transcripteur = TranscripteurDeterministe()
|
||||
reponse = client.post(
|
||||
"/internal/transcrire",
|
||||
files={"fichier": ("note.m4a", b"faux-audio")},
|
||||
headers={"X-Service-Token": "dev-only-ai-token"},
|
||||
)
|
||||
assert reponse.status_code == 200
|
||||
assert "10 octets" in reponse.json()["texte"]
|
||||
assert ".m4a" in reponse.json()["texte"]
|
||||
|
||||
|
||||
def test_question_trop_courte_rejetee_avant_tout():
|
||||
reponse = _client_sans_db().post(
|
||||
"/internal/search",
|
||||
json={"question": "ab"},
|
||||
headers={"X-Service-Token": "dev-only-ai-token"},
|
||||
)
|
||||
assert reponse.status_code == 422
|
||||
|
||||
|
||||
def _client_sans_db() -> TestClient:
|
||||
"""TestClient HORS gestionnaire de contexte : le lifespan (pool DB,
|
||||
modèle d'embeddings) ne tourne pas — on pose l'état minimal. Les tests
|
||||
d'intégration DB se font en local (recette), pas en CI (convention R5.1)."""
|
||||
from siop_ai.config import charger_reglages
|
||||
from siop_ai.transcription import construire_transcripteur
|
||||
|
||||
reglages = charger_reglages()
|
||||
app.state.reglages = reglages
|
||||
app.state.transcripteur = construire_transcripteur(
|
||||
reglages.ai_transcription, reglages.ai_transcription_model
|
||||
)
|
||||
return TestClient(app, raise_server_exceptions=False)
|
||||
51
apps/ai/tests/test_assistant.py
Normal file
51
apps/ai/tests/test_assistant.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""Logique de l'assistant testée sans base : le seuil « sourcé ou silencieux »
|
||||
et la sélection des suggestions — la partie SQL est couverte par la recette
|
||||
réelle (convention R5.1 : pytest purs en CI)."""
|
||||
|
||||
from siop_ai.assistant import (
|
||||
CHAMPS_BILAN,
|
||||
SEUIL_CONFIANCE_FORTE,
|
||||
SEUIL_PERTINENCE,
|
||||
SEUIL_SUGGESTION,
|
||||
_cosinus,
|
||||
)
|
||||
from siop_ai.embeddings import EmbeddeurDeterministe
|
||||
|
||||
|
||||
def test_les_seuils_sont_ordonnes():
|
||||
# Pertinence et suggestion vivent dans des pipelines distincts (question →
|
||||
# passages vs description → libellés) : pas d'ordre imposé entre eux.
|
||||
# L'invariant : une suggestion tout juste retenue n'est jamais « forte ».
|
||||
assert 0 < SEUIL_PERTINENCE < 1
|
||||
assert 0 < SEUIL_SUGGESTION < SEUIL_CONFIANCE_FORTE < 1
|
||||
|
||||
|
||||
def test_champs_bilan_couvrent_les_six_champs_du_contrat():
|
||||
assert set(CHAMPS_BILAN) == {
|
||||
"DOOR_STATE",
|
||||
"CABIN_POSITION",
|
||||
"ANOMALY",
|
||||
"EXTERNAL_CAUSE",
|
||||
"ACTION_TAKEN",
|
||||
"COMPONENT_CONCERNED",
|
||||
}
|
||||
|
||||
|
||||
def test_similarite_discrimine_le_bon_code():
|
||||
"""Le cœur de la suggestion : une description de panne de porte doit être
|
||||
plus proche du code « portes » que d'un code sans rapport."""
|
||||
embeddeur = EmbeddeurDeterministe()
|
||||
description, porte, treuil = embeddeur.encoder(
|
||||
[
|
||||
"la porte cabine rebondit, cellule encrassée, nettoyage barrière porte",
|
||||
"anomalie constatée : cellule ou barrière de porte encrassée",
|
||||
"élément concerné : treuil et moteur de traction",
|
||||
]
|
||||
)
|
||||
assert _cosinus(description, porte) > _cosinus(description, treuil)
|
||||
|
||||
|
||||
def test_cosinus_de_vecteurs_normes():
|
||||
embeddeur = EmbeddeurDeterministe()
|
||||
[v] = embeddeur.encoder(["contrôle mensuel des portes palières"])
|
||||
assert abs(_cosinus(v, v) - 1.0) < 1e-6
|
||||
24
apps/ai/tests/test_decoupage.py
Normal file
24
apps/ai/tests/test_decoupage.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from siop_ai.decoupage import CHEVAUCHEMENT, TAILLE_CIBLE, decouper
|
||||
|
||||
|
||||
def test_texte_court_un_seul_extrait():
|
||||
extraits = decouper("Serrer les vis de fixation des coulisseaux au couple de 25 N·m.")
|
||||
assert len(extraits) == 1
|
||||
|
||||
|
||||
def test_les_miettes_sont_ecartees():
|
||||
assert decouper("p. 3\n\n7\n\n") == []
|
||||
|
||||
|
||||
def test_long_texte_decoupe_avec_chevauchement():
|
||||
paragraphe = "La procédure de maintenance impose un contrôle mensuel des organes. " * 40
|
||||
extraits = decouper(paragraphe)
|
||||
assert len(extraits) >= 2
|
||||
assert all(len(e) <= TAILLE_CIBLE + CHEVAUCHEMENT for e in extraits)
|
||||
|
||||
|
||||
def test_paragraphes_courts_regroupes():
|
||||
texte = "\n\n".join(f"Étape {i} : vérifier le verrouillage de la porte palière." for i in range(6))
|
||||
extraits = decouper(texte)
|
||||
assert len(extraits) == 1
|
||||
assert "Étape 0" in extraits[0] and "Étape 5" in extraits[0]
|
||||
26
apps/ai/tests/test_embeddings.py
Normal file
26
apps/ai/tests/test_embeddings.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from siop_ai.embeddings import DIMENSIONS, EmbeddeurDeterministe
|
||||
|
||||
|
||||
def test_dimensions_et_normalisation():
|
||||
[vecteur] = EmbeddeurDeterministe().encoder(["couple de serrage des guides"])
|
||||
assert len(vecteur) == DIMENSIONS
|
||||
assert abs(sum(v * v for v in vecteur) - 1.0) < 1e-6
|
||||
|
||||
|
||||
def test_stable_et_discriminant():
|
||||
embeddeur = EmbeddeurDeterministe()
|
||||
a1 = embeddeur.encoder(["couple de serrage des coulisseaux de guides"])[0]
|
||||
a2 = embeddeur.encoder(["couple de serrage des coulisseaux de guides"])[0]
|
||||
b = embeddeur.encoder(["planning des congés du personnel administratif"])[0]
|
||||
cosinus = lambda x, y: sum(p * q for p, q in zip(x, y)) # noqa: E731 — vecteurs normés
|
||||
assert a1 == a2
|
||||
assert cosinus(a1, b) < 0.35 < cosinus(a1, a1)
|
||||
|
||||
|
||||
def test_textes_proches_plus_similaires_que_textes_eloignes():
|
||||
embeddeur = EmbeddeurDeterministe()
|
||||
question = embeddeur.encoder(["quel couple de serrage pour les guides ?"])[0]
|
||||
notice = embeddeur.encoder(["serrer les coulisseaux de guides au couple de 25 N·m"])[0]
|
||||
horsujet = embeddeur.encoder(["le syndic organise une assemblée générale annuelle"])[0]
|
||||
cosinus = lambda x, y: sum(p * q for p, q in zip(x, y)) # noqa: E731
|
||||
assert cosinus(question, notice) > cosinus(question, horsujet)
|
||||
62
apps/ai/tests/test_generation.py
Normal file
62
apps/ai/tests/test_generation.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""ADR-004 §3 — la génération est OPT-IN et configurable : clé, modèle, mode.
|
||||
Sans clé, le mode extractif est le contrat ; en mode api sans clé, le boot
|
||||
refuse (config validée au démarrage, comme l'API NestJS)."""
|
||||
|
||||
import pytest
|
||||
|
||||
from siop_ai.config import charger_reglages
|
||||
from siop_ai.generation import GenerateurExtractif, construire_generateur, construire_invite
|
||||
from siop_ai.recherche import ExtraitTrouve
|
||||
|
||||
|
||||
def _extrait(titre: str, locator: str, content: str) -> ExtraitTrouve:
|
||||
return ExtraitTrouve(
|
||||
source_type="DOCUMENT",
|
||||
document_id="d-1",
|
||||
work_order_id=None,
|
||||
titre=titre,
|
||||
locator=locator,
|
||||
content=content,
|
||||
score=0.9,
|
||||
)
|
||||
|
||||
|
||||
def test_defaut_extractif_sans_cle():
|
||||
generateur = construire_generateur("off", "", "claude-opus-4-8")
|
||||
assert isinstance(generateur, GenerateurExtractif)
|
||||
assert generateur.rediger("couple de serrage ?", [_extrait("n", "p. 1", "x")]) is None
|
||||
|
||||
|
||||
def test_mode_api_sans_cle_refuse_au_boot(monkeypatch):
|
||||
monkeypatch.setenv("AI_GENERATION", "api")
|
||||
monkeypatch.delenv("AI_API_KEY", raising=False)
|
||||
with pytest.raises(ValueError, match="AI_API_KEY"):
|
||||
charger_reglages()
|
||||
|
||||
|
||||
def test_mode_inconnu_refuse_au_boot(monkeypatch):
|
||||
monkeypatch.setenv("AI_GENERATION", "toujours")
|
||||
with pytest.raises(ValueError, match="AI_GENERATION"):
|
||||
charger_reglages()
|
||||
|
||||
|
||||
def test_cle_et_modele_configurables(monkeypatch):
|
||||
monkeypatch.setenv("AI_GENERATION", "api")
|
||||
monkeypatch.setenv("AI_API_KEY", "sk-test-123")
|
||||
monkeypatch.setenv("AI_MODEL", "claude-opus-4-8")
|
||||
reglages = charger_reglages()
|
||||
assert reglages.ai_api_key == "sk-test-123"
|
||||
assert reglages.ai_model == "claude-opus-4-8"
|
||||
|
||||
|
||||
def test_invite_numerote_les_extraits_et_rien_d_autre():
|
||||
invite = construire_invite(
|
||||
"quel couple de serrage pour les guides ?",
|
||||
[
|
||||
_extrait("Notice Gen2.pdf", "p. 42", "Serrer à 25 N·m."),
|
||||
_extrait("OT-2026-0341", "bilan du 2026-07-17", "Coulisseaux remplacés."),
|
||||
],
|
||||
)
|
||||
assert "[1] Notice Gen2.pdf · p. 42" in invite
|
||||
assert "[2] OT-2026-0341 · bilan du 2026-07-17" in invite
|
||||
assert invite.endswith("Question : quel couple de serrage pour les guides ?")
|
||||
36
apps/ai/tests/test_transcription.py
Normal file
36
apps/ai/tests/test_transcription.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""Dictée opt-in (R5 D5, ADR-004 §5) : off par défaut (aucune dépendance
|
||||
audio requise), déterministe en CI/tests. Le vrai moteur (faster-whisper)
|
||||
se vérifie en recette réelle (convention R5.1 — pas de mesure préalable
|
||||
demandée par le référent, 22/07/2026)."""
|
||||
|
||||
import pytest
|
||||
|
||||
from siop_ai.config import charger_reglages
|
||||
from siop_ai.transcription import TranscripteurDeterministe, construire_transcripteur
|
||||
|
||||
|
||||
def test_off_par_defaut_ne_construit_rien():
|
||||
assert construire_transcripteur("off", "small") is None
|
||||
|
||||
|
||||
def test_deterministe_stable_et_derive_la_taille():
|
||||
transcripteur = construire_transcripteur("deterministe", "small")
|
||||
assert isinstance(transcripteur, TranscripteurDeterministe)
|
||||
texte = transcripteur.transcrire(b"12345", "m4a")
|
||||
assert "5 octets" in texte
|
||||
assert ".m4a" in texte
|
||||
assert transcripteur.transcrire(b"12345", "m4a") == texte # stable
|
||||
|
||||
|
||||
def test_mode_inconnu_refuse_au_boot(monkeypatch):
|
||||
monkeypatch.setenv("AI_TRANSCRIPTION", "toujours")
|
||||
with pytest.raises(ValueError, match="AI_TRANSCRIPTION"):
|
||||
charger_reglages()
|
||||
|
||||
|
||||
def test_modele_configurable(monkeypatch):
|
||||
monkeypatch.setenv("AI_TRANSCRIPTION", "deterministe")
|
||||
monkeypatch.setenv("AI_TRANSCRIPTION_MODEL", "medium")
|
||||
reglages = charger_reglages()
|
||||
assert reglages.ai_transcription == "deterministe"
|
||||
assert reglages.ai_transcription_model == "medium"
|
||||
1974
apps/ai/uv.lock
generated
Normal file
1974
apps/ai/uv.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -18,3 +18,6 @@ MINIO_ENDPOINT=localhost
|
||||
MINIO_PORT=9000
|
||||
MINIO_ACCESS_KEY=siop
|
||||
MINIO_SECRET_KEY=siop-minio
|
||||
|
||||
# R4 — origines navigateur autorisées (Expo web / debug mobile). Vide = pas de CORS.
|
||||
CORS_ORIGINS=http://localhost:8081
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"@nestjs/core": "^11.1.0",
|
||||
"@nestjs/jwt": "^11.0.0",
|
||||
"@nestjs/platform-express": "^11.1.0",
|
||||
"@nestjs/throttler": "^6.5.0",
|
||||
"@prisma/client": "^6.8.0",
|
||||
"@siop/shared": "workspace:*",
|
||||
"argon2": "^0.43.0",
|
||||
@@ -39,6 +40,7 @@
|
||||
"@nestjs/testing": "^11.1.0",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/multer": "^2.2.0",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"jest": "^29.7.0",
|
||||
|
||||
60
apps/api/prisma/cleanup-e2e.ts
Normal file
60
apps/api/prisma/cleanup-e2e.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/** Purge des données créées par les parcours Playwright (motifs dédiés).
|
||||
* Appelé par le globalSetup e2e — la base locale reste propre entre les runs. */
|
||||
import 'dotenv/config';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
// R3 — d'abord les traces de gestion (les mouvements liés à un OT E2E
|
||||
// seraient orphelins après sa suppression et fausseraient les stocks seedés)
|
||||
await prisma.stockMovement.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ part: { designation: { contains: 'E2E' } } },
|
||||
{ workOrder: { title: { contains: 'E2E' } } },
|
||||
{ purchaseOrder: { supplier: { name: { contains: 'E2E' } } } },
|
||||
],
|
||||
},
|
||||
});
|
||||
await prisma.document.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ fileName: { contains: 'e2e' } },
|
||||
{ workOrder: { title: { contains: 'E2E' } } },
|
||||
],
|
||||
},
|
||||
});
|
||||
await prisma.purchaseOrderLine.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ part: { designation: { contains: 'E2E' } } },
|
||||
{ purchaseOrder: { supplier: { name: { contains: 'E2E' } } } },
|
||||
],
|
||||
},
|
||||
});
|
||||
await prisma.purchaseOrder.deleteMany({
|
||||
where: { supplier: { name: { contains: 'E2E' } } },
|
||||
});
|
||||
await prisma.part.deleteMany({ where: { designation: { contains: 'E2E' } } });
|
||||
await prisma.partner.deleteMany({ where: { name: { contains: 'E2E' } } });
|
||||
|
||||
await prisma.request.deleteMany({ where: { description: { contains: 'E2E' } } });
|
||||
await prisma.workOrder.deleteMany({ where: { title: { contains: 'E2E' } } });
|
||||
await prisma.meterReading.deleteMany({ where: { value: { gte: 90_000_000 } } });
|
||||
// Un appareil de test peut avoir reçu grilles/demandes (génération du mois) :
|
||||
// on purge ses dépendances avant lui (FK RESTRICT).
|
||||
await prisma.request.deleteMany({ where: { asset: { reference: { startsWith: 'E2E-' } } } });
|
||||
await prisma.workOrder.deleteMany({ where: { asset: { reference: { startsWith: 'E2E-' } } } });
|
||||
await prisma.asset.deleteMany({ where: { reference: { startsWith: 'E2E-' } } });
|
||||
await prisma.location.deleteMany({ where: { name: { contains: 'E2E' }, parentId: { not: null } } });
|
||||
await prisma.location.deleteMany({ where: { name: { contains: 'E2E' } } });
|
||||
await prisma.user.deleteMany({ where: { email: { startsWith: 'recrue-' } } });
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
@@ -0,0 +1,145 @@
|
||||
-- Migration autosuffisante : les extensions requises sont créées si absentes
|
||||
-- (la CI et tout environnement neuf n’ont pas notre init.sql).
|
||||
CREATE EXTENSION IF NOT EXISTS postgis;
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "CategoryKind" AS ENUM ('EQUIPMENT', 'COMPONENT_TYPE');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AssetStatus" AS ENUM ('IN_SERVICE', 'OUT_OF_SERVICE', 'UNDER_MAINTENANCE');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "activationExpiresAt" TIMESTAMP(3),
|
||||
ADD COLUMN "activationToken" TEXT,
|
||||
ADD COLUMN "phone" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Category" (
|
||||
"id" UUID NOT NULL,
|
||||
"kind" "CategoryKind" NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
|
||||
CONSTRAINT "Category_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Location" (
|
||||
"id" UUID NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"parentId" UUID,
|
||||
"address" TEXT,
|
||||
"city" TEXT,
|
||||
"guardianName" TEXT,
|
||||
"guardianPhone" TEXT,
|
||||
"latitude" DOUBLE PRECISION,
|
||||
"longitude" DOUBLE PRECISION,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Location_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Asset" (
|
||||
"id" UUID NOT NULL,
|
||||
"reference" TEXT NOT NULL,
|
||||
"brand" TEXT NOT NULL,
|
||||
"model" TEXT,
|
||||
"serialNumber" TEXT,
|
||||
"commissionedAt" TIMESTAMP(3),
|
||||
"loadKg" INTEGER,
|
||||
"floors" INTEGER,
|
||||
"status" "AssetStatus" NOT NULL DEFAULT 'IN_SERVICE',
|
||||
"categoryId" UUID NOT NULL,
|
||||
"locationId" UUID NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Asset_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AssetComponent" (
|
||||
"id" UUID NOT NULL,
|
||||
"assetId" UUID NOT NULL,
|
||||
"typeId" UUID NOT NULL,
|
||||
"designation" TEXT,
|
||||
|
||||
CONSTRAINT "AssetComponent_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Team" (
|
||||
"id" UUID NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
|
||||
CONSTRAINT "Team_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "_TeamToUser" (
|
||||
"A" UUID NOT NULL,
|
||||
"B" UUID NOT NULL,
|
||||
|
||||
CONSTRAINT "_TeamToUser_AB_pkey" PRIMARY KEY ("A","B")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Category_kind_name_key" ON "Category"("kind", "name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Location_parentId_idx" ON "Location"("parentId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Asset_reference_key" ON "Asset"("reference");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Asset_locationId_idx" ON "Asset"("locationId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Asset_categoryId_idx" ON "Asset"("categoryId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AssetComponent_assetId_idx" ON "AssetComponent"("assetId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Team_name_key" ON "Team"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "_TeamToUser_B_index" ON "_TeamToUser"("B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_activationToken_key" ON "User"("activationToken");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Location" ADD CONSTRAINT "Location_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "Location"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Asset" ADD CONSTRAINT "Asset_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Asset" ADD CONSTRAINT "Asset_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AssetComponent" ADD CONSTRAINT "AssetComponent_assetId_fkey" FOREIGN KEY ("assetId") REFERENCES "Asset"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AssetComponent" ADD CONSTRAINT "AssetComponent_typeId_fkey" FOREIGN KEY ("typeId") REFERENCES "Category"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_TeamToUser" ADD CONSTRAINT "_TeamToUser_A_fkey" FOREIGN KEY ("A") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_TeamToUser" ADD CONSTRAINT "_TeamToUser_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
|
||||
-- ————— PostGIS (hors périmètre Prisma, décision modèle R1) —————
|
||||
-- Position générée depuis latitude/longitude : rien à synchroniser côté app,
|
||||
-- la colonne est prête pour les requêtes spatiales (affectation auto, backlog).
|
||||
ALTER TABLE "Location"
|
||||
ADD COLUMN "position" geography(Point, 4326)
|
||||
GENERATED ALWAYS AS (ST_SetSRID(ST_MakePoint("longitude", "latitude"), 4326)::geography) STORED;
|
||||
|
||||
CREATE INDEX "Location_position_gix" ON "Location" USING GIST ("position");
|
||||
@@ -0,0 +1,268 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "WorkOrderType" AS ENUM ('CORRECTIVE', 'PREVENTIVE', 'WORKS');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "WorkOrderStatus" AS ENUM ('OPEN', 'IN_PROGRESS', 'ON_HOLD', 'DONE', 'CANCELLED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "WorkOrderPriority" AS ENUM ('NONE', 'LOW', 'MEDIUM', 'HIGH', 'PERSON_TRAPPED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "RequestStatus" AS ENUM ('RECEIVED', 'APPROVED', 'REJECTED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ChecklistState" AS ENUM ('PENDING', 'DONE', 'NA');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "BilanField" AS ENUM ('DOOR_STATE', 'CABIN_POSITION', 'ANOMALY', 'EXTERNAL_CAUSE', 'ACTION_TAKEN', 'COMPONENT_CONCERNED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "MeterKind" AS ENUM ('RUNNING_HOURS', 'STARTS');
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "Location_position_gix";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Location" DROP COLUMN "position";
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "WorkOrder" (
|
||||
"id" UUID NOT NULL,
|
||||
"reference" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"type" "WorkOrderType" NOT NULL,
|
||||
"status" "WorkOrderStatus" NOT NULL DEFAULT 'OPEN',
|
||||
"priority" "WorkOrderPriority" NOT NULL DEFAULT 'NONE',
|
||||
"assetId" UUID NOT NULL,
|
||||
"dueDate" TIMESTAMP(3),
|
||||
"createdById" UUID,
|
||||
"startedAt" TIMESTAMP(3),
|
||||
"completedAt" TIMESTAMP(3),
|
||||
"cancelledAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "WorkOrder_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "WorkOrderEvent" (
|
||||
"id" UUID NOT NULL,
|
||||
"workOrderId" UUID NOT NULL,
|
||||
"kind" TEXT NOT NULL,
|
||||
"message" TEXT,
|
||||
"byId" UUID,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "WorkOrderEvent_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Request" (
|
||||
"id" UUID NOT NULL,
|
||||
"reference" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL,
|
||||
"isPersonTrapped" BOOLEAN NOT NULL DEFAULT false,
|
||||
"status" "RequestStatus" NOT NULL DEFAULT 'RECEIVED',
|
||||
"rejectionReason" TEXT,
|
||||
"assetId" UUID NOT NULL,
|
||||
"requestedById" UUID,
|
||||
"requesterName" TEXT,
|
||||
"workOrderId" UUID,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Request_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ReferenceValue" (
|
||||
"id" UUID NOT NULL,
|
||||
"field" "BilanField" NOT NULL,
|
||||
"label" TEXT NOT NULL,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
|
||||
CONSTRAINT "ReferenceValue_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "InterventionReport" (
|
||||
"id" UUID NOT NULL,
|
||||
"workOrderId" UUID NOT NULL,
|
||||
"note" TEXT,
|
||||
"doorStateId" UUID,
|
||||
"cabinPositionId" UUID,
|
||||
"anomalyId" UUID,
|
||||
"externalCauseId" UUID,
|
||||
"actionTakenId" UUID,
|
||||
"componentConcernedId" UUID,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "InterventionReport_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TaskTemplate" (
|
||||
"id" UUID NOT NULL,
|
||||
"label" TEXT NOT NULL,
|
||||
"componentTypeId" UUID,
|
||||
"periodMonths" INTEGER NOT NULL,
|
||||
"isRegulatory" BOOLEAN NOT NULL DEFAULT false,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
|
||||
CONSTRAINT "TaskTemplate_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ChecklistItem" (
|
||||
"id" UUID NOT NULL,
|
||||
"workOrderId" UUID NOT NULL,
|
||||
"label" TEXT NOT NULL,
|
||||
"state" "ChecklistState" NOT NULL DEFAULT 'PENDING',
|
||||
"templateId" UUID,
|
||||
"doneById" UUID,
|
||||
"doneAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "ChecklistItem_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Meter" (
|
||||
"id" UUID NOT NULL,
|
||||
"assetId" UUID NOT NULL,
|
||||
"kind" "MeterKind" NOT NULL,
|
||||
|
||||
CONSTRAINT "Meter_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "MeterReading" (
|
||||
"id" UUID NOT NULL,
|
||||
"meterId" UUID NOT NULL,
|
||||
"value" INTEGER NOT NULL,
|
||||
"readById" UUID,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "MeterReading_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "_WorkOrderAssignees" (
|
||||
"A" UUID NOT NULL,
|
||||
"B" UUID NOT NULL,
|
||||
|
||||
CONSTRAINT "_WorkOrderAssignees_AB_pkey" PRIMARY KEY ("A","B")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "WorkOrder_reference_key" ON "WorkOrder"("reference");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WorkOrder_assetId_idx" ON "WorkOrder"("assetId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WorkOrder_status_idx" ON "WorkOrder"("status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WorkOrderEvent_workOrderId_idx" ON "WorkOrderEvent"("workOrderId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Request_reference_key" ON "Request"("reference");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Request_workOrderId_key" ON "Request"("workOrderId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Request_assetId_idx" ON "Request"("assetId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ReferenceValue_field_label_key" ON "ReferenceValue"("field", "label");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "InterventionReport_workOrderId_key" ON "InterventionReport"("workOrderId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "TaskTemplate_label_key" ON "TaskTemplate"("label");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ChecklistItem_workOrderId_idx" ON "ChecklistItem"("workOrderId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Meter_assetId_kind_key" ON "Meter"("assetId", "kind");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "MeterReading_meterId_idx" ON "MeterReading"("meterId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "_WorkOrderAssignees_B_index" ON "_WorkOrderAssignees"("B");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "WorkOrder" ADD CONSTRAINT "WorkOrder_assetId_fkey" FOREIGN KEY ("assetId") REFERENCES "Asset"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "WorkOrder" ADD CONSTRAINT "WorkOrder_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "WorkOrderEvent" ADD CONSTRAINT "WorkOrderEvent_workOrderId_fkey" FOREIGN KEY ("workOrderId") REFERENCES "WorkOrder"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "WorkOrderEvent" ADD CONSTRAINT "WorkOrderEvent_byId_fkey" FOREIGN KEY ("byId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Request" ADD CONSTRAINT "Request_assetId_fkey" FOREIGN KEY ("assetId") REFERENCES "Asset"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Request" ADD CONSTRAINT "Request_requestedById_fkey" FOREIGN KEY ("requestedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Request" ADD CONSTRAINT "Request_workOrderId_fkey" FOREIGN KEY ("workOrderId") REFERENCES "WorkOrder"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "InterventionReport" ADD CONSTRAINT "InterventionReport_workOrderId_fkey" FOREIGN KEY ("workOrderId") REFERENCES "WorkOrder"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "InterventionReport" ADD CONSTRAINT "InterventionReport_doorStateId_fkey" FOREIGN KEY ("doorStateId") REFERENCES "ReferenceValue"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "InterventionReport" ADD CONSTRAINT "InterventionReport_cabinPositionId_fkey" FOREIGN KEY ("cabinPositionId") REFERENCES "ReferenceValue"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "InterventionReport" ADD CONSTRAINT "InterventionReport_anomalyId_fkey" FOREIGN KEY ("anomalyId") REFERENCES "ReferenceValue"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "InterventionReport" ADD CONSTRAINT "InterventionReport_externalCauseId_fkey" FOREIGN KEY ("externalCauseId") REFERENCES "ReferenceValue"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "InterventionReport" ADD CONSTRAINT "InterventionReport_actionTakenId_fkey" FOREIGN KEY ("actionTakenId") REFERENCES "ReferenceValue"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "InterventionReport" ADD CONSTRAINT "InterventionReport_componentConcernedId_fkey" FOREIGN KEY ("componentConcernedId") REFERENCES "ReferenceValue"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TaskTemplate" ADD CONSTRAINT "TaskTemplate_componentTypeId_fkey" FOREIGN KEY ("componentTypeId") REFERENCES "Category"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ChecklistItem" ADD CONSTRAINT "ChecklistItem_workOrderId_fkey" FOREIGN KEY ("workOrderId") REFERENCES "WorkOrder"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ChecklistItem" ADD CONSTRAINT "ChecklistItem_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "TaskTemplate"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ChecklistItem" ADD CONSTRAINT "ChecklistItem_doneById_fkey" FOREIGN KEY ("doneById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Meter" ADD CONSTRAINT "Meter_assetId_fkey" FOREIGN KEY ("assetId") REFERENCES "Asset"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "MeterReading" ADD CONSTRAINT "MeterReading_meterId_fkey" FOREIGN KEY ("meterId") REFERENCES "Meter"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "MeterReading" ADD CONSTRAINT "MeterReading_readById_fkey" FOREIGN KEY ("readById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_WorkOrderAssignees" ADD CONSTRAINT "_WorkOrderAssignees_A_fkey" FOREIGN KEY ("A") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_WorkOrderAssignees" ADD CONSTRAINT "_WorkOrderAssignees_B_fkey" FOREIGN KEY ("B") REFERENCES "WorkOrder"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Asset" ADD COLUMN "underContract" BOOLEAN NOT NULL DEFAULT true;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "WorkOrder" ADD COLUMN "periodKey" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "WorkOrder_assetId_periodKey_key" ON "WorkOrder"("assetId", "periodKey");
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Request" ADD COLUMN "publicToken" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Request_publicToken_key" ON "Request"("publicToken");
|
||||
|
||||
@@ -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;
|
||||
@@ -39,12 +39,514 @@ model User {
|
||||
email String @unique
|
||||
displayName String
|
||||
passwordHash String? // null tant que le compte n'est pas activé (R1)
|
||||
phone String?
|
||||
roleId String @db.Uuid
|
||||
role Role @relation(fields: [roleId], references: [id])
|
||||
isActive Boolean @default(true)
|
||||
isDemo Boolean @default(false) // seul un compte isDemo est empruntable (ADR-002)
|
||||
// Invitation (R1) : lien d'activation 7 jours, usage unique.
|
||||
// Statut dérivé : invité = passwordHash null && token présent.
|
||||
activationToken String? @unique
|
||||
activationExpiresAt DateTime?
|
||||
teams Team[]
|
||||
// R6.6 — sites autorisés en signalement (Demandeur) ; vide = aucune restriction
|
||||
assignedSites Location[]
|
||||
// R2 — exploitation
|
||||
workOrdersAssigned WorkOrder[] @relation("WorkOrderAssignees")
|
||||
workOrdersCreated WorkOrder[] @relation("WorkOrderCreator")
|
||||
workOrderEvents WorkOrderEvent[]
|
||||
requests Request[]
|
||||
checklistDone ChecklistItem[]
|
||||
meterReadings MeterReading[]
|
||||
// R3 — gestion : taux horaire COURANT (le taux d'une saisie est figé dans LaborTime)
|
||||
hourlyRate Decimal? @db.Decimal(8, 2)
|
||||
laborTimes LaborTime[]
|
||||
stockMovements StockMovement[]
|
||||
purchaseOrders PurchaseOrder[]
|
||||
documents Document[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([roleId])
|
||||
}
|
||||
|
||||
// ————— R1 — Référentiel (docs/03-architecture/modele-donnees.md §R1) —————
|
||||
|
||||
enum CategoryKind {
|
||||
EQUIPMENT
|
||||
COMPONENT_TYPE
|
||||
}
|
||||
|
||||
enum AssetStatus {
|
||||
IN_SERVICE
|
||||
OUT_OF_SERVICE
|
||||
UNDER_MAINTENANCE
|
||||
}
|
||||
|
||||
model Category {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
kind CategoryKind
|
||||
name String
|
||||
isActive Boolean @default(true) // désactivable, jamais supprimée si utilisée
|
||||
assets Asset[]
|
||||
components AssetComponent[]
|
||||
taskTemplates TaskTemplate[]
|
||||
|
||||
@@unique([kind, name])
|
||||
}
|
||||
|
||||
model Location {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
name String
|
||||
parentId String? @db.Uuid // site (null) → zone ; profondeur max 2 (service)
|
||||
parent Location? @relation("LocationTree", fields: [parentId], references: [id])
|
||||
children Location[] @relation("LocationTree")
|
||||
address String?
|
||||
city String?
|
||||
guardianName String?
|
||||
guardianPhone String?
|
||||
latitude Float?
|
||||
longitude Float?
|
||||
// + colonne PostGIS générée (voir migration r1_referentiel) :
|
||||
// position geography(Point,4326) GENERATED ALWAYS AS (…) STORED
|
||||
// Client/syndic gérant le site (R3 — colonne « Rattachements » des Tiers)
|
||||
partnerId String? @db.Uuid
|
||||
partner Partner? @relation(fields: [partnerId], references: [id])
|
||||
assets Asset[]
|
||||
assignedUsers User[] // reverse de User.assignedSites (R6.6)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([parentId])
|
||||
@@index([partnerId])
|
||||
}
|
||||
|
||||
model Asset {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
reference String @unique // « A1 » — imprimée sur l'étiquette QR
|
||||
brand String
|
||||
model String?
|
||||
serialNumber String?
|
||||
commissionedAt DateTime?
|
||||
loadKg Int?
|
||||
floors Int?
|
||||
status AssetStatus @default(IN_SERVICE) // statut d'ÉQUIPEMENT ≠ statut d'OT
|
||||
underContract Boolean @default(true) // contrat préventif (grille mensuelle)
|
||||
categoryId String @db.Uuid
|
||||
category Category @relation(fields: [categoryId], references: [id])
|
||||
locationId String @db.Uuid
|
||||
location Location @relation(fields: [locationId], references: [id])
|
||||
components AssetComponent[]
|
||||
workOrders WorkOrder[]
|
||||
requests Request[]
|
||||
meters Meter[]
|
||||
documents Document[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([locationId])
|
||||
@@index([categoryId])
|
||||
}
|
||||
|
||||
// Organe : PAS de colonne emplacement — « un organe n'a pas d'emplacement
|
||||
// propre » est garanti par construction (décision maquettes R1).
|
||||
model AssetComponent {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
assetId String @db.Uuid
|
||||
asset Asset @relation(fields: [assetId], references: [id], onDelete: Cascade)
|
||||
typeId String @db.Uuid
|
||||
type Category @relation(fields: [typeId], references: [id])
|
||||
designation String?
|
||||
|
||||
@@index([assetId])
|
||||
}
|
||||
|
||||
model Team {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
name String @unique
|
||||
description String?
|
||||
members User[]
|
||||
}
|
||||
|
||||
// ————— R2 — Exploitation (docs/03-architecture/modele-donnees.md §R2) —————
|
||||
|
||||
enum WorkOrderType {
|
||||
CORRECTIVE // Dépannage
|
||||
PREVENTIVE // Maintenance (grille du mois)
|
||||
WORKS // Travaux
|
||||
}
|
||||
|
||||
enum WorkOrderStatus {
|
||||
OPEN
|
||||
IN_PROGRESS
|
||||
ON_HOLD
|
||||
DONE
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
enum WorkOrderPriority {
|
||||
NONE
|
||||
LOW
|
||||
MEDIUM
|
||||
HIGH
|
||||
PERSON_TRAPPED // personne bloquée — urgence absolue
|
||||
}
|
||||
|
||||
enum RequestStatus {
|
||||
RECEIVED
|
||||
APPROVED
|
||||
REJECTED
|
||||
}
|
||||
|
||||
enum ChecklistState {
|
||||
PENDING
|
||||
DONE
|
||||
NA
|
||||
}
|
||||
|
||||
enum BilanField {
|
||||
DOOR_STATE
|
||||
CABIN_POSITION
|
||||
ANOMALY
|
||||
EXTERNAL_CAUSE
|
||||
ACTION_TAKEN
|
||||
COMPONENT_CONCERNED
|
||||
}
|
||||
|
||||
enum MeterKind {
|
||||
RUNNING_HOURS
|
||||
STARTS
|
||||
}
|
||||
|
||||
model WorkOrder {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
reference String @unique // OT-2026-0341
|
||||
title String
|
||||
description String?
|
||||
type WorkOrderType
|
||||
status WorkOrderStatus @default(OPEN) // machine à états stricte (service)
|
||||
priority WorkOrderPriority @default(NONE)
|
||||
assetId String @db.Uuid
|
||||
asset Asset @relation(fields: [assetId], references: [id])
|
||||
dueDate DateTime?
|
||||
assignees User[] @relation("WorkOrderAssignees")
|
||||
createdById String? @db.Uuid
|
||||
createdBy User? @relation("WorkOrderCreator", fields: [createdById], references: [id])
|
||||
startedAt DateTime?
|
||||
completedAt DateTime?
|
||||
cancelledAt DateTime?
|
||||
events WorkOrderEvent[]
|
||||
ragChunks RagChunk[]
|
||||
checklist ChecklistItem[]
|
||||
report InterventionReport?
|
||||
request Request?
|
||||
stockMovements StockMovement[]
|
||||
laborTimes LaborTime[]
|
||||
documents Document[]
|
||||
// Grille du mois : « AAAA-MM » — l'unicité [assetId, periodKey] EST
|
||||
// l'idempotence de la génération (les OT correctifs restent à null).
|
||||
periodKey String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([assetId, periodKey])
|
||||
@@index([assetId])
|
||||
@@index([status])
|
||||
}
|
||||
|
||||
model WorkOrderEvent {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
workOrderId String @db.Uuid
|
||||
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
|
||||
kind String // COMMENT · STATUS_CHANGED · ASSIGNED · CREATED · FROM_REQUEST
|
||||
message String?
|
||||
byId String? @db.Uuid
|
||||
by User? @relation(fields: [byId], references: [id])
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([workOrderId])
|
||||
}
|
||||
|
||||
model Request {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
reference String @unique // DEM-2026-0112
|
||||
description String
|
||||
isPersonTrapped Boolean @default(false)
|
||||
status RequestStatus @default(RECEIVED)
|
||||
rejectionReason String? // REQUIS au rejet (service)
|
||||
assetId String @db.Uuid
|
||||
asset Asset @relation(fields: [assetId], references: [id])
|
||||
requestedById String? @db.Uuid
|
||||
requestedBy User? @relation(fields: [requestedById], references: [id])
|
||||
requesterName String? // portail public via QR (R2.4)
|
||||
// Suivi SANS COMPTE (portail) : le téléphone du gardien garde ce jeton,
|
||||
// seul moyen de lire l'avancement — jamais listé, jamais devinable.
|
||||
publicToken String? @unique
|
||||
workOrderId String? @unique @db.Uuid // lien 1-1 — jamais de doublon
|
||||
workOrder WorkOrder? @relation(fields: [workOrderId], references: [id])
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([assetId])
|
||||
}
|
||||
|
||||
// Référentiels administrables du bilan codé (un par champ)
|
||||
model ReferenceValue {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
field BilanField
|
||||
label String
|
||||
isActive Boolean @default(true)
|
||||
|
||||
doorStates InterventionReport[] @relation("BilanDoorState")
|
||||
cabinPositions InterventionReport[] @relation("BilanCabinPosition")
|
||||
anomalies InterventionReport[] @relation("BilanAnomaly")
|
||||
externalCauses InterventionReport[] @relation("BilanExternalCause")
|
||||
actionsTaken InterventionReport[] @relation("BilanActionTaken")
|
||||
componentsConcerned InterventionReport[] @relation("BilanComponentConcerned")
|
||||
|
||||
@@unique([field, label])
|
||||
}
|
||||
|
||||
model InterventionReport {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
workOrderId String @unique @db.Uuid
|
||||
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
|
||||
note String?
|
||||
|
||||
doorStateId String? @db.Uuid
|
||||
doorState ReferenceValue? @relation("BilanDoorState", fields: [doorStateId], references: [id])
|
||||
cabinPositionId String? @db.Uuid
|
||||
cabinPosition ReferenceValue? @relation("BilanCabinPosition", fields: [cabinPositionId], references: [id])
|
||||
anomalyId String? @db.Uuid
|
||||
anomaly ReferenceValue? @relation("BilanAnomaly", fields: [anomalyId], references: [id])
|
||||
externalCauseId String? @db.Uuid
|
||||
externalCause ReferenceValue? @relation("BilanExternalCause", fields: [externalCauseId], references: [id])
|
||||
actionTakenId String? @db.Uuid
|
||||
actionTaken ReferenceValue? @relation("BilanActionTaken", fields: [actionTakenId], references: [id])
|
||||
componentConcernedId String? @db.Uuid
|
||||
componentConcerned ReferenceValue? @relation("BilanComponentConcerned", fields: [componentConcernedId], references: [id])
|
||||
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model TaskTemplate {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
label String @unique
|
||||
componentTypeId String? @db.Uuid
|
||||
componentType Category? @relation(fields: [componentTypeId], references: [id])
|
||||
periodMonths Int // 1, 3, 6, 12…
|
||||
isRegulatory Boolean @default(false) // essai parachute
|
||||
isActive Boolean @default(true)
|
||||
checklistItems ChecklistItem[]
|
||||
}
|
||||
|
||||
model ChecklistItem {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
workOrderId String @db.Uuid
|
||||
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
|
||||
label String
|
||||
state ChecklistState @default(PENDING)
|
||||
templateId String? @db.Uuid
|
||||
template TaskTemplate? @relation(fields: [templateId], references: [id])
|
||||
doneById String? @db.Uuid
|
||||
doneBy User? @relation(fields: [doneById], references: [id])
|
||||
doneAt DateTime?
|
||||
|
||||
@@index([workOrderId])
|
||||
}
|
||||
|
||||
model Meter {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
assetId String @db.Uuid
|
||||
asset Asset @relation(fields: [assetId], references: [id], onDelete: Cascade)
|
||||
kind MeterKind
|
||||
readings MeterReading[]
|
||||
|
||||
@@unique([assetId, kind])
|
||||
}
|
||||
|
||||
model MeterReading {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
meterId String @db.Uuid
|
||||
meter Meter @relation(fields: [meterId], references: [id], onDelete: Cascade)
|
||||
value Int // strictement croissant (service)
|
||||
readById String? @db.Uuid
|
||||
readBy User? @relation(fields: [readById], references: [id])
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@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
|
||||
}
|
||||
|
||||
@@ -131,6 +131,17 @@ export async function seed(prisma: PrismaClient): Promise<void> {
|
||||
const passwordHash = await argon2.hash(
|
||||
process.env.SEED_DEMO_PASSWORD ?? 'Demo!2026',
|
||||
);
|
||||
await seedUsers(prisma, roleIds, passwordHash);
|
||||
await seedReferentiel(prisma);
|
||||
await seedExploitation(prisma);
|
||||
await seedGestion(prisma);
|
||||
}
|
||||
|
||||
async function seedUsers(
|
||||
prisma: PrismaClient,
|
||||
roleIds: Map<RoleName, string>,
|
||||
passwordHash: string,
|
||||
): Promise<void> {
|
||||
for (const u of DEMO_USERS) {
|
||||
// Les comptes démo appartiennent au seed : nom et rôle sont réalignés
|
||||
// à chaque exécution (jamais le mot de passe d'un compte existant).
|
||||
@@ -148,6 +159,623 @@ export async function seed(prisma: PrismaClient): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// ————— R1 — Référentiel (données des maquettes validées) —————
|
||||
|
||||
const EQUIPMENT_CATEGORIES = [
|
||||
'Ascenseur électrique',
|
||||
'Ascenseur hydraulique',
|
||||
'Monte-charge',
|
||||
'EPMR (plateforme PMR)',
|
||||
];
|
||||
const COMPONENT_TYPES = [
|
||||
'Portes cabine / palières',
|
||||
'Treuil / machinerie',
|
||||
'Parachute',
|
||||
'Armoire de commande',
|
||||
'Boutons & signalisation',
|
||||
];
|
||||
|
||||
/** site → zones ; positions réelles Casablanca/Mohammedia. */
|
||||
const SITES: {
|
||||
name: string;
|
||||
address: string;
|
||||
city: string;
|
||||
guardianName?: string;
|
||||
guardianPhone?: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
zones: string[];
|
||||
}[] = [
|
||||
{
|
||||
name: 'Tour Atlas', address: 'Bd de la Corniche, Aïn Diab', city: 'Casablanca',
|
||||
guardianName: 'Karim Doukkali', guardianPhone: '06 61 23 45 67',
|
||||
latitude: 33.6062, longitude: -7.6706,
|
||||
zones: ['Hall principal', 'Tour bureaux (étages 1-24)', 'Parking sous-sol', 'Résidence (aile est)'],
|
||||
},
|
||||
{
|
||||
name: 'Résidence Al Manar', address: 'Bd Hassan II', city: 'Mohammedia',
|
||||
guardianName: 'Hassan Alami',
|
||||
latitude: 33.6866, longitude: -7.383,
|
||||
zones: ['Hall principal'],
|
||||
},
|
||||
{
|
||||
name: 'Anfa Place', address: 'Bd de l’Océan Atlantique', city: 'Casablanca',
|
||||
latitude: 33.5883, longitude: -7.6822,
|
||||
zones: ['Galerie commerciale'],
|
||||
},
|
||||
{
|
||||
name: 'Clinique Yasmine', address: 'Rue Ibn Rochd', city: 'Casablanca',
|
||||
guardianName: 'Rachid Mansouri',
|
||||
latitude: 33.5731, longitude: -7.6316,
|
||||
zones: ['Bloc A'],
|
||||
},
|
||||
{
|
||||
name: 'Marina Center', address: 'Av. des FAR', city: 'Mohammedia',
|
||||
latitude: 33.7, longitude: -7.39,
|
||||
zones: ['Hall B'],
|
||||
},
|
||||
];
|
||||
|
||||
const ASSETS: {
|
||||
reference: string; brand: string; model: string; serialNumber?: string;
|
||||
commissionedAt?: string; loadKg?: number; floors?: number;
|
||||
category: string; site: string; zone: string;
|
||||
status?: 'IN_SERVICE' | 'OUT_OF_SERVICE' | 'UNDER_MAINTENANCE';
|
||||
components?: { type: string; designation?: string }[];
|
||||
}[] = [
|
||||
{
|
||||
reference: 'A1', brand: 'Otis', model: 'Gen2 Premier', serialNumber: 'OT-2020-4521',
|
||||
commissionedAt: '2020-03-15', loadKg: 630, floors: 8,
|
||||
category: 'Ascenseur électrique', site: 'Résidence Al Manar', zone: 'Hall principal',
|
||||
components: [
|
||||
{ type: 'Portes cabine / palières', designation: 'Fermator 40/10' },
|
||||
{ type: 'Treuil / machinerie', designation: 'Gen2 gearless' },
|
||||
{ type: 'Parachute' },
|
||||
{ type: 'Armoire de commande', designation: 'MCS 220' },
|
||||
],
|
||||
},
|
||||
{
|
||||
reference: 'A2', brand: 'Otis', model: 'Gen2', loadKg: 630, floors: 12,
|
||||
category: 'Ascenseur électrique', site: 'Tour Atlas', zone: 'Hall principal',
|
||||
},
|
||||
{
|
||||
reference: 'B1', brand: 'Schindler', model: '3300', loadKg: 1000, floors: 24,
|
||||
category: 'Ascenseur électrique', site: 'Tour Atlas', zone: 'Tour bureaux (étages 1-24)',
|
||||
},
|
||||
{
|
||||
reference: 'B2', brand: 'Schindler', model: '3300', loadKg: 1000, floors: 24,
|
||||
category: 'Ascenseur électrique', site: 'Tour Atlas', zone: 'Tour bureaux (étages 1-24)',
|
||||
status: 'OUT_OF_SERVICE',
|
||||
components: [
|
||||
{ type: 'Portes cabine / palières', designation: 'Sematic' },
|
||||
{ type: 'Armoire de commande' },
|
||||
],
|
||||
},
|
||||
{
|
||||
reference: 'M1', brand: 'Kone', model: 'TranSys', loadKg: 2000, floors: 3,
|
||||
category: 'Monte-charge', site: 'Tour Atlas', zone: 'Parking sous-sol',
|
||||
status: 'UNDER_MAINTENANCE',
|
||||
},
|
||||
{
|
||||
reference: 'C1', brand: 'Kone', model: 'MonoSpace 500', loadKg: 800, floors: 5,
|
||||
category: 'Ascenseur électrique', site: 'Anfa Place', zone: 'Galerie commerciale',
|
||||
},
|
||||
{
|
||||
reference: 'D1', brand: 'ThyssenKrupp', model: 'Evolution', loadKg: 1600, floors: 6,
|
||||
category: 'Ascenseur électrique', site: 'Clinique Yasmine', zone: 'Bloc A',
|
||||
},
|
||||
{
|
||||
reference: 'E2', brand: 'Otis', model: 'HydroFit', loadKg: 630, floors: 4,
|
||||
category: 'Ascenseur hydraulique', site: 'Marina Center', zone: 'Hall B',
|
||||
},
|
||||
];
|
||||
|
||||
const TEAMS: { name: string; description: string; memberEmails: string[] }[] = [
|
||||
{
|
||||
name: 'Casablanca Centre',
|
||||
description: 'Tour Atlas · Anfa Place · Clinique Yasmine',
|
||||
memberEmails: ['technicien@demo.siop.ma', 'technicien-limite@demo.siop.ma'],
|
||||
},
|
||||
{
|
||||
name: 'Mohammedia',
|
||||
description: 'Résidence Al Manar · Marina Center',
|
||||
memberEmails: [],
|
||||
},
|
||||
];
|
||||
|
||||
async function seedReferentiel(prisma: PrismaClient): Promise<void> {
|
||||
const categoryIds = new Map<string, string>();
|
||||
for (const [kind, names] of [
|
||||
['EQUIPMENT', EQUIPMENT_CATEGORIES],
|
||||
['COMPONENT_TYPE', COMPONENT_TYPES],
|
||||
] as const) {
|
||||
for (const name of names) {
|
||||
const category = await prisma.category.upsert({
|
||||
where: { kind_name: { kind, name } },
|
||||
update: {},
|
||||
create: { kind, name },
|
||||
});
|
||||
categoryIds.set(name, category.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Pas d'unicité en base sur (parent, nom) : idempotence par findFirst.
|
||||
const zoneIds = new Map<string, string>(); // « site / zone » → id
|
||||
for (const site of SITES) {
|
||||
const { zones, ...data } = site;
|
||||
let root = await prisma.location.findFirst({
|
||||
where: { name: site.name, parentId: null },
|
||||
});
|
||||
root ??= await prisma.location.create({ data });
|
||||
for (const zoneName of zones) {
|
||||
let zone = await prisma.location.findFirst({
|
||||
where: { name: zoneName, parentId: root.id },
|
||||
});
|
||||
zone ??= await prisma.location.create({
|
||||
data: { name: zoneName, parentId: root.id, city: site.city },
|
||||
});
|
||||
zoneIds.set(`${site.name} / ${zoneName}`, zone.id);
|
||||
}
|
||||
}
|
||||
|
||||
for (const asset of ASSETS) {
|
||||
const created = await prisma.asset.upsert({
|
||||
where: { reference: asset.reference },
|
||||
update: {},
|
||||
create: {
|
||||
reference: asset.reference,
|
||||
brand: asset.brand,
|
||||
model: asset.model,
|
||||
serialNumber: asset.serialNumber,
|
||||
commissionedAt: asset.commissionedAt ? new Date(asset.commissionedAt) : undefined,
|
||||
loadKg: asset.loadKg,
|
||||
floors: asset.floors,
|
||||
status: asset.status ?? 'IN_SERVICE',
|
||||
categoryId: categoryIds.get(asset.category)!,
|
||||
locationId: zoneIds.get(`${asset.site} / ${asset.zone}`)!,
|
||||
},
|
||||
include: { _count: { select: { components: true } } },
|
||||
});
|
||||
if (asset.components?.length && created._count.components === 0) {
|
||||
await prisma.assetComponent.createMany({
|
||||
data: asset.components.map((c) => ({
|
||||
assetId: created.id,
|
||||
typeId: categoryIds.get(c.type)!,
|
||||
designation: c.designation,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const team of TEAMS) {
|
||||
await prisma.team.upsert({
|
||||
where: { name: team.name },
|
||||
update: {},
|
||||
create: {
|
||||
name: team.name,
|
||||
description: team.description,
|
||||
members: { connect: team.memberEmails.map((email) => ({ email })) },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ————— R2 — Exploitation (référentiels du bilan, gabarits, données maquette) —————
|
||||
|
||||
const REFERENCE_VALUES: Record<string, string[]> = {
|
||||
DOOR_STATE: [
|
||||
'Fonctionnement normal',
|
||||
'Porte bloquée ouverte',
|
||||
'Porte bloquée fermée',
|
||||
'Fermeture incomplète',
|
||||
'Réouverture intempestive',
|
||||
],
|
||||
CABIN_POSITION: ['À niveau', 'Entre deux niveaux', 'Cuvette', 'Dernier niveau'],
|
||||
ANOMALY: [
|
||||
'Frottement mécanique',
|
||||
'Défaut électrique',
|
||||
'Usure normale',
|
||||
'Choc / vandalisme',
|
||||
'Aucune anomalie constatée',
|
||||
],
|
||||
EXTERNAL_CAUSE: ['Coupure électrique', 'Dégât des eaux', 'Mauvais usage', 'Aucune'],
|
||||
ACTION_TAKEN: [
|
||||
'Réglage',
|
||||
'Remplacement de pièce',
|
||||
'Nettoyage / graissage',
|
||||
'Remise en service simple',
|
||||
'Visite d’entretien',
|
||||
'Attente de pièce',
|
||||
],
|
||||
COMPONENT_CONCERNED: [
|
||||
'Portes',
|
||||
'Guides',
|
||||
'Treuil / machinerie',
|
||||
'Armoire de commande',
|
||||
'Boutons / signalisation',
|
||||
'Parachute',
|
||||
'Cabine',
|
||||
],
|
||||
};
|
||||
|
||||
/** Gabarits du préventif (maquette R2) — période calendaire en mois. */
|
||||
const TASK_TEMPLATES: {
|
||||
label: string;
|
||||
periodMonths: number;
|
||||
componentType?: string;
|
||||
isRegulatory?: boolean;
|
||||
}[] = [
|
||||
{ label: 'Contrôle fermeture / verrouillage des portes', periodMonths: 1, componentType: 'Portes cabine / palières' },
|
||||
{ label: 'Nettoyage cuvette et toit de cabine', periodMonths: 1 },
|
||||
{ label: 'Contrôle boutons cabine & paliers', periodMonths: 1, componentType: 'Boutons & signalisation' },
|
||||
{ label: 'Vérification éclairage de secours', periodMonths: 1 },
|
||||
{ label: 'Contrôle niveau d’huile réducteur', periodMonths: 3, componentType: 'Treuil / machinerie' },
|
||||
{ label: 'Vérification jeu des coulisseaux', periodMonths: 6 },
|
||||
{ label: 'Contrôle câbles de traction (usure, tension)', periodMonths: 6, componentType: 'Treuil / machinerie' },
|
||||
{ label: 'Essai du parachute', periodMonths: 12, componentType: 'Parachute', isRegulatory: true },
|
||||
];
|
||||
|
||||
async function seedExploitation(prisma: PrismaClient): Promise<void> {
|
||||
const refIds = new Map<string, string>(); // « FIELD/label » → id
|
||||
for (const [field, labels] of Object.entries(REFERENCE_VALUES)) {
|
||||
for (const label of labels) {
|
||||
const value = await prisma.referenceValue.upsert({
|
||||
where: { field_label: { field: field as never, label } },
|
||||
update: {},
|
||||
create: { field: field as never, label },
|
||||
});
|
||||
refIds.set(`${field}/${label}`, value.id);
|
||||
}
|
||||
}
|
||||
|
||||
const componentTypes = new Map(
|
||||
(await prisma.category.findMany({ where: { kind: 'COMPONENT_TYPE' } })).map((c) => [
|
||||
c.name,
|
||||
c.id,
|
||||
]),
|
||||
);
|
||||
for (const t of TASK_TEMPLATES) {
|
||||
await prisma.taskTemplate.upsert({
|
||||
where: { label: t.label },
|
||||
update: {},
|
||||
create: {
|
||||
label: t.label,
|
||||
periodMonths: t.periodMonths,
|
||||
isRegulatory: t.isRegulatory ?? false,
|
||||
componentTypeId: t.componentType ? componentTypes.get(t.componentType) : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Données de démonstration (rejouent la maquette : OT en cours, urgence,
|
||||
// grille du mois, demandes à approuver). Idempotent par référence.
|
||||
const parReference = async (ref: string) =>
|
||||
(await prisma.asset.findUniqueOrThrow({ where: { reference: ref } })).id;
|
||||
const parEmail = async (email: string) =>
|
||||
(await prisma.user.findUniqueOrThrow({ where: { email } })).id;
|
||||
|
||||
const a1 = await parReference('A1');
|
||||
const b2 = await parReference('B2');
|
||||
const c1 = await parReference('C1');
|
||||
const ahmed = await parEmail('technicien@demo.siop.ma');
|
||||
const salma = await parEmail('dispatcher@demo.siop.ma');
|
||||
const karim = await parEmail('demandeur@demo.siop.ma');
|
||||
const annee = new Date().getFullYear();
|
||||
|
||||
// R6.6 — Karim (Demandeur) est rattaché à Tour Atlas (cohérent avec le
|
||||
// guardianName déjà seedé pour ce site) : son signalement se limite à ce
|
||||
// parc, ses demandes historiques sur d'autres sites restent visibles.
|
||||
const tourAtlas = await prisma.location.findFirst({
|
||||
where: { name: 'Tour Atlas', parentId: null },
|
||||
});
|
||||
if (tourAtlas) {
|
||||
await prisma.user.update({
|
||||
where: { id: karim },
|
||||
data: { assignedSites: { connect: [{ id: tourAtlas.id }] } },
|
||||
});
|
||||
}
|
||||
|
||||
const grilleLabels = TASK_TEMPLATES.filter((t) => t.periodMonths === 1).map(
|
||||
(t) => t.label,
|
||||
);
|
||||
|
||||
const OTS: {
|
||||
ref: string; title: string; type: 'CORRECTIVE' | 'PREVENTIVE' | 'WORKS';
|
||||
status: 'OPEN' | 'IN_PROGRESS' | 'ON_HOLD' | 'DONE' | 'CANCELLED';
|
||||
priority: 'NONE' | 'LOW' | 'MEDIUM' | 'HIGH' | 'PERSON_TRAPPED';
|
||||
assetId: string; assignees?: string[]; dueJours?: number;
|
||||
checklist?: string[]; bilan?: Record<string, string>;
|
||||
}[] = [
|
||||
{
|
||||
ref: `OT-${annee}-0342`, title: 'Personne bloquée en cabine', type: 'CORRECTIVE',
|
||||
status: 'OPEN', priority: 'PERSON_TRAPPED', assetId: b2,
|
||||
},
|
||||
{
|
||||
ref: `OT-${annee}-0341`, title: 'Bruit anormal en gaine', type: 'CORRECTIVE',
|
||||
status: 'IN_PROGRESS', priority: 'HIGH', assetId: a1, assignees: [ahmed], dueJours: 2,
|
||||
},
|
||||
{
|
||||
ref: `OT-${annee}-0338`, title: 'Grille du mois — juillet', type: 'PREVENTIVE',
|
||||
status: 'IN_PROGRESS', priority: 'LOW', assetId: a1, assignees: [ahmed],
|
||||
dueJours: 15, checklist: grilleLabels,
|
||||
},
|
||||
{
|
||||
ref: `OT-${annee}-0332`, title: 'Réglage nivellement cabine', type: 'CORRECTIVE',
|
||||
status: 'DONE', priority: 'LOW', assetId: a1,
|
||||
bilan: {
|
||||
doorStateId: refIds.get('DOOR_STATE/Fonctionnement normal')!,
|
||||
actionTakenId: refIds.get('ACTION_TAKEN/Réglage')!,
|
||||
componentConcernedId: refIds.get('COMPONENT_CONCERNED/Guides')!,
|
||||
},
|
||||
},
|
||||
];
|
||||
for (const ot of OTS) {
|
||||
const existant = await prisma.workOrder.findUnique({ where: { reference: ot.ref } });
|
||||
if (existant) continue;
|
||||
await prisma.workOrder.create({
|
||||
data: {
|
||||
reference: ot.ref,
|
||||
title: ot.title,
|
||||
type: ot.type,
|
||||
status: ot.status,
|
||||
priority: ot.priority,
|
||||
assetId: ot.assetId,
|
||||
createdById: salma,
|
||||
dueDate: ot.dueJours
|
||||
? new Date(Date.now() + ot.dueJours * 24 * 3600 * 1000)
|
||||
: undefined,
|
||||
startedAt: ot.status === 'IN_PROGRESS' || ot.status === 'DONE' ? new Date() : undefined,
|
||||
completedAt: ot.status === 'DONE' ? new Date() : undefined,
|
||||
assignees: ot.assignees ? { connect: ot.assignees.map((id) => ({ id })) } : undefined,
|
||||
events: { create: { kind: 'CREATED', message: 'OT créé (seed)', byId: salma } },
|
||||
checklist: ot.checklist
|
||||
? { create: ot.checklist.map((label) => ({ label })) }
|
||||
: undefined,
|
||||
report: ot.bilan ? { create: ot.bilan } : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const DEMANDES: {
|
||||
ref: string; description: string; assetId: string; isPersonTrapped?: boolean;
|
||||
status: 'RECEIVED' | 'APPROVED' | 'REJECTED';
|
||||
rejectionReason?: string; otRef?: string;
|
||||
}[] = [
|
||||
{
|
||||
ref: `DEM-${annee}-0111`, assetId: a1, status: 'RECEIVED',
|
||||
description: 'La porte ne se ferme plus au 3ᵉ étage, il faut la retenir à la main.',
|
||||
},
|
||||
{ ref: `DEM-${annee}-0110`, assetId: c1, status: 'RECEIVED', description: 'Voyant étage éteint.' },
|
||||
{
|
||||
ref: `DEM-${annee}-0107`, assetId: a1, status: 'APPROVED',
|
||||
description: 'Bruit anormal en gaine.', otRef: `OT-${annee}-0341`,
|
||||
},
|
||||
{
|
||||
ref: `DEM-${annee}-0104`, assetId: b2, status: 'REJECTED',
|
||||
description: 'Odeur de brûlé.', rejectionReason: 'Fausse alerte confirmée sur place par le gardien.',
|
||||
},
|
||||
];
|
||||
for (const dem of DEMANDES) {
|
||||
const existant = await prisma.request.findUnique({ where: { reference: dem.ref } });
|
||||
if (existant) continue;
|
||||
const workOrderId = dem.otRef
|
||||
? (await prisma.workOrder.findUnique({ where: { reference: dem.otRef } }))?.id
|
||||
: undefined;
|
||||
await prisma.request.create({
|
||||
data: {
|
||||
reference: dem.ref,
|
||||
description: dem.description,
|
||||
isPersonTrapped: dem.isPersonTrapped ?? false,
|
||||
status: dem.status,
|
||||
rejectionReason: dem.rejectionReason,
|
||||
assetId: dem.assetId,
|
||||
requestedById: karim,
|
||||
workOrderId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Compteurs de la maquette (A1)
|
||||
for (const [kind, valeurs] of [
|
||||
['RUNNING_HOURS', [12246, 12322, 12411]],
|
||||
['STARTS', [1815400, 1831970]],
|
||||
] as const) {
|
||||
const meter = await prisma.meter.upsert({
|
||||
where: { assetId_kind: { assetId: a1, kind } },
|
||||
update: {},
|
||||
create: { assetId: a1, kind },
|
||||
include: { _count: { select: { readings: true } } },
|
||||
});
|
||||
if (meter._count.readings === 0) {
|
||||
await prisma.meterReading.createMany({
|
||||
data: valeurs.map((value) => ({ meterId: meter.id, value, readById: ahmed })),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ————— R3 — Gestion (tiers, pièces, mouvements, BC, taux — données maquette) —————
|
||||
|
||||
const PARTNERS: {
|
||||
name: string; kind: 'SUPPLIER' | 'CLIENT'; contactName?: string;
|
||||
phone?: string; email?: string; city?: string;
|
||||
}[] = [
|
||||
{ name: 'Ascentech Maroc', kind: 'SUPPLIER', contactName: 'M. Berrini', phone: '05 22 34 56 78', city: 'Casablanca' },
|
||||
{ name: 'SchindlerParts', kind: 'SUPPLIER', email: 'commandes@schindlerparts.ma' },
|
||||
{ name: 'Lubmaroc', kind: 'SUPPLIER', phone: '05 22 11 22 33' },
|
||||
{ name: 'Atlas Property Management', kind: 'CLIENT', contactName: 'Mme Zerhouni', phone: '06 61 98 76 54' },
|
||||
{ name: 'Syndic Al Manar', kind: 'CLIENT', contactName: 'M. Alami (gardien référent)' },
|
||||
];
|
||||
|
||||
const PARTS: {
|
||||
reference: string; designation: string; threshold: number;
|
||||
lastUnitPrice?: number; supplier?: string; compatible?: string;
|
||||
}[] = [
|
||||
{ reference: 'P-0019', designation: 'Cellule barrière porte (paire)', threshold: 4, lastUnitPrice: 640, supplier: 'SchindlerParts' },
|
||||
{ reference: 'P-0031', designation: 'Bouton palier lumineux Ø22', threshold: 10, lastUnitPrice: 45, supplier: 'Ascentech Maroc' },
|
||||
{ reference: 'P-0042', designation: 'Graisse guide (cartouche 400 g)', threshold: 8, lastUnitPrice: 85, supplier: 'Lubmaroc' },
|
||||
{ reference: 'P-0087', designation: 'Coulisseau de guide 16 mm', threshold: 6, lastUnitPrice: 120, supplier: 'Ascentech Maroc' },
|
||||
{ reference: 'P-0113', designation: 'Contact de porte NC-31', threshold: 5, lastUnitPrice: 85, supplier: 'Ascentech Maroc', compatible: 'Otis Gen2 · Schindler 3300' },
|
||||
];
|
||||
|
||||
async function seedGestion(prisma: PrismaClient): Promise<void> {
|
||||
const partnerIds = new Map<string, string>();
|
||||
for (const p of PARTNERS) {
|
||||
const partner = await prisma.partner.upsert({
|
||||
where: { name: p.name },
|
||||
update: {},
|
||||
create: p,
|
||||
});
|
||||
partnerIds.set(p.name, partner.id);
|
||||
}
|
||||
|
||||
// Rattachements de la maquette Tiers : les syndics gèrent leur site.
|
||||
for (const [site, partner] of [
|
||||
['Tour Atlas', 'Atlas Property Management'],
|
||||
['Résidence Al Manar', 'Syndic Al Manar'],
|
||||
] as const) {
|
||||
await prisma.location.updateMany({
|
||||
where: { name: site, parentId: null, partnerId: null },
|
||||
data: { partnerId: partnerIds.get(partner) },
|
||||
});
|
||||
}
|
||||
|
||||
const partIds = new Map<string, string>();
|
||||
for (const p of PARTS) {
|
||||
const part = await prisma.part.upsert({
|
||||
where: { reference: p.reference },
|
||||
update: {},
|
||||
create: {
|
||||
reference: p.reference,
|
||||
designation: p.designation,
|
||||
threshold: p.threshold,
|
||||
lastUnitPrice: p.lastUnitPrice,
|
||||
compatible: p.compatible,
|
||||
supplierId: p.supplier ? partnerIds.get(p.supplier) : undefined,
|
||||
},
|
||||
});
|
||||
partIds.set(p.reference, part.id);
|
||||
}
|
||||
|
||||
// Taux horaires courants (les saisies figent leur propre taux)
|
||||
for (const [email, taux] of [
|
||||
['technicien@demo.siop.ma', 120],
|
||||
['technicien-limite@demo.siop.ma', 90],
|
||||
] as const) {
|
||||
await prisma.user.update({ where: { email }, data: { hourlyRate: taux } });
|
||||
}
|
||||
|
||||
// Idempotence : si des mouvements existent déjà, l'histoire est en place
|
||||
if ((await prisma.stockMovement.count()) > 0) return;
|
||||
|
||||
const annee = new Date().getFullYear();
|
||||
const ahmed = await prisma.user.findUniqueOrThrow({ where: { email: 'technicien@demo.siop.ma' } });
|
||||
const nadia = await prisma.user.findUniqueOrThrow({ where: { email: 'gestionnaire@demo.siop.ma' } });
|
||||
|
||||
// BC reçus (historique) et BC en cours — comme la maquette
|
||||
const bc21 = await prisma.purchaseOrder.create({
|
||||
data: {
|
||||
reference: `BC-${annee}-0021`,
|
||||
status: 'RECEIVED',
|
||||
supplierId: partnerIds.get('Ascentech Maroc')!,
|
||||
createdById: nadia.id,
|
||||
sentAt: new Date(Date.now() - 40 * 86400e3),
|
||||
receivedAt: new Date(Date.now() - 34 * 86400e3),
|
||||
lines: {
|
||||
create: [
|
||||
{ partId: partIds.get('P-0113')!, quantity: 5, unitPrice: 85 },
|
||||
{ partId: partIds.get('P-0087')!, quantity: 6, unitPrice: 120 },
|
||||
{ partId: partIds.get('P-0031')!, quantity: 20, unitPrice: 45 },
|
||||
],
|
||||
},
|
||||
},
|
||||
include: { lines: true },
|
||||
});
|
||||
await prisma.purchaseOrder.create({
|
||||
data: {
|
||||
reference: `BC-${annee}-0024`,
|
||||
status: 'SENT',
|
||||
supplierId: partnerIds.get('Ascentech Maroc')!,
|
||||
createdById: nadia.id,
|
||||
sentAt: new Date(),
|
||||
lines: {
|
||||
create: [
|
||||
{ partId: partIds.get('P-0113')!, quantity: 10, unitPrice: 85 },
|
||||
{ partId: partIds.get('P-0087')!, quantity: 2, unitPrice: 127.5 },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Mouvements : réceptions du BC-0021 + entrées initiales + consommations
|
||||
for (const line of bc21.lines) {
|
||||
await prisma.stockMovement.create({
|
||||
data: {
|
||||
partId: line.partId,
|
||||
kind: 'RECEIPT',
|
||||
quantity: line.quantity,
|
||||
unitPrice: line.unitPrice,
|
||||
purchaseOrderId: bc21.id,
|
||||
byId: nadia.id,
|
||||
createdAt: new Date(Date.now() - 34 * 86400e3),
|
||||
},
|
||||
});
|
||||
}
|
||||
for (const [ref, qte] of [
|
||||
['P-0042', 19],
|
||||
['P-0019', 7],
|
||||
['P-0031', 6],
|
||||
] as const) {
|
||||
await prisma.stockMovement.create({
|
||||
data: {
|
||||
partId: partIds.get(ref)!,
|
||||
kind: 'ENTRY',
|
||||
quantity: qte,
|
||||
reason: 'Reprise de l’inventaire initial',
|
||||
byId: nadia.id,
|
||||
createdAt: new Date(Date.now() - 60 * 86400e3),
|
||||
},
|
||||
});
|
||||
}
|
||||
// P-0113 : consommations + ajustement → stock 2 (sous le seuil, maquette)
|
||||
const ot341 = await prisma.workOrder.findUnique({
|
||||
where: { reference: `OT-${annee}-0341` },
|
||||
});
|
||||
await prisma.stockMovement.createMany({
|
||||
data: [
|
||||
{ partId: partIds.get('P-0113')!, kind: 'ADJUSTMENT', quantity: -1, reason: 'Inventaire — pièce endommagée', byId: nadia.id },
|
||||
{ partId: partIds.get('P-0113')!, kind: 'CONSUMPTION', quantity: -2, unitPrice: 85, byId: ahmed.id },
|
||||
],
|
||||
});
|
||||
// P-0087 : consommation sur OT-0341 (carte maquette : 2 × 120 = 240 MAD)
|
||||
if (ot341) {
|
||||
await prisma.stockMovement.create({
|
||||
data: {
|
||||
partId: partIds.get('P-0087')!,
|
||||
kind: 'CONSUMPTION',
|
||||
quantity: -2,
|
||||
unitPrice: 120,
|
||||
workOrderId: ot341.id,
|
||||
byId: ahmed.id,
|
||||
},
|
||||
});
|
||||
await prisma.stockMovement.create({
|
||||
data: {
|
||||
partId: partIds.get('P-0042')!,
|
||||
kind: 'CONSUMPTION',
|
||||
quantity: -1,
|
||||
unitPrice: 85,
|
||||
workOrderId: ot341.id,
|
||||
byId: ahmed.id,
|
||||
},
|
||||
});
|
||||
// main-d'œuvre : 1 h 30 × 120 MAD/h = 180 (total maquette : 505 MAD)
|
||||
await prisma.laborTime.create({
|
||||
data: { workOrderId: ot341.id, userId: ahmed.id, minutes: 90, hourlyRate: 120 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* c8 ignore start — wrapper CLI */
|
||||
if (require.main === module) {
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
14
apps/api/src/analytics/analytics.controller.ts
Normal file
14
apps/api/src/analytics/analytics.controller.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { RequirePermission } from '../permissions/require-permission.decorator';
|
||||
import { AnalyticsService } from './analytics.service';
|
||||
|
||||
@Controller('analytics')
|
||||
export class AnalyticsController {
|
||||
constructor(private readonly analytics: AnalyticsService) {}
|
||||
|
||||
@Get('summary')
|
||||
@RequirePermission('ANALYTICS', 'view')
|
||||
summary(@Query('months') months?: string) {
|
||||
return this.analytics.summary(months);
|
||||
}
|
||||
}
|
||||
9
apps/api/src/analytics/analytics.module.ts
Normal file
9
apps/api/src/analytics/analytics.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AnalyticsController } from './analytics.controller';
|
||||
import { AnalyticsService } from './analytics.service';
|
||||
|
||||
@Module({
|
||||
controllers: [AnalyticsController],
|
||||
providers: [AnalyticsService],
|
||||
})
|
||||
export class AnalyticsModule {}
|
||||
201
apps/api/src/analytics/analytics.service.ts
Normal file
201
apps/api/src/analytics/analytics.service.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ANALYTICS_PERIODS, type AnalyticsPeriod, type AnalyticsSummary } from '@siop/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
const debutMois = (decalage: number): Date => {
|
||||
const d = new Date();
|
||||
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() - decalage, 1));
|
||||
};
|
||||
|
||||
/** Le tableau de la direction — tout est DÉRIVÉ des données réelles :
|
||||
* mouvements (prix figés), main-d'œuvre (taux figés), bilans codés. */
|
||||
@Injectable()
|
||||
export class AnalyticsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async summary(monthsRaw?: string): Promise<AnalyticsSummary> {
|
||||
// Période demandée par l'écran (3, 6 ou 12 mois) — 12 par défaut.
|
||||
const months: AnalyticsPeriod = ANALYTICS_PERIODS.find(
|
||||
(p) => p === Number(monthsRaw),
|
||||
) ?? 12;
|
||||
const depuisPeriode = debutMois(months - 1);
|
||||
|
||||
const [consommations, mainOeuvre, clos12m, grilles12m, correctifsClos, pannes, topDonnees] =
|
||||
await Promise.all([
|
||||
this.prisma.stockMovement.findMany({
|
||||
where: { kind: 'CONSUMPTION', createdAt: { gte: depuisPeriode } },
|
||||
select: { quantity: true, unitPrice: true, createdAt: true },
|
||||
}),
|
||||
this.prisma.laborTime.findMany({
|
||||
where: { createdAt: { gte: depuisPeriode } },
|
||||
select: { minutes: true, hourlyRate: true, createdAt: true },
|
||||
}),
|
||||
this.prisma.workOrder.groupBy({
|
||||
by: ['type'],
|
||||
where: { status: 'DONE', completedAt: { gte: depuisPeriode } },
|
||||
_count: { _all: true },
|
||||
}),
|
||||
this.prisma.workOrder.findMany({
|
||||
where: { periodKey: { not: null }, createdAt: { gte: depuisPeriode } },
|
||||
select: { status: true },
|
||||
}),
|
||||
this.prisma.workOrder.findMany({
|
||||
where: { type: 'CORRECTIVE', status: 'DONE', completedAt: { gte: depuisPeriode } },
|
||||
select: { createdAt: true, completedAt: true },
|
||||
}),
|
||||
this.prisma.interventionReport.groupBy({
|
||||
by: ['componentConcernedId'],
|
||||
where: {
|
||||
componentConcernedId: { not: null },
|
||||
workOrder: { status: 'DONE', type: 'CORRECTIVE', completedAt: { gte: depuisPeriode } },
|
||||
},
|
||||
_count: { _all: true },
|
||||
}),
|
||||
this.prisma.stockMovement.findMany({
|
||||
where: { kind: 'CONSUMPTION', workOrderId: { not: null }, createdAt: { gte: depuisPeriode } },
|
||||
select: {
|
||||
quantity: true,
|
||||
unitPrice: true,
|
||||
workOrder: {
|
||||
select: {
|
||||
type: true,
|
||||
asset: {
|
||||
select: {
|
||||
reference: true,
|
||||
location: { select: { name: true, parent: { select: { name: true } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
// Coûts par mois (6 derniers) — pièces + main-d'œuvre, prix/taux figés
|
||||
const cleMois = (d: Date) => d.toISOString().slice(0, 7);
|
||||
const parMois = new Map<string, number>();
|
||||
for (let i = months - 1; i >= 0; i--) parMois.set(cleMois(debutMois(i)), 0);
|
||||
for (const c of consommations) {
|
||||
const cle = cleMois(c.createdAt);
|
||||
if (parMois.has(cle)) {
|
||||
parMois.set(cle, parMois.get(cle)! + -c.quantity * Number(c.unitPrice ?? 0));
|
||||
}
|
||||
}
|
||||
for (const l of mainOeuvre) {
|
||||
const cle = cleMois(l.createdAt);
|
||||
if (parMois.has(cle)) {
|
||||
parMois.set(cle, parMois.get(cle)! + (l.minutes / 60) * Number(l.hourlyRate));
|
||||
}
|
||||
}
|
||||
const costsByMonth = [...parMois.entries()].map(([month, total]) => ({
|
||||
month,
|
||||
total: Math.round(total * 100) / 100,
|
||||
}));
|
||||
const moisCourant = cleMois(new Date());
|
||||
const moisPrecedent = cleMois(debutMois(1));
|
||||
|
||||
// Pannes par organe : depuis les bilans codés
|
||||
const labels = await this.prisma.referenceValue.findMany({
|
||||
where: { id: { in: pannes.map((p) => p.componentConcernedId!).filter(Boolean) } },
|
||||
});
|
||||
const labelDe = new Map(labels.map((l) => [l.id, l.label]));
|
||||
const failuresByComponent = pannes
|
||||
.map((p) => ({
|
||||
label: labelDe.get(p.componentConcernedId!) ?? '—',
|
||||
count: p._count._all,
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 6);
|
||||
|
||||
// Top équipements en coût (pièces via OT + main-d'œuvre)
|
||||
const laborParOT = await this.prisma.laborTime.findMany({
|
||||
where: { createdAt: { gte: depuisPeriode } },
|
||||
select: {
|
||||
minutes: true,
|
||||
hourlyRate: true,
|
||||
workOrder: {
|
||||
select: {
|
||||
type: true,
|
||||
asset: {
|
||||
select: {
|
||||
reference: true,
|
||||
location: { select: { name: true, parent: { select: { name: true } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
interface Cumul { siteName: string; correctives: number; partsCost: number; laborCost: number }
|
||||
const parAppareil = new Map<string, Cumul>();
|
||||
const cumul = (ref: string, siteName: string): Cumul => {
|
||||
if (!parAppareil.has(ref)) {
|
||||
parAppareil.set(ref, { siteName, correctives: 0, partsCost: 0, laborCost: 0 });
|
||||
}
|
||||
return parAppareil.get(ref)!;
|
||||
};
|
||||
for (const m of topDonnees) {
|
||||
const asset = m.workOrder!.asset;
|
||||
const c = cumul(asset.reference, asset.location.parent?.name ?? asset.location.name);
|
||||
c.partsCost += -m.quantity * Number(m.unitPrice ?? 0);
|
||||
}
|
||||
for (const l of laborParOT) {
|
||||
const asset = l.workOrder.asset;
|
||||
const c = cumul(asset.reference, asset.location.parent?.name ?? asset.location.name);
|
||||
c.laborCost += (l.minutes / 60) * Number(l.hourlyRate);
|
||||
}
|
||||
const correctivesParAppareil = await this.prisma.workOrder.groupBy({
|
||||
by: ['assetId'],
|
||||
where: { type: 'CORRECTIVE', createdAt: { gte: depuisPeriode } },
|
||||
_count: { _all: true },
|
||||
});
|
||||
const assetsRefs = await this.prisma.asset.findMany({
|
||||
where: { id: { in: correctivesParAppareil.map((c) => c.assetId) } },
|
||||
select: { id: true, reference: true },
|
||||
});
|
||||
const refDe = new Map(assetsRefs.map((a) => [a.id, a.reference]));
|
||||
for (const c of correctivesParAppareil) {
|
||||
const ref = refDe.get(c.assetId);
|
||||
if (ref && parAppareil.has(ref)) parAppareil.get(ref)!.correctives = c._count._all;
|
||||
}
|
||||
const topAssets = [...parAppareil.entries()]
|
||||
.map(([reference, c]) => ({
|
||||
reference,
|
||||
siteName: c.siteName,
|
||||
correctives: c.correctives,
|
||||
partsCost: Math.round(c.partsCost * 100) / 100,
|
||||
laborCost: Math.round(c.laborCost * 100) / 100,
|
||||
total: Math.round((c.partsCost + c.laborCost) * 100) / 100,
|
||||
}))
|
||||
.sort((a, b) => b.total - a.total)
|
||||
.slice(0, 5);
|
||||
|
||||
const totalClos = clos12m.reduce((s, g) => s + g._count._all, 0);
|
||||
const preventifsClos = clos12m.find((g) => g.type === 'PREVENTIVE')?._count._all ?? 0;
|
||||
const grillesTerminees = grilles12m.filter((g) => g.status === 'DONE').length;
|
||||
|
||||
return {
|
||||
months,
|
||||
monthCost: costsByMonth.find((c) => c.month === moisCourant)?.total ?? 0,
|
||||
previousMonthCost: costsByMonth.find((c) => c.month === moisPrecedent)?.total ?? 0,
|
||||
closed: { total: totalClos, preventive: preventifsClos },
|
||||
preventiveRate: grilles12m.length
|
||||
? Math.round((grillesTerminees / grilles12m.length) * 100) / 100
|
||||
: null,
|
||||
avgResolutionDays: correctifsClos.length
|
||||
? Math.round(
|
||||
(correctifsClos.reduce(
|
||||
(s, w) => s + (w.completedAt!.getTime() - w.createdAt.getTime()),
|
||||
0,
|
||||
) /
|
||||
correctifsClos.length /
|
||||
86400e3) *
|
||||
10,
|
||||
) / 10
|
||||
: null,
|
||||
failuresByComponent,
|
||||
costsByMonth,
|
||||
topAssets,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,28 @@
|
||||
import { DynamicModule, Module } from '@nestjs/common';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { AnalyticsModule } from './analytics/analytics.module';
|
||||
import { SearchModule } from './search/search.module';
|
||||
import { AssistantModule } from './assistant/assistant.module';
|
||||
import { AssetsModule } from './assets/assets.module';
|
||||
import { DocumentsModule } from './documents/documents.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { DemoAuthModule } from './auth/demo/demo-auth.module';
|
||||
import { JwtAuthGuard } from './auth/jwt-auth.guard';
|
||||
import { CategoriesModule } from './categories/categories.module';
|
||||
import { demoModeEnabled } from './config/env';
|
||||
import { FilesModule } from './files/files.module';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { LocationsModule } from './locations/locations.module';
|
||||
import { MetersModule } from './meters/meters.module';
|
||||
import { PartnersModule } from './partners/partners.module';
|
||||
import { PartsModule } from './parts/parts.module';
|
||||
import { PortalModule } from './portal/portal.module';
|
||||
import { PurchaseOrdersModule } from './purchase-orders/purchase-orders.module';
|
||||
import { PreventiveModule } from './preventive/preventive.module';
|
||||
import { ReferenceValuesModule } from './reference-values/reference-values.module';
|
||||
import { RequestsModule } from './requests/requests.module';
|
||||
import { TeamsModule } from './teams/teams.module';
|
||||
import { WorkOrdersModule } from './work-orders/work-orders.module';
|
||||
import { PermissionsGuard } from './permissions/permissions.guard';
|
||||
import { PermissionsModule } from './permissions/permissions.module';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
@@ -28,6 +45,26 @@ export class AppModule {
|
||||
AuthModule,
|
||||
UsersModule,
|
||||
HealthModule,
|
||||
// R1 — référentiel
|
||||
CategoriesModule,
|
||||
LocationsModule,
|
||||
AssetsModule,
|
||||
TeamsModule,
|
||||
// R2 — exploitation
|
||||
WorkOrdersModule,
|
||||
RequestsModule,
|
||||
ReferenceValuesModule,
|
||||
PreventiveModule,
|
||||
MetersModule,
|
||||
PortalModule,
|
||||
// R3 — gestion
|
||||
PartnersModule,
|
||||
PartsModule,
|
||||
PurchaseOrdersModule,
|
||||
DocumentsModule,
|
||||
AnalyticsModule,
|
||||
SearchModule,
|
||||
AssistantModule,
|
||||
// ADR-002 : hors DEMO_MODE, le module n'est pas enregistré → 404
|
||||
...(demoModeEnabled() ? [DemoAuthModule] : []),
|
||||
],
|
||||
|
||||
82
apps/api/src/assets/assets.controller.ts
Normal file
82
apps/api/src/assets/assets.controller.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
AssetComponentCreateSchema,
|
||||
AssetCreateSchema,
|
||||
AssetUpdateSchema,
|
||||
type AssetComponentCreate,
|
||||
type AssetCreate,
|
||||
type AssetUpdate,
|
||||
} from '@siop/shared';
|
||||
import { type AuthenticatedUser, CurrentUser } from '../auth/current-user.decorator';
|
||||
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||
import { RequirePermission } from '../permissions/require-permission.decorator';
|
||||
import { AssetsService } from './assets.service';
|
||||
|
||||
@Controller('assets')
|
||||
export class AssetsController {
|
||||
constructor(private readonly assetsService: AssetsService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermission('ASSETS', 'view')
|
||||
list() {
|
||||
return this.assetsService.list();
|
||||
}
|
||||
|
||||
/** Avant ':id' (ordre des routes) — authentification seule : le demandeur
|
||||
* doit pouvoir désigner l'appareil qu'il signale (filtré à son site s'il
|
||||
* en a un, R6.6). */
|
||||
@Get('options')
|
||||
options(@CurrentUser() user: AuthenticatedUser) {
|
||||
return this.assetsService.options(user);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermission('ASSETS', 'view')
|
||||
get(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.assetsService.get(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermission('ASSETS', 'create')
|
||||
create(@Body(new ZodValidationPipe(AssetCreateSchema)) body: AssetCreate) {
|
||||
return this.assetsService.create(body);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequirePermission('ASSETS', 'edit')
|
||||
update(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body(new ZodValidationPipe(AssetUpdateSchema)) body: AssetUpdate,
|
||||
) {
|
||||
return this.assetsService.update(id, body);
|
||||
}
|
||||
|
||||
@Post(':id/components')
|
||||
@RequirePermission('ASSETS', 'edit')
|
||||
addComponent(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body(new ZodValidationPipe(AssetComponentCreateSchema)) body: AssetComponentCreate,
|
||||
) {
|
||||
return this.assetsService.addComponent(id, body);
|
||||
}
|
||||
|
||||
@Delete(':id/components/:componentId')
|
||||
@RequirePermission('ASSETS', 'edit')
|
||||
@HttpCode(204)
|
||||
removeComponent(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('componentId', ParseUUIDPipe) componentId: string,
|
||||
) {
|
||||
return this.assetsService.removeComponent(id, componentId);
|
||||
}
|
||||
}
|
||||
10
apps/api/src/assets/assets.module.ts
Normal file
10
apps/api/src/assets/assets.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AssetsController } from './assets.controller';
|
||||
import { AssetsService } from './assets.service';
|
||||
|
||||
@Module({
|
||||
controllers: [AssetsController],
|
||||
providers: [AssetsService],
|
||||
exports: [AssetsService],
|
||||
})
|
||||
export class AssetsModule {}
|
||||
221
apps/api/src/assets/assets.service.ts
Normal file
221
apps/api/src/assets/assets.service.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import type {
|
||||
AssetComponentCreate,
|
||||
AssetComponentDto,
|
||||
AssetCreate,
|
||||
AssetDetail,
|
||||
AssetDto,
|
||||
AssetOptionsResponse,
|
||||
AssetsResponse,
|
||||
AssetUpdate,
|
||||
} from '@siop/shared';
|
||||
import type { AuthenticatedUser } from '../auth/current-user.decorator';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
const assetInclude = {
|
||||
category: true,
|
||||
location: { include: { parent: true } },
|
||||
_count: { select: { components: true } },
|
||||
} satisfies Prisma.AssetInclude;
|
||||
|
||||
type AssetRow = Prisma.AssetGetPayload<{ include: typeof assetInclude }>;
|
||||
|
||||
@Injectable()
|
||||
export class AssetsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/** Sites + zones autorisés pour le signalement de `user`, ou `null` si aucune
|
||||
* restriction (comportement historique — tous les rôles sauf un Demandeur
|
||||
* affecté à un site, R6.6). Réutilisée par `options()` ET par
|
||||
* `RequestsService.create` pour que les deux filtres ne divergent jamais. */
|
||||
async allowedLocationIds(user: AuthenticatedUser): Promise<string[] | null> {
|
||||
const me = await this.prisma.user.findUnique({
|
||||
where: { id: user.userId },
|
||||
select: { assignedSites: { select: { id: true } } },
|
||||
});
|
||||
const siteIds = (me?.assignedSites ?? []).map((s) => s.id);
|
||||
if (siteIds.length === 0) return null;
|
||||
const zones = await this.prisma.location.findMany({
|
||||
where: { parentId: { in: siteIds } },
|
||||
select: { id: true },
|
||||
});
|
||||
return [...siteIds, ...zones.map((z) => z.id)];
|
||||
}
|
||||
|
||||
/** Options minimales pour le signalement — ouvert à tout rôle authentifié,
|
||||
* filtré au périmètre du Demandeur s'il est affecté à un site (R6.6). */
|
||||
async options(user: AuthenticatedUser): Promise<AssetOptionsResponse> {
|
||||
const allowed = await this.allowedLocationIds(user);
|
||||
const rows = await this.prisma.asset.findMany({
|
||||
where: allowed ? { locationId: { in: allowed } } : undefined,
|
||||
include: { location: { include: { parent: true } } },
|
||||
orderBy: { reference: 'asc' },
|
||||
});
|
||||
return {
|
||||
options: rows.map((r) => ({
|
||||
id: r.id,
|
||||
reference: r.reference,
|
||||
siteName: r.location.parent?.name ?? r.location.name,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async list(): Promise<AssetsResponse> {
|
||||
const rows = await this.prisma.asset.findMany({
|
||||
include: assetInclude,
|
||||
orderBy: { reference: 'asc' },
|
||||
});
|
||||
return { assets: rows.map((r) => this.toDto(r)) };
|
||||
}
|
||||
|
||||
async get(id: string): Promise<AssetDetail> {
|
||||
const row = await this.prisma.asset.findUnique({
|
||||
where: { id },
|
||||
include: { ...assetInclude, components: { include: { type: true } } },
|
||||
});
|
||||
if (!row) throw new NotFoundException('Appareil inconnu');
|
||||
return {
|
||||
...this.toDto(row),
|
||||
components: row.components.map((c) => this.toComponentDto(c)),
|
||||
};
|
||||
}
|
||||
|
||||
async create(dto: AssetCreate): Promise<AssetDetail> {
|
||||
await this.assertEquipmentCategory(dto.categoryId);
|
||||
await this.assertLocation(dto.locationId);
|
||||
if (dto.components?.length) {
|
||||
await this.assertComponentTypes(dto.components.map((c) => c.typeId));
|
||||
}
|
||||
try {
|
||||
const created = await this.prisma.asset.create({
|
||||
data: {
|
||||
reference: dto.reference,
|
||||
brand: dto.brand,
|
||||
model: dto.model,
|
||||
serialNumber: dto.serialNumber,
|
||||
commissionedAt: dto.commissionedAt ? new Date(dto.commissionedAt) : undefined,
|
||||
loadKg: dto.loadKg,
|
||||
floors: dto.floors,
|
||||
categoryId: dto.categoryId,
|
||||
locationId: dto.locationId,
|
||||
components: dto.components?.length
|
||||
? { create: dto.components }
|
||||
: undefined,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
return this.get(created.id);
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||
throw new ConflictException('Cette référence est déjà utilisée');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async update(id: string, dto: AssetUpdate): Promise<AssetDetail> {
|
||||
if (dto.categoryId) await this.assertEquipmentCategory(dto.categoryId);
|
||||
if (dto.locationId) await this.assertLocation(dto.locationId);
|
||||
try {
|
||||
await this.prisma.asset.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...dto,
|
||||
commissionedAt: dto.commissionedAt ? new Date(dto.commissionedAt) : undefined,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') {
|
||||
throw new NotFoundException('Appareil inconnu');
|
||||
}
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||
throw new ConflictException('Cette référence est déjà utilisée');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
return this.get(id);
|
||||
}
|
||||
|
||||
async addComponent(
|
||||
assetId: string,
|
||||
dto: AssetComponentCreate,
|
||||
): Promise<AssetComponentDto> {
|
||||
const asset = await this.prisma.asset.findUnique({ where: { id: assetId } });
|
||||
if (!asset) throw new NotFoundException('Appareil inconnu');
|
||||
await this.assertComponentTypes([dto.typeId]);
|
||||
const created = await this.prisma.assetComponent.create({
|
||||
data: { assetId, ...dto },
|
||||
include: { type: true },
|
||||
});
|
||||
return this.toComponentDto(created);
|
||||
}
|
||||
|
||||
async removeComponent(assetId: string, componentId: string): Promise<void> {
|
||||
const { count } = await this.prisma.assetComponent.deleteMany({
|
||||
where: { id: componentId, assetId },
|
||||
});
|
||||
if (count === 0) throw new NotFoundException('Organe inconnu');
|
||||
}
|
||||
|
||||
private async assertEquipmentCategory(categoryId: string): Promise<void> {
|
||||
const category = await this.prisma.category.findUnique({ where: { id: categoryId } });
|
||||
if (!category || category.kind !== 'EQUIPMENT' || !category.isActive) {
|
||||
throw new BadRequestException(
|
||||
'La catégorie choisie n’est pas une catégorie d’équipement active',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async assertLocation(locationId: string): Promise<void> {
|
||||
const location = await this.prisma.location.findUnique({ where: { id: locationId } });
|
||||
if (!location) throw new BadRequestException('Emplacement inconnu');
|
||||
}
|
||||
|
||||
private async assertComponentTypes(typeIds: string[]): Promise<void> {
|
||||
const types = await this.prisma.category.findMany({
|
||||
where: { id: { in: typeIds } },
|
||||
});
|
||||
const valid =
|
||||
types.length === new Set(typeIds).size &&
|
||||
types.every((t) => t.kind === 'COMPONENT_TYPE' && t.isActive);
|
||||
if (!valid) {
|
||||
throw new BadRequestException('Chaque organe doit avoir un type d’organe actif');
|
||||
}
|
||||
}
|
||||
|
||||
private toComponentDto(c: { id: string; typeId: string; designation: string | null; type: { name: string } }): AssetComponentDto {
|
||||
return {
|
||||
id: c.id,
|
||||
typeId: c.typeId,
|
||||
typeName: c.type.name,
|
||||
designation: c.designation,
|
||||
};
|
||||
}
|
||||
|
||||
private toDto(row: AssetRow): AssetDto {
|
||||
return {
|
||||
id: row.id,
|
||||
reference: row.reference,
|
||||
brand: row.brand,
|
||||
model: row.model,
|
||||
serialNumber: row.serialNumber,
|
||||
commissionedAt: row.commissionedAt?.toISOString() ?? null,
|
||||
loadKg: row.loadKg,
|
||||
floors: row.floors,
|
||||
status: row.status,
|
||||
categoryId: row.categoryId,
|
||||
categoryName: row.category.name,
|
||||
locationId: row.locationId,
|
||||
locationName: row.location.name,
|
||||
siteName: row.location.parent?.name ?? row.location.name,
|
||||
componentCount: row._count.components,
|
||||
};
|
||||
}
|
||||
}
|
||||
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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
import { Body, Controller, HttpCode, Post } from '@nestjs/common';
|
||||
import { LoginRequestSchema, type LoginRequest } from '@siop/shared';
|
||||
import {
|
||||
ActivateRequestSchema,
|
||||
LoginRequestSchema,
|
||||
type ActivateRequest,
|
||||
type LoginRequest,
|
||||
} from '@siop/shared';
|
||||
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||
import { AuthService } from './auth.service';
|
||||
import { Public } from './public.decorator';
|
||||
@@ -14,4 +19,12 @@ export class AuthController {
|
||||
login(@Body(new ZodValidationPipe(LoginRequestSchema)) body: LoginRequest) {
|
||||
return this.authService.login(body.email, body.password);
|
||||
}
|
||||
|
||||
/** Publique par nature : la personne n'a pas encore de compte actif. */
|
||||
@Public()
|
||||
@Post('activate')
|
||||
@HttpCode(200)
|
||||
activate(@Body(new ZodValidationPipe(ActivateRequestSchema)) body: ActivateRequest) {
|
||||
return this.authService.activate(body.token, body.password);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
@@ -75,6 +76,35 @@ export class AuthService {
|
||||
return this.issueToken(user);
|
||||
}
|
||||
|
||||
/** R1 — activation d'un compte invité : lien 7 jours, usage unique,
|
||||
* choisit le mot de passe et connecte directement. */
|
||||
async activate(token: string, password: string): Promise<AuthResponse> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { activationToken: token },
|
||||
include: { role: true },
|
||||
});
|
||||
if (
|
||||
!user ||
|
||||
!user.isActive ||
|
||||
!user.activationExpiresAt ||
|
||||
user.activationExpiresAt < new Date()
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
'Ce lien d’activation est invalide ou a expiré — demandez un nouveau lien',
|
||||
);
|
||||
}
|
||||
const activated = await this.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
passwordHash: await argon2.hash(password),
|
||||
activationToken: null,
|
||||
activationExpiresAt: null,
|
||||
},
|
||||
include: { role: true },
|
||||
});
|
||||
return this.issueToken(activated);
|
||||
}
|
||||
|
||||
private async issueToken(user: UserWithRole): Promise<AuthResponse> {
|
||||
// Identité seule — les droits restent en base (invariant R0)
|
||||
const accessToken = await this.jwtService.signAsync({
|
||||
|
||||
44
apps/api/src/categories/categories.controller.ts
Normal file
44
apps/api/src/categories/categories.controller.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
CategoryCreateSchema,
|
||||
CategoryUpdateSchema,
|
||||
type CategoryCreate,
|
||||
type CategoryUpdate,
|
||||
} from '@siop/shared';
|
||||
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||
import { RequirePermission } from '../permissions/require-permission.decorator';
|
||||
import { CategoriesService } from './categories.service';
|
||||
|
||||
@Controller('categories')
|
||||
export class CategoriesController {
|
||||
constructor(private readonly categoriesService: CategoriesService) {}
|
||||
|
||||
/** Donnée de référence lue par les formulaires — authentification seule. */
|
||||
@Get()
|
||||
list() {
|
||||
return this.categoriesService.list();
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermission('SETTINGS', 'create')
|
||||
create(@Body(new ZodValidationPipe(CategoryCreateSchema)) body: CategoryCreate) {
|
||||
return this.categoriesService.create(body);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequirePermission('SETTINGS', 'edit')
|
||||
update(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body(new ZodValidationPipe(CategoryUpdateSchema)) body: CategoryUpdate,
|
||||
) {
|
||||
return this.categoriesService.update(id, body);
|
||||
}
|
||||
}
|
||||
10
apps/api/src/categories/categories.module.ts
Normal file
10
apps/api/src/categories/categories.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CategoriesController } from './categories.controller';
|
||||
import { CategoriesService } from './categories.service';
|
||||
|
||||
@Module({
|
||||
controllers: [CategoriesController],
|
||||
providers: [CategoriesService],
|
||||
exports: [CategoriesService],
|
||||
})
|
||||
export class CategoriesModule {}
|
||||
75
apps/api/src/categories/categories.service.ts
Normal file
75
apps/api/src/categories/categories.service.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type {
|
||||
CategoriesResponse,
|
||||
Category,
|
||||
CategoryCreate,
|
||||
CategoryUpdate,
|
||||
} from '@siop/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class CategoriesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(): Promise<CategoriesResponse> {
|
||||
const rows = await this.prisma.category.findMany({
|
||||
include: { _count: { select: { assets: true, components: true } } },
|
||||
orderBy: [{ kind: 'asc' }, { name: 'asc' }],
|
||||
});
|
||||
return {
|
||||
categories: rows.map((c) => ({
|
||||
id: c.id,
|
||||
kind: c.kind,
|
||||
name: c.name,
|
||||
isActive: c.isActive,
|
||||
usageCount: c.kind === 'EQUIPMENT' ? c._count.assets : c._count.components,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async create(dto: CategoryCreate): Promise<Category> {
|
||||
try {
|
||||
const created = await this.prisma.category.create({ data: dto });
|
||||
return { ...created, usageCount: 0 };
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||
throw new ConflictException('Ce nom existe déjà pour ce référentiel');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** Renommage / (dés)activation — la suppression n'existe pas (invariant R1). */
|
||||
async update(id: string, dto: CategoryUpdate): Promise<Category> {
|
||||
try {
|
||||
const updated = await this.prisma.category.update({
|
||||
where: { id },
|
||||
data: dto,
|
||||
include: { _count: { select: { assets: true, components: true } } },
|
||||
});
|
||||
return {
|
||||
id: updated.id,
|
||||
kind: updated.kind,
|
||||
name: updated.name,
|
||||
isActive: updated.isActive,
|
||||
usageCount:
|
||||
updated.kind === 'EQUIPMENT'
|
||||
? updated._count.assets
|
||||
: updated._count.components,
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') {
|
||||
throw new NotFoundException('Catégorie inconnue');
|
||||
}
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||
throw new ConflictException('Ce nom existe déjà pour ce référentiel');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,14 @@ const EnvSchema = z.object({
|
||||
MINIO_USE_SSL: z.string().optional(),
|
||||
MINIO_ACCESS_KEY: z.string().default('siop'),
|
||||
MINIO_SECRET_KEY: z.string().default('siop-minio'),
|
||||
MINIO_BUCKET: z.string().default('siop2'),
|
||||
// R4 : origines navigateur autorisées en CORS (Expo web / debug mobile).
|
||||
// Vide = aucun CORS (défaut sûr) — le web de prod passe par le proxy nginx
|
||||
// même-origine, les apps natives n'envoient pas d'Origin.
|
||||
CORS_ORIGINS: z.string().default(''),
|
||||
// R5 (ADR-004 §4) : le service IA interne — seul l'API le contacte.
|
||||
AI_SERVICE_URL: z.string().default('http://localhost:8000'),
|
||||
AI_SERVICE_TOKEN: z.string().default('dev-only-ai-token'),
|
||||
});
|
||||
|
||||
export type Env = z.infer<typeof EnvSchema>;
|
||||
|
||||
94
apps/api/src/documents/documents.controller.ts
Normal file
94
apps/api/src/documents/documents.controller.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
StreamableFile,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import {
|
||||
DOCUMENT_MAX_BYTES,
|
||||
DocumentCorpusUpdateSchema,
|
||||
type DocumentCorpusUpdate,
|
||||
} from '@siop/shared';
|
||||
import {
|
||||
AuthenticatedUser,
|
||||
CurrentUser,
|
||||
} from '../auth/current-user.decorator';
|
||||
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||
import { RequirePermission } from '../permissions/require-permission.decorator';
|
||||
import { DocumentsService } from './documents.service';
|
||||
|
||||
@Controller('documents')
|
||||
export class DocumentsController {
|
||||
constructor(private readonly documents: DocumentsService) {}
|
||||
|
||||
/** Bibliothèque interne — authentification seule (les cartes « Documents »
|
||||
* des fiches la consomment). */
|
||||
@Get()
|
||||
list(
|
||||
@Query('assetId') assetId?: string,
|
||||
@Query('workOrderId') workOrderId?: string,
|
||||
@Query('kind') kind?: string,
|
||||
) {
|
||||
return this.documents.list({ assetId, workOrderId, kind });
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', { limits: { fileSize: DOCUMENT_MAX_BYTES } }),
|
||||
)
|
||||
upload(
|
||||
@UploadedFile() file: Express.Multer.File | undefined,
|
||||
@Body() body: { kind?: string; assetId?: string; workOrderId?: string },
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
) {
|
||||
if (!file) throw new BadRequestException('Aucun fichier reçu (champ « file »)');
|
||||
return this.documents.upload(
|
||||
{
|
||||
buffer: file.buffer,
|
||||
originalName: file.originalname,
|
||||
contentType: file.mimetype,
|
||||
size: file.size,
|
||||
kind: body.kind ?? 'OTHER',
|
||||
assetId: body.assetId || undefined,
|
||||
workOrderId: body.workOrderId || undefined,
|
||||
},
|
||||
user,
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':id/download')
|
||||
async download(@Param('id', ParseUUIDPipe) id: string): Promise<StreamableFile> {
|
||||
const { stream, fileName, contentType } = await this.documents.download(id);
|
||||
return new StreamableFile(stream as never, {
|
||||
type: contentType,
|
||||
disposition: `attachment; filename*=UTF-8''${encodeURIComponent(fileName)}`,
|
||||
});
|
||||
}
|
||||
|
||||
/** Corpus IA (D3) : réservé aux gestionnaires du référentiel. */
|
||||
@Patch(':id/corpus')
|
||||
@RequirePermission('ASSETS', 'edit')
|
||||
setCorpus(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body(new ZodValidationPipe(DocumentCorpusUpdateSchema)) body: DocumentCorpusUpdate,
|
||||
) {
|
||||
return this.documents.setCorpus(id, body.inCorpus);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(204)
|
||||
remove(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: AuthenticatedUser) {
|
||||
return this.documents.remove(id, user);
|
||||
}
|
||||
}
|
||||
9
apps/api/src/documents/documents.module.ts
Normal file
9
apps/api/src/documents/documents.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DocumentsController } from './documents.controller';
|
||||
import { DocumentsService } from './documents.service';
|
||||
|
||||
@Module({
|
||||
controllers: [DocumentsController],
|
||||
providers: [DocumentsService],
|
||||
})
|
||||
export class DocumentsModule {}
|
||||
175
apps/api/src/documents/documents.service.ts
Normal file
175
apps/api/src/documents/documents.service.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import {
|
||||
DOCUMENT_CONTENT_TYPES,
|
||||
DOCUMENT_KINDS,
|
||||
DOCUMENT_MAX_BYTES,
|
||||
type DocumentDto,
|
||||
type DocumentKind,
|
||||
type DocumentsResponse,
|
||||
} from '@siop/shared';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { AuthenticatedUser } from '../auth/current-user.decorator';
|
||||
import { FILE_STORAGE, type FileStorage } from '../files/file-storage';
|
||||
import { PermissionsService } from '../permissions/permissions.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
const documentInclude = {
|
||||
asset: { select: { reference: true } },
|
||||
workOrder: { select: { reference: true } },
|
||||
uploadedBy: { select: { displayName: true } },
|
||||
} satisfies Prisma.DocumentInclude;
|
||||
|
||||
type Row = Prisma.DocumentGetPayload<{ include: typeof documentInclude }>;
|
||||
|
||||
export interface UploadInput {
|
||||
buffer: Buffer;
|
||||
originalName: string;
|
||||
contentType: string;
|
||||
size: number;
|
||||
kind: string;
|
||||
assetId?: string;
|
||||
workOrderId?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DocumentsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissions: PermissionsService,
|
||||
@Inject(FILE_STORAGE) private readonly storage: FileStorage,
|
||||
) {}
|
||||
|
||||
async list(filtres: {
|
||||
assetId?: string;
|
||||
workOrderId?: string;
|
||||
kind?: string;
|
||||
}): Promise<DocumentsResponse> {
|
||||
const rows = await this.prisma.document.findMany({
|
||||
where: {
|
||||
assetId: filtres.assetId || undefined,
|
||||
workOrderId: filtres.workOrderId || undefined,
|
||||
kind: (filtres.kind as DocumentKind) || undefined,
|
||||
},
|
||||
include: documentInclude,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return { documents: rows.map((r) => this.toDto(r)) };
|
||||
}
|
||||
|
||||
/** Upload : types fermés, 20 Mo max, rattachement REQUIS, permission
|
||||
* d'édition sur la CIBLE (appareil → ASSETS, OT → WORK_ORDERS). */
|
||||
async upload(input: UploadInput, user: AuthenticatedUser): Promise<DocumentDto> {
|
||||
if (!(DOCUMENT_KINDS as readonly string[]).includes(input.kind)) {
|
||||
throw new BadRequestException('Type de document inconnu');
|
||||
}
|
||||
if (!(DOCUMENT_CONTENT_TYPES as readonly string[]).includes(input.contentType)) {
|
||||
throw new BadRequestException('Format refusé — PDF, JPG ou PNG uniquement');
|
||||
}
|
||||
if (input.size > DOCUMENT_MAX_BYTES) {
|
||||
throw new BadRequestException('Fichier trop lourd — 20 Mo maximum');
|
||||
}
|
||||
if (!input.assetId && !input.workOrderId) {
|
||||
throw new BadRequestException('Rattachez le document à un appareil ou à un OT');
|
||||
}
|
||||
await this.assertCible(input.assetId, input.workOrderId, user, 'edit');
|
||||
|
||||
const storageKey = `documents/${randomUUID()}/${input.originalName.replace(/[^\w.\-()À-ſ ]/g, '_')}`;
|
||||
await this.storage.putObject(storageKey, input.buffer, input.contentType);
|
||||
const created = await this.prisma.document.create({
|
||||
data: {
|
||||
kind: input.kind as DocumentKind,
|
||||
fileName: input.originalName,
|
||||
storageKey,
|
||||
size: input.size,
|
||||
contentType: input.contentType,
|
||||
assetId: input.assetId,
|
||||
workOrderId: input.workOrderId,
|
||||
uploadedById: user.userId,
|
||||
},
|
||||
include: documentInclude,
|
||||
});
|
||||
return this.toDto(created);
|
||||
}
|
||||
|
||||
async download(id: string): Promise<{
|
||||
stream: NodeJS.ReadableStream;
|
||||
fileName: string;
|
||||
contentType: string;
|
||||
}> {
|
||||
const doc = await this.prisma.document.findUnique({ where: { id } });
|
||||
if (!doc) throw new NotFoundException('Document inconnu');
|
||||
return {
|
||||
stream: await this.storage.getObjectStream(doc.storageKey),
|
||||
fileName: doc.fileName,
|
||||
contentType: doc.contentType,
|
||||
};
|
||||
}
|
||||
|
||||
async remove(id: string, user: AuthenticatedUser): Promise<void> {
|
||||
const doc = await this.prisma.document.findUnique({ where: { id } });
|
||||
if (!doc) throw new NotFoundException('Document inconnu');
|
||||
await this.assertCible(doc.assetId ?? undefined, doc.workOrderId ?? undefined, user, 'edit');
|
||||
await this.prisma.document.delete({ where: { id } });
|
||||
await this.storage.removeObject(doc.storageKey).catch(() => {
|
||||
// le stockage peut être momentanément injoignable — la base fait foi
|
||||
});
|
||||
}
|
||||
|
||||
private async assertCible(
|
||||
assetId: string | undefined,
|
||||
workOrderId: string | undefined,
|
||||
user: AuthenticatedUser,
|
||||
right: 'edit',
|
||||
): Promise<void> {
|
||||
if (assetId) {
|
||||
const asset = await this.prisma.asset.findUnique({ where: { id: assetId } });
|
||||
if (!asset) throw new BadRequestException('Appareil inconnu');
|
||||
if (!(await this.permissions.can(user.roleId, 'ASSETS', right))) {
|
||||
throw new ForbiddenException('Droit manquant : ASSETS.edit');
|
||||
}
|
||||
}
|
||||
if (workOrderId) {
|
||||
const ot = await this.prisma.workOrder.findUnique({ where: { id: workOrderId } });
|
||||
if (!ot) throw new BadRequestException('OT inconnu');
|
||||
if (!(await this.permissions.can(user.roleId, 'WORK_ORDERS', right))) {
|
||||
throw new ForbiddenException('Droit manquant : WORK_ORDERS.edit');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private toDto(row: Row): DocumentDto {
|
||||
return {
|
||||
id: row.id,
|
||||
kind: row.kind,
|
||||
fileName: row.fileName,
|
||||
size: row.size,
|
||||
contentType: row.contentType,
|
||||
assetReference: row.asset?.reference ?? null,
|
||||
workOrderReference: row.workOrder?.reference ?? null,
|
||||
uploadedByName: row.uploadedBy?.displayName ?? null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
inCorpus: row.inCorpus,
|
||||
indexedAt: row.indexedAt?.toISOString() ?? null,
|
||||
chunkCount: row.chunkCount,
|
||||
};
|
||||
}
|
||||
|
||||
/** Interrupteur corpus (D3) — effectif à la prochaine réindexation. */
|
||||
async setCorpus(id: string, inCorpus: boolean): Promise<DocumentDto> {
|
||||
const doc = await this.prisma.document.findUnique({ where: { id } });
|
||||
if (!doc) throw new NotFoundException('Document inconnu');
|
||||
const updated = await this.prisma.document.update({
|
||||
where: { id },
|
||||
data: { inCorpus },
|
||||
include: documentInclude,
|
||||
});
|
||||
return this.toDto(updated);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
/**
|
||||
* Interface de stockage de fichiers (ADR-001) : MinIO n'est JAMAIS importé
|
||||
* ailleurs que dans son implémentation — le reste de l'API dépend de cette
|
||||
* interface (règle lint à venir en R0.12). R0 : santé seulement ;
|
||||
* les opérations arrivent avec les photos/documents (R2-R3).
|
||||
* ailleurs que dans son implémentation (règle ESLint) — le reste de l'API
|
||||
* dépend de cette interface. R3 : la bibliothèque de documents l'utilise ;
|
||||
* les téléchargements sont STREAMÉS par l'API (MinIO n'est pas exposé).
|
||||
*/
|
||||
export const FILE_STORAGE = Symbol('FILE_STORAGE');
|
||||
|
||||
export interface FileStorage {
|
||||
/** Lève une exception si le stockage est injoignable. */
|
||||
healthCheck(): Promise<void>;
|
||||
putObject(key: string, body: Buffer, contentType: string): Promise<void>;
|
||||
getObjectStream(key: string): Promise<NodeJS.ReadableStream>;
|
||||
removeObject(key: string): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import * as Minio from 'minio';
|
||||
import { loadEnv } from '../config/env';
|
||||
import type { FileStorage } from './file-storage';
|
||||
|
||||
/** Seul fichier du dépôt autorisé à importer le SDK MinIO. */
|
||||
/** Seul fichier du dépôt autorisé à importer le SDK MinIO (règle ESLint). */
|
||||
@Injectable()
|
||||
export class MinioStorageService implements FileStorage {
|
||||
export class MinioStorageService implements FileStorage, OnModuleInit {
|
||||
private readonly logger = new Logger(MinioStorageService.name);
|
||||
private readonly client: Minio.Client;
|
||||
private readonly bucket: string;
|
||||
|
||||
constructor() {
|
||||
const env = loadEnv();
|
||||
this.bucket = env.MINIO_BUCKET;
|
||||
this.client = new Minio.Client({
|
||||
endPoint: env.MINIO_ENDPOINT,
|
||||
port: env.MINIO_PORT,
|
||||
@@ -19,7 +22,34 @@ export class MinioStorageService implements FileStorage {
|
||||
});
|
||||
}
|
||||
|
||||
/** Le bucket est créé au démarrage — aucune étape manuelle au déploiement. */
|
||||
async onModuleInit(): Promise<void> {
|
||||
try {
|
||||
if (!(await this.client.bucketExists(this.bucket))) {
|
||||
await this.client.makeBucket(this.bucket);
|
||||
this.logger.log(`Bucket « ${this.bucket} » créé.`);
|
||||
}
|
||||
} catch (e) {
|
||||
// Le stockage peut être en retard au boot : /health le signalera.
|
||||
this.logger.warn(`Stockage indisponible au démarrage : ${String(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<void> {
|
||||
await this.client.listBuckets();
|
||||
}
|
||||
|
||||
async putObject(key: string, body: Buffer, contentType: string): Promise<void> {
|
||||
await this.client.putObject(this.bucket, key, body, body.length, {
|
||||
'Content-Type': contentType,
|
||||
});
|
||||
}
|
||||
|
||||
async getObjectStream(key: string): Promise<NodeJS.ReadableStream> {
|
||||
return this.client.getObject(this.bucket, key);
|
||||
}
|
||||
|
||||
async removeObject(key: string): Promise<void> {
|
||||
await this.client.removeObject(this.bucket, key);
|
||||
}
|
||||
}
|
||||
|
||||
44
apps/api/src/locations/locations.controller.ts
Normal file
44
apps/api/src/locations/locations.controller.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
LocationCreateSchema,
|
||||
LocationUpdateSchema,
|
||||
type LocationCreate,
|
||||
type LocationUpdate,
|
||||
} from '@siop/shared';
|
||||
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||
import { RequirePermission } from '../permissions/require-permission.decorator';
|
||||
import { LocationsService } from './locations.service';
|
||||
|
||||
@Controller('locations')
|
||||
export class LocationsController {
|
||||
constructor(private readonly locationsService: LocationsService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermission('LOCATIONS', 'view')
|
||||
list() {
|
||||
return this.locationsService.list();
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermission('LOCATIONS', 'create')
|
||||
create(@Body(new ZodValidationPipe(LocationCreateSchema)) body: LocationCreate) {
|
||||
return this.locationsService.create(body);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequirePermission('LOCATIONS', 'edit')
|
||||
update(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body(new ZodValidationPipe(LocationUpdateSchema)) body: LocationUpdate,
|
||||
) {
|
||||
return this.locationsService.update(id, body);
|
||||
}
|
||||
}
|
||||
10
apps/api/src/locations/locations.module.ts
Normal file
10
apps/api/src/locations/locations.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { LocationsController } from './locations.controller';
|
||||
import { LocationsService } from './locations.service';
|
||||
|
||||
@Module({
|
||||
controllers: [LocationsController],
|
||||
providers: [LocationsService],
|
||||
exports: [LocationsService],
|
||||
})
|
||||
export class LocationsModule {}
|
||||
134
apps/api/src/locations/locations.service.ts
Normal file
134
apps/api/src/locations/locations.service.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type {
|
||||
LocationCreate,
|
||||
LocationDto,
|
||||
LocationsResponse,
|
||||
LocationUpdate,
|
||||
} from '@siop/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
type LocationRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
parentId: string | null;
|
||||
address: string | null;
|
||||
city: string | null;
|
||||
guardianName: string | null;
|
||||
guardianPhone: string | null;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
partnerId: string | null;
|
||||
partner: { name: string } | null;
|
||||
_count: { assets: number };
|
||||
};
|
||||
|
||||
const locationInclude = {
|
||||
partner: { select: { name: true } },
|
||||
_count: { select: { assets: true } },
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export class LocationsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/** Liste plate ; l'assetCount d'un SITE inclut les appareils de ses zones. */
|
||||
async list(): Promise<LocationsResponse> {
|
||||
const rows: LocationRow[] = await this.prisma.location.findMany({
|
||||
include: locationInclude,
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
const childAssets = new Map<string, number>();
|
||||
for (const row of rows) {
|
||||
if (row.parentId) {
|
||||
childAssets.set(
|
||||
row.parentId,
|
||||
(childAssets.get(row.parentId) ?? 0) + row._count.assets,
|
||||
);
|
||||
}
|
||||
}
|
||||
return {
|
||||
locations: rows.map((row) =>
|
||||
this.toDto(row, row._count.assets + (childAssets.get(row.id) ?? 0)),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async create(dto: LocationCreate): Promise<LocationDto> {
|
||||
await this.assertDepth(dto.parentId);
|
||||
await this.assertClientPartner(dto.partnerId);
|
||||
const created = await this.prisma.location.create({
|
||||
data: dto,
|
||||
include: locationInclude,
|
||||
});
|
||||
return this.toDto(created, 0);
|
||||
}
|
||||
|
||||
async update(id: string, dto: LocationUpdate): Promise<LocationDto> {
|
||||
const existing = await this.prisma.location.findUnique({
|
||||
where: { id },
|
||||
include: { _count: { select: { children: true, assets: true } } },
|
||||
});
|
||||
if (!existing) throw new NotFoundException('Emplacement inconnu');
|
||||
if (dto.parentId) {
|
||||
if (dto.parentId === id) {
|
||||
throw new BadRequestException('Un emplacement ne peut pas être son propre parent');
|
||||
}
|
||||
if (existing._count.children > 0) {
|
||||
throw new BadRequestException(
|
||||
'Ce site a des zones : il ne peut pas devenir une zone (hiérarchie limitée à 2 niveaux)',
|
||||
);
|
||||
}
|
||||
await this.assertDepth(dto.parentId);
|
||||
}
|
||||
await this.assertClientPartner(dto.partnerId);
|
||||
const updated = await this.prisma.location.update({
|
||||
where: { id },
|
||||
data: dto,
|
||||
include: locationInclude,
|
||||
});
|
||||
return this.toDto(updated, updated._count.assets);
|
||||
}
|
||||
|
||||
/** Invariant R1 : site → zone, jamais plus profond. */
|
||||
private async assertDepth(parentId?: string): Promise<void> {
|
||||
if (!parentId) return;
|
||||
const parent = await this.prisma.location.findUnique({ where: { id: parentId } });
|
||||
if (!parent) throw new BadRequestException('Emplacement parent inconnu');
|
||||
if (parent.parentId) {
|
||||
throw new BadRequestException(
|
||||
'Hiérarchie limitée à 2 niveaux : une zone ne peut pas contenir d’emplacement',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** R3 : seul un tiers Client/syndic peut gérer un site. */
|
||||
private async assertClientPartner(partnerId?: string | null): Promise<void> {
|
||||
if (!partnerId) return;
|
||||
const partner = await this.prisma.partner.findUnique({ where: { id: partnerId } });
|
||||
if (!partner) throw new BadRequestException('Tiers inconnu');
|
||||
if (partner.kind !== 'CLIENT') {
|
||||
throw new BadRequestException('Seul un tiers « Client / syndic » peut être rattaché à un site');
|
||||
}
|
||||
}
|
||||
|
||||
private toDto(row: Omit<LocationRow, '_count'>, assetCount: number): LocationDto {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
parentId: row.parentId,
|
||||
address: row.address,
|
||||
city: row.city,
|
||||
guardianName: row.guardianName,
|
||||
guardianPhone: row.guardianPhone,
|
||||
latitude: row.latitude,
|
||||
longitude: row.longitude,
|
||||
partnerId: row.partnerId,
|
||||
partnerName: row.partner?.name ?? null,
|
||||
assetCount,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@ async function bootstrap() {
|
||||
assertDemoModeAllowed(); // ADR-002 — double verrou avant toute écoute réseau
|
||||
|
||||
const app = await NestFactory.create(AppModule.forRoot());
|
||||
const origines = env.CORS_ORIGINS.split(',').map((o) => o.trim()).filter(Boolean);
|
||||
if (origines.length) app.enableCors({ origin: origines });
|
||||
app.enableShutdownHooks();
|
||||
await app.listen(env.PORT);
|
||||
new Logger('Bootstrap').log(`API SIOP V2 démarrée sur :${env.PORT}`);
|
||||
|
||||
40
apps/api/src/meters/meters.controller.ts
Normal file
40
apps/api/src/meters/meters.controller.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
MeterReadingCreateSchema,
|
||||
type MeterReadingCreate,
|
||||
} 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 { MetersService } from './meters.service';
|
||||
|
||||
@Controller('assets/:id')
|
||||
export class MetersController {
|
||||
constructor(private readonly meters: MetersService) {}
|
||||
|
||||
@Get('meters')
|
||||
@RequirePermission('METERS', 'view')
|
||||
list(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.meters.list(id);
|
||||
}
|
||||
|
||||
@Post('meter-readings')
|
||||
@RequirePermission('METERS', 'create')
|
||||
addReading(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body(new ZodValidationPipe(MeterReadingCreateSchema)) body: MeterReadingCreate,
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
) {
|
||||
return this.meters.addReading(id, body, user);
|
||||
}
|
||||
}
|
||||
9
apps/api/src/meters/meters.module.ts
Normal file
9
apps/api/src/meters/meters.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MetersController } from './meters.controller';
|
||||
import { MetersService } from './meters.service';
|
||||
|
||||
@Module({
|
||||
controllers: [MetersController],
|
||||
providers: [MetersService],
|
||||
})
|
||||
export class MetersModule {}
|
||||
77
apps/api/src/meters/meters.service.ts
Normal file
77
apps/api/src/meters/meters.service.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { METER_KINDS, type MeterReadingCreate, type MetersResponse } from '@siop/shared';
|
||||
import type { AuthenticatedUser } from '../auth/current-user.decorator';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class MetersService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(assetId: string): Promise<MetersResponse> {
|
||||
await this.assertAsset(assetId);
|
||||
const meters = await this.prisma.meter.findMany({
|
||||
where: { assetId },
|
||||
include: {
|
||||
readings: {
|
||||
include: { readBy: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 10,
|
||||
},
|
||||
},
|
||||
});
|
||||
// Les deux compteurs existent toujours dans la réponse, même vides
|
||||
return {
|
||||
meters: METER_KINDS.map((kind) => {
|
||||
const meter = meters.find((m) => m.kind === kind);
|
||||
return {
|
||||
kind,
|
||||
readings:
|
||||
meter?.readings.map((r) => ({
|
||||
id: r.id,
|
||||
value: r.value,
|
||||
readBy: r.readBy
|
||||
? { id: r.readBy.id, displayName: r.readBy.displayName }
|
||||
: null,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
})) ?? [],
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Invariant : un compteur ne redescend jamais. */
|
||||
async addReading(
|
||||
assetId: string,
|
||||
dto: MeterReadingCreate,
|
||||
user: AuthenticatedUser,
|
||||
): Promise<MetersResponse> {
|
||||
await this.assertAsset(assetId);
|
||||
const meter = await this.prisma.meter.upsert({
|
||||
where: { assetId_kind: { assetId, kind: dto.kind } },
|
||||
update: {},
|
||||
create: { assetId, kind: dto.kind },
|
||||
});
|
||||
const dernier = await this.prisma.meterReading.findFirst({
|
||||
where: { meterId: meter.id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
if (dernier && dto.value <= dernier.value) {
|
||||
throw new BadRequestException(
|
||||
`Relevé refusé : ${dto.value} est inférieur ou égal au précédent (${dernier.value}) — un compteur ne redescend pas`,
|
||||
);
|
||||
}
|
||||
await this.prisma.meterReading.create({
|
||||
data: { meterId: meter.id, value: dto.value, readById: user.userId },
|
||||
});
|
||||
return this.list(assetId);
|
||||
}
|
||||
|
||||
private async assertAsset(assetId: string): Promise<void> {
|
||||
const asset = await this.prisma.asset.findUnique({ where: { id: assetId } });
|
||||
if (!asset) throw new NotFoundException('Appareil inconnu');
|
||||
}
|
||||
}
|
||||
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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
40
apps/api/src/portal/portal.controller.ts
Normal file
40
apps/api/src/portal/portal.controller.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
|
||||
import { Throttle, ThrottlerGuard } from '@nestjs/throttler';
|
||||
import {
|
||||
PortalRequestCreateSchema,
|
||||
type PortalRequestCreate,
|
||||
} from '@siop/shared';
|
||||
import { Public } from '../auth/public.decorator';
|
||||
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||
import { PortalService } from './portal.service';
|
||||
|
||||
/** Portail public (QR cabine) : seules routes @Public métier de l'API —
|
||||
* protégées par throttling (surface exposée sans compte). */
|
||||
@Controller('portal')
|
||||
@UseGuards(ThrottlerGuard)
|
||||
export class PortalController {
|
||||
constructor(private readonly portal: PortalService) {}
|
||||
|
||||
@Public()
|
||||
@Get('assets/:reference')
|
||||
@Throttle({ default: { ttl: 60_000, limit: 60 } })
|
||||
resolveAsset(@Param('reference') reference: string) {
|
||||
return this.portal.resolveAsset(reference);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('requests')
|
||||
@Throttle({ default: { ttl: 60_000, limit: 10 } }) // anti-spam signalement
|
||||
createRequest(
|
||||
@Body(new ZodValidationPipe(PortalRequestCreateSchema)) body: PortalRequestCreate,
|
||||
) {
|
||||
return this.portal.createRequest(body);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('requests/:reference/:token')
|
||||
@Throttle({ default: { ttl: 60_000, limit: 60 } })
|
||||
status(@Param('reference') reference: string, @Param('token') token: string) {
|
||||
return this.portal.status(reference, token);
|
||||
}
|
||||
}
|
||||
11
apps/api/src/portal/portal.module.ts
Normal file
11
apps/api/src/portal/portal.module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ThrottlerModule } from '@nestjs/throttler';
|
||||
import { PortalController } from './portal.controller';
|
||||
import { PortalService } from './portal.service';
|
||||
|
||||
@Module({
|
||||
imports: [ThrottlerModule.forRoot([{ ttl: 60_000, limit: 60 }])],
|
||||
controllers: [PortalController],
|
||||
providers: [PortalService],
|
||||
})
|
||||
export class PortalModule {}
|
||||
101
apps/api/src/portal/portal.service.ts
Normal file
101
apps/api/src/portal/portal.service.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import type {
|
||||
PortalAsset,
|
||||
PortalRequestCreate,
|
||||
PortalRequestCreated,
|
||||
PortalRequestStatus,
|
||||
PortalStep,
|
||||
} from '@siop/shared';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
const requestInclude = { workOrder: true } satisfies Prisma.RequestInclude;
|
||||
type Row = Prisma.RequestGetPayload<{ include: typeof requestInclude }>;
|
||||
|
||||
@Injectable()
|
||||
export class PortalService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/** Le QR encode la référence (imprimée sur l'étiquette). */
|
||||
async resolveAsset(reference: string): Promise<PortalAsset> {
|
||||
const asset = await this.prisma.asset.findUnique({
|
||||
where: { reference },
|
||||
include: { location: { include: { parent: true } } },
|
||||
});
|
||||
if (!asset) throw new NotFoundException('Référence inconnue');
|
||||
return {
|
||||
reference: asset.reference,
|
||||
siteName: asset.location.parent?.name ?? asset.location.name,
|
||||
locationName: asset.location.name,
|
||||
};
|
||||
}
|
||||
|
||||
async createRequest(dto: PortalRequestCreate): Promise<PortalRequestCreated> {
|
||||
const asset = await this.prisma.asset.findUnique({
|
||||
where: { reference: dto.assetReference },
|
||||
});
|
||||
if (!asset) throw new NotFoundException('Référence inconnue');
|
||||
for (let essai = 0; ; essai++) {
|
||||
try {
|
||||
const created = await this.prisma.request.create({
|
||||
data: {
|
||||
reference: await this.nextReference(),
|
||||
description: dto.description,
|
||||
isPersonTrapped: dto.isPersonTrapped ?? false,
|
||||
assetId: asset.id,
|
||||
requesterName: dto.requesterName || 'Portail (QR cabine)',
|
||||
publicToken: randomBytes(24).toString('base64url'),
|
||||
},
|
||||
include: requestInclude,
|
||||
});
|
||||
return { ...this.toStatus(created), publicToken: created.publicToken! };
|
||||
} catch (e) {
|
||||
if (
|
||||
e instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
e.code === 'P2002' &&
|
||||
essai < 3
|
||||
) {
|
||||
continue; // collision de référence — on retente
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Suivi par jeton opaque — pas de jeton, pas de lecture (aucune fuite). */
|
||||
async status(reference: string, token: string): Promise<PortalRequestStatus> {
|
||||
const request = await this.prisma.request.findUnique({
|
||||
where: { reference },
|
||||
include: requestInclude,
|
||||
});
|
||||
if (!request || !request.publicToken || request.publicToken !== token) {
|
||||
throw new NotFoundException('Signalement inconnu ou jeton invalide');
|
||||
}
|
||||
return this.toStatus(request);
|
||||
}
|
||||
|
||||
private async nextReference(): Promise<string> {
|
||||
const [{ nextval }] = await this.prisma.$queryRaw<[{ nextval: bigint }]>`
|
||||
SELECT nextval('request_ref_seq')`;
|
||||
return `DEM-${new Date().getFullYear()}-${String(nextval).padStart(4, '0')}`;
|
||||
}
|
||||
|
||||
private toStatus(row: Row): PortalRequestStatus {
|
||||
const step: PortalStep =
|
||||
row.workOrder?.status === 'DONE'
|
||||
? 'RESOLVED'
|
||||
: row.workOrder && row.workOrder.startedAt
|
||||
? 'IN_PROGRESS'
|
||||
: 'RECEIVED';
|
||||
return {
|
||||
reference: row.reference,
|
||||
description: row.description,
|
||||
isPersonTrapped: row.isPersonTrapped,
|
||||
status: row.status,
|
||||
step,
|
||||
rejectionReason: row.rejectionReason,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
69
apps/api/src/preventive/preventive.controller.ts
Normal file
69
apps/api/src/preventive/preventive.controller.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
PreventiveGenerateSchema,
|
||||
TaskTemplateCreateSchema,
|
||||
TaskTemplateUpdateSchema,
|
||||
type PreventiveGenerate,
|
||||
type TaskTemplateCreate,
|
||||
type TaskTemplateUpdate,
|
||||
} 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 { PreventiveService } from './preventive.service';
|
||||
|
||||
@Controller('preventive')
|
||||
export class PreventiveController {
|
||||
constructor(private readonly preventive: PreventiveService) {}
|
||||
|
||||
@Get('templates')
|
||||
@RequirePermission('WORK_ORDERS', 'view')
|
||||
listTemplates() {
|
||||
return this.preventive.listTemplates();
|
||||
}
|
||||
|
||||
@Post('templates')
|
||||
@RequirePermission('SETTINGS', 'create')
|
||||
createTemplate(
|
||||
@Body(new ZodValidationPipe(TaskTemplateCreateSchema)) body: TaskTemplateCreate,
|
||||
) {
|
||||
return this.preventive.createTemplate(body);
|
||||
}
|
||||
|
||||
@Patch('templates/:id')
|
||||
@RequirePermission('SETTINGS', 'edit')
|
||||
updateTemplate(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body(new ZodValidationPipe(TaskTemplateUpdateSchema)) body: TaskTemplateUpdate,
|
||||
) {
|
||||
return this.preventive.updateTemplate(id, body);
|
||||
}
|
||||
|
||||
@Post('generate')
|
||||
@HttpCode(200) // idempotent : rien n'est « créé » de plus au second appel
|
||||
@RequirePermission('WORK_ORDERS', 'create')
|
||||
generate(
|
||||
@Body(new ZodValidationPipe(PreventiveGenerateSchema)) body: PreventiveGenerate,
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
) {
|
||||
return this.preventive.generate(body.month, user);
|
||||
}
|
||||
|
||||
@Get('status')
|
||||
@RequirePermission('WORK_ORDERS', 'view')
|
||||
status() {
|
||||
return this.preventive.status();
|
||||
}
|
||||
}
|
||||
9
apps/api/src/preventive/preventive.module.ts
Normal file
9
apps/api/src/preventive/preventive.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PreventiveController } from './preventive.controller';
|
||||
import { PreventiveService } from './preventive.service';
|
||||
|
||||
@Module({
|
||||
controllers: [PreventiveController],
|
||||
providers: [PreventiveService],
|
||||
})
|
||||
export class PreventiveModule {}
|
||||
229
apps/api/src/preventive/preventive.service.ts
Normal file
229
apps/api/src/preventive/preventive.service.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import type {
|
||||
PreventiveGenerationResult,
|
||||
PreventiveStatus,
|
||||
TaskTemplateCreate,
|
||||
TaskTemplateDto,
|
||||
TaskTemplatesResponse,
|
||||
TaskTemplateUpdate,
|
||||
} from '@siop/shared';
|
||||
import type { AuthenticatedUser } from '../auth/current-user.decorator';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
const templateInclude = {
|
||||
componentType: true,
|
||||
} satisfies Prisma.TaskTemplateInclude;
|
||||
|
||||
type TemplateRow = Prisma.TaskTemplateGetPayload<{ include: typeof templateInclude }>;
|
||||
|
||||
const MOIS_FR = [
|
||||
'janvier', 'février', 'mars', 'avril', 'mai', 'juin',
|
||||
'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre',
|
||||
];
|
||||
|
||||
/** « AAAA-MM » → index absolu de mois. */
|
||||
const indexMois = (period: string): number => {
|
||||
const [annee, mois] = period.split('-').map(Number);
|
||||
return annee! * 12 + (mois! - 1);
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class PreventiveService {
|
||||
private readonly logger = new Logger(PreventiveService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
// ————— Gabarits —————
|
||||
|
||||
async listTemplates(): Promise<TaskTemplatesResponse> {
|
||||
const rows = await this.prisma.taskTemplate.findMany({
|
||||
include: templateInclude,
|
||||
orderBy: [{ periodMonths: 'asc' }, { label: 'asc' }],
|
||||
});
|
||||
return { templates: rows.map((t) => this.toDto(t)) };
|
||||
}
|
||||
|
||||
async createTemplate(dto: TaskTemplateCreate): Promise<TaskTemplateDto> {
|
||||
try {
|
||||
const created = await this.prisma.taskTemplate.create({
|
||||
data: dto,
|
||||
include: templateInclude,
|
||||
});
|
||||
return this.toDto(created);
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||
throw new ConflictException('Ce libellé de tâche existe déjà');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async updateTemplate(id: string, dto: TaskTemplateUpdate): Promise<TaskTemplateDto> {
|
||||
try {
|
||||
const updated = await this.prisma.taskTemplate.update({
|
||||
where: { id },
|
||||
data: dto,
|
||||
include: templateInclude,
|
||||
});
|
||||
return this.toDto(updated);
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') {
|
||||
throw new NotFoundException('Gabarit inconnu');
|
||||
}
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||
throw new ConflictException('Ce libellé de tâche existe déjà');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// ————— Génération mensuelle (IDEMPOTENTE) —————
|
||||
|
||||
/**
|
||||
* Une grille par appareil sous contrat et par mois — l'unicité
|
||||
* [assetId, periodKey] est en BASE : regénérer ne double jamais rien,
|
||||
* même en cas d'appels concurrents.
|
||||
* Tâches dues : périodicité ancrée sur la mise en service (à défaut, tout
|
||||
* mois) ; appareil sans historique préventif → « premier contrôle » (tout).
|
||||
*/
|
||||
async generate(
|
||||
month: string | undefined,
|
||||
user: AuthenticatedUser,
|
||||
): Promise<PreventiveGenerationResult> {
|
||||
const period = month ?? new Date().toISOString().slice(0, 7);
|
||||
const [annee, mois] = period.split('-').map(Number);
|
||||
const dernierJour = new Date(Date.UTC(annee!, mois!, 0, 23, 59, 59));
|
||||
const libelleMois = `${MOIS_FR[mois! - 1]} ${annee}`;
|
||||
|
||||
const [assets, templates] = await Promise.all([
|
||||
this.prisma.asset.findMany({
|
||||
where: { underContract: true },
|
||||
include: { _count: { select: { workOrders: { where: { type: 'PREVENTIVE' } } } } },
|
||||
}),
|
||||
this.prisma.taskTemplate.findMany({ where: { isActive: true } }),
|
||||
]);
|
||||
|
||||
let created = 0;
|
||||
let skipped = 0;
|
||||
let firstControls = 0;
|
||||
const references: string[] = [];
|
||||
|
||||
for (const asset of assets) {
|
||||
const premierControle = asset._count.workOrders === 0;
|
||||
const dues = premierControle
|
||||
? templates
|
||||
: templates.filter((t) => {
|
||||
if (t.periodMonths === 1) return true;
|
||||
const ancre = asset.commissionedAt
|
||||
? asset.commissionedAt.getUTCFullYear() * 12 + asset.commissionedAt.getUTCMonth()
|
||||
: indexMois(period);
|
||||
return (indexMois(period) - ancre) % t.periodMonths === 0;
|
||||
});
|
||||
if (dues.length === 0) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
let issue: 'créé' | 'sauté' | null = null;
|
||||
for (let essai = 0; essai < 4 && issue === null; essai++) {
|
||||
try {
|
||||
const ot = await this.prisma.workOrder.create({
|
||||
data: {
|
||||
reference: await this.nextReference(),
|
||||
title: premierControle
|
||||
? `Premier contrôle — ${libelleMois}`
|
||||
: `Grille du mois — ${libelleMois}`,
|
||||
type: 'PREVENTIVE',
|
||||
priority: 'LOW',
|
||||
assetId: asset.id,
|
||||
periodKey: period,
|
||||
dueDate: dernierJour,
|
||||
createdById: user.userId,
|
||||
checklist: {
|
||||
create: dues.map((t) => ({ label: t.label, templateId: t.id })),
|
||||
},
|
||||
events: {
|
||||
create: {
|
||||
kind: 'GENERATED',
|
||||
message: `Généré (${premierControle ? 'premier contrôle' : 'grille mensuelle'} · ${dues.length} tâches)`,
|
||||
byId: user.userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: { reference: true },
|
||||
});
|
||||
created++;
|
||||
if (premierControle) firstControls++;
|
||||
references.push(ot.reference);
|
||||
issue = 'créé';
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||
const cible = (e.meta?.target as string[] | string | undefined) ?? '';
|
||||
if (String(cible).includes('periodKey')) {
|
||||
issue = 'sauté'; // grille déjà en place : idempotence
|
||||
}
|
||||
// sinon : collision de référence (course avec une création d'OT)
|
||||
// → on recalcule et on retente
|
||||
} else if (
|
||||
e instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
e.code === 'P2003'
|
||||
) {
|
||||
issue = 'sauté'; // appareil supprimé entre lecture et écriture
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (issue !== 'créé') skipped++;
|
||||
}
|
||||
this.logger.log(
|
||||
`Préventif ${period} : ${created} générés, ${skipped} déjà en place, ${firstControls} premiers contrôles.`,
|
||||
);
|
||||
return { month: period, created, skipped, firstControls, references };
|
||||
}
|
||||
|
||||
async status(): Promise<PreventiveStatus> {
|
||||
const period = new Date().toISOString().slice(0, 7);
|
||||
const [assetsUnderContract, grilles] = await Promise.all([
|
||||
this.prisma.asset.count({ where: { underContract: true } }),
|
||||
this.prisma.workOrder.findMany({
|
||||
where: { periodKey: period },
|
||||
select: { status: true, dueDate: true, title: true },
|
||||
}),
|
||||
]);
|
||||
const maintenant = new Date();
|
||||
return {
|
||||
month: period,
|
||||
assetsUnderContract,
|
||||
generated: grilles.length,
|
||||
done: grilles.filter((g) => g.status === 'DONE').length,
|
||||
late: grilles.filter(
|
||||
(g) => g.status !== 'DONE' && g.status !== 'CANCELLED' && g.dueDate! < maintenant,
|
||||
).length,
|
||||
firstControls: grilles.filter((g) => g.title.startsWith('Premier contrôle')).length,
|
||||
};
|
||||
}
|
||||
|
||||
private async nextReference(): Promise<string> {
|
||||
const [{ nextval }] = await this.prisma.$queryRaw<[{ nextval: bigint }]>`
|
||||
SELECT nextval('work_order_ref_seq')`;
|
||||
return `OT-${new Date().getFullYear()}-${String(nextval).padStart(4, '0')}`;
|
||||
}
|
||||
|
||||
private toDto(row: TemplateRow): TaskTemplateDto {
|
||||
return {
|
||||
id: row.id,
|
||||
label: row.label,
|
||||
periodMonths: row.periodMonths,
|
||||
isRegulatory: row.isRegulatory,
|
||||
isActive: row.isActive,
|
||||
componentTypeId: row.componentTypeId,
|
||||
componentTypeName: row.componentType?.name ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
46
apps/api/src/reference-values/reference-values.controller.ts
Normal file
46
apps/api/src/reference-values/reference-values.controller.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ReferenceValueCreateSchema,
|
||||
ReferenceValueUpdateSchema,
|
||||
type ReferenceValueCreate,
|
||||
type ReferenceValueUpdate,
|
||||
} from '@siop/shared';
|
||||
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||
import { RequirePermission } from '../permissions/require-permission.decorator';
|
||||
import { ReferenceValuesService } from './reference-values.service';
|
||||
|
||||
@Controller('reference-values')
|
||||
export class ReferenceValuesController {
|
||||
constructor(private readonly referenceValues: ReferenceValuesService) {}
|
||||
|
||||
/** Lu par le formulaire de bilan — authentification seule. */
|
||||
@Get()
|
||||
list() {
|
||||
return this.referenceValues.list();
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermission('SETTINGS', 'create')
|
||||
create(
|
||||
@Body(new ZodValidationPipe(ReferenceValueCreateSchema)) body: ReferenceValueCreate,
|
||||
) {
|
||||
return this.referenceValues.create(body);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequirePermission('SETTINGS', 'edit')
|
||||
update(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body(new ZodValidationPipe(ReferenceValueUpdateSchema)) body: ReferenceValueUpdate,
|
||||
) {
|
||||
return this.referenceValues.update(id, body);
|
||||
}
|
||||
}
|
||||
10
apps/api/src/reference-values/reference-values.module.ts
Normal file
10
apps/api/src/reference-values/reference-values.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ReferenceValuesController } from './reference-values.controller';
|
||||
import { ReferenceValuesService } from './reference-values.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ReferenceValuesController],
|
||||
providers: [ReferenceValuesService],
|
||||
exports: [ReferenceValuesService],
|
||||
})
|
||||
export class ReferenceValuesModule {}
|
||||
93
apps/api/src/reference-values/reference-values.service.ts
Normal file
93
apps/api/src/reference-values/reference-values.service.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import type {
|
||||
ReferenceValueCreate,
|
||||
ReferenceValueDto,
|
||||
ReferenceValuesResponse,
|
||||
ReferenceValueUpdate,
|
||||
} from '@siop/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
const usageInclude = {
|
||||
_count: {
|
||||
select: {
|
||||
doorStates: true,
|
||||
cabinPositions: true,
|
||||
anomalies: true,
|
||||
externalCauses: true,
|
||||
actionsTaken: true,
|
||||
componentsConcerned: true,
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.ReferenceValueInclude;
|
||||
|
||||
type Row = Prisma.ReferenceValueGetPayload<{ include: typeof usageInclude }>;
|
||||
|
||||
@Injectable()
|
||||
export class ReferenceValuesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(): Promise<ReferenceValuesResponse> {
|
||||
const rows = await this.prisma.referenceValue.findMany({
|
||||
include: usageInclude,
|
||||
orderBy: [{ field: 'asc' }, { label: 'asc' }],
|
||||
});
|
||||
return { referenceValues: rows.map((r) => this.toDto(r)) };
|
||||
}
|
||||
|
||||
async create(dto: ReferenceValueCreate): Promise<ReferenceValueDto> {
|
||||
try {
|
||||
const created = await this.prisma.referenceValue.create({
|
||||
data: dto,
|
||||
include: usageInclude,
|
||||
});
|
||||
return this.toDto(created);
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||
throw new ConflictException('Ce libellé existe déjà pour ce champ');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** Renommage / (dés)activation — jamais de suppression (même règle que Category). */
|
||||
async update(id: string, dto: ReferenceValueUpdate): Promise<ReferenceValueDto> {
|
||||
try {
|
||||
const updated = await this.prisma.referenceValue.update({
|
||||
where: { id },
|
||||
data: dto,
|
||||
include: usageInclude,
|
||||
});
|
||||
return this.toDto(updated);
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') {
|
||||
throw new NotFoundException('Valeur inconnue');
|
||||
}
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||
throw new ConflictException('Ce libellé existe déjà pour ce champ');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private toDto(row: Row): ReferenceValueDto {
|
||||
const c = row._count;
|
||||
return {
|
||||
id: row.id,
|
||||
field: row.field,
|
||||
label: row.label,
|
||||
isActive: row.isActive,
|
||||
usageCount:
|
||||
c.doorStates +
|
||||
c.cabinPositions +
|
||||
c.anomalies +
|
||||
c.externalCauses +
|
||||
c.actionsTaken +
|
||||
c.componentsConcerned,
|
||||
};
|
||||
}
|
||||
}
|
||||
65
apps/api/src/requests/requests.controller.ts
Normal file
65
apps/api/src/requests/requests.controller.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
RequestApproveSchema,
|
||||
RequestCreateSchema,
|
||||
RequestRejectSchema,
|
||||
type RequestApprove,
|
||||
type RequestCreate,
|
||||
type RequestReject,
|
||||
} 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 { RequestsService } from './requests.service';
|
||||
|
||||
@Controller('requests')
|
||||
export class RequestsController {
|
||||
constructor(private readonly requests: RequestsService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermission('REQUESTS', 'view')
|
||||
list(@CurrentUser() user: AuthenticatedUser) {
|
||||
return this.requests.list(user);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermission('REQUESTS', 'create')
|
||||
create(
|
||||
@Body(new ZodValidationPipe(RequestCreateSchema)) body: RequestCreate,
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
) {
|
||||
return this.requests.create(body, user);
|
||||
}
|
||||
|
||||
@Post(':id/approve')
|
||||
@RequirePermission('REQUESTS', 'edit')
|
||||
approve(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body(new ZodValidationPipe(RequestApproveSchema)) body: RequestApprove,
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
) {
|
||||
return this.requests.approve(id, body, user);
|
||||
}
|
||||
|
||||
@Post(':id/reject')
|
||||
@HttpCode(200) // le contrat : 200, la demande est mise à jour
|
||||
@RequirePermission('REQUESTS', 'edit')
|
||||
reject(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body(new ZodValidationPipe(RequestRejectSchema)) body: RequestReject,
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
) {
|
||||
return this.requests.reject(id, body, user);
|
||||
}
|
||||
}
|
||||
12
apps/api/src/requests/requests.module.ts
Normal file
12
apps/api/src/requests/requests.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AssetsModule } from '../assets/assets.module';
|
||||
import { WorkOrdersModule } from '../work-orders/work-orders.module';
|
||||
import { RequestsController } from './requests.controller';
|
||||
import { RequestsService } from './requests.service';
|
||||
|
||||
@Module({
|
||||
imports: [WorkOrdersModule, AssetsModule],
|
||||
controllers: [RequestsController],
|
||||
providers: [RequestsService],
|
||||
})
|
||||
export class RequestsModule {}
|
||||
174
apps/api/src/requests/requests.service.ts
Normal file
174
apps/api/src/requests/requests.service.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import type {
|
||||
RequestApprove,
|
||||
RequestCreate,
|
||||
RequestReject,
|
||||
RequestsResponse,
|
||||
RequestSummary,
|
||||
WorkOrderDetail,
|
||||
} from '@siop/shared';
|
||||
import type { AuthenticatedUser } from '../auth/current-user.decorator';
|
||||
import { PermissionsService } from '../permissions/permissions.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AssetsService } from '../assets/assets.service';
|
||||
import { WorkOrdersService } from '../work-orders/work-orders.service';
|
||||
|
||||
const requestInclude = {
|
||||
asset: { include: { location: { include: { parent: true } } } },
|
||||
requestedBy: true,
|
||||
workOrder: true,
|
||||
} satisfies Prisma.RequestInclude;
|
||||
|
||||
type RequestRow = Prisma.RequestGetPayload<{ include: typeof requestInclude }>;
|
||||
|
||||
@Injectable()
|
||||
export class RequestsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissions: PermissionsService,
|
||||
private readonly workOrders: WorkOrdersService,
|
||||
private readonly assets: AssetsService,
|
||||
) {}
|
||||
|
||||
private async scope(user: AuthenticatedUser): Promise<Prisma.RequestWhereInput> {
|
||||
const viewOther = await this.permissions.can(user.roleId, 'REQUESTS', 'viewOther');
|
||||
return viewOther ? {} : { requestedById: user.userId };
|
||||
}
|
||||
|
||||
async list(user: AuthenticatedUser): Promise<RequestsResponse> {
|
||||
const rows = await this.prisma.request.findMany({
|
||||
where: await this.scope(user),
|
||||
include: requestInclude,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
rows.sort((a, b) => Number(b.isPersonTrapped) - Number(a.isPersonTrapped));
|
||||
return { requests: rows.map((r) => this.toDto(r)) };
|
||||
}
|
||||
|
||||
async create(dto: RequestCreate, user: AuthenticatedUser): Promise<RequestSummary> {
|
||||
const asset = await this.prisma.asset.findUnique({ where: { id: dto.assetId } });
|
||||
if (!asset) throw new BadRequestException('Équipement inconnu');
|
||||
// Défense en profondeur (R6.6) : même filtre que /assets/options, pour
|
||||
// qu'un Demandeur affecté à un site ne puisse pas contourner la liste
|
||||
// en soumettant directement un assetId hors périmètre.
|
||||
const allowed = await this.assets.allowedLocationIds(user);
|
||||
if (allowed && !allowed.includes(asset.locationId)) {
|
||||
throw new BadRequestException("Cet équipement n'est pas dans votre périmètre");
|
||||
}
|
||||
for (let essai = 0; ; essai++) {
|
||||
try {
|
||||
const created = await this.prisma.request.create({
|
||||
data: {
|
||||
reference: await this.nextReference(),
|
||||
description: dto.description,
|
||||
isPersonTrapped: dto.isPersonTrapped ?? false,
|
||||
assetId: dto.assetId,
|
||||
requestedById: user.userId,
|
||||
},
|
||||
include: requestInclude,
|
||||
});
|
||||
return this.toDto(created);
|
||||
} catch (e) {
|
||||
if (
|
||||
e instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
e.code === 'P2002' &&
|
||||
essai < 3
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Approuver = créer l'OT lié (1-1). Une demande ne se traite qu'une fois. */
|
||||
async approve(
|
||||
id: string,
|
||||
dto: RequestApprove,
|
||||
user: AuthenticatedUser,
|
||||
): Promise<WorkOrderDetail> {
|
||||
const request = await this.prisma.request.findUnique({
|
||||
where: { id },
|
||||
include: requestInclude,
|
||||
});
|
||||
if (!request) throw new NotFoundException('Demande inconnue');
|
||||
if (request.status !== 'RECEIVED') {
|
||||
throw new ConflictException('Cette demande a déjà été traitée');
|
||||
}
|
||||
const created = await this.workOrders.createRaw({
|
||||
title: dto.title ?? request.description.slice(0, 120),
|
||||
description: `${request.description}\n\n(Demande ${request.reference} — ${this.requesterLabel(request)})`,
|
||||
type: 'CORRECTIVE',
|
||||
priority: dto.priority ?? (request.isPersonTrapped ? 'PERSON_TRAPPED' : 'MEDIUM'),
|
||||
assetId: request.assetId,
|
||||
dueDate: dto.dueDate,
|
||||
assigneeIds: dto.assigneeIds,
|
||||
createdById: user.userId,
|
||||
eventKind: 'FROM_REQUEST',
|
||||
eventMessage: `OT créé depuis la demande ${request.reference}`,
|
||||
});
|
||||
await this.prisma.request.update({
|
||||
where: { id },
|
||||
data: { status: 'APPROVED', workOrderId: created.id },
|
||||
});
|
||||
return this.workOrders.get(created.id, user);
|
||||
}
|
||||
|
||||
async reject(
|
||||
id: string,
|
||||
dto: RequestReject,
|
||||
user: AuthenticatedUser,
|
||||
): Promise<RequestSummary> {
|
||||
void user;
|
||||
const request = await this.prisma.request.findUnique({ where: { id } });
|
||||
if (!request) throw new NotFoundException('Demande inconnue');
|
||||
if (request.status !== 'RECEIVED') {
|
||||
throw new ConflictException('Cette demande a déjà été traitée');
|
||||
}
|
||||
const updated = await this.prisma.request.update({
|
||||
where: { id },
|
||||
data: { status: 'REJECTED', rejectionReason: dto.reason },
|
||||
include: requestInclude,
|
||||
});
|
||||
return this.toDto(updated);
|
||||
}
|
||||
|
||||
private async nextReference(): Promise<string> {
|
||||
const [{ nextval }] = await this.prisma.$queryRaw<[{ nextval: bigint }]>`
|
||||
SELECT nextval('request_ref_seq')`;
|
||||
return `DEM-${new Date().getFullYear()}-${String(nextval).padStart(4, '0')}`;
|
||||
}
|
||||
|
||||
private requesterLabel(row: RequestRow): string {
|
||||
return row.requestedBy?.displayName ?? row.requesterName ?? 'Portail';
|
||||
}
|
||||
|
||||
private toDto(row: RequestRow): RequestSummary {
|
||||
return {
|
||||
id: row.id,
|
||||
reference: row.reference,
|
||||
description: row.description,
|
||||
isPersonTrapped: row.isPersonTrapped,
|
||||
status: row.status,
|
||||
rejectionReason: row.rejectionReason,
|
||||
assetId: row.assetId,
|
||||
assetReference: row.asset.reference,
|
||||
siteName: row.asset.location.parent?.name ?? row.asset.location.name,
|
||||
requesterLabel: this.requesterLabel(row),
|
||||
workOrder: row.workOrder
|
||||
? {
|
||||
id: row.workOrder.id,
|
||||
reference: row.workOrder.reference,
|
||||
status: row.workOrder.status,
|
||||
}
|
||||
: null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
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 },
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
44
apps/api/src/teams/teams.controller.ts
Normal file
44
apps/api/src/teams/teams.controller.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
TeamCreateSchema,
|
||||
TeamUpdateSchema,
|
||||
type TeamCreate,
|
||||
type TeamUpdate,
|
||||
} from '@siop/shared';
|
||||
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||
import { RequirePermission } from '../permissions/require-permission.decorator';
|
||||
import { TeamsService } from './teams.service';
|
||||
|
||||
@Controller('teams')
|
||||
export class TeamsController {
|
||||
constructor(private readonly teamsService: TeamsService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermission('PEOPLE_TEAMS', 'view')
|
||||
list() {
|
||||
return this.teamsService.list();
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermission('PEOPLE_TEAMS', 'create')
|
||||
create(@Body(new ZodValidationPipe(TeamCreateSchema)) body: TeamCreate) {
|
||||
return this.teamsService.create(body);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequirePermission('PEOPLE_TEAMS', 'edit')
|
||||
update(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body(new ZodValidationPipe(TeamUpdateSchema)) body: TeamUpdate,
|
||||
) {
|
||||
return this.teamsService.update(id, body);
|
||||
}
|
||||
}
|
||||
10
apps/api/src/teams/teams.module.ts
Normal file
10
apps/api/src/teams/teams.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TeamsController } from './teams.controller';
|
||||
import { TeamsService } from './teams.service';
|
||||
|
||||
@Module({
|
||||
controllers: [TeamsController],
|
||||
providers: [TeamsService],
|
||||
exports: [TeamsService],
|
||||
})
|
||||
export class TeamsModule {}
|
||||
92
apps/api/src/teams/teams.service.ts
Normal file
92
apps/api/src/teams/teams.service.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import type { Team, TeamCreate, TeamsResponse, TeamUpdate } from '@siop/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
const teamInclude = {
|
||||
members: { include: { role: true }, orderBy: { displayName: 'asc' } },
|
||||
} satisfies Prisma.TeamInclude;
|
||||
|
||||
type TeamRow = Prisma.TeamGetPayload<{ include: typeof teamInclude }>;
|
||||
|
||||
@Injectable()
|
||||
export class TeamsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(): Promise<TeamsResponse> {
|
||||
const rows = await this.prisma.team.findMany({
|
||||
include: teamInclude,
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
return { teams: rows.map((t) => this.toDto(t)) };
|
||||
}
|
||||
|
||||
async create(dto: TeamCreate): Promise<Team> {
|
||||
try {
|
||||
const created = await this.prisma.team.create({
|
||||
data: {
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
members: dto.memberIds?.length
|
||||
? { connect: dto.memberIds.map((id) => ({ id })) }
|
||||
: undefined,
|
||||
},
|
||||
include: teamInclude,
|
||||
});
|
||||
return this.toDto(created);
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||
throw new ConflictException('Ce nom d’équipe existe déjà');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async update(id: string, dto: TeamUpdate): Promise<Team> {
|
||||
try {
|
||||
const updated = await this.prisma.team.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
members: dto.memberIds
|
||||
? { set: dto.memberIds.map((memberId) => ({ id: memberId })) }
|
||||
: undefined,
|
||||
},
|
||||
include: teamInclude,
|
||||
});
|
||||
return this.toDto(updated);
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') {
|
||||
throw new NotFoundException('Équipe inconnue');
|
||||
}
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||
throw new ConflictException('Ce nom d’équipe existe déjà');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private toDto(row: TeamRow): Team {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
members: row.members.map((m) => ({
|
||||
id: m.id,
|
||||
displayName: m.displayName,
|
||||
roleName: m.role.name,
|
||||
initials: m.displayName
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((w) => w[0]!.toUpperCase())
|
||||
.join(''),
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,41 @@
|
||||
import { Controller, Get, NotFoundException } from '@nestjs/common';
|
||||
import type { MeResponse, RoleName } from '@siop/shared';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
NotFoundException,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
InvitationCreateSchema,
|
||||
UserUpdateSchema,
|
||||
type InvitationCreate,
|
||||
type MeResponse,
|
||||
type RoleName,
|
||||
type UserUpdate,
|
||||
} from '@siop/shared';
|
||||
import {
|
||||
AuthenticatedUser,
|
||||
CurrentUser,
|
||||
} from '../auth/current-user.decorator';
|
||||
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||
import { PermissionsService } from '../permissions/permissions.service';
|
||||
import { RequirePermission } from '../permissions/require-permission.decorator';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
@Controller('users')
|
||||
@Controller()
|
||||
export class UsersController {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissionsService: PermissionsService,
|
||||
private readonly usersService: UsersService,
|
||||
) {}
|
||||
|
||||
/** R0 : lecture du profil courant (gestion complète des utilisateurs en R1). */
|
||||
@Get('me')
|
||||
/** Profil courant — pas de permission : chacun lit le sien. */
|
||||
@Get('users/me')
|
||||
async me(@CurrentUser() current: AuthenticatedUser): Promise<MeResponse> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: current.userId },
|
||||
@@ -31,4 +51,37 @@ export class UsersController {
|
||||
permissions: await this.permissionsService.getForRole(user.roleId),
|
||||
};
|
||||
}
|
||||
|
||||
@Get('users')
|
||||
@RequirePermission('PEOPLE_TEAMS', 'view')
|
||||
list() {
|
||||
return this.usersService.list();
|
||||
}
|
||||
|
||||
@Get('roles')
|
||||
@RequirePermission('PEOPLE_TEAMS', 'view')
|
||||
roles() {
|
||||
return this.usersService.roles();
|
||||
}
|
||||
|
||||
@Post('users/invitations')
|
||||
@RequirePermission('PEOPLE_TEAMS', 'create')
|
||||
invite(@Body(new ZodValidationPipe(InvitationCreateSchema)) body: InvitationCreate) {
|
||||
return this.usersService.invite(body);
|
||||
}
|
||||
|
||||
@Post('users/:id/invitation')
|
||||
@RequirePermission('PEOPLE_TEAMS', 'edit')
|
||||
resendInvitation(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.usersService.resendInvitation(id);
|
||||
}
|
||||
|
||||
@Patch('users/:id')
|
||||
@RequirePermission('PEOPLE_TEAMS', 'edit')
|
||||
update(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body(new ZodValidationPipe(UserUpdateSchema)) body: UserUpdate,
|
||||
) {
|
||||
return this.usersService.update(id, body);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { UsersController } from './users.controller';
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
@Module({
|
||||
controllers: [UsersController],
|
||||
providers: [UsersService],
|
||||
exports: [UsersService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
|
||||
164
apps/api/src/users/users.service.ts
Normal file
164
apps/api/src/users/users.service.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import {
|
||||
INVITATION_TTL_DAYS,
|
||||
type InvitationCreate,
|
||||
type InvitationResponse,
|
||||
type RoleName,
|
||||
type RolesResponse,
|
||||
type UserAdmin,
|
||||
type UsersResponse,
|
||||
type UserUpdate,
|
||||
} from '@siop/shared';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
const userInclude = {
|
||||
role: true,
|
||||
teams: { orderBy: { name: 'asc' } },
|
||||
assignedSites: { orderBy: { name: 'asc' } },
|
||||
} satisfies Prisma.UserInclude;
|
||||
|
||||
type UserRow = Prisma.UserGetPayload<{ include: typeof userInclude }>;
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(): Promise<UsersResponse> {
|
||||
const rows = await this.prisma.user.findMany({
|
||||
include: userInclude,
|
||||
orderBy: { displayName: 'asc' },
|
||||
});
|
||||
return { users: rows.map((u) => this.toDto(u)) };
|
||||
}
|
||||
|
||||
async roles(): Promise<RolesResponse> {
|
||||
const roles = await this.prisma.role.findMany({ orderBy: { name: 'asc' } });
|
||||
return { roles: roles.map((r) => ({ id: r.id, name: r.name as RoleName })) };
|
||||
}
|
||||
|
||||
/** Invitation : compte créé SANS mot de passe + lien d'activation 7 jours.
|
||||
* L'envoi d'email viendra plus tard — le web affiche le lien à copier. */
|
||||
async invite(dto: InvitationCreate): Promise<InvitationResponse> {
|
||||
const role = await this.prisma.role.findUnique({ where: { id: dto.roleId } });
|
||||
if (!role) throw new NotFoundException('Rôle inconnu');
|
||||
if (dto.locationIds?.length) await this.assertTopLevelSites(dto.locationIds);
|
||||
try {
|
||||
const user = await this.prisma.user.create({
|
||||
data: {
|
||||
email: dto.email,
|
||||
displayName: dto.displayName,
|
||||
phone: dto.phone,
|
||||
roleId: dto.roleId,
|
||||
teams: dto.teamIds?.length
|
||||
? { connect: dto.teamIds.map((id) => ({ id })) }
|
||||
: undefined,
|
||||
assignedSites: dto.locationIds?.length
|
||||
? { connect: dto.locationIds.map((id) => ({ id })) }
|
||||
: undefined,
|
||||
...this.freshToken(),
|
||||
},
|
||||
});
|
||||
return {
|
||||
userId: user.id,
|
||||
activationToken: user.activationToken!,
|
||||
expiresAt: user.activationExpiresAt!.toISOString(),
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||
throw new ConflictException('Cet email a déjà un compte');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async resendInvitation(userId: string): Promise<InvitationResponse> {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) throw new NotFoundException('Personne inconnue');
|
||||
if (user.passwordHash) {
|
||||
throw new ConflictException('Ce compte est déjà activé');
|
||||
}
|
||||
const updated = await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: this.freshToken(),
|
||||
});
|
||||
return {
|
||||
userId: updated.id,
|
||||
activationToken: updated.activationToken!,
|
||||
expiresAt: updated.activationExpiresAt!.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async update(userId: string, dto: UserUpdate): Promise<UserAdmin> {
|
||||
if (dto.locationIds) await this.assertTopLevelSites(dto.locationIds);
|
||||
try {
|
||||
const updated = await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
displayName: dto.displayName,
|
||||
phone: dto.phone,
|
||||
roleId: dto.roleId,
|
||||
isActive: dto.isActive,
|
||||
hourlyRate: dto.hourlyRate,
|
||||
teams: dto.teamIds
|
||||
? { set: dto.teamIds.map((id) => ({ id })) }
|
||||
: undefined,
|
||||
assignedSites: dto.locationIds
|
||||
? { set: dto.locationIds.map((id) => ({ id })) }
|
||||
: undefined,
|
||||
},
|
||||
include: userInclude,
|
||||
});
|
||||
return this.toDto(updated);
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') {
|
||||
throw new NotFoundException('Personne inconnue');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** R6.6 : un Demandeur n'est affecté qu'à des sites racine, jamais des zones
|
||||
* — même invariant que la hiérarchie site/zone (LocationsService.assertDepth). */
|
||||
private async assertTopLevelSites(ids: string[]): Promise<void> {
|
||||
const sites = await this.prisma.location.findMany({ where: { id: { in: ids } } });
|
||||
if (sites.length !== ids.length) throw new BadRequestException('Site inconnu');
|
||||
if (sites.some((s) => s.parentId)) {
|
||||
throw new BadRequestException("L'affectation d'un Demandeur se fait à un site, pas à une zone");
|
||||
}
|
||||
}
|
||||
|
||||
private freshToken() {
|
||||
return {
|
||||
activationToken: randomBytes(32).toString('base64url'),
|
||||
activationExpiresAt: new Date(
|
||||
Date.now() + INVITATION_TTL_DAYS * 24 * 60 * 60 * 1000,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
private toDto(row: UserRow): UserAdmin {
|
||||
return {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
displayName: row.displayName,
|
||||
phone: row.phone,
|
||||
role: { id: row.role.id, name: row.role.name as RoleName },
|
||||
teams: row.teams.map((t) => ({ id: t.id, name: t.name })),
|
||||
status: !row.isActive
|
||||
? 'disabled'
|
||||
: row.passwordHash
|
||||
? 'active'
|
||||
: 'invited',
|
||||
isDemo: row.isDemo,
|
||||
hourlyRate: row.hourlyRate === null ? null : Number(row.hourlyRate),
|
||||
assignedSites: row.assignedSites.map((s) => ({ id: s.id, name: s.name })),
|
||||
};
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user