Compare commits

..
30 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
bachir f7e7ae3265 Phase 2 : filtrage server-side de /lignes (compte, client, type, signalement, écarts, recherche libre)
Remplace le "charge tout puis filtre côté client" (filteredRows()) par un
nouvel endpoint /lignes/api/lignes (LedgerRowsController), qui pousse tous
les filtres de la barre d'outils dans une seule requête Entity/Field Query
API (pas de SQL brut) -- y compris à travers la relation
field_repartition -> field_compte (paragraph -> taxonomie), confirmé
fonctionner empiriquement avant d'écrire le contrôleur. `rows` ne contient
donc plus que ce qui est à la fois dans la fenêtre de dates ET dans les
filtres actifs ; la fenêtre glissante elle-même (loadOlder/loadNewer/
ensureScrollable) est inchangée, seul ce qui la peuple change.

Ajouts :
- Nouveau filtre "Libellé / Détails" (recherche plein texte sur
  field_notes ou le titre), débouncé côté client (350ms).
- filterYear (l'"Année" dédiée) passe par le même endpoint via son
  paramètre "annee".
- mergeChangedRows() (le merge du polling) tient maintenant compte des
  filtres actifs : une ligne qui ne correspond plus après un changement
  est retirée de `rows`, une ligne qui correspond nouvellement est ajoutée
  -- polling lui-même reste global/non filtré, seul le merge est
  filter-aware.

JSON:API reste utilisé pour ce que l'endroit filtré ne couvre pas : le
groupe entrée/sorties liées (fetchLignesByNids) et le polling
(fetchChangedSince), tous deux indépendants d'une plage de dates+filtres.

Testé en local : chaque filtre individuellement et combiné (compte+q,
client+type), widening de fenêtre sous filtre restrictif, restauration
combinée depuis le hash au reload, modale d'édition + reloadWindow après
fermeture, polling sans erreur.
2026-09-08 14:04:35 +02:00
bachir 7bcf65d25b Ajoute field_ecart (Montant HT - somme répartition), stocké à la sauvegarde
Phase 1 du chantier filtrage serveur de /lignes : l'écart devient une
vraie valeur stockée plutôt que recalculée à la volée en résolvant les
paragraphes de répartition, pour que le futur filtre "Écarts uniquement"
côté serveur puisse filtrer directement dessus.

Contrairement au Montant TTC (protégé par un garde-fou pour ne jamais
altérer un TTC historique), l'écart n'a pas de valeur passée à
protéger -- il reflète l'état *actuel* de la répartition, donc
recalculé sans condition à chaque sauvegarde. figli_compta_ledger_
node_presave() calculait déjà cette somme pour la validation
répartition == HT ; le stockage était quasi gratuit à ajouter au même
endroit.

Migration (update hooks 8009/8010) : rétro-calcul mécanique pour les
1556 lignes existantes (tous types, contrairement aux migrations TVA/
cotisation qui ne concernaient que les entrées client) -- aucune
ambiguïté à arbitrer, juste HT moins répartition. Vérifié
indépendamment ligne par ligne après coup : 0 écart entre la valeur
recalculée à la main et celle stockée par la migration.

Nouveau flag d'état 'figli_compta_ledger.skip_revision', utilisé
uniquement par cette migration : backfiller un champ purement calculé
sur 1556 lignes déjà migrées n'est pas un changement éditorial qui
justifie 1556 nouvelles révisions -- vérifié qu'aucune révision
supplémentaire n'a été créée. Reste indépendant de skip_validation
(déjà utilisé par tous les autres scripts de migration de ce module,
et qui doit continuer à créer une révision).

Restructuration de figli_compta_ledger_node_presave() : le calcul de
l'écart et son stockage se font maintenant même sous skip_validation
(seul le lancement de l'exception reste conditionnel) -- vérifié que
l'exception se déclenche toujours normalement sur une vraie
répartition incohérente, et que Montant TTC/Cotisation restent
inchangés sur une sauvegarde qui ne touche ni HT ni TVA.
2026-09-08 13:37:39 +02:00
bachir 00956825fa Réduit les marges du tableau (côtés et dessous) aux 2/3
- Gin's .layout-container margin-left/right (48px) réduit à 16px, sur
  les routes de ce module (hide_admin_chrome, déjà route-scopé).
- #figli-home-app margin-bottom réduit de 1rem à 0,33rem.
- .figli-table-wrap : max-height 75vh remplacé par calc(100vh - 16rem)
  -- le pourcentage de viewport ne tenait plus compte du titre/menu +
  barre d'outils au-dessus (hauteur à peu près fixe, ~16rem), qui a
  grandi au fil des filtres ajoutés depuis le dernier fix du double
  scroll -- avait fini par redépasser ce que 75vh laissait de marge,
  réintroduisant un scroll de page en plus de celui du tableau (84px
  d'écart mesuré avant ce correctif, ~8px après -- l'essentiel de
  l'écart restant venant justement de la marge du dessous réduite
  ci-dessus, pas d'un nouveau débordement).
2026-09-07 14:54:06 +02:00
bachir 74ee8ab3b9 Badge TVA non officielle plus visible : gros liseret rouge sous le montant
Le point ambre discret n'était pas assez visible. Remplacé par un
border-bottom épais (3px, rouge) directement sur la cellule TVA --
lisible d'un coup d'œil sans avoir besoin de place horizontale
supplémentaire, contrairement à un badge texte/icône dans une colonne
de 3,5% de large.
2026-09-07 14:48:09 +02:00
bachir 6576b35c07 Badge sur les taux TVA non officiels, écart positif en vert
- Un point ambre discret (pas de texte -- la colonne TVA ne fait que
  3,5% de large, à peine assez pour "12,64 %" lui-même) marque tout
  taux TVA qui ne tombe sur aucun des 5 taux officiels français (0,
  2,1, 5,5, 10, 20 %, ±0,05 point comme dans les scripts de migration).
  Infobulle pour le détail.
- Écart positif (répartition > Montant HT) passe au vert
  (--figli-positive), au lieu du rouge uniforme précédent -- même
  logique que la colonne Montant HT, qui distingue déjà positif/négatif.
2026-09-07 14:45:06 +02:00
bachir 0baa03855b Le champ/colonne "1,1%" affiche le delta seul, pas HT + 1,1%
field_cotisation_urssaf stockait round(HT * 1.011, 2) (le montant
augmenté) -- "1,1%" comme libellé de champ/colonne désigne la
cotisation elle-même, pas HT + cotisation. Change pour
round(HT * 0.011, 2). La TVA continue de s'appliquer sur la base
augmentée (HT + ce champ) : Montant TTC est inchangé par cette
correction, seule la valeur affichée/stockée dans "1,1%" change.

Migration (update hook 8008) : recalcule field_cotisation_urssaf pour
les 154 lignes que figli_compta_ledger_update_8007() avait marquées
cotisation active, en soustrayant simplement le HT déjà correct --
contrairement à cette dernière, ce n'est pas un arbitrage sur des
données historiques ambiguës, juste un bug dans du code écrit plus tôt
le même jour, donc recalculé sans condition ni prudence particulière.

Ajoute aussi le total "1,1%" au pied du tableau (solde de l'année),
absent jusqu'ici : LedgerStatsController::totauxAnnee() somme
maintenant field_cotisation_urssaf comme il le fait déjà pour HT/TTC.
2026-09-07 14:38:10 +02:00
bachir 6c33bc0098 Cotisation diffuseur URSSAF (1,1%) : nouveau champ, TTC calculé en cascade
Architecture métier corrigée : la SAS devise HT, ajoute (quand
applicable -- pas systématique) 1,1% de cotisation diffuseur URSSAF
pour l'usage de freelances, puis applique la TVA sur ce montant
augmenté -- pas sur le HT brut. La répartition entre comptes reste
basée sur le HT seul, inchangée.

- field_cotisation_active (case à cocher, "Entrée client" uniquement,
  cochée par défaut sur une nouvelle ligne -- optionnelle puisque tous
  les devis ne l'incluent pas historiquement).
- field_cotisation_urssaf ("1,1%", montant calculé HT × 1,011, jamais
  éditable).
- Montant TTC = round(cotisation × (1 + TVA/100), 2) quand la
  cotisation s'applique, round(HT × (1 + TVA/100), 2) sinon --
  inchangé pour tous les types hors "Entrée client".

Migration (update hooks 8006/8007) : toutes les lignes "Entrée client"
de field_tva rétro-calculées par figli_compta_ledger_update_8005()
étaient fausses dès que la cotisation s'appliquait (taux mixte HT->TTC,
pas le vrai taux de TVA) -- corrigées avec la même prudence que la
migration précédente : 2021 laissée de côté (pratique non confirmée
sur cette année), et par ligne, un taux déjà "propre" (0/2,1/5,5/10/20
%) signifie qu'aucune cotisation n'a été appliquée (laissé tel quel) ;
sinon le nouveau taux recalculé n'est retenu que s'il retombe lui-même
sur un taux officiel, sinon la ligne reste inchangée plutôt que de
deviner. Montant TTC historique jamais réécrit, comme pour toute
migration de ce module. Résultat : 154 lignes corrigées (cotisation
active), 90 déjà correctes (cotisation inactive), 40 ambiguës laissées
telles quelles, 43 lignes 2021 ignorées.

Formulaire réorganisé en grille à 4 colonnes (HT | 1,1% | TVA | TTC),
case à cocher masquée hors "Entrée client" via #states réel (fiable
ici, contrairement au select TVA -- watch sur field_type_ligne, un
vrai champ, pas un élément ajouté à la main). Colonne "1,1%" ajoutée
au grand livre entre HT et TVA.
2026-09-07 14:33:20 +02:00
bachir 134c97ac9d Supprime le double scroll : menu à hauteur du titre, marge Gin retirée
Le menu Grand livre/Dashboard/Par compte passe en position fixe,
aligné avec le <h1> de la page plutôt que sur sa propre ligne en
dessous -- les deux viennent de régions Drupal différentes (le titre
du bloc sticky top-bar de Gin, le menu du contenu de la page) sans
conteneur flex/grid commun pour les aligner autrement.

Ça ne suffisait pas à éliminer le scroll de page en plus de celui du
tableau (max-height: 75vh sur .figli-table-wrap) : Gin applique un
margin-bottom: 80px sur <main class="page-content">, pensé pour une
page d'admin classique, pas pour ce layout à hauteur de viewport fixe.
Neutralisé sur les routes du module (hide_admin_chrome, déjà route-
scopé).

Au passage, corrige un oubli : figli_compta_ledger.dashboard_compte
n'était jamais dans la liste hide_admin_chrome, donc la barre d'admin
Gin restait visible sur /dashboard/compte (et donc le titre plus bas
que sur les 3 autres pages) -- ajouté.

Et sur demande complémentaire en cours de route : le même menu manquait
purement et simplement sur /lignes/historique (pas de template Twig
propre, juste un tableau brut) -- ajouté en tableau de rendu directement
dans HistoryController, mêmes classes CSS que le <nav> des autres pages.
2026-09-07 12:46:36 +02:00
bachir 897d3c208e htaccess 2026-09-07 12:26:48 +02:00
bachir e117089be5 Renomme HT/TTC, réduit leur largeur, ajoute une colonne TVA au grand livre
HT/TTC passent de "Montant HT"/"Montant TTC" à "HT"/"TTC" (5% de large
au lieu de 6,2%), avec une nouvelle colonne TVA entre les deux (3,5%,
affichée en %, arrondie à 2 décimales -- les lignes migrées portent
parfois un taux rétro-calculé à 4 décimales, voir
figli_compta_ledger_update_8005()). field_tva était déjà exposé sans
changement de requête JSON:API (pas de sparse fieldset ici).

Piège de spécificité CSS : .amount:not(.compte-col) (déjà en place
pour Écart) compte comme 2 classes à cause de :not(), donc un simple
.figli-ht-col à une classe perdait systématiquement contre elle --
recombiné en .amount.figli-ht-col pour égaler/dépasser sa spécificité.
2026-09-07 12:18:58 +02:00
bachir f0927bac88 TVA par défaut à 10 %, corrige le Montant TTC illisible
- TVA par défaut passe de 0 % à 10 % (taux intermédiaire) pour une
  nouvelle ligne -- la plupart des lignes saisies portent effectivement
  de la TVA.
- Montant TTC (désactivé) était illisible : Chromium affiche le texte
  d'un champ disabled via -webkit-text-fill-color plutôt que color,
  qui restait sur son gris par défaut du navigateur -- quasi invisible
  sur le fond gris de --flform-bg-subtle en mode sombre. Fixé
  explicitement sur les deux thèmes.
2026-09-07 12:01:33 +02:00
bachir 5aa00b2ffd TVA en sélection des taux officiels français, TTC non éditable
Le champ TVA devient un select (0 %, 2,1 %, 5,5 %, 10 %, 20 %) plutôt
qu'une saisie libre, avec une option "Autre (préciser)" qui révèle le
champ décimal existant -- indispensable pour les lignes migrées, dont
le taux rétro-calculé (figli_compta_ledger_update_8005()) est souvent
un taux effectif non standard qu'il ne faut surtout pas forcer à
s'aligner sur l'une des 5 valeurs officielles.

Montant TTC passe de readonly à #disabled : non focusable, non
éditable même via les flèches d'un input number, et Form API rejette
toute valeur soumise malgré tout au profit de #default_value.

Le select est un élément de formulaire à part (pas un widget de champ)
pour éviter que WidgetBase::extractFormValues() ne s'étouffe sur une
clé étrangère mêlée aux valeurs de field_tva -- un #validate callback
recopie la valeur choisie dans field_tva au moment opportun (après
tous les #validate, avant la reconstruction de l'entité au submit).

L'affichage/masquage du champ "Autre" repose sur du JS simple plutôt
que sur #states : #states pose bien l'attribut data-drupal-states mais
ne bascule jamais la visibilité dans cette modale AJAX précise, pour
une raison non identifiée (un #states pourtant fonctionnel existe
juste au-dessus, sur field_entree_liee, qui surveille un vrai champ
Field API plutôt qu'un select ajouté à la main).
2026-09-07 11:56:08 +02:00
bachir b7490eb76a Ajoute la TVA (%) et calcule automatiquement le Montant TTC
Architecture : Montant HT reste saisi à la main, un nouveau champ TVA
(%) le complète, et Montant TTC = round(HT * (1 + TVA/100), 2) devient
une valeur calculée plutôt que saisie -- champ readonly dans le
formulaire (aperçu live en JS), calcul faisant foi côté serveur dans
figli_compta_ledger_node_presave().

Migration (update hooks 8004/8005) : Montant TTC n'est **jamais**
modifié pour les données historiques -- seul un TVA effectif est
rétro-calculé depuis HT/TTC existants et ajouté en tant que nouvelle
métadonnée (1498 lignes renseignées, 58 laissées vides faute de TTC
source). Précision du champ TVA fixée à 4 décimales après vérification
empirique sur les 1556 lignes existantes : reconstruire TTC = HT * (1 +
TVA/100) avec un taux arrondi à 4 décimales ne s'écarte du TTC réel que
pour 9 lignes (probables factures multi-taux), contre 74 à 2 décimales.

Garde-fou supplémentaire dans le presave : le recalcul du TTC ne se
déclenche que si HT ou TVA ont réellement changé par rapport à la
révision précédente (comparaison à $node->original) -- sans ça,
rouvrir une ancienne ligne migrée pour corriger un simple libellé
aurait silencieusement dérivé son TTC historique de ±0,01€ à cause de
l'arrondi du taux rétro-calculé, ce qu'interdit la règle du projet de
ne jamais corriger les données historiques.

Réorganisation du formulaire : N° Facture rejoint Client sur une même
ligne, Montant HT/TVA/Montant TTC forment la ligne suivante -- les
poids de champs doivent rester des entiers (Drupal tronque silencieusement
tout poids fractionnaire lors de la sauvegarde de l'affichage).
2026-09-07 11:36:53 +02:00
bachir f00f680143 Corrige trois régressions du restyling du formulaire de ligne comptable
- Le select "Type de ligne" affichait un chevron géant répété (le
  shorthand `background: transparent` sur input/select/textarea
  réinitialisait aussi position/repeat/size de Claro, transformant sa
  flèche unique alignée à droite en motif carrelé). Les select gardent
  maintenant leur background Claro intact.
- Les menus "Toggle Actions" (⋮) de la Répartition étaient ouverts en
  permanence : mon display:flex écrasait le display:none par défaut de
  Claro, cassant le toggle piloté par paragraphs.actions.js. Rescopé à
  `.paragraphs-dropdown.open .paragraphs-dropdown-actions`.
- Marges resserrées : le `.form-item { margin-block: 1.5rem }` de Claro
  s'ajoutait à notre propre grid-gap. Neutralisé (`margin-block: 0`), et
  gap/paddings réduits.
2026-09-07 11:14:24 +02:00
bachir 4ce1b12099 Modernise l'UI du formulaire modal d'ajout/édition de ligne comptable
Le formulaire natif Drupal (Menu settings, URL alias, Authoring
information, tabledrag/drag-handle pour des tableaux de 1-3 lignes,
"Title" affiché tel quel) était pensé pour un éditeur de contenu
générique, pas pour la saisie numérique quotidienne d'une ligne
comptable. Sans toucher au Form API (validation, structure des champs
inchangées) :

- Grille compacte à 3 colonnes (Date+Type, Client, Montant HT/Facture/
  TTC en ligne), au lieu de l'empilement vertical par défaut.
- Menu settings / URL alias / Authoring information / Published
  masqués via #access -- jamais utilisés pour une ligne_comptable.
- "Title" relabellisé en "Libellé court" avec une description qui
  explicite son rôle de repli quand "Notes / détail" est vide (déjà le
  comportement de home.js/dashboard*.js, jusque-là invisible côté
  formulaire).
- Répartition / Entrée liée : poignée de glisser-déposer et bascule
  "Show row weights" masquées (l'ordre n'affecte jamais la somme ni
  l'affichage), boutons Ajouter/Retirer/Dupliquer restylés.

Classe CSS partagée `figli-ledger-form` ajoutée par form_alter plutôt
que de cibler la classe générée par Drupal, différente entre le
formulaire d'ajout (node-ligne-comptable-form) et celui d'édition
(node-ligne-comptable-edit-form).
2026-09-07 09:57:37 +02:00
53 changed files with 4519 additions and 351 deletions
+1
View File
@@ -6,4 +6,5 @@
/web/sites/*/files/
/web/sites/*/settings.local.php
/web/sites/*/settings.php
/private/
.env
+24
View File
@@ -0,0 +1,24 @@
# Deny all requests from Apache 2.4+.
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
# Deny all requests from Apache 2.0-2.2.
<IfModule !mod_authz_core.c>
Deny from all
</IfModule>
# Turn off all options we don't need.
Options -Indexes -ExecCGI -Includes -MultiViews
# Set the catch-all handler to prevent scripts from being executed.
SetHandler Drupal_Security_Do_Not_Remove_See_SA_2006_006
<Files *>
# Override the handler again if we're run later in the evaluation list.
SetHandler Drupal_Security_Do_Not_Remove_See_SA_2013_003
</Files>
# If we know how to do it safely, disable the PHP engine entirely.
<IfModule mod_php.c>
php_flag engine off
</IfModule>
@@ -4,7 +4,10 @@ status: true
dependencies:
config:
- field.field.node.ligne_comptable.field_client
- field.field.node.ligne_comptable.field_cotisation_active
- field.field.node.ligne_comptable.field_cotisation_urssaf
- field.field.node.ligne_comptable.field_date_ligne
- field.field.node.ligne_comptable.field_ecart
- field.field.node.ligne_comptable.field_entree_liee
- field.field.node.ligne_comptable.field_flag
- field.field.node.ligne_comptable.field_montant_ht
@@ -12,6 +15,7 @@ dependencies:
- field.field.node.ligne_comptable.field_notes
- field.field.node.ligne_comptable.field_numero_facture
- field.field.node.ligne_comptable.field_repartition
- field.field.node.ligne_comptable.field_tva
- field.field.node.ligne_comptable.field_type_ligne
- node.type.ligne_comptable
module:
@@ -39,6 +43,20 @@ content:
size: 60
placeholder: ''
third_party_settings: { }
field_cotisation_active:
type: boolean_checkbox
weight: 4
region: content
settings:
display_label: true
third_party_settings: { }
field_cotisation_urssaf:
type: number
weight: 6
region: content
settings:
placeholder: ''
third_party_settings: { }
field_date_ligne:
type: datetime_default
weight: 0
@@ -47,7 +65,7 @@ content:
third_party_settings: { }
field_entree_liee:
type: entity_reference_autocomplete
weight: 2
weight: 4
region: content
settings:
match_operator: CONTAINS
@@ -57,7 +75,7 @@ content:
third_party_settings: { }
field_flag:
type: entity_reference_autocomplete_tags
weight: 8
weight: 12
region: content
settings:
match_operator: CONTAINS
@@ -67,21 +85,21 @@ content:
third_party_settings: { }
field_montant_ht:
type: number
weight: 3
weight: 5
region: content
settings:
placeholder: ''
third_party_settings: { }
field_montant_ttc:
type: number
weight: 4
weight: 8
region: content
settings:
placeholder: ''
third_party_settings: { }
field_notes:
type: string_textarea
weight: 6
weight: 11
region: content
settings:
rows: 5
@@ -97,7 +115,7 @@ content:
third_party_settings: { }
field_repartition:
type: paragraphs
weight: 5
weight: 10
region: content
settings:
title: Répartition
@@ -113,6 +131,13 @@ content:
collapse_edit_all: collapse_edit_all
duplicate: duplicate
third_party_settings: { }
field_tva:
type: number
weight: 9
region: content
settings:
placeholder: ''
third_party_settings: { }
field_type_ligne:
type: options_select
weight: 1
@@ -151,5 +176,6 @@ content:
placeholder: ''
third_party_settings: { }
hidden:
field_ecart: true
promote: true
sticky: true
@@ -4,7 +4,10 @@ status: true
dependencies:
config:
- field.field.node.ligne_comptable.field_client
- field.field.node.ligne_comptable.field_cotisation_active
- field.field.node.ligne_comptable.field_cotisation_urssaf
- field.field.node.ligne_comptable.field_date_ligne
- field.field.node.ligne_comptable.field_ecart
- field.field.node.ligne_comptable.field_entree_liee
- field.field.node.ligne_comptable.field_flag
- field.field.node.ligne_comptable.field_montant_ht
@@ -12,6 +15,7 @@ dependencies:
- field.field.node.ligne_comptable.field_notes
- field.field.node.ligne_comptable.field_numero_facture
- field.field.node.ligne_comptable.field_repartition
- field.field.node.ligne_comptable.field_tva
- field.field.node.ligne_comptable.field_type_ligne
- node.type.ligne_comptable
module:
@@ -32,6 +36,27 @@ content:
third_party_settings: { }
weight: 2
region: content
field_cotisation_active:
type: boolean
label: above
settings:
format: default
format_custom_false: ''
format_custom_true: ''
third_party_settings: { }
weight: 4
region: content
field_cotisation_urssaf:
type: number_decimal
label: above
settings:
thousand_separator: ''
decimal_separator: .
scale: 2
prefix_suffix: true
third_party_settings: { }
weight: 6
region: content
field_date_ligne:
type: datetime_default
label: above
@@ -41,6 +66,17 @@ content:
third_party_settings: { }
weight: 0
region: content
field_ecart:
type: number_decimal
label: above
settings:
thousand_separator: ''
decimal_separator: .
scale: 2
prefix_suffix: true
third_party_settings: { }
weight: 13
region: content
field_entree_liee:
type: entity_reference_label
label: above
@@ -55,7 +91,7 @@ content:
settings:
link: true
third_party_settings: { }
weight: 8
weight: 12
region: content
field_montant_ht:
type: number_decimal
@@ -66,7 +102,7 @@ content:
scale: 2
prefix_suffix: true
third_party_settings: { }
weight: 3
weight: 5
region: content
field_montant_ttc:
type: number_decimal
@@ -77,14 +113,14 @@ content:
scale: 2
prefix_suffix: true
third_party_settings: { }
weight: 4
weight: 8
region: content
field_notes:
type: basic_string
label: above
settings: { }
third_party_settings: { }
weight: 6
weight: 11
region: content
field_numero_facture:
type: string
@@ -102,7 +138,18 @@ content:
view_mode: default
link: ''
third_party_settings: { }
weight: 5
weight: 10
region: content
field_tva:
type: number_decimal
label: above
settings:
thousand_separator: ''
decimal_separator: .
scale: 2
prefix_suffix: true
third_party_settings: { }
weight: 9
region: content
field_type_ligne:
type: list_default
@@ -0,0 +1,23 @@
uuid: 3defc3d6-42b6-4340-a7cf-fecadb919934
langcode: en
status: true
dependencies:
config:
- field.storage.node.field_cotisation_active
- node.type.ligne_comptable
id: node.ligne_comptable.field_cotisation_active
field_name: field_cotisation_active
entity_type: node
bundle: ligne_comptable
label: 'Cotisation diffuseur URSSAF (1,1%)'
description: ''
required: false
translatable: true
default_value:
-
value: 1
default_value_callback: ''
settings:
on_label: 'On'
off_label: 'Off'
field_type: boolean
@@ -0,0 +1,23 @@
uuid: 309c0b94-be32-42a3-b990-cb04858afb8d
langcode: en
status: true
dependencies:
config:
- field.storage.node.field_cotisation_urssaf
- node.type.ligne_comptable
id: node.ligne_comptable.field_cotisation_urssaf
field_name: field_cotisation_urssaf
entity_type: node
bundle: ligne_comptable
label: '1,1%'
description: ''
required: false
translatable: true
default_value: { }
default_value_callback: ''
settings:
min: null
max: null
prefix: ''
suffix: ''
field_type: decimal
@@ -0,0 +1,23 @@
uuid: 79bd7ee6-1e84-4edd-8065-775929c45fcb
langcode: en
status: true
dependencies:
config:
- field.storage.node.field_ecart
- node.type.ligne_comptable
id: node.ligne_comptable.field_ecart
field_name: field_ecart
entity_type: node
bundle: ligne_comptable
label: Écart
description: ''
required: false
translatable: true
default_value: { }
default_value_callback: ''
settings:
min: null
max: null
prefix: ''
suffix: ''
field_type: decimal
@@ -0,0 +1,25 @@
uuid: 9e9fc15c-9f7b-466a-bab0-fc6fe802a22f
langcode: en
status: true
dependencies:
config:
- field.storage.node.field_tva
- node.type.ligne_comptable
id: node.ligne_comptable.field_tva
field_name: field_tva
entity_type: node
bundle: ligne_comptable
label: 'TVA (%)'
description: ''
required: false
translatable: true
default_value:
-
value: 10.0
default_value_callback: ''
settings:
min: null
max: null
prefix: ''
suffix: ''
field_type: decimal
@@ -0,0 +1,18 @@
uuid: 28c4ae5a-011c-4f31-bca5-36874c6c182b
langcode: en
status: true
dependencies:
module:
- node
id: node.field_cotisation_active
field_name: field_cotisation_active
entity_type: node
type: boolean
settings: { }
module: core
locked: false
cardinality: 1
translatable: true
indexes: { }
persist_with_no_fields: false
custom_storage: false
@@ -0,0 +1,20 @@
uuid: 486554c0-7686-4c52-ab33-eb09b5573be5
langcode: en
status: true
dependencies:
module:
- node
id: node.field_cotisation_urssaf
field_name: field_cotisation_urssaf
entity_type: node
type: decimal
settings:
precision: 12
scale: 2
module: core
locked: false
cardinality: 1
translatable: true
indexes: { }
persist_with_no_fields: false
custom_storage: false
@@ -0,0 +1,20 @@
uuid: 95ae1d5b-db18-4ebf-93a4-34d6522a32d6
langcode: en
status: true
dependencies:
module:
- node
id: node.field_ecart
field_name: field_ecart
entity_type: node
type: decimal
settings:
precision: 12
scale: 2
module: core
locked: false
cardinality: 1
translatable: true
indexes: { }
persist_with_no_fields: false
custom_storage: false
@@ -0,0 +1,20 @@
uuid: 92ad1651-ab19-4c60-ad8c-4824fd97ecc3
langcode: en
status: true
dependencies:
module:
- node
id: node.field_tva
field_name: field_tva
entity_type: node
type: decimal
settings:
precision: 8
scale: 4
module: core
locked: false
cardinality: 1
translatable: true
indexes: { }
persist_with_no_fields: false
custom_storage: false
+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'
@@ -2,12 +2,28 @@
on those two custom routes, but attached here rather than duplicated
between home.css and dashboard.css -- and defined with its own explicit
colors rather than relying on either page's #figli-*-app-scoped custom
properties, since this <nav> sits outside both of those elements. */
properties, since this <nav> sits outside both of those elements.
Fixed rather than in normal flow: this <nav> and the page's <h1>
("Grand livre - SAS Figures Libres" etc.) come from two different
Drupal block regions (the title from Gin's sticky top-bar header,
this nav from the page content below it) with no shared flex/grid
container to align them side by side declaratively -- in normal flow
it rendered as its own full-width row *under* the title, costing
~75px of vertical space the table below could otherwise use before
the whole page (not just the table's own internal scroll) needed to
scroll too. Pulling it out of flow and pinning it next to the title
reclaims that space; top/right below are tuned to that title's actual
position, not derived from it, so they'd need revisiting if Gin's
header height or padding ever changes. */
.figli-page-nav {
position: fixed;
top: 0.65rem;
right: 1rem;
z-index: 50;
display: flex;
justify-content: flex-end;
gap: 0.4rem;
padding: 0.5rem 0.25rem 0;
}
.figli-page-nav a {
padding: 0.3rem 0.8rem;
@@ -53,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;
@@ -62,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;
@@ -26,3 +26,22 @@
.dialog-off-canvas-main-canvas {
margin-inline-start: 0 !important;
}
/* Gin's default 80px margin-bottom on <main>, meant as breathing room
below a normal admin content page -- on this app's tight,
single-viewport layout (page nav pinned next to the title, table
scrolling internally, see .figli-page-nav in admin-chrome.css) it was
the last remaining chunk of an *outer* page scroll on top of the
table's own, since max-height: 75vh on .figli-table-wrap already
accounts for everything else above/below it but not this. */
main.page-content {
margin-bottom: 0 !important;
}
/* Gin's default 48px left/right margin on .layout-container -- fine for
a normal admin content page with a narrower reading measure, but this
app's table wants the width. Cut to a third (16px). */
.layout-container {
margin-left: 16px !important;
margin-right: 16px !important;
}
@@ -13,7 +13,11 @@
--figli-col-hover: rgba(15, 23, 42, 0.05);
font-family: Inter, -apple-system, sans-serif;
margin: 1rem 0;
/* Bottom cut to a third (top left alone) -- with the outer page
scroll already eliminated (see hide-admin-chrome.css's main.page-
content override), the full 1rem below the table was just dead
space past the last visible row. */
margin: 1rem 0 0.33rem;
color: var(--figli-text);
}
@@ -172,7 +176,15 @@ html.gin--dark-mode #figli-home-app {
overflow-x: hidden;
border: 1px solid var(--figli-border);
border-radius: 6px;
max-height: 75vh;
/* Not a flat 75vh -- title/nav + toolbar (with its filter row) above
this add up to a roughly fixed ~16rem regardless of viewport
height, so a percentage-of-viewport max-height only fits by
coincidence at one particular window size and silently reintroduces
the outer page scroll (on top of this element's own) at any other,
as toolbar filters were added over time and pushed that fixed part
past what 75vh left room for. Subtracting the fixed part directly
keeps this sized to exactly the remaining space instead. */
max-height: calc(100vh - 16rem);
overflow-y: auto;
}
@@ -264,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);
}
@@ -292,12 +320,21 @@ html.gin--dark-mode #figli-home-app {
font-weight: 700;
}
/* Écart is called out by the Écart column itself (bold red, see
.figli-ecart below) -- no separate row-level treatment needed. */
/* Écart is called out by the Écart column itself (bold, red or green
depending on sign -- see below) -- no separate row-level treatment
needed. Répartition > Montant HT (positive écart) means the accounts
were credited more than what's actually owed for this line, the same
"more money than expected" sense .figli-montant-positif already
marks green elsewhere; the reverse (negative) stays the usual
red/error color. */
#figli-home-app td.figli-ecart {
color: var(--figli-error);
font-weight: 700;
}
#figli-home-app td.figli-ecart-positif {
color: var(--figli-positive);
font-weight: 700;
}
/* Signalé rows: a thick amber accent bar on the left edge. */
#figli-home-app tr.figli-flag-row td:first-child {
@@ -313,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
@@ -444,6 +489,39 @@ html.gin--dark-mode #figli-home-app {
width: 6.2%;
white-space: nowrap;
}
/* HT/TTC narrower than the general .amount rule above (still used by
Écart) -- shorter header text (HT/TTC vs. the old Montant HT/Montant
TTC) and TVA splitting the row between them means neither needs as
much room. TVA itself narrower still: "12,64 %" is the longest
realistic value (see figli_compta_ledger_update_8005()'s docblock on
backfilled non-round rates), well short of an 8-figure amount.
`.amount` doubled up in the selector (not just .figli-ht-col alone):
:not(.compte-col) in the rule above counts toward specificity same as
a real class, so a single-class selector here would lose to it. */
#figli-home-app .amount.figli-ht-col,
#figli-home-app .amount.figli-ttc-col {
width: 5%;
}
/* Cotisation diffuseur URSSAF (1,1%) -- blank for every non-"Entrée
client" row (see figli_compta_ledger_node_presave()), same width
budget as HT/TTC since it's a real € amount, not a short rate like
TVA. */
#figli-home-app .amount.figli-cotisation-col {
width: 5%;
}
#figli-home-app .amount.figli-tva-col {
width: 3.5%;
}
/* Non-official TVA rate marker -- a thick red underline on the cell
itself rather than a small badge/dot (tried first, not visible
enough): the column is only ~3.5% wide, barely enough for
"12,64 %" itself, so a border under the whole cell reads clearly at
a glance without needing extra horizontal room a text/icon badge
would need. Tooltip (title attribute in the twig template) carries
the actual explanation. */
#figli-home-app td.figli-tva-nonofficial {
border-bottom: 3px solid var(--figli-error);
}
#figli-home-app .amount.compte-col {
width: 4.3%;
white-space: nowrap;
@@ -0,0 +1,481 @@
/* Ligne comptable add/edit form (node/add/ligne_comptable, node/X/edit --
usually seen inside the "+ Ajouter une ligne" / "Modifier" AJAX modal
from /lignes, see js/home.js's openAddForm/openEditForm). The default
Drupal/Gin admin form widgets (tabledrag drag handles, the paragraphs
"Toggle Actions" dropdown, vertical tabs rendered as an accordion) are
built for content editors managing arbitrary nodes, not for the small
numeric entry form associates fill in daily. This only restyles it
into a compact grid -- no Form API behavior, validation, or field
structure changes here (those live in figli_compta_ledger.module,
which also hides the "advanced" accordion and "Published" checkbox
entirely rather than just visually, since neither is ever used for a
ligne_comptable).
Explicit local colors rather than Gin's --gin-* custom properties --
this form isn't scoped inside #figli-home-app/#figli-dashboard-app, so
the same "don't trust Gin's vars on custom routes" lesson from
home.css/dashboard.css applies here too. */
.figli-ledger-form {
--flform-border: #dcdee2;
--flform-border-soft: #e8eaed;
--flform-label: #4b5563;
--flform-text: #1a1a1a;
--flform-bg: #ffffff;
--flform-bg-subtle: #f7f8fa;
--flform-accent: #2f6f4f;
--flform-danger: #b3261e;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.5rem 1rem;
max-width: 44rem;
margin: 0 auto;
padding: 0.15rem 0.1rem 0.5rem;
color: var(--flform-text);
}
/* Claro gives every .form-item a large margin-block (1.5rem, meant for a
form with one field per row) -- with our own grid `gap` now doing that
job, this just doubles up the spacing between rows. */
.figli-ledger-form .form-item {
margin-block: 0;
}
html.gin--dark-mode .figli-ledger-form {
--flform-border: #3d3e42;
--flform-border-soft: #333438;
--flform-label: #a1a5ab;
--flform-text: #e8e9ea;
--flform-bg: #3a3b40;
--flform-bg-subtle: #2a2b2e;
--flform-accent: #5fb98a;
--flform-danger: #ff6b6b;
}
/* Wrapper divs Drupal still renders even though they're always empty
here (single-language site, path aliases handled elsewhere, status
hidden via #access in the module) -- without this they'd each still
claim an empty grid cell. */
.figli-ledger-form > .field--name-langcode,
.figli-ledger-form > .field--name-path,
.figli-ledger-form > .entity-content-form-footer,
.figli-ledger-form > .form-actions {
display: none;
}
/* Row layout, fully explicit (not left to grid auto-placement) -- with
Cotisation now in the mix there are three separate row groupings
(Client + N° Facture, the checkbox on its own full-width row, then
Montant HT + Cotisation + TVA + Montant TTC as a clean four-column
row) and relying on DOM order alone to keep them from bleeding into
each other got fragile once that many financial fields entered the
picture. This must stay in sync with the field weights set in
figli_compta_ledger_update_8006()/_figli_compta_ledger_create_node_type_ligne_comptable(). */
.figli-ledger-form > .field--name-title,
.figli-ledger-form > .field--name-field-entree-liee,
.figli-ledger-form > .field--name-field-cotisation-active,
.figli-ledger-form > .field--name-field-tva,
.figli-ledger-form > .field--name-field-repartition,
.figli-ledger-form > .field--name-field-notes,
.figli-ledger-form > .field--name-field-flag {
grid-column: 1 / -1;
}
.figli-ledger-form > .field--name-field-date-ligne {
grid-column: 1 / 2;
}
.figli-ledger-form > .field--name-field-type-ligne {
grid-column: 2 / 5;
}
.figli-ledger-form > .field--name-field-client {
grid-column: 1 / 4;
}
.figli-ledger-form > .field--name-field-numero-facture {
grid-column: 4 / 5;
}
.figli-ledger-form > .field--name-field-montant-ht {
grid-column: 1 / 2;
}
.figli-ledger-form > .field--name-field-cotisation-urssaf {
grid-column: 2 / 3;
}
/* The field_tva_rate select (added in figli_compta_ledger_form_alter(),
not a real field so it gets no field--name-* class) fills the third
slot of the HT/Cotisation/TVA/TTC row -- see that function's #weight
comment for why this specific ordering (weight 7, between Cotisation's
6 and TTC's 8) matters, not just matching column numbers. field_tva
itself (grouped with the other always-full-width fields above) only
appears -- full width, its own row -- when "Autre" is picked. */
.figli-ledger-form > .js-form-item-field-tva-rate {
grid-column: 3 / 4;
}
.figli-ledger-form > .field--name-field-montant-ttc {
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;
font-weight: 600;
color: var(--flform-label);
margin-bottom: 0.2rem;
display: inline-block;
}
.figli-ledger-form .form-item__description {
font-size: 0.72rem;
color: var(--flform-label);
margin-top: 0.2rem;
}
.figli-ledger-form input.form-element,
.figli-ledger-form select.form-element,
.figli-ledger-form textarea.form-element {
width: 100%;
box-sizing: border-box;
border: 1px solid var(--flform-border);
border-radius: 6px;
padding: 0.32rem 0.5rem;
font-size: 0.88rem;
color: var(--flform-text);
}
/* Not select: Claro positions its dropdown-chevron background-image via
background-position/-repeat/-size, which the `background` shorthand
below would silently reset to their initial values (top-left,
repeating) -- that turned the single right-aligned arrow into a tiled
pattern smeared across the whole field. Selects keep Claro's own
background untouched; only the border/radius/padding above apply. */
.figli-ledger-form input.form-element,
.figli-ledger-form textarea.form-element {
background: transparent;
}
.figli-ledger-form input.form-element:focus,
.figli-ledger-form select.form-element:focus,
.figli-ledger-form textarea.form-element:focus {
outline: 2px solid var(--flform-accent);
outline-offset: 1px;
}
/* Montant TTC: #disabled, computed from HT + TVA (see
figli_compta_ledger_node_presave()) -- a faint fill and full opacity
(overriding the browser's default half-transparent disabled look,
which would make the computed number harder to read) instead of the
plain white/transparent of an editable field. -webkit-text-fill-color
specifically: Chromium renders disabled/autofilled input text through
this property instead of `color` -- left alone, it defaults to a
fixed grey that's illegible against either theme's --flform-bg-subtle
(near-invisible dark-grey-on-dark-grey in dark mode, the actual bug
reported; same risk in light mode against a light fill). */
.figli-ledger-form input:disabled.form-element {
background: var(--flform-bg-subtle);
color: var(--flform-text);
-webkit-text-fill-color: var(--flform-text);
opacity: 1;
cursor: default;
}
.figli-ledger-form input[type="date"].form-element {
width: auto;
min-width: 9.5rem;
}
.figli-ledger-form textarea.form-element {
resize: vertical;
}
/* Multi-value tables (Répartition, Entrée client liée): drop the drag
handle and the "Show row weights" accessibility toggle for it -- row
order never affects the répartition sum or anything displayed
downstream, so with the usual 1-3 rows this is pure clutter. Remove
isn't affected: it stays in the per-row "Toggle Actions" menu. */
.figli-ledger-form .tabledrag-toggle-weight-wrapper,
.figli-ledger-form td.field-multiple-drag {
display: none;
}
.figli-ledger-form table.field-multiple-table {
width: 100%;
border-collapse: collapse;
background: var(--flform-bg-subtle);
border: 1px solid var(--flform-border-soft);
border-radius: 8px;
overflow: hidden;
}
.figli-ledger-form table.field-multiple-table thead th {
text-align: left;
padding: 0.3rem 0.5rem 0.2rem;
}
.figli-ledger-form table.field-multiple-table tbody td {
padding: 0.2rem 0.5rem;
vertical-align: top;
border-top: 1px solid var(--flform-border-soft);
}
.figli-ledger-form .field-multiple-table .form-item__label {
visibility: visible;
}
.figli-ledger-form .paragraphs-subform {
display: flex;
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
buttons by default, restyled as a small, consistent set instead of
browser-default grey buttons of varying sizes. */
.figli-ledger-form input.button {
font-size: 0.78rem;
padding: 0.3rem 0.65rem;
border-radius: 5px;
border: 1px solid var(--flform-border);
background: transparent;
color: var(--flform-text);
cursor: pointer;
}
.figli-ledger-form input.button:hover {
border-color: var(--flform-accent);
color: var(--flform-accent);
}
.figli-ledger-form .field-add-more-submit {
margin-top: 0.25rem;
}
.figli-ledger-form .paragraphs-dropdown-toggle {
border: none;
background: transparent;
color: var(--flform-label);
cursor: pointer;
font-size: 1rem;
line-height: 1;
padding: 0.1rem 0.3rem;
}
.figli-ledger-form .paragraphs-dropdown-toggle:hover {
color: var(--flform-accent);
}
/* Closed by default (Claro's own paragraphs.actions.css already sets
display: none here and toggles a sibling .open class on click via
paragraphs.actions.js) -- only restyle the panel's look, and only once
Drupal's own JS has actually opened it. Getting this wrong (e.g.
forcing `display: flex` unconditionally here) makes every "Toggle
Actions" menu render permanently expanded. */
.figli-ledger-form .paragraphs-dropdown.open .paragraphs-dropdown-actions {
display: flex;
flex-direction: column;
gap: 0.2rem;
padding: 0.3rem;
border: 1px solid var(--flform-border);
border-radius: 6px;
background: var(--flform-bg-subtle);
}
/* Field group heading style (the Répartition table's own label, inside
its <th>) -- match the rest of the form's label typography instead of
Drupal's default <h4>. */
.figli-ledger-form table.field-multiple-table h4.form-item__label {
font-size: 0.8rem;
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
used for écarts in the /lignes table itself. */
.figli-ledger-form tr.error,
.figli-ledger-form .has-error {
outline: 1px solid var(--flform-danger);
outline-offset: -1px;
}
/* Form actions (Save), rendered by the AJAX dialog's own button pane
rather than here in most cases, but keep a sane baseline for the
rare full-page (non-modal) view of this form. */
.figli-ledger-form .form-actions input.button--primary {
background: var(--flform-accent);
color: #fff;
border-color: var(--flform-accent);
font-weight: 600;
}
@@ -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) {
@@ -211,7 +238,32 @@ function _figli_compta_ledger_create_node_type_ligne_comptable() {
_figli_field('node', 'ligne_comptable', 'field_numero_facture', 'N° Facture', 'string', ['max_length' => 64]);
_figli_field('node', 'ligne_comptable', 'field_montant_ht', 'Montant HT (€)', 'decimal', ['precision' => 12, 'scale' => 2], TRUE);
// Scale 4 (not 2) so that reconstructing an existing invoice's exact
// TTC from HT * (1 + TVA/100) round-trips to the centime for the
// overwhelming majority of migrated lines -- see
// figli_compta_ledger_update_8005()'s docblock for the empirical check
// behind that choice. A user typing a plain rate like "20" is
// unaffected either way.
_figli_field('node', 'ligne_comptable', 'field_tva', 'TVA (%)', 'decimal', ['precision' => 8, 'scale' => 4, 'min' => 0]);
// Cotisation diffuseur URSSAF (1,1%), entrée client lines only -- see
// figli_compta_ledger_update_8006()'s docblock for the full
// explanation. field_cotisation_active is the editable on/off toggle
// (a devis doesn't always include it); field_cotisation_urssaf is the
// computed amount (Montant HT * 1.011), never entered by hand.
_figli_field('node', 'ligne_comptable', 'field_cotisation_active', 'Cotisation diffuseur URSSAF (1,1%)', 'boolean');
_figli_field('node', 'ligne_comptable', 'field_cotisation_urssaf', '1,1%', 'decimal', ['precision' => 12, 'scale' => 2]);
_figli_field('node', 'ligne_comptable', 'field_montant_ttc', 'Montant TTC (€)', 'decimal', ['precision' => 12, 'scale' => 2]);
// Montant HT - somme(répartition), kept in sync at every save (see
// figli_compta_ledger_node_presave()) so /lignes' "Écarts uniquement"
// 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
@@ -235,11 +287,35 @@ function _figli_compta_ledger_create_node_type_ligne_comptable() {
->setComponent('field_type_ligne', ['type' => 'options_select', 'weight' => 1])
->setComponent('field_client', ['type' => 'entity_reference_autocomplete', 'weight' => 2])
->setComponent('field_numero_facture', ['type' => 'string_textfield', 'weight' => 3])
->setComponent('field_montant_ht', ['type' => 'number', 'weight' => 4])
->setComponent('field_montant_ttc', ['type' => 'number', 'weight' => 5])
->setComponent('field_repartition', ['type' => 'paragraphs', 'weight' => 6, 'settings' => ['title' => 'Répartition', 'title_plural' => 'Répartitions', 'edit_mode' => 'open', 'add_mode' => 'button']])
->setComponent('field_notes', ['type' => 'string_textarea', 'weight' => 7])
->setComponent('field_flag', ['type' => 'entity_reference_autocomplete_tags', 'weight' => 8])
// Cotisation diffuseur URSSAF (1,1%) -- only "Entrée client" lines
// go through a client devis, the only place this cotisation is
// ever invoiced (see figli_compta_ledger_update_8006()'s
// docblock). Optional even there: not every entrée historically
// included it, so a checkbox rather than an automatic
// type-based assumption. Weight 4, tied with field_entree_liee --
// the two types that show each are mutually exclusive
// (entrée vs. versement/achat/hébergement/sous_traitant), so
// which one wins the tie never matters.
->setComponent('field_cotisation_active', ['type' => 'boolean_checkbox', 'weight' => 4, 'settings' => ['display_label' => TRUE]])
->setComponent('field_montant_ht', ['type' => 'number', 'weight' => 5])
->setComponent('field_cotisation_urssaf', ['type' => 'number', 'weight' => 6])
->setComponent('field_montant_ttc', ['type' => 'number', 'weight' => 8])
// 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' => 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();
}
@@ -254,11 +330,14 @@ function _figli_compta_ledger_create_node_type_ligne_comptable() {
->setComponent('field_type_ligne', ['type' => 'list_default', 'weight' => 1])
->setComponent('field_client', ['type' => 'entity_reference_label', 'weight' => 2])
->setComponent('field_numero_facture', ['type' => 'string', 'weight' => 3])
->setComponent('field_montant_ht', ['type' => 'number_decimal', 'weight' => 4])
->setComponent('field_montant_ttc', ['type' => 'number_decimal', 'weight' => 5])
->setComponent('field_repartition', ['type' => 'entity_reference_revisions_entity_view', 'weight' => 6])
->setComponent('field_notes', ['type' => 'basic_string', 'weight' => 7])
->setComponent('field_flag', ['type' => 'entity_reference_label', 'weight' => 8])
->setComponent('field_cotisation_active', ['type' => 'boolean', 'weight' => 4])
->setComponent('field_montant_ht', ['type' => 'number_decimal', 'weight' => 5])
->setComponent('field_cotisation_urssaf', ['type' => 'number_decimal', 'weight' => 6])
->setComponent('field_montant_ttc', ['type' => 'number_decimal', 'weight' => 8])
->setComponent('field_tva', ['type' => 'number_decimal', 'weight' => 9])
->setComponent('field_repartition', ['type' => 'entity_reference_revisions_entity_view', 'weight' => 10])
->setComponent('field_notes', ['type' => 'basic_string', 'weight' => 11])
->setComponent('field_flag', ['type' => 'entity_reference_label', 'weight' => 12])
->save();
}
}
@@ -353,3 +432,544 @@ function figli_compta_ledger_update_8003() {
$view_display->setComponent('field_flag', ['type' => 'entity_reference_label', 'weight' => 8])->save();
}
}
/**
* Adds field_tva (TVA %) to ligne_comptable and switches Montant TTC from
* manual entry to an automatically computed value (see
* figli_compta_ledger_node_presave(): TTC = round(HT * (1 + TVA/100), 2)
* from now on, whenever the line isn't a skip_validation-flagged
* migration save). Existing content is backfilled separately, see
* figli_compta_ledger_update_8005().
*
* Also reweights field_numero_facture (now next to Client, both
* identifying "which invoice/client" this line is about) and
* field_entree_liee (pushed just after it) so the HT/TVA/TTC trio can
* form its own clean three-column row afterwards -- see
* css/ledger-form.css's explicit grid-column rules, which this weight
* order must stay in sync with.
*/
function figli_compta_ledger_update_8004() {
_figli_field('node', 'ligne_comptable', 'field_tva', 'TVA (%)', 'decimal', ['precision' => 8, 'scale' => 4, 'min' => 0]);
// New lines default to 10% (taux intermédiaire) rather than blank --
// most new lines associates enter do carry VAT, so this saves a click
// on the common case instead of silently defaulting to "no VAT" for
// every line unless someone remembers to change it.
$field_tva = FieldConfig::loadByName('node', 'ligne_comptable', 'field_tva');
if ($field_tva && $field_tva->getDefaultValueLiteral() === []) {
$field_tva->setDefaultValue(10)->save();
}
// Weights are plain integers -- entity_display component config
// schema coerces anything else (a fractional weight silently rounds/
// truncates to its integer part on save), so the row-grouping can't be
// expressed as "insert field_tva between HT and TTC" via a fractional
// weight the way it might be elsewhere; every field in this block gets
// an explicit new integer instead.
$form_display = EntityFormDisplay::load('node.ligne_comptable.default');
if ($form_display) {
$form_display->setComponent('field_numero_facture', ['type' => 'string_textfield', 'weight' => 3]);
$form_display->setComponent('field_entree_liee', ['type' => 'entity_reference_autocomplete', 'weight' => 4] + (array) $form_display->getComponent('field_entree_liee'));
$form_display->setComponent('field_montant_ht', ['type' => 'number', 'weight' => 5] + (array) $form_display->getComponent('field_montant_ht'));
$form_display->setComponent('field_montant_ttc', ['type' => 'number', 'weight' => 7] + (array) $form_display->getComponent('field_montant_ttc'));
// Weight 6 (between HT and TTC) is now the non-field
// "field_tva_rate" select figli_compta_ledger_form_alter() adds --
// field_tva itself moves after Répartition/Notes/Flag's *old*
// positions, past the ones below, so it lands on its own row when
// "Autre" reveals it instead of disrupting the HT/rate/TTC row.
$form_display->setComponent('field_tva', ['type' => 'number', 'weight' => 8] + (array) $form_display->getComponent('field_tva'));
$form_display->setComponent('field_repartition', ['weight' => 9] + (array) $form_display->getComponent('field_repartition'));
$form_display->setComponent('field_notes', ['weight' => 10] + (array) $form_display->getComponent('field_notes'));
$form_display->setComponent('field_flag', ['weight' => 11] + (array) $form_display->getComponent('field_flag'));
$form_display->save();
}
$view_display = EntityViewDisplay::load('node.ligne_comptable.default');
if ($view_display) {
$view_display->setComponent('field_tva', ['type' => 'number_decimal', 'weight' => 8] + (array) $view_display->getComponent('field_tva'));
$view_display->setComponent('field_numero_facture', ['weight' => 3] + (array) $view_display->getComponent('field_numero_facture'));
$view_display->setComponent('field_montant_ht', ['weight' => 5] + (array) $view_display->getComponent('field_montant_ht'));
$view_display->setComponent('field_montant_ttc', ['weight' => 7] + (array) $view_display->getComponent('field_montant_ttc'));
$view_display->setComponent('field_repartition', ['weight' => 9] + (array) $view_display->getComponent('field_repartition'));
$view_display->setComponent('field_notes', ['weight' => 10] + (array) $view_display->getComponent('field_notes'));
$view_display->setComponent('field_flag', ['weight' => 11] + (array) $view_display->getComponent('field_flag'));
$view_display->save();
}
}
/**
* Backfills field_tva for existing ligne_comptable content from the
* existing Montant HT / Montant TTC pair -- never touches Montant TTC
* itself, so every historical value stays exactly as migrated (same
* "never correct historical data" rule as everywhere else in this
* module). Left blank where there's nothing to derive from (no TTC ever
* recorded, or HT is zero).
*
* Why scale 4 on field_tva: checked empirically against all 1556
* existing lignes_comptables before picking it. Of the 946 lines with a
* real (non-zero, HT != TTC) rate, rounding the derived rate to 2
* decimals and reconstructing TTC = HT * (1 + rate/100) mismatched the
* real historical TTC (by more than a centime) for 74 of them --
* unacceptably lossy for what's supposed to be a faithful backfill. At 4
* decimals that drops to 9 (likely genuine blended/multi-rate invoices
* collapsed into a single ligne_comptable, where no single "TVA %" can
* be perfectly exact) -- an acceptable residual, and still purely
* informational since Montant TTC itself is left untouched here either
* way.
*/
function figli_compta_ledger_update_8005() {
$storage = \Drupal::entityTypeManager()->getStorage('node');
$nids = $storage->getQuery()
->accessCheck(FALSE)
->condition('type', 'ligne_comptable')
->execute();
$filled = 0;
$skipped = 0;
// Only field_tva is written here -- montant_ttc is read but never
// set, so this can never trip figli_compta_ledger_node_presave()'s
// sum(répartition) == montant_ht check either way. skip_validation is
// set anyway (a) for consistency with every other bulk-migration
// script in this module and (b) because it also now suppresses the
// TTC auto-computation added in the same presave function, which
// would otherwise silently overwrite the untouched historical TTC the
// moment this script calls save().
\Drupal::state()->set('figli_compta_ledger.skip_validation', TRUE);
foreach ($storage->loadMultiple($nids) as $node) {
if (!$node->hasField('field_montant_ht') || $node->get('field_montant_ht')->isEmpty()
|| !$node->hasField('field_montant_ttc') || $node->get('field_montant_ttc')->isEmpty()) {
$skipped++;
continue;
}
$ht = (float) $node->get('field_montant_ht')->value;
$ttc = (float) $node->get('field_montant_ttc')->value;
if (abs($ht) < 0.0001) {
$skipped++;
continue;
}
$rate = round(($ttc / $ht - 1) * 100, 4);
$node->set('field_tva', $rate);
$node->save();
$filled++;
}
\Drupal::state()->delete('figli_compta_ledger.skip_validation');
return "TVA calculée pour $filled lignes, $skipped laissées vides (pas de Montant TTC renseigné ou Montant HT nul).";
}
/**
* Adds the cotisation diffuseur URSSAF (1,1%) fields to ligne_comptable,
* and switches Montant TTC's computation for "Entrée client" lines when
* the cotisation applies: TTC = round(cotisation * (1 + TVA/100), 2)
* instead of straight from Montant HT.
*
* The SAS invoices clients a HT amount, then -- when the devis includes
* it, not always -- adds a 1,1% "cotisation diffuseur" the SAS owes
* URSSAF for using freelancers, *then* applies TVA on top of that
* increased amount, not on the raw HT. Only "Entrée client" lines ever
* go through a client devis at all; every other type (versement, achat,
* charge...) is an outgoing/internal line with nothing to invoice a
* cotisation on, so both fields below are #states-hidden outside that
* type (see figli_compta_ledger_form_alter()) and
* figli_compta_ledger_node_presave() never applies the cascade to them.
*
* field_tva as backfilled by figli_compta_ledger_update_8005() didn't
* know about this cascade and is wrong for every entrée line that did
* have the cotisation applied (it computed straight from HT, so what it
* stored is actually the *blended* HT->TTC rate, not the real TVA
* rate) -- corrected separately in figli_compta_ledger_update_8007(),
* which also backfills field_cotisation_active/field_cotisation_urssaf
* for existing content.
*/
function figli_compta_ledger_update_8006() {
_figli_field('node', 'ligne_comptable', 'field_cotisation_active', 'Cotisation diffuseur URSSAF (1,1%)', 'boolean');
_figli_field('node', 'ligne_comptable', 'field_cotisation_urssaf', '1,1%', 'decimal', ['precision' => 12, 'scale' => 2]);
// New entrée lines default to the cotisation being applied -- the
// associates' normal practice going forward, even though it was only
// roughly half of existing content (figli_compta_ledger_update_8007()
// detects each historical line on its own rather than assuming either
// way for what's already there).
$field_active = FieldConfig::loadByName('node', 'ligne_comptable', 'field_cotisation_active');
if ($field_active && $field_active->getDefaultValueLiteral() === []) {
$field_active->setDefaultValue(1)->save();
}
// See figli_compta_ledger_update_8004()'s comment on why every weight
// here is a plain integer (fractional weights silently truncate).
$form_display = EntityFormDisplay::load('node.ligne_comptable.default');
if ($form_display) {
$form_display->setComponent('field_cotisation_active', ['type' => 'boolean_checkbox', 'weight' => 4, 'settings' => ['display_label' => TRUE]]);
$form_display->setComponent('field_montant_ht', ['type' => 'number', 'weight' => 5] + (array) $form_display->getComponent('field_montant_ht'));
$form_display->setComponent('field_cotisation_urssaf', ['type' => 'number', 'weight' => 6]);
// Weight 7 (between Cotisation and Montant TTC) is the non-field
// "field_tva_rate" select figli_compta_ledger_form_alter() adds.
$form_display->setComponent('field_montant_ttc', ['type' => 'number', 'weight' => 8] + (array) $form_display->getComponent('field_montant_ttc'));
$form_display->setComponent('field_tva', ['type' => 'number', 'weight' => 9] + (array) $form_display->getComponent('field_tva'));
$form_display->setComponent('field_repartition', ['weight' => 10] + (array) $form_display->getComponent('field_repartition'));
$form_display->setComponent('field_notes', ['weight' => 11] + (array) $form_display->getComponent('field_notes'));
$form_display->setComponent('field_flag', ['weight' => 12] + (array) $form_display->getComponent('field_flag'));
$form_display->save();
}
$view_display = EntityViewDisplay::load('node.ligne_comptable.default');
if ($view_display) {
$view_display->setComponent('field_cotisation_active', ['type' => 'boolean', 'weight' => 4]);
$view_display->setComponent('field_cotisation_urssaf', ['type' => 'number_decimal', 'weight' => 6]);
$view_display->setComponent('field_montant_ttc', ['weight' => 8] + (array) $view_display->getComponent('field_montant_ttc'));
$view_display->setComponent('field_tva', ['weight' => 9] + (array) $view_display->getComponent('field_tva'));
$view_display->setComponent('field_repartition', ['weight' => 10] + (array) $view_display->getComponent('field_repartition'));
$view_display->setComponent('field_notes', ['weight' => 11] + (array) $view_display->getComponent('field_notes'));
$view_display->setComponent('field_flag', ['weight' => 12] + (array) $view_display->getComponent('field_flag'));
$view_display->save();
}
}
/**
* Backfills field_cotisation_active/field_cotisation_urssaf and
* corrects field_tva for existing "Entrée client" lines, now that the
* HT -> +1,1% -> +TVA cascade explained in
* figli_compta_ledger_update_8006()'s docblock is understood. 2021 is
* left untouched entirely (explicit instruction -- that year's
* cotisation practice isn't confirmed), and Montant TTC itself is never
* written here, same "never correct historical data" rule as every
* other migration in this module.
*
* Per entrée line (2022+, both Montant HT and Montant TTC present):
* - old_rate = (TTC/HT - 1) * 100 -- what
* figli_compta_ledger_update_8005() already stored in field_tva.
* - If old_rate already lands on an official French VAT rate (0, 2.1,
* 5.5, 10, 20 -- ±0.05 points, allowing for rounding through 2-decimal
* HT/TTC storage), the cotisation was never applied to this line:
* field_tva is already correct as migrated, left alone, cotisation
* marked inactive.
* - Otherwise, new_rate = (TTC / (HT * 1.011) - 1) * 100. If *that*
* lands on an official rate, the cotisation was applied and old_rate
* was the wrong (blended HT->TTC) figure all along -- field_tva
* corrected to new_rate, cotisation marked active, field_cotisation_
* urssaf backfilled to HT * 1.011.
* - If neither lands cleanly, this is an ambiguous/blended case (a
* handful of genuinely multi-rate invoices collapsed into one line,
* same category figli_compta_ledger_update_8005() already found at 4-
* decimal precision) -- field_tva left untouched, cotisation marked
* inactive, rather than guess.
*
* Checked empirically before writing this (287 entrée lines with both
* amounts, 2022+): 90 already official (left alone), 154 corrected, 40
* still ambiguous both ways (left alone), 3 missing HT or TTC.
*/
function figli_compta_ledger_update_8007() {
$official = [0.0, 2.1, 5.5, 10.0, 20.0];
$epsilon = 0.05;
$matches_official = function ($rate) use ($official, $epsilon) {
foreach ($official as $o) {
if (abs($rate - $o) < $epsilon) {
return TRUE;
}
}
return FALSE;
};
$storage = \Drupal::entityTypeManager()->getStorage('node');
$nids = $storage->getQuery()
->accessCheck(FALSE)
->condition('type', 'ligne_comptable')
->condition('field_type_ligne', 'entree')
->execute();
$skipped2021 = 0;
$noData = 0;
$alreadyOfficial = 0;
$corrected = 0;
$stillAmbiguous = 0;
\Drupal::state()->set('figli_compta_ledger.skip_validation', TRUE);
foreach ($storage->loadMultiple($nids) as $node) {
$date = $node->hasField('field_date_ligne') ? $node->get('field_date_ligne')->value : NULL;
if ($date && substr($date, 0, 4) === '2021') {
$skipped2021++;
continue;
}
$ht = $node->hasField('field_montant_ht') && !$node->get('field_montant_ht')->isEmpty()
? (float) $node->get('field_montant_ht')->value : NULL;
$ttc = $node->hasField('field_montant_ttc') && !$node->get('field_montant_ttc')->isEmpty()
? (float) $node->get('field_montant_ttc')->value : NULL;
if ($ht === NULL || $ttc === NULL || abs($ht) < 0.0001) {
$node->set('field_cotisation_active', FALSE);
$node->save();
$noData++;
continue;
}
$old_rate = round(($ttc / $ht - 1) * 100, 4);
if ($matches_official($old_rate)) {
$node->set('field_cotisation_active', FALSE);
$node->save();
$alreadyOfficial++;
continue;
}
$new_rate = round(($ttc / ($ht * 1.011) - 1) * 100, 4);
if (!$matches_official($new_rate)) {
$node->set('field_cotisation_active', FALSE);
$node->save();
$stillAmbiguous++;
continue;
}
$node->set('field_cotisation_active', TRUE);
$node->set('field_cotisation_urssaf', round($ht * 1.011, 2));
$node->set('field_tva', $new_rate);
$node->save();
$corrected++;
}
\Drupal::state()->delete('figli_compta_ledger.skip_validation');
return "Cotisation/TVA : $corrected lignes corrigées (cotisation active), $alreadyOfficial déjà correctes (cotisation inactive), $stillAmbiguous laissées telles quelles (taux non standard des deux façons), $noData sans HT/TTC, $skipped2021 lignes 2021 ignorées.";
}
/**
* Corrects what field_cotisation_urssaf stores for the lines
* figli_compta_ledger_update_8007() marked cotisation-active: that
* migration (and figli_compta_ledger_node_presave() as first written)
* stored Montant HT + 1,1% there, but "1,1%" as a field/column label
* means just the 1,1% itself -- the delta, not the augmented total.
* TVA still applies to the augmented base (Montant HT + this field), so
* Montant TTC is untouched by this correction; only
* field_cotisation_urssaf's own value changes, from round(HT * 1.011, 2)
* to round(HT * 0.011, 2). Safe to run unconditionally on every
* cotisation-active line -- this is a bug in code from earlier the same
* day, not a judgment call about ambiguous historical data like
* figli_compta_ledger_update_8007()'s.
*/
function figli_compta_ledger_update_8008() {
$storage = \Drupal::entityTypeManager()->getStorage('node');
$nids = $storage->getQuery()
->accessCheck(FALSE)
->condition('type', 'ligne_comptable')
->condition('field_cotisation_active', 1)
->execute();
$fixed = 0;
\Drupal::state()->set('figli_compta_ledger.skip_validation', TRUE);
foreach ($storage->loadMultiple($nids) as $node) {
if (!$node->hasField('field_montant_ht') || $node->get('field_montant_ht')->isEmpty()) {
continue;
}
$ht = (float) $node->get('field_montant_ht')->value;
$node->set('field_cotisation_urssaf', round($ht * 0.011, 2));
$node->save();
$fixed++;
}
\Drupal::state()->delete('figli_compta_ledger.skip_validation');
return "field_cotisation_urssaf corrigé (delta seul, pas HT + delta) pour $fixed lignes.";
}
/**
* Adds field_ecart (Montant HT - somme répartition) to ligne_comptable --
* kept in sync at every save from now on (see
* figli_compta_ledger_node_presave()), so the server-side filtering work
* planned for /lignes can filter on a real stored value instead of
* resolving répartition paragraphs per row on every request. Existing
* content backfilled separately, see figli_compta_ledger_update_8010().
*/
function figli_compta_ledger_update_8009() {
// No form widget added -- purely a diagnostic/filtering value, already
// fully represented in the edit form by Montant HT + the Répartition
// paragraphs it's derived from, and already shown on /lignes' own
// Écart column. Nothing new for an associate to look at here; simply
// never calling setComponent() for it on the form display is enough
// to keep it off that form.
_figli_field('node', 'ligne_comptable', 'field_ecart', 'Écart', 'decimal', ['precision' => 12, 'scale' => 2]);
$view_display = EntityViewDisplay::load('node.ligne_comptable.default');
if ($view_display && !$view_display->getComponent('field_ecart')) {
$view_display->setComponent('field_ecart', ['type' => 'number_decimal', 'weight' => 13])->save();
}
}
/**
* Backfills field_ecart for every existing ligne_comptable (all types,
* unlike the TVA/cotisation migrations which only ever touched "Entrée
* client" lines -- répartition applies to every line regardless of
* type). Mechanical, not a judgment call like
* figli_compta_ledger_update_8007()'s TVA correction: Montant HT and the
* répartition amounts are both already-correct historical values, this
* just computes their difference and stores it, exactly what
* figli_compta_ledger_node_presave() will do going forward.
*
* Runs under two state flags, not just the usual skip_validation:
* skip_validation avoids tripping the répartition-sum exception on the
* ~18 lines with a known, deliberately-preserved historical mismatch
* (same as every other bulk script in this module); skip_revision
* additionally suppresses the forced-revision block in
* figli_compta_ledger_node_presave() -- backfilling a purely computed
* diagnostic field onto ~1500 already-migrated lines isn't an editorial
* change worth 1500 new revisions cluttering the audit trail that
* mechanism exists to protect. Montant TTC is never touched either way
* (skip_validation already keeps it out of reach, see
* figli_compta_ledger_node_presave()).
*/
function figli_compta_ledger_update_8010() {
$storage = \Drupal::entityTypeManager()->getStorage('node');
$nids = $storage->getQuery()
->accessCheck(FALSE)
->condition('type', 'ligne_comptable')
->execute();
$filled = 0;
$skipped = 0;
\Drupal::state()->set('figli_compta_ledger.skip_validation', TRUE);
\Drupal::state()->set('figli_compta_ledger.skip_revision', TRUE);
foreach ($storage->loadMultiple($nids) as $node) {
if (!$node->hasField('field_montant_ht') || $node->get('field_montant_ht')->isEmpty()
|| !$node->hasField('field_repartition')) {
$skipped++;
continue;
}
$ht = (float) $node->get('field_montant_ht')->value;
$somme = 0.0;
foreach ($node->get('field_repartition')->referencedEntities() as $paragraph) {
if ($paragraph->hasField('field_montant') && !$paragraph->get('field_montant')->isEmpty()) {
$somme += (float) $paragraph->get('field_montant')->value;
}
}
$node->set('field_ecart', round($ht - $somme, 2));
$node->save();
$filled++;
}
\Drupal::state()->delete('figli_compta_ledger.skip_validation');
\Drupal::state()->delete('figli_compta_ledger.skip_revision');
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: {}
@@ -35,6 +45,21 @@ dashboard_compte:
- core/drupal
- figli_compta_ledger/vue
ledger_form:
js:
js/ledger-form.js: {}
css:
theme:
css/ledger-form.css: {}
dependencies:
- 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
@@ -35,6 +35,11 @@ function figli_compta_ledger_form_alter(&$form, FormStateInterface $form_state,
if (!in_array($form_id, ['node_ligne_comptable_form', 'node_ligne_comptable_edit_form'], TRUE)) {
return;
}
// The add and edit forms get different auto-generated classes
// (node-ligne-comptable-form vs. node-ligne-comptable-edit-form) --
// one shared class so css/ledger-form.css can target both without
// duplicating every selector.
$form['#attributes']['class'][] = 'figli-ledger-form';
$form['#validate'][] = 'figli_compta_ledger_validate_repartition';
// Every save must create a revision, unconditionally -- there's no
@@ -71,6 +76,175 @@ function figli_compta_ledger_form_alter(&$form, FormStateInterface $form_state,
];
}
// "Title" is generic Drupal boilerplate that means nothing to a
// freelancer entering an accounting line -- but home.js/dashboard*.js
// do fall back to it as the displayed "Libellé" when Notes / détail is
// left empty, so it can't just be hidden. Relabel it so that
// relationship is explicit instead of a mystery required field.
if (isset($form['title']['widget'][0]['value'])) {
$form['title']['widget'][0]['value']['#title'] = t('Libellé court');
$form['title']['widget'][0]['value']['#description'] = t('Affiché dans le grand livre si "Notes / détail" ci-dessous est laissé vide.');
}
// Menu settings / URL alias / Authoring information: none of this is
// ever used for a ligne_comptable (no menu links, no path aliases, and
// the acting user + revision timestamp are already captured by the
// forced revision in figli_compta_ledger_node_presave() below) -- just
// noise in a form meant to be a quick numeric entry, not a full content
// editing screen. #access rather than deleting the keys: safe no-op if
// a future field ever gets #group => 'advanced'.
if (isset($form['advanced'])) {
$form['advanced']['#access'] = FALSE;
}
// "Published" is meaningless here too -- there's no draft/unpublished
// workflow for ledger lines, every one of them is expected to count
// immediately. Same pattern as the 'revision' field above: force the
// value rather than exposing a checkbox nobody should ever touch.
if (isset($form['status'])) {
$form['status']['widget']['value']['#default_value'] = TRUE;
$form['status']['#access'] = FALSE;
}
// Montant TTC is computed from Montant HT + TVA (see
// figli_compta_ledger_node_presave()) -- genuinely not editable
// (#disabled, not just readonly: a disabled field can't be focused or
// tabbed into at all, and Form API discards whatever a tampered
// request submits for it in favor of #default_value, same protection
// already used for 'revision' above). js/ledger-form.js can still set
// its .value directly for the live preview -- #disabled only blocks
// user interaction, not script writes.
if (isset($form['field_montant_ttc']['widget'][0]['value'])) {
$form['field_montant_ttc']['widget'][0]['value']['#disabled'] = TRUE;
$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
// fields are hidden otherwise. Real #states on real fields watching
// the real field_type_ligne select -- unlike field_tva_rate below,
// this is the same working pattern already used a few fields up on
// field_entree_liee, not the custom select that #states silently
// failed to toggle.
if (isset($form['field_cotisation_active'])) {
$form['field_cotisation_active']['#states'] = [
'visible' => [':input[name="field_type_ligne"]' => ['value' => 'entree']],
];
}
if (isset($form['field_cotisation_urssaf']['widget'][0]['value'])) {
$form['field_cotisation_urssaf']['#states'] = [
'visible' => [':input[name="field_type_ligne"]' => ['value' => 'entree']],
];
$form['field_cotisation_urssaf']['widget'][0]['value']['#disabled'] = TRUE;
$form['field_cotisation_urssaf']['widget'][0]['value']['#description'] = t('Calculé automatiquement (1,1% du montant HT) si la cotisation ci-dessus est cochée. La TVA est ensuite appliquée sur Montant HT + ce montant.');
}
// TVA: a select of the official French rates covers the overwhelming
// majority of new lines with one click, instead of associates having
// to know/type "20" or "5.5" by heart. "Autre" reveals the real
// decimal field underneath for the rest -- crucially including every
// pre-migration line, whose backfilled rate (see
// figli_compta_ledger_update_8005()) is often a blended, non-standard
// percentage that wouldn't match any of these options, and must stay
// editable/visible as the exact figure it is rather than being forced
// to snap to the nearest official rate.
if (isset($form['field_tva']['widget'][0]['value'])) {
$taux_officiels = [
'0' => t('0 % (exonéré)'),
'2.1' => t('2,1 % (taux particulier)'),
'5.5' => t('5,5 % (taux réduit)'),
'10' => t('10 % (taux intermédiaire)'),
'20' => t('20 % (taux normal)'),
];
$tva_field = &$form['field_tva']['widget'][0]['value'];
$current = $tva_field['#default_value'] ?? NULL;
$rate_default = 'autre';
if ($current === NULL || $current === '') {
// 10 % (taux intermédiaire), not 0 -- most new lines associates
// enter do carry VAT, so this saves a click on the common case
// instead of silently defaulting to "no VAT" for every line
// unless someone remembers to change it.
$rate_default = '10';
}
else {
foreach (array_keys($taux_officiels) as $rate) {
if (abs((float) $current - (float) $rate) < 0.0001) {
$rate_default = $rate;
break;
}
}
}
// A plain top-level element, deliberately *not* nested inside
// $form['field_tva'] -- Field API's WidgetBase::extractFormValues()
// reads the entire form_state value at that path as the field's own
// delta-indexed value array, and chokes on an unrelated sibling key
// mixed into it.
//
// Weight 7: the slot between field_cotisation_urssaf (6) and Montant
// TTC (8) left open in figli_compta_ledger_update_8006() -- field_tva
// itself sits at weight 9 (its own full-width row) so that this
// select forms a clean HT/Cotisation/rate/TTC four-column row
// whether or not "Autre" is currently revealing the real field_tva
// input further down. See css/ledger-form.css's grid-column rules
// for both -- the row layout there depends on this exact ordering,
// not just on matching #weight values.
$form['field_tva_rate'] = [
'#type' => 'select',
'#title' => t('TVA'),
'#options' => $taux_officiels + ['autre' => t('Autre (préciser)')],
'#default_value' => $rate_default,
'#weight' => 7,
];
$tva_field['#title'] = t('Taux exact (%)');
// Not #states: this element sits outside the field system (see the
// comment above) and, tested in this exact AJAX-loaded modal,
// #states' data-drupal-states attribute gets attached correctly but
// never actually toggles visibility here -- unclear why (a working
// #states dependency already exists a few fields up, on
// field_entree_liee, watching a real Field API select rather than
// this manually-added one), not worth chasing further when
// js/ledger-form.js already has to run custom JS on this form
// anyway for the HT/TVA -> TTC live preview. Plain JS toggle there
// instead -- see figliLedgerTvaRateToggle.
$form['#validate'][] = 'figli_compta_ledger_apply_tva_rate';
}
$form['#attached']['library'][] = 'figli_compta_ledger/ledger_form';
$request = \Drupal::request();
$wrapper_formats = ['drupal_ajax', 'drupal_modal', 'drupal_dialog'];
$is_ajax_modal = in_array($request->query->get('_wrapper_format'), $wrapper_formats, TRUE)
@@ -115,6 +289,28 @@ function figli_compta_ledger_validate_repartition(array &$form, FormStateInterfa
}
}
/**
* Form #validate callback: when a standard French VAT rate was picked from
* the field_tva_rate select (added in figli_compta_ledger_form_alter()),
* copies it into field_tva's own submitted value -- that's the field
* figli_compta_ledger_node_presave() actually reads to compute Montant TTC.
* Left untouched when "Autre" is selected: the real field_tva input,
* shown in that case by js/ledger-form.js's figliLedgerTvaRateToggle,
* already carries whatever the associate typed there.
*
* Runs late enough to matter despite EntityForm::validateForm() building an
* interim entity earlier in the same validate phase (for constraint
* checking only) -- submitForm() rebuilds the entity fresh from
* $form_state's values right before save(), by which point every
* #validate callback, this one included, has already run.
*/
function figli_compta_ledger_apply_tva_rate(array &$form, FormStateInterface $form_state) {
$rate = $form_state->getValue('field_tva_rate');
if ($rate !== NULL && $rate !== 'autre') {
$form_state->setValue(['field_tva', 0, 'value'], $rate);
}
}
/**
* #ajax callback for the node form submit button: close the modal on
* success, or re-render the form (with errors) in place on failure.
@@ -162,9 +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; new lines entered by
* associates through the form are never exempted.
* 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') {
@@ -175,23 +383,97 @@ function figli_compta_ledger_node_presave(NodeInterface $node) {
// acting user as its author -- this is the audit trail for a shared
// ledger, not the répartition-sum check below, so it's never exempted
// (not even during a skip_validation import: migrated data still needs
// an honest revision history).
$node->setNewRevision(TRUE);
$node->setRevisionLogMessage('');
$node->setRevisionUserId(\Drupal::currentUser()->id());
// setNewRevision() alone doesn't refresh this -- it stays whatever it
// was on the entity as loaded (the previous revision's timestamp),
// silently mislabeling every edit with its predecessor's save time.
$node->setRevisionCreationTime(\Drupal::time()->getRequestTime());
if (!$node->hasField('field_montant_ht') || !$node->hasField('field_repartition')) {
return;
// an honest revision history). The one deliberate exception:
// figli_compta_ledger_update_8010()'s one-off field_ecart backfill sets
// 'figli_compta_ledger.skip_revision' -- a purely computed diagnostic
// value being backfilled onto ~1500 already-migrated lines isn't an
// editorial change worth 1500 new revisions cluttering the very audit
// trail this exists to protect.
if (!\Drupal::state()->get('figli_compta_ledger.skip_revision', FALSE)) {
$node->setNewRevision(TRUE);
$node->setRevisionLogMessage('');
$node->setRevisionUserId(\Drupal::currentUser()->id());
// setNewRevision() alone doesn't refresh this -- it stays whatever it
// was on the entity as loaded (the previous revision's timestamp),
// silently mislabeling every edit with its predecessor's save time.
$node->setRevisionCreationTime(\Drupal::time()->getRequestTime());
}
if (\Drupal::state()->get('figli_compta_ledger.skip_validation', FALSE)) {
if (!$node->hasField('field_montant_ht')) {
return;
}
$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) {
$tva = $node->hasField('field_tva') && !$node->get('field_tva')->isEmpty() ? (float) $node->get('field_tva')->value : 0.0;
$type = $node->hasField('field_type_ligne') ? $node->get('field_type_ligne')->value : NULL;
$cotisation_active = $node->hasField('field_cotisation_active') && !$node->get('field_cotisation_active')->isEmpty()
? (bool) $node->get('field_cotisation_active')->value : FALSE;
// Only "Entrée client" lines ever go through a client devis -- the
// only place the cotisation diffuseur is invoiced at all (see
// figli_compta_ledger_update_8006()'s docblock) -- so the checkbox is
// ignored for every other type even if it somehow ended up checked
// (e.g. a line's type changed after the fact).
$cotisation_applies = $type === 'entree' && $cotisation_active;
// Montant TTC is no longer entered by hand -- it's a pure function of
// Montant HT, TVA, and (entrée client only) the cotisation diffuseur
// URSSAF cascade (see figli_compta_ledger_update_8004()/_8006()). Only
// recompute it when HT, TVA, type, or the cotisation toggle actually
// changed, though, rather than on every save unconditionally: the TVA
// backfilled onto pre-migration lines (figli_compta_ledger_update_8005()/
// _8007()) is a best-effort reconstruction and doesn't reproduce every
// historical Montant TTC to the exact centime, so blindly recomputing
// on an unrelated edit (fixing a typo in Libellé, say) would silently
// nudge an untouched historical value -- exactly what this module's
// "never correct historical data" rule exists to prevent. A genuinely
// new/changed HT, TVA, type, or cotisation toggle has no such history
// to protect.
if ($node->hasField('field_montant_ttc')) {
$inputs_changed = $node->isNew();
if (!$inputs_changed && isset($node->original)) {
$original = $node->original;
$orig_ht = $original->hasField('field_montant_ht') && !$original->get('field_montant_ht')->isEmpty()
? (float) $original->get('field_montant_ht')->value : NULL;
$orig_tva = $original->hasField('field_tva') && !$original->get('field_tva')->isEmpty()
? (float) $original->get('field_tva')->value : 0.0;
$orig_type = $original->hasField('field_type_ligne') ? $original->get('field_type_ligne')->value : NULL;
$orig_cotisation_active = $original->hasField('field_cotisation_active') && !$original->get('field_cotisation_active')->isEmpty()
? (bool) $original->get('field_cotisation_active')->value : FALSE;
$inputs_changed = $orig_ht !== $montant_ht || abs($orig_tva - $tva) > 0.00005
|| $orig_type !== $type || $orig_cotisation_active !== $cotisation_active;
}
if ($inputs_changed) {
if ($cotisation_applies) {
// field_cotisation_urssaf stores just the 1,1% itself (the
// delta), not Montant HT + 1,1% -- that's what "1,1%" as a
// column/field label means. TVA still applies to the augmented
// base though, so $base (used for Montant TTC just below) stays
// HT + delta; only what gets *written* to the field changes.
$delta = round($montant_ht * 0.011, 2);
$base = $montant_ht + $delta;
if ($node->hasField('field_cotisation_urssaf')) {
$node->set('field_cotisation_urssaf', $delta);
}
}
else {
$base = $montant_ht;
if ($node->hasField('field_cotisation_urssaf')) {
$node->set('field_cotisation_urssaf', NULL);
}
}
$node->set('field_montant_ttc', round($base * (1 + $tva / 100), 2));
}
}
}
if (!$node->hasField('field_repartition')) {
return;
}
$somme = 0.0;
foreach ($node->get('field_repartition')->referencedEntities() as $paragraph) {
if ($paragraph->hasField('field_montant') && !$paragraph->get('field_montant')->isEmpty()) {
@@ -200,6 +482,21 @@ function figli_compta_ledger_node_presave(NodeInterface $node) {
}
$ecart = round($montant_ht - $somme, 2);
// field_ecart mirrors this sum -- kept in sync unconditionally,
// regardless of skip_validation (see figli_compta_ledger_update_8009()'s
// docblock): unlike Montant TTC, there's no historical value to protect
// here, the field's whole purpose is to reflect the *current*
// répartition state, not a preserved snapshot -- and it's what /lignes'
// "Écarts uniquement" filter will eventually filter on server-side.
if ($node->hasField('field_ecart')) {
$node->set('field_ecart', $ecart);
}
if ($skip_validation) {
return;
}
if (abs($ecart) > 0.01) {
throw new EntityStorageException(sprintf(
"Répartition incohérente : la somme des comptes (%.2f €) ne correspond pas au montant HT (%.2f €). Écart : %.2f €. Corrigez la répartition avant d'enregistrer.",
@@ -216,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',
],
];
}
@@ -245,14 +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,44 +50,44 @@ 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:
type: entity:node
figli_compta_ledger.api_lignes:
path: '/lignes/api/lignes'
defaults:
_controller: '\Drupal\figli_compta_ledger\Controller\LedgerRowsController::index'
requirements:
_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'
@@ -104,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;
+278 -92
View File
@@ -2,26 +2,46 @@
* @file
* Progressive decoupling: Drupal renders the page shell (nav, auth via
* session cookie, the "Ajouter une ligne" modal form); this Vue app fetches
* JSON:API and renders a spreadsheet-like table of every ligne comptable,
* with filters, month/year grouping, and per-row écart (répartition sum vs
* montant HT) highlighting -- inconsistencies are shown, not hidden.
* a spreadsheet-like table of every ligne comptable, with filters,
* month/year grouping, and per-row écart (répartition sum vs montant HT)
* highlighting -- inconsistencies are shown, not hidden.
*
* Sliding window: with 5+ years of migrated history (~1500+ lines), loading
* everything up front took ~40s and made the tab briefly unresponsive.
* Instead of one big fetch, `rows` only ever holds a date-range window
* (initially the ~18 months around today), extended by ~6 months whenever
* the user scrolls near the top or bottom edge (IntersectionObserver on two
* sentinel rows), and trimmed from the far end once the window exceeds
* MAX_LOADED_MONTHS so it stays a genuine sliding buffer, not an
* ever-growing list. The "Année" filter and the totals footer can't be
* computed from a partial window, so they're backed by their own small
* server endpoints (LedgerStatsController) instead.
* the user scrolls near the top or bottom edge, and trimmed from the far
* end once the window exceeds MAX_LOADED_MONTHS so it stays a genuine
* sliding buffer, not an ever-growing list. The "Année" filter and the
* totals footer can't be computed from a partial window, so they're backed
* by their own small server endpoints (LedgerStatsController) instead.
*
* Filtering: every toolbar filter (compte, client, type, signalement,
* écarts, recherche libre) is applied server-side, folded straight into
* whatever request populates the window -- see LedgerRowsController and
* fetchFilteredLignes() below. `rows` therefore only ever holds rows that
* both fall in the loaded date range AND match the active filters; there's
* no separate "load everything, filter client-side" pass. JSON:API is
* still used for a few things the filtered endpoint doesn't cover: the
* entrée/sortie group drill-down (fetchLignesByNids()) and live-update
* polling (fetchChangedSince()), both keyed on specific node ids or
* "anything that changed" rather than a date range + filter set.
*/
(function (Drupal, Vue, jQuery) {
'use strict';
const API_BASE = '/jsonapi/node/ligne_comptable';
const EUR = new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' });
// Not style: 'percent' -- that expects a fraction (0.10) and field_tva
// stores a plain rate (10), which style: 'percent' would otherwise
// silently read as 1000%.
const PCT = new Intl.NumberFormat('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
// Mirrors the $taux_officiels select in figli_compta_ledger_form_alter()
// and the $official list in figli_compta_ledger_update_8007()/_8008() --
// same 0.05-point tolerance as those migration scripts, for the same
// "rounding through 2-decimal HT/TTC storage" reason.
const OFFICIAL_TVA_RATES = [0, 2.1, 5.5, 10, 20];
const OFFICIAL_TVA_EPSILON = 0.05;
const MONTHS = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin', 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre'];
const TYPE_LABELS = {
entree: 'Entrée client',
@@ -50,8 +70,7 @@
// per year can search almost forever without ever surfacing more of
// them. Comfortably wider than the whole migrated dataset (2021-today)
// so a filter effectively gets the entire history to search, still
// bounded (not literally unbounded memory) -- and DOM rendering cost
// stays proportional to filteredRows, not this raw fetched count.
// bounded (not literally unbounded memory).
const MAX_LOADED_MONTHS_FILTERED = 96;
// Hard cap on ensureScrollable()'s recursion (see below) -- comfortably
// more than enough rounds to walk the entire MIN_LOADABLE_DATE..
@@ -92,20 +111,39 @@
return (db.getFullYear() - da.getFullYear()) * 12 + (db.getMonth() - da.getMonth());
}
// Builds the JSON:API URL for a half-open date range [start, end).
function rangeUrl(start, end) {
const filter =
'filter[dateRange][group][conjunction]=AND' +
'&filter[gte][condition][path]=field_date_ligne' +
'&filter[gte][condition][operator]=%3E%3D' +
'&filter[gte][condition][value]=' + start +
'&filter[gte][condition][memberOf]=dateRange' +
'&filter[lt][condition][path]=field_date_ligne' +
'&filter[lt][condition][operator]=%3C' +
'&filter[lt][condition][value]=' + end +
'&filter[lt][condition][memberOf]=dateRange';
return API_BASE + '?include=field_repartition,field_repartition.field_compte,field_client,field_entree_liee,field_flag'
+ '&page[limit]=50&sort=field_date_ligne,drupal_internal__nid&' + filter;
// Builds the query string for LedgerRowsController::index() (GET
// /lignes/api/lignes) -- the server-side-filtered replacement for
// "fetch a JSON:API date range, then filter client-side": every
// toolbar filter is folded straight into the same request that loads
// the window, so `rows` only ever holds what's actually meant to be
// visible right now, pre-shaped exactly like buildRows() below would
// have produced from JSON:API (no separate row-building step needed
// for this path). rangeOrAnnee is either {start, end} (the normal
// sliding window, and jumpToYear()) or {annee} (filterYear's
// enterYearMode) -- the controller accepts either.
function filteredLignesUrl(rangeOrAnnee, filters) {
const params = new URLSearchParams();
if (rangeOrAnnee.annee) {
params.set('annee', rangeOrAnnee.annee);
} else {
params.set('start', rangeOrAnnee.start);
params.set('end', rangeOrAnnee.end);
}
if (filters.compte.length) params.set('compte', filters.compte.join(','));
if (filters.client) params.set('client', filters.client);
if (filters.type.length) params.set('type', filters.type.join(','));
if (filters.flag.length) params.set('flag', filters.flag.join(','));
if (filters.q) params.set('q', filters.q);
if (filters.ecarts) params.set('ecarts', '1');
if (filters.signale) params.set('signale', '1');
return '/lignes/api/lignes?' + params.toString();
}
async function fetchFilteredLignes(rangeOrAnnee, filters) {
const res = await fetch(filteredLignesUrl(rangeOrAnnee, filters), { headers: { Accept: 'application/json' } });
const json = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(json.error || ('/lignes/api/lignes a répondu ' + res.status));
return json.rows;
}
async function fetchLignes(url) {
@@ -267,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)
@@ -291,6 +363,7 @@
filterType: params.get('type') ? params.get('type').split(',') : [],
filterFlag: params.get('tag') ? params.get('tag').split(',') : [],
filterYear: params.get('annee') || '',
filterQ: params.get('q') || '',
onlyErrors: params.get('ecarts') === '1',
onlyFlagged: params.get('signale') === '1',
aller: params.get('aller') || '',
@@ -304,6 +377,7 @@
if (state.filterType.length) params.set('type', state.filterType.join(','));
if (state.filterFlag.length) params.set('tag', state.filterFlag.join(','));
if (state.filterYear) params.set('annee', state.filterYear);
if (state.filterQ) params.set('q', state.filterQ);
if (state.onlyErrors) params.set('ecarts', '1');
if (state.onlyFlagged) params.set('signale', '1');
// Redundant/ambiguous alongside an active "Année" filter -- that
@@ -365,11 +439,18 @@
facture: attrs.field_numero_facture || null,
libelle: attrs.field_notes || attrs.title,
montant_ht: montantHt,
cotisation: attrs.field_cotisation_urssaf !== null && attrs.field_cotisation_urssaf !== undefined ? parseFloat(attrs.field_cotisation_urssaf) : null,
tva: attrs.field_tva !== null && attrs.field_tva !== undefined ? parseFloat(attrs.field_tva) : null,
montant_ttc: attrs.field_montant_ttc !== null && attrs.field_montant_ttc !== undefined ? parseFloat(attrs.field_montant_ttc) : null,
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),
@@ -405,6 +486,11 @@
// means "has any tag at all".
filterFlag: [],
filterYear: '',
// Free-text match against Libellé/Détail (field_notes, falling
// back to the title) -- server-side (see LedgerRowsController),
// debounced client-side (see the filterQ watcher) so every
// keystroke doesn't fire its own request.
filterQ: '',
jumpYearValue: '',
// Not a filter itself (jumpYearValue always resets to '' right
// after firing) -- just remembers the last "Aller à" target so
@@ -592,21 +678,6 @@
if (!this.filterEntreeGroup) return [];
return this.allComptes.filter((c) => this.filterEntreeGroup.some((r) => r.parCompte[c] !== undefined));
},
filteredRows() {
return this.rows.filter((r) => {
// Several selected comptes/types match with OR semantics ("any
// of these") -- a line either touches one of the selected
// comptes or it doesn't, same idea for type.
if (this.filterCompte.length && !this.filterCompte.some((c) => r.parCompte[c] !== undefined)) return false;
if (this.filterClient && r.client !== this.filterClient) return false;
if (this.filterType.length && !this.filterType.includes(r.type)) return false;
if (this.filterFlag.length && !this.filterFlag.some((f) => r.flags.includes(f))) return false;
if (this.filterYear && (r.date || '').slice(0, 4) !== this.filterYear) return false;
if (this.onlyErrors && !r.hasError) return false;
if (this.onlyFlagged && !r.hasFlag) return false;
return true;
});
},
// Totals for the entrée + linked sorties drill-down modal (see
// filterEntreeGroup above): the whole point of that view is "does
// this entrée balance against what was paid out", so its own footer
@@ -637,8 +708,12 @@
par_compte: Object.fromEntries(Object.entries(parCompte).map(([c, v]) => [c, round(v)])),
};
},
// `rows` no longer needs a separate client-side filtering pass --
// every toolbar filter is already applied server-side by whatever
// fetched the current window (see fetchFilteredLignes()), so this
// only handles the month/year grouping on top.
groupedRows() {
const list = this.filteredRows;
const list = this.rows;
if (this.groupBy === 'none') return list;
const groups = new Map();
for (const r of list) {
@@ -668,6 +743,23 @@
formatEur(v) {
return v === null || v === undefined ? '' : EUR.format(v);
},
// Historical lines can carry a backfilled, non-round rate (e.g.
// 12.6414 -- see figli_compta_ledger_update_8005()'s docblock for
// why); rounded to 2 decimals for display, same as every amount
// column here, rather than showing the full stored precision.
formatPct(v) {
return v === null || v === undefined ? '' : PCT.format(v) + ' %';
},
// Flags a TVA rate that isn't one of the 5 official French VAT
// rates -- either a genuinely blended/multi-rate invoice collapsed
// into one ligne_comptable, or a migrated line
// figli_compta_ledger_update_8007() left ambiguous rather than
// guess (see that function's docblock). Null (no TVA recorded at
// all) is never flagged -- nothing to question there.
isTvaOfficial(v) {
if (v === null || v === undefined) return true;
return OFFICIAL_TVA_RATES.some((o) => Math.abs(v - o) < OFFICIAL_TVA_EPSILON);
},
// Écart between this ouverture row's year and the previous year's
// calculated closing balance -- restricted to whichever compte(s)
// this specific row's répartition actually touches (ouverture lines
@@ -943,21 +1035,45 @@
// that fall inside the current window get appended -- that's the
// one case that *does* need the array reassigned, since there's
// no existing row object to mutate.
//
// `rows` is server-filtered (see fetchFilteredLignes()), unlike
// before Phase 2 where it held the whole window and a computed
// property filtered it for display -- pollForChanges() is
// deliberately still unfiltered/global (see its own comment), so a
// change reported here might have moved a row *out* of the active
// filters (patch would leave a stale non-matching row visible) or
// *into* them (a row not previously loaded now qualifies). A row
// living in groupExtraRows instead is unaffected by the toolbar
// filters at all (that's the drill-down modal's own concern), so
// it's always just patched in place.
mergeChangedRows(changedRows) {
const isBeingEdited = (id) => this.editingTypeId === id || (this.editingCell && this.editingCell.id === id);
const known = new Map([...this.rows, ...this.groupExtraRows].map((r) => [r.id, r]));
const rowsById = new Map(this.rows.map((r) => [r.id, r]));
const extraById = new Map(this.groupExtraRows.map((r) => [r.id, r]));
const newOnes = [];
const removeIds = new Set();
for (const fresh of changedRows) {
if (isBeingEdited(fresh.id)) continue;
const existing = known.get(fresh.id);
const inExtra = extraById.get(fresh.id);
if (inExtra) {
Object.assign(inExtra, fresh);
continue;
}
const inWindow = fresh.date >= this.windowStart && fresh.date < this.windowEnd;
const matches = inWindow && this.rowMatchesFilters(fresh);
const existing = rowsById.get(fresh.id);
if (existing) {
Object.assign(existing, fresh);
} else if (fresh.date >= this.windowStart && fresh.date < this.windowEnd) {
if (matches) {
Object.assign(existing, fresh);
} else {
removeIds.add(fresh.id);
}
} else if (matches) {
newOnes.push(fresh);
}
}
if (newOnes.length) {
this.rows = [...this.rows, ...newOnes].sort((a, b) => (a.date || '').localeCompare(b.date || ''));
if (newOnes.length || removeIds.size) {
this.rows = [...this.rows.filter((r) => !removeIds.has(r.id)), ...newOnes].sort((a, b) => (a.date || '').localeCompare(b.date || ''));
}
},
startEditType(item) {
@@ -1161,8 +1277,7 @@
const today = todayStr();
this.windowStart = addMonths(today, -WINDOW_MONTHS);
this.windowEnd = addMonths(today, WINDOW_MONTHS);
const { data, includedMap } = await fetchLignes(rangeUrl(this.windowStart, this.windowEnd));
this.rows = buildRows(data, includedMap);
this.rows = await fetchFilteredLignes({ start: this.windowStart, end: this.windowEnd }, this.currentFilters());
} catch (err) {
this.error = err.message;
} finally {
@@ -1171,12 +1286,14 @@
},
// Post-edit refresh: re-fetch exactly the currently loaded window
// (not a fresh "last 18 months" window) so editing an old line
// doesn't silently reset how far the user had scrolled back.
// doesn't silently reset how far the user had scrolled back. Also
// the reload path every toolbar filter change routes through (see
// onFilterChanged()) -- with filtering now server-side, changing a
// filter has to re-fetch, not just re-scan what's already loaded.
async reloadWindow() {
if (!this.windowStart || !this.windowEnd) return;
try {
const { data, includedMap } = await fetchLignes(rangeUrl(this.windowStart, this.windowEnd));
this.rows = buildRows(data, includedMap);
this.rows = await fetchFilteredLignes({ start: this.windowStart, end: this.windowEnd }, this.currentFilters());
} catch (err) {
this.error = err.message;
}
@@ -1228,8 +1345,7 @@
try {
let newStart = addMonths(this.windowStart, -EXTEND_MONTHS);
if (newStart < MIN_LOADABLE_DATE) newStart = MIN_LOADABLE_DATE;
const { data, includedMap } = await fetchLignes(rangeUrl(newStart, this.windowStart));
const newRows = buildRows(data, includedMap);
const newRows = await fetchFilteredLignes({ start: newStart, end: this.windowStart }, this.currentFilters());
this.windowStart = newStart;
if (newRows.length) {
@@ -1269,8 +1385,7 @@
try {
let newEnd = addMonths(this.windowEnd, EXTEND_MONTHS);
if (newEnd > MAX_LOADABLE_DATE) newEnd = MAX_LOADABLE_DATE;
const { data, includedMap } = await fetchLignes(rangeUrl(this.windowEnd, newEnd));
const newRows = buildRows(data, includedMap);
const newRows = await fetchFilteredLignes({ start: this.windowEnd, end: newEnd }, this.currentFilters());
this.windowEnd = newEnd;
if (newRows.length) {
@@ -1330,8 +1445,9 @@
if ((notScrollable || wrap.scrollTop < threshold) && !this.loadingOlder) this.loadOlder();
if ((notScrollable || wrap.scrollTop + wrap.clientHeight > wrap.scrollHeight - threshold) && !this.loadingNewer) this.loadNewer();
},
// Whether compte/client/type/écarts thin filteredRows down from
// whatever's actually loaded -- loadOlder()/loadNewer() use this to
// Whether an active filter (server-side now, see
// fetchFilteredLignes()) thins `rows` down to fewer matches than an
// unfiltered window would have -- loadOlder()/loadNewer() use this to
// widen the trim cap (MAX_LOADED_MONTHS_FILTERED instead of
// MAX_LOADED_MONTHS), since the normal cap actively works against a
// sparse filter: extending one end and immediately trimming the
@@ -1339,7 +1455,73 @@
// filter with only a few matches a year could search almost forever
// without surfacing more of them.
isFiltering() {
return !!(this.filterCompte.length || this.filterClient || this.filterType.length || this.filterFlag.length || this.onlyErrors || this.onlyFlagged);
return !!(this.filterCompte.length || this.filterClient || this.filterType.length || this.filterFlag.length || this.filterQ || this.onlyErrors || this.onlyFlagged);
},
// The non-date part of every toolbar filter, in the shape
// fetchFilteredLignes()/filteredLignesUrl() expect -- built fresh
// from current state each call rather than kept as its own piece
// of reactive state, so there's only one source of truth for what
// "the active filters" are.
currentFilters() {
return {
compte: this.filterCompte,
client: this.filterClient,
type: this.filterType,
flag: this.filterFlag,
q: this.filterQ,
ecarts: this.onlyErrors,
signale: this.onlyFlagged,
};
},
// Client-side mirror of the non-date conditions LedgerRowsController
// applies server-side -- used only by mergeChangedRows() below, to
// decide whether a row a poll just reported on still belongs in
// `rows` (which, unlike before Phase 2, only ever holds matches for
// the active filters, not the whole window). filterYear/the date
// window itself is checked separately by the caller, since
// windowStart/windowEnd already cover both the sliding-window and
// year-mode cases identically.
rowMatchesFilters(row) {
if (this.filterCompte.length && !this.filterCompte.some((c) => row.parCompte[c] !== undefined)) return false;
if (this.filterClient && row.client !== this.filterClient) return false;
if (this.filterType.length && !this.filterType.includes(row.type)) return false;
if (this.filterFlag.length && !this.filterFlag.some((f) => row.flags.includes(f))) return false;
if (this.filterQ && !(row.libelle || '').toLowerCase().includes(this.filterQ.toLowerCase())) return false;
if (this.onlyErrors && !row.hasError) return false;
if (this.onlyFlagged && !row.hasFlag) return false;
return true;
},
// 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. 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.loadCurrentYearTotals();
this.syncHash();
},
// Keeps extending the window (both directions) as long as a filter
// leaves too few matching rows to fill the viewport -- otherwise
@@ -1457,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 {
@@ -1475,8 +1657,7 @@
try {
const start = year + '-01-01';
const end = (parseInt(year, 10) + 1) + '-01-01';
const { data, includedMap } = await fetchLignes(rangeUrl(start, end));
this.rows = buildRows(data, includedMap);
this.rows = await fetchFilteredLignes({ annee: year }, this.currentFilters());
this.windowStart = start;
this.windowEnd = end;
} catch (err) {
@@ -1528,8 +1709,7 @@
const yearStart = year + '-01-01';
this.windowStart = yearStart;
this.windowEnd = addMonths(yearStart, WINDOW_MONTHS * 2);
const { data, includedMap } = await fetchLignes(rangeUrl(this.windowStart, this.windowEnd));
this.rows = buildRows(data, includedMap);
this.rows = await fetchFilteredLignes({ start: this.windowStart, end: this.windowEnd }, this.currentFilters());
} catch (err) {
this.error = err.message;
} finally {
@@ -1553,6 +1733,7 @@
filterType: this.filterType,
filterFlag: this.filterFlag,
filterYear: this.filterYear,
filterQ: this.filterQ,
onlyErrors: this.onlyErrors,
onlyFlagged: this.onlyFlagged,
lastJumpYear: this.lastJumpYear,
@@ -1583,49 +1764,54 @@
}
this.syncHash();
},
// Any of these can thin filteredRows enough to remove the
// scrollbar the sliding window relies on to keep loading -- see
// ensureScrollable(). filterYear is handled separately above (it
// swaps the whole loading strategy, not just the visible subset).
// Each also reflects into the URL hash so the filtered view is
// reloadable/shareable -- see syncHash().
// Any of these is now pushed server-side (see LedgerRowsController/
// fetchFilteredLignes()) -- onFilterChanged() re-fetches the
// currently loaded window with the new filter applied, then widens
// it if that leaves too few rows to fill the viewport (see
// ensureScrollable()). filterYear is handled separately above (it
// swaps the whole loading strategy, not just which filter params
// get sent). Each also reflects into the URL hash -- see syncHash().
filterCompte() {
this.ensureScrollable();
this.syncHash();
this.onFilterChanged();
},
filterClient() {
this.ensureScrollable();
this.syncHash();
this.onFilterChanged();
},
filterType() {
this.ensureScrollable();
this.syncHash();
this.onFilterChanged();
},
filterFlag() {
this.ensureScrollable();
this.syncHash();
this.onFilterChanged();
},
// Debounced (unlike the other filters above) -- this one changes on
// every keystroke, and each change is a network round-trip now,
// not a free client-side recompute.
filterQ() {
clearTimeout(this._filterQDebounce);
this._filterQDebounce = setTimeout(() => this.onFilterChanged(), 350);
},
onlyErrors() {
this.ensureScrollable();
this.syncHash();
this.onFilterChanged();
},
onlyFlagged() {
this.ensureScrollable();
this.syncHash();
this.onFilterChanged();
},
},
async mounted() {
// Reproduce whatever the URL hash describes -- reload or a shared
// link should land on the same filtered view. The four basic
// filters just narrow filteredRows, so they're safe to set before
// deciding how to load; filterYear/aller each own their loading
// path (enterYearMode()/jumpToYear()), so at most one of those
// runs instead of the default load().
// link should land on the same filtered view. The basic filters are
// all sent as query params on the very first load() below, so
// setting them here first (before deciding how to load) means that
// first fetch already reflects the restored view instead of
// loading unfiltered and re-fetching a moment later; filterYear/
// aller each own their loading path (enterYearMode()/jumpToYear()),
// so at most one of those runs instead of the default load().
const hashState = readHashState();
this.filterCompte = hashState.filterCompte;
this.filterClient = hashState.filterClient;
this.filterType = hashState.filterType;
this.filterFlag = hashState.filterFlag;
this.filterQ = hashState.filterQ;
this.onlyErrors = hashState.onlyErrors;
this.onlyFlagged = hashState.onlyFlagged;
@@ -0,0 +1,378 @@
/**
* @file
* Live preview of Montant TTC = Montant HT * (1 + TVA/100) on the ligne
* comptable add/edit form -- purely cosmetic, so the associate sees the
* computed amount before saving instead of a blank/stale readonly field.
* The authoritative computation is server-side, in
* figli_compta_ledger_node_presave() (figli_compta_ledger.module) --
* whatever ends up in this input on submit is discarded and recomputed
* there regardless of whether this script even ran.
*/
(function (Drupal, once) {
'use strict';
Drupal.behaviors.figliLedgerTvaCalc = {
attach: function (context) {
// once()'s selector match runs through context.querySelectorAll(),
// which -- like any querySelectorAll -- never matches the context
// node itself, only its descendants. The AJAX dialog opening this
// form passes the <form class="figli-ledger-form"> element itself
// as context, so a selector of '.figli-ledger-form' silently
// matched nothing and this behavior never ran. Selecting the HT
// input directly sidesteps that: it's always a genuine descendant
// of whatever context gets passed (document on a full page load,
// the form itself from the dialog, or anything in between).
once('figli-ledger-tva-calc', '[name="field_montant_ht[0][value]"]', context).forEach(function (ht) {
var form = ht.closest('form');
var tva = form.querySelector('[name="field_tva[0][value]"]');
var rate = form.querySelector('[name="field_tva_rate"]');
var ttc = form.querySelector('[name="field_montant_ttc[0][value]"]');
var type = form.querySelector('[name="field_type_ligne"]');
var cotisationActive = form.querySelector('[name="field_cotisation_active[value]"]');
var cotisationAmount = form.querySelector('[name="field_cotisation_urssaf[0][value]"]');
if (!tva || !ttc) {
return;
}
// The rate select (when not "Autre") is the actual source of
// truth here -- figli_compta_ledger_apply_tva_rate() copies its
// value into field_tva server-side on submit, but that happens
// too late to matter for a live client-side preview.
function currentTva() {
if (rate && rate.value !== 'autre') {
return parseFloat(rate.value);
}
return parseFloat(tva.value);
}
// Mirrors figli_compta_ledger_node_presave()'s $cotisation_applies:
// only "Entrée client" lines with the checkbox on ever go through
// the +1,1% step before TVA.
function cotisationApplies() {
return !!type && type.value === 'entree' && !!cotisationActive && cotisationActive.checked;
}
function recompute() {
var htValue = parseFloat(ht.value);
if (isNaN(htValue)) {
return;
}
var tvaValue = currentTva();
if (isNaN(tvaValue)) {
tvaValue = 0;
}
var base = htValue;
if (cotisationApplies()) {
// Displayed/stored value is just the 1,1% itself (the
// delta), not HT + 1,1% -- but TVA still applies to the
// augmented base, so $base for the TTC calc below stays
// htValue + delta regardless.
var delta = Math.round(htValue * 0.011 * 100) / 100;
base = htValue + delta;
if (cotisationAmount) {
cotisationAmount.value = delta.toFixed(2);
}
}
else if (cotisationAmount) {
cotisationAmount.value = '';
}
ttc.value = (base * (1 + tvaValue / 100)).toFixed(2);
}
// Deliberately not called once up front on an existing (edit)
// line: the stored Montant TTC may predate this computed-field
// architecture and not be perfectly reproducible from HT/TVA to
// the centime (see figli_compta_ledger_update_8005()'s
// docblock) -- showing a recomputed number the instant the
// modal opens, before the associate has touched anything, would
// misrepresent what's actually saved. Only recompute once an
// actual input happens, matching the same "only overwrite
// Montant TTC when an input actually changed" guard in
// figli_compta_ledger_node_presave(). Harmless no-op on a
// genuinely new/blank line either way (HT is empty, recompute()
// bails out above).
ht.addEventListener('input', recompute);
tva.addEventListener('input', recompute);
if (rate) {
rate.addEventListener('change', recompute);
}
if (type) {
type.addEventListener('change', recompute);
}
if (cotisationActive) {
cotisationActive.addEventListener('change', recompute);
}
});
}
};
/**
* Shows/hides the real "Taux exact (%)" field_tva input based on the
* field_tva_rate select added in figli_compta_ledger_form_alter() --
* plain JS rather than #states: tested and confirmed #states' own
* data-drupal-states attribute gets attached correctly here but never
* actually toggles visibility, for reasons not tracked down (a
* working #states dependency already exists a few fields up, on
* field_entree_liee, watching a real Field API element rather than
* this manually-added select -- that one difference is the only lead
* so far). Not worth blocking on since this form already needs custom
* JS anyway for the TTC live preview above.
*/
Drupal.behaviors.figliLedgerTvaRateToggle = {
attach: function (context) {
once('figli-ledger-tva-rate-toggle', '[name="field_tva_rate"]', context).forEach(function (select) {
var form = select.closest('form');
var exact = form.querySelector('.field--name-field-tva');
if (!exact) {
return;
}
function toggle() {
exact.style.display = select.value === 'autre' ? '' : 'none';
}
select.addEventListener('change', toggle);
toggle();
});
}
};
/**
* 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(
@@ -57,6 +57,39 @@ class HistoryController extends ControllerBase {
}
return [
// Same markup/classes as the <nav class="figli-page-nav"> block in
// figli-compta-home.html.twig et al. (styled in admin-chrome.css,
// 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 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 5th "Historique" entry either.
'nav' => [
'#type' => 'container',
'#attributes' => ['class' => ['figli-page-nav']],
'home' => [
'#type' => 'link',
'#title' => $this->t('Grand livre'),
'#url' => Url::fromRoute('figli_compta_ledger.home'),
],
'dashboard' => [
'#type' => 'link',
'#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'),
'#url' => Url::fromRoute('figli_compta_ledger.dashboard_compte'),
],
],
'table' => [
'#type' => 'table',
'#header' => [
@@ -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;
@@ -0,0 +1,190 @@
<?php
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;
/**
* GET /lignes/api/lignes -- server-side filtered replacement for the old
* "load a date-range window via JSON:API, then filter client-side"
* approach in home.js. Every toolbar filter (compte, client, type,
* signalement, écarts, recherche libre) is pushed into a single Drupal
* Entity/Field Query API query -- not raw SQL, so entity access checks
* apply natively -- rather than fetching everything in range and
* discarding what doesn't match on the client. Confirmed empirically
* (drush php:eval against dev) that a condition can traverse
* field_repartition (entity_reference_revisions to paragraphs) into the
* paragraph's own field_compte (entity_reference to taxonomy_term) as a
* dotted relationship path -- that was the one open technical question
* before writing this. Date range (or "annee" in its place) stays
* required -- the sliding window itself isn't going away, only what
* populates it.
*/
class LedgerRowsController extends ControllerBase {
/**
* Mirrors LedgerActionsController::LINKABLE_TYPES.
*/
const LINKABLE_TYPES = ['versement', 'achat', 'hebergement', 'sous_traitant'];
public function index(Request $request) {
$annee = $request->query->get('annee');
if ($annee) {
if (!preg_match('/^\d{4}$/', $annee)) {
return new JsonResponse(['error' => 'Paramètre "annee" invalide.'], 400);
}
$start = $annee . '-01-01';
$end = ((int) $annee + 1) . '-01-01';
}
else {
$start = $request->query->get('start');
$end = $request->query->get('end');
if (!$start || !$end || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $start) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $end)) {
return new JsonResponse(['error' => 'Paramètres "start"/"end" invalides.'], 400);
}
}
$storage = $this->entityTypeManager()->getStorage('node');
$query = $storage->getQuery()
->accessCheck(TRUE)
->condition('type', 'ligne_comptable')
->condition('field_date_ligne', $start, '>=')
->condition('field_date_ligne', $end, '<')
->sort('field_date_ligne')
->sort('nid');
$compte = array_filter(explode(',', (string) $request->query->get('compte', '')));
if ($compte) {
$tids = $this->termIdsByNames('compte', $compte);
// No matching term at all (typo, renamed compte) still runs the
// query with an impossible condition rather than short-circuiting
// to an empty response -- fails the same visible "0 rows" way as
// an ordinary empty date range, instead of a silent special case.
$query->condition('field_repartition.entity.field_compte.target_id', $tids ?: [0], 'IN');
}
$client = trim((string) $request->query->get('client', ''));
if ($client !== '') {
$tids = $this->termIdsByNames('client', [$client]);
$query->condition('field_client', $tids ?: [0], 'IN');
}
$type = array_filter(explode(',', (string) $request->query->get('type', '')));
if ($type) {
$query->condition('field_type_ligne', array_values($type), 'IN');
}
$flag = array_filter(explode(',', (string) $request->query->get('flag', '')));
if ($flag) {
$tids = $this->termIdsByNames('flag', $flag);
$query->condition('field_flag.target_id', $tids ?: [0], 'IN');
}
$q = trim((string) $request->query->get('q', ''));
if ($q !== '') {
// Mirrors buildRows()'s `libelle: attrs.field_notes || attrs.title`
// fallback in home.js -- a line with no notes shows its title, so
// the search has to match either, not just field_notes.
$group = $query->orConditionGroup()
->condition('field_notes', $q, 'CONTAINS')
->condition('title', $q, 'CONTAINS');
$query->condition($group);
}
if ($request->query->get('ecarts') === '1') {
$query->condition('field_ecart', 0, '<>');
}
if ($request->query->get('signale') === '1') {
$query->exists('field_flag');
}
$nids = $query->execute();
$rows = [];
foreach ($storage->loadMultiple($nids) as $node) {
$rows[] = $this->serializeRow($node);
}
return new JsonResponse(['rows' => $rows]);
}
/**
* Resolves taxonomy term names to ids within a given vocabulary --
* shared by the compte/client/flag filters above. Silently drops names
* that don't match anything (the caller falls back to an impossible
* [0] condition rather than treating "no match" as "no filter").
*/
private function termIdsByNames(string $vid, array $names): array {
if (!$names) {
return [];
}
$tids = $this->entityTypeManager()->getStorage('taxonomy_term')->getQuery()
->accessCheck(FALSE)
->condition('vid', $vid)
->condition('name', array_values($names), 'IN')
->execute();
return array_values($tids);
}
/**
* Same row shape as buildRows() in home.js builds client-side from
* JSON:API, so the frontend can treat rows from either source
* identically. `id` is the node's UUID (what JSON:API exposes as
* node.id and every row-matching-by-id in home.js keys on), not the
* integer nid.
*/
private function serializeRow(NodeInterface $node): array {
$parCompte = [];
$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() : '(compte inconnu)';
$parCompte[$compte] = ($parCompte[$compte] ?? 0) + $montant;
$somme += $montant;
}
$ecart = $node->hasField('field_ecart') && !$node->get('field_ecart')->isEmpty()
? (float) $node->get('field_ecart')->value : 0.0;
$entreeLieeNodes = $node->hasField('field_entree_liee') ? $node->get('field_entree_liee')->referencedEntities() : [];
$flagTerms = $node->hasField('field_flag') ? $node->get('field_flag')->referencedEntities() : [];
$type = $node->get('field_type_ligne')->value;
return [
'id' => $node->uuid(),
'nid' => (int) $node->id(),
'changed' => date(DATE_ATOM, $node->getChangedTime()),
'date' => $node->get('field_date_ligne')->value,
'type' => $type,
'client' => $node->get('field_client')->entity ? $node->get('field_client')->entity->label() : NULL,
'facture' => $node->get('field_numero_facture')->value ?: NULL,
'libelle' => $node->get('field_notes')->value ?: $node->getTitle(),
'montant_ht' => $node->get('field_montant_ht')->isEmpty() ? NULL : (float) $node->get('field_montant_ht')->value,
'cotisation' => $node->hasField('field_cotisation_urssaf') && !$node->get('field_cotisation_urssaf')->isEmpty() ? (float) $node->get('field_cotisation_urssaf')->value : NULL,
'tva' => $node->hasField('field_tva') && !$node->get('field_tva')->isEmpty() ? (float) $node->get('field_tva')->value : NULL,
'montant_ttc' => $node->hasField('field_montant_ttc') && !$node->get('field_montant_ttc')->isEmpty() ? (float) $node->get('field_montant_ttc')->value : NULL,
'parCompte' => (object) $parCompte,
'somme' => $somme,
'ecart' => $ecart,
// "É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),
'flags' => array_map(fn ($t) => $t->label(), $flagTerms),
'hasFlag' => count($flagTerms) > 0,
];
}
}
@@ -5,78 +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;
$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;
$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),
'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>
@@ -1,24 +1,19 @@
{#
Dashboard shell: Drupal renders the page (nav, auth, permissions).
dashboard.js (Vue 3) fetches JSON:API and renders a spreadsheet-like table
of every ligne comptable, with filters and month/year grouping, client-side.
"Ajouter une ligne" opens the real Drupal node form in a modal
(core/drupal.dialog.ajax) -- no form logic duplicated in JS.
home.js (Vue 3) renders a spreadsheet-like table of every ligne comptable,
with filters and month/year grouping applied server-side (see
LedgerRowsController). "Ajouter une ligne" opens the real Drupal node form
in a modal (core/drupal.dialog.ajax) -- no form logic duplicated in JS.
#}
<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">
@@ -44,6 +39,13 @@
</span>
</label>
<label>Libellé / Détails
<span class="figli-filter-row">
<input type="text" v-model="filterQ" placeholder="Rechercher…" autocomplete="off" class="figli-client-input" />
<button v-if="filterQ" type="button" class="figli-filter-clear" @click="filterQ = ''" title="Effacer ce filtre">✕</button>
</span>
</label>
<label>Type
<span class="figli-filter-row">
<div class="figli-multiselect">
@@ -110,7 +112,7 @@
<option v-for="f in allFlagsList" :key="f" :value="f"></option>
</datalist>
<span class="figli-count" v-if="!loading">{{ filteredRows.length }} / {{ rows.length }} lignes chargées{{ errorCount ? ' — ' + errorCount + ' avec écart' : '' }}</span>
<span class="figli-count" v-if="!loading">{{ rows.length }} ligne{{ rows.length > 1 ? 's' : '' }} chargée{{ rows.length > 1 ? 's' : '' }}{{ errorCount ? ' — ' + errorCount + ' avec écart' : '' }}</span>
</div>
<p v-if="typeUpdateError" class="figli-error figli-inline-error">Erreur : {{ typeUpdateError }} <button type="button" class="figli-clear-drilldown" @click="typeUpdateError = null">✕</button></p>
@@ -128,21 +130,23 @@
<th>Facture</th>
<th>Libellé / Détail</th>
<th>Signalement</th>
<th class="amount">Montant HT</th>
<th class="amount">Montant TTC</th>
<th class="amount figli-ht-col">HT</th>
<th class="amount figli-cotisation-col">1,1%</th>
<th class="amount figli-tva-col">TVA</th>
<th class="amount figli-ttc-col">TTC</th>
<th v-for="c in allComptes" :key="c" class="amount compte-col">{{ c }}</th>
<th class="amount">Écart</th>
</tr>
</thead>
<tbody>
<tr ref="topSentinel" class="figli-sentinel-row">
<td :colspan="7 + allComptes.length + 3">
<td :colspan="7 + allComptes.length + 5">
<span v-if="loadingOlder">Chargement des mois précédents…</span>
</td>
</tr>
<template v-for="item in groupedRows" :key="item.key">
<tr v-if="item.isGroup" class="figli-group-row" :data-year="item.year">
<td :colspan="7 + allComptes.length + 3">{{ item.label }} <span class="figli-note">({{ item.count }} lignes)</span></td>
<td :colspan="7 + allComptes.length + 5">{{ item.label }} <span class="figli-note">({{ item.count }} lignes)</span></td>
</tr>
<tr v-else :class="{'figli-flag-row': item.hasFlag}" :data-year="item.date ? item.date.slice(0, 4) : null">
<td class="actions-col">
@@ -239,27 +243,39 @@
<template v-else>—</template>
</span>
</td>
<td class="amount" :class="montantClass(item.montant_ht)">{{ formatEur(item.montant_ht) }}</td>
<td class="amount">{{ formatEur(item.montant_ttc) }}</td>
<td class="amount figli-ht-col" :class="montantClass(item.montant_ht)">{{ formatEur(item.montant_ht) }}</td>
<td class="amount figli-cotisation-col">{{ formatEur(item.cotisation) }}</td>
<td class="amount figli-tva-col" :class="{'figli-tva-nonofficial': !isTvaOfficial(item.tva)}" :title="!isTvaOfficial(item.tva) ? 'Taux TVA non officiel (ni 0, 2,1, 5,5, 10 ni 20 %)' : null">{{ formatPct(item.tva) }}</td>
<td class="amount figli-ttc-col">{{ formatEur(item.montant_ttc) }}</td>
<td v-for="c in allComptes" :key="c" class="amount compte-col">{{ item.parCompte[c] !== undefined ? formatEur(item.parCompte[c]) : '' }}</td>
<td class="amount" :class="{'figli-ecart': item.hasError}">{{ item.hasError ? formatEur(item.ecart) : '' }}</td>
<td class="amount" :class="{'figli-ecart': item.hasError && item.ecart < 0, 'figli-ecart-positif': item.hasError && item.ecart > 0}">{{ item.hasError ? formatEur(item.ecart) : '' }}</td>
</tr>
</template>
<tr ref="bottomSentinel" class="figli-sentinel-row">
<td :colspan="7 + allComptes.length + 3">
<td :colspan="7 + allComptes.length + 5">
<span v-if="loadingNewer">Chargement des mois suivants…</span>
</td>
</tr>
</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>
<td class="amount">{{ currentYearTotals ? formatEur(currentYearTotals.montant_ht) : '' }}</td>
<td class="amount">{{ currentYearTotals ? formatEur(currentYearTotals.montant_ttc) : '' }}</td>
<td class="amount figli-ht-col">{{ currentYearTotals ? formatEur(currentYearTotals.montant_ht) : '' }}</td>
<td class="amount figli-cotisation-col">{{ currentYearTotals ? formatEur(currentYearTotals.cotisation) : '' }}</td>
<td class="amount figli-tva-col"></td>
<td class="amount figli-ttc-col">{{ currentYearTotals ? formatEur(currentYearTotals.montant_ttc) : '' }}</td>
<td v-for="c in allComptes" :key="c" class="amount compte-col" :class="currentYearTotals ? soldeClass(currentYearTotals.par_compte[c]) : ''">{{ currentYearTotals && currentYearTotals.par_compte[c] !== undefined ? formatEur(currentYearTotals.par_compte[c]) : '' }}</td>
<td class="amount" :class="currentYearTotals ? soldeClass(currentYearTotals.ecart) : ''">{{ currentYearTotals ? formatEur(currentYearTotals.ecart) : '' }}</td>
</tr>
@@ -318,13 +334,12 @@
<td class="amount" :class="montantClass(item.montant_ht)">{{ formatEur(item.montant_ht) }}</td>
<td class="amount">{{ formatEur(item.montant_ttc) }}</td>
<td v-for="c in modalComptes" :key="c" class="amount compte-col">{{ item.parCompte[c] !== undefined ? formatEur(item.parCompte[c]) : '' }}</td>
<td class="amount" :class="{'figli-ecart': item.hasError}">{{ item.hasError ? formatEur(item.ecart) : '' }}</td>
<td class="amount" :class="{'figli-ecart': item.hasError && item.ecart < 0, 'figli-ecart-positif': item.hasError && item.ecart > 0}">{{ item.hasError ? formatEur(item.ecart) : '' }}</td>
</tr>
</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>