15 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

13/13 e2e verts.

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 18:16:00 +01:00
135 changed files with 24283 additions and 799 deletions

View File

@@ -81,8 +81,16 @@ jobs:
DATABASE_URL: postgresql://siop:siop@localhost:5432/siop DATABASE_URL: postgresql://siop:siop@localhost:5432/siop
REDIS_URL: redis://localhost:6379 REDIS_URL: redis://localhost:6379
JWT_SECRET: ci-only-secret-0123456789abcdef JWT_SECRET: ci-only-secret-0123456789abcdef
MINIO_ENDPOINT: localhost
MINIO_ACCESS_KEY: siop
MINIO_SECRET_KEY: siop-minio
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v5
- name: Démarrer MinIO (l'image officielle exige une commande — pas un service)
run: |
docker run -d --name minio -p 9000:9000 \
-e MINIO_ROOT_USER=siop -e MINIO_ROOT_PASSWORD=siop-minio \
minio/minio:RELEASE.2025-09-07T16-13-09Z server /data
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
- uses: actions/setup-node@v5 - uses: actions/setup-node@v5
with: with:
@@ -110,6 +118,21 @@ jobs:
- run: pnpm --filter @siop/web test - run: pnpm --filter @siop/web test
- run: pnpm --filter @siop/web build - run: pnpm --filter @siop/web build
mobile:
name: mobile (typecheck + tests jest-expo)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v5
with:
node-version-file: .nvmrc
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm --filter @siop/shared build
- run: pnpm --filter @siop/mobile typecheck
- run: pnpm --filter @siop/mobile test
e2e: e2e:
name: e2e (parcours démo Playwright) name: e2e (parcours démo Playwright)
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -134,8 +157,16 @@ jobs:
DATABASE_URL: postgresql://siop:siop@localhost:5432/siop DATABASE_URL: postgresql://siop:siop@localhost:5432/siop
REDIS_URL: redis://localhost:6379 REDIS_URL: redis://localhost:6379
JWT_SECRET: ci-only-secret-0123456789abcdef JWT_SECRET: ci-only-secret-0123456789abcdef
MINIO_ENDPOINT: localhost
MINIO_ACCESS_KEY: siop
MINIO_SECRET_KEY: siop-minio
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v5
- name: Démarrer MinIO (l'image officielle exige une commande — pas un service)
run: |
docker run -d --name minio -p 9000:9000 \
-e MINIO_ROOT_USER=siop -e MINIO_ROOT_PASSWORD=siop-minio \
minio/minio:RELEASE.2025-09-07T16-13-09Z server /data
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
- uses: actions/setup-node@v5 - uses: actions/setup-node@v5
with: with:
@@ -164,7 +195,7 @@ jobs:
deploy: deploy:
name: deploy (Dokploy — siop2.apps.enset.top) name: deploy (Dokploy — siop2.apps.enset.top)
if: github.event_name == 'push' && github.ref == 'refs/heads/main' if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: [lint, contract, api, web, e2e] needs: [lint, contract, api, web, mobile, e2e]
runs-on: ubuntu-latest runs-on: ubuntu-latest
environment: production environment: production
steps: steps:

View File

@@ -48,6 +48,16 @@ pnpm + Turborepo. `apps/api` : NestJS, Prisma, PostgreSQL (pgvector + PostGIS),
- **R2.1 — socle backend exploitation** : migration `r2_exploitation` (10 tables), 15 opérations au contrat (41 total, transitions + champs requis du bilan dans `@siop/shared`), machine à états stricte avec garde de clôture (bilan 3 champs + checklist), demandeOT 1-1, rejet à motif, scoping « voir autre » (listes + accès directs), seed maquette (31 valeurs de bilan, 8 gabarits, OT/demandes/compteurs), 45 tests (95 %/79 %). - **R2.1 — socle backend exploitation** : migration `r2_exploitation` (10 tables), 15 opérations au contrat (41 total, transitions + champs requis du bilan dans `@siop/shared`), machine à états stricte avec garde de clôture (bilan 3 champs + checklist), demandeOT 1-1, rejet à motif, scoping « voir autre » (listes + accès directs), seed maquette (31 valeurs de bilan, 8 gabarits, OT/demandes/compteurs), 45 tests (95 %/79 %).
- **R2.2 — préventif + compteurs** : `underContract` + `periodKey` (unicité `[assetId, periodKey]` = idempotence EN BASE), génération mensuelle (ancrage mise en service, premier contrôle, gabarits administrables, statut du mois), compteurs strictement croissants ; +7 opérations (48), 50 tests (94,7 %/78 %). Automatisation cron/BullMQ notée pour le durcissement production. - **R2.2 — préventif + compteurs** : `underContract` + `periodKey` (unicité `[assetId, periodKey]` = idempotence EN BASE), génération mensuelle (ancrage mise en service, premier contrôle, gabarits administrables, statut du mois), compteurs strictement croissants ; +7 opérations (48), 50 tests (94,7 %/78 %). Automatisation cron/BullMQ notée pour le durcissement production.
- **R2.3 — écrans web exploitation** : liste/fiche OT (transitions via `allowedTransitions`, garde visible, bilan codé, checklist cliquable, activité), demandes+approbation/rejet motivé, nouvel OT (interrupteur urgence), préventif (tuiles+générer+gabarits), compteurs, tableau de bord réel, urgence traversante (chip topbar, badges, bandeau) ; `GET /assets/options` (trou Demandeur corrigé) ; retry sur collision de référence dans la génération ; 9 Playwright verts (recette R2 complète), 50 tests API. - **R2.3 — écrans web exploitation** : liste/fiche OT (transitions via `allowedTransitions`, garde visible, bilan codé, checklist cliquable, activité), demandes+approbation/rejet motivé, nouvel OT (interrupteur urgence), préventif (tuiles+générer+gabarits), compteurs, tableau de bord réel, urgence traversante (chip topbar, badges, bandeau) ; `GET /assets/options` (trou Demandeur corrigé) ; retry sur collision de référence dans la génération ; 9 Playwright verts (recette R2 complète), 50 tests API.
- **R2.4 — portail public QR** : `/q/{réf}` sans compte (écran validé R0, mobile d'abord), 3 routes publiques `/portal` throttlées, suivi par jeton opaque (`Request.publicToken`, jamais de liste exposée), étapes Reçu Intervention Résolu dérivées de l'OT ; boucle produit complète en e2e (11 Playwright verts, 52 opérations). - 🏁 **R2 CLOSE (16/07/2026, tag `release/r2`)** : recettée (1 anomalie corrigée en recette : tri « Interventions récentes »), déployée et vérifiée en ligne (portail `/q/A1`, urgence en tête, préventif de juillet généré).
- 🔄 **R2reste pour clore** : recette R2 avec le référent (revue pixel maquettes R0+R2, sur `pnpm dev` ou l'instance en ligne après déploiement), tag `release/r2` ouverture R3 Gestion (stock, BC, tiers, main-d'œuvre, analytics, bibliothèque maquettes à produire d'abord). - **R3maquettes validées** (16/07) + **R3.1 socle backend gestion** : migration `r3_gestion` (Partner, Part sans colonne de quantité, StockMovement signé/tracé/PU figé, PurchaseOrder, LaborTime taux figé, Document, User.hourlyRate) ; 66 opérations ; stock = Σ mouvements (jamais négatif, en transaction), réception BC RECEIPT + lastUnitPrice, conso/MO à prix/taux FIGÉS, `WorkOrderDetail.costs` immuable après clôture ; seed maquette (OT-0341 = 505 MAD, testé) ; 55 tests (92 %/74,9 %). **Durcissement : références par séquences Postgres** (fin des courses max+1).
- **R3.2 — bibliothèque + analytics** : `FileStorage` complet (put/stream/remove, bucket auto), upload multipart typé (20 Mo, rattachement requis, permission sur la cible), download **streamé par l'API**, octets vérifiés à l'identique en e2e ; `GET /analytics/summary` dérivé du réel (pannes par organe via bilans codés, coûts figés, taux préventif, top équipements) ; MinIO en CI ; 71 opérations, 58 tests (6 runs consécutifs verts).
- **R3.3** : 7 écrans web fidèles à maquette-r3 (stock/alertes, fiche pièce, BC + réception, création BC préremplie depuis l'alerte, tiers, bibliothèque, statistiques) + fiche OT complète (coûts réels : consommer à prix figé / saisir temps à taux figé ; carte Documents) + fiche ascenseur (Documents) + taux horaire dans Personnes + nav Ressources/Statistiques activée par la matrice. Recette R3 automatisée (13/13 e2e Playwright).
- **R3.4 — corrections de recette + recherche globale** (17/07) : revue pixel (artefact) 8 écarts corrigés sur arbitrage du référent (filtre fournisseur + tri sous-seuil + entrée de stock en liste, fournisseur cliquable, sélecteur de période 3/6/12 sur les stats, rattachements syndicsite via `Location.partnerId` (migration `r3_recette_fixes`), filtre « Rattaché à » + glisser-déposer en bibliothèque, aperçus/ouverture des documents) ; **recherche globale ⌘K** (`GET /search`, 73 opérations, familles filtrées par la matrice, « voir autre » respecté). 74 tests API, 14/14 Playwright.
- 🏁 **R3 CLOSE (17/07/2026, tag `release/r3`)** : recettée par le référent (revue pixel + 8 corrections + recherche globale, CI verte). Reste : redéploiement Dokploy (manuel, webhook CD absent) et vérification en ligne.
- **R4 — maquettes rédigées** (17/07) : `maquette-r4.html`, 7 écrans mobile technicien offline-first (Ma journée bi-état, fiche OT, clôture terrain avec garde, scan QR des étiquettes R1, fiche ascenseur, checklist en file, synchro & conflits à verrou optimiste) + 5 décisions à acter (offline-first en file, verrou optimiste tranché par l'humain, périmètre fermé technicien, scan local, photos en file ni audio ni géoloc en R4).
- **R4 — maquettes + décisions D1-D5 VALIDÉES par le référent (17/07)** ; **R4.1 socle mobile** : `apps/mobile` (Expo SDK 57, TS strict), connexion + sélecteur démo ADR-002, tabbar (onglets futurs marqués), « Ma journée » triée priorité/échéance, cache TanStack persisté (lecture hors-ligne D1), jeton SecureStore, client typé du contrat, `CORS_ORIGINS` opt-in côté API (Expo web/debug seulement), 6 tests jest-expo + job CI `mobile`. Vérifié 10/10 en Expo web (connexion démo Ma journée hors-ligne/retour).
- **R4.2 — terrain en ligne** : scanner QR réel (expo-camera, `analyseScan` testée, résolution locale D4, QR étrangers refusés), fiche ascenseur (D3, historique scopé), fiche OT (machine à états, coûts figés, garde par `closureBlockers` API), clôture terrain (bilan 6 champs au pouce), préventif grille cochable (appui long = N/A, `aria-checked`). Écritures en ligne assumées (bandeaux) la file est R4.3. Vérifié 12/12 en Expo web (dont clôture complète d'un OT de test), 12 tests jest-expo.
- **R4.3 — file & verrou** : verrou optimiste serveur (`baseUpdatedAt` 409 contextualisé, toute écriture avance la version, web inchangé, ADR-003 sécurité/routage mobile) ; file persistée rejouée dans l'ordre (propagation de version intra-lot, arrêt sur conflit, patchs optimistes, photos D5 compressées en file), écran Synchro & conflits (rejouer sur version à jour / abandonner), préchargement parc+référentiels, purge complète à la déconnexion. Recette « mode avion » 13/13 en Expo web (conflit tranché par l'humain, serveur DONE + bilan), 17 tests jest-expo, 74 tests API.
- 🏁 **R4 CLOSE (17/07/2026, tag `release/r4`)** : posé sur décision du référent avec la recette « mode avion » validée 13/13 en Expo web piloté ; **la recette sur téléphone (Expo Go — vrai mode avion, scan caméra) est reportée et reste due avant toute production client mobile**.
- 🔄 **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`.
- Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0R5. - Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0R5.

View File

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

View File

@@ -40,6 +40,7 @@
"@nestjs/testing": "^11.1.0", "@nestjs/testing": "^11.1.0",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"@types/multer": "^2.2.0",
"@types/node": "^24.0.0", "@types/node": "^24.0.0",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"jest": "^29.7.0", "jest": "^29.7.0",

View File

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

View File

@@ -0,0 +1,191 @@
-- CreateEnum
CREATE TYPE "PartnerKind" AS ENUM ('SUPPLIER', 'CLIENT');
-- CreateEnum
CREATE TYPE "PurchaseOrderStatus" AS ENUM ('DRAFT', 'SENT', 'RECEIVED', 'CANCELLED');
-- CreateEnum
CREATE TYPE "StockMovementKind" AS ENUM ('RECEIPT', 'ENTRY', 'CONSUMPTION', 'ADJUSTMENT');
-- CreateEnum
CREATE TYPE "DocumentKind" AS ENUM ('NOTICE', 'CERTIFICATE', 'PHOTO', 'OTHER');
-- AlterTable
ALTER TABLE "User" ADD COLUMN "hourlyRate" DECIMAL(8,2);
-- CreateTable
CREATE TABLE "Partner" (
"id" UUID NOT NULL,
"name" TEXT NOT NULL,
"kind" "PartnerKind" NOT NULL,
"contactName" TEXT,
"phone" TEXT,
"email" TEXT,
"city" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Partner_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Part" (
"id" UUID NOT NULL,
"reference" TEXT NOT NULL,
"designation" TEXT NOT NULL,
"threshold" INTEGER NOT NULL DEFAULT 0,
"lastUnitPrice" DECIMAL(10,2),
"compatible" TEXT,
"supplierId" UUID,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Part_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "StockMovement" (
"id" UUID NOT NULL,
"partId" UUID NOT NULL,
"kind" "StockMovementKind" NOT NULL,
"quantity" INTEGER NOT NULL,
"unitPrice" DECIMAL(10,2),
"reason" TEXT,
"workOrderId" UUID,
"purchaseOrderId" UUID,
"byId" UUID,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "StockMovement_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PurchaseOrder" (
"id" UUID NOT NULL,
"reference" TEXT NOT NULL,
"status" "PurchaseOrderStatus" NOT NULL DEFAULT 'DRAFT',
"supplierId" UUID NOT NULL,
"sentAt" TIMESTAMP(3),
"receivedAt" TIMESTAMP(3),
"cancelledAt" TIMESTAMP(3),
"createdById" UUID,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PurchaseOrder_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PurchaseOrderLine" (
"id" UUID NOT NULL,
"purchaseOrderId" UUID NOT NULL,
"partId" UUID NOT NULL,
"quantity" INTEGER NOT NULL,
"unitPrice" DECIMAL(10,2) NOT NULL,
CONSTRAINT "PurchaseOrderLine_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "LaborTime" (
"id" UUID NOT NULL,
"workOrderId" UUID NOT NULL,
"userId" UUID NOT NULL,
"minutes" INTEGER NOT NULL,
"hourlyRate" DECIMAL(8,2) NOT NULL,
"note" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "LaborTime_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Document" (
"id" UUID NOT NULL,
"kind" "DocumentKind" NOT NULL,
"fileName" TEXT NOT NULL,
"storageKey" TEXT NOT NULL,
"size" INTEGER NOT NULL,
"contentType" TEXT NOT NULL,
"assetId" UUID,
"workOrderId" UUID,
"uploadedById" UUID,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Document_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Partner_name_key" ON "Partner"("name");
-- CreateIndex
CREATE UNIQUE INDEX "Part_reference_key" ON "Part"("reference");
-- CreateIndex
CREATE INDEX "StockMovement_partId_idx" ON "StockMovement"("partId");
-- CreateIndex
CREATE INDEX "StockMovement_workOrderId_idx" ON "StockMovement"("workOrderId");
-- CreateIndex
CREATE UNIQUE INDEX "PurchaseOrder_reference_key" ON "PurchaseOrder"("reference");
-- CreateIndex
CREATE INDEX "PurchaseOrderLine_purchaseOrderId_idx" ON "PurchaseOrderLine"("purchaseOrderId");
-- CreateIndex
CREATE INDEX "LaborTime_workOrderId_idx" ON "LaborTime"("workOrderId");
-- CreateIndex
CREATE UNIQUE INDEX "Document_storageKey_key" ON "Document"("storageKey");
-- CreateIndex
CREATE INDEX "Document_assetId_idx" ON "Document"("assetId");
-- CreateIndex
CREATE INDEX "Document_workOrderId_idx" ON "Document"("workOrderId");
-- AddForeignKey
ALTER TABLE "Part" ADD CONSTRAINT "Part_supplierId_fkey" FOREIGN KEY ("supplierId") REFERENCES "Partner"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StockMovement" ADD CONSTRAINT "StockMovement_partId_fkey" FOREIGN KEY ("partId") REFERENCES "Part"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StockMovement" ADD CONSTRAINT "StockMovement_workOrderId_fkey" FOREIGN KEY ("workOrderId") REFERENCES "WorkOrder"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StockMovement" ADD CONSTRAINT "StockMovement_purchaseOrderId_fkey" FOREIGN KEY ("purchaseOrderId") REFERENCES "PurchaseOrder"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StockMovement" ADD CONSTRAINT "StockMovement_byId_fkey" FOREIGN KEY ("byId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PurchaseOrder" ADD CONSTRAINT "PurchaseOrder_supplierId_fkey" FOREIGN KEY ("supplierId") REFERENCES "Partner"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PurchaseOrder" ADD CONSTRAINT "PurchaseOrder_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PurchaseOrderLine" ADD CONSTRAINT "PurchaseOrderLine_purchaseOrderId_fkey" FOREIGN KEY ("purchaseOrderId") REFERENCES "PurchaseOrder"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PurchaseOrderLine" ADD CONSTRAINT "PurchaseOrderLine_partId_fkey" FOREIGN KEY ("partId") REFERENCES "Part"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LaborTime" ADD CONSTRAINT "LaborTime_workOrderId_fkey" FOREIGN KEY ("workOrderId") REFERENCES "WorkOrder"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LaborTime" ADD CONSTRAINT "LaborTime_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Document" ADD CONSTRAINT "Document_assetId_fkey" FOREIGN KEY ("assetId") REFERENCES "Asset"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Document" ADD CONSTRAINT "Document_workOrderId_fkey" FOREIGN KEY ("workOrderId") REFERENCES "WorkOrder"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Document" ADD CONSTRAINT "Document_uploadedById_fkey" FOREIGN KEY ("uploadedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,16 @@
-- Références OT/DEM/BC/P : séquences Postgres — la concurrence ne peut plus
-- produire de collision (leçon des générations parallèles).
-- La numérotation ne se remet pas à zéro chaque année : l'unicité prime.
CREATE SEQUENCE IF NOT EXISTS "work_order_ref_seq";
CREATE SEQUENCE IF NOT EXISTS "request_ref_seq";
CREATE SEQUENCE IF NOT EXISTS "purchase_order_ref_seq";
CREATE SEQUENCE IF NOT EXISTS "part_ref_seq";
SELECT setval('work_order_ref_seq',
GREATEST((SELECT COALESCE(MAX(SUBSTRING("reference" FROM '\d+$')::int), 0) FROM "WorkOrder"), 1000));
SELECT setval('request_ref_seq',
GREATEST((SELECT COALESCE(MAX(SUBSTRING("reference" FROM '\d+$')::int), 0) FROM "Request"), 1000));
SELECT setval('purchase_order_ref_seq',
GREATEST((SELECT COALESCE(MAX(SUBSTRING("reference" FROM '\d+$')::int), 0) FROM "PurchaseOrder"), 1000));
SELECT setval('part_ref_seq',
GREATEST((SELECT COALESCE(MAX(SUBSTRING("reference" FROM '\d+$')::int), 0) FROM "Part"), 1000));

View File

@@ -0,0 +1,8 @@
-- AlterTable
ALTER TABLE "Location" ADD COLUMN "partnerId" UUID;
-- CreateIndex
CREATE INDEX "Location_partnerId_idx" ON "Location"("partnerId");
-- AddForeignKey
ALTER TABLE "Location" ADD CONSTRAINT "Location_partnerId_fkey" FOREIGN KEY ("partnerId") REFERENCES "Partner"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -56,6 +56,12 @@ model User {
requests Request[] requests Request[]
checklistDone ChecklistItem[] checklistDone ChecklistItem[]
meterReadings MeterReading[] meterReadings MeterReading[]
// R3 — gestion : taux horaire COURANT (le taux d'une saisie est figé dans LaborTime)
hourlyRate Decimal? @db.Decimal(8, 2)
laborTimes LaborTime[]
stockMovements StockMovement[]
purchaseOrders PurchaseOrder[]
documents Document[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -101,11 +107,15 @@ model Location {
longitude Float? longitude Float?
// + colonne PostGIS générée (voir migration r1_referentiel) : // + colonne PostGIS générée (voir migration r1_referentiel) :
// position geography(Point,4326) GENERATED ALWAYS AS (…) STORED // position geography(Point,4326) GENERATED ALWAYS AS (…) STORED
// Client/syndic gérant le site (R3 — colonne « Rattachements » des Tiers)
partnerId String? @db.Uuid
partner Partner? @relation(fields: [partnerId], references: [id])
assets Asset[] assets Asset[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@index([parentId]) @@index([parentId])
@@index([partnerId])
} }
model Asset { model Asset {
@@ -127,6 +137,7 @@ model Asset {
workOrders WorkOrder[] workOrders WorkOrder[]
requests Request[] requests Request[]
meters Meter[] meters Meter[]
documents Document[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -225,6 +236,9 @@ model WorkOrder {
checklist ChecklistItem[] checklist ChecklistItem[]
report InterventionReport? report InterventionReport?
request Request? request Request?
stockMovements StockMovement[]
laborTimes LaborTime[]
documents Document[]
// Grille du mois : « AAAA-MM » — l'unicité [assetId, periodKey] EST // Grille du mois : « AAAA-MM » — l'unicité [assetId, periodKey] EST
// l'idempotence de la génération (les OT correctifs restent à null). // l'idempotence de la génération (les OT correctifs restent à null).
periodKey String? periodKey String?
@@ -358,3 +372,148 @@ model MeterReading {
@@index([meterId]) @@index([meterId])
} }
// ————— R3 — Gestion (docs/03-architecture/modele-donnees.md §R3) —————
enum PartnerKind {
SUPPLIER
CLIENT // syndic / propriétaire
}
enum PurchaseOrderStatus {
DRAFT
SENT
RECEIVED
CANCELLED
}
enum StockMovementKind {
RECEIPT // réception de BC (+, PU figé)
ENTRY // entrée manuelle (+)
CONSUMPTION // consommation d'OT (, PU figé)
ADJUSTMENT // inventaire (±, motif REQUIS)
}
enum DocumentKind {
NOTICE
CERTIFICATE
PHOTO
OTHER
}
model Partner {
id String @id @default(uuid()) @db.Uuid
name String @unique
kind PartnerKind
contactName String?
phone String?
email String?
city String?
isActive Boolean @default(true)
parts Part[]
purchaseOrders PurchaseOrder[]
sites Location[] // sites gérés (kind CLIENT)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Part {
id String @id @default(uuid()) @db.Uuid
reference String @unique // P-0113
designation String
threshold Int @default(0) // seuil d'alerte
// Dernier prix d'achat — figé sur chaque mouvement au moment T.
lastUnitPrice Decimal? @db.Decimal(10, 2)
compatible String?
supplierId String? @db.Uuid
supplier Partner? @relation(fields: [supplierId], references: [id])
isActive Boolean @default(true)
movements StockMovement[]
orderLines PurchaseOrderLine[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// Le stock EST la somme des mouvements — aucune colonne de quantité.
model StockMovement {
id String @id @default(uuid()) @db.Uuid
partId String @db.Uuid
part Part @relation(fields: [partId], references: [id], onDelete: Cascade)
kind StockMovementKind
quantity Int // signé : + entrée, sortie
unitPrice Decimal? @db.Decimal(10, 2) // figé (réception, consommation)
reason String? // ajustement : motif requis (service)
workOrderId String? @db.Uuid
workOrder WorkOrder? @relation(fields: [workOrderId], references: [id])
purchaseOrderId String? @db.Uuid
purchaseOrder PurchaseOrder? @relation(fields: [purchaseOrderId], references: [id])
byId String? @db.Uuid
by User? @relation(fields: [byId], references: [id])
createdAt DateTime @default(now())
@@index([partId])
@@index([workOrderId])
}
model PurchaseOrder {
id String @id @default(uuid()) @db.Uuid
reference String @unique // BC-2026-0024
status PurchaseOrderStatus @default(DRAFT)
supplierId String @db.Uuid
supplier Partner @relation(fields: [supplierId], references: [id])
lines PurchaseOrderLine[]
movements StockMovement[]
sentAt DateTime?
receivedAt DateTime?
cancelledAt DateTime?
createdById String? @db.Uuid
createdBy User? @relation(fields: [createdById], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model PurchaseOrderLine {
id String @id @default(uuid()) @db.Uuid
purchaseOrderId String @db.Uuid
purchaseOrder PurchaseOrder @relation(fields: [purchaseOrderId], references: [id], onDelete: Cascade)
partId String @db.Uuid
part Part @relation(fields: [partId], references: [id])
quantity Int
unitPrice Decimal @db.Decimal(10, 2)
@@index([purchaseOrderId])
}
// Main-d'œuvre : le taux est FIGÉ à la saisie (le coût d'un OT ne bouge plus).
model LaborTime {
id String @id @default(uuid()) @db.Uuid
workOrderId String @db.Uuid
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
userId String @db.Uuid
user User @relation(fields: [userId], references: [id])
minutes Int
hourlyRate Decimal @db.Decimal(8, 2)
note String?
createdAt DateTime @default(now())
@@index([workOrderId])
}
model Document {
id String @id @default(uuid()) @db.Uuid
kind DocumentKind
fileName String
storageKey String @unique // clé MinIO (FileStorage)
size Int
contentType String
assetId String? @db.Uuid
asset Asset? @relation(fields: [assetId], references: [id])
workOrderId String? @db.Uuid
workOrder WorkOrder? @relation(fields: [workOrderId], references: [id])
uploadedById String? @db.Uuid
uploadedBy User? @relation(fields: [uploadedById], references: [id])
createdAt DateTime @default(now())
@@index([assetId])
@@index([workOrderId])
}

View File

@@ -134,6 +134,7 @@ export async function seed(prisma: PrismaClient): Promise<void> {
await seedUsers(prisma, roleIds, passwordHash); await seedUsers(prisma, roleIds, passwordHash);
await seedReferentiel(prisma); await seedReferentiel(prisma);
await seedExploitation(prisma); await seedExploitation(prisma);
await seedGestion(prisma);
} }
async function seedUsers( async function seedUsers(
@@ -580,6 +581,188 @@ async function seedExploitation(prisma: PrismaClient): Promise<void> {
} }
} }
// ————— R3 — Gestion (tiers, pièces, mouvements, BC, taux — données maquette) —————
const PARTNERS: {
name: string; kind: 'SUPPLIER' | 'CLIENT'; contactName?: string;
phone?: string; email?: string; city?: string;
}[] = [
{ name: 'Ascentech Maroc', kind: 'SUPPLIER', contactName: 'M. Berrini', phone: '05 22 34 56 78', city: 'Casablanca' },
{ name: 'SchindlerParts', kind: 'SUPPLIER', email: 'commandes@schindlerparts.ma' },
{ name: 'Lubmaroc', kind: 'SUPPLIER', phone: '05 22 11 22 33' },
{ name: 'Atlas Property Management', kind: 'CLIENT', contactName: 'Mme Zerhouni', phone: '06 61 98 76 54' },
{ name: 'Syndic Al Manar', kind: 'CLIENT', contactName: 'M. Alami (gardien référent)' },
];
const PARTS: {
reference: string; designation: string; threshold: number;
lastUnitPrice?: number; supplier?: string; compatible?: string;
}[] = [
{ reference: 'P-0019', designation: 'Cellule barrière porte (paire)', threshold: 4, lastUnitPrice: 640, supplier: 'SchindlerParts' },
{ reference: 'P-0031', designation: 'Bouton palier lumineux Ø22', threshold: 10, lastUnitPrice: 45, supplier: 'Ascentech Maroc' },
{ reference: 'P-0042', designation: 'Graisse guide (cartouche 400 g)', threshold: 8, lastUnitPrice: 85, supplier: 'Lubmaroc' },
{ reference: 'P-0087', designation: 'Coulisseau de guide 16 mm', threshold: 6, lastUnitPrice: 120, supplier: 'Ascentech Maroc' },
{ reference: 'P-0113', designation: 'Contact de porte NC-31', threshold: 5, lastUnitPrice: 85, supplier: 'Ascentech Maroc', compatible: 'Otis Gen2 · Schindler 3300' },
];
async function seedGestion(prisma: PrismaClient): Promise<void> {
const partnerIds = new Map<string, string>();
for (const p of PARTNERS) {
const partner = await prisma.partner.upsert({
where: { name: p.name },
update: {},
create: p,
});
partnerIds.set(p.name, partner.id);
}
// Rattachements de la maquette Tiers : les syndics gèrent leur site.
for (const [site, partner] of [
['Tour Atlas', 'Atlas Property Management'],
['Résidence Al Manar', 'Syndic Al Manar'],
] as const) {
await prisma.location.updateMany({
where: { name: site, parentId: null, partnerId: null },
data: { partnerId: partnerIds.get(partner) },
});
}
const partIds = new Map<string, string>();
for (const p of PARTS) {
const part = await prisma.part.upsert({
where: { reference: p.reference },
update: {},
create: {
reference: p.reference,
designation: p.designation,
threshold: p.threshold,
lastUnitPrice: p.lastUnitPrice,
compatible: p.compatible,
supplierId: p.supplier ? partnerIds.get(p.supplier) : undefined,
},
});
partIds.set(p.reference, part.id);
}
// Taux horaires courants (les saisies figent leur propre taux)
for (const [email, taux] of [
['technicien@demo.siop.ma', 120],
['technicien-limite@demo.siop.ma', 90],
] as const) {
await prisma.user.update({ where: { email }, data: { hourlyRate: taux } });
}
// Idempotence : si des mouvements existent déjà, l'histoire est en place
if ((await prisma.stockMovement.count()) > 0) return;
const annee = new Date().getFullYear();
const ahmed = await prisma.user.findUniqueOrThrow({ where: { email: 'technicien@demo.siop.ma' } });
const nadia = await prisma.user.findUniqueOrThrow({ where: { email: 'gestionnaire@demo.siop.ma' } });
// BC reçus (historique) et BC en cours — comme la maquette
const bc21 = await prisma.purchaseOrder.create({
data: {
reference: `BC-${annee}-0021`,
status: 'RECEIVED',
supplierId: partnerIds.get('Ascentech Maroc')!,
createdById: nadia.id,
sentAt: new Date(Date.now() - 40 * 86400e3),
receivedAt: new Date(Date.now() - 34 * 86400e3),
lines: {
create: [
{ partId: partIds.get('P-0113')!, quantity: 5, unitPrice: 85 },
{ partId: partIds.get('P-0087')!, quantity: 6, unitPrice: 120 },
{ partId: partIds.get('P-0031')!, quantity: 20, unitPrice: 45 },
],
},
},
include: { lines: true },
});
await prisma.purchaseOrder.create({
data: {
reference: `BC-${annee}-0024`,
status: 'SENT',
supplierId: partnerIds.get('Ascentech Maroc')!,
createdById: nadia.id,
sentAt: new Date(),
lines: {
create: [
{ partId: partIds.get('P-0113')!, quantity: 10, unitPrice: 85 },
{ partId: partIds.get('P-0087')!, quantity: 2, unitPrice: 127.5 },
],
},
},
});
// Mouvements : réceptions du BC-0021 + entrées initiales + consommations
for (const line of bc21.lines) {
await prisma.stockMovement.create({
data: {
partId: line.partId,
kind: 'RECEIPT',
quantity: line.quantity,
unitPrice: line.unitPrice,
purchaseOrderId: bc21.id,
byId: nadia.id,
createdAt: new Date(Date.now() - 34 * 86400e3),
},
});
}
for (const [ref, qte] of [
['P-0042', 19],
['P-0019', 7],
['P-0031', 6],
] as const) {
await prisma.stockMovement.create({
data: {
partId: partIds.get(ref)!,
kind: 'ENTRY',
quantity: qte,
reason: 'Reprise de linventaire initial',
byId: nadia.id,
createdAt: new Date(Date.now() - 60 * 86400e3),
},
});
}
// P-0113 : consommations + ajustement → stock 2 (sous le seuil, maquette)
const ot341 = await prisma.workOrder.findUnique({
where: { reference: `OT-${annee}-0341` },
});
await prisma.stockMovement.createMany({
data: [
{ partId: partIds.get('P-0113')!, kind: 'ADJUSTMENT', quantity: -1, reason: 'Inventaire — pièce endommagée', byId: nadia.id },
{ partId: partIds.get('P-0113')!, kind: 'CONSUMPTION', quantity: -2, unitPrice: 85, byId: ahmed.id },
],
});
// P-0087 : consommation sur OT-0341 (carte maquette : 2 × 120 = 240 MAD)
if (ot341) {
await prisma.stockMovement.create({
data: {
partId: partIds.get('P-0087')!,
kind: 'CONSUMPTION',
quantity: -2,
unitPrice: 120,
workOrderId: ot341.id,
byId: ahmed.id,
},
});
await prisma.stockMovement.create({
data: {
partId: partIds.get('P-0042')!,
kind: 'CONSUMPTION',
quantity: -1,
unitPrice: 85,
workOrderId: ot341.id,
byId: ahmed.id,
},
});
// main-d'œuvre : 1 h 30 × 120 MAD/h = 180 (total maquette : 505 MAD)
await prisma.laborTime.create({
data: { workOrderId: ot341.id, userId: ahmed.id, minutes: 90, hourlyRate: 120 },
});
}
}
/* c8 ignore start — wrapper CLI */ /* c8 ignore start — wrapper CLI */
if (require.main === module) { if (require.main === module) {
const prisma = new PrismaClient(); const prisma = new PrismaClient();

View File

@@ -0,0 +1,14 @@
import { Controller, Get, Query } from '@nestjs/common';
import { RequirePermission } from '../permissions/require-permission.decorator';
import { AnalyticsService } from './analytics.service';
@Controller('analytics')
export class AnalyticsController {
constructor(private readonly analytics: AnalyticsService) {}
@Get('summary')
@RequirePermission('ANALYTICS', 'view')
summary(@Query('months') months?: string) {
return this.analytics.summary(months);
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { AnalyticsController } from './analytics.controller';
import { AnalyticsService } from './analytics.service';
@Module({
controllers: [AnalyticsController],
providers: [AnalyticsService],
})
export class AnalyticsModule {}

View File

@@ -0,0 +1,201 @@
import { Injectable } from '@nestjs/common';
import { ANALYTICS_PERIODS, type AnalyticsPeriod, type AnalyticsSummary } from '@siop/shared';
import { PrismaService } from '../prisma/prisma.service';
const debutMois = (decalage: number): Date => {
const d = new Date();
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() - decalage, 1));
};
/** Le tableau de la direction — tout est DÉRIVÉ des données réelles :
* mouvements (prix figés), main-d'œuvre (taux figés), bilans codés. */
@Injectable()
export class AnalyticsService {
constructor(private readonly prisma: PrismaService) {}
async summary(monthsRaw?: string): Promise<AnalyticsSummary> {
// Période demandée par l'écran (3, 6 ou 12 mois) — 12 par défaut.
const months: AnalyticsPeriod = ANALYTICS_PERIODS.find(
(p) => p === Number(monthsRaw),
) ?? 12;
const depuisPeriode = debutMois(months - 1);
const [consommations, mainOeuvre, clos12m, grilles12m, correctifsClos, pannes, topDonnees] =
await Promise.all([
this.prisma.stockMovement.findMany({
where: { kind: 'CONSUMPTION', createdAt: { gte: depuisPeriode } },
select: { quantity: true, unitPrice: true, createdAt: true },
}),
this.prisma.laborTime.findMany({
where: { createdAt: { gte: depuisPeriode } },
select: { minutes: true, hourlyRate: true, createdAt: true },
}),
this.prisma.workOrder.groupBy({
by: ['type'],
where: { status: 'DONE', completedAt: { gte: depuisPeriode } },
_count: { _all: true },
}),
this.prisma.workOrder.findMany({
where: { periodKey: { not: null }, createdAt: { gte: depuisPeriode } },
select: { status: true },
}),
this.prisma.workOrder.findMany({
where: { type: 'CORRECTIVE', status: 'DONE', completedAt: { gte: depuisPeriode } },
select: { createdAt: true, completedAt: true },
}),
this.prisma.interventionReport.groupBy({
by: ['componentConcernedId'],
where: {
componentConcernedId: { not: null },
workOrder: { status: 'DONE', type: 'CORRECTIVE', completedAt: { gte: depuisPeriode } },
},
_count: { _all: true },
}),
this.prisma.stockMovement.findMany({
where: { kind: 'CONSUMPTION', workOrderId: { not: null }, createdAt: { gte: depuisPeriode } },
select: {
quantity: true,
unitPrice: true,
workOrder: {
select: {
type: true,
asset: {
select: {
reference: true,
location: { select: { name: true, parent: { select: { name: true } } } },
},
},
},
},
},
}),
]);
// Coûts par mois (6 derniers) — pièces + main-d'œuvre, prix/taux figés
const cleMois = (d: Date) => d.toISOString().slice(0, 7);
const parMois = new Map<string, number>();
for (let i = months - 1; i >= 0; i--) parMois.set(cleMois(debutMois(i)), 0);
for (const c of consommations) {
const cle = cleMois(c.createdAt);
if (parMois.has(cle)) {
parMois.set(cle, parMois.get(cle)! + -c.quantity * Number(c.unitPrice ?? 0));
}
}
for (const l of mainOeuvre) {
const cle = cleMois(l.createdAt);
if (parMois.has(cle)) {
parMois.set(cle, parMois.get(cle)! + (l.minutes / 60) * Number(l.hourlyRate));
}
}
const costsByMonth = [...parMois.entries()].map(([month, total]) => ({
month,
total: Math.round(total * 100) / 100,
}));
const moisCourant = cleMois(new Date());
const moisPrecedent = cleMois(debutMois(1));
// Pannes par organe : depuis les bilans codés
const labels = await this.prisma.referenceValue.findMany({
where: { id: { in: pannes.map((p) => p.componentConcernedId!).filter(Boolean) } },
});
const labelDe = new Map(labels.map((l) => [l.id, l.label]));
const failuresByComponent = pannes
.map((p) => ({
label: labelDe.get(p.componentConcernedId!) ?? '—',
count: p._count._all,
}))
.sort((a, b) => b.count - a.count)
.slice(0, 6);
// Top équipements en coût (pièces via OT + main-d'œuvre)
const laborParOT = await this.prisma.laborTime.findMany({
where: { createdAt: { gte: depuisPeriode } },
select: {
minutes: true,
hourlyRate: true,
workOrder: {
select: {
type: true,
asset: {
select: {
reference: true,
location: { select: { name: true, parent: { select: { name: true } } } },
},
},
},
},
},
});
interface Cumul { siteName: string; correctives: number; partsCost: number; laborCost: number }
const parAppareil = new Map<string, Cumul>();
const cumul = (ref: string, siteName: string): Cumul => {
if (!parAppareil.has(ref)) {
parAppareil.set(ref, { siteName, correctives: 0, partsCost: 0, laborCost: 0 });
}
return parAppareil.get(ref)!;
};
for (const m of topDonnees) {
const asset = m.workOrder!.asset;
const c = cumul(asset.reference, asset.location.parent?.name ?? asset.location.name);
c.partsCost += -m.quantity * Number(m.unitPrice ?? 0);
}
for (const l of laborParOT) {
const asset = l.workOrder.asset;
const c = cumul(asset.reference, asset.location.parent?.name ?? asset.location.name);
c.laborCost += (l.minutes / 60) * Number(l.hourlyRate);
}
const correctivesParAppareil = await this.prisma.workOrder.groupBy({
by: ['assetId'],
where: { type: 'CORRECTIVE', createdAt: { gte: depuisPeriode } },
_count: { _all: true },
});
const assetsRefs = await this.prisma.asset.findMany({
where: { id: { in: correctivesParAppareil.map((c) => c.assetId) } },
select: { id: true, reference: true },
});
const refDe = new Map(assetsRefs.map((a) => [a.id, a.reference]));
for (const c of correctivesParAppareil) {
const ref = refDe.get(c.assetId);
if (ref && parAppareil.has(ref)) parAppareil.get(ref)!.correctives = c._count._all;
}
const topAssets = [...parAppareil.entries()]
.map(([reference, c]) => ({
reference,
siteName: c.siteName,
correctives: c.correctives,
partsCost: Math.round(c.partsCost * 100) / 100,
laborCost: Math.round(c.laborCost * 100) / 100,
total: Math.round((c.partsCost + c.laborCost) * 100) / 100,
}))
.sort((a, b) => b.total - a.total)
.slice(0, 5);
const totalClos = clos12m.reduce((s, g) => s + g._count._all, 0);
const preventifsClos = clos12m.find((g) => g.type === 'PREVENTIVE')?._count._all ?? 0;
const grillesTerminees = grilles12m.filter((g) => g.status === 'DONE').length;
return {
months,
monthCost: costsByMonth.find((c) => c.month === moisCourant)?.total ?? 0,
previousMonthCost: costsByMonth.find((c) => c.month === moisPrecedent)?.total ?? 0,
closed: { total: totalClos, preventive: preventifsClos },
preventiveRate: grilles12m.length
? Math.round((grillesTerminees / grilles12m.length) * 100) / 100
: null,
avgResolutionDays: correctifsClos.length
? Math.round(
(correctifsClos.reduce(
(s, w) => s + (w.completedAt!.getTime() - w.createdAt.getTime()),
0,
) /
correctifsClos.length /
86400e3) *
10,
) / 10
: null,
failuresByComponent,
costsByMonth,
topAssets,
};
}
}

View File

@@ -1,6 +1,9 @@
import { DynamicModule, Module } from '@nestjs/common'; import { DynamicModule, Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core'; import { APP_GUARD } from '@nestjs/core';
import { AnalyticsModule } from './analytics/analytics.module';
import { SearchModule } from './search/search.module';
import { AssetsModule } from './assets/assets.module'; import { AssetsModule } from './assets/assets.module';
import { DocumentsModule } from './documents/documents.module';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
import { DemoAuthModule } from './auth/demo/demo-auth.module'; import { DemoAuthModule } from './auth/demo/demo-auth.module';
import { JwtAuthGuard } from './auth/jwt-auth.guard'; import { JwtAuthGuard } from './auth/jwt-auth.guard';
@@ -10,7 +13,10 @@ import { FilesModule } from './files/files.module';
import { HealthModule } from './health/health.module'; import { HealthModule } from './health/health.module';
import { LocationsModule } from './locations/locations.module'; import { LocationsModule } from './locations/locations.module';
import { MetersModule } from './meters/meters.module'; import { MetersModule } from './meters/meters.module';
import { PartnersModule } from './partners/partners.module';
import { PartsModule } from './parts/parts.module';
import { PortalModule } from './portal/portal.module'; import { PortalModule } from './portal/portal.module';
import { PurchaseOrdersModule } from './purchase-orders/purchase-orders.module';
import { PreventiveModule } from './preventive/preventive.module'; import { PreventiveModule } from './preventive/preventive.module';
import { ReferenceValuesModule } from './reference-values/reference-values.module'; import { ReferenceValuesModule } from './reference-values/reference-values.module';
import { RequestsModule } from './requests/requests.module'; import { RequestsModule } from './requests/requests.module';
@@ -50,6 +56,13 @@ export class AppModule {
PreventiveModule, PreventiveModule,
MetersModule, MetersModule,
PortalModule, PortalModule,
// R3 — gestion
PartnersModule,
PartsModule,
PurchaseOrdersModule,
DocumentsModule,
AnalyticsModule,
SearchModule,
// 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

@@ -13,6 +13,11 @@ const EnvSchema = z.object({
MINIO_USE_SSL: z.string().optional(), MINIO_USE_SSL: z.string().optional(),
MINIO_ACCESS_KEY: z.string().default('siop'), MINIO_ACCESS_KEY: z.string().default('siop'),
MINIO_SECRET_KEY: z.string().default('siop-minio'), MINIO_SECRET_KEY: z.string().default('siop-minio'),
MINIO_BUCKET: z.string().default('siop2'),
// R4 : origines navigateur autorisées en CORS (Expo web / debug mobile).
// Vide = aucun CORS (défaut sûr) — le web de prod passe par le proxy nginx
// même-origine, les apps natives n'envoient pas d'Origin.
CORS_ORIGINS: z.string().default(''),
}); });
export type Env = z.infer<typeof EnvSchema>; export type Env = z.infer<typeof EnvSchema>;

View File

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

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { DocumentsController } from './documents.controller';
import { DocumentsService } from './documents.service';
@Module({
controllers: [DocumentsController],
providers: [DocumentsService],
})
export class DocumentsModule {}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,45 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
} from '@nestjs/common';
import {
PartnerCreateSchema,
PartnerUpdateSchema,
type PartnerCreate,
type PartnerUpdate,
} from '@siop/shared';
import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { RequirePermission } from '../permissions/require-permission.decorator';
import { PartnersService } from './partners.service';
/** Les tiers vivent sous la permission PURCHASE_ORDERS (décision R3). */
@Controller('partners')
export class PartnersController {
constructor(private readonly partners: PartnersService) {}
@Get()
@RequirePermission('PURCHASE_ORDERS', 'view')
list() {
return this.partners.list();
}
@Post()
@RequirePermission('PURCHASE_ORDERS', 'create')
create(@Body(new ZodValidationPipe(PartnerCreateSchema)) body: PartnerCreate) {
return this.partners.create(body);
}
@Patch(':id')
@RequirePermission('PURCHASE_ORDERS', 'edit')
update(
@Param('id', ParseUUIDPipe) id: string,
@Body(new ZodValidationPipe(PartnerUpdateSchema)) body: PartnerUpdate,
) {
return this.partners.update(id, body);
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { PartnersController } from './partners.controller';
import { PartnersService } from './partners.service';
@Module({
controllers: [PartnersController],
providers: [PartnersService],
})
export class PartnersModule {}

View File

@@ -0,0 +1,91 @@
import {
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import type {
PartnerCreate,
PartnerDto,
PartnersResponse,
PartnerUpdate,
} from '@siop/shared';
import { PrismaService } from '../prisma/prisma.service';
const partnerInclude = {
_count: {
select: {
purchaseOrders: { where: { status: { in: ['DRAFT', 'SENT'] } } },
},
},
// Rattachements de la maquette : sites (jamais les zones) gérés par un client/syndic.
sites: {
where: { parentId: null },
select: { name: true },
orderBy: { name: 'asc' as const },
},
} satisfies Prisma.PartnerInclude;
type Row = Prisma.PartnerGetPayload<{ include: typeof partnerInclude }>;
@Injectable()
export class PartnersService {
constructor(private readonly prisma: PrismaService) {}
async list(): Promise<PartnersResponse> {
const rows = await this.prisma.partner.findMany({
include: partnerInclude,
orderBy: [{ kind: 'asc' }, { name: 'asc' }],
});
return { partners: rows.map((r) => this.toDto(r)) };
}
async create(dto: PartnerCreate): Promise<PartnerDto> {
try {
const created = await this.prisma.partner.create({
data: dto,
include: partnerInclude,
});
return this.toDto(created);
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new ConflictException('Ce nom de tiers existe déjà');
}
throw e;
}
}
async update(id: string, dto: PartnerUpdate): Promise<PartnerDto> {
try {
const updated = await this.prisma.partner.update({
where: { id },
data: dto,
include: partnerInclude,
});
return this.toDto(updated);
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') {
throw new NotFoundException('Tiers inconnu');
}
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new ConflictException('Ce nom de tiers existe déjà');
}
throw e;
}
}
private toDto(row: Row): PartnerDto {
return {
id: row.id,
name: row.name,
kind: row.kind,
contactName: row.contactName,
phone: row.phone,
email: row.email,
city: row.city,
isActive: row.isActive,
openOrders: row._count.purchaseOrders,
siteNames: row.sites.map((s) => s.name),
};
}
}

View File

@@ -0,0 +1,66 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
} from '@nestjs/common';
import {
PartCreateSchema,
PartUpdateSchema,
StockMovementCreateSchema,
type PartCreate,
type PartUpdate,
type StockMovementCreate,
} from '@siop/shared';
import {
AuthenticatedUser,
CurrentUser,
} from '../auth/current-user.decorator';
import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { RequirePermission } from '../permissions/require-permission.decorator';
import { PartsService } from './parts.service';
@Controller('parts')
export class PartsController {
constructor(private readonly parts: PartsService) {}
@Get()
@RequirePermission('PARTS', 'view')
list() {
return this.parts.list();
}
@Get(':id')
@RequirePermission('PARTS', 'view')
get(@Param('id', ParseUUIDPipe) id: string) {
return this.parts.get(id);
}
@Post()
@RequirePermission('PARTS', 'create')
create(@Body(new ZodValidationPipe(PartCreateSchema)) body: PartCreate) {
return this.parts.create(body);
}
@Patch(':id')
@RequirePermission('PARTS', 'edit')
update(
@Param('id', ParseUUIDPipe) id: string,
@Body(new ZodValidationPipe(PartUpdateSchema)) body: PartUpdate,
) {
return this.parts.update(id, body);
}
@Post(':id/movements')
@RequirePermission('PARTS', 'edit')
addMovement(
@Param('id', ParseUUIDPipe) id: string,
@Body(new ZodValidationPipe(StockMovementCreateSchema)) body: StockMovementCreate,
@CurrentUser() user: AuthenticatedUser,
) {
return this.parts.addMovement(id, body, user);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { PartsController } from './parts.controller';
import { PartsService } from './parts.service';
@Module({
controllers: [PartsController],
providers: [PartsService],
exports: [PartsService],
})
export class PartsModule {}

View File

@@ -0,0 +1,231 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import type {
PartCreate,
PartDetail,
PartDto,
PartsResponse,
PartUpdate,
StockMovementCreate,
StockMovementDto,
} from '@siop/shared';
import type { AuthenticatedUser } from '../auth/current-user.decorator';
import { PrismaService } from '../prisma/prisma.service';
const partInclude = { supplier: true } satisfies Prisma.PartInclude;
type PartRow = Prisma.PartGetPayload<{ include: typeof partInclude }>;
const mvtInclude = {
workOrder: { select: { reference: true } },
purchaseOrder: { select: { reference: true } },
by: { select: { displayName: true } },
} satisfies Prisma.StockMovementInclude;
type MvtRow = Prisma.StockMovementGetPayload<{ include: typeof mvtInclude }>;
@Injectable()
export class PartsService {
constructor(private readonly prisma: PrismaService) {}
/** Le stock EST la somme des mouvements — calculé, jamais stocké. */
private async stocks(partIds?: string[]): Promise<Map<string, number>> {
const grouped = await this.prisma.stockMovement.groupBy({
by: ['partId'],
where: partIds ? { partId: { in: partIds } } : undefined,
_sum: { quantity: true },
});
return new Map(grouped.map((g) => [g.partId, g._sum.quantity ?? 0]));
}
async list(): Promise<PartsResponse> {
const rows = await this.prisma.part.findMany({
include: partInclude,
orderBy: { reference: 'asc' },
});
const stocks = await this.stocks();
return { parts: rows.map((r) => this.toDto(r, stocks.get(r.id) ?? 0)) };
}
async get(id: string): Promise<PartDetail> {
const row = await this.prisma.part.findUnique({ where: { id }, include: partInclude });
if (!row) throw new NotFoundException('Pièce inconnue');
const [stocks, movements] = await Promise.all([
this.stocks([id]),
this.prisma.stockMovement.findMany({
where: { partId: id },
include: mvtInclude,
orderBy: { createdAt: 'desc' },
take: 50,
}),
]);
return {
...this.toDto(row, stocks.get(id) ?? 0),
movements: movements.map((m) => this.toMovementDto(m)),
};
}
async create(dto: PartCreate): Promise<PartDetail> {
if (dto.supplierId) await this.assertSupplier(dto.supplierId);
for (let essai = 0; ; essai++) {
try {
const created = await this.prisma.part.create({
data: {
reference: await this.nextReference(),
designation: dto.designation,
threshold: dto.threshold ?? 0,
supplierId: dto.supplierId,
compatible: dto.compatible,
lastUnitPrice: dto.initialPrice,
},
select: { id: true },
});
return this.get(created.id);
} catch (e) {
if (
e instanceof Prisma.PrismaClientKnownRequestError &&
e.code === 'P2002' &&
essai < 3
) {
continue;
}
throw e;
}
}
}
async update(id: string, dto: PartUpdate): Promise<PartDetail> {
if (dto.supplierId) await this.assertSupplier(dto.supplierId);
try {
await this.prisma.part.update({ where: { id }, data: dto, select: { id: true } });
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') {
throw new NotFoundException('Pièce inconnue');
}
throw e;
}
return this.get(id);
}
/** Entrée manuelle ou ajustement — TOUJOURS un mouvement tracé. */
async addMovement(
id: string,
dto: StockMovementCreate,
user: AuthenticatedUser,
): Promise<PartDetail> {
const part = await this.prisma.part.findUnique({ where: { id } });
if (!part) throw new NotFoundException('Pièce inconnue');
if (dto.kind === 'ENTRY' && dto.quantity <= 0) {
throw new BadRequestException('Une entrée manuelle est positive');
}
if (dto.kind === 'ADJUSTMENT' && !dto.reason?.trim()) {
throw new BadRequestException('Un ajustement dinventaire exige un motif');
}
await this.prisma.$transaction(async (tx) => {
const somme = await tx.stockMovement.aggregate({
where: { partId: id },
_sum: { quantity: true },
});
const stock = somme._sum.quantity ?? 0;
if (stock + dto.quantity < 0) {
throw new ConflictException(
`Refusé : le stock deviendrait négatif (${stock} ${dto.quantity > 0 ? '+' : ''}${dto.quantity})`,
);
}
await tx.stockMovement.create({
data: {
partId: id,
kind: dto.kind,
quantity: dto.quantity,
reason: dto.reason,
byId: user.userId,
},
});
});
return this.get(id);
}
/** Consommation d'OT — stock suffisant exigé, PRIX FIGÉ au moment T.
* Appelé par WorkOrdersService dans le périmètre d'un OT vérifié. */
async consume(
partId: string,
quantity: number,
workOrderId: string,
user: AuthenticatedUser,
): Promise<{ designation: string; unitPrice: number }> {
const part = await this.prisma.part.findUnique({ where: { id: partId } });
if (!part || !part.isActive) throw new BadRequestException('Pièce inconnue ou désactivée');
if (part.lastUnitPrice === null) {
throw new ConflictException(
'Aucun prix connu pour cette pièce — réceptionnez un BC ou renseignez un prix initial',
);
}
await this.prisma.$transaction(async (tx) => {
const somme = await tx.stockMovement.aggregate({
where: { partId },
_sum: { quantity: true },
});
const stock = somme._sum.quantity ?? 0;
if (stock < quantity) {
throw new ConflictException(`Stock insuffisant : ${stock} en stock, ${quantity} demandé`);
}
await tx.stockMovement.create({
data: {
partId,
kind: 'CONSUMPTION',
quantity: -quantity,
unitPrice: part.lastUnitPrice,
workOrderId,
byId: user.userId,
},
});
});
return { designation: part.designation, unitPrice: Number(part.lastUnitPrice) };
}
private async assertSupplier(supplierId: string): Promise<void> {
const supplier = await this.prisma.partner.findUnique({ where: { id: supplierId } });
if (!supplier || supplier.kind !== 'SUPPLIER') {
throw new BadRequestException('Fournisseur inconnu');
}
}
private async nextReference(): Promise<string> {
const [{ nextval }] = await this.prisma.$queryRaw<[{ nextval: bigint }]>`
SELECT nextval('part_ref_seq')`;
return `P-${String(nextval).padStart(4, '0')}`;
}
private toDto(row: PartRow, stock: number): PartDto {
return {
id: row.id,
reference: row.reference,
designation: row.designation,
threshold: row.threshold,
lastUnitPrice: row.lastUnitPrice === null ? null : Number(row.lastUnitPrice),
compatible: row.compatible,
supplierId: row.supplierId,
supplierName: row.supplier?.name ?? null,
isActive: row.isActive,
stock,
belowThreshold: stock < row.threshold,
};
}
private toMovementDto(m: MvtRow): StockMovementDto {
return {
id: m.id,
kind: m.kind,
quantity: m.quantity,
unitPrice: m.unitPrice === null ? null : Number(m.unitPrice),
reason: m.reason,
workOrderReference: m.workOrder?.reference ?? null,
purchaseOrderReference: m.purchaseOrder?.reference ?? null,
byName: m.by?.displayName ?? null,
createdAt: m.createdAt.toISOString(),
};
}
}

View File

@@ -76,14 +76,9 @@ export class PortalService {
} }
private async nextReference(): Promise<string> { private async nextReference(): Promise<string> {
const annee = new Date().getFullYear(); const [{ nextval }] = await this.prisma.$queryRaw<[{ nextval: bigint }]>`
const dernier = await this.prisma.request.findFirst({ SELECT nextval('request_ref_seq')`;
where: { reference: { startsWith: `DEM-${annee}-` } }, return `DEM-${new Date().getFullYear()}-${String(nextval).padStart(4, '0')}`;
orderBy: { reference: 'desc' },
select: { reference: true },
});
const n = dernier ? Number(dernier.reference.split('-')[2]) + 1 : 1;
return `DEM-${annee}-${String(n).padStart(4, '0')}`;
} }
private toStatus(row: Row): PortalRequestStatus { private toStatus(row: Row): PortalRequestStatus {

View File

@@ -210,14 +210,9 @@ export class PreventiveService {
} }
private async nextReference(): Promise<string> { private async nextReference(): Promise<string> {
const annee = new Date().getFullYear(); const [{ nextval }] = await this.prisma.$queryRaw<[{ nextval: bigint }]>`
const dernier = await this.prisma.workOrder.findFirst({ SELECT nextval('work_order_ref_seq')`;
where: { reference: { startsWith: `OT-${annee}-` } }, return `OT-${new Date().getFullYear()}-${String(nextval).padStart(4, '0')}`;
orderBy: { reference: 'desc' },
select: { reference: true },
});
const n = dernier ? Number(dernier.reference.split('-')[2]) + 1 : 1;
return `OT-${annee}-${String(n).padStart(4, '0')}`;
} }
private toDto(row: TemplateRow): TaskTemplateDto { private toDto(row: TemplateRow): TaskTemplateDto {

View File

@@ -0,0 +1,59 @@
import {
Body,
Controller,
Get,
HttpCode,
Param,
ParseUUIDPipe,
Post,
} from '@nestjs/common';
import {
PurchaseOrderCreateSchema,
PurchaseOrderTransitionSchema,
type PurchaseOrderCreate,
type PurchaseOrderTransition,
} from '@siop/shared';
import {
AuthenticatedUser,
CurrentUser,
} from '../auth/current-user.decorator';
import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { RequirePermission } from '../permissions/require-permission.decorator';
import { PurchaseOrdersService } from './purchase-orders.service';
@Controller('purchase-orders')
export class PurchaseOrdersController {
constructor(private readonly purchaseOrders: PurchaseOrdersService) {}
@Get()
@RequirePermission('PURCHASE_ORDERS', 'view')
list() {
return this.purchaseOrders.list();
}
@Get(':id')
@RequirePermission('PURCHASE_ORDERS', 'view')
get(@Param('id', ParseUUIDPipe) id: string) {
return this.purchaseOrders.get(id);
}
@Post()
@RequirePermission('PURCHASE_ORDERS', 'create')
create(
@Body(new ZodValidationPipe(PurchaseOrderCreateSchema)) body: PurchaseOrderCreate,
@CurrentUser() user: AuthenticatedUser,
) {
return this.purchaseOrders.create(body, user);
}
@Post(':id/transition')
@HttpCode(200) // le contrat : 200, l'état change
@RequirePermission('PURCHASE_ORDERS', 'edit')
transition(
@Param('id', ParseUUIDPipe) id: string,
@Body(new ZodValidationPipe(PurchaseOrderTransitionSchema)) body: PurchaseOrderTransition,
@CurrentUser() user: AuthenticatedUser,
) {
return this.purchaseOrders.transition(id, body, user);
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { PurchaseOrdersController } from './purchase-orders.controller';
import { PurchaseOrdersService } from './purchase-orders.service';
@Module({
controllers: [PurchaseOrdersController],
providers: [PurchaseOrdersService],
})
export class PurchaseOrdersModule {}

View File

@@ -0,0 +1,170 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import type {
PurchaseOrderCreate,
PurchaseOrderDto,
PurchaseOrdersResponse,
PurchaseOrderStatus,
PurchaseOrderTransition,
} from '@siop/shared';
import type { AuthenticatedUser } from '../auth/current-user.decorator';
import { PrismaService } from '../prisma/prisma.service';
const poInclude = {
supplier: true,
lines: { include: { part: true } },
} satisfies Prisma.PurchaseOrderInclude;
type Row = Prisma.PurchaseOrderGetPayload<{ include: typeof poInclude }>;
/** Machine à états du BC : la réception est le seul chemin vers le stock. */
const TRANSITIONS: Record<PurchaseOrderStatus, PurchaseOrderStatus[]> = {
DRAFT: ['SENT', 'CANCELLED'],
SENT: ['RECEIVED', 'CANCELLED'],
RECEIVED: [],
CANCELLED: [],
};
@Injectable()
export class PurchaseOrdersService {
constructor(private readonly prisma: PrismaService) {}
async list(): Promise<PurchaseOrdersResponse> {
const rows = await this.prisma.purchaseOrder.findMany({
include: poInclude,
orderBy: { createdAt: 'desc' },
});
return { purchaseOrders: rows.map((r) => this.toDto(r)) };
}
async get(id: string): Promise<PurchaseOrderDto> {
const row = await this.prisma.purchaseOrder.findUnique({
where: { id },
include: poInclude,
});
if (!row) throw new NotFoundException('BC inconnu');
return this.toDto(row);
}
async create(dto: PurchaseOrderCreate, user: AuthenticatedUser): Promise<PurchaseOrderDto> {
const supplier = await this.prisma.partner.findUnique({
where: { id: dto.supplierId },
});
if (!supplier || supplier.kind !== 'SUPPLIER' || !supplier.isActive) {
throw new BadRequestException('Fournisseur inconnu ou inactif');
}
const parts = await this.prisma.part.findMany({
where: { id: { in: dto.lines.map((l) => l.partId) } },
});
if (parts.length !== new Set(dto.lines.map((l) => l.partId)).size) {
throw new BadRequestException('Pièce inconnue dans les lignes');
}
for (let essai = 0; ; essai++) {
try {
const created = await this.prisma.purchaseOrder.create({
data: {
reference: await this.nextReference(),
supplierId: dto.supplierId,
createdById: user.userId,
lines: { create: dto.lines },
},
select: { id: true },
});
return this.get(created.id);
} catch (e) {
if (
e instanceof Prisma.PrismaClientKnownRequestError &&
e.code === 'P2002' &&
essai < 3
) {
continue;
}
throw e;
}
}
}
async transition(
id: string,
dto: PurchaseOrderTransition,
user: AuthenticatedUser,
): Promise<PurchaseOrderDto> {
const row = await this.prisma.purchaseOrder.findUnique({
where: { id },
include: poInclude,
});
if (!row) throw new NotFoundException('BC inconnu');
if (!TRANSITIONS[row.status].includes(dto.to)) {
throw new ConflictException(`Transition interdite : ${row.status}${dto.to}`);
}
if (dto.to === 'RECEIVED') {
// LA règle : la réception crée les entrées de stock et FIGE les PU
await this.prisma.$transaction(async (tx) => {
for (const line of row.lines) {
await tx.stockMovement.create({
data: {
partId: line.partId,
kind: 'RECEIPT',
quantity: line.quantity,
unitPrice: line.unitPrice,
purchaseOrderId: row.id,
byId: user.userId,
},
});
await tx.part.update({
where: { id: line.partId },
data: { lastUnitPrice: line.unitPrice },
});
}
await tx.purchaseOrder.update({
where: { id },
data: { status: 'RECEIVED', receivedAt: new Date() },
});
});
} else {
await this.prisma.purchaseOrder.update({
where: { id },
data: {
status: dto.to,
sentAt: dto.to === 'SENT' ? new Date() : undefined,
cancelledAt: dto.to === 'CANCELLED' ? new Date() : undefined,
},
});
}
return this.get(id);
}
private async nextReference(): Promise<string> {
const [{ nextval }] = await this.prisma.$queryRaw<[{ nextval: bigint }]>`
SELECT nextval('purchase_order_ref_seq')`;
return `BC-${new Date().getFullYear()}-${String(nextval).padStart(4, '0')}`;
}
private toDto(row: Row): PurchaseOrderDto {
const lines = row.lines.map((l) => ({
id: l.id,
partId: l.partId,
partReference: l.part.reference,
designation: l.part.designation,
quantity: l.quantity,
unitPrice: Number(l.unitPrice),
}));
return {
id: row.id,
reference: row.reference,
status: row.status,
supplierId: row.supplierId,
supplierName: row.supplier.name,
lines,
total: lines.reduce((s, l) => s + l.quantity * l.unitPrice, 0),
sentAt: row.sentAt?.toISOString() ?? null,
receivedAt: row.receivedAt?.toISOString() ?? null,
createdAt: row.createdAt.toISOString(),
};
}
}

View File

@@ -131,14 +131,9 @@ export class RequestsService {
} }
private async nextReference(): Promise<string> { private async nextReference(): Promise<string> {
const annee = new Date().getFullYear(); const [{ nextval }] = await this.prisma.$queryRaw<[{ nextval: bigint }]>`
const dernier = await this.prisma.request.findFirst({ SELECT nextval('request_ref_seq')`;
where: { reference: { startsWith: `DEM-${annee}-` } }, return `DEM-${new Date().getFullYear()}-${String(nextval).padStart(4, '0')}`;
orderBy: { reference: 'desc' },
select: { reference: true },
});
const n = dernier ? Number(dernier.reference.split('-')[2]) + 1 : 1;
return `DEM-${annee}-${String(n).padStart(4, '0')}`;
} }
private requesterLabel(row: RequestRow): string { private requesterLabel(row: RequestRow): string {

View File

@@ -0,0 +1,15 @@
import { Controller, Get, Query } from '@nestjs/common';
import { AuthenticatedUser, CurrentUser } from '../auth/current-user.decorator';
import { SearchService } from './search.service';
/** Authentification seule (pas de @RequirePermission) : chaque famille de
* résultats est filtrée par la matrice DANS le service. */
@Controller('search')
export class SearchController {
constructor(private readonly search: SearchService) {}
@Get()
global(@CurrentUser() user: AuthenticatedUser, @Query('q') q = '') {
return this.search.search(user, q);
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { SearchController } from './search.controller';
import { SearchService } from './search.service';
@Module({
controllers: [SearchController],
providers: [SearchService],
})
export class SearchModule {}

View File

@@ -0,0 +1,105 @@
import { Injectable } from '@nestjs/common';
import type { Prisma } from '@prisma/client';
import { SEARCH_MAX_PER_KIND, SEARCH_MIN_CHARS, type SearchResponse } from '@siop/shared';
import type { AuthenticatedUser } from '../auth/current-user.decorator';
import { PermissionsService } from '../permissions/permissions.service';
import { PrismaService } from '../prisma/prisma.service';
/** Recherche globale de la topbar (⌘K). Chaque famille n'est interrogée que
* si le rôle a la permission `view` du domaine ; les OT respectent en plus
* l'invariant « voir autre » (même règle que la liste des OT). */
@Injectable()
export class SearchService {
constructor(
private readonly prisma: PrismaService,
private readonly permissions: PermissionsService,
) {}
async search(user: AuthenticatedUser, q: string): Promise<SearchResponse> {
const terme = q.trim();
const vide: SearchResponse = { workOrders: [], assets: [], sites: [] };
if (terme.length < SEARCH_MIN_CHARS) return vide;
const [voitOT, voitParc, voitSites] = await Promise.all([
this.permissions.can(user.roleId, 'WORK_ORDERS', 'view'),
this.permissions.can(user.roleId, 'ASSETS', 'view'),
this.permissions.can(user.roleId, 'LOCATIONS', 'view'),
]);
const contient = (champ: string): Prisma.StringFilter => ({
contains: champ,
mode: 'insensitive',
});
const [workOrders, assets, sites] = await Promise.all([
voitOT
? this.prisma.workOrder.findMany({
where: {
AND: [
{ OR: [{ reference: contient(terme) }, { title: contient(terme) }] },
await this.scopeOT(user),
],
},
select: { id: true, reference: true, title: true, status: true },
orderBy: { createdAt: 'desc' },
take: SEARCH_MAX_PER_KIND,
})
: [],
voitParc
? this.prisma.asset.findMany({
where: {
OR: [
{ reference: contient(terme) },
{ brand: contient(terme) },
{ model: contient(terme) },
],
},
select: {
id: true,
reference: true,
brand: true,
model: true,
location: { select: { name: true, parent: { select: { name: true } } } },
},
orderBy: { reference: 'asc' },
take: SEARCH_MAX_PER_KIND,
})
: [],
voitSites
? this.prisma.location.findMany({
where: {
parentId: null,
OR: [{ name: contient(terme) }, { city: contient(terme) }],
},
select: { id: true, name: true, city: true },
orderBy: { name: 'asc' },
take: SEARCH_MAX_PER_KIND,
})
: [],
]);
return {
workOrders,
assets: assets.map((a) => ({
id: a.id,
reference: a.reference,
brand: a.brand,
model: a.model,
siteName: a.location.parent?.name ?? a.location.name,
})),
sites,
};
}
/** Même invariant que WorkOrdersService.scope — sans le droit « voir
* autre », on ne trouve que SES OT. */
private async scopeOT(user: AuthenticatedUser): Promise<Prisma.WorkOrderWhereInput> {
const viewOther = await this.permissions.can(user.roleId, 'WORK_ORDERS', 'viewOther');
if (viewOther) return {};
return {
OR: [
{ assignees: { some: { id: user.userId } } },
{ createdById: user.userId },
],
};
}
}

View File

@@ -98,6 +98,7 @@ export class UsersService {
phone: dto.phone, phone: dto.phone,
roleId: dto.roleId, roleId: dto.roleId,
isActive: dto.isActive, isActive: dto.isActive,
hourlyRate: dto.hourlyRate,
teams: dto.teamIds teams: dto.teamIds
? { set: dto.teamIds.map((id) => ({ id })) } ? { set: dto.teamIds.map((id) => ({ id })) }
: undefined, : undefined,
@@ -136,6 +137,7 @@ export class UsersService {
? 'active' ? 'active'
: 'invited', : 'invited',
isDemo: row.isDemo, isDemo: row.isDemo,
hourlyRate: row.hourlyRate === null ? null : Number(row.hourlyRate),
}; };
} }
} }

View File

@@ -13,12 +13,16 @@ import {
AssigneesUpdateSchema, AssigneesUpdateSchema,
ChecklistPatchSchema, ChecklistPatchSchema,
CommentCreateSchema, CommentCreateSchema,
ConsumePartSchema,
LaborTimeCreateSchema,
ReportUpsertSchema, ReportUpsertSchema,
TransitionRequestSchema, TransitionRequestSchema,
WorkOrderCreateSchema, WorkOrderCreateSchema,
type AssigneesUpdate, type AssigneesUpdate,
type ChecklistPatch, type ChecklistPatch,
type CommentCreate, type CommentCreate,
type ConsumePart,
type LaborTimeCreate,
type ReportUpsert, type ReportUpsert,
type TransitionRequest, type TransitionRequest,
type WorkOrderCreate, type WorkOrderCreate,
@@ -97,6 +101,26 @@ export class WorkOrdersController {
return this.workOrders.upsertReport(id, body, user); return this.workOrders.upsertReport(id, body, user);
} }
@Post(':id/consume-part')
@RequirePermission('WORK_ORDERS', 'edit')
consumePart(
@Param('id', ParseUUIDPipe) id: string,
@Body(new ZodValidationPipe(ConsumePartSchema)) body: ConsumePart,
@CurrentUser() user: AuthenticatedUser,
) {
return this.workOrders.consumePart(id, body, user);
}
@Post(':id/labor')
@RequirePermission('WORK_ORDERS', 'edit')
addLabor(
@Param('id', ParseUUIDPipe) id: string,
@Body(new ZodValidationPipe(LaborTimeCreateSchema)) body: LaborTimeCreate,
@CurrentUser() user: AuthenticatedUser,
) {
return this.workOrders.addLabor(id, body, user);
}
@Patch(':id/checklist/:itemId') @Patch(':id/checklist/:itemId')
@RequirePermission('WORK_ORDERS', 'edit') @RequirePermission('WORK_ORDERS', 'edit')
patchChecklist( patchChecklist(

View File

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

View File

@@ -23,6 +23,7 @@ import {
type WorkOrderSummary, type WorkOrderSummary,
} from '@siop/shared'; } from '@siop/shared';
import type { AuthenticatedUser } from '../auth/current-user.decorator'; import type { AuthenticatedUser } from '../auth/current-user.decorator';
import { PartsService } from '../parts/parts.service';
import { PermissionsService } from '../permissions/permissions.service'; import { PermissionsService } from '../permissions/permissions.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
@@ -33,6 +34,13 @@ const detailInclude = {
request: { include: { requestedBy: true } }, request: { include: { requestedBy: true } },
events: { include: { by: true }, orderBy: { createdAt: 'desc' as const } }, events: { include: { by: true }, orderBy: { createdAt: 'desc' as const } },
checklist: { include: { doneBy: true }, orderBy: { label: 'asc' as const } }, checklist: { include: { doneBy: true }, orderBy: { label: 'asc' as const } },
// R3 — coûts figés
stockMovements: {
where: { kind: 'CONSUMPTION' as const },
include: { part: true },
orderBy: { createdAt: 'asc' as const },
},
laborTimes: { include: { user: true }, orderBy: { createdAt: 'asc' as const } },
report: { report: {
include: { include: {
doorState: true, doorState: true,
@@ -71,6 +79,7 @@ export class WorkOrdersService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly permissions: PermissionsService, private readonly permissions: PermissionsService,
private readonly parts: PartsService,
) {} ) {}
/** Invariant « voir autre » : sans le droit, on ne voit que SES OT. */ /** Invariant « voir autre » : sans le droit, on ne voit que SES OT. */
@@ -177,14 +186,10 @@ export class WorkOrdersService {
} }
private async nextReference(): Promise<string> { private async nextReference(): Promise<string> {
const annee = new Date().getFullYear(); // Séquence Postgres : insensible à la concurrence (jamais de collision)
const dernier = await this.prisma.workOrder.findFirst({ const [{ nextval }] = await this.prisma.$queryRaw<[{ nextval: bigint }]>`
where: { reference: { startsWith: `OT-${annee}-` } }, SELECT nextval('work_order_ref_seq')`;
orderBy: { reference: 'desc' }, return `OT-${new Date().getFullYear()}-${String(nextval).padStart(4, '0')}`;
select: { reference: true },
});
const n = dernier ? Number(dernier.reference.split('-')[2]) + 1 : 1;
return `OT-${annee}-${String(n).padStart(4, '0')}`;
} }
/** Machine à états stricte + garde de clôture. */ /** Machine à états stricte + garde de clôture. */
@@ -194,6 +199,7 @@ export class WorkOrdersService {
user: AuthenticatedUser, user: AuthenticatedUser,
): Promise<WorkOrderDetail> { ): Promise<WorkOrderDetail> {
const detail = await this.get(id, user); const detail = await this.get(id, user);
await this.assertVersion(id, dto.baseUpdatedAt);
if (!WORK_ORDER_TRANSITIONS[detail.status].includes(dto.to)) { if (!WORK_ORDER_TRANSITIONS[detail.status].includes(dto.to)) {
throw new ConflictException( throw new ConflictException(
`Transition interdite : ${WORK_ORDER_STATUS_LABELS[detail.status]}${WORK_ORDER_STATUS_LABELS[dto.to]}`, `Transition interdite : ${WORK_ORDER_STATUS_LABELS[detail.status]}${WORK_ORDER_STATUS_LABELS[dto.to]}`,
@@ -240,6 +246,7 @@ export class WorkOrdersService {
await this.prisma.workOrderEvent.create({ await this.prisma.workOrderEvent.create({
data: { workOrderId: id, kind: 'COMMENT', message, byId: user.userId }, data: { workOrderId: id, kind: 'COMMENT', message, byId: user.userId },
}); });
await this.toucher(id);
return this.get(id, user); return this.get(id, user);
} }
@@ -279,6 +286,7 @@ export class WorkOrdersService {
user: AuthenticatedUser, user: AuthenticatedUser,
): Promise<WorkOrderDetail> { ): Promise<WorkOrderDetail> {
await this.get(id, user); await this.get(id, user);
await this.assertVersion(id, dto.baseUpdatedAt);
// Chaque valeur fournie doit appartenir au référentiel de SON champ (et être active) // Chaque valeur fournie doit appartenir au référentiel de SON champ (et être active)
for (const [colonne, field] of Object.entries(REPORT_FIELDS)) { for (const [colonne, field] of Object.entries(REPORT_FIELDS)) {
const valeur = dto[colonne as keyof ReportUpsert]; const valeur = dto[colonne as keyof ReportUpsert];
@@ -305,6 +313,7 @@ export class WorkOrdersService {
update: donnees, update: donnees,
create: { workOrderId: id, ...donnees }, create: { workOrderId: id, ...donnees },
}); });
await this.toucher(id);
return this.get(id, user); return this.get(id, user);
} }
@@ -315,6 +324,7 @@ export class WorkOrdersService {
user: AuthenticatedUser, user: AuthenticatedUser,
): Promise<ChecklistItemDto> { ): Promise<ChecklistItemDto> {
await this.get(id, user); await this.get(id, user);
await this.assertVersion(id, dto.baseUpdatedAt);
const { count } = await this.prisma.checklistItem.updateMany({ const { count } = await this.prisma.checklistItem.updateMany({
where: { id: itemId, workOrderId: id }, where: { id: itemId, workOrderId: id },
data: { data: {
@@ -324,6 +334,7 @@ export class WorkOrdersService {
}, },
}); });
if (count === 0) throw new NotFoundException('Tâche inconnue'); if (count === 0) throw new NotFoundException('Tâche inconnue');
await this.toucher(id);
const item = await this.prisma.checklistItem.findUniqueOrThrow({ const item = await this.prisma.checklistItem.findUniqueOrThrow({
where: { id: itemId }, where: { id: itemId },
include: { doneBy: true }, include: { doneBy: true },
@@ -337,6 +348,67 @@ export class WorkOrdersService {
}; };
} }
// ————— R3 : coûts figés —————
/** Consommer une pièce : stock décrémenté, PRIX FIGÉ au moment T. */
async consumePart(
id: string,
dto: { partId: string; quantity: number },
user: AuthenticatedUser,
): Promise<WorkOrderDetail> {
const detail = await this.get(id, user); // périmètre + existence
if (detail.status === 'DONE' || detail.status === 'CANCELLED') {
throw new ConflictException('OT terminé : les coûts sont figés');
}
const { designation, unitPrice } = await this.parts.consume(
dto.partId,
dto.quantity,
id,
user,
);
await this.prisma.workOrderEvent.create({
data: {
workOrderId: id,
kind: 'PART_CONSUMED',
message: `${designation} × ${dto.quantity} (${unitPrice} MAD/u, prix figé)`,
byId: user.userId,
},
});
await this.toucher(id);
return this.get(id, user);
}
/** Saisir de la main-d'œuvre : TAUX FIGÉ à la saisie. */
async addLabor(
id: string,
dto: { minutes: number; userId?: string; note?: string },
user: AuthenticatedUser,
): Promise<WorkOrderDetail> {
const detail = await this.get(id, user);
if (detail.status === 'DONE' || detail.status === 'CANCELLED') {
throw new ConflictException('OT terminé : les coûts sont figés');
}
const cibleId = dto.userId ?? user.userId;
const cible = await this.prisma.user.findUnique({ where: { id: cibleId } });
if (!cible) throw new BadRequestException('Personne inconnue');
if (cible.hourlyRate === null) {
throw new ConflictException(
`Aucun taux horaire défini pour ${cible.displayName} — à renseigner dans Personnes`,
);
}
await this.prisma.laborTime.create({
data: {
workOrderId: id,
userId: cibleId,
minutes: dto.minutes,
hourlyRate: cible.hourlyRate, // figé
note: dto.note,
},
});
await this.toucher(id);
return this.get(id, user);
}
// ————— mapping ————— // ————— mapping —————
private toSummary( private toSummary(
@@ -387,6 +459,22 @@ export class WorkOrdersService {
} }
const versValeur = (v: { id: string; label: string } | null) => const versValeur = (v: { id: string; label: string } | null) =>
v ? { id: v.id, label: v.label } : null; v ? { id: v.id, label: v.label } : null;
// Coûts figés : chaque ligne porte le prix/taux du moment T
const partsCosts = row.stockMovements.map((m) => ({
id: m.id,
designation: m.part.designation,
quantity: -m.quantity,
unitPrice: Number(m.unitPrice ?? 0),
total: -m.quantity * Number(m.unitPrice ?? 0),
}));
const laborCosts = row.laborTimes.map((l) => ({
id: l.id,
displayName: l.user.displayName,
minutes: l.minutes,
hourlyRate: Number(l.hourlyRate),
total: Math.round((l.minutes / 60) * Number(l.hourlyRate) * 100) / 100,
note: l.note,
}));
return { return {
...this.toSummary(row), ...this.toSummary(row),
description: row.description, description: row.description,
@@ -430,8 +518,55 @@ export class WorkOrdersService {
componentConcerned: versValeur(report.componentConcerned), componentConcerned: versValeur(report.componentConcerned),
} }
: null, : null,
costs: {
parts: partsCosts,
labor: laborCosts,
total:
Math.round(
(partsCosts.reduce((s, p) => s + p.total, 0) +
laborCosts.reduce((s, l) => s + l.total, 0)) *
100,
) / 100,
},
allowedTransitions: WORK_ORDER_TRANSITIONS[row.status as WorkOrderStatus], allowedTransitions: WORK_ORDER_TRANSITIONS[row.status as WorkOrderStatus],
closureBlockers: blockers, closureBlockers: blockers,
updatedAt: row.updatedAt.toISOString(),
}; };
} }
/** Verrou optimiste (D2 mobile) : si l'appelant fournit la version lue et
* que l'OT a changé depuis, 409 avec le contexte (qui, quand) — rien
* n'est écrasé en silence. Sans version fournie (web), comportement
* inchangé. */
private async assertVersion(id: string, baseUpdatedAt?: string): Promise<void> {
if (!baseUpdatedAt) return;
const wo = await this.prisma.workOrder.findUniqueOrThrow({
where: { id },
select: {
updatedAt: true,
events: {
orderBy: { createdAt: 'desc' },
take: 1,
include: { by: true },
},
},
});
if (wo.updatedAt.getTime() > new Date(baseUpdatedAt).getTime()) {
const dernier = wo.events[0];
const qui = dernier?.by?.displayName ?? 'quelquun';
const quand = new Intl.DateTimeFormat('fr-FR', {
hour: '2-digit',
minute: '2-digit',
}).format(wo.updatedAt);
throw new ConflictException(
`Conflit de version : cet OT a été modifié par ${qui} à ${quand} — votre saisie n'a pas été appliquée.`,
);
}
}
/** Toute écriture « secondaire » (coche, bilan, commentaire, coûts) fait
* avancer la version de l'OT — sinon le verrou D2 ne verrait rien. */
private async toucher(id: string): Promise<void> {
await this.prisma.workOrder.update({ where: { id }, data: { updatedAt: new Date() } });
}
} }

View File

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

View File

@@ -140,12 +140,20 @@ describe('Exploitation (e2e)', () => {
.set(auth(salma)) .set(auth(salma))
.expect(200); .expect(200);
const position = listeSalma.workOrders.findIndex((w: { id: string }) => w.id === id); const position = listeSalma.workOrders.findIndex((w: { id: string }) => w.id === id);
const urgencesActives = listeSalma.workOrders.filter(
(w: { priority: string; status: string }) =>
w.priority === 'PERSON_TRAPPED' && w.status !== 'DONE' && w.status !== 'CANCELLED',
).length;
expect(position).toBeGreaterThanOrEqual(0); expect(position).toBeGreaterThanOrEqual(0);
expect(position).toBeLessThanOrEqual(urgencesActives); // Invariant DÉTERMINISTE (les specs parallèles créent des OT plus frais,
// les positions absolues sont donc instables) : notre OT fraîchement
// clôturé précède les OT du seed modifiés avant lui — avec l'ancien tri
// « par statut », tout En cours passait devant tous les Terminés.
const annee = new Date().getFullYear();
const posGrilleSeed = listeSalma.workOrders.findIndex(
(w: { reference: string }) => w.reference === `OT-${annee}-0338`, // En cours, seed
);
const posAncienDone = listeSalma.workOrders.findIndex(
(w: { reference: string }) => w.reference === `OT-${annee}-0332`, // Terminé, seed
);
expect(position).toBeLessThan(posGrilleSeed);
expect(position).toBeLessThan(posAncienDone);
// 7. Terminal : plus aucune transition // 7. Terminal : plus aucune transition
await http() await http()

View File

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

View File

@@ -0,0 +1,247 @@
/**
* E2E R3 (recette) — recherche globale : chaque famille de résultats est
* filtrée par la matrice, les OT respectent « voir autre » ; au passage,
* rattachements Tiers (siteNames) et période analytics paramétrable.
*/
process.env.DEMO_MODE = 'true';
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client';
import request from 'supertest';
import { seed } from '../prisma/seed';
import { AppModule } from '../src/app.module';
describe('Recherche globale & corrections de recette R3 (e2e)', () => {
let app: INestApplication;
let yasmine: string; // Administrateur
let salma: string; // Dispatcher (voir autre)
let youssef: string; // Technicien limité : SES OT, ASSETS view, pas de LOCATIONS
let karim: string; // Demandeur : aucune des trois familles
let otId: string;
const prisma = new PrismaClient();
const http = () => request(app.getHttpServer());
const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
const suffix = `rech-${Date.now().toString(36)}`;
beforeAll(async () => {
await seed(prisma);
const moduleRef = await Test.createTestingModule({
imports: [AppModule.forRoot()],
}).compile();
app = moduleRef.createNestApplication();
await app.init();
const { body } = await http().get('/auth/demo-accounts');
const login = async (roleName: string) => {
const compte = body.accounts.find((a: { roleName: string }) => a.roleName === roleName);
return (await http().post('/auth/demo-login').send({ userId: compte.id })).body
.accessToken as string;
};
yasmine = await login('Administrateur');
salma = await login('Dispatcher');
youssef = await login('Technicien limité');
karim = await login('Demandeur');
// Un OT au titre unique, assigné à Ahmed (PAS à Youssef).
const { body: assets } = await http().get('/assets').set(auth(salma));
const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A1');
const ahmed = body.accounts.find((a: { roleName: string }) => a.roleName === 'Technicien');
const cree = await http()
.post('/work-orders')
.set(auth(salma))
.send({
title: `Recherche E2E ${suffix}`,
type: 'CORRECTIVE',
priority: 'LOW',
assetId: a1.id,
assigneeIds: [ahmed.id],
})
.expect(201);
otId = cree.body.id;
});
afterAll(async () => {
await prisma.workOrder.deleteMany({ where: { title: { contains: suffix } } });
await app?.close();
await prisma.$disconnect();
});
it('dispatcher (voir autre) : OT par titre, appareil par référence, site insensible à la casse', async () => {
const parTitre = await http().get(`/search?q=${suffix}`).set(auth(salma)).expect(200);
expect(parTitre.body.workOrders.map((w: { id: string }) => w.id)).toContain(otId);
const parRef = await http().get('/search?q=A1').set(auth(salma)).expect(200);
expect(parRef.body.assets.some((a: { reference: string }) => a.reference === 'A1')).toBe(true);
expect(parRef.body.assets[0].siteName).toBeTruthy();
const parSite = await http().get('/search?q=atlas').set(auth(salma)).expect(200);
expect(parSite.body.sites.some((s: { name: string }) => s.name === 'Tour Atlas')).toBe(true);
});
it('technicien limité : ne trouve pas lOT dun autre, ni les sites ; le parc oui', async () => {
const res = await http().get(`/search?q=${suffix}`).set(auth(youssef)).expect(200);
expect(res.body.workOrders).toHaveLength(0); // pas « voir autre »
const parc = await http().get('/search?q=A1').set(auth(youssef)).expect(200);
expect(parc.body.assets.length).toBeGreaterThan(0); // ASSETS view
const sites = await http().get('/search?q=atlas').set(auth(youssef)).expect(200);
expect(sites.body.sites).toHaveLength(0); // pas de LOCATIONS view
});
it('demandeur : 200 mais aucune famille (aucune permission view)', async () => {
const res = await http().get('/search?q=atlas').set(auth(karim)).expect(200);
expect(res.body).toEqual({ workOrders: [], assets: [], sites: [] });
});
it('moins de 2 caractères : réponse vide, pas de requête inutile', async () => {
const res = await http().get('/search?q=a').set(auth(salma)).expect(200);
expect(res.body).toEqual({ workOrders: [], assets: [], sites: [] });
});
it('sans jeton : 401 (API fermée par défaut)', async () => {
await http().get('/search?q=atlas').expect(401);
});
it('tiers : les syndics portent leurs sites (« Rattachements » de la maquette)', async () => {
const { body } = await http().get('/partners').set(auth(yasmine)).expect(200);
const atlas = body.partners.find(
(p: { name: string }) => p.name === 'Atlas Property Management',
);
expect(atlas.siteNames).toEqual(['Tour Atlas']);
const fournisseur = body.partners.find((p: { name: string }) => p.name === 'Lubmaroc');
expect(fournisseur.siteNames).toEqual([]);
});
it('analytics : période 3/6/12 mois, 12 par défaut, valeur inconnue repliée sur 12', async () => {
const trois = await http().get('/analytics/summary?months=3').set(auth(yasmine)).expect(200);
expect(trois.body.months).toBe(3);
expect(trois.body.costsByMonth).toHaveLength(3);
const defaut = await http().get('/analytics/summary').set(auth(yasmine)).expect(200);
expect(defaut.body.months).toBe(12);
expect(defaut.body.costsByMonth).toHaveLength(12);
const inconnu = await http().get('/analytics/summary?months=7').set(auth(yasmine)).expect(200);
expect(inconnu.body.months).toBe(12);
});
it('sites : le rattachement naccepte quun tiers Client / syndic', async () => {
const { body: partners } = await http().get('/partners').set(auth(yasmine));
const fournisseur = partners.partners.find((p: { kind: string }) => p.kind === 'SUPPLIER');
const { body: locations } = await http().get('/locations').set(auth(yasmine));
const site = locations.locations.find(
(l: { parentId: string | null; name: string }) => !l.parentId && l.name === 'Anfa Place',
);
await http()
.patch(`/locations/${site.id}`)
.set(auth(yasmine))
.send({ partnerId: fournisseur.id })
.expect(400);
});
});
describe('Verrou optimiste D2 (e2e) — la version protège les saisies mobiles', () => {
let app: INestApplication;
let salma: string;
let ahmed: string;
let otId: string;
const prisma = new PrismaClient();
const http = () => request(app.getHttpServer());
const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
const suffix = `verrou-${Date.now().toString(36)}`;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [AppModule.forRoot()],
}).compile();
app = moduleRef.createNestApplication();
await app.init();
const { body } = await http().get('/auth/demo-accounts');
const login = async (roleName: string) => {
const compte = body.accounts.find((a: { roleName: string }) => a.roleName === roleName);
return (await http().post('/auth/demo-login').send({ userId: compte.id })).body
.accessToken as string;
};
salma = await login('Dispatcher');
ahmed = await login('Technicien');
const { body: assets } = await http().get('/assets').set(auth(salma));
const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A1');
const technicien = body.accounts.find((a: { roleName: string }) => a.roleName === 'Technicien');
const cree = await http()
.post('/work-orders')
.set(auth(salma))
.send({
title: `Verrou E2E ${suffix}`,
type: 'CORRECTIVE',
assetId: a1.id,
assigneeIds: [technicien.id],
})
.expect(201);
otId = cree.body.id;
});
afterAll(async () => {
await prisma.workOrder.deleteMany({ where: { title: { contains: suffix } } });
await app?.close();
await prisma.$disconnect();
});
it('version à jour : la transition passe ; le détail expose updatedAt', async () => {
const detail = await http().get(`/work-orders/${otId}`).set(auth(ahmed)).expect(200);
expect(detail.body.updatedAt).toBeTruthy();
await http()
.post(`/work-orders/${otId}/transition`)
.set(auth(ahmed))
.send({ to: 'IN_PROGRESS', baseUpdatedAt: detail.body.updatedAt })
.expect(200);
});
it('OT modifié entre-temps (commentaire) : 409 « Conflit de version » contextualisé', async () => {
const detail = await http().get(`/work-orders/${otId}`).set(auth(ahmed)).expect(200);
const versionLue = detail.body.updatedAt as string;
// Salma commente pendant qu'Ahmed est « hors-ligne » — la version avance
await http()
.post(`/work-orders/${otId}/comments`)
.set(auth(salma))
.send({ message: 'Réassigné après appel du syndic' })
.expect(201);
const refus = await http()
.post(`/work-orders/${otId}/transition`)
.set(auth(ahmed))
.send({ to: 'ON_HOLD', baseUpdatedAt: versionLue })
.expect(409);
expect(refus.body.message).toContain('Conflit de version');
expect(refus.body.message).toContain('Salma');
// L'OT n'a PAS bougé — rien d'écrasé en silence
const apres = await http().get(`/work-orders/${otId}`).set(auth(ahmed)).expect(200);
expect(apres.body.status).toBe('IN_PROGRESS');
});
it('le bilan et la coche font avancer la version (sinon le verrou est aveugle)', async () => {
const avant = (await http().get(`/work-orders/${otId}`).set(auth(ahmed))).body.updatedAt;
const { body: refs } = await http().get('/reference-values').set(auth(ahmed));
const porte = refs.referenceValues.find(
(v: { field: string; isActive: boolean }) => v.field === 'DOOR_STATE' && v.isActive,
);
await http()
.put(`/work-orders/${otId}/report`)
.set(auth(ahmed))
.send({ doorStateId: porte.id })
.expect(200);
const apres = (await http().get(`/work-orders/${otId}`).set(auth(ahmed))).body.updatedAt;
expect(new Date(apres).getTime()).toBeGreaterThan(new Date(avant).getTime());
});
it('sans baseUpdatedAt (web) : comportement inchangé, même après modification', async () => {
await http()
.post(`/work-orders/${otId}/comments`)
.set(auth(salma))
.send({ message: 'Nouvelle note' })
.expect(201);
await http()
.post(`/work-orders/${otId}/transition`)
.set(auth(ahmed))
.send({ to: 'ON_HOLD' })
.expect(200);
});
});

View File

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

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

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

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

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

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

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

31
apps/mobile/app.json Normal file
View File

@@ -0,0 +1,31 @@
{
"expo": {
"name": "SIOP",
"slug": "siop2-mobile",
"version": "0.1.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
"ios": {
"supportsTablet": true
},
"android": {
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/android-icon-foreground.png",
"backgroundImage": "./assets/android-icon-background.png",
"monochromeImage": "./assets/android-icon-monochrome.png"
},
"predictiveBackGestureEnabled": false
},
"web": {
"favicon": "./assets/favicon.png"
},
"plugins": [
"expo-router",
"expo-font",
"expo-secure-store"
],
"scheme": "siop"
}
}

View File

@@ -0,0 +1,71 @@
import { Tabs } from 'expo-router';
import { Text, type ColorValue } from 'react-native';
import { useAssets, useReferenceValues } from '@/api/exploitation';
import { useFile } from '@/file/store';
import { useTokens } from '@/theme/tokens';
/** La tabbar de la maquette R4. Les onglets à venir restent visibles
* (périmètre annoncé, même patron que la sidebar web) mais mènent à un
* écran « disponible en R4.x ». */
function Pic({ glyphe, couleur }: { glyphe: string; couleur: ColorValue }) {
return <Text style={{ fontSize: 17, color: couleur, lineHeight: 20 }}>{glyphe}</Text>;
}
export default function CoquilleTabs() {
const t = useTokens();
const file = useFile();
const conflits = file.some((s) => s.statut === 'CONFLIT');
// 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.
useAssets();
useReferenceValues();
return (
<Tabs
screenOptions={{
headerShown: false,
tabBarActiveTintColor: t.primaire,
tabBarInactiveTintColor: t.encre3,
tabBarStyle: { backgroundColor: t.surface, borderTopColor: t.bordure },
tabBarLabelStyle: { fontFamily: 'Manrope_700Bold', fontSize: 10 },
sceneStyle: { backgroundColor: t.fond },
}}
>
<Tabs.Screen
name="journee"
options={{
title: 'Ma journée',
tabBarIcon: ({ color }) => <Pic glyphe="☰" couleur={color} />,
}}
/>
<Tabs.Screen
name="scanner"
options={{
title: 'Scanner',
tabBarIcon: ({ color }) => <Pic glyphe="▣" couleur={color} />,
}}
/>
<Tabs.Screen
name="preventif"
options={{
title: 'Préventif',
tabBarIcon: ({ color }) => <Pic glyphe="✓" couleur={color} />,
}}
/>
<Tabs.Screen
name="synchro"
options={{
title: 'Synchro',
tabBarIcon: ({ color }) => <Pic glyphe="⇅" couleur={color} />,
tabBarBadge: file.length || undefined,
tabBarBadgeStyle: {
backgroundColor: conflits ? t.prioBloque : t.stAttente,
color: '#fff',
fontFamily: 'Manrope_800ExtraBold',
fontSize: 10,
},
}}
/>
</Tabs>
);
}

View File

@@ -0,0 +1,242 @@
import { useQuery } from '@tanstack/react-query';
import { router } from 'expo-router';
import { FlatList, Pressable, RefreshControl, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import {
WORK_ORDER_PRIORITY_LABELS,
WORK_ORDER_STATUS_LABELS,
WORK_ORDER_TYPE_LABELS,
type WorkOrderPriority,
type WorkOrderStatus,
} from '@siop/shared';
import { api, unwrap } from '@/api/client';
import { useHorsLigne, useLogout, useMe } from '@/auth/session';
import { triJournee } from '@/lib/journee';
import { useTokens, type Tokens } from '@/theme/tokens';
/** Écran 1 de la maquette R4 : les OT du technicien, priorité puis
* échéance, « personne bloquée » en tête. Hors-ligne : le cache persisté
* sert la liste, le bandeau l'assume (D1). */
function useWorkOrders() {
return useQuery({
queryKey: ['work-orders'],
queryFn: async () => (await unwrap(await api.GET('/work-orders'))).workOrders,
});
}
const STYLE_STATUT: Record<WorkOrderStatus, (t: Tokens) => [string, string]> = {
OPEN: (t) => [t.stOuvert, t.stOuvertFond],
IN_PROGRESS: (t) => [t.stEncours, t.stEncoursFond],
ON_HOLD: (t) => [t.stAttente, t.stAttenteFond],
DONE: (t) => [t.stTermine, t.stTermineFond],
CANCELLED: (t) => [t.stAnnule, t.stAnnuleFond],
};
const STRIE_PRIORITE: Record<WorkOrderPriority, (t: Tokens) => string> = {
PERSON_TRAPPED: (t) => t.prioBloque,
HIGH: (t) => t.prioHaute,
MEDIUM: (t) => t.prioMoyenne,
LOW: (t) => t.prioBasse,
NONE: () => 'transparent',
};
export default function PageJournee() {
const t = useTokens();
const horsLigne = useHorsLigne();
const { data: me } = useMe();
const { data: workOrders, refetch, isFetching, dataUpdatedAt } = useWorkOrders();
const logout = useLogout();
const ots = triJournee(workOrders ?? []);
const urgences = ots.filter((o) => o.priority === 'PERSON_TRAPPED');
const jour = new Intl.DateTimeFormat('fr-FR', { weekday: 'long', day: 'numeric', month: 'short' })
.format(new Date());
return (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<View style={{ padding: 14, gap: 10, flex: 1 }}>
{/* Entête app : marque + pastille synchro + compte */}
<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' }}>
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
Ma journée
</Text>
<Text
style={{
marginLeft: 'auto',
fontFamily: 'Manrope_600SemiBold',
fontSize: 11,
color: t.encre3,
}}
>
{jour}
</Text>
</View>
{horsLigne ? (
<View
style={{
backgroundColor: t.stAttenteFond,
borderColor: t.stAttente,
borderWidth: 1,
borderRadius: 10,
padding: 9,
}}
>
<Text style={{ color: t.stAttente, fontFamily: 'Manrope_700Bold', fontSize: 12 }}>
Hors-ligne liste du{' '}
{dataUpdatedAt
? new Intl.DateTimeFormat('fr-FR', { hour: '2-digit', minute: '2-digit' }).format(dataUpdatedAt)
: '…'}
. Vos saisies partiront en file (R4.3).
</Text>
</View>
) : null}
{urgences.length ? (
<View
style={{
backgroundColor: t.prioBloqueFond,
borderRadius: 10,
padding: 9,
flexDirection: 'row',
alignItems: 'center',
gap: 8,
}}
>
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: t.prioBloque }} />
<Text style={{ color: t.prioBloque, fontFamily: 'Manrope_800ExtraBold', fontSize: 12 }}>
{urgences.length === 1
? `1 personne bloquée — ${urgences[0]!.assetReference} · ${urgences[0]!.siteName}`
: `${urgences.length} personnes bloquées`}
</Text>
</View>
) : null}
<FlatList
data={ots}
keyExtractor={(o) => o.id}
refreshControl={
<RefreshControl refreshing={isFetching} onRefresh={() => void refetch()} tintColor={t.primaire} />
}
contentContainerStyle={{ gap: 8, paddingBottom: 12 }}
ListEmptyComponent={
<Text style={{ color: t.encre3, fontFamily: 'Manrope_600SemiBold', padding: 16, textAlign: 'center' }}>
Aucun OT en cours tirez pour rafraîchir.
</Text>
}
renderItem={({ item: o }) => {
const [enc, fond] = STYLE_STATUT[o.status](t);
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={`${o.reference}${o.title}`}
onPress={() => router.push(`/ot/${o.id}`)}
style={{
backgroundColor: t.surface,
borderColor: t.bordure,
borderWidth: 1,
borderRadius: 12,
borderLeftWidth: 4,
borderLeftColor: STRIE_PRIORITE[o.priority](t),
padding: 11,
gap: 2,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
<Text
style={{
fontFamily: 'Manrope_700Bold',
fontSize: 10.5,
letterSpacing: 0.6,
textTransform: 'uppercase',
color: t.encre2,
flex: 1,
}}
>
{o.reference} · {WORK_ORDER_TYPE_LABELS[o.type]}
</Text>
<Text
style={{
fontFamily: 'Manrope_700Bold',
fontSize: 10.5,
color: enc,
backgroundColor: fond,
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 999,
overflow: 'hidden',
}}
>
{WORK_ORDER_STATUS_LABELS[o.status]}
</Text>
</View>
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 14, color: t.encre }}>
{o.title}
</Text>
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 12, color: t.encre2 }}>
Asc. {o.assetReference} {o.siteName}
{o.dueDate
? ` · échéance ${new Intl.DateTimeFormat('fr-FR', { day: 'numeric', month: 'short' }).format(new Date(o.dueDate))}`
: ''}
{o.priority !== 'NONE' && o.priority !== 'PERSON_TRAPPED'
? ` · ${WORK_ORDER_PRIORITY_LABELS[o.priority]}`
: ''}
</Text>
</Pressable>
);
}}
/>
</View>
</SafeAreaView>
);
}

