feat(backend): add emprunt restitution with automatic anomaly detection
This commit is contained in:
@@ -1,7 +1,15 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import { AppError } from '../errors/app-error';
|
import { AppError } from '../errors/app-error';
|
||||||
import { creerEmprunt, listerMesEmpruntsEnCours } from '../services/emprunt.service';
|
import {
|
||||||
import { parseCreerEmpruntRequest, toEmpruntResponse } from '../dtos/emprunt.dto';
|
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> {
|
export async function getMesEmprunts(req: Request, res: Response): Promise<void> {
|
||||||
const user = req.user;
|
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);
|
const emprunt = await creerEmprunt(user.id, user.campusId, request);
|
||||||
res.status(201).json({ data: toEmpruntResponse(emprunt) });
|
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) });
|
||||||
|
}
|
||||||
|
|||||||
@@ -116,3 +116,31 @@ export function parseCreerEmpruntRequest(body: unknown): CreerEmpruntRequest {
|
|||||||
|
|
||||||
return request;
|
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 } } };
|
include: { materiel: { include: { categorie: true } } };
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
export type EmpruntAvecChecklists = Prisma.EmpruntGetPayload<{
|
||||||
|
include: { checklists: { include: { elements: true } } };
|
||||||
|
}>;
|
||||||
|
|
||||||
const STATUTS_EN_COURS: StatutEmprunt[] = ['EN_COURS', 'EN_RETARD'];
|
const STATUTS_EN_COURS: StatutEmprunt[] = ['EN_COURS', 'EN_RETARD'];
|
||||||
|
|
||||||
export function findEnCoursParUtilisateur(utilisateurId: number): Promise<EmpruntAvecMateriel[]> {
|
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 {
|
export interface CreerEmpruntData {
|
||||||
utilisateurId: number;
|
utilisateurId: number;
|
||||||
materielId: number;
|
materielId: number;
|
||||||
@@ -29,13 +48,7 @@ export interface CreerEmpruntData {
|
|||||||
dateRetourPrevue: Date;
|
dateRetourPrevue: Date;
|
||||||
modeIdentification: string;
|
modeIdentification: string;
|
||||||
commentaireDepart?: string;
|
commentaireDepart?: string;
|
||||||
elements: {
|
elements: ChecklistElementData[];
|
||||||
accessoireId?: number;
|
|
||||||
nomElement: string;
|
|
||||||
etat: string;
|
|
||||||
quantiteConstatee: number;
|
|
||||||
commentaire?: string;
|
|
||||||
}[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Création atomique : réservation du matériel (DISPONIBLE -> EMPRUNTE), emprunt,
|
/* 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 },
|
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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { getMesEmprunts, postEmprunt } from '../controllers/emprunt.controller';
|
import { getMesEmprunts, postEmprunt, postRestitution } from '../controllers/emprunt.controller';
|
||||||
|
|
||||||
export const empruntRoutes: Router = Router();
|
export const empruntRoutes: Router = Router();
|
||||||
empruntRoutes.post('/', postEmprunt);
|
empruntRoutes.post('/', postEmprunt);
|
||||||
|
empruntRoutes.post('/:id/restitution', postRestitution);
|
||||||
|
|
||||||
export const mesEmpruntsRoutes: Router = Router();
|
export const mesEmpruntsRoutes: Router = Router();
|
||||||
mesEmpruntsRoutes.get('/', getMesEmprunts);
|
mesEmpruntsRoutes.get('/', getMesEmprunts);
|
||||||
|
|||||||
@@ -1,14 +1,24 @@
|
|||||||
import { AppError } from '../errors/app-error';
|
import { AppError } from '../errors/app-error';
|
||||||
import { CreerEmpruntRequest } from '../dtos/emprunt.dto';
|
import {
|
||||||
|
CreerEmpruntRequest,
|
||||||
|
RestituerEmpruntRequest,
|
||||||
|
ChecklistElementRequest,
|
||||||
|
} from '../dtos/emprunt.dto';
|
||||||
import {
|
import {
|
||||||
findEnCoursParUtilisateur,
|
findEnCoursParUtilisateur,
|
||||||
creerEmpruntComplet,
|
creerEmpruntComplet,
|
||||||
|
findByIdAvecChecklists,
|
||||||
|
restituerEmpruntComplet,
|
||||||
EmpruntAvecMateriel,
|
EmpruntAvecMateriel,
|
||||||
|
RestituerEmpruntData,
|
||||||
} from '../repositories/emprunt.repository';
|
} from '../repositories/emprunt.repository';
|
||||||
import { findById as findMaterielById } from '../repositories/materiel.repository';
|
import { findById as findMaterielById } from '../repositories/materiel.repository';
|
||||||
import { findById as findPosteById } from '../repositories/poste-emprunt.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 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. */
|
/* RG15 : un étudiant ne consulte que ses propres emprunts non restitués. */
|
||||||
export function listerMesEmpruntsEnCours(utilisateurId: number): Promise<EmpruntAvecMateriel[]> {
|
export function listerMesEmpruntsEnCours(utilisateurId: number): Promise<EmpruntAvecMateriel[]> {
|
||||||
@@ -57,3 +67,116 @@ export async function creerEmprunt(
|
|||||||
elements: request.elements,
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.
|
- 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).
|
- 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
|
## 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é.*
|
||||||
|
|||||||
Reference in New Issue
Block a user