Amélioration de l'import

This commit is contained in:
2026-07-05 00:37:38 +02:00
parent 8bcd7e603d
commit 5e775da711
2 changed files with 287 additions and 90 deletions
+37
View File
@@ -344,6 +344,43 @@ router.post('/apply', (req, res, next) => {
} catch (e) { next(e); }
});
/**
* POST /api/imports/template
* Génère, à partir du fichier déjà analysé (preview) et du mappage/valeurs par défaut choisis,
* un jeu de données complet : toutes les colonnes du module cible (obligatoires + optionnelles),
* y compris celles qui n'ont pas de colonne source dans le fichier (laissées vides). L'utilisateur
* peut ainsi compléter/enrichir ce fichier avant de le réimporter.
*/
router.post('/template', (req, res, next) => {
try {
const { tempId, module, mapping = {}, defaults = {} } = req.body || {};
if (!tempId || !module) throw new HttpError(400, 'tempId et module sont requis');
const def = MODULES[module];
if (!def) throw new HttpError(400, 'Unknown module');
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');
const allTargets = [...def.requiredTargets, ...def.optionalTargets];
const v = (row, target) => {
const col = mapping[target];
if (col && row[col] !== undefined && row[col] !== null && row[col] !== '') return row[col];
return defaults[target] !== undefined ? defaults[target] : '';
};
const templateRows = rows.map(row => {
const out = {};
for (const t of allTargets) out[t] = v(row, t);
return out;
});
res.json({ headers: allTargets, rows: templateRows });
} catch (e) { next(e); }
});
/**
* POST /api/imports/dossier
* Importe un dossier investissement complet (format d'export natif).
+250 -90
View File
@@ -1,5 +1,6 @@
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 { useInvestisseur } from '../../context/InvestisseurContext.jsx';
@@ -13,6 +14,79 @@ function dlBlob(content, filename, type) {
URL.revokeObjectURL(url);
}
/** Horodatage local AAAAMMJJ_HHmmss (jamais toISOString(), cf. décalage UTC) */
function timestampSuffix() {
const d = new Date();
const pad = n => String(n).padStart(2, '0');
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
}
function templateToCSV(headers, rows) {
const BOM = ''; const sep = ';';
const q = v => `"${String(v ?? '').replace(/"/g, '""')}"`;
const data = rows.map(r => headers.map(h => r[h] ?? ''));
return BOM + [headers, ...data].map(r => r.map(q).join(sep)).join('\r\n');
}
function templateToXLS(headers, rows) {
const data = rows.map(r => {
const obj = {};
headers.forEach(h => { obj[h] = r[h] ?? ''; });
return obj;
});
const ws = XLSX.utils.json_to_sheet(data, { header: headers });
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Import');
return XLSX.write(wb, { type: 'array', bookType: 'xlsx' });
}
function templateToJSON(rows) {
return JSON.stringify(rows, null, 2);
}
/** Popup de choix de format, déclenché par un bouton texte (pas une icône) */
function TemplateDropdown({ disabled, busy, onCSV, onXLS, onJSON }) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
if (!open) return;
const h = e => { if (!ref.current?.contains(e.target)) setOpen(false); };
document.addEventListener('mousedown', h);
return () => document.removeEventListener('mousedown', h);
}, [open]);
const choose = fn => { setOpen(false); fn(); };
return (
<div ref={ref} style={{ position: 'relative', display: 'inline-block' }}>
<button type="button" disabled={disabled || busy} onClick={() => setOpen(o => !o)}>
{busy ? '…' : 'Générer un fichier pour compléter votre importation'}
</button>
{open && (
<div className="export-dropdown" role="menu">
<button role="menuitem" onClick={() => choose(onCSV)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>
<line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="16" y2="17"/>
</svg>
<span><strong>Format CSV</strong><small>Compatible Excel, LibreOffice</small></span>
</button>
<button role="menuitem" onClick={() => choose(onXLS)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="M9 13l2 2 4-4"/>
</svg>
<span><strong>Format Excel</strong><small>Fichier .xlsx Microsoft Excel</small></span>
</button>
<button role="menuitem" onClick={() => choose(onJSON)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>
<path d="M8 13h1.5a1 1 0 0 1 1 1v1a1 1 0 0 0 1 1 1 1 0 0 0-1 1v1a1 1 0 0 1-1 1H8"/>
<path d="M16 13h-1.5a1 1 0 0 0-1 1v1a1 1 0 0 1-1 1 1 1 0 0 1 1 1v1a1 1 0 0 0 1 1H16"/>
</svg>
<span><strong>Format JSON</strong><small>Réimportable, structuré</small></span>
</button>
</div>
)}
</div>
);
}
const countryLabel = code => COUNTRIES.find(c => c.code === code)?.name ?? code ?? '—';
const FISCALITE_LABELS = {
flat_tax: 'Flat Tax',
@@ -245,6 +319,25 @@ const FIELD_HINTS_OVERRIDE = {
taux_pfu: {
prelev_sociaux: 'taux de prélèvements sociaux en % (nombre, ex: 17.2)',
},
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"). '
+ 'Anti-doublon RENFORCÉ, à appliquer en 2 temps : '
+ '1) Avant de préfixer, cherche dans TOUT le nom du projet trouvé (pas seulement en tête) une occurrence du nom '
+ 'de l\'émetteur — y compris partielle ou abrégée (ex. "HMC" est une abréviation de "HMC GROUP", "MULTIPRINT" est '
+ 'le premier mot de "MULTIPRINT SAM") ou une variante proche (casse différente, "&" à la place de "ET", '
+ 'singulier/pluriel, ponctuation différente, ex. "QUALI PARTS & SERVICE" proche de "QUALI PARTS ET SERVICES"). '
+ '2) Si une telle occurrence existe n\'importe où dans le nom du projet trouvé, ne rajoute PAS le préfixe et '
+ 'SUPPRIME cette occurrence redondante du nom du projet (garde uniquement la partie descriptive restante, ex. '
+ '"HMC GROUP - HMC - Commande CSH25" devient "HMC GROUP - Commande CSH25", '
+ '"MULTIPRINT SAM - MULTIPRINT - ACM 06/2025" devient "MULTIPRINT SAM - ACM 06/2025", '
+ '"QUALI PARTS ET SERVICES - QUALI PARTS & SERVICE - Fact 981124" devient "QUALI PARTS ET SERVICES - Fact 981124"). '
+ 'Ne garde JAMAIS deux mentions (même partielles ou orthographiées différemment) du même émetteur dans le résultat final. '
+ 'Correction typographique : si le nom du projet contient des sigles ou des noms composés de segments courts '
+ 'séparés par des virgules (ex. "A,I,E,", "B,L,M,F,", "TWELVE,COM"), il s\'agit presque toujours d\'un point '
+ 'mal interprété (OCR) — remplace ces virgules par des points ("A.I.E.", "B.L.M.F.", "TWELVE.COM"). '
+ 'Si l\'émetteur est introuvable, laisse simplement le nom du projet tel quel, sans préfixe.',
},
};
function buildFieldsList(mod) {
@@ -347,9 +440,8 @@ function IaImportPasteBlock({ moduleLabel, iaJson, setIaJson, iaErr, setIaErr, o
}
/** Bloc collapsible : prompt IA dynamique (champs du module + plateformes/investissements existants) */
function IaImportPromptBlock({ module, moduleLabel, plats, investissements }) {
function IaImportPromptBlock({ module, moduleLabel, plats, investissements, open, setOpen }) {
const LS_KEY = 'cl_import_ia_prompt_template';
const [open, setOpen] = useState(false);
const [editing, setEditing] = useState(false);
const [prompt, setPrompt] = useState(() => localStorage.getItem(LS_KEY) || DEFAULT_IA_IMPORT_PROMPT);
const [draft, setDraft] = useState('');
@@ -474,7 +566,7 @@ function DossierImport({
return (
<div className="card">
<h3 style={{ margin: '0 0 4px' }}>Import Dossier investissement</h3>
<h3 style={{ margin: '0 0 4px' }}>Étape 2 - Précisez le fichier source de votre dossier d'investissement</h3>
<p className="text-muted" style={{ fontSize: 'var(--fs-sm)', marginBottom: 12 }}>
Restaure ou migre un dossier complet (investissement + remboursements + historique) depuis un fichier
<code style={{ margin: '0 4px' }}>.json</code> exporté par cette application.
@@ -552,6 +644,9 @@ export default function ImportsSection() {
const m = searchParams.get('module');
return MODULES[m] ? m : 'depots_retraits';
});
// null par défaut : seuls "Contexte" et "Historique" sont visibles au chargement.
// Si on arrive via un lien direct (?module=...), on ouvre directement "Fichier source".
const [activeMode, setActiveMode] = useState(() => searchParams.get('module') ? 'fichier' : null);
const [file, setFile] = useState(null);
const [preview, setPreview] = useState(null);
const [mapping, setMapping] = useState({});
@@ -564,6 +659,7 @@ export default function ImportsSection() {
const [err, setErr] = useState(null);
const [anomalies, setAnomalies] = useState([]);
const [fixingPlatId, setFixingPlatId] = useState(null);
const [templateBusy, setTemplateBusy] = useState(false);
const [dossierFile, setDossierFile] = useState(null);
const [dossierPreview, setDossierPreview] = useState(null);
@@ -652,6 +748,7 @@ export default function ImportsSection() {
/** Parse le JSON collé (issu d'un assistant IA) et l'envoie dans le même pipeline d'analyse. */
const [iaJson, setIaJson] = useState('');
const [iaErr, setIaErr] = useState(null);
const [iaPromptOpen, setIaPromptOpen] = useState(false);
const analyzeIaJson = async () => {
setIaErr(null);
let arr;
@@ -661,6 +758,7 @@ export default function ImportsSection() {
} catch (e) { setIaErr('JSON invalide : ' + e.message); return; }
if (!Array.isArray(arr)) { setIaErr("Le JSON doit être un tableau d'objets (une entrée par ligne)."); return; }
if (arr.length === 0) { setIaErr('Le tableau est vide.'); return; }
setIaPromptOpen(false); // replie le bloc "Prompt IA" une fois l'analyse lancée
const blob = new Blob([JSON.stringify(arr)], { type: 'application/json' });
const iaFile = new File([blob], 'import-ia.json', { type: 'application/json' });
await runPreview(iaFile, module);
@@ -701,22 +799,40 @@ export default function ImportsSection() {
finally { setFixingPlatId(null); }
};
const generateTemplate = async (format) => {
if (!preview) return;
setTemplateBusy(true);
try {
const r = await api.post('/imports/template', {
tempId: preview.tempId, module, mapping, defaults,
originalFilename: file?.name ?? preview.filename,
});
const base = `Import ${def?.label ?? module} - modele a completer ${timestampSuffix()}`;
if (format === 'csv') dlBlob(templateToCSV(r.headers, r.rows), `${base}.csv`, 'text/csv;charset=utf-8');
if (format === 'xls') dlBlob(templateToXLS(r.headers, r.rows), `${base}.xlsx`, 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
if (format === 'json') dlBlob(templateToJSON(r.rows), `${base}.json`, 'application/json');
} catch (e) { setErr(e.message); }
finally { setTemplateBusy(false); }
};
const multiDetenteur = new Set(plats.map(p => p.investisseur_id)).size > 1;
return (
<>
<div className="card">
<h3 style={{ margin: '0 0 4px' }}>1. Contexte de l'import</h3>
<h3 style={{ margin: '0 0 4px' }}>Étape 1 - Précisez le contexte de l'import</h3>
<p className="text-muted" style={{ margin: '0 0 16px', fontSize: 'var(--fs-sm)' }}>
Choisissez le type de données à importer : les blocs ci-dessous s'adaptent automatiquement
(champs attendus, prompt IA, dossier investissement).
</p>
<div className="row">
<div style={{ flex: 1 }}>
<div style={{ flex: '0 0 auto', width: 280 }}>
<label>Module cible</label>
<select value={module} onChange={e => {
setModule(e.target.value);
const v = e.target.value;
setModule(v);
setPreview(null); setMapping({}); setResult(null); setErr(null);
if (v !== 'investissements' && activeMode === 'dossier') setActiveMode(null);
}}>
{Object.entries(MODULES).map(([k, v]) => (
<option key={k} value={k}>{v.label}</option>
@@ -725,6 +841,35 @@ export default function ImportsSection() {
</div>
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 14, flexWrap: 'wrap' }}>
<button
type="button"
className={activeMode === 'fichier' ? 'primary' : 'secondary'}
style={{ flex: '0 0 auto', width: 'auto' }}
onClick={() => setActiveMode(m => m === 'fichier' ? null : 'fichier')}
>
Import par fichier source
</button>
<button
type="button"
className={activeMode === 'ia' ? 'primary' : 'secondary'}
style={{ flex: '0 0 auto', width: 'auto' }}
onClick={() => setActiveMode(m => m === 'ia' ? null : 'ia')}
>
Import avec l'aide de l'IA
</button>
{module === 'investissements' && (
<button
type="button"
className={activeMode === 'dossier' ? 'primary' : 'secondary'}
style={{ flex: '0 0 auto', width: 'auto' }}
onClick={() => setActiveMode(m => m === 'dossier' ? null : 'dossier')}
>
Import par dossier d'investissement
</button>
)}
</div>
{def?.note && (
<div className="import-module-note" style={{ marginTop: 12 }}>
{def.global && (
@@ -746,60 +891,106 @@ export default function ImportsSection() {
)}
</div>
<div className="card">
<h3 style={{ margin: '0 0 4px' }}>2. Fichier source</h3>
<p className="text-muted" style={{ margin: '0 0 16px', fontSize: 'var(--fs-sm)' }}>
Importez des données depuis un fichier Excel, CSV ou JSON pour le module <strong>{def?.label ?? module}</strong>.
</p>
<div className="row">
<div style={{ flex: 2 }}>
<label>Fichier .xlsx, .csv ou .json</label>
<input type="file" accept=".xlsx,.xls,.csv,.json" onChange={e => {
setFile(e.target.files[0]);
setPreview(null); setResult(null); setErr(null);
}} />
</div>
<div>
<button className="primary" onClick={onPreview} disabled={!file || busy || missingInv}>
{busy ? '' : 'Analyser'}
</button>
{activeMode === 'fichier' && (
<div className="card">
<h3 style={{ margin: '0 0 4px' }}>Étape 2 - Précisez le fichier source</h3>
<p className="text-muted" style={{ margin: '0 0 16px', fontSize: 'var(--fs-sm)' }}>
Importez des données depuis un fichier Excel, CSV ou JSON pour le module <strong>{def?.label ?? module}</strong>.
</p>
<div className="row">
<div style={{ flex: 2 }}>
<label>Fichier .xlsx, .csv ou .json</label>
<input type="file" accept=".xlsx,.xls,.csv,.json" onChange={e => {
setFile(e.target.files[0]);
setPreview(null); setResult(null); setErr(null);
}} />
</div>
<div>
<button className="primary" onClick={onPreview} disabled={!file || busy || missingInv}>
{busy ? '…' : 'Analyser'}
</button>
</div>
</div>
</div>
)}
{err && <div className="error" style={{ marginTop: 12 }}>{err}</div>}
<ResultBanner result={result} onDismiss={() => setResult(null)} style={{ marginTop: 12 }} />
{activeMode === 'dossier' && module === 'investissements' && (
<DossierImport
activeId={activeId}
navigate={navigate}
dossierFile={dossierFile} setDossierFile={setDossierFile}
dossierPreview={dossierPreview} setDossierPreview={setDossierPreview}
dossierResult={dossierResult} setDossierResult={setDossierResult}
dossierBusy={dossierBusy} setDossierBusy={setDossierBusy}
dossierErr={dossierErr} setDossierErr={setDossierErr}
dossierInputRef={dossierInputRef}
reloadHistory={() => api.get('/imports/history').then(setHistory).catch(() => {})}
/>
)}
{anomalies.length > 0 && (
<div style={{ marginTop: 12, display: 'flex', flexDirection: 'column', gap: 8 }}>
{anomalies.map(a => (
<div key={a.plateforme_id} style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12,
padding: '10px 14px', borderRadius: 8,
background: 'var(--surface-2)', border: '1px solid var(--warning)',
}}>
<span style={{ fontSize: 13 }}>
⚠ <strong>{a.plateforme_nom}</strong> : des données importées remontent au{' '}
<strong>{fmtDate(a.date_detectee)}</strong>, avant la date d'ouverture déclarée
({fmtDate(a.date_ouverture_actuelle)}).
</span>
<button
className="secondary"
onClick={() => fixDateOuverture(a)}
disabled={fixingPlatId === a.plateforme_id}
style={{ flexShrink: 0, whiteSpace: 'nowrap' }}
>
{fixingPlatId === a.plateforme_id ? '…' : `Corriger la date d'ouverture (${fmtDate(a.date_detectee)})`}
</button>
</div>
))}
</div>
)}
</div>
{activeMode === 'ia' && (
<div className="card">
<h3 style={{ margin: '0 0 4px' }}>Étape 2 - Aidez-vous d'une IA pour préparer les données à importer</h3>
<p className="text-muted" style={{ margin: '0 0 14px', fontSize: 'var(--fs-sm)' }}>
Utilisez un assistant IA pour convertir l'export de votre plateforme (CSV, Excel, PDF) en données structurées,
sans mappage manuel des colonnes. Module cible actuel : <strong>{def?.label ?? module}</strong>
{' '}(modifiable en section 1 ci-dessus).
</p>
<IaImportPasteBlock
moduleLabel={def?.label ?? module}
iaJson={iaJson} setIaJson={setIaJson}
iaErr={iaErr} setIaErr={setIaErr}
onAnalyze={analyzeIaJson}
busy={busy}
/>
<IaImportPromptBlock
module={module}
moduleLabel={def?.label ?? module}
plats={plats}
investissements={investissements}
open={iaPromptOpen}
setOpen={setIaPromptOpen}
/>
</div>
)}
{(err || result || anomalies.length > 0) && (
<div className="card">
{err && <div className="error">{err}</div>}
<ResultBanner result={result} onDismiss={() => setResult(null)} />
{anomalies.length > 0 && (
<div style={{ marginTop: err || result ? 12 : 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
{anomalies.map(a => (
<div key={a.plateforme_id} style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12,
padding: '10px 14px', borderRadius: 8,
background: 'var(--surface-2)', border: '1px solid var(--warning)',
}}>
<span style={{ fontSize: 13 }}>
<strong>{a.plateforme_nom}</strong> : des données importées remontent au{' '}
<strong>{fmtDate(a.date_detectee)}</strong>, avant la date d'ouverture déclarée
({fmtDate(a.date_ouverture_actuelle)}).
</span>
<button
className="secondary"
onClick={() => fixDateOuverture(a)}
disabled={fixingPlatId === a.plateforme_id}
style={{ flexShrink: 0, whiteSpace: 'nowrap' }}
>
{fixingPlatId === a.plateforme_id ? '' : `Corriger la date d'ouverture (${fmtDate(a.date_detectee)})`}
</button>
</div>
))}
</div>
)}
</div>
)}
{preview && (
<>
<div className="card">
<h3 style={{ marginTop: 0 }}>3. Mappage des colonnes</h3>
<h3 style={{ marginTop: 0 }}>Mappage des colonnes</h3>
<p className="text-muted" style={{ fontSize: 12 }}>
Fichier : <strong>{preview.filename}</strong> — feuille <em>{preview.sheetName}</em> — {preview.allRowCount} lignes.
{' '}Champs marqués <span style={{ color: 'var(--danger)' }}>*</span> obligatoires.
@@ -887,6 +1078,13 @@ export default function ImportsSection() {
<button className="primary" onClick={apply} disabled={busy || missingInv}>
{busy ? '…' : `Importer ${preview.allRowCount} lignes`}
</button>
<TemplateDropdown
disabled={missingInv}
busy={templateBusy}
onCSV={() => generateTemplate('csv')}
onXLS={() => generateTemplate('xls')}
onJSON={() => generateTemplate('json')}
/>
</div>
</div>
@@ -906,44 +1104,6 @@ export default function ImportsSection() {
</>
)}
{/* Le dossier investissement (capital + remboursements + historique) n'a de sens
que dans le contexte du module Investissements. */}
{module === 'investissements' && (
<DossierImport
activeId={activeId}
navigate={navigate}
dossierFile={dossierFile} setDossierFile={setDossierFile}
dossierPreview={dossierPreview} setDossierPreview={setDossierPreview}
dossierResult={dossierResult} setDossierResult={setDossierResult}
dossierBusy={dossierBusy} setDossierBusy={setDossierBusy}
dossierErr={dossierErr} setDossierErr={setDossierErr}
dossierInputRef={dossierInputRef}
reloadHistory={() => api.get('/imports/history').then(setHistory).catch(() => {})}
/>
)}
<div className="card">
<h3 style={{ margin: '0 0 4px' }}>Import généré par IA</h3>
<p className="text-muted" style={{ margin: '0 0 14px', fontSize: 'var(--fs-sm)' }}>
Utilisez un assistant IA pour convertir l'export de votre plateforme (CSV, Excel, PDF…) en données structurées,
sans mappage manuel des colonnes. Module cible actuel : <strong>{def?.label ?? module}</strong>
{' '}(modifiable en section 1 ci-dessus).
</p>
<IaImportPasteBlock
moduleLabel={def?.label ?? module}
iaJson={iaJson} setIaJson={setIaJson}
iaErr={iaErr} setIaErr={setIaErr}
onAnalyze={analyzeIaJson}
busy={busy}
/>
<IaImportPromptBlock
module={module}
moduleLabel={def?.label ?? module}
plats={plats}
investissements={investissements}
/>
</div>
<div className="card">
<h3 style={{ marginTop: 0 }}>Historique des imports</h3>
<table>