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

@@ -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);
});
});