From 69d006d3588e8bbe8ebe06c65487b8ecc871f95b Mon Sep 17 00:00:00 2001 From: Olivier Date: Sat, 4 Jul 2026 17:43:57 +0200 Subject: [PATCH] =?UTF-8?q?Correctif=20redescente=20du=20r=C3=A9f=C3=A9ren?= =?UTF-8?q?tiel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/db/index.js | 13 ++++++ backend/src/routes/associations-inv.js | 43 ++++++++++++------- backend/src/routes/categories-inv.js | 3 +- backend/src/routes/plateformes.js | 27 ++++++++---- backend/src/routes/referentiel.js | 19 +++++--- backend/src/routes/secteurs-inv.js | 3 +- .../src/pages/settings/PlateformesSection.jsx | 17 +++++--- 7 files changed, 82 insertions(+), 43 deletions(-) diff --git a/backend/src/db/index.js b/backend/src/db/index.js index 8da3af9..e77d582 100644 --- a/backend/src/db/index.js +++ b/backend/src/db/index.js @@ -2062,4 +2062,17 @@ console.log('[DB] Migrations 2FA OK'); } } +// ── Migration : exclusions de tags hérités du référentiel sur plateformes ── +// Permet à l'utilisateur de retirer une catégorie/secteur hérité du référentiel +// sans que la fusion d'affichage ou un futur push référentiel ne la réinjecte. +{ + const platColsExcl = db.prepare('PRAGMA table_info(plateformes)').all().map(c => c.name); + if (!platColsExcl.includes('excluded_categories_inv_ids')) { + db.exec("ALTER TABLE plateformes ADD COLUMN excluded_categories_inv_ids TEXT NOT NULL DEFAULT '[]'"); + } + if (!platColsExcl.includes('excluded_secteurs_inv_ids')) { + db.exec("ALTER TABLE plateformes ADD COLUMN excluded_secteurs_inv_ids TEXT NOT NULL DEFAULT '[]'"); + } +} + export default db; diff --git a/backend/src/routes/associations-inv.js b/backend/src/routes/associations-inv.js index d1643b1..1934ee9 100644 --- a/backend/src/routes/associations-inv.js +++ b/backend/src/routes/associations-inv.js @@ -28,8 +28,7 @@ const router = Router(); function checkPlatOwner(platId, userId) { const row = db.prepare(` SELECT p.id FROM plateformes p - JOIN investisseurs i ON i.id = p.investisseur_id - WHERE p.id = ? AND i.user_id = ? + WHERE p.id = ? AND p.user_id = ? `).get(platId, userId); if (!row) throw new HttpError(404, 'Plateforme introuvable ou accès refusé.'); return row; @@ -80,15 +79,16 @@ router.get('/plateformes/:id/categories-inv', (req, res, next) => { ORDER BY is_global DESC, c.nom `).all(req.params.id); - // Merge tags du référentiel avec is_inherited: 1 - const plat = db.prepare('SELECT referentiel_id FROM plateformes WHERE id = ?').get(req.params.id); + // Merge tags du référentiel avec is_inherited: 1 (sauf ceux explicitement retirés par l'utilisateur) + const plat = db.prepare('SELECT referentiel_id, excluded_categories_inv_ids FROM plateformes WHERE id = ?').get(req.params.id); if (plat?.referentiel_id) { + const excludedIds = new Set(JSON.parse(plat.excluded_categories_inv_ids || '[]')); const refCats = db.prepare(` SELECT c.id, c.nom, CASE WHEN c.user_id IS NULL THEN 1 ELSE 0 END AS is_global FROM referentiel_categories_inv rc JOIN categories_inv c ON c.id = rc.categorie_id WHERE rc.referentiel_id = ? - `).all(plat.referentiel_id); + `).all(plat.referentiel_id).filter(rc => !excludedIds.has(rc.id)); const refIdSet = new Set(refCats.map(r => r.id)); for (const row of rows) if (refIdSet.has(row.id)) row.is_inherited = 1; const ownIds = new Set(rows.map(r => r.id)); @@ -108,20 +108,25 @@ router.put('/plateformes/:id/categories-inv', (req, res, next) => { checkPlatOwner(req.params.id, userId); checkCatIds(ids, userId); - // Toujours inclure les tags hérités du référentiel + // L'utilisateur peut retirer un tag hérité du référentiel : on respecte exactement + // sa sélection, et on mémorise les tags référentiel explicitement exclus pour que + // la fusion d'affichage (GET) et un futur push référentiel ne les réinjectent pas. const plat = db.prepare('SELECT referentiel_id FROM plateformes WHERE id = ?').get(req.params.id); - let allIds = [...ids]; + const idSet = new Set(ids); + let excludedIds = []; if (plat?.referentiel_id) { const refCatIds = db.prepare('SELECT categorie_id FROM referentiel_categories_inv WHERE referentiel_id = ?') .all(plat.referentiel_id).map(r => r.categorie_id); - allIds = [...new Set([...refCatIds, ...ids])]; + excludedIds = refCatIds.filter(id => !idSet.has(id)); } db.transaction(() => { db.prepare('DELETE FROM plateforme_categories_inv WHERE plateforme_id = ?').run(req.params.id); const ins = db.prepare('INSERT INTO plateforme_categories_inv (plateforme_id, categorie_id) VALUES (?, ?)'); - for (const id of allIds) ins.run(req.params.id, id); - syncInvestissementsCategories(req.params.id, allIds); + for (const id of ids) ins.run(req.params.id, id); + db.prepare('UPDATE plateformes SET excluded_categories_inv_ids = ? WHERE id = ?') + .run(JSON.stringify(excludedIds), req.params.id); + syncInvestissementsCategories(req.params.id, ids); })(); res.json({ ok: true }); } catch (e) { next(e); } @@ -140,14 +145,15 @@ router.get('/plateformes/:id/secteurs-inv', (req, res, next) => { ORDER BY is_global DESC, s.nom `).all(req.params.id); - const plat = db.prepare('SELECT referentiel_id FROM plateformes WHERE id = ?').get(req.params.id); + const plat = db.prepare('SELECT referentiel_id, excluded_secteurs_inv_ids FROM plateformes WHERE id = ?').get(req.params.id); if (plat?.referentiel_id) { + const excludedIds = new Set(JSON.parse(plat.excluded_secteurs_inv_ids || '[]')); const refSects = db.prepare(` SELECT s.id, s.nom, CASE WHEN s.user_id IS NULL THEN 1 ELSE 0 END AS is_global FROM referentiel_secteurs_inv rs JOIN secteurs_inv s ON s.id = rs.secteur_id WHERE rs.referentiel_id = ? - `).all(plat.referentiel_id); + `).all(plat.referentiel_id).filter(rs => !excludedIds.has(rs.id)); const refIdSet = new Set(refSects.map(r => r.id)); for (const row of rows) if (refIdSet.has(row.id)) row.is_inherited = 1; const ownIds = new Set(rows.map(r => r.id)); @@ -167,19 +173,24 @@ router.put('/plateformes/:id/secteurs-inv', (req, res, next) => { checkPlatOwner(req.params.id, userId); checkSectIds(ids, userId); + // Même logique que pour les catégories : on respecte la sélection de l'utilisateur + // et on mémorise les secteurs référentiel explicitement exclus. const plat = db.prepare('SELECT referentiel_id FROM plateformes WHERE id = ?').get(req.params.id); - let allIds = [...ids]; + const idSet = new Set(ids); + let excludedIds = []; if (plat?.referentiel_id) { const refSectIds = db.prepare('SELECT secteur_id FROM referentiel_secteurs_inv WHERE referentiel_id = ?') .all(plat.referentiel_id).map(r => r.secteur_id); - allIds = [...new Set([...refSectIds, ...ids])]; + excludedIds = refSectIds.filter(id => !idSet.has(id)); } db.transaction(() => { db.prepare('DELETE FROM plateforme_secteurs_inv WHERE plateforme_id = ?').run(req.params.id); const ins = db.prepare('INSERT INTO plateforme_secteurs_inv (plateforme_id, secteur_id) VALUES (?, ?)'); - for (const id of allIds) ins.run(req.params.id, id); - syncInvestissementsSecteurs(req.params.id, allIds); + for (const id of ids) ins.run(req.params.id, id); + db.prepare('UPDATE plateformes SET excluded_secteurs_inv_ids = ? WHERE id = ?') + .run(JSON.stringify(excludedIds), req.params.id); + syncInvestissementsSecteurs(req.params.id, ids); })(); res.json({ ok: true }); } catch (e) { next(e); } diff --git a/backend/src/routes/categories-inv.js b/backend/src/routes/categories-inv.js index 3274bce..9bcceae 100644 --- a/backend/src/routes/categories-inv.js +++ b/backend/src/routes/categories-inv.js @@ -22,8 +22,7 @@ router.get('/', (req, res) => { (SELECT COUNT(*) FROM plateforme_categories_inv pc WHERE pc.categorie_id = c.id AND pc.plateforme_id IN ( SELECT p.id FROM plateformes p - JOIN investisseurs i ON i.id = p.investisseur_id - WHERE i.user_id = ? + WHERE p.user_id = ? ) ) AS nb_plateformes, (SELECT COUNT(*) FROM investissement_categories_inv ic WHERE ic.categorie_id = c.id diff --git a/backend/src/routes/plateformes.js b/backend/src/routes/plateformes.js index 32511ab..98c2bd9 100644 --- a/backend/src/routes/plateformes.js +++ b/backend/src/routes/plateformes.js @@ -143,7 +143,8 @@ function attachCategoriesInv(rows) { return rows.map(r => { const ownTags = map[r.id] || []; if (!r.referentiel_id) return { ...r, categories_inv: ownTags }; - const refTags = refCatMap[r.referentiel_id] || []; + const excludedIds = new Set(JSON.parse(r.excluded_categories_inv_ids || '[]')); + const refTags = (refCatMap[r.referentiel_id] || []).filter(t => !excludedIds.has(t.id)); const refIdSet = new Set(refTags.map(t => t.id)); const ownIds = new Set(ownTags.map(t => t.id)); for (const tag of ownTags) tag.is_inherited = refIdSet.has(tag.id); @@ -192,7 +193,8 @@ function attachSecteursInv(rows) { return rows.map(r => { const ownTags = map[r.id] || []; if (!r.referentiel_id) return { ...r, secteurs_inv: ownTags }; - const refTags = refSectMap[r.referentiel_id] || []; + const excludedIds = new Set(JSON.parse(r.excluded_secteurs_inv_ids || '[]')); + const refTags = (refSectMap[r.referentiel_id] || []).filter(t => !excludedIds.has(t.id)); const refIdSet = new Set(refTags.map(t => t.id)); const ownIds = new Set(ownTags.map(t => t.id)); for (const tag of ownTags) tag.is_inherited = refIdSet.has(tag.id); @@ -218,8 +220,9 @@ function syncCategories(platId, catIds) { // ── Lecture référentiel (tous users authentifiés) ───────────────────────── router.get('/referentiel-list', (_req, res) => { const rows = db.prepare(` - SELECT pr.id, pr.nom, pr.domiciliation, pr.fiscalite, - pr.taux_fiscalite_locale, pr.type_produit_fiscal, pr.logo_filename, pr.icone_filename + SELECT pr.id, pr.nom, pr.url, pr.domiciliation, pr.fiscalite, + pr.taux_fiscalite_locale, pr.type_produit_fiscal, pr.logo_filename, pr.icone_filename, + pr.methode_remboursement, pr.type_pret_defaut, pr.freq_interets_defaut FROM plateformes_referentiel pr ORDER BY pr.nom `).all(); @@ -233,6 +236,7 @@ router.get('/', (req, res) => { p.methode_remboursement, p.investisseur_id, p.date_ouverture, p.logo_filename, p.icone_filename, p.created_at, p.type_pret_defaut, p.freq_interets_defaut, p.referentiel_id, p.overridden_fields, + p.excluded_categories_inv_ids, p.excluded_secteurs_inv_ids, pr.nom AS referentiel_nom, pr.description AS referentiel_description, inv.nom AS investisseur_nom, inv.prenom AS investisseur_prenom, inv.type AS investisseur_type, inv.type_fiscal AS investisseur_type_fiscal, @@ -251,7 +255,10 @@ router.get('/', (req, res) => { })); const withCats = attachCategories(req.user.id, enriched); const withCatsInv = attachCategoriesInv(withCats); - const withAll = attachSecteursInv(withCatsInv); + const withAll = attachSecteursInv(withCatsInv).map(r => { + const { excluded_categories_inv_ids, excluded_secteurs_inv_ids, ...rest } = r; + return rest; + }); res.json(withAll); }); @@ -684,12 +691,14 @@ router.post('/:id/reset', (req, res, next) => { db.prepare(` UPDATE plateformes - SET nom=?, domiciliation=?, fiscalite=?, taux_fiscalite_locale=?, - type_produit_fiscal=?, logo_filename=?, overridden_fields='[]' + SET nom=?, url=?, domiciliation=?, fiscalite=?, taux_fiscalite_locale=?, + type_produit_fiscal=?, logo_filename=?, methode_remboursement=?, + type_pret_defaut=?, freq_interets_defaut=?, overridden_fields='[]' WHERE id=? AND user_id=? `).run( - ref.nom, ref.domiciliation, ref.fiscalite, ref.taux_fiscalite_locale ?? null, - ref.type_produit_fiscal, ref.logo_filename ?? null, + ref.nom, ref.url ?? null, ref.domiciliation, ref.fiscalite, ref.taux_fiscalite_locale ?? null, + ref.type_produit_fiscal, ref.logo_filename ?? null, ref.methode_remboursement || 'portefeuille', + ref.type_pret_defaut ?? null, ref.freq_interets_defaut ?? null, req.params.id, req.user.id ); diff --git a/backend/src/routes/referentiel.js b/backend/src/routes/referentiel.js index b63dcf2..57341dc 100644 --- a/backend/src/routes/referentiel.js +++ b/backend/src/routes/referentiel.js @@ -825,7 +825,7 @@ router.post('/:id/push', (req, res, next) => { ).all(ref.id).map(r => r.secteur_id); const plateformes = db.prepare( - 'SELECT id, overridden_fields FROM plateformes WHERE referentiel_id = ?' + 'SELECT id, overridden_fields, excluded_categories_inv_ids, excluded_secteurs_inv_ids FROM plateformes WHERE referentiel_id = ?' ).all(ref.id); let nb_updated = 0; @@ -853,14 +853,19 @@ router.post('/:id/push', (req, res, next) => { } // ── Catégories/secteurs ── + // Méthode dure (force) : réinitialise aussi les exclusions utilisateur et remplace toutes les associations. + // Méthode douce (défaut) : respecte les tags que l'utilisateur a explicitement retirés (excluded_*_ids). + const excludedCatIds = force ? new Set() : new Set(JSON.parse(plat.excluded_categories_inv_ids || '[]')); + const excludedSectIds = force ? new Set() : new Set(JSON.parse(plat.excluded_secteurs_inv_ids || '[]')); + if (force) { - // Méthode dure : remplace toutes les associations db.prepare('DELETE FROM plateforme_categories_inv WHERE plateforme_id = ?').run(plat.id); db.prepare('DELETE FROM plateforme_secteurs_inv WHERE plateforme_id = ?').run(plat.id); + db.prepare("UPDATE plateformes SET excluded_categories_inv_ids = '[]', excluded_secteurs_inv_ids = '[]' WHERE id = ?").run(plat.id); } - // Méthode douce : INSERT OR IGNORE (n'écrase pas les associations existantes) - for (const catId of refCatIds) insCat.run(plat.id, catId); - for (const sectId of refSectIds) insSect.run(plat.id, sectId); + // INSERT OR IGNORE (n'écrase pas les associations existantes), en sautant les tags exclus par l'utilisateur + for (const catId of refCatIds) if (!excludedCatIds.has(catId)) insCat.run(plat.id, catId); + for (const sectId of refSectIds) if (!excludedSectIds.has(sectId)) insSect.run(plat.id, sectId); // Sync investissements actifs de la plateforme const invs = db.prepare( @@ -871,8 +876,8 @@ router.post('/:id/push', (req, res, next) => { db.prepare('DELETE FROM investissement_categories_inv WHERE investissement_id = ?').run(inv.id); db.prepare('DELETE FROM investissement_secteurs_inv WHERE investissement_id = ?').run(inv.id); } - for (const catId of refCatIds) insInvCat.run(inv.id, catId); - for (const sectId of refSectIds) insInvSect.run(inv.id, sectId); + for (const catId of refCatIds) if (!excludedCatIds.has(catId)) insInvCat.run(inv.id, catId); + for (const sectId of refSectIds) if (!excludedSectIds.has(sectId)) insInvSect.run(inv.id, sectId); } nb_updated++; diff --git a/backend/src/routes/secteurs-inv.js b/backend/src/routes/secteurs-inv.js index fdf3374..ed21801 100644 --- a/backend/src/routes/secteurs-inv.js +++ b/backend/src/routes/secteurs-inv.js @@ -21,8 +21,7 @@ router.get('/', (req, res) => { (SELECT COUNT(*) FROM plateforme_secteurs_inv ps WHERE ps.secteur_id = s.id AND ps.plateforme_id IN ( SELECT p.id FROM plateformes p - JOIN investisseurs i ON i.id = p.investisseur_id - WHERE i.user_id = ? + WHERE p.user_id = ? ) ) AS nb_plateformes, (SELECT COUNT(*) FROM investissement_secteurs_inv is2 WHERE is2.secteur_id = s.id diff --git a/frontend/src/pages/settings/PlateformesSection.jsx b/frontend/src/pages/settings/PlateformesSection.jsx index 7b82932..a32f672 100644 --- a/frontend/src/pages/settings/PlateformesSection.jsx +++ b/frontend/src/pages/settings/PlateformesSection.jsx @@ -576,9 +576,9 @@ function PlatForm({ state, setter, logoFile, setLogoFile, logoPreview, setLogoPr const inherited = (state.inherited_cat_ids || []).includes(c.id); const checked = (state.categories_inv_ids || []).includes(c.id); return ( -