feat(loans): confirm declared material state changes
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user