Link sorties to the entrée client they pay out against
New field_entree_liee (entity reference, node -> node) on
ligne_comptable, restricted to entrée-type nodes via a custom
EntreeClientSelection plugin -- narrows further to the same client as
the sortie being linked when one is already known, using the
referencing-entity context Drupal's selection handler API passes
through (getSelectionHandler($field, $entity)).
Quick-link UI (per the associates' explicit ask: no need to open the
full ligne_comptable form just for this):
- A chain-link icon next to the edit pencil on versement/achat/
hébergement rows (the only types that pay out against a client
invoice) opens LinkEntreeForm, a one-field AJAX modal, reusing the
same modal/close-on-save plumbing as the edit form. Filled/colored
when already linked, with the linked entrée's label on hover.
Also present (states-hidden unless one of those three types is
selected) on the full node form for whoever's already there anyway.
- Entrée rows get a reconciliation badge once at least one sortie
links back to them, clickable to drill the table down to just that
entrée and its linked sorties.
Conformity check assumes multi-compte répartition on both sides (an
entrée's répartition and each linked sortie's répartition can each
split across several comptes -- confirmed this is the real shape of
"hébergement" sorties, e.g. OVH/HETZNER renewals split across all 8
comptes, even though versement/achat lines happen to always be
single-compte in the current data). Per compte, compares the entrée's
répartition share (positive, owed) against the summed répartition of
every linked sortie (negative, paid) -- a residual near zero means
settled, positive means still owed ("reste à verser"), negative means
overpaid ("sur-versé", flagged for a closer look).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -21,13 +21,16 @@
|
||||
autre: 'Autre',
|
||||
ouverture: 'Ouverture',
|
||||
};
|
||||
// Sorties that can be linked to the entrée client they pay out against
|
||||
// (field_entree_liee) -- charge/autre/ouverture aren't client-specific.
|
||||
const LINKABLE_TYPES = ['versement', 'achat', 'hebergement'];
|
||||
|
||||
async function fetchAllLignes() {
|
||||
// sort includes drupal_internal__nid as a tie-breaker: field_date_ligne
|
||||
// alone is not unique (many lines share a date), and without a unique
|
||||
// secondary sort key, offset pagination can silently duplicate or skip
|
||||
// rows across pages.
|
||||
let url = API_BASE + '?include=field_repartition,field_repartition.field_compte,field_client&page[limit]=50&sort=field_date_ligne,drupal_internal__nid';
|
||||
let url = API_BASE + '?include=field_repartition,field_repartition.field_compte,field_client,field_entree_liee&page[limit]=50&sort=field_date_ligne,drupal_internal__nid';
|
||||
const allData = [];
|
||||
const includedMap = new Map();
|
||||
while (url) {
|
||||
@@ -55,6 +58,7 @@
|
||||
const rels = node.relationships || {};
|
||||
const attrs = node.attributes;
|
||||
const clientTerm = resolve(includedMap, rels.field_client && rels.field_client.data);
|
||||
const entreeLieeNode = resolve(includedMap, rels.field_entree_liee && rels.field_entree_liee.data);
|
||||
const parCompte = {};
|
||||
let somme = 0;
|
||||
const repartitionRefs = (rels.field_repartition && rels.field_repartition.data) || [];
|
||||
@@ -82,6 +86,9 @@
|
||||
somme,
|
||||
ecart,
|
||||
hasError: Math.abs(ecart) > 0.01,
|
||||
linkable: LINKABLE_TYPES.includes(attrs.field_type_ligne),
|
||||
entreeLieeId: entreeLieeNode ? entreeLieeNode.id : null,
|
||||
entreeLieeLabel: entreeLieeNode ? (entreeLieeNode.attributes.title || null) : null,
|
||||
});
|
||||
}
|
||||
rows.sort((a, b) => (a.date || '').localeCompare(b.date || ''));
|
||||
@@ -102,6 +109,7 @@
|
||||
groupBy: 'month',
|
||||
onlyErrors: false,
|
||||
hoverCol: null,
|
||||
filterEntreeId: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -117,6 +125,53 @@
|
||||
errorCount() {
|
||||
return this.rows.filter((r) => r.hasError).length;
|
||||
},
|
||||
sortiesByEntree() {
|
||||
const map = new Map();
|
||||
for (const r of this.rows) {
|
||||
if (!r.entreeLieeId) continue;
|
||||
if (!map.has(r.entreeLieeId)) map.set(r.entreeLieeId, []);
|
||||
map.get(r.entreeLieeId).push(r);
|
||||
}
|
||||
return map;
|
||||
},
|
||||
// Compares each entrée's répartition (positive shares owed) against
|
||||
// the combined répartition of every sortie linked to it (negative
|
||||
// amounts paid out), per compte -- both sides can be split across
|
||||
// several comptes. A residual near zero means fully settled;
|
||||
// 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.
|
||||
reconciliationByEntree() {
|
||||
const map = new Map();
|
||||
for (const entreeRow of this.rows) {
|
||||
if (entreeRow.type !== 'entree') continue;
|
||||
const linked = this.sortiesByEntree.get(entreeRow.id) || [];
|
||||
const versementsParCompte = {};
|
||||
for (const s of linked) {
|
||||
for (const [compte, montant] of Object.entries(s.parCompte)) {
|
||||
versementsParCompte[compte] = (versementsParCompte[compte] || 0) + montant;
|
||||
}
|
||||
}
|
||||
const comptes = new Set([...Object.keys(entreeRow.parCompte), ...Object.keys(versementsParCompte)]);
|
||||
let resteAVerser = 0;
|
||||
let surVerse = 0;
|
||||
const detail = [];
|
||||
for (const c of comptes) {
|
||||
const residual = Math.round(((entreeRow.parCompte[c] || 0) + (versementsParCompte[c] || 0)) * 100) / 100;
|
||||
if (residual > 0.01) resteAVerser += residual;
|
||||
else if (residual < -0.01) surVerse += -residual;
|
||||
if (Math.abs(residual) > 0.01) detail.push(c + ' : ' + this.formatEur(residual));
|
||||
}
|
||||
map.set(entreeRow.id, {
|
||||
count: linked.length,
|
||||
resteAVerser: Math.round(resteAVerser * 100) / 100,
|
||||
surVerse: Math.round(surVerse * 100) / 100,
|
||||
detail: detail.join(', ') || 'Entièrement soldé',
|
||||
});
|
||||
}
|
||||
return map;
|
||||
},
|
||||
footerTotals() {
|
||||
const parCompte = {};
|
||||
this.allComptes.forEach((c) => { parCompte[c] = 0; });
|
||||
@@ -132,6 +187,13 @@
|
||||
return { montant_ht: montantHt, montant_ttc: montantTtc, parCompte, ecart };
|
||||
},
|
||||
filteredRows() {
|
||||
// Drill-down mode: an entrée and only the sorties linked to it,
|
||||
// ignoring the other filters -- clicking its badge again clears it.
|
||||
if (this.filterEntreeId) {
|
||||
const entree = this.rows.find((r) => r.id === this.filterEntreeId);
|
||||
const linked = this.sortiesByEntree.get(this.filterEntreeId) || [];
|
||||
return entree ? [entree, ...linked] : linked;
|
||||
}
|
||||
return this.rows.filter((r) => {
|
||||
if (this.filterCompte && r.parCompte[this.filterCompte] === undefined) return false;
|
||||
if (this.filterClient && r.client !== this.filterClient) return false;
|
||||
@@ -196,6 +258,17 @@
|
||||
progress: { type: 'throbber' },
|
||||
}).execute();
|
||||
},
|
||||
openLinkForm(nid) {
|
||||
Drupal.ajax({
|
||||
url: '/lignes/' + nid + '/lier',
|
||||
dialogType: 'modal',
|
||||
dialog: { width: 500, title: 'Lier à une entrée client' },
|
||||
progress: { type: 'throbber' },
|
||||
}).execute();
|
||||
},
|
||||
toggleEntreeFilter(id) {
|
||||
this.filterEntreeId = this.filterEntreeId === id ? null : id;
|
||||
},
|
||||
onCellHover(evt) {
|
||||
const cell = evt.target.closest('td, th');
|
||||
if (!cell) return;
|
||||
|
||||
Reference in New Issue
Block a user