diff --git a/backend/src/routes/imports.js b/backend/src/routes/imports.js index c9ed86b..37f145b 100644 --- a/backend/src/routes/imports.js +++ b/backend/src/routes/imports.js @@ -122,6 +122,21 @@ router.post('/apply', (req, res, next) => { const { rows } = parseFile(tempPath, origName || 'file.xlsx'); + // Résolution plateforme_id / investissement_id : accepte un ID numérique OU un nom + // (ex. colonne "Plateforme" contenant "BienPrêter" plutôt qu'un ID) — cf. resolveRefId. + let platIdSet = new Set(), platNameMap = 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])); + } + let invIdSet = new Set(), invNameMap = 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])); + } + let inserted = 0, skipped = 0; const errors = []; @@ -144,7 +159,7 @@ router.post('/apply', (req, res, next) => { VALUES (?,?,?,?,?,?,?,?) `).run( req.investisseur.id, - Number(v('plateforme_id')), + resolveRefId(v('plateforme_id'), platIdSet, platNameMap, 'Plateforme'), normaliseDate(v('date_operation')), normaliseType(v('type')), num(v('montant')), @@ -162,7 +177,7 @@ router.post('/apply', (req, res, next) => { VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) `).run( req.investisseur.id, - Number(v('plateforme_id')), + resolveRefId(v('plateforme_id'), platIdSet, platNameMap, 'Plateforme'), String(v('nom_projet')), v('emetteur') || null, normaliseDate(v('date_souscription')), @@ -192,7 +207,7 @@ router.post('/apply', (req, res, next) => { prelev_forfaitaire, interets_nets, net_recu, statut, source) VALUES (?,?,?,?,?,?,?,?,?,?,?) `).run( - Number(v('investissement_id')), + resolveRefId(v('investissement_id'), invIdSet, invNameMap, 'Investissement'), normaliseDate(v('date_remb')), capital, cashback, bruts, ps, pf, interets_nets, net_recu, v('statut') || 'paye', @@ -496,6 +511,35 @@ function stripAccents(s) { return String(s).normalize('NFD').replace(/[̀-ͯ]/g, ''); } +/** Normalise un nom pour comparaison insensible à la casse/accents (ex. plateforme, projet) */ +function normalizeName(s) { + return stripAccents(String(s || '')) + .toLowerCase() + .trim() + .replace(/\s+/g, ' '); +} + +/** + * Résout une valeur de colonne (ID numérique OU nom texte) vers l'ID cible réel. + * - Si la valeur est un entier, elle doit correspondre à un ID existant (idSet). + * - Sinon, elle est recherchée par nom normalisé (nameMap) — insensible casse/accents. + * Lève une erreur explicite si rien ne correspond (la ligne sera ignorée par l'appelant). + */ +function resolveRefId(value, idSet, nameMap, label) { + if (value === undefined || value === null || String(value).trim() === '') { + throw new Error(`${label} manquant`); + } + const raw = String(value).trim(); + if (/^\d+$/.test(raw)) { + const id = Number(raw); + if (idSet.has(id)) return id; + throw new Error(`${label} #${id} introuvable`); + } + const id = nameMap.get(normalizeName(raw)); + if (id == null) throw new Error(`${label} "${raw}" introuvable`); + return id; +} + /** Convertit une valeur monétaire en nombre (gère "€", espaces, virgule décimale) */ function num(v) { if (v === undefined || v === null || v === '') return 0; diff --git a/frontend/src/pages/DepotsRetraits.jsx b/frontend/src/pages/DepotsRetraits.jsx index 5270bb6..853e10e 100644 --- a/frontend/src/pages/DepotsRetraits.jsx +++ b/frontend/src/pages/DepotsRetraits.jsx @@ -60,11 +60,11 @@ function mouvToCSV(rows) { return BOM + [headers,...data].map(r => r.map(q).join(sep)).join('\r\n'); } -function mouvToXLS(rows) { +function mouvToXLS(rows, multiDetenteur) { const data = rows.map(r => ({ 'Date': r.date_operation, 'Plateforme': r.plateforme_nom || '', - 'Détenteur': r.plateforme_detenteur_nom || '', + ...(multiDetenteur ? { 'Détenteur': r.plateforme_detenteur_nom || '' } : {}), 'Type': r.type === 'depot' ? 'Dépôt' : 'Retrait', 'Montant (€)': r.montant, })); @@ -1155,7 +1155,7 @@ export default function DepotsRetraits() { dlBlob(mouvToCSV(rows), 'mouvements.csv', 'text/csv;charset=utf-8')} - onXLS={() => dlBlob(mouvToXLS(rows), 'mouvements.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')} + onXLS={() => dlBlob(mouvToXLS(rows, multiDetenteur), 'mouvements.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')} onJSON={() => dlBlob(mouvToJSON(rows), 'mouvements.json', 'application/json')} /> diff --git a/frontend/src/pages/settings/ImportsSection.jsx b/frontend/src/pages/settings/ImportsSection.jsx index c883d1b..aa95bc9 100644 --- a/frontend/src/pages/settings/ImportsSection.jsx +++ b/frontend/src/pages/settings/ImportsSection.jsx @@ -133,6 +133,74 @@ const MODULE_LABEL = { taux_pfu: 'Flat Tax — Taux PFU', }; +/** Normalise un en-tête de colonne pour reconnaissance auto : accents, casse, ponctuation ignorés */ +function normalizeHeader(s) { + return String(s) + .normalize('NFD').replace(/[̀-ͯ]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]/g, ''); +} + +/** Synonymes usuels d'en-têtes de fichier reconnus pour chaque champ cible (comparés via normalizeHeader) */ +const FIELD_SYNONYMS = { + date_operation: ['date'], + date_souscription: ['date'], + date_remb: ['date', 'dateremboursement'], + date_premiere_echeance:['datepremiereecheance', 'premiereecheance'], + date_cible: ['datecible'], + type: ['type'], + montant: ['montant'], + montant_investi: ['montant', 'montantinvesti', 'montantinvestissement'], + plateforme_id: ['plateforme'], + investissement_id: ['investissement', 'projet', 'nomprojet'], + nom_projet: ['projet', 'nomprojet', 'nom'], + nom: ['nom', 'plateforme'], + emetteur: ['emetteur'], + taux_interet: ['taux', 'tauxinteret'], + duree_mois: ['duree', 'dureemois'], + type_remb: ['typeremb', 'typederemboursement'], + freq_interets: ['frequence', 'freqinterets', 'periodicite'], + statut: ['statut'], + libelle: ['libelle', 'label', 'description'], + reference: ['reference', 'ref'], + notes: ['notes', 'commentaire', 'commentaires'], + url: ['url', 'siteweb', 'site'], + capital: ['capital'], + cashback: ['cashback'], + interets_bruts: ['interetsbruts', 'interets'], + prelev_sociaux: ['prelevementssociaux', 'prelevsociaux', 'ps'], + prelev_forfaitaire: ['prelevementforfaitaire', 'impotrevenu', 'ir'], + net_recu: ['netrecu', 'montantnet'], + annee: ['annee', 'year'], + pfu_total: ['pfutotal', 'pfu'], + impot_revenu: ['impotrevenu', 'impot'], +}; + +/** Champs pour lesquels la colonne mappée peut contenir un ID *ou* un nom texte (résolu côté serveur) */ +const NAME_RESOLVABLE_FIELDS = new Set(['plateforme_id', 'investissement_id']); + +/** Normalise un nom pour comparaison insensible casse/accents — miroir de normalizeName (backend) */ +function normalizeName(s) { + return String(s || '') + .normalize('NFD').replace(/[̀-ͯ]/g, '') + .toLowerCase() + .trim() + .replace(/\s+/g, ' '); +} + +/** + * Résout localement (aperçu) une valeur de colonne (ID ou nom) vers l'enregistrement correspondant, + * pour prévisualiser à l'utilisateur l'association qui sera faite côté serveur à l'import. + */ +function resolvePreviewMatch(raw, refList, nameKey) { + const rawStr = String(raw).trim(); + if (/^\d+$/.test(rawStr)) { + return refList.find(x => String(x.id) === rawStr) || null; + } + const norm = normalizeName(rawStr); + return refList.find(x => normalizeName(x[nameKey]) === norm) || null; +} + /* ── Imports — composant dossier ─────────────────────────────── */ function DossierImport({ activeId, navigate, @@ -297,11 +365,34 @@ export default function ImportsSection() { const r = await api.upload('/imports/preview', fd); setPreview(r); const auto = {}; + const usedHeaders = new Set(); for (const t of allTargets) { - const col = r.headers.find(h => h.toLowerCase().replace(/\W/g, '_') === t); - if (col) auto[t] = col; + // 1) correspondance exacte (ex. en-tête littéralement "plateforme_id") + let col = r.headers.find(h => !usedHeaders.has(h) && h.toLowerCase().replace(/\W/g, '_') === t); + // 2) correspondance par synonyme usuel (ex. "Plateforme", "Date", "Montant (€)") + if (!col) { + const syns = FIELD_SYNONYMS[t] || []; + col = r.headers.find(h => !usedHeaders.has(h) && syns.includes(normalizeHeader(h))); + } + if (col) { auto[t] = col; usedHeaders.add(col); } } setMapping(auto); + + // Pré-remplit la valeur par défaut quand toutes les lignes de l'échantillon + // se résolvent vers la même plateforme/investissement (confirme visuellement l'association). + const autoDefaults = {}; + for (const t of Object.keys(auto)) { + if (!NAME_RESOLVABLE_FIELDS.has(t)) continue; + const refList = t === 'plateforme_id' ? plats : investissements; + const nameKey = t === 'plateforme_id' ? 'nom' : 'nom_projet'; + const rawValues = [...new Set( + r.sampleRows.map(row => row[auto[t]]).filter(v => v !== null && v !== undefined && String(v).trim() !== '') + )]; + if (rawValues.length !== 1) continue; + const match = resolvePreviewMatch(rawValues[0], refList, nameKey); + if (match) autoDefaults[t] = String(match.id); + } + if (Object.keys(autoDefaults).length > 0) setDefaults(d => ({ ...d, ...autoDefaults })); } catch (e) { setErr(e.message); } finally { setBusy(false); } }; @@ -404,12 +495,49 @@ export default function ImportsSection() { {t} {isReq && *} + {NAME_RESOLVABLE_FIELDS.has(t) && ( +
+ ID ou nom accepté (reconnu automatiquement) +
+ )} + {NAME_RESOLVABLE_FIELDS.has(t) && mapping[t] && (() => { + const refList = t === 'plateforme_id' ? plats : investissements; + const nameKey = t === 'plateforme_id' ? 'nom' : 'nom_projet'; + const rawValues = [...new Set( + preview.sampleRows + .map(r => r[mapping[t]]) + .filter(v => v !== null && v !== undefined && String(v).trim() !== '') + )]; + if (rawValues.length === 0) return null; + return ( +
+ {rawValues.slice(0, 5).map(raw => { + const rawStr = String(raw).trim(); + const match = resolvePreviewMatch(rawStr, refList, nameKey); + return ( +
+ {match ? ( + ✓ « {rawStr} » → {match[nameKey]} + ) : ( + ✗ « {rawStr} » introuvable — ligne ignorée + )} +
+ ); + })} + {rawValues.length > 5 && ( +
+ +{rawValues.length - 5} autre(s) valeur(s) dans le fichier +
+ )} +
+ ); + })()} {t === 'plateforme_id' ? (