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

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

- src/Import/ : CsvReleveParser (ISO-8859-1 confirmé, en-tête strict,
  fgetcsv avec escape '' explicite -- dépréciation PHP 8.4, rejets
  propres avec n° de ligne), ReleveImportBatch (Batch API par lots de
  25, dédoublonnage COMPTÉ par empreinte field_import_fitid -- max(0,
  k−m) importe les vrais doublons légitimes et dédoublonne à travers
  des fichiers qui se chevauchent --, totaux de contrôle au centime
  sur la page de résultat, résumé en tempstore privé).
- ReleveUploadForm : upload private://releves (fichier conservé +
  usage, hors de portée du cron), parse en validateForm(), batch,
  redirection vers la page de résultat.
- field_montant_releve : référence bancaire immuable, écrite une fois
  à l'import et jamais par presave ; affichée en TEXTE sous Montant
  TTC (widget remplacé par un #type item -- un item ne soumet rien et
  extractFormValues() saute le champ sans valeur soumise, la valeur
  survit donc à chaque save) ; masquée sur les lignes sans montant.
- SkipValidationContext : contournement du contrôle de répartition
  requête-scopé (ferme le trou de concurrence de l'ancien state
  global, AUDIT-2026-09-09 §2.2) -- presave honore le service (state
  gardé pour compat), updateType/updateField basculent dessus.
- Permissions (AUDIT §2.2 priorité 1) : access figli ledger sur toutes
  les routes du module + autocomplete (RouteSubscriber), import
  réservé Éditeur/Admin, access content retiré du rôle Authenticated
  (config/sync re-exportée pour les 4 rôles).
- Gin : hook_gin_ignore_sticky_form_actions() -- sans ça, le bouton
  Importer partait dans la barre sticky du chrome masqué.
- install : 8011 champs, 8012 index sur les empreintes, 8013/8014
  montant_releve sur le formulaire sous le TTC (poids renumérotés).
- /lignes : boutons + Ajouter / Importer / Historique dans le footer
  sticky (compacts), footer colspan dès la première colonne, badges de
  signalement qui reviennent à la ligne au lieu de déborder.
