Les totaux du footer de /lignes suivent maintenant les filtres actifs

Remplace /lignes/api/totaux (LedgerStatsController::totauxAnnee(),
toujours non filtré) par une agrégation côté client des lignes renvoyées
par LedgerRowsController::index() (même endpoint que la fenêtre
glissante) avec annee=<année visible> + les filtres actifs. Reste une
requête serveur dédiée sur l'année entière (pas de LIMIT/range sur la
requête Entity), donc le total ne dépend jamais de ce qui est
effectivement chargé dans la fenêtre glissante à cet instant -- vérifié :
207 lignes non filtrées vs 13 avec le filtre "OVH", total du footer
identique au calcul indépendant dans les deux cas.

onFilterChanged() déclenche maintenant systématiquement
loadCurrentYearTotals() (pas seulement quand detectCurrentYear() détecte
un changement d'année) -- sinon changer un filtre en restant sur la même
année laissait le footer afficher l'ancien total non filtré.

totauxAnnee() et sa route sont supprimés (plus aucun appelant après ce
changement, vérifié par recherche).
This commit is contained in:
2026-09-08 15:25:18 +02:00
parent cb2a0fcdaa
commit 96bd9502d2
4 changed files with 59 additions and 90 deletions
@@ -50,13 +50,6 @@ figli_compta_ledger.link_entree:
node: node:
type: entity:node type: entity:node
figli_compta_ledger.api_totaux_annee:
path: '/lignes/api/totaux'
defaults:
_controller: '\Drupal\figli_compta_ledger\Controller\LedgerStatsController::totauxAnnee'
requirements:
_permission: 'access content'
figli_compta_ledger.api_annees: figli_compta_ledger.api_annees:
path: '/lignes/api/annees' path: '/lignes/api/annees'
defaults: defaults:
@@ -305,10 +305,44 @@
return json; return json;
} }
async function fetchYearTotals(annee) { // Sums the same fields LedgerRowsController::index() rows carry into
const res = await fetch('/lignes/api/totaux?annee=' + encodeURIComponent(annee), { headers: { Accept: 'application/json' } }); // the {montant_ht, cotisation, montant_ttc, ecart, par_compte} shape
if (!res.ok) throw new Error('/lignes/api/totaux a répondu ' + res.status); // the footer template expects.
return res.json(); function aggregateTotals(rows) {
let montantHt = 0;
let cotisation = 0;
let montantTtc = 0;
let ecart = 0;
const parCompte = {};
for (const r of rows) {
montantHt += r.montant_ht || 0;
cotisation += r.cotisation || 0;
montantTtc += r.montant_ttc || 0;
ecart += r.ecart || 0;
for (const [c, v] of Object.entries(r.parCompte)) {
parCompte[c] = (parCompte[c] || 0) + v;
}
}
const round = (v) => Math.round(v * 100) / 100;
return {
montant_ht: round(montantHt),
cotisation: round(cotisation),
montant_ttc: round(montantTtc),
ecart: round(ecart),
par_compte: Object.fromEntries(Object.entries(parCompte).map(([c, v]) => [c, round(v)])),
};
}
// Per-année footer totals, filtered the same way the table itself is --
// fetches every row for the year via the same server-side-filtered
// endpoint the table window uses (fetchFilteredLignes), then sums them
// client-side. Replaces the old /lignes/api/totaux (LedgerStatsController::
// totauxAnnee(), always unfiltered) now that the footer needs to
// reflect whatever's actually on screen, not the whole year regardless
// of the active filters.
async function fetchYearTotals(annee, filters) {
const rows = await fetchFilteredLignes({ annee }, filters);
return Object.assign({ annee }, aggregateTotals(rows));
} }
// URL hash (#compte=Maud,Bachir&type=versement,achat&annee=2023&ecarts=1&aller=2024) // URL hash (#compte=Maud,Bachir&type=versement,achat&annee=2023&ecarts=1&aller=2024)
@@ -1471,8 +1505,17 @@
// filter actually left too few rows to fill the viewport, wedging // filter actually left too few rows to fill the viewport, wedging
// every future filter change and scroll-triggered load right along // every future filter change and scroll-triggered load right along
// with it. // with it.
//
// loadCurrentYearTotals() is called unconditionally here (not just
// left to detectCurrentYear()'s own trigger): that one only
// re-fetches when the *visible year* changes, so a filter change
// while staying on the same year would otherwise leave the footer
// showing stale, pre-filter totals. Independent of the window
// reload above (different endpoint call, no shared state), so no
// need to chain it through the same queue.
onFilterChanged() { onFilterChanged() {
this._queueWindowOp(() => this.reloadWindow()).then(() => this.ensureScrollable()); this._queueWindowOp(() => this.reloadWindow()).then(() => this.ensureScrollable());
this.loadCurrentYearTotals();
this.syncHash(); this.syncHash();
}, },
// Keeps extending the window (both directions) as long as a filter // Keeps extending the window (both directions) as long as a filter
@@ -1591,7 +1634,7 @@
if (!this.currentYear) return; if (!this.currentYear) return;
this.currentYearLoading = true; this.currentYearLoading = true;
try { try {
this.currentYearTotals = await fetchYearTotals(this.currentYear); this.currentYearTotals = await fetchYearTotals(this.currentYear, this.currentFilters());
} catch (err) { } catch (err) {
this.currentYearTotals = null; this.currentYearTotals = null;
} finally { } finally {
@@ -60,9 +60,9 @@ class DashboardStatsController extends ControllerBase {
// share, pre-summed per (annee, compte, type) in SQL -- backs solde // share, pre-summed per (annee, compte, type) in SQL -- backs solde
// par compte, both all-time and per-year (each year's own total // par compte, both all-time and per-year (each year's own total
// already includes that year's ouverture line, so it *is* that // already includes that year's ouverture line, so it *is* that
// year's closing balance -- same logic as // year's closing balance -- same logic as home.js's
// LedgerStatsController::totauxAnnee()), and the per-compte type // aggregateTotals()/fetchYearTotals() use for the /lignes footer),
// breakdown used by /dashboard/compte. // and the per-compte type breakdown used by /dashboard/compte.
$compteQuery = $connection->select('node__field_date_ligne', 'd'); $compteQuery = $connection->select('node__field_date_ligne', 'd');
$compteQuery->innerJoin('node__field_type_ligne', 't2', 't2.entity_id = d.entity_id'); $compteQuery->innerJoin('node__field_type_ligne', 't2', 't2.entity_id = d.entity_id');
$compteQuery->innerJoin('node__field_repartition', 'r', 'r.entity_id = d.entity_id'); $compteQuery->innerJoin('node__field_repartition', 'r', 'r.entity_id = d.entity_id');
@@ -5,87 +5,20 @@ namespace Drupal\figli_compta_ledger\Controller;
use Drupal\Core\Controller\ControllerBase; use Drupal\Core\Controller\ControllerBase;
use Drupal\node\NodeInterface; use Drupal\node\NodeInterface;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
/** /**
* Small aggregate endpoints backing the /lignes sliding window: the * Small aggregate endpoints backing the /lignes sliding window: the
* row-level JSON:API fetch only ever covers a date range (see home.js), so * row-level fetch only ever covers a date range (see home.js), so the
* neither the totals footer nor the "Année" dropdown can be computed from * "Année" dropdown can't be computed from whatever's currently loaded --
* whatever's currently loaded -- they need their own always-accurate * it needs its own always-accurate query, decoupled from the row window.
* queries, decoupled from the row window. * The footer totals used to live here too (totauxAnnee(), unfiltered)
* until the footer needed to reflect the active toolbar filters -- it's
* now computed client-side in home.js from LedgerRowsController::index()
* rows instead, the same server-side-filtered endpoint the table itself
* uses.
*/ */
class LedgerStatsController extends ControllerBase { class LedgerStatsController extends ControllerBase {
/**
* GET /lignes/api/totaux?annee=2023 -- per-compte répartition sums (plus
* montant HT/TTC/écart totals) for every ligne_comptable dated that
* year, including ouverture lines: the footer is meant to read as the
* actual account balance (solde) for the year, i.e. the same "clôture
* calculée" (ouverture + every movement dated within the year) that
* reconciliationOuverture() compares the *next* year's ouverture
* against. Excluding ouverture here would make this a net-movement
* figure instead, which never matches what the reconciliation badge's
* tooltip cites for the same year.
*/
public function totauxAnnee(Request $request) {
$annee = $request->query->get('annee');
if (!$annee || !preg_match('/^\d{4}$/', $annee)) {
return new JsonResponse(['error' => 'Paramètre "annee" invalide.'], 400);
}
$storage = $this->entityTypeManager()->getStorage('node');
$nids = $storage->getQuery()
->accessCheck(TRUE)
->condition('type', 'ligne_comptable')
->condition('field_date_ligne', $annee . '-01-01', '>=')
->condition('field_date_ligne', ((int) $annee + 1) . '-01-01', '<')
->execute();
$par_compte = [];
$montant_ht = 0.0;
$cotisation = 0.0;
$montant_ttc = 0.0;
$ecart = 0.0;
foreach ($storage->loadMultiple($nids) as $node) {
$ht = $node->hasField('field_montant_ht') && !$node->get('field_montant_ht')->isEmpty()
? (float) $node->get('field_montant_ht')->value : 0.0;
$ttc = $node->hasField('field_montant_ttc') && !$node->get('field_montant_ttc')->isEmpty()
? (float) $node->get('field_montant_ttc')->value : 0.0;
$montant_ht += $ht;
$montant_ttc += $ttc;
// Blank (never 0) for most lines -- only "Entrée client" lines
// with the cotisation checkbox on ever have this field set (see
// figli_compta_ledger_node_presave()) -- but summing a blank
// value as 0 here is exactly right for a total.
if ($node->hasField('field_cotisation_urssaf') && !$node->get('field_cotisation_urssaf')->isEmpty()) {
$cotisation += (float) $node->get('field_cotisation_urssaf')->value;
}
$somme = 0.0;
foreach ($node->get('field_repartition')->referencedEntities() as $paragraph) {
if (!$paragraph->hasField('field_montant') || $paragraph->get('field_montant')->isEmpty()) {
continue;
}
$montant = (float) $paragraph->get('field_montant')->value;
$compte = $paragraph->get('field_compte')->entity ? $paragraph->get('field_compte')->entity->label() : NULL;
if ($compte) {
$par_compte[$compte] = ($par_compte[$compte] ?? 0) + $montant;
}
$somme += $montant;
}
$ecart += round($ht - $somme, 2);
}
return new JsonResponse([
'annee' => $annee,
'montant_ht' => round($montant_ht, 2),
'cotisation' => round($cotisation, 2),
'montant_ttc' => round($montant_ttc, 2),
'ecart' => round($ecart, 2),
'par_compte' => array_map(fn ($v) => round($v, 2), $par_compte),
]);
}
/** /**
* GET /lignes/api/annees -- distinct years, most recent first, with at * GET /lignes/api/annees -- distinct years, most recent first, with at
* least 5 lines. The threshold exists specifically to keep the handful * least 5 lines. The threshold exists specifically to keep the handful