Files
siop2/docs/03-architecture/modele-donnees.md
pr-daaif f7702e4252 feat(r2.1): socle backend exploitation — machine à états, bilan codé, demandes 1-1
- migration r2_exploitation (10 tables) : WorkOrder (référence séquentielle,
  horodatages), WorkOrderEvent, Request (1-1, motif de rejet), ReferenceValue,
  InterventionReport (6 FK), TaskTemplate/ChecklistItem, Meter/MeterReading
- contrat : 15 opérations (41 total) ; la table des transitions et les champs
  requis du bilan vivent dans @siop/shared ; la fiche OT expose
  allowedTransitions + closureBlockers (messages métier)
- API : machine à états stricte ; garde de clôture (bilan 3 champs requis +
  checklist sans tâche en attente) ; approbation → OT lié 1-1 (409 si déjà
  traitée) ; rejet à motif obligatoire ; scoping « voir autre » sur listes et
  accès directs (404 sans fuite) ; validation des valeurs de bilan par champ ;
  « personne bloquée » triée en tête côté API
- seed : 31 valeurs de référentiels, 8 gabarits (parachute réglementaire),
  OT/demandes/compteurs de la maquette — idempotent
- 45 tests verts (95 % stmts / 79 % branches) dont la recette officielle
  rejouée de bout en bout ; smoke test sur build de prod

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 13:56:07 +01:00

201 lines
9.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Modèle de données — SIOP V2
> **Rôle de ce document (playbook)** : le modèle est introduit **par release** (on ne
> modélise pas ce qu'on ne construit pas). Chaque section explique les invariants et OÙ
> ils vivent (base / service). La v1 sert de référence éprouvée pour les entités à venir.
## R0 — Identité & permissions
```prisma
model Role {
id String @id @default(uuid()) @db.Uuid
name String @unique // Administrateur, Dispatcher, Technicien,
users User[] // Technicien limité, Gestionnaire,
permissions Permission[] // Demandeur, Vue seule
}
model Permission { // matrice rôles × objets × droits — EN BASE, jamais dans le JWT
id String @id @default(uuid()) @db.Uuid
roleId String @db.Uuid
role Role @relation(...)
objectCategory String // enum applicatif : WORK_ORDERS, ASSETS, …
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)
roleId String @db.Uuid
role Role @relation(...)
isActive Boolean @default(true)
isDemo Boolean @default(false) // seul un compte isDemo est empruntable (ADR-002)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
```
**Invariants R0** :
| Invariant | Où il vit |
| --- | --- |
| Un rôle par utilisateur ; la matrice décide de tout accès | `PermissionsGuard` (relit la base, cache 60 s) |
| Le JWT ne porte jamais de droits (seulement l'identité + roleId) | conception auth |
| `demo-login` refuse tout compte `isDemo=false` | service auth (+ test e2e) |
| Matrice complète : chaque rôle a une ligne par catégorie d'objet | seed idempotent (+ test) |
## R1 — Référentiel
```prisma
enum CategoryKind { EQUIPMENT COMPONENT_TYPE } // référentiels administrables
enum AssetStatus { IN_SERVICE OUT_OF_SERVICE UNDER_MAINTENANCE }
model Category { // renommable/désactivable, JAMAIS supprimée si utilisée
id String @id @default(uuid()) @db.Uuid
kind CategoryKind
name String
isActive Boolean @default(true)
@@unique([kind, name])
}
model Location { // site (parentId null) → zone (1 niveau max, vérifié service)
id String @id @default(uuid()) @db.Uuid
name String
parentId String? @db.Uuid // auto-relation « LocationTree »
address String? city String?
guardianName String? guardianPhone String?
latitude Float? longitude Float? // saisies par la carte
// + colonne PostGIS générée (migration SQL) :
// position geography(Point,4326) GENERATED ALWAYS AS (ST_Point(longitude,latitude)::geography) STORED
}
model Asset { // l'appareil (ascenseur, monte-charge…)
id String @id @default(uuid()) @db.Uuid
reference String @unique // « A1 », « B2 » — imprimée sur le QR
brand String model String? serialNumber String?
commissionedAt DateTime? loadKg Int? floors Int?
status AssetStatus @default(IN_SERVICE) // statut d'ÉQUIPEMENT ≠ statut d'OT
categoryId String @db.Uuid // Category(kind=EQUIPMENT)
locationId String @db.Uuid // rattachement obligatoire
components AssetComponent[]
}
model AssetComponent { // organe — PAS de colonne emplacement : la règle
id String @id @default(uuid()) @db.Uuid // « un organe n'a pas d'emplacement
assetId String @db.Uuid // propre » est garantie PAR
typeId String @db.Uuid // CONSTRUCTION (table dédiée),
designation String? // plus besoin du CHECK v1
}
model Team { // équipes par zone — l'assignation d'OT (R2) s'appuiera dessus
id String @id @default(uuid()) @db.Uuid
name String @unique
description String?
members User[] // m2m implicite
}
// User (R0) reçoit : phone?, teams Team[], et l'invitation (ADR maquettes R1) :
// activationToken String? @unique + activationExpiresAt DateTime?
// statut dérivé : invité = passwordHash null && token présent ; actif = hash présent
```
**Invariants R1** :
| Invariant | Où il vit |
| --- | --- |
| Hiérarchie d'emplacements limitée à 2 niveaux (site → zone) | service locations (+ test) |
| Un organe n'a pas d'emplacement propre | par construction (AssetComponent sans locationId) |
| `Category(kind)` cohérente avec l'usage (EQUIPMENT sur Asset, COMPONENT_TYPE sur organe) | services (+ test) |
| Catégorie utilisée : jamais supprimée (désactivation seulement) | service categories (+ test) |
| 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)
- **R3** : `Part`/`StockMovement` (stock **dérivé des mouvements**), `PurchaseOrder`,
`Partner`, `LaborTime` (taux figé), `Document`.
- **R4** : `WorkOrder.version` (verrou optimiste de la synchro mobile).
- **R5** : tables d'embeddings pgvector (côté service IA).
## Journal des migrations
| # | Migration | Contenu |
| --- | --- | --- |
| 1 | `r0_identity` | Role, Permission, User (+ index & uniques ci-dessus) |