Corrections sur les jobs En retard
This commit is contained in:
+134
-20
@@ -14,29 +14,40 @@ function writeLog({ status, nbChanges, details, errorMsg }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Insère une notification utilisateur (best-effort, ne bloque jamais le job). */
|
||||||
|
function notifyUser(userId, { type, title, body, link }) {
|
||||||
|
try {
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO notifications (user_id, type, title, body, link)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
`).run(userId, type, title, body ?? null, link ?? null);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[autoStatut] Impossible de créer la notification :', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Passe automatiquement au statut "en_retard" les investissements dont :
|
* Passe automatiquement au statut "en_retard" les investissements dont :
|
||||||
* - le statut est actuellement "en_cours"
|
* - le statut est actuellement "en_cours"
|
||||||
* - la date_cible est renseignée et strictement antérieure à aujourd'hui
|
* - la date_cible est renseignée et strictement antérieure à aujourd'hui
|
||||||
*
|
*
|
||||||
* Chaque passage est tracé dans investissement_historique avec le
|
* Chaque passage est tracé dans investissement_historique avec le
|
||||||
* type_evenement 'passage_auto_retard' pour conserver l'auditabilité.
|
* type_evenement 'passage_auto_retard' pour conserver l'auditabilité, et
|
||||||
|
* génère une notification utilisateur.
|
||||||
*
|
*
|
||||||
* @returns {number} nombre d'investissements mis à jour
|
* @returns {number} nombre d'investissements mis à jour
|
||||||
*/
|
*/
|
||||||
export function checkStatutsRetard() {
|
function checkPassagesEnRetard() {
|
||||||
const candidats = db.prepare(`
|
const candidats = db.prepare(`
|
||||||
SELECT id, nom_projet, date_cible
|
SELECT i.id, i.nom_projet, i.date_cible, inv.user_id
|
||||||
FROM investissements
|
FROM investissements i
|
||||||
WHERE statut = 'en_cours'
|
JOIN investisseurs inv ON inv.id = i.investisseur_id
|
||||||
AND date_cible IS NOT NULL
|
WHERE i.statut = 'en_cours'
|
||||||
AND date_cible < date('now')
|
AND i.date_cible IS NOT NULL
|
||||||
|
AND i.date_cible < date('now')
|
||||||
`).all();
|
`).all();
|
||||||
|
|
||||||
if (candidats.length === 0) {
|
if (candidats.length === 0) return { nb: 0, details: [] };
|
||||||
writeLog({ status: 'ok', nbChanges: 0, details: 'Aucun investissement en retard détecté' });
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateStmt = db.prepare(`
|
const updateStmt = db.prepare(`
|
||||||
UPDATE investissements
|
UPDATE investissements
|
||||||
@@ -63,22 +74,125 @@ export function checkStatutsRetard() {
|
|||||||
}]),
|
}]),
|
||||||
`Passage automatique : date cible (${inv.date_cible}) dépassée`
|
`Passage automatique : date cible (${inv.date_cible}) dépassée`
|
||||||
);
|
);
|
||||||
|
notifyUser(inv.user_id, {
|
||||||
|
type: 'warning',
|
||||||
|
title: `Prêt en retard : "${inv.nom_projet}"`,
|
||||||
|
body: `La date cible (${inv.date_cible}) est dépassée sans remboursement enregistré. Le prêt est passé automatiquement au statut "En retard".`,
|
||||||
|
link: `/investissements/${inv.id}`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
tx();
|
tx();
|
||||||
|
|
||||||
const details = candidats
|
return {
|
||||||
.map(i => `"${i.nom_projet}" (id=${i.id}, date_cible=${i.date_cible})`)
|
nb: candidats.length,
|
||||||
.join('; ');
|
details: candidats.map(i => `"${i.nom_projet}" (id=${i.id}, date_cible=${i.date_cible})`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
writeLog({
|
/**
|
||||||
status: 'ok',
|
* Repasse automatiquement au statut "en_cours" les investissements dont :
|
||||||
nbChanges: candidats.length,
|
* - le statut est actuellement "en_retard"
|
||||||
details: `Passé en retard : ${details}`,
|
* - la date_cible est désormais renseignée à une échéance future plausible
|
||||||
|
* (entre aujourd'hui et +30 ans — cette borne haute évite qu'une date encore
|
||||||
|
* aberrante, ex. année 2400 non corrigée, ne soit prise à tort pour une date
|
||||||
|
* valide simplement parce qu'elle est "dans le futur")
|
||||||
|
* - le dernier événement ayant modifié le statut est bien le passage automatique
|
||||||
|
* en retard ('passage_auto_retard') — un passage en retard décidé manuellement
|
||||||
|
* par l'utilisateur n'est jamais annulé automatiquement.
|
||||||
|
*
|
||||||
|
* Typiquement déclenché après une correction de date_cible (ex : bouton
|
||||||
|
* "Corriger les dates des prêts différés" dans Nettoyage) qui repousse
|
||||||
|
* l'échéance dans le futur.
|
||||||
|
*
|
||||||
|
* @returns {number} nombre d'investissements mis à jour
|
||||||
|
*/
|
||||||
|
function checkRetoursEnCours() {
|
||||||
|
const candidats = db.prepare(`
|
||||||
|
SELECT i.id, i.nom_projet, i.date_cible, inv.user_id
|
||||||
|
FROM investissements i
|
||||||
|
JOIN investisseurs inv ON inv.id = i.investisseur_id
|
||||||
|
WHERE i.statut = 'en_retard'
|
||||||
|
AND i.date_cible IS NOT NULL
|
||||||
|
AND i.date_cible >= date('now')
|
||||||
|
AND i.date_cible <= date('now', '+30 years')
|
||||||
|
AND (
|
||||||
|
SELECT h.type_evenement FROM investissement_historique h
|
||||||
|
WHERE h.investissement_id = i.id
|
||||||
|
AND h.changements LIKE '%"champ":"statut"%'
|
||||||
|
ORDER BY h.created_at DESC, h.id DESC
|
||||||
|
LIMIT 1
|
||||||
|
) = 'passage_auto_retard'
|
||||||
|
`).all();
|
||||||
|
|
||||||
|
if (candidats.length === 0) return { nb: 0, details: [] };
|
||||||
|
|
||||||
|
const updateStmt = db.prepare(`
|
||||||
|
UPDATE investissements
|
||||||
|
SET statut = 'en_cours', updated_at = datetime('now')
|
||||||
|
WHERE id = ?
|
||||||
|
`);
|
||||||
|
|
||||||
|
const histStmt = db.prepare(`
|
||||||
|
INSERT INTO investissement_historique
|
||||||
|
(investissement_id, type_evenement, changements, notes)
|
||||||
|
VALUES (?, 'retour_auto_en_cours', ?, ?)
|
||||||
|
`);
|
||||||
|
|
||||||
|
const tx = db.transaction(() => {
|
||||||
|
for (const inv of candidats) {
|
||||||
|
updateStmt.run(inv.id);
|
||||||
|
histStmt.run(
|
||||||
|
inv.id,
|
||||||
|
JSON.stringify([{
|
||||||
|
champ: 'statut',
|
||||||
|
label: 'Statut',
|
||||||
|
ancienne_valeur: 'en_retard',
|
||||||
|
nouvelle_valeur: 'en_cours',
|
||||||
|
}]),
|
||||||
|
`Retour automatique : date cible (${inv.date_cible}) désormais future`
|
||||||
|
);
|
||||||
|
notifyUser(inv.user_id, {
|
||||||
|
type: 'success',
|
||||||
|
title: `Prêt de nouveau en cours : "${inv.nom_projet}"`,
|
||||||
|
body: `La date cible a été corrigée (${inv.date_cible}, désormais future). Le prêt repasse automatiquement au statut "En cours".`,
|
||||||
|
link: `/investissements/${inv.id}`,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
tx();
|
||||||
|
|
||||||
console.log(`[autoStatut] ${candidats.length} investissement(s) passé(s) en retard : ${details}`);
|
return {
|
||||||
return candidats.length;
|
nb: candidats.length,
|
||||||
|
details: candidats.map(i => `"${i.nom_projet}" (id=${i.id}, date_cible=${i.date_cible})`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie et applique les transitions automatiques de statut liées à la date_cible :
|
||||||
|
* en_cours → en_retard (échéance dépassée) et en_retard → en_cours (échéance corrigée
|
||||||
|
* dans le futur, uniquement si le passage en retard était lui-même automatique).
|
||||||
|
*
|
||||||
|
* @returns {number} nombre total d'investissements mis à jour
|
||||||
|
*/
|
||||||
|
export function checkStatutsRetard() {
|
||||||
|
const retards = checkPassagesEnRetard();
|
||||||
|
const retours = checkRetoursEnCours();
|
||||||
|
|
||||||
|
const nbChanges = retards.nb + retours.nb;
|
||||||
|
if (nbChanges === 0) {
|
||||||
|
writeLog({ status: 'ok', nbChanges: 0, details: 'Aucun changement de statut détecté' });
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = [];
|
||||||
|
if (retards.nb > 0) parts.push(`Passé en retard : ${retards.details.join('; ')}`);
|
||||||
|
if (retours.nb > 0) parts.push(`Repassé en cours : ${retours.details.join('; ')}`);
|
||||||
|
const details = parts.join(' | ');
|
||||||
|
|
||||||
|
writeLog({ status: 'ok', nbChanges, details });
|
||||||
|
console.log(`[autoStatut] ${nbChanges} investissement(s) mis à jour : ${details}`);
|
||||||
|
return nbChanges;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import db from '../db/index.js';
|
|||||||
import { HttpError } from '../middleware/errorHandler.js';
|
import { HttpError } from '../middleware/errorHandler.js';
|
||||||
import { requireInvestisseur } from '../middleware/investisseurScope.js';
|
import { requireInvestisseur } from '../middleware/investisseurScope.js';
|
||||||
import { generateSimul, generateSimulWithReinvestissements } from '../utils/schedule.js';
|
import { generateSimul, generateSimulWithReinvestissements } from '../utils/schedule.js';
|
||||||
|
import { recordHistory, detectChangements, detectTypeEvenement } from './investissements.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -417,7 +418,7 @@ router.post('/dossier', (req, res, next) => {
|
|||||||
|
|
||||||
/* ── 2. Chercher l'investissement existant (clé naturelle) */
|
/* ── 2. Chercher l'investissement existant (clé naturelle) */
|
||||||
const existing = db.prepare(`
|
const existing = db.prepare(`
|
||||||
SELECT id FROM investissements
|
SELECT * FROM investissements
|
||||||
WHERE investisseur_id = ? AND nom_projet = ? AND date_souscription = ?
|
WHERE investisseur_id = ? AND nom_projet = ? AND date_souscription = ?
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`).get(req.investisseur.id, inv.nom_projet, inv.date_souscription);
|
`).get(req.investisseur.id, inv.nom_projet, inv.date_souscription);
|
||||||
@@ -499,6 +500,20 @@ router.post('/dossier', (req, res, next) => {
|
|||||||
} else {
|
} else {
|
||||||
/* ────────────── SCÉNARIO UPDATE ──────────────────────── */
|
/* ────────────── SCÉNARIO UPDATE ──────────────────────── */
|
||||||
investissementId = existing.id;
|
investissementId = existing.id;
|
||||||
|
|
||||||
|
const nouveau = {
|
||||||
|
plateforme_id: plateformeId,
|
||||||
|
date_premiere_echeance: inv.date_premiere_echeance || null,
|
||||||
|
date_cible: inv.date_cible || null,
|
||||||
|
date_debut_simul: inv.date_debut_simul || null,
|
||||||
|
montant_investi: Number(inv.montant_investi),
|
||||||
|
taux_interet: inv.taux_interet ?? null,
|
||||||
|
duree_mois: inv.duree_mois ?? null,
|
||||||
|
type_remb: inv.type_remb || 'in_fine',
|
||||||
|
freq_interets: inv.freq_interets || 'mensuel',
|
||||||
|
statut: inv.statut || 'en_cours',
|
||||||
|
};
|
||||||
|
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
UPDATE investissements SET
|
UPDATE investissements SET
|
||||||
plateforme_id = ?, emetteur = ?,
|
plateforme_id = ?, emetteur = ?,
|
||||||
@@ -508,11 +523,10 @@ router.post('/dossier', (req, res, next) => {
|
|||||||
reference = ?, notes = ?
|
reference = ?, notes = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`).run(
|
`).run(
|
||||||
plateformeId, inv.emetteur || null,
|
nouveau.plateforme_id, inv.emetteur || null,
|
||||||
inv.date_premiere_echeance || null, inv.date_cible || null, inv.date_debut_simul || null,
|
nouveau.date_premiere_echeance, nouveau.date_cible, nouveau.date_debut_simul,
|
||||||
Number(inv.montant_investi), inv.taux_interet ?? null, inv.duree_mois ?? null,
|
nouveau.montant_investi, nouveau.taux_interet, nouveau.duree_mois,
|
||||||
inv.type_remb || 'in_fine', inv.freq_interets || 'mensuel',
|
nouveau.type_remb, nouveau.freq_interets, nouveau.statut,
|
||||||
inv.statut || 'en_cours',
|
|
||||||
inv.reference || null, inv.notes || null,
|
inv.reference || null, inv.notes || null,
|
||||||
investissementId,
|
investissementId,
|
||||||
);
|
);
|
||||||
@@ -565,15 +579,29 @@ router.post('/dossier', (req, res, next) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Entrée d'historique de la mise à jour
|
// Entrée d'historique de la mise à jour — diff précis champ par champ
|
||||||
db.prepare(`
|
// (même logique que l'édition manuelle, pour que la date_cible/date_premiere_echeance
|
||||||
INSERT INTO investissement_historique (investissement_id, type_evenement, changements)
|
// etc. écrasées par ce ré-import restent traçables dans l'historique du prêt).
|
||||||
VALUES (?, 'import', ?)
|
const changements = detectChangements(existing, nouveau);
|
||||||
`).run(investissementId, JSON.stringify([{
|
const notesImport = `Import dossier (${dossier.exported_at || 'date export inconnue'}) — ` +
|
||||||
|
`${rembInserted} remb. ajouté(s), ${reinvInserted} réinvest. ajouté(s)`;
|
||||||
|
|
||||||
|
if (changements.length > 0) {
|
||||||
|
recordHistory(investissementId, {
|
||||||
|
type_evenement: detectTypeEvenement(changements),
|
||||||
|
changements,
|
||||||
|
notes: notesImport,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
recordHistory(investissementId, {
|
||||||
|
type_evenement: 'import',
|
||||||
|
changements: [{
|
||||||
champ: 'import', label: 'Mise à jour dossier',
|
champ: 'import', label: 'Mise à jour dossier',
|
||||||
ancienne_valeur: null,
|
ancienne_valeur: null,
|
||||||
nouvelle_valeur: `${dossier.exported_at || 'inconnu'} — ${rembInserted} remb. ajouté(s), ${reinvInserted} réinvest. ajouté(s)`,
|
nouvelle_valeur: notesImport,
|
||||||
}]));
|
}],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
action = 'updated';
|
action = 'updated';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { generateSimul } from '../utils/schedule.js';
|
|||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
const TRACKED_FIELDS = [
|
export const TRACKED_FIELDS = [
|
||||||
{ key: 'type_remb', label: 'Type de prêt' },
|
{ key: 'type_remb', label: 'Type de prêt' },
|
||||||
{ key: 'taux_interet', label: 'Taux annuel (%)' },
|
{ key: 'taux_interet', label: 'Taux annuel (%)' },
|
||||||
{ key: 'duree_mois', label: 'Durée (mois)' },
|
{ key: 'duree_mois', label: 'Durée (mois)' },
|
||||||
@@ -20,7 +20,7 @@ const TRACKED_FIELDS = [
|
|||||||
{ key: 'plateforme_id', label: 'Plateforme' },
|
{ key: 'plateforme_id', label: 'Plateforme' },
|
||||||
];
|
];
|
||||||
|
|
||||||
function recordHistory(investissementId, { type_evenement, changements, notes }) {
|
export function recordHistory(investissementId, { type_evenement, changements, notes }) {
|
||||||
if (!changements || changements.length === 0) return;
|
if (!changements || changements.length === 0) return;
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
INSERT INTO investissement_historique (investissement_id, type_evenement, changements, notes)
|
INSERT INTO investissement_historique (investissement_id, type_evenement, changements, notes)
|
||||||
@@ -28,7 +28,7 @@ function recordHistory(investissementId, { type_evenement, changements, notes })
|
|||||||
`).run(investissementId, type_evenement, JSON.stringify(changements), notes || null);
|
`).run(investissementId, type_evenement, JSON.stringify(changements), notes || null);
|
||||||
}
|
}
|
||||||
|
|
||||||
function detectChangements(ancien, nouveau) {
|
export function detectChangements(ancien, nouveau) {
|
||||||
const diffs = [];
|
const diffs = [];
|
||||||
for (const { key, label } of TRACKED_FIELDS) {
|
for (const { key, label } of TRACKED_FIELDS) {
|
||||||
const av = ancien[key] ?? null;
|
const av = ancien[key] ?? null;
|
||||||
@@ -42,7 +42,7 @@ function detectChangements(ancien, nouveau) {
|
|||||||
return diffs;
|
return diffs;
|
||||||
}
|
}
|
||||||
|
|
||||||
function detectTypeEvenement(changements) {
|
export function detectTypeEvenement(changements) {
|
||||||
const champsRestructuration = ['type_remb', 'date_debut_simul'];
|
const champsRestructuration = ['type_remb', 'date_debut_simul'];
|
||||||
if (changements.some(c => champsRestructuration.includes(c.champ))) return 'restructuration';
|
if (changements.some(c => champsRestructuration.includes(c.champ))) return 'restructuration';
|
||||||
return 'modification';
|
return 'modification';
|
||||||
@@ -180,7 +180,9 @@ router.post('/fix-differe-dates', (req, res, next) => {
|
|||||||
try {
|
try {
|
||||||
const rows = db.prepare(`
|
const rows = db.prepare(`
|
||||||
SELECT i.id, i.nom_projet, i.date_souscription, i.duree_mois,
|
SELECT i.id, i.nom_projet, i.date_souscription, i.duree_mois,
|
||||||
i.date_premiere_echeance, i.date_cible
|
i.date_premiere_echeance, i.date_cible,
|
||||||
|
i.montant_investi, i.taux_interet, i.type_remb, i.freq_interets,
|
||||||
|
i.date_debut_simul, i.echeance_fin_de_mois
|
||||||
FROM investissements i
|
FROM investissements i
|
||||||
JOIN investisseurs inv ON inv.id = i.investisseur_id AND inv.user_id = ?
|
JOIN investisseurs inv ON inv.id = i.investisseur_id AND inv.user_id = ?
|
||||||
WHERE i.type_remb = 'differe'
|
WHERE i.type_remb = 'differe'
|
||||||
@@ -222,6 +224,45 @@ router.post('/fix-differe-dates', (req, res, next) => {
|
|||||||
|
|
||||||
if (incoherent) {
|
if (incoherent) {
|
||||||
stmt.run(dateCalculee, dateCalculee, inv.id);
|
stmt.run(dateCalculee, dateCalculee, inv.id);
|
||||||
|
|
||||||
|
const changements = [];
|
||||||
|
if (String(inv.date_premiere_echeance ?? null) !== String(dateCalculee)) {
|
||||||
|
changements.push({
|
||||||
|
champ: 'date_premiere_echeance',
|
||||||
|
label: 'Date 1ère échéance',
|
||||||
|
ancienne_valeur: inv.date_premiere_echeance,
|
||||||
|
nouvelle_valeur: dateCalculee,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (String(inv.date_cible ?? null) !== String(dateCalculee)) {
|
||||||
|
changements.push({
|
||||||
|
champ: 'date_cible',
|
||||||
|
label: 'Date cible',
|
||||||
|
ancienne_valeur: inv.date_cible,
|
||||||
|
nouvelle_valeur: dateCalculee,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
recordHistory(inv.id, {
|
||||||
|
type_evenement: 'correction_auto_dates',
|
||||||
|
changements,
|
||||||
|
notes: 'Correction automatique (Nettoyage > Corriger les dates des prêts différés) : écart > 2 ans avec date_souscription + duree_mois',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Régénère l'échéancier de projection avec la date corrigée — sans ça,
|
||||||
|
// simul_remboursements reste calé sur l'ancienne date aberrante.
|
||||||
|
generateSimul(db, {
|
||||||
|
id: inv.id,
|
||||||
|
montant_investi: inv.montant_investi,
|
||||||
|
taux_interet: inv.taux_interet,
|
||||||
|
duree_mois: inv.duree_mois,
|
||||||
|
type_remb: inv.type_remb,
|
||||||
|
freq_interets: inv.freq_interets,
|
||||||
|
date_premiere_echeance: dateCalculee,
|
||||||
|
date_debut_simul: inv.date_debut_simul,
|
||||||
|
date_souscription: inv.date_souscription,
|
||||||
|
echeance_fin_de_mois: inv.echeance_fin_de_mois ?? 0,
|
||||||
|
});
|
||||||
|
|
||||||
corriges.push({
|
corriges.push({
|
||||||
id: inv.id,
|
id: inv.id,
|
||||||
nom_projet: inv.nom_projet,
|
nom_projet: inv.nom_projet,
|
||||||
|
|||||||
@@ -209,8 +209,8 @@ export default function DataCleanupSection() {
|
|||||||
</p>
|
</p>
|
||||||
<p style={{ margin: '0 0 20px', lineHeight: 1.6 }} className="text-muted">
|
<p style={{ margin: '0 0 20px', lineHeight: 1.6 }} className="text-muted">
|
||||||
La correction ne s'applique que si l'écart entre la date existante et la date calculée
|
La correction ne s'applique que si l'écart entre la date existante et la date calculée
|
||||||
dépasse <strong>2 ans</strong>. Les simulations de remboursement associées ne sont pas
|
dépasse <strong>2 ans</strong>. L'échéancier de projection (simulation de remboursement)
|
||||||
recalculées automatiquement.
|
est régénéré automatiquement avec la date corrigée.
|
||||||
</p>
|
</p>
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||||
<button onClick={() => setShowDiffereModal(false)} disabled={loadingDiffere}>Annuler</button>
|
<button onClick={() => setShowDiffereModal(false)} disabled={loadingDiffere}>Annuler</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user