diff --git a/CHANGELOG.md b/CHANGELOG.md index b8049521f..25986289c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ Le contenu est rédigé en français à destination des **fiduciaires, PME, ind ### Fixed +- **La balance des comptes ne concordait pas avec le bilan ([#385](https://github.com/guycorbaz/kesh/issues/385)).** Elle ne montrait que les **mouvements** de la période : le compte « clients » de la balance 2026 ne valait pas celui du bilan 2026, qui reporte les soldes depuis l'origine. Deux rapports, un compte, deux nombres — c'est ce qu'un réviseur trouve en dix minutes. + + **Désormais**, chaque compte porte son **solde d'ouverture**, ses mouvements et son **solde de clôture**. Un compte de bilan reporte son solde depuis l'origine ; un compte de produits ou de charges repart de zéro au début de l'exercice. La clôture d'un compte de bilan **est** son solde au bilan ; celle d'un compte de résultat est son montant **depuis le début de l'exercice** — égal au compte de résultat quand la période commence au premier jour de l'exercice —, et ouverture comme clôture concordent toujours avec le grand livre. Une ligne **« Résultat reporté (calculé) »** porte le résultat des exercices antérieurs, qu'aucun compte ne porte faute d'écriture de clôture, et un **contrôle ✓/⚠️** vérifie que les ouvertures s'équilibrent avec elle (écrit en toutes lettres dans le PDF quand il tombe). Un compte archivé figure tant qu'il porte un solde. + - **L'export de souveraineté ne portait que la moitié de votre comptabilité.** Il promettait de quoi « migrer vers un autre logiciel » et laissait dehors **les factures fournisseurs, les avoirs, les règlements, les projets, les ordres de virement, les personnes de contact, les pièces reçues et le journal d'audit** — onze tables comptables sur les trente que compte désormais l'archive. ⚠️ **Un cas était particulièrement trompeur** : les écritures exportaient bien un identifiant de projet, mais la table des projets manquait — l'identifiant ne renvoyait donc à rien. *Un fichier qui a l'air complet est pire qu'un fichier manifestement incomplet.* @@ -48,6 +52,8 @@ Le contenu est rédigé en français à destination des **fiduciaires, PME, ind ### Changed +- **La balance des comptes change de colonnes — écran, PDF et CSV ([#385](https://github.com/guycorbaz/kesh/issues/385)).** Ouverture | Débit | Crédit | Clôture : la colonne **« Solde »**, qui était le net des mouvements de la période et se prenait pour un solde de compte, disparaît de l'écran et du PDF. ⚠️ **Dans le CSV, la colonne `Solde` est remplacée** par `SoldeOuverture` (avant `TotalDebit`) et `SoldeCloture` (en dernier) : un outil qui lit ce fichier par position doit être adapté. Le CSV porte aussi la ligne du résultat reporté. *(API : `openingBalance` et `closingBalance` par compte, `retainedEarnings` et `openingBalanced` au rapport ; `balance` garde son sens — le net des mouvements.)* + - **Le bouton *Créer un avoir* s'affiche aussi sur une facture payée**, et c'est voulu. Il était masqué sur une facture payée mais pas sur une facture réglée en partie : l'écran ne couvrait que la moitié de la règle. Il reste désormais visible, et Kesh explique le refus dans le dialogue — comme le bouton *Dévalider*. - **Supprimer une facture validée n'est plus possible — il faut la « dévalider » d'abord.** Jusqu'ici, le bouton *Supprimer* d'une facture validée effaçait la facture **et son écriture comptable** d'un seul geste. La destruction d'une écriture était donc un **effet de bord du mot « supprimer »** : rien, dans le vocabulaire de l'écran, ne disait qu'on touchait aux livres. diff --git a/_bmad-output/implementation-artifacts/25-5-b-balance-solde-ouverture.md b/_bmad-output/implementation-artifacts/25-5-b-balance-solde-ouverture.md new file mode 100644 index 000000000..ecd5c3264 --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-5-b-balance-solde-ouverture.md @@ -0,0 +1,376 @@ +# Story 25.5-b : La balance des comptes porte un solde d'ouverture — et concorde enfin avec le bilan + +Status: done + +**Issue : [#385]**, que cette story **ferme** : la PR porte `closes #385` dans le **titre ET le corps**. Seconde moitié de +la 25-5, découpée le 2026-09-23 (la 25-5-a a fermé #386). Branche `story/25-5-b-balance-solde-ouverture`, partie de +`main` (`27c61def`) : la story ne dépend pas de la chaîne 25-4, qui ne touche pas `trial_balance.rs`. + +## Story + +En tant que **personne qui tient ses livres dans Kesh, ou qui les révise**, +je veux que **la balance des comptes montre pour chaque compte son solde d'ouverture, ses mouvements et son solde de +clôture**, +afin que **le solde de clôture d'un compte de bilan soit le même nombre que celui du bilan** — un compte, un nombre. + +## Les faits, vérifiés dans le code + +- **La balance actuelle est une balance des mouvements.** `crates/kesh-report/src/trial_balance.rs:60-104` filtre les + lignes sur `je.fiscal_year_id = ?` **et** `je.entry_date BETWEEN start AND end`. `balance` est le **net des mouvements** + de la période, signé par type (`Asset`/`Expense` : débit − crédit ; sinon crédit − débit). +- **Le bilan est cumulatif depuis l'origine** (`balance_sheet.rs:1-30`) : comptes de bilan = Σ de toutes les écritures + `entry_date ≤ fin`, tous exercices confondus ; plus deux **lignes calculées** : `retained_earnings` (P&L + `entry_date < fy_start`) et `equity_result` (P&L `[fy_start, fin]`). +- ⚠️ **L'écran avoue l'écart** : `TrialBalanceView.svelte:26-32` affiche `reports-trial-balance-period-note` — « Le total + par compte n'est pas comparable au solde cumulé du même compte au bilan ». Cette note devient **fausse** avec la story. +- **Le moteur existe — dans le grand livre** (Story 24-1). `general_ledger.rs:11-35` pose **la règle d'ouverture** : + `Asset`/`Liability` = cumul `entry_date < from`, tous exercices ; `Revenue`/`Expense` = cumul depuis le **début de + l'exercice contenant `from`** (0 si `from` est ce premier jour). Implémentée par `opening_balance` (`:220-275`), avec + `is_debit_natured` / `signed` (`:182-193`) et `fiscal_year_start_containing` (`:195-218`) — **privés au module**, et + **requête par compte**. +- **Un test de concordance existe déjà** entre grand livre et balance (`crates/kesh-report/tests/general_ledger.rs:150-205`) : + il ne compare que `total_debit` / `total_credit` — les mouvements, la seule chose que la balance porte aujourd'hui. +- **Règle d'inclusion actuelle** (`trial_balance.rs:3-6`) : comptes actifs toujours ; archivés **seulement s'ils ont une + écriture dans la période**. Un compte archivé portant un solde d'ouverture mais sans mouvement **disparaîtrait** — + alors que le bilan le compte. +- **Rendus** : CSV `csv.rs:249-296` (`NumeroCompte;NomCompte;TotalDebit;TotalCredit;Solde`, ligne `Total`) ; PDF + `pdf.rs:699-850` (5 colonnes, A4 portrait, nom tronqué à 30 caractères, positions `MARGIN_LEFT_MM + 20/90/125/160`). + Les libellés `ledger_opening`, `ledger_closing` et `retained_result_label` existent déjà dans `SectionLabels` + (`pdf.rs:74-140`) ; côté écran, `reports-ledger-opening`, `reports-ledger-closing` et + `reports-retained-earnings-calculated` / `-loss` existent déjà dans les 4 locales. ⚠️ Les libellés « Solde + d'ouverture » / « Solde de clôture » sont **trop longs** pour les colonnes de la balance (AC 7) : seuls + `retained_result_label` et `reports-retained-earnings-*` sont réutilisés. +- **Consommateurs de `TrialBalance` / `TrialBalanceRow`** (construction littérale à compléter) : `csv.rs` (tests + `:820`, `:1128`), `pdf.rs` (`fixture_tb`, `:1810`), `benches/export.rs:100-125`. Appels de `generate` : + `kesh-api/src/routes/reports.rs:399`, `:991` ; tests `kesh-db/tests/report_aggregates.rs` (4 sites), + `kesh-api/tests/reports_e2e.rs`, `reports_export_e2e.rs`, `kesh-report/tests/general_ledger.rs`. +- **Le manuel est faux, et pas seulement sur l'ouverture** : `user-manual.tex:1557-1559` — « Liste tous les comptes avec + leurs soldes débiteurs et créditeurs. Filtres : période, **niveau de détail (comptes principaux uniquement, ou tous + sous-comptes)** ». Aucun filtre de niveau n'existe (`grep -n "detail\|niveau" reports/+page.svelte routes/reports.rs` : + rien). Glossaire `:2043` : « … à une date donnée ». + +## Acceptance Criteria + +**AC 1 — Le solde d'ouverture, selon la règle du grand livre.** `TrialBalanceRow` gagne `opening_balance` et +`closing_balance` (`Decimal`, même convention de signe que `balance`). Ouverture : comptes de bilan (`Asset`, +`Liability`) = Σ des lignes `entry_date < period.start_date`, **tous exercices confondus** ; comptes de résultat +(`Revenue`, `Expense`) = Σ des lignes `entry_date ∈ [début de l'exercice de la période, period.start_date[` — 0 quand la +période commence le premier jour de l'exercice. Le début de l'exercice est celui de `period.fiscal_year_id` (la +`ReportPeriod` est déjà bornée à cet exercice). **Clôture = ouverture + net des mouvements** (`balance`). + +**AC 2 — Une seule règle, pas deux copies — et la forme qui le permet.** La règle type → borne vit **en Rust, une +fois** : un module partagé de `kesh-report` (ex. `crate::opening`) porte `is_debit_natured`, `signed` et +`opening_from(t, before_start: (debit, credit), since_fy_start: (debit, credit)) -> Decimal` (bilan : la première +paire ; résultat : la seconde ; signé par type). La balance fait **une requête agrégée** (pas une requête par compte) +qui rend, par compte, **trois paires de sommes brutes** — `entry_date < start`, `entry_date ∈ [fy_start, start[`, et les +mouvements de la période — **sans** `CASE` sur le type ni sur le signe en SQL (le `CASE` de signe actuel, +`trial_balance.rs:71-75`, disparaît au profit de `signed`). Le grand livre appelle `opening_from` lui aussi (sa +requête d'ouverture rend les deux paires ; sa période est libre — quand aucun exercice ne couvre `from`, +`fiscal_year_start_containing` rend `None` et il passe une paire **nulle** « depuis le début d'exercice », ce qui garde +l'ouverture d'un compte de résultat à zéro comme aujourd'hui) ; ses tests restent verts sans retouche d'assertion. ⚠️ `TrialBalanceRow` +dérive `sqlx::FromRow` (`trial_balance.rs:35`) : un champ calculé en Rust ferait échouer la lecture **à l'exécution** +(`ColumnNotFound`), invisible à la compilation — lire dans une structure brute intermédiaire, puis construire la +ligne. + +**AC 3 — Les mouvements ne changent pas.** `total_debit`, `total_credit` (par ligne et au total), `balance` (net des +mouvements) et `balanced` gardent **exactement** leur sens et leurs valeurs actuels. `balance` est documenté au champ +comme « net des mouvements de la période » — l'API publique (clés PAT) ne voit qu'**ajouter** des champs. + +**AC 4 — La ligne calculée du résultat reporté.** Sans écriture de clôture, le résultat des exercices antérieurs n'est +porté par **aucun compte** : sans lui, ni la colonne d'ouverture ni celle de clôture ne s'équilibrent. `TrialBalance` +gagne `retained_earnings: Decimal` — **même valeur** que `BalanceSheet::retained_earnings` pour l'exercice de la période +(P&L `entry_date < fy_start`, signe crédit-positif), obtenue en appelant **la même fonction** : +`fetch_retained_earnings` (`balance_sheet.rs:333`, privée) est rendue visible au crate (`pub(crate)`), pas recopiée. Il est rendu comme une **ligne +à part**, après les comptes, libellée « Résultat reporté (calculé) » (« Perte reportée » si négatif, comme au bilan), +ouverture = clôture = `retained_earnings`, mouvements vides. Ce n'est **pas** une `TrialBalanceRow` (pas +d'`account_id`, pas de lien vers le grand livre). ⚠️ La bascule sur « Perte reportée » n'existe **qu'à l'écran**, comme +au bilan (`BalanceSheetView.svelte:97-100`) ; le CSV et le PDF du bilan portent un libellé fixe (`csv.rs:140-144`, +`pdf.rs:596-599`) — la balance fait de même. + +**AC 5 — Un contrôle, pas une tautologie.** `TrialBalance` gagne **un** indicateur `opening_balanced` (`bool`) : +`Σ debit_sense(row.opening_balance) − retained_earnings = 0`, où +`debit_sense(v) = if is_debit_natured(row.account_type) { v } else { −v }` — les soldes stockés sont **déjà signés par +type** : les additionner sans re-signer les comptes à nature créditrice donnerait un faux ⚠️ sur des livres exacts +(validation P1, calcul : 1000 − 600 ≠ 0 sur un jeu équilibré). Vérification chiffrée : ventes 1000 et charge 400 en +exercice 1, balance de l'exercice 2 → ouvertures en sens débit 1000 (clients) − 400 (caisse) = 600, `retained_earnings` +600, contrôle nul. **Pas de `closing_balanced`** (validation P2) : Σ debit_sense(clôture) = Σ debit_sense(ouverture) + +(total débit − total crédit des mouvements), et des mouvements déséquilibrés font déjà échouer le rapport +(`TrialBalanceUnbalanced`) — un second indicateur serait la copie du premier, une identité de construction. Le résultat reporté est calculé **indépendamment** (comme au bilan), donc +l'égalité est une vérification réelle, pas une identité de construction. Un déséquilibre n'est **pas** une erreur (à la +différence de `TrialBalanceUnbalanced` sur les mouvements) : il est **journalisé** en `tracing::warn!` — le niveau +d'`equation_holds` au bilan (`balance_sheet.rs:237`) — et affiché (⚠️). + +**AC 6 — La règle d'inclusion.** Un compte est rendu s'il est **actif**, **ou** s'il a un mouvement dans la période, +**ou** si son **solde d'ouverture est non nul**. Un compte archivé à solde d'ouverture non nul sans mouvement apparaît +donc (marqueur `active: false`), sinon la clôture ne concorderait pas avec le bilan. Doc du module mise à jour. + +**AC 7 — Les rendus.** +- **Écran** (`TrialBalanceView.svelte`) : colonnes N° | Intitulé | **Ouverture** | Débit | Crédit | **Clôture** — clés + **neuves et courtes** `reports-column-opening` (« Ouverture ») et `reports-column-closing` (« Clôture ») dans les 4 + locales (les clés du grand livre rendent « Solde d'ouverture », trop long et non ancrable) ; la colonne « Solde » (net + des mouvements) disparaît de l'écran ; pied : totaux débit/crédit et ✓/⚠️ des mouvements comme aujourd'hui, plus + ✓/⚠️ d'`opening_balanced` sous la colonne Ouverture (AC 5) ; la ligne calculée (AC 4) avant le pied. + La note `reports-trial-balance-period-note` est **retirée** (clé supprimée des 4 locales) — elle deviendrait fausse. +- **CSV** : `NumeroCompte;NomCompte;SoldeOuverture;TotalDebit;TotalCredit;SoldeCloture` — la colonne `Solde` est + **remplacée**, pas renommée en silence (CHANGELOG — ⚠️ changement incompatible pour qui lit ce CSV, dans une version + 0.x : **arbitré par Guy le 2026-10-03 : remplacer**). Ligne de résultat reporté sur le patron du bilan + (`csv.rs:139-145`) : numéro **vide**, libellé fixe « Résultat reporté (calculé) » dans la colonne du nom, ouverture et + clôture remplies, débit et crédit vides ; puis ligne `Total` (débit, crédit ; ouverture et clôture vides). +- **PDF** : six colonnes en A4 portrait. La mise en page à cinq colonnes occupe **déjà** toute la largeur + (20 + 70 + 3 × 35 = 195 mm = 210 − 15, validation P1) : positions imposées `MARGIN_LEFT_MM +` **0** (N°), **20** + (intitulé), **75** (ouverture), **105** (débit), **135** (crédit), **165** (clôture) — colonnes de montant de 30 mm + (« -1'234'567.89 » y tient), intitulé de 55 mm tronqué à **22** caractères (au lieu de 30, constante nommée). + En-têtes : deux champs **neufs** de `SectionLabels`, `col_opening` = « Ouverture » et `col_closing` = « Clôture » + (défauts fr de `PdfContext::fr_ch_default`) — « Solde d'ouverture » en Helvetica-Bold 10 pt mesure 29,85 mm et + toucherait « Débit », « Solde de clôture » finirait à 2,5 mm du bord (validation P2). Ligne `retained_result_label`. Garde « vide » inchangée + (`rows.is_empty()`). +- `isReportEmpty('trial-balance', …)` (`reports.api.ts:169-172`) : inchangé — les comptes actifs sont toujours rendus. + +**AC 8 — L'API et les types.** `openingBalance`, `closingBalance` sur chaque ligne ; `retainedEarnings`, +`openingBalanced` au niveau du rapport — dans la réponse de `GET /api/v1/reports/trial-balance` et dans +`reports.types.ts`. Libellés neufs éventuels dans les 4 locales, `sitesTotal` recompté depuis la source. + +**AC 9 — La concordance, prouvée en appelant les autres rapports.** Le motif d'échec redouté est **muet** : +`clôture = ouverture + mouvements` resterait vrai, le rapport serait intérieurement cohérent et extérieurement faux. +Les tests appellent donc **réellement** `generate_balance_sheet`, `generate_income_statement` et le grand livre : +- deux exercices, des écritures sur les deux (dont un compte de bilan, un de produits, un de charges) ; balance de + l'exercice 2 → **clôture de chaque compte de bilan = son solde au bilan** à la fin de la période ; **clôture de chaque + compte de résultat = son montant au compte de résultat** ; `retained_earnings` = celui du bilan ; ouverture du compte + de produits **= 0** au premier jour de l'exercice 2 (et non le cumul de l'exercice 1) ; +- une période **en cours d'exercice** (mars–juin de l'exercice 2) : ouverture du compte de produits = cumul janvier– + février ; clôture = celle du grand livre sur la même période (`section.closing`) ; +- **la frontière** : dans la période mars–juin, une écriture datée **le 1er mars** (comptée dans les mouvements, + absente de l'ouverture) et une datée **la veille du début de période** (dans l'ouverture, absente des mouvements) — + la mutation `<` → `<=` compterait la première deux fois, et seules les comparaisons au bilan et au grand livre la + voient (celle au grand livre seulement si la borne n'est pas dans le module partagé) ; +- une période **d'un seul jour** (`start = end`), une écriture ce jour-là et une la veille ; +- le **premier exercice** d'une société (aucune écriture antérieure) : `retained_earnings = 0`, ouvertures nulles, + contrôle vrai ; une **société sans aucune écriture** : tout à zéro, contrôle vrai ; un **exercice 2 ouvert et encore + vide** après un exercice 1 mouvementé — la première vue réelle d'une année neuve : ouvertures = clôtures de + l'exercice 1 pour les comptes de bilan, nulles pour les comptes de résultat, mouvements nuls, contrôle vrai ; +- des **pertes cumulées** (charges > produits en exercice 1) : `retained_earnings < 0`, contrôle vrai ; le libellé + « Perte reportée » est vérifié **dans le Vitest de la vue** (seul rendu qui bascule, AC 4) ; +- `opening_balanced` vrai dans ces scénarios — et **faux** quand l'égalité est réellement cassée : une ligne + d'écriture **déséquilibrée insérée en SQL brut** (hors `create_in_tx`) datée dans l'exercice 1 — sur un compte de + bilan **et**, dans un second cas, sur un compte de résultat (l'écart passe alors par `retained_earnings`) → balance + de l'exercice 2 rendue (pas d'erreur : les mouvements de la période restent équilibrés), `opening_balanced` à + `false`. Sans ce test, le contrôle reproduit l'angle mort d'`equation_holds`, jamais testé à + `false` dans le dépôt (validation P1) ; +- un test **unitaire** de `debit_sense` (compte à nature créditrice re-signé) ; +- un compte **archivé** à solde d'ouverture non nul, sans mouvement → présent, clôture = bilan ; et un compte de + **résultat** archivé, mouvementé en janvier, balance de mars–juin → présent (ouverture non nulle), `opening_balanced` + vrai (une règle d'inclusion limitée aux comptes de bilan le ferait disparaître et le contrôle rougirait) ; +- non-régression : mouvements, `balance`, `balanced` identiques à avant sur le jeu existant (`report_aggregates.rs`) ; +- le test existant `concordance_compte_de_resultat` (`general_ledger.rs:135-205`) étendu à `opening` / `closing` **et + passé à une période en cours d'exercice** — sur l'exercice entier, les deux ouvertures valent 0 par construction et + une mutation « ouverture toujours nulle » survivrait. +- Rendus : CSV (en-tête à six colonnes, **valeurs** d'ouverture et de clôture dans l'ordre des colonnes, ligne de + résultat reporté, ligne `Total`) ; PDF (rendu non vide avec la ligne calculée ; la troncature à 22 caractères testée + sur un intitulé de 38) ; **Vitest** de la vue (`TrialBalanceView.test.ts`, à créer : colonnes, ligne calculée, ✓/⚠️, note absente) ; + test d'API sur la forme de la réponse (`reports_e2e.rs`) ; **E2E** : la balance affiche les colonnes Ouverture / + Clôture (`reports.spec.ts`, sélecteurs ancrés). + +Chacun aurait échoué avant le patch ; chaque test de concordance nomme la mutation qu'il tue (ex. « ouverture des +comptes de résultat cumulée depuis l'origine », « règle d'inclusion sans le solde d'ouverture »). + +**AC 10 — Le manuel et le CHANGELOG.** `user-manual.tex` § *Balance des comptes* (`:1557-1559`) réécrit : les quatre +colonnes, la règle d'ouverture (bilan depuis l'origine, résultat depuis le début de l'exercice), la ligne calculée, +la concordance avec le bilan et le grand livre ; la phrase sur le **filtre de niveau de détail**, qui n'existe pas, +**retirée**. Le grand livre (`:1561-1565`, « là où la balance dit la même chose autrement ») relu. Le manuel dit aussi : (a) les +soldes de départ sont une écriture datée du premier jour du premier exercice (`:615`) — ils apparaissent donc en +**mouvements** de cet exercice, ouverture nulle ; (b) Ouverture et Clôture sont **signées selon la nature du compte** : +leur somme visible n'est pas nulle (1000 − 400 + 600 = 1200 dans l'exemple de l'AC 5) alors que l'écran affiche ✓ — +le contrôle porte sur les soldes en sens débit. Glossaire `:2043` +corrigé (« à une date donnée » → sur une période, ouverture et clôture). PDF régénéré, contrôlé **aplati** +(`pdftotext … | tr '\n' ' ' | tr -s ' '`). CHANGELOG, section `## [0.12.1] — Non publié` (il n'y a pas de section `[Unreleased]`) : `Changed` (colonnes de l'écran, du PDF et du CSV — +`Solde` remplacé) et `Fixed` (#385). README et site relus (`grep -rn "balance" README.md website/`). + +## Tasks / Subtasks + +- [x] **T1 — le module partagé** (AC 2) : extraire `is_debit_natured`, `signed`, la borne basse de l'ouverture ; le grand + livre l'appelle ; ses tests verts sans retouche d'assertion. +- [x] **T2 — la balance** (AC 1, 3, 4, 5, 6) : requête agrégée, champs neufs, ligne calculée, contrôle, inclusion. +- [x] **T3 — les rendus** CSV et PDF (AC 7), fixtures et bench complétés. +- [x] **T4 — l'écran, les types, l'i18n** (AC 7, 8) : vue, `reports.types.ts`, clé de note retirée et deux clés de + colonne ajoutées dans les 4 locales, `sitesTotal`. +- [x] **T5 — tests** (AC 9). +- [x] **T6 — manuel, CHANGELOG** (AC 10). +- [x] **T7 — gates** : backend complet, frontend complet, **E2E complet**. + +## Dev Notes + +### Ce qu'il ne faut pas faire + +- **Ne pas** recopier `opening_balance` du grand livre dans `trial_balance.rs` (DRY, AC 2) — ni l'appeler compte par + compte (une requête par compte sur un plan de 300 comptes). +- **Ne pas** changer le sens de `balance` : l'API est publique (clés PAT, Story 15-x). On **ajoute**. +- **Ne pas** fusionner la ligne calculée avec un compte physique de rôle `RetainedEarnings` (collision D1 de la 14-3c, + `balance_sheet.rs:40-55`) : le compte physique reste une ligne de compte, la ligne calculée reste à part. +- **Ne pas** borner l'ouverture des comptes de bilan par `fiscal_year_id` : c'est exactement le défaut de #385. +- **Ne pas** introduire de filtre de niveau de détail pour rendre le manuel vrai : c'est le manuel qu'on corrige. +- La « requête agrégée unique » (AC 2) n'est tenue par **aucun test** (pas de compteur de requêtes dans le dépôt) : + c'est un contrôle de **revue de code**, assumé comme tel. + +### Limites assumées (à écrire au manuel et en doc de module) + +- **Signe lu sur le type courant** du compte : retyper un compte mouvementé re-signe tout son historique — limite + préexistante du grand livre (#274, #382), héritée telle quelle. +- **Pas d'écriture de clôture** : la remise à zéro des comptes de résultat n'existe que comme borne basse du `SUM` ; + c'est ce qui rend les tests de concordance (AC 9) indispensables. + +### Modules + +`kesh-report` (balance, module partagé, rendus, deux libellés PDF), `frontend` (vue, types, test), `kesh-i18n` (clé +retirée, deux clés de colonne), + `docs`, `CHANGELOG`. `kesh-api` : aucun changement de code attendu (la route sérialise la structure ; +les libellés PDF existent) — tests seulement. **Trois modules de code**, sous le seuil de la § *Règle de splitting +préventif*. + +### References + +- Issue [#385] ; audit du 2026-08-26 (comptable § 5.2, DAF § 1.3). +- `crates/kesh-report/src/general_ledger.rs:1-60`, `:182-275` — la règle et son implémentation. +- `crates/kesh-report/src/balance_sheet.rs:1-120`, `:224-228`, `:333` — le calcul de `retained_earnings`. +- Story 24-1 (grand livre) — `_bmad-output/implementation-artifacts/24-1-grand-livre.md`. + +## Dev Agent Record + +### Agent Model Used + +Claude Opus 5.5 (`claude-opus-5-5`). + +### Debug Log References + +- Les dix tests de concordance ont passé **du premier coup** : aucun n'a donc été vu rouge avant le code. D'où les + mutations ci-dessous, qui tiennent lieu de phase rouge. +- Deux garde-fous du frontend ont rougi au gate, et ils avaient raison : + - `e2e-selecteurs-traduits` : l'E2E ancrait l'en-tête « Clôture » sur son libellé traduit. Remplacé par les + `data-testid` `tb-col-opening` et `tb-col-closing`, et l'absence de « Solde » par un compte de six en-têtes. + - `i18n-libelle-en-dur` : la déclaration neuve `retainedLabel` est **nommée** dans le relevé (44 → 45, + `conforme` 38 → 39). Ses deux branches délèguent à `i18nMsg`. +- Manuel : les glyphes ✓ et ⚠️ manquent dans la police (`Missing character`). Remplacés par des mots. Les dix + `Missing character` restants sont antérieurs à la story. +- `cargo fmt` a reformaté le fichier de test neuf. + +### Completion Notes List + +- **T1, le module partagé** — `crate::opening` porte `is_debit_natured`, `is_bilan`, `signed`, `debit_sense` et + `opening_from`. Le grand livre lit désormais les deux paires de sommes en **une** requête par compte. Quand aucun + exercice ne couvre `from`, la borne basse vaut `from` lui-même et la paire est nulle. Ses tests passent sans + retouche d'assertion ; ses deux tests unitaires de signe et de nature ont migré dans `opening.rs`. +- **T2, la balance** — une requête agrégée rend trois paires de sommes brutes par compte, sans aucun `CASE` sur le + type ni sur le signe. Elle est lue dans une structure brute `RawRow`, la ligne étant construite en Rust. + - Règle d'inclusion : actif, ou mouvementé, ou porteur d'un solde d'ouverture. + - `retained_earnings` vient de `balance_sheet::fetch_retained_earnings`, passé en `pub(crate)` et non recopié. + - `opening_balanced` est calculé par `debit_sense` ; un écart est signalé par `tracing::warn!`. + - `balance`, `total_debit`, `total_credit` et `balanced` sont inchangés. + - Les trois tests unitaires tautologiques de `trial_balance.rs` (qui comparaient des constantes) sont remplacés + par trois tests de `is_opening_balanced`. +- **T3, les rendus** — CSV à six colonnes, avec la ligne du résultat reporté sur le patron du bilan. PDF à six + colonnes aux positions imposées, avec l'intitulé tronqué à 22 caractères et deux libellés neufs, `col_opening` et + `col_closing`. Le libellé de la ligne calculée n'est pas tronqué : environ 46 mm pour 55 mm disponibles. +- **T4, l'écran** — les colonnes Ouverture et Clôture, la ligne calculée (« Perte reportée » si elle est négative) + et le contrôle ✓/⚠️ d'ouverture, distinct de celui des mouvements. La note retirée a perdu sa clé dans les 4 + locales ; deux clés neuves sont ajoutées. `sitesTotal` passe de 1756 à 1758. +- **T5, les tests** — **périmètre : de `HEAD` = `42623926` (fiche validée, aucun code) à l'arbre de travail.** + - Rust, **+14 nets** : + - `trial_balance_opening.rs` : 10 tests neufs ; + - `opening.rs` : 4 tests, dont 2 migrés du grand livre (`general_ledger.rs` passe de 4 à 2) ; + - `pdf.rs` : +2 ; + - `trial_balance.rs` : 3 tests remplacés ; + - `csv.rs` : un test réécrit, qui porte maintenant les valeurs ; + - `concordance_compte_de_resultat` étendu à une période en cours d'exercice ; + - `reports_e2e.rs` : un test étendu à la forme de la réponse. + - Vitest : **+6** dans `TrialBalanceView.test.ts`, neuf. E2E : `reports.spec.ts` étendu. +- **Gates** (T7) : base remise à zéro, `scripts/test-fast.sh` **2545/2545, 4 ignorés** (fmt, clippy, nextest) ; + frontend `check` 0 erreur, `lint-i18n-ownership` PASS, `test:unit` **845/845**, `build` OK ; **E2E complet** sur + `kesh_e2e` migrée à neuf (run de 10:07 UTC) : **224 passés, 12 échoués, 19 ignorés** — les 10 attendus d'un run matinal (KF-029 ×7, KF-045 ×2 + `invoices.spec.ts:415/439`, KF-046 `sidebar-navigation:75`) et la paire de pollution documentée `products:166` + + `product-revenue-account:133`, **vertes rejouées seules** ; `reports.spec.ts` vert, extension 25-5-b comprise. +- ⚠️ **Deux tests PDF prouvent moins que l'AC 9 ne le demande** (revue P1, C-2) : `…_is_not_the_empty_render` compare + des longueurs, et le test de troncature appelle la fonction et la constante, pas le site d'appel du rendu. Le PDF ne + s'inspecte pas sans extraire son texte ; limite assumée, aucune mutation PDF n'est déclarée tuée par eux. +- **Mutations tuées** : + - `<` → `<=` sur la borne d'ouverture : 2 tests tombent ; + - ouverture de résultat cumulée depuis l'origine : 5 tests ; + - bilan borné par l'exercice (le défaut de #385) : 5 tests ; + - ancienne règle d'inclusion : 1 test ; + - contrôle tautologique : 2 tests ; + - à l'écran, contrôle lu sur `balanced` : 1 test ; + - ouverture affichée à la place de la clôture : 1 test. +- **T6, manuel et CHANGELOG** : + - § *Balance des comptes* réécrite. La phrase sur le filtre de niveau de détail, qui n'existait pas, est retirée. + - Les deux points de la validation P2 sont écrits : les soldes de départ en mouvements, les montants signés. + - Le renvoi vers le grand livre est corrigé, ainsi que le glossaire ; le label `sec:soldes-depart` est posé. + - PDF régénéré et contrôlé aplati. + - CHANGELOG `[0.12.1]` : une entrée `Fixed` et une entrée `Changed`, cette dernière signalant l'incompatibilité CSV. + - README et site relus, toujours vrais. + +### File List + +- `CHANGELOG.md` +- `crates/kesh-api/tests/reports_e2e.rs` +- `crates/kesh-i18n/locales/{de-CH,en-CH,fr-CH,it-CH}/messages.ftl` +- `crates/kesh-report/benches/export.rs` +- `crates/kesh-report/src/{balance_sheet.rs,csv.rs,general_ledger.rs,lib.rs,pdf.rs,trial_balance.rs}` +- `crates/kesh-report/src/opening.rs` (neuf) +- `crates/kesh-report/tests/general_ledger.rs` +- `crates/kesh-report/tests/trial_balance_opening.rs` (neuf) +- `docs/manual/fr/user-manual.{tex,pdf}` +- `frontend/src/lib/features/reports/{TrialBalanceView.svelte,reports.types.ts}` +- `frontend/src/lib/features/reports/TrialBalanceView.test.ts` (neuf) +- `frontend/src/lib/shared/{i18n-keys.test.ts,i18n-libelle-en-dur.test.ts}` +- `frontend/tests/e2e/reports.spec.ts` + +## Change Log + +- **2026-10-03** — Arbitrage de Guy (« ok ») : la colonne CSV `Solde` est **remplacée**, comme livré. +- **2026-10-03** — Revue de code P2 ciblée (Haiku, prompt `25-5-b-review-prompt-p2.md`) : **0 finding**, sorties des + quatre vérifications fournies. Le seul résidu cité (« montant au compte de résultat », AC 9) relu par + l'orchestrateur : il porte sur l'exercice entier, où l'égalité tient. **Boucle close** : P1 1 MED (Sonnet ×3) → P2 0 + (Haiku). Gate complet après la remédiation : base remise à zéro, `scripts/test-fast.sh` **2547/2547, 4 ignorés** ; + frontend inchangé depuis son gate ; E2E rejoué entre-temps : 227 passés, 9 échoués — les 9 attendus d'un run + matinal —, 19 ignorés. Statut → `done`. +- **2026-10-03** — Revue de code P1 (Sonnet ×3, prompt `25-5-b-review-prompt-p1.md`) : **0 CRITICAL/HIGH, 1 MED**, LOW. + - **C-1 (MED)** : le manuel et le CHANGELOG promettaient la concordance d'un compte de **résultat** avec le compte de + résultat **sur toute période**. C'est faux en cours d'exercice, où le compte de résultat ne porte que les mouvements + (mars–juin : 820 contre 570). Corrigé : clôture « depuis le début de l'exercice », égale au compte de résultat + quand la période commence au premier jour de l'exercice, au grand livre toujours. PDF régénéré, contrôlé aplati. + - **LOW corrigés** : + - le contrôle d'ouverture n'existait qu'à l'écran ; le PDF l'écrit désormais sous les totaux quand il tombe (A-1, + nouveau libellé `opening_unbalanced`, test, mutation tuée) ; + - un compte archivé aux mouvements compensés n'était exercé par aucun test (C-3, test neuf, mutation « inclusion + sur le montant net » tuée) ; + - les ignorés manquaient aux gates déclarés (C-4) ; + - la limite des deux tests PDF est écrite (C-2) ; + - deux limites sont écrites en doc de module : l'invariant date ↔ exercice, tenu par `create_in_tx` + (`DateOutsideFiscalYear`), vérifié (A-4, B-2) ; et la lecture de tout l'historique (A-5, B-1). + - **Écartés** : + - « Perte reportée » réservé à l'écran, voulu par l'AC 4 (A-2) ; + - la convention de signe, écrite au manuel (A-3) ; + - la ligne du résultat reporté affichée à 0, voulue (B-3). + - Gate ciblé : `fmt`, `clippy kesh-report`, `binary(trial_balance_opening)` + tests `trial_balance` 21/21. **Gate complet + au push.** +- **2026-10-03** — Implémentée (dev-story) : T1–T7, gates ci-dessus. Statut → `review`. +- **2026-10-03** — Validation P3 ciblée (Haiku, prompt `25-5-b-validate-prompt-p3.md`) : **0 finding**, sorties des quatre + vérifications fournies. Le point 2 (compatibilité de l'AC 2 avec le grand livre), établi par raisonnement, **repris + par l'orchestrateur** : la période libre du grand livre (`LedgerPeriod`) impose de passer une paire nulle quand aucun + exercice ne couvre `from` — écrit à l'AC 2 (LOW). **Boucle close** : P1 1H/4M (Sonnet ×3) → P2 4M/9L (Opus) → P3 0 + (Haiku). ⚠️ Arbitrage de Guy attendu sur le CSV (remplacer `Solde` ou ajouter deux colonnes en fin de ligne) — défaut + retenu : remplacer. +- **2026-10-03** — Validation P2 (Opus, prompt `25-5-b-validate-prompt-p2.md`) : **0 CRITICAL/HIGH, 4 MED, 9 LOW**, + retenus. `closing_balanced` retiré — copie d'`opening_balanced` par construction (M1) ; la forme de l'AC 2 prescrite + (sommes brutes en SQL, règle en Rust partagée avec le grand livre ; piège `FromRow`) (M2) ; libellés courts + « Ouverture » / « Clôture » — les en-têtes du grand livre débordent au PDF (calcul AFM) et ne s'ancrent pas en E2E + (M3) ; « Perte reportée » à l'écran seulement, comme au bilan (M4) ; test de concordance du grand livre en cours + d'exercice, « société sans écriture » et exercice 2 vide, compte de résultat archivé, `warn!`, CHANGELOG `[0.12.1]` + et incompatibilité CSV à arbitrer, « la veille », colonne du libellé CSV, deux phrases au manuel (L1–L8). Trend : + P1 1H/4M → P2 4M. +- **2026-10-03** — Validation P1 (Sonnet ×3, prompt `25-5-b-validate-prompt-p1.md`) : **1 HIGH, 4 MED**, LOW. Retenus : + la frontière `entry_date = start` testée, et la veille (C1, HIGH) ; la formule du sens débit écrite et chiffrée (A) ; + le contrôle testé à `false` par une ligne déséquilibrée insérée en SQL brut (C2) ; premier exercice (C3) ; positions + des six colonnes du PDF chiffrées — la largeur est déjà saturée à cinq (B) ; période d'un jour, exercice vide, pertes + cumulées, valeurs CSV, troncature, requête unique laissée à la revue, deux références décalées (LOW). Règle, ligne + calculée et contrôle **confirmés par le calcul** sur trois jeux (lentille A, mutation #385 détectée : −1000 ≠ 0). +- **2026-10-03** — Créée (Guy : « pousse et continue »). + +[#385]: https://github.com/guycorbaz/kesh/issues/385 diff --git a/_bmad-output/implementation-artifacts/25-5-b-review-prompt-p1.md b/_bmad-output/implementation-artifacts/25-5-b-review-prompt-p1.md new file mode 100644 index 000000000..7d9095475 --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-5-b-review-prompt-p1.md @@ -0,0 +1,45 @@ +# Prompt — revue de code P1, Story 25-5-b (la balance porte un solde d'ouverture) + +*Versionné le 2026-10-03. Trois lentilles (Sonnet), contexte frais chacune.* + +Dépôt `/home/gcorbaz/devel/kesh`, branche `story/25-5-b-balance-solde-ouverture`. **Diff à revoir : `git diff 42623926 46edd970`** +— un seul commit d'implémentation. Fiche : `_bmad-output/implementation-artifacts/25-5-b-balance-solde-ouverture.md` +(AC 1–10, Dev Notes, Dev Agent Record). Issue : `gh issue view 385`. Règles : `CLAUDE.md`. + +## Lentilles + +- **A — Blind hunter** : le diff **seul**, sans la fiche. SQL de `trial_balance.rs` (liaisons dans le bon ordre — + compte-les une à une contre les `?` ; `SUM(CASE … END)` sans `ELSE` → `NULL` couvert par `COALESCE` ? ; `CAST … AS + SIGNED` lu en `i64` ; la jointure et le filtre de société), la requête d'ouverture réécrite du grand livre, `Decimal`, + rendus CSV/PDF, réactivité Svelte 5 et `big.js`. +- **B — Edge-case hunter** : le diff et le code environnant. + - Le grand livre : sa nouvelle requête d'ouverture rend-elle **exactement** l'ancien résultat dans tous les cas — compte + de bilan, compte de résultat, `from` hors de tout exercice, `from` = premier jour d'un exercice, exercices avec trous ? + - La balance : `fy_start` lu par `find_by_id_in_company` — exercice d'une autre société ? Une écriture d'une **autre + société** sur un compte de cette société (impossible ?) ; une écriture datée hors de son exercice ; un compte retypé. + - La règle d'inclusion : un compte archivé dont l'ouverture est nulle mais qui a des lignes **de montant net nul** dans + la période ; `period_lines` compte-t-il les bonnes lignes ? + - `opening_balanced` : faux positif possible sur des livres exacts (arrondi `Decimal`, échelle 4 décimales) ? + - L'écran : `retainedEarnings` absent ou illisible, rapport vide, ligne calculée quand il n'y a aucun compte. + - La performance : la sous-requête lit toutes les lignes jusqu'à `end` — index utilisés (`idx_jel_account`) ? +- **C — Acceptance auditor** : chaque AC tenu ? Recompte depuis la source (`sitesTotal`, relevé des libellés en dur, clés + dans les 4 locales, décomptes de tests du Dev Agent Record aux deux bornes `42623926` / `46edd970`). **Pars du + symptôme** : `grep -rn "\.balance\b\|totalDebit\|reports-column-balance" frontend/src crates/kesh-report crates/kesh-api/src` + — un consommateur de la balance qui suppose encore l'ancienne forme ? Les tests prouvent-ils ce qu'ils disent (chaque + mutation déclarée est-elle tuée par l'assertion écrite) ? Le **manuel** (`docs/manual/fr/user-manual.tex` et **PDF + aplati**, `pdftotext … | tr '\n' ' ' | tr -s ' '` vers `target/gate-logs/`) dit-il ce que fait le code — § *Balance des + comptes*, § *Grand livre*, glossaire, § *Reprise de comptabilité* ? Le CHANGELOG ? Le Dev Agent Record n'affirme-t-il que + ce qui a tourné ? + +## Ce que tu rends + +Findings avec sévérité (CRITICAL/HIGH/MEDIUM/LOW), `fichier:ligne`, **preuve** (commande et sortie, ou code cité relu), +scénario d'échec, correction. Pour tout finding affirmant qu'un code est absent ou présent : la sortie d'un `grep -nF`. +⛔ La liste des axes exercés ET non exercés. + +## Interdits + +⛔ N'écris aucun fichier du dépôt hors `target/gate-logs/` ; aucune commande qui écrit dans le dépôt ou dans une base : +`scripts/*` (dont `scripts/prepare-release.sh`), `make`, `latexmk`, `git commit`/`add`/`checkout`, `sqlx`, +`cargo test`/`nextest`, `npm run`, `npx`, `gh issue create`/`comment`/`edit`. Autorisés : lecture, `grep`, `sed`, +`git log`/`show`/`diff`, `gh issue view`, `pdftotext` vers `target/gate-logs/`. diff --git a/_bmad-output/implementation-artifacts/25-5-b-review-prompt-p2.md b/_bmad-output/implementation-artifacts/25-5-b-review-prompt-p2.md new file mode 100644 index 000000000..c58358e2c --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-5-b-review-prompt-p2.md @@ -0,0 +1,33 @@ +# Prompt — revue de code P2 ciblée, Story 25-5-b + +*Versionné le 2026-10-03. **Une lentille** (Haiku), contexte frais — passe ciblée sur la remédiation de P1 (P1 Sonnet ×3 : +1 MED, LOW).* + +Dépôt `/home/gcorbaz/devel/kesh`, branche `story/25-5-b-balance-solde-ouverture`. **Diff à relire, un seul commit, aplati : +`git show 84505eed`.** Fiche : `_bmad-output/implementation-artifacts/25-5-b-balance-solde-ouverture.md`. + +## Lentille — regression hunter + +1. `crates/kesh-report/src/pdf.rs` : le libellé `opening_unbalanced` est-il ajouté à `SectionLabels` **et** à ses défauts, + sans autre construction littérale de `SectionLabels` ailleurs (`grep -rn "SectionLabels {" crates`) ? L'avertissement + est-il dessiné après `ensure_space_for_row` ? Tient-il sur une ligne (Helvetica-Bold 10 pt, ~0,55 em par caractère, + largeur utile 180 mm) ? Le test l'exerce-t-il dans les deux sens ? +2. `crates/kesh-report/tests/trial_balance_opening.rs`, test `un_compte_archive_aux_mouvements_compenses_figure` : les deux + écritures tombent-elles dans l'exercice et la période ? L'assertion distingue-t-elle « présent à zéro » d'« absent » ? +3. Manuel et CHANGELOG : la phrase corrigée dit-elle juste ? Lis `crates/kesh-report/src/income_statement.rs` (filtre des + mouvements) et vérifie : sur une période qui ne commence pas au premier jour de l'exercice, compte de résultat ≠ clôture + de la balance ; sur l'exercice entier, égalité. Reste-t-il **ailleurs** la promesse fausse ? `grep -rn "montant au compte + de résultat" docs CHANGELOG.md _bmad-output/implementation-artifacts/25-5-b-*.md crates`. +4. La doc de module de `trial_balance.rs` : `DateOutsideFiscalYear` existe-t-il bien dans `journal_entries::create_in_tx` + (`grep -n "DateOutsideFiscalYear" crates/kesh-db/src/repositories/journal_entries.rs`) ? + +## Ce que tu rends + +Findings avec sévérité, endroit, **preuve obligatoire : la commande ET sa sortie** — un finding sans sortie sera rejeté, +un « 0 finding » sans les sorties des quatre vérifications aussi. ⛔ La liste des axes exercés ET non exercés. + +## Interdits + +⛔ N'écris aucun fichier du dépôt ; aucune commande qui écrit dans le dépôt ou dans une base (`scripts/*` dont +`scripts/prepare-release.sh`, `make`, `latexmk`, `git commit`/`add`/`checkout`, `sqlx`, `cargo test`/`nextest`, +`npm run`, `npx`, `gh issue create`/`comment`/`edit`). Autorisés : lecture, `grep`, `sed`, `git log`/`show`/`diff`. diff --git a/_bmad-output/implementation-artifacts/25-5-b-validate-prompt-p1.md b/_bmad-output/implementation-artifacts/25-5-b-validate-prompt-p1.md new file mode 100644 index 000000000..e426cb8df --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-5-b-validate-prompt-p1.md @@ -0,0 +1,48 @@ +# Prompt — validation P1, Story 25-5-b (la balance porte un solde d'ouverture) + +*Versionné le 2026-10-03. Trois lentilles (Sonnet), contexte frais chacune.* + +Dépôt `/home/gcorbaz/devel/kesh`, branche `story/25-5-b-balance-solde-ouverture`. **Fiche à valider :** +`_bmad-output/implementation-artifacts/25-5-b-balance-solde-ouverture.md` (commit `ac40f66b`). Issue : `gh issue view 385`. +Règles : `CLAUDE.md`. La fiche décrit du travail **à faire** : un finding qui reproche au code de ne pas encore le porter +sera rejeté. + +## Lentilles + +- **A — Exactitude comptable.** La règle d'ouverture (bilan depuis l'origine, résultat depuis le début de l'exercice) + est-elle juste pour une balance suisse ? La ligne calculée du résultat reporté rend-elle **réellement** les colonnes + d'ouverture et de clôture équilibrées — refais le calcul sur deux exercices (vente et charge en exercice 1, vente en + exercice 2, balance de l'exercice 2 entière puis d'une période mars–juin) : Σ des ouvertures en sens débit − + `retained_earnings` = 0 ? Et la clôture ? Le résultat **de l'exercice en cours avant la période** (janvier–février) + est-il compté une fois, pas deux (dans les ouvertures des comptes de résultat ET dans `retained_earnings`) ? Le + contrôle de l'AC 5 est-il une vraie vérification ou une identité ? La concordance avec le bilan tient-elle quand la + période ne finit pas au dernier jour de l'exercice ? Un compte physique de rôle `RetainedEarnings` (report d'ouverture + d'un migrant) est-il compté deux fois avec la ligne calculée ? +- **B — Faisabilité dans le code.** Chaque référence `fichier:ligne` de la fiche existe-t-elle et dit-elle ce que la fiche + affirme (`grep -nF`, `sed -n`) ? L'extraction de l'AC 2 est-elle possible sans changer le comportement du grand livre + (lis `general_ledger.rs` en entier) ? `fetch_retained_earnings` prend-il la bonne date ? La requête agrégée unique + est-elle écrivable (règle d'inclusion sur un solde d'ouverture non nul : `HAVING` ou sous-requête ?) ? Les six colonnes + tiennent-elles en A4 portrait avec la police et les positions actuelles (`pdf.rs`) ? Tous les consommateurs de + `TrialBalance`/`TrialBalanceRow` et de la clé `reports-trial-balance-period-note` sont-ils listés (`grep -rn`) ? Le + périmètre (trois modules de code) est-il exact ? +- **C — Complétude et tests.** Chaque AC est-il testable, et le test prévu aurait-il échoué avant le patch ? Manque-t-il + un cas (période d'un seul jour ; premier exercice, `retained_earnings` = 0 ; exercice sans écriture ; compte de bilan + sans aucun mouvement ni ouverture ; écriture datée le jour même du début de période) ? Le manuel : relis + `docs/manual/fr/user-manual.tex` § *Balance des comptes*, § *Grand livre*, glossaire **et le PDF aplati** + (`pdftotext docs/manual/fr/user-manual.pdf - | tr '\n' ' ' | tr -s ' '` vers + `/tmp/claude-1000/-home-gcorbaz-devel-kesh/379e6f94-8029-42cb-9720-fa27c2fb204c/scratchpad/`) : la fiche liste-t-elle + **tous** les sites que le patch rendra faux ? Pars du symptôme : `grep -rn "mouvement de la période\|balance de vérification\|Balance des comptes" docs website README.md crates/kesh-i18n frontend/src`. + CHANGELOG, README, site. + +## Ce que tu rends + +Findings avec sévérité (CRITICAL/HIGH/MEDIUM/LOW), endroit, **preuve** (commande ET sortie, `grep -nF` pour toute +présence ou absence ; calcul chiffré pour la lentille A), correction proposée. ⛔ La liste des axes exercés ET non +exercés. + +## Interdits + +⛔ N'écris aucun fichier du dépôt ; aucune commande qui écrit dans le dépôt ou dans une base : `scripts/*` (dont +`scripts/prepare-release.sh`), `make`, `latexmk`, `git commit`/`add`/`checkout`, `sqlx`, `cargo test`/`nextest`, +`npm run`, `npx`, `gh issue create`/`comment`/`edit`. Autorisés : lecture, `grep`, `sed`, `git log`/`show`/`diff`, +`gh issue view`, `pdftotext` vers le scratchpad. diff --git a/_bmad-output/implementation-artifacts/25-5-b-validate-prompt-p2.md b/_bmad-output/implementation-artifacts/25-5-b-validate-prompt-p2.md new file mode 100644 index 000000000..f7a91a9a1 --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-5-b-validate-prompt-p2.md @@ -0,0 +1,42 @@ +# Prompt — validation P2, Story 25-5-b (la balance porte un solde d'ouverture) + +*Versionné le 2026-10-03. Une passe (Opus), contexte frais, les trois lentilles. Trend : P1 1H/4M (Sonnet ×3).* + +Dépôt `/home/gcorbaz/devel/kesh`, branche `story/25-5-b-balance-solde-ouverture`. **Fiche :** +`_bmad-output/implementation-artifacts/25-5-b-balance-solde-ouverture.md` (tête `b6c1d5f4`). Remédiation de P1 : +`git diff 7e529131 b6c1d5f4`. Issue : `gh issue view 385`. Règles : `CLAUDE.md`. La fiche décrit du travail **à faire** : +un finding qui reproche au code de ne pas encore le porter sera rejeté. + +## Ce que tu vérifies + +1. **La remédiation de P1** (le diff) : chaque ajout est-il juste ? En particulier : + - la formule `debit_sense` et la vérification chiffrée de l'AC 5 — refais le calcul ; + - le test du contrôle à `false` : une ligne déséquilibrée insérée en SQL brut dans l'exercice 1 casse-t-elle + **réellement** les deux égalités (ouverture ET clôture) sans faire échouer le rapport ? `fetch_retained_earnings` + lit-il cette ligne (selon le type du compte choisi, elle tombe dans le résultat reporté ou dans une ouverture — + l'écart survit-il ?) ; `TrialBalanceUnbalanced` (mouvements de la période) reste-t-il muet ? + - les positions des six colonnes du PDF (`pdf.rs`, police Helvetica 10 pt, `MARGIN_LEFT_MM`, `PAGE_WIDTH_MM`, + `truncate_with_ellipsis`) : chevauchement ? la colonne de clôture déborde-t-elle de la page ? + - les scénarios ajoutés à l'AC 9 contredisent-ils un autre AC ? +2. **Ce que P1 n'a pas regardé** — cohérence interne de la fiche (AC, tâches, Dev Notes, *Modules*) ; la règle d'ouverture + pour une période **dont le début n'est pas le début de l'exercice** combinée à la règle d'inclusion (AC 6) ; un compte de + **résultat** archivé à ouverture non nulle en cours d'exercice ; la ligne calculée au CSV (libellé fixe fr — cohérent + avec les autres lignes CSV de `csv.rs` ?) ; la doc de l'API publique (`docs/` — `grep -rn "trial-balance" docs`) et le + manuel admin s'ils décrivent la réponse ; le test de concordance existant `general_ledger.rs:150-205` : étendu à + `opening`/`closing`, son scénario (exercice entier) prouve-t-il quelque chose sur l'ouverture ? +3. **Le manuel** — `docs/manual/fr/user-manual.tex` (§ *Balance des comptes*, § *Grand livre*, glossaire) **et le PDF + aplati** (`pdftotext docs/manual/fr/user-manual.pdf - | tr '\n' ' ' | tr -s ' '` vers + `/tmp/claude-1000/-home-gcorbaz-devel-kesh/379e6f94-8029-42cb-9720-fa27c2fb204c/scratchpad/`) : la fiche nomme-t-elle + tous les sites que le patch rendra faux ? + +## Ce que tu rends + +Findings avec sévérité (CRITICAL/HIGH/MEDIUM/LOW), endroit, **preuve** (commande ET sortie, `grep -nF` pour toute +présence ou absence, calcul chiffré), correction proposée. ⛔ La liste des axes exercés ET non exercés. + +## Interdits + +⛔ N'écris aucun fichier du dépôt ; aucune commande qui écrit dans le dépôt ou dans une base : `scripts/*` (dont +`scripts/prepare-release.sh`), `make`, `latexmk`, `git commit`/`add`/`checkout`, `sqlx`, `cargo test`/`nextest`, +`npm run`, `npx`, `gh issue create`/`comment`/`edit`. Autorisés : lecture, `grep`, `sed`, `git log`/`show`/`diff`, +`gh issue view`, `pdftotext` vers le scratchpad. diff --git a/_bmad-output/implementation-artifacts/25-5-b-validate-prompt-p3.md b/_bmad-output/implementation-artifacts/25-5-b-validate-prompt-p3.md new file mode 100644 index 000000000..053297ec3 --- /dev/null +++ b/_bmad-output/implementation-artifacts/25-5-b-validate-prompt-p3.md @@ -0,0 +1,36 @@ +# Prompt — validation P3 ciblée, Story 25-5-b + +*Versionné le 2026-10-03. **Une lentille** (Haiku), contexte frais — passe ciblée sur la remédiation de P2. Trend : +P1 1H/4M (Sonnet ×3) → P2 4M/9L (Opus).* + +Dépôt `/home/gcorbaz/devel/kesh`, branche `story/25-5-b-balance-solde-ouverture`. **Diff à relire, aplati, un seul +commit : `git diff 6fc79cf5 013b2ae7`** (la fiche `_bmad-output/implementation-artifacts/25-5-b-balance-solde-ouverture.md`). +La fiche décrit du travail **à faire** : un finding qui reproche au code de ne pas encore le porter sera rejeté. + +## Lentille — regression hunter + +1. **Cohérence interne après le patch** : `closing_balanced` a été retiré — reste-t-il une phrase (AC 5, 7, 8, 9, tâches, + Dev Notes) qui suppose deux indicateurs ou un ✓/⚠️ sous la colonne Clôture ? `grep -nF "closing" ` et lis chaque + occurrence. +2. **L'AC 2 réécrit** : la forme prescrite (trois paires de sommes brutes, `opening_from` en Rust) est-elle compatible + avec le grand livre tel qu'il est ? Lis `crates/kesh-report/src/general_ledger.rs:194-275` : sa requête d'ouverture + peut-elle rendre les deux paires sans changer son résultat ? `fiscal_year_start_containing` y renvoie `None` quand + aucun exercice ne couvre `from` — la forme prescrite conserve-t-elle ce cas ? +3. **Les libellés** : les clés `reports-column-opening` / `reports-column-closing` existent-elles **déjà** dans + `crates/kesh-i18n/locales/fr-CH/messages.ftl` (collision) ? `grep -n "reports-column-" …`. Les champs `col_opening` / + `col_closing` existent-ils déjà dans `SectionLabels` (`crates/kesh-report/src/pdf.rs`) ? +4. **Les références ajoutées** existent-elles et disent-elles ce que la fiche affirme : `balance_sheet.rs:237`, + `BalanceSheetView.svelte:97-100`, `csv.rs:139-145`, `csv.rs:140-144`, `pdf.rs:596-599`, `general_ledger.rs:135-205`, + `user-manual.tex:615`, `CHANGELOG.md` (section `## [0.12.1] — Non publié`) ? + +## Ce que tu rends + +Findings avec sévérité, endroit, **preuve obligatoire : la commande ET sa sortie** — un finding sans sortie de commande +sera rejeté, un « 0 finding » sans les sorties des quatre vérifications aussi. ⛔ La liste des axes exercés ET non +exercés. + +## Interdits + +⛔ N'écris aucun fichier du dépôt ; aucune commande qui écrit dans le dépôt ou dans une base (`scripts/*` dont +`scripts/prepare-release.sh`, `make`, `latexmk`, `git commit`/`add`/`checkout`, `sqlx`, `cargo test`/`nextest`, +`npm run`, `npx`, `gh issue create`/`comment`/`edit`). Autorisés : lecture, `grep`, `sed`, `git log`/`show`/`diff`. diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index a4f7f76fb..7f684779b 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -368,7 +368,7 @@ development_status: 25-4-d-solder-le-reste: backlog # [#384] ramenee de la 25-6 le 2026-09-27 (Guy) — perte sur debiteur, escompte, frais : clore une facture partiellement reglee ; la part TVA reduit la TVA due. 25-4-propager-le-residuel: split # 2026-09-26 DECOUPEE (six modules > 5) en 25-4-a / 25-4-b / 25-4-c, puis 25-4-d (#384, 2026-09-27) ; #455 et #456 rattachees. # [#416] [#420] — la 24-2 a introduit le reglement PARTIEL ; le residuel n'est propage ni a la balance agee ni a l'echeancier, et **le score de rapprochement compare au TTC et non au residuel** : le virement du solde d'une facture partiellement reglee **score 0**. 25-5-a-export-souverainete: done # 2026-09-26 **MERGEE (PR #452, #386 fermee) SANS REVUE DE CODE** ; revue a posteriori CLOSE en 3 passes sur `story/25-5-a-revue-code` (4 HIGH P1, 1 HIGH orchestrateur P2 — colonnes manquantes, garde de colonnes —, P3 ciblee test seul) ; gates 2466/2466, E2E 221/8/19 sans regression ; reports #465, #466. # 2026-09-23 **SPECIFIEE et VALIDEE en 1 passe** (0 CRITICAL, 4 HIGH, 3 MEDIUM, 3 LOW, tous corriges). [#386] — issue du DECOUPAGE de la 25-5 (arbitrage de Guy) : #385 (Balance vs Bilan) et #386 n'ont en commun que le mot « etat », et touchent des modules disjoints. ⛔ **Trois faits qui corrigent l'issue** : 21 tables absentes et non 10 ; le trou s'est CREUSE depuis son ecriture (`invoice_settlements`, Epic 24) ; et SIX repositories sur huit n'ont aucune lecture exhaustive — le vrai cout, absent de l'issue. Arbitrage de Guy : l'export CSV ne porte que les donnees COMPTABLES (11 dedans), les backups portent tout ; 10 exclusions AVEC motif ecrit, dont 3 tables de SECRETS. Les justificatifs (fichiers) partent en story separee sur la SAUVEGARDE. ⛔ La vraie livraison est la GARDE D'EXHAUSTIVITE (patron D4-ter + P7), non les 11 exports : trois epics ont rouvert le trou en silence. ⚠️ Pieges releves par la validation : le nombre 19 code en dur a SEPT endroits dont deux `debug_assert_eq!` qui PANIQUENT ; `audit_log` n'a AUCUN dump exhaustif (ses fonctions sont bornees, la route rejette au-dela de 10 000 lignes) ; `csv_sanitize` existe et n'est pas employee par cet export ; deux sites de manuel DEJA faux (un dossier `reports/` invente, un « 38 » pour 39). - 25-5-b-balance-solde-ouverture: backlog # [#385] — la **Balance des comptes ne concorde pas avec le Bilan**, il manque la colonne « solde d'ouverture » (III.4). Detachee de la 25-5 le 2026-09-23 : voir 25-5-a. + 25-5-b-balance-solde-ouverture: done # 2026-10-03 CREEE, validee (3 passes), implementee, revue close en 2 passes (P1 1 MED -> P2 0) (partie de main). ouverture + mouvements + cloture, ligne calculee du resultat reporte, closes #385. [#385] — la **Balance des comptes ne concorde pas avec le Bilan**, il manque la colonne « solde d'ouverture » (III.4). Detachee de la 25-5 le 2026-09-23 : voir 25-5-a. 25-5-etats-et-export: split # [#385] [#386] — la **Balance des comptes ne concorde pas avec le Bilan**, il manque la colonne « solde d'ouverture » (III.4) ; et **l'export de souverainete omet les achats, les avoirs, les projets et la piste d'audit** (V.6) — *il promet plus qu'il ne tient*. 25-6-tableau-de-bord-et-dettes: backlog # [#388] [#389] [#387] (#384 ramenee en 25-4-d le 2026-09-27) — la tuile « Dernieres ecritures » **n'appelle rien** et affiche toujours « Aucune ecriture » (*une tuile qui est un decor*) ; le « solde bancaire » est le solde COMPTABLE, donc faux ; imputer l'ECART d'un reglement partiel (escompte, frais bancaires, perte sur debiteur) ; et **le PDF d'une facture n'est jamais archive**, il est regenere a la volee (IV.9) — *un document reemis n'est pas le document envoye*. epic-16: done # 2026-08-11 (nuit) MERGÉ — dernière story 16-3b via PR #296 (squash `593bb845`). #152, #144 et #151 fermées. v0.9.0 prête à taguer. # 2026-07-26 KICKOFF (rétro Epic 14, décision D-D ; GO Guy après publication de v0.8.0). Branche `story/16-1-compte-produit-par-ligne` créée sur main `ef6cdf52`. Séquence : 16-1 (#152 socle par ligne) → 16-2 (#144 défaut catalogue) → 16-3 (#151 reliquat PDF). # ÉLU PROCHAIN EPIC — kickoff dès le merge de la PR groupée Epic 14. Reste #144 (compte produit au catalogue) + #152 (compte produit par ligne) ; #151 (récap TVA facture) déjà mergé hors-bande via PR #267 (bb3c8d64, partiel : coord. émetteur / n° client restants). Capte aussi les CR utilisateurs #265/#266/#268. # Facturation avancée (#144/#152/#151) — Epic dédié acté 2026-06-04 diff --git a/crates/kesh-api/tests/reports_e2e.rs b/crates/kesh-api/tests/reports_e2e.rs index 58ef83ad6..88fdc3ec9 100644 --- a/crates/kesh-api/tests/reports_e2e.rs +++ b/crates/kesh-api/tests/reports_e2e.rs @@ -590,6 +590,13 @@ async fn trial_balance_total_debit_equals_total_credit(pool: MySqlPool) { assert_eq!(resp.status(), 200); let body: Value = resp.json().await.unwrap(); assert_eq!(body["balanced"], true); + // Story 25-5-b — les champs neufs traversent la frontière HTTP. + assert_eq!(body["openingBalanced"], true); + assert!(body["retainedEarnings"].is_string(), "{body}"); + let first = &body["rows"].as_array().expect("rows")[0]; + for key in ["openingBalance", "closingBalance", "balance"] { + assert!(first[key].is_string(), "{key} absent : {first}"); + } } // ============================================================ diff --git a/crates/kesh-i18n/locales/de-CH/messages.ftl b/crates/kesh-i18n/locales/de-CH/messages.ftl index 5f5cf114e..18dcf17a7 100644 --- a/crates/kesh-i18n/locales/de-CH/messages.ftl +++ b/crates/kesh-i18n/locales/de-CH/messages.ftl @@ -1096,6 +1096,8 @@ reports-column-account-name = Bezeichnung reports-column-debit = Soll reports-column-credit = Haben reports-column-balance = Saldo +reports-column-opening = Eröffnung +reports-column-closing = Abschluss reports-column-entry-date = Datum reports-column-description = Buchungstext @@ -1139,7 +1141,6 @@ reports-equity-result-loss = Verlust der Periode reports-retained-earnings = Gewinnvortrag reports-retained-earnings-calculated = Gewinnvortrag (berechnet) reports-retained-earnings-loss = Verlustvortrag -reports-trial-balance-period-note = Die Rohbilanz zeigt die Bewegung der Periode (pro Geschäftsjahr). Die Summe pro Konto ist nicht mit dem kumulierten Saldo desselben Kontos in der Bilanz vergleichbar (Saldovortrag seit Beginn). # Alerts + badges UI (2 — code review Pass 1 i18n leaks) reports-equation-warning = ⚠️ Bilanzgleichung ungültig (Quelldaten prüfen). diff --git a/crates/kesh-i18n/locales/en-CH/messages.ftl b/crates/kesh-i18n/locales/en-CH/messages.ftl index b08af60ee..9ccb420e9 100644 --- a/crates/kesh-i18n/locales/en-CH/messages.ftl +++ b/crates/kesh-i18n/locales/en-CH/messages.ftl @@ -1096,6 +1096,8 @@ reports-column-account-name = Account Name reports-column-debit = Debit reports-column-credit = Credit reports-column-balance = Balance +reports-column-opening = Opening +reports-column-closing = Closing reports-column-entry-date = Date reports-column-description = Description @@ -1139,7 +1141,6 @@ reports-equity-result-loss = Period loss reports-retained-earnings = Retained earnings reports-retained-earnings-calculated = Retained earnings (calculated) reports-retained-earnings-loss = Accumulated loss -reports-trial-balance-period-note = The trial balance shows the movement of the period (per fiscal year). The per-account total is not comparable to the cumulative balance of the same account in the balance sheet (carry-forward from inception). # Alerts + badges UI (2 — code review Pass 1 i18n leaks) reports-equation-warning = ⚠️ Balance sheet equation unbalanced (verify source data). diff --git a/crates/kesh-i18n/locales/fr-CH/messages.ftl b/crates/kesh-i18n/locales/fr-CH/messages.ftl index 0e17c9cfd..1da1b3e68 100644 --- a/crates/kesh-i18n/locales/fr-CH/messages.ftl +++ b/crates/kesh-i18n/locales/fr-CH/messages.ftl @@ -1151,6 +1151,8 @@ reports-column-account-name = Intitulé reports-column-debit = Débit reports-column-credit = Crédit reports-column-balance = Solde +reports-column-opening = Ouverture +reports-column-closing = Clôture reports-column-entry-date = Date reports-column-description = Libellé @@ -1199,7 +1201,6 @@ reports-equity-result-loss = Perte de l'exercice reports-retained-earnings = Résultat reporté reports-retained-earnings-calculated = Résultat reporté (calculé) reports-retained-earnings-loss = Perte reportée -reports-trial-balance-period-note = La balance de vérification affiche le mouvement de la période (par exercice). Le total par compte n'est pas comparable au solde cumulé du même compte au bilan (report à-nouveau depuis l'origine). # Alertes + badges UI (2 — code review Pass 1 i18n leaks) reports-equation-warning = ⚠️ Équation bilan déséquilibrée (vérifier données source). diff --git a/crates/kesh-i18n/locales/it-CH/messages.ftl b/crates/kesh-i18n/locales/it-CH/messages.ftl index 55c658f3e..4491a46e6 100644 --- a/crates/kesh-i18n/locales/it-CH/messages.ftl +++ b/crates/kesh-i18n/locales/it-CH/messages.ftl @@ -1096,6 +1096,8 @@ reports-column-account-name = Denominazione reports-column-debit = Dare reports-column-credit = Avere reports-column-balance = Saldo +reports-column-opening = Apertura +reports-column-closing = Chiusura reports-column-entry-date = Data reports-column-description = Causale @@ -1139,7 +1141,6 @@ reports-equity-result-loss = Perdita del periodo reports-retained-earnings = Risultato riportato reports-retained-earnings-calculated = Risultato riportato (calcolato) reports-retained-earnings-loss = Perdita riportata -reports-trial-balance-period-note = Il bilancio di verifica mostra il movimento del periodo (per esercizio). Il totale per conto non è comparabile con il saldo cumulato dello stesso conto nel bilancio (riporto a nuovo dall'origine). # Alerts + badges UI (2 — code review Pass 1 i18n leaks) reports-equation-warning = ⚠️ Equazione di bilancio non bilanciata (verificare i dati sorgente). diff --git a/crates/kesh-report/benches/export.rs b/crates/kesh-report/benches/export.rs index b5f188b96..c459deaf9 100644 --- a/crates/kesh-report/benches/export.rs +++ b/crates/kesh-report/benches/export.rs @@ -109,9 +109,11 @@ fn make_trial_balance(n: usize) -> TrialBalance { AccountType::Liability }, active: true, + opening_balance: Decimal::from(250), total_debit: Decimal::from(100), total_credit: Decimal::from(100), balance: Decimal::from(0), + closing_balance: Decimal::from(250), }) .collect(); let total_debit: Decimal = rows.iter().map(|r| r.total_debit).sum(); @@ -122,6 +124,8 @@ fn make_trial_balance(n: usize) -> TrialBalance { total_debit, total_credit, balanced: true, + retained_earnings: Decimal::ZERO, + opening_balanced: true, } } diff --git a/crates/kesh-report/src/balance_sheet.rs b/crates/kesh-report/src/balance_sheet.rs index 22562af7b..6c82b3242 100644 --- a/crates/kesh-report/src/balance_sheet.rs +++ b/crates/kesh-report/src/balance_sheet.rs @@ -330,7 +330,7 @@ async fn fetch_cumulative_section( /// /// **Couture (AC-G)** : même borne date `entry_date < before` que /// `fetch_cumulative_section` ; un snapshot de clôture stockera aussi ce report. -async fn fetch_retained_earnings( +pub(crate) async fn fetch_retained_earnings( pool: &MySqlPool, company_id: i64, before: NaiveDate, diff --git a/crates/kesh-report/src/csv.rs b/crates/kesh-report/src/csv.rs index 075070de1..32b7140c0 100644 --- a/crates/kesh-report/src/csv.rs +++ b/crates/kesh-report/src/csv.rs @@ -249,8 +249,11 @@ pub fn render_income_statement_csv( /// Génère le CSV de la balance des comptes (AC #14). /// -/// Colonnes : `NumeroCompte;NomCompte;TotalDebit;TotalCredit;Solde`. Ligne -/// finale totaux débit/crédit avec colonnes solde vide. +/// Colonnes : `NumeroCompte;NomCompte;SoldeOuverture;TotalDebit;TotalCredit;SoldeCloture` +/// — Story 25-5-b (#385) : la colonne `Solde` (net des mouvements, pris pour un +/// solde de compte) est **remplacée** par l'ouverture et la clôture. Après les +/// comptes, la ligne calculée du résultat reporté (numéro vide, comme au bilan), +/// puis la ligne `Total` (mouvements ; ouverture et clôture vides). pub fn render_trial_balance_csv( tb: &TrialBalance, mut writer: W, @@ -261,9 +264,10 @@ pub fn render_trial_balance_csv( wtr.write_record([ "NumeroCompte", "NomCompte", + "SoldeOuverture", "TotalDebit", "TotalCredit", - "Solde", + "SoldeCloture", ]) .map_err(map_csv_err)?; @@ -276,16 +280,30 @@ pub fn render_trial_balance_csv( wtr.write_record([ &row.account_number, &row.account_name, + &format_amount_iso(row.opening_balance), &format_amount_iso(row.total_debit), &format_amount_iso(row.total_credit), - &format_amount_iso(row.balance), + &format_amount_iso(row.closing_balance), ]) .map_err(map_csv_err)?; } + // Ligne calculée : libellé fixe, comme au CSV du bilan. + let retained = format_amount_iso(tb.retained_earnings); + wtr.write_record([ + "", + "Résultat reporté (calculé)", + &retained, + "", + "", + &retained, + ]) + .map_err(map_csv_err)?; + wtr.write_record([ "Total", "", + "", &format_amount_iso(tb.total_debit), &format_amount_iso(tb.total_credit), "", @@ -824,6 +842,8 @@ mod tests { total_debit: Decimal::ZERO, total_credit: Decimal::ZERO, balanced: true, + retained_earnings: Decimal::ZERO, + opening_balanced: true, }; let mut buf = Vec::new(); render_trial_balance_csv(&tb, &mut buf).unwrap(); @@ -1134,24 +1154,35 @@ mod tests { account_name: "Caisse".into(), account_type: AccountType::Asset, active: true, + opening_balance: dec!(1000), total_debit: dec!(200), total_credit: dec!(50), balance: dec!(150), + closing_balance: dec!(1150), }], total_debit: dec!(200), total_credit: dec!(50), balanced: true, + retained_earnings: dec!(-600), + opening_balanced: true, }; let mut buf = Vec::new(); render_trial_balance_csv(&tb, &mut buf).unwrap(); let body = String::from_utf8(buf[3..].to_vec()).unwrap(); let lines: Vec<&str> = body.split("\r\n").filter(|l| !l.is_empty()).collect(); - assert_eq!(lines.len(), 3, "1 header + 1 row + 1 total = 3 lines"); + assert_eq!( + lines.len(), + 4, + "1 header + 1 row + résultat reporté + 1 total = 4 lines" + ); assert_eq!( lines[0], - "NumeroCompte;NomCompte;TotalDebit;TotalCredit;Solde" + "NumeroCompte;NomCompte;SoldeOuverture;TotalDebit;TotalCredit;SoldeCloture" ); - assert!(lines[2].starts_with("Total;")); + // Story 25-5-b — l'ouverture et la clôture dans l'ordre des colonnes. + assert_eq!(lines[1], "1000;Caisse;1000.00;200.00;50.00;1150.00"); + assert_eq!(lines[2], ";Résultat reporté (calculé);-600.00;;;-600.00"); + assert_eq!(lines[3], "Total;;;200.00;50.00;"); } // Story 21-7 — CSV balance âgée. diff --git a/crates/kesh-report/src/general_ledger.rs b/crates/kesh-report/src/general_ledger.rs index 7825f95ec..3c6edca8f 100644 --- a/crates/kesh-report/src/general_ledger.rs +++ b/crates/kesh-report/src/general_ledger.rs @@ -10,6 +10,9 @@ //! //! # La règle du solde d'ouverture — le cœur du module //! +//! Elle vit dans [`crate::opening`], **partagée avec la balance des comptes** +//! (Story 25-5-b) : une seule règle, que les deux rapports appliquent. +//! //! Elle **diffère selon le type de compte**, et c'est ce qu'un lecteur pressé //! prendra de travers : //! @@ -53,6 +56,7 @@ use serde::Serialize; use sqlx::MySqlPool; use crate::errors::ReportError; +use crate::opening::{is_bilan, is_debit_natured, opening_from, signed}; /// Plafond de lignes rendues en une fois, aligné sur le patron du dépôt. /// @@ -178,20 +182,6 @@ struct RawAccount { active: bool, } -/// `true` si le compte a le débit pour côté naturel. -fn is_debit_natured(t: AccountType) -> bool { - matches!(t, AccountType::Asset | AccountType::Expense) -} - -/// Applique la convention de signe du dépôt — celle de la balance et du bilan. -fn signed(t: AccountType, debit: Decimal, credit: Decimal) -> Decimal { - if is_debit_natured(t) { - debit - credit - } else { - credit - debit - } -} - /// Début de l'exercice contenant `date`, s'il en existe un. /// /// Sert **uniquement** aux comptes de résultat : c'est la borne basse de leur @@ -217,10 +207,13 @@ async fn fiscal_year_start_containing( Ok(row.map(|r| r.0)) } -/// Solde d'ouverture d'un compte au `from` — **la règle centrale du module**. +/// Solde d'ouverture d'un compte au `from` — **la règle centrale du module**, +/// appliquée par [`opening_from`] (module partagé avec la balance des comptes). /// -/// Comptes de bilan : cumul depuis l'origine. Comptes de résultat : cumul depuis -/// le début de leur exercice. Voir la documentation du module pour le pourquoi. +/// Une requête lit les deux paires de sommes brutes : avant `from`, et depuis le +/// début de l'exercice contenant `from`. Quand aucun exercice ne couvre `from`, la +/// borne basse vaut `from` lui-même : la seconde paire est nulle, et l'ouverture +/// d'un compte de résultat aussi. async fn opening_balance( pool: &MySqlPool, company_id: i64, @@ -228,50 +221,28 @@ async fn opening_balance( account_type: AccountType, from: NaiveDate, ) -> Result { - // Borne basse : aucune pour un compte de bilan ; le début de l'exercice pour - // un compte de résultat. `None` ⇒ ouverture nulle, la borne haute étant - // exclusive et égale à la borne basse. - let lower: Option = match account_type { - AccountType::Asset | AccountType::Liability => None, - AccountType::Revenue | AccountType::Expense => { - match fiscal_year_start_containing(pool, company_id, from).await? { - Some(start) => Some(start), - // Aucun exercice ne couvre `from` : rien à reporter. - None => return Ok(Decimal::ZERO), - } - } - }; - - let sql = match lower { - Some(_) => { - "SELECT COALESCE(SUM(jel.debit), 0) AS d, COALESCE(SUM(jel.credit), 0) AS c \ - FROM journal_entry_lines jel \ - INNER JOIN journal_entries je ON je.id = jel.entry_id \ - WHERE jel.account_id = ? AND je.company_id = ? \ - AND je.entry_date >= ? AND je.entry_date < ?" - } - None => { - "SELECT COALESCE(SUM(jel.debit), 0) AS d, COALESCE(SUM(jel.credit), 0) AS c \ - FROM journal_entry_lines jel \ - INNER JOIN journal_entries je ON je.id = jel.entry_id \ - WHERE jel.account_id = ? AND je.company_id = ? \ - AND je.entry_date < ?" - } - }; - - let mut q = sqlx::query_as::<_, (Decimal, Decimal)>(sql) - .bind(account_id) - .bind(company_id); - if let Some(lo) = lower { - q = q.bind(lo); - } - let (d, c) = q - .bind(from) - .fetch_one(pool) - .await - .map_err(kesh_db::errors::map_db_error)?; + let fy_start = fiscal_year_start_containing(pool, company_id, from) + .await? + .unwrap_or(from); + + let (bd, bc, sd, sc) = sqlx::query_as::<_, (Decimal, Decimal, Decimal, Decimal)>( + "SELECT COALESCE(SUM(jel.debit), 0), COALESCE(SUM(jel.credit), 0), \ + COALESCE(SUM(CASE WHEN je.entry_date >= ? THEN jel.debit ELSE 0 END), 0), \ + COALESCE(SUM(CASE WHEN je.entry_date >= ? THEN jel.credit ELSE 0 END), 0) \ + FROM journal_entry_lines jel \ + INNER JOIN journal_entries je ON je.id = jel.entry_id \ + WHERE jel.account_id = ? AND je.company_id = ? AND je.entry_date < ?", + ) + .bind(fy_start) + .bind(fy_start) + .bind(account_id) + .bind(company_id) + .bind(from) + .fetch_one(pool) + .await + .map_err(kesh_db::errors::map_db_error)?; - Ok(signed(account_type, d, c)) + Ok(opening_from(account_type, (bd, bc), (sd, sc))) } /// Comptes à rendre : ceux qui ont un mouvement sur la période **ou** un solde @@ -560,39 +531,10 @@ pub async fn generate( }) } -/// `true` pour un compte de bilan — dont le solde se reporte d'un exercice à -/// l'autre. -fn is_bilan(t: AccountType) -> bool { - matches!(t, AccountType::Asset | AccountType::Liability) -} - #[cfg(test)] mod tests { use super::*; - fn d(v: i64) -> Decimal { - Decimal::from(v) - } - - #[test] - fn signe_suit_la_nature_du_compte() { - // Convention du dépôt, reprise telle quelle de la balance et du bilan. - assert_eq!(signed(AccountType::Asset, d(100), d(30)), d(70)); - assert_eq!(signed(AccountType::Expense, d(100), d(30)), d(70)); - assert_eq!(signed(AccountType::Liability, d(30), d(100)), d(70)); - assert_eq!(signed(AccountType::Revenue, d(30), d(100)), d(70)); - } - - #[test] - fn seuls_les_comptes_de_bilan_reportent_leur_solde() { - // C'est ce qui commande la borne basse du solde d'ouverture, et la - // remise à zéro du solde progressif au passage d'exercice. - assert!(is_bilan(AccountType::Asset)); - assert!(is_bilan(AccountType::Liability)); - assert!(!is_bilan(AccountType::Revenue)); - assert!(!is_bilan(AccountType::Expense)); - } - #[test] fn periode_inversee_est_refusee() { let from = NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(); diff --git a/crates/kesh-report/src/lib.rs b/crates/kesh-report/src/lib.rs index 6760d3584..9f7b32f19 100644 --- a/crates/kesh-report/src/lib.rs +++ b/crates/kesh-report/src/lib.rs @@ -20,6 +20,7 @@ pub mod errors; pub mod general_ledger; pub mod income_statement; pub mod journal_report; +pub mod opening; pub mod pdf; pub mod period; pub mod project_report; diff --git a/crates/kesh-report/src/opening.rs b/crates/kesh-report/src/opening.rs new file mode 100644 index 000000000..19c3ec249 --- /dev/null +++ b/crates/kesh-report/src/opening.rs @@ -0,0 +1,136 @@ +//! La règle du solde d'ouverture, partagée par le grand livre et la balance des +//! comptes (Story 25-5-b, #385). +//! +//! # La règle +//! +//! Elle **diffère selon le type de compte** : +//! +//! - `Asset` / `Liability` (comptes de bilan) : cumul sur `entry_date < début`, +//! **tous exercices confondus** — même patron que le bilan. +//! - `Revenue` / `Expense` (comptes de résultat) : cumul depuis le **début de +//! l'exercice** contenant le début de la période seulement. Si la période +//! commence le premier jour d'un exercice, l'ouverture vaut 0. +//! +//! Un compte de bilan reporte son solde d'un exercice à l'autre ; un compte de +//! résultat est soldé au bouclement et repart de zéro. +//! +//! ⚠️ **Kesh ne passe aucune écriture de clôture** : cette remise à zéro n'existe +//! que comme borne basse d'une somme. Les appelants lisent donc, en SQL, **deux +//! paires de sommes brutes** — avant le début, et depuis le début de l'exercice — +//! et c'est [`opening_from`] qui choisit, **ici seulement**. Recopier ce choix en +//! `CASE` SQL dans un rapport en ferait une seconde règle, qui divergerait en +//! silence. +//! +//! # Limite connue +//! +//! Le signe est lu sur le `account_type` **courant** du compte : retyper un +//! compte mouvementé re-signe tout son historique (issues #274 et #382). + +use kesh_db::entities::AccountType; +use rust_decimal::Decimal; + +/// Une paire de sommes brutes `(débit, crédit)`, telle que la lit SQL. +pub type DebitCredit = (Decimal, Decimal); + +/// `true` si le compte a le débit pour côté naturel. +pub fn is_debit_natured(t: AccountType) -> bool { + matches!(t, AccountType::Asset | AccountType::Expense) +} + +/// `true` pour un compte de bilan — dont le solde se reporte d'un exercice à +/// l'autre. +pub fn is_bilan(t: AccountType) -> bool { + matches!(t, AccountType::Asset | AccountType::Liability) +} + +/// Applique la convention de signe du dépôt — celle de la balance, du bilan et +/// du grand livre : positif du côté naturel du compte. +pub fn signed(t: AccountType, debit: Decimal, credit: Decimal) -> Decimal { + if is_debit_natured(t) { + debit - credit + } else { + credit - debit + } +} + +/// Ramène un solde **signé par type** en sens débit (débit − crédit), quel que +/// soit le compte. C'est la grandeur qui s'additionne d'un compte à l'autre : +/// additionner des soldes signés par type sans les re-signer mélange les deux +/// conventions. +pub fn debit_sense(t: AccountType, signed_balance: Decimal) -> Decimal { + if is_debit_natured(t) { + signed_balance + } else { + -signed_balance + } +} + +/// Solde d'ouverture, signé par type, depuis les deux paires de sommes brutes : +/// `before_start` (`entry_date < début`) pour un compte de bilan, +/// `since_fy_start` (`entry_date ∈ [début de l'exercice, début[`) pour un compte +/// de résultat. +/// +/// Un appelant dont la période n'est couverte par aucun exercice passe une paire +/// `since_fy_start` **nulle** : l'ouverture d'un compte de résultat vaut alors 0. +pub fn opening_from( + t: AccountType, + before_start: DebitCredit, + since_fy_start: DebitCredit, +) -> Decimal { + let (debit, credit) = if is_bilan(t) { + before_start + } else { + since_fy_start + }; + signed(t, debit, credit) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn d(v: i64) -> Decimal { + Decimal::from(v) + } + + #[test] + fn signe_suit_la_nature_du_compte() { + assert_eq!(signed(AccountType::Asset, d(100), d(30)), d(70)); + assert_eq!(signed(AccountType::Expense, d(100), d(30)), d(70)); + assert_eq!(signed(AccountType::Liability, d(30), d(100)), d(70)); + assert_eq!(signed(AccountType::Revenue, d(30), d(100)), d(70)); + } + + #[test] + fn seuls_les_comptes_de_bilan_reportent_leur_solde() { + assert!(is_bilan(AccountType::Asset)); + assert!(is_bilan(AccountType::Liability)); + assert!(!is_bilan(AccountType::Revenue)); + assert!(!is_bilan(AccountType::Expense)); + } + + /// Un compte à nature créditrice est re-signé : un passif de 70 (crédit) vaut + /// −70 en sens débit. Sans ce re-signe, le contrôle d'ouverture de la balance + /// rougirait sur des livres exacts. + #[test] + fn le_sens_debit_re_signe_les_comptes_a_nature_creditrice() { + assert_eq!(debit_sense(AccountType::Asset, d(70)), d(70)); + assert_eq!(debit_sense(AccountType::Expense, d(70)), d(70)); + assert_eq!(debit_sense(AccountType::Liability, d(70)), d(-70)); + assert_eq!(debit_sense(AccountType::Revenue, d(70)), d(-70)); + } + + /// Le bilan prend la paire « avant le début », le résultat la paire « depuis + /// le début de l'exercice » — jamais l'inverse. + #[test] + fn l_ouverture_choisit_la_paire_selon_le_type() { + let before = (d(1000), d(0)); + let since = (d(0), d(200)); + assert_eq!(opening_from(AccountType::Asset, before, since), d(1000)); + assert_eq!(opening_from(AccountType::Revenue, before, since), d(200)); + assert_eq!( + opening_from(AccountType::Revenue, before, (d(0), d(0))), + d(0) + ); + } +} diff --git a/crates/kesh-report/src/pdf.rs b/crates/kesh-report/src/pdf.rs index d12912adc..f42abc32a 100644 --- a/crates/kesh-report/src/pdf.rs +++ b/crates/kesh-report/src/pdf.rs @@ -3,7 +3,7 @@ //! 4 fonctions publiques pures (input DTO → `Result, ReportError>`) : //! - [`render_balance_sheet_pdf`] — Bilan (Actifs / Passifs / résultat de l'exercice). //! - [`render_income_statement_pdf`] — Compte de résultat (Produits / Charges / résultat net). -//! - [`render_trial_balance_pdf`] — Balance des comptes (5 colonnes : N°, Compte, Débit, Crédit, Solde). +//! - [`render_trial_balance_pdf`] — Balance des comptes (6 colonnes : N°, Compte, Ouverture, Débit, Crédit, Clôture). //! - [`render_journal_report_pdf`] — Journaux (5 sections fixes, filtre optionnel). //! //! Architecture (Pass 1 AA-M1 + Decision §swiss-amount-format) : @@ -114,6 +114,14 @@ pub struct SectionLabels { pub col_debit: String, pub col_credit: String, pub col_balance: String, + /// En-tête court de la colonne d'ouverture de la balance (Story 25-5-b) — + /// « Solde d'ouverture » ne tient pas dans une colonne de 30 mm. + pub col_opening: String, + /// En-tête court de la colonne de clôture de la balance (Story 25-5-b). + pub col_closing: String, + /// Avertissement de la balance quand les ouvertures ne s'équilibrent pas avec + /// le résultat reporté (Story 25-5-b) — l'écran a son ⚠️, le PDF ce texte. + pub opening_unbalanced: String, pub col_entry_date: String, pub col_description: String, // Header @@ -168,6 +176,12 @@ impl SectionLabels { col_debit: "Débit".into(), col_credit: "Crédit".into(), col_balance: "Solde".into(), + col_opening: "Ouverture".into(), + col_closing: "Clôture".into(), + // ≈ 145 mm en Helvetica-Bold 10 pt : tient sur une ligne (`write_line` ne + // renvoie pas à la ligne). + opening_unbalanced: + "Contrôle d'ouverture en écart : une écriture antérieure est déséquilibrée.".into(), col_entry_date: "Date".into(), col_description: "Libellé".into(), header_period: "Période".into(), @@ -696,7 +710,35 @@ pub fn render_income_statement_pdf( builder.finalize() } -/// Génère le PDF de la balance des comptes — 5 colonnes (AC #5). +/// Positions des six colonnes de la balance (Story 25-5-b), en mm depuis la marge +/// gauche. A4 portrait est déjà saturé à cinq colonnes (20 + 70 + 3 × 35 = 195 mm) : +/// quatre colonnes de montant de 30 mm (« -12'345'678.90 » ≈ 23 mm en Helvetica +/// 10 pt) laissent 55 mm à l'intitulé. +const TB_COL_X: [f32; 6] = [0.0, 20.0, 75.0, 105.0, 135.0, 165.0]; + +/// Longueur maximale de l'intitulé d'un compte dans la balance PDF : 22 +/// caractères tiennent dans 55 mm, même en capitales. +const TB_NAME_MAX_CHARS: usize = 22; + +/// Dessine une ligne de la balance : six cellules, `None` = cellule vide. +fn draw_tb_row(builder: &mut PdfBuilder, cells: [Option<&str>; 6], bold: bool) { + let y = builder.cursor_y; + let font = if bold { + builder.font_bold.clone() + } else { + builder.font.clone() + }; + let layer = builder.current_layer(); + for (cell, x) in cells.iter().zip(TB_COL_X) { + if let Some(text) = cell { + layer.use_text(*text, FONT_SIZE_PT, Mm(MARGIN_LEFT_MM + x), Mm(y), &font); + } + } +} + +/// Génère le PDF de la balance des comptes — 6 colonnes : N°, Intitulé, +/// Ouverture, Débit, Crédit, Clôture (Story 25-5-b, #385), puis la ligne calculée +/// du résultat reporté et les totaux des mouvements. pub fn render_trial_balance_pdf( tb: &TrialBalance, ctx: &PdfContext, @@ -715,133 +757,95 @@ pub fn render_trial_balance_pdf( return builder.finalize(); } - // En-têtes de colonnes (5 colonnes : N°, Compte, Débit, Crédit, Solde) - let col_num_x = MARGIN_LEFT_MM; - let col_name_x = MARGIN_LEFT_MM + 20.0; - let col_debit_x = MARGIN_LEFT_MM + 90.0; - let col_credit_x = MARGIN_LEFT_MM + 125.0; - let col_balance_x = MARGIN_LEFT_MM + 160.0; - - { - let y = builder.cursor_y; - let layer = builder.current_layer(); - layer.use_text( - &ctx.section_labels.col_account_number, - FONT_SIZE_PT, - Mm(col_num_x), - Mm(y), - &builder.font_bold, - ); - layer.use_text( - &ctx.section_labels.col_account_name, - FONT_SIZE_PT, - Mm(col_name_x), - Mm(y), - &builder.font_bold, - ); - layer.use_text( - &ctx.section_labels.col_debit, - FONT_SIZE_PT, - Mm(col_debit_x), - Mm(y), - &builder.font_bold, - ); - layer.use_text( - &ctx.section_labels.col_credit, - FONT_SIZE_PT, - Mm(col_credit_x), - Mm(y), - &builder.font_bold, - ); - layer.use_text( - &ctx.section_labels.col_balance, - FONT_SIZE_PT, - Mm(col_balance_x), - Mm(y), - &builder.font_bold, - ); - } + let l = &ctx.section_labels; + draw_tb_row( + &mut builder, + [ + Some(&l.col_account_number), + Some(&l.col_account_name), + Some(&l.col_opening), + Some(&l.col_debit), + Some(&l.col_credit), + Some(&l.col_closing), + ], + true, + ); builder.cursor_y -= LINE_HEIGHT_MM * 1.5; - // Lignes for row in &tb.rows { builder.ensure_space_for_row(); - let y = builder.cursor_y; - let layer = builder.current_layer(); - layer.use_text( - &row.account_number, - FONT_SIZE_PT, - Mm(col_num_x), - Mm(y), - &builder.font, - ); - // Pass 1 code-review H4 (BH1-H4) : la colonne `name` (largeur 70mm) peut - // déborder sur la colonne `débit` pour des libellés CH GAAP > 30 chars - // (e.g. « Provisions pour dépréciation d'actifs »). Truncate à 30 chars - // avec ellipsis Unicode pour préserver la lisibilité des colonnes - // débit/crédit/solde alignées. Refactor multi-ligne → v0.2 si feedback. - let display_name = truncate_with_ellipsis(&row.account_name, 30); - layer.use_text( - &display_name, - FONT_SIZE_PT, - Mm(col_name_x), - Mm(y), - &builder.font, - ); - layer.use_text( + // Pass 1 code-review H4 (BH1-H4) : un intitulé trop long déborderait sur la + // colonne suivante — tronqué avec ellipsis Unicode. + let name = truncate_with_ellipsis(&row.account_name, TB_NAME_MAX_CHARS); + let (opening, debit, credit, closing) = ( + format_swiss_amount(row.opening_balance), format_swiss_amount(row.total_debit), - FONT_SIZE_PT, - Mm(col_debit_x), - Mm(y), - &builder.font, - ); - layer.use_text( format_swiss_amount(row.total_credit), - FONT_SIZE_PT, - Mm(col_credit_x), - Mm(y), - &builder.font, + format_swiss_amount(row.closing_balance), ); - layer.use_text( - format_swiss_amount(row.balance), - FONT_SIZE_PT, - Mm(col_balance_x), - Mm(y), - &builder.font, + draw_tb_row( + &mut builder, + [ + Some(&row.account_number), + Some(&name), + Some(&opening), + Some(&debit), + Some(&credit), + Some(&closing), + ], + false, ); builder.cursor_y -= LINE_HEIGHT_MM; } - // Totaux + // Ligne calculée du résultat reporté : ouverture = clôture, sans mouvement. + builder.ensure_space_for_row(); + let retained = format_swiss_amount(tb.retained_earnings); + // Libellé fixe, non tronqué : « Résultat reporté (calculé) » ≈ 46 mm en casse + // mixte, dans les 55 mm de la colonne. + draw_tb_row( + &mut builder, + [ + None, + Some(&l.retained_result_label), + Some(&retained), + None, + None, + Some(&retained), + ], + false, + ); + builder.cursor_y -= LINE_HEIGHT_MM; + + // Totaux des mouvements. builder.cursor_y -= LINE_HEIGHT_MM * 0.5; builder.ensure_space_for_row(); - { - let y = builder.cursor_y; - let layer = builder.current_layer(); - layer.use_text( - &ctx.section_labels.grand_total, - FONT_SIZE_PT, - Mm(col_num_x), - Mm(y), - &builder.font_bold, - ); - layer.use_text( - format_swiss_amount(tb.total_debit), - FONT_SIZE_PT, - Mm(col_debit_x), - Mm(y), - &builder.font_bold, - ); - layer.use_text( - format_swiss_amount(tb.total_credit), - FONT_SIZE_PT, - Mm(col_credit_x), - Mm(y), - &builder.font_bold, - ); - } + let (total_debit, total_credit) = ( + format_swiss_amount(tb.total_debit), + format_swiss_amount(tb.total_credit), + ); + draw_tb_row( + &mut builder, + [ + Some(&l.grand_total), + None, + None, + Some(&total_debit), + Some(&total_credit), + None, + ], + true, + ); builder.cursor_y -= LINE_HEIGHT_MM; + // Le contrôle d'ouverture : silencieux s'il tient, écrit s'il tombe — un PDF + // remis à un réviseur ne doit pas perdre le signal que l'écran affiche. + if !tb.opening_balanced { + builder.cursor_y -= LINE_HEIGHT_MM * 0.5; + builder.ensure_space_for_row(); + builder.write_line(&l.opening_unbalanced, FONT_SIZE_PT, true, 0.0); + } + builder.finalize() } @@ -1819,14 +1823,18 @@ mod tests { account_name: "Caisse".into(), account_type: AccountType::Asset, active: true, + opening_balance: dec!(500), total_debit: dec!(2000), total_credit: dec!(1000), balance: dec!(1000), + closing_balance: dec!(1500), }] }, total_debit: if empty { Decimal::ZERO } else { dec!(2000) }, total_credit: if empty { Decimal::ZERO } else { dec!(1000) }, balanced: true, + retained_earnings: if empty { Decimal::ZERO } else { dec!(500) }, + opening_balanced: true, } } @@ -1986,6 +1994,37 @@ mod tests { assert!(bytes.starts_with(b"%PDF-1.")); } + #[test] + fn trial_balance_pdf_with_retained_line_is_not_the_empty_render() { + // Story 25-5-b — la ligne calculée et les six colonnes : le rendu peuplé + // diffère du rendu vide (garde `rows.is_empty()` inchangée). + let ctx = PdfContext::fr_ch_default("CI Test Company"); + let full = render_trial_balance_pdf(&fixture_tb(false), &ctx).unwrap(); + let empty = render_trial_balance_pdf(&fixture_tb(true), &ctx).unwrap(); + assert!(full.starts_with(b"%PDF-1.")); + assert!(full.len() > empty.len()); + } + + #[test] + fn trial_balance_pdf_writes_the_opening_warning_only_when_unbalanced() { + // Story 25-5-b, revue P1 — le contrôle d'ouverture n'existait qu'à l'écran. + let ctx = PdfContext::fr_ch_default("CI Test Company"); + let ok = render_trial_balance_pdf(&fixture_tb(false), &ctx).unwrap(); + let mut broken = fixture_tb(false); + broken.opening_balanced = false; + let warned = render_trial_balance_pdf(&broken, &ctx).unwrap(); + assert!(warned.len() > ok.len(), "l'avertissement doit être écrit"); + } + + #[test] + fn trial_balance_names_are_truncated_to_fit_six_columns() { + // Story 25-5-b — 55 mm pour l'intitulé : 22 caractères, ellipsis comprise. + let long = "Provisions pour dépréciation d'actifs"; // 37 caractères + let truncated = truncate_with_ellipsis(long, TB_NAME_MAX_CHARS); + assert_eq!(truncated.chars().count(), 22); + assert!(truncated.ends_with('…')); + } + #[test] fn journal_report_pdf_starts_with_pdf_signature() { let ctx = PdfContext::fr_ch_default("CI Test Company"); diff --git a/crates/kesh-report/src/trial_balance.rs b/crates/kesh-report/src/trial_balance.rs index 23a7b58a0..3c5d6d864 100644 --- a/crates/kesh-report/src/trial_balance.rs +++ b/crates/kesh-report/src/trial_balance.rs @@ -1,20 +1,61 @@ -//! Balance des comptes : 1 ligne par compte avec total débit / total crédit / solde. +//! Balance des comptes : pour chaque compte, **solde d'ouverture, mouvements de la +//! période, solde de clôture** — et une ligne calculée du résultat reporté. //! -//! Règle d'inclusion (Pass 1 ECH-03) : -//! - Comptes **actifs** : inclus dans tous les cas (même sans écriture, balance=0) -//! - Comptes **archivés avec écritures dans la période** : inclus (marqueur `active: false`) -//! - Comptes **archivés sans écriture dans la période** : exclus +//! # Pourquoi l'ouverture (Story 25-5-b, #385) //! -//! Invariant : `total_debit == total_credit` (cohérence partie double, vérifiée à -//! l'INSERT des écritures dans `kesh-db::repositories::journal_entries::create_in_tx:201`). -//! Si déséquilibré → `ReportError::TrialBalanceUnbalanced` + log error! (defense in depth). +//! Jusqu'à la 25-5-b, la balance ne portait que les mouvements de la période : +//! le compte « clients » de la balance 2026 ne valait pas celui du bilan 2026, +//! qui est cumulatif depuis l'origine. Désormais `closing_balance` d'un compte de +//! bilan **est** son solde au bilan, et celui d'un compte de résultat son montant +//! au compte de résultat. La règle d'ouverture vit dans [`crate::opening`], +//! partagée avec le grand livre. +//! +//! # Règle d'inclusion +//! +//! Un compte est rendu s'il est **actif**, **ou** s'il a un mouvement dans la +//! période, **ou** si son **solde d'ouverture est non nul** — un compte archivé +//! qui porte encore un solde doit apparaître, sinon la clôture ne concorderait +//! plus avec le bilan. Un compte rendu archivé porte `active: false`. +//! +//! # La ligne calculée du résultat reporté +//! +//! Kesh ne passe aucune écriture de clôture : le résultat des exercices +//! antérieurs n'est porté par **aucun compte**. Sans lui, ni la colonne +//! d'ouverture ni celle de clôture ne s'équilibrent. [`TrialBalance::retained_earnings`] +//! le porte — la même valeur que le bilan, par la même fonction. +//! +//! # Deux contrôles +//! +//! - **Mouvements** : `total_debit == total_credit` (partie double, vérifiée à +//! l'INSERT des écritures dans `kesh-db::repositories::journal_entries::create_in_tx`). +//! Si déséquilibré → `ReportError::TrialBalanceUnbalanced` + log error! (defense +//! in depth). +//! - **Ouverture** : `opening_balanced` (voir le champ). Un déséquilibre n'est pas +//! une erreur : il est journalisé et affiché, comme `equation_holds` au bilan. +//! Il n'y a pas de contrôle de clôture : la clôture vaut l'ouverture plus des +//! mouvements équilibrés, ce serait la copie du premier. +//! +//! # Limites connues +//! +//! - Le signe est lu sur le `account_type` **courant** du compte (issues #274, #382). +//! - Les mouvements filtrent `fiscal_year_id` **et** la date, l'ouverture la date +//! seule : la concordance avec le bilan suppose que toute écriture tombe dans les +//! bornes de son exercice — ce que `journal_entries::create_in_tx` impose +//! (`DateOutsideFiscalYear`), et qu'aucune mise à jour ne peut plus défaire +//! (Story 24-4b). +//! - La requête lit **tout l'historique** de la société jusqu'à la fin de la +//! période — le prix d'une ouverture cumulée depuis l'origine, comme au bilan. +//! Sans mesure à ce jour ; à surveiller si l'historique grossit. use kesh_db::entities::AccountType; +use kesh_db::repositories::fiscal_years; use rust_decimal::Decimal; use serde::Serialize; use sqlx::MySqlPool; +use crate::balance_sheet::fetch_retained_earnings; use crate::errors::ReportError; +use crate::opening::{debit_sense, opening_from, signed}; use crate::period::ReportPeriod; /// Balance des comptes complète. @@ -23,28 +64,64 @@ use crate::period::ReportPeriod; pub struct TrialBalance { pub period: ReportPeriod, pub rows: Vec, + /// Total des mouvements débit de la période. pub total_debit: Decimal, + /// Total des mouvements crédit de la période. pub total_credit: Decimal, + /// Mouvements équilibrés — toujours vrai dans un rapport rendu (sinon + /// `TrialBalanceUnbalanced`). pub balanced: bool, + /// Résultat reporté **calculé** : cumul des résultats des exercices + /// strictement antérieurs à celui de la période (P&L `entry_date < fy_start`), + /// signe crédit-positif — négatif = pertes cumulées. Même valeur que + /// `BalanceSheet::retained_earnings`. Ouverture = clôture, sans mouvement. + pub retained_earnings: Decimal, + /// `Σ debit_sense(opening_balance) − retained_earnings == 0`. Le résultat + /// reporté étant calculé **indépendamment**, c'est une vérification réelle : + /// une ligne d'écriture déséquilibrée antérieure à la période la fait tomber. + pub opening_balanced: bool, } -/// Ligne de la balance des comptes. -/// -/// `account_number` (camelCase) mappe la colonne DB `accounts.number` -/// (Pass 4 AA4-01). -#[derive(Debug, Clone, Serialize, sqlx::FromRow)] +/// Ligne de la balance des comptes. Tous les soldes suivent la convention du +/// dépôt : positifs du côté naturel du compte (`Asset`/`Expense` : débit). +#[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct TrialBalanceRow { pub account_id: i64, - #[sqlx(rename = "number")] + /// `accounts.number`. pub account_number: String, - #[sqlx(rename = "name")] + /// `accounts.name`. pub account_name: String, pub account_type: AccountType, pub active: bool, + /// Solde au début de la période — règle de [`crate::opening`]. + pub opening_balance: Decimal, + /// Mouvements débit de la période. pub total_debit: Decimal, + /// Mouvements crédit de la période. pub total_credit: Decimal, + /// **Net des mouvements** de la période (pas un solde de compte). pub balance: Decimal, + /// `opening_balance + balance` — pour un compte de bilan, son solde au bilan. + pub closing_balance: Decimal, +} + +/// Ligne brute, telle que la lit SQL : trois paires de sommes, aucun choix de +/// règle ni de signe (ils sont en Rust, dans [`crate::opening`]). +#[derive(Debug, sqlx::FromRow)] +struct RawRow { + account_id: i64, + number: String, + name: String, + account_type: AccountType, + active: bool, + before_debit: Decimal, + before_credit: Decimal, + since_fy_debit: Decimal, + since_fy_credit: Decimal, + period_debit: Decimal, + period_credit: Decimal, + period_lines: i64, } /// Génère la balance des comptes pour la période donnée. @@ -53,12 +130,16 @@ pub async fn generate( company_id: i64, period: &ReportPeriod, ) -> Result { - // Règle d'inclusion (Pass 1 ECH-03) : - // (active=true OR EXISTS écritures dans la période) - // - // CORRECTION (Pass 4 dev) : la subquery `filtered_jel` filtre les lignes par - // fiscal_year + période AVANT le LEFT JOIN sur accounts, sinon SUM agrège - // les lignes de TOUS les fy (bug constaté avec fy2 sans écritures). + let fy = fiscal_years::find_by_id_in_company(pool, company_id, period.fiscal_year_id) + .await? + .ok_or(ReportError::FiscalYearNotFound { + fiscal_year_id: period.fiscal_year_id, + })?; + let fy_start = fy.start_date; + + // Une requête agrégée : par compte, les sommes avant le début, depuis le début + // de l'exercice, et sur la période. Les mouvements gardent leur filtre + // d'origine (exercice ET dates). let sql = " SELECT a.id AS account_id, @@ -66,52 +147,83 @@ pub async fn generate( a.name, a.account_type, a.active, - COALESCE(SUM(filtered_jel.debit), 0) AS total_debit, - COALESCE(SUM(filtered_jel.credit), 0) AS total_credit, - CASE - WHEN a.account_type IN ('Asset', 'Expense') - THEN COALESCE(SUM(filtered_jel.debit), 0) - COALESCE(SUM(filtered_jel.credit), 0) - ELSE COALESCE(SUM(filtered_jel.credit), 0) - COALESCE(SUM(filtered_jel.debit), 0) - END AS balance + COALESCE(SUM(CASE WHEN x.entry_date < ? THEN x.debit END), 0) AS before_debit, + COALESCE(SUM(CASE WHEN x.entry_date < ? THEN x.credit END), 0) AS before_credit, + COALESCE(SUM(CASE WHEN x.entry_date >= ? AND x.entry_date < ? THEN x.debit END), 0) + AS since_fy_debit, + COALESCE(SUM(CASE WHEN x.entry_date >= ? AND x.entry_date < ? THEN x.credit END), 0) + AS since_fy_credit, + COALESCE(SUM(CASE WHEN x.fiscal_year_id = ? AND x.entry_date BETWEEN ? AND ? + THEN x.debit END), 0) AS period_debit, + COALESCE(SUM(CASE WHEN x.fiscal_year_id = ? AND x.entry_date BETWEEN ? AND ? + THEN x.credit END), 0) AS period_credit, + CAST(COALESCE(SUM(CASE WHEN x.fiscal_year_id = ? AND x.entry_date BETWEEN ? AND ? + THEN 1 ELSE 0 END), 0) AS SIGNED) AS period_lines FROM accounts a LEFT JOIN ( - SELECT jel.account_id, jel.debit, jel.credit + SELECT jel.account_id, jel.debit, jel.credit, je.entry_date, je.fiscal_year_id FROM journal_entry_lines jel INNER JOIN journal_entries je ON je.id = jel.entry_id WHERE je.company_id = ? - AND je.fiscal_year_id = ? - AND je.entry_date BETWEEN ? AND ? - ) AS filtered_jel ON filtered_jel.account_id = a.id + AND je.entry_date <= ? + ) AS x ON x.account_id = a.id WHERE a.company_id = ? - AND ( - a.active = TRUE - OR EXISTS ( - SELECT 1 FROM journal_entry_lines jel2 - INNER JOIN journal_entries je2 ON je2.id = jel2.entry_id - WHERE jel2.account_id = a.id - AND je2.company_id = ? - AND je2.fiscal_year_id = ? - AND je2.entry_date BETWEEN ? AND ? - ) - ) GROUP BY a.id, a.number, a.name, a.account_type, a.active ORDER BY a.number ASC "; - let rows = sqlx::query_as::<_, TrialBalanceRow>(sql) - .bind(company_id) // filtered_jel.je.company_id + let (start, end) = (period.start_date, period.end_date); + let raw = sqlx::query_as::<_, RawRow>(sql) + .bind(start) // before_debit + .bind(start) // before_credit + .bind(fy_start) + .bind(start) // since_fy_debit + .bind(fy_start) + .bind(start) // since_fy_credit .bind(period.fiscal_year_id) - .bind(period.start_date) - .bind(period.end_date) - .bind(company_id) // a.company_id (WHERE) - .bind(company_id) // je2.company_id (EXISTS subquery) + .bind(start) + .bind(end) // period_debit .bind(period.fiscal_year_id) - .bind(period.start_date) - .bind(period.end_date) + .bind(start) + .bind(end) // period_credit + .bind(period.fiscal_year_id) + .bind(start) + .bind(end) // period_lines + .bind(company_id) // x: je.company_id + .bind(end) // x: borne haute + .bind(company_id) // a.company_id .fetch_all(pool) .await .map_err(kesh_db::errors::map_db_error)?; + let rows: Vec = raw + .into_iter() + .filter_map(|r| { + let opening = opening_from( + r.account_type, + (r.before_debit, r.before_credit), + (r.since_fy_debit, r.since_fy_credit), + ); + // Règle d'inclusion : actif, ou mouvementé, ou porteur d'un solde. + if !r.active && r.period_lines == 0 && opening.is_zero() { + return None; + } + let balance = signed(r.account_type, r.period_debit, r.period_credit); + Some(TrialBalanceRow { + account_id: r.account_id, + account_number: r.number, + account_name: r.name, + account_type: r.account_type, + active: r.active, + opening_balance: opening, + total_debit: r.period_debit, + total_credit: r.period_credit, + balance, + closing_balance: opening + balance, + }) + }) + .collect(); + let total_debit: Decimal = rows.iter().map(|r| r.total_debit).sum(); let total_credit: Decimal = rows.iter().map(|r| r.total_credit).sum(); let balanced = total_debit == total_credit; @@ -130,38 +242,92 @@ pub async fn generate( }); } + let retained_earnings = fetch_retained_earnings(pool, company_id, fy_start).await?; + let opening_balanced = is_opening_balanced(&rows, retained_earnings); + if !opening_balanced { + tracing::warn!( + company_id, + fiscal_year_id = period.fiscal_year_id, + retained_earnings = %retained_earnings, + "trial_balance : les soldes d'ouverture ne s'équilibrent pas avec le résultat reporté" + ); + } + Ok(TrialBalance { period: period.clone(), rows, total_debit, total_credit, balanced, + retained_earnings, + opening_balanced, }) } +/// `Σ debit_sense(opening_balance) − retained_earnings == 0` : les soldes signés +/// par type sont ramenés en sens débit avant d'être additionnés. +fn is_opening_balanced(rows: &[TrialBalanceRow], retained_earnings: Decimal) -> bool { + let sum: Decimal = rows + .iter() + .map(|r| debit_sense(r.account_type, r.opening_balance)) + .sum(); + sum - retained_earnings == Decimal::ZERO +} + #[cfg(test)] mod tests { + use super::*; use rust_decimal_macros::dec; + fn row(account_type: AccountType, opening: Decimal) -> TrialBalanceRow { + TrialBalanceRow { + account_id: 0, + account_number: String::new(), + account_name: String::new(), + account_type, + active: true, + opening_balance: opening, + total_debit: Decimal::ZERO, + total_credit: Decimal::ZERO, + balance: Decimal::ZERO, + closing_balance: opening, + } + } + + /// Ventes 1000 et charge 400 en exercice 1 (clients 1000, caisse −400, tous + /// deux à l'actif) : ouvertures en sens débit 600 = résultat reporté 600. #[test] - fn balanced_when_debit_equals_credit() { - let total_debit = dec!(1500); - let total_credit = dec!(1500); - assert!(total_debit == total_credit); + fn l_ouverture_s_equilibre_avec_le_resultat_reporte() { + let rows = [ + row(AccountType::Asset, dec!(1000)), + row(AccountType::Asset, dec!(-400)), + ]; + assert!(is_opening_balanced(&rows, dec!(600))); + assert!(!is_opening_balanced(&rows, dec!(500))); } + /// Un passif créditeur de 5000 (report d'un migrant) face à une banque de + /// 5000 : sans le re-signe en sens débit, la somme vaudrait 10000 et le + /// contrôle rougirait sur des livres exacts. #[test] - fn unbalanced_when_debit_diverges() { - let total_debit = dec!(1500); - let total_credit = dec!(1499); - assert!(total_debit != total_credit); + fn les_comptes_a_nature_creditrice_sont_re_signes() { + let rows = [ + row(AccountType::Asset, dec!(5000)), + row(AccountType::Liability, dec!(5000)), + ]; + assert!(is_opening_balanced(&rows, Decimal::ZERO)); } + /// Exercice en cours : un produit de 200 avant la période (janvier–février) + /// est porté par l'ouverture du compte de produits, PAS par le résultat + /// reporté — compté une fois. #[test] - fn zero_balance_for_inactive_accounts_without_entries() { - // L'invariant : si compte archivé sans écriture, exclus de la balance. - // Si compte actif sans écriture, balance = 0 (SUM NULL → COALESCE 0). - let active_no_entries_balance = rust_decimal::Decimal::ZERO; - assert_eq!(active_no_entries_balance, rust_decimal::Decimal::ZERO); + fn le_resultat_de_l_exercice_avant_la_periode_est_compte_une_fois() { + let rows = [ + row(AccountType::Asset, dec!(1200)), + row(AccountType::Asset, dec!(-400)), + row(AccountType::Revenue, dec!(200)), + ]; + assert!(is_opening_balanced(&rows, dec!(600))); } } diff --git a/crates/kesh-report/tests/general_ledger.rs b/crates/kesh-report/tests/general_ledger.rs index 8b0e1308c..29c051882 100644 --- a/crates/kesh-report/tests/general_ledger.rs +++ b/crates/kesh-report/tests/general_ledger.rs @@ -204,6 +204,47 @@ async fn concordance_compte_de_resultat(pool: MySqlPool) { section.closing, is_row.balance, "la clôture doit égaler le montant du compte de résultat" ); + + // Story 25-5-b — en cours d'exercice (à partir du 1er mars), la vente de + // février fait l'ouverture du compte de ventes ; le grand livre et la balance + // doivent la porter de la même façon, et clore au même nombre. Sur + // l'exercice entier, les deux ouvertures vaudraient 0 par construction. + let (from, to) = (ymd(2026, 3, 1), ymd(2026, 12, 31)); + let mid_ledger = generate( + &pool, + seeded.company_id, + &LedgerPeriod::new(from, to).expect("période"), + &LedgerOptions::default(), + ) + .await + .expect("grand livre en cours d'exercice"); + let mid_section = mid_ledger + .sections + .iter() + .find(|s| s.account_id == ventes) + .expect("le compte de ventes doit figurer"); + let mid_rp = ReportPeriod { + fiscal_year_id: seeded.fiscal_year_id, + start_date: from, + end_date: to, + }; + let mid_tb = generate_trial_balance(&pool, seeded.company_id, &mid_rp) + .await + .expect("balance en cours d'exercice"); + let mid_row = mid_tb + .rows + .iter() + .find(|r| r.account_id == ventes) + .expect("le compte doit figurer à la balance"); + assert_eq!(mid_section.opening, dec!(1000.00)); + assert_eq!( + mid_section.opening, mid_row.opening_balance, + "l'ouverture doit concorder entre grand livre et balance" + ); + assert_eq!( + mid_section.closing, mid_row.closing_balance, + "la clôture doit concorder entre grand livre et balance" + ); } /// La partie double, vue depuis le grand livre : c'est le test le moins cher du diff --git a/crates/kesh-report/tests/trial_balance_opening.rs b/crates/kesh-report/tests/trial_balance_opening.rs new file mode 100644 index 000000000..d4cd0c565 --- /dev/null +++ b/crates/kesh-report/tests/trial_balance_opening.rs @@ -0,0 +1,599 @@ +//! La balance des comptes porte un solde d'ouverture — Story 25-5-b (#385). +//! +//! ⚠️ **Les tests de CONCORDANCE sont la raison d'être de ce fichier.** Le défaut +//! redouté est muet : si l'ouverture est prise de travers, `clôture = ouverture + +//! mouvements` reste vrai et le rapport est intérieurement cohérent, extérieurement +//! faux. Ils appellent donc réellement le bilan, le compte de résultat et le grand +//! livre, et chacun nomme la mutation qu'il tue. +//! +//! Pré-requis : MariaDB démarré. + +use chrono::NaiveDate; +use kesh_db::entities::journal_entry::{Journal, NewJournalEntry, NewJournalEntryLine}; +use kesh_db::repositories::journal_entries; +use kesh_db::test_fixtures::{SeededCompany, seed_accounting_company}; +use kesh_report::general_ledger::{LedgerOptions, LedgerPeriod}; +use kesh_report::period::ReportPeriod; +use kesh_report::trial_balance::TrialBalance; +use kesh_report::{generate_balance_sheet, generate_general_ledger, generate_trial_balance}; +use rust_decimal::Decimal; +use rust_decimal_macros::dec; +use sqlx::MySqlPool; + +fn ymd(y: i32, m: u32, d: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, d).expect("date valide") +} + +/// Poste une écriture équilibrée à deux lignes dans l'exercice `fy`. Renvoie +/// l'id de l'écriture. +#[allow(clippy::too_many_arguments)] +async fn post( + pool: &MySqlPool, + seeded: &SeededCompany, + fy: i64, + date: NaiveDate, + debit_account: &str, + credit_account: &str, + amount: Decimal, +) -> i64 { + journal_entries::create( + pool, + fy, + seeded.admin_user_id, + NewJournalEntry { + company_id: seeded.company_id, + entry_date: date, + journal: Journal::OD, + description: "écriture".into(), + project_id: None, + lines: vec![ + NewJournalEntryLine { + account_id: seeded.accounts[debit_account], + debit: amount, + credit: Decimal::ZERO, + project_id: None, + }, + NewJournalEntryLine { + account_id: seeded.accounts[credit_account], + debit: Decimal::ZERO, + credit: amount, + project_id: None, + }, + ], + }, + ) + .await + .expect("écriture postée") + .entry + .id +} + +/// Deux exercices, 2025 et 2026. ⚠️ La fixture seede UN exercice 2020-2030 : il +/// faut le rétrécir, sinon 2025 et 2026 sont le même exercice et le report ne +/// prouve plus rien. +async fn two_years(pool: &MySqlPool, seeded: &SeededCompany) -> (i64, i64) { + sqlx::query("UPDATE fiscal_years SET end_date = '2025-12-31' WHERE id = ?") + .bind(seeded.fiscal_year_id) + .execute(pool) + .await + .expect("exercice seedé rétréci à 2025"); + let fy2026: i64 = sqlx::query_scalar( + "INSERT INTO fiscal_years (company_id, name, start_date, end_date, status, created_at, updated_at) \ + VALUES (?, 'FY 2026', '2026-01-01', '2026-12-31', 'Open', NOW(3), NOW(3)) RETURNING id", + ) + .bind(seeded.company_id) + .fetch_one(pool) + .await + .expect("exercice 2026 créé"); + (seeded.fiscal_year_id, fy2026) +} + +/// Le jeu de référence : ventes 1000 et charge 400 en 2025 ; en 2026, une vente +/// de 200 en février, 50 la veille du 1er mars, 70 le 1er mars (la frontière), +/// 500 en avril, 30 en août. +async fn reference_books(pool: &MySqlPool, seeded: &SeededCompany) -> (i64, i64) { + let (fy25, fy26) = two_years(pool, seeded).await; + post( + pool, + seeded, + fy25, + ymd(2025, 3, 1), + "1100", + "3000", + dec!(1000), + ) + .await; + post( + pool, + seeded, + fy25, + ymd(2025, 6, 1), + "4000", + "1000", + dec!(400), + ) + .await; + post( + pool, + seeded, + fy26, + ymd(2026, 2, 1), + "1100", + "3000", + dec!(200), + ) + .await; + let veille = ymd(2026, 3, 1).pred_opt().expect("veille"); + post(pool, seeded, fy26, veille, "1000", "3000", dec!(50)).await; + post( + pool, + seeded, + fy26, + ymd(2026, 3, 1), + "1000", + "3000", + dec!(70), + ) + .await; + post( + pool, + seeded, + fy26, + ymd(2026, 4, 15), + "1100", + "3000", + dec!(500), + ) + .await; + post( + pool, + seeded, + fy26, + ymd(2026, 8, 1), + "1100", + "3000", + dec!(30), + ) + .await; + (fy25, fy26) +} + +fn period(fy: i64, start: NaiveDate, end: NaiveDate) -> ReportPeriod { + ReportPeriod { + fiscal_year_id: fy, + start_date: start, + end_date: end, + } +} + +async fn balance(pool: &MySqlPool, seeded: &SeededCompany, p: &ReportPeriod) -> TrialBalance { + generate_trial_balance(pool, seeded.company_id, p) + .await + .expect("balance") +} + +/// `(ouverture, débit, crédit, clôture)` d'un compte, par numéro. +fn row(tb: &TrialBalance, number: &str) -> (Decimal, Decimal, Decimal, Decimal) { + let r = tb + .rows + .iter() + .find(|r| r.account_number == number) + .unwrap_or_else(|| panic!("le compte {number} doit figurer à la balance")); + ( + r.opening_balance, + r.total_debit, + r.total_credit, + r.closing_balance, + ) +} + +/// ⛔ Toute la balance de l'exercice 2 concorde avec le bilan (comptes de bilan) +/// et le compte de résultat (comptes de résultat). Mutation tuée : « ouverture +/// bornée par l'exercice » (le défaut de #385 — la caisse et la banque perdraient +/// leur report) et « ouverture des comptes de résultat cumulée depuis l'origine » +/// (les ventes porteraient les 1000 de 2025). +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn la_balance_de_l_exercice_concorde_avec_le_bilan_et_le_resultat(pool: MySqlPool) { + let seeded = seed_accounting_company(&pool).await.expect("seed"); + let (_, fy26) = reference_books(&pool, &seeded).await; + let p = period(fy26, ymd(2026, 1, 1), ymd(2026, 12, 31)); + let tb = balance(&pool, &seeded, &p).await; + + assert_eq!( + row(&tb, "3000").0, + Decimal::ZERO, + "les ventes repartent de zéro" + ); + assert_eq!(row(&tb, "1100").0, dec!(1000), "la banque reporte 2025"); + assert_eq!(row(&tb, "1000").0, dec!(-400), "la caisse reporte 2025"); + assert_eq!(tb.retained_earnings, dec!(600)); + assert!(tb.opening_balanced); + + let bs = generate_balance_sheet(&pool, seeded.company_id, &p) + .await + .expect("bilan"); + assert_eq!(tb.retained_earnings, bs.retained_earnings); + let bs_lines: Vec<_> = bs + .assets + .iter() + .chain(&bs.liabilities) + .chain(&bs.equity) + .collect(); + let is = kesh_report::generate_income_statement(&pool, seeded.company_id, &p) + .await + .expect("compte de résultat"); + let is_lines: Vec<_> = is.revenues.iter().chain(&is.expenses).collect(); + + for r in &tb.rows { + let expected = if kesh_report::opening::is_bilan(r.account_type) { + bs_lines + .iter() + .find(|b| b.account_id == r.account_id) + .map_or(Decimal::ZERO, |b| b.balance) + } else { + is_lines + .iter() + .find(|b| b.account_id == r.account_id) + .map_or(Decimal::ZERO, |b| b.balance) + }; + assert_eq!( + r.closing_balance, expected, + "compte {} : la clôture doit égaler le bilan / le compte de résultat", + r.account_number + ); + } +} + +/// ⛔ Période en cours d'exercice (mars–juin), avec une écriture LE 1er mars et une +/// la veille. Mutation tuée : `<` → `<=` sur la borne d'ouverture (la vente du 1er +/// mars serait comptée deux fois — en ouverture et en mouvement). +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn en_cours_d_exercice_la_frontiere_n_est_comptee_qu_une_fois(pool: MySqlPool) { + let seeded = seed_accounting_company(&pool).await.expect("seed"); + let (_, fy26) = reference_books(&pool, &seeded).await; + let (start, end) = (ymd(2026, 3, 1), ymd(2026, 6, 30)); + let tb = balance(&pool, &seeded, &period(fy26, start, end)).await; + + // Ventes : 200 + 50 (la veille) en ouverture ; 70 (le 1er mars) + 500 en mouvement. + assert_eq!( + row(&tb, "3000"), + (dec!(250), Decimal::ZERO, dec!(570), dec!(820)) + ); + // Caisse : −400 + 50 en ouverture ; 70 en mouvement. + assert_eq!( + row(&tb, "1000"), + (dec!(-350), dec!(70), Decimal::ZERO, dec!(-280)) + ); + assert!(tb.opening_balanced); + + // La même clôture que le grand livre, compte par compte. + let ledger = generate_general_ledger( + &pool, + seeded.company_id, + &LedgerPeriod::new(start, end).expect("période"), + &LedgerOptions::default(), + ) + .await + .expect("grand livre"); + for s in &ledger.sections { + let r = tb + .rows + .iter() + .find(|r| r.account_id == s.account_id) + .expect("tout compte du grand livre figure à la balance"); + assert_eq!( + r.opening_balance, s.opening, + "ouverture {}", + s.account_number + ); + assert_eq!(r.closing_balance, s.closing, "clôture {}", s.account_number); + } + + // Et la clôture d'un compte de bilan, celle du bilan arrêté au 30 juin. + let bs = generate_balance_sheet(&pool, seeded.company_id, &period(fy26, start, end)) + .await + .expect("bilan"); + let caisse = bs + .assets + .iter() + .find(|a| a.account_number == "1000") + .expect("caisse au bilan"); + assert_eq!(row(&tb, "1000").3, caisse.balance); +} + +/// Une période d'un seul jour : l'écriture du jour en mouvement, celle de la +/// veille en ouverture. +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn une_periode_d_un_jour(pool: MySqlPool) { + let seeded = seed_accounting_company(&pool).await.expect("seed"); + let (_, fy26) = reference_books(&pool, &seeded).await; + let day = ymd(2026, 3, 1); + let tb = balance(&pool, &seeded, &period(fy26, day, day)).await; + assert_eq!( + row(&tb, "1000"), + (dec!(-350), dec!(70), Decimal::ZERO, dec!(-280)) + ); + assert!(tb.opening_balanced); +} + +/// Le premier exercice d'une société : rien n'est reporté. +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn le_premier_exercice_n_a_rien_a_reporter(pool: MySqlPool) { + let seeded = seed_accounting_company(&pool).await.expect("seed"); + let fy = seeded.fiscal_year_id; + post( + &pool, + &seeded, + fy, + ymd(2026, 3, 1), + "1100", + "3000", + dec!(1000), + ) + .await; + let tb = balance( + &pool, + &seeded, + &period(fy, ymd(2020, 1, 1), ymd(2030, 12, 31)), + ) + .await; + assert_eq!(tb.retained_earnings, Decimal::ZERO); + assert!(tb.rows.iter().all(|r| r.opening_balance.is_zero())); + assert_eq!(row(&tb, "1100").3, dec!(1000)); + assert!(tb.opening_balanced); +} + +/// Une société sans aucune écriture : tout à zéro, contrôle vrai. +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn une_societe_sans_ecriture(pool: MySqlPool) { + let seeded = seed_accounting_company(&pool).await.expect("seed"); + let fy = seeded.fiscal_year_id; + let tb = balance( + &pool, + &seeded, + &period(fy, ymd(2020, 1, 1), ymd(2030, 12, 31)), + ) + .await; + assert!( + !tb.rows.is_empty(), + "les comptes actifs sont toujours rendus" + ); + assert!( + tb.rows + .iter() + .all(|r| r.opening_balance.is_zero() && r.closing_balance.is_zero()) + ); + assert_eq!(tb.retained_earnings, Decimal::ZERO); + assert!(tb.opening_balanced); +} + +/// L'exercice 2 ouvert et encore vide — la première vue d'une année neuve : +/// ouvertures = clôtures de 2025 pour le bilan, nulles pour le résultat. +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn un_exercice_neuf_encore_vide_montre_le_report(pool: MySqlPool) { + let seeded = seed_accounting_company(&pool).await.expect("seed"); + let (fy25, fy26) = two_years(&pool, &seeded).await; + post( + &pool, + &seeded, + fy25, + ymd(2025, 3, 1), + "1100", + "3000", + dec!(1000), + ) + .await; + post( + &pool, + &seeded, + fy25, + ymd(2025, 6, 1), + "4000", + "1000", + dec!(400), + ) + .await; + let tb = balance( + &pool, + &seeded, + &period(fy26, ymd(2026, 1, 1), ymd(2026, 12, 31)), + ) + .await; + assert_eq!( + row(&tb, "1100"), + (dec!(1000), Decimal::ZERO, Decimal::ZERO, dec!(1000)) + ); + assert_eq!(row(&tb, "3000").0, Decimal::ZERO); + assert_eq!(row(&tb, "4000").0, Decimal::ZERO); + assert_eq!(tb.retained_earnings, dec!(600)); + assert!(tb.opening_balanced); +} + +/// Des pertes cumulées : résultat reporté négatif, contrôle vrai. +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn des_pertes_cumulees(pool: MySqlPool) { + let seeded = seed_accounting_company(&pool).await.expect("seed"); + let (fy25, fy26) = two_years(&pool, &seeded).await; + post( + &pool, + &seeded, + fy25, + ymd(2025, 3, 1), + "1100", + "3000", + dec!(100), + ) + .await; + post( + &pool, + &seeded, + fy25, + ymd(2025, 6, 1), + "4000", + "1100", + dec!(900), + ) + .await; + let tb = balance( + &pool, + &seeded, + &period(fy26, ymd(2026, 1, 1), ymd(2026, 12, 31)), + ) + .await; + assert_eq!(tb.retained_earnings, dec!(-800)); + assert!(tb.opening_balanced); +} + +/// Insère une ligne **déséquilibrée** dans une écriture existante, hors +/// `create_in_tx` (qui garantit la partie double). +async fn break_entry(pool: &MySqlPool, entry_id: i64, account_id: i64) { + sqlx::query( + "INSERT INTO journal_entry_lines (entry_id, account_id, line_order, debit, credit) \ + VALUES (?, ?, 99, 10, 0)", + ) + .bind(entry_id) + .bind(account_id) + .execute(pool) + .await + .expect("ligne déséquilibrée insérée"); +} + +/// ⛔ Le contrôle d'ouverture TOMBE quand l'égalité est réellement cassée — sur un +/// compte de bilan, puis sur un compte de résultat (l'écart passe alors par le +/// résultat reporté). Le rapport est rendu : les mouvements de 2026 restent +/// équilibrés. Mutation tuée : un contrôle tautologique (toujours vrai). +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn le_controle_d_ouverture_tombe_sur_un_compte_de_bilan(pool: MySqlPool) { + let seeded = seed_accounting_company(&pool).await.expect("seed"); + let (fy25, fy26) = two_years(&pool, &seeded).await; + let e = post( + &pool, + &seeded, + fy25, + ymd(2025, 3, 1), + "1100", + "3000", + dec!(1000), + ) + .await; + break_entry(&pool, e, seeded.accounts["1000"]).await; + let tb = balance( + &pool, + &seeded, + &period(fy26, ymd(2026, 1, 1), ymd(2026, 12, 31)), + ) + .await; + assert!(!tb.opening_balanced); +} + +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn le_controle_d_ouverture_tombe_sur_un_compte_de_resultat(pool: MySqlPool) { + let seeded = seed_accounting_company(&pool).await.expect("seed"); + let (fy25, fy26) = two_years(&pool, &seeded).await; + let e = post( + &pool, + &seeded, + fy25, + ymd(2025, 3, 1), + "1100", + "3000", + dec!(1000), + ) + .await; + break_entry(&pool, e, seeded.accounts["4000"]).await; + let tb = balance( + &pool, + &seeded, + &period(fy26, ymd(2026, 1, 1), ymd(2026, 12, 31)), + ) + .await; + assert!(!tb.opening_balanced); +} + +async fn archive(pool: &MySqlPool, account_id: i64) { + sqlx::query("UPDATE accounts SET active = FALSE WHERE id = ?") + .bind(account_id) + .execute(pool) + .await + .expect("compte archivé"); +} + +/// Un compte archivé dont les lignes de la période **se compensent** (net nul), +/// sans ouverture : il figure — il a été mouvementé. Mutation tuée : une règle +/// d'inclusion sur le **montant** des mouvements plutôt que sur leur existence. +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn un_compte_archive_aux_mouvements_compenses_figure(pool: MySqlPool) { + let seeded = seed_accounting_company(&pool).await.expect("seed"); + let fy = seeded.fiscal_year_id; + post( + &pool, + &seeded, + fy, + ymd(2026, 3, 1), + "1000", + "1100", + dec!(100), + ) + .await; + post( + &pool, + &seeded, + fy, + ymd(2026, 3, 2), + "1100", + "1000", + dec!(100), + ) + .await; + archive(&pool, seeded.accounts["1100"]).await; + let tb = balance( + &pool, + &seeded, + &period(fy, ymd(2026, 1, 1), ymd(2026, 12, 31)), + ) + .await; + assert_eq!( + row(&tb, "1100"), + (Decimal::ZERO, dec!(100), dec!(100), Decimal::ZERO) + ); +} + +/// ⛔ Un compte archivé qui porte encore un solde figure à la balance — comptes de +/// bilan et de résultat. Mutation tuée : la règle d'inclusion d'avant (archivé = +/// seulement s'il a un mouvement dans la période) — le compte disparaîtrait, sa +/// clôture ne concorderait plus avec le bilan et le contrôle rougirait. +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn un_compte_archive_porteur_d_un_solde_figure(pool: MySqlPool) { + let seeded = seed_accounting_company(&pool).await.expect("seed"); + let (_, fy26) = reference_books(&pool, &seeded).await; + archive(&pool, seeded.accounts["1100"]).await; + archive(&pool, seeded.accounts["3000"]).await; + + // Exercice 2 entier : la banque archivée porte son report et ses mouvements. + let p = period(fy26, ymd(2026, 1, 1), ymd(2026, 12, 31)); + let tb = balance(&pool, &seeded, &p).await; + let banque = tb + .rows + .iter() + .find(|r| r.account_number == "1100") + .expect("banque archivée présente"); + assert!(!banque.active); + assert!(tb.opening_balanced); + + // Juillet : aucun mouvement de la banque ni des ventes ; les deux portent une + // ouverture non nulle et doivent figurer. + let july = period(fy26, ymd(2026, 7, 1), ymd(2026, 7, 31)); + let tb = balance(&pool, &seeded, &july).await; + assert_eq!(row(&tb, "1100").0, dec!(1700)); + assert_eq!(row(&tb, "3000").0, dec!(820)); + assert!(tb.opening_balanced); + let bs = generate_balance_sheet(&pool, seeded.company_id, &july) + .await + .expect("bilan"); + let b = bs + .assets + .iter() + .find(|a| a.account_number == "1100") + .expect("banque au bilan"); + assert_eq!(row(&tb, "1100").3, b.balance); +} diff --git a/docs/manual/fr/user-manual.pdf b/docs/manual/fr/user-manual.pdf index f68c17a33..77a65c233 100644 Binary files a/docs/manual/fr/user-manual.pdf and b/docs/manual/fr/user-manual.pdf differ diff --git a/docs/manual/fr/user-manual.tex b/docs/manual/fr/user-manual.tex index fe5ed9fcb..50deb098b 100644 --- a/docs/manual/fr/user-manual.tex +++ b/docs/manual/fr/user-manual.tex @@ -603,7 +603,7 @@ \subsection{Réouverture d'un exercice clôturé} On rouvre les exercices \textbf{du plus récent vers l'ancien} : si un exercice postérieur est encore clôturé, Kesh refuse la réouverture et indique lequel rouvrir d'abord. Effectuer cette opération avec parcimonie pour préserver l'intégrité légale. \end{keshtip} -\subsection{Reprise de comptabilité — soldes de départ} +\subsection{Reprise de comptabilité — soldes de départ}\label{sec:soldes-depart} Si vous migrez vers Kesh depuis un autre logiciel comptable, l'écran \emph{Administration} → \emph{Soldes de départ} (réservé aux rôles Comptable et Administrateur) vous permet de reprendre le bilan de clôture de votre ancienne comptabilité en une seule saisie : @@ -1556,13 +1556,38 @@ \subsection{Compte de résultat} \subsection{Balance des comptes} -\emph{Mensuel} → \emph{Rapports} → \emph{Balance}. Liste tous les comptes avec leurs soldes débiteurs et créditeurs. Filtres : période, niveau de détail (comptes principaux uniquement, ou tous sous-comptes). +\emph{Mensuel} → \emph{Rapports} → \emph{Balance}. Pour chaque compte, sur la période choisie : le \textbf{solde +d'ouverture}, les \textbf{mouvements} débit et crédit, et le \textbf{solde de clôture} (ouverture + mouvements). + +\begin{itemize} + \item \textbf{L'ouverture suit la nature du compte.} Un compte de bilan (actif, passif) reporte son solde depuis + l'origine, tous exercices confondus ; un compte de produits ou de charges repart de zéro au début de + l'exercice — son ouverture ne compte que ce qui précède la période \emph{dans l'exercice}. + \item \textbf{La balance concorde avec le bilan et le grand livre.} La clôture d'un compte de bilan est son solde au + bilan à la fin de la période ; celle d'un compte de résultat, son montant \textbf{depuis le début de l'exercice} — + égal au compte de résultat quand la période commence au premier jour de l'exercice, puisque le compte de + résultat ne porte que les mouvements de la période choisie. Ouverture et clôture sont, dans tous les cas, + celles du grand livre (\S\ref{sec:grand-livre}) sur la même période. + \item \textbf{La ligne « Résultat reporté (calculé) ».} Kesh ne passe aucune écriture de clôture : le résultat des + exercices antérieurs n'est porté par aucun compte. Une ligne calculée le montre, sous les comptes — la même + valeur qu'au bilan, « Perte reportée » à l'écran si elle est négative. + \item \textbf{Les montants sont signés selon la nature du compte} — positifs du côté naturel (débit pour un actif ou + une charge, crédit pour un passif ou un produit). La somme visible des colonnes d'ouverture et de clôture n'est + donc pas nulle ; le \textbf{contrôle} affiché sous l'ouverture (une coche) la vérifie en ramenant chaque solde au sens débit, résultat + reporté compris. Un avertissement à sa place signale une écriture déséquilibrée antérieure à la période ; le PDF + l'écrit en toutes lettres sous les totaux. + \item \textbf{Premier exercice} : les soldes de départ saisis à la reprise sont une écriture datée du premier jour + de l'exercice (\S\ref{sec:soldes-depart}) — ils apparaissent donc en \emph{mouvements}, et l'ouverture vaut zéro. + \item Un compte \textbf{archivé} figure tant qu'il porte un solde ou un mouvement. +\end{itemize} + +Seul filtre : la période. \subsection{Grand livre}\label{sec:grand-livre} \emph{Rapports} → \emph{Grand livre}. C'est l'\textbf{extrait d'un compte} : ce qui a fait son -solde, ligne à ligne. Là où le bilan dit « clients : 84'320.15 » et où la balance dit la même -chose autrement, le grand livre répond à la question qui suit toujours — \emph{de quoi ce solde +solde, ligne à ligne. Là où le bilan dit « clients : 84'320.15 » et où la balance clôt ce compte au +même nombre, le grand livre répond à la question qui suit toujours — \emph{de quoi ce solde est-il fait ?} Chaque compte y est encadré par trois lignes sans lesquelles ce n'est pas un extrait : le @@ -2040,7 +2065,7 @@ \subsection{Glossaire comptable} \item[Actif] Ce que possède l'entreprise (liquidités, créances, stocks, immobilisations). Classe 1 du plan comptable. \item[Amortissement] Constatation comptable de la perte de valeur d'une immobilisation sur sa durée d'utilisation. \item[Avoir (note de crédit)] Document qui annule tout ou partie d'une facture émise (l'inverse d'une facture). - \item[Balance des comptes] Liste de tous les comptes avec leurs soldes débiteurs et créditeurs à une date donnée. + \item[Balance des comptes] Liste des comptes avec, sur une période, leur solde d'ouverture, leurs mouvements et leur solde de clôture. \item[Bilan] État du patrimoine d'une entreprise à une date donnée (actifs = passifs + capitaux propres). \item[Capitaux propres] Fonds appartenant aux propriétaires (capital, réserves, résultat reporté). Différence entre actifs et dettes. \item[Charge] Coût supporté par l'entreprise (achats, salaires, loyer). Classes 4 à 6. diff --git a/frontend/src/lib/features/reports/TrialBalanceView.svelte b/frontend/src/lib/features/reports/TrialBalanceView.svelte index f49146447..d4375d614 100644 --- a/frontend/src/lib/features/reports/TrialBalanceView.svelte +++ b/frontend/src/lib/features/reports/TrialBalanceView.svelte @@ -1,5 +1,8 @@ @@ -24,25 +39,24 @@ {i18nMsg('reports-error-no-entries-in-period', 'Aucune écriture dans la période sélectionnée.')}

