Amélioration de l'importation de donnée
This commit is contained in:
+223
-15
@@ -93,7 +93,14 @@ router.post('/preview', upload.single('file'), (req, res, next) => {
|
|||||||
} catch (e) { next(e); }
|
} 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 {
|
try {
|
||||||
const { tempId, module, mapping, defaults = {} } = req.body || {};
|
const { tempId, module, mapping, defaults = {} } = req.body || {};
|
||||||
if (!tempId || !module || !mapping) {
|
if (!tempId || !module || !mapping) {
|
||||||
@@ -102,6 +109,190 @@ router.post('/apply', (req, res, next) => {
|
|||||||
const def = MODULES[module];
|
const def = MODULES[module];
|
||||||
if (!def) throw new HttpError(400, 'Unknown 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
|
// Require a specific investisseur for transactional modules
|
||||||
if (INVESTISSEUR_SCOPED.includes(module)) {
|
if (INVESTISSEUR_SCOPED.includes(module)) {
|
||||||
requireInvestisseur(req, res, () => {});
|
requireInvestisseur(req, res, () => {});
|
||||||
@@ -160,6 +351,10 @@ router.post('/apply', (req, res, next) => {
|
|||||||
}
|
}
|
||||||
return defaults[target];
|
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') {
|
if (module === 'depots_retraits') {
|
||||||
const plateformeId = resolveRefId(v('plateforme_id'), platIdSet, platNameMap, 'Plateforme');
|
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
|
AND type = ? AND ABS(montant - ?) < 0.005
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`).get(req.investisseur.id, plateformeId, dateOperation, type, montant);
|
`).get(req.investisseur.id, plateformeId, dateOperation, type, montant);
|
||||||
if (dup) { duplicates++; continue; }
|
if (dup && rowDecision !== 'accept') { duplicates++; continue; }
|
||||||
|
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
INSERT INTO depots_retraits
|
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 = ?
|
WHERE investisseur_id = ? AND plateforme_id = ? AND nom_projet = ? AND date_souscription = ?
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`).get(req.investisseur.id, plateformeId, nomProjet, dateSouscription);
|
`).get(req.investisseur.id, plateformeId, nomProjet, dateSouscription);
|
||||||
if (dup) { duplicates++; continue; }
|
if (dup && rowDecision !== 'accept') { duplicates++; continue; }
|
||||||
|
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
INSERT INTO investissements
|
INSERT INTO investissements
|
||||||
@@ -248,7 +443,7 @@ router.post('/apply', (req, res, next) => {
|
|||||||
AND ABS(capital - ?) < 0.005 AND ABS(interets_bruts - ?) < 0.005
|
AND ABS(capital - ?) < 0.005 AND ABS(interets_bruts - ?) < 0.005
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`).get(investissementId, dateRemb, capital, bruts);
|
`).get(investissementId, dateRemb, capital, bruts);
|
||||||
if (dup) { duplicates++; continue; }
|
if (dup && rowDecision !== 'accept') { duplicates++; continue; }
|
||||||
|
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
INSERT INTO remboursements
|
INSERT INTO remboursements
|
||||||
@@ -266,21 +461,34 @@ router.post('/apply', (req, res, next) => {
|
|||||||
} else if (module === 'plateformes') {
|
} else if (module === 'plateformes') {
|
||||||
const nom = String(v('nom') || '').trim();
|
const nom = String(v('nom') || '').trim();
|
||||||
if (!nom) throw new Error('Le champ nom est vide');
|
if (!nom) throw new Error('Le champ nom est vide');
|
||||||
const r = db.prepare(`
|
const existingPlat = db.prepare('SELECT id FROM plateformes WHERE user_id = ? AND nom = ?').get(req.user.id, nom);
|
||||||
INSERT OR IGNORE INTO plateformes (user_id, nom, url, notes)
|
if (existingPlat) {
|
||||||
VALUES (?, ?, ?, ?)
|
// Une plateforme "nom" est unique par utilisateur/détenteur : impossible de créer un
|
||||||
`).run(
|
// second doublon en base. "Accepter" le doublon signifie donc ici mettre à jour la
|
||||||
req.user.id,
|
// fiche existante avec les valeurs importées, plutôt que de créer une nouvelle ligne.
|
||||||
nom,
|
if (rowDecision !== 'accept') { duplicates++; continue; }
|
||||||
v('url') || null,
|
db.prepare('UPDATE plateformes SET url = ?, notes = ? WHERE id = ?')
|
||||||
v('notes') || null,
|
.run(v('url') || null, v('notes') || null, existingPlat.id);
|
||||||
);
|
} else {
|
||||||
// changes = 0 means the row was ignored (nom already exists) — doublon, pas une erreur
|
db.prepare(`
|
||||||
if (r.changes === 0) { duplicates++; continue; }
|
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') {
|
} else if (module === 'taux_pfu') {
|
||||||
const annee = parseInt(v('annee'), 10);
|
const annee = parseInt(v('annee'), 10);
|
||||||
if (!annee || annee < 2000 || annee > 2100) throw new Error('Année invalide');
|
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(`
|
db.prepare(`
|
||||||
INSERT INTO taux_pfu (annee, pfu_total, impot_revenu, prelev_sociaux)
|
INSERT INTO taux_pfu (annee, pfu_total, impot_revenu, prelev_sociaux)
|
||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?)
|
||||||
|
|||||||
@@ -700,6 +700,61 @@ router.delete('/:id', (req, res, next) => {
|
|||||||
} catch (e) { next(e); }
|
} 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 ──────────────────────────────────────
|
// ── Reset aux valeurs du référentiel ──────────────────────────────────────
|
||||||
router.post('/:id/reset', (req, res, next) => {
|
router.post('/:id/reset', (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,10 +1,29 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { api } from '../../api.js';
|
import { api } from '../../api.js';
|
||||||
|
|
||||||
function IconBroom() {
|
function IconBroom() {
|
||||||
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M3 21l9-9"/><path d="M12.22 6.22L17 1.5l5.5 5.5-4.72 4.78"/><path d="M5 17c.5-2 2-3.5 4-4.5l3.5 3.5c-1 2-2.5 3.5-4.5 4"/></svg>;
|
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M3 21l9-9"/><path d="M12.22 6.22L17 1.5l5.5 5.5-4.72 4.78"/><path d="M5 17c.5-2 2-3.5 4-4.5l3.5 3.5c-1 2-2.5 3.5-4.5 4"/></svg>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
export default function DataCleanupSection() {
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -20,6 +39,46 @@ export default function DataCleanupSection() {
|
|||||||
const [successMsg, setSuccessMsg] = useState(null);
|
const [successMsg, setSuccessMsg] = useState(null);
|
||||||
const [errorMsg, setErrorMsg] = 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 () => {
|
const handleReprocess = async () => {
|
||||||
setLoadingReprocess(true);
|
setLoadingReprocess(true);
|
||||||
setErrorMsg(null);
|
setErrorMsg(null);
|
||||||
@@ -226,6 +285,41 @@ export default function DataCleanupSection() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: '14px 16px', borderRadius: 8,
|
||||||
|
border: '1px solid var(--border)', background: 'var(--bg-secondary, var(--bg))',
|
||||||
|
marginBottom: 10 }}>
|
||||||
|
<div style={{ fontWeight: 500, fontSize: 'var(--fs-sm)', marginBottom: 2 }}>
|
||||||
|
Supprimer les données d'une plateforme
|
||||||
|
</div>
|
||||||
|
<div className="text-muted" style={{ fontSize: 12, marginBottom: 12 }}>
|
||||||
|
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.
|
||||||
|
</div>
|
||||||
|
<div className="row" style={{ gap: 10, alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
||||||
|
<div style={{ flex: '1 1 220px', minWidth: 200 }}>
|
||||||
|
<label style={{ fontSize: 12 }}>Plateforme</label>
|
||||||
|
<select value={purgePlatId} onChange={e => setPurgePlatId(e.target.value)}>
|
||||||
|
<option value="">Sélectionnez une plateforme…</option>
|
||||||
|
{plats.map(p => <option key={p.id} value={p.id}>{p.nom}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: '1 1 280px', minWidth: 240 }}>
|
||||||
|
<label style={{ fontSize: 12 }}>Données à supprimer</label>
|
||||||
|
<select value={purgeScope} onChange={e => setPurgeScope(e.target.value)}>
|
||||||
|
{PURGE_SCOPES.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="danger"
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
onClick={openPurgeModal}
|
||||||
|
disabled={!purgePlatId || loadingPurge}
|
||||||
|
>
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
padding: '14px 16px', borderRadius: 8,
|
padding: '14px 16px', borderRadius: 8,
|
||||||
border: '1px solid var(--border)', background: 'var(--bg-secondary, var(--bg))' }}>
|
border: '1px solid var(--border)', background: 'var(--bg-secondary, var(--bg))' }}>
|
||||||
@@ -373,6 +467,49 @@ export default function DataCleanupSection() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showPurgeModal && purgePlat && (
|
||||||
|
<div className="modal-overlay" onClick={() => setShowPurgeModal(false)}>
|
||||||
|
<div className="modal" style={{ maxWidth: 480 }} onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="modal-header" style={{ borderBottom: '1px solid var(--border)', paddingBottom: 12, marginBottom: 16 }}>
|
||||||
|
<h3 style={{ margin: 0, color: 'var(--danger, #ef4444)' }}>⚠ Suppression de données — action irréversible</h3>
|
||||||
|
</div>
|
||||||
|
<p style={{ margin: '0 0 12px', lineHeight: 1.6 }}>
|
||||||
|
Vous vous apprêtez à supprimer, pour la plateforme <strong>{purgePlat.nom}</strong> :
|
||||||
|
</p>
|
||||||
|
<p style={{ margin: '0 0 12px', lineHeight: 1.6, fontFamily: 'monospace', fontSize: 13,
|
||||||
|
background: 'var(--surface-2, var(--bg))', padding: '8px 12px', borderRadius: 6,
|
||||||
|
border: '1px solid var(--border)' }}>
|
||||||
|
{PURGE_SCOPES.find(s => s.value === purgeScope)?.label}
|
||||||
|
</p>
|
||||||
|
<p style={{ margin: '0 0 16px', lineHeight: 1.6 }} className="text-muted">
|
||||||
|
La fiche plateforme "{purgePlat.nom}" est conservée. Cette opération ne peut pas être annulée.
|
||||||
|
</p>
|
||||||
|
<div style={{ margin: '0 0 20px' }}>
|
||||||
|
<label style={{ display: 'block', fontSize: 'var(--fs-sm)', fontWeight: 500, marginBottom: 6 }}>
|
||||||
|
Pour confirmer, retapez le nom exact de la plateforme : <strong>{purgePlat.nom}</strong>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={purgeConfirmText}
|
||||||
|
onChange={e => setPurgeConfirmText(e.target.value)}
|
||||||
|
placeholder={purgePlat.nom}
|
||||||
|
disabled={loadingPurge}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||||
|
<button onClick={() => setShowPurgeModal(false)} disabled={loadingPurge}>Annuler</button>
|
||||||
|
<button
|
||||||
|
className="danger"
|
||||||
|
onClick={handlePurge}
|
||||||
|
disabled={loadingPurge || purgeConfirmText.trim() !== purgePlat.nom}
|
||||||
|
>
|
||||||
|
{loadingPurge ? 'Suppression en cours…' : 'Confirmer la suppression'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{showModal && (
|
{showModal && (
|
||||||
<div className="modal-overlay" onClick={() => setShowModal(false)}>
|
<div className="modal-overlay" onClick={() => setShowModal(false)}>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useState, useEffect, useRef } from 'react';
|
|||||||
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import * as XLSX from 'xlsx';
|
import * as XLSX from 'xlsx';
|
||||||
import { api } from '../../api.js';
|
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 { useInvestisseur } from '../../context/InvestisseurContext.jsx';
|
||||||
import ResultBanner from '../../components/ResultBanner.jsx';
|
import ResultBanner from '../../components/ResultBanner.jsx';
|
||||||
|
|
||||||
@@ -319,6 +319,14 @@ const FIELD_HINTS_OVERRIDE = {
|
|||||||
taux_pfu: {
|
taux_pfu: {
|
||||||
prelev_sociaux: 'taux de prélèvements sociaux en % (nombre, ex: 17.2)',
|
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: {
|
investissements: {
|
||||||
nom_projet: 'trouve le nom du projet naturellement dans le fichier, puis FORMATE-le ainsi : '
|
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"). '
|
+ '"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}". `
|
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`;
|
+ `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(' | ');
|
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`;
|
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) {
|
if (mod === 'remboursements') {
|
||||||
const names = investissements.map(i => i.nom_projet).join(' | ');
|
let out = `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nRÈGLE CAPITAL vs INTÉRÊTS\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`
|
||||||
const scopeNote = scopePlateforme ? ` de la plateforme "${scopePlateforme.nom}"` : '';
|
+ `De nombreux exports de plateformes affichent UNE SEULE ligne "Remboursement" / "Remboursement mensualité" par échéance, `
|
||||||
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 `
|
+ `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 `
|
||||||
+ `(émetteur, référence de commande/facture, mots-clés communs — même partiels ou approximatifs) et utilise EXACTEMENT ce nom comme `
|
+ `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.). `
|
||||||
+ `valeur d'investissement_id. Une correspondance approximative suffit : ne cherche pas une identité parfaite, une similarité claire et `
|
+ `Ne recopie JAMAIS le montant total d'une ligne de remboursement dans interets_bruts sans avoir vérifié ceci :\n`
|
||||||
+ `raisonnable est acceptable. Ne me pose AUCUNE question ligne par ligne — traite tout le fichier directement.\n${names}\n\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`
|
||||||
+ `⚠️ Ce n'est que si, après analyse de l'ensemble du fichier, une ou plusieurs lignes ne présentent VRAIMENT aucune ressemblance avec `
|
+ `2. Calcule le ratio (prélèvements sociaux + prélèvement forfaitaire trouvés) ÷ (montant total de la ligne de remboursement). `
|
||||||
+ `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 `
|
+ `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`
|
||||||
+ `pour l'ensemble de ces lignes (pas une par ligne), du type « Je n'ai pas trouvé de correspondance fiable pour les lignes suivantes : `
|
+ `3. Si ce ratio est NETTEMENT inférieur (quelques % voire < 1 %), la ligne mélange capital et intérêts : reconstitue le VRAI montant `
|
||||||
+ `[...]. À quel investissement correspondent-elles ? » en ne me proposant QUE ces réponses possibles :\n${names}\n`
|
+ `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 `
|
||||||
+ `Sinon, génère directement le JSON complet pour toutes les lignes sans attendre de réponse.\n\n`;
|
+ `(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 '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -447,9 +479,18 @@ function IaImportPasteBlock({ moduleLabel, iaJson, setIaJson, iaErr, setIaErr, o
|
|||||||
resize: 'vertical', background: 'var(--surface-2)', border: '1px solid var(--border)',
|
resize: 'vertical', background: 'var(--surface-2)', border: '1px solid var(--border)',
|
||||||
borderRadius: 6, padding: 10, color: 'var(--text)', boxSizing: 'border-box' }} />
|
borderRadius: 6, padding: 10, color: 'var(--text)', boxSizing: 'border-box' }} />
|
||||||
{iaErr && <div className="error" style={{ marginTop: 8 }}>{iaErr}</div>}
|
{iaErr && <div className="error" style={{ marginTop: 8 }}>{iaErr}</div>}
|
||||||
<button className="primary" onClick={onAnalyze} disabled={!iaJson.trim() || busy} style={{ marginTop: 10 }}>
|
<div style={{ display: 'flex', gap: 8, marginTop: 10 }}>
|
||||||
{busy ? '…' : 'Analyser les données →'}
|
<button className="primary" onClick={onAnalyze} disabled={!iaJson.trim() || busy}>
|
||||||
</button>
|
{busy ? '…' : 'Analyser les données →'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setIaJson(''); setIaErr(null); }}
|
||||||
|
disabled={!iaJson || busy}
|
||||||
|
>
|
||||||
|
Vider
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -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 (
|
||||||
|
<div style={{ border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden' }}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 8,
|
||||||
|
padding: '10px 12px', background: 'var(--surface-2, var(--bg))',
|
||||||
|
}}>
|
||||||
|
<label style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 8, flex: 1, minWidth: 0, cursor: 'pointer',
|
||||||
|
// Neutralise le style global des <label> (légendes de champ en majuscules) :
|
||||||
|
// ici c'est le titre cliquable d'une ligne à cocher, pas une légende de formulaire.
|
||||||
|
textTransform: 'none', letterSpacing: 'normal', margin: 0, fontWeight: 400,
|
||||||
|
fontSize: 13, color: 'var(--text)',
|
||||||
|
}}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={decision === 'accept'}
|
||||||
|
disabled={busy}
|
||||||
|
// Le style global `input { width: 100%; padding: 7px 10px; }` (prévu pour les champs
|
||||||
|
// texte) s'applique aussi aux cases à cocher — on le neutralise ici explicitement
|
||||||
|
// (même correctif que .cat-select-item / .plat-multiselect-item dans ce fichier CSS).
|
||||||
|
style={{ width: 14, height: 14, padding: 0, flexShrink: 0, accentColor: 'var(--primary)' }}
|
||||||
|
onChange={e => onDecisionChange(e.target.checked ? 'accept' : 'skip')}
|
||||||
|
/>
|
||||||
|
<span style={{ fontWeight: 600 }}>
|
||||||
|
Doublon de données repéré en ligne {d.row} avec {d.matchedRow ? `celles de la ligne ${d.matchedRow}` : 'un enregistrement déjà en base'}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDetailOpen(o => !o)}
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4, display: 'flex', color: 'var(--text-muted)' }}
|
||||||
|
title={detailOpen ? 'Masquer la comparaison' : 'Voir la comparaison'}
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
|
||||||
|
style={{ transform: detailOpen ? 'rotate(180deg)' : 'none', transition: 'transform .2s' }}>
|
||||||
|
<polyline points="6 9 12 15 18 9"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{detailOpen && (
|
||||||
|
<div style={{ padding: '10px 12px', borderTop: '1px solid var(--border)' }}>
|
||||||
|
{d.matchedRow && (
|
||||||
|
<div className="text-muted" style={{ fontSize: 11, marginBottom: 6 }}>
|
||||||
|
Doublon avec la ligne {d.matchedRow} du même fichier (pas encore en base)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<table style={{ margin: 0, fontSize: 12 }}>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Champ</th><th>{d.matchedRow ? `Ligne ${d.matchedRow} du fichier` : 'Existant en base'}</th><th>Ligne importée</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{fields.map(f => {
|
||||||
|
const diff = dupValuesDiffer(d.existing?.[f], d.incoming?.[f]);
|
||||||
|
return (
|
||||||
|
<tr key={f}>
|
||||||
|
<td className="text-muted">{DUP_FIELD_LABELS[f] || f}</td>
|
||||||
|
<td>{fmtDupValue(f, d.existing?.[f])}</td>
|
||||||
|
<td style={diff ? { color: 'var(--warning)', fontWeight: 600 } : undefined}>
|
||||||
|
{fmtDupValue(f, d.incoming?.[f])}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="modal-overlay" onClick={onCancel}>
|
||||||
|
<div className="modal" style={{ maxWidth: 720, maxHeight: '85vh', display: 'flex', flexDirection: 'column' }} onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="modal-header" style={{ borderBottom: '1px solid var(--border)', paddingBottom: 12, marginBottom: 12 }}>
|
||||||
|
<h3 style={{ margin: 0 }}>⚠ {duplicates.length} doublon{duplicates.length > 1 ? 's' : ''} détecté{duplicates.length > 1 ? 's' : ''} — {moduleLabel}</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted" style={{ margin: '0 0 12px', fontSize: 13, lineHeight: 1.5 }}>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
|
||||||
|
<button type="button" style={{ fontSize: 12, padding: '4px 10px' }} onClick={() => setAll('accept')} disabled={busy}>
|
||||||
|
Cocher tous les doublons
|
||||||
|
</button>
|
||||||
|
<button type="button" style={{ fontSize: 12, padding: '4px 10px' }} onClick={() => setAll('skip')} disabled={busy}>
|
||||||
|
Décocher tous les doublons
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ overflowY: 'auto', flex: 1, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{duplicates.map(d => (
|
||||||
|
<DuplicateRow
|
||||||
|
key={d.row}
|
||||||
|
d={d}
|
||||||
|
decision={decisions[d.row] ?? (isTauxPfu ? 'accept' : 'skip')}
|
||||||
|
onDecisionChange={(val) => setDecisions({ ...decisions, [d.row]: val })}
|
||||||
|
busy={busy}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 14, paddingTop: 12, borderTop: '1px solid var(--border)' }}>
|
||||||
|
<button onClick={onCancel} disabled={busy}>Annuler l'import</button>
|
||||||
|
<button className="primary" onClick={onConfirm} disabled={busy}>
|
||||||
|
{busy ? 'Import en cours…' : 'Confirmer et importer'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Imports — composant dossier ─────────────────────────────── */
|
/* ── Imports — composant dossier ─────────────────────────────── */
|
||||||
function DossierImport({
|
function DossierImport({
|
||||||
activeId, navigate,
|
activeId, navigate,
|
||||||
@@ -692,6 +900,16 @@ export default function ImportsSection() {
|
|||||||
api.get('/plateformes').then(setPlats).catch(() => {});
|
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)
|
// Nettoie le paramètre ?module= une fois consommé (évite de le reproposer au refresh)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!searchParams.get('module')) return;
|
if (!searchParams.get('module')) return;
|
||||||
@@ -799,27 +1017,64 @@ export default function ImportsSection() {
|
|||||||
await runPreview(iaFile, module);
|
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);
|
setBusy(true); setErr(null);
|
||||||
try {
|
try {
|
||||||
const r = await api.post('/imports/apply', {
|
const r = await api.post('/imports/apply', {
|
||||||
tempId: preview.tempId, module, mapping, defaults,
|
tempId: preview.tempId, module, mapping, defaults,
|
||||||
originalFilename: file?.name ?? preview.filename,
|
originalFilename: file?.name ?? preview.filename,
|
||||||
|
duplicateDecisions,
|
||||||
});
|
});
|
||||||
setResult({
|
const msg = `✔ Import terminé : ${r.inserted} / ${r.total} lignes insérées`
|
||||||
ok: true,
|
+ (r.duplicates > 0 ? `, ${r.duplicates} doublon(s) ignoré(s)` : '')
|
||||||
msg: `✔ Import terminé : ${r.inserted} / ${r.total} lignes insérées`
|
+ (r.skipped > 0 ? `, ${r.skipped} ignorée(s)` : '')
|
||||||
+ (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
|
||||||
+ (r.errors?.length > 0 ? ` (${r.errors.length} avertissement(s))` : ''),
|
// complet de la page (voir useEffect au montage), le temps que l'historique/les listes
|
||||||
});
|
// se rafraîchissent dans un état propre.
|
||||||
setAnomalies(r.anomalies || []);
|
sessionStorage.setItem('cl_import_last_result', msg);
|
||||||
setPreview(null); setFile(null); setMapping({}); setDefaults({});
|
setIaJson(''); setIaErr(null);
|
||||||
api.get('/imports/history').then(setHistory).catch(() => {});
|
window.location.reload();
|
||||||
if (module === 'plateformes') api.get('/plateformes').then(setPlats).catch(() => {});
|
|
||||||
} catch (e) { setErr(e.message); }
|
} 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) => {
|
const fixDateOuverture = async (anomaly) => {
|
||||||
@@ -1183,6 +1438,18 @@ export default function ImportsSection() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<DuplicatesModal
|
||||||
|
open={dupModalOpen}
|
||||||
|
module={module}
|
||||||
|
moduleLabel={def?.label ?? module}
|
||||||
|
duplicates={dupList}
|
||||||
|
decisions={dupDecisions}
|
||||||
|
setDecisions={setDupDecisions}
|
||||||
|
onConfirm={confirmDuplicatesAndApply}
|
||||||
|
onCancel={() => { setDupModalOpen(false); setDupList([]); setDupDecisions({}); }}
|
||||||
|
busy={dupBusy}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-1
@@ -724,11 +724,24 @@ tr:hover td { background: var(--surface-2); }
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Modal */
|
/* Modal */
|
||||||
.modal-backdrop {
|
.modal-backdrop, .modal-overlay {
|
||||||
position: fixed; inset: 0; background: rgba(0,0,0,.55);
|
position: fixed; inset: 0; background: rgba(0,0,0,.55);
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
z-index: 100; padding: 20px;
|
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 */
|
||||||
.login-shell { min-height: 100vh; display: flex; align-items: center; justify-content: center; background: var(--bg); }
|
.login-shell { min-height: 100vh; display: flex; align-items: center; justify-content: center; background: var(--bg); }
|
||||||
|
|||||||
Reference in New Issue
Block a user