From 4ef7cbee04230a4d20c257d07243786ae43d9bcf Mon Sep 17 00:00:00 2001 From: Olivier Date: Tue, 14 Jul 2026 14:10:27 +0200 Subject: [PATCH] =?UTF-8?q?Am=C3=A9lioration=20de=20l'importation=20de=20d?= =?UTF-8?q?onn=C3=A9e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/routes/imports.js | 238 ++++++++++++- backend/src/routes/plateformes.js | 55 +++ .../src/pages/settings/DataCleanupSection.jsx | 141 +++++++- .../src/pages/settings/ImportsSection.jsx | 329 ++++++++++++++++-- frontend/src/styles.css | 15 +- 5 files changed, 729 insertions(+), 49 deletions(-) diff --git a/backend/src/routes/imports.js b/backend/src/routes/imports.js index c989cf0..6837461 100644 --- a/backend/src/routes/imports.js +++ b/backend/src/routes/imports.js @@ -93,7 +93,14 @@ router.post('/preview', upload.single('file'), (req, res, next) => { } catch (e) { next(e); } }); -router.post('/apply', (req, res, next) => { +/** + * POST /api/imports/check-duplicates + * Même entrée que /apply (tempId, module, mapping, defaults) mais AUCUNE écriture en base : + * rejoue la détection anti-doublon module par module et renvoie, pour chaque ligne concernée, + * les valeurs de la ligne importée en regard de l'enregistrement existant en base, afin que + * l'utilisateur puisse décider ligne par ligne (modale de doublons) avant l'import réel. + */ +router.post('/check-duplicates', (req, res, next) => { try { const { tempId, module, mapping, defaults = {} } = req.body || {}; if (!tempId || !module || !mapping) { @@ -102,6 +109,190 @@ router.post('/apply', (req, res, next) => { const def = MODULES[module]; if (!def) throw new HttpError(400, 'Unknown module'); + if (INVESTISSEUR_SCOPED.includes(module)) { + requireInvestisseur(req, res, () => {}); + } + + const tempPath = path.join(UPLOAD_DIR, tempId); + if (!fs.existsSync(tempPath)) throw new HttpError(404, 'Uploaded file expired'); + + const origName = req.body.originalFilename || ''; + const { rows } = parseFile(tempPath, origName || 'file.xlsx'); + + let platIdSet = new Set(), platNameMap = new Map(), platNomById = new Map(); + if (module === 'depots_retraits' || module === 'investissements') { + const platRows = db.prepare('SELECT id, nom FROM plateformes WHERE user_id = ?').all(req.user.id); + platIdSet = new Set(platRows.map(p => p.id)); + platNameMap = new Map(platRows.map(p => [normalizeName(p.nom), p.id])); + platNomById = new Map(platRows.map(p => [p.id, p.nom])); + } + let invIdSet = new Set(), invNameMap = new Map(), invNomById = new Map(); + if (module === 'remboursements') { + const invRows = db.prepare('SELECT id, nom_projet FROM investissements WHERE investisseur_id = ?').all(req.investisseur.id); + invIdSet = new Set(invRows.map(i => i.id)); + invNameMap = new Map(invRows.map(i => [normalizeName(i.nom_projet), i.id])); + invNomById = new Map(invRows.map(i => [i.id, i.nom_projet])); + } + + const duplicates = []; + // Doublons INTERNES au fichier : /apply traite les lignes une à une dans une seule transaction, + // donc une ligne répétée plus loin dans le même fichier se retrouve comparée à la précédente + // occurrence tout juste "insérée" — pas seulement aux enregistrements déjà présents en base. + // Comme cette route ne fait aucune écriture, on simule cet effet avec une map en mémoire : + // clé de dédoublonnage → { row, incoming } de la première occurrence rencontrée. + const seenDepotsRetraits = new Map(); + const seenInvestissements = new Map(); + const seenRemboursements = new Map(); + const seenPlateformes = new Map(); + const seenTauxPfu = new Map(); + + for (let idx = 0; idx < rows.length; idx++) { + const row = rows[idx]; + const v = (target) => { + const col = mapping[target]; + if (col && row[col] !== undefined && row[col] !== null && row[col] !== '') return row[col]; + return defaults[target]; + }; + const rowNum = idx + 2; + + try { + 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')); + const incoming = { + plateforme: platNomById.get(plateformeId) || '', date_operation: dateOperation, + type, montant, libelle: v('libelle') || null, reference: v('reference') || null, + }; + const existing = db.prepare(` + SELECT * 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); + const key = `${plateformeId}|${dateOperation}|${type}|${montant.toFixed(2)}`; + const seen = seenDepotsRetraits.get(key); + if (existing) { + duplicates.push({ row: rowNum, incoming, existing: { + plateforme: platNomById.get(existing.plateforme_id) || '', date_operation: existing.date_operation, + type: existing.type, montant: existing.montant, libelle: existing.libelle, reference: existing.reference, + } }); + } else if (seen) { + duplicates.push({ row: rowNum, matchedRow: seen.row, incoming, existing: seen.incoming }); + } else { + seenDepotsRetraits.set(key, { row: rowNum, incoming }); + } + } 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')); + const incoming = { + plateforme: platNomById.get(plateformeId) || '', nom_projet: nomProjet, date_souscription: dateSouscription, + montant_investi: num(v('montant_investi')), taux_interet: v('taux_interet') || null, + duree_mois: v('duree_mois') || null, statut: v('statut') || 'en_cours', + }; + const existing = db.prepare(` + SELECT * FROM investissements + WHERE investisseur_id = ? AND plateforme_id = ? AND nom_projet = ? AND date_souscription = ? + LIMIT 1 + `).get(req.investisseur.id, plateformeId, nomProjet, dateSouscription); + const key = `${plateformeId}|${normalizeName(nomProjet)}|${dateSouscription}`; + const seen = seenInvestissements.get(key); + if (existing) { + duplicates.push({ row: rowNum, incoming, existing: { + plateforme: platNomById.get(existing.plateforme_id) || '', nom_projet: existing.nom_projet, + date_souscription: existing.date_souscription, montant_investi: existing.montant_investi, + taux_interet: existing.taux_interet, duree_mois: existing.duree_mois, statut: existing.statut, + } }); + } else if (seen) { + duplicates.push({ row: rowNum, matchedRow: seen.row, incoming, existing: seen.incoming }); + } else { + seenInvestissements.set(key, { row: rowNum, incoming }); + } + } 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 bruts = num(v('interets_bruts')); + const incoming = { + investissement: invNomById.get(investissementId) || '', date_remb: dateRemb, capital, + cashback: num(v('cashback')), interets_bruts: bruts, + prelev_sociaux: num(v('prelev_sociaux')), prelev_forfaitaire: num(v('prelev_forfaitaire')), + statut: v('statut') || 'paye', + }; + const existing = db.prepare(` + SELECT * 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); + const key = `${investissementId}|${dateRemb}|${capital.toFixed(2)}|${bruts.toFixed(2)}`; + const seen = seenRemboursements.get(key); + if (existing) { + duplicates.push({ row: rowNum, incoming, existing: { + investissement: invNomById.get(existing.investissement_id) || '', date_remb: existing.date_remb, + capital: existing.capital, cashback: existing.cashback, interets_bruts: existing.interets_bruts, + prelev_sociaux: existing.prelev_sociaux, prelev_forfaitaire: existing.prelev_forfaitaire, + statut: existing.statut, + } }); + } else if (seen) { + duplicates.push({ row: rowNum, matchedRow: seen.row, incoming, existing: seen.incoming }); + } else { + seenRemboursements.set(key, { row: rowNum, incoming }); + } + } else if (module === 'plateformes') { + const nom = String(v('nom') || '').trim(); + if (!nom) continue; + const incoming = { nom, url: v('url') || null, notes: v('notes') || null }; + const existing = db.prepare('SELECT * FROM plateformes WHERE user_id = ? AND nom = ?').get(req.user.id, nom); + const key = nom; + const seen = seenPlateformes.get(key); + if (existing) { + duplicates.push({ row: rowNum, incoming, existing: { nom: existing.nom, url: existing.url, notes: existing.notes } }); + } else if (seen) { + duplicates.push({ row: rowNum, matchedRow: seen.row, incoming, existing: seen.incoming }); + } else { + seenPlateformes.set(key, { row: rowNum, incoming }); + } + } else if (module === 'taux_pfu') { + const annee = parseInt(v('annee'), 10); + if (!annee) continue; + const incoming = { + annee, pfu_total: num(v('pfu_total')), impot_revenu: num(v('impot_revenu')), prelev_sociaux: num(v('prelev_sociaux')), + }; + const existing = db.prepare('SELECT * FROM taux_pfu WHERE annee = ?').get(annee); + const key = annee; + const seen = seenTauxPfu.get(key); + if (existing) { + duplicates.push({ row: rowNum, incoming, existing: { + annee: existing.annee, pfu_total: existing.pfu_total, impot_revenu: existing.impot_revenu, prelev_sociaux: existing.prelev_sociaux, + } }); + } else if (seen) { + duplicates.push({ row: rowNum, matchedRow: seen.row, incoming, existing: seen.incoming }); + } else { + seenTauxPfu.set(key, { row: rowNum, incoming }); + } + } + } catch { + // Une ligne dont les références ne se résolvent pas sera de toute façon signalée en erreur + // par /apply — on ne la fait pas échouer ici, la vérification des doublons est best-effort. + } + } + + res.json({ total: rows.length, duplicates }); + } catch (e) { next(e); } +}); + +router.post('/apply', (req, res, next) => { + try { + const { tempId, module, mapping, defaults = {}, duplicateDecisions = {} } = req.body || {}; + if (!tempId || !module || !mapping) { + throw new HttpError(400, 'tempId, module and mapping are required'); + } + const def = MODULES[module]; + if (!def) throw new HttpError(400, 'Unknown module'); + // Require a specific investisseur for transactional modules if (INVESTISSEUR_SCOPED.includes(module)) { requireInvestisseur(req, res, () => {}); @@ -160,6 +351,10 @@ router.post('/apply', (req, res, next) => { } return defaults[target]; }; + // Décision prise par l'utilisateur dans la modale de doublons (voir /check-duplicates) : + // 'accept' = importer quand même malgré la correspondance ; absent/'skip' = comportement + // historique (la ligne en doublon est ignorée). + const rowDecision = duplicateDecisions[String(idx + 2)]; if (module === 'depots_retraits') { const plateformeId = resolveRefId(v('plateforme_id'), platIdSet, platNameMap, 'Plateforme'); @@ -175,7 +370,7 @@ router.post('/apply', (req, res, next) => { AND type = ? AND ABS(montant - ?) < 0.005 LIMIT 1 `).get(req.investisseur.id, plateformeId, dateOperation, type, montant); - if (dup) { duplicates++; continue; } + if (dup && rowDecision !== 'accept') { duplicates++; continue; } db.prepare(` INSERT INTO depots_retraits @@ -204,7 +399,7 @@ router.post('/apply', (req, res, next) => { 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; } + if (dup && rowDecision !== 'accept') { duplicates++; continue; } db.prepare(` INSERT INTO investissements @@ -248,7 +443,7 @@ router.post('/apply', (req, res, next) => { AND ABS(capital - ?) < 0.005 AND ABS(interets_bruts - ?) < 0.005 LIMIT 1 `).get(investissementId, dateRemb, capital, bruts); - if (dup) { duplicates++; continue; } + if (dup && rowDecision !== 'accept') { duplicates++; continue; } db.prepare(` INSERT INTO remboursements @@ -266,21 +461,34 @@ router.post('/apply', (req, res, next) => { } else if (module === 'plateformes') { const nom = String(v('nom') || '').trim(); if (!nom) throw new Error('Le champ nom est vide'); - const r = db.prepare(` - INSERT OR IGNORE INTO plateformes (user_id, nom, url, notes) - VALUES (?, ?, ?, ?) - `).run( - req.user.id, - nom, - v('url') || null, - v('notes') || null, - ); - // changes = 0 means the row was ignored (nom already exists) — doublon, pas une erreur - if (r.changes === 0) { duplicates++; continue; } + const existingPlat = db.prepare('SELECT id FROM plateformes WHERE user_id = ? AND nom = ?').get(req.user.id, nom); + if (existingPlat) { + // Une plateforme "nom" est unique par utilisateur/détenteur : impossible de créer un + // second doublon en base. "Accepter" le doublon signifie donc ici mettre à jour la + // fiche existante avec les valeurs importées, plutôt que de créer une nouvelle ligne. + if (rowDecision !== 'accept') { duplicates++; continue; } + db.prepare('UPDATE plateformes SET url = ?, notes = ? WHERE id = ?') + .run(v('url') || null, v('notes') || null, existingPlat.id); + } else { + db.prepare(` + INSERT INTO plateformes (user_id, nom, url, notes) + VALUES (?, ?, ?, ?) + `).run( + req.user.id, + nom, + v('url') || null, + v('notes') || null, + ); + } } else if (module === 'taux_pfu') { const annee = parseInt(v('annee'), 10); if (!annee || annee < 2000 || annee > 2100) throw new Error('Année invalide'); + const existingTaux = db.prepare('SELECT annee FROM taux_pfu WHERE annee = ?').get(annee); + // Comportement historique : un taux existant est toujours écrasé par l'import (upsert). + // On ne change ce comportement que si l'utilisateur a explicitement refusé le doublon + // dans la modale de vérification — dans ce cas seulement, le taux existant est préservé. + if (existingTaux && rowDecision === 'skip') { duplicates++; continue; } db.prepare(` INSERT INTO taux_pfu (annee, pfu_total, impot_revenu, prelev_sociaux) VALUES (?, ?, ?, ?) diff --git a/backend/src/routes/plateformes.js b/backend/src/routes/plateformes.js index f4b452a..ca6ac9f 100644 --- a/backend/src/routes/plateformes.js +++ b/backend/src/routes/plateformes.js @@ -700,6 +700,61 @@ router.delete('/:id', (req, res, next) => { } catch (e) { next(e); } }); +// ── Purge des données d'une plateforme (dépôts/retraits, investissements, remboursements) ── +// La plateforme elle-même n'est jamais supprimée par cette route : seules les données +// rattachées le sont, afin de permettre une réimportation propre derrière. +const PurgeSchema = z.object({ + scope: z.enum(['all', 'depots_retraits', 'investissements', 'remboursements']), + confirmNom: z.string().min(1), +}); + +router.post('/:id/purge-donnees', (req, res, next) => { + try { + const { scope, confirmNom } = PurgeSchema.parse(req.body); + const plat = db.prepare('SELECT id, nom FROM plateformes WHERE id = ? AND user_id = ?') + .get(req.params.id, req.user.id); + if (!plat) throw new HttpError(404, 'Plateforme introuvable'); + + // Sécurité supplémentaire : le nom retapé doit correspondre exactement (protection + // contre une confirmation validée par erreur sur la mauvaise plateforme). + if (confirmNom.trim() !== plat.nom) { + throw new HttpError(400, 'Le nom saisi ne correspond pas exactement au nom de la plateforme.'); + } + + const platId = plat.id; + const counts = { depots_retraits: 0, investissements: 0, remboursements: 0, simul_remboursements: 0 }; + + const tx = db.transaction(() => { + if (scope === 'depots_retraits' || scope === 'all') { + counts.depots_retraits = db.prepare('DELETE FROM depots_retraits WHERE plateforme_id = ?').run(platId).changes; + } + if (scope === 'remboursements') { + // Ne touche que les remboursements réels ; les investissements et leur échéancier restent. + counts.remboursements = db.prepare(` + DELETE FROM remboursements + WHERE investissement_id IN (SELECT id FROM investissements WHERE plateforme_id = ?) + `).run(platId).changes; + } + if (scope === 'investissements' || scope === 'all') { + // Compte avant suppression : les remboursements et l'échéancier simulé sont + // supprimés en cascade (ON DELETE CASCADE) par la suppression des investissements. + counts.remboursements = db.prepare(` + SELECT COUNT(*) AS n FROM remboursements + WHERE investissement_id IN (SELECT id FROM investissements WHERE plateforme_id = ?) + `).get(platId).n; + counts.simul_remboursements = db.prepare(` + SELECT COUNT(*) AS n FROM simul_remboursements + WHERE investissement_id IN (SELECT id FROM investissements WHERE plateforme_id = ?) + `).get(platId).n; + counts.investissements = db.prepare('DELETE FROM investissements WHERE plateforme_id = ?').run(platId).changes; + } + }); + tx(); + + res.json({ ok: true, scope, plateforme: plat.nom, counts }); + } catch (e) { next(e); } +}); + // ── Reset aux valeurs du référentiel ────────────────────────────────────── router.post('/:id/reset', (req, res, next) => { try { diff --git a/frontend/src/pages/settings/DataCleanupSection.jsx b/frontend/src/pages/settings/DataCleanupSection.jsx index 27caf93..756343e 100644 --- a/frontend/src/pages/settings/DataCleanupSection.jsx +++ b/frontend/src/pages/settings/DataCleanupSection.jsx @@ -1,10 +1,29 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { api } from '../../api.js'; function IconBroom() { return ; } +const PURGE_SCOPES = [ + { value: 'all', label: 'Toutes les données (dépôts/retraits + investissements + remboursements)' }, + { value: 'depots_retraits', label: 'Dépôts / Retraits uniquement' }, + { value: 'investissements', label: "Investissements (et leurs remboursements liés)" }, + { value: 'remboursements', label: 'Remboursements uniquement (les investissements sont conservés)' }, +]; + +function purgeSummary(counts, scope) { + const parts = []; + if (counts.depots_retraits > 0) parts.push(`${counts.depots_retraits} dépôt(s)/retrait(s)`); + if (counts.investissements > 0) parts.push(`${counts.investissements} investissement(s)`); + if (counts.remboursements > 0) parts.push(`${counts.remboursements} remboursement(s)`); + if (scope === 'investissements' && counts.simul_remboursements > 0) { + parts.push(`${counts.simul_remboursements} échéance(s) simulée(s)`); + } + if (parts.length === 0) return 'Aucune donnée à supprimer trouvée pour cette plateforme.'; + return `Supprimé : ${parts.join(', ')}.`; +} + export default function DataCleanupSection() { const [showModal, setShowModal] = useState(false); const [loading, setLoading] = useState(false); @@ -20,6 +39,46 @@ export default function DataCleanupSection() { const [successMsg, setSuccessMsg] = useState(null); const [errorMsg, setErrorMsg] = useState(null); + const [plats, setPlats] = useState([]); + const [purgePlatId, setPurgePlatId] = useState(''); + const [purgeScope, setPurgeScope] = useState('all'); + const [showPurgeModal, setShowPurgeModal] = useState(false); + const [purgeConfirmText, setPurgeConfirmText] = useState(''); + const [loadingPurge, setLoadingPurge] = useState(false); + + useEffect(() => { + api.get('/plateformes').then(setPlats).catch(() => {}); + }, []); + + const purgePlat = plats.find(p => String(p.id) === String(purgePlatId)) || null; + + const openPurgeModal = () => { + if (!purgePlat) return; + setPurgeConfirmText(''); + setShowPurgeModal(true); + }; + + const handlePurge = async () => { + if (!purgePlat || purgeConfirmText.trim() !== purgePlat.nom) return; + setLoadingPurge(true); + setErrorMsg(null); + setSuccessMsg(null); + try { + const r = await api.post(`/plateformes/${purgePlat.id}/purge-donnees`, { + scope: purgeScope, + confirmNom: purgeConfirmText.trim(), + }); + setSuccessMsg(`"${r.plateforme}" — ${purgeSummary(r.counts, purgeScope)}`); + setShowPurgeModal(false); + setPurgeConfirmText(''); + } catch (err) { + setErrorMsg(err.message || 'Une erreur est survenue.'); + setShowPurgeModal(false); + } finally { + setLoadingPurge(false); + } + }; + const handleReprocess = async () => { setLoadingReprocess(true); setErrorMsg(null); @@ -226,6 +285,41 @@ export default function DataCleanupSection() { +
+
+ Supprimer les données d'une plateforme +
+
+ Efface les données rattachées à une plateforme (utile pour repartir d'une base propre avant + une réimportation). La fiche plateforme elle-même n'est jamais supprimée par cette action. +
+
+
+ + +
+
+ + +
+ +
+
+
@@ -373,7 +467,50 @@ export default function DataCleanupSection() {
)} - + {showPurgeModal && purgePlat && ( +
setShowPurgeModal(false)}> +
e.stopPropagation()}> +
+

⚠ Suppression de données — action irréversible

+
+

+ Vous vous apprêtez à supprimer, pour la plateforme {purgePlat.nom} : +

+

+ {PURGE_SCOPES.find(s => s.value === purgeScope)?.label} +

+

+ La fiche plateforme "{purgePlat.nom}" est conservée. Cette opération ne peut pas être annulée. +

+
+ + setPurgeConfirmText(e.target.value)} + placeholder={purgePlat.nom} + disabled={loadingPurge} + style={{ width: '100%' }} + /> +
+
+ + +
+
+
+ )} + {showModal && (
setShowModal(false)}>
e.stopPropagation()}> diff --git a/frontend/src/pages/settings/ImportsSection.jsx b/frontend/src/pages/settings/ImportsSection.jsx index 57aea0d..e1de0cf 100644 --- a/frontend/src/pages/settings/ImportsSection.jsx +++ b/frontend/src/pages/settings/ImportsSection.jsx @@ -2,7 +2,7 @@ import { useState, useEffect, useRef } from 'react'; import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'; import * as XLSX from 'xlsx'; import { api } from '../../api.js'; -import { fmtDate } from '../../utils/format.js'; +import { fmtDate, fmtEUR } from '../../utils/format.js'; import { useInvestisseur } from '../../context/InvestisseurContext.jsx'; import ResultBanner from '../../components/ResultBanner.jsx'; @@ -319,6 +319,14 @@ const FIELD_HINTS_OVERRIDE = { taux_pfu: { prelev_sociaux: 'taux de prélèvements sociaux en % (nombre, ex: 17.2)', }, + remboursements: { + capital: 'part de capital remboursée, en euros (nombre). Voir la section RÈGLE CAPITAL vs INTÉRÊTS ci-dessous : ' + + 'ne déduis JAMAIS ce champ par simple lecture d\'une colonne "capital" si le fichier n\'en a pas — reconstitue-le ' + + 'si nécessaire à partir du montant total de la ligne et des prélèvements associés.', + interets_bruts: 'intérêts bruts perçus, en euros (nombre, avant prélèvements). ⚠️ Voir la section RÈGLE CAPITAL vs ' + + 'INTÉRÊTS ci-dessous avant de remplir ce champ : ne recopie JAMAIS aveuglément le montant total d\'une ligne de ' + + 'remboursement dans ce champ sans vérifier sa cohérence avec les prélèvements sociaux/IR associés.', + }, investissements: { nom_projet: 'trouve le nom du projet naturellement dans le fichier, puis FORMATE-le ainsi : ' + '"NOM DE L\'ÉMETTEUR EN MAJUSCULES - nom du projet trouvé" (ex: "SCI DUPONT - Résidence Les Tilleuls"). ' @@ -363,23 +371,47 @@ function buildReferenceSection(mod, plats, investissements, scopePlateforme) { return `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nPLATEFORME\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nCet import concerne EXCLUSIVEMENT la plateforme "${scopePlateforme.nom}". ` + `N'inclus PAS de champ plateforme_id dans le JSON généré (il est déjà connu et sera appliqué automatiquement à chaque ligne).\n\n`; } - if (plats.length > 0) { + if (plats.length === 1) { + const only = plats[0].nom; + return `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nPLATEFORME (une seule existante)\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nUne seule plateforme est enregistrée à ce jour : "${only}". Il n'y a donc AUCUNE ambiguïté possible : utilise "${only}" comme valeur de plateforme_id pour TOUTES les lignes générées, sans me poser de question — même si le fichier ne mentionne aucun nom de plateforme, aucun logo ni en-tête identifiable.\nNe me pose une question QUE dans le cas précis suivant : le fichier mentionne EXPLICITEMENT un nom de plateforme différent de "${only}" (texte, logo ou en-tête clairement identifiable). Dans ce seul cas, NE GÉNÈRE PAS le JSON tout de suite et indique-moi le nom trouvé en me demandant s'il s'agit d'une nouvelle plateforme à créer ou d'une erreur de fichier.\n\n`; + } + if (plats.length > 1) { const names = plats.map(p => p.nom).join(' | '); return `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nPLATEFORMES EXISTANTES (pour plateforme_id)\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nUtilise EXACTEMENT l'un de ces noms si la plateforme correspond à l'une d'elles :\n${names}\n\n⚠️ Si le fichier ne permet pas d'identifier avec certitude de quelle plateforme il provient (nom absent du fichier, logo/en-tête ambigu, etc.), NE DEVINE PAS et NE GÉNÈRE PAS le JSON tout de suite : pose-moi d'abord la question « De quelle plateforme provient ce fichier ? » en ne me proposant QUE ces réponses possibles :\n${names}\nAttends ma réponse, puis utilise le nom choisi comme valeur de plateforme_id pour TOUTES les lignes générées.\n\n`; } } - if (mod === 'remboursements' && investissements.length > 0) { - const names = investissements.map(i => i.nom_projet).join(' | '); - const scopeNote = scopePlateforme ? ` de la plateforme "${scopePlateforme.nom}"` : ''; - return `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nINVESTISSEMENTS EXISTANTS${scopeNote} (pour investissement_id)\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nPour CHAQUE ligne, recherche dans la liste ci-dessous le nom de projet dont la correspondance est la PLUS PROCHE ` - + `(émetteur, référence de commande/facture, mots-clés communs — même partiels ou approximatifs) et utilise EXACTEMENT ce nom comme ` - + `valeur d'investissement_id. Une correspondance approximative suffit : ne cherche pas une identité parfaite, une similarité claire et ` - + `raisonnable est acceptable. Ne me pose AUCUNE question ligne par ligne — traite tout le fichier directement.\n${names}\n\n` - + `⚠️ Ce n'est que si, après analyse de l'ensemble du fichier, une ou plusieurs lignes ne présentent VRAIMENT aucune ressemblance avec ` - + `l'un de ces noms (aucun mot commun, aucune référence reconnaissable) que tu peux me poser, à la toute fin, UNE SEULE question groupée ` - + `pour l'ensemble de ces lignes (pas une par ligne), du type « Je n'ai pas trouvé de correspondance fiable pour les lignes suivantes : ` - + `[...]. À quel investissement correspondent-elles ? » en ne me proposant QUE ces réponses possibles :\n${names}\n` - + `Sinon, génère directement le JSON complet pour toutes les lignes sans attendre de réponse.\n\n`; + if (mod === 'remboursements') { + let out = `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nRÈGLE CAPITAL vs INTÉRÊTS\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n` + + `De nombreux exports de plateformes affichent UNE SEULE ligne "Remboursement" / "Remboursement mensualité" par échéance, ` + + `dont le montant peut mélanger CAPITAL et INTÉRÊTS (prêts amortissables), accompagnée de une ou deux lignes de prélèvement ` + + `fiscal séparées juste avant/après dans le fichier (souvent nommées "CSG/CRDS", "Prélèvement IR/PFU", "Prélèvements sociaux", etc.). ` + + `Ne recopie JAMAIS le montant total d'une ligne de remboursement dans interets_bruts sans avoir vérifié ceci :\n` + + `1. Le taux global de prélèvement français sur des intérêts est d'environ 30 % (≈17,2 % de prélèvements sociaux + ≈12,8 % de prélèvement forfaitaire/IR).\n` + + `2. Calcule le ratio (prélèvements sociaux + prélèvement forfaitaire trouvés) ÷ (montant total de la ligne de remboursement). ` + + `Si ce ratio est PROCHE de 30 % (tolérance large, ex. 20 à 35 %), la ligne est un remboursement d'INTÉRÊTS PUR : interets_bruts = montant total de la ligne, n'inclus PAS de champ capital.\n` + + `3. Si ce ratio est NETTEMENT inférieur (quelques % voire < 1 %), la ligne mélange capital et intérêts : reconstitue le VRAI montant ` + + `d'intérêts bruts à partir des prélèvements eux-mêmes — interets_bruts ≈ prélèvement_sociaux ÷ 0,172, à recouper avec prélèvement_forfaitaire ÷ 0,128 ` + + `(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`; + + if (investissements.length > 0) { + const names = investissements.map(i => i.nom_projet).join(' | '); + const scopeNote = scopePlateforme ? ` de la plateforme "${scopePlateforme.nom}"` : ''; + out += `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nINVESTISSEMENTS EXISTANTS${scopeNote} (pour investissement_id)\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nPour CHAQUE ligne, recherche dans la liste ci-dessous le nom de projet dont la correspondance est la PLUS PROCHE ` + + `(émetteur, référence de commande/facture, mots-clés communs — même partiels ou approximatifs) et utilise EXACTEMENT ce nom comme ` + + `valeur d'investissement_id. Une correspondance approximative suffit : ne cherche pas une identité parfaite, une similarité claire et ` + + `raisonnable est acceptable. Ne me pose AUCUNE question ligne par ligne — traite tout le fichier directement.\n${names}\n\n` + + `⚠️ Ce n'est que si, après analyse de l'ensemble du fichier, une ou plusieurs lignes ne présentent VRAIMENT aucune ressemblance avec ` + + `l'un de ces noms (aucun mot commun, aucune référence reconnaissable) que tu peux me poser, à la toute fin, UNE SEULE question groupée ` + + `pour l'ensemble de ces lignes (pas une par ligne), du type « Je n'ai pas trouvé de correspondance fiable pour les lignes suivantes : ` + + `[...]. À quel investissement correspondent-elles ? » en ne me proposant QUE ces réponses possibles :\n${names}\n` + + `Sinon, génère directement le JSON complet pour toutes les lignes sans attendre de réponse.\n\n`; + } + return out; } return ''; } @@ -447,9 +479,18 @@ function IaImportPasteBlock({ moduleLabel, iaJson, setIaJson, iaErr, setIaErr, o resize: 'vertical', background: 'var(--surface-2)', border: '1px solid var(--border)', borderRadius: 6, padding: 10, color: 'var(--text)', boxSizing: 'border-box' }} /> {iaErr &&
{iaErr}
} - +
+ + +
)}
@@ -534,6 +575,173 @@ function IaImportPromptBlock({ module, moduleLabel, plats, investissements, scop ); } +/* ══════════════════════════════════════════════════════════════ + Modale de doublons — présente, ligne par ligne, la correspondance + trouvée en base en regard de la ligne importée, pour accepter ou + refuser chaque import en doublon (tous modules confondus). + ══════════════════════════════════════════════════════════════ */ + +/** Libellés humains des champs pouvant apparaître dans incoming/existing */ +const DUP_FIELD_LABELS = { + plateforme: 'Plateforme', date_operation: 'Date', type: 'Type', montant: 'Montant', + libelle: 'Libellé', reference: 'Référence', + nom_projet: 'Projet', date_souscription: 'Date souscription', montant_investi: 'Montant investi', + taux_interet: "Taux d'intérêt", duree_mois: 'Durée (mois)', statut: 'Statut', + investissement: 'Investissement', date_remb: 'Date remboursement', capital: 'Capital', + cashback: 'Cashback', interets_bruts: 'Intérêts bruts', prelev_sociaux: 'Prélèv. sociaux', + prelev_forfaitaire: 'Prélèv. forfaitaire', + nom: 'Nom', url: 'URL', notes: 'Notes', + annee: 'Année', pfu_total: 'PFU total (%)', impot_revenu: 'Impôt revenu (%)', +}; + +/** Formatage léger d'une valeur selon le nom du champ (date / montant / brut) */ +function fmtDupValue(field, value) { + if (value === null || value === undefined || value === '') return '—'; + if (field.startsWith('date_') || field === 'date_operation') return fmtDate(value); + if (['montant', 'montant_investi', 'capital', 'cashback', 'interets_bruts', 'prelev_sociaux', 'prelev_forfaitaire'].includes(field)) { + return fmtEUR(Number(value)); + } + if (['taux_interet', 'pfu_total', 'impot_revenu'].includes(field)) return `${value} %`; + return String(value); +} + +/** true si les deux valeurs diffèrent (comparaison souple, insensible au type) */ +function dupValuesDiffer(a, b) { + if (a === null || a === undefined) a = ''; + if (b === null || b === undefined) b = ''; + return String(a) !== String(b); +} + +/** Une ligne "doublon" repliable : case à cocher en tête de ligne (comme titre), détail masqué par défaut */ +function DuplicateRow({ d, decision, onDecisionChange, busy }) { + const [detailOpen, setDetailOpen] = useState(false); + const fields = [...new Set([...Object.keys(d.existing || {}), ...Object.keys(d.incoming || {})])]; + + return ( +
+
+ + +
+ {detailOpen && ( +
+ {d.matchedRow && ( +
+ Doublon avec la ligne {d.matchedRow} du même fichier (pas encore en base) +
+ )} + + + + + + {fields.map(f => { + const diff = dupValuesDiffer(d.existing?.[f], d.incoming?.[f]); + return ( + + + + + + ); + })} + +
Champ{d.matchedRow ? `Ligne ${d.matchedRow} du fichier` : 'Existant en base'}Ligne importée
{DUP_FIELD_LABELS[f] || f}{fmtDupValue(f, d.existing?.[f])} + {fmtDupValue(f, d.incoming?.[f])} +
+
+ )} +
+ ); +} + +function DuplicatesModal({ open, module, moduleLabel, duplicates, decisions, setDecisions, onConfirm, onCancel, busy }) { + if (!open) return null; + const isTauxPfu = module === 'taux_pfu'; + + const setAll = (decision) => { + const next = {}; + for (const d of duplicates) next[d.row] = decision; + setDecisions(next); + }; + + return ( +
+
e.stopPropagation()}> +
+

⚠ {duplicates.length} doublon{duplicates.length > 1 ? 's' : ''} détecté{duplicates.length > 1 ? 's' : ''} — {moduleLabel}

+
+

+ Ces lignes du fichier correspondent à des enregistrements déjà présents en base. + {isTauxPfu + ? ' Par défaut, le taux existant sera mis à jour avec les nouvelles valeurs (comme aujourd\'hui) — décochez pour le conserver tel quel.' + : ' Par défaut, elles sont ignorées — cochez pour les importer quand même.'} + {' '}Les autres lignes du fichier (non listées ici) seront importées normalement. + {' '}Cliquez sur la flèche pour voir le détail de la comparaison. +

+
+ + +
+
+ {duplicates.map(d => ( + setDecisions({ ...decisions, [d.row]: val })} + busy={busy} + /> + ))} +
+
+ + +
+
+
+ ); +} + /* ── Imports — composant dossier ─────────────────────────────── */ function DossierImport({ activeId, navigate, @@ -692,6 +900,16 @@ export default function ImportsSection() { api.get('/plateformes').then(setPlats).catch(() => {}); }, []); + // Ré-affiche le résultat du dernier import juste après le rechargement automatique de la + // page qui suit un import réussi (voir runApply) — sessionStorage survit au reload. + useEffect(() => { + const msg = sessionStorage.getItem('cl_import_last_result'); + if (msg) { + setResult({ ok: true, msg }); + sessionStorage.removeItem('cl_import_last_result'); + } + }, []); + // Nettoie le paramètre ?module= une fois consommé (évite de le reproposer au refresh) useEffect(() => { if (!searchParams.get('module')) return; @@ -799,27 +1017,64 @@ export default function ImportsSection() { await runPreview(iaFile, module); }; - const apply = async () => { + // Modale de doublons — se déclenche automatiquement au clic sur "Importer" (voir apply()). + const [dupModalOpen, setDupModalOpen] = useState(false); + const [dupList, setDupList] = useState([]); + const [dupDecisions, setDupDecisions] = useState({}); + const [dupBusy, setDupBusy] = useState(false); + + const runApply = async (duplicateDecisions = {}) => { setBusy(true); setErr(null); try { const r = await api.post('/imports/apply', { tempId: preview.tempId, module, mapping, defaults, originalFilename: file?.name ?? preview.filename, + duplicateDecisions, }); - setResult({ - ok: true, - 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))` : ''), - }); - setAnomalies(r.anomalies || []); - setPreview(null); setFile(null); setMapping({}); setDefaults({}); - api.get('/imports/history').then(setHistory).catch(() => {}); - if (module === 'plateformes') api.get('/plateformes').then(setPlats).catch(() => {}); + const 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))` : ''); + // Le résultat est gardé en session pour être ré-affiché juste après le rechargement + // complet de la page (voir useEffect au montage), le temps que l'historique/les listes + // se rafraîchissent dans un état propre. + sessionStorage.setItem('cl_import_last_result', msg); + setIaJson(''); setIaErr(null); + window.location.reload(); } catch (e) { setErr(e.message); } - finally { setBusy(false); } + finally { setBusy(false); setDupBusy(false); } + }; + + const apply = async () => { + if (!preview) return; + setBusy(true); setErr(null); + try { + const check = await api.post('/imports/check-duplicates', { + tempId: preview.tempId, module, mapping, defaults, + originalFilename: file?.name ?? preview.filename, + }); + if (check.duplicates && check.duplicates.length > 0) { + // Par défaut : ignorer (comportement historique), sauf pour taux_pfu où le comportement + // historique est d'écraser le taux existant. + const initial = {}; + for (const d of check.duplicates) initial[d.row] = module === 'taux_pfu' ? 'accept' : 'skip'; + setDupList(check.duplicates); + setDupDecisions(initial); + setDupModalOpen(true); + setBusy(false); + return; + } + await runApply({}); + } catch (e) { + setErr(e.message); + setBusy(false); + } + }; + + const confirmDuplicatesAndApply = async () => { + setDupBusy(true); + await runApply(dupDecisions); }; const fixDateOuverture = async (anomaly) => { @@ -1183,6 +1438,18 @@ export default function ImportsSection() { + + { setDupModalOpen(false); setDupList([]); setDupDecisions({}); }} + busy={dupBusy} + /> ); } diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 50d43d0..9e61c5e 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -724,11 +724,24 @@ tr:hover td { background: var(--surface-2); } } /* Modal */ -.modal-backdrop { +.modal-backdrop, .modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.55); display: flex; align-items: center; justify-content: center; z-index: 100; padding: 20px; } +/* Boîte de contenu des modales "ad hoc" (celles qui n'utilisent pas le composant + partagé Modal.jsx) : mêmes fondations visuelles que .card, avec gestion du + débordement pour les contenus longs (ex. listes de doublons). */ +.modal { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + box-shadow: var(--shadow); + padding: 20px; + width: 100%; + overflow: auto; +} +.modal-header { display: flex; justify-content: space-between; align-items: center; } /* Login */ .login-shell { min-height: 100vh; display: flex; align-items: center; justify-content: center; background: var(--bg); }