20 Commits

Author SHA1 Message Date
pr-daaif
3d787a2d39 docs(r3): clôture — journal et état d'avancement pour le tag release/r3
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 02:27:58 +01:00
pr-daaif
460ef4a80e feat(r3.4): corrections de recette (8 écarts) + recherche globale ⌘K
Arbitrage du référent sur la revue pixel : tout corriger, activer la
recherche.

- Stock : filtre fournisseur, sous-seuil en tête, « Entrée de stock »
  depuis la liste ; fiche pièce : fournisseur → lien Tiers.
- Statistiques : période 3/6/12 mois (paramètre months au contrat).
- Tiers : rattachements syndic→site (migration r3_recette_fixes,
  Location.partnerId gardé CLIENT), éditable sur la fiche site, seedé.
- Bibliothèque : filtre « Rattaché à » + glisser-déposer (modale
  préremplie, rattachement toujours requis).
- Recherche globale : GET /search (73 opérations) — familles OT/
  ascenseurs/sites filtrées par la matrice, « voir autre » respecté ;
  topbar ⌘K, debounce, résultats groupés, navigation clavier.

74 tests API (8 nouveaux sur le scoping de la recherche), 14/14
Playwright dont un parcours « recette corrigée », 18/18 contrôles en
navigateur réel, zéro erreur console.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 02:14:43 +01:00
pr-daaif
c46a97ed08 feat(r3.3): aperçu réel des documents + ouverture dans le navigateur
Retour de pré-recette : les fichiers téléversés n'avaient ni aperçu ni
ouverture. Les images affichent leur vraie vignette (blob authentifié →
URL objet révoquée au démontage — un <img src> nu ne porte pas le jeton) ;
« Ouvrir » (bouton + clic vignette) affiche PDF et images dans un nouvel
onglet, ouvert dans le geste utilisateur pour passer les bloqueurs de
pop-up. « Télécharger » inchangé.

Vérifié en navigateur réel : vignette chargée, onglets blob (PDF rendu
dans la visionneuse), download intact, zéro erreur console.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 22:44:22 +01:00
pr-daaif
c5382a5538 feat(r3.3): écrans web de la gestion — stock, achats, coûts OT, documents, statistiques
- Stock : alertes sous seuil (strie ambre), « Préparer le BC » sur la ligne ;
  fiche pièce où les mouvements SONT le stock (entrée / ajustement motivé)
- Bons de commande : liste + panneau (envoi, annulation, réception → entrées
  de stock à PU figé) ; création préremplie depuis l'alerte de seuil
- Fiche OT : carte « Pièces & main-d'œuvre » réelle (consommation à prix figé
  annoncé avant le clic, temps au taux figé) + carte Documents (upload/download) ;
  même carte Documents sur la fiche ascenseur
- Statistiques : 4 KPI, pannes par organe (bilans codés), coûts par mois,
  top équipements ; le tableau de bord allume « Pannes par organe »
- Tiers, bibliothèque (upload rattaché, filtre par type), taux horaire
  éditable dans Personnes ; nav Ressources/Pilotage activée par la matrice
- Recette R3 Playwright (2 parcours autonomes) ; cleanup-e2e étendu aux
  entités R3 ; le formulaire BC attend la fin du refetch (stock périmé en cache)

13/13 e2e verts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 22:16:56 +01:00
pr-daaif
a4e98c2000 fix(ci): MinIO via docker run — bitnami/minio n'existe plus, l'image officielle exige une commande
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 21:25:47 +01:00
pr-daaif
2ffdd13cf4 feat(r3.2): bibliothèque de documents (FileStorage réel) + analytics
- FileStorage : putObject/getObjectStream/removeObject, bucket créé au
  démarrage — MinIO confiné à son implémentation (règle ESLint intacte)
- documents : upload multipart (PDF/JPG/PNG, 20 Mo max, rattachement
  appareil OU OT requis, permission d'édition sur la CIBLE), liste
  filtrable, téléchargement STREAMÉ par l'API (MinIO jamais exposé),
  suppression ; e2e : octets téléchargés identiques aux octets envoyés
- analytics : GET /analytics/summary dérivé du réel — coûts/mois
  (mouvements + main-d'œuvre figés), pannes par organe (bilans codés),
  taux de préventif, durée moyenne de résolution, top équipements
- générateur OpenAPI : query params, multipart, réponse binaire (71 ops)
- CI : service MinIO (bitnami) sur les jobs api et e2e
- test de régression du tri « Interventions récentes » rendu déterministe
  (positions absolues instables sous 12 suites parallèles) ; 58 tests,
  6 runs complets consécutifs verts

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 21:24:04 +01:00
pr-daaif
32eb20b5a0 fix(web): connexion — signaler clairement une API injoignable
Le formulaire email/mot de passe s'affichait seul, sans explication, quand
l'API locale n'était pas démarrée (la liste démo était masquée comme si
DEMO_MODE était absent). Une erreur réseau affiche désormais « L'API ne
répond pas — démarrez-la avec pnpm dev » ; le 404 (pas de mode démo)
reste silencieux, c'est le comportement ADR-002.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 21:11:46 +01:00
pr-daaif
dfdf8f7c18 feat(r3.1): socle backend gestion — stock dérivé, BC, coûts figés sur OT
- migration r3_gestion (7 tables) : Partner, Part (SANS colonne de
  quantité), StockMovement (signé, tracé, PU figé), PurchaseOrder/Line,
  LaborTime (taux figé), Document (R3.2) ; User.hourlyRate administrable
- API (66 opérations) : tiers ; pièces (stock = Σ mouvements, alerte sous
  seuil) ; entrée/ajustement (motif requis, stock jamais négatif, en
  transaction) ; BC Brouillon→Envoyé→Reçu (la réception crée les RECEIPT
  et met à jour lastUnitPrice) ; consommation sur OT (stock suffisant,
  PRIX FIGÉ) ; main-d'œuvre (TAUX FIGÉ, refus si taux non défini) ;
  WorkOrderDetail.costs ; coûts verrouillés après clôture (409)
- seed : tiers/pièces/BC/taux de la maquette — OT-0341 = 505 MAD (testé)
- durcissement : références OT/DEM/BC/P par SÉQUENCES Postgres (nextval)
  — fin des courses « max+1 » (500 sporadiques sous charge parallèle) ;
  3 runs Jest complets consécutifs verts
- 55 tests (92 % stmts / 74,9 % branches) dont la recette officielle :
  consommer sous seuil → BC → réception → réappro, prix/taux figés prouvés

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 20:42:03 +01:00
pr-daaif
d5041c6f05 docs(r3): kickoff design-first — maquettes de la gestion (7 écrans) à valider
- maquette-r3.html (moule validé) : stock DÉRIVÉ des mouvements (alertes
  sous seuil → préparer le BC), fiche pièce (les mouvements sont le stock),
  bons de commande (réception → entrées, PU figé), coûts sur OT (pièces à
  prix figé, main-d'œuvre à taux figé), statistiques (pannes par organe
  depuis les bilans codés, coûts, top équipements — palette CVD), tiers,
  bibliothèque (documents typés rattachés, MinIO via FileStorage)
- 4 décisions soumises (stock jamais saisi, prix/taux figés, réception
  créatrice de mouvements, types de documents fermés)
- rendu vérifié (7 écrans, bi-thème, zéro erreur)
- BLOQUANT : validation du référent avant tout code applicatif R3

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 18:16:00 +01:00
pr-daaif
3aa7189e96 docs: 🏁 R2 close — recettée, déployée en ligne, tag release/r2
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 18:12:04 +01:00
pr-daaif
f7c0da1b0f fix(r2): un OT clôturé disparaissait des « Interventions récentes » des rôles voyant tout
Anomalie de recette (référent) : OT assigné à Ahmed, traité, clôturé —
visible dans son tableau de bord mais pas dans celui de Salma ni de
l'admin. Cause : liste triée par statut (Terminés en queue) alors que le
tableau de bord prend les 5 premières lignes ; les listes scopées
(technicien) sont courtes, celles des rôles « voir autre » non.

- tri par dernière activité (updatedAt desc) : un OT fraîchement clôturé
  remonte en tête pour tous
- urgences « personne bloquée » ACTIVES épinglées au sommet (une urgence
  annulée ne squatte plus la tête — corrigé au passage)
- test de régression dans la recette e2e (position ≤ urgences actives)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 18:01:17 +01:00
pr-daaif
50254daea4 feat(r2.4): portail public QR — signalement sans compte, suivi par jeton
- 3 routes publiques /portal (les seules @Public métier), throttlées
  (@nestjs/throttler : 10 signalements/min, 60 lectures/min) : résolution
  du QR, signalement (retourne un jeton de suivi opaque), suivi par
  référence + jeton — pas de jeton, pas de lecture, jamais de liste
- migration r2_portail : Request.publicToken (unique)
- page /q/{réf} fidèle à l'écran 6 validé R0 (mobile d'abord) : équipement
  prérempli « détecté par le QR », interrupteur personne bloquée,
  suivi sans jargon Reçu → Intervention → Résolu (dérivé de l'OT lié),
  rejet affiché « Sans suite : {motif} », photo annoncée (upload R3) ;
  signalements gardés sur le téléphone (localStorage, 10 max)
- e2e : LA boucle produit — gardien sans compte → traitement → ✓ Résolu ;
  11 tests Playwright verts, 50 tests API, 52 opérations au contrat
- générateur OpenAPI : paramètres non-« id » plus typés uuid

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 17:33:10 +01:00
pr-daaif
6a2dae1710 feat(r2.3): écrans web exploitation — OT, demandes, préventif, compteurs, urgence
- liste OT (filtres, strie rouge, « immédiat ») ; fiche OT : transitions
  pilotées par allowedTransitions, clôture grisée avec la garde expliquée,
  bilan codé (6 selects sur référentiels), checklist Fait→N-A→à faire,
  activité + commentaires, assignation, annulation motivée
- nouvel OT : interrupteur « personne bloquée » qui force la priorité
- demandes : table + panneau d'approbation (priorité, assignation,
  approuver → fiche OT), rejet en modale à motif obligatoire, signalement
  interne ; statut « Résolue » dérivé de l'OT lié
- préventif : tuiles réelles, générer + résumé (« regénérer ne double
  rien »), gabarits administrables ; compteurs : saisie + historique
- tableau de bord réel : bandeau urgence cliquable, KPIs, OT par statut,
  interventions récentes ; accueil dédié aux rôles sans exploitation
- urgence traversante : chip topbar pulsante (60 s), badges de nav
- GET /assets/options (auth seule) : le Demandeur peut désigner l'appareil
  qu'il signale — trou débusqué par l'e2e (49 opérations au contrat)
- génération préventive durcie : collision de référence RETENTÉE (plus de
  saut silencieux), P2003 toléré ; 3 runs Jest complets consécutifs verts
- 9 tests Playwright (recette R2 officielle rejouée intégralement),
  50 tests API (94,6 % / 78,9 %)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 17:13:20 +01:00
pr-daaif
902efa6192 feat(r2.2): préventif — génération mensuelle idempotente — et compteurs
- migration : Asset.underContract + WorkOrder.periodKey avec unicité
  [assetId, periodKey] — l'idempotence de la génération est EN BASE
  (deux appels concurrents ne doublent jamais une grille)
- génération : une grille par appareil sous contrat, périodicités ancrées
  sur la mise en service (mensuelles toujours dues), premier contrôle
  (toutes les tâches) pour un appareil sans historique, échéance fin de
  mois, événement GENERATED ; déclenchement manuel (cron au durcissement)
- gabarits administrables (désactivé = exclu des générations suivantes) ;
  GET /preventive/status (générées, terminées, en retard, premiers contrôles)
- compteurs : GET meters (2 compteurs, récents d'abord) ; relevé
  strictement croissant, refus motivé sinon
- contrat +7 opérations (48) ; 50 tests verts (94,7 % / 78 %) ; smoke test
  prod : juillet généré (9 grilles, 8 premiers contrôles), 2e appel à zéro

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 15:15:57 +01:00
pr-daaif
f7702e4252 feat(r2.1): socle backend exploitation — machine à états, bilan codé, demandes 1-1
- migration r2_exploitation (10 tables) : WorkOrder (référence séquentielle,
  horodatages), WorkOrderEvent, Request (1-1, motif de rejet), ReferenceValue,
  InterventionReport (6 FK), TaskTemplate/ChecklistItem, Meter/MeterReading
- contrat : 15 opérations (41 total) ; la table des transitions et les champs
  requis du bilan vivent dans @siop/shared ; la fiche OT expose
  allowedTransitions + closureBlockers (messages métier)
- API : machine à états stricte ; garde de clôture (bilan 3 champs requis +
  checklist sans tâche en attente) ; approbation → OT lié 1-1 (409 si déjà
  traitée) ; rejet à motif obligatoire ; scoping « voir autre » sur listes et
  accès directs (404 sans fuite) ; validation des valeurs de bilan par champ ;
  « personne bloquée » triée en tête côté API
- seed : 31 valeurs de référentiels, 8 gabarits (parachute réglementaire),
  OT/demandes/compteurs de la maquette — idempotent
- 45 tests verts (95 % stmts / 79 % branches) dont la recette officielle
  rejouée de bout en bout ; smoke test sur build de prod

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 13:56:07 +01:00
pr-daaif
54d926e9f4 docs(r2): kickoff design-first — maquettes des 5 écrans manquants à valider
- maquette-r2.html (moule validé) : demandes + panneau d'approbation
  (approuver → OT 1-1, rejet à motif requis), nouvel OT (interrupteur
  personne bloquée), préventif (gabarits à périodicité, essai parachute
  réglementaire, génération du mois idempotente, premiers contrôles),
  OT préventif (checklist Fait/N-A, clôture bloquée), compteurs (relevés
  croissants)
- les écrans cœur (dashboard, liste OT, fiche OT/bilan codé, portail)
  restent ceux validés en R0
- rendu vérifié (5 écrans, bi-thème, zéro erreur)
- BLOQUANT : validation du référent avant tout code applicatif R2

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 13:33:43 +01:00
pr-daaif
c4d1ab7225 docs: 🏁 R1 close — recettée, déployée en ligne, tag release/r1
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 13:23:52 +01:00
pr-daaif
9778629827 feat(r1.2): écrans web du référentiel — 7 écrans fidèles à maquette-r1
- Sites : liste + carte Leaflet/OSM réelle (pins maquette, positions
  PostGIS), création en modale avec position posée au clic sur la carte
- Fiche site : arbre site→zones, appareils rattachés, ajout de zone
- Ascenseurs : table dense, filtres site/statut, strie rouge à l'arrêt ;
  fiche appareil (organes ± ajout/retrait, statut, QR réel) ; création
  identité → rattachement → organes ; étiquette A6 imprimable (objet
  papier, window.print n'imprime qu'elle)
- Personnes & équipes : invitation → lien d'activation affiché à copier
  (pas d'email en R1, décision explicite), renvoi de lien, équipes ;
  page /activation (choix du mot de passe, connexion directe)
- Catégories : ajout, renommage inline, désactivation
- navigation et actions pilotées par la matrice (usePermissions) —
  l'API re-vérifie chaque requête
- e2e Playwright : recette R1 officielle rejouée intégralement (site →
  zone → appareil+organe → étiquette → invitation → activation) +
  parcours « la matrice pilote l'UI » ; purge idempotente en globalSetup
- alias Vite @siop/shared → source TS (exports nommés CJS ↯ workspace lié)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 13:06:45 +01:00
pr-daaif
266ffaaf1b feat(r1.1): socle backend du référentiel — modèle, contrat, API, seed, tests
- migration r1_referentiel : Category (EQUIPMENT/COMPONENT_TYPE), Location
  (site → zone, lat/lng + colonne PostGIS générée geography(Point,4326)
  + index GIST), Asset (statut d'équipement), AssetComponent (organe sans
  emplacement PAR CONSTRUCTION), Team, invitation sur User ; migration
  autosuffisante (CREATE EXTENSION IF NOT EXISTS postgis)
- contrat : 21 nouvelles opérations (26 total), générateur OpenAPI étendu
  aux paramètres de chemin ; spec + client web régénérés dans ce commit
- API : modules categories/locations/assets/teams + gestion des personnes
  (liste, rôles, invitation lien 7 j à usage unique, activation publique
  qui connecte directement, mise à jour rôle/équipes) — tout sous
  @RequirePermission ; invariants en service (profondeur 2, kinds,
  catégorie jamais supprimée)
- seed : parc de la maquette validée (5 sites + 8 zones, 8 appareils,
  organes A1/B2, 9 catégories, 2 équipes) — idempotent
- 36 tests verts (couverture 96 % stmts / 85 % branches) : recette
  site→zone→appareil→organes, matrice vivante, invitation→activation ;
  smoke test sur build de prod
- CI : postgres → postgis/postgis:18-3.6 (la migration R1 l'exige)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 12:27:48 +01:00
pr-daaif
6d9aafdab4 docs(r1): kickoff design-first — maquettes HD R1 (7 écrans) à valider
- R0 close : tag release/r0 (recette + production en ligne)
- maquette-r1.html dans le moule validé (CSS répliquée, tokens identiques) :
  sites + carte PostGIS, fiche site (hiérarchie d'emplacements), liste
  ascenseurs (statuts d'équipement distincts des statuts OT), création
  (identité → rattachement → organes), étiquette QR imprimable A6,
  personnes & équipes (invitation par lien d'activation), catégories
- rendu vérifié en Chrome headless (7 écrans, bi-thème, zéro erreur)
- BLOQUANT : validation du référent avant tout code applicatif R1

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 12:00:05 +01:00
150 changed files with 31354 additions and 113 deletions

View File

@@ -59,10 +59,10 @@ 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.
# R1+ : la migration r1_referentiel exige PostGIS (colonne générée geography).
# pgvector arrivera en R5 (bascule alors sur limage infra/postgres).
postgres:
image: postgres:18
image: postgis/postgis:18-3.6
env:
POSTGRES_USER: siop
POSTGRES_PASSWORD: siop
@@ -81,8 +81,16 @@ 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 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:
@@ -115,7 +123,7 @@ jobs:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:18
image: postgis/postgis:18-3.6
env:
POSTGRES_USER: siop
POSTGRES_PASSWORD: siop
@@ -134,8 +142,16 @@ 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 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:

View File

@@ -39,6 +39,20 @@ 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.
- 🔄 **R0reste** : 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.
- **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.
- **R1maquettes 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 sitezone + 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), demandeOT 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 syndicsite 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.
- 🔄 **Reprise ici** : redéployer sur Dokploy et vérifier en ligne (migration `r3_recette_fixes` + seed au boot) ouverture R4 Mobile (Expo, design d'abord maquettes avant tout code).
- Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0R5.

View File

@@ -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",

View 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());

View File

@@ -0,0 +1,145 @@
-- Migration autosuffisante : les extensions requises sont créées si absentes
-- (la CI et tout environnement neuf nont 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");

View File

@@ -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;

View File

@@ -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");

View File

@@ -0,0 +1,6 @@
-- AlterTable
ALTER TABLE "Request" ADD COLUMN "publicToken" TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "Request_publicToken_key" ON "Request"("publicToken");

View File

@@ -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;

View File

@@ -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));

View File

@@ -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;

View File

@@ -39,12 +39,481 @@ 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[]
// 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[]
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[]
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())
@@index([assetId])
@@index([workOrderId])
}

View File

@@ -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,610 @@ 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 lOcé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 dentretien',
'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 dhuile 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();
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 linventaire 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();

View 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);
}
}

View 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 {}

View 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,
};
}
}

View File

@@ -1,11 +1,27 @@
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 { 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 +44,25 @@ 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,
// ADR-002 : hors DEMO_MODE, le module n'est pas enregistré → 404
...(demoModeEnabled() ? [DemoAuthModule] : []),
],

View File

@@ -0,0 +1,80 @@
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 { 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. */
@Get('options')
options() {
return this.assetsService.options();
}
@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);
}
}

View 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 {}

View File

@@ -0,0 +1,199 @@
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 { 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) {}
/** Options minimales pour le signalement — ouvert à tout rôle authentifié. */
async options(): Promise<AssetOptionsResponse> {
const rows = await this.prisma.asset.findMany({
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 nest 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 dorgane 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,
};
}
}

View File

@@ -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);
}
}

View File

@@ -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 dactivation 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({

View 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);
}
}

View 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 {}

View 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;
}
}
}

View File

@@ -13,6 +13,7 @@ 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'),
});
export type Env = z.infer<typeof EnvSchema>;

View File

@@ -0,0 +1,77 @@
import {
BadRequestException,
Body,
Controller,
Delete,
Get,
HttpCode,
Param,
ParseUUIDPipe,
Post,
Query,
StreamableFile,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { DOCUMENT_MAX_BYTES } from '@siop/shared';
import {
AuthenticatedUser,
CurrentUser,
} from '../auth/current-user.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)}`,
});
}
@Delete(':id')
@HttpCode(204)
remove(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: AuthenticatedUser) {
return this.documents.remove(id, user);
}
}

View 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 {}

View File

@@ -0,0 +1,160 @@
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(),
};
}
}

View File

@@ -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>;
}

View File

@@ -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);
}
}

View 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);
}
}

View 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 {}

