Resolve the full entrée/versement group regardless of the sliding window

filterEntreeGroup, reconciliationByEntree and versementStatus only ever
searched `rows`, the currently loaded date-range slice -- a versement
linked to entrées from other years (confirmed live: one node had 3
linked entrées spanning 2023/2025/2025) silently only showed whatever
happened to be in the loaded window, both in the drill-down modal and in
the reste-à-verser/sur-versé math.

Added LedgerStatsController::groupeEntree() (GET /lignes/api/groupe/
{node}), a real DB query following field_entree_liee in both directions
(a node's own targets, and any node referencing it) -- something the
client can't discover from a partial window. toggleEntreeFilter() now
fetches the complete group up front and merges whatever isn't already
loaded into groupExtraRows; every affected computed reads the combined
pool via a new allKnownRows.

Also stopped versementStatus from defaulting to a false "ok" when a
linked entrée's reconciliation can't be resolved yet -- it now reports a
distinct "inconnu" (À vérifier) status instead of silently assuming
everything's settled.
This commit is contained in:
2026-09-05 19:03:41 +02:00
parent 619a2ab70c
commit b73a980282
4 changed files with 177 additions and 19 deletions
@@ -55,6 +55,17 @@ figli_compta_ledger.api_reconciliation_ouverture:
requirements:
_permission: 'access content'
figli_compta_ledger.api_groupe_entree:
path: '/lignes/api/groupe/{node}'
defaults:
_controller: '\Drupal\figli_compta_ledger\Controller\LedgerStatsController::groupeEntree'
requirements:
_permission: 'access content'
options:
parameters:
node:
type: entity:node
figli_compta_ledger.api_dashboard_stats:
path: '/dashboard/api/stats'
defaults:
+117 -18
View File
@@ -123,6 +123,31 @@
return { data: dedup, includedMap };
}
// GET /lignes/api/groupe/{nid} -- the full transitive closure of node
// ids connected via field_entree_liee (both directions), resolved
// server-side since the sliding window can't discover it client-side
// (see LedgerStatsController::groupeEntree()).
async function fetchGroupeIds(nid) {
const res = await fetch('/lignes/api/groupe/' + nid, { headers: { Accept: 'application/json' } });
if (!res.ok) throw new Error('/lignes/api/groupe a répondu ' + res.status);
const json = await res.json();
return json.nids || [];
}
// Fetches full ligne data (same includes as the sliding window) for a
// specific set of node ids, regardless of date -- used to fill in
// whichever entrée/sortie nodes fetchGroupeIds() found that aren't in
// the currently loaded window.
async function fetchLignesByNids(nids) {
if (!nids.length) return { data: [], includedMap: new Map() };
const params = new URLSearchParams();
params.set('include', 'field_repartition,field_repartition.field_compte,field_client,field_entree_liee');
params.set('filter[parNid][condition][path]', 'drupal_internal__nid');
params.set('filter[parNid][condition][operator]', 'IN');
nids.forEach((nid) => params.append('filter[parNid][condition][value][]', nid));
return fetchLignes(API_BASE + '?' + params.toString());
}
async function fetchClientNames() {
// page[limit]=200 is silently clamped to core's hard cap of 50 by
// JSON:API (Query\OffsetPage::SIZE_MAX) -- with 106 client terms,
@@ -290,6 +315,14 @@
onlyErrors: false,
hoverCol: null,
filterEntreeId: null,
// Rows fetched to fill in entrées/sorties that /lignes/api/groupe
// found but the sliding window doesn't have loaded (a versement
// can link to an entrée from any prior year) -- see
// allKnownRows()/toggleEntreeFilter(). Kept around (not cleared
// on close) as a soft cache: reopening the same or an overlapping
// group doesn't need to re-fetch.
groupExtraRows: [],
groupLoading: false,
// Which row's type badge is currently showing its inline <select>
// instead of the badge -- only one at a time.
editingTypeId: null,
@@ -323,11 +356,23 @@
errorCount() {
return this.rows.filter((r) => r.hasError).length;
},
// Every row known locally that could feed the reconciliation/
// drill-down computeds below: the loaded sliding window, plus
// whatever groupExtraRows filled in (see toggleEntreeFilter()) --
// deduped by id in case the same node ends up in both. A versement
// can link to (or be linked from) an entrée dated years earlier or
// later, well outside `rows`; groupExtraRows is how that gap gets
// closed once the full group has been resolved server-side.
allKnownRows() {
if (!this.groupExtraRows.length) return this.rows;
const seen = new Set(this.rows.map((r) => r.id));
return [...this.rows, ...this.groupExtraRows.filter((r) => !seen.has(r.id))];
},
// A sortie linked to several entrées (one payment covering several
// invoices) appears under each of them here.
sortiesByEntree() {
const map = new Map();
for (const r of this.rows) {
for (const r of this.allKnownRows) {
for (const entreeId of r.entreeLieeIds) {
if (!map.has(entreeId)) map.set(entreeId, []);
map.get(entreeId).push(r);
@@ -342,9 +387,11 @@
// positive means still owed ("reste à verser"); negative means more
// was paid out than the entrée allocated ("sur-versé", worth a
// closer look). Precomputed once for all entrée rows rather than
// recomputed per template read. Limited to the currently loaded
// window -- a sortie linked to an entrée outside it won't be
// counted (accepted trade-off of the sliding window).
// recomputed per template read. Only sees whatever sortiesByEntree
// knows about -- accurate for an entrée once its full group has
// been resolved (see allKnownRows()/toggleEntreeFilter()), but may
// under-count a sortie linked to it that's neither in the loaded
// window nor already fetched into groupExtraRows.
//
// A sortie linked to several entrées at once (one payment covering
// several invoices) has no record of how much of it applies to
@@ -355,7 +402,7 @@
// the same money).
reconciliationByEntree() {
const map = new Map();
for (const entreeRow of this.rows) {
for (const entreeRow of this.allKnownRows) {
if (entreeRow.type !== 'entree') continue;
const linked = this.sortiesByEntree.get(entreeRow.id) || [];
const versementsParCompte = {};
@@ -398,13 +445,19 @@
// so drilling into one entrée should also surface every *other*
// entrée it shares a sortie with, and that entrée's own sorties in
// turn -- not just the originally-clicked entrée's direct links.
// Runs over allKnownRows (loaded window + groupExtraRows), not just
// `rows` -- toggleEntreeFilter() fetches the complete group up
// front via /lignes/api/groupe/{node}, so by the time this
// re-evaluates, every member should already be present regardless
// of its date.
filterEntreeGroup() {
if (!this.filterEntreeId) return null;
const pool = this.allKnownRows;
const entreeIds = new Set([this.filterEntreeId]);
let grown = true;
while (grown) {
grown = false;
for (const r of this.rows) {
for (const r of pool) {
if (r.type === 'entree' || !r.entreeLieeIds.some((id) => entreeIds.has(id))) continue;
for (const id of r.entreeLieeIds) {
if (!entreeIds.has(id)) {
@@ -414,8 +467,8 @@
}
}
}
const entrees = this.rows.filter((r) => r.type === 'entree' && entreeIds.has(r.id));
const sorties = this.rows.filter((r) => r.type !== 'entree' && r.entreeLieeIds.some((id) => entreeIds.has(id)));
const entrees = pool.filter((r) => r.type === 'entree' && entreeIds.has(r.id));
const sorties = pool.filter((r) => r.type !== 'entree' && r.entreeLieeIds.some((id) => entreeIds.has(id)));
return [...entrees, ...sorties];
},
filteredRows() {
@@ -525,9 +578,15 @@
// equal-split note), each entrée's residual for these compte(s)
// counts separately -- they're independent invoices, each with
// its own outstanding amount.
// - linked and settled (as far as the currently loaded window can
// tell -- a linked entrée outside it is silently skipped, same
// accepted trade-off as reconciliationByEntree itself)
// - linked and settled
// - inconnu: at least one linked entrée's reconciliation couldn't
// be resolved (not yet fetched into groupExtraRows -- see
// allKnownRows()/loadEntreeGroup()). Deliberately distinct from
// "ok": defaulting an unresolved entrée to "settled" would show
// a false all-clear for a versement that's actually fine, or
// one that owes money, purely because its linked entrée hasn't
// been fetched yet -- opening the badge resolves it and flips
// the status to whatever it actually is.
versementStatus(item) {
if (item.type !== 'versement') return null;
if (!item.entreeLieeIds.length) {
@@ -535,9 +594,13 @@
}
let resteAVerser = 0;
let surVerse = 0;
let unresolved = false;
for (const entreeId of item.entreeLieeIds) {
const recon = this.reconciliationByEntree.get(entreeId);
if (!recon) continue;
if (!recon) {
unresolved = true;
continue;
}
for (const c of Object.keys(item.parCompte)) {
const residual = recon.parCompteResidual[c] || 0;
if (residual > 0.01) resteAVerser += residual;
@@ -550,6 +613,9 @@
if (surVerse > 0.01) {
return { kind: 'sur-verse', detail: 'Sur-versé (comptes de cette ligne) : ' + this.formatEur(Math.round(surVerse * 100) / 100) };
}
if (unresolved) {
return { kind: 'inconnu', detail: 'Entrée(s) liée(s) pas encore vérifiée(s) -- cliquer pour vérifier.' };
}
const n = item.entreeLieeIds.length;
return { kind: 'ok', detail: 'Lié à ' + n + ' entrée' + (n > 1 ? 's' : '') + ' client' + (n > 1 ? 's' : '') + '.' };
},
@@ -557,16 +623,17 @@
if (kind === 'non-liee') return 'Non liée';
if (kind === 'reste') return 'Reste à verser';
if (kind === 'sur-verse') return 'Sur-versé';
if (kind === 'inconnu') return 'À vérifier';
return 'Lié';
},
// Only non-liee/sur-versé read as a hard anomaly (red); reste is
// its own softer amber; ok gets neither, falling back to the
// badge's default green -- same "all clear" green the entrée side
// already uses for a fully-settled "N sorties liées".
// Only non-liee/sur-versé read as a hard anomaly (red); reste and
// inconnu are their own softer amber; ok gets neither, falling back
// to the badge's default green -- same "all clear" green the
// entrée side already uses for a fully-settled "N sorties liées".
versementStatusClasses(kind) {
return {
'is-anomalie': kind === 'non-liee' || kind === 'sur-verse',
'is-reste': kind === 'reste',
'is-reste': kind === 'reste' || kind === 'inconnu',
};
},
// jj/mm/aa -- shorter than the API's ISO yyyy-mm-dd, saves column
@@ -623,11 +690,43 @@
// main table, so opening/closing it never touches the main table's
// scroll position.
toggleEntreeFilter(id) {
this.filterEntreeId = this.filterEntreeId === id ? null : id;
if (this.filterEntreeId === id) {
this.closeDrilldown();
return;
}
this.filterEntreeId = id;
this.loadEntreeGroup(id);
},
closeDrilldown() {
this.filterEntreeId = null;
},
// Resolves the full transitive-closure group for the given row id
// via /lignes/api/groupe/{nid} (the sliding window can't discover
// it client-side -- see LedgerStatsController::groupeEntree()), and
// fetches full ligne data for whatever member isn't already known
// locally, merging it into groupExtraRows. filterEntreeGroup/
// reconciliationByEntree re-evaluate automatically once that
// lands, since they read allKnownRows (rows + groupExtraRows).
async loadEntreeGroup(id) {
const row = this.allKnownRows.find((r) => r.id === id);
if (!row) return;
this.groupLoading = true;
try {
const nids = await fetchGroupeIds(row.nid);
const known = new Set(this.allKnownRows.map((r) => r.nid));
const missingNids = nids.filter((nid) => !known.has(nid));
if (missingNids.length) {
const { data, includedMap } = await fetchLignesByNids(missingNids);
const newRows = buildRows(data, includedMap);
const existingIds = new Set(this.groupExtraRows.map((r) => r.id));
this.groupExtraRows = [...this.groupExtraRows, ...newRows.filter((r) => !existingIds.has(r.id))];
}
} catch (err) {
this.typeUpdateError = err.message;
} finally {
this.groupLoading = false;
}
},
startEditType(item) {
this.typeUpdateError = null;
this.editingTypeId = item.id;
@@ -3,6 +3,7 @@
namespace Drupal\figli_compta_ledger\Controller;
use Drupal\Core\Controller\ControllerBase;
use Drupal\node\NodeInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
@@ -176,4 +177,51 @@ class LedgerStatsController extends ControllerBase {
return new JsonResponse($result);
}
/**
* GET /lignes/api/groupe/{node} -- the full transitive closure of
* entrée/sortie nodes connected via field_entree_liee, following links
* in *both* directions (this node's own field_entree_liee targets, and
* any other node that references it). The client's sliding window only
* ever holds a date-range slice of `rows` -- a versement can link to
* (or be linked from) an entrée dated years earlier or later, so
* finding the complete group from whatever happens to be loaded is not
* possible client-side; the reverse direction in particular ("which
* sorties reference this entrée") needs a real query, not a scan of
* already-loaded rows. Returns node ids only (not full ligne data) --
* the client re-fetches those specific ids via JSON:API, reusing its
* existing row-building/reconciliation code for the result.
*/
public function groupeEntree(NodeInterface $node) {
$storage = $this->entityTypeManager()->getStorage('node');
$ids = [(int) $node->id() => TRUE];
$queue = [(int) $node->id()];
while ($queue) {
$current_id = array_shift($queue);
$current = $storage->load($current_id);
if (!$current || !$current->hasField('field_entree_liee')) {
continue;
}
foreach ($current->get('field_entree_liee')->referencedEntities() as $entree) {
$eid = (int) $entree->id();
if (!isset($ids[$eid])) {
$ids[$eid] = TRUE;
$queue[] = $eid;
}
}
$referencing = $storage->getQuery()
->accessCheck(TRUE)
->condition('type', 'ligne_comptable')
->condition('field_entree_liee', $current_id)
->execute();
foreach ($referencing as $nid) {
$nid = (int) $nid;
if (!isset($ids[$nid])) {
$ids[$nid] = TRUE;
$queue[] = $nid;
}
}
}
return new JsonResponse(['nids' => array_keys($ids)]);
}
}
@@ -196,7 +196,7 @@
<div v-if="filterEntreeId" class="figli-modal-backdrop" @click.self="closeDrilldown">
<div class="figli-modal">
<div class="figli-modal-header">
<h3>Entrée + sorties liées</h3>
<h3>Entrée + sorties liées <span v-if="groupLoading" class="figli-note">vérification…</span></h3>
<button type="button" class="figli-modal-close" @click="closeDrilldown">✕</button>
</div>
<div class="figli-modal-body">