30 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
121 changed files with 12375 additions and 152 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']
@@ -86,6 +77,15 @@ jobs:
MINIO_SECRET_KEY: siop-minio 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) - name: Démarrer MinIO (l'image officielle exige une commande — pas un service)
run: | run: |
docker run -d --name minio -p 9000:9000 \ docker run -d --name minio -p 9000:9000 \
@@ -133,20 +133,25 @@ jobs:
- run: pnpm --filter @siop/mobile typecheck - run: pnpm --filter @siop/mobile typecheck
- run: pnpm --filter @siop/mobile test - 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']
@@ -162,6 +167,15 @@ jobs:
MINIO_SECRET_KEY: siop-minio 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) - name: Démarrer MinIO (l'image officielle exige une commande — pas un service)
run: | run: |
docker run -d --name minio -p 9000:9000 \ docker run -d --name minio -p 9000:9000 \
@@ -177,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
@@ -195,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, mobile, 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.
@@ -59,5 +59,30 @@ pnpm + Turborepo. `apps/api` : NestJS, Prisma, PostgreSQL (pgvector + PostGIS),
- **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.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.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**. - 🏁 **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**.
- 🔄 **Reprise ici** : R5 IA design d'abord : maquettes (assistant RAG sourcé, suggestion de codes de bilan) + décisions à acter, AUCUN code `apps/ai` avant validation. Restes : recette R4 sur appareil, redéploiement Dokploy de `release/r3`. - **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).
- Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0R5. - **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

@@ -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,8 @@ model User {
activationToken String? @unique activationToken String? @unique
activationExpiresAt DateTime? activationExpiresAt DateTime?
teams Team[] teams Team[]
// R6.6 — sites autorisés en signalement (Demandeur) ; vide = aucune restriction
assignedSites Location[]
// R2 — exploitation // R2 — exploitation
workOrdersAssigned WorkOrder[] @relation("WorkOrderAssignees") workOrdersAssigned WorkOrder[] @relation("WorkOrderAssignees")
workOrdersCreated WorkOrder[] @relation("WorkOrderCreator") workOrdersCreated WorkOrder[] @relation("WorkOrderCreator")
@@ -111,6 +113,7 @@ model Location {
partnerId String? @db.Uuid partnerId String? @db.Uuid
partner Partner? @relation(fields: [partnerId], references: [id]) 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
@@ -233,6 +236,7 @@ model WorkOrder {
completedAt DateTime? completedAt DateTime?
cancelledAt DateTime? cancelledAt DateTime?
events WorkOrderEvent[] events WorkOrderEvent[]
ragChunks RagChunk[]
checklist ChecklistItem[] checklist ChecklistItem[]
report InterventionReport? report InterventionReport?
request Request? request Request?
@@ -513,7 +517,36 @@ model Document {
uploadedById String? @db.Uuid uploadedById String? @db.Uuid
uploadedBy User? @relation(fields: [uploadedById], references: [id]) uploadedBy User? @relation(fields: [uploadedById], references: [id])
createdAt DateTime @default(now()) 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([assetId])
@@index([workOrderId]) @@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

@@ -462,6 +462,19 @@ async function seedExploitation(prisma: PrismaClient): Promise<void> {
const karim = await parEmail('demandeur@demo.siop.ma'); const karim = await parEmail('demandeur@demo.siop.ma');
const annee = new Date().getFullYear(); const annee = new Date().getFullYear();
// R6.6 — Karim (Demandeur) est rattaché à Tour Atlas (cohérent avec le
// guardianName déjà seedé pour ce site) : son signalement se limite à ce
// parc, ses demandes historiques sur d'autres sites restent visibles.
const tourAtlas = await prisma.location.findFirst({
where: { name: 'Tour Atlas', parentId: null },
});
if (tourAtlas) {
await prisma.user.update({
where: { id: karim },
data: { assignedSites: { connect: [{ id: tourAtlas.id }] } },
});
}
const grilleLabels = TASK_TEMPLATES.filter((t) => t.periodMonths === 1).map( const grilleLabels = TASK_TEMPLATES.filter((t) => t.periodMonths === 1).map(
(t) => t.label, (t) => t.label,
); );

View File

@@ -2,6 +2,7 @@ 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 { AnalyticsModule } from './analytics/analytics.module';
import { SearchModule } from './search/search.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 { DocumentsModule } from './documents/documents.module';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
@@ -63,6 +64,7 @@ export class AppModule {
DocumentsModule, DocumentsModule,
AnalyticsModule, AnalyticsModule,
SearchModule, 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';
@@ -32,10 +33,11 @@ export class AssetsController {
} }
/** Avant ':id' (ordre des routes) — authentification seule : le demandeur /** Avant ':id' (ordre des routes) — authentification seule : le demandeur
* doit pouvoir désigner l'appareil qu'il signale. */ * doit pouvoir désigner l'appareil qu'il signale (filtré à son site s'il
* en a un, R6.6). */
@Get('options') @Get('options')
options() { options(@CurrentUser() user: AuthenticatedUser) {
return this.assetsService.options(); return this.assetsService.options(user);
} }
@Get(':id') @Get(':id')

View File

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

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

@@ -18,6 +18,9 @@ const EnvSchema = z.object({
// Vide = aucun CORS (défaut sûr) — le web de prod passe par le proxy nginx // 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. // même-origine, les apps natives n'envoient pas d'Origin.
CORS_ORIGINS: z.string().default(''), 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

@@ -7,6 +7,7 @@ import {
HttpCode, HttpCode,
Param, Param,
ParseUUIDPipe, ParseUUIDPipe,
Patch,
Post, Post,
Query, Query,
StreamableFile, StreamableFile,
@@ -14,11 +15,17 @@ import {
UseInterceptors, UseInterceptors,
} from '@nestjs/common'; } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express'; import { FileInterceptor } from '@nestjs/platform-express';
import { DOCUMENT_MAX_BYTES } from '@siop/shared'; import {
DOCUMENT_MAX_BYTES,
DocumentCorpusUpdateSchema,
type DocumentCorpusUpdate,
} from '@siop/shared';
import { import {
AuthenticatedUser, AuthenticatedUser,
CurrentUser, CurrentUser,
} from '../auth/current-user.decorator'; } from '../auth/current-user.decorator';
import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { RequirePermission } from '../permissions/require-permission.decorator';
import { DocumentsService } from './documents.service'; import { DocumentsService } from './documents.service';
@Controller('documents') @Controller('documents')
@@ -69,6 +76,16 @@ export class DocumentsController {
}); });
} }
/** 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') @Delete(':id')
@HttpCode(204) @HttpCode(204)
remove(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: AuthenticatedUser) { remove(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: AuthenticatedUser) {

View File

@@ -155,6 +155,21 @@ export class DocumentsService {
workOrderReference: row.workOrder?.reference ?? null, workOrderReference: row.workOrder?.reference ?? null,
uploadedByName: row.uploadedBy?.displayName ?? null, uploadedByName: row.uploadedBy?.displayName ?? null,
createdAt: row.createdAt.toISOString(), 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,10 +1,11 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AssetsModule } from '../assets/assets.module';
import { WorkOrdersModule } from '../work-orders/work-orders.module'; import { WorkOrdersModule } from '../work-orders/work-orders.module';
import { RequestsController } from './requests.controller'; import { RequestsController } from './requests.controller';
import { RequestsService } from './requests.service'; import { RequestsService } from './requests.service';
@Module({ @Module({
imports: [WorkOrdersModule], imports: [WorkOrdersModule, AssetsModule],
controllers: [RequestsController], controllers: [RequestsController],
providers: [RequestsService], providers: [RequestsService],
}) })

View File

@@ -16,6 +16,7 @@ import type {
import type { AuthenticatedUser } from '../auth/current-user.decorator'; import type { AuthenticatedUser } from '../auth/current-user.decorator';
import { PermissionsService } from '../permissions/permissions.service'; import { PermissionsService } from '../permissions/permissions.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { AssetsService } from '../assets/assets.service';
import { WorkOrdersService } from '../work-orders/work-orders.service'; import { WorkOrdersService } from '../work-orders/work-orders.service';
const requestInclude = { const requestInclude = {
@@ -32,6 +33,7 @@ export class RequestsService {
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly permissions: PermissionsService, private readonly permissions: PermissionsService,
private readonly workOrders: WorkOrdersService, private readonly workOrders: WorkOrdersService,
private readonly assets: AssetsService,
) {} ) {}
private async scope(user: AuthenticatedUser): Promise<Prisma.RequestWhereInput> { private async scope(user: AuthenticatedUser): Promise<Prisma.RequestWhereInput> {
@@ -52,6 +54,13 @@ export class RequestsService {
async create(dto: RequestCreate, user: AuthenticatedUser): Promise<RequestSummary> { async create(dto: RequestCreate, user: AuthenticatedUser): Promise<RequestSummary> {
const asset = await this.prisma.asset.findUnique({ where: { id: dto.assetId } }); const asset = await this.prisma.asset.findUnique({ where: { id: dto.assetId } });
if (!asset) throw new BadRequestException('Équipement inconnu'); if (!asset) throw new BadRequestException('Équipement inconnu');
// Défense en profondeur (R6.6) : même filtre que /assets/options, pour
// qu'un Demandeur affecté à un site ne puisse pas contourner la liste
// en soumettant directement un assetId hors périmètre.
const allowed = await this.assets.allowedLocationIds(user);
if (allowed && !allowed.includes(asset.locationId)) {
throw new BadRequestException("Cet équipement n'est pas dans votre périmètre");
}
for (let essai = 0; ; essai++) { for (let essai = 0; ; essai++) {
try { try {
const created = await this.prisma.request.create({ const created = await this.prisma.request.create({

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 },
@@ -102,6 +109,9 @@ export class UsersService {
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,
}); });
@@ -114,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'),
@@ -138,6 +158,7 @@ export class UsersService {
: 'invited', : 'invited',
isDemo: row.isDemo, isDemo: row.isDemo,
hourlyRate: row.hourlyRate === null ? null : Number(row.hourlyRate), hourlyRate: row.hourlyRate === null ? null : Number(row.hourlyRate),
assignedSites: row.assignedSites.map((s) => ({ id: s.id, name: s.name })),
}; };
} }
} }

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

@@ -87,6 +87,36 @@ describe('Bibliothèque & analytics (e2e)', () => {
await http().get(`/documents/${envoye.body.id}/download`).set(auth(nadia)).expect(404); 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 () => { it('refus typés : format, rattachement manquant, cible inconnue, permission', async () => {
const { body: assets } = await http().get('/assets').set(auth(nadia)); const { body: assets } = await http().get('/assets').set(auth(nadia));
const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A1'); const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A1');

View File

@@ -55,9 +55,10 @@ describe('Exploitation (e2e)', () => {
const auth = (t: string) => ({ Authorization: `Bearer ${t}` }); const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
it('recette : demande → approbation → OT assigné → bilan → clôture → suivi', async () => { it('recette : demande → approbation → OT assigné → bilan → clôture → suivi', async () => {
// 1. Karim (gardien) signale // 1. Karim (gardien) signale — sur A2, dans son site rattaché (Tour
// Atlas, R6.6) : un Demandeur ne peut plus signaler hors périmètre.
const { body: assets } = await http().get('/assets').set(auth(salma)); const { body: assets } = await http().get('/assets').set(auth(salma));
const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A1'); const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A2');
const demande = await http() const demande = await http()
.post('/requests') .post('/requests')
.set(auth(karim)) .set(auth(karim))
@@ -234,8 +235,9 @@ describe('Exploitation (e2e)', () => {
}); });
it('rejet : motif obligatoire ; bilan : valeur hors champ refusée', async () => { it('rejet : motif obligatoire ; bilan : valeur hors champ refusée', async () => {
// B1 : dans le site rattaché de Karim (Tour Atlas, R6.6).
const { body: assets } = await http().get('/assets').set(auth(salma)); const { body: assets } = await http().get('/assets').set(auth(salma));
const c1 = assets.assets.find((a: { reference: string }) => a.reference === 'C1'); const c1 = assets.assets.find((a: { reference: string }) => a.reference === 'B1');
const demande = await http() const demande = await http()
.post('/requests') .post('/requests')
.set(auth(karim)) .set(auth(karim))

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

View File

@@ -7,7 +7,8 @@
"icon": "./assets/icon.png", "icon": "./assets/icon.png",
"userInterfaceStyle": "automatic", "userInterfaceStyle": "automatic",
"ios": { "ios": {
"supportsTablet": true "supportsTablet": true,
"bundleIdentifier": "com.anonymous.siop2-mobile"
}, },
"android": { "android": {
"adaptiveIcon": { "adaptiveIcon": {
@@ -16,7 +17,8 @@
"backgroundImage": "./assets/android-icon-background.png", "backgroundImage": "./assets/android-icon-background.png",
"monochromeImage": "./assets/android-icon-monochrome.png" "monochromeImage": "./assets/android-icon-monochrome.png"
}, },
"predictiveBackGestureEnabled": false "predictiveBackGestureEnabled": false,
"package": "com.anonymous.siop2mobile"
}, },
"web": { "web": {
"favicon": "./assets/favicon.png" "favicon": "./assets/favicon.png"
@@ -24,7 +26,8 @@
"plugins": [ "plugins": [
"expo-router", "expo-router",
"expo-font", "expo-font",
"expo-secure-store" "expo-secure-store",
"expo-audio"
], ],
"scheme": "siop" "scheme": "siop"
} }

View File

@@ -1,12 +1,19 @@
import { Tabs } from 'expo-router'; import { Tabs } from 'expo-router';
import { Text, type ColorValue } from 'react-native'; import { Text, type ColorValue } from 'react-native';
import { useAssets, useReferenceValues } from '@/api/exploitation'; import { useAssets, useReferenceValues } from '@/api/exploitation';
import { useMe } from '@/auth/session';
import { ongletsVisibles } from '@/auth/roles';
import { usePermissions } from '@/auth/use-permissions';
import { useFile } from '@/file/store'; import { useFile } from '@/file/store';
import { useTokens } from '@/theme/tokens'; import { useTokens } from '@/theme/tokens';
/** La tabbar de la maquette R4. Les onglets à venir restent visibles /** Barre d'onglets ADAPTATIVE (maquette « mobile ouvert à tous les rôles »,
* (périmètre annoncé, même patron que la sidebar web) mais mènent à un * D1) : le Technicien/Technicien limité gardent exactement leurs 4 onglets
* écran « disponible en R4.x ». */ * terrain R4, inchangés. Les autres rôles reçoivent Accueil/OT/Menu (ou une
* variante plus étroite — Demandeur, Vue seule — selon leurs droits,
* `ongletsVisibles`). Tous les écrans sont toujours déclarés : `href: null`
* masque un onglet de la barre sans le retirer du navigateur (le Menu peut
* toujours y pousser directement). */
function Pic({ glyphe, couleur }: { glyphe: string; couleur: ColorValue }) { function Pic({ glyphe, couleur }: { glyphe: string; couleur: ColorValue }) {
return <Text style={{ fontSize: 17, color: couleur, lineHeight: 20 }}>{glyphe}</Text>; return <Text style={{ fontSize: 17, color: couleur, lineHeight: 20 }}>{glyphe}</Text>;
@@ -16,10 +23,19 @@ export default function CoquilleTabs() {
const t = useTokens(); const t = useTokens();
const file = useFile(); const file = useFile();
const conflits = file.some((s) => s.statut === 'CONFLIT'); const conflits = file.some((s) => s.statut === 'CONFLIT');
const { data: me } = useMe();
const { can } = usePermissions();
const visibles = ongletsVisibles(me?.role.name);
// D1 « lecture locale » : le parc (scan D4) et les référentiels du bilan // D1 « lecture locale » : le parc (scan D4) et les référentiels du bilan
// se préchargent dès l'entrée — le sous-sol n'attend pas qu'on y pense. // se préchargent dès l'entrée — le sous-sol n'attend pas qu'on y pense.
useAssets(); // Le Demandeur n'a pas ASSETS.view (seulement /assets/options) : inutile
useReferenceValues(); // d'appeler /assets pour lui, l'API répondrait 403.
const peutAssets = can('ASSETS', 'view');
useAssets({ enabled: peutAssets });
useReferenceValues({ enabled: peutAssets });
const cacher = (nom: string) => (visibles.has(nom) ? undefined : null);
return ( return (
<Tabs <Tabs
screenOptions={{ screenOptions={{
@@ -34,6 +50,7 @@ export default function CoquilleTabs() {
<Tabs.Screen <Tabs.Screen
name="journee" name="journee"
options={{ options={{
href: cacher('journee'),
title: 'Ma journée', title: 'Ma journée',
tabBarIcon: ({ color }) => <Pic glyphe="☰" couleur={color} />, tabBarIcon: ({ color }) => <Pic glyphe="☰" couleur={color} />,
}} }}
@@ -41,6 +58,7 @@ export default function CoquilleTabs() {
<Tabs.Screen <Tabs.Screen
name="scanner" name="scanner"
options={{ options={{
href: cacher('scanner'),
title: 'Scanner', title: 'Scanner',
tabBarIcon: ({ color }) => <Pic glyphe="▣" couleur={color} />, tabBarIcon: ({ color }) => <Pic glyphe="▣" couleur={color} />,
}} }}
@@ -48,6 +66,7 @@ export default function CoquilleTabs() {
<Tabs.Screen <Tabs.Screen
name="preventif" name="preventif"
options={{ options={{
href: cacher('preventif'),
title: 'Préventif', title: 'Préventif',
tabBarIcon: ({ color }) => <Pic glyphe="✓" couleur={color} />, tabBarIcon: ({ color }) => <Pic glyphe="✓" couleur={color} />,
}} }}
@@ -55,6 +74,7 @@ export default function CoquilleTabs() {
<Tabs.Screen <Tabs.Screen
name="synchro" name="synchro"
options={{ options={{
href: cacher('synchro'),
title: 'Synchro', title: 'Synchro',
tabBarIcon: ({ color }) => <Pic glyphe="⇅" couleur={color} />, tabBarIcon: ({ color }) => <Pic glyphe="⇅" couleur={color} />,
tabBarBadge: file.length || undefined, tabBarBadge: file.length || undefined,
@@ -66,6 +86,38 @@ export default function CoquilleTabs() {
}, },
}} }}
/> />
<Tabs.Screen
name="accueil"
options={{
href: cacher('accueil'),
title: 'Accueil',
tabBarIcon: ({ color }) => <Pic glyphe="⌂" couleur={color} />,
}}
/>
<Tabs.Screen
name="ot"
options={{
href: cacher('ot'),
title: 'OT',
tabBarIcon: ({ color }) => <Pic glyphe="☰" couleur={color} />,
}}
/>
<Tabs.Screen
name="nouvelle-demande"
options={{
href: cacher('nouvelle-demande'),
title: 'Nouvelle demande',
tabBarIcon: ({ color }) => <Pic glyphe="+" couleur={color} />,
}}
/>
<Tabs.Screen
name="menu"
options={{
href: cacher('menu'),
title: 'Menu',
tabBarIcon: ({ color }) => <Pic glyphe="☷" couleur={color} />,
}}
/>
</Tabs> </Tabs>
); );
} }

View File

@@ -0,0 +1,179 @@
import { router } from 'expo-router';
import { Pressable, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRequests, useWorkOrders } from '@/api/exploitation';
import { useMe } from '@/auth/session';
import { usePermissions } from '@/auth/use-permissions';
import { EnteteTabs } from '@/composants/ui';
import { PanneauDemandes } from '@/composants/panneau-demandes';
import { useTokens } from '@/theme/tokens';
/** Accueil — maquette « mobile ouvert à tous les rôles », écran 2 : le
* Demandeur y voit directement ses demandes (son seul geste) ; les rôles
* de gestion (Administrateur/Gestionnaire/Dispatcher/Vue seule) y trouvent
* leurs signaux réels (urgence, OT en cours, demandes à traiter) — aucun
* chiffre inventé : seuls WORK_ORDERS/REQUESTS sont déjà câblés sur mobile,
* le reste (stock, préventif du mois…) attend son propre écran (Menu). */
export default function PageAccueil() {
const { data: me } = useMe();
if (me?.role.name === 'Demandeur') {
return (
<SafeAreaView style={{ flex: 1 }} edges={['top']}>
<View style={{ flex: 1, padding: 14, gap: 10 }}>
<EnteteTabs />
<PanneauDemandes />
</View>
</SafeAreaView>
);
}
return <TableauDeBordGestion />;
}
function TableauDeBordGestion() {
const t = useTokens();
const { can } = usePermissions();
const peutOT = can('WORK_ORDERS', 'view');
const peutDemandes = can('REQUESTS', 'view');
const { data: workOrders } = useWorkOrders();
const { data: requests } = useRequests();
const ots = workOrders ?? [];
const urgences = ots.filter((o) => o.priority === 'PERSON_TRAPPED' && o.status !== 'DONE' && o.status !== 'CANCELLED');
const enCours = ots.filter((o) => o.status === 'OPEN' || o.status === 'IN_PROGRESS').length;
const aTraiter = (requests ?? []).filter((r) => r.status === 'RECEIVED').length;
return (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<View style={{ padding: 14, gap: 10, flex: 1 }}>
<EnteteTabs />
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
Accueil
</Text>
{urgences.length ? (
<Pressable
accessibilityRole="button"
onPress={() => router.push(`/ot/${urgences[0]!.id}`)}
style={{
backgroundColor: t.prioBloqueFond,
borderRadius: 10,
padding: 9,
flexDirection: 'row',
alignItems: 'center',
gap: 8,
}}
>
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: t.prioBloque }} />
<Text style={{ color: t.prioBloque, fontFamily: 'Manrope_800ExtraBold', fontSize: 12 }}>
{urgences.length === 1
? `1 personne bloquée — ${urgences[0]!.assetReference} · ${urgences[0]!.siteName}`
: `${urgences.length} personnes bloquées`}
</Text>
</Pressable>
) : null}
{peutOT || peutDemandes ? (
<View style={{ flexDirection: 'row', gap: 8 }}>
{peutOT ? (
<View
style={{
flex: 1,
backgroundColor: t.surface,
borderColor: t.bordure,
borderWidth: 1,
borderRadius: 10,
padding: 10,
}}
>
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.primaire }}>
{enCours}
</Text>
<Text style={{ fontFamily: 'Manrope_600SemiBold', fontSize: 10.5, color: t.encre2 }}>
OT en cours
</Text>
</View>
) : null}
{peutDemandes ? (
<View
style={{
flex: 1,
backgroundColor: t.surface,
borderColor: t.bordure,
borderWidth: 1,
borderRadius: 10,
padding: 10,
}}
>
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
{aTraiter}
</Text>
<Text style={{ fontFamily: 'Manrope_600SemiBold', fontSize: 10.5, color: t.encre2 }}>
Demandes reçues
</Text>
</View>
) : null}
</View>
) : null}
{peutDemandes && aTraiter > 0 ? (
<Pressable
accessibilityRole="button"
onPress={() => router.push('/demandes')}
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 10,
backgroundColor: t.surface,
borderColor: t.bordure,
borderWidth: 1,
borderRadius: 10,
padding: 11,
}}
>
<View
style={{
width: 28,
height: 28,
borderRadius: 8,
backgroundColor: t.primaireDoux,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Text style={{ fontSize: 14, color: t.primaire }}></Text>
</View>
<View style={{ flex: 1 }}>
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 12.5, color: t.encre }}>
Demandes en attente
</Text>
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 11, color: t.encre2 }}>
à approuver ou rejeter
</Text>
</View>
<Text
style={{
fontFamily: 'Manrope_800ExtraBold',
fontSize: 10.5,
color: t.stAttente,
backgroundColor: t.stAttenteFond,
paddingHorizontal: 7,
paddingVertical: 2,
borderRadius: 999,
overflow: 'hidden',
}}
>
{aTraiter}
</Text>
<Text style={{ color: t.encre3, fontSize: 13 }}></Text>
</Pressable>
) : null}
{!peutOT && !peutDemandes ? (
<Text style={{ color: t.encre3, fontFamily: 'Manrope_600SemiBold', textAlign: 'center', padding: 24 }}>
Rien à afficher ici pour votre rôle voir le Menu.
</Text>
) : null}
</View>
</SafeAreaView>
);
}

View File

@@ -10,7 +10,8 @@ import {
type WorkOrderStatus, type WorkOrderStatus,
} from '@siop/shared'; } from '@siop/shared';
import { api, unwrap } from '@/api/client'; import { api, unwrap } from '@/api/client';
import { useHorsLigne, useLogout, useMe } from '@/auth/session'; import { useHorsLigne } from '@/auth/session';
import { EnteteTabs } from '@/composants/ui';
import { triJournee } from '@/lib/journee'; import { triJournee } from '@/lib/journee';
import { useTokens, type Tokens } from '@/theme/tokens'; import { useTokens, type Tokens } from '@/theme/tokens';
@@ -43,9 +44,7 @@ const STRIE_PRIORITE: Record<WorkOrderPriority, (t: Tokens) => string> = {
export default function PageJournee() { export default function PageJournee() {
const t = useTokens(); const t = useTokens();
const horsLigne = useHorsLigne(); const horsLigne = useHorsLigne();
const { data: me } = useMe();
const { data: workOrders, refetch, isFetching, dataUpdatedAt } = useWorkOrders(); const { data: workOrders, refetch, isFetching, dataUpdatedAt } = useWorkOrders();
const logout = useLogout();
const ots = triJournee(workOrders ?? []); const ots = triJournee(workOrders ?? []);
const urgences = ots.filter((o) => o.priority === 'PERSON_TRAPPED'); const urgences = ots.filter((o) => o.priority === 'PERSON_TRAPPED');
@@ -55,57 +54,7 @@ export default function PageJournee() {
return ( return (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}> <SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<View style={{ padding: 14, gap: 10, flex: 1 }}> <View style={{ padding: 14, gap: 10, flex: 1 }}>
{/* Entête app : marque + pastille synchro + compte */} <EnteteTabs />
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<Text style={{ color: t.safran, fontSize: 15 }}></Text>
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 17, color: t.encre }}>
SIOP
</Text>
<Text style={{ fontFamily: 'Manrope_600SemiBold', fontSize: 10.5, color: t.encre3 }}>
Technicien
</Text>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6, marginLeft: 'auto' }}>
<Text
accessibilityLabel="État de synchronisation"
style={{
fontFamily: 'Manrope_700Bold',
fontSize: 10.5,
color: horsLigne ? t.stAttente : t.stTermine,
backgroundColor: horsLigne ? t.stAttenteFond : t.stTermineFond,
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 999,
overflow: 'hidden',
}}
>
{horsLigne ? 'Hors-ligne' : 'Synchro à jour'}
</Text>
<Pressable
accessibilityRole="button"
accessibilityLabel="Se déconnecter"
onLongPress={() => {
void logout().then(() => router.replace('/connexion'));
}}
style={{
width: 26,
height: 26,
borderRadius: 13,
backgroundColor: t.stEncours,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Text style={{ color: '#fff', fontFamily: 'Manrope_700Bold', fontSize: 10 }}>
{(me?.displayName ?? '·')
.split(' ')
.map((m) => m[0])
.join('')
.slice(0, 2)
.toUpperCase()}
</Text>
</Pressable>
</View>
</View>
<View style={{ flexDirection: 'row', alignItems: 'baseline' }}> <View style={{ flexDirection: 'row', alignItems: 'baseline' }}>
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}> <Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>

View File

@@ -0,0 +1,141 @@
import { router } from 'expo-router';
import { Pressable, ScrollView, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import type { ObjectCategory, PermissionRight } from '@siop/shared';
import { useMe } from '@/auth/session';
import { estRoleTerrain } from '@/auth/roles';
import { usePermissions } from '@/auth/use-permissions';
import { EnteteTabs } from '@/composants/ui';
import { useTokens, type Tokens } from '@/theme/tokens';
/** Menu — maquette « mobile ouvert à tous les rôles », écran 3 : reprend à
* l'identique les 4 groupes et l'ordre de la sidebar web (Exploitation /
* Parc / Ressources / Pilotage, coquille.tsx), filtrés par LA MÊME matrice
* (D2 — aucune règle de droit nouvelle). Un groupe sans aucun lien visible
* est masqué en entier (D3) : l'écran est petit, un intitulé vide n'aide
* personne. Ce que « Ma journée »/« Préventif » couvrent déjà pour le
* Technicien n'est pas dupliqué ici. */
interface LienMenu {
libelle: string;
permission: [ObjectCategory, PermissionRight];
ico: string;
cacheEnTerrain?: boolean; // déjà couvert par un onglet terrain dédié
route?: string;
aVenir?: string; // detail affiché sur l'écran « à venir »
}
const GROUPES: { titre: string; liens: LienMenu[] }[] = [
{
titre: 'Exploitation',
liens: [
{ libelle: 'Ordres de travail', ico: '☰', permission: ['WORK_ORDERS', 'view'], cacheEnTerrain: true, route: '/(tabs)/ot' },
{ libelle: 'Demandes', ico: '✎', permission: ['REQUESTS', 'view'], route: '/demandes' },
{ libelle: 'Préventif', ico: '✓', permission: ['WORK_ORDERS', 'view'], cacheEnTerrain: true, aVenir: 'Les grilles préventives pour les rôles de gestion arrivent dans une prochaine étape — le Technicien les a déjà dans son onglet dédié.' },
],
},
{
titre: 'Parc',
liens: [
{ libelle: 'Ascenseurs', ico: '▣', permission: ['ASSETS', 'view'], route: '/ascenseurs' },
{ libelle: 'Sites', ico: '◫', permission: ['LOCATIONS', 'view'], route: '/sites' },
{ libelle: 'Catégories', ico: '▤', permission: ['SETTINGS', 'view'], aVenir: 'Ladministration des catégories reste sur le grand écran pour linstant.' },
],
},
{
titre: 'Ressources',
liens: [
{ libelle: 'Stock & achats', ico: '◔', permission: ['PARTS', 'view'], route: '/stock' },
{ libelle: 'Tiers', ico: '⇄', permission: ['PURCHASE_ORDERS', 'view'], route: '/tiers' },
{ libelle: 'Fichiers', ico: '▧', permission: ['ASSETS', 'view'], route: '/bibliotheque' },
],
},
{
titre: 'Pilotage',
liens: [
{ libelle: 'Statistiques', ico: '◈', permission: ['ANALYTICS', 'view'], route: '/statistiques' },
{ libelle: 'Assistant', ico: '✦', permission: ['WORK_ORDERS', 'view'], route: '/assistant' },
{ libelle: 'Personnes & équipes', ico: '◎', permission: ['PEOPLE_TEAMS', 'view'], route: '/personnes' },
],
},
];
export default function PageMenu() {
const t = useTokens();
const { data: me } = useMe();
const { can } = usePermissions();
const terrain = estRoleTerrain(me?.role.name);
const groupesVisibles = GROUPES.map((g) => ({
...g,
liens: g.liens.filter(
({ permission, cacheEnTerrain }) => can(...permission) && !(cacheEnTerrain && terrain),
),
})).filter((g) => g.liens.length > 0);
return (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<View style={{ padding: 14, gap: 10, flex: 1 }}>
<EnteteTabs />
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>Menu</Text>
<ScrollView contentContainerStyle={{ gap: 14, paddingBottom: 16 }} showsVerticalScrollIndicator={false}>
{groupesVisibles.length === 0 ? (
<Text style={{ color: t.encre3, fontFamily: 'Manrope_600SemiBold', textAlign: 'center', padding: 24 }}>
Rien d'autre accessible à votre rôle ici.
</Text>
) : null}
{groupesVisibles.map((g) => (
<View key={g.titre} style={{ gap: 6 }}>
<Text
style={{
fontFamily: 'Manrope_800ExtraBold',
fontSize: 10.5,
letterSpacing: 0.8,
textTransform: 'uppercase',
color: t.encre3,
}}
>
{g.titre}
</Text>
{g.liens.map((lien) => (
<LigneMenu key={lien.libelle} lien={lien} t={t} />
))}
</View>
))}
</ScrollView>
</View>
</SafeAreaView>
);
}
function LigneMenu({ lien, t }: { lien: LienMenu; t: Tokens }) {
const aller = () => {
if (lien.route) {
router.push(lien.route as never);
} else {
router.push({ pathname: '/a-venir', params: { titre: lien.libelle, detail: lien.aVenir ?? '' } });
}
};
return (
<Pressable
accessibilityRole="button"
onPress={aller}
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 10,
backgroundColor: t.surface,
borderColor: t.bordure,
borderWidth: 1,
borderRadius: 10,
padding: 11,
}}
>
<Text style={{ width: 22, textAlign: 'center', fontSize: 14, color: t.primaire }}>{lien.ico}</Text>
<Text style={{ flex: 1, fontFamily: 'Manrope_700Bold', fontSize: 12.5, color: t.encre }}>
{lien.libelle}
</Text>
<Text style={{ color: t.encre3, fontSize: 13 }}></Text>
</Pressable>
);
}

View File

@@ -0,0 +1,24 @@
import { router } from 'expo-router';
import { ScrollView, Text } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { FormulaireDemande } from '@/composants/formulaire-demande';
import { EnteteTabs } from '@/composants/ui';
import { useTokens } from '@/theme/tokens';
/** Onglet du Demandeur (maquette « mobile ouvert à tous les rôles »,
* écran 5) : le geste qu'il fait le plus mérite son propre onglet plutôt
* qu'un bouton caché dans l'Accueil. */
export default function PageNouvelleDemande() {
const t = useTokens();
return (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<ScrollView contentContainerStyle={{ padding: 14, gap: 10 }}>
<EnteteTabs />
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
Nouvelle demande
</Text>
<FormulaireDemande surSucces={() => router.replace('/(tabs)/accueil')} />
</ScrollView>
</SafeAreaView>
);
}

View File

@@ -0,0 +1,154 @@
import { router } from 'expo-router';
import { FlatList, Pressable, RefreshControl, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import {
WORK_ORDER_PRIORITY_LABELS,
WORK_ORDER_STATUS_LABELS,
WORK_ORDER_TYPE_LABELS,
type WorkOrderPriority,
type WorkOrderStatus,
} from '@siop/shared';
import { useWorkOrders } from '@/api/exploitation';
import { EnteteTabs } from '@/composants/ui';
import { triJournee } from '@/lib/journee';
import { useTokens, type Tokens } from '@/theme/tokens';
/** Ordres de travail — maquette « mobile ouvert à tous les rôles », écran 4 :
* à la différence de « Ma journée » (Technicien, ses OT uniquement),
* Administrateur/Gestionnaire/Dispatcher ont `viewOther` — l'API renvoie
* déjà TOUS les OT (ADR-003, rien à filtrer ici). Même tri priorité puis
* échéance que « Ma journée » ; ouvrir un OT réutilise la fiche R4 telle
* quelle (elle affiche déjà les actions permises via `allowedTransitions`). */
const STYLE_STATUT: Record<WorkOrderStatus, (t: Tokens) => [string, string]> = {
OPEN: (t) => [t.stOuvert, t.stOuvertFond],
IN_PROGRESS: (t) => [t.stEncours, t.stEncoursFond],
ON_HOLD: (t) => [t.stAttente, t.stAttenteFond],
DONE: (t) => [t.stTermine, t.stTermineFond],
CANCELLED: (t) => [t.stAnnule, t.stAnnuleFond],
};
const STRIE_PRIORITE: Record<WorkOrderPriority, (t: Tokens) => string> = {
PERSON_TRAPPED: (t) => t.prioBloque,
HIGH: (t) => t.prioHaute,
MEDIUM: (t) => t.prioMoyenne,
LOW: (t) => t.prioBasse,
NONE: () => 'transparent',
};
export default function PageOT() {
const t = useTokens();
const { data: workOrders, refetch, isFetching } = useWorkOrders();
const ots = triJournee(workOrders ?? []);
const urgences = ots.filter((o) => o.priority === 'PERSON_TRAPPED');
return (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<View style={{ padding: 14, gap: 10, flex: 1 }}>
<EnteteTabs />
<View style={{ flexDirection: 'row', alignItems: 'baseline' }}>
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
Ordres de travail
</Text>
<Text style={{ marginLeft: 'auto', fontFamily: 'Manrope_600SemiBold', fontSize: 11, color: t.encre3 }}>
{ots.length}
</Text>
</View>
{urgences.length ? (
<View
style={{
backgroundColor: t.prioBloqueFond,
borderRadius: 10,
padding: 9,
flexDirection: 'row',
alignItems: 'center',
gap: 8,
}}
>
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: t.prioBloque }} />
<Text style={{ color: t.prioBloque, fontFamily: 'Manrope_800ExtraBold', fontSize: 12 }}>
{urgences.length === 1
? `1 personne bloquée — ${urgences[0]!.assetReference} · ${urgences[0]!.siteName}`
: `${urgences.length} personnes bloquées`}
</Text>
</View>
) : null}
<FlatList
data={ots}
keyExtractor={(o) => o.id}
refreshControl={
<RefreshControl refreshing={isFetching} onRefresh={() => void refetch()} tintColor={t.primaire} />
}
contentContainerStyle={{ gap: 8, paddingBottom: 12 }}
ListEmptyComponent={
<Text style={{ color: t.encre3, fontFamily: 'Manrope_600SemiBold', padding: 16, textAlign: 'center' }}>
Aucun OT tirez pour rafraîchir.
</Text>
}
renderItem={({ item: o }) => {
const [enc, fond] = STYLE_STATUT[o.status](t);
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={`${o.reference}${o.title}`}
onPress={() => router.push(`/ot/${o.id}`)}
style={{
backgroundColor: t.surface,
borderColor: t.bordure,
borderWidth: 1,
borderRadius: 12,
borderLeftWidth: 4,
borderLeftColor: STRIE_PRIORITE[o.priority](t),
padding: 11,
gap: 2,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
<Text
style={{
fontFamily: 'Manrope_700Bold',
fontSize: 10.5,
letterSpacing: 0.6,
textTransform: 'uppercase',
color: t.encre2,
flex: 1,
}}
>
{o.reference} · {WORK_ORDER_TYPE_LABELS[o.type]}
</Text>
<Text
style={{
fontFamily: 'Manrope_700Bold',
fontSize: 10.5,
color: enc,
backgroundColor: fond,
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 999,
overflow: 'hidden',
}}
>
{WORK_ORDER_STATUS_LABELS[o.status]}
</Text>
</View>
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 14, color: t.encre }}>
{o.title}
</Text>
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 12, color: t.encre2 }}>
Asc. {o.assetReference} {o.siteName}
{o.dueDate
? ` · échéance ${new Intl.DateTimeFormat('fr-FR', { day: 'numeric', month: 'short' }).format(new Date(o.dueDate))}`
: ''}
{o.priority !== 'NONE' && o.priority !== 'PERSON_TRAPPED'
? ` · ${WORK_ORDER_PRIORITY_LABELS[o.priority]}`
: ''}
</Text>
</Pressable>
);
}}
/>
</View>
</SafeAreaView>
);
}

