Import de relevé bancaire CSV en libre-service (/lignes/importer-releve)

Chaque transaction du relevé devient une ligne « à trier » : type autre,
répartition vide (liseré rouge existant via field_ecart), tag field_flag
« IMP AAMMJJ » (liseré ambre + filtre existants), client rapproché par
mots (ClientMatcher : séquence contiguë, sinon mot significatif unique --
jamais en sous-chaîne, jamais auto-créé).

- src/Import/ : CsvReleveParser (ISO-8859-1 confirmé, en-tête strict,
  fgetcsv avec escape '' explicite -- dépréciation PHP 8.4, rejets
  propres avec n° de ligne), ReleveImportBatch (Batch API par lots de
  25, dédoublonnage COMPTÉ par empreinte field_import_fitid -- max(0,
  k−m) importe les vrais doublons légitimes et dédoublonne à travers
  des fichiers qui se chevauchent --, totaux de contrôle au centime
  sur la page de résultat, résumé en tempstore privé).
- ReleveUploadForm : upload private://releves (fichier conservé +
  usage, hors de portée du cron), parse en validateForm(), batch,
  redirection vers la page de résultat.
- field_montant_releve : référence bancaire immuable, écrite une fois
  à l'import et jamais par presave ; affichée en TEXTE sous Montant
  TTC (widget remplacé par un #type item -- un item ne soumet rien et
  extractFormValues() saute le champ sans valeur soumise, la valeur
  survit donc à chaque save) ; masquée sur les lignes sans montant.
- SkipValidationContext : contournement du contrôle de répartition
  requête-scopé (ferme le trou de concurrence de l'ancien state
  global, AUDIT-2026-09-09 §2.2) -- presave honore le service (state
  gardé pour compat), updateType/updateField basculent dessus.
- Permissions (AUDIT §2.2 priorité 1) : access figli ledger sur toutes
  les routes du module + autocomplete (RouteSubscriber), import
  réservé Éditeur/Admin, access content retiré du rôle Authenticated
  (config/sync re-exportée pour les 4 rôles).
- Gin : hook_gin_ignore_sticky_form_actions() -- sans ça, le bouton
  Importer partait dans la barre sticky du chrome masqué.
- install : 8011 champs, 8012 index sur les empreintes, 8013/8014
  montant_releve sur le formulaire sous le TTC (poids renumérotés).
- /lignes : boutons + Ajouter / Importer / Historique dans le footer
  sticky (compacts), footer colspan dès la première colonne, badges de
  signalement qui reviennent à la ligne au lieu de déborder.
