Compare commits

..
15 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
bachir 3688bddba8 Affiche les messages Drupal par-dessus les fenêtres modales
La région [data-drupal-messages] vit dans le layout Gin, où des stacking
contexts ancêtres neutralisaient son position:fixed + z-index : tout le
sous-arbre passait sous l'overlay de la modale jQuery UI (enfant direct
du <body>), rendant illisibles les messages insérés pendant l'édition
d'une ligne (MessageCommand -- erreur de répartition, création...).

admin-chrome.js déplace la région en enfant direct du <body> au
chargement et la re-vérifie dès qu'une modale entre dans le DOM ;
admin-chrome.css passe son z-index à 100000, hors d'atteinte du
_moveToTop de jQuery UI (qui ne remonte un dialog que au-dessus des
siblings .ui-front, ce que la région n'est pas).
2026-09-09 12:59:37 +02:00
bachir af36591d70 Ajoute le total en bas de chaque graph Charges structurelles par client
Ligne "Total" sous le graph principal et sous chacune des cartes par
année -- somme calculée à partir des items déjà tracés (sumItems()), pas
d'un champ stats séparé, pour rester cohérente par construction avec les
barres affichées au-dessus.
2026-09-08 15:38:17 +02:00
bachir 74d8eb2aa2 Ajoute "Charges structurelles par client" (+ par année) sur le dashboard SAS
Réutilise le node-level query déjà exécuté pour caParClient/totalParType
(aucune requête SQL supplémentaire) -- accumule abs(montant_ht) des
lignes type=charge par client (le vendeur/organisme : loyer, assurance,
URSSAF...). Trié par montant décroissant (pas alphabétique), pas de
plafond top-N vu le petit nombre de vendeurs distincts. Coloré en gris
"charge", même couleur que ce type dans "Répartition de l'activité par
type".
2026-09-08 15:30:31 +02:00
bachir 96bd9502d2 Les totaux du footer de /lignes suivent maintenant les filtres actifs
Remplace /lignes/api/totaux (LedgerStatsController::totauxAnnee(),
toujours non filtré) par une agrégation côté client des lignes renvoyées
par LedgerRowsController::index() (même endpoint que la fenêtre
glissante) avec annee=<année visible> + les filtres actifs. Reste une
requête serveur dédiée sur l'année entière (pas de LIMIT/range sur la
requête Entity), donc le total ne dépend jamais de ce qui est
effectivement chargé dans la fenêtre glissante à cet instant -- vérifié :
207 lignes non filtrées vs 13 avec le filtre "OVH", total du footer
identique au calcul indépendant dans les deux cas.

onFilterChanged() déclenche maintenant systématiquement
loadCurrentYearTotals() (pas seulement quand detectCurrentYear() détecte
un changement d'année) -- sinon changer un filtre en restant sur la même
année laissait le footer afficher l'ancien total non filtré.

totauxAnnee() et sa route sont supprimés (plus aucun appelant après ce
changement, vérifié par recherche).
2026-09-08 15:25:18 +02:00
bachir cb2a0fcdaa Trie le graph versements par ordre alphabétique, Salaire/stage et Sous-traitant en dernier
Les 6 comptes sont désormais triés alphabétiquement plutôt que par
montant décroissant ; Salaire/stage et Sous-traitant ne participent pas à
ce tri, ils sont simplement ajoutés après coup, dans cet ordre fixe.
2026-09-08 15:10:49 +02:00
bachir 612269ec6d Colore les lignes du graph versements selon leur type
Les 6 comptes reprennent la couleur "versement" (orange, même que
"Versement freelance" dans Répartition par type) puisque c'est la même
somme, juste ventilée par bénéficiaire ; Salaire/stage et Sous-traitant
gardent leur propre couleur de type. Réutilise typeColor()/TYPE_COLORS
déjà en place, juste besoin d'un champ "type" sur chaque item pour que
colorFor puisse s'en servir.
2026-09-08 15:07:41 +02:00
bachir d17a3e2e33 Ajoute Salaire/stage et Sous-traitant au graphique des versements par compte
Deux lignes supplémentaires (pas ventilées par compte, un seul total
chacune) dans "Total des versements par compte" et sa version par année --
même source déjà utilisée par "Répartition de l'activité par type"
(total_par_type / total_par_type_par_annee), aucun changement backend.
2026-09-08 15:05:18 +02:00
bachir 692f7f3ea3 Ajoute "Total des versements par compte" (+ par année) sur le dashboard SAS
Réutilise total_par_type_par_compte / total_par_type_par_compte_par_annee,
déjà exposés par /dashboard/api/stats pour /dashboard/compte -- aucun
changement backend nécessaire, juste extrait la clé "versement" par
compte au lieu de la garder scindée par type.
2026-09-08 15:03:51 +02:00
bachir 8dfb4af98a Nouvelle page /dashboard/repartition, renomme "Dashboard" en "SAS" dans le menu
Nouvelle page "Répartition/Soldes" entre "SAS" (ex-"Dashboard") et "Par
compte" dans le menu : reprend "Solde par compte" et "Évolution du solde
par compte", retirés du dashboard général pour le recentrer sur
l'activité/CA/type/client. Mêmes données déjà exposées par
/dashboard/api/stats (solde_par_compte, solde_par_compte_par_annee),
aucun changement backend nécessaire pour cette page.

js/dashboard-repartition.js reprend le HBarChart/MiniTrend de
dashboard.js -- dupliqués plutôt que partagés, même convention que
dashboard-compte.js. Racine Vue volontairement le même id
#figli-dashboard-app que les deux autres pages dashboard (pas un id
dédié) : dashboard.css scope ses variables CSS (thème clair/sombre) sur
ce sélecteur, réutiliser le même id est comment les trois pages héritent
du même thème sans feuille de style séparée -- vérifié en dark mode.

Le lien de menu "Dashboard" devient "SAS" partout (les 3 templates Twig
+ le nav en render array de HistoryController, qui n'a pas de template
Twig propre).

dashboard.js : MiniTrend/soldeParCompteItems/comptesOrdonnes/
trendValues supprimés (code mort après le déplacement, plus rien ne les
utilise sur le dashboard général).
2026-09-08 15:00:22 +02:00
bachir 43817dcdce Retire la carte "Meilleur solde" du dashboard général 2026-09-08 14:38:23 +02:00
bachir 3552b5a79d Ajoute la répartition par type et le top clients par année sur /dashboard
Reprend exactement le pattern des petits multiples déjà en place sur
/dashboard/compte (grille figli-year-hbar-grid, variante compacte de
h-bar-chart) -- mêmes classes CSS, aucun nouveau style nécessaire.

Backend : DashboardStatsController::stats() calcule maintenant aussi
total_par_type_par_annee et top_clients_par_annee (top 8, contre 12 en
toutes années confondues) à partir des mêmes requêtes SQL déjà en place,
sans requête supplémentaire.

