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

@@ -37,6 +37,26 @@ import {
UsersResponseSchema,
UserUpdateSchema,
} from './schemas/users-admin';
import {
AssigneesUpdateSchema,
ChecklistItemSchema,
ChecklistPatchSchema,
CommentCreateSchema,
ReferenceValueCreateSchema,
ReferenceValueSchema,
ReferenceValuesResponseSchema,
ReferenceValueUpdateSchema,
ReportUpsertSchema,
RequestApproveSchema,
RequestCreateSchema,
RequestRejectSchema,
RequestsResponseSchema,
RequestSummarySchema,
TransitionRequestSchema,
WorkOrderCreateSchema,
WorkOrderDetailSchema,
WorkOrdersResponseSchema,
} from './schemas/exploitation';
/**
* Contrat d'API R0 — source unique de vérité (règle d'or ADR-001).
@@ -367,6 +387,189 @@ export const API_CONTRACT: ApiOperation[] = [
404: { description: 'Inconnue' },
},
},
// ————— R2 · Exploitation —————
{
operationId: 'listWorkOrders',
method: 'get',
path: '/work-orders',
summary: 'Ordres de travail (sans « voir autre » : seulement les siens)',
tags: ['work-orders'],
responses: {
200: { description: 'Liste', name: 'WorkOrdersResponse', schema: WorkOrdersResponseSchema },
},
},
{
operationId: 'getWorkOrder',
method: 'get',
path: '/work-orders/{id}',
summary: 'Fiche OT (activité, checklist, bilan, transitions autorisées, blocages de clôture)',
tags: ['work-orders'],
pathParams: ['id'],
responses: {
200: { description: 'Fiche', name: 'WorkOrderDetail', schema: WorkOrderDetailSchema },
404: { description: 'Inconnu (ou hors de son périmètre)' },
},
},
{
operationId: 'createWorkOrder',
method: 'post',
path: '/work-orders',
summary: 'Créer un OT (statut Ouvert)',
tags: ['work-orders'],
request: { name: 'WorkOrderCreate', schema: WorkOrderCreateSchema },
responses: {
201: { description: 'Créé', name: 'WorkOrderDetail', schema: WorkOrderDetailSchema },
},
},
{
operationId: 'transitionWorkOrder',
method: 'post',
path: '/work-orders/{id}/transition',
summary: 'Changer létat (machine à états stricte ; DONE exige bilan + checklist)',
tags: ['work-orders'],
pathParams: ['id'],
request: { name: 'TransitionRequest', schema: TransitionRequestSchema },
responses: {
200: { description: 'État changé', name: 'WorkOrderDetail', schema: WorkOrderDetailSchema },
409: { description: 'Transition interdite ou clôture bloquée (garde)' },
},
},
{
operationId: 'commentWorkOrder',
method: 'post',
path: '/work-orders/{id}/comments',
summary: 'Commenter (activité chronologique)',
tags: ['work-orders'],
pathParams: ['id'],
request: { name: 'CommentCreate', schema: CommentCreateSchema },
responses: {
201: { description: 'Commentaire ajouté', name: 'WorkOrderDetail', schema: WorkOrderDetailSchema },
},
},
{
operationId: 'setWorkOrderAssignees',
method: 'put',
path: '/work-orders/{id}/assignees',
summary: 'Assigner (remplace la liste)',
tags: ['work-orders'],
pathParams: ['id'],
request: { name: 'AssigneesUpdate', schema: AssigneesUpdateSchema },
responses: {
200: { description: 'Assignés', name: 'WorkOrderDetail', schema: WorkOrderDetailSchema },
},
},
{
operationId: 'upsertWorkOrderReport',
method: 'put',
path: '/work-orders/{id}/report',
summary: 'Renseigner le bilan codé (null efface un champ)',
tags: ['work-orders'],
pathParams: ['id'],
request: { name: 'ReportUpsert', schema: ReportUpsertSchema },
responses: {
200: { description: 'Bilan enregistré', name: 'WorkOrderDetail', schema: WorkOrderDetailSchema },
400: { description: 'Valeur hors référentiel du champ' },
},
},
{
operationId: 'patchChecklistItem',
method: 'patch',
path: '/work-orders/{id}/checklist/{itemId}',
summary: 'Régler une tâche de la grille (Fait / N-A / à faire)',
tags: ['work-orders'],
pathParams: ['id', 'itemId'],
request: { name: 'ChecklistPatch', schema: ChecklistPatchSchema },
responses: {
200: { description: 'Tâche réglée', name: 'ChecklistItem', schema: ChecklistItemSchema },
404: { description: 'Tâche inconnue' },
},
},
{
operationId: 'listRequests',
method: 'get',
path: '/requests',
summary: 'Demandes (sans « voir autre » : seulement les siennes)',
tags: ['requests'],
responses: {
200: { description: 'Liste', name: 'RequestsResponse', schema: RequestsResponseSchema },
},
},
{
operationId: 'createRequest',
method: 'post',
path: '/requests',
summary: 'Signaler (interne — le portail public QR arrive en R2.4)',
tags: ['requests'],
request: { name: 'RequestCreate', schema: RequestCreateSchema },
responses: {
201: { description: 'Demande créée', name: 'RequestSummary', schema: RequestSummarySchema },
},
},
{
operationId: 'approveRequest',
method: 'post',
path: '/requests/{id}/approve',
summary: 'Approuver → crée lOT lié (1-1, jamais de doublon)',
tags: ['requests'],
pathParams: ['id'],
request: { name: 'RequestApprove', schema: RequestApproveSchema },
responses: {
201: { description: 'OT créé et lié', name: 'WorkOrderDetail', schema: WorkOrderDetailSchema },
409: { description: 'Demande déjà traitée' },
},
},
{
operationId: 'rejectRequest',
method: 'post',
path: '/requests/{id}/reject',
summary: 'Rejeter — motif obligatoire, lisible côté demandeur',
tags: ['requests'],
pathParams: ['id'],
request: { name: 'RequestReject', schema: RequestRejectSchema },
responses: {
200: { description: 'Rejetée', name: 'RequestSummary', schema: RequestSummarySchema },
409: { description: 'Demande déjà traitée' },
},
},
{
operationId: 'listReferenceValues',
method: 'get',
path: '/reference-values',
summary: 'Référentiels du bilan codé (6 champs)',
tags: ['reference-values'],
responses: {
200: {
description: 'Liste',
name: 'ReferenceValuesResponse',
schema: ReferenceValuesResponseSchema,
},
},
},
{
operationId: 'createReferenceValue',
method: 'post',
path: '/reference-values',
summary: 'Ajouter une valeur de référentiel',
tags: ['reference-values'],
request: { name: 'ReferenceValueCreate', schema: ReferenceValueCreateSchema },
responses: {
201: { description: 'Créée', name: 'ReferenceValue', schema: ReferenceValueSchema },
409: { description: 'Libellé déjà présent pour ce champ' },
},
},
{
operationId: 'updateReferenceValue',
method: 'patch',
path: '/reference-values/{id}',
summary: 'Renommer ou (dés)activer — jamais de suppression si utilisée',
tags: ['reference-values'],
pathParams: ['id'],
request: { name: 'ReferenceValueUpdate', schema: ReferenceValueUpdateSchema },
responses: {
200: { description: 'Mise à jour', name: 'ReferenceValue', schema: ReferenceValueSchema },
404: { description: 'Inconnue' },
},
},
{
operationId: 'getHealth',
method: 'get',

View File

@@ -0,0 +1,93 @@
/** Vocabulaires de l'exploitation (R2) — partagés API / web / seed. */
export const WORK_ORDER_TYPES = ['CORRECTIVE', 'PREVENTIVE', 'WORKS'] as const;
export type WorkOrderType = (typeof WORK_ORDER_TYPES)[number];
export const WORK_ORDER_TYPE_LABELS: Record<WorkOrderType, string> = {
CORRECTIVE: 'Dépannage',
PREVENTIVE: 'Maintenance',
WORKS: 'Travaux',
};
export const WORK_ORDER_STATUSES = [
'OPEN',
'IN_PROGRESS',
'ON_HOLD',
'DONE',
'CANCELLED',
] as const;
export type WorkOrderStatus = (typeof WORK_ORDER_STATUSES)[number];
export const WORK_ORDER_STATUS_LABELS: Record<WorkOrderStatus, string> = {
OPEN: 'Ouvert',
IN_PROGRESS: 'En cours',
ON_HOLD: 'En attente',
DONE: 'Terminé',
CANCELLED: 'Annulé',
};
/** Machine à états STRICTE — la table est la loi, côté API comme côté UI. */
export const WORK_ORDER_TRANSITIONS: Record<WorkOrderStatus, WorkOrderStatus[]> = {
OPEN: ['IN_PROGRESS', 'CANCELLED'],
IN_PROGRESS: ['ON_HOLD', 'DONE', 'CANCELLED'],
ON_HOLD: ['IN_PROGRESS', 'CANCELLED'],
DONE: [],
CANCELLED: [],
};
export const WORK_ORDER_PRIORITIES = [
'NONE',
'LOW',
'MEDIUM',
'HIGH',
'PERSON_TRAPPED',
] as const;
export type WorkOrderPriority = (typeof WORK_ORDER_PRIORITIES)[number];
export const WORK_ORDER_PRIORITY_LABELS: Record<WorkOrderPriority, string> = {
NONE: 'Aucune',
LOW: 'Basse',
MEDIUM: 'Moyenne',
HIGH: 'Haute',
PERSON_TRAPPED: 'Personne bloquée',
};
export const REQUEST_STATUSES = ['RECEIVED', 'APPROVED', 'REJECTED'] as const;
export type RequestStatus = (typeof REQUEST_STATUSES)[number];
export const REQUEST_STATUS_LABELS: Record<RequestStatus, string> = {
RECEIVED: 'Reçue',
APPROVED: 'Approuvée',
REJECTED: 'Rejetée',
};
export const CHECKLIST_STATES = ['PENDING', 'DONE', 'NA'] as const;
export type ChecklistState = (typeof CHECKLIST_STATES)[number];
export const BILAN_FIELDS = [
'DOOR_STATE',
'CABIN_POSITION',
'ANOMALY',
'EXTERNAL_CAUSE',
'ACTION_TAKEN',
'COMPONENT_CONCERNED',
] as const;
export type BilanField = (typeof BILAN_FIELDS)[number];
export const BILAN_FIELD_LABELS: Record<BilanField, string> = {
DOOR_STATE: 'État des portes',
CABIN_POSITION: 'Position cabine',
ANOMALY: 'Anomalie constatée',
EXTERNAL_CAUSE: 'Cause extérieure',
ACTION_TAKEN: 'Action réalisée',
COMPONENT_CONCERNED: 'Élément concerné',
};
/** Champs du bilan REQUIS pour clôturer (maquette fiche OT validée R0). */
export const REQUIRED_BILAN_FIELDS: readonly BilanField[] = [
'DOOR_STATE',
'ACTION_TAKEN',
'COMPONENT_CONCERNED',
];
export const METER_KINDS = ['RUNNING_HOURS', 'STARTS'] as const;
export type MeterKind = (typeof METER_KINDS)[number];
export const METER_KIND_LABELS: Record<MeterKind, string> = {
RUNNING_HOURS: 'Heures de marche',
STARTS: 'Démarrages',
};