View 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 demplacement',
);
}
}
/** 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,
};
}
}

View 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);
}
}

View 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 {}

View 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');
}
}

View 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);
}
}

View 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 {}

View 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),
};
}
}

View 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);
}
}

View 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 {}

View 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 dinventaire 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(),
};
}
}

View 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);
}
}

View 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 {}

View 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(),
};
}
}

View 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();
}
}

View 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 {}

View 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,
};
}
}

View 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);
}
}

View 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 {}

View 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(),
};
}
}

View 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);
}
}

View 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 {}

View 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,
};
}
}

View 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);
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { WorkOrdersModule } from '../work-orders/work-orders.module';
import { RequestsController } from './requests.controller';
import { RequestsService } from './requests.service';
@Module({
imports: [WorkOrdersModule],
controllers: [RequestsController],
providers: [RequestsService],
})
export class RequestsModule {}

View File

@@ -0,0 +1,165 @@
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 { 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 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');
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(),
};
}
}

View 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);
}
}

View 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 {}

View 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 },
],
};
}
}

View 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);
}
}

View 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 {}

View 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(''),
})),
};
}
}

View File

@@ -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);
}
}

View File

@@ -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 {}

View File

@@ -0,0 +1,143 @@
import {
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' } },
} 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');
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,
...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> {
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,
},
include: userInclude,
});
return this.toDto(updated);
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') {
throw new NotFoundException('Personne inconnue');
}
throw e;
}
}
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),
};
}
}

View File

@@ -0,0 +1,134 @@
import {
Body,
Controller,
Get,
HttpCode,
Param,
ParseUUIDPipe,
Patch,
Post,
Put,
} from '@nestjs/common';
import {
AssigneesUpdateSchema,
ChecklistPatchSchema,
CommentCreateSchema,
ConsumePartSchema,
LaborTimeCreateSchema,
ReportUpsertSchema,
TransitionRequestSchema,
WorkOrderCreateSchema,
type AssigneesUpdate,
type ChecklistPatch,
type CommentCreate,
type ConsumePart,
type LaborTimeCreate,
type ReportUpsert,
type TransitionRequest,
type WorkOrderCreate,
} 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 { WorkOrdersService } from './work-orders.service';
@Controller('work-orders')
export class WorkOrdersController {
constructor(private readonly workOrders: WorkOrdersService) {}
@Get()
@RequirePermission('WORK_ORDERS', 'view')
list(@CurrentUser() user: AuthenticatedUser) {
return this.workOrders.list(user);
}
@Get(':id')
@RequirePermission('WORK_ORDERS', 'view')
get(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: AuthenticatedUser) {
return this.workOrders.get(id, user);
}
@Post()
@RequirePermission('WORK_ORDERS', 'create')
create(
@Body(new ZodValidationPipe(WorkOrderCreateSchema)) body: WorkOrderCreate,
@CurrentUser() user: AuthenticatedUser,
) {
return this.workOrders.create(body, user);
}
@Post(':id/transition')
@HttpCode(200) // le contrat : 200, l'état change — rien n'est « créé »
@RequirePermission('WORK_ORDERS', 'edit')
transition(
@Param('id', ParseUUIDPipe) id: string,
@Body(new ZodValidationPipe(TransitionRequestSchema)) body: TransitionRequest,
@CurrentUser() user: AuthenticatedUser,
) {
return this.workOrders.transition(id, body, user);
}
@Post(':id/comments')
@RequirePermission('WORK_ORDERS', 'edit')
comment(
@Param('id', ParseUUIDPipe) id: string,
@Body(new ZodValidationPipe(CommentCreateSchema)) body: CommentCreate,
@CurrentUser() user: AuthenticatedUser,
) {
return this.workOrders.comment(id, body.message, user);
}
@Put(':id/assignees')
@RequirePermission('WORK_ORDERS', 'edit')
setAssignees(
@Param('id', ParseUUIDPipe) id: string,
@Body(new ZodValidationPipe(AssigneesUpdateSchema)) body: AssigneesUpdate,
@CurrentUser() user: AuthenticatedUser,
) {
return this.workOrders.setAssignees(id, body, user);
}
@Put(':id/report')
@RequirePermission('WORK_ORDERS', 'edit')
upsertReport(
@Param('id', ParseUUIDPipe) id: string,
@Body(new ZodValidationPipe(ReportUpsertSchema)) body: ReportUpsert,
@CurrentUser() user: AuthenticatedUser,
) {
return this.workOrders.upsertReport(id, body, user);
}
@Post(':id/consume-part')
@RequirePermission('WORK_ORDERS', 'edit')
consumePart(
@Param('id', ParseUUIDPipe) id: string,
@Body(new ZodValidationPipe(ConsumePartSchema)) body: ConsumePart,
@CurrentUser() user: AuthenticatedUser,
) {
return this.workOrders.consumePart(id, body, user);
}
@Post(':id/labor')
@RequirePermission('WORK_ORDERS', 'edit')
addLabor(
@Param('id', ParseUUIDPipe) id: string,
@Body(new ZodValidationPipe(LaborTimeCreateSchema)) body: LaborTimeCreate,
@CurrentUser() user: AuthenticatedUser,
) {
return this.workOrders.addLabor(id, body, user);
}
@Patch(':id/checklist/:itemId')
@RequirePermission('WORK_ORDERS', 'edit')
patchChecklist(
@Param('id', ParseUUIDPipe) id: string,
@Param('itemId', ParseUUIDPipe) itemId: string,
@Body(new ZodValidationPipe(ChecklistPatchSchema)) body: ChecklistPatch,
@CurrentUser() user: AuthenticatedUser,
) {
return this.workOrders.patchChecklist(id, itemId, body, user);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { PartsModule } from '../parts/parts.module';
import { WorkOrdersController } from './work-orders.controller';
import { WorkOrdersService } from './work-orders.service';
@Module({
imports: [PartsModule],
controllers: [WorkOrdersController],
providers: [WorkOrdersService],
exports: [WorkOrdersService],
})
export class WorkOrdersModule {}

View File

@@ -0,0 +1,527 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import {
BILAN_FIELD_LABELS,
REQUIRED_BILAN_FIELDS,
WORK_ORDER_STATUS_LABELS,
WORK_ORDER_TRANSITIONS,
type AssigneesUpdate,
type BilanField,
type ChecklistItemDto,
type ChecklistPatch,
type ReportUpsert,
type TransitionRequest,
type WorkOrderCreate,
type WorkOrderDetail,
type WorkOrderStatus,
type WorkOrdersResponse,
type WorkOrderSummary,
} from '@siop/shared';
import type { AuthenticatedUser } from '../auth/current-user.decorator';
import { PartsService } from '../parts/parts.service';
import { PermissionsService } from '../permissions/permissions.service';
import { PrismaService } from '../prisma/prisma.service';
const detailInclude = {
asset: { include: { location: { include: { parent: true } } } },
assignees: true,
createdBy: true,
request: { include: { requestedBy: true } },
events: { include: { by: true }, orderBy: { createdAt: 'desc' as const } },
checklist: { include: { doneBy: true }, orderBy: { label: 'asc' as const } },
// R3 — coûts figés
stockMovements: {
where: { kind: 'CONSUMPTION' as const },
include: { part: true },
orderBy: { createdAt: 'asc' as const },
},
laborTimes: { include: { user: true }, orderBy: { createdAt: 'asc' as const } },
report: {
include: {
doorState: true,
cabinPosition: true,
anomaly: true,
externalCause: true,
actionTaken: true,
componentConcerned: true,
},
},
} satisfies Prisma.WorkOrderInclude;
type DetailRow = Prisma.WorkOrderGetPayload<{ include: typeof detailInclude }>;
const initialsOf = (name: string) =>
name.split(/\s+/).filter(Boolean).slice(0, 2).map((w) => w[0]!.toUpperCase()).join('');
const toPersonne = (u: { id: string; displayName: string }) => ({
id: u.id,
displayName: u.displayName,
initials: initialsOf(u.displayName),
});
/** Champ du bilan → colonne du rapport (validation des référentiels). */
const REPORT_FIELDS: Record<string, BilanField> = {
doorStateId: 'DOOR_STATE',
cabinPositionId: 'CABIN_POSITION',
anomalyId: 'ANOMALY',
externalCauseId: 'EXTERNAL_CAUSE',
actionTakenId: 'ACTION_TAKEN',
componentConcernedId: 'COMPONENT_CONCERNED',
};
@Injectable()
export class WorkOrdersService {
constructor(
private readonly prisma: PrismaService,
private readonly permissions: PermissionsService,
private readonly parts: PartsService,
) {}
/** Invariant « voir autre » : sans le droit, on ne voit que SES OT. */
private async scope(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 },
],
};
}
async list(user: AuthenticatedUser): Promise<WorkOrdersResponse> {
const rows = await this.prisma.workOrder.findMany({
where: await this.scope(user),
include: {
asset: { include: { location: { include: { parent: true } } } },
assignees: true,
},
// Dernière activité d'abord : un OT qui vient d'être clôturé remonte
// (« Interventions récentes » du tableau de bord = tête de liste).
orderBy: { updatedAt: 'desc' },
});
// « personne bloquée » ACTIVE saute en tête, toujours (tri stable)
const urgenceActive = (r: (typeof rows)[number]) =>
r.priority === 'PERSON_TRAPPED' && r.status !== 'DONE' && r.status !== 'CANCELLED';
rows.sort((a, b) => Number(urgenceActive(b)) - Number(urgenceActive(a)));
return { workOrders: rows.map((r) => this.toSummary(r)) };
}
async get(id: string, user: AuthenticatedUser): Promise<WorkOrderDetail> {
const row = await this.prisma.workOrder.findFirst({
where: { AND: [{ id }, await this.scope(user)] },
include: detailInclude,
});
if (!row) throw new NotFoundException('OT inconnu');
return this.toDetail(row);
}
async create(dto: WorkOrderCreate, user: AuthenticatedUser): Promise<WorkOrderDetail> {
const asset = await this.prisma.asset.findUnique({ where: { id: dto.assetId } });
if (!asset) throw new BadRequestException('Équipement inconnu');
const created = await this.createRaw({
...dto,
createdById: user.userId,
eventKind: 'CREATED',
eventMessage: 'OT créé',
});
return this.get(created.id, user);
}
/** Création partagée (OT manuel, approbation de demande, préventif R2.2). */
async createRaw(input: {
title: string;
description?: string;
type: WorkOrderCreate['type'];
priority?: WorkOrderCreate['priority'];
assetId: string;
dueDate?: string;
assigneeIds?: string[];
createdById?: string;
eventKind: string;
eventMessage: string;
}): Promise<{ id: string }> {
for (let essai = 0; ; essai++) {
try {
return await this.prisma.workOrder.create({
data: {
reference: await this.nextReference(),
title: input.title,
description: input.description,
type: input.type,
priority: input.priority ?? 'NONE',
assetId: input.assetId,
dueDate: input.dueDate ? new Date(input.dueDate) : undefined,
createdById: input.createdById,
assignees: input.assigneeIds?.length
? { connect: input.assigneeIds.map((id) => ({ id })) }
: undefined,
events: {
create: {
kind: input.eventKind,
message: input.eventMessage,
byId: input.createdById,
},
},
},
select: { id: true },
});
} catch (e) {
// collision de référence (concurrence) : on retente
if (
e instanceof Prisma.PrismaClientKnownRequestError &&
e.code === 'P2002' &&
essai < 3
) {
continue;
}
throw e;
}
}
}
private async nextReference(): Promise<string> {
// Séquence Postgres : insensible à la concurrence (jamais de collision)
const [{ nextval }] = await this.prisma.$queryRaw<[{ nextval: bigint }]>`
SELECT nextval('work_order_ref_seq')`;
return `OT-${new Date().getFullYear()}-${String(nextval).padStart(4, '0')}`;
}
/** Machine à états stricte + garde de clôture. */
async transition(
id: string,
dto: TransitionRequest,
user: AuthenticatedUser,
): Promise<WorkOrderDetail> {
const detail = await this.get(id, user);
if (!WORK_ORDER_TRANSITIONS[detail.status].includes(dto.to)) {
throw new ConflictException(
`Transition interdite : ${WORK_ORDER_STATUS_LABELS[detail.status]}${WORK_ORDER_STATUS_LABELS[dto.to]}`,
);
}
if (dto.to === 'DONE' && detail.closureBlockers.length > 0) {
throw new ConflictException(
`Clôture bloquée : ${detail.closureBlockers.join(' ; ')}`,
);
}
const horodatage: Prisma.WorkOrderUpdateInput =
dto.to === 'IN_PROGRESS' && !detail.startedAt
? { startedAt: new Date() }
: dto.to === 'DONE'
? { completedAt: new Date() }
: dto.to === 'CANCELLED'
? { cancelledAt: new Date() }
: {};
await this.prisma.workOrder.update({
where: { id },
data: {
status: dto.to,
...horodatage,
events: {
create: {
kind: 'STATUS_CHANGED',
message:
`${WORK_ORDER_STATUS_LABELS[detail.status]}${WORK_ORDER_STATUS_LABELS[dto.to]}` +
(dto.comment ? `${dto.comment}` : ''),
byId: user.userId,
},
},
},
});
return this.get(id, user);
}
async comment(
id: string,
message: string,
user: AuthenticatedUser,
): Promise<WorkOrderDetail> {
await this.get(id, user); // périmètre
await this.prisma.workOrderEvent.create({
data: { workOrderId: id, kind: 'COMMENT', message, byId: user.userId },
});
return this.get(id, user);
}
async setAssignees(
id: string,
dto: AssigneesUpdate,
user: AuthenticatedUser,
): Promise<WorkOrderDetail> {
await this.get(id, user);
const users = await this.prisma.user.findMany({
where: { id: { in: dto.assigneeIds } },
});
if (users.length !== new Set(dto.assigneeIds).size) {
throw new BadRequestException('Personne inconnue dans la liste');
}
await this.prisma.workOrder.update({
where: { id },
data: {
assignees: { set: dto.assigneeIds.map((assigneeId) => ({ id: assigneeId })) },
events: {
create: {
kind: 'ASSIGNED',
message: users.length
? `Assigné(s) : ${users.map((u) => u.displayName).join(', ')}`
: 'Assignation retirée',
byId: user.userId,
},
},
},
});
return this.get(id, user);
}
async upsertReport(
id: string,
dto: ReportUpsert,
user: AuthenticatedUser,
): Promise<WorkOrderDetail> {
await this.get(id, user);
// Chaque valeur fournie doit appartenir au référentiel de SON champ (et être active)
for (const [colonne, field] of Object.entries(REPORT_FIELDS)) {
const valeur = dto[colonne as keyof ReportUpsert];
if (typeof valeur === 'string') {
const ref = await this.prisma.referenceValue.findUnique({ where: { id: valeur } });
if (!ref || ref.field !== field || !ref.isActive) {
throw new BadRequestException(
`Valeur hors référentiel pour « ${BILAN_FIELD_LABELS[field]} »`,
);
}
}
}
const donnees = {
note: dto.note,
doorStateId: dto.doorStateId,
cabinPositionId: dto.cabinPositionId,
anomalyId: dto.anomalyId,
externalCauseId: dto.externalCauseId,
actionTakenId: dto.actionTakenId,
componentConcernedId: dto.componentConcernedId,
};
await this.prisma.interventionReport.upsert({
where: { workOrderId: id },
update: donnees,
create: { workOrderId: id, ...donnees },
});
return this.get(id, user);
}
async patchChecklist(
id: string,
itemId: string,
dto: ChecklistPatch,
user: AuthenticatedUser,
): Promise<ChecklistItemDto> {
await this.get(id, user);
const { count } = await this.prisma.checklistItem.updateMany({
where: { id: itemId, workOrderId: id },
data: {
state: dto.state,
doneById: dto.state === 'PENDING' ? null : user.userId,
doneAt: dto.state === 'PENDING' ? null : new Date(),
},
});
if (count === 0) throw new NotFoundException('Tâche inconnue');
const item = await this.prisma.checklistItem.findUniqueOrThrow({
where: { id: itemId },
include: { doneBy: true },
});
return {
id: item.id,
label: item.label,
state: item.state,
doneBy: item.doneBy ? toPersonne(item.doneBy) : null,
doneAt: item.doneAt?.toISOString() ?? null,
};
}
// ————— R3 : coûts figés —————
/** Consommer une pièce : stock décrémenté, PRIX FIGÉ au moment T. */
async consumePart(
id: string,
dto: { partId: string; quantity: number },
user: AuthenticatedUser,
): Promise<WorkOrderDetail> {
const detail = await this.get(id, user); // périmètre + existence
if (detail.status === 'DONE' || detail.status === 'CANCELLED') {
throw new ConflictException('OT terminé : les coûts sont figés');
}
const { designation, unitPrice } = await this.parts.consume(
dto.partId,
dto.quantity,
id,
user,
);
await this.prisma.workOrderEvent.create({
data: {
workOrderId: id,
kind: 'PART_CONSUMED',
message: `${designation} × ${dto.quantity} (${unitPrice} MAD/u, prix figé)`,
byId: user.userId,
},
});
return this.get(id, user);
}
/** Saisir de la main-d'œuvre : TAUX FIGÉ à la saisie. */
async addLabor(
id: string,
dto: { minutes: number; userId?: string; note?: string },
user: AuthenticatedUser,
): Promise<WorkOrderDetail> {
const detail = await this.get(id, user);
if (detail.status === 'DONE' || detail.status === 'CANCELLED') {
throw new ConflictException('OT terminé : les coûts sont figés');
}
const cibleId = dto.userId ?? user.userId;
const cible = await this.prisma.user.findUnique({ where: { id: cibleId } });
if (!cible) throw new BadRequestException('Personne inconnue');
if (cible.hourlyRate === null) {
throw new ConflictException(
`Aucun taux horaire défini pour ${cible.displayName} — à renseigner dans Personnes`,
);
}
await this.prisma.laborTime.create({
data: {
workOrderId: id,
userId: cibleId,
minutes: dto.minutes,
hourlyRate: cible.hourlyRate, // figé
note: dto.note,
},
});
return this.get(id, user);
}
// ————— mapping —————
private toSummary(
row: Prisma.WorkOrderGetPayload<{
include: {
asset: { include: { location: { include: { parent: true } } } };
assignees: true;
};
}>,
): WorkOrderSummary {
return {
id: row.id,
reference: row.reference,
title: row.title,
type: row.type,
status: row.status,
priority: row.priority,
assetId: row.assetId,
assetReference: row.asset.reference,
siteName: row.asset.location.parent?.name ?? row.asset.location.name,
dueDate: row.dueDate?.toISOString() ?? null,
assignees: row.assignees.map(toPersonne),
createdAt: row.createdAt.toISOString(),
};
}
private toDetail(row: DetailRow): WorkOrderDetail {
const report = row.report;
const blockers: string[] = [];
if (row.checklist.some((c) => c.state === 'PENDING')) {
blockers.push('la checklist est incomplète : traitez chaque tâche (Fait ou N-A)');
}
const manquants = REQUIRED_BILAN_FIELDS.filter((field) => {
const parChamp: Record<BilanField, string | null | undefined> = {
DOOR_STATE: report?.doorStateId,
CABIN_POSITION: report?.cabinPositionId,
ANOMALY: report?.anomalyId,
EXTERNAL_CAUSE: report?.externalCauseId,
ACTION_TAKEN: report?.actionTakenId,
COMPONENT_CONCERNED: report?.componentConcernedId,
};
return !parChamp[field];
});
if (manquants.length) {
blockers.push(
`bilan incomplet : ${manquants.map((f) => `« ${BILAN_FIELD_LABELS[f]} »`).join(', ')}`,
);
}
const versValeur = (v: { id: string; label: string } | null) =>
v ? { id: v.id, label: v.label } : null;
// Coûts figés : chaque ligne porte le prix/taux du moment T
const partsCosts = row.stockMovements.map((m) => ({
id: m.id,
designation: m.part.designation,
quantity: -m.quantity,
unitPrice: Number(m.unitPrice ?? 0),
total: -m.quantity * Number(m.unitPrice ?? 0),
}));
const laborCosts = row.laborTimes.map((l) => ({
id: l.id,
displayName: l.user.displayName,
minutes: l.minutes,
hourlyRate: Number(l.hourlyRate),
total: Math.round((l.minutes / 60) * Number(l.hourlyRate) * 100) / 100,
note: l.note,
}));
return {
...this.toSummary(row),
description: row.description,
locationName: row.asset.location.name,
startedAt: row.startedAt?.toISOString() ?? null,
completedAt: row.completedAt?.toISOString() ?? null,
cancelledAt: row.cancelledAt?.toISOString() ?? null,
createdBy: row.createdBy ? toPersonne(row.createdBy) : null,
request: row.request
? {
id: row.request.id,
reference: row.request.reference,
requesterLabel:
row.request.requestedBy?.displayName ??
row.request.requesterName ??
'Portail',
}
: null,
events: row.events.map((e) => ({
id: e.id,
kind: e.kind,
message: e.message,
by: e.by ? toPersonne(e.by) : null,
createdAt: e.createdAt.toISOString(),
})),
checklist: row.checklist.map((c) => ({
id: c.id,
label: c.label,
state: c.state,
doneBy: c.doneBy ? toPersonne(c.doneBy) : null,
doneAt: c.doneAt?.toISOString() ?? null,
})),
report: report
? {
note: report.note,
doorState: versValeur(report.doorState),
cabinPosition: versValeur(report.cabinPosition),
anomaly: versValeur(report.anomaly),
externalCause: versValeur(report.externalCause),
actionTaken: versValeur(report.actionTaken),
componentConcerned: versValeur(report.componentConcerned),
}
: null,
costs: {
parts: partsCosts,
labor: laborCosts,
total:
Math.round(
(partsCosts.reduce((s, p) => s + p.total, 0) +
laborCosts.reduce((s, l) => s + l.total, 0)) *
100,
) / 100,
},
allowedTransitions: WORK_ORDER_TRANSITIONS[row.status as WorkOrderStatus],
closureBlockers: blockers,
};
}
}

View File

