This commit is contained in:
ocroguennec committed 2026-09-27 15:09:18 +02:00
1 parent 1e3994f17f
commit 624d32ff7c
17 files changed
+2192 -1

No files matched your search

+174
View File
@@ -0,0 +1,174 @@
import { Router } from 'express';
import { z } from 'zod';
import db from '../db/index.js';
import { HttpError } from '../middleware/errorHandler.js';
/**
* routes/enveloppesReferentiel.js — Référentiel admin des enveloppes d'épargne/investissement
* (Livret A, Assurance-vie, PEA, PER...) — chantier "Référentiel Enveloppes & Supports
* d'épargne" (28/09/26, demande Olivier).
*
* Même esprit que institutionsReferentiel.js : catalogue transverse à tous les espaces de
* travail, catégories (familles) en N-N via referentiel_categories_enveloppes. S'y ajoute une
* seconde relation N-N propre à ce référentiel : enveloppes_supports_disponibles, qui indique
* quels supports d'investissement (supports_referentiel — fonds euros, ETF, SCPI...) sont
* disponibles dans chaque enveloppe (ex. Assurance-vie multisupport ↔ fonds euros, ETF, SCPI...).
* Une enveloppe "cash" (Livret A, Compte courant...) n'a simplement aucune ligne dans cette
* seconde relation.
*
* Catalogue seul pour l'instant (cadrage validé par Olivier via AskUserQuestion le 28/09/26) —
* aucun rattachement à l'existant (comptes, investissements...), en préparation du futur module
* patrimoine (cf. plan de déploiement, doc projet).
*/
const router = Router();
// Toutes ces routes sont montées sous requireAdmin dans server.js
const Schema = z.object({
nom: z.string().min(1).max(200),
description: z.string().nullable().optional(),
plafond_eur: z.number().positive().nullable().optional(),
regime_fiscal: z.string().nullable().optional(),
categories_ids: z.array(z.number().int()).optional().default([]),
supports_ids: z.array(z.number().int()).optional().default([]),
});
// ── Helpers ────────────────────────────────────────────────────────────────
/** Catégories (familles) attachées à chaque enveloppe */
function attachCategories(rows) {
if (rows.length === 0) return rows;
const ids = rows.map(r => r.id);
const links = db.prepare(`
SELECT rc.enveloppe_id, c.id AS categorie_id, c.nom
FROM referentiel_categories_enveloppes rc
JOIN categories_enveloppes c ON c.id = rc.categorie_id
WHERE rc.enveloppe_id IN (${ids.map(() => '?').join(',')})
ORDER BY c.nom
`).all(...ids);
const map = {};
for (const l of links) {
if (!map[l.enveloppe_id]) map[l.enveloppe_id] = [];
map[l.enveloppe_id].push({ id: l.categorie_id, nom: l.nom });
}
return rows.map(r => ({ ...r, categories: map[r.id] || [] }));
}
/** Supports disponibles attachés à chaque enveloppe */
function attachSupports(rows) {
if (rows.length === 0) return rows;
const ids = rows.map(r => r.id);
const links = db.prepare(`
SELECT esd.enveloppe_id, s.id AS support_id, s.nom
FROM enveloppes_supports_disponibles esd
JOIN supports_referentiel s ON s.id = esd.support_id
WHERE esd.enveloppe_id IN (${ids.map(() => '?').join(',')})
ORDER BY s.nom
`).all(...ids);
const map = {};
for (const l of links) {
if (!map[l.enveloppe_id]) map[l.enveloppe_id] = [];
map[l.enveloppe_id].push({ id: l.support_id, nom: l.nom });
}
return rows.map(r => ({ ...r, supports: map[r.id] || [] }));
}
function saveCategories(enveloppeId, categoriesIds) {
db.prepare('DELETE FROM referentiel_categories_enveloppes WHERE enveloppe_id = ?').run(enveloppeId);
if (categoriesIds && categoriesIds.length > 0) {
const ins = db.prepare('INSERT OR IGNORE INTO referentiel_categories_enveloppes (enveloppe_id, categorie_id) VALUES (?,?)');
for (const id of categoriesIds) ins.run(enveloppeId, id);
}
}
function saveSupports(enveloppeId, supportsIds) {
db.prepare('DELETE FROM enveloppes_supports_disponibles WHERE enveloppe_id = ?').run(enveloppeId);
if (supportsIds && supportsIds.length > 0) {
const ins = db.prepare('INSERT OR IGNORE INTO enveloppes_supports_disponibles (enveloppe_id, support_id) VALUES (?,?)');
for (const id of supportsIds) ins.run(enveloppeId, id);
}
}
// ── GET /api/enveloppes-referentiel ─────────────────────────────────────────
router.get('/', (_req, res) => {
let rows = db.prepare('SELECT * FROM enveloppes_referentiel ORDER BY nom').all();
rows = attachSupports(attachCategories(rows));
res.json(rows);
});
// ── GET /api/enveloppes-referentiel/:id ─────────────────────────────────────
router.get('/:id', (req, res, next) => {
try {
const row = db.prepare('SELECT * FROM enveloppes_referentiel WHERE id = ?').get(req.params.id);
if (!row) throw new HttpError(404, 'Enveloppe introuvable');
const [enriched] = attachSupports(attachCategories([row]));
res.json(enriched);
} catch (e) { next(e); }
});
// ── POST /api/enveloppes-referentiel ────────────────────────────────────────
router.post('/', (req, res, next) => {
try {
const data = Schema.parse(req.body);
const existing = db.prepare('SELECT id FROM enveloppes_referentiel WHERE nom = ?').get(data.nom);
if (existing) throw new HttpError(409, `Une enveloppe "${data.nom}" existe déjà`);
const r = db.prepare(`
INSERT INTO enveloppes_referentiel (nom, description, plafond_eur, regime_fiscal, updated_at)
VALUES (?,?,?,?, datetime('now'))
`).run(data.nom, data.description || null, data.plafond_eur ?? null, data.regime_fiscal || null);
const enveloppeId = r.lastInsertRowid;
db.transaction(() => {
saveCategories(enveloppeId, data.categories_ids);
saveSupports(enveloppeId, data.supports_ids);
})();
const row = db.prepare('SELECT * FROM enveloppes_referentiel WHERE id = ?').get(enveloppeId);
const [enriched] = attachSupports(attachCategories([row]));
res.status(201).json(enriched);
} catch (e) { next(e); }
});
// ── PUT /api/enveloppes-referentiel/:id ─────────────────────────────────────
router.put('/:id', (req, res, next) => {
try {
const row = db.prepare('SELECT * FROM enveloppes_referentiel WHERE id = ?').get(req.params.id);
if (!row) throw new HttpError(404, 'Enveloppe introuvable');
const data = Schema.parse(req.body);
if (data.nom !== row.nom) {
const dup = db.prepare('SELECT id FROM enveloppes_referentiel WHERE nom = ? AND id != ?').get(data.nom, row.id);
if (dup) throw new HttpError(409, `Une enveloppe "${data.nom}" existe déjà`);
}
db.prepare(`
UPDATE enveloppes_referentiel
SET nom=?, description=?, plafond_eur=?, regime_fiscal=?, updated_at=datetime('now')
WHERE id=?
`).run(data.nom, data.description || null, data.plafond_eur ?? null, data.regime_fiscal || null, row.id);
db.transaction(() => {
saveCategories(row.id, data.categories_ids);
saveSupports(row.id, data.supports_ids);
})();
const updated = db.prepare('SELECT * FROM enveloppes_referentiel WHERE id = ?').get(row.id);
const [enriched] = attachSupports(attachCategories([updated]));
res.json(enriched);
} catch (e) { next(e); }
});
// ── DELETE /api/enveloppes-referentiel/:id ──────────────────────────────────
router.delete('/:id', (req, res, next) => {
try {
const row = db.prepare('SELECT id FROM enveloppes_referentiel WHERE id = ?').get(req.params.id);
if (!row) throw new HttpError(404, 'Enveloppe introuvable');
// referentiel_categories_enveloppes et enveloppes_supports_disponibles ont toutes deux
// ON DELETE CASCADE sur enveloppe_id : aucune purge manuelle nécessaire.
db.prepare('DELETE FROM enveloppes_referentiel WHERE id = ?').run(row.id);
res.json({ deleted: true });
} catch (e) { next(e); }
});
export default router;
@@ -0,0 +1,104 @@
import { Router } from 'express';
import { z } from 'zod';
import db from '../db/index.js';
import { HttpError } from '../middleware/errorHandler.js';
/**
* routes/ref-categories-enveloppes.js — liste globale des catégories (familles) du référentiel
* "Enveloppes & Supports d'épargne" (Comptes courants, Épargne réglementée, Assurance-vie...).
* Même patron que routes/ref-categories-institutions.js (categories_institutions), table dédiée
* (categories_enveloppes) — cf. enveloppesReferentiel.js.
*/
const router = Router();
// Toutes ces routes sont montées sous requireAdmin dans server.js
/* GET /api/ref-categories-enveloppes — liste complète */
router.get('/', (_req, res) => {
const rows = db.prepare(`
SELECT c.id, c.nom,
COUNT(rc.enveloppe_id) AS nb_utilises
FROM categories_enveloppes c
LEFT JOIN referentiel_categories_enveloppes rc ON rc.categorie_id = c.id
GROUP BY c.id
ORDER BY c.nom
`).all();
res.json(rows);
});
/* POST /api/ref-categories-enveloppes — créer */
router.post('/', (req, res, next) => {
try {
const { nom } = z.object({ nom: z.string().min(1).max(200) }).parse(req.body);
const exists = db.prepare('SELECT id FROM categories_enveloppes WHERE nom = ?').get(nom.trim());
if (exists) throw new HttpError(409, `La catégorie "${nom.trim()}" existe déjà.`);
const r = db.prepare('INSERT INTO categories_enveloppes (nom) VALUES (?)').run(nom.trim());
res.status(201).json({ id: r.lastInsertRowid, nom: nom.trim(), nb_utilises: 0 });
} catch (e) { next(e); }
});
/* PUT /api/ref-categories-enveloppes/:id — renommer (propage automatiquement) */
router.put('/:id', (req, res, next) => {
try {
const { nom } = z.object({ nom: z.string().min(1).max(200) }).parse(req.body);
const row = db.prepare('SELECT id, nom FROM categories_enveloppes WHERE id = ?').get(req.params.id);
if (!row) throw new HttpError(404, 'Catégorie introuvable');
const dup = db.prepare('SELECT id FROM categories_enveloppes WHERE nom = ? AND id != ?').get(nom.trim(), row.id);
if (dup) throw new HttpError(409, `La catégorie "${nom.trim()}" existe déjà.`);
db.prepare('UPDATE categories_enveloppes SET nom = ? WHERE id = ?').run(nom.trim(), row.id);
const nb = db.prepare('SELECT COUNT(*) AS n FROM referentiel_categories_enveloppes WHERE categorie_id = ?').get(row.id).n;
res.json({ id: row.id, nom: nom.trim(), nb_utilises: nb });
} catch (e) { next(e); }
});
/* DELETE /api/ref-categories-enveloppes/:id — supprimer si non utilisée */
router.delete('/:id', (req, res, next) => {
try {
const row = db.prepare('SELECT id FROM categories_enveloppes WHERE id = ?').get(req.params.id);
if (!row) throw new HttpError(404, 'Catégorie introuvable');
const nb = db.prepare('SELECT COUNT(*) AS n FROM referentiel_categories_enveloppes WHERE categorie_id = ?').get(row.id).n;
if (nb > 0) throw new HttpError(409, `Catégorie utilisée par ${nb} enveloppe(s) — retirez-la d'abord.`);
db.prepare('DELETE FROM categories_enveloppes WHERE id = ?').run(row.id);
res.status(204).end();
} catch (e) { next(e); }
});
/* POST /api/ref-categories-enveloppes/:id/merge — fusionner dans une autre catégorie */
router.post('/:id/merge', (req, res, next) => {
try {
const { target_id } = z.object({ target_id: z.number().int() }).parse(req.body);
const source = db.prepare('SELECT id, nom FROM categories_enveloppes WHERE id = ?').get(req.params.id);
if (!source) throw new HttpError(404, 'Catégorie source introuvable');
const target = db.prepare('SELECT id, nom FROM categories_enveloppes WHERE id = ?').get(target_id);
if (!target) throw new HttpError(404, 'Catégorie cible introuvable');
if (source.id === target.id) throw new HttpError(400, 'Source et cible identiques');
const result = db.transaction(() => {
const refs = db.prepare(
'SELECT enveloppe_id FROM referentiel_categories_enveloppes WHERE categorie_id = ?'
).all(source.id);
let added = 0, skipped = 0;
for (const { enveloppe_id } of refs) {
const exists = db.prepare(
'SELECT 1 FROM referentiel_categories_enveloppes WHERE enveloppe_id = ? AND categorie_id = ?'
).get(enveloppe_id, target.id);
if (!exists) {
db.prepare(
'INSERT INTO referentiel_categories_enveloppes (enveloppe_id, categorie_id) VALUES (?, ?)'
).run(enveloppe_id, target.id);
added++;
} else {
skipped++;
}
}
db.prepare('DELETE FROM referentiel_categories_enveloppes WHERE categorie_id = ?').run(source.id);
db.prepare('DELETE FROM categories_enveloppes WHERE id = ?').run(source.id);
return { added, skipped, total: refs.length };
})();
res.json({ ok: true, ...result,
message: `Fusion terminée : ${result.added} lien(s) ajouté(s), ${result.skipped} déjà présent(s). "${source.nom}" supprimée.` });
} catch (e) { next(e); }
});
export default router;
+102
View File
@@ -0,0 +1,102 @@
import { Router } from 'express';
import { z } from 'zod';
import db from '../db/index.js';
import { HttpError } from '../middleware/errorHandler.js';
/**
* routes/supportsReferentiel.js — Référentiel admin des supports d'investissement (fonds
* euros, ETF, SCPI, titres vifs...) — chantier "Référentiel Enveloppes & Supports d'épargne"
* (28/09/26, demande Olivier).
*
* Catalogue à plat (pas de sous-catégories — une dizaine d'entrées seulement, cf. "Types de
* supports (UC) disponibles" dans la liste fournie par Olivier), transverse à tous les espaces
* de travail comme les institutions financières. Consommé par enveloppesReferentiel.js pour la
* relation N-N enveloppe ↔ supports disponibles (enveloppes_supports_disponibles).
*
* Catalogue seul pour l'instant (cadrage validé par Olivier via AskUserQuestion le 28/09/26) —
* aucun rattachement à l'existant (comptes, investissements...), en préparation du futur module
* patrimoine (cf. plan de déploiement, doc projet).
*/
const router = Router();
// Toutes ces routes sont montées sous requireAdmin dans server.js
const Schema = z.object({
nom: z.string().min(1).max(200),
description: z.string().nullable().optional(),
});
// ── GET /api/supports-referentiel ───────────────────────────────────────────
router.get('/', (_req, res) => {
const rows = db.prepare(`
SELECT s.*, COUNT(esd.enveloppe_id) AS nb_enveloppes
FROM supports_referentiel s
LEFT JOIN enveloppes_supports_disponibles esd ON esd.support_id = s.id
GROUP BY s.id
ORDER BY s.nom
`).all();
res.json(rows);
});
// ── GET /api/supports-referentiel/:id ───────────────────────────────────────
router.get('/:id', (req, res, next) => {
try {
const row = db.prepare('SELECT * FROM supports_referentiel WHERE id = ?').get(req.params.id);
if (!row) throw new HttpError(404, 'Support introuvable');
res.json(row);
} catch (e) { next(e); }
});
// ── POST /api/supports-referentiel ──────────────────────────────────────────
router.post('/', (req, res, next) => {
try {
const data = Schema.parse(req.body);
const existing = db.prepare('SELECT id FROM supports_referentiel WHERE nom = ?').get(data.nom);
if (existing) throw new HttpError(409, `Un support "${data.nom}" existe déjà`);
const r = db.prepare(`
INSERT INTO supports_referentiel (nom, description, updated_at)
VALUES (?, ?, datetime('now'))
`).run(data.nom, data.description || null);
const row = db.prepare('SELECT * FROM supports_referentiel WHERE id = ?').get(r.lastInsertRowid);
res.status(201).json(row);
} catch (e) { next(e); }
});
// ── PUT /api/supports-referentiel/:id ───────────────────────────────────────
router.put('/:id', (req, res, next) => {
try {
const row = db.prepare('SELECT * FROM supports_referentiel WHERE id = ?').get(req.params.id);
if (!row) throw new HttpError(404, 'Support introuvable');
const data = Schema.parse(req.body);
if (data.nom !== row.nom) {
const dup = db.prepare('SELECT id FROM supports_referentiel WHERE nom = ? AND id != ?').get(data.nom, row.id);
if (dup) throw new HttpError(409, `Un support "${data.nom}" existe déjà`);
}
db.prepare(`
UPDATE supports_referentiel
SET nom=?, description=?, updated_at=datetime('now')
WHERE id=?
`).run(data.nom, data.description || null, row.id);
const updated = db.prepare('SELECT * FROM supports_referentiel WHERE id = ?').get(row.id);
res.json(updated);
} catch (e) { next(e); }
});
// ── DELETE /api/supports-referentiel/:id ────────────────────────────────────
router.delete('/:id', (req, res, next) => {
try {
const row = db.prepare('SELECT id FROM supports_referentiel WHERE id = ?').get(req.params.id);
if (!row) throw new HttpError(404, 'Support introuvable');
// enveloppes_supports_disponibles a ON DELETE CASCADE sur support_id : les liens sont
// nettoyés automatiquement, aucune purge manuelle nécessaire ici.
db.prepare('DELETE FROM supports_referentiel WHERE id = ?').run(row.id);
res.json({ deleted: true });
} catch (e) { next(e); }
});
export default router;