Implementation de la feature de gestion des fichiers sur les investissements
This commit is contained in:
1 parent
f6829dec9d
commit
8953d0bb35
21 files changed
+1729
-181
No files matched your search
@@ -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 = ?)
|
||||
|
||||
Reference in new issue
Block a user