@@ -0,0 +1,221 @@
/**
* E2E R1 — administration du référentiel : équipes, renommages, mises à jour
* et TOUS les refus (conflits 409, inconnus 404, invalides 400) — le contrat
* d'erreur fait partie du contrat.
*/
process.env.DEMO_MODE = 'true';
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client';
import request from 'supertest';
import { seed } from '../prisma/seed';
import { AppModule } from '../src/app.module';
const GHOST = '00000000-0000-4000-8000-000000000000';
describe('Administration du référentiel (e2e)', () => {
let app: INestApplication;
let admin: string;
const prisma = new PrismaClient();
const http = () => request(app.getHttpServer());
const suffix = `adm-${Date.now().toString(36)}`;
beforeAll(async () => {
await seed(prisma);
const moduleRef = await Test.createTestingModule({
imports: [AppModule.forRoot()],
}).compile();
app = moduleRef.createNestApplication();
await app.init();
const { body } = await http().get('/auth/demo-accounts');
const compte = body.accounts.find(
(a: { roleName: string }) => a.roleName === 'Administrateur',
);
admin = (await http().post('/auth/demo-login').send({ userId: compte.id })).body
.accessToken;
});
afterAll(async () => {
await prisma.team.deleteMany({ where: { name: { contains: suffix } } });
await prisma.category.deleteMany({ where: { name: { contains: suffix } } });
await prisma.location.deleteMany({ where: { name: { contains: suffix } } });
await app?.close();
await prisma.$disconnect();
});
const auth = () => ({ Authorization: `Bearer ${admin}` });
it('équipes : création, doublon 409, membres, 404', async () => {
const { body: users } = await http().get('/users').set(auth());
const ahmed = users.users.find(
(u: { email: string }) => u.email === 'technicien@demo.siop.ma',
);
const team = await http()
.post('/teams')
.set(auth())
.send({ name: `Équipe ${suffix}`, description: 'Zone de test' })
.expect(201);
expect(team.body.members).toHaveLength(0);
await http()
.post('/teams')
.set(auth())
.send({ name: `Équipe ${suffix}` })
.expect(409);
const updated = await http()
.patch(`/teams/${team.body.id}`)
.set(auth())
.send({ memberIds: [ahmed.id], description: 'Zone mise à jour' })
.expect(200);
expect(updated.body.members.map((m: { id: string }) => m.id)).toEqual([ahmed.id]);
const { body: teams } = await http().get('/teams').set(auth()).expect(200);
expect(teams.teams.some((t: { id: string }) => t.id === team.body.id)).toBe(true);
await http().patch(`/teams/${GHOST}`).set(auth()).send({ name: 'X' }).expect(404);
await http()
.patch(`/teams/${team.body.id}`)
.set(auth())
.send({ name: 'Casablanca Centre' })
.expect(409);
});
it('catégories : création, doublon 409, renommage, 404', async () => {
const created = await http()
.post('/categories')
.set(auth())
.send({ kind: 'EQUIPMENT', name: `Catégorie ${suffix}` })
.expect(201);
await http()
.post('/categories')
.set(auth())
.send({ kind: 'EQUIPMENT', name: `Catégorie ${suffix}` })
.expect(409);
const renamed = await http()
.patch(`/categories/${created.body.id}`)
.set(auth())
.send({ name: `Catégorie ${suffix} v2` })
.expect(200);
expect(renamed.body.name).toBe(`Catégorie ${suffix} v2`);
await http()
.patch(`/categories/${GHOST}`)
.set(auth())
.send({ name: 'X' })
.expect(404);
});
it('emplacements : renommage, parent inconnu, auto-parent, site avec zones, 404', async () => {
const site = await http()
.post('/locations')
.set(auth())
.send({ name: `Site ${suffix}` })
.expect(201);
const zone = await http()
.post('/locations')
.set(auth())
.send({ name: `Zone ${suffix}`, parentId: site.body.id })
.expect(201);
await http()
.patch(`/locations/${zone.body.id}`)
.set(auth())
.send({ guardianName: 'Gardien Test', guardianPhone: '06 00 00 00 00' })
.expect(200);
await http()
.post('/locations')
.set(auth())
.send({ name: `Orphelin ${suffix}`, parentId: GHOST })
.expect(400);
await http()
.patch(`/locations/${zone.body.id}`)
.set(auth())
.send({ parentId: zone.body.id })
.expect(400);
// Le site a une zone : il ne peut pas devenir zone lui-même
await http()
.patch(`/locations/${site.body.id}`)
.set(auth())
.send({ parentId: zone.body.id })
.expect(400);
await http().patch(`/locations/${GHOST}`).set(auth()).send({ name: 'X' }).expect(404);
});
it('appareils : statut, référence en conflit, emplacement inconnu, 404', async () => {
const { body } = await http().get('/assets').set(auth());
const a1 = body.assets.find((a: { reference: string }) => a.reference === 'A1');
const b1 = body.assets.find((a: { reference: string }) => a.reference === 'B1');
const updated = await http()
.patch(`/assets/${b1.id}`)
.set(auth())
.send({ status: 'UNDER_MAINTENANCE' })
.expect(200);
expect(updated.body.status).toBe('UNDER_MAINTENANCE');
await http()
.patch(`/assets/${b1.id}`)
.set(auth())
.send({ status: 'IN_SERVICE' })
.expect(200);
await http()
.patch(`/assets/${b1.id}`)
.set(auth())
.send({ reference: a1.reference })
.expect(409);
await http()
.patch(`/assets/${b1.id}`)
.set(auth())
.send({ locationId: GHOST })
.expect(400);
await http().get(`/assets/${GHOST}`).set(auth()).expect(404);
await http()
.patch(`/assets/${GHOST}`)
.set(auth())
.send({ brand: 'X' })
.expect(404);
await http()
.post(`/assets/${GHOST}/components`)
.set(auth())
.send({ typeId: GHOST })
.expect(404);
await http()
.delete(`/assets/${b1.id}/components/${GHOST}`)
.set(auth())
.expect(404);
});
it('personnes : mise à jour rôle/équipes, invitation sur rôle inconnu, 404', async () => {
const { body: users } = await http().get('/users').set(auth());
const youssef = users.users.find(
(u: { email: string }) => u.email === 'technicien-limite@demo.siop.ma',
);
const { body: teams } = await http().get('/teams').set(auth());
const mohammedia = teams.teams.find((t: { name: string }) => t.name === 'Mohammedia');
const updated = await http()
.patch(`/users/${youssef.id}`)
.set(auth())
.send({ phone: '06 12 34 56 78', teamIds: [mohammedia.id] })
.expect(200);
expect(updated.body.teams.map((t: { name: string }) => t.name)).toContain('Mohammedia');
// remise en l'état (seed idempotent : les équipes ne sont pas réécrites)
const casa = teams.teams.find((t: { name: string }) => t.name === 'Casablanca Centre');
await http()
.patch(`/users/${youssef.id}`)
.set(auth())
.send({ teamIds: [casa.id] })
.expect(200);
await http().patch(`/users/${GHOST}`).set(auth()).send({ phone: '0' }).expect(404);
await http()
.post('/users/invitations')
.set(auth())
.send({ email: `x-${suffix}@spelev.ma`, displayName: 'X', roleId: GHOST })
.expect(404);
await http().post(`/users/${GHOST}/invitation`).set(auth()).expect(404);
});
});

View File

@@ -0,0 +1,141 @@
/**
* E2E R3.2 — bibliothèque (upload MinIO via FileStorage, download streamé,
* refus typés, permission sur la cible) + analytics (dérivé du réel).
* Nécessite MinIO (infra locale / service CI).
*/
process.env.DEMO_MODE = 'true';
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client';
import request from 'supertest';
import { seed } from '../prisma/seed';
import { AppModule } from '../src/app.module';
const PNG_1PX = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
);
describe('Bibliothèque & analytics (e2e)', () => {
let app: INestApplication;
let nadia: string;
let karim: string; // Demandeur : aucune permission d'édition
const prisma = new PrismaClient();
const http = () => request(app.getHttpServer());
const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
const suffix = `doc-${Date.now().toString(36)}`;
beforeAll(async () => {
await seed(prisma);
const moduleRef = await Test.createTestingModule({
imports: [AppModule.forRoot()],
}).compile();
app = moduleRef.createNestApplication();
await app.init();
const { body } = await http().get('/auth/demo-accounts');
const login = async (roleName: string) => {
const compte = body.accounts.find((a: { roleName: string }) => a.roleName === roleName);
return (await http().post('/auth/demo-login').send({ userId: compte.id })).body
.accessToken as string;
};
nadia = await login('Gestionnaire');
karim = await login('Demandeur');
});
afterAll(async () => {
await prisma.document.deleteMany({ where: { fileName: { contains: suffix } } });
await app?.close();
await prisma.$disconnect();
});
it('upload → liste filtrée → download identique → suppression', async () => {
const { body: assets } = await http().get('/assets').set(auth(nadia));
const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A1');
const envoye = await http()
.post('/documents')
.set(auth(nadia))
.field('kind', 'CERTIFICATE')
.field('assetId', a1.id)
.attach('file', PNG_1PX, { filename: `certificat-${suffix}.png`, contentType: 'image/png' })
.expect(201);
expect(envoye.body.assetReference).toBe('A1');
expect(envoye.body.size).toBe(PNG_1PX.length);
const liste = await http()
.get(`/documents?assetId=${a1.id}&kind=CERTIFICATE`)
.set(auth(nadia))
.expect(200);
expect(
liste.body.documents.some((d: { fileName: string }) => d.fileName.includes(suffix)),
).toBe(true);
const telecharge = await http()
.get(`/documents/${envoye.body.id}/download`)
.set(auth(nadia))
.buffer(true)
.parse((res, cb) => {
const chunks: Buffer[] = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => cb(null, Buffer.concat(chunks)));
})
.expect(200);
expect(Buffer.compare(telecharge.body as Buffer, PNG_1PX)).toBe(0); // octets identiques
await http().delete(`/documents/${envoye.body.id}`).set(auth(nadia)).expect(204);
await http().get(`/documents/${envoye.body.id}/download`).set(auth(nadia)).expect(404);
});
it('refus typés : format, rattachement manquant, cible inconnue, permission', async () => {
const { body: assets } = await http().get('/assets').set(auth(nadia));
const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A1');
await http()
.post('/documents')
.set(auth(nadia))
.field('kind', 'OTHER')
.field('assetId', a1.id)
.attach('file', Buffer.from('binaire'), { filename: `x-${suffix}.exe`, contentType: 'application/octet-stream' })
.expect(400);
await http()
.post('/documents')
.set(auth(nadia))
.field('kind', 'PHOTO')
.attach('file', PNG_1PX, { filename: `orphelin-${suffix}.png`, contentType: 'image/png' })
.expect(400); // aucun rattachement
await http()
.post('/documents')
.set(auth(nadia))
.field('kind', 'PHOTO')
.field('assetId', '00000000-0000-4000-8000-000000000000')
.attach('file', PNG_1PX, { filename: `fantome-${suffix}.png`, contentType: 'image/png' })
.expect(400);
// Karim (Demandeur) : lecture de la bibliothèque OK, édition refusée
await http().get('/documents').set(auth(karim)).expect(200);
await http()
.post('/documents')
.set(auth(karim))
.field('kind', 'PHOTO')
.field('assetId', a1.id)
.attach('file', PNG_1PX, { filename: `interdit-${suffix}.png`, contentType: 'image/png' })
.expect(403);
});
it('analytics : le tableau de la direction est dérivé du réel (bilans, coûts figés)', async () => {
const res = await http().get('/analytics/summary').set(auth(nadia)).expect(200);
const s = res.body;
expect(s.costsByMonth).toHaveLength(12); // période par défaut
// Le seed a consommé 325 MAD de pièces + 180 de MO sur OT-0341 ce mois-ci
expect(s.monthCost).toBeGreaterThanOrEqual(505);
// Pannes par organe : OT-0332 (seed) a un bilan « Guides »
expect(
s.failuresByComponent.some((f: { label: string }) => f.label === 'Guides'),
).toBe(true);
// Top équipements : A1 porte les coûts du seed
expect(s.topAssets.some((t: { reference: string }) => t.reference === 'A1')).toBe(true);
expect(s.closed.total).toBeGreaterThanOrEqual(1);
// Karim (Demandeur, sans ANALYTICS) → 403
await http().get('/analytics/summary').set(auth(karim)).expect(403);
});
});

View File

@@ -0,0 +1,374 @@
/**
* E2E R2 — exploitation : machine à états stricte, garde de clôture
* (bilan + checklist), demande → OT (1-1), scoping « voir autre ».
* Rejoue la recette officielle : demande gardien → approbation → OT →
* intervention → bilan codé → clôture → suivi demandeur.
*/
process.env.DEMO_MODE = 'true';
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client';
import request from 'supertest';
import { seed } from '../prisma/seed';
import { AppModule } from '../src/app.module';
describe('Exploitation (e2e)', () => {
let app: INestApplication;
let salma: string; // Dispatcher — voir autre, approuve
let ahmed: string; // Technicien — SES OT seulement
let karim: string; // Demandeur — SES demandes seulement
let ahmedId: string;
const prisma = new PrismaClient();
const http = () => request(app.getHttpServer());
const suffix = Date.now().toString(36);
beforeAll(async () => {
await seed(prisma);
const moduleRef = await Test.createTestingModule({
imports: [AppModule.forRoot()],
}).compile();
app = moduleRef.createNestApplication();
await app.init();
const { body } = await http().get('/auth/demo-accounts');
const login = async (roleName: string) => {
const compte = body.accounts.find((a: { roleName: string }) => a.roleName === roleName);
const res = await http().post('/auth/demo-login').send({ userId: compte.id });
return res.body.accessToken as string;
};
salma = await login('Dispatcher');
ahmed = await login('Technicien');
karim = await login('Demandeur');
ahmedId = (
await prisma.user.findUniqueOrThrow({ where: { email: 'technicien@demo.siop.ma' } })
).id;
});
afterAll(async () => {
await prisma.request.deleteMany({ where: { description: { contains: suffix } } });
await prisma.workOrder.deleteMany({ where: { title: { contains: suffix } } });
await app?.close();
await prisma.$disconnect();
});
const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
it('recette : demande → approbation → OT assigné → bilan → clôture → suivi', async () => {
// 1. Karim (gardien) signale
const { body: assets } = await http().get('/assets').set(auth(salma));
const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A1');
const demande = await http()
.post('/requests')
.set(auth(karim))
.send({ assetId: a1.id, description: `Porte qui grince (${suffix})` })
.expect(201);
expect(demande.body.status).toBe('RECEIVED');
// 2. Salma approuve → OT créé et lié (1-1)
const ot = await http()
.post(`/requests/${demande.body.id}/approve`)
.set(auth(salma))
.send({ title: `Réparer porte qui grince (${suffix})`, priority: 'MEDIUM', assigneeIds: [ahmedId] })
.expect(201);
expect(ot.body.status).toBe('OPEN');
expect(ot.body.request.reference).toBe(demande.body.reference);
// double approbation → refus
await http()
.post(`/requests/${demande.body.id}/approve`)
.set(auth(salma))
.send({})
.expect(409);
// 3. Ahmed démarre, commente
const id = ot.body.id;
await http()
.post(`/work-orders/${id}/transition`)
.set(auth(ahmed))
.send({ to: 'IN_PROGRESS' })
.expect(200);
await http()
.post(`/work-orders/${id}/comments`)
.set(auth(ahmed))
.send({ message: 'Charnière usée, graissage effectué.' })
.expect(201);
// 4. Clôture SANS bilan → bloquée (garde)
const refus = await http()
.post(`/work-orders/${id}/transition`)
.set(auth(ahmed))
.send({ to: 'DONE' })
.expect(409);
expect(refus.body.message).toContain('bilan incomplet');
// 5. Bilan codé (3 champs requis) puis clôture
const { body: refs } = await http().get('/reference-values').set(auth(ahmed));
const valeur = (field: string, label: string) =>
refs.referenceValues.find(
(v: { field: string; label: string }) => v.field === field && v.label === label,
).id;
await http()
.put(`/work-orders/${id}/report`)
.set(auth(ahmed))
.send({
doorStateId: valeur('DOOR_STATE', 'Fonctionnement normal'),
actionTakenId: valeur('ACTION_TAKEN', 'Nettoyage / graissage'),
componentConcernedId: valeur('COMPONENT_CONCERNED', 'Portes'),
})
.expect(200);
const clos = await http()
.post(`/work-orders/${id}/transition`)
.set(auth(ahmed))
.send({ to: 'DONE', comment: 'RAS après graissage' })
.expect(200);
expect(clos.body.status).toBe('DONE');
expect(clos.body.completedAt).toBeTruthy();
// 6. Karim suit SA demande : l'OT lié est terminé
const { body: mesDemandes } = await http().get('/requests').set(auth(karim)).expect(200);
const laMienne = mesDemandes.requests.find(
(r: { id: string }) => r.id === demande.body.id,
);
expect(laMienne.workOrder.status).toBe('DONE');
// 6 bis. Régression recette R2 : l'OT fraîchement clôturé remonte EN TÊTE
// de la liste de Salma (après les urgences actives) — il doit apparaître
// dans les « Interventions récentes » de tout le monde, pas seulement
// chez l'assigné.
const { body: listeSalma } = await http()
.get('/work-orders')
.set(auth(salma))
.expect(200);
const position = listeSalma.workOrders.findIndex((w: { id: string }) => w.id === id);
expect(position).toBeGreaterThanOrEqual(0);
// Invariant DÉTERMINISTE (les specs parallèles créent des OT plus frais,
// les positions absolues sont donc instables) : notre OT fraîchement
// clôturé précède les OT du seed modifiés avant lui — avec l'ancien tri
// « par statut », tout En cours passait devant tous les Terminés.
const annee = new Date().getFullYear();
const posGrilleSeed = listeSalma.workOrders.findIndex(
(w: { reference: string }) => w.reference === `OT-${annee}-0338`, // En cours, seed
);
const posAncienDone = listeSalma.workOrders.findIndex(
(w: { reference: string }) => w.reference === `OT-${annee}-0332`, // Terminé, seed
);
expect(position).toBeLessThan(posGrilleSeed);
expect(position).toBeLessThan(posAncienDone);
// 7. Terminal : plus aucune transition
await http()
.post(`/work-orders/${id}/transition`)
.set(auth(ahmed))
.send({ to: 'IN_PROGRESS' })
.expect(409);
});
it('machine à états : OPEN → DONE direct interdit ; OPEN → ON_HOLD interdit', async () => {
const { body } = await http().get('/work-orders').set(auth(salma));
const ouvert = body.workOrders.find(
(w: { status: string; priority: string }) =>
w.status === 'OPEN' && w.priority === 'PERSON_TRAPPED',
);
await http()
.post(`/work-orders/${ouvert.id}/transition`)
.set(auth(salma))
.send({ to: 'DONE' })
.expect(409);
await http()
.post(`/work-orders/${ouvert.id}/transition`)
.set(auth(salma))
.send({ to: 'ON_HOLD' })
.expect(409);
});
it('garde de clôture : la checklist incomplète bloque la grille du mois', async () => {
const { body } = await http().get('/work-orders').set(auth(ahmed));
const grille = body.workOrders.find((w: { type: string }) => w.type === 'PREVENTIVE');
const detail = await http().get(`/work-orders/${grille.id}`).set(auth(ahmed)).expect(200);
expect(detail.body.closureBlockers.join(' ')).toContain('checklist');
// Régler toutes les tâches (Fait/N-A) → le blocage checklist disparaît
for (const [i, item] of detail.body.checklist.entries()) {
await http()
.patch(`/work-orders/${grille.id}/checklist/${item.id}`)
.set(auth(ahmed))
.send({ state: i % 2 ? 'NA' : 'DONE' })
.expect(200);
}
const apres = await http().get(`/work-orders/${grille.id}`).set(auth(ahmed)).expect(200);
expect(apres.body.closureBlockers.join(' ')).not.toContain('checklist');
expect(apres.body.checklist.every((c: { doneBy: unknown }) => c.doneBy)).toBe(true);
// remise en état (seed idempotent)
for (const item of apres.body.checklist) {
await http()
.patch(`/work-orders/${grille.id}/checklist/${item.id}`)
.set(auth(ahmed))
.send({ state: 'PENDING' })
.expect(200);
}
});
it('« voir autre » : Ahmed ne voit que SES OT ; Karim que SES demandes', async () => {
const { body: tous } = await http().get('/work-orders').set(auth(salma));
const { body: siens } = await http().get('/work-orders').set(auth(ahmed));
expect(tous.workOrders.length).toBeGreaterThan(siens.workOrders.length);
expect(
siens.workOrders.every((w: { assignees: { id: string }[] }) =>
w.assignees.some((a) => a.id === ahmedId),
),
).toBe(true);
// un OT non assigné à Ahmed lui est invisible (404, pas 403 — pas de fuite)
const autre = tous.workOrders.find(
(w: { assignees: { id: string }[] }) => !w.assignees.some((a) => a.id === ahmedId),
);
await http().get(`/work-orders/${autre.id}`).set(auth(ahmed)).expect(404);
const { body: demandes } = await http().get('/requests').set(auth(karim));
expect(demandes.requests.length).toBeGreaterThan(0);
// Karim (create seulement) ne peut pas approuver
await http()
.post(`/requests/${demandes.requests[0].id}/approve`)
.set(auth(karim))
.send({})
.expect(403);
});
it('rejet : motif obligatoire ; bilan : valeur hors champ refusée', async () => {
const { body: assets } = await http().get('/assets').set(auth(salma));
const c1 = assets.assets.find((a: { reference: string }) => a.reference === 'C1');
const demande = await http()
.post('/requests')
.set(auth(karim))
.send({ assetId: c1.id, description: `À rejeter (${suffix})` })
.expect(201);
await http()
.post(`/requests/${demande.body.id}/reject`)
.set(auth(salma))
.send({ reason: '' })
.expect(400);
const rejetee = await http()
.post(`/requests/${demande.body.id}/reject`)
.set(auth(salma))
.send({ reason: 'Doublon de la demande précédente.' })
.expect(200);
expect(rejetee.body.rejectionReason).toContain('Doublon');
// bilan : une valeur ACTION_TAKEN dans le champ DOOR_STATE → 400
const { body: refs } = await http().get('/reference-values').set(auth(salma));
const action = refs.referenceValues.find((v: { field: string }) => v.field === 'ACTION_TAKEN');
const { body: wos } = await http().get('/work-orders').set(auth(salma));
const enCours = wos.workOrders.find((w: { status: string }) => w.status === 'IN_PROGRESS');
await http()
.put(`/work-orders/${enCours.id}/report`)
.set(auth(salma))
.send({ doorStateId: action.id })
.expect(400);
});
it('« personne bloquée » saute en tête de liste', async () => {
const { body } = await http().get('/work-orders').set(auth(salma));
expect(body.workOrders[0].priority).toBe('PERSON_TRAPPED');
});
it('OT manuel : cycle complet En attente ↔ En cours, puis annulation motivée', async () => {
const { body: assets } = await http().get('/assets').set(auth(salma));
const e2 = assets.assets.find((a: { reference: string }) => a.reference === 'E2');
const ot = await http()
.post('/work-orders')
.set(auth(salma))
.send({
title: `Travaux cabine (${suffix})`,
type: 'WORKS',
priority: 'LOW',
assetId: e2.id,
dueDate: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
assigneeIds: [ahmedId],
})
.expect(201);
const id = ot.body.id;
await http().post(`/work-orders/${id}/transition`).set(auth(salma)).send({ to: 'IN_PROGRESS' }).expect(200);
const attente = await http()
.post(`/work-orders/${id}/transition`)
.set(auth(salma))
.send({ to: 'ON_HOLD', comment: 'Attente de pièce' })
.expect(200);
expect(attente.body.allowedTransitions).toEqual(['IN_PROGRESS', 'CANCELLED']);
await http().post(`/work-orders/${id}/transition`).set(auth(salma)).send({ to: 'IN_PROGRESS' }).expect(200);
const annule = await http()
.post(`/work-orders/${id}/transition`)
.set(auth(salma))
.send({ to: 'CANCELLED', comment: 'Travaux reportés au trimestre prochain' })
.expect(200);
expect(annule.body.cancelledAt).toBeTruthy();
});
it('refus propres : équipement inconnu, personne inconnue, tâche inconnue, OT invisible', async () => {
const GHOST = '00000000-0000-4000-8000-000000000000';
await http()
.post('/work-orders')
.set(auth(salma))
.send({ title: 'X', type: 'CORRECTIVE', assetId: GHOST })
.expect(400);
await http()
.post('/requests')
.set(auth(karim))
.send({ assetId: GHOST, description: 'X' })
.expect(400);
const { body: wos } = await http().get('/work-orders').set(auth(salma));
const un = wos.workOrders[0];
await http()
.put(`/work-orders/${un.id}/assignees`)
.set(auth(salma))
.send({ assigneeIds: [GHOST] })
.expect(400);
await http()
.patch(`/work-orders/${un.id}/checklist/${GHOST}`)
.set(auth(salma))
.send({ state: 'DONE' })
.expect(404);
await http().get(`/work-orders/${GHOST}`).set(auth(salma)).expect(404);
await http()
.post(`/requests/${GHOST}/reject`)
.set(auth(salma))
.send({ reason: 'motif' })
.expect(404);
});
it('référentiels du bilan : ajout (admin), doublon 409, renommage, 404', async () => {
const { body } = await http().get('/auth/demo-accounts');
const adminCompte = body.accounts.find(
(a: { roleName: string }) => a.roleName === 'Administrateur',
);
const admin = (
await http().post('/auth/demo-login').send({ userId: adminCompte.id })
).body.accessToken as string;
const creee = await http()
.post('/reference-values')
.set(auth(admin))
.send({ field: 'ANOMALY', label: `Anomalie test ${suffix}` })
.expect(201);
await http()
.post('/reference-values')
.set(auth(admin))
.send({ field: 'ANOMALY', label: `Anomalie test ${suffix}` })
.expect(409);
await http()
.patch(`/reference-values/${creee.body.id}`)
.set(auth(admin))
.send({ isActive: false })
.expect(200);
await http()
.patch(`/reference-values/00000000-0000-4000-8000-000000000000`)
.set(auth(admin))
.send({ label: 'X' })
.expect(404);
// Salma (Dispatcher, sans SETTINGS.create) → 403
await http()
.post('/reference-values')
.set(auth(salma))
.send({ field: 'ANOMALY', label: 'Interdit' })
.expect(403);
await prisma.referenceValue.delete({ where: { id: creee.body.id } });
});
});

