mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 20:51:53 +00:00
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:
69
apps/api/src/preventive/preventive.controller.ts
Normal file
69
apps/api/src/preventive/preventive.controller.ts
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
9
apps/api/src/preventive/preventive.module.ts
Normal file
9
apps/api/src/preventive/preventive.module.ts
Normal file
@@ -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 {}
|
||||
221
apps/api/src/preventive/preventive.service.ts
Normal file
221
apps/api/src/preventive/preventive.service.ts
Normal file
@@ -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<TaskTemplatesResponse> {
|
||||
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<TaskTemplateDto> {
|
||||
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<TaskTemplateDto> {
|
||||
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<PreventiveGenerationResult> {
|
||||
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<PreventiveStatus> {
|
||||
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<string> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user