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); }
|
||||
});
|
||||
|
||||
router.post('/apply', (req, res, next) => {
|
||||
/**
|
||||
* POST /api/imports/check-duplicates
|
||||
* Même entrée que /apply (tempId, module, mapping, defaults) mais AUCUNE écriture en base :
|
||||
* rejoue la détection anti-doublon module par module et renvoie, pour chaque ligne concernée,
|
||||
* les valeurs de la ligne importée en regard de l'enregistrement existant en base, afin que
|
||||
* l'utilisateur puisse décider ligne par ligne (modale de doublons) avant l'import réel.
|
||||
*/
|
||||
router.post('/check-duplicates', (req, res, next) => {
|
||||
try {
|
||||
const { tempId, module, mapping, defaults = {} } = req.body || {};
|
||||
if (!tempId || !module || !mapping) {
|
||||
@@ -102,6 +109,190 @@ router.post('/apply', (req, res, next) => {
|
||||
const def = MODULES[module];
|
||||
if (!def) throw new HttpError(400, 'Unknown module');
|
||||
|
||||
if (INVESTISSEUR_SCOPED.includes(module)) {
|
||||
requireInvestisseur(req, res, () => {});
|
||||
}
|
||||
|
||||
const tempPath = path.join(UPLOAD_DIR, tempId);
|
||||
if (!fs.existsSync(tempPath)) throw new HttpError(404, 'Uploaded file expired');
|
||||
|
||||
const origName = req.body.originalFilename || '';
|
||||
const { rows } = parseFile(tempPath, origName || 'file.xlsx');
|
||||
|
||||
let platIdSet = new Set(), platNameMap = new Map(), platNomById = new Map();
|
||||
if (module === 'depots_retraits' || module === 'investissements') {
|
||||
const platRows = db.prepare('SELECT id, nom FROM plateformes WHERE user_id = ?').all(req.user.id);
|
||||
platIdSet = new Set(platRows.map(p => p.id));
|
||||
platNameMap = new Map(platRows.map(p => [normalizeName(p.nom), p.id]));
|
||||
platNomById = new Map(platRows.map(p => [p.id, p.nom]));
|
||||
}
|
||||
let invIdSet = new Set(), invNameMap = new Map(), invNomById = new Map();
|
||||
if (module === 'remboursements') {
|
||||
const invRows = db.prepare('SELECT id, nom_projet FROM investissements WHERE investisseur_id = ?').all(req.investisseur.id);
|
||||
invIdSet = new Set(invRows.map(i => i.id));
|
||||
invNameMap = new Map(invRows.map(i => [normalizeName(i.nom_projet), i.id]));
|
||||
invNomById = new Map(invRows.map(i => [i.id, i.nom_projet]));
|
||||
}
|
||||
|
||||
const duplicates = [];
|
||||
// Doublons INTERNES au fichier : /apply traite les lignes une à une dans une seule transaction,
|
||||
// donc une ligne répétée plus loin dans le même fichier se retrouve comparée à la précédente
|
||||
// occurrence tout juste "insérée" — pas seulement aux enregistrements déjà présents en base.
|
||||
// Comme cette route ne fait aucune écriture, on simule cet effet avec une map en mémoire :
|
||||
// clé de dédoublonnage → { row, incoming } de la première occurrence rencontrée.
|
||||
const seenDepotsRetraits = new Map();
|
||||
const seenInvestissements = new Map();
|
||||
const seenRemboursements = new Map();
|
||||
const seenPlateformes = new Map();
|
||||
const seenTauxPfu = new Map();
|
||||
|
||||
for (let idx = 0; idx < rows.length; idx++) {
|
||||
const row = rows[idx];
|
||||
const v = (target) => {
|
||||
const col = mapping[target];
|
||||
if (col && row[col] !== undefined && row[col] !== null && row[col] !== '') return row[col];
|
||||
return defaults[target];
|
||||
};
|
||||
const rowNum = idx + 2;
|
||||
|
||||
try {
|
||||
if (module === 'depots_retraits') {
|
||||
const plateformeId = resolveRefId(v('plateforme_id'), platIdSet, platNameMap, 'Plateforme');
|
||||
const dateOperation = normaliseDate(v('date_operation'));
|
||||
const type = normaliseType(v('type'));
|
||||
const montant = num(v('montant'));
|
||||
const incoming = {
|
||||
plateforme: platNomById.get(plateformeId) || '', date_operation: dateOperation,
|
||||
type, montant, libelle: v('libelle') || null, reference: v('reference') || null,
|
||||
};
|
||||
const existing = db.prepare(`
|
||||
SELECT * FROM depots_retraits
|
||||
WHERE investisseur_id = ? AND plateforme_id = ? AND date_operation = ?
|
||||
AND type = ? AND ABS(montant - ?) < 0.005
|
||||
LIMIT 1
|
||||
`).get(req.investisseur.id, plateformeId, dateOperation, type, montant);
|
||||
const key = `${plateformeId}|${dateOperation}|${type}|${montant.toFixed(2)}`;
|
||||
const seen = seenDepotsRetraits.get(key);
|
||||
if (existing) {
|
||||
duplicates.push({ row: rowNum, incoming, existing: {
|
||||
plateforme: platNomById.get(existing.plateforme_id) || '', date_operation: existing.date_operation,
|
||||
type: existing.type, montant: existing.montant, libelle: existing.libelle, reference: existing.reference,
|
||||
} });
|
||||
} else if (seen) {
|
||||
duplicates.push({ row: rowNum, matchedRow: seen.row, incoming, existing: seen.incoming });
|
||||
} else {
|
||||
seenDepotsRetraits.set(key, { row: rowNum, incoming });
|
||||
}
|
||||
} else if (module === 'investissements') {
|
||||
const plateformeId = resolveRefId(v('plateforme_id'), platIdSet, platNameMap, 'Plateforme');
|
||||
const nomProjet = String(v('nom_projet'));
|
||||
const dateSouscription = normaliseDate(v('date_souscription'));
|
||||
const incoming = {
|
||||
plateforme: platNomById.get(plateformeId) || '', nom_projet: nomProjet, date_souscription: dateSouscription,
|
||||
montant_investi: num(v('montant_investi')), taux_interet: v('taux_interet') || null,
|
||||
duree_mois: v('duree_mois') || null, statut: v('statut') || 'en_cours',
|
||||
};
|
||||
const existing = db.prepare(`
|
||||
SELECT * FROM investissements
|
||||
WHERE investisseur_id = ? AND plateforme_id = ? AND nom_projet = ? AND date_souscription = ?
|
||||
LIMIT 1
|
||||
`).get(req.investisseur.id, plateformeId, nomProjet, dateSouscription);
|
||||
const key = `${plateformeId}|${normalizeName(nomProjet)}|${dateSouscription}`;
|
||||
const seen = seenInvestissements.get(key);
|
||||
if (existing) {
|
||||
duplicates.push({ row: rowNum, incoming, existing: {
|
||||
plateforme: platNomById.get(existing.plateforme_id) || '', nom_projet: existing.nom_projet,
|
||||
date_souscription: existing.date_souscription, montant_investi: existing.montant_investi,
|
||||
taux_interet: existing.taux_interet, duree_mois: existing.duree_mois, statut: existing.statut,
|
||||
} });
|
||||
} else if (seen) {
|
||||
duplicates.push({ row: rowNum, matchedRow: seen.row, incoming, existing: seen.incoming });
|
||||
} else {
|
||||
seenInvestissements.set(key, { row: rowNum, incoming });
|
||||
}
|
||||
} else if (module === 'remboursements') {
|
||||
const investissementId = resolveRefId(v('investissement_id'), invIdSet, invNameMap, 'Investissement');
|
||||
const dateRemb = normaliseDate(v('date_remb'));
|
||||
const capital = num(v('capital'));
|
||||
const bruts = num(v('interets_bruts'));
|
||||
const incoming = {
|
||||
investissement: invNomById.get(investissementId) || '', date_remb: dateRemb, capital,
|
||||
cashback: num(v('cashback')), interets_bruts: bruts,
|
||||
prelev_sociaux: num(v('prelev_sociaux')), prelev_forfaitaire: num(v('prelev_forfaitaire')),
|
||||
statut: v('statut') || 'paye',
|
||||
};
|
||||
const existing = db.prepare(`
|
||||
SELECT * FROM remboursements
|
||||
WHERE investissement_id = ? AND date_remb = ?
|
||||
AND ABS(capital - ?) < 0.005 AND ABS(interets_bruts - ?) < 0.005
|
||||
LIMIT 1
|
||||
`).get(investissementId, dateRemb, capital, bruts);
|
||||
const key = `${investissementId}|${dateRemb}|${capital.toFixed(2)}|${bruts.toFixed(2)}`;
|
||||
const seen = seenRemboursements.get(key);
|
||||
if (existing) {
|
||||
duplicates.push({ row: rowNum, incoming, existing: {
|
||||
investissement: invNomById.get(existing.investissement_id) || '', date_remb: existing.date_remb,
|
||||
capital: existing.capital, cashback: existing.cashback, interets_bruts: existing.interets_bruts,
|
||||
prelev_sociaux: existing.prelev_sociaux, prelev_forfaitaire: existing.prelev_forfaitaire,
|
||||
statut: existing.statut,
|
||||
} });
|
||||
} else if (seen) {
|
||||
duplicates.push({ row: rowNum, matchedRow: seen.row, incoming, existing: seen.incoming });
|
||||
} else {
|
||||
seenRemboursements.set(key, { row: rowNum, incoming });
|
||||
}
|
||||
} else if (module === 'plateformes') {
|
||||
const nom = String(v('nom') || '').trim();
|
||||
if (!nom) continue;
|
||||
const incoming = { nom, url: v('url') || null, notes: v('notes') || null };
|
||||
const existing = db.prepare('SELECT * FROM plateformes WHERE user_id = ? AND nom = ?').get(req.user.id, nom);
|
||||
const key = nom;
|
||||
const seen = seenPlateformes.get(key);
|
||||
if (existing) {
|
||||
duplicates.push({ row: rowNum, incoming, existing: { nom: existing.nom, url: existing.url, notes: existing.notes } });
|
||||
} else if (seen) {
|
||||
duplicates.push({ row: rowNum, matchedRow: seen.row, incoming, existing: seen.incoming });
|
||||
} else {
|
||||
seenPlateformes.set(key, { row: rowNum, incoming });
|
||||
}
|
||||
} else if (module === 'taux_pfu') {
|
||||
const annee = parseInt(v('annee'), 10);
|
||||
if (!annee) continue;
|
||||
const incoming = {
|
||||
annee, pfu_total: num(v('pfu_total')), impot_revenu: num(v('impot_revenu')), prelev_sociaux: num(v('prelev_sociaux')),
|
||||
};
|
||||
const existing = db.prepare('SELECT * FROM taux_pfu WHERE annee = ?').get(annee);
|
||||
const key = annee;
|
||||
const seen = seenTauxPfu.get(key);
|
||||
if (existing) {
|
||||
duplicates.push({ row: rowNum, incoming, existing: {
|
||||
annee: existing.annee, pfu_total: existing.pfu_total, impot_revenu: existing.impot_revenu, prelev_sociaux: existing.prelev_sociaux,
|
||||
} });
|
||||
} else if (seen) {
|
||||
duplicates.push({ row: rowNum, matchedRow: seen.row, incoming, existing: seen.incoming });
|
||||
} else {
|
||||
seenTauxPfu.set(key, { row: rowNum, incoming });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Une ligne dont les références ne se résolvent pas sera de toute façon signalée en erreur
|
||||
// par /apply — on ne la fait pas échouer ici, la vérification des doublons est best-effort.
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ total: rows.length, duplicates });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
router.post('/apply', (req, res, next) => {
|
||||
try {
|
||||
const { tempId, module, mapping, defaults = {}, duplicateDecisions = {} } = req.body || {};
|
||||
if (!tempId || !module || !mapping) {
|
||||
throw new HttpError(400, 'tempId, module and mapping are required');
|
||||
}
|
||||
const def = MODULES[module];
|
||||
if (!def) throw new HttpError(400, 'Unknown module');
|
||||
|
||||
// Require a specific investisseur for transactional modules
|
||||
if (INVESTISSEUR_SCOPED.includes(module)) {
|
||||
requireInvestisseur(req, res, () => {});
|
||||
@@ -160,6 +351,10 @@ router.post('/apply', (req, res, next) => {
|
||||
}
|
||||
return defaults[target];
|
||||
};
|
||||
// Décision prise par l'utilisateur dans la modale de doublons (voir /check-duplicates) :
|
||||
// 'accept' = importer quand même malgré la correspondance ; absent/'skip' = comportement
|
||||
// historique (la ligne en doublon est ignorée).
|
||||
const rowDecision = duplicateDecisions[String(idx + 2)];
|
||||
|
||||
if (module === 'depots_retraits') {
|
||||
const plateformeId = resolveRefId(v('plateforme_id'), platIdSet, platNameMap, 'Plateforme');
|
||||
@@ -175,7 +370,7 @@ router.post('/apply', (req, res, next) => {
|
||||
AND type = ? AND ABS(montant - ?) < 0.005
|
||||
LIMIT 1
|
||||
`).get(req.investisseur.id, plateformeId, dateOperation, type, montant);
|
||||
if (dup) { duplicates++; continue; }
|
||||
if (dup && rowDecision !== 'accept') { duplicates++; continue; }
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO depots_retraits
|
||||
@@ -204,7 +399,7 @@ router.post('/apply', (req, res, next) => {
|
||||
WHERE investisseur_id = ? AND plateforme_id = ? AND nom_projet = ? AND date_souscription = ?
|
||||
LIMIT 1
|
||||
`).get(req.investisseur.id, plateformeId, nomProjet, dateSouscription);
|
||||
if (dup) { duplicates++; continue; }
|
||||
if (dup && rowDecision !== 'accept') { duplicates++; continue; }
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO investissements
|
||||
@@ -248,7 +443,7 @@ router.post('/apply', (req, res, next) => {
|
||||
AND ABS(capital - ?) < 0.005 AND ABS(interets_bruts - ?) < 0.005
|
||||
LIMIT 1
|
||||
`).get(investissementId, dateRemb, capital, bruts);
|
||||
if (dup) { duplicates++; continue; }
|
||||
if (dup && rowDecision !== 'accept') { duplicates++; continue; }
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO remboursements
|
||||
@@ -266,21 +461,34 @@ router.post('/apply', (req, res, next) => {
|
||||
} else if (module === 'plateformes') {
|
||||
const nom = String(v('nom') || '').trim();
|
||||
if (!nom) throw new Error('Le champ nom est vide');
|
||||
const r = db.prepare(`
|
||||
INSERT OR IGNORE INTO plateformes (user_id, nom, url, notes)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(
|
||||
req.user.id,
|
||||
nom,
|
||||
v('url') || null,
|
||||
v('notes') || null,
|
||||
);
|
||||
// changes = 0 means the row was ignored (nom already exists) — doublon, pas une erreur
|
||||
if (r.changes === 0) { duplicates++; continue; }
|
||||
const existingPlat = db.prepare('SELECT id FROM plateformes WHERE user_id = ? AND nom = ?').get(req.user.id, nom);
|
||||
if (existingPlat) {
|
||||
// Une plateforme "nom" est unique par utilisateur/détenteur : impossible de créer un
|
||||
// second doublon en base. "Accepter" le doublon signifie donc ici mettre à jour la
|
||||
// fiche existante avec les valeurs importées, plutôt que de créer une nouvelle ligne.
|
||||
if (rowDecision !== 'accept') { duplicates++; continue; }
|
||||
db.prepare('UPDATE plateformes SET url = ?, notes = ? WHERE id = ?')
|
||||
.run(v('url') || null, v('notes') || null, existingPlat.id);
|
||||
} else {
|
||||
db.prepare(`
|
||||
INSERT INTO plateformes (user_id, nom, url, notes)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(
|
||||
req.user.id,
|
||||
nom,
|
||||
v('url') || null,
|
||||
v('notes') || null,
|
||||
);
|
||||
}
|
||||
|
||||
} else if (module === 'taux_pfu') {
|
||||
const annee = parseInt(v('annee'), 10);
|
||||
if (!annee || annee < 2000 || annee > 2100) throw new Error('Année invalide');
|
||||
const existingTaux = db.prepare('SELECT annee FROM taux_pfu WHERE annee = ?').get(annee);
|
||||
// Comportement historique : un taux existant est toujours écrasé par l'import (upsert).
|
||||
// On ne change ce comportement que si l'utilisateur a explicitement refusé le doublon
|
||||
// dans la modale de vérification — dans ce cas seulement, le taux existant est préservé.
|
||||
if (existingTaux && rowDecision === 'skip') { duplicates++; continue; }
|
||||
db.prepare(`
|
||||
INSERT INTO taux_pfu (annee, pfu_total, impot_revenu, prelev_sociaux)
|
||||
VALUES (?, ?, ?, ?)
|
||||
|
||||
@@ -700,6 +700,61 @@ router.delete('/:id', (req, res, next) => {
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// ── Purge des données d'une plateforme (dépôts/retraits, investissements, remboursements) ──
|
||||
// La plateforme elle-même n'est jamais supprimée par cette route : seules les données
|
||||
// rattachées le sont, afin de permettre une réimportation propre derrière.
|
||||
const PurgeSchema = z.object({
|
||||
scope: z.enum(['all', 'depots_retraits', 'investissements', 'remboursements']),
|
||||
confirmNom: z.string().min(1),
|
||||
});
|
||||
|
||||
router.post('/:id/purge-donnees', (req, res, next) => {
|
||||
try {
|
||||
const { scope, confirmNom } = PurgeSchema.parse(req.body);
|
||||
const plat = db.prepare('SELECT id, nom FROM plateformes WHERE id = ? AND user_id = ?')
|
||||
.get(req.params.id, req.user.id);
|
||||
if (!plat) throw new HttpError(404, 'Plateforme introuvable');
|
||||
|
||||
// Sécurité supplémentaire : le nom retapé doit correspondre exactement (protection
|
||||
// contre une confirmation validée par erreur sur la mauvaise plateforme).
|
||||
if (confirmNom.trim() !== plat.nom) {
|
||||
throw new HttpError(400, 'Le nom saisi ne correspond pas exactement au nom de la plateforme.');
|
||||
}
|
||||
|
||||
const platId = plat.id;
|
||||
const counts = { depots_retraits: 0, investissements: 0, remboursements: 0, simul_remboursements: 0 };
|
||||
|
||||
const tx = db.transaction(() => {
|
||||
if (scope === 'depots_retraits' || scope === 'all') {
|
||||
counts.depots_retraits = db.prepare('DELETE FROM depots_retraits WHERE plateforme_id = ?').run(platId).changes;
|
||||
}
|
||||
if (scope === 'remboursements') {
|
||||
// Ne touche que les remboursements réels ; les investissements et leur échéancier restent.
|
||||
counts.remboursements = db.prepare(`
|
||||
DELETE FROM remboursements
|
||||
WHERE investissement_id IN (SELECT id FROM investissements WHERE plateforme_id = ?)
|
||||
`).run(platId).changes;
|
||||
}
|
||||
if (scope === 'investissements' || scope === 'all') {
|
||||
// Compte avant suppression : les remboursements et l'échéancier simulé sont
|
||||
// supprimés en cascade (ON DELETE CASCADE) par la suppression des investissements.
|
||||
counts.remboursements = db.prepare(`
|
||||
SELECT COUNT(*) AS n FROM remboursements
|
||||
WHERE investissement_id IN (SELECT id FROM investissements WHERE plateforme_id = ?)
|
||||
`).get(platId).n;
|
||||
counts.simul_remboursements = db.prepare(`
|
||||
SELECT COUNT(*) AS n FROM simul_remboursements
|
||||
WHERE investissement_id IN (SELECT id FROM investissements WHERE plateforme_id = ?)
|
||||
`).get(platId).n;
|
||||
counts.investissements = db.prepare('DELETE FROM investissements WHERE plateforme_id = ?').run(platId).changes;
|
||||
}
|
||||
});
|
||||
tx();
|
||||
|
||||
res.json({ ok: true, scope, plateforme: plat.nom, counts });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// ── Reset aux valeurs du référentiel ──────────────────────────────────────
|
||||
router.post('/:id/reset', (req, res, next) => {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user