mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-09 21:21:54 +00:00
Compare commits
20 Commits
release/r0
...
release/r3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d787a2d39 | ||
|
|
460ef4a80e | ||
|
|
c46a97ed08 | ||
|
|
c5382a5538 | ||
|
|
a4e98c2000 | ||
|
|
2ffdd13cf4 | ||
|
|
32eb20b5a0 | ||
|
|
dfdf8f7c18 | ||
|
|
d5041c6f05 | ||
|
|
3aa7189e96 | ||
|
|
f7c0da1b0f | ||
|
|
50254daea4 | ||
|
|
6a2dae1710 | ||
|
|
902efa6192 | ||
|
|
f7702e4252 | ||
|
|
54d926e9f4 | ||
|
|
c4d1ab7225 | ||
|
|
9778629827 | ||
|
|
266ffaaf1b | ||
|
|
6d9aafdab4 |
24
.github/workflows/ci.yml
vendored
24
.github/workflows/ci.yml
vendored
@@ -59,10 +59,10 @@ jobs:
|
|||||||
name: api (tests + couverture ≥ 70 %)
|
name: api (tests + couverture ≥ 70 %)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
services:
|
services:
|
||||||
# R0 : PostgreSQL nu suffit (r0_identity). Dès que des tests exigeront
|
# R1+ : la migration r1_referentiel exige PostGIS (colonne générée geography).
|
||||||
# pgvector/PostGIS (R1+), passer sur l'image infra/postgres.
|
# pgvector arrivera en R5 (bascule alors sur l’image infra/postgres).
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:18
|
image: postgis/postgis:18-3.6
|
||||||
env:
|
env:
|
||||||
POSTGRES_USER: siop
|
POSTGRES_USER: siop
|
||||||
POSTGRES_PASSWORD: siop
|
POSTGRES_PASSWORD: siop
|
||||||
@@ -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:
|
||||||
@@ -115,7 +123,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:18
|
image: postgis/postgis:18-3.6
|
||||||
env:
|
env:
|
||||||
POSTGRES_USER: siop
|
POSTGRES_USER: siop
|
||||||
POSTGRES_PASSWORD: siop
|
POSTGRES_PASSWORD: siop
|
||||||
@@ -134,8 +142,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:
|
||||||
|
|||||||
18
CLAUDE.md
18
CLAUDE.md
@@ -39,6 +39,20 @@ pnpm + Turborepo. `apps/api` : NestJS, Prisma, PostgreSQL (pgvector + PostGIS),
|
|||||||
- ✅ **R0.11 `apps/web`** : React 19 + Vite + Tailwind v4, `tokens.css` copié tel quel + classes extraites de la maquette validée, Manrope auto-hébergée ; connexion + sélecteur démo (masqué si 404 — une seule source de vérité : l'API), coquille sidebar (rail safran, écrans futurs marqués R1-R3) / topbar (thème, chip DÉMO, sélecteur de rôle), page /design ; client typé `schema.d.ts` généré depuis `docs/openapi.json` et committé ; vérifié en navigateur réel (bascule de rôle 101 ms, bi-thème, 0 erreur console).
|
- ✅ **R0.11 `apps/web`** : React 19 + Vite + Tailwind v4, `tokens.css` copié tel quel + classes extraites de la maquette validée, Manrope auto-hébergée ; connexion + sélecteur démo (masqué si 404 — une seule source de vérité : l'API), coquille sidebar (rail safran, écrans futurs marqués R1-R3) / topbar (thème, chip DÉMO, sélecteur de rôle), page /design ; client typé `schema.d.ts` généré depuis `docs/openapi.json` et committé ; vérifié en navigateur réel (bascule de rôle 101 ms, bi-thème, 0 erreur console).
|
||||||
- ✅ **R0.12 — tests + CI** (5 jobs verts) : `lint` (ESLint 10 flat config racine, règle ADR-001 anti-import MinIO codée et vérifiée), `ci-contract` (régénération spec+client, diff bloquant), `api` (PostgreSQL 18 + Redis, migrate deploy, Jest couverture ≥ 70 % bloquante — mesurée 97,5 %), `web` (typecheck + vitest + build), `e2e` (Playwright : parcours démo, bascule < 3 s chronométrée, déconnexion). Badge au README.
|
- ✅ **R0.12 — tests + CI** (5 jobs verts) : `lint` (ESLint 10 flat config racine, règle ADR-001 anti-import MinIO codée et vérifiée), `ci-contract` (régénération spec+client, diff bloquant), `api` (PostgreSQL 18 + Redis, migrate deploy, Jest couverture ≥ 70 % bloquante — mesurée 97,5 %), `web` (typecheck + vitest + build), `e2e` (Playwright : parcours démo, bascule < 3 s chronométrée, déconnexion). Badge au README.
|
||||||
- ✅ **R0.13 — Dockerfiles + runbook Dokploy** : image api (multi-stage pnpm deploy, `prisma migrate deploy` au boot, seed optionnel `SEED_ON_START`, non-root, healthcheck ; `binaryTargets` explicites) ; image web (nginx, proxy `/api` résolu à la requête, fallback SPA, cache assets) ; `infra/docker-compose.dokploy.yml` (5 services `siop2-`, seul le web sur `dokploy-network`) ; runbook `docs/06-production/runbook-dokploy.md`. **Répétition locale complète validée** (migrate+seed au boot, parcours via nginx conteneurisé, double verrou ADR-002 observé). Le déploiement réel attend les accès au serveur du partenaire.
|
- ✅ **R0.13 — Dockerfiles + runbook Dokploy** : image api (multi-stage pnpm deploy, `prisma migrate deploy` au boot, seed optionnel `SEED_ON_START`, non-root, healthcheck ; `binaryTargets` explicites) ; image web (nginx, proxy `/api` résolu à la requête, fallback SPA, cache assets) ; `infra/docker-compose.dokploy.yml` (5 services `siop2-`, seul le web sur `dokploy-network`) ; runbook `docs/06-production/runbook-dokploy.md`. **Répétition locale complète validée** (migrate+seed au boot, parcours via nginx conteneurisé, double verrou ADR-002 observé). Le déploiement réel attend les accès au serveur du partenaire.
|
||||||
- 🚀 **R0 EN PRODUCTION (16/07/2026)** : `https://siop2.apps.enset.top` (Dokploy ENSET, profil démo, vérifiée : health ok, 7 comptes, demo-login, 401 sans jeton, HTTPS). La production client SPELEV attend les accès au serveur du partenaire.
|
- ✅ **R0 CLOSE** (tag `release/r0`) — en production : `https://siop2.apps.enset.top` (Dokploy ENSET, profil démo, vérifiée en ligne). Restes non bloquants : secret `DOKPLOY_WEBHOOK_URL` (CD), sauvegardes PostgreSQL Dokploy. Production client SPELEV : attend les accès au serveur du partenaire.
|
||||||
- 🔄 **R0 — reste** : secret `DOKPLOY_WEBHOOK_URL` pour activer le CD (runbook §4), sauvegardes PostgreSQL Dokploy, recette R0 avec le référent (revue pixel écrans ↔ maquettes) → ouverture de R1 Référentiel.
|
- ✅ **R1 — maquettes validées** (16/07) : `maquette-r1.html`, 7 écrans + 4 décisions (statuts d'équipement ≠ statuts OT ; organe sans emplacement par construction ; invitation par lien 7 j ; catégories jamais supprimées si utilisées).
|
||||||
|
- ✅ **R1.1 — socle backend** : migration `r1_referentiel` (Category, Location 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 CLOSE (16/07/2026, tag `release/r1`)** : recettée par le référent, déployée et vérifiée en ligne (migration + seed au boot, 5 sites / 8 appareils sur l'instance).
|
||||||
|
- ✅ **R2 — maquettes validées** (16/07) : `maquette-r2.html` (demandes/approbation, nouvel OT, préventif, checklist, compteurs) + 4 décisions actées.
|
||||||
|
- ✅ **R2.1 — socle backend exploitation** : migration `r2_exploitation` (10 tables), 15 opérations au contrat (41 total, transitions + champs requis du bilan dans `@siop/shared`), machine à états stricte avec garde de clôture (bilan 3 champs + checklist), 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.
|
||||||
|
- 🔄 **Reprise ici** : redéployer sur Dokploy et vérifier en ligne (migration `r3_recette_fixes` + seed au boot) → ouverture R4 Mobile (Expo, design d'abord — maquettes avant tout code).
|
||||||
- Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0→R5.
|
- Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0→R5.
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
60
apps/api/prisma/cleanup-e2e.ts
Normal file
60
apps/api/prisma/cleanup-e2e.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
/** Purge des données créées par les parcours Playwright (motifs dédiés).
|
||||||
|
* Appelé par le globalSetup e2e — la base locale reste propre entre les runs. */
|
||||||
|
import 'dotenv/config';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
// R3 — d'abord les traces de gestion (les mouvements liés à un OT E2E
|
||||||
|
// seraient orphelins après sa suppression et fausseraient les stocks seedés)
|
||||||
|
await prisma.stockMovement.deleteMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ part: { designation: { contains: 'E2E' } } },
|
||||||
|
{ workOrder: { title: { contains: 'E2E' } } },
|
||||||
|
{ purchaseOrder: { supplier: { name: { contains: 'E2E' } } } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.document.deleteMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ fileName: { contains: 'e2e' } },
|
||||||
|
{ workOrder: { title: { contains: 'E2E' } } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.purchaseOrderLine.deleteMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ part: { designation: { contains: 'E2E' } } },
|
||||||
|
{ purchaseOrder: { supplier: { name: { contains: 'E2E' } } } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.purchaseOrder.deleteMany({
|
||||||
|
where: { supplier: { name: { contains: 'E2E' } } },
|
||||||
|
});
|
||||||
|
await prisma.part.deleteMany({ where: { designation: { contains: 'E2E' } } });
|
||||||
|
await prisma.partner.deleteMany({ where: { name: { contains: 'E2E' } } });
|
||||||
|
|
||||||
|
await prisma.request.deleteMany({ where: { description: { contains: 'E2E' } } });
|
||||||
|
await prisma.workOrder.deleteMany({ where: { title: { contains: 'E2E' } } });
|
||||||
|
await prisma.meterReading.deleteMany({ where: { value: { gte: 90_000_000 } } });
|
||||||
|
// Un appareil de test peut avoir reçu grilles/demandes (génération du mois) :
|
||||||
|
// on purge ses dépendances avant lui (FK RESTRICT).
|
||||||
|
await prisma.request.deleteMany({ where: { asset: { reference: { startsWith: 'E2E-' } } } });
|
||||||
|
await prisma.workOrder.deleteMany({ where: { asset: { reference: { startsWith: 'E2E-' } } } });
|
||||||
|
await prisma.asset.deleteMany({ where: { reference: { startsWith: 'E2E-' } } });
|
||||||
|
await prisma.location.deleteMany({ where: { name: { contains: 'E2E' }, parentId: { not: null } } });
|
||||||
|
await prisma.location.deleteMany({ where: { name: { contains: 'E2E' } } });
|
||||||
|
await prisma.user.deleteMany({ where: { email: { startsWith: 'recrue-' } } });
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exitCode = 1;
|
||||||
|
})
|
||||||
|
.finally(() => prisma.$disconnect());
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
-- Migration autosuffisante : les extensions requises sont créées si absentes
|
||||||
|
-- (la CI et tout environnement neuf n’ont pas notre init.sql).
|
||||||
|
CREATE EXTENSION IF NOT EXISTS postgis;
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "CategoryKind" AS ENUM ('EQUIPMENT', 'COMPONENT_TYPE');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "AssetStatus" AS ENUM ('IN_SERVICE', 'OUT_OF_SERVICE', 'UNDER_MAINTENANCE');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ADD COLUMN "activationExpiresAt" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "activationToken" TEXT,
|
||||||
|
ADD COLUMN "phone" TEXT;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Category" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"kind" "CategoryKind" NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
|
||||||
|
CONSTRAINT "Category_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Location" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"parentId" UUID,
|
||||||
|
"address" TEXT,
|
||||||
|
"city" TEXT,
|
||||||
|
"guardianName" TEXT,
|
||||||
|
"guardianPhone" TEXT,
|
||||||
|
"latitude" DOUBLE PRECISION,
|
||||||
|
"longitude" DOUBLE PRECISION,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Location_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Asset" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"reference" TEXT NOT NULL,
|
||||||
|
"brand" TEXT NOT NULL,
|
||||||
|
"model" TEXT,
|
||||||
|
"serialNumber" TEXT,
|
||||||
|
"commissionedAt" TIMESTAMP(3),
|
||||||
|
"loadKg" INTEGER,
|
||||||
|
"floors" INTEGER,
|
||||||
|
"status" "AssetStatus" NOT NULL DEFAULT 'IN_SERVICE',
|
||||||
|
"categoryId" UUID NOT NULL,
|
||||||
|
"locationId" UUID NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Asset_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "AssetComponent" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"assetId" UUID NOT NULL,
|
||||||
|
"typeId" UUID NOT NULL,
|
||||||
|
"designation" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "AssetComponent_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Team" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "Team_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "_TeamToUser" (
|
||||||
|
"A" UUID NOT NULL,
|
||||||
|
"B" UUID NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "_TeamToUser_AB_pkey" PRIMARY KEY ("A","B")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Category_kind_name_key" ON "Category"("kind", "name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Location_parentId_idx" ON "Location"("parentId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Asset_reference_key" ON "Asset"("reference");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Asset_locationId_idx" ON "Asset"("locationId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Asset_categoryId_idx" ON "Asset"("categoryId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "AssetComponent_assetId_idx" ON "AssetComponent"("assetId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Team_name_key" ON "Team"("name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "_TeamToUser_B_index" ON "_TeamToUser"("B");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_activationToken_key" ON "User"("activationToken");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Location" ADD CONSTRAINT "Location_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "Location"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Asset" ADD CONSTRAINT "Asset_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Asset" ADD CONSTRAINT "Asset_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "AssetComponent" ADD CONSTRAINT "AssetComponent_assetId_fkey" FOREIGN KEY ("assetId") REFERENCES "Asset"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "AssetComponent" ADD CONSTRAINT "AssetComponent_typeId_fkey" FOREIGN KEY ("typeId") REFERENCES "Category"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "_TeamToUser" ADD CONSTRAINT "_TeamToUser_A_fkey" FOREIGN KEY ("A") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "_TeamToUser" ADD CONSTRAINT "_TeamToUser_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
|
||||||
|
-- ————— PostGIS (hors périmètre Prisma, décision modèle R1) —————
|
||||||
|
-- Position générée depuis latitude/longitude : rien à synchroniser côté app,
|
||||||
|
-- la colonne est prête pour les requêtes spatiales (affectation auto, backlog).
|
||||||
|
ALTER TABLE "Location"
|
||||||
|
ADD COLUMN "position" geography(Point, 4326)
|
||||||
|
GENERATED ALWAYS AS (ST_SetSRID(ST_MakePoint("longitude", "latitude"), 4326)::geography) STORED;
|
||||||
|
|
||||||
|
CREATE INDEX "Location_position_gix" ON "Location" USING GIST ("position");
|
||||||
@@ -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;
|
||||||
@@ -39,12 +39,481 @@ model User {
|
|||||||
email String @unique
|
email String @unique
|
||||||
displayName String
|
displayName String
|
||||||
passwordHash String? // null tant que le compte n'est pas activé (R1)
|
passwordHash String? // null tant que le compte n'est pas activé (R1)
|
||||||
|
phone String?
|
||||||
roleId String @db.Uuid
|
roleId String @db.Uuid
|
||||||
role Role @relation(fields: [roleId], references: [id])
|
role Role @relation(fields: [roleId], references: [id])
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
isDemo Boolean @default(false) // seul un compte isDemo est empruntable (ADR-002)
|
isDemo Boolean @default(false) // seul un compte isDemo est empruntable (ADR-002)
|
||||||
|
// Invitation (R1) : lien d'activation 7 jours, usage unique.
|
||||||
|
// Statut dérivé : invité = passwordHash null && token présent.
|
||||||
|
activationToken String? @unique
|
||||||
|
activationExpiresAt DateTime?
|
||||||
|
teams Team[]
|
||||||
|
// R2 — exploitation
|
||||||
|
workOrdersAssigned WorkOrder[] @relation("WorkOrderAssignees")
|
||||||
|
workOrdersCreated WorkOrder[] @relation("WorkOrderCreator")
|
||||||
|
workOrderEvents WorkOrderEvent[]
|
||||||
|
requests Request[]
|
||||||
|
checklistDone ChecklistItem[]
|
||||||
|
meterReadings MeterReading[]
|
||||||
|
// R3 — gestion : taux horaire COURANT (le taux d'une saisie est figé dans LaborTime)
|
||||||
|
hourlyRate Decimal? @db.Decimal(8, 2)
|
||||||
|
laborTimes LaborTime[]
|
||||||
|
stockMovements StockMovement[]
|
||||||
|
purchaseOrders PurchaseOrder[]
|
||||||
|
documents Document[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@index([roleId])
|
@@index([roleId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ————— R1 — Référentiel (docs/03-architecture/modele-donnees.md §R1) —————
|
||||||
|
|
||||||
|
enum CategoryKind {
|
||||||
|
EQUIPMENT
|
||||||
|
COMPONENT_TYPE
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AssetStatus {
|
||||||
|
IN_SERVICE
|
||||||
|
OUT_OF_SERVICE
|
||||||
|
UNDER_MAINTENANCE
|
||||||
|
}
|
||||||
|
|
||||||
|
model Category {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
kind CategoryKind
|
||||||
|
name String
|
||||||
|
isActive Boolean @default(true) // désactivable, jamais supprimée si utilisée
|
||||||
|
assets Asset[]
|
||||||
|
components AssetComponent[]
|
||||||
|
taskTemplates TaskTemplate[]
|
||||||
|
|
||||||
|
@@unique([kind, name])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Location {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
name String
|
||||||
|
parentId String? @db.Uuid // site (null) → zone ; profondeur max 2 (service)
|
||||||
|
parent Location? @relation("LocationTree", fields: [parentId], references: [id])
|
||||||
|
children Location[] @relation("LocationTree")
|
||||||
|
address String?
|
||||||
|
city String?
|
||||||
|
guardianName String?
|
||||||
|
guardianPhone String?
|
||||||
|
latitude Float?
|
||||||
|
longitude Float?
|
||||||
|
// + colonne PostGIS générée (voir migration r1_referentiel) :
|
||||||
|
// position geography(Point,4326) GENERATED ALWAYS AS (…) STORED
|
||||||
|
// Client/syndic gérant le site (R3 — colonne « Rattachements » des Tiers)
|
||||||
|
partnerId String? @db.Uuid
|
||||||
|
partner Partner? @relation(fields: [partnerId], references: [id])
|
||||||
|
assets Asset[]
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([parentId])
|
||||||
|
@@index([partnerId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Asset {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
reference String @unique // « A1 » — imprimée sur l'étiquette QR
|
||||||
|
brand String
|
||||||
|
model String?
|
||||||
|
serialNumber String?
|
||||||
|
commissionedAt DateTime?
|
||||||
|
loadKg Int?
|
||||||
|
floors Int?
|
||||||
|
status AssetStatus @default(IN_SERVICE) // statut d'ÉQUIPEMENT ≠ statut d'OT
|
||||||
|
underContract Boolean @default(true) // contrat préventif (grille mensuelle)
|
||||||
|
categoryId String @db.Uuid
|
||||||
|
category Category @relation(fields: [categoryId], references: [id])
|
||||||
|
locationId String @db.Uuid
|
||||||
|
location Location @relation(fields: [locationId], references: [id])
|
||||||
|
components AssetComponent[]
|
||||||
|
workOrders WorkOrder[]
|
||||||
|
requests Request[]
|
||||||
|
meters Meter[]
|
||||||
|
documents Document[]
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([locationId])
|
||||||
|
@@index([categoryId])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Organe : PAS de colonne emplacement — « un organe n'a pas d'emplacement
|
||||||
|
// propre » est garanti par construction (décision maquettes R1).
|
||||||
|
model AssetComponent {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
assetId String @db.Uuid
|
||||||
|
asset Asset @relation(fields: [assetId], references: [id], onDelete: Cascade)
|
||||||
|
typeId String @db.Uuid
|
||||||
|
type Category @relation(fields: [typeId], references: [id])
|
||||||
|
designation String?
|
||||||
|
|
||||||
|
@@index([assetId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Team {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
name String @unique
|
||||||
|
description String?
|
||||||
|
members User[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ————— R2 — Exploitation (docs/03-architecture/modele-donnees.md §R2) —————
|
||||||
|
|
||||||
|
enum WorkOrderType {
|
||||||
|
CORRECTIVE // Dépannage
|
||||||
|
PREVENTIVE // Maintenance (grille du mois)
|
||||||
|
WORKS // Travaux
|
||||||
|
}
|
||||||
|
|
||||||
|
enum WorkOrderStatus {
|
||||||
|
OPEN
|
||||||
|
IN_PROGRESS
|
||||||
|
ON_HOLD
|
||||||
|
DONE
|
||||||
|
CANCELLED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum WorkOrderPriority {
|
||||||
|
NONE
|
||||||
|
LOW
|
||||||
|
MEDIUM
|
||||||
|
HIGH
|
||||||
|
PERSON_TRAPPED // personne bloquée — urgence absolue
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RequestStatus {
|
||||||
|
RECEIVED
|
||||||
|
APPROVED
|
||||||
|
REJECTED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ChecklistState {
|
||||||
|
PENDING
|
||||||
|
DONE
|
||||||
|
NA
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BilanField {
|
||||||
|
DOOR_STATE
|
||||||
|
CABIN_POSITION
|
||||||
|
ANOMALY
|
||||||
|
EXTERNAL_CAUSE
|
||||||
|
ACTION_TAKEN
|
||||||
|
COMPONENT_CONCERNED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum MeterKind {
|
||||||
|
RUNNING_HOURS
|
||||||
|
STARTS
|
||||||
|
}
|
||||||
|
|
||||||
|
model WorkOrder {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
reference String @unique // OT-2026-0341
|
||||||
|
title String
|
||||||
|
description String?
|
||||||
|
type WorkOrderType
|
||||||
|
status WorkOrderStatus @default(OPEN) // machine à états stricte (service)
|
||||||
|
priority WorkOrderPriority @default(NONE)
|
||||||
|
assetId String @db.Uuid
|
||||||
|
asset Asset @relation(fields: [assetId], references: [id])
|
||||||
|
dueDate DateTime?
|
||||||
|
assignees User[] @relation("WorkOrderAssignees")
|
||||||
|
createdById String? @db.Uuid
|
||||||
|
createdBy User? @relation("WorkOrderCreator", fields: [createdById], references: [id])
|
||||||
|
startedAt DateTime?
|
||||||
|
completedAt DateTime?
|
||||||
|
cancelledAt DateTime?
|
||||||
|
events WorkOrderEvent[]
|
||||||
|
checklist ChecklistItem[]
|
||||||
|
report InterventionReport?
|
||||||
|
request Request?
|
||||||
|
stockMovements StockMovement[]
|
||||||
|
laborTimes LaborTime[]
|
||||||
|
documents Document[]
|
||||||
|
// Grille du mois : « AAAA-MM » — l'unicité [assetId, periodKey] EST
|
||||||
|
// l'idempotence de la génération (les OT correctifs restent à null).
|
||||||
|
periodKey String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([assetId, periodKey])
|
||||||
|
@@index([assetId])
|
||||||
|
@@index([status])
|
||||||
|
}
|
||||||
|
|
||||||
|
model WorkOrderEvent {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
workOrderId String @db.Uuid
|
||||||
|
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
|
||||||
|
kind String // COMMENT · STATUS_CHANGED · ASSIGNED · CREATED · FROM_REQUEST
|
||||||
|
message String?
|
||||||
|
byId String? @db.Uuid
|
||||||
|
by User? @relation(fields: [byId], references: [id])
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([workOrderId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Request {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
reference String @unique // DEM-2026-0112
|
||||||
|
description String
|
||||||
|
isPersonTrapped Boolean @default(false)
|
||||||
|
status RequestStatus @default(RECEIVED)
|
||||||
|
rejectionReason String? // REQUIS au rejet (service)
|
||||||
|
assetId String @db.Uuid
|
||||||
|
asset Asset @relation(fields: [assetId], references: [id])
|
||||||
|
requestedById String? @db.Uuid
|
||||||
|
requestedBy User? @relation(fields: [requestedById], references: [id])
|
||||||
|
requesterName String? // portail public via QR (R2.4)
|
||||||
|
// Suivi SANS COMPTE (portail) : le téléphone du gardien garde ce jeton,
|
||||||
|
// seul moyen de lire l'avancement — jamais listé, jamais devinable.
|
||||||
|
publicToken String? @unique
|
||||||
|
workOrderId String? @unique @db.Uuid // lien 1-1 — jamais de doublon
|
||||||
|
workOrder WorkOrder? @relation(fields: [workOrderId], references: [id])
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([assetId])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Référentiels administrables du bilan codé (un par champ)
|
||||||
|
model ReferenceValue {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
field BilanField
|
||||||
|
label String
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
|
||||||
|
doorStates InterventionReport[] @relation("BilanDoorState")
|
||||||
|
cabinPositions InterventionReport[] @relation("BilanCabinPosition")
|
||||||
|
anomalies InterventionReport[] @relation("BilanAnomaly")
|
||||||
|
externalCauses InterventionReport[] @relation("BilanExternalCause")
|
||||||
|
actionsTaken InterventionReport[] @relation("BilanActionTaken")
|
||||||
|
componentsConcerned InterventionReport[] @relation("BilanComponentConcerned")
|
||||||
|
|
||||||
|
@@unique([field, label])
|
||||||
|
}
|
||||||
|
|
||||||
|
model InterventionReport {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
workOrderId String @unique @db.Uuid
|
||||||
|
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
|
||||||
|
note String?
|
||||||
|
|
||||||
|
doorStateId String? @db.Uuid
|
||||||
|
doorState ReferenceValue? @relation("BilanDoorState", fields: [doorStateId], references: [id])
|
||||||
|
cabinPositionId String? @db.Uuid
|
||||||
|
cabinPosition ReferenceValue? @relation("BilanCabinPosition", fields: [cabinPositionId], references: [id])
|
||||||
|
anomalyId String? @db.Uuid
|
||||||
|
anomaly ReferenceValue? @relation("BilanAnomaly", fields: [anomalyId], references: [id])
|
||||||
|
externalCauseId String? @db.Uuid
|
||||||
|
externalCause ReferenceValue? @relation("BilanExternalCause", fields: [externalCauseId], references: [id])
|
||||||
|
actionTakenId String? @db.Uuid
|
||||||
|
actionTaken ReferenceValue? @relation("BilanActionTaken", fields: [actionTakenId], references: [id])
|
||||||
|
componentConcernedId String? @db.Uuid
|
||||||
|
componentConcerned ReferenceValue? @relation("BilanComponentConcerned", fields: [componentConcernedId], references: [id])
|
||||||
|
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model TaskTemplate {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
label String @unique
|
||||||
|
componentTypeId String? @db.Uuid
|
||||||
|
componentType Category? @relation(fields: [componentTypeId], references: [id])
|
||||||
|
periodMonths Int // 1, 3, 6, 12…
|
||||||
|
isRegulatory Boolean @default(false) // essai parachute
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
checklistItems ChecklistItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model ChecklistItem {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
workOrderId String @db.Uuid
|
||||||
|
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
|
||||||
|
label String
|
||||||
|
state ChecklistState @default(PENDING)
|
||||||
|
templateId String? @db.Uuid
|
||||||
|
template TaskTemplate? @relation(fields: [templateId], references: [id])
|
||||||
|
doneById String? @db.Uuid
|
||||||
|
doneBy User? @relation(fields: [doneById], references: [id])
|
||||||
|
doneAt DateTime?
|
||||||
|
|
||||||
|
@@index([workOrderId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Meter {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
assetId String @db.Uuid
|
||||||
|
asset Asset @relation(fields: [assetId], references: [id], onDelete: Cascade)
|
||||||
|
kind MeterKind
|
||||||
|
readings MeterReading[]
|
||||||
|
|
||||||
|
@@unique([assetId, kind])
|
||||||
|
}
|
||||||
|
|
||||||
|
model MeterReading {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
meterId String @db.Uuid
|
||||||
|
meter Meter @relation(fields: [meterId], references: [id], onDelete: Cascade)
|
||||||
|
value Int // strictement croissant (service)
|
||||||
|
readById String? @db.Uuid
|
||||||
|
readBy User? @relation(fields: [readById], references: [id])
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([meterId])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ————— R3 — Gestion (docs/03-architecture/modele-donnees.md §R3) —————
|
||||||
|
|
||||||
|
enum PartnerKind {
|
||||||
|
SUPPLIER
|
||||||
|
CLIENT // syndic / propriétaire
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PurchaseOrderStatus {
|
||||||
|
DRAFT
|
||||||
|
SENT
|
||||||
|
RECEIVED
|
||||||
|
CANCELLED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum StockMovementKind {
|
||||||
|
RECEIPT // réception de BC (+, PU figé)
|
||||||
|
ENTRY // entrée manuelle (+)
|
||||||
|
CONSUMPTION // consommation d'OT (−, PU figé)
|
||||||
|
ADJUSTMENT // inventaire (±, motif REQUIS)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum DocumentKind {
|
||||||
|
NOTICE
|
||||||
|
CERTIFICATE
|
||||||
|
PHOTO
|
||||||
|
OTHER
|
||||||
|
}
|
||||||
|
|
||||||
|
model Partner {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
name String @unique
|
||||||
|
kind PartnerKind
|
||||||
|
contactName String?
|
||||||
|
phone String?
|
||||||
|
email String?
|
||||||
|
city String?
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
parts Part[]
|
||||||
|
purchaseOrders PurchaseOrder[]
|
||||||
|
sites Location[] // sites gérés (kind CLIENT)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model Part {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
reference String @unique // P-0113
|
||||||
|
designation String
|
||||||
|
threshold Int @default(0) // seuil d'alerte
|
||||||
|
// Dernier prix d'achat — figé sur chaque mouvement au moment T.
|
||||||
|
lastUnitPrice Decimal? @db.Decimal(10, 2)
|
||||||
|
compatible String?
|
||||||
|
supplierId String? @db.Uuid
|
||||||
|
supplier Partner? @relation(fields: [supplierId], references: [id])
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
movements StockMovement[]
|
||||||
|
orderLines PurchaseOrderLine[]
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
// Le stock EST la somme des mouvements — aucune colonne de quantité.
|
||||||
|
model StockMovement {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
partId String @db.Uuid
|
||||||
|
part Part @relation(fields: [partId], references: [id], onDelete: Cascade)
|
||||||
|
kind StockMovementKind
|
||||||
|
quantity Int // signé : + entrée, − sortie
|
||||||
|
unitPrice Decimal? @db.Decimal(10, 2) // figé (réception, consommation)
|
||||||
|
reason String? // ajustement : motif requis (service)
|
||||||
|
workOrderId String? @db.Uuid
|
||||||
|
workOrder WorkOrder? @relation(fields: [workOrderId], references: [id])
|
||||||
|
purchaseOrderId String? @db.Uuid
|
||||||
|
purchaseOrder PurchaseOrder? @relation(fields: [purchaseOrderId], references: [id])
|
||||||
|
byId String? @db.Uuid
|
||||||
|
by User? @relation(fields: [byId], references: [id])
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([partId])
|
||||||
|
@@index([workOrderId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model PurchaseOrder {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
reference String @unique // BC-2026-0024
|
||||||
|
status PurchaseOrderStatus @default(DRAFT)
|
||||||
|
supplierId String @db.Uuid
|
||||||
|
supplier Partner @relation(fields: [supplierId], references: [id])
|
||||||
|
lines PurchaseOrderLine[]
|
||||||
|
movements StockMovement[]
|
||||||
|
sentAt DateTime?
|
||||||
|
receivedAt DateTime?
|
||||||
|
cancelledAt DateTime?
|
||||||
|
createdById String? @db.Uuid
|
||||||
|
createdBy User? @relation(fields: [createdById], references: [id])
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model PurchaseOrderLine {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
purchaseOrderId String @db.Uuid
|
||||||
|
purchaseOrder PurchaseOrder @relation(fields: [purchaseOrderId], references: [id], onDelete: Cascade)
|
||||||
|
partId String @db.Uuid
|
||||||
|
part Part @relation(fields: [partId], references: [id])
|
||||||
|
quantity Int
|
||||||
|
unitPrice Decimal @db.Decimal(10, 2)
|
||||||
|
|
||||||
|
@@index([purchaseOrderId])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main-d'œuvre : le taux est FIGÉ à la saisie (le coût d'un OT ne bouge plus).
|
||||||
|
model LaborTime {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
workOrderId String @db.Uuid
|
||||||
|
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
|
||||||
|
userId String @db.Uuid
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
minutes Int
|
||||||
|
hourlyRate Decimal @db.Decimal(8, 2)
|
||||||
|
note String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([workOrderId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Document {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
kind DocumentKind
|
||||||
|
fileName String
|
||||||
|
storageKey String @unique // clé MinIO (FileStorage)
|
||||||
|
size Int
|
||||||
|
contentType String
|
||||||
|
assetId String? @db.Uuid
|
||||||
|
asset Asset? @relation(fields: [assetId], references: [id])
|
||||||
|
workOrderId String? @db.Uuid
|
||||||
|
workOrder WorkOrder? @relation(fields: [workOrderId], references: [id])
|
||||||
|
uploadedById String? @db.Uuid
|
||||||
|
uploadedBy User? @relation(fields: [uploadedById], references: [id])
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([assetId])
|
||||||
|
@@index([workOrderId])
|
||||||
|
}
|
||||||
|
|||||||
@@ -131,6 +131,17 @@ export async function seed(prisma: PrismaClient): Promise<void> {
|
|||||||
const passwordHash = await argon2.hash(
|
const passwordHash = await argon2.hash(
|
||||||
process.env.SEED_DEMO_PASSWORD ?? 'Demo!2026',
|
process.env.SEED_DEMO_PASSWORD ?? 'Demo!2026',
|
||||||
);
|
);
|
||||||
|
await seedUsers(prisma, roleIds, passwordHash);
|
||||||
|
await seedReferentiel(prisma);
|
||||||
|
await seedExploitation(prisma);
|
||||||
|
await seedGestion(prisma);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedUsers(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
roleIds: Map<RoleName, string>,
|
||||||
|
passwordHash: string,
|
||||||
|
): Promise<void> {
|
||||||
for (const u of DEMO_USERS) {
|
for (const u of DEMO_USERS) {
|
||||||
// Les comptes démo appartiennent au seed : nom et rôle sont réalignés
|
// Les comptes démo appartiennent au seed : nom et rôle sont réalignés
|
||||||
// à chaque exécution (jamais le mot de passe d'un compte existant).
|
// à chaque exécution (jamais le mot de passe d'un compte existant).
|
||||||
@@ -148,6 +159,610 @@ export async function seed(prisma: PrismaClient): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ————— R1 — Référentiel (données des maquettes validées) —————
|
||||||
|
|
||||||
|
const EQUIPMENT_CATEGORIES = [
|
||||||
|
'Ascenseur électrique',
|
||||||
|
'Ascenseur hydraulique',
|
||||||
|
'Monte-charge',
|
||||||
|
'EPMR (plateforme PMR)',
|
||||||
|
];
|
||||||
|
const COMPONENT_TYPES = [
|
||||||
|
'Portes cabine / palières',
|
||||||
|
'Treuil / machinerie',
|
||||||
|
'Parachute',
|
||||||
|
'Armoire de commande',
|
||||||
|
'Boutons & signalisation',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** site → zones ; positions réelles Casablanca/Mohammedia. */
|
||||||
|
const SITES: {
|
||||||
|
name: string;
|
||||||
|
address: string;
|
||||||
|
city: string;
|
||||||
|
guardianName?: string;
|
||||||
|
guardianPhone?: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
zones: string[];
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
name: 'Tour Atlas', address: 'Bd de la Corniche, Aïn Diab', city: 'Casablanca',
|
||||||
|
guardianName: 'Karim Doukkali', guardianPhone: '06 61 23 45 67',
|
||||||
|
latitude: 33.6062, longitude: -7.6706,
|
||||||
|
zones: ['Hall principal', 'Tour bureaux (étages 1-24)', 'Parking sous-sol', 'Résidence (aile est)'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Résidence Al Manar', address: 'Bd Hassan II', city: 'Mohammedia',
|
||||||
|
guardianName: 'Hassan Alami',
|
||||||
|
latitude: 33.6866, longitude: -7.383,
|
||||||
|
zones: ['Hall principal'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Anfa Place', address: 'Bd de l’Océan Atlantique', city: 'Casablanca',
|
||||||
|
latitude: 33.5883, longitude: -7.6822,
|
||||||
|
zones: ['Galerie commerciale'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Clinique Yasmine', address: 'Rue Ibn Rochd', city: 'Casablanca',
|
||||||
|
guardianName: 'Rachid Mansouri',
|
||||||
|
latitude: 33.5731, longitude: -7.6316,
|
||||||
|
zones: ['Bloc A'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Marina Center', address: 'Av. des FAR', city: 'Mohammedia',
|
||||||
|
latitude: 33.7, longitude: -7.39,
|
||||||
|
zones: ['Hall B'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const ASSETS: {
|
||||||
|
reference: string; brand: string; model: string; serialNumber?: string;
|
||||||
|
commissionedAt?: string; loadKg?: number; floors?: number;
|
||||||
|
category: string; site: string; zone: string;
|
||||||
|
status?: 'IN_SERVICE' | 'OUT_OF_SERVICE' | 'UNDER_MAINTENANCE';
|
||||||
|
components?: { type: string; designation?: string }[];
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
reference: 'A1', brand: 'Otis', model: 'Gen2 Premier', serialNumber: 'OT-2020-4521',
|
||||||
|
commissionedAt: '2020-03-15', loadKg: 630, floors: 8,
|
||||||
|
category: 'Ascenseur électrique', site: 'Résidence Al Manar', zone: 'Hall principal',
|
||||||
|
components: [
|
||||||
|
{ type: 'Portes cabine / palières', designation: 'Fermator 40/10' },
|
||||||
|
{ type: 'Treuil / machinerie', designation: 'Gen2 gearless' },
|
||||||
|
{ type: 'Parachute' },
|
||||||
|
{ type: 'Armoire de commande', designation: 'MCS 220' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
reference: 'A2', brand: 'Otis', model: 'Gen2', loadKg: 630, floors: 12,
|
||||||
|
category: 'Ascenseur électrique', site: 'Tour Atlas', zone: 'Hall principal',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
reference: 'B1', brand: 'Schindler', model: '3300', loadKg: 1000, floors: 24,
|
||||||
|
category: 'Ascenseur électrique', site: 'Tour Atlas', zone: 'Tour bureaux (étages 1-24)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
reference: 'B2', brand: 'Schindler', model: '3300', loadKg: 1000, floors: 24,
|
||||||
|
category: 'Ascenseur électrique', site: 'Tour Atlas', zone: 'Tour bureaux (étages 1-24)',
|
||||||
|
status: 'OUT_OF_SERVICE',
|
||||||
|
components: [
|
||||||
|
{ type: 'Portes cabine / palières', designation: 'Sematic' },
|
||||||
|
{ type: 'Armoire de commande' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
reference: 'M1', brand: 'Kone', model: 'TranSys', loadKg: 2000, floors: 3,
|
||||||
|
category: 'Monte-charge', site: 'Tour Atlas', zone: 'Parking sous-sol',
|
||||||
|
status: 'UNDER_MAINTENANCE',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
reference: 'C1', brand: 'Kone', model: 'MonoSpace 500', loadKg: 800, floors: 5,
|
||||||
|
category: 'Ascenseur électrique', site: 'Anfa Place', zone: 'Galerie commerciale',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
reference: 'D1', brand: 'ThyssenKrupp', model: 'Evolution', loadKg: 1600, floors: 6,
|
||||||
|
category: 'Ascenseur électrique', site: 'Clinique Yasmine', zone: 'Bloc A',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
reference: 'E2', brand: 'Otis', model: 'HydroFit', loadKg: 630, floors: 4,
|
||||||
|
category: 'Ascenseur hydraulique', site: 'Marina Center', zone: 'Hall B',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const TEAMS: { name: string; description: string; memberEmails: string[] }[] = [
|
||||||
|
{
|
||||||
|
name: 'Casablanca Centre',
|
||||||
|
description: 'Tour Atlas · Anfa Place · Clinique Yasmine',
|
||||||
|
memberEmails: ['technicien@demo.siop.ma', 'technicien-limite@demo.siop.ma'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Mohammedia',
|
||||||
|
description: 'Résidence Al Manar · Marina Center',
|
||||||
|
memberEmails: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
async function seedReferentiel(prisma: PrismaClient): Promise<void> {
|
||||||
|
const categoryIds = new Map<string, string>();
|
||||||
|
for (const [kind, names] of [
|
||||||
|
['EQUIPMENT', EQUIPMENT_CATEGORIES],
|
||||||
|
['COMPONENT_TYPE', COMPONENT_TYPES],
|
||||||
|
] as const) {
|
||||||
|
for (const name of names) {
|
||||||
|
const category = await prisma.category.upsert({
|
||||||
|
where: { kind_name: { kind, name } },
|
||||||
|
update: {},
|
||||||
|
create: { kind, name },
|
||||||
|
});
|
||||||
|
categoryIds.set(name, category.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pas d'unicité en base sur (parent, nom) : idempotence par findFirst.
|
||||||
|
const zoneIds = new Map<string, string>(); // « site / zone » → id
|
||||||
|
for (const site of SITES) {
|
||||||
|
const { zones, ...data } = site;
|
||||||
|
let root = await prisma.location.findFirst({
|
||||||
|
where: { name: site.name, parentId: null },
|
||||||
|
});
|
||||||
|
root ??= await prisma.location.create({ data });
|
||||||
|
for (const zoneName of zones) {
|
||||||
|
let zone = await prisma.location.findFirst({
|
||||||
|
where: { name: zoneName, parentId: root.id },
|
||||||
|
});
|
||||||
|
zone ??= await prisma.location.create({
|
||||||
|
data: { name: zoneName, parentId: root.id, city: site.city },
|
||||||
|
});
|
||||||
|
zoneIds.set(`${site.name} / ${zoneName}`, zone.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const asset of ASSETS) {
|
||||||
|
const created = await prisma.asset.upsert({
|
||||||
|
where: { reference: asset.reference },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
reference: asset.reference,
|
||||||
|
brand: asset.brand,
|
||||||
|
model: asset.model,
|
||||||
|
serialNumber: asset.serialNumber,
|
||||||
|
commissionedAt: asset.commissionedAt ? new Date(asset.commissionedAt) : undefined,
|
||||||
|
loadKg: asset.loadKg,
|
||||||
|
floors: asset.floors,
|
||||||
|
status: asset.status ?? 'IN_SERVICE',
|
||||||
|
categoryId: categoryIds.get(asset.category)!,
|
||||||
|
locationId: zoneIds.get(`${asset.site} / ${asset.zone}`)!,
|
||||||
|
},
|
||||||
|
include: { _count: { select: { components: true } } },
|
||||||
|
});
|
||||||
|
if (asset.components?.length && created._count.components === 0) {
|
||||||
|
await prisma.assetComponent.createMany({
|
||||||
|
data: asset.components.map((c) => ({
|
||||||
|
assetId: created.id,
|
||||||
|
typeId: categoryIds.get(c.type)!,
|
||||||
|
designation: c.designation,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const team of TEAMS) {
|
||||||
|
await prisma.team.upsert({
|
||||||
|
where: { name: team.name },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
name: team.name,
|
||||||
|
description: team.description,
|
||||||
|
members: { connect: team.memberEmails.map((email) => ({ email })) },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ————— R2 — Exploitation (référentiels du bilan, gabarits, données maquette) —————
|
||||||
|
|
||||||
|
const REFERENCE_VALUES: Record<string, string[]> = {
|
||||||
|
DOOR_STATE: [
|
||||||
|
'Fonctionnement normal',
|
||||||
|
'Porte bloquée ouverte',
|
||||||
|
'Porte bloquée fermée',
|
||||||
|
'Fermeture incomplète',
|
||||||
|
'Réouverture intempestive',
|
||||||
|
],
|
||||||
|
CABIN_POSITION: ['À niveau', 'Entre deux niveaux', 'Cuvette', 'Dernier niveau'],
|
||||||
|
ANOMALY: [
|
||||||
|
'Frottement mécanique',
|
||||||
|
'Défaut électrique',
|
||||||
|
'Usure normale',
|
||||||
|
'Choc / vandalisme',
|
||||||
|
'Aucune anomalie constatée',
|
||||||
|
],
|
||||||
|
EXTERNAL_CAUSE: ['Coupure électrique', 'Dégât des eaux', 'Mauvais usage', 'Aucune'],
|
||||||
|
ACTION_TAKEN: [
|
||||||
|
'Réglage',
|
||||||
|
'Remplacement de pièce',
|
||||||
|
'Nettoyage / graissage',
|
||||||
|
'Remise en service simple',
|
||||||
|
'Visite 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,11 +1,27 @@
|
|||||||
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 { 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';
|
||||||
|
import { CategoriesModule } from './categories/categories.module';
|
||||||
import { demoModeEnabled } from './config/env';
|
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 { MetersModule } from './meters/meters.module';
|
||||||
|
import { PartnersModule } from './partners/partners.module';
|
||||||
|
import { PartsModule } from './parts/parts.module';
|
||||||
|
import { PortalModule } from './portal/portal.module';
|
||||||
|
import { PurchaseOrdersModule } from './purchase-orders/purchase-orders.module';
|
||||||
|
import { PreventiveModule } from './preventive/preventive.module';
|
||||||
|
import { ReferenceValuesModule } from './reference-values/reference-values.module';
|
||||||
|
import { RequestsModule } from './requests/requests.module';
|
||||||
|
import { TeamsModule } from './teams/teams.module';
|
||||||
|
import { WorkOrdersModule } from './work-orders/work-orders.module';
|
||||||
import { PermissionsGuard } from './permissions/permissions.guard';
|
import { 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';
|
||||||
@@ -28,6 +44,25 @@ export class AppModule {
|
|||||||
AuthModule,
|
AuthModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
HealthModule,
|
HealthModule,
|
||||||
|
// R1 — référentiel
|
||||||
|
CategoriesModule,
|
||||||
|
LocationsModule,
|
||||||
|
AssetsModule,
|
||||||
|
TeamsModule,
|
||||||
|
// R2 — exploitation
|
||||||
|
WorkOrdersModule,
|
||||||
|
RequestsModule,
|
||||||
|
ReferenceValuesModule,
|
||||||
|
PreventiveModule,
|
||||||
|
MetersModule,
|
||||||
|
PortalModule,
|
||||||
|
// R3 — gestion
|
||||||
|
PartnersModule,
|
||||||
|
PartsModule,
|
||||||
|
PurchaseOrdersModule,
|
||||||
|
DocumentsModule,
|
||||||
|
AnalyticsModule,
|
||||||
|
SearchModule,
|
||||||
// ADR-002 : hors DEMO_MODE, le module n'est pas enregistré → 404
|
// ADR-002 : hors DEMO_MODE, le module n'est pas enregistré → 404
|
||||||
...(demoModeEnabled() ? [DemoAuthModule] : []),
|
...(demoModeEnabled() ? [DemoAuthModule] : []),
|
||||||
],
|
],
|
||||||
|
|||||||
80
apps/api/src/assets/assets.controller.ts
Normal file
80
apps/api/src/assets/assets.controller.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
AssetComponentCreateSchema,
|
||||||
|
AssetCreateSchema,
|
||||||
|
AssetUpdateSchema,
|
||||||
|
type AssetComponentCreate,
|
||||||
|
type AssetCreate,
|
||||||
|
type AssetUpdate,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||||
|
import { RequirePermission } from '../permissions/require-permission.decorator';
|
||||||
|
import { AssetsService } from './assets.service';
|
||||||
|
|
||||||
|
@Controller('assets')
|
||||||
|
export class AssetsController {
|
||||||
|
constructor(private readonly assetsService: AssetsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequirePermission('ASSETS', 'view')
|
||||||
|
list() {
|
||||||
|
return this.assetsService.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Avant ':id' (ordre des routes) — authentification seule : le demandeur
|
||||||
|
* doit pouvoir désigner l'appareil qu'il signale. */
|
||||||
|
@Get('options')
|
||||||
|
options() {
|
||||||
|
return this.assetsService.options();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@RequirePermission('ASSETS', 'view')
|
||||||
|
get(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.assetsService.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequirePermission('ASSETS', 'create')
|
||||||
|
create(@Body(new ZodValidationPipe(AssetCreateSchema)) body: AssetCreate) {
|
||||||
|
return this.assetsService.create(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@RequirePermission('ASSETS', 'edit')
|
||||||
|
update(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body(new ZodValidationPipe(AssetUpdateSchema)) body: AssetUpdate,
|
||||||
|
) {
|
||||||
|
return this.assetsService.update(id, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/components')
|
||||||
|
@RequirePermission('ASSETS', 'edit')
|
||||||
|
addComponent(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body(new ZodValidationPipe(AssetComponentCreateSchema)) body: AssetComponentCreate,
|
||||||
|
) {
|
||||||
|
return this.assetsService.addComponent(id, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id/components/:componentId')
|
||||||
|
@RequirePermission('ASSETS', 'edit')
|
||||||
|
@HttpCode(204)
|
||||||
|
removeComponent(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Param('componentId', ParseUUIDPipe) componentId: string,
|
||||||
|
) {
|
||||||
|
return this.assetsService.removeComponent(id, componentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/api/src/assets/assets.module.ts
Normal file
10
apps/api/src/assets/assets.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AssetsController } from './assets.controller';
|
||||||
|
import { AssetsService } from './assets.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [AssetsController],
|
||||||
|
providers: [AssetsService],
|
||||||
|
exports: [AssetsService],
|
||||||
|
})
|
||||||
|
export class AssetsModule {}
|
||||||
199
apps/api/src/assets/assets.service.ts
Normal file
199
apps/api/src/assets/assets.service.ts
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import type {
|
||||||
|
AssetComponentCreate,
|
||||||
|
AssetComponentDto,
|
||||||
|
AssetCreate,
|
||||||
|
AssetDetail,
|
||||||
|
AssetDto,
|
||||||
|
AssetOptionsResponse,
|
||||||
|
AssetsResponse,
|
||||||
|
AssetUpdate,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
const assetInclude = {
|
||||||
|
category: true,
|
||||||
|
location: { include: { parent: true } },
|
||||||
|
_count: { select: { components: true } },
|
||||||
|
} satisfies Prisma.AssetInclude;
|
||||||
|
|
||||||
|
type AssetRow = Prisma.AssetGetPayload<{ include: typeof assetInclude }>;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AssetsService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
/** Options minimales pour le signalement — ouvert à tout rôle authentifié. */
|
||||||
|
async options(): Promise<AssetOptionsResponse> {
|
||||||
|
const rows = await this.prisma.asset.findMany({
|
||||||
|
include: { location: { include: { parent: true } } },
|
||||||
|
orderBy: { reference: 'asc' },
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
options: rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
reference: r.reference,
|
||||||
|
siteName: r.location.parent?.name ?? r.location.name,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(): Promise<AssetsResponse> {
|
||||||
|
const rows = await this.prisma.asset.findMany({
|
||||||
|
include: assetInclude,
|
||||||
|
orderBy: { reference: 'asc' },
|
||||||
|
});
|
||||||
|
return { assets: rows.map((r) => this.toDto(r)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(id: string): Promise<AssetDetail> {
|
||||||
|
const row = await this.prisma.asset.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { ...assetInclude, components: { include: { type: true } } },
|
||||||
|
});
|
||||||
|
if (!row) throw new NotFoundException('Appareil inconnu');
|
||||||
|
return {
|
||||||
|
...this.toDto(row),
|
||||||
|
components: row.components.map((c) => this.toComponentDto(c)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: AssetCreate): Promise<AssetDetail> {
|
||||||
|
await this.assertEquipmentCategory(dto.categoryId);
|
||||||
|
await this.assertLocation(dto.locationId);
|
||||||
|
if (dto.components?.length) {
|
||||||
|
await this.assertComponentTypes(dto.components.map((c) => c.typeId));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const created = await this.prisma.asset.create({
|
||||||
|
data: {
|
||||||
|
reference: dto.reference,
|
||||||
|
brand: dto.brand,
|
||||||
|
model: dto.model,
|
||||||
|
serialNumber: dto.serialNumber,
|
||||||
|
commissionedAt: dto.commissionedAt ? new Date(dto.commissionedAt) : undefined,
|
||||||
|
loadKg: dto.loadKg,
|
||||||
|
floors: dto.floors,
|
||||||
|
categoryId: dto.categoryId,
|
||||||
|
locationId: dto.locationId,
|
||||||
|
components: dto.components?.length
|
||||||
|
? { create: dto.components }
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
return this.get(created.id);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||||
|
throw new ConflictException('Cette référence est déjà utilisée');
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: AssetUpdate): Promise<AssetDetail> {
|
||||||
|
if (dto.categoryId) await this.assertEquipmentCategory(dto.categoryId);
|
||||||
|
if (dto.locationId) await this.assertLocation(dto.locationId);
|
||||||
|
try {
|
||||||
|
await this.prisma.asset.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...dto,
|
||||||
|
commissionedAt: dto.commissionedAt ? new Date(dto.commissionedAt) : undefined,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') {
|
||||||
|
throw new NotFoundException('Appareil inconnu');
|
||||||
|
}
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||||
|
throw new ConflictException('Cette référence est déjà utilisée');
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
return this.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async addComponent(
|
||||||
|
assetId: string,
|
||||||
|
dto: AssetComponentCreate,
|
||||||
|
): Promise<AssetComponentDto> {
|
||||||
|
const asset = await this.prisma.asset.findUnique({ where: { id: assetId } });
|
||||||
|
if (!asset) throw new NotFoundException('Appareil inconnu');
|
||||||
|
await this.assertComponentTypes([dto.typeId]);
|
||||||
|
const created = await this.prisma.assetComponent.create({
|
||||||
|
data: { assetId, ...dto },
|
||||||
|
include: { type: true },
|
||||||
|
});
|
||||||
|
return this.toComponentDto(created);
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeComponent(assetId: string, componentId: string): Promise<void> {
|
||||||
|
const { count } = await this.prisma.assetComponent.deleteMany({
|
||||||
|
where: { id: componentId, assetId },
|
||||||
|
});
|
||||||
|
if (count === 0) throw new NotFoundException('Organe inconnu');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertEquipmentCategory(categoryId: string): Promise<void> {
|
||||||
|
const category = await this.prisma.category.findUnique({ where: { id: categoryId } });
|
||||||
|
if (!category || category.kind !== 'EQUIPMENT' || !category.isActive) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'La catégorie choisie n’est pas une catégorie d’équipement active',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertLocation(locationId: string): Promise<void> {
|
||||||
|
const location = await this.prisma.location.findUnique({ where: { id: locationId } });
|
||||||
|
if (!location) throw new BadRequestException('Emplacement inconnu');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertComponentTypes(typeIds: string[]): Promise<void> {
|
||||||
|
const types = await this.prisma.category.findMany({
|
||||||
|
where: { id: { in: typeIds } },
|
||||||
|
});
|
||||||
|
const valid =
|
||||||
|
types.length === new Set(typeIds).size &&
|
||||||
|
types.every((t) => t.kind === 'COMPONENT_TYPE' && t.isActive);
|
||||||
|
if (!valid) {
|
||||||
|
throw new BadRequestException('Chaque organe doit avoir un type d’organe actif');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private toComponentDto(c: { id: string; typeId: string; designation: string | null; type: { name: string } }): AssetComponentDto {
|
||||||
|
return {
|
||||||
|
id: c.id,
|
||||||
|
typeId: c.typeId,
|
||||||
|
typeName: c.type.name,
|
||||||
|
designation: c.designation,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDto(row: AssetRow): AssetDto {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
reference: row.reference,
|
||||||
|
brand: row.brand,
|
||||||
|
model: row.model,
|
||||||
|
serialNumber: row.serialNumber,
|
||||||
|
commissionedAt: row.commissionedAt?.toISOString() ?? null,
|
||||||
|
loadKg: row.loadKg,
|
||||||
|
floors: row.floors,
|
||||||
|
status: row.status,
|
||||||
|
categoryId: row.categoryId,
|
||||||
|
categoryName: row.category.name,
|
||||||
|
locationId: row.locationId,
|
||||||
|
locationName: row.location.name,
|
||||||
|
siteName: row.location.parent?.name ?? row.location.name,
|
||||||
|
componentCount: row._count.components,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
import { Body, Controller, HttpCode, Post } from '@nestjs/common';
|
import { Body, Controller, HttpCode, Post } from '@nestjs/common';
|
||||||
import { LoginRequestSchema, type LoginRequest } from '@siop/shared';
|
import {
|
||||||
|
ActivateRequestSchema,
|
||||||
|
LoginRequestSchema,
|
||||||
|
type ActivateRequest,
|
||||||
|
type LoginRequest,
|
||||||
|
} from '@siop/shared';
|
||||||
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { Public } from './public.decorator';
|
import { Public } from './public.decorator';
|
||||||
@@ -14,4 +19,12 @@ export class AuthController {
|
|||||||
login(@Body(new ZodValidationPipe(LoginRequestSchema)) body: LoginRequest) {
|
login(@Body(new ZodValidationPipe(LoginRequestSchema)) body: LoginRequest) {
|
||||||
return this.authService.login(body.email, body.password);
|
return this.authService.login(body.email, body.password);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Publique par nature : la personne n'a pas encore de compte actif. */
|
||||||
|
@Public()
|
||||||
|
@Post('activate')
|
||||||
|
@HttpCode(200)
|
||||||
|
activate(@Body(new ZodValidationPipe(ActivateRequestSchema)) body: ActivateRequest) {
|
||||||
|
return this.authService.activate(body.token, body.password);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
BadRequestException,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
@@ -75,6 +76,35 @@ export class AuthService {
|
|||||||
return this.issueToken(user);
|
return this.issueToken(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** R1 — activation d'un compte invité : lien 7 jours, usage unique,
|
||||||
|
* choisit le mot de passe et connecte directement. */
|
||||||
|
async activate(token: string, password: string): Promise<AuthResponse> {
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { activationToken: token },
|
||||||
|
include: { role: true },
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
!user ||
|
||||||
|
!user.isActive ||
|
||||||
|
!user.activationExpiresAt ||
|
||||||
|
user.activationExpiresAt < new Date()
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Ce lien d’activation est invalide ou a expiré — demandez un nouveau lien',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const activated = await this.prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: {
|
||||||
|
passwordHash: await argon2.hash(password),
|
||||||
|
activationToken: null,
|
||||||
|
activationExpiresAt: null,
|
||||||
|
},
|
||||||
|
include: { role: true },
|
||||||
|
});
|
||||||
|
return this.issueToken(activated);
|
||||||
|
}
|
||||||
|
|
||||||
private async issueToken(user: UserWithRole): Promise<AuthResponse> {
|
private async issueToken(user: UserWithRole): Promise<AuthResponse> {
|
||||||
// Identité seule — les droits restent en base (invariant R0)
|
// Identité seule — les droits restent en base (invariant R0)
|
||||||
const accessToken = await this.jwtService.signAsync({
|
const accessToken = await this.jwtService.signAsync({
|
||||||
|
|||||||
44
apps/api/src/categories/categories.controller.ts
Normal file
44
apps/api/src/categories/categories.controller.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
CategoryCreateSchema,
|
||||||
|
CategoryUpdateSchema,
|
||||||
|
type CategoryCreate,
|
||||||
|
type CategoryUpdate,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||||
|
import { RequirePermission } from '../permissions/require-permission.decorator';
|
||||||
|
import { CategoriesService } from './categories.service';
|
||||||
|
|
||||||
|
@Controller('categories')
|
||||||
|
export class CategoriesController {
|
||||||
|
constructor(private readonly categoriesService: CategoriesService) {}
|
||||||
|
|
||||||
|
/** Donnée de référence lue par les formulaires — authentification seule. */
|
||||||
|
@Get()
|
||||||
|
list() {
|
||||||
|
return this.categoriesService.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequirePermission('SETTINGS', 'create')
|
||||||
|
create(@Body(new ZodValidationPipe(CategoryCreateSchema)) body: CategoryCreate) {
|
||||||
|
return this.categoriesService.create(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@RequirePermission('SETTINGS', 'edit')
|
||||||
|
update(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body(new ZodValidationPipe(CategoryUpdateSchema)) body: CategoryUpdate,
|
||||||
|
) {
|
||||||
|
return this.categoriesService.update(id, body);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/api/src/categories/categories.module.ts
Normal file
10
apps/api/src/categories/categories.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { CategoriesController } from './categories.controller';
|
||||||
|
import { CategoriesService } from './categories.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [CategoriesController],
|
||||||
|
providers: [CategoriesService],
|
||||||
|
exports: [CategoriesService],
|
||||||
|
})
|
||||||
|
export class CategoriesModule {}
|
||||||
75
apps/api/src/categories/categories.service.ts
Normal file
75
apps/api/src/categories/categories.service.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import {
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import type {
|
||||||
|
CategoriesResponse,
|
||||||
|
Category,
|
||||||
|
CategoryCreate,
|
||||||
|
CategoryUpdate,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CategoriesService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async list(): Promise<CategoriesResponse> {
|
||||||
|
const rows = await this.prisma.category.findMany({
|
||||||
|
include: { _count: { select: { assets: true, components: true } } },
|
||||||
|
orderBy: [{ kind: 'asc' }, { name: 'asc' }],
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
categories: rows.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
kind: c.kind,
|
||||||
|
name: c.name,
|
||||||
|
isActive: c.isActive,
|
||||||
|
usageCount: c.kind === 'EQUIPMENT' ? c._count.assets : c._count.components,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CategoryCreate): Promise<Category> {
|
||||||
|
try {
|
||||||
|
const created = await this.prisma.category.create({ data: dto });
|
||||||
|
return { ...created, usageCount: 0 };
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||||
|
throw new ConflictException('Ce nom existe déjà pour ce référentiel');
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renommage / (dés)activation — la suppression n'existe pas (invariant R1). */
|
||||||
|
async update(id: string, dto: CategoryUpdate): Promise<Category> {
|
||||||
|
try {
|
||||||
|
const updated = await this.prisma.category.update({
|
||||||
|
where: { id },
|
||||||
|
data: dto,
|
||||||
|
include: { _count: { select: { assets: true, components: true } } },
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
id: updated.id,
|
||||||
|
kind: updated.kind,
|
||||||
|
name: updated.name,
|
||||||
|
isActive: updated.isActive,
|
||||||
|
usageCount:
|
||||||
|
updated.kind === 'EQUIPMENT'
|
||||||
|
? updated._count.assets
|
||||||
|
: updated._count.components,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') {
|
||||||
|
throw new NotFoundException('Catégorie inconnue');
|
||||||
|
}
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||||
|
throw new ConflictException('Ce nom existe déjà pour ce référentiel');
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ 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'),
|
||||||
});
|
});
|
||||||
|
|
||||||
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
44
apps/api/src/locations/locations.controller.ts
Normal file
44
apps/api/src/locations/locations.controller.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
LocationCreateSchema,
|
||||||
|
LocationUpdateSchema,
|
||||||
|
type LocationCreate,
|
||||||
|
type LocationUpdate,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||||
|
import { RequirePermission } from '../permissions/require-permission.decorator';
|
||||||
|
import { LocationsService } from './locations.service';
|
||||||
|
|
||||||
|
@Controller('locations')
|
||||||
|
export class LocationsController {
|
||||||
|
constructor(private readonly locationsService: LocationsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequirePermission('LOCATIONS', 'view')
|
||||||
|
list() {
|
||||||
|
return this.locationsService.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequirePermission('LOCATIONS', 'create')
|
||||||
|
create(@Body(new ZodValidationPipe(LocationCreateSchema)) body: LocationCreate) {
|
||||||
|
return this.locationsService.create(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@RequirePermission('LOCATIONS', 'edit')
|
||||||
|
update(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body(new ZodValidationPipe(LocationUpdateSchema)) body: LocationUpdate,
|
||||||
|
) {
|
||||||
|
return this.locationsService.update(id, body);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/api/src/locations/locations.module.ts
Normal file
10
apps/api/src/locations/locations.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { LocationsController } from './locations.controller';
|
||||||
|
import { LocationsService } from './locations.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [LocationsController],
|
||||||
|
providers: [LocationsService],
|
||||||
|
exports: [LocationsService],
|
||||||
|
})
|
||||||
|
export class LocationsModule {}
|
||||||
134
apps/api/src/locations/locations.service.ts
Normal file
134
apps/api/src/locations/locations.service.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import type {
|
||||||
|
LocationCreate,
|
||||||
|
LocationDto,
|
||||||
|
LocationsResponse,
|
||||||
|
LocationUpdate,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
type LocationRow = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
parentId: string | null;
|
||||||
|
address: string | null;
|
||||||
|
city: string | null;
|
||||||
|
guardianName: string | null;
|
||||||
|
guardianPhone: string | null;
|
||||||
|
latitude: number | null;
|
||||||
|
longitude: number | null;
|
||||||
|
partnerId: string | null;
|
||||||
|
partner: { name: string } | null;
|
||||||
|
_count: { assets: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
const locationInclude = {
|
||||||
|
partner: { select: { name: true } },
|
||||||
|
_count: { select: { assets: true } },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class LocationsService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
/** Liste plate ; l'assetCount d'un SITE inclut les appareils de ses zones. */
|
||||||
|
async list(): Promise<LocationsResponse> {
|
||||||
|
const rows: LocationRow[] = await this.prisma.location.findMany({
|
||||||
|
include: locationInclude,
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
});
|
||||||
|
const childAssets = new Map<string, number>();
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.parentId) {
|
||||||
|
childAssets.set(
|
||||||
|
row.parentId,
|
||||||
|
(childAssets.get(row.parentId) ?? 0) + row._count.assets,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
locations: rows.map((row) =>
|
||||||
|
this.toDto(row, row._count.assets + (childAssets.get(row.id) ?? 0)),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: LocationCreate): Promise<LocationDto> {
|
||||||
|
await this.assertDepth(dto.parentId);
|
||||||
|
await this.assertClientPartner(dto.partnerId);
|
||||||
|
const created = await this.prisma.location.create({
|
||||||
|
data: dto,
|
||||||
|
include: locationInclude,
|
||||||
|
});
|
||||||
|
return this.toDto(created, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: LocationUpdate): Promise<LocationDto> {
|
||||||
|
const existing = await this.prisma.location.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { _count: { select: { children: true, assets: true } } },
|
||||||
|
});
|
||||||
|
if (!existing) throw new NotFoundException('Emplacement inconnu');
|
||||||
|
if (dto.parentId) {
|
||||||
|
if (dto.parentId === id) {
|
||||||
|
throw new BadRequestException('Un emplacement ne peut pas être son propre parent');
|
||||||
|
}
|
||||||
|
if (existing._count.children > 0) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Ce site a des zones : il ne peut pas devenir une zone (hiérarchie limitée à 2 niveaux)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await this.assertDepth(dto.parentId);
|
||||||
|
}
|
||||||
|
await this.assertClientPartner(dto.partnerId);
|
||||||
|
const updated = await this.prisma.location.update({
|
||||||
|
where: { id },
|
||||||
|
data: dto,
|
||||||
|
include: locationInclude,
|
||||||
|
});
|
||||||
|
return this.toDto(updated, updated._count.assets);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Invariant R1 : site → zone, jamais plus profond. */
|
||||||
|
private async assertDepth(parentId?: string): Promise<void> {
|
||||||
|
if (!parentId) return;
|
||||||
|
const parent = await this.prisma.location.findUnique({ where: { id: parentId } });
|
||||||
|
if (!parent) throw new BadRequestException('Emplacement parent inconnu');
|
||||||
|
if (parent.parentId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Hiérarchie limitée à 2 niveaux : une zone ne peut pas contenir d’emplacement',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** R3 : seul un tiers Client/syndic peut gérer un site. */
|
||||||
|
private async assertClientPartner(partnerId?: string | null): Promise<void> {
|
||||||
|
if (!partnerId) return;
|
||||||
|
const partner = await this.prisma.partner.findUnique({ where: { id: partnerId } });
|
||||||
|
if (!partner) throw new BadRequestException('Tiers inconnu');
|
||||||
|
if (partner.kind !== 'CLIENT') {
|
||||||
|
throw new BadRequestException('Seul un tiers « Client / syndic » peut être rattaché à un site');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDto(row: Omit<LocationRow, '_count'>, assetCount: number): LocationDto {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
parentId: row.parentId,
|
||||||
|
address: row.address,
|
||||||
|
city: row.city,
|
||||||
|
guardianName: row.guardianName,
|
||||||
|
guardianPhone: row.guardianPhone,
|
||||||
|
latitude: row.latitude,
|
||||||
|
longitude: row.longitude,
|
||||||
|
partnerId: row.partnerId,
|
||||||
|
partnerName: row.partner?.name ?? null,
|
||||||
|
assetCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
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 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
44
apps/api/src/teams/teams.controller.ts
Normal file
44
apps/api/src/teams/teams.controller.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
TeamCreateSchema,
|
||||||
|
TeamUpdateSchema,
|
||||||
|
type TeamCreate,
|
||||||
|
type TeamUpdate,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||||
|
import { RequirePermission } from '../permissions/require-permission.decorator';
|
||||||
|
import { TeamsService } from './teams.service';
|
||||||
|
|
||||||
|
@Controller('teams')
|
||||||
|
export class TeamsController {
|
||||||
|
constructor(private readonly teamsService: TeamsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequirePermission('PEOPLE_TEAMS', 'view')
|
||||||
|
list() {
|
||||||
|
return this.teamsService.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequirePermission('PEOPLE_TEAMS', 'create')
|
||||||
|
create(@Body(new ZodValidationPipe(TeamCreateSchema)) body: TeamCreate) {
|
||||||
|
return this.teamsService.create(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@RequirePermission('PEOPLE_TEAMS', 'edit')
|
||||||
|
update(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body(new ZodValidationPipe(TeamUpdateSchema)) body: TeamUpdate,
|
||||||
|
) {
|
||||||
|
return this.teamsService.update(id, body);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/api/src/teams/teams.module.ts
Normal file
10
apps/api/src/teams/teams.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TeamsController } from './teams.controller';
|
||||||
|
import { TeamsService } from './teams.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [TeamsController],
|
||||||
|
providers: [TeamsService],
|
||||||
|
exports: [TeamsService],
|
||||||
|
})
|
||||||
|
export class TeamsModule {}
|
||||||
92
apps/api/src/teams/teams.service.ts
Normal file
92
apps/api/src/teams/teams.service.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import {
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import type { Team, TeamCreate, TeamsResponse, TeamUpdate } from '@siop/shared';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
const teamInclude = {
|
||||||
|
members: { include: { role: true }, orderBy: { displayName: 'asc' } },
|
||||||
|
} satisfies Prisma.TeamInclude;
|
||||||
|
|
||||||
|
type TeamRow = Prisma.TeamGetPayload<{ include: typeof teamInclude }>;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TeamsService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async list(): Promise<TeamsResponse> {
|
||||||
|
const rows = await this.prisma.team.findMany({
|
||||||
|
include: teamInclude,
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
});
|
||||||
|
return { teams: rows.map((t) => this.toDto(t)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: TeamCreate): Promise<Team> {
|
||||||
|
try {
|
||||||
|
const created = await this.prisma.team.create({
|
||||||
|
data: {
|
||||||
|
name: dto.name,
|
||||||
|
description: dto.description,
|
||||||
|
members: dto.memberIds?.length
|
||||||
|
? { connect: dto.memberIds.map((id) => ({ id })) }
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
include: teamInclude,
|
||||||
|
});
|
||||||
|
return this.toDto(created);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||||
|
throw new ConflictException('Ce nom d’équipe existe déjà');
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: TeamUpdate): Promise<Team> {
|
||||||
|
try {
|
||||||
|
const updated = await this.prisma.team.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
name: dto.name,
|
||||||
|
description: dto.description,
|
||||||
|
members: dto.memberIds
|
||||||
|
? { set: dto.memberIds.map((memberId) => ({ id: memberId })) }
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
include: teamInclude,
|
||||||
|
});
|
||||||
|
return this.toDto(updated);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') {
|
||||||
|
throw new NotFoundException('Équipe inconnue');
|
||||||
|
}
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||||
|
throw new ConflictException('Ce nom d’équipe existe déjà');
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDto(row: TeamRow): Team {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
description: row.description,
|
||||||
|
members: row.members.map((m) => ({
|
||||||
|
id: m.id,
|
||||||
|
displayName: m.displayName,
|
||||||
|
roleName: m.role.name,
|
||||||
|
initials: m.displayName
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((w) => w[0]!.toUpperCase())
|
||||||
|
.join(''),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,21 +1,41 @@
|
|||||||
import { Controller, Get, NotFoundException } from '@nestjs/common';
|
import {
|
||||||
import type { MeResponse, RoleName } from '@siop/shared';
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
NotFoundException,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
InvitationCreateSchema,
|
||||||
|
UserUpdateSchema,
|
||||||
|
type InvitationCreate,
|
||||||
|
type MeResponse,
|
||||||
|
type RoleName,
|
||||||
|
type UserUpdate,
|
||||||
|
} from '@siop/shared';
|
||||||
import {
|
import {
|
||||||
AuthenticatedUser,
|
AuthenticatedUser,
|
||||||
CurrentUser,
|
CurrentUser,
|
||||||
} from '../auth/current-user.decorator';
|
} from '../auth/current-user.decorator';
|
||||||
|
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||||
import { PermissionsService } from '../permissions/permissions.service';
|
import { PermissionsService } from '../permissions/permissions.service';
|
||||||
|
import { RequirePermission } from '../permissions/require-permission.decorator';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { UsersService } from './users.service';
|
||||||
|
|
||||||
@Controller('users')
|
@Controller()
|
||||||
export class UsersController {
|
export class UsersController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly permissionsService: PermissionsService,
|
private readonly permissionsService: PermissionsService,
|
||||||
|
private readonly usersService: UsersService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** R0 : lecture du profil courant (gestion complète des utilisateurs en R1). */
|
/** Profil courant — pas de permission : chacun lit le sien. */
|
||||||
@Get('me')
|
@Get('users/me')
|
||||||
async me(@CurrentUser() current: AuthenticatedUser): Promise<MeResponse> {
|
async me(@CurrentUser() current: AuthenticatedUser): Promise<MeResponse> {
|
||||||
const user = await this.prisma.user.findUnique({
|
const user = await this.prisma.user.findUnique({
|
||||||
where: { id: current.userId },
|
where: { id: current.userId },
|
||||||
@@ -31,4 +51,37 @@ export class UsersController {
|
|||||||
permissions: await this.permissionsService.getForRole(user.roleId),
|
permissions: await this.permissionsService.getForRole(user.roleId),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('users')
|
||||||
|
@RequirePermission('PEOPLE_TEAMS', 'view')
|
||||||
|
list() {
|
||||||
|
return this.usersService.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('roles')
|
||||||
|
@RequirePermission('PEOPLE_TEAMS', 'view')
|
||||||
|
roles() {
|
||||||
|
return this.usersService.roles();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('users/invitations')
|
||||||
|
@RequirePermission('PEOPLE_TEAMS', 'create')
|
||||||
|
invite(@Body(new ZodValidationPipe(InvitationCreateSchema)) body: InvitationCreate) {
|
||||||
|
return this.usersService.invite(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('users/:id/invitation')
|
||||||
|
@RequirePermission('PEOPLE_TEAMS', 'edit')
|
||||||
|
resendInvitation(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.usersService.resendInvitation(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('users/:id')
|
||||||
|
@RequirePermission('PEOPLE_TEAMS', 'edit')
|
||||||
|
update(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body(new ZodValidationPipe(UserUpdateSchema)) body: UserUpdate,
|
||||||
|
) {
|
||||||
|
return this.usersService.update(id, body);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { UsersController } from './users.controller';
|
import { UsersController } from './users.controller';
|
||||||
|
import { UsersService } from './users.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [UsersController],
|
controllers: [UsersController],
|
||||||
|
providers: [UsersService],
|
||||||
|
exports: [UsersService],
|
||||||
})
|
})
|
||||||
export class UsersModule {}
|
export class UsersModule {}
|
||||||
|
|||||||
143
apps/api/src/users/users.service.ts
Normal file
143
apps/api/src/users/users.service.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import {
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import {
|
||||||
|
INVITATION_TTL_DAYS,
|
||||||
|
type InvitationCreate,
|
||||||
|
type InvitationResponse,
|
||||||
|
type RoleName,
|
||||||
|
type RolesResponse,
|
||||||
|
type UserAdmin,
|
||||||
|
type UsersResponse,
|
||||||
|
type UserUpdate,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
const userInclude = {
|
||||||
|
role: true,
|
||||||
|
teams: { orderBy: { name: 'asc' } },
|
||||||
|
} satisfies Prisma.UserInclude;
|
||||||
|
|
||||||
|
type UserRow = Prisma.UserGetPayload<{ include: typeof userInclude }>;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class UsersService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async list(): Promise<UsersResponse> {
|
||||||
|
const rows = await this.prisma.user.findMany({
|
||||||
|
include: userInclude,
|
||||||
|
orderBy: { displayName: 'asc' },
|
||||||
|
});
|
||||||
|
return { users: rows.map((u) => this.toDto(u)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async roles(): Promise<RolesResponse> {
|
||||||
|
const roles = await this.prisma.role.findMany({ orderBy: { name: 'asc' } });
|
||||||
|
return { roles: roles.map((r) => ({ id: r.id, name: r.name as RoleName })) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Invitation : compte créé SANS mot de passe + lien d'activation 7 jours.
|
||||||
|
* L'envoi d'email viendra plus tard — le web affiche le lien à copier. */
|
||||||
|
async invite(dto: InvitationCreate): Promise<InvitationResponse> {
|
||||||
|
const role = await this.prisma.role.findUnique({ where: { id: dto.roleId } });
|
||||||
|
if (!role) throw new NotFoundException('Rôle inconnu');
|
||||||
|
try {
|
||||||
|
const user = await this.prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: dto.email,
|
||||||
|
displayName: dto.displayName,
|
||||||
|
phone: dto.phone,
|
||||||
|
roleId: dto.roleId,
|
||||||
|
teams: dto.teamIds?.length
|
||||||
|
? { connect: dto.teamIds.map((id) => ({ id })) }
|
||||||
|
: undefined,
|
||||||
|
...this.freshToken(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
userId: user.id,
|
||||||
|
activationToken: user.activationToken!,
|
||||||
|
expiresAt: user.activationExpiresAt!.toISOString(),
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||||
|
throw new ConflictException('Cet email a déjà un compte');
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async resendInvitation(userId: string): Promise<InvitationResponse> {
|
||||||
|
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||||
|
if (!user) throw new NotFoundException('Personne inconnue');
|
||||||
|
if (user.passwordHash) {
|
||||||
|
throw new ConflictException('Ce compte est déjà activé');
|
||||||
|
}
|
||||||
|
const updated = await this.prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: this.freshToken(),
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
userId: updated.id,
|
||||||
|
activationToken: updated.activationToken!,
|
||||||
|
expiresAt: updated.activationExpiresAt!.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(userId: string, dto: UserUpdate): Promise<UserAdmin> {
|
||||||
|
try {
|
||||||
|
const updated = await this.prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: {
|
||||||
|
displayName: dto.displayName,
|
||||||
|
phone: dto.phone,
|
||||||
|
roleId: dto.roleId,
|
||||||
|
isActive: dto.isActive,
|
||||||
|
hourlyRate: dto.hourlyRate,
|
||||||
|
teams: dto.teamIds
|
||||||
|
? { set: dto.teamIds.map((id) => ({ id })) }
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
include: userInclude,
|
||||||
|
});
|
||||||
|
return this.toDto(updated);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') {
|
||||||
|
throw new NotFoundException('Personne inconnue');
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private freshToken() {
|
||||||
|
return {
|
||||||
|
activationToken: randomBytes(32).toString('base64url'),
|
||||||
|
activationExpiresAt: new Date(
|
||||||
|
Date.now() + INVITATION_TTL_DAYS * 24 * 60 * 60 * 1000,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDto(row: UserRow): UserAdmin {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
email: row.email,
|
||||||
|
displayName: row.displayName,
|
||||||
|
phone: row.phone,
|
||||||
|
role: { id: row.role.id, name: row.role.name as RoleName },
|
||||||
|
teams: row.teams.map((t) => ({ id: t.id, name: t.name })),
|
||||||
|
status: !row.isActive
|
||||||
|
? 'disabled'
|
||||||
|
: row.passwordHash
|
||||||
|
? 'active'
|
||||||
|
: 'invited',
|
||||||
|
isDemo: row.isDemo,
|
||||||
|
hourlyRate: row.hourlyRate === null ? null : Number(row.hourlyRate),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
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 {}
|
||||||
527
apps/api/src/work-orders/work-orders.service.ts
Normal file
527
apps/api/src/work-orders/work-orders.service.ts
Normal file
@@ -0,0 +1,527 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import {
|
||||||
|
BILAN_FIELD_LABELS,
|
||||||
|
REQUIRED_BILAN_FIELDS,
|
||||||
|
WORK_ORDER_STATUS_LABELS,
|
||||||
|
WORK_ORDER_TRANSITIONS,
|
||||||
|
type AssigneesUpdate,
|
||||||
|
type BilanField,
|
||||||
|
type ChecklistItemDto,
|
||||||
|
type ChecklistPatch,
|
||||||
|
type ReportUpsert,
|
||||||
|
type TransitionRequest,
|
||||||
|
type WorkOrderCreate,
|
||||||
|
type WorkOrderDetail,
|
||||||
|
type WorkOrderStatus,
|
||||||
|
type WorkOrdersResponse,
|
||||||
|
type WorkOrderSummary,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import type { AuthenticatedUser } from '../auth/current-user.decorator';
|
||||||
|
import { PartsService } from '../parts/parts.service';
|
||||||
|
import { PermissionsService } from '../permissions/permissions.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
const detailInclude = {
|
||||||
|
asset: { include: { location: { include: { parent: true } } } },
|
||||||
|
assignees: true,
|
||||||
|
createdBy: true,
|
||||||
|
request: { include: { requestedBy: true } },
|
||||||
|
events: { include: { by: true }, orderBy: { createdAt: 'desc' as const } },
|
||||||
|
checklist: { include: { doneBy: true }, orderBy: { label: 'asc' as const } },
|
||||||
|
// R3 — coûts figés
|
||||||
|
stockMovements: {
|
||||||
|
where: { kind: 'CONSUMPTION' as const },
|
||||||
|
include: { part: true },
|
||||||
|
orderBy: { createdAt: 'asc' as const },
|
||||||
|
},
|
||||||
|
laborTimes: { include: { user: true }, orderBy: { createdAt: 'asc' as const } },
|
||||||
|
report: {
|
||||||
|
include: {
|
||||||
|
doorState: true,
|
||||||
|
cabinPosition: true,
|
||||||
|
anomaly: true,
|
||||||
|
externalCause: true,
|
||||||
|
actionTaken: true,
|
||||||
|
componentConcerned: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies Prisma.WorkOrderInclude;
|
||||||
|
|
||||||
|
type DetailRow = Prisma.WorkOrderGetPayload<{ include: typeof detailInclude }>;
|
||||||
|
|
||||||
|
const initialsOf = (name: string) =>
|
||||||
|
name.split(/\s+/).filter(Boolean).slice(0, 2).map((w) => w[0]!.toUpperCase()).join('');
|
||||||
|
|
||||||
|
const toPersonne = (u: { id: string; displayName: string }) => ({
|
||||||
|
id: u.id,
|
||||||
|
displayName: u.displayName,
|
||||||
|
initials: initialsOf(u.displayName),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Champ du bilan → colonne du rapport (validation des référentiels). */
|
||||||
|
const REPORT_FIELDS: Record<string, BilanField> = {
|
||||||
|
doorStateId: 'DOOR_STATE',
|
||||||
|
cabinPositionId: 'CABIN_POSITION',
|
||||||
|
anomalyId: 'ANOMALY',
|
||||||
|
externalCauseId: 'EXTERNAL_CAUSE',
|
||||||
|
actionTakenId: 'ACTION_TAKEN',
|
||||||
|
componentConcernedId: 'COMPONENT_CONCERNED',
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WorkOrdersService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly permissions: PermissionsService,
|
||||||
|
private readonly parts: PartsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Invariant « voir autre » : sans le droit, on ne voit que SES OT. */
|
||||||
|
private async scope(user: AuthenticatedUser): Promise<Prisma.WorkOrderWhereInput> {
|
||||||
|
const viewOther = await this.permissions.can(user.roleId, 'WORK_ORDERS', 'viewOther');
|
||||||
|
if (viewOther) return {};
|
||||||
|
return {
|
||||||
|
OR: [
|
||||||
|
{ assignees: { some: { id: user.userId } } },
|
||||||
|
{ createdById: user.userId },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(user: AuthenticatedUser): Promise<WorkOrdersResponse> {
|
||||||
|
const rows = await this.prisma.workOrder.findMany({
|
||||||
|
where: await this.scope(user),
|
||||||
|
include: {
|
||||||
|
asset: { include: { location: { include: { parent: true } } } },
|
||||||
|
assignees: true,
|
||||||
|
},
|
||||||
|
// Dernière activité d'abord : un OT qui vient d'être clôturé remonte
|
||||||
|
// (« Interventions récentes » du tableau de bord = tête de liste).
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
});
|
||||||
|
// « personne bloquée » ACTIVE saute en tête, toujours (tri stable)
|
||||||
|
const urgenceActive = (r: (typeof rows)[number]) =>
|
||||||
|
r.priority === 'PERSON_TRAPPED' && r.status !== 'DONE' && r.status !== 'CANCELLED';
|
||||||
|
rows.sort((a, b) => Number(urgenceActive(b)) - Number(urgenceActive(a)));
|
||||||
|
return { workOrders: rows.map((r) => this.toSummary(r)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(id: string, user: AuthenticatedUser): Promise<WorkOrderDetail> {
|
||||||
|
const row = await this.prisma.workOrder.findFirst({
|
||||||
|
where: { AND: [{ id }, await this.scope(user)] },
|
||||||
|
include: detailInclude,
|
||||||
|
});
|
||||||
|
if (!row) throw new NotFoundException('OT inconnu');
|
||||||
|
return this.toDetail(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: WorkOrderCreate, user: AuthenticatedUser): Promise<WorkOrderDetail> {
|
||||||
|
const asset = await this.prisma.asset.findUnique({ where: { id: dto.assetId } });
|
||||||
|
if (!asset) throw new BadRequestException('Équipement inconnu');
|
||||||
|
const created = await this.createRaw({
|
||||||
|
...dto,
|
||||||
|
createdById: user.userId,
|
||||||
|
eventKind: 'CREATED',
|
||||||
|
eventMessage: 'OT créé',
|
||||||
|
});
|
||||||
|
return this.get(created.id, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Création partagée (OT manuel, approbation de demande, préventif R2.2). */
|
||||||
|
async createRaw(input: {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
type: WorkOrderCreate['type'];
|
||||||
|
priority?: WorkOrderCreate['priority'];
|
||||||
|
assetId: string;
|
||||||
|
dueDate?: string;
|
||||||
|
assigneeIds?: string[];
|
||||||
|
createdById?: string;
|
||||||
|
eventKind: string;
|
||||||
|
eventMessage: string;
|
||||||
|
}): Promise<{ id: string }> {
|
||||||
|
for (let essai = 0; ; essai++) {
|
||||||
|
try {
|
||||||
|
return await this.prisma.workOrder.create({
|
||||||
|
data: {
|
||||||
|
reference: await this.nextReference(),
|
||||||
|
title: input.title,
|
||||||
|
description: input.description,
|
||||||
|
type: input.type,
|
||||||
|
priority: input.priority ?? 'NONE',
|
||||||
|
assetId: input.assetId,
|
||||||
|
dueDate: input.dueDate ? new Date(input.dueDate) : undefined,
|
||||||
|
createdById: input.createdById,
|
||||||
|
assignees: input.assigneeIds?.length
|
||||||
|
? { connect: input.assigneeIds.map((id) => ({ id })) }
|
||||||
|
: undefined,
|
||||||
|
events: {
|
||||||
|
create: {
|
||||||
|
kind: input.eventKind,
|
||||||
|
message: input.eventMessage,
|
||||||
|
byId: input.createdById,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// collision de référence (concurrence) : on retente
|
||||||
|
if (
|
||||||
|
e instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
|
e.code === 'P2002' &&
|
||||||
|
essai < 3
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async nextReference(): Promise<string> {
|
||||||
|
// Séquence Postgres : insensible à la concurrence (jamais de collision)
|
||||||
|
const [{ nextval }] = await this.prisma.$queryRaw<[{ nextval: bigint }]>`
|
||||||
|
SELECT nextval('work_order_ref_seq')`;
|
||||||
|
return `OT-${new Date().getFullYear()}-${String(nextval).padStart(4, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Machine à états stricte + garde de clôture. */
|
||||||
|
async transition(
|
||||||
|
id: string,
|
||||||
|
dto: TransitionRequest,
|
||||||
|
user: AuthenticatedUser,
|
||||||
|
): Promise<WorkOrderDetail> {
|
||||||
|
const detail = await this.get(id, user);
|
||||||
|
if (!WORK_ORDER_TRANSITIONS[detail.status].includes(dto.to)) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Transition interdite : ${WORK_ORDER_STATUS_LABELS[detail.status]} → ${WORK_ORDER_STATUS_LABELS[dto.to]}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (dto.to === 'DONE' && detail.closureBlockers.length > 0) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Clôture bloquée : ${detail.closureBlockers.join(' ; ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const horodatage: Prisma.WorkOrderUpdateInput =
|
||||||
|
dto.to === 'IN_PROGRESS' && !detail.startedAt
|
||||||
|
? { startedAt: new Date() }
|
||||||
|
: dto.to === 'DONE'
|
||||||
|
? { completedAt: new Date() }
|
||||||
|
: dto.to === 'CANCELLED'
|
||||||
|
? { cancelledAt: new Date() }
|
||||||
|
: {};
|
||||||
|
await this.prisma.workOrder.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: dto.to,
|
||||||
|
...horodatage,
|
||||||
|
events: {
|
||||||
|
create: {
|
||||||
|
kind: 'STATUS_CHANGED',
|
||||||
|
message:
|
||||||
|
`${WORK_ORDER_STATUS_LABELS[detail.status]} → ${WORK_ORDER_STATUS_LABELS[dto.to]}` +
|
||||||
|
(dto.comment ? ` — ${dto.comment}` : ''),
|
||||||
|
byId: user.userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.get(id, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async comment(
|
||||||
|
id: string,
|
||||||
|
message: string,
|
||||||
|
user: AuthenticatedUser,
|
||||||
|
): Promise<WorkOrderDetail> {
|
||||||
|
await this.get(id, user); // périmètre
|
||||||
|
await this.prisma.workOrderEvent.create({
|
||||||
|
data: { workOrderId: id, kind: 'COMMENT', message, byId: user.userId },
|
||||||
|
});
|
||||||
|
return this.get(id, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setAssignees(
|
||||||
|
id: string,
|
||||||
|
dto: AssigneesUpdate,
|
||||||
|
user: AuthenticatedUser,
|
||||||
|
): Promise<WorkOrderDetail> {
|
||||||
|
await this.get(id, user);
|
||||||
|
const users = await this.prisma.user.findMany({
|
||||||
|
where: { id: { in: dto.assigneeIds } },
|
||||||
|
});
|
||||||
|
if (users.length !== new Set(dto.assigneeIds).size) {
|
||||||
|
throw new BadRequestException('Personne inconnue dans la liste');
|
||||||
|
}
|
||||||
|
await this.prisma.workOrder.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
assignees: { set: dto.assigneeIds.map((assigneeId) => ({ id: assigneeId })) },
|
||||||
|
events: {
|
||||||
|
create: {
|
||||||
|
kind: 'ASSIGNED',
|
||||||
|
message: users.length
|
||||||
|
? `Assigné(s) : ${users.map((u) => u.displayName).join(', ')}`
|
||||||
|
: 'Assignation retirée',
|
||||||
|
byId: user.userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.get(id, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsertReport(
|
||||||
|
id: string,
|
||||||
|
dto: ReportUpsert,
|
||||||
|
user: AuthenticatedUser,
|
||||||
|
): Promise<WorkOrderDetail> {
|
||||||
|
await this.get(id, user);
|
||||||
|
// Chaque valeur fournie doit appartenir au référentiel de SON champ (et être active)
|
||||||
|
for (const [colonne, field] of Object.entries(REPORT_FIELDS)) {
|
||||||
|
const valeur = dto[colonne as keyof ReportUpsert];
|
||||||
|
if (typeof valeur === 'string') {
|
||||||
|
const ref = await this.prisma.referenceValue.findUnique({ where: { id: valeur } });
|
||||||
|
if (!ref || ref.field !== field || !ref.isActive) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Valeur hors référentiel pour « ${BILAN_FIELD_LABELS[field]} »`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const donnees = {
|
||||||
|
note: dto.note,
|
||||||
|
doorStateId: dto.doorStateId,
|
||||||
|
cabinPositionId: dto.cabinPositionId,
|
||||||
|
anomalyId: dto.anomalyId,
|
||||||
|
externalCauseId: dto.externalCauseId,
|
||||||
|
actionTakenId: dto.actionTakenId,
|
||||||
|
componentConcernedId: dto.componentConcernedId,
|
||||||
|
};
|
||||||
|
await this.prisma.interventionReport.upsert({
|
||||||
|
where: { workOrderId: id },
|
||||||
|
update: donnees,
|
||||||
|
create: { workOrderId: id, ...donnees },
|
||||||
|
});
|
||||||
|
return this.get(id, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async patchChecklist(
|
||||||
|
id: string,
|
||||||
|
itemId: string,
|
||||||
|
dto: ChecklistPatch,
|
||||||
|
user: AuthenticatedUser,
|
||||||
|
): Promise<ChecklistItemDto> {
|
||||||
|
await this.get(id, user);
|
||||||
|
const { count } = await this.prisma.checklistItem.updateMany({
|
||||||
|
where: { id: itemId, workOrderId: id },
|
||||||
|
data: {
|
||||||
|
state: dto.state,
|
||||||
|
doneById: dto.state === 'PENDING' ? null : user.userId,
|
||||||
|
doneAt: dto.state === 'PENDING' ? null : new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (count === 0) throw new NotFoundException('Tâche inconnue');
|
||||||
|
const item = await this.prisma.checklistItem.findUniqueOrThrow({
|
||||||
|
where: { id: itemId },
|
||||||
|
include: { doneBy: true },
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
label: item.label,
|
||||||
|
state: item.state,
|
||||||
|
doneBy: item.doneBy ? toPersonne(item.doneBy) : null,
|
||||||
|
doneAt: item.doneAt?.toISOString() ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ————— R3 : coûts figés —————
|
||||||
|
|
||||||
|
/** Consommer une pièce : stock décrémenté, PRIX FIGÉ au moment T. */
|
||||||
|
async consumePart(
|
||||||
|
id: string,
|
||||||
|
dto: { partId: string; quantity: number },
|
||||||
|
user: AuthenticatedUser,
|
||||||
|
): Promise<WorkOrderDetail> {
|
||||||
|
const detail = await this.get(id, user); // périmètre + existence
|
||||||
|
if (detail.status === 'DONE' || detail.status === 'CANCELLED') {
|
||||||
|
throw new ConflictException('OT terminé : les coûts sont figés');
|
||||||
|
}
|
||||||
|
const { designation, unitPrice } = await this.parts.consume(
|
||||||
|
dto.partId,
|
||||||
|
dto.quantity,
|
||||||
|
id,
|
||||||
|
user,
|
||||||
|
);
|
||||||
|
await this.prisma.workOrderEvent.create({
|
||||||
|
data: {
|
||||||
|
workOrderId: id,
|
||||||
|
kind: 'PART_CONSUMED',
|
||||||
|
message: `${designation} × ${dto.quantity} (${unitPrice} MAD/u, prix figé)`,
|
||||||
|
byId: user.userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.get(id, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Saisir de la main-d'œuvre : TAUX FIGÉ à la saisie. */
|
||||||
|
async addLabor(
|
||||||
|
id: string,
|
||||||
|
dto: { minutes: number; userId?: string; note?: string },
|
||||||
|
user: AuthenticatedUser,
|
||||||
|
): Promise<WorkOrderDetail> {
|
||||||
|
const detail = await this.get(id, user);
|
||||||
|
if (detail.status === 'DONE' || detail.status === 'CANCELLED') {
|
||||||
|
throw new ConflictException('OT terminé : les coûts sont figés');
|
||||||
|
}
|
||||||
|
const cibleId = dto.userId ?? user.userId;
|
||||||
|
const cible = await this.prisma.user.findUnique({ where: { id: cibleId } });
|
||||||
|
if (!cible) throw new BadRequestException('Personne inconnue');
|
||||||
|
if (cible.hourlyRate === null) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Aucun taux horaire défini pour ${cible.displayName} — à renseigner dans Personnes`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await this.prisma.laborTime.create({
|
||||||
|
data: {
|
||||||
|
workOrderId: id,
|
||||||
|
userId: cibleId,
|
||||||
|
minutes: dto.minutes,
|
||||||
|
hourlyRate: cible.hourlyRate, // figé
|
||||||
|
note: dto.note,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.get(id, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ————— mapping —————
|
||||||
|
|
||||||
|
private toSummary(
|
||||||
|
row: Prisma.WorkOrderGetPayload<{
|
||||||
|
include: {
|
||||||
|
asset: { include: { location: { include: { parent: true } } } };
|
||||||
|
assignees: true;
|
||||||
|
};
|
||||||
|
}>,
|
||||||
|
): WorkOrderSummary {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
reference: row.reference,
|
||||||
|
title: row.title,
|
||||||
|
type: row.type,
|
||||||
|
status: row.status,
|
||||||
|
priority: row.priority,
|
||||||
|
assetId: row.assetId,
|
||||||
|
assetReference: row.asset.reference,
|
||||||
|
siteName: row.asset.location.parent?.name ?? row.asset.location.name,
|
||||||
|
dueDate: row.dueDate?.toISOString() ?? null,
|
||||||
|
assignees: row.assignees.map(toPersonne),
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDetail(row: DetailRow): WorkOrderDetail {
|
||||||
|
const report = row.report;
|
||||||
|
const blockers: string[] = [];
|
||||||
|
if (row.checklist.some((c) => c.state === 'PENDING')) {
|
||||||
|
blockers.push('la checklist est incomplète : traitez chaque tâche (Fait ou N-A)');
|
||||||
|
}
|
||||||
|
const manquants = REQUIRED_BILAN_FIELDS.filter((field) => {
|
||||||
|
const parChamp: Record<BilanField, string | null | undefined> = {
|
||||||
|
DOOR_STATE: report?.doorStateId,
|
||||||
|
CABIN_POSITION: report?.cabinPositionId,
|
||||||
|
ANOMALY: report?.anomalyId,
|
||||||
|
EXTERNAL_CAUSE: report?.externalCauseId,
|
||||||
|
ACTION_TAKEN: report?.actionTakenId,
|
||||||
|
COMPONENT_CONCERNED: report?.componentConcernedId,
|
||||||
|
};
|
||||||
|
return !parChamp[field];
|
||||||
|
});
|
||||||
|
if (manquants.length) {
|
||||||
|
blockers.push(
|
||||||
|
`bilan incomplet : ${manquants.map((f) => `« ${BILAN_FIELD_LABELS[f]} »`).join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const versValeur = (v: { id: string; label: string } | null) =>
|
||||||
|
v ? { id: v.id, label: v.label } : null;
|
||||||
|
// Coûts figés : chaque ligne porte le prix/taux du moment T
|
||||||
|
const partsCosts = row.stockMovements.map((m) => ({
|
||||||
|
id: m.id,
|
||||||
|
designation: m.part.designation,
|
||||||
|
quantity: -m.quantity,
|
||||||
|
unitPrice: Number(m.unitPrice ?? 0),
|
||||||
|
total: -m.quantity * Number(m.unitPrice ?? 0),
|
||||||
|
}));
|
||||||
|
const laborCosts = row.laborTimes.map((l) => ({
|
||||||
|
id: l.id,
|
||||||
|
displayName: l.user.displayName,
|
||||||
|
minutes: l.minutes,
|
||||||
|
hourlyRate: Number(l.hourlyRate),
|
||||||
|
total: Math.round((l.minutes / 60) * Number(l.hourlyRate) * 100) / 100,
|
||||||
|
note: l.note,
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
...this.toSummary(row),
|
||||||
|
description: row.description,
|
||||||
|
locationName: row.asset.location.name,
|
||||||
|
startedAt: row.startedAt?.toISOString() ?? null,
|
||||||
|
completedAt: row.completedAt?.toISOString() ?? null,
|
||||||
|
cancelledAt: row.cancelledAt?.toISOString() ?? null,
|
||||||
|
createdBy: row.createdBy ? toPersonne(row.createdBy) : null,
|
||||||
|
request: row.request
|
||||||
|
? {
|
||||||
|
id: row.request.id,
|
||||||
|
reference: row.request.reference,
|
||||||
|
requesterLabel:
|
||||||
|
row.request.requestedBy?.displayName ??
|
||||||
|
row.request.requesterName ??
|
||||||
|
'Portail',
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
events: row.events.map((e) => ({
|
||||||
|
id: e.id,
|
||||||
|
kind: e.kind,
|
||||||
|
message: e.message,
|
||||||
|
by: e.by ? toPersonne(e.by) : null,
|
||||||
|
createdAt: e.createdAt.toISOString(),
|
||||||
|
})),
|
||||||
|
checklist: row.checklist.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
label: c.label,
|
||||||
|
state: c.state,
|
||||||
|
doneBy: c.doneBy ? toPersonne(c.doneBy) : null,
|
||||||
|
doneAt: c.doneAt?.toISOString() ?? null,
|
||||||
|
})),
|
||||||
|
report: report
|
||||||
|
? {
|
||||||
|
note: report.note,
|
||||||
|
doorState: versValeur(report.doorState),
|
||||||
|
cabinPosition: versValeur(report.cabinPosition),
|
||||||
|
anomaly: versValeur(report.anomaly),
|
||||||
|
externalCause: versValeur(report.externalCause),
|
||||||
|
actionTaken: versValeur(report.actionTaken),
|
||||||
|
componentConcerned: versValeur(report.componentConcerned),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
costs: {
|
||||||
|
parts: partsCosts,
|
||||||
|
labor: laborCosts,
|
||||||
|
total:
|
||||||
|
Math.round(
|
||||||
|
(partsCosts.reduce((s, p) => s + p.total, 0) +
|
||||||
|
laborCosts.reduce((s, l) => s + l.total, 0)) *
|
||||||
|
100,
|
||||||
|
) / 100,
|
||||||
|
},
|
||||||
|
allowedTransitions: WORK_ORDER_TRANSITIONS[row.status as WorkOrderStatus],
|
||||||
|
closureBlockers: blockers,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
221
apps/api/test/administration.e2e-spec.ts
Normal file
221
apps/api/test/administration.e2e-spec.ts
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
/**
|
||||||
|
* E2E R1 — administration du référentiel : équipes, renommages, mises à jour
|
||||||
|
* et TOUS les refus (conflits 409, inconnus 404, invalides 400) — le contrat
|
||||||
|
* d'erreur fait partie du contrat.
|
||||||
|
*/
|
||||||
|
process.env.DEMO_MODE = 'true';
|
||||||
|
|
||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { seed } from '../prisma/seed';
|
||||||
|
import { AppModule } from '../src/app.module';
|
||||||
|
|
||||||
|
const GHOST = '00000000-0000-4000-8000-000000000000';
|
||||||
|
|
||||||
|
describe('Administration du référentiel (e2e)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let admin: string;
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
const http = () => request(app.getHttpServer());
|
||||||
|
const suffix = `adm-${Date.now().toString(36)}`;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await seed(prisma);
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
imports: [AppModule.forRoot()],
|
||||||
|
}).compile();
|
||||||
|
app = moduleRef.createNestApplication();
|
||||||
|
await app.init();
|
||||||
|
const { body } = await http().get('/auth/demo-accounts');
|
||||||
|
const compte = body.accounts.find(
|
||||||
|
(a: { roleName: string }) => a.roleName === 'Administrateur',
|
||||||
|
);
|
||||||
|
admin = (await http().post('/auth/demo-login').send({ userId: compte.id })).body
|
||||||
|
.accessToken;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.team.deleteMany({ where: { name: { contains: suffix } } });
|
||||||
|
await prisma.category.deleteMany({ where: { name: { contains: suffix } } });
|
||||||
|
await prisma.location.deleteMany({ where: { name: { contains: suffix } } });
|
||||||
|
await app?.close();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
const auth = () => ({ Authorization: `Bearer ${admin}` });
|
||||||
|
|
||||||
|
it('équipes : création, doublon 409, membres, 404', async () => {
|
||||||
|
const { body: users } = await http().get('/users').set(auth());
|
||||||
|
const ahmed = users.users.find(
|
||||||
|
(u: { email: string }) => u.email === 'technicien@demo.siop.ma',
|
||||||
|
);
|
||||||
|
|
||||||
|
const team = await http()
|
||||||
|
.post('/teams')
|
||||||
|
.set(auth())
|
||||||
|
.send({ name: `Équipe ${suffix}`, description: 'Zone de test' })
|
||||||
|
.expect(201);
|
||||||
|
expect(team.body.members).toHaveLength(0);
|
||||||
|
|
||||||
|
await http()
|
||||||
|
.post('/teams')
|
||||||
|
.set(auth())
|
||||||
|
.send({ name: `Équipe ${suffix}` })
|
||||||
|
.expect(409);
|
||||||
|
|
||||||
|
const updated = await http()
|
||||||
|
.patch(`/teams/${team.body.id}`)
|
||||||
|
.set(auth())
|
||||||
|
.send({ memberIds: [ahmed.id], description: 'Zone mise à jour' })
|
||||||
|
.expect(200);
|
||||||
|
expect(updated.body.members.map((m: { id: string }) => m.id)).toEqual([ahmed.id]);
|
||||||
|
|
||||||
|
const { body: teams } = await http().get('/teams').set(auth()).expect(200);
|
||||||
|
expect(teams.teams.some((t: { id: string }) => t.id === team.body.id)).toBe(true);
|
||||||
|
|
||||||
|
await http().patch(`/teams/${GHOST}`).set(auth()).send({ name: 'X' }).expect(404);
|
||||||
|
await http()
|
||||||
|
.patch(`/teams/${team.body.id}`)
|
||||||
|
.set(auth())
|
||||||
|
.send({ name: 'Casablanca Centre' })
|
||||||
|
.expect(409);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('catégories : création, doublon 409, renommage, 404', async () => {
|
||||||
|
const created = await http()
|
||||||
|
.post('/categories')
|
||||||
|
.set(auth())
|
||||||
|
.send({ kind: 'EQUIPMENT', name: `Catégorie ${suffix}` })
|
||||||
|
.expect(201);
|
||||||
|
await http()
|
||||||
|
.post('/categories')
|
||||||
|
.set(auth())
|
||||||
|
.send({ kind: 'EQUIPMENT', name: `Catégorie ${suffix}` })
|
||||||
|
.expect(409);
|
||||||
|
const renamed = await http()
|
||||||
|
.patch(`/categories/${created.body.id}`)
|
||||||
|
.set(auth())
|
||||||
|
.send({ name: `Catégorie ${suffix} v2` })
|
||||||
|
.expect(200);
|
||||||
|
expect(renamed.body.name).toBe(`Catégorie ${suffix} v2`);
|
||||||
|
await http()
|
||||||
|
.patch(`/categories/${GHOST}`)
|
||||||
|
.set(auth())
|
||||||
|
.send({ name: 'X' })
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emplacements : renommage, parent inconnu, auto-parent, site avec zones, 404', async () => {
|
||||||
|
const site = await http()
|
||||||
|
.post('/locations')
|
||||||
|
.set(auth())
|
||||||
|
.send({ name: `Site ${suffix}` })
|
||||||
|
.expect(201);
|
||||||
|
const zone = await http()
|
||||||
|
.post('/locations')
|
||||||
|
.set(auth())
|
||||||
|
.send({ name: `Zone ${suffix}`, parentId: site.body.id })
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
await http()
|
||||||
|
.patch(`/locations/${zone.body.id}`)
|
||||||
|
.set(auth())
|
||||||
|
.send({ guardianName: 'Gardien Test', guardianPhone: '06 00 00 00 00' })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
await http()
|
||||||
|
.post('/locations')
|
||||||
|
.set(auth())
|
||||||
|
.send({ name: `Orphelin ${suffix}`, parentId: GHOST })
|
||||||
|
.expect(400);
|
||||||
|
await http()
|
||||||
|
.patch(`/locations/${zone.body.id}`)
|
||||||
|
.set(auth())
|
||||||
|
.send({ parentId: zone.body.id })
|
||||||
|
.expect(400);
|
||||||
|
// Le site a une zone : il ne peut pas devenir zone lui-même
|
||||||
|
await http()
|
||||||
|
.patch(`/locations/${site.body.id}`)
|
||||||
|
.set(auth())
|
||||||
|
.send({ parentId: zone.body.id })
|
||||||
|
.expect(400);
|
||||||
|
await http().patch(`/locations/${GHOST}`).set(auth()).send({ name: 'X' }).expect(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appareils : statut, référence en conflit, emplacement inconnu, 404', async () => {
|
||||||
|
const { body } = await http().get('/assets').set(auth());
|
||||||
|
const a1 = body.assets.find((a: { reference: string }) => a.reference === 'A1');
|
||||||
|
const b1 = body.assets.find((a: { reference: string }) => a.reference === 'B1');
|
||||||
|
|
||||||
|
const updated = await http()
|
||||||
|
.patch(`/assets/${b1.id}`)
|
||||||
|
.set(auth())
|
||||||
|
.send({ status: 'UNDER_MAINTENANCE' })
|
||||||
|
.expect(200);
|
||||||
|
expect(updated.body.status).toBe('UNDER_MAINTENANCE');
|
||||||
|
await http()
|
||||||
|
.patch(`/assets/${b1.id}`)
|
||||||
|
.set(auth())
|
||||||
|
.send({ status: 'IN_SERVICE' })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
await http()
|
||||||
|
.patch(`/assets/${b1.id}`)
|
||||||
|
.set(auth())
|
||||||
|
.send({ reference: a1.reference })
|
||||||
|
.expect(409);
|
||||||
|
await http()
|
||||||
|
.patch(`/assets/${b1.id}`)
|
||||||
|
.set(auth())
|
||||||
|
.send({ locationId: GHOST })
|
||||||
|
.expect(400);
|
||||||
|
await http().get(`/assets/${GHOST}`).set(auth()).expect(404);
|
||||||
|
await http()
|
||||||
|
.patch(`/assets/${GHOST}`)
|
||||||
|
.set(auth())
|
||||||
|
.send({ brand: 'X' })
|
||||||
|
.expect(404);
|
||||||
|
await http()
|
||||||
|
.post(`/assets/${GHOST}/components`)
|
||||||
|
.set(auth())
|
||||||
|
.send({ typeId: GHOST })
|
||||||
|
.expect(404);
|
||||||
|
await http()
|
||||||
|
.delete(`/assets/${b1.id}/components/${GHOST}`)
|
||||||
|
.set(auth())
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('personnes : mise à jour rôle/équipes, invitation sur rôle inconnu, 404', async () => {
|
||||||
|
const { body: users } = await http().get('/users').set(auth());
|
||||||
|
const youssef = users.users.find(
|
||||||
|
(u: { email: string }) => u.email === 'technicien-limite@demo.siop.ma',
|
||||||
|
);
|
||||||
|
const { body: teams } = await http().get('/teams').set(auth());
|
||||||
|
const mohammedia = teams.teams.find((t: { name: string }) => t.name === 'Mohammedia');
|
||||||
|
|
||||||
|
const updated = await http()
|
||||||
|
.patch(`/users/${youssef.id}`)
|
||||||
|
.set(auth())
|
||||||
|
.send({ phone: '06 12 34 56 78', teamIds: [mohammedia.id] })
|
||||||
|
.expect(200);
|
||||||
|
expect(updated.body.teams.map((t: { name: string }) => t.name)).toContain('Mohammedia');
|
||||||
|
// remise en l'état (seed idempotent : les équipes ne sont pas réécrites)
|
||||||
|
const casa = teams.teams.find((t: { name: string }) => t.name === 'Casablanca Centre');
|
||||||
|
await http()
|
||||||
|
.patch(`/users/${youssef.id}`)
|
||||||
|
.set(auth())
|
||||||
|
.send({ teamIds: [casa.id] })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
await http().patch(`/users/${GHOST}`).set(auth()).send({ phone: '0' }).expect(404);
|
||||||
|
await http()
|
||||||
|
.post('/users/invitations')
|
||||||
|
.set(auth())
|
||||||
|
.send({ email: `x-${suffix}@spelev.ma`, displayName: 'X', roleId: GHOST })
|
||||||
|
.expect(404);
|
||||||
|
await http().post(`/users/${GHOST}/invitation`).set(auth()).expect(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
128
apps/api/test/invitation.e2e-spec.ts
Normal file
128
apps/api/test/invitation.e2e-spec.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
/**
|
||||||
|
* E2E R1 — invitation & activation : lien 7 jours, usage unique,
|
||||||
|
* aucun compte actif avant activation (décision maquettes R1).
|
||||||
|
*/
|
||||||
|
process.env.DEMO_MODE = 'true';
|
||||||
|
|
||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { seed } from '../prisma/seed';
|
||||||
|
import { AppModule } from '../src/app.module';
|
||||||
|
|
||||||
|
describe('Invitation → activation (e2e)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let admin: string;
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
const http = () => request(app.getHttpServer());
|
||||||
|
const email = `invite-${Date.now().toString(36)}@spelev.ma`;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await seed(prisma);
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
imports: [AppModule.forRoot()],
|
||||||
|
}).compile();
|
||||||
|
app = moduleRef.createNestApplication();
|
||||||
|
await app.init();
|
||||||
|
|
||||||
|
const { body } = await http().get('/auth/demo-accounts');
|
||||||
|
const compte = body.accounts.find(
|
||||||
|
(a: { roleName: string }) => a.roleName === 'Administrateur',
|
||||||
|
);
|
||||||
|
admin = (await http().post('/auth/demo-login').send({ userId: compte.id })).body
|
||||||
|
.accessToken;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.user.deleteMany({ where: { email } });
|
||||||
|
await app?.close();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invite, empêche la connexion avant activation, active, connecte', async () => {
|
||||||
|
const { body: roles } = await http()
|
||||||
|
.get('/roles')
|
||||||
|
.set('Authorization', `Bearer ${admin}`)
|
||||||
|
.expect(200);
|
||||||
|
const technicien = roles.roles.find(
|
||||||
|
(r: { name: string }) => r.name === 'Technicien',
|
||||||
|
);
|
||||||
|
|
||||||
|
// 1. Invitation → compte « invited », lien émis
|
||||||
|
const invitation = await http()
|
||||||
|
.post('/users/invitations')
|
||||||
|
.set('Authorization', `Bearer ${admin}`)
|
||||||
|
.send({ email, displayName: 'Recrue Test', roleId: technicien.id })
|
||||||
|
.expect(201);
|
||||||
|
expect(invitation.body.activationToken).toBeTruthy();
|
||||||
|
|
||||||
|
const { body: users } = await http()
|
||||||
|
.get('/users')
|
||||||
|
.set('Authorization', `Bearer ${admin}`);
|
||||||
|
const invited = users.users.find((u: { email: string }) => u.email === email);
|
||||||
|
expect(invited.status).toBe('invited');
|
||||||
|
|
||||||
|
// 2. Pas de connexion possible avant activation
|
||||||
|
await http()
|
||||||
|
.post('/auth/login')
|
||||||
|
.send({ email, password: 'MotDePasse!123' })
|
||||||
|
.expect(401);
|
||||||
|
|
||||||
|
// 3. Renvoi du lien : l'ancien devient invalide (usage unique)
|
||||||
|
const renvoi = await http()
|
||||||
|
.post(`/users/${invitation.body.userId}/invitation`)
|
||||||
|
.set('Authorization', `Bearer ${admin}`)
|
||||||
|
.expect(201);
|
||||||
|
await http()
|
||||||
|
.post('/auth/activate')
|
||||||
|
.send({ token: invitation.body.activationToken, password: 'MotDePasse!123' })
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
// 4. Activation → connecté directement, statut « active »
|
||||||
|
const activation = await http()
|
||||||
|
.post('/auth/activate')
|
||||||
|
.send({ token: renvoi.body.activationToken, password: 'MotDePasse!123' })
|
||||||
|
.expect(200);
|
||||||
|
expect(activation.body.user.role.name).toBe('Technicien');
|
||||||
|
await http()
|
||||||
|
.get('/users/me')
|
||||||
|
.set('Authorization', `Bearer ${activation.body.accessToken}`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
// 5. Le lien est consommé ; la connexion classique fonctionne désormais
|
||||||
|
await http()
|
||||||
|
.post('/auth/activate')
|
||||||
|
.send({ token: renvoi.body.activationToken, password: 'Autre!12345' })
|
||||||
|
.expect(400);
|
||||||
|
await http()
|
||||||
|
.post('/auth/login')
|
||||||
|
.send({ email, password: 'MotDePasse!123' })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
// 6. Renvoyer un lien sur un compte activé → refus
|
||||||
|
await http()
|
||||||
|
.post(`/users/${invitation.body.userId}/invitation`)
|
||||||
|
.set('Authorization', `Bearer ${admin}`)
|
||||||
|
.expect(409);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuse un email déjà connu (409) et un lien fantaisiste (400)', async () => {
|
||||||
|
const { body: roles } = await http()
|
||||||
|
.get('/roles')
|
||||||
|
.set('Authorization', `Bearer ${admin}`);
|
||||||
|
await http()
|
||||||
|
.post('/users/invitations')
|
||||||
|
.set('Authorization', `Bearer ${admin}`)
|
||||||
|
.send({
|
||||||
|
email: 'dispatcher@demo.siop.ma',
|
||||||
|
displayName: 'Doublon',
|
||||||
|
roleId: roles.roles[0].id,
|
||||||
|
})
|
||||||
|
.expect(409);
|
||||||
|
await http()
|
||||||
|
.post('/auth/activate')
|
||||||
|
.send({ token: 'jeton-inexistant-123', password: 'MotDePasse!123' })
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
141
apps/api/test/recherche.e2e-spec.ts
Normal file
141
apps/api/test/recherche.e2e-spec.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
/**
|
||||||
|
* E2E R3 (recette) — recherche globale : chaque famille de résultats est
|
||||||
|
* filtrée par la matrice, les OT respectent « voir autre » ; au passage,
|
||||||
|
* rattachements Tiers (siteNames) et période analytics paramétrable.
|
||||||
|
*/
|
||||||
|
process.env.DEMO_MODE = 'true';
|
||||||
|
|
||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { seed } from '../prisma/seed';
|
||||||
|
import { AppModule } from '../src/app.module';
|
||||||
|
|
||||||
|
describe('Recherche globale & corrections de recette R3 (e2e)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let yasmine: string; // Administrateur
|
||||||
|
let salma: string; // Dispatcher (voir autre)
|
||||||
|
let youssef: string; // Technicien limité : SES OT, ASSETS view, pas de LOCATIONS
|
||||||
|
let karim: string; // Demandeur : aucune des trois familles
|
||||||
|
let otId: string;
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
const http = () => request(app.getHttpServer());
|
||||||
|
const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
|
||||||
|
const suffix = `rech-${Date.now().toString(36)}`;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await seed(prisma);
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
imports: [AppModule.forRoot()],
|
||||||
|
}).compile();
|
||||||
|
app = moduleRef.createNestApplication();
|
||||||
|
await app.init();
|
||||||
|
const { body } = await http().get('/auth/demo-accounts');
|
||||||
|
const login = async (roleName: string) => {
|
||||||
|
const compte = body.accounts.find((a: { roleName: string }) => a.roleName === roleName);
|
||||||
|
return (await http().post('/auth/demo-login').send({ userId: compte.id })).body
|
||||||
|
.accessToken as string;
|
||||||
|
};
|
||||||
|
yasmine = await login('Administrateur');
|
||||||
|
salma = await login('Dispatcher');
|
||||||
|
youssef = await login('Technicien limité');
|
||||||
|
karim = await login('Demandeur');
|
||||||
|
|
||||||
|
// Un OT au titre unique, assigné à Ahmed (PAS à Youssef).
|
||||||
|
const { body: assets } = await http().get('/assets').set(auth(salma));
|
||||||
|
const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A1');
|
||||||
|
const ahmed = body.accounts.find((a: { roleName: string }) => a.roleName === 'Technicien');
|
||||||
|
const cree = await http()
|
||||||
|
.post('/work-orders')
|
||||||
|
.set(auth(salma))
|
||||||
|
.send({
|
||||||
|
title: `Recherche E2E ${suffix}`,
|
||||||
|
type: 'CORRECTIVE',
|
||||||
|
priority: 'LOW',
|
||||||
|
assetId: a1.id,
|
||||||
|
assigneeIds: [ahmed.id],
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
otId = cree.body.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.workOrder.deleteMany({ where: { title: { contains: suffix } } });
|
||||||
|
await app?.close();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispatcher (voir autre) : OT par titre, appareil par référence, site insensible à la casse', async () => {
|
||||||
|
const parTitre = await http().get(`/search?q=${suffix}`).set(auth(salma)).expect(200);
|
||||||
|
expect(parTitre.body.workOrders.map((w: { id: string }) => w.id)).toContain(otId);
|
||||||
|
|
||||||
|
const parRef = await http().get('/search?q=A1').set(auth(salma)).expect(200);
|
||||||
|
expect(parRef.body.assets.some((a: { reference: string }) => a.reference === 'A1')).toBe(true);
|
||||||
|
expect(parRef.body.assets[0].siteName).toBeTruthy();
|
||||||
|
|
||||||
|
const parSite = await http().get('/search?q=atlas').set(auth(salma)).expect(200);
|
||||||
|
expect(parSite.body.sites.some((s: { name: string }) => s.name === 'Tour Atlas')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('technicien limité : ne trouve pas 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
185
apps/api/test/referentiel.e2e-spec.ts
Normal file
185
apps/api/test/referentiel.e2e-spec.ts
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
/**
|
||||||
|
* E2E R1 — référentiel : parcours de recette (site → zone → appareil → organes)
|
||||||
|
* + invariants (profondeur 2, types d'organes, matrice de permissions vivante).
|
||||||
|
*/
|
||||||
|
process.env.DEMO_MODE = 'true';
|
||||||
|
|
||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { seed } from '../prisma/seed';
|
||||||
|
import { AppModule } from '../src/app.module';
|
||||||
|
|
||||||
|
describe('Référentiel (e2e)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let admin: string; // jetons
|
||||||
|
let technicien: string;
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
const http = () => request(app.getHttpServer());
|
||||||
|
const suffix = Date.now().toString(36); // données de test uniques et repérables
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await seed(prisma);
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
imports: [AppModule.forRoot()],
|
||||||
|
}).compile();
|
||||||
|
app = moduleRef.createNestApplication();
|
||||||
|
await app.init();
|
||||||
|
|
||||||
|
const { body } = await http().get('/auth/demo-accounts');
|
||||||
|
const login = async (roleName: string) => {
|
||||||
|
const compte = body.accounts.find((a: { roleName: string }) => a.roleName === roleName);
|
||||||
|
const res = await http().post('/auth/demo-login').send({ userId: compte.id });
|
||||||
|
return res.body.accessToken as string;
|
||||||
|
};
|
||||||
|
admin = await login('Administrateur');
|
||||||
|
technicien = await login('Technicien');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.asset.deleteMany({ where: { reference: { contains: suffix } } });
|
||||||
|
await prisma.location.deleteMany({ where: { name: { contains: suffix } } });
|
||||||
|
await app?.close();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parcours de recette : site → zone → appareil avec organes', async () => {
|
||||||
|
const site = await http()
|
||||||
|
.post('/locations')
|
||||||
|
.set('Authorization', `Bearer ${admin}`)
|
||||||
|
.send({
|
||||||
|
name: `Site Recette ${suffix}`,
|
||||||
|
city: 'Casablanca',
|
||||||
|
latitude: 33.59,
|
||||||
|
longitude: -7.61,
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
expect(site.body.parentId).toBeNull();
|
||||||
|
|
||||||
|
const zone = await http()
|
||||||
|
.post('/locations')
|
||||||
|
.set('Authorization', `Bearer ${admin}`)
|
||||||
|
.send({ name: `Hall Recette ${suffix}`, parentId: site.body.id })
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const { body: cats } = await http()
|
||||||
|
.get('/categories')
|
||||||
|
.set('Authorization', `Bearer ${admin}`);
|
||||||
|
const equipement = cats.categories.find(
|
||||||
|
(c: { kind: string }) => c.kind === 'EQUIPMENT',
|
||||||
|
);
|
||||||
|
const typeOrgane = cats.categories.find(
|
||||||
|
(c: { kind: string; isActive: boolean }) => c.kind === 'COMPONENT_TYPE' && c.isActive,
|
||||||
|
);
|
||||||
|
|
||||||
|
const asset = await http()
|
||||||
|
.post('/assets')
|
||||||
|
.set('Authorization', `Bearer ${admin}`)
|
||||||
|
.send({
|
||||||
|
reference: `T-${suffix}`,
|
||||||
|
brand: 'Otis',
|
||||||
|
model: 'Gen2',
|
||||||
|
categoryId: equipement.id,
|
||||||
|
locationId: zone.body.id,
|
||||||
|
components: [{ typeId: typeOrgane.id, designation: 'Organe de test' }],
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
expect(asset.body.components).toHaveLength(1);
|
||||||
|
expect(asset.body.siteName).toBe(`Site Recette ${suffix}`);
|
||||||
|
|
||||||
|
// Ajout puis retrait d'un organe
|
||||||
|
const organe = await http()
|
||||||
|
.post(`/assets/${asset.body.id}/components`)
|
||||||
|
.set('Authorization', `Bearer ${admin}`)
|
||||||
|
.send({ typeId: typeOrgane.id })
|
||||||
|
.expect(201);
|
||||||
|
await http()
|
||||||
|
.delete(`/assets/${asset.body.id}/components/${organe.body.id}`)
|
||||||
|
.set('Authorization', `Bearer ${admin}`)
|
||||||
|
.expect(204);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuse une hiérarchie de profondeur 3 (site → zone → ?)', async () => {
|
||||||
|
const site = await http()
|
||||||
|
.post('/locations')
|
||||||
|
.set('Authorization', `Bearer ${admin}`)
|
||||||
|
.send({ name: `Site Profond ${suffix}` })
|
||||||
|
.expect(201);
|
||||||
|
const zone = await http()
|
||||||
|
.post('/locations')
|
||||||
|
.set('Authorization', `Bearer ${admin}`)
|
||||||
|
.send({ name: `Zone Profonde ${suffix}`, parentId: site.body.id })
|
||||||
|
.expect(201);
|
||||||
|
await http()
|
||||||
|
.post('/locations')
|
||||||
|
.set('Authorization', `Bearer ${admin}`)
|
||||||
|
.send({ name: `Sous-zone ${suffix}`, parentId: zone.body.id })
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuse un appareil dont la catégorie n’est pas un équipement', async () => {
|
||||||
|
const { body: cats } = await http()
|
||||||
|
.get('/categories')
|
||||||
|
.set('Authorization', `Bearer ${admin}`);
|
||||||
|
const typeOrgane = cats.categories.find((c: { kind: string }) => c.kind === 'COMPONENT_TYPE');
|
||||||
|
const { body: locs } = await http()
|
||||||
|
.get('/locations')
|
||||||
|
.set('Authorization', `Bearer ${admin}`);
|
||||||
|
await http()
|
||||||
|
.post('/assets')
|
||||||
|
.set('Authorization', `Bearer ${admin}`)
|
||||||
|
.send({
|
||||||
|
reference: `KO-${suffix}`,
|
||||||
|
brand: 'Test',
|
||||||
|
categoryId: typeOrgane.id, // kind COMPONENT_TYPE → refus
|
||||||
|
locationId: locs.locations[0].id,
|
||||||
|
})
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('la matrice vit : un Technicien lit le parc mais ne crée rien', async () => {
|
||||||
|
await http().get('/assets').set('Authorization', `Bearer ${technicien}`).expect(200);
|
||||||
|
await http().get('/locations').set('Authorization', `Bearer ${technicien}`).expect(200);
|
||||||
|
await http()
|
||||||
|
.post('/locations')
|
||||||
|
.set('Authorization', `Bearer ${technicien}`)
|
||||||
|
.send({ name: `Interdit ${suffix}` })
|
||||||
|
.expect(403);
|
||||||
|
await http().get('/users').set('Authorization', `Bearer ${technicien}`).expect(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('le seed de la maquette est en place (A1 et ses organes, statuts)', async () => {
|
||||||
|
const { body } = await http()
|
||||||
|
.get('/assets')
|
||||||
|
.set('Authorization', `Bearer ${admin}`)
|
||||||
|
.expect(200);
|
||||||
|
const a1 = body.assets.find((a: { reference: string }) => a.reference === 'A1');
|
||||||
|
expect(a1.siteName).toBe('Résidence Al Manar');
|
||||||
|
expect(a1.componentCount).toBe(4);
|
||||||
|
const b2 = body.assets.find((a: { reference: string }) => a.reference === 'B2');
|
||||||
|
expect(b2.status).toBe('OUT_OF_SERVICE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('catégorie utilisée : désactivable, jamais supprimable (la route n’existe pas)', async () => {
|
||||||
|
const { body: cats } = await http()
|
||||||
|
.get('/categories')
|
||||||
|
.set('Authorization', `Bearer ${admin}`);
|
||||||
|
const used = cats.categories.find((c: { usageCount: number }) => c.usageCount > 0);
|
||||||
|
await http()
|
||||||
|
.delete(`/categories/${used.id}`)
|
||||||
|
.set('Authorization', `Bearer ${admin}`)
|
||||||
|
.expect(404); // pas de DELETE dans le contrat
|
||||||
|
const off = await http()
|
||||||
|
.patch(`/categories/${used.id}`)
|
||||||
|
.set('Authorization', `Bearer ${admin}`)
|
||||||
|
.send({ isActive: false })
|
||||||
|
.expect(200);
|
||||||
|
expect(off.body.isActive).toBe(false);
|
||||||
|
await http()
|
||||||
|
.patch(`/categories/${used.id}`)
|
||||||
|
.set('Authorization', `Bearer ${admin}`)
|
||||||
|
.send({ isActive: true })
|
||||||
|
.expect(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
import { execSync } from 'node:child_process';
|
import { execSync } from 'node:child_process';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
/** Seed idempotent avant le parcours : mêmes comptes en local et en CI. */
|
/** Seed idempotent + purge des restes du run précédent : mêmes données
|
||||||
|
* en local et en CI. */
|
||||||
export default function globalSetup() {
|
export default function globalSetup() {
|
||||||
execSync('pnpm --filter @siop/api seed', {
|
const cwd = fileURLToPath(new URL('../../..', import.meta.url));
|
||||||
cwd: fileURLToPath(new URL('../../..', import.meta.url)),
|
execSync('pnpm --filter @siop/api exec tsx prisma/cleanup-e2e.ts', {
|
||||||
|
cwd,
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
env: process.env,
|
env: process.env,
|
||||||
});
|
});
|
||||||
|
execSync('pnpm --filter @siop/api seed', { cwd, stdio: 'inherit', env: process.env });
|
||||||
}
|
}
|
||||||
|
|||||||
77
apps/web/e2e/parcours-portail.spec.ts
Normal file
77
apps/web/e2e/parcours-portail.spec.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* R2.4 — LA boucle produit : le gardien scanne le QR (aucun compte), signale,
|
||||||
|
* l'équipe traite (via API), le gardien voit « Résolu » sur son téléphone.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const suffix = Date.now().toString(36);
|
||||||
|
const API = 'http://localhost:3000';
|
||||||
|
|
||||||
|
test('portail QR : signalement sans compte → traitement → suivi Résolu', async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
// 1 · Le gardien scanne le QR de l'ascenseur A1 → formulaire prérempli
|
||||||
|
await page.goto('/q/A1');
|
||||||
|
await expect(page.getByText(/Ascenseur A1/)).toBeVisible();
|
||||||
|
await expect(page.getByText('détecté par le QR')).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByLabel('Décrivez le problème').fill(`E2E ${suffix} — voyant cabine éteint`);
|
||||||
|
await page.getByRole('button', { name: 'Envoyer le signalement' }).click();
|
||||||
|
await expect(page.getByText('l\'équipe SPELEV est prévenue')).toBeVisible();
|
||||||
|
|
||||||
|
// « Mes signalements » : Reçu, sans jargon
|
||||||
|
const ligne = page.locator('.item-liste', { hasText: suffix });
|
||||||
|
await expect(ligne).toBeVisible();
|
||||||
|
await expect(ligne.locator('.etape.fait')).toHaveText('✓ Reçu');
|
||||||
|
|
||||||
|
// 2 · Côté SPELEV (via API — le parcours UI est couvert par parcours-r2) :
|
||||||
|
// approbation → démarrage → bilan → clôture
|
||||||
|
const comptes = await (await request.get(`${API}/auth/demo-accounts`)).json();
|
||||||
|
const salmaId = comptes.accounts.find((a: { roleName: string }) => a.roleName === 'Dispatcher').id;
|
||||||
|
const jeton = (await (await request.post(`${API}/auth/demo-login`, { data: { userId: salmaId } })).json()).accessToken;
|
||||||
|
const auth = { Authorization: `Bearer ${jeton}` };
|
||||||
|
|
||||||
|
const demandes = await (await request.get(`${API}/requests`, { headers: auth })).json();
|
||||||
|
const demande = demandes.requests.find((r: { description: string }) => r.description.includes(suffix));
|
||||||
|
expect(demande).toBeTruthy();
|
||||||
|
|
||||||
|
const ot = await (
|
||||||
|
await request.post(`${API}/requests/${demande.id}/approve`, { headers: auth, data: {} })
|
||||||
|
).json();
|
||||||
|
await request.post(`${API}/work-orders/${ot.id}/transition`, {
|
||||||
|
headers: auth,
|
||||||
|
data: { to: 'IN_PROGRESS' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3 · Le gardien recharge : « Intervention » franchie
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.locator('.item-liste', { hasText: suffix }).getByText('✓ Intervention')).toBeVisible();
|
||||||
|
|
||||||
|
// 4 · Clôture avec bilan → « Résolu »
|
||||||
|
const refs = await (await request.get(`${API}/reference-values`, { headers: auth })).json();
|
||||||
|
const valeur = (field: string) =>
|
||||||
|
refs.referenceValues.find((v: { field: string; isActive: boolean }) => v.field === field && v.isActive).id;
|
||||||
|
await request.put(`${API}/work-orders/${ot.id}/report`, {
|
||||||
|
headers: auth,
|
||||||
|
data: {
|
||||||
|
doorStateId: valeur('DOOR_STATE'),
|
||||||
|
actionTakenId: valeur('ACTION_TAKEN'),
|
||||||
|
componentConcernedId: valeur('COMPONENT_CONCERNED'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const cloture = await request.post(`${API}/work-orders/${ot.id}/transition`, {
|
||||||
|
headers: auth,
|
||||||
|
data: { to: 'DONE' },
|
||||||
|
});
|
||||||
|
expect(cloture.ok()).toBe(true);
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.locator('.item-liste', { hasText: suffix }).getByText('✓ Résolu')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('QR inconnu : message clair, pas d’écran technique', async ({ page }) => {
|
||||||
|
await page.goto('/q/INTROUVABLE-99');
|
||||||
|
await expect(page.getByText(/aucun appareil connu/)).toBeVisible();
|
||||||
|
});
|
||||||
96
apps/web/e2e/parcours-r1.spec.ts
Normal file
96
apps/web/e2e/parcours-r1.spec.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recette R1 (plan de releases) : créer un site → un ascenseur → ses organes
|
||||||
|
* → imprimer l'étiquette QR → inviter un technicien (activation incluse).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const suffix = Date.now().toString(36);
|
||||||
|
|
||||||
|
test('recette R1 complète, de la création du site à l’activation de l’invité', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
// Connexion démo (Administrateur — création tous azimuts)
|
||||||
|
await page.goto('/connexion');
|
||||||
|
await page.getByRole('button', { name: /Yasmine Alaoui/ }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Tableau de bord' })).toBeVisible();
|
||||||
|
|
||||||
|
// 1 · Sites : le parc seedé est là, on crée un site
|
||||||
|
await page.getByRole('link', { name: 'Sites' }).click();
|
||||||
|
await expect(page.getByRole('cell', { name: /Tour Atlas/ })).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: '+ Nouveau site' }).click();
|
||||||
|
await page.getByLabel('Nom du site *').fill(`Site E2E ${suffix}`);
|
||||||
|
await page.getByLabel('Ville').fill('Casablanca');
|
||||||
|
await page.getByRole('button', { name: 'Créer le site' }).click();
|
||||||
|
|
||||||
|
// → fiche du site créé ; on lui ajoute une zone
|
||||||
|
await expect(page.getByRole('heading', { name: `Site E2E ${suffix}` })).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: '+ Ajouter un emplacement' }).click();
|
||||||
|
await page.getByLabel('Nom de la zone *').fill(`Hall E2E ${suffix}`);
|
||||||
|
await page.getByRole('button', { name: 'Ajouter', exact: true }).click();
|
||||||
|
await expect(page.locator('.arbre')).toContainText(`Hall E2E ${suffix}`);
|
||||||
|
|
||||||
|
// 2 · Nouvel ascenseur avec un organe
|
||||||
|
await page.getByRole('button', { name: '+ Nouvel ascenseur' }).click();
|
||||||
|
await page.getByLabel('Référence *').fill(`E2E-${suffix}`);
|
||||||
|
await page.getByLabel('Marque *').fill('Otis');
|
||||||
|
await page.getByLabel('Catégorie *').selectOption({ label: 'Ascenseur électrique' });
|
||||||
|
await page.getByLabel('Emplacement dans le site *').selectOption({ label: `Hall E2E ${suffix}` });
|
||||||
|
await page.getByLabel("Type de l'organe 1").selectOption({ label: 'Portes cabine / palières' });
|
||||||
|
await page.getByLabel("Désignation de l'organe 1").fill('Fermator 40/10');
|
||||||
|
await page.getByRole('button', { name: "Créer l'ascenseur (QR généré)" }).click();
|
||||||
|
|
||||||
|
// → fiche appareil : identité, organe, QR
|
||||||
|
await expect(
|
||||||
|
page.getByRole('heading', { name: new RegExp(`Ascenseur E2E-${suffix}`) }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.getByText('Portes cabine / palières')).toBeVisible();
|
||||||
|
|
||||||
|
// 3 · Étiquette QR imprimable
|
||||||
|
await page.getByRole('button', { name: 'Étiquette QR' }).click();
|
||||||
|
await expect(page.locator('.etiquette')).toContainText(`Ascenseur E2E-${suffix}`);
|
||||||
|
await expect(page.locator('.etiquette .consigne')).toContainText('Scannez ce code');
|
||||||
|
|
||||||
|
// 4 · Inviter un technicien → récupérer le lien d'activation
|
||||||
|
await page.getByRole('link', { name: 'Personnes' }).click();
|
||||||
|
await page.getByRole('button', { name: '+ Inviter' }).click();
|
||||||
|
await page.getByLabel('Nom complet *').fill('Recrue E2E');
|
||||||
|
await page.getByLabel('Email professionnel *').fill(`recrue-${suffix}@spelev.ma`);
|
||||||
|
await page.getByLabel('Rôle *').selectOption({ label: 'Technicien' });
|
||||||
|
await page.getByRole('button', { name: "Envoyer l'invitation" }).click();
|
||||||
|
const lien = await page.getByTestId('lien-activation').textContent();
|
||||||
|
expect(lien).toContain('/activation?token=');
|
||||||
|
await page.getByRole('button', { name: 'Fermer' }).click();
|
||||||
|
await expect(page.getByRole('cell', { name: /Recrue E2E/ })).toBeVisible();
|
||||||
|
await expect(page.getByText('Invitation envoyée')).toBeVisible();
|
||||||
|
|
||||||
|
// 5 · La recrue active son compte via le lien et entre directement
|
||||||
|
await page.locator('.compte').click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Se déconnecter' }).click();
|
||||||
|
await page.goto(lien!);
|
||||||
|
await page.getByLabel('Mot de passe (8 caractères minimum)').fill('MotDePasse!123');
|
||||||
|
await page.getByLabel('Confirmez le mot de passe').fill('MotDePasse!123');
|
||||||
|
await page.getByRole('button', { name: 'Activer mon compte' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Tableau de bord' })).toBeVisible();
|
||||||
|
await expect(page.locator('.topbar')).toContainText('Recrue E2E');
|
||||||
|
await expect(page.locator('.topbar')).toContainText('Technicien');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('la matrice pilote l’UI : un technicien voit le parc sans boutons de création', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto('/connexion');
|
||||||
|
await page.getByRole('button', { name: /Ahmed Benali/ }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Tableau de bord' })).toBeVisible();
|
||||||
|
|
||||||
|
// Catégories (SETTINGS) absent de sa sidebar ; Ascenseurs accessible
|
||||||
|
await expect(page.locator('.nav')).not.toContainText('Catégories');
|
||||||
|
await page.getByRole('link', { name: 'Ascenseurs' }).click();
|
||||||
|
await expect(page.getByRole('cell', { name: 'A1', exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: '+ Nouvel ascenseur' })).toHaveCount(0);
|
||||||
|
|
||||||
|
// Fiche : statuts visibles, pas de retrait d'organe
|
||||||
|
await page.getByRole('cell', { name: 'A1', exact: true }).click();
|
||||||
|
await expect(page.getByText('Treuil / machinerie')).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: /Retirer/ })).toHaveCount(0);
|
||||||
|
});
|
||||||
117
apps/web/e2e/parcours-r2.spec.ts
Normal file
117
apps/web/e2e/parcours-r2.spec.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recette R2 (plan de releases) : demande gardien → approbation → OT →
|
||||||
|
* intervention → bilan codé → clôture → suivi demandeur ; génération du
|
||||||
|
* préventif du mois (idempotente) ; compteur croissant.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const suffix = Date.now().toString(36);
|
||||||
|
|
||||||
|
test('recette R2 : demande → OT → bilan codé → clôture → suivi demandeur', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
// 1 · Karim (demandeur) signale depuis l'app
|
||||||
|
await page.goto('/connexion');
|
||||||
|
await page.getByRole('button', { name: /Karim Doukkali/ }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Mes signalements' })).toBeVisible();
|
||||||
|
await page.getByRole('link', { name: /Demandes/ }).click();
|
||||||
|
await page.getByRole('button', { name: '+ Signaler' }).click();
|
||||||
|
await page.getByLabel('Équipement *').selectOption({ index: 1 });
|
||||||
|
await page.getByLabel('Décrivez le problème *').fill(`E2E ${suffix} — porte qui claque très fort`);
|
||||||
|
await page.getByRole('button', { name: 'Envoyer le signalement' }).click();
|
||||||
|
await expect(page.getByRole('cell', { name: new RegExp(suffix) })).toBeVisible();
|
||||||
|
|
||||||
|
// 2 · Salma (dispatcher) approuve → OT créé, assigné à Ahmed
|
||||||
|
await page.locator('.compte').click();
|
||||||
|
await page.getByRole('menuitem', { name: /Salma Idrissi/ }).click();
|
||||||
|
await page.getByRole('link', { name: /Demandes/ }).click();
|
||||||
|
await page.getByRole('cell', { name: new RegExp(suffix) }).click();
|
||||||
|
await page.getByLabel('Assigner à').selectOption({ index: 1 }); // Ahmed Benali
|
||||||
|
await page.getByRole('button', { name: "Approuver → créer l'OT" }).click();
|
||||||
|
|
||||||
|
// → fiche OT
|
||||||
|
await expect(page.getByRole('heading', { name: new RegExp(suffix) })).toBeVisible();
|
||||||
|
await expect(page.locator('.entete-page .st')).toHaveText('Ouvert');
|
||||||
|
|
||||||
|
// 3 · Démarrer, tenter de clôturer (bloqué : bilan), remplir le bilan, clôturer
|
||||||
|
await page.getByRole('button', { name: 'Démarrer' }).click();
|
||||||
|
await expect(page.locator('.entete-page .st')).toHaveText('En cours');
|
||||||
|
const boutonCloture = page.getByRole('button', { name: "Clôturer l'intervention" });
|
||||||
|
await expect(boutonCloture).toBeDisabled(); // garde visible dans l'UI
|
||||||
|
await expect(page.locator('.avertissement')).toContainText('bilan incomplet');
|
||||||
|
|
||||||
|
await page.getByLabel(/État des portes/).selectOption({ label: 'Fonctionnement normal' });
|
||||||
|
await page.getByLabel(/Action réalisée/).selectOption({ label: 'Réglage' });
|
||||||
|
await page.getByLabel(/Élément concerné/).selectOption({ label: 'Portes' });
|
||||||
|
await expect(boutonCloture).toBeEnabled();
|
||||||
|
await boutonCloture.click();
|
||||||
|
await expect(page.locator('.entete-page .st')).toHaveText('Terminé');
|
||||||
|
|
||||||
|
// 4 · Karim suit : sa demande est Résolue
|
||||||
|
await page.locator('.compte').click();
|
||||||
|
await page.getByRole('menuitem', { name: /Karim Doukkali/ }).click();
|
||||||
|
await page.getByRole('link', { name: /Demandes/ }).click();
|
||||||
|
const ligne = page.locator('tr', { hasText: suffix });
|
||||||
|
await expect(ligne.locator('.st')).toHaveText('Résolue');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('préventif : générer le mois — puis regénérer ne double rien', async ({ page }) => {
|
||||||
|
await page.goto('/connexion');
|
||||||
|
await page.getByRole('button', { name: /Yasmine Alaoui/ }).click();
|
||||||
|
await page.getByRole('link', { name: 'Préventif' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Préventif' })).toBeVisible();
|
||||||
|
await expect(page.getByText('Essai du parachute')).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /Générer la grille de/ }).click();
|
||||||
|
await expect(page.locator('.gen-info')).toContainText('regénérer ne double rien');
|
||||||
|
// Second appel : 0 créé
|
||||||
|
await page.getByRole('button', { name: /Générer la grille de/ }).click();
|
||||||
|
await expect(page.locator('.gen-info')).toContainText('0 OT créés');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('urgence « personne bloquée » : interrupteur → chip topbar + bandeau + tête de liste', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto('/connexion');
|
||||||
|
await page.getByRole('button', { name: /Salma Idrissi/ }).click();
|
||||||
|
await page.getByRole('link', { name: /Ordres de travail/ }).click();
|
||||||
|
await page.getByRole('button', { name: '+ Nouvel OT' }).click();
|
||||||
|
await page.getByLabel('Objet *').fill(`E2E ${suffix} — personne bloquée cage B`);
|
||||||
|
await page.getByLabel('Équipement *').selectOption({ index: 1 });
|
||||||
|
await page.getByRole('switch', { name: 'Personne bloquée' }).click();
|
||||||
|
await page.getByRole('button', { name: "Créer l'OT (statut Ouvert)" }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: new RegExp(suffix) })).toBeVisible();
|
||||||
|
|
||||||
|
// chip topbar (pulse) présent et bandeau sur le tableau de bord
|
||||||
|
await expect(page.locator('.topbar .chip-urgence')).toContainText('personne');
|
||||||
|
await page.getByRole('link', { name: 'Tableau de bord' }).click();
|
||||||
|
await expect(page.locator('.bandeau-urgence').first()).toBeVisible();
|
||||||
|
// tête de liste OT
|
||||||
|
await page.getByRole('link', { name: /Ordres de travail/ }).click();
|
||||||
|
await expect(page.locator('tbody tr').first()).toHaveClass(/ligne-bloque/);
|
||||||
|
|
||||||
|
// nettoyage : annuler l'OT d'urgence créé (motif via prompt)
|
||||||
|
await page.locator('tbody tr').first().click();
|
||||||
|
page.once('dialog', (d) => d.accept('Test e2e — nettoyage'));
|
||||||
|
await page.getByRole('button', { name: 'Annuler', exact: true }).click();
|
||||||
|
await expect(page.locator('.entete-page .st')).toHaveText('Annulé');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('compteurs : relevé croissant accepté, régression refusée', async ({ page }) => {
|
||||||
|
await page.goto('/connexion');
|
||||||
|
await page.getByRole('button', { name: /Ahmed Benali/ }).click();
|
||||||
|
await page.getByRole('link', { name: 'Ascenseurs' }).click();
|
||||||
|
await page.getByRole('cell', { name: 'A1', exact: true }).click();
|
||||||
|
await page.getByRole('button', { name: 'Compteurs' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: /Compteurs — Ascenseur A1/ })).toBeVisible();
|
||||||
|
|
||||||
|
const saisie = page.getByLabel('Nouveau relevé — Heures de marche');
|
||||||
|
await saisie.fill('99999999');
|
||||||
|
await page.getByRole('button', { name: 'Enregistrer le relevé' }).first().click();
|
||||||
|
await expect(page.locator('.chrono').first()).toContainText('99 999 999');
|
||||||
|
|
||||||
|
await saisie.fill('12');
|
||||||
|
await page.getByRole('button', { name: 'Enregistrer le relevé' }).first().click();
|
||||||
|
await expect(page.locator('.erreur-form')).toContainText('ne redescend pas');
|
||||||
|
});
|
||||||
209
apps/web/e2e/parcours-r3.spec.ts
Normal file
209
apps/web/e2e/parcours-r3.spec.ts
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recette R3 (plan de releases) : pièce sous le seuil → BC prérempli →
|
||||||
|
* réception (le stock remonte, l'alerte s'éteint) ; coûts réels sur OT
|
||||||
|
* (prix figé + taux figé) ; document rattaché téléversé puis téléchargé ;
|
||||||
|
* statistiques lisibles par la Vue seule.
|
||||||
|
* Chaque test crée SES données (marquées E2E, purgées au global-setup).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const suffix = Date.now().toString(36);
|
||||||
|
|
||||||
|
/** PNG 1×1 valide — assez pour tester upload/download réels via MinIO. */
|
||||||
|
const PNG_1PX = Buffer.from(
|
||||||
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
|
||||||
|
'base64',
|
||||||
|
);
|
||||||
|
|
||||||
|
async function connexionDemo(page: import('@playwright/test').Page, nom: RegExp) {
|
||||||
|
await page.goto('/connexion');
|
||||||
|
await page.getByRole('button', { name: nom }).click();
|
||||||
|
await expect(page.locator('.topbar')).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changerDeCompte(page: import('@playwright/test').Page, nom: RegExp) {
|
||||||
|
await page.locator('.compte').click();
|
||||||
|
await page.getByRole('menuitem', { name: nom }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
test('recette R3 : tiers → pièce sous seuil → BC prérempli → réception → alerte éteinte', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const fournisseur = `E2E Fournisseur ${suffix}`;
|
||||||
|
const piece = `E2E Pièce ${suffix}`;
|
||||||
|
|
||||||
|
// 1 · Nadia (gestionnaire) crée le fournisseur
|
||||||
|
await connexionDemo(page, /Nadia Berrada/);
|
||||||
|
await page.getByRole('link', { name: 'Tiers' }).click();
|
||||||
|
await page.getByRole('button', { name: '+ Nouveau tiers' }).click();
|
||||||
|
await page.getByLabel('Nom *').fill(fournisseur);
|
||||||
|
await page.getByLabel('Type *').selectOption('SUPPLIER');
|
||||||
|
await page.getByRole('button', { name: 'Créer', exact: true }).click();
|
||||||
|
await expect(page.getByRole('cell', { name: fournisseur })).toBeVisible();
|
||||||
|
|
||||||
|
// 2 · Nouvelle pièce (seuil 5, prix initial 100) → fiche
|
||||||
|
await page.getByRole('link', { name: 'Stock & achats' }).click();
|
||||||
|
await page.getByRole('button', { name: '+ Nouvelle pièce' }).click();
|
||||||
|
await page.getByLabel('Désignation *').fill(piece);
|
||||||
|
await page.getByLabel("Seuil d'alerte").fill('5');
|
||||||
|
await page.getByLabel('Prix initial (MAD)').fill('100');
|
||||||
|
await page.getByLabel('Fournisseur', { exact: true }).selectOption({ label: fournisseur });
|
||||||
|
await page.getByRole('button', { name: 'Créer la pièce' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: piece })).toBeVisible();
|
||||||
|
|
||||||
|
// 3 · Entrée de 2 → stock 2 < seuil 5 : alerte
|
||||||
|
await page.getByRole('button', { name: 'Entrée de stock' }).click();
|
||||||
|
await page.getByLabel(/Quantité/).fill('2');
|
||||||
|
await page.getByRole('button', { name: 'Tracer le mouvement' }).click();
|
||||||
|
await expect(page.locator('.entete-page .stock-bas')).toHaveText('Sous le seuil');
|
||||||
|
await expect(page.locator('dd', { hasText: 'seuil : 5' })).toContainText('2');
|
||||||
|
|
||||||
|
// 4 · « Préparer le BC » → formulaire prérempli (fournisseur, qté 8, PU 100)
|
||||||
|
await page.getByRole('button', { name: 'Préparer le BC' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Nouveau bon de commande' })).toBeVisible();
|
||||||
|
await expect(page.getByLabel('Fournisseur *')).toHaveValue(/.+/);
|
||||||
|
await expect(page.getByLabel('Quantité de la ligne 1')).toHaveValue('8'); // seuil − stock + 5
|
||||||
|
await expect(page.getByLabel('Prix unitaire de la ligne 1')).toHaveValue('100');
|
||||||
|
await page.getByRole('button', { name: 'Créer le BC (brouillon)' }).click();
|
||||||
|
|
||||||
|
// 5 · Envoyer puis réceptionner — le panneau suit le BC
|
||||||
|
const ligneBC = page.locator('tr', { hasText: fournisseur }).first();
|
||||||
|
await ligneBC.click();
|
||||||
|
await expect(page.locator('.panneau .st')).toHaveText('Brouillon');
|
||||||
|
await page.getByRole('button', { name: 'Envoyer au fournisseur' }).click();
|
||||||
|
await expect(page.locator('.panneau .st')).toHaveText('Envoyé');
|
||||||
|
await page.getByRole('button', { name: /Réceptionner/ }).click();
|
||||||
|
await expect(page.locator('.panneau .st')).toHaveText('Reçu');
|
||||||
|
|
||||||
|
// 6 · Le stock est remonté (2 + 8 = 10), l'alerte est éteinte
|
||||||
|
await page.getByRole('link', { name: 'Stock & achats' }).click();
|
||||||
|
const lignePiece = page.locator('tr', { hasText: piece });
|
||||||
|
await expect(lignePiece.locator('td').nth(2)).toHaveText('10');
|
||||||
|
await expect(lignePiece.locator('.stock-bas')).toHaveCount(0);
|
||||||
|
|
||||||
|
// 7 · La fiche pièce raconte tout : entrée manuelle + réception à PU figé
|
||||||
|
await lignePiece.click();
|
||||||
|
await expect(page.locator('.mvt.entree')).toHaveCount(2);
|
||||||
|
await expect(page.getByText('100 MAD/u')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recette R3 : coûts réels sur OT (prix + taux figés) + document + statistiques', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const piece = `E2E PièceOT ${suffix}`;
|
||||||
|
const titreOT = `E2E ${suffix} — coûts`;
|
||||||
|
|
||||||
|
// 1 · Nadia crée une pièce avec du stock (10 à 100 MAD)
|
||||||
|
await connexionDemo(page, /Nadia Berrada/);
|
||||||
|
await page.getByRole('link', { name: 'Stock & achats' }).click();
|
||||||
|
await page.getByRole('button', { name: '+ Nouvelle pièce' }).click();
|
||||||
|
await page.getByLabel('Désignation *').fill(piece);
|
||||||
|
await page.getByLabel('Prix initial (MAD)').fill('100');
|
||||||
|
await page.getByRole('button', { name: 'Créer la pièce' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: piece })).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'Entrée de stock' }).click();
|
||||||
|
await page.getByLabel(/Quantité/).fill('10');
|
||||||
|
await page.getByRole('button', { name: 'Tracer le mouvement' }).click();
|
||||||
|
await expect(page.locator('dd', { hasText: 'seuil : 0' })).toContainText('10');
|
||||||
|
|
||||||
|
// 2 · Salma (dispatcher) crée l'OT et l'assigne à Ahmed
|
||||||
|
await changerDeCompte(page, /Salma Idrissi/);
|
||||||
|
await page.getByRole('link', { name: /Ordres de travail/ }).click();
|
||||||
|
await page.getByRole('button', { name: '+ Nouvel OT' }).click();
|
||||||
|
await page.getByLabel('Objet *').fill(titreOT);
|
||||||
|
await page.getByLabel('Équipement *').selectOption({ index: 1 });
|
||||||
|
await page.getByLabel('Assigner à').selectOption({ label: 'Ahmed Benali' });
|
||||||
|
await page.getByRole('button', { name: "Créer l'OT (statut Ouvert)" }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: titreOT })).toBeVisible();
|
||||||
|
|
||||||
|
// 3 · Ahmed (technicien) démarre, consomme 2 pièces, saisit 1 h 30
|
||||||
|
await changerDeCompte(page, /Ahmed Benali/);
|
||||||
|
await page.getByRole('link', { name: /Ordres de travail/ }).click();
|
||||||
|
await page.getByRole('cell', { name: titreOT }).click();
|
||||||
|
await page.getByRole('button', { name: 'Démarrer' }).click();
|
||||||
|
await expect(page.locator('.entete-page .st')).toHaveText('En cours');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: '+ Consommer une pièce' }).click();
|
||||||
|
await page.getByLabel('Pièce *').selectOption({ label: `${piece} — stock : 10` });
|
||||||
|
await expect(page.getByText('Prix figé à la consommation')).toBeVisible();
|
||||||
|
await page.getByLabel('Quantité *').fill('2');
|
||||||
|
await page.getByRole('button', { name: /^Consommer/ }).click();
|
||||||
|
await expect(page.locator('.item-liste', { hasText: `${piece} × 2` })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: '+ Saisir du temps' }).click();
|
||||||
|
await page.getByLabel('Heures').fill('1');
|
||||||
|
await page.getByLabel('Minutes').fill('30');
|
||||||
|
await page.getByRole('button', { name: 'Enregistrer' }).click();
|
||||||
|
// 2 × 100 + 1,5 h × 120 MAD/h (taux seedé d'Ahmed) = 380 MAD
|
||||||
|
await expect(page.getByText('1 h 30 × 120 MAD/h')).toBeVisible();
|
||||||
|
await expect(page.locator('.total .num')).toHaveText('380 MAD');
|
||||||
|
|
||||||
|
// 4 · Ahmed joint une photo — puis la retélécharge (aller-retour MinIO réel)
|
||||||
|
await page.getByLabel('Choisir un fichier').setInputFiles({
|
||||||
|
name: `photo-e2e-${suffix}.png`,
|
||||||
|
mimeType: 'image/png',
|
||||||
|
buffer: PNG_1PX,
|
||||||
|
});
|
||||||
|
await expect(page.getByText(`photo-e2e-${suffix}.png`)).toBeVisible();
|
||||||
|
const attenteTelechargement = page.waitForEvent('download');
|
||||||
|
await page.getByRole('button', { name: 'Télécharger' }).click();
|
||||||
|
expect((await attenteTelechargement).suggestedFilename()).toBe(`photo-e2e-${suffix}.png`);
|
||||||
|
|
||||||
|
// 5 · Rachid (vue seule) lit les statistiques
|
||||||
|
await changerDeCompte(page, /Rachid Bennis/);
|
||||||
|
await page.getByRole('link', { name: 'Statistiques', exact: true }).click();
|
||||||
|
await expect(page.getByText('OT clôturés · 12 mois')).toBeVisible();
|
||||||
|
await expect(page.getByText('Pannes par organe · 12 mois')).toBeVisible();
|
||||||
|
await expect(page.getByText('Top équipements en coût · 12 mois')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recette R3 corrigée : recherche ⌘K, tri/filtre stock, période stats, rattachements, dépôt', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await connexionDemo(page, /Yasmine Alaoui/);
|
||||||
|
|
||||||
|
// Recherche globale : ⌘K → site (insensible à la casse), clic → fiche site
|
||||||
|
await page.keyboard.press('ControlOrMeta+k');
|
||||||
|
await page.keyboard.type('atlas');
|
||||||
|
await page.locator('.resultat', { hasText: 'Tour Atlas' }).first().click();
|
||||||
|
await expect(page).toHaveURL(/\/sites\//);
|
||||||
|
// le site porte son syndic (rattachement seedé, éditable par l'admin)
|
||||||
|
await expect(page.getByLabel('Client / syndic du site')).toHaveValue(/.+/);
|
||||||
|
|
||||||
|
// Recherche OT au clavier seul (Entrée sur le premier résultat)
|
||||||
|
await page.keyboard.press('ControlOrMeta+k');
|
||||||
|
await page.keyboard.type('bruit');
|
||||||
|
await page.locator('.resultat', { hasText: 'Bruit anormal' }).first().waitFor();
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await expect(page).toHaveURL(/\/ot\//);
|
||||||
|
|
||||||
|
// Stock : les pièces sous le seuil remontent en tête, filtre fournisseur,
|
||||||
|
// « Entrée de stock » accessible depuis la liste (choix de la pièce)
|
||||||
|
await page.getByRole('link', { name: 'Stock & achats' }).click();
|
||||||
|
await expect(page.locator('tbody tr').first().locator('.stock-bas')).toHaveText(/Sous le seuil/);
|
||||||
|
await page.getByLabel('Filtrer par fournisseur').selectOption({ label: 'Lubmaroc' });
|
||||||
|
await expect(page.locator('tbody tr', { hasText: 'P-0042' })).toHaveCount(1);
|
||||||
|
await page.getByLabel('Filtrer par fournisseur').selectOption({ index: 0 });
|
||||||
|
await page.getByRole('button', { name: 'Entrée de stock' }).click();
|
||||||
|
await expect(page.getByLabel('Pièce *')).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'Annuler' }).click();
|
||||||
|
|
||||||
|
// Fiche pièce : le fournisseur est un lien vers les tiers
|
||||||
|
await page.getByRole('cell', { name: 'P-0113' }).click();
|
||||||
|
await page.getByRole('link', { name: 'Ascentech Maroc' }).click();
|
||||||
|
await expect(page).toHaveURL(/\/tiers/);
|
||||||
|
// Tiers : les syndics affichent leurs sites
|
||||||
|
await expect(page.getByRole('cell', { name: 'Tour Atlas' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('cell', { name: 'Résidence Al Manar' })).toBeVisible();
|
||||||
|
|
||||||
|
// Statistiques : la période est un vrai sélecteur
|
||||||
|
await page.getByRole('link', { name: 'Statistiques', exact: true }).click();
|
||||||
|
await page.getByLabel('Période').selectOption('3');
|
||||||
|
await expect(page.getByText('OT clôturés · 3 mois')).toBeVisible();
|
||||||
|
|
||||||
|
// Bibliothèque : filtre « Rattaché à » + zone de glisser-déposer
|
||||||
|
await page.getByRole('link', { name: 'Fichiers' }).click();
|
||||||
|
await expect(page.getByLabel('Filtrer par appareil')).toBeVisible();
|
||||||
|
await expect(page.locator('.zone-depot')).toBeVisible();
|
||||||
|
});
|
||||||
@@ -15,21 +15,26 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/manrope": "^5.1.0",
|
"@fontsource/manrope": "^5.1.0",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.19",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
||||||
"@siop/shared": "workspace:*",
|
"@siop/shared": "workspace:*",
|
||||||
"@tanstack/react-query": "^5.62.0",
|
"@tanstack/react-query": "^5.62.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^0.469.0",
|
"lucide-react": "^0.469.0",
|
||||||
"openapi-fetch": "^0.13.4",
|
"openapi-fetch": "^0.13.4",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
|
"react-leaflet": "^5.0.0",
|
||||||
"react-router-dom": "^7.1.0",
|
"react-router-dom": "^7.1.0",
|
||||||
"tailwind-merge": "^2.6.0"
|
"tailwind-merge": "^2.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.61.1",
|
"@playwright/test": "^1.61.1",
|
||||||
"@tailwindcss/vite": "^4.1.0",
|
"@tailwindcss/vite": "^4.1.0",
|
||||||
|
"@types/leaflet": "^1.9.21",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
|||||||
@@ -1,36 +1,72 @@
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { BrowserRouter, Route, Routes } from 'react-router-dom';
|
import { BrowserRouter, Route, Routes } from 'react-router-dom';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
import { Coquille } from '@/layout/coquille';
|
import { Coquille } from '@/layout/coquille';
|
||||||
|
import PageAchats from '@/pages/achats';
|
||||||
|
import PageActivation from '@/pages/activation';
|
||||||
|
import PageAscenseurs from '@/pages/ascenseurs';
|
||||||
|
import PageBibliotheque from '@/pages/bibliotheque';
|
||||||
|
import PageCategories from '@/pages/categories';
|
||||||
|
import PageCompteurs from '@/pages/compteurs';
|
||||||
import PageConnexion from '@/pages/connexion';
|
import PageConnexion from '@/pages/connexion';
|
||||||
|
import PageDemandes from '@/pages/demandes';
|
||||||
import PageDesign from '@/pages/design';
|
import PageDesign from '@/pages/design';
|
||||||
|
import PageEtiquette from '@/pages/etiquette';
|
||||||
|
import PageFicheAscenseur from '@/pages/fiche-ascenseur';
|
||||||
|
import PageFicheOT from '@/pages/fiche-ot';
|
||||||
|
import PageFichePiece from '@/pages/fiche-piece';
|
||||||
|
import PageFicheSite from '@/pages/fiche-site';
|
||||||
|
import PageNouvelAchat from '@/pages/nouvel-achat';
|
||||||
|
import PageNouvelAscenseur from '@/pages/nouvel-ascenseur';
|
||||||
|
import PageNouvelOT from '@/pages/nouvel-ot';
|
||||||
|
import PageOrdresTravail from '@/pages/ordres-travail';
|
||||||
|
import PagePersonnes from '@/pages/personnes';
|
||||||
|
import PagePortail from '@/pages/portail';
|
||||||
|
import PagePreventif from '@/pages/preventif';
|
||||||
|
import PageSites from '@/pages/sites';
|
||||||
|
import PageStatistiques from '@/pages/statistiques';
|
||||||
|
import PageStock from '@/pages/stock';
|
||||||
import PageTableauDeBord from '@/pages/tableau-de-bord';
|
import PageTableauDeBord from '@/pages/tableau-de-bord';
|
||||||
|
import PageTiers from '@/pages/tiers';
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: { queries: { refetchOnWindowFocus: false } },
|
defaultOptions: { queries: { refetchOnWindowFocus: false } },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const dansCoquille = (page: ReactNode) => <Coquille>{page}</Coquille>;
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/connexion" element={<PageConnexion />} />
|
<Route path="/connexion" element={<PageConnexion />} />
|
||||||
<Route
|
<Route path="/activation" element={<PageActivation />} />
|
||||||
path="/"
|
{/* Portail public — cible du QR cabine, AUCUN compte */}
|
||||||
element={
|
<Route path="/q/:reference" element={<PagePortail />} />
|
||||||
<Coquille>
|
<Route path="/" element={dansCoquille(<PageTableauDeBord />)} />
|
||||||
<PageTableauDeBord />
|
<Route path="/design" element={dansCoquille(<PageDesign />)} />
|
||||||
</Coquille>
|
<Route path="/sites" element={dansCoquille(<PageSites />)} />
|
||||||
}
|
<Route path="/sites/:id" element={dansCoquille(<PageFicheSite />)} />
|
||||||
/>
|
<Route path="/ascenseurs" element={dansCoquille(<PageAscenseurs />)} />
|
||||||
<Route
|
<Route path="/ascenseurs/nouveau" element={dansCoquille(<PageNouvelAscenseur />)} />
|
||||||
path="/design"
|
<Route path="/ascenseurs/:id" element={dansCoquille(<PageFicheAscenseur />)} />
|
||||||
element={
|
<Route path="/ascenseurs/:id/etiquette" element={dansCoquille(<PageEtiquette />)} />
|
||||||
<Coquille>
|
<Route path="/ascenseurs/:id/compteurs" element={dansCoquille(<PageCompteurs />)} />
|
||||||
<PageDesign />
|
<Route path="/personnes" element={dansCoquille(<PagePersonnes />)} />
|
||||||
</Coquille>
|
<Route path="/categories" element={dansCoquille(<PageCategories />)} />
|
||||||
}
|
<Route path="/ot" element={dansCoquille(<PageOrdresTravail />)} />
|
||||||
/>
|
<Route path="/ot/nouveau" element={dansCoquille(<PageNouvelOT />)} />
|
||||||
|
<Route path="/ot/:id" element={dansCoquille(<PageFicheOT />)} />
|
||||||
|
<Route path="/demandes" element={dansCoquille(<PageDemandes />)} />
|
||||||
|
<Route path="/preventif" element={dansCoquille(<PagePreventif />)} />
|
||||||
|
<Route path="/stock" element={dansCoquille(<PageStock />)} />
|
||||||
|
<Route path="/stock/:id" element={dansCoquille(<PageFichePiece />)} />
|
||||||
|
<Route path="/achats" element={dansCoquille(<PageAchats />)} />
|
||||||
|
<Route path="/achats/nouveau" element={dansCoquille(<PageNouvelAchat />)} />
|
||||||
|
<Route path="/tiers" element={dansCoquille(<PageTiers />)} />
|
||||||
|
<Route path="/bibliotheque" element={dansCoquille(<PageBibliotheque />)} />
|
||||||
|
<Route path="/statistiques" element={dansCoquille(<PageStatistiques />)} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
|||||||
235
apps/web/src/api/exploitation.ts
Normal file
235
apps/web/src/api/exploitation.ts
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import type {
|
||||||
|
AssigneesUpdate,
|
||||||
|
ChecklistPatch,
|
||||||
|
MeterReadingCreate,
|
||||||
|
PreventiveGenerate,
|
||||||
|
ReportUpsert,
|
||||||
|
RequestApprove,
|
||||||
|
RequestCreate,
|
||||||
|
RequestReject,
|
||||||
|
TaskTemplateCreate,
|
||||||
|
TaskTemplateUpdate,
|
||||||
|
TransitionRequest,
|
||||||
|
WorkOrderCreate,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import { api } from './client';
|
||||||
|
|
||||||
|
/** Hooks R2 — client typé, invalidations ciblées. */
|
||||||
|
|
||||||
|
async function unwrap<T>(res: { data?: T; error?: unknown; response: Response }): Promise<T> {
|
||||||
|
if (res.error || res.data === undefined) {
|
||||||
|
const message =
|
||||||
|
(res.error as { message?: string } | undefined)?.message ??
|
||||||
|
`Le serveur a répondu ${res.response.status}`;
|
||||||
|
throw new Error(Array.isArray(message) ? message.join(' — ') : message);
|
||||||
|
}
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useInvalidate(...keys: string[]) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return () =>
|
||||||
|
Promise.all(keys.map((key) => queryClient.invalidateQueries({ queryKey: [key] })));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ————— Ordres de travail —————
|
||||||
|
|
||||||
|
export function useWorkOrders(enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['work-orders'],
|
||||||
|
enabled,
|
||||||
|
queryFn: async () => (await unwrap(await api.GET('/work-orders'))).workOrders,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWorkOrder(id: string | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['work-orders', id],
|
||||||
|
enabled: !!id,
|
||||||
|
queryFn: async () =>
|
||||||
|
unwrap(await api.GET('/work-orders/{id}', { params: { path: { id: id! } } })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateWorkOrder() {
|
||||||
|
const invalidate = useInvalidate('work-orders');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: WorkOrderCreate) =>
|
||||||
|
unwrap(await api.POST('/work-orders', { body })),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTransitionWorkOrder(id: string) {
|
||||||
|
const invalidate = useInvalidate('work-orders', 'requests', 'preventive-status');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: TransitionRequest) =>
|
||||||
|
unwrap(await api.POST('/work-orders/{id}/transition', { params: { path: { id } }, body })),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCommentWorkOrder(id: string) {
|
||||||
|
const invalidate = useInvalidate('work-orders');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (message: string) =>
|
||||||
|
unwrap(
|
||||||
|
await api.POST('/work-orders/{id}/comments', {
|
||||||
|
params: { path: { id } },
|
||||||
|
body: { message },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSetAssignees(id: string) {
|
||||||
|
const invalidate = useInvalidate('work-orders');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: AssigneesUpdate) =>
|
||||||
|
unwrap(await api.PUT('/work-orders/{id}/assignees', { params: { path: { id } }, body })),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpsertReport(id: string) {
|
||||||
|
const invalidate = useInvalidate('work-orders');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: ReportUpsert) =>
|
||||||
|
unwrap(await api.PUT('/work-orders/{id}/report', { params: { path: { id } }, body })),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePatchChecklist(id: string) {
|
||||||
|
const invalidate = useInvalidate('work-orders');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ itemId, ...body }: ChecklistPatch & { itemId: string }) =>
|
||||||
|
unwrap(
|
||||||
|
await api.PATCH('/work-orders/{id}/checklist/{itemId}', {
|
||||||
|
params: { path: { id, itemId } },
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ————— Demandes —————
|
||||||
|
|
||||||
|
export function useRequests() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['requests'],
|
||||||
|
queryFn: async () => (await unwrap(await api.GET('/requests'))).requests,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateRequest() {
|
||||||
|
const invalidate = useInvalidate('requests');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: RequestCreate) => unwrap(await api.POST('/requests', { body })),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useApproveRequest() {
|
||||||
|
const invalidate = useInvalidate('requests', 'work-orders');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ id, ...body }: RequestApprove & { id: string }) =>
|
||||||
|
unwrap(await api.POST('/requests/{id}/approve', { params: { path: { id } }, body })),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRejectRequest() {
|
||||||
|
const invalidate = useInvalidate('requests');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ id, ...body }: RequestReject & { id: string }) =>
|
||||||
|
unwrap(await api.POST('/requests/{id}/reject', { params: { path: { id } }, body })),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ————— Référentiels du bilan —————
|
||||||
|
|
||||||
|
export function useReferenceValues() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['reference-values'],
|
||||||
|
staleTime: 5 * 60_000,
|
||||||
|
queryFn: async () =>
|
||||||
|
(await unwrap(await api.GET('/reference-values'))).referenceValues,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ————— Préventif —————
|
||||||
|
|
||||||
|
export function useTaskTemplates() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['task-templates'],
|
||||||
|
queryFn: async () => (await unwrap(await api.GET('/preventive/templates'))).templates,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateTaskTemplate() {
|
||||||
|
const invalidate = useInvalidate('task-templates');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: TaskTemplateCreate) =>
|
||||||
|
unwrap(await api.POST('/preventive/templates', { body })),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateTaskTemplate() {
|
||||||
|
const invalidate = useInvalidate('task-templates');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ id, ...body }: TaskTemplateUpdate & { id: string }) =>
|
||||||
|
unwrap(
|
||||||
|
await api.PATCH('/preventive/templates/{id}', { params: { path: { id } }, body }),
|
||||||
|
),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePreventiveStatus(enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['preventive-status'],
|
||||||
|
enabled,
|
||||||
|
queryFn: async () => unwrap(await api.GET('/preventive/status')),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGeneratePreventive() {
|
||||||
|
const invalidate = useInvalidate('preventive-status', 'work-orders');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: PreventiveGenerate) =>
|
||||||
|
unwrap(await api.POST('/preventive/generate', { body })),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ————— Compteurs —————
|
||||||
|
|
||||||
|
export function useMeters(assetId: string | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['meters', assetId],
|
||||||
|
enabled: !!assetId,
|
||||||
|
queryFn: async () =>
|
||||||
|
(await unwrap(await api.GET('/assets/{id}/meters', { params: { path: { id: assetId! } } })))
|
||||||
|
.meters,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAddMeterReading(assetId: string) {
|
||||||
|
const invalidate = useInvalidate('meters');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: MeterReadingCreate) =>
|
||||||
|
unwrap(
|
||||||
|
await api.POST('/assets/{id}/meter-readings', {
|
||||||
|
params: { path: { id: assetId } },
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
268
apps/web/src/api/gestion.ts
Normal file
268
apps/web/src/api/gestion.ts
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import type {
|
||||||
|
ConsumePart,
|
||||||
|
LaborTimeCreate,
|
||||||
|
PartCreate,
|
||||||
|
PartnerCreate,
|
||||||
|
PartnerUpdate,
|
||||||
|
PartUpdate,
|
||||||
|
PurchaseOrderCreate,
|
||||||
|
PurchaseOrderTransition,
|
||||||
|
StockMovementCreate,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import { api, getToken } from './client';
|
||||||
|
|
||||||
|
/** Hooks R3 — client typé ; l'upload passe par FormData (multipart). */
|
||||||
|
|
||||||
|
async function unwrap<T>(res: { data?: T; error?: unknown; response: Response }): Promise<T> {
|
||||||
|
if (res.error || res.data === undefined) {
|
||||||
|
const message =
|
||||||
|
(res.error as { message?: string } | undefined)?.message ??
|
||||||
|
`Le serveur a répondu ${res.response.status}`;
|
||||||
|
throw new Error(Array.isArray(message) ? message.join(' — ') : message);
|
||||||
|
}
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useInvalidate(...keys: string[]) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return () =>
|
||||||
|
Promise.all(keys.map((key) => queryClient.invalidateQueries({ queryKey: [key] })));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ————— Tiers —————
|
||||||
|
|
||||||
|
export function usePartners(enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['partners'],
|
||||||
|
enabled,
|
||||||
|
queryFn: async () => (await unwrap(await api.GET('/partners'))).partners,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreatePartner() {
|
||||||
|
const invalidate = useInvalidate('partners');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: PartnerCreate) => unwrap(await api.POST('/partners', { body })),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdatePartner() {
|
||||||
|
const invalidate = useInvalidate('partners');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ id, ...body }: PartnerUpdate & { id: string }) =>
|
||||||
|
unwrap(await api.PATCH('/partners/{id}', { params: { path: { id } }, body })),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ————— Pièces & stock —————
|
||||||
|
|
||||||
|
export function useParts(enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['parts'],
|
||||||
|
enabled,
|
||||||
|
queryFn: async () => (await unwrap(await api.GET('/parts'))).parts,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePart(id: string | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['parts', id],
|
||||||
|
enabled: !!id,
|
||||||
|
queryFn: async () =>
|
||||||
|
unwrap(await api.GET('/parts/{id}', { params: { path: { id: id! } } })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreatePart() {
|
||||||
|
const invalidate = useInvalidate('parts');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: PartCreate) => unwrap(await api.POST('/parts', { body })),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdatePart(id: string) {
|
||||||
|
const invalidate = useInvalidate('parts');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: PartUpdate) =>
|
||||||
|
unwrap(await api.PATCH('/parts/{id}', { params: { path: { id } }, body })),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAddStockMovement(id: string) {
|
||||||
|
const invalidate = useInvalidate('parts');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: StockMovementCreate) =>
|
||||||
|
unwrap(await api.POST('/parts/{id}/movements', { params: { path: { id } }, body })),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ————— Bons de commande —————
|
||||||
|
|
||||||
|
export function usePurchaseOrders(enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['purchase-orders'],
|
||||||
|
enabled,
|
||||||
|
queryFn: async () =>
|
||||||
|
(await unwrap(await api.GET('/purchase-orders'))).purchaseOrders,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreatePurchaseOrder() {
|
||||||
|
const invalidate = useInvalidate('purchase-orders');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: PurchaseOrderCreate) =>
|
||||||
|
unwrap(await api.POST('/purchase-orders', { body })),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTransitionPurchaseOrder() {
|
||||||
|
const invalidate = useInvalidate('purchase-orders', 'parts');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ id, ...body }: PurchaseOrderTransition & { id: string }) =>
|
||||||
|
unwrap(
|
||||||
|
await api.POST('/purchase-orders/{id}/transition', { params: { path: { id } }, body }),
|
||||||
|
),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ————— Coûts sur OT —————
|
||||||
|
|
||||||
|
export function useConsumePart(workOrderId: string) {
|
||||||
|
const invalidate = useInvalidate('work-orders', 'parts');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: ConsumePart) =>
|
||||||
|
unwrap(
|
||||||
|
await api.POST('/work-orders/{id}/consume-part', {
|
||||||
|
params: { path: { id: workOrderId } },
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAddLaborTime(workOrderId: string) {
|
||||||
|
const invalidate = useInvalidate('work-orders');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: LaborTimeCreate) =>
|
||||||
|
unwrap(
|
||||||
|
await api.POST('/work-orders/{id}/labor', {
|
||||||
|
params: { path: { id: workOrderId } },
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ————— Bibliothèque —————
|
||||||
|
|
||||||
|
export function useDocuments(filtres: { assetId?: string; workOrderId?: string; kind?: string } = {}) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['documents', filtres],
|
||||||
|
queryFn: async () =>
|
||||||
|
(
|
||||||
|
await unwrap(
|
||||||
|
await api.GET('/documents', { params: { query: filtres } }),
|
||||||
|
)
|
||||||
|
).documents,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUploadDocument() {
|
||||||
|
const invalidate = useInvalidate('documents');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (input: {
|
||||||
|
file: File;
|
||||||
|
kind: string;
|
||||||
|
assetId?: string;
|
||||||
|
workOrderId?: string;
|
||||||
|
}) => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', input.file);
|
||||||
|
form.append('kind', input.kind);
|
||||||
|
if (input.assetId) form.append('assetId', input.assetId);
|
||||||
|
if (input.workOrderId) form.append('workOrderId', input.workOrderId);
|
||||||
|
const res = await fetch('/api/documents', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${getToken()}` },
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const corps = (await res.json().catch(() => null)) as { message?: string } | null;
|
||||||
|
throw new Error(corps?.message ?? `Téléversement refusé (${res.status})`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteDocument() {
|
||||||
|
const invalidate = useInvalidate('documents');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (id: string) => {
|
||||||
|
const res = await api.DELETE('/documents/{id}', { params: { path: { id } } });
|
||||||
|
if (res.response.status >= 400) throw new Error('Suppression impossible');
|
||||||
|
},
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Récupération authentifiée du fichier (un lien nu ne porte pas le jeton). */
|
||||||
|
export async function blobDocument(id: string): Promise<Blob> {
|
||||||
|
const res = await fetch(`/api/documents/${id}/download`, {
|
||||||
|
headers: { Authorization: `Bearer ${getToken()}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Téléchargement impossible');
|
||||||
|
return res.blob();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function telechargerDocument(id: string, fileName: string): Promise<void> {
|
||||||
|
const blob = await blobDocument(id);
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = fileName;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ouverture dans un nouvel onglet (PDF et images : le navigateur les affiche). */
|
||||||
|
export async function ouvrirDocument(id: string): Promise<void> {
|
||||||
|
// Onglet ouvert dans le geste utilisateur, sinon les bloqueurs de pop-up
|
||||||
|
// refusent un window.open survenant après le fetch.
|
||||||
|
const onglet = window.open('', '_blank');
|
||||||
|
try {
|
||||||
|
const blob = await blobDocument(id);
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
if (onglet) onglet.location.href = url;
|
||||||
|
else window.open(url, '_blank');
|
||||||
|
} catch (e) {
|
||||||
|
onglet?.close();
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ————— Analytics —————
|
||||||
|
|
||||||
|
export function useAnalyticsSummary(months: 3 | 6 | 12 = 12, enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['analytics', months],
|
||||||
|
enabled,
|
||||||
|
queryFn: async () =>
|
||||||
|
unwrap(
|
||||||
|
await api.GET('/analytics/summary', {
|
||||||
|
params: { query: { months: String(months) } },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
22
apps/web/src/api/recherche.ts
Normal file
22
apps/web/src/api/recherche.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { SEARCH_MIN_CHARS } from '@siop/shared';
|
||||||
|
import { api } from './client';
|
||||||
|
|
||||||
|
async function unwrap<T>(res: { data?: T; error?: unknown; response: Response }): Promise<T> {
|
||||||
|
if (res.data !== undefined) return res.data;
|
||||||
|
throw new Error(`Recherche indisponible (${res.response.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recherche globale de la topbar (⌘K). L'API filtre chaque famille par la
|
||||||
|
* matrice — le client n'a aucune logique de permission à porter. */
|
||||||
|
export function useGlobalSearch(q: string) {
|
||||||
|
const terme = q.trim();
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['search', terme],
|
||||||
|
enabled: terme.length >= SEARCH_MIN_CHARS,
|
||||||
|
staleTime: 30_000,
|
||||||
|
placeholderData: (precedent) => precedent, // pas de clignotement en tapant
|
||||||
|
queryFn: async () =>
|
||||||
|
unwrap(await api.GET('/search', { params: { query: { q: terme } } })),
|
||||||
|
});
|
||||||
|
}
|
||||||
208
apps/web/src/api/referentiel.ts
Normal file
208
apps/web/src/api/referentiel.ts
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import type {
|
||||||
|
AssetComponentCreate,
|
||||||
|
AssetCreate,
|
||||||
|
AssetUpdate,
|
||||||
|
CategoryCreate,
|
||||||
|
CategoryUpdate,
|
||||||
|
InvitationCreate,
|
||||||
|
LocationCreate,
|
||||||
|
LocationUpdate,
|
||||||
|
TeamCreate,
|
||||||
|
UserUpdate,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import { api } from './client';
|
||||||
|
|
||||||
|
/** Hooks R1 — chaque appel passe par le client typé (règle d'or). */
|
||||||
|
|
||||||
|
async function unwrap<T>(res: { data?: T; error?: unknown; response: Response }): Promise<T> {
|
||||||
|
if (res.error || res.data === undefined) {
|
||||||
|
const message =
|
||||||
|
(res.error as { message?: string } | undefined)?.message ??
|
||||||
|
`Le serveur a répondu ${res.response.status}`;
|
||||||
|
throw new Error(Array.isArray(message) ? message.join(' — ') : message);
|
||||||
|
}
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLocations() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['locations'],
|
||||||
|
queryFn: async () => (await unwrap(await api.GET('/locations'))).locations,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAssets() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['assets'],
|
||||||
|
queryFn: async () => (await unwrap(await api.GET('/assets'))).assets,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options minimales (signalement) — accessibles à tout rôle authentifié. */
|
||||||
|
export function useAssetOptions() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['asset-options'],
|
||||||
|
queryFn: async () => (await unwrap(await api.GET('/assets/options'))).options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAsset(id: string | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['assets', id],
|
||||||
|
enabled: !!id,
|
||||||
|
queryFn: async () =>
|
||||||
|
unwrap(await api.GET('/assets/{id}', { params: { path: { id: id! } } })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCategories() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['categories'],
|
||||||
|
queryFn: async () => (await unwrap(await api.GET('/categories'))).categories,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTeams() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['teams'],
|
||||||
|
queryFn: async () => (await unwrap(await api.GET('/teams'))).teams,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUsers() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['users'],
|
||||||
|
queryFn: async () => (await unwrap(await api.GET('/users'))).users,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRoles() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['roles'],
|
||||||
|
queryFn: async () => (await unwrap(await api.GET('/roles'))).roles,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function useInvalidate(...keys: string[]) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return () =>
|
||||||
|
Promise.all(keys.map((key) => queryClient.invalidateQueries({ queryKey: [key] })));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateLocation() {
|
||||||
|
const invalidate = useInvalidate('locations');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: LocationCreate) =>
|
||||||
|
unwrap(await api.POST('/locations', { body })),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateLocation(id: string) {
|
||||||
|
const invalidate = useInvalidate('locations', 'partners');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: LocationUpdate) =>
|
||||||
|
unwrap(await api.PATCH('/locations/{id}', { params: { path: { id } }, body })),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateAsset() {
|
||||||
|
const invalidate = useInvalidate('assets', 'locations');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: AssetCreate) => unwrap(await api.POST('/assets', { body })),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateAsset(id: string) {
|
||||||
|
const invalidate = useInvalidate('assets');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: AssetUpdate) =>
|
||||||
|
unwrap(await api.PATCH('/assets/{id}', { params: { path: { id } }, body })),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAddComponent(assetId: string) {
|
||||||
|
const invalidate = useInvalidate('assets');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: AssetComponentCreate) =>
|
||||||
|
unwrap(
|
||||||
|
await api.POST('/assets/{id}/components', {
|
||||||
|
params: { path: { id: assetId } },
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRemoveComponent(assetId: string) {
|
||||||
|
const invalidate = useInvalidate('assets');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (componentId: string) => {
|
||||||
|
const res = await api.DELETE('/assets/{id}/components/{componentId}', {
|
||||||
|
params: { path: { id: assetId, componentId } },
|
||||||
|
});
|
||||||
|
if (res.response.status >= 400) throw new Error('Suppression impossible');
|
||||||
|
},
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateCategory() {
|
||||||
|
const invalidate = useInvalidate('categories');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: CategoryCreate) =>
|
||||||
|
unwrap(await api.POST('/categories', { body })),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateCategory() {
|
||||||
|
const invalidate = useInvalidate('categories');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ id, ...body }: CategoryUpdate & { id: string }) =>
|
||||||
|
unwrap(await api.PATCH('/categories/{id}', { params: { path: { id } }, body })),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateTeam() {
|
||||||
|
const invalidate = useInvalidate('teams', 'users');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: TeamCreate) => unwrap(await api.POST('/teams', { body })),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useInviteUser() {
|
||||||
|
const invalidate = useInvalidate('users');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: InvitationCreate) =>
|
||||||
|
unwrap(await api.POST('/users/invitations', { body })),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useResendInvitation() {
|
||||||
|
const invalidate = useInvalidate('users');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (userId: string) =>
|
||||||
|
unwrap(
|
||||||
|
await api.POST('/users/{id}/invitation', { params: { path: { id: userId } } }),
|
||||||
|
),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateUser() {
|
||||||
|
const invalidate = useInvalidate('users', 'teams');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ id, ...body }: UserUpdate & { id: string }) =>
|
||||||
|
unwrap(await api.PATCH('/users/{id}', { params: { path: { id } }, body })),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
3794
apps/web/src/api/schema.d.ts
vendored
3794
apps/web/src/api/schema.d.ts
vendored
File diff suppressed because it is too large
Load Diff
@@ -21,7 +21,9 @@ export function useMe() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** ADR-002 : la liste n'existe qu'en DEMO_MODE — un 404 signifie « pas de
|
/** ADR-002 : la liste n'existe qu'en DEMO_MODE — un 404 signifie « pas de
|
||||||
* mode démo », le web n'affiche alors rien (null). */
|
* mode démo », le web n'affiche alors rien (null). Une ERREUR RÉSEAU, en
|
||||||
|
* revanche, signifie que l'API est injoignable : l'écran de connexion
|
||||||
|
* l'affiche clairement (isError) au lieu de masquer la démo en silence. */
|
||||||
export function useDemoAccounts() {
|
export function useDemoAccounts() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['demo-accounts'],
|
queryKey: ['demo-accounts'],
|
||||||
@@ -29,8 +31,9 @@ export function useDemoAccounts() {
|
|||||||
staleTime: 5 * 60_000,
|
staleTime: 5 * 60_000,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data, response } = await api.GET('/auth/demo-accounts');
|
const { data, response } = await api.GET('/auth/demo-accounts');
|
||||||
if (response.status === 404) return null;
|
if (response.status === 404) return null; // pas de mode démo : normal
|
||||||
return data?.accounts ?? null;
|
if (!data) throw new Error(`API : ${response.status}`);
|
||||||
|
return data.accounts;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
19
apps/web/src/auth/use-permissions.ts
Normal file
19
apps/web/src/auth/use-permissions.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { ObjectCategory, PermissionRight } from '@siop/shared';
|
||||||
|
import { useMe } from './use-auth';
|
||||||
|
|
||||||
|
/** Lecture UI de la matrice (l'API re-vérifie chaque requête, invariant R0). */
|
||||||
|
export function usePermissions() {
|
||||||
|
const { data: me } = useMe();
|
||||||
|
const can = (category: ObjectCategory, right: PermissionRight): boolean => {
|
||||||
|
const entry = me?.permissions.find((p) => p.objectCategory === category);
|
||||||
|
if (!entry) return false;
|
||||||
|
switch (right) {
|
||||||
|
case 'view': return entry.canView;
|
||||||
|
case 'viewOther': return entry.canViewOther;
|
||||||
|
case 'create': return entry.canCreate;
|
||||||
|
case 'edit': return entry.canEdit;
|
||||||
|
case 'delete': return entry.canDelete;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return { can, chargé: !!me };
|
||||||
|
}
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import type { RoleName } from '@siop/shared';
|
|
||||||
import { cn } from '@/lib/cn';
|
import { cn } from '@/lib/cn';
|
||||||
|
|
||||||
/** Couleur d'avatar par rôle (tokens uniquement — répartition de la maquette). */
|
/** Couleur d'avatar par rôle (tokens uniquement — répartition de la maquette). */
|
||||||
const ROLE_TOKEN: Record<RoleName, string> = {
|
const ROLE_TOKEN: Record<string, string> = {
|
||||||
Administrateur: 'var(--graf-1)',
|
Administrateur: 'var(--graf-1)',
|
||||||
Dispatcher: 'var(--primaire)',
|
Dispatcher: 'var(--primaire)',
|
||||||
Technicien: 'var(--graf-3)',
|
Technicien: 'var(--graf-3)',
|
||||||
@@ -18,13 +17,14 @@ export function Avatar({
|
|||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
initials: string;
|
initials: string;
|
||||||
roleName?: RoleName;
|
roleName?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
|
const couleur = roleName ? ROLE_TOKEN[roleName] : undefined;
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn('avatar', className)}
|
className={cn('avatar', className)}
|
||||||
style={roleName ? { background: ROLE_TOKEN[roleName] } : undefined}
|
style={couleur ? { background: couleur } : undefined}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
{initials}
|
{initials}
|
||||||
|
|||||||
179
apps/web/src/components/carte-documents.tsx
Normal file
179
apps/web/src/components/carte-documents.tsx
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
DOCUMENT_KIND_LABELS,
|
||||||
|
DOCUMENT_KINDS,
|
||||||
|
DOCUMENT_MAX_BYTES,
|
||||||
|
type DocumentDto,
|
||||||
|
type DocumentKind,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import {
|
||||||
|
blobDocument,
|
||||||
|
ouvrirDocument,
|
||||||
|
telechargerDocument,
|
||||||
|
useDeleteDocument,
|
||||||
|
useDocuments,
|
||||||
|
useUploadDocument,
|
||||||
|
} from '@/api/gestion';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
const CLASSE_TYPE: Partial<Record<DocumentKind, string>> = {
|
||||||
|
CERTIFICATE: 'type-doc certificat',
|
||||||
|
PHOTO: 'type-doc photo',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function tailleLisible(octets: number): string {
|
||||||
|
if (octets === 0) return '0 Ko';
|
||||||
|
if (octets >= 1024 * 1024) return `${(octets / (1024 * 1024)).toFixed(1).replace('.', ',')} Mo`;
|
||||||
|
return `${Math.max(1, Math.round(octets / 1024))} Ko`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aperçu réel des images : le fichier exige le jeton, donc pas de <img src>
|
||||||
|
* direct — on récupère le blob et on affiche une URL objet, révoquée au
|
||||||
|
* démontage. Les PDF gardent leur pictogramme (le navigateur les ouvre). */
|
||||||
|
function ApercuVignette({ doc }: { doc: DocumentDto }) {
|
||||||
|
const estImage = doc.contentType.startsWith('image/');
|
||||||
|
const [url, setUrl] = useState<string>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!estImage) return;
|
||||||
|
let actif = true;
|
||||||
|
let objet: string | undefined;
|
||||||
|
blobDocument(doc.id)
|
||||||
|
.then((blob) => {
|
||||||
|
if (!actif) return;
|
||||||
|
objet = URL.createObjectURL(blob);
|
||||||
|
setUrl(objet);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
return () => {
|
||||||
|
actif = false;
|
||||||
|
if (objet) URL.revokeObjectURL(objet);
|
||||||
|
};
|
||||||
|
}, [doc.id, estImage]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="vignette"
|
||||||
|
title={`Ouvrir ${doc.fileName}`}
|
||||||
|
onClick={() => void ouvrirDocument(doc.id)}
|
||||||
|
>
|
||||||
|
{url ? <img src={url} alt={doc.fileName} /> : estImage ? '🖼' : '📄'}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VignetteDoc({ doc, surSuppression }: { doc: DocumentDto; surSuppression?: (id: string) => void }) {
|
||||||
|
return (
|
||||||
|
<div className="doc">
|
||||||
|
<ApercuVignette doc={doc} />
|
||||||
|
<span className={CLASSE_TYPE[doc.kind] ?? 'type-doc'}>{DOCUMENT_KIND_LABELS[doc.kind]}</span>
|
||||||
|
<b>{doc.fileName}</b>
|
||||||
|
<span className="meta">
|
||||||
|
{doc.assetReference ? `Asc. ${doc.assetReference} · ` : ''}
|
||||||
|
{doc.workOrderReference ? `${doc.workOrderReference} · ` : ''}
|
||||||
|
{tailleLisible(doc.size)} ·{' '}
|
||||||
|
{new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium' }).format(new Date(doc.createdAt))}
|
||||||
|
{doc.uploadedByName ? ` · ${doc.uploadedByName}` : ''}
|
||||||
|
</span>
|
||||||
|
<span style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void ouvrirDocument(doc.id)}
|
||||||
|
style={{ color: 'var(--primaire)', fontWeight: 600, fontSize: 12.5 }}
|
||||||
|
>
|
||||||
|
Ouvrir
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void telechargerDocument(doc.id, doc.fileName)}
|
||||||
|
style={{ color: 'var(--primaire)', fontWeight: 600, fontSize: 12.5 }}
|
||||||
|
>
|
||||||
|
Télécharger
|
||||||
|
</button>
|
||||||
|
{surSuppression ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(`Supprimer « ${doc.fileName} » ?`)) surSuppression(doc.id);
|
||||||
|
}}
|
||||||
|
style={{ color: 'var(--danger)', fontWeight: 600, fontSize: 12.5 }}
|
||||||
|
>
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Carte « Documents » des fiches OT / ascenseur — la même bibliothèque,
|
||||||
|
* filtrée sur la cible ; l'upload rattache automatiquement. */
|
||||||
|
export function CarteDocuments({
|
||||||
|
cible,
|
||||||
|
peutEditer,
|
||||||
|
}: {
|
||||||
|
cible: { assetId: string } | { workOrderId: string };
|
||||||
|
peutEditer: boolean;
|
||||||
|
}) {
|
||||||
|
const { data: documents } = useDocuments(cible);
|
||||||
|
const upload = useUploadDocument();
|
||||||
|
const suppression = useDeleteDocument();
|
||||||
|
const fichierRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [kind, setKind] = useState<DocumentKind>('workOrderId' in cible ? 'PHOTO' : 'NOTICE');
|
||||||
|
|
||||||
|
const surFichier = (file: File | undefined) => {
|
||||||
|
if (!file) return;
|
||||||
|
if (file.size > DOCUMENT_MAX_BYTES) {
|
||||||
|
window.alert('Fichier trop lourd (20 Mo max).');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
upload.mutate({ file, kind, ...cible });
|
||||||
|
if (fichierRef.current) fichierRef.current.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="carte">
|
||||||
|
<h2>Documents</h2>
|
||||||
|
{documents?.length ? (
|
||||||
|
<div className="docs" style={{ gridTemplateColumns: 'repeat(2, 1fr)' }}>
|
||||||
|
{documents.map((d) => (
|
||||||
|
<VignetteDoc
|
||||||
|
key={d.id}
|
||||||
|
doc={d}
|
||||||
|
surSuppression={peutEditer ? (id) => suppression.mutate(id) : undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-encre-3">Aucun document rattaché.</p>
|
||||||
|
)}
|
||||||
|
{peutEditer ? (
|
||||||
|
<div className="mt-3 flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
aria-label="Type de document"
|
||||||
|
value={kind}
|
||||||
|
onChange={(e) => setKind(e.target.value as DocumentKind)}
|
||||||
|
className="rounded-lg border border-bordure-forte bg-surface p-2 text-encre"
|
||||||
|
>
|
||||||
|
{DOCUMENT_KINDS.map((k) => (
|
||||||
|
<option key={k} value={k}>{DOCUMENT_KIND_LABELS[k]}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
ref={fichierRef}
|
||||||
|
type="file"
|
||||||
|
accept="application/pdf,image/jpeg,image/png"
|
||||||
|
aria-label="Choisir un fichier"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => surFichier(e.target.files?.[0])}
|
||||||
|
/>
|
||||||
|
<Button disabled={upload.isPending} onClick={() => fichierRef.current?.click()}>
|
||||||
|
{upload.isPending ? 'Téléversement…' : 'Téléverser (PDF, JPG, PNG · 20 Mo)'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{upload.isError ? <p className="erreur-form">{upload.error.message}</p> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
apps/web/src/components/carte-geo.tsx
Normal file
77
apps/web/src/components/carte-geo.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { divIcon } from 'leaflet';
|
||||||
|
import 'leaflet/dist/leaflet.css';
|
||||||
|
import { MapContainer, Marker, TileLayer, Tooltip, useMapEvents } from 'react-leaflet';
|
||||||
|
import { cn } from '@/lib/cn';
|
||||||
|
|
||||||
|
/** Carte Leaflet/OSM (positions PostGIS) — pins au style de la maquette. */
|
||||||
|
|
||||||
|
export interface PinGeo {
|
||||||
|
id: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
libelle: string;
|
||||||
|
alerte?: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CENTRE_CASABLANCA: [number, number] = [33.59, -7.6];
|
||||||
|
|
||||||
|
function iconePin(alerte?: boolean) {
|
||||||
|
return divIcon({
|
||||||
|
className: '',
|
||||||
|
html: `<div class="pin-geo${alerte ? ' alerte' : ''}"></div>`,
|
||||||
|
iconSize: [26, 26],
|
||||||
|
iconAnchor: [13, 26],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ClicCarte({ onClick }: { onClick?: (lat: number, lng: number) => void }) {
|
||||||
|
useMapEvents({
|
||||||
|
click(e) {
|
||||||
|
onClick?.(Number(e.latlng.lat.toFixed(5)), Number(e.latlng.lng.toFixed(5)));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CarteGeo({
|
||||||
|
pins,
|
||||||
|
className,
|
||||||
|
zoom = 11,
|
||||||
|
centre,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
pins: PinGeo[];
|
||||||
|
className?: string;
|
||||||
|
zoom?: number;
|
||||||
|
centre?: [number, number];
|
||||||
|
onClick?: (lat: number, lng: number) => void;
|
||||||
|
}) {
|
||||||
|
const centreEffectif =
|
||||||
|
centre ??
|
||||||
|
(pins.length
|
||||||
|
? ([pins[0]!.latitude, pins[0]!.longitude] as [number, number])
|
||||||
|
: CENTRE_CASABLANCA);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('geo', className)}>
|
||||||
|
<MapContainer center={centreEffectif} zoom={zoom} scrollWheelZoom={false}>
|
||||||
|
<TileLayer
|
||||||
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||||
|
url="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
|
/>
|
||||||
|
<ClicCarte onClick={onClick} />
|
||||||
|
{pins.map((pin) => (
|
||||||
|
<Marker
|
||||||
|
key={pin.id}
|
||||||
|
position={[pin.latitude, pin.longitude]}
|
||||||
|
icon={iconePin(pin.alerte)}
|
||||||
|
eventHandlers={pin.onClick ? { click: pin.onClick } : undefined}
|
||||||
|
>
|
||||||
|
<Tooltip direction="bottom" offset={[0, 6]}>{pin.libelle}</Tooltip>
|
||||||
|
</Marker>
|
||||||
|
))}
|
||||||
|
</MapContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
apps/web/src/components/chips-ot.tsx
Normal file
66
apps/web/src/components/chips-ot.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import {
|
||||||
|
REQUEST_STATUS_LABELS,
|
||||||
|
WORK_ORDER_PRIORITY_LABELS,
|
||||||
|
WORK_ORDER_STATUS_LABELS,
|
||||||
|
type RequestStatus,
|
||||||
|
type WorkOrderPriority,
|
||||||
|
type WorkOrderStatus,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import { cn } from '@/lib/cn';
|
||||||
|
|
||||||
|
const ST_CLASSES: Record<WorkOrderStatus, string> = {
|
||||||
|
OPEN: 'ouvert',
|
||||||
|
IN_PROGRESS: 'encours',
|
||||||
|
ON_HOLD: 'attente',
|
||||||
|
DONE: 'termine',
|
||||||
|
CANCELLED: 'annule',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Statut OT — les couleurs réservées de la charte. */
|
||||||
|
export function StatutOT({ statut }: { statut: WorkOrderStatus }) {
|
||||||
|
return (
|
||||||
|
<span className={cn('st', ST_CLASSES[statut])}>
|
||||||
|
{WORK_ORDER_STATUS_LABELS[statut]}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRIO_CLASSES: Record<WorkOrderPriority, string | null> = {
|
||||||
|
NONE: null,
|
||||||
|
LOW: 'basse',
|
||||||
|
MEDIUM: 'moyenne',
|
||||||
|
HIGH: 'haute',
|
||||||
|
PERSON_TRAPPED: 'bloque',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Priorité — pastille + libellé, jamais la couleur seule (charte). */
|
||||||
|
export function PrioriteOT({ priorite }: { priorite: WorkOrderPriority }) {
|
||||||
|
const classe = PRIO_CLASSES[priorite];
|
||||||
|
if (!classe) return <span className="text-encre-3">—</span>;
|
||||||
|
return (
|
||||||
|
<span className={cn('prio', classe)}>
|
||||||
|
<i />
|
||||||
|
{WORK_ORDER_PRIORITY_LABELS[priorite]}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEM_CLASSES: Record<RequestStatus, string> = {
|
||||||
|
RECEIVED: 'recue',
|
||||||
|
APPROVED: 'approuvee',
|
||||||
|
REJECTED: 'rejetee',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Statut de demande — « Résolue » quand l'OT lié est terminé. */
|
||||||
|
export function StatutDemande({
|
||||||
|
statut,
|
||||||
|
otStatut,
|
||||||
|
}: {
|
||||||
|
statut: RequestStatus;
|
||||||
|
otStatut?: WorkOrderStatus | null;
|
||||||
|
}) {
|
||||||
|
if (statut === 'APPROVED' && otStatut === 'DONE') {
|
||||||
|
return <span className="st resolue">Résolue</span>;
|
||||||
|
}
|
||||||
|
return <span className={cn('st', DEM_CLASSES[statut])}>{REQUEST_STATUS_LABELS[statut]}</span>;
|
||||||
|
}
|
||||||
@@ -89,6 +89,12 @@ export const IcoPersonnes = () => (
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const IcoCategories = () => (
|
||||||
|
<svg {...base}>
|
||||||
|
<path d="M4 7h16M4 12h16M4 17h10" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
export const IcoRecherche = () => (
|
export const IcoRecherche = () => (
|
||||||
<svg {...base}>
|
<svg {...base}>
|
||||||
<circle cx="11" cy="11" r="7" />
|
<circle cx="11" cy="11" r="7" />
|
||||||
|
|||||||
179
apps/web/src/components/recherche-globale.tsx
Normal file
179
apps/web/src/components/recherche-globale.tsx
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { SEARCH_MIN_CHARS } from '@siop/shared';
|
||||||
|
import { useGlobalSearch } from '@/api/recherche';
|
||||||
|
import { StatutOT } from '@/components/chips-ot';
|
||||||
|
import { IcoRecherche } from '@/components/nav-icons';
|
||||||
|
|
||||||
|
interface Resultat {
|
||||||
|
cle: string;
|
||||||
|
route: string;
|
||||||
|
contenu: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** La recherche de la topbar, enfin active (recette R3) : ⌘K pour saisir,
|
||||||
|
* résultats groupés (OT / ascenseurs / sites), déjà filtrés par la matrice
|
||||||
|
* côté API — flèches + Entrée pour naviguer. */
|
||||||
|
export function RechercheGlobale() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [saisie, setSaisie] = useState('');
|
||||||
|
const [q, setQ] = useState('');
|
||||||
|
const [ouverte, setOuverte] = useState(false);
|
||||||
|
const [actif, setActif] = useState(0);
|
||||||
|
const champ = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Débounce : on n'interroge l'API que 200 ms après la dernière frappe.
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => setQ(saisie), 200);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [saisie]);
|
||||||
|
|
||||||
|
// ⌘K / Ctrl+K : focus depuis n'importe où.
|
||||||
|
useEffect(() => {
|
||||||
|
const surTouche = (e: KeyboardEvent) => {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
||||||
|
e.preventDefault();
|
||||||
|
champ.current?.focus();
|
||||||
|
champ.current?.select();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', surTouche);
|
||||||
|
return () => window.removeEventListener('keydown', surTouche);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { data } = useGlobalSearch(q);
|
||||||
|
|
||||||
|
const resultats = useMemo<Resultat[]>(() => {
|
||||||
|
if (!data) return [];
|
||||||
|
return [
|
||||||
|
...data.workOrders.map((w) => ({
|
||||||
|
cle: `ot-${w.id}`,
|
||||||
|
route: `/ot/${w.id}`,
|
||||||
|
contenu: (
|
||||||
|
<>
|
||||||
|
<span className="ref num">{w.reference}</span>
|
||||||
|
<span className="titre">{w.title}</span>
|
||||||
|
<span className="fin"><StatutOT statut={w.status} /></span>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
...data.assets.map((a) => ({
|
||||||
|
cle: `asc-${a.id}`,
|
||||||
|
route: `/ascenseurs/${a.id}`,
|
||||||
|
contenu: (
|
||||||
|
<>
|
||||||
|
<span className="ref num">{a.reference}</span>
|
||||||
|
<span className="titre">
|
||||||
|
{a.brand}
|
||||||
|
{a.model ? ` ${a.model}` : ''}
|
||||||
|
</span>
|
||||||
|
<span className="fin">{a.siteName}</span>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
...data.sites.map((s) => ({
|
||||||
|
cle: `site-${s.id}`,
|
||||||
|
route: `/sites/${s.id}`,
|
||||||
|
contenu: (
|
||||||
|
<>
|
||||||
|
<span className="titre"><b>{s.name}</b></span>
|
||||||
|
<span className="fin">{s.city ?? ''}</span>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const groupes = useMemo(() => {
|
||||||
|
if (!data) return [];
|
||||||
|
const g: { libelle: string; debut: number; nombre: number }[] = [];
|
||||||
|
if (data.workOrders.length) g.push({ libelle: 'Ordres de travail', debut: 0, nombre: data.workOrders.length });
|
||||||
|
if (data.assets.length) g.push({ libelle: 'Ascenseurs', debut: data.workOrders.length, nombre: data.assets.length });
|
||||||
|
if (data.sites.length) {
|
||||||
|
g.push({
|
||||||
|
libelle: 'Sites',
|
||||||
|
debut: data.workOrders.length + data.assets.length,
|
||||||
|
nombre: data.sites.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return g;
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const visible = ouverte && q.trim().length >= SEARCH_MIN_CHARS;
|
||||||
|
|
||||||
|
const aller = (r: Resultat | undefined) => {
|
||||||
|
if (!r) return;
|
||||||
|
setOuverte(false);
|
||||||
|
setSaisie('');
|
||||||
|
navigate(r.route);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="recherche" data-active>
|
||||||
|
<IcoRecherche />
|
||||||
|
<input
|
||||||
|
ref={champ}
|
||||||
|
type="search"
|
||||||
|
placeholder="Rechercher un OT, un ascenseur, un site…"
|
||||||
|
aria-label="Recherche globale"
|
||||||
|
value={saisie}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSaisie(e.target.value);
|
||||||
|
setOuverte(true);
|
||||||
|
setActif(0);
|
||||||
|
}}
|
||||||
|
onFocus={() => setOuverte(true)}
|
||||||
|
onBlur={() => setOuverte(false)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setOuverte(false);
|
||||||
|
champ.current?.blur();
|
||||||
|
}
|
||||||
|
if (!visible || !resultats.length) return;
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
setActif((a) => Math.min(a + 1, resultats.length - 1));
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
setActif((a) => Math.max(a - 1, 0));
|
||||||
|
}
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
aller(resultats[actif]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<kbd>⌘K</kbd>
|
||||||
|
{visible ? (
|
||||||
|
<div className="resultats" role="listbox" aria-label="Résultats">
|
||||||
|
{groupes.map((g) => (
|
||||||
|
<div key={g.libelle}>
|
||||||
|
<div className="groupe-resultats">{g.libelle}</div>
|
||||||
|
{resultats.slice(g.debut, g.debut + g.nombre).map((r, i) => (
|
||||||
|
<button
|
||||||
|
key={r.cle}
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={g.debut + i === actif}
|
||||||
|
className="resultat"
|
||||||
|
data-actif={g.debut + i === actif || undefined}
|
||||||
|
onMouseEnter={() => setActif(g.debut + i)}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault(); // avant le blur du champ
|
||||||
|
aller(r);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{r.contenu}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!resultats.length ? (
|
||||||
|
<div className="groupe-resultats">Aucun résultat pour « {q.trim()} »</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
apps/web/src/components/statut-equipement.tsx
Normal file
13
apps/web/src/components/statut-equipement.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { ASSET_STATUS_LABELS, type AssetStatus } from '@siop/shared';
|
||||||
|
import { cn } from '@/lib/cn';
|
||||||
|
|
||||||
|
const CLASSES: Record<AssetStatus, string> = {
|
||||||
|
IN_SERVICE: 'termine',
|
||||||
|
OUT_OF_SERVICE: 'arret',
|
||||||
|
UNDER_MAINTENANCE: 'maintenance',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Statut d'ÉQUIPEMENT — pastille + libellé, jamais la couleur seule (charte). */
|
||||||
|
export function StatutEquipement({ statut }: { statut: AssetStatus }) {
|
||||||
|
return <span className={cn('st', CLASSES[statut])}>{ASSET_STATUS_LABELS[statut]}</span>;
|
||||||
|
}
|
||||||
29
apps/web/src/components/ui/modale.tsx
Normal file
29
apps/web/src/components/ui/modale.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import * as Dialog from '@radix-ui/react-dialog';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
/** Modale au style de la maquette, portée par Radix (focus, Échap, overlay). */
|
||||||
|
export function Modale({
|
||||||
|
titre,
|
||||||
|
ouverte,
|
||||||
|
surFermeture,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
titre: string;
|
||||||
|
ouverte: boolean;
|
||||||
|
surFermeture: (ouverte: boolean) => void;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Dialog.Root open={ouverte} onOpenChange={surFermeture}>
|
||||||
|
<Dialog.Portal>
|
||||||
|
<Dialog.Overlay className="voile-radix" />
|
||||||
|
<Dialog.Content className="modale" aria-describedby={undefined}>
|
||||||
|
<Dialog.Title asChild>
|
||||||
|
<h3>{titre}</h3>
|
||||||
|
</Dialog.Title>
|
||||||
|
{children}
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user