View File

@@ -2,7 +2,7 @@ import { router } from 'expo-router';
import { FlatList, Pressable, Text, View } from 'react-native'; import { FlatList, Pressable, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useWorkOrders } from '@/api/exploitation'; import { useWorkOrders } from '@/api/exploitation';
import { ChipStatut } from '@/composants/ui'; import { ChipStatut, EnteteTabs } from '@/composants/ui';
import { useTokens } from '@/theme/tokens'; import { useTokens } from '@/theme/tokens';
/** Onglet Préventif : mes grilles du moment — chaque tuile mène à la /** Onglet Préventif : mes grilles du moment — chaque tuile mène à la
@@ -18,6 +18,7 @@ export default function PagePreventif() {
return ( return (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}> <SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<View style={{ flex: 1, padding: 14, gap: 10 }}> <View style={{ flex: 1, padding: 14, gap: 10 }}>
<EnteteTabs />
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}> <Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
Préventif Préventif
</Text> </Text>

View File

@@ -5,7 +5,7 @@ import { Platform, Text, TextInput, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useAssets } from '@/api/exploitation'; import { useAssets } from '@/api/exploitation';
import { useHorsLigne } from '@/auth/session'; import { useHorsLigne } from '@/auth/session';
import { BoutonTel } from '@/composants/ui'; import { BoutonTel, EnteteTabs } from '@/composants/ui';
import { analyseScan } from '@/lib/scan'; import { analyseScan } from '@/lib/scan';
import { useTokens } from '@/theme/tokens'; import { useTokens } from '@/theme/tokens';
@@ -59,6 +59,7 @@ export default function PageScanner() {
return ( return (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}> <SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<View style={{ flex: 1, padding: 14, gap: 10 }}> <View style={{ flex: 1, padding: 14, gap: 10 }}>
<EnteteTabs />
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}> <Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
Scanner Scanner
</Text> </Text>

View File

@@ -3,7 +3,7 @@ import { router } from 'expo-router';
import { FlatList, Pressable, Text, View } from 'react-native'; import { FlatList, Pressable, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useHorsLigne } from '@/auth/session'; import { useHorsLigne } from '@/auth/session';
import { BoutonTel } from '@/composants/ui'; import { BoutonTel, EnteteTabs } from '@/composants/ui';
import { useFile, type Saisie } from '@/file/store'; import { useFile, type Saisie } from '@/file/store';
import { abandonnerSaisie, rejouer, rejouerSurVersionAJour } from '@/file/synchro'; import { abandonnerSaisie, rejouer, rejouerSurVersionAJour } from '@/file/synchro';
import { useTokens } from '@/theme/tokens'; import { useTokens } from '@/theme/tokens';
@@ -33,6 +33,7 @@ export default function PageSynchro() {
return ( return (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}> <SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<View style={{ flex: 1, padding: 14, gap: 10 }}> <View style={{ flex: 1, padding: 14, gap: 10 }}>
<EnteteTabs />
<View style={{ flexDirection: 'row', alignItems: 'baseline', gap: 8 }}> <View style={{ flexDirection: 'row', alignItems: 'baseline', gap: 8 }}>
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}> <Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
Synchro Synchro

View File

@@ -0,0 +1,21 @@
import { useLocalSearchParams } from 'expo-router';
import { SafeAreaView } from 'react-native-safe-area-context';
import { AVenir } from '@/composants/a-venir';
import { EnteteFiche } from '@/composants/ui';
import { useTokens } from '@/theme/tokens';
/** Destination générique du Menu pour les familles pas encore portées sur
* mobile (Sites, Stock, Tiers, Personnes, Statistiques, Assistant…) — même
* patron que la sidebar web (release annoncée, jamais un cul-de-sac
* silencieux). `AVenir` existait déjà (a-venir.tsx) mais n'était encore
* câblé nulle part. */
export default function PageAVenir() {
const t = useTokens();
const { titre, detail } = useLocalSearchParams<{ titre: string; detail: string }>();
return (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<EnteteFiche titre={titre ?? '—'} />
<AVenir titre={titre ?? '—'} release="une prochaine étape" detail={detail ?? ''} />
</SafeAreaView>
);
}