Frontend : dashboard.js n'a jamais de données ligne par ligne (contraire-
ment à dashboard-compte.js qui filtre côté client) -- l'agrégation par
année doit donc venir du serveur. Ajout du prop "compact" au HBarChart de
dashboard.js (jusqu'ici absent, seule la copie de dashboard-compte.js
l'avait).
2026-09-08 14:34:04 +02:00
bachir c927771795 Corrige un deadlock dans onFilterChanged() qui gelait le tableau
ensureScrollable() (appelé par onFilterChanged() après chaque changement
de filtre) appelle lui-même loadOlder()/loadNewer(), qui passent par le
même _queueWindowOp -- en le chaînant *à l'intérieur* de l'opération déjà
mise en file par onFilterChanged(), la file d'attente se retrouvait à
attendre sa propre continuation dès qu'un filtre laissait trop peu de
lignes pour remplir l'écran, gelant purement et simplement le tableau
(recherche qui ne charge plus les lignes précédentes en scrollant, et
même effacer le filtre ensuite ne faisait plus rien -- tout attendait
derrière l'opération bloquée).

Corrigé en chaînant ensureScrollable() après la résolution de l'opération
mise en file, pas dedans -- ses propres appels à loadOlder()/loadNewer()
s'empilent alors normalement sur la file, sans dépendance circulaire.

Reproduit et vérifié en conditions réelles : recherche "Assurance local"
(47 correspondances de 2023 à 2026) qui chargeait bien 2026 mais bloquait
en scrollant vers le haut -- après correctif, chaque scroll vers le haut
déclenche bien un nouveau chargement (vérifié sur 2 scrolls successifs),
et effacer le filtre recharge immédiatement la vue complète.
2026-09-08 14:22:39 +02:00
41 changed files with 2541 additions and 250 deletions
+1
View File
@@ -6,4 +6,5 @@
/web/sites/*/files/
/web/sites/*/settings.local.php
/web/sites/*/settings.php
/private/
.env
+3
View File
@@ -5,6 +5,7 @@ dependencies:
config:
- node.type.ligne_comptable
module:
- figli_compta_ledger
- node
- system
id: admin
@@ -13,7 +14,9 @@ weight: 6
is_admin: false
permissions:
- 'access content'
- 'access figli ledger'
- 'create ligne_comptable content'
- 'delete any ligne_comptable content'
- 'edit any ligne_comptable content'
- 'import ligne_comptable releve'
- 'view ligne_comptable revisions'
-2
View File
@@ -7,7 +7,6 @@ dependencies:
module:
- file
- filter
- system
_core:
default_config_hash: wkW7P5A53YhGmsgamrmTbfwpZrqdnPYiJdoAZQtdmJg
id: authenticated
@@ -15,6 +14,5 @@ label: 'Authenticated user'
weight: 1
is_admin: false
permissions:
- 'access content'
- 'delete own files'
- 'use text format basic_html'
+3
View File
@@ -5,6 +5,7 @@ dependencies:
config:
- node.type.ligne_comptable
module:
- figli_compta_ledger
- node
- system
id: editeur
@@ -13,7 +14,9 @@ weight: 5
is_admin: false
permissions:
- 'access content'
- 'access figli ledger'
- 'create ligne_comptable content'
- 'delete any ligne_comptable content'
- 'edit any ligne_comptable content'
- 'import ligne_comptable releve'
- 'view ligne_comptable revisions'
+2
View File
@@ -5,6 +5,7 @@ dependencies:
config:
- node.type.ligne_comptable
module:
- figli_compta_ledger
- node
- system
id: user
@@ -13,4 +14,5 @@ weight: 4
is_admin: false
permissions:
- 'access content'
- 'access figli ledger'
- 'view ligne_comptable revisions'
@@ -69,7 +69,14 @@ html.gin--dark-mode .figli-page-nav a.is-active {
([data-drupal-messages-fallback], used when Drupal.Message.add() -- our
own MessageCommand-driven AJAX messages included -- has no region to
attach to). Auto-dismiss timing for non-error messages is handled in
admin-chrome.js. */
admin-chrome.js.
z-index 100000: the region is lifted to a direct <body> child by
admin-chrome.js (Gin's layout stacking contexts would otherwise bury
it under the modal overlay), and 100000 puts it above the jQuery UI
dialog itself (~100, .ui-front) and its overlay (dialog - 1) -- and
unreachable: jQuery UI only ever raises a dialog above .ui-front
siblings (_moveToTop), which the messages wrapper is not. Messages
stay readable on top of everything while a modal is open. */
[data-drupal-messages],
[data-drupal-messages-fallback] {
position: fixed !important;
@@ -78,7 +85,7 @@ html.gin--dark-mode .figli-page-nav a.is-active {
left: auto !important;
width: auto;
max-width: 22rem;
z-index: 1000;
z-index: 100000;
}
[data-drupal-messages] .messages-list__wrapper,
[data-drupal-messages] .messages__wrapper,
@@ -364,6 +364,25 @@ html.gin--dark-mode #figli-dashboard-app {
font-size: 0.7rem;
}
/* Total line under an h-bar-chart (currently just Charges structurelles
par client, main chart and each per-année card) -- separate from the
chart component itself, plain right-aligned text matching
.figli-hbar-value's alignment/tabular-nums so the total lines up
visually with the bars' own value column above it. */
#figli-dashboard-app .figli-chart-total {
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--figli-border);
text-align: right;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
#figli-dashboard-app .figli-chart-total.is-compact {
margin-top: 0.4rem;
padding-top: 0.4rem;
font-size: 0.72rem;
}
/* --- Year small multiples (par année, next to the all-time chart) --- */
#figli-dashboard-app .figli-year-hbar-grid {
display: grid;
@@ -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
@@ -20,6 +20,7 @@
--flform-border-soft: #e8eaed;
--flform-label: #4b5563;
--flform-text: #1a1a1a;
--flform-bg: #ffffff;
--flform-bg-subtle: #f7f8fa;
--flform-accent: #2f6f4f;
--flform-danger: #b3261e;
@@ -43,6 +44,7 @@ html.gin--dark-mode .figli-ledger-form {
--flform-border-soft: #333438;
--flform-label: #a1a5ab;
--flform-text: #e8e9ea;
--flform-bg: #3a3b40;
--flform-bg-subtle: #2a2b2e;
--flform-accent: #5fb98a;
--flform-danger: #ff6b6b;
@@ -108,6 +110,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;
@@ -192,10 +214,10 @@ html.gin--dark-mode .figli-ledger-form {
}
.figli-ledger-form table.field-multiple-table thead th {
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 {
padding: 0.35rem 0.6rem;
padding: 0.2rem 0.5rem;
vertical-align: top;
border-top: 1px solid var(--flform-border-soft);
}
@@ -204,12 +226,25 @@ html.gin--dark-mode .figli-ledger-form {
}
.figli-ledger-form .paragraphs-subform {
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 {
flex: 1;
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
Remove/Duplicate/Collapse actions -- all plain Drupal form-submit
@@ -229,7 +264,7 @@ html.gin--dark-mode .figli-ledger-form {
color: var(--flform-accent);
}
.figli-ledger-form .field-add-more-submit {
margin-top: 0.4rem;
margin-top: 0.25rem;
}
.figli-ledger-form .paragraphs-dropdown-toggle {
border: none;
@@ -267,6 +302,164 @@ html.gin--dark-mode .figli-ledger-form {
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
figli_compta_ledger_validate_repartition()) -- kept subtle (a red
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_paragraph_repartition();
_figli_compta_ledger_create_node_type_ligne_comptable();
// Fresh installs never run hook_update_N below the current schema
// version -- the import's dedup index is created here directly, and
// existing sites get it from figli_compta_ledger_update_8012().
_figli_compta_ledger_ensure_fitid_index();
}
/**
* Index on the bank statement import's dedup fingerprint column:
* ReleveUploadForm::submitForm() runs a grouped COUNT with
* WHERE field_import_fitid_value IN (...) on every upload. Negligible
* at ~1500 lines today, but that table only ever grows, and this keeps
* the lookup off a full scan without depending on the optimizer.
*/
function _figli_compta_ledger_ensure_fitid_index() {
$schema = \Drupal::database()->schema();
if ($schema->tableExists('node__field_import_fitid')
&& !$schema->indexExists('node__field_import_fitid', 'field_import_fitid_value')) {
// MySQL's addIndex() needs the column's field specification to
// normalize the index (utf8mb4 key-length check); varchar(64) stays
// under the 191-char shortening threshold, so the index covers the
// whole fingerprint column.
$schema->addIndex('node__field_import_fitid', 'field_import_fitid_value', ['field_import_fitid_value'], [
'fields' => [
'field_import_fitid_value' => ['type' => 'varchar', 'length' => 64, 'not null' => FALSE],
],
]);
}
}
function _figli_compta_ledger_create_vocabulary($vid, $name, array $terms) {
@@ -231,6 +258,12 @@ function _figli_compta_ledger_create_node_type_ligne_comptable() {
// filter and its future server-side equivalent can filter on a real
// stored value instead of resolving répartition paragraphs per row.
_figli_field('node', 'ligne_comptable', 'field_ecart', 'Écart', 'decimal', ['precision' => 12, 'scale' => 2]);
// Bank statement import (see src/Import/): dedup fingerprint per
// transaction, and the immutable bank amount for audit. No form/display
// widget for either -- same "hidden technical field" treatment as
// field_ecart before its dashboard treatment (update_8009).
_figli_field('node', 'ligne_comptable', 'field_import_fitid', 'Empreinte import relevé', 'string', ['max_length' => 64]);
_figli_field('node', 'ligne_comptable', 'field_montant_releve', 'Montant relevé bancaire (€)', 'decimal', ['precision' => 12, 'scale' => 2]);
_figli_field('node', 'ligne_comptable', 'field_notes', 'Notes / détail', 'string_long');
// Free-tagging signalement (e.g. "client impayé", "à relancer") -- purely
@@ -267,17 +300,22 @@ function _figli_compta_ledger_create_node_type_ligne_comptable() {
->setComponent('field_montant_ht', ['type' => 'number', 'weight' => 5])
->setComponent('field_cotisation_urssaf', ['type' => 'number', 'weight' => 6])
->setComponent('field_montant_ttc', ['type' => 'number', 'weight' => 8])
// field_tva sits *after* Montant TTC, not between Cotisation and
// TTC -- figli_compta_ledger_form_alter() inserts a non-field
// "field_tva_rate" select at weight 7 (a select of the official
// French VAT rates) to fill that visual slot instead; this real
// field only becomes visible (still at its own weight, on its own
// Read-only bank reference directly under Montant TTC (the widget
// is #disabled by figli_compta_ledger_form_alter()) -- see
// update_8013/_8014 for why this lives on the form despite being
// import-written only, and why it sits at weight 9.
->setComponent('field_montant_releve', ['type' => 'number', 'weight' => 9])
// field_tva sits *after* Montant TTC (and the bank reference), not
// between Cotisation and TTC -- figli_compta_ledger_form_alter()
// inserts a non-field "field_tva_rate" select at weight 7 (a
// select of the official French VAT rates) to fill that visual
// slot instead; this real field only becomes visible (on its own
// full-width row) when "Autre" is picked there. See
// css/ledger-form.css's grid-column rules for both.
->setComponent('field_tva', ['type' => 'number', 'weight' => 9])
->setComponent('field_repartition', ['type' => 'paragraphs', 'weight' => 10, 'settings' => ['title' => 'Répartition', 'title_plural' => 'Répartitions', 'edit_mode' => 'open', 'add_mode' => 'button']])
->setComponent('field_notes', ['type' => 'string_textarea', 'weight' => 11])
->setComponent('field_flag', ['type' => 'entity_reference_autocomplete_tags', 'weight' => 12])
->setComponent('field_tva', ['type' => 'number', 'weight' => 10])
->setComponent('field_repartition', ['type' => 'paragraphs', 'weight' => 11, 'settings' => ['title' => 'Répartition', 'title_plural' => 'Répartitions', 'edit_mode' => 'open', 'add_mode' => 'button', 'features' => ['collapse_edit_all' => 'collapse_edit_all']]])
->setComponent('field_notes', ['type' => 'string_textarea', 'weight' => 12])
->setComponent('field_flag', ['type' => 'entity_reference_autocomplete_tags', 'weight' => 13])
->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).";
}
/**
* 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é).';
}
@@ -25,6 +25,16 @@ dashboard:
- core/drupal
- figli_compta_ledger/vue
dashboard_repartition:
js:
js/dashboard-repartition.js: {}
css:
theme:
css/dashboard.css: {}
dependencies:
- core/drupal
- figli_compta_ledger/vue
dashboard_compte:
js:
js/dashboard-compte.js: {}
@@ -45,6 +55,11 @@ ledger_form:
- core/drupal
- core/once
releve_import:
css:
theme:
css/releve-import.css: {}
admin_chrome:
css:
theme:
@@ -29,3 +29,11 @@ figli_compta_ledger.history:
menu_name: admin
parent: system.admin
weight: -8
figli_compta_ledger.releve_import:
title: 'Importer un relevé'
description: 'Créer des lignes brouillon depuis un export CSV bancaire'
route_name: figli_compta_ledger.releve_import_form
menu_name: admin
parent: system.admin
weight: -7
@@ -119,6 +119,37 @@ function figli_compta_ledger_form_alter(&$form, FormStateInterface $form_state,
$form['field_montant_ttc']['widget'][0]['value']['#description'] = t('Calculé automatiquement à partir du montant HT et de la TVA.');
}
// Montant relevé bancaire: the bank statement import's immutable audit
// reference (see PLAN-import-releve-bancaire.md and src/Import/) --
// displayed as plain text, no input box at all: an editable-looking
// box for a value nobody may type wastes space and misleads. Replacing
// the widget with a #type => 'item' element is loss-proof by
// construction: an item submits nothing, and
// WidgetBase::extractFormValues() skips the field entirely when no
// value was submitted (its $key_exists check) -- the stored amount
// survives every save untouched, which is the whole point. Hidden
// entirely on lines that have no bank amount (manually entered ones).
if (isset($form['field_montant_releve'])) {
/** @var \Drupal\node\NodeInterface $entity */
$entity = $form_state->getFormObject()->getEntity();
if (!$entity->get('field_montant_releve')->isEmpty()) {
$montant = (float) $entity->get('field_montant_releve')->value;
// The widget's own #title can't be trusted here (Claro moves the
// label to its form-item wrapper and leaves an empty string on the
// input -- '' isn't caught by ??), so read the configured label.
$field_config = \Drupal\field\Entity\FieldConfig::loadByName('node', $entity->bundle(), 'field_montant_releve');
$form['field_montant_releve']['widget'] = [
'#type' => 'item',
'#title' => $field_config ? $field_config->getLabel() : t('Montant relevé bancaire (€)'),
'#markup' => '<strong class="figli-releve-value">' . number_format($montant, 2, ',', ' ') . ' €</strong>',
'#description' => t("Référence bancaire immuable — renseignée à l'import du relevé, jamais modifiée."),
];
}
else {
$form['field_montant_releve']['#access'] = FALSE;
}
}
// Cotisation diffuseur URSSAF (1,1%) -- only ever relevant for
// "Entrée client" lines (the only type invoiced to a client via a
// devis; see figli_compta_ledger_update_8006()'s docblock), so both
@@ -327,14 +358,21 @@ function figli_compta_ledger_node_form_ajax_submit(array $form, FormStateInterfa
* Historical imports (drush migration scripts) deliberately preserve the
* source spreadsheets' raw data, including known répartition mismatches --
* those get surfaced as visible inconsistencies in the dashboard instead of
* being silently fixed. Set the 'figli_compta_ledger.skip_validation' state
* flag around such a bulk import to bypass this check *and* the Montant TTC
* auto-computation below; new lines entered by associates through the form
* are never exempted from either. field_ecart is the one thing still kept
* in sync even under skip_validation (see below) -- separately, a second
* flag ('figli_compta_ledger.skip_revision') additionally suppresses the
* forced-revision block, used only by figli_compta_ledger_update_8010()'s
* field_ecart backfill.
* being silently fixed. Programmatic saves that need to bypass this check
* (bulk imports, the inline-edit endpoints, the bank statement import) wrap
* their save() in the figli_compta_ledger.skip_validation_context service
* (request-scoped, see \Drupal\figli_compta_ledger\SkipValidationContext).
* The legacy 'figli_compta_ledger.skip_validation' *state* key still works
* for already-shipped migration scripts, but new code must use the service:
* the state key is a site-wide flag, and a concurrent normal form save
* hitting the same window would silently skip validation too (see
* AUDIT-2026-09-09.md §2.2). Either way this bypasses this check *and* the
* Montant TTC auto-computation below; new lines entered by associates
* through the form are never exempted from either. field_ecart is the one
* thing still kept in sync even under skip (see below) -- separately, a
* second state flag ('figli_compta_ledger.skip_revision') additionally
* suppresses the forced-revision block, used only by
* figli_compta_ledger_update_8010()'s field_ecart backfill.
*/
function figli_compta_ledger_node_presave(NodeInterface $node) {
if ($node->bundle() !== 'ligne_comptable') {
@@ -365,7 +403,8 @@ function figli_compta_ledger_node_presave(NodeInterface $node) {
return;
}
$skip_validation = \Drupal::state()->get('figli_compta_ledger.skip_validation', FALSE);
$skip_validation = \Drupal::service('figli_compta_ledger.skip_validation_context')->isSkipped()
|| \Drupal::state()->get('figli_compta_ledger.skip_validation', FALSE);
$montant_ht = (float) $node->get('field_montant_ht')->value;
if (!$skip_validation) {
@@ -474,17 +513,25 @@ function figli_compta_ledger_node_presave(NodeInterface $node) {
function figli_compta_ledger_theme($existing, $type, $theme, $path) {
return [
'figli_compta_home' => [
'variables' => ['can_view_history' => FALSE, 'current_route' => NULL],
'variables' => ['can_view_history' => FALSE, 'can_import_releve' => FALSE, 'current_route' => NULL],
'template' => 'figli-compta-home',
],
'figli_compta_dashboard' => [
'variables' => ['current_route' => NULL],
'template' => 'figli-compta-dashboard',
],
'figli_compta_dashboard_repartition' => [
'variables' => ['current_route' => NULL],
'template' => 'figli-compta-dashboard-repartition',
],
'figli_compta_dashboard_compte' => [
'variables' => ['current_route' => NULL],
'template' => 'figli-compta-dashboard-compte',
],
'figli_compta_releve_import_result' => [
'variables' => ['summary' => [], 'lignes_url' => NULL, 'import_url' => NULL],
'template' => 'figli-compta-releve-import-result',
],
];
}
@@ -503,15 +550,36 @@ function figli_compta_ledger_page_attachments(array &$attachments) {
$front_end_routes = [
'figli_compta_ledger.home',
'figli_compta_ledger.dashboard',
'figli_compta_ledger.dashboard_repartition',
'figli_compta_ledger.dashboard_compte',
'figli_compta_ledger.history',
'figli_compta_ledger.link_entree',
'figli_compta_ledger.releve_import_form',
'figli_compta_ledger.releve_import_result',
];
if (in_array(\Drupal::routeMatch()->getRouteName(), $front_end_routes, TRUE)) {
$attachments['#attached']['library'][] = 'figli_compta_ledger/hide_admin_chrome';
}
}
/**
* Implements hook_gin_ignore_sticky_form_actions().
*
* Gin's sticky action buttons (forced on whenever the core Navigation
* module is active, as here) relocate a form's primary submit into the
* Gin chrome's sticky action bar -- the very chrome this module's
* front-end routes deliberately hide ($front_end_routes in
* figli_compta_ledger_page_attachments()). Without this opt-out, the
* full-page import form ends up with no visible button at all: Gin's
* after-build moves "Importer le relevé" into the hidden bar, leaving
* only managed_file's own inline "Remove" button. The other forms of
* this module don't need it -- node forms and LinkEntreeForm are opened
* in modals, which Gin skips by itself (isModalOrOffcanvas()).
*/
function figli_compta_ledger_gin_ignore_sticky_form_actions(): array {
return ['figli_compta_ledger_releve_upload_form'];
}
/**
* Implements hook_help().
*/
@@ -0,0 +1,9 @@
access figli ledger:
title: 'Accéder au grand livre'
description: 'Consulte les lignes comptables, les tableaux de bord et toutes les API du module (données financières de la SAS).'
restrict access: true
import ligne_comptable releve:
title: 'Importer un relevé bancaire'
description: 'Téléverse un export CSV bancaire et crée des lignes comptables brouillon à trier (crée du contenu).'
restrict access: true
@@ -4,7 +4,7 @@ figli_compta_ledger.home:
_controller: '\Drupal\figli_compta_ledger\Controller\DashboardController::home'
_title: 'Grand livre - SAS Figures Libres'
requirements:
_permission: 'access content'
_permission: 'access figli ledger'
figli_compta_ledger.dashboard:
path: '/dashboard'
@@ -12,7 +12,15 @@ figli_compta_ledger.dashboard:
_controller: '\Drupal\figli_compta_ledger\Controller\DashboardController::view'
_title: 'Tableau de bord - SAS Figures Libres'
requirements:
_permission: 'access content'
_permission: 'access figli ledger'
figli_compta_ledger.dashboard_repartition:
path: '/dashboard/repartition'
defaults:
_controller: '\Drupal\figli_compta_ledger\Controller\DashboardController::repartitionView'
_title: 'Répartition / Soldes - SAS Figures Libres'
requirements:
_permission: 'access figli ledger'
figli_compta_ledger.dashboard_compte:
path: '/dashboard/compte'
@@ -20,7 +28,7 @@ figli_compta_ledger.dashboard_compte:
_controller: '\Drupal\figli_compta_ledger\Controller\DashboardController::compteView'
_title: 'Tableau de bord par compte - SAS Figures Libres'
requirements:
_permission: 'access content'
_permission: 'access figli ledger'
figli_compta_ledger.history:
path: '/lignes/historique'
@@ -42,33 +50,26 @@ figli_compta_ledger.link_entree:
node:
type: entity:node
figli_compta_ledger.api_totaux_annee:
path: '/lignes/api/totaux'
defaults:
_controller: '\Drupal\figli_compta_ledger\Controller\LedgerStatsController::totauxAnnee'
requirements:
_permission: 'access content'
figli_compta_ledger.api_annees:
path: '/lignes/api/annees'
defaults:
_controller: '\Drupal\figli_compta_ledger\Controller\LedgerStatsController::annees'
requirements:
_permission: 'access content'
_permission: 'access figli ledger'
figli_compta_ledger.api_reconciliation_ouverture:
path: '/lignes/api/reconciliation-ouverture'
defaults:
_controller: '\Drupal\figli_compta_ledger\Controller\LedgerStatsController::reconciliationOuverture'
requirements:
_permission: 'access content'
_permission: 'access figli ledger'
figli_compta_ledger.api_groupe_entree:
path: '/lignes/api/groupe/{node}'
defaults:
_controller: '\Drupal\figli_compta_ledger\Controller\LedgerStatsController::groupeEntree'
requirements:
_permission: 'access content'
_permission: 'access figli ledger'
options:
parameters:
node:
@@ -79,14 +80,14 @@ figli_compta_ledger.api_lignes:
defaults:
_controller: '\Drupal\figli_compta_ledger\Controller\LedgerRowsController::index'
requirements:
_permission: 'access content'
_permission: 'access figli ledger'
figli_compta_ledger.api_dashboard_stats:
path: '/dashboard/api/stats'
defaults:
_controller: '\Drupal\figli_compta_ledger\Controller\DashboardStatsController::stats'
requirements:
_permission: 'access content'
_permission: 'access figli ledger'
figli_compta_ledger.update_type:
path: '/lignes/{node}/type'
@@ -111,3 +112,19 @@ figli_compta_ledger.update_field:
parameters:
node:
type: entity:node
figli_compta_ledger.releve_import_form:
path: '/lignes/importer-releve'
defaults:
_form: '\Drupal\figli_compta_ledger\Form\ReleveUploadForm'
_title: 'Importer un relevé bancaire'
requirements:
_permission: 'import ligne_comptable releve'
figli_compta_ledger.releve_import_result:
path: '/lignes/importer-releve/resultat'
defaults:
_controller: '\Drupal\figli_compta_ledger\Controller\ReleveImportResultController::result'
_title: "Résultat de l'import du relevé"
requirements:
_permission: 'import ligne_comptable releve'
@@ -3,3 +3,12 @@ services:
class: Drupal\figli_compta_ledger\EventSubscriber\RouteSubscriber
tags:
- { name: event_subscriber }
# Request-scoped répartition-check opt-out (see the class docblock: unlike
# the historical state key, a skip held here is invisible to concurrent
# requests -- audited in AUDIT-2026-09-09.md §2.2).
figli_compta_ledger.skip_validation_context:
class: Drupal\figli_compta_ledger\SkipValidationContext
figli_compta_ledger.client_matcher:
class: Drupal\figli_compta_ledger\Import\ClientMatcher
@@ -39,7 +39,32 @@
root.querySelectorAll('.messages-list__item').forEach(handleMessage);
}
/**
* Lifts the [data-drupal-messages] region to a direct <body> child.
*
* Gin renders it deep inside its layout (main.page-content > region
* highlighted), where ancestor stacking contexts neutralize the
* region's position:fixed + high z-index (admin-chrome.css): the whole
* subtree then stacks below the jQuery UI modal overlay -- a direct
* <body> child -- which is why messages inserted while a modal is open
* (MessageCommand-driven, e.g. the répartition-sum error on the
* ligne_comptable modal form) rendered unreadably *under* the overlay.
* As a <body> child, the region's z-index competes directly with the
* overlay/dialog and wins. Core itself puts the fallback wrapper at
* body level (Drupal.Message.defaultWrapper(), misc/message.js), so
* this is also where messages already land on a page without the
* region; MessageCommand re-queries the region at response time, so
* moving it after page load breaks no insertion path.
*/
function liftMessages() {
var region = document.querySelector('[data-drupal-messages]');
if (region && region.parentElement !== document.body) {
document.body.appendChild(region);
}
}
function init() {
liftMessages();
scan(document);
new MutationObserver(function (mutations) {
mutations.forEach(function (mutation) {
@@ -48,6 +73,16 @@
if (node.classList && node.classList.contains('messages-list__item')) {
handleMessage(node);
}
// A modal (or a re-rendered messages region) entering the DOM:
// re-check the lift right when it matters -- the region must
// already be body-level when the dialog's overlay and any
// MessageCommand insertions show up. matches() first because
// querySelector() never matches the node itself. liftMessages()
// is idempotent, so over-triggering is harmless.
if (node.matches('.ui-dialog, [data-drupal-messages]')
|| node.querySelector('.ui-dialog, [data-drupal-messages]')) {
liftMessages();
}
if (node.querySelectorAll) {
scan(node);
}
@@ -0,0 +1,163 @@
/**
* @file
* "Répartition / Soldes": solde par compte (all-time bar chart + one
* year-by-year trend per compte), split out of the general /dashboard so
* that page stays focused on activity/CA/type/client breakdowns. Same
* /dashboard/api/stats endpoint as dashboard.js/dashboard-compte.js
* (DashboardStatsController -- plain SQL GROUP BY, not Entity API), no
* new backend needed -- solde_par_compte/solde_par_compte_par_annee were
* already in that response, just unused on this page until now.
*
* HBarChart/MiniTrend are duplicated from dashboard.js rather than
* shared, same established convention as dashboard-compte.js's own
* copies (see dashboard.css's comment on the Signalement filter rules).
* Root element id is deliberately the same #figli-dashboard-app as the
* other two dashboard pages (not a unique id) -- dashboard.css scopes
* its CSS custom properties (--figli-bg, dark-mode overrides, etc.) to
* that selector, and reusing it is how all three dashboard pages pick
* those up without a separate stylesheet.
*/
(function (Drupal, Vue) {
'use strict';
const EUR_ROUND = new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 });
async function fetchStats() {
const res = await fetch('/dashboard/api/stats', { headers: { Accept: 'application/json' } });
if (!res.ok) throw new Error('/dashboard/api/stats a répondu ' + res.status);
return res.json();
}
// Horizontal bar chart -- same shape as dashboard.js's own copy,
// including the zero-centered "diverging" layout for negative values
// (a compte's solde can be a débit).
const HBarChart = {
props: {
items: { type: Array, required: true },
formatValue: { type: Function, required: true },
colorFor: { type: Function, default: null },
},
computed: {
hasNegative() {
return this.items.some((i) => i.value < 0);
},
maxAbs() {
return Math.max(1, ...this.items.map((i) => Math.abs(i.value)));
},
},
methods: {
fillStyle(item) {
const pct = (Math.abs(item.value) / this.maxAbs) * 100;
if (this.hasNegative) {
return item.value >= 0
? { left: '50%', width: pct / 2 + '%' }
: { right: '50%', width: pct / 2 + '%' };
}
return { left: 0, width: pct + '%' };
},
fillColor(item) {
if (this.colorFor) return this.colorFor(item);
return item.value < 0 ? 'var(--figli-error)' : 'var(--figli-positive)';
},
},
template:
'<div class="figli-hbar-chart">' +
'<div class="figli-hbar-row" v-for="item in items" :key="item.label">' +
'<div class="figli-hbar-label" :title="item.label">{{ item.label }}</div>' +
'<div class="figli-hbar-track" :class="{\'is-diverging\': hasNegative}">' +
'<div class="figli-hbar-zero" v-if="hasNegative"></div>' +
'<div class="figli-hbar-fill" :style="[fillStyle(item), {background: fillColor(item)}]"></div>' +
'</div>' +
'<div class="figli-hbar-value">{{ formatValue(item.value) }}</div>' +
'</div>' +
'</div>',
};
// Small multiples: one compact zero-centered bar-per-year trend per
// compte, instead of a single 8-series line chart -- same reasoning
// and same markup as dashboard.js's own copy.
const MiniTrend = {
props: {
annees: { type: Array, required: true },
values: { type: Array, required: true },
formatValue: { type: Function, required: true },
},
computed: {
maxAbs() {
return Math.max(1, ...this.values.filter((v) => v !== null).map((v) => Math.abs(v)));
},
},
methods: {
barHeight(v) {
if (v === null) return '0%';
return Math.max(3, (Math.abs(v) / this.maxAbs) * 100) + '%';
},
},
template:
'<div class="figli-mini-trend">' +
'<div class="figli-mini-bar-col" v-for="(v, i) in values" :key="annees[i]" :title="annees[i] + \' : \' + (v === null ? \'—\' : formatValue(v))">' +
'<div class="figli-mini-bar-track">' +
'<div class="figli-mini-bar-fill" :class="v !== null && v < 0 ? \'is-negative\' : \'is-positive\'" :style="{height: barHeight(v)}"></div>' +
'</div>' +
'<div class="figli-mini-bar-label">{{ annees[i].slice(2) }}</div>' +
'</div>' +
'</div>',
};
const App = {
components: { HBarChart, MiniTrend },
data() {
return { loading: true, error: null, stats: null };
},
computed: {
soldeParCompteItems() {
if (!this.stats) return [];
return Object.entries(this.stats.solde_par_compte)
.map(([label, value]) => ({ label, value }))
.sort((a, b) => b.value - a.value);
},
// Comptes ordered by all-time solde (richest first) -- same order
// as soldeParCompteItems, so the trend grid below reads as a
// continuation of the bar chart above it rather than an unrelated
// shuffle.
comptesOrdonnes() {
return this.soldeParCompteItems.map((i) => i.label);
},
},
methods: {
formatEurRound(v) {
return EUR_ROUND.format(v);
},
trendValues(compte) {
return this.stats.annees.map((y) => {
const parAnnee = this.stats.solde_par_compte_par_annee[y];
return parAnnee && parAnnee[compte] !== undefined ? parAnnee[compte] : null;
});
},
async load() {
this.loading = true;
this.error = null;
try {
this.stats = await fetchStats();
} catch (err) {
this.error = err.message;
} finally {
this.loading = false;
}
},
},
mounted() {
this.load();
},
};
Drupal.behaviors.figliComptaDashboardRepartition = {
attach(context) {
const root = context.querySelector ? context.querySelector('#figli-dashboard-app') : null;
if (root && !root.dataset.figliInitialized) {
root.dataset.figliInitialized = '1';
Vue.createApp(App).mount(root);
}
},
};
})(Drupal, Vue);
@@ -1,11 +1,12 @@
/**
* @file
* Dashboard: charts and aggregate totals (solde par compte, chiffre
* d'affaires par année, répartition par type, top clients), computed
* server-side (DashboardStatsController -- plain SQL GROUP BY, not Entity
* API) and rendered here as small dependency-free div/CSS bar charts. No
* charting library: this project vendors its own JS (see js/vendor/), and
* a handful of bar/line charts don't warrant pulling one in.
* Dashboard: charts and aggregate totals (chiffre d'affaires par année,
* répartition par type, top clients), computed server-side
* (DashboardStatsController -- plain SQL GROUP BY, not Entity API) and
* rendered here as small dependency-free div/CSS bar charts. No charting
* library: this project vendors its own JS (see js/vendor/), and a
* handful of bar/line charts don't warrant pulling one in. Solde par
* compte lives on its own page (js/dashboard-repartition.js).
*/
(function (Drupal, Vue) {
'use strict';
@@ -54,6 +55,12 @@
items: { type: Array, required: true },
formatValue: { type: Function, required: true },
colorFor: { type: Function, default: null },
// Narrower label/value columns, smaller text -- for the per-année
// small-multiples grids, where a full-width chart wouldn't fit in
// a grid card. Same prop as dashboard-compte.js's own HBarChart
// copy (this project duplicates the component rather than sharing
// it between dashboard.js/dashboard-compte.js, see dashboard.css).
compact: { type: Boolean, default: false },
},
computed: {
hasNegative() {
@@ -79,7 +86,7 @@
},
},
template:
'<div class="figli-hbar-chart">' +
'<div class="figli-hbar-chart" :class="{\'is-compact\': compact}">' +
'<div class="figli-hbar-row" v-for="item in items" :key="item.label">' +
'<div class="figli-hbar-label" :title="item.label">{{ item.label }}</div>' +
'<div class="figli-hbar-track" :class="{\'is-diverging\': hasNegative}">' +
@@ -119,41 +126,8 @@
'</div>',
};
// Small multiples: one compact zero-centered bar-per-year trend per
// compte, instead of a single 8-series line chart -- eight overlapping
// lines sharing one small area is hard to read; eight small independent
// trends, each answering "is this person's balance growing or
// shrinking", is not.
const MiniTrend = {
props: {
annees: { type: Array, required: true },
values: { type: Array, required: true },
formatValue: { type: Function, required: true },
},
computed: {
maxAbs() {
return Math.max(1, ...this.values.filter((v) => v !== null).map((v) => Math.abs(v)));
},
},
methods: {
barHeight(v) {
if (v === null) return '0%';
return Math.max(3, (Math.abs(v) / this.maxAbs) * 100) + '%';
},
},
template:
'<div class="figli-mini-trend">' +
'<div class="figli-mini-bar-col" v-for="(v, i) in values" :key="annees[i]" :title="annees[i] + \' : \' + (v === null ? \'—\' : formatValue(v))">' +
'<div class="figli-mini-bar-track">' +
'<div class="figli-mini-bar-fill" :class="v !== null && v < 0 ? \'is-negative\' : \'is-positive\'" :style="{height: barHeight(v)}"></div>' +
'</div>' +
'<div class="figli-mini-bar-label">{{ annees[i].slice(2) }}</div>' +
'</div>' +
'</div>',
};
const App = {
components: { HBarChart, ColumnChart: VBarChart, MiniTrend },
components: { HBarChart, ColumnChart: VBarChart },
data() {
return { loading: true, error: null, stats: null };
},
@@ -162,12 +136,6 @@
if (!this.stats) return [];
return this.stats.annees.map((y) => ({ label: y, value: this.stats.ca_par_annee[y] || 0 }));
},
soldeParCompteItems() {
if (!this.stats) return [];
return Object.entries(this.stats.solde_par_compte)
.map(([label, value]) => ({ label, value }))
.sort((a, b) => b.value - a.value);
},
typeItems() {
if (!this.stats) return [];
return Object.entries(this.stats.total_par_type)
@@ -178,12 +146,105 @@
if (!this.stats) return [];
return this.stats.top_clients.map((c) => ({ label: c.client, value: c.ca }));
},
// Comptes ordered by all-time solde (richest first) -- same order
// as soldeParCompteItems, so the trend grid below reads as a
// continuation of the bar chart above it rather than an unrelated
// shuffle.
comptesOrdonnes() {
return this.soldeParCompteItems.map((i) => i.label);
// Small multiples, one per year -- same source as typeItems() above
// (total_par_type_par_annee is the same node-level SQL query, just
// also grouped by année, no extra request). Mirrors
// dashboard-compte.js's typeItemsParAnnee, but that one filters a
// full row list client-side (it has one, scoped to a single
// compte); this page never fetches full rows (see this file's
// docblock), so the per-année breakdown has to already be
// pre-aggregated server-side.
typeItemsParAnnee() {
if (!this.stats) return [];
return this.stats.annees.map((annee) => {
const parType = this.stats.total_par_type_par_annee[annee] || {};
const items = Object.entries(parType)
.map(([type, value]) => ({ label: TYPE_LABELS[type] || type, value, type }))
.sort((a, b) => b.value - a.value);
return { annee, items };
}).filter((y) => y.items.length);
},
// Same idea for topClientsItems() -- top_clients_par_annee is
// already capped to 8 per year server-side (see
// DashboardStatsController::stats()), same reasoning as
// dashboard-compte.js capping its own per-année version to 5.
topClientsParAnnee() {
if (!this.stats) return [];
return this.stats.annees.map((annee) => {
const items = (this.stats.top_clients_par_annee[annee] || [])
.map((c) => ({ label: c.client, value: c.ca }));
return { annee, items };
}).filter((y) => y.items.length);
},
// Charges structurelles by client (i.e. by vendor: loyer,
// assurance, hébergement, URSSAF...) -- sorted by value descending
// server-side already (no alphabetical option asked for here,
// unlike the versements-par-compte chart), no top-N cap needed
// (see DashboardStatsController::stats()'s own comment -- only a
// handful of distinct vendors ever show up on a "charge" line).
// `type: 'charge'` on every item is just so colorFor="typeColor"
// (same method the type-breakdown chart uses) paints every bar the
// same "charge" grey -- every row here is that type by definition.
chargeParClientItems() {
if (!this.stats) return [];
return this.stats.charge_par_client.map((c) => ({ label: c.client, value: c.total, type: 'charge' }));
},
chargeParClientParAnnee() {
if (!this.stats) return [];
return this.stats.annees.map((annee) => {
const items = (this.stats.charge_par_client_par_annee[annee] || [])
.map((c) => ({ label: c.client, value: c.total, type: 'charge' }));
return { annee, items };
}).filter((y) => y.items.length);
},
// total_par_type_par_compte is already in the stats response --
// it's the same répartition-level query /dashboard/compte uses for
// its own (single-compte) type breakdown, just never sliced down
// to one type across every compte before now. No new backend
// field needed, just pull out the "versement" entry per compte.
// Salaire/stage and sous-traitant are the two other ways money
// leaves the collective toward a person/entity outside the 6
// comptes associés -- added as two extra rows (not split per
// compte, there's only one meaningful total each) so the chart
// reads as "who/what actually got paid", not just the 6 associés.
// Sourced from total_par_type (all-time)/total_par_type_par_annee,
// same fields typeItems()/typeItemsParAnnee() above already use.
// `type` on every item is what colorFor="typeColor" keys off of
// (same typeColor() method typeItems() already feeds) -- the 6
// compte rows are coloured as "versement" (they're that same
// money, just broken down by recipient instead of summed), and
// the two extra rows keep their own type colour.
versementsParCompteItems() {
if (!this.stats) return [];
// Alphabetical among the 6 comptes, but Salaire/stage and
// Sous-traitant always pushed on afterward -- they're not
// comptes, so they stay out of that ordering and just close out
// the list, in that fixed order.
const items = Object.entries(this.stats.total_par_type_par_compte)
.filter(([, parType]) => parType.versement !== undefined)
.map(([compte, parType]) => ({ label: compte, value: parType.versement, type: 'versement' }))
.sort((a, b) => a.label.localeCompare(b.label, 'fr'));
const parType = this.stats.total_par_type || {};
if (parType.salaire_stage) items.push({ label: TYPE_LABELS.salaire_stage, value: parType.salaire_stage, type: 'salaire_stage' });
if (parType.sous_traitant) items.push({ label: TYPE_LABELS.sous_traitant, value: parType.sous_traitant, type: 'sous_traitant' });
return items;
},
// Small multiples, one per year -- same source as typeItemsParAnnee
// above (total_par_type_par_compte_par_annee, already there for
// /dashboard/compte's own per-année breakdown).
versementsParCompteParAnnee() {
if (!this.stats) return [];
return this.stats.annees.map((annee) => {
const parCompte = this.stats.total_par_type_par_compte_par_annee[annee] || {};
const items = Object.entries(parCompte)
.filter(([, parType]) => parType.versement !== undefined)
.map(([compte, parType]) => ({ label: compte, value: parType.versement, type: 'versement' }))
.sort((a, b) => a.label.localeCompare(b.label, 'fr'));
const parType = this.stats.total_par_type_par_annee[annee] || {};
if (parType.salaire_stage) items.push({ label: TYPE_LABELS.salaire_stage, value: parType.salaire_stage, type: 'salaire_stage' });
if (parType.sous_traitant) items.push({ label: TYPE_LABELS.sous_traitant, value: parType.sous_traitant, type: 'sous_traitant' });
return { annee, items };
}).filter((y) => y.items.length);
},
totalCA() {
if (!this.stats) return 0;
@@ -206,15 +267,17 @@
formatEurRound(v) {
return EUR_ROUND.format(v);
},
trendValues(compte) {
return this.stats.annees.map((y) => {
const parAnnee = this.stats.solde_par_compte_par_annee[y];
return parAnnee && parAnnee[compte] !== undefined ? parAnnee[compte] : null;
});
},
typeColor(item) {
return TYPE_COLORS[item.type] || '#6b7280';
},
// Sum of an hbar chart's own items -- for the "Total" line under
// Charges structurelles par client (main chart and each per-année
// card): those items are already whatever's actually plotted, so
// summing them directly (rather than a separate stats field) keeps
// the total consistent with the bars above it by construction.
sumItems(items) {
return items.reduce((sum, i) => sum + i.value, 0);
},
async load() {
this.loading = true;
this.error = null;
@@ -305,10 +305,44 @@
return json;
}
async function fetchYearTotals(annee) {
const res = await fetch('/lignes/api/totaux?annee=' + encodeURIComponent(annee), { headers: { Accept: 'application/json' } });
if (!res.ok) throw new Error('/lignes/api/totaux a répondu ' + res.status);
return res.json();
// Sums the same fields LedgerRowsController::index() rows carry into
// the {montant_ht, cotisation, montant_ttc, ecart, par_compte} shape
// the footer template expects.
function aggregateTotals(rows) {
let montantHt = 0;
let cotisation = 0;
let montantTtc = 0;
let ecart = 0;
const parCompte = {};
for (const r of rows) {
montantHt += r.montant_ht || 0;
cotisation += r.cotisation || 0;
montantTtc += r.montant_ttc || 0;
ecart += r.ecart || 0;
for (const [c, v] of Object.entries(r.parCompte)) {
parCompte[c] = (parCompte[c] || 0) + v;
}
}
const round = (v) => Math.round(v * 100) / 100;
return {
montant_ht: round(montantHt),
cotisation: round(cotisation),
montant_ttc: round(montantTtc),
ecart: round(ecart),
par_compte: Object.fromEntries(Object.entries(parCompte).map(([c, v]) => [c, round(v)])),
};
}
// Per-année footer totals, filtered the same way the table itself is --
// fetches every row for the year via the same server-side-filtered
// endpoint the table window uses (fetchFilteredLignes), then sums them
// client-side. Replaces the old /lignes/api/totaux (LedgerStatsController::
// totauxAnnee(), always unfiltered) now that the footer needs to
// reflect whatever's actually on screen, not the whole year regardless
// of the active filters.
async function fetchYearTotals(annee, filters) {
const rows = await fetchFilteredLignes({ annee }, filters);
return Object.assign({ annee }, aggregateTotals(rows));
}
// URL hash (#compte=Maud,Bachir&type=versement,achat&annee=2023&ecarts=1&aller=2024)
@@ -411,7 +445,12 @@
parCompte,
somme,
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),
entreeLieeIds: entreeLieeNodes.map((n) => n.id),
entreeLieeLabels: entreeLieeNodes.map((n) => n.attributes.title || n.id),
@@ -1455,14 +1494,33 @@
// Every toolbar filter change routes through here: with filtering
// now server-side (see fetchFilteredLignes()), there's no more
// "just recompute a client-side view" -- the currently loaded
// window has to be re-fetched with the new filter applied. Queued
// through the same chain as loadOlder()/loadNewer() (see
// _queueWindowOp) since several filters can change in the same
// tick (e.g. mounted() restoring them all from the URL hash at
// once), and interleaving their fetches would race on `rows` the
// same way parallel loadOlder()/loadNewer() calls used to.
// window has to be re-fetched with the new filter applied. The
// reload itself is queued through the same chain as loadOlder()/
// loadNewer() (see _queueWindowOp) since several filters can change
// in the same tick (e.g. mounted() restoring them all from the URL
// hash at once), and interleaving their fetches would race on
// `rows` the same way parallel loadOlder()/loadNewer() calls used
// to. ensureScrollable() is deliberately chained AFTER that queued
// op settles, not passed into it: ensureScrollable() itself calls
// loadOlder()/loadNewer(), which each enqueue their own op onto the
// very same chain -- queuing it *inside* the op currently occupying
// that chain made the chain await its own continuation (the queued
// op can't finish until its child call, appended behind it on the
// same chain, finishes first) and deadlocked solid the moment a
// filter actually left too few rows to fill the viewport, wedging
// every future filter change and scroll-triggered load right along
// with it.
//
// loadCurrentYearTotals() is called unconditionally here (not just
// left to detectCurrentYear()'s own trigger): that one only
// re-fetches when the *visible year* changes, so a filter change
// while staying on the same year would otherwise leave the footer
// showing stale, pre-filter totals. Independent of the window
// reload above (different endpoint call, no shared state), so no
// need to chain it through the same queue.
onFilterChanged() {
this._queueWindowOp(() => this.reloadWindow().then(() => this.ensureScrollable()));
this._queueWindowOp(() => this.reloadWindow()).then(() => this.ensureScrollable());
this.loadCurrentYearTotals();
this.syncHash();
},
// Keeps extending the window (both directions) as long as a filter
@@ -1581,7 +1639,7 @@
if (!this.currentYear) return;
this.currentYearLoading = true;
try {
this.currentYearTotals = await fetchYearTotals(this.currentYear);
this.currentYearTotals = await fetchYearTotals(this.currentYear, this.currentFilters());
} catch (err) {
this.currentYearTotals = null;
} finally {
@@ -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);
@@ -18,6 +18,7 @@ class DashboardController extends ControllerBase {
return [
'#theme' => 'figli_compta_home',
'#can_view_history' => $this->currentUser()->hasPermission('view ligne_comptable revisions'),
'#can_import_releve' => $this->currentUser()->hasPermission('import ligne_comptable releve'),
'#current_route' => 'figli_compta_ledger.home',
'#attached' => [
'library' => ['figli_compta_ledger/home'],
@@ -40,7 +41,23 @@ class DashboardController extends ControllerBase {
}
/**
* Third page: one compte associé (freelance) at a time -- entrées client
* Third page: solde par compte, all-time and year by year -- split out
* of the general /dashboard so that page stays focused on activité/CA/
* type/client rather than per-compte balances. Same
* /dashboard/api/stats data as /dashboard, just a different slice of it.
*/
public function repartitionView() {
return [
'#theme' => 'figli_compta_dashboard_repartition',
'#current_route' => 'figli_compta_ledger.dashboard_repartition',
'#attached' => [
'library' => ['figli_compta_ledger/dashboard_repartition'],
],
];
}
/**
* Fourth page: one compte associé (freelance) at a time -- entrées client
* vs versements freelance, and above all which entrées haven't been
* (fully) paid out yet. Complements the aggregate /dashboard above,
* which mixes every compte and every type together.
@@ -60,9 +60,9 @@ class DashboardStatsController extends ControllerBase {
// share, pre-summed per (annee, compte, type) in SQL -- backs solde
// par compte, both all-time and per-year (each year's own total
// already includes that year's ouverture line, so it *is* that
// year's closing balance -- same logic as
// LedgerStatsController::totauxAnnee()), and the per-compte type
// breakdown used by /dashboard/compte.
// year's closing balance -- same logic as home.js's
// aggregateTotals()/fetchYearTotals() use for the /lignes footer),
// and the per-compte type breakdown used by /dashboard/compte.
$compteQuery = $connection->select('node__field_date_ligne', 'd');
$compteQuery->innerJoin('node__field_type_ligne', 't2', 't2.entity_id = d.entity_id');
$compteQuery->innerJoin('node__field_repartition', 'r', 'r.entity_id = d.entity_id');
@@ -82,7 +82,11 @@ class DashboardStatsController extends ControllerBase {
// --- Aggregate the node-level rows in PHP. ---
$caParAnnee = [];
$totalParType = [];
$totalParTypeParAnnee = [];
$caParClient = [];
$caParClientParAnnee = [];
$chargeParClient = [];
$chargeParClientParAnnee = [];
$annees = [];
foreach ($nodeRows as $row) {
$montant = $row->montant_ht !== NULL ? (float) $row->montant_ht : 0.0;
@@ -97,6 +101,16 @@ class DashboardStatsController extends ControllerBase {
$client = $row->client ?: '(sans client)';
$caParClient[$client] = ($caParClient[$client] ?? 0) + $montant;
}
// Same "who does this money go to" breakdown as $caParClient above,
// but for charges structurelles instead of entrées -- field_client
// on a charge line is the vendor/organisme (loyer, assurance,
// URSSAF...), not a paying client, but it's the same field/same
// taxonomy, so the same grouping applies. Absolute value, same
// convention as $totalParType.
if ($row->type === 'charge') {
$client = $row->client ?: '(sans client)';
$chargeParClient[$client] = ($chargeParClient[$client] ?? 0) + abs($montant);
}
if (!$this->isAnneeValide($row->annee)) {
continue;
@@ -104,6 +118,22 @@ class DashboardStatsController extends ControllerBase {
$annees[$row->annee] = TRUE;
if ($row->type === 'entree') {
$caParAnnee[$row->annee] = ($caParAnnee[$row->annee] ?? 0) + $montant;
// Per-année équivalent of $caParClient above -- backs the "Top
// clients par année" small multiples, same rows, no extra query.
$client = $row->client ?: '(sans client)';
$caParClientParAnnee[$row->annee][$client] =
($caParClientParAnnee[$row->annee][$client] ?? 0) + $montant;
}
if ($row->type === 'charge') {
$client = $row->client ?: '(sans client)';
$chargeParClientParAnnee[$row->annee][$client] =
($chargeParClientParAnnee[$row->annee][$client] ?? 0) + abs($montant);
}
// Per-année équivalent of $totalParType above -- backs the
// "Répartition de l'activité par type" small multiples.
if ($row->type !== 'ouverture') {
$totalParTypeParAnnee[$row->annee][$row->type] =
($totalParTypeParAnnee[$row->annee][$row->type] ?? 0) + abs($montant);
}
}
arsort($caParClient);
@@ -116,6 +146,43 @@ class DashboardStatsController extends ControllerBase {
$topClients[] = ['client' => $client, 'ca' => round($total, 2)];
}
// Top 8 (not 12 like the all-time chart above) -- one per year keeps
// the small-multiples grid readable, same reasoning as the per-compte
// équivalent on /dashboard/compte (there it's top 5, computed
// client-side from full row data; here it's top 8, computed here
// since dashboard.js only ever gets pre-aggregated SQL, never full
// rows -- see this controller's docblock).
$topClientsParAnnee = [];
foreach ($caParClientParAnnee as $annee => $parClient) {
arsort($parClient);
$topClientsParAnnee[$annee] = [];
$i = 0;
foreach ($parClient as $client => $total) {
if ($i++ >= 8) {
break;
}
$topClientsParAnnee[$annee][] = ['client' => $client, 'ca' => round($total, 2)];
}
}
// No top-N cap here (unlike top clients above) -- structural charges
// only ever go to a handful of recurring vendors (loyer, assurance,
// hébergement, URSSAF...), not the 100+ distinct clients entrées can
// have, so the full list is already short.
arsort($chargeParClient);
$chargeParClientList = [];
foreach ($chargeParClient as $client => $total) {
$chargeParClientList[] = ['client' => $client, 'total' => round($total, 2)];
}
$chargeParClientParAnneeList = [];
foreach ($chargeParClientParAnnee as $annee => $parClient) {
arsort($parClient);
$chargeParClientParAnneeList[$annee] = [];
foreach ($parClient as $client => $total) {
$chargeParClientParAnneeList[$annee][] = ['client' => $client, 'total' => round($total, 2)];
}
}
// --- Aggregate the répartition-level rows in PHP. ---
$soldeParCompte = [];
$soldeParCompteParAnnee = [];
@@ -170,12 +237,22 @@ class DashboardStatsController extends ControllerBase {
// year, which can differ year to year.
ksort($soldeParCompteParAnnee);
ksort($totalParTypeParCompteParAnnee);
ksort($totalParTypeParAnnee);
ksort($topClientsParAnnee);
ksort($chargeParClientParAnneeList);
return new JsonResponse([
'annees' => $anneesList,
'ca_par_annee' => array_map(fn ($v) => round($v, 2), $caParAnnee),
'total_par_type' => array_map(fn ($v) => round($v, 2), $totalParType),
'total_par_type_par_annee' => array_map(
fn ($parType) => array_map(fn ($v) => round($v, 2), $parType),
$totalParTypeParAnnee
),
'top_clients' => $topClients,
'top_clients_par_annee' => $topClientsParAnnee,
'charge_par_client' => $chargeParClientList,
'charge_par_client_par_annee' => $chargeParClientParAnneeList,
'solde_par_compte' => array_map(fn ($v) => round($v, 2), $soldeParCompte),
'solde_par_compte_par_annee' => $soldeParCompteParAnnee,
'total_par_type_par_compte' => array_map(
@@ -62,10 +62,10 @@ class HistoryController extends ControllerBase {
// pinned next to the page title) -- this controller has no twig
// template of its own to put a real <nav> in, but the CSS only
// ever targets the class, not the tag, so a render-array
// 'container' (<div>) here looks identical. None of the three
// 'container' (<div>) here looks identical. None of the four
// links is ever "active" here since this history feed isn't one
// of them -- same as visiting it from any of the other pages'
// nav, which doesn't include a 4th "Historique" entry either.
// nav, which doesn't include a 5th "Historique" entry either.
'nav' => [
'#type' => 'container',
'#attributes' => ['class' => ['figli-page-nav']],
@@ -76,9 +76,14 @@ class HistoryController extends ControllerBase {
],
'dashboard' => [
'#type' => 'link',
'#title' => $this->t('Dashboard'),
'#title' => $this->t('SAS'),
'#url' => Url::fromRoute('figli_compta_ledger.dashboard'),
],
'dashboard_repartition' => [
'#type' => 'link',
'#title' => $this->t('Répartition/Soldes'),
'#url' => Url::fromRoute('figli_compta_ledger.dashboard_repartition'),
],
'dashboard_compte' => [
'#type' => 'link',
'#title' => $this->t('Par compte'),
@@ -5,8 +5,10 @@ namespace Drupal\figli_compta_ledger\Controller;
use Drupal\Core\Access\CsrfRequestHeaderAccessCheck;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Entity\EntityStorageException;
use Drupal\figli_compta_ledger\SkipValidationContext;
use Drupal\node\NodeInterface;
use Drupal\taxonomy\Entity\Term;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
@@ -23,6 +25,24 @@ use Symfony\Component\HttpFoundation\Request;
*/
class LedgerActionsController extends ControllerBase {
/**
* Request-scoped répartition-check opt-out -- see the class docblock of
* \Drupal\figli_compta_ledger\SkipValidationContext for why this replaced
* the historical global state key here.
*
* @var \Drupal\figli_compta_ledger\SkipValidationContext
*/
protected $skipValidationContext;
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
$instance = parent::create($container);
$instance->skipValidationContext = $container->get('figli_compta_ledger.skip_validation_context');
return $instance;
}
/**
* Fields editable inline from /lignes without opening the full node
* edit form -- keys are the short names the frontend sends; values are
@@ -96,20 +116,17 @@ class LedgerActionsController extends ControllerBase {
// (from historical data, never corrected -- see figli_compta_ledger's
// module docblock) exactly as it was. The présave check exists to
// catch new inconsistent entries, not to block relabeling the type of
// an already-migrated line, so skip it for this save only. try/finally
// guarantees the global flag clears even if save() throws for an
// unrelated reason -- leaving it on would silently skip validation on
// every other save on the site.
\Drupal::state()->set('figli_compta_ledger.skip_validation', TRUE);
// an already-migrated line, so skip it for this save only. The
// SkipValidationContext service is request-scoped with a try/finally
// inside skip(), so the check is back on the instant save() returns
// or throws -- no global flag left hanging that a concurrent save
// from someone else could fall into.
try {
$node->save();
$this->skipValidationContext->skip(fn () => $node->save());
}
catch (EntityStorageException $e) {
return new JsonResponse(['error' => $e->getMessage()], 422);
}
finally {
\Drupal::state()->delete('figli_compta_ledger.skip_validation');
}
return new JsonResponse([
'success' => TRUE,
@@ -167,17 +184,14 @@ class LedgerActionsController extends ControllerBase {
// signalement changes here, montant_ht and field_repartition are
// untouched, so skipping the répartition check for this save can
// never introduce a mismatch -- it can only leave a pre-existing
// historical one exactly as it was.
\Drupal::state()->set('figli_compta_ledger.skip_validation', TRUE);
// historical one exactly as it was. Request-scoped skip (see
// updateType()'s comment), no global flag.
try {
$node->save();
$this->skipValidationContext->skip(fn () => $node->save());
}
catch (EntityStorageException $e) {
return new JsonResponse(['error' => $e->getMessage()], 422);
}
finally {
\Drupal::state()->delete('figli_compta_ledger.skip_validation');
}
if ($field === 'client') {
$newValue = $node->get('field_client')->entity ? $node->get('field_client')->entity->label() : NULL;
@@ -172,7 +172,13 @@ class LedgerRowsController extends ControllerBase {
'parCompte' => (object) $parCompte,
'somme' => $somme,
'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),
'entreeLieeIds' => array_map(fn ($n) => $n->uuid(), $entreeLieeNodes),
'entreeLieeLabels' => array_map(fn ($n) => $n->getTitle() ?: $n->uuid(), $entreeLieeNodes),
@@ -5,87 +5,20 @@ namespace Drupal\figli_compta_ledger\Controller;
use Drupal\Core\Controller\ControllerBase;
use Drupal\node\NodeInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
/**
* Small aggregate endpoints backing the /lignes sliding window: the
* row-level JSON:API fetch only ever covers a date range (see home.js), so
* neither the totals footer nor the "Année" dropdown can be computed from
* whatever's currently loaded -- they need their own always-accurate
* queries, decoupled from the row window.
* row-level fetch only ever covers a date range (see home.js), so the
* "Année" dropdown can't be computed from whatever's currently loaded --
* it needs its own always-accurate query, decoupled from the row window.
* The footer totals used to live here too (totauxAnnee(), unfiltered)
* until the footer needed to reflect the active toolbar filters -- it's
* now computed client-side in home.js from LedgerRowsController::index()
* rows instead, the same server-side-filtered endpoint the table itself
* uses.
*/
class LedgerStatsController extends ControllerBase {
/**
* GET /lignes/api/totaux?annee=2023 -- per-compte répartition sums (plus
* montant HT/TTC/écart totals) for every ligne_comptable dated that
* year, including ouverture lines: the footer is meant to read as the
* actual account balance (solde) for the year, i.e. the same "clôture
* calculée" (ouverture + every movement dated within the year) that
* reconciliationOuverture() compares the *next* year's ouverture
* against. Excluding ouverture here would make this a net-movement
* figure instead, which never matches what the reconciliation badge's
* tooltip cites for the same year.
*/
public function totauxAnnee(Request $request) {
$annee = $request->query->get('annee');
if (!$annee || !preg_match('/^\d{4}$/', $annee)) {
return new JsonResponse(['error' => 'Paramètre "annee" invalide.'], 400);
}
$storage = $this->entityTypeManager()->getStorage('node');
$nids = $storage->getQuery()
->accessCheck(TRUE)
->condition('type', 'ligne_comptable')
->condition('field_date_ligne', $annee . '-01-01', '>=')
->condition('field_date_ligne', ((int) $annee + 1) . '-01-01', '<')
->execute();
$par_compte = [];
$montant_ht = 0.0;
$cotisation = 0.0;
$montant_ttc = 0.0;
$ecart = 0.0;
foreach ($storage->loadMultiple($nids) as $node) {
$ht = $node->hasField('field_montant_ht') && !$node->get('field_montant_ht')->isEmpty()
? (float) $node->get('field_montant_ht')->value : 0.0;
$ttc = $node->hasField('field_montant_ttc') && !$node->get('field_montant_ttc')->isEmpty()
? (float) $node->get('field_montant_ttc')->value : 0.0;
$montant_ht += $ht;
$montant_ttc += $ttc;
// Blank (never 0) for most lines -- only "Entrée client" lines
// with the cotisation checkbox on ever have this field set (see
// figli_compta_ledger_node_presave()) -- but summing a blank
// value as 0 here is exactly right for a total.
if ($node->hasField('field_cotisation_urssaf') && !$node->get('field_cotisation_urssaf')->isEmpty()) {
$cotisation += (float) $node->get('field_cotisation_urssaf')->value;
}
$somme = 0.0;
foreach ($node->get('field_repartition')->referencedEntities() as $paragraph) {
if (!$paragraph->hasField('field_montant') || $paragraph->get('field_montant')->isEmpty()) {
continue;
}
$montant = (float) $paragraph->get('field_montant')->value;
$compte = $paragraph->get('field_compte')->entity ? $paragraph->get('field_compte')->entity->label() : NULL;
if ($compte) {
$par_compte[$compte] = ($par_compte[$compte] ?? 0) + $montant;
}
$somme += $montant;
}
$ecart += round($ht - $somme, 2);
}
return new JsonResponse([
'annee' => $annee,
'montant_ht' => round($montant_ht, 2),
'cotisation' => round($cotisation, 2),
'montant_ttc' => round($montant_ttc, 2),
'ecart' => round($ecart, 2),
'par_compte' => array_map(fn ($v) => round($v, 2), $par_compte),
]);
}
/**
* GET /lignes/api/annees -- distinct years, most recent first, with at
* least 5 lines. The threshold exists specifically to keep the handful
@@ -0,0 +1,72 @@
<?php
namespace Drupal\figli_compta_ledger\Controller;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Routing\LocalRedirectResponse;
use Drupal\Core\Url;
/**
* Post-import summary page: counts, the accounting control total
* (file total == created + duplicates, to the centime), the list of
* duplicates skipped for human review, and a deep link into /lignes
* filtered on this batch's "IMP AAMMJJ" tag.
*
* The numbers live one-shot in the private tempstore (written by
* ReleveImportBatch::finished(), read + purged here): the page is only
* meaningful right after an import, and every user sees their own.
*/
class ReleveImportResultController extends ControllerBase {
/**
* Renders the summary of the import that just ran.
*/
public function result() {
$store = \Drupal::service('tempstore.private')->get('figli_compta_ledger');
$summary = $store->get('releve_import_result');
if (!$summary) {
// Direct navigation (bookmark, back button long after the import):
// no numbers in memory anymore, send back to the form instead of
// showing an empty shell.
$this->messenger()->addWarning($this->t("Le résultat d'un import n'est disponible qu'immédiatement après l'import lui-même."));
return new LocalRedirectResponse(Url::fromRoute('figli_compta_ledger.releve_import_form')->toString());
}
$store->delete('releve_import_result');
$eur = fn ($x) => number_format((float) $x, 2, ',', ' ') . ' €';
$fr_date = fn ($iso) => preg_replace('/^(\d{4})-(\d{2})-(\d{2})$/', '$3/$2/$1', (string) $iso);
$view = [
'file_name' => $summary['file_name'],
'tag' => $summary['tag'],
'created' => (int) $summary['created'],
'duplicates' => (int) $summary['duplicates'],
'matched' => (int) $summary['matched'],
'unmatched' => (int) $summary['unmatched'],
'errors_count' => count($summary['errors']),
'errors' => array_map(fn ($e) => $e['libelle'] . ' — ' . $e['error'], $summary['errors']),
'duplicates_list' => array_map(fn ($d) => [
'date' => $fr_date($d['date']),
'montant' => $eur($d['montant']),
'libelle' => $d['libelle'],
], $summary['duplicates_list']),
'file_total' => $eur($summary['file_total']),
'created_total' => $eur($summary['created_total']),
'duplicates_total' => $eur($summary['duplicates_total']),
'totals_ok' => (bool) $summary['totals_ok'],
];
return [
'#theme' => 'figli_compta_releve_import_result',
'#summary' => $view,
// /lignes reads its filter state from location.hash -- the flag
// filter key is "tag" (see readHashState() in js/home.js), values
// are comma-separated flag names.
'#lignes_url' => Url::fromRoute('figli_compta_ledger.home')->toString() . '#tag=' . rawurlencode($summary['tag']),
'#import_url' => Url::fromRoute('figli_compta_ledger.releve_import_form')->toString(),
'#attached' => ['library' => ['figli_compta_ledger/releve_import']],
'#cache' => ['max-age' => 0],
];
}
}
@@ -24,7 +24,11 @@ class RouteSubscriber extends RouteSubscriberBase {
*/
protected function alterRoutes(RouteCollection $collection) {
if ($route = $collection->get('system.entity_autocomplete')) {
$route->setRequirement('_permission', 'access content');
// 'access figli ledger' rather than 'access content': the generic
// Authenticated role no longer holds the latter (removed 2026-09),
// and anyone entitled to see ledger autocomplete suggestions must
// be entitled to the ledger's data itself.
$route->setRequirement('_permission', 'access figli ledger');
}
}
@@ -0,0 +1,184 @@
<?php
namespace Drupal\figli_compta_ledger\Form;
use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\file\Entity\File;
use Drupal\figli_compta_ledger\Import\CsvReleveParser;
use Drupal\figli_compta_ledger\Import\ReleveImportBatch;
use Drupal\figli_compta_ledger\Import\ReleveTransaction;
use Drupal\taxonomy\Entity\Term;
/**
* Upload form for a bank statement export (CSV v1 -- see
* PLAN-import-releve-bancaire.md). Self-service for the associates: the
* file lands in the *private* filesystem (financial data), is parsed in
* memory, deduplicated count-aware against lines already in base, then
* turned into "à trier" draft lines by ReleveImportBatch. Nothing here
* writes ledger lines directly -- everything goes through the normal
* Node::save() lifecycle under SkipValidationContext.
*/
class ReleveUploadForm extends FormBase {
/**
* {@inheritdoc}
*/
public function getFormId(): string {
return 'figli_compta_ledger_releve_upload_form';
}
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state): array {
$form['releve_file'] = [
'#type' => 'managed_file',
'#title' => $this->t('Relevé bancaire (CSV)'),
'#upload_location' => 'private://releves',
// Only .csv: the OFX/CMI exports of the same account exist but
// are explicitly out of v1 scope (truncated labels / unstable
// structure -- see the plan). A clear message beats a silent
// failure for someone uploading them by mistake.
'#upload_validators' => [
'FileExtension' => ['extensions' => 'csv'],
],
'#required' => TRUE,
'#description' => $this->t('Export CSV de la banque : colonnes « Date ; Date de valeur ; Débit ; Crédit ; Libellé ; Solde » (les fichiers .ofx et .cmi ne sont pas pris en charge pour l\'instant). Chaque transaction devient une ligne « à trier » : type, répartition et HT/TVA restent à assigner à la main.'),
];
$form['actions'] = ['#type' => 'actions'];
$form['actions']['submit'] = [
'#type' => 'submit',
'#value' => $this->t('Importer le relevé'),
'#button_type' => 'primary',
];
return $form;
}
/**
* {@inheritdoc}
*
* The idiomatic home for the parse: an invalid file is a validation
* error, rejected before anything is written (the file entity is only
* promoted in submitForm()). The parsed transactions are stashed in
* $form_state so the file is never parsed twice.
*/
public function validateForm(array &$form, FormStateInterface $form_state): void {
$fids = $form_state->getValue('releve_file');
$fids = is_array($fids) ? $fids : [];
if (!$fids) {
// #required already covers the empty case.
return;
}
$file = File::load(reset($fids));
if (!$file) {
$form_state->setErrorByName('releve_file', $this->t("Le fichier téléversé n'a pas pu être retrouvé."));
return;
}
// Parse (pure, no writes). A clean form error -- never a crash page
// -- for anything the parser rejects.
try {
$transactions = (new CsvReleveParser())->parse($file->getFileUri());
}
catch (\RuntimeException $e) {
$form_state->setErrorByName('releve_file', $e->getMessage());
return;
}
$form_state->set('releve_fid', (int) $file->id());
$form_state->set('releve_transactions', array_map(fn ($t) => $t->toArray(), $transactions));
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state): void {
$file = File::load($form_state->get('releve_fid'));
$transactions = array_map([ReleveTransaction::class, 'fromArray'], $form_state->get('releve_transactions') ?: []);
if (!$file || !$transactions) {
// Normally unreachable -- validateForm() blocks bad files before
// submit is reached. Defensive only.
$form_state->setErrorByName('releve_file', $this->t("Rien à importer : relancez l'upload."));
return;
}
// Keep the uploaded statement permanently + registered as our usage:
// it's accounting source material, cron must not garbage-collect it
// after a few hours as it would a temporary file.
$file->setPermanent();
$file->save();
\Drupal::service('file.usage')->add($file, 'figli_compta_ledger', 'releve_import', (int) $file->id());
// Count-aware dedup: how many times each fingerprint appears in this
// file (k), one grouped query for how many already exist in base
// (m, all provenances combined), quota = max(0, k − m).
$counts = [];
foreach ($transactions as $t) {
$counts[$t->fitid] = ($counts[$t->fitid] ?? 0) + 1;
}
$db_counts = [];
if ($counts) {
$select = \Drupal::database()->select('node__field_import_fitid', 'f')
->condition('f.field_import_fitid_value', array_keys($counts), 'IN');
$select->addField('f', 'field_import_fitid_value', 'fitid');
$select->addExpression('COUNT(*)', 'n');
$select->groupBy('f.field_import_fitid_value');
foreach ($select->execute()->fetchAllKeyed() as $fitid => $n) {
$db_counts[$fitid] = (int) $n;
}
}
$quotas = [];
foreach ($counts as $fitid => $k) {
$quotas[$fitid] = max(0, $k - ($db_counts[$fitid] ?? 0));
}
// One "IMP AAMMJJ" flag term per import batch (day granularity: the
// same day's re-imports join the same lot) -- short on purpose, it
// renders as a badge in /lignes' narrow Signalement column. The
// associates sort lines through the existing signalement mechanism
// (filter + amber marker), zero new UI.
$tag = 'IMP ' . date('ymd');
$terms = \Drupal::entityTypeManager()->getStorage('taxonomy_term')
->loadByProperties(['vid' => 'flag', 'name' => $tag]);
if ($terms) {
$term = reset($terms);
}
else {
$term = Term::create(['vid' => 'flag', 'name' => $tag]);
$term->save();
}
$file_total = 0.0;
$payload = [];
foreach ($transactions as $t) {
$file_total += $t->montant;
$payload[] = $t->toArray();
}
batch_set([
'title' => $this->t('Import du relevé bancaire'),
'operations' => [
[
[ReleveImportBatch::class, 'operation'],
[$payload, $quotas, [
'file_name' => $file->getFilename(),
'file_total' => round($file_total, 2),
'tag' => $tag,
'flag_tid' => (int) $term->id(),
]],
],
],
'finished' => [ReleveImportBatch::class, 'finished'],
'init_message' => $this->t('Import du relevé en cours…'),
'progress_message' => $this->t('@current/@total'),
'error_message' => $this->t('L\'import a rencontré une erreur inattendue.'),
]);
// Where the browser lands once the batch is done -- the result page
// reads its numbers from the private tempstore.
$form_state->setRedirect('figli_compta_ledger.releve_import_result');
}
}
@@ -0,0 +1,144 @@
<?php
namespace Drupal\figli_compta_ledger\Import;
use Drupal\taxonomy\Entity\Term;
/**
* Suggests which client term a bank label refers to -- conservatively:
* an empty match a human fills in beats a wrong match nobody re-checks
* (the same philosophy as the whole import feature: pre-fill, never
* decide).
*
* Matching is word-based, never raw substring: after normalization
* (uppercase, accents removed, non-alphanumerics as separators) a
* client's full name must appear as a contiguous word *sequence* in the
* label ("OVH SAS" matches "PRLV SEPA OVH SAS TWLN…" but a hypothetical
* client "AIR" would NOT match "CLAIR" -- the v0 substring draft of this
* plan had exactly that false-positive mode for short names).
*
* Pass 1: full normalized client name as contiguous word sequence. Two
* distinct clients matching is ambiguous → empty.
* Pass 2 (only if pass 1 found nothing): a single "significant" word
* (≥ 4 chars, not a legal-form filler like SAS/SARL) that belongs to
* exactly ONE client in the whole vocabulary. Several candidate clients
* → empty. Deliberately recall-biased: a generic-but-unique word (say
* "MAISON", held by a single client) can suggest the wrong client for
* an unrelated label -- acceptable because every imported line is
* flagged and manually sorted (see ReleveImportBatch), so a wrong
* suggestion gets corrected by a human rather than trusted.
*/
final class ClientMatcher {
/**
* Legal-form filler words never significant on their own.
*/
private const STOPWORDS = ['SAS', 'SARL', 'SA', 'SASU', 'EURL', 'SCI', 'SCOP', 'ASSOCIATION', 'GMBH', 'SNC'];
/**
* Loaded client vocabulary, shape: [['term' => Term, 'words' => string[]]].
*
* @var array|null
*/
private ?array $clients = NULL;
/**
* Returns the client term a bank label most likely refers to, or NULL
* when nothing safe can be said. Never creates a term (unlike flag
* auto-creation) -- the client vocabulary stays curated by hand.
*/
public function match(string $libelle): ?Term {
$words = $this->words($libelle);
if (!$words) {
return NULL;
}
$clients = $this->loadClients();
if (!$clients) {
return NULL;
}
// Pass 1: full name as a contiguous word sequence, unique candidate.
$pass1 = [];
foreach ($clients as $client) {
if (self::containsSequence($words, $client['words'])) {
$pass1[$client['term']->id()] = $client['term'];
}
}
if (count($pass1) === 1) {
return reset($pass1);
}
if (count($pass1) > 1) {
return NULL;
}
// Pass 2: a significant word owned by exactly one client vocabulary.
$pass2 = [];
foreach ($clients as $client) {
foreach ($client['words'] as $word) {
if (mb_strlen($word) < 4 || in_array($word, self::STOPWORDS, TRUE)) {
continue;
}
if (in_array($word, $words, TRUE)) {
$pass2[$client['term']->id()] = $client['term'];
break;
}
}
}
if (count($pass2) === 1) {
return reset($pass2);
}
return NULL;
}
/**
* Uppercase, accent-free word tokens: "EPAU / POPSU" → ["EPAU","POPSU"].
*
* @return string[]
*/
private function words(string $text): array {
$transliterated = \Drupal::transliteration()->transliterate($text, 'fr');
$upper = mb_strtoupper($transliterated);
$words = preg_split('/[^A-Z0-9]+/', $upper, -1, PREG_SPLIT_NO_EMPTY);
return $words === FALSE ? [] : $words;
}
/**
* Loads (once per request) every client term with its normalized words.
*/
private function loadClients(): array {
if ($this->clients !== NULL) {
return $this->clients;
}
$this->clients = [];
$terms = \Drupal::entityTypeManager()->getStorage('taxonomy_term')
->loadByProperties(['vid' => 'client']);
foreach ($terms as $term) {
$words = $this->words($term->label());
if ($words) {
$this->clients[] = ['term' => $term, 'words' => $words];
}
}
return $this->clients;
}
/**
* Whether $needle appears in $haystack as a contiguous word sequence.
*/
private static function containsSequence(array $haystack, array $needle): bool {
$n = count($needle);
$h = count($haystack);
if ($n === 0 || $n > $h) {
return FALSE;
}
for ($i = 0; $i <= $h - $n; $i++) {
for ($j = 0; $j < $n; $j++) {
if ($haystack[$i + $j] !== $needle[$j]) {
continue 2;
}
}
return TRUE;
}
return FALSE;
}
}
@@ -0,0 +1,160 @@
<?php
namespace Drupal\figli_compta_ledger\Import;
/**
* Parses the CSV export of the SAS bank account into ReleveTransaction
* objects. Built from (and verified against) the real sample in
* sources-compta/extrais de comptes/00021322002.csv: `;`-separated,
* ISO-8859-1 encoded, columns "Date;Date de valeur;Débit;Crédit;Libellé;
* Solde", dates JJ/MM/AAAA, French decimal comma, exactly one of
* Débit/Crédit filled per row. The Solde column is ignored (no balance
* reconciliation in v1).
*
* Any deviation (wrong header, unparsable date/amount, empty label) throws
* a RuntimeException with a clear, user-facing French message -- the upload
* form catches it and shows a form error, never a raw crash page. Nothing
* is written to the database from here: parsing is a pure in-memory step.
*/
final class CsvReleveParser {
/**
* The exact header (after ISO-8859-1 → UTF-8 conversion) a file must
* carry to be considered a supported statement export.
*/
private const HEADER = ['Date', 'Date de valeur', 'Débit', 'Crédit', 'Libellé', 'Solde'];
/**
* Parses a file by URI (any stream wrapper, typically private://).
*
* @return \Drupal\figli_compta_ledger\Import\ReleveTransaction[]
* Every data row as a transaction, in file order.
*
* @throws \RuntimeException
* With a ready-to-display message when the file isn't a supported
* statement export.
*/
public function parse(string $uri): array {
$stream = @fopen($uri, 'r');
if ($stream === FALSE) {
throw new \RuntimeException("Le fichier téléversé n'a pas pu être relu depuis le stockage privé.");
}
// Confirmed ISO-8859-1 on the real sample: a naive UTF-8 read would
// corrupt every accented label -- the one truly silent bug risk of
// this parser. The filter converts as fgetcsv() reads.
stream_filter_append($stream, 'convert.iconv.ISO-8859-1/UTF-8');
// Explicit enclosure + empty $escape: PHP 8.4 deprecates relying on
// the default escape (backslash), whose legacy behavior would let a
// stray "\" in a bank label swallow the next character -- with ''
// the bank's own quotes stay the only special characters.
$header = fgetcsv($stream, NULL, ';', '"', '');
if ($header === FALSE) {
fclose($stream);
throw new \RuntimeException('Le fichier est vide.');
}
$header = array_map(fn ($h) => trim((string) $h), $header);
if ($header !== self::HEADER) {
fclose($stream);
throw new \RuntimeException('Format de fichier non reconnu. En-tête attendu : « ' . implode(';', self::HEADER) . ' ». Seul l\'export CSV de la banque est pris en charge pour l\'instant (.ofx et .cmi non encore).');
}
$transactions = [];
$line = 1;
while (($row = fgetcsv($stream, NULL, ';', '"', '')) !== FALSE) {
$line++;
// Fully blank rows are just padding at the end of some exports.
if (trim(implode('', array_map('strval', $row))) === '') {
continue;
}
if (count($row) < 6) {
fclose($stream);
throw new \RuntimeException("Ligne $line : nombre de colonnes inattendu (" . count($row) . ", 6 attendues).");
}
$dateRaw = trim((string) $row[0]);
if (!preg_match('/^(\d{2})\/(\d{2})\/(\d{4})$/', $dateRaw, $m) || !checkdate((int) $m[2], (int) $m[1], (int) $m[3])) {
fclose($stream);
throw new \RuntimeException("Ligne $line : date « $dateRaw » invalide (JJ/MM/AAAA attendu).");
}
$date = $m[3] . '-' . $m[2] . '-' . $m[1];
$debit = trim((string) $row[2]);
$credit = trim((string) $row[3]);
if ($debit !== '' && $credit !== '') {
fclose($stream);
throw new \RuntimeException("Ligne $line : Débit et Crédit renseignés simultanément, format inattendu.");
}
// A Débit is money out whatever sign the bank exported it with
// (the sample already stores it negative; -abs() normalizes any
// sibling export that doesn't), a Crédit is money in.
if ($debit !== '') {
$montant = -abs($this->parseAmount($debit, $line));
}
elseif ($credit !== '') {
$montant = abs($this->parseAmount($credit, $line));
}
else {
fclose($stream);
throw new \RuntimeException("Ligne $line : ni Débit ni Crédit renseigné.");
}
$libelle = self::normalizeLibelle((string) $row[4]);
if ($libelle === '') {
fclose($stream);
throw new \RuntimeException("Ligne $line : libellé vide, impossible de tracer la transaction.");
}
$transactions[] = new ReleveTransaction(
$date,
$montant,
$libelle,
self::fitid($date, $montant, $libelle),
);
}
fclose($stream);
if (!$transactions) {
throw new \RuntimeException("Aucune transaction trouvée dans le fichier (en-tête seul).");
}
return $transactions;
}
/**
* Fingerprint of one transaction: 'csv:' + sha1(date | signed amount to
* the centime | whitespace-normalized label). No case-folding -- two
* exports of the same account reproduce labels byte for byte, and the
* fingerprint must stay stable for the count-aware dedup to recognize
* an already-imported transaction years later.
*
* The amount goes in as a fixed 2-decimal string ("−1234.56") so no
* floating-point representation ever enters the hash.
*/
public static function fitid(string $date, float $montant, string $normalizedLibelle): string {
return 'csv:' . sha1($date . '|' . number_format($montant, 2, '.', '') . '|' . $normalizedLibelle);
}
/**
* Trim + collapse internal whitespace runs to one space: stray double
* spaces would otherwise make the same transaction fingerprint
* differently across two exports of the same account.
*/
public static function normalizeLibelle(string $libelle): string {
return trim((string) preg_replace('/\s+/u', ' ', $libelle));
}
/**
* French decimal ("1 234,56", "-45,89") → float, with a hard format
* check -- anything unexpected rejects the whole file with the line
* number rather than being silently coerced.
*/
private function parseAmount(string $raw, int $line): float {
$clean = str_replace([' ', "\xC2\xA0"], '', $raw);
$clean = str_replace(',', '.', $clean);
if (!preg_match('/^[+-]?\d+(\.\d+)?$/', $clean)) {
throw new \RuntimeException("Ligne $line : montant « $raw » invalide.");
}
return (float) $clean;
}
}
@@ -0,0 +1,181 @@
<?php
namespace Drupal\figli_compta_ledger\Import;
use Drupal\Core\Entity\EntityStorageException;
use Drupal\node\Entity\Node;
use Drupal\taxonomy\Entity\Term;
/**
* Batch backend of the bank statement import: turns the parsed
* transactions into ligne_comptable nodes, ~25 per PHP-FPM request
* (several hundred transactions would blow the memory/time budget of a
* single request -- no Batch API precedent existed in this module
* before this).
*
* Count-aware dedup (see PLAN-import-releve-bancaire.md): for every
* fingerprint, the file tells how many times the transaction appears (k)
* and the database how many are already imported (m) -- the batch then
* creates max(0, k − m) lines. That imports every legitimate duplicate
* (two identical transfers the same day) while still recognizing an
* already-imported transaction across overlapping files.
*
* Every created line goes through SkipValidationContext (request-scoped,
* NOT the historical state key): répartition is deliberately empty, so
* the presave invariant sum(répartition) == montant_ht must not fire --
* field_ecart (= montant_ht) still gets computed and drives the existing
* "à trier" red marker on /lignes.
*/
final class ReleveImportBatch {
/**
* Transactions processed per batch step.
*/
const CHUNK = 25;
/**
* Batch operation -- called repeatedly by Drupal until finished.
*
* @param array $transactions
* ReleveTransaction::toArray() payloads, in file order.
* @param array $quotas
* fitid => remaining lines to create (k − m, floored at 0).
* @param array $meta
* Immutable import metadata: file_name, file_total (sum of every
* transaction's signed amount, the control total), tag (flag term
* name), flag_tid.
* @param array $context
* Batch context (sandbox holds index + mutable quotas, results hold
* the accumulators finished() assembles the summary from).
*/
public static function operation(array $transactions, array $quotas, array $meta, array &$context): void {
if (!isset($context['sandbox']['index'])) {
$context['sandbox']['index'] = 0;
$context['sandbox']['total'] = count($transactions);
$context['sandbox']['quotas'] = $quotas;
// Seed results with the immutable import metadata (no key
// collision with the accumulators) + the zeroed accumulators.
$context['results'] += $meta + [
'created' => 0,
'duplicates' => 0,
'matched' => 0,
'unmatched' => 0,
'created_total' => 0.0,
'duplicates_total' => 0.0,
'duplicates_list' => [],
'errors' => [],
];
}
/** @var \Drupal\figli_compta_ledger\SkipValidationContext $skip */
$skip = \Drupal::service('figli_compta_ledger.skip_validation_context');
/** @var \Drupal\figli_compta_ledger\Import\ClientMatcher $matcher */
$matcher = \Drupal::service('figli_compta_ledger.client_matcher');
$end = min($context['sandbox']['index'] + self::CHUNK, $context['sandbox']['total']);
while ($context['sandbox']['index'] < $end) {
$t = ReleveTransaction::fromArray($transactions[$context['sandbox']['index']]);
// Count-aware dedup: quota exhausted → already in base (this many
// times), skip but surface it on the result page for human review.
if (($context['sandbox']['quotas'][$t->fitid] ?? 0) <= 0) {
$context['results']['duplicates']++;
$context['results']['duplicates_total'] += $t->montant;
$context['results']['duplicates_list'][] = [
'date' => $t->date,
'montant' => $t->montant,
'libelle' => $t->libelle,
];
$context['sandbox']['index']++;
continue;
}
$context['sandbox']['quotas'][$t->fitid]--;
$client = $matcher->match($t->libelle);
// Sensible truncate for the required title field: the full label
// lives in field_notes, the title only backs it up as fallback
// (same libelle display rule as everywhere in /lignes).
$node = Node::create([
'type' => 'ligne_comptable',
'title' => mb_substr($t->libelle, 0, 255),
'uid' => \Drupal::currentUser()->id(),
'status' => 1,
'field_date_ligne' => $t->date,
'field_type_ligne' => 'autre',
// Immutable audit reference (written here, never again), plus
// the three "same value to start with" fields the associate
// corrects while sorting (see PLAN's HT vs TTC section).
'field_montant_releve' => $t->montant,
'field_montant_ht' => $t->montant,
'field_montant_ttc' => $t->montant,
'field_tva' => 0,
'field_notes' => $t->libelle,
'field_import_fitid' => $t->fitid,
'field_client' => $client ? $client->id() : NULL,
'field_flag' => [$meta['flag_tid']],
]);
try {
$skip->skip(fn () => $node->save());
$context['results']['created']++;
$context['results']['created_total'] += $t->montant;
$client ? $context['results']['matched']++ : $context['results']['unmatched']++;
}
catch (EntityStorageException $e) {
$context['results']['errors'][] = [
'libelle' => $t->libelle,
'error' => $e->getMessage(),
];
}
$context['sandbox']['index']++;
}
$context['message'] = t('Import du relevé : @done/@total transactions', [
'@done' => $context['sandbox']['index'],
'@total' => $context['sandbox']['total'],
]);
$context['finished'] = $context['sandbox']['total'] > 0
? $context['sandbox']['index'] / $context['sandbox']['total']
: 1;
}
/**
* Batch finished callback: assembles the summary the result page
* reads -- including the accounting control total (file total must
* equal created + duplicates, to the centime; if not, a parsing bug
* silently ate a line somewhere, and the page says so loudly) -- and
* stores it in the private tempstore (per-user, request-safe), where
* ReleveImportResultController picks it up once and purges it.
*/
public static function finished(bool $success, array $results, array $operations): void {
if (!$success) {
\Drupal::messenger()->addError("L'import a échoué à mi-parcours. Les transactions déjà traitées sont enregistrées ; relancez l'import du même fichier, le dédoublonnage ne recréera que ce qui manque.");
return;
}
$created_total = round((float) ($results['created_total'] ?? 0.0), 2);
$duplicates_total = round((float) ($results['duplicates_total'] ?? 0.0), 2);
$file_total = round((float) ($results['file_total'] ?? 0.0), 2);
$summary = [
'file_name' => (string) ($results['file_name'] ?? ''),
'tag' => (string) ($results['tag'] ?? ''),
'created' => (int) ($results['created'] ?? 0),
'duplicates' => (int) ($results['duplicates'] ?? 0),
'matched' => (int) ($results['matched'] ?? 0),
'unmatched' => (int) ($results['unmatched'] ?? 0),
'errors' => $results['errors'] ?? [],
'duplicates_list' => $results['duplicates_list'] ?? [],
'file_total' => $file_total,
'created_total' => $created_total,
'duplicates_total' => $duplicates_total,
// To the centime: every parsed transaction was either created or
// recognized as already in base. Any drift means a line vanished
// -- never expected, always announced.
'totals_ok' => abs($file_total - $created_total - $duplicates_total) < 0.005,
];
\Drupal::service('tempstore.private')->get('figli_compta_ledger')
->set('releve_import_result', $summary);
}
}
@@ -0,0 +1,47 @@
<?php
namespace Drupal\figli_compta_ledger\Import;
/**
* One bank statement transaction, in the neutral shape every parser
* (CSV today, OFX/CMI maybe later) produces -- the rest of the import
* chain (dedup, client matching, node creation) only ever sees this.
*
* $date: AAAA-MM-JJ (converted from the bank format at parse time).
* $montant: signed, negative = money out (Débit), to the centime.
* $libelle: raw bank label (full, untruncated in CSV), the basis for
* client matching and the line's visible Notes.
* $fitid: dedup fingerprint, see CsvReleveParser::fitid().
*/
final class ReleveTransaction {
public function __construct(
public readonly string $date,
public readonly float $montant,
public readonly string $libelle,
public readonly string $fitid,
) {}
/**
* Plain-array shape for Batch API serialization (operation args and
* sandbox are serialized between requests).
*/
public function toArray(): array {
return [
'date' => $this->date,
'montant' => $this->montant,
'libelle' => $this->libelle,
'fitid' => $this->fitid,
];
}
public static function fromArray(array $values): self {
return new self(
(string) $values['date'],
(float) $values['montant'],
(string) $values['libelle'],
(string) $values['fitid'],
);
}
}
@@ -0,0 +1,64 @@
<?php
namespace Drupal\figli_compta_ledger;
/**
* Request-scoped opt-out of the répartition invariant check in
* figli_compta_ledger_node_presave().
*
* The historical state key ('figli_compta_ledger.skip_validation') is a flag
* shared by every request on the site, stored in the database: while a
* programmatic save (inline type change, bank statement import) holds it,
* a *concurrent* normal form save happening in another PHP-FPM request would
* silently skip validation too -- exactly the kind of hole an integrity
* check must never have. This service lives in the dependency injection
* container of its own request, so a skip here is physically invisible to
* every other request; the depth counter makes nested skips safe and the
* try/finally in skip() guarantees it unwinds on exceptions as well as on
* normal completion.
*
* The state key is still honored by node_presave() for backward compatibility
* with already-shipped migration scripts, but new code (batch imports,
* future migrations) must use this service instead.
*/
final class SkipValidationContext {
/**
* Current skip depth (0 = validation active).
*
* @var int
*/
private int $depth = 0;
/**
* Runs $operation with the répartition-sum check disabled for this
* request only, restoring it afterwards whatever happens.
*
* @param callable $operation
* Typically fn () => $node->save().
*
* @return mixed
* Whatever $operation returns.
*/
public function skip(callable $operation): mixed {
$this->depth++;
try {
return $operation();
}
finally {
$this->depth--;
}
}
/**
* Whether the répartition-sum check is currently disabled for this
* request. Read by figli_compta_ledger_node_presave().
*
* @return bool
* TRUE when a skip() is currently in progress.
*/
public function isSkipped(): bool {
return $this->depth > 0;
}
}
@@ -10,7 +10,8 @@
#}
<nav class="figli-page-nav">
<a href="{{ path('figli_compta_ledger.home') }}" class="{{ current_route == 'figli_compta_ledger.home' ? 'is-active' : '' }}">Grand livre</a>
<a href="{{ path('figli_compta_ledger.dashboard') }}" class="{{ current_route == 'figli_compta_ledger.dashboard' ? 'is-active' : '' }}">Dashboard</a>
<a href="{{ path('figli_compta_ledger.dashboard') }}" class="{{ current_route == 'figli_compta_ledger.dashboard' ? 'is-active' : '' }}">SAS</a>
<a href="{{ path('figli_compta_ledger.dashboard_repartition') }}" class="{{ current_route == 'figli_compta_ledger.dashboard_repartition' ? 'is-active' : '' }}">Répartition/Soldes</a>
<a href="{{ path('figli_compta_ledger.dashboard_compte') }}" class="{{ current_route == 'figli_compta_ledger.dashboard_compte' ? 'is-active' : '' }}">Par compte</a>
</nav>
{% verbatim %}
@@ -0,0 +1,39 @@
{#
Répartition / Soldes: solde par compte (all-time + évolution année par
année), split out of the general dashboard (figli-compta-dashboard.html.twig)
to keep that one focused on activité/CA/type/client.
{% verbatim %} below: this is Vue template syntax, not Twig -- both use
{{ }}, so verbatim tells Twig to leave it alone and let Vue compile it
in the browser.
#}
<nav class="figli-page-nav">
<a href="{{ path('figli_compta_ledger.home') }}" class="{{ current_route == 'figli_compta_ledger.home' ? 'is-active' : '' }}">Grand livre</a>
<a href="{{ path('figli_compta_ledger.dashboard') }}" class="{{ current_route == 'figli_compta_ledger.dashboard' ? 'is-active' : '' }}">SAS</a>
<a href="{{ path('figli_compta_ledger.dashboard_repartition') }}" class="{{ current_route == 'figli_compta_ledger.dashboard_repartition' ? 'is-active' : '' }}">Répartition/Soldes</a>
<a href="{{ path('figli_compta_ledger.dashboard_compte') }}" class="{{ current_route == 'figli_compta_ledger.dashboard_compte' ? 'is-active' : '' }}">Par compte</a>
</nav>
{% verbatim %}
<div id="figli-dashboard-app">
<p v-if="loading">Chargement des données…</p>
<p v-else-if="error" class="figli-error">Erreur de chargement du tableau de bord : {{ error }}</p>
<template v-else>
<section class="figli-chart-section">
<h2>Solde par compte</h2>
<p class="figli-note">Solde cumulé de chaque compte depuis l'origine (ouverture comprise) -- vert = créditeur, rouge = débiteur.</p>
<h-bar-chart :items="soldeParCompteItems" :format-value="formatEurRound"></h-bar-chart>
</section>
<section class="figli-chart-section">
<h2>Évolution du solde par compte</h2>
<p class="figli-note">Solde de clôture de chaque compte, année par année.</p>
<div class="figli-trend-grid">
<div class="figli-trend-card" v-for="compte in comptesOrdonnes" :key="compte">
<div class="figli-trend-title">{{ compte }}</div>
<mini-trend :annees="stats.annees" :values="trendValues(compte)" :format-value="formatEurRound"></mini-trend>
</div>
</div>
</section>
</template>
</div>
{% endverbatim %}
@@ -1,6 +1,7 @@
{#
Charts and aggregate totals: solde par compte, chiffre d'affaires par
année, répartition par type, top clients. The spreadsheet-like
Charts and aggregate totals: chiffre d'affaires par année, répartition
par type, top clients. Solde par compte lives on its own page now (see
figli-compta-dashboard-repartition.html.twig). The spreadsheet-like
line-by-line view is the site's home page (figli-compta-home.html.twig).
{% verbatim %} below: this is Vue template syntax, not Twig -- both use
@@ -9,7 +10,8 @@
#}
<nav class="figli-page-nav">
<a href="{{ path('figli_compta_ledger.home') }}" class="{{ current_route == 'figli_compta_ledger.home' ? 'is-active' : '' }}">Grand livre</a>
<a href="{{ path('figli_compta_ledger.dashboard') }}" class="{{ current_route == 'figli_compta_ledger.dashboard' ? 'is-active' : '' }}">Dashboard</a>
<a href="{{ path('figli_compta_ledger.dashboard') }}" class="{{ current_route == 'figli_compta_ledger.dashboard' ? 'is-active' : '' }}">SAS</a>
<a href="{{ path('figli_compta_ledger.dashboard_repartition') }}" class="{{ current_route == 'figli_compta_ledger.dashboard_repartition' ? 'is-active' : '' }}">Répartition/Soldes</a>
<a href="{{ path('figli_compta_ledger.dashboard_compte') }}" class="{{ current_route == 'figli_compta_ledger.dashboard_compte' ? 'is-active' : '' }}">Par compte</a>
</nav>
{% verbatim %}
@@ -26,10 +28,6 @@
<div class="figli-summary-label">CA {{ caAnneeEnCours.annee }}</div>
<div class="figli-summary-value">{{ formatEurRound(caAnneeEnCours.value) }}</div>
</div>
<div class="figli-summary-card" v-for="item in soldeParCompteItems.slice(0, 1)" :key="'top-' + item.label">
<div class="figli-summary-label">Meilleur solde</div>
<div class="figli-summary-value" :class="item.value < 0 ? 'is-negative' : 'is-positive'">{{ item.label }} · {{ formatEurRound(item.value) }}</div>
</div>
</div>
<section class="figli-chart-section">
@@ -38,33 +36,54 @@
<column-chart :items="caParAnneeItems" :format-value="formatEurRound"></column-chart>
</section>
<section class="figli-chart-section">
<h2>Solde par compte</h2>
<p class="figli-note">Solde cumulé de chaque compte depuis l'origine (ouverture comprise) -- vert = créditeur, rouge = débiteur.</p>
<h-bar-chart :items="soldeParCompteItems" :format-value="formatEurRound"></h-bar-chart>
</section>
<section class="figli-chart-section">
<h2>Évolution du solde par compte</h2>
<p class="figli-note">Solde de clôture de chaque compte, année par année.</p>
<div class="figli-trend-grid">
<div class="figli-trend-card" v-for="compte in comptesOrdonnes" :key="compte">
<div class="figli-trend-title">{{ compte }}</div>
<mini-trend :annees="stats.annees" :values="trendValues(compte)" :format-value="formatEurRound"></mini-trend>
</div>
</div>
</section>
<section class="figli-chart-section">
<h2>Répartition de l'activité par type</h2>
<p class="figli-note">Montant total (HT, valeur absolue) par type de ligne, hors ouvertures.</p>
<h-bar-chart :items="typeItems" :format-value="formatEurRound" :color-for="typeColor"></h-bar-chart>
<div class="figli-year-hbar-grid">
<div class="figli-year-hbar-card" v-for="y in typeItemsParAnnee" :key="y.annee">
<div class="figli-year-hbar-title">{{ y.annee }}</div>
<h-bar-chart :items="y.items" :format-value="formatEurRound" :color-for="typeColor" compact></h-bar-chart>
</div>
</div>
</section>
<section class="figli-chart-section">
<h2>Top clients par chiffre d'affaires</h2>
<p class="figli-note">Les 12 clients ayant généré le plus de chiffre d'affaires, toutes années confondues.</p>
<h-bar-chart :items="topClientsItems" :format-value="formatEurRound"></h-bar-chart>
<div class="figli-year-hbar-grid">
<div class="figli-year-hbar-card" v-for="y in topClientsParAnnee" :key="y.annee">
<div class="figli-year-hbar-title">{{ y.annee }}</div>
<h-bar-chart :items="y.items" :format-value="formatEurRound" compact></h-bar-chart>
</div>
</div>
</section>
<section class="figli-chart-section">
<h2>Charges structurelles par client</h2>
<p class="figli-note">Montant total (HT, valeur absolue) des charges structurelles par vendeur/organisme, toutes années confondues.</p>
<h-bar-chart :items="chargeParClientItems" :format-value="formatEurRound" :color-for="typeColor"></h-bar-chart>
<div class="figli-chart-total">Total : {{ formatEurRound(sumItems(chargeParClientItems)) }}</div>
<div class="figli-year-hbar-grid">
<div class="figli-year-hbar-card" v-for="y in chargeParClientParAnnee" :key="y.annee">
<div class="figli-year-hbar-title">{{ y.annee }}</div>
<h-bar-chart :items="y.items" :format-value="formatEurRound" :color-for="typeColor" compact></h-bar-chart>
<div class="figli-chart-total is-compact">Total : {{ formatEurRound(sumItems(y.items)) }}</div>
</div>
</div>
</section>
<section class="figli-chart-section">
<h2>Total des versements par compte</h2>
<p class="figli-note">Montant total versé à chaque compte associé, plus les salaires/stages et sous-traitants, toutes années confondues.</p>
<h-bar-chart :items="versementsParCompteItems" :format-value="formatEurRound" :color-for="typeColor"></h-bar-chart>
<div class="figli-year-hbar-grid">
<div class="figli-year-hbar-card" v-for="y in versementsParCompteParAnnee" :key="y.annee">
<div class="figli-year-hbar-title">{{ y.annee }}</div>
<h-bar-chart :items="y.items" :format-value="formatEurRound" :color-for="typeColor" compact></h-bar-chart>
</div>
</div>
</section>
</template>
</div>
@@ -7,18 +7,13 @@
#}
<nav class="figli-page-nav">
<a href="{{ path('figli_compta_ledger.home') }}" class="{{ current_route == 'figli_compta_ledger.home' ? 'is-active' : '' }}">Grand livre</a>
<a href="{{ path('figli_compta_ledger.dashboard') }}" class="{{ current_route == 'figli_compta_ledger.dashboard' ? 'is-active' : '' }}">Dashboard</a>
<a href="{{ path('figli_compta_ledger.dashboard') }}" class="{{ current_route == 'figli_compta_ledger.dashboard' ? 'is-active' : '' }}">SAS</a>
<a href="{{ path('figli_compta_ledger.dashboard_repartition') }}" class="{{ current_route == 'figli_compta_ledger.dashboard_repartition' ? 'is-active' : '' }}">Répartition/Soldes</a>
<a href="{{ path('figli_compta_ledger.dashboard_compte') }}" class="{{ current_route == 'figli_compta_ledger.dashboard_compte' ? 'is-active' : '' }}">Par compte</a>
</nav>
{% verbatim %}
<div id="figli-home-app">
<div class="figli-toolbar">
<a href="/node/add/ligne_comptable" class="button button--primary" @click.prevent="openAddForm">+ Ajouter une ligne</a>
{% endverbatim %}
{% if can_view_history %}
<a href="{{ path('figli_compta_ledger.history') }}" class="button">Historique</a>
{% endif %}
{% verbatim %}
<label>Compte
<span class="figli-filter-row">
@@ -264,8 +259,16 @@
</tbody>
<tfoot>
<tr class="figli-totals-row">
<td class="actions-col"></td>
<td colspan="6">
<td colspan="7">
<a href="/node/add/ligne_comptable" class="button button--primary" @click.prevent="openAddForm">+ Ajouter une ligne</a>
{% endverbatim %}
{% if can_import_releve %}
<a href="{{ path('figli_compta_ledger.releve_import_form') }}" class="button">Importer un relevé</a>
{% endif %}
{% if can_view_history %}
<a href="{{ path('figli_compta_ledger.history') }}" class="button">Historique</a>
{% endif %}
{% verbatim %}
Solde {{ currentYear || '…' }} (créditeur / débiteur)
<span v-if="currentYearLoading" class="figli-note">chargement…</span>
</td>
@@ -336,8 +339,7 @@
</tbody>
<tfoot>
<tr class="figli-totals-row">
<td class="actions-col"></td>
<td colspan="5">Solde entrées et sorties liées (créditeur / débiteur)</td>
<td colspan="6">Solde entrées et sorties liées (créditeur / débiteur)</td>
<td class="amount">{{ drilldownTotals ? formatEur(drilldownTotals.montant_ht) : '' }}</td>
<td class="amount">{{ drilldownTotals ? formatEur(drilldownTotals.montant_ttc) : '' }}</td>
<td v-for="c in modalComptes" :key="c" class="amount compte-col" :class="drilldownTotals ? soldeClass(drilldownTotals.par_compte[c]) : ''">{{ drilldownTotals && drilldownTotals.par_compte[c] !== undefined ? formatEur(drilldownTotals.par_compte[c]) : '' }}</td>
@@ -0,0 +1,80 @@
{#
Résultat d'import d'un relevé bancaire (ReleveImportResultController).
Page simple sans Vue : Twig rend tout, les chiffres viennent du tempstore
privé (une seule lecture, purgée aussitôt). Mêmes classes de navigation
que les autres pages du module (figli-page-nav, admin-chrome.css).
#}
<nav class="figli-page-nav">
<a href="{{ path('figli_compta_ledger.home') }}">Grand livre</a>
<a href="{{ path('figli_compta_ledger.dashboard') }}">SAS</a>
<a href="{{ path('figli_compta_ledger.dashboard_repartition') }}">Répartition/Soldes</a>
<a href="{{ path('figli_compta_ledger.dashboard_compte') }}">Par compte</a>
</nav>
<div class="figli-releve-result">
<h2>Import terminé — {{ summary.file_name }}</h2>
<p class="figli-releve-tag">Chaque ligne créée porte le signalement
<span class="figli-flag-badge">{{ summary.tag }}</span> et apparaît sur
<a href="{{ lignes_url }}">/lignes</a> avec un liseré rouge (répartition à faire) tant qu'elle n'a pas été triée.</p>
<div class="figli-releve-stats">
<div class="figli-releve-stat">
<span class="figli-releve-stat-value">{{ summary.created }}</span>
<span class="figli-releve-stat-label">lignes créées</span>
</div>
<div class="figli-releve-stat">
<span class="figli-releve-stat-value">{{ summary.duplicates }}</span>
<span class="figli-releve-stat-label">doublons déjà en base (ignorés)</span>
</div>
<div class="figli-releve-stat">
<span class="figli-releve-stat-value">{{ summary.matched }}</span>
<span class="figli-releve-stat-label">clients rapprochés</span>
</div>
<div class="figli-releve-stat">
<span class="figli-releve-stat-value">{{ summary.unmatched }}</span>
<span class="figli-releve-stat-label">sans client (à remplir)</span>
</div>
</div>
<h3>Totaux de contrôle</h3>
<table class="figli-releve-totals">
<tbody>
<tr><th scope="row">Somme des mouvements du fichier</th><td class="figli-releve-amount">{{ summary.file_total }}</td></tr>
<tr><th scope="row">Somme des lignes créées</th><td class="figli-releve-amount">{{ summary.created_total }}</td></tr>
<tr><th scope="row">Somme des doublons ignorés</th><td class="figli-releve-amount">{{ summary.duplicates_total }}</td></tr>
</tbody>
</table>
{% if summary.totals_ok %}
<p class="figli-releve-ok">Équilibre vérifié au centime : aucun mouvement perdu ni dupliqué.</p>
{% else %}
<p class="figli-releve-alert">Anomalie de parsing : la somme des mouvements du fichier ne correspond pas à « créées + doublons ». Vérifiez le fichier source et les lignes importées avant de trier le lot.</p>
{% endif %}
{% if summary.errors_count %}
<h3>Erreurs ({{ summary.errors_count }})</h3>
<ul class="figli-releve-errors">
{% for e in summary.errors %}<li>{{ e }}</li>{% endfor %}
</ul>
{% endif %}
{% if summary.duplicates_list %}
<h3>Doublons ignorés ({{ summary.duplicates_list|length }})</h3>
<p>Ces transactions figuraient déjà en base (empreinte identique, toutes provenances confondues). Vérifiez la liste si vous attendiez un mouvement.</p>
<div class="figli-releve-dup-wrap">
<table class="figli-releve-dups">
<thead>
<tr><th scope="col">Date</th><th scope="col">Montant</th><th scope="col">Libellé</th></tr>
</thead>
<tbody>
{% for d in summary.duplicates_list %}
<tr><td>{{ d.date }}</td><td class="figli-releve-amount">{{ d.montant }}</td><td>{{ d.libelle }}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<div class="figli-releve-actions">
<a class="button button--primary" href="{{ lignes_url }}">Voir les lignes importées sur /lignes</a>
<a class="button" href="{{ import_url }}">Importer un autre relevé</a>
</div>
</div>