feat(r2.2): préventif — génération mensuelle idempotente — et compteurs

- migration : Asset.underContract + WorkOrder.periodKey avec unicité
  [assetId, periodKey] — l'idempotence de la génération est EN BASE
  (deux appels concurrents ne doublent jamais une grille)
- génération : une grille par appareil sous contrat, périodicités ancrées
  sur la mise en service (mensuelles toujours dues), premier contrôle
  (toutes les tâches) pour un appareil sans historique, échéance fin de
  mois, événement GENERATED ; déclenchement manuel (cron au durcissement)
- gabarits administrables (désactivé = exclu des générations suivantes) ;
  GET /preventive/status (générées, terminées, en retard, premiers contrôles)
- compteurs : GET meters (2 compteurs, récents d'abord) ; relevé
  strictement croissant, refus motivé sinon
- contrat +7 opérations (48) ; 50 tests verts (94,7 % / 78 %) ; smoke test
  prod : juillet généré (9 grilles, 8 premiers contrôles), 2e appel à zéro

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pr-daaif
2026-07-16 15:15:57 +01:00
parent f7702e4252
commit 902efa6192
17 changed files with 1868 additions and 1 deletions

View File

@@ -4,6 +4,24 @@ Trace chronologique des sessions (la plus récente en premier). Le **playbook**
---
## 2026-07-16 — Pr. Daaif (+ Claude) — R2.2 : préventif (génération idempotente) + compteurs
**Actions**
- **Modèle** (migration `r2_preventif_compteurs`) : `Asset.underContract` (contrat préventif, maquette fiche appareil) et `WorkOrder.periodKey` avec **unicité `[assetId, periodKey]` : l'idempotence de la génération est EN BASE**, pas seulement dans le code — deux générations concurrentes ne peuvent pas doubler une grille.
- **Génération mensuelle** (`POST /preventive/generate`, 200 idempotent) : une grille par appareil sous contrat ; tâches dues par périodicité **ancrée sur la mise en service** (mensuelles toujours dues) ; appareil sans historique préventif → OT « **Premier contrôle** » avec toutes les tâches ; échéance = fin de mois ; événement GENERATED tracé. Déclenchement manuel en R2 (bouton maquette) — l'automatisation cron/BullMQ viendra avec le durcissement production.
- Gabarits administrables (ajout/renommage/désactivation — un gabarit désactivé sort des générations suivantes) ; `GET /preventive/status` (générées, terminées, en retard, premiers contrôles du mois).
- **Compteurs** : `GET /assets/{id}/meters` (les 2 compteurs, relevés récents d'abord) ; `POST /assets/{id}/meter-readings`**relevé strictement croissant**, refus motivé sinon.
- Contrat : +7 opérations (48). **50 tests verts** (couverture 94,7 % / 78 %) : idempotence (8 grilles seed, jamais 16), premier contrôle vs grille normale, mois anniversaire (mars 2031 pour A1 : les 3/6/12 mois tombent ensemble), gabarit désactivé exclu, compteur qui refuse de redescendre. Smoke test prod : juillet 2026 généré (9 grilles, 8 premiers contrôles), 2ᵉ appel à zéro.
**Leçon**
- Les specs Jest partagent la base en parallèle : les assertions e2e doivent porter sur **leurs propres données** (ici les 8 appareils du seed), jamais sur des comptages globaux.
**Prochaine étape** : R2.3 — écrans web de l'exploitation (liste/fiche OT, demandes+approbation, nouvel OT, préventif, checklist, compteurs, chip urgence + bandeau tableau de bord).
---
## 2026-07-16 — Pr. Daaif (+ Claude) — R2.1 : socle backend de l'exploitation
**Actions**

View File

@@ -930,6 +930,271 @@
}
}
},
"/preventive/templates": {
"get": {
"operationId": "listTaskTemplates",
"summary": "Gabarits de tâches du préventif (périodicité en mois)",
"tags": [
"preventive"
],
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Liste",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TaskTemplatesResponse"
}
}
}
}
}
},
"post": {
"operationId": "createTaskTemplate",
"summary": "Ajouter un gabarit",
"tags": [
"preventive"
],
"security": [
{
"bearerAuth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TaskTemplateCreate"
}
}
}
},
"responses": {
"201": {
"description": "Créé",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TaskTemplate"
}
}
}
},
"409": {
"description": "Libellé déjà utilisé"
}
}
}
},
"/preventive/templates/{id}": {
"patch": {
"operationId": "updateTaskTemplate",
"summary": "Modifier / (dés)activer un gabarit — jamais de suppression si utilisé",
"tags": [
"preventive"
],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"security": [
{
"bearerAuth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TaskTemplateUpdate"
}
}
}
},
"responses": {
"200": {
"description": "Mis à jour",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TaskTemplate"
}
}
}
},
"404": {
"description": "Inconnu"
}
}
}
},
"/preventive/generate": {
"post": {
"operationId": "generatePreventive",
"summary": "Générer la grille du mois — IDEMPOTENT (regénérer ne double rien)",
"tags": [
"preventive"
],
"security": [
{
"bearerAuth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PreventiveGenerate"
}
}
}
},
"responses": {
"200": {
"description": "Résumé de génération",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PreventiveGenerationResult"
}
}
}
}
}
}
},
"/preventive/status": {
"get": {
"operationId": "getPreventiveStatus",
"summary": "État du mois courant (générées, terminées, en retard, premiers contrôles)",
"tags": [
"preventive"
],
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "État",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PreventiveStatus"
}
}
}
}
}
}
},
"/assets/{id}/meters": {
"get": {
"operationId": "getAssetMeters",
"summary": "Compteurs de lappareil (relevés récents dabord)",
"tags": [
"meters"
],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Compteurs",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MetersResponse"
}
}
}
},
"404": {
"description": "Appareil inconnu"
}
}
}
},
"/assets/{id}/meter-readings": {
"post": {
"operationId": "addMeterReading",
"summary": "Enregistrer un relevé — strictement croissant",
"tags": [
"meters"
],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"security": [
{
"bearerAuth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MeterReadingCreate"
}
}
}
},
"responses": {
"201": {
"description": "Relevé enregistré",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MetersResponse"
}
}
}
},
"400": {
"description": "Relevé inférieur ou égal au précédent"
}
}
}
},
"/work-orders": {
"get": {
"operationId": "listWorkOrders",
@@ -3308,6 +3573,396 @@
},
"additionalProperties": false
},
"TaskTemplatesResponse": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"templates": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
},
"label": {
"type": "string"
},
"periodMonths": {
"type": "integer",
"minimum": -9007199254740991,
"maximum": 9007199254740991
},
"isRegulatory": {
"type": "boolean"
},
"isActive": {
"type": "boolean"
},
"componentTypeId": {
"anyOf": [
{
"type": "string",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
},
{
"type": "null"
}
]
},
"componentTypeName": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"required": [
"id",
"label",
"periodMonths",
"isRegulatory",
"isActive",
"componentTypeId",
"componentTypeName"
],
"additionalProperties": false
}
}
},
"required": [
"templates"
],
"additionalProperties": false
},
"TaskTemplate": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
},
"label": {
"type": "string"
},
"periodMonths": {
"type": "integer",
"minimum": -9007199254740991,
"maximum": 9007199254740991
},
"isRegulatory": {
"type": "boolean"
},
"isActive": {
"type": "boolean"
},
"componentTypeId": {
"anyOf": [
{
"type": "string",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
},
{
"type": "null"
}
]
},
"componentTypeName": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"required": [
"id",
"label",
"periodMonths",
"isRegulatory",
"isActive",
"componentTypeId",
"componentTypeName"
],
"additionalProperties": false
},
"TaskTemplateCreate": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"label": {
"type": "string",
"minLength": 1,
"maxLength": 200
},
"periodMonths": {
"type": "integer",
"minimum": 1,
"maximum": 60
},
"componentTypeId": {
"type": "string",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
},
"isRegulatory": {
"type": "boolean"
}
},
"required": [
"label",
"periodMonths"
],
"additionalProperties": false
},
"TaskTemplateUpdate": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"label": {
"type": "string",
"minLength": 1,
"maxLength": 200
},
"periodMonths": {
"type": "integer",
"minimum": 1,
"maximum": 60
},
"componentTypeId": {
"anyOf": [
{
"type": "string",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
},
{
"type": "null"
}
]
},
"isRegulatory": {
"type": "boolean"
},
"isActive": {
"type": "boolean"
}
},
"additionalProperties": false
},
"PreventiveGenerationResult": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"month": {
"type": "string"
},
"created": {
"type": "integer",
"minimum": -9007199254740991,
"maximum": 9007199254740991
},
"skipped": {
"type": "integer",
"minimum": -9007199254740991,
"maximum": 9007199254740991
},
"firstControls": {
"type": "integer",
"minimum": -9007199254740991,
"maximum": 9007199254740991
},
"references": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
"month",
"created",
"skipped",
"firstControls",
"references"
],
"additionalProperties": false
},
"PreventiveGenerate": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"month": {
"type": "string",
"pattern": "^\\d{4}-(0[1-9]|1[0-2])$"
}
},
"additionalProperties": false
},
"PreventiveStatus": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"month": {
"type": "string"
},
"assetsUnderContract": {
"type": "integer",
"minimum": -9007199254740991,
"maximum": 9007199254740991
},
"generated": {
"type": "integer",
"minimum": -9007199254740991,
"maximum": 9007199254740991
},
"done": {
"type": "integer",
"minimum": -9007199254740991,
"maximum": 9007199254740991
},
"late": {
"type": "integer",
"minimum": -9007199254740991,
"maximum": 9007199254740991
},
"firstControls": {
"type": "integer",
"minimum": -9007199254740991,
"maximum": 9007199254740991
}
},
"required": [
"month",
"assetsUnderContract",
"generated",
"done",
"late",
"firstControls"
],
"additionalProperties": false
},
"MetersResponse": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"meters": {
"type": "array",
"items": {
"type": "object",
"properties": {
"kind": {
"type": "string",
"enum": [
"RUNNING_HOURS",
"STARTS"
]
},
"readings": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
},
"value": {
"type": "integer",
"minimum": -9007199254740991,
"maximum": 9007199254740991
},
"readBy": {
"anyOf": [
{
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
},
"displayName": {
"type": "string"
}
},
"required": [
"id",
"displayName"
],
"additionalProperties": false
},
{
"type": "null"
}
]
},
"createdAt": {
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
}
},
"required": [
"id",
"value",
"readBy",
"createdAt"
],
"additionalProperties": false
}
}
},
"required": [
"kind",
"readings"
],
"additionalProperties": false
}
}
},
"required": [
"meters"
],
"additionalProperties": false
},
"MeterReadingCreate": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"kind": {
"type": "string",
"enum": [
"RUNNING_HOURS",
"STARTS"
]
},
"value": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
}
},
"required": [
"kind",
"value"
],
"additionalProperties": false
},
"WorkOrdersResponse": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",