View File

@@ -0,0 +1,76 @@
import { router } from 'expo-router';
import { FlatList, Pressable, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useWorkOrders } from '@/api/exploitation';
import { ChipStatut } from '@/composants/ui';
import { useTokens } from '@/theme/tokens';
/** Onglet Préventif : mes grilles du moment — chaque tuile mène à la
* checklist cochable (écran 6). Les OT viennent déjà scopés par l'API. */
export default function PagePreventif() {
const t = useTokens();
const { data: workOrders } = useWorkOrders();
const grilles = (workOrders ?? [])
.filter((w) => w.type === 'PREVENTIVE' && w.status !== 'DONE' && w.status !== 'CANCELLED')
.sort((a, b) => (a.dueDate ?? '9999').localeCompare(b.dueDate ?? '9999'));
return (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<View style={{ flex: 1, padding: 14, gap: 10 }}>
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
Préventif
</Text>
<FlatList
data={grilles}
keyExtractor={(w) => w.id}
contentContainerStyle={{ gap: 8, paddingBottom: 12 }}
ListEmptyComponent={
<Text style={{ color: t.encre3, fontFamily: 'Manrope_600SemiBold', padding: 16, textAlign: 'center' }}>
Aucune grille préventive en cours pour vous.
</Text>
}
renderItem={({ item: w }) => (
<Pressable
accessibilityRole="button"
onPress={() => router.push(`/ot/${w.id}/grille`)}
style={{
backgroundColor: t.surface,
borderColor: t.bordure,
borderWidth: 1,
borderRadius: 12,
padding: 12,
gap: 2,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
<Text
style={{
flex: 1,
fontFamily: 'Manrope_700Bold',
fontSize: 10.5,
letterSpacing: 0.6,
textTransform: 'uppercase',
color: t.encre2,
}}
>
{w.reference}
</Text>
<ChipStatut statut={w.status} />
</View>
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 14, color: t.encre }}>
{w.title}
</Text>
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 12, color: t.encre2 }}>
Asc. {w.assetReference} {w.siteName}
{w.dueDate
? ` · pour le ${new Intl.DateTimeFormat('fr-FR', { day: 'numeric', month: 'short' }).format(new Date(w.dueDate))}`
: ''}
</Text>
</Pressable>
)}
/>
</View>
</SafeAreaView>
);
}

