diff --git a/backend/src/db/index.js b/backend/src/db/index.js index eb02a6a..7912cdc 100644 --- a/backend/src/db/index.js +++ b/backend/src/db/index.js @@ -2241,4 +2241,17 @@ db.exec(` `); 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; diff --git a/backend/src/jobs/checkDonneesIncompletes.js b/backend/src/jobs/checkDonneesIncompletes.js new file mode 100644 index 0000000..2da9734 --- /dev/null +++ b/backend/src/jobs/checkDonneesIncompletes.js @@ -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'); +} diff --git a/backend/src/routes/imports.js b/backend/src/routes/imports.js index 6837461..dd3a364 100644 --- a/backend/src/routes/imports.js +++ b/backend/src/routes/imports.js @@ -8,6 +8,7 @@ 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'; +import { checkDonneesIncompletes } from '../jobs/checkDonneesIncompletes.js'; const router = Router(); @@ -514,6 +515,15 @@ router.post('/apply', (req, res, next) => { }); 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 const anomalies = []; if (platMinDate.size > 0) { diff --git a/backend/src/server.js b/backend/src/server.js index 4cbb7bd..8da7776 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -36,6 +36,7 @@ import { startAutoStatutJob } from './jobs/autoStatut.js'; import { startAutoExportJob } from './jobs/autoExport.js'; import { startAutoTicketStatusJob } from './jobs/autoTicketStatus.js'; import { startAutoCleanNotifsJob } from './jobs/autoCleanNotifs.js'; +import { startCheckDonneesIncompletesJob } from './jobs/checkDonneesIncompletes.js'; import adminRouter from './routes/admin.js'; import invitationsRouter from './routes/invitations.js'; import auditLogsRouter from './routes/auditLogs.js'; @@ -150,4 +151,5 @@ app.listen(PORT, () => { startAutoExportJob(); startAutoTicketStatusJob(); startAutoCleanNotifsJob(); + startCheckDonneesIncompletesJob(); }); diff --git a/frontend/src/pages/settings/ImportsSection.jsx b/frontend/src/pages/settings/ImportsSection.jsx index e1de0cf..3dde904 100644 --- a/frontend/src/pages/settings/ImportsSection.jsx +++ b/frontend/src/pages/settings/ImportsSection.jsx @@ -179,7 +179,7 @@ const MODULES = { remboursements: { label: 'Remboursements', 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, }, 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é. ` + `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 ` - + `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` - + `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), ` - + `assure-toi que net_recu = capital + (interets_bruts − prelev_sociaux − prelev_forfaitaire).\n\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. ⚠️ RÈGLE CRITIQUE — présence systématique des champs : sur CHAQUE ligne du JSON généré, inclus TOUJOURS explicitement les 5 champs ` + + `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) { const names = investissements.map(i => i.nom_projet).join(' | ');