947 lines
42 KiB
JavaScript
947 lines
42 KiB
JavaScript
import { Router } from 'express';
|
|
import multer from 'multer';
|
|
import xlsx from 'xlsx';
|
|
import path from 'node:path';
|
|
import fs from 'node:fs';
|
|
import db from '../db/index.js';
|
|
import { HttpError } from '../middleware/errorHandler.js';
|
|
import { requireInvestisseur } from '../middleware/investisseurScope.js';
|
|
import { generateSimul, generateSimulWithReinvestissements, adjustSimulForActuals } from '../utils/schedule.js';
|
|
import { recordHistory, detectChangements, detectTypeEvenement } from './investissements.js';
|
|
import { checkDonneesIncompletes } from '../jobs/checkDonneesIncompletes.js';
|
|
import { syncInvestissementStatut } from './remboursements.js';
|
|
|
|
const router = Router();
|
|
|
|
const UPLOAD_DIR = process.env.UPLOAD_DIR || path.resolve('./uploads');
|
|
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
|
|
|
const upload = multer({
|
|
dest: UPLOAD_DIR,
|
|
limits: { fileSize: 10 * 1024 * 1024 }, // 10 MB
|
|
});
|
|
|
|
/**
|
|
* Step 1: POST /api/imports/preview
|
|
* multipart/form-data with `file` (xlsx/csv)
|
|
* -> returns: { headers: [...], sampleRows: [...], allRowCount, tempId }
|
|
*
|
|
* Step 2: POST /api/imports/apply
|
|
* body: { tempId, module, mapping, defaults }
|
|
* -> applies the mapping and inserts rows in the chosen module
|
|
*/
|
|
|
|
// Modules scoped to a specific investisseur (require X-Investisseur-Id)
|
|
const INVESTISSEUR_SCOPED = ['depots_retraits', 'investissements', 'remboursements'];
|
|
|
|
const MODULES = {
|
|
depots_retraits: {
|
|
requiredTargets: ['plateforme_id', 'date_operation', 'type', 'montant'],
|
|
optionalTargets: ['libelle', 'reference', 'notes'],
|
|
},
|
|
investissements: {
|
|
requiredTargets: ['plateforme_id', 'nom_projet', 'date_souscription', 'montant_investi'],
|
|
optionalTargets: ['emetteur','date_premiere_echeance','date_cible','taux_interet','duree_mois','type_remb','freq_interets','statut','reference','notes'],
|
|
},
|
|
remboursements: {
|
|
requiredTargets: ['investissement_id', 'date_remb'],
|
|
optionalTargets: ['capital','cashback','interets_bruts','prelev_sociaux','prelev_forfaitaire','statut','notes'],
|
|
},
|
|
plateformes: {
|
|
requiredTargets: ['nom'],
|
|
optionalTargets: ['url', 'notes'],
|
|
},
|
|
taux_pfu: {
|
|
requiredTargets: ['annee', 'pfu_total', 'impot_revenu', 'prelev_sociaux'],
|
|
optionalTargets: [],
|
|
},
|
|
};
|
|
|
|
/** Parse un fichier uploadé selon son extension → tableau d'objets */
|
|
function parseFile(filePath, originalName) {
|
|
const ext = path.extname(originalName).toLowerCase();
|
|
if (ext === '.json') {
|
|
const content = fs.readFileSync(filePath, 'utf8');
|
|
let parsed;
|
|
try { parsed = JSON.parse(content); } catch {
|
|
throw new HttpError(400, 'Fichier JSON invalide — vérifiez la syntaxe');
|
|
}
|
|
if (!Array.isArray(parsed)) {
|
|
throw new HttpError(400, 'Le fichier JSON doit contenir un tableau d\'objets à la racine');
|
|
}
|
|
return { rows: parsed, sheetName: 'json' };
|
|
}
|
|
// Excel / CSV
|
|
const wb = xlsx.readFile(filePath, { cellDates: true });
|
|
const sheetName = wb.SheetNames[0];
|
|
const ws = wb.Sheets[sheetName];
|
|
return { rows: xlsx.utils.sheet_to_json(ws, { defval: null, raw: false }), sheetName };
|
|
}
|
|
|
|
router.post('/preview', upload.single('file'), (req, res, next) => {
|
|
try {
|
|
if (!req.file) throw new HttpError(400, 'No file uploaded');
|
|
const { rows, sheetName } = parseFile(req.file.path, req.file.originalname);
|
|
const headers = rows.length ? Object.keys(rows[0]) : [];
|
|
|
|
res.json({
|
|
tempId: path.basename(req.file.path),
|
|
filename: req.file.originalname,
|
|
sheetName,
|
|
headers,
|
|
sampleRows: rows.slice(0, 10),
|
|
allRowCount: rows.length,
|
|
});
|
|
} catch (e) { next(e); }
|
|
});
|
|
|
|
/**
|
|
* 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) {
|
|
throw new HttpError(400, 'tempId, module and mapping are required');
|
|
}
|
|
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, () => {});
|
|
}
|
|
|
|
const tempPath = path.join(UPLOAD_DIR, tempId);
|
|
if (!fs.existsSync(tempPath)) throw new HttpError(404, 'Uploaded file expired');
|
|
|
|
for (const target of def.requiredTargets) {
|
|
if (!mapping[target] && defaults[target] === undefined) {
|
|
throw new HttpError(400, `Missing mapping for required field: ${target}`);
|
|
}
|
|
}
|
|
|
|
// Déterminer la source selon l'extension du fichier original (passé en body optionnel)
|
|
const origName = req.body.originalFilename || '';
|
|
const isJson = path.extname(origName).toLowerCase() === '.json';
|
|
const srcLabel = isJson ? 'import_json' : 'import_excel';
|
|
|
|
const { rows } = parseFile(tempPath, origName || 'file.xlsx');
|
|
|
|
// Résolution plateforme_id / investissement_id : accepte un ID numérique OU un nom
|
|
// (ex. colonne "Plateforme" contenant "BienPrêter" plutôt qu'un ID) — cf. resolveRefId.
|
|
let platIdSet = new Set(), platNameMap = new Map();
|
|
if (module === 'depots_retraits' || module === 'investissements') {
|
|
const platRows = db.prepare('SELECT id, nom FROM plateformes WHERE user_id = ?').all(req.user.id);
|
|
platIdSet = new Set(platRows.map(p => p.id));
|
|
platNameMap = new Map(platRows.map(p => [normalizeName(p.nom), p.id]));
|
|
}
|
|
let invIdSet = new Set(), invNameMap = new Map();
|
|
if (module === 'remboursements') {
|
|
const invRows = db.prepare('SELECT id, nom_projet FROM investissements WHERE investisseur_id = ?').all(req.investisseur.id);
|
|
invIdSet = new Set(invRows.map(i => i.id));
|
|
invNameMap = new Map(invRows.map(i => [normalizeName(i.nom_projet), i.id]));
|
|
}
|
|
|
|
let inserted = 0, skipped = 0, duplicates = 0;
|
|
const errors = [];
|
|
// Investissements touchés par un remboursement importé (module 'remboursements') — sert à
|
|
// reproduire après coup ce que fait la création manuelle d'un remboursement (statut auto
|
|
// + réajustement de l'échéancier), que l'import ne faisait pas jusqu'ici.
|
|
const affectedInvestissementIds = new Set();
|
|
// Date la plus ancienne rencontrée par plateforme (dépôts/retraits + investissements)
|
|
// — sert à détecter une date antérieure à la date d'ouverture déclarée sur la plateforme.
|
|
const platMinDate = new Map();
|
|
const trackPlatDate = (plateformeId, dateStr) => {
|
|
if (!plateformeId || !dateStr) return;
|
|
const cur = platMinDate.get(plateformeId);
|
|
if (!cur || dateStr < cur) platMinDate.set(plateformeId, dateStr);
|
|
};
|
|
|
|
const tx = db.transaction(() => {
|
|
for (let idx = 0; idx < rows.length; idx++) {
|
|
const row = rows[idx];
|
|
try {
|
|
const v = (target) => {
|
|
const col = mapping[target];
|
|
if (col && row[col] !== undefined && row[col] !== null && row[col] !== '') {
|
|
return row[col];
|
|
}
|
|
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');
|
|
const dateOperation = normaliseDate(v('date_operation'));
|
|
const type = normaliseType(v('type'));
|
|
const montant = num(v('montant'));
|
|
trackPlatDate(plateformeId, dateOperation);
|
|
|
|
// Anti-doublon : même investisseur + plateforme + date + type + montant
|
|
const dup = db.prepare(`
|
|
SELECT id 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);
|
|
if (dup && rowDecision !== 'accept') { duplicates++; continue; }
|
|
|
|
db.prepare(`
|
|
INSERT INTO depots_retraits
|
|
(investisseur_id, plateforme_id, date_operation, type, montant, libelle, reference, source)
|
|
VALUES (?,?,?,?,?,?,?,?)
|
|
`).run(
|
|
req.investisseur.id,
|
|
plateformeId,
|
|
dateOperation,
|
|
type,
|
|
montant,
|
|
v('libelle') || null,
|
|
v('reference') || null,
|
|
srcLabel,
|
|
);
|
|
|
|
} 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'));
|
|
trackPlatDate(plateformeId, dateSouscription);
|
|
|
|
// Anti-doublon : même investisseur + plateforme + nom du projet + date de souscription
|
|
const dup = db.prepare(`
|
|
SELECT id FROM investissements
|
|
WHERE investisseur_id = ? AND plateforme_id = ? AND nom_projet = ? AND date_souscription = ?
|
|
LIMIT 1
|
|
`).get(req.investisseur.id, plateformeId, nomProjet, dateSouscription);
|
|
if (dup && rowDecision !== 'accept') { duplicates++; continue; }
|
|
|
|
db.prepare(`
|
|
INSERT INTO investissements
|
|
(investisseur_id, plateforme_id, nom_projet, emetteur, date_souscription,
|
|
date_premiere_echeance, date_cible, montant_investi, taux_interet, duree_mois,
|
|
type_remb, freq_interets, statut, reference, source)
|
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
`).run(
|
|
req.investisseur.id,
|
|
plateformeId,
|
|
nomProjet,
|
|
v('emetteur') || null,
|
|
dateSouscription,
|
|
v('date_premiere_echeance') ? normaliseDate(v('date_premiere_echeance')) : null,
|
|
v('date_cible') ? normaliseDate(v('date_cible')) : null,
|
|
num(v('montant_investi')),
|
|
v('taux_interet') ? Number(String(v('taux_interet')).replace(',', '.')) : null,
|
|
v('duree_mois') ? parseInt(v('duree_mois'), 10) : null,
|
|
v('type_remb') || null,
|
|
v('freq_interets') || 'mensuel',
|
|
v('statut') || 'en_cours',
|
|
v('reference') || null,
|
|
srcLabel,
|
|
);
|
|
|
|
} 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 cashback = num(v('cashback'));
|
|
const bruts = num(v('interets_bruts'));
|
|
const ps = num(v('prelev_sociaux'));
|
|
const pf = num(v('prelev_forfaitaire'));
|
|
const interets_nets = Math.round((bruts - ps - pf) * 100) / 100;
|
|
const net_recu = Math.round((capital + cashback + interets_nets) * 100) / 100;
|
|
|
|
// Anti-doublon : même investissement + date + capital + intérêts bruts
|
|
const dup = db.prepare(`
|
|
SELECT id 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);
|
|
if (dup && rowDecision !== 'accept') { duplicates++; continue; }
|
|
|
|
db.prepare(`
|
|
INSERT INTO remboursements
|
|
(investissement_id, date_remb, capital, cashback, interets_bruts, prelev_sociaux,
|
|
prelev_forfaitaire, interets_nets, net_recu, statut, source)
|
|
VALUES (?,?,?,?,?,?,?,?,?,?,?)
|
|
`).run(
|
|
investissementId,
|
|
dateRemb,
|
|
capital, cashback, bruts, ps, pf, interets_nets, net_recu,
|
|
v('statut') || 'paye',
|
|
srcLabel,
|
|
);
|
|
affectedInvestissementIds.add(investissementId);
|
|
|
|
} else if (module === 'plateformes') {
|
|
const nom = String(v('nom') || '').trim();
|
|
if (!nom) throw new Error('Le champ nom est vide');
|
|
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 (?, ?, ?, ?)
|
|
ON CONFLICT(annee) DO UPDATE SET
|
|
pfu_total = excluded.pfu_total,
|
|
impot_revenu = excluded.impot_revenu,
|
|
prelev_sociaux = excluded.prelev_sociaux,
|
|
updated_at = datetime('now')
|
|
`).run(
|
|
annee,
|
|
num(v('pfu_total')),
|
|
num(v('impot_revenu')),
|
|
num(v('prelev_sociaux')),
|
|
);
|
|
}
|
|
|
|
inserted++;
|
|
} catch (err) {
|
|
skipped++;
|
|
errors.push({ row: idx + 2, error: err.message });
|
|
}
|
|
}
|
|
});
|
|
tx();
|
|
|
|
// Vérifie et notifie les données essentielles manquantes (taux, durée, type de
|
|
// remboursement, date de première échéance) sur les investissements importés —
|
|
// même logique que le job horaire, best-effort pour ne jamais bloquer l'import.
|
|
if (module === 'investissements' && inserted > 0) {
|
|
try { checkDonneesIncompletes(); } catch (e) {
|
|
console.error('[imports] checkDonneesIncompletes après import a échoué :', e.message);
|
|
}
|
|
}
|
|
|
|
// Reproduit après import ce que fait la création manuelle d'un remboursement :
|
|
// passage automatique au statut "rembourse" si le capital est intégralement remboursé,
|
|
// et réajustement de l'échéancier (troncature des échéances futures devenues caduques en
|
|
// cas de remboursement anticipé). Sans cela, un investissement soldé via import restait
|
|
// affiché "En cours" avec un échéancier complet non réajusté (contrairement à la saisie manuelle).
|
|
if (module === 'remboursements' && affectedInvestissementIds.size > 0) {
|
|
for (const invId of affectedInvestissementIds) {
|
|
try {
|
|
syncInvestissementStatut(invId);
|
|
adjustSimulForActuals(db, invId);
|
|
} catch (e) {
|
|
console.error(`[imports] réajustement post-import échoué pour l'investissement #${invId} :`, e.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Anomalie : une date importée précède la date d'ouverture déclarée sur la plateforme
|
|
const anomalies = [];
|
|
if (platMinDate.size > 0) {
|
|
for (const [plateformeId, minDate] of platMinDate) {
|
|
const plat = db.prepare('SELECT id, nom, date_ouverture FROM plateformes WHERE id = ? AND user_id = ?')
|
|
.get(plateformeId, req.user.id);
|
|
if (plat && plat.date_ouverture && minDate < plat.date_ouverture) {
|
|
anomalies.push({
|
|
plateforme_id: plat.id,
|
|
plateforme_nom: plat.nom,
|
|
date_ouverture_actuelle: plat.date_ouverture,
|
|
date_detectee: minDate,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
db.prepare(`
|
|
INSERT INTO imports (user_id, investisseur_id, module, filename, rows_total, rows_inserted, rows_skipped, rows_duplicates, mapping_json)
|
|
VALUES (?,?,?,?,?,?,?,?,?)
|
|
`).run(
|
|
req.user.id,
|
|
req.investisseur?.id ?? null,
|
|
module,
|
|
tempId,
|
|
rows.length,
|
|
inserted,
|
|
skipped,
|
|
duplicates,
|
|
JSON.stringify(mapping),
|
|
);
|
|
|
|
// Clean up temp file
|
|
try { fs.unlinkSync(tempPath); } catch { /* */ }
|
|
|
|
res.json({ inserted, skipped, duplicates, total: rows.length, errors: errors.slice(0, 50), anomalies });
|
|
} 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).
|
|
* Scénario CREATE : le dossier n'existe pas → création complète.
|
|
* Scénario UPDATE : le dossier existe déjà → mise à jour des champs + remboursements manquants.
|
|
* Identification : (investisseur_id, nom_projet, date_souscription) — clé naturelle portable.
|
|
*/
|
|
router.post('/dossier', (req, res, next) => {
|
|
try {
|
|
requireInvestisseur(req, res, () => {});
|
|
|
|
const { dossier } = req.body || {};
|
|
if (!dossier || dossier.type !== 'dossier_investissement') {
|
|
throw new HttpError(400, 'Format invalide — attendu { dossier: { type: "dossier_investissement", ... } }');
|
|
}
|
|
const { investissement: inv, plateforme: platInfo, remboursements = [], reinvestissements = [], historique = [] } = dossier;
|
|
if (!inv?.nom_projet || !inv?.date_souscription) {
|
|
throw new HttpError(400, 'Champs obligatoires manquants : nom_projet, date_souscription');
|
|
}
|
|
|
|
let action, investissementId;
|
|
|
|
const tx = db.transaction(() => {
|
|
/* ── 1. Résoudre / créer la plateforme ─────────────────── */
|
|
let platRow = db.prepare('SELECT id FROM plateformes WHERE user_id = ? AND nom = ?')
|
|
.get(req.user.id, platInfo?.nom || '');
|
|
if (!platRow && platInfo?.nom) {
|
|
const r = db.prepare('INSERT INTO plateformes (user_id, nom, url) VALUES (?,?,?)')
|
|
.run(req.user.id, platInfo.nom, platInfo.url || null);
|
|
platRow = { id: r.lastInsertRowid };
|
|
}
|
|
if (!platRow) throw new HttpError(400, 'Plateforme introuvable et nom manquant dans le dossier');
|
|
const plateformeId = platRow.id;
|
|
|
|
/* ── 2. Chercher l'investissement existant (clé naturelle) */
|
|
const existing = db.prepare(`
|
|
SELECT * FROM investissements
|
|
WHERE investisseur_id = ? AND nom_projet = ? AND date_souscription = ?
|
|
LIMIT 1
|
|
`).get(req.investisseur.id, inv.nom_projet, inv.date_souscription);
|
|
|
|
if (!existing) {
|
|
/* ────────────── SCÉNARIO CREATE ──────────────────────── */
|
|
const r = db.prepare(`
|
|
INSERT INTO investissements
|
|
(investisseur_id, plateforme_id, nom_projet, emetteur, date_souscription,
|
|
date_premiere_echeance, date_cible, date_debut_simul, montant_investi,
|
|
taux_interet, duree_mois, type_remb, freq_interets, statut, reference, source, notes)
|
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, 'import_dossier', ?)
|
|
`).run(
|
|
req.investisseur.id, plateformeId,
|
|
inv.nom_projet, inv.emetteur || null,
|
|
inv.date_souscription,
|
|
inv.date_premiere_echeance || null, inv.date_cible || null, inv.date_debut_simul || null,
|
|
Number(inv.montant_investi), inv.taux_interet ?? null, inv.duree_mois ?? null,
|
|
inv.type_remb || 'in_fine', inv.freq_interets || 'mensuel',
|
|
inv.statut || 'en_cours', inv.reference || null, inv.notes || null,
|
|
);
|
|
investissementId = Number(r.lastInsertRowid);
|
|
|
|
// Remboursements
|
|
for (const rb of remboursements) {
|
|
db.prepare(`
|
|
INSERT INTO remboursements
|
|
(investissement_id, date_remb, capital, cashback, interets_bruts,
|
|
prelev_sociaux, prelev_forfaitaire, interets_nets, net_recu, statut, notes, source)
|
|
VALUES (?,?,?,?,?,?,?,?,?,?,?, 'import_dossier')
|
|
`).run(
|
|
investissementId, rb.date_remb,
|
|
rb.capital || 0, rb.cashback || 0, rb.interets_bruts || 0,
|
|
rb.prelev_sociaux || 0, rb.prelev_forfaitaire || 0,
|
|
rb.interets_nets || 0, rb.net_recu || 0,
|
|
rb.statut || 'paye', rb.notes || null,
|
|
);
|
|
}
|
|
|
|
// Réinvestissements
|
|
for (const rv of reinvestissements) {
|
|
if (!rv.date_reinvestissement || !rv.montant) continue;
|
|
db.prepare(`
|
|
INSERT INTO reinvestissements (investissement_id, montant, date_reinvestissement, note, source)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
`).run(
|
|
investissementId,
|
|
Number(rv.montant),
|
|
rv.date_reinvestissement,
|
|
rv.note || null,
|
|
rv.source || 'manuel',
|
|
);
|
|
}
|
|
|
|
// Historique (lecture seule — on importe pour la traçabilité)
|
|
for (const h of historique) {
|
|
db.prepare(`
|
|
INSERT INTO investissement_historique
|
|
(investissement_id, type_evenement, changements, notes, created_at)
|
|
VALUES (?,?,?,?,?)
|
|
`).run(
|
|
investissementId,
|
|
h.type_evenement || 'import',
|
|
typeof h.changements === 'string' ? h.changements : JSON.stringify(h.changements || []),
|
|
h.notes || null,
|
|
h.created_at || null,
|
|
);
|
|
}
|
|
// Entrée d'historique de l'import lui-même
|
|
db.prepare(`
|
|
INSERT INTO investissement_historique (investissement_id, type_evenement, changements)
|
|
VALUES (?, 'import', ?)
|
|
`).run(investissementId, JSON.stringify([{
|
|
champ: 'import', label: 'Import dossier',
|
|
ancienne_valeur: null, nouvelle_valeur: dossier.exported_at || 'inconnu',
|
|
}]));
|
|
|
|
action = 'created';
|
|
} else {
|
|
/* ────────────── SCÉNARIO UPDATE ──────────────────────── */
|
|
investissementId = existing.id;
|
|
|
|
const nouveau = {
|
|
plateforme_id: plateformeId,
|
|
date_premiere_echeance: inv.date_premiere_echeance || null,
|
|
date_cible: inv.date_cible || null,
|
|
date_debut_simul: inv.date_debut_simul || null,
|
|
montant_investi: Number(inv.montant_investi),
|
|
taux_interet: inv.taux_interet ?? null,
|
|
duree_mois: inv.duree_mois ?? null,
|
|
type_remb: inv.type_remb || 'in_fine',
|
|
freq_interets: inv.freq_interets || 'mensuel',
|
|
statut: inv.statut || 'en_cours',
|
|
};
|
|
|
|
db.prepare(`
|
|
UPDATE investissements SET
|
|
plateforme_id = ?, emetteur = ?,
|
|
date_premiere_echeance = ?, date_cible = ?, date_debut_simul = ?,
|
|
montant_investi = ?, taux_interet = ?, duree_mois = ?,
|
|
type_remb = ?, freq_interets = ?, statut = ?,
|
|
reference = ?, notes = ?
|
|
WHERE id = ?
|
|
`).run(
|
|
nouveau.plateforme_id, inv.emetteur || null,
|
|
nouveau.date_premiere_echeance, nouveau.date_cible, nouveau.date_debut_simul,
|
|
nouveau.montant_investi, nouveau.taux_interet, nouveau.duree_mois,
|
|
nouveau.type_remb, nouveau.freq_interets, nouveau.statut,
|
|
inv.reference || null, inv.notes || null,
|
|
investissementId,
|
|
);
|
|
|
|
// Remboursements : ajouter les manquants (clé naturelle = date_remb + capital + interets_bruts)
|
|
let rembInserted = 0;
|
|
for (const rb of remboursements) {
|
|
const exists = db.prepare(`
|
|
SELECT id FROM remboursements
|
|
WHERE investissement_id = ? AND date_remb = ?
|
|
AND ABS(capital - ?) < 0.005 AND ABS(interets_bruts - ?) < 0.005
|
|
`).get(investissementId, rb.date_remb, rb.capital || 0, rb.interets_bruts || 0);
|
|
if (!exists) {
|
|
db.prepare(`
|
|
INSERT INTO remboursements
|
|
(investissement_id, date_remb, capital, cashback, interets_bruts,
|
|
prelev_sociaux, prelev_forfaitaire, interets_nets, net_recu, statut, notes, source)
|
|
VALUES (?,?,?,?,?,?,?,?,?,?,?, 'import_dossier')
|
|
`).run(
|
|
investissementId, rb.date_remb,
|
|
rb.capital || 0, rb.cashback || 0, rb.interets_bruts || 0,
|
|
rb.prelev_sociaux || 0, rb.prelev_forfaitaire || 0,
|
|
rb.interets_nets || 0, rb.net_recu || 0,
|
|
rb.statut || 'paye', rb.notes || null,
|
|
);
|
|
rembInserted++;
|
|
}
|
|
}
|
|
|
|
// Réinvestissements : ajouter les manquants (clé = date + montant)
|
|
let reinvInserted = 0;
|
|
for (const rv of reinvestissements) {
|
|
if (!rv.date_reinvestissement || !rv.montant) continue;
|
|
const rvExists = db.prepare(`
|
|
SELECT id FROM reinvestissements
|
|
WHERE investissement_id = ? AND date_reinvestissement = ? AND ABS(montant - ?) < 0.005
|
|
`).get(investissementId, rv.date_reinvestissement, Number(rv.montant));
|
|
if (!rvExists) {
|
|
db.prepare(`
|
|
INSERT INTO reinvestissements (investissement_id, montant, date_reinvestissement, note, source)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
`).run(
|
|
investissementId,
|
|
Number(rv.montant),
|
|
rv.date_reinvestissement,
|
|
rv.note || null,
|
|
rv.source || 'manuel',
|
|
);
|
|
reinvInserted++;
|
|
}
|
|
}
|
|
|
|
// Entrée d'historique de la mise à jour — diff précis champ par champ
|
|
// (même logique que l'édition manuelle, pour que la date_cible/date_premiere_echeance
|
|
// etc. écrasées par ce ré-import restent traçables dans l'historique du prêt).
|
|
const changements = detectChangements(existing, nouveau);
|
|
const notesImport = `Import dossier (${dossier.exported_at || 'date export inconnue'}) — ` +
|
|
`${rembInserted} remb. ajouté(s), ${reinvInserted} réinvest. ajouté(s)`;
|
|
|
|
if (changements.length > 0) {
|
|
recordHistory(investissementId, {
|
|
type_evenement: detectTypeEvenement(changements),
|
|
changements,
|
|
notes: notesImport,
|
|
});
|
|
} else {
|
|
recordHistory(investissementId, {
|
|
type_evenement: 'import',
|
|
changements: [{
|
|
champ: 'import', label: 'Mise à jour dossier',
|
|
ancienne_valeur: null,
|
|
nouvelle_valeur: notesImport,
|
|
}],
|
|
});
|
|
}
|
|
|
|
action = 'updated';
|
|
}
|
|
});
|
|
tx();
|
|
|
|
/* ── 3. Régénérer la simulation ─────────────────────────── */
|
|
const fresh = db.prepare('SELECT * FROM investissements WHERE id = ?').get(investissementId);
|
|
generateSimulWithReinvestissements(db, investissementId);
|
|
|
|
/* ── 4. Log import ──────────────────────────────────────── */
|
|
db.prepare(`
|
|
INSERT INTO imports (user_id, investisseur_id, module, filename, rows_total, rows_inserted, rows_skipped, mapping_json)
|
|
VALUES (?,?,?,?,?,?,?,?)
|
|
`).run(
|
|
req.user.id, req.investisseur.id,
|
|
'dossier_investissement',
|
|
`Dossier_${inv.nom_projet}`,
|
|
1, 1, 0,
|
|
JSON.stringify({ action, investissementId }),
|
|
);
|
|
|
|
res.json({ action, investissementId });
|
|
} catch (e) { next(e); }
|
|
});
|
|
|
|
router.get('/history', (req, res) => {
|
|
const rows = db.prepare(`
|
|
SELECT * FROM imports WHERE user_id=? ORDER BY id DESC LIMIT 100
|
|
`).all(req.user.id);
|
|
res.json(rows);
|
|
});
|
|
|
|
// helpers
|
|
|
|
/** Supprime les accents/diacritiques d'une chaîne (ex. "Dépôt" → "Depot") */
|
|
function stripAccents(s) {
|
|
return String(s).normalize('NFD').replace(/[̀-ͯ]/g, '');
|
|
}
|
|
|
|
/** Normalise un nom pour comparaison insensible à la casse/accents (ex. plateforme, projet) */
|
|
function normalizeName(s) {
|
|
return stripAccents(String(s || ''))
|
|
.toLowerCase()
|
|
.trim()
|
|
.replace(/\s+/g, ' ');
|
|
}
|
|
|
|
/**
|
|
* Résout une valeur de colonne (ID numérique OU nom texte) vers l'ID cible réel.
|
|
* - Si la valeur est un entier, elle doit correspondre à un ID existant (idSet).
|
|
* - Sinon, elle est recherchée par nom normalisé (nameMap) — insensible casse/accents.
|
|
* Lève une erreur explicite si rien ne correspond (la ligne sera ignorée par l'appelant).
|
|
*/
|
|
function resolveRefId(value, idSet, nameMap, label) {
|
|
if (value === undefined || value === null || String(value).trim() === '') {
|
|
throw new Error(`${label} manquant`);
|
|
}
|
|
const raw = String(value).trim();
|
|
if (/^\d+$/.test(raw)) {
|
|
const id = Number(raw);
|
|
if (idSet.has(id)) return id;
|
|
throw new Error(`${label} #${id} introuvable`);
|
|
}
|
|
const id = nameMap.get(normalizeName(raw));
|
|
if (id == null) throw new Error(`${label} "${raw}" introuvable`);
|
|
return id;
|
|
}
|
|
|
|
/** Convertit une valeur monétaire en nombre (gère "€", espaces, virgule décimale) */
|
|
function num(v) {
|
|
if (v === undefined || v === null || v === '') return 0;
|
|
// Supprimer tout ce qui n'est pas chiffre, virgule, point ou signe moins
|
|
const clean = String(v).replace(/[^\d,.-]/g, '').replace(',', '.');
|
|
const n = Number(clean);
|
|
return isNaN(n) ? 0 : n;
|
|
}
|
|
|
|
function normaliseType(v) {
|
|
// Normalise les accents avant la comparaison : "Dépôt" → "depot"
|
|
const s = stripAccents(String(v || '')).toLowerCase();
|
|
if (s.startsWith('dep') || s === 'versement' || s === 'in') return 'depot';
|
|
if (s.startsWith('ret') || s === 'withdrawal' || s === 'out') return 'retrait';
|
|
return s; // CHECK constraint will reject if invalid
|
|
}
|
|
function normaliseDate(v) {
|
|
if (!v) return null;
|
|
if (v instanceof Date) return v.toISOString().slice(0, 10);
|
|
const s = String(v).trim();
|
|
// Already ISO
|
|
if (/^\d{4}-\d{2}-\d{2}/.test(s)) return s.slice(0, 10);
|
|
// dd/mm/yyyy
|
|
const m = s.match(/^(\d{1,2})[/.-](\d{1,2})[/.-](\d{2,4})$/);
|
|
if (m) {
|
|
const [_, d, mo, y] = m;
|
|
const yy = y.length === 2 ? '20' + y : y;
|
|
return `${yy}-${mo.padStart(2, '0')}-${d.padStart(2, '0')}`;
|
|
}
|
|
return s;
|
|
}
|
|
|
|
export default router;
|