View File

@@ -0,0 +1,158 @@
import { CameraView, useCameraPermissions } from 'expo-camera';
import { router } from 'expo-router';
import { useRef, useState } from 'react';
import { Platform, Text, TextInput, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useAssets } from '@/api/exploitation';
import { useHorsLigne } from '@/auth/session';
import { BoutonTel } from '@/composants/ui';
import { analyseScan } from '@/lib/scan';
import { useTokens } from '@/theme/tokens';
/** Écran 4 de la maquette R4 (D4) : le QR de l'étiquette A6 contient l'URL
* portail `…/q/REF` — la résolution se fait D'ABORD dans le parc déjà en
* cache (donc en sous-sol aussi). Repli : saisie de la référence. */
export default function PageScanner() {
const t = useTokens();
const horsLigne = useHorsLigne();
const [permission, demanderPermission] = useCameraPermissions();
const { data: assets, refetch } = useAssets();
const [manuel, setManuel] = useState('');
const [erreur, setErreur] = useState<string | null>(null);
const dernierScan = useRef(0);
const resoudre = async (brut: string) => {
const reference = analyseScan(brut);
if (!reference) {
setErreur('Ce code nest pas une étiquette SIOP.');
return;
}
// 1 · le parc en cache (fonctionne hors-ligne)
let appareil = (assets ?? []).find((a) => a.reference.toUpperCase() === reference);
// 2 · sinon, un rafraîchissement si le réseau est là
if (!appareil && !horsLigne) {
const frais = await refetch();
appareil = (frais.data ?? []).find((a) => a.reference.toUpperCase() === reference);
}
if (!appareil) {
setErreur(
horsLigne
? `« ${reference} » n'est pas dans le parc synchronisé — réessayez au retour du réseau.`
: `Aucun appareil « ${reference} » dans le parc.`,
);
return;
}
setErreur(null);
setManuel('');
router.push(`/ascenseur/${appareil.id}`);
};
const surScan = ({ data }: { data: string }) => {
const maintenant = Date.now();
if (maintenant - dernierScan.current < 1500) return; // anti-rafale
dernierScan.current = maintenant;
void resoudre(data);
};
const cameraUtilisable = Platform.OS !== 'web' && permission?.granted;
return (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<View style={{ flex: 1, padding: 14, gap: 10 }}>
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
Scanner
</Text>
{cameraUtilisable ? (
<View style={{ flex: 1, borderRadius: 12, overflow: 'hidden' }}>
<CameraView
style={{ flex: 1 }}
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
onBarcodeScanned={surScan}
/>
<Text
style={{
position: 'absolute',
bottom: 12,
alignSelf: 'center',
color: '#dfe7f2',
fontFamily: 'Manrope_600SemiBold',
fontSize: 12.5,
}}
>
Visez le QR de létiquette de cabine
</Text>
</View>
) : (
<View
style={{
flex: 1,
borderRadius: 12,
backgroundColor: '#131c2c',
alignItems: 'center',
justifyContent: 'center',
gap: 12,
padding: 20,
}}
>
<View
style={{
width: 150,
height: 150,
borderRadius: 14,
borderWidth: 2.5,
borderColor: t.safran,
}}
/>
<Text
style={{
color: '#dfe7f2',
fontFamily: 'Manrope_600SemiBold',
fontSize: 12.5,
textAlign: 'center',
}}
>
{Platform.OS === 'web'
? 'Caméra indisponible sur web — saisissez la référence ci-dessous.'
: 'Lappareil photo sert uniquement à lire les étiquettes du parc.'}
</Text>
{Platform.OS !== 'web' && !permission?.granted ? (
<BoutonTel libelle="Autoriser la caméra" surAppui={() => void demanderPermission()} />
) : null}
</View>
)}
<View style={{ flexDirection: 'row', gap: 8 }}>
<TextInput
accessibilityLabel="Référence de l'appareil"
placeholder="Référence (A1, B2…)"
placeholderTextColor={t.encre3}
autoCapitalize="characters"
value={manuel}
onChangeText={(v) => {
setManuel(v);
setErreur(null);
}}
onSubmitEditing={() => void resoudre(manuel)}
style={{
flex: 1,
backgroundColor: t.surface,
borderColor: t.bordureForte,
borderWidth: 1.5,
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 10,
color: t.encre,
fontFamily: 'Manrope_600SemiBold',
}}
/>
<BoutonTel libelle="Ouvrir" desactive={!manuel.trim()} surAppui={() => void resoudre(manuel)} />
</View>
{erreur ? (
<Text style={{ color: t.danger, fontFamily: 'Manrope_600SemiBold', fontSize: 12.5 }}>
{erreur}
</Text>
) : null}
</View>
</SafeAreaView>
);
}

