Maj Phase 2 - Institutions financières
This commit is contained in:
1 parent
1dcb33aa74
commit
b18ffc63ee
18 files changed
+383
-16
No files matched your search
@@ -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;
|
||||
@@ -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,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;
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user