View File

@@ -0,0 +1,90 @@
import { router } from 'expo-router';
import { FlatList, Pressable, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { ASSET_STATUS_LABELS, type AssetStatus } from '@siop/shared';
import { useAssets } from '@/api/exploitation';
import { EnteteFiche } from '@/composants/ui';
import { useTokens, type Tokens } from '@/theme/tokens';
/** Ascenseurs — R6.2 (Parc). Le parc complet, déjà préchargé (D1 lecture
* locale) — même donnée que le Scanner, présentée en liste plutôt qu'en
* résolution QR. Ouvre la fiche R4 telle quelle (écran 5). */
export default function PageAscenseurs() {
const t = useTokens();
const { data: assets } = useAssets();
const appareils = [...(assets ?? [])].sort((a, b) => a.reference.localeCompare(b.reference));
return (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<View style={{ flex: 1, padding: 14, gap: 10 }}>
<EnteteFiche titre="Ascenseurs" />
<FlatList
data={appareils}
keyExtractor={(a) => a.id}
contentContainerStyle={{ gap: 8, paddingBottom: 12 }}
ListEmptyComponent={
<Text style={{ color: t.encre3, fontFamily: 'Manrope_600SemiBold', padding: 16, textAlign: 'center' }}>
Aucun appareil accessible.
</Text>
}
renderItem={({ item: a }) => (
<Pressable
accessibilityRole="button"
onPress={() => router.push(`/ascenseur/${a.id}`)}
style={{
backgroundColor: t.surface,
borderColor: t.bordure,
borderWidth: 1,
borderRadius: 12,
padding: 12,
gap: 2,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
<Text
style={{
fontFamily: 'Manrope_700Bold',
fontSize: 10.5,
letterSpacing: 0.6,
textTransform: 'uppercase',
color: t.encre2,
flex: 1,
}}
>
{a.reference}
</Text>
<ChipStatutAppareil statut={a.status} t={t} />
</View>
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 14, color: t.encre }}>
{a.brand} {a.model ?? ''}
</Text>
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 12, color: t.encre2 }}>
{a.siteName} {a.locationName}
</Text>
</Pressable>
)}
/>
</View>
</SafeAreaView>
);
}
function ChipStatutAppareil({ statut, t }: { statut: AssetStatus; t: Tokens }) {
const enService = statut === 'IN_SERVICE';
return (
<Text
style={{
fontFamily: 'Manrope_700Bold',
fontSize: 10.5,
color: enService ? t.stTermine : t.stAttente,
backgroundColor: enService ? t.stTermineFond : t.stAttenteFond,
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 999,
overflow: 'hidden',
}}
>
{ASSET_STATUS_LABELS[statut]}
</Text>
);
}

View File

@@ -0,0 +1,380 @@
// SDK 57 : les fonctions module-level (dont deleteAsync) sont dépréciées au
// profit des classes File/Directory et lèvent désormais en développement —
// import explicite du sous-chemin legacy, comportement inchangé sinon.
import * as FileSystem from 'expo-file-system/legacy';
import { router } from 'expo-router';
import { useRef, useState } from 'react';
import { Pressable, ScrollView, Text, TextInput, View } from 'react-native';
import {
RecordingPresets,
requestRecordingPermissionsAsync,
setAudioModeAsync,
useAudioRecorder,
} from 'expo-audio';
import { SafeAreaView } from 'react-native-safe-area-context';
import type { AssistantAnswer, AssistantExcerpt } from '@siop/shared';
import { useAskAssistant } from '@/api/assistant';
import { useTranscription } from '@/api/exploitation';
import { BoutonTel, EnteteFiche } from '@/composants/ui';
import { useTokens, type Tokens } from '@/theme/tokens';
/** Assistant — maquette « Assistant mobile » (validée 02/08) : même contenu
* et mêmes règles que le web (D2 « sourcé ou silencieux », ADR-004 §4 —
* le service IA n'est jamais appelé directement, toujours via l'API).
* D5 : la question peut être tapée OU dictée — même pipeline que la
* dictée déjà livrée en clôture (ADR-004 §5), la transcription REMPLIT le
* champ (éditable), aucun envoi automatique. */
interface Echange {
question: string;
reponse?: AssistantAnswer;
erreur?: string;
}
export default function PageAssistant() {
const t = useTokens();
const ask = useAskAssistant();
const transcrire = useTranscription();
const recorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY);
const [question, setQuestion] = useState('');
const [echanges, setEchanges] = useState<Echange[]>([]);
const [enregistrement, setEnregistrement] = useState(false);
const saisieRef = useRef<TextInput>(null);
const demarrerDictee = async () => {
const { granted } = await requestRecordingPermissionsAsync();
if (!granted) return;
// iOS refuse recorder.record() sans autorisation explicite de la session
// audio (RecordingDisabledException) — même correctif qu'en clôture.
await setAudioModeAsync({ allowsRecording: true, playsInSilentMode: true });
await recorder.prepareToRecordAsync();
recorder.record();
setEnregistrement(true);
};
const terminerDictee = async () => {
await recorder.stop();
await setAudioModeAsync({ allowsRecording: false });
setEnregistrement(false);
const uri = recorder.uri;
if (!uri) return;
transcrire.mutate(
{ uri, nom: 'question.m4a', mime: 'audio/m4a' },
{
onSuccess: (resultat) => setQuestion(resultat.text),
// Loi 09-08 (D5) : l'audio ne survit jamais à l'appel, succès ou pas.
onSettled: () => void FileSystem.deleteAsync(uri, { idempotent: true }),
},
);
};
const envoyer = () => {
const q = question.trim();
if (q.length < 3 || ask.isPending) return;
setQuestion('');
setEchanges((liste) => [...liste, { question: q }]);
ask.mutate(
{ question: q },
{
onSuccess: (reponse) =>
setEchanges((liste) =>
liste.map((e, i) => (i === liste.length - 1 ? { ...e, reponse } : e)),
),
onError: (erreur) =>
setEchanges((liste) =>
liste.map((e, i) => (i === liste.length - 1 ? { ...e, erreur: erreur.message } : e)),
),
},
);
};
return (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<View style={{ flex: 1, padding: 14, gap: 10 }}>
<EnteteFiche titre="Assistant" />
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ gap: 10, paddingBottom: 8 }}>
{echanges.length === 0 ? (
<Text style={{ color: t.encre2, fontFamily: 'Manrope_400Regular', fontSize: 12.5 }}>
Posez une question sur vos notices, vos historiques d'intervention ou vos
procédures — chaque réponse cite ses sources. Quand le corpus ne porte pas la
réponse, l'assistant le dit au lieu d'inventer.
</Text>
) : null}
{echanges.map((e, i) => (
<View key={i} style={{ gap: 8 }}>
<Text
style={{
alignSelf: 'flex-end',
backgroundColor: t.primaire,
color: '#fff',
borderRadius: 13,
borderBottomRightRadius: 3,
paddingHorizontal: 12,
paddingVertical: 9,
maxWidth: '85%',
fontFamily: 'Manrope_600SemiBold',
fontSize: 12.5,
}}
>
{e.question}
</Text>
{e.reponse ? (
<Reponse reponse={e.reponse} t={t} surReformuler={() => saisieRef.current?.focus()} />
) : e.erreur ? (
<Text style={{ color: t.danger, fontFamily: 'Manrope_600SemiBold', fontSize: 12.5 }}>
{e.erreur}
</Text>
) : (
<Text style={{ color: t.encre3, fontFamily: 'Manrope_400Regular', fontSize: 12.5 }}>
Recherche dans le corpus…
</Text>
)}
</View>
))}
</ScrollView>
{enregistrement ? (
<View
style={{
alignItems: 'center',
gap: 8,
borderWidth: 1.5,
borderColor: t.bordure,
borderRadius: 13,
backgroundColor: t.surface,
padding: 16,
}}
>
<View
style={{
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: t.prioBloque,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Text style={{ fontSize: 18 }}>🎙</Text>
</View>
<Text style={{ color: t.encre2, fontFamily: 'Manrope_400Regular', fontSize: 11.5 }}>
Enregistrement en cours…
</Text>
<BoutonTel
libelle="■ Terminer la dictée"
surAppui={() => void terminerDictee()}
/>
</View>
) : (
<View
style={{
gap: 8,
borderWidth: 1.5,
borderColor: t.bordureForte,
borderRadius: 10,
backgroundColor: t.surface,
padding: 10,
}}
>
{/* Zone pleine largeur, multiligne : la question dictée doit se
* lire EN ENTIER avant d'envoyer (trouvé en recette, 02/08
* une barre d'une ligne masquait le texte transcrit long). */}
<TextInput
ref={saisieRef}
accessibilityLabel="Poser une question"
multiline
scrollEnabled
value={transcrire.isPending ? 'Transcription' : question}
editable={!transcrire.isPending}
onChangeText={setQuestion}
maxLength={500}
placeholder="Poser une question, ou dictez avec 🎙…"
placeholderTextColor={t.encre3}
style={{
// Hauteur FIXE plutôt que maxHeight seul : sur iOS, un
// TextInput multiligne ignore parfois maxHeight et grandit
// avec le contenu, poussant/recouvrant les boutons du
// dessous (trouvé en recette, 02/08). Hauteur fixe +
// scrollEnabled = défilement interne garanti au-delà.
height: 96,
color: t.encre,
fontFamily: 'Manrope_600SemiBold',
fontSize: 13.5,
textAlignVertical: 'top',
}}
/>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<Pressable
accessibilityRole="button"
accessibilityLabel="Dicter la question"
disabled={transcrire.isPending}
onPress={() => void demarrerDictee()}
style={{
width: 30,
height: 30,
borderRadius: 15,
backgroundColor: t.primaireDoux,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Text style={{ fontSize: 14 }}>🎙</Text>
</Pressable>
<View style={{ flex: 1 }}>
<BoutonTel
libelle="Envoyer"
desactive={question.trim().length < 3 || ask.isPending || transcrire.isPending}
surAppui={envoyer}
/>
</View>
</View>
</View>
)}
{transcrire.isError ? (
<Text style={{ color: t.danger, fontFamily: 'Manrope_600SemiBold', fontSize: 11.5 }}>
{transcrire.error.message}
</Text>
) : null}
</View>
</SafeAreaView>
);
}
function Reponse({
reponse,
t,
surReformuler,
}: {
reponse: AssistantAnswer;
t: Tokens;
surReformuler: () => void;
}) {
if (reponse.mode === 'REFUSAL') {
return (
<View
style={{
borderWidth: 1.5,
borderColor: t.bordureForte,
borderStyle: 'dashed',
borderRadius: 13,
padding: 12,
gap: 8,
backgroundColor: t.surface,
}}
>
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 12.5, color: t.encre }}>
Je ne trouve pas de source fiable dans votre bibliothèque — je préfère ne pas inventer.
</Text>
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 11.5, color: t.encre2 }}>
J'ai cherché dans {reponse.corpus.documents} document
{reponse.corpus.documents > 1 ? 's' : ''} indexé
{reponse.corpus.documents > 1 ? 's' : ''} et {reponse.corpus.reports} bilan
{reponse.corpus.reports > 1 ? 's' : ''} d'intervention : rien d'assez proche de votre
question.
</Text>
<BoutonTel libelle="Reformuler ma question" variante="contour" surAppui={surReformuler} />
</View>
);
}
return (
<View
style={{
backgroundColor: t.surface,
borderColor: t.bordure,
borderWidth: 1,
borderRadius: 13,
borderBottomLeftRadius: 3,
padding: 11,
gap: 9,
}}
>
{reponse.mode === 'GENERATED' && reponse.answer ? (
<Text style={{ fontFamily: 'Manrope_500Medium', fontSize: 12.5, color: t.encre }}>
{reponse.answer}
</Text>
) : (
<Text style={{ fontFamily: 'Manrope_500Medium', fontSize: 12.5, color: t.encre }}>
Voici ce que portent vos sources les extraits sont cités tels quels.
</Text>
)}
<View
style={{ gap: 7, borderTopWidth: 1, borderTopColor: t.bordure, paddingTop: 9 }}
>
{reponse.excerpts.map((ex, i) => (
<Source key={`${ex.locator}-${i}`} ex={ex} no={i + 1} t={t} />
))}
</View>
<View
style={{
flexDirection: 'row',
gap: 6,
backgroundColor: t.safranDoux,
borderRadius: 9,
padding: 8,
}}
>
<Text style={{ color: t.alerte, fontSize: 10.5, fontFamily: 'Manrope_600SemiBold', flex: 1 }}>
L'IA propose, vous validez : vérifiez la notice avant d'agir sur l'appareil.
</Text>
</View>
</View>
);
}
function Source({ ex, no, t }: { ex: AssistantExcerpt; no: number; t: Tokens }) {
const ouvrir = () => {
if (ex.sourceType === 'DOCUMENT' && ex.documentId) router.push(`/bibliotheque/${ex.documentId}`);
else if (ex.workOrderId) router.push(`/ot/${ex.workOrderId}`);
};
return (
<Pressable
accessibilityRole="button"
onPress={ouvrir}
style={{ backgroundColor: t.surface2, borderRadius: 9, padding: 9, gap: 3 }}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 7 }}>
<Text
style={{
width: 16,
height: 16,
borderRadius: 5,
backgroundColor: t.primaireDoux,
color: t.primaire,
fontSize: 10,
fontFamily: 'Manrope_800ExtraBold',
textAlign: 'center',
lineHeight: 16,
}}
>
{no}
</Text>
<Text
numberOfLines={1}
style={{ flex: 1, fontFamily: 'Manrope_700Bold', fontSize: 11.5, color: t.encre }}
>
{ex.title}
</Text>
</View>
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 10.5, color: t.encre2, marginLeft: 23 }}>
{ex.sourceType === 'DOCUMENT' ? 'Bibliothèque' : 'Historique'} · {ex.locator}
</Text>
<Text
style={{
fontFamily: 'Manrope_500Medium',
fontStyle: 'italic',
fontSize: 11,
color: t.encre2,
marginLeft: 23,
borderLeftWidth: 2,
borderLeftColor: t.safran,
paddingLeft: 7,
}}
>
« {ex.content} »
</Text>
<Text style={{ marginLeft: 23, color: t.primaire, fontFamily: 'Manrope_700Bold', fontSize: 11 }}>
{ex.sourceType === 'DOCUMENT' ? 'Voir le document' : "Ouvrir l'OT"}
</Text>
</Pressable>
);
}

View File

@@ -0,0 +1,52 @@
import { useLocalSearchParams } from 'expo-router';
import { ScrollView, Text } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useDocuments } from '@/api/ressources';
import { Carte, EnteteFiche, LigneInfo } from '@/composants/ui';
import { useTokens } from '@/theme/tokens';
const KIND_LABEL: Record<string, string> = {
NOTICE: 'Notice',
CERTIFICATE: 'Certificat',
PHOTO: 'Photo',
OTHER: 'Autre',
};
function taille(octets: number): string {
if (octets < 1024) return `${octets} o`;
if (octets < 1024 * 1024) return `${Math.round(octets / 1024)} Ko`;
return `${(octets / (1024 * 1024)).toFixed(1)} Mo`;
}
/** Fiche document — R6.5. Métadonnées seules (D2, comme la bibliothèque
* R6.3) : l'ouverture du fichier lui-même demande `expo-sharing`, pas
* encore une dépendance du projet — ouvrir depuis le web pour l'instant.
* Destination de « Voir le document » depuis les sources de l'Assistant. */
export default function PageFicheDocument() {
const t = useTokens();
const { id } = useLocalSearchParams<{ id: string }>();
const { data: documents } = useDocuments();
const doc = (documents ?? []).find((d) => d.id === id);
if (!doc) return null;
return (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<ScrollView contentContainerStyle={{ padding: 14, gap: 10 }}>
<EnteteFiche titre={doc.fileName} />
<Carte titre="Document">
<LigneInfo nom="Type" valeur={KIND_LABEL[doc.kind] ?? doc.kind} />
<LigneInfo nom="Taille" valeur={taille(doc.size)} />
<LigneInfo nom="Rattaché à" valeur={doc.assetReference ?? doc.workOrderReference ?? '—'} />
<LigneInfo nom="Ajouté par" valeur={doc.uploadedByName ?? '—'} />
<LigneInfo
nom="Ajouté le"
valeur={new Intl.DateTimeFormat('fr-FR', { dateStyle: 'long' }).format(new Date(doc.createdAt))}
/>
</Carte>
<Text style={{ color: t.encre3, fontFamily: 'Manrope_400Regular', fontSize: 11.5, textAlign: 'center' }}>
Ouvrir le fichier se fait depuis le web pour l'instant.
</Text>
</ScrollView>
</SafeAreaView>
);
}

View File

@@ -0,0 +1,83 @@
import { router } from 'expo-router';
import { FlatList, Pressable, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useDocuments } from '@/api/ressources';
import { EnteteFiche } from '@/composants/ui';
import { useTokens } from '@/theme/tokens';
const ICONE_TYPE: Record<string, string> = {
NOTICE: '📘',
CERTIFICATE: '📜',
PHOTO: '🖼',
OTHER: '📄',
};
function taille(octets: number): string {
if (octets < 1024) return `${octets} o`;
if (octets < 1024 * 1024) return `${Math.round(octets / 1024)} Ko`;
return `${(octets / (1024 * 1024)).toFixed(1)} Mo`;
}
/** Fichiers — R6.3 (Ressources). Consultation des métadonnées seulement
* (D4) : l'ouverture/téléchargement d'un document sur mobile demande un
* flux authentifié (l'API le streame, MinIO n'est jamais exposé) suivi
* d'un partage natif — nécessite `expo-sharing`, pas encore une dépendance
* du projet ; ajoutée dans une prochaine étape plutôt que d'introduire une
* nouvelle dépendance native (et donc un nouveau build) dans cette passe.
* En attendant : ouvrir depuis le web. */
export default function PageBibliotheque() {
const t = useTokens();
const { data: documents } = useDocuments();
const fichiers = [...(documents ?? [])].sort(
(a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt),
);
return (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<View style={{ flex: 1, padding: 14, gap: 10 }}>
<EnteteFiche titre="Fichiers" />
<Text style={{ fontFamily: 'Manrope_600SemiBold', fontSize: 11.5, color: t.encre3 }}>
Consultation seulement l'ouverture se fait depuis le web pour l'instant.
</Text>
<FlatList
data={fichiers}
keyExtractor={(d) => d.id}
contentContainerStyle={{ gap: 8, paddingBottom: 12 }}
ListEmptyComponent={
<Text style={{ color: t.encre3, fontFamily: 'Manrope_600SemiBold', padding: 16, textAlign: 'center' }}>
Aucun document accessible.
</Text>
}
renderItem={({ item: d }) => (
<Pressable
accessibilityRole="button"
onPress={() => router.push(`/bibliotheque/${d.id}`)}
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 10,
backgroundColor: t.surface,
borderColor: t.bordure,
borderWidth: 1,
borderRadius: 12,
padding: 12,
}}
>
<Text style={{ fontSize: 20 }}>{ICONE_TYPE[d.kind] ?? '📄'}</Text>
<View style={{ flex: 1, gap: 1 }}>
<Text numberOfLines={1} style={{ fontFamily: 'Manrope_700Bold', fontSize: 13, color: t.encre }}>
{d.fileName}
</Text>
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 11.5, color: t.encre2 }}>
{[d.assetReference && `Asc. ${d.assetReference}`, d.workOrderReference, taille(d.size)]
.filter(Boolean)
.join(' · ')}
</Text>
</View>
</Pressable>
)}
/>
</View>
</SafeAreaView>
);
}

View File