View File

@@ -0,0 +1,182 @@
import { useQueryClient } from '@tanstack/react-query';
import { router } from 'expo-router';
import { FlatList, Pressable, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useHorsLigne } from '@/auth/session';
import { BoutonTel } from '@/composants/ui';
import { useFile, type Saisie } from '@/file/store';
import { abandonnerSaisie, rejouer, rejouerSurVersionAJour } from '@/file/synchro';
import { useTokens } from '@/theme/tokens';
/** Écran 7 de la maquette R4 : la file VISIBLE et honnête. Rejeu dans
* l'ordre ; un conflit (verrou D2) arrête la file sur l'élément, montre le
* message de l'API (qui, quand) et VOUS tranchez — rien n'est écrasé ni
* perdu en silence. */
const ICONES: Record<Saisie['type'], string> = {
TRANSITION: '🏁',
COCHE: '✓',
BILAN: '📋',
PHOTO: '🖼',
};
export default function PageSynchro() {
const t = useTokens();
const horsLigne = useHorsLigne();
const file = useFile();
const queryClient = useQueryClient();
const conflit = file.find((s) => s.statut === 'CONFLIT');
const heure = (iso: string) =>
new Intl.DateTimeFormat('fr-FR', { hour: '2-digit', minute: '2-digit' }).format(new Date(iso));
return (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<View style={{ flex: 1, padding: 14, gap: 10 }}>
<View style={{ flexDirection: 'row', alignItems: 'baseline', gap: 8 }}>
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
Synchro
</Text>
<Text style={{ marginLeft: 'auto', fontFamily: 'Manrope_600SemiBold', fontSize: 11, color: t.encre3 }}>
{conflit ? 'arrêtée sur le conflit' : file.length ? 'rejouée dans lordre' : ''}
</Text>
</View>
{conflit ? (
<View
style={{
backgroundColor: t.prioBloqueFond,
borderColor: t.danger,
borderWidth: 1.5,
borderRadius: 12,
padding: 12,
gap: 8,
}}
>
<Text style={{ color: t.danger, fontFamily: 'Manrope_800ExtraBold', fontSize: 13 }}>
Conflit sur {conflit.otReference}
</Text>
<Text style={{ color: t.encre, fontFamily: 'Manrope_600SemiBold', fontSize: 12.5 }}>
{conflit.erreur ?? 'LOT a été modifié pendant que vous étiez hors-ligne.'}
</Text>
<Text style={{ color: t.encre2, fontFamily: 'Manrope_400Regular', fontSize: 11.5 }}>
Votre saisie (« {conflit.libelle} », {heure(conflit.creeA)}) est conservée telle
quelle dans la file rien nest perdu.
</Text>
<BoutonTel
libelle="Voir lOT à jour"
variante="contour"
surAppui={() => router.push(`/ot/${conflit.otId}`)}
/>
<BoutonTel
libelle="Rejouer ma saisie sur la version à jour"
surAppui={() => void rejouerSurVersionAJour(conflit.id, queryClient)}
/>
<BoutonTel
libelle="Abandonner ma saisie"
variante="gris"
surAppui={() => void abandonnerSaisie(conflit.id, queryClient)}
/>
</View>
) : null}
<FlatList
data={file}
keyExtractor={(s) => s.id}
contentContainerStyle={{ gap: 8, paddingBottom: 12 }}
ListEmptyComponent={
<View style={{ padding: 24, alignItems: 'center', gap: 6 }}>
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 15, color: t.encre }}>
Rien en attente
</Text>
<Text
style={{
fontFamily: 'Manrope_400Regular',
fontSize: 12.5,
color: t.encre2,
textAlign: 'center',
}}
>
Toutes vos saisies sont sur le serveur. Les gestes faits hors-ligne apparaîtront
ici, rejoués dans lordre au retour du réseau.
</Text>
</View>
}
renderItem={({ item: s }) => (
<View
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 10,
backgroundColor: t.surface,
borderColor: s.statut === 'CONFLIT' ? t.danger : t.bordure,
borderWidth: 1,
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 10,
}}
>
<View
style={{
width: 28,
height: 28,
borderRadius: 8,
backgroundColor: t.surface2,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Text style={{ fontSize: 13 }}>{ICONES[s.type]}</Text>
</View>
<View style={{ flex: 1 }}>
<Text numberOfLines={1} style={{ fontFamily: 'Manrope_700Bold', fontSize: 12.5, color: t.encre }}>
{s.libelle}
</Text>
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 11, color: t.encre2 }}>
{s.otReference} · {heure(s.creeA)}
</Text>
</View>
<Text
style={{
fontFamily: 'Manrope_700Bold',
fontSize: 10.5,
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 999,
overflow: 'hidden',
color:
s.statut === 'CONFLIT' ? t.danger : s.statut === 'ENVOI' ? t.stOuvert : t.stAttente,
backgroundColor:
s.statut === 'CONFLIT'
? t.prioBloqueFond
: s.statut === 'ENVOI'
? t.stOuvertFond
: t.stAttenteFond,
}}
>
{s.statut === 'CONFLIT' ? 'conflit' : s.statut === 'ENVOI' ? 'envoi…' : 'en attente'}
</Text>
</View>
)}
/>
{file.length && !horsLigne ? (
<Pressable
accessibilityRole="button"
onPress={() => void rejouer(queryClient)}
style={{ backgroundColor: t.primaire, borderRadius: 10, padding: 12, alignItems: 'center' }}
>
<Text style={{ color: '#fff', fontFamily: 'Manrope_700Bold', fontSize: 14 }}>
Rejouer la file maintenant
</Text>
</Pressable>
) : null}
{horsLigne ? (
<Text style={{ color: t.stAttente, fontFamily: 'Manrope_600SemiBold', fontSize: 12, textAlign: 'center' }}>
Hors-ligne la file repartira seule au retour du réseau.
</Text>
) : null}
</View>
</SafeAreaView>
);
}

