52 Commits

Author SHA1 Message Date
pr-daaif
a79551a4a3 docs(r6): recette Demandeur confirmée sur iPhone physique
Accueil (liste des demandes + statut) et création (sélection dans la
liste ou scan QR) vérifiés en conditions réelles par le référent.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 09:43:02 +01:00
pr-daaif
39320d081e fix(mobile): R6.8 — la connexion routait toujours vers "Ma journée"
connexion.tsx faisait router.replace('/(tabs)/journee') en dur — trouvé
en recette : Karim (Demandeur) atterrissait sur l'écran du Technicien
après avoir basculé de compte démo. La redirection par rôle
(ongletAccueil) n'était branchée qu'à l'aiguillage initial (app/index.tsx,
R6.1), pas au retour de connexion — le chemin réellement emprunté à
chaque bascule de compte (pas de sélecteur de rôle en direct sur mobile,
changer de compte = se déconnecter puis se reconnecter).

useLogin()/useDemoLogin() (auth/session.tsx) renvoient maintenant la
réponse complète (contient user.role.name) au lieu de la jeter après en
avoir extrait le jeton. connexion.tsx : entrer(role) route vers
/(tabs)/${ongletAccueil(role)} pour les deux chemins (connexion classique
et sélecteur démo).

Au passage : sous-titre "Technicien" et accroche "sur le terrain — même
sans réseau" (reste de R4, mobile alors réservé au Technicien) devenus
trompeurs pour les autres rôles depuis R6 — généricisés.

Typecheck/tests/lint verts sur les 4 paquets.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 09:37:12 +01:00
pr-daaif
19076a91d3 docs(r6): périmètre WORK_ORDERS du Gestionnaire confirmé acceptable
Le référent tranche : pas de resserrement demandé, l'accès complet
hérité de R2/R3 reste en l'état.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 20:48:31 +01:00
pr-daaif
ed9833e56d fix(mobile): R6.7 — assignation à un technicien lors de l'approbation
Le Gestionnaire approuvait une demande sur mobile sans pouvoir assigner
l'OT à un technicien (l'OT partait non assigné) — remonté en recette :
"ce n'est pas à lui d'agir comme un technicien". Le web le fait déjà
(PanneauApprobation, demandes.tsx).

PanneauDemandes.tsx : "Approuver → OT" ouvre un panneau avec un ChoixTel
"Assigner à" (techniciens actifs, même filtre que le web —
status active && role.name.startsWith('Technicien')) avant de confirmer.
assigneeIds déjà supporté par l'API et le contrat — aucun changement
serveur nécessaire, seule l'UI mobile manquait cette étape.

Vérifié de bout en bout : demande → approuvée avec technicien assigné →
OT confirmé avec le bon assignee. Typecheck/tests/lint verts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 20:44:21 +01:00
pr-daaif
c9168ece16 feat(r6): R6.6 — Demandeur restreint à son site
Trouvé en recette : GET /assets/options n'avait aucun filtre — tout
Demandeur voyait le parc complet dans le sélecteur d'équipement de
"Nouvelle demande", sur le web ET le mobile (même endpoint partagé).
Risque réel : signaler accidentellement une panne sur un ascenseur qu'on
ne gère pas.

Corrigé à la racine, sur les deux plateformes à la fois :

- Relation many-to-many User↔Location (assignedSites/assignedUsers,
  migration r6_demandeur_sites, même style que Team.members) — vide =
  aucune restriction, comportement historique inchangé pour tous les
  rôles sauf un Demandeur affecté à un site.
- AssetsService.allowedLocationIds(user) : sites + zones filles
  autorisés, ou null si aucune restriction — réutilisée par options()
  ET par RequestsService.create (défense en profondeur : un assetId
  soumis directement hors périmètre est rejeté, 400).
- UsersService : assertTopLevelSites (un Demandeur est affecté à un
  site, jamais une zone) ; invite()/update() branchent locationIds
  (remplace l'affectation, comme teamIds).
- Web (personnes.tsx) : ModaleInvitation affiche les sites à cocher pour
  un rôle Demandeur ; colonne "Sites" éditable via une modale dédiée.
- Mobile (formulaire-demande.tsx) : bouton "Scanner l'étiquette" en
  raccourci — résout uniquement contre les options déjà chargées (déjà
  filtrées), jamais de repli sur le parc complet qui annulerait la
  restriction. Aucun changement à useAssetOptions() : le filtrage
  serveur profite automatiquement au formulaire mobile.
- Seed : Karim Doukkali (démo) rattaché à Tour Atlas.

Bug trouvé en vérification avant tout commit : create() comparait
allowed.includes(dto.assetId), mais allowed est une liste d'ids de
sites/zones, pas d'ids d'appareils — aurait rejeté à tort tout
signalement d'un Demandeur affecté, y compris dans son propre périmètre.
Corrigé (comparaison sur asset.locationId) ; méthode renommée
allowedAssetIds → allowedLocationIds pour que le nom dise ce qu'elle
retourne.

exploitation.e2e-spec.ts mis à jour (A1/C1 → A2/B1, dans le site de
Karim — sinon rejetés par la nouvelle règle, comportement voulu).
79/80 tests API verts, le seul échec (documents-analytics, monthCost)
est le flake calendaire déjà identifié cette session, sans rapport.
Typecheck/tests/lint verts sur les 4 paquets.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 20:22:50 +01:00
pr-daaif
1ae0529a6a docs(r6): confirmation référent — dictée Assistant validée sur iPhone
Trois corrections successives (expo-file-system legacy, zone de relecture
multiligne, chevauchement ScrollView/TextInput) validées en conditions
réelles — consignées, aucune n'aurait été détectée par
typecheck/tests/lint seuls.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 13:30:23 +01:00
pr-daaif
9aaee4e358 fix(mobile): Assistant — bouton Envoyer chevauchait encore le texte dicté
Deux causes cumulées : le ScrollView du chat n'avait pas style={flex:1}
(seul contentContainerStyle était posé), donc pas correctement borné dans
son parent flex — pouvait empiéter sur le composeur du dessous. Et le
TextInput multiligne utilisait maxHeight seul, qu'iOS ignore parfois en
laissant le champ grandir avec le contenu au lieu de défiler en interne.

Fix : ScrollView proprement borné (style={flex:1}) ; TextInput à hauteur
FIXE (96) + scrollEnabled, qui garantit le défilement interne au-delà
plutôt qu'une dépendance à un maxHeight pas toujours respecté.

Typecheck propre, 17 tests Jest, lint 5/5 paquets.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 13:23:54 +01:00
pr-daaif
63656565f7 fix(mobile): Assistant — question dictée pleinement visible avant envoi
Retour de recette (02/08) : la barre de saisie sur une ligne masquait le
texte transcrit dès qu'il dépassait quelques mots, empêchant la relecture
avant envoi (D5 de la maquette Assistant). Remplacée par une zone
multiligne pleine largeur (jusqu'à 160px, défilement interne au-delà) —
même patron que la description de clôture (cloture.tsx) — avec "Envoyer"
comme geste séparé et délibéré en dessous, pas accolé au champ.

Typecheck propre, 17 tests Jest, lint 5/5 paquets.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 13:21:38 +01:00
pr-daaif
6f74fd4206 fix(mobile): expo-file-system deleteAsync — import legacy subpath
SDK 57 deprecates the module-level functions (deleteAsync included) in
favor of the File/Directory classes, and now throws at runtime in dev
instead of just warning — surfaced as a red-box crash on the Assistant
screen right after transcription (found in recette, 02/08). The dictée
purge in cloture.tsx uses the exact same call and was silently affected
too. Fix: import from 'expo-file-system/legacy' in both — same function
signature, no behavior change, just the non-deprecated entry point.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 13:12:44 +01:00
pr-daaif
be5dfb6bc7 fix(r6): recette Gestionnaire — Menu non scrollable, BC sans confirmation
Deux bugs réels trouvés sur iPhone physique par le référent :

1. app/(tabs)/menu.tsx n'était pas scrollable (View, pas ScrollView) —
   avec les 4 groupes désormais pleinement câblés (12 liens + 4 en-têtes),
   le contenu dépasse la hauteur de l'écran et le dernier groupe
   (Pilotage, dont Personnes & équipes) était strictement inaccessible.
   Présent depuis R6.1, seulement révélé maintenant. Fix : ScrollView
   autour de la liste des groupes, en-tête fixe au-dessus.

2. app/stock/[id]/commander.tsx naviguait silencieusement en arrière
   après succès — le référent a créé deux BC (vérifiés corrects côté
   serveur) sans aucun retour dans l'app. Fix : écran de confirmation
   explicite (référence, fournisseur, total, statut) avant de revenir.

Amélioration sur retour direct : Tiers gagne une fiche détail
(app/tiers/[id].tsx — identité, contact, BC en cours, sites rattachés,
aucune nouvelle requête) — le lecture seule sans réaction au tap se
lisait comme un écran cassé plutôt que délibérément simple.

Typecheck propre, 17 tests Jest, lint 5/5 paquets.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 12:59:40 +01:00
pr-daaif
ac8b92e9c5 feat(r6): R6.5 — Assistant mobile (chat sourcé + dictée)
Maquette dédiée validée (docs/02-design/maquettes/maquette-mobile-assistant.html,
3 écrans) — la seule sous-étape de R6 à avoir eu son propre tour de
design-first plutôt qu'une exécution directe de la maquette R6 initiale,
un chat sourcé étant un patron d'écran neuf. D1 un échange à la fois ; D2
"Voir le document" → métadonnées seules (R6.3), "Ouvrir l'OT" → fiche R4 ;
D3 refus chiffré + avertissement permanents, identiques au web ; D4
Assistant rejoint Pilotage pour tout rôle avec WORK_ORDERS.view ; D5
(ajoutée en revue) — la question peut être tapée OU dictée, même pipeline
que la dictée déjà livrée en clôture (ADR-004 §5, faster-whisper local,
audio jamais persisté), la transcription remplit le champ, jamais d'envoi
automatique.

api/assistant.ts (useAskAssistant, 503 géré comme le web). Écran Assistant :
chat un échange à la fois, citations numérotées, sources avec extrait
exact, refus honnête chiffré + Reformuler. Micro dans la barre de saisie :
réutilise telle quelle la mécanique de cloture.tsx (useAudioRecorder,
setAudioModeAsync, useTranscription, purge quoi qu'il arrive) — pipeline
déjà recetté sur iPhone physique (01/08), aucun nouveau bug à découvrir.

Fiche document (bibliotheque/[id].tsx, nouveau) — la liste R6.3 y mène
aussi désormais. Menu : Assistant route réellement — le Menu R6 n'a plus
d'entrée "à venir" dans les 4 groupes (Catégories exceptée, admin, hors
périmètre mobile).

R6 fonctionnellement complet (Exploitation/Parc/Ressources/Pilotage,
Assistant inclus, pour tous les rôles) — reste une recette complète avant
de considérer R6 close au sens R0→R5.

Typecheck propre, 17 tests Jest, lint 5/5 paquets. Contrat non touché.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 12:18:25 +01:00
pr-daaif
700bd4e7ff docs(r6): confirmation référent — nav adaptative + déconnexion sur iPhone
Dernier point ouvert depuis le début de R6 (R6.1), clos. Reste avant de
considérer R6 close au même sens que R0→R5 : une recette plus complète
des sous-étapes R6.2/R6.3/R6.4, pour l'instant seulement vérifiées par
typecheck/tests/lint, jamais en usage réel.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 11:56:44 +01:00
pr-daaif
4c1a2d67a0 feat(r6): R6.4 — Pilotage sur mobile (Statistiques, Personnes)
EXPO_PUBLIC_WEB_URL/WEB_URL pour le lien d'activation (pointe la page web
/activation?token=…, aucun équivalent mobile). api/pilotage.ts
(useAnalyticsSummary, useUsers, useRoles, useInviteUser).

Écran Statistiques (maquette écran 7) : période 3/6/12 mois, coût du mois,
taux préventif, OT clôturés, pannes par organe, top équipements — cartes
plutôt que graphes denses (D4).

Écran Personnes & équipes : liste + statut, Inviter (nom/e-mail/rôle),
lien d'activation en texte sélectionnable (Text selectable, RN natif) —
expo-clipboard est une dépendance native absente, différée comme
expo-sharing en R6.3 plutôt qu'ajoutée à la légère. Taux horaire/rôles/
équipes restent gérés au web (D4).

Assistant reste "à venir" dans le Menu : un chat sourcé est un nouveau
patron d'écran jamais maquetté sur mobile, contrairement aux autres
familles qui réutilisaient des patrons déjà validés — mérite son propre
tour de design-first plutôt qu'être exécuté par réflexe en fin de release.

R6 quasi close (Exploitation/Parc/Ressources/Pilotage livrés pour tous les
rôles, hors Assistant) — reste la confirmation du référent sur iPhone
(R6.1 + correctif déconnexion) avant clôture réelle.

Typecheck propre, 17 tests Jest, lint 5/5 paquets. Contrat non touché.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 11:43:57 +01:00
pr-daaif
f210d4f801 feat(r6): R6.3 — Ressources sur mobile (Stock, Tiers, Fichiers)
api/ressources.ts (usePartners, useParts/usePart, useCreatePurchaseOrder,
useDocuments). Écran Stock (sous-seuil en tête) + fiche pièce + "Commander"
pré-rempli en une ligne (fournisseur figé, quantité = manquant jusqu'au
seuil, prix = dernier connu — BC multi-lignes détaillé réservé au web, D4).
Tiers en lecture seule (création/édition réservées au web). Fichiers en
métadonnées seules — l'ouverture demande expo-sharing (dépendance native
absente, donc un nouveau build natif) : différée explicitement plutôt
qu'ajoutée à la légère au milieu de cette passe.

Menu : Stock & achats / Tiers / Fichiers routent réellement.

Typecheck propre, 17 tests Jest, lint 5/5 paquets. Contrat non touché,
toutes les opérations utilisées existaient déjà depuis R3.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 11:35:55 +01:00
pr-daaif
21dd6cf034 feat(r6): R6.2 — Parc sur mobile (Sites, Ascenseurs)
useLocations() mobile ; écran Sites (premier niveau) ; fiche site
(identité, zones, ascenseurs du site — consultation seule, D4, pas de
carte ni d'édition sur mobile pour l'instant) ; écran Ascenseurs (parc
complet déjà préchargé, D1) ouvrant la fiche appareil R4 telle quelle
(déjà générique, aucune modification nécessaire). Menu : Ascenseurs/Sites
routent réellement au lieu de « à venir » ; Catégories reste à venir
(admin, hors périmètre R6.2).

Exécution directe de la maquette R6 déjà validée, patrons visuels
réutilisés (Carte/LigneInfo/EnteteFiche) — pas de nouveau tour de design.

