|
|
|
@@ -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;
|
|
|
|
|