View File

@@ -0,0 +1,351 @@
/**
* E2E R3 — gestion : la recette officielle (consommer sous seuil → BC →
* réception → réappro ; coût complet d'un OT) et les invariants comptables
* (stock dérivé jamais négatif, prix et taux FIGÉS, ajustement motivé).
*/
process.env.DEMO_MODE = 'true';
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client';
import request from 'supertest';
import { seed } from '../prisma/seed';
import { AppModule } from '../src/app.module';
describe('Gestion (e2e)', () => {
let app: INestApplication;
let nadia: string; // Gestionnaire
let salma: string; // Dispatcher
let ahmed: string; // Technicien (PARTS view seulement)
let admin: string;
const prisma = new PrismaClient();
const http = () => request(app.getHttpServer());
const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
const suffix = `G3-${Date.now().toString(36)}`;
beforeAll(async () => {
await seed(prisma);
const moduleRef = await Test.createTestingModule({
imports: [AppModule.forRoot()],
}).compile();
app = moduleRef.createNestApplication();
await app.init();
const { body } = await http().get('/auth/demo-accounts');
const login = async (roleName: string) => {
const compte = body.accounts.find((a: { roleName: string }) => a.roleName === roleName);
return (await http().post('/auth/demo-login').send({ userId: compte.id })).body
.accessToken as string;
};
nadia = await login('Gestionnaire');
salma = await login('Dispatcher');
ahmed = await login('Technicien');
admin = await login('Administrateur');
});
afterAll(async () => {
await prisma.stockMovement.deleteMany({
where: {
OR: [
{ part: { designation: { contains: suffix } } },
{ workOrder: { title: { contains: suffix } } },
],
},
});
await prisma.workOrder.deleteMany({ where: { title: { contains: suffix } } });
await prisma.purchaseOrder.deleteMany({
where: { lines: { some: { part: { designation: { contains: suffix } } } } },
});
await prisma.part.deleteMany({ where: { designation: { contains: suffix } } });
await prisma.partner.deleteMany({ where: { name: { contains: suffix } } });
await app?.close();
await prisma.$disconnect();
});
it('recette R3 : consommer sous seuil → BC → réception → réappro (prix figés)', async () => {
// Pièce dédiée au test : stock 6, seuil 4, PU initial 100
const piece = await http()
.post('/parts')
.set(auth(nadia))
.send({ designation: `Pièce ${suffix}`, threshold: 4, initialPrice: 100 })
.expect(201);
await http()
.post(`/parts/${piece.body.id}/movements`)
.set(auth(nadia))
.send({ kind: 'ENTRY', quantity: 6 })
.expect(201);
// OT + consommation de 3 → stock 3, SOUS le seuil
const { body: assets } = await http().get('/assets').set(auth(salma));
const ot = await http()
.post('/work-orders')
.set(auth(salma))
.send({
title: `Réparation ${suffix}`,
type: 'CORRECTIVE',
assetId: assets.assets[0].id,
})
.expect(201);
const apresConso = await http()
.post(`/work-orders/${ot.body.id}/consume-part`)
.set(auth(salma))
.send({ partId: piece.body.id, quantity: 3 })
.expect(201);
expect(apresConso.body.costs.parts[0]).toMatchObject({
quantity: 3,
unitPrice: 100,
total: 300,
});
const sousSeuil = await http().get(`/parts/${piece.body.id}`).set(auth(nadia));
expect(sousSeuil.body.stock).toBe(3);
expect(sousSeuil.body.belowThreshold).toBe(true);
// BC : brouillon → envoyé → reçu (PU 110) → stock 13, plus d'alerte
const { body: partners } = await http().get('/partners').set(auth(nadia));
const fournisseur = partners.partners.find((p: { kind: string }) => p.kind === 'SUPPLIER');
const bc = await http()
.post('/purchase-orders')
.set(auth(nadia))
.send({
supplierId: fournisseur.id,
lines: [{ partId: piece.body.id, quantity: 10, unitPrice: 110 }],
})
.expect(201);
expect(bc.body.status).toBe('DRAFT');
expect(bc.body.total).toBe(1100);
await http()
.post(`/purchase-orders/${bc.body.id}/transition`)
.set(auth(nadia))
.send({ to: 'SENT' })
.expect(200);
const recu = await http()
.post(`/purchase-orders/${bc.body.id}/transition`)
.set(auth(nadia))
.send({ to: 'RECEIVED' })
.expect(200);
expect(recu.body.receivedAt).toBeTruthy();
const apresReception = await http().get(`/parts/${piece.body.id}`).set(auth(nadia));
expect(apresReception.body.stock).toBe(13);
expect(apresReception.body.belowThreshold).toBe(false);
expect(apresReception.body.lastUnitPrice).toBe(110); // nouveau prix courant
expect(
apresReception.body.movements.some(
(m: { kind: string; purchaseOrderReference: string | null }) =>
m.kind === 'RECEIPT' && m.purchaseOrderReference === bc.body.reference,
),
).toBe(true);
// PRIX FIGÉ : la consommation passée reste à 100, malgré le nouveau PU 110
const otRelu = await http().get(`/work-orders/${ot.body.id}`).set(auth(salma));
expect(otRelu.body.costs.parts[0].unitPrice).toBe(100);
});
it('coût complet dun OT : pièces + main-dœuvre — le TAUX est figé à la saisie', async () => {
const { body: users } = await http().get('/users').set(auth(admin));
const ahmedUser = users.users.find(
(u: { email: string }) => u.email === 'technicien@demo.siop.ma',
);
const tauxInitial = ahmedUser.hourlyRate; // 120 (seed)
const { body: assets } = await http().get('/assets').set(auth(salma));
const ot = await http()
.post('/work-orders')
.set(auth(salma))
.send({ title: `Coût complet ${suffix}`, type: 'CORRECTIVE', assetId: assets.assets[0].id })
.expect(201);
// Salma (sans taux défini) tente de saisir pour elle-même → refus motivé
await http()
.post(`/work-orders/${ot.body.id}/labor`)
.set(auth(salma))
.send({ minutes: 30 })
.expect(409);
// 1 h 30 d'Ahmed au taux courant
const avecMO = await http()
.post(`/work-orders/${ot.body.id}/labor`)
.set(auth(salma))
.send({ minutes: 90, userId: ahmedUser.id })
.expect(201);
expect(avecMO.body.costs.labor[0].hourlyRate).toBe(tauxInitial);
expect(avecMO.body.costs.labor[0].total).toBe(
Math.round((90 / 60) * tauxInitial * 100) / 100,
);
const totalAvant = avecMO.body.costs.total;
// L'admin augmente le taux courant d'Ahmed → le coût de l'OT NE BOUGE PAS
await http()
.patch(`/users/${ahmedUser.id}`)
.set(auth(admin))
.send({ hourlyRate: tauxInitial + 30 })
.expect(200);
const otApres = await http().get(`/work-orders/${ot.body.id}`).set(auth(salma));
expect(otApres.body.costs.total).toBe(totalAvant);
// remise en l'état (le seed ne réécrit pas les taux existants)
await http()
.patch(`/users/${ahmedUser.id}`)
.set(auth(admin))
.send({ hourlyRate: tauxInitial })
.expect(200);
});
it('invariants : motif requis, stock jamais négatif, prix inconnu refusé, coûts figés après clôture', async () => {
const piece = await http()
.post('/parts')
.set(auth(nadia))
.send({ designation: `Sans prix ${suffix}`, threshold: 0 })
.expect(201);
// ajustement sans motif → 400 ; stock négatif → 409
await http()
.post(`/parts/${piece.body.id}/movements`)
.set(auth(nadia))
.send({ kind: 'ADJUSTMENT', quantity: -1 })
.expect(400);
await http()
.post(`/parts/${piece.body.id}/movements`)
.set(auth(nadia))
.send({ kind: 'ADJUSTMENT', quantity: -1, reason: 'test' })
.expect(409);
await http()
.post(`/parts/${piece.body.id}/movements`)
.set(auth(nadia))
.send({ kind: 'ENTRY', quantity: -3 })
.expect(400);
// consommation sans prix connu → 409 ; stock insuffisant → 409
await http()
.post(`/parts/${piece.body.id}/movements`)
.set(auth(nadia))
.send({ kind: 'ENTRY', quantity: 5 })
.expect(201);
const { body: assets } = await http().get('/assets').set(auth(salma));
const ot = await http()
.post('/work-orders')
.set(auth(salma))
.send({ title: `Invariants ${suffix}`, type: 'CORRECTIVE', assetId: assets.assets[0].id })
.expect(201);
await http()
.post(`/work-orders/${ot.body.id}/consume-part`)
.set(auth(salma))
.send({ partId: piece.body.id, quantity: 2 })
.expect(409); // pas de prix connu
// Technicien : PARTS en lecture seule
await http()
.post('/parts')
.set(auth(ahmed))
.send({ designation: `Interdit ${suffix}` })
.expect(403);
// BC : réception d'un brouillon interdite
const { body: partners } = await http().get('/partners').set(auth(nadia));
const fournisseur = partners.partners.find((p: { kind: string }) => p.kind === 'SUPPLIER');
const bc = await http()
.post('/purchase-orders')
.set(auth(nadia))
.send({ supplierId: fournisseur.id, lines: [{ partId: piece.body.id, quantity: 1, unitPrice: 10 }] })
.expect(201);
await http()
.post(`/purchase-orders/${bc.body.id}/transition`)
.set(auth(nadia))
.send({ to: 'RECEIVED' })
.expect(409);
await http()
.post(`/purchase-orders/${bc.body.id}/transition`)
.set(auth(nadia))
.send({ to: 'CANCELLED' })
.expect(200);
});
it('refus propres : doublons, inconnus, transitions interdites, coûts figés après clôture', async () => {
const GHOST = '00000000-0000-4000-8000-000000000000';
// tiers : doublon 409, 404, désactivation
const tiers = await http()
.post('/partners')
.set(auth(nadia))
.send({ name: `Tiers ${suffix}`, kind: 'SUPPLIER' })
.expect(201);
await http()
.post('/partners')
.set(auth(nadia))
.send({ name: `Tiers ${suffix}`, kind: 'SUPPLIER' })
.expect(409);
await http()
.patch(`/partners/${tiers.body.id}`)
.set(auth(nadia))
.send({ isActive: false })
.expect(200);
await http().patch(`/partners/${GHOST}`).set(auth(nadia)).send({ city: 'X' }).expect(404);
// pièce : fournisseur inconnu 400, 404, désactivée non consommable
await http()
.post('/parts')
.set(auth(nadia))
.send({ designation: `Fournisseur fantôme ${suffix}`, supplierId: GHOST })
.expect(400);
await http().patch(`/parts/${GHOST}`).set(auth(nadia)).send({ threshold: 1 }).expect(404);
await http().get(`/parts/${GHOST}`).set(auth(nadia)).expect(404);
const desactivee = await http()
.post('/parts')
.set(auth(nadia))
.send({ designation: `Désactivée ${suffix}`, initialPrice: 10 })
.expect(201);
await http()
.patch(`/parts/${desactivee.body.id}`)
.set(auth(nadia))
.send({ isActive: false })
.expect(200);
const { body: assets } = await http().get('/assets').set(auth(salma));
const ot = await http()
.post('/work-orders')
.set(auth(salma))
.send({ title: `Refus ${suffix}`, type: 'CORRECTIVE', assetId: assets.assets[0].id })
.expect(201);
await http()
.post(`/work-orders/${ot.body.id}/consume-part`)
.set(auth(salma))
.send({ partId: desactivee.body.id, quantity: 1 })
.expect(400);
// BC : fournisseur inactif 400, pièce inconnue 400, 404, transition sur terminal 409
await http()
.post('/purchase-orders')
.set(auth(nadia))
.send({ supplierId: tiers.body.id, lines: [{ partId: desactivee.body.id, quantity: 1, unitPrice: 5 }] })
.expect(400); // désactivé plus haut
await http()
.post('/purchase-orders')
.set(auth(nadia))
.send({ supplierId: GHOST, lines: [{ partId: desactivee.body.id, quantity: 1, unitPrice: 5 }] })
.expect(400);
await http().get(`/purchase-orders/${GHOST}`).set(auth(nadia)).expect(404);
// main-d'œuvre : personne inconnue 400 ; OT annulé → coûts figés 409
await http()
.post(`/work-orders/${ot.body.id}/labor`)
.set(auth(salma))
.send({ minutes: 10, userId: GHOST })
.expect(400);
await http()
.post(`/work-orders/${ot.body.id}/transition`)
.set(auth(salma))
.send({ to: 'CANCELLED', comment: 'test' })
.expect(200);
await http()
.post(`/work-orders/${ot.body.id}/consume-part`)
.set(auth(salma))
.send({ partId: desactivee.body.id, quantity: 1 })
.expect(409);
await http()
.post(`/work-orders/${ot.body.id}/labor`)
.set(auth(salma))
.send({ minutes: 10 })
.expect(409);
});
it('le seed rejoue la carte maquette : OT-0341 coûte 505 MAD', async () => {
const annee = new Date().getFullYear();
const { body } = await http().get('/work-orders').set(auth(salma));
const ot341 = body.workOrders.find(
(w: { reference: string }) => w.reference === `OT-${annee}-0341`,
);
const detail = await http().get(`/work-orders/${ot341.id}`).set(auth(salma)).expect(200);
expect(detail.body.costs.total).toBe(505); // 240 + 85 + 180
expect(detail.body.costs.labor[0].hourlyRate).toBe(120);
});
});

View File

@@ -0,0 +1,128 @@
/**
* E2E R1 — invitation & activation : lien 7 jours, usage unique,
* aucun compte actif avant activation (décision maquettes R1).
*/
process.env.DEMO_MODE = 'true';
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client';
import request from 'supertest';
import { seed } from '../prisma/seed';
import { AppModule } from '../src/app.module';
describe('Invitation → activation (e2e)', () => {
let app: INestApplication;
let admin: string;
const prisma = new PrismaClient();
const http = () => request(app.getHttpServer());
const email = `invite-${Date.now().toString(36)}@spelev.ma`;
beforeAll(async () => {
await seed(prisma);
const moduleRef = await Test.createTestingModule({
imports: [AppModule.forRoot()],
}).compile();
app = moduleRef.createNestApplication();
await app.init();
const { body } = await http().get('/auth/demo-accounts');
const compte = body.accounts.find(
(a: { roleName: string }) => a.roleName === 'Administrateur',
);
admin = (await http().post('/auth/demo-login').send({ userId: compte.id })).body
.accessToken;
});
afterAll(async () => {
await prisma.user.deleteMany({ where: { email } });
await app?.close();
await prisma.$disconnect();
});
it('invite, empêche la connexion avant activation, active, connecte', async () => {
const { body: roles } = await http()
.get('/roles')
.set('Authorization', `Bearer ${admin}`)
.expect(200);
const technicien = roles.roles.find(
(r: { name: string }) => r.name === 'Technicien',
);
// 1. Invitation → compte « invited », lien émis
const invitation = await http()
.post('/users/invitations')
.set('Authorization', `Bearer ${admin}`)
.send({ email, displayName: 'Recrue Test', roleId: technicien.id })
.expect(201);
expect(invitation.body.activationToken).toBeTruthy();
const { body: users } = await http()
.get('/users')
.set('Authorization', `Bearer ${admin}`);
const invited = users.users.find((u: { email: string }) => u.email === email);
expect(invited.status).toBe('invited');
// 2. Pas de connexion possible avant activation
await http()
.post('/auth/login')
.send({ email, password: 'MotDePasse!123' })
.expect(401);
// 3. Renvoi du lien : l'ancien devient invalide (usage unique)
const renvoi = await http()
.post(`/users/${invitation.body.userId}/invitation`)
.set('Authorization', `Bearer ${admin}`)
.expect(201);
await http()
.post('/auth/activate')
.send({ token: invitation.body.activationToken, password: 'MotDePasse!123' })
.expect(400);
// 4. Activation → connecté directement, statut « active »
const activation = await http()
.post('/auth/activate')
.send({ token: renvoi.body.activationToken, password: 'MotDePasse!123' })
.expect(200);
expect(activation.body.user.role.name).toBe('Technicien');
await http()
.get('/users/me')
.set('Authorization', `Bearer ${activation.body.accessToken}`)
.expect(200);
// 5. Le lien est consommé ; la connexion classique fonctionne désormais
await http()
.post('/auth/activate')
.send({ token: renvoi.body.activationToken, password: 'Autre!12345' })
.expect(400);
await http()
.post('/auth/login')
.send({ email, password: 'MotDePasse!123' })
.expect(200);
// 6. Renvoyer un lien sur un compte activé → refus
await http()
.post(`/users/${invitation.body.userId}/invitation`)
.set('Authorization', `Bearer ${admin}`)
.expect(409);
});
it('refuse un email déjà connu (409) et un lien fantaisiste (400)', async () => {
const { body: roles } = await http()
.get('/roles')
.set('Authorization', `Bearer ${admin}`);
await http()
.post('/users/invitations')
.set('Authorization', `Bearer ${admin}`)
.send({
email: 'dispatcher@demo.siop.ma',
displayName: 'Doublon',
roleId: roles.roles[0].id,
})
.expect(409);
await http()
.post('/auth/activate')
.send({ token: 'jeton-inexistant-123', password: 'MotDePasse!123' })
.expect(400);
});
});

View File