View File

@@ -0,0 +1,80 @@
import {
Manrope_400Regular,
Manrope_600SemiBold,
Manrope_700Bold,
Manrope_800ExtraBold,
useFonts,
} from '@expo-google-fonts/manrope';
import NetInfo from '@react-native-community/netinfo';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister';
import { onlineManager, QueryClient } from '@tanstack/react-query';
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { useEffect, useState } from 'react';
import { HorsLigneProvider } from '@/auth/session';
import { chargerFile } from '@/file/store';
import { rejouer } from '@/file/synchro';
import { useTokens } from '@/theme/tokens';
/** Racine de l'app (D1 validée) : le cache TanStack est PERSISTÉ dans
* AsyncStorage — les OT lus restent lisibles hors-ligne, y compris après
* redémarrage. NetInfo pilote onlineManager (pas de retry dans le vide)
* et le drapeau hors-ligne des écrans. */
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60_000,
gcTime: 7 * 24 * 3600_000, // une semaine de lecture hors-ligne
retry: 1,
},
},
});
const persister = createAsyncStoragePersister({
storage: AsyncStorage,
key: 'siop.cache',
});
export default function RacineApp() {
const t = useTokens();
const [horsLigne, setHorsLigne] = useState(false);
const [polices] = useFonts({
Manrope_400Regular,
Manrope_600SemiBold,
Manrope_700Bold,
Manrope_800ExtraBold,
});
useEffect(() => {
// D1 : la file survit au redémarrage — on la recharge avant tout.
void chargerFile().then(() => rejouer(queryClient));
return NetInfo.addEventListener((etat) => {
const enLigne = !!etat.isConnected;
onlineManager.setOnline(enLigne);
setHorsLigne(!enLigne);
if (enLigne) void rejouer(queryClient); // le retour du réseau vide la file
});
}, []);
if (!polices) return null;
return (
<PersistQueryClientProvider
client={queryClient}
persistOptions={{ persister, maxAge: 7 * 24 * 3600_000 }}
>
<HorsLigneProvider valeur={horsLigne}>
<StatusBar style="auto" />
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: t.fond },
}}
/>
</HorsLigneProvider>
</PersistQueryClientProvider>
);
}

View File

@@ -0,0 +1,129 @@
import { router, useLocalSearchParams } from 'expo-router';
import { Pressable, ScrollView, Text } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { ASSET_STATUS_LABELS } from '@siop/shared';
import { useAsset, useWorkOrders } from '@/api/exploitation';
import { Carte, ChipStatut, EnteteFiche, LigneInfo } from '@/composants/ui';
import { triJournee } from '@/lib/journee';
import { useTokens } from '@/theme/tokens';
/** Écran 5 de la maquette R4 : tout ce qu'il faut DEVANT la machine.
* Consultation seule (D3) — l'historique montre ce que le rôle a le droit
* de voir (invariant « voir autre », même règle que partout). */
export default function PageAscenseur() {
const t = useTokens();
const { id } = useLocalSearchParams<{ id: string }>();
const { data: appareil } = useAsset(id);
const { data: workOrders } = useWorkOrders();
if (!appareil) return null;
const interventions = (workOrders ?? [])
.filter((w) => w.assetId === appareil.id)
.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
const enCours = triJournee(interventions);
const statut = ASSET_STATUS_LABELS[appareil.status];
return (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<ScrollView contentContainerStyle={{ padding: 14, gap: 10 }}>
<EnteteFiche
titre={`Asc. ${appareil.reference}`}
apres={
<Text
style={{
fontFamily: 'Manrope_700Bold',
fontSize: 10.5,
color: appareil.status === 'IN_SERVICE' ? t.stTermine : t.stAttente,
backgroundColor: appareil.status === 'IN_SERVICE' ? t.stTermineFond : t.stAttenteFond,
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 999,
overflow: 'hidden',
}}
>
{statut}
</Text>
}
/>
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 16, color: t.encre }}>
{appareil.brand}
{appareil.model ? ` ${appareil.model}` : ''} {appareil.siteName}
</Text>
<Carte titre="Identité">
<LigneInfo nom="Emplacement" valeur={appareil.locationName} />
<LigneInfo nom="Catégorie" valeur={appareil.categoryName} />
<LigneInfo
nom="Mise en service"
valeur={
appareil.commissionedAt
? new Intl.DateTimeFormat('fr-FR', { month: 'long', year: 'numeric' }).format(
new Date(appareil.commissionedAt),
)
: '—'
}
/>
<LigneInfo
nom="Charge / niveaux"
valeur={`${appareil.loadKg != null ? `${appareil.loadKg} kg` : '—'} · ${appareil.floors != null ? `${appareil.floors} niveaux` : '—'}`}
/>
{appareil.serialNumber ? (
<LigneInfo nom="N° de série" valeur={appareil.serialNumber} />
) : null}
</Carte>
<Carte titre="Organes suivis">
{appareil.components.length ? (
appareil.components.map((c) => (
<LigneInfo key={c.id} nom={c.typeName} valeur={c.designation ?? '—'} />
))
) : (
<Text style={{ color: t.encre3, fontFamily: 'Manrope_400Regular', fontSize: 12.5 }}>
Aucun organe déclaré.
</Text>
)}
</Carte>
<Carte titre={`Interventions visibles (${interventions.length})`}>
{interventions.slice(0, 6).map((w) => (
<Pressable
key={w.id}
accessibilityRole="button"
onPress={() => router.push(`/ot/${w.id}`)}
style={{ flexDirection: 'row', alignItems: 'center', gap: 8, paddingVertical: 3 }}
>
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 12.5, color: t.primaire }}>
{w.reference}
</Text>
<Text
numberOfLines={1}
style={{ flex: 1, fontFamily: 'Manrope_400Regular', fontSize: 12.5, color: t.encre }}
>
{w.title}
</Text>
<ChipStatut statut={w.status} />
</Pressable>
))}
{!interventions.length ? (
<Text style={{ color: t.encre3, fontFamily: 'Manrope_400Regular', fontSize: 12.5 }}>
Aucune intervention visible pour votre rôle.
</Text>
) : null}
</Carte>
{enCours.length ? (
<Pressable
accessibilityRole="button"
onPress={() => router.push(`/ot/${enCours[0]!.id}`)}
style={{ backgroundColor: t.primaire, borderRadius: 10, padding: 12, alignItems: 'center' }}
>
<Text style={{ color: '#fff', fontFamily: 'Manrope_700Bold', fontSize: 14 }}>
Ouvrir lOT en cours ({enCours[0]!.reference})
</Text>
</Pressable>
) : null}
</ScrollView>
</SafeAreaView>
);
}

