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 = { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 = { 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, }; } }