Files
siop2/docs/03-architecture/modele-donnees.md
pr-daaif dfdf8f7c18 feat(r3.1): socle backend gestion — stock dérivé, BC, coûts figés sur OT
- migration r3_gestion (7 tables) : Partner, Part (SANS colonne de
  quantité), StockMovement (signé, tracé, PU figé), PurchaseOrder/Line,
  LaborTime (taux figé), Document (R3.2) ; User.hourlyRate administrable
- API (66 opérations) : tiers ; pièces (stock = Σ mouvements, alerte sous
  seuil) ; entrée/ajustement (motif requis, stock jamais négatif, en
  transaction) ; BC Brouillon→Envoyé→Reçu (la réception crée les RECEIPT
  et met à jour lastUnitPrice) ; consommation sur OT (stock suffisant,
  PRIX FIGÉ) ; main-d'œuvre (TAUX FIGÉ, refus si taux non défini) ;
  WorkOrderDetail.costs ; coûts verrouillés après clôture (409)
- seed : tiers/pièces/BC/taux de la maquette — OT-0341 = 505 MAD (testé)
- durcissement : références OT/DEM/BC/P par SÉQUENCES Postgres (nextval)
  — fin des courses « max+1 » (500 sporadiques sous charge parallèle) ;
  3 runs Jest complets consécutifs verts
- 55 tests (92 % stmts / 74,9 % branches) dont la recette officielle :
  consommer sous seuil → BC → réception → réappro, prix/taux figés prouvés

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

12 KiB
Raw Permalink 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

R2 — Exploitation

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)

R3 — Gestion

enum PartnerKind         { SUPPLIER CLIENT }                 // fournisseur / syndic
enum PurchaseOrderStatus { DRAFT SENT RECEIVED CANCELLED }
enum StockMovementKind   { RECEIPT ENTRY CONSUMPTION ADJUSTMENT }
enum DocumentKind        { NOTICE CERTIFICATE PHOTO OTHER }

model Partner { id, name @unique, kind, contact…, isActive }

model Part {                 // AUCUNE colonne de quantité : le stock EST la
  id, reference @unique      // somme des mouvements (décision v1 éprouvée)
  designation, threshold Int // seuil d'alerte
  lastUnitPrice Decimal?     // dernier prix d'achat (mis à jour à la réception)
  supplierId? → Partner
}

model StockMovement {        // + entrée /  sortie, TOUJOURS tracé
  id, partId (cascade), kind, quantity Int (signé)
  unitPrice Decimal?         // FIGÉ (réception : PU du BC ; consommation : lastUnitPrice)
  reason String?             // ADJUSTMENT : motif REQUIS (service)
  workOrderId? → WorkOrder   // consommation
  purchaseOrderId? → PurchaseOrder // réception
  byId? → User
}

model PurchaseOrder {        // DRAFT → SENT → RECEIVED ; annulable avant réception
  id, reference @unique, status, supplierId → Partner
  lines PurchaseOrderLine[]  // partId, quantity, unitPrice Decimal
  // la RÉCEPTION crée un mouvement RECEIPT par ligne et met à jour lastUnitPrice
}

model LaborTime {            // heures × taux FIGÉ à la saisie
  id, workOrderId (cascade), userId → User, minutes Int, hourlyRate Decimal, note?
}
// User reçoit hourlyRate Decimal? (taux COURANT, administrable)

model Document {             // MinIO derrière FileStorage — jamais d'accès direct
  id, kind, fileName, storageKey @unique, size, contentType
  assetId? / workOrderId?    // rattachement REQUIS à l'un des deux (service)
}

Invariants R3 :

Invariant Où il vit
Stock = Σ mouvements ; jamais de saisie directe de quantité par construction (pas de colonne)
Un ajustement exige un motif ; le stock ne devient jamais négatif service parts (+ tests)
Consommation : PU figé = lastUnitPrice au moment T ; stock suffisant exigé service (transaction)
Réception : BC SENT uniquement ; crée les RECEIPT + met à jour lastUnitPrice service (transaction)
Main-d'œuvre : taux figé = User.hourlyRate au moment T (refus si non défini) service
Coût total d'un OT = Σ consommations + Σ main-d'œuvre — IMMUABLE après coup dérivé des lignes figées
Document : rattaché à un appareil OU un OT ; types fermés ; 20 Mo max service documents (R3.2)

À venir (référence v1 éprouvée, sera réintroduit release par release)

  • 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)