diff --git a/CLAUDE.md b/CLAUDE.md index 067d5ca..f2c46a1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,5 +46,6 @@ pnpm + Turborepo. `apps/api` : NestJS, Prisma, PostgreSQL (pgvector + PostGIS), - 🏁 **R1 CLOSE (16/07/2026, tag `release/r1`)** : recettĂ©e par le rĂ©fĂ©rent, dĂ©ployĂ©e et vĂ©rifiĂ©e en ligne (migration + seed au boot, 5 sites / 8 appareils sur l'instance). - ✅ **R2 — maquettes validĂ©es** (16/07) : `maquette-r2.html` (demandes/approbation, nouvel OT, prĂ©ventif, checklist, compteurs) + 4 dĂ©cisions actĂ©es. - ✅ **R2.1 — socle backend exploitation** : migration `r2_exploitation` (10 tables), 15 opĂ©rations au contrat (41 total, transitions + champs requis du bilan dans `@siop/shared`), machine Ă  Ă©tats stricte avec garde de clĂŽture (bilan 3 champs + checklist), demande→OT 1-1, rejet Ă  motif, scoping « voir autre » (listes + accĂšs directs), seed maquette (31 valeurs de bilan, 8 gabarits, OT/demandes/compteurs), 45 tests (95 %/79 %). -- 🔄 **R2.2 — reprise ici** : gĂ©nĂ©ration mensuelle du prĂ©ventif (idempotente, premier contrĂŽle, BullMQ ou dĂ©clenchement manuel + cron) + API compteurs (relevĂ©s croissants) → R2.3 Ă©crans web (liste/fiche OT, demandes, nouvel OT, prĂ©ventif, checklist, compteurs, chip urgence topbar + bandeau dashboard) → R2.4 portail public QR → recette + dĂ©ploiement + tag. +- ✅ **R2.2 — prĂ©ventif + compteurs** : `underContract` + `periodKey` (unicitĂ© `[assetId, periodKey]` = idempotence EN BASE), gĂ©nĂ©ration mensuelle (ancrage mise en service, premier contrĂŽle, gabarits administrables, statut du mois), compteurs strictement croissants ; +7 opĂ©rations (48), 50 tests (94,7 %/78 %). Automatisation cron/BullMQ notĂ©e pour le durcissement production. +- 🔄 **R2.3 — reprise ici** : Ă©crans web exploitation fidĂšles aux maquettes (liste OT + filtres + strie urgence, fiche OT : transitions/activitĂ©/bilan codĂ©/checklist, demandes + panneau d'approbation, nouvel OT + interrupteur personne bloquĂ©e, prĂ©ventif + bouton gĂ©nĂ©rer, compteurs en fiche appareil, chip urgence topbar + bandeau tableau de bord) → e2e Playwright recette R2 → R2.4 portail public QR → recette + dĂ©ploiement + tag. - DĂ©tail quotidien : `docs/journal/journal.md`. DĂ©pĂŽt : `siop-spelev/siop2` (privĂ©), jalons R0→R5. diff --git a/apps/api/prisma/migrations/20260716150957_r2_preventif_compteurs/migration.sql b/apps/api/prisma/migrations/20260716150957_r2_preventif_compteurs/migration.sql new file mode 100644 index 0000000..f6f3c33 --- /dev/null +++ b/apps/api/prisma/migrations/20260716150957_r2_preventif_compteurs/migration.sql @@ -0,0 +1,9 @@ +-- AlterTable +ALTER TABLE "Asset" ADD COLUMN "underContract" BOOLEAN NOT NULL DEFAULT true; + +-- AlterTable +ALTER TABLE "WorkOrder" ADD COLUMN "periodKey" TEXT; + +-- CreateIndex +CREATE UNIQUE INDEX "WorkOrder_assetId_periodKey_key" ON "WorkOrder"("assetId", "periodKey"); + diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index f8d9251..80be5ad 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -118,6 +118,7 @@ model Asset { loadKg Int? floors Int? status AssetStatus @default(IN_SERVICE) // statut d'ÉQUIPEMENT ≠ statut d'OT + underContract Boolean @default(true) // contrat prĂ©ventif (grille mensuelle) categoryId String @db.Uuid category Category @relation(fields: [categoryId], references: [id]) locationId String @db.Uuid @@ -224,9 +225,13 @@ model WorkOrder { checklist ChecklistItem[] report InterventionReport? request Request? + // Grille du mois : « AAAA-MM » — l'unicitĂ© [assetId, periodKey] EST + // l'idempotence de la gĂ©nĂ©ration (les OT correctifs restent Ă  null). + periodKey String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + @@unique([assetId, periodKey]) @@index([assetId]) @@index([status]) } diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 5bedadf..6d813f6 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -9,6 +9,8 @@ import { demoModeEnabled } from './config/env'; import { FilesModule } from './files/files.module'; import { HealthModule } from './health/health.module'; import { LocationsModule } from './locations/locations.module'; +import { MetersModule } from './meters/meters.module'; +import { PreventiveModule } from './preventive/preventive.module'; import { ReferenceValuesModule } from './reference-values/reference-values.module'; import { RequestsModule } from './requests/requests.module'; import { TeamsModule } from './teams/teams.module'; @@ -44,6 +46,8 @@ export class AppModule { WorkOrdersModule, RequestsModule, ReferenceValuesModule, + PreventiveModule, + MetersModule, // ADR-002 : hors DEMO_MODE, le module n'est pas enregistrĂ© → 404 ...(demoModeEnabled() ? [DemoAuthModule] : []), ], diff --git a/apps/api/src/meters/meters.controller.ts b/apps/api/src/meters/meters.controller.ts new file mode 100644 index 0000000..8e539fc --- /dev/null +++ b/apps/api/src/meters/meters.controller.ts @@ -0,0 +1,40 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Post, +} from '@nestjs/common'; +import { + MeterReadingCreateSchema, + type MeterReadingCreate, +} from '@siop/shared'; +import { + AuthenticatedUser, + CurrentUser, +} from '../auth/current-user.decorator'; +import { ZodValidationPipe } from '../common/zod-validation.pipe'; +import { RequirePermission } from '../permissions/require-permission.decorator'; +import { MetersService } from './meters.service'; + +@Controller('assets/:id') +export class MetersController { + constructor(private readonly meters: MetersService) {} + + @Get('meters') + @RequirePermission('METERS', 'view') + list(@Param('id', ParseUUIDPipe) id: string) { + return this.meters.list(id); + } + + @Post('meter-readings') + @RequirePermission('METERS', 'create') + addReading( + @Param('id', ParseUUIDPipe) id: string, + @Body(new ZodValidationPipe(MeterReadingCreateSchema)) body: MeterReadingCreate, + @CurrentUser() user: AuthenticatedUser, + ) { + return this.meters.addReading(id, body, user); + } +} diff --git a/apps/api/src/meters/meters.module.ts b/apps/api/src/meters/meters.module.ts new file mode 100644 index 0000000..cd26b9d --- /dev/null +++ b/apps/api/src/meters/meters.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { MetersController } from './meters.controller'; +import { MetersService } from './meters.service'; + +@Module({ + controllers: [MetersController], + providers: [MetersService], +}) +export class MetersModule {} diff --git a/apps/api/src/meters/meters.service.ts b/apps/api/src/meters/meters.service.ts new file mode 100644 index 0000000..f8cc4fb --- /dev/null +++ b/apps/api/src/meters/meters.service.ts @@ -0,0 +1,77 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { METER_KINDS, type MeterReadingCreate, type MetersResponse } from '@siop/shared'; +import type { AuthenticatedUser } from '../auth/current-user.decorator'; +import { PrismaService } from '../prisma/prisma.service'; + +@Injectable() +export class MetersService { + constructor(private readonly prisma: PrismaService) {} + + async list(assetId: string): Promise { + await this.assertAsset(assetId); + const meters = await this.prisma.meter.findMany({ + where: { assetId }, + include: { + readings: { + include: { readBy: true }, + orderBy: { createdAt: 'desc' }, + take: 10, + }, + }, + }); + // Les deux compteurs existent toujours dans la rĂ©ponse, mĂȘme vides + return { + meters: METER_KINDS.map((kind) => { + const meter = meters.find((m) => m.kind === kind); + return { + kind, + readings: + meter?.readings.map((r) => ({ + id: r.id, + value: r.value, + readBy: r.readBy + ? { id: r.readBy.id, displayName: r.readBy.displayName } + : null, + createdAt: r.createdAt.toISOString(), + })) ?? [], + }; + }), + }; + } + + /** Invariant : un compteur ne redescend jamais. */ + async addReading( + assetId: string, + dto: MeterReadingCreate, + user: AuthenticatedUser, + ): Promise { + await this.assertAsset(assetId); + const meter = await this.prisma.meter.upsert({ + where: { assetId_kind: { assetId, kind: dto.kind } }, + update: {}, + create: { assetId, kind: dto.kind }, + }); + const dernier = await this.prisma.meterReading.findFirst({ + where: { meterId: meter.id }, + orderBy: { createdAt: 'desc' }, + }); + if (dernier && dto.value <= dernier.value) { + throw new BadRequestException( + `RelevĂ© refusĂ© : ${dto.value} est infĂ©rieur ou Ă©gal au prĂ©cĂ©dent (${dernier.value}) — un compteur ne redescend pas`, + ); + } + await this.prisma.meterReading.create({ + data: { meterId: meter.id, value: dto.value, readById: user.userId }, + }); + return this.list(assetId); + } + + private async assertAsset(assetId: string): Promise { + const asset = await this.prisma.asset.findUnique({ where: { id: assetId } }); + if (!asset) throw new NotFoundException('Appareil inconnu'); + } +} diff --git a/apps/api/src/preventive/preventive.controller.ts b/apps/api/src/preventive/preventive.controller.ts new file mode 100644 index 0000000..9888211 --- /dev/null +++ b/apps/api/src/preventive/preventive.controller.ts @@ -0,0 +1,69 @@ +import { + Body, + Controller, + Get, + HttpCode, + Param, + ParseUUIDPipe, + Patch, + Post, +} from '@nestjs/common'; +import { + PreventiveGenerateSchema, + TaskTemplateCreateSchema, + TaskTemplateUpdateSchema, + type PreventiveGenerate, + type TaskTemplateCreate, + type TaskTemplateUpdate, +} from '@siop/shared'; +import { + AuthenticatedUser, + CurrentUser, +} from '../auth/current-user.decorator'; +import { ZodValidationPipe } from '../common/zod-validation.pipe'; +import { RequirePermission } from '../permissions/require-permission.decorator'; +import { PreventiveService } from './preventive.service'; + +@Controller('preventive') +export class PreventiveController { + constructor(private readonly preventive: PreventiveService) {} + + @Get('templates') + @RequirePermission('WORK_ORDERS', 'view') + listTemplates() { + return this.preventive.listTemplates(); + } + + @Post('templates') + @RequirePermission('SETTINGS', 'create') + createTemplate( + @Body(new ZodValidationPipe(TaskTemplateCreateSchema)) body: TaskTemplateCreate, + ) { + return this.preventive.createTemplate(body); + } + + @Patch('templates/:id') + @RequirePermission('SETTINGS', 'edit') + updateTemplate( + @Param('id', ParseUUIDPipe) id: string, + @Body(new ZodValidationPipe(TaskTemplateUpdateSchema)) body: TaskTemplateUpdate, + ) { + return this.preventive.updateTemplate(id, body); + } + + @Post('generate') + @HttpCode(200) // idempotent : rien n'est « créé » de plus au second appel + @RequirePermission('WORK_ORDERS', 'create') + generate( + @Body(new ZodValidationPipe(PreventiveGenerateSchema)) body: PreventiveGenerate, + @CurrentUser() user: AuthenticatedUser, + ) { + return this.preventive.generate(body.month, user); + } + + @Get('status') + @RequirePermission('WORK_ORDERS', 'view') + status() { + return this.preventive.status(); + } +} diff --git a/apps/api/src/preventive/preventive.module.ts b/apps/api/src/preventive/preventive.module.ts new file mode 100644 index 0000000..ff9c6f6 --- /dev/null +++ b/apps/api/src/preventive/preventive.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { PreventiveController } from './preventive.controller'; +import { PreventiveService } from './preventive.service'; + +@Module({ + controllers: [PreventiveController], + providers: [PreventiveService], +}) +export class PreventiveModule {} diff --git a/apps/api/src/preventive/preventive.service.ts b/apps/api/src/preventive/preventive.service.ts new file mode 100644 index 0000000..03d9f26 --- /dev/null +++ b/apps/api/src/preventive/preventive.service.ts @@ -0,0 +1,221 @@ +import { + ConflictException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import type { + PreventiveGenerationResult, + PreventiveStatus, + TaskTemplateCreate, + TaskTemplateDto, + TaskTemplatesResponse, + TaskTemplateUpdate, +} from '@siop/shared'; +import type { AuthenticatedUser } from '../auth/current-user.decorator'; +import { PrismaService } from '../prisma/prisma.service'; + +const templateInclude = { + componentType: true, +} satisfies Prisma.TaskTemplateInclude; + +type TemplateRow = Prisma.TaskTemplateGetPayload<{ include: typeof templateInclude }>; + +const MOIS_FR = [ + 'janvier', 'fĂ©vrier', 'mars', 'avril', 'mai', 'juin', + 'juillet', 'aoĂ»t', 'septembre', 'octobre', 'novembre', 'dĂ©cembre', +]; + +/** « AAAA-MM » → index absolu de mois. */ +const indexMois = (period: string): number => { + const [annee, mois] = period.split('-').map(Number); + return annee! * 12 + (mois! - 1); +}; + +@Injectable() +export class PreventiveService { + private readonly logger = new Logger(PreventiveService.name); + + constructor(private readonly prisma: PrismaService) {} + + // ————— Gabarits ————— + + async listTemplates(): Promise { + const rows = await this.prisma.taskTemplate.findMany({ + include: templateInclude, + orderBy: [{ periodMonths: 'asc' }, { label: 'asc' }], + }); + return { templates: rows.map((t) => this.toDto(t)) }; + } + + async createTemplate(dto: TaskTemplateCreate): Promise { + try { + const created = await this.prisma.taskTemplate.create({ + data: dto, + include: templateInclude, + }); + return this.toDto(created); + } catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { + throw new ConflictException('Ce libellĂ© de tĂąche existe dĂ©jĂ '); + } + throw e; + } + } + + async updateTemplate(id: string, dto: TaskTemplateUpdate): Promise { + try { + const updated = await this.prisma.taskTemplate.update({ + where: { id }, + data: dto, + include: templateInclude, + }); + return this.toDto(updated); + } catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') { + throw new NotFoundException('Gabarit inconnu'); + } + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { + throw new ConflictException('Ce libellĂ© de tĂąche existe dĂ©jĂ '); + } + throw e; + } + } + + // ————— GĂ©nĂ©ration mensuelle (IDEMPOTENTE) ————— + + /** + * Une grille par appareil sous contrat et par mois — l'unicitĂ© + * [assetId, periodKey] est en BASE : regĂ©nĂ©rer ne double jamais rien, + * mĂȘme en cas d'appels concurrents. + * TĂąches dues : pĂ©riodicitĂ© ancrĂ©e sur la mise en service (Ă  dĂ©faut, tout + * mois) ; appareil sans historique prĂ©ventif → « premier contrĂŽle » (tout). + */ + async generate( + month: string | undefined, + user: AuthenticatedUser, + ): Promise { + const period = month ?? new Date().toISOString().slice(0, 7); + const [annee, mois] = period.split('-').map(Number); + const dernierJour = new Date(Date.UTC(annee!, mois!, 0, 23, 59, 59)); + const libelleMois = `${MOIS_FR[mois! - 1]} ${annee}`; + + const [assets, templates] = await Promise.all([ + this.prisma.asset.findMany({ + where: { underContract: true }, + include: { _count: { select: { workOrders: { where: { type: 'PREVENTIVE' } } } } }, + }), + this.prisma.taskTemplate.findMany({ where: { isActive: true } }), + ]); + + let created = 0; + let skipped = 0; + let firstControls = 0; + const references: string[] = []; + + for (const asset of assets) { + const premierControle = asset._count.workOrders === 0; + const dues = premierControle + ? templates + : templates.filter((t) => { + if (t.periodMonths === 1) return true; + const ancre = asset.commissionedAt + ? asset.commissionedAt.getUTCFullYear() * 12 + asset.commissionedAt.getUTCMonth() + : indexMois(period); + return (indexMois(period) - ancre) % t.periodMonths === 0; + }); + if (dues.length === 0) { + skipped++; + continue; + } + try { + const ot = await this.prisma.workOrder.create({ + data: { + reference: await this.nextReference(), + title: premierControle + ? `Premier contrĂŽle — ${libelleMois}` + : `Grille du mois — ${libelleMois}`, + type: 'PREVENTIVE', + priority: 'LOW', + assetId: asset.id, + periodKey: period, + dueDate: dernierJour, + createdById: user.userId, + checklist: { + create: dues.map((t) => ({ label: t.label, templateId: t.id })), + }, + events: { + create: { + kind: 'GENERATED', + message: `GĂ©nĂ©rĂ© (${premierControle ? 'premier contrĂŽle' : 'grille mensuelle'} · ${dues.length} tĂąches)`, + byId: user.userId, + }, + }, + }, + select: { reference: true }, + }); + created++; + if (premierControle) firstControls++; + references.push(ot.reference); + } catch (e) { + // [assetId, periodKey] dĂ©jĂ  pris → grille dĂ©jĂ  en place : idempotence. + // (Collision de rĂ©fĂ©rence, rarissime : le prochain appel rattrape.) + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { + skipped++; + continue; + } + throw e; + } + } + this.logger.log( + `PrĂ©ventif ${period} : ${created} gĂ©nĂ©rĂ©s, ${skipped} dĂ©jĂ  en place, ${firstControls} premiers contrĂŽles.`, + ); + return { month: period, created, skipped, firstControls, references }; + } + + async status(): Promise { + const period = new Date().toISOString().slice(0, 7); + const [assetsUnderContract, grilles] = await Promise.all([ + this.prisma.asset.count({ where: { underContract: true } }), + this.prisma.workOrder.findMany({ + where: { periodKey: period }, + select: { status: true, dueDate: true, title: true }, + }), + ]); + const maintenant = new Date(); + return { + month: period, + assetsUnderContract, + generated: grilles.length, + done: grilles.filter((g) => g.status === 'DONE').length, + late: grilles.filter( + (g) => g.status !== 'DONE' && g.status !== 'CANCELLED' && g.dueDate! < maintenant, + ).length, + firstControls: grilles.filter((g) => g.title.startsWith('Premier contrĂŽle')).length, + }; + } + + private async nextReference(): Promise { + const annee = new Date().getFullYear(); + const dernier = await this.prisma.workOrder.findFirst({ + where: { reference: { startsWith: `OT-${annee}-` } }, + orderBy: { reference: 'desc' }, + select: { reference: true }, + }); + const n = dernier ? Number(dernier.reference.split('-')[2]) + 1 : 1; + return `OT-${annee}-${String(n).padStart(4, '0')}`; + } + + private toDto(row: TemplateRow): TaskTemplateDto { + return { + id: row.id, + label: row.label, + periodMonths: row.periodMonths, + isRegulatory: row.isRegulatory, + isActive: row.isActive, + componentTypeId: row.componentTypeId, + componentTypeName: row.componentType?.name ?? null, + }; + } +} diff --git a/apps/api/test/preventif.e2e-spec.ts b/apps/api/test/preventif.e2e-spec.ts new file mode 100644 index 0000000..d61478e --- /dev/null +++ b/apps/api/test/preventif.e2e-spec.ts @@ -0,0 +1,189 @@ +/** + * E2E R2.2 — prĂ©ventif : gĂ©nĂ©ration IDEMPOTENTE (l'unicitĂ© est en base), + * premier contrĂŽle (tout), pĂ©riodicitĂ©s ancrĂ©es sur la mise en service ; + * compteurs strictement croissants. + * Les gĂ©nĂ©rations de test utilisent des mois lointains (2031) puis sont purgĂ©es. + */ +process.env.DEMO_MODE = 'true'; + +import { INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import { PrismaClient } from '@prisma/client'; +import request from 'supertest'; +import { seed } from '../prisma/seed'; +import { AppModule } from '../src/app.module'; + +describe('PrĂ©ventif & compteurs (e2e)', () => { + let app: INestApplication; + let admin: string; + let ahmed: string; + const prisma = new PrismaClient(); + const http = () => request(app.getHttpServer()); + const auth = (t: string) => ({ Authorization: `Bearer ${t}` }); + + beforeAll(async () => { + await seed(prisma); + await prisma.workOrder.deleteMany({ where: { periodKey: { startsWith: '2031-' } } }); + const moduleRef = await Test.createTestingModule({ + imports: [AppModule.forRoot()], + }).compile(); + app = moduleRef.createNestApplication(); + await app.init(); + const { body } = await http().get('/auth/demo-accounts'); + const login = async (roleName: string) => { + const compte = body.accounts.find((a: { roleName: string }) => a.roleName === roleName); + return (await http().post('/auth/demo-login').send({ userId: compte.id })).body + .accessToken as string; + }; + admin = await login('Administrateur'); + ahmed = await login('Technicien'); + }); + + afterAll(async () => { + await prisma.workOrder.deleteMany({ where: { periodKey: { startsWith: '2031-' } } }); + await prisma.taskTemplate.deleteMany({ where: { label: { contains: 'TEST-' } } }); + await prisma.meterReading.deleteMany({ where: { value: { gte: 90000000 } } }); + await app?.close(); + await prisma.$disconnect(); + }); + + // Les assertions portent sur les 8 appareils du SEED : d'autres specs Jest + // crĂ©ent/dĂ©truisent des appareils en parallĂšle sur la mĂȘme base. + const SEED_REFS = ['A1', 'A2', 'B1', 'B2', 'C1', 'D1', 'E2', 'M1']; + const grillesSeed = (period: string) => + prisma.workOrder.count({ + where: { periodKey: period, asset: { reference: { in: SEED_REFS } } }, + }); + + it('gĂ©nĂšre une grille par appareil sous contrat — et regĂ©nĂ©rer ne double RIEN', async () => { + const premiere = await http() + .post('/preventive/generate') + .set(auth(admin)) + .send({ month: '2031-01' }) + .expect(200); + expect(premiere.body.created).toBeGreaterThanOrEqual(SEED_REFS.length); + expect(premiere.body.references.length).toBe(premiere.body.created); + expect(await grillesSeed('2031-01')).toBe(SEED_REFS.length); + + const seconde = await http() + .post('/preventive/generate') + .set(auth(admin)) + .send({ month: '2031-01' }) + .expect(200); + expect(seconde.body.skipped).toBeGreaterThanOrEqual(SEED_REFS.length); + expect(await grillesSeed('2031-01')).toBe(SEED_REFS.length); // toujours 8, pas 16 + }); + + it('premier contrĂŽle (toutes les tĂąches) pour un appareil sans historique ; grille normale sinon', async () => { + const templatesActifs = await prisma.taskTemplate.count({ where: { isActive: true } }); + const mensuelles = await prisma.taskTemplate.count({ + where: { isActive: true, periodMonths: 1 }, + }); + + // B2 n'avait aucun OT prĂ©ventif avant 2031-01 → premier contrĂŽle + const b2 = await prisma.asset.findUniqueOrThrow({ where: { reference: 'B2' } }); + const grilleB2 = await prisma.workOrder.findUniqueOrThrow({ + where: { assetId_periodKey: { assetId: b2.id, periodKey: '2031-01' } }, + include: { _count: { select: { checklist: true } } }, + }); + expect(grilleB2.title).toContain('Premier contrĂŽle'); + expect(grilleB2._count.checklist).toBe(templatesActifs); + + // A1 a un historique prĂ©ventif (seed juillet) → grille normale. + // Mise en service mars 2020 : janvier 2031 n'est le mois ni du 12, ni du 3, + // ni du 6 mois (Ă©cart 130) → seules les tĂąches MENSUELLES sont dues. + const a1 = await prisma.asset.findUniqueOrThrow({ where: { reference: 'A1' } }); + const grilleA1 = await prisma.workOrder.findUniqueOrThrow({ + where: { assetId_periodKey: { assetId: a1.id, periodKey: '2031-01' } }, + include: { _count: { select: { checklist: true } } }, + }); + expect(grilleA1.title).toContain('Grille du mois'); + expect(grilleA1._count.checklist).toBe(mensuelles); + }); + + it('mois anniversaire de mise en service : les pĂ©riodicitĂ©s longues tombent (mars 2031 pour A1)', async () => { + await http() + .post('/preventive/generate') + .set(auth(admin)) + .send({ month: '2031-03' }) + .expect(200); + // Ă©cart mars 2020 → mars 2031 = 132 mois : divisible par 3, 6 et 12 + const templatesActifs = await prisma.taskTemplate.count({ where: { isActive: true } }); + const a1 = await prisma.asset.findUniqueOrThrow({ where: { reference: 'A1' } }); + const grille = await prisma.workOrder.findUniqueOrThrow({ + where: { assetId_periodKey: { assetId: a1.id, periodKey: '2031-03' } }, + include: { checklist: true }, + }); + expect(grille.checklist.length).toBe(templatesActifs); + expect(grille.checklist.some((c) => c.label === 'Essai du parachute')).toBe(true); + }); + + it('gabarits : ajout, doublon 409, dĂ©sactivation (exclu des gĂ©nĂ©rations suivantes), 404', async () => { + const cree = await http() + .post('/preventive/templates') + .set(auth(admin)) + .send({ label: 'TEST-ContrĂŽle Ă©clairage gaine', periodMonths: 1 }) + .expect(201); + await http() + .post('/preventive/templates') + .set(auth(admin)) + .send({ label: 'TEST-ContrĂŽle Ă©clairage gaine', periodMonths: 1 }) + .expect(409); + await http() + .patch(`/preventive/templates/${cree.body.id}`) + .set(auth(admin)) + .send({ isActive: false }) + .expect(200); + await http() + .patch('/preventive/templates/00000000-0000-4000-8000-000000000000') + .set(auth(admin)) + .send({ label: 'X' }) + .expect(404); + // dĂ©sactivĂ© → absent de la gĂ©nĂ©ration d'un nouveau mois + await http() + .post('/preventive/generate') + .set(auth(admin)) + .send({ month: '2031-05' }) + .expect(200); + const labels = await prisma.checklistItem.findMany({ + where: { workOrder: { periodKey: '2031-05' } }, + select: { label: true }, + }); + expect(labels.some((l) => l.label.startsWith('TEST-'))).toBe(false); + // le statut du mois courant rĂ©pond (structure) + const status = await http().get('/preventive/status').set(auth(ahmed)).expect(200); + expect(status.body).toHaveProperty('generated'); + expect(status.body).toHaveProperty('late'); + }); + + it('compteurs : relevĂ© croissant acceptĂ©, rĂ©gression refusĂ©e, appareil inconnu 404', async () => { + const { body: assets } = await http().get('/assets').set(auth(ahmed)); + const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A1'); + + const liste = await http().get(`/assets/${a1.id}/meters`).set(auth(ahmed)).expect(200); + expect(liste.body.meters).toHaveLength(2); // les 2 compteurs, mĂȘme vides + const heures = liste.body.meters.find((m: { kind: string }) => m.kind === 'RUNNING_HOURS'); + const dernier = heures.readings[0]?.value ?? 0; + + const apres = await http() + .post(`/assets/${a1.id}/meter-readings`) + .set(auth(ahmed)) + .send({ kind: 'RUNNING_HOURS', value: 90000000 + dernier }) + .expect(201); + const majHeures = apres.body.meters.find( + (m: { kind: string }) => m.kind === 'RUNNING_HOURS', + ); + expect(majHeures.readings[0].value).toBe(90000000 + dernier); + expect(majHeures.readings[0].readBy.displayName).toBe('Ahmed Benali'); + + await http() + .post(`/assets/${a1.id}/meter-readings`) + .set(auth(ahmed)) + .send({ kind: 'RUNNING_HOURS', value: dernier }) + .expect(400); + await http() + .get('/assets/00000000-0000-4000-8000-000000000000/meters') + .set(auth(ahmed)) + .expect(404); + }); +}); diff --git a/apps/web/src/api/schema.d.ts b/apps/web/src/api/schema.d.ts index fb82314..71c5300 100644 --- a/apps/web/src/api/schema.d.ts +++ b/apps/web/src/api/schema.d.ts @@ -355,6 +355,109 @@ export interface paths { patch: operations["updateUser"]; trace?: never; }; + "/preventive/templates": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Gabarits de tĂąches du prĂ©ventif (pĂ©riodicitĂ© en mois) */ + get: operations["listTaskTemplates"]; + put?: never; + /** Ajouter un gabarit */ + post: operations["createTaskTemplate"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/preventive/templates/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** Modifier / (dĂ©s)activer un gabarit — jamais de suppression si utilisĂ© */ + patch: operations["updateTaskTemplate"]; + trace?: never; + }; + "/preventive/generate": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** GĂ©nĂ©rer la grille du mois — IDEMPOTENT (regĂ©nĂ©rer ne double rien) */ + post: operations["generatePreventive"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/preventive/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** État du mois courant (gĂ©nĂ©rĂ©es, terminĂ©es, en retard, premiers contrĂŽles) */ + get: operations["getPreventiveStatus"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/assets/{id}/meters": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Compteurs de l’appareil (relevĂ©s rĂ©cents d’abord) */ + get: operations["getAssetMeters"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/assets/{id}/meter-readings": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Enregistrer un relevĂ© — strictement croissant */ + post: operations["addMeterReading"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/work-orders": { parameters: { query?: never; @@ -941,6 +1044,83 @@ export interface components { teamIds?: string[]; isActive?: boolean; }; + TaskTemplatesResponse: { + templates: { + /** Format: uuid */ + id: string; + label: string; + periodMonths: number; + isRegulatory: boolean; + isActive: boolean; + componentTypeId: string | null; + componentTypeName: string | null; + }[]; + }; + TaskTemplate: { + /** Format: uuid */ + id: string; + label: string; + periodMonths: number; + isRegulatory: boolean; + isActive: boolean; + componentTypeId: string | null; + componentTypeName: string | null; + }; + TaskTemplateCreate: { + label: string; + periodMonths: number; + /** Format: uuid */ + componentTypeId?: string; + isRegulatory?: boolean; + }; + TaskTemplateUpdate: { + label?: string; + periodMonths?: number; + componentTypeId?: string | null; + isRegulatory?: boolean; + isActive?: boolean; + }; + PreventiveGenerationResult: { + month: string; + created: number; + skipped: number; + firstControls: number; + references: string[]; + }; + PreventiveGenerate: { + month?: string; + }; + PreventiveStatus: { + month: string; + assetsUnderContract: number; + generated: number; + done: number; + late: number; + firstControls: number; + }; + MetersResponse: { + meters: { + /** @enum {string} */ + kind: "RUNNING_HOURS" | "STARTS"; + readings: { + /** Format: uuid */ + id: string; + value: number; + readBy: { + /** Format: uuid */ + id: string; + displayName: string; + } | null; + /** Format: date-time */ + createdAt: string; + }[]; + }[]; + }; + MeterReadingCreate: { + /** @enum {string} */ + kind: "RUNNING_HOURS" | "STARTS"; + value: number; + }; WorkOrdersResponse: { workOrders: { /** Format: uuid */ @@ -1947,6 +2127,196 @@ export interface operations { }; }; }; + listTaskTemplates: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Liste */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TaskTemplatesResponse"]; + }; + }; + }; + }; + createTaskTemplate: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TaskTemplateCreate"]; + }; + }; + responses: { + /** @description Créé */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TaskTemplate"]; + }; + }; + /** @description LibellĂ© dĂ©jĂ  utilisĂ© */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + updateTaskTemplate: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TaskTemplateUpdate"]; + }; + }; + responses: { + /** @description Mis Ă  jour */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TaskTemplate"]; + }; + }; + /** @description Inconnu */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + generatePreventive: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PreventiveGenerate"]; + }; + }; + responses: { + /** @description RĂ©sumĂ© de gĂ©nĂ©ration */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PreventiveGenerationResult"]; + }; + }; + }; + }; + getPreventiveStatus: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description État */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PreventiveStatus"]; + }; + }; + }; + }; + getAssetMeters: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Compteurs */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MetersResponse"]; + }; + }; + /** @description Appareil inconnu */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + addMeterReading: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["MeterReadingCreate"]; + }; + }; + responses: { + /** @description RelevĂ© enregistrĂ© */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MetersResponse"]; + }; + }; + /** @description RelevĂ© infĂ©rieur ou Ă©gal au prĂ©cĂ©dent */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; listWorkOrders: { parameters: { query?: never; diff --git a/docs/journal/journal.md b/docs/journal/journal.md index c4257a8..da6f05c 100644 --- a/docs/journal/journal.md +++ b/docs/journal/journal.md @@ -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** diff --git a/docs/openapi.json b/docs/openapi.json index f0242b8..d4c4913 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -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 l’appareil (relevĂ©s rĂ©cents d’abord)", + "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", diff --git a/packages/shared/src/contract.ts b/packages/shared/src/contract.ts index d507851..c52e36d 100644 --- a/packages/shared/src/contract.ts +++ b/packages/shared/src/contract.ts @@ -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 l’appareil (relevĂ©s rĂ©cents d’abord)', + 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', diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 7723728..191546d 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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'; diff --git a/packages/shared/src/schemas/preventif.ts b/packages/shared/src/schemas/preventif.ts new file mode 100644 index 0000000..75b03bb --- /dev/null +++ b/packages/shared/src/schemas/preventif.ts @@ -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; + +export const TaskTemplatesResponseSchema = z.object({ + templates: z.array(TaskTemplateSchema), +}); +export type TaskTemplatesResponse = z.infer; + +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; + +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; + +// ————— 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; + +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; + +// ————— 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; + +export const MetersResponseSchema = z.object({ meters: z.array(MeterSchema) }); +export type MetersResponse = z.infer; + +export const MeterReadingCreateSchema = z.object({ + kind: z.enum(METER_KINDS), + value: z.number().int().min(0), // strictement croissant (service) +}); +export type MeterReadingCreate = z.infer;