mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
feat(r2.1): socle backend exploitation — machine à états, bilan codé, demandes 1-1
- migration r2_exploitation (10 tables) : WorkOrder (référence séquentielle, horodatages), WorkOrderEvent, Request (1-1, motif de rejet), ReferenceValue, InterventionReport (6 FK), TaskTemplate/ChecklistItem, Meter/MeterReading - contrat : 15 opérations (41 total) ; la table des transitions et les champs requis du bilan vivent dans @siop/shared ; la fiche OT expose allowedTransitions + closureBlockers (messages métier) - API : machine à états stricte ; garde de clôture (bilan 3 champs requis + checklist sans tâche en attente) ; approbation → OT lié 1-1 (409 si déjà traitée) ; rejet à motif obligatoire ; scoping « voir autre » sur listes et accès directs (404 sans fuite) ; validation des valeurs de bilan par champ ; « personne bloquée » triée en tête côté API - seed : 31 valeurs de référentiels, 8 gabarits (parachute réglementaire), OT/demandes/compteurs de la maquette — idempotent - 45 tests verts (95 % stmts / 79 % branches) dont la recette officielle rejouée de bout en bout ; smoke test sur build de prod Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -44,5 +44,7 @@ pnpm + Turborepo. `apps/api` : NestJS, Prisma, PostgreSQL (pgvector + PostGIS),
|
||||
- ✅ **R1.1 — socle backend** : migration `r1_referentiel` (Category, Location site→zone + PostGIS générée, Asset, AssetComponent, Team, invitation User), 21 nouvelles opérations au contrat (26 total), 4 modules API + invitations/activation sous `@RequirePermission`, seed parc maquette (5 sites, 8 appareils, organes, 2 équipes), 36 tests (96 %/85 %), CI sur `postgis/postgis:18-3.6`.
|
||||
- ✅ **R1.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 Exploitation — ouverte, design d'abord** : `maquette-r2.html` (5 écrans : demandes/approbation, nouvel OT, préventif gabarits+génération, OT préventif checklist, compteurs) — **⛔ en attente de validation du référent avant tout code R2** (4 décisions soumises, dont la sémantique des statuts de demande). Écrans cœur déjà validés en R0. Ensuite : modèle R2 (WorkOrder machine à états, Request 1-1, InterventionReport bilan codé + ReferenceValue, TaskTemplate/ChecklistItem, Meter/MeterReading) → contrat → API (garde de clôture, génération idempotente BullMQ) → web → recette (parcours demande gardien → OT → bilan → clôture ; grille de juillet).
|
||||
- ✅ **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 — reprise ici** : génération mensuelle du préventif (idempotente, premier contrôle, BullMQ ou déclenchement manuel + cron) + API compteurs (relevés croissants) → R2.3 écrans web (liste/fiche OT, demandes, nouvel OT, préventif, checklist, compteurs, chip urgence topbar + bandeau dashboard) → R2.4 portail public QR → recette + déploiement + tag.
|
||||
- Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0→R5.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -49,6 +49,13 @@ model User {
|
||||
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[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -75,6 +82,7 @@ model Category {
|
||||
isActive Boolean @default(true) // désactivable, jamais supprimée si utilisée
|
||||
assets Asset[]
|
||||
components AssetComponent[]
|
||||
taskTemplates TaskTemplate[]
|
||||
|
||||
@@unique([kind, name])
|
||||
}
|
||||
@@ -115,6 +123,9 @@ model Asset {
|
||||
locationId String @db.Uuid
|
||||
location Location @relation(fields: [locationId], references: [id])
|
||||
components AssetComponent[]
|
||||
workOrders WorkOrder[]
|
||||
requests Request[]
|
||||
meters Meter[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -141,3 +152,201 @@ model Team {
|
||||
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?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@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)
|
||||
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])
|
||||
}
|
||||
|
||||
@@ -133,6 +133,7 @@ export async function seed(prisma: PrismaClient): Promise<void> {
|
||||
);
|
||||
await seedUsers(prisma, roleIds, passwordHash);
|
||||
await seedReferentiel(prisma);
|
||||
await seedExploitation(prisma);
|
||||
}
|
||||
|
||||
async function seedUsers(
|
||||
@@ -358,6 +359,227 @@ async function seedReferentiel(prisma: PrismaClient): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// ————— R2 — Exploitation (référentiels du bilan, gabarits, données maquette) —————
|
||||
|
||||
const REFERENCE_VALUES: Record<string, string[]> = {
|
||||
DOOR_STATE: [
|
||||
'Fonctionnement normal',
|
||||
'Porte bloquée ouverte',
|
||||
'Porte bloquée fermée',
|
||||
'Fermeture incomplète',
|
||||
'Réouverture intempestive',
|
||||
],
|
||||
CABIN_POSITION: ['À niveau', 'Entre deux niveaux', 'Cuvette', 'Dernier niveau'],
|
||||
ANOMALY: [
|
||||
'Frottement mécanique',
|
||||
'Défaut électrique',
|
||||
'Usure normale',
|
||||
'Choc / vandalisme',
|
||||
'Aucune anomalie constatée',
|
||||
],
|
||||
EXTERNAL_CAUSE: ['Coupure électrique', 'Dégât des eaux', 'Mauvais usage', 'Aucune'],
|
||||
ACTION_TAKEN: [
|
||||
'Réglage',
|
||||
'Remplacement de pièce',
|
||||
'Nettoyage / graissage',
|
||||
'Remise en service simple',
|
||||
'Visite d’entretien',
|
||||
'Attente de pièce',
|
||||
],
|
||||
COMPONENT_CONCERNED: [
|
||||
'Portes',
|
||||
'Guides',
|
||||
'Treuil / machinerie',
|
||||
'Armoire de commande',
|
||||
'Boutons / signalisation',
|
||||
'Parachute',
|
||||
'Cabine',
|
||||
],
|
||||
};
|
||||
|
||||
/** Gabarits du préventif (maquette R2) — période calendaire en mois. */
|
||||
const TASK_TEMPLATES: {
|
||||
label: string;
|
||||
periodMonths: number;
|
||||
componentType?: string;
|
||||
isRegulatory?: boolean;
|
||||
}[] = [
|
||||
{ label: 'Contrôle fermeture / verrouillage des portes', periodMonths: 1, componentType: 'Portes cabine / palières' },
|
||||
{ label: 'Nettoyage cuvette et toit de cabine', periodMonths: 1 },
|
||||
{ label: 'Contrôle boutons cabine & paliers', periodMonths: 1, componentType: 'Boutons & signalisation' },
|
||||
{ label: 'Vérification éclairage de secours', periodMonths: 1 },
|
||||
{ label: 'Contrôle niveau d’huile réducteur', periodMonths: 3, componentType: 'Treuil / machinerie' },
|
||||
{ label: 'Vérification jeu des coulisseaux', periodMonths: 6 },
|
||||
{ label: 'Contrôle câbles de traction (usure, tension)', periodMonths: 6, componentType: 'Treuil / machinerie' },
|
||||
{ label: 'Essai du parachute', periodMonths: 12, componentType: 'Parachute', isRegulatory: true },
|
||||
];
|
||||
|
||||
async function seedExploitation(prisma: PrismaClient): Promise<void> {
|
||||
const refIds = new Map<string, string>(); // « FIELD/label » → id
|
||||
for (const [field, labels] of Object.entries(REFERENCE_VALUES)) {
|
||||
for (const label of labels) {
|
||||
const value = await prisma.referenceValue.upsert({
|
||||
where: { field_label: { field: field as never, label } },
|
||||
update: {},
|
||||
create: { field: field as never, label },
|
||||
});
|
||||
refIds.set(`${field}/${label}`, value.id);
|
||||
}
|
||||
}
|
||||
|
||||
const componentTypes = new Map(
|
||||
(await prisma.category.findMany({ where: { kind: 'COMPONENT_TYPE' } })).map((c) => [
|
||||
c.name,
|
||||
c.id,
|
||||
]),
|
||||
);
|
||||
for (const t of TASK_TEMPLATES) {
|
||||
await prisma.taskTemplate.upsert({
|
||||
where: { label: t.label },
|
||||
update: {},
|
||||
create: {
|
||||
label: t.label,
|
||||
periodMonths: t.periodMonths,
|
||||
isRegulatory: t.isRegulatory ?? false,
|
||||
componentTypeId: t.componentType ? componentTypes.get(t.componentType) : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Données de démonstration (rejouent la maquette : OT en cours, urgence,
|
||||
// grille du mois, demandes à approuver). Idempotent par référence.
|
||||
const parReference = async (ref: string) =>
|
||||
(await prisma.asset.findUniqueOrThrow({ where: { reference: ref } })).id;
|
||||
const parEmail = async (email: string) =>
|
||||
(await prisma.user.findUniqueOrThrow({ where: { email } })).id;
|
||||
|
||||
const a1 = await parReference('A1');
|
||||
const b2 = await parReference('B2');
|
||||
const c1 = await parReference('C1');
|
||||
const ahmed = await parEmail('technicien@demo.siop.ma');
|
||||
const salma = await parEmail('dispatcher@demo.siop.ma');
|
||||
const karim = await parEmail('demandeur@demo.siop.ma');
|
||||
const annee = new Date().getFullYear();
|
||||
|
||||
const grilleLabels = TASK_TEMPLATES.filter((t) => t.periodMonths === 1).map(
|
||||
(t) => t.label,
|
||||
);
|
||||
|
||||
const OTS: {
|
||||
ref: string; title: string; type: 'CORRECTIVE' | 'PREVENTIVE' | 'WORKS';
|
||||
status: 'OPEN' | 'IN_PROGRESS' | 'ON_HOLD' | 'DONE' | 'CANCELLED';
|
||||
priority: 'NONE' | 'LOW' | 'MEDIUM' | 'HIGH' | 'PERSON_TRAPPED';
|
||||
assetId: string; assignees?: string[]; dueJours?: number;
|
||||
checklist?: string[]; bilan?: Record<string, string>;
|
||||
}[] = [
|
||||
{
|
||||
ref: `OT-${annee}-0342`, title: 'Personne bloquée en cabine', type: 'CORRECTIVE',
|
||||
status: 'OPEN', priority: 'PERSON_TRAPPED', assetId: b2,
|
||||
},
|
||||
{
|
||||
ref: `OT-${annee}-0341`, title: 'Bruit anormal en gaine', type: 'CORRECTIVE',
|
||||
status: 'IN_PROGRESS', priority: 'HIGH', assetId: a1, assignees: [ahmed], dueJours: 2,
|
||||
},
|
||||
{
|
||||
ref: `OT-${annee}-0338`, title: 'Grille du mois — juillet', type: 'PREVENTIVE',
|
||||
status: 'IN_PROGRESS', priority: 'LOW', assetId: a1, assignees: [ahmed],
|
||||
dueJours: 15, checklist: grilleLabels,
|
||||
},
|
||||
{
|
||||
ref: `OT-${annee}-0332`, title: 'Réglage nivellement cabine', type: 'CORRECTIVE',
|
||||
status: 'DONE', priority: 'LOW', assetId: a1,
|
||||
bilan: {
|
||||
doorStateId: refIds.get('DOOR_STATE/Fonctionnement normal')!,
|
||||
actionTakenId: refIds.get('ACTION_TAKEN/Réglage')!,
|
||||
componentConcernedId: refIds.get('COMPONENT_CONCERNED/Guides')!,
|
||||
},
|
||||
},
|
||||
];
|
||||
for (const ot of OTS) {
|
||||
const existant = await prisma.workOrder.findUnique({ where: { reference: ot.ref } });
|
||||
if (existant) continue;
|
||||
await prisma.workOrder.create({
|
||||
data: {
|
||||
reference: ot.ref,
|
||||
title: ot.title,
|
||||
type: ot.type,
|
||||
status: ot.status,
|
||||
priority: ot.priority,
|
||||
assetId: ot.assetId,
|
||||
createdById: salma,
|
||||
dueDate: ot.dueJours
|
||||
? new Date(Date.now() + ot.dueJours * 24 * 3600 * 1000)
|
||||
: undefined,
|
||||
startedAt: ot.status === 'IN_PROGRESS' || ot.status === 'DONE' ? new Date() : undefined,
|
||||
completedAt: ot.status === 'DONE' ? new Date() : undefined,
|
||||
assignees: ot.assignees ? { connect: ot.assignees.map((id) => ({ id })) } : undefined,
|
||||
events: { create: { kind: 'CREATED', message: 'OT créé (seed)', byId: salma } },
|
||||
checklist: ot.checklist
|
||||
? { create: ot.checklist.map((label) => ({ label })) }
|
||||
: undefined,
|
||||
report: ot.bilan ? { create: ot.bilan } : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const DEMANDES: {
|
||||
ref: string; description: string; assetId: string; isPersonTrapped?: boolean;
|
||||
status: 'RECEIVED' | 'APPROVED' | 'REJECTED';
|
||||
rejectionReason?: string; otRef?: string;
|
||||
}[] = [
|
||||
{
|
||||
ref: `DEM-${annee}-0111`, assetId: a1, status: 'RECEIVED',
|
||||
description: 'La porte ne se ferme plus au 3ᵉ étage, il faut la retenir à la main.',
|
||||
},
|
||||
{ ref: `DEM-${annee}-0110`, assetId: c1, status: 'RECEIVED', description: 'Voyant étage éteint.' },
|
||||
{
|
||||
ref: `DEM-${annee}-0107`, assetId: a1, status: 'APPROVED',
|
||||
description: 'Bruit anormal en gaine.', otRef: `OT-${annee}-0341`,
|
||||
},
|
||||
{
|
||||
ref: `DEM-${annee}-0104`, assetId: b2, status: 'REJECTED',
|
||||
description: 'Odeur de brûlé.', rejectionReason: 'Fausse alerte confirmée sur place par le gardien.',
|
||||
},
|
||||
];
|
||||
for (const dem of DEMANDES) {
|
||||
const existant = await prisma.request.findUnique({ where: { reference: dem.ref } });
|
||||
if (existant) continue;
|
||||
const workOrderId = dem.otRef
|
||||
? (await prisma.workOrder.findUnique({ where: { reference: dem.otRef } }))?.id
|
||||
: undefined;
|
||||
await prisma.request.create({
|
||||
data: {
|
||||
reference: dem.ref,
|
||||
description: dem.description,
|
||||
isPersonTrapped: dem.isPersonTrapped ?? false,
|
||||
status: dem.status,
|
||||
rejectionReason: dem.rejectionReason,
|
||||
assetId: dem.assetId,
|
||||
requestedById: karim,
|
||||
workOrderId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Compteurs de la maquette (A1)
|
||||
for (const [kind, valeurs] of [
|
||||
['RUNNING_HOURS', [12246, 12322, 12411]],
|
||||
['STARTS', [1815400, 1831970]],
|
||||
] as const) {
|
||||
const meter = await prisma.meter.upsert({
|
||||
where: { assetId_kind: { assetId: a1, kind } },
|
||||
update: {},
|
||||
create: { assetId: a1, kind },
|
||||
include: { _count: { select: { readings: true } } },
|
||||
});
|
||||
if (meter._count.readings === 0) {
|
||||
await prisma.meterReading.createMany({
|
||||
data: valeurs.map((value) => ({ meterId: meter.id, value, readById: ahmed })),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* c8 ignore start — wrapper CLI */
|
||||
if (require.main === module) {
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
@@ -9,7 +9,10 @@ import { demoModeEnabled } from './config/env';
|
||||
import { FilesModule } from './files/files.module';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { LocationsModule } from './locations/locations.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 { PermissionsModule } from './permissions/permissions.module';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
@@ -37,6 +40,10 @@ export class AppModule {
|
||||
LocationsModule,
|
||||
AssetsModule,
|
||||
TeamsModule,
|
||||
// R2 — exploitation
|
||||
WorkOrdersModule,
|
||||
RequestsModule,
|
||||
ReferenceValuesModule,
|
||||
// ADR-002 : hors DEMO_MODE, le module n'est pas enregistré → 404
|
||||
...(demoModeEnabled() ? [DemoAuthModule] : []),
|
||||
],
|
||||
|
||||
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 {}
|
||||
170
apps/api/src/requests/requests.service.ts
Normal file
170
apps/api/src/requests/requests.service.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
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 annee = new Date().getFullYear();
|
||||
const dernier = await this.prisma.request.findFirst({
|
||||
where: { reference: { startsWith: `DEM-${annee}-` } },
|
||||
orderBy: { reference: 'desc' },
|
||||
select: { reference: true },
|
||||
});
|
||||
const n = dernier ? Number(dernier.reference.split('-')[2]) + 1 : 1;
|
||||
return `DEM-${annee}-${String(n).padStart(4, '0')}`;
|
||||
}
|
||||
|
||||
private requesterLabel(row: RequestRow): string {
|
||||
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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
110
apps/api/src/work-orders/work-orders.controller.ts
Normal file
110
apps/api/src/work-orders/work-orders.controller.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
AssigneesUpdateSchema,
|
||||
ChecklistPatchSchema,
|
||||
CommentCreateSchema,
|
||||
ReportUpsertSchema,
|
||||
TransitionRequestSchema,
|
||||
WorkOrderCreateSchema,
|
||||
type AssigneesUpdate,
|
||||
type ChecklistPatch,
|
||||
type CommentCreate,
|
||||
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);
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
}
|
||||
10
apps/api/src/work-orders/work-orders.module.ts
Normal file
10
apps/api/src/work-orders/work-orders.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { WorkOrdersController } from './work-orders.controller';
|
||||
import { WorkOrdersService } from './work-orders.service';
|
||||
|
||||
@Module({
|
||||
controllers: [WorkOrdersController],
|
||||
providers: [WorkOrdersService],
|
||||
exports: [WorkOrdersService],
|
||||
})
|
||||
export class WorkOrdersModule {}
|
||||
435
apps/api/src/work-orders/work-orders.service.ts
Normal file
435
apps/api/src/work-orders/work-orders.service.ts
Normal file
@@ -0,0 +1,435 @@
|
||||
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 { 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 } },
|
||||
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,
|
||||
) {}
|
||||
|
||||
/** 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,
|
||||
},
|
||||
orderBy: [{ status: 'asc' }, { createdAt: 'desc' }],
|
||||
});
|
||||
// « personne bloquée » saute en tête, toujours
|
||||
rows.sort((a, b) =>
|
||||
Number(b.priority === 'PERSON_TRAPPED') - Number(a.priority === 'PERSON_TRAPPED'),
|
||||
);
|
||||
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> {
|
||||
const annee = new Date().getFullYear();
|
||||
const dernier = await this.prisma.workOrder.findFirst({
|
||||
where: { reference: { startsWith: `OT-${annee}-` } },
|
||||
orderBy: { reference: 'desc' },
|
||||
select: { reference: true },
|
||||
});
|
||||
const n = dernier ? Number(dernier.reference.split('-')[2]) + 1 : 1;
|
||||
return `OT-${annee}-${String(n).padStart(4, '0')}`;
|
||||
}
|
||||
|
||||
/** Machine à états stricte + garde de clôture. */
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
// ————— 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;
|
||||
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,
|
||||
allowedTransitions: WORK_ORDER_TRANSITIONS[row.status as WorkOrderStatus],
|
||||
closureBlockers: blockers,
|
||||
};
|
||||
}
|
||||
}
|
||||
350
apps/api/test/exploitation.e2e-spec.ts
Normal file
350
apps/api/test/exploitation.e2e-spec.ts
Normal file
@@ -0,0 +1,350 @@
|
||||
/**
|
||||
* 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');
|
||||
|
||||
// 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 } });
|
||||
});
|
||||
});
|
||||
905
apps/web/src/api/schema.d.ts
vendored
905
apps/web/src/api/schema.d.ts
vendored
@@ -355,6 +355,213 @@ export interface paths {
|
||||
patch: operations["updateUser"];
|
||||
trace?: never;
|
||||
};
|
||||
"/work-orders": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Ordres de travail (sans « voir autre » : seulement les siens) */
|
||||
get: operations["listWorkOrders"];
|
||||
put?: never;
|
||||
/** Créer un OT (statut Ouvert) */
|
||||
post: operations["createWorkOrder"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/work-orders/{id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Fiche OT (activité, checklist, bilan, transitions autorisées, blocages de clôture) */
|
||||
get: operations["getWorkOrder"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/work-orders/{id}/transition": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Changer l’état (machine à états stricte ; DONE exige bilan + checklist) */
|
||||
post: operations["transitionWorkOrder"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/work-orders/{id}/comments": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Commenter (activité chronologique) */
|
||||
post: operations["commentWorkOrder"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/work-orders/{id}/assignees": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
/** Assigner (remplace la liste) */
|
||||
put: operations["setWorkOrderAssignees"];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/work-orders/{id}/report": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
/** Renseigner le bilan codé (null efface un champ) */
|
||||
put: operations["upsertWorkOrderReport"];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/work-orders/{id}/checklist/{itemId}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
/** Régler une tâche de la grille (Fait / N-A / à faire) */
|
||||
patch: operations["patchChecklistItem"];
|
||||
trace?: never;
|
||||
};
|
||||
"/requests": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Demandes (sans « voir autre » : seulement les siennes) */
|
||||
get: operations["listRequests"];
|
||||
put?: never;
|
||||
/** Signaler (interne — le portail public QR arrive en R2.4) */
|
||||
post: operations["createRequest"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/requests/{id}/approve": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Approuver → crée l’OT lié (1-1, jamais de doublon) */
|
||||
post: operations["approveRequest"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/requests/{id}/reject": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Rejeter — motif obligatoire, lisible côté demandeur */
|
||||
post: operations["rejectRequest"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/reference-values": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Référentiels du bilan codé (6 champs) */
|
||||
get: operations["listReferenceValues"];
|
||||
put?: never;
|
||||
/** Ajouter une valeur de référentiel */
|
||||
post: operations["createReferenceValue"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/reference-values/{id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
/** Renommer ou (dés)activer — jamais de suppression si utilisée */
|
||||
patch: operations["updateReferenceValue"];
|
||||
trace?: never;
|
||||
};
|
||||
"/health": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -734,6 +941,285 @@ export interface components {
|
||||
teamIds?: string[];
|
||||
isActive?: boolean;
|
||||
};
|
||||
WorkOrdersResponse: {
|
||||
workOrders: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
reference: string;
|
||||
title: string;
|
||||
/** @enum {string} */
|
||||
type: "CORRECTIVE" | "PREVENTIVE" | "WORKS";
|
||||
/** @enum {string} */
|
||||
status: "OPEN" | "IN_PROGRESS" | "ON_HOLD" | "DONE" | "CANCELLED";
|
||||
/** @enum {string} */
|
||||
priority: "NONE" | "LOW" | "MEDIUM" | "HIGH" | "PERSON_TRAPPED";
|
||||
/** Format: uuid */
|
||||
assetId: string;
|
||||
assetReference: string;
|
||||
siteName: string;
|
||||
dueDate: string | null;
|
||||
assignees: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
displayName: string;
|
||||
initials: string;
|
||||
}[];
|
||||
/** Format: date-time */
|
||||
createdAt: string;
|
||||
}[];
|
||||
};
|
||||
WorkOrderDetail: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
reference: string;
|
||||
title: string;
|
||||
/** @enum {string} */
|
||||
type: "CORRECTIVE" | "PREVENTIVE" | "WORKS";
|
||||
/** @enum {string} */
|
||||
status: "OPEN" | "IN_PROGRESS" | "ON_HOLD" | "DONE" | "CANCELLED";
|
||||
/** @enum {string} */
|
||||
priority: "NONE" | "LOW" | "MEDIUM" | "HIGH" | "PERSON_TRAPPED";
|
||||
/** Format: uuid */
|
||||
assetId: string;
|
||||
assetReference: string;
|
||||
siteName: string;
|
||||
dueDate: string | null;
|
||||
assignees: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
displayName: string;
|
||||
initials: string;
|
||||
}[];
|
||||
/** Format: date-time */
|
||||
createdAt: string;
|
||||
description: string | null;
|
||||
locationName: string;
|
||||
startedAt: string | null;
|
||||
completedAt: string | null;
|
||||
cancelledAt: string | null;
|
||||
createdBy: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
displayName: string;
|
||||
initials: string;
|
||||
} | null;
|
||||
request: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
reference: string;
|
||||
requesterLabel: string;
|
||||
} | null;
|
||||
events: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
kind: string;
|
||||
message: string | null;
|
||||
by: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
displayName: string;
|
||||
initials: string;
|
||||
} | null;
|
||||
/** Format: date-time */
|
||||
createdAt: string;
|
||||
}[];
|
||||
checklist: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
label: string;
|
||||
/** @enum {string} */
|
||||
state: "PENDING" | "DONE" | "NA";
|
||||
doneBy: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
displayName: string;
|
||||
initials: string;
|
||||
} | null;
|
||||
doneAt: string | null;
|
||||
}[];
|
||||
report: {
|
||||
note: string | null;
|
||||
doorState: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
label: string;
|
||||
} | null;
|
||||
cabinPosition: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
label: string;
|
||||
} | null;
|
||||
anomaly: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
label: string;
|
||||
} | null;
|
||||
externalCause: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
label: string;
|
||||
} | null;
|
||||
actionTaken: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
label: string;
|
||||
} | null;
|
||||
componentConcerned: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
label: string;
|
||||
} | null;
|
||||
} | null;
|
||||
allowedTransitions: ("OPEN" | "IN_PROGRESS" | "ON_HOLD" | "DONE" | "CANCELLED")[];
|
||||
closureBlockers: string[];
|
||||
};
|
||||
WorkOrderCreate: {
|
||||
title: string;
|
||||
description?: string;
|
||||
/** @enum {string} */
|
||||
type: "CORRECTIVE" | "PREVENTIVE" | "WORKS";
|
||||
/** @enum {string} */
|
||||
priority?: "NONE" | "LOW" | "MEDIUM" | "HIGH" | "PERSON_TRAPPED";
|
||||
/** Format: uuid */
|
||||
assetId: string;
|
||||
/** Format: date-time */
|
||||
dueDate?: string;
|
||||
assigneeIds?: string[];
|
||||
};
|
||||
TransitionRequest: {
|
||||
/** @enum {string} */
|
||||
to: "OPEN" | "IN_PROGRESS" | "ON_HOLD" | "DONE" | "CANCELLED";
|
||||
comment?: string;
|
||||
};
|
||||
CommentCreate: {
|
||||
message: string;
|
||||
};
|
||||
AssigneesUpdate: {
|
||||
assigneeIds: string[];
|
||||
};
|
||||
ReportUpsert: {
|
||||
note?: string | null;
|
||||
doorStateId?: string | null;
|
||||
cabinPositionId?: string | null;
|
||||
anomalyId?: string | null;
|
||||
externalCauseId?: string | null;
|
||||
actionTakenId?: string | null;
|
||||
componentConcernedId?: string | null;
|
||||
};
|
||||
ChecklistItem: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
label: string;
|
||||
/** @enum {string} */
|
||||
state: "PENDING" | "DONE" | "NA";
|
||||
doneBy: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
displayName: string;
|
||||
initials: string;
|
||||
} | null;
|
||||
doneAt: string | null;
|
||||
};
|
||||
ChecklistPatch: {
|
||||
/** @enum {string} */
|
||||
state: "PENDING" | "DONE" | "NA";
|
||||
};
|
||||
RequestsResponse: {
|
||||
requests: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
reference: string;
|
||||
description: string;
|
||||
isPersonTrapped: boolean;
|
||||
/** @enum {string} */
|
||||
status: "RECEIVED" | "APPROVED" | "REJECTED";
|
||||
rejectionReason: string | null;
|
||||
/** Format: uuid */
|
||||
assetId: string;
|
||||
assetReference: string;
|
||||
siteName: string;
|
||||
requesterLabel: string;
|
||||
workOrder: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
reference: string;
|
||||
/** @enum {string} */
|
||||
status: "OPEN" | "IN_PROGRESS" | "ON_HOLD" | "DONE" | "CANCELLED";
|
||||
} | null;
|
||||
/** Format: date-time */
|
||||
createdAt: string;
|
||||
}[];
|
||||
};
|
||||
RequestSummary: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
reference: string;
|
||||
description: string;
|
||||
isPersonTrapped: boolean;
|
||||
/** @enum {string} */
|
||||
status: "RECEIVED" | "APPROVED" | "REJECTED";
|
||||
rejectionReason: string | null;
|
||||
/** Format: uuid */
|
||||
assetId: string;
|
||||
assetReference: string;
|
||||
siteName: string;
|
||||
requesterLabel: string;
|
||||
workOrder: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
reference: string;
|
||||
/** @enum {string} */
|
||||
status: "OPEN" | "IN_PROGRESS" | "ON_HOLD" | "DONE" | "CANCELLED";
|
||||
} | null;
|
||||
/** Format: date-time */
|
||||
createdAt: string;
|
||||
};
|
||||
RequestCreate: {
|
||||
/** Format: uuid */
|
||||
assetId: string;
|
||||
description: string;
|
||||
isPersonTrapped?: boolean;
|
||||
};
|
||||
RequestApprove: {
|
||||
title?: string;
|
||||
/** @enum {string} */
|
||||
priority?: "NONE" | "LOW" | "MEDIUM" | "HIGH" | "PERSON_TRAPPED";
|
||||
assigneeIds?: string[];
|
||||
/** Format: date-time */
|
||||
dueDate?: string;
|
||||
};
|
||||
RequestReject: {
|
||||
reason: string;
|
||||
};
|
||||
ReferenceValuesResponse: {
|
||||
referenceValues: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
/** @enum {string} */
|
||||
field: "DOOR_STATE" | "CABIN_POSITION" | "ANOMALY" | "EXTERNAL_CAUSE" | "ACTION_TAKEN" | "COMPONENT_CONCERNED";
|
||||
label: string;
|
||||
isActive: boolean;
|
||||
usageCount: number;
|
||||
}[];
|
||||
};
|
||||
ReferenceValue: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
/** @enum {string} */
|
||||
field: "DOOR_STATE" | "CABIN_POSITION" | "ANOMALY" | "EXTERNAL_CAUSE" | "ACTION_TAKEN" | "COMPONENT_CONCERNED";
|
||||
label: string;
|
||||
isActive: boolean;
|
||||
usageCount: number;
|
||||
};
|
||||
ReferenceValueCreate: {
|
||||
/** @enum {string} */
|
||||
field: "DOOR_STATE" | "CABIN_POSITION" | "ANOMALY" | "EXTERNAL_CAUSE" | "ACTION_TAKEN" | "COMPONENT_CONCERNED";
|
||||
label: string;
|
||||
};
|
||||
ReferenceValueUpdate: {
|
||||
label?: string;
|
||||
isActive?: boolean;
|
||||
};
|
||||
HealthResponse: {
|
||||
/** @enum {string} */
|
||||
status: "ok" | "degraded";
|
||||
@@ -1461,6 +1947,425 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
listWorkOrders: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Liste */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["WorkOrdersResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
createWorkOrder: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["WorkOrderCreate"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Créé */
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["WorkOrderDetail"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getWorkOrder: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Fiche */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["WorkOrderDetail"];
|
||||
};
|
||||
};
|
||||
/** @description Inconnu (ou hors de son périmètre) */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
transitionWorkOrder: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["TransitionRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description État changé */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["WorkOrderDetail"];
|
||||
};
|
||||
};
|
||||
/** @description Transition interdite ou clôture bloquée (garde) */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
commentWorkOrder: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["CommentCreate"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Commentaire ajouté */
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["WorkOrderDetail"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
setWorkOrderAssignees: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["AssigneesUpdate"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Assignés */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["WorkOrderDetail"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
upsertWorkOrderReport: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["ReportUpsert"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Bilan enregistré */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["WorkOrderDetail"];
|
||||
};
|
||||
};
|
||||
/** @description Valeur hors référentiel du champ */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
patchChecklistItem: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
itemId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["ChecklistPatch"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Tâche réglée */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ChecklistItem"];
|
||||
};
|
||||
};
|
||||
/** @description Tâche inconnue */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
listRequests: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Liste */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["RequestsResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
createRequest: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["RequestCreate"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Demande créée */
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["RequestSummary"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
approveRequest: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["RequestApprove"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OT créé et lié */
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["WorkOrderDetail"];
|
||||
};
|
||||
};
|
||||
/** @description Demande déjà traitée */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
rejectRequest: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["RequestReject"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Rejetée */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["RequestSummary"];
|
||||
};
|
||||
};
|
||||
/** @description Demande déjà traitée */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
listReferenceValues: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Liste */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ReferenceValuesResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
createReferenceValue: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["ReferenceValueCreate"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Créée */
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ReferenceValue"];
|
||||
};
|
||||
};
|
||||
/** @description Libellé déjà présent pour ce champ */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
updateReferenceValue: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["ReferenceValueUpdate"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Mise à jour */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ReferenceValue"];
|
||||
};
|
||||
};
|
||||
/** @description Inconnue */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
getHealth: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -116,11 +116,78 @@ model Team { // équipes par zone — l'assignation d'OT (R2) s
|
||||
| Lien d'activation : 7 jours, usage unique, aucun compte actif avant | service users (+ test e2e) |
|
||||
| Position : lat/lng saisis, colonne PostGIS **générée** pour les requêtes spatiales futures | migration SQL |
|
||||
|
||||
## R2 — Exploitation
|
||||
|
||||
```prisma
|
||||
enum WorkOrderType { CORRECTIVE PREVENTIVE WORKS } // Dépannage / Maintenance / Travaux
|
||||
enum WorkOrderStatus { OPEN IN_PROGRESS ON_HOLD DONE CANCELLED }
|
||||
enum WorkOrderPriority { NONE LOW MEDIUM HIGH PERSON_TRAPPED } // personne bloquée = priorité, pas statut
|
||||
enum RequestStatus { RECEIVED APPROVED REJECTED } // « Résolue » = dérivé (OT lié DONE)
|
||||
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 { // machine à états STRICTE (service, table des transitions)
|
||||
id String @id … reference String @unique // OT-2026-0341 (séquence + retry)
|
||||
title String description String?
|
||||
type WorkOrderType status WorkOrderStatus @default(OPEN)
|
||||
priority WorkOrderPriority @default(NONE)
|
||||
assetId → Asset dueDate DateTime?
|
||||
assignees User[] ("WorkOrderAssignees") createdById → User?
|
||||
startedAt? completedAt? cancelledAt?
|
||||
events WorkOrderEvent[] checklist ChecklistItem[]
|
||||
report InterventionReport? request Request?
|
||||
}
|
||||
|
||||
model WorkOrderEvent { // activité chronologique : commentaires + traces de transition
|
||||
id, workOrderId (cascade), kind String, message String?, byId → User?, createdAt
|
||||
}
|
||||
|
||||
model Request { // demande — converge vers l'OT (lien 1-1, jamais de doublon)
|
||||
id, reference @unique // DEM-2026-0112
|
||||
description, isPersonTrapped Boolean @default(false)
|
||||
status RequestStatus @default(RECEIVED) rejectionReason String? // motif REQUIS au rejet
|
||||
assetId → Asset requestedById → User? requesterName String? // portail public R2.4
|
||||
workOrderId String? @unique → WorkOrder
|
||||
}
|
||||
|
||||
model ReferenceValue { // référentiels ADMINISTRABLES du bilan codé (un par champ)
|
||||
id, field BilanField, label String, isActive Boolean @default(true)
|
||||
@@unique([field, label])
|
||||
}
|
||||
|
||||
model InterventionReport { // bilan codé 6 champs — 1-1 avec l'OT
|
||||
id, workOrderId @unique (cascade), note String?
|
||||
doorStateId? cabinPositionId? anomalyId? externalCauseId? actionTakenId? componentConcernedId?
|
||||
// requis pour clôturer : DOOR_STATE, ACTION_TAKEN, COMPONENT_CONCERNED (maquette)
|
||||
}
|
||||
|
||||
model TaskTemplate { // gabarit du préventif (période calendaire en mois)
|
||||
id, label, componentTypeId? → Category, periodMonths Int, isRegulatory Boolean, isActive
|
||||
}
|
||||
model ChecklistItem { // la grille du mois d'un OT préventif
|
||||
id, workOrderId (cascade), label, state ChecklistState @default(PENDING)
|
||||
templateId? → TaskTemplate doneById? → User doneAt?
|
||||
}
|
||||
|
||||
model Meter { id, assetId → Asset, kind MeterKind, @@unique([assetId, kind]) }
|
||||
model MeterReading { id, meterId (cascade), value Int, readById? → User, createdAt }
|
||||
```
|
||||
|
||||
**Invariants R2** :
|
||||
|
||||
| Invariant | Où il vit |
|
||||
| --- | --- |
|
||||
| Transitions : OPEN→(IN_PROGRESS·CANCELLED) ; IN_PROGRESS→(ON_HOLD·DONE·CANCELLED) ; ON_HOLD→(IN_PROGRESS·CANCELLED) ; DONE/CANCELLED terminaux | service work-orders (+ tests) |
|
||||
| **Garde de clôture** : bilan (3 champs requis) + checklist sans PENDING | service (transition → DONE) |
|
||||
| Demande approuvée = **un seul** OT (1-1), rejet ⇒ motif obligatoire | contrainte @unique + service |
|
||||
| « Voir autre » : sans `canViewOther`, un rôle ne voit que SES objets (assigné/créateur/demandeur) | services (scoping des listes + accès) |
|
||||
| ReferenceValue/TaskTemplate utilisés : désactivables, jamais supprimés | services (même règle que Category) |
|
||||
| Relevé de compteur strictement croissant | service meters |
|
||||
| Génération mensuelle idempotente ; appareil sans historique ⇒ premier contrôle (toutes tâches) | service préventif (R2.2, + tests) |
|
||||
|
||||
## À venir (référence v1 éprouvée, sera réintroduit release par release)
|
||||
|
||||
- **R2** : `WorkOrder` (machine à états stricte, priorité « personne bloquée »), `Request`
|
||||
(lien 1-1 vers OT), `InterventionReport` (bilan codé 6 champs → `ReferenceValue`),
|
||||
`TaskTemplate`/`PreventivePlan`/`ChecklistItem` (périodicité calendrier), `Meter`.
|
||||
- **R3** : `Part`/`StockMovement` (stock **dérivé des mouvements**), `PurchaseOrder`,
|
||||
`Partner`, `LaborTime` (taux figé), `Document`.
|
||||
- **R4** : `WorkOrder.version` (verrou optimiste de la synchro mobile).
|
||||
|
||||
@@ -4,6 +4,25 @@ Trace chronologique des sessions (la plus récente en premier). Le **playbook**
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-16 — Pr. Daaif (+ Claude) — R2.1 : socle backend de l'exploitation
|
||||
|
||||
**Actions**
|
||||
|
||||
- **Modèle R2** (doc + migration `r2_exploitation`, 10 tables) : WorkOrder (référence séquentielle, horodatages par état), WorkOrderEvent (activité), Request (1-1 vers OT, motif de rejet), ReferenceValue (référentiels du bilan par champ), InterventionReport (6 FK nommées), TaskTemplate/ChecklistItem, Meter/MeterReading.
|
||||
- **Contrat** : 15 opérations (41 total) — la **table des transitions** et les champs requis du bilan vivent dans `@siop/shared` (une seule loi pour l'API et l'UI) ; la fiche OT expose `allowedTransitions` et `closureBlockers` (messages métier).
|
||||
- **API** : machine à états stricte (transition → DONE refusée si bilan incomplet OU checklist avec tâche sans réponse — messages « quoi faire », charte §7) ; approbation de demande = création d'OT lié en 1-1 (double traitement → 409) ; rejet à motif obligatoire ; **scoping « voir autre »** appliqué aux listes ET aux accès directs (404, pas de fuite) ; valeurs de bilan validées champ par champ.
|
||||
- **Seed** : 31 valeurs de référentiels (6 champs), 8 gabarits de préventif (parachute réglementaire), 4 OT et 4 demandes rejouant la maquette, compteurs d'A1.
|
||||
- **45 tests verts** (couverture 95 % / 79 % branches) dont la **recette officielle rejouée** : demande Karim → approbation Salma → OT assigné Ahmed → démarrage → clôture refusée sans bilan → bilan codé → clôture → Karim voit sa demande résolue. Smoke test build prod.
|
||||
|
||||
**Décisions**
|
||||
|
||||
- `POST` de transition/rejet répondent **200** (le contrat prime sur le défaut 201 de Nest — c'est le contrat qui a raison).
|
||||
- La priorité « personne bloquée » trie en tête côté API : aucun client ne peut l'oublier.
|
||||
|
||||
**Prochaine étape** : R2.2 — génération mensuelle du préventif (BullMQ, idempotente, premier contrôle) + API compteurs, puis R2.3 écrans web.
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-16 — Pr. Daaif (+ Claude) — R2 ouverte : maquettes des écrans manquants à valider
|
||||
|
||||
**Actions**
|
||||
@@ -19,6 +38,7 @@ Trace chronologique des sessions (la plus récente en premier). Le **playbook**
|
||||
- Relevé de compteur strictement croissant (refus sinon).
|
||||
|
||||
**⛔ Bloquant** : validation des maquettes R2 par le référent avant toute ligne de code R2.
|
||||
**→ Levé le 16/07/2026 : maquettes R2 et les 4 décisions VALIDÉES par le référent** (statuts de demande = extension cohérente, actée). Lancement R2.1 (socle backend exploitation).
|
||||
|
||||
---
|
||||
|
||||
|
||||
2057
docs/openapi.json
2057
docs/openapi.json
File diff suppressed because it is too large
Load Diff
@@ -37,6 +37,26 @@ import {
|
||||
UsersResponseSchema,
|
||||
UserUpdateSchema,
|
||||
} from './schemas/users-admin';
|
||||
import {
|
||||
AssigneesUpdateSchema,
|
||||
ChecklistItemSchema,
|
||||
ChecklistPatchSchema,
|
||||
CommentCreateSchema,
|
||||
ReferenceValueCreateSchema,
|
||||
ReferenceValueSchema,
|
||||
ReferenceValuesResponseSchema,
|
||||
ReferenceValueUpdateSchema,
|
||||
ReportUpsertSchema,
|
||||
RequestApproveSchema,
|
||||
RequestCreateSchema,
|
||||
RequestRejectSchema,
|
||||
RequestsResponseSchema,
|
||||
RequestSummarySchema,
|
||||
TransitionRequestSchema,
|
||||
WorkOrderCreateSchema,
|
||||
WorkOrderDetailSchema,
|
||||
WorkOrdersResponseSchema,
|
||||
} from './schemas/exploitation';
|
||||
|
||||
/**
|
||||
* Contrat d'API R0 — source unique de vérité (règle d'or ADR-001).
|
||||
@@ -367,6 +387,189 @@ export const API_CONTRACT: ApiOperation[] = [
|
||||
404: { description: 'Inconnue' },
|
||||
},
|
||||
},
|
||||
// ————— R2 · Exploitation —————
|
||||
{
|
||||
operationId: 'listWorkOrders',
|
||||
method: 'get',
|
||||
path: '/work-orders',
|
||||
summary: 'Ordres de travail (sans « voir autre » : seulement les siens)',
|
||||
tags: ['work-orders'],
|
||||
responses: {
|
||||
200: { description: 'Liste', name: 'WorkOrdersResponse', schema: WorkOrdersResponseSchema },
|
||||
},
|
||||
},
|
||||
{
|
||||
operationId: 'getWorkOrder',
|
||||
method: 'get',
|
||||
path: '/work-orders/{id}',
|
||||
summary: 'Fiche OT (activité, checklist, bilan, transitions autorisées, blocages de clôture)',
|
||||
tags: ['work-orders'],
|
||||
pathParams: ['id'],
|
||||
responses: {
|
||||
200: { description: 'Fiche', name: 'WorkOrderDetail', schema: WorkOrderDetailSchema },
|
||||
404: { description: 'Inconnu (ou hors de son périmètre)' },
|
||||
},
|
||||
},
|
||||
{
|
||||
operationId: 'createWorkOrder',
|
||||
method: 'post',
|
||||
path: '/work-orders',
|
||||
summary: 'Créer un OT (statut Ouvert)',
|
||||
tags: ['work-orders'],
|
||||
request: { name: 'WorkOrderCreate', schema: WorkOrderCreateSchema },
|
||||
responses: {
|
||||
201: { description: 'Créé', name: 'WorkOrderDetail', schema: WorkOrderDetailSchema },
|
||||
},
|
||||
},
|
||||
{
|
||||
operationId: 'transitionWorkOrder',
|
||||
method: 'post',
|
||||
path: '/work-orders/{id}/transition',
|
||||
summary: 'Changer l’état (machine à états stricte ; DONE exige bilan + checklist)',
|
||||
tags: ['work-orders'],
|
||||
pathParams: ['id'],
|
||||
request: { name: 'TransitionRequest', schema: TransitionRequestSchema },
|
||||
responses: {
|
||||
200: { description: 'État changé', name: 'WorkOrderDetail', schema: WorkOrderDetailSchema },
|
||||
409: { description: 'Transition interdite ou clôture bloquée (garde)' },
|
||||
},
|
||||
},
|
||||
{
|
||||
operationId: 'commentWorkOrder',
|
||||
method: 'post',
|
||||
path: '/work-orders/{id}/comments',
|
||||
summary: 'Commenter (activité chronologique)',
|
||||
tags: ['work-orders'],
|
||||
pathParams: ['id'],
|
||||
request: { name: 'CommentCreate', schema: CommentCreateSchema },
|
||||
responses: {
|
||||
201: { description: 'Commentaire ajouté', name: 'WorkOrderDetail', schema: WorkOrderDetailSchema },
|
||||
},
|
||||
},
|
||||
{
|
||||
operationId: 'setWorkOrderAssignees',
|
||||
method: 'put',
|
||||
path: '/work-orders/{id}/assignees',
|
||||
summary: 'Assigner (remplace la liste)',
|
||||
tags: ['work-orders'],
|
||||
pathParams: ['id'],
|
||||
request: { name: 'AssigneesUpdate', schema: AssigneesUpdateSchema },
|
||||
responses: {
|
||||
200: { description: 'Assignés', name: 'WorkOrderDetail', schema: WorkOrderDetailSchema },
|
||||
},
|
||||
},
|
||||
{
|
||||
operationId: 'upsertWorkOrderReport',
|
||||
method: 'put',
|
||||
path: '/work-orders/{id}/report',
|
||||
summary: 'Renseigner le bilan codé (null efface un champ)',
|
||||
tags: ['work-orders'],
|
||||
pathParams: ['id'],
|
||||
request: { name: 'ReportUpsert', schema: ReportUpsertSchema },
|
||||
responses: {
|
||||
200: { description: 'Bilan enregistré', name: 'WorkOrderDetail', schema: WorkOrderDetailSchema },
|
||||
400: { description: 'Valeur hors référentiel du champ' },
|
||||
},
|
||||
},
|
||||
{
|
||||
operationId: 'patchChecklistItem',
|
||||
method: 'patch',
|
||||
path: '/work-orders/{id}/checklist/{itemId}',
|
||||
summary: 'Régler une tâche de la grille (Fait / N-A / à faire)',
|
||||
tags: ['work-orders'],
|
||||
pathParams: ['id', 'itemId'],
|
||||
request: { name: 'ChecklistPatch', schema: ChecklistPatchSchema },
|
||||
responses: {
|
||||
200: { description: 'Tâche réglée', name: 'ChecklistItem', schema: ChecklistItemSchema },
|
||||
404: { description: 'Tâche inconnue' },
|
||||
},
|
||||
},
|
||||
{
|
||||
operationId: 'listRequests',
|
||||
method: 'get',
|
||||
path: '/requests',
|
||||
summary: 'Demandes (sans « voir autre » : seulement les siennes)',
|
||||
tags: ['requests'],
|
||||
responses: {
|
||||
200: { description: 'Liste', name: 'RequestsResponse', schema: RequestsResponseSchema },
|
||||
},
|
||||
},
|
||||
{
|
||||
operationId: 'createRequest',
|
||||
method: 'post',
|
||||
path: '/requests',
|
||||
summary: 'Signaler (interne — le portail public QR arrive en R2.4)',
|
||||
tags: ['requests'],
|
||||
request: { name: 'RequestCreate', schema: RequestCreateSchema },
|
||||
responses: {
|
||||
201: { description: 'Demande créée', name: 'RequestSummary', schema: RequestSummarySchema },
|
||||
},
|
||||
},
|
||||
{
|
||||
operationId: 'approveRequest',
|
||||
method: 'post',
|
||||
path: '/requests/{id}/approve',
|
||||
summary: 'Approuver → crée l’OT lié (1-1, jamais de doublon)',
|
||||
tags: ['requests'],
|
||||
pathParams: ['id'],
|
||||
request: { name: 'RequestApprove', schema: RequestApproveSchema },
|
||||
responses: {
|
||||
201: { description: 'OT créé et lié', name: 'WorkOrderDetail', schema: WorkOrderDetailSchema },
|
||||
409: { description: 'Demande déjà traitée' },
|
||||
},
|
||||
},
|
||||
{
|
||||
operationId: 'rejectRequest',
|
||||
method: 'post',
|
||||
path: '/requests/{id}/reject',
|
||||
summary: 'Rejeter — motif obligatoire, lisible côté demandeur',
|
||||
tags: ['requests'],
|
||||
pathParams: ['id'],
|
||||
request: { name: 'RequestReject', schema: RequestRejectSchema },
|
||||
responses: {
|
||||
200: { description: 'Rejetée', name: 'RequestSummary', schema: RequestSummarySchema },
|
||||
409: { description: 'Demande déjà traitée' },
|
||||
},
|
||||
},
|
||||
{
|
||||
operationId: 'listReferenceValues',
|
||||
method: 'get',
|
||||
path: '/reference-values',
|
||||
summary: 'Référentiels du bilan codé (6 champs)',
|
||||
tags: ['reference-values'],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Liste',
|
||||
name: 'ReferenceValuesResponse',
|
||||
schema: ReferenceValuesResponseSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
operationId: 'createReferenceValue',
|
||||
method: 'post',
|
||||
path: '/reference-values',
|
||||
summary: 'Ajouter une valeur de référentiel',
|
||||
tags: ['reference-values'],
|
||||
request: { name: 'ReferenceValueCreate', schema: ReferenceValueCreateSchema },
|
||||
responses: {
|
||||
201: { description: 'Créée', name: 'ReferenceValue', schema: ReferenceValueSchema },
|
||||
409: { description: 'Libellé déjà présent pour ce champ' },
|
||||
},
|
||||
},
|
||||
{
|
||||
operationId: 'updateReferenceValue',
|
||||
method: 'patch',
|
||||
path: '/reference-values/{id}',
|
||||
summary: 'Renommer ou (dés)activer — jamais de suppression si utilisée',
|
||||
tags: ['reference-values'],
|
||||
pathParams: ['id'],
|
||||
request: { name: 'ReferenceValueUpdate', schema: ReferenceValueUpdateSchema },
|
||||
responses: {
|
||||
200: { description: 'Mise à jour', name: 'ReferenceValue', schema: ReferenceValueSchema },
|
||||
404: { description: 'Inconnue' },
|
||||
},
|
||||
},
|
||||
{
|
||||
operationId: 'getHealth',
|
||||
method: 'get',
|
||||
|
||||
93
packages/shared/src/exploitation.ts
Normal file
93
packages/shared/src/exploitation.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/** Vocabulaires de l'exploitation (R2) — partagés API / web / seed. */
|
||||
|
||||
export const WORK_ORDER_TYPES = ['CORRECTIVE', 'PREVENTIVE', 'WORKS'] as const;
|
||||
export type WorkOrderType = (typeof WORK_ORDER_TYPES)[number];
|
||||
export const WORK_ORDER_TYPE_LABELS: Record<WorkOrderType, string> = {
|
||||
CORRECTIVE: 'Dépannage',
|
||||
PREVENTIVE: 'Maintenance',
|
||||
WORKS: 'Travaux',
|
||||
};
|
||||
|
||||
export const WORK_ORDER_STATUSES = [
|
||||
'OPEN',
|
||||
'IN_PROGRESS',
|
||||
'ON_HOLD',
|
||||
'DONE',
|
||||
'CANCELLED',
|
||||
] as const;
|
||||
export type WorkOrderStatus = (typeof WORK_ORDER_STATUSES)[number];
|
||||
export const WORK_ORDER_STATUS_LABELS: Record<WorkOrderStatus, string> = {
|
||||
OPEN: 'Ouvert',
|
||||
IN_PROGRESS: 'En cours',
|
||||
ON_HOLD: 'En attente',
|
||||
DONE: 'Terminé',
|
||||
CANCELLED: 'Annulé',
|
||||
};
|
||||
|
||||
/** Machine à états STRICTE — la table est la loi, côté API comme côté UI. */
|
||||
export const WORK_ORDER_TRANSITIONS: Record<WorkOrderStatus, WorkOrderStatus[]> = {
|
||||
OPEN: ['IN_PROGRESS', 'CANCELLED'],
|
||||
IN_PROGRESS: ['ON_HOLD', 'DONE', 'CANCELLED'],
|
||||
ON_HOLD: ['IN_PROGRESS', 'CANCELLED'],
|
||||
DONE: [],
|
||||
CANCELLED: [],
|
||||
};
|
||||
|
||||
export const WORK_ORDER_PRIORITIES = [
|
||||
'NONE',
|
||||
'LOW',
|
||||
'MEDIUM',
|
||||
'HIGH',
|
||||
'PERSON_TRAPPED',
|
||||
] as const;
|
||||
export type WorkOrderPriority = (typeof WORK_ORDER_PRIORITIES)[number];
|
||||
export const WORK_ORDER_PRIORITY_LABELS: Record<WorkOrderPriority, string> = {
|
||||
NONE: 'Aucune',
|
||||
LOW: 'Basse',
|
||||
MEDIUM: 'Moyenne',
|
||||
HIGH: 'Haute',
|
||||
PERSON_TRAPPED: 'Personne bloquée',
|
||||
};
|
||||
|
||||
export const REQUEST_STATUSES = ['RECEIVED', 'APPROVED', 'REJECTED'] as const;
|
||||
export type RequestStatus = (typeof REQUEST_STATUSES)[number];
|
||||
export const REQUEST_STATUS_LABELS: Record<RequestStatus, string> = {
|
||||
RECEIVED: 'Reçue',
|
||||
APPROVED: 'Approuvée',
|
||||
REJECTED: 'Rejetée',
|
||||
};
|
||||
|
||||
export const CHECKLIST_STATES = ['PENDING', 'DONE', 'NA'] as const;
|
||||
export type ChecklistState = (typeof CHECKLIST_STATES)[number];
|
||||
|
||||
export const BILAN_FIELDS = [
|
||||
'DOOR_STATE',
|
||||
'CABIN_POSITION',
|
||||
'ANOMALY',
|
||||
'EXTERNAL_CAUSE',
|
||||
'ACTION_TAKEN',
|
||||
'COMPONENT_CONCERNED',
|
||||
] as const;
|
||||
export type BilanField = (typeof BILAN_FIELDS)[number];
|
||||
export const BILAN_FIELD_LABELS: Record<BilanField, string> = {
|
||||
DOOR_STATE: 'État des portes',
|
||||
CABIN_POSITION: 'Position cabine',
|
||||
ANOMALY: 'Anomalie constatée',
|
||||
EXTERNAL_CAUSE: 'Cause extérieure',
|
||||
ACTION_TAKEN: 'Action réalisée',
|
||||
COMPONENT_CONCERNED: 'Élément concerné',
|
||||
};
|
||||
|
||||
/** Champs du bilan REQUIS pour clôturer (maquette fiche OT validée R0). */
|
||||
export const REQUIRED_BILAN_FIELDS: readonly BilanField[] = [
|
||||
'DOOR_STATE',
|
||||
'ACTION_TAKEN',
|
||||
'COMPONENT_CONCERNED',
|
||||
];
|
||||
|
||||
export const METER_KINDS = ['RUNNING_HOURS', 'STARTS'] as const;
|
||||
export type MeterKind = (typeof METER_KINDS)[number];
|
||||
export const METER_KIND_LABELS: Record<MeterKind, string> = {
|
||||
RUNNING_HOURS: 'Heures de marche',
|
||||
STARTS: 'Démarrages',
|
||||
};
|
||||
@@ -1,5 +1,7 @@
|
||||
export * from './permissions';
|
||||
export * from './referentiel';
|
||||
export * from './exploitation';
|
||||
export * from './schemas/exploitation';
|
||||
export * from './schemas/auth';
|
||||
export * from './schemas/users';
|
||||
export * from './schemas/users-admin';
|
||||
|
||||
209
packages/shared/src/schemas/exploitation.ts
Normal file
209
packages/shared/src/schemas/exploitation.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
BILAN_FIELDS,
|
||||
CHECKLIST_STATES,
|
||||
REQUEST_STATUSES,
|
||||
WORK_ORDER_PRIORITIES,
|
||||
WORK_ORDER_STATUSES,
|
||||
WORK_ORDER_TYPES,
|
||||
} from '../exploitation';
|
||||
|
||||
const PersonneSchema = z.object({
|
||||
id: z.uuid(),
|
||||
displayName: z.string(),
|
||||
initials: z.string(),
|
||||
});
|
||||
|
||||
// ————— Ordres de travail —————
|
||||
|
||||
export const WorkOrderSummarySchema = z.object({
|
||||
id: z.uuid(),
|
||||
reference: z.string(),
|
||||
title: z.string(),
|
||||
type: z.enum(WORK_ORDER_TYPES),
|
||||
status: z.enum(WORK_ORDER_STATUSES),
|
||||
priority: z.enum(WORK_ORDER_PRIORITIES),
|
||||
assetId: z.uuid(),
|
||||
assetReference: z.string(),
|
||||
siteName: z.string(),
|
||||
dueDate: z.iso.datetime().nullable(),
|
||||
assignees: z.array(PersonneSchema),
|
||||
createdAt: z.iso.datetime(),
|
||||
});
|
||||
export type WorkOrderSummary = z.infer<typeof WorkOrderSummarySchema>;
|
||||
|
||||
export const WorkOrdersResponseSchema = z.object({
|
||||
workOrders: z.array(WorkOrderSummarySchema),
|
||||
});
|
||||
export type WorkOrdersResponse = z.infer<typeof WorkOrdersResponseSchema>;
|
||||
|
||||
export const WorkOrderEventSchema = z.object({
|
||||
id: z.uuid(),
|
||||
kind: z.string(),
|
||||
message: z.string().nullable(),
|
||||
by: PersonneSchema.nullable(),
|
||||
createdAt: z.iso.datetime(),
|
||||
});
|
||||
|
||||
export const ChecklistItemSchema = z.object({
|
||||
id: z.uuid(),
|
||||
label: z.string(),
|
||||
state: z.enum(CHECKLIST_STATES),
|
||||
doneBy: PersonneSchema.nullable(),
|
||||
doneAt: z.iso.datetime().nullable(),
|
||||
});
|
||||
export type ChecklistItemDto = z.infer<typeof ChecklistItemSchema>;
|
||||
|
||||
const BilanValueSchema = z.object({ id: z.uuid(), label: z.string() }).nullable();
|
||||
|
||||
export const InterventionReportSchema = z.object({
|
||||
note: z.string().nullable(),
|
||||
doorState: BilanValueSchema,
|
||||
cabinPosition: BilanValueSchema,
|
||||
anomaly: BilanValueSchema,
|
||||
externalCause: BilanValueSchema,
|
||||
actionTaken: BilanValueSchema,
|
||||
componentConcerned: BilanValueSchema,
|
||||
});
|
||||
export type InterventionReportDto = z.infer<typeof InterventionReportSchema>;
|
||||
|
||||
export const WorkOrderDetailSchema = WorkOrderSummarySchema.extend({
|
||||
description: z.string().nullable(),
|
||||
locationName: z.string(),
|
||||
startedAt: z.iso.datetime().nullable(),
|
||||
completedAt: z.iso.datetime().nullable(),
|
||||
cancelledAt: z.iso.datetime().nullable(),
|
||||
createdBy: PersonneSchema.nullable(),
|
||||
request: z
|
||||
.object({ id: z.uuid(), reference: z.string(), requesterLabel: z.string() })
|
||||
.nullable(),
|
||||
events: z.array(WorkOrderEventSchema),
|
||||
checklist: z.array(ChecklistItemSchema),
|
||||
report: InterventionReportSchema.nullable(),
|
||||
/** Ce que la machine à états autorise depuis l'état courant. */
|
||||
allowedTransitions: z.array(z.enum(WORK_ORDER_STATUSES)),
|
||||
/** Ce qui bloque la clôture (vide = clôturable) — messages métier. */
|
||||
closureBlockers: z.array(z.string()),
|
||||
});
|
||||
export type WorkOrderDetail = z.infer<typeof WorkOrderDetailSchema>;
|
||||
|
||||
export const WorkOrderCreateSchema = z.object({
|
||||
title: z.string().min(1).max(200),
|
||||
description: z.string().max(2000).optional(),
|
||||
type: z.enum(WORK_ORDER_TYPES),
|
||||
priority: z.enum(WORK_ORDER_PRIORITIES).optional(),
|
||||
assetId: z.uuid(),
|
||||
dueDate: z.iso.datetime().optional(),
|
||||
assigneeIds: z.array(z.uuid()).optional(),
|
||||
});
|
||||
export type WorkOrderCreate = z.infer<typeof WorkOrderCreateSchema>;
|
||||
|
||||
export const TransitionRequestSchema = z.object({
|
||||
to: z.enum(WORK_ORDER_STATUSES),
|
||||
comment: z.string().max(500).optional(),
|
||||
});
|
||||
export type TransitionRequest = z.infer<typeof TransitionRequestSchema>;
|
||||
|
||||
export const CommentCreateSchema = z.object({
|
||||
message: z.string().min(1).max(1000),
|
||||
});
|
||||
export type CommentCreate = z.infer<typeof CommentCreateSchema>;
|
||||
|
||||
export const AssigneesUpdateSchema = z.object({
|
||||
assigneeIds: z.array(z.uuid()).max(10),
|
||||
});
|
||||
export type AssigneesUpdate = z.infer<typeof AssigneesUpdateSchema>;
|
||||
|
||||
/** Upsert du bilan — null efface un champ. */
|
||||
export const ReportUpsertSchema = z.object({
|
||||
note: z.string().max(2000).nullable().optional(),
|
||||
doorStateId: z.uuid().nullable().optional(),
|
||||
cabinPositionId: z.uuid().nullable().optional(),
|
||||
anomalyId: z.uuid().nullable().optional(),
|
||||
externalCauseId: z.uuid().nullable().optional(),
|
||||
actionTakenId: z.uuid().nullable().optional(),
|
||||
componentConcernedId: z.uuid().nullable().optional(),
|
||||
});
|
||||
export type ReportUpsert = z.infer<typeof ReportUpsertSchema>;
|
||||
|
||||
export const ChecklistPatchSchema = z.object({
|
||||
state: z.enum(CHECKLIST_STATES),
|
||||
});
|
||||
export type ChecklistPatch = z.infer<typeof ChecklistPatchSchema>;
|
||||
|
||||
// ————— Demandes —————
|
||||
|
||||
export const RequestSummarySchema = z.object({
|
||||
id: z.uuid(),
|
||||
reference: z.string(),
|
||||
description: z.string(),
|
||||
isPersonTrapped: z.boolean(),
|
||||
status: z.enum(REQUEST_STATUSES),
|
||||
rejectionReason: z.string().nullable(),
|
||||
assetId: z.uuid(),
|
||||
assetReference: z.string(),
|
||||
siteName: z.string(),
|
||||
requesterLabel: z.string(),
|
||||
workOrder: z
|
||||
.object({
|
||||
id: z.uuid(),
|
||||
reference: z.string(),
|
||||
status: z.enum(WORK_ORDER_STATUSES),
|
||||
})
|
||||
.nullable(),
|
||||
createdAt: z.iso.datetime(),
|
||||
});
|
||||
export type RequestSummary = z.infer<typeof RequestSummarySchema>;
|
||||
|
||||
export const RequestsResponseSchema = z.object({
|
||||
requests: z.array(RequestSummarySchema),
|
||||
});
|
||||
export type RequestsResponse = z.infer<typeof RequestsResponseSchema>;
|
||||
|
||||
export const RequestCreateSchema = z.object({
|
||||
assetId: z.uuid(),
|
||||
description: z.string().min(1).max(2000),
|
||||
isPersonTrapped: z.boolean().optional(),
|
||||
});
|
||||
export type RequestCreate = z.infer<typeof RequestCreateSchema>;
|
||||
|
||||
export const RequestApproveSchema = z.object({
|
||||
title: z.string().min(1).max(200).optional(), // défaut : description tronquée
|
||||
priority: z.enum(WORK_ORDER_PRIORITIES).optional(),
|
||||
assigneeIds: z.array(z.uuid()).optional(),
|
||||
dueDate: z.iso.datetime().optional(),
|
||||
});
|
||||
export type RequestApprove = z.infer<typeof RequestApproveSchema>;
|
||||
|
||||
export const RequestRejectSchema = z.object({
|
||||
reason: z.string().min(3, 'Le motif est requis').max(500),
|
||||
});
|
||||
export type RequestReject = z.infer<typeof RequestRejectSchema>;
|
||||
|
||||
// ————— Référentiels du bilan codé —————
|
||||
|
||||
export const ReferenceValueSchema = z.object({
|
||||
id: z.uuid(),
|
||||
field: z.enum(BILAN_FIELDS),
|
||||
label: z.string(),
|
||||
isActive: z.boolean(),
|
||||
usageCount: z.number().int(),
|
||||
});
|
||||
export type ReferenceValueDto = z.infer<typeof ReferenceValueSchema>;
|
||||
|
||||
export const ReferenceValuesResponseSchema = z.object({
|
||||
referenceValues: z.array(ReferenceValueSchema),
|
||||
});
|
||||
export type ReferenceValuesResponse = z.infer<typeof ReferenceValuesResponseSchema>;
|
||||
|
||||
export const ReferenceValueCreateSchema = z.object({
|
||||
field: z.enum(BILAN_FIELDS),
|
||||
label: z.string().min(1).max(120),
|
||||
});
|
||||
export type ReferenceValueCreate = z.infer<typeof ReferenceValueCreateSchema>;
|
||||
|
||||
export const ReferenceValueUpdateSchema = z.object({
|
||||
label: z.string().min(1).max(120).optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
});
|
||||
export type ReferenceValueUpdate = z.infer<typeof ReferenceValueUpdateSchema>;
|
||||
Reference in New Issue
Block a user