@@ -9,12 +9,18 @@ import {
TextInput, TextInput,
View, View,
} from 'react-native'; } from 'react-native';
import type { RoleName } from '@siop/shared';
import { ongletAccueil } from '@/auth/roles';
import { useDemoAccounts, useDemoLogin, useLogin } from '@/auth/session'; import { useDemoAccounts, useDemoLogin, useLogin } from '@/auth/session';
import { useTokens } from '@/theme/tokens'; import { useTokens } from '@/theme/tokens';
/** Connexion mobile — mêmes règles que le web : formulaire e-mail/mot de /** Connexion mobile — mêmes règles que le web : formulaire e-mail/mot de
* passe, et le sélecteur démo (< 3 s pour changer de rôle, ADR-002) * passe, et le sélecteur démo (< 3 s pour changer de rôle, ADR-002)
* UNIQUEMENT si l'API l'expose. */ * UNIQUEMENT si l'API l'expose. Depuis R6, l'app n'est plus réservée au
* Technicien : on route vers l'onglet d'accueil du RÔLE réel (« Ma
* journée » pour le terrain, « Accueil » sinon) — jamais un onglet figé
* (trouvé en recette : un Demandeur atterrissait sur « Ma journée »,
* l'écran du Technicien, faute de lire le rôle retourné par la connexion). */
export default function PageConnexion() { export default function PageConnexion() {
const t = useTokens(); const t = useTokens();
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
@@ -23,7 +29,7 @@ export default function PageConnexion() {
const login = useLogin(); const login = useLogin();
const demo = useDemoLogin(); const demo = useDemoLogin();
const entrer = () => router.replace('/(tabs)/journee'); const entrer = (role: RoleName) => router.replace(`/(tabs)/${ongletAccueil(role)}`);
return ( return (
<KeyboardAvoidingView <KeyboardAvoidingView
@@ -36,12 +42,9 @@ export default function PageConnexion() {
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 26, color: t.encre }}> <Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 26, color: t.encre }}>
SIOP SIOP
</Text> </Text>
<Text style={{ fontFamily: 'Manrope_600SemiBold', fontSize: 13, color: t.encre3 }}>
Technicien
</Text>
</View> </View>
<Text style={{ fontFamily: 'Manrope_400Regular', color: t.encre2, marginBottom: 8 }}> <Text style={{ fontFamily: 'Manrope_400Regular', color: t.encre2, marginBottom: 8 }}>
Vos ordres de travail, sur le terrain même sans réseau. Vos interventions et votre suivi, que vous soyez.
</Text> </Text>
<View style={{ gap: 10 }}> <View style={{ gap: 10 }}>
@@ -84,7 +87,10 @@ export default function PageConnexion() {
accessibilityRole="button" accessibilityRole="button"
disabled={!email || !motDePasse || login.isPending} disabled={!email || !motDePasse || login.isPending}
onPress={() => onPress={() =>
login.mutate({ email, password: motDePasse }, { onSuccess: entrer }) login.mutate(
{ email, password: motDePasse },
{ onSuccess: (res) => entrer(res.user.role.name) },
)
} }
style={{ style={{
backgroundColor: !email || !motDePasse ? t.bordureForte : t.primaire, backgroundColor: !email || !motDePasse ? t.bordureForte : t.primaire,
@@ -122,7 +128,9 @@ export default function PageConnexion() {
key={c.id} key={c.id}
accessibilityRole="button" accessibilityRole="button"
disabled={demo.isPending} disabled={demo.isPending}
onPress={() => demo.mutate(c.id, { onSuccess: entrer })} onPress={() =>
demo.mutate(c.id, { onSuccess: (res) => entrer(res.user.role.name) })
}
style={{ style={{
backgroundColor: t.surface, backgroundColor: t.surface,
borderColor: t.bordure, borderColor: t.bordure,

View File

@@ -0,0 +1,19 @@
import { View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { PanneauDemandes } from '@/composants/panneau-demandes';
import { EnteteFiche } from '@/composants/ui';
import { useTokens } from '@/theme/tokens';
/** Écran poussé depuis le Menu (Administrateur/Gestionnaire/Dispatcher/Vue
* seule — pas d'onglet dédié pour eux, contrairement au Demandeur). */
export default function PageDemandesPoussee() {
const t = useTokens();
return (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<View style={{ flex: 1, padding: 14, gap: 10 }}>
<EnteteFiche titre="Demandes" />
<PanneauDemandes />
</View>
</SafeAreaView>
);
}

View File

@@ -0,0 +1,18 @@
import { router } from 'expo-router';
import { ScrollView } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { FormulaireDemande } from '@/composants/formulaire-demande';
import { EnteteFiche } from '@/composants/ui';
import { useTokens } from '@/theme/tokens';
export default function PageNouvelleDemandePoussee() {
const t = useTokens();
return (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<ScrollView contentContainerStyle={{ padding: 14, gap: 10 }}>
<EnteteFiche titre="Nouvelle demande" />
<FormulaireDemande surSucces={() => (router.canGoBack() ? router.back() : router.replace('/demandes'))} />
</ScrollView>
</SafeAreaView>
);
}

View File

@@ -1,9 +1,11 @@
import { Redirect } from 'expo-router'; import { Redirect } from 'expo-router';
import { ActivityIndicator, View } from 'react-native'; import { ActivityIndicator, View } from 'react-native';
import { ongletAccueil } from '@/auth/roles';
import { useMe } from '@/auth/session'; import { useMe } from '@/auth/session';
import { useTokens } from '@/theme/tokens'; import { useTokens } from '@/theme/tokens';
/** Aiguillage : session valide → Ma journée ; sinon → connexion. /** Aiguillage : session valide → l'onglet d'accueil du rôle (Ma journée
* pour le terrain, Accueil pour les autres) ; sinon → connexion.
* (Hors-ligne avec cache persisté, /users/me sort du cache : on entre.) */ * (Hors-ligne avec cache persisté, /users/me sort du cache : on entre.) */
export default function Aiguillage() { export default function Aiguillage() {
const t = useTokens(); const t = useTokens();
@@ -16,5 +18,9 @@ export default function Aiguillage() {
</View> </View>
); );
} }
return me ? <Redirect href="/(tabs)/journee" /> : <Redirect href="/connexion" />; return me ? (
<Redirect href={`/(tabs)/${ongletAccueil(me.role.name)}`} />
) : (
<Redirect href="/connexion" />
);
} }

View File

@@ -1,16 +1,33 @@
// SDK 57 : les fonctions module-level (dont deleteAsync) sont dépréciées au
// profit des classes File/Directory et lèvent désormais en développement —
// import explicite du sous-chemin legacy, comportement inchangé sinon.
import * as FileSystem from 'expo-file-system/legacy';
import { router, useLocalSearchParams } from 'expo-router'; import { router, useLocalSearchParams } from 'expo-router';
import { useState } from 'react'; import { useState } from 'react';
import { ScrollView, Text, View } from 'react-native'; import { Pressable, ScrollView, Text, TextInput, View } from 'react-native';
import {
RecordingPresets,
requestRecordingPermissionsAsync,
setAudioModeAsync,
useAudioRecorder,
} from 'expo-audio';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { import {
BILAN_FIELD_LABELS, BILAN_FIELD_LABELS,
BILAN_FIELDS, BILAN_FIELDS,
REQUIRED_BILAN_FIELDS, REQUIRED_BILAN_FIELDS,
type BilanField, type BilanField,
type BilanSuggestion,
type ReportUpsert, type ReportUpsert,
type WorkOrderDetail,
} from '@siop/shared'; } from '@siop/shared';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { useReferenceValues, useWorkOrder } from '@/api/exploitation'; import {
useReferenceValues,
useSuggestionBilan,
useTranscription,
useWorkOrder,
} from '@/api/exploitation';
import { useHorsLigne } from '@/auth/session'; import { useHorsLigne } from '@/auth/session';
import { BoutonTel, Carte, ChoixTel, EnteteFiche } from '@/composants/ui'; import { BoutonTel, Carte, ChoixTel, EnteteFiche } from '@/composants/ui';
import { enfilerBilan, enfilerTransition } from '@/file/actions'; import { enfilerBilan, enfilerTransition } from '@/file/actions';
@@ -95,6 +112,11 @@ export default function PageCloture() {
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}> <SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<ScrollView contentContainerStyle={{ padding: 14, gap: 10 }}> <ScrollView contentContainerStyle={{ padding: 14, gap: 10 }}>
<EnteteFiche titre={`Clôturer ${ot.reference}`} /> <EnteteFiche titre={`Clôturer ${ot.reference}`} />
<CarteSuggestion
ot={ot}
horsLigne={horsLigne}
surApplication={(s) => setChoix((c) => ({ ...c, [s.field]: s.valueId }))}
/>
<Carte titre="Bilan d'intervention — requis pour clôturer"> <Carte titre="Bilan d'intervention — requis pour clôturer">
<View style={{ gap: 10 }}> <View style={{ gap: 10 }}>
{[0, 2, 4].map((rang) => ( {[0, 2, 4].map((rang) => (
@@ -151,3 +173,198 @@ export default function PageCloture() {
</SafeAreaView> </SafeAreaView>
); );
} }
/** Écran 4 des maquettes R5 : décrire au pouce → chips suggérées (D1 — un
* appui = un choix humain, les chips ne font que pré-remplir les sélecteurs).
* L'IA est un service serveur : hors-ligne, la suggestion attend le réseau
* — la clôture en file R4, elle, n'en a pas besoin.
* Écran 6 (Voix, R5 D5, amendé 22/07) : dicter au lieu de taper — l'audio
* n'est JAMAIS conservé (transcrit puis effacé côté serveur ET localement),
* seule la transcription relue compte. Une fois jointe à l'OT, elle rejoint
* le corpus de l'assistant à la clôture, comme les bilans déjà codés. */
function CarteSuggestion({
ot,
horsLigne,
surApplication,
}: {
ot: WorkOrderDetail;
horsLigne: boolean;
surApplication: (s: BilanSuggestion) => void;
}) {
const t = useTokens();
const queryClient = useQueryClient();
const suggerer = useSuggestionBilan();
const transcrire = useTranscription();
const recorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY);
const [description, setDescription] = useState('');
const [appliquees, setAppliquees] = useState<Set<BilanField>>(new Set());
const [enregistrement, setEnregistrement] = useState(false);
const [jointe, setJointe] = useState(false);
const suggestions = suggerer.data?.suggestions ?? [];
const demarrerDictee = async () => {
const { granted } = await requestRecordingPermissionsAsync();
if (!granted) return;
// iOS refuse recorder.record() tant que la session audio n'a pas été
// explicitement autorisée à enregistrer (RecordingDisabledException).
await setAudioModeAsync({ allowsRecording: true, playsInSilentMode: true });
await recorder.prepareToRecordAsync();
recorder.record();
setEnregistrement(true);
setJointe(false);
};
const terminerDictee = async () => {
await recorder.stop();
await setAudioModeAsync({ allowsRecording: false }); // n'arme le micro que le temps de dicter
setEnregistrement(false);
const uri = recorder.uri;
if (!uri) return;
transcrire.mutate(
{ uri, nom: 'dictee.m4a', mime: 'audio/m4a' },
{
onSuccess: (resultat) => setDescription(resultat.text),
// Loi 09-08 (D5) : l'audio ne survit JAMAIS à l'appel, succès ou pas —
// le fichier local suit le même sort que sur le serveur.
onSettled: () => void FileSystem.deleteAsync(uri, { idempotent: true }),
},
);
};
return (
<Carte titre="Décrire pour suggérer (optionnel)">
<TextInput
multiline
value={description}
onChangeText={(v) => {
setDescription(v);
setJointe(false);
}}
maxLength={2000}
placeholder="Décrivez la panne et ce que vous avez fait, ou dictez avec 🎙…"
placeholderTextColor={t.encre3}
accessibilityLabel="Décrire pour suggérer"
style={{
minHeight: 64,
borderWidth: 1.5,
borderColor: t.bordureForte,
borderRadius: 9,
backgroundColor: t.surface,
padding: 9,
color: t.encre,
fontFamily: 'Manrope_500Medium',
fontSize: 13,
textAlignVertical: 'top',
}}
/>
<BoutonTel
libelle={
horsLigne
? '🎙 Dictée — réseau requis'
: enregistrement
? '■ Terminer la dictée'
: transcrire.isPending
? 'Transcription…'
: '🎙 Dicter la description'
}
variante={enregistrement ? undefined : 'contour'}
desactive={horsLigne || transcrire.isPending}
surAppui={() => void (enregistrement ? terminerDictee() : demarrerDictee())}
/>
{transcrire.isError ? (
<Text style={{ color: t.danger, fontFamily: 'Manrope_600SemiBold', fontSize: 12 }}>
{transcrire.error.message}
</Text>
) : null}
{transcrire.isSuccess && !enregistrement ? (
<Text style={{ color: t.stTermine, fontFamily: 'Manrope_600SemiBold', fontSize: 11 }}>
Audio transcrit et supprimé relisez avant dappliquer.
</Text>
) : null}
<BoutonTel
libelle={jointe ? '✓ Description jointe à lOT' : 'Joindre la description à lOT'}
variante="contour"
desactive={jointe || description.trim().length === 0}
surAppui={() => {
enfilerBilan(queryClient, ot, { note: description.trim() }, {});
setJointe(true);
}}
/>
<Text style={{ color: t.encre3, fontFamily: 'Manrope_500Medium', fontSize: 11 }}>
🛡 Une fois jointe et lOT clôturé, cette description (anonymisée) rejoint le corpus de
lassistant comme les bilans déjà codés.
</Text>
<BoutonTel
libelle={
horsLigne
? 'Suggérer — réseau requis'
: suggerer.isPending
? 'Analyse…'
: '✨ Suggérer les codes'
}
variante="contour"
desactive={horsLigne || suggerer.isPending || description.trim().length < 10}
surAppui={() => {
setAppliquees(new Set());
suggerer.mutate(description.trim());
}}
/>
{suggerer.isError ? (
<Text style={{ color: t.danger, fontFamily: 'Manrope_600SemiBold', fontSize: 12 }}>
{suggerer.error.message}
</Text>
) : null}
{suggerer.isSuccess && suggestions.length === 0 ? (
<Text style={{ color: t.encre2, fontFamily: 'Manrope_500Medium', fontSize: 12 }}>
Aucun code assez proche lIA ne devine pas : choisissez dans les sélecteurs.
</Text>
) : null}
{suggestions.length > 0 ? (
<>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6 }}>
{suggestions.map((s) => {
const faite = appliquees.has(s.field);
return (
<Pressable
key={s.field}
accessibilityRole="button"
accessibilityLabel={`Appliquer ${BILAN_FIELD_LABELS[s.field]} : ${s.label}`}
disabled={faite}
onPress={() => {
surApplication(s);
setAppliquees((avant) => new Set([...avant, s.field]));
}}
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 5,
borderWidth: 1.5,
borderColor: t.primaire,
backgroundColor: faite ? t.primaire : t.primaireDoux,
borderRadius: 999,
paddingVertical: 6,
paddingHorizontal: 10,
}}
>
<Text
style={{
color: faite ? '#fff' : t.primaire,
fontFamily: 'Manrope_700Bold',
fontSize: 12,
}}
>
{faite ? '✓' : '✨'} {BILAN_FIELD_LABELS[s.field]} : {s.label}
</Text>
</Pressable>
);
})}
</View>
<Text style={{ color: t.encre3, fontFamily: 'Manrope_500Medium', fontSize: 11 }}>
Rien ne senregistre sans votre geste les chips pré-remplissent les sélecteurs
ci-dessous.
</Text>
</>
) : null}
</Carte>
);
}

View File

@@ -0,0 +1,94 @@
import { router } from 'expo-router';
import { FlatList, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useUsers } from '@/api/pilotage';
import { usePermissions } from '@/auth/use-permissions';
import { BoutonTel, EnteteFiche } from '@/composants/ui';
import { useTokens, type Tokens } from '@/theme/tokens';
const LABEL_STATUT: Record<'active' | 'invited' | 'disabled', string> = {
active: 'Actif',
invited: 'Invitation envoyée',
disabled: 'Désactivé',
};
/** Personnes & équipes — R6.4 (Pilotage). Consultation + « Inviter » (D4) —
* taux horaire, équipes, rôles restent gérés depuis le web pour l'instant. */
export default function PagePersonnes() {
const t = useTokens();
const { data: users } = useUsers();
const { can } = usePermissions();
const personnes = [...(users ?? [])].sort((a, b) => a.displayName.localeCompare(b.displayName));
return (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<View style={{ flex: 1, padding: 14, gap: 10 }}>
<EnteteFiche titre="Personnes" />
{can('PEOPLE_TEAMS', 'create') ? (
<BoutonTel libelle="+ Inviter" surAppui={() => router.push('/personnes/inviter')} />
) : null}
<FlatList
data={personnes}
keyExtractor={(u) => u.id}
contentContainerStyle={{ gap: 8, paddingBottom: 12 }}
ListEmptyComponent={
<Text style={{ color: t.encre3, fontFamily: 'Manrope_600SemiBold', padding: 16, textAlign: 'center' }}>
Personne accessible.
</Text>
}
renderItem={({ item: u }) => <LignePersonne utilisateur={u} t={t} />}
/>
</View>
</SafeAreaView>
);
}
function LignePersonne({
utilisateur: u,
t,
}: {
utilisateur: { id: string; displayName: string; email: string; role: { name: string }; teams: { name: string }[]; status: 'active' | 'invited' | 'disabled' };
t: Tokens;
}) {
const [enc, fond] =
u.status === 'active'
? [t.stTermine, t.stTermineFond]
: u.status === 'invited'
? [t.stAttente, t.stAttenteFond]
: [t.stAnnule, t.stAnnuleFond];
return (
<View
style={{
backgroundColor: t.surface,
borderColor: t.bordure,
borderWidth: 1,
borderRadius: 12,
padding: 12,
gap: 2,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
<Text style={{ flex: 1, fontFamily: 'Manrope_700Bold', fontSize: 14, color: t.encre }}>
{u.displayName}
</Text>
<Text
style={{
fontFamily: 'Manrope_700Bold',
fontSize: 10.5,
color: enc,
backgroundColor: fond,
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 999,
}}
>
{LABEL_STATUT[u.status]}
</Text>
</View>
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 12, color: t.encre2 }}>
{u.role.name}
{u.teams.length ? ` · ${u.teams.map((eq) => eq.name).join(', ')}` : ''}
</Text>
</View>
);
}

View File

@@ -0,0 +1,99 @@
import { router } from 'expo-router';
import { useState } from 'react';
import { ScrollView, Text, TextInput, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useInviteUser, useRoles } from '@/api/pilotage';
import { BoutonTel, ChoixTel, EnteteFiche } from '@/composants/ui';
import { useTokens } from '@/theme/tokens';
/** Invitation — R6.4. Jamais de mot de passe créé pour autrui (décision R1) :
* seul un lien d'activation valable 7 j est émis, à transmettre à la main. */
export default function PageInviter() {
const t = useTokens();
const { data: roles } = useRoles();
const invitation = useInviteUser();
const [displayName, setDisplayName] = useState('');
const [email, setEmail] = useState('');
const [roleId, setRoleId] = useState<string | null>(null);
const role = (roles ?? []).find((r) => r.id === roleId) ?? null;
const valide = displayName.trim().length >= 2 && /\S+@\S+\.\S+/.test(email) && !!roleId;
return (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<ScrollView contentContainerStyle={{ padding: 14, gap: 12 }}>
<EnteteFiche titre="Inviter" />
<View style={{ gap: 4 }}>
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 11, color: t.encre2 }}>
Nom complet <Text style={{ color: t.danger }}>*</Text>
</Text>
<TextInput
accessibilityLabel="Nom complet"
value={displayName}
onChangeText={setDisplayName}
style={{
borderWidth: 1.5,
borderColor: t.bordureForte,
borderRadius: 9,
paddingHorizontal: 10,
paddingVertical: 9,
color: t.encre,
fontFamily: 'Manrope_600SemiBold',
fontSize: 13,
}}
/>
</View>
<View style={{ gap: 4 }}>
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 11, color: t.encre2 }}>
E-mail <Text style={{ color: t.danger }}>*</Text>
</Text>
<TextInput
accessibilityLabel="E-mail"
autoCapitalize="none"
keyboardType="email-address"
value={email}
onChangeText={setEmail}
style={{
borderWidth: 1.5,
borderColor: t.bordureForte,
borderRadius: 9,
paddingHorizontal: 10,
paddingVertical: 9,
color: t.encre,
fontFamily: 'Manrope_600SemiBold',
fontSize: 13,
}}
/>
</View>
<ChoixTel
libelle="Rôle"
requis
valeur={role ? { id: role.id, label: role.name } : null}
options={(roles ?? []).map((r) => ({ id: r.id, label: r.name }))}
surChoix={setRoleId}
/>
{invitation.isError ? (
<Text style={{ color: t.danger, fontFamily: 'Manrope_600SemiBold', fontSize: 12.5 }}>
{invitation.error.message}
</Text>
) : null}
<BoutonTel
libelle="Émettre le lien d'activation"
desactive={!valide || invitation.isPending}
surAppui={() =>
invitation.mutate(
{ displayName: displayName.trim(), email: email.trim(), roleId: roleId! },
{
onSuccess: (r) =>
router.replace({
pathname: '/personnes/lien',
params: { token: r.activationToken, expiresAt: r.expiresAt },
}),
},
)
}
/>
</ScrollView>
</SafeAreaView>
);
}

View File

@@ -0,0 +1,43 @@
import { router, useLocalSearchParams } from 'expo-router';
import { ScrollView, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { WEB_URL } from '@/api/client';
import { BoutonTel, Carte, EnteteFiche } from '@/composants/ui';
import { useTokens } from '@/theme/tokens';
/** Lien d'activation émis — R6.4. Pas de presse-papiers natif sur mobile
* pour l'instant (`expo-clipboard` demanderait un nouveau build natif,
* même raisonnement que l'ouverture de document en Ressources) : le texte
* est sélectionnable (`Text selectable`, RN natif, aucune dépendance) —
* appui long → copier, comme partout sur le téléphone. */
export default function PageLienActivation() {
const t = useTokens();
const { token, expiresAt } = useLocalSearchParams<{ token: string; expiresAt: string }>();
const url = `${WEB_URL}/activation?token=${token}`;
return (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<ScrollView contentContainerStyle={{ padding: 14, gap: 12 }}>
<EnteteFiche titre="Lien d'activation émis" />
<Text style={{ fontFamily: 'Manrope_600SemiBold', fontSize: 13, color: t.encre2 }}>
Transmettez ce lien à la personne (valable jusqu'au{' '}
{new Intl.DateTimeFormat('fr-FR', { dateStyle: 'long' }).format(new Date(expiresAt))}) :
</Text>
<Carte>
<Text
selectable
style={{ fontFamily: 'Manrope_700Bold', fontSize: 13, color: t.primaire }}
>
{url}
</Text>
</Carte>
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 11.5, color: t.encre3 }}>
Appui long sur le lien pour le sélectionner et le copier.
</Text>
<View style={{ marginTop: 8 }}>
<BoutonTel libelle="Terminé" surAppui={() => router.replace('/personnes')} />
</View>
</ScrollView>
</SafeAreaView>
);
}

View File

@@ -0,0 +1,99 @@
import { router, useLocalSearchParams } from 'expo-router';
import { Pressable, ScrollView, Text } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { ASSET_STATUS_LABELS, type AssetStatus } from '@siop/shared';
import { useAssets, useLocations } from '@/api/exploitation';
import { Carte, EnteteFiche, LigneInfo } from '@/composants/ui';
import { useTokens, type Tokens } from '@/theme/tokens';
/** Fiche site — R6.2. Consultation seule (D4) : identité, zones, ascenseurs
* du site. Pas de carte interactive ni d'édition sur mobile pour l'instant
* (réservé au web) — même filtre appareils↔site que fiche-site.tsx (web) :
* par nom, la liste étant déjà plate côté serveur. */
export default function PageFicheSite() {
const t = useTokens();
const { id } = useLocalSearchParams<{ id: string }>();
const { data: locations } = useLocations();
const { data: assets } = useAssets();
const site = (locations ?? []).find((l) => l.id === id);
if (!site) return null;
const zones = (locations ?? []).filter((l) => l.parentId === site.id);
const appareils = (assets ?? []).filter((a) => a.siteName === site.name);
return (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<ScrollView contentContainerStyle={{ padding: 14, gap: 10 }}>
<EnteteFiche titre={site.name} />
<Carte titre="Identité">
<LigneInfo nom="Adresse" valeur={[site.address, site.city].filter(Boolean).join(' — ') || '—'} />
<LigneInfo nom="Gardien" valeur={site.guardianName ?? '—'} />
{site.guardianPhone ? <LigneInfo nom="Téléphone" valeur={site.guardianPhone} /> : null}
<LigneInfo nom="Client / syndic" valeur={site.partnerName ?? '—'} />
<LigneInfo nom="Appareils" valeur={String(site.assetCount)} />
</Carte>
{zones.length ? (
<Carte titre="Emplacements">
{zones.map((z) => (
<LigneInfo
key={z.id}
nom={z.name}
valeur={appareils.filter((a) => a.locationId === z.id).map((a) => a.reference).join(' · ') || '—'}
/>
))}
</Carte>
) : null}
<Carte titre={`Ascenseurs du site (${appareils.length})`}>
{appareils.map((a) => (
<Pressable
key={a.id}
accessibilityRole="button"
onPress={() => router.push(`/ascenseur/${a.id}`)}
style={{ flexDirection: 'row', alignItems: 'center', gap: 8, paddingVertical: 3 }}
>
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 12.5, color: t.primaire }}>
{a.reference}
</Text>
<Text
numberOfLines={1}
style={{ flex: 1, fontFamily: 'Manrope_400Regular', fontSize: 12.5, color: t.encre }}
>
{a.brand} {a.model ?? ''}
</Text>
<ChipStatutAppareil statut={a.status} t={t} />
</Pressable>
))}
{!appareils.length ? (
<Text style={{ color: t.encre3, fontFamily: 'Manrope_400Regular', fontSize: 12.5 }}>
Aucun appareil rattaché.
</Text>
) : null}
</Carte>
</ScrollView>
</SafeAreaView>
);
}
function ChipStatutAppareil({ statut, t }: { statut: AssetStatus; t: Tokens }) {
const enService = statut === 'IN_SERVICE';
return (
<Text
style={{
fontFamily: 'Manrope_700Bold',
fontSize: 10.5,
color: enService ? t.stTermine : t.stAttente,
backgroundColor: enService ? t.stTermineFond : t.stAttenteFond,
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 999,
overflow: 'hidden',
}}
>
{ASSET_STATUS_LABELS[statut]}
</Text>
);
}

View File

@@ -0,0 +1,69 @@
import { router } from 'expo-router';
import { FlatList, Pressable, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useLocations } from '@/api/exploitation';
import { EnteteFiche } from '@/composants/ui';
import { useTokens } from '@/theme/tokens';
/** Sites — R6.2 (Parc). Liste plate → sites de premier niveau seulement
* (une zone n'a jamais de fiche propre, elle suit son site — même règle
* que le web, sites.tsx). */
export default function PageSites() {
const t = useTokens();
const { data: locations } = useLocations();
const sites = (locations ?? []).filter((l) => l.parentId === null);
return (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<View style={{ flex: 1, padding: 14, gap: 10 }}>
<EnteteFiche titre="Sites" />
<FlatList
data={sites}
keyExtractor={(s) => s.id}
contentContainerStyle={{ gap: 8, paddingBottom: 12 }}
ListEmptyComponent={
<Text style={{ color: t.encre3, fontFamily: 'Manrope_600SemiBold', padding: 16, textAlign: 'center' }}>
Aucun site accessible.
</Text>
}
renderItem={({ item: s }) => (
<Pressable
accessibilityRole="button"
onPress={() => router.push(`/sites/${s.id}`)}
style={{
backgroundColor: t.surface,
borderColor: t.bordure,
borderWidth: 1,
borderRadius: 12,
padding: 12,
gap: 2,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
<Text style={{ flex: 1, fontFamily: 'Manrope_700Bold', fontSize: 14, color: t.encre }}>
{s.name}
</Text>
<Text
style={{
fontFamily: 'Manrope_700Bold',
fontSize: 10.5,
color: t.encre2,
backgroundColor: t.surface2,
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 999,
}}
>
{s.assetCount} appareil{s.assetCount > 1 ? 's' : ''}
</Text>
</View>
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 12, color: t.encre2 }}>
{[s.address, s.city].filter(Boolean).join(' — ') || '—'}
</Text>
</Pressable>
)}
/>
</View>
</SafeAreaView>
);
}

View File

@@ -0,0 +1,130 @@
import { useState } from 'react';
import { Pressable, ScrollView, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useAnalyticsSummary } from '@/api/pilotage';
import { Carte, EnteteFiche, LigneInfo } from '@/composants/ui';
import { useTokens } from '@/theme/tokens';
const fmtMAD = new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'MAD', maximumFractionDigits: 0 });
const PERIODES = [3, 6, 12] as const;
/** Statistiques — R6.4 (Pilotage). Maquette écran 7 : les mêmes chiffres
* que `/analytics/summary` (web), en cartes plutôt qu'en graphes denses
* (D4) — coût du mois, taux préventif, pannes par organe. */
export default function PageStatistiques() {
const t = useTokens();
const [periode, setPeriode] = useState<(typeof PERIODES)[number]>(12);
const { data } = useAnalyticsSummary(periode);
return (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<ScrollView contentContainerStyle={{ padding: 14, gap: 10 }}>
<EnteteFiche titre="Statistiques" />
<View style={{ flexDirection: 'row', gap: 8 }}>
{PERIODES.map((p) => (
<Pressable
key={p}
accessibilityRole="button"
onPress={() => setPeriode(p)}
style={{
flex: 1,
paddingVertical: 8,
borderRadius: 8,
alignItems: 'center',
backgroundColor: periode === p ? t.primaire : t.surface,
borderWidth: 1,
borderColor: periode === p ? t.primaire : t.bordure,
}}
>
<Text
style={{
fontFamily: 'Manrope_700Bold',
fontSize: 12.5,
color: periode === p ? '#fff' : t.encre2,
}}
>
{p} mois
</Text>
</Pressable>
))}
</View>
{data ? (
<>
<View style={{ flexDirection: 'row', gap: 8 }}>
<View
style={{
flex: 1,
backgroundColor: t.surface,
borderColor: t.bordure,
borderWidth: 1,
borderRadius: 10,
padding: 10,
}}
>
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 17, color: t.primaire }}>
{fmtMAD.format(data.monthCost)}
</Text>
<Text style={{ fontFamily: 'Manrope_600SemiBold', fontSize: 10.5, color: t.encre2 }}>
Coût du mois
</Text>
</View>
<View
style={{
flex: 1,
backgroundColor: t.surface,
borderColor: t.bordure,
borderWidth: 1,
borderRadius: 10,
padding: 10,
}}
>
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 17, color: t.encre }}>
{data.preventiveRate != null ? `${Math.round(data.preventiveRate * 100)} %` : '—'}
</Text>
<Text style={{ fontFamily: 'Manrope_600SemiBold', fontSize: 10.5, color: t.encre2 }}>
Taux préventif
</Text>
</View>
</View>
<Carte titre={`OT clôturés · ${data.months} mois`}>
<LigneInfo nom="Total" valeur={String(data.closed.total)} />
<LigneInfo nom="Dont préventif" valeur={String(data.closed.preventive)} />
<LigneInfo
nom="Délai moyen"
valeur={data.avgResolutionDays != null ? `${data.avgResolutionDays.toFixed(1)} j` : '—'}
/>
</Carte>
<Carte titre={`Pannes par organe · ${data.months} mois`}>
{data.failuresByComponent.slice(0, 5).map((f) => (
<LigneInfo key={f.label} nom={f.label} valeur={String(f.count)} />
))}
{!data.failuresByComponent.length ? (
<Text style={{ color: t.encre3, fontFamily: 'Manrope_400Regular', fontSize: 12.5 }}>
Aucune panne codée sur la période.
</Text>
) : null}
</Carte>
<Carte titre="Top équipements en coût">
{data.topAssets.slice(0, 5).map((a) => (
<LigneInfo
key={a.reference}
nom={`Asc. ${a.reference}${a.siteName}`}
valeur={fmtMAD.format(a.total)}
/>
))}
{!data.topAssets.length ? (
<Text style={{ color: t.encre3, fontFamily: 'Manrope_400Regular', fontSize: 12.5 }}>
Aucune donnée sur la période.
</Text>
) : null}
</Carte>
</>
) : null}
</ScrollView>
</SafeAreaView>
);
}

