Mandatory revisions on ligne_comptable + cross-node history page

Every save now forces a new revision, unconditionally:
- Form: hide the "Create new revision" checkbox and the log message
  field (#access = FALSE, not just a default) so submitted values for
  either can't override them -- Form API discards user input for
  #access-denied elements and falls back to #default_value.
- hook_node_presave(): the same thing enforced for any save that
  doesn't go through the form (drush scripts, etc.), plus explicitly
  setting the revision author (current user) and revision timestamp.
  setNewRevision(TRUE) alone does NOT refresh revision_timestamp -- it
  carries over the previous revision's value, which would silently
  mislabel every edit with its predecessor's save time. Verified via a
  drush test save before/after.
- Applies regardless of figli_compta_ledger.skip_validation: that flag
  is about the répartition-sum check on historical imports, a
  different concern -- revision history is never exempted.

New /lignes/historique page (HistoryController): a single reverse-
chronological feed across every ligne_comptable's revisions, gated by
the 'view ligne_comptable revisions' permission (granted to all three
associate roles). Conceptually a revision of one ligne is a revision
of the grand livre as a whole, so this aggregates across nodes rather
than reusing Drupal's per-node revision history page. Each row links
to that specific revision via core's existing revision-view route.
Linked from the /lignes toolbar, shown only when the current user has
the permission.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 11:25:00 +02:00
co-authored by Claude Sonnet 5
parent 994c7c7aad
commit 384b283547
9 changed files with 147 additions and 3 deletions
+1
View File
@@ -16,3 +16,4 @@ permissions:
- 'create ligne_comptable content' - 'create ligne_comptable content'
- 'delete any ligne_comptable content' - 'delete any ligne_comptable content'
- 'edit any ligne_comptable content' - 'edit any ligne_comptable content'
- 'view ligne_comptable revisions'
+1
View File
@@ -16,3 +16,4 @@ permissions:
- 'create ligne_comptable content' - 'create ligne_comptable content'
- 'delete any ligne_comptable content' - 'delete any ligne_comptable content'
- 'edit any ligne_comptable content' - 'edit any ligne_comptable content'
- 'view ligne_comptable revisions'
+4
View File
@@ -2,7 +2,10 @@ uuid: 24f4e46d-c714-486d-b000-f092eb727470
langcode: en langcode: en
status: true status: true
dependencies: dependencies:
config:
- node.type.ligne_comptable
module: module:
- node
- system - system
id: user id: user
label: 'Utilisateur (lecture seule)' label: 'Utilisateur (lecture seule)'
@@ -10,3 +13,4 @@ weight: 4
is_admin: false is_admin: false
permissions: permissions:
- 'access content' - 'access content'
- 'view ligne_comptable revisions'
@@ -13,3 +13,11 @@ figli_compta_ledger.dashboard:
menu_name: admin menu_name: admin
parent: system.admin parent: system.admin
weight: -10 weight: -10
figli_compta_ledger.history:
title: 'Historique du grand livre'
description: 'Toutes les révisions de toutes les lignes comptables'
route_name: figli_compta_ledger.history
menu_name: admin
parent: system.admin
weight: -9
@@ -2,8 +2,11 @@
/** /**
* @file * @file
* Enforces the one invariant that explained most of the errors found in the * Enforces two invariants for ligne_comptable: sum(répartition.montant)
* historical spreadsheets: sum(répartition.montant) must equal montant_ht. * must equal montant_ht (explained most of the errors found in the
* historical spreadsheets), and every save must create a revision with no
* way to opt out, for an audit trail of who changed the shared ledger and
* when.
*/ */
use Drupal\node\NodeInterface; use Drupal\node\NodeInterface;
@@ -33,6 +36,21 @@ function figli_compta_ledger_form_alter(&$form, FormStateInterface $form_state,
} }
$form['#validate'][] = 'figli_compta_ledger_validate_repartition'; $form['#validate'][] = 'figli_compta_ledger_validate_repartition';
// Every save must create a revision, unconditionally -- there's no
// legitimate reason to skip it for an accounting record, so don't let
// anyone opt out. #access = FALSE (rather than just leaving the
// #default_value alone) makes Form API discard whatever a tampered
// request might submit for this field and fall back to #default_value.
// figli_compta_ledger_node_presave() enforces the same thing for any
// save that doesn't go through this form at all.
if (isset($form['revision'])) {
$form['revision']['#default_value'] = TRUE;
$form['revision']['#access'] = FALSE;
}
if (isset($form['revision_log'])) {
$form['revision_log']['#access'] = FALSE;
}
$request = \Drupal::request(); $request = \Drupal::request();
$wrapper_formats = ['drupal_ajax', 'drupal_modal', 'drupal_dialog']; $wrapper_formats = ['drupal_ajax', 'drupal_modal', 'drupal_dialog'];
$is_ajax_modal = in_array($request->query->get('_wrapper_format'), $wrapper_formats, TRUE) $is_ajax_modal = in_array($request->query->get('_wrapper_format'), $wrapper_formats, TRUE)
@@ -110,6 +128,20 @@ function figli_compta_ledger_node_presave(NodeInterface $node) {
if ($node->bundle() !== 'ligne_comptable') { if ($node->bundle() !== 'ligne_comptable') {
return; return;
} }
// Force a revision on every save, with no log message and the actual
// acting user as its author -- this is the audit trail for a shared
// ledger, not the répartition-sum check below, so it's never exempted
// (not even during a skip_validation import: migrated data still needs
// an honest revision history).
$node->setNewRevision(TRUE);
$node->setRevisionLogMessage('');
$node->setRevisionUserId(\Drupal::currentUser()->id());
// setNewRevision() alone doesn't refresh this -- it stays whatever it
// was on the entity as loaded (the previous revision's timestamp),
// silently mislabeling every edit with its predecessor's save time.
$node->setRevisionCreationTime(\Drupal::time()->getRequestTime());
if (!$node->hasField('field_montant_ht') || !$node->hasField('field_repartition')) { if (!$node->hasField('field_montant_ht') || !$node->hasField('field_repartition')) {
return; return;
} }
@@ -142,7 +174,7 @@ function figli_compta_ledger_node_presave(NodeInterface $node) {
function figli_compta_ledger_theme($existing, $type, $theme, $path) { function figli_compta_ledger_theme($existing, $type, $theme, $path) {
return [ return [
'figli_compta_home' => [ 'figli_compta_home' => [
'variables' => [], 'variables' => ['can_view_history' => FALSE],
'template' => 'figli-compta-home', 'template' => 'figli-compta-home',
], ],
'figli_compta_dashboard' => [ 'figli_compta_dashboard' => [
@@ -13,3 +13,11 @@ figli_compta_ledger.dashboard:
_title: 'Tableau de bord - SAS Figures Libres' _title: 'Tableau de bord - SAS Figures Libres'
requirements: requirements:
_permission: 'access content' _permission: 'access content'
figli_compta_ledger.history:
path: '/lignes/historique'
defaults:
_controller: '\Drupal\figli_compta_ledger\Controller\HistoryController::history'
_title: 'Historique du grand livre - SAS Figures Libres'
requirements:
_permission: 'view ligne_comptable revisions'
@@ -17,6 +17,7 @@ class DashboardController extends ControllerBase {
public function home() { public function home() {
return [ return [
'#theme' => 'figli_compta_home', '#theme' => 'figli_compta_home',
'#can_view_history' => $this->currentUser()->hasPermission('view ligne_comptable revisions'),
'#attached' => [ '#attached' => [
'library' => ['figli_compta_ledger/home'], 'library' => ['figli_compta_ledger/home'],
], ],
@@ -0,0 +1,84 @@
<?php
namespace Drupal\figli_compta_ledger\Controller;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Url;
/**
* Grand livre history: a single reverse-chronological feed across every
* ligne_comptable's revisions, rather than Drupal's default per-node
* history page -- a revision of one ligne is, conceptually, a revision of
* the grand livre as a whole.
*/
class HistoryController extends ControllerBase {
/**
* Lists every ligne_comptable revision, most recent change first.
*/
public function history() {
$node_storage = $this->entityTypeManager()->getStorage('node');
$date_formatter = \Drupal::service('date.formatter');
$revision_ids = $this->entityTypeManager()->getStorage('node')->getQuery()
->accessCheck(TRUE)
->allRevisions()
->condition('type', 'ligne_comptable')
->sort('revision_timestamp', 'DESC')
->pager(50)
->execute();
$rows = [];
foreach ($revision_ids as $vid => $nid) {
$revision = $node_storage->loadRevision($vid);
if (!$revision) {
continue;
}
$author = $revision->getRevisionUser();
$rows[] = [
$date_formatter->format($revision->getRevisionCreationTime(), 'short'),
$author ? $author->getDisplayName() : $this->t('Utilisateur supprimé'),
$revision->hasField('field_date_ligne') ? $revision->get('field_date_ligne')->value : '',
$revision->label(),
$revision->hasField('field_montant_ht') && !$revision->get('field_montant_ht')->isEmpty()
? $revision->get('field_montant_ht')->value . ' €'
: '',
[
'data' => [
'#type' => 'link',
'#title' => $this->t('Voir cette version'),
'#url' => Url::fromRoute('entity.node.revision', [
'node' => $nid,
'node_revision' => $vid,
]),
],
],
];
}
return [
'table' => [
'#type' => 'table',
'#header' => [
$this->t('Modifié le'),
$this->t('Par'),
$this->t('Date de la ligne'),
$this->t('Ligne'),
$this->t('Montant HT'),
$this->t('Version'),
],
'#rows' => $rows,
'#empty' => $this->t('Aucune révision.'),
'#attributes' => ['class' => ['figli-historique-table']],
],
'pager' => [
'#type' => 'pager',
],
'#cache' => [
'contexts' => ['user.permissions'],
'tags' => ['node_list:ligne_comptable'],
],
];
}
}
@@ -9,6 +9,11 @@
<div id="figli-home-app"> <div id="figli-home-app">
<div class="figli-toolbar"> <div class="figli-toolbar">
<a href="/node/add/ligne_comptable" class="button button--primary" @click.prevent="openAddForm">+ Ajouter une ligne</a> <a href="/node/add/ligne_comptable" class="button button--primary" @click.prevent="openAddForm">+ Ajouter une ligne</a>
{% endverbatim %}
{% if can_view_history %}
<a href="{{ path('figli_compta_ledger.history') }}" class="button">Historique</a>
{% endif %}
{% verbatim %}
<label>Compte <label>Compte
<select v-model="filterCompte"> <select v-model="filterCompte">