Amélioration du traitement des mouvements
This commit is contained in:
1 parent
9540221577
commit
6c96e023a9
8 files changed
+348
-82
No files matched your search
@@ -71,14 +71,25 @@ export default function DistributionChart({ rows }) {
|
||||
const svgRef = useRef(null);
|
||||
const wrapRef = useRef(null);
|
||||
|
||||
/* Solde net par plateforme */
|
||||
/* Solde net par plateforme.
|
||||
Deux usages du composant :
|
||||
- lignes avec `type` ('depot'/'retrait', page Dépôts/Retraits) : si le
|
||||
jeu de lignes mélange dépôts ET retraits (aucun filtre de type actif),
|
||||
on calcule le NET (dépôts − retraits) par plateforme, pas la somme
|
||||
brute — sinon un retrait s'additionnait au lieu de se soustraire.
|
||||
- lignes sans `type` (ex. capital restant dû sur Investissements.jsx) :
|
||||
comportement inchangé, simple somme de `montant`. */
|
||||
const { data, allRetraits } = useMemo(() => {
|
||||
if (!rows?.length) return { data: [], allRetraits: false };
|
||||
const allRetraits = rows.every(r => r.type === 'retrait');
|
||||
const hasTypes = rows.some(r => r.type === 'depot' || r.type === 'retrait');
|
||||
const allDepots = hasTypes && rows.every(r => r.type === 'depot');
|
||||
const allRetraits = hasTypes && rows.every(r => r.type === 'retrait');
|
||||
const mixedTypes = hasTypes && !allDepots && !allRetraits;
|
||||
const byPlat = {};
|
||||
for (const r of rows) {
|
||||
const key = r.plateforme_nom || `#${r.plateforme_id}`;
|
||||
byPlat[key] = (byPlat[key] || 0) + r.montant;
|
||||
const delta = mixedTypes ? (r.type === 'depot' ? r.montant : -r.montant) : r.montant;
|
||||
byPlat[key] = (byPlat[key] || 0) + delta;
|
||||
}
|
||||
const data = Object.entries(byPlat)
|
||||
.filter(([, v]) => v > 0)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -38,8 +38,13 @@ function fmtTodayFull() {
|
||||
}
|
||||
|
||||
/* ── Composant ──────────────────────────────────────────────── */
|
||||
export default function SoldeChart({ rows }) {
|
||||
const [range, setRange] = useState('TOUT');
|
||||
export default function SoldeChart({ rows, range: rangeProp, onRangeChange }) {
|
||||
// Contrôlé si le parent fournit `range`/`onRangeChange` (pour synchroniser
|
||||
// d'autres composants, ex. DistributionChart, sur la même plage) — sinon
|
||||
// repli sur un state interne (usage autonome).
|
||||
const [rangeState, setRangeState] = useState('TOUT');
|
||||
const range = rangeProp !== undefined ? rangeProp : rangeState;
|
||||
const setRange = (r) => { if (onRangeChange) onRangeChange(r); else setRangeState(r); };
|
||||
const [hover, setHover] = useState(null); // { x, y, value, date }
|
||||
const svgRef = useRef(null);
|
||||
const wrapRef = useRef(null);
|
||||
|
||||
Reference in new issue
Block a user