View File

@@ -0,0 +1,65 @@
import { router, useLocalSearchParams } from 'expo-router';
import { ScrollView, Text } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { usePart } from '@/api/ressources';
import { usePermissions } from '@/auth/use-permissions';
import { BoutonTel, Carte, EnteteFiche, LigneInfo } from '@/composants/ui';
import { useTokens } from '@/theme/tokens';
const fmt = new Intl.NumberFormat('fr-FR');
const fmtMAD = new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'MAD' });
/** Fiche pièce — R6.3. Consultation + une action courante (D4) : commander,
* pré-rempli depuis cette fiche, comme la maquette (écran 6). Les
* mouvements de stock (entrée manuelle, ajustement) restent au web. */
export default function PageFichePiece() {
const t = useTokens();
const { id } = useLocalSearchParams<{ id: string }>();
const { data: piece } = usePart(id);
const { can } = usePermissions();
if (!piece) return null;
const peutCommander = can('PURCHASE_ORDERS', 'create') && !!piece.supplierId;
return (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<ScrollView contentContainerStyle={{ padding: 14, gap: 10 }}>
<EnteteFiche titre={piece.designation} />
<Carte titre="Identité">
<LigneInfo nom="Référence" valeur={piece.reference} />
<LigneInfo
nom="Stock"
valeur={
<Text
style={{
color: piece.belowThreshold ? t.danger : t.encre,
fontFamily: 'Manrope_700Bold',
fontSize: 12.5,
}}
>
{fmt.format(piece.stock)}
</Text>
}
/>
<LigneInfo nom="Seuil d'alerte" valeur={fmt.format(piece.threshold)} />
<LigneInfo nom="Fournisseur" valeur={piece.supplierName ?? '—'} />
<LigneInfo
nom="Dernier prix"
valeur={piece.lastUnitPrice != null ? fmtMAD.format(piece.lastUnitPrice) : '—'}
/>
{piece.compatible ? <LigneInfo nom="Compatible" valeur={piece.compatible} /> : null}
</Carte>
{peutCommander ? (
<BoutonTel libelle="Commander" surAppui={() => router.push(`/stock/${piece.id}/commander`)} />
) : null}
{!piece.supplierId && can('PURCHASE_ORDERS', 'create') ? (
<Text style={{ color: t.encre3, fontFamily: 'Manrope_400Regular', fontSize: 12, textAlign: 'center' }}>
Aucun fournisseur associé commande depuis le web.
</Text>
) : null}
</ScrollView>
</SafeAreaView>
);
}

View File

@@ -0,0 +1,130 @@
import { router, useLocalSearchParams } from 'expo-router';
import { useState } from 'react';
import { ScrollView, Text, TextInput, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import type { PurchaseOrderDto } from '@siop/shared';
import { useCreatePurchaseOrder, usePart } from '@/api/ressources';
import { BoutonTel, Carte, EnteteFiche, LigneInfo } from '@/composants/ui';
import { useTokens } from '@/theme/tokens';
const fmtMAD = new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'MAD' });
/** Nouveau BC pré-rempli depuis l'alerte stock — maquette écran 6. Une
* seule ligne (cette pièce) : le bon de commande multi-lignes détaillé
* reste au web (D4). Confirmation explicite après création (trouvé en
* recette 02/08 : la navigation instantanée en cas de succès ne donnait
* aucun retour visible — deux BC créés côté serveur sans que le référent
* s'en aperçoive dans l'app). */
export default function PageCommander() {
const t = useTokens();
const { id } = useLocalSearchParams<{ id: string }>();
const { data: piece } = usePart(id);
const creation = useCreatePurchaseOrder();
const manquant = piece ? Math.max(piece.threshold - piece.stock, 1) : 1;
const [quantite, setQuantite] = useState(String(manquant));
const [prix, setPrix] = useState('');
const [cree, setCree] = useState<PurchaseOrderDto | null>(null);
if (!piece || !piece.supplierId) return null;
if (cree) {
return (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<ScrollView contentContainerStyle={{ padding: 14, gap: 12 }}>
<EnteteFiche titre="BC créé" />
<Carte titre="✓ Bon de commande enregistré">
<LigneInfo nom="Référence" valeur={cree.reference} />
<LigneInfo nom="Fournisseur" valeur={cree.supplierName} />
<LigneInfo nom="Total" valeur={fmtMAD.format(cree.total)} />
<LigneInfo nom="Statut" valeur="Brouillon" />
</Carte>
<Text style={{ color: t.encre3, fontFamily: 'Manrope_400Regular', fontSize: 11.5, textAlign: 'center' }}>
L'envoi au fournisseur se fait depuis le web pour l'instant.
</Text>
<BoutonTel
libelle="Terminé"
surAppui={() => (router.canGoBack() ? router.back() : router.replace('/stock'))}
/>
</ScrollView>
</SafeAreaView>
);
}
const prixDefaut = piece.lastUnitPrice != null ? String(piece.lastUnitPrice) : '';
const prixSaisi = prix || prixDefaut;
const qte = Number(quantite);
const pu = Number(prixSaisi);
const valide = qte > 0 && pu >= 0 && Number.isFinite(qte) && Number.isFinite(pu);
return (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<ScrollView contentContainerStyle={{ padding: 14, gap: 10 }}>
<EnteteFiche titre="Nouveau BC" />
<Carte titre="Ligne">
<LigneInfo nom="Fournisseur" valeur={piece.supplierName ?? '—'} />
<LigneInfo nom="Pièce" valeur={`${piece.designation} (${piece.reference})`} />
</Carte>
<View style={{ gap: 4 }}>
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 11, color: t.encre2 }}>Quantité</Text>
<TextInput
accessibilityLabel="Quantité"
keyboardType="numeric"
value={quantite}
onChangeText={setQuantite}
style={{
borderWidth: 1.5,
borderColor: t.bordureForte,
borderRadius: 9,
paddingHorizontal: 10,
paddingVertical: 9,
color: t.encre,
fontFamily: 'Manrope_600SemiBold',
fontSize: 13,
}}
/>
</View>
<View style={{ gap: 4 }}>
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 11, color: t.encre2 }}>
Prix unitaire (MAD)
</Text>
<TextInput
accessibilityLabel="Prix unitaire"
keyboardType="numeric"
placeholder={prixDefaut || '0'}
placeholderTextColor={t.encre3}
value={prix}
onChangeText={setPrix}
style={{
borderWidth: 1.5,
borderColor: t.bordureForte,
borderRadius: 9,
paddingHorizontal: 10,
paddingVertical: 9,
color: t.encre,
fontFamily: 'Manrope_600SemiBold',
fontSize: 13,
}}
/>
</View>
{creation.isError ? (
<Text style={{ color: t.danger, fontFamily: 'Manrope_600SemiBold', fontSize: 12.5 }}>
{creation.error.message}
</Text>
) : null}
<BoutonTel
libelle="Créer le BC"
desactive={!valide || creation.isPending}
surAppui={() =>
creation.mutate(
{
supplierId: piece.supplierId!,
lines: [{ partId: piece.id, quantity: qte, unitPrice: pu }],
},
{ onSuccess: (bc) => setCree(bc) },
)
}
/>
</ScrollView>
</SafeAreaView>
);
}

View File

@@ -0,0 +1,90 @@
import { router } from 'expo-router';
import { FlatList, Pressable, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useParts } from '@/api/ressources';
import { EnteteFiche } from '@/composants/ui';
import { useTokens } from '@/theme/tokens';
const fmt = new Intl.NumberFormat('fr-FR');
/** Stock — R6.3 (Ressources). Sous-seuil en tête, comme le web (stock.tsx) —
* ce qui demande une action passe avant le reste. */
export default function PageStock() {
const t = useTokens();
const { data: parts } = useParts();
const pieces = [...(parts ?? [])].sort((a, b) => {
if (a.belowThreshold !== b.belowThreshold) return a.belowThreshold ? -1 : 1;
return a.designation.localeCompare(b.designation);
});
const sousSeuil = pieces.filter((p) => p.belowThreshold).length;
return (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<View style={{ flex: 1, padding: 14, gap: 10 }}>
<EnteteFiche
titre="Stock & achats"
apres={
sousSeuil ? (
<Text
style={{
fontFamily: 'Manrope_800ExtraBold',
fontSize: 10.5,
color: t.danger,
backgroundColor: t.prioBloqueFond,
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 999,
}}
>
{sousSeuil} sous seuil
</Text>
) : undefined
}
/>
<FlatList
data={pieces}
keyExtractor={(p) => p.id}
contentContainerStyle={{ gap: 8, paddingBottom: 12 }}
ListEmptyComponent={
<Text style={{ color: t.encre3, fontFamily: 'Manrope_600SemiBold', padding: 16, textAlign: 'center' }}>
Aucune pièce accessible.
</Text>
}
renderItem={({ item: p }) => (
<Pressable
accessibilityRole="button"
onPress={() => router.push(`/stock/${p.id}`)}
style={{
backgroundColor: t.surface,
borderColor: p.belowThreshold ? t.danger : t.bordure,
borderWidth: p.belowThreshold ? 1.5 : 1,
borderRadius: 12,
padding: 12,
gap: 2,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
<Text style={{ flex: 1, fontFamily: 'Manrope_700Bold', fontSize: 14, color: t.encre }}>
{p.designation}
</Text>
<Text
style={{
fontFamily: 'Manrope_800ExtraBold',
fontSize: 13,
color: p.belowThreshold ? t.danger : t.encre,
}}
>
{fmt.format(p.stock)}
</Text>
</View>
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 12, color: t.encre2 }}>
{p.reference} · seuil {fmt.format(p.threshold)}
{p.supplierName ? ` · ${p.supplierName}` : ''}
</Text>
</Pressable>
)}
/>
</View>
</SafeAreaView>
);
}

View File

