feat(backend): add emprunt restitution with automatic anomaly detection
This commit is contained in:
@@ -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) });
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user