mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
- 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>
12 KiB
12 KiB
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) |