@@ -0,0 +1,48 @@
import { useLocalSearchParams } from 'expo-router';
import { ScrollView, Text } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { usePartners } from '@/api/ressources';
import { Carte, EnteteFiche, LigneInfo } from '@/composants/ui';
import { useTokens } from '@/theme/tokens';
/** Fiche tiers — R6.3, ajoutée en recette (02/08). Consultation seule (D4) :
* identité, contact, activité (BC en cours pour un fournisseur, sites
* rattachés pour un client/syndic) — déjà connu de la liste, pas de
* requête supplémentaire. Création/édition restent au web. */
export default function PageFicheTiers() {
const t = useTokens();
const { id } = useLocalSearchParams<{ id: string }>();
const { data: partners } = usePartners();
const p = (partners ?? []).find((x) => x.id === id);
if (!p) return null;
return (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<ScrollView contentContainerStyle={{ padding: 14, gap: 10 }}>
<EnteteFiche titre={p.name} />
<Carte titre="Identité">
<LigneInfo nom="Type" valeur={p.kind === 'SUPPLIER' ? 'Fournisseur' : 'Client'} />
<LigneInfo nom="Statut" valeur={p.isActive ? 'Actif' : 'Désactivé'} />
<LigneInfo nom="Contact" valeur={p.contactName ?? '—'} />
<LigneInfo nom="Téléphone" valeur={p.phone ?? '—'} />
<LigneInfo nom="E-mail" valeur={p.email ?? '—'} />
<LigneInfo nom="Ville" valeur={p.city ?? '—'} />
</Carte>
{p.kind === 'SUPPLIER' ? (
<Carte titre="Achats">
<LigneInfo nom="BC en cours" valeur={String(p.openOrders)} />
</Carte>
) : null}
{p.siteNames.length ? (
<Carte titre="Sites rattachés">
{p.siteNames.map((s) => (
<Text key={s} style={{ fontFamily: 'Manrope_600SemiBold', fontSize: 12.5, color: t.encre }}>
{s}
</Text>
))}
</Carte>
) : null}
</ScrollView>
</SafeAreaView>
);
}

View File

@@ -0,0 +1,72 @@
import { router } from 'expo-router';
import { FlatList, Pressable, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { usePartners } from '@/api/ressources';
import { EnteteFiche } from '@/composants/ui';
import { useTokens } from '@/theme/tokens';
/** Tiers — R6.3 (Ressources). Consultation seule sur mobile (D4) : la
* création/édition de fournisseurs et clients reste au web pour l'instant.
* Fiche détail ajoutée en recette (02/08) : le tap sans réaction se lisait
* comme un écran cassé plutôt que délibérément simple. */
export default function PageTiers() {
const t = useTokens();
const { data: partners } = usePartners();
const tiers = [...(partners ?? [])].sort((a, b) => a.name.localeCompare(b.name));
return (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<View style={{ flex: 1, padding: 14, gap: 10 }}>
<EnteteFiche titre="Tiers" />
<FlatList
data={tiers}
keyExtractor={(p) => p.id}
contentContainerStyle={{ gap: 8, paddingBottom: 12 }}
ListEmptyComponent={
<Text style={{ color: t.encre3, fontFamily: 'Manrope_600SemiBold', padding: 16, textAlign: 'center' }}>
Aucun tiers accessible.
</Text>
}
renderItem={({ item: p }) => (
<Pressable
accessibilityRole="button"
onPress={() => router.push(`/tiers/${p.id}`)}
style={{
backgroundColor: t.surface,
borderColor: t.bordure,
borderWidth: 1,
borderRadius: 12,
padding: 12,
gap: 2,
opacity: p.isActive ? 1 : 0.55,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
<Text style={{ flex: 1, fontFamily: 'Manrope_700Bold', fontSize: 14, color: t.encre }}>
{p.name}
</Text>
<Text
style={{
fontFamily: 'Manrope_700Bold',
fontSize: 10.5,
color: p.kind === 'SUPPLIER' ? t.stOuvert : t.stEncours,
backgroundColor: p.kind === 'SUPPLIER' ? t.stOuvertFond : t.stEncoursFond,
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 999,
}}
>
{p.kind === 'SUPPLIER' ? 'Fournisseur' : 'Client'}
</Text>
</View>
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 12, color: t.encre2 }}>
{[p.contactName, p.phone, p.city].filter(Boolean).join(' · ') || '—'}
{p.kind === 'SUPPLIER' && p.openOrders ? ` · ${p.openOrders} BC en cours` : ''}
</Text>
</Pressable>
)}
/>
</View>
</SafeAreaView>
);
}

View File

@@ -11,8 +11,10 @@
"@tanstack/react-query": "^5.101.2", "@tanstack/react-query": "^5.101.2",
"@tanstack/react-query-persist-client": "^5.101.2", "@tanstack/react-query-persist-client": "^5.101.2",
"expo": "~57.0.6", "expo": "~57.0.6",
"expo-audio": "~57.0.2",
"expo-camera": "~57.0.3", "expo-camera": "~57.0.3",
"expo-constants": "~57.0.5", "expo-constants": "~57.0.5",
"expo-file-system": "~57.0.1",
"expo-font": "~57.0.1", "expo-font": "~57.0.1",
"expo-image-manipulator": "~57.0.4", "expo-image-manipulator": "~57.0.4",
"expo-image-picker": "~57.0.4", "expo-image-picker": "~57.0.4",
@@ -39,8 +41,8 @@
}, },
"scripts": { "scripts": {
"start": "expo start", "start": "expo start",
"android": "expo start --android", "android": "expo run:android",
"ios": "expo start --ios", "ios": "expo run:ios",
"web": "expo start --web", "web": "expo start --web",
"typecheck": "tsc -p tsconfig.json --noEmit", "typecheck": "tsc -p tsconfig.json --noEmit",
"test": "jest", "test": "jest",

View File

@@ -0,0 +1,26 @@
import { useMutation } from '@tanstack/react-query';
import type { AssistantAsk } from '@siop/shared';
import { api } from './client';
/** Hook R6.5 — l'assistant passe par l'API NestJS (le service IA n'est
* jamais appelé depuis l'app, ADR-004 §4). Le 503 est un état ATTENDU du
* contrat (service éteint ou pas encore déployé) — même message que le web. */
async function unwrap<T>(res: { data?: T; error?: unknown; response: Response }): Promise<T> {
if (res.response.status === 503) {
throw new Error('Assistant indisponible pour le moment — réessayez dans un instant.');
}
if (res.error || res.data === undefined) {
const message =
(res.error as { message?: string } | undefined)?.message ??
`Le serveur a répondu ${res.response.status}`;
throw new Error(Array.isArray(message) ? message.join(' — ') : message);
}
return res.data;
}
export function useAskAssistant() {
return useMutation({
mutationFn: async (body: AssistantAsk) => unwrap(await api.POST('/assistant/ask', { body })),
});
}

View File

@@ -7,6 +7,11 @@ import { lireJeton } from './jeton';
* EXPO_PUBLIC_API_URL pointe l'API (IP LAN pour Expo Go sur téléphone). */ * EXPO_PUBLIC_API_URL pointe l'API (IP LAN pour Expo Go sur téléphone). */
export const API_URL = process.env.EXPO_PUBLIC_API_URL ?? 'http://localhost:3000'; export const API_URL = process.env.EXPO_PUBLIC_API_URL ?? 'http://localhost:3000';
/** Origine du web (R6.4 — lien d'activation « Personnes », même page
* `/activation?token=…` que le web, il n'existe pas d'équivalent mobile).
* À définir par environnement comme EXPO_PUBLIC_API_URL. */
export const WEB_URL = process.env.EXPO_PUBLIC_WEB_URL ?? 'http://localhost:5173';
export const api = createClient<paths>({ baseUrl: API_URL }); export const api = createClient<paths>({ baseUrl: API_URL });
api.use({ api.use({

View File

@@ -1,6 +1,14 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { ChecklistState, ReportUpsert, WorkOrderStatus } from '@siop/shared'; import type {
import { api, unwrap } from './client'; ChecklistState,
ReportUpsert,
RequestApprove,
RequestCreate,
RequestReject,
WorkOrderStatus,
} from '@siop/shared';
import { api, API_URL, unwrap } from './client';
import { lireJeton } from './jeton';
/** Hooks R4.2 — mêmes opérations que le web (contrat unique). Les écritures /** Hooks R4.2 — mêmes opérations que le web (contrat unique). Les écritures
* restent EN LIGNE dans cette release ; la mise en file arrive en R4.3 (D1). */ * restent EN LIGNE dans cette release ; la mise en file arrive en R4.3 (D1). */
@@ -20,9 +28,17 @@ export function useWorkOrder(id: string) {
}); });
} }
export function useAssets() { export function useLocations() {
return useQuery({
queryKey: ['locations'],
queryFn: async () => (await unwrap(await api.GET('/locations'))).locations,
});
}
export function useAssets(opts?: { enabled?: boolean }) {
return useQuery({ return useQuery({
queryKey: ['assets'], queryKey: ['assets'],
enabled: opts?.enabled ?? true,
queryFn: async () => (await unwrap(await api.GET('/assets'))).assets, queryFn: async () => (await unwrap(await api.GET('/assets'))).assets,
}); });
} }
@@ -46,14 +62,70 @@ export function useDocumentsOT(workOrderId: string) {
}); });
} }
export function useReferenceValues() { export function useReferenceValues(opts?: { enabled?: boolean }) {
return useQuery({ return useQuery({
queryKey: ['reference-values'], queryKey: ['reference-values'],
enabled: opts?.enabled ?? true,
staleTime: 3600_000, // référentiels administrables : stables en journée staleTime: 3600_000, // référentiels administrables : stables en journée
queryFn: async () => (await unwrap(await api.GET('/reference-values'))).referenceValues, queryFn: async () => (await unwrap(await api.GET('/reference-values'))).referenceValues,
}); });
} }
// ————— Demandes (mobile ouvert à tous les rôles) —————
// Même endpoint pour tous : l'API scope déjà « les siennes » (Demandeur) ou
// « toutes » (viewOther, ADR-003) — rien à filtrer côté app.
export function useRequests() {
return useQuery({
queryKey: ['requests'],
queryFn: async () => (await unwrap(await api.GET('/requests'))).requests,
});
}
/** Options minimales (signalement) — accessibles à tout rôle authentifié,
* y compris Demandeur qui n'a pas ASSETS.view (même trou corrigé qu'au web). */
export function useAssetOptions() {
return useQuery({
queryKey: ['asset-options'],
queryFn: async () => (await unwrap(await api.GET('/assets/options'))).options,
});
}
function useInvalideRequests() {
const queryClient = useQueryClient();
return () => queryClient.invalidateQueries({ queryKey: ['requests'] });
}
export function useCreateRequest() {
const invalide = useInvalideRequests();
return useMutation({
mutationFn: async (body: RequestCreate) => unwrap(await api.POST('/requests', { body })),
onSuccess: invalide,
});
}
export function useApproveRequest() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, ...body }: RequestApprove & { id: string }) =>
unwrap(await api.POST('/requests/{id}/approve', { params: { path: { id } }, body })),
onSuccess: () =>
Promise.all([
queryClient.invalidateQueries({ queryKey: ['requests'] }),
queryClient.invalidateQueries({ queryKey: ['work-orders'] }),
]),
});
}
export function useRejectRequest() {
const invalide = useInvalideRequests();
return useMutation({
mutationFn: async ({ id, ...body }: RequestReject & { id: string }) =>
unwrap(await api.POST('/requests/{id}/reject', { params: { path: { id } }, body })),
onSuccess: invalide,
});
}
function useInvalideOT(id: string) { function useInvalideOT(id: string) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return () => return () =>
@@ -91,6 +163,42 @@ export function useCocheChecklist(otId: string) {
}); });
} }
/** Suggestion R5 (D1) : l'IA est un service SERVEUR — la suggestion demande le
* réseau, la clôture en file R4 fonctionne sans elle. */
export function useSuggestionBilan() {
return useMutation({
mutationFn: async (description: string) =>
unwrap(await api.POST('/assistant/suggest-bilan', { body: { description } })),
});
}
/** Dictée (R5 D5, opt-in) : multipart hors client typé, même raison que la
* photo (D5) — le fichier natif {uri, name, type} ne passe pas par `api.*`.
* L'audio ne transite qu'une fois ; rien n'est stocké côté app après. */
export function useTranscription() {
return useMutation({
mutationFn: async (fichier: { uri: string; nom: string; mime: string }) => {
const form = new FormData();
form.append('file', {
uri: fichier.uri,
name: fichier.nom,
type: fichier.mime,
} as unknown as Blob);
const jeton = await lireJeton();
const res = await fetch(`${API_URL}/assistant/transcribe`, {
method: 'POST',
headers: jeton ? { Authorization: `Bearer ${jeton}` } : undefined,
body: form,
});
if (!res.ok) {
const corps = (await res.json().catch(() => null)) as { message?: string } | null;
throw new Error(corps?.message ?? `Dictée indisponible (${res.status})`);
}
return (await res.json()) as { text: string };
},
});
}
export function useBilan(otId: string) { export function useBilan(otId: string) {
const invalide = useInvalideOT(otId); const invalide = useInvalideOT(otId);
return useMutation({ return useMutation({

View File

@@ -0,0 +1,39 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { InvitationCreate } from '@siop/shared';
import { api, unwrap } from './client';
/** Hooks R6.4 — Pilotage (Statistiques, Personnes & équipes) : mêmes
* opérations que le web (gestion.ts/referentiel.ts), lecture + une action
* courante (inviter) sur mobile — pas la gestion complète des rôles/
* équipes/taux horaires, réservée au web (D4). */
export function useAnalyticsSummary(months: 3 | 6 | 12 = 12) {
return useQuery({
queryKey: ['analytics', months],
queryFn: async () =>
unwrap(await api.GET('/analytics/summary', { params: { query: { months: String(months) } } })),
});
}
export function useUsers() {
return useQuery({
queryKey: ['users'],
queryFn: async () => (await unwrap(await api.GET('/users'))).users,
});
}
export function useRoles() {
return useQuery({
queryKey: ['roles'],
queryFn: async () => (await unwrap(await api.GET('/roles'))).roles,
});
}
export function useInviteUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (body: InvitationCreate) =>
unwrap(await api.POST('/users/invitations', { body })),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }),
});
}

View File

@@ -0,0 +1,47 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { PurchaseOrderCreate } from '@siop/shared';
import { api, unwrap } from './client';
/** Hooks R6.3 — Ressources (Stock, Tiers, Fichiers) : mêmes opérations que
* le web (gestion.ts), périmètre mobile plus étroit (D4 — consultation +
* action courante « commander », pas les flux de gestion les plus denses :
* pas de création/édition de pièce ou de tiers sur mobile pour l'instant). */
export function usePartners() {
return useQuery({
queryKey: ['partners'],
queryFn: async () => (await unwrap(await api.GET('/partners'))).partners,
});
}
export function useParts() {
return useQuery({
queryKey: ['parts'],
queryFn: async () => (await unwrap(await api.GET('/parts'))).parts,
});
}
export function usePart(id: string) {
return useQuery({
queryKey: ['parts', id],
queryFn: async () => unwrap(await api.GET('/parts/{id}', { params: { path: { id } } })),
});
}
export function useCreatePurchaseOrder() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (body: PurchaseOrderCreate) =>
unwrap(await api.POST('/purchase-orders', { body })),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['purchase-orders'] }),
});
}
/** Bibliothèque complète (sans filtre appareil/OT — voir aussi
* `useDocumentsOT` dans exploitation.ts, scopée à un OT). */
export function useDocuments() {
return useQuery({
queryKey: ['documents'],
queryFn: async () => (await unwrap(await api.GET('/documents'))).documents,
});
}

View File

@@ -407,6 +407,23 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/documents/{id}/corpus": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
/** Inclure/exclure du corpus IA (D3 — réversible, effectif à la prochaine réindexation) */
patch: operations["updateDocumentCorpus"];
trace?: never;
};
"/documents/{id}": { "/documents/{id}": {
parameters: { parameters: {
query?: never; query?: never;
@@ -441,6 +458,74 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/assistant/ask": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Assistant R5 — sourcé ou silencieux : extraits cités ou refus honnête (D2) */
post: operations["askAssistant"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/assistant/suggest-bilan": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Suggérer des codes de bilan depuis une description libre (D1 — lhumain valide) */
post: operations["suggestBilan"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/assistant/reindex": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Réindexer le corpus (bibliothèque PDF + bilans clôturés, anonymisés à lingestion — D4) */
post: operations["reindexAssistant"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/assistant/transcribe": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Dictée (R5 D5, opt-in) — laudio est transcrit puis JAMAIS conservé, à relire avant tout usage */
post: operations["transcribeAudio"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/search": { "/search": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1331,6 +1416,11 @@ export interface components {
status: "active" | "invited" | "disabled"; status: "active" | "invited" | "disabled";
isDemo: boolean; isDemo: boolean;
hourlyRate: number | null; hourlyRate: number | null;
assignedSites: {
/** Format: uuid */
id: string;
name: string;
}[];
}[]; }[];
}; };
RolesResponse: { RolesResponse: {
@@ -1356,6 +1446,7 @@ export interface components {
roleId: string; roleId: string;
teamIds?: string[]; teamIds?: string[];
phone?: string; phone?: string;
locationIds?: string[];
}; };
UserAdmin: { UserAdmin: {
/** Format: uuid */ /** Format: uuid */
@@ -1379,6 +1470,11 @@ export interface components {
status: "active" | "invited" | "disabled"; status: "active" | "invited" | "disabled";
isDemo: boolean; isDemo: boolean;
hourlyRate: number | null; hourlyRate: number | null;
assignedSites: {
/** Format: uuid */
id: string;
name: string;
}[];
}; };
UserUpdate: { UserUpdate: {
displayName?: string; displayName?: string;
@@ -1388,6 +1484,7 @@ export interface components {
teamIds?: string[]; teamIds?: string[];
isActive?: boolean; isActive?: boolean;
hourlyRate?: number | null; hourlyRate?: number | null;
locationIds?: string[];
}; };
DocumentsResponse: { DocumentsResponse: {
documents: { documents: {
@@ -1403,6 +1500,9 @@ export interface components {
uploadedByName: string | null; uploadedByName: string | null;
/** Format: date-time */ /** Format: date-time */
createdAt: string; createdAt: string;
inCorpus: boolean;
indexedAt: string | null;
chunkCount: number;
}[]; }[];
}; };
Document: { Document: {
@@ -1418,6 +1518,12 @@ export interface components {
uploadedByName: string | null; uploadedByName: string | null;
/** Format: date-time */ /** Format: date-time */
createdAt: string; createdAt: string;
inCorpus: boolean;
indexedAt: string | null;
chunkCount: number;
};
DocumentCorpusUpdate: {
inCorpus: boolean;
}; };
AnalyticsSummary: { AnalyticsSummary: {
months: number; months: number;
@@ -1446,6 +1552,53 @@ export interface components {
total: number; total: number;
}[]; }[];
}; };
AssistantAnswer: {
/** @enum {string} */
mode: "EXTRACTIVE" | "GENERATED" | "REFUSAL";
answer: string | null;
excerpts: {
/** @enum {string} */
sourceType: "DOCUMENT" | "WORK_ORDER";
documentId: string | null;
workOrderId: string | null;
title: string;
locator: string;
content: string;
score: number;
}[];
corpus: {
documents: number;
reports: number;
};
};
AssistantAsk: {
question: string;
};
BilanSuggestions: {
suggestions: {
/** @enum {string} */
field: "DOOR_STATE" | "CABIN_POSITION" | "ANOMALY" | "EXTERNAL_CAUSE" | "ACTION_TAKEN" | "COMPONENT_CONCERNED";
/** Format: uuid */
valueId: string;
label: string;
/** @enum {string} */
confidence: "HIGH" | "MEDIUM";
similarReports: number;
score: number;
}[];
};
SuggestBilan: {
description: string;
};
ReindexResult: {
documentsIndexed: number;
documentsSkipped: number;
reportsIndexed: number;
chunks: number;
};
TranscriptionResult: {
text: string;
};
SearchResponse: { SearchResponse: {
workOrders: { workOrders: {
/** Format: uuid */ /** Format: uuid */
@@ -2918,6 +3071,39 @@ export interface operations {
}; };
}; };
}; };
updateDocumentCorpus: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["DocumentCorpusUpdate"];
};
};
responses: {
/** @description Document mis à jour */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Document"];
};
};
/** @description Inconnu */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
deleteDocument: { deleteDocument: {
parameters: { parameters: {
query?: never; query?: never;
@@ -2967,6 +3153,129 @@ export interface operations {
}; };
}; };
}; };
askAssistant: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["AssistantAsk"];
};
};
responses: {
/** @description Réponse sourcée ou refus */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AssistantAnswer"];
};
};
/** @description Service IA indisponible */
503: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
suggestBilan: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SuggestBilan"];
};
};
responses: {
/** @description Suggestions (codes existants seulement) */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["BilanSuggestions"];
};
};
/** @description Service IA indisponible */
503: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
reindexAssistant: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Bilan dindexation */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ReindexResult"];
};
};
/** @description Service IA indisponible */
503: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
transcribeAudio: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"multipart/form-data": {
/** Format: binary */
file: string;
};
};
};
responses: {
/** @description Texte transcrit — à relire (D1) */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["TranscriptionResult"];
};
};
/** @description Dictée non activée ou service IA indisponible */
503: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
globalSearch: { globalSearch: {
parameters: { parameters: {
query: { query: {

View File

@@ -0,0 +1,26 @@
import type { RoleName } from '@siop/shared';
/** Barre d'onglets ADAPTATIVE (maquette « mobile ouvert à tous les rôles »,
* D1) : le Technicien/Technicien limité gardent exactement leurs onglets
* terrain R4 ; les autres rôles reçoivent Accueil/OT/Menu (ou une variante
* plus étroite selon leurs droits). Décidé une fois ici, pas éparpillé. */
const ROLES_TERRAIN: RoleName[] = ['Technicien', 'Technicien limité'];
export function estRoleTerrain(role: RoleName | undefined): boolean {
return !role || ROLES_TERRAIN.includes(role);
}
export function ongletsVisibles(role: RoleName | undefined): ReadonlySet<string> {
if (estRoleTerrain(role)) {
return new Set(['journee', 'scanner', 'preventif', 'synchro', 'menu']);
}
if (role === 'Demandeur') return new Set(['accueil', 'nouvelle-demande']);
if (role === 'Vue seule') return new Set(['accueil', 'menu']);
return new Set(['accueil', 'ot', 'menu']); // Administrateur, Gestionnaire, Dispatcher
}
/** Onglet sur lequel router.replace() après connexion (voir app/index.tsx). */
export function ongletAccueil(role: RoleName | undefined): 'journee' | 'accueil' {
return estRoleTerrain(role) ? 'journee' : 'accueil';
}

View File

@@ -42,6 +42,7 @@ export function useLogin() {
mutationFn: async (input: { email: string; password: string }) => { mutationFn: async (input: { email: string; password: string }) => {
const res = await unwrap(await api.POST('/auth/login', { body: input })); const res = await unwrap(await api.POST('/auth/login', { body: input }));
await apres(res.accessToken); await apres(res.accessToken);
return res; // le rôle sert à router vers le bon onglet d'accueil
}, },
}); });
} }
@@ -52,6 +53,7 @@ export function useDemoLogin() {
mutationFn: async (userId: string) => { mutationFn: async (userId: string) => {
const res = await unwrap(await api.POST('/auth/demo-login', { body: { userId } })); const res = await unwrap(await api.POST('/auth/demo-login', { body: { userId } }));
await apres(res.accessToken); await apres(res.accessToken);
return res; // le rôle sert à router vers le bon onglet d'accueil
}, },
}); });
} }

