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
@@ -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>