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.1 — socle backend** : migration `r1_referentiel` (Category, Location site→zone + PostGIS générée, Asset, AssetComponent, Team, invitation User), 21 nouvelles opérations au contrat (26 total), 4 modules API + invitations/activation sous `@RequirePermission`, seed parc maquette (5 sites, 8 appareils, organes, 2 équipes), 36 tests (96 %/85 %), CI sur `postgis/postgis:18-3.6`.
|
||||||
- ✅ **R1.2 — écrans web du référentiel** : 7 écrans fidèles à maquette-r1.html (Sites + carte Leaflet/OSM, fiche site, ascenseurs, fiche appareil + QR réel, création, étiquette A6 imprimable, personnes & équipes avec lien d'activation à copier, catégories) + page /activation ; navigation et actions pilotées par la matrice (`usePermissions`) ; recette R1 rejouée en e2e Playwright (dont activation d'un invité) ; alias Vite `@siop/shared` → source TS (leçon CJS/workspace).
|
- ✅ **R1.2 — écrans web du référentiel** : 7 écrans fidèles à maquette-r1.html (Sites + carte Leaflet/OSM, fiche site, ascenseurs, fiche appareil + QR réel, création, étiquette A6 imprimable, personnes & équipes avec lien d'activation à copier, catégories) + page /activation ; navigation et actions pilotées par la matrice (`usePermissions`) ; recette R1 rejouée en e2e Playwright (dont activation d'un invité) ; alias Vite `@siop/shared` → source TS (leçon CJS/workspace).
|
||||||
- 🏁 **R1 CLOSE (16/07/2026, tag `release/r1`)** : recettée par le référent, déployée et vérifiée en ligne (migration + seed au boot, 5 sites / 8 appareils sur l'instance).
|
- 🏁 **R1 CLOSE (16/07/2026, tag `release/r1`)** : recettée par le référent, déployée et vérifiée en ligne (migration + seed au boot, 5 sites / 8 appareils sur l'instance).
|
||||||
- 🔄 **R2 Exploitation — 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.
|
- 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
|
activationToken String? @unique
|
||||||
activationExpiresAt DateTime?
|
activationExpiresAt DateTime?
|
||||||
teams Team[]
|
teams Team[]
|
||||||
|
// R2 — exploitation
|
||||||
|
workOrdersAssigned WorkOrder[] @relation("WorkOrderAssignees")
|
||||||
|
workOrdersCreated WorkOrder[] @relation("WorkOrderCreator")
|
||||||
|
workOrderEvents WorkOrderEvent[]
|
||||||
|
requests Request[]
|
||||||
|
checklistDone ChecklistItem[]
|
||||||
|
meterReadings MeterReading[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -75,6 +82,7 @@ model Category {
|
|||||||
isActive Boolean @default(true) // désactivable, jamais supprimée si utilisée
|
isActive Boolean @default(true) // désactivable, jamais supprimée si utilisée
|
||||||
assets Asset[]
|
assets Asset[]
|
||||||
components AssetComponent[]
|
components AssetComponent[]
|
||||||
|
taskTemplates TaskTemplate[]
|
||||||
|
|
||||||
@@unique([kind, name])
|
@@unique([kind, name])
|
||||||
}
|
}
|
||||||
@@ -115,6 +123,9 @@ model Asset {
|
|||||||
locationId String @db.Uuid
|
locationId String @db.Uuid
|
||||||
location Location @relation(fields: [locationId], references: [id])
|
location Location @relation(fields: [locationId], references: [id])
|
||||||
components AssetComponent[]
|
components AssetComponent[]
|
||||||
|
workOrders WorkOrder[]
|
||||||
|
requests Request[]
|
||||||
|
meters Meter[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -141,3 +152,201 @@ model Team {
|
|||||||
description String?
|
description String?
|
||||||
members User[]
|
members User[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ————— R2 — Exploitation (docs/03-architecture/modele-donnees.md §R2) —————
|
||||||
|
|
||||||
|
enum WorkOrderType {
|
||||||
|
CORRECTIVE // Dépannage
|
||||||
|
PREVENTIVE // Maintenance (grille du mois)
|
||||||
|
WORKS // Travaux
|
||||||
|
}
|
||||||
|
|
||||||
|
enum WorkOrderStatus {
|
||||||
|
OPEN
|
||||||
|
IN_PROGRESS
|
||||||
|
ON_HOLD
|
||||||
|
DONE
|
||||||
|
CANCELLED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum WorkOrderPriority {
|
||||||
|
NONE
|
||||||
|
LOW
|
||||||
|
MEDIUM
|
||||||
|
HIGH
|
||||||
|
PERSON_TRAPPED // personne bloquée — urgence absolue
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RequestStatus {
|
||||||
|
RECEIVED
|
||||||
|
APPROVED
|
||||||
|
REJECTED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ChecklistState {
|
||||||
|
PENDING
|
||||||
|
DONE
|
||||||
|
NA
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BilanField {
|
||||||
|
DOOR_STATE
|
||||||
|
CABIN_POSITION
|
||||||
|
ANOMALY
|
||||||
|
EXTERNAL_CAUSE
|
||||||
|
ACTION_TAKEN
|
||||||
|
COMPONENT_CONCERNED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum MeterKind {
|
||||||
|
RUNNING_HOURS
|
||||||
|
STARTS
|
||||||
|
}
|
||||||
|
|
||||||
|
model WorkOrder {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
reference String @unique // OT-2026-0341
|
||||||
|
title String
|
||||||
|
description String?
|
||||||
|
type WorkOrderType
|
||||||
|
status WorkOrderStatus @default(OPEN) // machine à états stricte (service)
|
||||||
|
priority WorkOrderPriority @default(NONE)
|
||||||
|
assetId String @db.Uuid
|
||||||
|
asset Asset @relation(fields: [assetId], references: [id])
|
||||||
|
dueDate DateTime?
|
||||||
|
assignees User[] @relation("WorkOrderAssignees")
|
||||||
|
createdById String? @db.Uuid
|
||||||
|
createdBy User? @relation("WorkOrderCreator", fields: [createdById], references: [id])
|
||||||
|
startedAt DateTime?
|
||||||
|
completedAt DateTime?
|
||||||
|
cancelledAt DateTime?
|
||||||
|
events WorkOrderEvent[]
|
||||||
|
checklist ChecklistItem[]
|
||||||
|
report InterventionReport?
|
||||||
|
request Request?
|
||||||
|
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 seedUsers(prisma, roleIds, passwordHash);
|
||||||
await seedReferentiel(prisma);
|
await seedReferentiel(prisma);
|
||||||
|
await seedExploitation(prisma);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function seedUsers(
|
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 */
|
/* c8 ignore start — wrapper CLI */
|
||||||
if (require.main === module) {
|
if (require.main === module) {
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ import { demoModeEnabled } from './config/env';
|
|||||||
import { FilesModule } from './files/files.module';
|
import { FilesModule } from './files/files.module';
|
||||||
import { HealthModule } from './health/health.module';
|
import { HealthModule } from './health/health.module';
|
||||||
import { LocationsModule } from './locations/locations.module';
|
import { LocationsModule } from './locations/locations.module';
|
||||||
|
import { ReferenceValuesModule } from './reference-values/reference-values.module';
|
||||||
|
import { RequestsModule } from './requests/requests.module';
|
||||||
import { TeamsModule } from './teams/teams.module';
|
import { TeamsModule } from './teams/teams.module';
|
||||||
|
import { WorkOrdersModule } from './work-orders/work-orders.module';
|
||||||
import { PermissionsGuard } from './permissions/permissions.guard';
|
import { PermissionsGuard } from './permissions/permissions.guard';
|
||||||
import { PermissionsModule } from './permissions/permissions.module';
|
import { PermissionsModule } from './permissions/permissions.module';
|
||||||
import { PrismaModule } from './prisma/prisma.module';
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
@@ -37,6 +40,10 @@ export class AppModule {
|
|||||||
LocationsModule,
|
LocationsModule,
|
||||||
AssetsModule,
|
AssetsModule,
|
||||||
TeamsModule,
|
TeamsModule,
|
||||||
|
// R2 — exploitation
|
||||||
|
WorkOrdersModule,
|
||||||
|
RequestsModule,
|
||||||
|
ReferenceValuesModule,
|
||||||
// ADR-002 : hors DEMO_MODE, le module n'est pas enregistré → 404
|
// ADR-002 : hors DEMO_MODE, le module n'est pas enregistré → 404
|
||||||
...(demoModeEnabled() ? [DemoAuthModule] : []),
|
...(demoModeEnabled() ? [DemoAuthModule] : []),
|
||||||
],
|
],
|
||||||
|
|||||||
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"];
|
patch: operations["updateUser"];
|
||||||
trace?: never;
|
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": {
|
"/health": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -734,6 +941,285 @@ export interface components {
|
|||||||
teamIds?: string[];
|
teamIds?: string[];
|
||||||
isActive?: boolean;
|
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: {
|
HealthResponse: {
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
status: "ok" | "degraded";
|
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: {
|
getHealth: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
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) |
|
| 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 |
|
| 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)
|
## À 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`,
|
- **R3** : `Part`/`StockMovement` (stock **dérivé des mouvements**), `PurchaseOrder`,
|
||||||
`Partner`, `LaborTime` (taux figé), `Document`.
|
`Partner`, `LaborTime` (taux figé), `Document`.
|
||||||
- **R4** : `WorkOrder.version` (verrou optimiste de la synchro mobile).
|
- **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
|
## 2026-07-16 — Pr. Daaif (+ Claude) — R2 ouverte : maquettes des écrans manquants à valider
|
||||||
|
|
||||||
**Actions**
|
**Actions**
|
||||||
@@ -19,6 +38,7 @@ Trace chronologique des sessions (la plus récente en premier). Le **playbook**
|
|||||||
- Relevé de compteur strictement croissant (refus sinon).
|
- Relevé de compteur strictement croissant (refus sinon).
|
||||||
|
|
||||||
**⛔ Bloquant** : validation des maquettes R2 par le référent avant toute ligne de code R2.
|
**⛔ 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,
|
UsersResponseSchema,
|
||||||
UserUpdateSchema,
|
UserUpdateSchema,
|
||||||
} from './schemas/users-admin';
|
} 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).
|
* 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' },
|
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',
|
operationId: 'getHealth',
|
||||||
method: 'get',
|
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 './permissions';
|
||||||
export * from './referentiel';
|
export * from './referentiel';
|
||||||
|
export * from './exploitation';
|
||||||
|
export * from './schemas/exploitation';
|
||||||
export * from './schemas/auth';
|
export * from './schemas/auth';
|
||||||
export * from './schemas/users';
|
export * from './schemas/users';
|
||||||
export * from './schemas/users-admin';
|
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