mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
- migration : Asset.underContract + WorkOrder.periodKey avec unicité [assetId, periodKey] — l'idempotence de la génération est EN BASE (deux appels concurrents ne doublent jamais une grille) - génération : une grille par appareil sous contrat, périodicités ancrées sur la mise en service (mensuelles toujours dues), premier contrôle (toutes les tâches) pour un appareil sans historique, échéance fin de mois, événement GENERATED ; déclenchement manuel (cron au durcissement) - gabarits administrables (désactivé = exclu des générations suivantes) ; GET /preventive/status (générées, terminées, en retard, premiers contrôles) - compteurs : GET meters (2 compteurs, récents d'abord) ; relevé strictement croissant, refus motivé sinon - contrat +7 opérations (48) ; 50 tests verts (94,7 % / 78 %) ; smoke test prod : juillet généré (9 grilles, 8 premiers contrôles), 2e appel à zéro Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
358 lines
12 KiB
Plaintext
358 lines
12 KiB
Plaintext
// Modèle R0 — identité & permissions (docs/03-architecture/modele-donnees.md).
|
||
// La matrice rôles × objets × droits vit EN BASE ; le JWT ne porte jamais de droits.
|
||
|
||
generator client {
|
||
provider = "prisma-client-js"
|
||
// Cibles explicites : poste de dev (native) + conteneur node:24-slim
|
||
// (OpenSSL 3) en x64 (serveur partenaire) et arm64 (répétition locale).
|
||
binaryTargets = ["native", "debian-openssl-3.0.x", "linux-arm64-openssl-3.0.x"]
|
||
}
|
||
|
||
datasource db {
|
||
provider = "postgresql"
|
||
url = env("DATABASE_URL")
|
||
}
|
||
|
||
model Role {
|
||
id String @id @default(uuid()) @db.Uuid
|
||
name String @unique // 7 rôles seedés — voir @siop/shared ROLE_NAMES
|
||
users User[]
|
||
permissions Permission[]
|
||
}
|
||
|
||
model Permission {
|
||
id String @id @default(uuid()) @db.Uuid
|
||
roleId String @db.Uuid
|
||
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
|
||
objectCategory String // enum applicatif — voir @siop/shared OBJECT_CATEGORIES
|
||
canView Boolean @default(false)
|
||
canViewOther Boolean @default(false) // « voir autre » : au-delà de ses propres objets
|
||
canCreate Boolean @default(false)
|
||
canEdit Boolean @default(false)
|
||
canDelete Boolean @default(false)
|
||
|
||
@@unique([roleId, objectCategory])
|
||
}
|
||
|
||
model User {
|
||
id String @id @default(uuid()) @db.Uuid
|
||
email String @unique
|
||
displayName String
|
||
passwordHash String? // null tant que le compte n'est pas activé (R1)
|
||
phone String?
|
||
roleId String @db.Uuid
|
||
role Role @relation(fields: [roleId], references: [id])
|
||
isActive Boolean @default(true)
|
||
isDemo Boolean @default(false) // seul un compte isDemo est empruntable (ADR-002)
|
||
// Invitation (R1) : lien d'activation 7 jours, usage unique.
|
||
// Statut dérivé : invité = passwordHash null && token présent.
|
||
activationToken String? @unique
|
||
activationExpiresAt DateTime?
|
||
teams Team[]
|
||
// R2 — exploitation
|
||
workOrdersAssigned WorkOrder[] @relation("WorkOrderAssignees")
|
||
workOrdersCreated WorkOrder[] @relation("WorkOrderCreator")
|
||
workOrderEvents WorkOrderEvent[]
|
||
requests Request[]
|
||
checklistDone ChecklistItem[]
|
||
meterReadings MeterReading[]
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
@@index([roleId])
|
||
}
|
||
|
||
// ————— R1 — Référentiel (docs/03-architecture/modele-donnees.md §R1) —————
|
||
|
||
enum CategoryKind {
|
||
EQUIPMENT
|
||
COMPONENT_TYPE
|
||
}
|
||
|
||
enum AssetStatus {
|
||
IN_SERVICE
|
||
OUT_OF_SERVICE
|
||
UNDER_MAINTENANCE
|
||
}
|
||
|
||
model Category {
|
||
id String @id @default(uuid()) @db.Uuid
|
||
kind CategoryKind
|
||
name String
|
||
isActive Boolean @default(true) // désactivable, jamais supprimée si utilisée
|
||
assets Asset[]
|
||
components AssetComponent[]
|
||
taskTemplates TaskTemplate[]
|
||
|
||
@@unique([kind, name])
|
||
}
|
||
|
||
model Location {
|
||
id String @id @default(uuid()) @db.Uuid
|
||
name String
|
||
parentId String? @db.Uuid // site (null) → zone ; profondeur max 2 (service)
|
||
parent Location? @relation("LocationTree", fields: [parentId], references: [id])
|
||
children Location[] @relation("LocationTree")
|
||
address String?
|
||
city String?
|
||
guardianName String?
|
||
guardianPhone String?
|
||
latitude Float?
|
||
longitude Float?
|
||
// + colonne PostGIS générée (voir migration r1_referentiel) :
|
||
// position geography(Point,4326) GENERATED ALWAYS AS (…) STORED
|
||
assets Asset[]
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
@@index([parentId])
|
||
}
|
||
|
||
model Asset {
|
||
id String @id @default(uuid()) @db.Uuid
|
||
reference String @unique // « A1 » — imprimée sur l'étiquette QR
|
||
brand String
|
||
model String?
|
||
serialNumber String?
|
||
commissionedAt DateTime?
|
||
loadKg Int?
|
||
floors Int?
|
||
status AssetStatus @default(IN_SERVICE) // statut d'ÉQUIPEMENT ≠ statut d'OT
|
||
underContract Boolean @default(true) // contrat préventif (grille mensuelle)
|
||
categoryId String @db.Uuid
|
||
category Category @relation(fields: [categoryId], references: [id])
|
||
locationId String @db.Uuid
|
||
location Location @relation(fields: [locationId], references: [id])
|
||
components AssetComponent[]
|
||
workOrders WorkOrder[]
|
||
requests Request[]
|
||
meters Meter[]
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
@@index([locationId])
|
||
@@index([categoryId])
|
||
}
|
||
|
||
// Organe : PAS de colonne emplacement — « un organe n'a pas d'emplacement
|
||
// propre » est garanti par construction (décision maquettes R1).
|
||
model AssetComponent {
|
||
id String @id @default(uuid()) @db.Uuid
|
||
assetId String @db.Uuid
|
||
asset Asset @relation(fields: [assetId], references: [id], onDelete: Cascade)
|
||
typeId String @db.Uuid
|
||
type Category @relation(fields: [typeId], references: [id])
|
||
designation String?
|
||
|
||
@@index([assetId])
|
||
}
|
||
|
||
model Team {
|
||
id String @id @default(uuid()) @db.Uuid
|
||
name String @unique
|
||
description String?
|
||
members User[]
|
||
}
|
||
|
||
// ————— R2 — Exploitation (docs/03-architecture/modele-donnees.md §R2) —————
|
||
|
||
enum WorkOrderType {
|
||
CORRECTIVE // Dépannage
|
||
PREVENTIVE // Maintenance (grille du mois)
|
||
WORKS // Travaux
|
||
}
|
||
|
||
enum WorkOrderStatus {
|
||
OPEN
|
||
IN_PROGRESS
|
||
ON_HOLD
|
||
DONE
|
||
CANCELLED
|
||
}
|
||
|
||
enum WorkOrderPriority {
|
||
NONE
|
||
LOW
|
||
MEDIUM
|
||
HIGH
|
||
PERSON_TRAPPED // personne bloquée — urgence absolue
|
||
}
|
||
|
||
enum RequestStatus {
|
||
RECEIVED
|
||
APPROVED
|
||
REJECTED
|
||
}
|
||
|
||
enum ChecklistState {
|
||
PENDING
|
||
DONE
|
||
NA
|
||
}
|
||
|
||
enum BilanField {
|
||
DOOR_STATE
|
||
CABIN_POSITION
|
||
ANOMALY
|
||
EXTERNAL_CAUSE
|
||
ACTION_TAKEN
|
||
COMPONENT_CONCERNED
|
||
}
|
||
|
||
enum MeterKind {
|
||
RUNNING_HOURS
|
||
STARTS
|
||
}
|
||
|
||
model WorkOrder {
|
||
id String @id @default(uuid()) @db.Uuid
|
||
reference String @unique // OT-2026-0341
|
||
title String
|
||
description String?
|
||
type WorkOrderType
|
||
status WorkOrderStatus @default(OPEN) // machine à états stricte (service)
|
||
priority WorkOrderPriority @default(NONE)
|
||
assetId String @db.Uuid
|
||
asset Asset @relation(fields: [assetId], references: [id])
|
||
dueDate DateTime?
|
||
assignees User[] @relation("WorkOrderAssignees")
|
||
createdById String? @db.Uuid
|
||
createdBy User? @relation("WorkOrderCreator", fields: [createdById], references: [id])
|
||
startedAt DateTime?
|
||
completedAt DateTime?
|
||
cancelledAt DateTime?
|
||
events WorkOrderEvent[]
|
||
checklist ChecklistItem[]
|
||
report InterventionReport?
|
||
request Request?
|
||
// Grille du mois : « AAAA-MM » — l'unicité [assetId, periodKey] EST
|
||
// l'idempotence de la génération (les OT correctifs restent à null).
|
||
periodKey String?
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
@@unique([assetId, periodKey])
|
||
@@index([assetId])
|
||
@@index([status])
|
||
}
|
||
|
||
model WorkOrderEvent {
|
||
id String @id @default(uuid()) @db.Uuid
|
||
workOrderId String @db.Uuid
|
||
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
|
||
kind String // COMMENT · STATUS_CHANGED · ASSIGNED · CREATED · FROM_REQUEST
|
||
message String?
|
||
byId String? @db.Uuid
|
||
by User? @relation(fields: [byId], references: [id])
|
||
createdAt DateTime @default(now())
|
||
|
||
@@index([workOrderId])
|
||
}
|
||
|
||
model Request {
|
||
id String @id @default(uuid()) @db.Uuid
|
||
reference String @unique // DEM-2026-0112
|
||
description String
|
||
isPersonTrapped Boolean @default(false)
|
||
status RequestStatus @default(RECEIVED)
|
||
rejectionReason String? // REQUIS au rejet (service)
|
||
assetId String @db.Uuid
|
||
asset Asset @relation(fields: [assetId], references: [id])
|
||
requestedById String? @db.Uuid
|
||
requestedBy User? @relation(fields: [requestedById], references: [id])
|
||
requesterName String? // portail public via QR (R2.4)
|
||
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])
|
||
}
|