Analyse des dossier d'investissemetns incomplets

This commit is contained in:
2026-07-14 14:45:23 +02:00
parent 4ef7cbee04
commit 3e4c0dffa8
5 changed files with 164 additions and 4 deletions
+13
View File
@@ -2241,4 +2241,17 @@ db.exec(`
`); `);
db.exec('CREATE INDEX IF NOT EXISTS idx_objectifs_investisseur ON objectifs(investisseur_id, type, annee)'); db.exec('CREATE INDEX IF NOT EXISTS idx_objectifs_investisseur ON objectifs(investisseur_id, type, annee)');
// ── Migration : suivi anti-doublon des notifications "données incomplètes" ──
// Stocke la signature (liste triée des champs essentiels manquants) au moment
// de la dernière notification envoyée pour cet investissement, afin que le job
// checkDonneesIncompletes ne notifie à nouveau que si ce manque a changé
// (nouveau champ manquant, champ comblé, ou tout comblé → signature NULL).
{
const cols = db.prepare('PRAGMA table_info(investissements)').all().map(c => c.name);
if (!cols.includes('donnees_incompletes_signature')) {
db.exec('ALTER TABLE investissements ADD COLUMN donnees_incompletes_signature TEXT');
console.log('[DB] investissements: colonne donnees_incompletes_signature ajoutée');
}
}
export default db; export default db;
+130
View File
@@ -0,0 +1,130 @@
import db from '../db/index.js';
const JOB_NAME = 'check_donnees_incompletes';
// Champs essentiels vérifiés sur chaque investissement, avec leur libellé
// utilisé dans le corps de la notification.
const CHAMPS_ESSENTIELS = [
{ champ: 'taux_interet', label: "taux d'intérêt" },
{ champ: 'duree_mois', label: 'durée' },
{ champ: 'type_remb', label: 'type de remboursement' },
{ champ: 'date_premiere_echeance', label: 'date de première échéance' },
];
/** Persiste une entrée dans job_logs */
function writeLog({ status, nbChanges, details, errorMsg }) {
try {
db.prepare(`
INSERT INTO job_logs (job_name, status, nb_changes, details, error_msg)
VALUES (?, ?, ?, ?, ?)
`).run(JOB_NAME, status, nbChanges ?? 0, details ?? null, errorMsg ?? null);
} catch (e) {
console.error('[checkDonneesIncompletes] Impossible d\'écrire dans job_logs :', e.message);
}
}
/** 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('[checkDonneesIncompletes] Impossible de créer la notification :', e.message);
}
}
/**
* Vérifie que chaque investissement actif (statut != 'cloture') possède bien
* ses données essentielles : taux d'intérêt, durée, type de remboursement,
* date de première échéance.
*
* Anti-doublon : la signature triée des champs manquants est stockée dans
* investissements.donnees_incompletes_signature.
* - signature inchangée depuis la dernière notification → rien à faire
* - plus aucun champ manquant → signature réinitialisée (NULL), silencieux
* - signature différente (nouveau manque, ou manque partiellement comblé
* mais toujours incomplet) → nouvelle notification, signature mise à jour
*
* @returns {number} nombre de notifications envoyées
*/
export function checkDonneesIncompletes() {
const rows = db.prepare(`
SELECT i.id, i.nom_projet, i.taux_interet, i.duree_mois, i.type_remb,
i.date_premiere_echeance, i.donnees_incompletes_signature, inv.user_id
FROM investissements i
JOIN investisseurs inv ON inv.id = i.investisseur_id
WHERE i.statut != 'cloture'
`).all();
const updateSignature = db.prepare(`
UPDATE investissements SET donnees_incompletes_signature = ? WHERE id = ?
`);
let nbNotifs = 0;
const details = [];
const tx = db.transaction(() => {
for (const inv of rows) {
const manquants = CHAMPS_ESSENTIELS.filter(({ champ }) => {
const val = inv[champ];
return val === null || val === undefined || val === '';
});
const signature = manquants.length > 0
? manquants.map(m => m.champ).sort().join(',')
: null;
if (signature === inv.donnees_incompletes_signature) continue; // déjà à jour, rien à notifier
updateSignature.run(signature, inv.id);
if (signature) {
const labels = manquants.map(m => m.label).join(', ');
notifyUser(inv.user_id, {
type: 'warning',
title: `Données incomplètes : "${inv.nom_projet}"`,
body: `Champs manquants : ${labels}.`,
link: `/investissements/${inv.id}`,
});
nbNotifs++;
details.push(`"${inv.nom_projet}" (id=${inv.id}) : ${labels}`);
}
// sinon : données désormais complètes, on réinitialise juste la signature (pas de notification)
}
});
tx();
const summary = nbNotifs === 0 ? 'Aucune nouvelle notification' : details.join(' | ');
writeLog({ status: 'ok', nbChanges: nbNotifs, details: summary });
if (nbNotifs > 0) {
console.log(`[checkDonneesIncompletes] ${nbNotifs} notification(s) envoyée(s) : ${summary}`);
}
return nbNotifs;
}
/**
* Démarre le job de vérification des données essentielles manquantes.
* Exécution immédiate au démarrage, puis toutes les heures.
*/
export function startCheckDonneesIncompletesJob() {
const INTERVAL_MS = 60 * 60 * 1000; // 1 heure
try {
checkDonneesIncompletes();
} catch (err) {
console.error('[checkDonneesIncompletes] Erreur initiale :', err);
writeLog({ status: 'error', nbChanges: 0, errorMsg: err.message });
}
setInterval(() => {
try {
checkDonneesIncompletes();
} catch (err) {
console.error('[checkDonneesIncompletes] Erreur :', err);
writeLog({ status: 'error', nbChanges: 0, errorMsg: err.message });
}
}, INTERVAL_MS);
console.log('[checkDonneesIncompletes] Job démarré — vérification toutes les heures');
}
+10
View File
@@ -8,6 +8,7 @@ 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'; import { recordHistory, detectChangements, detectTypeEvenement } from './investissements.js';
import { checkDonneesIncompletes } from '../jobs/checkDonneesIncompletes.js';
const router = Router(); const router = Router();
@@ -514,6 +515,15 @@ router.post('/apply', (req, res, next) => {
}); });
tx(); tx();
// Vérifie et notifie les données essentielles manquantes (taux, durée, type de
// remboursement, date de première échéance) sur les investissements importés —
// même logique que le job horaire, best-effort pour ne jamais bloquer l'import.
if (module === 'investissements' && inserted > 0) {
try { checkDonneesIncompletes(); } catch (e) {
console.error('[imports] checkDonneesIncompletes après import a échoué :', e.message);
}
}
// Anomalie : une date importée précède la date d'ouverture déclarée sur la plateforme // Anomalie : une date importée précède la date d'ouverture déclarée sur la plateforme
const anomalies = []; const anomalies = [];
if (platMinDate.size > 0) { if (platMinDate.size > 0) {
+2
View File
@@ -36,6 +36,7 @@ import { startAutoStatutJob } from './jobs/autoStatut.js';
import { startAutoExportJob } from './jobs/autoExport.js'; import { startAutoExportJob } from './jobs/autoExport.js';
import { startAutoTicketStatusJob } from './jobs/autoTicketStatus.js'; import { startAutoTicketStatusJob } from './jobs/autoTicketStatus.js';
import { startAutoCleanNotifsJob } from './jobs/autoCleanNotifs.js'; import { startAutoCleanNotifsJob } from './jobs/autoCleanNotifs.js';
import { startCheckDonneesIncompletesJob } from './jobs/checkDonneesIncompletes.js';
import adminRouter from './routes/admin.js'; import adminRouter from './routes/admin.js';
import invitationsRouter from './routes/invitations.js'; import invitationsRouter from './routes/invitations.js';
import auditLogsRouter from './routes/auditLogs.js'; import auditLogsRouter from './routes/auditLogs.js';
@@ -150,4 +151,5 @@ app.listen(PORT, () => {
startAutoExportJob(); startAutoExportJob();
startAutoTicketStatusJob(); startAutoTicketStatusJob();
startAutoCleanNotifsJob(); startAutoCleanNotifsJob();
startCheckDonneesIncompletesJob();
}); });
@@ -179,7 +179,7 @@ const MODULES = {
remboursements: { remboursements: {
label: 'Remboursements', label: 'Remboursements',
required: ['investissement_id', 'date_remb'], required: ['investissement_id', 'date_remb'],
optional: ['capital', 'interets_bruts', 'prelev_sociaux', 'prelev_forfaitaire', 'net_recu', 'statut'], optional: ['capital', 'interets_bruts', 'prelev_sociaux', 'prelev_forfaitaire', 'statut'],
needsInvestisseur: true, needsInvestisseur: true,
}, },
plateformes: { plateformes: {
@@ -394,9 +394,14 @@ function buildReferenceSection(mod, plats, investissements, scopePlateforme) {
+ `(les deux estimations doivent être proches ; en cas d'écart, privilégie leur moyenne) — puis capital = montant total de la ligne interets_bruts ainsi recalculé. ` + `(les deux estimations doivent être proches ; en cas d'écart, privilégie leur moyenne) — puis capital = montant total de la ligne interets_bruts ainsi recalculé. `
+ `N'utilise JAMAIS le montant total brut de la ligne comme interets_bruts dans ce cas.\n` + `N'utilise JAMAIS le montant total brut de la ligne comme interets_bruts dans ce cas.\n`
+ `4. Si AUCUNE ligne de prélèvement n'est associée à la ligne de remboursement (aucun CSG/CRDS ni prélèvement IR/PFU adjacent), il s'agit d'un remboursement ` + `4. Si AUCUNE ligne de prélèvement n'est associée à la ligne de remboursement (aucun CSG/CRDS ni prélèvement IR/PFU adjacent), il s'agit d'un remboursement `
+ `de capital PUR (souvent l'échéance finale d'un prêt in fine/différé) : capital = montant total de la ligne, n'inclus ni interets_bruts, ni prelev_sociaux, ni prelev_forfaitaire.\n` + `de capital PUR (souvent l'échéance finale d'un prêt in fine/différé) : capital = montant total de la ligne.\n`
+ `5. Dans tous les cas, net_recu = montant total réellement reçu (capital + intérêts nets). Si tu inclus capital ET interets_bruts sur la même ligne (mensualité mixte), ` + `5. ⚠️ RÈGLE CRITIQUE — présence systématique des champs : sur CHAQUE ligne du JSON généré, inclus TOUJOURS explicitement les 5 champs `
+ `assure-toi que net_recu = capital + (interets_bruts prelev_sociaux prelev_forfaitaire).\n\n`; + `capital, cashback, interets_bruts, prelev_sociaux, prelev_forfaitaire — même quand leur valeur est 0. N'omets JAMAIS un champ sous prétexte `
+ `qu'il vaut 0 sur cette ligne précise (ex. une ligne de remboursement de capital pur doit quand même inclure "interets_bruts": 0, "prelev_sociaux": 0, `
+ `"prelev_forfaitaire": 0, et pas seulement "capital"). Cette règle est impérative : l'application détecte les colonnes disponibles à partir des champs `
+ `présents sur la PREMIÈRE ligne du JSON uniquement — un champ absent de la première ligne ne sera JAMAIS pris en compte à l'import, même s'il apparaît `
+ `rempli sur une ligne suivante (ex. le remboursement final de capital serait alors importé à 0 €, silencieusement). `
+ `N'inclus PAS de champ "net_recu" : il n'est pas utilisé par l'import, qui recalcule lui-même le montant net à partir de capital/cashback/interets_bruts/prélèvements.\n\n`;
if (investissements.length > 0) { if (investissements.length > 0) {
const names = investissements.map(i => i.nom_projet).join(' | '); const names = investissements.map(i => i.nom_projet).join(' | ');