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:
2026-09-04 12:22:47 +02:00
co-authored by Claude Sonnet 5
parent 27a799e937
commit d04d0e8824
12 changed files with 358 additions and 4 deletions
@@ -5,6 +5,7 @@ dependencies:
config:
- field.field.node.ligne_comptable.field_client
- field.field.node.ligne_comptable.field_date_ligne
- field.field.node.ligne_comptable.field_entree_liee
- field.field.node.ligne_comptable.field_montant_ht
- field.field.node.ligne_comptable.field_montant_ttc
- field.field.node.ligne_comptable.field_notes
@@ -42,6 +43,16 @@ content:
region: content
settings: { }
third_party_settings: { }
field_entree_liee:
type: entity_reference_autocomplete
weight: 2
region: content
settings:
match_operator: CONTAINS
match_limit: 10
size: 60
placeholder: ''
third_party_settings: { }
field_montant_ht:
type: number
weight: 3
@@ -5,6 +5,7 @@ dependencies:
config:
- field.field.node.ligne_comptable.field_client
- field.field.node.ligne_comptable.field_date_ligne
- field.field.node.ligne_comptable.field_entree_liee
- field.field.node.ligne_comptable.field_montant_ht
- field.field.node.ligne_comptable.field_montant_ttc
- field.field.node.ligne_comptable.field_notes
@@ -38,6 +39,14 @@ content:
third_party_settings: { }
weight: 0
region: content
field_entree_liee:
type: entity_reference_label
label: above
settings:
link: true
third_party_settings: { }
weight: 2
region: content
field_montant_ht:
type: number_decimal
label: above
@@ -0,0 +1,23 @@
uuid: c2d68287-8e24-4529-a035-39395acca4d3
langcode: en
status: true
dependencies:
config:
- field.storage.node.field_entree_liee
- node.type.ligne_comptable
id: node.ligne_comptable.field_entree_liee
field_name: field_entree_liee
entity_type: node
bundle: ligne_comptable
label: 'Entrée client liée'
description: ''
required: false
translatable: true
default_value: { }
default_value_callback: ''
settings:
handler: 'figli_compta_ledger:entree_client'
handler_settings:
target_bundles:
ligne_comptable: ligne_comptable
field_type: entity_reference
@@ -0,0 +1,19 @@
uuid: d7148451-7a96-4515-b0b5-42ad9fecbdce
langcode: en
status: true
dependencies:
module:
- node
id: node.field_entree_liee
field_name: field_entree_liee
entity_type: node
type: entity_reference
settings:
target_type: node
module: core
locked: false
cardinality: 1
translatable: true
indexes: { }
persist_with_no_fields: false
custom_storage: false
@@ -0,0 +1,5 @@
# EntreeClientSelection only adds query conditions on top of NodeSelection
# (itself a DefaultSelection subclass) -- no new handler_settings keys, so
# it uses the same schema shape as the core 'default' handler.
entity_reference_selection.figli_compta_ledger:
type: entity_reference_selection.default
@@ -204,7 +204,14 @@ html.gin--dark-mode #figli-home-app {
text-align: center;
}
#figli-home-app .figli-edit-btn {
#figli-home-app td.actions-col {
display: flex;
gap: 0.15rem;
justify-content: center;
}
#figli-home-app .figli-edit-btn,
#figli-home-app .figli-link-btn {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -218,12 +225,45 @@ html.gin--dark-mode #figli-home-app {
cursor: pointer;
}
#figli-home-app .figli-edit-btn:hover {
#figli-home-app .figli-edit-btn:hover,
#figli-home-app .figli-link-btn:hover {
background: var(--figli-bg-alt);
border-color: var(--figli-border);
color: var(--figli-text);
}
#figli-home-app .figli-link-btn.is-linked {
color: #3b6fe0;
}
/* Reconciliation badge on entrée rows: répartition (money owed) vs the
combined répartition of every linked sortie (money actually paid out). */
#figli-home-app .figli-recon-badge {
display: inline-block;
margin-left: 0.4rem;
padding: 0.05rem 0.4rem;
border-radius: 10px;
font-size: 0.68rem;
font-weight: 600;
white-space: nowrap;
cursor: pointer;
background: #1a7f371a;
color: var(--figli-positive);
}
#figli-home-app .figli-recon-badge.is-reste {
background: #d97a0a1a;
color: #d97a0a;
}
#figli-home-app .figli-recon-badge.is-anomalie {
background: #c9312b1a;
color: var(--figli-error);
}
#figli-home-app .figli-clear-drilldown {
font-size: 0.8rem;
padding: 0.2rem 0.6rem;
}
#figli-home-app .figli-error {
background: #fde8e8;
border: 1px solid #f4a3a3;
@@ -51,6 +51,21 @@ function figli_compta_ledger_form_alter(&$form, FormStateInterface $form_state,
$form['revision_log']['#access'] = FALSE;
}
// field_entree_liee only makes sense on the "sortie" types that pay out
// against a client invoice -- charge/autre/ouverture aren't tied to a
// specific entrée, and entree lines don't link to themselves.
if (isset($form['field_entree_liee'])) {
$form['field_entree_liee']['#states'] = [
'visible' => [
[':input[name="field_type_ligne"]' => ['value' => 'versement']],
'or',
[':input[name="field_type_ligne"]' => ['value' => 'achat']],
'or',
[':input[name="field_type_ligne"]' => ['value' => 'hebergement']],
],
];
}
$request = \Drupal::request();
$wrapper_formats = ['drupal_ajax', 'drupal_modal', 'drupal_dialog'];
$is_ajax_modal = in_array($request->query->get('_wrapper_format'), $wrapper_formats, TRUE)
@@ -21,3 +21,15 @@ figli_compta_ledger.history:
_title: 'Historique du grand livre - SAS Figures Libres'
requirements:
_permission: 'view ligne_comptable revisions'
figli_compta_ledger.link_entree:
path: '/lignes/{node}/lier'
defaults:
_form: '\Drupal\figli_compta_ledger\Form\LinkEntreeForm'
_title: 'Lier à une entrée client'
requirements:
_entity_access: 'node.update'
options:
parameters:
node:
type: entity:node
@@ -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;
@@ -0,0 +1,85 @@
<?php
namespace Drupal\figli_compta_ledger\Form;
use Drupal\Core\Ajax\AjaxResponse;
use Drupal\Core\Ajax\CloseModalDialogCommand;
use Drupal\Core\Ajax\ReplaceCommand;
use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\node\NodeInterface;
/**
* Quick-link form: sets field_entree_liee on a single sortie line without
* opening the full ligne_comptable edit form -- the associates only ever
* need to touch this one field to link a versement/achat/hébergement to
* the entrée client it pays out against.
*/
class LinkEntreeForm extends FormBase {
/**
* {@inheritdoc}
*/
public function getFormId() {
return 'figli_compta_ledger_link_entree_form';
}
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state, ?NodeInterface $node = NULL) {
$form_state->set('node', $node);
$form['field_entree_liee'] = [
'#type' => 'entity_autocomplete',
'#title' => $this->t('Entrée client liée'),
'#target_type' => 'node',
'#selection_handler' => 'figli_compta_ledger:entree_client',
'#selection_settings' => [
'target_bundles' => ['ligne_comptable' => 'ligne_comptable'],
// Narrows the autocomplete to the same client as this sortie --
// read by EntreeClientSelection::buildEntityQuery().
'entity' => $node,
],
'#default_value' => $node->get('field_entree_liee')->entity,
'#description' => $this->t('Laisser vide pour retirer le lien.'),
];
$form['actions'] = ['#type' => 'actions'];
$form['actions']['submit'] = [
'#type' => 'submit',
'#value' => $this->t('Enregistrer'),
'#ajax' => [
'callback' => '::ajaxSubmit',
],
];
return $form;
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
/** @var \Drupal\node\NodeInterface $node */
$node = $form_state->get('node');
$node->set('field_entree_liee', $form_state->getValue('field_entree_liee') ?: NULL);
$node->save();
}
/**
* #ajax callback: close the modal on success (the /lignes table picks up
* "dialog:afterclose" to refresh), or re-render the form in place.
*/
public function ajaxSubmit(array &$form, FormStateInterface $form_state) {
$response = new AjaxResponse();
if ($form_state->getErrors()) {
unset($form['#prefix'], $form['#suffix']);
$response->addCommand(new ReplaceCommand('#' . $form['#id'], $form));
return $response;
}
$response->addCommand(new CloseModalDialogCommand());
return $response;
}
}
@@ -0,0 +1,38 @@
<?php
namespace Drupal\figli_compta_ledger\Plugin\EntityReferenceSelection;
use Drupal\Core\Entity\Attribute\EntityReferenceSelection;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\node\Plugin\EntityReferenceSelection\NodeSelection;
/**
* Restricts field_entree_liee's candidates to "entrée" ligne_comptable
* nodes, scoped to the same client as the sortie being linked when one is
* already set on it.
*/
#[EntityReferenceSelection(
id: "figli_compta_ledger:entree_client",
label: new TranslatableMarkup("Entrées client (SAS Figures Libres)"),
entity_types: ["node"],
group: "figli_compta_ledger",
weight: 0,
)]
class EntreeClientSelection extends NodeSelection {
/**
* {@inheritdoc}
*/
protected function buildEntityQuery($match = NULL, $match_operator = 'CONTAINS') {
$query = parent::buildEntityQuery($match, $match_operator);
$query->condition('field_type_ligne', 'entree');
$entity = $this->configuration['entity'] ?? NULL;
if ($entity && $entity->hasField('field_client') && !$entity->get('field_client')->isEmpty()) {
$query->condition('field_client', $entity->get('field_client')->target_id);
}
return $query;
}
}
@@ -55,6 +55,8 @@
<input type="checkbox" v-model="onlyErrors" /> Écarts uniquement
</label>
<button type="button" class="button figli-clear-drilldown" v-if="filterEntreeId" @click="filterEntreeId = null">✕ Entrée + sorties liées uniquement</button>
<span class="figli-count" v-if="!loading">{{ filteredRows.length }} / {{ rows.length }} lignes{{ errorCount ? ' — ' + errorCount + ' avec écart' : '' }}</span>
</div>
@@ -84,12 +86,34 @@
<td>{{ item.date }}</td>
<td>{{ item.client || '—' }}</td>
<td><span class="figli-badge" :class="'type-' + item.type">{{ typeLabel(item.type) }}</span></td>
<td class="figli-libelle">{{ item.libelle }}</td>
<td class="figli-libelle">
{{ item.libelle }}
<span
v-if="item.type === 'entree' && reconciliationByEntree.get(item.id) && reconciliationByEntree.get(item.id).count > 0"
class="figli-recon-badge"
:class="{'is-anomalie': reconciliationByEntree.get(item.id).surVerse > 0, 'is-reste': reconciliationByEntree.get(item.id).resteAVerser > 0}"
:title="reconciliationByEntree.get(item.id).detail"
@click="toggleEntreeFilter(item.id)"
>{{ reconciliationByEntree.get(item.id).count }} sortie{{ reconciliationByEntree.get(item.id).count > 1 ? 's' : '' }} liée{{ reconciliationByEntree.get(item.id).count > 1 ? 's' : '' }}<template v-if="reconciliationByEntree.get(item.id).resteAVerser > 0"> · reste {{ formatEur(reconciliationByEntree.get(item.id).resteAVerser) }}</template><template v-if="reconciliationByEntree.get(item.id).surVerse > 0"> · sur-versé {{ formatEur(reconciliationByEntree.get(item.id).surVerse) }}</template></span>
</td>
<td class="amount">{{ formatEur(item.montant_ht) }}</td>
<td class="amount">{{ formatEur(item.montant_ttc) }}</td>
<td v-for="c in allComptes" :key="c" class="amount compte-col">{{ item.parCompte[c] !== undefined ? formatEur(item.parCompte[c]) : '' }}</td>
<td class="amount" :class="{'figli-ecart': item.hasError}">{{ item.hasError ? formatEur(item.ecart) : '' }}</td>
<td class="actions-col">
<button
v-if="item.linkable"
type="button"
class="figli-link-btn"
:class="{'is-linked': item.entreeLieeId}"
:title="item.entreeLieeId ? 'Lié à : ' + item.entreeLieeLabel : 'Lier à une entrée client'"
@click="openLinkForm(item.nid)"
>
<svg viewBox="0 0 20 20" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M8 12a3 3 0 0 0 4.24 0l2-2a3 3 0 0 0-4.24-4.24l-1 1" />
<path d="M12 8a3 3 0 0 0-4.24 0l-2 2a3 3 0 0 0 4.24 4.24l1-1" />
</svg>
</button>
<button type="button" class="figli-edit-btn" title="Modifier" @click="openEditForm(item.nid)">
<svg viewBox="0 0 20 20" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M13.5 3.5l3 3L6 17l-3.5.5.5-3.5L13.5 3.5z" />