mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
Arbitrage du référent sur la revue pixel : tout corriger, activer la recherche. - Stock : filtre fournisseur, sous-seuil en tête, « Entrée de stock » depuis la liste ; fiche pièce : fournisseur → lien Tiers. - Statistiques : période 3/6/12 mois (paramètre months au contrat). - Tiers : rattachements syndic→site (migration r3_recette_fixes, Location.partnerId gardé CLIENT), éditable sur la fiche site, seedé. - Bibliothèque : filtre « Rattaché à » + glisser-déposer (modale préremplie, rattachement toujours requis). - Recherche globale : GET /search (73 opérations) — familles OT/ ascenseurs/sites filtrées par la matrice, « voir autre » respecté ; topbar ⌘K, debounce, résultats groupés, navigation clavier. 74 tests API (8 nouveaux sur le scoping de la recherche), 14/14 Playwright dont un parcours « recette corrigée », 18/18 contrôles en navigateur réel, zéro erreur console. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
520 lines
18 KiB
Plaintext
520 lines
18 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[]
|
||
// R3 — gestion : taux horaire COURANT (le taux d'une saisie est figé dans LaborTime)
|
||
hourlyRate Decimal? @db.Decimal(8, 2)
|
||
laborTimes LaborTime[]
|
||
stockMovements StockMovement[]
|
||
purchaseOrders PurchaseOrder[]
|
||
documents Document[]
|
||
createdAt DateTime @default(now())
|
||
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
|
||
// Client/syndic gérant le site (R3 — colonne « Rattachements » des Tiers)
|
||
partnerId String? @db.Uuid
|
||
partner Partner? @relation(fields: [partnerId], references: [id])
|
||
assets Asset[]
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
@@index([parentId])
|
||
@@index([partnerId])
|
||
}
|
||
|
||
model Asset {
|
||
id String @id @default(uuid()) @db.Uuid
|
||
reference String @unique // « A1 » — imprimée sur l'étiquette QR
|
||
brand String
|
||
model String?
|
||
serialNumber String?
|
||
commissionedAt DateTime?
|
||
loadKg Int?
|
||
floors Int?
|
||
status AssetStatus @default(IN_SERVICE) // statut d'ÉQUIPEMENT ≠ statut d'OT
|
||
underContract Boolean @default(true) // contrat préventif (grille mensuelle)
|
||
categoryId String @db.Uuid
|
||
category Category @relation(fields: [categoryId], references: [id])
|
||
locationId String @db.Uuid
|
||
location Location @relation(fields: [locationId], references: [id])
|
||
components AssetComponent[]
|
||
workOrders WorkOrder[]
|
||
requests Request[]
|
||
meters Meter[]
|
||
documents Document[]
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
@@index([locationId])
|
||
@@index([categoryId])
|
||
}
|
||
|
||
// Organe : PAS de colonne emplacement — « un organe n'a pas d'emplacement
|
||
// propre » est garanti par construction (décision maquettes R1).
|
||
model AssetComponent {
|
||
id String @id @default(uuid()) @db.Uuid
|
||
assetId String @db.Uuid
|
||
asset Asset @relation(fields: [assetId], references: [id], onDelete: Cascade)
|
||
typeId String @db.Uuid
|
||
type Category @relation(fields: [typeId], references: [id])
|
||
designation String?
|
||
|
||
@@index([assetId])
|
||
}
|
||
|
||
model Team {
|
||
id String @id @default(uuid()) @db.Uuid
|
||
name String @unique
|
||
description String?
|
||
members User[]
|
||
}
|
||
|
||
// ————— R2 — Exploitation (docs/03-architecture/modele-donnees.md §R2) —————
|
||
|
||
enum WorkOrderType {
|
||
CORRECTIVE // Dépannage
|
||
PREVENTIVE // Maintenance (grille du mois)
|
||
WORKS // Travaux
|
||
}
|
||
|
||
enum WorkOrderStatus {
|
||
OPEN
|
||
IN_PROGRESS
|
||
ON_HOLD
|
||
DONE
|
||
CANCELLED
|
||
}
|
||
|
||
enum WorkOrderPriority {
|
||
NONE
|
||
LOW
|
||
MEDIUM
|
||
HIGH
|
||
PERSON_TRAPPED // personne bloquée — urgence absolue
|
||
}
|
||
|
||
enum RequestStatus {
|
||
RECEIVED
|
||
APPROVED
|
||
REJECTED
|
||
}
|
||
|
||
enum ChecklistState {
|
||
PENDING
|
||
DONE
|
||
NA
|
||
}
|
||
|
||
enum BilanField {
|
||
DOOR_STATE
|
||
CABIN_POSITION
|
||
ANOMALY
|
||
EXTERNAL_CAUSE
|
||
ACTION_TAKEN
|
||
COMPONENT_CONCERNED
|
||
}
|
||
|
||
enum MeterKind {
|
||
RUNNING_HOURS
|
||
STARTS
|
||
}
|
||
|
||
model WorkOrder {
|
||
id String @id @default(uuid()) @db.Uuid
|
||
reference String @unique // OT-2026-0341
|
||
title String
|
||
description String?
|
||
type WorkOrderType
|
||
status WorkOrderStatus @default(OPEN) // machine à états stricte (service)
|
||
priority WorkOrderPriority @default(NONE)
|
||
assetId String @db.Uuid
|
||
asset Asset @relation(fields: [assetId], references: [id])
|
||
dueDate DateTime?
|
||
assignees User[] @relation("WorkOrderAssignees")
|
||
createdById String? @db.Uuid
|
||
createdBy User? @relation("WorkOrderCreator", fields: [createdById], references: [id])
|
||
startedAt DateTime?
|
||
completedAt DateTime?
|
||
cancelledAt DateTime?
|
||
events WorkOrderEvent[]
|
||
checklist ChecklistItem[]
|
||
report InterventionReport?
|
||
request Request?
|
||
stockMovements StockMovement[]
|
||
laborTimes LaborTime[]
|
||
documents Document[]
|
||
// Grille du mois : « AAAA-MM » — l'unicité [assetId, periodKey] EST
|
||
// l'idempotence de la génération (les OT correctifs restent à null).
|
||
periodKey String?
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
@@unique([assetId, periodKey])
|
||
@@index([assetId])
|
||
@@index([status])
|
||
}
|
||
|
||
model WorkOrderEvent {
|
||
id String @id @default(uuid()) @db.Uuid
|
||
workOrderId String @db.Uuid
|
||
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
|
||
kind String // COMMENT · STATUS_CHANGED · ASSIGNED · CREATED · FROM_REQUEST
|
||
message String?
|
||
byId String? @db.Uuid
|
||
by User? @relation(fields: [byId], references: [id])
|
||
createdAt DateTime @default(now())
|
||
|
||
@@index([workOrderId])
|
||
}
|
||
|
||
model Request {
|
||
id String @id @default(uuid()) @db.Uuid
|
||
reference String @unique // DEM-2026-0112
|
||
description String
|
||
isPersonTrapped Boolean @default(false)
|
||
status RequestStatus @default(RECEIVED)
|
||
rejectionReason String? // REQUIS au rejet (service)
|
||
assetId String @db.Uuid
|
||
asset Asset @relation(fields: [assetId], references: [id])
|
||
requestedById String? @db.Uuid
|
||
requestedBy User? @relation(fields: [requestedById], references: [id])
|
||
requesterName String? // portail public via QR (R2.4)
|
||
// Suivi SANS COMPTE (portail) : le téléphone du gardien garde ce jeton,
|
||
// seul moyen de lire l'avancement — jamais listé, jamais devinable.
|
||
publicToken String? @unique
|
||
workOrderId String? @unique @db.Uuid // lien 1-1 — jamais de doublon
|
||
workOrder WorkOrder? @relation(fields: [workOrderId], references: [id])
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
@@index([assetId])
|
||
}
|
||
|
||
// Référentiels administrables du bilan codé (un par champ)
|
||
model ReferenceValue {
|
||
id String @id @default(uuid()) @db.Uuid
|
||
field BilanField
|
||
label String
|
||
isActive Boolean @default(true)
|
||
|
||
doorStates InterventionReport[] @relation("BilanDoorState")
|
||
cabinPositions InterventionReport[] @relation("BilanCabinPosition")
|
||
anomalies InterventionReport[] @relation("BilanAnomaly")
|
||
externalCauses InterventionReport[] @relation("BilanExternalCause")
|
||
actionsTaken InterventionReport[] @relation("BilanActionTaken")
|
||
componentsConcerned InterventionReport[] @relation("BilanComponentConcerned")
|
||
|
||
@@unique([field, label])
|
||
}
|
||
|
||
model InterventionReport {
|
||
id String @id @default(uuid()) @db.Uuid
|
||
workOrderId String @unique @db.Uuid
|
||
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
|
||
note String?
|
||
|
||
doorStateId String? @db.Uuid
|
||
doorState ReferenceValue? @relation("BilanDoorState", fields: [doorStateId], references: [id])
|
||
cabinPositionId String? @db.Uuid
|
||
cabinPosition ReferenceValue? @relation("BilanCabinPosition", fields: [cabinPositionId], references: [id])
|
||
anomalyId String? @db.Uuid
|
||
anomaly ReferenceValue? @relation("BilanAnomaly", fields: [anomalyId], references: [id])
|
||
externalCauseId String? @db.Uuid
|
||
externalCause ReferenceValue? @relation("BilanExternalCause", fields: [externalCauseId], references: [id])
|
||
actionTakenId String? @db.Uuid
|
||
actionTaken ReferenceValue? @relation("BilanActionTaken", fields: [actionTakenId], references: [id])
|
||
componentConcernedId String? @db.Uuid
|
||
componentConcerned ReferenceValue? @relation("BilanComponentConcerned", fields: [componentConcernedId], references: [id])
|
||
|
||
updatedAt DateTime @updatedAt
|
||
}
|
||
|
||
model TaskTemplate {
|
||
id String @id @default(uuid()) @db.Uuid
|
||
label String @unique
|
||
componentTypeId String? @db.Uuid
|
||
componentType Category? @relation(fields: [componentTypeId], references: [id])
|
||
periodMonths Int // 1, 3, 6, 12…
|
||
isRegulatory Boolean @default(false) // essai parachute
|
||
isActive Boolean @default(true)
|
||
checklistItems ChecklistItem[]
|
||
}
|
||
|
||
model ChecklistItem {
|
||
id String @id @default(uuid()) @db.Uuid
|
||
workOrderId String @db.Uuid
|
||
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
|
||
label String
|
||
state ChecklistState @default(PENDING)
|
||
templateId String? @db.Uuid
|
||
template TaskTemplate? @relation(fields: [templateId], references: [id])
|
||
doneById String? @db.Uuid
|
||
doneBy User? @relation(fields: [doneById], references: [id])
|
||
doneAt DateTime?
|
||
|
||
@@index([workOrderId])
|
||
}
|
||
|
||
model Meter {
|
||
id String @id @default(uuid()) @db.Uuid
|
||
assetId String @db.Uuid
|
||
asset Asset @relation(fields: [assetId], references: [id], onDelete: Cascade)
|
||
kind MeterKind
|
||
readings MeterReading[]
|
||
|
||
@@unique([assetId, kind])
|
||
}
|
||
|
||
model MeterReading {
|
||
id String @id @default(uuid()) @db.Uuid
|
||
meterId String @db.Uuid
|
||
meter Meter @relation(fields: [meterId], references: [id], onDelete: Cascade)
|
||
value Int // strictement croissant (service)
|
||
readById String? @db.Uuid
|
||
readBy User? @relation(fields: [readById], references: [id])
|
||
createdAt DateTime @default(now())
|
||
|
||
@@index([meterId])
|
||
}
|
||
|
||
// ————— R3 — Gestion (docs/03-architecture/modele-donnees.md §R3) —————
|
||
|
||
enum PartnerKind {
|
||
SUPPLIER
|
||
CLIENT // syndic / propriétaire
|
||
}
|
||
|
||
enum PurchaseOrderStatus {
|
||
DRAFT
|
||
SENT
|
||
RECEIVED
|
||
CANCELLED
|
||
}
|
||
|
||
enum StockMovementKind {
|
||
RECEIPT // réception de BC (+, PU figé)
|
||
ENTRY // entrée manuelle (+)
|
||
CONSUMPTION // consommation d'OT (−, PU figé)
|
||
ADJUSTMENT // inventaire (±, motif REQUIS)
|
||
}
|
||
|
||
enum DocumentKind {
|
||
NOTICE
|
||
CERTIFICATE
|
||
PHOTO
|
||
OTHER
|
||
}
|
||
|
||
model Partner {
|
||
id String @id @default(uuid()) @db.Uuid
|
||
name String @unique
|
||
kind PartnerKind
|
||
contactName String?
|
||
phone String?
|
||
email String?
|
||
city String?
|
||
isActive Boolean @default(true)
|
||
parts Part[]
|
||
purchaseOrders PurchaseOrder[]
|
||
sites Location[] // sites gérés (kind CLIENT)
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
}
|
||
|
||
model Part {
|
||
id String @id @default(uuid()) @db.Uuid
|
||
reference String @unique // P-0113
|
||
designation String
|
||
threshold Int @default(0) // seuil d'alerte
|
||
// Dernier prix d'achat — figé sur chaque mouvement au moment T.
|
||
lastUnitPrice Decimal? @db.Decimal(10, 2)
|
||
compatible String?
|
||
supplierId String? @db.Uuid
|
||
supplier Partner? @relation(fields: [supplierId], references: [id])
|
||
isActive Boolean @default(true)
|
||
movements StockMovement[]
|
||
orderLines PurchaseOrderLine[]
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
}
|
||
|
||
// Le stock EST la somme des mouvements — aucune colonne de quantité.
|
||
model StockMovement {
|
||
id String @id @default(uuid()) @db.Uuid
|
||
partId String @db.Uuid
|
||
part Part @relation(fields: [partId], references: [id], onDelete: Cascade)
|
||
kind StockMovementKind
|
||
quantity Int // signé : + entrée, − sortie
|
||
unitPrice Decimal? @db.Decimal(10, 2) // figé (réception, consommation)
|
||
reason String? // ajustement : motif requis (service)
|
||
workOrderId String? @db.Uuid
|
||
workOrder WorkOrder? @relation(fields: [workOrderId], references: [id])
|
||
purchaseOrderId String? @db.Uuid
|
||
purchaseOrder PurchaseOrder? @relation(fields: [purchaseOrderId], references: [id])
|
||
byId String? @db.Uuid
|
||
by User? @relation(fields: [byId], references: [id])
|
||
createdAt DateTime @default(now())
|
||
|
||
@@index([partId])
|
||
@@index([workOrderId])
|
||
}
|
||
|
||
model PurchaseOrder {
|
||
id String @id @default(uuid()) @db.Uuid
|
||
reference String @unique // BC-2026-0024
|
||
status PurchaseOrderStatus @default(DRAFT)
|
||
supplierId String @db.Uuid
|
||
supplier Partner @relation(fields: [supplierId], references: [id])
|
||
lines PurchaseOrderLine[]
|
||
movements StockMovement[]
|
||
sentAt DateTime?
|
||
receivedAt DateTime?
|
||
cancelledAt DateTime?
|
||
createdById String? @db.Uuid
|
||
createdBy User? @relation(fields: [createdById], references: [id])
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
}
|
||
|
||
model PurchaseOrderLine {
|
||
id String @id @default(uuid()) @db.Uuid
|
||
purchaseOrderId String @db.Uuid
|
||
purchaseOrder PurchaseOrder @relation(fields: [purchaseOrderId], references: [id], onDelete: Cascade)
|
||
partId String @db.Uuid
|
||
part Part @relation(fields: [partId], references: [id])
|
||
quantity Int
|
||
unitPrice Decimal @db.Decimal(10, 2)
|
||
|
||
@@index([purchaseOrderId])
|
||
}
|
||
|
||
// Main-d'œuvre : le taux est FIGÉ à la saisie (le coût d'un OT ne bouge plus).
|
||
model LaborTime {
|
||
id String @id @default(uuid()) @db.Uuid
|
||
workOrderId String @db.Uuid
|
||
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
|
||
userId String @db.Uuid
|
||
user User @relation(fields: [userId], references: [id])
|
||
minutes Int
|
||
hourlyRate Decimal @db.Decimal(8, 2)
|
||
note String?
|
||
createdAt DateTime @default(now())
|
||
|
||
@@index([workOrderId])
|
||
}
|
||
|
||
model Document {
|
||
id String @id @default(uuid()) @db.Uuid
|
||
kind DocumentKind
|
||
fileName String
|
||
storageKey String @unique // clé MinIO (FileStorage)
|
||
size Int
|
||
contentType String
|
||
assetId String? @db.Uuid
|
||
asset Asset? @relation(fields: [assetId], references: [id])
|
||
workOrderId String? @db.Uuid
|
||
workOrder WorkOrder? @relation(fields: [workOrderId], references: [id])
|
||
uploadedById String? @db.Uuid
|
||
uploadedBy User? @relation(fields: [uploadedById], references: [id])
|
||
createdAt DateTime @default(now())
|
||
|
||
@@index([assetId])
|
||
@@index([workOrderId])
|
||
}
|