FixBug: Décalrage de date des prêts différés
This commit is contained in:
+50
-14
@@ -227,27 +227,45 @@ if (rembCols.includes('autres_taxes')) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Migration : renommage date_debut → date_premiere_echeance, date_echeance → date_cible ──
|
// ── 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 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')) {
|
if (cols.includes('date_debut')) {
|
||||||
db.exec('ALTER TABLE investissements RENAME COLUMN date_debut TO date_premiere_echeance');
|
db.exec('ALTER TABLE investissements RENAME COLUMN date_debut TO date_premiere_echeance');
|
||||||
}
|
}
|
||||||
if (cols.includes('date_echeance')) {
|
if (cols.includes('date_echeance')) {
|
||||||
db.exec('ALTER TABLE investissements RENAME COLUMN date_echeance TO date_cible');
|
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
|
db.exec(`UPDATE investissements
|
||||||
SET date_premiere_echeance = date(date_souscription, '+1 month')
|
SET date_premiere_echeance = date(date_souscription, '+1 month')
|
||||||
WHERE date_premiere_echeance IS NULL AND date_souscription IS NOT NULL`);
|
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
|
// Formule cohérente avec la simulation : échéance 1 = date_premiere_echeance, dernière = +duree-1 mois
|
||||||
db.exec(`UPDATE investissements
|
db.exec(`UPDATE investissements
|
||||||
SET date_cible = date(date_premiere_echeance, '+' || (duree_mois - 1) || ' months')
|
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`);
|
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
|
// Correction ponctuelle des date_cible déjà calculées avec l'ancienne formule
|
||||||
SET date_cible = date(date_premiere_echeance, '+' || (duree_mois - 1) || ' months')
|
// (+duree_mois au lieu de +duree_mois-1) — ne s'exécute que le jour du renommage effectif
|
||||||
WHERE date_premiere_echeance IS NOT NULL AND duree_mois IS NOT NULL`);
|
// 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 ──
|
// ── 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 ──
|
// ── Migration : prêts différés — date_premiere_echeance doit égaler date_cible ──
|
||||||
// (versement unique à l'échéance : les deux dates sont identiques)
|
// (versement unique à l'échéance : les deux dates sont identiques)
|
||||||
{
|
{
|
||||||
const needsFix = db.prepare(`
|
const aCorriger = db.prepare(`
|
||||||
SELECT COUNT(*) AS n FROM investissements
|
SELECT id, nom_projet, date_premiere_echeance, date_cible
|
||||||
|
FROM investissements
|
||||||
WHERE type_remb = 'differe'
|
WHERE type_remb = 'differe'
|
||||||
AND date_cible IS NOT NULL
|
AND date_cible IS NOT NULL
|
||||||
AND (date_premiere_echeance IS NULL OR date_premiere_echeance != date_cible)
|
AND (date_premiere_echeance IS NULL OR date_premiere_echeance != date_cible)
|
||||||
`).get().n;
|
`).all();
|
||||||
|
|
||||||
if (needsFix > 0) {
|
if (aCorriger.length > 0) {
|
||||||
db.exec(`
|
const updateStmt = db.prepare(`
|
||||||
UPDATE investissements
|
UPDATE investissements SET date_premiere_echeance = date_cible WHERE id = ?
|
||||||
SET date_premiere_echeance = date_cible
|
|
||||||
WHERE type_remb = 'differe' AND date_cible IS NOT NULL
|
|
||||||
`);
|
`);
|
||||||
|
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)
|
// Régénère les simulations avec la nouvelle logique (date = startDate directement)
|
||||||
const differeInvs = db.prepare(`
|
const differeInvs = db.prepare(`
|
||||||
SELECT id, montant_investi, taux_interet, duree_mois, type_remb, freq_interets,
|
SELECT id, montant_investi, taux_interet, duree_mois, type_remb, freq_interets,
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { z } from 'zod';
|
|||||||
import db from '../db/index.js';
|
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 } from '../utils/schedule.js';
|
import { generateSimul, generateSimulWithReinvestissements } from '../utils/schedule.js';
|
||||||
|
import { checkStatutsRetard } from '../jobs/autoStatut.js';
|
||||||
|
|
||||||
const router = Router();
|
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 });
|
res.json({ updated: corriges.length, detail: corriges });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export default function DataCleanupSection() {
|
|||||||
const [loadingReprocess, setLoadingReprocess] = useState(false);
|
const [loadingReprocess, setLoadingReprocess] = useState(false);
|
||||||
const [showDiffereModal, setShowDiffereModal] = useState(false);
|
const [showDiffereModal, setShowDiffereModal] = useState(false);
|
||||||
const [loadingDiffere, setLoadingDiffere] = useState(false);
|
const [loadingDiffere, setLoadingDiffere] = useState(false);
|
||||||
|
const [showEcheancierModal, setShowEcheancierModal] = useState(false);
|
||||||
|
const [loadingEcheancier, setLoadingEcheancier] = useState(false);
|
||||||
const [showBackfillModal, setShowBackfillModal] = useState(false);
|
const [showBackfillModal, setShowBackfillModal] = useState(false);
|
||||||
const [loadingBackfill, setLoadingBackfill] = useState(false);
|
const [loadingBackfill, setLoadingBackfill] = useState(false);
|
||||||
const [successMsg, setSuccessMsg] = useState(null);
|
const [successMsg, setSuccessMsg] = useState(null);
|
||||||
@@ -54,13 +56,16 @@ export default function DataCleanupSection() {
|
|||||||
setErrorMsg(null);
|
setErrorMsg(null);
|
||||||
setSuccessMsg(null);
|
setSuccessMsg(null);
|
||||||
try {
|
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) {
|
if (updated === 0) {
|
||||||
setSuccessMsg('Aucune date incohérente détectée sur les prêts différés.');
|
setSuccessMsg('Aucune date incohérente détectée sur les prêts différés.');
|
||||||
} else {
|
} else {
|
||||||
|
const suffixeStatuts = statutsMisAJour > 0
|
||||||
|
? ` (statut recalculé pour ${statutsMisAJour} prêt${statutsMisAJour > 1 ? 's' : ''} suite à la correction)`
|
||||||
|
: '';
|
||||||
setSuccessMsg(
|
setSuccessMsg(
|
||||||
`${updated} prêt${updated > 1 ? 's' : ''} différé${updated > 1 ? 's' : ''} corrigé${updated > 1 ? 's' : ''} : ` +
|
`${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);
|
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 () => {
|
const handleBackfillComptes = async () => {
|
||||||
setLoadingBackfill(true);
|
setLoadingBackfill(true);
|
||||||
setErrorMsg(null);
|
setErrorMsg(null);
|
||||||
@@ -148,6 +176,30 @@ export default function DataCleanupSection() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
|
padding: '14px 16px', borderRadius: 8,
|
||||||
|
border: '1px solid var(--border)', background: 'var(--bg-secondary, var(--bg))',
|
||||||
|
marginBottom: 10 }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 500, fontSize: 'var(--fs-sm)', marginBottom: 2 }}>
|
||||||
|
Vérifier la cohérence de l'échéancier des prêts différés
|
||||||
|
</div>
|
||||||
|
<div className="text-muted" style={{ fontSize: 12 }}>
|
||||||
|
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).
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
style={{ marginLeft: 16, whiteSpace: 'nowrap', flexShrink: 0 }}
|
||||||
|
onClick={() => setShowEcheancierModal(true)}
|
||||||
|
disabled={loadingEcheancier}
|
||||||
|
>
|
||||||
|
Vérifier
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
padding: '14px 16px', borderRadius: 8,
|
padding: '14px 16px', borderRadius: 8,
|
||||||
border: '1px solid var(--border)', background: 'var(--bg-secondary, var(--bg))',
|
border: '1px solid var(--border)', background: 'var(--bg-secondary, var(--bg))',
|
||||||
@@ -222,6 +274,31 @@ export default function DataCleanupSection() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showEcheancierModal && (
|
||||||
|
<div className="modal-overlay" onClick={() => setShowEcheancierModal(false)}>
|
||||||
|
<div className="modal" style={{ maxWidth: 480 }} onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="modal-header" style={{ borderBottom: '1px solid var(--border)', paddingBottom: 12, marginBottom: 16 }}>
|
||||||
|
<h3 style={{ margin: 0 }}>Vérifier la cohérence de l'échéancier des prêts différés</h3>
|
||||||
|
</div>
|
||||||
|
<p style={{ margin: '0 0 12px', lineHeight: 1.6 }}>
|
||||||
|
Pour chaque prêt de type <strong>différé</strong>, 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).
|
||||||
|
</p>
|
||||||
|
<p style={{ margin: '0 0 20px', lineHeight: 1.6 }} className="text-muted">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||||
|
<button onClick={() => setShowEcheancierModal(false)} disabled={loadingEcheancier}>Annuler</button>
|
||||||
|
<button className="primary" onClick={handleCheckEcheancierDiffere} disabled={loadingEcheancier}>
|
||||||
|
{loadingEcheancier ? 'Vérification en cours…' : 'Lancer la vérification'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{showBackfillModal && (
|
{showBackfillModal && (
|
||||||
<div className="modal-overlay" onClick={() => setShowBackfillModal(false)}>
|
<div className="modal-overlay" onClick={() => setShowBackfillModal(false)}>
|
||||||
<div className="modal" style={{ maxWidth: 480 }} onClick={e => e.stopPropagation()}>
|
<div className="modal" style={{ maxWidth: 480 }} onClick={e => e.stopPropagation()}>
|
||||||
|
|||||||
Reference in New Issue
Block a user