This commit is contained in:
2026-09-09 14:58:36 +02:00
parent 3688bddba8
commit d2f3179f01
27 changed files with 1434 additions and 57 deletions
@@ -276,6 +276,22 @@ html.gin--dark-mode #figli-home-app {
border-bottom: none;
}
/* "+ Ajouter une ligne" + "Importer un relevé" live in the sticky footer
now (they used to lead the toolbar and crowd its filter row). Compact
overrides for Gin's .button, which is sized for full admin forms --
way too big inside a dense totals row. Size-only overrides (no
colors): Gin's own light/dark button palettes keep applying. */
#figli-home-app tr.figli-totals-row .button {
display: inline-block;
margin: 0 0.4rem 0 0;
padding: 0.15rem 0.55rem;
font-size: 0.72rem;
line-height: 1.4;
vertical-align: middle;
border-radius: 4px;
box-shadow: none;
}
#figli-home-app td.figli-solde-crediteur {
color: var(--figli-positive);
}
@@ -334,7 +350,15 @@ html.gin--dark-mode #figli-home-app {
font-weight: 600;
background: color-mix(in srgb, var(--figli-warning) 15%, transparent);
color: var(--figli-warning);
white-space: nowrap;
/* The Signalement column is narrow (6%) -- a badge must wrap inside
the cell instead of overflowing into the neighboring column. The
cell itself already allows wrapping (.figli-flag-cell); this makes
the badge wrap too, including single long tokens (anywhere) and
within its own padding box (max-width + border-box). */
box-sizing: border-box;
max-width: 100%;
white-space: normal;
overflow-wrap: anywhere;
}
/* Column highlight to pair with the row hover, forming a crosshair over
@@ -108,6 +108,26 @@ html.gin--dark-mode .figli-ledger-form {
grid-column: 4 / 5;
}
/* Montant relevé bancaire (field_montant_releve): the import's
immutable bank reference, displayed as plain text -- the widget is
replaced by a #type => 'item' in figli_compta_ledger_form_alter(),
no input box at all. Under Montant TTC in the same column so the two
amounts compare at a glance while sorting an imported line; the
"réf." label marker + tabular digits carry the "value you look at,
not one you type" convention. Hidden entirely on lines with no bank
amount (manually entered ones). */
.figli-ledger-form > .field--name-field-montant-releve {
grid-column: 4 / 5;
}
.figli-ledger-form > .field--name-field-montant-releve label::after {
content: " · réf.";
font-weight: 400;
color: var(--flform-label);
}
.figli-ledger-form > .field--name-field-montant-releve .figli-releve-value {
font-variant-numeric: tabular-nums;
}
/* Field basics */
.figli-ledger-form .form-item__label {
font-size: 0.78rem;
@@ -0,0 +1,142 @@
/*
* Page de résultat d'import de relevé (templates/figli-compta-releve-import-result.html.twig).
*
* Piège #1 du CLAUDE.md respecté : le mode sombre Gin (html.gin--dark-mode)
* ne résout pas les CSS custom properties de Gin de façon fiable sur ces
* routes custom — couleurs explicites en local (--figli-*) + bloc dark mode
* dédié, jamais de fallback seul.
*/
.figli-releve-result {
max-width: 60rem;
margin: 0 auto;
padding: 1rem 1.5rem 3rem;
color: #161616;
--figli-border: #d4d4d4;
--figli-bg: #ffffff;
--figli-bg-soft: #f6f6f6;
--figli-ok: #1b5e20;
--figli-ok-bg: #e8f5e9;
--figli-alert: #b71c1c;
--figli-alert-bg: #ffebee;
}
.figli-releve-result h2 {
margin-top: 1.2rem;
}
.figli-releve-tag .figli-flag-badge {
display: inline-block;
padding: 0.1rem 0.5rem;
border-radius: 0.75rem;
background: #7a5c00;
color: #ffffff;
font-size: 0.85em;
}
.figli-releve-stats {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin: 1.2rem 0;
}
.figli-releve-stat {
flex: 1 1 10rem;
padding: 0.8rem 1rem;
border: 1px solid var(--figli-border);
border-radius: 6px;
background: var(--figli-bg);
text-align: center;
}
.figli-releve-stat-value {
display: block;
font-size: 1.6rem;
font-weight: 600;
}
.figli-releve-stat-label {
display: block;
font-size: 0.85rem;
color: #5f5f5f;
}
.figli-releve-totals {
border-collapse: collapse;
margin: 0.5rem 0 1rem;
}
.figli-releve-totals th,
.figli-releve-totals td {
padding: 0.4rem 0.8rem 0.4rem 0;
border-bottom: 1px solid var(--figli-border);
text-align: left;
}
.figli-releve-amount {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.figli-releve-ok {
padding: 0.5rem 0.8rem;
border-left: 3px solid var(--figli-ok);
background: var(--figli-ok-bg);
color: var(--figli-ok);
}
.figli-releve-alert {
padding: 0.5rem 0.8rem;
border-left: 3px solid var(--figli-alert);
background: var(--figli-alert-bg);
color: var(--figli-alert);
font-weight: 600;
}
.figli-releve-errors li {
color: var(--figli-alert);
margin-bottom: 0.25rem;
}
.figli-releve-dup-wrap {
max-height: 20rem;
overflow-y: auto;
border: 1px solid var(--figli-border);
border-radius: 6px;
background: var(--figli-bg-soft);
}
.figli-releve-dups {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.figli-releve-dups th,
.figli-releve-dups td {
padding: 0.35rem 0.8rem;
text-align: left;
border-bottom: 1px solid var(--figli-border);
}
.figli-releve-actions {
margin-top: 1.5rem;
display: flex;
gap: 0.75rem;
}
/* Mode sombre Gin — mêmes règles, palette inversée, cf. CLAUDE.md piège #1. */
html.gin--dark-mode .figli-releve-result {
color: #e6e6e6;
--figli-border: #3a3a3a;
--figli-bg: #1c1c1c;
--figli-bg-soft: #232323;
--figli-ok: #9ee493;
--figli-ok-bg: #123016;
--figli-alert: #ff8a80;
--figli-alert-bg: #3a1212;
}
html.gin--dark-mode .figli-releve-stat-label {
color: #a3a3a3;
}