diff --git a/eme-backend/src/controllers/responsable.controller.ts b/eme-backend/src/controllers/responsable.controller.ts index 86af75c..d17707a 100644 --- a/eme-backend/src/controllers/responsable.controller.ts +++ b/eme-backend/src/controllers/responsable.controller.ts @@ -1,6 +1,7 @@ import { Request, Response } from 'express'; import { AppError } from '../errors/app-error'; import { + changerStatutAnomalieResponsableService, getDashboardResponsable, listerAnomaliesResponsable, listerHistoriqueResponsable, @@ -9,6 +10,7 @@ import { listerEmpruntsResponsable, marquerNotificationResponsableLue, marquerToutesNotificationsResponsableLues, + parseAnomalieId, parseCategorieId, parseLu, parseNotificationId, @@ -27,6 +29,13 @@ import { toNotificationResponsableResponse, } from '../dtos/responsable.dto'; +function asBodyObject(value: unknown): Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new AppError(400, 'Corps de requete invalide'); + } + return value as Record; +} + function getHistoriqueFiltres(req: Request) { const action = typeof req.query.action === 'string' ? req.query.action : undefined; return { @@ -102,6 +111,30 @@ export async function getAnomalies(req: Request, res: Response): Promise { res.json({ data: anomalies.map(toAnomalieResponsableResponse) }); } +export async function patchAnomalieStatut(req: Request, res: Response): Promise { + const user = req.user; + if (!user) { + throw new AppError(401, 'Authentification requise'); + } + + const anomalieId = parseAnomalieId(req.params.id); + const body = asBodyObject(req.body); + const statut = parseStatutAnomalie(body.statut); + if (!statut) { + throw new AppError(400, 'statut requis'); + } + const observation = typeof body.observation === 'string' ? body.observation : undefined; + + const anomalie = await changerStatutAnomalieResponsableService( + user.id, + user.roleCode, + user.campusId, + anomalieId, + { statut, observation }, + ); + res.json({ data: toAnomalieResponsableResponse(anomalie) }); +} + export async function getNotifications(req: Request, res: Response): Promise { const user = req.user; if (!user) { diff --git a/eme-backend/src/repositories/responsable.repository.ts b/eme-backend/src/repositories/responsable.repository.ts index 2b7824c..b0ea2a3 100644 --- a/eme-backend/src/repositories/responsable.repository.ts +++ b/eme-backend/src/repositories/responsable.repository.ts @@ -86,6 +86,14 @@ export interface HistoriqueResponsableFiltres { dateFin?: Date; } +export interface ChangerStatutAnomalieData { + anomalieId: number; + campusId: number; + responsableId: number; + statut: string; + observation?: string; +} + function mapStatutCounts(rows: Array<{ statut: string; _count: { _all: number } }>): StatutCount[] { return rows.map((row) => ({ statut: row.statut, count: row._count._all })); } @@ -208,6 +216,62 @@ export function findAnomaliesResponsable( }); } +export function findAnomalieResponsableById( + campusId: number, + anomalieId: number, +): Promise { + return prisma.anomalie.findFirst({ + where: { + id: anomalieId, + emprunt: { campusId }, + }, + include: { + emprunt: true, + etudiant: true, + materiel: { include: { categorie: true } }, + traitePar: true, + }, + }); +} + +export function changerStatutAnomalieResponsable( + data: ChangerStatutAnomalieData, +): Promise { + return prisma.$transaction(async (tx) => { + const anomalie = await tx.anomalie.update({ + where: { id: data.anomalieId }, + data: { + statut: data.statut, + traiteeParId: data.responsableId, + ...(data.observation !== undefined ? { observation: data.observation } : {}), + ...(data.statut === 'RESOLUE' || data.statut === 'CLOTUREE' + ? { dateResolution: new Date() } + : {}), + }, + include: { + emprunt: true, + etudiant: true, + materiel: { include: { categorie: true } }, + traitePar: true, + }, + }); + + await tx.historique.create({ + data: { + utilisateurId: data.responsableId, + empruntId: anomalie.empruntId, + materielId: anomalie.materielId, + campusId: data.campusId, + action: 'TRAITEMENT_ANOMALIE', + description: `Anomalie #${anomalie.id} passee au statut ${data.statut}`, + dateAction: new Date(), + }, + }); + + return anomalie; + }); +} + export function findNotificationsResponsable( utilisateurId: number, filtres: NotificationResponsableFiltres, diff --git a/eme-backend/src/routes/responsable.routes.ts b/eme-backend/src/routes/responsable.routes.ts index 2a31e3b..e22f5c2 100644 --- a/eme-backend/src/routes/responsable.routes.ts +++ b/eme-backend/src/routes/responsable.routes.ts @@ -7,6 +7,7 @@ import { getHistoriqueCsv, getMateriels, getNotifications, + patchAnomalieStatut, patchNotificationLue, patchNotificationsLues, } from '../controllers/responsable.controller'; @@ -17,6 +18,7 @@ responsableRoutes.get('/dashboard', getDashboard); responsableRoutes.get('/emprunts', getEmprunts); responsableRoutes.get('/materiels', getMateriels); responsableRoutes.get('/anomalies', getAnomalies); +responsableRoutes.patch('/anomalies/:id/statut', patchAnomalieStatut); responsableRoutes.get('/notifications', getNotifications); responsableRoutes.patch('/notifications/lu-toutes', patchNotificationsLues); responsableRoutes.patch('/notifications/:id/lu', patchNotificationLue); diff --git a/eme-backend/src/services/responsable.service.ts b/eme-backend/src/services/responsable.service.ts index d52ba20..4f3fa72 100644 --- a/eme-backend/src/services/responsable.service.ts +++ b/eme-backend/src/services/responsable.service.ts @@ -1,10 +1,12 @@ import { AppError } from '../errors/app-error'; import { + changerStatutAnomalieResponsable, countAnomaliesParStatut, countEmpruntsParStatut, countMaterielsParStatut, countNotificationsNonLues, findActiviteRecente, + findAnomalieResponsableById, findAnomaliesResponsable, findEmpruntsResponsable, findHistoriqueResponsable, @@ -65,6 +67,18 @@ export interface ListerHistoriqueResponsableFiltres { dateFin?: Date; } +export interface ChangerStatutAnomalieResponsableRequest { + statut: StatutAnomalie; + observation?: string; +} + +const TRANSITIONS_ANOMALIE: Record = { + DETECTEE: ['EN_COURS_TRAITEMENT'], + EN_COURS_TRAITEMENT: ['RESOLUE'], + RESOLUE: ['CLOTUREE'], + CLOTUREE: [], +}; + function verifierResponsable(roleCode: string): void { if (roleCode !== 'RESPONSABLE') { throw new AppError(403, 'Acces reserve au responsable materiel'); @@ -153,6 +167,19 @@ export function parseNotificationId(value: unknown): number { return parsed; } +export function parseAnomalieId(value: unknown): number { + if (typeof value !== 'string') { + throw new AppError(400, 'anomalieId invalide'); + } + + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new AppError(400, 'anomalieId invalide'); + } + + return parsed; +} + export function parsePositiveIntQuery(value: unknown, champ: string): number | undefined { if (value === undefined) { return undefined; @@ -264,3 +291,31 @@ export function listerHistoriqueResponsable( verifierResponsable(roleCode); return findHistoriqueResponsable(campusId, filtres); } + +export async function changerStatutAnomalieResponsableService( + responsableId: number, + roleCode: string, + campusId: number, + anomalieId: number, + data: ChangerStatutAnomalieResponsableRequest, +): Promise { + verifierResponsable(roleCode); + + const anomalie = await findAnomalieResponsableById(campusId, anomalieId); + if (!anomalie) { + throw new AppError(404, 'Anomalie introuvable'); + } + + const statutActuel = anomalie.statut as StatutAnomalie; + if (!TRANSITIONS_ANOMALIE[statutActuel]?.includes(data.statut)) { + throw new AppError(400, `Transition invalide depuis ${anomalie.statut} vers ${data.statut}`); + } + + return changerStatutAnomalieResponsable({ + anomalieId, + campusId, + responsableId, + statut: data.statut, + observation: data.observation, + }); +} diff --git a/review.md b/review.md index 25b0fcf..d94c1fc 100644 --- a/review.md +++ b/review.md @@ -373,6 +373,14 @@ En terminal interactif (VS Code), `migrate dev` fonctionne normalement (taper `y - Endpoint `GET /api/responsable/historique/export.csv` ajouté avec les mêmes filtres. - Export CSV généré côté API avec les colonnes principales : date, action, description, utilisateur, email, matériel, emprunt. +### Étape 40 — API responsable : cycle de statut des anomalies +- Branche dédiée `feat/responsable-anomalies-cycle-api` créée après merge de l'historique responsable dans `develop`. +- Endpoint `PATCH /api/responsable/anomalies/:id/statut` ajouté, protégé par rôle `RESPONSABLE`. +- Filtrage par campus appliqué avant toute modification pour empêcher le traitement d'une anomalie hors campus (RG27). +- Transitions autorisées : `DETECTEE -> EN_COURS_TRAITEMENT -> RESOLUE -> CLOTUREE`. +- Le responsable connecté est enregistré dans `traiteeParId`; une observation optionnelle peut être conservée. +- Une entrée d'historique `TRAITEMENT_ANOMALIE` est créée à chaque transition. + --- -*Dernière mise à jour : 2026-07-16 — Bloc API responsable complété côté consultation, notifications et historique/export.* +*Dernière mise à jour : 2026-07-16 — Bloc API responsable complété côté consultation, notifications, historique/export et cycle anomalies.*