Amélioration du traitement des mouvements

This commit is contained in:
ocroguennec committed 2026-08-21 09:20:48 +02:00
1 parent 9540221577
commit 6c96e023a9
8 files changed
+348 -82

No files matched your search

+170 -2
View File
@@ -41,13 +41,179 @@ export const MOUV_TYPE_LABELS = {
interets_plateforme: 'Intérêts plateforme',
};
/* ── Tri des mouvements pour une même date ───────────────────────────
1) Toutes les lignes d'une même plateforme, pour une même date, sont regroupées
ensemble (ordre alphabétique des plateformes entre elles — décidé avec Olivier
le 2026-08-21, cf. incident du 20/08 où Indemo et La première brique
s'entrelaçaient).
2) À l'intérieur d'un bloc plateforme+date, TOUT est ordonné du plus récent au plus
ancien, à tous les niveaux — familles, chaînes distinctes, ET position au sein
d'une même chaîne (cohérent avec le tri global le plus récent d'abord) :
1. Correction de solde
2. Retrait (manuel)
3. Souscription / Réinvestissement — argent qui sort pour investir
└─ Cashback "prime à la souscription" du même jour sur le même
investissement (affiché AVANT sa souscription : chronologiquement
postérieur), si ce remboursement n'a par ailleurs aucune ligne
crédit propre (cf. ci-dessous)
4. Dépôt (manuel) — alimente le compte
5. Remboursement / Intérêts plateforme / Bonus — argent qui entre
└─ Retrait AUTO (sweep compte_courant) qui reverse ce remboursement
└─ Prélèvement CSG
└─ Prélèvement IR
└─ Prélèvement impôts à l'étranger
└─ Cashback (si rattaché à ce remboursement)
└─ (le remboursement lui-même, ligne la plus ancienne de sa chaîne,
apparaît donc en dernier — ses prélèvements/son retrait AUTO sont
chronologiquement postérieurs)
Plusieurs chaînes de même famille le même jour (ex. deux souscriptions) sont
ordonnées par id décroissant (la plus récemment créée en premier). L'ordre
interne d'une chaîne (constante MOUV_REMB_CHAIN_POS) reste défini "parent=0,
enfants=1,2,3…" mais est PARCOURU EN DÉCROISSANT par le comparateur — inverser
MOUV_REMB_CHAIN_POS lui-même serait une erreur, seul le sens du tri doit changer.
Correction du 2026-08-21 (2e retour d'Olivier) : la 1ère version de cette règle
laissait le sous-ordre d'une chaîne "parent puis enfants" non inversé, ce qui
affichait "remboursement, puis ses prélèvements" — incohérent avec le reste de
la journée en "plus récent d'abord".
3) DÉPÔT ENTRE SOUSCRIPTION ET REMBOURSEMENT (et non plus toujours le plus ancien) :
cas typique signalé par Olivier le 2026-08-21 sur BienPrêter/17-08 — un
remboursement (Tradimex) déclenche un dépôt pour "relancer des investissements",
qui finance ensuite 2 nouvelles souscriptions le même jour. Chronologiquement :
remboursement → dépôt → souscriptions. Il n'existe AUCUN lien en base entre un
dépôt manuel et le remboursement/les souscriptions qu'il finance (contrairement
au retrait AUTO ou au cashback de souscription, qui ont un `remboursement_id`/
`investissement_id` explicite) — impossible donc de rattacher précisément CE
dépôt à CE remboursement. Le compromis retenu est une RÈGLE GÉNÉRALE (pas un
chaînage par ligne) : un dépôt manuel est toujours supposé plus récent qu'un
remboursement de la même plateforme/journée (donc affiché au-dessus de sa
chaîne), mais plus ancien qu'une souscription de la même plateforme/journée
(donc affiché en dessous). C'est une approximation assumée : si un dépôt sans
lien avec un remboursement du jour se trouve être réellement antérieur à ce
remboursement, l'affichage sera dans le mauvais sens — aucune donnée ne permet
de trancher. Règle validée avec Olivier (retour direct sur capture, pas
d'AskUserQuestion cette fois — cas jugé suffisamment clair). ── */
const MOUV_FAMILY_RANK = {
remboursement: 1, interets_plateforme: 1, bonus: 1, cashback: 1,
prelevement_local: 1, prelevement_ir: 1, prelevement_csg: 1,
depot: 2,
souscription: 3,
retrait: 4,
correction: 5,
};
// Position au sein d'une chaîne "remboursement" (lignes partageant le même _rembId)
const MOUV_REMB_CHAIN_POS = {
remboursement: 0, interets_plateforme: 0, bonus: 0,
cashback: 1, prelevement_local: 2, prelevement_ir: 3, prelevement_csg: 4,
};
export function sortMouvements(list) {
// _rembId des remboursements qui ont une vraie ligne crédit (remboursement / intérêts
// plateforme) — sert à NE PAS rattacher leur cashback à une souscription du même jour :
// un cashback qui accompagne un vrai remboursement reste dans la chaîne de ce remboursement.
const rembHasCreditLine = new Set(
list.filter(r => r.type === 'remboursement' || r.type === 'interets_plateforme').map(r => r._rembId)
);
// Souscriptions/réinvestissements par (investissement, date) — pour rattacher un cashback
// "prime à la souscription" (remboursement sans ligne crédit propre, même investissement,
// même date) à la souscription qu'il accompagne.
const subByInvAndDate = new Map(
list.filter(r => r.type === 'souscription').map(r => [`${r._invId}|${r.date_operation}`, r])
);
const enriched = list.map(row => {
// Retrait AUTO (sweep compte_courant) : reste collé juste après le remboursement qui le déclenche.
if (row.type === 'retrait' && row.source === 'auto_remboursement' && row.remboursement_id != null) {
return { row, family: MOUV_FAMILY_RANK.remboursement, anchor: row.remboursement_id, pos: 5 };
}
// Cashback "prime à la souscription" : pas de ligne crédit propre + souscription du même
// jour sur le même investissement → rattaché à cette souscription plutôt qu'isolé.
if (row.type === 'cashback' && !rembHasCreditLine.has(row._rembId) && row._invId != null) {
const sub = subByInvAndDate.get(`${row._invId}|${row.date_operation}`);
if (sub) return { row, family: MOUV_FAMILY_RANK.souscription, anchor: sub._invId, pos: 1 };
}
// Lignes d'un remboursement (parent + cashback resté attaché + prélèvements)
if (row._rembId != null) {
return {
row,
family: MOUV_FAMILY_RANK[row.type] ?? MOUV_FAMILY_RANK.remboursement,
anchor: row._rembId,
pos: MOUV_REMB_CHAIN_POS[row.type] ?? 0,
};
}
// Souscription / réinvestissement (parent de sa propre chaîne)
if (row.type === 'souscription') {
return { row, family: MOUV_FAMILY_RANK.souscription, anchor: row._invId, pos: 0 };
}
// Correction de solde
if (row.type === 'correction') {
return { row, family: MOUV_FAMILY_RANK.correction, anchor: row._correctionId, pos: 0 };
}
// Dépôt / retrait manuel (hors sweep AUTO déjà traité plus haut)
return { row, family: MOUV_FAMILY_RANK[row.type] ?? MOUV_FAMILY_RANK.retrait, anchor: row.id, pos: 0 };
});
return enriched
.sort((a, b) =>
b.row.date_operation.localeCompare(a.row.date_operation) || // date : plus récent d'abord
(a.row.plateforme_nom || '').localeCompare(b.row.plateforme_nom || '', 'fr') || // plateforme : ordre alphabétique
b.family - a.family || // famille : plus récent de la journée d'abord
(Number(b.anchor) - Number(a.anchor)) || // chaînes distinctes : id décroissant (plus récente d'abord)
b.pos - a.pos || // au sein d'une chaîne : le plus récent (enfant) d'abord
String(b.row.id).localeCompare(String(a.row.id)) // filet de sécurité
)
.map(e => e.row);
}
/* ── Solde indicatif du porte-monnaie, ligne par ligne ────────────────────────────
Calcule, pour CHAQUE mouvement d'une plateforme, le solde du porte-monnaie de cette
plateforme immédiatement après ce mouvement — à titre indicatif (aide à repérer des
anomalies visuellement), PAS une source de vérité chiffrée : c'est une somme cumulée
de signedMontant() dans l'ordre chronologique probable (cf. sortMouvements ci-dessus,
lui-même basé sur des règles de départage à date égale qui restent des approximations
en l'absence d'horodatage précis dans les données).
IMPORTANT : `list` DOIT être la liste COMPLÈTE et NON FILTRÉE (aucun filtre
année/mois/type) des mouvements du scope investisseur courant — sinon le cumul
démarrerait arbitrairement au milieu de l'historique et le solde n'aurait aucun sens.
Le filtre plateforme n'a en revanche pas d'importance : le calcul est de toute façon
fait indépendamment pour chaque plateforme.
Ne touche PAS aux formules officielles du solde porte-monnaie (kpiSoldePortefeuille /
computeSoldePortefeuille / walletMap, cf. [[project_solde_portefeuille]] et
[[project_mouvements_tab_wallet_events]]) : ce solde-ci est un indicateur d'affichage
indépendant, recalculé à partir des mêmes lignes déjà utilisées pour l'affichage et
l'export — jamais l'inverse. Retourne une Map id de ligne → solde après cette ligne.
Décidé avec Olivier le 2026-08-21. ── */
export function computeSoldeIndicatifMap(list) {
const byPlateforme = new Map();
for (const row of list) {
const key = row.plateforme_id ?? row.plateforme_nom ?? '?';
if (!byPlateforme.has(key)) byPlateforme.set(key, []);
byPlateforme.get(key).push(row);
}
const soldeById = new Map();
for (const rows of byPlateforme.values()) {
// sortMouvements() trie du plus récent au plus ancien ; on inverse pour cumuler
// dans l'ordre chronologique probable (du plus ancien au plus récent).
const chrono = sortMouvements(rows).slice().reverse();
let running = 0;
for (const row of chrono) {
running += signedMontant(row);
soldeById.set(row.id, running);
}
}
return soldeById;
}
export function mouvToCSV(rows) {
const BOM = ''; const sep = ';';
const q = v => `"${String(v ?? '').replace(/"/g, '""')}"`;
const headers = ['Date','Plateforme','Type','Projet','Montant (€)','Libellé','Référence','Source'];
const headers = ['Date','Plateforme','Type','Projet','Montant (€)','Solde porte-monnaie indicatif (€)','Libellé','Référence','Source'];
const data = rows.map(r => [
r.date_operation, r.plateforme_nom||'', MOUV_TYPE_LABELS[r.type] || r.type, r._projet||'',
String(signedMontant(r)).replace('.',','), r.libelle||'', r.reference||'', r.source||'',
String(signedMontant(r)).replace('.',','),
r._soldeIndicatif != null ? String(r._soldeIndicatif).replace('.',',') : '',
r.libelle||'', r.reference||'', r.source||'',
]);
return BOM + [headers,...data].map(r => r.map(q).join(sep)).join('\r\n');
}
@@ -60,6 +226,7 @@ export function mouvToXLS(rows, multiDetenteur) {
'Type': MOUV_TYPE_LABELS[r.type] || r.type,
'Projet': r._projet || '',
'Montant (€)': signedMontant(r),
'Solde porte-monnaie indicatif (€)': r._soldeIndicatif ?? '',
}));
const ws = XLSX.utils.json_to_sheet(data);
const wb = XLSX.utils.book_new();
@@ -74,6 +241,7 @@ export function mouvToJSON(rows) {
type: r.type,
projet: r._projet || null,
montant: signedMontant(r),
solde_indicatif: r._soldeIndicatif ?? null,
libelle: r.libelle || null,
reference: r.reference || null,
source: r.source || null,