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() { +
+ 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. +
+| 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])} + | +
+ 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. +
+