mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +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:
@@ -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).
|
- 🏁 **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 — 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.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.
|
- Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0→R5.
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|
||||||
@@ -118,6 +118,7 @@ model Asset {
|
|||||||
loadKg Int?
|
loadKg Int?
|
||||||
floors Int?
|
floors Int?
|
||||||
status AssetStatus @default(IN_SERVICE) // statut d'ÉQUIPEMENT ≠ statut d'OT
|
status AssetStatus @default(IN_SERVICE) // statut d'ÉQUIPEMENT ≠ statut d'OT
|
||||||
|
underContract Boolean @default(true) // contrat préventif (grille mensuelle)
|
||||||
categoryId String @db.Uuid
|
categoryId String @db.Uuid
|
||||||
category Category @relation(fields: [categoryId], references: [id])
|
category Category @relation(fields: [categoryId], references: [id])
|
||||||
locationId String @db.Uuid
|
locationId String @db.Uuid
|
||||||
@@ -224,9 +225,13 @@ model WorkOrder {
|
|||||||
checklist ChecklistItem[]
|
checklist ChecklistItem[]
|
||||||
report InterventionReport?
|
report InterventionReport?
|
||||||
request Request?
|
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())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([assetId, periodKey])
|
||||||
@@index([assetId])
|
@@index([assetId])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { demoModeEnabled } from './config/env';
|
|||||||
import { FilesModule } from './files/files.module';
|
import { FilesModule } from './files/files.module';
|
||||||
import { HealthModule } from './health/health.module';
|
import { HealthModule } from './health/health.module';
|
||||||
import { LocationsModule } from './locations/locations.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 { ReferenceValuesModule } from './reference-values/reference-values.module';
|
||||||
import { RequestsModule } from './requests/requests.module';
|
import { RequestsModule } from './requests/requests.module';
|
||||||
import { TeamsModule } from './teams/teams.module';
|
import { TeamsModule } from './teams/teams.module';
|
||||||
@@ -44,6 +46,8 @@ export class AppModule {
|
|||||||
WorkOrdersModule,
|
WorkOrdersModule,
|
||||||
RequestsModule,
|
RequestsModule,
|
||||||
ReferenceValuesModule,
|
ReferenceValuesModule,
|
||||||
|
PreventiveModule,
|
||||||
|
MetersModule,
|
||||||
// ADR-002 : hors DEMO_MODE, le module n'est pas enregistré → 404
|
// ADR-002 : hors DEMO_MODE, le module n'est pas enregistré → 404
|
||||||
...(demoModeEnabled() ? [DemoAuthModule] : []),
|
...(demoModeEnabled() ? [DemoAuthModule] : []),
|
||||||
],
|
],
|
||||||
|
|||||||
40
apps/api/src/meters/meters.controller.ts
Normal file
40
apps/api/src/meters/meters.controller.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/api/src/meters/meters.module.ts
Normal file
9
apps/api/src/meters/meters.module.ts
Normal file
@@ -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 {}
|
||||||
77
apps/api/src/meters/meters.service.ts
Normal file
77
apps/api/src/meters/meters.service.ts
Normal file
@@ -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<MetersResponse> {
|
||||||
|
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<MetersResponse> {
|
||||||
|
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<void> {
|
||||||
|
const asset = await this.prisma.asset.findUnique({ where: { id: assetId } });
|
||||||
|
if (!asset) throw new NotFoundException('Appareil inconnu');
|
||||||
|
}
|
||||||
|
}
|
||||||
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
189
apps/api/test/preventif.e2e-spec.ts
Normal file
189
apps/api/test/preventif.e2e-spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
370
apps/web/src/api/schema.d.ts
vendored
370
apps/web/src/api/schema.d.ts
vendored
@@ -355,6 +355,109 @@ export interface paths {
|
|||||||
patch: operations["updateUser"];
|
patch: operations["updateUser"];
|
||||||
trace?: never;
|
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": {
|
"/work-orders": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -941,6 +1044,83 @@ export interface components {
|
|||||||
teamIds?: string[];
|
teamIds?: string[];
|
||||||
isActive?: boolean;
|
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: {
|
WorkOrdersResponse: {
|
||||||
workOrders: {
|
workOrders: {
|
||||||
/** Format: uuid */
|
/** 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: {
|
listWorkOrders: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -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
|
## 2026-07-16 — Pr. Daaif (+ Claude) — R2.1 : socle backend de l'exploitation
|
||||||
|
|
||||||
**Actions**
|
**Actions**
|
||||||
|
|||||||
@@ -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": {
|
"/work-orders": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "listWorkOrders",
|
"operationId": "listWorkOrders",
|
||||||
@@ -3308,6 +3573,396 @@
|
|||||||
},
|
},
|
||||||
"additionalProperties": false
|
"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": {
|
"WorkOrdersResponse": {
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|||||||
@@ -37,6 +37,17 @@ import {
|
|||||||
UsersResponseSchema,
|
UsersResponseSchema,
|
||||||
UserUpdateSchema,
|
UserUpdateSchema,
|
||||||
} from './schemas/users-admin';
|
} from './schemas/users-admin';
|
||||||
|
import {
|
||||||
|
MeterReadingCreateSchema,
|
||||||
|
MetersResponseSchema,
|
||||||
|
PreventiveGenerateSchema,
|
||||||
|
PreventiveGenerationResultSchema,
|
||||||
|
PreventiveStatusSchema,
|
||||||
|
TaskTemplateCreateSchema,
|
||||||
|
TaskTemplateSchema,
|
||||||
|
TaskTemplatesResponseSchema,
|
||||||
|
TaskTemplateUpdateSchema,
|
||||||
|
} from './schemas/preventif';
|
||||||
import {
|
import {
|
||||||
AssigneesUpdateSchema,
|
AssigneesUpdateSchema,
|
||||||
ChecklistItemSchema,
|
ChecklistItemSchema,
|
||||||
@@ -387,6 +398,93 @@ export const API_CONTRACT: ApiOperation[] = [
|
|||||||
404: { description: 'Inconnue' },
|
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 —————
|
// ————— R2 · Exploitation —————
|
||||||
{
|
{
|
||||||
operationId: 'listWorkOrders',
|
operationId: 'listWorkOrders',
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export * from './permissions';
|
|||||||
export * from './referentiel';
|
export * from './referentiel';
|
||||||
export * from './exploitation';
|
export * from './exploitation';
|
||||||
export * from './schemas/exploitation';
|
export * from './schemas/exploitation';
|
||||||
|
export * from './schemas/preventif';
|
||||||
export * from './schemas/auth';
|
export * from './schemas/auth';
|
||||||
export * from './schemas/users';
|
export * from './schemas/users';
|
||||||
export * from './schemas/users-admin';
|
export * from './schemas/users-admin';
|
||||||
|
|||||||
92
packages/shared/src/schemas/preventif.ts
Normal file
92
packages/shared/src/schemas/preventif.ts
Normal 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>;
|
||||||
Reference in New Issue
Block a user