View File

@@ -0,0 +1,26 @@
import type { ObjectCategory, PermissionRight } from '@siop/shared';
import { useMe } from './session';
/** Lecture UI de la matrice — même patron que le web (use-permissions.ts).
* L'API re-vérifie chaque requête (ADR-003) : ceci ne fait QUE piloter
* l'affichage (onglets, groupes du Menu, boutons d'action). */
export function usePermissions() {
const { data: me } = useMe();
const can = (category: ObjectCategory, right: PermissionRight): boolean => {
const entry = me?.permissions.find((p) => p.objectCategory === category);
if (!entry) return false;
switch (right) {
case 'view':
return entry.canView;
case 'viewOther':
return entry.canViewOther;
case 'create':
return entry.canCreate;
case 'edit':
return entry.canEdit;
case 'delete':
return entry.canDelete;
}
};
return { can };
}

View File

@@ -0,0 +1,189 @@
import { CameraView, useCameraPermissions } from 'expo-camera';
import { useRef, useState } from 'react';
import { Platform, Pressable, Text, TextInput, View } from 'react-native';
import { useAssetOptions, useCreateRequest } from '@/api/exploitation';
import { analyseScan } from '@/lib/scan';
import { useTokens } from '@/theme/tokens';
import { BoutonTel, ChoixTel } from './ui';
/** Formulaire « Nouvelle demande » — mêmes champs que la modale de
* signalement du web (asset via `/assets/options`, accessible même sans
* ASSETS.view ; description ; personne bloquée). Utilisé à la fois par
* l'onglet du Demandeur et par le Menu des rôles gestion.
* Scan QR (R6.6) : raccourci mobile pour resélectionner un équipement déjà
* dans son périmètre — résolution UNIQUEMENT contre les options déjà
* chargées (déjà filtrées par site pour un Demandeur affecté), jamais un
* repli sur le parc complet qui annulerait la restriction. */
export function FormulaireDemande({ surSucces }: { surSucces: (id: string) => void }) {
const t = useTokens();
const { data: options } = useAssetOptions();
const creation = useCreateRequest();
const [assetId, setAssetId] = useState<string | null>(null);
const [description, setDescription] = useState('');
const [personneBloquee, setPersonneBloquee] = useState(false);
const [scanOuvert, setScanOuvert] = useState(false);
const [erreurScan, setErreurScan] = useState<string | null>(null);
const [permission, demanderPermission] = useCameraPermissions();
const dernierScan = useRef(0);
const asset = (options ?? []).find((a) => a.id === assetId) ?? null;
const valide = !!assetId && description.trim().length >= 3;
const cameraUtilisable = Platform.OS !== 'web' && permission?.granted;
const surScan = ({ data }: { data: string }) => {
const maintenant = Date.now();
if (maintenant - dernierScan.current < 1500) return; // anti-rafale
dernierScan.current = maintenant;
const reference = analyseScan(data);
const trouve = reference
? (options ?? []).find((a) => a.reference.toUpperCase() === reference)
: null;
if (!trouve) {
setErreurScan("Cet appareil n'existe pas ou n'est pas dans votre périmètre.");
return;
}
setErreurScan(null);
setAssetId(trouve.id);
setScanOuvert(false);
};
const envoyer = () => {
if (!assetId) return;
creation.mutate(
{ assetId, description: description.trim(), isPersonTrapped: personneBloquee },
{ onSuccess: (r) => surSucces(r.id) },
);
};
return (
<View style={{ gap: 12 }}>
<ChoixTel
libelle="Équipement"
requis
valeur={asset ? { id: asset.id, label: `${asset.reference}${asset.siteName}` } : null}
options={(options ?? []).map((a) => ({ id: a.id, label: `${a.reference}${a.siteName}` }))}
surChoix={setAssetId}
/>
{scanOuvert ? (
<View style={{ gap: 8 }}>
{cameraUtilisable ? (
<View style={{ height: 220, borderRadius: 12, overflow: 'hidden' }}>
<CameraView
style={{ flex: 1 }}
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
onBarcodeScanned={surScan}
/>
</View>
) : (
<View
style={{
height: 220,
borderRadius: 12,
backgroundColor: '#131c2c',
alignItems: 'center',
justifyContent: 'center',
gap: 10,
padding: 16,
}}
>
<Text
style={{
color: '#dfe7f2',
fontFamily: 'Manrope_600SemiBold',
fontSize: 12.5,
textAlign: 'center',
}}
>
{Platform.OS === 'web'
? 'Caméra indisponible sur web.'
: "Visez le QR de l'étiquette de cabine."}
</Text>
{Platform.OS !== 'web' && !permission?.granted ? (
<BoutonTel libelle="Autoriser la caméra" surAppui={() => void demanderPermission()} />
) : null}
</View>
)}
<BoutonTel libelle="Annuler le scan" variante="gris" surAppui={() => setScanOuvert(false)} />
</View>
) : (
<BoutonTel
libelle="📷 Scanner l'étiquette"
variante="contour"
surAppui={() => {
setErreurScan(null);
setScanOuvert(true);
}}
/>
)}
{erreurScan ? (
<Text style={{ color: t.danger, fontFamily: 'Manrope_600SemiBold', fontSize: 12.5 }}>
{erreurScan}
</Text>
) : null}
<View style={{ gap: 4 }}>
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 11, color: t.encre2 }}>
Description <Text style={{ color: t.danger }}>*</Text>
</Text>
<TextInput
accessibilityLabel="Description du problème"
placeholder="Ex. : la porte ne se ferme plus au 3ᵉ étage"
placeholderTextColor={t.encre3}
multiline
value={description}
onChangeText={setDescription}
style={{
borderWidth: 1.5,
borderColor: t.bordureForte,
borderRadius: 9,
paddingHorizontal: 10,
paddingVertical: 9,
minHeight: 64,
color: t.encre,
fontFamily: 'Manrope_600SemiBold',
fontSize: 13,
textAlignVertical: 'top',
}}
/>
</View>
<Pressable
accessibilityRole="switch"
accessibilityLabel="Une personne est-elle bloquée ?"
accessibilityState={{ checked: personneBloquee }}
onPress={() => setPersonneBloquee((v) => !v)}
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
backgroundColor: personneBloquee ? t.prioBloqueFond : t.surface,
borderColor: personneBloquee ? t.danger : t.bordure,
borderWidth: 1,
borderRadius: 10,
padding: 12,
}}
>
<Text
style={{
fontFamily: 'Manrope_700Bold',
fontSize: 13,
color: personneBloquee ? t.danger : t.encre,
}}
>
Une personne est-elle bloquée ?
</Text>
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 13, color: personneBloquee ? t.danger : t.encre3 }}>
{personneBloquee ? 'OUI' : 'NON'}
</Text>
</Pressable>
{creation.isError ? (
<Text style={{ color: t.danger, fontFamily: 'Manrope_600SemiBold', fontSize: 12.5 }}>
{creation.error.message}
</Text>
) : null}
<BoutonTel
libelle="Envoyer la demande"
desactive={!valide || creation.isPending}
surAppui={envoyer}
/>
</View>
);
}

View File

@@ -0,0 +1,238 @@
import { router } from 'expo-router';
import { useState } from 'react';
import { FlatList, Text, TextInput, View } from 'react-native';
import type { RequestSummary } from '@siop/shared';
import {
useApproveRequest,
useRejectRequest,
useRequests,
} from '@/api/exploitation';
import { useUsers } from '@/api/pilotage';
import { usePermissions } from '@/auth/use-permissions';
import { useTokens, type Tokens } from '@/theme/tokens';
import { BoutonTel, ChoixTel } from './ui';
/** Panneau Demandes — UN SEUL composant pour tous les rôles (maquette
* « mobile ouvert à tous les rôles », écran 5) : le Demandeur y crée et
* suit SES demandes ; Gestionnaire/Dispatcher/Administrateur y approuvent
* ou rejettent (motif obligatoire, comme au web) ; Vue seule consulte sans
* bouton. L'API renvoie déjà la liste correctement scopée (ADR-003) — ce
* composant affiche ce qu'on lui donne et ne propose que les actions
* permises par `can(...)`. */
export function PanneauDemandes() {
const t = useTokens();
const { can } = usePermissions();
const { data: requests } = useRequests();
const peutTraiter = can('REQUESTS', 'edit');
const peutCreer = can('REQUESTS', 'create');
const toutes = requests ?? [];
const aTraiter = toutes.filter((r) => r.status === 'RECEIVED').length;
return (
<View style={{ flex: 1, gap: 10 }}>
<View style={{ flexDirection: 'row', alignItems: 'baseline' }}>
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
Demandes
</Text>
<Text style={{ marginLeft: 'auto', fontFamily: 'Manrope_600SemiBold', fontSize: 11, color: t.encre3 }}>
{aTraiter} à traiter · {toutes.length} au total
</Text>
</View>
{peutCreer ? (
<BoutonTel libelle="+ Nouvelle demande" surAppui={() => router.push('/demandes/nouvelle')} />
) : null}
<FlatList
data={toutes}
keyExtractor={(r) => r.id}
contentContainerStyle={{ gap: 8, paddingBottom: 12 }}
ListEmptyComponent={
<Text style={{ color: t.encre3, fontFamily: 'Manrope_600SemiBold', padding: 16, textAlign: 'center' }}>
Aucune demande pour l'instant.
</Text>
}
renderItem={({ item: r }) => <CarteDemande demande={r} peutTraiter={peutTraiter} />}
/>
</View>
);
}
const STYLE_STATUT: Record<RequestSummary['status'], (t: Tokens) => [string, string]> = {
RECEIVED: (t) => [t.stAttente, t.stAttenteFond],
APPROVED: (t) => [t.stTermine, t.stTermineFond],
REJECTED: (t) => [t.stAnnule, t.stAnnuleFond],
};
const LABEL_STATUT: Record<RequestSummary['status'], string> = {
RECEIVED: 'Reçue',
APPROVED: 'Approuvée',
REJECTED: 'Rejetée',
};
function CarteDemande({ demande: r, peutTraiter }: { demande: RequestSummary; peutTraiter: boolean }) {
const t = useTokens();
const { data: users } = useUsers();
const approbation = useApproveRequest();
const rejet = useRejectRequest();
// Un seul panneau ouvert à la fois : approbation (avec assignation — ce
// n'est pas au Gestionnaire d'agir comme un technicien, il délègue) ou
// rejet (motif obligatoire). null = fermé, les deux boutons côte à côte.
const [panneau, setPanneau] = useState<'approuver' | 'rejeter' | null>(null);
const [motif, setMotif] = useState('');
const [assigneId, setAssigneId] = useState<string | null>(null);
const techniciens = (users ?? []).filter(
(u) => u.status === 'active' && u.role.name.startsWith('Technicien'),
);
const technicien = techniciens.find((tt) => tt.id === assigneId) ?? null;
const [enc, fond] = STYLE_STATUT[r.status](t);
const enCours = r.status === 'RECEIVED';
return (
<View
style={{
backgroundColor: t.surface,
borderColor: r.isPersonTrapped && enCours ? t.danger : t.bordure,
borderWidth: r.isPersonTrapped && enCours ? 1.5 : 1,
borderRadius: 12,
padding: 12,
gap: 6,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
<Text
style={{
flex: 1,
fontFamily: 'Manrope_700Bold',
fontSize: 10.5,
letterSpacing: 0.6,
textTransform: 'uppercase',
color: t.encre2,
}}
>
{r.reference}
</Text>
<Text
style={{
fontFamily: 'Manrope_700Bold',
fontSize: 10.5,
color: enc,
backgroundColor: fond,
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 999,
overflow: 'hidden',
}}
>
{LABEL_STATUT[r.status]}
</Text>
</View>
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 14, color: t.encre }}>
{r.isPersonTrapped ? ' ' : ''}
{r.description}
</Text>
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 12, color: t.encre2 }}>
Asc. {r.assetReference} — {r.siteName} · {r.requesterLabel}
</Text>
{r.rejectionReason ? (
<Text style={{ fontFamily: 'Manrope_600SemiBold', fontSize: 12, color: t.danger }}>
Motif du rejet : {r.rejectionReason}
</Text>
) : null}
{peutTraiter && enCours ? (
panneau === null ? (
<View style={{ flexDirection: 'row', gap: 8, marginTop: 4 }}>
<View style={{ flex: 1 }}>
<BoutonTel libelle="Approuver → OT" variante="vert" surAppui={() => setPanneau('approuver')} />
</View>
<View style={{ flex: 1 }}>
<BoutonTel libelle="Rejeter" variante="gris" surAppui={() => setPanneau('rejeter')} />
</View>
</View>
) : panneau === 'approuver' ? (
<View style={{ gap: 6, marginTop: 4 }}>
<ChoixTel
libelle="Assigner à"
valeur={technicien ? { id: technicien.id, label: technicien.displayName } : null}
options={techniciens.map((tt) => ({ id: tt.id, label: tt.displayName }))}
surChoix={setAssigneId}
/>
<View style={{ flexDirection: 'row', gap: 8 }}>
<View style={{ flex: 1 }}>
<BoutonTel
libelle="Annuler"
variante="contour"
surAppui={() => {
setPanneau(null);
setAssigneId(null);
}}
/>
</View>
<View style={{ flex: 1 }}>
<BoutonTel
libelle="Confirmer l'approbation"
variante="vert"
desactive={approbation.isPending}
surAppui={() =>
approbation.mutate(
{
id: r.id,
priority: r.isPersonTrapped ? 'PERSON_TRAPPED' : 'HIGH',
assigneeIds: assigneId ? [assigneId] : undefined,
},
{ onSuccess: () => setPanneau(null) },
)
}
/>
</View>
</View>
</View>
) : (
<View style={{ gap: 6, marginTop: 4 }}>
<TextInput
accessibilityLabel="Motif du rejet"
placeholder="Motif (lisible par le demandeur) *"
placeholderTextColor={t.encre3}
value={motif}
onChangeText={setMotif}
style={{
borderWidth: 1.5,
borderColor: t.bordureForte,
borderRadius: 9,
paddingHorizontal: 10,
paddingVertical: 9,
color: t.encre,
fontFamily: 'Manrope_600SemiBold',
fontSize: 13,
}}
/>
<View style={{ flexDirection: 'row', gap: 8 }}>
<View style={{ flex: 1 }}>
<BoutonTel
libelle="Annuler"
variante="contour"
surAppui={() => {
setPanneau(null);
setMotif('');
}}
/>
</View>
<View style={{ flex: 1 }}>
<BoutonTel
libelle="Confirmer le rejet"
desactive={motif.trim().length < 3}
surAppui={() =>
rejet.mutate(
{ id: r.id, reason: motif.trim() },
{ onSuccess: () => setPanneau(null) },
)
}
/>
</View>
</View>
</View>
)
) : null}
</View>
);
}

View File

@@ -1,7 +1,8 @@
import { router } from 'expo-router'; import { router } from 'expo-router';
import { useState, type ReactNode } from 'react'; import { useState, type ReactNode } from 'react';
import { Modal, Pressable, ScrollView, Text, View } from 'react-native'; import { Alert, Modal, Pressable, ScrollView, Text, View } from 'react-native';
import { WORK_ORDER_STATUS_LABELS, type WorkOrderStatus } from '@siop/shared'; import { WORK_ORDER_STATUS_LABELS, type WorkOrderStatus } from '@siop/shared';
import { useHorsLigne, useLogout, useMe } from '@/auth/session';
import { useTokens, type Tokens } from '@/theme/tokens'; import { useTokens, type Tokens } from '@/theme/tokens';
/** Briques d'écran de la maquette R4 — cartes, chips, boutons, sélecteur. */ /** Briques d'écran de la maquette R4 — cartes, chips, boutons, sélecteur. */
@@ -263,3 +264,76 @@ export function ChoixTel({
</View> </View>
); );
} }
/** Entête commune à tous les onglets (D'où qu'on parte, on peut se
* déconnecter — trouvée manquante en recette : seule « Ma journée » avait
* ce contrôle, ailleurs l'utilisateur se sentait bloqué). Le rôle affiché
* est CELUI du compte connecté, pas un libellé figé — le mobile n'est plus
* réservé aux techniciens (voir décision élargissant le périmètre). */
export function EnteteTabs() {
const t = useTokens();
const horsLigne = useHorsLigne();
const { data: me } = useMe();
const logout = useLogout();
const seDeconnecter = () => {
Alert.alert('Se déconnecter ?', me?.displayName ? `Compte : ${me.displayName}` : undefined, [
{ text: 'Annuler', style: 'cancel' },
{
text: 'Se déconnecter',
style: 'destructive',
onPress: () => void logout().then(() => router.replace('/connexion')),
},
]);
};
return (
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<Text style={{ color: t.safran, fontSize: 15 }}></Text>
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 17, color: t.encre }}>SIOP</Text>
<Text style={{ fontFamily: 'Manrope_600SemiBold', fontSize: 10.5, color: t.encre3 }}>
{me?.role.name ?? '·'}
</Text>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6, marginLeft: 'auto' }}>
<Text
accessibilityLabel="État de synchronisation"
style={{
fontFamily: 'Manrope_700Bold',
fontSize: 10.5,
color: horsLigne ? t.stAttente : t.stTermine,
backgroundColor: horsLigne ? t.stAttenteFond : t.stTermineFond,
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 999,
overflow: 'hidden',
}}
>
{horsLigne ? 'Hors-ligne' : 'Synchro à jour'}
</Text>
<Pressable
accessibilityRole="button"
accessibilityLabel="Compte et déconnexion"
onPress={seDeconnecter}
hitSlop={8}
style={{
width: 26,
height: 26,
borderRadius: 13,
backgroundColor: t.stEncours,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Text style={{ color: '#fff', fontFamily: 'Manrope_700Bold', fontSize: 10 }}>
{(me?.displayName ?? '·')
.split(' ')
.map((m) => m[0])
.join('')
.slice(0, 2)
.toUpperCase()}
</Text>
</Pressable>
</View>
</View>
);
}

View File

