Compare commits

...
3 Commits
Author SHA1 Message Date
bachir 6e1c421e5b ecarts de 0.01€ visible dans le tableau 2026-09-09 21:42:17 +02:00
bachir 5eca3804bf Répartition assistée sur le formulaire + widget compacté
- ledger-form.js : nouveau behavior figliLedgerRepartition --
  pré-remplit les montants de répartition (1re ligne = HT entier,
  chaque ajout partage au centime avec report du reste sur les lignes
  suivantes), valeurs saisies manuellement ou chargées de la base
  « figées » (plus jamais déplacées), écart en direct dans la cellule
  de titre du widget (miroir exact du round(HT − somme, 2) et de la
  tolérance 0,01 du presave). Zéro changement PHP : #validate et
  node_presave() restent l'autorité. Les verrous vivent hors du DOM
  (survie aux re-rendus AJAX du widget) ; la détection manuel/assist
  repose sur le fait qu'une écriture programmatique ne déclenche pas
  d'événement input.
- ledger-form.css : compactage du widget -- titre « Répartition » par
  ligne, bouton Collapse et colonne Order masqués, paragraph-top en
  absolu (coin haut-droit, zéro hauteur), une seule ligne par
  répartition « Compte [input] Montant (€) [input] » où l'input du
  Compte est contraint en pourcentage de son wrapper claro-autocomplete
  (size=60 : il débordait sur le libellé Montant), header réordonné
  titre / écart / menu trois-points, inputs visibles au repos
  (--flform-bg, atténué en mode sombre), marqueur « figé ».
- install : update_8015 (features du widget vidées) puis update_8016
  (collapse_edit_all rétabli à la demande, duplicate reste off) +
  settings de l'install fraîche synchronisés.
