diff --git a/backend/src/db/index.js b/backend/src/db/index.js index 6ade72d..4b48834 100644 --- a/backend/src/db/index.js +++ b/backend/src/db/index.js @@ -227,27 +227,45 @@ if (rembCols.includes('autres_taxes')) { } // ── Migration : renommage date_debut → date_premiere_echeance, date_echeance → date_cible ── +// +// ATTENTION (bug corrigé le 12/07/2026) : le bloc de "correction de formule" ci-dessous +// (SET date_cible = date_premiere_echeance + (duree_mois-1) mois, SANS clause IS NULL) +// s'exécutait auparavant à CHAQUE démarrage du serveur, pour TOUS les investissements, +// quel que soit type_remb. Combiné à la migration suivante (qui recopie date_cible dans +// date_premiere_echeance pour les prêts différés), cela créait une boucle : à chaque +// redémarrage, date_cible dérivait de +(duree_mois-1) mois supplémentaires, sans jamais +// être tracé dans investissement_historique. C'est la cause des dates aberrantes (parfois +// des siècles dans le futur) observées sur les prêts différés. La correction ne doit +// s'exécuter qu'UNE SEULE FOIS, au moment réel du renommage de colonne — jamais après. { const cols = db.prepare('PRAGMA table_info(investissements)').all().map(c => c.name); + const migrationEnCours = cols.includes('date_debut') || cols.includes('date_echeance'); + if (cols.includes('date_debut')) { db.exec('ALTER TABLE investissements RENAME COLUMN date_debut TO date_premiere_echeance'); } if (cols.includes('date_echeance')) { db.exec('ALTER TABLE investissements RENAME COLUMN date_echeance TO date_cible'); } - // Backfill date_premiere_echeance = souscription + 1 mois si vide + // Backfill date_premiere_echeance = souscription + 1 mois si vide (idempotent : ne touche + // jamais une valeur déjà renseignée) db.exec(`UPDATE investissements SET date_premiere_echeance = date(date_souscription, '+1 month') WHERE date_premiere_echeance IS NULL AND date_souscription IS NOT NULL`); - // Backfill date_cible = date_premiere_echeance + (duree_mois - 1) mois si vide + // Backfill date_cible = date_premiere_echeance + (duree_mois - 1) mois si vide (idempotent) // Formule cohérente avec la simulation : échéance 1 = date_premiere_echeance, dernière = +duree-1 mois db.exec(`UPDATE investissements SET date_cible = date(date_premiere_echeance, '+' || (duree_mois - 1) || ' months') WHERE date_cible IS NULL AND date_premiere_echeance IS NOT NULL AND duree_mois IS NOT NULL`); - // Correction des date_cible déjà calculées avec l'ancienne formule (+duree_mois au lieu de +duree_mois-1) - db.exec(`UPDATE investissements - SET date_cible = date(date_premiere_echeance, '+' || (duree_mois - 1) || ' months') - WHERE date_premiere_echeance IS NOT NULL AND duree_mois IS NOT NULL`); + + // Correction ponctuelle des date_cible déjà calculées avec l'ancienne formule + // (+duree_mois au lieu de +duree_mois-1) — ne s'exécute que le jour du renommage effectif + // des colonnes (migrationEnCours), jamais à chaque démarrage. + if (migrationEnCours) { + db.exec(`UPDATE investissements + SET date_cible = date(date_premiere_echeance, '+' || (duree_mois - 1) || ' months') + WHERE date_premiere_echeance IS NOT NULL AND duree_mois IS NOT NULL`); + } } // ── Migration : type_remb 'mensuel' → 'amortissable' + ajout freq_interets ── @@ -275,19 +293,37 @@ if (!rembCols2.includes('interets_nets')) { // ── Migration : prêts différés — date_premiere_echeance doit égaler date_cible ── // (versement unique à l'échéance : les deux dates sont identiques) { - const needsFix = db.prepare(` - SELECT COUNT(*) AS n FROM investissements + const aCorriger = db.prepare(` + SELECT id, nom_projet, date_premiere_echeance, date_cible + FROM investissements WHERE type_remb = 'differe' AND date_cible IS NOT NULL AND (date_premiere_echeance IS NULL OR date_premiere_echeance != date_cible) - `).get().n; + `).all(); - if (needsFix > 0) { - db.exec(` - UPDATE investissements - SET date_premiere_echeance = date_cible - WHERE type_remb = 'differe' AND date_cible IS NOT NULL + if (aCorriger.length > 0) { + const updateStmt = db.prepare(` + UPDATE investissements SET date_premiere_echeance = date_cible WHERE id = ? `); + const histStmt = db.prepare(` + INSERT INTO investissement_historique (investissement_id, type_evenement, changements, notes) + VALUES (?, 'correction_auto_echeancier', ?, ?) + `); + + for (const inv of aCorriger) { + updateStmt.run(inv.id); + histStmt.run( + inv.id, + JSON.stringify([{ + champ: 'date_premiere_echeance', + label: 'Date 1ère échéance', + ancienne_valeur: inv.date_premiere_echeance, + nouvelle_valeur: inv.date_cible, + }]), + "Migration au démarrage : synchronisation avec date_cible (prêt différé — les deux dates doivent être identiques)", + ); + } + // Régénère les simulations avec la nouvelle logique (date = startDate directement) const differeInvs = db.prepare(` SELECT id, montant_investi, taux_interet, duree_mois, type_remb, freq_interets, diff --git a/backend/src/routes/investissements.js b/backend/src/routes/investissements.js index 2a7ed72..e23faa3 100644 --- a/backend/src/routes/investissements.js +++ b/backend/src/routes/investissements.js @@ -3,7 +3,8 @@ import { z } from 'zod'; import db from '../db/index.js'; import { HttpError } from '../middleware/errorHandler.js'; import { requireInvestisseur } from '../middleware/investisseurScope.js'; -import { generateSimul } from '../utils/schedule.js'; +import { generateSimul, generateSimulWithReinvestissements } from '../utils/schedule.js'; +import { checkStatutsRetard } from '../jobs/autoStatut.js'; const router = Router(); @@ -275,6 +276,86 @@ router.post('/fix-differe-dates', (req, res, next) => { } } + // Une date corrigée peut désormais être passée (prêt qui bascule en retard) ou au + // contraire future (prêt qui en sort) — on relance immédiatement la vérification de + // statut plutôt que d'attendre le prochain passage du job (minuit / redémarrage serveur), + // sinon le prêt affiche un statut incohérent avec sa date jusqu'au lendemain. + const statutsMisAJour = corriges.length > 0 ? checkStatutsRetard() : 0; + + res.json({ updated: corriges.length, detail: corriges, statutsMisAJour }); + } catch (err) { + next(err); + } +}); + +// POST /api/investissements/check-echeancier-differe +// Vérifie, pour chaque prêt différé, que l'échéancier de projection (simul_remboursements) +// contient bien une unique échéance dont la date correspond à date_premiere_echeance +// (== date_cible pour un prêt différé). Régénère l'échéancier sinon — utile après un +// import, une restauration, ou tout chemin ayant modifié les dates sans régénérer la +// simulation associée (cf. bouton "Corriger les dates des prêts différés" ci-dessus, +// qui ne couvre que l'écart date_souscription + durée > 2 ans). +router.post('/check-echeancier-differe', (req, res, next) => { + try { + const rows = db.prepare(` + SELECT i.id, i.nom_projet, i.date_premiere_echeance, i.date_cible, + i.montant_investi, i.taux_interet, i.duree_mois, i.type_remb, i.freq_interets, + i.date_debut_simul, i.date_souscription, 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' + AND i.date_premiere_echeance IS NOT NULL + AND i.taux_interet IS NOT NULL + AND i.duree_mois IS NOT NULL + `).all(req.user.id); + + const getSimul = db.prepare(` + SELECT id, numero_echeance, date_prevue + FROM simul_remboursements + WHERE investissement_id = ? + ORDER BY numero_echeance + `); + const hasReinvest = db.prepare('SELECT 1 FROM reinvestissements WHERE investissement_id = ? LIMIT 1'); + + const corriges = []; + + for (const inv of rows) { + const simulRows = getSimul.all(inv.id); + const incoherent = + simulRows.length !== 1 || + simulRows[0].date_prevue !== inv.date_premiere_echeance; + + if (!incoherent) continue; + + const ancienEcheancier = simulRows.length === 0 + ? 'aucune échéance' + : simulRows.map(s => `${s.date_prevue} (n°${s.numero_echeance})`).join(', '); + + if (hasReinvest.get(inv.id)) { + generateSimulWithReinvestissements(db, inv.id); + } else { + generateSimul(db, inv); + } + + recordHistory(inv.id, { + type_evenement: 'correction_auto_echeancier', + changements: [{ + champ: 'echeancier', + label: 'Échéancier de projection', + ancienne_valeur: ancienEcheancier, + nouvelle_valeur: inv.date_premiere_echeance, + }], + notes: "Correction automatique (Nettoyage > Vérifier la cohérence de l'échéancier des prêts différés)", + }); + + corriges.push({ + id: inv.id, + nom_projet: inv.nom_projet, + ancien_echeancier: ancienEcheancier, + nouvelle_date: inv.date_premiere_echeance, + }); + } + res.json({ updated: corriges.length, detail: corriges }); } catch (err) { next(err); diff --git a/frontend/src/pages/settings/DataCleanupSection.jsx b/frontend/src/pages/settings/DataCleanupSection.jsx index 68db15d..36d40af 100644 --- a/frontend/src/pages/settings/DataCleanupSection.jsx +++ b/frontend/src/pages/settings/DataCleanupSection.jsx @@ -12,6 +12,8 @@ export default function DataCleanupSection() { const [loadingReprocess, setLoadingReprocess] = useState(false); const [showDiffereModal, setShowDiffereModal] = useState(false); const [loadingDiffere, setLoadingDiffere] = useState(false); + const [showEcheancierModal, setShowEcheancierModal] = useState(false); + const [loadingEcheancier, setLoadingEcheancier] = useState(false); const [showBackfillModal, setShowBackfillModal] = useState(false); const [loadingBackfill, setLoadingBackfill] = useState(false); const [successMsg, setSuccessMsg] = useState(null); @@ -54,13 +56,16 @@ export default function DataCleanupSection() { setErrorMsg(null); setSuccessMsg(null); try { - const { updated, detail } = await api.post('/investissements/fix-differe-dates', {}); + const { updated, detail, statutsMisAJour } = await api.post('/investissements/fix-differe-dates', {}); if (updated === 0) { setSuccessMsg('Aucune date incohérente détectée sur les prêts différés.'); } else { + const suffixeStatuts = statutsMisAJour > 0 + ? ` (statut recalculé pour ${statutsMisAJour} prêt${statutsMisAJour > 1 ? 's' : ''} suite à la correction)` + : ''; setSuccessMsg( `${updated} prêt${updated > 1 ? 's' : ''} différé${updated > 1 ? 's' : ''} corrigé${updated > 1 ? 's' : ''} : ` + - detail.map(d => d.nom_projet).join(', ') + '.' + detail.map(d => d.nom_projet).join(', ') + '.' + suffixeStatuts ); } setShowDiffereModal(false); @@ -72,6 +77,29 @@ export default function DataCleanupSection() { } }; + const handleCheckEcheancierDiffere = async () => { + setLoadingEcheancier(true); + setErrorMsg(null); + setSuccessMsg(null); + try { + const { updated, detail } = await api.post('/investissements/check-echeancier-differe', {}); + if (updated === 0) { + setSuccessMsg("Échéancier cohérent pour tous les prêts différés — rien à corriger."); + } else { + setSuccessMsg( + `Échéancier régénéré pour ${updated} prêt${updated > 1 ? 's' : ''} différé${updated > 1 ? 's' : ''} : ` + + detail.map(d => d.nom_projet).join(', ') + '.' + ); + } + setShowEcheancierModal(false); + } catch (err) { + setErrorMsg(err.message || 'Une erreur est survenue.'); + setShowEcheancierModal(false); + } finally { + setLoadingEcheancier(false); + } + }; + const handleBackfillComptes = async () => { setLoadingBackfill(true); setErrorMsg(null); @@ -148,6 +176,30 @@ export default function DataCleanupSection() { +
+
+
+ Vérifier la cohérence de l'échéancier des prêts différés +
+
+ Contrôle que l'échéancier de projection de chaque prêt différé comporte bien une unique + échéance, à la date de 1ère échéance / date cible enregistrée sur le prêt. Régénère + automatiquement l'échéancier en cas d'écart (ex. date corrigée sans mise à jour de la + simulation). +
+
+ +
+
)} + {showEcheancierModal && ( +
setShowEcheancierModal(false)}> +
e.stopPropagation()}> +
+

Vérifier la cohérence de l'échéancier des prêts différés

+
+

+ Pour chaque prêt de type différé, cette opération vérifie que l'échéancier + de projection (simulation de remboursement) contient bien une unique échéance, à la date + enregistrée sur le prêt (1ère échéance / date cible). +

+

+ En cas d'écart (échéance manquante, en double, ou à une date différente de celle du prêt), + l'échéancier est régénéré automatiquement à partir des données actuelles du prêt. +

+
+ + +
+
+
+ )} + {showBackfillModal && (
setShowBackfillModal(false)}>
e.stopPropagation()}>