View File

@@ -0,0 +1,150 @@
import { router } from 'expo-router';
import { useState } from 'react';
import {
KeyboardAvoidingView,
Platform,
Pressable,
ScrollView,
Text,
TextInput,
View,
} from 'react-native';
import { useDemoAccounts, useDemoLogin, useLogin } from '@/auth/session';
import { useTokens } from '@/theme/tokens';
/** 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)
* UNIQUEMENT si l'API l'expose. */
export default function PageConnexion() {
const t = useTokens();
const [email, setEmail] = useState('');
const [motDePasse, setMotDePasse] = useState('');
const { data: comptes } = useDemoAccounts();
const login = useLogin();
const demo = useDemoLogin();
const entrer = () => router.replace('/(tabs)/journee');
return (
<KeyboardAvoidingView
style={{ flex: 1, backgroundColor: t.fond }}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ScrollView contentContainerStyle={{ padding: 24, paddingTop: 80, gap: 12 }}>
<View style={{ flexDirection: 'row', alignItems: 'baseline', gap: 8 }}>
<Text style={{ color: t.safran, fontSize: 22 }}></Text>
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 26, color: t.encre }}>
SIOP
</Text>
<Text style={{ fontFamily: 'Manrope_600SemiBold', fontSize: 13, color: t.encre3 }}>
Technicien
</Text>
</View>
<Text style={{ fontFamily: 'Manrope_400Regular', color: t.encre2, marginBottom: 8 }}>
Vos ordres de travail, sur le terrain même sans réseau.
</Text>
<View style={{ gap: 10 }}>
<TextInput
accessibilityLabel="E-mail"
placeholder="E-mail"
placeholderTextColor={t.encre3}
autoCapitalize="none"
keyboardType="email-address"
value={email}
onChangeText={setEmail}
style={{
backgroundColor: t.surface,
borderColor: t.bordureForte,
borderWidth: 1.5,
borderRadius: 10,
padding: 13,
color: t.encre,
fontFamily: 'Manrope_600SemiBold',
}}
/>
<TextInput
accessibilityLabel="Mot de passe"
placeholder="Mot de passe"
placeholderTextColor={t.encre3}
secureTextEntry
value={motDePasse}
onChangeText={setMotDePasse}
style={{
backgroundColor: t.surface,
borderColor: t.bordureForte,
borderWidth: 1.5,
borderRadius: 10,
padding: 13,
color: t.encre,
fontFamily: 'Manrope_600SemiBold',
}}
/>
<Pressable
accessibilityRole="button"
disabled={!email || !motDePasse || login.isPending}
onPress={() =>
login.mutate({ email, password: motDePasse }, { onSuccess: entrer })
}
style={{
backgroundColor: !email || !motDePasse ? t.bordureForte : t.primaire,
borderRadius: 10,
padding: 14,
alignItems: 'center',
}}
>
<Text style={{ color: '#fff', fontFamily: 'Manrope_700Bold', fontSize: 15 }}>
{login.isPending ? 'Connexion…' : 'Se connecter'}
</Text>
</Pressable>
{login.isError ? (
<Text style={{ color: t.danger, fontFamily: 'Manrope_600SemiBold', fontSize: 13 }}>
{login.error.message}
</Text>
) : null}
</View>
{comptes?.length ? (
<View style={{ marginTop: 18, gap: 8 }}>
<Text
style={{
fontFamily: 'Manrope_700Bold',
fontSize: 11,
letterSpacing: 1,
textTransform: 'uppercase',
color: t.encre3,
}}
>
Mode démo connexion 1 clic
</Text>
{comptes.map((c) => (
<Pressable
key={c.id}
accessibilityRole="button"
disabled={demo.isPending}
onPress={() => demo.mutate(c.id, { onSuccess: entrer })}
style={{
backgroundColor: t.surface,
borderColor: t.bordure,
borderWidth: 1,
borderRadius: 10,
padding: 12,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<Text style={{ fontFamily: 'Manrope_700Bold', color: t.encre, fontSize: 14 }}>
{c.displayName}
</Text>
<Text style={{ fontFamily: 'Manrope_600SemiBold', color: t.encre3, fontSize: 12 }}>
{c.roleName}
</Text>
</Pressable>
))}
</View>
) : null}
</ScrollView>
</KeyboardAvoidingView>
);
}

20
apps/mobile/app/index.tsx Normal file
View File

@@ -0,0 +1,20 @@
import { Redirect } from 'expo-router';
import { ActivityIndicator, View } from 'react-native';
import { useMe } from '@/auth/session';
import { useTokens } from '@/theme/tokens';
/** Aiguillage : session valide → Ma journée ; sinon → connexion.
* (Hors-ligne avec cache persisté, /users/me sort du cache : on entre.) */
export default function Aiguillage() {
const t = useTokens();
const { data: me, isLoading } = useMe();
if (isLoading) {
return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: t.fond }}>
<ActivityIndicator color={t.primaire} />
</View>
);
}
return me ? <Redirect href="/(tabs)/journee" /> : <Redirect href="/connexion" />;
}

View File

@@ -0,0 +1,153 @@
import { router, useLocalSearchParams } from 'expo-router';
import { useState } from 'react';
import { ScrollView, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import {
BILAN_FIELD_LABELS,
BILAN_FIELDS,
REQUIRED_BILAN_FIELDS,
type BilanField,
type ReportUpsert,
} from '@siop/shared';
import { useQueryClient } from '@tanstack/react-query';
import { useReferenceValues, useWorkOrder } from '@/api/exploitation';
import { useHorsLigne } from '@/auth/session';
import { BoutonTel, Carte, ChoixTel, EnteteFiche } from '@/composants/ui';
import { enfilerBilan, enfilerTransition } from '@/file/actions';
import { useTokens } from '@/theme/tokens';
/** Écran 3 de la maquette R4 : le bilan codé R2 au pouce — 3 champs requis,
* garde visible et bloquante. Depuis R4.3, bilan et clôture partent EN
* FILE (D1) : hors-ligne, l'OT passe localement à « Terminé (en file) »
* et le serveur tranchera au rejeu (verrou D2 si l'OT a bougé). */
type ChampBilanId =
| 'doorStateId'
| 'cabinPositionId'
| 'anomalyId'
| 'externalCauseId'
| 'actionTakenId'
| 'componentConcernedId';
const CHAMP_VERS_ID: Record<BilanField, ChampBilanId> = {
DOOR_STATE: 'doorStateId',
CABIN_POSITION: 'cabinPositionId',
ANOMALY: 'anomalyId',
EXTERNAL_CAUSE: 'externalCauseId',
ACTION_TAKEN: 'actionTakenId',
COMPONENT_CONCERNED: 'componentConcernedId',
};
const CHAMP_VERS_VALEUR = {
DOOR_STATE: 'doorState',
CABIN_POSITION: 'cabinPosition',
ANOMALY: 'anomaly',
EXTERNAL_CAUSE: 'externalCause',
ACTION_TAKEN: 'actionTaken',
COMPONENT_CONCERNED: 'componentConcerned',
} as const;
export default function PageCloture() {
const t = useTokens();
const horsLigne = useHorsLigne();
const { id } = useLocalSearchParams<{ id: string }>();
const { data: ot } = useWorkOrder(id);
const { data: valeurs } = useReferenceValues();
const queryClient = useQueryClient();
const [choix, setChoix] = useState<Partial<Record<BilanField, string | null>>>({});
if (!ot) return null;
const optionsDe = (champ: BilanField) =>
(valeurs ?? []).filter((v: { field: string; isActive: boolean }) => v.field === champ && v.isActive);
/** Valeur affichée : le choix local s'il existe, sinon le bilan serveur. */
const valeurDe = (champ: BilanField): { id: string; label: string } | null => {
if (champ in choix) {
const vid = choix[champ];
if (!vid) return null;
const v = (valeurs ?? []).find((x) => x.id === vid);
return v ? { id: v.id, label: v.label } : null;
}
return ot.report?.[CHAMP_VERS_VALEUR[champ]] ?? null;
};
const enregistrer = () => {
const corps: ReportUpsert = {};
const labels: Parameters<typeof enfilerBilan>[3] = {};
for (const champ of BILAN_FIELDS) {
if (champ in choix) {
corps[CHAMP_VERS_ID[champ]] = choix[champ] ?? null;
labels[CHAMP_VERS_ID[champ]] = valeurDe(champ);
}
}
enfilerBilan(queryClient, ot, corps, labels);
};
const cloturer = () => {
enregistrer();
enfilerTransition(queryClient, ot, 'DONE');
router.replace(`/ot/${id}`);
};
const manquants = REQUIRED_BILAN_FIELDS.filter((c) => !valeurDe(c));
return (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<ScrollView contentContainerStyle={{ padding: 14, gap: 10 }}>
<EnteteFiche titre={`Clôturer ${ot.reference}`} />
<Carte titre="Bilan d'intervention — requis pour clôturer">
<View style={{ gap: 10 }}>
{[0, 2, 4].map((rang) => (
<View key={rang} style={{ flexDirection: 'row', gap: 8 }}>
{[BILAN_FIELDS[rang]!, BILAN_FIELDS[rang + 1]!].map((champ) => (
<ChoixTel
key={champ}
libelle={BILAN_FIELD_LABELS[champ]}
requis={REQUIRED_BILAN_FIELDS.includes(champ)}
valeur={valeurDe(champ)}
options={optionsDe(champ)}
surChoix={(vid) => setChoix((c) => ({ ...c, [champ]: vid }))}
/>
))}
</View>
))}
</View>
{manquants.length ? (
<Text style={{ color: t.alerte, fontFamily: 'Manrope_600SemiBold', fontSize: 12 }}>
{manquants.map((c) => `« ${BILAN_FIELD_LABELS[c]} »`).join(', ')}{' '}
{manquants.length > 1 ? 'manquent' : 'manque'} la clôture restera bloquée (même garde
que le web).
</Text>
) : null}
</Carte>
<BoutonTel
libelle="Enregistrer le bilan"
variante="contour"
surAppui={() => {
enregistrer();
router.replace(`/ot/${id}`);
}}
/>
<BoutonTel
libelle={
manquants.length
? 'Clôturer (bilan incomplet)'
: horsLigne
? 'Clôturer — partira à la synchro'
: "Clôturer l'intervention"
}
variante={manquants.length ? 'gris' : 'vert'}
desactive={manquants.length > 0}
surAppui={cloturer}
/>
{horsLigne ? (
<Text style={{ color: t.stAttente, fontFamily: 'Manrope_600SemiBold', fontSize: 12 }}>
Hors-ligne : la clôture est enregistrée sur le téléphone et sera rejouée telle quelle
au retour du réseau. Si lOT a changé entre-temps, VOUS trancherez (écran Synchro).
</Text>
) : null}
</ScrollView>
</SafeAreaView>
);
}

View File

@@ -0,0 +1,145 @@
import { useQueryClient } from '@tanstack/react-query';
import { useLocalSearchParams } from 'expo-router';
import { Pressable, ScrollView, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useWorkOrder } from '@/api/exploitation';
import { useHorsLigne } from '@/auth/session';
import { EnteteFiche } from '@/composants/ui';
import { enfilerCoche } from '@/file/actions';
import { useFile } from '@/file/store';
import { prochainEtat, progression } from '@/lib/checklist';
import { useTokens } from '@/theme/tokens';
/** Écran 6 de la maquette R4 : la grille au pouce. Appui simple = coche,
* appui long = non-applicable. Chaque coche part en file INDIVIDUELLEMENT
* (D1) : 7 coches faites = 7 coches sauvées, réseau ou pas. */
export default function PageGrille() {
const t = useTokens();
const horsLigne = useHorsLigne();
const { id } = useLocalSearchParams<{ id: string }>();
const { data: ot } = useWorkOrder(id);
const queryClient = useQueryClient();
const file = useFile();
if (!ot) return null;
const { faits, total, pct } = progression(ot.checklist);
const enFile = new Set(
file.flatMap((s) => (s.otId === ot.id && s.type === 'COCHE' ? [s.payload.itemId] : [])),
);
const basculer = (item: { id: string; label: string }, etat: Parameters<typeof prochainEtat>[0], versNA = false) => {
enfilerCoche(
queryClient,
ot,
item,
versNA ? (etat === 'NA' ? 'PENDING' : 'NA') : prochainEtat(etat),
);
};
return (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<ScrollView contentContainerStyle={{ padding: 14, gap: 10 }}>
<EnteteFiche
titre={`Grille — ${ot.assetReference}`}
apres={
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 13, color: t.encre2 }}>
{faits}/{total}
</Text>
}
/>
<View style={{ height: 8, borderRadius: 999, backgroundColor: t.surface2, overflow: 'hidden' }}>
<View
style={{ width: `${Math.round(pct * 100)}%`, height: '100%', backgroundColor: t.succes }}
/>
</View>
{horsLigne ? (
<View
style={{
backgroundColor: t.stAttenteFond,
borderColor: t.stAttente,
borderWidth: 1,
borderRadius: 10,
padding: 9,
}}
>
<Text style={{ color: t.stAttente, fontFamily: 'Manrope_700Bold', fontSize: 12 }}>
Hors-ligne chaque coche part en file et se synchronisera au retour du réseau.
</Text>
</View>
) : null}
{ot.checklist.map((item) => (
<Pressable
key={item.id}
accessibilityRole="checkbox"
aria-checked={item.state === 'DONE'}
accessibilityLabel={item.label}
onPress={() => basculer(item, item.state)}
onLongPress={() => basculer(item, item.state, true)}
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 10,
backgroundColor: t.surface,
borderColor: t.bordure,
borderWidth: 1,
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 11,
}}
>
<View
style={{
width: 22,
height: 22,
borderRadius: 6,
borderWidth: 2,
borderColor: item.state === 'DONE' ? t.succes : t.bordureForte,
backgroundColor: item.state === 'DONE' ? t.succes : 'transparent',
alignItems: 'center',
justifyContent: 'center',
}}
>
{item.state === 'DONE' ? (
<Text style={{ color: '#fff', fontSize: 13, fontFamily: 'Manrope_800ExtraBold' }}></Text>
) : item.state === 'NA' ? (
<Text style={{ color: t.encre3, fontSize: 10, fontFamily: 'Manrope_800ExtraBold' }}>NA</Text>
) : null}
</View>
<Text
style={{
flex: 1,
fontFamily: 'Manrope_600SemiBold',
fontSize: 13,
color: item.state === 'PENDING' ? t.encre : t.encre2,
}}
>
{item.label}
</Text>
{enFile.has(item.id) ? (
<Text
style={{
fontFamily: 'Manrope_800ExtraBold',
fontSize: 9,
color: t.stAttente,
backgroundColor: t.stAttenteFond,
paddingHorizontal: 6,
borderRadius: 999,
overflow: 'hidden',
}}
>
en file
</Text>
) : item.doneBy ? (
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 10.5, color: t.encre3 }}>
{item.doneBy.displayName.split(' ')[0]}
</Text>
) : null}
</Pressable>
))}
<Text style={{ color: t.encre3, fontFamily: 'Manrope_400Regular', fontSize: 11.5 }}>
Appui simple : cocher / décocher · appui long : non-applicable.
</Text>
</ScrollView>
</SafeAreaView>
);
}

View File

@@ -0,0 +1,196 @@
import { useQueryClient } from '@tanstack/react-query';
import { router, useLocalSearchParams } from 'expo-router';
import { ScrollView, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import {
WORK_ORDER_PRIORITY_LABELS,
WORK_ORDER_TYPE_LABELS,
} from '@siop/shared';
import { useWorkOrder } from '@/api/exploitation';
import { useHorsLigne } from '@/auth/session';
import { BoutonTel, Carte, ChipStatut, EnteteFiche, LigneInfo } from '@/composants/ui';
import { CartePhotos } from '@/composants/carte-photos';
import { enfilerTransition } from '@/file/actions';
import { useFile } from '@/file/store';
import { progression } from '@/lib/checklist';
import { useTokens } from '@/theme/tokens';
const fmt = new Intl.NumberFormat('fr-FR');
/** Écran 2 de la maquette R4 : la même machine à états que le web — UN
* bouton principal selon l'état. Depuis R4.3, chaque geste part en file
* (D1) : l'écran montre l'état local tout de suite, la synchro suit. */
export default function PageFicheOT() {
const t = useTokens();
const horsLigne = useHorsLigne();
const { id } = useLocalSearchParams<{ id: string }>();
const { data: ot } = useWorkOrder(id);
const queryClient = useQueryClient();
const file = useFile();
if (!ot) return null;
const grille = progression(ot.checklist);
const enFile = file.filter((s) => s.otId === ot.id);
const peutDemarrer = ot.allowedTransitions.includes('IN_PROGRESS');
const peutCloturer = ot.allowedTransitions.includes('DONE');
const peutSuspendre = ot.allowedTransitions.includes('ON_HOLD');
const demarrer = () => enfilerTransition(queryClient, ot, 'IN_PROGRESS');
return (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<ScrollView contentContainerStyle={{ padding: 14, gap: 10 }}>
<EnteteFiche
titre={ot.reference}
apres={
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
{enFile.length ? (
<Text
style={{
fontFamily: 'Manrope_800ExtraBold',
fontSize: 10,
color: t.stAttente,
backgroundColor: t.stAttenteFond,
paddingHorizontal: 7,
paddingVertical: 2,
borderRadius: 999,
overflow: 'hidden',
}}
>
{enFile.length} en file
</Text>
) : null}
<ChipStatut statut={ot.status} />
</View>
}
/>
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 17, color: t.encre }}>
{ot.title}
</Text>
{horsLigne ? (
<View
style={{
backgroundColor: t.stAttenteFond,
borderColor: t.stAttente,
borderWidth: 1,
borderRadius: 10,
padding: 9,
}}
>
<Text style={{ color: t.stAttente, fontFamily: 'Manrope_700Bold', fontSize: 12 }}>
Hors-ligne vos gestes partent en file et se synchroniseront au retour du réseau.
</Text>
</View>
) : null}
<Carte titre="Intervention">
<LigneInfo nom="Type" valeur={WORK_ORDER_TYPE_LABELS[ot.type]} />
<LigneInfo
nom="Équipement"
valeur={`Asc. ${ot.assetReference}${ot.siteName}`}
/>
<LigneInfo
nom="Priorité"
valeur={
<Text
style={{
fontFamily: 'Manrope_700Bold',
fontSize: 12.5,
color: ot.priority === 'PERSON_TRAPPED' || ot.priority === 'HIGH' ? t.prioHaute : t.encre,
}}
>
{WORK_ORDER_PRIORITY_LABELS[ot.priority]}
</Text>
}
/>
<LigneInfo
nom="Échéance"
valeur={
ot.dueDate
? new Intl.DateTimeFormat('fr-FR', { dateStyle: 'long' }).format(new Date(ot.dueDate))
: '—'
}
/>
{ot.request ? (
<LigneInfo nom="Demande liée" valeur={`${ot.request.reference} · ${ot.request.requesterLabel}`} />
) : null}
</Carte>
<Carte titre={`Pièces & main-d'œuvre — ${fmt.format(ot.costs.total)} MAD`}>
{ot.costs.parts.map((p) => (
<LigneInfo key={p.id} nom={`${p.designation} × ${p.quantity}`} valeur={`${fmt.format(p.total)}`} />
))}
{ot.costs.labor.map((l) => (
<LigneInfo
key={l.id}
nom={`${l.displayName} · ${Math.floor(l.minutes / 60)} h ${String(l.minutes % 60).padStart(2, '0')} × ${fmt.format(l.hourlyRate)}/h`}
valeur={`${fmt.format(l.total)}`}
/>
))}
{!ot.costs.parts.length && !ot.costs.labor.length ? (
<Text style={{ color: t.encre3, fontFamily: 'Manrope_400Regular', fontSize: 12.5 }}>
Aucune consommation ni temps saisi. (Saisies pièces/temps : depuis le web en R4.2.)
</Text>
) : null}
</Carte>
{ot.checklist.length ? (
<Carte titre={`Checklist liée — ${grille.faits}/${grille.total}`}>
<BoutonTel
libelle={grille.faits === grille.total ? 'Grille complète ✓ — revoir' : 'Cocher la grille'}
variante="contour"
surAppui={() => router.push(`/ot/${ot.id}/grille`)}
/>
</Carte>
) : null}
{ot.closureBlockers.length && (peutCloturer || ot.status === 'IN_PROGRESS') ? (
<Text style={{ color: t.alerte, fontFamily: 'Manrope_600SemiBold', fontSize: 12 }}>
{ot.closureBlockers.join(' · ')}
</Text>
) : null}
{peutDemarrer ? (
<BoutonTel
libelle={ot.status === 'ON_HOLD' ? 'Reprendre' : 'Démarrer'}
surAppui={demarrer}
/>
) : null}
{peutCloturer ? (
<BoutonTel
libelle="Clôturer l'intervention"
variante="vert"
surAppui={() => router.push(`/ot/${ot.id}/cloture`)}
/>
) : null}
{peutSuspendre ? (
<BoutonTel
libelle="Mettre en attente"
variante="contour"
surAppui={() => enfilerTransition(queryClient, ot, 'ON_HOLD')}
/>
) : null}
<CartePhotos ot={ot} />
<Carte titre="Activité">
{ot.events.slice(0, 5).map((e) => (
<View key={e.id} style={{ gap: 1 }}>
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 12, color: t.encre }}>
{e.kind}
{e.message ? `${e.message}` : ''}
</Text>
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 11, color: t.encre3 }}>
{new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium', timeStyle: 'short' }).format(
new Date(e.createdAt),
)}
{e.by ? ` · ${e.by.displayName}` : ''}
</Text>
</View>
))}
</Carte>
</ScrollView>
</SafeAreaView>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
apps/mobile/assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -0,0 +1,11 @@
/** jest-expo (convention CLAUDE.md) — R4.1 teste la logique pure (tri de
* Ma journée, tokens bi-thème) ; les parcours complets passeront par la
* recette sur appareil. */
module.exports = {
preset: 'jest-expo',
setupFiles: ['./jest.setup.js'],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
testMatch: ['**/*.test.ts'],
};

View File

@@ -0,0 +1,4 @@
/* AsyncStorage : mock officiel du paquet (pas de natif sous jest). */
jest.mock('@react-native-async-storage/async-storage', () =>
require('@react-native-async-storage/async-storage/jest/async-storage-mock'),
);

51
apps/mobile/package.json Normal file
View File

@@ -0,0 +1,51 @@
{
"name": "@siop/mobile",
"version": "0.1.0",
"main": "expo-router/entry",
"dependencies": {
"@expo-google-fonts/manrope": "^0.4.2",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-community/netinfo": "12.0.1",
"@siop/shared": "workspace:*",
"@tanstack/query-async-storage-persister": "^5.101.2",
"@tanstack/react-query": "^5.101.2",
"@tanstack/react-query-persist-client": "^5.101.2",
"expo": "~57.0.6",
"expo-camera": "~57.0.3",
"expo-constants": "~57.0.5",
"expo-font": "~57.0.1",
"expo-image-manipulator": "~57.0.4",
"expo-image-picker": "~57.0.4",
"expo-linking": "~57.0.3",
"expo-router": "~57.0.6",
"expo-secure-store": "~57.0.1",
"expo-status-bar": "~57.0.1",
"openapi-fetch": "^0.13.8",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-native": "0.86.0",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "4.25.2",
"react-native-web": "^0.21.2"
},
"devDependencies": {
"@types/jest": "^29.5.14",
"@types/react": "~19.2.2",
"jest": "^29.7.0",
"jest-expo": "^57.0.2",
"openapi-typescript": "^7.13.0",
"react-test-renderer": "^19.2.7",
"typescript": "~6.0.3"
},
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "jest",
"generate:client": "openapi-typescript ../../docs/openapi.json -o src/api/schema.d.ts",
"lint": "eslint app src"
},
"private": true
}

