Améliorationde l'import
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
<ExportDropdown
|
||||
disabled={rows.length === 0}
|
||||
onCSV={() => 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')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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() {
|
||||
<td>
|
||||
<code style={{ fontSize: 11 }}>{t}</code>
|
||||
{isReq && <span style={{ color: 'var(--danger)' }}> *</span>}
|
||||
{NAME_RESOLVABLE_FIELDS.has(t) && (
|
||||
<div className="text-muted" style={{ fontSize: 10, marginTop: 2 }}>
|
||||
ID ou nom accepté (reconnu automatiquement)
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<select value={mapping[t] || ''} onChange={e => setMapping({ ...mapping, [t]: e.target.value })}>
|
||||
<option value="">— ignorer —</option>
|
||||
{preview.headers.map(h => <option key={h} value={h}>{h}</option>)}
|
||||
</select>
|
||||
{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 (
|
||||
<div style={{ marginTop: 6, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
{rawValues.slice(0, 5).map(raw => {
|
||||
const rawStr = String(raw).trim();
|
||||
const match = resolvePreviewMatch(rawStr, refList, nameKey);
|
||||
return (
|
||||
<div key={rawStr} style={{ fontSize: 11, lineHeight: 1.3 }}>
|
||||
{match ? (
|
||||
<span style={{ color: 'var(--success)' }}>✓ « {rawStr} » → {match[nameKey]}</span>
|
||||
) : (
|
||||
<span style={{ color: 'var(--danger)' }}>✗ « {rawStr} » introuvable — ligne ignorée</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{rawValues.length > 5 && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
+{rawValues.length - 5} autre(s) valeur(s) dans le fichier
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</td>
|
||||
<td>
|
||||
{t === 'plateforme_id' ? (
|
||||
|
||||
Reference in New Issue
Block a user