@@ -0,0 +1,208 @@
/**
* E2E R2.2 — préventif : génération IDEMPOTENTE (l'unicité est en base),
* premier contrôle (tout), périodicités ancrées sur la mise en service ;
* compteurs strictement croissants.
* Les générations de test utilisent des mois lointains (2031) puis sont purgées.
*/
process.env.DEMO_MODE = 'true';
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client';
import request from 'supertest';
import { seed } from '../prisma/seed';
import { AppModule } from '../src/app.module';
describe('Préventif & compteurs (e2e)', () => {
let app: INestApplication;
let admin: string;
let ahmed: string;
const prisma = new PrismaClient();
const http = () => request(app.getHttpServer());
const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
beforeAll(async () => {
await seed(prisma);
await prisma.workOrder.deleteMany({ where: { periodKey: { startsWith: '2031-' } } });
const moduleRef = await Test.createTestingModule({
imports: [AppModule.forRoot()],
}).compile();
app = moduleRef.createNestApplication();
await app.init();
const { body } = await http().get('/auth/demo-accounts');
const login = async (roleName: string) => {
const compte = body.accounts.find((a: { roleName: string }) => a.roleName === roleName);
return (await http().post('/auth/demo-login').send({ userId: compte.id })).body
.accessToken as string;
};
admin = await login('Administrateur');
ahmed = await login('Technicien');
});
afterAll(async () => {
await prisma.workOrder.deleteMany({ where: { periodKey: { startsWith: '2031-' } } });
await prisma.taskTemplate.deleteMany({ where: { label: { contains: 'TEST-' } } });
await prisma.meterReading.deleteMany({ where: { value: { gte: 90000000 } } });
await app?.close();
await prisma.$disconnect();
});
// Les assertions portent sur les 8 appareils du SEED : d'autres specs Jest
// créent/détruisent des appareils en parallèle sur la même base.
const SEED_REFS = ['A1', 'A2', 'B1', 'B2', 'C1', 'D1', 'E2', 'M1'];
const grillesSeed = (period: string) =>
prisma.workOrder.count({
where: { periodKey: period, asset: { reference: { in: SEED_REFS } } },
});
it('génère une grille par appareil sous contrat — et regénérer ne double RIEN', async () => {
const premiere = await http()
.post('/preventive/generate')
.set(auth(admin))
.send({ month: '2031-01' })
.expect(200);
expect(premiere.body.created).toBeGreaterThanOrEqual(SEED_REFS.length);
expect(premiere.body.references.length).toBe(premiere.body.created);
expect(await grillesSeed('2031-01')).toBe(SEED_REFS.length);
const seconde = await http()
.post('/preventive/generate')
.set(auth(admin))
.send({ month: '2031-01' })
.expect(200);
expect(seconde.body.skipped).toBeGreaterThanOrEqual(SEED_REFS.length);
expect(await grillesSeed('2031-01')).toBe(SEED_REFS.length); // toujours 8, pas 16
});
it('premier contrôle (toutes les tâches) pour un appareil sans historique ; grille normale sinon', async () => {
const templatesActifs = await prisma.taskTemplate.count({ where: { isActive: true } });
const mensuelles = await prisma.taskTemplate.count({
where: { isActive: true, periodMonths: 1 },
});
// Un appareil VIERGE créé par ce test (aucune génération n'a pu le toucher)
const categorie = await prisma.category.findFirstOrThrow({ where: { kind: 'EQUIPMENT' } });
const site = await prisma.location.findFirstOrThrow({ where: { parentId: null } });
const vierge = await prisma.asset.create({
data: {
reference: `PVT-${Date.now().toString(36)}`,
brand: 'Test',
categoryId: categorie.id,
locationId: site.id,
},
});
try {
await http()
.post('/preventive/generate')
.set(auth(admin))
.send({ month: '2031-09' })
.expect(200);
const grille = await prisma.workOrder.findUniqueOrThrow({
where: { assetId_periodKey: { assetId: vierge.id, periodKey: '2031-09' } },
include: { _count: { select: { checklist: true } } },
});
expect(grille.title).toContain('Premier contrôle');
expect(grille._count.checklist).toBe(templatesActifs);
} finally {
await prisma.workOrder.deleteMany({ where: { assetId: vierge.id } });
await prisma.asset.delete({ where: { id: vierge.id } });
}
// A1 a toujours un historique préventif (seed) → grille normale.
// Mise en service mars 2020 : janvier 2031 (écart 130 mois) n'est le mois
// ni du 3, ni du 6, ni du 12 → seules les tâches MENSUELLES sont dues.
const a1 = await prisma.asset.findUniqueOrThrow({ where: { reference: 'A1' } });
const grilleA1 = await prisma.workOrder.findUniqueOrThrow({
where: { assetId_periodKey: { assetId: a1.id, periodKey: '2031-01' } },
include: { _count: { select: { checklist: true } } },
});
expect(grilleA1.title).toContain('Grille du mois');
expect(grilleA1._count.checklist).toBe(mensuelles);
});
it('mois anniversaire de mise en service : les périodicités longues tombent (mars 2031 pour A1)', async () => {
await http()
.post('/preventive/generate')
.set(auth(admin))
.send({ month: '2031-03' })
.expect(200);
// écart mars 2020 → mars 2031 = 132 mois : divisible par 3, 6 et 12
const templatesActifs = await prisma.taskTemplate.count({ where: { isActive: true } });
const a1 = await prisma.asset.findUniqueOrThrow({ where: { reference: 'A1' } });
const grille = await prisma.workOrder.findUniqueOrThrow({
where: { assetId_periodKey: { assetId: a1.id, periodKey: '2031-03' } },
include: { checklist: true },
});
expect(grille.checklist.length).toBe(templatesActifs);
expect(grille.checklist.some((c) => c.label === 'Essai du parachute')).toBe(true);
});
it('gabarits : ajout, doublon 409, désactivation (exclu des générations suivantes), 404', async () => {
const cree = await http()
.post('/preventive/templates')
.set(auth(admin))
.send({ label: 'TEST-Contrôle éclairage gaine', periodMonths: 1 })
.expect(201);
await http()
.post('/preventive/templates')
.set(auth(admin))
.send({ label: 'TEST-Contrôle éclairage gaine', periodMonths: 1 })
.expect(409);
await http()
.patch(`/preventive/templates/${cree.body.id}`)
.set(auth(admin))
.send({ isActive: false })
.expect(200);
await http()
.patch('/preventive/templates/00000000-0000-4000-8000-000000000000')
.set(auth(admin))
.send({ label: 'X' })
.expect(404);
// désactivé → absent de la génération d'un nouveau mois
await http()
.post('/preventive/generate')
.set(auth(admin))
.send({ month: '2031-05' })
.expect(200);
const labels = await prisma.checklistItem.findMany({
where: { workOrder: { periodKey: '2031-05' } },
select: { label: true },
});
expect(labels.some((l) => l.label.startsWith('TEST-'))).toBe(false);
// le statut du mois courant répond (structure)
const status = await http().get('/preventive/status').set(auth(ahmed)).expect(200);
expect(status.body).toHaveProperty('generated');
expect(status.body).toHaveProperty('late');
});
it('compteurs : relevé croissant accepté, régression refusée, appareil inconnu 404', async () => {
const { body: assets } = await http().get('/assets').set(auth(ahmed));
const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A1');
const liste = await http().get(`/assets/${a1.id}/meters`).set(auth(ahmed)).expect(200);
expect(liste.body.meters).toHaveLength(2); // les 2 compteurs, même vides
const heures = liste.body.meters.find((m: { kind: string }) => m.kind === 'RUNNING_HOURS');
const dernier = heures.readings[0]?.value ?? 0;
const apres = await http()
.post(`/assets/${a1.id}/meter-readings`)
.set(auth(ahmed))
.send({ kind: 'RUNNING_HOURS', value: 90000000 + dernier })
.expect(201);
const majHeures = apres.body.meters.find(
(m: { kind: string }) => m.kind === 'RUNNING_HOURS',
);
expect(majHeures.readings[0].value).toBe(90000000 + dernier);
expect(majHeures.readings[0].readBy.displayName).toBe('Ahmed Benali');
await http()
.post(`/assets/${a1.id}/meter-readings`)
.set(auth(ahmed))
.send({ kind: 'RUNNING_HOURS', value: dernier })
.expect(400);
await http()
.get('/assets/00000000-0000-4000-8000-000000000000/meters')
.set(auth(ahmed))
.expect(404);
});
});

View File

@@ -0,0 +1,141 @@
/**
* E2E R3 (recette) — recherche globale : chaque famille de résultats est
* filtrée par la matrice, les OT respectent « voir autre » ; au passage,
* rattachements Tiers (siteNames) et période analytics paramétrable.
*/
process.env.DEMO_MODE = 'true';
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client';
import request from 'supertest';
import { seed } from '../prisma/seed';
import { AppModule } from '../src/app.module';
describe('Recherche globale & corrections de recette R3 (e2e)', () => {
let app: INestApplication;
let yasmine: string; // Administrateur
let salma: string; // Dispatcher (voir autre)
let youssef: string; // Technicien limité : SES OT, ASSETS view, pas de LOCATIONS
let karim: string; // Demandeur : aucune des trois familles
let otId: string;
const prisma = new PrismaClient();
const http = () => request(app.getHttpServer());
const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
const suffix = `rech-${Date.now().toString(36)}`;
beforeAll(async () => {
await seed(prisma);
const moduleRef = await Test.createTestingModule({
imports: [AppModule.forRoot()],
}).compile();
app = moduleRef.createNestApplication();
await app.init();
const { body } = await http().get('/auth/demo-accounts');
const login = async (roleName: string) => {
const compte = body.accounts.find((a: { roleName: string }) => a.roleName === roleName);
return (await http().post('/auth/demo-login').send({ userId: compte.id })).body
.accessToken as string;
};
yasmine = await login('Administrateur');
salma = await login('Dispatcher');
youssef = await login('Technicien limité');
karim = await login('Demandeur');
// Un OT au titre unique, assigné à Ahmed (PAS à Youssef).
const { body: assets } = await http().get('/assets').set(auth(salma));
const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A1');
const ahmed = body.accounts.find((a: { roleName: string }) => a.roleName === 'Technicien');
const cree = await http()
.post('/work-orders')
.set(auth(salma))
.send({
title: `Recherche E2E ${suffix}`,
type: 'CORRECTIVE',
priority: 'LOW',
assetId: a1.id,
assigneeIds: [ahmed.id],
})
.expect(201);
otId = cree.body.id;
});
afterAll(async () => {
await prisma.workOrder.deleteMany({ where: { title: { contains: suffix } } });
await app?.close();
await prisma.$disconnect();
});
it('dispatcher (voir autre) : OT par titre, appareil par référence, site insensible à la casse', async () => {
const parTitre = await http().get(`/search?q=${suffix}`).set(auth(salma)).expect(200);
expect(parTitre.body.workOrders.map((w: { id: string }) => w.id)).toContain(otId);
const parRef = await http().get('/search?q=A1').set(auth(salma)).expect(200);
expect(parRef.body.assets.some((a: { reference: string }) => a.reference === 'A1')).toBe(true);
expect(parRef.body.assets[0].siteName).toBeTruthy();
const parSite = await http().get('/search?q=atlas').set(auth(salma)).expect(200);
expect(parSite.body.sites.some((s: { name: string }) => s.name === 'Tour Atlas')).toBe(true);
});
it('technicien limité : ne trouve pas lOT dun autre, ni les sites ; le parc oui', async () => {
const res = await http().get(`/search?q=${suffix}`).set(auth(youssef)).expect(200);
expect(res.body.workOrders).toHaveLength(0); // pas « voir autre »
const parc = await http().get('/search?q=A1').set(auth(youssef)).expect(200);
expect(parc.body.assets.length).toBeGreaterThan(0); // ASSETS view
const sites = await http().get('/search?q=atlas').set(auth(youssef)).expect(200);
expect(sites.body.sites).toHaveLength(0); // pas de LOCATIONS view
});
it('demandeur : 200 mais aucune famille (aucune permission view)', async () => {
const res = await http().get('/search?q=atlas').set(auth(karim)).expect(200);
expect(res.body).toEqual({ workOrders: [], assets: [], sites: [] });
});
it('moins de 2 caractères : réponse vide, pas de requête inutile', async () => {
const res = await http().get('/search?q=a').set(auth(salma)).expect(200);
expect(res.body).toEqual({ workOrders: [], assets: [], sites: [] });
});
it('sans jeton : 401 (API fermée par défaut)', async () => {
await http().get('/search?q=atlas').expect(401);
});
it('tiers : les syndics portent leurs sites (« Rattachements » de la maquette)', async () => {
const { body } = await http().get('/partners').set(auth(yasmine)).expect(200);
const atlas = body.partners.find(
(p: { name: string }) => p.name === 'Atlas Property Management',
);
expect(atlas.siteNames).toEqual(['Tour Atlas']);
const fournisseur = body.partners.find((p: { name: string }) => p.name === 'Lubmaroc');
expect(fournisseur.siteNames).toEqual([]);
});
it('analytics : période 3/6/12 mois, 12 par défaut, valeur inconnue repliée sur 12', async () => {
const trois = await http().get('/analytics/summary?months=3').set(auth(yasmine)).expect(200);
expect(trois.body.months).toBe(3);
expect(trois.body.costsByMonth).toHaveLength(3);
const defaut = await http().get('/analytics/summary').set(auth(yasmine)).expect(200);
expect(defaut.body.months).toBe(12);
expect(defaut.body.costsByMonth).toHaveLength(12);
const inconnu = await http().get('/analytics/summary?months=7').set(auth(yasmine)).expect(200);
expect(inconnu.body.months).toBe(12);
});
it('sites : le rattachement naccepte quun tiers Client / syndic', async () => {
const { body: partners } = await http().get('/partners').set(auth(yasmine));
const fournisseur = partners.partners.find((p: { kind: string }) => p.kind === 'SUPPLIER');
const { body: locations } = await http().get('/locations').set(auth(yasmine));
const site = locations.locations.find(
(l: { parentId: string | null; name: string }) => !l.parentId && l.name === 'Anfa Place',
);
await http()
.patch(`/locations/${site.id}`)
.set(auth(yasmine))
.send({ partnerId: fournisseur.id })
.expect(400);
});
});

View File

@@ -0,0 +1,185 @@
/**
* E2E R1 — référentiel : parcours de recette (site → zone → appareil → organes)
* + invariants (profondeur 2, types d'organes, matrice de permissions vivante).
*/
process.env.DEMO_MODE = 'true';
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client';
import request from 'supertest';
import { seed } from '../prisma/seed';
import { AppModule } from '../src/app.module';
describe('Référentiel (e2e)', () => {
let app: INestApplication;
let admin: string; // jetons
let technicien: string;
const prisma = new PrismaClient();
const http = () => request(app.getHttpServer());
const suffix = Date.now().toString(36); // données de test uniques et repérables
beforeAll(async () => {
await seed(prisma);
const moduleRef = await Test.createTestingModule({
imports: [AppModule.forRoot()],
}).compile();
app = moduleRef.createNestApplication();
await app.init();
const { body } = await http().get('/auth/demo-accounts');
const login = async (roleName: string) => {
const compte = body.accounts.find((a: { roleName: string }) => a.roleName === roleName);
const res = await http().post('/auth/demo-login').send({ userId: compte.id });
return res.body.accessToken as string;
};
admin = await login('Administrateur');
technicien = await login('Technicien');
});
afterAll(async () => {
await prisma.asset.deleteMany({ where: { reference: { contains: suffix } } });
await prisma.location.deleteMany({ where: { name: { contains: suffix } } });
await app?.close();
await prisma.$disconnect();
});
it('parcours de recette : site → zone → appareil avec organes', async () => {
const site = await http()
.post('/locations')
.set('Authorization', `Bearer ${admin}`)
.send({
name: `Site Recette ${suffix}`,
city: 'Casablanca',
latitude: 33.59,
longitude: -7.61,
})
.expect(201);
expect(site.body.parentId).toBeNull();
const zone = await http()
.post('/locations')
.set('Authorization', `Bearer ${admin}`)
.send({ name: `Hall Recette ${suffix}`, parentId: site.body.id })
.expect(201);
const { body: cats } = await http()
.get('/categories')
.set('Authorization', `Bearer ${admin}`);
const equipement = cats.categories.find(
(c: { kind: string }) => c.kind === 'EQUIPMENT',
);
const typeOrgane = cats.categories.find(
(c: { kind: string; isActive: boolean }) => c.kind === 'COMPONENT_TYPE' && c.isActive,
);
const asset = await http()
.post('/assets')
.set('Authorization', `Bearer ${admin}`)
.send({
reference: `T-${suffix}`,
brand: 'Otis',
model: 'Gen2',
categoryId: equipement.id,
locationId: zone.body.id,
components: [{ typeId: typeOrgane.id, designation: 'Organe de test' }],
})
.expect(201);
expect(asset.body.components).toHaveLength(1);
expect(asset.body.siteName).toBe(`Site Recette ${suffix}`);
// Ajout puis retrait d'un organe
const organe = await http()
.post(`/assets/${asset.body.id}/components`)
.set('Authorization', `Bearer ${admin}`)
.send({ typeId: typeOrgane.id })
.expect(201);
await http()
.delete(`/assets/${asset.body.id}/components/${organe.body.id}`)
.set('Authorization', `Bearer ${admin}`)
.expect(204);
});
it('refuse une hiérarchie de profondeur 3 (site → zone → ?)', async () => {
const site = await http()
.post('/locations')
.set('Authorization', `Bearer ${admin}`)
.send({ name: `Site Profond ${suffix}` })
.expect(201);
const zone = await http()
.post('/locations')
.set('Authorization', `Bearer ${admin}`)
.send({ name: `Zone Profonde ${suffix}`, parentId: site.body.id })
.expect(201);
await http()
.post('/locations')
.set('Authorization', `Bearer ${admin}`)
.send({ name: `Sous-zone ${suffix}`, parentId: zone.body.id })
.expect(400);
});
it('refuse un appareil dont la catégorie nest pas un équipement', async () => {
const { body: cats } = await http()
.get('/categories')
.set('Authorization', `Bearer ${admin}`);
const typeOrgane = cats.categories.find((c: { kind: string }) => c.kind === 'COMPONENT_TYPE');
const { body: locs } = await http()
.get('/locations')
.set('Authorization', `Bearer ${admin}`);
await http()
.post('/assets')
.set('Authorization', `Bearer ${admin}`)
.send({
reference: `KO-${suffix}`,
brand: 'Test',
categoryId: typeOrgane.id, // kind COMPONENT_TYPE → refus
locationId: locs.locations[0].id,
})
.expect(400);
});
it('la matrice vit : un Technicien lit le parc mais ne crée rien', async () => {
await http().get('/assets').set('Authorization', `Bearer ${technicien}`).expect(200);
await http().get('/locations').set('Authorization', `Bearer ${technicien}`).expect(200);
await http()
.post('/locations')
.set('Authorization', `Bearer ${technicien}`)
.send({ name: `Interdit ${suffix}` })
.expect(403);
await http().get('/users').set('Authorization', `Bearer ${technicien}`).expect(403);
});
it('le seed de la maquette est en place (A1 et ses organes, statuts)', async () => {
const { body } = await http()
.get('/assets')
.set('Authorization', `Bearer ${admin}`)
.expect(200);
const a1 = body.assets.find((a: { reference: string }) => a.reference === 'A1');
expect(a1.siteName).toBe('Résidence Al Manar');
expect(a1.componentCount).toBe(4);
const b2 = body.assets.find((a: { reference: string }) => a.reference === 'B2');
expect(b2.status).toBe('OUT_OF_SERVICE');
});
it('catégorie utilisée : désactivable, jamais supprimable (la route nexiste pas)', async () => {
const { body: cats } = await http()
.get('/categories')
.set('Authorization', `Bearer ${admin}`);
const used = cats.categories.find((c: { usageCount: number }) => c.usageCount > 0);
await http()
.delete(`/categories/${used.id}`)
.set('Authorization', `Bearer ${admin}`)
.expect(404); // pas de DELETE dans le contrat
const off = await http()
.patch(`/categories/${used.id}`)
.set('Authorization', `Bearer ${admin}`)
.send({ isActive: false })
.expect(200);
expect(off.body.isActive).toBe(false);
await http()
.patch(`/categories/${used.id}`)
.set('Authorization', `Bearer ${admin}`)
.send({ isActive: true })
.expect(200);
});
});

View File