@@ -85,7 +85,7 @@ export function enfilerBilan(
patchDetail(queryClient, ot.id, (c) => ({ patchDetail(queryClient, ot.id, (c) => ({
...c, ...c,
report: { report: {
note: c.report?.note ?? null, note: corps.note !== undefined ? corps.note : (c.report?.note ?? null),
doorState: labels.doorStateId !== undefined ? (labels.doorStateId ?? null) : (c.report?.doorState ?? null), doorState: labels.doorStateId !== undefined ? (labels.doorStateId ?? null) : (c.report?.doorState ?? null),
cabinPosition: cabinPosition:
labels.cabinPositionId !== undefined ? (labels.cabinPositionId ?? null) : (c.report?.cabinPosition ?? null), labels.cabinPositionId !== undefined ? (labels.cabinPositionId ?? null) : (c.report?.cabinPosition ?? null),

View File

@@ -0,0 +1,149 @@
import { expect, test } from '@playwright/test';
/**
* Recette R5 (plan de releases) : la bibliothèque EST le corpus (bandeau
* 09-08, statut d'indexation, interrupteur, réindexation explicite) ; puis
* l'assistant « sourcé ou silencieux » (réponse citée depuis le PDF téléversé,
* refus honnête et chiffré sinon) ; enfin la suggestion de bilan sur un OT
* (codes existants, appliqués par le geste humain, liseré « suggéré »).
* Le service IA tourne avec l'embeddeur déterministe et des seuils abaissés
* (ci.yml) — c'est le CIRCUIT qui se recette, le vrai modèle se recette en
* local (journal R5.1).
*/
const suffix = Date.now().toString(36);
/** PDF 1 page minimal mais VALIDE (xref calculée) — pypdf doit pouvoir en
* extraire le texte : c'est ce qui alimente l'index côté service IA. */
function pdfMinimal(texte: string): Buffer {
const contenu = `BT /F1 12 Tf 72 720 Td (${texte.replace(/[()\\]/g, '\\$&')}) Tj ET`;
const objets = [
'<< /Type /Catalog /Pages 2 0 R >>',
'<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R ' +
'/Resources << /Font << /F1 5 0 R >> >> >>',
`<< /Length ${contenu.length} >>\nstream\n${contenu}\nendstream`,
'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>',
];
let corps = '%PDF-1.4\n';
const offsets: number[] = [];
objets.forEach((objet, i) => {
offsets.push(corps.length);
corps += `${i + 1} 0 obj\n${objet}\nendobj\n`;
});
const debutXref = corps.length;
corps += `xref\n0 ${objets.length + 1}\n0000000000 65535 f \n`;
for (const offset of offsets) corps += `${String(offset).padStart(10, '0')} 00000 n \n`;
corps += `trailer\n<< /Size ${objets.length + 1} /Root 1 0 R >>\nstartxref\n${debutXref}\n%%EOF`;
return Buffer.from(corps, 'latin1');
}
async function connexionDemo(page: import('@playwright/test').Page, nom: RegExp) {
await page.goto('/connexion');
await page.getByRole('button', { name: nom }).click();
await expect(page.locator('.topbar')).toBeVisible();
}
test('recette R5 : corpus → réindexation → assistant sourcé → refus honnête', async ({ page }) => {
test.setTimeout(180_000); // deux réindexations complètes du corpus réel
const fichier = `e2e-notice-${suffix}.pdf`;
// 1 · Nadia (gestionnaire, ASSETS.edit) — la bibliothèque est le corpus
await connexionDemo(page, /Nadia Berrada/);
await page.getByRole('link', { name: 'Fichiers' }).click();
await expect(page.getByRole('heading', { name: /corpus de l'assistant/ })).toBeVisible();
await expect(page.locator('.avert')).toContainText('Loi 09-08'); // l'anonymisation est DITE
// 2 · Téléverser une notice PDF de test rattachée à A1
await page.getByRole('button', { name: 'Téléverser' }).click();
await page.getByLabel("Rattacher à l'appareil *").selectOption({ index: 1 });
await page.getByLabel('Choisir un fichier').setInputFiles({
name: fichier,
mimeType: 'application/pdf',
buffer: pdfMinimal(
'Couple de serrage des coulisseaux de guides : 25 Nm. Verifier le jeu lateral.',
),
});
await page.getByRole('button', { name: 'Téléverser', exact: true }).last().click();
const vignette = page.locator('tr', { hasText: fichier }); // tableau du corpus (arbitrage référent)
await expect(vignette).toBeVisible();
await expect(vignette.locator('.st')).toHaveText('à indexer'); // jamais indexé à la naissance
// 3 · Réindexer tout — geste explicite, bilan chiffré, statut à jour
await page.getByRole('button', { name: 'Réindexer tout' }).click();
// l'ingestion réelle (MinIO + pypdf + embeddings) peut dépasser les 5 s
await expect(page.getByText('Réindexation terminée')).toBeVisible({ timeout: 60_000 });
await expect(vignette.locator('.st')).toContainText('indexé ·');
await expect(vignette.locator('.st')).toContainText('extrait');
// 4 · L'assistant répond SOURCÉ depuis ce PDF (embeddeur déterministe :
// la question reprend les mots de la notice)
await page.getByRole('link', { name: 'Assistant' }).click();
await expect(page.getByText('répond UNIQUEMENT depuis votre bibliothèque')).toBeVisible();
await page
.getByLabel('Poser une question')
.fill('couple de serrage des coulisseaux de guides ?');
await page.getByRole('button', { name: 'Envoyer' }).click();
const source = page.locator('.source', { hasText: fichier });
await expect(source).toBeVisible();
await expect(source.locator('.extrait')).toContainText('25 Nm'); // l'extrait EXACT
await expect(source.locator('.ou')).toContainText('p. 1'); // la citation pointe la page
await expect(page.locator('.msg-r .avert')).toContainText('vous validez'); // pas un disclaimer caché
// 5 · Sans source au-dessus du seuil : refus honnête, chiffré, avec l'action utile
await page.getByLabel('Poser une question').fill('xylophone quantique zorglub ?');
await page.getByRole('button', { name: 'Envoyer' }).click();
const refus = page.locator('.refus');
await expect(refus).toBeVisible();
await expect(refus).toContainText('je préfère ne pas inventer');
await expect(refus).toContainText(/J'ai cherché dans \d+ documents? indexés? et \d+ bilans?/);
await expect(refus.getByRole('link', { name: /Téléverser la notice/ })).toBeVisible();
// 6 · L'interrupteur exclut la notice — réversible, effectif à la réindexation
await page.getByRole('link', { name: 'Fichiers' }).click();
await vignette.getByRole('switch').click();
await expect(vignette.locator('.st')).toHaveText('exclu du corpus');
await page.getByRole('button', { name: 'Réindexer tout' }).click();
await expect(page.getByText('Réindexation terminée')).toBeVisible({ timeout: 60_000 });
// Ménage : la notice de test sort de la bibliothèque
page.on('dialog', (d) => void d.accept());
await vignette.getByRole('button', { name: 'Supprimer' }).click();
await expect(vignette).toHaveCount(0);
});
test('recette R5 : suggestion de bilan — codes existants, appliqués par l\'humain', async ({
page,
}) => {
const titre = `E2E Suggestion ${suffix}`;
// 1 · Nadia crée un OT de dépannage sur A1
await connexionDemo(page, /Nadia Berrada/);
await page.getByRole('link', { name: 'Ordres de travail' }).click();
await page.getByRole('button', { name: '+ Nouvel OT' }).click();
await page.getByLabel('Objet *').fill(titre);
await page.getByLabel('Équipement *').selectOption({ index: 1 });
await page.getByRole('button', { name: "Créer l'OT (statut Ouvert)" }).click();
await expect(page.getByRole('heading', { name: titre })).toBeVisible();
// 2 · Décrire pour suggérer — l'IA propose des codes EXISTANTS, justifiés
await page
.getByLabel('Décrire pour suggérer (optionnel)')
.fill('Frottement mécanique sur les portes, nettoyage et graissage effectués, essais OK.');
await page.getByRole('button', { name: /Suggérer les codes/ }).click();
const suggestions = page.locator('.suggestion');
await expect(suggestions.first()).toBeVisible();
await expect(page.locator('.confiance').first()).toContainText('confiance');
// 3 · « Appliquer les N » : les sélecteurs se pré-remplissent, liseré « suggéré »
await page.getByRole('button', { name: /Appliquer les \d/ }).click();
await expect(page.locator('.champ-b[data-suggere]').first()).toBeVisible();
const anomalie = page.locator('#bilan-ANOMALY');
await expect(anomalie).not.toHaveValue('');
// 4 · Un choix MANUEL retire le liseré du champ concerné (l'humain a repris la main)
const champAnomalie = page.locator('.champ-b', { has: anomalie });
await expect(champAnomalie).toHaveAttribute('data-suggere', 'true');
await anomalie.selectOption({ index: 1 });
await expect(champAnomalie).not.toHaveAttribute('data-suggere', 'true');
});

View File

@@ -14,6 +14,10 @@ const API_ENV = {
DATABASE_URL: DATABASE_URL:
process.env.DATABASE_URL ?? 'postgresql://siop:siop@localhost:5432/siop', process.env.DATABASE_URL ?? 'postgresql://siop:siop@localhost:5432/siop',
REDIS_URL: process.env.REDIS_URL ?? 'redis://localhost:6379', REDIS_URL: process.env.REDIS_URL ?? 'redis://localhost:6379',
// R5 : le service IA écoute sur 8000 (CI : embeddeur déterministe,
// seuils abaissés — voir ci.yml ; localement : lancez apps/ai avant).
AI_SERVICE_URL: process.env.AI_SERVICE_URL ?? 'http://localhost:8000',
AI_SERVICE_TOKEN: process.env.AI_SERVICE_TOKEN ?? 'dev-only-ai-token',
}; };
export default defineConfig({ export default defineConfig({

View File

@@ -5,6 +5,7 @@ import { Coquille } from '@/layout/coquille';
import PageAchats from '@/pages/achats'; import PageAchats from '@/pages/achats';
import PageActivation from '@/pages/activation'; import PageActivation from '@/pages/activation';
import PageAscenseurs from '@/pages/ascenseurs'; import PageAscenseurs from '@/pages/ascenseurs';
import PageAssistant from '@/pages/assistant';
import PageBibliotheque from '@/pages/bibliotheque'; import PageBibliotheque from '@/pages/bibliotheque';
import PageCategories from '@/pages/categories'; import PageCategories from '@/pages/categories';
import PageCompteurs from '@/pages/compteurs'; import PageCompteurs from '@/pages/compteurs';
@@ -67,6 +68,7 @@ export default function App() {
<Route path="/tiers" element={dansCoquille(<PageTiers />)} /> <Route path="/tiers" element={dansCoquille(<PageTiers />)} />
<Route path="/bibliotheque" element={dansCoquille(<PageBibliotheque />)} /> <Route path="/bibliotheque" element={dansCoquille(<PageBibliotheque />)} />
<Route path="/statistiques" element={dansCoquille(<PageStatistiques />)} /> <Route path="/statistiques" element={dansCoquille(<PageStatistiques />)} />
<Route path="/assistant" element={dansCoquille(<PageAssistant />)} />
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
</QueryClientProvider> </QueryClientProvider>

View File

@@ -0,0 +1,57 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { AssistantAsk, SuggestBilan } from '@siop/shared';
import { api } from './client';
/** Hooks R5 — l'assistant passe par l'API NestJS (le service IA n'est jamais
* appelé du navigateur, ADR-004 §4). Le 503 est un état ATTENDU du contrat
* (service éteint ou pas encore déployé) : les écrans l'affichent posément. */
async function unwrap<T>(res: { data?: T; error?: unknown; response: Response }): Promise<T> {
if (res.response.status === 503) {
throw new Error('Assistant indisponible pour le moment — réessayez dans un instant.');
}
if (res.error || res.data === undefined) {
const message =
(res.error as { message?: string } | undefined)?.message ??
`Le serveur a répondu ${res.response.status}`;
throw new Error(Array.isArray(message) ? message.join(' — ') : message);
}
return res.data;
}
export function useAskAssistant() {
return useMutation({
mutationFn: async (body: AssistantAsk) =>
unwrap(await api.POST('/assistant/ask', { body })),
});
}
export function useSuggestBilan() {
return useMutation({
mutationFn: async (body: SuggestBilan) =>
unwrap(await api.POST('/assistant/suggest-bilan', { body })),
});
}
export function useReindexAssistant() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async () => unwrap(await api.POST('/assistant/reindex')),
// la réindexation met à jour indexedAt/chunkCount de chaque document
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['documents'] }),
});
}
export function useSetDocumentCorpus() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (input: { id: string; inCorpus: boolean }) =>
unwrap(
await api.PATCH('/documents/{id}/corpus', {
params: { path: { id: input.id } },
body: { inCorpus: input.inCorpus },
}),
),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['documents'] }),
});
}

View File

@@ -407,6 +407,23 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/documents/{id}/corpus": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
/** Inclure/exclure du corpus IA (D3 — réversible, effectif à la prochaine réindexation) */
patch: operations["updateDocumentCorpus"];
trace?: never;
};
"/documents/{id}": { "/documents/{id}": {
parameters: { parameters: {
query?: never; query?: never;
@@ -441,6 +458,74 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/assistant/ask": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Assistant R5 — sourcé ou silencieux : extraits cités ou refus honnête (D2) */
post: operations["askAssistant"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/assistant/suggest-bilan": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Suggérer des codes de bilan depuis une description libre (D1 — lhumain valide) */
post: operations["suggestBilan"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/assistant/reindex": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Réindexer le corpus (bibliothèque PDF + bilans clôturés, anonymisés à lingestion — D4) */
post: operations["reindexAssistant"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/assistant/transcribe": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Dictée (R5 D5, opt-in) — laudio est transcrit puis JAMAIS conservé, à relire avant tout usage */
post: operations["transcribeAudio"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/search": { "/search": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1331,6 +1416,11 @@ export interface components {
status: "active" | "invited" | "disabled"; status: "active" | "invited" | "disabled";
isDemo: boolean; isDemo: boolean;
hourlyRate: number | null; hourlyRate: number | null;
assignedSites: {
/** Format: uuid */
id: string;
name: string;
}[];
}[]; }[];
}; };
RolesResponse: { RolesResponse: {
@@ -1356,6 +1446,7 @@ export interface components {
roleId: string; roleId: string;
teamIds?: string[]; teamIds?: string[];
phone?: string; phone?: string;
locationIds?: string[];
}; };
UserAdmin: { UserAdmin: {
/** Format: uuid */ /** Format: uuid */
@@ -1379,6 +1470,11 @@ export interface components {
status: "active" | "invited" | "disabled"; status: "active" | "invited" | "disabled";
isDemo: boolean; isDemo: boolean;
hourlyRate: number | null; hourlyRate: number | null;
assignedSites: {
/** Format: uuid */
id: string;
name: string;
}[];
}; };
UserUpdate: { UserUpdate: {
displayName?: string; displayName?: string;
@@ -1388,6 +1484,7 @@ export interface components {
teamIds?: string[]; teamIds?: string[];
isActive?: boolean; isActive?: boolean;
hourlyRate?: number | null; hourlyRate?: number | null;
locationIds?: string[];
}; };
DocumentsResponse: { DocumentsResponse: {
documents: { documents: {
@@ -1403,6 +1500,9 @@ export interface components {
uploadedByName: string | null; uploadedByName: string | null;
/** Format: date-time */ /** Format: date-time */
createdAt: string; createdAt: string;
inCorpus: boolean;
indexedAt: string | null;
chunkCount: number;
}[]; }[];
}; };
Document: { Document: {
@@ -1418,6 +1518,12 @@ export interface components {
uploadedByName: string | null; uploadedByName: string | null;
/** Format: date-time */ /** Format: date-time */
createdAt: string; createdAt: string;
inCorpus: boolean;
indexedAt: string | null;
chunkCount: number;
};
DocumentCorpusUpdate: {
inCorpus: boolean;
}; };
AnalyticsSummary: { AnalyticsSummary: {
months: number; months: number;
@@ -1446,6 +1552,53 @@ export interface components {
total: number; total: number;
}[]; }[];
}; };
AssistantAnswer: {
/** @enum {string} */
mode: "EXTRACTIVE" | "GENERATED" | "REFUSAL";
answer: string | null;
excerpts: {
/** @enum {string} */
sourceType: "DOCUMENT" | "WORK_ORDER";
documentId: string | null;
workOrderId: string | null;
title: string;
locator: string;
content: string;
score: number;
}[];
corpus: {
documents: number;
reports: number;
};
};
AssistantAsk: {
question: string;
};
BilanSuggestions: {
suggestions: {
/** @enum {string} */
field: "DOOR_STATE" | "CABIN_POSITION" | "ANOMALY" | "EXTERNAL_CAUSE" | "ACTION_TAKEN" | "COMPONENT_CONCERNED";
/** Format: uuid */
valueId: string;
label: string;
/** @enum {string} */
confidence: "HIGH" | "MEDIUM";
similarReports: number;
score: number;
}[];
};
SuggestBilan: {
description: string;
};
ReindexResult: {
documentsIndexed: number;
documentsSkipped: number;
reportsIndexed: number;
chunks: number;
};
TranscriptionResult: {
text: string;
};
SearchResponse: { SearchResponse: {
workOrders: { workOrders: {
/** Format: uuid */ /** Format: uuid */
@@ -2918,6 +3071,39 @@ export interface operations {
}; };
}; };
}; };
updateDocumentCorpus: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["DocumentCorpusUpdate"];
};
};
responses: {
/** @description Document mis à jour */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Document"];
};
};
/** @description Inconnu */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
deleteDocument: { deleteDocument: {
parameters: { parameters: {
query?: never; query?: never;
@@ -2967,6 +3153,129 @@ export interface operations {
}; };
}; };
}; };
askAssistant: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["AssistantAsk"];
};
};
responses: {
/** @description Réponse sourcée ou refus */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AssistantAnswer"];
};
};
/** @description Service IA indisponible */
503: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
suggestBilan: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SuggestBilan"];
};
};
responses: {
/** @description Suggestions (codes existants seulement) */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["BilanSuggestions"];
};
};
/** @description Service IA indisponible */
503: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
reindexAssistant: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Bilan dindexation */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ReindexResult"];
};
};
/** @description Service IA indisponible */
503: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
transcribeAudio: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"multipart/form-data": {
/** Format: binary */
file: string;
};
};
};
responses: {
/** @description Texte transcrit — à relire (D1) */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["TranscriptionResult"];
};
};
/** @description Dictée non activée ou service IA indisponible */
503: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
globalSearch: { globalSearch: {
parameters: { parameters: {
query: { query: {

View File

@@ -6,6 +6,7 @@ import {
type DocumentDto, type DocumentDto,
type DocumentKind, type DocumentKind,
} from '@siop/shared'; } from '@siop/shared';
import { useSetDocumentCorpus } from '@/api/assistant';
import { import {
blobDocument, blobDocument,
ouvrirDocument, ouvrirDocument,
@@ -63,7 +64,58 @@ function ApercuVignette({ doc }: { doc: DocumentDto }) {
); );
} }
export function VignetteDoc({ doc, surSuppression }: { doc: DocumentDto; surSuppression?: (id: string) => void }) { /** Statut corpus (R5, D3) : lisible d'un coup d'œil, jamais ambigu. */
export function StatutCorpus({ doc }: { doc: DocumentDto }) {
if (doc.contentType !== 'application/pdf') {
return <span className="st exclu">image non indexable</span>;
}
if (!doc.inCorpus) return <span className="st exclu">exclu du corpus</span>;
if (!doc.indexedAt) return <span className="st encours">à indexer</span>;
const quand = new Intl.DateTimeFormat('fr-FR', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
}).format(new Date(doc.indexedAt));
return (
<span className="st ok">
indexé · {quand} · {doc.chunkCount} extrait{doc.chunkCount > 1 ? 's' : ''}
</span>
);
}
/** Interrupteur d'inclusion au corpus (R5, D3) — PDF seulement, réversible.
* Une image non indexable s'affiche ÉTEINTE quel que soit l'état stocké :
* l'interrupteur montre la réalité du corpus, pas une colonne de base. */
export function InterrupteurCorpus({ doc }: { doc: DocumentDto }) {
const bascule = useSetDocumentCorpus();
return (
<button
type="button"
role="switch"
aria-checked={doc.contentType === 'application/pdf' && doc.inCorpus}
aria-label={doc.inCorpus ? 'Exclure du corpus' : 'Inclure au corpus'}
title={
doc.contentType !== 'application/pdf'
? 'Seuls les PDF sont indexables'
: doc.inCorpus
? 'Exclure du corpus (effectif à la prochaine réindexation)'
: 'Inclure au corpus (effectif à la prochaine réindexation)'
}
className="interrupteur corpus"
disabled={bascule.isPending || doc.contentType !== 'application/pdf'}
onClick={() => bascule.mutate({ id: doc.id, inCorpus: !doc.inCorpus })}
/>
);
}
export function VignetteDoc({
doc,
surSuppression,
}: {
doc: DocumentDto;
surSuppression?: (id: string) => void;
}) {
return ( return (
<div className="doc"> <div className="doc">
<ApercuVignette doc={doc} /> <ApercuVignette doc={doc} />

View File

@@ -82,6 +82,13 @@ export const IcoStatistiques = () => (
</svg> </svg>
); );
export const IcoAssistant = () => (
<svg {...base}>
<path d="M12 3l1.8 4.7L18.5 9l-4.7 1.8L12 15.5l-1.8-4.7L5.5 9l4.7-1.3z" />
<path d="M18.5 15l.9 2.1 2.1.9-2.1.9-.9 2.1-.9-2.1-2.1-.9 2.1-.9z" />
</svg>
);
export const IcoPersonnes = () => ( export const IcoPersonnes = () => (
<svg {...base}> <svg {...base}>
<circle cx="9" cy="8" r="3.5" /> <circle cx="9" cy="8" r="3.5" />

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