Analyse des dossier d'investissemetns incomplets
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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(' | ');
|
||||
|
||||
Reference in New Issue
Block a user