diff --git a/backend/src/db/index.js b/backend/src/db/index.js index e77d582..e0c873b 100644 --- a/backend/src/db/index.js +++ b/backend/src/db/index.js @@ -2075,4 +2075,15 @@ console.log('[DB] Migrations 2FA OK'); } } +// ── Migration : compteur de doublons ignorés sur imports ────────────────── +// Distingue les lignes ignorées car identiques à une ligne déjà en base +// (rows_duplicates) des lignes ignorées pour une autre raison (rows_skipped +// reste le total ; rows_duplicates est un sous-ensemble informatif). +{ + const importsCols = db.prepare('PRAGMA table_info(imports)').all().map(c => c.name); + if (!importsCols.includes('rows_duplicates')) { + db.exec('ALTER TABLE imports ADD COLUMN rows_duplicates INTEGER NOT NULL DEFAULT 0'); + } +} + export default db; diff --git a/backend/src/routes/imports.js b/backend/src/routes/imports.js index 37f145b..0a5c6fc 100644 --- a/backend/src/routes/imports.js +++ b/backend/src/routes/imports.js @@ -137,7 +137,7 @@ router.post('/apply', (req, res, next) => { invNameMap = new Map(invRows.map(i => [normalizeName(i.nom_projet), i.id])); } - let inserted = 0, skipped = 0; + let inserted = 0, skipped = 0, duplicates = 0; const errors = []; const tx = db.transaction(() => { @@ -153,22 +153,48 @@ router.post('/apply', (req, res, next) => { }; if (module === 'depots_retraits') { + const plateformeId = resolveRefId(v('plateforme_id'), platIdSet, platNameMap, 'Plateforme'); + const dateOperation = normaliseDate(v('date_operation')); + const type = normaliseType(v('type')); + const montant = num(v('montant')); + + // Anti-doublon : même investisseur + plateforme + date + type + montant + const dup = db.prepare(` + SELECT id FROM depots_retraits + WHERE investisseur_id = ? AND plateforme_id = ? AND date_operation = ? + AND type = ? AND ABS(montant - ?) < 0.005 + LIMIT 1 + `).get(req.investisseur.id, plateformeId, dateOperation, type, montant); + if (dup) { duplicates++; continue; } + db.prepare(` INSERT INTO depots_retraits (investisseur_id, plateforme_id, date_operation, type, montant, libelle, reference, source) VALUES (?,?,?,?,?,?,?,?) `).run( req.investisseur.id, - resolveRefId(v('plateforme_id'), platIdSet, platNameMap, 'Plateforme'), - normaliseDate(v('date_operation')), - normaliseType(v('type')), - num(v('montant')), + plateformeId, + dateOperation, + type, + montant, v('libelle') || null, v('reference') || null, srcLabel, ); } else if (module === 'investissements') { + const plateformeId = resolveRefId(v('plateforme_id'), platIdSet, platNameMap, 'Plateforme'); + const nomProjet = String(v('nom_projet')); + const dateSouscription = normaliseDate(v('date_souscription')); + + // Anti-doublon : même investisseur + plateforme + nom du projet + date de souscription + const dup = db.prepare(` + SELECT id FROM investissements + WHERE investisseur_id = ? AND plateforme_id = ? AND nom_projet = ? AND date_souscription = ? + LIMIT 1 + `).get(req.investisseur.id, plateformeId, nomProjet, dateSouscription); + if (dup) { duplicates++; continue; } + db.prepare(` INSERT INTO investissements (investisseur_id, plateforme_id, nom_projet, emetteur, date_souscription, @@ -177,10 +203,10 @@ router.post('/apply', (req, res, next) => { VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) `).run( req.investisseur.id, - resolveRefId(v('plateforme_id'), platIdSet, platNameMap, 'Plateforme'), - String(v('nom_projet')), + plateformeId, + nomProjet, v('emetteur') || null, - normaliseDate(v('date_souscription')), + dateSouscription, v('date_premiere_echeance') ? normaliseDate(v('date_premiere_echeance')) : null, v('date_cible') ? normaliseDate(v('date_cible')) : null, num(v('montant_investi')), @@ -194,6 +220,8 @@ router.post('/apply', (req, res, next) => { ); } else if (module === 'remboursements') { + const investissementId = resolveRefId(v('investissement_id'), invIdSet, invNameMap, 'Investissement'); + const dateRemb = normaliseDate(v('date_remb')); const capital = num(v('capital')); const cashback = num(v('cashback')); const bruts = num(v('interets_bruts')); @@ -201,14 +229,24 @@ router.post('/apply', (req, res, next) => { const pf = num(v('prelev_forfaitaire')); const interets_nets = Math.round((bruts - ps - pf) * 100) / 100; const net_recu = Math.round((capital + cashback + interets_nets) * 100) / 100; + + // Anti-doublon : même investissement + date + capital + intérêts bruts + const dup = db.prepare(` + SELECT id FROM remboursements + WHERE investissement_id = ? AND date_remb = ? + AND ABS(capital - ?) < 0.005 AND ABS(interets_bruts - ?) < 0.005 + LIMIT 1 + `).get(investissementId, dateRemb, capital, bruts); + if (dup) { duplicates++; continue; } + db.prepare(` INSERT INTO remboursements (investissement_id, date_remb, capital, cashback, interets_bruts, prelev_sociaux, prelev_forfaitaire, interets_nets, net_recu, statut, source) VALUES (?,?,?,?,?,?,?,?,?,?,?) `).run( - resolveRefId(v('investissement_id'), invIdSet, invNameMap, 'Investissement'), - normaliseDate(v('date_remb')), + investissementId, + dateRemb, capital, cashback, bruts, ps, pf, interets_nets, net_recu, v('statut') || 'paye', srcLabel, @@ -226,8 +264,8 @@ router.post('/apply', (req, res, next) => { v('url') || null, v('notes') || null, ); - // changes = 0 means the row was ignored (nom already exists) - if (r.changes === 0) throw new Error(`Plateforme "${nom}" existe déjà — ignorée`); + // changes = 0 means the row was ignored (nom already exists) — doublon, pas une erreur + if (r.changes === 0) { duplicates++; continue; } } else if (module === 'taux_pfu') { const annee = parseInt(v('annee'), 10); @@ -258,8 +296,8 @@ router.post('/apply', (req, res, next) => { tx(); db.prepare(` - INSERT INTO imports (user_id, investisseur_id, module, filename, rows_total, rows_inserted, rows_skipped, mapping_json) - VALUES (?,?,?,?,?,?,?,?) + INSERT INTO imports (user_id, investisseur_id, module, filename, rows_total, rows_inserted, rows_skipped, rows_duplicates, mapping_json) + VALUES (?,?,?,?,?,?,?,?,?) `).run( req.user.id, req.investisseur?.id ?? null, @@ -268,13 +306,14 @@ router.post('/apply', (req, res, next) => { rows.length, inserted, skipped, + duplicates, JSON.stringify(mapping), ); // Clean up temp file try { fs.unlinkSync(tempPath); } catch { /* */ } - res.json({ inserted, skipped, total: rows.length, errors: errors.slice(0, 50) }); + res.json({ inserted, skipped, duplicates, total: rows.length, errors: errors.slice(0, 50) }); } catch (e) { next(e); } }); diff --git a/frontend/src/pages/settings/ImportsSection.jsx b/frontend/src/pages/settings/ImportsSection.jsx index aa95bc9..c2db01d 100644 --- a/frontend/src/pages/settings/ImportsSection.jsx +++ b/frontend/src/pages/settings/ImportsSection.jsx @@ -406,7 +406,11 @@ export default function ImportsSection() { }); setResult({ ok: true, - msg: `✔ Import terminé : ${r.inserted} / ${r.total} lignes insérées${r.skipped > 0 ? `, ${r.skipped} ignorées` : ''}.${r.errors?.length > 0 ? ` (${r.errors.length} avertissement(s))` : ''}`, + msg: `✔ Import terminé : ${r.inserted} / ${r.total} lignes insérées` + + (r.duplicates > 0 ? `, ${r.duplicates} doublon(s) ignoré(s)` : '') + + (r.skipped > 0 ? `, ${r.skipped} ignorée(s)` : '') + + '.' + + (r.errors?.length > 0 ? ` (${r.errors.length} avertissement(s))` : ''), }); setPreview(null); setFile(null); setMapping({}); setDefaults({}); api.get('/imports/history').then(setHistory).catch(() => {}); @@ -602,12 +606,12 @@ export default function ImportsSection() { DateModuleFichier - TotalOKKO + TotalOKDoublonsKO {history.length === 0 && ( - Aucun import + Aucun import )} {history.map(h => ( @@ -616,6 +620,7 @@ export default function ImportsSection() { {h.filename} {h.rows_total} {h.rows_inserted} + 0 ? 'var(--text-muted)' : undefined }}>{h.rows_duplicates ?? 0} 0 ? 'var(--warning)' : undefined }}>{h.rows_skipped} ))}