2026-09-09 16:44:46 +02:00
bachir d2f3179f01 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.
2026-09-09 14:58:36 +02:00
30 changed files with 1907 additions and 63 deletions
+1
View File
@@ -6,4 +6,5 @@
/web/sites/*/files/ /web/sites/*/files/
/web/sites/*/settings.local.php /web/sites/*/settings.local.php
/web/sites/*/settings.php /web/sites/*/settings.php
/private/
.env .env
+3
View File
@@ -5,6 +5,7 @@ dependencies:
config: config:
- node.type.ligne_comptable - node.type.ligne_comptable
module: module:
- figli_compta_ledger
- node - node
- system - system
id: admin id: admin
@@ -13,7 +14,9 @@ weight: 6
is_admin: false is_admin: false
permissions: permissions:
- 'access content' - 'access content'
- 'access figli ledger'
- 'create ligne_comptable content' - 'create ligne_comptable content'
- 'delete any ligne_comptable content' - 'delete any ligne_comptable content'
- 'edit any ligne_comptable content' - 'edit any ligne_comptable content'
- 'import ligne_comptable releve'
- 'view ligne_comptable revisions' - 'view ligne_comptable revisions'
-2
View File
@@ -7,7 +7,6 @@ dependencies:
module: module:
- file - file
- filter - filter
- system
_core: _core:
default_config_hash: wkW7P5A53YhGmsgamrmTbfwpZrqdnPYiJdoAZQtdmJg default_config_hash: wkW7P5A53YhGmsgamrmTbfwpZrqdnPYiJdoAZQtdmJg
id: authenticated id: authenticated
@@ -15,6 +14,5 @@ label: 'Authenticated user'
weight: 1 weight: 1
is_admin: false is_admin: false
permissions: permissions:
- 'access content'
- 'delete own files' - 'delete own files'
- 'use text format basic_html' - 'use text format basic_html'
+3
View File
@@ -5,6 +5,7 @@ dependencies:
config: config:
- node.type.ligne_comptable - node.type.ligne_comptable
module: module:
- figli_compta_ledger
- node - node
- system - system
id: editeur id: editeur
@@ -13,7 +14,9 @@ weight: 5
is_admin: false is_admin: false
permissions: permissions:
- 'access content' - 'access content'
- 'access figli ledger'
- 'create ligne_comptable content' - 'create ligne_comptable content'
- 'delete any ligne_comptable content' - 'delete any ligne_comptable content'
- 'edit any ligne_comptable content' - 'edit any ligne_comptable content'
- 'import ligne_comptable releve'
- 'view ligne_comptable revisions' - 'view ligne_comptable revisions'
+2
View File
@@ -5,6 +5,7 @@ dependencies:
config: config:
- node.type.ligne_comptable - node.type.ligne_comptable
module: module:
- figli_compta_ledger
- node - node
- system - system
id: user id: user
@@ -13,4 +14,5 @@ weight: 4
is_admin: false is_admin: false
permissions: permissions:
- 'access content' - 'access content'
- 'access figli ledger'
- 'view ligne_comptable revisions' - 'view ligne_comptable revisions'
@@ -276,6 +276,22 @@ html.gin--dark-mode #figli-home-app {
border-bottom: none; 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 { #figli-home-app td.figli-solde-crediteur {
color: var(--figli-positive); color: var(--figli-positive);
} }
@@ -334,7 +350,15 @@ html.gin--dark-mode #figli-home-app {
font-weight: 600; font-weight: 600;
background: color-mix(in srgb, var(--figli-warning) 15%, transparent); background: color-mix(in srgb, var(--figli-warning) 15%, transparent);
color: var(--figli-warning); 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 /* Column highlight to pair with the row hover, forming a crosshair over
@@ -20,6 +20,7 @@
--flform-border-soft: #e8eaed; --flform-border-soft: #e8eaed;
--flform-label: #4b5563; --flform-label: #4b5563;
--flform-text: #1a1a1a; --flform-text: #1a1a1a;
--flform-bg: #ffffff;
--flform-bg-subtle: #f7f8fa; --flform-bg-subtle: #f7f8fa;
--flform-accent: #2f6f4f; --flform-accent: #2f6f4f;
--flform-danger: #b3261e; --flform-danger: #b3261e;
@@ -43,6 +44,7 @@ html.gin--dark-mode .figli-ledger-form {
--flform-border-soft: #333438; --flform-border-soft: #333438;
--flform-label: #a1a5ab; --flform-label: #a1a5ab;
--flform-text: #e8e9ea; --flform-text: #e8e9ea;
--flform-bg: #3a3b40;
--flform-bg-subtle: #2a2b2e; --flform-bg-subtle: #2a2b2e;
--flform-accent: #5fb98a; --flform-accent: #5fb98a;
--flform-danger: #ff6b6b; --flform-danger: #ff6b6b;
@@ -108,6 +110,26 @@ html.gin--dark-mode .figli-ledger-form {
grid-column: 4 / 5; 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 */ /* Field basics */
.figli-ledger-form .form-item__label { .figli-ledger-form .form-item__label {
font-size: 0.78rem; font-size: 0.78rem;
@@ -192,10 +214,10 @@ html.gin--dark-mode .figli-ledger-form {
} }
.figli-ledger-form table.field-multiple-table thead th { .figli-ledger-form table.field-multiple-table thead th {
text-align: left; text-align: left;
padding: 0.4rem 0.6rem 0.25rem; padding: 0.3rem 0.5rem 0.2rem;
} }
.figli-ledger-form table.field-multiple-table tbody td { .figli-ledger-form table.field-multiple-table tbody td {
padding: 0.35rem 0.6rem; padding: 0.2rem 0.5rem;
vertical-align: top; vertical-align: top;
border-top: 1px solid var(--flform-border-soft); border-top: 1px solid var(--flform-border-soft);
} }
@@ -204,12 +226,25 @@ html.gin--dark-mode .figli-ledger-form {
} }
.figli-ledger-form .paragraphs-subform { .figli-ledger-form .paragraphs-subform {
display: flex; display: flex;
gap: 0.6rem; gap: 0.5rem;
/* Réserve le coin haut-droit (bouton Remove du paragraph-top, en
position:absolute) : les inputs ne passent plus dessous et
rétrécissent d'autant. */
padding-right: 4.4rem;
} }
.figli-ledger-form .paragraphs-subform > .js-form-wrapper { .figli-ledger-form .paragraphs-subform > .js-form-wrapper {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
} }
/* Le Compte (nom, souvent long) prend une part plus large que le
Montant (chiffre court) -- tous deux plus étroits qu'avant, la zone
du Remove étant réservée ci-dessus. */
.figli-ledger-form .paragraphs-subform > .js-form-wrapper.field--name-field-compte {
flex: 1.6;
}
.figli-ledger-form .paragraphs-subform > .js-form-wrapper.field--name-field-montant {
flex: 1;
}
/* Buttons: "Ajouter Répartition" / "Add another item", and the per-row /* Buttons: "Ajouter Répartition" / "Add another item", and the per-row
Remove/Duplicate/Collapse actions -- all plain Drupal form-submit Remove/Duplicate/Collapse actions -- all plain Drupal form-submit
@@ -229,7 +264,7 @@ html.gin--dark-mode .figli-ledger-form {
color: var(--flform-accent); color: var(--flform-accent);
} }
.figli-ledger-form .field-add-more-submit { .figli-ledger-form .field-add-more-submit {
margin-top: 0.4rem; margin-top: 0.25rem;
} }
.figli-ledger-form .paragraphs-dropdown-toggle { .figli-ledger-form .paragraphs-dropdown-toggle {
border: none; border: none;
@@ -267,6 +302,164 @@ html.gin--dark-mode .figli-ledger-form {
margin: 0; margin: 0;
} }
/* ==== Répartition : compactage du widget (une ligne = Compte + Montant,
rien d'autre) + assist (PLAN-repartition-assistee.md) ==== */
/* Une ligne de répartition ne doit montrer que ses deux champs. Le
titre "Répartition" de chaque ligne est redondant (le titre du champ
est déjà en thead), le bouton Collapse n'a pas de sens (edit_mode:
open, les lignes sont toujours ouvertes -- et le CSS les masque de
toute façon côté JS), et paragraph-info/paragraph-summary sont rendus
vides pour ce bundle : chacun coûtait une ligne de bruit vertical. */
.figli-ledger-form .field--name-field-repartition .paragraph-type,
.figli-ledger-form .field--name-field-repartition .paragraph-info,
.figli-ledger-form .field--name-field-repartition .paragraph-summary,
.figli-ledger-form .field--name-field-repartition .paragraphs-icon-button-collapse {
display: none;
}
/* Le "paragraph-top" ne porte plus que le menu d'actions de la ligne
(Remove) : en ABSOLU dans le coin haut-droit de la ligne (td en
position:relative), aligné sur le padding horizontal de la cellule --
il n'ajoute donc AUCUNE hauteur, la ligne se réduit à ses deux
champs. */
.figli-ledger-form .field--name-field-repartition tr.paragraph-type--repartition > td {
position: relative;
/* Aucun padding vertical sur les lignes de répartition (demande
explicite) : la hauteur de ligne vient uniquement des champs, le
bordure-top reste comme séparateur. */
padding-top: 0;
padding-bottom: 0;
}
.figli-ledger-form .field--name-field-repartition .paragraph-top {
position: absolute;
top: 0.15rem;
right: 0.5rem;
z-index: 2;
display: flex;
align-items: center;
gap: 0.3rem;
margin: 0;
}
.figli-ledger-form .field--name-field-repartition .paragraph-top .paragraphs-actions {
margin: 0;
}
/* Colonne "Order" (poids des lignes) des tables multi-valeurs : l'ordre
ne compte jamais ici (cf. handles de drag déjà masqués plus haut),
deux champs de largeur récupérés. */
.figli-ledger-form .field-multiple-table thead th:last-child,
.figli-ledger-form .field-multiple-table td.delta-order {
display: none;
}
/* Menu "Toggle Actions" du thead : il porte le bouton "Collapse / Edit
all" (feature rétablie par update_8016) -- seul le mode "Drag & drop"
y est masqué, un réordonnancement sans objet (handles masqués plus
haut). Le menu par ligne (Remove) est hors de ce sélecteur. */
.figli-ledger-form .field--name-field-repartition thead input[name="field_repartition_dragdrop_mode"] {
display: none;
}
/* Barre de titre du widget : titre "Répartition" À GAUCHE, écart AU
MILIEU, menu trois-points À DROITE (l'ordre DOM est titre, actions,
puis l'écart injecté par JS, d'où les order). */
.figli-ledger-form .field--name-field-repartition th.field-label {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.6rem;
}
.figli-ledger-form .field--name-field-repartition th.field-label h4 {
order: 0;
margin: 0;
}
.figli-ledger-form .figli-repartition-ecart {
order: 1;
margin: 0;
white-space: nowrap;
font-size: 0.8rem;
font-weight: 400;
color: var(--flform-label);
}
.figli-ledger-form .field--name-field-repartition th.field-label .paragraphs-actions {
order: 2;
}
.figli-ledger-form .figli-repartition-ecart-val {
font-weight: 600;
}
.figli-ledger-form .figli-repartition-ecart.is-ok .figli-repartition-ecart-val {
color: var(--flform-accent);
}
.figli-ledger-form .figli-repartition-ecart.is-ko .figli-repartition-ecart-val {
color: var(--flform-danger);
}
/* Une ligne par répartition : "Compte [input] Montant (€) [input]".
Le piège : le form-item de Compte n'a PAS l'input pour enfant direct
-- claro l'emballe dans div.claro-autocomplete (le Montant, lui, a
l'input direct). Sans flex/min-width sur ce wrapper, l'input
(size=60) force sa largeur native, pousse le Montant hors de la
cellule et les deux champs disparaissent : chaque niveau doit être
flex et compressible. */
.figli-ledger-form .paragraphs-subform .form-item {
display: flex;
align-items: center;
gap: 0.35rem;
min-width: 0;
}
.figli-ledger-form .paragraphs-subform .form-item__label {
margin: 0;
white-space: nowrap;
}
.figli-ledger-form .paragraphs-subform .claro-autocomplete {
display: block;
flex: 1;
min-width: 0;
}
/* L'input du Compte (size="60", ~420px de largeur intrinsèque) : la
chaîne flex/min-width ne suffit pas à le contenir de façon fiable à
tous les niveaux -- contrainte dure en pourcentage de son wrapper à
la place : physiquement incapable de déborder sur la colonne du
Montant, quoi que dise l'attribut size. */
.figli-ledger-form .paragraphs-subform .claro-autocomplete input.form-element {
width: 100%;
max-width: 100%;
}
/* Inputs visibles au repos (fond opaque + bordure franche, les deux
modes via --flform-bg) et compressibles. flex:1 s'applique : au
Montant comme item direct du form-item, au Compte comme item du
claro-autocomplete flex ci-dessus. */
.figli-ledger-form .paragraphs-subform input.form-element {
flex: 1;
min-width: 0;
width: auto;
max-width: none;
background: var(--flform-bg);
border: 1px solid var(--flform-border);
color: var(--flform-text);
-webkit-text-fill-color: var(--flform-text);
}
/* Marqueur "figé" (ledger-form.js) : une valeur que l'assist ne
touchera plus -- saisie manuelle, ou chargée de la base. En item flex
(le form-item est en ligne ci-dessus), il suit l'input ; pointillé =
même convention que la référence bancaire, "valeur à regarder, pas à
retaper". */
.figli-ledger-form .figli-repartition-locked::after {
content: "figé";
flex: none;
align-self: center;
font-size: 0.64rem;
line-height: 1;
padding: 0.16rem 0.28rem;
border-radius: 5px;
border: 1px dashed var(--flform-border);
color: var(--flform-label);
}
/* A répartition row flagged by the sum-mismatch #validate error (see /* A répartition row flagged by the sum-mismatch #validate error (see
figli_compta_ledger_validate_repartition()) -- kept subtle (a red figli_compta_ledger_validate_repartition()) -- kept subtle (a red
outline, not a solid fill) to match the same red-liseré convention outline, not a solid fill) to match the same red-liseré convention
@@ -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_vocabulary('flag', 'Signalement', []);
_figli_compta_ledger_create_paragraph_repartition(); _figli_compta_ledger_create_paragraph_repartition();
_figli_compta_ledger_create_node_type_ligne_comptable(); _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) { 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 // filter and its future server-side equivalent can filter on a real
// stored value instead of resolving répartition paragraphs per row. // stored value instead of resolving répartition paragraphs per row.
_figli_field('node', 'ligne_comptable', 'field_ecart', 'Écart', 'decimal', ['precision' => 12, 'scale' => 2]); _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'); _figli_field('node', 'ligne_comptable', 'field_notes', 'Notes / détail', 'string_long');
// Free-tagging signalement (e.g. "client impayé", "à relancer") -- purely // 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_montant_ht', ['type' => 'number', 'weight' => 5])
->setComponent('field_cotisation_urssaf', ['type' => 'number', 'weight' => 6]) ->setComponent('field_cotisation_urssaf', ['type' => 'number', 'weight' => 6])
->setComponent('field_montant_ttc', ['type' => 'number', 'weight' => 8]) ->setComponent('field_montant_ttc', ['type' => 'number', 'weight' => 8])
// field_tva sits *after* Montant TTC, not between Cotisation and // Read-only bank reference directly under Montant TTC (the widget
// TTC -- figli_compta_ledger_form_alter() inserts a non-field // is #disabled by figli_compta_ledger_form_alter()) -- see
// "field_tva_rate" select at weight 7 (a select of the official // update_8013/_8014 for why this lives on the form despite being
// French VAT rates) to fill that visual slot instead; this real // import-written only, and why it sits at weight 9.
// field only becomes visible (still at its own weight, on its own ->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 // full-width row) when "Autre" is picked there. See
// css/ledger-form.css's grid-column rules for both. // css/ledger-form.css's grid-column rules for both.
->setComponent('field_tva', ['type' => 'number', 'weight' => 9]) ->setComponent('field_tva', ['type' => 'number', 'weight' => 10])
->setComponent('field_repartition', ['type' => 'paragraphs', 'weight' => 10, 'settings' => ['title' => 'Répartition', 'title_plural' => 'Répartitions', 'edit_mode' => 'open', 'add_mode' => 'button']]) ->setComponent('field_repartition', ['type' => 'paragraphs', 'weight' => 11, 'settings' => ['title' => 'Répartition', 'title_plural' => 'Répartitions', 'edit_mode' => 'open', 'add_mode' => 'button', 'features' => ['collapse_edit_all' => 'collapse_edit_all']]])
->setComponent('field_notes', ['type' => 'string_textarea', 'weight' => 11]) ->setComponent('field_notes', ['type' => 'string_textarea', 'weight' => 12])
->setComponent('field_flag', ['type' => 'entity_reference_autocomplete_tags', 'weight' => 12]) ->setComponent('field_flag', ['type' => 'entity_reference_autocomplete_tags', 'weight' => 13])
->save(); ->save();
} }
@@ -807,3 +845,131 @@ 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)."; 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).';
}
/**
* Empties the Paragraphs widget's "features" setting for
* field_repartition: with duplicate and collapse_edit_all off, the
* widget stops rendering the per-row Duplicate action and the thead's
* "Collapse / Edit all" -- pure noise on a 1-3 row répartition whose
* order never matters (see ledger-form.css's compacting rules for the
* rest: per-row title and Collapse button are hidden in CSS, they have
* no widget setting).
*/
function figli_compta_ledger_update_8015() {
$form_display = EntityFormDisplay::load('node.ligne_comptable.default');
if ($form_display) {
$component = $form_display->getComponent('field_repartition');
if ($component) {
$component['settings']['features'] = [];
$form_display->setComponent('field_repartition', $component);
$form_display->save();
}
}
return 'Widget Répartition compacté (features duplicate/collapse_edit_all désactivées).';
}
/**
* Re-enables the Paragraphs widget's collapse_edit_all feature (the
* "Collapse / Edit all" action in the widget's title cell) -- removed
* along with duplicate by update_8015, asked back by the associates
* the same day. Duplicate stays off: it has no use on a 1-3 row
* répartition whose order never matters.
*/
function figli_compta_ledger_update_8016() {
$form_display = EntityFormDisplay::load('node.ligne_comptable.default');
if ($form_display) {
$component = $form_display->getComponent('field_repartition');
if ($component) {
$component['settings']['features'] = ['collapse_edit_all' => 'collapse_edit_all'];
$form_display->setComponent('field_repartition', $component);
$form_display->save();
}
}
return 'Bouton "Collapse / Edit all" rétabli sur le widget Répartition (duplicate reste désactivé).';
}
@@ -55,6 +55,11 @@ ledger_form:
- core/drupal - core/drupal
- core/once - core/once
releve_import:
css:
theme:
css/releve-import.css: {}
admin_chrome: admin_chrome:
css: css:
theme: theme:
@@ -29,3 +29,11 @@ figli_compta_ledger.history:
menu_name: admin menu_name: admin
parent: system.admin parent: system.admin
weight: -8 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.'); $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 // Cotisation diffuseur URSSAF (1,1%) -- only ever relevant for
// "Entrée client" lines (the only type invoiced to a client via a // "Entrée client" lines (the only type invoiced to a client via a
// devis; see figli_compta_ledger_update_8006()'s docblock), so both // 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 * Historical imports (drush migration scripts) deliberately preserve the
* source spreadsheets' raw data, including known répartition mismatches -- * source spreadsheets' raw data, including known répartition mismatches --
* those get surfaced as visible inconsistencies in the dashboard instead of * those get surfaced as visible inconsistencies in the dashboard instead of
* being silently fixed. Set the 'figli_compta_ledger.skip_validation' state * being silently fixed. Programmatic saves that need to bypass this check
* flag around such a bulk import to bypass this check *and* the Montant TTC * (bulk imports, the inline-edit endpoints, the bank statement import) wrap
* auto-computation below; new lines entered by associates through the form * their save() in the figli_compta_ledger.skip_validation_context service
* are never exempted from either. field_ecart is the one thing still kept * (request-scoped, see \Drupal\figli_compta_ledger\SkipValidationContext).
* in sync even under skip_validation (see below) -- separately, a second * The legacy 'figli_compta_ledger.skip_validation' *state* key still works
* flag ('figli_compta_ledger.skip_revision') additionally suppresses the * for already-shipped migration scripts, but new code must use the service:
* forced-revision block, used only by figli_compta_ledger_update_8010()'s * the state key is a site-wide flag, and a concurrent normal form save
* field_ecart backfill. * 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) { function figli_compta_ledger_node_presave(NodeInterface $node) {
if ($node->bundle() !== 'ligne_comptable') { if ($node->bundle() !== 'ligne_comptable') {
@@ -365,7 +403,8 @@ function figli_compta_ledger_node_presave(NodeInterface $node) {
return; 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; $montant_ht = (float) $node->get('field_montant_ht')->value;
if (!$skip_validation) { if (!$skip_validation) {
@@ -474,7 +513,7 @@ function figli_compta_ledger_node_presave(NodeInterface $node) {
function figli_compta_ledger_theme($existing, $type, $theme, $path) { function figli_compta_ledger_theme($existing, $type, $theme, $path) {
return [ return [
'figli_compta_home' => [ 'figli_compta_home' => [
'variables' => ['can_view_history' => FALSE, 'current_route' => NULL], 'variables' => ['can_view_history' => FALSE, 'can_import_releve' => FALSE, 'current_route' => NULL],
'template' => 'figli-compta-home', 'template' => 'figli-compta-home',
], ],
'figli_compta_dashboard' => [ 'figli_compta_dashboard' => [
@@ -489,6 +528,10 @@ function figli_compta_ledger_theme($existing, $type, $theme, $path) {
'variables' => ['current_route' => NULL], 'variables' => ['current_route' => NULL],
'template' => 'figli-compta-dashboard-compte', '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.dashboard_compte',
'figli_compta_ledger.history', 'figli_compta_ledger.history',
'figli_compta_ledger.link_entree', '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)) { if (in_array(\Drupal::routeMatch()->getRouteName(), $front_end_routes, TRUE)) {
$attachments['#attached']['library'][] = 'figli_compta_ledger/hide_admin_chrome'; $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(). * 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' _controller: '\Drupal\figli_compta_ledger\Controller\DashboardController::home'
_title: 'Grand livre - SAS Figures Libres' _title: 'Grand livre - SAS Figures Libres'
requirements: requirements:
_permission: 'access content' _permission: 'access figli ledger'
figli_compta_ledger.dashboard: figli_compta_ledger.dashboard:
path: '/dashboard' path: '/dashboard'
@@ -12,7 +12,7 @@ figli_compta_ledger.dashboard:
_controller: '\Drupal\figli_compta_ledger\Controller\DashboardController::view' _controller: '\Drupal\figli_compta_ledger\Controller\DashboardController::view'
_title: 'Tableau de bord - SAS Figures Libres' _title: 'Tableau de bord - SAS Figures Libres'
requirements: requirements:
_permission: 'access content' _permission: 'access figli ledger'
figli_compta_ledger.dashboard_repartition: figli_compta_ledger.dashboard_repartition:
path: '/dashboard/repartition' path: '/dashboard/repartition'
@@ -20,7 +20,7 @@ figli_compta_ledger.dashboard_repartition:
_controller: '\Drupal\figli_compta_ledger\Controller\DashboardController::repartitionView' _controller: '\Drupal\figli_compta_ledger\Controller\DashboardController::repartitionView'
_title: 'Répartition / Soldes - SAS Figures Libres' _title: 'Répartition / Soldes - SAS Figures Libres'
requirements: requirements:
_permission: 'access content' _permission: 'access figli ledger'
figli_compta_ledger.dashboard_compte: figli_compta_ledger.dashboard_compte:
path: '/dashboard/compte' path: '/dashboard/compte'
@@ -28,7 +28,7 @@ figli_compta_ledger.dashboard_compte:
_controller: '\Drupal\figli_compta_ledger\Controller\DashboardController::compteView' _controller: '\Drupal\figli_compta_ledger\Controller\DashboardController::compteView'
_title: 'Tableau de bord par compte - SAS Figures Libres' _title: 'Tableau de bord par compte - SAS Figures Libres'
requirements: requirements:
_permission: 'access content' _permission: 'access figli ledger'
figli_compta_ledger.history: figli_compta_ledger.history:
path: '/lignes/historique' path: '/lignes/historique'
@@ -55,21 +55,21 @@ figli_compta_ledger.api_annees:
defaults: defaults:
_controller: '\Drupal\figli_compta_ledger\Controller\LedgerStatsController::annees' _controller: '\Drupal\figli_compta_ledger\Controller\LedgerStatsController::annees'
requirements: requirements:
_permission: 'access content' _permission: 'access figli ledger'
figli_compta_ledger.api_reconciliation_ouverture: figli_compta_ledger.api_reconciliation_ouverture:
path: '/lignes/api/reconciliation-ouverture' path: '/lignes/api/reconciliation-ouverture'
defaults: defaults:
_controller: '\Drupal\figli_compta_ledger\Controller\LedgerStatsController::reconciliationOuverture' _controller: '\Drupal\figli_compta_ledger\Controller\LedgerStatsController::reconciliationOuverture'
requirements: requirements:
_permission: 'access content' _permission: 'access figli ledger'
figli_compta_ledger.api_groupe_entree: figli_compta_ledger.api_groupe_entree:
path: '/lignes/api/groupe/{node}' path: '/lignes/api/groupe/{node}'
defaults: defaults:
_controller: '\Drupal\figli_compta_ledger\Controller\LedgerStatsController::groupeEntree' _controller: '\Drupal\figli_compta_ledger\Controller\LedgerStatsController::groupeEntree'
requirements: requirements:
_permission: 'access content' _permission: 'access figli ledger'
options: options:
parameters: parameters:
node: node:
@@ -80,14 +80,14 @@ figli_compta_ledger.api_lignes:
defaults: defaults:
_controller: '\Drupal\figli_compta_ledger\Controller\LedgerRowsController::index' _controller: '\Drupal\figli_compta_ledger\Controller\LedgerRowsController::index'
requirements: requirements:
_permission: 'access content' _permission: 'access figli ledger'
figli_compta_ledger.api_dashboard_stats: figli_compta_ledger.api_dashboard_stats:
path: '/dashboard/api/stats' path: '/dashboard/api/stats'
defaults: defaults:
_controller: '\Drupal\figli_compta_ledger\Controller\DashboardStatsController::stats' _controller: '\Drupal\figli_compta_ledger\Controller\DashboardStatsController::stats'
requirements: requirements:
_permission: 'access content' _permission: 'access figli ledger'
figli_compta_ledger.update_type: figli_compta_ledger.update_type:
path: '/lignes/{node}/type' path: '/lignes/{node}/type'
@@ -112,3 +112,19 @@ figli_compta_ledger.update_field:
parameters: parameters:
node: node:
type: entity: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 class: Drupal\figli_compta_ledger\EventSubscriber\RouteSubscriber
tags: tags:
- { name: event_subscriber } - { 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
@@ -445,7 +445,12 @@
parCompte, parCompte,
somme, somme,
ecart, ecart,
hasError: Math.abs(ecart) > 0.01, // Visible écart = non-zero to the centime (a 0.01 mismatch is
// savable -- the blocking threshold stays at 0.01 server-side --
// but must still be shown; mirrors LedgerRowsController's
// hasError). 0.005 guards against float representation of
// stored centimes.
hasError: Math.abs(ecart) > 0.005,
linkable: LINKABLE_TYPES.includes(attrs.field_type_ligne), linkable: LINKABLE_TYPES.includes(attrs.field_type_ligne),
entreeLieeIds: entreeLieeNodes.map((n) => n.id), entreeLieeIds: entreeLieeNodes.map((n) => n.id),
entreeLieeLabels: entreeLieeNodes.map((n) => n.attributes.title || n.id), entreeLieeLabels: entreeLieeNodes.map((n) => n.attributes.title || n.id),
@@ -136,4 +136,243 @@
}); });
} }
}; };
/**
* Répartition assistée (see PLAN-repartition-assistee.md): auto-fills
* the répartition amounts so the associate never does the small
* arithmetic by hand. Purely client-side pre-filling -- the server
* stays the authority (figli_compta_ledger_validate_repartition() +
* figli_compta_ledger_node_presave() unchanged), same philosophy as
* the TTC live preview above: with JS off, the form behaves exactly
* as before.
*
* Rules (plan §Comportement):
* - a value loaded from the database, or typed by the user, is
* "figée" (pinned) and never moved again;
* - the remaining rows split (HT − somme(figées)) to the centime,
* rounding remainders carried by the later rows, 0 when the
* remainder is negative;
* - triggers: row added/removed (Paragraphs AJAX re-render ->
* behavior attach), HT change, manual amount edit;
* - live "Réparti / Écart" line in the widget's title cell: green when
* the écart is zero to the centime, red otherwise (a 0.01 écart is
* savable -- blocking stays at 0.01 server-side -- but is shown,
* same as /lignes does).
*
* "Manual vs assist" detection rests on a DOM property: assigning
* input.value programmatically fires no event, only a real user edit
* fires 'input'. Locks therefore survive the widget's full AJAX
* re-renders (every add/remove rebuilds the inputs) because they live
* in this closure, keyed by input name (deltas are stable -- drag
* handles are hidden, order never matters); the assisted{} memory is
* what makes a never-assisted value (i.e. anything loaded from the
* database) figée by construction, and keeps a stale lock harmless
* after a row deletion reindexes the deltas.
*/
Drupal.behaviors.figliLedgerRepartition = {
attach: function (context) {
// Behaviors attach on every AJAX response with the replaced
// fragment as context -- the form itself only once (which is when
// the delegated listener below is set up), any later attach is a
// Paragraphs re-render and just needs a refresh. Like the TVA
// behavior's comment explains, context may BE the form (modal) or
// a fragment INSIDE it (add/remove) -- collect all three cases,
// then dedupe through once().
var ctx = (context && (context.nodeType === 1 || context.nodeType === 9)) ? context : document;
var forms = [];
if (ctx.nodeType === 1 && ctx.matches('.figli-ledger-form')) {
forms.push(ctx);
}
if (ctx.querySelectorAll) {
Array.prototype.forEach.call(ctx.querySelectorAll('.figli-ledger-form'), function (f) {
forms.push(f);
});
}
if (!forms.length && ctx.nodeType === 1 && ctx.closest) {
var ancestor = ctx.closest('.figli-ledger-form');
if (ancestor) {
forms.push(ancestor);
}
}
forms.forEach(function (form) {
if (once('figli-ledger-repartition', form).length) {
form.figliLedgerRepartition = initRepartitionAssist(form);
}
if (form.figliLedgerRepartition) {
form.figliLedgerRepartition.refresh();
}
});
}
};
function initRepartitionAssist(form) {
// input.name -> true (manual edit at some point, never move again).
var figees = {};
// input.name -> last value WE wrote (the assist's own writes).
var assisted = {};
var MONTANT_SEL = 'input[name$="[field_montant][0][value]"]';
var HT_NAME = 'field_montant_ht[0][value]';
function round2(x) {
return Math.round(x * 100) / 100;
}
function eur(x) {
return (x < 0 ? '-' : '') + Math.abs(x).toFixed(2).replace('.', ',') + ' €';
}
function widget() {
return form.querySelector('.field--name-field-repartition');
}
function amounts() {
var w = widget();
if (!w) {
return [];
}
return Array.prototype.slice.call(w.querySelectorAll(MONTANT_SEL));
}
function htValue() {
var ht = form.querySelector('[name="' + HT_NAME + '"]');
var v = ht ? parseFloat(ht.value) : NaN;
return isNaN(v) ? NaN : v;
}
// The écart lives inside the table's own th.field-label ("Répartition"
// heading cell), next to the Collapse-all menu -- requested layout:
// everything the réparation needs to know, in the table's title bar.
// That puts it INSIDE the widget's AJAX re-render zone (every
// add/remove rebuilds the table), so the element gets wiped and
// recreated on each refresh -- exactly what refresh() below does.
function ecartRow() {
var existing = form.querySelector('.figli-repartition-ecart');
if (existing) {
return existing;
}
var w = widget();
var th = w ? w.querySelector('th.field-label') : null;
if (!th) {
return null;
}
var span = document.createElement('span');
span.className = 'figli-repartition-ecart';
span.innerHTML = '<span class="figli-repartition-ecart-sum"></span> · <span class="figli-repartition-ecart-val"></span>';
th.appendChild(span);
return span;
}
function writeAssisted(input, value) {
var s = value.toFixed(2);
if (input.value !== s) {
input.value = s;
}
assisted[input.name] = s;
}
function refresh() {
var row = ecartRow();
var inputs = amounts();
if (row) {
// Hidden until the first répartition exists, wiped with the rest
// of the table on add/remove -- re-created on the next refresh.
row.style.display = inputs.length ? '' : 'none';
}
if (!inputs.length) {
return;
}
var ht = htValue();
// Classify + mark figées + sum their values.
var sommeFigees = 0;
var libres = [];
inputs.forEach(function (input) {
var value = input.value.trim();
var figee = !!figees[input.name]
|| (value !== '' && String(assisted[input.name]) !== value);
var item = input.closest('.form-item');
if (item) {
item.classList.toggle('figli-repartition-locked', figee);
}
if (figee) {
var v = parseFloat(value);
if (!isNaN(v)) {
sommeFigees = round2(sommeFigees + v);
}
}
else {
libres.push(input);
}
});
// Distribute: sequential split, each row gets round(restant /
// restantes), remainder carried by the later rows -- sum exact to
// the centime (a naive HT/n leaves 0,03€ of écart on 3 rows).
var restant = isNaN(ht) ? NaN : round2(ht - sommeFigees);
libres.forEach(function (input, i) {
if (isNaN(restant)) {
// No HT to distribute yet: clear our own previous writes only.
if (input.value !== '' && String(assisted[input.name]) === input.value) {
input.value = '';
}
return;
}
var n = libres.length - i;
var v = restant <= 0 ? 0 : round2(restant / n);
writeAssisted(input, v);
restant = round2(restant - v);
});
// Live écart, mirroring the server exactly (same rounding, same
// 0.01 tolerance as figli_compta_ledger_node_presave()).
var somme = 0;
inputs.forEach(function (input) {
var v = parseFloat(input.value);
if (!isNaN(v)) {
somme = round2(somme + v);
}
});
var ecart = isNaN(ht) ? NaN : round2(ht - somme);
if (!row) {
return;
}
row.querySelector('.figli-repartition-ecart-sum').textContent = 'Réparti : ' + eur(somme);
var val = row.querySelector('.figli-repartition-ecart-val');
val.textContent = isNaN(ecart) ? 'Écart : —' : 'Écart : ' + eur(ecart);
// Green only when the écart is zero to the centime: a 0.01
// mismatch is still savable (the server's blocking threshold
// stays at 0.01) but it IS an écart -- signaled here in red,
// exactly as /lignes now displays it (LedgerRowsController's
// hasError). 0.005 guards against float representations of
// centime values.
row.classList.toggle('is-ok', !isNaN(ecart) && Math.abs(ecart) <= 0.005);
row.classList.toggle('is-ko', !isNaN(ecart) && Math.abs(ecart) > 0.005);
}
// One delegated listener on the form (survives every Paragraphs
// re-render, unlike per-input listeners). Programmatic writes above
// fire no event, so any 'input' seen here IS a human edit: lock it
// -- or unlock when cleared, so an emptied row becomes free again.
form.addEventListener('input', function (e) {
var t = e.target;
if (!t.matches) {
return;
}
if (t.matches(MONTANT_SEL)) {
if (t.value.trim() === '') {
delete figees[t.name];
}
else {
figees[t.name] = true;
}
refresh();
}
else if (t.name === HT_NAME) {
refresh();
}
});
return { refresh: refresh };
}
})(Drupal, once); })(Drupal, once);
@@ -18,6 +18,7 @@ class DashboardController extends ControllerBase {
return [ return [
'#theme' => 'figli_compta_home', '#theme' => 'figli_compta_home',
'#can_view_history' => $this->currentUser()->hasPermission('view ligne_comptable revisions'), '#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', '#current_route' => 'figli_compta_ledger.home',
'#attached' => [ '#attached' => [
'library' => ['figli_compta_ledger/home'], 'library' => ['figli_compta_ledger/home'],
@@ -5,8 +5,10 @@ namespace Drupal\figli_compta_ledger\Controller;
use Drupal\Core\Access\CsrfRequestHeaderAccessCheck; use Drupal\Core\Access\CsrfRequestHeaderAccessCheck;
use Drupal\Core\Controller\ControllerBase; use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Entity\EntityStorageException; use Drupal\Core\Entity\EntityStorageException;
use Drupal\figli_compta_ledger\SkipValidationContext;
use Drupal\node\NodeInterface; use Drupal\node\NodeInterface;
use Drupal\taxonomy\Entity\Term; use Drupal\taxonomy\Entity\Term;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
@@ -23,6 +25,24 @@ use Symfony\Component\HttpFoundation\Request;
*/ */
class LedgerActionsController extends ControllerBase { 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 * Fields editable inline from /lignes without opening the full node
* edit form -- keys are the short names the frontend sends; values are * 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 // (from historical data, never corrected -- see figli_compta_ledger's
// module docblock) exactly as it was. The présave check exists to // module docblock) exactly as it was. The présave check exists to
// catch new inconsistent entries, not to block relabeling the type of // catch new inconsistent entries, not to block relabeling the type of
// an already-migrated line, so skip it for this save only. try/finally // an already-migrated line, so skip it for this save only. The
// guarantees the global flag clears even if save() throws for an // SkipValidationContext service is request-scoped with a try/finally
// unrelated reason -- leaving it on would silently skip validation on // inside skip(), so the check is back on the instant save() returns
// every other save on the site. // or throws -- no global flag left hanging that a concurrent save
\Drupal::state()->set('figli_compta_ledger.skip_validation', TRUE); // from someone else could fall into.
try { try {
$node->save(); $this->skipValidationContext->skip(fn () => $node->save());
} }
catch (EntityStorageException $e) { catch (EntityStorageException $e) {
return new JsonResponse(['error' => $e->getMessage()], 422); return new JsonResponse(['error' => $e->getMessage()], 422);
} }
finally {
\Drupal::state()->delete('figli_compta_ledger.skip_validation');
}
return new JsonResponse([ return new JsonResponse([
'success' => TRUE, 'success' => TRUE,
@@ -167,17 +184,14 @@ class LedgerActionsController extends ControllerBase {
// signalement changes here, montant_ht and field_repartition are // signalement changes here, montant_ht and field_repartition are
// untouched, so skipping the répartition check for this save can // untouched, so skipping the répartition check for this save can
// never introduce a mismatch -- it can only leave a pre-existing // never introduce a mismatch -- it can only leave a pre-existing
// historical one exactly as it was. // historical one exactly as it was. Request-scoped skip (see
\Drupal::state()->set('figli_compta_ledger.skip_validation', TRUE); // updateType()'s comment), no global flag.
try { try {
$node->save(); $this->skipValidationContext->skip(fn () => $node->save());
} }
catch (EntityStorageException $e) { catch (EntityStorageException $e) {
return new JsonResponse(['error' => $e->getMessage()], 422); return new JsonResponse(['error' => $e->getMessage()], 422);
} }
finally {
\Drupal::state()->delete('figli_compta_ledger.skip_validation');
}
if ($field === 'client') { if ($field === 'client') {
$newValue = $node->get('field_client')->entity ? $node->get('field_client')->entity->label() : NULL; $newValue = $node->get('field_client')->entity ? $node->get('field_client')->entity->label() : NULL;
@@ -172,7 +172,13 @@ class LedgerRowsController extends ControllerBase {
'parCompte' => (object) $parCompte, 'parCompte' => (object) $parCompte,
'somme' => $somme, 'somme' => $somme,
'ecart' => $ecart, 'ecart' => $ecart,
'hasError' => abs($ecart) > 0.01, // "Écart visible" == non-zero to the centime. The presave/validation
// BLOCKING threshold stays at > 0.01 (tolerated rounding noise is
// savable), but a 0.01 écart is still a real mismatch and must be
// shown in the table -- at > 0.01 here it displayed nothing at all.
// 0.005 (not 0.0) keeps float representations of stored centimes
// from misclassifying.
'hasError' => abs($ecart) > 0.005,
'linkable' => in_array($type, self::LINKABLE_TYPES, TRUE), 'linkable' => in_array($type, self::LINKABLE_TYPES, TRUE),
'entreeLieeIds' => array_map(fn ($n) => $n->uuid(), $entreeLieeNodes), 'entreeLieeIds' => array_map(fn ($n) => $n->uuid(), $entreeLieeNodes),
'entreeLieeLabels' => array_map(fn ($n) => $n->getTitle() ?: $n->uuid(), $entreeLieeNodes), 'entreeLieeLabels' => array_map(fn ($n) => $n->getTitle() ?: $n->uuid(), $entreeLieeNodes),
@@ -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) { protected function alterRoutes(RouteCollection $collection) {
if ($route = $collection->get('system.entity_autocomplete')) { 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 %} {% verbatim %}
<div id="figli-home-app"> <div id="figli-home-app">
<div class="figli-toolbar"> <div class="figli-toolbar">
<a href="/node/add/ligne_comptable" class="button button--primary" @click.prevent="openAddForm">+ Ajouter une ligne</a>
{% endverbatim %}
{% if can_view_history %}
<a href="{{ path('figli_compta_ledger.history') }}" class="button">Historique</a>
{% endif %}
{% verbatim %}
<label>Compte <label>Compte
<span class="figli-filter-row"> <span class="figli-filter-row">
@@ -265,8 +259,16 @@
</tbody> </tbody>
<tfoot> <tfoot>
<tr class="figli-totals-row"> <tr class="figli-totals-row">
<td class="actions-col"></td> <td colspan="7">
<td colspan="6"> <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) Solde {{ currentYear || '…' }} (créditeur / débiteur)
<span v-if="currentYearLoading" class="figli-note">chargement…</span> <span v-if="currentYearLoading" class="figli-note">chargement…</span>
</td> </td>
@@ -337,8 +339,7 @@
</tbody> </tbody>
<tfoot> <tfoot>
<tr class="figli-totals-row"> <tr class="figli-totals-row">
<td class="actions-col"></td> <td colspan="6">Solde entrées et sorties liées (créditeur / débiteur)</td>
<td colspan="5">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_ht) : '' }}</td>
<td class="amount">{{ drilldownTotals ? formatEur(drilldownTotals.montant_ttc) : '' }}</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> <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>