Files
siop2/docs/03-architecture/modele-donnees.md
pr-daaif 266ffaaf1b feat(r1.1): socle backend du référentiel — modèle, contrat, API, seed, tests
- migration r1_referentiel : Category (EQUIPMENT/COMPONENT_TYPE), Location
  (site → zone, lat/lng + colonne PostGIS générée geography(Point,4326)
  + index GIST), Asset (statut d'équipement), AssetComponent (organe sans
  emplacement PAR CONSTRUCTION), Team, invitation sur User ; migration
  autosuffisante (CREATE EXTENSION IF NOT EXISTS postgis)
- contrat : 21 nouvelles opérations (26 total), générateur OpenAPI étendu
  aux paramètres de chemin ; spec + client web régénérés dans ce commit
- API : modules categories/locations/assets/teams + gestion des personnes
  (liste, rôles, invitation lien 7 j à usage unique, activation publique
  qui connecte directement, mise à jour rôle/équipes) — tout sous
  @RequirePermission ; invariants en service (profondeur 2, kinds,
  catégorie jamais supprimée)
- seed : parc de la maquette validée (5 sites + 8 zones, 8 appareils,
  organes A1/B2, 9 catégories, 2 équipes) — idempotent
- 36 tests verts (couverture 96 % stmts / 85 % branches) : recette
  site→zone→appareil→organes, matrice vivante, invitation→activation ;
  smoke test sur build de prod
- CI : postgres → postgis/postgis:18-3.6 (la migration R1 l'exige)

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

6.1 KiB
Raw Blame History

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

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

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

À 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).
  • R5 : tables d'embeddings pgvector (côté service IA).

Journal des migrations

# Migration Contenu
1 r0_identity Role, Permission, User (+ index & uniques ci-dessus)