feat(backend): add responsable anomalies status cycle

This commit is contained in:
SaidSoighiri94
2026-07-17 10:06:39 +02:00
parent 9d8ae70063
commit 2772f4cfd4
5 changed files with 163 additions and 1 deletions
@@ -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<string, unknown> {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new AppError(400, 'Corps de requete invalide');
}
return value as Record<string, unknown>;
}
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<void> {
res.json({ data: anomalies.map(toAnomalieResponsableResponse) });
}
export async function patchAnomalieStatut(req: Request, res: Response): Promise<void> {
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<void> {
const user = req.user;
if (!user) {
@@ -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<AnomalieResponsable | null> {
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<AnomalieResponsable> {
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,
@@ -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);
@@ -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<StatutAnomalie, StatutAnomalie[]> = {
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<AnomalieResponsable> {
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,
});
}
+9 -1
View File
@@ -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.*