mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
Anomalie de recette (référent) : OT assigné à Ahmed, traité, clôturé — visible dans son tableau de bord mais pas dans celui de Salma ni de l'admin. Cause : liste triée par statut (Terminés en queue) alors que le tableau de bord prend les 5 premières lignes ; les listes scopées (technicien) sont courtes, celles des rôles « voir autre » non. - tri par dernière activité (updatedAt desc) : un OT fraîchement clôturé remonte en tête pour tous - urgences « personne bloquée » ACTIVES épinglées au sommet (une urgence annulée ne squatte plus la tête — corrigé au passage) - test de régression dans la recette e2e (position ≤ urgences actives) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
438 lines
14 KiB
TypeScript
438 lines
14 KiB
TypeScript
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,
|
|
},
|
|
// Dernière activité d'abord : un OT qui vient d'être clôturé remonte
|
|
// (« Interventions récentes » du tableau de bord = tête de liste).
|
|
orderBy: { updatedAt: 'desc' },
|
|
});
|
|
// « personne bloquée » ACTIVE saute en tête, toujours (tri stable)
|
|
const urgenceActive = (r: (typeof rows)[number]) =>
|
|
r.priority === 'PERSON_TRAPPED' && r.status !== 'DONE' && r.status !== 'CANCELLED';
|
|
rows.sort((a, b) => Number(urgenceActive(b)) - Number(urgenceActive(a)));
|
|
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,
|
|
};
|
|
}
|
|
}
|