diff --git a/CLAUDE.md b/CLAUDE.md index e241b4c..067d5ca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,5 +44,7 @@ pnpm + Turborepo. `apps/api` : NestJS, Prisma, PostgreSQL (pgvector + PostGIS), - ✅ **R1.1 — socle backend** : migration `r1_referentiel` (Category, Location site→zone + PostGIS générée, Asset, AssetComponent, Team, invitation User), 21 nouvelles opérations au contrat (26 total), 4 modules API + invitations/activation sous `@RequirePermission`, seed parc maquette (5 sites, 8 appareils, organes, 2 équipes), 36 tests (96 %/85 %), CI sur `postgis/postgis:18-3.6`. - ✅ **R1.2 — écrans web du référentiel** : 7 écrans fidèles à maquette-r1.html (Sites + carte Leaflet/OSM, fiche site, ascenseurs, fiche appareil + QR réel, création, étiquette A6 imprimable, personnes & équipes avec lien d'activation à copier, catégories) + page /activation ; navigation et actions pilotées par la matrice (`usePermissions`) ; recette R1 rejouée en e2e Playwright (dont activation d'un invité) ; alias Vite `@siop/shared` → source TS (leçon CJS/workspace). - 🏁 **R1 CLOSE (16/07/2026, tag `release/r1`)** : recettée par le référent, déployée et vérifiée en ligne (migration + seed au boot, 5 sites / 8 appareils sur l'instance). -- 🔄 **R2 Exploitation — ouverte, design d'abord** : `maquette-r2.html` (5 écrans : demandes/approbation, nouvel OT, préventif gabarits+génération, OT préventif checklist, compteurs) — **⛔ en attente de validation du référent avant tout code R2** (4 décisions soumises, dont la sémantique des statuts de demande). Écrans cœur déjà validés en R0. Ensuite : modèle R2 (WorkOrder machine à états, Request 1-1, InterventionReport bilan codé + ReferenceValue, TaskTemplate/ChecklistItem, Meter/MeterReading) → contrat → API (garde de clôture, génération idempotente BullMQ) → web → recette (parcours demande gardien → OT → bilan → clôture ; grille de juillet). +- ✅ **R2 — maquettes validées** (16/07) : `maquette-r2.html` (demandes/approbation, nouvel OT, préventif, checklist, compteurs) + 4 décisions actées. +- ✅ **R2.1 — socle backend exploitation** : migration `r2_exploitation` (10 tables), 15 opérations au contrat (41 total, transitions + champs requis du bilan dans `@siop/shared`), machine à états stricte avec garde de clôture (bilan 3 champs + checklist), demande→OT 1-1, rejet à motif, scoping « voir autre » (listes + accès directs), seed maquette (31 valeurs de bilan, 8 gabarits, OT/demandes/compteurs), 45 tests (95 %/79 %). +- 🔄 **R2.2 — reprise ici** : génération mensuelle du préventif (idempotente, premier contrôle, BullMQ ou déclenchement manuel + cron) + API compteurs (relevés croissants) → R2.3 écrans web (liste/fiche OT, demandes, nouvel OT, préventif, checklist, compteurs, chip urgence topbar + bandeau dashboard) → R2.4 portail public QR → recette + déploiement + tag. - Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0→R5. diff --git a/apps/api/prisma/migrations/20260716134528_r2_exploitation/migration.sql b/apps/api/prisma/migrations/20260716134528_r2_exploitation/migration.sql new file mode 100644 index 0000000..e0cf582 --- /dev/null +++ b/apps/api/prisma/migrations/20260716134528_r2_exploitation/migration.sql @@ -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; + diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 6a175fb..f8d9251 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -49,6 +49,13 @@ model User { activationToken String? @unique activationExpiresAt DateTime? teams Team[] + // R2 — exploitation + workOrdersAssigned WorkOrder[] @relation("WorkOrderAssignees") + workOrdersCreated WorkOrder[] @relation("WorkOrderCreator") + workOrderEvents WorkOrderEvent[] + requests Request[] + checklistDone ChecklistItem[] + meterReadings MeterReading[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -75,6 +82,7 @@ model Category { isActive Boolean @default(true) // désactivable, jamais supprimée si utilisée assets Asset[] components AssetComponent[] + taskTemplates TaskTemplate[] @@unique([kind, name]) } @@ -115,6 +123,9 @@ model Asset { locationId String @db.Uuid location Location @relation(fields: [locationId], references: [id]) components AssetComponent[] + workOrders WorkOrder[] + requests Request[] + meters Meter[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -141,3 +152,201 @@ model Team { description String? members User[] } + +// ————— R2 — Exploitation (docs/03-architecture/modele-donnees.md §R2) ————— + +enum WorkOrderType { + CORRECTIVE // Dépannage + PREVENTIVE // Maintenance (grille du mois) + WORKS // Travaux +} + +enum WorkOrderStatus { + OPEN + IN_PROGRESS + ON_HOLD + DONE + CANCELLED +} + +enum WorkOrderPriority { + NONE + LOW + MEDIUM + HIGH + PERSON_TRAPPED // personne bloquée — urgence absolue +} + +enum RequestStatus { + RECEIVED + APPROVED + REJECTED +} + +enum ChecklistState { + PENDING + DONE + NA +} + +enum BilanField { + DOOR_STATE + CABIN_POSITION + ANOMALY + EXTERNAL_CAUSE + ACTION_TAKEN + COMPONENT_CONCERNED +} + +enum MeterKind { + RUNNING_HOURS + STARTS +} + +model WorkOrder { + id String @id @default(uuid()) @db.Uuid + reference String @unique // OT-2026-0341 + title String + description String? + type WorkOrderType + status WorkOrderStatus @default(OPEN) // machine à états stricte (service) + priority WorkOrderPriority @default(NONE) + assetId String @db.Uuid + asset Asset @relation(fields: [assetId], references: [id]) + dueDate DateTime? + assignees User[] @relation("WorkOrderAssignees") + createdById String? @db.Uuid + createdBy User? @relation("WorkOrderCreator", fields: [createdById], references: [id]) + startedAt DateTime? + completedAt DateTime? + cancelledAt DateTime? + events WorkOrderEvent[] + checklist ChecklistItem[] + report InterventionReport? + request Request? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([assetId]) + @@index([status]) +} + +model WorkOrderEvent { + id String @id @default(uuid()) @db.Uuid + workOrderId String @db.Uuid + workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade) + kind String // COMMENT · STATUS_CHANGED · ASSIGNED · CREATED · FROM_REQUEST + message String? + byId String? @db.Uuid + by User? @relation(fields: [byId], references: [id]) + createdAt DateTime @default(now()) + + @@index([workOrderId]) +} + +model Request { + id String @id @default(uuid()) @db.Uuid + reference String @unique // DEM-2026-0112 + description String + isPersonTrapped Boolean @default(false) + status RequestStatus @default(RECEIVED) + rejectionReason String? // REQUIS au rejet (service) + assetId String @db.Uuid + asset Asset @relation(fields: [assetId], references: [id]) + requestedById String? @db.Uuid + requestedBy User? @relation(fields: [requestedById], references: [id]) + requesterName String? // portail public via QR (R2.4) + workOrderId String? @unique @db.Uuid // lien 1-1 — jamais de doublon + workOrder WorkOrder? @relation(fields: [workOrderId], references: [id]) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([assetId]) +} + +// Référentiels administrables du bilan codé (un par champ) +model ReferenceValue { + id String @id @default(uuid()) @db.Uuid + field BilanField + label String + isActive Boolean @default(true) + + doorStates InterventionReport[] @relation("BilanDoorState") + cabinPositions InterventionReport[] @relation("BilanCabinPosition") + anomalies InterventionReport[] @relation("BilanAnomaly") + externalCauses InterventionReport[] @relation("BilanExternalCause") + actionsTaken InterventionReport[] @relation("BilanActionTaken") + componentsConcerned InterventionReport[] @relation("BilanComponentConcerned") + + @@unique([field, label]) +} + +model InterventionReport { + id String @id @default(uuid()) @db.Uuid + workOrderId String @unique @db.Uuid + workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade) + note String? + + doorStateId String? @db.Uuid + doorState ReferenceValue? @relation("BilanDoorState", fields: [doorStateId], references: [id]) + cabinPositionId String? @db.Uuid + cabinPosition ReferenceValue? @relation("BilanCabinPosition", fields: [cabinPositionId], references: [id]) + anomalyId String? @db.Uuid + anomaly ReferenceValue? @relation("BilanAnomaly", fields: [anomalyId], references: [id]) + externalCauseId String? @db.Uuid + externalCause ReferenceValue? @relation("BilanExternalCause", fields: [externalCauseId], references: [id]) + actionTakenId String? @db.Uuid + actionTaken ReferenceValue? @relation("BilanActionTaken", fields: [actionTakenId], references: [id]) + componentConcernedId String? @db.Uuid + componentConcerned ReferenceValue? @relation("BilanComponentConcerned", fields: [componentConcernedId], references: [id]) + + updatedAt DateTime @updatedAt +} + +model TaskTemplate { + id String @id @default(uuid()) @db.Uuid + label String @unique + componentTypeId String? @db.Uuid + componentType Category? @relation(fields: [componentTypeId], references: [id]) + periodMonths Int // 1, 3, 6, 12… + isRegulatory Boolean @default(false) // essai parachute + isActive Boolean @default(true) + checklistItems ChecklistItem[] +} + +model ChecklistItem { + id String @id @default(uuid()) @db.Uuid + workOrderId String @db.Uuid + workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade) + label String + state ChecklistState @default(PENDING) + templateId String? @db.Uuid + template TaskTemplate? @relation(fields: [templateId], references: [id]) + doneById String? @db.Uuid + doneBy User? @relation(fields: [doneById], references: [id]) + doneAt DateTime? + + @@index([workOrderId]) +} + +model Meter { + id String @id @default(uuid()) @db.Uuid + assetId String @db.Uuid + asset Asset @relation(fields: [assetId], references: [id], onDelete: Cascade) + kind MeterKind + readings MeterReading[] + + @@unique([assetId, kind]) +} + +model MeterReading { + id String @id @default(uuid()) @db.Uuid + meterId String @db.Uuid + meter Meter @relation(fields: [meterId], references: [id], onDelete: Cascade) + value Int // strictement croissant (service) + readById String? @db.Uuid + readBy User? @relation(fields: [readById], references: [id]) + createdAt DateTime @default(now()) + + @@index([meterId]) +} diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts index 72afbd9..2653102 100644 --- a/apps/api/prisma/seed.ts +++ b/apps/api/prisma/seed.ts @@ -133,6 +133,7 @@ export async function seed(prisma: PrismaClient): Promise { ); await seedUsers(prisma, roleIds, passwordHash); await seedReferentiel(prisma); + await seedExploitation(prisma); } async function seedUsers( @@ -358,6 +359,227 @@ async function seedReferentiel(prisma: PrismaClient): Promise { } } +// ————— R2 — Exploitation (référentiels du bilan, gabarits, données maquette) ————— + +const REFERENCE_VALUES: Record = { + 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 { + const refIds = new Map(); // « 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; + }[] = [ + { + ref: `OT-${annee}-0342`, title: 'Personne bloquée en cabine', type: 'CORRECTIVE', + status: 'OPEN', priority: 'PERSON_TRAPPED', assetId: b2, + }, + { + ref: `OT-${annee}-0341`, title: 'Bruit anormal en gaine', type: 'CORRECTIVE', + status: 'IN_PROGRESS', priority: 'HIGH', assetId: a1, assignees: [ahmed], dueJours: 2, + }, + { + ref: `OT-${annee}-0338`, title: 'Grille du mois — juillet', type: 'PREVENTIVE', + status: 'IN_PROGRESS', priority: 'LOW', assetId: a1, assignees: [ahmed], + dueJours: 15, checklist: grilleLabels, + }, + { + ref: `OT-${annee}-0332`, title: 'Réglage nivellement cabine', type: 'CORRECTIVE', + status: 'DONE', priority: 'LOW', assetId: a1, + bilan: { + doorStateId: refIds.get('DOOR_STATE/Fonctionnement normal')!, + actionTakenId: refIds.get('ACTION_TAKEN/Réglage')!, + componentConcernedId: refIds.get('COMPONENT_CONCERNED/Guides')!, + }, + }, + ]; + for (const ot of OTS) { + const existant = await prisma.workOrder.findUnique({ where: { reference: ot.ref } }); + if (existant) continue; + await prisma.workOrder.create({ + data: { + reference: ot.ref, + title: ot.title, + type: ot.type, + status: ot.status, + priority: ot.priority, + assetId: ot.assetId, + createdById: salma, + dueDate: ot.dueJours + ? new Date(Date.now() + ot.dueJours * 24 * 3600 * 1000) + : undefined, + startedAt: ot.status === 'IN_PROGRESS' || ot.status === 'DONE' ? new Date() : undefined, + completedAt: ot.status === 'DONE' ? new Date() : undefined, + assignees: ot.assignees ? { connect: ot.assignees.map((id) => ({ id })) } : undefined, + events: { create: { kind: 'CREATED', message: 'OT créé (seed)', byId: salma } }, + checklist: ot.checklist + ? { create: ot.checklist.map((label) => ({ label })) } + : undefined, + report: ot.bilan ? { create: ot.bilan } : undefined, + }, + }); + } + + const DEMANDES: { + ref: string; description: string; assetId: string; isPersonTrapped?: boolean; + status: 'RECEIVED' | 'APPROVED' | 'REJECTED'; + rejectionReason?: string; otRef?: string; + }[] = [ + { + ref: `DEM-${annee}-0111`, assetId: a1, status: 'RECEIVED', + description: 'La porte ne se ferme plus au 3ᵉ étage, il faut la retenir à la main.', + }, + { ref: `DEM-${annee}-0110`, assetId: c1, status: 'RECEIVED', description: 'Voyant étage éteint.' }, + { + ref: `DEM-${annee}-0107`, assetId: a1, status: 'APPROVED', + description: 'Bruit anormal en gaine.', otRef: `OT-${annee}-0341`, + }, + { + ref: `DEM-${annee}-0104`, assetId: b2, status: 'REJECTED', + description: 'Odeur de brûlé.', rejectionReason: 'Fausse alerte confirmée sur place par le gardien.', + }, + ]; + for (const dem of DEMANDES) { + const existant = await prisma.request.findUnique({ where: { reference: dem.ref } }); + if (existant) continue; + const workOrderId = dem.otRef + ? (await prisma.workOrder.findUnique({ where: { reference: dem.otRef } }))?.id + : undefined; + await prisma.request.create({ + data: { + reference: dem.ref, + description: dem.description, + isPersonTrapped: dem.isPersonTrapped ?? false, + status: dem.status, + rejectionReason: dem.rejectionReason, + assetId: dem.assetId, + requestedById: karim, + workOrderId, + }, + }); + } + + // Compteurs de la maquette (A1) + for (const [kind, valeurs] of [ + ['RUNNING_HOURS', [12246, 12322, 12411]], + ['STARTS', [1815400, 1831970]], + ] as const) { + const meter = await prisma.meter.upsert({ + where: { assetId_kind: { assetId: a1, kind } }, + update: {}, + create: { assetId: a1, kind }, + include: { _count: { select: { readings: true } } }, + }); + if (meter._count.readings === 0) { + await prisma.meterReading.createMany({ + data: valeurs.map((value) => ({ meterId: meter.id, value, readById: ahmed })), + }); + } + } +} + /* c8 ignore start — wrapper CLI */ if (require.main === module) { const prisma = new PrismaClient(); diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 6210b2f..5bedadf 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -9,7 +9,10 @@ import { demoModeEnabled } from './config/env'; import { FilesModule } from './files/files.module'; import { HealthModule } from './health/health.module'; import { LocationsModule } from './locations/locations.module'; +import { ReferenceValuesModule } from './reference-values/reference-values.module'; +import { RequestsModule } from './requests/requests.module'; import { TeamsModule } from './teams/teams.module'; +import { WorkOrdersModule } from './work-orders/work-orders.module'; import { PermissionsGuard } from './permissions/permissions.guard'; import { PermissionsModule } from './permissions/permissions.module'; import { PrismaModule } from './prisma/prisma.module'; @@ -37,6 +40,10 @@ export class AppModule { LocationsModule, AssetsModule, TeamsModule, + // R2 — exploitation + WorkOrdersModule, + RequestsModule, + ReferenceValuesModule, // ADR-002 : hors DEMO_MODE, le module n'est pas enregistré → 404 ...(demoModeEnabled() ? [DemoAuthModule] : []), ], diff --git a/apps/api/src/reference-values/reference-values.controller.ts b/apps/api/src/reference-values/reference-values.controller.ts new file mode 100644 index 0000000..554bd63 --- /dev/null +++ b/apps/api/src/reference-values/reference-values.controller.ts @@ -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); + } +} diff --git a/apps/api/src/reference-values/reference-values.module.ts b/apps/api/src/reference-values/reference-values.module.ts new file mode 100644 index 0000000..6da7666 --- /dev/null +++ b/apps/api/src/reference-values/reference-values.module.ts @@ -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 {} diff --git a/apps/api/src/reference-values/reference-values.service.ts b/apps/api/src/reference-values/reference-values.service.ts new file mode 100644 index 0000000..0c1c36b --- /dev/null +++ b/apps/api/src/reference-values/reference-values.service.ts @@ -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 { + 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 { + 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 { + 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, + }; + } +} diff --git a/apps/api/src/requests/requests.controller.ts b/apps/api/src/requests/requests.controller.ts new file mode 100644 index 0000000..3b6f3b5 --- /dev/null +++ b/apps/api/src/requests/requests.controller.ts @@ -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); + } +} diff --git a/apps/api/src/requests/requests.module.ts b/apps/api/src/requests/requests.module.ts new file mode 100644 index 0000000..7c2a763 --- /dev/null +++ b/apps/api/src/requests/requests.module.ts @@ -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 {} diff --git a/apps/api/src/requests/requests.service.ts b/apps/api/src/requests/requests.service.ts new file mode 100644 index 0000000..f9c0dcc --- /dev/null +++ b/apps/api/src/requests/requests.service.ts @@ -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 { + const viewOther = await this.permissions.can(user.roleId, 'REQUESTS', 'viewOther'); + return viewOther ? {} : { requestedById: user.userId }; + } + + async list(user: AuthenticatedUser): Promise { + 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 { + 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 { + 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 { + 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 { + 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(), + }; + } +} diff --git a/apps/api/src/work-orders/work-orders.controller.ts b/apps/api/src/work-orders/work-orders.controller.ts new file mode 100644 index 0000000..387bb62 --- /dev/null +++ b/apps/api/src/work-orders/work-orders.controller.ts @@ -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); + } +} diff --git a/apps/api/src/work-orders/work-orders.module.ts b/apps/api/src/work-orders/work-orders.module.ts new file mode 100644 index 0000000..fdc787b --- /dev/null +++ b/apps/api/src/work-orders/work-orders.module.ts @@ -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 {} diff --git a/apps/api/src/work-orders/work-orders.service.ts b/apps/api/src/work-orders/work-orders.service.ts new file mode 100644 index 0000000..7e67447 --- /dev/null +++ b/apps/api/src/work-orders/work-orders.service.ts @@ -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 = { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 = { + 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, + }; + } +} diff --git a/apps/api/test/exploitation.e2e-spec.ts b/apps/api/test/exploitation.e2e-spec.ts new file mode 100644 index 0000000..32d8548 --- /dev/null +++ b/apps/api/test/exploitation.e2e-spec.ts @@ -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 } }); + }); +}); diff --git a/apps/web/src/api/schema.d.ts b/apps/web/src/api/schema.d.ts index 11a91df..fb82314 100644 --- a/apps/web/src/api/schema.d.ts +++ b/apps/web/src/api/schema.d.ts @@ -355,6 +355,213 @@ export interface paths { patch: operations["updateUser"]; trace?: never; }; + "/work-orders": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Ordres de travail (sans « voir autre » : seulement les siens) */ + get: operations["listWorkOrders"]; + put?: never; + /** Créer un OT (statut Ouvert) */ + post: operations["createWorkOrder"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/work-orders/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Fiche OT (activité, checklist, bilan, transitions autorisées, blocages de clôture) */ + get: operations["getWorkOrder"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/work-orders/{id}/transition": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Changer l’état (machine à états stricte ; DONE exige bilan + checklist) */ + post: operations["transitionWorkOrder"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/work-orders/{id}/comments": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Commenter (activité chronologique) */ + post: operations["commentWorkOrder"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/work-orders/{id}/assignees": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Assigner (remplace la liste) */ + put: operations["setWorkOrderAssignees"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/work-orders/{id}/report": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Renseigner le bilan codé (null efface un champ) */ + put: operations["upsertWorkOrderReport"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/work-orders/{id}/checklist/{itemId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** Régler une tâche de la grille (Fait / N-A / à faire) */ + patch: operations["patchChecklistItem"]; + trace?: never; + }; + "/requests": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Demandes (sans « voir autre » : seulement les siennes) */ + get: operations["listRequests"]; + put?: never; + /** Signaler (interne — le portail public QR arrive en R2.4) */ + post: operations["createRequest"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/requests/{id}/approve": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Approuver → crée l’OT lié (1-1, jamais de doublon) */ + post: operations["approveRequest"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/requests/{id}/reject": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Rejeter — motif obligatoire, lisible côté demandeur */ + post: operations["rejectRequest"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/reference-values": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Référentiels du bilan codé (6 champs) */ + get: operations["listReferenceValues"]; + put?: never; + /** Ajouter une valeur de référentiel */ + post: operations["createReferenceValue"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/reference-values/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** Renommer ou (dés)activer — jamais de suppression si utilisée */ + patch: operations["updateReferenceValue"]; + trace?: never; + }; "/health": { parameters: { query?: never; @@ -734,6 +941,285 @@ export interface components { teamIds?: string[]; isActive?: boolean; }; + WorkOrdersResponse: { + workOrders: { + /** Format: uuid */ + id: string; + reference: string; + title: string; + /** @enum {string} */ + type: "CORRECTIVE" | "PREVENTIVE" | "WORKS"; + /** @enum {string} */ + status: "OPEN" | "IN_PROGRESS" | "ON_HOLD" | "DONE" | "CANCELLED"; + /** @enum {string} */ + priority: "NONE" | "LOW" | "MEDIUM" | "HIGH" | "PERSON_TRAPPED"; + /** Format: uuid */ + assetId: string; + assetReference: string; + siteName: string; + dueDate: string | null; + assignees: { + /** Format: uuid */ + id: string; + displayName: string; + initials: string; + }[]; + /** Format: date-time */ + createdAt: string; + }[]; + }; + WorkOrderDetail: { + /** Format: uuid */ + id: string; + reference: string; + title: string; + /** @enum {string} */ + type: "CORRECTIVE" | "PREVENTIVE" | "WORKS"; + /** @enum {string} */ + status: "OPEN" | "IN_PROGRESS" | "ON_HOLD" | "DONE" | "CANCELLED"; + /** @enum {string} */ + priority: "NONE" | "LOW" | "MEDIUM" | "HIGH" | "PERSON_TRAPPED"; + /** Format: uuid */ + assetId: string; + assetReference: string; + siteName: string; + dueDate: string | null; + assignees: { + /** Format: uuid */ + id: string; + displayName: string; + initials: string; + }[]; + /** Format: date-time */ + createdAt: string; + description: string | null; + locationName: string; + startedAt: string | null; + completedAt: string | null; + cancelledAt: string | null; + createdBy: { + /** Format: uuid */ + id: string; + displayName: string; + initials: string; + } | null; + request: { + /** Format: uuid */ + id: string; + reference: string; + requesterLabel: string; + } | null; + events: { + /** Format: uuid */ + id: string; + kind: string; + message: string | null; + by: { + /** Format: uuid */ + id: string; + displayName: string; + initials: string; + } | null; + /** Format: date-time */ + createdAt: string; + }[]; + checklist: { + /** Format: uuid */ + id: string; + label: string; + /** @enum {string} */ + state: "PENDING" | "DONE" | "NA"; + doneBy: { + /** Format: uuid */ + id: string; + displayName: string; + initials: string; + } | null; + doneAt: string | null; + }[]; + report: { + note: string | null; + doorState: { + /** Format: uuid */ + id: string; + label: string; + } | null; + cabinPosition: { + /** Format: uuid */ + id: string; + label: string; + } | null; + anomaly: { + /** Format: uuid */ + id: string; + label: string; + } | null; + externalCause: { + /** Format: uuid */ + id: string; + label: string; + } | null; + actionTaken: { + /** Format: uuid */ + id: string; + label: string; + } | null; + componentConcerned: { + /** Format: uuid */ + id: string; + label: string; + } | null; + } | null; + allowedTransitions: ("OPEN" | "IN_PROGRESS" | "ON_HOLD" | "DONE" | "CANCELLED")[]; + closureBlockers: string[]; + }; + WorkOrderCreate: { + title: string; + description?: string; + /** @enum {string} */ + type: "CORRECTIVE" | "PREVENTIVE" | "WORKS"; + /** @enum {string} */ + priority?: "NONE" | "LOW" | "MEDIUM" | "HIGH" | "PERSON_TRAPPED"; + /** Format: uuid */ + assetId: string; + /** Format: date-time */ + dueDate?: string; + assigneeIds?: string[]; + }; + TransitionRequest: { + /** @enum {string} */ + to: "OPEN" | "IN_PROGRESS" | "ON_HOLD" | "DONE" | "CANCELLED"; + comment?: string; + }; + CommentCreate: { + message: string; + }; + AssigneesUpdate: { + assigneeIds: string[]; + }; + ReportUpsert: { + note?: string | null; + doorStateId?: string | null; + cabinPositionId?: string | null; + anomalyId?: string | null; + externalCauseId?: string | null; + actionTakenId?: string | null; + componentConcernedId?: string | null; + }; + ChecklistItem: { + /** Format: uuid */ + id: string; + label: string; + /** @enum {string} */ + state: "PENDING" | "DONE" | "NA"; + doneBy: { + /** Format: uuid */ + id: string; + displayName: string; + initials: string; + } | null; + doneAt: string | null; + }; + ChecklistPatch: { + /** @enum {string} */ + state: "PENDING" | "DONE" | "NA"; + }; + RequestsResponse: { + requests: { + /** Format: uuid */ + id: string; + reference: string; + description: string; + isPersonTrapped: boolean; + /** @enum {string} */ + status: "RECEIVED" | "APPROVED" | "REJECTED"; + rejectionReason: string | null; + /** Format: uuid */ + assetId: string; + assetReference: string; + siteName: string; + requesterLabel: string; + workOrder: { + /** Format: uuid */ + id: string; + reference: string; + /** @enum {string} */ + status: "OPEN" | "IN_PROGRESS" | "ON_HOLD" | "DONE" | "CANCELLED"; + } | null; + /** Format: date-time */ + createdAt: string; + }[]; + }; + RequestSummary: { + /** Format: uuid */ + id: string; + reference: string; + description: string; + isPersonTrapped: boolean; + /** @enum {string} */ + status: "RECEIVED" | "APPROVED" | "REJECTED"; + rejectionReason: string | null; + /** Format: uuid */ + assetId: string; + assetReference: string; + siteName: string; + requesterLabel: string; + workOrder: { + /** Format: uuid */ + id: string; + reference: string; + /** @enum {string} */ + status: "OPEN" | "IN_PROGRESS" | "ON_HOLD" | "DONE" | "CANCELLED"; + } | null; + /** Format: date-time */ + createdAt: string; + }; + RequestCreate: { + /** Format: uuid */ + assetId: string; + description: string; + isPersonTrapped?: boolean; + }; + RequestApprove: { + title?: string; + /** @enum {string} */ + priority?: "NONE" | "LOW" | "MEDIUM" | "HIGH" | "PERSON_TRAPPED"; + assigneeIds?: string[]; + /** Format: date-time */ + dueDate?: string; + }; + RequestReject: { + reason: string; + }; + ReferenceValuesResponse: { + referenceValues: { + /** Format: uuid */ + id: string; + /** @enum {string} */ + field: "DOOR_STATE" | "CABIN_POSITION" | "ANOMALY" | "EXTERNAL_CAUSE" | "ACTION_TAKEN" | "COMPONENT_CONCERNED"; + label: string; + isActive: boolean; + usageCount: number; + }[]; + }; + ReferenceValue: { + /** Format: uuid */ + id: string; + /** @enum {string} */ + field: "DOOR_STATE" | "CABIN_POSITION" | "ANOMALY" | "EXTERNAL_CAUSE" | "ACTION_TAKEN" | "COMPONENT_CONCERNED"; + label: string; + isActive: boolean; + usageCount: number; + }; + ReferenceValueCreate: { + /** @enum {string} */ + field: "DOOR_STATE" | "CABIN_POSITION" | "ANOMALY" | "EXTERNAL_CAUSE" | "ACTION_TAKEN" | "COMPONENT_CONCERNED"; + label: string; + }; + ReferenceValueUpdate: { + label?: string; + isActive?: boolean; + }; HealthResponse: { /** @enum {string} */ status: "ok" | "degraded"; @@ -1461,6 +1947,425 @@ export interface operations { }; }; }; + listWorkOrders: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Liste */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkOrdersResponse"]; + }; + }; + }; + }; + createWorkOrder: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["WorkOrderCreate"]; + }; + }; + responses: { + /** @description Créé */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkOrderDetail"]; + }; + }; + }; + }; + getWorkOrder: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Fiche */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkOrderDetail"]; + }; + }; + /** @description Inconnu (ou hors de son périmètre) */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + transitionWorkOrder: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TransitionRequest"]; + }; + }; + responses: { + /** @description État changé */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkOrderDetail"]; + }; + }; + /** @description Transition interdite ou clôture bloquée (garde) */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + commentWorkOrder: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CommentCreate"]; + }; + }; + responses: { + /** @description Commentaire ajouté */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkOrderDetail"]; + }; + }; + }; + }; + setWorkOrderAssignees: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AssigneesUpdate"]; + }; + }; + responses: { + /** @description Assignés */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkOrderDetail"]; + }; + }; + }; + }; + upsertWorkOrderReport: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ReportUpsert"]; + }; + }; + responses: { + /** @description Bilan enregistré */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkOrderDetail"]; + }; + }; + /** @description Valeur hors référentiel du champ */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + patchChecklistItem: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + itemId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ChecklistPatch"]; + }; + }; + responses: { + /** @description Tâche réglée */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ChecklistItem"]; + }; + }; + /** @description Tâche inconnue */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + listRequests: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Liste */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RequestsResponse"]; + }; + }; + }; + }; + createRequest: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RequestCreate"]; + }; + }; + responses: { + /** @description Demande créée */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RequestSummary"]; + }; + }; + }; + }; + approveRequest: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RequestApprove"]; + }; + }; + responses: { + /** @description OT créé et lié */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkOrderDetail"]; + }; + }; + /** @description Demande déjà traitée */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + rejectRequest: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RequestReject"]; + }; + }; + responses: { + /** @description Rejetée */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RequestSummary"]; + }; + }; + /** @description Demande déjà traitée */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + listReferenceValues: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Liste */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ReferenceValuesResponse"]; + }; + }; + }; + }; + createReferenceValue: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ReferenceValueCreate"]; + }; + }; + responses: { + /** @description Créée */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ReferenceValue"]; + }; + }; + /** @description Libellé déjà présent pour ce champ */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + updateReferenceValue: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ReferenceValueUpdate"]; + }; + }; + responses: { + /** @description Mise à jour */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ReferenceValue"]; + }; + }; + /** @description Inconnue */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; getHealth: { parameters: { query?: never; diff --git a/docs/03-architecture/modele-donnees.md b/docs/03-architecture/modele-donnees.md index 9763544..9e8f98c 100644 --- a/docs/03-architecture/modele-donnees.md +++ b/docs/03-architecture/modele-donnees.md @@ -116,11 +116,78 @@ model Team { // équipes par zone — l'assignation d'OT (R2) s | Lien d'activation : 7 jours, usage unique, aucun compte actif avant | service users (+ test e2e) | | Position : lat/lng saisis, colonne PostGIS **générée** pour les requêtes spatiales futures | migration SQL | +## R2 — Exploitation + +```prisma +enum WorkOrderType { CORRECTIVE PREVENTIVE WORKS } // Dépannage / Maintenance / Travaux +enum WorkOrderStatus { OPEN IN_PROGRESS ON_HOLD DONE CANCELLED } +enum WorkOrderPriority { NONE LOW MEDIUM HIGH PERSON_TRAPPED } // personne bloquée = priorité, pas statut +enum RequestStatus { RECEIVED APPROVED REJECTED } // « Résolue » = dérivé (OT lié DONE) +enum ChecklistState { PENDING DONE NA } +enum BilanField { DOOR_STATE CABIN_POSITION ANOMALY EXTERNAL_CAUSE ACTION_TAKEN COMPONENT_CONCERNED } +enum MeterKind { RUNNING_HOURS STARTS } + +model WorkOrder { // machine à états STRICTE (service, table des transitions) + id String @id … reference String @unique // OT-2026-0341 (séquence + retry) + title String description String? + type WorkOrderType status WorkOrderStatus @default(OPEN) + priority WorkOrderPriority @default(NONE) + assetId → Asset dueDate DateTime? + assignees User[] ("WorkOrderAssignees") createdById → User? + startedAt? completedAt? cancelledAt? + events WorkOrderEvent[] checklist ChecklistItem[] + report InterventionReport? request Request? +} + +model WorkOrderEvent { // activité chronologique : commentaires + traces de transition + id, workOrderId (cascade), kind String, message String?, byId → User?, createdAt +} + +model Request { // demande — converge vers l'OT (lien 1-1, jamais de doublon) + id, reference @unique // DEM-2026-0112 + description, isPersonTrapped Boolean @default(false) + status RequestStatus @default(RECEIVED) rejectionReason String? // motif REQUIS au rejet + assetId → Asset requestedById → User? requesterName String? // portail public R2.4 + workOrderId String? @unique → WorkOrder +} + +model ReferenceValue { // référentiels ADMINISTRABLES du bilan codé (un par champ) + id, field BilanField, label String, isActive Boolean @default(true) + @@unique([field, label]) +} + +model InterventionReport { // bilan codé 6 champs — 1-1 avec l'OT + id, workOrderId @unique (cascade), note String? + doorStateId? cabinPositionId? anomalyId? externalCauseId? actionTakenId? componentConcernedId? + // requis pour clôturer : DOOR_STATE, ACTION_TAKEN, COMPONENT_CONCERNED (maquette) +} + +model TaskTemplate { // gabarit du préventif (période calendaire en mois) + id, label, componentTypeId? → Category, periodMonths Int, isRegulatory Boolean, isActive +} +model ChecklistItem { // la grille du mois d'un OT préventif + id, workOrderId (cascade), label, state ChecklistState @default(PENDING) + templateId? → TaskTemplate doneById? → User doneAt? +} + +model Meter { id, assetId → Asset, kind MeterKind, @@unique([assetId, kind]) } +model MeterReading { id, meterId (cascade), value Int, readById? → User, createdAt } +``` + +**Invariants R2** : + +| Invariant | Où il vit | +| --- | --- | +| Transitions : OPEN→(IN_PROGRESS·CANCELLED) ; IN_PROGRESS→(ON_HOLD·DONE·CANCELLED) ; ON_HOLD→(IN_PROGRESS·CANCELLED) ; DONE/CANCELLED terminaux | service work-orders (+ tests) | +| **Garde de clôture** : bilan (3 champs requis) + checklist sans PENDING | service (transition → DONE) | +| Demande approuvée = **un seul** OT (1-1), rejet ⇒ motif obligatoire | contrainte @unique + service | +| « Voir autre » : sans `canViewOther`, un rôle ne voit que SES objets (assigné/créateur/demandeur) | services (scoping des listes + accès) | +| ReferenceValue/TaskTemplate utilisés : désactivables, jamais supprimés | services (même règle que Category) | +| Relevé de compteur strictement croissant | service meters | +| Génération mensuelle idempotente ; appareil sans historique ⇒ premier contrôle (toutes tâches) | service préventif (R2.2, + tests) | + ## À venir (référence v1 éprouvée, sera réintroduit release par release) -- **R2** : `WorkOrder` (machine à états stricte, priorité « personne bloquée »), `Request` - (lien 1-1 vers OT), `InterventionReport` (bilan codé 6 champs → `ReferenceValue`), - `TaskTemplate`/`PreventivePlan`/`ChecklistItem` (périodicité calendrier), `Meter`. - **R3** : `Part`/`StockMovement` (stock **dérivé des mouvements**), `PurchaseOrder`, `Partner`, `LaborTime` (taux figé), `Document`. - **R4** : `WorkOrder.version` (verrou optimiste de la synchro mobile). diff --git a/docs/journal/journal.md b/docs/journal/journal.md index 753e177..c4257a8 100644 --- a/docs/journal/journal.md +++ b/docs/journal/journal.md @@ -4,6 +4,25 @@ Trace chronologique des sessions (la plus récente en premier). Le **playbook** --- +## 2026-07-16 — Pr. Daaif (+ Claude) — R2.1 : socle backend de l'exploitation + +**Actions** + +- **Modèle R2** (doc + migration `r2_exploitation`, 10 tables) : WorkOrder (référence séquentielle, horodatages par état), WorkOrderEvent (activité), Request (1-1 vers OT, motif de rejet), ReferenceValue (référentiels du bilan par champ), InterventionReport (6 FK nommées), TaskTemplate/ChecklistItem, Meter/MeterReading. +- **Contrat** : 15 opérations (41 total) — la **table des transitions** et les champs requis du bilan vivent dans `@siop/shared` (une seule loi pour l'API et l'UI) ; la fiche OT expose `allowedTransitions` et `closureBlockers` (messages métier). +- **API** : machine à états stricte (transition → DONE refusée si bilan incomplet OU checklist avec tâche sans réponse — messages « quoi faire », charte §7) ; approbation de demande = création d'OT lié en 1-1 (double traitement → 409) ; rejet à motif obligatoire ; **scoping « voir autre »** appliqué aux listes ET aux accès directs (404, pas de fuite) ; valeurs de bilan validées champ par champ. +- **Seed** : 31 valeurs de référentiels (6 champs), 8 gabarits de préventif (parachute réglementaire), 4 OT et 4 demandes rejouant la maquette, compteurs d'A1. +- **45 tests verts** (couverture 95 % / 79 % branches) dont la **recette officielle rejouée** : demande Karim → approbation Salma → OT assigné Ahmed → démarrage → clôture refusée sans bilan → bilan codé → clôture → Karim voit sa demande résolue. Smoke test build prod. + +**Décisions** + +- `POST` de transition/rejet répondent **200** (le contrat prime sur le défaut 201 de Nest — c'est le contrat qui a raison). +- La priorité « personne bloquée » trie en tête côté API : aucun client ne peut l'oublier. + +**Prochaine étape** : R2.2 — génération mensuelle du préventif (BullMQ, idempotente, premier contrôle) + API compteurs, puis R2.3 écrans web. + +--- + ## 2026-07-16 — Pr. Daaif (+ Claude) — R2 ouverte : maquettes des écrans manquants à valider **Actions** @@ -19,6 +38,7 @@ Trace chronologique des sessions (la plus récente en premier). Le **playbook** - Relevé de compteur strictement croissant (refus sinon). **⛔ Bloquant** : validation des maquettes R2 par le référent avant toute ligne de code R2. +**→ Levé le 16/07/2026 : maquettes R2 et les 4 décisions VALIDÉES par le référent** (statuts de demande = extension cohérente, actée). Lancement R2.1 (socle backend exploitation). --- diff --git a/docs/openapi.json b/docs/openapi.json index 1dcc24d..f0242b8 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -930,6 +930,632 @@ } } }, + "/work-orders": { + "get": { + "operationId": "listWorkOrders", + "summary": "Ordres de travail (sans « voir autre » : seulement les siens)", + "tags": [ + "work-orders" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Liste", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkOrdersResponse" + } + } + } + } + } + }, + "post": { + "operationId": "createWorkOrder", + "summary": "Créer un OT (statut Ouvert)", + "tags": [ + "work-orders" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkOrderCreate" + } + } + } + }, + "responses": { + "201": { + "description": "Créé", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkOrderDetail" + } + } + } + } + } + } + }, + "/work-orders/{id}": { + "get": { + "operationId": "getWorkOrder", + "summary": "Fiche OT (activité, checklist, bilan, transitions autorisées, blocages de clôture)", + "tags": [ + "work-orders" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Fiche", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkOrderDetail" + } + } + } + }, + "404": { + "description": "Inconnu (ou hors de son périmètre)" + } + } + } + }, + "/work-orders/{id}/transition": { + "post": { + "operationId": "transitionWorkOrder", + "summary": "Changer l’état (machine à états stricte ; DONE exige bilan + checklist)", + "tags": [ + "work-orders" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TransitionRequest" + } + } + } + }, + "responses": { + "200": { + "description": "État changé", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkOrderDetail" + } + } + } + }, + "409": { + "description": "Transition interdite ou clôture bloquée (garde)" + } + } + } + }, + "/work-orders/{id}/comments": { + "post": { + "operationId": "commentWorkOrder", + "summary": "Commenter (activité chronologique)", + "tags": [ + "work-orders" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommentCreate" + } + } + } + }, + "responses": { + "201": { + "description": "Commentaire ajouté", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkOrderDetail" + } + } + } + } + } + } + }, + "/work-orders/{id}/assignees": { + "put": { + "operationId": "setWorkOrderAssignees", + "summary": "Assigner (remplace la liste)", + "tags": [ + "work-orders" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssigneesUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Assignés", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkOrderDetail" + } + } + } + } + } + } + }, + "/work-orders/{id}/report": { + "put": { + "operationId": "upsertWorkOrderReport", + "summary": "Renseigner le bilan codé (null efface un champ)", + "tags": [ + "work-orders" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportUpsert" + } + } + } + }, + "responses": { + "200": { + "description": "Bilan enregistré", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkOrderDetail" + } + } + } + }, + "400": { + "description": "Valeur hors référentiel du champ" + } + } + } + }, + "/work-orders/{id}/checklist/{itemId}": { + "patch": { + "operationId": "patchChecklistItem", + "summary": "Régler une tâche de la grille (Fait / N-A / à faire)", + "tags": [ + "work-orders" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "itemId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChecklistPatch" + } + } + } + }, + "responses": { + "200": { + "description": "Tâche réglée", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChecklistItem" + } + } + } + }, + "404": { + "description": "Tâche inconnue" + } + } + } + }, + "/requests": { + "get": { + "operationId": "listRequests", + "summary": "Demandes (sans « voir autre » : seulement les siennes)", + "tags": [ + "requests" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Liste", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestsResponse" + } + } + } + } + } + }, + "post": { + "operationId": "createRequest", + "summary": "Signaler (interne — le portail public QR arrive en R2.4)", + "tags": [ + "requests" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestCreate" + } + } + } + }, + "responses": { + "201": { + "description": "Demande créée", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestSummary" + } + } + } + } + } + } + }, + "/requests/{id}/approve": { + "post": { + "operationId": "approveRequest", + "summary": "Approuver → crée l’OT lié (1-1, jamais de doublon)", + "tags": [ + "requests" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestApprove" + } + } + } + }, + "responses": { + "201": { + "description": "OT créé et lié", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkOrderDetail" + } + } + } + }, + "409": { + "description": "Demande déjà traitée" + } + } + } + }, + "/requests/{id}/reject": { + "post": { + "operationId": "rejectRequest", + "summary": "Rejeter — motif obligatoire, lisible côté demandeur", + "tags": [ + "requests" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestReject" + } + } + } + }, + "responses": { + "200": { + "description": "Rejetée", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestSummary" + } + } + } + }, + "409": { + "description": "Demande déjà traitée" + } + } + } + }, + "/reference-values": { + "get": { + "operationId": "listReferenceValues", + "summary": "Référentiels du bilan codé (6 champs)", + "tags": [ + "reference-values" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Liste", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReferenceValuesResponse" + } + } + } + } + } + }, + "post": { + "operationId": "createReferenceValue", + "summary": "Ajouter une valeur de référentiel", + "tags": [ + "reference-values" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReferenceValueCreate" + } + } + } + }, + "responses": { + "201": { + "description": "Créée", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReferenceValue" + } + } + } + }, + "409": { + "description": "Libellé déjà présent pour ce champ" + } + } + } + }, + "/reference-values/{id}": { + "patch": { + "operationId": "updateReferenceValue", + "summary": "Renommer ou (dés)activer — jamais de suppression si utilisée", + "tags": [ + "reference-values" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReferenceValueUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Mise à jour", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReferenceValue" + } + } + } + }, + "404": { + "description": "Inconnue" + } + } + } + }, "/health": { "get": { "operationId": "getHealth", @@ -2682,6 +3308,1437 @@ }, "additionalProperties": false }, + "WorkOrdersResponse": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "workOrders": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "reference": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "CORRECTIVE", + "PREVENTIVE", + "WORKS" + ] + }, + "status": { + "type": "string", + "enum": [ + "OPEN", + "IN_PROGRESS", + "ON_HOLD", + "DONE", + "CANCELLED" + ] + }, + "priority": { + "type": "string", + "enum": [ + "NONE", + "LOW", + "MEDIUM", + "HIGH", + "PERSON_TRAPPED" + ] + }, + "assetId": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "assetReference": { + "type": "string" + }, + "siteName": { + "type": "string" + }, + "dueDate": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ] + }, + "assignees": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "displayName": { + "type": "string" + }, + "initials": { + "type": "string" + } + }, + "required": [ + "id", + "displayName", + "initials" + ], + "additionalProperties": false + } + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + } + }, + "required": [ + "id", + "reference", + "title", + "type", + "status", + "priority", + "assetId", + "assetReference", + "siteName", + "dueDate", + "assignees", + "createdAt" + ], + "additionalProperties": false + } + } + }, + "required": [ + "workOrders" + ], + "additionalProperties": false + }, + "WorkOrderDetail": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "reference": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "CORRECTIVE", + "PREVENTIVE", + "WORKS" + ] + }, + "status": { + "type": "string", + "enum": [ + "OPEN", + "IN_PROGRESS", + "ON_HOLD", + "DONE", + "CANCELLED" + ] + }, + "priority": { + "type": "string", + "enum": [ + "NONE", + "LOW", + "MEDIUM", + "HIGH", + "PERSON_TRAPPED" + ] + }, + "assetId": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "assetReference": { + "type": "string" + }, + "siteName": { + "type": "string" + }, + "dueDate": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ] + }, + "assignees": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "displayName": { + "type": "string" + }, + "initials": { + "type": "string" + } + }, + "required": [ + "id", + "displayName", + "initials" + ], + "additionalProperties": false + } + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "locationName": { + "type": "string" + }, + "startedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ] + }, + "completedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ] + }, + "cancelledAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ] + }, + "createdBy": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "displayName": { + "type": "string" + }, + "initials": { + "type": "string" + } + }, + "required": [ + "id", + "displayName", + "initials" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "request": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "reference": { + "type": "string" + }, + "requesterLabel": { + "type": "string" + } + }, + "required": [ + "id", + "reference", + "requesterLabel" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "events": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "kind": { + "type": "string" + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "by": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "displayName": { + "type": "string" + }, + "initials": { + "type": "string" + } + }, + "required": [ + "id", + "displayName", + "initials" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + } + }, + "required": [ + "id", + "kind", + "message", + "by", + "createdAt" + ], + "additionalProperties": false + } + }, + "checklist": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "label": { + "type": "string" + }, + "state": { + "type": "string", + "enum": [ + "PENDING", + "DONE", + "NA" + ] + }, + "doneBy": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "displayName": { + "type": "string" + }, + "initials": { + "type": "string" + } + }, + "required": [ + "id", + "displayName", + "initials" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "doneAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "label", + "state", + "doneBy", + "doneAt" + ], + "additionalProperties": false + } + }, + "report": { + "anyOf": [ + { + "type": "object", + "properties": { + "note": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "doorState": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "label": { + "type": "string" + } + }, + "required": [ + "id", + "label" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "cabinPosition": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "label": { + "type": "string" + } + }, + "required": [ + "id", + "label" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "anomaly": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "label": { + "type": "string" + } + }, + "required": [ + "id", + "label" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "externalCause": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "label": { + "type": "string" + } + }, + "required": [ + "id", + "label" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "actionTaken": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "label": { + "type": "string" + } + }, + "required": [ + "id", + "label" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "componentConcerned": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "label": { + "type": "string" + } + }, + "required": [ + "id", + "label" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "note", + "doorState", + "cabinPosition", + "anomaly", + "externalCause", + "actionTaken", + "componentConcerned" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "allowedTransitions": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "OPEN", + "IN_PROGRESS", + "ON_HOLD", + "DONE", + "CANCELLED" + ] + } + }, + "closureBlockers": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "id", + "reference", + "title", + "type", + "status", + "priority", + "assetId", + "assetReference", + "siteName", + "dueDate", + "assignees", + "createdAt", + "description", + "locationName", + "startedAt", + "completedAt", + "cancelledAt", + "createdBy", + "request", + "events", + "checklist", + "report", + "allowedTransitions", + "closureBlockers" + ], + "additionalProperties": false + }, + "WorkOrderCreate": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "title": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "description": { + "type": "string", + "maxLength": 2000 + }, + "type": { + "type": "string", + "enum": [ + "CORRECTIVE", + "PREVENTIVE", + "WORKS" + ] + }, + "priority": { + "type": "string", + "enum": [ + "NONE", + "LOW", + "MEDIUM", + "HIGH", + "PERSON_TRAPPED" + ] + }, + "assetId": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "dueDate": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + "assigneeIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + } + } + }, + "required": [ + "title", + "type", + "assetId" + ], + "additionalProperties": false + }, + "TransitionRequest": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "to": { + "type": "string", + "enum": [ + "OPEN", + "IN_PROGRESS", + "ON_HOLD", + "DONE", + "CANCELLED" + ] + }, + "comment": { + "type": "string", + "maxLength": 500 + } + }, + "required": [ + "to" + ], + "additionalProperties": false + }, + "CommentCreate": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "message": { + "type": "string", + "minLength": 1, + "maxLength": 1000 + } + }, + "required": [ + "message" + ], + "additionalProperties": false + }, + "AssigneesUpdate": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "assigneeIds": { + "maxItems": 10, + "type": "array", + "items": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + } + } + }, + "required": [ + "assigneeIds" + ], + "additionalProperties": false + }, + "ReportUpsert": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "note": { + "anyOf": [ + { + "type": "string", + "maxLength": 2000 + }, + { + "type": "null" + } + ] + }, + "doorStateId": { + "anyOf": [ + { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + { + "type": "null" + } + ] + }, + "cabinPositionId": { + "anyOf": [ + { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + { + "type": "null" + } + ] + }, + "anomalyId": { + "anyOf": [ + { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + { + "type": "null" + } + ] + }, + "externalCauseId": { + "anyOf": [ + { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + { + "type": "null" + } + ] + }, + "actionTakenId": { + "anyOf": [ + { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + { + "type": "null" + } + ] + }, + "componentConcernedId": { + "anyOf": [ + { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + "ChecklistItem": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "label": { + "type": "string" + }, + "state": { + "type": "string", + "enum": [ + "PENDING", + "DONE", + "NA" + ] + }, + "doneBy": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "displayName": { + "type": "string" + }, + "initials": { + "type": "string" + } + }, + "required": [ + "id", + "displayName", + "initials" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "doneAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "label", + "state", + "doneBy", + "doneAt" + ], + "additionalProperties": false + }, + "ChecklistPatch": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "state": { + "type": "string", + "enum": [ + "PENDING", + "DONE", + "NA" + ] + } + }, + "required": [ + "state" + ], + "additionalProperties": false + }, + "RequestsResponse": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "requests": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "reference": { + "type": "string" + }, + "description": { + "type": "string" + }, + "isPersonTrapped": { + "type": "boolean" + }, + "status": { + "type": "string", + "enum": [ + "RECEIVED", + "APPROVED", + "REJECTED" + ] + }, + "rejectionReason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "assetId": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "assetReference": { + "type": "string" + }, + "siteName": { + "type": "string" + }, + "requesterLabel": { + "type": "string" + }, + "workOrder": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "reference": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "OPEN", + "IN_PROGRESS", + "ON_HOLD", + "DONE", + "CANCELLED" + ] + } + }, + "required": [ + "id", + "reference", + "status" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + } + }, + "required": [ + "id", + "reference", + "description", + "isPersonTrapped", + "status", + "rejectionReason", + "assetId", + "assetReference", + "siteName", + "requesterLabel", + "workOrder", + "createdAt" + ], + "additionalProperties": false + } + } + }, + "required": [ + "requests" + ], + "additionalProperties": false + }, + "RequestSummary": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "reference": { + "type": "string" + }, + "description": { + "type": "string" + }, + "isPersonTrapped": { + "type": "boolean" + }, + "status": { + "type": "string", + "enum": [ + "RECEIVED", + "APPROVED", + "REJECTED" + ] + }, + "rejectionReason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "assetId": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "assetReference": { + "type": "string" + }, + "siteName": { + "type": "string" + }, + "requesterLabel": { + "type": "string" + }, + "workOrder": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "reference": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "OPEN", + "IN_PROGRESS", + "ON_HOLD", + "DONE", + "CANCELLED" + ] + } + }, + "required": [ + "id", + "reference", + "status" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + } + }, + "required": [ + "id", + "reference", + "description", + "isPersonTrapped", + "status", + "rejectionReason", + "assetId", + "assetReference", + "siteName", + "requesterLabel", + "workOrder", + "createdAt" + ], + "additionalProperties": false + }, + "RequestCreate": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "assetId": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 2000 + }, + "isPersonTrapped": { + "type": "boolean" + } + }, + "required": [ + "assetId", + "description" + ], + "additionalProperties": false + }, + "RequestApprove": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "title": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "priority": { + "type": "string", + "enum": [ + "NONE", + "LOW", + "MEDIUM", + "HIGH", + "PERSON_TRAPPED" + ] + }, + "assigneeIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + } + }, + "dueDate": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + } + }, + "additionalProperties": false + }, + "RequestReject": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "reason": { + "type": "string", + "minLength": 3, + "maxLength": 500 + } + }, + "required": [ + "reason" + ], + "additionalProperties": false + }, + "ReferenceValuesResponse": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "referenceValues": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "field": { + "type": "string", + "enum": [ + "DOOR_STATE", + "CABIN_POSITION", + "ANOMALY", + "EXTERNAL_CAUSE", + "ACTION_TAKEN", + "COMPONENT_CONCERNED" + ] + }, + "label": { + "type": "string" + }, + "isActive": { + "type": "boolean" + }, + "usageCount": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "id", + "field", + "label", + "isActive", + "usageCount" + ], + "additionalProperties": false + } + } + }, + "required": [ + "referenceValues" + ], + "additionalProperties": false + }, + "ReferenceValue": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "field": { + "type": "string", + "enum": [ + "DOOR_STATE", + "CABIN_POSITION", + "ANOMALY", + "EXTERNAL_CAUSE", + "ACTION_TAKEN", + "COMPONENT_CONCERNED" + ] + }, + "label": { + "type": "string" + }, + "isActive": { + "type": "boolean" + }, + "usageCount": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "id", + "field", + "label", + "isActive", + "usageCount" + ], + "additionalProperties": false + }, + "ReferenceValueCreate": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "field": { + "type": "string", + "enum": [ + "DOOR_STATE", + "CABIN_POSITION", + "ANOMALY", + "EXTERNAL_CAUSE", + "ACTION_TAKEN", + "COMPONENT_CONCERNED" + ] + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 120 + } + }, + "required": [ + "field", + "label" + ], + "additionalProperties": false + }, + "ReferenceValueUpdate": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 120 + }, + "isActive": { + "type": "boolean" + } + }, + "additionalProperties": false + }, "HealthResponse": { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", diff --git a/packages/shared/src/contract.ts b/packages/shared/src/contract.ts index b6538a2..d507851 100644 --- a/packages/shared/src/contract.ts +++ b/packages/shared/src/contract.ts @@ -37,6 +37,26 @@ import { UsersResponseSchema, UserUpdateSchema, } from './schemas/users-admin'; +import { + AssigneesUpdateSchema, + ChecklistItemSchema, + ChecklistPatchSchema, + CommentCreateSchema, + ReferenceValueCreateSchema, + ReferenceValueSchema, + ReferenceValuesResponseSchema, + ReferenceValueUpdateSchema, + ReportUpsertSchema, + RequestApproveSchema, + RequestCreateSchema, + RequestRejectSchema, + RequestsResponseSchema, + RequestSummarySchema, + TransitionRequestSchema, + WorkOrderCreateSchema, + WorkOrderDetailSchema, + WorkOrdersResponseSchema, +} from './schemas/exploitation'; /** * Contrat d'API R0 — source unique de vérité (règle d'or ADR-001). @@ -367,6 +387,189 @@ export const API_CONTRACT: ApiOperation[] = [ 404: { description: 'Inconnue' }, }, }, + // ————— R2 · Exploitation ————— + { + operationId: 'listWorkOrders', + method: 'get', + path: '/work-orders', + summary: 'Ordres de travail (sans « voir autre » : seulement les siens)', + tags: ['work-orders'], + responses: { + 200: { description: 'Liste', name: 'WorkOrdersResponse', schema: WorkOrdersResponseSchema }, + }, + }, + { + operationId: 'getWorkOrder', + method: 'get', + path: '/work-orders/{id}', + summary: 'Fiche OT (activité, checklist, bilan, transitions autorisées, blocages de clôture)', + tags: ['work-orders'], + pathParams: ['id'], + responses: { + 200: { description: 'Fiche', name: 'WorkOrderDetail', schema: WorkOrderDetailSchema }, + 404: { description: 'Inconnu (ou hors de son périmètre)' }, + }, + }, + { + operationId: 'createWorkOrder', + method: 'post', + path: '/work-orders', + summary: 'Créer un OT (statut Ouvert)', + tags: ['work-orders'], + request: { name: 'WorkOrderCreate', schema: WorkOrderCreateSchema }, + responses: { + 201: { description: 'Créé', name: 'WorkOrderDetail', schema: WorkOrderDetailSchema }, + }, + }, + { + operationId: 'transitionWorkOrder', + method: 'post', + path: '/work-orders/{id}/transition', + summary: 'Changer l’état (machine à états stricte ; DONE exige bilan + checklist)', + tags: ['work-orders'], + pathParams: ['id'], + request: { name: 'TransitionRequest', schema: TransitionRequestSchema }, + responses: { + 200: { description: 'État changé', name: 'WorkOrderDetail', schema: WorkOrderDetailSchema }, + 409: { description: 'Transition interdite ou clôture bloquée (garde)' }, + }, + }, + { + operationId: 'commentWorkOrder', + method: 'post', + path: '/work-orders/{id}/comments', + summary: 'Commenter (activité chronologique)', + tags: ['work-orders'], + pathParams: ['id'], + request: { name: 'CommentCreate', schema: CommentCreateSchema }, + responses: { + 201: { description: 'Commentaire ajouté', name: 'WorkOrderDetail', schema: WorkOrderDetailSchema }, + }, + }, + { + operationId: 'setWorkOrderAssignees', + method: 'put', + path: '/work-orders/{id}/assignees', + summary: 'Assigner (remplace la liste)', + tags: ['work-orders'], + pathParams: ['id'], + request: { name: 'AssigneesUpdate', schema: AssigneesUpdateSchema }, + responses: { + 200: { description: 'Assignés', name: 'WorkOrderDetail', schema: WorkOrderDetailSchema }, + }, + }, + { + operationId: 'upsertWorkOrderReport', + method: 'put', + path: '/work-orders/{id}/report', + summary: 'Renseigner le bilan codé (null efface un champ)', + tags: ['work-orders'], + pathParams: ['id'], + request: { name: 'ReportUpsert', schema: ReportUpsertSchema }, + responses: { + 200: { description: 'Bilan enregistré', name: 'WorkOrderDetail', schema: WorkOrderDetailSchema }, + 400: { description: 'Valeur hors référentiel du champ' }, + }, + }, + { + operationId: 'patchChecklistItem', + method: 'patch', + path: '/work-orders/{id}/checklist/{itemId}', + summary: 'Régler une tâche de la grille (Fait / N-A / à faire)', + tags: ['work-orders'], + pathParams: ['id', 'itemId'], + request: { name: 'ChecklistPatch', schema: ChecklistPatchSchema }, + responses: { + 200: { description: 'Tâche réglée', name: 'ChecklistItem', schema: ChecklistItemSchema }, + 404: { description: 'Tâche inconnue' }, + }, + }, + { + operationId: 'listRequests', + method: 'get', + path: '/requests', + summary: 'Demandes (sans « voir autre » : seulement les siennes)', + tags: ['requests'], + responses: { + 200: { description: 'Liste', name: 'RequestsResponse', schema: RequestsResponseSchema }, + }, + }, + { + operationId: 'createRequest', + method: 'post', + path: '/requests', + summary: 'Signaler (interne — le portail public QR arrive en R2.4)', + tags: ['requests'], + request: { name: 'RequestCreate', schema: RequestCreateSchema }, + responses: { + 201: { description: 'Demande créée', name: 'RequestSummary', schema: RequestSummarySchema }, + }, + }, + { + operationId: 'approveRequest', + method: 'post', + path: '/requests/{id}/approve', + summary: 'Approuver → crée l’OT lié (1-1, jamais de doublon)', + tags: ['requests'], + pathParams: ['id'], + request: { name: 'RequestApprove', schema: RequestApproveSchema }, + responses: { + 201: { description: 'OT créé et lié', name: 'WorkOrderDetail', schema: WorkOrderDetailSchema }, + 409: { description: 'Demande déjà traitée' }, + }, + }, + { + operationId: 'rejectRequest', + method: 'post', + path: '/requests/{id}/reject', + summary: 'Rejeter — motif obligatoire, lisible côté demandeur', + tags: ['requests'], + pathParams: ['id'], + request: { name: 'RequestReject', schema: RequestRejectSchema }, + responses: { + 200: { description: 'Rejetée', name: 'RequestSummary', schema: RequestSummarySchema }, + 409: { description: 'Demande déjà traitée' }, + }, + }, + { + operationId: 'listReferenceValues', + method: 'get', + path: '/reference-values', + summary: 'Référentiels du bilan codé (6 champs)', + tags: ['reference-values'], + responses: { + 200: { + description: 'Liste', + name: 'ReferenceValuesResponse', + schema: ReferenceValuesResponseSchema, + }, + }, + }, + { + operationId: 'createReferenceValue', + method: 'post', + path: '/reference-values', + summary: 'Ajouter une valeur de référentiel', + tags: ['reference-values'], + request: { name: 'ReferenceValueCreate', schema: ReferenceValueCreateSchema }, + responses: { + 201: { description: 'Créée', name: 'ReferenceValue', schema: ReferenceValueSchema }, + 409: { description: 'Libellé déjà présent pour ce champ' }, + }, + }, + { + operationId: 'updateReferenceValue', + method: 'patch', + path: '/reference-values/{id}', + summary: 'Renommer ou (dés)activer — jamais de suppression si utilisée', + tags: ['reference-values'], + pathParams: ['id'], + request: { name: 'ReferenceValueUpdate', schema: ReferenceValueUpdateSchema }, + responses: { + 200: { description: 'Mise à jour', name: 'ReferenceValue', schema: ReferenceValueSchema }, + 404: { description: 'Inconnue' }, + }, + }, { operationId: 'getHealth', method: 'get', diff --git a/packages/shared/src/exploitation.ts b/packages/shared/src/exploitation.ts new file mode 100644 index 0000000..1e918bf --- /dev/null +++ b/packages/shared/src/exploitation.ts @@ -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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + RUNNING_HOURS: 'Heures de marche', + STARTS: 'Démarrages', +}; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 1672841..7723728 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,5 +1,7 @@ export * from './permissions'; export * from './referentiel'; +export * from './exploitation'; +export * from './schemas/exploitation'; export * from './schemas/auth'; export * from './schemas/users'; export * from './schemas/users-admin'; diff --git a/packages/shared/src/schemas/exploitation.ts b/packages/shared/src/schemas/exploitation.ts new file mode 100644 index 0000000..084a28a --- /dev/null +++ b/packages/shared/src/schemas/exploitation.ts @@ -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; + +export const WorkOrdersResponseSchema = z.object({ + workOrders: z.array(WorkOrderSummarySchema), +}); +export type WorkOrdersResponse = z.infer; + +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; + +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; + +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; + +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; + +export const TransitionRequestSchema = z.object({ + to: z.enum(WORK_ORDER_STATUSES), + comment: z.string().max(500).optional(), +}); +export type TransitionRequest = z.infer; + +export const CommentCreateSchema = z.object({ + message: z.string().min(1).max(1000), +}); +export type CommentCreate = z.infer; + +export const AssigneesUpdateSchema = z.object({ + assigneeIds: z.array(z.uuid()).max(10), +}); +export type AssigneesUpdate = z.infer; + +/** 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; + +export const ChecklistPatchSchema = z.object({ + state: z.enum(CHECKLIST_STATES), +}); +export type ChecklistPatch = z.infer; + +// ————— 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; + +export const RequestsResponseSchema = z.object({ + requests: z.array(RequestSummarySchema), +}); +export type RequestsResponse = z.infer; + +export const RequestCreateSchema = z.object({ + assetId: z.uuid(), + description: z.string().min(1).max(2000), + isPersonTrapped: z.boolean().optional(), +}); +export type RequestCreate = z.infer; + +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; + +export const RequestRejectSchema = z.object({ + reason: z.string().min(3, 'Le motif est requis').max(500), +}); +export type RequestReject = z.infer; + +// ————— 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; + +export const ReferenceValuesResponseSchema = z.object({ + referenceValues: z.array(ReferenceValueSchema), +}); +export type ReferenceValuesResponse = z.infer; + +export const ReferenceValueCreateSchema = z.object({ + field: z.enum(BILAN_FIELDS), + label: z.string().min(1).max(120), +}); +export type ReferenceValueCreate = z.infer; + +export const ReferenceValueUpdateSchema = z.object({ + label: z.string().min(1).max(120).optional(), + isActive: z.boolean().optional(), +}); +export type ReferenceValueUpdate = z.infer;