@@ -1,11 +1,14 @@
import { execSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
/** Seed idempotent avant le parcours : mêmes comptes en local et en CI. */
/** Seed idempotent + purge des restes du run précédent : mêmes données
* en local et en CI. */
export default function globalSetup() {
execSync('pnpm --filter @siop/api seed', {
cwd: fileURLToPath(new URL('../../..', import.meta.url)),
const cwd = fileURLToPath(new URL('../../..', import.meta.url));
execSync('pnpm --filter @siop/api exec tsx prisma/cleanup-e2e.ts', {
cwd,
stdio: 'inherit',
env: process.env,
});
execSync('pnpm --filter @siop/api seed', { cwd, stdio: 'inherit', env: process.env });
}

View File

@@ -0,0 +1,77 @@
import { expect, test } from '@playwright/test';
/**
* R2.4 — LA boucle produit : le gardien scanne le QR (aucun compte), signale,
* l'équipe traite (via API), le gardien voit « Résolu » sur son téléphone.
*/
const suffix = Date.now().toString(36);
const API = 'http://localhost:3000';
test('portail QR : signalement sans compte → traitement → suivi Résolu', async ({
page,
request,
}) => {
// 1 · Le gardien scanne le QR de l'ascenseur A1 → formulaire prérempli
await page.goto('/q/A1');
await expect(page.getByText(/Ascenseur A1/)).toBeVisible();
await expect(page.getByText('détecté par le QR')).toBeVisible();
await page.getByLabel('Décrivez le problème').fill(`E2E ${suffix} — voyant cabine éteint`);
await page.getByRole('button', { name: 'Envoyer le signalement' }).click();
await expect(page.getByText('l\'équipe SPELEV est prévenue')).toBeVisible();
// « Mes signalements » : Reçu, sans jargon
const ligne = page.locator('.item-liste', { hasText: suffix });
await expect(ligne).toBeVisible();
await expect(ligne.locator('.etape.fait')).toHaveText('✓ Reçu');
// 2 · Côté SPELEV (via API — le parcours UI est couvert par parcours-r2) :
// approbation → démarrage → bilan → clôture
const comptes = await (await request.get(`${API}/auth/demo-accounts`)).json();
const salmaId = comptes.accounts.find((a: { roleName: string }) => a.roleName === 'Dispatcher').id;
const jeton = (await (await request.post(`${API}/auth/demo-login`, { data: { userId: salmaId } })).json()).accessToken;
const auth = { Authorization: `Bearer ${jeton}` };
const demandes = await (await request.get(`${API}/requests`, { headers: auth })).json();
const demande = demandes.requests.find((r: { description: string }) => r.description.includes(suffix));
expect(demande).toBeTruthy();
const ot = await (
await request.post(`${API}/requests/${demande.id}/approve`, { headers: auth, data: {} })
).json();
await request.post(`${API}/work-orders/${ot.id}/transition`, {
headers: auth,
data: { to: 'IN_PROGRESS' },
});
// 3 · Le gardien recharge : « Intervention » franchie
await page.reload();
await expect(page.locator('.item-liste', { hasText: suffix }).getByText('✓ Intervention')).toBeVisible();
// 4 · Clôture avec bilan → « Résolu »
const refs = await (await request.get(`${API}/reference-values`, { headers: auth })).json();
const valeur = (field: string) =>
refs.referenceValues.find((v: { field: string; isActive: boolean }) => v.field === field && v.isActive).id;
await request.put(`${API}/work-orders/${ot.id}/report`, {
headers: auth,
data: {
doorStateId: valeur('DOOR_STATE'),
actionTakenId: valeur('ACTION_TAKEN'),
componentConcernedId: valeur('COMPONENT_CONCERNED'),
},
});
const cloture = await request.post(`${API}/work-orders/${ot.id}/transition`, {
headers: auth,
data: { to: 'DONE' },
});
expect(cloture.ok()).toBe(true);
await page.reload();
await expect(page.locator('.item-liste', { hasText: suffix }).getByText('✓ Résolu')).toBeVisible();
});
test('QR inconnu : message clair, pas décran technique', async ({ page }) => {
await page.goto('/q/INTROUVABLE-99');
await expect(page.getByText(/aucun appareil connu/)).toBeVisible();
});

View File

@@ -0,0 +1,96 @@
import { expect, test } from '@playwright/test';
/**
* Recette R1 (plan de releases) : créer un site → un ascenseur → ses organes
* → imprimer l'étiquette QR → inviter un technicien (activation incluse).
*/
const suffix = Date.now().toString(36);
test('recette R1 complète, de la création du site à lactivation de linvité', async ({
page,
}) => {
// Connexion démo (Administrateur — création tous azimuts)
await page.goto('/connexion');
await page.getByRole('button', { name: /Yasmine Alaoui/ }).click();
await expect(page.getByRole('heading', { name: 'Tableau de bord' })).toBeVisible();
// 1 · Sites : le parc seedé est là, on crée un site
await page.getByRole('link', { name: 'Sites' }).click();
await expect(page.getByRole('cell', { name: /Tour Atlas/ })).toBeVisible();
await page.getByRole('button', { name: '+ Nouveau site' }).click();
await page.getByLabel('Nom du site *').fill(`Site E2E ${suffix}`);
await page.getByLabel('Ville').fill('Casablanca');
await page.getByRole('button', { name: 'Créer le site' }).click();
// → fiche du site créé ; on lui ajoute une zone
await expect(page.getByRole('heading', { name: `Site E2E ${suffix}` })).toBeVisible();
await page.getByRole('button', { name: '+ Ajouter un emplacement' }).click();
await page.getByLabel('Nom de la zone *').fill(`Hall E2E ${suffix}`);
await page.getByRole('button', { name: 'Ajouter', exact: true }).click();
await expect(page.locator('.arbre')).toContainText(`Hall E2E ${suffix}`);
// 2 · Nouvel ascenseur avec un organe
await page.getByRole('button', { name: '+ Nouvel ascenseur' }).click();
await page.getByLabel('Référence *').fill(`E2E-${suffix}`);
await page.getByLabel('Marque *').fill('Otis');
await page.getByLabel('Catégorie *').selectOption({ label: 'Ascenseur électrique' });
await page.getByLabel('Emplacement dans le site *').selectOption({ label: `Hall E2E ${suffix}` });
await page.getByLabel("Type de l'organe 1").selectOption({ label: 'Portes cabine / palières' });
await page.getByLabel("Désignation de l'organe 1").fill('Fermator 40/10');
await page.getByRole('button', { name: "Créer l'ascenseur (QR généré)" }).click();
// → fiche appareil : identité, organe, QR
await expect(
page.getByRole('heading', { name: new RegExp(`Ascenseur E2E-${suffix}`) }),
).toBeVisible();
await expect(page.getByText('Portes cabine / palières')).toBeVisible();
// 3 · Étiquette QR imprimable
await page.getByRole('button', { name: 'Étiquette QR' }).click();
await expect(page.locator('.etiquette')).toContainText(`Ascenseur E2E-${suffix}`);
await expect(page.locator('.etiquette .consigne')).toContainText('Scannez ce code');
// 4 · Inviter un technicien → récupérer le lien d'activation
await page.getByRole('link', { name: 'Personnes' }).click();
await page.getByRole('button', { name: '+ Inviter' }).click();
await page.getByLabel('Nom complet *').fill('Recrue E2E');
await page.getByLabel('Email professionnel *').fill(`recrue-${suffix}@spelev.ma`);
await page.getByLabel('Rôle *').selectOption({ label: 'Technicien' });
await page.getByRole('button', { name: "Envoyer l'invitation" }).click();
const lien = await page.getByTestId('lien-activation').textContent();
expect(lien).toContain('/activation?token=');
await page.getByRole('button', { name: 'Fermer' }).click();
await expect(page.getByRole('cell', { name: /Recrue E2E/ })).toBeVisible();
await expect(page.getByText('Invitation envoyée')).toBeVisible();
// 5 · La recrue active son compte via le lien et entre directement
await page.locator('.compte').click();
await page.getByRole('menuitem', { name: 'Se déconnecter' }).click();
await page.goto(lien!);
await page.getByLabel('Mot de passe (8 caractères minimum)').fill('MotDePasse!123');
await page.getByLabel('Confirmez le mot de passe').fill('MotDePasse!123');
await page.getByRole('button', { name: 'Activer mon compte' }).click();
await expect(page.getByRole('heading', { name: 'Tableau de bord' })).toBeVisible();
await expect(page.locator('.topbar')).toContainText('Recrue E2E');
await expect(page.locator('.topbar')).toContainText('Technicien');
});
test('la matrice pilote lUI : un technicien voit le parc sans boutons de création', async ({
page,
}) => {
await page.goto('/connexion');
await page.getByRole('button', { name: /Ahmed Benali/ }).click();
await expect(page.getByRole('heading', { name: 'Tableau de bord' })).toBeVisible();
// Catégories (SETTINGS) absent de sa sidebar ; Ascenseurs accessible
await expect(page.locator('.nav')).not.toContainText('Catégories');
await page.getByRole('link', { name: 'Ascenseurs' }).click();
await expect(page.getByRole('cell', { name: 'A1', exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: '+ Nouvel ascenseur' })).toHaveCount(0);
// Fiche : statuts visibles, pas de retrait d'organe
await page.getByRole('cell', { name: 'A1', exact: true }).click();
await expect(page.getByText('Treuil / machinerie')).toBeVisible();
await expect(page.getByRole('button', { name: /Retirer/ })).toHaveCount(0);
});

View File

@@ -0,0 +1,117 @@
import { expect, test } from '@playwright/test';
/**
* Recette R2 (plan de releases) : demande gardien → approbation → OT →
* intervention → bilan codé → clôture → suivi demandeur ; génération du
* préventif du mois (idempotente) ; compteur croissant.
*/
const suffix = Date.now().toString(36);
test('recette R2 : demande → OT → bilan codé → clôture → suivi demandeur', async ({
page,
}) => {
// 1 · Karim (demandeur) signale depuis l'app
await page.goto('/connexion');
await page.getByRole('button', { name: /Karim Doukkali/ }).click();
await expect(page.getByRole('heading', { name: 'Mes signalements' })).toBeVisible();
await page.getByRole('link', { name: /Demandes/ }).click();
await page.getByRole('button', { name: '+ Signaler' }).click();
await page.getByLabel('Équipement *').selectOption({ index: 1 });
await page.getByLabel('Décrivez le problème *').fill(`E2E ${suffix} — porte qui claque très fort`);
await page.getByRole('button', { name: 'Envoyer le signalement' }).click();
await expect(page.getByRole('cell', { name: new RegExp(suffix) })).toBeVisible();
// 2 · Salma (dispatcher) approuve → OT créé, assigné à Ahmed
await page.locator('.compte').click();
await page.getByRole('menuitem', { name: /Salma Idrissi/ }).click();
await page.getByRole('link', { name: /Demandes/ }).click();
await page.getByRole('cell', { name: new RegExp(suffix) }).click();
await page.getByLabel('Assigner à').selectOption({ index: 1 }); // Ahmed Benali
await page.getByRole('button', { name: "Approuver → créer l'OT" }).click();
// → fiche OT
await expect(page.getByRole('heading', { name: new RegExp(suffix) })).toBeVisible();
await expect(page.locator('.entete-page .st')).toHaveText('Ouvert');
// 3 · Démarrer, tenter de clôturer (bloqué : bilan), remplir le bilan, clôturer
await page.getByRole('button', { name: 'Démarrer' }).click();
await expect(page.locator('.entete-page .st')).toHaveText('En cours');
const boutonCloture = page.getByRole('button', { name: "Clôturer l'intervention" });
await expect(boutonCloture).toBeDisabled(); // garde visible dans l'UI
await expect(page.locator('.avertissement')).toContainText('bilan incomplet');
await page.getByLabel(/État des portes/).selectOption({ label: 'Fonctionnement normal' });
await page.getByLabel(/Action réalisée/).selectOption({ label: 'Réglage' });
await page.getByLabel(/Élément concerné/).selectOption({ label: 'Portes' });
await expect(boutonCloture).toBeEnabled();
await boutonCloture.click();
await expect(page.locator('.entete-page .st')).toHaveText('Terminé');
// 4 · Karim suit : sa demande est Résolue
await page.locator('.compte').click();
await page.getByRole('menuitem', { name: /Karim Doukkali/ }).click();
await page.getByRole('link', { name: /Demandes/ }).click();
const ligne = page.locator('tr', { hasText: suffix });
await expect(ligne.locator('.st')).toHaveText('Résolue');
});
test('préventif : générer le mois — puis regénérer ne double rien', async ({ page }) => {
await page.goto('/connexion');
await page.getByRole('button', { name: /Yasmine Alaoui/ }).click();
await page.getByRole('link', { name: 'Préventif' }).click();
await expect(page.getByRole('heading', { name: 'Préventif' })).toBeVisible();
await expect(page.getByText('Essai du parachute')).toBeVisible();
await page.getByRole('button', { name: /Générer la grille de/ }).click();
await expect(page.locator('.gen-info')).toContainText('regénérer ne double rien');
// Second appel : 0 créé
await page.getByRole('button', { name: /Générer la grille de/ }).click();
await expect(page.locator('.gen-info')).toContainText('0 OT créés');
});
test('urgence « personne bloquée » : interrupteur → chip topbar + bandeau + tête de liste', async ({
page,
}) => {
await page.goto('/connexion');
await page.getByRole('button', { name: /Salma Idrissi/ }).click();
await page.getByRole('link', { name: /Ordres de travail/ }).click();
await page.getByRole('button', { name: '+ Nouvel OT' }).click();
await page.getByLabel('Objet *').fill(`E2E ${suffix} — personne bloquée cage B`);
await page.getByLabel('Équipement *').selectOption({ index: 1 });
await page.getByRole('switch', { name: 'Personne bloquée' }).click();
await page.getByRole('button', { name: "Créer l'OT (statut Ouvert)" }).click();
await expect(page.getByRole('heading', { name: new RegExp(suffix) })).toBeVisible();
// chip topbar (pulse) présent et bandeau sur le tableau de bord
await expect(page.locator('.topbar .chip-urgence')).toContainText('personne');
await page.getByRole('link', { name: 'Tableau de bord' }).click();
await expect(page.locator('.bandeau-urgence').first()).toBeVisible();
// tête de liste OT
await page.getByRole('link', { name: /Ordres de travail/ }).click();
await expect(page.locator('tbody tr').first()).toHaveClass(/ligne-bloque/);
// nettoyage : annuler l'OT d'urgence créé (motif via prompt)
await page.locator('tbody tr').first().click();
page.once('dialog', (d) => d.accept('Test e2e — nettoyage'));
await page.getByRole('button', { name: 'Annuler', exact: true }).click();
await expect(page.locator('.entete-page .st')).toHaveText('Annulé');
});
test('compteurs : relevé croissant accepté, régression refusée', async ({ page }) => {
await page.goto('/connexion');
await page.getByRole('button', { name: /Ahmed Benali/ }).click();
await page.getByRole('link', { name: 'Ascenseurs' }).click();
await page.getByRole('cell', { name: 'A1', exact: true }).click();
await page.getByRole('button', { name: 'Compteurs' }).click();
await expect(page.getByRole('heading', { name: /Compteurs — Ascenseur A1/ })).toBeVisible();
const saisie = page.getByLabel('Nouveau relevé — Heures de marche');
await saisie.fill('99999999');
await page.getByRole('button', { name: 'Enregistrer le relevé' }).first().click();
await expect(page.locator('.chrono').first()).toContainText('99 999 999');
await saisie.fill('12');
await page.getByRole('button', { name: 'Enregistrer le relevé' }).first().click();
await expect(page.locator('.erreur-form')).toContainText('ne redescend pas');
});

View File

@@ -0,0 +1,209 @@
import { expect, test } from '@playwright/test';
/**
* Recette R3 (plan de releases) : pièce sous le seuil → BC prérempli →
* réception (le stock remonte, l'alerte s'éteint) ; coûts réels sur OT
* (prix figé + taux figé) ; document rattaché téléversé puis téléchargé ;
* statistiques lisibles par la Vue seule.
* Chaque test crée SES données (marquées E2E, purgées au global-setup).
*/
const suffix = Date.now().toString(36);
/** PNG 1×1 valide — assez pour tester upload/download réels via MinIO. */
const PNG_1PX = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
'base64',
);
async function connexionDemo(page: import('@playwright/test').Page, nom: RegExp) {
await page.goto('/connexion');
await page.getByRole('button', { name: nom }).click();
await expect(page.locator('.topbar')).toBeVisible();
}
async function changerDeCompte(page: import('@playwright/test').Page, nom: RegExp) {
await page.locator('.compte').click();
await page.getByRole('menuitem', { name: nom }).click();
}
test('recette R3 : tiers → pièce sous seuil → BC prérempli → réception → alerte éteinte', async ({
page,
}) => {
const fournisseur = `E2E Fournisseur ${suffix}`;
const piece = `E2E Pièce ${suffix}`;
// 1 · Nadia (gestionnaire) crée le fournisseur
await connexionDemo(page, /Nadia Berrada/);
await page.getByRole('link', { name: 'Tiers' }).click();
await page.getByRole('button', { name: '+ Nouveau tiers' }).click();
await page.getByLabel('Nom *').fill(fournisseur);
await page.getByLabel('Type *').selectOption('SUPPLIER');
await page.getByRole('button', { name: 'Créer', exact: true }).click();
await expect(page.getByRole('cell', { name: fournisseur })).toBeVisible();
// 2 · Nouvelle pièce (seuil 5, prix initial 100) → fiche
await page.getByRole('link', { name: 'Stock & achats' }).click();
await page.getByRole('button', { name: '+ Nouvelle pièce' }).click();
await page.getByLabel('Désignation *').fill(piece);
await page.getByLabel("Seuil d'alerte").fill('5');
await page.getByLabel('Prix initial (MAD)').fill('100');
await page.getByLabel('Fournisseur', { exact: true }).selectOption({ label: fournisseur });
await page.getByRole('button', { name: 'Créer la pièce' }).click();
await expect(page.getByRole('heading', { name: piece })).toBeVisible();
// 3 · Entrée de 2 → stock 2 < seuil 5 : alerte
await page.getByRole('button', { name: 'Entrée de stock' }).click();
await page.getByLabel(/Quantité/).fill('2');
await page.getByRole('button', { name: 'Tracer le mouvement' }).click();
await expect(page.locator('.entete-page .stock-bas')).toHaveText('Sous le seuil');
await expect(page.locator('dd', { hasText: 'seuil : 5' })).toContainText('2');
// 4 · « Préparer le BC » → formulaire prérempli (fournisseur, qté 8, PU 100)
await page.getByRole('button', { name: 'Préparer le BC' }).click();
await expect(page.getByRole('heading', { name: 'Nouveau bon de commande' })).toBeVisible();
await expect(page.getByLabel('Fournisseur *')).toHaveValue(/.+/);
await expect(page.getByLabel('Quantité de la ligne 1')).toHaveValue('8'); // seuil stock + 5
await expect(page.getByLabel('Prix unitaire de la ligne 1')).toHaveValue('100');
await page.getByRole('button', { name: 'Créer le BC (brouillon)' }).click();
// 5 · Envoyer puis réceptionner — le panneau suit le BC
const ligneBC = page.locator('tr', { hasText: fournisseur }).first();
await ligneBC.click();
await expect(page.locator('.panneau .st')).toHaveText('Brouillon');
await page.getByRole('button', { name: 'Envoyer au fournisseur' }).click();
await expect(page.locator('.panneau .st')).toHaveText('Envoyé');
await page.getByRole('button', { name: /Réceptionner/ }).click();
await expect(page.locator('.panneau .st')).toHaveText('Reçu');
// 6 · Le stock est remonté (2 + 8 = 10), l'alerte est éteinte
await page.getByRole('link', { name: 'Stock & achats' }).click();
const lignePiece = page.locator('tr', { hasText: piece });
await expect(lignePiece.locator('td').nth(2)).toHaveText('10');
await expect(lignePiece.locator('.stock-bas')).toHaveCount(0);
// 7 · La fiche pièce raconte tout : entrée manuelle + réception à PU figé
await lignePiece.click();
await expect(page.locator('.mvt.entree')).toHaveCount(2);
await expect(page.getByText('100 MAD/u')).toBeVisible();
});
test('recette R3 : coûts réels sur OT (prix + taux figés) + document + statistiques', async ({
page,
}) => {
const piece = `E2E PièceOT ${suffix}`;
const titreOT = `E2E ${suffix} — coûts`;
// 1 · Nadia crée une pièce avec du stock (10 à 100 MAD)
await connexionDemo(page, /Nadia Berrada/);
await page.getByRole('link', { name: 'Stock & achats' }).click();
await page.getByRole('button', { name: '+ Nouvelle pièce' }).click();
await page.getByLabel('Désignation *').fill(piece);
await page.getByLabel('Prix initial (MAD)').fill('100');
await page.getByRole('button', { name: 'Créer la pièce' }).click();
await expect(page.getByRole('heading', { name: piece })).toBeVisible();
await page.getByRole('button', { name: 'Entrée de stock' }).click();
await page.getByLabel(/Quantité/).fill('10');
await page.getByRole('button', { name: 'Tracer le mouvement' }).click();
await expect(page.locator('dd', { hasText: 'seuil : 0' })).toContainText('10');
// 2 · Salma (dispatcher) crée l'OT et l'assigne à Ahmed
await changerDeCompte(page, /Salma Idrissi/);
await page.getByRole('link', { name: /Ordres de travail/ }).click();
await page.getByRole('button', { name: '+ Nouvel OT' }).click();
await page.getByLabel('Objet *').fill(titreOT);
await page.getByLabel('Équipement *').selectOption({ index: 1 });
await page.getByLabel('Assigner à').selectOption({ label: 'Ahmed Benali' });
await page.getByRole('button', { name: "Créer l'OT (statut Ouvert)" }).click();
await expect(page.getByRole('heading', { name: titreOT })).toBeVisible();
// 3 · Ahmed (technicien) démarre, consomme 2 pièces, saisit 1 h 30
await changerDeCompte(page, /Ahmed Benali/);
await page.getByRole('link', { name: /Ordres de travail/ }).click();
await page.getByRole('cell', { name: titreOT }).click();
await page.getByRole('button', { name: 'Démarrer' }).click();
await expect(page.locator('.entete-page .st')).toHaveText('En cours');
await page.getByRole('button', { name: '+ Consommer une pièce' }).click();
await page.getByLabel('Pièce *').selectOption({ label: `${piece} — stock : 10` });
await expect(page.getByText('Prix figé à la consommation')).toBeVisible();
await page.getByLabel('Quantité *').fill('2');
await page.getByRole('button', { name: /^Consommer/ }).click();
await expect(page.locator('.item-liste', { hasText: `${piece} × 2` })).toBeVisible();
await page.getByRole('button', { name: '+ Saisir du temps' }).click();
await page.getByLabel('Heures').fill('1');
await page.getByLabel('Minutes').fill('30');
await page.getByRole('button', { name: 'Enregistrer' }).click();
// 2 × 100 + 1,5 h × 120 MAD/h (taux seedé d'Ahmed) = 380 MAD
await expect(page.getByText('1 h 30 × 120 MAD/h')).toBeVisible();
await expect(page.locator('.total .num')).toHaveText('380 MAD');
// 4 · Ahmed joint une photo — puis la retélécharge (aller-retour MinIO réel)
await page.getByLabel('Choisir un fichier').setInputFiles({
name: `photo-e2e-${suffix}.png`,
mimeType: 'image/png',
buffer: PNG_1PX,
});
await expect(page.getByText(`photo-e2e-${suffix}.png`)).toBeVisible();
const attenteTelechargement = page.waitForEvent('download');
await page.getByRole('button', { name: 'Télécharger' }).click();
expect((await attenteTelechargement).suggestedFilename()).toBe(`photo-e2e-${suffix}.png`);
// 5 · Rachid (vue seule) lit les statistiques
await changerDeCompte(page, /Rachid Bennis/);
await page.getByRole('link', { name: 'Statistiques', exact: true }).click();
await expect(page.getByText('OT clôturés · 12 mois')).toBeVisible();
await expect(page.getByText('Pannes par organe · 12 mois')).toBeVisible();
await expect(page.getByText('Top équipements en coût · 12 mois')).toBeVisible();
});
test('recette R3 corrigée : recherche ⌘K, tri/filtre stock, période stats, rattachements, dépôt', async ({
page,
}) => {
await connexionDemo(page, /Yasmine Alaoui/);
// Recherche globale : ⌘K → site (insensible à la casse), clic → fiche site
await page.keyboard.press('ControlOrMeta+k');
await page.keyboard.type('atlas');
await page.locator('.resultat', { hasText: 'Tour Atlas' }).first().click();
await expect(page).toHaveURL(/\/sites\//);
// le site porte son syndic (rattachement seedé, éditable par l'admin)
await expect(page.getByLabel('Client / syndic du site')).toHaveValue(/.+/);
// Recherche OT au clavier seul (Entrée sur le premier résultat)
await page.keyboard.press('ControlOrMeta+k');
await page.keyboard.type('bruit');
await page.locator('.resultat', { hasText: 'Bruit anormal' }).first().waitFor();
await page.keyboard.press('Enter');
await expect(page).toHaveURL(/\/ot\//);
// Stock : les pièces sous le seuil remontent en tête, filtre fournisseur,
// « Entrée de stock » accessible depuis la liste (choix de la pièce)
await page.getByRole('link', { name: 'Stock & achats' }).click();
await expect(page.locator('tbody tr').first().locator('.stock-bas')).toHaveText(/Sous le seuil/);
await page.getByLabel('Filtrer par fournisseur').selectOption({ label: 'Lubmaroc' });
await expect(page.locator('tbody tr', { hasText: 'P-0042' })).toHaveCount(1);
await page.getByLabel('Filtrer par fournisseur').selectOption({ index: 0 });
await page.getByRole('button', { name: 'Entrée de stock' }).click();
await expect(page.getByLabel('Pièce *')).toBeVisible();
await page.getByRole('button', { name: 'Annuler' }).click();
// Fiche pièce : le fournisseur est un lien vers les tiers
await page.getByRole('cell', { name: 'P-0113' }).click();
await page.getByRole('link', { name: 'Ascentech Maroc' }).click();
await expect(page).toHaveURL(/\/tiers/);
// Tiers : les syndics affichent leurs sites
await expect(page.getByRole('cell', { name: 'Tour Atlas' })).toBeVisible();
await expect(page.getByRole('cell', { name: 'Résidence Al Manar' })).toBeVisible();
// Statistiques : la période est un vrai sélecteur
await page.getByRole('link', { name: 'Statistiques', exact: true }).click();
await page.getByLabel('Période').selectOption('3');
await expect(page.getByText('OT clôturés · 3 mois')).toBeVisible();
// Bibliothèque : filtre « Rattaché à » + zone de glisser-déposer
await page.getByRole('link', { name: 'Fichiers' }).click();
await expect(page.getByLabel('Filtrer par appareil')).toBeVisible();
await expect(page.locator('.zone-depot')).toBeVisible();
});

View File

@@ -15,21 +15,26 @@
},
"dependencies": {
"@fontsource/manrope": "^5.1.0",
"@radix-ui/react-dialog": "^1.1.19",
"@radix-ui/react-dropdown-menu": "^2.1.4",
"@siop/shared": "workspace:*",
"@tanstack/react-query": "^5.62.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"leaflet": "^1.9.4",
"lucide-react": "^0.469.0",
"openapi-fetch": "^0.13.4",
"qrcode.react": "^4.2.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-leaflet": "^5.0.0",
"react-router-dom": "^7.1.0",
"tailwind-merge": "^2.6.0"
},
"devDependencies": {
"@playwright/test": "^1.61.1",
"@tailwindcss/vite": "^4.1.0",
"@types/leaflet": "^1.9.21",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",

View File

@@ -1,36 +1,72 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import type { ReactNode } from 'react';
import { Coquille } from '@/layout/coquille';
import PageAchats from '@/pages/achats';
import PageActivation from '@/pages/activation';
import PageAscenseurs from '@/pages/ascenseurs';
import PageBibliotheque from '@/pages/bibliotheque';
import PageCategories from '@/pages/categories';
import PageCompteurs from '@/pages/compteurs';
import PageConnexion from '@/pages/connexion';
import PageDemandes from '@/pages/demandes';
import PageDesign from '@/pages/design';
import PageEtiquette from '@/pages/etiquette';
import PageFicheAscenseur from '@/pages/fiche-ascenseur';
import PageFicheOT from '@/pages/fiche-ot';
import PageFichePiece from '@/pages/fiche-piece';
import PageFicheSite from '@/pages/fiche-site';
import PageNouvelAchat from '@/pages/nouvel-achat';
import PageNouvelAscenseur from '@/pages/nouvel-ascenseur';
import PageNouvelOT from '@/pages/nouvel-ot';
import PageOrdresTravail from '@/pages/ordres-travail';
import PagePersonnes from '@/pages/personnes';
import PagePortail from '@/pages/portail';
import PagePreventif from '@/pages/preventif';
import PageSites from '@/pages/sites';
import PageStatistiques from '@/pages/statistiques';
import PageStock from '@/pages/stock';
import PageTableauDeBord from '@/pages/tableau-de-bord';
import PageTiers from '@/pages/tiers';
const queryClient = new QueryClient({
defaultOptions: { queries: { refetchOnWindowFocus: false } },
});
const dansCoquille = (page: ReactNode) => <Coquille>{page}</Coquille>;
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<Routes>
<Route path="/connexion" element={<PageConnexion />} />
<Route
path="/"
element={
<Coquille>
<PageTableauDeBord />
</Coquille>
}
/>
<Route
path="/design"
element={
<Coquille>
<PageDesign />
</Coquille>
}
/>
<Route path="/activation" element={<PageActivation />} />
{/* Portail public — cible du QR cabine, AUCUN compte */}
<Route path="/q/:reference" element={<PagePortail />} />
<Route path="/" element={dansCoquille(<PageTableauDeBord />)} />
<Route path="/design" element={dansCoquille(<PageDesign />)} />
<Route path="/sites" element={dansCoquille(<PageSites />)} />
<Route path="/sites/:id" element={dansCoquille(<PageFicheSite />)} />
<Route path="/ascenseurs" element={dansCoquille(<PageAscenseurs />)} />
<Route path="/ascenseurs/nouveau" element={dansCoquille(<PageNouvelAscenseur />)} />
<Route path="/ascenseurs/:id" element={dansCoquille(<PageFicheAscenseur />)} />
<Route path="/ascenseurs/:id/etiquette" element={dansCoquille(<PageEtiquette />)} />
<Route path="/ascenseurs/:id/compteurs" element={dansCoquille(<PageCompteurs />)} />
<Route path="/personnes" element={dansCoquille(<PagePersonnes />)} />
<Route path="/categories" element={dansCoquille(<PageCategories />)} />
<Route path="/ot" element={dansCoquille(<PageOrdresTravail />)} />
<Route path="/ot/nouveau" element={dansCoquille(<PageNouvelOT />)} />
<Route path="/ot/:id" element={dansCoquille(<PageFicheOT />)} />
<Route path="/demandes" element={dansCoquille(<PageDemandes />)} />
<Route path="/preventif" element={dansCoquille(<PagePreventif />)} />
<Route path="/stock" element={dansCoquille(<PageStock />)} />
<Route path="/stock/:id" element={dansCoquille(<PageFichePiece />)} />
<Route path="/achats" element={dansCoquille(<PageAchats />)} />
<Route path="/achats/nouveau" element={dansCoquille(<PageNouvelAchat />)} />
<Route path="/tiers" element={dansCoquille(<PageTiers />)} />
<Route path="/bibliotheque" element={dansCoquille(<PageBibliotheque />)} />
<Route path="/statistiques" element={dansCoquille(<PageStatistiques />)} />
</Routes>
</BrowserRouter>
</QueryClientProvider>

View File

@@ -0,0 +1,235 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type {
AssigneesUpdate,
ChecklistPatch,
MeterReadingCreate,
PreventiveGenerate,
ReportUpsert,
RequestApprove,
RequestCreate,
RequestReject,
TaskTemplateCreate,
TaskTemplateUpdate,
TransitionRequest,
WorkOrderCreate,
} from '@siop/shared';
import { api } from './client';
/** Hooks R2 — client typé, invalidations ciblées. */
async function unwrap<T>(res: { data?: T; error?: unknown; response: Response }): Promise<T> {
if (res.error || res.data === undefined) {
const message =
(res.error as { message?: string } | undefined)?.message ??
`Le serveur a répondu ${res.response.status}`;
throw new Error(Array.isArray(message) ? message.join(' — ') : message);
}
return res.data;
}
function useInvalidate(...keys: string[]) {
const queryClient = useQueryClient();
return () =>
Promise.all(keys.map((key) => queryClient.invalidateQueries({ queryKey: [key] })));
}
// ————— Ordres de travail —————
export function useWorkOrders(enabled = true) {
return useQuery({
queryKey: ['work-orders'],
enabled,
queryFn: async () => (await unwrap(await api.GET('/work-orders'))).workOrders,
});
}
export function useWorkOrder(id: string | undefined) {
return useQuery({
queryKey: ['work-orders', id],
enabled: !!id,
queryFn: async () =>
unwrap(await api.GET('/work-orders/{id}', { params: { path: { id: id! } } })),
});
}
export function useCreateWorkOrder() {
const invalidate = useInvalidate('work-orders');
return useMutation({
mutationFn: async (body: WorkOrderCreate) =>
unwrap(await api.POST('/work-orders', { body })),
onSuccess: invalidate,
});
}
export function useTransitionWorkOrder(id: string) {
const invalidate = useInvalidate('work-orders', 'requests', 'preventive-status');
return useMutation({
mutationFn: async (body: TransitionRequest) =>
unwrap(await api.POST('/work-orders/{id}/transition', { params: { path: { id } }, body })),
onSuccess: invalidate,
});
}
export function useCommentWorkOrder(id: string) {
const invalidate = useInvalidate('work-orders');
return useMutation({
mutationFn: async (message: string) =>
unwrap(
await api.POST('/work-orders/{id}/comments', {
params: { path: { id } },
body: { message },
}),
),
onSuccess: invalidate,
});
}
export function useSetAssignees(id: string) {
const invalidate = useInvalidate('work-orders');
return useMutation({
mutationFn: async (body: AssigneesUpdate) =>
unwrap(await api.PUT('/work-orders/{id}/assignees', { params: { path: { id } }, body })),
onSuccess: invalidate,
});
}
export function useUpsertReport(id: string) {
const invalidate = useInvalidate('work-orders');
return useMutation({
mutationFn: async (body: ReportUpsert) =>
unwrap(await api.PUT('/work-orders/{id}/report', { params: { path: { id } }, body })),
onSuccess: invalidate,
});
}
export function usePatchChecklist(id: string) {
const invalidate = useInvalidate('work-orders');
return useMutation({
mutationFn: async ({ itemId, ...body }: ChecklistPatch & { itemId: string }) =>
unwrap(
await api.PATCH('/work-orders/{id}/checklist/{itemId}', {
params: { path: { id, itemId } },
body,
}),
),
onSuccess: invalidate,
});
}
// ————— Demandes —————
export function useRequests() {
return useQuery({
queryKey: ['requests'],
queryFn: async () => (await unwrap(await api.GET('/requests'))).requests,
});
}
export function useCreateRequest() {
const invalidate = useInvalidate('requests');
return useMutation({
mutationFn: async (body: RequestCreate) => unwrap(await api.POST('/requests', { body })),
onSuccess: invalidate,
});
}
export function useApproveRequest() {
const invalidate = useInvalidate('requests', 'work-orders');
return useMutation({
mutationFn: async ({ id, ...body }: RequestApprove & { id: string }) =>
unwrap(await api.POST('/requests/{id}/approve', { params: { path: { id } }, body })),
onSuccess: invalidate,
});
}
export function useRejectRequest() {
const invalidate = useInvalidate('requests');
return useMutation({
mutationFn: async ({ id, ...body }: RequestReject & { id: string }) =>
unwrap(await api.POST('/requests/{id}/reject', { params: { path: { id } }, body })),
onSuccess: invalidate,
});
}
// ————— Référentiels du bilan —————
export function useReferenceValues() {
return useQuery({
queryKey: ['reference-values'],
staleTime: 5 * 60_000,
queryFn: async () =>
(await unwrap(await api.GET('/reference-values'))).referenceValues,
});
}
// ————— Préventif —————
export function useTaskTemplates() {
return useQuery({
queryKey: ['task-templates'],
queryFn: async () => (await unwrap(await api.GET('/preventive/templates'))).templates,
});
}
export function useCreateTaskTemplate() {
const invalidate = useInvalidate('task-templates');
return useMutation({
mutationFn: async (body: TaskTemplateCreate) =>
unwrap(await api.POST('/preventive/templates', { body })),
onSuccess: invalidate,
});
}
export function useUpdateTaskTemplate() {
const invalidate = useInvalidate('task-templates');
return useMutation({
mutationFn: async ({ id, ...body }: TaskTemplateUpdate & { id: string }) =>
unwrap(
await api.PATCH('/preventive/templates/{id}', { params: { path: { id } }, body }),
),
onSuccess: invalidate,
});
}
export function usePreventiveStatus(enabled = true) {
return useQuery({
queryKey: ['preventive-status'],
enabled,
queryFn: async () => unwrap(await api.GET('/preventive/status')),
});
}
export function useGeneratePreventive() {
const invalidate = useInvalidate('preventive-status', 'work-orders');
return useMutation({
mutationFn: async (body: PreventiveGenerate) =>
unwrap(await api.POST('/preventive/generate', { body })),
onSuccess: invalidate,
});
}
// ————— Compteurs —————
export function useMeters(assetId: string | undefined) {
return useQuery({
queryKey: ['meters', assetId],
enabled: !!assetId,
queryFn: async () =>
(await unwrap(await api.GET('/assets/{id}/meters', { params: { path: { id: assetId! } } })))
.meters,
});
}
export function useAddMeterReading(assetId: string) {
const invalidate = useInvalidate('meters');
return useMutation({
mutationFn: async (body: MeterReadingCreate) =>
unwrap(
await api.POST('/assets/{id}/meter-readings', {
params: { path: { id: assetId } },
body,
}),
),
onSuccess: invalidate,
});
}

268
apps/web/src/api/gestion.ts Normal file
View File

@@ -0,0 +1,268 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type {
ConsumePart,
LaborTimeCreate,
PartCreate,
PartnerCreate,
PartnerUpdate,
PartUpdate,
PurchaseOrderCreate,
PurchaseOrderTransition,
StockMovementCreate,
} from '@siop/shared';
import { api, getToken } from './client';
/** Hooks R3 — client typé ; l'upload passe par FormData (multipart). */
async function unwrap<T>(res: { data?: T; error?: unknown; response: Response }): Promise<T> {
if (res.error || res.data === undefined) {
const message =
(res.error as { message?: string } | undefined)?.message ??
`Le serveur a répondu ${res.response.status}`;
throw new Error(Array.isArray(message) ? message.join(' — ') : message);
}
return res.data;
}
function useInvalidate(...keys: string[]) {
const queryClient = useQueryClient();
return () =>
Promise.all(keys.map((key) => queryClient.invalidateQueries({ queryKey: [key] })));
}
// ————— Tiers —————
export function usePartners(enabled = true) {
return useQuery({
queryKey: ['partners'],
enabled,
queryFn: async () => (await unwrap(await api.GET('/partners'))).partners,
});
}
export function useCreatePartner() {
const invalidate = useInvalidate('partners');
return useMutation({
mutationFn: async (body: PartnerCreate) => unwrap(await api.POST('/partners', { body })),
onSuccess: invalidate,
});
}
export function useUpdatePartner() {
const invalidate = useInvalidate('partners');
return useMutation({
mutationFn: async ({ id, ...body }: PartnerUpdate & { id: string }) =>
unwrap(await api.PATCH('/partners/{id}', { params: { path: { id } }, body })),
onSuccess: invalidate,
});
}
// ————— Pièces & stock —————
export function useParts(enabled = true) {
return useQuery({
queryKey: ['parts'],
enabled,
queryFn: async () => (await unwrap(await api.GET('/parts'))).parts,
});
}
export function usePart(id: string | undefined) {
return useQuery({
queryKey: ['parts', id],
enabled: !!id,
queryFn: async () =>
unwrap(await api.GET('/parts/{id}', { params: { path: { id: id! } } })),
});
}
export function useCreatePart() {
const invalidate = useInvalidate('parts');
return useMutation({
mutationFn: async (body: PartCreate) => unwrap(await api.POST('/parts', { body })),
onSuccess: invalidate,
});
}
export function useUpdatePart(id: string) {
const invalidate = useInvalidate('parts');
return useMutation({
mutationFn: async (body: PartUpdate) =>
unwrap(await api.PATCH('/parts/{id}', { params: { path: { id } }, body })),
onSuccess: invalidate,
});
}
export function useAddStockMovement(id: string) {
const invalidate = useInvalidate('parts');
return useMutation({
mutationFn: async (body: StockMovementCreate) =>
unwrap(await api.POST('/parts/{id}/movements', { params: { path: { id } }, body })),
onSuccess: invalidate,
});
}
// ————— Bons de commande —————
export function usePurchaseOrders(enabled = true) {
return useQuery({
queryKey: ['purchase-orders'],
enabled,
queryFn: async () =>
(await unwrap(await api.GET('/purchase-orders'))).purchaseOrders,
});
}
export function useCreatePurchaseOrder() {
const invalidate = useInvalidate('purchase-orders');
return useMutation({
mutationFn: async (body: PurchaseOrderCreate) =>
unwrap(await api.POST('/purchase-orders', { body })),
onSuccess: invalidate,
});
}
export function useTransitionPurchaseOrder() {
const invalidate = useInvalidate('purchase-orders', 'parts');
return useMutation({
mutationFn: async ({ id, ...body }: PurchaseOrderTransition & { id: string }) =>
unwrap(
await api.POST('/purchase-orders/{id}/transition', { params: { path: { id } }, body }),
),
onSuccess: invalidate,
});
}
// ————— Coûts sur OT —————
export function useConsumePart(workOrderId: string) {
const invalidate = useInvalidate('work-orders', 'parts');
return useMutation({
mutationFn: async (body: ConsumePart) =>
unwrap(
await api.POST('/work-orders/{id}/consume-part', {
params: { path: { id: workOrderId } },
body,
}),
),
onSuccess: invalidate,
});
}
export function useAddLaborTime(workOrderId: string) {
const invalidate = useInvalidate('work-orders');
return useMutation({
mutationFn: async (body: LaborTimeCreate) =>
unwrap(
await api.POST('/work-orders/{id}/labor', {
params: { path: { id: workOrderId } },
body,
}),
),
onSuccess: invalidate,
});
}
// ————— Bibliothèque —————
export function useDocuments(filtres: { assetId?: string; workOrderId?: string; kind?: string } = {}) {
return useQuery({
queryKey: ['documents', filtres],
queryFn: async () =>
(
await unwrap(
await api.GET('/documents', { params: { query: filtres } }),
)
).documents,
});
}
export function useUploadDocument() {
const invalidate = useInvalidate('documents');
return useMutation({
mutationFn: async (input: {
file: File;
kind: string;
assetId?: string;
workOrderId?: string;
}) => {
const form = new FormData();
form.append('file', input.file);
form.append('kind', input.kind);
if (input.assetId) form.append('assetId', input.assetId);
if (input.workOrderId) form.append('workOrderId', input.workOrderId);
const res = await fetch('/api/documents', {
method: 'POST',
headers: { Authorization: `Bearer ${getToken()}` },
body: form,
});
if (!res.ok) {
const corps = (await res.json().catch(() => null)) as { message?: string } | null;
throw new Error(corps?.message ?? `Téléversement refusé (${res.status})`);
}
return res.json();
},
onSuccess: invalidate,
});
}
export function useDeleteDocument() {
const invalidate = useInvalidate('documents');
return useMutation({
mutationFn: async (id: string) => {
const res = await api.DELETE('/documents/{id}', { params: { path: { id } } });
if (res.response.status >= 400) throw new Error('Suppression impossible');
},
onSuccess: invalidate,
});
}
/** Récupération authentifiée du fichier (un lien nu ne porte pas le jeton). */
export async function blobDocument(id: string): Promise<Blob> {
const res = await fetch(`/api/documents/${id}/download`, {
headers: { Authorization: `Bearer ${getToken()}` },
});
if (!res.ok) throw new Error('Téléchargement impossible');
return res.blob();
}
export async function telechargerDocument(id: string, fileName: string): Promise<void> {
const blob = await blobDocument(id);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
a.click();
URL.revokeObjectURL(url);
}
/** Ouverture dans un nouvel onglet (PDF et images : le navigateur les affiche). */
export async function ouvrirDocument(id: string): Promise<void> {
// Onglet ouvert dans le geste utilisateur, sinon les bloqueurs de pop-up
// refusent un window.open survenant après le fetch.
const onglet = window.open('', '_blank');
try {
const blob = await blobDocument(id);
const url = URL.createObjectURL(blob);
if (onglet) onglet.location.href = url;
else window.open(url, '_blank');
} catch (e) {
onglet?.close();
throw e;
}
}
// ————— Analytics —————
export function useAnalyticsSummary(months: 3 | 6 | 12 = 12, enabled = true) {
return useQuery({
queryKey: ['analytics', months],
enabled,
queryFn: async () =>
unwrap(
await api.GET('/analytics/summary', {
params: { query: { months: String(months) } },
}),
),
});
}

View File

@@ -0,0 +1,22 @@
import { useQuery } from '@tanstack/react-query';
import { SEARCH_MIN_CHARS } from '@siop/shared';
import { api } from './client';
async function unwrap<T>(res: { data?: T; error?: unknown; response: Response }): Promise<T> {
if (res.data !== undefined) return res.data;
throw new Error(`Recherche indisponible (${res.response.status})`);
}
/** Recherche globale de la topbar (⌘K). L'API filtre chaque famille par la
* matrice — le client n'a aucune logique de permission à porter. */
export function useGlobalSearch(q: string) {
const terme = q.trim();
return useQuery({
queryKey: ['search', terme],
enabled: terme.length >= SEARCH_MIN_CHARS,
staleTime: 30_000,
placeholderData: (precedent) => precedent, // pas de clignotement en tapant
queryFn: async () =>
unwrap(await api.GET('/search', { params: { query: { q: terme } } })),
});
}

View File

@@ -0,0 +1,208 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type {
AssetComponentCreate,
AssetCreate,
AssetUpdate,
CategoryCreate,
CategoryUpdate,
InvitationCreate,
LocationCreate,
LocationUpdate,
TeamCreate,
UserUpdate,
} from '@siop/shared';
import { api } from './client';
/** Hooks R1 — chaque appel passe par le client typé (règle d'or). */
async function unwrap<T>(res: { data?: T; error?: unknown; response: Response }): Promise<T> {
if (res.error || res.data === undefined) {
const message =
(res.error as { message?: string } | undefined)?.message ??
`Le serveur a répondu ${res.response.status}`;
throw new Error(Array.isArray(message) ? message.join(' — ') : message);
}
return res.data;
}
export function useLocations() {
return useQuery({
queryKey: ['locations'],
queryFn: async () => (await unwrap(await api.GET('/locations'))).locations,
});
}
export function useAssets() {
return useQuery({
queryKey: ['assets'],
queryFn: async () => (await unwrap(await api.GET('/assets'))).assets,
});
}
/** Options minimales (signalement) — accessibles à tout rôle authentifié. */
export function useAssetOptions() {
return useQuery({
queryKey: ['asset-options'],
queryFn: async () => (await unwrap(await api.GET('/assets/options'))).options,
});
}
export function useAsset(id: string | undefined) {
return useQuery({
queryKey: ['assets', id],
enabled: !!id,
queryFn: async () =>
unwrap(await api.GET('/assets/{id}', { params: { path: { id: id! } } })),
});
}
export function useCategories() {
return useQuery({
queryKey: ['categories'],
queryFn: async () => (await unwrap(await api.GET('/categories'))).categories,
});
}
export function useTeams() {
return useQuery({
queryKey: ['teams'],
queryFn: async () => (await unwrap(await api.GET('/teams'))).teams,
});
}
export function useUsers() {
return useQuery({
queryKey: ['users'],
queryFn: async () => (await unwrap(await api.GET('/users'))).users,
});
}
export function useRoles() {
return useQuery({
queryKey: ['roles'],
queryFn: async () => (await unwrap(await api.GET('/roles'))).roles,
});
}
function useInvalidate(...keys: string[]) {
const queryClient = useQueryClient();
return () =>
Promise.all(keys.map((key) => queryClient.invalidateQueries({ queryKey: [key] })));
}
export function useCreateLocation() {
const invalidate = useInvalidate('locations');
return useMutation({
mutationFn: async (body: LocationCreate) =>
unwrap(await api.POST('/locations', { body })),
onSuccess: invalidate,
});
}
export function useUpdateLocation(id: string) {
const invalidate = useInvalidate('locations', 'partners');
return useMutation({
mutationFn: async (body: LocationUpdate) =>
unwrap(await api.PATCH('/locations/{id}', { params: { path: { id } }, body })),
onSuccess: invalidate,
});
}
export function useCreateAsset() {
const invalidate = useInvalidate('assets', 'locations');
return useMutation({
mutationFn: async (body: AssetCreate) => unwrap(await api.POST('/assets', { body })),
onSuccess: invalidate,
});
}
export function useUpdateAsset(id: string) {
const invalidate = useInvalidate('assets');
return useMutation({
mutationFn: async (body: AssetUpdate) =>
unwrap(await api.PATCH('/assets/{id}', { params: { path: { id } }, body })),
onSuccess: invalidate,
});
}
export function useAddComponent(assetId: string) {
const invalidate = useInvalidate('assets');
return useMutation({
mutationFn: async (body: AssetComponentCreate) =>
unwrap(
await api.POST('/assets/{id}/components', {
params: { path: { id: assetId } },
body,
}),
),
onSuccess: invalidate,
});
}
export function useRemoveComponent(assetId: string) {
const invalidate = useInvalidate('assets');
return useMutation({
mutationFn: async (componentId: string) => {
const res = await api.DELETE('/assets/{id}/components/{componentId}', {
params: { path: { id: assetId, componentId } },
});
if (res.response.status >= 400) throw new Error('Suppression impossible');
},
onSuccess: invalidate,
});
}
export function useCreateCategory() {
const invalidate = useInvalidate('categories');
return useMutation({
mutationFn: async (body: CategoryCreate) =>
unwrap(await api.POST('/categories', { body })),
onSuccess: invalidate,
});
}
export function useUpdateCategory() {
const invalidate = useInvalidate('categories');
return useMutation({
mutationFn: async ({ id, ...body }: CategoryUpdate & { id: string }) =>
unwrap(await api.PATCH('/categories/{id}', { params: { path: { id } }, body })),
onSuccess: invalidate,
});
}
export function useCreateTeam() {
const invalidate = useInvalidate('teams', 'users');
return useMutation({
mutationFn: async (body: TeamCreate) => unwrap(await api.POST('/teams', { body })),
onSuccess: invalidate,
});
}
export function useInviteUser() {
const invalidate = useInvalidate('users');
return useMutation({
mutationFn: async (body: InvitationCreate) =>
unwrap(await api.POST('/users/invitations', { body })),
onSuccess: invalidate,
});
}
export function useResendInvitation() {
const invalidate = useInvalidate('users');
return useMutation({
mutationFn: async (userId: string) =>
unwrap(
await api.POST('/users/{id}/invitation', { params: { path: { id: userId } } }),
),
onSuccess: invalidate,
});
}
export function useUpdateUser() {
const invalidate = useInvalidate('users', 'teams');
return useMutation({
mutationFn: async ({ id, ...body }: UserUpdate & { id: string }) =>
unwrap(await api.PATCH('/users/{id}', { params: { path: { id } }, body })),
onSuccess: invalidate,
});
}

File diff suppressed because it is too large Load Diff

View File

@@ -21,7 +21,9 @@ export function useMe() {
}
/** ADR-002 : la liste n'existe qu'en DEMO_MODE — un 404 signifie « pas de
* mode démo », le web n'affiche alors rien (null). */
* mode démo », le web n'affiche alors rien (null). Une ERREUR RÉSEAU, en
* revanche, signifie que l'API est injoignable : l'écran de connexion
* l'affiche clairement (isError) au lieu de masquer la démo en silence. */
export function useDemoAccounts() {
return useQuery({
queryKey: ['demo-accounts'],
@@ -29,8 +31,9 @@ export function useDemoAccounts() {
staleTime: 5 * 60_000,
queryFn: async () => {
const { data, response } = await api.GET('/auth/demo-accounts');
if (response.status === 404) return null;
return data?.accounts ?? null;
if (response.status === 404) return null; // pas de mode démo : normal
if (!data) throw new Error(`API : ${response.status}`);
return data.accounts;
},
});
}

View File

@@ -0,0 +1,19 @@
import type { ObjectCategory, PermissionRight } from '@siop/shared';
import { useMe } from './use-auth';
/** Lecture UI de la matrice (l'API re-vérifie chaque requête, invariant R0). */
export function usePermissions() {
const { data: me } = useMe();
const can = (category: ObjectCategory, right: PermissionRight): boolean => {
const entry = me?.permissions.find((p) => p.objectCategory === category);
if (!entry) return false;
switch (right) {
case 'view': return entry.canView;
case 'viewOther': return entry.canViewOther;
case 'create': return entry.canCreate;
case 'edit': return entry.canEdit;
case 'delete': return entry.canDelete;
}
};
return { can, chargé: !!me };
}

View File

@@ -1,8 +1,7 @@
import type { RoleName } from '@siop/shared';
import { cn } from '@/lib/cn';
/** Couleur d'avatar par rôle (tokens uniquement — répartition de la maquette). */
const ROLE_TOKEN: Record<RoleName, string> = {
const ROLE_TOKEN: Record<string, string> = {
Administrateur: 'var(--graf-1)',
Dispatcher: 'var(--primaire)',
Technicien: 'var(--graf-3)',
@@ -18,13 +17,14 @@ export function Avatar({
className,
}: {
initials: string;
roleName?: RoleName;
roleName?: string;
className?: string;
}) {
const couleur = roleName ? ROLE_TOKEN[roleName] : undefined;
return (
<span
className={cn('avatar', className)}
style={roleName ? { background: ROLE_TOKEN[roleName] } : undefined}
style={couleur ? { background: couleur } : undefined}
aria-hidden="true"
>
{initials}

View File

@@ -0,0 +1,179 @@
import { useEffect, useRef, useState } from 'react';
import {
DOCUMENT_KIND_LABELS,
DOCUMENT_KINDS,
DOCUMENT_MAX_BYTES,
type DocumentDto,
type DocumentKind,
} from '@siop/shared';
import {
blobDocument,
ouvrirDocument,
telechargerDocument,
useDeleteDocument,
useDocuments,
useUploadDocument,
} from '@/api/gestion';
import { Button } from '@/components/ui/button';
const CLASSE_TYPE: Partial<Record<DocumentKind, string>> = {
CERTIFICATE: 'type-doc certificat',
PHOTO: 'type-doc photo',
};
export function tailleLisible(octets: number): string {
if (octets === 0) return '0 Ko';
if (octets >= 1024 * 1024) return `${(octets / (1024 * 1024)).toFixed(1).replace('.', ',')} Mo`;
return `${Math.max(1, Math.round(octets / 1024))} Ko`;
}
/** Aperçu réel des images : le fichier exige le jeton, donc pas de <img src>
* direct — on récupère le blob et on affiche une URL objet, révoquée au
* démontage. Les PDF gardent leur pictogramme (le navigateur les ouvre). */
function ApercuVignette({ doc }: { doc: DocumentDto }) {
const estImage = doc.contentType.startsWith('image/');
const [url, setUrl] = useState<string>();
useEffect(() => {
if (!estImage) return;
let actif = true;
let objet: string | undefined;
blobDocument(doc.id)
.then((blob) => {
if (!actif) return;
objet = URL.createObjectURL(blob);
setUrl(objet);
})
.catch(() => {});
return () => {
actif = false;
if (objet) URL.revokeObjectURL(objet);
};
}, [doc.id, estImage]);
return (
<button
type="button"
className="vignette"
title={`Ouvrir ${doc.fileName}`}
onClick={() => void ouvrirDocument(doc.id)}
>
{url ? <img src={url} alt={doc.fileName} /> : estImage ? '🖼' : '📄'}
</button>
);
}
export function VignetteDoc({ doc, surSuppression }: { doc: DocumentDto; surSuppression?: (id: string) => void }) {
return (
<div className="doc">
<ApercuVignette doc={doc} />
<span className={CLASSE_TYPE[doc.kind] ?? 'type-doc'}>{DOCUMENT_KIND_LABELS[doc.kind]}</span>
<b>{doc.fileName}</b>
<span className="meta">
{doc.assetReference ? `Asc. ${doc.assetReference} · ` : ''}
{doc.workOrderReference ? `${doc.workOrderReference} · ` : ''}
{tailleLisible(doc.size)} ·{' '}
{new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium' }).format(new Date(doc.createdAt))}
{doc.uploadedByName ? ` · ${doc.uploadedByName}` : ''}
</span>
<span style={{ display: 'flex', gap: 8 }}>
<button
type="button"
onClick={() => void ouvrirDocument(doc.id)}
style={{ color: 'var(--primaire)', fontWeight: 600, fontSize: 12.5 }}
>
Ouvrir
</button>
<button
type="button"
onClick={() => void telechargerDocument(doc.id, doc.fileName)}
style={{ color: 'var(--primaire)', fontWeight: 600, fontSize: 12.5 }}
>
Télécharger
</button>
{surSuppression ? (
<button
type="button"
onClick={() => {
if (window.confirm(`Supprimer « ${doc.fileName} » ?`)) surSuppression(doc.id);
}}
style={{ color: 'var(--danger)', fontWeight: 600, fontSize: 12.5 }}
>
Supprimer
</button>
) : null}
</span>
</div>
);
}
/** Carte « Documents » des fiches OT / ascenseur — la même bibliothèque,
* filtrée sur la cible ; l'upload rattache automatiquement. */
export function CarteDocuments({
cible,
peutEditer,
}: {
cible: { assetId: string } | { workOrderId: string };
peutEditer: boolean;
}) {
const { data: documents } = useDocuments(cible);
const upload = useUploadDocument();
const suppression = useDeleteDocument();
const fichierRef = useRef<HTMLInputElement>(null);
const [kind, setKind] = useState<DocumentKind>('workOrderId' in cible ? 'PHOTO' : 'NOTICE');
const surFichier = (file: File | undefined) => {
if (!file) return;
if (file.size > DOCUMENT_MAX_BYTES) {
window.alert('Fichier trop lourd (20 Mo max).');
return;
}
upload.mutate({ file, kind, ...cible });
if (fichierRef.current) fichierRef.current.value = '';
};
return (
<div className="carte">
<h2>Documents</h2>
{documents?.length ? (
<div className="docs" style={{ gridTemplateColumns: 'repeat(2, 1fr)' }}>
{documents.map((d) => (
<VignetteDoc
key={d.id}
doc={d}
surSuppression={peutEditer ? (id) => suppression.mutate(id) : undefined}
/>
))}
</div>
) : (
<p className="text-encre-3">Aucun document rattaché.</p>
)}
{peutEditer ? (
<div className="mt-3 flex items-center gap-2">
<select
aria-label="Type de document"
value={kind}
onChange={(e) => setKind(e.target.value as DocumentKind)}
className="rounded-lg border border-bordure-forte bg-surface p-2 text-encre"
>
{DOCUMENT_KINDS.map((k) => (
<option key={k} value={k}>{DOCUMENT_KIND_LABELS[k]}</option>
))}
</select>
<input
ref={fichierRef}
type="file"
accept="application/pdf,image/jpeg,image/png"
aria-label="Choisir un fichier"
className="hidden"
onChange={(e) => surFichier(e.target.files?.[0])}
/>
<Button disabled={upload.isPending} onClick={() => fichierRef.current?.click()}>
{upload.isPending ? 'Téléversement…' : 'Téléverser (PDF, JPG, PNG · 20 Mo)'}
</Button>
</div>
) : null}
{upload.isError ? <p className="erreur-form">{upload.error.message}</p> : null}
</div>
);
}

View File

@@ -0,0 +1,77 @@
import { divIcon } from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { MapContainer, Marker, TileLayer, Tooltip, useMapEvents } from 'react-leaflet';
import { cn } from '@/lib/cn';
/** Carte Leaflet/OSM (positions PostGIS) — pins au style de la maquette. */
export interface PinGeo {
id: string;
latitude: number;
longitude: number;
libelle: string;
alerte?: boolean;
onClick?: () => void;
}
const CENTRE_CASABLANCA: [number, number] = [33.59, -7.6];
function iconePin(alerte?: boolean) {
return divIcon({
className: '',
html: `<div class="pin-geo${alerte ? ' alerte' : ''}"></div>`,
iconSize: [26, 26],
iconAnchor: [13, 26],
});
}
function ClicCarte({ onClick }: { onClick?: (lat: number, lng: number) => void }) {
useMapEvents({
click(e) {
onClick?.(Number(e.latlng.lat.toFixed(5)), Number(e.latlng.lng.toFixed(5)));
},
});
return null;
}
export function CarteGeo({
pins,
className,
zoom = 11,
centre,
onClick,
}: {
pins: PinGeo[];
className?: string;
zoom?: number;
centre?: [number, number];
onClick?: (lat: number, lng: number) => void;
}) {
const centreEffectif =
centre ??
(pins.length
? ([pins[0]!.latitude, pins[0]!.longitude] as [number, number])
: CENTRE_CASABLANCA);
return (
<div className={cn('geo', className)}>
<MapContainer center={centreEffectif} zoom={zoom} scrollWheelZoom={false}>
<TileLayer
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<ClicCarte onClick={onClick} />
{pins.map((pin) => (
<Marker
key={pin.id}
position={[pin.latitude, pin.longitude]}
icon={iconePin(pin.alerte)}
eventHandlers={pin.onClick ? { click: pin.onClick } : undefined}
>
<Tooltip direction="bottom" offset={[0, 6]}>{pin.libelle}</Tooltip>
</Marker>
))}
</MapContainer>
</div>
);
}

View File

@@ -0,0 +1,66 @@
import {
REQUEST_STATUS_LABELS,
WORK_ORDER_PRIORITY_LABELS,
WORK_ORDER_STATUS_LABELS,
type RequestStatus,
type WorkOrderPriority,
type WorkOrderStatus,
} from '@siop/shared';
import { cn } from '@/lib/cn';
const ST_CLASSES: Record<WorkOrderStatus, string> = {
OPEN: 'ouvert',
IN_PROGRESS: 'encours',
ON_HOLD: 'attente',
DONE: 'termine',
CANCELLED: 'annule',
};
/** Statut OT — les couleurs réservées de la charte. */
export function StatutOT({ statut }: { statut: WorkOrderStatus }) {
return (
<span className={cn('st', ST_CLASSES[statut])}>
{WORK_ORDER_STATUS_LABELS[statut]}
</span>
);
}
const PRIO_CLASSES: Record<WorkOrderPriority, string | null> = {
NONE: null,
LOW: 'basse',
MEDIUM: 'moyenne',
HIGH: 'haute',
PERSON_TRAPPED: 'bloque',
};
/** Priorité — pastille + libellé, jamais la couleur seule (charte). */
export function PrioriteOT({ priorite }: { priorite: WorkOrderPriority }) {
const classe = PRIO_CLASSES[priorite];
if (!classe) return <span className="text-encre-3"></span>;
return (
<span className={cn('prio', classe)}>
<i />
{WORK_ORDER_PRIORITY_LABELS[priorite]}
</span>
);
}
const DEM_CLASSES: Record<RequestStatus, string> = {
RECEIVED: 'recue',
APPROVED: 'approuvee',
REJECTED: 'rejetee',
};
/** Statut de demande — « Résolue » quand l'OT lié est terminé. */
export function StatutDemande({
statut,
otStatut,
}: {
statut: RequestStatus;
otStatut?: WorkOrderStatus | null;
}) {
if (statut === 'APPROVED' && otStatut === 'DONE') {
return <span className="st resolue">Résolue</span>;
}
return <span className={cn('st', DEM_CLASSES[statut])}>{REQUEST_STATUS_LABELS[statut]}</span>;
}

View File

@@ -89,6 +89,12 @@ export const IcoPersonnes = () => (
</svg>
);
export const IcoCategories = () => (
<svg {...base}>
<path d="M4 7h16M4 12h16M4 17h10" />
</svg>
);
export const IcoRecherche = () => (
<svg {...base}>
<circle cx="11" cy="11" r="7" />

View File

@@ -0,0 +1,179 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { SEARCH_MIN_CHARS } from '@siop/shared';
import { useGlobalSearch } from '@/api/recherche';
import { StatutOT } from '@/components/chips-ot';
import { IcoRecherche } from '@/components/nav-icons';
interface Resultat {
cle: string;
route: string;
contenu: React.ReactNode;
}
/** La recherche de la topbar, enfin active (recette R3) : ⌘K pour saisir,
* résultats groupés (OT / ascenseurs / sites), déjà filtrés par la matrice
* côté API — flèches + Entrée pour naviguer. */
export function RechercheGlobale() {
const navigate = useNavigate();
const [saisie, setSaisie] = useState('');
const [q, setQ] = useState('');
const [ouverte, setOuverte] = useState(false);
const [actif, setActif] = useState(0);
const champ = useRef<HTMLInputElement>(null);
// Débounce : on n'interroge l'API que 200 ms après la dernière frappe.
useEffect(() => {
const t = setTimeout(() => setQ(saisie), 200);
return () => clearTimeout(t);
}, [saisie]);
// ⌘K / Ctrl+K : focus depuis n'importe où.
useEffect(() => {
const surTouche = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
champ.current?.focus();
champ.current?.select();
}
};
window.addEventListener('keydown', surTouche);
return () => window.removeEventListener('keydown', surTouche);
}, []);
const { data } = useGlobalSearch(q);
const resultats = useMemo<Resultat[]>(() => {
if (!data) return [];
return [
...data.workOrders.map((w) => ({
cle: `ot-${w.id}`,
route: `/ot/${w.id}`,
contenu: (
<>
<span className="ref num">{w.reference}</span>
<span className="titre">{w.title}</span>
<span className="fin"><StatutOT statut={w.status} /></span>
</>
),
})),
...data.assets.map((a) => ({
cle: `asc-${a.id}`,
route: `/ascenseurs/${a.id}`,
contenu: (
<>
<span className="ref num">{a.reference}</span>
<span className="titre">
{a.brand}
{a.model ? ` ${a.model}` : ''}
</span>
<span className="fin">{a.siteName}</span>
</>
),
})),
...data.sites.map((s) => ({
cle: `site-${s.id}`,
route: `/sites/${s.id}`,
contenu: (
<>
<span className="titre"><b>{s.name}</b></span>
<span className="fin">{s.city ?? ''}</span>
</>
),
})),
];
}, [data]);
const groupes = useMemo(() => {
if (!data) return [];
const g: { libelle: string; debut: number; nombre: number }[] = [];
if (data.workOrders.length) g.push({ libelle: 'Ordres de travail', debut: 0, nombre: data.workOrders.length });
if (data.assets.length) g.push({ libelle: 'Ascenseurs', debut: data.workOrders.length, nombre: data.assets.length });
if (data.sites.length) {
g.push({
libelle: 'Sites',
debut: data.workOrders.length + data.assets.length,
nombre: data.sites.length,
});
}
return g;
}, [data]);
const visible = ouverte && q.trim().length >= SEARCH_MIN_CHARS;
const aller = (r: Resultat | undefined) => {
if (!r) return;
setOuverte(false);
setSaisie('');
navigate(r.route);
};
return (
<div className="recherche" data-active>
<IcoRecherche />
<input
ref={champ}
type="search"
placeholder="Rechercher un OT, un ascenseur, un site…"
aria-label="Recherche globale"
value={saisie}
onChange={(e) => {
setSaisie(e.target.value);
setOuverte(true);
setActif(0);
}}
onFocus={() => setOuverte(true)}
onBlur={() => setOuverte(false)}
onKeyDown={(e) => {
if (e.key === 'Escape') {
setOuverte(false);
champ.current?.blur();
}
if (!visible || !resultats.length) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
setActif((a) => Math.min(a + 1, resultats.length - 1));
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setActif((a) => Math.max(a - 1, 0));
}
if (e.key === 'Enter') {
e.preventDefault();
aller(resultats[actif]);
}
}}
/>
<kbd>K</kbd>
{visible ? (
<div className="resultats" role="listbox" aria-label="Résultats">
{groupes.map((g) => (
<div key={g.libelle}>
<div className="groupe-resultats">{g.libelle}</div>
{resultats.slice(g.debut, g.debut + g.nombre).map((r, i) => (
<button
key={r.cle}
type="button"
role="option"
aria-selected={g.debut + i === actif}
className="resultat"
data-actif={g.debut + i === actif || undefined}
onMouseEnter={() => setActif(g.debut + i)}
onMouseDown={(e) => {
e.preventDefault(); // avant le blur du champ
aller(r);
}}
>
{r.contenu}
</button>
))}
</div>
))}
{!resultats.length ? (
<div className="groupe-resultats">Aucun résultat pour « {q.trim()} »</div>
) : null}
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,13 @@
import { ASSET_STATUS_LABELS, type AssetStatus } from '@siop/shared';
import { cn } from '@/lib/cn';
const CLASSES: Record<AssetStatus, string> = {
IN_SERVICE: 'termine',
OUT_OF_SERVICE: 'arret',
UNDER_MAINTENANCE: 'maintenance',
};
/** Statut d'ÉQUIPEMENT — pastille + libellé, jamais la couleur seule (charte). */
export function StatutEquipement({ statut }: { statut: AssetStatus }) {
return <span className={cn('st', CLASSES[statut])}>{ASSET_STATUS_LABELS[statut]}</span>;
}

View File

@@ -0,0 +1,29 @@
import * as Dialog from '@radix-ui/react-dialog';
import type { ReactNode } from 'react';
/** Modale au style de la maquette, portée par Radix (focus, Échap, overlay). */
export function Modale({
titre,
ouverte,
surFermeture,
children,
}: {
titre: string;
ouverte: boolean;
surFermeture: (ouverte: boolean) => void;
children: ReactNode;
}) {
return (
<Dialog.Root open={ouverte} onOpenChange={surFermeture}>
<Dialog.Portal>
<Dialog.Overlay className="voile-radix" />
<Dialog.Content className="modale" aria-describedby={undefined}>
<Dialog.Title asChild>
<h3>{titre}</h3>
</Dialog.Title>
{children}
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}

Some files were not shown because too many files have changed in this diff Show More