{:else} -

- {i18nMsg( - 'reports-trial-balance-period-note', - 'La balance de vérification affiche le mouvement de la période (par exercice). Le total par compte n’est pas comparable au solde cumulé du même compte au bilan (report à-nouveau depuis l’origine).', - )} -

+ - + {#each dto.rows as r (r.accountId)} - + + - + {/each} + + + + + + + + + - diff --git a/frontend/src/lib/features/reports/TrialBalanceView.test.ts b/frontend/src/lib/features/reports/TrialBalanceView.test.ts new file mode 100644 index 000000000..29af85c96 --- /dev/null +++ b/frontend/src/lib/features/reports/TrialBalanceView.test.ts @@ -0,0 +1,101 @@ +// Story 25-5-b (#385) — tests Vitest pour TrialBalanceView.svelte : ouverture, +// mouvements, clôture, la ligne calculée du résultat reporté et le contrôle +// d'ouverture. i18nMsg est mocké pour renvoyer le fallback. + +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/svelte"; + +vi.mock("$lib/shared/utils/i18n.svelte", () => ({ + i18nMsg: (_key: string, fallback: string) => fallback, +})); + +import TrialBalanceView from "./TrialBalanceView.svelte"; +import type { TrialBalanceDto } from "./reports.types"; + +function makeDto(overrides: Partial = {}): TrialBalanceDto { + return { + period: { fiscalYearId: 1, startDate: "2026-01-01", endDate: "2026-12-31" }, + rows: [ + { + accountId: 1, + accountNumber: "1100", + accountName: "Banque", + accountType: "Asset", + active: true, + openingBalance: "1000", + totalDebit: "500", + totalCredit: "0", + balance: "500", + closingBalance: "1500", + }, + ], + totalDebit: "500", + totalCredit: "500", + balanced: true, + retainedEarnings: "600", + openingBalanced: true, + ...overrides, + }; +} + +describe("TrialBalanceView — Story 25-5-b", () => { + it("affiche les colonnes Ouverture et Clôture, sans « Solde » ni la note retirée", () => { + render(TrialBalanceView, { dto: makeDto() }); + const headers = screen + .getAllByRole("columnheader") + .map((h) => h.textContent?.trim()); + expect(headers).toEqual([ + "N°", + "Intitulé", + "Ouverture", + "Débit", + "Crédit", + "Clôture", + ]); + expect(screen.queryByText(/mouvement de la période/)).toBeNull(); + }); + + it("porte l’ouverture et la clôture de chaque compte, dans l’ordre des colonnes", () => { + render(TrialBalanceView, { dto: makeDto() }); + const cells = Array.from( + screen.getByTestId("tb-row").querySelectorAll("td"), + ).map((c) => c.textContent?.replace(/\s+/g, " ").trim()); + expect(cells[2]).toMatch(/1.?000\.00/); + expect(cells[5]).toMatch(/1.?500\.00/); + }); + + it("rend la ligne calculée du résultat reporté (ouverture = clôture)", () => { + render(TrialBalanceView, { dto: makeDto() }); + const row = screen.getByTestId("tb-retained"); + expect(row.textContent).toContain("Résultat reporté (calculé)"); + const cells = Array.from(row.querySelectorAll("td")).map((c) => + c.textContent?.trim(), + ); + expect(cells[2]).toBe(cells[5]); + expect(cells[3]).toBe(""); + }); + + it("bascule sur « Perte reportée » quand le résultat reporté est négatif", () => { + render(TrialBalanceView, { dto: makeDto({ retainedEarnings: "-800" }) }); + expect(screen.getByTestId("tb-retained").textContent).toContain( + "Perte reportée", + ); + }); + + it("signale une ouverture déséquilibrée, indépendamment des mouvements", () => { + render(TrialBalanceView, { dto: makeDto({ openingBalanced: false }) }); + expect(screen.getByTestId("tb-opening-check").textContent?.trim()).toBe( + "⚠️", + ); + expect(screen.getByTestId("tb-movements-check").textContent?.trim()).toBe( + "✓", + ); + }); + + it("affiche ✓ quand l’ouverture s’équilibre", () => { + render(TrialBalanceView, { dto: makeDto() }); + expect(screen.getByTestId("tb-opening-check").textContent?.trim()).toBe( + "✓", + ); + }); +}); diff --git a/frontend/src/lib/features/reports/reports.types.ts b/frontend/src/lib/features/reports/reports.types.ts index a32ef98c8..58635615b 100644 --- a/frontend/src/lib/features/reports/reports.types.ts +++ b/frontend/src/lib/features/reports/reports.types.ts @@ -56,9 +56,14 @@ export interface TrialBalanceRow { accountName: string; accountType: AccountType; active: boolean; + /** Story 25-5-b — solde au début de la période, signé selon la nature du compte. */ + openingBalance: string; totalDebit: string; totalCredit: string; + /** Net des mouvements de la période — pas un solde de compte. */ balance: string; + /** Story 25-5-b — ouverture + mouvements : pour un compte de bilan, son solde au bilan. */ + closingBalance: string; } export interface TrialBalanceDto { @@ -67,6 +72,10 @@ export interface TrialBalanceDto { totalDebit: string; totalCredit: string; balanced: boolean; + /** Story 25-5-b — résultat reporté calculé (exercices antérieurs), crédit-positif. */ + retainedEarnings: string; + /** Story 25-5-b — Σ des ouvertures en sens débit − résultat reporté = 0. */ + openingBalanced: boolean; } export type Journal = 'Achats' | 'Ventes' | 'Banque' | 'Caisse' | 'OD'; diff --git a/frontend/src/lib/shared/i18n-keys.test.ts b/frontend/src/lib/shared/i18n-keys.test.ts index 723844b02..eb8e271ef 100644 --- a/frontend/src/lib/shared/i18n-keys.test.ts +++ b/frontend/src/lib/shared/i18n-keys.test.ts @@ -349,7 +349,14 @@ const ATTENDU = { // (+4) — la section « Différences d'arrondi » : titre, aide, libellé du // sélecteur (trois clés neuves `settings-invoicing-rounding-*` dans les quatre // catalogues) et son option vide, qui réemploie `settings-invoicing-select-none`. - sitesTotal: 1756, + // + // Story 25-5-b (#385) : **1756 → 1758**, recompté par ce test et par + // `grep -c "i18nMsg("` aux deux bornes de `reports/TrialBalanceView.svelte` + // (11 → 13) — la note « mouvement de la période » retirée (−1, clé supprimée des + // quatre catalogues), l'en-tête « Solde » remplacé par « Ouverture » et + // « Clôture » (+1, deux clés neuves `reports-column-opening` / `-closing`), et + // le libellé de la ligne calculée, qui réemploie les deux clés du bilan (+2). + sitesTotal: 1758, sitesNonResolus: 34, relais: 7, sitesGabarit: 10, diff --git a/frontend/src/lib/shared/i18n-libelle-en-dur.test.ts b/frontend/src/lib/shared/i18n-libelle-en-dur.test.ts index 3b07559da..6bc62fdeb 100644 --- a/frontend/src/lib/shared/i18n-libelle-en-dur.test.ts +++ b/frontend/src/lib/shared/i18n-libelle-en-dur.test.ts @@ -140,8 +140,15 @@ const SUFFIXES = ['Label', 'Text', 'Display']; * à `i18nMsg` (`reconciliation-cancel-confirm-invoice` / `-entry`) : aucun littéral, donc * `conforme` **37 → 38** ; `ecartee` ne bouge pas. *Identifiée par la même recherche des * trois suffixes dans les fichiers de la story — la seule déclaration neuve qui en porte un.* + * + * ⚠️ **44 → 45, et la déclaration est NOMMÉE** : `retainedLabel` + * (`lib/features/reports/TrialBalanceView.svelte`), né avec la ligne calculée du résultat + * reporté de la Story 25-5-b (#385). Ses deux branches délèguent à `i18nMsg` + * (`reports-retained-earnings-loss` / `-calculated`) : aucun littéral, donc `conforme` + * **38 → 39** ; `ecartee` ne bouge pas. *Identifiée par la même recherche des trois + * suffixes dans les fichiers de la story — la seule déclaration neuve qui en porte un.* */ -const CANDIDATES_ATTENDUES = 44; +const CANDIDATES_ATTENDUES = 45; /** Les trois délimiteurs de littéral en JS/TS. */ const QUOTES = ["'", '"', '`']; @@ -658,7 +665,7 @@ describe('libellés en dur — l’angle mort #255', () => { else if (c.retours.length > 0) classes.ecartee += 1; else classes.conforme += 1; } - expect(classes).toEqual({ nonAnalysee: 0, enViolation: 0, ecartee: 6, conforme: 38 }); + expect(classes).toEqual({ nonAnalysee: 0, enViolation: 0, ecartee: 6, conforme: 39 }); // La somme est recalculée depuis les classes, jamais depuis le total qu'elle contrôle. const somme = Object.values(classes).reduce((a, b) => a + b, 0); expect(somme).toBe(CANDIDATES_ATTENDUES); diff --git a/frontend/tests/e2e/reports.spec.ts b/frontend/tests/e2e/reports.spec.ts index b4fbb95e8..ed27bb44d 100644 --- a/frontend/tests/e2e/reports.spec.ts +++ b/frontend/tests/e2e/reports.spec.ts @@ -399,6 +399,15 @@ test('grand livre : une écriture postée réapparaît dans l’extrait de son c await page.getByRole('button', { name: /générer/i }).click(); const lien = page.getByRole('link', { name: debitNumber }).first(); await expect(lien).toBeVisible({ timeout: 5000 }); + // Story 25-5-b (#385) — la balance porte l'ouverture et la clôture, et la + // ligne calculée du résultat reporté : les champs neufs traversent la frontière. + const panel = page.getByRole('tabpanel'); + await expect(page.getByTestId('tb-col-opening')).toBeVisible(); + await expect(page.getByTestId('tb-col-closing')).toBeVisible(); + // Six colonnes : la colonne « Solde » (net des mouvements) a disparu. + await expect(panel.getByRole('columnheader')).toHaveCount(6); + await expect(page.getByTestId('tb-retained')).toBeVisible(); + await expect(page.getByTestId('tb-opening-check')).toHaveText('✓'); await lien.click(); await expect(page).toHaveURL(/tab=general-ledger/); await expect(page).toHaveURL(new RegExp(`accountId=`));
{i18nMsg('reports-column-account-number', 'N°')} {i18nMsg('reports-column-account-name', 'Intitulé')}{i18nMsg('reports-column-opening', 'Ouverture')} {i18nMsg('reports-column-debit', 'Débit')} {i18nMsg('reports-column-credit', 'Crédit')}{i18nMsg('reports-column-balance', 'Solde')}{i18nMsg('reports-column-closing', 'Clôture')}
{i18nMsg('reports-archived-label', 'archivé')}{/if} {fmt(r.openingBalance)} {fmt(r.totalDebit)} {fmt(r.totalCredit)}{fmt(r.balance)}{fmt(r.closingBalance)}
{retainedLabel}{fmt(dto.retainedEarnings)}{fmt(dto.retainedEarnings)}
{i18nMsg('reports-grand-total', 'Total général')} + {dto.openingBalanced ? '✓' : '⚠️'} + {fmt(dto.totalDebit)} {fmt(dto.totalCredit)} + {dto.balanced ? '✓' : '⚠️'}