diff --git a/.gitignore b/.gitignore index c3176d9..2776dd0 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ /web/sites/*/files/ /web/sites/*/settings.local.php /web/sites/*/settings.php +/private/ .env diff --git a/config/sync/user.role.admin.yml b/config/sync/user.role.admin.yml index 4a4ee65..53b5655 100644 --- a/config/sync/user.role.admin.yml +++ b/config/sync/user.role.admin.yml @@ -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' diff --git a/config/sync/user.role.authenticated.yml b/config/sync/user.role.authenticated.yml index f35ed48..7d21d31 100644 --- a/config/sync/user.role.authenticated.yml +++ b/config/sync/user.role.authenticated.yml @@ -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' diff --git a/config/sync/user.role.editeur.yml b/config/sync/user.role.editeur.yml index 8c9cd99..b1087b8 100644 --- a/config/sync/user.role.editeur.yml +++ b/config/sync/user.role.editeur.yml @@ -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' diff --git a/config/sync/user.role.user.yml b/config/sync/user.role.user.yml index 87d0f3d..5667cec 100644 --- a/config/sync/user.role.user.yml +++ b/config/sync/user.role.user.yml @@ -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' diff --git a/web/modules/custom/figli_compta_ledger/css/home.css b/web/modules/custom/figli_compta_ledger/css/home.css index 4a5f1ef..9adc4db 100644 --- a/web/modules/custom/figli_compta_ledger/css/home.css +++ b/web/modules/custom/figli_compta_ledger/css/home.css @@ -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 diff --git a/web/modules/custom/figli_compta_ledger/css/ledger-form.css b/web/modules/custom/figli_compta_ledger/css/ledger-form.css index aa1aa78..7b264b3 100644 --- a/web/modules/custom/figli_compta_ledger/css/ledger-form.css +++ b/web/modules/custom/figli_compta_ledger/css/ledger-form.css @@ -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; diff --git a/web/modules/custom/figli_compta_ledger/css/releve-import.css b/web/modules/custom/figli_compta_ledger/css/releve-import.css new file mode 100644 index 0000000..7e44b28 --- /dev/null +++ b/web/modules/custom/figli_compta_ledger/css/releve-import.css @@ -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; +} diff --git a/web/modules/custom/figli_compta_ledger/figli_compta_ledger.install b/web/modules/custom/figli_compta_ledger/figli_compta_ledger.install index 2c3991d..a261f8e 100644 --- a/web/modules/custom/figli_compta_ledger/figli_compta_ledger.install +++ b/web/modules/custom/figli_compta_ledger/figli_compta_ledger.install @@ -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:'), 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).'; +} diff --git a/web/modules/custom/figli_compta_ledger/figli_compta_ledger.libraries.yml b/web/modules/custom/figli_compta_ledger/figli_compta_ledger.libraries.yml index 56d5ee1..6f3d18a 100644 --- a/web/modules/custom/figli_compta_ledger/figli_compta_ledger.libraries.yml +++ b/web/modules/custom/figli_compta_ledger/figli_compta_ledger.libraries.yml @@ -55,6 +55,11 @@ ledger_form: - core/drupal - core/once +releve_import: + css: + theme: + css/releve-import.css: {} + admin_chrome: css: theme: diff --git a/web/modules/custom/figli_compta_ledger/figli_compta_ledger.links.menu.yml b/web/modules/custom/figli_compta_ledger/figli_compta_ledger.links.menu.yml index 082cb45..c2f46fa 100644 --- a/web/modules/custom/figli_compta_ledger/figli_compta_ledger.links.menu.yml +++ b/web/modules/custom/figli_compta_ledger/figli_compta_ledger.links.menu.yml @@ -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 diff --git a/web/modules/custom/figli_compta_ledger/figli_compta_ledger.module b/web/modules/custom/figli_compta_ledger/figli_compta_ledger.module index a5f65d2..fd3dea4 100644 --- a/web/modules/custom/figli_compta_ledger/figli_compta_ledger.module +++ b/web/modules/custom/figli_compta_ledger/figli_compta_ledger.module @@ -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' => '' . number_format($montant, 2, ',', ' ') . ' €', + '#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(). */ diff --git a/web/modules/custom/figli_compta_ledger/figli_compta_ledger.permissions.yml b/web/modules/custom/figli_compta_ledger/figli_compta_ledger.permissions.yml new file mode 100644 index 0000000..82b6681 --- /dev/null +++ b/web/modules/custom/figli_compta_ledger/figli_compta_ledger.permissions.yml @@ -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 diff --git a/web/modules/custom/figli_compta_ledger/figli_compta_ledger.routing.yml b/web/modules/custom/figli_compta_ledger/figli_compta_ledger.routing.yml index 394af1a..9818301 100644 --- a/web/modules/custom/figli_compta_ledger/figli_compta_ledger.routing.yml +++ b/web/modules/custom/figli_compta_ledger/figli_compta_ledger.routing.yml @@ -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' diff --git a/web/modules/custom/figli_compta_ledger/figli_compta_ledger.services.yml b/web/modules/custom/figli_compta_ledger/figli_compta_ledger.services.yml index 369d4ce..99db495 100644 --- a/web/modules/custom/figli_compta_ledger/figli_compta_ledger.services.yml +++ b/web/modules/custom/figli_compta_ledger/figli_compta_ledger.services.yml @@ -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 diff --git a/web/modules/custom/figli_compta_ledger/src/Controller/DashboardController.php b/web/modules/custom/figli_compta_ledger/src/Controller/DashboardController.php index 5bc0f05..2d0ce8e 100644 --- a/web/modules/custom/figli_compta_ledger/src/Controller/DashboardController.php +++ b/web/modules/custom/figli_compta_ledger/src/Controller/DashboardController.php @@ -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'], diff --git a/web/modules/custom/figli_compta_ledger/src/Controller/LedgerActionsController.php b/web/modules/custom/figli_compta_ledger/src/Controller/LedgerActionsController.php index 4dfcb09..3342d90 100644 --- a/web/modules/custom/figli_compta_ledger/src/Controller/LedgerActionsController.php +++ b/web/modules/custom/figli_compta_ledger/src/Controller/LedgerActionsController.php @@ -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; diff --git a/web/modules/custom/figli_compta_ledger/src/Controller/ReleveImportResultController.php b/web/modules/custom/figli_compta_ledger/src/Controller/ReleveImportResultController.php new file mode 100644 index 0000000..a3ae79d --- /dev/null +++ b/web/modules/custom/figli_compta_ledger/src/Controller/ReleveImportResultController.php @@ -0,0 +1,72 @@ +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], + ]; + } + +} diff --git a/web/modules/custom/figli_compta_ledger/src/EventSubscriber/RouteSubscriber.php b/web/modules/custom/figli_compta_ledger/src/EventSubscriber/RouteSubscriber.php index c4eea19..908d450 100644 --- a/web/modules/custom/figli_compta_ledger/src/EventSubscriber/RouteSubscriber.php +++ b/web/modules/custom/figli_compta_ledger/src/EventSubscriber/RouteSubscriber.php @@ -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'); } } diff --git a/web/modules/custom/figli_compta_ledger/src/Form/ReleveUploadForm.php b/web/modules/custom/figli_compta_ledger/src/Form/ReleveUploadForm.php new file mode 100644 index 0000000..c07f62b --- /dev/null +++ b/web/modules/custom/figli_compta_ledger/src/Form/ReleveUploadForm.php @@ -0,0 +1,184 @@ + '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'); + } + +} diff --git a/web/modules/custom/figli_compta_ledger/src/Import/ClientMatcher.php b/web/modules/custom/figli_compta_ledger/src/Import/ClientMatcher.php new file mode 100644 index 0000000..65b6aa3 --- /dev/null +++ b/web/modules/custom/figli_compta_ledger/src/Import/ClientMatcher.php @@ -0,0 +1,144 @@ + 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; + } + +} diff --git a/web/modules/custom/figli_compta_ledger/src/Import/CsvReleveParser.php b/web/modules/custom/figli_compta_ledger/src/Import/CsvReleveParser.php new file mode 100644 index 0000000..789673d --- /dev/null +++ b/web/modules/custom/figli_compta_ledger/src/Import/CsvReleveParser.php @@ -0,0 +1,160 @@ + 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; + } + +} diff --git a/web/modules/custom/figli_compta_ledger/src/Import/ReleveImportBatch.php b/web/modules/custom/figli_compta_ledger/src/Import/ReleveImportBatch.php new file mode 100644 index 0000000..cad56ba --- /dev/null +++ b/web/modules/custom/figli_compta_ledger/src/Import/ReleveImportBatch.php @@ -0,0 +1,181 @@ + 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); + } + +} diff --git a/web/modules/custom/figli_compta_ledger/src/Import/ReleveTransaction.php b/web/modules/custom/figli_compta_ledger/src/Import/ReleveTransaction.php new file mode 100644 index 0000000..80edf69 --- /dev/null +++ b/web/modules/custom/figli_compta_ledger/src/Import/ReleveTransaction.php @@ -0,0 +1,47 @@ + $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'], + ); + } + +} diff --git a/web/modules/custom/figli_compta_ledger/src/SkipValidationContext.php b/web/modules/custom/figli_compta_ledger/src/SkipValidationContext.php new file mode 100644 index 0000000..5ec04f7 --- /dev/null +++ b/web/modules/custom/figli_compta_ledger/src/SkipValidationContext.php @@ -0,0 +1,64 @@ + $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; + } + +} diff --git a/web/modules/custom/figli_compta_ledger/templates/figli-compta-home.html.twig b/web/modules/custom/figli_compta_ledger/templates/figli-compta-home.html.twig index 9495665..11937a9 100644 --- a/web/modules/custom/figli_compta_ledger/templates/figli-compta-home.html.twig +++ b/web/modules/custom/figli_compta_ledger/templates/figli-compta-home.html.twig @@ -14,12 +14,6 @@ {% verbatim %}
- + Ajouter une ligne - {% endverbatim %} - {% if can_view_history %} - Historique - {% endif %} - {% verbatim %}