feat(loans): confirm declared material state changes

This commit is contained in:
SaidSoighiri94
2026-07-24 13:41:40 +02:00
parent 3739e9b0a0
commit a562b3248f
26 changed files with 1832 additions and 282 deletions
@@ -4,10 +4,12 @@ import {
creerEmprunt,
listerMesEmpruntsEnCours,
restituerEmprunt,
signalerTentativeModificationRetour,
} from '../services/emprunt.service';
import {
parseCreerEmpruntRequest,
parseRestituerEmpruntRequest,
parseSignalerTentativeRetourRequest,
toEmpruntResponse,
} from '../dtos/emprunt.dto';
@@ -47,3 +49,19 @@ export async function postRestitution(req: Request, res: Response): Promise<void
const emprunt = await restituerEmprunt(user.id, empruntId, request);
res.json({ data: toEmpruntResponse(emprunt) });
}
export async function postAlerteChangementRetour(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 = parseSignalerTentativeRetourRequest(req.body);
const alerteCreee = await signalerTentativeModificationRetour(user.id, empruntId, request);
res.json({ data: { alerteCreee } });
}
@@ -2,15 +2,18 @@ import { Request, Response } from 'express';
import { AppError } from '../errors/app-error';
import {
changerStatutAnomalieResponsableService,
deciderEcartResponsableService,
getDashboardResponsable,
listerAnomaliesResponsable,
listerHistoriqueResponsable,
listerNotificationsResponsable,
listerMaterielsResponsable,
listerEmpruntsResponsable,
listerEcartsResponsable,
marquerNotificationResponsableLue,
marquerToutesNotificationsResponsableLues,
parseAnomalieId,
parseEmpruntId,
parseCategorieId,
parseLu,
parseNotificationId,
@@ -24,6 +27,7 @@ import {
toAnomalieResponsableResponse,
toDashboardResponsableResponse,
toEmpruntResponsableResponse,
toEcartResponsableResponse,
toHistoriqueResponsableResponse,
toMaterielResponsableResponse,
toNotificationResponsableResponse,
@@ -77,6 +81,44 @@ export async function getEmprunts(req: Request, res: Response): Promise<void> {
res.json({ data: emprunts.map(toEmpruntResponsableResponse) });
}
export async function getEcarts(req: Request, res: Response): Promise<void> {
const user = req.user;
if (!user) {
throw new AppError(401, 'Authentification requise');
}
const ecarts = await listerEcartsResponsable(user.roleCode, user.campusId);
res.json({ data: ecarts.map(toEcartResponsableResponse) });
}
export async function patchEcartDecision(req: Request, res: Response): Promise<void> {
const user = req.user;
if (!user) {
throw new AppError(401, 'Authentification requise');
}
const empruntId = parseEmpruntId(req.params.id);
const body = asBodyObject(req.body);
if (body.decision !== 'CONFIRMER' && body.decision !== 'REFUSER') {
throw new AppError(400, 'decision invalide');
}
const observation = typeof body.observation === 'string' ? body.observation : undefined;
const statutMateriel = parseStatutMateriel(body.statutMateriel);
const ecart = await deciderEcartResponsableService(
user.id,
user.roleCode,
user.campusId,
empruntId,
{
decision: body.decision,
observation,
statutMateriel,
},
);
res.json({ data: toEcartResponsableResponse(ecart) });
}
export async function getMateriels(req: Request, res: Response): Promise<void> {
const user = req.user;
if (!user) {
+36
View File
@@ -10,9 +10,19 @@ export interface EmpruntResponse {
dateRetourReelle: Date | null;
statut: string;
materiel: MaterielResponse;
checklistDepart: ChecklistElementResponse[];
}
export interface ChecklistElementResponse {
accessoireId: number | null;
nomElement: string;
etat: string;
quantiteConstatee: number;
}
export function toEmpruntResponse(emprunt: EmpruntAvecMateriel): EmpruntResponse {
const checklistDepart = emprunt.checklists.find((checklist) => checklist.type === 'DEPART');
return {
id: emprunt.id,
dateEmprunt: emprunt.dateEmprunt,
@@ -20,6 +30,13 @@ export function toEmpruntResponse(emprunt: EmpruntAvecMateriel): EmpruntResponse
dateRetourReelle: emprunt.dateRetourReelle,
statut: emprunt.statut,
materiel: toMaterielResponse(emprunt.materiel),
checklistDepart:
checklistDepart?.elements.map((element) => ({
accessoireId: element.accessoireId,
nomElement: element.nomElement,
etat: element.etat,
quantiteConstatee: element.quantiteConstatee,
})) ?? [],
};
}
@@ -123,6 +140,25 @@ export interface RestituerEmpruntRequest {
elements: ChecklistElementRequest[];
}
export interface SignalerTentativeRetourRequest {
nomElement: string;
etatInitial: string;
etatDemande: string;
}
export function parseSignalerTentativeRetourRequest(body: unknown): SignalerTentativeRetourRequest {
const obj = asObject(body, 'Corps de requete invalide');
if (typeof obj.nomElement !== 'string' || obj.nomElement.trim() === '') {
throw new AppError(400, 'nomElement requis');
}
return {
nomElement: obj.nomElement.trim(),
etatInitial: asEnum(obj.etatInitial, ETAT_CHECKLIST_ELEMENT, 'etatInitial'),
etatDemande: asEnum(obj.etatDemande, ETAT_CHECKLIST_ELEMENT, 'etatDemande'),
};
}
export function parseRestituerEmpruntRequest(body: unknown): RestituerEmpruntRequest {
const obj = asObject(body, 'Corps de requete invalide');
+36
View File
@@ -2,6 +2,7 @@ import {
ActiviteResponsable,
AnomalieResponsable,
EmpruntResponsable,
EcartResponsable,
HistoriqueResponsable,
MaterielResponsable,
NotificationResponsable,
@@ -67,6 +68,21 @@ export interface EmpruntResponsableResponse {
};
}
export interface EcartResponsableResponse extends EmpruntResponsableResponse {
typeEcart: 'DEPART' | 'RETOUR';
checklists: Array<{
type: string;
elements: Array<{
id: number;
accessoireId: number | null;
nomElement: string;
etat: string;
quantiteConstatee: number;
commentaire: string | null;
}>;
}>;
}
export interface MaterielResponsableResponse {
id: number;
nom: string;
@@ -251,6 +267,26 @@ export function toEmpruntResponsableResponse(
};
}
export function toEcartResponsableResponse(emprunt: EcartResponsable): EcartResponsableResponse {
return {
...toEmpruntResponsableResponse(emprunt),
typeEcart: emprunt.checklists.some((checklist) => checklist.type === 'RETOUR')
? 'RETOUR'
: 'DEPART',
checklists: emprunt.checklists.map((checklist) => ({
type: checklist.type,
elements: checklist.elements.map((element) => ({
id: element.id,
accessoireId: element.accessoireId,
nomElement: element.nomElement,
etat: element.etat,
quantiteConstatee: element.quantiteConstatee,
commentaire: element.commentaire,
})),
})),
};
}
export function toMaterielResponsableResponse(
materiel: MaterielResponsable,
): MaterielResponsableResponse {
+3
View File
@@ -1,5 +1,6 @@
export const STATUT_MATERIEL = [
'DISPONIBLE',
'RESERVE',
'EMPRUNTE',
'NON_CONFORME',
'DETERIORE',
@@ -10,8 +11,10 @@ export const STATUT_MATERIEL = [
export type StatutMateriel = (typeof STATUT_MATERIEL)[number];
export const STATUT_EMPRUNT = [
'EN_ATTENTE_VALIDATION_DEPART',
'EN_COURS',
'EN_RETARD',
'EN_ATTENTE_VALIDATION_RETOUR',
'CLOTURE',
'RETOUR_NON_CONFORME',
'ANNULE',
@@ -4,7 +4,10 @@ import { StatutEmprunt } from '../models/enums';
import { AppError } from '../errors/app-error';
export type EmpruntAvecMateriel = Prisma.EmpruntGetPayload<{
include: { materiel: { include: { categorie: true } } };
include: {
materiel: { include: { categorie: true } };
checklists: { include: { elements: true } };
};
}>;
export type EmpruntAvecChecklists = Prisma.EmpruntGetPayload<{
@@ -12,6 +15,12 @@ export type EmpruntAvecChecklists = Prisma.EmpruntGetPayload<{
}>;
const STATUTS_EN_COURS: StatutEmprunt[] = ['EN_COURS', 'EN_RETARD'];
const STATUTS_BLOQUANTS: StatutEmprunt[] = [
'EN_ATTENTE_VALIDATION_DEPART',
'EN_COURS',
'EN_RETARD',
'EN_ATTENTE_VALIDATION_RETOUR',
];
export function findEnCoursParUtilisateur(utilisateurId: number): Promise<EmpruntAvecMateriel[]> {
return prisma.emprunt.findMany({
@@ -19,7 +28,10 @@ export function findEnCoursParUtilisateur(utilisateurId: number): Promise<Emprun
utilisateurId,
statut: { in: STATUTS_EN_COURS },
},
include: { materiel: { include: { categorie: true } } },
include: {
materiel: { include: { categorie: true } },
checklists: { include: { elements: true } },
},
orderBy: { dateRetourPrevue: 'asc' },
});
}
@@ -31,6 +43,71 @@ export function findByIdAvecChecklists(id: number): Promise<EmpruntAvecChecklist
});
}
export interface SignalerTentativeRetourData {
empruntId: number;
utilisateurId: number;
materielId: number;
campusId: number;
sallePretId: number;
posteEmpruntId: number;
responsablesIds: number[];
description: string;
}
export function signalerTentativeRetour(data: SignalerTentativeRetourData): Promise<boolean> {
return prisma.$transaction(async (tx) => {
const emprunt = await tx.emprunt.findFirst({
where: {
id: data.empruntId,
utilisateurId: data.utilisateurId,
statut: { in: STATUTS_EN_COURS },
},
select: { id: true },
});
if (!emprunt) {
throw new AppError(409, 'Emprunt non restituable');
}
const dejaSignalee = await tx.historique.findFirst({
where: {
empruntId: data.empruntId,
action: 'TENTATIVE_MODIFICATION_RETOUR',
},
select: { id: true },
});
if (dejaSignalee) {
return false;
}
if (data.responsablesIds.length > 0) {
await tx.notification.createMany({
data: data.responsablesIds.map((responsableId) => ({
utilisateurId: responsableId,
titre: 'Tentative de modification au retour',
message: data.description,
type: 'TENTATIVE_MODIFICATION_RETOUR',
})),
});
}
await tx.historique.create({
data: {
utilisateurId: data.utilisateurId,
empruntId: data.empruntId,
materielId: data.materielId,
campusId: data.campusId,
sallePretId: data.sallePretId,
posteEmpruntId: data.posteEmpruntId,
action: 'TENTATIVE_MODIFICATION_RETOUR',
description: data.description,
dateAction: new Date(),
},
});
return true;
});
}
interface ChecklistElementData {
accessoireId?: number;
nomElement: string;
@@ -42,6 +119,7 @@ interface ChecklistElementData {
export interface CreerEmpruntData {
utilisateurId: number;
materielId: number;
categorieId: number;
campusId: number;
sallePretId: number;
posteEmpruntId: number;
@@ -49,59 +127,122 @@ export interface CreerEmpruntData {
modeIdentification: string;
commentaireDepart?: string;
elements: ChecklistElementData[];
statutEmprunt: StatutEmprunt;
statutMateriel: string;
responsablesIds: number[];
descriptionEcart?: string;
}
/* Création atomique : réservation du matériel (DISPONIBLE -> EMPRUNTE), emprunt,
checklist de départ et ses éléments. Si une étape échoue, tout est annulé. */
export function creerEmpruntComplet(data: CreerEmpruntData): Promise<EmpruntAvecMateriel> {
return prisma.$transaction(async (tx) => {
// Réservation atomique : empêche un double emprunt du même matériel (RG07).
const reservation = await tx.materiel.updateMany({
where: { id: data.materielId, statut: 'DISPONIBLE' },
data: { statut: 'EMPRUNTE' },
});
if (reservation.count === 0) {
throw new AppError(409, 'Materiel non disponible');
}
const emprunt = await tx.emprunt.create({
data: {
utilisateurId: data.utilisateurId,
materielId: data.materielId,
campusId: data.campusId,
sallePretId: data.sallePretId,
posteEmpruntId: data.posteEmpruntId,
dateEmprunt: new Date(),
dateRetourPrevue: data.dateRetourPrevue,
statut: 'EN_COURS',
modeIdentificationEmprunt: data.modeIdentification,
commentaireDepart: data.commentaireDepart ?? null,
},
});
await tx.checklist.create({
data: {
empruntId: emprunt.id,
utilisateurId: data.utilisateurId,
type: 'DEPART',
dateVerification: new Date(),
elements: {
create: data.elements.map((element) => ({
accessoireId: element.accessoireId ?? null,
nomElement: element.nomElement,
etat: element.etat,
quantiteConstatee: element.quantiteConstatee,
commentaire: element.commentaire ?? null,
})),
return prisma.$transaction(
async (tx) => {
const empruntBloquant = await tx.emprunt.findFirst({
where: {
utilisateurId: data.utilisateurId,
statut: { in: STATUTS_BLOQUANTS },
materiel: { categorieId: data.categorieId },
},
},
});
select: { id: true },
});
if (empruntBloquant) {
throw new AppError(
409,
'Un materiel de cette categorie est deja emprunte ou en attente de confirmation',
);
}
return tx.emprunt.findUniqueOrThrow({
where: { id: emprunt.id },
include: { materiel: { include: { categorie: true } } },
});
});
const reservation = await tx.materiel.updateMany({
where: { id: data.materielId, statut: 'DISPONIBLE' },
data: { statut: data.statutMateriel },
});
if (reservation.count === 0) {
throw new AppError(409, 'Materiel non disponible');
}
const maintenant = new Date();
const emprunt = await tx.emprunt.create({
data: {
utilisateurId: data.utilisateurId,
materielId: data.materielId,
campusId: data.campusId,
sallePretId: data.sallePretId,
posteEmpruntId: data.posteEmpruntId,
dateEmprunt: maintenant,
dateRetourPrevue: data.dateRetourPrevue,
statut: data.statutEmprunt,
modeIdentificationEmprunt: data.modeIdentification,
commentaireDepart: data.commentaireDepart ?? null,
},
});
await tx.checklist.create({
data: {
empruntId: emprunt.id,
utilisateurId: data.utilisateurId,
type: 'DEPART',
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,
})),
},
},
});
if (data.descriptionEcart) {
if (data.responsablesIds.length > 0) {
await tx.notification.createMany({
data: data.responsablesIds.map((responsableId) => ({
utilisateurId: responsableId,
titre: 'Ecart signale au depart',
message: data.descriptionEcart as string,
type: 'ECART_DEPART',
})),
});
}
await tx.historique.create({
data: {
utilisateurId: data.utilisateurId,
empruntId: emprunt.id,
materielId: data.materielId,
campusId: data.campusId,
sallePretId: data.sallePretId,
posteEmpruntId: data.posteEmpruntId,
action: 'ECART_DEPART_SIGNALE',
description: data.descriptionEcart,
dateAction: maintenant,
},
});
} else {
await tx.historique.create({
data: {
utilisateurId: data.utilisateurId,
empruntId: emprunt.id,
materielId: data.materielId,
campusId: data.campusId,
sallePretId: data.sallePretId,
posteEmpruntId: data.posteEmpruntId,
action: 'EMPRUNT_AUTOMATIQUE',
description: `Emprunt #${emprunt.id} active avec une checklist de depart inchangee`,
dateAction: maintenant,
},
});
}
return tx.emprunt.findUniqueOrThrow({
where: { id: emprunt.id },
include: {
materiel: { include: { categorie: true } },
checklists: { include: { elements: true } },
},
});
},
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable },
);
}
export interface RestituerEmpruntData {
@@ -113,19 +254,34 @@ export interface RestituerEmpruntData {
modeIdentification: string;
commentaireRetour?: string;
elements: ChecklistElementData[];
anomalie?: {
type: string;
description: string;
responsablesIds: number[];
};
campusId: number;
sallePretId: number;
posteEmpruntId: number;
responsablesIds: number[];
descriptionEcart?: string;
}
/* 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();
const transition = await tx.emprunt.updateMany({
where: {
id: data.empruntId,
utilisateurId: data.utilisateurId,
statut: { in: STATUTS_EN_COURS },
},
data: {
statut: data.statutEmprunt,
...(data.statutEmprunt === 'CLOTURE' ? { dateRetourReelle: maintenant } : {}),
modeIdentificationRetour: data.modeIdentification,
commentaireRetour: data.commentaireRetour ?? null,
},
});
if (transition.count === 0) {
throw new AppError(409, 'Emprunt non restituable');
}
await tx.checklist.create({
data: {
empruntId: data.empruntId,
@@ -144,52 +300,64 @@ export function restituerEmpruntComplet(data: RestituerEmpruntData): Promise<Emp
},
});
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: {
if (data.descriptionEcart) {
const tentativeDejaSignalee = await tx.historique.findFirst({
where: {
empruntId: data.empruntId,
materielId: data.materielId,
utilisateurId: data.utilisateurId,
type: infoAnomalie.type,
description: infoAnomalie.description,
statut: 'DETECTEE',
detecteeAutomatiquement: true,
dateDetection: maintenant,
action: 'TENTATIVE_MODIFICATION_RETOUR',
},
select: { id: true },
});
if (infoAnomalie.responsablesIds.length > 0) {
if (!tentativeDejaSignalee && data.responsablesIds.length > 0) {
await tx.notification.createMany({
data: infoAnomalie.responsablesIds.map((responsableId) => ({
data: data.responsablesIds.map((responsableId) => ({
utilisateurId: responsableId,
anomalieId: anomalie.id,
titre: 'Nouvelle anomalie detectee',
message: infoAnomalie.description,
type: 'ANOMALIE',
titre: 'Changement signale au retour',
message: data.descriptionEcart as string,
type: 'ECART_RETOUR',
})),
});
}
await tx.historique.create({
data: {
utilisateurId: data.utilisateurId,
empruntId: data.empruntId,
materielId: data.materielId,
campusId: data.campusId,
sallePretId: data.sallePretId,
posteEmpruntId: data.posteEmpruntId,
action: 'ECART_RETOUR_SIGNALE',
description: data.descriptionEcart,
dateAction: maintenant,
},
});
} else {
await tx.historique.create({
data: {
utilisateurId: data.utilisateurId,
empruntId: data.empruntId,
materielId: data.materielId,
campusId: data.campusId,
sallePretId: data.sallePretId,
posteEmpruntId: data.posteEmpruntId,
action: 'RESTITUTION_AUTOMATIQUE',
description: `Emprunt #${data.empruntId} cloture avec une checklist de retour inchangee`,
dateAction: maintenant,
},
});
}
return tx.emprunt.findUniqueOrThrow({
where: { id: data.empruntId },
include: { materiel: { include: { categorie: true } } },
include: {
materiel: { include: { categorie: true } },
checklists: { include: { elements: true } },
},
});
});
}
@@ -21,6 +21,19 @@ export type EmpruntResponsable = Prisma.EmpruntGetPayload<{
};
}>;
export type EcartResponsable = Prisma.EmpruntGetPayload<{
include: {
utilisateur: true;
materiel: {
include: {
categorie: true;
accessoires: { include: { accessoire: true } };
};
};
checklists: { include: { elements: true } };
};
}>;
export type MaterielResponsable = Prisma.MaterielGetPayload<{
include: {
categorie: true;
@@ -94,6 +107,23 @@ export interface ChangerStatutAnomalieData {
observation?: string;
}
export interface DeciderEcartResponsableData {
empruntId: number;
campusId: number;
responsableId: number;
statutAttendu: string;
statutEmpruntFinal: string;
statutMaterielFinal: string;
typeEcart: 'DEPART' | 'RETOUR';
decision: 'CONFIRMER' | 'REFUSER';
description: string;
observation?: string;
elementsDepartCorriges?: Array<{
id: number;
quantiteConstatee: number;
}>;
}
function mapStatutCounts(rows: Array<{ statut: string; _count: { _all: number } }>): StatutCount[] {
return rows.map((row) => ({ statut: row.statut, count: row._count._all }));
}
@@ -167,6 +197,128 @@ export function findEmpruntsResponsable(
});
}
const INCLUDE_ECART = {
utilisateur: true,
materiel: {
include: {
categorie: true,
accessoires: { include: { accessoire: true } },
},
},
checklists: { include: { elements: true } },
} satisfies Prisma.EmpruntInclude;
export function findEcartsResponsable(campusId: number): Promise<EcartResponsable[]> {
return prisma.emprunt.findMany({
where: {
campusId,
statut: {
in: ['EN_ATTENTE_VALIDATION_DEPART', 'EN_ATTENTE_VALIDATION_RETOUR'],
},
},
include: INCLUDE_ECART,
orderBy: { updatedAt: 'asc' },
});
}
export function findEcartResponsableById(
campusId: number,
empruntId: number,
): Promise<EcartResponsable | null> {
return prisma.emprunt.findFirst({
where: {
id: empruntId,
campusId,
statut: {
in: ['EN_ATTENTE_VALIDATION_DEPART', 'EN_ATTENTE_VALIDATION_RETOUR'],
},
},
include: INCLUDE_ECART,
});
}
export function deciderEcartResponsable(
data: DeciderEcartResponsableData,
): Promise<EcartResponsable> {
return prisma.$transaction(async (tx) => {
const maintenant = new Date();
const transition = await tx.emprunt.updateMany({
where: {
id: data.empruntId,
campusId: data.campusId,
statut: data.statutAttendu,
},
data: {
statut: data.statutEmpruntFinal,
...(data.typeEcart === 'RETOUR' ? { dateRetourReelle: maintenant } : {}),
},
});
if (transition.count === 0) {
throw new Error('ECART_DEJA_TRAITE');
}
await tx.materiel.update({
where: {
id: (await tx.emprunt.findUniqueOrThrow({ where: { id: data.empruntId } })).materielId,
},
data: { statut: data.statutMaterielFinal },
});
if (data.elementsDepartCorriges) {
await Promise.all(
data.elementsDepartCorriges.map((element) =>
tx.checklistElement.update({
where: { id: element.id },
data: {
etat: 'PRESENT',
quantiteConstatee: element.quantiteConstatee,
commentaire: null,
},
}),
),
);
}
if (data.decision === 'CONFIRMER') {
const emprunt = await tx.emprunt.findUniqueOrThrow({ where: { id: data.empruntId } });
await tx.anomalie.create({
data: {
empruntId: data.empruntId,
materielId: emprunt.materielId,
utilisateurId: emprunt.utilisateurId,
traiteeParId: data.responsableId,
type: data.typeEcart === 'DEPART' ? 'ECART_DEPART' : 'RETOUR_NON_CONFORME',
description: data.description,
statut: 'DETECTEE',
detecteeAutomatiquement: false,
observation: data.observation ?? null,
dateDetection: maintenant,
},
});
}
const emprunt = await tx.emprunt.findUniqueOrThrow({ where: { id: data.empruntId } });
await tx.historique.create({
data: {
utilisateurId: data.responsableId,
empruntId: data.empruntId,
materielId: emprunt.materielId,
campusId: data.campusId,
sallePretId: emprunt.sallePretId,
posteEmpruntId: emprunt.posteEmpruntId,
action: `ECART_${data.typeEcart}_${data.decision}`,
description: data.description,
dateAction: maintenant,
},
});
return tx.emprunt.findUniqueOrThrow({
where: { id: data.empruntId },
include: INCLUDE_ECART,
});
});
}
export function findMaterielsResponsable(
campusId: number,
filtres: MaterielResponsableFiltres,
+7 -1
View File
@@ -1,8 +1,14 @@
import { Router } from 'express';
import { getMesEmprunts, postEmprunt, postRestitution } from '../controllers/emprunt.controller';
import {
getMesEmprunts,
postAlerteChangementRetour,
postEmprunt,
postRestitution,
} from '../controllers/emprunt.controller';
export const empruntRoutes: Router = Router();
empruntRoutes.post('/', postEmprunt);
empruntRoutes.post('/:id/alerte-changement-retour', postAlerteChangementRetour);
empruntRoutes.post('/:id/restitution', postRestitution);
export const mesEmpruntsRoutes: Router = Router();
@@ -3,11 +3,13 @@ import {
getAnomalies,
getDashboard,
getEmprunts,
getEcarts,
getHistorique,
getHistoriqueCsv,
getMateriels,
getNotifications,
patchAnomalieStatut,
patchEcartDecision,
patchNotificationLue,
patchNotificationsLues,
} from '../controllers/responsable.controller';
@@ -16,6 +18,8 @@ export const responsableRoutes: Router = Router();
responsableRoutes.get('/dashboard', getDashboard);
responsableRoutes.get('/emprunts', getEmprunts);
responsableRoutes.get('/ecarts', getEcarts);
responsableRoutes.patch('/ecarts/:id/decision', patchEcartDecision);
responsableRoutes.get('/materiels', getMateriels);
responsableRoutes.get('/anomalies', getAnomalies);
responsableRoutes.patch('/anomalies/:id/statut', patchAnomalieStatut);
+158 -65
View File
@@ -11,11 +11,13 @@ import {
restituerEmpruntComplet,
EmpruntAvecMateriel,
RestituerEmpruntData,
signalerTentativeRetour,
} from '../repositories/emprunt.repository';
import { findById as findMaterielById } from '../repositories/materiel.repository';
import { findDetailParCampus } from '../repositories/materiel.repository';
import { findById as findPosteById } from '../repositories/poste-emprunt.repository';
import { findResponsablesParCampus } from '../repositories/utilisateur.repository';
import { StatutEmprunt } from '../models/enums';
import { SignalerTentativeRetourRequest } from '../dtos/emprunt.dto';
const DUREE_EMPRUNT_JOURS = 14;
const STATUTS_RESTITUABLES: readonly string[] = ['EN_COURS', 'EN_RETARD'];
@@ -25,6 +27,51 @@ export function listerMesEmpruntsEnCours(utilisateurId: number): Promise<Emprunt
return findEnCoursParUtilisateur(utilisateurId);
}
export async function signalerTentativeModificationRetour(
utilisateurId: number,
empruntId: number,
request: SignalerTentativeRetourRequest,
): Promise<boolean> {
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');
const elementDepart = checklistDepart?.elements.find(
(element) => element.nomElement.toLocaleLowerCase() === request.nomElement.toLocaleLowerCase(),
);
if (!elementDepart) {
throw new AppError(400, 'Element absent de la checklist de depart');
}
if (elementDepart.etat !== request.etatInitial) {
throw new AppError(400, "L'etat initial ne correspond pas au depart valide");
}
if (request.etatInitial === request.etatDemande) {
throw new AppError(400, 'Aucun changement a signaler');
}
const responsables = await findResponsablesParCampus(emprunt.campusId);
return signalerTentativeRetour({
empruntId,
utilisateurId,
materielId: emprunt.materielId,
campusId: emprunt.campusId,
sallePretId: emprunt.sallePretId,
posteEmpruntId: emprunt.posteEmpruntId,
responsablesIds: responsables.map((responsable) => responsable.id),
description:
`Tentative de modification au retour de l'emprunt #${emprunt.id} : ` +
`${elementDepart.nomElement} ${request.etatInitial} -> ${request.etatDemande}.`,
});
}
/* RG07/RG10/RG11/RG12 : création d'un emprunt avec sa checklist de départ.
Les contrôles d'éligibilité (campus, disponibilité) sont faits ici ; l'écriture
atomique est déléguée au repository. */
@@ -33,13 +80,10 @@ export async function creerEmprunt(
campusId: number,
request: CreerEmpruntRequest,
): Promise<EmpruntAvecMateriel> {
const materiel = await findMaterielById(request.materielId);
const materiel = await findDetailParCampus(request.materielId, campusId);
if (!materiel) {
throw new AppError(404, 'Materiel introuvable');
}
if (materiel.campusId !== campusId) {
throw new AppError(403, 'Materiel rattache a un autre campus');
}
if (materiel.statut !== 'DISPONIBLE') {
throw new AppError(409, 'Materiel non disponible');
}
@@ -55,72 +99,124 @@ export async function creerEmprunt(
const dateRetourPrevue = new Date();
dateRetourPrevue.setDate(dateRetourPrevue.getDate() + DUREE_EMPRUNT_JOURS);
const references: ElementReference[] =
materiel.accessoires.length > 0
? materiel.accessoires.map((liaison) => ({
accessoireId: liaison.accessoireId,
nomElement: liaison.accessoire.nom,
etat: 'PRESENT',
quantiteConstatee: liaison.quantiteAttendue,
}))
: [
{
accessoireId: null,
nomElement: materiel.nom,
etat: 'PRESENT',
quantiteConstatee: 1,
},
];
const elements = normaliserChecklist(references, request.elements, 'depart');
const differences = comparerEtats(references, elements);
const responsables = differences.length > 0 ? await findResponsablesParCampus(campusId) : [];
const descriptionEcart =
differences.length > 0
? `Ecart au depart pour ${materiel.nom} : ${differences.join(', ')}.`
: undefined;
return creerEmpruntComplet({
utilisateurId,
materielId: request.materielId,
categorieId: materiel.categorieId,
campusId,
sallePretId: poste.sallePretId,
posteEmpruntId: request.posteEmpruntId,
dateRetourPrevue,
modeIdentification: request.modeIdentification,
commentaireDepart: request.commentaireDepart,
elements: request.elements,
elements,
statutEmprunt: differences.length > 0 ? 'EN_ATTENTE_VALIDATION_DEPART' : 'EN_COURS',
statutMateriel: differences.length > 0 ? 'RESERVE' : 'EMPRUNTE',
responsablesIds: responsables.map((responsable) => responsable.id),
descriptionEcart,
});
}
interface ElementDepart {
interface ElementReference {
accessoireId: number | null;
nomElement: string;
etat: string;
quantiteConstatee: number;
}
interface ResultatComparaison {
conforme: boolean;
auMoinsUnDeteriore: boolean;
details: string[];
}
function trouverRetour(
depart: ElementDepart,
retour: ChecklistElementRequest[],
function trouverElement(
reference: ElementReference,
elements: ChecklistElementRequest[],
): ChecklistElementRequest | undefined {
if (depart.accessoireId !== null) {
const parId = retour.find((element) => element.accessoireId === depart.accessoireId);
if (reference.accessoireId !== null) {
const parId = elements.find((element) => element.accessoireId === reference.accessoireId);
if (parId) {
return parId;
}
}
return retour.find((element) => element.nomElement === depart.nomElement);
return elements.find(
(element) =>
element.nomElement.trim().toLocaleLowerCase() === reference.nomElement.toLocaleLowerCase(),
);
}
/* 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;
function normaliserChecklist(
references: ElementReference[],
elements: ChecklistElementRequest[],
type: 'depart' | 'retour',
): ChecklistElementRequest[] {
if (elements.length !== references.length) {
throw new AppError(400, `La checklist de ${type} doit contenir tous les elements attendus`);
}
for (const elementDepart of depart) {
if (elementDepart.etat !== 'PRESENT') {
continue;
const utilises = new Set<ChecklistElementRequest>();
return references.map((reference) => {
const element = trouverElement(reference, elements);
if (!element || utilises.has(element)) {
throw new AppError(400, `Element de checklist manquant : ${reference.nomElement}`);
}
utilises.add(element);
return {
accessoireId: reference.accessoireId ?? undefined,
nomElement: reference.nomElement,
etat: element.etat,
quantiteConstatee:
type === 'depart' && element.etat === 'PRESENT'
? reference.quantiteConstatee
: element.quantiteConstatee,
commentaire: element.commentaire,
};
});
}
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;
function comparerEtats(
references: ElementReference[],
elements: ChecklistElementRequest[],
): string[] {
const details: string[] = [];
for (const reference of references) {
const element = trouverElement(reference, elements);
if (
!element ||
element.etat !== reference.etat ||
element.quantiteConstatee !== reference.quantiteConstatee
) {
details.push(
`${reference.nomElement} : ${reference.etat}/${reference.quantiteConstatee} -> ${
element?.etat ?? 'ABSENT'
}/${element?.quantiteConstatee ?? 0}`,
);
}
}
return { conforme: details.length === 0, auMoinsUnDeteriore, details };
return 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,
@@ -142,41 +238,38 @@ export async function restituerEmprunt(
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 references = checklistDepart.elements.map((element) => ({
accessoireId: element.accessoireId,
nomElement: element.nomElement,
etat: element.etat,
quantiteConstatee: element.quantiteConstatee,
}));
const elements = normaliserChecklist(references, request.elements, 'retour');
const differences = comparerEtats(references, elements);
const statutEmprunt: StatutEmprunt =
differences.length === 0 ? 'CLOTURE' : 'EN_ATTENTE_VALIDATION_RETOUR';
const responsables =
differences.length > 0 ? await findResponsablesParCampus(emprunt.campusId) : [];
const descriptionEcart =
differences.length > 0
? `Changement au retour pour l'emprunt #${emprunt.id} : ${differences.join(', ')}.`
: undefined;
const donnees: RestituerEmpruntData = {
empruntId,
utilisateurId,
materielId: emprunt.materielId,
statutEmprunt,
statutMateriel,
statutMateriel: differences.length === 0 ? 'DISPONIBLE' : 'EMPRUNTE',
modeIdentification: request.modeIdentification,
commentaireRetour: request.commentaireRetour,
elements: request.elements,
elements,
campusId: emprunt.campusId,
sallePretId: emprunt.sallePretId,
posteEmpruntId: emprunt.posteEmpruntId,
responsablesIds: responsables.map((responsable) => responsable.id),
descriptionEcart,
};
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);
}
@@ -5,7 +5,10 @@ import {
countEmpruntsParStatut,
countMaterielsParStatut,
countNotificationsNonLues,
deciderEcartResponsable,
findActiviteRecente,
findEcartResponsableById,
findEcartsResponsable,
findAnomalieResponsableById,
findAnomaliesResponsable,
findEmpruntsResponsable,
@@ -21,6 +24,7 @@ import {
HistoriqueResponsable,
MaterielResponsable,
NotificationResponsable,
EcartResponsable,
} from '../repositories/responsable.repository';
import {
STATUT_ANOMALIE,
@@ -72,6 +76,20 @@ export interface ChangerStatutAnomalieResponsableRequest {
observation?: string;
}
export interface DeciderEcartResponsableRequest {
decision: 'CONFIRMER' | 'REFUSER';
observation?: string;
statutMateriel?: StatutMateriel;
}
const STATUTS_MATERIEL_RETOUR_CONFIRMES: readonly StatutMateriel[] = [
'DISPONIBLE',
'NON_CONFORME',
'DETERIORE',
'MAINTENANCE',
'INDISPONIBLE',
];
const TRANSITIONS_ANOMALIE: Record<StatutAnomalie, StatutAnomalie[]> = {
DETECTEE: ['EN_COURS_TRAITEMENT'],
EN_COURS_TRAITEMENT: ['RESOLUE'],
@@ -180,6 +198,19 @@ export function parseAnomalieId(value: unknown): number {
return parsed;
}
export function parseEmpruntId(value: unknown): number {
if (typeof value !== 'string') {
throw new AppError(400, 'empruntId invalide');
}
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new AppError(400, 'empruntId invalide');
}
return parsed;
}
export function parsePositiveIntQuery(value: unknown, champ: string): number | undefined {
if (value === undefined) {
return undefined;
@@ -234,6 +265,101 @@ export function listerEmpruntsResponsable(
return findEmpruntsResponsable(campusId, filtres);
}
export function listerEcartsResponsable(
roleCode: string,
campusId: number,
): Promise<EcartResponsable[]> {
verifierResponsable(roleCode);
return findEcartsResponsable(campusId);
}
function decrireChecklist(ecart: EcartResponsable, type: 'DEPART' | 'RETOUR'): string {
const checklist = ecart.checklists.find((item) => item.type === type);
if (!checklist) {
throw new AppError(409, `Checklist de ${type.toLocaleLowerCase()} introuvable`);
}
return checklist.elements
.map((element) => `${element.nomElement}=${element.etat}/${element.quantiteConstatee}`)
.join(', ');
}
export async function deciderEcartResponsableService(
responsableId: number,
roleCode: string,
campusId: number,
empruntId: number,
request: DeciderEcartResponsableRequest,
): Promise<EcartResponsable> {
verifierResponsable(roleCode);
const ecart = await findEcartResponsableById(campusId, empruntId);
if (!ecart) {
throw new AppError(404, 'Ecart en attente introuvable');
}
const typeEcart = ecart.statut === 'EN_ATTENTE_VALIDATION_DEPART' ? 'DEPART' : 'RETOUR';
if (
typeEcart === 'RETOUR' &&
request.decision === 'CONFIRMER' &&
(!request.statutMateriel || !STATUTS_MATERIEL_RETOUR_CONFIRMES.includes(request.statutMateriel))
) {
throw new AppError(400, 'statutMateriel final requis pour confirmer le retour');
}
const description =
`Ecart ${typeEcart.toLocaleLowerCase()} ${request.decision.toLocaleLowerCase()} ` +
`pour l'emprunt #${ecart.id} : ${decrireChecklist(ecart, typeEcart)}.`;
let elementsDepartCorriges: Array<{ id: number; quantiteConstatee: number }> | undefined;
if (typeEcart === 'DEPART' && request.decision === 'REFUSER') {
const checklistDepart = ecart.checklists.find((checklist) => checklist.type === 'DEPART');
if (!checklistDepart) {
throw new AppError(409, 'Checklist de depart introuvable');
}
elementsDepartCorriges = checklistDepart.elements.map((element) => {
const accessoire = ecart.materiel.accessoires.find(
(liaison) => liaison.accessoireId === element.accessoireId,
);
return {
id: element.id,
quantiteConstatee: accessoire?.quantiteAttendue ?? 1,
};
});
}
try {
return await deciderEcartResponsable({
empruntId,
campusId,
responsableId,
statutAttendu: ecart.statut,
statutEmpruntFinal:
typeEcart === 'DEPART'
? 'EN_COURS'
: request.decision === 'CONFIRMER'
? 'RETOUR_NON_CONFORME'
: 'CLOTURE',
statutMaterielFinal:
typeEcart === 'DEPART'
? 'EMPRUNTE'
: request.decision === 'CONFIRMER'
? (request.statutMateriel as StatutMateriel)
: 'DISPONIBLE',
typeEcart,
decision: request.decision,
description,
observation: request.observation,
elementsDepartCorriges,
});
} catch (error) {
if (error instanceof Error && error.message === 'ECART_DEJA_TRAITE') {
throw new AppError(409, 'Cet ecart a deja ete traite');
}
throw error;
}
}
export function listerMaterielsResponsable(
roleCode: string,
campusId: number,