Typecheck propre, 17 tests Jest, lint 5/5 paquets.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 11:27:50 +01:00
pr-daaif
ab0f260d0f feat(r6): mobile ouvert à tous les rôles — maquette + socle R6.1
R4 avait délibérément fermé le mobile au Technicien. Le référent demande
l'ouverture à tous les rôles avec les mêmes droits qu'au web — nouvelle
release R6 (R4 est close, R4.1/4.2/4.3 déjà pris par le socle mobile
technicien d'origine, pas de réouverture ni de collision de numérotation).

Maquette (docs/02-design/maquettes/maquette-mobile-tous-roles.html, 7
écrans) validée par le référent : D1 barre d'onglets adaptative par rôle
(Technicien/Technicien limité inchangés + onglet Menu ajouté) ; D2 le Menu
reprend à l'identique les 4 groupes du web, même matrice de permissions ;
D3 groupe sans lien visible masqué en entier ; D4 le mobile porte les
actions courantes par famille, pas les flux de gestion les plus denses ;
D5 aucune logique de permission propre au mobile.

R6.1 — socle : usePermissions() mobile calqué sur le web ; barre d'onglets
adaptative (ongletsVisibles par rôle, href:null masque sans retirer du
navigateur) ; Accueil (dashboard réel pour Administrateur/Gestionnaire/
Dispatcher/Vue seule, aucun chiffre inventé) ; OT (liste complète,
viewOther déjà géré serveur, réutilise la fiche OT R4 telle quelle) ; Menu
(groupes filtrés, vide masqué) ; Demandes (PanneauDemandes — un seul
composant pour tous les rôles : création/suivi Demandeur, approbation/
rejet Gestionnaire/Dispatcher/Administrateur, lecture Vue seule).
Familles pas encore portées : écran « à venir » plutôt qu'un lien mort.

Typecheck propre, 17 tests Jest, lint 5/5 paquets.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 11:19:44 +01:00
pr-daaif
3e9e708116 fix(mobile): déconnexion accessible depuis tous les onglets
Bug remonté par le référent : une fois authentifié, impossible de revenir en
arrière ou de se déconnecter. Seule « Ma journée » portait ce contrôle
(onLongPress non découvrable) ; les 3 autres onglets n'avaient rien.

EnteteTabs() factorise l'entête (composants/ui.tsx) : tap simple + confirmation
Alert, rôle affiché = celui du compte connecté (plus de libellé "Technicien"
figé — le mobile n'est plus réservé aux techniciens). Réutilisée dans les 4
onglets.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 22:09:17 +01:00
pr-daaif
4af3f5668a fix(mobile): dictée validée sur iPhone physique — 2 bugs réels corrigés
setAudioModeAsync({allowsRecording:true}) manquant avant recorder.record()
(RecordingDisabledException iOS) ; EXPO_USE_PRECOMPILED_MODULES/
RCT_USE_PREBUILT_RNCORE jamais persistés dans .env (documentés dans ADR-005
mais seulement exportés en shell ad-hoc) — corrigé pour que tout rebuild
depuis zéro ne reproduise plus le crash dyld. Recette terrain complète
vérifiée : dictée → note → clôture → réindexation → recherche sémantique.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 22:09:04 +01:00
pr-daaif
59ed6f3952 feat(r5): dictée — audio local (faster-whisper) → note → corpus (ADR-004 §5)
Écran Voix R5 (maquetté, jamais construit) implémenté sur décision du
référent : open-source et local, pas d'API externe.

- apps/ai : faster-whisper (CTranslate2, CPU, MIT) opt-in
  (AI_TRANSCRIPTION=off|locale|deterministe, défaut off) ; endpoint
  /internal/transcrire — l'audio ne survit JAMAIS à l'appel (fichier
  temporaire supprimé quoi qu'il arrive) ; indexer_bilans inclut
  désormais InterventionReport.note anonymisée (champ existant depuis
  R2, jamais eu d'écran jusqu'ici) ; 29 pytest.
- Contrat (77 opérations) : POST /assistant/transcribe (multipart).
- API : proxy multipart vers siop2-ai (WORK_ORDERS.edit — même droit
  que la saisie du bilan) ; 2 tests e2e (80 tests API au total).
- Mobile : expo-audio + expo-file-system, bouton dicter/terminer sur
  l'écran de clôture, purge locale après transcription, « Joindre la
  description à l'OT » (corrige un bug latent : enfilerBilan ignorait
  silencieusement les mises à jour de note).
- Docker : siop2-ai embarque le modèle Whisper au build (1,54→2,19 Go),
  construit et vérifié (transcription réelle en conteneur, non-root).
- Vérifié réellement : transcription fidèle (voix de synthèse
  française) en direct, bout en bout via l'API, dans le conteneur
  Docker construit, et chaîne corpus complète (note → clôture →
  réindexation → recherche sémantique).
- Base de dev locale réinitialisée avec accord explicite du référent
  (prisma migrate reset, bloqué par défaut pour un agent IA) après
  pollution par les tests manuels de la recette terrain précédente.

Reste : test tactile sur iPhone physique (bouton dicter) — bloqué par
une connexion USB qui ne s'est pas rétablie malgré câble/port/
redémarrage essayés à plusieurs reprises, reporté comme la recette
Android.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 11:55:47 +01:00
pr-daaif
0730bf9dad docs(r5): amender l'écran Voix — la transcription alimente aussi le corpus
L'écran 6 (dictée, jamais construite — option « si budget temps ») dit
maintenant explicitement que la description relue et jointe à l'OT
rejoint le corpus de l'assistant à la clôture, anonymisée comme les
bilans codés. Idée discutée avec le référent (transcription locale via
faster-whisper envisagée, moteur/calibrage tranchés dans le détail),
maquette amendée et validée — implémentation non demandée pour
l'instant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 10:44:03 +01:00
pr-daaif
189e8e8162 feat(mobile): recette terrain iPhone 15 Pro — 7/7 validés (ADR-005)
Expo Go (App Store) bloqué par le retard d'approbation Apple (SDK 54
contre notre SDK 57) — décision : builds natifs locaux (Xcode/Android
Studio, signature Apple ID personnel gratuite) pour la vraie recette
terrain, Expo Go conservé pour l'aperçu sans installation ailleurs.

- ADR-005 : deux canaux de distribution, managed workflow conservé.
- apps/mobile/.env : EXPO_PUBLIC_USE_RN_FETCH=1 — fix permanent d'un
  bug SDK 57 (fetch global expo/fetch incompatible avec l'upload
  multipart natif {uri,name,type}, classé à tort « réseau
  indisponible » par notre propre gestion d'erreur).
- package.json/app.json : scripts ios/android pointés sur les builds
  natifs (expo run:*) plutôt que expo start (Expo Go), identifiants
  de bundle générés par le prebuild.
- Recette iOS (iPhone 15 Pro physique) : connexion démo, scan caméra
  réel (résolution locale + rejet QR étranger), vrai mode avion (D1),
  conflit de version tranché par l'humain avec photo réellement
  téléversée (D2/D5), persistance de la file à travers fermeture et
  reconstruction de l'app, suggestions R5 au vrai modèle, purge de
  sécurité au changement de compte (ADR-003) — 7/7 validés, la
  validation terrain due depuis release/r4 est levée pour iOS.
- Android : build natif sur émulateur, passage santé complet
  (connexion, navigation, écran Scanner, résolution de référence).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 20:12:48 +01:00
pr-daaif
557435b81a docs(r5): clôture — journal et état d'avancement pour le tag release/r5
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 21:57:56 +01:00
pr-daaif
cccfaaabc9 feat(r5): bibliothèque-corpus en tableau (arbitrage de recette du référent)
- La liste des documents passe en tableau, conforme à l'écran 5 de
  maquette-r5 (la bibliothèque sera surtout des PDF) : Document,
  Rattaché à, Indexation, Corpus (ASSETS.edit), actions.
- Les vignettes restent aux cartes « Documents » des fiches OT/ascenseur
  (photos) ; StatutCorpus/InterrupteurCorpus extraits en composants.
- Une image non indexable affiche son interrupteur éteint quel que soit
  l'état stocké — l'interrupteur montre la réalité du corpus.
- e2e adapté aux lignes de tableau, 16/16 Playwright rejoués ; artefact
  de revue pixel mis à jour (arbitrages rendus).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 21:40:28 +01:00
pr-daaif
b69c54ed0f feat(r5): recette sans clé API + durcissement production siop2-ai
Recette (mode extractif, aucune clé) — elle a invalidé le modèle R5.1 :
- MiniLM-384 classait la page-réponse DERRIÈRE des passages sans rapport
  (0,24 vs 0,41 sur la question type du plan) → bascule mesurée vers
  paraphrase-multilingual-mpnet-base-v2 (768 d, local/CPU), ADR-004 amendé
  avec le banc comparatif (e5-large écarté : 2,2 Go, scores compressés).
- Migration r5_embeddings_mpnet : pgvector 384 → 768, index vidé
  (re-dérivable par « Réindexer tout »).
- Découpage affiné (~350 caractères) : la phrase-réponse ne se noie plus,
  l'extrait cité est lisible ; seuils par défaut recalés 0,45/0,40/0,55.
- Rejouée après bascule : réponse sourcée p. 2 en tête, refus honnête
  chiffré, suggestions étagées — 16/16 Playwright, 78 API, 23 pytest.
- Revue pixel publiée (6 écrans réels vs maquettes, 3 arbitrages).

Durcissement :
- apps/ai/Dockerfile : uv, modèle ONNX téléchargé AU BUILD (ADR-004 §1),
  non-root, healthcheck ; répétition locale conteneurisée validée
  (healthz, reindex via MinIO/pgvector, 401 sans jeton, refus de boot
  api-sans-clé, réponse sourcée depuis le conteneur).
- Compose Dokploy : siop2-ai interne (jamais sur dokploy-network,
  AI_SERVICE_TOKEN requis, génération opt-in, seuils par env) ;
  siop2-api branché (AI_SERVICE_URL).
- Runbook §5-6 : service IA en production, calibrage des seuils sur le
  corpus client, réindexation post-déploiement.

Le tag release/r5 attend la validation de la revue pixel par le référent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 21:24:33 +01:00
pr-daaif
28eecc1fb9 feat(r5.3): écrans IA — assistant web, corpus administrable, suggestions OT/mobile
- Contrat (76 opérations) : Document expose inCorpus/indexedAt/chunkCount,
  PATCH /documents/{id}/corpus (ASSETS.edit), POST /assistant/reindex
  (bilan chiffré) ; ci-contract vérifie désormais aussi le client mobile.
- Web : page /assistant (chat sourcé — extraits exacts cités, Ouvrir vers
  PDF authentifié ou fiche OT, avertissement permanent ; refus honnête
  chiffré avec action utile) ; Bibliothèque = corpus (bandeau 09-08,
  statut d'indexation par document, interrupteur d'exclusion PDF,
  Réindexer tout) ; fiche OT : « Décrire pour suggérer » (Appliquer =
  geste humain, liseré « suggéré » retiré au choix manuel).
- Mobile : chips de suggestion dans la clôture (un appui = un champ
  pré-rempli, « réseau requis » hors-ligne — la file R4 n'en dépend pas).
- apps/ai : seuils AI_SEUIL_* configurables par env (CI + calibrage).
- CI e2e : service siop2-ai (embeddeur déterministe, seuils calibrés sur
  mesures : match 0,66 vs bruit 0,11) + parcours R5 Playwright (PDF généré
  xref valide → réindexation → réponse sourcée → refus → suggestion).
- Vérifié : 16/16 Playwright, 78 tests API, 23 pytest, 17 jest-expo ;
  chaîne réelle au vrai modèle ONNX (web 7/7, mobile Expo web 6/6).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 17:26:22 +01:00
pr-daaif
45ae491827 feat(r5.2): assistant au contrat + suggestion de codes de bilan
apps/ai : /internal/ask — seuil de pertinence, extraits sourcés ou
refus honnête portant la taille du corpus cherché (D2), rédaction via
le Generateur opt-in ; /internal/suggest — similarité sémantique entre
la description libre et les libellés ACTIFS des référentiels, un code
par champ, confiance FORTE/MOYENNE, « N bilans similaires sur ce
parc ». Sans LLM : déterministe, explicable. 23 pytest.

Contrat (74 opérations) : POST /assistant/ask → AssistantAnswer
(EXTRACTIVE/GENERATED/REFUSAL, extraits cités, corpus cherché) et
POST /assistant/suggest-bilan (codes existants seulement) ; clients
web/mobile régénérés.

API NestJS : module assistant — proxy vers siop2-ai (AI_SERVICE_URL/
AI_SERVICE_TOKEN, ADR-004 §4), permissions matrice (ask=view,
suggest=edit), traduction interne→contrat, 503 propre si service
éteint. 6 e2e sur stub HTTP (76 tests API).

Bug débusqué par la vraie chaîne : fastembed ne norme pas ses
vecteurs — la similarité des suggestions dépassait 1 (pgvector
normalisait dans son opérateur, masquant l'écart). Normalisation à
l'encodage + réindexation : bilans en tête (0.41), refus hors corpus,
scores cosinus ≤ 1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 15:05:08 +01:00
pr-daaif
76c2ccdfb1 feat(r5.1): clé API de génération configurable (ADR-004 §3 en actes)
Demande du référent : la génération opt-in devient réellement
configurable. AI_GENERATION=off|api, AI_API_KEY (exigée en mode api —
le boot refuse sinon, jamais loguée, /healthz n'expose que le mode),
AI_MODEL (défaut claude-opus-4-8).

generation.py : interface Generateur — GenerateurExtractif (contrat de
base sans LLM) et GenerateurAPI (SDK officiel anthropic, dépendance
optionnelle --extra generation, absente des tests/CI). Consigne :
citations [n] obligatoires depuis les extraits anonymisés, jamais
d'invention, rappel de validation humaine. Tout échec (refus du
modèle, quota, réseau) retombe silencieusement sur l'extractif.

5 tests ajoutés (19 pytest) + .env.example. Vérifié en réel : boot
refusé api-sans-clé, générateur construit, healthz sans secret.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 14:38:36 +01:00
pr-daaif
d4d73a6f76 fix(ci): PostgreSQL de CI = l'image infra (pgvector + PostGIS)
La migration r5_ia exige pgvector, absent de postgis/postgis — la
bascule annoncée en R1 (« pgvector arrivera en R5 ») est faite : les
jobs api et e2e construisent infra/postgres et le démarrent par étape
(les services GitHub ne savent pas builder). La migration crée aussi
l'extension elle-même (idempotent, même patron que PostGIS en r1).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 12:27:03 +01:00
pr-daaif
837dcba1db feat(r5.1): socle apps/ai — ingestion anonymisée + recherche sémantique
ADR-004 : embeddings locaux sur CPU (fastembed ONNX,
paraphrase-multilingual-MiniLM-L12-v2, 384 dims — les textes du client
ne quittent jamais le serveur), pgvector dans le Postgres existant
(RagChunk possédé par Prisma, migration r5_ia + état de corpus sur
Document), génération opt-in (mode extractif par défaut : la recette
passe sans clé API), service siop2-ai jamais exposé — joint par l'API
NestJS seule (X-Service-Token).

apps/ai (FastAPI + uv) : pipeline PDF MinIO → texte paginé (pypdf) →
anonymisation D4 (e-mails, téléphones marocains, noms connus de la
base, insensible casse/accents — fonction pure testée) → découpage
avec chevauchement (testé) → embeddings → RagChunk localisé (« p. 42 »,
« bilan du 17/07 »). Bilans codés clôturés ingérés. Exclusion de
corpus (D3) appliquée à l'ingestion ET à la lecture.

14 pytest + ruff, embeddeur déterministe en CI (aucun téléchargement),
job CI ai (uv), deploy en dépend. Vérifié en réel avec le vrai modèle :
corpus seedé réindexé en 7 s (PDF réel → 31 extraits paginés + 3
bilans), recherche sémantique concluante, e-mails → ⟨contact⟩,
0 identité dans les chunks (contrôle SQL).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 12:22:15 +01:00
pr-daaif
199fce69d0 feat(r5): maquettes HD IA — 6 écrans à valider, l'IA propose, l'humain valide
Design d'abord : aucune ligne de code apps/ai avant validation.
Assistant RAG sourcé (citations numérotées → document/page/extrait
exact, ouvrables), refus explicite hors corpus (anti-hallucination
visible), suggestion de codes de bilan web + mobile (codes existants
seulement, pré-remplissage « suggéré », enregistrement humain), corpus
& ingestion administrable (bibliothèque R3 + bilans codés, exclusion
réversible, bandeau 09-08), voix en option (opt-in, transcription
relue, audio purgé immédiatement). Bi-thème, tokens répliqués.

5 décisions soumises avec les écrans : aucune écriture automatique,
sourcé ou silencieux, corpus fermé et visible, anonymisation à
l'ingestion (09-08), voix purgée. ADR-004 (modèles/embeddings) au
lancement du socle R5.1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 11:37:28 +01:00
pr-daaif
a5e8ca6d62 docs(r4): clôture — tag release/r4, recette téléphone reportée (actée)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 11:31:54 +01:00
pr-daaif
c8b3c1769a feat(r4.3): file d'écriture, verrou optimiste D2, Synchro & conflits
Serveur : updatedAt exposé au détail OT ; baseUpdatedAt optionnel sur
transition/coche/bilan → 409 « Conflit de version » contextualisé (qui,
quand) ; toute écriture secondaire (coche, bilan, commentaire, conso,
MO) fait avancer la version — sans version fournie, le web est
inchangé. ADR-003 : sécurité & protocole de routage mobile (qui vit où
sur l'appareil, purge complète à la déconnexion — correctif réel : la
file et le cache persisté survivaient au logout).

Mobile : file persistée AsyncStorage rejouée dans l'ordre — succès →
propagation de la version fraîche aux saisies restantes du même OT
(nos écritures ne se conflictent pas entre elles, un écart étranger
reste détecté) ; coupure → tout attend ; refus → CONFLIT, la file
s'arrête, l'humain tranche (voir l'OT / rejouer sur version à jour /
abandonner). Transitions, coches, bilan et photos (D5, compressées
~1600 px) passent par la file avec patch optimiste du cache ; écran
Synchro (badge tabbar ambre/rouge) ; préchargement parc + référentiels
(le bilan hors-ligne a ses vocabulaires).

Recette « mode avion » 13/13 en Expo web piloté : gestes hors-ligne →
3 en file → modification concurrente de Salma → conflit tranché →
serveur Terminé avec bilan. 17 tests jest-expo, 74 tests API.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 09:50:02 +01:00
pr-daaif
a22ea60f83 feat(r4.2): terrain mobile — scan QR, fiches ascenseur/OT, clôture, grille
Scanner expo-camera (QR seulement) : analyseScan testée — URL portail
…/q/REF des étiquettes A6 ou référence tapée, QR étrangers refusés ;
résolution D4 dans le parc en cache d'abord (sous-sol inclus), message
honnête sinon. Fiche ascenseur en consultation (D3, historique scopé
« voir autre »). Fiche OT : un bouton principal selon la machine à
états, coûts figés, garde alimentée par closureBlockers (source unique
API). Clôture terrain : bilan codé 6 champs au pouce (sélecteur plein
écran), garde visible, clôture en ligne. Préventif : mes grilles →
checklist cochable, appui long = N/A, aria-checked (leçon RN web :
accessibilityState.checked ne produit pas aria-checked).

Écritures en ligne assumées en R4.2 (bandeaux hors-ligne explicites) —
la file persistée et le verrou optimiste sont R4.3 (D1/D2).

Vérifié 12/12 en Expo web piloté : scan → fiche → OT-0341 (505 MAD,
garde), coche/décoche restituée, OT de test Démarrer → bilan → Terminé,
zéro erreur console, données purgées. 12 tests jest-expo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 03:47:04 +01:00
pr-daaif
514fc7c391 fix(ci): job mobile — « -- --ci » devenait un motif Jest (0 test trouvé)
Jest reçoit tout ce qui suit « -- » comme pattern positionnel ; sur
GitHub Actions CI=true suffit, Jest le détecte seul.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 03:16:38 +01:00
pr-daaif
5c8d5aac92 feat(r4.1): socle mobile Expo — connexion démo, Ma journée, lecture hors-ligne
apps/mobile (Expo SDK 57, TS strict, workspace pnpm) : connexion
e-mail/mdp + sélecteur démo ADR-002 (uniquement si l'API l'expose),
tabbar de la maquette validée (onglets futurs marqués R4.2/R4.3),
« Ma journée » triée priorité puis échéance (fonction pure testée),
urgence en tête, pastille de synchro.

D1 en actes (lecture) : cache TanStack persisté dans AsyncStorage
(7 jours), NetInfo → onlineManager + bandeau hors-ligne horodaté ;
jeton en SecureStore ; tokens light/dark répliqués et testés ; client
typé régénéré depuis docs/openapi.json (règle d'or).

API : CORS_ORIGINS opt-in (vide par défaut) — Expo web/debug seulement,
le web de prod reste derrière le proxy, les apps natives n'ont pas
d'Origin.

Vérifié 10/10 en Expo web piloté (connexion démo Ahmed → Ma journée
scopée → hors-ligne servie du cache → retour) ; 6 tests jest-expo ;
lint racine étendu (react-hooks) ; job CI mobile, deploy en dépend.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 03:12:34 +01:00
pr-daaif
2736a2f3a1 feat(r4): maquettes HD mobile technicien — 7 écrans offline-first à valider
Design d'abord : aucune ligne de code Expo avant validation. Ma journée
(en ligne / hors-ligne côte à côte), fiche OT, clôture terrain (bilan
codé R2, garde visible, variante « en file »), scan QR des étiquettes
A6 (R1, hors-ligne inclus), fiche ascenseur, checklist du mois (coches
individuelles en file), synchro & conflits (verrou optimiste, l'humain
tranche). Tokens répliqués, bi-thème, données du parc seedé.

5 décisions soumises avec les écrans : offline-first en file, verrou
optimiste, périmètre fermé technicien, scan résolu en local, photos
compressées en file — ni audio ni géolocalisation en R4 (loi 09-08).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 02:37:18 +01:00
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
242 changed files with 47364 additions and 145 deletions

View File

@@ -44,14 +44,15 @@ jobs:
cache: pnpm cache: pnpm
- run: pnpm install --frozen-lockfile - run: pnpm install --frozen-lockfile
- run: pnpm --filter @siop/shared build - run: pnpm --filter @siop/shared build
- name: Régénérer la spec et le client typé - name: Régénérer la spec et les clients typés
run: | run: |
pnpm --filter @siop/shared contract pnpm --filter @siop/shared contract
pnpm --filter @siop/web generate:client pnpm --filter @siop/web generate:client
pnpm --filter @siop/mobile generate:client
- name: Vérifier qu'aucun artefact ne dérive du contrat - name: Vérifier qu'aucun artefact ne dérive du contrat
run: | run: |
if ! git diff --exit-code -- docs/openapi.json apps/web/src/api/schema.d.ts; then if ! git diff --exit-code -- docs/openapi.json apps/web/src/api/schema.d.ts apps/mobile/src/api/schema.d.ts; then
echo "::error::Contrat désynchronisé — régénérez spec et clients dans le même commit (pnpm contract && pnpm --filter @siop/web generate:client)." echo "::error::Contrat désynchronisé — régénérez spec et clients dans le même commit (pnpm contract && pnpm --filter @siop/web generate:client && pnpm --filter @siop/mobile generate:client)."
exit 1 exit 1
fi fi
@@ -59,18 +60,8 @@ jobs:
name: api (tests + couverture ≥ 70 %) name: api (tests + couverture ≥ 70 %)
runs-on: ubuntu-latest runs-on: ubuntu-latest
services: services:
# R1+ : la migration r1_referentiel exige PostGIS (colonne générée geography). # PostgreSQL vient d'infra/postgres (pgvector + PostGIS — R5) : les
# pgvector arrivera en R5 (bascule alors sur limage infra/postgres). # services ne savent pas builder, il démarre donc par étape ci-dessous.
postgres:
image: postgis/postgis:18-3.6
env:
POSTGRES_USER: siop
POSTGRES_PASSWORD: siop
POSTGRES_DB: siop
ports: ['5432:5432']
options: >-
--health-cmd "pg_isready -U siop -d siop"
--health-interval 5s --health-timeout 3s --health-retries 10
redis: redis:
image: redis:7.4-alpine image: redis:7.4-alpine
ports: ['6379:6379'] ports: ['6379:6379']
@@ -81,8 +72,25 @@ jobs:
DATABASE_URL: postgresql://siop:siop@localhost:5432/siop DATABASE_URL: postgresql://siop:siop@localhost:5432/siop
REDIS_URL: redis://localhost:6379 REDIS_URL: redis://localhost:6379
JWT_SECRET: ci-only-secret-0123456789abcdef JWT_SECRET: ci-only-secret-0123456789abcdef
MINIO_ENDPOINT: localhost
MINIO_ACCESS_KEY: siop
MINIO_SECRET_KEY: siop-minio
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v5
- name: Démarrer PostgreSQL (image infra — pgvector + PostGIS, comme partout)
run: |
docker build -t siop2/postgres infra/postgres
docker run -d --name postgres -p 5432:5432 \
-e POSTGRES_USER=siop -e POSTGRES_PASSWORD=siop -e POSTGRES_DB=siop \
siop2/postgres
for i in $(seq 1 30); do
docker exec postgres pg_isready -U siop -d siop && break; sleep 2;
done
- name: Démarrer MinIO (l'image officielle exige une commande — pas un service)
run: |
docker run -d --name minio -p 9000:9000 \
-e MINIO_ROOT_USER=siop -e MINIO_ROOT_PASSWORD=siop-minio \
minio/minio:RELEASE.2025-09-07T16-13-09Z server /data
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
- uses: actions/setup-node@v5 - uses: actions/setup-node@v5
with: with:
@@ -110,20 +118,40 @@ jobs:
- run: pnpm --filter @siop/web test - run: pnpm --filter @siop/web test
- run: pnpm --filter @siop/web build - run: pnpm --filter @siop/web build
mobile:
name: mobile (typecheck + tests jest-expo)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v5
with:
node-version-file: .nvmrc
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm --filter @siop/shared build
- run: pnpm --filter @siop/mobile typecheck
- run: pnpm --filter @siop/mobile test
ai:
name: ai (pytest + ruff — uv)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: astral-sh/setup-uv@v5
- run: uv sync
working-directory: apps/ai
- run: uv run ruff check src tests
working-directory: apps/ai
- run: uv run pytest
working-directory: apps/ai
e2e: e2e:
name: e2e (parcours démo Playwright) name: e2e (parcours démo Playwright)
runs-on: ubuntu-latest runs-on: ubuntu-latest
services: services:
postgres: # PostgreSQL vient d'infra/postgres (pgvector + PostGIS — R5) : les
image: postgis/postgis:18-3.6 # services ne savent pas builder, il démarre donc par étape ci-dessous.
env:
POSTGRES_USER: siop
POSTGRES_PASSWORD: siop
POSTGRES_DB: siop
ports: ['5432:5432']
options: >-
--health-cmd "pg_isready -U siop -d siop"
--health-interval 5s --health-timeout 3s --health-retries 10
redis: redis:
image: redis:7.4-alpine image: redis:7.4-alpine
ports: ['6379:6379'] ports: ['6379:6379']
@@ -134,8 +162,25 @@ jobs:
DATABASE_URL: postgresql://siop:siop@localhost:5432/siop DATABASE_URL: postgresql://siop:siop@localhost:5432/siop
REDIS_URL: redis://localhost:6379 REDIS_URL: redis://localhost:6379
JWT_SECRET: ci-only-secret-0123456789abcdef JWT_SECRET: ci-only-secret-0123456789abcdef
MINIO_ENDPOINT: localhost
MINIO_ACCESS_KEY: siop
MINIO_SECRET_KEY: siop-minio
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v5
- name: Démarrer PostgreSQL (image infra — pgvector + PostGIS, comme partout)
run: |
docker build -t siop2/postgres infra/postgres
docker run -d --name postgres -p 5432:5432 \
-e POSTGRES_USER=siop -e POSTGRES_PASSWORD=siop -e POSTGRES_DB=siop \
siop2/postgres
for i in $(seq 1 30); do
docker exec postgres pg_isready -U siop -d siop && break; sleep 2;
done
- name: Démarrer MinIO (l'image officielle exige une commande — pas un service)
run: |
docker run -d --name minio -p 9000:9000 \
-e MINIO_ROOT_USER=siop -e MINIO_ROOT_PASSWORD=siop-minio \
minio/minio:RELEASE.2025-09-07T16-13-09Z server /data
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
- uses: actions/setup-node@v5 - uses: actions/setup-node@v5
with: with:
@@ -146,8 +191,30 @@ jobs:
- run: pnpm --filter @siop/api prisma:generate - run: pnpm --filter @siop/api prisma:generate
- run: pnpm --filter @siop/api exec prisma migrate deploy - run: pnpm --filter @siop/api exec prisma migrate deploy
- run: pnpm --filter @siop/api build - run: pnpm --filter @siop/api build
- uses: astral-sh/setup-uv@v5
- name: Démarrer le service IA (R5 — embeddeur déterministe, seuils CI)
working-directory: apps/ai
env:
AI_EMBEDDINGS: deterministe
AI_SERVICE_TOKEN: dev-only-ai-token
# L'embeddeur déterministe (tri-grammes hachés) produit des scores
# bien plus bas que le vrai modèle : seuils calibrés sur mesures
# réelles (vrai match ≈ 0,66 ; bruit de collisions ≈ 0,11 ; codes
# de bilan pertinents ≈ 0,14-0,16 ; parasites ≈ 0,08).
AI_SEUIL_PERTINENCE: '0.20'
AI_SEUIL_SUGGESTION: '0.10'
AI_SEUIL_CONFIANCE_FORTE: '0.30'
run: |
uv sync
nohup uv run uvicorn siop_ai.app:app --host 127.0.0.1 --port 8000 > /tmp/siop-ai.log 2>&1 &
for i in $(seq 1 30); do
curl -fsS http://127.0.0.1:8000/healthz && break; sleep 1;
done
- run: pnpm --filter @siop/web exec playwright install --with-deps chromium - run: pnpm --filter @siop/web exec playwright install --with-deps chromium
- run: pnpm --filter @siop/web e2e - run: pnpm --filter @siop/web e2e
- name: Journal du service IA en cas d'échec
if: failure()
run: cat /tmp/siop-ai.log || true
- name: Traces Playwright en cas d'échec - name: Traces Playwright en cas d'échec
if: failure() if: failure()
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
@@ -164,7 +231,7 @@ jobs:
deploy: deploy:
name: deploy (Dokploy — siop2.apps.enset.top) name: deploy (Dokploy — siop2.apps.enset.top)
if: github.event_name == 'push' && github.ref == 'refs/heads/main' if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: [lint, contract, api, web, e2e] needs: [lint, contract, api, web, mobile, ai, e2e]
runs-on: ubuntu-latest runs-on: ubuntu-latest
environment: production environment: production
steps: steps:

9
.gitignore vendored
View File

@@ -2,8 +2,17 @@
node_modules/ node_modules/
dist/ dist/
.env .env
# apps/mobile/.env ne porte aucun secret — un réglage de build (fetch RN
# classique, cf. ADR-005) qui doit être le même pour tout le monde.
!apps/mobile/.env
.turbo/ .turbo/
coverage/ coverage/
*.tsbuildinfo *.tsbuildinfo
test-results/ test-results/
playwright-report/ playwright-report/
# Python (apps/ai)
.venv/
__pycache__/
.pytest_cache/
.ruff_cache/

View File

@@ -10,7 +10,7 @@ Projet **réel** (SPELEV, maintenance d'ascenseurs, Maroc) mené par le Pr. Daai
1. **Design-first** : aucune ligne de code applicatif avant validation par le référent de la charte graphique, des tokens et des maquettes HD (`docs/02-design/`). Chaque release subit une revue « pixel » écrans ↔ maquettes. 1. **Design-first** : aucune ligne de code applicatif avant validation par le référent de la charte graphique, des tokens et des maquettes HD (`docs/02-design/`). Chaque release subit une revue « pixel » écrans ↔ maquettes.
2. **Playbook vivant** : chaque phase du cycle de vie a son dossier `docs/0X-*/` (template réutilisable + artefacts réels). Le journal quotidien vit dans `docs/journal/`**jamais** dans le livre. 2. **Playbook vivant** : chaque phase du cycle de vie a son dossier `docs/0X-*/` (template réutilisable + artefacts réels). Le journal quotidien vit dans `docs/journal/`**jamais** dans le livre.
3. **Périmètre fermé par release** : R0 Fondations → R1 Référentiel → R2 Exploitation → R3 Gestion → R4 Mobile → R5 IA. On n'ouvre pas Rn+1 avant recette **et** déploiement de Rn. 3. **Périmètre fermé par release** : R0 Fondations → R1 Référentiel → R2 Exploitation → R3 Gestion → R4 Mobile → R5 IA → R6 Mobile tous rôles. On n'ouvre pas Rn+1 avant recette **et** déploiement de Rn.
4. **Déployer tôt** : chaque release part sur le serveur de production (Dokploy) dès sa recette. 4. **Déployer tôt** : chaque release part sur le serveur de production (Dokploy) dès sa recette.
5. **Le développeur est le premier utilisateur** : `DEMO_MODE=true` active un **sélecteur de compte démo** (connexion 1 clic sur les comptes seedés, endpoint `POST /auth/demo-login` strictement absent si l'env ne l'active pas). Critère : changer de rôle en < 3 s sans mot de passe. 5. **Le développeur est le premier utilisateur** : `DEMO_MODE=true` active un **sélecteur de compte démo** (connexion 1 clic sur les comptes seedés, endpoint `POST /auth/demo-login` strictement absent si l'env ne l'active pas). Critère : changer de rôle en < 3 s sans mot de passe.
@@ -44,5 +44,45 @@ pnpm + Turborepo. `apps/api` : NestJS, Prisma, PostgreSQL (pgvector + PostGIS),
- **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.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.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). - 🏁 **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 Exploitation — reprise ici, design d'abord** : maquettes R0 déjà validées pour tableau de bord / liste OT / fiche OT (bilan codé) / portail demandeur ; **à maquetter avant tout code : préventif** (gabarits à périodicité, grille du mois, checklist) et écrans secondaires (création OT/demande). Ensuite : modèle R2 (WorkOrder machine à états, Request 1-1, InterventionReport bilan codé, TaskTemplate/PreventivePlan/ChecklistItem, Meter) API web mobile-ready. - **R2 — maquettes validées** (16/07) : `maquette-r2.html` (demandes/approbation, nouvel OT, préventif, checklist, compteurs) + 4 décisions actées.
- Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0R5. - **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.
- **R4 — maquettes rédigées** (17/07) : `maquette-r4.html`, 7 écrans mobile technicien offline-first (Ma journée bi-état, fiche OT, clôture terrain avec garde, scan QR des étiquettes R1, fiche ascenseur, checklist en file, synchro & conflits à verrou optimiste) + 5 décisions à acter (offline-first en file, verrou optimiste tranché par l'humain, périmètre fermé technicien, scan local, photos en file ni audio ni géoloc en R4).
- **R4 — maquettes + décisions D1-D5 VALIDÉES par le référent (17/07)** ; **R4.1 socle mobile** : `apps/mobile` (Expo SDK 57, TS strict), connexion + sélecteur démo ADR-002, tabbar (onglets futurs marqués), « Ma journée » triée priorité/échéance, cache TanStack persisté (lecture hors-ligne D1), jeton SecureStore, client typé du contrat, `CORS_ORIGINS` opt-in côté API (Expo web/debug seulement), 6 tests jest-expo + job CI `mobile`. Vérifié 10/10 en Expo web (connexion démo Ma journée hors-ligne/retour).
- **R4.2 — terrain en ligne** : scanner QR réel (expo-camera, `analyseScan` testée, résolution locale D4, QR étrangers refusés), fiche ascenseur (D3, historique scopé), fiche OT (machine à états, coûts figés, garde par `closureBlockers` API), clôture terrain (bilan 6 champs au pouce), préventif grille cochable (appui long = N/A, `aria-checked`). Écritures en ligne assumées (bandeaux) la file est R4.3. Vérifié 12/12 en Expo web (dont clôture complète d'un OT de test), 12 tests jest-expo.
- **R4.3 — file & verrou** : verrou optimiste serveur (`baseUpdatedAt` 409 contextualisé, toute écriture avance la version, web inchangé, ADR-003 sécurité/routage mobile) ; file persistée rejouée dans l'ordre (propagation de version intra-lot, arrêt sur conflit, patchs optimistes, photos D5 compressées en file), écran Synchro & conflits (rejouer sur version à jour / abandonner), préchargement parc+référentiels, purge complète à la déconnexion. Recette « mode avion » 13/13 en Expo web (conflit tranché par l'humain, serveur DONE + bilan), 17 tests jest-expo, 74 tests API.
- 🏁 **R4 CLOSE (17/07/2026, tag `release/r4`)** : posé sur décision du référent avec la recette « mode avion » validée 13/13 en Expo web piloté ; **la recette sur téléphone (Expo Go — vrai mode avion, scan caméra) est reportée et reste due avant toute production client mobile**.
- **R5 — maquettes rédigées** (17/07) : `maquette-r5.html`, 6 écrans (assistant RAG à citations sources document/page/extrait, refus explicite hors corpus, suggestion de bilan web+mobile à validation humaine, corpus & ingestion administrable, voix opt-in avec purge) + 5 décisions à acter (aucune écriture automatique, sourcé ou silencieux, corpus fermé, anonymisation 09-08, voix purgée).
- **R5 — maquettes + décisions D1-D5 VALIDÉES par le référent (17/07)** ; **R5.1 socle `apps/ai`** : ADR-004 (embeddings locaux fastembed 384d, pgvector via migration Prisma `r5_ia` (`RagChunk` + corpus sur Document), génération opt-in mode extractif par défaut, service jamais exposé joint par l'API seule), pipeline d'ingestion anonymisé D4 (fonction pure testée, PDF paginés + bilans codés), `/internal/reindex` + `/internal/search` sous jeton de service, 14 pytest + ruff + job CI `ai` (embeddeur déterministe en CI). Vérifié en réel : corpus seedé indexé en 7 s, recherche sémantique concluante, 0 identité dans les chunks.
- **R5.1+ génération opt-in** : `Generateur` ADR-004 §3 extractif par défaut, `AI_GENERATION=api` + `AI_API_KEY` (exigée au boot, jamais loguée ; Dokploy secrets) + `AI_MODEL` (défaut claude-opus-4-8), SDK anthropic en extra optionnel, citations [n] obligatoires, repli extractif sur tout échec (dont `stop_reason=refusal`). **R5.2 assistant au contrat** : `POST /assistant/ask` (WORK_ORDERS.view) + `POST /assistant/suggest-bilan` (WORK_ORDERS.edit) proxifiés par NestJS (traduction dialecte interne contrat, 503 propre), suggestion = codes EXISTANTS seulement (confiance + « N bilans similaires »), normalisation fastembed corrigée (débusquée en chaîne réelle), 23 pytest, tests API sur stub HTTP.
- **R5.3 — écrans IA** : page web Assistant (chat sourcé, refus honnête chiffré, « Ouvrir » vers PDF/OT), Bibliothèque = corpus administrable (statut d'indexation par document, interrupteur d'exclusion, « Réindexer tout », bandeau 09-08), suggestions fiche OT Appliquer » = geste humain, liseré « suggéré » retiré au choix manuel) et clôture mobile (chips, « réseau requis » hors-ligne) ; contrat 76 opérations (corpus sur Document, `PATCH /documents/{id}/corpus`, `POST /assistant/reindex` ci-contract vérifie aussi le client mobile) ; seuils `AI_SEUIL_*` par env ; job e2e CI avec `siop2-ai` (embeddeur déterministe, seuils calibrés sur mesures réelles), 16/16 Playwright, 78 tests API ; chaîne vérifiée au vrai modèle ONNX (web 7/7, mobile Expo web 6/6, 0 erreur console).
- **Recette R5 sans clé API + durcissement** (17/07) : la recette a invalidé MiniLM-384 (page-réponse classée derrière des passages sans rapport, 0,24 vs 0,41) **bascule mesurée vers `paraphrase-multilingual-mpnet-base-v2` 768 d** (ADR-004 amendé, migration `r5_embeddings_mpnet`, découpage ~350 car., seuils 0,45/0,40/0,55) ; recette type (réponse sourcée p. 2, refus honnête, D1-D5, tout en extractif) ; revue pixel publiée (artefact, 3 arbitrages) ; Dockerfile `siop2-ai` (modèle au build, non-root), compose Dokploy (service interne, `AI_SERVICE_TOKEN` requis, génération opt-in), runbook §5-6 (service IA, calibrage seuils client, réindexation post-déploiement).
- **Revue pixel R5 validée par le référent (17/07)** 1 correction appliquée sur arbitrage : la Bibliothèque-corpus passe en **tableau** (Document/Rattaché à/Indexation/Corpus + actions ; interrupteur éteint pour les non-indexables), vignettes conservées sur les cartes Documents des fiches ; « Appliquer » direct et calibrage au runbook validés tels quels. 16/16 Playwright rejoués.
- 🏁 **R5 CLOSE (17/07/2026, tag `release/r5`)** : recettée par le référent (revue pixel + arbitrage tableau), recette passée SANS clé API, durcissement répété en local conteneurisé. **R0 → R5 : périmètre v1 couvert.**
- **Recette terrain mobile sur iPhone 15 Pro — 7/7 validés (19/07, ADR-005)** : Expo Go bloqué par le retard d'approbation Apple (SDK 54 vs notre SDK 57) **décision : builds natifs locaux (Xcode/Android Studio, signature gratuite) pour la vraie recette, Expo Go conservé pour l'aperçu sans installation**. 5 obstacles techniques réels corrigés et documentés (UDID plutôt que nom, ne jamais contourner `expo run:*` par un xcodebuild manuel, modules Expo/RN précompilés SDK 56/57 incompatibles avec la liaison statique du projet, `fetch` global incompatible avec l'upload multipart natif fix permanent `EXPO_PUBLIC_USE_RN_FETCH=1` dans `apps/mobile/.env`, découverte réseau du dev client peu fiable). Recette iOS : connexion, scan caméra réel (résolution + rejet QR étranger), vrai mode avion, conflit D2 tranché par l'humain avec photo réellement téléversée, persistance à travers fermeture/reconstruction de l'app, suggestions R5 au vrai modèle, purge de sécurité au changement de compte tout validé. Android : build natif sur émulateur, passage santé complet.
- **Dictée implémentée (22/07, ADR-004 §5)** : écran Voix R5 (jamais construit) `faster-whisper` local (CTranslate2, CPU, opt-in `AI_TRANSCRIPTION=off|locale`, défaut off), audio jamais persisté (purge serveur ET mobile quoi qu'il arrive), transcription relecture humaine suggestion immédiate et/ou sauvegarde dans `InterventionReport.note` (champ existant depuis R2, jamais eu d'écran jusqu'ici) `note` rejoint le corpus à la clôture comme les bilans codés. Mobile : `expo-audio`/`expo-file-system`, bouton dicter sur l'écran de clôture. Docker `siop2-ai` embarque le modèle (1,542,19 Go). Vérifié réellement (voix de synthèse texte fidèle, bout en bout API, conteneur Docker construit, chaîne corpus complète noteclôtureréindexationrecherche). 80 tests API, 29 pytest ai, mobile vert.
- **Dictée validée sur iPhone physique (01/08, ADR-004 §5)** : le blocage USB du 22/07 était un faux négatif de `system_profiler` (outil de diagnostic en panne, pas le câble). Deux bugs réels trouvés et corrigés : `expo-audio` iOS exige `setAudioModeAsync({ allowsRecording: true })` avant `record()` (absent, jamais détecté hors appareil réel) ; le correctif des modules précompilés (ADR-005) n'avait jamais été rendu permanent dans `apps/mobile/.env` corrigé. Chaîne complète vérifiée en vrai : micro dictée transcription fidèle note clôture réindexation **retrouvé par la recherche sémantique**.
- fix(mobile) **déconnexion accessible depuis tous les onglets (02/08)** : bug remonté par le référent (une fois authentifié, aucun moyen de revenir en arrière ou de se déconnecter seule « Ma journée » portait ce contrôle, via un `onLongPress` non découvrable). `EnteteTabs` (`composants/ui.tsx`) factorise une entête commune aux 4 onglets terrain, tap simple + confirmation, rôle affiché dynamique.
- **R6 — maquette validée (02/08)** : `maquette-mobile-tous-roles.html`, 7 écrans (synthèse par rôle, Accueil adaptatif, Menu groupé, OT côté Dispatcher, Demandes, Stock, Personnes & statistiques) + 5 décisions actées D1 barre d'onglets adaptative (Technicien/Technicien limité inchangés + onglet Menu) ; D2 le Menu reprend à l'identique les 4 groupes du web (même matrice, aucune règle nouvelle) ; D3 groupe sans lien visible masqué en entier ; D4 le mobile porte les actions courantes par famille, pas les flux de gestion les plus denses (réservés au web) ; D5 aucune logique de permission propre au mobile. Nouvelle release (R4 est close et R4.1/4.2/4.3 déjà pris par le socle mobile technicien d'origine pas de réouverture, pas de collision de numérotation).
- **R6.1 — socle** : `usePermissions()` mobile (calqué sur le web, lit `me.permissions`) ; barre d'onglets adaptative par rôle (`ongletsVisibles`, `href: null` masque sans retirer du navigateur) ; écrans Accueil (dashboard réel pour Administrateur/Gestionnaire/Dispatcher/Vue seule, aucun chiffre inventé), OT (liste complète `viewOther`, réutilise la fiche OT R4 telle quelle), Menu (groupes filtrés par la matrice, groupe vide masqué), Demandes (`PanneauDemandes` un seul composant pour tous les rôles : création/suivi pour le Demandeur, approbation/rejet à motif pour Gestionnaire/Dispatcher/Administrateur, lecture seule pour Vue seule ; `GET /assets/options`, déjà ouvert à tout rôle authentifié depuis R2.3, réutilisé pour le sélecteur d'équipement). Familles pas encore portées (Sites, Ascenseurs, Stock, Tiers, Fichiers, Statistiques, Assistant, Personnes) : écran « à venir » honnête plutôt qu'un lien mort. Typecheck propre, 17 tests Jest verts, lint 5/5 paquets pas de vérification visuelle en navigateur de mon côté (aucun outil de ce type dans cet environnement), à confirmer par le référent sur l'iPhone déjà connecté au serveur Metro.
- **R6.2 — Parc (Sites, Ascenseurs)** : `useLocations()` mobile ; écran Sites (premier niveau, `parentId===null`) ; fiche site (identité, zones, ascenseurs du site consultation seule, D4, pas de carte ni d'édition sur mobile) ; écran Ascenseurs (parc complet déjà préchargé, D1) ouvrant la fiche appareil R4 telle quelle (générique, aucune modification nécessaire). Menu : Ascenseurs/Sites routent réellement, Catégories reste à venir (admin, hors périmètre). Typecheck propre, 17 tests Jest, lint 5/5 exécution directe de la maquette déjà validée, pas de nouveau tour de design.
- **R6.3 — Ressources (Stock, Tiers, Fichiers)** : `api/ressources.ts` (usePartners/useParts/usePart/useCreatePurchaseOrder/useDocuments) ; écran Stock (sous-seuil en tête) + fiche pièce + « Commander » pré-rempli en une ligne (fournisseur figé, quantité = manquant jusqu'au seuil, prix = dernier connu le BC multi-lignes détaillé reste au web, D4) ; Tiers en lecture seule (création/édition réservées au web) ; Fichiers en métadonnées seules l'ouverture/téléchargement demande `expo-sharing` (dépendance native absente, donc un nouveau build) et est explicitement différée plutôt qu'ajoutée à la légère. Menu branché. Typecheck propre, 17 tests Jest, lint 5/5, contrat non touché (toutes les opérations existaient déjà depuis R3).
- **R6.4 — Pilotage (Statistiques, Personnes)** : `EXPO_PUBLIC_WEB_URL`/`WEB_URL` (lien d'activation vers la page web, pas d'équivalent mobile) ; `api/pilotage.ts` ; écran Statistiques (période 3/6/12, coût du mois, taux préventif, pannes par organe, top équipements cartes plutôt que graphes, D4) ; écran Personnes & équipes (liste + statut, Inviter, lien d'activation en texte sélectionnable `expo-clipboard` différée, même raisonnement que `expo-sharing` en R6.3 ; taux horaire/rôles/équipes restent au web). **Assistant reste à venir** dans le Menu un chat sourcé est un nouveau patron d'écran jamais maquetté sur mobile, contrairement aux autres familles qui réutilisaient Carte/LigneInfo/EnteteFiche déjà validés ; mérite son propre tour de design-first. Typecheck propre, 17 tests Jest, lint 5/5, contrat non touché.
- **Nav adaptative + correctif déconnexion confirmés par le référent sur iPhone physique** dernier point ouvert depuis le début de R6, clos.
- **R6.5 — Assistant mobile (chat sourcé + dictée)** : maquette dédiée validée (3 écrans, D1-D5 D5 ajoutée en revue : question tapée OU dictée, même pipeline que la dictée déjà livrée en clôture, transcription remplit le champ, jamais d'envoi automatique). `api/assistant.ts` (503 géré comme le web). Écran Assistant : chat un échange à la fois, citations numérotées, sources avec extrait exact Voir le document » métadonnées seules, R6.3 ; « Ouvrir l'OT » fiche R4), refus honnête chiffré + Reformuler, avertissement permanent. Fiche document (`bibliotheque/[id].tsx`, nouveau) la liste R6.3 y mène aussi désormais. **Le Menu R6 n'a plus d'entrée « à venir »** dans les 4 groupes (Catégories exceptée, admin, hors périmètre mobile). Typecheck propre, 17 tests Jest, lint 5/5, contrat non touché.
- 🔄 **Recette R6 en cours (Gestionnaire, partie 1)** 2 bugs réels trouvés et corrigés sur iPhone physique : Menu non scrollable (le dernier groupe, Pilotage, était strictement inaccessible une fois les 4 groupes pleinement câblés présent depuis R6.1, révélé seulement maintenant) ; BC créé sans confirmation visible dans l'app (vérifié côté serveur : les BC étaient bien créés, juste aucun retour affiché). Amélioration sur retour direct : Tiers gagne une fiche détail (identité/contact/BC en cours/sites rattachés) le lecture-seule sans aucune réaction au tap se lisait comme cassé. Trois signalements vérifiés et écartés (faux positifs) : approbation de demande (a fonctionné), bibliothèque vide (confirmé côté serveur aucun document sur cette instance, pas un bug mobile), période statistiques (transmise et traitée correctement, les indicateurs affichés ne varient juste pas avec ce jeu de données).
- **Assistant mobile — dictée confirmée sur iPhone physique**, après 3 corrections trouvées en conditions réelles (aucune n'aurait été vue par typecheck/tests/lint) : `expo-file-system` deleteAsync déprécié SDK 57 (import `/legacy`, appliqué aussi à `cloture.tsx`) ; texte transcrit pas entièrement visible (zone multiligne pleine largeur, Envoyer en geste séparé) ; bouton Envoyer chevauchant encore le texte (ScrollView du chat sans `style={flex:1}`, TextInput à hauteur fixe plutôt que `maxHeight` seul, pas fiable sur iOS).
- **R6.6 — Demandeur restreint à son site** : trou trouvé en recette `GET /assets/options` n'avait aucun filtre (web ET mobile touchés, pas seulement mobile). Corrigé à la racine : relation `User↔Location` (`assignedSites`, migration `r6_demandeur_sites`, vide = aucune restriction pour les autres rôles) ; `AssetsService.allowedLocationIds()` réutilisée par `options()` et par `RequestsService.create` (défense en profondeur, 400 si hors périmètre) ; gestion des sites d'un Demandeur côté web (`personnes.tsx`, invitation + modale dédiée) ; raccourci scan QR côté mobile (`formulaire-demande.tsx`, résout uniquement contre les options déjà filtrées, jamais de repli sur le parc complet). Karim Doukkali (démo) rattaché à Tour Atlas. Bug trouvé en vérification avant tout commit (comparaison id-de-site vs id-d'appareil dans `create()`, aurait rejeté à tort tout signalement d'un Demandeur affecté) et corrigé, méthode renommée `allowedAssetIds` `allowedLocationIds` pour que le nom dise ce qu'elle retourne. 79/80 tests API (le seul échec est le flake `monthCost` déjà connu, sans rapport) ; typecheck/tests/lint verts sur les 4 paquets.
- **R6.7 — assignation à l'approbation (mobile)** : le Gestionnaire approuvait une demande mobile sans pouvoir assigner de technicien (l'OT partait non assigné) « ce n'est pas à lui d'agir comme un technicien ». `PanneauDemandes` gagne un panneau d'approbation avec `ChoixTel` « Assigner à » (techniciens actifs, même filtre que le web), avant confirmation. Rien à changer côté API (`assigneeIds` déjà supporté). Vérifié de bout en bout (demande approuvée avec assigné OT avec `assignees` correct). Typecheck/tests/lint verts.
- **Périmètre `WORK_ORDERS` du Gestionnaire confirmé acceptable tel quel par le référent** l'accès complet hérité de R2/R3 (peut techniquement démarrer/clôturer n'importe quel OT) reste en l'état, aucun resserrement demandé.
- **R6.8 — la connexion routait toujours vers « Ma journée »** : `connexion.tsx` faisait `router.replace('/(tabs)/journee')` en dur la redirection par rôle (`ongletAccueil`) n'était branchée qu'à l'aiguillage initial (R6.1), pas au retour de connexion, le chemin réellement emprunté à chaque bascule de compte démo (pas de sélecteur de rôle en direct sur mobile, changer de compte = se déconnecter puis se reconnecter). Karim (Demandeur) atterrissait sur l'écran du Technicien. `useLogin()`/`useDemoLogin()` renvoient maintenant la réponse complète (rôle inclus) ; `entrer(role)` route vers le bon onglet. Sous-titre « Technicien »/accroche terrain de l'écran de connexion (reste de R4) généricisés au passage. Typecheck/tests/lint verts.
- **Recette R6 confirmée sur iPhone physique** Gestionnaire (Accueil, OT, Menu complet, Assistant+dictée) et Demandeur (Accueil, Nouvelle demande filtrée à son site, scan QR) tous deux vérifiés en conditions réelles, au-delà du typecheck/tests/lint.
- 🔄 **Reprise ici** : à trancher avec le référent considérer R6 close (tag `release/r6`, comme R0R5) ou poursuivre sur un reste identifié (Vue seule/Dispatcher/Administrateur non explicitement recettés sur iPhone, `expo-sharing`/`expo-clipboard` en réserve, recette Android sur appareil physique). Restes non bloquants inchangés : redéploiement Dokploy de l'instance ENSET (`AI_SERVICE_TOKEN` à créer runbook §2 puis « Réindexer tout »), calibrage `AI_SEUIL_*` et qualité darija sur corpus SPELEV réel, secret `DOKPLOY_WEBHOOK_URL`, production client SPELEV (attend les accès serveur du partenaire).
- Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0R5 (v1) + R6 en cours.

9
apps/ai/.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
.venv
.pytest_cache
.ruff_cache
__pycache__
tests
README.md
.env
.env.example
Dockerfile

20
apps/ai/.env.example Normal file
View File

@@ -0,0 +1,20 @@
# Copier en .env pour le dev local (défauts alignés sur infra/docker-compose.yml).
DATABASE_URL=postgresql://siop:siop@localhost:5432/siop
MINIO_ENDPOINT=localhost
MINIO_PORT=9000
MINIO_ACCESS_KEY=siop
MINIO_SECRET_KEY=siop-minio
MINIO_BUCKET=siop2
# Seule l'API NestJS connaît ce secret (ADR-004 §4 — service jamais public).
AI_SERVICE_TOKEN=dev-only-ai-token
# Embeddings : locale (fastembed ONNX, CPU) | deterministe (tests/CI)
AI_EMBEDDINGS=locale
# Génération (ADR-004 §3) : off = mode extractif (défaut — la recette passe
# sans clé) | api = rédaction par le LLM sur textes déjà anonymisés (D4).
# AI_GENERATION=api exige AI_API_KEY (le boot refuse sinon).
AI_GENERATION=off
AI_API_KEY=
AI_MODEL=claude-opus-4-8

44
apps/ai/Dockerfile Normal file
View File

@@ -0,0 +1,44 @@
# SIOP V2 — image du service IA (siop2-ai, ADR-004).
# Contexte de build : apps/ai (le service est autonome, pas de dépendance au
# monorepo). Étage 1 : uv sync + téléchargement du modèle ONNX AU BUILD
# (ADR-004 §1 — jamais au démarrage) ; étage 2 : runtime minimal non-root.
# Ce service n'est JAMAIS exposé publiquement : seul siop2-api le contacte,
# porteur du secret AI_SERVICE_TOKEN (ADR-004 §4).
FROM ghcr.io/astral-sh/uv:python3.11-bookworm-slim AS builder
WORKDIR /app
ENV UV_LINK_MODE=copy \
FASTEMBED_CACHE_PATH=/opt/fastembed \
HF_HOME=/opt/whisper
# Manifestes d'abord (cache de couche), puis le code. L'installation du projet
# reste éditable (.pth → /app/src) : src est donc copié dans l'image finale.
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-install-project --no-dev \
--extra embeddings --extra generation --extra transcription
COPY src src
RUN uv sync --frozen --no-dev \
--extra embeddings --extra generation --extra transcription
# Les modèles locaux (embeddings + dictée) sont EMBARQUÉS dans l'image : pas
# de téléchargement au boot (démarrage prévisible, marche sans accès à
# Hugging Face en production). La dictée reste opt-in (AI_TRANSCRIPTION=off
# par défaut) — le modèle est prêt si elle est activée un jour, sans rebuild.
RUN uv run python -c "from siop_ai.embeddings import EmbeddeurLocal; EmbeddeurLocal()"
RUN uv run python -c "from siop_ai.transcription import TranscripteurLocal; TranscripteurLocal('small')"
FROM python:3.11-slim-bookworm
WORKDIR /app
ENV PATH=/app/.venv/bin:$PATH \
FASTEMBED_CACHE_PATH=/opt/fastembed \
HF_HOME=/opt/whisper
RUN useradd --system --create-home siop
COPY --from=builder --chown=siop:siop /app/.venv /app/.venv
COPY --from=builder --chown=siop:siop /app/src /app/src
COPY --from=builder --chown=siop:siop /opt/fastembed /opt/fastembed
COPY --from=builder --chown=siop:siop /opt/whisper /opt/whisper
USER siop
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8000/healthz', timeout=4).status==200 else 1)"
CMD ["uvicorn", "siop_ai.app:app", "--host", "0.0.0.0", "--port", "8000"]

33
apps/ai/README.md Normal file
View File

@@ -0,0 +1,33 @@
# @siop/ai — service IA (R5)
FastAPI + uv (ADR-001), **jamais exposé** : seule l'API NestJS le contacte avec
`X-Service-Token` (ADR-004 §4). Maquettes et décisions D1-D5 validées le 17/07/2026.
## Lancer (dev)
```bash
cd apps/ai
uv sync --extra embeddings # le vrai modèle ONNX (CPU, ~120 Mo au premier run)
uv run uvicorn siop_ai.app:app --port 8000
# CI / tests : uv sync && uv run pytest (embeddeur déterministe, aucun téléchargement)
```
Variables (défauts dev dans `config.py`, gabarit dans `.env.example`) :
`DATABASE_URL`, `MINIO_*`, `AI_SERVICE_TOKEN`, `AI_EMBEDDINGS=locale|deterministe`,
et la génération opt-in (ADR-004 §3) : `AI_GENERATION=off|api`, **`AI_API_KEY`**
(exigée en mode api — le boot refuse sinon, jamais loguée ni exposée),
`AI_MODEL` (défaut `claude-opus-4-8`). Mode api : `uv sync --extra generation`
(SDK officiel `anthropic`) ; tout échec du LLM retombe sur le mode extractif.
## Ce que porte R5.1 (socle)
- **Ingestion anonymisée (D4)** : PDF de la bibliothèque (MinIO) page par page +
bilans codés clôturés → anonymisation (e-mails, téléphones, noms connus de la
base) → découpage → embeddings locaux → `RagChunk` (pgvector, schéma Prisma).
- **Recherche sémantique** `/internal/search` : extraits sourcés (document + page
ou bilan daté) avec score — la brique de « sourcé ou silencieux » (D2).
- `/internal/reindex` idempotent ; l'exclusion de corpus (`Document.inCorpus`,
D3) s'applique à l'ingestion ET à la lecture.
La suite : R5.2 assistant (mode extractif puis génération opt-in) + suggestion de
bilan ; R5.3 écrans ; durcissement : Dockerfile + compose Dokploy (`siop2-ai`).

46
apps/ai/pyproject.toml Normal file
View File

@@ -0,0 +1,46 @@
[project]
name = "siop-ai"
version = "0.1.0"
description = "SIOP V2 — service IA (R5) : ingestion anonymisée, recherche sémantique, assistant sourcé (ADR-004)"
requires-python = ">=3.11"
dependencies = [
"fastapi>=0.115",
"uvicorn[standard]>=0.32",
"pydantic-settings>=2.6",
"asyncpg>=0.30",
"pypdf>=5.1",
"minio>=7.2",
"python-multipart>=0.0.20",
]
[project.optional-dependencies]
# Le vrai modèle (ONNX, CPU) — absent des tests/CI (embeddeur déterministe).
embeddings = ["fastembed>=0.4"]
# Génération opt-in (ADR-004 §3) — absente des tests/CI (repli extractif).
generation = ["anthropic>=0.75"]
# Transcription opt-in (dictée R5, ADR-004 §5) — absente des tests/CI
# (transcripteur déterministe). CTranslate2/CPU, licence MIT, local.
transcription = ["faster-whisper>=1.1"]
[dependency-groups]
dev = [
"pytest>=8.3",
"pytest-asyncio>=0.24",
"httpx>=0.27",
"ruff>=0.8",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/siop_ai"]
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
[tool.ruff]
line-length = 100
src = ["src", "tests"]

View File

@@ -0,0 +1 @@
"""SIOP V2 — service IA (R5). L'IA propose, l'humain valide (D1)."""

View File

@@ -0,0 +1,62 @@
"""Anonymisation à l'ingestion (D4, loi 09-08) : identités et coordonnées ne
partent JAMAIS dans les index vectoriels ni dans les prompts.
Fonction pure, testée : e-mails, téléphones (formats marocains et
internationaux), et les noms de personnes CONNUS de la base (utilisateurs,
gardiens, contacts tiers) fournis par l'appelant.
"""
import re
import unicodedata
JETON_PERSONNE = "⟨personne⟩"
JETON_CONTACT = "⟨contact⟩"
_EMAIL = re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+")
# 06 12 34 56 78 · 0612345678 · +212 6 12 34 56 78 · 05 22-34-56-78…
_TELEPHONE = re.compile(r"(?:\+?\d{1,3}[\s.-]?)?(?:0|\(0\))?\d(?:[\s.-]?\d{2}){4}")
def _sans_accents(texte: str) -> str:
return "".join(
c for c in unicodedata.normalize("NFD", texte) if unicodedata.category(c) != "Mn"
)
def anonymiser(texte: str, noms_connus: list[str] | None = None) -> str:
"""Remplace coordonnées et noms connus par des jetons neutres.
Les noms sont remplacés insensiblement à la casse ET aux accents
(« Idrissi » attrape « idrissi »), prénom seul compris quand il est
assez long pour ne pas mutiler le texte technique.
"""
resultat = _EMAIL.sub(JETON_CONTACT, texte)
resultat = _TELEPHONE.sub(JETON_CONTACT, resultat)
for nom in sorted(noms_connus or [], key=len, reverse=True):
nom = nom.strip()
if len(nom) < 3:
continue
morceaux = [nom] + [m for m in nom.split() if len(m) >= 4]
for morceau in morceaux:
motif = re.compile(
r"\b" + re.escape(_sans_accents(morceau)) + r"\b", re.IGNORECASE
)
# on cherche sur une copie sans accents mais on remplace l'original
copie = _sans_accents(resultat)
sortie: list[str] = []
position = 0
for correspondance in motif.finditer(copie):
sortie.append(resultat[position : correspondance.start()])
sortie.append(JETON_PERSONNE)
position = correspondance.end()
sortie.append(resultat[position:])
resultat = "".join(sortie)
# jetons collés en double (« prénom nom » remplacés séparément)
resultat = re.sub(
rf"{re.escape(JETON_PERSONNE)}(\s+{re.escape(JETON_PERSONNE)})+",
JETON_PERSONNE,
resultat,
)
return resultat

133
apps/ai/src/siop_ai/app.py Normal file
View File

@@ -0,0 +1,133 @@
"""Service IA — JAMAIS exposé publiquement (ADR-004 §4) : seule l'API NestJS
le contacte, avec le secret partagé `X-Service-Token`. Les permissions des
utilisateurs restent l'affaire de l'API — ici, un seul appelant de confiance.
"""
from contextlib import asynccontextmanager
from dataclasses import asdict
import asyncpg
from fastapi import Depends, FastAPI, Header, HTTPException, UploadFile
from pydantic import BaseModel, Field
from .assistant import repondre, suggerer_bilan
from .config import Reglages, charger_reglages
from .embeddings import construire_embeddeur
from .generation import construire_generateur
from .ingestion import reindexer_tout
from .recherche import chercher
from .transcription import construire_transcripteur
@asynccontextmanager
async def cycle_de_vie(app: FastAPI):
reglages = charger_reglages()
app.state.reglages = reglages
app.state.embeddeur = construire_embeddeur(reglages.ai_embeddings)
app.state.generateur = construire_generateur(
reglages.ai_generation, reglages.ai_api_key, reglages.ai_model
)
app.state.transcripteur = construire_transcripteur(
reglages.ai_transcription, reglages.ai_transcription_model
)
app.state.pool = await asyncpg.create_pool(reglages.database_url, min_size=1, max_size=5)
yield
await app.state.pool.close()
app = FastAPI(title="SIOP V2 — service IA (R5)", lifespan=cycle_de_vie)
def verifier_jeton(
x_service_token: str = Header(default=""),
) -> None:
reglages: Reglages = app.state.reglages
if x_service_token != reglages.ai_service_token:
raise HTTPException(status_code=401, detail="Jeton de service invalide")
@app.get("/healthz")
async def sante() -> dict:
"""Sonde interne (compose/Dokploy) — ne révèle ni corpus ni secret."""
reglages = getattr(app.state, "reglages", None) or charger_reglages()
return {
"status": "ok",
"service": "siop2-ai",
"generation": reglages.ai_generation, # « off » = extractif — jamais la clé
"transcription": reglages.ai_transcription,
}
@app.post("/internal/reindex", dependencies=[Depends(verifier_jeton)])
async def reindexer() -> dict:
async with app.state.pool.acquire() as cnx:
resultat = await reindexer_tout(cnx, app.state.reglages, app.state.embeddeur)
return asdict(resultat)
class RequeteRecherche(BaseModel):
question: str = Field(min_length=3, max_length=500)
limite: int = Field(default=5, ge=1, le=10)
@app.post("/internal/search", dependencies=[Depends(verifier_jeton)])
async def rechercher(corps: RequeteRecherche) -> dict:
async with app.state.pool.acquire() as cnx:
extraits = await chercher(cnx, app.state.embeddeur, corps.question, corps.limite)
return {"extraits": [asdict(e) for e in extraits]}
@app.post("/internal/ask", dependencies=[Depends(verifier_jeton)])
async def demander(corps: RequeteRecherche) -> dict:
"""L'assistant D2 : extraits sourcés au-dessus du seuil, ou refus honnête
(ce qui a été cherché) — la rédaction n'existe qu'en mode génératif."""
async with app.state.pool.acquire() as cnx:
reponse = await repondre(
cnx,
app.state.embeddeur,
app.state.generateur,
corps.question,
corps.limite,
seuil_pertinence=app.state.reglages.ai_seuil_pertinence,
)
return {
"mode": reponse.mode,
"answer": reponse.answer,
"extraits": [asdict(e) for e in reponse.extraits],
"corpus": {
"documents": reponse.documents_corpus,
"bilans": reponse.bilans_corpus,
},
}
class RequeteSuggestion(BaseModel):
description: str = Field(min_length=10, max_length=2000)
@app.post("/internal/suggest", dependencies=[Depends(verifier_jeton)])
async def suggerer(corps: RequeteSuggestion) -> dict:
"""Suggestion de codes de bilan (D1) : uniquement des codes EXISTANTS,
avec confiance et « N bilans similaires » — l'humain applique, ou pas."""
async with app.state.pool.acquire() as cnx:
suggestions = await suggerer_bilan(
cnx,
app.state.embeddeur,
corps.description,
seuil_suggestion=app.state.reglages.ai_seuil_suggestion,
seuil_confiance_forte=app.state.reglages.ai_seuil_confiance_forte,
)
return {"suggestions": [asdict(s) for s in suggestions]}
@app.post("/internal/transcrire", dependencies=[Depends(verifier_jeton)])
async def transcrire(fichier: UploadFile) -> dict:
"""Dictée opt-in (R5 D5, loi 09-08) : l'audio ne persiste JAMAIS — un
fichier temporaire le temps de l'inférence, supprimé aussitôt (voir
transcription.py). 503 propre si AI_TRANSCRIPTION=off (défaut)."""
if app.state.transcripteur is None:
raise HTTPException(status_code=503, detail="Transcription non activée (AI_TRANSCRIPTION=off)")
audio = await fichier.read()
extension = (fichier.filename or "audio.m4a").rsplit(".", 1)[-1]
texte = app.state.transcripteur.transcrire(audio, extension)
return {"texte": texte}

View File

@@ -0,0 +1,153 @@
"""L'assistant (D2 — « sourcé ou silencieux ») et la suggestion de bilan.
- `repondre` : recherche sémantique → extraits au-dessus du seuil de
pertinence, ou refus HONNÊTE qui dit ce qui a été cherché (écran 2 des
maquettes). La rédaction est déléguée au `Generateur` (opt-in ADR-004 §3) ;
sans lui, le mode extractif est la réponse.
- `suggerer_bilan` : similarité sémantique entre la description libre et les
libellés ACTIFS des référentiels (l'IA ne peut suggérer que des codes
existants) + comptage des bilans similaires du parc. Sans LLM : rapide,
déterministe, explicable.
"""
from dataclasses import dataclass
import asyncpg
from .embeddings import Embeddeur
from .generation import Generateur
from .recherche import ExtraitTrouve, chercher
# Répliques des libellés français de @siop/shared (BILAN_FIELD_LABELS) —
# utilisés pour contextualiser les embeddings des codes.
CHAMPS_BILAN = {
"DOOR_STATE": "état des portes",
"CABIN_POSITION": "position cabine",
"ANOMALY": "anomalie constatée",
"EXTERNAL_CAUSE": "cause extérieure",
"ACTION_TAKEN": "action réalisée",
"COMPONENT_CONCERNED": "élément concerné",
}
# Défauts — surchargés par la config (AI_SEUIL_*) : calibrage en recette.
SEUIL_PERTINENCE = 0.45 # en dessous : le corpus ne porte pas la réponse
SEUIL_SUGGESTION = 0.40
SEUIL_CONFIANCE_FORTE = 0.55
@dataclass
class ReponseAssistant:
mode: str # « extractif » | « genere » | « refus »
answer: str | None
extraits: list[ExtraitTrouve]
documents_corpus: int
bilans_corpus: int
async def _taille_corpus(cnx: asyncpg.Connection) -> tuple[int, int]:
ligne = await cnx.fetchrow(
'''
SELECT
(SELECT count(DISTINCT "documentId") FROM "RagChunk"
WHERE "sourceType" = 'DOCUMENT') AS documents,
(SELECT count(*) FROM "RagChunk" WHERE "sourceType" = 'WORK_ORDER') AS bilans
'''
)
return ligne["documents"], ligne["bilans"]
async def repondre(
cnx: asyncpg.Connection,
embeddeur: Embeddeur,
generateur: Generateur,
question: str,
limite: int = 5,
seuil_pertinence: float = SEUIL_PERTINENCE,
) -> ReponseAssistant:
documents, bilans = await _taille_corpus(cnx)
extraits = await chercher(cnx, embeddeur, question, limite)
pertinents = [e for e in extraits if e.score >= seuil_pertinence]
if not pertinents:
# D2 : refus explicite — on dit ce qu'on a cherché, on n'invente rien.
return ReponseAssistant(
mode="refus",
answer=None,
extraits=[],
documents_corpus=documents,
bilans_corpus=bilans,
)
redige = generateur.rediger(question, pertinents)
return ReponseAssistant(
mode="genere" if redige else "extractif",
answer=redige,
extraits=pertinents,
documents_corpus=documents,
bilans_corpus=bilans,
)
@dataclass
class SuggestionBilan:
field: str
value_id: str
label: str
confidence: str # « FORTE » | « MOYENNE »
similar_reports: int # bilans du parc portant déjà ce code (« 9 bilans similaires »)
score: float
def _cosinus(a: list[float], b: list[float]) -> float:
return sum(x * y for x, y in zip(a, b)) # vecteurs déjà normés
async def suggerer_bilan(
cnx: asyncpg.Connection,
embeddeur: Embeddeur,
description: str,
seuil_suggestion: float = SEUIL_SUGGESTION,
seuil_confiance_forte: float = SEUIL_CONFIANCE_FORTE,
) -> list[SuggestionBilan]:
valeurs = await cnx.fetch(
'SELECT id, field, label FROM "ReferenceValue" WHERE "isActive" ORDER BY field, label'
)
if not valeurs:
return []
textes = [description] + [
f"{CHAMPS_BILAN.get(v['field'], v['field'])} : {v['label']}" for v in valeurs
]
vecteurs = embeddeur.encoder(textes)
v_description, v_valeurs = vecteurs[0], vecteurs[1:]
# Le meilleur code par champ, au-dessus du seuil — jamais plus d'une
# suggestion par champ, jamais un code inventé.
meilleurs: dict[str, tuple[asyncpg.Record, float]] = {}
for valeur, vecteur in zip(valeurs, v_valeurs):
score = _cosinus(v_description, vecteur)
if score < seuil_suggestion:
continue
champ = valeur["field"]
if champ not in meilleurs or score > meilleurs[champ][1]:
meilleurs[champ] = (valeur, score)
suggestions: list[SuggestionBilan] = []
for valeur, score in meilleurs.values():
# « 9 bilans similaires sur ce parc » : les bilans clôturés portant ce code
similaires = await cnx.fetchval(
'SELECT count(*) FROM "RagChunk" WHERE "sourceType" = \'WORK_ORDER\' AND content ILIKE $1',
f"%{valeur['label']}%",
)
suggestions.append(
SuggestionBilan(
field=valeur["field"],
value_id=str(valeur["id"]),
label=valeur["label"],
confidence="FORTE" if score >= seuil_confiance_forte else "MOYENNE",
similar_reports=similaires,
score=round(score, 4),
)
)
suggestions.sort(key=lambda s: s.score, reverse=True)
return suggestions

View File

@@ -0,0 +1,52 @@
"""Configuration — validée au démarrage, comme l'API NestJS (même philosophie)."""
from pydantic_settings import BaseSettings
class Reglages(BaseSettings):
# Postgres partagé (schéma possédé par Prisma — apps/api)
database_url: str = "postgresql://siop:siop@localhost:5432/siop"
# MinIO en direct sur le réseau privé (ADR-004 §4 — jamais exposé)
minio_endpoint: str = "localhost"
minio_port: int = 9000
minio_use_ssl: bool = False
minio_access_key: str = "siop"
minio_secret_key: str = "siop-minio"
minio_bucket: str = "siop2"
# Le service n'est JAMAIS public : seul l'API NestJS le contacte,
# porteuse de ce secret partagé (ADR-004 §4).
ai_service_token: str = "dev-only-ai-token"
# Embeddings : « locale » (fastembed ONNX) ou « deterministe » (tests/CI)
ai_embeddings: str = "locale"
# Génération (ADR-004 §3) : « off » = mode extractif (défaut honnête,
# la recette passe sans clé) ; « api » = rédaction par le LLM externe,
# sur textes DÉJÀ anonymisés (D4), citations obligatoires.
ai_generation: str = "off"
ai_api_key: str = "" # requise seulement si ai_generation=api — jamais loguée
ai_model: str = "claude-opus-4-8"
# Seuils de similarité — constantes de départ, calibrables par env
# (recette sur corpus réel ; abaissés en CI e2e — embeddeur déterministe).
ai_seuil_pertinence: float = 0.45
ai_seuil_suggestion: float = 0.40
ai_seuil_confiance_forte: float = 0.55
# Dictée opt-in (R5 D5, ADR-004 §5) : « off » (défaut — endpoint refuse
# proprement), « locale » (faster-whisper, CPU) ou « deterministe » (CI).
ai_transcription: str = "off"
ai_transcription_model: str = "small" # tiny|base|small|medium|large-v3
model_config = {"env_prefix": "", "case_sensitive": False}
def charger_reglages() -> Reglages:
reglages = Reglages()
# asyncpg ne comprend pas le paramètre ?schema= de Prisma
if "?" in reglages.database_url:
reglages.database_url = reglages.database_url.split("?")[0]
# Même philosophie que l'API NestJS : une config invalide refuse de booter.
if reglages.ai_generation not in ("off", "api"):
raise ValueError("AI_GENERATION doit valoir « off » ou « api »")
if reglages.ai_generation == "api" and not reglages.ai_api_key:
raise ValueError("AI_GENERATION=api exige AI_API_KEY (voir ADR-004 §3)")
if reglages.ai_transcription not in ("off", "locale", "deterministe"):
raise ValueError("AI_TRANSCRIPTION doit valoir « off », « locale » ou « deterministe »")
return reglages

View File

@@ -0,0 +1,57 @@
"""Découpage du texte en extraits indexables — pur et testé.
Paragraphes regroupés jusqu'à ~350 caractères, avec un chevauchement de
queue pour ne pas couper une prescription en deux. Un extrait trop long est
scindé sur les phrases.
Le grain est court À DESSEIN (recette R5) : sur des pages entières, la phrase
qui répond se noie dans son contexte et les scores question→passage ne
séparent plus le pertinent du voisin de domaine ; à ~350 caractères, la marge
revient — et l'extrait cité à l'écran reste lisible d'un coup d'œil.
"""
import re
TAILLE_CIBLE = 350
CHEVAUCHEMENT = 80
TAILLE_MINIMALE = 40 # en deçà : bruit (titres orphelins, numéros de page)
def _phrases(texte: str) -> list[str]:
return [p.strip() for p in re.split(r"(?<=[.!?;])\s+", texte) if p.strip()]
def decouper(texte: str) -> list[str]:
paragraphes = [p.strip() for p in re.split(r"\n\s*\n", texte) if p.strip()]
extraits: list[str] = []
courant = ""
def pousser() -> None:
nonlocal courant
nettoye = courant.strip()
if len(nettoye) >= TAILLE_MINIMALE:
extraits.append(nettoye)
courant = ""
for paragraphe in paragraphes:
paragraphe = re.sub(r"\s+", " ", paragraphe)
if len(courant) + len(paragraphe) + 1 > TAILLE_CIBLE and courant:
queue = courant[-CHEVAUCHEMENT:]
pousser()
courant = queue + " "
while len(paragraphe) > TAILLE_CIBLE:
phrases = _phrases(paragraphe)
if len(phrases) <= 1:
courant += paragraphe[:TAILLE_CIBLE]
paragraphe = paragraphe[TAILLE_CIBLE - CHEVAUCHEMENT :]
pousser()
continue
morceau = ""
while phrases and len(morceau) + len(phrases[0]) + 1 <= TAILLE_CIBLE:
morceau += phrases.pop(0) + " "
courant += morceau
pousser()
paragraphe = " ".join(phrases)
courant += paragraphe + " "
pousser()
return extraits

View File

@@ -0,0 +1,65 @@
"""Embeddeurs (ADR-004) : le vrai modèle local ONNX, et un déterministe pour
tests/CI — même interface, mêmes dimensions (DIMENSIONS), aucun téléchargement en test.
"""
import hashlib
import math
from typing import Protocol
DIMENSIONS = 768
# mpnet remplace MiniLM-384 : décision de recette R5 (journal 17/07) — sur le
# banc français, MiniLM classait la page-réponse DERRIÈRE des passages sans
# rapport (0,24 vs 0,41) ; mpnet rétablit le classement et une marge
# signal/bruit exploitable (≥ 0,46 vs ≤ 0,42).
MODELE_LOCAL = "sentence-transformers/paraphrase-multilingual-mpnet-base-v2"
class Embeddeur(Protocol):
def encoder(self, textes: list[str]) -> list[list[float]]: ...
class EmbeddeurDeterministe:
"""Sac de tri-grammes haché puis normalisé : stable, sans réseau, et les
textes proches partagent des composantes — assez pour tester le circuit
complet (ingestion → pgvector → similarité)."""
def encoder(self, textes: list[str]) -> list[list[float]]:
return [self._un(t) for t in textes]
def _un(self, texte: str) -> list[float]:
vecteur = [0.0] * DIMENSIONS
mots = texte.lower().split()
grammes = mots + [" ".join(mots[i : i + 3]) for i in range(max(0, len(mots) - 2))]
for gramme in grammes:
empreinte = hashlib.sha256(gramme.encode()).digest()
indice = int.from_bytes(empreinte[:4], "big") % DIMENSIONS
signe = 1.0 if empreinte[4] % 2 == 0 else -1.0
vecteur[indice] += signe
norme = math.sqrt(sum(v * v for v in vecteur)) or 1.0
return [v / norme for v in vecteur]
class EmbeddeurLocal:
"""fastembed (ONNX, CPU) — chargé paresseusement, jamais importé en test.
Sortie NORMÉE : fastembed ne garantit pas des vecteurs unitaires, or la
similarité par produit scalaire (suggestions) l'exige — pgvector, lui,
normalise dans son opérateur cosinus, ce qui masquait l'écart."""
def __init__(self) -> None:
from fastembed import TextEmbedding # import différé (dépendance optionnelle)
self._modele = TextEmbedding(model_name=MODELE_LOCAL)
def encoder(self, textes: list[str]) -> list[list[float]]:
vecteurs = []
for vecteur in self._modele.embed(textes):
liste = vecteur.tolist()
norme = math.sqrt(sum(v * v for v in liste)) or 1.0
vecteurs.append([v / norme for v in liste])
return vecteurs
def construire_embeddeur(mode: str) -> Embeddeur:
if mode == "deterministe":
return EmbeddeurDeterministe()
return EmbeddeurLocal()

View File

@@ -0,0 +1,94 @@
"""Génération des réponses rédigées (ADR-004 §3) — OPT-IN par configuration.
- « off » (défaut) : mode extractif — pas de LLM, l'assistant montrera les
extraits sourcés tels quels. La recette R5 passe entièrement dans ce mode.
- « api » : rédaction par Claude (SDK officiel), sur des extraits DÉJÀ
anonymisés (D4), avec l'obligation de ne rien affirmer hors extraits (D2).
Tout échec (refus, réseau, quota) retombe sur le mode extractif — jamais
d'erreur utilisateur à cause du LLM.
"""
import logging
from typing import Protocol
from .recherche import ExtraitTrouve
journal = logging.getLogger("siop_ai.generation")
CONSIGNE = """Tu es l'assistant SIOP d'une société de maintenance d'ascenseurs.
Tu réponds en français, UNIQUEMENT à partir des extraits fournis (notices et
historiques d'intervention du parc, déjà anonymisés).
Règles absolues :
- chaque affirmation porte sa citation [n] renvoyant à un extrait fourni ;
- si les extraits ne portent pas la réponse, dis-le et n'invente RIEN ;
- reste bref et opérationnel : un technicien te lit sur le terrain ;
- termine toujours par le rappel que l'humain vérifie avant d'agir."""
class Generateur(Protocol):
def rediger(self, question: str, extraits: list[ExtraitTrouve]) -> str | None: ...
class GenerateurExtractif:
"""Le contrat de base : pas de rédaction — l'appelant montre les extraits.
`None` signifie « pas de texte généré », jamais une erreur."""
def rediger(self, question: str, extraits: list[ExtraitTrouve]) -> str | None:
return None
def construire_invite(question: str, extraits: list[ExtraitTrouve]) -> str:
"""L'invite utilisateur — fonction pure, testée : la question et les
extraits numérotés, rien d'autre (les textes sont déjà anonymisés)."""
blocs = [
f"[{rang}] {e.titre} · {e.locator}\n{e.content}"
for rang, e in enumerate(extraits, start=1)
]
return "Extraits du corpus :\n\n" + "\n\n".join(blocs) + f"\n\nQuestion : {question}"
class GenerateurAPI:
"""Rédaction par Claude — import différé : la dépendance `anthropic` est
optionnelle (groupe `generation`), absente des tests/CI."""
def __init__(self, api_key: str, modele: str) -> None:
from anthropic import Anthropic
self._client = Anthropic(api_key=api_key)
self._modele = modele
def rediger(self, question: str, extraits: list[ExtraitTrouve]) -> str | None:
import anthropic
if not extraits:
return None # sourcé ou silencieux (D2) : rien à citer = rien à rédiger
try:
reponse = self._client.messages.create(
model=self._modele,
max_tokens=2048, # réponses courtes et sourcées, par conception
thinking={"type": "adaptive"},
system=CONSIGNE,
messages=[
{"role": "user", "content": construire_invite(question, extraits)}
],
)
if reponse.stop_reason == "refusal":
journal.warning("Génération refusée par le modèle — repli extractif")
return None
texte = "".join(b.text for b in reponse.content if b.type == "text").strip()
return texte or None
except anthropic.RateLimitError:
journal.warning("Quota API atteint — repli extractif")
return None
except anthropic.APIStatusError as e:
journal.warning("API génération %s — repli extractif", e.status_code)
return None
except anthropic.APIConnectionError:
journal.warning("API génération injoignable — repli extractif")
return None
def construire_generateur(mode: str, api_key: str, modele: str) -> Generateur:
if mode == "api":
return GenerateurAPI(api_key, modele)
return GenerateurExtractif()

View File

@@ -0,0 +1,178 @@
"""Ingestion du corpus (D3) : bibliothèque R3 (PDF, MinIO) + bilans codés.
Chaque texte passe par l'anonymisation (D4) AVANT découpage et embeddings.
Les chunks vivent dans `RagChunk` (pgvector, schéma possédé par Prisma).
"""
import io
import json
from dataclasses import dataclass
import asyncpg
from minio import Minio
from pypdf import PdfReader
from .anonymisation import anonymiser
from .config import Reglages
from .decoupage import decouper
from .embeddings import Embeddeur
@dataclass
class ResultatIngestion:
documents_indexes: int
documents_ignores: int
bilans_indexes: int
extraits: int
def _vecteur_sql(vecteur: list[float]) -> str:
return "[" + ",".join(f"{v:.6f}" for v in vecteur) + "]"
async def noms_a_anonymiser(cnx: asyncpg.Connection) -> list[str]:
"""Toutes les identités connues de la base (D4) : utilisateurs, gardiens,
contacts tiers, demandeurs du portail."""
lignes = await cnx.fetch(
'''
SELECT "displayName" AS nom FROM "User"
UNION SELECT "guardianName" FROM "Location" WHERE "guardianName" IS NOT NULL
UNION SELECT "contactName" FROM "Partner" WHERE "contactName" IS NOT NULL
'''
)
return [ligne["nom"] for ligne in lignes if ligne["nom"]]
def extraire_texte_pdf(octets: bytes) -> list[tuple[str, str]]:
"""[(texte, localisation)] par page — la citation doit pointer la page."""
lecteur = PdfReader(io.BytesIO(octets))
pages: list[tuple[str, str]] = []
for numero, page in enumerate(lecteur.pages, start=1):
texte = page.extract_text() or ""
if texte.strip():
pages.append((texte, f"p. {numero}"))
return pages
async def indexer_documents(
cnx: asyncpg.Connection,
reglages: Reglages,
embeddeur: Embeddeur,
noms: list[str],
) -> tuple[int, int, int]:
minio = Minio(
f"{reglages.minio_endpoint}:{reglages.minio_port}",
access_key=reglages.minio_access_key,
secret_key=reglages.minio_secret_key,
secure=reglages.minio_use_ssl,
)
documents = await cnx.fetch(
'SELECT id, "fileName", "storageKey", "contentType", "inCorpus" FROM "Document"'
)
indexes, ignores, total_extraits = 0, 0, 0
for doc in documents:
# réindexation idempotente : on repart de zéro pour ce document
await cnx.execute('DELETE FROM "RagChunk" WHERE "documentId" = $1', doc["id"])
if not doc["inCorpus"] or doc["contentType"] != "application/pdf":
await cnx.execute(
'UPDATE "Document" SET "indexedAt" = NULL, "chunkCount" = 0 WHERE id = $1',
doc["id"],
)
ignores += 1
continue
reponse = minio.get_object(reglages.minio_bucket, doc["storageKey"])
try:
octets = reponse.read()
finally:
reponse.close()
reponse.release_conn()
extraits: list[tuple[str, str]] = []
for texte_page, localisation in extraire_texte_pdf(octets):
texte_sur = anonymiser(texte_page, noms)
extraits += [(morceau, localisation) for morceau in decouper(texte_sur)]
if extraits:
vecteurs = embeddeur.encoder([contenu for contenu, _ in extraits])
await cnx.executemany(
'''
INSERT INTO "RagChunk"
(id, "sourceType", "documentId", locator, content, embedding)
VALUES (gen_random_uuid(), 'DOCUMENT', $1, $2, $3, $4::vector)
''',
[
(doc["id"], localisation, contenu, _vecteur_sql(vecteur))
for (contenu, localisation), vecteur in zip(extraits, vecteurs)
],
)
await cnx.execute(
'UPDATE "Document" SET "indexedAt" = now(), "chunkCount" = $2 WHERE id = $1',
doc["id"],
len(extraits),
)
indexes += 1
total_extraits += len(extraits)
return indexes, ignores, total_extraits
async def indexer_bilans(
cnx: asyncpg.Connection, embeddeur: Embeddeur, noms: list[str]
) -> tuple[int, int]:
"""Les bilans codés clôturés — « sur votre parc, ce réglage a déjà… »."""
await cnx.execute('DELETE FROM "RagChunk" WHERE "sourceType" = \'WORK_ORDER\'')
bilans = await cnx.fetch(
'''
SELECT wo.id, wo.reference, wo.title, wo."completedAt", ir.note,
a.reference AS asset_ref, a.brand, a.model,
(SELECT json_object_agg(rv.field, rv.label)
FROM "InterventionReport" ir2
JOIN "ReferenceValue" rv ON rv.id IN (
ir2."doorStateId", ir2."cabinPositionId", ir2."anomalyId",
ir2."externalCauseId", ir2."actionTakenId", ir2."componentConcernedId")
WHERE ir2."workOrderId" = wo.id) AS bilan
FROM "WorkOrder" wo
JOIN "InterventionReport" ir ON ir."workOrderId" = wo.id
JOIN "Asset" a ON a.id = wo."assetId"
WHERE wo.status = 'DONE'
'''
)
lignes = []
for bilan in bilans:
champs = json.loads(bilan["bilan"]) if bilan["bilan"] else {}
codes = " ; ".join(f"{champ} : {label}" for champ, label in champs.items())
# note (D5) : dictée ou saisie libre relue par l'humain — même pipeline
# d'anonymisation que le reste, aucun traitement à part.
note = f" Description libre : {bilan['note']}." if bilan["note"] else ""
contenu = anonymiser(
f"Intervention {bilan['reference']}{bilan['title']}. "
f"Appareil {bilan['asset_ref']} ({bilan['brand']} {bilan['model'] or ''}). "
f"Bilan codé : {codes}.{note}",
noms,
)
quand = bilan["completedAt"].date().isoformat() if bilan["completedAt"] else "date inconnue"
lignes.append((bilan["id"], f"bilan du {quand}", contenu))
if lignes:
vecteurs = embeddeur.encoder([contenu for _, _, contenu in lignes])
await cnx.executemany(
'''
INSERT INTO "RagChunk"
(id, "sourceType", "workOrderId", locator, content, embedding)
VALUES (gen_random_uuid(), 'WORK_ORDER', $1, $2, $3, $4::vector)
''',
[
(wo_id, localisation, contenu, _vecteur_sql(vecteur))
for (wo_id, localisation, contenu), vecteur in zip(lignes, vecteurs)
],
)
return len(lignes), len(lignes)
async def reindexer_tout(
cnx: asyncpg.Connection, reglages: Reglages, embeddeur: Embeddeur
) -> ResultatIngestion:
noms = await noms_a_anonymiser(cnx)
docs_ok, docs_non, extraits_docs = await indexer_documents(cnx, reglages, embeddeur, noms)
bilans, extraits_bilans = await indexer_bilans(cnx, embeddeur, noms)
return ResultatIngestion(
documents_indexes=docs_ok,
documents_ignores=docs_non,
bilans_indexes=bilans,
extraits=extraits_docs + extraits_bilans,
)

View File

@@ -0,0 +1,57 @@
"""Recherche sémantique dans le corpus (pgvector, distance cosinus).
Ne renvoie QUE des extraits sourcés — la brique de « sourcé ou silencieux ».
"""
from dataclasses import dataclass
import asyncpg
from .embeddings import Embeddeur
from .ingestion import _vecteur_sql
@dataclass
class ExtraitTrouve:
source_type: str
document_id: str | None
work_order_id: str | None
titre: str # nom de fichier ou référence d'OT
locator: str
content: str
score: float # similarité cosinus (0..1)
async def chercher(
cnx: asyncpg.Connection,
embeddeur: Embeddeur,
question: str,
limite: int = 5,
) -> list[ExtraitTrouve]:
vecteur = _vecteur_sql(embeddeur.encoder([question])[0])
lignes = await cnx.fetch(
'''
SELECT c."sourceType", c."documentId", c."workOrderId", c.locator, c.content,
1 - (c.embedding <=> $1::vector) AS score,
COALESCE(d."fileName", wo.reference, '?') AS titre
FROM "RagChunk" c
LEFT JOIN "Document" d ON d.id = c."documentId"
LEFT JOIN "WorkOrder" wo ON wo.id = c."workOrderId"
WHERE c."documentId" IS NULL OR d."inCorpus" -- l'exclusion D3 s'applique aussi à la lecture
ORDER BY c.embedding <=> $1::vector
LIMIT $2
''',
vecteur,
limite,
)
return [
ExtraitTrouve(
source_type=ligne["sourceType"],
document_id=str(ligne["documentId"]) if ligne["documentId"] else None,
work_order_id=str(ligne["workOrderId"]) if ligne["workOrderId"] else None,
titre=ligne["titre"],
locator=ligne["locator"],
content=ligne["content"],
score=float(ligne["score"]),
)
for ligne in lignes
]

View File

@@ -0,0 +1,54 @@
"""Transcription audio (dictée R5, ADR-004 §5) : l'audio ne survit JAMAIS à
cet appel — un fichier temporaire le temps de l'inférence, supprimé aussitôt,
quoi qu'il arrive (D5, loi 09-08). Seul le texte transcrit est retourné ; la
relecture humaine reste le seul contenu conservé, dans l'OT (D1).
"""
import os
import tempfile
from typing import Protocol
class Transcripteur(Protocol):
def transcrire(self, audio: bytes, extension: str) -> str: ...
class TranscripteurLocal:
"""faster-whisper (CTranslate2, CPU, licence MIT) — chargé paresseusement,
jamais importé en test. Modèle et langue forcée en français : le mélange
français/darija du terrain reste un point de vigilance non calibré (pas
de mesure préalable demandée par le référent, 22/07/2026) — à revoir sur
échantillons réels si la qualité déçoit en recette."""
def __init__(self, modele: str) -> None:
from faster_whisper import WhisperModel # import différé (dépendance optionnelle)
self._modele = WhisperModel(modele, device="cpu", compute_type="int8")
def transcrire(self, audio: bytes, extension: str) -> str:
fd, chemin = tempfile.mkstemp(suffix=f".{extension}")
try:
with os.fdopen(fd, "wb") as f:
f.write(audio)
segments, _ = self._modele.transcribe(chemin, language="fr", vad_filter=True)
return " ".join(segment.text.strip() for segment in segments).strip()
finally:
os.remove(chemin) # D5 — l'audio ne doit JAMAIS survivre à l'appel
class TranscripteurDeterministe:
"""Tests/CI : pas de modèle, pas de dépendance audio réelle — renvoie un
texte stable dérivé de la taille du fichier (même interface)."""
def transcrire(self, audio: bytes, extension: str) -> str:
return f"transcription déterministe ({len(audio)} octets, .{extension})"
def construire_transcripteur(mode: str, modele: str) -> Transcripteur | None:
"""None si la dictée n'est pas activée (AI_TRANSCRIPTION=off, défaut) —
l'appelant doit alors refuser proprement (503), pas planter."""
if mode == "off":
return None
if mode == "deterministe":
return TranscripteurDeterministe()
return TranscripteurLocal(modele)

View File

@@ -0,0 +1,33 @@
"""D4 (loi 09-08) : rien d'identifiant ne doit survivre à l'ingestion."""
from siop_ai.anonymisation import JETON_CONTACT, JETON_PERSONNE, anonymiser
def test_emails_et_telephones_marocains():
texte = "Appeler M. Alami au 06 12 34 56 78 ou +212 5 22 34 56 78, sinon gardien@residence.ma"
resultat = anonymiser(texte, ["M. Alami"])
assert "06 12" not in resultat
assert "+212" not in resultat
assert "gardien@residence.ma" not in resultat
assert resultat.count(JETON_CONTACT) == 3
def test_noms_connus_meme_sans_accents_ni_casse():
texte = "Intervention validée par salma idrissi puis contrôlée par AHMED BENALI."
resultat = anonymiser(texte, ["Salma Idrissi", "Ahmed Benali"])
assert "idrissi" not in resultat.lower()
assert "benali" not in resultat.lower()
assert resultat.count(JETON_PERSONNE) == 2
def test_prenom_seul_est_attrape_mais_pas_les_mots_courts():
resultat = anonymiser("Vu avec Ahmed sur site.", ["Ahmed Benali"])
assert "Ahmed" not in resultat
# « NC-31 » ou « vis » ne doivent jamais être mutilés par un nom court
resultat2 = anonymiser("Contact NC-31 réglé, vis serrées.", ["N. C."])
assert "NC-31" in resultat2
def test_le_texte_technique_reste_intact():
texte = "Serrer les coulisseaux au couple de 25 N·m ; jeu latéral 0,5 mm."
assert anonymiser(texte, ["Salma Idrissi"]) == texte

69
apps/ai/tests/test_app.py Normal file
View File

@@ -0,0 +1,69 @@
"""Le service n'est jamais public : sans le jeton de service, 401 partout
(la santé exceptée — sonde d'infra qui ne révèle rien)."""
from fastapi.testclient import TestClient
from siop_ai.app import app
def test_sante_publique_interne():
reponse = _client_sans_db().get("/healthz")
assert reponse.status_code == 200
assert reponse.json()["service"] == "siop2-ai"
def test_endpoints_internes_refuses_sans_jeton():
client = _client_sans_db()
assert client.post("/internal/reindex").status_code == 401
assert client.post("/internal/search", json={"question": "couple de serrage ?"}).status_code == 401
assert client.post("/internal/transcrire", files={"fichier": ("a.m4a", b"x")}).status_code == 401
def test_transcription_off_par_defaut_503():
"""Dictée pas activée (AI_TRANSCRIPTION=off, défaut) : refus propre,
jamais un 500 — même contrat que le service IA éteint côté NestJS."""
reponse = _client_sans_db().post(
"/internal/transcrire",
files={"fichier": ("note.m4a", b"faux-audio")},
headers={"X-Service-Token": "dev-only-ai-token"},
)
assert reponse.status_code == 503
def test_transcription_deterministe_retourne_un_texte():
from siop_ai.transcription import TranscripteurDeterministe
client = _client_sans_db()
app.state.transcripteur = TranscripteurDeterministe()
reponse = client.post(
"/internal/transcrire",
files={"fichier": ("note.m4a", b"faux-audio")},
headers={"X-Service-Token": "dev-only-ai-token"},
)
assert reponse.status_code == 200
assert "10 octets" in reponse.json()["texte"]
assert ".m4a" in reponse.json()["texte"]
def test_question_trop_courte_rejetee_avant_tout():
reponse = _client_sans_db().post(
"/internal/search",
json={"question": "ab"},
headers={"X-Service-Token": "dev-only-ai-token"},
)
assert reponse.status_code == 422
def _client_sans_db() -> TestClient:
"""TestClient HORS gestionnaire de contexte : le lifespan (pool DB,
modèle d'embeddings) ne tourne pas — on pose l'état minimal. Les tests
d'intégration DB se font en local (recette), pas en CI (convention R5.1)."""
from siop_ai.config import charger_reglages
from siop_ai.transcription import construire_transcripteur
reglages = charger_reglages()
app.state.reglages = reglages
app.state.transcripteur = construire_transcripteur(
reglages.ai_transcription, reglages.ai_transcription_model
)
return TestClient(app, raise_server_exceptions=False)

View File

@@ -0,0 +1,51 @@
"""Logique de l'assistant testée sans base : le seuil « sourcé ou silencieux »
et la sélection des suggestions — la partie SQL est couverte par la recette
réelle (convention R5.1 : pytest purs en CI)."""
from siop_ai.assistant import (
CHAMPS_BILAN,
SEUIL_CONFIANCE_FORTE,
SEUIL_PERTINENCE,
SEUIL_SUGGESTION,
_cosinus,
)
from siop_ai.embeddings import EmbeddeurDeterministe
def test_les_seuils_sont_ordonnes():
# Pertinence et suggestion vivent dans des pipelines distincts (question →
# passages vs description → libellés) : pas d'ordre imposé entre eux.
# L'invariant : une suggestion tout juste retenue n'est jamais « forte ».
assert 0 < SEUIL_PERTINENCE < 1
assert 0 < SEUIL_SUGGESTION < SEUIL_CONFIANCE_FORTE < 1
def test_champs_bilan_couvrent_les_six_champs_du_contrat():
assert set(CHAMPS_BILAN) == {
"DOOR_STATE",
"CABIN_POSITION",
"ANOMALY",
"EXTERNAL_CAUSE",
"ACTION_TAKEN",
"COMPONENT_CONCERNED",
}
def test_similarite_discrimine_le_bon_code():
"""Le cœur de la suggestion : une description de panne de porte doit être
plus proche du code « portes » que d'un code sans rapport."""
embeddeur = EmbeddeurDeterministe()
description, porte, treuil = embeddeur.encoder(
[
"la porte cabine rebondit, cellule encrassée, nettoyage barrière porte",
"anomalie constatée : cellule ou barrière de porte encrassée",
"élément concerné : treuil et moteur de traction",
]
)
assert _cosinus(description, porte) > _cosinus(description, treuil)
def test_cosinus_de_vecteurs_normes():
embeddeur = EmbeddeurDeterministe()
[v] = embeddeur.encoder(["contrôle mensuel des portes palières"])
assert abs(_cosinus(v, v) - 1.0) < 1e-6

View File

@@ -0,0 +1,24 @@
from siop_ai.decoupage import CHEVAUCHEMENT, TAILLE_CIBLE, decouper
def test_texte_court_un_seul_extrait():
extraits = decouper("Serrer les vis de fixation des coulisseaux au couple de 25 N·m.")
assert len(extraits) == 1
def test_les_miettes_sont_ecartees():
assert decouper("p. 3\n\n7\n\n") == []
def test_long_texte_decoupe_avec_chevauchement():
paragraphe = "La procédure de maintenance impose un contrôle mensuel des organes. " * 40
extraits = decouper(paragraphe)
assert len(extraits) >= 2
assert all(len(e) <= TAILLE_CIBLE + CHEVAUCHEMENT for e in extraits)
def test_paragraphes_courts_regroupes():
texte = "\n\n".join(f"Étape {i} : vérifier le verrouillage de la porte palière." for i in range(6))
extraits = decouper(texte)
assert len(extraits) == 1
assert "Étape 0" in extraits[0] and "Étape 5" in extraits[0]

View File

@@ -0,0 +1,26 @@
from siop_ai.embeddings import DIMENSIONS, EmbeddeurDeterministe
def test_dimensions_et_normalisation():
[vecteur] = EmbeddeurDeterministe().encoder(["couple de serrage des guides"])
assert len(vecteur) == DIMENSIONS
assert abs(sum(v * v for v in vecteur) - 1.0) < 1e-6
def test_stable_et_discriminant():
embeddeur = EmbeddeurDeterministe()
a1 = embeddeur.encoder(["couple de serrage des coulisseaux de guides"])[0]
a2 = embeddeur.encoder(["couple de serrage des coulisseaux de guides"])[0]
b = embeddeur.encoder(["planning des congés du personnel administratif"])[0]
cosinus = lambda x, y: sum(p * q for p, q in zip(x, y)) # noqa: E731 — vecteurs normés
assert a1 == a2
assert cosinus(a1, b) < 0.35 < cosinus(a1, a1)
def test_textes_proches_plus_similaires_que_textes_eloignes():
embeddeur = EmbeddeurDeterministe()
question = embeddeur.encoder(["quel couple de serrage pour les guides ?"])[0]
notice = embeddeur.encoder(["serrer les coulisseaux de guides au couple de 25 N·m"])[0]
horsujet = embeddeur.encoder(["le syndic organise une assemblée générale annuelle"])[0]
cosinus = lambda x, y: sum(p * q for p, q in zip(x, y)) # noqa: E731
assert cosinus(question, notice) > cosinus(question, horsujet)

View File

@@ -0,0 +1,62 @@
"""ADR-004 §3 — la génération est OPT-IN et configurable : clé, modèle, mode.
Sans clé, le mode extractif est le contrat ; en mode api sans clé, le boot
refuse (config validée au démarrage, comme l'API NestJS)."""
import pytest
from siop_ai.config import charger_reglages
from siop_ai.generation import GenerateurExtractif, construire_generateur, construire_invite
from siop_ai.recherche import ExtraitTrouve
def _extrait(titre: str, locator: str, content: str) -> ExtraitTrouve:
return ExtraitTrouve(
source_type="DOCUMENT",
document_id="d-1",
work_order_id=None,
titre=titre,
locator=locator,
content=content,
score=0.9,
)
def test_defaut_extractif_sans_cle():
generateur = construire_generateur("off", "", "claude-opus-4-8")
assert isinstance(generateur, GenerateurExtractif)
assert generateur.rediger("couple de serrage ?", [_extrait("n", "p. 1", "x")]) is None
def test_mode_api_sans_cle_refuse_au_boot(monkeypatch):
monkeypatch.setenv("AI_GENERATION", "api")
monkeypatch.delenv("AI_API_KEY", raising=False)
with pytest.raises(ValueError, match="AI_API_KEY"):
charger_reglages()
def test_mode_inconnu_refuse_au_boot(monkeypatch):
monkeypatch.setenv("AI_GENERATION", "toujours")
with pytest.raises(ValueError, match="AI_GENERATION"):
charger_reglages()
def test_cle_et_modele_configurables(monkeypatch):
monkeypatch.setenv("AI_GENERATION", "api")
monkeypatch.setenv("AI_API_KEY", "sk-test-123")
monkeypatch.setenv("AI_MODEL", "claude-opus-4-8")
reglages = charger_reglages()
assert reglages.ai_api_key == "sk-test-123"
assert reglages.ai_model == "claude-opus-4-8"
def test_invite_numerote_les_extraits_et_rien_d_autre():
invite = construire_invite(
"quel couple de serrage pour les guides ?",
[
_extrait("Notice Gen2.pdf", "p. 42", "Serrer à 25 N·m."),
_extrait("OT-2026-0341", "bilan du 2026-07-17", "Coulisseaux remplacés."),
],
)
assert "[1] Notice Gen2.pdf · p. 42" in invite
assert "[2] OT-2026-0341 · bilan du 2026-07-17" in invite
assert invite.endswith("Question : quel couple de serrage pour les guides ?")

View File

@@ -0,0 +1,36 @@
"""Dictée opt-in (R5 D5, ADR-004 §5) : off par défaut (aucune dépendance
audio requise), déterministe en CI/tests. Le vrai moteur (faster-whisper)
se vérifie en recette réelle (convention R5.1 — pas de mesure préalable
demandée par le référent, 22/07/2026)."""
import pytest
from siop_ai.config import charger_reglages
from siop_ai.transcription import TranscripteurDeterministe, construire_transcripteur
def test_off_par_defaut_ne_construit_rien():
assert construire_transcripteur("off", "small") is None
def test_deterministe_stable_et_derive_la_taille():
transcripteur = construire_transcripteur("deterministe", "small")
assert isinstance(transcripteur, TranscripteurDeterministe)
texte = transcripteur.transcrire(b"12345", "m4a")
assert "5 octets" in texte
assert ".m4a" in texte
assert transcripteur.transcrire(b"12345", "m4a") == texte # stable
def test_mode_inconnu_refuse_au_boot(monkeypatch):
monkeypatch.setenv("AI_TRANSCRIPTION", "toujours")
with pytest.raises(ValueError, match="AI_TRANSCRIPTION"):
charger_reglages()
def test_modele_configurable(monkeypatch):
monkeypatch.setenv("AI_TRANSCRIPTION", "deterministe")
monkeypatch.setenv("AI_TRANSCRIPTION_MODEL", "medium")
reglages = charger_reglages()
assert reglages.ai_transcription == "deterministe"
assert reglages.ai_transcription_model == "medium"

1974
apps/ai/uv.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -18,3 +18,6 @@ MINIO_ENDPOINT=localhost
MINIO_PORT=9000 MINIO_PORT=9000
MINIO_ACCESS_KEY=siop MINIO_ACCESS_KEY=siop
MINIO_SECRET_KEY=siop-minio MINIO_SECRET_KEY=siop-minio
# R4 — origines navigateur autorisées (Expo web / debug mobile). Vide = pas de CORS.
CORS_ORIGINS=http://localhost:8081

View File

@@ -23,6 +23,7 @@
"@nestjs/core": "^11.1.0", "@nestjs/core": "^11.1.0",
"@nestjs/jwt": "^11.0.0", "@nestjs/jwt": "^11.0.0",
"@nestjs/platform-express": "^11.1.0", "@nestjs/platform-express": "^11.1.0",
"@nestjs/throttler": "^6.5.0",
"@prisma/client": "^6.8.0", "@prisma/client": "^6.8.0",
"@siop/shared": "workspace:*", "@siop/shared": "workspace:*",
"argon2": "^0.43.0", "argon2": "^0.43.0",
@@ -39,6 +40,7 @@
"@nestjs/testing": "^11.1.0", "@nestjs/testing": "^11.1.0",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"@types/multer": "^2.2.0",
"@types/node": "^24.0.0", "@types/node": "^24.0.0",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"jest": "^29.7.0", "jest": "^29.7.0",

View File

@@ -6,6 +6,46 @@ import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient(); const prisma = new PrismaClient();
async function main() { async function main() {
// R3 — d'abord les traces de gestion (les mouvements liés à un OT E2E
// seraient orphelins après sa suppression et fausseraient les stocks seedés)
await prisma.stockMovement.deleteMany({
where: {
OR: [
{ part: { designation: { contains: 'E2E' } } },
{ workOrder: { title: { contains: 'E2E' } } },
{ purchaseOrder: { supplier: { name: { contains: 'E2E' } } } },
],
},
});
await prisma.document.deleteMany({
where: {
OR: [
{ fileName: { contains: 'e2e' } },
{ workOrder: { title: { contains: 'E2E' } } },
],
},
});
await prisma.purchaseOrderLine.deleteMany({
where: {
OR: [
{ part: { designation: { contains: 'E2E' } } },
{ purchaseOrder: { supplier: { name: { contains: 'E2E' } } } },
],
},
});
await prisma.purchaseOrder.deleteMany({
where: { supplier: { name: { contains: 'E2E' } } },
});
await prisma.part.deleteMany({ where: { designation: { contains: 'E2E' } } });
await prisma.partner.deleteMany({ where: { name: { contains: 'E2E' } } });
await prisma.request.deleteMany({ where: { description: { contains: 'E2E' } } });
await prisma.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.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' }, parentId: { not: null } } });
await prisma.location.deleteMany({ where: { name: { contains: 'E2E' } } }); await prisma.location.deleteMany({ where: { name: { contains: 'E2E' } } });

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

@@ -0,0 +1,36 @@
-- R5 (ADR-004) : pgvector — même patron que PostGIS en r1_referentiel
CREATE EXTENSION IF NOT EXISTS vector;
-- CreateEnum
CREATE TYPE "RagSourceType" AS ENUM ('DOCUMENT', 'WORK_ORDER');
-- AlterTable
ALTER TABLE "Document" ADD COLUMN "chunkCount" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "inCorpus" BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN "indexedAt" TIMESTAMP(3);
-- CreateTable
CREATE TABLE "RagChunk" (
"id" UUID NOT NULL,
"sourceType" "RagSourceType" NOT NULL,
"documentId" UUID,
"workOrderId" UUID,
"locator" TEXT NOT NULL,
"content" TEXT NOT NULL,
"embedding" vector(384) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RagChunk_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "RagChunk_documentId_idx" ON "RagChunk"("documentId");
-- CreateIndex
CREATE INDEX "RagChunk_workOrderId_idx" ON "RagChunk"("workOrderId");
-- AddForeignKey
ALTER TABLE "RagChunk" ADD CONSTRAINT "RagChunk_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "Document"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RagChunk" ADD CONSTRAINT "RagChunk_workOrderId_fkey" FOREIGN KEY ("workOrderId") REFERENCES "WorkOrder"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,8 @@
-- Décision de recette R5 (journal 17/07) : le modèle d'embeddings passe de
-- MiniLM (384 dims) à paraphrase-multilingual-mpnet-base-v2 (768 dims) —
-- MiniLM classait la page-réponse derrière des passages sans rapport.
-- Les chunks sont re-dérivables : on vide l'index et on change la dimension ;
-- une réindexation (bouton « Réindexer tout » ou /assistant/reindex) reconstruit tout.
TRUNCATE "RagChunk";
ALTER TABLE "RagChunk" DROP COLUMN "embedding";
ALTER TABLE "RagChunk" ADD COLUMN "embedding" vector(768) NOT NULL;

View File

@@ -0,0 +1,16 @@
-- CreateTable
CREATE TABLE "_LocationToUser" (
"A" UUID NOT NULL,
"B" UUID NOT NULL,
CONSTRAINT "_LocationToUser_AB_pkey" PRIMARY KEY ("A","B")
);
-- CreateIndex
CREATE INDEX "_LocationToUser_B_index" ON "_LocationToUser"("B");
-- AddForeignKey
ALTER TABLE "_LocationToUser" ADD CONSTRAINT "_LocationToUser_A_fkey" FOREIGN KEY ("A") REFERENCES "Location"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_LocationToUser" ADD CONSTRAINT "_LocationToUser_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -49,6 +49,21 @@ model User {
activationToken String? @unique activationToken String? @unique
activationExpiresAt DateTime? activationExpiresAt DateTime?
teams Team[] teams Team[]
// R6.6 — sites autorisés en signalement (Demandeur) ; vide = aucune restriction
assignedSites Location[]
// R2 — exploitation
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()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -75,6 +90,7 @@ model Category {
isActive Boolean @default(true) // désactivable, jamais supprimée si utilisée isActive Boolean @default(true) // désactivable, jamais supprimée si utilisée
assets Asset[] assets Asset[]
components AssetComponent[] components AssetComponent[]
taskTemplates TaskTemplate[]
@@unique([kind, name]) @@unique([kind, name])
} }
@@ -93,11 +109,16 @@ model Location {
longitude Float? longitude Float?
// + colonne PostGIS générée (voir migration r1_referentiel) : // + colonne PostGIS générée (voir migration r1_referentiel) :
// position geography(Point,4326) GENERATED ALWAYS AS (…) STORED // position geography(Point,4326) GENERATED ALWAYS AS (…) STORED
// Client/syndic gérant le site (R3 — colonne « Rattachements » des Tiers)
partnerId String? @db.Uuid
partner Partner? @relation(fields: [partnerId], references: [id])
assets Asset[] assets Asset[]
assignedUsers User[] // reverse de User.assignedSites (R6.6)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@index([parentId]) @@index([parentId])
@@index([partnerId])
} }
model Asset { model Asset {
@@ -110,11 +131,16 @@ model Asset {
loadKg Int? loadKg Int?
floors Int? floors Int?
status AssetStatus @default(IN_SERVICE) // statut d'ÉQUIPEMENT ≠ statut d'OT status AssetStatus @default(IN_SERVICE) // statut d'ÉQUIPEMENT ≠ statut d'OT
underContract Boolean @default(true) // contrat préventif (grille mensuelle)
categoryId String @db.Uuid categoryId String @db.Uuid
category Category @relation(fields: [categoryId], references: [id]) category Category @relation(fields: [categoryId], references: [id])
locationId String @db.Uuid locationId String @db.Uuid
location Location @relation(fields: [locationId], references: [id]) location Location @relation(fields: [locationId], references: [id])
components AssetComponent[] components AssetComponent[]
workOrders WorkOrder[]
requests Request[]
meters Meter[]
documents Document[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -141,3 +167,386 @@ model Team {
description String? description String?
members User[] members User[]
} }
// ————— R2 — Exploitation (docs/03-architecture/modele-donnees.md §R2) —————
enum WorkOrderType {
CORRECTIVE // Dépannage
PREVENTIVE // Maintenance (grille du mois)
WORKS // Travaux
}
enum WorkOrderStatus {
OPEN
IN_PROGRESS
ON_HOLD
DONE
CANCELLED
}
enum WorkOrderPriority {
NONE
LOW
MEDIUM
HIGH
PERSON_TRAPPED // personne bloquée — urgence absolue
}
enum RequestStatus {
RECEIVED
APPROVED
REJECTED
}
enum ChecklistState {
PENDING
DONE
NA
}
enum BilanField {
DOOR_STATE
CABIN_POSITION
ANOMALY
EXTERNAL_CAUSE
ACTION_TAKEN
COMPONENT_CONCERNED
}
enum MeterKind {
RUNNING_HOURS
STARTS
}
model WorkOrder {
id String @id @default(uuid()) @db.Uuid
reference String @unique // OT-2026-0341
title String
description String?
type WorkOrderType
status WorkOrderStatus @default(OPEN) // machine à états stricte (service)
priority WorkOrderPriority @default(NONE)
assetId String @db.Uuid
asset Asset @relation(fields: [assetId], references: [id])
dueDate DateTime?
assignees User[] @relation("WorkOrderAssignees")
createdById String? @db.Uuid
createdBy User? @relation("WorkOrderCreator", fields: [createdById], references: [id])
startedAt DateTime?
completedAt DateTime?
cancelledAt DateTime?
events WorkOrderEvent[]
ragChunks RagChunk[]
checklist ChecklistItem[]
report InterventionReport?
request Request?
stockMovements StockMovement[]
laborTimes LaborTime[]
documents Document[]
// Grille du mois : « AAAA-MM » — l'unicité [assetId, periodKey] EST
// l'idempotence de la génération (les OT correctifs restent à null).
periodKey String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([assetId, periodKey])
@@index([assetId])
@@index([status])
}
model WorkOrderEvent {
id String @id @default(uuid()) @db.Uuid
workOrderId String @db.Uuid
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
kind String // COMMENT · STATUS_CHANGED · ASSIGNED · CREATED · FROM_REQUEST
message String?
byId String? @db.Uuid
by User? @relation(fields: [byId], references: [id])
createdAt DateTime @default(now())
@@index([workOrderId])
}
model Request {
id String @id @default(uuid()) @db.Uuid
reference String @unique // DEM-2026-0112
description String
isPersonTrapped Boolean @default(false)
status RequestStatus @default(RECEIVED)
rejectionReason String? // REQUIS au rejet (service)
assetId String @db.Uuid
asset Asset @relation(fields: [assetId], references: [id])
requestedById String? @db.Uuid
requestedBy User? @relation(fields: [requestedById], references: [id])
requesterName String? // portail public via QR (R2.4)
// Suivi SANS COMPTE (portail) : le téléphone du gardien garde ce jeton,
// seul moyen de lire l'avancement — jamais listé, jamais devinable.
publicToken String? @unique
workOrderId String? @unique @db.Uuid // lien 1-1 — jamais de doublon
workOrder WorkOrder? @relation(fields: [workOrderId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([assetId])
}
// Référentiels administrables du bilan codé (un par champ)
model ReferenceValue {
id String @id @default(uuid()) @db.Uuid
field BilanField
label String
isActive Boolean @default(true)
doorStates InterventionReport[] @relation("BilanDoorState")
cabinPositions InterventionReport[] @relation("BilanCabinPosition")
anomalies InterventionReport[] @relation("BilanAnomaly")
externalCauses InterventionReport[] @relation("BilanExternalCause")
actionsTaken InterventionReport[] @relation("BilanActionTaken")
componentsConcerned InterventionReport[] @relation("BilanComponentConcerned")
@@unique([field, label])
}
model InterventionReport {
id String @id @default(uuid()) @db.Uuid
workOrderId String @unique @db.Uuid
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
note String?
doorStateId String? @db.Uuid
doorState ReferenceValue? @relation("BilanDoorState", fields: [doorStateId], references: [id])
cabinPositionId String? @db.Uuid
cabinPosition ReferenceValue? @relation("BilanCabinPosition", fields: [cabinPositionId], references: [id])
anomalyId String? @db.Uuid
anomaly ReferenceValue? @relation("BilanAnomaly", fields: [anomalyId], references: [id])
externalCauseId String? @db.Uuid
externalCause ReferenceValue? @relation("BilanExternalCause", fields: [externalCauseId], references: [id])
actionTakenId String? @db.Uuid
actionTaken ReferenceValue? @relation("BilanActionTaken", fields: [actionTakenId], references: [id])
componentConcernedId String? @db.Uuid
componentConcerned ReferenceValue? @relation("BilanComponentConcerned", fields: [componentConcernedId], references: [id])
updatedAt DateTime @updatedAt
}
model TaskTemplate {
id String @id @default(uuid()) @db.Uuid
label String @unique
componentTypeId String? @db.Uuid
componentType Category? @relation(fields: [componentTypeId], references: [id])
periodMonths Int // 1, 3, 6, 12…
isRegulatory Boolean @default(false) // essai parachute
isActive Boolean @default(true)
checklistItems ChecklistItem[]
}
model ChecklistItem {
id String @id @default(uuid()) @db.Uuid
workOrderId String @db.Uuid
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
label String
state ChecklistState @default(PENDING)
templateId String? @db.Uuid
template TaskTemplate? @relation(fields: [templateId], references: [id])
doneById String? @db.Uuid
doneBy User? @relation(fields: [doneById], references: [id])
doneAt DateTime?
@@index([workOrderId])
}
model Meter {
id String @id @default(uuid()) @db.Uuid
assetId String @db.Uuid
asset Asset @relation(fields: [assetId], references: [id], onDelete: Cascade)
kind MeterKind
readings MeterReading[]
@@unique([assetId, kind])
}
model MeterReading {
id String @id @default(uuid()) @db.Uuid
meterId String @db.Uuid
meter Meter @relation(fields: [meterId], references: [id], onDelete: Cascade)
value Int // strictement croissant (service)
readById String? @db.Uuid
readBy User? @relation(fields: [readById], references: [id])
createdAt DateTime @default(now())
@@index([meterId])
}
// ————— R3 — Gestion (docs/03-architecture/modele-donnees.md §R3) —————
enum PartnerKind {
SUPPLIER
CLIENT // syndic / propriétaire
}
enum PurchaseOrderStatus {
DRAFT
SENT
RECEIVED
CANCELLED
}
enum StockMovementKind {
RECEIPT // réception de BC (+, PU figé)
ENTRY // entrée manuelle (+)
CONSUMPTION // consommation d'OT (, PU figé)
ADJUSTMENT // inventaire (±, motif REQUIS)
}
enum DocumentKind {
NOTICE
CERTIFICATE
PHOTO
OTHER
}
model Partner {
id String @id @default(uuid()) @db.Uuid
name String @unique
kind PartnerKind
contactName String?
phone String?
email String?
city String?
isActive Boolean @default(true)
parts Part[]
purchaseOrders PurchaseOrder[]
sites Location[] // sites gérés (kind CLIENT)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Part {
id String @id @default(uuid()) @db.Uuid
reference String @unique // P-0113
designation String
threshold Int @default(0) // seuil d'alerte
// Dernier prix d'achat — figé sur chaque mouvement au moment T.
lastUnitPrice Decimal? @db.Decimal(10, 2)
compatible String?
supplierId String? @db.Uuid
supplier Partner? @relation(fields: [supplierId], references: [id])
isActive Boolean @default(true)
movements StockMovement[]
orderLines PurchaseOrderLine[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// Le stock EST la somme des mouvements — aucune colonne de quantité.
model StockMovement {
id String @id @default(uuid()) @db.Uuid
partId String @db.Uuid
part Part @relation(fields: [partId], references: [id], onDelete: Cascade)
kind StockMovementKind
quantity Int // signé : + entrée, sortie
unitPrice Decimal? @db.Decimal(10, 2) // figé (réception, consommation)
reason String? // ajustement : motif requis (service)
workOrderId String? @db.Uuid
workOrder WorkOrder? @relation(fields: [workOrderId], references: [id])
purchaseOrderId String? @db.Uuid
purchaseOrder PurchaseOrder? @relation(fields: [purchaseOrderId], references: [id])
byId String? @db.Uuid
by User? @relation(fields: [byId], references: [id])
createdAt DateTime @default(now())
@@index([partId])
@@index([workOrderId])
}
model PurchaseOrder {
id String @id @default(uuid()) @db.Uuid
reference String @unique // BC-2026-0024
status PurchaseOrderStatus @default(DRAFT)
supplierId String @db.Uuid
supplier Partner @relation(fields: [supplierId], references: [id])
lines PurchaseOrderLine[]
movements StockMovement[]
sentAt DateTime?
receivedAt DateTime?
cancelledAt DateTime?
createdById String? @db.Uuid
createdBy User? @relation(fields: [createdById], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model PurchaseOrderLine {
id String @id @default(uuid()) @db.Uuid
purchaseOrderId String @db.Uuid
purchaseOrder PurchaseOrder @relation(fields: [purchaseOrderId], references: [id], onDelete: Cascade)
partId String @db.Uuid
part Part @relation(fields: [partId], references: [id])
quantity Int
unitPrice Decimal @db.Decimal(10, 2)
@@index([purchaseOrderId])
}
// Main-d'œuvre : le taux est FIGÉ à la saisie (le coût d'un OT ne bouge plus).
model LaborTime {
id String @id @default(uuid()) @db.Uuid
workOrderId String @db.Uuid
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
userId String @db.Uuid
user User @relation(fields: [userId], references: [id])
minutes Int
hourlyRate Decimal @db.Decimal(8, 2)
note String?
createdAt DateTime @default(now())
@@index([workOrderId])
}
model Document {
id String @id @default(uuid()) @db.Uuid
kind DocumentKind
fileName String
storageKey String @unique // clé MinIO (FileStorage)
size Int
contentType String
assetId String? @db.Uuid
asset Asset? @relation(fields: [assetId], references: [id])
workOrderId String? @db.Uuid
workOrder WorkOrder? @relation(fields: [workOrderId], references: [id])
uploadedById String? @db.Uuid
uploadedBy User? @relation(fields: [uploadedById], references: [id])
createdAt DateTime @default(now())
// R5 (D3) : le corpus est visible et réversible, document par document.
inCorpus Boolean @default(true)
indexedAt DateTime? // null = jamais indexé
chunkCount Int @default(0)
chunks RagChunk[]
@@index([assetId])
@@index([workOrderId])
}
/// R5 — extraits indexés du corpus (ADR-004) : bibliothèque + bilans codés,
/// ANONYMISÉS À L'INGESTION (D4). Écrit par apps/ai, schéma possédé par Prisma.
model RagChunk {
id String @id @default(uuid()) @db.Uuid
sourceType RagSourceType
documentId String? @db.Uuid
document Document? @relation(fields: [documentId], references: [id], onDelete: Cascade)
workOrderId String? @db.Uuid
workOrder WorkOrder? @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
/// Repère humain de la source : « p. 42 », « bilan du 17/07/2026 »…
locator String
content String
embedding Unsupported("vector(768)")
createdAt DateTime @default(now())
@@index([documentId])
@@index([workOrderId])
}
enum RagSourceType {
DOCUMENT
WORK_ORDER
}

View File

@@ -133,6 +133,8 @@ export async function seed(prisma: PrismaClient): Promise<void> {
); );
await seedUsers(prisma, roleIds, passwordHash); await seedUsers(prisma, roleIds, passwordHash);
await seedReferentiel(prisma); await seedReferentiel(prisma);
await seedExploitation(prisma);
await seedGestion(prisma);
} }
async function seedUsers( async function seedUsers(
@@ -358,6 +360,422 @@ async function seedReferentiel(prisma: PrismaClient): Promise<void> {
} }
} }
// ————— 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();
// R6.6 — Karim (Demandeur) est rattaché à Tour Atlas (cohérent avec le
// guardianName déjà seedé pour ce site) : son signalement se limite à ce
// parc, ses demandes historiques sur d'autres sites restent visibles.
const tourAtlas = await prisma.location.findFirst({
where: { name: 'Tour Atlas', parentId: null },
});
if (tourAtlas) {
await prisma.user.update({
where: { id: karim },
data: { assignedSites: { connect: [{ id: tourAtlas.id }] } },
});
}
const grilleLabels = TASK_TEMPLATES.filter((t) => t.periodMonths === 1).map(
(t) => t.label,
);
const OTS: {
ref: string; title: string; type: 'CORRECTIVE' | 'PREVENTIVE' | 'WORKS';
status: 'OPEN' | 'IN_PROGRESS' | 'ON_HOLD' | 'DONE' | 'CANCELLED';
priority: 'NONE' | 'LOW' | 'MEDIUM' | 'HIGH' | 'PERSON_TRAPPED';
assetId: string; assignees?: string[]; dueJours?: number;
checklist?: string[]; bilan?: Record<string, string>;
}[] = [
{
ref: `OT-${annee}-0342`, title: 'Personne bloquée en cabine', type: 'CORRECTIVE',
status: 'OPEN', priority: 'PERSON_TRAPPED', assetId: b2,
},
{
ref: `OT-${annee}-0341`, title: 'Bruit anormal en gaine', type: 'CORRECTIVE',
status: 'IN_PROGRESS', priority: 'HIGH', assetId: a1, assignees: [ahmed], dueJours: 2,
},
{
ref: `OT-${annee}-0338`, title: 'Grille du mois — juillet', type: 'PREVENTIVE',
status: 'IN_PROGRESS', priority: 'LOW', assetId: a1, assignees: [ahmed],
dueJours: 15, checklist: grilleLabels,
},
{
ref: `OT-${annee}-0332`, title: 'Réglage nivellement cabine', type: 'CORRECTIVE',
status: 'DONE', priority: 'LOW', assetId: a1,
bilan: {
doorStateId: refIds.get('DOOR_STATE/Fonctionnement normal')!,
actionTakenId: refIds.get('ACTION_TAKEN/Réglage')!,
componentConcernedId: refIds.get('COMPONENT_CONCERNED/Guides')!,
},
},
];
for (const ot of OTS) {
const existant = await prisma.workOrder.findUnique({ where: { reference: ot.ref } });
if (existant) continue;
await prisma.workOrder.create({
data: {
reference: ot.ref,
title: ot.title,
type: ot.type,
status: ot.status,
priority: ot.priority,
assetId: ot.assetId,
createdById: salma,
dueDate: ot.dueJours
? new Date(Date.now() + ot.dueJours * 24 * 3600 * 1000)
: undefined,
startedAt: ot.status === 'IN_PROGRESS' || ot.status === 'DONE' ? new Date() : undefined,
completedAt: ot.status === 'DONE' ? new Date() : undefined,
assignees: ot.assignees ? { connect: ot.assignees.map((id) => ({ id })) } : undefined,
events: { create: { kind: 'CREATED', message: 'OT créé (seed)', byId: salma } },
checklist: ot.checklist
? { create: ot.checklist.map((label) => ({ label })) }
: undefined,
report: ot.bilan ? { create: ot.bilan } : undefined,
},
});
}
const DEMANDES: {
ref: string; description: string; assetId: string; isPersonTrapped?: boolean;
status: 'RECEIVED' | 'APPROVED' | 'REJECTED';
rejectionReason?: string; otRef?: string;
}[] = [
{
ref: `DEM-${annee}-0111`, assetId: a1, status: 'RECEIVED',
description: 'La porte ne se ferme plus au 3ᵉ étage, il faut la retenir à la main.',
},
{ ref: `DEM-${annee}-0110`, assetId: c1, status: 'RECEIVED', description: 'Voyant étage éteint.' },
{
ref: `DEM-${annee}-0107`, assetId: a1, status: 'APPROVED',
description: 'Bruit anormal en gaine.', otRef: `OT-${annee}-0341`,
},
{
ref: `DEM-${annee}-0104`, assetId: b2, status: 'REJECTED',
description: 'Odeur de brûlé.', rejectionReason: 'Fausse alerte confirmée sur place par le gardien.',
},
];
for (const dem of DEMANDES) {
const existant = await prisma.request.findUnique({ where: { reference: dem.ref } });
if (existant) continue;
const workOrderId = dem.otRef
? (await prisma.workOrder.findUnique({ where: { reference: dem.otRef } }))?.id
: undefined;
await prisma.request.create({
data: {
reference: dem.ref,
description: dem.description,
isPersonTrapped: dem.isPersonTrapped ?? false,
status: dem.status,
rejectionReason: dem.rejectionReason,
assetId: dem.assetId,
requestedById: karim,
workOrderId,
},
});
}
// Compteurs de la maquette (A1)
for (const [kind, valeurs] of [
['RUNNING_HOURS', [12246, 12322, 12411]],
['STARTS', [1815400, 1831970]],
] as const) {
const meter = await prisma.meter.upsert({
where: { assetId_kind: { assetId: a1, kind } },
update: {},
create: { assetId: a1, kind },
include: { _count: { select: { readings: true } } },
});
if (meter._count.readings === 0) {
await prisma.meterReading.createMany({
data: valeurs.map((value) => ({ meterId: meter.id, value, readById: ahmed })),
});
}
}
}
// ————— R3 — Gestion (tiers, pièces, mouvements, BC, taux — données maquette) —————
const PARTNERS: {
name: string; kind: 'SUPPLIER' | 'CLIENT'; contactName?: string;
phone?: string; email?: string; city?: string;
}[] = [
{ name: 'Ascentech Maroc', kind: 'SUPPLIER', contactName: 'M. Berrini', phone: '05 22 34 56 78', city: 'Casablanca' },
{ name: 'SchindlerParts', kind: 'SUPPLIER', email: 'commandes@schindlerparts.ma' },
{ name: 'Lubmaroc', kind: 'SUPPLIER', phone: '05 22 11 22 33' },
{ name: 'Atlas Property Management', kind: 'CLIENT', contactName: 'Mme Zerhouni', phone: '06 61 98 76 54' },
{ name: 'Syndic Al Manar', kind: 'CLIENT', contactName: 'M. Alami (gardien référent)' },
];
const PARTS: {
reference: string; designation: string; threshold: number;
lastUnitPrice?: number; supplier?: string; compatible?: string;
}[] = [
{ reference: 'P-0019', designation: 'Cellule barrière porte (paire)', threshold: 4, lastUnitPrice: 640, supplier: 'SchindlerParts' },
{ reference: 'P-0031', designation: 'Bouton palier lumineux Ø22', threshold: 10, lastUnitPrice: 45, supplier: 'Ascentech Maroc' },
{ reference: 'P-0042', designation: 'Graisse guide (cartouche 400 g)', threshold: 8, lastUnitPrice: 85, supplier: 'Lubmaroc' },
{ reference: 'P-0087', designation: 'Coulisseau de guide 16 mm', threshold: 6, lastUnitPrice: 120, supplier: 'Ascentech Maroc' },
{ reference: 'P-0113', designation: 'Contact de porte NC-31', threshold: 5, lastUnitPrice: 85, supplier: 'Ascentech Maroc', compatible: 'Otis Gen2 · Schindler 3300' },
];
async function seedGestion(prisma: PrismaClient): Promise<void> {
const partnerIds = new Map<string, string>();
for (const p of PARTNERS) {
const partner = await prisma.partner.upsert({
where: { name: p.name },
update: {},
create: p,
});
partnerIds.set(p.name, partner.id);
}
// Rattachements de la maquette Tiers : les syndics gèrent leur site.
for (const [site, partner] of [
['Tour Atlas', 'Atlas Property Management'],
['Résidence Al Manar', 'Syndic Al Manar'],
] as const) {
await prisma.location.updateMany({
where: { name: site, parentId: null, partnerId: null },
data: { partnerId: partnerIds.get(partner) },
});
}
const partIds = new Map<string, string>();
for (const p of PARTS) {
const part = await prisma.part.upsert({
where: { reference: p.reference },
update: {},
create: {
reference: p.reference,
designation: p.designation,
threshold: p.threshold,
lastUnitPrice: p.lastUnitPrice,
compatible: p.compatible,
supplierId: p.supplier ? partnerIds.get(p.supplier) : undefined,
},
});
partIds.set(p.reference, part.id);
}
// Taux horaires courants (les saisies figent leur propre taux)
for (const [email, taux] of [
['technicien@demo.siop.ma', 120],
['technicien-limite@demo.siop.ma', 90],
] as const) {
await prisma.user.update({ where: { email }, data: { hourlyRate: taux } });
}
// Idempotence : si des mouvements existent déjà, l'histoire est en place
if ((await prisma.stockMovement.count()) > 0) return;
const annee = new Date().getFullYear();
const ahmed = await prisma.user.findUniqueOrThrow({ where: { email: 'technicien@demo.siop.ma' } });
const nadia = await prisma.user.findUniqueOrThrow({ where: { email: 'gestionnaire@demo.siop.ma' } });
// BC reçus (historique) et BC en cours — comme la maquette
const bc21 = await prisma.purchaseOrder.create({
data: {
reference: `BC-${annee}-0021`,
status: 'RECEIVED',
supplierId: partnerIds.get('Ascentech Maroc')!,
createdById: nadia.id,
sentAt: new Date(Date.now() - 40 * 86400e3),
receivedAt: new Date(Date.now() - 34 * 86400e3),
lines: {
create: [
{ partId: partIds.get('P-0113')!, quantity: 5, unitPrice: 85 },
{ partId: partIds.get('P-0087')!, quantity: 6, unitPrice: 120 },
{ partId: partIds.get('P-0031')!, quantity: 20, unitPrice: 45 },
],
},
},
include: { lines: true },
});
await prisma.purchaseOrder.create({
data: {
reference: `BC-${annee}-0024`,
status: 'SENT',
supplierId: partnerIds.get('Ascentech Maroc')!,
createdById: nadia.id,
sentAt: new Date(),
lines: {
create: [
{ partId: partIds.get('P-0113')!, quantity: 10, unitPrice: 85 },
{ partId: partIds.get('P-0087')!, quantity: 2, unitPrice: 127.5 },
],
},
},
});
// Mouvements : réceptions du BC-0021 + entrées initiales + consommations
for (const line of bc21.lines) {
await prisma.stockMovement.create({
data: {
partId: line.partId,
kind: 'RECEIPT',
quantity: line.quantity,
unitPrice: line.unitPrice,
purchaseOrderId: bc21.id,
byId: nadia.id,
createdAt: new Date(Date.now() - 34 * 86400e3),
},
});
}
for (const [ref, qte] of [
['P-0042', 19],
['P-0019', 7],
['P-0031', 6],
] as const) {
await prisma.stockMovement.create({
data: {
partId: partIds.get(ref)!,
kind: 'ENTRY',
quantity: qte,
reason: 'Reprise de 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 */ /* c8 ignore start — wrapper CLI */
if (require.main === module) { if (require.main === module) {
const prisma = new PrismaClient(); 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,6 +1,10 @@
import { DynamicModule, Module } from '@nestjs/common'; import { DynamicModule, Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core'; import { APP_GUARD } from '@nestjs/core';
import { AnalyticsModule } from './analytics/analytics.module';
import { SearchModule } from './search/search.module';
import { AssistantModule } from './assistant/assistant.module';
import { AssetsModule } from './assets/assets.module'; import { AssetsModule } from './assets/assets.module';
import { DocumentsModule } from './documents/documents.module';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
import { DemoAuthModule } from './auth/demo/demo-auth.module'; import { DemoAuthModule } from './auth/demo/demo-auth.module';
import { JwtAuthGuard } from './auth/jwt-auth.guard'; import { JwtAuthGuard } from './auth/jwt-auth.guard';
@@ -9,7 +13,16 @@ import { demoModeEnabled } from './config/env';
import { FilesModule } from './files/files.module'; import { FilesModule } from './files/files.module';
import { HealthModule } from './health/health.module'; import { HealthModule } from './health/health.module';
import { LocationsModule } from './locations/locations.module'; import { LocationsModule } from './locations/locations.module';
import { MetersModule } from './meters/meters.module';
import { 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 { TeamsModule } from './teams/teams.module';
import { WorkOrdersModule } from './work-orders/work-orders.module';
import { PermissionsGuard } from './permissions/permissions.guard'; import { PermissionsGuard } from './permissions/permissions.guard';
import { PermissionsModule } from './permissions/permissions.module'; import { PermissionsModule } from './permissions/permissions.module';
import { PrismaModule } from './prisma/prisma.module'; import { PrismaModule } from './prisma/prisma.module';
@@ -37,6 +50,21 @@ export class AppModule {
LocationsModule, LocationsModule,
AssetsModule, AssetsModule,
TeamsModule, TeamsModule,
// R2 — exploitation
WorkOrdersModule,
RequestsModule,
ReferenceValuesModule,
PreventiveModule,
MetersModule,
PortalModule,
// R3 — gestion
PartnersModule,
PartsModule,
PurchaseOrdersModule,
DocumentsModule,
AnalyticsModule,
SearchModule,
AssistantModule,
// ADR-002 : hors DEMO_MODE, le module n'est pas enregistré → 404 // ADR-002 : hors DEMO_MODE, le module n'est pas enregistré → 404
...(demoModeEnabled() ? [DemoAuthModule] : []), ...(demoModeEnabled() ? [DemoAuthModule] : []),
], ],

View File

@@ -17,6 +17,7 @@ import {
type AssetCreate, type AssetCreate,
type AssetUpdate, type AssetUpdate,
} from '@siop/shared'; } from '@siop/shared';
import { type AuthenticatedUser, CurrentUser } from '../auth/current-user.decorator';
import { ZodValidationPipe } from '../common/zod-validation.pipe'; import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { RequirePermission } from '../permissions/require-permission.decorator'; import { RequirePermission } from '../permissions/require-permission.decorator';
import { AssetsService } from './assets.service'; import { AssetsService } from './assets.service';
@@ -31,6 +32,14 @@ export class AssetsController {
return this.assetsService.list(); return this.assetsService.list();
} }
/** Avant ':id' (ordre des routes) — authentification seule : le demandeur
* doit pouvoir désigner l'appareil qu'il signale (filtré à son site s'il
* en a un, R6.6). */
@Get('options')
options(@CurrentUser() user: AuthenticatedUser) {
return this.assetsService.options(user);
}
@Get(':id') @Get(':id')
@RequirePermission('ASSETS', 'view') @RequirePermission('ASSETS', 'view')
get(@Param('id', ParseUUIDPipe) id: string) { get(@Param('id', ParseUUIDPipe) id: string) {

View File

@@ -11,9 +11,11 @@ import type {
AssetCreate, AssetCreate,
AssetDetail, AssetDetail,
AssetDto, AssetDto,
AssetOptionsResponse,
AssetsResponse, AssetsResponse,
AssetUpdate, AssetUpdate,
} from '@siop/shared'; } from '@siop/shared';
import type { AuthenticatedUser } from '../auth/current-user.decorator';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
const assetInclude = { const assetInclude = {
@@ -28,6 +30,42 @@ type AssetRow = Prisma.AssetGetPayload<{ include: typeof assetInclude }>;
export class AssetsService { export class AssetsService {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
/** Sites + zones autorisés pour le signalement de `user`, ou `null` si aucune
* restriction (comportement historique — tous les rôles sauf un Demandeur
* affecté à un site, R6.6). Réutilisée par `options()` ET par
* `RequestsService.create` pour que les deux filtres ne divergent jamais. */
async allowedLocationIds(user: AuthenticatedUser): Promise<string[] | null> {
const me = await this.prisma.user.findUnique({
where: { id: user.userId },
select: { assignedSites: { select: { id: true } } },
});
const siteIds = (me?.assignedSites ?? []).map((s) => s.id);
if (siteIds.length === 0) return null;
const zones = await this.prisma.location.findMany({
where: { parentId: { in: siteIds } },
select: { id: true },
});
return [...siteIds, ...zones.map((z) => z.id)];
}
/** Options minimales pour le signalement — ouvert à tout rôle authentifié,
* filtré au périmètre du Demandeur s'il est affecté à un site (R6.6). */
async options(user: AuthenticatedUser): Promise<AssetOptionsResponse> {
const allowed = await this.allowedLocationIds(user);
const rows = await this.prisma.asset.findMany({
where: allowed ? { locationId: { in: allowed } } : undefined,
include: { location: { include: { parent: true } } },
orderBy: { reference: 'asc' },
});
return {
options: rows.map((r) => ({
id: r.id,
reference: r.reference,
siteName: r.location.parent?.name ?? r.location.name,
})),
};
}
async list(): Promise<AssetsResponse> { async list(): Promise<AssetsResponse> {
const rows = await this.prisma.asset.findMany({ const rows = await this.prisma.asset.findMany({
include: assetInclude, include: assetInclude,

View File

@@ -0,0 +1,66 @@
import {
BadRequestException,
Body,
Controller,
HttpCode,
Post,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import {
AssistantAskSchema,
SuggestBilanSchema,
type AssistantAsk,
type SuggestBilan,
} from '@siop/shared';
import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { RequirePermission } from '../permissions/require-permission.decorator';
import { AssistantService } from './assistant.service';
// Dictée (D5) : un enregistrement de quelques dizaines de secondes tient
// largement dans 15 Mo — pas besoin du plafond 20 Mo des documents R3.
const AUDIO_MAX_BYTES = 15 * 1024 * 1024;
@Controller('assistant')
export class AssistantController {
constructor(private readonly assistant: AssistantService) {}
/** Poser une question — qui lit les OT peut interroger le corpus. */
@Post('ask')
@HttpCode(200)
@RequirePermission('WORK_ORDERS', 'view')
ask(@Body(new ZodValidationPipe(AssistantAskSchema)) body: AssistantAsk) {
return this.assistant.ask(body);
}
/** Réindexer le corpus — même droit que la gestion du référentiel (D3). */
@Post('reindex')
@HttpCode(200)
@RequirePermission('ASSETS', 'edit')
reindex() {
return this.assistant.reindex();
}
/** Suggérer des codes — réservé à qui remplit des bilans (D1). */
@Post('suggest-bilan')
@HttpCode(200)
@RequirePermission('WORK_ORDERS', 'edit')
suggest(@Body(new ZodValidationPipe(SuggestBilanSchema)) body: SuggestBilan) {
return this.assistant.suggestBilan(body);
}
/** Dictée (D5) — même droit que la saisie du bilan qu'elle alimente. */
@Post('transcribe')
@HttpCode(200)
@RequirePermission('WORK_ORDERS', 'edit')
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: AUDIO_MAX_BYTES } }))
transcribe(@UploadedFile() file: Express.Multer.File | undefined) {
if (!file) throw new BadRequestException('Aucun enregistrement reçu (champ « file »)');
return this.assistant.transcribe({
buffer: file.buffer,
originalName: file.originalname,
contentType: file.mimetype,
});
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { AssistantController } from './assistant.controller';
import { AssistantService } from './assistant.service';
@Module({
controllers: [AssistantController],
providers: [AssistantService],
})
export class AssistantModule {}

View File

@@ -0,0 +1,161 @@
import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
import type {
AssistantAnswer,
AssistantAsk,
BilanField,
BilanSuggestionsResponse,
ReindexResult,
SuggestBilan,
TranscriptionResult,
} from '@siop/shared';
import { loadEnv } from '../config/env';
/** Proxy vers `siop2-ai` (ADR-004 §4) : le service IA n'est JAMAIS public —
* l'API porte l'auth utilisateur (matrice) et le jeton de service interne.
* Il traduit aussi le dialecte interne (français, snake_case) vers le
* contrat (@siop/shared) — une seule vérité côté clients. */
interface ExtraitInterne {
source_type: 'DOCUMENT' | 'WORK_ORDER';
document_id: string | null;
work_order_id: string | null;
titre: string;
locator: string;
content: string;
score: number;
}
const MODES = { extractif: 'EXTRACTIVE', genere: 'GENERATED', refus: 'REFUSAL' } as const;
const CONFIANCES = { FORTE: 'HIGH', MOYENNE: 'MEDIUM' } as const;
@Injectable()
export class AssistantService {
private readonly journal = new Logger(AssistantService.name);
private readonly env = loadEnv();
private async appeler<T>(chemin: string, corps: unknown): Promise<T> {
let reponse: Response;
try {
reponse = await fetch(`${this.env.AI_SERVICE_URL}${chemin}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Service-Token': this.env.AI_SERVICE_TOKEN,
},
body: JSON.stringify(corps),
});
} catch {
this.journal.warn(`Service IA injoignable (${chemin})`);
throw new ServiceUnavailableException(
'Assistant indisponible pour le moment — réessayez dans un instant.',
);
}
if (!reponse.ok) {
this.journal.warn(`Service IA a refusé ${chemin} (${reponse.status})`);
throw new ServiceUnavailableException(
'Assistant indisponible pour le moment — réessayez dans un instant.',
);
}
return (await reponse.json()) as T;
}
async ask(dto: AssistantAsk): Promise<AssistantAnswer> {
const brut = await this.appeler<{
mode: keyof typeof MODES;
answer: string | null;
extraits: ExtraitInterne[];
corpus: { documents: number; bilans: number };
}>('/internal/ask', { question: dto.question });
return {
mode: MODES[brut.mode],
answer: brut.answer,
excerpts: brut.extraits.map((e) => ({
sourceType: e.source_type,
documentId: e.document_id,
workOrderId: e.work_order_id,
title: e.titre,
locator: e.locator,
content: e.content,
score: e.score,
})),
corpus: { documents: brut.corpus.documents, reports: brut.corpus.bilans },
};
}
async reindex(): Promise<ReindexResult> {
const brut = await this.appeler<{
documents_indexes: number;
documents_ignores: number;
bilans_indexes: number;
extraits: number;
}>('/internal/reindex', {});
return {
documentsIndexed: brut.documents_indexes,
documentsSkipped: brut.documents_ignores,
reportsIndexed: brut.bilans_indexes,
chunks: brut.extraits,
};
}
/** Dictée (R5 D5, opt-in) : l'audio ne transite qu'une fois vers `siop2-ai`
* — jamais écrit ni sur disque ni en base ici, transmis tel quel en
* multipart. Le service IA l'efface aussitôt transcrit (transcription.py). */
async transcribe(audio: {
buffer: Buffer;
originalName: string;
contentType: string;
}): Promise<TranscriptionResult> {
const form = new FormData();
form.append(
'fichier',
new Blob([new Uint8Array(audio.buffer)], { type: audio.contentType }),
audio.originalName,
);
let reponse: Response;
try {
reponse = await fetch(`${this.env.AI_SERVICE_URL}/internal/transcrire`, {
method: 'POST',
headers: { 'X-Service-Token': this.env.AI_SERVICE_TOKEN },
body: form,
});
} catch {
this.journal.warn('Service IA injoignable (/internal/transcrire)');
throw new ServiceUnavailableException(
'Dictée indisponible pour le moment — réessayez dans un instant.',
);
}
if (!reponse.ok) {
this.journal.warn(`Service IA a refusé /internal/transcrire (${reponse.status})`);
throw new ServiceUnavailableException(
'Dictée indisponible pour le moment — réessayez dans un instant.',
);
}
const brut = (await reponse.json()) as { texte: string };
return { text: brut.texte };
}
async suggestBilan(dto: SuggestBilan): Promise<BilanSuggestionsResponse> {
const brut = await this.appeler<{
suggestions: {
field: BilanField;
value_id: string;
label: string;
confidence: keyof typeof CONFIANCES;
similar_reports: number;
score: number;
}[];
}>('/internal/suggest', { description: dto.description });
return {
suggestions: brut.suggestions.map((s) => ({
field: s.field,
valueId: s.value_id,
label: s.label,
confidence: CONFIANCES[s.confidence],
similarReports: s.similar_reports,
score: s.score,
})),
};
}
}

View File

@@ -13,6 +13,14 @@ const EnvSchema = z.object({
MINIO_USE_SSL: z.string().optional(), MINIO_USE_SSL: z.string().optional(),
MINIO_ACCESS_KEY: z.string().default('siop'), MINIO_ACCESS_KEY: z.string().default('siop'),
MINIO_SECRET_KEY: z.string().default('siop-minio'), MINIO_SECRET_KEY: z.string().default('siop-minio'),
MINIO_BUCKET: z.string().default('siop2'),
// R4 : origines navigateur autorisées en CORS (Expo web / debug mobile).
// Vide = aucun CORS (défaut sûr) — le web de prod passe par le proxy nginx
// même-origine, les apps natives n'envoient pas d'Origin.
CORS_ORIGINS: z.string().default(''),
// R5 (ADR-004 §4) : le service IA interne — seul l'API le contacte.
AI_SERVICE_URL: z.string().default('http://localhost:8000'),
AI_SERVICE_TOKEN: z.string().default('dev-only-ai-token'),
}); });
export type Env = z.infer<typeof EnvSchema>; export type Env = z.infer<typeof EnvSchema>;

View File

@@ -0,0 +1,94 @@
import {
BadRequestException,
Body,
Controller,
Delete,
Get,
HttpCode,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
StreamableFile,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import {
DOCUMENT_MAX_BYTES,
DocumentCorpusUpdateSchema,
type DocumentCorpusUpdate,
} from '@siop/shared';
import {
AuthenticatedUser,
CurrentUser,
} from '../auth/current-user.decorator';
import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { RequirePermission } from '../permissions/require-permission.decorator';
import { DocumentsService } from './documents.service';
@Controller('documents')
export class DocumentsController {
constructor(private readonly documents: DocumentsService) {}
/** Bibliothèque interne — authentification seule (les cartes « Documents »
* des fiches la consomment). */
@Get()
list(
@Query('assetId') assetId?: string,
@Query('workOrderId') workOrderId?: string,
@Query('kind') kind?: string,
) {
return this.documents.list({ assetId, workOrderId, kind });
}
@Post()
@UseInterceptors(
FileInterceptor('file', { limits: { fileSize: DOCUMENT_MAX_BYTES } }),
)
upload(
@UploadedFile() file: Express.Multer.File | undefined,
@Body() body: { kind?: string; assetId?: string; workOrderId?: string },
@CurrentUser() user: AuthenticatedUser,
) {
if (!file) throw new BadRequestException('Aucun fichier reçu (champ « file »)');
return this.documents.upload(
{
buffer: file.buffer,
originalName: file.originalname,
contentType: file.mimetype,
size: file.size,
kind: body.kind ?? 'OTHER',
assetId: body.assetId || undefined,
workOrderId: body.workOrderId || undefined,
},
user,
);
}
@Get(':id/download')
async download(@Param('id', ParseUUIDPipe) id: string): Promise<StreamableFile> {
const { stream, fileName, contentType } = await this.documents.download(id);
return new StreamableFile(stream as never, {
type: contentType,
disposition: `attachment; filename*=UTF-8''${encodeURIComponent(fileName)}`,
});
}
/** Corpus IA (D3) : réservé aux gestionnaires du référentiel. */
@Patch(':id/corpus')
@RequirePermission('ASSETS', 'edit')
setCorpus(
@Param('id', ParseUUIDPipe) id: string,
@Body(new ZodValidationPipe(DocumentCorpusUpdateSchema)) body: DocumentCorpusUpdate,
) {
return this.documents.setCorpus(id, body.inCorpus);
}
@Delete(':id')
@HttpCode(204)
remove(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: AuthenticatedUser) {
return this.documents.remove(id, user);
}
}

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,175 @@
import {
BadRequestException,
ForbiddenException,
Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import {
DOCUMENT_CONTENT_TYPES,
DOCUMENT_KINDS,
DOCUMENT_MAX_BYTES,
type DocumentDto,
type DocumentKind,
type DocumentsResponse,
} from '@siop/shared';
import { randomUUID } from 'node:crypto';
import type { AuthenticatedUser } from '../auth/current-user.decorator';
import { FILE_STORAGE, type FileStorage } from '../files/file-storage';
import { PermissionsService } from '../permissions/permissions.service';
import { PrismaService } from '../prisma/prisma.service';
const documentInclude = {
asset: { select: { reference: true } },
workOrder: { select: { reference: true } },
uploadedBy: { select: { displayName: true } },
} satisfies Prisma.DocumentInclude;
type Row = Prisma.DocumentGetPayload<{ include: typeof documentInclude }>;
export interface UploadInput {
buffer: Buffer;
originalName: string;
contentType: string;
size: number;
kind: string;
assetId?: string;
workOrderId?: string;
}
@Injectable()
export class DocumentsService {
constructor(
private readonly prisma: PrismaService,
private readonly permissions: PermissionsService,
@Inject(FILE_STORAGE) private readonly storage: FileStorage,
) {}
async list(filtres: {
assetId?: string;
workOrderId?: string;
kind?: string;
}): Promise<DocumentsResponse> {
const rows = await this.prisma.document.findMany({
where: {
assetId: filtres.assetId || undefined,
workOrderId: filtres.workOrderId || undefined,
kind: (filtres.kind as DocumentKind) || undefined,
},
include: documentInclude,
orderBy: { createdAt: 'desc' },
});
return { documents: rows.map((r) => this.toDto(r)) };
}
/** Upload : types fermés, 20 Mo max, rattachement REQUIS, permission
* d'édition sur la CIBLE (appareil → ASSETS, OT → WORK_ORDERS). */
async upload(input: UploadInput, user: AuthenticatedUser): Promise<DocumentDto> {
if (!(DOCUMENT_KINDS as readonly string[]).includes(input.kind)) {
throw new BadRequestException('Type de document inconnu');
}
if (!(DOCUMENT_CONTENT_TYPES as readonly string[]).includes(input.contentType)) {
throw new BadRequestException('Format refusé — PDF, JPG ou PNG uniquement');
}
if (input.size > DOCUMENT_MAX_BYTES) {
throw new BadRequestException('Fichier trop lourd — 20 Mo maximum');
}
if (!input.assetId && !input.workOrderId) {
throw new BadRequestException('Rattachez le document à un appareil ou à un OT');
}
await this.assertCible(input.assetId, input.workOrderId, user, 'edit');
const storageKey = `documents/${randomUUID()}/${input.originalName.replace(/[^\w.\-()À-ſ ]/g, '_')}`;
await this.storage.putObject(storageKey, input.buffer, input.contentType);
const created = await this.prisma.document.create({
data: {
kind: input.kind as DocumentKind,
fileName: input.originalName,
storageKey,
size: input.size,
contentType: input.contentType,
assetId: input.assetId,
workOrderId: input.workOrderId,
uploadedById: user.userId,
},
include: documentInclude,
});
return this.toDto(created);
}
async download(id: string): Promise<{
stream: NodeJS.ReadableStream;
fileName: string;
contentType: string;
}> {
const doc = await this.prisma.document.findUnique({ where: { id } });
if (!doc) throw new NotFoundException('Document inconnu');
return {
stream: await this.storage.getObjectStream(doc.storageKey),
fileName: doc.fileName,
contentType: doc.contentType,
};
}
async remove(id: string, user: AuthenticatedUser): Promise<void> {
const doc = await this.prisma.document.findUnique({ where: { id } });
if (!doc) throw new NotFoundException('Document inconnu');
await this.assertCible(doc.assetId ?? undefined, doc.workOrderId ?? undefined, user, 'edit');
await this.prisma.document.delete({ where: { id } });
await this.storage.removeObject(doc.storageKey).catch(() => {
// le stockage peut être momentanément injoignable — la base fait foi
});
}
private async assertCible(
assetId: string | undefined,
workOrderId: string | undefined,
user: AuthenticatedUser,
right: 'edit',
): Promise<void> {
if (assetId) {
const asset = await this.prisma.asset.findUnique({ where: { id: assetId } });
if (!asset) throw new BadRequestException('Appareil inconnu');
if (!(await this.permissions.can(user.roleId, 'ASSETS', right))) {
throw new ForbiddenException('Droit manquant : ASSETS.edit');
}
}
if (workOrderId) {
const ot = await this.prisma.workOrder.findUnique({ where: { id: workOrderId } });
if (!ot) throw new BadRequestException('OT inconnu');
if (!(await this.permissions.can(user.roleId, 'WORK_ORDERS', right))) {
throw new ForbiddenException('Droit manquant : WORK_ORDERS.edit');
}
}
}
private toDto(row: Row): DocumentDto {
return {
id: row.id,
kind: row.kind,
fileName: row.fileName,
size: row.size,
contentType: row.contentType,
assetReference: row.asset?.reference ?? null,
workOrderReference: row.workOrder?.reference ?? null,
uploadedByName: row.uploadedBy?.displayName ?? null,
createdAt: row.createdAt.toISOString(),
inCorpus: row.inCorpus,
indexedAt: row.indexedAt?.toISOString() ?? null,
chunkCount: row.chunkCount,
};
}
/** Interrupteur corpus (D3) — effectif à la prochaine réindexation. */
async setCorpus(id: string, inCorpus: boolean): Promise<DocumentDto> {
const doc = await this.prisma.document.findUnique({ where: { id } });
if (!doc) throw new NotFoundException('Document inconnu');
const updated = await this.prisma.document.update({
where: { id },
data: { inCorpus },
include: documentInclude,
});
return this.toDto(updated);
}
}

View File

@@ -1,12 +1,15 @@
/** /**
* Interface de stockage de fichiers (ADR-001) : MinIO n'est JAMAIS importé * Interface de stockage de fichiers (ADR-001) : MinIO n'est JAMAIS importé
* ailleurs que dans son implémentation — le reste de l'API dépend de cette * ailleurs que dans son implémentation (règle ESLint) — le reste de l'API
* interface (règle lint à venir en R0.12). R0 : santé seulement ; * dépend de cette interface. R3 : la bibliothèque de documents l'utilise ;
* les opérations arrivent avec les photos/documents (R2-R3). * les téléchargements sont STREAMÉS par l'API (MinIO n'est pas exposé).
*/ */
export const FILE_STORAGE = Symbol('FILE_STORAGE'); export const FILE_STORAGE = Symbol('FILE_STORAGE');
export interface FileStorage { export interface FileStorage {
/** Lève une exception si le stockage est injoignable. */ /** Lève une exception si le stockage est injoignable. */
healthCheck(): Promise<void>; healthCheck(): Promise<void>;
putObject(key: string, body: Buffer, contentType: string): Promise<void>;
getObjectStream(key: string): Promise<NodeJS.ReadableStream>;
removeObject(key: string): Promise<void>;
} }

View File

@@ -1,15 +1,18 @@
import { Injectable } from '@nestjs/common'; import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import * as Minio from 'minio'; import * as Minio from 'minio';
import { loadEnv } from '../config/env'; import { loadEnv } from '../config/env';
import type { FileStorage } from './file-storage'; import type { FileStorage } from './file-storage';
/** Seul fichier du dépôt autorisé à importer le SDK MinIO. */ /** Seul fichier du dépôt autorisé à importer le SDK MinIO (règle ESLint). */
@Injectable() @Injectable()
export class MinioStorageService implements FileStorage { export class MinioStorageService implements FileStorage, OnModuleInit {
private readonly logger = new Logger(MinioStorageService.name);
private readonly client: Minio.Client; private readonly client: Minio.Client;
private readonly bucket: string;
constructor() { constructor() {
const env = loadEnv(); const env = loadEnv();
this.bucket = env.MINIO_BUCKET;
this.client = new Minio.Client({ this.client = new Minio.Client({
endPoint: env.MINIO_ENDPOINT, endPoint: env.MINIO_ENDPOINT,
port: env.MINIO_PORT, port: env.MINIO_PORT,
@@ -19,7 +22,34 @@ export class MinioStorageService implements FileStorage {
}); });
} }
/** Le bucket est créé au démarrage — aucune étape manuelle au déploiement. */
async onModuleInit(): Promise<void> {
try {
if (!(await this.client.bucketExists(this.bucket))) {
await this.client.makeBucket(this.bucket);
this.logger.log(`Bucket « ${this.bucket} » créé.`);
}
} catch (e) {
// Le stockage peut être en retard au boot : /health le signalera.
this.logger.warn(`Stockage indisponible au démarrage : ${String(e)}`);
}
}
async healthCheck(): Promise<void> { async healthCheck(): Promise<void> {
await this.client.listBuckets(); await this.client.listBuckets();
} }
async putObject(key: string, body: Buffer, contentType: string): Promise<void> {
await this.client.putObject(this.bucket, key, body, body.length, {
'Content-Type': contentType,
});
}
async getObjectStream(key: string): Promise<NodeJS.ReadableStream> {
return this.client.getObject(this.bucket, key);
}
async removeObject(key: string): Promise<void> {
await this.client.removeObject(this.bucket, key);
}
} }

View File

@@ -21,9 +21,16 @@ type LocationRow = {
guardianPhone: string | null; guardianPhone: string | null;
latitude: number | null; latitude: number | null;
longitude: number | null; longitude: number | null;
partnerId: string | null;
partner: { name: string } | null;
_count: { assets: number }; _count: { assets: number };
}; };
const locationInclude = {
partner: { select: { name: true } },
_count: { select: { assets: true } },
} as const;
@Injectable() @Injectable()
export class LocationsService { export class LocationsService {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
@@ -31,7 +38,7 @@ export class LocationsService {
/** Liste plate ; l'assetCount d'un SITE inclut les appareils de ses zones. */ /** Liste plate ; l'assetCount d'un SITE inclut les appareils de ses zones. */
async list(): Promise<LocationsResponse> { async list(): Promise<LocationsResponse> {
const rows: LocationRow[] = await this.prisma.location.findMany({ const rows: LocationRow[] = await this.prisma.location.findMany({
include: { _count: { select: { assets: true } } }, include: locationInclude,
orderBy: { name: 'asc' }, orderBy: { name: 'asc' },
}); });
const childAssets = new Map<string, number>(); const childAssets = new Map<string, number>();
@@ -52,9 +59,10 @@ export class LocationsService {
async create(dto: LocationCreate): Promise<LocationDto> { async create(dto: LocationCreate): Promise<LocationDto> {
await this.assertDepth(dto.parentId); await this.assertDepth(dto.parentId);
await this.assertClientPartner(dto.partnerId);
const created = await this.prisma.location.create({ const created = await this.prisma.location.create({
data: dto, data: dto,
include: { _count: { select: { assets: true } } }, include: locationInclude,
}); });
return this.toDto(created, 0); return this.toDto(created, 0);
} }
@@ -76,10 +84,11 @@ export class LocationsService {
} }
await this.assertDepth(dto.parentId); await this.assertDepth(dto.parentId);
} }
await this.assertClientPartner(dto.partnerId);
const updated = await this.prisma.location.update({ const updated = await this.prisma.location.update({
where: { id }, where: { id },
data: dto, data: dto,
include: { _count: { select: { assets: true } } }, include: locationInclude,
}); });
return this.toDto(updated, updated._count.assets); return this.toDto(updated, updated._count.assets);
} }
@@ -96,6 +105,16 @@ export class LocationsService {
} }
} }
/** R3 : seul un tiers Client/syndic peut gérer un site. */
private async assertClientPartner(partnerId?: string | null): Promise<void> {
if (!partnerId) return;
const partner = await this.prisma.partner.findUnique({ where: { id: partnerId } });
if (!partner) throw new BadRequestException('Tiers inconnu');
if (partner.kind !== 'CLIENT') {
throw new BadRequestException('Seul un tiers « Client / syndic » peut être rattaché à un site');
}
}
private toDto(row: Omit<LocationRow, '_count'>, assetCount: number): LocationDto { private toDto(row: Omit<LocationRow, '_count'>, assetCount: number): LocationDto {
return { return {
id: row.id, id: row.id,
@@ -107,6 +126,8 @@ export class LocationsService {
guardianPhone: row.guardianPhone, guardianPhone: row.guardianPhone,
latitude: row.latitude, latitude: row.latitude,
longitude: row.longitude, longitude: row.longitude,
partnerId: row.partnerId,
partnerName: row.partner?.name ?? null,
assetCount, assetCount,
}; };
} }

View File

@@ -9,6 +9,8 @@ async function bootstrap() {
assertDemoModeAllowed(); // ADR-002 — double verrou avant toute écoute réseau assertDemoModeAllowed(); // ADR-002 — double verrou avant toute écoute réseau
const app = await NestFactory.create(AppModule.forRoot()); const app = await NestFactory.create(AppModule.forRoot());
const origines = env.CORS_ORIGINS.split(',').map((o) => o.trim()).filter(Boolean);
if (origines.length) app.enableCors({ origin: origines });
app.enableShutdownHooks(); app.enableShutdownHooks();
await app.listen(env.PORT); await app.listen(env.PORT);
new Logger('Bootstrap').log(`API SIOP V2 démarrée sur :${env.PORT}`); new Logger('Bootstrap').log(`API SIOP V2 démarrée sur :${env.PORT}`);

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,12 @@
import { Module } from '@nestjs/common';
import { AssetsModule } from '../assets/assets.module';
import { WorkOrdersModule } from '../work-orders/work-orders.module';
import { RequestsController } from './requests.controller';
import { RequestsService } from './requests.service';
@Module({
imports: [WorkOrdersModule, AssetsModule],
controllers: [RequestsController],
providers: [RequestsService],
})
export class RequestsModule {}

View File

@@ -0,0 +1,174 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import type {
RequestApprove,
RequestCreate,
RequestReject,
RequestsResponse,
RequestSummary,
WorkOrderDetail,
} from '@siop/shared';
import type { AuthenticatedUser } from '../auth/current-user.decorator';
import { PermissionsService } from '../permissions/permissions.service';
import { PrismaService } from '../prisma/prisma.service';
import { AssetsService } from '../assets/assets.service';
import { WorkOrdersService } from '../work-orders/work-orders.service';
const requestInclude = {
asset: { include: { location: { include: { parent: true } } } },
requestedBy: true,
workOrder: true,
} satisfies Prisma.RequestInclude;
type RequestRow = Prisma.RequestGetPayload<{ include: typeof requestInclude }>;
@Injectable()
export class RequestsService {
constructor(
private readonly prisma: PrismaService,
private readonly permissions: PermissionsService,
private readonly workOrders: WorkOrdersService,
private readonly assets: AssetsService,
) {}
private async scope(user: AuthenticatedUser): Promise<Prisma.RequestWhereInput> {
const viewOther = await this.permissions.can(user.roleId, 'REQUESTS', 'viewOther');
return viewOther ? {} : { requestedById: user.userId };
}
async list(user: AuthenticatedUser): Promise<RequestsResponse> {
const rows = await this.prisma.request.findMany({
where: await this.scope(user),
include: requestInclude,
orderBy: { createdAt: 'desc' },
});
rows.sort((a, b) => Number(b.isPersonTrapped) - Number(a.isPersonTrapped));
return { requests: rows.map((r) => this.toDto(r)) };
}
async create(dto: RequestCreate, user: AuthenticatedUser): Promise<RequestSummary> {
const asset = await this.prisma.asset.findUnique({ where: { id: dto.assetId } });
if (!asset) throw new BadRequestException('Équipement inconnu');
// Défense en profondeur (R6.6) : même filtre que /assets/options, pour
// qu'un Demandeur affecté à un site ne puisse pas contourner la liste
// en soumettant directement un assetId hors périmètre.
const allowed = await this.assets.allowedLocationIds(user);
if (allowed && !allowed.includes(asset.locationId)) {
throw new BadRequestException("Cet équipement n'est pas dans votre périmètre");
}
for (let essai = 0; ; essai++) {
try {
const created = await this.prisma.request.create({
data: {
reference: await this.nextReference(),
description: dto.description,
isPersonTrapped: dto.isPersonTrapped ?? false,
assetId: dto.assetId,
requestedById: user.userId,
},
include: requestInclude,
});
return this.toDto(created);
} catch (e) {
if (
e instanceof Prisma.PrismaClientKnownRequestError &&
e.code === 'P2002' &&
essai < 3
) {
continue;
}
throw e;
}
}
}
/** Approuver = créer l'OT lié (1-1). Une demande ne se traite qu'une fois. */
async approve(
id: string,
dto: RequestApprove,
user: AuthenticatedUser,
): Promise<WorkOrderDetail> {
const request = await this.prisma.request.findUnique({
where: { id },
include: requestInclude,
});
if (!request) throw new NotFoundException('Demande inconnue');
if (request.status !== 'RECEIVED') {
throw new ConflictException('Cette demande a déjà été traitée');
}
const created = await this.workOrders.createRaw({
title: dto.title ?? request.description.slice(0, 120),
description: `${request.description}\n\n(Demande ${request.reference}${this.requesterLabel(request)})`,
type: 'CORRECTIVE',
priority: dto.priority ?? (request.isPersonTrapped ? 'PERSON_TRAPPED' : 'MEDIUM'),
assetId: request.assetId,
dueDate: dto.dueDate,
assigneeIds: dto.assigneeIds,
createdById: user.userId,
eventKind: 'FROM_REQUEST',
eventMessage: `OT créé depuis la demande ${request.reference}`,
});
await this.prisma.request.update({
where: { id },
data: { status: 'APPROVED', workOrderId: created.id },
});
return this.workOrders.get(created.id, user);
}
async reject(
id: string,
dto: RequestReject,
user: AuthenticatedUser,
): Promise<RequestSummary> {
void user;
const request = await this.prisma.request.findUnique({ where: { id } });
if (!request) throw new NotFoundException('Demande inconnue');
if (request.status !== 'RECEIVED') {
throw new ConflictException('Cette demande a déjà été traitée');
}
const updated = await this.prisma.request.update({
where: { id },
data: { status: 'REJECTED', rejectionReason: dto.reason },
include: requestInclude,
});
return this.toDto(updated);
}
private async nextReference(): Promise<string> {
const [{ nextval }] = await this.prisma.$queryRaw<[{ nextval: bigint }]>`
SELECT nextval('request_ref_seq')`;
return `DEM-${new Date().getFullYear()}-${String(nextval).padStart(4, '0')}`;
}
private requesterLabel(row: RequestRow): string {
return row.requestedBy?.displayName ?? row.requesterName ?? 'Portail';
}
private toDto(row: RequestRow): RequestSummary {
return {
id: row.id,
reference: row.reference,
description: row.description,
isPersonTrapped: row.isPersonTrapped,
status: row.status,
rejectionReason: row.rejectionReason,
assetId: row.assetId,
assetReference: row.asset.reference,
siteName: row.asset.location.parent?.name ?? row.asset.location.name,
requesterLabel: this.requesterLabel(row),
workOrder: row.workOrder
? {
id: row.workOrder.id,
reference: row.workOrder.reference,
status: row.workOrder.status,
}
: null,
createdAt: row.createdAt.toISOString(),
};
}
}

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

@@ -1,4 +1,5 @@
import { import {
BadRequestException,
ConflictException, ConflictException,
Injectable, Injectable,
NotFoundException, NotFoundException,
@@ -20,6 +21,7 @@ import { PrismaService } from '../prisma/prisma.service';
const userInclude = { const userInclude = {
role: true, role: true,
teams: { orderBy: { name: 'asc' } }, teams: { orderBy: { name: 'asc' } },
assignedSites: { orderBy: { name: 'asc' } },
} satisfies Prisma.UserInclude; } satisfies Prisma.UserInclude;
type UserRow = Prisma.UserGetPayload<{ include: typeof userInclude }>; type UserRow = Prisma.UserGetPayload<{ include: typeof userInclude }>;
@@ -46,6 +48,7 @@ export class UsersService {
async invite(dto: InvitationCreate): Promise<InvitationResponse> { async invite(dto: InvitationCreate): Promise<InvitationResponse> {
const role = await this.prisma.role.findUnique({ where: { id: dto.roleId } }); const role = await this.prisma.role.findUnique({ where: { id: dto.roleId } });
if (!role) throw new NotFoundException('Rôle inconnu'); if (!role) throw new NotFoundException('Rôle inconnu');
if (dto.locationIds?.length) await this.assertTopLevelSites(dto.locationIds);
try { try {
const user = await this.prisma.user.create({ const user = await this.prisma.user.create({
data: { data: {
@@ -56,6 +59,9 @@ export class UsersService {
teams: dto.teamIds?.length teams: dto.teamIds?.length
? { connect: dto.teamIds.map((id) => ({ id })) } ? { connect: dto.teamIds.map((id) => ({ id })) }
: undefined, : undefined,
assignedSites: dto.locationIds?.length
? { connect: dto.locationIds.map((id) => ({ id })) }
: undefined,
...this.freshToken(), ...this.freshToken(),
}, },
}); });
@@ -90,6 +96,7 @@ export class UsersService {
} }
async update(userId: string, dto: UserUpdate): Promise<UserAdmin> { async update(userId: string, dto: UserUpdate): Promise<UserAdmin> {
if (dto.locationIds) await this.assertTopLevelSites(dto.locationIds);
try { try {
const updated = await this.prisma.user.update({ const updated = await this.prisma.user.update({
where: { id: userId }, where: { id: userId },
@@ -98,9 +105,13 @@ export class UsersService {
phone: dto.phone, phone: dto.phone,
roleId: dto.roleId, roleId: dto.roleId,
isActive: dto.isActive, isActive: dto.isActive,
hourlyRate: dto.hourlyRate,
teams: dto.teamIds teams: dto.teamIds
? { set: dto.teamIds.map((id) => ({ id })) } ? { set: dto.teamIds.map((id) => ({ id })) }
: undefined, : undefined,
assignedSites: dto.locationIds
? { set: dto.locationIds.map((id) => ({ id })) }
: undefined,
}, },
include: userInclude, include: userInclude,
}); });
@@ -113,6 +124,16 @@ export class UsersService {
} }
} }
/** R6.6 : un Demandeur n'est affecté qu'à des sites racine, jamais des zones
* — même invariant que la hiérarchie site/zone (LocationsService.assertDepth). */
private async assertTopLevelSites(ids: string[]): Promise<void> {
const sites = await this.prisma.location.findMany({ where: { id: { in: ids } } });
if (sites.length !== ids.length) throw new BadRequestException('Site inconnu');
if (sites.some((s) => s.parentId)) {
throw new BadRequestException("L'affectation d'un Demandeur se fait à un site, pas à une zone");
}
}
private freshToken() { private freshToken() {
return { return {
activationToken: randomBytes(32).toString('base64url'), activationToken: randomBytes(32).toString('base64url'),
@@ -136,6 +157,8 @@ export class UsersService {
? 'active' ? 'active'
: 'invited', : 'invited',
isDemo: row.isDemo, isDemo: row.isDemo,
hourlyRate: row.hourlyRate === null ? null : Number(row.hourlyRate),
assignedSites: row.assignedSites.map((s) => ({ id: s.id, name: s.name })),
}; };
} }
} }

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,572 @@
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);
await this.assertVersion(id, dto.baseUpdatedAt);
if (!WORK_ORDER_TRANSITIONS[detail.status].includes(dto.to)) {
throw new ConflictException(
`Transition interdite : ${WORK_ORDER_STATUS_LABELS[detail.status]}${WORK_ORDER_STATUS_LABELS[dto.to]}`,
);
}
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 },
});
await this.toucher(id);
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);
await this.assertVersion(id, dto.baseUpdatedAt);
// Chaque valeur fournie doit appartenir au référentiel de SON champ (et être active)
for (const [colonne, field] of Object.entries(REPORT_FIELDS)) {
const valeur = dto[colonne as keyof ReportUpsert];
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 },
});
await this.toucher(id);
return this.get(id, user);
}
async patchChecklist(
id: string,
itemId: string,
dto: ChecklistPatch,
user: AuthenticatedUser,
): Promise<ChecklistItemDto> {
await this.get(id, user);
await this.assertVersion(id, dto.baseUpdatedAt);
const { count } = await this.prisma.checklistItem.updateMany({
where: { id: itemId, workOrderId: id },
data: {
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');
await this.toucher(id);
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,
},
});
await this.toucher(id);
return this.get(id, user);
}
/** Saisir de la main-d'œuvre : TAUX FIGÉ à la saisie. */
async addLabor(
id: string,
dto: { minutes: number; userId?: string; note?: string },
user: AuthenticatedUser,
): Promise<WorkOrderDetail> {
const detail = await this.get(id, user);
if (detail.status === 'DONE' || detail.status === 'CANCELLED') {
throw new ConflictException('OT terminé : les coûts sont figés');
}
const cibleId = dto.userId ?? user.userId;
const cible = await this.prisma.user.findUnique({ where: { id: cibleId } });
if (!cible) throw new BadRequestException('Personne inconnue');
if (cible.hourlyRate === null) {
throw new ConflictException(
`Aucun taux horaire défini pour ${cible.displayName} — à renseigner dans Personnes`,
);
}
await this.prisma.laborTime.create({
data: {
workOrderId: id,
userId: cibleId,
minutes: dto.minutes,
hourlyRate: cible.hourlyRate, // figé
note: dto.note,
},
});
await this.toucher(id);
return this.get(id, user);
}
// ————— mapping —————
private toSummary(
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,
updatedAt: row.updatedAt.toISOString(),
};
}
/** Verrou optimiste (D2 mobile) : si l'appelant fournit la version lue et
* que l'OT a changé depuis, 409 avec le contexte (qui, quand) — rien
* n'est écrasé en silence. Sans version fournie (web), comportement
* inchangé. */
private async assertVersion(id: string, baseUpdatedAt?: string): Promise<void> {
if (!baseUpdatedAt) return;
const wo = await this.prisma.workOrder.findUniqueOrThrow({
where: { id },
select: {
updatedAt: true,
events: {
orderBy: { createdAt: 'desc' },
take: 1,
include: { by: true },
},
},
});
if (wo.updatedAt.getTime() > new Date(baseUpdatedAt).getTime()) {
const dernier = wo.events[0];
const qui = dernier?.by?.displayName ?? 'quelquun';
const quand = new Intl.DateTimeFormat('fr-FR', {
hour: '2-digit',
minute: '2-digit',
}).format(wo.updatedAt);
throw new ConflictException(
`Conflit de version : cet OT a été modifié par ${qui} à ${quand} — votre saisie n'a pas été appliquée.`,
);
}
}
/** Toute écriture « secondaire » (coche, bilan, commentaire, coûts) fait
* avancer la version de l'OT — sinon le verrou D2 ne verrait rien. */
private async toucher(id: string): Promise<void> {
await this.prisma.workOrder.update({ where: { id }, data: { updatedAt: new Date() } });
}
}

View File

@@ -0,0 +1,210 @@
/**
* E2E R5.2 — assistant au contrat : le service IA reste interne, l'API porte
* l'auth et la matrice ; le dialecte interne est traduit vers @siop/shared.
* Le service IA est joué par un STUB HTTP local (la vraie chaîne se vérifie
* en recette réelle — convention R5.1).
*/
process.env.DEMO_MODE = 'true';
import { createServer, type Server } from 'node:http';
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { AppModule } from '../src/app.module';
const REPONSE_ASK = {
mode: 'extractif',
answer: null,
extraits: [
{
source_type: 'DOCUMENT',
document_id: '7d7bfa5c-2f43-4f9e-9e59-3c1f0a5df001',
work_order_id: null,
titre: 'Notice Gen2.pdf',
locator: 'p. 42',
content: 'Serrer les coulisseaux au couple de 25 N·m.',
score: 0.61,
},
],
corpus: { documents: 6, bilans: 214 },
};
const REPONSE_SUGGEST = {
suggestions: [
{
field: 'ANOMALY',
value_id: '7d7bfa5c-2f43-4f9e-9e59-3c1f0a5df002',
label: 'Cellule/barrière encrassée',
confidence: 'FORTE',
similar_reports: 9,
score: 0.62,
},
],
};
const REPONSE_REINDEX = {
documents_indexes: 6,
documents_ignores: 2,
bilans_indexes: 214,
extraits: 180,
};
const REPONSE_TRANSCRIRE = { texte: 'Porte cabine qui rebondit, cellule encrassée.' };
describe('Assistant (e2e — stub du service IA)', () => {
let app: INestApplication;
let stub: Server;
let ahmed: string; // Technicien : view + edit sur WORK_ORDERS, ASSETS en lecture
let karim: string; // Demandeur : aucun droit WORK_ORDERS
let rachid: string; // Vue seule : view sans edit
let nadia: string; // Gestionnaire : ASSETS.edit — administre le corpus
const requetesRecues: { url: string; jeton: string | undefined }[] = [];
const http = () => request(app.getHttpServer());
const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
beforeAll(async () => {
// Stub du service IA sur un port éphémère
stub = createServer((req, res) => {
requetesRecues.push({
url: req.url ?? '',
jeton: req.headers['x-service-token'] as string | undefined,
});
res.setHeader('Content-Type', 'application/json');
if (req.url === '/internal/ask') res.end(JSON.stringify(REPONSE_ASK));
else if (req.url === '/internal/suggest') res.end(JSON.stringify(REPONSE_SUGGEST));
else if (req.url === '/internal/reindex') res.end(JSON.stringify(REPONSE_REINDEX));
else if (req.url === '/internal/transcrire') res.end(JSON.stringify(REPONSE_TRANSCRIRE));
else {
res.statusCode = 404;
res.end('{}');
}
});
await new Promise<void>((resolve) => stub.listen(0, '127.0.0.1', resolve));
const adresse = stub.address();
const port = typeof adresse === 'object' && adresse ? adresse.port : 0;
process.env.AI_SERVICE_URL = `http://127.0.0.1:${port}`;
process.env.AI_SERVICE_TOKEN = 'jeton-de-test';
const moduleRef = await Test.createTestingModule({
imports: [AppModule.forRoot()],
}).compile();
app = moduleRef.createNestApplication();
await app.init();
const { body } = await http().get('/auth/demo-accounts');
const login = async (roleName: string) => {
const compte = body.accounts.find((a: { roleName: string }) => a.roleName === roleName);
return (await http().post('/auth/demo-login').send({ userId: compte.id })).body
.accessToken as string;
};
ahmed = await login('Technicien');
karim = await login('Demandeur');
rachid = await login('Vue seule');
nadia = await login('Gestionnaire');
});
afterAll(async () => {
await app?.close();
await new Promise<void>((resolve) => stub.close(() => resolve()));
});
it('ask : traduit le dialecte interne vers le contrat, avec le jeton de service', async () => {
const res = await http()
.post('/assistant/ask')
.set(auth(ahmed))
.send({ question: 'quel couple de serrage pour les guides ?' })
.expect(200);
expect(res.body.mode).toBe('EXTRACTIVE');
expect(res.body.excerpts[0]).toMatchObject({
sourceType: 'DOCUMENT',
title: 'Notice Gen2.pdf',
locator: 'p. 42',
});
expect(res.body.corpus).toEqual({ documents: 6, reports: 214 });
const derniere = requetesRecues.at(-1)!;
expect(derniere.url).toBe('/internal/ask');
expect(derniere.jeton).toBe('jeton-de-test'); // ADR-004 §4
});
it('suggest-bilan : codes existants traduits (FORTE → HIGH, snake → camel)', async () => {
const res = await http()
.post('/assistant/suggest-bilan')
.set(auth(ahmed))
.send({ description: 'porte cabine qui rebondit, cellule encrassée, nettoyage fait' })
.expect(200);
expect(res.body.suggestions[0]).toMatchObject({
field: 'ANOMALY',
confidence: 'HIGH',
similarReports: 9,
});
});
it('la matrice sapplique : demandeur sans WORK_ORDERS → 403 sur ask', async () => {
await http()
.post('/assistant/ask')
.set(auth(karim))
.send({ question: 'où sont les notices ?' })
.expect(403);
});
it('vue seule : ask autorisé (view), suggest refusé (edit requis — D1)', async () => {
await http()
.post('/assistant/ask')
.set(auth(rachid))
.send({ question: 'historique du parc ?' })
.expect(200);
await http()
.post('/assistant/suggest-bilan')
.set(auth(rachid))
.send({ description: 'une description suffisamment longue ici' })
.expect(403);
});
it('reindex : traduit le bilan dindexation, réservé à ASSETS.edit (D3)', async () => {
const res = await http().post('/assistant/reindex').set(auth(nadia)).expect(200);
expect(res.body).toEqual({
documentsIndexed: 6,
documentsSkipped: 2,
reportsIndexed: 214,
chunks: 180,
});
expect(requetesRecues.at(-1)!.url).toBe('/internal/reindex');
// Ahmed (Technicien) lit le parc mais n'administre pas le corpus
await http().post('/assistant/reindex').set(auth(ahmed)).expect(403);
});
it('transcribe : dictée (D5) — texte transcrit, réservé à qui édite les OT', async () => {
const res = await http()
.post('/assistant/transcribe')
.set(auth(ahmed))
.attach('file', Buffer.from('faux-audio'), { filename: 'note.m4a', contentType: 'audio/m4a' })
.expect(200);
expect(res.body).toEqual({ text: 'Porte cabine qui rebondit, cellule encrassée.' });
expect(requetesRecues.at(-1)!.url).toBe('/internal/transcrire');
expect(requetesRecues.at(-1)!.jeton).toBe('jeton-de-test');
// Karim (Demandeur) n'a pas WORK_ORDERS.edit
await http()
.post('/assistant/transcribe')
.set(auth(karim))
.attach('file', Buffer.from('faux-audio'), { filename: 'note.m4a', contentType: 'audio/m4a' })
.expect(403);
});
it('transcribe : refuse proprement sans fichier joint', async () => {
await http().post('/assistant/transcribe').set(auth(ahmed)).expect(400);
});
it('question trop courte : 400 avant tout appel au service IA', async () => {
const avant = requetesRecues.length;
await http().post('/assistant/ask').set(auth(ahmed)).send({ question: 'ab' }).expect(400);
expect(requetesRecues.length).toBe(avant);
});
it('service IA éteint : 503 propre, jamais un 500', async () => {
await new Promise<void>((resolve) => stub.close(() => resolve()));
await http()
.post('/assistant/ask')
.set(auth(ahmed))
.send({ question: 'le service est-il là ?' })
.expect(503);
});
});

View File

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

View File

@@ -0,0 +1,376 @@
/**
* 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 — sur A2, dans son site rattaché (Tour
// Atlas, R6.6) : un Demandeur ne peut plus signaler hors périmètre.
const { body: assets } = await http().get('/assets').set(auth(salma));
const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A2');
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 () => {
// B1 : dans le site rattaché de Karim (Tour Atlas, R6.6).
const { body: assets } = await http().get('/assets').set(auth(salma));
const c1 = assets.assets.find((a: { reference: string }) => a.reference === 'B1');
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,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,247 @@
/**
* E2E R3 (recette) — recherche globale : chaque famille de résultats est
* filtrée par la matrice, les OT respectent « voir autre » ; au passage,
* rattachements Tiers (siteNames) et période analytics paramétrable.
*/
process.env.DEMO_MODE = 'true';
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client';
import request from 'supertest';
import { seed } from '../prisma/seed';
import { AppModule } from '../src/app.module';
describe('Recherche globale & corrections de recette R3 (e2e)', () => {
let app: INestApplication;
let yasmine: string; // Administrateur
let salma: string; // Dispatcher (voir autre)
let youssef: string; // Technicien limité : SES OT, ASSETS view, pas de LOCATIONS
let karim: string; // Demandeur : aucune des trois familles
let otId: string;
const prisma = new PrismaClient();
const http = () => request(app.getHttpServer());
const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
const suffix = `rech-${Date.now().toString(36)}`;
beforeAll(async () => {
await seed(prisma);
const moduleRef = await Test.createTestingModule({
imports: [AppModule.forRoot()],
}).compile();
app = moduleRef.createNestApplication();
await app.init();
const { body } = await http().get('/auth/demo-accounts');
const login = async (roleName: string) => {
const compte = body.accounts.find((a: { roleName: string }) => a.roleName === roleName);
return (await http().post('/auth/demo-login').send({ userId: compte.id })).body
.accessToken as string;
};
yasmine = await login('Administrateur');
salma = await login('Dispatcher');
youssef = await login('Technicien limité');
karim = await login('Demandeur');
// Un OT au titre unique, assigné à Ahmed (PAS à Youssef).
const { body: assets } = await http().get('/assets').set(auth(salma));
const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A1');
const ahmed = body.accounts.find((a: { roleName: string }) => a.roleName === 'Technicien');
const cree = await http()
.post('/work-orders')
.set(auth(salma))
.send({
title: `Recherche E2E ${suffix}`,
type: 'CORRECTIVE',
priority: 'LOW',
assetId: a1.id,
assigneeIds: [ahmed.id],
})
.expect(201);
otId = cree.body.id;
});
afterAll(async () => {
await prisma.workOrder.deleteMany({ where: { title: { contains: suffix } } });
await app?.close();
await prisma.$disconnect();
});
it('dispatcher (voir autre) : OT par titre, appareil par référence, site insensible à la casse', async () => {
const parTitre = await http().get(`/search?q=${suffix}`).set(auth(salma)).expect(200);
expect(parTitre.body.workOrders.map((w: { id: string }) => w.id)).toContain(otId);
const parRef = await http().get('/search?q=A1').set(auth(salma)).expect(200);
expect(parRef.body.assets.some((a: { reference: string }) => a.reference === 'A1')).toBe(true);
expect(parRef.body.assets[0].siteName).toBeTruthy();
const parSite = await http().get('/search?q=atlas').set(auth(salma)).expect(200);
expect(parSite.body.sites.some((s: { name: string }) => s.name === 'Tour Atlas')).toBe(true);
});
it('technicien limité : ne trouve pas 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);
});
});
describe('Verrou optimiste D2 (e2e) — la version protège les saisies mobiles', () => {
let app: INestApplication;
let salma: string;
let ahmed: string;
let otId: string;
const prisma = new PrismaClient();
const http = () => request(app.getHttpServer());
const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
const suffix = `verrou-${Date.now().toString(36)}`;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [AppModule.forRoot()],
}).compile();
app = moduleRef.createNestApplication();
await app.init();
const { body } = await http().get('/auth/demo-accounts');
const login = async (roleName: string) => {
const compte = body.accounts.find((a: { roleName: string }) => a.roleName === roleName);
return (await http().post('/auth/demo-login').send({ userId: compte.id })).body
.accessToken as string;
};
salma = await login('Dispatcher');
ahmed = await login('Technicien');
const { body: assets } = await http().get('/assets').set(auth(salma));
const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A1');
const technicien = body.accounts.find((a: { roleName: string }) => a.roleName === 'Technicien');
const cree = await http()
.post('/work-orders')
.set(auth(salma))
.send({
title: `Verrou E2E ${suffix}`,
type: 'CORRECTIVE',
assetId: a1.id,
assigneeIds: [technicien.id],
})
.expect(201);
otId = cree.body.id;
});
afterAll(async () => {
await prisma.workOrder.deleteMany({ where: { title: { contains: suffix } } });
await app?.close();
await prisma.$disconnect();
});
it('version à jour : la transition passe ; le détail expose updatedAt', async () => {
const detail = await http().get(`/work-orders/${otId}`).set(auth(ahmed)).expect(200);
expect(detail.body.updatedAt).toBeTruthy();
await http()
.post(`/work-orders/${otId}/transition`)
.set(auth(ahmed))
.send({ to: 'IN_PROGRESS', baseUpdatedAt: detail.body.updatedAt })
.expect(200);
});
it('OT modifié entre-temps (commentaire) : 409 « Conflit de version » contextualisé', async () => {
const detail = await http().get(`/work-orders/${otId}`).set(auth(ahmed)).expect(200);
const versionLue = detail.body.updatedAt as string;
// Salma commente pendant qu'Ahmed est « hors-ligne » — la version avance
await http()
.post(`/work-orders/${otId}/comments`)
.set(auth(salma))
.send({ message: 'Réassigné après appel du syndic' })
.expect(201);
const refus = await http()
.post(`/work-orders/${otId}/transition`)
.set(auth(ahmed))
.send({ to: 'ON_HOLD', baseUpdatedAt: versionLue })
.expect(409);
expect(refus.body.message).toContain('Conflit de version');
expect(refus.body.message).toContain('Salma');
// L'OT n'a PAS bougé — rien d'écrasé en silence
const apres = await http().get(`/work-orders/${otId}`).set(auth(ahmed)).expect(200);
expect(apres.body.status).toBe('IN_PROGRESS');
});
it('le bilan et la coche font avancer la version (sinon le verrou est aveugle)', async () => {
const avant = (await http().get(`/work-orders/${otId}`).set(auth(ahmed))).body.updatedAt;
const { body: refs } = await http().get('/reference-values').set(auth(ahmed));
const porte = refs.referenceValues.find(
(v: { field: string; isActive: boolean }) => v.field === 'DOOR_STATE' && v.isActive,
);
await http()
.put(`/work-orders/${otId}/report`)
.set(auth(ahmed))
.send({ doorStateId: porte.id })
.expect(200);
const apres = (await http().get(`/work-orders/${otId}`).set(auth(ahmed))).body.updatedAt;
expect(new Date(apres).getTime()).toBeGreaterThan(new Date(avant).getTime());
});
it('sans baseUpdatedAt (web) : comportement inchangé, même après modification', async () => {
await http()
.post(`/work-orders/${otId}/comments`)
.set(auth(salma))
.send({ message: 'Nouvelle note' })
.expect(201);
await http()
.post(`/work-orders/${otId}/transition`)
.set(auth(ahmed))
.send({ to: 'ON_HOLD' })
.expect(200);
});
});

View File

@@ -0,0 +1,5 @@
{
"enabledPlugins": {
"expo@claude-plugins-official": true
}
}

21
apps/mobile/.env Normal file
View File

@@ -0,0 +1,21 @@
# Depuis Expo SDK 57, le `fetch` global est `expo/fetch` (WinterCG) — qui ne
# comprend pas le raccourci FormData natif `{uri, name, type}` utilisé pour
# joindre un fichier au multipart (photos d'intervention, D5). Sans ce
# réglage, l'upload échoue avec `Unsupported FormDataPart implementation`,
# systématiquement classé à tort comme une coupure réseau (D1) et mis en
# boucle de retry silencieuse — trouvé en recette terrain iOS (17/07/2026).
# Voir ADR-005 et docs/journal/journal.md.
EXPO_PUBLIC_USE_RN_FETCH=1
# Modules Expo/RN précompilés (XCFrameworks, SDK 56/57) incompatibles avec la
# liaison statique de ce projet (pas de use_frameworks!) : sans ce réglage,
# l'app compile mais CRASHE au lancement
# (dyld: Library not loaded: @rpath/React.framework/React, signal 6).
# Force la recompilation depuis les sources — voir ADR-005.
EXPO_USE_PRECOMPILED_MODULES=0
RCT_USE_PREBUILT_RNCORE=0
# Origine du web — R6.4, lien d'activation « Personnes » (/activation?token=…,
# il n'existe pas d'équivalent mobile). À ajuster par environnement comme
# EXPO_PUBLIC_API_URL (IP LAN ou domaine de prod).
EXPO_PUBLIC_WEB_URL=http://localhost:5173

41
apps/mobile/.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
# generated native folders
/ios
/android

3
apps/mobile/AGENTS.md Normal file
View File

@@ -0,0 +1,3 @@
# Expo HAS CHANGED
Read the exact versioned docs at https://docs.expo.dev/versions/v57.0.0/ before writing any code.

33
apps/mobile/README.md Normal file
View File

@@ -0,0 +1,33 @@
# @siop/mobile — l'app du technicien (R4)
Expo (SDK 57), offline-first, périmètre fermé : **Ma journée, scan, préventif, synchro**
la gestion reste web, le demandeur reste sur le portail QR public. Maquettes validées :
`docs/02-design/maquettes/maquette-r4.html` (+ décisions D1-D5 au journal du 17/07/2026).
## Lancer
```bash
# API locale d'abord (apps/api : pnpm dev), puis :
pnpm --filter @siop/mobile start # QR Expo Go (téléphone sur le MÊME réseau)
EXPO_PUBLIC_API_URL=http://192.168.x.y:3000 pnpm --filter @siop/mobile start
```
- `EXPO_PUBLIC_API_URL` : URL de l'API vue **depuis le téléphone** (IP LAN, pas localhost).
Défaut : `http://localhost:3000` (suffisant pour `expo start --web`).
- Vérification navigateur : `expo start --web` + `CORS_ORIGINS=http://localhost:8081`
côté API (les apps natives n'envoient pas d'Origin — CORS ne concerne que le web).
## Ce que porte R4.1 (socle)
- Connexion e-mail/mot de passe + **sélecteur démo** (ADR-002 : n'existe que si l'API l'expose).
- Coquille tabbar (onglets à venir marqués R4.2/R4.3) + **Ma journée** : OT triés
priorité puis échéance (`src/lib/journee.ts`, testé), urgence en tête, pastille de synchro.
- **Lecture hors-ligne (D1)** : cache TanStack persisté dans AsyncStorage (7 jours),
NetInfo pilote `onlineManager` + bandeau. L'écriture en file arrive en R4.3.
- Client typé généré depuis `docs/openapi.json` (`pnpm generate:client`) — règle d'or ADR-001.
- Jeton dans SecureStore (trousseau) ; AsyncStorage en repli web de dev.
## Tests
`pnpm --filter @siop/mobile test` (jest-expo — logique pure : tri, tokens) ;
`typecheck` et le lint racine s'appliquent (job CI `mobile`).

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