View File

@@ -1,5 +1,7 @@
export * from './permissions';
export * from './referentiel';
export * from './exploitation';
export * from './schemas/exploitation';
export * from './schemas/auth';
export * from './schemas/users';
export * from './schemas/users-admin';

View File

@@ -0,0 +1,209 @@
import { z } from 'zod';
import {
BILAN_FIELDS,
CHECKLIST_STATES,
REQUEST_STATUSES,
WORK_ORDER_PRIORITIES,
WORK_ORDER_STATUSES,
WORK_ORDER_TYPES,
} from '../exploitation';
const PersonneSchema = z.object({
id: z.uuid(),
displayName: z.string(),
initials: z.string(),
});
// ————— Ordres de travail —————
export const WorkOrderSummarySchema = z.object({
id: z.uuid(),
reference: z.string(),
title: z.string(),
type: z.enum(WORK_ORDER_TYPES),
status: z.enum(WORK_ORDER_STATUSES),
priority: z.enum(WORK_ORDER_PRIORITIES),
assetId: z.uuid(),
assetReference: z.string(),
siteName: z.string(),
dueDate: z.iso.datetime().nullable(),
assignees: z.array(PersonneSchema),
createdAt: z.iso.datetime(),
});
export type WorkOrderSummary = z.infer<typeof WorkOrderSummarySchema>;
export const WorkOrdersResponseSchema = z.object({
workOrders: z.array(WorkOrderSummarySchema),
});
export type WorkOrdersResponse = z.infer<typeof WorkOrdersResponseSchema>;
export const WorkOrderEventSchema = z.object({
id: z.uuid(),
kind: z.string(),
message: z.string().nullable(),
by: PersonneSchema.nullable(),
createdAt: z.iso.datetime(),
});
export const ChecklistItemSchema = z.object({
id: z.uuid(),
label: z.string(),
state: z.enum(CHECKLIST_STATES),
doneBy: PersonneSchema.nullable(),
doneAt: z.iso.datetime().nullable(),
});
export type ChecklistItemDto = z.infer<typeof ChecklistItemSchema>;
const BilanValueSchema = z.object({ id: z.uuid(), label: z.string() }).nullable();
export const InterventionReportSchema = z.object({
note: z.string().nullable(),
doorState: BilanValueSchema,
cabinPosition: BilanValueSchema,
anomaly: BilanValueSchema,
externalCause: BilanValueSchema,
actionTaken: BilanValueSchema,
componentConcerned: BilanValueSchema,
});
export type InterventionReportDto = z.infer<typeof InterventionReportSchema>;
export const WorkOrderDetailSchema = WorkOrderSummarySchema.extend({
description: z.string().nullable(),
locationName: z.string(),
startedAt: z.iso.datetime().nullable(),
completedAt: z.iso.datetime().nullable(),
cancelledAt: z.iso.datetime().nullable(),
createdBy: PersonneSchema.nullable(),
request: z
.object({ id: z.uuid(), reference: z.string(), requesterLabel: z.string() })
.nullable(),
events: z.array(WorkOrderEventSchema),
checklist: z.array(ChecklistItemSchema),
report: InterventionReportSchema.nullable(),
/** Ce que la machine à états autorise depuis l'état courant. */
allowedTransitions: z.array(z.enum(WORK_ORDER_STATUSES)),
/** Ce qui bloque la clôture (vide = clôturable) — messages métier. */
closureBlockers: z.array(z.string()),
});
export type WorkOrderDetail = z.infer<typeof WorkOrderDetailSchema>;
export const WorkOrderCreateSchema = z.object({
title: z.string().min(1).max(200),
description: z.string().max(2000).optional(),
type: z.enum(WORK_ORDER_TYPES),
priority: z.enum(WORK_ORDER_PRIORITIES).optional(),
assetId: z.uuid(),
dueDate: z.iso.datetime().optional(),
assigneeIds: z.array(z.uuid()).optional(),
});
export type WorkOrderCreate = z.infer<typeof WorkOrderCreateSchema>;
export const TransitionRequestSchema = z.object({
to: z.enum(WORK_ORDER_STATUSES),
comment: z.string().max(500).optional(),
});
export type TransitionRequest = z.infer<typeof TransitionRequestSchema>;
export const CommentCreateSchema = z.object({
message: z.string().min(1).max(1000),
});
export type CommentCreate = z.infer<typeof CommentCreateSchema>;
export const AssigneesUpdateSchema = z.object({
assigneeIds: z.array(z.uuid()).max(10),
});
export type AssigneesUpdate = z.infer<typeof AssigneesUpdateSchema>;
/** Upsert du bilan — null efface un champ. */
export const ReportUpsertSchema = z.object({
note: z.string().max(2000).nullable().optional(),
doorStateId: z.uuid().nullable().optional(),
cabinPositionId: z.uuid().nullable().optional(),
anomalyId: z.uuid().nullable().optional(),
externalCauseId: z.uuid().nullable().optional(),
actionTakenId: z.uuid().nullable().optional(),
componentConcernedId: z.uuid().nullable().optional(),
});
export type ReportUpsert = z.infer<typeof ReportUpsertSchema>;
export const ChecklistPatchSchema = z.object({
state: z.enum(CHECKLIST_STATES),
});
export type ChecklistPatch = z.infer<typeof ChecklistPatchSchema>;
// ————— Demandes —————
export const RequestSummarySchema = z.object({
id: z.uuid(),
reference: z.string(),
description: z.string(),
isPersonTrapped: z.boolean(),
status: z.enum(REQUEST_STATUSES),
rejectionReason: z.string().nullable(),
assetId: z.uuid(),
assetReference: z.string(),
siteName: z.string(),
requesterLabel: z.string(),
workOrder: z
.object({
id: z.uuid(),
reference: z.string(),
status: z.enum(WORK_ORDER_STATUSES),
})
.nullable(),
createdAt: z.iso.datetime(),
});
export type RequestSummary = z.infer<typeof RequestSummarySchema>;
export const RequestsResponseSchema = z.object({
requests: z.array(RequestSummarySchema),
});
export type RequestsResponse = z.infer<typeof RequestsResponseSchema>;
export const RequestCreateSchema = z.object({
assetId: z.uuid(),
description: z.string().min(1).max(2000),
isPersonTrapped: z.boolean().optional(),
});
export type RequestCreate = z.infer<typeof RequestCreateSchema>;
export const RequestApproveSchema = z.object({
title: z.string().min(1).max(200).optional(), // défaut : description tronquée
priority: z.enum(WORK_ORDER_PRIORITIES).optional(),
assigneeIds: z.array(z.uuid()).optional(),
dueDate: z.iso.datetime().optional(),
});
export type RequestApprove = z.infer<typeof RequestApproveSchema>;
export const RequestRejectSchema = z.object({
reason: z.string().min(3, 'Le motif est requis').max(500),
});
export type RequestReject = z.infer<typeof RequestRejectSchema>;
// ————— Référentiels du bilan codé —————
export const ReferenceValueSchema = z.object({
id: z.uuid(),
field: z.enum(BILAN_FIELDS),
label: z.string(),
isActive: z.boolean(),
usageCount: z.number().int(),
});
export type ReferenceValueDto = z.infer<typeof ReferenceValueSchema>;
export const ReferenceValuesResponseSchema = z.object({
referenceValues: z.array(ReferenceValueSchema),
});
export type ReferenceValuesResponse = z.infer<typeof ReferenceValuesResponseSchema>;
export const ReferenceValueCreateSchema = z.object({
field: z.enum(BILAN_FIELDS),
label: z.string().min(1).max(120),
});
export type ReferenceValueCreate = z.infer<typeof ReferenceValueCreateSchema>;
export const ReferenceValueUpdateSchema = z.object({
label: z.string().min(1).max(120).optional(),
isActive: z.boolean().optional(),
});
export type ReferenceValueUpdate = z.infer<typeof ReferenceValueUpdateSchema>;