Maj Phase 2 - Institutions financières

This commit is contained in:
ocroguennec committed 2026-09-26 18:28:15 +02:00
1 parent 1dcb33aa74
commit b18ffc63ee
18 files changed
+383 -16

No files matched your search

+12
View File
@@ -3657,4 +3657,16 @@ db.exec(`
}
}
// ── Migration : institution_id sur comptes (Phase 2, demande Olivier 26/09/26) ────────────
// Rattache un compte au référentiel institutions_referentiel — nullable, non-destructif :
// le champ banque (texte libre) reste en base tel quel pour les comptes non encore réconciliés
// (cf. plan de déploiement, doc projet claude/plan_referentiel_institutions_financieres.md).
{
const cols = db.prepare('PRAGMA table_info(comptes)').all().map(c => c.name);
if (!cols.includes('institution_id')) {
db.exec('ALTER TABLE comptes ADD COLUMN institution_id INTEGER REFERENCES institutions_referentiel(id) ON DELETE SET NULL');
}
}
export default db;
+86
View File
@@ -832,4 +832,90 @@ router.post('/exports/:filename/restore', async (req, res, next) => {
}
});
/* ── Comptes orphelins (banque texte libre non reliée au référentiel institutions) ───────
Phase 2 du chantier "Référentiel Institutions financières" (cf. plan de déploiement, doc
projet claude/plan_referentiel_institutions_financieres.md). Contrairement aux plateformes
orphelines (une ligne par plateforme), ici on regroupe par valeur `banque` distincte car
plusieurs comptes (potentiellement de plusieurs utilisateurs) partagent souvent le même
texte libre — Lier/Importer agit donc sur tous les comptes portant cette valeur d'un coup. */
/** Liste les valeurs `banque` distinctes non reliées à institution_id, avec suggestion */
router.get('/comptes-orphelins', (_req, res) => {
const rows = db.prepare(`
SELECT c.banque, u.id AS user_id, u.email AS user_email, u.display_name AS user_display_name, COUNT(*) AS nb
FROM comptes c
JOIN users u ON u.id = c.user_id
WHERE c.institution_id IS NULL AND c.banque IS NOT NULL AND TRIM(c.banque) != ''
GROUP BY c.banque, u.id
ORDER BY c.banque, u.email
`).all();
const byBanque = {};
for (const r of rows) {
if (!byBanque[r.banque]) byBanque[r.banque] = { banque: r.banque, total: 0, users: [] };
byBanque[r.banque].total += r.nb;
byBanque[r.banque].users.push({ id: r.user_id, email: r.user_email, display_name: r.user_display_name, nb: r.nb });
}
const refs = db.prepare('SELECT id, nom FROM institutions_referentiel ORDER BY nom').all();
const enriched = Object.values(byBanque).map(g => {
let bestRef = null, bestScore = 0;
for (const ref of refs) {
const score = similarity(g.banque, ref.nom);
if (score > bestScore) { bestScore = score; bestRef = ref; }
}
return {
...g,
suggestion: bestScore >= SIMILARITY_THRESHOLD
? { institution_id: bestRef.id, institution_nom: bestRef.nom, score: Math.round(bestScore * 100) }
: null,
};
}).sort((a, b) => a.banque.localeCompare(b.banque, 'fr'));
res.json(enriched);
});
/** Lie tous les comptes portant cette valeur `banque` à une institution existante du référentiel */
router.post('/comptes-orphelins/lier', (req, res, next) => {
try {
const { banque, institution_id } = z.object({
banque: z.string().min(1),
institution_id: z.number().int().positive(),
}).parse(req.body);
const ref = db.prepare('SELECT * FROM institutions_referentiel WHERE id = ?').get(institution_id);
if (!ref) throw new HttpError(404, 'Institution introuvable');
const r = db.prepare(
'UPDATE comptes SET institution_id = ? WHERE banque = ? AND institution_id IS NULL'
).run(institution_id, banque);
res.json({ linked: true, nb_comptes: r.changes, institution: ref });
} catch (e) { next(e); }
});
/** Crée une nouvelle institution dans le référentiel à partir de la valeur `banque`, et lie
tous les comptes correspondants (ou lie simplement si une institution de ce nom existe déjà) */
router.post('/comptes-orphelins/importer', (req, res, next) => {
try {
const { banque } = z.object({ banque: z.string().min(1) }).parse(req.body);
let ref = db.prepare('SELECT * FROM institutions_referentiel WHERE nom = ?').get(banque);
let created = false;
if (!ref) {
const r = db.prepare('INSERT INTO institutions_referentiel (nom) VALUES (?)').run(banque);
ref = db.prepare('SELECT * FROM institutions_referentiel WHERE id = ?').get(r.lastInsertRowid);
created = true;
}
const upd = db.prepare(
'UPDATE comptes SET institution_id = ? WHERE banque = ? AND institution_id IS NULL'
).run(ref.id, banque);
res.status(created ? 201 : 200).json({ linked: true, created, nb_comptes: upd.changes, institution: ref });
} catch (e) { next(e); }
});
export default router;
+10 -6
View File
@@ -10,16 +10,20 @@ const Schema = z.object({
type: z.enum(['compte_courant', 'pea_pme']).default('compte_courant'),
investisseur_id: z.number().int().positive().nullable().optional(),
banque: z.string().nullable().optional(),
institution_id: z.number().int().positive().nullable().optional(),
exoneration_fiscale: z.enum(['aucune', 'pfnl_5ans']).default('aucune'),
});
router.get('/', (req, res) => {
const rows = db.prepare(`
SELECT c.id, c.nom, c.type, c.banque, c.exoneration_fiscale, c.investisseur_id, c.created_at,
SELECT c.id, c.nom, c.type, c.banque, c.institution_id, c.exoneration_fiscale, c.investisseur_id, c.created_at,
inv.nom AS investisseur_nom, inv.prenom AS investisseur_prenom,
inv.type AS investisseur_type, inv.type_fiscal AS investisseur_type_fiscal
inv.type AS investisseur_type, inv.type_fiscal AS investisseur_type_fiscal,
ir.nom AS institution_nom, ir.domiciliation AS institution_domiciliation,
ir.logo_filename AS institution_logo_filename, ir.icone_filename AS institution_icone_filename
FROM comptes c
LEFT JOIN investisseurs inv ON inv.id = c.investisseur_id
LEFT JOIN institutions_referentiel ir ON ir.id = c.institution_id
WHERE c.user_id = ?
ORDER BY c.nom
`).all(req.user.id);
@@ -30,8 +34,8 @@ router.post('/', (req, res, next) => {
try {
const body = Schema.parse(req.body);
const r = db.prepare(
'INSERT INTO comptes (user_id, nom, type, banque, investisseur_id, exoneration_fiscale) VALUES (?,?,?,?,?,?)'
).run(req.user.id, body.nom, body.type, body.banque || null, body.investisseur_id ?? null, body.exoneration_fiscale ?? 'aucune');
'INSERT INTO comptes (user_id, nom, type, banque, institution_id, investisseur_id, exoneration_fiscale) VALUES (?,?,?,?,?,?,?)'
).run(req.user.id, body.nom, body.type, body.banque || null, body.institution_id ?? null, body.investisseur_id ?? null, body.exoneration_fiscale ?? 'aucune');
res.status(201).json({ id: r.lastInsertRowid, ...body });
} catch (e) { next(e); }
});
@@ -40,8 +44,8 @@ router.put('/:id', (req, res, next) => {
try {
const body = Schema.parse(req.body);
const r = db.prepare(
`UPDATE comptes SET nom=?, type=?, banque=?, investisseur_id=?, exoneration_fiscale=?, updated_at=datetime('now') WHERE id=? AND user_id=?`
).run(body.nom, body.type, body.banque || null, body.investisseur_id ?? null, body.exoneration_fiscale ?? 'aucune', req.params.id, req.user.id);
`UPDATE comptes SET nom=?, type=?, banque=?, institution_id=?, investisseur_id=?, exoneration_fiscale=?, updated_at=datetime('now') WHERE id=? AND user_id=?`
).run(body.nom, body.type, body.banque || null, body.institution_id ?? null, body.investisseur_id ?? null, body.exoneration_fiscale ?? 'aucune', req.params.id, req.user.id);
if (r.changes === 0) throw new HttpError(404, 'Not found');
res.json({ id: Number(req.params.id), ...body });
} catch (e) { next(e); }
@@ -0,0 +1,44 @@
import { Router } from 'express';
import db from '../db/index.js';
/**
* routes/institutionsReferentielPublic.js — lecture seule du référentiel "Institutions
* financières" pour tous les utilisateurs authentifiés (pas requireAdmin), sur le modèle de
* referentielPublic.js. Contrairement aux plateformes (où chaque utilisateur crée sa propre
* entrée, réconciliée a posteriori par l'admin via OrphelinesSection), le référentiel
* institutions est un catalogue transverse : on expose ici une LISTE complète, utilisée par le
* sélecteur "Banque" de Paramètres > Mes comptes (Phase 2, cf. plan de déploiement).
*/
const router = Router();
// Monté sous requireAuth (sans requireAdmin) — lecture seule.
function attachCategories(rows) {
if (rows.length === 0) return rows;
const ids = rows.map(r => r.id);
const links = db.prepare(`
SELECT rc.institution_id, c.id, c.nom
FROM referentiel_categories_institutions rc
JOIN categories_institutions c ON c.id = rc.categorie_id
WHERE rc.institution_id IN (${ids.map(() => '?').join(',')})
ORDER BY c.nom
`).all(...ids);
const map = {};
for (const l of links) {
if (!map[l.institution_id]) map[l.institution_id] = [];
map[l.institution_id].push({ id: l.id, nom: l.nom });
}
return rows.map(r => ({ ...r, categories: map[r.id] || [] }));
}
/* GET /api/institutions-referentiel-public — liste complète (lecture seule) */
router.get('/', (_req, res) => {
const rows = db.prepare(`
SELECT id, nom, domiciliation, logo_filename, icone_filename
FROM institutions_referentiel
ORDER BY nom
`).all();
res.json(attachCategories(rows));
});
export default router;
+2
View File
@@ -51,6 +51,7 @@ import tauxCreditImpotRouter from './routes/tauxCreditImpot.js';
import referentielRouter from './routes/referentiel.js';
import referentielPublicRouter from './routes/referentielPublic.js';
import institutionsReferentielRouter from './routes/institutionsReferentiel.js';
import institutionsReferentielPublicRouter from './routes/institutionsReferentielPublic.js';
import refCategoriesInstitutionsRouter from './routes/ref-categories-institutions.js';
import refCategoriesRouter from './routes/ref-categories.js';
import refSecteursRouter from './routes/ref-secteurs.js';
@@ -173,6 +174,7 @@ app.use('/api/referentiel-public', requireAuth, referentielPublicRouter);
// Chantier "Référentiel Institutions financières" (26/09/26, demande Olivier) — Phase 1 :
// catalogue admin transverse aux workspaces, cf. institutionsReferentiel.js.
app.use('/api/institutions-referentiel', requireAuth, requireAdmin, institutionsReferentielRouter);
app.use('/api/institutions-referentiel-public', requireAuth, institutionsReferentielPublicRouter);
app.use('/api/ref-categories-institutions', requireAuth, requireAdmin, refCategoriesInstitutionsRouter);
app.use('/api/ref-categories', requireAuth, requireAdmin, refCategoriesRouter);
app.use('/api/ref-secteurs', requireAuth, requireAdmin, refSecteursRouter);