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,435 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import {
BILAN_FIELD_LABELS,
REQUIRED_BILAN_FIELDS,
WORK_ORDER_STATUS_LABELS,
WORK_ORDER_TRANSITIONS,
type AssigneesUpdate,
type BilanField,
type ChecklistItemDto,
type ChecklistPatch,
type ReportUpsert,
type TransitionRequest,
type WorkOrderCreate,
type WorkOrderDetail,
type WorkOrderStatus,
type WorkOrdersResponse,
type WorkOrderSummary,
} from '@siop/shared';
import type { AuthenticatedUser } from '../auth/current-user.decorator';
import { PermissionsService } from '../permissions/permissions.service';
import { PrismaService } from '../prisma/prisma.service';
const detailInclude = {
asset: { include: { location: { include: { parent: true } } } },
assignees: true,
createdBy: true,
request: { include: { requestedBy: true } },
events: { include: { by: true }, orderBy: { createdAt: 'desc' as const } },
checklist: { include: { doneBy: true }, orderBy: { label: 'asc' as const } },
report: {
include: {
doorState: true,
cabinPosition: true,
anomaly: true,
externalCause: true,
actionTaken: true,
componentConcerned: true,
},
},
} satisfies Prisma.WorkOrderInclude;
type DetailRow = Prisma.WorkOrderGetPayload<{ include: typeof detailInclude }>;
const initialsOf = (name: string) =>
name.split(/\s+/).filter(Boolean).slice(0, 2).map((w) => w[0]!.toUpperCase()).join('');
const toPersonne = (u: { id: string; displayName: string }) => ({
id: u.id,
displayName: u.displayName,
initials: initialsOf(u.displayName),
});
/** Champ du bilan → colonne du rapport (validation des référentiels). */
const REPORT_FIELDS: Record<string, BilanField> = {
doorStateId: 'DOOR_STATE',
cabinPositionId: 'CABIN_POSITION',
anomalyId: 'ANOMALY',
externalCauseId: 'EXTERNAL_CAUSE',
actionTakenId: 'ACTION_TAKEN',
componentConcernedId: 'COMPONENT_CONCERNED',
};
@Injectable()
export class WorkOrdersService {
constructor(
private readonly prisma: PrismaService,
private readonly permissions: PermissionsService,
) {}
/** Invariant « voir autre » : sans le droit, on ne voit que SES OT. */
private async scope(user: AuthenticatedUser): Promise<Prisma.WorkOrderWhereInput> {
const viewOther = await this.permissions.can(user.roleId, 'WORK_ORDERS', 'viewOther');
if (viewOther) return {};
return {
OR: [
{ assignees: { some: { id: user.userId } } },
{ createdById: user.userId },
],
};
}
async list(user: AuthenticatedUser): Promise<WorkOrdersResponse> {
const rows = await this.prisma.workOrder.findMany({
where: await this.scope(user),
include: {
asset: { include: { location: { include: { parent: true } } } },
assignees: true,
},
orderBy: [{ status: 'asc' }, { createdAt: 'desc' }],
});
// « personne bloquée » saute en tête, toujours
rows.sort((a, b) =>
Number(b.priority === 'PERSON_TRAPPED') - Number(a.priority === 'PERSON_TRAPPED'),
);
return { workOrders: rows.map((r) => this.toSummary(r)) };
}
async get(id: string, user: AuthenticatedUser): Promise<WorkOrderDetail> {
const row = await this.prisma.workOrder.findFirst({
where: { AND: [{ id }, await this.scope(user)] },
include: detailInclude,
});
if (!row) throw new NotFoundException('OT inconnu');
return this.toDetail(row);
}
async create(dto: WorkOrderCreate, user: AuthenticatedUser): Promise<WorkOrderDetail> {
const asset = await this.prisma.asset.findUnique({ where: { id: dto.assetId } });
if (!asset) throw new BadRequestException('Équipement inconnu');
const created = await this.createRaw({
...dto,
createdById: user.userId,
eventKind: 'CREATED',
eventMessage: 'OT créé',
});
return this.get(created.id, user);
}
/** Création partagée (OT manuel, approbation de demande, préventif R2.2). */
async createRaw(input: {
title: string;
description?: string;
type: WorkOrderCreate['type'];
priority?: WorkOrderCreate['priority'];
assetId: string;
dueDate?: string;
assigneeIds?: string[];
createdById?: string;
eventKind: string;
eventMessage: string;
}): Promise<{ id: string }> {
for (let essai = 0; ; essai++) {
try {
return await this.prisma.workOrder.create({
data: {
reference: await this.nextReference(),
title: input.title,
description: input.description,
type: input.type,
priority: input.priority ?? 'NONE',
assetId: input.assetId,
dueDate: input.dueDate ? new Date(input.dueDate) : undefined,
createdById: input.createdById,
assignees: input.assigneeIds?.length
? { connect: input.assigneeIds.map((id) => ({ id })) }
: undefined,
events: {
create: {
kind: input.eventKind,
message: input.eventMessage,
byId: input.createdById,
},
},
},
select: { id: true },
});
} catch (e) {
// collision de référence (concurrence) : on retente
if (
e instanceof Prisma.PrismaClientKnownRequestError &&
e.code === 'P2002' &&
essai < 3
) {
continue;
}
throw e;
}
}
}
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')}`;
}
/** Machine à états stricte + garde de clôture. */
async transition(
id: string,
dto: TransitionRequest,
user: AuthenticatedUser,
): Promise<WorkOrderDetail> {
const detail = await this.get(id, user);
if (!WORK_ORDER_TRANSITIONS[detail.status].includes(dto.to)) {
throw new ConflictException(
`Transition interdite : ${WORK_ORDER_STATUS_LABELS[detail.status]}${WORK_ORDER_STATUS_LABELS[dto.to]}`,
);
}
if (dto.to === 'DONE' && detail.closureBlockers.length > 0) {
throw new ConflictException(
`Clôture bloquée : ${detail.closureBlockers.join(' ; ')}`,
);
}
const horodatage: Prisma.WorkOrderUpdateInput =
dto.to === 'IN_PROGRESS' && !detail.startedAt
? { startedAt: new Date() }
: dto.to === 'DONE'
? { completedAt: new Date() }
: dto.to === 'CANCELLED'
? { cancelledAt: new Date() }
: {};
await this.prisma.workOrder.update({
where: { id },
data: {
status: dto.to,
...horodatage,
events: {
create: {
kind: 'STATUS_CHANGED',
message:
`${WORK_ORDER_STATUS_LABELS[detail.status]}${WORK_ORDER_STATUS_LABELS[dto.to]}` +
(dto.comment ? `${dto.comment}` : ''),
byId: user.userId,
},
},
},
});
return this.get(id, user);
}
async comment(
id: string,
message: string,
user: AuthenticatedUser,
): Promise<WorkOrderDetail> {
await this.get(id, user); // périmètre
await this.prisma.workOrderEvent.create({
data: { workOrderId: id, kind: 'COMMENT', message, byId: user.userId },
});
return this.get(id, user);
}
async setAssignees(
id: string,
dto: AssigneesUpdate,
user: AuthenticatedUser,
): Promise<WorkOrderDetail> {
await this.get(id, user);
const users = await this.prisma.user.findMany({
where: { id: { in: dto.assigneeIds } },
});
if (users.length !== new Set(dto.assigneeIds).size) {
throw new BadRequestException('Personne inconnue dans la liste');
}
await this.prisma.workOrder.update({
where: { id },
data: {
assignees: { set: dto.assigneeIds.map((assigneeId) => ({ id: assigneeId })) },
events: {
create: {
kind: 'ASSIGNED',
message: users.length
? `Assigné(s) : ${users.map((u) => u.displayName).join(', ')}`
: 'Assignation retirée',
byId: user.userId,
},
},
},
});
return this.get(id, user);
}
async upsertReport(
id: string,
dto: ReportUpsert,
user: AuthenticatedUser,
): Promise<WorkOrderDetail> {
await this.get(id, user);
// Chaque valeur fournie doit appartenir au référentiel de SON champ (et être active)
for (const [colonne, field] of Object.entries(REPORT_FIELDS)) {
const valeur = dto[colonne as keyof ReportUpsert];
if (typeof valeur === 'string') {
const ref = await this.prisma.referenceValue.findUnique({ where: { id: valeur } });
if (!ref || ref.field !== field || !ref.isActive) {
throw new BadRequestException(
`Valeur hors référentiel pour « ${BILAN_FIELD_LABELS[field]} »`,
);
}
}
}
const donnees = {
note: dto.note,
doorStateId: dto.doorStateId,
cabinPositionId: dto.cabinPositionId,
anomalyId: dto.anomalyId,
externalCauseId: dto.externalCauseId,
actionTakenId: dto.actionTakenId,
componentConcernedId: dto.componentConcernedId,
};
await this.prisma.interventionReport.upsert({
where: { workOrderId: id },
update: donnees,
create: { workOrderId: id, ...donnees },
});
return this.get(id, user);
}
async patchChecklist(
id: string,
itemId: string,
dto: ChecklistPatch,
user: AuthenticatedUser,
): Promise<ChecklistItemDto> {
await this.get(id, user);
const { count } = await this.prisma.checklistItem.updateMany({
where: { id: itemId, workOrderId: id },
data: {
state: dto.state,
doneById: dto.state === 'PENDING' ? null : user.userId,
doneAt: dto.state === 'PENDING' ? null : new Date(),
},
});
if (count === 0) throw new NotFoundException('Tâche inconnue');
const item = await this.prisma.checklistItem.findUniqueOrThrow({
where: { id: itemId },
include: { doneBy: true },
});
return {
id: item.id,
label: item.label,
state: item.state,
doneBy: item.doneBy ? toPersonne(item.doneBy) : null,
doneAt: item.doneAt?.toISOString() ?? null,
};
}
// ————— mapping —————
private toSummary(
row: Prisma.WorkOrderGetPayload<{
include: {
asset: { include: { location: { include: { parent: true } } } };
assignees: true;
};
}>,
): WorkOrderSummary {
return {
id: row.id,
reference: row.reference,
title: row.title,
type: row.type,
status: row.status,
priority: row.priority,
assetId: row.assetId,
assetReference: row.asset.reference,
siteName: row.asset.location.parent?.name ?? row.asset.location.name,
dueDate: row.dueDate?.toISOString() ?? null,
assignees: row.assignees.map(toPersonne),
createdAt: row.createdAt.toISOString(),
};
}
private toDetail(row: DetailRow): WorkOrderDetail {
const report = row.report;
const blockers: string[] = [];
if (row.checklist.some((c) => c.state === 'PENDING')) {
blockers.push('la checklist est incomplète : traitez chaque tâche (Fait ou N-A)');
}
const manquants = REQUIRED_BILAN_FIELDS.filter((field) => {
const parChamp: Record<BilanField, string | null | undefined> = {
DOOR_STATE: report?.doorStateId,
CABIN_POSITION: report?.cabinPositionId,
ANOMALY: report?.anomalyId,
EXTERNAL_CAUSE: report?.externalCauseId,
ACTION_TAKEN: report?.actionTakenId,
COMPONENT_CONCERNED: report?.componentConcernedId,
};
return !parChamp[field];
});
if (manquants.length) {
blockers.push(
`bilan incomplet : ${manquants.map((f) => `« ${BILAN_FIELD_LABELS[f]} »`).join(', ')}`,
);
}
const versValeur = (v: { id: string; label: string } | null) =>
v ? { id: v.id, label: v.label } : null;
return {
...this.toSummary(row),
description: row.description,
locationName: row.asset.location.name,
startedAt: row.startedAt?.toISOString() ?? null,
completedAt: row.completedAt?.toISOString() ?? null,
cancelledAt: row.cancelledAt?.toISOString() ?? null,
createdBy: row.createdBy ? toPersonne(row.createdBy) : null,
request: row.request
? {
id: row.request.id,
reference: row.request.reference,
requesterLabel:
row.request.requestedBy?.displayName ??
row.request.requesterName ??
'Portail',
}
: null,
events: row.events.map((e) => ({
id: e.id,
kind: e.kind,
message: e.message,
by: e.by ? toPersonne(e.by) : null,
createdAt: e.createdAt.toISOString(),
})),
checklist: row.checklist.map((c) => ({
id: c.id,
label: c.label,
state: c.state,
doneBy: c.doneBy ? toPersonne(c.doneBy) : null,
doneAt: c.doneAt?.toISOString() ?? null,
})),
report: report
? {
note: report.note,
doorState: versValeur(report.doorState),
cabinPosition: versValeur(report.cabinPosition),
anomaly: versValeur(report.anomaly),
externalCause: versValeur(report.externalCause),
actionTaken: versValeur(report.actionTaken),
componentConcerned: versValeur(report.componentConcerned),
}
: null,
allowedTransitions: WORK_ORDER_TRANSITIONS[row.status as WorkOrderStatus],
closureBlockers: blockers,
};
}
}