From ec3c7f7cabd497fb6d8d3ff7873291f0956cb4ed Mon Sep 17 00:00:00 2001
From: Olivier
Date: Sun, 12 Jul 2026 17:39:57 +0200
Subject: [PATCH] Corrections sur les jobs En retard
---
backend/src/jobs/autoStatut.js | 154 +++++++++++++++---
backend/src/routes/imports.js | 58 +++++--
backend/src/routes/investissements.js | 51 +++++-
.../src/pages/settings/DataCleanupSection.jsx | 4 +-
4 files changed, 225 insertions(+), 42 deletions(-)
diff --git a/backend/src/jobs/autoStatut.js b/backend/src/jobs/autoStatut.js
index 4eb0957..77f258d 100644
--- a/backend/src/jobs/autoStatut.js
+++ b/backend/src/jobs/autoStatut.js
@@ -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 :
* - le statut est actuellement "en_cours"
* - la date_cible est renseignée et strictement antérieure à aujourd'hui
*
* 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
*/
-export function checkStatutsRetard() {
+function checkPassagesEnRetard() {
const candidats = db.prepare(`
- SELECT id, nom_projet, date_cible
- FROM investissements
- WHERE statut = 'en_cours'
- AND date_cible IS NOT NULL
- AND date_cible < date('now')
+ 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_cours'
+ AND i.date_cible IS NOT NULL
+ AND i.date_cible < date('now')
`).all();
- if (candidats.length === 0) {
- writeLog({ status: 'ok', nbChanges: 0, details: 'Aucun investissement en retard détecté' });
- return 0;
- }
+ if (candidats.length === 0) return { nb: 0, details: [] };
const updateStmt = db.prepare(`
UPDATE investissements
@@ -63,22 +74,125 @@ export function checkStatutsRetard() {
}]),
`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();
- const details = candidats
- .map(i => `"${i.nom_projet}" (id=${i.id}, date_cible=${i.date_cible})`)
- .join('; ');
+ return {
+ nb: candidats.length,
+ details: candidats.map(i => `"${i.nom_projet}" (id=${i.id}, date_cible=${i.date_cible})`),
+ };
+}
- writeLog({
- status: 'ok',
- nbChanges: candidats.length,
- details: `Passé en retard : ${details}`,
+/**
+ * Repasse automatiquement au statut "en_cours" les investissements dont :
+ * - le statut est actuellement "en_retard"
+ * - 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 candidats.length;
+ return {
+ 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;
}
/**
diff --git a/backend/src/routes/imports.js b/backend/src/routes/imports.js
index dff96b1..c989cf0 100644
--- a/backend/src/routes/imports.js
+++ b/backend/src/routes/imports.js
@@ -7,6 +7,7 @@ import db from '../db/index.js';
import { HttpError } from '../middleware/errorHandler.js';
import { requireInvestisseur } from '../middleware/investisseurScope.js';
import { generateSimul, generateSimulWithReinvestissements } from '../utils/schedule.js';
+import { recordHistory, detectChangements, detectTypeEvenement } from './investissements.js';
const router = Router();
@@ -417,7 +418,7 @@ router.post('/dossier', (req, res, next) => {
/* ── 2. Chercher l'investissement existant (clé naturelle) */
const existing = db.prepare(`
- SELECT id FROM investissements
+ SELECT * FROM investissements
WHERE investisseur_id = ? AND nom_projet = ? AND date_souscription = ?
LIMIT 1
`).get(req.investisseur.id, inv.nom_projet, inv.date_souscription);
@@ -499,6 +500,20 @@ router.post('/dossier', (req, res, next) => {
} else {
/* ────────────── SCÉNARIO UPDATE ──────────────────────── */
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(`
UPDATE investissements SET
plateforme_id = ?, emetteur = ?,
@@ -508,11 +523,10 @@ router.post('/dossier', (req, res, next) => {
reference = ?, notes = ?
WHERE id = ?
`).run(
- plateformeId, inv.emetteur || null,
- inv.date_premiere_echeance || null, inv.date_cible || null, inv.date_debut_simul || null,
- Number(inv.montant_investi), inv.taux_interet ?? null, inv.duree_mois ?? null,
- inv.type_remb || 'in_fine', inv.freq_interets || 'mensuel',
- inv.statut || 'en_cours',
+ nouveau.plateforme_id, inv.emetteur || null,
+ nouveau.date_premiere_echeance, nouveau.date_cible, nouveau.date_debut_simul,
+ nouveau.montant_investi, nouveau.taux_interet, nouveau.duree_mois,
+ nouveau.type_remb, nouveau.freq_interets, nouveau.statut,
inv.reference || null, inv.notes || null,
investissementId,
);
@@ -565,15 +579,29 @@ router.post('/dossier', (req, res, next) => {
}
}
- // Entrée d'historique de la mise à jour
- db.prepare(`
- INSERT INTO investissement_historique (investissement_id, type_evenement, changements)
- VALUES (?, 'import', ?)
- `).run(investissementId, JSON.stringify([{
- champ: 'import', label: 'Mise à jour dossier',
- ancienne_valeur: null,
- nouvelle_valeur: `${dossier.exported_at || 'inconnu'} — ${rembInserted} remb. ajouté(s), ${reinvInserted} réinvest. ajouté(s)`,
- }]));
+ // Entrée d'historique de la mise à jour — diff précis champ par champ
+ // (même logique que l'édition manuelle, pour que la date_cible/date_premiere_echeance
+ // etc. écrasées par ce ré-import restent traçables dans l'historique du prêt).
+ const changements = detectChangements(existing, nouveau);
+ 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',
+ ancienne_valeur: null,
+ nouvelle_valeur: notesImport,
+ }],
+ });
+ }
action = 'updated';
}
diff --git a/backend/src/routes/investissements.js b/backend/src/routes/investissements.js
index d88d8fa..2a7ed72 100644
--- a/backend/src/routes/investissements.js
+++ b/backend/src/routes/investissements.js
@@ -7,7 +7,7 @@ import { generateSimul } from '../utils/schedule.js';
const router = Router();
-const TRACKED_FIELDS = [
+export const TRACKED_FIELDS = [
{ key: 'type_remb', label: 'Type de prêt' },
{ key: 'taux_interet', label: 'Taux annuel (%)' },
{ key: 'duree_mois', label: 'Durée (mois)' },
@@ -20,7 +20,7 @@ const TRACKED_FIELDS = [
{ 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;
db.prepare(`
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);
}
-function detectChangements(ancien, nouveau) {
+export function detectChangements(ancien, nouveau) {
const diffs = [];
for (const { key, label } of TRACKED_FIELDS) {
const av = ancien[key] ?? null;
@@ -42,7 +42,7 @@ function detectChangements(ancien, nouveau) {
return diffs;
}
-function detectTypeEvenement(changements) {
+export function detectTypeEvenement(changements) {
const champsRestructuration = ['type_remb', 'date_debut_simul'];
if (changements.some(c => champsRestructuration.includes(c.champ))) return 'restructuration';
return 'modification';
@@ -180,7 +180,9 @@ router.post('/fix-differe-dates', (req, res, next) => {
try {
const rows = db.prepare(`
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
JOIN investisseurs inv ON inv.id = i.investisseur_id AND inv.user_id = ?
WHERE i.type_remb = 'differe'
@@ -222,6 +224,45 @@ router.post('/fix-differe-dates', (req, res, next) => {
if (incoherent) {
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({
id: inv.id,
nom_projet: inv.nom_projet,
diff --git a/frontend/src/pages/settings/DataCleanupSection.jsx b/frontend/src/pages/settings/DataCleanupSection.jsx
index c13f5ae..68db15d 100644
--- a/frontend/src/pages/settings/DataCleanupSection.jsx
+++ b/frontend/src/pages/settings/DataCleanupSection.jsx
@@ -209,8 +209,8 @@ export default function DataCleanupSection() {
La correction ne s'applique que si l'écart entre la date existante et la date calculée
- dépasse 2 ans. Les simulations de remboursement associées ne sont pas
- recalculées automatiquement.
+ dépasse 2 ans. L'échéancier de projection (simulation de remboursement)
+ est régénéré automatiquement avec la date corrigée.