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)');
|
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;
|
||||||
|
|||||||
@@ -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 { 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) {
|
||||||
|
|||||||
@@ -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(' | ');
|
||||||
|
|||||||
Reference in New Issue
Block a user