feat(backend): add emprunt restitution with automatic anomaly detection

This commit is contained in:
SaidSoighiri94
2026-07-06 10:10:09 +02:00
parent 3215a3c11f
commit be7d095fc3
7 changed files with 306 additions and 12 deletions
@@ -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<void> {
const user = req.user;
@@ -23,3 +31,19 @@ export async function postEmprunt(req: Request, res: Response): Promise<void> {
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<void> {
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) });
}
+28
View File
@@ -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;
}
@@ -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<EmpruntAvecMateriel[]> {
@@ -20,6 +24,21 @@ export function findEnCoursParUtilisateur(utilisateurId: number): Promise<Emprun
});
}
export function findByIdAvecChecklists(id: number): Promise<EmpruntAvecChecklists | null> {
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<EmpruntAvec
});
});
}
export interface RestituerEmpruntData {
empruntId: number;
utilisateurId: number;
materielId: number;
statutEmprunt: string;
statutMateriel: string;
modeIdentification: string;
commentaireRetour?: string;
elements: ChecklistElementData[];
anomalie?: {
type: string;
description: string;
responsablesIds: number[];
};
}
/* Restitution atomique : checklist de retour, mise à jour de l'emprunt et du
matériel, et — si non conforme — création de l'anomalie et des notifications. */
export function restituerEmpruntComplet(data: RestituerEmpruntData): Promise<EmpruntAvecMateriel> {
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 } } },
});
});
}
@@ -20,3 +20,10 @@ export function findById(id: number): Promise<UtilisateurProfil | null> {
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 },
});
}
+2 -1
View File
@@ -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);
+124 -1
View File
@@ -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<EmpruntAvecMateriel[]> {
@@ -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<EmpruntAvecMateriel> {
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);
}
+9 -1
View File
@@ -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é.*