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

@@ -37,6 +37,17 @@ import {
UsersResponseSchema,
UserUpdateSchema,
} from './schemas/users-admin';
import {
MeterReadingCreateSchema,
MetersResponseSchema,
PreventiveGenerateSchema,
PreventiveGenerationResultSchema,
PreventiveStatusSchema,
TaskTemplateCreateSchema,
TaskTemplateSchema,
TaskTemplatesResponseSchema,
TaskTemplateUpdateSchema,
} from './schemas/preventif';
import {
AssigneesUpdateSchema,
ChecklistItemSchema,
@@ -387,6 +398,93 @@ export const API_CONTRACT: ApiOperation[] = [
404: { description: 'Inconnue' },
},
},
// ————— R2 · Préventif & compteurs —————
{
operationId: 'listTaskTemplates',
method: 'get',
path: '/preventive/templates',
summary: 'Gabarits de tâches du préventif (périodicité en mois)',
tags: ['preventive'],
responses: {
200: { description: 'Liste', name: 'TaskTemplatesResponse', schema: TaskTemplatesResponseSchema },
},
},
{
operationId: 'createTaskTemplate',
method: 'post',
path: '/preventive/templates',
summary: 'Ajouter un gabarit',
tags: ['preventive'],
request: { name: 'TaskTemplateCreate', schema: TaskTemplateCreateSchema },
responses: {
201: { description: 'Créé', name: 'TaskTemplate', schema: TaskTemplateSchema },
409: { description: 'Libellé déjà utilisé' },
},
},
{
operationId: 'updateTaskTemplate',
method: 'patch',
path: '/preventive/templates/{id}',
summary: 'Modifier / (dés)activer un gabarit — jamais de suppression si utilisé',
tags: ['preventive'],
pathParams: ['id'],
request: { name: 'TaskTemplateUpdate', schema: TaskTemplateUpdateSchema },
responses: {
200: { description: 'Mis à jour', name: 'TaskTemplate', schema: TaskTemplateSchema },
404: { description: 'Inconnu' },
},
},
{
operationId: 'generatePreventive',
method: 'post',
path: '/preventive/generate',
summary: 'Générer la grille du mois — IDEMPOTENT (regénérer ne double rien)',
tags: ['preventive'],
request: { name: 'PreventiveGenerate', schema: PreventiveGenerateSchema },
responses: {
200: {
description: 'Résumé de génération',
name: 'PreventiveGenerationResult',
schema: PreventiveGenerationResultSchema,
},
},
},
{
operationId: 'getPreventiveStatus',
method: 'get',
path: '/preventive/status',
summary: 'État du mois courant (générées, terminées, en retard, premiers contrôles)',
tags: ['preventive'],
responses: {
200: { description: 'État', name: 'PreventiveStatus', schema: PreventiveStatusSchema },
},
},
{
operationId: 'getAssetMeters',
method: 'get',
path: '/assets/{id}/meters',
summary: 'Compteurs de lappareil (relevés récents dabord)',
tags: ['meters'],
pathParams: ['id'],
responses: {
200: { description: 'Compteurs', name: 'MetersResponse', schema: MetersResponseSchema },
404: { description: 'Appareil inconnu' },
},
},
{
operationId: 'addMeterReading',
method: 'post',
path: '/assets/{id}/meter-readings',
summary: 'Enregistrer un relevé — strictement croissant',
tags: ['meters'],
pathParams: ['id'],
request: { name: 'MeterReadingCreate', schema: MeterReadingCreateSchema },
responses: {
201: { description: 'Relevé enregistré', name: 'MetersResponse', schema: MetersResponseSchema },
400: { description: 'Relevé inférieur ou égal au précédent' },
},
},
// ————— R2 · Exploitation —————
{
operationId: 'listWorkOrders',

View File

@@ -2,6 +2,7 @@ export * from './permissions';
export * from './referentiel';
export * from './exploitation';
export * from './schemas/exploitation';
export * from './schemas/preventif';
export * from './schemas/auth';
export * from './schemas/users';
export * from './schemas/users-admin';

View File

@@ -0,0 +1,92 @@
import { z } from 'zod';
import { METER_KINDS } from '../exploitation';
// ————— Gabarits du préventif —————
export const TaskTemplateSchema = z.object({
id: z.uuid(),
label: z.string(),
periodMonths: z.number().int(),
isRegulatory: z.boolean(),
isActive: z.boolean(),
componentTypeId: z.uuid().nullable(),
componentTypeName: z.string().nullable(),
});
export type TaskTemplateDto = z.infer<typeof TaskTemplateSchema>;
export const TaskTemplatesResponseSchema = z.object({
templates: z.array(TaskTemplateSchema),
});
export type TaskTemplatesResponse = z.infer<typeof TaskTemplatesResponseSchema>;
export const TaskTemplateCreateSchema = z.object({
label: z.string().min(1).max(200),
periodMonths: z.number().int().min(1).max(60),
componentTypeId: z.uuid().optional(),
isRegulatory: z.boolean().optional(),
});
export type TaskTemplateCreate = z.infer<typeof TaskTemplateCreateSchema>;
export const TaskTemplateUpdateSchema = z.object({
label: z.string().min(1).max(200).optional(),
periodMonths: z.number().int().min(1).max(60).optional(),
componentTypeId: z.uuid().nullable().optional(),
isRegulatory: z.boolean().optional(),
isActive: z.boolean().optional(),
});
export type TaskTemplateUpdate = z.infer<typeof TaskTemplateUpdateSchema>;
// ————— Génération mensuelle —————
const MOIS = /^\d{4}-(0[1-9]|1[0-2])$/;
export const PreventiveGenerateSchema = z.object({
/** « AAAA-MM » — défaut : le mois courant. */
month: z.string().regex(MOIS, 'Format attendu : AAAA-MM').optional(),
});
export type PreventiveGenerate = z.infer<typeof PreventiveGenerateSchema>;
export const PreventiveGenerationResultSchema = z.object({
month: z.string(),
created: z.number().int(), // grilles créées par CET appel
skipped: z.number().int(), // déjà générées (idempotence)
firstControls: z.number().int(), // parmi les créées
references: z.array(z.string()), // OT créés
});
export type PreventiveGenerationResult = z.infer<
typeof PreventiveGenerationResultSchema
>;
export const PreventiveStatusSchema = z.object({
month: z.string(),
assetsUnderContract: z.number().int(),
generated: z.number().int(),
done: z.number().int(),
late: z.number().int(), // échéance dépassée et non terminé
firstControls: z.number().int(),
});
export type PreventiveStatus = z.infer<typeof PreventiveStatusSchema>;
// ————— Compteurs —————
export const MeterReadingSchema = z.object({
id: z.uuid(),
value: z.number().int(),
readBy: z.object({ id: z.uuid(), displayName: z.string() }).nullable(),
createdAt: z.iso.datetime(),
});
export const MeterSchema = z.object({
kind: z.enum(METER_KINDS),
readings: z.array(MeterReadingSchema), // les plus récents d'abord
});
export type MeterDto = z.infer<typeof MeterSchema>;
export const MetersResponseSchema = z.object({ meters: z.array(MeterSchema) });
export type MetersResponse = z.infer<typeof MetersResponseSchema>;
export const MeterReadingCreateSchema = z.object({
kind: z.enum(METER_KINDS),
value: z.number().int().min(0), // strictement croissant (service)
});
export type MeterReadingCreate = z.infer<typeof MeterReadingCreateSchema>;