View File

@@ -0,0 +1,31 @@
import createClient from 'openapi-fetch';
import type { paths } from './schema';
import { lireJeton } from './jeton';
/** Client typé — généré depuis docs/openapi.json (règle d'or ADR-001 :
* spec committée, clients régénérés dans le même commit).
* 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 = createClient<paths>({ baseUrl: API_URL });
api.use({
async onRequest({ request }) {
const jeton = await lireJeton();
if (jeton) request.headers.set('Authorization', `Bearer ${jeton}`);
return request;
},
});
export async function unwrap<T>(res: {
data?: T;
error?: unknown;
response: Response;
}): Promise<T> {
if (res.data !== undefined) return res.data;
const message =
res.error && typeof res.error === 'object' && 'message' in res.error
? String((res.error as { message: unknown }).message)
: `API injoignable ou refus (${res.response.status})`;
throw new Error(message);
}

View File

@@ -0,0 +1,103 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { ChecklistState, ReportUpsert, WorkOrderStatus } from '@siop/shared';
import { api, unwrap } from './client';
/** 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). */
export function useWorkOrders() {
return useQuery({
queryKey: ['work-orders'],
queryFn: async () => (await unwrap(await api.GET('/work-orders'))).workOrders,
});
}
export function useWorkOrder(id: string) {
return useQuery({
queryKey: ['work-orders', id],
queryFn: async () =>
unwrap(await api.GET('/work-orders/{id}', { params: { path: { id } } })),
});
}
export function useAssets() {
return useQuery({
queryKey: ['assets'],
queryFn: async () => (await unwrap(await api.GET('/assets'))).assets,
});
}
export function useAsset(id: string) {
return useQuery({
queryKey: ['assets', id],
queryFn: async () => unwrap(await api.GET('/assets/{id}', { params: { path: { id } } })),
});
}
export function useDocumentsOT(workOrderId: string) {
return useQuery({
queryKey: ['documents', workOrderId],
queryFn: async () =>
(
await unwrap(
await api.GET('/documents', { params: { query: { workOrderId } } }),
)
).documents,
});
}
export function useReferenceValues() {
return useQuery({
queryKey: ['reference-values'],
staleTime: 3600_000, // référentiels administrables : stables en journée
queryFn: async () => (await unwrap(await api.GET('/reference-values'))).referenceValues,
});
}
function useInvalideOT(id: string) {
const queryClient = useQueryClient();
return () =>
Promise.all([
queryClient.invalidateQueries({ queryKey: ['work-orders', id] }),
queryClient.invalidateQueries({ queryKey: ['work-orders'] }),
]);
}
export function useTransition(id: string) {
const invalide = useInvalideOT(id);
return useMutation({
mutationFn: async (input: { to: WorkOrderStatus; comment?: string }) =>
unwrap(
await api.POST('/work-orders/{id}/transition', {
params: { path: { id } },
body: input,
}),
),
onSuccess: invalide,
});
}
export function useCocheChecklist(otId: string) {
const invalide = useInvalideOT(otId);
return useMutation({
mutationFn: async (input: { itemId: string; state: ChecklistState }) =>
unwrap(
await api.PATCH('/work-orders/{id}/checklist/{itemId}', {
params: { path: { id: otId, itemId: input.itemId } },
body: { state: input.state },
}),
),
onSuccess: invalide,
});
}
export function useBilan(otId: string) {
const invalide = useInvalideOT(otId);
return useMutation({
mutationFn: async (body: ReportUpsert) =>
unwrap(
await api.PUT('/work-orders/{id}/report', { params: { path: { id: otId } }, body }),
),
onSuccess: invalide,
});
}

View File

@@ -0,0 +1,22 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import * as SecureStore from 'expo-secure-store';
import { Platform } from 'react-native';
/** Le jeton vit dans le trousseau du téléphone (SecureStore) ; sur web
* (vérifications de dev seulement), repli AsyncStorage. */
const CLE = 'siop.jeton';
const natif = Platform.OS !== 'web';
export async function lireJeton(): Promise<string | null> {
return natif ? SecureStore.getItemAsync(CLE) : AsyncStorage.getItem(CLE);
}
export async function ecrireJeton(jeton: string): Promise<void> {
if (natif) await SecureStore.setItemAsync(CLE, jeton);
else await AsyncStorage.setItem(CLE, jeton);
}
export async function effacerJeton(): Promise<void> {
if (natif) await SecureStore.deleteItemAsync(CLE);
else await AsyncStorage.removeItem(CLE);
}

4120
apps/mobile/src/api/schema.d.ts vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,83 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { createContext, useContext, type ReactNode } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { api, unwrap } from '@/api/client';
import { ecrireJeton, effacerJeton } from '@/api/jeton';
import { viderFile } from '@/file/store';
/** Session mobile — mêmes règles que le web : le sélecteur démo n'existe
* que si l'API répond (ADR-002 : 404 sinon, une seule source de vérité). */
export function useMe() {
return useQuery({
queryKey: ['me'],
retry: false,
queryFn: async () => unwrap(await api.GET('/users/me')),
});
}
export function useDemoAccounts() {
return useQuery({
queryKey: ['demo-accounts'],
retry: false,
queryFn: async () => {
const res = await api.GET('/auth/demo-accounts');
if (res.response.status === 404) return null; // démo désactivée
return (await unwrap(res)).accounts;
},
});
}
function useApresConnexion() {
const queryClient = useQueryClient();
return async (accessToken: string) => {
await ecrireJeton(accessToken);
await queryClient.invalidateQueries();
};
}
export function useLogin() {
const apres = useApresConnexion();
return useMutation({
mutationFn: async (input: { email: string; password: string }) => {
const res = await unwrap(await api.POST('/auth/login', { body: input }));
await apres(res.accessToken);
},
});
}
export function useDemoLogin() {
const apres = useApresConnexion();
return useMutation({
mutationFn: async (userId: string) => {
const res = await unwrap(await api.POST('/auth/demo-login', { body: { userId } }));
await apres(res.accessToken);
},
});
}
export function useLogout() {
const queryClient = useQueryClient();
return async () => {
// Rien ne survit à la session sur l'appareil : jeton (trousseau), file
// d'écriture, cache OT persisté (loi 09-08 — minimisation).
await effacerJeton();
viderFile();
queryClient.clear();
await AsyncStorage.removeItem('siop.cache');
};
}
/** Drapeau hors-ligne partagé (pastille topbar + bandeaux d'écrans). */
export const HorsLigneContexte = createContext(false);
export const useHorsLigne = () => useContext(HorsLigneContexte);
export function HorsLigneProvider({
valeur,
children,
}: {
valeur: boolean;
children: ReactNode;
}) {
return <HorsLigneContexte.Provider value={valeur}>{children}</HorsLigneContexte.Provider>;
}

View File

@@ -0,0 +1,40 @@
import { Text, View } from 'react-native';
import { useTokens } from '@/theme/tokens';
/** Écran futur du périmètre R4 annoncé — même patron que la sidebar web :
* visible, honnête sur sa release, jamais un cul-de-sac silencieux. */
export function AVenir({ titre, release, detail }: { titre: string; release: string; detail: string }) {
const t = useTokens();
return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', padding: 32, gap: 8 }}>
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 18, color: t.encre }}>
{titre}
</Text>
<Text
style={{
fontFamily: 'Manrope_700Bold',
fontSize: 11,
color: t.safran,
backgroundColor: t.safranDoux,
paddingHorizontal: 10,
paddingVertical: 3,
borderRadius: 999,
overflow: 'hidden',
}}
>
Disponible en {release}
</Text>
<Text
style={{
fontFamily: 'Manrope_400Regular',
fontSize: 13,
color: t.encre2,
textAlign: 'center',
maxWidth: 280,
}}
>
{detail}
</Text>
</View>
);
}

View File

@@ -0,0 +1,98 @@
import { useQueryClient } from '@tanstack/react-query';
import * as ImageManipulator from 'expo-image-manipulator';
import * as ImagePicker from 'expo-image-picker';
import { Alert, Pressable, Text, View } from 'react-native';
import type { WorkOrderDetail } from '@siop/shared';
import { useDocumentsOT } from '@/api/exploitation';
import { Carte } from '@/composants/ui';
import { enfilerPhoto } from '@/file/actions';
import { useFile } from '@/file/store';
import { useTokens } from '@/theme/tokens';
/** Photos d'intervention (D5) : compressées côté app (~1 600 px) puis mises
* EN FILE comme le reste — rattachées à l'OT dans la bibliothèque R3.
* Ni audio ni géolocalisation en R4 (loi 09-08, minimisation). */
export function CartePhotos({ ot }: { ot: WorkOrderDetail }) {
const t = useTokens();
const queryClient = useQueryClient();
const file = useFile();
const { data: documents } = useDocumentsOT(ot.id);
const photosServeur = (documents ?? []).filter((d) => d.contentType.startsWith('image/'));
const photosEnFile = file.filter((s) => s.otId === ot.id && s.type === 'PHOTO');
const prendre = async () => {
const resultat = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ['images'],
quality: 0.9,
});
const image = resultat.assets?.[0];
if (!image) return;
try {
// Compression D5 : jamais un original de 12 Mo dans la file.
const contexte = ImageManipulator.ImageManipulator.manipulate(image.uri);
if (image.width > 1600) contexte.resize({ width: 1600 });
const rendu = await contexte.renderAsync();
const sauve = await rendu.saveAsync({
compress: 0.7,
format: ImageManipulator.SaveFormat.JPEG,
});
enfilerPhoto(queryClient, ot, {
uri: sauve.uri,
nom: `intervention-${ot.reference}-${Date.now().toString(36)}.jpg`,
mime: 'image/jpeg',
});
} catch (e) {
Alert.alert('Photo impossible', e instanceof Error ? e.message : 'Erreur inconnue');
}
};
return (
<Carte titre={`Photos (${photosServeur.length + photosEnFile.length})`}>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
{photosServeur.map((d) => (
<View key={d.id} style={vignette(t.surface2, t.bordure)}>
<Text style={{ fontSize: 18 }}>🖼</Text>
</View>
))}
{photosEnFile.map((s) => (
<View key={s.id} style={vignette(t.stAttenteFond, t.stAttente)}>
<Text style={{ fontSize: 18 }}>🖼</Text>
<Text
style={{
position: 'absolute',
bottom: 2,
fontFamily: 'Manrope_800ExtraBold',
fontSize: 8,
color: t.stAttente,
}}
>
en file
</Text>
</View>
))}
<Pressable
accessibilityRole="button"
accessibilityLabel="Ajouter une photo"
onPress={() => void prendre()}
style={vignette('transparent', t.bordureForte, true)}
>
<Text style={{ fontSize: 22, color: t.primaire }}>+</Text>
</Pressable>
</View>
</Carte>
);
}
const vignette = (fond: string, bordure: string, pointille = false) =>
({
width: 56,
height: 56,
borderRadius: 9,
backgroundColor: fond,
borderWidth: 1.5,
borderColor: bordure,
borderStyle: pointille ? ('dashed' as const) : ('solid' as const),
alignItems: 'center' as const,
justifyContent: 'center' as const,
}) as const;

View File

@@ -0,0 +1,265 @@
import { router } from 'expo-router';
import { useState, type ReactNode } from 'react';
import { Modal, Pressable, ScrollView, Text, View } from 'react-native';
import { WORK_ORDER_STATUS_LABELS, type WorkOrderStatus } from '@siop/shared';
import { useTokens, type Tokens } from '@/theme/tokens';
/** Briques d'écran de la maquette R4 — cartes, chips, boutons, sélecteur. */
export function Carte({ titre, children }: { titre?: string; children: ReactNode }) {
const t = useTokens();
return (
<View
style={{
backgroundColor: t.surface,
borderColor: t.bordure,
borderWidth: 1,
borderRadius: 12,
padding: 12,
gap: 8,
}}
>
{titre ? (
<Text
style={{
fontFamily: 'Manrope_700Bold',
fontSize: 10.5,
letterSpacing: 0.8,
textTransform: 'uppercase',
color: t.encre2,
}}
>
{titre}
</Text>
) : null}
{children}
</View>
);
}
export function LigneInfo({ nom, valeur }: { nom: string; valeur: ReactNode }) {
const t = useTokens();
return (
<View style={{ flexDirection: 'row', justifyContent: 'space-between', gap: 10 }}>
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 12.5, color: t.encre2 }}>
{nom}
</Text>
{typeof valeur === 'string' ? (
<Text
style={{
fontFamily: 'Manrope_700Bold',
fontSize: 12.5,
color: t.encre,
flexShrink: 1,
textAlign: 'right',
}}
>
{valeur}
</Text>
) : (
valeur
)}
</View>
);
}
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],
};
export function ChipStatut({ statut }: { statut: WorkOrderStatus }) {
const t = useTokens();
const [encre, fond] = STYLE_STATUT[statut](t);
return (
<Text
style={{
fontFamily: 'Manrope_700Bold',
fontSize: 10.5,
color: encre,
backgroundColor: fond,
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 999,
overflow: 'hidden',
}}
>
{WORK_ORDER_STATUS_LABELS[statut]}
</Text>
);
}
export function BoutonTel({
libelle,
variante = 'prim',
desactive,
surAppui,
}: {
libelle: string;
variante?: 'prim' | 'vert' | 'contour' | 'gris';
desactive?: boolean;
surAppui: () => void;
}) {
const t = useTokens();
const fonds = { prim: t.primaire, vert: t.succes, contour: t.surface, gris: t.bordureForte };
return (
<Pressable
accessibilityRole="button"
disabled={desactive}
onPress={surAppui}
style={{
backgroundColor: desactive ? t.bordureForte : fonds[variante],
borderWidth: variante === 'contour' ? 1.5 : 0,
borderColor: t.primaire,
borderRadius: 10,
padding: 12,
alignItems: 'center',
}}
>
<Text
style={{
fontFamily: 'Manrope_700Bold',
fontSize: 14,
color: variante === 'contour' && !desactive ? t.primaire : '#fff',
}}
>
{libelle}
</Text>
</Pressable>
);
}
export function EnteteFiche({ titre, apres }: { titre: string; apres?: ReactNode }) {
const t = useTokens();
return (
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<Pressable
accessibilityRole="button"
accessibilityLabel="Retour"
onPress={() => (router.canGoBack() ? router.back() : router.replace('/(tabs)/journee'))}
hitSlop={10}
>
<Text style={{ fontSize: 22, color: t.primaire, fontFamily: 'Manrope_700Bold' }}></Text>
</Pressable>
<Text
numberOfLines={1}
style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 17, color: t.encre, flex: 1 }}
>
{titre}
</Text>
{apres}
</View>
);
}
/** Sélecteur au pouce (RN n'a pas de <select>) : un champ qui ouvre une
* liste plein écran — utilisé par le bilan codé. */
export function ChoixTel({
libelle,
requis,
valeur,
options,
surChoix,
}: {
libelle: string;
requis?: boolean;
valeur: { id: string; label: string } | null;
options: { id: string; label: string }[];
surChoix: (id: string | null) => void;
}) {
const t = useTokens();
const [ouvert, setOuvert] = useState(false);
return (
<View style={{ gap: 4, flex: 1 }}>
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 11, color: t.encre2 }}>
{libelle} {requis ? <Text style={{ color: t.danger }}>*</Text> : null}
</Text>
<Pressable
accessibilityRole="button"
accessibilityLabel={libelle}
onPress={() => setOuvert(true)}
style={{
borderWidth: 1.5,
borderColor: t.bordureForte,
borderRadius: 9,
backgroundColor: t.surface,
paddingHorizontal: 10,
paddingVertical: 9,
}}
>
<Text
numberOfLines={1}
style={{
fontFamily: 'Manrope_600SemiBold',
fontSize: 13,
color: valeur ? t.encre : t.encre3,
}}
>
{valeur ? valeur.label : 'Sélectionner…'}
</Text>
</Pressable>
<Modal visible={ouvert} animationType="slide" transparent onRequestClose={() => setOuvert(false)}>
<Pressable
style={{ flex: 1, backgroundColor: 'rgba(9,14,22,.55)' }}
onPress={() => setOuvert(false)}
/>
<View
style={{
backgroundColor: t.surface,
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
maxHeight: '70%',
padding: 14,
gap: 4,
}}
>
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 15, color: t.encre }}>
{libelle}
</Text>
<ScrollView>
<Pressable
accessibilityRole="button"
onPress={() => {
surChoix(null);
setOuvert(false);
}}
style={{ paddingVertical: 11 }}
>
<Text style={{ fontFamily: 'Manrope_600SemiBold', fontSize: 14, color: t.encre3 }}>
(laisser vide)
</Text>
</Pressable>
{options.map((o) => (
<Pressable
key={o.id}
accessibilityRole="button"
onPress={() => {
surChoix(o.id);
setOuvert(false);
}}
style={{
paddingVertical: 11,
borderTopWidth: 1,
borderTopColor: t.bordure,
}}
>
<Text
style={{
fontFamily: 'Manrope_600SemiBold',
fontSize: 14,
color: o.id === valeur?.id ? t.primaire : t.encre,
}}
>
{o.label} {o.id === valeur?.id ? '✓' : ''}
</Text>
</Pressable>
))}
</ScrollView>
</View>
</Modal>
</View>
);
}

View File

@@ -0,0 +1,125 @@
import type { QueryClient } from '@tanstack/react-query';
import {
WORK_ORDER_STATUS_LABELS,
WORK_ORDER_TRANSITIONS,
type ChecklistState,
type ReportUpsert,
type WorkOrderDetail,
type WorkOrderStatus,
} from '@siop/shared';
import { enfiler, lireFile } from './store';
import { rejouer } from './synchro';
/** Chaque geste terrain = une saisie en file + un patch OPTIMISTE du cache
* (le technicien voit son travail tout de suite, même en mode avion — le
* cache persisté garde ce patch après redémarrage). En ligne, la file se
* vide dans la foulée : le comportement R4.2 est conservé. */
function patchDetail(
queryClient: QueryClient,
otId: string,
patch: (ot: WorkOrderDetail) => WorkOrderDetail,
): void {
queryClient.setQueryData<WorkOrderDetail>(['work-orders', otId], (courant) =>
courant ? patch(courant) : courant,
);
}
export function enfilerTransition(
queryClient: QueryClient,
ot: WorkOrderDetail,
to: WorkOrderStatus,
): void {
enfiler({
type: 'TRANSITION',
otId: ot.id,
otReference: ot.reference,
libelle:
to === 'DONE' ? 'Clôture de lintervention' : `Passage à « ${WORK_ORDER_STATUS_LABELS[to]} »`,
baseUpdatedAt: ot.updatedAt,
payload: { to },
});
patchDetail(queryClient, ot.id, (c) => ({
...c,
status: to,
allowedTransitions: WORK_ORDER_TRANSITIONS[to],
}));
void rejouer(queryClient);
}
export function enfilerCoche(
queryClient: QueryClient,
ot: WorkOrderDetail,
item: { id: string; label: string },
state: ChecklistState,
): void {
enfiler({
type: 'COCHE',
otId: ot.id,
otReference: ot.reference,
libelle: `Coche « ${item.label} » → ${state === 'DONE' ? 'fait' : state === 'NA' ? 'N/A' : 'à faire'}`,
baseUpdatedAt: ot.updatedAt,
payload: { itemId: item.id, state },
});
patchDetail(queryClient, ot.id, (c) => ({
...c,
checklist: c.checklist.map((i) => (i.id === item.id ? { ...i, state } : i)),
}));
void rejouer(queryClient);
}
export function enfilerBilan(
queryClient: QueryClient,
ot: WorkOrderDetail,
corps: Omit<ReportUpsert, 'baseUpdatedAt'>,
labels: Partial<Record<keyof ReportUpsert, { id: string; label: string } | null>>,
): void {
enfiler({
type: 'BILAN',
otId: ot.id,
otReference: ot.reference,
libelle: 'Bilan dintervention codé',
baseUpdatedAt: ot.updatedAt,
payload: corps,
});
patchDetail(queryClient, ot.id, (c) => ({
...c,
report: {
note: c.report?.note ?? null,
doorState: labels.doorStateId !== undefined ? (labels.doorStateId ?? null) : (c.report?.doorState ?? null),
cabinPosition:
labels.cabinPositionId !== undefined ? (labels.cabinPositionId ?? null) : (c.report?.cabinPosition ?? null),
anomaly: labels.anomalyId !== undefined ? (labels.anomalyId ?? null) : (c.report?.anomaly ?? null),
externalCause:
labels.externalCauseId !== undefined ? (labels.externalCauseId ?? null) : (c.report?.externalCause ?? null),
actionTaken:
labels.actionTakenId !== undefined ? (labels.actionTakenId ?? null) : (c.report?.actionTaken ?? null),
componentConcerned:
labels.componentConcernedId !== undefined
? (labels.componentConcernedId ?? null)
: (c.report?.componentConcerned ?? null),
},
}));
void rejouer(queryClient);
}
export function enfilerPhoto(
queryClient: QueryClient,
ot: WorkOrderDetail,
fichier: { uri: string; nom: string; mime: string },
): void {
enfiler({
type: 'PHOTO',
otId: ot.id,
otReference: ot.reference,
libelle: `Photo — ${fichier.nom}`,
baseUpdatedAt: ot.updatedAt,
payload: fichier,
});
void rejouer(queryClient);
}
/** Ce que la file porte encore pour cet OT — chips « en file » des écrans. */
export function saisiesPour(otId: string) {
return lireFile().filter((s) => s.otId === otId);
}

View File

@@ -0,0 +1,93 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useSyncExternalStore } from 'react';
import type { ChecklistState, ReportUpsert, WorkOrderStatus } from '@siop/shared';
/** La file d'écriture (D1) : chaque geste hors-ligne devient une saisie
* datée, persistée dans AsyncStorage — elle survit au redémarrage — et
* rejouée DANS L'ORDRE au retour du réseau. Chaque saisie porte la version
* de l'OT lue au moment du geste (D2, verrou optimiste). */
export type Saisie = {
id: string;
creeA: string; // ISO — affiché à l'écran Synchro
otId: string;
otReference: string;
libelle: string; // « Coche “Jeu des coulisseaux” »
baseUpdatedAt: string; // version lue (D2)
statut: 'EN_ATTENTE' | 'ENVOI' | 'CONFLIT';
erreur?: string; // message du 409 / refus métier — montré tel quel
} & (
| { type: 'TRANSITION'; payload: { to: WorkOrderStatus } }
| { type: 'COCHE'; payload: { itemId: string; state: ChecklistState } }
| { type: 'BILAN'; payload: Omit<ReportUpsert, 'baseUpdatedAt'> }
| { type: 'PHOTO'; payload: { uri: string; nom: string; mime: string } }
);
const CLE = 'siop.file.v1';
let file: Saisie[] = [];
const abonnes = new Set<() => void>();
function notifier(): void {
for (const a of abonnes) a();
void AsyncStorage.setItem(CLE, JSON.stringify(file));
}
/** À l'ouverture de l'app : la file survit au redémarrage (D1). */
export async function chargerFile(): Promise<void> {
const brut = await AsyncStorage.getItem(CLE);
if (brut) {
// Un ENVOI interrompu par un crash redevient EN_ATTENTE (rejouable).
file = (JSON.parse(brut) as Saisie[]).map((s) =>
s.statut === 'ENVOI' ? { ...s, statut: 'EN_ATTENTE' } : s,
);
for (const a of abonnes) a();
}
}
export const lireFile = (): Saisie[] => file;
export function enfiler(saisie: Omit<Saisie, 'id' | 'creeA' | 'statut'>): Saisie {
const complete = {
...saisie,
id: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
creeA: new Date().toISOString(),
statut: 'EN_ATTENTE',
} as Saisie;
file = [...file, complete];
notifier();
return complete;
}
export function majSaisie(id: string, patch: Partial<Pick<Saisie, 'statut' | 'erreur' | 'baseUpdatedAt'>>): void {
file = file.map((s) => (s.id === id ? ({ ...s, ...patch } as Saisie) : s));
notifier();
}
export function retirer(id: string): void {
file = file.filter((s) => s.id !== id);
notifier();
}
export function useFile(): Saisie[] {
return useSyncExternalStore(
(cb) => {
abonnes.add(cb);
return () => abonnes.delete(cb);
},
lireFile,
lireFile,
);
}
/** Déconnexion : la file appartient à la SESSION — on la purge (sécurité :
* un autre compte sur le même téléphone ne doit jamais rejouer les saisies
* du précédent), avec sa persistance. */
export function viderFile(): void {
file = [];
notifier();
}
/** Réservé aux tests : repartir d'une file vide (sans persistance). */
export function _viderPourTests(): void {
file = [];
}

