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:
@@ -55,6 +55,17 @@ figli_compta_ledger.api_reconciliation_ouverture:
|
|||||||
requirements:
|
requirements:
|
||||||
_permission: 'access content'
|
_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:
|
figli_compta_ledger.api_dashboard_stats:
|
||||||
path: '/dashboard/api/stats'
|
path: '/dashboard/api/stats'
|
||||||
defaults:
|
defaults:
|
||||||
|
|||||||
@@ -123,6 +123,31 @@
|
|||||||
return { data: dedup, includedMap };
|
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() {
|
async function fetchClientNames() {
|
||||||
// page[limit]=200 is silently clamped to core's hard cap of 50 by
|
// page[limit]=200 is silently clamped to core's hard cap of 50 by
|
||||||
// JSON:API (Query\OffsetPage::SIZE_MAX) -- with 106 client terms,
|
// JSON:API (Query\OffsetPage::SIZE_MAX) -- with 106 client terms,
|
||||||
@@ -290,6 +315,14 @@
|
|||||||
onlyErrors: false,
|
onlyErrors: false,
|
||||||
hoverCol: null,
|
hoverCol: null,
|
||||||
filterEntreeId: 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>
|
// Which row's type badge is currently showing its inline <select>
|
||||||
// instead of the badge -- only one at a time.
|
// instead of the badge -- only one at a time.
|
||||||
editingTypeId: null,
|
editingTypeId: null,
|
||||||
@@ -323,11 +356,23 @@
|
|||||||
errorCount() {
|
errorCount() {
|
||||||
return this.rows.filter((r) => r.hasError).length;
|
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
|
// A sortie linked to several entrées (one payment covering several
|
||||||
// invoices) appears under each of them here.
|
// invoices) appears under each of them here.
|
||||||
sortiesByEntree() {
|
sortiesByEntree() {
|
||||||
const map = new Map();
|
const map = new Map();
|
||||||
for (const r of this.rows) {
|
for (const r of this.allKnownRows) {
|
||||||
for (const entreeId of r.entreeLieeIds) {
|
for (const entreeId of r.entreeLieeIds) {
|
||||||
if (!map.has(entreeId)) map.set(entreeId, []);
|
if (!map.has(entreeId)) map.set(entreeId, []);
|
||||||
map.get(entreeId).push(r);
|
map.get(entreeId).push(r);
|
||||||
@@ -342,9 +387,11 @@
|
|||||||
// positive means still owed ("reste à verser"); negative means more
|
// positive means still owed ("reste à verser"); negative means more
|
||||||
// was paid out than the entrée allocated ("sur-versé", worth a
|
// was paid out than the entrée allocated ("sur-versé", worth a
|
||||||
// closer look). Precomputed once for all entrée rows rather than
|
// closer look). Precomputed once for all entrée rows rather than
|
||||||
// recomputed per template read. Limited to the currently loaded
|
// recomputed per template read. Only sees whatever sortiesByEntree
|
||||||
// window -- a sortie linked to an entrée outside it won't be
|
// knows about -- accurate for an entrée once its full group has
|
||||||
// counted (accepted trade-off of the sliding window).
|
// 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
|
// A sortie linked to several entrées at once (one payment covering
|
||||||
// several invoices) has no record of how much of it applies to
|
// several invoices) has no record of how much of it applies to
|
||||||
@@ -355,7 +402,7 @@
|
|||||||
// the same money).
|
// the same money).
|
||||||
reconciliationByEntree() {
|
reconciliationByEntree() {
|
||||||
const map = new Map();
|
const map = new Map();
|
||||||
for (const entreeRow of this.rows) {
|
for (const entreeRow of this.allKnownRows) {
|
||||||
if (entreeRow.type !== 'entree') continue;
|
if (entreeRow.type !== 'entree') continue;
|
||||||
const linked = this.sortiesByEntree.get(entreeRow.id) || [];
|
const linked = this.sortiesByEntree.get(entreeRow.id) || [];
|
||||||
const versementsParCompte = {};
|
const versementsParCompte = {};
|
||||||
@@ -398,13 +445,19 @@
|
|||||||
// so drilling into one entrée should also surface every *other*
|
// 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
|
// 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.
|
// 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() {
|
filterEntreeGroup() {
|
||||||
if (!this.filterEntreeId) return null;
|
if (!this.filterEntreeId) return null;
|
||||||
|
const pool = this.allKnownRows;
|
||||||
const entreeIds = new Set([this.filterEntreeId]);
|
const entreeIds = new Set([this.filterEntreeId]);
|
||||||
let grown = true;
|
let grown = true;
|
||||||
while (grown) {
|
while (grown) {
|
||||||
grown = false;
|
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;
|
if (r.type === 'entree' || !r.entreeLieeIds.some((id) => entreeIds.has(id))) continue;
|
||||||
for (const id of r.entreeLieeIds) {
|
for (const id of r.entreeLieeIds) {
|
||||||
if (!entreeIds.has(id)) {
|
if (!entreeIds.has(id)) {
|
||||||
@@ -414,8 +467,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const entrees = this.rows.filter((r) => r.type === 'entree' && entreeIds.has(r.id));
|
const entrees = pool.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 sorties = pool.filter((r) => r.type !== 'entree' && r.entreeLieeIds.some((id) => entreeIds.has(id)));
|
||||||
return [...entrees, ...sorties];
|
return [...entrees, ...sorties];
|
||||||
},
|
},
|
||||||
filteredRows() {
|
filteredRows() {
|
||||||
@@ -525,9 +578,15 @@
|
|||||||
// equal-split note), each entrée's residual for these compte(s)
|
// equal-split note), each entrée's residual for these compte(s)
|
||||||
// counts separately -- they're independent invoices, each with
|
// counts separately -- they're independent invoices, each with
|
||||||
// its own outstanding amount.
|
// its own outstanding amount.
|
||||||
// - linked and settled (as far as the currently loaded window can
|
// - linked and settled
|
||||||
// tell -- a linked entrée outside it is silently skipped, same
|
// - inconnu: at least one linked entrée's reconciliation couldn't
|
||||||
// accepted trade-off as reconciliationByEntree itself)
|
// 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) {
|
versementStatus(item) {
|
||||||
if (item.type !== 'versement') return null;
|
if (item.type !== 'versement') return null;
|
||||||
if (!item.entreeLieeIds.length) {
|
if (!item.entreeLieeIds.length) {
|
||||||
@@ -535,9 +594,13 @@
|
|||||||
}
|
}
|
||||||
let resteAVerser = 0;
|
let resteAVerser = 0;
|
||||||
let surVerse = 0;
|
let surVerse = 0;
|
||||||
|
let unresolved = false;
|
||||||
for (const entreeId of item.entreeLieeIds) {
|
for (const entreeId of item.entreeLieeIds) {
|
||||||
const recon = this.reconciliationByEntree.get(entreeId);
|
const recon = this.reconciliationByEntree.get(entreeId);
|
||||||
if (!recon) continue;
|
if (!recon) {
|
||||||
|
unresolved = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
for (const c of Object.keys(item.parCompte)) {
|
for (const c of Object.keys(item.parCompte)) {
|
||||||
const residual = recon.parCompteResidual[c] || 0;
|
const residual = recon.parCompteResidual[c] || 0;
|
||||||
if (residual > 0.01) resteAVerser += residual;
|
if (residual > 0.01) resteAVerser += residual;
|
||||||
@@ -550,6 +613,9 @@
|
|||||||
if (surVerse > 0.01) {
|
if (surVerse > 0.01) {
|
||||||
return { kind: 'sur-verse', detail: 'Sur-versé (comptes de cette ligne) : ' + this.formatEur(Math.round(surVerse * 100) / 100) };
|
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;
|
const n = item.entreeLieeIds.length;
|
||||||
return { kind: 'ok', detail: 'Lié à ' + n + ' entrée' + (n > 1 ? 's' : '') + ' client' + (n > 1 ? 's' : '') + '.' };
|
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 === 'non-liee') return 'Non liée';
|
||||||
if (kind === 'reste') return 'Reste à verser';
|
if (kind === 'reste') return 'Reste à verser';
|
||||||
if (kind === 'sur-verse') return 'Sur-versé';
|
if (kind === 'sur-verse') return 'Sur-versé';
|
||||||
|
if (kind === 'inconnu') return 'À vérifier';
|
||||||
return 'Lié';
|
return 'Lié';
|
||||||
},
|
},
|
||||||
// Only non-liee/sur-versé read as a hard anomaly (red); reste is
|
// Only non-liee/sur-versé read as a hard anomaly (red); reste and
|
||||||
// its own softer amber; ok gets neither, falling back to the
|
// inconnu are their own softer amber; ok gets neither, falling back
|
||||||
// badge's default green -- same "all clear" green the entrée side
|
// to the badge's default green -- same "all clear" green the
|
||||||
// already uses for a fully-settled "N sorties liées".
|
// entrée side already uses for a fully-settled "N sorties liées".
|
||||||
versementStatusClasses(kind) {
|
versementStatusClasses(kind) {
|
||||||
return {
|
return {
|
||||||
'is-anomalie': kind === 'non-liee' || kind === 'sur-verse',
|
'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
|
// 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
|
// main table, so opening/closing it never touches the main table's
|
||||||
// scroll position.
|
// scroll position.
|
||||||
toggleEntreeFilter(id) {
|
toggleEntreeFilter(id) {
|
||||||
this.filterEntreeId = this.filterEntreeId === id ? null : id;
|
if (this.filterEntreeId === id) {
|
||||||
|
this.closeDrilldown();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.filterEntreeId = id;
|
||||||
|
this.loadEntreeGroup(id);
|
||||||
},
|
},
|
||||||
closeDrilldown() {
|
closeDrilldown() {
|
||||||
this.filterEntreeId = null;
|
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) {
|
startEditType(item) {
|
||||||
this.typeUpdateError = null;
|
this.typeUpdateError = null;
|
||||||
this.editingTypeId = item.id;
|
this.editingTypeId = item.id;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace Drupal\figli_compta_ledger\Controller;
|
namespace Drupal\figli_compta_ledger\Controller;
|
||||||
|
|
||||||
use Drupal\Core\Controller\ControllerBase;
|
use Drupal\Core\Controller\ControllerBase;
|
||||||
|
use Drupal\node\NodeInterface;
|
||||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
|
||||||
@@ -176,4 +177,51 @@ class LedgerStatsController extends ControllerBase {
|
|||||||
return new JsonResponse($result);
|
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 v-if="filterEntreeId" class="figli-modal-backdrop" @click.self="closeDrilldown">
|
||||||
<div class="figli-modal">
|
<div class="figli-modal">
|
||||||
<div class="figli-modal-header">
|
<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>
|
<button type="button" class="figli-modal-close" @click="closeDrilldown">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="figli-modal-body">
|
<div class="figli-modal-body">
|
||||||
|
|||||||
Reference in New Issue
Block a user