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.