This commit is contained in:
2026-09-09 14:58:36 +02:00
parent 3688bddba8
commit d2f3179f01
27 changed files with 1434 additions and 57 deletions
+1
View File
@@ -6,4 +6,5 @@
/web/sites/*/files/
/web/sites/*/settings.local.php
/web/sites/*/settings.php
/private/
.env
+3
View File
@@ -5,6 +5,7 @@ dependencies:
config:
- node.type.ligne_comptable
module:
- figli_compta_ledger
- node
- system
id: admin
@@ -13,7 +14,9 @@ weight: 6
is_admin: false
permissions:
- 'access content'
- 'access figli ledger'
- 'create ligne_comptable content'
- 'delete any ligne_comptable content'
- 'edit any ligne_comptable content'
- 'import ligne_comptable releve'
- 'view ligne_comptable revisions'
-2
View File
@@ -7,7 +7,6 @@ dependencies:
module:
- file
- filter
- system
_core:
default_config_hash: wkW7P5A53YhGmsgamrmTbfwpZrqdnPYiJdoAZQtdmJg
id: authenticated
@@ -15,6 +14,5 @@ label: 'Authenticated user'
weight: 1
is_admin: false
permissions:
- 'access content'
- 'delete own files'
- 'use text format basic_html'
+3
View File
@@ -5,6 +5,7 @@ dependencies:
config:
- node.type.ligne_comptable
module:
- figli_compta_ledger
- node
- system
id: editeur
@@ -13,7 +14,9 @@ weight: 5
is_admin: false
permissions:
- 'access content'
- 'access figli ledger'
- 'create ligne_comptable content'
- 'delete any ligne_comptable content'
- 'edit any ligne_comptable content'
- 'import ligne_comptable releve'
- 'view ligne_comptable revisions'
+2
View File
@@ -5,6 +5,7 @@ dependencies:
config:
- node.type.ligne_comptable
module:
- figli_compta_ledger
- node
- system
id: user
@@ -13,4 +14,5 @@ weight: 4
is_admin: false
permissions:
- 'access content'
- 'access figli ledger'
- 'view ligne_comptable revisions'
@@ -276,6 +276,22 @@ html.gin--dark-mode #figli-home-app {
border-bottom: none;
}
/* "+ Ajouter une ligne" + "Importer un relevé" live in the sticky footer
now (they used to lead the toolbar and crowd its filter row). Compact
overrides for Gin's .button, which is sized for full admin forms --
way too big inside a dense totals row. Size-only overrides (no
colors): Gin's own light/dark button palettes keep applying. */
#figli-home-app tr.figli-totals-row .button {
display: inline-block;
margin: 0 0.4rem 0 0;
padding: 0.15rem 0.55rem;
font-size: 0.72rem;
line-height: 1.4;
vertical-align: middle;
border-radius: 4px;
box-shadow: none;
}
#figli-home-app td.figli-solde-crediteur {
color: var(--figli-positive);
}
@@ -334,7 +350,15 @@ html.gin--dark-mode #figli-home-app {
font-weight: 600;
background: color-mix(in srgb, var(--figli-warning) 15%, transparent);
color: var(--figli-warning);
white-space: nowrap;
/* The Signalement column is narrow (6%) -- a badge must wrap inside
the cell instead of overflowing into the neighboring column. The
cell itself already allows wrapping (.figli-flag-cell); this makes
the badge wrap too, including single long tokens (anywhere) and
within its own padding box (max-width + border-box). */
box-sizing: border-box;
max-width: 100%;
white-space: normal;
overflow-wrap: anywhere;
}
/* Column highlight to pair with the row hover, forming a crosshair over
@@ -108,6 +108,26 @@ html.gin--dark-mode .figli-ledger-form {
grid-column: 4 / 5;
}
/* Montant relevé bancaire (field_montant_releve): the import's
immutable bank reference, displayed as plain text -- the widget is
replaced by a #type => 'item' in figli_compta_ledger_form_alter(),
no input box at all. Under Montant TTC in the same column so the two
amounts compare at a glance while sorting an imported line; the
"réf." label marker + tabular digits carry the "value you look at,
not one you type" convention. Hidden entirely on lines with no bank
amount (manually entered ones). */
.figli-ledger-form > .field--name-field-montant-releve {
grid-column: 4 / 5;
}
.figli-ledger-form > .field--name-field-montant-releve label::after {
content: " · réf.";
font-weight: 400;
color: var(--flform-label);
}
.figli-ledger-form > .field--name-field-montant-releve .figli-releve-value {
font-variant-numeric: tabular-nums;
}
/* Field basics */
.figli-ledger-form .form-item__label {
font-size: 0.78rem;
@@ -0,0 +1,142 @@
/*
* Page de résultat d'import de relevé (templates/figli-compta-releve-import-result.html.twig).
*
* Piège #1 du CLAUDE.md respecté : le mode sombre Gin (html.gin--dark-mode)
* ne résout pas les CSS custom properties de Gin de façon fiable sur ces
* routes custom — couleurs explicites en local (--figli-*) + bloc dark mode
* dédié, jamais de fallback seul.
*/
.figli-releve-result {
max-width: 60rem;
margin: 0 auto;
padding: 1rem 1.5rem 3rem;
color: #161616;
--figli-border: #d4d4d4;
--figli-bg: #ffffff;
--figli-bg-soft: #f6f6f6;
--figli-ok: #1b5e20;
--figli-ok-bg: #e8f5e9;
--figli-alert: #b71c1c;
--figli-alert-bg: #ffebee;
}
.figli-releve-result h2 {
margin-top: 1.2rem;
}
.figli-releve-tag .figli-flag-badge {
display: inline-block;
padding: 0.1rem 0.5rem;
border-radius: 0.75rem;
background: #7a5c00;
color: #ffffff;
font-size: 0.85em;
}
.figli-releve-stats {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin: 1.2rem 0;
}
.figli-releve-stat {
flex: 1 1 10rem;
padding: 0.8rem 1rem;
border: 1px solid var(--figli-border);
border-radius: 6px;
background: var(--figli-bg);
text-align: center;
}
.figli-releve-stat-value {
display: block;
font-size: 1.6rem;
font-weight: 600;
}
.figli-releve-stat-label {
display: block;
font-size: 0.85rem;
color: #5f5f5f;
}
.figli-releve-totals {
border-collapse: collapse;
margin: 0.5rem 0 1rem;
}
.figli-releve-totals th,
.figli-releve-totals td {
padding: 0.4rem 0.8rem 0.4rem 0;
border-bottom: 1px solid var(--figli-border);
text-align: left;
}
.figli-releve-amount {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.figli-releve-ok {
padding: 0.5rem 0.8rem;
border-left: 3px solid var(--figli-ok);
background: var(--figli-ok-bg);
color: var(--figli-ok);
}
.figli-releve-alert {
padding: 0.5rem 0.8rem;
border-left: 3px solid var(--figli-alert);
background: var(--figli-alert-bg);
color: var(--figli-alert);
font-weight: 600;
}
.figli-releve-errors li {
color: var(--figli-alert);
margin-bottom: 0.25rem;
}
.figli-releve-dup-wrap {
max-height: 20rem;
overflow-y: auto;
border: 1px solid var(--figli-border);
border-radius: 6px;
background: var(--figli-bg-soft);
}
.figli-releve-dups {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.figli-releve-dups th,
.figli-releve-dups td {
padding: 0.35rem 0.8rem;
text-align: left;
border-bottom: 1px solid var(--figli-border);
}
.figli-releve-actions {
margin-top: 1.5rem;
display: flex;
gap: 0.75rem;
}
/* Mode sombre Gin — mêmes règles, palette inversée, cf. CLAUDE.md piège #1. */
html.gin--dark-mode .figli-releve-result {
color: #e6e6e6;
--figli-border: #3a3a3a;
--figli-bg: #1c1c1c;
--figli-bg-soft: #232323;
--figli-ok: #9ee493;
--figli-ok-bg: #123016;
--figli-alert: #ff8a80;
--figli-alert-bg: #3a1212;
}
html.gin--dark-mode .figli-releve-stat-label {
color: #a3a3a3;
}
@@ -51,6 +51,33 @@ function figli_compta_ledger_install() {
_figli_compta_ledger_create_vocabulary('flag', 'Signalement', []);
_figli_compta_ledger_create_paragraph_repartition();
_figli_compta_ledger_create_node_type_ligne_comptable();
// Fresh installs never run hook_update_N below the current schema
// version -- the import's dedup index is created here directly, and
// existing sites get it from figli_compta_ledger_update_8012().
_figli_compta_ledger_ensure_fitid_index();
}
/**
* Index on the bank statement import's dedup fingerprint column:
* ReleveUploadForm::submitForm() runs a grouped COUNT with
* WHERE field_import_fitid_value IN (...) on every upload. Negligible
* at ~1500 lines today, but that table only ever grows, and this keeps
* the lookup off a full scan without depending on the optimizer.
*/
function _figli_compta_ledger_ensure_fitid_index() {
$schema = \Drupal::database()->schema();
if ($schema->tableExists('node__field_import_fitid')
&& !$schema->indexExists('node__field_import_fitid', 'field_import_fitid_value')) {
// MySQL's addIndex() needs the column's field specification to
// normalize the index (utf8mb4 key-length check); varchar(64) stays
// under the 191-char shortening threshold, so the index covers the
// whole fingerprint column.
$schema->addIndex('node__field_import_fitid', 'field_import_fitid_value', ['field_import_fitid_value'], [
'fields' => [
'field_import_fitid_value' => ['type' => 'varchar', 'length' => 64, 'not null' => FALSE],
],
]);
}
}
function _figli_compta_ledger_create_vocabulary($vid, $name, array $terms) {
@@ -231,6 +258,12 @@ function _figli_compta_ledger_create_node_type_ligne_comptable() {
// filter and its future server-side equivalent can filter on a real
// stored value instead of resolving répartition paragraphs per row.
_figli_field('node', 'ligne_comptable', 'field_ecart', 'Écart', 'decimal', ['precision' => 12, 'scale' => 2]);
// Bank statement import (see src/Import/): dedup fingerprint per
// transaction, and the immutable bank amount for audit. No form/display
// widget for either -- same "hidden technical field" treatment as
// field_ecart before its dashboard treatment (update_8009).
_figli_field('node', 'ligne_comptable', 'field_import_fitid', 'Empreinte import relevé', 'string', ['max_length' => 64]);
_figli_field('node', 'ligne_comptable', 'field_montant_releve', 'Montant relevé bancaire (€)', 'decimal', ['precision' => 12, 'scale' => 2]);
_figli_field('node', 'ligne_comptable', 'field_notes', 'Notes / détail', 'string_long');
// Free-tagging signalement (e.g. "client impayé", "à relancer") -- purely
@@ -267,17 +300,22 @@ function _figli_compta_ledger_create_node_type_ligne_comptable() {
->setComponent('field_montant_ht', ['type' => 'number', 'weight' => 5])
->setComponent('field_cotisation_urssaf', ['type' => 'number', 'weight' => 6])
->setComponent('field_montant_ttc', ['type' => 'number', 'weight' => 8])
// field_tva sits *after* Montant TTC, not between Cotisation and
// TTC -- figli_compta_ledger_form_alter() inserts a non-field
// "field_tva_rate" select at weight 7 (a select of the official
// French VAT rates) to fill that visual slot instead; this real
// field only becomes visible (still at its own weight, on its own
// Read-only bank reference directly under Montant TTC (the widget
// is #disabled by figli_compta_ledger_form_alter()) -- see
// update_8013/_8014 for why this lives on the form despite being
// import-written only, and why it sits at weight 9.
->setComponent('field_montant_releve', ['type' => 'number', 'weight' => 9])
// field_tva sits *after* Montant TTC (and the bank reference), not
// between Cotisation and TTC -- figli_compta_ledger_form_alter()
// inserts a non-field "field_tva_rate" select at weight 7 (a
// select of the official French VAT rates) to fill that visual
// slot instead; this real field only becomes visible (on its own
// full-width row) when "Autre" is picked there. See
// css/ledger-form.css's grid-column rules for both.
->setComponent('field_tva', ['type' => 'number', 'weight' => 9])
->setComponent('field_repartition', ['type' => 'paragraphs', 'weight' => 10, 'settings' => ['title' => 'Répartition', 'title_plural' => 'Répartitions', 'edit_mode' => 'open', 'add_mode' => 'button']])
->setComponent('field_notes', ['type' => 'string_textarea', 'weight' => 11])
->setComponent('field_flag', ['type' => 'entity_reference_autocomplete_tags', 'weight' => 12])
->setComponent('field_tva', ['type' => 'number', 'weight' => 10])
->setComponent('field_repartition', ['type' => 'paragraphs', 'weight' => 11, 'settings' => ['title' => 'Répartition', 'title_plural' => 'Répartitions', 'edit_mode' => 'open', 'add_mode' => 'button']])
->setComponent('field_notes', ['type' => 'string_textarea', 'weight' => 12])
->setComponent('field_flag', ['type' => 'entity_reference_autocomplete_tags', 'weight' => 13])
->save();
}
@@ -807,3 +845,87 @@ function figli_compta_ledger_update_8010() {
return "Écart calculé pour $filled lignes (aucune nouvelle révision créée), $skipped laissées vides (pas de Montant HT).";
}
/**
* Adds the two technical fields behind the bank statement import (see
* PLAN-import-releve-bancaire.md and src/Import/):
* - field_import_fitid (string 64): per-transaction dedup fingerprint
* ('csv:<sha1(date|montant|libellé normalisé)>'), compared count-aware
* against every line already in base, all provenances combined;
* - field_montant_releve (decimal 12,2): the real bank amount, written
* once at import and never touched again by anything -- the immutable
* audit reference field_montant_ttc (a normal, recomputed field) can
* legitimately drift away from as the associate corrects HT/TVA.
*
* Neither gets a form or display widget: purely technical, same treatment
* as field_ecart (update_8009). No data to backfill -- only the import
* itself writes these.
*/
function figli_compta_ledger_update_8011() {
_figli_field('node', 'ligne_comptable', 'field_import_fitid', 'Empreinte import relevé', 'string', ['max_length' => 64]);
_figli_field('node', 'ligne_comptable', 'field_montant_releve', 'Montant relevé bancaire (€)', 'decimal', ['precision' => 12, 'scale' => 2]);
return 'Champs field_import_fitid + field_montant_releve ajoutés (import de relevé bancaire).';
}
/**
* Adds the dedup index on node__field_import_fitid(field_import_fitid_value)
* -- see _figli_compta_ledger_ensure_fitid_index(). Split from update_8011
* because that one already ran when the index need was reviewed.
*/
function figli_compta_ledger_update_8012() {
_figli_compta_ledger_ensure_fitid_index();
return "Index ajouté sur node__field_import_fitid (empreintes d'import, requête de dédoublonnage).";
}
/**
* Adds field_montant_releve to the ligne_comptable form display as a
* read-only reference: the widget is #disabled by
* figli_compta_ledger_form_alter() and nothing but the bank statement
* import ever writes the field (see update_8011), but associates need to
* SEE the bank's amount while they correct HT/TVA on an imported line --
* an écart between it and the recomputed Montant TTC is a useful signal
* (grouped invoice, partial payment), not something to hide.
*/
function figli_compta_ledger_update_8013() {
$form_display = EntityFormDisplay::load('node.ligne_comptable.default');
if ($form_display && !$form_display->getComponent('field_montant_releve')) {
$form_display->setComponent('field_montant_releve', ['type' => 'number', 'weight' => 13])->save();
}
return 'field_montant_releve visible en lecture seule sur le formulaire (référence bancaire des lignes importées).';
}
/**
* Moves field_montant_releve from the form's bottom up to weight 9,
* directly under Montant TTC (weight 8) -- the bank reference reads best
* right below the amount it gets compared against while sorting an
* imported line. Weights are plain integers (display config coerces
* fractional ones, see update_8004()'s comment), so inserting means
* renumbering the tail -- field_tva/repartition/notes/flag shift to
* 10/11/12/13, exactly the kind of renumber update_8004/_8006 did
* before. Mirrors the fresh-install weights in
* _figli_compta_ledger_create_node_type_ligne_comptable().
*/
function figli_compta_ledger_update_8014() {
$form_display = EntityFormDisplay::load('node.ligne_comptable.default');
if ($form_display) {
foreach ([
'field_montant_releve' => 9,
'field_tva' => 10,
'field_repartition' => 11,
'field_notes' => 12,
'field_flag' => 13,
] as $field_name => $weight) {
$component = $form_display->getComponent($field_name);
if ($component) {
$component['weight'] = $weight;
$form_display->setComponent($field_name, $component);
}
}
$form_display->save();
}
return 'field_montant_releve placé sous Montant TTC (weights décalés).';
}
@@ -55,6 +55,11 @@ ledger_form:
- core/drupal
- core/once
releve_import:
css:
theme:
css/releve-import.css: {}
admin_chrome:
css:
theme:
@@ -29,3 +29,11 @@ figli_compta_ledger.history:
menu_name: admin
parent: system.admin
weight: -8
figli_compta_ledger.releve_import:
title: 'Importer un relevé'
description: 'Créer des lignes brouillon depuis un export CSV bancaire'
route_name: figli_compta_ledger.releve_import_form
menu_name: admin
parent: system.admin
weight: -7
@@ -119,6 +119,37 @@ function figli_compta_ledger_form_alter(&$form, FormStateInterface $form_state,
$form['field_montant_ttc']['widget'][0]['value']['#description'] = t('Calculé automatiquement à partir du montant HT et de la TVA.');
}
// Montant relevé bancaire: the bank statement import's immutable audit
// reference (see PLAN-import-releve-bancaire.md and src/Import/) --
// displayed as plain text, no input box at all: an editable-looking
// box for a value nobody may type wastes space and misleads. Replacing
// the widget with a #type => 'item' element is loss-proof by
// construction: an item submits nothing, and
// WidgetBase::extractFormValues() skips the field entirely when no
// value was submitted (its $key_exists check) -- the stored amount
// survives every save untouched, which is the whole point. Hidden
// entirely on lines that have no bank amount (manually entered ones).
if (isset($form['field_montant_releve'])) {
/** @var \Drupal\node\NodeInterface $entity */
$entity = $form_state->getFormObject()->getEntity();
if (!$entity->get('field_montant_releve')->isEmpty()) {
$montant = (float) $entity->get('field_montant_releve')->value;
// The widget's own #title can't be trusted here (Claro moves the
// label to its form-item wrapper and leaves an empty string on the
// input -- '' isn't caught by ??), so read the configured label.
$field_config = \Drupal\field\Entity\FieldConfig::loadByName('node', $entity->bundle(), 'field_montant_releve');
$form['field_montant_releve']['widget'] = [
'#type' => 'item',
'#title' => $field_config ? $field_config->getLabel() : t('Montant relevé bancaire (€)'),
'#markup' => '<strong class="figli-releve-value">' . number_format($montant, 2, ',', ' ') . ' €</strong>',
'#description' => t("Référence bancaire immuable — renseignée à l'import du relevé, jamais modifiée."),
];
}
else {
$form['field_montant_releve']['#access'] = FALSE;
}
}
// Cotisation diffuseur URSSAF (1,1%) -- only ever relevant for
// "Entrée client" lines (the only type invoiced to a client via a
// devis; see figli_compta_ledger_update_8006()'s docblock), so both
@@ -327,14 +358,21 @@ function figli_compta_ledger_node_form_ajax_submit(array $form, FormStateInterfa
* Historical imports (drush migration scripts) deliberately preserve the
* source spreadsheets' raw data, including known répartition mismatches --
* those get surfaced as visible inconsistencies in the dashboard instead of
* being silently fixed. Set the 'figli_compta_ledger.skip_validation' state
* flag around such a bulk import to bypass this check *and* the Montant TTC
* auto-computation below; new lines entered by associates through the form
* are never exempted from either. field_ecart is the one thing still kept
* in sync even under skip_validation (see below) -- separately, a second
* flag ('figli_compta_ledger.skip_revision') additionally suppresses the
* forced-revision block, used only by figli_compta_ledger_update_8010()'s
* field_ecart backfill.
* being silently fixed. Programmatic saves that need to bypass this check
* (bulk imports, the inline-edit endpoints, the bank statement import) wrap
* their save() in the figli_compta_ledger.skip_validation_context service
* (request-scoped, see \Drupal\figli_compta_ledger\SkipValidationContext).
* The legacy 'figli_compta_ledger.skip_validation' *state* key still works
* for already-shipped migration scripts, but new code must use the service:
* the state key is a site-wide flag, and a concurrent normal form save
* hitting the same window would silently skip validation too (see
* AUDIT-2026-09-09.md §2.2). Either way this bypasses this check *and* the
* Montant TTC auto-computation below; new lines entered by associates
* through the form are never exempted from either. field_ecart is the one
* thing still kept in sync even under skip (see below) -- separately, a
* second state flag ('figli_compta_ledger.skip_revision') additionally
* suppresses the forced-revision block, used only by
* figli_compta_ledger_update_8010()'s field_ecart backfill.
*/
function figli_compta_ledger_node_presave(NodeInterface $node) {
if ($node->bundle() !== 'ligne_comptable') {
@@ -365,7 +403,8 @@ function figli_compta_ledger_node_presave(NodeInterface $node) {
return;
}
$skip_validation = \Drupal::state()->get('figli_compta_ledger.skip_validation', FALSE);
$skip_validation = \Drupal::service('figli_compta_ledger.skip_validation_context')->isSkipped()
|| \Drupal::state()->get('figli_compta_ledger.skip_validation', FALSE);
$montant_ht = (float) $node->get('field_montant_ht')->value;
if (!$skip_validation) {
@@ -474,7 +513,7 @@ function figli_compta_ledger_node_presave(NodeInterface $node) {
function figli_compta_ledger_theme($existing, $type, $theme, $path) {
return [
'figli_compta_home' => [
'variables' => ['can_view_history' => FALSE, 'current_route' => NULL],
'variables' => ['can_view_history' => FALSE, 'can_import_releve' => FALSE, 'current_route' => NULL],
'template' => 'figli-compta-home',
],
'figli_compta_dashboard' => [
@@ -489,6 +528,10 @@ function figli_compta_ledger_theme($existing, $type, $theme, $path) {
'variables' => ['current_route' => NULL],
'template' => 'figli-compta-dashboard-compte',
],
'figli_compta_releve_import_result' => [
'variables' => ['summary' => [], 'lignes_url' => NULL, 'import_url' => NULL],
'template' => 'figli-compta-releve-import-result',
],
];
}
@@ -511,12 +554,32 @@ function figli_compta_ledger_page_attachments(array &$attachments) {
'figli_compta_ledger.dashboard_compte',
'figli_compta_ledger.history',
'figli_compta_ledger.link_entree',
'figli_compta_ledger.releve_import_form',
'figli_compta_ledger.releve_import_result',
];
if (in_array(\Drupal::routeMatch()->getRouteName(), $front_end_routes, TRUE)) {
$attachments['#attached']['library'][] = 'figli_compta_ledger/hide_admin_chrome';
}
}
/**
* Implements hook_gin_ignore_sticky_form_actions().
*
* Gin's sticky action buttons (forced on whenever the core Navigation
* module is active, as here) relocate a form's primary submit into the
* Gin chrome's sticky action bar -- the very chrome this module's
* front-end routes deliberately hide ($front_end_routes in
* figli_compta_ledger_page_attachments()). Without this opt-out, the
* full-page import form ends up with no visible button at all: Gin's
* after-build moves "Importer le relevé" into the hidden bar, leaving
* only managed_file's own inline "Remove" button. The other forms of
* this module don't need it -- node forms and LinkEntreeForm are opened
* in modals, which Gin skips by itself (isModalOrOffcanvas()).
*/
function figli_compta_ledger_gin_ignore_sticky_form_actions(): array {
return ['figli_compta_ledger_releve_upload_form'];
}
/**
* Implements hook_help().
*/
@@ -0,0 +1,9 @@
access figli ledger:
title: 'Accéder au grand livre'
description: 'Consulte les lignes comptables, les tableaux de bord et toutes les API du module (données financières de la SAS).'
restrict access: true
import ligne_comptable releve:
title: 'Importer un relevé bancaire'
description: 'Téléverse un export CSV bancaire et crée des lignes comptables brouillon à trier (crée du contenu).'
restrict access: true
@@ -4,7 +4,7 @@ figli_compta_ledger.home:
_controller: '\Drupal\figli_compta_ledger\Controller\DashboardController::home'
_title: 'Grand livre - SAS Figures Libres'
requirements:
_permission: 'access content'
_permission: 'access figli ledger'
figli_compta_ledger.dashboard:
path: '/dashboard'
@@ -12,7 +12,7 @@ figli_compta_ledger.dashboard:
_controller: '\Drupal\figli_compta_ledger\Controller\DashboardController::view'
_title: 'Tableau de bord - SAS Figures Libres'
requirements:
_permission: 'access content'
_permission: 'access figli ledger'
figli_compta_ledger.dashboard_repartition:
path: '/dashboard/repartition'
@@ -20,7 +20,7 @@ figli_compta_ledger.dashboard_repartition:
_controller: '\Drupal\figli_compta_ledger\Controller\DashboardController::repartitionView'
_title: 'Répartition / Soldes - SAS Figures Libres'
requirements:
_permission: 'access content'
_permission: 'access figli ledger'
figli_compta_ledger.dashboard_compte:
path: '/dashboard/compte'
@@ -28,7 +28,7 @@ figli_compta_ledger.dashboard_compte:
_controller: '\Drupal\figli_compta_ledger\Controller\DashboardController::compteView'
_title: 'Tableau de bord par compte - SAS Figures Libres'
requirements:
_permission: 'access content'
_permission: 'access figli ledger'
figli_compta_ledger.history:
path: '/lignes/historique'
@@ -55,21 +55,21 @@ figli_compta_ledger.api_annees:
defaults:
_controller: '\Drupal\figli_compta_ledger\Controller\LedgerStatsController::annees'
requirements:
_permission: 'access content'
_permission: 'access figli ledger'
figli_compta_ledger.api_reconciliation_ouverture:
path: '/lignes/api/reconciliation-ouverture'
defaults:
_controller: '\Drupal\figli_compta_ledger\Controller\LedgerStatsController::reconciliationOuverture'
requirements:
_permission: 'access content'
_permission: 'access figli ledger'
figli_compta_ledger.api_groupe_entree:
path: '/lignes/api/groupe/{node}'
defaults:
_controller: '\Drupal\figli_compta_ledger\Controller\LedgerStatsController::groupeEntree'
requirements:
_permission: 'access content'
_permission: 'access figli ledger'
options:
parameters:
node:
@@ -80,14 +80,14 @@ figli_compta_ledger.api_lignes:
defaults:
_controller: '\Drupal\figli_compta_ledger\Controller\LedgerRowsController::index'
requirements:
_permission: 'access content'
_permission: 'access figli ledger'
figli_compta_ledger.api_dashboard_stats:
path: '/dashboard/api/stats'
defaults:
_controller: '\Drupal\figli_compta_ledger\Controller\DashboardStatsController::stats'
requirements:
_permission: 'access content'
_permission: 'access figli ledger'
figli_compta_ledger.update_type:
path: '/lignes/{node}/type'
@@ -112,3 +112,19 @@ figli_compta_ledger.update_field:
parameters:
node:
type: entity:node
figli_compta_ledger.releve_import_form:
path: '/lignes/importer-releve'
defaults:
_form: '\Drupal\figli_compta_ledger\Form\ReleveUploadForm'
_title: 'Importer un relevé bancaire'
requirements:
_permission: 'import ligne_comptable releve'
figli_compta_ledger.releve_import_result:
path: '/lignes/importer-releve/resultat'
defaults:
_controller: '\Drupal\figli_compta_ledger\Controller\ReleveImportResultController::result'
_title: "Résultat de l'import du relevé"
requirements:
_permission: 'import ligne_comptable releve'
@@ -3,3 +3,12 @@ services:
class: Drupal\figli_compta_ledger\EventSubscriber\RouteSubscriber
tags:
- { name: event_subscriber }
# Request-scoped répartition-check opt-out (see the class docblock: unlike
# the historical state key, a skip held here is invisible to concurrent
# requests -- audited in AUDIT-2026-09-09.md §2.2).
figli_compta_ledger.skip_validation_context:
class: Drupal\figli_compta_ledger\SkipValidationContext
figli_compta_ledger.client_matcher:
class: Drupal\figli_compta_ledger\Import\ClientMatcher
@@ -18,6 +18,7 @@ class DashboardController extends ControllerBase {
return [
'#theme' => 'figli_compta_home',
'#can_view_history' => $this->currentUser()->hasPermission('view ligne_comptable revisions'),
'#can_import_releve' => $this->currentUser()->hasPermission('import ligne_comptable releve'),
'#current_route' => 'figli_compta_ledger.home',
'#attached' => [
'library' => ['figli_compta_ledger/home'],
@@ -5,8 +5,10 @@ namespace Drupal\figli_compta_ledger\Controller;
use Drupal\Core\Access\CsrfRequestHeaderAccessCheck;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Entity\EntityStorageException;
use Drupal\figli_compta_ledger\SkipValidationContext;
use Drupal\node\NodeInterface;
use Drupal\taxonomy\Entity\Term;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
@@ -23,6 +25,24 @@ use Symfony\Component\HttpFoundation\Request;
*/
class LedgerActionsController extends ControllerBase {
/**
* Request-scoped répartition-check opt-out -- see the class docblock of
* \Drupal\figli_compta_ledger\SkipValidationContext for why this replaced
* the historical global state key here.
*
* @var \Drupal\figli_compta_ledger\SkipValidationContext
*/
protected $skipValidationContext;
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
$instance = parent::create($container);
$instance->skipValidationContext = $container->get('figli_compta_ledger.skip_validation_context');
return $instance;
}
/**
* Fields editable inline from /lignes without opening the full node
* edit form -- keys are the short names the frontend sends; values are
@@ -96,20 +116,17 @@ class LedgerActionsController extends ControllerBase {
// (from historical data, never corrected -- see figli_compta_ledger's
// module docblock) exactly as it was. The présave check exists to
// catch new inconsistent entries, not to block relabeling the type of
// an already-migrated line, so skip it for this save only. try/finally
// guarantees the global flag clears even if save() throws for an
// unrelated reason -- leaving it on would silently skip validation on
// every other save on the site.
\Drupal::state()->set('figli_compta_ledger.skip_validation', TRUE);
// an already-migrated line, so skip it for this save only. The
// SkipValidationContext service is request-scoped with a try/finally
// inside skip(), so the check is back on the instant save() returns
// or throws -- no global flag left hanging that a concurrent save
// from someone else could fall into.
try {
$node->save();
$this->skipValidationContext->skip(fn () => $node->save());
}
catch (EntityStorageException $e) {
return new JsonResponse(['error' => $e->getMessage()], 422);
}
finally {
\Drupal::state()->delete('figli_compta_ledger.skip_validation');
}
return new JsonResponse([
'success' => TRUE,
@@ -167,17 +184,14 @@ class LedgerActionsController extends ControllerBase {
// signalement changes here, montant_ht and field_repartition are
// untouched, so skipping the répartition check for this save can
// never introduce a mismatch -- it can only leave a pre-existing
// historical one exactly as it was.
\Drupal::state()->set('figli_compta_ledger.skip_validation', TRUE);
// historical one exactly as it was. Request-scoped skip (see
// updateType()'s comment), no global flag.
try {
$node->save();
$this->skipValidationContext->skip(fn () => $node->save());
}
catch (EntityStorageException $e) {
return new JsonResponse(['error' => $e->getMessage()], 422);
}
finally {
\Drupal::state()->delete('figli_compta_ledger.skip_validation');
}
if ($field === 'client') {
$newValue = $node->get('field_client')->entity ? $node->get('field_client')->entity->label() : NULL;
@@ -0,0 +1,72 @@
<?php
namespace Drupal\figli_compta_ledger\Controller;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Routing\LocalRedirectResponse;
use Drupal\Core\Url;
/**
* Post-import summary page: counts, the accounting control total
* (file total == created + duplicates, to the centime), the list of
* duplicates skipped for human review, and a deep link into /lignes
* filtered on this batch's "IMP AAMMJJ" tag.
*
* The numbers live one-shot in the private tempstore (written by
* ReleveImportBatch::finished(), read + purged here): the page is only
* meaningful right after an import, and every user sees their own.
*/
class ReleveImportResultController extends ControllerBase {
/**
* Renders the summary of the import that just ran.
*/
public function result() {
$store = \Drupal::service('tempstore.private')->get('figli_compta_ledger');
$summary = $store->get('releve_import_result');
if (!$summary) {
// Direct navigation (bookmark, back button long after the import):
// no numbers in memory anymore, send back to the form instead of
// showing an empty shell.
$this->messenger()->addWarning($this->t("Le résultat d'un import n'est disponible qu'immédiatement après l'import lui-même."));
return new LocalRedirectResponse(Url::fromRoute('figli_compta_ledger.releve_import_form')->toString());
}
$store->delete('releve_import_result');
$eur = fn ($x) => number_format((float) $x, 2, ',', ' ') . ' €';
$fr_date = fn ($iso) => preg_replace('/^(\d{4})-(\d{2})-(\d{2})$/', '$3/$2/$1', (string) $iso);
$view = [
'file_name' => $summary['file_name'],
'tag' => $summary['tag'],
'created' => (int) $summary['created'],
'duplicates' => (int) $summary['duplicates'],
'matched' => (int) $summary['matched'],
'unmatched' => (int) $summary['unmatched'],
'errors_count' => count($summary['errors']),
'errors' => array_map(fn ($e) => $e['libelle'] . ' — ' . $e['error'], $summary['errors']),
'duplicates_list' => array_map(fn ($d) => [
'date' => $fr_date($d['date']),
'montant' => $eur($d['montant']),
'libelle' => $d['libelle'],
], $summary['duplicates_list']),
'file_total' => $eur($summary['file_total']),
'created_total' => $eur($summary['created_total']),
'duplicates_total' => $eur($summary['duplicates_total']),
'totals_ok' => (bool) $summary['totals_ok'],
];
return [
'#theme' => 'figli_compta_releve_import_result',
'#summary' => $view,
// /lignes reads its filter state from location.hash -- the flag
// filter key is "tag" (see readHashState() in js/home.js), values
// are comma-separated flag names.
'#lignes_url' => Url::fromRoute('figli_compta_ledger.home')->toString() . '#tag=' . rawurlencode($summary['tag']),
'#import_url' => Url::fromRoute('figli_compta_ledger.releve_import_form')->toString(),
'#attached' => ['library' => ['figli_compta_ledger/releve_import']],
'#cache' => ['max-age' => 0],
];
}
}
@@ -24,7 +24,11 @@ class RouteSubscriber extends RouteSubscriberBase {
*/
protected function alterRoutes(RouteCollection $collection) {
if ($route = $collection->get('system.entity_autocomplete')) {
$route->setRequirement('_permission', 'access content');
// 'access figli ledger' rather than 'access content': the generic
// Authenticated role no longer holds the latter (removed 2026-09),
// and anyone entitled to see ledger autocomplete suggestions must
// be entitled to the ledger's data itself.
$route->setRequirement('_permission', 'access figli ledger');
}
}
@@ -0,0 +1,184 @@
<?php
namespace Drupal\figli_compta_ledger\Form;
use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\file\Entity\File;
use Drupal\figli_compta_ledger\Import\CsvReleveParser;
use Drupal\figli_compta_ledger\Import\ReleveImportBatch;
use Drupal\figli_compta_ledger\Import\ReleveTransaction;
use Drupal\taxonomy\Entity\Term;
/**
* Upload form for a bank statement export (CSV v1 -- see
* PLAN-import-releve-bancaire.md). Self-service for the associates: the
* file lands in the *private* filesystem (financial data), is parsed in
* memory, deduplicated count-aware against lines already in base, then
* turned into "à trier" draft lines by ReleveImportBatch. Nothing here
* writes ledger lines directly -- everything goes through the normal
* Node::save() lifecycle under SkipValidationContext.
*/
class ReleveUploadForm extends FormBase {
/**
* {@inheritdoc}
*/
public function getFormId(): string {
return 'figli_compta_ledger_releve_upload_form';
}
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state): array {
$form['releve_file'] = [
'#type' => 'managed_file',
'#title' => $this->t('Relevé bancaire (CSV)'),
'#upload_location' => 'private://releves',
// Only .csv: the OFX/CMI exports of the same account exist but
// are explicitly out of v1 scope (truncated labels / unstable
// structure -- see the plan). A clear message beats a silent
// failure for someone uploading them by mistake.
'#upload_validators' => [
'FileExtension' => ['extensions' => 'csv'],
],
'#required' => TRUE,
'#description' => $this->t('Export CSV de la banque : colonnes « Date ; Date de valeur ; Débit ; Crédit ; Libellé ; Solde » (les fichiers .ofx et .cmi ne sont pas pris en charge pour l\'instant). Chaque transaction devient une ligne « à trier » : type, répartition et HT/TVA restent à assigner à la main.'),
];
$form['actions'] = ['#type' => 'actions'];
$form['actions']['submit'] = [
'#type' => 'submit',
'#value' => $this->t('Importer le relevé'),
'#button_type' => 'primary',
];
return $form;
}
/**
* {@inheritdoc}
*
* The idiomatic home for the parse: an invalid file is a validation
* error, rejected before anything is written (the file entity is only
* promoted in submitForm()). The parsed transactions are stashed in
* $form_state so the file is never parsed twice.
*/
public function validateForm(array &$form, FormStateInterface $form_state): void {
$fids = $form_state->getValue('releve_file');
$fids = is_array($fids) ? $fids : [];
if (!$fids) {
// #required already covers the empty case.
return;
}
$file = File::load(reset($fids));
if (!$file) {
$form_state->setErrorByName('releve_file', $this->t("Le fichier téléversé n'a pas pu être retrouvé."));
return;
}
// Parse (pure, no writes). A clean form error -- never a crash page
// -- for anything the parser rejects.
try {
$transactions = (new CsvReleveParser())->parse($file->getFileUri());
}
catch (\RuntimeException $e) {
$form_state->setErrorByName('releve_file', $e->getMessage());
return;
}
$form_state->set('releve_fid', (int) $file->id());
$form_state->set('releve_transactions', array_map(fn ($t) => $t->toArray(), $transactions));
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state): void {
$file = File::load($form_state->get('releve_fid'));
$transactions = array_map([ReleveTransaction::class, 'fromArray'], $form_state->get('releve_transactions') ?: []);
if (!$file || !$transactions) {
// Normally unreachable -- validateForm() blocks bad files before
// submit is reached. Defensive only.
$form_state->setErrorByName('releve_file', $this->t("Rien à importer : relancez l'upload."));
return;
}
// Keep the uploaded statement permanently + registered as our usage:
// it's accounting source material, cron must not garbage-collect it
// after a few hours as it would a temporary file.
$file->setPermanent();
$file->save();
\Drupal::service('file.usage')->add($file, 'figli_compta_ledger', 'releve_import', (int) $file->id());
// Count-aware dedup: how many times each fingerprint appears in this
// file (k), one grouped query for how many already exist in base
// (m, all provenances combined), quota = max(0, k m).
$counts = [];
foreach ($transactions as $t) {
$counts[$t->fitid] = ($counts[$t->fitid] ?? 0) + 1;
}
$db_counts = [];
if ($counts) {
$select = \Drupal::database()->select('node__field_import_fitid', 'f')
->condition('f.field_import_fitid_value', array_keys($counts), 'IN');
$select->addField('f', 'field_import_fitid_value', 'fitid');
$select->addExpression('COUNT(*)', 'n');
$select->groupBy('f.field_import_fitid_value');
foreach ($select->execute()->fetchAllKeyed() as $fitid => $n) {
$db_counts[$fitid] = (int) $n;
}
}
$quotas = [];
foreach ($counts as $fitid => $k) {
$quotas[$fitid] = max(0, $k - ($db_counts[$fitid] ?? 0));
}
// One "IMP AAMMJJ" flag term per import batch (day granularity: the
// same day's re-imports join the same lot) -- short on purpose, it
// renders as a badge in /lignes' narrow Signalement column. The
// associates sort lines through the existing signalement mechanism
// (filter + amber marker), zero new UI.
$tag = 'IMP ' . date('ymd');
$terms = \Drupal::entityTypeManager()->getStorage('taxonomy_term')
->loadByProperties(['vid' => 'flag', 'name' => $tag]);
if ($terms) {
$term = reset($terms);
}
else {
$term = Term::create(['vid' => 'flag', 'name' => $tag]);
$term->save();
}
$file_total = 0.0;
$payload = [];
foreach ($transactions as $t) {
$file_total += $t->montant;
$payload[] = $t->toArray();
}
batch_set([
'title' => $this->t('Import du relevé bancaire'),
'operations' => [
[
[ReleveImportBatch::class, 'operation'],
[$payload, $quotas, [
'file_name' => $file->getFilename(),
'file_total' => round($file_total, 2),
'tag' => $tag,
'flag_tid' => (int) $term->id(),
]],
],
],
'finished' => [ReleveImportBatch::class, 'finished'],
'init_message' => $this->t('Import du relevé en cours…'),
'progress_message' => $this->t('@current/@total'),
'error_message' => $this->t('L\'import a rencontré une erreur inattendue.'),
]);
// Where the browser lands once the batch is done -- the result page
// reads its numbers from the private tempstore.
$form_state->setRedirect('figli_compta_ledger.releve_import_result');
}
}
@@ -0,0 +1,144 @@
<?php
namespace Drupal\figli_compta_ledger\Import;
use Drupal\taxonomy\Entity\Term;
/**
* Suggests which client term a bank label refers to -- conservatively:
* an empty match a human fills in beats a wrong match nobody re-checks
* (the same philosophy as the whole import feature: pre-fill, never
* decide).
*
* Matching is word-based, never raw substring: after normalization
* (uppercase, accents removed, non-alphanumerics as separators) a
* client's full name must appear as a contiguous word *sequence* in the
* label ("OVH SAS" matches "PRLV SEPA OVH SAS TWLN…" but a hypothetical
* client "AIR" would NOT match "CLAIR" -- the v0 substring draft of this
* plan had exactly that false-positive mode for short names).
*
* Pass 1: full normalized client name as contiguous word sequence. Two
* distinct clients matching is ambiguous empty.
* Pass 2 (only if pass 1 found nothing): a single "significant" word
* ( 4 chars, not a legal-form filler like SAS/SARL) that belongs to
* exactly ONE client in the whole vocabulary. Several candidate clients
* empty. Deliberately recall-biased: a generic-but-unique word (say
* "MAISON", held by a single client) can suggest the wrong client for
* an unrelated label -- acceptable because every imported line is
* flagged and manually sorted (see ReleveImportBatch), so a wrong
* suggestion gets corrected by a human rather than trusted.
*/
final class ClientMatcher {
/**
* Legal-form filler words never significant on their own.
*/
private const STOPWORDS = ['SAS', 'SARL', 'SA', 'SASU', 'EURL', 'SCI', 'SCOP', 'ASSOCIATION', 'GMBH', 'SNC'];
/**
* Loaded client vocabulary, shape: [['term' => Term, 'words' => string[]]].
*
* @var array|null
*/
private ?array $clients = NULL;
/**
* Returns the client term a bank label most likely refers to, or NULL
* when nothing safe can be said. Never creates a term (unlike flag
* auto-creation) -- the client vocabulary stays curated by hand.
*/
public function match(string $libelle): ?Term {
$words = $this->words($libelle);
if (!$words) {
return NULL;
}
$clients = $this->loadClients();
if (!$clients) {
return NULL;
}
// Pass 1: full name as a contiguous word sequence, unique candidate.
$pass1 = [];
foreach ($clients as $client) {
if (self::containsSequence($words, $client['words'])) {
$pass1[$client['term']->id()] = $client['term'];
}
}
if (count($pass1) === 1) {
return reset($pass1);
}
if (count($pass1) > 1) {
return NULL;
}
// Pass 2: a significant word owned by exactly one client vocabulary.
$pass2 = [];
foreach ($clients as $client) {
foreach ($client['words'] as $word) {
if (mb_strlen($word) < 4 || in_array($word, self::STOPWORDS, TRUE)) {
continue;
}
if (in_array($word, $words, TRUE)) {
$pass2[$client['term']->id()] = $client['term'];
break;
}
}
}
if (count($pass2) === 1) {
return reset($pass2);
}
return NULL;
}
/**
* Uppercase, accent-free word tokens: "EPAU / POPSU" ["EPAU","POPSU"].
*
* @return string[]
*/
private function words(string $text): array {
$transliterated = \Drupal::transliteration()->transliterate($text, 'fr');
$upper = mb_strtoupper($transliterated);
$words = preg_split('/[^A-Z0-9]+/', $upper, -1, PREG_SPLIT_NO_EMPTY);
return $words === FALSE ? [] : $words;
}
/**
* Loads (once per request) every client term with its normalized words.
*/
private function loadClients(): array {
if ($this->clients !== NULL) {
return $this->clients;
}
$this->clients = [];
$terms = \Drupal::entityTypeManager()->getStorage('taxonomy_term')
->loadByProperties(['vid' => 'client']);
foreach ($terms as $term) {
$words = $this->words($term->label());
if ($words) {
$this->clients[] = ['term' => $term, 'words' => $words];
}
}
return $this->clients;
}
/**
* Whether $needle appears in $haystack as a contiguous word sequence.
*/
private static function containsSequence(array $haystack, array $needle): bool {
$n = count($needle);
$h = count($haystack);
if ($n === 0 || $n > $h) {
return FALSE;
}
for ($i = 0; $i <= $h - $n; $i++) {
for ($j = 0; $j < $n; $j++) {
if ($haystack[$i + $j] !== $needle[$j]) {
continue 2;
}
}
return TRUE;
}
return FALSE;
}
}
@@ -0,0 +1,160 @@
<?php
namespace Drupal\figli_compta_ledger\Import;
/**
* Parses the CSV export of the SAS bank account into ReleveTransaction
* objects. Built from (and verified against) the real sample in
* sources-compta/extrais de comptes/00021322002.csv: `;`-separated,
* ISO-8859-1 encoded, columns "Date;Date de valeur;Débit;Crédit;Libellé;
* Solde", dates JJ/MM/AAAA, French decimal comma, exactly one of
* Débit/Crédit filled per row. The Solde column is ignored (no balance
* reconciliation in v1).
*
* Any deviation (wrong header, unparsable date/amount, empty label) throws
* a RuntimeException with a clear, user-facing French message -- the upload
* form catches it and shows a form error, never a raw crash page. Nothing
* is written to the database from here: parsing is a pure in-memory step.
*/
final class CsvReleveParser {
/**
* The exact header (after ISO-8859-1 UTF-8 conversion) a file must
* carry to be considered a supported statement export.
*/
private const HEADER = ['Date', 'Date de valeur', 'Débit', 'Crédit', 'Libellé', 'Solde'];
/**
* Parses a file by URI (any stream wrapper, typically private://).
*
* @return \Drupal\figli_compta_ledger\Import\ReleveTransaction[]
* Every data row as a transaction, in file order.
*
* @throws \RuntimeException
* With a ready-to-display message when the file isn't a supported
* statement export.
*/
public function parse(string $uri): array {
$stream = @fopen($uri, 'r');
if ($stream === FALSE) {
throw new \RuntimeException("Le fichier téléversé n'a pas pu être relu depuis le stockage privé.");
}
// Confirmed ISO-8859-1 on the real sample: a naive UTF-8 read would
// corrupt every accented label -- the one truly silent bug risk of
// this parser. The filter converts as fgetcsv() reads.
stream_filter_append($stream, 'convert.iconv.ISO-8859-1/UTF-8');
// Explicit enclosure + empty $escape: PHP 8.4 deprecates relying on
// the default escape (backslash), whose legacy behavior would let a
// stray "\" in a bank label swallow the next character -- with ''
// the bank's own quotes stay the only special characters.
$header = fgetcsv($stream, NULL, ';', '"', '');
if ($header === FALSE) {
fclose($stream);
throw new \RuntimeException('Le fichier est vide.');
}
$header = array_map(fn ($h) => trim((string) $h), $header);
if ($header !== self::HEADER) {
fclose($stream);
throw new \RuntimeException('Format de fichier non reconnu. En-tête attendu : « ' . implode(';', self::HEADER) . ' ». Seul l\'export CSV de la banque est pris en charge pour l\'instant (.ofx et .cmi non encore).');
}
$transactions = [];
$line = 1;
while (($row = fgetcsv($stream, NULL, ';', '"', '')) !== FALSE) {
$line++;
// Fully blank rows are just padding at the end of some exports.
if (trim(implode('', array_map('strval', $row))) === '') {
continue;
}
if (count($row) < 6) {
fclose($stream);
throw new \RuntimeException("Ligne $line : nombre de colonnes inattendu (" . count($row) . ", 6 attendues).");
}
$dateRaw = trim((string) $row[0]);
if (!preg_match('/^(\d{2})\/(\d{2})\/(\d{4})$/', $dateRaw, $m) || !checkdate((int) $m[2], (int) $m[1], (int) $m[3])) {
fclose($stream);
throw new \RuntimeException("Ligne $line : date « $dateRaw » invalide (JJ/MM/AAAA attendu).");
}
$date = $m[3] . '-' . $m[2] . '-' . $m[1];
$debit = trim((string) $row[2]);
$credit = trim((string) $row[3]);
if ($debit !== '' && $credit !== '') {
fclose($stream);
throw new \RuntimeException("Ligne $line : Débit et Crédit renseignés simultanément, format inattendu.");
}
// A Débit is money out whatever sign the bank exported it with
// (the sample already stores it negative; -abs() normalizes any
// sibling export that doesn't), a Crédit is money in.
if ($debit !== '') {
$montant = -abs($this->parseAmount($debit, $line));
}
elseif ($credit !== '') {
$montant = abs($this->parseAmount($credit, $line));
}
else {
fclose($stream);
throw new \RuntimeException("Ligne $line : ni Débit ni Crédit renseigné.");
}
$libelle = self::normalizeLibelle((string) $row[4]);
if ($libelle === '') {
fclose($stream);
throw new \RuntimeException("Ligne $line : libellé vide, impossible de tracer la transaction.");
}
$transactions[] = new ReleveTransaction(
$date,
$montant,
$libelle,
self::fitid($date, $montant, $libelle),
);
}
fclose($stream);
if (!$transactions) {
throw new \RuntimeException("Aucune transaction trouvée dans le fichier (en-tête seul).");
}
return $transactions;
}
/**
* Fingerprint of one transaction: 'csv:' + sha1(date | signed amount to
* the centime | whitespace-normalized label). No case-folding -- two
* exports of the same account reproduce labels byte for byte, and the
* fingerprint must stay stable for the count-aware dedup to recognize
* an already-imported transaction years later.
*
* The amount goes in as a fixed 2-decimal string ("1234.56") so no
* floating-point representation ever enters the hash.
*/
public static function fitid(string $date, float $montant, string $normalizedLibelle): string {
return 'csv:' . sha1($date . '|' . number_format($montant, 2, '.', '') . '|' . $normalizedLibelle);
}
/**
* Trim + collapse internal whitespace runs to one space: stray double
* spaces would otherwise make the same transaction fingerprint
* differently across two exports of the same account.
*/
public static function normalizeLibelle(string $libelle): string {
return trim((string) preg_replace('/\s+/u', ' ', $libelle));
}
/**
* French decimal ("1 234,56", "-45,89") float, with a hard format
* check -- anything unexpected rejects the whole file with the line
* number rather than being silently coerced.
*/
private function parseAmount(string $raw, int $line): float {
$clean = str_replace([' ', "\xC2\xA0"], '', $raw);
$clean = str_replace(',', '.', $clean);
if (!preg_match('/^[+-]?\d+(\.\d+)?$/', $clean)) {
throw new \RuntimeException("Ligne $line : montant « $raw » invalide.");
}
return (float) $clean;
}
}
@@ -0,0 +1,181 @@
<?php
namespace Drupal\figli_compta_ledger\Import;
use Drupal\Core\Entity\EntityStorageException;
use Drupal\node\Entity\Node;
use Drupal\taxonomy\Entity\Term;
/**
* Batch backend of the bank statement import: turns the parsed
* transactions into ligne_comptable nodes, ~25 per PHP-FPM request
* (several hundred transactions would blow the memory/time budget of a
* single request -- no Batch API precedent existed in this module
* before this).
*
* Count-aware dedup (see PLAN-import-releve-bancaire.md): for every
* fingerprint, the file tells how many times the transaction appears (k)
* and the database how many are already imported (m) -- the batch then
* creates max(0, k m) lines. That imports every legitimate duplicate
* (two identical transfers the same day) while still recognizing an
* already-imported transaction across overlapping files.
*
* Every created line goes through SkipValidationContext (request-scoped,
* NOT the historical state key): répartition is deliberately empty, so
* the presave invariant sum(répartition) == montant_ht must not fire --
* field_ecart (= montant_ht) still gets computed and drives the existing
* "à trier" red marker on /lignes.
*/
final class ReleveImportBatch {
/**
* Transactions processed per batch step.
*/
const CHUNK = 25;
/**
* Batch operation -- called repeatedly by Drupal until finished.
*
* @param array $transactions
* ReleveTransaction::toArray() payloads, in file order.
* @param array $quotas
* fitid => remaining lines to create (k m, floored at 0).
* @param array $meta
* Immutable import metadata: file_name, file_total (sum of every
* transaction's signed amount, the control total), tag (flag term
* name), flag_tid.
* @param array $context
* Batch context (sandbox holds index + mutable quotas, results hold
* the accumulators finished() assembles the summary from).
*/
public static function operation(array $transactions, array $quotas, array $meta, array &$context): void {
if (!isset($context['sandbox']['index'])) {
$context['sandbox']['index'] = 0;
$context['sandbox']['total'] = count($transactions);
$context['sandbox']['quotas'] = $quotas;
// Seed results with the immutable import metadata (no key
// collision with the accumulators) + the zeroed accumulators.
$context['results'] += $meta + [
'created' => 0,
'duplicates' => 0,
'matched' => 0,
'unmatched' => 0,
'created_total' => 0.0,
'duplicates_total' => 0.0,
'duplicates_list' => [],
'errors' => [],
];
}
/** @var \Drupal\figli_compta_ledger\SkipValidationContext $skip */
$skip = \Drupal::service('figli_compta_ledger.skip_validation_context');
/** @var \Drupal\figli_compta_ledger\Import\ClientMatcher $matcher */
$matcher = \Drupal::service('figli_compta_ledger.client_matcher');
$end = min($context['sandbox']['index'] + self::CHUNK, $context['sandbox']['total']);
while ($context['sandbox']['index'] < $end) {
$t = ReleveTransaction::fromArray($transactions[$context['sandbox']['index']]);
// Count-aware dedup: quota exhausted → already in base (this many
// times), skip but surface it on the result page for human review.
if (($context['sandbox']['quotas'][$t->fitid] ?? 0) <= 0) {
$context['results']['duplicates']++;
$context['results']['duplicates_total'] += $t->montant;
$context['results']['duplicates_list'][] = [
'date' => $t->date,
'montant' => $t->montant,
'libelle' => $t->libelle,
];
$context['sandbox']['index']++;
continue;
}
$context['sandbox']['quotas'][$t->fitid]--;
$client = $matcher->match($t->libelle);
// Sensible truncate for the required title field: the full label
// lives in field_notes, the title only backs it up as fallback
// (same libelle display rule as everywhere in /lignes).
$node = Node::create([
'type' => 'ligne_comptable',
'title' => mb_substr($t->libelle, 0, 255),
'uid' => \Drupal::currentUser()->id(),
'status' => 1,
'field_date_ligne' => $t->date,
'field_type_ligne' => 'autre',
// Immutable audit reference (written here, never again), plus
// the three "same value to start with" fields the associate
// corrects while sorting (see PLAN's HT vs TTC section).
'field_montant_releve' => $t->montant,
'field_montant_ht' => $t->montant,
'field_montant_ttc' => $t->montant,
'field_tva' => 0,
'field_notes' => $t->libelle,
'field_import_fitid' => $t->fitid,
'field_client' => $client ? $client->id() : NULL,
'field_flag' => [$meta['flag_tid']],
]);
try {
$skip->skip(fn () => $node->save());
$context['results']['created']++;
$context['results']['created_total'] += $t->montant;
$client ? $context['results']['matched']++ : $context['results']['unmatched']++;
}
catch (EntityStorageException $e) {
$context['results']['errors'][] = [
'libelle' => $t->libelle,
'error' => $e->getMessage(),
];
}
$context['sandbox']['index']++;
}
$context['message'] = t('Import du relevé : @done/@total transactions', [
'@done' => $context['sandbox']['index'],
'@total' => $context['sandbox']['total'],
]);
$context['finished'] = $context['sandbox']['total'] > 0
? $context['sandbox']['index'] / $context['sandbox']['total']
: 1;
}
/**
* Batch finished callback: assembles the summary the result page
* reads -- including the accounting control total (file total must
* equal created + duplicates, to the centime; if not, a parsing bug
* silently ate a line somewhere, and the page says so loudly) -- and
* stores it in the private tempstore (per-user, request-safe), where
* ReleveImportResultController picks it up once and purges it.
*/
public static function finished(bool $success, array $results, array $operations): void {
if (!$success) {
\Drupal::messenger()->addError("L'import a échoué à mi-parcours. Les transactions déjà traitées sont enregistrées ; relancez l'import du même fichier, le dédoublonnage ne recréera que ce qui manque.");
return;
}
$created_total = round((float) ($results['created_total'] ?? 0.0), 2);
$duplicates_total = round((float) ($results['duplicates_total'] ?? 0.0), 2);
$file_total = round((float) ($results['file_total'] ?? 0.0), 2);
$summary = [
'file_name' => (string) ($results['file_name'] ?? ''),
'tag' => (string) ($results['tag'] ?? ''),
'created' => (int) ($results['created'] ?? 0),
'duplicates' => (int) ($results['duplicates'] ?? 0),
'matched' => (int) ($results['matched'] ?? 0),
'unmatched' => (int) ($results['unmatched'] ?? 0),
'errors' => $results['errors'] ?? [],
'duplicates_list' => $results['duplicates_list'] ?? [],
'file_total' => $file_total,
'created_total' => $created_total,
'duplicates_total' => $duplicates_total,
// To the centime: every parsed transaction was either created or
// recognized as already in base. Any drift means a line vanished
// -- never expected, always announced.
'totals_ok' => abs($file_total - $created_total - $duplicates_total) < 0.005,
];
\Drupal::service('tempstore.private')->get('figli_compta_ledger')
->set('releve_import_result', $summary);
}
}
@@ -0,0 +1,47 @@
<?php
namespace Drupal\figli_compta_ledger\Import;
/**
* One bank statement transaction, in the neutral shape every parser
* (CSV today, OFX/CMI maybe later) produces -- the rest of the import
* chain (dedup, client matching, node creation) only ever sees this.
*
* $date: AAAA-MM-JJ (converted from the bank format at parse time).
* $montant: signed, negative = money out (Débit), to the centime.
* $libelle: raw bank label (full, untruncated in CSV), the basis for
* client matching and the line's visible Notes.
* $fitid: dedup fingerprint, see CsvReleveParser::fitid().
*/
final class ReleveTransaction {
public function __construct(
public readonly string $date,
public readonly float $montant,
public readonly string $libelle,
public readonly string $fitid,
) {}
/**
* Plain-array shape for Batch API serialization (operation args and
* sandbox are serialized between requests).
*/
public function toArray(): array {
return [
'date' => $this->date,
'montant' => $this->montant,
'libelle' => $this->libelle,
'fitid' => $this->fitid,
];
}
public static function fromArray(array $values): self {
return new self(
(string) $values['date'],
(float) $values['montant'],
(string) $values['libelle'],
(string) $values['fitid'],
);
}
}
@@ -0,0 +1,64 @@
<?php
namespace Drupal\figli_compta_ledger;
/**
* Request-scoped opt-out of the répartition invariant check in
* figli_compta_ledger_node_presave().
*
* The historical state key ('figli_compta_ledger.skip_validation') is a flag
* shared by every request on the site, stored in the database: while a
* programmatic save (inline type change, bank statement import) holds it,
* a *concurrent* normal form save happening in another PHP-FPM request would
* silently skip validation too -- exactly the kind of hole an integrity
* check must never have. This service lives in the dependency injection
* container of its own request, so a skip here is physically invisible to
* every other request; the depth counter makes nested skips safe and the
* try/finally in skip() guarantees it unwinds on exceptions as well as on
* normal completion.
*
* The state key is still honored by node_presave() for backward compatibility
* with already-shipped migration scripts, but new code (batch imports,
* future migrations) must use this service instead.
*/
final class SkipValidationContext {
/**
* Current skip depth (0 = validation active).
*
* @var int
*/
private int $depth = 0;
/**
* Runs $operation with the répartition-sum check disabled for this
* request only, restoring it afterwards whatever happens.
*
* @param callable $operation
* Typically fn () => $node->save().
*
* @return mixed
* Whatever $operation returns.
*/
public function skip(callable $operation): mixed {
$this->depth++;
try {
return $operation();
}
finally {
$this->depth--;
}
}
/**
* Whether the répartition-sum check is currently disabled for this
* request. Read by figli_compta_ledger_node_presave().
*
* @return bool
* TRUE when a skip() is currently in progress.
*/
public function isSkipped(): bool {
return $this->depth > 0;
}
}
@@ -14,12 +14,6 @@
{% verbatim %}
<div id="figli-home-app">
<div class="figli-toolbar">
<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
<span class="figli-filter-row">
@@ -265,8 +259,16 @@
</tbody>
<tfoot>
<tr class="figli-totals-row">
<td class="actions-col"></td>
<td colspan="6">
<td colspan="7">
<a href="/node/add/ligne_comptable" class="button button--primary" @click.prevent="openAddForm">+ Ajouter une ligne</a>
{% endverbatim %}
{% if can_import_releve %}
<a href="{{ path('figli_compta_ledger.releve_import_form') }}" class="button">Importer un relevé</a>
{% endif %}
{% if can_view_history %}
<a href="{{ path('figli_compta_ledger.history') }}" class="button">Historique</a>
{% endif %}
{% verbatim %}
Solde {{ currentYear || '…' }} (créditeur / débiteur)
<span v-if="currentYearLoading" class="figli-note">chargement…</span>
</td>
@@ -337,8 +339,7 @@
</tbody>
<tfoot>
<tr class="figli-totals-row">
<td class="actions-col"></td>
<td colspan="5">Solde entrées et sorties liées (créditeur / débiteur)</td>
<td colspan="6">Solde entrées et sorties liées (créditeur / débiteur)</td>
<td class="amount">{{ drilldownTotals ? formatEur(drilldownTotals.montant_ht) : '' }}</td>
<td class="amount">{{ drilldownTotals ? formatEur(drilldownTotals.montant_ttc) : '' }}</td>
<td v-for="c in modalComptes" :key="c" class="amount compte-col" :class="drilldownTotals ? soldeClass(drilldownTotals.par_compte[c]) : ''">{{ drilldownTotals && drilldownTotals.par_compte[c] !== undefined ? formatEur(drilldownTotals.par_compte[c]) : '' }}</td>
@@ -0,0 +1,80 @@
{#
Résultat d'import d'un relevé bancaire (ReleveImportResultController).
Page simple sans Vue : Twig rend tout, les chiffres viennent du tempstore
privé (une seule lecture, purgée aussitôt). Mêmes classes de navigation
que les autres pages du module (figli-page-nav, admin-chrome.css).
#}
<nav class="figli-page-nav">
<a href="{{ path('figli_compta_ledger.home') }}">Grand livre</a>
<a href="{{ path('figli_compta_ledger.dashboard') }}">SAS</a>
<a href="{{ path('figli_compta_ledger.dashboard_repartition') }}">Répartition/Soldes</a>
<a href="{{ path('figli_compta_ledger.dashboard_compte') }}">Par compte</a>
</nav>
<div class="figli-releve-result">
<h2>Import terminé — {{ summary.file_name }}</h2>
<p class="figli-releve-tag">Chaque ligne créée porte le signalement
<span class="figli-flag-badge">{{ summary.tag }}</span> et apparaît sur
<a href="{{ lignes_url }}">/lignes</a> avec un liseré rouge (répartition à faire) tant qu'elle n'a pas été triée.</p>
<div class="figli-releve-stats">
<div class="figli-releve-stat">
<span class="figli-releve-stat-value">{{ summary.created }}</span>
<span class="figli-releve-stat-label">lignes créées</span>
</div>
<div class="figli-releve-stat">
<span class="figli-releve-stat-value">{{ summary.duplicates }}</span>
<span class="figli-releve-stat-label">doublons déjà en base (ignorés)</span>
</div>
<div class="figli-releve-stat">
<span class="figli-releve-stat-value">{{ summary.matched }}</span>
<span class="figli-releve-stat-label">clients rapprochés</span>
</div>
<div class="figli-releve-stat">
<span class="figli-releve-stat-value">{{ summary.unmatched }}</span>
<span class="figli-releve-stat-label">sans client (à remplir)</span>
</div>
</div>
<h3>Totaux de contrôle</h3>
<table class="figli-releve-totals">
<tbody>
<tr><th scope="row">Somme des mouvements du fichier</th><td class="figli-releve-amount">{{ summary.file_total }}</td></tr>
<tr><th scope="row">Somme des lignes créées</th><td class="figli-releve-amount">{{ summary.created_total }}</td></tr>
<tr><th scope="row">Somme des doublons ignorés</th><td class="figli-releve-amount">{{ summary.duplicates_total }}</td></tr>
</tbody>
</table>
{% if summary.totals_ok %}
<p class="figli-releve-ok">Équilibre vérifié au centime : aucun mouvement perdu ni dupliqué.</p>
{% else %}
<p class="figli-releve-alert">Anomalie de parsing : la somme des mouvements du fichier ne correspond pas à « créées + doublons ». Vérifiez le fichier source et les lignes importées avant de trier le lot.</p>
{% endif %}
{% if summary.errors_count %}
<h3>Erreurs ({{ summary.errors_count }})</h3>
<ul class="figli-releve-errors">
{% for e in summary.errors %}<li>{{ e }}</li>{% endfor %}
</ul>
{% endif %}
{% if summary.duplicates_list %}
<h3>Doublons ignorés ({{ summary.duplicates_list|length }})</h3>
<p>Ces transactions figuraient déjà en base (empreinte identique, toutes provenances confondues). Vérifiez la liste si vous attendiez un mouvement.</p>
<div class="figli-releve-dup-wrap">
<table class="figli-releve-dups">
<thead>
<tr><th scope="col">Date</th><th scope="col">Montant</th><th scope="col">Libellé</th></tr>
</thead>
<tbody>
{% for d in summary.duplicates_list %}
<tr><td>{{ d.date }}</td><td class="figli-releve-amount">{{ d.montant }}</td><td>{{ d.libelle }}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<div class="figli-releve-actions">
<a class="button button--primary" href="{{ lignes_url }}">Voir les lignes importées sur /lignes</a>
<a class="button" href="{{ import_url }}">Importer un autre relevé</a>
</div>
</div>