Correctif redescente du référentiel

This commit is contained in:
2026-07-04 17:43:57 +02:00
parent ff77ee977d
commit 69d006d358
7 changed files with 82 additions and 43 deletions
+13
View File
@@ -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;
+27 -16
View File
@@ -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); }
+1 -2
View File
@@ -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
+18 -9
View File
@@ -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
);
+12 -7
View File
@@ -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++;
+1 -2
View File
@@ -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
@@ -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 (
<label key={c.id} style={{ display: 'flex', alignItems: 'center', gap: 4, cursor: inherited ? 'default' : 'pointer',
opacity: inherited ? 0.6 : 1 }}>
<input type="checkbox" checked={checked} disabled={inherited} style={{ width: 'auto' }}
<label key={c.id} style={{ display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' }}
title={inherited ? 'Catégorie héritée du référentiel — décochez pour la retirer de cette plateforme' : undefined}>
<input type="checkbox" checked={checked} style={{ width: 'auto' }}
onChange={e => setter(s => ({
...s,
categories_inv_ids: e.target.checked
@@ -599,9 +599,9 @@ function PlatForm({ state, setter, logoFile, setLogoFile, logoPreview, setLogoPr
const inherited = (state.inherited_sect_ids || []).includes(s.id);
const checked = (state.secteurs_inv_ids || []).includes(s.id);
return (
<label key={s.id} style={{ display: 'flex', alignItems: 'center', gap: 4, cursor: inherited ? 'default' : 'pointer',
opacity: inherited ? 0.6 : 1 }}>
<input type="checkbox" checked={checked} disabled={inherited} style={{ width: 'auto' }}
<label key={s.id} style={{ display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' }}
title={inherited ? 'Secteur hérité du référentiel — décochez pour le retirer de cette plateforme' : undefined}>
<input type="checkbox" checked={checked} style={{ width: 'auto' }}
onChange={e => setter(prev => ({
...prev,
secteurs_inv_ids: e.target.checked
@@ -783,11 +783,14 @@ export default function PlateformesSection() {
try {
await api.post('/plateformes', {
nom: ref.nom,
url: ref.url || '',
domiciliation: ref.domiciliation || 'FR',
fiscalite: ref.fiscalite || 'flat_tax',
taux_fiscalite_locale: ref.taux_fiscalite_locale ?? null,
type_produit_fiscal: ref.type_produit_fiscal || '2TT',
methode_remboursement: 'portefeuille',
methode_remboursement: ref.methode_remboursement || 'portefeuille',
type_pret_defaut: ref.type_pret_defaut || null,
freq_interets_defaut: ref.freq_interets_defaut || null,
referentiel_id: ref.id,
});
setShowAddPicker(false);