Implementation de la feature de gestion des fichiers sur les investissements

This commit is contained in:
ocroguennec committed 2026-08-29 18:49:01 +02:00
1 parent f6829dec9d
commit 8953d0bb35
21 files changed
+1729 -181

No files matched your search

+177
View File
@@ -1,10 +1,14 @@
import { Router } from 'express';
import { z } from 'zod';
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, monthsDiff, adjustSimulForActuals, replayAdjustSimulForActuals } from '../utils/schedule.js';
import { checkStatutsRetard } from '../jobs/autoStatut.js';
import { createZip, sanitizeZipPart } from '../utils/zip.js';
import { docsDir, deleteDocumentsForEntity } from './documents.js';
const router = Router();
@@ -488,6 +492,173 @@ router.get('/:id', (req, res, next) => {
} catch (e) { next(e); }
});
/* ── GET /api/investissements/:id/export ─────────────────────────────────
Export ZIP complet d'un "dossier" d'investissement — demande Olivier
29/08/26, rework du bouton "Exporter" du menu ⋮ (bloc "Informations du
projet"), qui ne produisait jusqu'ici qu'un simple .json construit
côté client, sans les documents joints. Contenu du zip :
- manifest.json : toutes les données nécessaires pour recréer le dossier
(investissement, plateforme, investisseur, catégories/secteurs,
remboursements, projections, réinvestissements, historique, révisions,
pertes) + les métadonnées de chaque document (nom, taille, type, et son
chemin dans le zip) — en vue d'une future réimportation (pas encore
implémentée, cf. décision Olivier : seulement l'export pour l'instant).
- documents/<nom>.<ext> : les fichiers eux-mêmes, lus sur disque via
docsDir (partagé avec documents.js). Toujours produit même sans aucun
document — contrairement à l'export global de Mon compte
(GET /api/documents/export), qui lui exige au moins un document
puisque son seul contenu, ce sont les fichiers. ────────────────────── */
router.get('/:id/export', (req, res, next) => {
try {
const inv = db.prepare(`
SELECT i.*, p.nom AS plateforme_nom, cp.nom AS categorie_nom,
c.nom AS compte_nom, c.type AS compte_type
FROM investissements i
JOIN plateformes p ON p.id = i.plateforme_id
JOIN investisseurs inv ON inv.id = i.investisseur_id AND inv.user_id = ?
LEFT JOIN categories_plateforme cp ON cp.id = i.categorie_id
LEFT JOIN comptes c ON c.id = i.compte_id
WHERE i.id = ?
`).get(req.user.id, req.params.id);
if (!inv) throw new HttpError(404, 'Not found');
const investisseur = db.prepare(
'SELECT nom, prenom, type, type_fiscal, notes, email FROM investisseurs WHERE id = ? AND user_id = ?'
).get(inv.investisseur_id, req.user.id);
const remboursements = db.prepare(
'SELECT * FROM remboursements WHERE investissement_id = ? ORDER BY date_remb'
).all(req.params.id);
const simul = db.prepare(
'SELECT * FROM simul_remboursements WHERE investissement_id = ? ORDER BY numero_echeance'
).all(req.params.id);
const historique = db.prepare(
'SELECT * FROM investissement_historique WHERE investissement_id = ? ORDER BY created_at ASC'
).all(req.params.id).map(h => ({ ...h, changements: JSON.parse(h.changements) }));
const revisions = db.prepare(
'SELECT * FROM investissement_revisions WHERE investissement_id = ? ORDER BY id ASC'
).all(req.params.id);
const pertes = db.prepare(
'SELECT * FROM investissement_pertes WHERE investissement_id = ? ORDER BY id ASC'
).all(req.params.id);
const reinvestissements = db.prepare(
'SELECT * FROM reinvestissements WHERE investissement_id = ? ORDER BY date_reinvestissement'
).all(req.params.id);
const categories_inv = db.prepare(`
SELECT c.nom FROM investissement_categories_inv ic
JOIN categories_inv c ON c.id = ic.categorie_id
WHERE ic.investissement_id = ?
ORDER BY c.nom
`).all(req.params.id).map(r => r.nom);
const secteurs_inv = db.prepare(`
SELECT s.nom FROM investissement_secteurs_inv is2
JOIN secteurs_inv s ON s.id = is2.secteur_id
WHERE is2.investissement_id = ?
ORDER BY s.nom
`).all(req.params.id).map(r => r.nom);
const documents = db.prepare(
"SELECT * FROM documents WHERE user_id = ? AND entity_type = 'investissement' AND entity_id = ? ORDER BY created_at ASC"
).all(req.user.id, req.params.id);
// Fichiers du zip + métadonnées correspondantes dans le manifeste (le nom
// affiché sert de nom de fichier dans le zip, avec gestion des doublons —
// même logique que l'export global de Mon compte, cf. documents.js).
const usedNames = new Set();
const documentsMeta = [];
const entries = [];
for (const doc of documents) {
const baseLabel = sanitizeZipPart(doc.nom_affichage);
let finalName = `${baseLabel}.${doc.extension}`;
let n = 2;
while (usedNames.has(finalName)) { finalName = `${baseLabel} (${n}).${doc.extension}`; n++; }
usedNames.add(finalName);
const zipPath = `documents/${finalName}`;
documentsMeta.push({
nom_affichage: doc.nom_affichage,
nom_original: doc.nom_original,
extension: doc.extension,
mime_type: doc.mime_type,
taille_octets: doc.taille_octets,
categorie: doc.categorie,
zip_path: zipPath,
});
const filePath = path.join(docsDir, doc.filename);
if (fs.existsSync(filePath)) entries.push({ name: zipPath, data: fs.readFileSync(filePath) });
}
const manifest = {
version: '1.0',
type: 'dossier_investissement',
exported_at: new Date().toISOString(),
investissement: {
nom_projet: inv.nom_projet,
emetteur: inv.emetteur,
date_souscription: inv.date_souscription,
date_premiere_echeance: inv.date_premiere_echeance,
date_cible: inv.date_cible,
date_debut_simul: inv.date_debut_simul,
montant_investi: inv.montant_investi,
taux_interet: inv.taux_interet,
duree_mois: inv.duree_mois,
type_remb: inv.type_remb,
freq_interets: inv.freq_interets,
statut: inv.statut,
reference: inv.reference,
source: inv.source,
notes: inv.notes,
pays_exposition: inv.pays_exposition,
methode_remboursement: inv.methode_remboursement,
echeance_fin_de_mois: inv.echeance_fin_de_mois,
nom_compte_courant: inv.nom_compte_courant,
categorie: inv.categorie_nom || null,
compte: inv.compte_nom ? { nom: inv.compte_nom, type: inv.compte_type } : null,
},
plateforme: { nom: inv.plateforme_nom },
investisseur: investisseur || null,
categories_inv,
secteurs_inv,
remboursements: remboursements.map(r => ({
date_remb: r.date_remb, capital: r.capital, cashback: r.cashback,
interets_bruts: r.interets_bruts, prelev_sociaux: r.prelev_sociaux,
prelev_forfaitaire: r.prelev_forfaitaire, interets_nets: r.interets_nets,
net_recu: r.net_recu, statut: r.statut, notes: r.notes,
})),
projections: simul.map(s => ({
numero_echeance: s.numero_echeance, date_prevue: s.date_prevue,
capital_prevu: s.capital_prevu, interets_prevus: s.interets_prevus, total_prevu: s.total_prevu,
})),
reinvestissements: reinvestissements.map(r => ({
date_reinvestissement: r.date_reinvestissement, montant: r.montant, source: r.source, note: r.note,
})),
historique: historique.map(h => ({
type_evenement: h.type_evenement, changements: h.changements, notes: h.notes, created_at: h.created_at,
})),
revisions: revisions.map(r => ({
date_effet: r.date_effet, ancien_taux: r.ancien_taux, nouveau_taux: r.nouveau_taux,
ancienne_date_cible: r.ancienne_date_cible, nouvelle_date_cible: r.nouvelle_date_cible,
motif: r.motif, created_at: r.created_at,
})),
pertes: pertes.map(p => ({
date_effet: p.date_effet, montant_perte: p.montant_perte, ancien_statut: p.ancien_statut,
motif: p.motif, created_at: p.created_at,
})),
documents: documentsMeta,
};
entries.unshift({ name: 'manifest.json', data: JSON.stringify(manifest, null, 2) });
const zipBuf = createZip(entries);
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const filename = `Dossier_Investissement_${req.params.id}_${ts}.zip`;
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.send(zipBuf);
} catch (e) { next(e); }
});
router.post('/', (req, res, next) => {
try {
const body = Schema.parse(req.body);
@@ -617,6 +788,12 @@ router.put('/:id/fiscalite-override', (req, res, next) => {
router.delete('/:id', (req, res, next) => {
try {
// Documents liés à cet investissement (lignes + fichiers sur disque) :
// nettoyés avant l'investissement lui-même. entity_id est un lien
// polymorphe sans contrainte de clé étrangère — rien ne les supprimerait
// sinon, ils resteraient orphelins (demande Olivier 29/08/26).
deleteDocumentsForEntity(req.user.id, 'investissement', Number(req.params.id));
const r = db.prepare(`
DELETE FROM investissements
WHERE id = ? AND investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)