From be7d095fc3a55d1852811dab836af083aa57e37c Mon Sep 17 00:00:00 2001 From: SaidSoighiri94 Date: Mon, 6 Jul 2026 10:10:09 +0200 Subject: [PATCH] feat(backend): add emprunt restitution with automatic anomaly detection --- .../src/controllers/emprunt.controller.ts | 28 +++- eme-backend/src/dtos/emprunt.dto.ts | 28 ++++ .../src/repositories/emprunt.repository.ts | 117 +++++++++++++++- .../repositories/utilisateur.repository.ts | 7 + eme-backend/src/routes/emprunt.routes.ts | 3 +- eme-backend/src/services/emprunt.service.ts | 125 +++++++++++++++++- review.md | 10 +- 7 files changed, 306 insertions(+), 12 deletions(-) diff --git a/eme-backend/src/controllers/emprunt.controller.ts b/eme-backend/src/controllers/emprunt.controller.ts index c3f2d7f..4a92ef7 100644 --- a/eme-backend/src/controllers/emprunt.controller.ts +++ b/eme-backend/src/controllers/emprunt.controller.ts @@ -1,7 +1,15 @@ import { Request, Response } from 'express'; import { AppError } from '../errors/app-error'; -import { creerEmprunt, listerMesEmpruntsEnCours } from '../services/emprunt.service'; -import { parseCreerEmpruntRequest, toEmpruntResponse } from '../dtos/emprunt.dto'; +import { + creerEmprunt, + listerMesEmpruntsEnCours, + restituerEmprunt, +} from '../services/emprunt.service'; +import { + parseCreerEmpruntRequest, + parseRestituerEmpruntRequest, + toEmpruntResponse, +} from '../dtos/emprunt.dto'; export async function getMesEmprunts(req: Request, res: Response): Promise { const user = req.user; @@ -23,3 +31,19 @@ export async function postEmprunt(req: Request, res: Response): Promise { const emprunt = await creerEmprunt(user.id, user.campusId, request); res.status(201).json({ data: toEmpruntResponse(emprunt) }); } + +export async function postRestitution(req: Request, res: Response): Promise { + const user = req.user; + if (!user) { + throw new AppError(401, 'Authentification requise'); + } + + const empruntId = Number(req.params.id); + if (!Number.isInteger(empruntId) || empruntId <= 0) { + throw new AppError(400, 'Identifiant emprunt invalide'); + } + + const request = parseRestituerEmpruntRequest(req.body); + const emprunt = await restituerEmprunt(user.id, empruntId, request); + res.json({ data: toEmpruntResponse(emprunt) }); +} diff --git a/eme-backend/src/dtos/emprunt.dto.ts b/eme-backend/src/dtos/emprunt.dto.ts index c01822f..fd5ddae 100644 --- a/eme-backend/src/dtos/emprunt.dto.ts +++ b/eme-backend/src/dtos/emprunt.dto.ts @@ -116,3 +116,31 @@ export function parseCreerEmpruntRequest(body: unknown): CreerEmpruntRequest { return request; } + +export interface RestituerEmpruntRequest { + modeIdentification: string; + commentaireRetour?: string; + elements: ChecklistElementRequest[]; +} + +export function parseRestituerEmpruntRequest(body: unknown): RestituerEmpruntRequest { + const obj = asObject(body, 'Corps de requete invalide'); + + if (!Array.isArray(obj.elements) || obj.elements.length === 0) { + throw new AppError(400, 'La checklist de retour est obligatoire (RG16)'); + } + + const request: RestituerEmpruntRequest = { + modeIdentification: asEnum(obj.modeIdentification, MODE_IDENTIFICATION, 'modeIdentification'), + elements: obj.elements.map((element, index) => parseElement(element, index)), + }; + + if (obj.commentaireRetour !== undefined) { + if (typeof obj.commentaireRetour !== 'string') { + throw new AppError(400, 'commentaireRetour invalide'); + } + request.commentaireRetour = obj.commentaireRetour; + } + + return request; +} diff --git a/eme-backend/src/repositories/emprunt.repository.ts b/eme-backend/src/repositories/emprunt.repository.ts index 241a606..6f80ba7 100644 --- a/eme-backend/src/repositories/emprunt.repository.ts +++ b/eme-backend/src/repositories/emprunt.repository.ts @@ -7,6 +7,10 @@ export type EmpruntAvecMateriel = Prisma.EmpruntGetPayload<{ include: { materiel: { include: { categorie: true } } }; }>; +export type EmpruntAvecChecklists = Prisma.EmpruntGetPayload<{ + include: { checklists: { include: { elements: true } } }; +}>; + const STATUTS_EN_COURS: StatutEmprunt[] = ['EN_COURS', 'EN_RETARD']; export function findEnCoursParUtilisateur(utilisateurId: number): Promise { @@ -20,6 +24,21 @@ export function findEnCoursParUtilisateur(utilisateurId: number): Promise { + return prisma.emprunt.findUnique({ + where: { id }, + include: { checklists: { include: { elements: true } } }, + }); +} + +interface ChecklistElementData { + accessoireId?: number; + nomElement: string; + etat: string; + quantiteConstatee: number; + commentaire?: string; +} + export interface CreerEmpruntData { utilisateurId: number; materielId: number; @@ -29,13 +48,7 @@ export interface CreerEmpruntData { dateRetourPrevue: Date; modeIdentification: string; commentaireDepart?: string; - elements: { - accessoireId?: number; - nomElement: string; - etat: string; - quantiteConstatee: number; - commentaire?: string; - }[]; + elements: ChecklistElementData[]; } /* Création atomique : réservation du matériel (DISPONIBLE -> EMPRUNTE), emprunt, @@ -90,3 +103,93 @@ export function creerEmpruntComplet(data: CreerEmpruntData): Promise { + return prisma.$transaction(async (tx) => { + const maintenant = new Date(); + + await tx.checklist.create({ + data: { + empruntId: data.empruntId, + utilisateurId: data.utilisateurId, + type: 'RETOUR', + dateVerification: maintenant, + elements: { + create: data.elements.map((element) => ({ + accessoireId: element.accessoireId ?? null, + nomElement: element.nomElement, + etat: element.etat, + quantiteConstatee: element.quantiteConstatee, + commentaire: element.commentaire ?? null, + })), + }, + }, + }); + + await tx.emprunt.update({ + where: { id: data.empruntId }, + data: { + statut: data.statutEmprunt, + dateRetourReelle: maintenant, + modeIdentificationRetour: data.modeIdentification, + commentaireRetour: data.commentaireRetour ?? null, + }, + }); + + await tx.materiel.update({ + where: { id: data.materielId }, + data: { statut: data.statutMateriel }, + }); + + if (data.anomalie) { + const infoAnomalie = data.anomalie; + const anomalie = await tx.anomalie.create({ + data: { + empruntId: data.empruntId, + materielId: data.materielId, + utilisateurId: data.utilisateurId, + type: infoAnomalie.type, + description: infoAnomalie.description, + statut: 'DETECTEE', + detecteeAutomatiquement: true, + dateDetection: maintenant, + }, + }); + + if (infoAnomalie.responsablesIds.length > 0) { + await tx.notification.createMany({ + data: infoAnomalie.responsablesIds.map((responsableId) => ({ + utilisateurId: responsableId, + anomalieId: anomalie.id, + titre: 'Nouvelle anomalie detectee', + message: infoAnomalie.description, + type: 'ANOMALIE', + })), + }); + } + } + + return tx.emprunt.findUniqueOrThrow({ + where: { id: data.empruntId }, + include: { materiel: { include: { categorie: true } } }, + }); + }); +} diff --git a/eme-backend/src/repositories/utilisateur.repository.ts b/eme-backend/src/repositories/utilisateur.repository.ts index c83be2f..8a375af 100644 --- a/eme-backend/src/repositories/utilisateur.repository.ts +++ b/eme-backend/src/repositories/utilisateur.repository.ts @@ -20,3 +20,10 @@ export function findById(id: number): Promise { include: { role: true, campus: true }, }); } + +export function findResponsablesParCampus(campusId: number): Promise<{ id: number }[]> { + return prisma.utilisateur.findMany({ + where: { campusId, actif: true, role: { code: 'RESPONSABLE' } }, + select: { id: true }, + }); +} diff --git a/eme-backend/src/routes/emprunt.routes.ts b/eme-backend/src/routes/emprunt.routes.ts index 0c803a4..98d31d2 100644 --- a/eme-backend/src/routes/emprunt.routes.ts +++ b/eme-backend/src/routes/emprunt.routes.ts @@ -1,8 +1,9 @@ import { Router } from 'express'; -import { getMesEmprunts, postEmprunt } from '../controllers/emprunt.controller'; +import { getMesEmprunts, postEmprunt, postRestitution } from '../controllers/emprunt.controller'; export const empruntRoutes: Router = Router(); empruntRoutes.post('/', postEmprunt); +empruntRoutes.post('/:id/restitution', postRestitution); export const mesEmpruntsRoutes: Router = Router(); mesEmpruntsRoutes.get('/', getMesEmprunts); diff --git a/eme-backend/src/services/emprunt.service.ts b/eme-backend/src/services/emprunt.service.ts index 25d16c1..64fa35d 100644 --- a/eme-backend/src/services/emprunt.service.ts +++ b/eme-backend/src/services/emprunt.service.ts @@ -1,14 +1,24 @@ import { AppError } from '../errors/app-error'; -import { CreerEmpruntRequest } from '../dtos/emprunt.dto'; +import { + CreerEmpruntRequest, + RestituerEmpruntRequest, + ChecklistElementRequest, +} from '../dtos/emprunt.dto'; import { findEnCoursParUtilisateur, creerEmpruntComplet, + findByIdAvecChecklists, + restituerEmpruntComplet, EmpruntAvecMateriel, + RestituerEmpruntData, } from '../repositories/emprunt.repository'; import { findById as findMaterielById } from '../repositories/materiel.repository'; import { findById as findPosteById } from '../repositories/poste-emprunt.repository'; +import { findResponsablesParCampus } from '../repositories/utilisateur.repository'; +import { StatutEmprunt } from '../models/enums'; const DUREE_EMPRUNT_JOURS = 14; +const STATUTS_RESTITUABLES: readonly string[] = ['EN_COURS', 'EN_RETARD']; /* RG15 : un étudiant ne consulte que ses propres emprunts non restitués. */ export function listerMesEmpruntsEnCours(utilisateurId: number): Promise { @@ -57,3 +67,116 @@ export async function creerEmprunt( elements: request.elements, }); } + +interface ElementDepart { + accessoireId: number | null; + nomElement: string; + etat: string; +} + +interface ResultatComparaison { + conforme: boolean; + auMoinsUnDeteriore: boolean; + details: string[]; +} + +function trouverRetour( + depart: ElementDepart, + retour: ChecklistElementRequest[], +): ChecklistElementRequest | undefined { + if (depart.accessoireId !== null) { + const parId = retour.find((element) => element.accessoireId === depart.accessoireId); + if (parId) { + return parId; + } + } + return retour.find((element) => element.nomElement === depart.nomElement); +} + +/* RG17 : compare la checklist de départ à celle de retour. Un élément présent au + départ mais absent ou détérioré au retour rend la restitution non conforme (RG20). */ +function comparerChecklists( + depart: ElementDepart[], + retour: ChecklistElementRequest[], +): ResultatComparaison { + const details: string[] = []; + let auMoinsUnDeteriore = false; + + for (const elementDepart of depart) { + if (elementDepart.etat !== 'PRESENT') { + continue; + } + + const elementRetour = trouverRetour(elementDepart, retour); + if (!elementRetour || elementRetour.etat === 'ABSENT') { + details.push(`${elementDepart.nomElement} manquant au retour`); + } else if (elementRetour.etat === 'DETERIORE') { + details.push(`${elementDepart.nomElement} deteriore au retour`); + auMoinsUnDeteriore = true; + } + } + + return { conforme: details.length === 0, auMoinsUnDeteriore, details }; +} + +/* RG14-RG20 : restitution d'un emprunt avec comparaison des checklists, clôture + ou passage en non conforme, et création automatique d'anomalie + notifications. */ +export async function restituerEmprunt( + utilisateurId: number, + empruntId: number, + request: RestituerEmpruntRequest, +): Promise { + const emprunt = await findByIdAvecChecklists(empruntId); + if (!emprunt) { + throw new AppError(404, 'Emprunt introuvable'); + } + if (emprunt.utilisateurId !== utilisateurId) { + throw new AppError(403, 'Cet emprunt ne vous appartient pas'); + } + if (!STATUTS_RESTITUABLES.includes(emprunt.statut)) { + throw new AppError(409, 'Emprunt non restituable'); + } + + const checklistDepart = emprunt.checklists.find((checklist) => checklist.type === 'DEPART'); + if (!checklistDepart) { + throw new AppError(409, 'Checklist de depart introuvable'); + } + + const comparaison = comparerChecklists( + checklistDepart.elements.map((element) => ({ + accessoireId: element.accessoireId, + nomElement: element.nomElement, + etat: element.etat, + })), + request.elements, + ); + + const statutEmprunt: StatutEmprunt = comparaison.conforme ? 'CLOTURE' : 'RETOUR_NON_CONFORME'; + + let statutMateriel = 'DISPONIBLE'; + if (!comparaison.conforme) { + statutMateriel = comparaison.auMoinsUnDeteriore ? 'DETERIORE' : 'NON_CONFORME'; + } + + const donnees: RestituerEmpruntData = { + empruntId, + utilisateurId, + materielId: emprunt.materielId, + statutEmprunt, + statutMateriel, + modeIdentification: request.modeIdentification, + commentaireRetour: request.commentaireRetour, + elements: request.elements, + }; + + if (!comparaison.conforme) { + const responsables = await findResponsablesParCampus(emprunt.campusId); + donnees.anomalie = { + type: 'RETOUR_NON_CONFORME', + description: `Retour non conforme : ${comparaison.details.join(', ')}.`, + responsablesIds: responsables.map((responsable) => responsable.id), + }; + } + + return restituerEmpruntComplet(donnees); +} diff --git a/review.md b/review.md index 62a996c..57672d6 100644 --- a/review.md +++ b/review.md @@ -156,6 +156,14 @@ En terminal interactif (VS Code), `migrate dev` fonctionne normalement (taper `y - Contrôle du poste (existe + même campus, RG05). Routage : `POST /api/emprunts` et `GET /api/mes-emprunts` sur des routeurs séparés. - Vérifié au runtime : 401 sans auth, 400 checklist vide, 404 matériel inconnu, 201 création (matériel -> `EMPRUNTE`, +14j), 409 doublon (réservation atomique). +### Étape 20 — Block 4 : restitution + anomalie automatique (POST /api/emprunts/:id/restitution) +- Comparaison automatique départ/retour (RG17) : un élément présent au départ mais absent/détérioré au retour rend la restitution non conforme (RG20). Appariement par `accessoireId` sinon par `nomElement`. +- Restitution atomique : checklist de retour + mise à jour emprunt/matériel + (si non conforme) anomalie `DETECTEE` + notifications. +- RG15 (propriétaire), statut restituable, RG18 (conforme -> `CLOTURE` + matériel `DISPONIBLE`), RG19 (non conforme -> `RETOUR_NON_CONFORME` + matériel `DETERIORE`/`NON_CONFORME`). +- RG24 : notification à tous les responsables (`RESPONSABLE`) du campus de l'emprunt. +- Vérifié au runtime : 404/403/409 ; restitution conforme (CLOTURE + DISPONIBLE) ; non conforme (RETOUR_NON_CONFORME + NON_CONFORME + anomalie + notification à Karim). +- **Block 4 (API Étudiant) terminé : 6/6 endpoints.** + --- ## Dette technique en attente @@ -171,4 +179,4 @@ En terminal interactif (VS Code), `migrate dev` fonctionne normalement (taper `y --- -*Dernière mise à jour : 2026-07-06 — Création d'emprunt (POST /api/emprunts) en place (5/6 du Block 4).* +*Dernière mise à jour : 2026-07-06 — Restitution + anomalie auto en place. Block 4 (API Étudiant) terminé.*