feat(r2.1): socle backend exploitation — machine à états, bilan codé, demandes 1-1

- migration r2_exploitation (10 tables) : WorkOrder (référence séquentielle,
  horodatages), WorkOrderEvent, Request (1-1, motif de rejet), ReferenceValue,
  InterventionReport (6 FK), TaskTemplate/ChecklistItem, Meter/MeterReading
- contrat : 15 opérations (41 total) ; la table des transitions et les champs
  requis du bilan vivent dans @siop/shared ; la fiche OT expose
  allowedTransitions + closureBlockers (messages métier)
- API : machine à états stricte ; garde de clôture (bilan 3 champs requis +
  checklist sans tâche en attente) ; approbation → OT lié 1-1 (409 si déjà
  traitée) ; rejet à motif obligatoire ; scoping « voir autre » sur listes et
  accès directs (404 sans fuite) ; validation des valeurs de bilan par champ ;
  « personne bloquée » triée en tête côté API
- seed : 31 valeurs de référentiels, 8 gabarits (parachute réglementaire),
  OT/demandes/compteurs de la maquette — idempotent
- 45 tests verts (95 % stmts / 79 % branches) dont la recette officielle
  rejouée de bout en bout ; smoke test sur build de prod

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pr-daaif
2026-07-16 13:56:07 +01:00
parent 54d926e9f4
commit f7702e4252
23 changed files with 5568 additions and 4 deletions

View File

@@ -0,0 +1,170 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import type {
RequestApprove,
RequestCreate,
RequestReject,
RequestsResponse,
RequestSummary,
WorkOrderDetail,
} from '@siop/shared';
import type { AuthenticatedUser } from '../auth/current-user.decorator';
import { PermissionsService } from '../permissions/permissions.service';
import { PrismaService } from '../prisma/prisma.service';
import { WorkOrdersService } from '../work-orders/work-orders.service';
const requestInclude = {
asset: { include: { location: { include: { parent: true } } } },
requestedBy: true,
workOrder: true,
} satisfies Prisma.RequestInclude;
type RequestRow = Prisma.RequestGetPayload<{ include: typeof requestInclude }>;
@Injectable()
export class RequestsService {
constructor(
private readonly prisma: PrismaService,
private readonly permissions: PermissionsService,
private readonly workOrders: WorkOrdersService,
) {}
private async scope(user: AuthenticatedUser): Promise<Prisma.RequestWhereInput> {
const viewOther = await this.permissions.can(user.roleId, 'REQUESTS', 'viewOther');
return viewOther ? {} : { requestedById: user.userId };
}
async list(user: AuthenticatedUser): Promise<RequestsResponse> {
const rows = await this.prisma.request.findMany({
where: await this.scope(user),
include: requestInclude,
orderBy: { createdAt: 'desc' },
});
rows.sort((a, b) => Number(b.isPersonTrapped) - Number(a.isPersonTrapped));
return { requests: rows.map((r) => this.toDto(r)) };
}
async create(dto: RequestCreate, user: AuthenticatedUser): Promise<RequestSummary> {
const asset = await this.prisma.asset.findUnique({ where: { id: dto.assetId } });
if (!asset) throw new BadRequestException('Équipement inconnu');
for (let essai = 0; ; essai++) {
try {
const created = await this.prisma.request.create({
data: {
reference: await this.nextReference(),
description: dto.description,
isPersonTrapped: dto.isPersonTrapped ?? false,
assetId: dto.assetId,
requestedById: user.userId,
},
include: requestInclude,
});
return this.toDto(created);
} catch (e) {
if (
e instanceof Prisma.PrismaClientKnownRequestError &&
e.code === 'P2002' &&
essai < 3
) {
continue;
}
throw e;
}
}
}
/** Approuver = créer l'OT lié (1-1). Une demande ne se traite qu'une fois. */
async approve(
id: string,
dto: RequestApprove,
user: AuthenticatedUser,
): Promise<WorkOrderDetail> {
const request = await this.prisma.request.findUnique({
where: { id },
include: requestInclude,
});
if (!request) throw new NotFoundException('Demande inconnue');
if (request.status !== 'RECEIVED') {
throw new ConflictException('Cette demande a déjà été traitée');
}
const created = await this.workOrders.createRaw({
title: dto.title ?? request.description.slice(0, 120),
description: `${request.description}\n\n(Demande ${request.reference}${this.requesterLabel(request)})`,
type: 'CORRECTIVE',
priority: dto.priority ?? (request.isPersonTrapped ? 'PERSON_TRAPPED' : 'MEDIUM'),
assetId: request.assetId,
dueDate: dto.dueDate,
assigneeIds: dto.assigneeIds,
createdById: user.userId,
eventKind: 'FROM_REQUEST',
eventMessage: `OT créé depuis la demande ${request.reference}`,
});
await this.prisma.request.update({
where: { id },
data: { status: 'APPROVED', workOrderId: created.id },
});
return this.workOrders.get(created.id, user);
}
async reject(
id: string,
dto: RequestReject,
user: AuthenticatedUser,
): Promise<RequestSummary> {
void user;
const request = await this.prisma.request.findUnique({ where: { id } });
if (!request) throw new NotFoundException('Demande inconnue');
if (request.status !== 'RECEIVED') {
throw new ConflictException('Cette demande a déjà été traitée');
}
const updated = await this.prisma.request.update({
where: { id },
data: { status: 'REJECTED', rejectionReason: dto.reason },
include: requestInclude,
});
return this.toDto(updated);
}
private async nextReference(): Promise<string> {
const annee = new Date().getFullYear();
const dernier = await this.prisma.request.findFirst({
where: { reference: { startsWith: `DEM-${annee}-` } },
orderBy: { reference: 'desc' },
select: { reference: true },
});
const n = dernier ? Number(dernier.reference.split('-')[2]) + 1 : 1;
return `DEM-${annee}-${String(n).padStart(4, '0')}`;
}
private requesterLabel(row: RequestRow): string {
return row.requestedBy?.displayName ?? row.requesterName ?? 'Portail';
}
private toDto(row: RequestRow): RequestSummary {
return {
id: row.id,
reference: row.reference,
description: row.description,
isPersonTrapped: row.isPersonTrapped,
status: row.status,
rejectionReason: row.rejectionReason,
assetId: row.assetId,
assetReference: row.asset.reference,
siteName: row.asset.location.parent?.name ?? row.asset.location.name,
requesterLabel: this.requesterLabel(row),
workOrder: row.workOrder
? {
id: row.workOrder.id,
reference: row.workOrder.reference,
status: row.workOrder.status,
}
: null,
createdAt: row.createdAt.toISOString(),
};
}
}