View File

@@ -0,0 +1,93 @@
import { QueryClient } from '@tanstack/react-query';
import { enfiler, lireFile, retirer, _viderPourTests, type Saisie } from './store';
import { rejouer, type ResultatEnvoi } from './synchro';
/** Le contrat D1/D2 de la file, testé sans réseau : rejeu DANS L'ORDRE,
* arrêt sur conflit (l'humain tranche), reprise après coupure. */
const qc = new QueryClient();
const saisie = (libelle: string): Saisie =>
enfiler({
type: 'TRANSITION',
otId: 'ot-1',
otReference: 'OT-TEST',
libelle,
baseUpdatedAt: '2026-07-17T10:00:00.000Z',
payload: { to: 'IN_PROGRESS' },
}) && lireFile().at(-1)!;
beforeEach(() => _viderPourTests());
describe('rejouer (file D1/D2)', () => {
it('rejoue dans lordre et vide la file quand tout passe', async () => {
saisie('a');
saisie('b');
const envoyes: string[] = [];
await rejouer(qc, async (s) => {
envoyes.push(s.libelle);
return { ok: true };
});
expect(envoyes).toEqual(['a', 'b']);
expect(lireFile()).toHaveLength(0);
});
it('conflit : la saisie passe en CONFLIT et la file SARRÊTE là — rien nest perdu', async () => {
saisie('a');
saisie('b');
saisie('c');
const envoyer = async (s: Saisie): Promise<ResultatEnvoi> =>
s.libelle === 'b'
? { ok: false, reseau: false, message: 'Conflit de version : modifié par Salma à 14 h 38' }
: { ok: true };
await rejouer(qc, envoyer);
const file = lireFile();
expect(file.map((s) => [s.libelle, s.statut])).toEqual([
['b', 'CONFLIT'],
['c', 'EN_ATTENTE'], // derrière le conflit : on attend l'humain
]);
expect(file[0]!.erreur).toContain('Salma');
});
it('coupure réseau : tout reste EN_ATTENTE, rejouable au retour', async () => {
saisie('a');
saisie('b');
await rejouer(qc, async () => ({ ok: false, reseau: true, message: 'Réseau indisponible' }));
expect(lireFile().map((s) => s.statut)).toEqual(['EN_ATTENTE', 'EN_ATTENTE']);
// le réseau revient
await rejouer(qc, async () => ({ ok: true }));
expect(lireFile()).toHaveLength(0);
});
it('après « abandonner » le conflit, le reste de la file repart', async () => {
saisie('a');
saisie('b');
await rejouer(qc, async (s) =>
s.libelle === 'a' ? { ok: false, reseau: false, message: 'refus métier' } : { ok: true },
);
expect(lireFile()).toHaveLength(2);
retirer(lireFile()[0]!.id); // l'humain abandonne la saisie en conflit
await rejouer(qc, async () => ({ ok: true }));
expect(lireFile()).toHaveLength(0);
});
});
describe('propagation de version (nos écritures ne se conflictent pas entre elles)', () => {
it('au succès, la version fraîche se propage aux saisies restantes du même OT', async () => {
saisie('a');
saisie('b');
await rejouer(qc, async (s) =>
s.libelle === 'a' ? { ok: true, nouvelleVersion: '2026-07-17T11:00:00.000Z' } : { ok: true },
);
// b a été envoyée après propagation — vérifions via un rejeu espion
_viderPourTests();
saisie('c');
saisie('d');
const basesVues: string[] = [];
await rejouer(qc, async (s) => {
basesVues.push(s.baseUpdatedAt);
return { ok: true, nouvelleVersion: 'V-FRAICHE' };
});
expect(basesVues).toEqual(['2026-07-17T10:00:00.000Z', 'V-FRAICHE']);
});
});

View File

@@ -0,0 +1,160 @@
import type { QueryClient } from '@tanstack/react-query';
import { api } from '@/api/client';
import { lireJeton } from '@/api/jeton';
import { API_URL } from '@/api/client';
import { lireFile, majSaisie, retirer, type Saisie } from './store';
/** Rejeu de la file (D1/D2) : dans l'ordre, une saisie à la fois.
* - succès → la saisie sort de la file ;
* - coupure réseau → tout reste EN_ATTENTE, on réessaiera ;
* - refus (409 verrou, garde métier) → la saisie passe en CONFLIT et la
* file S'ARRÊTE LÀ : l'humain tranche à l'écran Synchro. */
export type ResultatEnvoi =
| { ok: true; nouvelleVersion?: string }
| { ok: false; reseau: boolean; message: string };
export async function envoyerSaisie(s: Saisie): Promise<ResultatEnvoi> {
try {
let status: number;
let message = '';
let nouvelleVersion: string | undefined;
if (s.type === 'TRANSITION') {
const res = await api.POST('/work-orders/{id}/transition', {
params: { path: { id: s.otId } },
body: { to: s.payload.to, baseUpdatedAt: s.baseUpdatedAt },
});
status = res.response.status;
message = messageDe(res.error);
nouvelleVersion = res.data?.updatedAt;
} else if (s.type === 'COCHE') {
const res = await api.PATCH('/work-orders/{id}/checklist/{itemId}', {
params: { path: { id: s.otId, itemId: s.payload.itemId } },
body: { state: s.payload.state, baseUpdatedAt: s.baseUpdatedAt },
});
status = res.response.status;
message = messageDe(res.error);
} else if (s.type === 'BILAN') {
const res = await api.PUT('/work-orders/{id}/report', {
params: { path: { id: s.otId } },
body: { ...s.payload, baseUpdatedAt: s.baseUpdatedAt },
});
status = res.response.status;
message = messageDe(res.error);
nouvelleVersion = res.data?.updatedAt;
} else {
// PHOTO — multipart hors client typé (D5) : fichier compressé en file.
const form = new FormData();
form.append('kind', 'PHOTO');
form.append('workOrderId', s.otId);
if (s.payload.uri.startsWith('http') || s.payload.uri.startsWith('blob:') || s.payload.uri.startsWith('data:')) {
const blob = await (await fetch(s.payload.uri)).blob();
form.append('file', new File([blob], s.payload.nom, { type: s.payload.mime }));
} else {
// URI de fichier natif : React Native sait téléverser {uri, name, type}
form.append('file', {
uri: s.payload.uri,
name: s.payload.nom,
type: s.payload.mime,
} as unknown as Blob);
}
const jeton = await lireJeton();
const res = await fetch(`${API_URL}/documents`, {
method: 'POST',
headers: jeton ? { Authorization: `Bearer ${jeton}` } : undefined,
body: form,
});
status = res.status;
if (!res.ok) {
const corps = (await res.json().catch(() => null)) as { message?: string } | null;
message = corps?.message ?? `Téléversement refusé (${res.status})`;
}
}
if (status < 400) {
// Coche/photo ne renvoient pas le détail : on relit la version pour
// que les saisies suivantes du lot ne se heurtent pas à NOS écritures.
if (!nouvelleVersion) {
const frais = await api.GET('/work-orders/{id}', { params: { path: { id: s.otId } } });
nouvelleVersion = frais.data?.updatedAt;
}
return { ok: true, nouvelleVersion };
}
return { ok: false, reseau: false, message: message || `Refus (${status})` };
} catch {
return { ok: false, reseau: true, message: 'Réseau indisponible' };
}
}
function messageDe(erreur: unknown): string {
return erreur && typeof erreur === 'object' && 'message' in erreur
? String((erreur as { message: unknown }).message)
: '';
}
let enCours = false;
export async function rejouer(
queryClient: QueryClient,
envoyer: (s: Saisie) => Promise<ResultatEnvoi> = envoyerSaisie,
): Promise<void> {
if (enCours) return;
enCours = true;
try {
for (;;) {
const suivante = lireFile().find((s) => s.statut === 'EN_ATTENTE');
if (!suivante) break;
// Un conflit plus ancien barre la route : l'ordre est la promesse D1.
const conflitAvant = lireFile().some(
(s) => s.statut === 'CONFLIT' && s.creeA <= suivante.creeA,
);
if (conflitAvant) break;
majSaisie(suivante.id, { statut: 'ENVOI' });
const resultat = await envoyer(suivante);
if (resultat.ok) {
retirer(suivante.id);
// Notre propre écriture a fait avancer la version : les saisies
// restantes du même OT repartent de là (un écart ÉTRANGER ultérieur
// restera détecté par le verrou).
if (resultat.nouvelleVersion) {
for (const s of lireFile()) {
if (s.otId === suivante.otId && s.statut === 'EN_ATTENTE') {
majSaisie(s.id, { baseUpdatedAt: resultat.nouvelleVersion });
}
}
}
await queryClient.invalidateQueries({ queryKey: ['work-orders'] });
await queryClient.invalidateQueries({ queryKey: ['documents'] });
} else if (resultat.reseau) {
majSaisie(suivante.id, { statut: 'EN_ATTENTE' });
break; // le réseau reviendra — rien n'est perdu
} else {
majSaisie(suivante.id, { statut: 'CONFLIT', erreur: resultat.message });
break; // l'humain tranche (D2)
}
}
} finally {
enCours = false;
}
}
/** Résolutions du conflit (maquette écran 7). */
export async function rejouerSurVersionAJour(
saisieId: string,
queryClient: QueryClient,
): Promise<void> {
const saisie = lireFile().find((s) => s.id === saisieId);
if (!saisie) return;
const frais = await api.GET('/work-orders/{id}', { params: { path: { id: saisie.otId } } });
if (frais.data) {
majSaisie(saisieId, { statut: 'EN_ATTENTE', baseUpdatedAt: frais.data.updatedAt, erreur: undefined });
await rejouer(queryClient);
}
}
export async function abandonnerSaisie(saisieId: string, queryClient: QueryClient): Promise<void> {
retirer(saisieId);
// On recharge la vérité serveur : le patch optimiste local est annulé.
await queryClient.invalidateQueries({ queryKey: ['work-orders'] });
await rejouer(queryClient);
}

View File

@@ -0,0 +1,25 @@
import { prochainEtat, progression } from './checklist';
describe('checklist (écran 6 de la maquette R4)', () => {
it('progression : DONE et NA comptent comme traités (règle de la garde R2)', () => {
const { faits, total, pct } = progression([
{ state: 'DONE' },
{ state: 'NA' },
{ state: 'PENDING' },
{ state: 'PENDING' },
]);
expect(faits).toBe(2);
expect(total).toBe(4);
expect(pct).toBe(0.5);
});
it('grille vide : pas de division par zéro', () => {
expect(progression([]).pct).toBe(0);
});
it('appui simple : PENDING ⇄ DONE ; depuis NA on revient à DONE', () => {
expect(prochainEtat('PENDING')).toBe('DONE');
expect(prochainEtat('DONE')).toBe('PENDING');
expect(prochainEtat('NA')).toBe('DONE');
});
});

View File

@@ -0,0 +1,19 @@
import type { ChecklistState } from '@siop/shared';
/** Progression d'une grille : cochée OU non-applicable = traitée (même
* règle que la garde de clôture R2). */
export function progression(items: readonly { state: ChecklistState }[]): {
faits: number;
total: number;
pct: number;
} {
const total = items.length;
const faits = items.filter((i) => i.state !== 'PENDING').length;
return { faits, total, pct: total ? faits / total : 0 };
}
/** Le geste au pouce : appui simple PENDING ⇄ DONE ; le N/A passe par
* l'appui long (assumé à l'écran). Depuis NA, un appui revient à DONE. */
export function prochainEtat(actuel: ChecklistState): ChecklistState {
return actuel === 'PENDING' ? 'DONE' : actuel === 'DONE' ? 'PENDING' : 'DONE';
}

View File

@@ -0,0 +1,44 @@
import { triJournee, type OtJournee } from './journee';
const ot = (sur: Partial<OtJournee>): OtJournee => ({
priority: 'NONE',
status: 'OPEN',
dueDate: null,
createdAt: '2026-07-01T08:00:00.000Z',
...sur,
});
describe('triJournee (écran 1 de la maquette R4)', () => {
it('met « personne bloquée » en tête, puis la priorité décroissante', () => {
const tri = triJournee([
ot({ priority: 'LOW' }),
ot({ priority: 'PERSON_TRAPPED' }),
ot({ priority: 'HIGH' }),
ot({ priority: 'MEDIUM' }),
]);
expect(tri.map((o) => o.priority)).toEqual(['PERSON_TRAPPED', 'HIGH', 'MEDIUM', 'LOW']);
});
it('à priorité égale : échéance la plus proche dabord, sans échéance en dernier', () => {
const tri = triJournee([
ot({ priority: 'HIGH', dueDate: null, createdAt: '2026-07-01T08:00:00.000Z' }),
ot({ priority: 'HIGH', dueDate: '2026-07-20T00:00:00.000Z' }),
ot({ priority: 'HIGH', dueDate: '2026-07-18T00:00:00.000Z' }),
]);
expect(tri.map((o) => o.dueDate)).toEqual([
'2026-07-18T00:00:00.000Z',
'2026-07-20T00:00:00.000Z',
null,
]);
});
it('écarte les OT terminés et annulés — la journée ne montre que là-faire', () => {
const tri = triJournee([
ot({ status: 'DONE' }),
ot({ status: 'IN_PROGRESS' }),
ot({ status: 'CANCELLED' }),
ot({ status: 'ON_HOLD' }),
]);
expect(tri.map((o) => o.status)).toEqual(['IN_PROGRESS', 'ON_HOLD']);
});
});

View File

@@ -0,0 +1,32 @@
import type { WorkOrderPriority, WorkOrderStatus } from '@siop/shared';
/** Tri de « Ma journée » (maquette R4, écran 1) : priorité décroissante
* (personne bloquée en tête) puis échéance la plus proche ; les OT clos
* ou annulés n'apparaissent pas. Fonction pure — testée. */
const POIDS: Record<WorkOrderPriority, number> = {
PERSON_TRAPPED: 4,
HIGH: 3,
MEDIUM: 2,
LOW: 1,
NONE: 0,
};
export interface OtJournee {
priority: WorkOrderPriority;
status: WorkOrderStatus;
dueDate: string | null;
createdAt: string;
}
export function triJournee<T extends OtJournee>(ots: readonly T[]): T[] {
return ots
.filter((o) => o.status !== 'DONE' && o.status !== 'CANCELLED')
.sort(
(a, b) =>
POIDS[b.priority] - POIDS[a.priority] ||
(a.dueDate ? Date.parse(a.dueDate) : Infinity) -
(b.dueDate ? Date.parse(b.dueDate) : Infinity) ||
Date.parse(a.createdAt) - Date.parse(b.createdAt),
);
}

View File

@@ -0,0 +1,21 @@
import { analyseScan } from './scan';
describe('analyseScan (D4 — QR des étiquettes A6)', () => {
it("extrait la référence de l'URL portail, quel que soit le domaine", () => {
expect(analyseScan('https://siop2.apps.enset.top/q/A1')).toBe('A1');
expect(analyseScan('http://localhost:5173/q/B2')).toBe('B2');
expect(analyseScan('https://client.spelev.ma/q/MC-1')).toBe('MC-1');
});
it('accepte une référence tapée à la main, normalisée en majuscules', () => {
expect(analyseScan(' a1 ')).toBe('A1');
expect(analyseScan('b2')).toBe('B2');
});
it('refuse ce qui nest pas à nous — jamais décran blanc sur un QR étranger', () => {
expect(analyseScan('https://example.com/promo?x=1')).toBeNull();
expect(analyseScan('WIFI:T:WPA;S:box;P:secret;;')).toBeNull();
expect(analyseScan('')).toBeNull();
expect(analyseScan('référence avec espaces')).toBeNull();
});
});

View File

@@ -0,0 +1,16 @@
/** Analyse du scan (D4) : le QR des étiquettes A6 (R1) encode l'URL du
* portail public `…/q/REF`. On accepte aussi une référence tapée à la main.
* Retour : la référence normalisée, ou null si le code n'est pas un nôtre. */
const REF_VALIDE = /^[A-Z0-9][A-Z0-9-]{0,11}$/;
export function analyseScan(brut: string): string | null {
const texte = brut.trim();
if (!texte) return null;
// URL d'étiquette : http(s)://…/q/A1 (quel que soit le domaine d'origine)
const url = texte.match(/^https?:\/\/[^\s]+\/q\/([^\s/?#]+)$/i);
const candidat = decodeURIComponent(url?.[1] ?? texte).toUpperCase();
return REF_VALIDE.test(candidat) ? candidat : null;
}

View File

@@ -0,0 +1,20 @@
import { CLAIR, SOMBRE } from './tokens';
describe('tokens SIOP portés en RN', () => {
it('le sombre couvre exactement les clés du clair (pas de token orphelin)', () => {
expect(Object.keys(SOMBRE).sort()).toEqual(Object.keys(CLAIR).sort());
});
it('réplique les valeurs pivots de docs/02-design/tokens.css', () => {
expect(CLAIR.primaire).toBe('#1f4fb8');
expect(CLAIR.safran).toBe('#dd8a0b');
expect(SOMBRE.fond).toBe('#0f1622');
expect(SOMBRE.primaire).toBe('#6e92e8');
});
it('chaque valeur est une couleur hex ou transparent', () => {
for (const jeu of [CLAIR, SOMBRE]) {
for (const v of Object.values(jeu)) expect(v).toMatch(/^#[0-9a-f]{6}$/);
}
});
});

View File

@@ -0,0 +1,80 @@
import { useColorScheme } from 'react-native';
/** Tokens SIOP portés en RN — répliques de docs/02-design/tokens.css
* (mêmes valeurs que le web et les maquettes R4, bi-thème). */
export const CLAIR = {
primaire: '#1f4fb8',
primaireDoux: '#eaf0fb',
safran: '#dd8a0b',
safranDoux: '#fdf3e3',
fond: '#f5f7fa',
surface: '#ffffff',
surface2: '#eef2f7',
bordure: '#dce3ec',
bordureForte: '#b9c4d4',
encre: '#1b2534',
encre2: '#55647a',
encre3: '#8494ab',
stOuvert: '#3d6fe0',
stOuvertFond: '#e9effc',
stEncours: '#6d5bd8',
stEncoursFond: '#efecfa',
stAttente: '#b96f07',
stAttenteFond: '#fbf1df',
stTermine: '#178a50',
stTermineFond: '#e6f5ec',
stAnnule: '#68788f',
stAnnuleFond: '#edf0f4',
prioBloque: '#d92626',
prioBloqueFond: '#fdeaea',
prioHaute: '#d92626',
prioMoyenne: '#b96f07',
prioBasse: '#178a50',
prioAucune: '#8494ab',
succes: '#178a50',
alerte: '#b96f07',
danger: '#d92626',
navFond: '#16233b',
} as const;
export const SOMBRE: Tokens = {
primaire: '#6e92e8',
primaireDoux: '#1d2c4c',
safran: '#e89a1f',
safranDoux: '#33270f',
fond: '#0f1622',
surface: '#182234',
surface2: '#1e2a40',
bordure: '#2b3850',
bordureForte: '#3d4d6b',
encre: '#e8edf5',
encre2: '#a7b4c8',
encre3: '#6d7d96',
stOuvert: '#7da2ee',
stOuvertFond: '#1c2a47',
stEncours: '#a394ec',
stEncoursFond: '#262040',
stAttente: '#e0a33c',
stAttenteFond: '#322510',
stTermine: '#4bc084',
stTermineFond: '#12301f',
stAnnule: '#93a3ba',
stAnnuleFond: '#222d3f',
prioBloque: '#f26d6d',
prioBloqueFond: '#3a1414',
prioHaute: '#f26d6d',
prioMoyenne: '#e0a33c',
prioBasse: '#4bc084',
prioAucune: '#6d7d96',
succes: '#4bc084',
alerte: '#e0a33c',
danger: '#f26d6d',
navFond: '#0c1524',
};
export type Tokens = { [K in keyof typeof CLAIR]: string };
export function useTokens(): Tokens {
return useColorScheme() === 'dark' ? SOMBRE : CLAIR;
}

10
apps/mobile/tsconfig.json Normal file
View File

@@ -0,0 +1,10 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"types": ["jest"],
"paths": {
"@/*": ["./src/*"]
}
}
}

View File

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

View File

@@ -2,8 +2,10 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter, Route, Routes } from 'react-router-dom'; import { BrowserRouter, Route, Routes } from 'react-router-dom';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { Coquille } from '@/layout/coquille'; import { Coquille } from '@/layout/coquille';
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 PageBibliotheque from '@/pages/bibliotheque';
import PageCategories from '@/pages/categories'; import PageCategories from '@/pages/categories';
import PageCompteurs from '@/pages/compteurs'; import PageCompteurs from '@/pages/compteurs';
import PageConnexion from '@/pages/connexion'; import PageConnexion from '@/pages/connexion';
@@ -12,7 +14,9 @@ import PageDesign from '@/pages/design';
import PageEtiquette from '@/pages/etiquette'; import PageEtiquette from '@/pages/etiquette';
import PageFicheAscenseur from '@/pages/fiche-ascenseur'; import PageFicheAscenseur from '@/pages/fiche-ascenseur';
import PageFicheOT from '@/pages/fiche-ot'; import PageFicheOT from '@/pages/fiche-ot';
import PageFichePiece from '@/pages/fiche-piece';
import PageFicheSite from '@/pages/fiche-site'; import PageFicheSite from '@/pages/fiche-site';
import PageNouvelAchat from '@/pages/nouvel-achat';
import PageNouvelAscenseur from '@/pages/nouvel-ascenseur'; import PageNouvelAscenseur from '@/pages/nouvel-ascenseur';
import PageNouvelOT from '@/pages/nouvel-ot'; import PageNouvelOT from '@/pages/nouvel-ot';
import PageOrdresTravail from '@/pages/ordres-travail'; import PageOrdresTravail from '@/pages/ordres-travail';
@@ -20,7 +24,10 @@ import PagePersonnes from '@/pages/personnes';
import PagePortail from '@/pages/portail'; import PagePortail from '@/pages/portail';
import PagePreventif from '@/pages/preventif'; import PagePreventif from '@/pages/preventif';
import PageSites from '@/pages/sites'; import PageSites from '@/pages/sites';
import PageStatistiques from '@/pages/statistiques';
import PageStock from '@/pages/stock';
import PageTableauDeBord from '@/pages/tableau-de-bord'; import PageTableauDeBord from '@/pages/tableau-de-bord';
import PageTiers from '@/pages/tiers';
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { queries: { refetchOnWindowFocus: false } }, defaultOptions: { queries: { refetchOnWindowFocus: false } },
@@ -53,6 +60,13 @@ export default function App() {
<Route path="/ot/:id" element={dansCoquille(<PageFicheOT />)} /> <Route path="/ot/:id" element={dansCoquille(<PageFicheOT />)} />
<Route path="/demandes" element={dansCoquille(<PageDemandes />)} /> <Route path="/demandes" element={dansCoquille(<PageDemandes />)} />
<Route path="/preventif" element={dansCoquille(<PagePreventif />)} /> <Route path="/preventif" element={dansCoquille(<PagePreventif />)} />
<Route path="/stock" element={dansCoquille(<PageStock />)} />
<Route path="/stock/:id" element={dansCoquille(<PageFichePiece />)} />
<Route path="/achats" element={dansCoquille(<PageAchats />)} />
<Route path="/achats/nouveau" element={dansCoquille(<PageNouvelAchat />)} />
<Route path="/tiers" element={dansCoquille(<PageTiers />)} />
<Route path="/bibliotheque" element={dansCoquille(<PageBibliotheque />)} />
<Route path="/statistiques" element={dansCoquille(<PageStatistiques />)} />
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
</QueryClientProvider> </QueryClientProvider>

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

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

View File

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

View File

@@ -7,6 +7,7 @@ import type {
CategoryUpdate, CategoryUpdate,
InvitationCreate, InvitationCreate,
LocationCreate, LocationCreate,
LocationUpdate,
TeamCreate, TeamCreate,
UserUpdate, UserUpdate,
} from '@siop/shared'; } from '@siop/shared';
@@ -98,6 +99,15 @@ export function useCreateLocation() {
}); });
} }
export function useUpdateLocation(id: string) {
const invalidate = useInvalidate('locations', 'partners');
return useMutation({
mutationFn: async (body: LocationUpdate) =>
unwrap(await api.PATCH('/locations/{id}', { params: { path: { id } }, body })),
onSuccess: invalidate,
});
}
export function useCreateAsset() { export function useCreateAsset() {
const invalidate = useInvalidate('assets', 'locations'); const invalidate = useInvalidate('assets', 'locations');
return useMutation({ return useMutation({

File diff suppressed because it is too large Load Diff

View File

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

View File

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

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