Implementation de la notion d'espace d'investissement
This commit is contained in:
1 parent
6b6cad7cd6
commit
4b7df66862
54 files changed
+6782
-288
No files matched your search
@@ -3069,6 +3069,22 @@ db.exec(`
|
||||
`);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_user_workspaces_user ON user_workspaces(user_id)');
|
||||
|
||||
// ── Migration : couleur de thème par (utilisateur, workspace) (17/09/26) ──
|
||||
// Permet à l'utilisateur de personnaliser la couleur d'accent par espace de
|
||||
// travail (demande Olivier) : NULL = hérite de la couleur de l'application
|
||||
// (personnelle si définie via user_preferences.color_theme, sinon défaut
|
||||
// admin) — même énumération que color_theme (cf. COLOR_THEME_VALUES,
|
||||
// routes/preferences.js). Stocké sur user_workspaces (PK user_id+
|
||||
// workspace_id) plutôt que sur workspaces : c'est un réglage personnel par
|
||||
// compte, pas un réglage global de l'espace. Cf. AppearanceSection.jsx,
|
||||
// WorkspaceContext.jsx et UiContext.jsx (workspaceColorOverride).
|
||||
{
|
||||
const uwCols = db.prepare('PRAGMA table_info(user_workspaces)').all().map(c => c.name);
|
||||
if (!uwCols.includes('color_theme')) {
|
||||
db.exec('ALTER TABLE user_workspaces ADD COLUMN color_theme TEXT');
|
||||
}
|
||||
}
|
||||
|
||||
// Seed : workspace "crowdlending" = l'app actuelle. Idempotent (slug UNIQUE).
|
||||
db.prepare(`
|
||||
INSERT OR IGNORE INTO workspaces (slug, nom, libelle_menu, description, actif_global, ordre)
|
||||
@@ -3103,4 +3119,247 @@ export function grantDefaultWorkspace(userId) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Migration : Workspaces sur le référentiel plateformes (08/09/26) ─────
|
||||
// Chaque entrée du référentiel (plateformes_referentiel) est désormais
|
||||
// rattachée à un ou plusieurs workspaces (relation N-N, même patron que
|
||||
// referentiel_categories_inv/referentiel_secteurs_inv). Demande d'Olivier :
|
||||
// réaffectation obligatoire vers "crowdlending" pour ce qui existe déjà en
|
||||
// base, et au moins une affectation exigée à la création (validé côté API
|
||||
// par le schéma Zod de routes/referentiel.js, min(1) sur workspace_ids).
|
||||
// Placé ici (après la création de `workspaces` ci-dessus) pour garantir
|
||||
// que la table existe déjà. Cf. project_workspaces_transformation.md.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS referentiel_workspaces (
|
||||
referentiel_id INTEGER NOT NULL REFERENCES plateformes_referentiel(id) ON DELETE CASCADE,
|
||||
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (referentiel_id, workspace_id)
|
||||
)
|
||||
`);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_ref_workspaces_ws ON referentiel_workspaces(workspace_id)');
|
||||
|
||||
// Backfill idempotent : toute entrée référentiel sans aucune affectation
|
||||
// (comptes créés avant cette migration, ou entrées jamais touchées depuis)
|
||||
// est rattachée au workspace "crowdlending" — seul workspace réel à ce jour.
|
||||
{
|
||||
const wsCrowdlending = db.prepare("SELECT id FROM workspaces WHERE slug = 'crowdlending'").get();
|
||||
if (wsCrowdlending) {
|
||||
db.prepare(`
|
||||
INSERT OR IGNORE INTO referentiel_workspaces (referentiel_id, workspace_id)
|
||||
SELECT pr.id, ?
|
||||
FROM plateformes_referentiel pr
|
||||
WHERE NOT EXISTS (SELECT 1 FROM referentiel_workspaces rw WHERE rw.referentiel_id = pr.id)
|
||||
`).run(wsCrowdlending.id);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Migration : Workspaces sur les plateformes utilisateur (08/09/26) ────
|
||||
// Extension du même principe que le référentiel ci-dessus : chaque
|
||||
// plateforme UTILISATEUR (table `plateformes`, distincte du référentiel
|
||||
// admin) peut désormais être rattachée à un ou plusieurs workspaces (N-N,
|
||||
// même patron `xxx_workspaces`). Décision d'Olivier (AskUserQuestion,
|
||||
// 08/09/26) : un compte sur une plateforme peut servir à la fois au
|
||||
// crowdlending et à un futur workspace Private Equity — pas de colonne
|
||||
// unique comme initialement envisagé pour l'Étape 2 du retrofit général.
|
||||
// Backfill : toute plateforme déjà en base est rattachée à "crowdlending".
|
||||
// Cf. project_workspaces_transformation.md.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS plateforme_workspaces (
|
||||
plateforme_id INTEGER NOT NULL REFERENCES plateformes(id) ON DELETE CASCADE,
|
||||
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (plateforme_id, workspace_id)
|
||||
)
|
||||
`);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_plat_workspaces_ws ON plateforme_workspaces(workspace_id)');
|
||||
|
||||
// Backfill idempotent : toute plateforme utilisateur sans aucune affectation
|
||||
// est rattachée au workspace "crowdlending" — seul workspace réel à ce jour.
|
||||
{
|
||||
const wsCrowdlending = db.prepare("SELECT id FROM workspaces WHERE slug = 'crowdlending'").get();
|
||||
if (wsCrowdlending) {
|
||||
db.prepare(`
|
||||
INSERT OR IGNORE INTO plateforme_workspaces (plateforme_id, workspace_id)
|
||||
SELECT p.id, ?
|
||||
FROM plateformes p
|
||||
WHERE NOT EXISTS (SELECT 1 FROM plateforme_workspaces pw WHERE pw.plateforme_id = p.id)
|
||||
`).run(wsCrowdlending.id);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Migration : workspace_id sur depots_retraits (17/09/26) ─────────────
|
||||
// Contrairement à plateformes/plateformes_referentiel (relation N-N — un compte
|
||||
// pouvant servir plusieurs workspaces à la fois), Olivier a tranché le 17/09/26
|
||||
// pour une colonne UNIQUE sur depots_retraits : chaque mouvement de trésorerie
|
||||
// est "flêché" vers un seul workspace précis. Démarre, table par table, l'Étape 1
|
||||
// du retrofit workspace_id documenté dans project_workspaces_transformation.md —
|
||||
// dépôts/retraits en premier, à la demande explicite d'Olivier (remboursements
|
||||
// ensuite). Colonne nullable, backfill de l'existant vers "crowdlending".
|
||||
{
|
||||
const drCols2 = db.prepare('PRAGMA table_info(depots_retraits)').all().map(c => c.name);
|
||||
if (!drCols2.includes('workspace_id')) {
|
||||
db.exec('ALTER TABLE depots_retraits ADD COLUMN workspace_id INTEGER REFERENCES workspaces(id)');
|
||||
}
|
||||
}
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_depret_workspace ON depots_retraits(workspace_id)');
|
||||
|
||||
// Backfill idempotent : tout dépôt/retrait sans workspace (créé avant cette
|
||||
// migration) est rattaché à "crowdlending" — seul workspace réel à ce jour.
|
||||
{
|
||||
const wsCrowdlending2 = db.prepare("SELECT id FROM workspaces WHERE slug = 'crowdlending'").get();
|
||||
if (wsCrowdlending2) {
|
||||
db.prepare('UPDATE depots_retraits SET workspace_id = ? WHERE workspace_id IS NULL').run(wsCrowdlending2.id);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Migration : workspace_id sur remboursements (17/09/26) ──────────────
|
||||
// Suite du retrofit workspace_id (project_workspaces_transformation.md),
|
||||
// table par table à la demande d'Olivier : après depots_retraits, les
|
||||
// remboursements. Même principe : colonne unique nullable, backfill de
|
||||
// l'existant vers "crowdlending".
|
||||
{
|
||||
const rembCols = db.prepare('PRAGMA table_info(remboursements)').all().map(c => c.name);
|
||||
if (!rembCols.includes('workspace_id')) {
|
||||
db.exec('ALTER TABLE remboursements ADD COLUMN workspace_id INTEGER REFERENCES workspaces(id)');
|
||||
}
|
||||
}
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_remb_workspace ON remboursements(workspace_id)');
|
||||
|
||||
// Backfill idempotent : tout remboursement sans workspace (créé avant cette
|
||||
// migration) est rattaché à "crowdlending" — seul workspace réel à ce jour.
|
||||
{
|
||||
const wsCrowdlending3 = db.prepare("SELECT id FROM workspaces WHERE slug = 'crowdlending'").get();
|
||||
if (wsCrowdlending3) {
|
||||
db.prepare('UPDATE remboursements SET workspace_id = ? WHERE workspace_id IS NULL').run(wsCrowdlending3.id);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Investissements Private Equity (17/09/26) ────────────────────────────
|
||||
// Nouveau modèle de données, volontairement séparé de `investissements`
|
||||
// (cf. project_workspaces_transformation.md) : un deal PE (ex. Fundora) n'a
|
||||
// ni échéancier connu à l'avance, ni taux d'intérêt fixe — c'est un montant
|
||||
// unique investi (même principe que le crowdlending, décision Olivier
|
||||
// 17/09/26 : le porte-monnaie diminue d'un coup, pas de suivi souscrit/
|
||||
// décaissé), valorisé périodiquement par le fonds, avec un objectif de
|
||||
// performance hétérogène selon les deals (multiple "x4,5" ou fourchette de
|
||||
// taux annuel "9-12 %/an") — on le stocke donc en texte libre plutôt que
|
||||
// d'essayer de le structurer. Table neuve : workspace_id est NOT NULL dès
|
||||
// la création, pas besoin du nullable+backfill utilisé pour les tables
|
||||
// préexistantes (depots_retraits, remboursements).
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS investissements_pe (
|
||||
id INTEGER PRIMARY KEY,
|
||||
investisseur_id INTEGER NOT NULL REFERENCES investisseurs(id) ON DELETE CASCADE,
|
||||
plateforme_id INTEGER NOT NULL REFERENCES plateformes(id) ON DELETE CASCADE,
|
||||
workspace_id INTEGER NOT NULL REFERENCES workspaces(id),
|
||||
nom_deal TEXT NOT NULL,
|
||||
strategie TEXT, -- texte libre : 'Capital développement', 'Capital risque', 'Dette'...
|
||||
date_souscription TEXT NOT NULL,
|
||||
duree_mois INTEGER,
|
||||
montant_investi REAL NOT NULL CHECK(montant_investi > 0),
|
||||
objectif TEXT, -- texte libre : "x4,5" ou "9 % - 12 %/an"
|
||||
statut TEXT NOT NULL DEFAULT 'valide'
|
||||
CHECK(statut IN ('en_attente','valide','cloture','perte_definitive')),
|
||||
reference TEXT,
|
||||
source TEXT NOT NULL DEFAULT 'manuel',
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_invpe_inv ON investissements_pe(investisseur_id)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_invpe_plat ON investissements_pe(plateforme_id)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_invpe_ws ON investissements_pe(workspace_id)');
|
||||
|
||||
// ── Migration : frais PE — investissement_pe_id (17/09/26) ─────────────────
|
||||
// frais_operations était exclusivement rattachée à investissements (crowdlending).
|
||||
// Pour permettre la traçabilité des frais des deals PE (investissements_pe), on ajoute
|
||||
// une colonne investissement_pe_id nullable, on desserre investissement_id (désormais
|
||||
// nullable lui aussi), et on impose par CHECK qu'exactement une des deux références
|
||||
// soit renseignée. Reconstruction de table (SQLite ne permet pas d'altérer une
|
||||
// contrainte NOT NULL ni un CHECK en place) — même stratégie que les migrations
|
||||
// précédentes de frais_operations, cf. plus haut dans ce fichier.
|
||||
{
|
||||
const fraisOpColsPe = db.prepare('PRAGMA table_info(frais_operations)').all().map(c => c.name);
|
||||
if (!fraisOpColsPe.includes('investissement_pe_id')) {
|
||||
const tempName = '__repair_frais_operations_pe';
|
||||
|
||||
const fixedDdl = `
|
||||
CREATE TABLE "${tempName}" (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
investissement_id INTEGER REFERENCES investissements(id) ON DELETE CASCADE,
|
||||
investissement_pe_id INTEGER REFERENCES investissements_pe(id) ON DELETE CASCADE,
|
||||
plateforme_id INTEGER NOT NULL REFERENCES plateformes(id) ON DELETE CASCADE,
|
||||
remboursement_id INTEGER REFERENCES remboursements(id) ON DELETE CASCADE,
|
||||
montant REAL NOT NULL,
|
||||
mode_reglement TEXT NOT NULL DEFAULT 'source'
|
||||
CHECK(mode_reglement IN ('source','portefeuille','compte_courant','remboursement')),
|
||||
compte_id INTEGER REFERENCES comptes(id) ON DELETE SET NULL,
|
||||
date_operation TEXT NOT NULL,
|
||||
origine TEXT NOT NULL DEFAULT 'manuel'
|
||||
CHECK(origine IN ('souscription','remboursement','manuel')),
|
||||
categorie TEXT NOT NULL DEFAULT 'autre',
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
CHECK ((investissement_id IS NOT NULL) + (investissement_pe_id IS NOT NULL) = 1)
|
||||
)
|
||||
`;
|
||||
|
||||
const colDefs = db.prepare('PRAGMA table_info(frais_operations)').all();
|
||||
const colNames = colDefs.map(c => `"${c.name}"`).join(', ');
|
||||
const idxs = db.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='frais_operations' AND sql IS NOT NULL"
|
||||
).all();
|
||||
|
||||
db.exec('PRAGMA foreign_keys = OFF');
|
||||
db.exec(`DROP TABLE IF EXISTS "${tempName}"`);
|
||||
db.exec(fixedDdl);
|
||||
db.exec(`INSERT INTO "${tempName}" (${colNames}) SELECT ${colNames} FROM frais_operations`);
|
||||
for (const idx of idxs) db.exec(`DROP INDEX IF EXISTS "${idx.name}"`);
|
||||
db.exec('DROP TABLE frais_operations');
|
||||
// legacy_alter_table = ON : empêche la réécriture automatique des FK enfants
|
||||
db.exec('PRAGMA legacy_alter_table = ON');
|
||||
db.exec(`ALTER TABLE "${tempName}" RENAME TO frais_operations`);
|
||||
db.exec('PRAGMA legacy_alter_table = OFF');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_fraisop_inv ON frais_operations(investissement_id)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_fraisop_inv_pe ON frais_operations(investissement_pe_id)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_fraisop_plat ON frais_operations(plateforme_id)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_fraisop_remb ON frais_operations(remboursement_id)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_fraisop_date ON frais_operations(date_operation)');
|
||||
}
|
||||
}
|
||||
|
||||
// Historique des valorisations (relevés périodiques du fonds) — une ligne par
|
||||
// date de valorisation, plutôt qu'un champ unique écrasé à chaque mise à jour,
|
||||
// pour permettre un graphique d'évolution et un TVPI recalculable à toute date
|
||||
// passée (décision Olivier 17/09/26).
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS investissements_pe_valorisations (
|
||||
id INTEGER PRIMARY KEY,
|
||||
investissement_pe_id INTEGER NOT NULL REFERENCES investissements_pe(id) ON DELETE CASCADE,
|
||||
date_valorisation TEXT NOT NULL,
|
||||
valorisation_nette REAL NOT NULL,
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_invpeval_inv ON investissements_pe_valorisations(investissement_pe_id)');
|
||||
|
||||
// Lien optionnel remboursement → deal PE (17/09/26) : une distribution (retour
|
||||
// de capital, plus-value) perçue sur un deal PE est saisie comme un
|
||||
// remboursement bonus_plateforme/interets_plateforme, comme demandé par
|
||||
// Olivier ("comme un remboursement, page déjà construite") — ce lien
|
||||
// supplémentaire permet de rattacher cette distribution à un deal précis
|
||||
// (indispensable quand plusieurs deals partagent une même plateforme, ex.
|
||||
// Fundora) afin de calculer un TVPI par deal plutôt que par plateforme.
|
||||
// Nullable et sans effet sur les remboursements crowdlending existants.
|
||||
{
|
||||
const rembCols2 = db.prepare('PRAGMA table_info(remboursements)').all().map(c => c.name);
|
||||
if (!rembCols2.includes('investissement_pe_id')) {
|
||||
db.exec('ALTER TABLE remboursements ADD COLUMN investissement_pe_id INTEGER REFERENCES investissements_pe(id)');
|
||||
}
|
||||
}
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_remb_invpe ON remboursements(investissement_pe_id)');
|
||||
|
||||
export default db;
|
||||
Reference in new issue
Block a user