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,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 } },
},
});
});
}