# 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) | ## R3 — Gestion ```prisma 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) |