mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-09 13:11:54 +00:00
Compare commits
22 Commits
release/r1
...
release/r4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5e8ca6d62 | ||
|
|
c8b3c1769a | ||
|
|
a22ea60f83 | ||
|
|
514fc7c391 | ||
|
|
5c8d5aac92 | ||
|
|
2736a2f3a1 | ||
|
|
3d787a2d39 | ||
|
|
460ef4a80e | ||
|
|
c46a97ed08 | ||
|
|
c5382a5538 | ||
|
|
a4e98c2000 | ||
|
|
2ffdd13cf4 | ||
|
|
32eb20b5a0 | ||
|
|
dfdf8f7c18 | ||
|
|
d5041c6f05 | ||
|
|
3aa7189e96 | ||
|
|
f7c0da1b0f | ||
|
|
50254daea4 | ||
|
|
6a2dae1710 | ||
|
|
902efa6192 | ||
|
|
f7702e4252 | ||
|
|
54d926e9f4 |
33
.github/workflows/ci.yml
vendored
33
.github/workflows/ci.yml
vendored
@@ -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:
|
||||||
|
|||||||
17
CLAUDE.md
17
CLAUDE.md
@@ -44,5 +44,20 @@ pnpm + Turborepo. `apps/api` : NestJS, Prisma, PostgreSQL (pgvector + PostGIS),
|
|||||||
- ✅ **R1.1 — socle backend** : migration `r1_referentiel` (Category, Location site→zone + PostGIS générée, Asset, AssetComponent, Team, invitation User), 21 nouvelles opérations au contrat (26 total), 4 modules API + invitations/activation sous `@RequirePermission`, seed parc maquette (5 sites, 8 appareils, organes, 2 équipes), 36 tests (96 %/85 %), CI sur `postgis/postgis:18-3.6`.
|
- ✅ **R1.1 — socle backend** : migration `r1_referentiel` (Category, Location site→zone + PostGIS générée, Asset, AssetComponent, Team, invitation User), 21 nouvelles opérations au contrat (26 total), 4 modules API + invitations/activation sous `@RequirePermission`, seed parc maquette (5 sites, 8 appareils, organes, 2 équipes), 36 tests (96 %/85 %), CI sur `postgis/postgis:18-3.6`.
|
||||||
- ✅ **R1.2 — écrans web du référentiel** : 7 écrans fidèles à maquette-r1.html (Sites + carte Leaflet/OSM, fiche site, ascenseurs, fiche appareil + QR réel, création, étiquette A6 imprimable, personnes & équipes avec lien d'activation à copier, catégories) + page /activation ; navigation et actions pilotées par la matrice (`usePermissions`) ; recette R1 rejouée en e2e Playwright (dont activation d'un invité) ; alias Vite `@siop/shared` → source TS (leçon CJS/workspace).
|
- ✅ **R1.2 — écrans web du référentiel** : 7 écrans fidèles à maquette-r1.html (Sites + carte Leaflet/OSM, fiche site, ascenseurs, fiche appareil + QR réel, création, étiquette A6 imprimable, personnes & équipes avec lien d'activation à copier, catégories) + page /activation ; navigation et actions pilotées par la matrice (`usePermissions`) ; recette R1 rejouée en e2e Playwright (dont activation d'un invité) ; alias Vite `@siop/shared` → source TS (leçon CJS/workspace).
|
||||||
- 🏁 **R1 CLOSE (16/07/2026, tag `release/r1`)** : recettée par le référent, déployée et vérifiée en ligne (migration + seed au boot, 5 sites / 8 appareils sur l'instance).
|
- 🏁 **R1 CLOSE (16/07/2026, tag `release/r1`)** : recettée par le référent, déployée et vérifiée en ligne (migration + seed au boot, 5 sites / 8 appareils sur l'instance).
|
||||||
- 🔄 **R2 Exploitation — reprise ici, design d'abord** : maquettes R0 déjà validées pour tableau de bord / liste OT / fiche OT (bilan codé) / portail demandeur ; **à maquetter avant tout code : préventif** (gabarits à périodicité, grille du mois, checklist) et écrans secondaires (création OT/demande). Ensuite : modèle R2 (WorkOrder machine à états, Request 1-1, InterventionReport bilan codé, TaskTemplate/PreventivePlan/ChecklistItem, Meter) → API → web → mobile-ready.
|
- ✅ **R2 — maquettes validées** (16/07) : `maquette-r2.html` (demandes/approbation, nouvel OT, préventif, checklist, compteurs) + 4 décisions actées.
|
||||||
|
- ✅ **R2.1 — socle backend exploitation** : migration `r2_exploitation` (10 tables), 15 opérations au contrat (41 total, transitions + champs requis du bilan dans `@siop/shared`), machine à états stricte avec garde de clôture (bilan 3 champs + checklist), demande→OT 1-1, rejet à motif, scoping « voir autre » (listes + accès directs), seed maquette (31 valeurs de bilan, 8 gabarits, OT/demandes/compteurs), 45 tests (95 %/79 %).
|
||||||
|
- ✅ **R2.2 — préventif + compteurs** : `underContract` + `periodKey` (unicité `[assetId, periodKey]` = idempotence EN BASE), génération mensuelle (ancrage mise en service, premier contrôle, gabarits administrables, statut du mois), compteurs strictement croissants ; +7 opérations (48), 50 tests (94,7 %/78 %). Automatisation cron/BullMQ notée pour le durcissement production.
|
||||||
|
- ✅ **R2.3 — écrans web exploitation** : liste/fiche OT (transitions via `allowedTransitions`, garde visible, bilan codé, checklist cliquable, activité), demandes+approbation/rejet motivé, nouvel OT (interrupteur urgence), préventif (tuiles+générer+gabarits), compteurs, tableau de bord réel, urgence traversante (chip topbar, badges, bandeau) ; `GET /assets/options` (trou Demandeur corrigé) ; retry sur collision de référence dans la génération ; 9 Playwright verts (recette R2 complète), 50 tests API.
|
||||||
|
- 🏁 **R2 CLOSE (16/07/2026, tag `release/r2`)** : recettée (1 anomalie corrigée en recette : tri « Interventions récentes »), déployée et vérifiée en ligne (portail `/q/A1`, urgence en tête, préventif de juillet généré).
|
||||||
|
- ✅ **R3 — maquettes validées** (16/07) + **R3.1 socle backend gestion** : migration `r3_gestion` (Partner, Part sans colonne de quantité, StockMovement signé/tracé/PU figé, PurchaseOrder, LaborTime taux figé, Document, User.hourlyRate) ; 66 opérations ; stock = Σ mouvements (jamais négatif, en transaction), réception BC → RECEIPT + lastUnitPrice, conso/MO à prix/taux FIGÉS, `WorkOrderDetail.costs` immuable après clôture ; seed maquette (OT-0341 = 505 MAD, testé) ; 55 tests (92 %/74,9 %). **Durcissement : références par séquences Postgres** (fin des courses max+1).
|
||||||
|
- ✅ **R3.2 — bibliothèque + analytics** : `FileStorage` complet (put/stream/remove, bucket auto), upload multipart typé (20 Mo, rattachement requis, permission sur la cible), download **streamé par l'API**, octets vérifiés à l'identique en e2e ; `GET /analytics/summary` dérivé du réel (pannes par organe via bilans codés, coûts figés, taux préventif, top équipements) ; MinIO en CI ; 71 opérations, 58 tests (6 runs consécutifs verts).
|
||||||
|
- ✅ **R3.3** : 7 écrans web fidèles à maquette-r3 (stock/alertes, fiche pièce, BC + réception, création BC préremplie depuis l'alerte, tiers, bibliothèque, statistiques) + fiche OT complète (coûts réels : consommer à prix figé / saisir temps à taux figé ; carte Documents) + fiche ascenseur (Documents) + taux horaire dans Personnes + nav Ressources/Statistiques activée par la matrice. Recette R3 automatisée (13/13 e2e Playwright).
|
||||||
|
- ✅ **R3.4 — corrections de recette + recherche globale** (17/07) : revue pixel (artefact) → 8 écarts corrigés sur arbitrage du référent (filtre fournisseur + tri sous-seuil + entrée de stock en liste, fournisseur cliquable, sélecteur de période 3/6/12 sur les stats, rattachements syndic→site via `Location.partnerId` (migration `r3_recette_fixes`), filtre « Rattaché à » + glisser-déposer en bibliothèque, aperçus/ouverture des documents) ; **recherche globale ⌘K** (`GET /search`, 73 opérations, familles filtrées par la matrice, « voir autre » respecté). 74 tests API, 14/14 Playwright.
|
||||||
|
- 🏁 **R3 CLOSE (17/07/2026, tag `release/r3`)** : recettée par le référent (revue pixel + 8 corrections + recherche globale, CI verte). Reste : redéploiement Dokploy (manuel, webhook CD absent) et vérification en ligne.
|
||||||
|
- ✅ **R4 — maquettes rédigées** (17/07) : `maquette-r4.html`, 7 écrans mobile technicien offline-first (Ma journée bi-état, fiche OT, clôture terrain avec garde, scan QR des étiquettes R1, fiche ascenseur, checklist en file, synchro & conflits à verrou optimiste) + 5 décisions à acter (offline-first en file, verrou optimiste tranché par l'humain, périmètre fermé technicien, scan local, photos en file — ni audio ni géoloc en R4).
|
||||||
|
- ✅ **R4 — maquettes + décisions D1-D5 VALIDÉES par le référent (17/07)** ; **R4.1 socle mobile** : `apps/mobile` (Expo SDK 57, TS strict), connexion + sélecteur démo ADR-002, tabbar (onglets futurs marqués), « Ma journée » triée priorité/échéance, cache TanStack persisté (lecture hors-ligne D1), jeton SecureStore, client typé du contrat, `CORS_ORIGINS` opt-in côté API (Expo web/debug seulement), 6 tests jest-expo + job CI `mobile`. Vérifié 10/10 en Expo web (connexion démo → Ma journée → hors-ligne/retour).
|
||||||
|
- ✅ **R4.2 — terrain en ligne** : scanner QR réel (expo-camera, `analyseScan` testée, résolution locale D4, QR étrangers refusés), fiche ascenseur (D3, historique scopé), fiche OT (machine à états, coûts figés, garde par `closureBlockers` API), clôture terrain (bilan 6 champs au pouce), préventif → grille cochable (appui long = N/A, `aria-checked`). Écritures en ligne assumées (bandeaux) — la file est R4.3. Vérifié 12/12 en Expo web (dont clôture complète d'un OT de test), 12 tests jest-expo.
|
||||||
|
- ✅ **R4.3 — file & verrou** : verrou optimiste serveur (`baseUpdatedAt` → 409 contextualisé, toute écriture avance la version, web inchangé, ADR-003 sécurité/routage mobile) ; file persistée rejouée dans l'ordre (propagation de version intra-lot, arrêt sur conflit, patchs optimistes, photos D5 compressées en file), écran Synchro & conflits (rejouer sur version à jour / abandonner), préchargement parc+référentiels, purge complète à la déconnexion. Recette « mode avion » 13/13 en Expo web (conflit tranché par l'humain, serveur DONE + bilan), 17 tests jest-expo, 74 tests API.
|
||||||
|
- 🏁 **R4 CLOSE (17/07/2026, tag `release/r4`)** : posé sur décision du référent avec la recette « mode avion » validée 13/13 en Expo web piloté ; **la recette sur téléphone (Expo Go — vrai mode avion, scan caméra) est reportée et reste due avant toute production client mobile**.
|
||||||
|
- 🔄 **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 R0→R5.
|
- Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0→R5.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
"@nestjs/core": "^11.1.0",
|
"@nestjs/core": "^11.1.0",
|
||||||
"@nestjs/jwt": "^11.0.0",
|
"@nestjs/jwt": "^11.0.0",
|
||||||
"@nestjs/platform-express": "^11.1.0",
|
"@nestjs/platform-express": "^11.1.0",
|
||||||
|
"@nestjs/throttler": "^6.5.0",
|
||||||
"@prisma/client": "^6.8.0",
|
"@prisma/client": "^6.8.0",
|
||||||
"@siop/shared": "workspace:*",
|
"@siop/shared": "workspace:*",
|
||||||
"argon2": "^0.43.0",
|
"argon2": "^0.43.0",
|
||||||
@@ -39,6 +40,7 @@
|
|||||||
"@nestjs/testing": "^11.1.0",
|
"@nestjs/testing": "^11.1.0",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
|
"@types/multer": "^2.2.0",
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
|
|||||||
@@ -6,6 +6,46 @@ import { PrismaClient } from '@prisma/client';
|
|||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
|
// R3 — d'abord les traces de gestion (les mouvements liés à un OT E2E
|
||||||
|
// seraient orphelins après sa suppression et fausseraient les stocks seedés)
|
||||||
|
await prisma.stockMovement.deleteMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ part: { designation: { contains: 'E2E' } } },
|
||||||
|
{ workOrder: { title: { contains: 'E2E' } } },
|
||||||
|
{ purchaseOrder: { supplier: { name: { contains: 'E2E' } } } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.document.deleteMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ fileName: { contains: 'e2e' } },
|
||||||
|
{ workOrder: { title: { contains: 'E2E' } } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.purchaseOrderLine.deleteMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ part: { designation: { contains: 'E2E' } } },
|
||||||
|
{ purchaseOrder: { supplier: { name: { contains: 'E2E' } } } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.purchaseOrder.deleteMany({
|
||||||
|
where: { supplier: { name: { contains: 'E2E' } } },
|
||||||
|
});
|
||||||
|
await prisma.part.deleteMany({ where: { designation: { contains: 'E2E' } } });
|
||||||
|
await prisma.partner.deleteMany({ where: { name: { contains: 'E2E' } } });
|
||||||
|
|
||||||
|
await prisma.request.deleteMany({ where: { description: { contains: 'E2E' } } });
|
||||||
|
await prisma.workOrder.deleteMany({ where: { title: { contains: 'E2E' } } });
|
||||||
|
await prisma.meterReading.deleteMany({ where: { value: { gte: 90_000_000 } } });
|
||||||
|
// Un appareil de test peut avoir reçu grilles/demandes (génération du mois) :
|
||||||
|
// on purge ses dépendances avant lui (FK RESTRICT).
|
||||||
|
await prisma.request.deleteMany({ where: { asset: { reference: { startsWith: 'E2E-' } } } });
|
||||||
|
await prisma.workOrder.deleteMany({ where: { asset: { reference: { startsWith: 'E2E-' } } } });
|
||||||
await prisma.asset.deleteMany({ where: { reference: { startsWith: 'E2E-' } } });
|
await prisma.asset.deleteMany({ where: { reference: { startsWith: 'E2E-' } } });
|
||||||
await prisma.location.deleteMany({ where: { name: { contains: 'E2E' }, parentId: { not: null } } });
|
await prisma.location.deleteMany({ where: { name: { contains: 'E2E' }, parentId: { not: null } } });
|
||||||
await prisma.location.deleteMany({ where: { name: { contains: 'E2E' } } });
|
await prisma.location.deleteMany({ where: { name: { contains: 'E2E' } } });
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "WorkOrderType" AS ENUM ('CORRECTIVE', 'PREVENTIVE', 'WORKS');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "WorkOrderStatus" AS ENUM ('OPEN', 'IN_PROGRESS', 'ON_HOLD', 'DONE', 'CANCELLED');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "WorkOrderPriority" AS ENUM ('NONE', 'LOW', 'MEDIUM', 'HIGH', 'PERSON_TRAPPED');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "RequestStatus" AS ENUM ('RECEIVED', 'APPROVED', 'REJECTED');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ChecklistState" AS ENUM ('PENDING', 'DONE', 'NA');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "BilanField" AS ENUM ('DOOR_STATE', 'CABIN_POSITION', 'ANOMALY', 'EXTERNAL_CAUSE', 'ACTION_TAKEN', 'COMPONENT_CONCERNED');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "MeterKind" AS ENUM ('RUNNING_HOURS', 'STARTS');
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "Location_position_gix";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Location" DROP COLUMN "position";
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "WorkOrder" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"reference" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"type" "WorkOrderType" NOT NULL,
|
||||||
|
"status" "WorkOrderStatus" NOT NULL DEFAULT 'OPEN',
|
||||||
|
"priority" "WorkOrderPriority" NOT NULL DEFAULT 'NONE',
|
||||||
|
"assetId" UUID NOT NULL,
|
||||||
|
"dueDate" TIMESTAMP(3),
|
||||||
|
"createdById" UUID,
|
||||||
|
"startedAt" TIMESTAMP(3),
|
||||||
|
"completedAt" TIMESTAMP(3),
|
||||||
|
"cancelledAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "WorkOrder_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "WorkOrderEvent" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"workOrderId" UUID NOT NULL,
|
||||||
|
"kind" TEXT NOT NULL,
|
||||||
|
"message" TEXT,
|
||||||
|
"byId" UUID,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "WorkOrderEvent_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Request" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"reference" TEXT NOT NULL,
|
||||||
|
"description" TEXT NOT NULL,
|
||||||
|
"isPersonTrapped" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"status" "RequestStatus" NOT NULL DEFAULT 'RECEIVED',
|
||||||
|
"rejectionReason" TEXT,
|
||||||
|
"assetId" UUID NOT NULL,
|
||||||
|
"requestedById" UUID,
|
||||||
|
"requesterName" TEXT,
|
||||||
|
"workOrderId" UUID,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Request_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ReferenceValue" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"field" "BilanField" NOT NULL,
|
||||||
|
"label" TEXT NOT NULL,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
|
||||||
|
CONSTRAINT "ReferenceValue_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "InterventionReport" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"workOrderId" UUID NOT NULL,
|
||||||
|
"note" TEXT,
|
||||||
|
"doorStateId" UUID,
|
||||||
|
"cabinPositionId" UUID,
|
||||||
|
"anomalyId" UUID,
|
||||||
|
"externalCauseId" UUID,
|
||||||
|
"actionTakenId" UUID,
|
||||||
|
"componentConcernedId" UUID,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "InterventionReport_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "TaskTemplate" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"label" TEXT NOT NULL,
|
||||||
|
"componentTypeId" UUID,
|
||||||
|
"periodMonths" INTEGER NOT NULL,
|
||||||
|
"isRegulatory" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
|
||||||
|
CONSTRAINT "TaskTemplate_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ChecklistItem" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"workOrderId" UUID NOT NULL,
|
||||||
|
"label" TEXT NOT NULL,
|
||||||
|
"state" "ChecklistState" NOT NULL DEFAULT 'PENDING',
|
||||||
|
"templateId" UUID,
|
||||||
|
"doneById" UUID,
|
||||||
|
"doneAt" TIMESTAMP(3),
|
||||||
|
|
||||||
|
CONSTRAINT "ChecklistItem_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Meter" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"assetId" UUID NOT NULL,
|
||||||
|
"kind" "MeterKind" NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Meter_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "MeterReading" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"meterId" UUID NOT NULL,
|
||||||
|
"value" INTEGER NOT NULL,
|
||||||
|
"readById" UUID,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "MeterReading_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "_WorkOrderAssignees" (
|
||||||
|
"A" UUID NOT NULL,
|
||||||
|
"B" UUID NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "_WorkOrderAssignees_AB_pkey" PRIMARY KEY ("A","B")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "WorkOrder_reference_key" ON "WorkOrder"("reference");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "WorkOrder_assetId_idx" ON "WorkOrder"("assetId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "WorkOrder_status_idx" ON "WorkOrder"("status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "WorkOrderEvent_workOrderId_idx" ON "WorkOrderEvent"("workOrderId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Request_reference_key" ON "Request"("reference");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Request_workOrderId_key" ON "Request"("workOrderId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Request_assetId_idx" ON "Request"("assetId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "ReferenceValue_field_label_key" ON "ReferenceValue"("field", "label");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "InterventionReport_workOrderId_key" ON "InterventionReport"("workOrderId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "TaskTemplate_label_key" ON "TaskTemplate"("label");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ChecklistItem_workOrderId_idx" ON "ChecklistItem"("workOrderId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Meter_assetId_kind_key" ON "Meter"("assetId", "kind");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "MeterReading_meterId_idx" ON "MeterReading"("meterId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "_WorkOrderAssignees_B_index" ON "_WorkOrderAssignees"("B");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "WorkOrder" ADD CONSTRAINT "WorkOrder_assetId_fkey" FOREIGN KEY ("assetId") REFERENCES "Asset"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "WorkOrder" ADD CONSTRAINT "WorkOrder_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "WorkOrderEvent" ADD CONSTRAINT "WorkOrderEvent_workOrderId_fkey" FOREIGN KEY ("workOrderId") REFERENCES "WorkOrder"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "WorkOrderEvent" ADD CONSTRAINT "WorkOrderEvent_byId_fkey" FOREIGN KEY ("byId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Request" ADD CONSTRAINT "Request_assetId_fkey" FOREIGN KEY ("assetId") REFERENCES "Asset"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Request" ADD CONSTRAINT "Request_requestedById_fkey" FOREIGN KEY ("requestedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Request" ADD CONSTRAINT "Request_workOrderId_fkey" FOREIGN KEY ("workOrderId") REFERENCES "WorkOrder"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "InterventionReport" ADD CONSTRAINT "InterventionReport_workOrderId_fkey" FOREIGN KEY ("workOrderId") REFERENCES "WorkOrder"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "InterventionReport" ADD CONSTRAINT "InterventionReport_doorStateId_fkey" FOREIGN KEY ("doorStateId") REFERENCES "ReferenceValue"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "InterventionReport" ADD CONSTRAINT "InterventionReport_cabinPositionId_fkey" FOREIGN KEY ("cabinPositionId") REFERENCES "ReferenceValue"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "InterventionReport" ADD CONSTRAINT "InterventionReport_anomalyId_fkey" FOREIGN KEY ("anomalyId") REFERENCES "ReferenceValue"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "InterventionReport" ADD CONSTRAINT "InterventionReport_externalCauseId_fkey" FOREIGN KEY ("externalCauseId") REFERENCES "ReferenceValue"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "InterventionReport" ADD CONSTRAINT "InterventionReport_actionTakenId_fkey" FOREIGN KEY ("actionTakenId") REFERENCES "ReferenceValue"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "InterventionReport" ADD CONSTRAINT "InterventionReport_componentConcernedId_fkey" FOREIGN KEY ("componentConcernedId") REFERENCES "ReferenceValue"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "TaskTemplate" ADD CONSTRAINT "TaskTemplate_componentTypeId_fkey" FOREIGN KEY ("componentTypeId") REFERENCES "Category"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ChecklistItem" ADD CONSTRAINT "ChecklistItem_workOrderId_fkey" FOREIGN KEY ("workOrderId") REFERENCES "WorkOrder"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ChecklistItem" ADD CONSTRAINT "ChecklistItem_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "TaskTemplate"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ChecklistItem" ADD CONSTRAINT "ChecklistItem_doneById_fkey" FOREIGN KEY ("doneById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Meter" ADD CONSTRAINT "Meter_assetId_fkey" FOREIGN KEY ("assetId") REFERENCES "Asset"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "MeterReading" ADD CONSTRAINT "MeterReading_meterId_fkey" FOREIGN KEY ("meterId") REFERENCES "Meter"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "MeterReading" ADD CONSTRAINT "MeterReading_readById_fkey" FOREIGN KEY ("readById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "_WorkOrderAssignees" ADD CONSTRAINT "_WorkOrderAssignees_A_fkey" FOREIGN KEY ("A") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "_WorkOrderAssignees" ADD CONSTRAINT "_WorkOrderAssignees_B_fkey" FOREIGN KEY ("B") REFERENCES "WorkOrder"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Asset" ADD COLUMN "underContract" BOOLEAN NOT NULL DEFAULT true;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "WorkOrder" ADD COLUMN "periodKey" TEXT;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "WorkOrder_assetId_periodKey_key" ON "WorkOrder"("assetId", "periodKey");
|
||||||
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Request" ADD COLUMN "publicToken" TEXT;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Request_publicToken_key" ON "Request"("publicToken");
|
||||||
|
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "PartnerKind" AS ENUM ('SUPPLIER', 'CLIENT');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "PurchaseOrderStatus" AS ENUM ('DRAFT', 'SENT', 'RECEIVED', 'CANCELLED');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "StockMovementKind" AS ENUM ('RECEIPT', 'ENTRY', 'CONSUMPTION', 'ADJUSTMENT');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "DocumentKind" AS ENUM ('NOTICE', 'CERTIFICATE', 'PHOTO', 'OTHER');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ADD COLUMN "hourlyRate" DECIMAL(8,2);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Partner" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"kind" "PartnerKind" NOT NULL,
|
||||||
|
"contactName" TEXT,
|
||||||
|
"phone" TEXT,
|
||||||
|
"email" TEXT,
|
||||||
|
"city" TEXT,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Partner_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Part" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"reference" TEXT NOT NULL,
|
||||||
|
"designation" TEXT NOT NULL,
|
||||||
|
"threshold" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"lastUnitPrice" DECIMAL(10,2),
|
||||||
|
"compatible" TEXT,
|
||||||
|
"supplierId" UUID,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Part_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "StockMovement" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"partId" UUID NOT NULL,
|
||||||
|
"kind" "StockMovementKind" NOT NULL,
|
||||||
|
"quantity" INTEGER NOT NULL,
|
||||||
|
"unitPrice" DECIMAL(10,2),
|
||||||
|
"reason" TEXT,
|
||||||
|
"workOrderId" UUID,
|
||||||
|
"purchaseOrderId" UUID,
|
||||||
|
"byId" UUID,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "StockMovement_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "PurchaseOrder" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"reference" TEXT NOT NULL,
|
||||||
|
"status" "PurchaseOrderStatus" NOT NULL DEFAULT 'DRAFT',
|
||||||
|
"supplierId" UUID NOT NULL,
|
||||||
|
"sentAt" TIMESTAMP(3),
|
||||||
|
"receivedAt" TIMESTAMP(3),
|
||||||
|
"cancelledAt" TIMESTAMP(3),
|
||||||
|
"createdById" UUID,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "PurchaseOrder_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "PurchaseOrderLine" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"purchaseOrderId" UUID NOT NULL,
|
||||||
|
"partId" UUID NOT NULL,
|
||||||
|
"quantity" INTEGER NOT NULL,
|
||||||
|
"unitPrice" DECIMAL(10,2) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "PurchaseOrderLine_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "LaborTime" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"workOrderId" UUID NOT NULL,
|
||||||
|
"userId" UUID NOT NULL,
|
||||||
|
"minutes" INTEGER NOT NULL,
|
||||||
|
"hourlyRate" DECIMAL(8,2) NOT NULL,
|
||||||
|
"note" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "LaborTime_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Document" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"kind" "DocumentKind" NOT NULL,
|
||||||
|
"fileName" TEXT NOT NULL,
|
||||||
|
"storageKey" TEXT NOT NULL,
|
||||||
|
"size" INTEGER NOT NULL,
|
||||||
|
"contentType" TEXT NOT NULL,
|
||||||
|
"assetId" UUID,
|
||||||
|
"workOrderId" UUID,
|
||||||
|
"uploadedById" UUID,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "Document_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Partner_name_key" ON "Partner"("name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Part_reference_key" ON "Part"("reference");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "StockMovement_partId_idx" ON "StockMovement"("partId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "StockMovement_workOrderId_idx" ON "StockMovement"("workOrderId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "PurchaseOrder_reference_key" ON "PurchaseOrder"("reference");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "PurchaseOrderLine_purchaseOrderId_idx" ON "PurchaseOrderLine"("purchaseOrderId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "LaborTime_workOrderId_idx" ON "LaborTime"("workOrderId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Document_storageKey_key" ON "Document"("storageKey");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Document_assetId_idx" ON "Document"("assetId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Document_workOrderId_idx" ON "Document"("workOrderId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Part" ADD CONSTRAINT "Part_supplierId_fkey" FOREIGN KEY ("supplierId") REFERENCES "Partner"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "StockMovement" ADD CONSTRAINT "StockMovement_partId_fkey" FOREIGN KEY ("partId") REFERENCES "Part"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "StockMovement" ADD CONSTRAINT "StockMovement_workOrderId_fkey" FOREIGN KEY ("workOrderId") REFERENCES "WorkOrder"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "StockMovement" ADD CONSTRAINT "StockMovement_purchaseOrderId_fkey" FOREIGN KEY ("purchaseOrderId") REFERENCES "PurchaseOrder"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "StockMovement" ADD CONSTRAINT "StockMovement_byId_fkey" FOREIGN KEY ("byId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "PurchaseOrder" ADD CONSTRAINT "PurchaseOrder_supplierId_fkey" FOREIGN KEY ("supplierId") REFERENCES "Partner"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "PurchaseOrder" ADD CONSTRAINT "PurchaseOrder_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "PurchaseOrderLine" ADD CONSTRAINT "PurchaseOrderLine_purchaseOrderId_fkey" FOREIGN KEY ("purchaseOrderId") REFERENCES "PurchaseOrder"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "PurchaseOrderLine" ADD CONSTRAINT "PurchaseOrderLine_partId_fkey" FOREIGN KEY ("partId") REFERENCES "Part"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "LaborTime" ADD CONSTRAINT "LaborTime_workOrderId_fkey" FOREIGN KEY ("workOrderId") REFERENCES "WorkOrder"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "LaborTime" ADD CONSTRAINT "LaborTime_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Document" ADD CONSTRAINT "Document_assetId_fkey" FOREIGN KEY ("assetId") REFERENCES "Asset"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Document" ADD CONSTRAINT "Document_workOrderId_fkey" FOREIGN KEY ("workOrderId") REFERENCES "WorkOrder"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Document" ADD CONSTRAINT "Document_uploadedById_fkey" FOREIGN KEY ("uploadedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- Références OT/DEM/BC/P : séquences Postgres — la concurrence ne peut plus
|
||||||
|
-- produire de collision (leçon des générations parallèles).
|
||||||
|
-- La numérotation ne se remet pas à zéro chaque année : l'unicité prime.
|
||||||
|
CREATE SEQUENCE IF NOT EXISTS "work_order_ref_seq";
|
||||||
|
CREATE SEQUENCE IF NOT EXISTS "request_ref_seq";
|
||||||
|
CREATE SEQUENCE IF NOT EXISTS "purchase_order_ref_seq";
|
||||||
|
CREATE SEQUENCE IF NOT EXISTS "part_ref_seq";
|
||||||
|
|
||||||
|
SELECT setval('work_order_ref_seq',
|
||||||
|
GREATEST((SELECT COALESCE(MAX(SUBSTRING("reference" FROM '\d+$')::int), 0) FROM "WorkOrder"), 1000));
|
||||||
|
SELECT setval('request_ref_seq',
|
||||||
|
GREATEST((SELECT COALESCE(MAX(SUBSTRING("reference" FROM '\d+$')::int), 0) FROM "Request"), 1000));
|
||||||
|
SELECT setval('purchase_order_ref_seq',
|
||||||
|
GREATEST((SELECT COALESCE(MAX(SUBSTRING("reference" FROM '\d+$')::int), 0) FROM "PurchaseOrder"), 1000));
|
||||||
|
SELECT setval('part_ref_seq',
|
||||||
|
GREATEST((SELECT COALESCE(MAX(SUBSTRING("reference" FROM '\d+$')::int), 0) FROM "Part"), 1000));
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Location" ADD COLUMN "partnerId" UUID;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Location_partnerId_idx" ON "Location"("partnerId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Location" ADD CONSTRAINT "Location_partnerId_fkey" FOREIGN KEY ("partnerId") REFERENCES "Partner"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -49,6 +49,19 @@ model User {
|
|||||||
activationToken String? @unique
|
activationToken String? @unique
|
||||||
activationExpiresAt DateTime?
|
activationExpiresAt DateTime?
|
||||||
teams Team[]
|
teams Team[]
|
||||||
|
// R2 — exploitation
|
||||||
|
workOrdersAssigned WorkOrder[] @relation("WorkOrderAssignees")
|
||||||
|
workOrdersCreated WorkOrder[] @relation("WorkOrderCreator")
|
||||||
|
workOrderEvents WorkOrderEvent[]
|
||||||
|
requests Request[]
|
||||||
|
checklistDone ChecklistItem[]
|
||||||
|
meterReadings MeterReading[]
|
||||||
|
// R3 — gestion : taux horaire COURANT (le taux d'une saisie est figé dans LaborTime)
|
||||||
|
hourlyRate Decimal? @db.Decimal(8, 2)
|
||||||
|
laborTimes LaborTime[]
|
||||||
|
stockMovements StockMovement[]
|
||||||
|
purchaseOrders PurchaseOrder[]
|
||||||
|
documents Document[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -75,6 +88,7 @@ model Category {
|
|||||||
isActive Boolean @default(true) // désactivable, jamais supprimée si utilisée
|
isActive Boolean @default(true) // désactivable, jamais supprimée si utilisée
|
||||||
assets Asset[]
|
assets Asset[]
|
||||||
components AssetComponent[]
|
components AssetComponent[]
|
||||||
|
taskTemplates TaskTemplate[]
|
||||||
|
|
||||||
@@unique([kind, name])
|
@@unique([kind, name])
|
||||||
}
|
}
|
||||||
@@ -93,11 +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 {
|
||||||
@@ -110,11 +128,16 @@ model Asset {
|
|||||||
loadKg Int?
|
loadKg Int?
|
||||||
floors Int?
|
floors Int?
|
||||||
status AssetStatus @default(IN_SERVICE) // statut d'ÉQUIPEMENT ≠ statut d'OT
|
status AssetStatus @default(IN_SERVICE) // statut d'ÉQUIPEMENT ≠ statut d'OT
|
||||||
|
underContract Boolean @default(true) // contrat préventif (grille mensuelle)
|
||||||
categoryId String @db.Uuid
|
categoryId String @db.Uuid
|
||||||
category Category @relation(fields: [categoryId], references: [id])
|
category Category @relation(fields: [categoryId], references: [id])
|
||||||
locationId String @db.Uuid
|
locationId String @db.Uuid
|
||||||
location Location @relation(fields: [locationId], references: [id])
|
location Location @relation(fields: [locationId], references: [id])
|
||||||
components AssetComponent[]
|
components AssetComponent[]
|
||||||
|
workOrders WorkOrder[]
|
||||||
|
requests Request[]
|
||||||
|
meters Meter[]
|
||||||
|
documents Document[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -141,3 +164,356 @@ model Team {
|
|||||||
description String?
|
description String?
|
||||||
members User[]
|
members User[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ————— R2 — Exploitation (docs/03-architecture/modele-donnees.md §R2) —————
|
||||||
|
|
||||||
|
enum WorkOrderType {
|
||||||
|
CORRECTIVE // Dépannage
|
||||||
|
PREVENTIVE // Maintenance (grille du mois)
|
||||||
|
WORKS // Travaux
|
||||||
|
}
|
||||||
|
|
||||||
|
enum WorkOrderStatus {
|
||||||
|
OPEN
|
||||||
|
IN_PROGRESS
|
||||||
|
ON_HOLD
|
||||||
|
DONE
|
||||||
|
CANCELLED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum WorkOrderPriority {
|
||||||
|
NONE
|
||||||
|
LOW
|
||||||
|
MEDIUM
|
||||||
|
HIGH
|
||||||
|
PERSON_TRAPPED // personne bloquée — urgence absolue
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RequestStatus {
|
||||||
|
RECEIVED
|
||||||
|
APPROVED
|
||||||
|
REJECTED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ChecklistState {
|
||||||
|
PENDING
|
||||||
|
DONE
|
||||||
|
NA
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BilanField {
|
||||||
|
DOOR_STATE
|
||||||
|
CABIN_POSITION
|
||||||
|
ANOMALY
|
||||||
|
EXTERNAL_CAUSE
|
||||||
|
ACTION_TAKEN
|
||||||
|
COMPONENT_CONCERNED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum MeterKind {
|
||||||
|
RUNNING_HOURS
|
||||||
|
STARTS
|
||||||
|
}
|
||||||
|
|
||||||
|
model WorkOrder {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
reference String @unique // OT-2026-0341
|
||||||
|
title String
|
||||||
|
description String?
|
||||||
|
type WorkOrderType
|
||||||
|
status WorkOrderStatus @default(OPEN) // machine à états stricte (service)
|
||||||
|
priority WorkOrderPriority @default(NONE)
|
||||||
|
assetId String @db.Uuid
|
||||||
|
asset Asset @relation(fields: [assetId], references: [id])
|
||||||
|
dueDate DateTime?
|
||||||
|
assignees User[] @relation("WorkOrderAssignees")
|
||||||
|
createdById String? @db.Uuid
|
||||||
|
createdBy User? @relation("WorkOrderCreator", fields: [createdById], references: [id])
|
||||||
|
startedAt DateTime?
|
||||||
|
completedAt DateTime?
|
||||||
|
cancelledAt DateTime?
|
||||||
|
events WorkOrderEvent[]
|
||||||
|
checklist ChecklistItem[]
|
||||||
|
report InterventionReport?
|
||||||
|
request Request?
|
||||||
|
stockMovements StockMovement[]
|
||||||
|
laborTimes LaborTime[]
|
||||||
|
documents Document[]
|
||||||
|
// Grille du mois : « AAAA-MM » — l'unicité [assetId, periodKey] EST
|
||||||
|
// l'idempotence de la génération (les OT correctifs restent à null).
|
||||||
|
periodKey String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([assetId, periodKey])
|
||||||
|
@@index([assetId])
|
||||||
|
@@index([status])
|
||||||
|
}
|
||||||
|
|
||||||
|
model WorkOrderEvent {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
workOrderId String @db.Uuid
|
||||||
|
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
|
||||||
|
kind String // COMMENT · STATUS_CHANGED · ASSIGNED · CREATED · FROM_REQUEST
|
||||||
|
message String?
|
||||||
|
byId String? @db.Uuid
|
||||||
|
by User? @relation(fields: [byId], references: [id])
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([workOrderId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Request {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
reference String @unique // DEM-2026-0112
|
||||||
|
description String
|
||||||
|
isPersonTrapped Boolean @default(false)
|
||||||
|
status RequestStatus @default(RECEIVED)
|
||||||
|
rejectionReason String? // REQUIS au rejet (service)
|
||||||
|
assetId String @db.Uuid
|
||||||
|
asset Asset @relation(fields: [assetId], references: [id])
|
||||||
|
requestedById String? @db.Uuid
|
||||||
|
requestedBy User? @relation(fields: [requestedById], references: [id])
|
||||||
|
requesterName String? // portail public via QR (R2.4)
|
||||||
|
// Suivi SANS COMPTE (portail) : le téléphone du gardien garde ce jeton,
|
||||||
|
// seul moyen de lire l'avancement — jamais listé, jamais devinable.
|
||||||
|
publicToken String? @unique
|
||||||
|
workOrderId String? @unique @db.Uuid // lien 1-1 — jamais de doublon
|
||||||
|
workOrder WorkOrder? @relation(fields: [workOrderId], references: [id])
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([assetId])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Référentiels administrables du bilan codé (un par champ)
|
||||||
|
model ReferenceValue {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
field BilanField
|
||||||
|
label String
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
|
||||||
|
doorStates InterventionReport[] @relation("BilanDoorState")
|
||||||
|
cabinPositions InterventionReport[] @relation("BilanCabinPosition")
|
||||||
|
anomalies InterventionReport[] @relation("BilanAnomaly")
|
||||||
|
externalCauses InterventionReport[] @relation("BilanExternalCause")
|
||||||
|
actionsTaken InterventionReport[] @relation("BilanActionTaken")
|
||||||
|
componentsConcerned InterventionReport[] @relation("BilanComponentConcerned")
|
||||||
|
|
||||||
|
@@unique([field, label])
|
||||||
|
}
|
||||||
|
|
||||||
|
model InterventionReport {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
workOrderId String @unique @db.Uuid
|
||||||
|
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
|
||||||
|
note String?
|
||||||
|
|
||||||
|
doorStateId String? @db.Uuid
|
||||||
|
doorState ReferenceValue? @relation("BilanDoorState", fields: [doorStateId], references: [id])
|
||||||
|
cabinPositionId String? @db.Uuid
|
||||||
|
cabinPosition ReferenceValue? @relation("BilanCabinPosition", fields: [cabinPositionId], references: [id])
|
||||||
|
anomalyId String? @db.Uuid
|
||||||
|
anomaly ReferenceValue? @relation("BilanAnomaly", fields: [anomalyId], references: [id])
|
||||||
|
externalCauseId String? @db.Uuid
|
||||||
|
externalCause ReferenceValue? @relation("BilanExternalCause", fields: [externalCauseId], references: [id])
|
||||||
|
actionTakenId String? @db.Uuid
|
||||||
|
actionTaken ReferenceValue? @relation("BilanActionTaken", fields: [actionTakenId], references: [id])
|
||||||
|
componentConcernedId String? @db.Uuid
|
||||||
|
componentConcerned ReferenceValue? @relation("BilanComponentConcerned", fields: [componentConcernedId], references: [id])
|
||||||
|
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model TaskTemplate {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
label String @unique
|
||||||
|
componentTypeId String? @db.Uuid
|
||||||
|
componentType Category? @relation(fields: [componentTypeId], references: [id])
|
||||||
|
periodMonths Int // 1, 3, 6, 12…
|
||||||
|
isRegulatory Boolean @default(false) // essai parachute
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
checklistItems ChecklistItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model ChecklistItem {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
workOrderId String @db.Uuid
|
||||||
|
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
|
||||||
|
label String
|
||||||
|
state ChecklistState @default(PENDING)
|
||||||
|
templateId String? @db.Uuid
|
||||||
|
template TaskTemplate? @relation(fields: [templateId], references: [id])
|
||||||
|
doneById String? @db.Uuid
|
||||||
|
doneBy User? @relation(fields: [doneById], references: [id])
|
||||||
|
doneAt DateTime?
|
||||||
|
|
||||||
|
@@index([workOrderId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Meter {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
assetId String @db.Uuid
|
||||||
|
asset Asset @relation(fields: [assetId], references: [id], onDelete: Cascade)
|
||||||
|
kind MeterKind
|
||||||
|
readings MeterReading[]
|
||||||
|
|
||||||
|
@@unique([assetId, kind])
|
||||||
|
}
|
||||||
|
|
||||||
|
model MeterReading {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
meterId String @db.Uuid
|
||||||
|
meter Meter @relation(fields: [meterId], references: [id], onDelete: Cascade)
|
||||||
|
value Int // strictement croissant (service)
|
||||||
|
readById String? @db.Uuid
|
||||||
|
readBy User? @relation(fields: [readById], references: [id])
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([meterId])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ————— R3 — Gestion (docs/03-architecture/modele-donnees.md §R3) —————
|
||||||
|
|
||||||
|
enum PartnerKind {
|
||||||
|
SUPPLIER
|
||||||
|
CLIENT // syndic / propriétaire
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PurchaseOrderStatus {
|
||||||
|
DRAFT
|
||||||
|
SENT
|
||||||
|
RECEIVED
|
||||||
|
CANCELLED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum StockMovementKind {
|
||||||
|
RECEIPT // réception de BC (+, PU figé)
|
||||||
|
ENTRY // entrée manuelle (+)
|
||||||
|
CONSUMPTION // consommation d'OT (−, PU figé)
|
||||||
|
ADJUSTMENT // inventaire (±, motif REQUIS)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum DocumentKind {
|
||||||
|
NOTICE
|
||||||
|
CERTIFICATE
|
||||||
|
PHOTO
|
||||||
|
OTHER
|
||||||
|
}
|
||||||
|
|
||||||
|
model Partner {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
name String @unique
|
||||||
|
kind PartnerKind
|
||||||
|
contactName String?
|
||||||
|
phone String?
|
||||||
|
email String?
|
||||||
|
city String?
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
parts Part[]
|
||||||
|
purchaseOrders PurchaseOrder[]
|
||||||
|
sites Location[] // sites gérés (kind CLIENT)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model Part {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
reference String @unique // P-0113
|
||||||
|
designation String
|
||||||
|
threshold Int @default(0) // seuil d'alerte
|
||||||
|
// Dernier prix d'achat — figé sur chaque mouvement au moment T.
|
||||||
|
lastUnitPrice Decimal? @db.Decimal(10, 2)
|
||||||
|
compatible String?
|
||||||
|
supplierId String? @db.Uuid
|
||||||
|
supplier Partner? @relation(fields: [supplierId], references: [id])
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
movements StockMovement[]
|
||||||
|
orderLines PurchaseOrderLine[]
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
// Le stock EST la somme des mouvements — aucune colonne de quantité.
|
||||||
|
model StockMovement {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
partId String @db.Uuid
|
||||||
|
part Part @relation(fields: [partId], references: [id], onDelete: Cascade)
|
||||||
|
kind StockMovementKind
|
||||||
|
quantity Int // signé : + entrée, − sortie
|
||||||
|
unitPrice Decimal? @db.Decimal(10, 2) // figé (réception, consommation)
|
||||||
|
reason String? // ajustement : motif requis (service)
|
||||||
|
workOrderId String? @db.Uuid
|
||||||
|
workOrder WorkOrder? @relation(fields: [workOrderId], references: [id])
|
||||||
|
purchaseOrderId String? @db.Uuid
|
||||||
|
purchaseOrder PurchaseOrder? @relation(fields: [purchaseOrderId], references: [id])
|
||||||
|
byId String? @db.Uuid
|
||||||
|
by User? @relation(fields: [byId], references: [id])
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([partId])
|
||||||
|
@@index([workOrderId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model PurchaseOrder {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
reference String @unique // BC-2026-0024
|
||||||
|
status PurchaseOrderStatus @default(DRAFT)
|
||||||
|
supplierId String @db.Uuid
|
||||||
|
supplier Partner @relation(fields: [supplierId], references: [id])
|
||||||
|
lines PurchaseOrderLine[]
|
||||||
|
movements StockMovement[]
|
||||||
|
sentAt DateTime?
|
||||||
|
receivedAt DateTime?
|
||||||
|
cancelledAt DateTime?
|
||||||
|
createdById String? @db.Uuid
|
||||||
|
createdBy User? @relation(fields: [createdById], references: [id])
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model PurchaseOrderLine {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
purchaseOrderId String @db.Uuid
|
||||||
|
purchaseOrder PurchaseOrder @relation(fields: [purchaseOrderId], references: [id], onDelete: Cascade)
|
||||||
|
partId String @db.Uuid
|
||||||
|
part Part @relation(fields: [partId], references: [id])
|
||||||
|
quantity Int
|
||||||
|
unitPrice Decimal @db.Decimal(10, 2)
|
||||||
|
|
||||||
|
@@index([purchaseOrderId])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main-d'œuvre : le taux est FIGÉ à la saisie (le coût d'un OT ne bouge plus).
|
||||||
|
model LaborTime {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
workOrderId String @db.Uuid
|
||||||
|
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
|
||||||
|
userId String @db.Uuid
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
minutes Int
|
||||||
|
hourlyRate Decimal @db.Decimal(8, 2)
|
||||||
|
note String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([workOrderId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Document {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
kind DocumentKind
|
||||||
|
fileName String
|
||||||
|
storageKey String @unique // clé MinIO (FileStorage)
|
||||||
|
size Int
|
||||||
|
contentType String
|
||||||
|
assetId String? @db.Uuid
|
||||||
|
asset Asset? @relation(fields: [assetId], references: [id])
|
||||||
|
workOrderId String? @db.Uuid
|
||||||
|
workOrder WorkOrder? @relation(fields: [workOrderId], references: [id])
|
||||||
|
uploadedById String? @db.Uuid
|
||||||
|
uploadedBy User? @relation(fields: [uploadedById], references: [id])
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([assetId])
|
||||||
|
@@index([workOrderId])
|
||||||
|
}
|
||||||
|
|||||||
@@ -133,6 +133,8 @@ export async function seed(prisma: PrismaClient): Promise<void> {
|
|||||||
);
|
);
|
||||||
await seedUsers(prisma, roleIds, passwordHash);
|
await seedUsers(prisma, roleIds, passwordHash);
|
||||||
await seedReferentiel(prisma);
|
await seedReferentiel(prisma);
|
||||||
|
await seedExploitation(prisma);
|
||||||
|
await seedGestion(prisma);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function seedUsers(
|
async function seedUsers(
|
||||||
@@ -358,6 +360,409 @@ async function seedReferentiel(prisma: PrismaClient): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ————— R2 — Exploitation (référentiels du bilan, gabarits, données maquette) —————
|
||||||
|
|
||||||
|
const REFERENCE_VALUES: Record<string, string[]> = {
|
||||||
|
DOOR_STATE: [
|
||||||
|
'Fonctionnement normal',
|
||||||
|
'Porte bloquée ouverte',
|
||||||
|
'Porte bloquée fermée',
|
||||||
|
'Fermeture incomplète',
|
||||||
|
'Réouverture intempestive',
|
||||||
|
],
|
||||||
|
CABIN_POSITION: ['À niveau', 'Entre deux niveaux', 'Cuvette', 'Dernier niveau'],
|
||||||
|
ANOMALY: [
|
||||||
|
'Frottement mécanique',
|
||||||
|
'Défaut électrique',
|
||||||
|
'Usure normale',
|
||||||
|
'Choc / vandalisme',
|
||||||
|
'Aucune anomalie constatée',
|
||||||
|
],
|
||||||
|
EXTERNAL_CAUSE: ['Coupure électrique', 'Dégât des eaux', 'Mauvais usage', 'Aucune'],
|
||||||
|
ACTION_TAKEN: [
|
||||||
|
'Réglage',
|
||||||
|
'Remplacement de pièce',
|
||||||
|
'Nettoyage / graissage',
|
||||||
|
'Remise en service simple',
|
||||||
|
'Visite d’entretien',
|
||||||
|
'Attente de pièce',
|
||||||
|
],
|
||||||
|
COMPONENT_CONCERNED: [
|
||||||
|
'Portes',
|
||||||
|
'Guides',
|
||||||
|
'Treuil / machinerie',
|
||||||
|
'Armoire de commande',
|
||||||
|
'Boutons / signalisation',
|
||||||
|
'Parachute',
|
||||||
|
'Cabine',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Gabarits du préventif (maquette R2) — période calendaire en mois. */
|
||||||
|
const TASK_TEMPLATES: {
|
||||||
|
label: string;
|
||||||
|
periodMonths: number;
|
||||||
|
componentType?: string;
|
||||||
|
isRegulatory?: boolean;
|
||||||
|
}[] = [
|
||||||
|
{ label: 'Contrôle fermeture / verrouillage des portes', periodMonths: 1, componentType: 'Portes cabine / palières' },
|
||||||
|
{ label: 'Nettoyage cuvette et toit de cabine', periodMonths: 1 },
|
||||||
|
{ label: 'Contrôle boutons cabine & paliers', periodMonths: 1, componentType: 'Boutons & signalisation' },
|
||||||
|
{ label: 'Vérification éclairage de secours', periodMonths: 1 },
|
||||||
|
{ label: 'Contrôle niveau d’huile réducteur', periodMonths: 3, componentType: 'Treuil / machinerie' },
|
||||||
|
{ label: 'Vérification jeu des coulisseaux', periodMonths: 6 },
|
||||||
|
{ label: 'Contrôle câbles de traction (usure, tension)', periodMonths: 6, componentType: 'Treuil / machinerie' },
|
||||||
|
{ label: 'Essai du parachute', periodMonths: 12, componentType: 'Parachute', isRegulatory: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
async function seedExploitation(prisma: PrismaClient): Promise<void> {
|
||||||
|
const refIds = new Map<string, string>(); // « FIELD/label » → id
|
||||||
|
for (const [field, labels] of Object.entries(REFERENCE_VALUES)) {
|
||||||
|
for (const label of labels) {
|
||||||
|
const value = await prisma.referenceValue.upsert({
|
||||||
|
where: { field_label: { field: field as never, label } },
|
||||||
|
update: {},
|
||||||
|
create: { field: field as never, label },
|
||||||
|
});
|
||||||
|
refIds.set(`${field}/${label}`, value.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const componentTypes = new Map(
|
||||||
|
(await prisma.category.findMany({ where: { kind: 'COMPONENT_TYPE' } })).map((c) => [
|
||||||
|
c.name,
|
||||||
|
c.id,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
for (const t of TASK_TEMPLATES) {
|
||||||
|
await prisma.taskTemplate.upsert({
|
||||||
|
where: { label: t.label },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
label: t.label,
|
||||||
|
periodMonths: t.periodMonths,
|
||||||
|
isRegulatory: t.isRegulatory ?? false,
|
||||||
|
componentTypeId: t.componentType ? componentTypes.get(t.componentType) : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Données de démonstration (rejouent la maquette : OT en cours, urgence,
|
||||||
|
// grille du mois, demandes à approuver). Idempotent par référence.
|
||||||
|
const parReference = async (ref: string) =>
|
||||||
|
(await prisma.asset.findUniqueOrThrow({ where: { reference: ref } })).id;
|
||||||
|
const parEmail = async (email: string) =>
|
||||||
|
(await prisma.user.findUniqueOrThrow({ where: { email } })).id;
|
||||||
|
|
||||||
|
const a1 = await parReference('A1');
|
||||||
|
const b2 = await parReference('B2');
|
||||||
|
const c1 = await parReference('C1');
|
||||||
|
const ahmed = await parEmail('technicien@demo.siop.ma');
|
||||||
|
const salma = await parEmail('dispatcher@demo.siop.ma');
|
||||||
|
const karim = await parEmail('demandeur@demo.siop.ma');
|
||||||
|
const annee = new Date().getFullYear();
|
||||||
|
|
||||||
|
const grilleLabels = TASK_TEMPLATES.filter((t) => t.periodMonths === 1).map(
|
||||||
|
(t) => t.label,
|
||||||
|
);
|
||||||
|
|
||||||
|
const OTS: {
|
||||||
|
ref: string; title: string; type: 'CORRECTIVE' | 'PREVENTIVE' | 'WORKS';
|
||||||
|
status: 'OPEN' | 'IN_PROGRESS' | 'ON_HOLD' | 'DONE' | 'CANCELLED';
|
||||||
|
priority: 'NONE' | 'LOW' | 'MEDIUM' | 'HIGH' | 'PERSON_TRAPPED';
|
||||||
|
assetId: string; assignees?: string[]; dueJours?: number;
|
||||||
|
checklist?: string[]; bilan?: Record<string, string>;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
ref: `OT-${annee}-0342`, title: 'Personne bloquée en cabine', type: 'CORRECTIVE',
|
||||||
|
status: 'OPEN', priority: 'PERSON_TRAPPED', assetId: b2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ref: `OT-${annee}-0341`, title: 'Bruit anormal en gaine', type: 'CORRECTIVE',
|
||||||
|
status: 'IN_PROGRESS', priority: 'HIGH', assetId: a1, assignees: [ahmed], dueJours: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ref: `OT-${annee}-0338`, title: 'Grille du mois — juillet', type: 'PREVENTIVE',
|
||||||
|
status: 'IN_PROGRESS', priority: 'LOW', assetId: a1, assignees: [ahmed],
|
||||||
|
dueJours: 15, checklist: grilleLabels,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ref: `OT-${annee}-0332`, title: 'Réglage nivellement cabine', type: 'CORRECTIVE',
|
||||||
|
status: 'DONE', priority: 'LOW', assetId: a1,
|
||||||
|
bilan: {
|
||||||
|
doorStateId: refIds.get('DOOR_STATE/Fonctionnement normal')!,
|
||||||
|
actionTakenId: refIds.get('ACTION_TAKEN/Réglage')!,
|
||||||
|
componentConcernedId: refIds.get('COMPONENT_CONCERNED/Guides')!,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
for (const ot of OTS) {
|
||||||
|
const existant = await prisma.workOrder.findUnique({ where: { reference: ot.ref } });
|
||||||
|
if (existant) continue;
|
||||||
|
await prisma.workOrder.create({
|
||||||
|
data: {
|
||||||
|
reference: ot.ref,
|
||||||
|
title: ot.title,
|
||||||
|
type: ot.type,
|
||||||
|
status: ot.status,
|
||||||
|
priority: ot.priority,
|
||||||
|
assetId: ot.assetId,
|
||||||
|
createdById: salma,
|
||||||
|
dueDate: ot.dueJours
|
||||||
|
? new Date(Date.now() + ot.dueJours * 24 * 3600 * 1000)
|
||||||
|
: undefined,
|
||||||
|
startedAt: ot.status === 'IN_PROGRESS' || ot.status === 'DONE' ? new Date() : undefined,
|
||||||
|
completedAt: ot.status === 'DONE' ? new Date() : undefined,
|
||||||
|
assignees: ot.assignees ? { connect: ot.assignees.map((id) => ({ id })) } : undefined,
|
||||||
|
events: { create: { kind: 'CREATED', message: 'OT créé (seed)', byId: salma } },
|
||||||
|
checklist: ot.checklist
|
||||||
|
? { create: ot.checklist.map((label) => ({ label })) }
|
||||||
|
: undefined,
|
||||||
|
report: ot.bilan ? { create: ot.bilan } : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEMANDES: {
|
||||||
|
ref: string; description: string; assetId: string; isPersonTrapped?: boolean;
|
||||||
|
status: 'RECEIVED' | 'APPROVED' | 'REJECTED';
|
||||||
|
rejectionReason?: string; otRef?: string;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
ref: `DEM-${annee}-0111`, assetId: a1, status: 'RECEIVED',
|
||||||
|
description: 'La porte ne se ferme plus au 3ᵉ étage, il faut la retenir à la main.',
|
||||||
|
},
|
||||||
|
{ ref: `DEM-${annee}-0110`, assetId: c1, status: 'RECEIVED', description: 'Voyant étage éteint.' },
|
||||||
|
{
|
||||||
|
ref: `DEM-${annee}-0107`, assetId: a1, status: 'APPROVED',
|
||||||
|
description: 'Bruit anormal en gaine.', otRef: `OT-${annee}-0341`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ref: `DEM-${annee}-0104`, assetId: b2, status: 'REJECTED',
|
||||||
|
description: 'Odeur de brûlé.', rejectionReason: 'Fausse alerte confirmée sur place par le gardien.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
for (const dem of DEMANDES) {
|
||||||
|
const existant = await prisma.request.findUnique({ where: { reference: dem.ref } });
|
||||||
|
if (existant) continue;
|
||||||
|
const workOrderId = dem.otRef
|
||||||
|
? (await prisma.workOrder.findUnique({ where: { reference: dem.otRef } }))?.id
|
||||||
|
: undefined;
|
||||||
|
await prisma.request.create({
|
||||||
|
data: {
|
||||||
|
reference: dem.ref,
|
||||||
|
description: dem.description,
|
||||||
|
isPersonTrapped: dem.isPersonTrapped ?? false,
|
||||||
|
status: dem.status,
|
||||||
|
rejectionReason: dem.rejectionReason,
|
||||||
|
assetId: dem.assetId,
|
||||||
|
requestedById: karim,
|
||||||
|
workOrderId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compteurs de la maquette (A1)
|
||||||
|
for (const [kind, valeurs] of [
|
||||||
|
['RUNNING_HOURS', [12246, 12322, 12411]],
|
||||||
|
['STARTS', [1815400, 1831970]],
|
||||||
|
] as const) {
|
||||||
|
const meter = await prisma.meter.upsert({
|
||||||
|
where: { assetId_kind: { assetId: a1, kind } },
|
||||||
|
update: {},
|
||||||
|
create: { assetId: a1, kind },
|
||||||
|
include: { _count: { select: { readings: true } } },
|
||||||
|
});
|
||||||
|
if (meter._count.readings === 0) {
|
||||||
|
await prisma.meterReading.createMany({
|
||||||
|
data: valeurs.map((value) => ({ meterId: meter.id, value, readById: ahmed })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ————— R3 — Gestion (tiers, pièces, mouvements, BC, taux — données maquette) —————
|
||||||
|
|
||||||
|
const PARTNERS: {
|
||||||
|
name: string; kind: 'SUPPLIER' | 'CLIENT'; contactName?: string;
|
||||||
|
phone?: string; email?: string; city?: string;
|
||||||
|
}[] = [
|
||||||
|
{ name: 'Ascentech Maroc', kind: 'SUPPLIER', contactName: 'M. Berrini', phone: '05 22 34 56 78', city: 'Casablanca' },
|
||||||
|
{ name: 'SchindlerParts', kind: 'SUPPLIER', email: 'commandes@schindlerparts.ma' },
|
||||||
|
{ name: 'Lubmaroc', kind: 'SUPPLIER', phone: '05 22 11 22 33' },
|
||||||
|
{ name: 'Atlas Property Management', kind: 'CLIENT', contactName: 'Mme Zerhouni', phone: '06 61 98 76 54' },
|
||||||
|
{ name: 'Syndic Al Manar', kind: 'CLIENT', contactName: 'M. Alami (gardien référent)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PARTS: {
|
||||||
|
reference: string; designation: string; threshold: number;
|
||||||
|
lastUnitPrice?: number; supplier?: string; compatible?: string;
|
||||||
|
}[] = [
|
||||||
|
{ reference: 'P-0019', designation: 'Cellule barrière porte (paire)', threshold: 4, lastUnitPrice: 640, supplier: 'SchindlerParts' },
|
||||||
|
{ reference: 'P-0031', designation: 'Bouton palier lumineux Ø22', threshold: 10, lastUnitPrice: 45, supplier: 'Ascentech Maroc' },
|
||||||
|
{ reference: 'P-0042', designation: 'Graisse guide (cartouche 400 g)', threshold: 8, lastUnitPrice: 85, supplier: 'Lubmaroc' },
|
||||||
|
{ reference: 'P-0087', designation: 'Coulisseau de guide 16 mm', threshold: 6, lastUnitPrice: 120, supplier: 'Ascentech Maroc' },
|
||||||
|
{ reference: 'P-0113', designation: 'Contact de porte NC-31', threshold: 5, lastUnitPrice: 85, supplier: 'Ascentech Maroc', compatible: 'Otis Gen2 · Schindler 3300' },
|
||||||
|
];
|
||||||
|
|
||||||
|
async function seedGestion(prisma: PrismaClient): Promise<void> {
|
||||||
|
const partnerIds = new Map<string, string>();
|
||||||
|
for (const p of PARTNERS) {
|
||||||
|
const partner = await prisma.partner.upsert({
|
||||||
|
where: { name: p.name },
|
||||||
|
update: {},
|
||||||
|
create: p,
|
||||||
|
});
|
||||||
|
partnerIds.set(p.name, partner.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rattachements de la maquette Tiers : les syndics gèrent leur site.
|
||||||
|
for (const [site, partner] of [
|
||||||
|
['Tour Atlas', 'Atlas Property Management'],
|
||||||
|
['Résidence Al Manar', 'Syndic Al Manar'],
|
||||||
|
] as const) {
|
||||||
|
await prisma.location.updateMany({
|
||||||
|
where: { name: site, parentId: null, partnerId: null },
|
||||||
|
data: { partnerId: partnerIds.get(partner) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const partIds = new Map<string, string>();
|
||||||
|
for (const p of PARTS) {
|
||||||
|
const part = await prisma.part.upsert({
|
||||||
|
where: { reference: p.reference },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
reference: p.reference,
|
||||||
|
designation: p.designation,
|
||||||
|
threshold: p.threshold,
|
||||||
|
lastUnitPrice: p.lastUnitPrice,
|
||||||
|
compatible: p.compatible,
|
||||||
|
supplierId: p.supplier ? partnerIds.get(p.supplier) : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
partIds.set(p.reference, part.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Taux horaires courants (les saisies figent leur propre taux)
|
||||||
|
for (const [email, taux] of [
|
||||||
|
['technicien@demo.siop.ma', 120],
|
||||||
|
['technicien-limite@demo.siop.ma', 90],
|
||||||
|
] as const) {
|
||||||
|
await prisma.user.update({ where: { email }, data: { hourlyRate: taux } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idempotence : si des mouvements existent déjà, l'histoire est en place
|
||||||
|
if ((await prisma.stockMovement.count()) > 0) return;
|
||||||
|
|
||||||
|
const annee = new Date().getFullYear();
|
||||||
|
const ahmed = await prisma.user.findUniqueOrThrow({ where: { email: 'technicien@demo.siop.ma' } });
|
||||||
|
const nadia = await prisma.user.findUniqueOrThrow({ where: { email: 'gestionnaire@demo.siop.ma' } });
|
||||||
|
|
||||||
|
// BC reçus (historique) et BC en cours — comme la maquette
|
||||||
|
const bc21 = await prisma.purchaseOrder.create({
|
||||||
|
data: {
|
||||||
|
reference: `BC-${annee}-0021`,
|
||||||
|
status: 'RECEIVED',
|
||||||
|
supplierId: partnerIds.get('Ascentech Maroc')!,
|
||||||
|
createdById: nadia.id,
|
||||||
|
sentAt: new Date(Date.now() - 40 * 86400e3),
|
||||||
|
receivedAt: new Date(Date.now() - 34 * 86400e3),
|
||||||
|
lines: {
|
||||||
|
create: [
|
||||||
|
{ partId: partIds.get('P-0113')!, quantity: 5, unitPrice: 85 },
|
||||||
|
{ partId: partIds.get('P-0087')!, quantity: 6, unitPrice: 120 },
|
||||||
|
{ partId: partIds.get('P-0031')!, quantity: 20, unitPrice: 45 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: { lines: true },
|
||||||
|
});
|
||||||
|
await prisma.purchaseOrder.create({
|
||||||
|
data: {
|
||||||
|
reference: `BC-${annee}-0024`,
|
||||||
|
status: 'SENT',
|
||||||
|
supplierId: partnerIds.get('Ascentech Maroc')!,
|
||||||
|
createdById: nadia.id,
|
||||||
|
sentAt: new Date(),
|
||||||
|
lines: {
|
||||||
|
create: [
|
||||||
|
{ partId: partIds.get('P-0113')!, quantity: 10, unitPrice: 85 },
|
||||||
|
{ partId: partIds.get('P-0087')!, quantity: 2, unitPrice: 127.5 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mouvements : réceptions du BC-0021 + entrées initiales + consommations
|
||||||
|
for (const line of bc21.lines) {
|
||||||
|
await prisma.stockMovement.create({
|
||||||
|
data: {
|
||||||
|
partId: line.partId,
|
||||||
|
kind: 'RECEIPT',
|
||||||
|
quantity: line.quantity,
|
||||||
|
unitPrice: line.unitPrice,
|
||||||
|
purchaseOrderId: bc21.id,
|
||||||
|
byId: nadia.id,
|
||||||
|
createdAt: new Date(Date.now() - 34 * 86400e3),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const [ref, qte] of [
|
||||||
|
['P-0042', 19],
|
||||||
|
['P-0019', 7],
|
||||||
|
['P-0031', 6],
|
||||||
|
] as const) {
|
||||||
|
await prisma.stockMovement.create({
|
||||||
|
data: {
|
||||||
|
partId: partIds.get(ref)!,
|
||||||
|
kind: 'ENTRY',
|
||||||
|
quantity: qte,
|
||||||
|
reason: 'Reprise de l’inventaire initial',
|
||||||
|
byId: nadia.id,
|
||||||
|
createdAt: new Date(Date.now() - 60 * 86400e3),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// P-0113 : consommations + ajustement → stock 2 (sous le seuil, maquette)
|
||||||
|
const ot341 = await prisma.workOrder.findUnique({
|
||||||
|
where: { reference: `OT-${annee}-0341` },
|
||||||
|
});
|
||||||
|
await prisma.stockMovement.createMany({
|
||||||
|
data: [
|
||||||
|
{ partId: partIds.get('P-0113')!, kind: 'ADJUSTMENT', quantity: -1, reason: 'Inventaire — pièce endommagée', byId: nadia.id },
|
||||||
|
{ partId: partIds.get('P-0113')!, kind: 'CONSUMPTION', quantity: -2, unitPrice: 85, byId: ahmed.id },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
// P-0087 : consommation sur OT-0341 (carte maquette : 2 × 120 = 240 MAD)
|
||||||
|
if (ot341) {
|
||||||
|
await prisma.stockMovement.create({
|
||||||
|
data: {
|
||||||
|
partId: partIds.get('P-0087')!,
|
||||||
|
kind: 'CONSUMPTION',
|
||||||
|
quantity: -2,
|
||||||
|
unitPrice: 120,
|
||||||
|
workOrderId: ot341.id,
|
||||||
|
byId: ahmed.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.stockMovement.create({
|
||||||
|
data: {
|
||||||
|
partId: partIds.get('P-0042')!,
|
||||||
|
kind: 'CONSUMPTION',
|
||||||
|
quantity: -1,
|
||||||
|
unitPrice: 85,
|
||||||
|
workOrderId: ot341.id,
|
||||||
|
byId: ahmed.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// main-d'œuvre : 1 h 30 × 120 MAD/h = 180 (total maquette : 505 MAD)
|
||||||
|
await prisma.laborTime.create({
|
||||||
|
data: { workOrderId: ot341.id, userId: ahmed.id, minutes: 90, hourlyRate: 120 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* c8 ignore start — wrapper CLI */
|
/* c8 ignore start — wrapper CLI */
|
||||||
if (require.main === module) {
|
if (require.main === module) {
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|||||||
14
apps/api/src/analytics/analytics.controller.ts
Normal file
14
apps/api/src/analytics/analytics.controller.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Controller, Get, Query } from '@nestjs/common';
|
||||||
|
import { RequirePermission } from '../permissions/require-permission.decorator';
|
||||||
|
import { AnalyticsService } from './analytics.service';
|
||||||
|
|
||||||
|
@Controller('analytics')
|
||||||
|
export class AnalyticsController {
|
||||||
|
constructor(private readonly analytics: AnalyticsService) {}
|
||||||
|
|
||||||
|
@Get('summary')
|
||||||
|
@RequirePermission('ANALYTICS', 'view')
|
||||||
|
summary(@Query('months') months?: string) {
|
||||||
|
return this.analytics.summary(months);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/api/src/analytics/analytics.module.ts
Normal file
9
apps/api/src/analytics/analytics.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AnalyticsController } from './analytics.controller';
|
||||||
|
import { AnalyticsService } from './analytics.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [AnalyticsController],
|
||||||
|
providers: [AnalyticsService],
|
||||||
|
})
|
||||||
|
export class AnalyticsModule {}
|
||||||
201
apps/api/src/analytics/analytics.service.ts
Normal file
201
apps/api/src/analytics/analytics.service.ts
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ANALYTICS_PERIODS, type AnalyticsPeriod, type AnalyticsSummary } from '@siop/shared';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
const debutMois = (decalage: number): Date => {
|
||||||
|
const d = new Date();
|
||||||
|
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() - decalage, 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Le tableau de la direction — tout est DÉRIVÉ des données réelles :
|
||||||
|
* mouvements (prix figés), main-d'œuvre (taux figés), bilans codés. */
|
||||||
|
@Injectable()
|
||||||
|
export class AnalyticsService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async summary(monthsRaw?: string): Promise<AnalyticsSummary> {
|
||||||
|
// Période demandée par l'écran (3, 6 ou 12 mois) — 12 par défaut.
|
||||||
|
const months: AnalyticsPeriod = ANALYTICS_PERIODS.find(
|
||||||
|
(p) => p === Number(monthsRaw),
|
||||||
|
) ?? 12;
|
||||||
|
const depuisPeriode = debutMois(months - 1);
|
||||||
|
|
||||||
|
const [consommations, mainOeuvre, clos12m, grilles12m, correctifsClos, pannes, topDonnees] =
|
||||||
|
await Promise.all([
|
||||||
|
this.prisma.stockMovement.findMany({
|
||||||
|
where: { kind: 'CONSUMPTION', createdAt: { gte: depuisPeriode } },
|
||||||
|
select: { quantity: true, unitPrice: true, createdAt: true },
|
||||||
|
}),
|
||||||
|
this.prisma.laborTime.findMany({
|
||||||
|
where: { createdAt: { gte: depuisPeriode } },
|
||||||
|
select: { minutes: true, hourlyRate: true, createdAt: true },
|
||||||
|
}),
|
||||||
|
this.prisma.workOrder.groupBy({
|
||||||
|
by: ['type'],
|
||||||
|
where: { status: 'DONE', completedAt: { gte: depuisPeriode } },
|
||||||
|
_count: { _all: true },
|
||||||
|
}),
|
||||||
|
this.prisma.workOrder.findMany({
|
||||||
|
where: { periodKey: { not: null }, createdAt: { gte: depuisPeriode } },
|
||||||
|
select: { status: true },
|
||||||
|
}),
|
||||||
|
this.prisma.workOrder.findMany({
|
||||||
|
where: { type: 'CORRECTIVE', status: 'DONE', completedAt: { gte: depuisPeriode } },
|
||||||
|
select: { createdAt: true, completedAt: true },
|
||||||
|
}),
|
||||||
|
this.prisma.interventionReport.groupBy({
|
||||||
|
by: ['componentConcernedId'],
|
||||||
|
where: {
|
||||||
|
componentConcernedId: { not: null },
|
||||||
|
workOrder: { status: 'DONE', type: 'CORRECTIVE', completedAt: { gte: depuisPeriode } },
|
||||||
|
},
|
||||||
|
_count: { _all: true },
|
||||||
|
}),
|
||||||
|
this.prisma.stockMovement.findMany({
|
||||||
|
where: { kind: 'CONSUMPTION', workOrderId: { not: null }, createdAt: { gte: depuisPeriode } },
|
||||||
|
select: {
|
||||||
|
quantity: true,
|
||||||
|
unitPrice: true,
|
||||||
|
workOrder: {
|
||||||
|
select: {
|
||||||
|
type: true,
|
||||||
|
asset: {
|
||||||
|
select: {
|
||||||
|
reference: true,
|
||||||
|
location: { select: { name: true, parent: { select: { name: true } } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Coûts par mois (6 derniers) — pièces + main-d'œuvre, prix/taux figés
|
||||||
|
const cleMois = (d: Date) => d.toISOString().slice(0, 7);
|
||||||
|
const parMois = new Map<string, number>();
|
||||||
|
for (let i = months - 1; i >= 0; i--) parMois.set(cleMois(debutMois(i)), 0);
|
||||||
|
for (const c of consommations) {
|
||||||
|
const cle = cleMois(c.createdAt);
|
||||||
|
if (parMois.has(cle)) {
|
||||||
|
parMois.set(cle, parMois.get(cle)! + -c.quantity * Number(c.unitPrice ?? 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const l of mainOeuvre) {
|
||||||
|
const cle = cleMois(l.createdAt);
|
||||||
|
if (parMois.has(cle)) {
|
||||||
|
parMois.set(cle, parMois.get(cle)! + (l.minutes / 60) * Number(l.hourlyRate));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const costsByMonth = [...parMois.entries()].map(([month, total]) => ({
|
||||||
|
month,
|
||||||
|
total: Math.round(total * 100) / 100,
|
||||||
|
}));
|
||||||
|
const moisCourant = cleMois(new Date());
|
||||||
|
const moisPrecedent = cleMois(debutMois(1));
|
||||||
|
|
||||||
|
// Pannes par organe : depuis les bilans codés
|
||||||
|
const labels = await this.prisma.referenceValue.findMany({
|
||||||
|
where: { id: { in: pannes.map((p) => p.componentConcernedId!).filter(Boolean) } },
|
||||||
|
});
|
||||||
|
const labelDe = new Map(labels.map((l) => [l.id, l.label]));
|
||||||
|
const failuresByComponent = pannes
|
||||||
|
.map((p) => ({
|
||||||
|
label: labelDe.get(p.componentConcernedId!) ?? '—',
|
||||||
|
count: p._count._all,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.count - a.count)
|
||||||
|
.slice(0, 6);
|
||||||
|
|
||||||
|
// Top équipements en coût (pièces via OT + main-d'œuvre)
|
||||||
|
const laborParOT = await this.prisma.laborTime.findMany({
|
||||||
|
where: { createdAt: { gte: depuisPeriode } },
|
||||||
|
select: {
|
||||||
|
minutes: true,
|
||||||
|
hourlyRate: true,
|
||||||
|
workOrder: {
|
||||||
|
select: {
|
||||||
|
type: true,
|
||||||
|
asset: {
|
||||||
|
select: {
|
||||||
|
reference: true,
|
||||||
|
location: { select: { name: true, parent: { select: { name: true } } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
interface Cumul { siteName: string; correctives: number; partsCost: number; laborCost: number }
|
||||||
|
const parAppareil = new Map<string, Cumul>();
|
||||||
|
const cumul = (ref: string, siteName: string): Cumul => {
|
||||||
|
if (!parAppareil.has(ref)) {
|
||||||
|
parAppareil.set(ref, { siteName, correctives: 0, partsCost: 0, laborCost: 0 });
|
||||||
|
}
|
||||||
|
return parAppareil.get(ref)!;
|
||||||
|
};
|
||||||
|
for (const m of topDonnees) {
|
||||||
|
const asset = m.workOrder!.asset;
|
||||||
|
const c = cumul(asset.reference, asset.location.parent?.name ?? asset.location.name);
|
||||||
|
c.partsCost += -m.quantity * Number(m.unitPrice ?? 0);
|
||||||
|
}
|
||||||
|
for (const l of laborParOT) {
|
||||||
|
const asset = l.workOrder.asset;
|
||||||
|
const c = cumul(asset.reference, asset.location.parent?.name ?? asset.location.name);
|
||||||
|
c.laborCost += (l.minutes / 60) * Number(l.hourlyRate);
|
||||||
|
}
|
||||||
|
const correctivesParAppareil = await this.prisma.workOrder.groupBy({
|
||||||
|
by: ['assetId'],
|
||||||
|
where: { type: 'CORRECTIVE', createdAt: { gte: depuisPeriode } },
|
||||||
|
_count: { _all: true },
|
||||||
|
});
|
||||||
|
const assetsRefs = await this.prisma.asset.findMany({
|
||||||
|
where: { id: { in: correctivesParAppareil.map((c) => c.assetId) } },
|
||||||
|
select: { id: true, reference: true },
|
||||||
|
});
|
||||||
|
const refDe = new Map(assetsRefs.map((a) => [a.id, a.reference]));
|
||||||
|
for (const c of correctivesParAppareil) {
|
||||||
|
const ref = refDe.get(c.assetId);
|
||||||
|
if (ref && parAppareil.has(ref)) parAppareil.get(ref)!.correctives = c._count._all;
|
||||||
|
}
|
||||||
|
const topAssets = [...parAppareil.entries()]
|
||||||
|
.map(([reference, c]) => ({
|
||||||
|
reference,
|
||||||
|
siteName: c.siteName,
|
||||||
|
correctives: c.correctives,
|
||||||
|
partsCost: Math.round(c.partsCost * 100) / 100,
|
||||||
|
laborCost: Math.round(c.laborCost * 100) / 100,
|
||||||
|
total: Math.round((c.partsCost + c.laborCost) * 100) / 100,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.total - a.total)
|
||||||
|
.slice(0, 5);
|
||||||
|
|
||||||
|
const totalClos = clos12m.reduce((s, g) => s + g._count._all, 0);
|
||||||
|
const preventifsClos = clos12m.find((g) => g.type === 'PREVENTIVE')?._count._all ?? 0;
|
||||||
|
const grillesTerminees = grilles12m.filter((g) => g.status === 'DONE').length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
months,
|
||||||
|
monthCost: costsByMonth.find((c) => c.month === moisCourant)?.total ?? 0,
|
||||||
|
previousMonthCost: costsByMonth.find((c) => c.month === moisPrecedent)?.total ?? 0,
|
||||||
|
closed: { total: totalClos, preventive: preventifsClos },
|
||||||
|
preventiveRate: grilles12m.length
|
||||||
|
? Math.round((grillesTerminees / grilles12m.length) * 100) / 100
|
||||||
|
: null,
|
||||||
|
avgResolutionDays: correctifsClos.length
|
||||||
|
? Math.round(
|
||||||
|
(correctifsClos.reduce(
|
||||||
|
(s, w) => s + (w.completedAt!.getTime() - w.createdAt.getTime()),
|
||||||
|
0,
|
||||||
|
) /
|
||||||
|
correctifsClos.length /
|
||||||
|
86400e3) *
|
||||||
|
10,
|
||||||
|
) / 10
|
||||||
|
: null,
|
||||||
|
failuresByComponent,
|
||||||
|
costsByMonth,
|
||||||
|
topAssets,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,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';
|
||||||
@@ -9,7 +12,16 @@ import { demoModeEnabled } from './config/env';
|
|||||||
import { FilesModule } from './files/files.module';
|
import { FilesModule } from './files/files.module';
|
||||||
import { HealthModule } from './health/health.module';
|
import { HealthModule } from './health/health.module';
|
||||||
import { LocationsModule } from './locations/locations.module';
|
import { LocationsModule } from './locations/locations.module';
|
||||||
|
import { MetersModule } from './meters/meters.module';
|
||||||
|
import { PartnersModule } from './partners/partners.module';
|
||||||
|
import { PartsModule } from './parts/parts.module';
|
||||||
|
import { PortalModule } from './portal/portal.module';
|
||||||
|
import { PurchaseOrdersModule } from './purchase-orders/purchase-orders.module';
|
||||||
|
import { PreventiveModule } from './preventive/preventive.module';
|
||||||
|
import { ReferenceValuesModule } from './reference-values/reference-values.module';
|
||||||
|
import { RequestsModule } from './requests/requests.module';
|
||||||
import { TeamsModule } from './teams/teams.module';
|
import { TeamsModule } from './teams/teams.module';
|
||||||
|
import { WorkOrdersModule } from './work-orders/work-orders.module';
|
||||||
import { PermissionsGuard } from './permissions/permissions.guard';
|
import { PermissionsGuard } from './permissions/permissions.guard';
|
||||||
import { PermissionsModule } from './permissions/permissions.module';
|
import { PermissionsModule } from './permissions/permissions.module';
|
||||||
import { PrismaModule } from './prisma/prisma.module';
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
@@ -37,6 +49,20 @@ export class AppModule {
|
|||||||
LocationsModule,
|
LocationsModule,
|
||||||
AssetsModule,
|
AssetsModule,
|
||||||
TeamsModule,
|
TeamsModule,
|
||||||
|
// R2 — exploitation
|
||||||
|
WorkOrdersModule,
|
||||||
|
RequestsModule,
|
||||||
|
ReferenceValuesModule,
|
||||||
|
PreventiveModule,
|
||||||
|
MetersModule,
|
||||||
|
PortalModule,
|
||||||
|
// R3 — gestion
|
||||||
|
PartnersModule,
|
||||||
|
PartsModule,
|
||||||
|
PurchaseOrdersModule,
|
||||||
|
DocumentsModule,
|
||||||
|
AnalyticsModule,
|
||||||
|
SearchModule,
|
||||||
// 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] : []),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -31,6 +31,13 @@ export class AssetsController {
|
|||||||
return this.assetsService.list();
|
return this.assetsService.list();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Avant ':id' (ordre des routes) — authentification seule : le demandeur
|
||||||
|
* doit pouvoir désigner l'appareil qu'il signale. */
|
||||||
|
@Get('options')
|
||||||
|
options() {
|
||||||
|
return this.assetsService.options();
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@RequirePermission('ASSETS', 'view')
|
@RequirePermission('ASSETS', 'view')
|
||||||
get(@Param('id', ParseUUIDPipe) id: string) {
|
get(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
AssetCreate,
|
AssetCreate,
|
||||||
AssetDetail,
|
AssetDetail,
|
||||||
AssetDto,
|
AssetDto,
|
||||||
|
AssetOptionsResponse,
|
||||||
AssetsResponse,
|
AssetsResponse,
|
||||||
AssetUpdate,
|
AssetUpdate,
|
||||||
} from '@siop/shared';
|
} from '@siop/shared';
|
||||||
@@ -28,6 +29,21 @@ type AssetRow = Prisma.AssetGetPayload<{ include: typeof assetInclude }>;
|
|||||||
export class AssetsService {
|
export class AssetsService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
/** Options minimales pour le signalement — ouvert à tout rôle authentifié. */
|
||||||
|
async options(): Promise<AssetOptionsResponse> {
|
||||||
|
const rows = await this.prisma.asset.findMany({
|
||||||
|
include: { location: { include: { parent: true } } },
|
||||||
|
orderBy: { reference: 'asc' },
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
options: rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
reference: r.reference,
|
||||||
|
siteName: r.location.parent?.name ?? r.location.name,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async list(): Promise<AssetsResponse> {
|
async list(): Promise<AssetsResponse> {
|
||||||
const rows = await this.prisma.asset.findMany({
|
const rows = await this.prisma.asset.findMany({
|
||||||
include: assetInclude,
|
include: assetInclude,
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
77
apps/api/src/documents/documents.controller.ts
Normal file
77
apps/api/src/documents/documents.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/api/src/documents/documents.module.ts
Normal file
9
apps/api/src/documents/documents.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { DocumentsController } from './documents.controller';
|
||||||
|
import { DocumentsService } from './documents.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [DocumentsController],
|
||||||
|
providers: [DocumentsService],
|
||||||
|
})
|
||||||
|
export class DocumentsModule {}
|
||||||
160
apps/api/src/documents/documents.service.ts
Normal file
160
apps/api/src/documents/documents.service.ts
Normal 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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
|||||||
40
apps/api/src/meters/meters.controller.ts
Normal file
40
apps/api/src/meters/meters.controller.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Post,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
MeterReadingCreateSchema,
|
||||||
|
type MeterReadingCreate,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import {
|
||||||
|
AuthenticatedUser,
|
||||||
|
CurrentUser,
|
||||||
|
} from '../auth/current-user.decorator';
|
||||||
|
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||||
|
import { RequirePermission } from '../permissions/require-permission.decorator';
|
||||||
|
import { MetersService } from './meters.service';
|
||||||
|
|
||||||
|
@Controller('assets/:id')
|
||||||
|
export class MetersController {
|
||||||
|
constructor(private readonly meters: MetersService) {}
|
||||||
|
|
||||||
|
@Get('meters')
|
||||||
|
@RequirePermission('METERS', 'view')
|
||||||
|
list(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.meters.list(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('meter-readings')
|
||||||
|
@RequirePermission('METERS', 'create')
|
||||||
|
addReading(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body(new ZodValidationPipe(MeterReadingCreateSchema)) body: MeterReadingCreate,
|
||||||
|
@CurrentUser() user: AuthenticatedUser,
|
||||||
|
) {
|
||||||
|
return this.meters.addReading(id, body, user);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/api/src/meters/meters.module.ts
Normal file
9
apps/api/src/meters/meters.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { MetersController } from './meters.controller';
|
||||||
|
import { MetersService } from './meters.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [MetersController],
|
||||||
|
providers: [MetersService],
|
||||||
|
})
|
||||||
|
export class MetersModule {}
|
||||||
77
apps/api/src/meters/meters.service.ts
Normal file
77
apps/api/src/meters/meters.service.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { METER_KINDS, type MeterReadingCreate, type MetersResponse } from '@siop/shared';
|
||||||
|
import type { AuthenticatedUser } from '../auth/current-user.decorator';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MetersService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async list(assetId: string): Promise<MetersResponse> {
|
||||||
|
await this.assertAsset(assetId);
|
||||||
|
const meters = await this.prisma.meter.findMany({
|
||||||
|
where: { assetId },
|
||||||
|
include: {
|
||||||
|
readings: {
|
||||||
|
include: { readBy: true },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Les deux compteurs existent toujours dans la réponse, même vides
|
||||||
|
return {
|
||||||
|
meters: METER_KINDS.map((kind) => {
|
||||||
|
const meter = meters.find((m) => m.kind === kind);
|
||||||
|
return {
|
||||||
|
kind,
|
||||||
|
readings:
|
||||||
|
meter?.readings.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
value: r.value,
|
||||||
|
readBy: r.readBy
|
||||||
|
? { id: r.readBy.id, displayName: r.readBy.displayName }
|
||||||
|
: null,
|
||||||
|
createdAt: r.createdAt.toISOString(),
|
||||||
|
})) ?? [],
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Invariant : un compteur ne redescend jamais. */
|
||||||
|
async addReading(
|
||||||
|
assetId: string,
|
||||||
|
dto: MeterReadingCreate,
|
||||||
|
user: AuthenticatedUser,
|
||||||
|
): Promise<MetersResponse> {
|
||||||
|
await this.assertAsset(assetId);
|
||||||
|
const meter = await this.prisma.meter.upsert({
|
||||||
|
where: { assetId_kind: { assetId, kind: dto.kind } },
|
||||||
|
update: {},
|
||||||
|
create: { assetId, kind: dto.kind },
|
||||||
|
});
|
||||||
|
const dernier = await this.prisma.meterReading.findFirst({
|
||||||
|
where: { meterId: meter.id },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
if (dernier && dto.value <= dernier.value) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Relevé refusé : ${dto.value} est inférieur ou égal au précédent (${dernier.value}) — un compteur ne redescend pas`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await this.prisma.meterReading.create({
|
||||||
|
data: { meterId: meter.id, value: dto.value, readById: user.userId },
|
||||||
|
});
|
||||||
|
return this.list(assetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertAsset(assetId: string): Promise<void> {
|
||||||
|
const asset = await this.prisma.asset.findUnique({ where: { id: assetId } });
|
||||||
|
if (!asset) throw new NotFoundException('Appareil inconnu');
|
||||||
|
}
|
||||||
|
}
|
||||||
45
apps/api/src/partners/partners.controller.ts
Normal file
45
apps/api/src/partners/partners.controller.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
PartnerCreateSchema,
|
||||||
|
PartnerUpdateSchema,
|
||||||
|
type PartnerCreate,
|
||||||
|
type PartnerUpdate,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||||
|
import { RequirePermission } from '../permissions/require-permission.decorator';
|
||||||
|
import { PartnersService } from './partners.service';
|
||||||
|
|
||||||
|
/** Les tiers vivent sous la permission PURCHASE_ORDERS (décision R3). */
|
||||||
|
@Controller('partners')
|
||||||
|
export class PartnersController {
|
||||||
|
constructor(private readonly partners: PartnersService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequirePermission('PURCHASE_ORDERS', 'view')
|
||||||
|
list() {
|
||||||
|
return this.partners.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequirePermission('PURCHASE_ORDERS', 'create')
|
||||||
|
create(@Body(new ZodValidationPipe(PartnerCreateSchema)) body: PartnerCreate) {
|
||||||
|
return this.partners.create(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@RequirePermission('PURCHASE_ORDERS', 'edit')
|
||||||
|
update(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body(new ZodValidationPipe(PartnerUpdateSchema)) body: PartnerUpdate,
|
||||||
|
) {
|
||||||
|
return this.partners.update(id, body);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/api/src/partners/partners.module.ts
Normal file
9
apps/api/src/partners/partners.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PartnersController } from './partners.controller';
|
||||||
|
import { PartnersService } from './partners.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [PartnersController],
|
||||||
|
providers: [PartnersService],
|
||||||
|
})
|
||||||
|
export class PartnersModule {}
|
||||||
91
apps/api/src/partners/partners.service.ts
Normal file
91
apps/api/src/partners/partners.service.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import {
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import type {
|
||||||
|
PartnerCreate,
|
||||||
|
PartnerDto,
|
||||||
|
PartnersResponse,
|
||||||
|
PartnerUpdate,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
const partnerInclude = {
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
purchaseOrders: { where: { status: { in: ['DRAFT', 'SENT'] } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Rattachements de la maquette : sites (jamais les zones) gérés par un client/syndic.
|
||||||
|
sites: {
|
||||||
|
where: { parentId: null },
|
||||||
|
select: { name: true },
|
||||||
|
orderBy: { name: 'asc' as const },
|
||||||
|
},
|
||||||
|
} satisfies Prisma.PartnerInclude;
|
||||||
|
|
||||||
|
type Row = Prisma.PartnerGetPayload<{ include: typeof partnerInclude }>;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PartnersService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async list(): Promise<PartnersResponse> {
|
||||||
|
const rows = await this.prisma.partner.findMany({
|
||||||
|
include: partnerInclude,
|
||||||
|
orderBy: [{ kind: 'asc' }, { name: 'asc' }],
|
||||||
|
});
|
||||||
|
return { partners: rows.map((r) => this.toDto(r)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: PartnerCreate): Promise<PartnerDto> {
|
||||||
|
try {
|
||||||
|
const created = await this.prisma.partner.create({
|
||||||
|
data: dto,
|
||||||
|
include: partnerInclude,
|
||||||
|
});
|
||||||
|
return this.toDto(created);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||||
|
throw new ConflictException('Ce nom de tiers existe déjà');
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: PartnerUpdate): Promise<PartnerDto> {
|
||||||
|
try {
|
||||||
|
const updated = await this.prisma.partner.update({
|
||||||
|
where: { id },
|
||||||
|
data: dto,
|
||||||
|
include: partnerInclude,
|
||||||
|
});
|
||||||
|
return this.toDto(updated);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') {
|
||||||
|
throw new NotFoundException('Tiers inconnu');
|
||||||
|
}
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||||
|
throw new ConflictException('Ce nom de tiers existe déjà');
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDto(row: Row): PartnerDto {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
kind: row.kind,
|
||||||
|
contactName: row.contactName,
|
||||||
|
phone: row.phone,
|
||||||
|
email: row.email,
|
||||||
|
city: row.city,
|
||||||
|
isActive: row.isActive,
|
||||||
|
openOrders: row._count.purchaseOrders,
|
||||||
|
siteNames: row.sites.map((s) => s.name),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
66
apps/api/src/parts/parts.controller.ts
Normal file
66
apps/api/src/parts/parts.controller.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
PartCreateSchema,
|
||||||
|
PartUpdateSchema,
|
||||||
|
StockMovementCreateSchema,
|
||||||
|
type PartCreate,
|
||||||
|
type PartUpdate,
|
||||||
|
type StockMovementCreate,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import {
|
||||||
|
AuthenticatedUser,
|
||||||
|
CurrentUser,
|
||||||
|
} from '../auth/current-user.decorator';
|
||||||
|
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||||
|
import { RequirePermission } from '../permissions/require-permission.decorator';
|
||||||
|
import { PartsService } from './parts.service';
|
||||||
|
|
||||||
|
@Controller('parts')
|
||||||
|
export class PartsController {
|
||||||
|
constructor(private readonly parts: PartsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequirePermission('PARTS', 'view')
|
||||||
|
list() {
|
||||||
|
return this.parts.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@RequirePermission('PARTS', 'view')
|
||||||
|
get(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.parts.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequirePermission('PARTS', 'create')
|
||||||
|
create(@Body(new ZodValidationPipe(PartCreateSchema)) body: PartCreate) {
|
||||||
|
return this.parts.create(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@RequirePermission('PARTS', 'edit')
|
||||||
|
update(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body(new ZodValidationPipe(PartUpdateSchema)) body: PartUpdate,
|
||||||
|
) {
|
||||||
|
return this.parts.update(id, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/movements')
|
||||||
|
@RequirePermission('PARTS', 'edit')
|
||||||
|
addMovement(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body(new ZodValidationPipe(StockMovementCreateSchema)) body: StockMovementCreate,
|
||||||
|
@CurrentUser() user: AuthenticatedUser,
|
||||||
|
) {
|
||||||
|
return this.parts.addMovement(id, body, user);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/api/src/parts/parts.module.ts
Normal file
10
apps/api/src/parts/parts.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PartsController } from './parts.controller';
|
||||||
|
import { PartsService } from './parts.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [PartsController],
|
||||||
|
providers: [PartsService],
|
||||||
|
exports: [PartsService],
|
||||||
|
})
|
||||||
|
export class PartsModule {}
|
||||||
231
apps/api/src/parts/parts.service.ts
Normal file
231
apps/api/src/parts/parts.service.ts
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import type {
|
||||||
|
PartCreate,
|
||||||
|
PartDetail,
|
||||||
|
PartDto,
|
||||||
|
PartsResponse,
|
||||||
|
PartUpdate,
|
||||||
|
StockMovementCreate,
|
||||||
|
StockMovementDto,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import type { AuthenticatedUser } from '../auth/current-user.decorator';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
const partInclude = { supplier: true } satisfies Prisma.PartInclude;
|
||||||
|
type PartRow = Prisma.PartGetPayload<{ include: typeof partInclude }>;
|
||||||
|
|
||||||
|
const mvtInclude = {
|
||||||
|
workOrder: { select: { reference: true } },
|
||||||
|
purchaseOrder: { select: { reference: true } },
|
||||||
|
by: { select: { displayName: true } },
|
||||||
|
} satisfies Prisma.StockMovementInclude;
|
||||||
|
type MvtRow = Prisma.StockMovementGetPayload<{ include: typeof mvtInclude }>;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PartsService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
/** Le stock EST la somme des mouvements — calculé, jamais stocké. */
|
||||||
|
private async stocks(partIds?: string[]): Promise<Map<string, number>> {
|
||||||
|
const grouped = await this.prisma.stockMovement.groupBy({
|
||||||
|
by: ['partId'],
|
||||||
|
where: partIds ? { partId: { in: partIds } } : undefined,
|
||||||
|
_sum: { quantity: true },
|
||||||
|
});
|
||||||
|
return new Map(grouped.map((g) => [g.partId, g._sum.quantity ?? 0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(): Promise<PartsResponse> {
|
||||||
|
const rows = await this.prisma.part.findMany({
|
||||||
|
include: partInclude,
|
||||||
|
orderBy: { reference: 'asc' },
|
||||||
|
});
|
||||||
|
const stocks = await this.stocks();
|
||||||
|
return { parts: rows.map((r) => this.toDto(r, stocks.get(r.id) ?? 0)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(id: string): Promise<PartDetail> {
|
||||||
|
const row = await this.prisma.part.findUnique({ where: { id }, include: partInclude });
|
||||||
|
if (!row) throw new NotFoundException('Pièce inconnue');
|
||||||
|
const [stocks, movements] = await Promise.all([
|
||||||
|
this.stocks([id]),
|
||||||
|
this.prisma.stockMovement.findMany({
|
||||||
|
where: { partId: id },
|
||||||
|
include: mvtInclude,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 50,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
...this.toDto(row, stocks.get(id) ?? 0),
|
||||||
|
movements: movements.map((m) => this.toMovementDto(m)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: PartCreate): Promise<PartDetail> {
|
||||||
|
if (dto.supplierId) await this.assertSupplier(dto.supplierId);
|
||||||
|
for (let essai = 0; ; essai++) {
|
||||||
|
try {
|
||||||
|
const created = await this.prisma.part.create({
|
||||||
|
data: {
|
||||||
|
reference: await this.nextReference(),
|
||||||
|
designation: dto.designation,
|
||||||
|
threshold: dto.threshold ?? 0,
|
||||||
|
supplierId: dto.supplierId,
|
||||||
|
compatible: dto.compatible,
|
||||||
|
lastUnitPrice: dto.initialPrice,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
return this.get(created.id);
|
||||||
|
} catch (e) {
|
||||||
|
if (
|
||||||
|
e instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
|
e.code === 'P2002' &&
|
||||||
|
essai < 3
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: PartUpdate): Promise<PartDetail> {
|
||||||
|
if (dto.supplierId) await this.assertSupplier(dto.supplierId);
|
||||||
|
try {
|
||||||
|
await this.prisma.part.update({ where: { id }, data: dto, select: { id: true } });
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') {
|
||||||
|
throw new NotFoundException('Pièce inconnue');
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
return this.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Entrée manuelle ou ajustement — TOUJOURS un mouvement tracé. */
|
||||||
|
async addMovement(
|
||||||
|
id: string,
|
||||||
|
dto: StockMovementCreate,
|
||||||
|
user: AuthenticatedUser,
|
||||||
|
): Promise<PartDetail> {
|
||||||
|
const part = await this.prisma.part.findUnique({ where: { id } });
|
||||||
|
if (!part) throw new NotFoundException('Pièce inconnue');
|
||||||
|
if (dto.kind === 'ENTRY' && dto.quantity <= 0) {
|
||||||
|
throw new BadRequestException('Une entrée manuelle est positive');
|
||||||
|
}
|
||||||
|
if (dto.kind === 'ADJUSTMENT' && !dto.reason?.trim()) {
|
||||||
|
throw new BadRequestException('Un ajustement d’inventaire exige un motif');
|
||||||
|
}
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
const somme = await tx.stockMovement.aggregate({
|
||||||
|
where: { partId: id },
|
||||||
|
_sum: { quantity: true },
|
||||||
|
});
|
||||||
|
const stock = somme._sum.quantity ?? 0;
|
||||||
|
if (stock + dto.quantity < 0) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Refusé : le stock deviendrait négatif (${stock} ${dto.quantity > 0 ? '+' : ''}${dto.quantity})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await tx.stockMovement.create({
|
||||||
|
data: {
|
||||||
|
partId: id,
|
||||||
|
kind: dto.kind,
|
||||||
|
quantity: dto.quantity,
|
||||||
|
reason: dto.reason,
|
||||||
|
byId: user.userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return this.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Consommation d'OT — stock suffisant exigé, PRIX FIGÉ au moment T.
|
||||||
|
* Appelé par WorkOrdersService dans le périmètre d'un OT vérifié. */
|
||||||
|
async consume(
|
||||||
|
partId: string,
|
||||||
|
quantity: number,
|
||||||
|
workOrderId: string,
|
||||||
|
user: AuthenticatedUser,
|
||||||
|
): Promise<{ designation: string; unitPrice: number }> {
|
||||||
|
const part = await this.prisma.part.findUnique({ where: { id: partId } });
|
||||||
|
if (!part || !part.isActive) throw new BadRequestException('Pièce inconnue ou désactivée');
|
||||||
|
if (part.lastUnitPrice === null) {
|
||||||
|
throw new ConflictException(
|
||||||
|
'Aucun prix connu pour cette pièce — réceptionnez un BC ou renseignez un prix initial',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
const somme = await tx.stockMovement.aggregate({
|
||||||
|
where: { partId },
|
||||||
|
_sum: { quantity: true },
|
||||||
|
});
|
||||||
|
const stock = somme._sum.quantity ?? 0;
|
||||||
|
if (stock < quantity) {
|
||||||
|
throw new ConflictException(`Stock insuffisant : ${stock} en stock, ${quantity} demandé`);
|
||||||
|
}
|
||||||
|
await tx.stockMovement.create({
|
||||||
|
data: {
|
||||||
|
partId,
|
||||||
|
kind: 'CONSUMPTION',
|
||||||
|
quantity: -quantity,
|
||||||
|
unitPrice: part.lastUnitPrice,
|
||||||
|
workOrderId,
|
||||||
|
byId: user.userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return { designation: part.designation, unitPrice: Number(part.lastUnitPrice) };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertSupplier(supplierId: string): Promise<void> {
|
||||||
|
const supplier = await this.prisma.partner.findUnique({ where: { id: supplierId } });
|
||||||
|
if (!supplier || supplier.kind !== 'SUPPLIER') {
|
||||||
|
throw new BadRequestException('Fournisseur inconnu');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async nextReference(): Promise<string> {
|
||||||
|
const [{ nextval }] = await this.prisma.$queryRaw<[{ nextval: bigint }]>`
|
||||||
|
SELECT nextval('part_ref_seq')`;
|
||||||
|
return `P-${String(nextval).padStart(4, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDto(row: PartRow, stock: number): PartDto {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
reference: row.reference,
|
||||||
|
designation: row.designation,
|
||||||
|
threshold: row.threshold,
|
||||||
|
lastUnitPrice: row.lastUnitPrice === null ? null : Number(row.lastUnitPrice),
|
||||||
|
compatible: row.compatible,
|
||||||
|
supplierId: row.supplierId,
|
||||||
|
supplierName: row.supplier?.name ?? null,
|
||||||
|
isActive: row.isActive,
|
||||||
|
stock,
|
||||||
|
belowThreshold: stock < row.threshold,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private toMovementDto(m: MvtRow): StockMovementDto {
|
||||||
|
return {
|
||||||
|
id: m.id,
|
||||||
|
kind: m.kind,
|
||||||
|
quantity: m.quantity,
|
||||||
|
unitPrice: m.unitPrice === null ? null : Number(m.unitPrice),
|
||||||
|
reason: m.reason,
|
||||||
|
workOrderReference: m.workOrder?.reference ?? null,
|
||||||
|
purchaseOrderReference: m.purchaseOrder?.reference ?? null,
|
||||||
|
byName: m.by?.displayName ?? null,
|
||||||
|
createdAt: m.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
40
apps/api/src/portal/portal.controller.ts
Normal file
40
apps/api/src/portal/portal.controller.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
|
||||||
|
import { Throttle, ThrottlerGuard } from '@nestjs/throttler';
|
||||||
|
import {
|
||||||
|
PortalRequestCreateSchema,
|
||||||
|
type PortalRequestCreate,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import { Public } from '../auth/public.decorator';
|
||||||
|
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||||
|
import { PortalService } from './portal.service';
|
||||||
|
|
||||||
|
/** Portail public (QR cabine) : seules routes @Public métier de l'API —
|
||||||
|
* protégées par throttling (surface exposée sans compte). */
|
||||||
|
@Controller('portal')
|
||||||
|
@UseGuards(ThrottlerGuard)
|
||||||
|
export class PortalController {
|
||||||
|
constructor(private readonly portal: PortalService) {}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Get('assets/:reference')
|
||||||
|
@Throttle({ default: { ttl: 60_000, limit: 60 } })
|
||||||
|
resolveAsset(@Param('reference') reference: string) {
|
||||||
|
return this.portal.resolveAsset(reference);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Post('requests')
|
||||||
|
@Throttle({ default: { ttl: 60_000, limit: 10 } }) // anti-spam signalement
|
||||||
|
createRequest(
|
||||||
|
@Body(new ZodValidationPipe(PortalRequestCreateSchema)) body: PortalRequestCreate,
|
||||||
|
) {
|
||||||
|
return this.portal.createRequest(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Get('requests/:reference/:token')
|
||||||
|
@Throttle({ default: { ttl: 60_000, limit: 60 } })
|
||||||
|
status(@Param('reference') reference: string, @Param('token') token: string) {
|
||||||
|
return this.portal.status(reference, token);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
apps/api/src/portal/portal.module.ts
Normal file
11
apps/api/src/portal/portal.module.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ThrottlerModule } from '@nestjs/throttler';
|
||||||
|
import { PortalController } from './portal.controller';
|
||||||
|
import { PortalService } from './portal.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [ThrottlerModule.forRoot([{ ttl: 60_000, limit: 60 }])],
|
||||||
|
controllers: [PortalController],
|
||||||
|
providers: [PortalService],
|
||||||
|
})
|
||||||
|
export class PortalModule {}
|
||||||
101
apps/api/src/portal/portal.service.ts
Normal file
101
apps/api/src/portal/portal.service.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import type {
|
||||||
|
PortalAsset,
|
||||||
|
PortalRequestCreate,
|
||||||
|
PortalRequestCreated,
|
||||||
|
PortalRequestStatus,
|
||||||
|
PortalStep,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
const requestInclude = { workOrder: true } satisfies Prisma.RequestInclude;
|
||||||
|
type Row = Prisma.RequestGetPayload<{ include: typeof requestInclude }>;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PortalService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
/** Le QR encode la référence (imprimée sur l'étiquette). */
|
||||||
|
async resolveAsset(reference: string): Promise<PortalAsset> {
|
||||||
|
const asset = await this.prisma.asset.findUnique({
|
||||||
|
where: { reference },
|
||||||
|
include: { location: { include: { parent: true } } },
|
||||||
|
});
|
||||||
|
if (!asset) throw new NotFoundException('Référence inconnue');
|
||||||
|
return {
|
||||||
|
reference: asset.reference,
|
||||||
|
siteName: asset.location.parent?.name ?? asset.location.name,
|
||||||
|
locationName: asset.location.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async createRequest(dto: PortalRequestCreate): Promise<PortalRequestCreated> {
|
||||||
|
const asset = await this.prisma.asset.findUnique({
|
||||||
|
where: { reference: dto.assetReference },
|
||||||
|
});
|
||||||
|
if (!asset) throw new NotFoundException('Référence inconnue');
|
||||||
|
for (let essai = 0; ; essai++) {
|
||||||
|
try {
|
||||||
|
const created = await this.prisma.request.create({
|
||||||
|
data: {
|
||||||
|
reference: await this.nextReference(),
|
||||||
|
description: dto.description,
|
||||||
|
isPersonTrapped: dto.isPersonTrapped ?? false,
|
||||||
|
assetId: asset.id,
|
||||||
|
requesterName: dto.requesterName || 'Portail (QR cabine)',
|
||||||
|
publicToken: randomBytes(24).toString('base64url'),
|
||||||
|
},
|
||||||
|
include: requestInclude,
|
||||||
|
});
|
||||||
|
return { ...this.toStatus(created), publicToken: created.publicToken! };
|
||||||
|
} catch (e) {
|
||||||
|
if (
|
||||||
|
e instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
|
e.code === 'P2002' &&
|
||||||
|
essai < 3
|
||||||
|
) {
|
||||||
|
continue; // collision de référence — on retente
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Suivi par jeton opaque — pas de jeton, pas de lecture (aucune fuite). */
|
||||||
|
async status(reference: string, token: string): Promise<PortalRequestStatus> {
|
||||||
|
const request = await this.prisma.request.findUnique({
|
||||||
|
where: { reference },
|
||||||
|
include: requestInclude,
|
||||||
|
});
|
||||||
|
if (!request || !request.publicToken || request.publicToken !== token) {
|
||||||
|
throw new NotFoundException('Signalement inconnu ou jeton invalide');
|
||||||
|
}
|
||||||
|
return this.toStatus(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async nextReference(): Promise<string> {
|
||||||
|
const [{ nextval }] = await this.prisma.$queryRaw<[{ nextval: bigint }]>`
|
||||||
|
SELECT nextval('request_ref_seq')`;
|
||||||
|
return `DEM-${new Date().getFullYear()}-${String(nextval).padStart(4, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toStatus(row: Row): PortalRequestStatus {
|
||||||
|
const step: PortalStep =
|
||||||
|
row.workOrder?.status === 'DONE'
|
||||||
|
? 'RESOLVED'
|
||||||
|
: row.workOrder && row.workOrder.startedAt
|
||||||
|
? 'IN_PROGRESS'
|
||||||
|
: 'RECEIVED';
|
||||||
|
return {
|
||||||
|
reference: row.reference,
|
||||||
|
description: row.description,
|
||||||
|
isPersonTrapped: row.isPersonTrapped,
|
||||||
|
status: row.status,
|
||||||
|
step,
|
||||||
|
rejectionReason: row.rejectionReason,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
69
apps/api/src/preventive/preventive.controller.ts
Normal file
69
apps/api/src/preventive/preventive.controller.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
PreventiveGenerateSchema,
|
||||||
|
TaskTemplateCreateSchema,
|
||||||
|
TaskTemplateUpdateSchema,
|
||||||
|
type PreventiveGenerate,
|
||||||
|
type TaskTemplateCreate,
|
||||||
|
type TaskTemplateUpdate,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import {
|
||||||
|
AuthenticatedUser,
|
||||||
|
CurrentUser,
|
||||||
|
} from '../auth/current-user.decorator';
|
||||||
|
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||||
|
import { RequirePermission } from '../permissions/require-permission.decorator';
|
||||||
|
import { PreventiveService } from './preventive.service';
|
||||||
|
|
||||||
|
@Controller('preventive')
|
||||||
|
export class PreventiveController {
|
||||||
|
constructor(private readonly preventive: PreventiveService) {}
|
||||||
|
|
||||||
|
@Get('templates')
|
||||||
|
@RequirePermission('WORK_ORDERS', 'view')
|
||||||
|
listTemplates() {
|
||||||
|
return this.preventive.listTemplates();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('templates')
|
||||||
|
@RequirePermission('SETTINGS', 'create')
|
||||||
|
createTemplate(
|
||||||
|
@Body(new ZodValidationPipe(TaskTemplateCreateSchema)) body: TaskTemplateCreate,
|
||||||
|
) {
|
||||||
|
return this.preventive.createTemplate(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('templates/:id')
|
||||||
|
@RequirePermission('SETTINGS', 'edit')
|
||||||
|
updateTemplate(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body(new ZodValidationPipe(TaskTemplateUpdateSchema)) body: TaskTemplateUpdate,
|
||||||
|
) {
|
||||||
|
return this.preventive.updateTemplate(id, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('generate')
|
||||||
|
@HttpCode(200) // idempotent : rien n'est « créé » de plus au second appel
|
||||||
|
@RequirePermission('WORK_ORDERS', 'create')
|
||||||
|
generate(
|
||||||
|
@Body(new ZodValidationPipe(PreventiveGenerateSchema)) body: PreventiveGenerate,
|
||||||
|
@CurrentUser() user: AuthenticatedUser,
|
||||||
|
) {
|
||||||
|
return this.preventive.generate(body.month, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('status')
|
||||||
|
@RequirePermission('WORK_ORDERS', 'view')
|
||||||
|
status() {
|
||||||
|
return this.preventive.status();
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/api/src/preventive/preventive.module.ts
Normal file
9
apps/api/src/preventive/preventive.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PreventiveController } from './preventive.controller';
|
||||||
|
import { PreventiveService } from './preventive.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [PreventiveController],
|
||||||
|
providers: [PreventiveService],
|
||||||
|
})
|
||||||
|
export class PreventiveModule {}
|
||||||
229
apps/api/src/preventive/preventive.service.ts
Normal file
229
apps/api/src/preventive/preventive.service.ts
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
import {
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import type {
|
||||||
|
PreventiveGenerationResult,
|
||||||
|
PreventiveStatus,
|
||||||
|
TaskTemplateCreate,
|
||||||
|
TaskTemplateDto,
|
||||||
|
TaskTemplatesResponse,
|
||||||
|
TaskTemplateUpdate,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import type { AuthenticatedUser } from '../auth/current-user.decorator';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
const templateInclude = {
|
||||||
|
componentType: true,
|
||||||
|
} satisfies Prisma.TaskTemplateInclude;
|
||||||
|
|
||||||
|
type TemplateRow = Prisma.TaskTemplateGetPayload<{ include: typeof templateInclude }>;
|
||||||
|
|
||||||
|
const MOIS_FR = [
|
||||||
|
'janvier', 'février', 'mars', 'avril', 'mai', 'juin',
|
||||||
|
'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** « AAAA-MM » → index absolu de mois. */
|
||||||
|
const indexMois = (period: string): number => {
|
||||||
|
const [annee, mois] = period.split('-').map(Number);
|
||||||
|
return annee! * 12 + (mois! - 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PreventiveService {
|
||||||
|
private readonly logger = new Logger(PreventiveService.name);
|
||||||
|
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
// ————— Gabarits —————
|
||||||
|
|
||||||
|
async listTemplates(): Promise<TaskTemplatesResponse> {
|
||||||
|
const rows = await this.prisma.taskTemplate.findMany({
|
||||||
|
include: templateInclude,
|
||||||
|
orderBy: [{ periodMonths: 'asc' }, { label: 'asc' }],
|
||||||
|
});
|
||||||
|
return { templates: rows.map((t) => this.toDto(t)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async createTemplate(dto: TaskTemplateCreate): Promise<TaskTemplateDto> {
|
||||||
|
try {
|
||||||
|
const created = await this.prisma.taskTemplate.create({
|
||||||
|
data: dto,
|
||||||
|
include: templateInclude,
|
||||||
|
});
|
||||||
|
return this.toDto(created);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||||
|
throw new ConflictException('Ce libellé de tâche existe déjà');
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateTemplate(id: string, dto: TaskTemplateUpdate): Promise<TaskTemplateDto> {
|
||||||
|
try {
|
||||||
|
const updated = await this.prisma.taskTemplate.update({
|
||||||
|
where: { id },
|
||||||
|
data: dto,
|
||||||
|
include: templateInclude,
|
||||||
|
});
|
||||||
|
return this.toDto(updated);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') {
|
||||||
|
throw new NotFoundException('Gabarit inconnu');
|
||||||
|
}
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||||
|
throw new ConflictException('Ce libellé de tâche existe déjà');
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ————— Génération mensuelle (IDEMPOTENTE) —————
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Une grille par appareil sous contrat et par mois — l'unicité
|
||||||
|
* [assetId, periodKey] est en BASE : regénérer ne double jamais rien,
|
||||||
|
* même en cas d'appels concurrents.
|
||||||
|
* Tâches dues : périodicité ancrée sur la mise en service (à défaut, tout
|
||||||
|
* mois) ; appareil sans historique préventif → « premier contrôle » (tout).
|
||||||
|
*/
|
||||||
|
async generate(
|
||||||
|
month: string | undefined,
|
||||||
|
user: AuthenticatedUser,
|
||||||
|
): Promise<PreventiveGenerationResult> {
|
||||||
|
const period = month ?? new Date().toISOString().slice(0, 7);
|
||||||
|
const [annee, mois] = period.split('-').map(Number);
|
||||||
|
const dernierJour = new Date(Date.UTC(annee!, mois!, 0, 23, 59, 59));
|
||||||
|
const libelleMois = `${MOIS_FR[mois! - 1]} ${annee}`;
|
||||||
|
|
||||||
|
const [assets, templates] = await Promise.all([
|
||||||
|
this.prisma.asset.findMany({
|
||||||
|
where: { underContract: true },
|
||||||
|
include: { _count: { select: { workOrders: { where: { type: 'PREVENTIVE' } } } } },
|
||||||
|
}),
|
||||||
|
this.prisma.taskTemplate.findMany({ where: { isActive: true } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let created = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
let firstControls = 0;
|
||||||
|
const references: string[] = [];
|
||||||
|
|
||||||
|
for (const asset of assets) {
|
||||||
|
const premierControle = asset._count.workOrders === 0;
|
||||||
|
const dues = premierControle
|
||||||
|
? templates
|
||||||
|
: templates.filter((t) => {
|
||||||
|
if (t.periodMonths === 1) return true;
|
||||||
|
const ancre = asset.commissionedAt
|
||||||
|
? asset.commissionedAt.getUTCFullYear() * 12 + asset.commissionedAt.getUTCMonth()
|
||||||
|
: indexMois(period);
|
||||||
|
return (indexMois(period) - ancre) % t.periodMonths === 0;
|
||||||
|
});
|
||||||
|
if (dues.length === 0) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let issue: 'créé' | 'sauté' | null = null;
|
||||||
|
for (let essai = 0; essai < 4 && issue === null; essai++) {
|
||||||
|
try {
|
||||||
|
const ot = await this.prisma.workOrder.create({
|
||||||
|
data: {
|
||||||
|
reference: await this.nextReference(),
|
||||||
|
title: premierControle
|
||||||
|
? `Premier contrôle — ${libelleMois}`
|
||||||
|
: `Grille du mois — ${libelleMois}`,
|
||||||
|
type: 'PREVENTIVE',
|
||||||
|
priority: 'LOW',
|
||||||
|
assetId: asset.id,
|
||||||
|
periodKey: period,
|
||||||
|
dueDate: dernierJour,
|
||||||
|
createdById: user.userId,
|
||||||
|
checklist: {
|
||||||
|
create: dues.map((t) => ({ label: t.label, templateId: t.id })),
|
||||||
|
},
|
||||||
|
events: {
|
||||||
|
create: {
|
||||||
|
kind: 'GENERATED',
|
||||||
|
message: `Généré (${premierControle ? 'premier contrôle' : 'grille mensuelle'} · ${dues.length} tâches)`,
|
||||||
|
byId: user.userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: { reference: true },
|
||||||
|
});
|
||||||
|
created++;
|
||||||
|
if (premierControle) firstControls++;
|
||||||
|
references.push(ot.reference);
|
||||||
|
issue = 'créé';
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||||
|
const cible = (e.meta?.target as string[] | string | undefined) ?? '';
|
||||||
|
if (String(cible).includes('periodKey')) {
|
||||||
|
issue = 'sauté'; // grille déjà en place : idempotence
|
||||||
|
}
|
||||||
|
// sinon : collision de référence (course avec une création d'OT)
|
||||||
|
// → on recalcule et on retente
|
||||||
|
} else if (
|
||||||
|
e instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
|
e.code === 'P2003'
|
||||||
|
) {
|
||||||
|
issue = 'sauté'; // appareil supprimé entre lecture et écriture
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (issue !== 'créé') skipped++;
|
||||||
|
}
|
||||||
|
this.logger.log(
|
||||||
|
`Préventif ${period} : ${created} générés, ${skipped} déjà en place, ${firstControls} premiers contrôles.`,
|
||||||
|
);
|
||||||
|
return { month: period, created, skipped, firstControls, references };
|
||||||
|
}
|
||||||
|
|
||||||
|
async status(): Promise<PreventiveStatus> {
|
||||||
|
const period = new Date().toISOString().slice(0, 7);
|
||||||
|
const [assetsUnderContract, grilles] = await Promise.all([
|
||||||
|
this.prisma.asset.count({ where: { underContract: true } }),
|
||||||
|
this.prisma.workOrder.findMany({
|
||||||
|
where: { periodKey: period },
|
||||||
|
select: { status: true, dueDate: true, title: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const maintenant = new Date();
|
||||||
|
return {
|
||||||
|
month: period,
|
||||||
|
assetsUnderContract,
|
||||||
|
generated: grilles.length,
|
||||||
|
done: grilles.filter((g) => g.status === 'DONE').length,
|
||||||
|
late: grilles.filter(
|
||||||
|
(g) => g.status !== 'DONE' && g.status !== 'CANCELLED' && g.dueDate! < maintenant,
|
||||||
|
).length,
|
||||||
|
firstControls: grilles.filter((g) => g.title.startsWith('Premier contrôle')).length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async nextReference(): Promise<string> {
|
||||||
|
const [{ nextval }] = await this.prisma.$queryRaw<[{ nextval: bigint }]>`
|
||||||
|
SELECT nextval('work_order_ref_seq')`;
|
||||||
|
return `OT-${new Date().getFullYear()}-${String(nextval).padStart(4, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDto(row: TemplateRow): TaskTemplateDto {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
label: row.label,
|
||||||
|
periodMonths: row.periodMonths,
|
||||||
|
isRegulatory: row.isRegulatory,
|
||||||
|
isActive: row.isActive,
|
||||||
|
componentTypeId: row.componentTypeId,
|
||||||
|
componentTypeName: row.componentType?.name ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
59
apps/api/src/purchase-orders/purchase-orders.controller.ts
Normal file
59
apps/api/src/purchase-orders/purchase-orders.controller.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Post,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
PurchaseOrderCreateSchema,
|
||||||
|
PurchaseOrderTransitionSchema,
|
||||||
|
type PurchaseOrderCreate,
|
||||||
|
type PurchaseOrderTransition,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import {
|
||||||
|
AuthenticatedUser,
|
||||||
|
CurrentUser,
|
||||||
|
} from '../auth/current-user.decorator';
|
||||||
|
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||||
|
import { RequirePermission } from '../permissions/require-permission.decorator';
|
||||||
|
import { PurchaseOrdersService } from './purchase-orders.service';
|
||||||
|
|
||||||
|
@Controller('purchase-orders')
|
||||||
|
export class PurchaseOrdersController {
|
||||||
|
constructor(private readonly purchaseOrders: PurchaseOrdersService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequirePermission('PURCHASE_ORDERS', 'view')
|
||||||
|
list() {
|
||||||
|
return this.purchaseOrders.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@RequirePermission('PURCHASE_ORDERS', 'view')
|
||||||
|
get(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.purchaseOrders.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequirePermission('PURCHASE_ORDERS', 'create')
|
||||||
|
create(
|
||||||
|
@Body(new ZodValidationPipe(PurchaseOrderCreateSchema)) body: PurchaseOrderCreate,
|
||||||
|
@CurrentUser() user: AuthenticatedUser,
|
||||||
|
) {
|
||||||
|
return this.purchaseOrders.create(body, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/transition')
|
||||||
|
@HttpCode(200) // le contrat : 200, l'état change
|
||||||
|
@RequirePermission('PURCHASE_ORDERS', 'edit')
|
||||||
|
transition(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body(new ZodValidationPipe(PurchaseOrderTransitionSchema)) body: PurchaseOrderTransition,
|
||||||
|
@CurrentUser() user: AuthenticatedUser,
|
||||||
|
) {
|
||||||
|
return this.purchaseOrders.transition(id, body, user);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/api/src/purchase-orders/purchase-orders.module.ts
Normal file
9
apps/api/src/purchase-orders/purchase-orders.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PurchaseOrdersController } from './purchase-orders.controller';
|
||||||
|
import { PurchaseOrdersService } from './purchase-orders.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [PurchaseOrdersController],
|
||||||
|
providers: [PurchaseOrdersService],
|
||||||
|
})
|
||||||
|
export class PurchaseOrdersModule {}
|
||||||
170
apps/api/src/purchase-orders/purchase-orders.service.ts
Normal file
170
apps/api/src/purchase-orders/purchase-orders.service.ts
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import type {
|
||||||
|
PurchaseOrderCreate,
|
||||||
|
PurchaseOrderDto,
|
||||||
|
PurchaseOrdersResponse,
|
||||||
|
PurchaseOrderStatus,
|
||||||
|
PurchaseOrderTransition,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import type { AuthenticatedUser } from '../auth/current-user.decorator';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
const poInclude = {
|
||||||
|
supplier: true,
|
||||||
|
lines: { include: { part: true } },
|
||||||
|
} satisfies Prisma.PurchaseOrderInclude;
|
||||||
|
|
||||||
|
type Row = Prisma.PurchaseOrderGetPayload<{ include: typeof poInclude }>;
|
||||||
|
|
||||||
|
/** Machine à états du BC : la réception est le seul chemin vers le stock. */
|
||||||
|
const TRANSITIONS: Record<PurchaseOrderStatus, PurchaseOrderStatus[]> = {
|
||||||
|
DRAFT: ['SENT', 'CANCELLED'],
|
||||||
|
SENT: ['RECEIVED', 'CANCELLED'],
|
||||||
|
RECEIVED: [],
|
||||||
|
CANCELLED: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PurchaseOrdersService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async list(): Promise<PurchaseOrdersResponse> {
|
||||||
|
const rows = await this.prisma.purchaseOrder.findMany({
|
||||||
|
include: poInclude,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
return { purchaseOrders: rows.map((r) => this.toDto(r)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(id: string): Promise<PurchaseOrderDto> {
|
||||||
|
const row = await this.prisma.purchaseOrder.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: poInclude,
|
||||||
|
});
|
||||||
|
if (!row) throw new NotFoundException('BC inconnu');
|
||||||
|
return this.toDto(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: PurchaseOrderCreate, user: AuthenticatedUser): Promise<PurchaseOrderDto> {
|
||||||
|
const supplier = await this.prisma.partner.findUnique({
|
||||||
|
where: { id: dto.supplierId },
|
||||||
|
});
|
||||||
|
if (!supplier || supplier.kind !== 'SUPPLIER' || !supplier.isActive) {
|
||||||
|
throw new BadRequestException('Fournisseur inconnu ou inactif');
|
||||||
|
}
|
||||||
|
const parts = await this.prisma.part.findMany({
|
||||||
|
where: { id: { in: dto.lines.map((l) => l.partId) } },
|
||||||
|
});
|
||||||
|
if (parts.length !== new Set(dto.lines.map((l) => l.partId)).size) {
|
||||||
|
throw new BadRequestException('Pièce inconnue dans les lignes');
|
||||||
|
}
|
||||||
|
for (let essai = 0; ; essai++) {
|
||||||
|
try {
|
||||||
|
const created = await this.prisma.purchaseOrder.create({
|
||||||
|
data: {
|
||||||
|
reference: await this.nextReference(),
|
||||||
|
supplierId: dto.supplierId,
|
||||||
|
createdById: user.userId,
|
||||||
|
lines: { create: dto.lines },
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
return this.get(created.id);
|
||||||
|
} catch (e) {
|
||||||
|
if (
|
||||||
|
e instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
|
e.code === 'P2002' &&
|
||||||
|
essai < 3
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async transition(
|
||||||
|
id: string,
|
||||||
|
dto: PurchaseOrderTransition,
|
||||||
|
user: AuthenticatedUser,
|
||||||
|
): Promise<PurchaseOrderDto> {
|
||||||
|
const row = await this.prisma.purchaseOrder.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: poInclude,
|
||||||
|
});
|
||||||
|
if (!row) throw new NotFoundException('BC inconnu');
|
||||||
|
if (!TRANSITIONS[row.status].includes(dto.to)) {
|
||||||
|
throw new ConflictException(`Transition interdite : ${row.status} → ${dto.to}`);
|
||||||
|
}
|
||||||
|
if (dto.to === 'RECEIVED') {
|
||||||
|
// LA règle : la réception crée les entrées de stock et FIGE les PU
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
for (const line of row.lines) {
|
||||||
|
await tx.stockMovement.create({
|
||||||
|
data: {
|
||||||
|
partId: line.partId,
|
||||||
|
kind: 'RECEIPT',
|
||||||
|
quantity: line.quantity,
|
||||||
|
unitPrice: line.unitPrice,
|
||||||
|
purchaseOrderId: row.id,
|
||||||
|
byId: user.userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await tx.part.update({
|
||||||
|
where: { id: line.partId },
|
||||||
|
data: { lastUnitPrice: line.unitPrice },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await tx.purchaseOrder.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status: 'RECEIVED', receivedAt: new Date() },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await this.prisma.purchaseOrder.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: dto.to,
|
||||||
|
sentAt: dto.to === 'SENT' ? new Date() : undefined,
|
||||||
|
cancelledAt: dto.to === 'CANCELLED' ? new Date() : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async nextReference(): Promise<string> {
|
||||||
|
const [{ nextval }] = await this.prisma.$queryRaw<[{ nextval: bigint }]>`
|
||||||
|
SELECT nextval('purchase_order_ref_seq')`;
|
||||||
|
return `BC-${new Date().getFullYear()}-${String(nextval).padStart(4, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDto(row: Row): PurchaseOrderDto {
|
||||||
|
const lines = row.lines.map((l) => ({
|
||||||
|
id: l.id,
|
||||||
|
partId: l.partId,
|
||||||
|
partReference: l.part.reference,
|
||||||
|
designation: l.part.designation,
|
||||||
|
quantity: l.quantity,
|
||||||
|
unitPrice: Number(l.unitPrice),
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
reference: row.reference,
|
||||||
|
status: row.status,
|
||||||
|
supplierId: row.supplierId,
|
||||||
|
supplierName: row.supplier.name,
|
||||||
|
lines,
|
||||||
|
total: lines.reduce((s, l) => s + l.quantity * l.unitPrice, 0),
|
||||||
|
sentAt: row.sentAt?.toISOString() ?? null,
|
||||||
|
receivedAt: row.receivedAt?.toISOString() ?? null,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
46
apps/api/src/reference-values/reference-values.controller.ts
Normal file
46
apps/api/src/reference-values/reference-values.controller.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ReferenceValueCreateSchema,
|
||||||
|
ReferenceValueUpdateSchema,
|
||||||
|
type ReferenceValueCreate,
|
||||||
|
type ReferenceValueUpdate,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||||
|
import { RequirePermission } from '../permissions/require-permission.decorator';
|
||||||
|
import { ReferenceValuesService } from './reference-values.service';
|
||||||
|
|
||||||
|
@Controller('reference-values')
|
||||||
|
export class ReferenceValuesController {
|
||||||
|
constructor(private readonly referenceValues: ReferenceValuesService) {}
|
||||||
|
|
||||||
|
/** Lu par le formulaire de bilan — authentification seule. */
|
||||||
|
@Get()
|
||||||
|
list() {
|
||||||
|
return this.referenceValues.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequirePermission('SETTINGS', 'create')
|
||||||
|
create(
|
||||||
|
@Body(new ZodValidationPipe(ReferenceValueCreateSchema)) body: ReferenceValueCreate,
|
||||||
|
) {
|
||||||
|
return this.referenceValues.create(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@RequirePermission('SETTINGS', 'edit')
|
||||||
|
update(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body(new ZodValidationPipe(ReferenceValueUpdateSchema)) body: ReferenceValueUpdate,
|
||||||
|
) {
|
||||||
|
return this.referenceValues.update(id, body);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/api/src/reference-values/reference-values.module.ts
Normal file
10
apps/api/src/reference-values/reference-values.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ReferenceValuesController } from './reference-values.controller';
|
||||||
|
import { ReferenceValuesService } from './reference-values.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [ReferenceValuesController],
|
||||||
|
providers: [ReferenceValuesService],
|
||||||
|
exports: [ReferenceValuesService],
|
||||||
|
})
|
||||||
|
export class ReferenceValuesModule {}
|
||||||
93
apps/api/src/reference-values/reference-values.service.ts
Normal file
93
apps/api/src/reference-values/reference-values.service.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import {
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import type {
|
||||||
|
ReferenceValueCreate,
|
||||||
|
ReferenceValueDto,
|
||||||
|
ReferenceValuesResponse,
|
||||||
|
ReferenceValueUpdate,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
const usageInclude = {
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
doorStates: true,
|
||||||
|
cabinPositions: true,
|
||||||
|
anomalies: true,
|
||||||
|
externalCauses: true,
|
||||||
|
actionsTaken: true,
|
||||||
|
componentsConcerned: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies Prisma.ReferenceValueInclude;
|
||||||
|
|
||||||
|
type Row = Prisma.ReferenceValueGetPayload<{ include: typeof usageInclude }>;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ReferenceValuesService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async list(): Promise<ReferenceValuesResponse> {
|
||||||
|
const rows = await this.prisma.referenceValue.findMany({
|
||||||
|
include: usageInclude,
|
||||||
|
orderBy: [{ field: 'asc' }, { label: 'asc' }],
|
||||||
|
});
|
||||||
|
return { referenceValues: rows.map((r) => this.toDto(r)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: ReferenceValueCreate): Promise<ReferenceValueDto> {
|
||||||
|
try {
|
||||||
|
const created = await this.prisma.referenceValue.create({
|
||||||
|
data: dto,
|
||||||
|
include: usageInclude,
|
||||||
|
});
|
||||||
|
return this.toDto(created);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||||
|
throw new ConflictException('Ce libellé existe déjà pour ce champ');
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renommage / (dés)activation — jamais de suppression (même règle que Category). */
|
||||||
|
async update(id: string, dto: ReferenceValueUpdate): Promise<ReferenceValueDto> {
|
||||||
|
try {
|
||||||
|
const updated = await this.prisma.referenceValue.update({
|
||||||
|
where: { id },
|
||||||
|
data: dto,
|
||||||
|
include: usageInclude,
|
||||||
|
});
|
||||||
|
return this.toDto(updated);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') {
|
||||||
|
throw new NotFoundException('Valeur inconnue');
|
||||||
|
}
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||||
|
throw new ConflictException('Ce libellé existe déjà pour ce champ');
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDto(row: Row): ReferenceValueDto {
|
||||||
|
const c = row._count;
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
field: row.field,
|
||||||
|
label: row.label,
|
||||||
|
isActive: row.isActive,
|
||||||
|
usageCount:
|
||||||
|
c.doorStates +
|
||||||
|
c.cabinPositions +
|
||||||
|
c.anomalies +
|
||||||
|
c.externalCauses +
|
||||||
|
c.actionsTaken +
|
||||||
|
c.componentsConcerned,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
65
apps/api/src/requests/requests.controller.ts
Normal file
65
apps/api/src/requests/requests.controller.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Post,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
RequestApproveSchema,
|
||||||
|
RequestCreateSchema,
|
||||||
|
RequestRejectSchema,
|
||||||
|
type RequestApprove,
|
||||||
|
type RequestCreate,
|
||||||
|
type RequestReject,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import {
|
||||||
|
AuthenticatedUser,
|
||||||
|
CurrentUser,
|
||||||
|
} from '../auth/current-user.decorator';
|
||||||
|
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||||
|
import { RequirePermission } from '../permissions/require-permission.decorator';
|
||||||
|
import { RequestsService } from './requests.service';
|
||||||
|
|
||||||
|
@Controller('requests')
|
||||||
|
export class RequestsController {
|
||||||
|
constructor(private readonly requests: RequestsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequirePermission('REQUESTS', 'view')
|
||||||
|
list(@CurrentUser() user: AuthenticatedUser) {
|
||||||
|
return this.requests.list(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequirePermission('REQUESTS', 'create')
|
||||||
|
create(
|
||||||
|
@Body(new ZodValidationPipe(RequestCreateSchema)) body: RequestCreate,
|
||||||
|
@CurrentUser() user: AuthenticatedUser,
|
||||||
|
) {
|
||||||
|
return this.requests.create(body, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/approve')
|
||||||
|
@RequirePermission('REQUESTS', 'edit')
|
||||||
|
approve(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body(new ZodValidationPipe(RequestApproveSchema)) body: RequestApprove,
|
||||||
|
@CurrentUser() user: AuthenticatedUser,
|
||||||
|
) {
|
||||||
|
return this.requests.approve(id, body, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/reject')
|
||||||
|
@HttpCode(200) // le contrat : 200, la demande est mise à jour
|
||||||
|
@RequirePermission('REQUESTS', 'edit')
|
||||||
|
reject(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body(new ZodValidationPipe(RequestRejectSchema)) body: RequestReject,
|
||||||
|
@CurrentUser() user: AuthenticatedUser,
|
||||||
|
) {
|
||||||
|
return this.requests.reject(id, body, user);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
apps/api/src/requests/requests.module.ts
Normal file
11
apps/api/src/requests/requests.module.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { WorkOrdersModule } from '../work-orders/work-orders.module';
|
||||||
|
import { RequestsController } from './requests.controller';
|
||||||
|
import { RequestsService } from './requests.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [WorkOrdersModule],
|
||||||
|
controllers: [RequestsController],
|
||||||
|
providers: [RequestsService],
|
||||||
|
})
|
||||||
|
export class RequestsModule {}
|
||||||
165
apps/api/src/requests/requests.service.ts
Normal file
165
apps/api/src/requests/requests.service.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import type {
|
||||||
|
RequestApprove,
|
||||||
|
RequestCreate,
|
||||||
|
RequestReject,
|
||||||
|
RequestsResponse,
|
||||||
|
RequestSummary,
|
||||||
|
WorkOrderDetail,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import type { AuthenticatedUser } from '../auth/current-user.decorator';
|
||||||
|
import { PermissionsService } from '../permissions/permissions.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { WorkOrdersService } from '../work-orders/work-orders.service';
|
||||||
|
|
||||||
|
const requestInclude = {
|
||||||
|
asset: { include: { location: { include: { parent: true } } } },
|
||||||
|
requestedBy: true,
|
||||||
|
workOrder: true,
|
||||||
|
} satisfies Prisma.RequestInclude;
|
||||||
|
|
||||||
|
type RequestRow = Prisma.RequestGetPayload<{ include: typeof requestInclude }>;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RequestsService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly permissions: PermissionsService,
|
||||||
|
private readonly workOrders: WorkOrdersService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private async scope(user: AuthenticatedUser): Promise<Prisma.RequestWhereInput> {
|
||||||
|
const viewOther = await this.permissions.can(user.roleId, 'REQUESTS', 'viewOther');
|
||||||
|
return viewOther ? {} : { requestedById: user.userId };
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(user: AuthenticatedUser): Promise<RequestsResponse> {
|
||||||
|
const rows = await this.prisma.request.findMany({
|
||||||
|
where: await this.scope(user),
|
||||||
|
include: requestInclude,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
rows.sort((a, b) => Number(b.isPersonTrapped) - Number(a.isPersonTrapped));
|
||||||
|
return { requests: rows.map((r) => this.toDto(r)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: RequestCreate, user: AuthenticatedUser): Promise<RequestSummary> {
|
||||||
|
const asset = await this.prisma.asset.findUnique({ where: { id: dto.assetId } });
|
||||||
|
if (!asset) throw new BadRequestException('Équipement inconnu');
|
||||||
|
for (let essai = 0; ; essai++) {
|
||||||
|
try {
|
||||||
|
const created = await this.prisma.request.create({
|
||||||
|
data: {
|
||||||
|
reference: await this.nextReference(),
|
||||||
|
description: dto.description,
|
||||||
|
isPersonTrapped: dto.isPersonTrapped ?? false,
|
||||||
|
assetId: dto.assetId,
|
||||||
|
requestedById: user.userId,
|
||||||
|
},
|
||||||
|
include: requestInclude,
|
||||||
|
});
|
||||||
|
return this.toDto(created);
|
||||||
|
} catch (e) {
|
||||||
|
if (
|
||||||
|
e instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
|
e.code === 'P2002' &&
|
||||||
|
essai < 3
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Approuver = créer l'OT lié (1-1). Une demande ne se traite qu'une fois. */
|
||||||
|
async approve(
|
||||||
|
id: string,
|
||||||
|
dto: RequestApprove,
|
||||||
|
user: AuthenticatedUser,
|
||||||
|
): Promise<WorkOrderDetail> {
|
||||||
|
const request = await this.prisma.request.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: requestInclude,
|
||||||
|
});
|
||||||
|
if (!request) throw new NotFoundException('Demande inconnue');
|
||||||
|
if (request.status !== 'RECEIVED') {
|
||||||
|
throw new ConflictException('Cette demande a déjà été traitée');
|
||||||
|
}
|
||||||
|
const created = await this.workOrders.createRaw({
|
||||||
|
title: dto.title ?? request.description.slice(0, 120),
|
||||||
|
description: `${request.description}\n\n(Demande ${request.reference} — ${this.requesterLabel(request)})`,
|
||||||
|
type: 'CORRECTIVE',
|
||||||
|
priority: dto.priority ?? (request.isPersonTrapped ? 'PERSON_TRAPPED' : 'MEDIUM'),
|
||||||
|
assetId: request.assetId,
|
||||||
|
dueDate: dto.dueDate,
|
||||||
|
assigneeIds: dto.assigneeIds,
|
||||||
|
createdById: user.userId,
|
||||||
|
eventKind: 'FROM_REQUEST',
|
||||||
|
eventMessage: `OT créé depuis la demande ${request.reference}`,
|
||||||
|
});
|
||||||
|
await this.prisma.request.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status: 'APPROVED', workOrderId: created.id },
|
||||||
|
});
|
||||||
|
return this.workOrders.get(created.id, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async reject(
|
||||||
|
id: string,
|
||||||
|
dto: RequestReject,
|
||||||
|
user: AuthenticatedUser,
|
||||||
|
): Promise<RequestSummary> {
|
||||||
|
void user;
|
||||||
|
const request = await this.prisma.request.findUnique({ where: { id } });
|
||||||
|
if (!request) throw new NotFoundException('Demande inconnue');
|
||||||
|
if (request.status !== 'RECEIVED') {
|
||||||
|
throw new ConflictException('Cette demande a déjà été traitée');
|
||||||
|
}
|
||||||
|
const updated = await this.prisma.request.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status: 'REJECTED', rejectionReason: dto.reason },
|
||||||
|
include: requestInclude,
|
||||||
|
});
|
||||||
|
return this.toDto(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async nextReference(): Promise<string> {
|
||||||
|
const [{ nextval }] = await this.prisma.$queryRaw<[{ nextval: bigint }]>`
|
||||||
|
SELECT nextval('request_ref_seq')`;
|
||||||
|
return `DEM-${new Date().getFullYear()}-${String(nextval).padStart(4, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private requesterLabel(row: RequestRow): string {
|
||||||
|
return row.requestedBy?.displayName ?? row.requesterName ?? 'Portail';
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDto(row: RequestRow): RequestSummary {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
reference: row.reference,
|
||||||
|
description: row.description,
|
||||||
|
isPersonTrapped: row.isPersonTrapped,
|
||||||
|
status: row.status,
|
||||||
|
rejectionReason: row.rejectionReason,
|
||||||
|
assetId: row.assetId,
|
||||||
|
assetReference: row.asset.reference,
|
||||||
|
siteName: row.asset.location.parent?.name ?? row.asset.location.name,
|
||||||
|
requesterLabel: this.requesterLabel(row),
|
||||||
|
workOrder: row.workOrder
|
||||||
|
? {
|
||||||
|
id: row.workOrder.id,
|
||||||
|
reference: row.workOrder.reference,
|
||||||
|
status: row.workOrder.status,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
15
apps/api/src/search/search.controller.ts
Normal file
15
apps/api/src/search/search.controller.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Controller, Get, Query } from '@nestjs/common';
|
||||||
|
import { AuthenticatedUser, CurrentUser } from '../auth/current-user.decorator';
|
||||||
|
import { SearchService } from './search.service';
|
||||||
|
|
||||||
|
/** Authentification seule (pas de @RequirePermission) : chaque famille de
|
||||||
|
* résultats est filtrée par la matrice DANS le service. */
|
||||||
|
@Controller('search')
|
||||||
|
export class SearchController {
|
||||||
|
constructor(private readonly search: SearchService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
global(@CurrentUser() user: AuthenticatedUser, @Query('q') q = '') {
|
||||||
|
return this.search.search(user, q);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/api/src/search/search.module.ts
Normal file
9
apps/api/src/search/search.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { SearchController } from './search.controller';
|
||||||
|
import { SearchService } from './search.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [SearchController],
|
||||||
|
providers: [SearchService],
|
||||||
|
})
|
||||||
|
export class SearchModule {}
|
||||||
105
apps/api/src/search/search.service.ts
Normal file
105
apps/api/src/search/search.service.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import type { Prisma } from '@prisma/client';
|
||||||
|
import { SEARCH_MAX_PER_KIND, SEARCH_MIN_CHARS, type SearchResponse } from '@siop/shared';
|
||||||
|
import type { AuthenticatedUser } from '../auth/current-user.decorator';
|
||||||
|
import { PermissionsService } from '../permissions/permissions.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
/** Recherche globale de la topbar (⌘K). Chaque famille n'est interrogée que
|
||||||
|
* si le rôle a la permission `view` du domaine ; les OT respectent en plus
|
||||||
|
* l'invariant « voir autre » (même règle que la liste des OT). */
|
||||||
|
@Injectable()
|
||||||
|
export class SearchService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly permissions: PermissionsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async search(user: AuthenticatedUser, q: string): Promise<SearchResponse> {
|
||||||
|
const terme = q.trim();
|
||||||
|
const vide: SearchResponse = { workOrders: [], assets: [], sites: [] };
|
||||||
|
if (terme.length < SEARCH_MIN_CHARS) return vide;
|
||||||
|
|
||||||
|
const [voitOT, voitParc, voitSites] = await Promise.all([
|
||||||
|
this.permissions.can(user.roleId, 'WORK_ORDERS', 'view'),
|
||||||
|
this.permissions.can(user.roleId, 'ASSETS', 'view'),
|
||||||
|
this.permissions.can(user.roleId, 'LOCATIONS', 'view'),
|
||||||
|
]);
|
||||||
|
const contient = (champ: string): Prisma.StringFilter => ({
|
||||||
|
contains: champ,
|
||||||
|
mode: 'insensitive',
|
||||||
|
});
|
||||||
|
|
||||||
|
const [workOrders, assets, sites] = await Promise.all([
|
||||||
|
voitOT
|
||||||
|
? this.prisma.workOrder.findMany({
|
||||||
|
where: {
|
||||||
|
AND: [
|
||||||
|
{ OR: [{ reference: contient(terme) }, { title: contient(terme) }] },
|
||||||
|
await this.scopeOT(user),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: { id: true, reference: true, title: true, status: true },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: SEARCH_MAX_PER_KIND,
|
||||||
|
})
|
||||||
|
: [],
|
||||||
|
voitParc
|
||||||
|
? this.prisma.asset.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ reference: contient(terme) },
|
||||||
|
{ brand: contient(terme) },
|
||||||
|
{ model: contient(terme) },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
reference: true,
|
||||||
|
brand: true,
|
||||||
|
model: true,
|
||||||
|
location: { select: { name: true, parent: { select: { name: true } } } },
|
||||||
|
},
|
||||||
|
orderBy: { reference: 'asc' },
|
||||||
|
take: SEARCH_MAX_PER_KIND,
|
||||||
|
})
|
||||||
|
: [],
|
||||||
|
voitSites
|
||||||
|
? this.prisma.location.findMany({
|
||||||
|
where: {
|
||||||
|
parentId: null,
|
||||||
|
OR: [{ name: contient(terme) }, { city: contient(terme) }],
|
||||||
|
},
|
||||||
|
select: { id: true, name: true, city: true },
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
take: SEARCH_MAX_PER_KIND,
|
||||||
|
})
|
||||||
|
: [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
workOrders,
|
||||||
|
assets: assets.map((a) => ({
|
||||||
|
id: a.id,
|
||||||
|
reference: a.reference,
|
||||||
|
brand: a.brand,
|
||||||
|
model: a.model,
|
||||||
|
siteName: a.location.parent?.name ?? a.location.name,
|
||||||
|
})),
|
||||||
|
sites,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Même invariant que WorkOrdersService.scope — sans le droit « voir
|
||||||
|
* autre », on ne trouve que SES OT. */
|
||||||
|
private async scopeOT(user: AuthenticatedUser): Promise<Prisma.WorkOrderWhereInput> {
|
||||||
|
const viewOther = await this.permissions.can(user.roleId, 'WORK_ORDERS', 'viewOther');
|
||||||
|
if (viewOther) return {};
|
||||||
|
return {
|
||||||
|
OR: [
|
||||||
|
{ assignees: { some: { id: user.userId } } },
|
||||||
|
{ createdById: user.userId },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
134
apps/api/src/work-orders/work-orders.controller.ts
Normal file
134
apps/api/src/work-orders/work-orders.controller.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
AssigneesUpdateSchema,
|
||||||
|
ChecklistPatchSchema,
|
||||||
|
CommentCreateSchema,
|
||||||
|
ConsumePartSchema,
|
||||||
|
LaborTimeCreateSchema,
|
||||||
|
ReportUpsertSchema,
|
||||||
|
TransitionRequestSchema,
|
||||||
|
WorkOrderCreateSchema,
|
||||||
|
type AssigneesUpdate,
|
||||||
|
type ChecklistPatch,
|
||||||
|
type CommentCreate,
|
||||||
|
type ConsumePart,
|
||||||
|
type LaborTimeCreate,
|
||||||
|
type ReportUpsert,
|
||||||
|
type TransitionRequest,
|
||||||
|
type WorkOrderCreate,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import {
|
||||||
|
AuthenticatedUser,
|
||||||
|
CurrentUser,
|
||||||
|
} from '../auth/current-user.decorator';
|
||||||
|
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||||
|
import { RequirePermission } from '../permissions/require-permission.decorator';
|
||||||
|
import { WorkOrdersService } from './work-orders.service';
|
||||||
|
|
||||||
|
@Controller('work-orders')
|
||||||
|
export class WorkOrdersController {
|
||||||
|
constructor(private readonly workOrders: WorkOrdersService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequirePermission('WORK_ORDERS', 'view')
|
||||||
|
list(@CurrentUser() user: AuthenticatedUser) {
|
||||||
|
return this.workOrders.list(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@RequirePermission('WORK_ORDERS', 'view')
|
||||||
|
get(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: AuthenticatedUser) {
|
||||||
|
return this.workOrders.get(id, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequirePermission('WORK_ORDERS', 'create')
|
||||||
|
create(
|
||||||
|
@Body(new ZodValidationPipe(WorkOrderCreateSchema)) body: WorkOrderCreate,
|
||||||
|
@CurrentUser() user: AuthenticatedUser,
|
||||||
|
) {
|
||||||
|
return this.workOrders.create(body, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/transition')
|
||||||
|
@HttpCode(200) // le contrat : 200, l'état change — rien n'est « créé »
|
||||||
|
@RequirePermission('WORK_ORDERS', 'edit')
|
||||||
|
transition(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body(new ZodValidationPipe(TransitionRequestSchema)) body: TransitionRequest,
|
||||||
|
@CurrentUser() user: AuthenticatedUser,
|
||||||
|
) {
|
||||||
|
return this.workOrders.transition(id, body, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/comments')
|
||||||
|
@RequirePermission('WORK_ORDERS', 'edit')
|
||||||
|
comment(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body(new ZodValidationPipe(CommentCreateSchema)) body: CommentCreate,
|
||||||
|
@CurrentUser() user: AuthenticatedUser,
|
||||||
|
) {
|
||||||
|
return this.workOrders.comment(id, body.message, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id/assignees')
|
||||||
|
@RequirePermission('WORK_ORDERS', 'edit')
|
||||||
|
setAssignees(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body(new ZodValidationPipe(AssigneesUpdateSchema)) body: AssigneesUpdate,
|
||||||
|
@CurrentUser() user: AuthenticatedUser,
|
||||||
|
) {
|
||||||
|
return this.workOrders.setAssignees(id, body, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id/report')
|
||||||
|
@RequirePermission('WORK_ORDERS', 'edit')
|
||||||
|
upsertReport(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body(new ZodValidationPipe(ReportUpsertSchema)) body: ReportUpsert,
|
||||||
|
@CurrentUser() user: AuthenticatedUser,
|
||||||
|
) {
|
||||||
|
return this.workOrders.upsertReport(id, body, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/consume-part')
|
||||||
|
@RequirePermission('WORK_ORDERS', 'edit')
|
||||||
|
consumePart(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body(new ZodValidationPipe(ConsumePartSchema)) body: ConsumePart,
|
||||||
|
@CurrentUser() user: AuthenticatedUser,
|
||||||
|
) {
|
||||||
|
return this.workOrders.consumePart(id, body, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/labor')
|
||||||
|
@RequirePermission('WORK_ORDERS', 'edit')
|
||||||
|
addLabor(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body(new ZodValidationPipe(LaborTimeCreateSchema)) body: LaborTimeCreate,
|
||||||
|
@CurrentUser() user: AuthenticatedUser,
|
||||||
|
) {
|
||||||
|
return this.workOrders.addLabor(id, body, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id/checklist/:itemId')
|
||||||
|
@RequirePermission('WORK_ORDERS', 'edit')
|
||||||
|
patchChecklist(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Param('itemId', ParseUUIDPipe) itemId: string,
|
||||||
|
@Body(new ZodValidationPipe(ChecklistPatchSchema)) body: ChecklistPatch,
|
||||||
|
@CurrentUser() user: AuthenticatedUser,
|
||||||
|
) {
|
||||||
|
return this.workOrders.patchChecklist(id, itemId, body, user);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
apps/api/src/work-orders/work-orders.module.ts
Normal file
12
apps/api/src/work-orders/work-orders.module.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PartsModule } from '../parts/parts.module';
|
||||||
|
import { WorkOrdersController } from './work-orders.controller';
|
||||||
|
import { WorkOrdersService } from './work-orders.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PartsModule],
|
||||||
|
controllers: [WorkOrdersController],
|
||||||
|
providers: [WorkOrdersService],
|
||||||
|
exports: [WorkOrdersService],
|
||||||
|
})
|
||||||
|
export class WorkOrdersModule {}
|
||||||
572
apps/api/src/work-orders/work-orders.service.ts
Normal file
572
apps/api/src/work-orders/work-orders.service.ts
Normal file
@@ -0,0 +1,572 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import {
|
||||||
|
BILAN_FIELD_LABELS,
|
||||||
|
REQUIRED_BILAN_FIELDS,
|
||||||
|
WORK_ORDER_STATUS_LABELS,
|
||||||
|
WORK_ORDER_TRANSITIONS,
|
||||||
|
type AssigneesUpdate,
|
||||||
|
type BilanField,
|
||||||
|
type ChecklistItemDto,
|
||||||
|
type ChecklistPatch,
|
||||||
|
type ReportUpsert,
|
||||||
|
type TransitionRequest,
|
||||||
|
type WorkOrderCreate,
|
||||||
|
type WorkOrderDetail,
|
||||||
|
type WorkOrderStatus,
|
||||||
|
type WorkOrdersResponse,
|
||||||
|
type WorkOrderSummary,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import type { AuthenticatedUser } from '../auth/current-user.decorator';
|
||||||
|
import { PartsService } from '../parts/parts.service';
|
||||||
|
import { PermissionsService } from '../permissions/permissions.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
const detailInclude = {
|
||||||
|
asset: { include: { location: { include: { parent: true } } } },
|
||||||
|
assignees: true,
|
||||||
|
createdBy: true,
|
||||||
|
request: { include: { requestedBy: true } },
|
||||||
|
events: { include: { by: true }, orderBy: { createdAt: 'desc' as const } },
|
||||||
|
checklist: { include: { doneBy: true }, orderBy: { label: 'asc' as const } },
|
||||||
|
// R3 — coûts figés
|
||||||
|
stockMovements: {
|
||||||
|
where: { kind: 'CONSUMPTION' as const },
|
||||||
|
include: { part: true },
|
||||||
|
orderBy: { createdAt: 'asc' as const },
|
||||||
|
},
|
||||||
|
laborTimes: { include: { user: true }, orderBy: { createdAt: 'asc' as const } },
|
||||||
|
report: {
|
||||||
|
include: {
|
||||||
|
doorState: true,
|
||||||
|
cabinPosition: true,
|
||||||
|
anomaly: true,
|
||||||
|
externalCause: true,
|
||||||
|
actionTaken: true,
|
||||||
|
componentConcerned: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies Prisma.WorkOrderInclude;
|
||||||
|
|
||||||
|
type DetailRow = Prisma.WorkOrderGetPayload<{ include: typeof detailInclude }>;
|
||||||
|
|
||||||
|
const initialsOf = (name: string) =>
|
||||||
|
name.split(/\s+/).filter(Boolean).slice(0, 2).map((w) => w[0]!.toUpperCase()).join('');
|
||||||
|
|
||||||
|
const toPersonne = (u: { id: string; displayName: string }) => ({
|
||||||
|
id: u.id,
|
||||||
|
displayName: u.displayName,
|
||||||
|
initials: initialsOf(u.displayName),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Champ du bilan → colonne du rapport (validation des référentiels). */
|
||||||
|
const REPORT_FIELDS: Record<string, BilanField> = {
|
||||||
|
doorStateId: 'DOOR_STATE',
|
||||||
|
cabinPositionId: 'CABIN_POSITION',
|
||||||
|
anomalyId: 'ANOMALY',
|
||||||
|
externalCauseId: 'EXTERNAL_CAUSE',
|
||||||
|
actionTakenId: 'ACTION_TAKEN',
|
||||||
|
componentConcernedId: 'COMPONENT_CONCERNED',
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WorkOrdersService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly permissions: PermissionsService,
|
||||||
|
private readonly parts: PartsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Invariant « voir autre » : sans le droit, on ne voit que SES OT. */
|
||||||
|
private async scope(user: AuthenticatedUser): Promise<Prisma.WorkOrderWhereInput> {
|
||||||
|
const viewOther = await this.permissions.can(user.roleId, 'WORK_ORDERS', 'viewOther');
|
||||||
|
if (viewOther) return {};
|
||||||
|
return {
|
||||||
|
OR: [
|
||||||
|
{ assignees: { some: { id: user.userId } } },
|
||||||
|
{ createdById: user.userId },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(user: AuthenticatedUser): Promise<WorkOrdersResponse> {
|
||||||
|
const rows = await this.prisma.workOrder.findMany({
|
||||||
|
where: await this.scope(user),
|
||||||
|
include: {
|
||||||
|
asset: { include: { location: { include: { parent: true } } } },
|
||||||
|
assignees: true,
|
||||||
|
},
|
||||||
|
// Dernière activité d'abord : un OT qui vient d'être clôturé remonte
|
||||||
|
// (« Interventions récentes » du tableau de bord = tête de liste).
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
});
|
||||||
|
// « personne bloquée » ACTIVE saute en tête, toujours (tri stable)
|
||||||
|
const urgenceActive = (r: (typeof rows)[number]) =>
|
||||||
|
r.priority === 'PERSON_TRAPPED' && r.status !== 'DONE' && r.status !== 'CANCELLED';
|
||||||
|
rows.sort((a, b) => Number(urgenceActive(b)) - Number(urgenceActive(a)));
|
||||||
|
return { workOrders: rows.map((r) => this.toSummary(r)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(id: string, user: AuthenticatedUser): Promise<WorkOrderDetail> {
|
||||||
|
const row = await this.prisma.workOrder.findFirst({
|
||||||
|
where: { AND: [{ id }, await this.scope(user)] },
|
||||||
|
include: detailInclude,
|
||||||
|
});
|
||||||
|
if (!row) throw new NotFoundException('OT inconnu');
|
||||||
|
return this.toDetail(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: WorkOrderCreate, user: AuthenticatedUser): Promise<WorkOrderDetail> {
|
||||||
|
const asset = await this.prisma.asset.findUnique({ where: { id: dto.assetId } });
|
||||||
|
if (!asset) throw new BadRequestException('Équipement inconnu');
|
||||||
|
const created = await this.createRaw({
|
||||||
|
...dto,
|
||||||
|
createdById: user.userId,
|
||||||
|
eventKind: 'CREATED',
|
||||||
|
eventMessage: 'OT créé',
|
||||||
|
});
|
||||||
|
return this.get(created.id, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Création partagée (OT manuel, approbation de demande, préventif R2.2). */
|
||||||
|
async createRaw(input: {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
type: WorkOrderCreate['type'];
|
||||||
|
priority?: WorkOrderCreate['priority'];
|
||||||
|
assetId: string;
|
||||||
|
dueDate?: string;
|
||||||
|
assigneeIds?: string[];
|
||||||
|
createdById?: string;
|
||||||
|
eventKind: string;
|
||||||
|
eventMessage: string;
|
||||||
|
}): Promise<{ id: string }> {
|
||||||
|
for (let essai = 0; ; essai++) {
|
||||||
|
try {
|
||||||
|
return await this.prisma.workOrder.create({
|
||||||
|
data: {
|
||||||
|
reference: await this.nextReference(),
|
||||||
|
title: input.title,
|
||||||
|
description: input.description,
|
||||||
|
type: input.type,
|
||||||
|
priority: input.priority ?? 'NONE',
|
||||||
|
assetId: input.assetId,
|
||||||
|
dueDate: input.dueDate ? new Date(input.dueDate) : undefined,
|
||||||
|
createdById: input.createdById,
|
||||||
|
assignees: input.assigneeIds?.length
|
||||||
|
? { connect: input.assigneeIds.map((id) => ({ id })) }
|
||||||
|
: undefined,
|
||||||
|
events: {
|
||||||
|
create: {
|
||||||
|
kind: input.eventKind,
|
||||||
|
message: input.eventMessage,
|
||||||
|
byId: input.createdById,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// collision de référence (concurrence) : on retente
|
||||||
|
if (
|
||||||
|
e instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
|
e.code === 'P2002' &&
|
||||||
|
essai < 3
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async nextReference(): Promise<string> {
|
||||||
|
// Séquence Postgres : insensible à la concurrence (jamais de collision)
|
||||||
|
const [{ nextval }] = await this.prisma.$queryRaw<[{ nextval: bigint }]>`
|
||||||
|
SELECT nextval('work_order_ref_seq')`;
|
||||||
|
return `OT-${new Date().getFullYear()}-${String(nextval).padStart(4, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Machine à états stricte + garde de clôture. */
|
||||||
|
async transition(
|
||||||
|
id: string,
|
||||||
|
dto: TransitionRequest,
|
||||||
|
user: AuthenticatedUser,
|
||||||
|
): Promise<WorkOrderDetail> {
|
||||||
|
const detail = await this.get(id, user);
|
||||||
|
await this.assertVersion(id, dto.baseUpdatedAt);
|
||||||
|
if (!WORK_ORDER_TRANSITIONS[detail.status].includes(dto.to)) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Transition interdite : ${WORK_ORDER_STATUS_LABELS[detail.status]} → ${WORK_ORDER_STATUS_LABELS[dto.to]}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (dto.to === 'DONE' && detail.closureBlockers.length > 0) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Clôture bloquée : ${detail.closureBlockers.join(' ; ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const horodatage: Prisma.WorkOrderUpdateInput =
|
||||||
|
dto.to === 'IN_PROGRESS' && !detail.startedAt
|
||||||
|
? { startedAt: new Date() }
|
||||||
|
: dto.to === 'DONE'
|
||||||
|
? { completedAt: new Date() }
|
||||||
|
: dto.to === 'CANCELLED'
|
||||||
|
? { cancelledAt: new Date() }
|
||||||
|
: {};
|
||||||
|
await this.prisma.workOrder.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: dto.to,
|
||||||
|
...horodatage,
|
||||||
|
events: {
|
||||||
|
create: {
|
||||||
|
kind: 'STATUS_CHANGED',
|
||||||
|
message:
|
||||||
|
`${WORK_ORDER_STATUS_LABELS[detail.status]} → ${WORK_ORDER_STATUS_LABELS[dto.to]}` +
|
||||||
|
(dto.comment ? ` — ${dto.comment}` : ''),
|
||||||
|
byId: user.userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.get(id, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async comment(
|
||||||
|
id: string,
|
||||||
|
message: string,
|
||||||
|
user: AuthenticatedUser,
|
||||||
|
): Promise<WorkOrderDetail> {
|
||||||
|
await this.get(id, user); // périmètre
|
||||||
|
await this.prisma.workOrderEvent.create({
|
||||||
|
data: { workOrderId: id, kind: 'COMMENT', message, byId: user.userId },
|
||||||
|
});
|
||||||
|
await this.toucher(id);
|
||||||
|
return this.get(id, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setAssignees(
|
||||||
|
id: string,
|
||||||
|
dto: AssigneesUpdate,
|
||||||
|
user: AuthenticatedUser,
|
||||||
|
): Promise<WorkOrderDetail> {
|
||||||
|
await this.get(id, user);
|
||||||
|
const users = await this.prisma.user.findMany({
|
||||||
|
where: { id: { in: dto.assigneeIds } },
|
||||||
|
});
|
||||||
|
if (users.length !== new Set(dto.assigneeIds).size) {
|
||||||
|
throw new BadRequestException('Personne inconnue dans la liste');
|
||||||
|
}
|
||||||
|
await this.prisma.workOrder.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
assignees: { set: dto.assigneeIds.map((assigneeId) => ({ id: assigneeId })) },
|
||||||
|
events: {
|
||||||
|
create: {
|
||||||
|
kind: 'ASSIGNED',
|
||||||
|
message: users.length
|
||||||
|
? `Assigné(s) : ${users.map((u) => u.displayName).join(', ')}`
|
||||||
|
: 'Assignation retirée',
|
||||||
|
byId: user.userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.get(id, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsertReport(
|
||||||
|
id: string,
|
||||||
|
dto: ReportUpsert,
|
||||||
|
user: AuthenticatedUser,
|
||||||
|
): Promise<WorkOrderDetail> {
|
||||||
|
await this.get(id, user);
|
||||||
|
await this.assertVersion(id, dto.baseUpdatedAt);
|
||||||
|
// Chaque valeur fournie doit appartenir au référentiel de SON champ (et être active)
|
||||||
|
for (const [colonne, field] of Object.entries(REPORT_FIELDS)) {
|
||||||
|
const valeur = dto[colonne as keyof ReportUpsert];
|
||||||
|
if (typeof valeur === 'string') {
|
||||||
|
const ref = await this.prisma.referenceValue.findUnique({ where: { id: valeur } });
|
||||||
|
if (!ref || ref.field !== field || !ref.isActive) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Valeur hors référentiel pour « ${BILAN_FIELD_LABELS[field]} »`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const donnees = {
|
||||||
|
note: dto.note,
|
||||||
|
doorStateId: dto.doorStateId,
|
||||||
|
cabinPositionId: dto.cabinPositionId,
|
||||||
|
anomalyId: dto.anomalyId,
|
||||||
|
externalCauseId: dto.externalCauseId,
|
||||||
|
actionTakenId: dto.actionTakenId,
|
||||||
|
componentConcernedId: dto.componentConcernedId,
|
||||||
|
};
|
||||||
|
await this.prisma.interventionReport.upsert({
|
||||||
|
where: { workOrderId: id },
|
||||||
|
update: donnees,
|
||||||
|
create: { workOrderId: id, ...donnees },
|
||||||
|
});
|
||||||
|
await this.toucher(id);
|
||||||
|
return this.get(id, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async patchChecklist(
|
||||||
|
id: string,
|
||||||
|
itemId: string,
|
||||||
|
dto: ChecklistPatch,
|
||||||
|
user: AuthenticatedUser,
|
||||||
|
): Promise<ChecklistItemDto> {
|
||||||
|
await this.get(id, user);
|
||||||
|
await this.assertVersion(id, dto.baseUpdatedAt);
|
||||||
|
const { count } = await this.prisma.checklistItem.updateMany({
|
||||||
|
where: { id: itemId, workOrderId: id },
|
||||||
|
data: {
|
||||||
|
state: dto.state,
|
||||||
|
doneById: dto.state === 'PENDING' ? null : user.userId,
|
||||||
|
doneAt: dto.state === 'PENDING' ? null : new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (count === 0) throw new NotFoundException('Tâche inconnue');
|
||||||
|
await this.toucher(id);
|
||||||
|
const item = await this.prisma.checklistItem.findUniqueOrThrow({
|
||||||
|
where: { id: itemId },
|
||||||
|
include: { doneBy: true },
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
label: item.label,
|
||||||
|
state: item.state,
|
||||||
|
doneBy: item.doneBy ? toPersonne(item.doneBy) : null,
|
||||||
|
doneAt: item.doneAt?.toISOString() ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ————— R3 : coûts figés —————
|
||||||
|
|
||||||
|
/** Consommer une pièce : stock décrémenté, PRIX FIGÉ au moment T. */
|
||||||
|
async consumePart(
|
||||||
|
id: string,
|
||||||
|
dto: { partId: string; quantity: number },
|
||||||
|
user: AuthenticatedUser,
|
||||||
|
): Promise<WorkOrderDetail> {
|
||||||
|
const detail = await this.get(id, user); // périmètre + existence
|
||||||
|
if (detail.status === 'DONE' || detail.status === 'CANCELLED') {
|
||||||
|
throw new ConflictException('OT terminé : les coûts sont figés');
|
||||||
|
}
|
||||||
|
const { designation, unitPrice } = await this.parts.consume(
|
||||||
|
dto.partId,
|
||||||
|
dto.quantity,
|
||||||
|
id,
|
||||||
|
user,
|
||||||
|
);
|
||||||
|
await this.prisma.workOrderEvent.create({
|
||||||
|
data: {
|
||||||
|
workOrderId: id,
|
||||||
|
kind: 'PART_CONSUMED',
|
||||||
|
message: `${designation} × ${dto.quantity} (${unitPrice} MAD/u, prix figé)`,
|
||||||
|
byId: user.userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await this.toucher(id);
|
||||||
|
return this.get(id, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Saisir de la main-d'œuvre : TAUX FIGÉ à la saisie. */
|
||||||
|
async addLabor(
|
||||||
|
id: string,
|
||||||
|
dto: { minutes: number; userId?: string; note?: string },
|
||||||
|
user: AuthenticatedUser,
|
||||||
|
): Promise<WorkOrderDetail> {
|
||||||
|
const detail = await this.get(id, user);
|
||||||
|
if (detail.status === 'DONE' || detail.status === 'CANCELLED') {
|
||||||
|
throw new ConflictException('OT terminé : les coûts sont figés');
|
||||||
|
}
|
||||||
|
const cibleId = dto.userId ?? user.userId;
|
||||||
|
const cible = await this.prisma.user.findUnique({ where: { id: cibleId } });
|
||||||
|
if (!cible) throw new BadRequestException('Personne inconnue');
|
||||||
|
if (cible.hourlyRate === null) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Aucun taux horaire défini pour ${cible.displayName} — à renseigner dans Personnes`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await this.prisma.laborTime.create({
|
||||||
|
data: {
|
||||||
|
workOrderId: id,
|
||||||
|
userId: cibleId,
|
||||||
|
minutes: dto.minutes,
|
||||||
|
hourlyRate: cible.hourlyRate, // figé
|
||||||
|
note: dto.note,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await this.toucher(id);
|
||||||
|
return this.get(id, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ————— mapping —————
|
||||||
|
|
||||||
|
private toSummary(
|
||||||
|
row: Prisma.WorkOrderGetPayload<{
|
||||||
|
include: {
|
||||||
|
asset: { include: { location: { include: { parent: true } } } };
|
||||||
|
assignees: true;
|
||||||
|
};
|
||||||
|
}>,
|
||||||
|
): WorkOrderSummary {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
reference: row.reference,
|
||||||
|
title: row.title,
|
||||||
|
type: row.type,
|
||||||
|
status: row.status,
|
||||||
|
priority: row.priority,
|
||||||
|
assetId: row.assetId,
|
||||||
|
assetReference: row.asset.reference,
|
||||||
|
siteName: row.asset.location.parent?.name ?? row.asset.location.name,
|
||||||
|
dueDate: row.dueDate?.toISOString() ?? null,
|
||||||
|
assignees: row.assignees.map(toPersonne),
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDetail(row: DetailRow): WorkOrderDetail {
|
||||||
|
const report = row.report;
|
||||||
|
const blockers: string[] = [];
|
||||||
|
if (row.checklist.some((c) => c.state === 'PENDING')) {
|
||||||
|
blockers.push('la checklist est incomplète : traitez chaque tâche (Fait ou N-A)');
|
||||||
|
}
|
||||||
|
const manquants = REQUIRED_BILAN_FIELDS.filter((field) => {
|
||||||
|
const parChamp: Record<BilanField, string | null | undefined> = {
|
||||||
|
DOOR_STATE: report?.doorStateId,
|
||||||
|
CABIN_POSITION: report?.cabinPositionId,
|
||||||
|
ANOMALY: report?.anomalyId,
|
||||||
|
EXTERNAL_CAUSE: report?.externalCauseId,
|
||||||
|
ACTION_TAKEN: report?.actionTakenId,
|
||||||
|
COMPONENT_CONCERNED: report?.componentConcernedId,
|
||||||
|
};
|
||||||
|
return !parChamp[field];
|
||||||
|
});
|
||||||
|
if (manquants.length) {
|
||||||
|
blockers.push(
|
||||||
|
`bilan incomplet : ${manquants.map((f) => `« ${BILAN_FIELD_LABELS[f]} »`).join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const versValeur = (v: { id: string; label: string } | null) =>
|
||||||
|
v ? { id: v.id, label: v.label } : null;
|
||||||
|
// Coûts figés : chaque ligne porte le prix/taux du moment T
|
||||||
|
const partsCosts = row.stockMovements.map((m) => ({
|
||||||
|
id: m.id,
|
||||||
|
designation: m.part.designation,
|
||||||
|
quantity: -m.quantity,
|
||||||
|
unitPrice: Number(m.unitPrice ?? 0),
|
||||||
|
total: -m.quantity * Number(m.unitPrice ?? 0),
|
||||||
|
}));
|
||||||
|
const laborCosts = row.laborTimes.map((l) => ({
|
||||||
|
id: l.id,
|
||||||
|
displayName: l.user.displayName,
|
||||||
|
minutes: l.minutes,
|
||||||
|
hourlyRate: Number(l.hourlyRate),
|
||||||
|
total: Math.round((l.minutes / 60) * Number(l.hourlyRate) * 100) / 100,
|
||||||
|
note: l.note,
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
...this.toSummary(row),
|
||||||
|
description: row.description,
|
||||||
|
locationName: row.asset.location.name,
|
||||||
|
startedAt: row.startedAt?.toISOString() ?? null,
|
||||||
|
completedAt: row.completedAt?.toISOString() ?? null,
|
||||||
|
cancelledAt: row.cancelledAt?.toISOString() ?? null,
|
||||||
|
createdBy: row.createdBy ? toPersonne(row.createdBy) : null,
|
||||||
|
request: row.request
|
||||||
|
? {
|
||||||
|
id: row.request.id,
|
||||||
|
reference: row.request.reference,
|
||||||
|
requesterLabel:
|
||||||
|
row.request.requestedBy?.displayName ??
|
||||||
|
row.request.requesterName ??
|
||||||
|
'Portail',
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
events: row.events.map((e) => ({
|
||||||
|
id: e.id,
|
||||||
|
kind: e.kind,
|
||||||
|
message: e.message,
|
||||||
|
by: e.by ? toPersonne(e.by) : null,
|
||||||
|
createdAt: e.createdAt.toISOString(),
|
||||||
|
})),
|
||||||
|
checklist: row.checklist.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
label: c.label,
|
||||||
|
state: c.state,
|
||||||
|
doneBy: c.doneBy ? toPersonne(c.doneBy) : null,
|
||||||
|
doneAt: c.doneAt?.toISOString() ?? null,
|
||||||
|
})),
|
||||||
|
report: report
|
||||||
|
? {
|
||||||
|
note: report.note,
|
||||||
|
doorState: versValeur(report.doorState),
|
||||||
|
cabinPosition: versValeur(report.cabinPosition),
|
||||||
|
anomaly: versValeur(report.anomaly),
|
||||||
|
externalCause: versValeur(report.externalCause),
|
||||||
|
actionTaken: versValeur(report.actionTaken),
|
||||||
|
componentConcerned: versValeur(report.componentConcerned),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
costs: {
|
||||||
|
parts: partsCosts,
|
||||||
|
labor: laborCosts,
|
||||||
|
total:
|
||||||
|
Math.round(
|
||||||
|
(partsCosts.reduce((s, p) => s + p.total, 0) +
|
||||||
|
laborCosts.reduce((s, l) => s + l.total, 0)) *
|
||||||
|
100,
|
||||||
|
) / 100,
|
||||||
|
},
|
||||||
|
allowedTransitions: WORK_ORDER_TRANSITIONS[row.status as WorkOrderStatus],
|
||||||
|
closureBlockers: blockers,
|
||||||
|
updatedAt: row.updatedAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verrou optimiste (D2 mobile) : si l'appelant fournit la version lue et
|
||||||
|
* que l'OT a changé depuis, 409 avec le contexte (qui, quand) — rien
|
||||||
|
* n'est écrasé en silence. Sans version fournie (web), comportement
|
||||||
|
* inchangé. */
|
||||||
|
private async assertVersion(id: string, baseUpdatedAt?: string): Promise<void> {
|
||||||
|
if (!baseUpdatedAt) return;
|
||||||
|
const wo = await this.prisma.workOrder.findUniqueOrThrow({
|
||||||
|
where: { id },
|
||||||
|
select: {
|
||||||
|
updatedAt: true,
|
||||||
|
events: {
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 1,
|
||||||
|
include: { by: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (wo.updatedAt.getTime() > new Date(baseUpdatedAt).getTime()) {
|
||||||
|
const dernier = wo.events[0];
|
||||||
|
const qui = dernier?.by?.displayName ?? 'quelqu’un';
|
||||||
|
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() } });
|
||||||
|
}
|
||||||
|
}
|
||||||
141
apps/api/test/documents-analytics.e2e-spec.ts
Normal file
141
apps/api/test/documents-analytics.e2e-spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
374
apps/api/test/exploitation.e2e-spec.ts
Normal file
374
apps/api/test/exploitation.e2e-spec.ts
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
/**
|
||||||
|
* E2E R2 — exploitation : machine à états stricte, garde de clôture
|
||||||
|
* (bilan + checklist), demande → OT (1-1), scoping « voir autre ».
|
||||||
|
* Rejoue la recette officielle : demande gardien → approbation → OT →
|
||||||
|
* intervention → bilan codé → clôture → suivi demandeur.
|
||||||
|
*/
|
||||||
|
process.env.DEMO_MODE = 'true';
|
||||||
|
|
||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { seed } from '../prisma/seed';
|
||||||
|
import { AppModule } from '../src/app.module';
|
||||||
|
|
||||||
|
describe('Exploitation (e2e)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let salma: string; // Dispatcher — voir autre, approuve
|
||||||
|
let ahmed: string; // Technicien — SES OT seulement
|
||||||
|
let karim: string; // Demandeur — SES demandes seulement
|
||||||
|
let ahmedId: string;
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
const http = () => request(app.getHttpServer());
|
||||||
|
const suffix = Date.now().toString(36);
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await seed(prisma);
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
imports: [AppModule.forRoot()],
|
||||||
|
}).compile();
|
||||||
|
app = moduleRef.createNestApplication();
|
||||||
|
await app.init();
|
||||||
|
|
||||||
|
const { body } = await http().get('/auth/demo-accounts');
|
||||||
|
const login = async (roleName: string) => {
|
||||||
|
const compte = body.accounts.find((a: { roleName: string }) => a.roleName === roleName);
|
||||||
|
const res = await http().post('/auth/demo-login').send({ userId: compte.id });
|
||||||
|
return res.body.accessToken as string;
|
||||||
|
};
|
||||||
|
salma = await login('Dispatcher');
|
||||||
|
ahmed = await login('Technicien');
|
||||||
|
karim = await login('Demandeur');
|
||||||
|
ahmedId = (
|
||||||
|
await prisma.user.findUniqueOrThrow({ where: { email: 'technicien@demo.siop.ma' } })
|
||||||
|
).id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.request.deleteMany({ where: { description: { contains: suffix } } });
|
||||||
|
await prisma.workOrder.deleteMany({ where: { title: { contains: suffix } } });
|
||||||
|
await app?.close();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
|
||||||
|
|
||||||
|
it('recette : demande → approbation → OT assigné → bilan → clôture → suivi', async () => {
|
||||||
|
// 1. Karim (gardien) signale
|
||||||
|
const { body: assets } = await http().get('/assets').set(auth(salma));
|
||||||
|
const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A1');
|
||||||
|
const demande = await http()
|
||||||
|
.post('/requests')
|
||||||
|
.set(auth(karim))
|
||||||
|
.send({ assetId: a1.id, description: `Porte qui grince (${suffix})` })
|
||||||
|
.expect(201);
|
||||||
|
expect(demande.body.status).toBe('RECEIVED');
|
||||||
|
|
||||||
|
// 2. Salma approuve → OT créé et lié (1-1)
|
||||||
|
const ot = await http()
|
||||||
|
.post(`/requests/${demande.body.id}/approve`)
|
||||||
|
.set(auth(salma))
|
||||||
|
.send({ title: `Réparer porte qui grince (${suffix})`, priority: 'MEDIUM', assigneeIds: [ahmedId] })
|
||||||
|
.expect(201);
|
||||||
|
expect(ot.body.status).toBe('OPEN');
|
||||||
|
expect(ot.body.request.reference).toBe(demande.body.reference);
|
||||||
|
// double approbation → refus
|
||||||
|
await http()
|
||||||
|
.post(`/requests/${demande.body.id}/approve`)
|
||||||
|
.set(auth(salma))
|
||||||
|
.send({})
|
||||||
|
.expect(409);
|
||||||
|
|
||||||
|
// 3. Ahmed démarre, commente
|
||||||
|
const id = ot.body.id;
|
||||||
|
await http()
|
||||||
|
.post(`/work-orders/${id}/transition`)
|
||||||
|
.set(auth(ahmed))
|
||||||
|
.send({ to: 'IN_PROGRESS' })
|
||||||
|
.expect(200);
|
||||||
|
await http()
|
||||||
|
.post(`/work-orders/${id}/comments`)
|
||||||
|
.set(auth(ahmed))
|
||||||
|
.send({ message: 'Charnière usée, graissage effectué.' })
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
// 4. Clôture SANS bilan → bloquée (garde)
|
||||||
|
const refus = await http()
|
||||||
|
.post(`/work-orders/${id}/transition`)
|
||||||
|
.set(auth(ahmed))
|
||||||
|
.send({ to: 'DONE' })
|
||||||
|
.expect(409);
|
||||||
|
expect(refus.body.message).toContain('bilan incomplet');
|
||||||
|
|
||||||
|
// 5. Bilan codé (3 champs requis) puis clôture
|
||||||
|
const { body: refs } = await http().get('/reference-values').set(auth(ahmed));
|
||||||
|
const valeur = (field: string, label: string) =>
|
||||||
|
refs.referenceValues.find(
|
||||||
|
(v: { field: string; label: string }) => v.field === field && v.label === label,
|
||||||
|
).id;
|
||||||
|
await http()
|
||||||
|
.put(`/work-orders/${id}/report`)
|
||||||
|
.set(auth(ahmed))
|
||||||
|
.send({
|
||||||
|
doorStateId: valeur('DOOR_STATE', 'Fonctionnement normal'),
|
||||||
|
actionTakenId: valeur('ACTION_TAKEN', 'Nettoyage / graissage'),
|
||||||
|
componentConcernedId: valeur('COMPONENT_CONCERNED', 'Portes'),
|
||||||
|
})
|
||||||
|
.expect(200);
|
||||||
|
const clos = await http()
|
||||||
|
.post(`/work-orders/${id}/transition`)
|
||||||
|
.set(auth(ahmed))
|
||||||
|
.send({ to: 'DONE', comment: 'RAS après graissage' })
|
||||||
|
.expect(200);
|
||||||
|
expect(clos.body.status).toBe('DONE');
|
||||||
|
expect(clos.body.completedAt).toBeTruthy();
|
||||||
|
|
||||||
|
// 6. Karim suit SA demande : l'OT lié est terminé
|
||||||
|
const { body: mesDemandes } = await http().get('/requests').set(auth(karim)).expect(200);
|
||||||
|
const laMienne = mesDemandes.requests.find(
|
||||||
|
(r: { id: string }) => r.id === demande.body.id,
|
||||||
|
);
|
||||||
|
expect(laMienne.workOrder.status).toBe('DONE');
|
||||||
|
|
||||||
|
// 6 bis. Régression recette R2 : l'OT fraîchement clôturé remonte EN TÊTE
|
||||||
|
// de la liste de Salma (après les urgences actives) — il doit apparaître
|
||||||
|
// dans les « Interventions récentes » de tout le monde, pas seulement
|
||||||
|
// chez l'assigné.
|
||||||
|
const { body: listeSalma } = await http()
|
||||||
|
.get('/work-orders')
|
||||||
|
.set(auth(salma))
|
||||||
|
.expect(200);
|
||||||
|
const position = listeSalma.workOrders.findIndex((w: { id: string }) => w.id === id);
|
||||||
|
expect(position).toBeGreaterThanOrEqual(0);
|
||||||
|
// Invariant DÉTERMINISTE (les specs parallèles créent des OT plus frais,
|
||||||
|
// les positions absolues sont donc instables) : notre OT fraîchement
|
||||||
|
// clôturé précède les OT du seed modifiés avant lui — avec l'ancien tri
|
||||||
|
// « par statut », tout En cours passait devant tous les Terminés.
|
||||||
|
const annee = new Date().getFullYear();
|
||||||
|
const posGrilleSeed = listeSalma.workOrders.findIndex(
|
||||||
|
(w: { reference: string }) => w.reference === `OT-${annee}-0338`, // En cours, seed
|
||||||
|
);
|
||||||
|
const posAncienDone = listeSalma.workOrders.findIndex(
|
||||||
|
(w: { reference: string }) => w.reference === `OT-${annee}-0332`, // Terminé, seed
|
||||||
|
);
|
||||||
|
expect(position).toBeLessThan(posGrilleSeed);
|
||||||
|
expect(position).toBeLessThan(posAncienDone);
|
||||||
|
|
||||||
|
// 7. Terminal : plus aucune transition
|
||||||
|
await http()
|
||||||
|
.post(`/work-orders/${id}/transition`)
|
||||||
|
.set(auth(ahmed))
|
||||||
|
.send({ to: 'IN_PROGRESS' })
|
||||||
|
.expect(409);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('machine à états : OPEN → DONE direct interdit ; OPEN → ON_HOLD interdit', async () => {
|
||||||
|
const { body } = await http().get('/work-orders').set(auth(salma));
|
||||||
|
const ouvert = body.workOrders.find(
|
||||||
|
(w: { status: string; priority: string }) =>
|
||||||
|
w.status === 'OPEN' && w.priority === 'PERSON_TRAPPED',
|
||||||
|
);
|
||||||
|
await http()
|
||||||
|
.post(`/work-orders/${ouvert.id}/transition`)
|
||||||
|
.set(auth(salma))
|
||||||
|
.send({ to: 'DONE' })
|
||||||
|
.expect(409);
|
||||||
|
await http()
|
||||||
|
.post(`/work-orders/${ouvert.id}/transition`)
|
||||||
|
.set(auth(salma))
|
||||||
|
.send({ to: 'ON_HOLD' })
|
||||||
|
.expect(409);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('garde de clôture : la checklist incomplète bloque la grille du mois', async () => {
|
||||||
|
const { body } = await http().get('/work-orders').set(auth(ahmed));
|
||||||
|
const grille = body.workOrders.find((w: { type: string }) => w.type === 'PREVENTIVE');
|
||||||
|
const detail = await http().get(`/work-orders/${grille.id}`).set(auth(ahmed)).expect(200);
|
||||||
|
expect(detail.body.closureBlockers.join(' ')).toContain('checklist');
|
||||||
|
|
||||||
|
// Régler toutes les tâches (Fait/N-A) → le blocage checklist disparaît
|
||||||
|
for (const [i, item] of detail.body.checklist.entries()) {
|
||||||
|
await http()
|
||||||
|
.patch(`/work-orders/${grille.id}/checklist/${item.id}`)
|
||||||
|
.set(auth(ahmed))
|
||||||
|
.send({ state: i % 2 ? 'NA' : 'DONE' })
|
||||||
|
.expect(200);
|
||||||
|
}
|
||||||
|
const apres = await http().get(`/work-orders/${grille.id}`).set(auth(ahmed)).expect(200);
|
||||||
|
expect(apres.body.closureBlockers.join(' ')).not.toContain('checklist');
|
||||||
|
expect(apres.body.checklist.every((c: { doneBy: unknown }) => c.doneBy)).toBe(true);
|
||||||
|
// remise en état (seed idempotent)
|
||||||
|
for (const item of apres.body.checklist) {
|
||||||
|
await http()
|
||||||
|
.patch(`/work-orders/${grille.id}/checklist/${item.id}`)
|
||||||
|
.set(auth(ahmed))
|
||||||
|
.send({ state: 'PENDING' })
|
||||||
|
.expect(200);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('« voir autre » : Ahmed ne voit que SES OT ; Karim que SES demandes', async () => {
|
||||||
|
const { body: tous } = await http().get('/work-orders').set(auth(salma));
|
||||||
|
const { body: siens } = await http().get('/work-orders').set(auth(ahmed));
|
||||||
|
expect(tous.workOrders.length).toBeGreaterThan(siens.workOrders.length);
|
||||||
|
expect(
|
||||||
|
siens.workOrders.every((w: { assignees: { id: string }[] }) =>
|
||||||
|
w.assignees.some((a) => a.id === ahmedId),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
// un OT non assigné à Ahmed lui est invisible (404, pas 403 — pas de fuite)
|
||||||
|
const autre = tous.workOrders.find(
|
||||||
|
(w: { assignees: { id: string }[] }) => !w.assignees.some((a) => a.id === ahmedId),
|
||||||
|
);
|
||||||
|
await http().get(`/work-orders/${autre.id}`).set(auth(ahmed)).expect(404);
|
||||||
|
|
||||||
|
const { body: demandes } = await http().get('/requests').set(auth(karim));
|
||||||
|
expect(demandes.requests.length).toBeGreaterThan(0);
|
||||||
|
// Karim (create seulement) ne peut pas approuver
|
||||||
|
await http()
|
||||||
|
.post(`/requests/${demandes.requests[0].id}/approve`)
|
||||||
|
.set(auth(karim))
|
||||||
|
.send({})
|
||||||
|
.expect(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejet : motif obligatoire ; bilan : valeur hors champ refusée', async () => {
|
||||||
|
const { body: assets } = await http().get('/assets').set(auth(salma));
|
||||||
|
const c1 = assets.assets.find((a: { reference: string }) => a.reference === 'C1');
|
||||||
|
const demande = await http()
|
||||||
|
.post('/requests')
|
||||||
|
.set(auth(karim))
|
||||||
|
.send({ assetId: c1.id, description: `À rejeter (${suffix})` })
|
||||||
|
.expect(201);
|
||||||
|
await http()
|
||||||
|
.post(`/requests/${demande.body.id}/reject`)
|
||||||
|
.set(auth(salma))
|
||||||
|
.send({ reason: '' })
|
||||||
|
.expect(400);
|
||||||
|
const rejetee = await http()
|
||||||
|
.post(`/requests/${demande.body.id}/reject`)
|
||||||
|
.set(auth(salma))
|
||||||
|
.send({ reason: 'Doublon de la demande précédente.' })
|
||||||
|
.expect(200);
|
||||||
|
expect(rejetee.body.rejectionReason).toContain('Doublon');
|
||||||
|
|
||||||
|
// bilan : une valeur ACTION_TAKEN dans le champ DOOR_STATE → 400
|
||||||
|
const { body: refs } = await http().get('/reference-values').set(auth(salma));
|
||||||
|
const action = refs.referenceValues.find((v: { field: string }) => v.field === 'ACTION_TAKEN');
|
||||||
|
const { body: wos } = await http().get('/work-orders').set(auth(salma));
|
||||||
|
const enCours = wos.workOrders.find((w: { status: string }) => w.status === 'IN_PROGRESS');
|
||||||
|
await http()
|
||||||
|
.put(`/work-orders/${enCours.id}/report`)
|
||||||
|
.set(auth(salma))
|
||||||
|
.send({ doorStateId: action.id })
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('« personne bloquée » saute en tête de liste', async () => {
|
||||||
|
const { body } = await http().get('/work-orders').set(auth(salma));
|
||||||
|
expect(body.workOrders[0].priority).toBe('PERSON_TRAPPED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('OT manuel : cycle complet En attente ↔ En cours, puis annulation motivée', async () => {
|
||||||
|
const { body: assets } = await http().get('/assets').set(auth(salma));
|
||||||
|
const e2 = assets.assets.find((a: { reference: string }) => a.reference === 'E2');
|
||||||
|
const ot = await http()
|
||||||
|
.post('/work-orders')
|
||||||
|
.set(auth(salma))
|
||||||
|
.send({
|
||||||
|
title: `Travaux cabine (${suffix})`,
|
||||||
|
type: 'WORKS',
|
||||||
|
priority: 'LOW',
|
||||||
|
assetId: e2.id,
|
||||||
|
dueDate: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
|
||||||
|
assigneeIds: [ahmedId],
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
const id = ot.body.id;
|
||||||
|
await http().post(`/work-orders/${id}/transition`).set(auth(salma)).send({ to: 'IN_PROGRESS' }).expect(200);
|
||||||
|
const attente = await http()
|
||||||
|
.post(`/work-orders/${id}/transition`)
|
||||||
|
.set(auth(salma))
|
||||||
|
.send({ to: 'ON_HOLD', comment: 'Attente de pièce' })
|
||||||
|
.expect(200);
|
||||||
|
expect(attente.body.allowedTransitions).toEqual(['IN_PROGRESS', 'CANCELLED']);
|
||||||
|
await http().post(`/work-orders/${id}/transition`).set(auth(salma)).send({ to: 'IN_PROGRESS' }).expect(200);
|
||||||
|
const annule = await http()
|
||||||
|
.post(`/work-orders/${id}/transition`)
|
||||||
|
.set(auth(salma))
|
||||||
|
.send({ to: 'CANCELLED', comment: 'Travaux reportés au trimestre prochain' })
|
||||||
|
.expect(200);
|
||||||
|
expect(annule.body.cancelledAt).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refus propres : équipement inconnu, personne inconnue, tâche inconnue, OT invisible', async () => {
|
||||||
|
const GHOST = '00000000-0000-4000-8000-000000000000';
|
||||||
|
await http()
|
||||||
|
.post('/work-orders')
|
||||||
|
.set(auth(salma))
|
||||||
|
.send({ title: 'X', type: 'CORRECTIVE', assetId: GHOST })
|
||||||
|
.expect(400);
|
||||||
|
await http()
|
||||||
|
.post('/requests')
|
||||||
|
.set(auth(karim))
|
||||||
|
.send({ assetId: GHOST, description: 'X' })
|
||||||
|
.expect(400);
|
||||||
|
const { body: wos } = await http().get('/work-orders').set(auth(salma));
|
||||||
|
const un = wos.workOrders[0];
|
||||||
|
await http()
|
||||||
|
.put(`/work-orders/${un.id}/assignees`)
|
||||||
|
.set(auth(salma))
|
||||||
|
.send({ assigneeIds: [GHOST] })
|
||||||
|
.expect(400);
|
||||||
|
await http()
|
||||||
|
.patch(`/work-orders/${un.id}/checklist/${GHOST}`)
|
||||||
|
.set(auth(salma))
|
||||||
|
.send({ state: 'DONE' })
|
||||||
|
.expect(404);
|
||||||
|
await http().get(`/work-orders/${GHOST}`).set(auth(salma)).expect(404);
|
||||||
|
await http()
|
||||||
|
.post(`/requests/${GHOST}/reject`)
|
||||||
|
.set(auth(salma))
|
||||||
|
.send({ reason: 'motif' })
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('référentiels du bilan : ajout (admin), doublon 409, renommage, 404', async () => {
|
||||||
|
const { body } = await http().get('/auth/demo-accounts');
|
||||||
|
const adminCompte = body.accounts.find(
|
||||||
|
(a: { roleName: string }) => a.roleName === 'Administrateur',
|
||||||
|
);
|
||||||
|
const admin = (
|
||||||
|
await http().post('/auth/demo-login').send({ userId: adminCompte.id })
|
||||||
|
).body.accessToken as string;
|
||||||
|
|
||||||
|
const creee = await http()
|
||||||
|
.post('/reference-values')
|
||||||
|
.set(auth(admin))
|
||||||
|
.send({ field: 'ANOMALY', label: `Anomalie test ${suffix}` })
|
||||||
|
.expect(201);
|
||||||
|
await http()
|
||||||
|
.post('/reference-values')
|
||||||
|
.set(auth(admin))
|
||||||
|
.send({ field: 'ANOMALY', label: `Anomalie test ${suffix}` })
|
||||||
|
.expect(409);
|
||||||
|
await http()
|
||||||
|
.patch(`/reference-values/${creee.body.id}`)
|
||||||
|
.set(auth(admin))
|
||||||
|
.send({ isActive: false })
|
||||||
|
.expect(200);
|
||||||
|
await http()
|
||||||
|
.patch(`/reference-values/00000000-0000-4000-8000-000000000000`)
|
||||||
|
.set(auth(admin))
|
||||||
|
.send({ label: 'X' })
|
||||||
|
.expect(404);
|
||||||
|
// Salma (Dispatcher, sans SETTINGS.create) → 403
|
||||||
|
await http()
|
||||||
|
.post('/reference-values')
|
||||||
|
.set(auth(salma))
|
||||||
|
.send({ field: 'ANOMALY', label: 'Interdit' })
|
||||||
|
.expect(403);
|
||||||
|
await prisma.referenceValue.delete({ where: { id: creee.body.id } });
|
||||||
|
});
|
||||||
|
});
|
||||||
351
apps/api/test/gestion.e2e-spec.ts
Normal file
351
apps/api/test/gestion.e2e-spec.ts
Normal 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 d’un 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
208
apps/api/test/preventif.e2e-spec.ts
Normal file
208
apps/api/test/preventif.e2e-spec.ts
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
/**
|
||||||
|
* E2E R2.2 — préventif : génération IDEMPOTENTE (l'unicité est en base),
|
||||||
|
* premier contrôle (tout), périodicités ancrées sur la mise en service ;
|
||||||
|
* compteurs strictement croissants.
|
||||||
|
* Les générations de test utilisent des mois lointains (2031) puis sont purgées.
|
||||||
|
*/
|
||||||
|
process.env.DEMO_MODE = 'true';
|
||||||
|
|
||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { seed } from '../prisma/seed';
|
||||||
|
import { AppModule } from '../src/app.module';
|
||||||
|
|
||||||
|
describe('Préventif & compteurs (e2e)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let admin: string;
|
||||||
|
let ahmed: string;
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
const http = () => request(app.getHttpServer());
|
||||||
|
const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await seed(prisma);
|
||||||
|
await prisma.workOrder.deleteMany({ where: { periodKey: { startsWith: '2031-' } } });
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
imports: [AppModule.forRoot()],
|
||||||
|
}).compile();
|
||||||
|
app = moduleRef.createNestApplication();
|
||||||
|
await app.init();
|
||||||
|
const { body } = await http().get('/auth/demo-accounts');
|
||||||
|
const login = async (roleName: string) => {
|
||||||
|
const compte = body.accounts.find((a: { roleName: string }) => a.roleName === roleName);
|
||||||
|
return (await http().post('/auth/demo-login').send({ userId: compte.id })).body
|
||||||
|
.accessToken as string;
|
||||||
|
};
|
||||||
|
admin = await login('Administrateur');
|
||||||
|
ahmed = await login('Technicien');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.workOrder.deleteMany({ where: { periodKey: { startsWith: '2031-' } } });
|
||||||
|
await prisma.taskTemplate.deleteMany({ where: { label: { contains: 'TEST-' } } });
|
||||||
|
await prisma.meterReading.deleteMany({ where: { value: { gte: 90000000 } } });
|
||||||
|
await app?.close();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Les assertions portent sur les 8 appareils du SEED : d'autres specs Jest
|
||||||
|
// créent/détruisent des appareils en parallèle sur la même base.
|
||||||
|
const SEED_REFS = ['A1', 'A2', 'B1', 'B2', 'C1', 'D1', 'E2', 'M1'];
|
||||||
|
const grillesSeed = (period: string) =>
|
||||||
|
prisma.workOrder.count({
|
||||||
|
where: { periodKey: period, asset: { reference: { in: SEED_REFS } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
it('génère une grille par appareil sous contrat — et regénérer ne double RIEN', async () => {
|
||||||
|
const premiere = await http()
|
||||||
|
.post('/preventive/generate')
|
||||||
|
.set(auth(admin))
|
||||||
|
.send({ month: '2031-01' })
|
||||||
|
.expect(200);
|
||||||
|
expect(premiere.body.created).toBeGreaterThanOrEqual(SEED_REFS.length);
|
||||||
|
expect(premiere.body.references.length).toBe(premiere.body.created);
|
||||||
|
expect(await grillesSeed('2031-01')).toBe(SEED_REFS.length);
|
||||||
|
|
||||||
|
const seconde = await http()
|
||||||
|
.post('/preventive/generate')
|
||||||
|
.set(auth(admin))
|
||||||
|
.send({ month: '2031-01' })
|
||||||
|
.expect(200);
|
||||||
|
expect(seconde.body.skipped).toBeGreaterThanOrEqual(SEED_REFS.length);
|
||||||
|
expect(await grillesSeed('2031-01')).toBe(SEED_REFS.length); // toujours 8, pas 16
|
||||||
|
});
|
||||||
|
|
||||||
|
it('premier contrôle (toutes les tâches) pour un appareil sans historique ; grille normale sinon', async () => {
|
||||||
|
const templatesActifs = await prisma.taskTemplate.count({ where: { isActive: true } });
|
||||||
|
const mensuelles = await prisma.taskTemplate.count({
|
||||||
|
where: { isActive: true, periodMonths: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Un appareil VIERGE créé par ce test (aucune génération n'a pu le toucher)
|
||||||
|
const categorie = await prisma.category.findFirstOrThrow({ where: { kind: 'EQUIPMENT' } });
|
||||||
|
const site = await prisma.location.findFirstOrThrow({ where: { parentId: null } });
|
||||||
|
const vierge = await prisma.asset.create({
|
||||||
|
data: {
|
||||||
|
reference: `PVT-${Date.now().toString(36)}`,
|
||||||
|
brand: 'Test',
|
||||||
|
categoryId: categorie.id,
|
||||||
|
locationId: site.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await http()
|
||||||
|
.post('/preventive/generate')
|
||||||
|
.set(auth(admin))
|
||||||
|
.send({ month: '2031-09' })
|
||||||
|
.expect(200);
|
||||||
|
const grille = await prisma.workOrder.findUniqueOrThrow({
|
||||||
|
where: { assetId_periodKey: { assetId: vierge.id, periodKey: '2031-09' } },
|
||||||
|
include: { _count: { select: { checklist: true } } },
|
||||||
|
});
|
||||||
|
expect(grille.title).toContain('Premier contrôle');
|
||||||
|
expect(grille._count.checklist).toBe(templatesActifs);
|
||||||
|
} finally {
|
||||||
|
await prisma.workOrder.deleteMany({ where: { assetId: vierge.id } });
|
||||||
|
await prisma.asset.delete({ where: { id: vierge.id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// A1 a toujours un historique préventif (seed) → grille normale.
|
||||||
|
// Mise en service mars 2020 : janvier 2031 (écart 130 mois) n'est le mois
|
||||||
|
// ni du 3, ni du 6, ni du 12 → seules les tâches MENSUELLES sont dues.
|
||||||
|
const a1 = await prisma.asset.findUniqueOrThrow({ where: { reference: 'A1' } });
|
||||||
|
const grilleA1 = await prisma.workOrder.findUniqueOrThrow({
|
||||||
|
where: { assetId_periodKey: { assetId: a1.id, periodKey: '2031-01' } },
|
||||||
|
include: { _count: { select: { checklist: true } } },
|
||||||
|
});
|
||||||
|
expect(grilleA1.title).toContain('Grille du mois');
|
||||||
|
expect(grilleA1._count.checklist).toBe(mensuelles);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mois anniversaire de mise en service : les périodicités longues tombent (mars 2031 pour A1)', async () => {
|
||||||
|
await http()
|
||||||
|
.post('/preventive/generate')
|
||||||
|
.set(auth(admin))
|
||||||
|
.send({ month: '2031-03' })
|
||||||
|
.expect(200);
|
||||||
|
// écart mars 2020 → mars 2031 = 132 mois : divisible par 3, 6 et 12
|
||||||
|
const templatesActifs = await prisma.taskTemplate.count({ where: { isActive: true } });
|
||||||
|
const a1 = await prisma.asset.findUniqueOrThrow({ where: { reference: 'A1' } });
|
||||||
|
const grille = await prisma.workOrder.findUniqueOrThrow({
|
||||||
|
where: { assetId_periodKey: { assetId: a1.id, periodKey: '2031-03' } },
|
||||||
|
include: { checklist: true },
|
||||||
|
});
|
||||||
|
expect(grille.checklist.length).toBe(templatesActifs);
|
||||||
|
expect(grille.checklist.some((c) => c.label === 'Essai du parachute')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gabarits : ajout, doublon 409, désactivation (exclu des générations suivantes), 404', async () => {
|
||||||
|
const cree = await http()
|
||||||
|
.post('/preventive/templates')
|
||||||
|
.set(auth(admin))
|
||||||
|
.send({ label: 'TEST-Contrôle éclairage gaine', periodMonths: 1 })
|
||||||
|
.expect(201);
|
||||||
|
await http()
|
||||||
|
.post('/preventive/templates')
|
||||||
|
.set(auth(admin))
|
||||||
|
.send({ label: 'TEST-Contrôle éclairage gaine', periodMonths: 1 })
|
||||||
|
.expect(409);
|
||||||
|
await http()
|
||||||
|
.patch(`/preventive/templates/${cree.body.id}`)
|
||||||
|
.set(auth(admin))
|
||||||
|
.send({ isActive: false })
|
||||||
|
.expect(200);
|
||||||
|
await http()
|
||||||
|
.patch('/preventive/templates/00000000-0000-4000-8000-000000000000')
|
||||||
|
.set(auth(admin))
|
||||||
|
.send({ label: 'X' })
|
||||||
|
.expect(404);
|
||||||
|
// désactivé → absent de la génération d'un nouveau mois
|
||||||
|
await http()
|
||||||
|
.post('/preventive/generate')
|
||||||
|
.set(auth(admin))
|
||||||
|
.send({ month: '2031-05' })
|
||||||
|
.expect(200);
|
||||||
|
const labels = await prisma.checklistItem.findMany({
|
||||||
|
where: { workOrder: { periodKey: '2031-05' } },
|
||||||
|
select: { label: true },
|
||||||
|
});
|
||||||
|
expect(labels.some((l) => l.label.startsWith('TEST-'))).toBe(false);
|
||||||
|
// le statut du mois courant répond (structure)
|
||||||
|
const status = await http().get('/preventive/status').set(auth(ahmed)).expect(200);
|
||||||
|
expect(status.body).toHaveProperty('generated');
|
||||||
|
expect(status.body).toHaveProperty('late');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('compteurs : relevé croissant accepté, régression refusée, appareil inconnu 404', async () => {
|
||||||
|
const { body: assets } = await http().get('/assets').set(auth(ahmed));
|
||||||
|
const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A1');
|
||||||
|
|
||||||
|
const liste = await http().get(`/assets/${a1.id}/meters`).set(auth(ahmed)).expect(200);
|
||||||
|
expect(liste.body.meters).toHaveLength(2); // les 2 compteurs, même vides
|
||||||
|
const heures = liste.body.meters.find((m: { kind: string }) => m.kind === 'RUNNING_HOURS');
|
||||||
|
const dernier = heures.readings[0]?.value ?? 0;
|
||||||
|
|
||||||
|
const apres = await http()
|
||||||
|
.post(`/assets/${a1.id}/meter-readings`)
|
||||||
|
.set(auth(ahmed))
|
||||||
|
.send({ kind: 'RUNNING_HOURS', value: 90000000 + dernier })
|
||||||
|
.expect(201);
|
||||||
|
const majHeures = apres.body.meters.find(
|
||||||
|
(m: { kind: string }) => m.kind === 'RUNNING_HOURS',
|
||||||
|
);
|
||||||
|
expect(majHeures.readings[0].value).toBe(90000000 + dernier);
|
||||||
|
expect(majHeures.readings[0].readBy.displayName).toBe('Ahmed Benali');
|
||||||
|
|
||||||
|
await http()
|
||||||
|
.post(`/assets/${a1.id}/meter-readings`)
|
||||||
|
.set(auth(ahmed))
|
||||||
|
.send({ kind: 'RUNNING_HOURS', value: dernier })
|
||||||
|
.expect(400);
|
||||||
|
await http()
|
||||||
|
.get('/assets/00000000-0000-4000-8000-000000000000/meters')
|
||||||
|
.set(auth(ahmed))
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
247
apps/api/test/recherche.e2e-spec.ts
Normal file
247
apps/api/test/recherche.e2e-spec.ts
Normal 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 l’OT d’un 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 n’accepte qu’un 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
5
apps/mobile/.claude/settings.json
Normal file
5
apps/mobile/.claude/settings.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"enabledPlugins": {
|
||||||
|
"expo@claude-plugins-official": true
|
||||||
|
}
|
||||||
|
}
|
||||||
41
apps/mobile/.gitignore
vendored
Normal file
41
apps/mobile/.gitignore
vendored
Normal 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
3
apps/mobile/AGENTS.md
Normal 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
33
apps/mobile/README.md
Normal 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
31
apps/mobile/app.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
71
apps/mobile/app/(tabs)/_layout.tsx
Normal file
71
apps/mobile/app/(tabs)/_layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
242
apps/mobile/app/(tabs)/journee.tsx
Normal file
242
apps/mobile/app/(tabs)/journee.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
apps/mobile/app/(tabs)/preventif.tsx
Normal file
76
apps/mobile/app/(tabs)/preventif.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
158
apps/mobile/app/(tabs)/scanner.tsx
Normal file
158
apps/mobile/app/(tabs)/scanner.tsx
Normal 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 n’est 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.'
|
||||||
|
: 'L’appareil 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
182
apps/mobile/app/(tabs)/synchro.tsx
Normal file
182
apps/mobile/app/(tabs)/synchro.tsx
Normal 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 l’ordre' : ''}
|
||||||
|
</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 ?? 'L’OT 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 n’est perdu.
|
||||||
|
</Text>
|
||||||
|
<BoutonTel
|
||||||
|
libelle="Voir l’OT à 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 l’ordre 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
80
apps/mobile/app/_layout.tsx
Normal file
80
apps/mobile/app/_layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
129
apps/mobile/app/ascenseur/[id].tsx
Normal file
129
apps/mobile/app/ascenseur/[id].tsx
Normal 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 l’OT en cours ({enCours[0]!.reference})
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
150
apps/mobile/app/connexion.tsx
Normal file
150
apps/mobile/app/connexion.tsx
Normal 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
20
apps/mobile/app/index.tsx
Normal 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" />;
|
||||||
|
}
|
||||||
153
apps/mobile/app/ot/[id]/cloture.tsx
Normal file
153
apps/mobile/app/ot/[id]/cloture.tsx
Normal 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 l’OT a changé entre-temps, VOUS trancherez (écran Synchro).
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
145
apps/mobile/app/ot/[id]/grille.tsx
Normal file
145
apps/mobile/app/ot/[id]/grille.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
196
apps/mobile/app/ot/[id]/index.tsx
Normal file
196
apps/mobile/app/ot/[id]/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
BIN
apps/mobile/assets/android-icon-background.png
Normal file
BIN
apps/mobile/assets/android-icon-background.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
apps/mobile/assets/android-icon-foreground.png
Normal file
BIN
apps/mobile/assets/android-icon-foreground.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 77 KiB |
BIN
apps/mobile/assets/android-icon-monochrome.png
Normal file
BIN
apps/mobile/assets/android-icon-monochrome.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.0 KiB |
BIN
apps/mobile/assets/favicon.png
Normal file
BIN
apps/mobile/assets/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
BIN
apps/mobile/assets/icon.png
Normal file
BIN
apps/mobile/assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 384 KiB |
BIN
apps/mobile/assets/splash-icon.png
Normal file
BIN
apps/mobile/assets/splash-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
11
apps/mobile/jest.config.js
Normal file
11
apps/mobile/jest.config.js
Normal 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'],
|
||||||
|
};
|
||||||
4
apps/mobile/jest.setup.js
Normal file
4
apps/mobile/jest.setup.js
Normal 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
51
apps/mobile/package.json
Normal 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
|
||||||
|
}
|
||||||
31
apps/mobile/src/api/client.ts
Normal file
31
apps/mobile/src/api/client.ts
Normal 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);
|
||||||
|
}
|
||||||
103
apps/mobile/src/api/exploitation.ts
Normal file
103
apps/mobile/src/api/exploitation.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
22
apps/mobile/src/api/jeton.ts
Normal file
22
apps/mobile/src/api/jeton.ts
Normal 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
4120
apps/mobile/src/api/schema.d.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
83
apps/mobile/src/auth/session.tsx
Normal file
83
apps/mobile/src/auth/session.tsx
Normal 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>;
|
||||||
|
}
|
||||||
40
apps/mobile/src/composants/a-venir.tsx
Normal file
40
apps/mobile/src/composants/a-venir.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
apps/mobile/src/composants/carte-photos.tsx
Normal file
98
apps/mobile/src/composants/carte-photos.tsx
Normal 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;
|
||||||
265
apps/mobile/src/composants/ui.tsx
Normal file
265
apps/mobile/src/composants/ui.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
125
apps/mobile/src/file/actions.ts
Normal file
125
apps/mobile/src/file/actions.ts
Normal 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 l’intervention' : `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 d’intervention 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);
|
||||||
|
}
|
||||||
93
apps/mobile/src/file/store.ts
Normal file
93
apps/mobile/src/file/store.ts
Normal 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 = [];
|
||||||
|
}
|
||||||
93
apps/mobile/src/file/synchro.test.ts
Normal file
93
apps/mobile/src/file/synchro.test.ts
Normal 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 l’ordre 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 S’ARRÊTE là — rien n’est 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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user