diff --git a/CHANGELOG.md b/CHANGELOG.md index 529eb62ea..e6bdb8b2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,8 +28,12 @@ Le contenu est rédigé en français à destination des **fiduciaires, PME, ind - **Les opérations rejouées après un interblocage sont journalisées en avertissement.** Chaque opération rejouée est désormais journalisée par le serveur au niveau *avertissement*, avec le nom de l'opération (`operation`) et le numéro de la tentative ; un rejeu épuisé (interblocage encore à la dernière tentative) l'est au niveau *erreur*, avec le nom de l'opération et le nombre de tentatives. +- **Clôture et création d'un exercice : deux refus neufs pour une intégration par clé d'API ([#543](https://github.com/guycorbaz/kesh/issues/543)).** `POST /api/v1/fiscal-years/{id}/close` rend **`409 EARLIER_FISCAL_YEAR_OPEN`** (nouveau) tant qu'un exercice antérieur est ouvert — `details.fiscalYearId` / `fiscalYearName` nomment le plus ancien, à clôturer d'abord ; une intégration qui clôturait hors d'ordre reçoit désormais ce refus (la clôture est ouverte aux clés, rôle Comptable et plus). `POST /api/v1/fiscal-years` rend **`400 LATER_FISCAL_YEAR_CLOSED`** si un exercice postérieur à la date de début demandée est clôturé, avec un message propre à la création. Les deux routes rejouent un interblocage transitoire. Détail : `docs/api-external.md`. + ### Corrigé +- **Les exercices se clôturent dans l'ordre : un bilan clos ne change plus en silence ([#543](https://github.com/guycorbaz/kesh/issues/543)).** Le bilan est cumulatif depuis l'origine : toute écriture d'un exercice figure dans le bilan des suivants. Or on pouvait clôturer un exercice en laissant ouvert un exercice antérieur — ou créer un exercice antérieur à un exercice clôturé —, puis écrire dans cet exercice ouvert : le bilan clos changeait sans que rien ne le signale. **La clôture est désormais refusée tant qu'un exercice antérieur est ouvert** : à l'écran, le bouton **Clôturer** est désactivé et son infobulle nomme l'exercice à clôturer d'abord. **La création d'un exercice est refusée** si un exercice postérieur est déjà clôturé. Avec la réouverture, qui allait déjà du plus récent vers l'ancien, aucun exercice ouvert ne peut plus précéder un exercice clôturé **à partir d'une installation saine**. Le refus de modifier ou de supprimer une écriture sous un exercice postérieur clos dit maintenant comment s'en sortir — rouvrir les exercices clôturés **en commençant par le plus récent**, et non celui qu'il nomme, que la réouverture refuserait. ⚠️ Une installation mise à jour ou une sauvegarde restaurée peut porter cet état, hérité d'une version antérieure : il se résorbe en clôturant le plus ancien exercice ouvert. + - **Un compte non imputable est refusé partout où un client le désigne pour recevoir des écritures ([#427](https://github.com/guycorbaz/kesh/issues/427), [#429](https://github.com/guycorbaz/kesh/issues/429)).** Les écrans filtraient ces comptes, mais le serveur ne les contrôlait pas : un appel direct — par clé d'API ou par une intégration — pouvait rapprocher une transaction bancaire sur **9000 Bilan d'ouverture**, et fausser le résultat de l'exercice et le report à nouveau. Sont désormais refusés en `400 ACCOUNT_NOT_POSTABLE`, qui nomme le compte : le **rapprochement** manuel et ventilé, l'acceptation d'une proposition ventilée ou par règle (dans `failed[]`, sans bloquer le reste du lot) ; les **règles d'affectation** — création, changement de compte, et **réactivation** d'une règle dont le compte n'est plus imputable ; le **compte comptable d'un compte bancaire**, à la création, ou quand on le change. Une règle dont le compte est devenu non imputable — par exemple scindé en sous-comptes — **n'est plus proposée** ; la règle suivante qui correspond l'est à sa place. Les **réglages de facturation** refusent un compte non imputable **au moment où on le désigne** (créance, produit, TVA due, TVA récupérable, décompte TVA, créanciers), avec un message qui nomme le champ. ⚠️ **Limites** : un compte déjà désigné ou déjà lié, devenu non imputable après coup, reste accepté — on peut enregistrer les réglages, modifier le compte bancaire ou renommer la règle sans le changer. La validation d'une facture et la saisie (ou la complétion) d'une facture fournisseur refusent désormais un compte de réglage devenu non imputable (entrée ci-dessous) ; **restent** hors de cette garde l'avoir, le compte de produit par défaut et le compte comptable d'un compte bancaire déjà lié. L'écran des règles affichait « [object Object] » au lieu du message d'un refus : il affiche le message. Sur l'écran des comptes bancaires, un compte lié devenu non imputable s'affichait vide dans les formulaires de modification et de lien : il reste affiché. - **Un compte de réglage devenu non imputable n'est plus utilisé par les écritures automatiques ([#429](https://github.com/guycorbaz/kesh/issues/429)).** La créance et la TVA due, désignées dans *Paramètres → Facturation*, recevaient l'écriture de chaque facture validée même devenues non imputables — après la création de sous-comptes, la case *imputable* décochée ou le rôle « résultat de l'exercice » attribué —, comme les créanciers et la TVA récupérable pour chaque facture fournisseur. La **validation d'une facture** et la **saisie (ou la complétion) d'une facture fournisseur** les contrôlent désormais au moment d'écrire, et refusent avec un message qui nomme le compte, renvoie à *Paramètres → Facturation* et dit qu'un **administrateur** doit y désigner un compte imputable — la page des réglages lui est réservée. La TVA due (ou récupérable) n'est contrôlée que si la pièce en porte. L'**avoir** n'est pas soumis à ce contrôle, délibérément — une facture émise reste annulable : il crédite la créance que la facture a débitée (entrée ci-dessous, [#473](https://github.com/guycorbaz/kesh/issues/473)), et relit encore la TVA due dans les réglages du moment, ce que corrigera [#525](https://github.com/guycorbaz/kesh/issues/525). ⚠️ **Après la mise à jour, vérifiez dans *Paramètres → Facturation* que les comptes désignés sont imputables** : un compte devenu non imputable (sous-comptes créés, case *imputable* décochée…) doit y être remplacé, faute de quoi la validation des factures (ou la saisie des factures fournisseurs) est refusée. *(API : `400 ACCOUNT_NOT_POSTABLE`, `details.rejected[{accountId, accountNumber}]`, sur `POST /invoices/{id}/validate`, `POST /supplier-invoices` et `POST /imported-supplier-invoices/{id}/complete` — après tous les autres refus, exercice compris.)* diff --git a/_bmad-output/implementation-artifacts/15-12-cloture-dans-l-ordre.md b/_bmad-output/implementation-artifacts/15-12-cloture-dans-l-ordre.md new file mode 100644 index 000000000..972721239 --- /dev/null +++ b/_bmad-output/implementation-artifacts/15-12-cloture-dans-l-ordre.md @@ -0,0 +1,108 @@ +# Story 15.12 : Clôturer les exercices dans l'ordre — et ne plus écrire sous un bilan clos — fiche index, découpée en 15-12a et 15-12b + +Status: split + + + +**Issue** : #543 (P1) — **fermée par la 15-12b** ; la 15-12a porte `refs #543`. + +## Découpage + +| fiche | contenu | issue | dépend de | +|---|---|---|---| +| **15-12a** — `15-12a-cloture-dans-l-ordre.md` | **L'ordre** : l'invariant I (les exercices clos forment un préfixe) et ses trois transitions — clôture refusée tant qu'un antérieur est ouvert (`409 EARLIER_FISCAL_YEAR_OPEN`), création refusée sous un postérieur clos (`400 LATER_FISCAL_YEAR_CLOSED`), réouverture LIFO inchangée ; ordre des verrous de `close` tenu par le code — antérieurs verrouillés un par un par clé primaire, puis relecture verrouillante (C119, qui révise C110) ; rejeu des deux routes et preuve HTTP de l'enveloppe (C109) ; **message neutre** de `LATER_FISCAL_YEAR_CLOSED` et ancienne clé alignée (AC 9, C111) ; tests de concurrence à deux connexions ; bouton « Clôturer » désactivé ; E2E ; doc de la clôture. AC 1-7, 9, 13, 14, 17, 22 et la part A des AC 19, 21, 23 — 15 AC, 11 tâches | refs #543 | 15-8a, 15-8b, 15-5e1/e2 (mergées) | +| **15-12b** — `15-12b-filet-sous-un-bilan-clos.md` | **Le filet** pour les données héritées : `LATER_FISCAL_YEAR_CLOSED` aux deux points de passage du journal (`create_in_tx_inner` sans verrou, `delete_in_tx` sans condition — la dévalidation), inventaire fermé des écrivains, lot de rapprochement aux **trois** voies (C108), bandeau d'état hérité et réparation dans l'ordre LIFO, autres écrans et prédicteurs (angle mort, C120), doc du filet. AC 8, 10, 11, 12, 15, 16, 18, 20 et la part B des AC 19, 21, 23 — 11 AC, 9 tâches | **closes #543** | **15-12a** | + +**Numérotation** : les AC gardent leur numéro de la 15-12 dans les deux fiches (références croisées +stables) ; un AC partagé porte « part A » / « part B ». Les tâches sont renumérotées par fiche, chacune +avec son origine (« ex-T4 »). + +**Recompte aux deux bornes** (commandes : `sed -n '/^## Acceptance/,/^## Tasks/p' | grep -oE +'^ ?[0-9]{1,2}\. \*\*'` et `grep -cE '^- \[ \] \*\*T[0-9]+' `) — avant (`dae3a618`) : **23 AC, +14 tâches** ; après : 15-12a **15 AC, 11 tâches**, 15-12b **11 AC, 9 tâches**. 15 + 11 = 26 = 23 + 3 AC +partagés (19, 21, 23) ; 11 + 9 = 20 = 14 + 6 tâches présentes dans les deux fiches (ex-T0, T7, T9, T11, +T12, T13), l'ex-T4 étant répartie (le message dans la T3 de la 15-12a, le filet dans la T1 de la 15-12b). +Mutations : (i)-(iv), (viii), (ix) et la neuve (x) dans la 15-12a ; (v), (vi), (vii-a), (vii-b) dans la +15-12b. + +**Ordre écrit** (C112, mis à jour au découpage de la 15-1a, C125) : **15-12a → 15-12b → 15-1a-i → 15-1a-ii** (puis 15-1b, 15-1c). Le **prérequis réel** de la +15-1a est la **15-12a** (l'invariant I, la clôture rejouée) ; la 15-12b passe avant de préférence, les +deux touchant `journal_entries::delete_in_tx`. ⚠️ La v0.13.0 ne se tague pas sans la 15-12b (#543, P1 ; +et le message neutre de la 15-12a n'est vrai de l'état hérité qu'avec le filet). + +## Change Log + +- 2026-10-08 — Création (bmad-create-story, en autonomie ; choix C89). 23 AC, 14 tâches (T0-T13). + Inventaires : 22 routes `Rejouee` + 2 `Exemptee` de production au registre, ramenées à 3 points de + passage (19 / 1 / 2, plus 2 hors filet) ; sources de l'état fautif : clôture hors ordre, création + d'un exercice antérieur à un exercice clos, restauration d'une sauvegarde (réouverture : garde LIFO ; + démo, graine de test et `onboarding::finalize` : un seul exercice, sans risque). Dérogation au + découpage écrite (8 modules), coupe de repli 15-12a/15-12b. +- 2026-10-09 — **Validation P1** (deux lentilles Opus en parallèle, contexte frais : R auditeur + d'acceptation, F adversaire plein périmètre ; rapports `target/gate-logs/15-12-p1-R.md` et + `…-p1-F.md`). Bruts : R 1 HIGH / 3 MEDIUM / 11 LOW, F 0 / 5 MEDIUM / 7 LOW ; après dédoublonnage + (R1=F1, R2=F2, R3=F3, R4=F6 ; parmi les LOW R8=F9, R11≈F11, R13≈F10, R15=F8) : **1 HIGH, 4 MEDIUM, + 14 LOW distincts**, tous d'origine, **aucun recyclé**. Remédiation (C100) : **HIGH R1=F1** — la + lecture verrouillante de la clôture ne compte que dans la course réouverture/clôture : AC 13 réécrit + (13 a ordonné, réouverture de N en cours contre `close(L)`, tue la mutation (ii) sur l'état final ; + ancien 13 c retiré), preuve des Dev Notes refondue en (α)/(β), AC 8 et AC 19 alignés ; **R2=F2** — + 13 c (ex-13 d) rendu déterministe par un ordre forcé ; **R3=F3** — bandeau : clôturer d'abord + l'exercice ouvert le plus ancien, sinon rouvrir à partir du plus récent clos, jamais `{closed}` en + premier ; message de l'AC 9 neutre (F12), réparation directe ajoutée à l'AC 16 ; **F4** — « toutes + les autres acquisitions sont ascendantes » retiré (AC 3, AC 14) ; ordre réel de la contre-passation + et des quatre annulations écrit, cycle avec la clôture résolu par le rejeu, test 13 d et mutation + (ix) ; **R4=F6** — AC 23 : inventaire par recherche des tableaux et listes de refus + d'`api-external.md` (dévalidation et son décompte `:311`, `:321-391`, `:355`, `:363`, `:371`, + `:484`) et du manuel (liste de la dévalidation `:1314-1337`) ; **LOW** : précédence de la + dévalidation (R5), commande de recompte bornée et vérifiée (R6 : 22 / 2), sites dynamiques et + `delete_all_by_company` tranché (R7), doc-comment du registre (R8/F9), ancienne clé gardée (R9), clé + du lot tranchée (R10), AC 18 par les appelants avec la boîte de validation d'une facture (R11/F11), + contre-passation par la route (R12), suspects de `src/` et base partagée (R13/F10), test HTTP de la + création (R14), dérogation recomptée — 11 modules — et réargumentée sans la preuve réfutée, + déclencheur de repli élargi (R15/F8), 13 b mesuré dans les deux configurations (F5), restauration en + vol écrite comme angle mort (F7). Story **non découpée** (C100 : la coupe ne ramène aucune moitié + sous le seuil — 7 et 8 modules) ; signal déclaré au Project Lead. Toujours 23 AC, 14 tâches + (recomptés). Passe suivante : P2 complète (Sonnet, rotation D6). +- 2026-10-09 — **Validation P2** (Sonnet ×2, contexte frais : R chasseur de régressions, F adversaire + plein périmètre ; rapports `target/gate-logs/15-12-p2-R.md` et `…-p2-F.md`). Bruts : R 0 / 0 / 2 + MEDIUM / 5 LOW, F 0 / 0 / 3 MEDIUM / 8 LOW ; après dédoublonnage (R1 = F1, R5 = F6, R7 = F9, R3 + rattaché à F3) : **0 CRITICAL, 0 HIGH, 4 MEDIUM, 10 LOW distincts**. Les MEDIUM sont d'origine ; deux + LOW sont des résidus de la remédiation P1 (R4, F7). Trend : P1 1 HIGH / 4 MEDIUM → P2 0 HIGH / 4 + MEDIUM. **Story DÉCOUPÉE** (C107, décision de l'orchestrateur sur F3 + R3) en 15-12a / 15-12b, la + remédiation de chaque finding portée dans la fiche qui convient (C108 à C112 ; détail aux Change Logs + des deux fiches). F7 (« 8 modules » en tête de la ligne du registre) : ligne réécrite au découpage. + Point rendu à l'orchestrateur pour la 15-1a (fiche non modifiée) : R6/F11, C112. + **Passe suivante : P3 complète (Opus ×2, rotation D6) sur les DEUX fiches** — la découpe redistribue + tout le texte (une omission ou une dépendance A → B résiduelle ne se voit qu'en relisant les deux), et + la remédiation change des règles (lot à trois voies, `ORDER BY` d'une requête de production, texte du + refus, preuve de l'enveloppe) : la passe ciblée ne suffit pas (§ *La passe ciblée*). +- 2026-10-09 — **Validation P3** (Opus ×2, contexte frais, sur les **deux** fiches ; rapports + `target/gate-logs/15-12-p3-R.md` et `…-p3-F.md`). Bruts : R 0 / 0 / 1 MEDIUM / 8 LOW, F 0 / 0 / 3 + MEDIUM / 8 LOW ; après dédoublonnage (R1 = F3, R3 rattaché à F2) : **0 CRITICAL, 0 HIGH, 3 MEDIUM, 15 + LOW distincts**. Le découpage ne perd aucun AC, aucune tâche ni aucune mutation, et aucune dépendance + A → B résiduelle n'a été trouvée (R). ⚠️ **Deux des trois MEDIUM sont nés de la remédiation P2** (F2 : + le « par construction » de C110 ; R1 = F3 : l'inventaire par une forme ajouté en P2) — motif du + `CLAUDE.md`, « la sévérité se déplace vers ce qu'on vient d'écrire ». Trend : P1 1 HIGH / 4 MEDIUM → P2 + 0 HIGH / 4 MEDIUM → P3 0 HIGH / 3 MEDIUM. Remédiation (C119 à C123) : **F2** — clôture à verrous un par + un par clé primaire et relecture verrouillante, `FORCE INDEX` écarté, `OPEN_COVERING_DATE_SQL` sans + `ORDER BY` (C119, révise C110 ; **à reporter sur C114** de la 15-1a) ; **R1 = F3** — inventaire de + l'AC 21 part B par le symptôme, un seul site change de sens ; **F1** — prédicteurs d'annulation : angle + mort assumé, issue (C120) ; **F9** — dérogation au découpage écrite dans la forme codifiée, acceptée par + l'orchestrateur (C121) ; **C117** reçu de la 15-1a, écrit à la frontière de la 15-12b ; LOW traités + (détail aux Change Logs des deux fiches). Signal de découpage : la sévérité baisse (4 → 3 MEDIUM), mais + deux MEDIUM naissent d'un correctif — le **recyclage** que l'amendement D5 nomme comme déclencheur. + **Déclaré au Project Lead** ; non suivi, la dérogation étant acceptée (C121) — une troisième coupe + ferait des stories intestables seules ; les deux défauts recyclés portent sur un inventaire et une + formule, non sur une règle métier qui ne converge pas. Comptes : 15-12a **15 AC / 11 + tâches**, 15-12b **11 AC / 9 tâches**, recomptés par la commande ci-dessus. + **Passe suivante : P4** — **complète sur la 15-12a** (Sonnet ×2, rotation D6) : C119 change le + protocole de verrouillage de la clôture, cœur de la fiche, et § *La passe ciblée* exclut la passe + ciblée quand la remédiation change une règle ; **ciblée sur la 15-12b** (une lentille, Haiku admis par + D6), braquée sur le seul diff de cette remédiation (AC 11, 12, 18, 21, Dev Notes, dérogation), qui ne + change aucune règle du filet. diff --git a/_bmad-output/implementation-artifacts/15-12-validate-prompt-p1.md b/_bmad-output/implementation-artifacts/15-12-validate-prompt-p1.md new file mode 100644 index 000000000..99838ba03 --- /dev/null +++ b/_bmad-output/implementation-artifacts/15-12-validate-prompt-p1.md @@ -0,0 +1,35 @@ +# Prompt — validation P1 de la spec, Story 15-12 + +*Versionné le 2026-10-08. Deux lentilles (Opus), contexte frais chacune, en lecture seule. Rotation (décision D6 de la +rétro 25) : passes complètes Sonnet ↔ Opus ; Haiku réservé à une passe ciblée de fin de boucle.* + +Dépôt `/home/gcorbaz/devel/kesh`, branche `story/15-5-gardes-postabilite-serveur`. **Fiche** : `_bmad-output/implementation-artifacts/15-12-cloture-dans-l-ordre.md`. C'est la première passe : la fiche n'a pas encore été remédiée. +Issues, par `gh api repos/guycorbaz/kesh/issues/N` (et `/comments`) — `gh issue view` échoue ici : #543. +Choix autonomes : `_bmad-output/implementation-artifacts/epic-15-choix-autonomes.md`. Règles : `CLAUDE.md`. Fiche 029f023c (choix C89). La branche de planification est en RETARD sur main : lis le CODE sur origin/main (git show origin/main:) ou dans le worktree /home/gcorbaz/devel/kesh-15-6a (à jour, lecture seule). Attaquer en priorité : (1) la preuve du filet SANS verrou de l'AC8 — cherche un écrivain qui ne tient pas le verrou de son exercice, ou une écriture de fiscal_years.status hors close/reopen (grep au code) ; (2) l'AC 13 b : la course création/clôture — l'interblocage annoncé est-il réel (ordre des verrous lu au code) ? ; (3) la table de l'AC12 : toute écriture SQL sur journal_entries hors tests passe-t-elle par create_in_tx_inner / update / delete_in_tx ? (inventaire des sites NON résolus) ; (4) le motif 'ORDER BY start_date ASC … FOR UPDATE' qui risque de confondre les tests de concurrence de la 15-8a ; (5) les 10 sites frontend qui réagissent à FISCAL_YEAR_CLOSED (AC18) ; (6) la dérogation au découpage (8 modules) ; (7) le bandeau pour les installations déjà fautives et le sens de la réparation (rouvrir le plus récent clos). +Aucun code de la story n'est encore écrit. + +## Lentilles + +- **R — Auditeur d'acceptation** : applique `.claude/skills/bmad-create-story/checklist.md`. Chaque phrase des issues couverte par un AC testable, chaque AC par une tâche, chaque tâche par un test ; implémentable sans deviner (fonctions, fichiers, codes d'erreur, clés i18n 4 locales, replis Svelte) ; faits cités revérifiés au code (`grep -nF`) ; recompte des AC et tâches. +- **F — Full-scope adversary** : la conception elle-même. Les chemins de code qui écrivent ou lisent ce que la story + change (inventorier les sites NON résolus, pas énumérer ceux qui marchent) ; transactions, verrous, ordre des + contrôles ; tests existants qui changeront de sens ; frontend qui lit les codes ou champs touchés (`grep -rn` dans + `frontend/src`) ; i18n 4 locales et replis ; le **manuel** (`docs/manual/fr/*.tex` **et le PDF aplati** : + `pdftotext docs/manual/fr/user-manual.pdf - | tr '\n' ' ' | tr -s ' '` vers `target/gate-logs/`) ; + `docs/api-external.md` ; CHANGELOG ; la règle de découpage (§ « Règle de splitting préventif », amendement D5). + +## Ce que tu rends + +Findings numérotés avec sévérité (CRITICAL/HIGH/MEDIUM/LOW), `fichier:ligne` (fiche et code), **preuve** (commande et +sortie, ou code cité relu), correction proposée. 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** — un « 0 finding » sans elle ne compte pas. +⛔ **Écris ton rapport complet** dans `target/gate-logs/15-12-p1-.md` (R ou F ; autorisé), et rends +comme dernier message **seulement** : ce chemin, le bilan par sévérité, et une ligne par finding MEDIUM ou plus. + +## 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`/`stash`, +`sqlx`, `cargo test`/`nextest`/`build`, `npm run`, `npx`, `gh issue create`/`comment`/`edit`, aucune requête SQL +d'écriture. Autorisés : lecture, `grep`, `sed -n`, `git log`/`show`/`diff`, `gh api` en lecture, `pdftotext` +vers `target/gate-logs/`. diff --git a/_bmad-output/implementation-artifacts/15-12-validate-prompt-p2.md b/_bmad-output/implementation-artifacts/15-12-validate-prompt-p2.md new file mode 100644 index 000000000..93fb96092 --- /dev/null +++ b/_bmad-output/implementation-artifacts/15-12-validate-prompt-p2.md @@ -0,0 +1,35 @@ +# Prompt — validation P2 de la spec, Story 15-12 + +*Versionné le 2026-10-09. Deux lentilles (Sonnet), contexte frais chacune, en lecture seule. Rotation (décision D6 de la +rétro 25) : passes complètes Sonnet ↔ Opus ; Haiku réservé à une passe ciblée de fin de boucle.* + +Dépôt `/home/gcorbaz/devel/kesh`, branche `story/15-5-gardes-postabilite-serveur`. **Fiche** : `_bmad-output/implementation-artifacts/15-12-cloture-dans-l-ordre.md`. La dernière remédiation est le commit `65e4a0f0` (`git show 65e4a0f0`) — lis le Change Log de la fiche. +Issues, par `gh api repos/guycorbaz/kesh/issues/N` (et `/comments`) — `gh issue view` échoue ici : #543. +Choix autonomes : `_bmad-output/implementation-artifacts/epic-15-choix-autonomes.md`. Règles : `CLAUDE.md`. La branche de planification est en RETARD sur main (main = 8f9811d8 : 15-5e1, 15-5e2, 15-8a/b, 15-11a mergées) : lis le CODE sur origin/main (git show origin/main:). La P1 a réécrit (C100) : AC13 entier (13 a ordonné avec une réouverture simulée NON validée — représente-t-elle fidèlement reopen au code ? ; 13 c déterministe ; 13 d à deux connexions et sa faisabilité), la preuve (α)/(β) de l'AC8 et l'angle mort de la restauration, l'AC3 (ordre réel des verrous : contre-passation et annulations verrouillent l'exercice d'origine puis balaient depuis le premier), l'AC6 (la clôture est enveloppée dans le rejeu — vérifie au registre des routes de main qu'elle ne l'est pas déjà), le bandeau AC15 et la réparation AC16 face à la garde LIFO, l'inventaire AC23 (api-external.md et manuel, .tex ET PDF aplati), l'AC18 par les appelants frontend, et la nouvelle dérogation au découpage (11 modules ; 7 et 8 après la coupe 15-12a/b). +Aucun code de la story n'est encore écrit. + +## Lentilles + +- **R — Regression hunter** : la dernière remédiation (`git show 65e4a0f0`) a-t-elle introduit des défauts ? Contradictions entre AC, tâches, Dev Notes et choix du registre ; frontière avec les fiches sœurs ; faits recopiés sans vérification (numéros de ligne, fonctions, codes, clés i18n) — revérifie-les au code (`grep -nF`) ; recompte des AC et tâches ; implémentable sans deviner ? +- **F — Full-scope adversary** : la conception elle-même. Les chemins de code qui écrivent ou lisent ce que la story + change (inventorier les sites NON résolus, pas énumérer ceux qui marchent) ; transactions, verrous, ordre des + contrôles ; tests existants qui changeront de sens ; frontend qui lit les codes ou champs touchés (`grep -rn` dans + `frontend/src`) ; i18n 4 locales et replis ; le **manuel** (`docs/manual/fr/*.tex` **et le PDF aplati** : + `pdftotext docs/manual/fr/user-manual.pdf - | tr '\n' ' ' | tr -s ' '` vers `target/gate-logs/`) ; + `docs/api-external.md` ; CHANGELOG ; la règle de découpage (§ « Règle de splitting préventif », amendement D5). + +## Ce que tu rends + +Findings numérotés avec sévérité (CRITICAL/HIGH/MEDIUM/LOW), `fichier:ligne` (fiche et code), **preuve** (commande et +sortie, ou code cité relu), correction proposée. 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** — un « 0 finding » sans elle ne compte pas. +⛔ **Écris ton rapport complet** dans `target/gate-logs/15-12-p2-.md` (R ou F ; autorisé), et rends +comme dernier message **seulement** : ce chemin, le bilan par sévérité, et une ligne par finding MEDIUM ou plus. + +## 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`/`stash`, +`sqlx`, `cargo test`/`nextest`/`build`, `npm run`, `npx`, `gh issue create`/`comment`/`edit`, aucune requête SQL +d'écriture. Autorisés : lecture, `grep`, `sed -n`, `git log`/`show`/`diff`, `gh api` en lecture, `pdftotext` +vers `target/gate-logs/`. diff --git a/_bmad-output/implementation-artifacts/15-12-validate-prompt-p3.md b/_bmad-output/implementation-artifacts/15-12-validate-prompt-p3.md new file mode 100644 index 000000000..718e5bd8d --- /dev/null +++ b/_bmad-output/implementation-artifacts/15-12-validate-prompt-p3.md @@ -0,0 +1,35 @@ +# Prompt — validation P3 de la spec, Story 15-12 + +*Versionné le 2026-10-09. Deux lentilles (Opus), contexte frais chacune, en lecture seule. Rotation (décision D6 de la +rétro 25) : passes complètes Sonnet ↔ Opus ; Haiku réservé à une passe ciblée de fin de boucle.* + +Dépôt `/home/gcorbaz/devel/kesh`, branche `story/15-5-gardes-postabilite-serveur`. **Fiche** : `_bmad-output/implementation-artifacts/15-12-cloture-dans-l-ordre.md`. La dernière remédiation est le commit `9b403aaf` (`git show 9b403aaf`) — lis le Change Log de la fiche. +Issues, par `gh api repos/guycorbaz/kesh/issues/N` (et `/comments`) — `gh issue view` échoue ici : #543. +Choix autonomes : `_bmad-output/implementation-artifacts/epic-15-choix-autonomes.md`. Règles : `CLAUDE.md`. **La story vient d'être découpée** : l'index ci-dessus renvoie à `15-12a-cloture-dans-l-ordre.md` (l'ordre) et `15-12b-filet-sous-un-bilan-clos.md` (le filet) — relis LES DEUX fiches ensemble. Choix C100, C107 à C112. Axes prioritaires : rien n'est perdu au découpage (recompte AC/tâches/tests/mutations aux deux bornes contre `git show dae3a618:_bmad-output/implementation-artifacts/15-12-cloture-dans-l-ordre.md`) ; aucune dépendance résiduelle de A vers B (A livrable et testable seule) ; le lot de rapprochement à trois voies (`accept_one_rule`) ; `ORDER BY start_date` dans `OPEN_COVERING_DATE_SQL` et les verrous de clé suivante en REPEATABLE READ (étape (b)) ; la preuve HTTP du rejeu ; le texte du refus et ses 5 sites ; le manuel, PDF aplati compris. +Aucun code de la story n'est encore écrit. + +## Lentilles + +- **R — Regression hunter** : la dernière remédiation (`git show 9b403aaf`) a-t-elle introduit des défauts ? Contradictions entre AC, tâches, Dev Notes et choix du registre ; frontière avec les fiches sœurs ; faits recopiés sans vérification (numéros de ligne, fonctions, codes, clés i18n) — revérifie-les au code (`grep -nF`) ; recompte des AC et tâches ; implémentable sans deviner ? +- **F — Full-scope adversary** : la conception elle-même. Les chemins de code qui écrivent ou lisent ce que la story + change (inventorier les sites NON résolus, pas énumérer ceux qui marchent) ; transactions, verrous, ordre des + contrôles ; tests existants qui changeront de sens ; frontend qui lit les codes ou champs touchés (`grep -rn` dans + `frontend/src`) ; i18n 4 locales et replis ; le **manuel** (`docs/manual/fr/*.tex` **et le PDF aplati** : + `pdftotext docs/manual/fr/user-manual.pdf - | tr '\n' ' ' | tr -s ' '` vers `target/gate-logs/`) ; + `docs/api-external.md` ; CHANGELOG ; la règle de découpage (§ « Règle de splitting préventif », amendement D5). + +## Ce que tu rends + +Findings numérotés avec sévérité (CRITICAL/HIGH/MEDIUM/LOW), `fichier:ligne` (fiche et code), **preuve** (commande et +sortie, ou code cité relu), correction proposée. 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** — un « 0 finding » sans elle ne compte pas. +⛔ **Écris ton rapport complet** dans `target/gate-logs/15-12-p3-.md` (R ou F ; autorisé), et rends +comme dernier message **seulement** : ce chemin, le bilan par sévérité, et une ligne par finding MEDIUM ou plus. + +## 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`/`stash`, +`sqlx`, `cargo test`/`nextest`/`build`, `npm run`, `npx`, `gh issue create`/`comment`/`edit`, aucune requête SQL +d'écriture. Autorisés : lecture, `grep`, `sed -n`, `git log`/`show`/`diff`, `gh api` en lecture, `pdftotext` +vers `target/gate-logs/`. diff --git a/_bmad-output/implementation-artifacts/15-12a-cloture-dans-l-ordre.md b/_bmad-output/implementation-artifacts/15-12a-cloture-dans-l-ordre.md new file mode 100644 index 000000000..f285a6979 --- /dev/null +++ b/_bmad-output/implementation-artifacts/15-12a-cloture-dans-l-ordre.md @@ -0,0 +1,1354 @@ +# Story 15.12a : Clôturer les exercices dans l'ordre — l'invariant, ses trois transitions, son écran + +Status: done + + + + +**Issues** : **refs #543** (P1 — « on peut écrire dans un exercice ouvert alors qu'un exercice postérieur +est déjà clos — le bilan clos change en silence »). Cette fiche rend l'état fautif **inatteignable** à +partir d'un état sain ; le **filet** pour les données qui le portent déjà est la **15-12b**, qui +**ferme** #543. Mot-clé dans la PR (`refs #543`), le dépôt merge en squash. + +**Dépend de** la 15-8a et de la 15-8b (`DbError::LaterFiscalYearClosed`, code +`LATER_FISCAL_YEAR_CLOSED`, `find_later_closed_in_tx` / `find_later_closed`, garde du `PUT` et du +`DELETE`) et de la 15-5e1/15-5e2 (enveloppes `kesh_db::retry` / `kesh_api::retry`, registre à deux +colonnes). **Toutes mergées.** + +**Bloque** : la **15-12b** (elle lit `EarlierFiscalYearOpen`, la clé du message de l'AC 9, la clôture +ordonnée pour la réparation de l'AC 16) ; la **15-1a**, dont c'est le **prérequis réel** (C112). + +**Touche des fichiers que d'autres stories en vol touchent** : `docs/manual/fr/user-manual.tex` et +`admin-manual.{tex,pdf}` (la 15-11a, mergée, a ajouté 195 lignes au manuel administrateur et régénéré son +PDF — la branche de planification est en retard sur `main`), `CHANGELOG.md` (rubriques de `[0.13.0]`), +`crates/kesh-db/src/repositories/journal_entries.rs` (ici : le seul doc-comment de `reverse_in_tx_inner`, +AC 14), et la 15-12b (mêmes fichiers). En cas de rebase : garder les deux côtés, **régénérer** les PDF +après le rebase (conflit binaire). + +## Story + +En tant que **comptable** qui clôture ses exercices, +je veux que **Kesh refuse de clôturer un exercice tant qu'un exercice antérieur est encore ouvert**, et +refuse de créer un exercice antérieur à un exercice déjà clôturé, +afin que **l'état « exercice ouvert suivi d'un exercice clôturé » ne puisse plus être atteint** — le +bilan est cumulatif depuis l'origine (`crates/kesh-report/src/balance_sheet.rs:8-11`) : toute écriture de +N figure dans le bilan de N+1. + +### Le défaut, établi au code + +- `fiscal_years::close` (`crates/kesh-db/src/repositories/fiscal_years.rs:773`) est un `UPDATE … SET + status = 'Closed' WHERE id = ? AND status = 'Open'` : il ne regarde **aucun** autre exercice. Clôturer + 2027 avec 2026 ouvert est accepté. +- `fiscal_years::create` (`:156`) ne contrôle que le chevauchement et le nom : créer 2026 **après** avoir + clôturé 2027 donne un exercice ouvert antérieur à un exercice clos. +- La réouverture (`reopen`, `:872`) a déjà sa garde LIFO (Story 14-2) : elle ne crée pas l'état fautif. +- Dans l'état « N ouvert, N+1 clos », dix-neuf routes écrivent dans N sans contrôle : c'est l'objet de + la **15-12b**. + +### Conception retenue (C89, C100, C107) + +- **Invariant I** : *pour chaque société, les exercices clôturés forment un préfixe de l'ordre + chronologique* — aucun exercice ouvert ne précède un exercice clôturé. +- **Clôture** : refusée tant qu'un exercice antérieur est ouvert (le préfixe ne s'allonge que par son + premier exercice ouvert). C'est le cœur : elle ferme tous les flux d'un coup pour toute installation + qui part d'un état sain. +- **Création** : refusée si un exercice postérieur à la date de début demandée est clôturé. +- **Réouverture** : garde LIFO existante (le préfixe ne raccourcit que par son dernier exercice). + +⚠️ **La portée de l'invariant, déclarée (F11, C112)** : I est vrai de tout état **obtenu par ces trois +transitions à partir d'un état sain**. Il ne l'est **pas** des installations qui portent déjà l'état +fautif — données d'une v0.12.x, restauration d'une sauvegarde (`admin::full_import`) — ni d'un état +écrit hors de `close` / `reopen` (SQL direct, future transition). Ces états-là ne repassent par aucune +transition et ne peuvent que se résorber (clôture du plus ancien ouvert, réouvertures LIFO). Tout +consommateur de l'invariant doit **tolérer** l'état hérité ou l'écrire comme limite : le filet de la +15-12b couvre les écritures du journal (création, suppression), **pas** le lettrage de la 15-1a ni la +vue « au » d'une date de la 15-1b. + +Écartées (détail en C89) : refuser seulement l'écriture sans ordonner la clôture ; une détection au +démarrage ; refuser d'importer une sauvegarde fautive ; une migration qui « répare ». + +### Frontière avec la 15-12b + +La 15-12b généralise la garde `LATER_FISCAL_YEAR_CLOSED` aux deux points de passage du journal +(`create_in_tx_inner`, `delete_in_tx`), au lot de rapprochement, et signale l'état hérité à l'écran (bandeau, +réparation). **Rien ici ne lit ni ne cite le filet comme existant** : les doc-comments de l'AC 7 et de +l'AC 14 nomment ce qui contourne l'invariant ; la 15-12b les complète en y décrivant son filet. Livrée +seule, la 15-12a est complète et testable : dans un état sain, l'état fautif est inatteignable ; dans un +état hérité, le comportement des écritures est celui de `main` (seuls `PUT` et `DELETE` refusent). +⚠️ **Publication** : **la v0.13.0 ne se tague pas sans la 15-12b** (qui ferme #543, P1). Le message neutre +de l'AC 9 ne nomme, depuis la revue P1 (B-1, C-15-12a-3), que ce que le code de la 15-12a garde — la +modification et la suppression d'une écriture ; la 15-12b l'élargira à la saisie avec sa garde. + +### Frontière avec la 15-1a (R6, F11, C112) + +- La 15-12a est le **prérequis réel** de la 15-1a : elle tient l'invariant I sur lequel reposent la + règle des exercices du lettrage et la vue « au » d'une date. +- **La clôture devient rejouée** (AC 6) : la 15-1a, qui la disait « non rejouée », a été corrigée par sa + propre remédiation P2 (elle dit désormais que la 15-12a l'enveloppe — `15-1a-socle-lettrage.md`, § des + verrous, points 2 et 3, relu au 2026-10-09). Le **principe** que la 15-1a en tire (verrouiller les exercices en mode + `Manual` dans l'ordre chronologique, comme la clôture ; aucun verrou d'exercice en mode `System`) + **reste juste** : l'ordre ascendant évite le cycle, le rejeu ne fait que l'absorber s'il survient. + ⚠️ **La forme** de ce verrouillage s'aligne sur l'AC 3 (C119, validation P3) : un parcours + d'intervalle `… ORDER BY start_date ASC FOR UPDATE` ne fixe pas l'ordre des verrous (il dépend du + plan). **Fait (C125)** — la 15-1a a depuis été découpée en **15-1a-i** (marque du lettrage) et + **15-1a-ii** (gardes) : verrous par clé primaire, un par un dans l'ordre de `start_date`, bornés aux + exercices qui portent une ligne du groupe ; l'exercice postérieur clos est lu sans verrou par + `find_later_closed`. Aucun fantôme n'en change le verdict : le lettrage n'a pas de relecture sous + verrou à faire. +- Dans l'**état hérité**, l'invariant ne tient pas : un groupe entièrement dans un exercice ouvert N, sous + un N+1 clos, passe la règle « au moins une ligne sur un exercice ouvert ». Ni cette fiche ni la 15-12b + ne le refusent ; la 15-1a l'a tranché (C113 : elle **garde** l'état hérité, patron 15-8a). +- ⛔ **Cette fiche ne modifie pas les fiches 15-1a-i / 15-1a-ii** : le point est porté à l'orchestrateur. +- **Ordre** : 15-12a → 15-12b → 15-1a-i → 15-1a-ii. + +## Acceptance Criteria + +### La clôture dans l'ordre + +1. **Refus de la clôture.** `fiscal_years::close(pool, user_id, company_id, Y)` refuse, **sans rien + écrire** (ni statut, ni ligne d'audit `fiscal_year.closed`), dès qu'un exercice **de la même + société** dont `start_date < Y.start_date` est `Open`. L'erreur est une **variante neuve** + `DbError::EarlierFiscalYearOpen { fiscal_year_id, fiscal_year_name }` qui nomme le **plus ancien** + exercice antérieur ouvert (`ORDER BY start_date ASC LIMIT 1`) — celui qu'il faut clôturer + d'abord (en nommer un plus proche enverrait l'utilisateur sur un exercice que la même garde + refuserait à son tour). Les exercices d'une autre société sont ignorés (test dédié). + +2. **Précédence des refus de `close`**, testée paire par paire : `NotFound` (exercice inexistant ou + d'une autre société) → **déjà clos** (`IllegalStateTransition`, comportement actuel, 409 + `ILLEGAL_STATE_TRANSITION`) → `EarlierFiscalYearOpen`. ⚠️ Un exercice déjà clos **et** précédé d'un + exercice ouvert (état fautif hérité) répond « déjà clos » : l'état de l'exercice lui-même parle + d'abord, comme `FISCAL_YEAR_CLOSED` avant `LATER_FISCAL_YEAR_CLOSED` au `PUT`. + +3. **Ordre des verrous de `close`**, écrit à son doc-comment et tenu par le **code**, non par le plan + de l'optimiseur *(réécrit en P3 : F2, F5, F6, R3 ; C119, qui révise C110)* : + (a) lecture **non verrouillante** de `start_date` de Y (scopée `(id, company_id)`) — `start_date` est + **immuable** : aucun `UPDATE` du code de production n'écrit `start_date` (les `UPDATE fiscal_years` + hors `#[cfg(test)]` sont ceux de `close`, `reopen` — statut — et `update_name` — `name` seul, + `fiscal_years.rs:400`) ; une ligne supprimée puis recréée (`kesh-seed` `reset_demo`, + `lib.rs:275`) prend un autre `id` et sort de (b) ; la restauration d'une sauvegarde est l'angle + mort déclaré (Dev Notes) *(F5 de P4)* ; absente → `NotFound` ; + (b) **liste des antérieurs, sans verrou** — constante `LIST_EARLIER_SQL` : `SELECT id FROM + fiscal_years WHERE company_id = ? AND start_date < ? ORDER BY start_date ASC`, **tous statuts** (un + antérieur clos dans la vue peut être en cours de réouverture) ; + (b') **chacun verrouillé, un par un, dans cet ordre**, par une boucle Rust — constante + `LOCK_EARLIER_BY_ID_SQL` : `SELECT id FROM fiscal_years WHERE id = ? AND company_id = ? FOR UPDATE`. + Un exercice listé en (b) et disparu depuis (`onboarding::reset` efface les exercices de la société) + est ignoré : la relecture (d) fait foi ; + (c) verrou de Y — constante **`LOCK_IN_COMPANY_SQL`** : `SELECT id, company_id, name, start_date, + end_date, status, created_at, updated_at FROM fiscal_years WHERE id = ? AND company_id = ? FOR + UPDATE`, c'est-à-dire **le texte que `reopen` et `update_name` écrivent déjà chacun à la main**, + extrait en une constante partagée par les trois (DRY ; F1 = R5 de P4). Sa liste de colonnes est + **la condition du motif** de l'AC 13 (`["SELECT id, company_id", "WHERE id = ", "FOR UPDATE"]`), + qui en est tiré : une forme réduite (`SELECT status …`) le rendrait aveugle. Par `fetch_optional` + — Y disparu depuis (a) → `NotFound`, jamais une panique (F6) ; verdict « déjà clos » ; + (d) **relecture verrouillante** — constante `FIND_EARLIER_OPEN_SQL` : `SELECT … FROM fiscal_years + WHERE company_id = ? AND start_date < ? AND status = 'Open' ORDER BY start_date ASC LIMIT 1 FOR + UPDATE`. Lecture verrouillante, donc sur l'état **validé le plus récent** et non sur la vue fixée en + (a) : c'est **elle** qui rend le verdict `EarlierFiscalYearOpen` (le plus ancien antérieur ouvert, + AC 1) ; + (e) `UPDATE` existant, audit, `COMMIT`. + **Pourquoi un par un (F2)** : une lecture verrouillante par intervalle (`… ORDER BY start_date ASC + FOR UPDATE`, la forme de la P2) ne fixe que l'ordre du **résultat**. InnoDB verrouille dans l'ordre du + **parcours**, que choisit l'optimiseur : l'index `uq_fiscal_years_company_start_date` (ascendant), + mais aussi l'autre index préfixé par `company_id`, `uq_fiscal_years_company_name` (deux index, la + contrainte de clé étrangère réutilisant le premier — `test-schema/0001_schema_squash.sql:548-550`, + R3), ou la clé primaire, suivis d'un `filesort`. Le dépôt l'a **mesuré** : + `opening_complement.rs:278-281` (« avec `, id`, le plan passe par `Using filesort`, qui lit — donc, + en `FOR UPDATE`, verrouille — tous les exercices de la société »). Or l'ordre des `id` diverge de + celui des `start_date` dès qu'un exercice antérieur est créé après un postérieur, ce que l'AC 5 laisse + permis sous un exercice ouvert. Le verrou par clé primaire dans une boucle a un ordre que **seul le + code** fixe : aucune mesure n'en dépend. + **Ce que cette forme perd, et ce qui le rend** : + - **les verrous de clé suivante et d'intervalle** d'un parcours : un exercice antérieur à Y **créé** + après la vue de (a) — un fantôme — n'est ni listé en (b) ni verrouillé en (b'). La relecture (d) le + voit : elle lit l'état validé. Et une telle création ne peut plus se valider une fois Y tenu par + (c) : sa garde `find_later_closed_in_tx` (AC 5) parcourt les exercices postérieurs à X et + **examine** Y — elle le demande, et attend la clôture —, sauf si un exercice **clos** s'interpose + entre X et Y, auquel cas la création est refusée de toute façon. Ce qui s'est validé avant (c), (d) + le lit ; ce qui viendrait après attend. *(Revue P1, A1 : dans les tests, la création bute même plus + tôt, dans `find_overlapping` ; le verrou de la garde elle-même — une création dont la garde a + passé **tient** Y, la clôture l'attend en (c) et la voit en (d) — est prouvé par le 13 b3. Le cas du + fantôme validé entre (a) et (c) n'a pas de test : aucun montage simple ne le force, B-2.)* ; + - **des requêtes** : une par exercice antérieur (une par année, quelques-unes par société) ; + - **(d) reste un parcours d'intervalle**, dont les acquisitions **nouvelles** dépendent du plan. Mais + elle vient quand la clôture tient déjà Y et tous les antérieurs connus : ce qu'elle peut encore + acquérir, ce sont les fantômes (ouverts — ils font refuser la clôture) et, sous un plan qui ne + suit pas l'index, d'autres lignes de la société. Un cycle qui y naîtrait est absorbé par le rejeu + de la clôture (AC 6) ; l'invariant I n'en dépend pas, (d) étant verrouillante quel que soit le plan. + `EXPLAIN` de (d) mesuré en T0 **à titre descriptif**, dans les deux régimes (base éphémère à une + société ; base de dev à plusieurs sociétés) — il ne fonde rien. + **Écartée (C119)** : `FORCE INDEX (uq_fiscal_years_company_start_date)` sur la requête de parcours, + avec un test de régression sur son `EXPLAIN`. La garantie y reste une **mesure** — un indice que + l'optimiseur suit et qu'un `EXPLAIN` confirme pour la version de MariaDB testée —, là où la boucle + n'en demande aucune ; et l'indice fige un nom d'index dans une requête. Le dépôt emploie `FORCE + INDEX (PRIMARY)` (`company_invoice_settings.rs:622`, 15-5d) pour **borner** des verrous partagés à + des clés primaires, non pour ordonner des verrous exclusifs. + **Pourquoi cet ordre** : la clôture acquiert les antérieurs connus **avant** Y, dans l'ordre + chronologique. Verrouiller Y d'abord l'exposerait au cycle avec une réouverture de Y-1 (qui tient + Y-1 puis demande les postérieurs, Y compris) — et `reopen` n'est **pas** rejouée : elle serait la + victime, en 500. ⚠️ L'argument suppose que la réouverture ne demande, après Y-1, que des + postérieurs : vrai sous le plan par l'index de `FIND_LATER_CLOSED_SQL` (`reopen`, garde 15-8a, et la + création de l'AC 5) ; sous un autre plan, ce parcours peut verrouiller toute la société. Limite + **préexistante** de `reopen` et de la garde 15-8a, dont le code ne change pas ici : `EXPLAIN` de + `FIND_LATER_CLOSED_SQL` mesuré en T0 dans les deux régimes, à titre descriptif ; un plan qui ne suit + pas l'index est écrit au Dev Agent Record et signalé à l'orchestrateur (issue), non corrigé ici. + ⚠️ **`OPEN_COVERING_DATE_SQL` reste sans `ORDER BY`** (C119, **révise C110**) : la requête rend au + plus une ligne (les exercices ne se chevauchent pas) ; un `ORDER BY` n'en changerait pas le résultat + et ne fixerait pas l'ordre de ses verrous, pour la raison ci-dessus. Rien dans cette fiche ne dépend + de l'ordre dans lequel `find_open_covering_date` verrouille : son doc-comment dit déjà que la place de + l'exercice est une convention de fréquence, la défense étant le rejeu (`fiscal_years.rs:543-549`). + ⚠️ **Ce n'est PAS l'ordre de tous les flux** (C100). Les flux qui verrouillent l'exercice d'une + **origine** puis cherchent l'exercice **du jour** prennent l'ordre inverse : `reverse_in_tx_inner` + (`journal_entries.rs:2175`) verrouille l'origine et son exercice par clé primaire (`:2183-2187`), + puis `find_open_covering_date(today)` (`:2300`) parcourt un intervalle qui contient les exercices + **antérieurs** à l'origine — depuis le premier exercice de la société sous le plan par l'index, toute + la société sous un autre plan — et les verrouille **après** elle. Mêmes deux temps, précédés du + verrou de la pièce, dans `supplier_invoices::cancel_in_tx` (`:1008`, puis `reverse_owned_in_tx` + `:1033`), `supplier_invoices::cancel_settlement_in_tx` (`:1213`, puis `:1242`), + `invoice_settlements_write::cancel_settlement_in_tx` (`:753`, puis `:777`) *(relocalisés sur + `5e4bec50` en T0, R1 de P4)* et + `reconciliation_cancel::cancel_in_tx` (`:310`, puis `reverse_in_tx` `:370`). Un cycle avec la + clôture est donc **possible, quel que soit le plan** : `close(N)` tient un antérieur M (étape b') et + demande N (étape c) ; la contre-passation d'une écriture de N tient N et demande M. Il se résout par + le **rejeu** : les cinq routes de ces flux sont `Rejouee` au registre (`reverse_journal_entry`, + `cancel_supplier_invoice`, `cancel_supplier_invoice_settlement`, `cancel_invoice_settlement_handler`, + `post_cancel_reconciliation`), et la clôture le devient (AC 6) — test 13 d. + **Le même cycle existe avec la création** (F5) : `create(X)` tient les exercices antérieurs à X + (pré-contrôle `find_overlapping … FOR UPDATE`, un parcours qui contient tous les exercices de début + antérieur à la fin de X), puis demande les postérieurs (`find_later_closed_in_tx`, AC 5) ; une + contre-passation ou une annulation d'une écriture d'un exercice N postérieur à X tient N, puis + parcourt depuis le premier exercice. Résolu par le rejeu des deux côtés (création : AC 6 ; flux : + `Rejouee`). Les requêtes (b), (b'), (c) et (d) sont des **constantes**, pas des chaînes écrites deux + fois. + ⚠️ **« Se résout par le rejeu » a une borne** (F6 de P4) : l'enveloppe fait au plus + **trois tentatives** (`DEFAULT_MAX_DEADLOCK_ATTEMPTS = 3`, `kesh-db/src/retry.rs:71`), et un + dépassement du délai d'attente de verrou (`1205`, `innodb_lock_wait_timeout` = 50 s) n'est **pas** + rejoué (`retry.rs:96-99`). Conséquence nouvelle pour la clôture : elle attend désormais en (b') + tout écrivain en vol qui tient un exercice antérieur (les écrivains verrouillent par parcours + depuis le premier exercice, `find_open_covering_date`) ; une attente de plus de 50 s rend un 500. + Préexistant pour la contre-passation, nouveau pour la clôture ; écrit aux Dev Notes. + +4. **HTTP.** `POST /api/v1/fiscal-years/{id}/close` rend **`409`** code **`EARLIER_FISCAL_YEAR_OPEN`**, + `details.fiscalYearId` / `details.fiscalYearName` (l'exercice à clôturer d'abord), message + localisé `error-fiscal-year-close-earlier-open` avec `$name` (« Clôturez d'abord l'exercice + « { $name } », plus ancien et encore ouvert : le bilan est cumulatif, et un exercice ne se clôt + qu'après tous ceux qui le précèdent. »). Mapping dans `kesh-api/src/errors.rs`, à côté de + `LaterFiscalYearClosed`. **Code dédié, et non `ILLEGAL_STATE_TRANSITION` comme la garde LIFO de la + réouverture** : l'écran des exercices traduit **tout** `ILLEGAL_STATE_TRANSITION` de la clôture en + « Cet exercice est déjà clôturé » (`+page.svelte`, `submitClose`) — réutiliser ce code y afficherait + un mensonge ; et une intégration par clé d'API doit pouvoir distinguer les deux refus. + +5. **Création.** `fiscal_years::create` refuse un exercice dont la `start_date` précède celle d'un + exercice **clôturé** de la société : `DbError::LaterFiscalYearClosed { fiscal_year_id, + fiscal_year_name }` (le **plus proche** postérieur clos, `find_later_closed_in_tx` — lecture + **verrouillante**, cf. AC 13 b), après les pré-contrôles existants (longueur du nom, chevauchement, + nom en double) et avant l'`INSERT`. HTTP **`400 LATER_FISCAL_YEAR_CLOSED`**, mêmes `details` que le + mapping global, mais **message propre à la création** *(T0, F3 de P4, choix C-15-12a-1)* : le texte + de l'AC 9 conseille la contre-passation d'**une écriture**, ce qui ne répond à rien de ce que tente + l'écran de création. `map_create_error` intercepte donc `DbError::LaterFiscalYearClosed` et le rend + par une variante d'`AppError` dédiée (`FiscalYearBeforeClosedYear { fiscal_year_id, + fiscal_year_name }`), clé neuve **`error-fiscal-year-create-later-closed`** — fr-CH : « L'exercice + « { $name } », postérieur, est clôturé, et son bilan reprend tout ce qui le précède : aucun exercice + ne peut être créé avant sa date de début tant qu'il l'est. Pour créer celui-ci, un administrateur + rouvre d'abord les exercices clôturés, en commençant par le plus récent. » (textes DE/IT/EN : AC 9). + Code, statut et `details` restent ceux de l'AC 9. + `create_for_seed` (une seule société neuve, un seul exercice : `kesh-seed/src/lib.rs:168`) et + `create_if_absent_in_tx` (n'insère que si la société n'a **aucun** exercice) ne peuvent pas produire + l'état fautif et ne changent pas — écrit à leur doc-comment. **Test HTTP** (`fiscal_years_e2e.rs`) : + `POST /fiscal-years` sous un exercice clos → `400 LATER_FISCAL_YEAR_CLOSED`, corps complet + (`details.fiscalYearId` / `fiscalYearName`, message de création ci-dessus) ; paire de précédence : une demande + qui **chevauche** un exercice et précède un exercice clos rend **`400 VALIDATION_ERROR`**, message + `error-fiscal-year-overlap` (pré-contrôle existant, qui parle d'abord : `FY_OVERLAP_KEY` est la clé + interne de `DbError::Invariant`, que `map_create_error` traduit en `AppError::Validation` — + `routes/fiscal_years.rs:102`, `errors.rs:1277-1278` sur `5e4bec50` ; R7). Le test existant + `create_overlap` (`fiscal_years_e2e.rs:229`) asserte ce message. + +6. **Rejeu des deux routes, et sa preuve.** `create_fiscal_year` et `close_fiscal_year` + (`routes/fiscal_years.rs:218`, `:279` ; aucune enveloppe aujourd'hui) appellent leur dépôt dans + l'enveloppe `kesh_db::retry::retry_on_deadlock("fiscal_years::create" | "fiscal_years::close", …)` + (transaction unique, sûre à relancer) : la course création/clôture peut se résoudre par un + interblocage (verrous de la relecture (d) de la clôture, ou des verrous par clé primaire de (b')/(c), + contre les parcours et l'intention d'insertion de la création, cf. AC 13 b), et les cycles + clôture/contre-passation et création/contre-passation (AC 3, F5) en sont. Elles restent + `SansEcritureAuJournal` au registre `crates/kesh-api/tests/audit_route_registry.rs` (point (vi) de + son doc-comment : la colonne ne dit rien de la présence d'une enveloppe) ; les volets (c) et (c bis) + restent verts (enveloppe nommée, jamais la primitive `retry_with`). + **La preuve de l'enveloppe** (R2, C109) — le volet (c) n'examine pas les `SansEcriture…`, et un test + `kesh-db` écrirait l'enveloppe lui-même : + - **clôture** : test HTTP `fiscal_year_close_is_replayed_when_it_is_the_deadlock_victim` dans + `crates/kesh-api/tests/rejeu_interblocage_e2e.rs`, patron des tests 2 à 5 du fichier + (`transaction_lourde`, `attendre_une_requete_en_cours`, témoin `common::capture_rejeu`). Montage : + M **clos**, N **ouvert**, M < N ; la transaction lourde tient N (`SELECT … FROM fiscal_years WHERE + id = N FOR UPDATE`) ; `POST /fiscal-years/{N}/close` lancée en tâche ; attente de la clôture à + l'étape **(c)** (motif de l'AC 13) — l'étape (b') a verrouillé M **par sa clé primaire**, + explicitement et quel que soit son statut ; la transaction demande M (`.expect` : elle doit + l'obtenir, sinon c'est elle la victime et le test ne prouve rien) ; elle annule ; la route rejoue et + rend **200**, N clos, et l'événement `warn!` de `kesh_db::retry` nomme `fiscal_years::close`. + *(L'hypothèse de la P2 — « la requête de parcours garde le verrou d'une ligne examinée qu'elle ne + retient pas » — n'a plus d'objet depuis C119 : le verrou de M est posé par (b'), non déduit d'un + parcours ; et le point d'arrêt n'est plus « (b) ou (c) selon la mesure » : (b') ne bloque pas sur M, + libre, si bien que la clôture ne peut buter qu'en (c), sur N.)* + - **création** : même patron si la mesure de l'AC 13 b montre un interblocage dont la victime se + laisse forcer (`fiscal_year_creation_is_replayed_when_it_is_the_deadlock_victim`) ; **sinon**, la + mutation (x) est écrite « tenue par revue » au Dev Agent Record et au point (vi) du registre, comme + `onboarding::finalize`. ✅ **Mesuré en T0 : la victime se laisse forcer** — montage M clos, Y + ouvert, M < X < Y ; la transaction lourde tient Y par clé primaire ; `POST /fiscal-years` (X) passe + son pré-contrôle `find_overlapping` (qui lit, donc verrouille, M) et bute sur Y dans + `find_later_closed_in_tx` (motif `["start_date > ", "FOR UPDATE"]`) — **à la main** ; dans le test + HTTP 10, sur une base `#[sqlx::test]` de quelques lignes, elle bute dès `find_overlapping`, d'où son + motif large `["fiscal_years", "FOR UPDATE"]` (revue P1, A1) ; la transaction demande M : + interblocage, la création (plus légère) est annulée, la transaction obtient M, annule ; la route + rejoue et rend **201**. Le test est donc écrit, et il tue la mutation (x). + **Le doc-comment du registre est mis à jour** (propagation) : le point (iv) (`:94-98`) retire + `POST /fiscal-years/{id}/close` des routes non rejouées exposées au cycle (il y garde `/reopen`, en + nommant le cycle réouverture/contre-passation, préexistant et hors de cette story) ; le point (vi) + (`:103`) passe de « **deux** routes `SansEcritureAuJournal` sont rejouées » à **quatre**, en nommant + `create_fiscal_year` et `close_fiscal_year`, leur enveloppe **et leur preuve** (test HTTP, ou revue). + **Et les deux autres énumérations des preuves dynamiques du même doc-comment** (F4) : le point + **(ii)** (« Seuls les tests 2 à 5 et 7 de `rejeu_interblocage_e2e.rs` le prouvent dynamiquement, pour + **cinq** routes de la famille `DbError` », `:58-60`) et le point **(iii bis)** (« Ont une preuve + dynamique : les **cinq** routes des tests 2 à 5 et 7… », `:72-73`) gagnent le test de la clôture — + et celui de la création si T0 l'a rendu possible —, leur famille (`DbError`, enveloppe + `kesh_db::retry`) et le nombre de routes. Chaque décompte est **refait depuis le fichier de tests et + le code des routes**, non incrémenté (`grep -n "^async fn\|^#\[sqlx::test" rejeu_interblocage_e2e.rs`). + +7. **Réouverture inchangée, invariant écrit.** `reopen` ne change pas de code. Le doc-comment du + module `fiscal_years.rs` énonce l'invariant I, les trois transitions qui le tiennent (clôture : AC 1 ; + création : AC 5 ; réouverture : garde LIFO), **sa portée** (états atteints depuis un état sain) et ce + qui le contourne : la restauration d'une sauvegarde (`admin::full_import`), les données d'une version + antérieure, toute écriture de `fiscal_years.status` hors de `close` / `reopen` (si une future + transition le fait, la preuve tombe). La 15-12b y ajoute la description de son filet. Le doc-comment + de `DbError::LaterFiscalYearClosed` (`kesh-db/src/errors.rs:553-561`), qui dit « `fiscal_years::close` + ne regarde pas les exercices antérieurs », est réécrit : la variante sert au `PUT`, au `DELETE` et à + la création d'un exercice (la 15-12b y ajoutera ses deux points de passage). + +### Le message + +9. **Message neutre** *(déplacé de la 15-12b à la découpe, R3/C107 : l'AC 5 le rend dès cette fiche ; + complété en P2, F5/R4/C111)*. Le mapping global de `DbError::LaterFiscalYearClosed` + (`kesh-api/src/errors.rs:2843-2872` sur `5e4bec50`) lit une **clé neuve** `error-later-fiscal-year-closed`, aux + quatre locales et dans le repli Rust (qui remplace celui de `:2851-2853`). Texte fr-CH de + référence : « L'exercice « { $name } », postérieur, est clôturé, et son bilan reprend tout ce qui le + précède : aucune écriture datée avant sa date de début ne peut être modifiée ni supprimée tant qu'il + l'est. Une telle écriture se corrige par une contre-passation ; sinon, un administrateur rouvre les + exercices clôturés, en commençant par le plus récent. » *(Revue P1, B-1/E4, C-15-12a-3 : la première + rédaction disait « rien ne peut être enregistré, modifié ou supprimé » — faux de la saisie tant que + la 15-12b n'a pas posé sa garde, et contraire au manuel `user-manual.tex:712` et à + `api-external.md`. La 15-12b élargira le texte à la saisie.)* + - La formulation ne présuppose **pas** que l'exercice visé existe (elle vaut à la création d'un + exercice, F12) ; + - elle **garde le conseil** que le `PUT` / `DELETE` donnait (la contre-passation, F5) ; + - elle ne prescrit **jamais** de rouvrir « { $name } » : ce serait refusé par la garde LIFO dès qu'un + exercice plus récent est clos (C100) ; « en commençant par le plus récent » est le seul ordre que + LIFO accepte, et il vaut aussi à la création d'un exercice (état sain, où le bandeau de la 15-12b ne + s'affiche pas : le message porte lui-même la marche à suivre — F5). ⚠️ **À la création d'un + exercice, c'est la clé dédiée de l'AC 5 qui parle** (T0, C-15-12a-1) : le conseil de + contre-passation n'y a pas d'objet. + **Textes des trois autres locales** *(T0, F3 de P4)* — termes du glossaire `docs/i18n-glossaire.md` + (ligne *clôture (d'exercice)* : *Abschluss* / *chiusura* / *closing*, jamais « fermer » — KF-041 ; + *contre-passation* : *Stornobuchung* / *storno* / *reversal*, comme les clés voisines) : + - `error-later-fiscal-year-closed` — de-CH : « Das spätere Geschäftsjahr „{ $name }“ ist + abgeschlossen, und seine Bilanz enthält alles, was ihm vorangeht: Eine Buchung vor seinem Beginn + kann weder geändert noch gelöscht werden, solange es abgeschlossen ist. Eine solche Buchung wird + durch eine Stornobuchung korrigiert; andernfalls eröffnet eine Administratorin oder ein Administrator + die abgeschlossenen Geschäftsjahre wieder, beginnend mit dem neuesten. » ; it-CH : « L’esercizio + successivo « { $name } » è chiuso, e il suo bilancio riprende tutto ciò che lo precede: nessuna + scrittura datata prima della sua data d’inizio può essere modificata o eliminata finché lo è. Una + tale scrittura si corregge con uno storno; altrimenti, un amministratore riapre gli esercizi chiusi, + cominciando dal più recente. » ; en-CH : « The later fiscal year "{ $name }" is closed, and its + balance sheet includes everything before it: no entry dated before its start date can be changed + or deleted while it is closed. Such an entry is corrected with a reversal; otherwise, an + administrator reopens the closed fiscal years, starting with the most recent. » + - `error-fiscal-year-create-later-closed` (AC 5) — même première proposition, puis de-CH : « Vor + seinem Beginn kann kein Geschäftsjahr erstellt werden, solange es abgeschlossen ist. Um dieses zu + erstellen, eröffnet eine Administratorin oder ein Administrator zuerst die abgeschlossenen + Geschäftsjahre wieder, beginnend mit dem neuesten. » ; it-CH : « nessun esercizio può essere creato + prima della sua data d’inizio finché lo è. Per crearlo, un amministratore riapre prima gli esercizi + chiusi, cominciando dal più recente. » ; en-CH : « no fiscal year can be created before its start + date while it is closed. To create this one, an administrator first reopens the closed fiscal + years, starting with the most recent. » + - ancienne clé alignée (ci-dessous), première phrase inchangée, puis de-CH : « Korrigieren Sie sie + durch eine Stornobuchung; andernfalls eröffnet eine Administratorin oder ein Administrator die + abgeschlossenen Geschäftsjahre wieder, beginnend mit dem neuesten. » ; it-CH : « Correggetela con + uno storno; altrimenti, un amministratore riapre gli esercizi chiusi, cominciando dal più + recente. » ; en-CH : « Correct it with a reversal; otherwise, an administrator reopens the closed + fiscal years, starting with the most recent. » + - `error-fiscal-year-close-earlier-open` (AC 4) et `fiscal-year-close-blocked-earlier-open` (AC 17) — + de-CH : « Schliessen Sie zuerst das Geschäftsjahr „{ $name }“ ab, das älter und noch offen ist[: + Die Bilanz ist kumulativ, und ein Geschäftsjahr wird erst nach allen vorangehenden + abgeschlossen]. » ; it-CH : « Chiudi prima l’esercizio « { $name } », più vecchio e ancora + aperto[: il bilancio è cumulativo, e un esercizio si chiude solo dopo tutti quelli che lo + precedono]. » ; en-CH : « First close fiscal year "{ $name }", which is earlier and still open[: + the balance sheet is cumulative, and a fiscal year can only be closed after all those before + it]. » (entre crochets : la seconde proposition, propre à la clé de l'API). + Le texte actuel (`journal-entries-modify-blocked-later-fiscal-year-closed`, « elle reste figée… + corrigez par une contre-passation ») ne vaut que pour la modification ; la clé neuve sert désormais + au `PUT`, au `DELETE` et à la création d'un exercice (la 15-12b y ajoute la saisie, les règlements, la + validation d'une facture, la dévalidation). **L'ancienne clé reste**, lue par + `frontend/src/lib/features/journal-entries/blocker-messages.ts:91-96`, texte d'écran de la fiche + d'écriture (`modificationBlockedBy`). **Mais sa prescription est alignée** (R4, C111) — elle dit + aujourd'hui « Un administrateur peut rouvrir cet exercice », le geste que C100 a réfuté, dans l'état + même (hérité) où elle s'affiche. Texte fr-CH : « L'exercice postérieur { $name } est clôturé, et son + bilan reprend cette écriture : elle reste figée tant qu'il l'est. Corrigez-la par une + contre-passation ; sinon, un administrateur rouvre les exercices clôturés, en commençant par le plus + récent. » **Sites, relevés sur `8f9811d8` par la valeur** (`grep -rnE "rouvrir cet exercice|reopen that + fiscal year|wieder eröffnen;|riaprire quell"` hors fiches et PDF ; **relocalisés sur `5e4bec50` en T0**) : + `crates/kesh-i18n/locales/fr-CH/messages.ftl:376`, `de-CH/messages.ftl:382`, + `it-CH/messages.ftl:382`, `en-CH/messages.ftl:382`, repli de + `blocker-messages.ts:94` — et le repli Rust `errors.rs:2852`, qui disparaît avec le passage à la clé + neuve. Le `grep` se refait après correctif et ne doit plus rien rendre hors des fiches de la 15-8a. + Code, statut (`400`) et `details` inchangés. + +### La concurrence + +13. **Tests à deux connexions** (`crates/kesh-db/tests/fiscal_years_repository.rs`, base éphémère + `#[sqlx::test]`, `attendre_une_requete_en_cours` de `test_fixtures.rs`) — chacun asserte **l'état + final** et l'issue de **chaque** côté, et **force** l'entrelacement qu'il teste : une course libre + (`tokio::spawn` × 2) ne tombe qu'au hasard dans la fenêtre qui compte (C100). La session W est une + transaction de test qui tient un verrou. + ⚠️ **Désignations** *(T0, R4 de P4)* : les **tests** s'écrivent toujours « 13 a » … « 13 d » (et + « 13 b1 », « 13 b2 » pour les deux configurations du 13 b) ; les **étapes** de `close` s'écrivent + « étape (a) » … « étape (e) », « étape (b') ». Une lettre seule entre parenthèses, ci-dessous, est + une étape dans la liste des motifs, un test dans la liste des tests. + **« Attendre la clôture », ce que l'aide sait voir (R7, F9)** : `attendre_une_requete_en_cours` + (`test_fixtures.rs:560-590`) compte les **autres** connexions de la base dont `PROCESSLIST.INFO` + contient tous les motifs : elle voit une requête **en cours d'exécution**, qu'elle soit bloquée ou + non — elle ne sait **pas** dire « en attente de verrou ». Ce qui fait de « en cours » une attente, + c'est le montage : W tient la ligne que la requête attendue demande, si bien que la requête reste + visible tant qu'elle bute. Les doc-comments des tests le disent ainsi, sans prétendre davantage. + Elle rend `false` (et non une panique) si la tâche observée a fini — `interrompre` — : un test doit + alors rougir sur l'**état final**, jamais sur le délai de dix secondes. + **Motifs discriminants**, par étape de `close` (C119 ; toutes portent `fiscal_years` ; les motifs + évitent `?`, que `INFO` peut montrer substitué ou non — T0 le constate) : + - **(a)** lecture non verrouillante de `start_date`, et **(b)** liste sans verrou des antérieurs : + **jamais attendues** (elles ne bloquent pas) ; + - **(b')** verrou d'un antérieur par clé primaire (`LOCK_EARLIER_BY_ID_SQL`) : + `["SELECT id FROM fiscal_years WHERE id = ", "FOR UPDATE"]` — la liste de colonnes réduite à `id` + l'oppose à (c) ; + - **(c)** verrou de Y par clé primaire : `["SELECT id, company_id", "WHERE id = ", "FOR UPDATE"]` — + `WHERE id = ` exclut les requêtes par société, `FOR UPDATE` exclut l'étape (a) ; + - **(d)** relecture verrouillante (`FIND_EARLIER_OPEN_SQL`) : `["start_date < ", "FOR UPDATE"]` — + l'espace après `<` exclut `start_date <= ` d'`OPEN_COVERING_DATE_SQL` et `start_date > ` de + `FIND_LATER_CLOSED_SQL` ; `FOR UPDATE` exclut la liste (b). + Motifs par test : **13 a et 13 c** — la clôture bute en **(b')** sur N, que W tient ; motif large + `["fiscal_years", "FOR UPDATE"]`, tolérant à l'étape où elle bute : sous la mutation (ii) — (d) non + verrouillante —, elle bute toujours en (b'), et le test rougit ensuite sur l'état final ou l'issue. + Ce motif large convient parce qu'aucune autre connexion n'exécute de requête pendant l'attente (W est + inactive). **13 d** et **test HTTP de l'AC 6** — le motif de **(c)** : la clôture ne peut buter + qu'en (c), sur N ; (b') a verrouillé M, libre, sans attendre, et l'exécution est séquentielle — voir + (c) en cours prouve que (b') a rendu et tient M. *(L'aléa de la P2 — « la clôture peut buter dès (b), + un instant avant d'avoir verrouillé M » — disparaît avec le parcours d'intervalle : C119.)* + - **(a) Une réouverture de N en cours contre `close(L)`** (N clos, L ouvert, L > N, rien d'autre) — + **c'est la preuve de l'invariant I, et le test qui tue la mutation (ii)**. W refait les gestes de + `reopen` sans les valider : `SELECT … WHERE id = N FOR UPDATE`, lecture des postérieurs clos + `FOR UPDATE` (rend rien), `UPDATE fiscal_years SET status = 'Open' WHERE id = N`. On lance + `close(L)` (dans son enveloppe), on attend qu'elle soit vue en cours, on valide W ; on asserte + `EarlierFiscalYearOpen` nommant N, L **ouvert**, aucune ligne d'audit `fiscal_year.closed`. + Sous la mutation (ii) (relecture (d) non verrouillante), la clôture attend W en (b') puis lit (d) + dans sa vue, fixée en (a) pendant que W n'était pas validée : N **clos** — la clôture passe, et + l'état final est « N ouvert, L clos » — rouge sur la propriété elle-même, de façon déterministe. Le test existant `reopen_close_concurrent_is_serialized` (`:1055`), course libre qui + asserte que la clôture réussit toujours, est **remplacé** par celui-ci. + - **(b) `create(X)` contre `close(Y)`**, X antérieur à Y, la société n'ayant aucun autre exercice + ouvert avant Y : **jamais** « X ouvert, Y clos ». Les deux appels passent par leur **enveloppe de + rejeu** (AC 6) — le test appelle les fonctions du dépôt **dans** `retry_on_deadlock`, comme les + routes. ⚠️ Mécanisme à **mesurer** en T0 et non à supposer, **dans les deux configurations** : + (b1) sans aucun exercice antérieur à X ; (b2) avec un exercice **clos** antérieur à X — là, le + pré-contrôle `find_overlapping` de la création (parcours depuis le premier exercice) et le verrou + (b') de la clôture portent tous deux sur ce premier exercice et s'y rencontrent probablement + (sérialisation, sans interblocage). ✅ **Mesuré en T0** (MariaDB 10.11.16, trois sessions à la + main, la clôture bloquée en étape (c) par une session qui tient Y, la création lancée ensuite) : + **13 b1 — sérialisation** : la création passe ses pré-contrôles, bute sur Y dans + `find_later_closed_in_tx`, attend la clôture et lit Y **clos** → `LaterFiscalYearClosed` — + **à la main** ; **dans les tests** (`#[sqlx::test]`), la création bute dès `find_overlapping`, sur + Y (b1) ou sur M (b2), et ne lit Y clos dans sa garde qu'après : les 13 b1 / b2 prouvent la + propriété, **non le verrou de la garde** — une garde non verrouillante y passerait aussi (revue + P1, A1 = B-2 = E1). D'où le **13 b3** ci-dessous ; + **13 b2 — interblocage**, contrairement à la supposition : la clôture tient M (étape (b')), la + création lit M dans `find_overlapping` et l'attend, la clôture, Y obtenu, demande en étape (d) le + verrou d'index de M que la création a posé — cycle ; victime observée : la création, qui rejouée + lit Y clos. La phrase de l'AC 6 (« peut se résoudre par un interblocage ») est donc **confirmée**. + Dans les deux cas, la propriété ne dépend pas du mécanisme : + une création validée avant que la clôture ne tienne Y est lue par (d) ; une création postérieure + attend Y (AC 3, « ce que cette forme perd »). Un test par configuration ; chacun écrit le mécanisme + observé (interblocage rejoué, ou sérialisation) dans son doc-comment, et la phrase de l'AC 6 est + corrigée si aucune configuration n'interbloque. La mesure décide aussi de la preuve de + l'enveloppe de la création (AC 6, mutation (x)). + - **(b3) Le verrou de la garde de `create`** *(ajouté en revue P1, A1)* : W refait les gestes de + `create` **qui suivent** ses pré-contrôles, sans les valider — la garde `find_later_closed_in_tx` + (rend rien, Y ouvert), puis, après le lancement de `close(Y)` et son attente en étape (c), + l'`INSERT` de X — et valide. On asserte : jamais « X ouvert, Y clos » ; la clôture vue en (c) ; + `EarlierFiscalYearOpen` nommant X ; Y ouvert ; aucun audit `fiscal_year.closed`. W ne rejoue + **pas** `find_overlapping`, dont le verrou de borne masquerait celui de la garde. **Tue la mutation + (xi)** (garde non verrouillante), jouée : seul ce test rougit (« X ouvert sous Y clos »). + - **(c) Une clôture de N en cours contre `close(N+1)`** (N et N+1 ouverts) — garde contre un refus + **parasite**, sous forme **ordonnée** (C100) : W pose `UPDATE fiscal_years SET status = 'Closed' + WHERE id = N`, non validé ; on lance `close(N+1)`, on attend qu'elle soit vue en cours (bloquée sur + N à l'étape (b')), on valide W ; `close(N+1)` **réussit**. Forme ordonnée plutôt que l'ensemble des + issues admises : lancées librement, `close(N+1)` peut lire N ouvert la première et refuser + **légitimement** — asserter l'ensemble des issues admettrait ce refus, et le test ne garderait plus + contre le refus parasite, qui est son objet. Sous la mutation (ii), `close(N+1)` attend W en (b'), + puis lit (d) dans sa vue fixée en (a) : N **ouvert** → refus parasite → rouge sur l'issue. + - **(d) `close(N)` contre une contre-passation** (cycle de l'AC 3 ; M clos, N ouvert, T ouvert + couvrant le jour du serveur, M < N < T) : W refait les deux temps de `reverse_in_tx_inner` — + verrou de N par clé primaire, puis `fiscal_years::find_open_covering_date(today)` — en lançant + `close(N)` entre les deux et en attendant de la voir buter en (c) (motif ci-dessus). W demande + alors M — son parcours le contient quel que soit le plan —, que la clôture tient par (b') : + interblocage. Assertion : la clôture, dans son enveloppe, **finit + acceptée** (N clos) quelle que soit la victime ; si c'est W, W reçoit un 1213 (annulée par le test). + T0 mesure si la victime se laisse forcer (W alourdie d'abord, patron des tests de rejeu). **Objet de + ce test : le cycle existe et se résout** — il ne prétend **pas** tuer la mutation (ix), l'enveloppe + y étant écrite par le test (R2) ; c'est le test HTTP de l'AC 6 qui la tue. + ✅ **Mesuré en T0** : la victime est **la clôture**, W alourdie (500 lignes) **ou non** — W obtient + M. ⚠️ **Qui libère W** *(F6 de P4)* : la clôture rejouée rebute en étape (b') sur M, que W tient + désormais ; le test **annule W dès que W a obtenu M, puis seulement** attend la clôture — un `join` + d'abord attendrait `innodb_lock_wait_timeout` (`1205`, jamais rejoué). Si, contre la mesure, W + était la victime, W reçoit un 1213 et le test l'annule de même. + L'ancien **13 c** de la création (« un écrivain dans N contre `close(N+1)` ») est **retiré** (C100). + Chaque test nomme, dans son doc-comment, la **mutation qu'il tue** (AC 19) — **sauf** le 13 d + (preuve d'existence et de résolution du cycle, aucune mutation) et les 13 b1 / 13 b2, dont la + propriété (« jamais X ouvert, Y clos ») est tenue par les gardes (i) et (iv) ensemble ; ils + l'écrivent ainsi *(T0, R3 de P4)*. + +14. **Doc-comments canoniques** *(révisé en P3 : C119, R5, F5)*. Le doc-comment du module + `fiscal_years.rs` (§ « Lock ordering & audit », `:11-22`) dit que les mutatrices « ne verrouillent + que `fiscal_years` » — toujours vrai — et recopie la phrase de l'AC 3 : **la clôture** acquiert ses + antérieurs connus **un par un par clé primaire, dans l'ordre chronologique — ordre fixé par le + code** —, puis l'exercice, puis relit les antérieurs ouverts sous verrou ; **la réouverture, la + garde 15-8a et la création** prennent leur exercice (ou leurs pré-contrôles) puis **parcourent** les + postérieurs (`FIND_LATER_CLOSED_SQL`), dans l'ordre du **plan** — ascendant sous l'index + `uq_fiscal_years_company_start_date`, mesuré en T0 à titre descriptif ; `find_open_covering_date` + parcourt un intervalle dans l'ordre du plan, convention de fréquence. Les flux qui verrouillent + l'exercice d'une origine puis cherchent l'exercice du jour (contre-passation et les quatre + annulations qui la portent) prennent l'ordre inverse, et leurs cycles avec la clôture **et avec la + création** se résolvent par le rejeu des deux côtés. ⛔ **Pas** « l'ordre ascendant de toutes les + acquisitions » (C100), ni « ascendant par construction » d'un parcours d'intervalle (C119) : un + `ORDER BY` fixe le résultat, pas l'ordre des verrous. Le doc-comment de `reverse_in_tx_inner` nomme + ces cycles à côté de son étape (4). Le doc-comment de `close` décrit les étapes (a) à (e), ce que la + forme « un par un » perd et ce qui le rend (AC 3). + **Les deux doc-comments de `fiscal_years.rs` que la story rend faux** (R5) : + - `FIND_LATER_CLOSED_SQL` (doc-comment `:664-667`) dit que les tests de la 15-8a reconnaissent la requête + verrouillante à ses motifs `ORDER BY start_date ASC` et `FOR UPDATE` : après cette fiche, + `FIND_EARLIER_OPEN_SQL` (AC 3 d) porte aussi ce texte — le doc-comment le dit, et renvoie à la + vérification de l'AC 21 ; + - `find_later_closed_in_tx` (`:639-645`) dit son hypothèse de verrouillage (*next-key locking* du + parcours `start_date > ?`) « testée par le test de course concurrente `reopen`/`close` » — test + **remplacé** par le 13 a (AC 13), qui n'éprouve pas cette hypothèse-là. Réécrit : l'hypothèse vaut + sous le plan par l'index (`EXPLAIN` de T0, descriptif), et ce qui la teste aujourd'hui est nommé + (les tests de concurrence de la 15-8a, `journal_entries_modification.rs:410`, `:511`), ou l'absence + de test est écrite. + **`docs/MULTI-TENANT-SCOPING-PATTERNS.md`** (F4 de la P2) — la ligne `fiscal_years::create / + update_name / close / find_*_locked` (`:323`) décrit l'ordre des verrous que la story change : elle + devient « `close` : exercices antérieurs lus sans verrou puis verrouillés un par un par clé primaire + (`start_date` croissant), puis l'exercice, puis relecture verrouillante des antérieurs ouverts ; + `create` : pré-contrôles puis exercices postérieurs clos (`find_later_closed_in_tx`) ; + `find_open_covering_date` : parcours, ordre du plan » ; une note nomme les cycles clôture / + contre-passation et création / contre-passation (et les quatre annulations), résolus par le rejeu des + deux côtés (Pattern 5 : l'ordre réduit la fréquence, le rejeu est la défense). La ligne du `DELETE` + (`:327`) ne change pas ici (la 15-12b la relit). + **Aucun** doc-comment « # Ordre des locks » d'un flux (`validate_invoice`, + `supplier_invoices::create_in_tx`, règlements) ne change. Vérification en T0 : + `grep -rnE "find_later_closed(_in_tx)?\(" crates/kesh-db/src` — la version `_in_tx` n'est appelée + que par `update`, `delete_in_tx`, `reopen` et `create` d'exercice ; la version non verrouillante, + que par le motif d'écran de `GET /journal-entries/{id}` (`journal_entries.rs:1101` sur `5e4bec50`). + +### L'écran des exercices + +17. **Bouton « Clôturer ».** Pour un exercice ouvert précédé d'un exercice ouvert, le bouton est + **désactivé** avec un `title` nommant le plus ancien antérieur ouvert — clé + `fiscal-year-close-blocked-earlier-open` (« Clôturez d'abord l'exercice « { $name } », plus ancien + et encore ouvert. »), patron exact du bouton « Réouvrir » (`+page.svelte:362-378`, fonction sœur + `earliestEarlierOpen` alignée sur la requête serveur `ORDER BY start_date ASC LIMIT 1`). Le serveur + reste l'autorité : `submitClose` (`:215`) traite `EARLIER_FISCAL_YEAR_OPEN` **avant** sa branche + `ILLEGAL_STATE_TRANSITION` (`:228`) (message du serveur, la modale se ferme, la liste se recharge) — + test Vitest qui **rougit** si la branche « déjà clôturé » attrape ce code. La boîte de création + affiche le message de `LATER_FISCAL_YEAR_CLOSED` (chemin générique actuel — à vérifier, pas à + supposer). + ⚠️ **`frontend/src/lib/shared/i18n-keys.test.ts` porte des bornes exactes** *(T0, F2 de P4)* — + sur `5e4bec50` : `sitesTotal: 1915`, `sitesNonResolus: 31`, `relais: 6`, `sitesGabarit: 10`, + `litterauxMin: 1050` (`:496-500`). Chaque `msg(…)` / `i18nMsg(…)` ajouté à `+page.svelte` bouge + `sitesTotal` : la borne se **recompte depuis la source** aux deux bornes du diff (règle de + l'en-tête du fichier : recompter, ne pas ajuster), avec une ligne d'historique, et le décompte va + au Dev Agent Record. + +### Ce qui doit être prouvé, et le reste + +19. **Mutations — part A** (chacune jouée, observée rouge, restaurée — ⚠️ toucher le fichier après + restauration, cargo garde le binaire muté) : (i) retirer la garde de `close` → AC 1 rouge ; (ii) + rendre la relecture AC 3 (d) non verrouillante → tests 13 a **et** 13 c rouges, sur l'état final ou + l'issue ; (iii) verrouiller Y avant ses antérieurs — (c) avant (b'), ou (b') retirée (C119) → + observation seulement, sur une **variante** du + 13 a où W verrouille N, lance la clôture, puis lit les postérieurs : la clôture mutée tient Y et + attend N, W demande Y — interblocage attendu, dont la victime peut être W, c'est-à-dire une + réouverture, non rejouée, en 500. (Dans le 13 a tel que monté, W lit les postérieurs **avant** de + lancer la clôture : la mutation (iii) n'y forme pas de cycle et le test reste vert.) Justification de + l'ordre, pas une garantie ; (iv) retirer la garde de `create` → AC 5 rouge ; (xi) *(revue P1, A1)* + rendre la garde de `create` non verrouillante (`FOR UPDATE` retiré de `find_later_closed_in_tx`) → + test 13 b3 rouge ; (viii) faire passer + `EARLIER_FISCAL_YEAR_OPEN` par la branche `ILLEGAL_STATE_TRANSITION` de l'écran → Vitest rouge ; (ix) + appeler `fiscal_years::close` hors de son enveloppe dans la **route** → test HTTP de l'AC 6 rouge + (500 ou absence du témoin de rejeu) ; (x) appeler `fiscal_years::create` hors de son enveloppe → + test HTTP de l'AC 6 rouge **si** T0 l'a rendu possible, sinon « tenue par revue », écrit. L'ordre de + la boucle (b') n'a pas de mutation propre : il n'a d'effet que sur la formation d'un cycle, ce + qu'observe (iii). Les mutations (v), (vi), (vii) sont dans la 15-12b. Résultat de chaque mutation au Dev + Agent Record. + +21. **Tests existants à reprendre — part A** (ceux que la clôture ordonnée et la garde de création + changent de sens) — triage au gate, chaque rouge classé (a) scénario désormais impossible par l'API + → réécrit (ordre de clôture corrigé, ou état posé par SQL **si** le test vise la donnée héritée), ou + (b) défaut réel → corrigé. **Inventaire par recherche — la clôture** (relevé sur `8f9811d8`, + reconfirmé sur `5e4bec50` : mêmes fichiers et lignes) : + `git grep -nE "fiscal_years::close\(|/close\"" -- 'crates/*.rs'` (⚠️ le chemin `'crates/*.rs'`, où + `*` traverse les `/` dans un *pathspec* git, couvre `src/lib.rs` ; la forme de la P2, + `'crates/*/src/**/*.rs'`, exigeait un sous-répertoire et ne le rendait pas — R2), hors code de + production (`routes/fiscal_years.rs:288`, `lib.rs:602`) : + `fiscal_years_repository.rs` (`reopen_lifo_three_years_intercalated` `:873` — FY1 clos, FY2 ouvert, + FY3 clos **par l'API** : à poser par SQL ; `reopen_close_concurrent_is_serialized` `:1055` — remplacé + par le 13 a ; les autres appels `:264-1080`, un exercice ou un ordre déjà croissant : à vérifier) ; + `fiscal_years_e2e.rs` (`:674-858`, `:1427`, aide `create_and_close_fy` `:1491`/`:1510` et ses + appelants) ; `reconciliation_e2e.rs` (`an_invoice_of_a_closed_year_paid_the_next_year_is_unreconcilable` + `:3823`, `a_closed_year_refuses_with_the_reconciliation_text` `:3891` — exercices insérés par SQL puis + clos par le dépôt) ; `invoice_settlement.rs` (`:1046`, dans `monter`) ; `supplier_invoices_repository.rs` + (`:1587`, `:1848`, dans `monter` / `monter_achat`) ; `opening_balances_e2e.rs` (`:673`, `:959`, + `:1090`, `:1247`), `opening_balances_repository.rs` (`:444`), `invoice_echeancier_e2e.rs` (`:720` : + clôture du seul exercice, a priori sain) ; **modules de test internes à `src/`**, sur la **base + partagée** (`test_pool`, « première company ») : `journal_entries.rs` `mod tests` + (`test_create_rejects_closed_fiscal_year` `:3475`, `update_no_op_in_closed_fy_returns_fiscal_year_closed` + `:5069` — `fiscal_years::close` sur l'exercice que `setup` garantit ouvert : refusée désormais si un + exercice antérieur ouvert subsiste pour la société). Les tests qui posent l'état par SQL direct + relèvent de la 15-12b (le filet ; son AC 21 les inventorie par le symptôme). + **Inventaire par recherche — la création** (R2 ; relevé sur `5e4bec50`) : + `git grep -nE "fiscal_years::create\(|exercice_de\(|/fiscal-years\"|NewFiscalYear \{" -- 'crates/*.rs'` + — 21 fichiers, dont 4 de production (`lib.rs`, `routes/fiscal_years.rs`, `routes/onboarding.rs`, + `kesh-seed/src/lib.rs`) et l'entité. Un site ne change de sens que s'il crée un exercice **antérieur + à un exercice déjà clos** dans son test. Lus, avec l'ordre des créations et des clôtures de chaque + test : `fiscal_years_repository.rs` (toutes les créations précèdent les clôtures du test), + `fiscal_years_e2e.rs` (idem ; `reopen_lifo_blocked_returns_409_distinct_message` crée 2025 puis 2026, + chacun clos après sa création — ordre croissant), `journal_entry_reversal_e2e.rs` (aide + `exercice_de`, `:1353`, qui crée **puis** pose le statut : appels en ordre croissant d'année, ou + exercices créés ouverts avant toute pose — `:1786`, `:1863-1865`, `:2077`, `:2115-2116`, `:2510`, `:2585`) ; + les autres fichiers ne clôturent aucun exercice postérieur à une création. **Aucun cas trouvé** ; + T0 le reconfirme sur le `main` du moment, et un rouge `LATER_FISCAL_YEAR_CLOSED` à la création se + trie comme les autres. + Les tests de la 15-8a/15-8b qui posent « N+1 clos » par `UPDATE` direct + (`journal_entries_modification.rs:395`, `:499` ; `journal_entries.rs:3250-3270`, `:3444-3455` — ce + dernier, le test C-15-8-29, est **inversé** par la 15-12b, AC 10) **restent** ; leur doc-comment « la clôture **en cours** d'un exercice postérieur » est réécrit + (« transition simulée par SQL : la clôture la refuse depuis la 15-12a ; le test garde la propriété du + verrou pour les données héritées »). + ⚠️ **Motifs existants de `attendre_une_requete_en_cours`** : `["ORDER BY start_date ASC", "FOR UPDATE"]` + (`journal_entries_modification.rs:410`, `:511`) visent `FIND_LATER_CLOSED_SQL`. Après cette fiche, + **une** autre requête porte ce texte : `FIND_EARLIER_OPEN_SQL` (AC 3 d) ; `OPEN_COVERING_DATE_SQL` + n'en reçoit pas (C119 révise C110). Relevé : `journal_entries::update` ne l'appelle pas, et aucune + autre connexion n'exécute de requête pendant ces attentes — aucune confusion aujourd'hui. T0 le + vérifie ; si les deux sites restent tels quels, l'écrire au Dev Agent Record, sinon passer au motif + discriminant `"start_date > "`. + **Côté frontend** *(T0, F2 de P4)* : `i18n-keys.test.ts` (bornes exactes, AC 17) et + `fiscal-years-page.test.ts` ; `blocker-messages.test.ts` asserte un fragment du repli + (« exercice postérieur Exercice 2027 est clôturé ») que la réécriture de l'AC 9 conserve. + +22. **E2E Playwright** (`frontend/tests/e2e/fiscal-years.spec.ts`) : le test « crée un exercice 2031, + le renomme puis le clôture » (`:56-96`) **casse** — l'exercice seedé 2020-2030 est ouvert. Il est + réécrit : créer et renommer 2031 ; le bouton « Clôturer » de 2031 est **désactivé**, son `title` + contient le nom **complet** de l'exercice seedé, « Exercice CI 2020-2030 » (`test_fixtures.rs:119`, + `scripts/seed-dev-db.sql:68` — F11 : asserter le nom complet, ou une correspondance partielle + **écrite comme telle**, jamais un « Exercice CI » qu'un sélecteur exact ne trouverait pas) ; + clôturer l'exercice seedé ; clôturer 2031 → « Clôturé ». Sélecteurs par + `data-testid` (ajouter `fiscal-year-close-{id}` au bouton, patron de `fiscal-year-reopen-{id}`). + Les autres specs ne clôturent qu'un exercice seul (`closeSeededFiscalYearViaApi`) : vérifié par + `grep -rn "/close" frontend/tests/e2e`. + +23. **Documentation — part A.** + - **Manuel utilisateur** (`docs/manual/fr/user-manual.tex`) : § « Clôture d'un exercice » (`:699-715`) + — la clôture se fait **dans l'ordre** (refus et bouton désactivé, nommés) ; § « Réouverture » + (`:717-723`) — rappel que la réouverture va dans l'ordre inverse (la procédure de réparation d'un + état hérité est de la 15-12b) ; création d'exercice (`:697`) — un exercice ne se crée pas avant un + exercice clôturé. **Item « Verrouille l'exercice » (`:708`)** (R8) : la proposition « clôturer + l'exercice suivant fige aussi celui-ci » présente comme disponible un geste que l'AC 1 **refuse** — + clôturer l'exercice suivant d'un exercice ouvert. Elle est réécrite : un exercice ne se clôture + qu'après tous ceux qui le précèdent, si bien qu'une écriture se modifie tant que son exercice est + ouvert ; et si des données antérieures (installation mise à jour, sauvegarde restaurée) portent + déjà un exercice clôturé après un exercice ouvert, cet exercice ouvert reste figé pour la + modification et la suppression. **Deux autres phrases** *(T0, F4 de P4)* : `:752` (complément + des soldes de départ — « Si un exercice suivant est déjà clôturé, son bilan reporté l'est + aussi », un état que le premier exercice ouvert ne peut plus avoir sous un exercice clos que par + des données antérieures) et `:762` (« Un exercice antérieur créé \emph{après} la génération + devient à son tour le premier », désormais refusé si un exercice postérieur est clôturé) — + incise à chacune. ⚠️ Seuls **restent** la parenthèse « pour la modification et la + suppression seulement… limite suivie par l'issue #543 » de ce même item et l'avertissement des + conditions de modification (`:494-500`) : ils décrivent l'état hérité, que seule la 15-12b ferme. + - **Manuel admin** (`admin-manual.tex`, recalé sur `8f9811d8`) : paragraphe « Réouverture d'un + exercice clôturé » (`:1381-1382`) — la clôture dans l'ordre, symétrique de la garde de réouverture. + Les puces `:1920` (« aucun exercice postérieur clôturé », clé `read-write`) et `:1959` (« si + aucun exercice postérieur n'est clôturé ») restent vraies *(relocalisées par le texte en T0, F7 + de P4)*. + - **`docs/api-external.md`** — `:484` (tableau des erreurs) : ligne neuve **`409 + EARLIER_FISCAL_YEAR_OPEN`** (`POST /fiscal-years/{id}/close`, `details`) ; `LATER_FISCAL_YEAR_CLOSED` + gagne la création d'exercice (`POST /fiscal-years`) parmi ses routes ; changement de contrat signalé + (une intégration qui clôturait hors d'ordre reçoit un refus). Le retrait de « ne garde pas encore + les autres chemins (#543) » est de la 15-12b. + - **PDF régénérés** (`make fr` dans `docs/manual/`), contrôlés **aplatis** + (`pdftotext f.pdf - | tr '\n' ' ' | tr -s ' '`) : les phrases neuves présentes, PDF utilisateur et + administrateur. + - **`CHANGELOG.md` `[0.13.0]`** (F10, C123) : entrée sous `### Corrigé` pour le **défaut** (refs + #543 : l'état « exercice ouvert suivi d'un exercice clôturé » ne s'atteint plus — clôture dans + l'ordre, création d'exercice, bouton désactivé, message du refus `LATER_FISCAL_YEAR_CLOSED`) **et** + entrée sous `### Modifié` pour le **changement de contrat d'API** — une intégration par clé qui + clôturait hors d'ordre reçoit `409 EARLIER_FISCAL_YEAR_OPEN`, une création sous un exercice clos + `400 LATER_FISCAL_YEAR_CLOSED` (la clôture est ouverte aux clés, Comptable+) —, patron des entrées + #532 de la même version, qui rangent sous `Modifié` ce qui change un comportement d'API. La 15-12b + complète l'entrée `Corrigé` (filet, réparation) et réécrit la parenthèse de l'entrée #532. + - **README** « Feuille de route » : inchangé a priori (correctif) — vérifié. + +## Tasks / Subtasks + +- [x] **T0 — Relevés au sol sur le `main` du moment** (AC 3, 6, 9, 13, 14, 21) *(ex-T0, part A)* + - [x] Refaire les numéros de ligne cités, par leur texte. + - [x] Vérifier que `start_date` n'est écrite nulle part après l'`INSERT` (AC 3 a). + - [x] Mesurer en MariaDB 10.11 (deux sessions `mariadb` à la main) le mécanisme de l'AC 13 b dans + ses **deux** configurations (b1, b2), le cycle de l'AC 13 d et le moyen d'en forcer la victime ; + `EXPLAIN` **descriptif** (C119 : rien n'en dépend) de `FIND_EARLIER_OPEN_SQL` (relecture (d)) et + de `FIND_LATER_CLOSED_SQL`, **dans les deux régimes** — base éphémère `#[sqlx::test]` à une + société, base de dev à plusieurs sociétés —, écrit au Dev Agent Record ; un plan de + `FIND_LATER_CLOSED_SQL` qui ne suit pas `uq_fiscal_years_company_start_date` est signalé à + l'orchestrateur (AC 3, limite préexistante de `reopen` et de la garde 15-8a). + - [x] Constater la forme de `PROCESSLIST.INFO` d'une requête préparée (`?` ou valeurs) ; vérifier les + motifs de l'AC 13 et ceux de `journal_entries_modification.rs:410`, `:511` (AC 21). + - [x] Refaire le `grep` des sites de l'ancienne clé (AC 9) et chercher toute assertion de test sur + son texte ou sur celui du repli Rust. +- [x] **T1 — Clôture dans l'ordre (dépôt)** (AC 1, 2, 3, 7, 14) *(ex-T1)* + - [x] `DbError::EarlierFiscalYearOpen { fiscal_year_id, fiscal_year_name }` (+ `error_code()` si la + famille l'exige — suivre `DbError::LaterFiscalYearClosed` : variante `errors.rs:562`, + `error_code()` `:1015` ; `ModificationBlocker::LaterFiscalYearClosed` (`:198-221`) est une + autre énumération et **ne change pas** — T0, R2 de P4). + - [x] Constantes `LIST_EARLIER_SQL`, `LOCK_EARLIER_BY_ID_SQL`, `FIND_EARLIER_OPEN_SQL` ; `close` + réordonné en (a)-(b)-(b')-(c)-(d)-(e), la boucle (b') en Rust (C119) ; `OPEN_COVERING_DATE_SQL` + **inchangée** (C119 révise C110) ; doc-comments (module, `close`, `FIND_LATER_CLOSED_SQL` et + `find_later_closed_in_tx` — R5 —, invariant I et sa portée, `DbError::LaterFiscalYearClosed`, + `reverse_in_tx_inner`) ; `docs/MULTI-TENANT-SCOPING-PATTERNS.md` (AC 14). + - [x] Tests : refus, précédence (paires), autre société, rien d'écrit (statut + audit) ; **deux + exercices antérieurs aux `id` inversés** par rapport à leurs dates (le plus ancien créé en + second), le plus ancien ouvert → `EarlierFiscalYearOpen` le nomme ; exercice disparu entre (a) + et (c) → `NotFound` (F6 : W tient Y, la clôture bute en (c), W supprime Y — sans écriture — + et valide ; jamais de panique). +- [x] **T2 — Création** (AC 5) *(ex-T2)* — garde, doc-comments de `create_for_seed` / + `create_if_absent_in_tx`, tests de dépôt ; test HTTP `POST /fiscal-years` → `400 + LATER_FISCAL_YEAR_CLOSED` et paire chevauchement (`400 VALIDATION_ERROR`) (avec T3). +- [x] **T3 — HTTP, message et rejeu** (AC 4, 6, 9) *(ex-T3, et la part « message » de l'ex-T4)* — + mapping 409 dans `kesh-api/src/errors.rs` ; clé `error-later-fiscal-year-closed` ×4 + repli Rust ; + ancienne clé alignée ×4 + repli de `blocker-messages.ts` (C111) ; enveloppes des deux routes ; + tests `fiscal_years_e2e.rs` (corps complet : code, message, `details`) ; test HTTP de rejeu de la + clôture (et de la création si T0 le permet) dans `rejeu_interblocage_e2e.rs` ; registre vert et son + doc-comment (points (ii), (iii bis), (iv) et (vi) — F4). +- [x] **T4 — Concurrence** (AC 13) *(ex-T6)* — tests (a), (b1), (b2), (c), (d), entrelacements forcés, + motifs de l'AC 13 ; `reopen_close_concurrent_is_serialized` remplacé par le 13 a. +- [x] **T5 — Écran des exercices : la clôture** (AC 17) *(ex-T7, part A)* — bouton désactivé, + `earliestEarlierOpen`, `submitClose` ; clés `fiscal-year-close-blocked-earlier-open`, + `error-fiscal-year-close-earlier-open` ×4 ; `npm run lint-i18n-ownership` ; Vitest + (`fiscal-years-page.test.ts`). +- [x] **T6 — Tests existants** (AC 21, part A) *(ex-T9, part A)* — triage écrit, test par test, `mod + tests` de `src/` compris ; doc-comments des tests 15-8a/15-8b. +- [x] **T7 — E2E** (AC 22) *(ex-T10)*. +- [x] **T8 — Mutations** (AC 19, part A) *(ex-T11, part A)*. +- [x] **T9 — Documentation** (AC 23, part A) *(ex-T12, part A)* — manuels + PDF aplatis, + `api-external.md`, CHANGELOG. +- [x] **T10 — Gates** *(ex-T13)* — base remise à zéro (DROP/CREATE de **ses** bases, jamais de + redémarrage du conteneur) ; `scripts/test-fast.sh` complet (story `kesh-db` : gate complet même en + cours de boucle) ; frontend (`check`, `lint-i18n-ownership`, `test:unit`, `build`) ; E2E complet au + **dernier commit de code**, jugé fichier par fichier contre `docs/testing.md` § « Les échecs + attendus ». Ne déclarer que ce qui a tourné. + +## Dev Notes + +### Pourquoi la clôture, et pas seulement la garde d'écriture + +L'issue propose deux voies. Garder seulement l'écriture laisserait l'état fautif atteignable par un clic, +et imposerait une garde sur chaque flux — c'est ce que la 15-8a a fait pour deux routes, et l'issue est +née de ce qu'il en restait dix-neuf. Ordonner la clôture rend l'état **inatteignable** à partir d'un état +sain : la garde d'écriture de la 15-12b n'est plus qu'un filet pour les données héritées. + +### Pourquoi la clôture verrouille ses antérieurs (l'invariant sous concurrence) + +L'étape (a) de `close` est une lecture cohérente qui fixe la vue de sa transaction ; si une réouverture +de N est en cours (non validée), cette vue montre N **clos** ; une relecture (d) non verrouillante y lirait +« aucun antérieur ouvert », la clôture de L passerait après la validation de la réouverture, et l'état +« N ouvert, L clos » serait atteint **par l'API**. Le verrou (b') attend la réouverture, et la relecture +verrouillante (d) lit N ouvert. Test 13 a, mutation (ii). C'est la propriété (α) sur laquelle la 15-12b fonde la preuve de son +filet non verrouillant. + +⚠️ **Angle mort assumé — la restauration en vol** (C100). `admin::full_import` ne se sérialise qu'avec +les autres imports (verrou `_kesh_version id = 1 FOR UPDATE`, `routes/admin.rs:173-179`), **pas** avec la +clôture ni les écrivains : c'est le cas, préexistant et plus large, de toute écriture en vol pendant une +restauration. Écrit au doc-comment du module (la 15-12b l'écrit aussi à la ligne `admin::full_import` du +registre). + +### Ordre des verrous — ce qui change, ce qui ne change pas + +- **Change** : `close` prend les antérieurs connus un par un par clé primaire, dans l'ordre + chronologique fixé par le code, puis Y, puis relit les antérieurs ouverts sous verrou (C119) ; + `create` prend en plus les postérieurs (`find_later_closed_in_tx`) après ses deux pré-contrôles. +- **Ne change pas, et c'est voulu** : `find_open_covering_date` (C119 révise C110 — un `ORDER BY` + n'aurait fixé que son résultat, déjà unique, non l'ordre de ses verrous). +- **Ne change pas** : `update`, `delete_in_tx`, `reopen`, les flux d'écriture (la 15-12b n'y ajoute + qu'une lecture non verrouillante et la levée d'une condition). +- **L'ordre réel des autres flux** (C100) : la contre-passation (`reverse_in_tx_inner`) verrouille + l'origine et son exercice par clé primaire, puis `find_open_covering_date(today)` parcourt l'index + depuis le premier exercice et verrouille les exercices antérieurs à l'origine **après** elle ; les + annulations (`supplier_invoices::cancel_in_tx`, `cancel_settlement_in_tx`, + `invoice_settlements_write::cancel_settlement_in_tx`, `reconciliation_cancel::cancel_in_tx`) + verrouillent d'abord la pièce, puis l'écriture d'origine et son exercice, puis passent par ce socle. + Règlements, paiements et saisie prennent l'exercice d'abord (`find_open_covering_date`, parcours dans + l'ordre du plan, puis l'exercice par clé primaire, déjà tenu) et rien d'autre dans `fiscal_years`. +- Interblocages possibles, tous résolus par le rejeu des **deux** côtés : création contre clôture + (AC 13 b, si la mesure en montre un) ; **contre-passation et annulations contre clôture** (AC 3, + test 13 d) ; **contre-passation et annulations contre création** (AC 3, F5) — les cinq routes sont + `Rejouee`, la clôture et la création le deviennent (AC 6). +- **Hors de cette story, écrit** : la réouverture, non rejouée, peut être la victime d'un cycle avec + une contre-passation (elle tient Y-1 et demande les postérieurs ; la contre-passation tient une + origine postérieure et parcourt depuis le premier exercice). Préexistant, sans rapport avec la + clôture dans l'ordre : nommé au point (iv) du registre (AC 6), qui le porte déjà pour `/reopen`. +- **Hors de cette story, écrit — cycle clôture ↔ renommage** *(T0, R9 de P4)* : `update_name` tient + l'exercice renommé Y (`LOCK_IN_COMPANY_SQL`), puis verrouille l'exercice **homonyme** + (`… WHERE company_id = ? AND name = ? AND id <> ? FOR UPDATE`). Renommer Y au nom d'un antérieur M + tient Y et demande M ; `close(Y)` tient M (étape (b')) et demande Y : cycle, **nouveau** (la clôture + ne verrouillait pas ses antérieurs). La victime peut être le renommage, route + `PUT /fiscal-years/{id}` **non rejouée** → 500, sur un renommage que le pré-contrôle aurait refusé + de toute façon (`FY_NAME_DUPLICATE`). Très rare et bénin ; nommé au point (iv) du registre avec + `/reopen`, pas de code. +- **Attente bornée** *(T0, F6 de P4)* : la clôture attend désormais en étape (b') tout écrivain en vol + qui tient un exercice antérieur ; au-delà de `innodb_lock_wait_timeout` (50 s), `1205` → 500, non + rejoué. Préexistant pour la contre-passation, nouveau pour la clôture ; accepté (une transaction + d'écriture dure quelques millisecondes). + +### Codes et messages + +| Situation | Code | HTTP | Clé | +|---|---|---|---| +| Clôture avec un antérieur ouvert | `EARLIER_FISCAL_YEAR_OPEN` (neuf) | 409 | `error-fiscal-year-close-earlier-open` | +| `PUT` / `DELETE` d'une écriture, création d'exercice, sous un postérieur clos | `LATER_FISCAL_YEAR_CLOSED` | 400 | `error-later-fiscal-year-closed` (neuve) | +| Fiche d'écriture, motif du blocage (écran) | — | — | `journal-entries-modify-blocked-later-fiscal-year-closed` (gardée, prescription alignée) | +| Bouton « Clôturer » désactivé | — | — | `fiscal-year-close-blocked-earlier-open` | + +409 pour la clôture, comme les autres refus de transition de cet écran (déjà clos, réouverture LIFO) ; +400 pour `LATER_FISCAL_YEAR_CLOSED`, inchangé (15-8a : l'état d'un exercice, pas un conflit sur +l'objet). + +### Ce qui doit être préservé + +- Le comportement de la 15-8a/15-8b (`PUT`, `DELETE`, écran de la fiche) — codes, `details`, ordre ; + seul le **texte** du refus change (AC 9). +- Les tests qui posent l'état fautif par SQL : ils deviennent les seuls moyens de l'exercer, ne pas + les « simplifier » en passant par l'API. +- `create_for_seed`, `create_if_absent_in_tx` : inchangés (AC 5). +- `admin::full_import` : accepte une sauvegarde fautive (C89). + +### Hors périmètre, et écrit + +- Le filet, le lot de rapprochement, le bandeau d'état hérité, la réparation : **15-12b**. +- Toute clôture « réelle » (écritures de clôture, report physique) — Kesh clôture par verrou. +- Une route de réparation automatique — la réparation est comptable. +- **Les autres textes qui prescrivent une réouverture sans dire dans quel ordre** (F8, C122) — même + symptôme que celui que C111 corrige pour l'ancienne clé, mais préexistant et hors du défaut de #543 : + ils disent « un administrateur doit rouvrir l'exercice », geste que la garde LIFO refuse dès qu'un + exercice plus récent est clos. Relevé par la valeur sur `5e4bec50` (`grep -rnE "rouvrir + l'exercice|doit d'abord le rouvrir|rouvrez-le"` hors fiches et PDF) : fr-CH + `settlement-cancel-blocked-fiscal-year-closed` (`:812`), `reconciliation-cancel-blocked-fiscal-year-closed` + (`:820`), `supplier-invoices-cancel-blocked-fiscal-year-closed` (`:1949`), `error-fiscal-year-reopen-blocked` + (`:922`, « rouvrez-le d'abord » sans dire lequel — le bouton le nomme, l'API non), + `error-opening-balances-first-year-closed` (`:959`) et leurs trois autres locales ; replis Rust + (`kesh-api/src/errors.rs:2978`, `:3509`, `:3546`, et — complétés en T0, R6 de P4 — + `kesh-api/src/routes/fiscal_years.rs:178`, `routes/opening_balances.rs:205`, `:406`) et frontend + (`settlement-cancel-blocked.ts:39`, `reconciliation-cancel.ts:60`, `invoice-cancel.ts:43`) ; manuel + utilisateur (`:719` — « peut rouvrir l'exercice directement », légitime pour la procédure —, + `:1405`, `:1427`, `:1714`, `:2242`). ⚠️ Des tests Vitest **figent** le texte courant + (`InvoiceSettlements.test.ts`, `reconciliation-cancel.test.ts`, `CancelReconciliationDialog.test.ts`, + `settlement-cancel-blocked.test.ts`, `invoice-settlements-page.test.ts`) : l'issue les nommera. **Issue + [#569](https://github.com/guycorbaz/kesh/issues/569)** (P3 : texte qui décrit un geste que le code + refuse dans un cas), qui les alignera sur la prescription de C111 — « en commençant par le plus + récent ». Les trois replis Rust d'`errors.rs` (règlement, rapprochement, facture fournisseur) la + citent en commentaire depuis la revue P1 (A3/E3). + +### Dérogation règle de splitting + +*(Section rédigée en P3 — F9, décision de l'orchestrateur, C121.)* + +- **Le critère franchi** : le premier critère de la § « Règle de splitting préventif » du `CLAUDE.md` + (plus de 5 modules). Recompté à la découpe : **8** modules de premier niveau — + `kesh-db/repositories/fiscal_years`, `kesh-db/errors`, `kesh-db/repositories/journal_entries` (un + doc-comment, AC 14) ; `kesh-api/routes/fiscal_years`, `kesh-api/errors` (mapping 409, clé de l'AC 9) ; + `kesh-i18n` ; `frontend/routes/(app)/settings/fiscal-years` ; `frontend/features/journal-entries` + (repli de `blocker-messages.ts`, AC 9). Hors décompte : tests, doc (dont + `MULTI-TENANT-SCOPING-PATTERNS.md`). La remédiation P3 (C119) ne change pas ce compte : la clôture + « un par un » reste dans `fiscal_years`, et `OPEN_COVERING_DATE_SQL` n'est plus touchée. +- **Pourquoi l'exception codifiée s'applique** : la story a **déjà** été découpée une fois (C107), selon + la seule couture naturelle — l'ordre (cette fiche) et le filet (15-12b). Un second découpage + séparerait l'ordre de `close` de sa concurrence (AC 3, AC 13 : les tests à deux connexions sont la + seule preuve de l'ordre), ou la garde de son écran et de son message (AC 1, 4, 9, 17 : le bouton + désactivé et le refus serveur se testent ensemble, Vitest et E2E compris) : des sous-stories qui ne + seraient **pas testables isolément** — l'exception que le `CLAUDE.md` écrit (« merges intermédiaires + impossibles à tester en isolation »). Trois des huit modules ne portent qu'un ou deux sites (un + doc-comment, un repli, des clés). +- **Risque accepté** : une revue de code qui embrasse huit modules relit moins finement chacun ; la + remédiation d'une passe peut introduire un défaut dans un module que la passe suivante ne relit pas. + Mitigation : la passe ciblée sur chaque remédiation (§ *La passe ciblée*), et les inventaires par + recherche (AC 21, AC 23) refaits à chaque passe. +- **Signal déclaré au Project Lead** (Guy), dans cette fiche et au registre (C121). + +### Tests — ce qui rendrait un test vert sans rien prouver + +- Un test de concurrence qui n'asserte que « pas de panique » (le test actuel `:1055` en est un : il + admet l'état fautif comme issue). +- Une attente qui ne distingue pas « en cours » de « bloquée » prise pour une preuve de blocage : c'est + le montage qui fait l'attente (AC 13). +- Une enveloppe de rejeu écrite par le test lui-même, prise pour la preuve de celle de la route (R2). +- Une mutation restaurée par `cp`/`mv` sans `touch` : le binaire muté reste en cache. + +### Fichiers touchés (prévision) + +`crates/kesh-db/src/repositories/fiscal_years.rs`, `crates/kesh-db/src/repositories/journal_entries.rs` +(doc-comment de `reverse_in_tx_inner`), `crates/kesh-db/src/errors.rs`, +`crates/kesh-db/tests/fiscal_years_repository.rs`, `crates/kesh-db/tests/journal_entries_modification.rs` +(doc-comments), `crates/kesh-api/src/errors.rs`, `crates/kesh-api/src/routes/fiscal_years.rs`, +`crates/kesh-api/tests/fiscal_years_e2e.rs`, `crates/kesh-api/tests/rejeu_interblocage_e2e.rs`, +`crates/kesh-api/tests/audit_route_registry.rs` (doc-comment, points (iv) et (vi)), tests listés à +l'AC 21, `crates/kesh-i18n/locales/{fr-CH,de-CH,it-CH,en-CH}/messages.ftl`, +`frontend/src/routes/(app)/settings/fiscal-years/+page.svelte` et son test, +`frontend/src/lib/features/journal-entries/blocker-messages.ts`, +`frontend/tests/e2e/fiscal-years.spec.ts`, `docs/manual/fr/{user,admin}-manual.{tex,pdf}`, +`docs/api-external.md`, `docs/MULTI-TENANT-SCOPING-PATTERNS.md`, `CHANGELOG.md`. +**Aucune migration** (P1-P8 sans objet). + +### References + +- Issue #543 et ses deux commentaires (15-8a, 15-8b). +- `crates/kesh-db/src/repositories/fiscal_years.rs` — `OPEN_COVERING_DATE_SQL` `:528`, + `find_open_covering_date` `:550`, `find_later_closed_in_tx` `:646`, `FIND_LATER_CLOSED_SQL` `:668`, + `close` `:773`, `reopen` `:872`. +- `crates/kesh-db/src/repositories/journal_entries.rs` — `reverse_in_tx_inner` `:2175` (`:2300`). +- `crates/kesh-api/src/errors.rs:2815-2842` ; `crates/kesh-api/src/routes/fiscal_years.rs:279` + (`close_fiscal_year`), `:218` (`create_fiscal_year`). +- `crates/kesh-api/tests/audit_route_registry.rs` ; `crates/kesh-api/tests/rejeu_interblocage_e2e.rs` + (tests 2 à 5, `transaction_lourde`, `capture_rejeu`) ; `crates/kesh-db/src/test_fixtures.rs:539-590`. +- `crates/kesh-report/src/balance_sheet.rs:1-20` (bilan cumulatif). +- Fiches `15-12b-filet-sous-un-bilan-clos.md` (suite), `15-12-cloture-dans-l-ordre.md` (index ; version + complète au commit `dae3a618`), `15-8a-modifier-une-ecriture.md`, `15-8b-supprimer-une-ecriture.md`, + `15-5e1-socle-rejeu.md`, `15-1a-socle-lettrage.md` (frontière). +- `CLAUDE.md` § Pattern batch, § Règle de splitting, § Propagation post-patch. + +## Dev Agent Record + +### Agent Model Used + +Claude Opus 5.5 (agent de développement, worktree `/home/gcorbaz/devel/kesh-15-12a`). + +### Mesures T0 (2026-10-09, MariaDB 10.11.16, base de mesure `kesh_1512a_t0`) + +- **Isolement et délai** : `REPEATABLE-READ`, `innodb_lock_wait_timeout = 50`. +- **`EXPLAIN` (descriptif, C119 — rien n'en dépend)** de `FIND_EARLIER_OPEN_SQL` (étape (d)) et de + `FIND_LATER_CLOSED_SQL`, chacun en `… FOR UPDATE`, ainsi que de `LIST_EARLIER_SQL` : + - régime « une société » (quatre exercices, le plus ancien créé en dernier — `id` inversés), avant et + après `ANALYZE TABLE` : `type = range`, `key = uq_fiscal_years_company_start_date`, `Extra = Using + index condition; Using where` (liste : `Using where; Using index`) — **pas de `filesort`** ; + - régime « plusieurs sociétés » (six sociétés, vingt exercices chacune), avant et après `ANALYZE` : + même plan, `rows` 15-16 (la société seule). + ⇒ `FIND_LATER_CLOSED_SQL` **suit l'index** dans les deux régimes : la limite préexistante de `reopen` + et de la garde 15-8a (plan qui ne suivrait pas l'index) n'est **pas** observée ; rien à signaler. +- **AC 13 b — création contre clôture** (trois sessions `mariadb`, la clôture bloquée en étape (c) par + une session qui tient Y, la création lancée 0,5 s après) : + - **b1** (aucun exercice antérieur à X) : **sérialisation** — la création bute sur Y dans + `find_later_closed_in_tx`, attend la clôture, lit Y clos (refus `LaterFiscalYearClosed`). + - **b2** (M clos antérieur à X) : **interblocage** (1213) — victime observée : la création ; la + clôture valide. (Mesure faite d'abord sans l'étape (b') par erreur de script : la victime était + alors la clôture, en étape (d) ; refaite avec (b').) +- **Preuve HTTP de l'enveloppe de création** (montage de l'AC 6) : W alourdie (500 lignes) tient Y ; la + création bute en `find_later_closed_in_tx` ; W demande M → **la création est la victime, W obtient + M**. Déterministe sur deux essais. ⇒ test HTTP écrit, mutation (x) testée. +- **AC 13 d — clôture contre contre-passation** (M clos, N ouvert, T ouvert couvrant le jour) : W tient + N ; la clôture prend M (b') et bute sur N (c) ; W lance `OPEN_COVERING_DATE_SQL … FOR UPDATE` : + **interblocage, victime la clôture**, que W soit alourdie ou non ; W obtient M. +- **Écritures de `fiscal_years` hors tests** (`git grep`) : `INSERT` (`create`, `create_if_absent_in_tx`, + `insert_fiscal_year_in_tx`), `UPDATE` de statut (`close`, `reopen`) et de nom (`update_name`), + `DELETE` (`kesh-seed` `reset_demo`) ; les autres (`accounts.rs`, `invoices.rs`, `journal_entries.rs`) + sont dans des `mod tests`. Aucun `UPDATE` de `start_date`. +- **`find_later_closed(_in_tx)?`** : la version verrouillante est appelée par `reopen` + (`fiscal_years.rs:911`), `journal_entries::update` (`:1365`) et `delete_in_tx` (`:1682`) ; la version + libre par `GET /journal-entries/{id}` (`:1101`). Conforme à l'AC 14. +- **Motifs existants** `["ORDER BY start_date ASC", "FOR UPDATE"]` (`journal_entries_modification.rs:410`, + `:511`) : `journal_entries::update` / `delete` n'appellent pas `close`, et aucune autre connexion + n'exécute de requête pendant ces attentes — **laissés tels quels** (AC 21). +- **Ancienne clé (AC 9)** : sites relocalisés (fr-CH `:376`, autres `:382`, `blocker-messages.ts:94`, + repli Rust `errors.rs:2852`) ; seule assertion de test sur son texte : + `blocker-messages.test.ts:27`, fragment conservé par la réécriture. +- **Forme de `PROCESSLIST.INFO`** (constatée en T4, dans un test à deux connexions) : le texte de la + requête préparée **avec ses `?`**, non substitués (« … WHERE company_id = ? AND start_date <= ? AND + end_date >= ? LIMIT 1 FOR UPDATE »). Les motifs, qui évitent `?`, valent dans les deux cas. +- ⚠️ **Écart de mesure 13 b1** : à la main (T0), la création passait `find_overlapping` et butait dans + sa garde ; **dans le test** (`#[sqlx::test]`), elle bute dès `find_overlapping`, sur Y (borne de son + parcours), que la session W tient. Même issue (sérialisation, création refusée en une tentative) ; + le test fait foi et son doc-comment le dit. En 13 b2 comme dans le montage HTTP de l'AC 6, la + création bute aussi dans `find_overlapping` (sur M en b2) : les motifs des tests visent ce + pré-contrôle (`["end_date >= ", "LIMIT 1 FOR UPDATE"]`), ou un motif tolérant au test HTTP. + +### Debug Log References + +- Gate backend : `target/gate-logs/15-12a-gate-backend.log` (worktree) ; frontend : + `target/gate-logs/15-12a-gate-frontend.log` ; E2E : `target/gate-logs/15-12a-e2e.log`, backend + `target/gate-logs/15-12a-backend-e2e.log`. + +### Completion Notes List + +**Commits** (sur `5e4bec50` + planification `77194098`) : `15144b1f` (T0), `1d17cc2c` (dépôt, T1/T2/T4), +`15814d29` (HTTP, message, rejeu, T3), `a29cc474` (écran, T5), `f0bee2a0` (E2E et doc-comments des tests +15-8a/15-8b, T6/T7), `2c69ce4b` (documentation, T9). + +**Ce qui est livré** : +- `DbError::EarlierFiscalYearOpen` ; `close` en étapes (a)-(e) — constantes `START_DATE_IN_COMPANY_SQL`, + `LIST_EARLIER_SQL`, `LOCK_EARLIER_BY_ID_SQL`, `LOCK_IN_COMPANY_SQL` (partagée avec `reopen` et + `update_name`, qui l'écrivaient chacune à la main), `FIND_EARLIER_OPEN_SQL` ; `OPEN_COVERING_DATE_SQL` + inchangée (C119). +- `create` : garde `find_later_closed_in_tx` après les pré-contrôles ; `create_for_seed` et + `create_if_absent_in_tx` inchangés, leur doc-comment dit pourquoi. +- HTTP : `409 EARLIER_FISCAL_YEAR_OPEN` ; `400 LATER_FISCAL_YEAR_CLOSED` à la création, message propre + (`AppError::FiscalYearBeforeClosedYear`, clé `error-fiscal-year-create-later-closed` — C-15-12a-1) ; + clé neuve `error-later-fiscal-year-closed` au mapping global ; corps `LATER_FISCAL_YEAR_CLOSED` construit + par une seule fonction (`later_fiscal_year_closed_response`) pour les deux messages ; ancienne clé + alignée aux quatre locales et au repli de `blocker-messages.ts`. `grep` de l'ancienne prescription + (`rouvrir cet exercice|reopen that fiscal year|wieder eröffnen;|riaprire quell`) hors fiches et PDF : + **0 résultat**. +- Enveloppes `retry_on_deadlock("fiscal_years::create" | "fiscal_years::close", …)` dans les routes ; + doc-comment du registre (points (ii), (iii bis), (iv) — cycle renommage/clôture nommé, `/close` retirée + — et (vi) : **quatre** routes) ; en-tête de `rejeu_interblocage_e2e.rs`. +- Écran : `earliestEarlierOpen`, bouton désactivé (`data-testid="fiscal-year-close-{id}"`, `title`), + `submitClose` traite `EARLIER_FISCAL_YEAR_OPEN` avant `ILLEGAL_STATE_TRANSITION` ; la boîte de + création affiche `err.message` (chemin générique **vérifié** dans `submitCreate`). +- Doc-comments : module `fiscal_years.rs` (invariant I, portée, angle mort de la restauration, ordre des + verrous), `close`, `create`, `FIND_LATER_CLOSED_SQL`, `find_later_closed_in_tx`, + `DbError::LaterFiscalYearClosed`, `reverse_in_tx_inner` ; `docs/MULTI-TENANT-SCOPING-PATTERNS.md`. + +**Tests ajoutés** (recomptés `grep -cE '#\[(sqlx|tokio)::test'` aux deux bornes `5e4bec50` → `HEAD`) : +`fiscal_years_repository.rs` 40 → 53 (+14 neufs, −1 remplacé : `reopen_close_concurrent_is_serialized` +→ 13 a) ; `fiscal_years_e2e.rs` 41 → 44 (+3) ; `rejeu_interblocage_e2e.rs` 7 → 9 (+2, tests 9 et 10) ; +Vitest `fiscal-years-page.test.ts` 6 → 9 (+3). Backend : +18, cohérent avec le gate (2879 selon le +registre de sprint, compté sur l'état rebasé sur `8f9811d8` et **non rejoué** sur `5e4bec50` — précision +de la revue P1, A7 → 2897). + +**Mutations (AC 19), jouées et constatées** (chacune restaurée par copie puis `touch`) : +- (i) garde de `close` neutralisée → **3 rouges** : `close_is_refused_while_an_earlier_year_is_open`, + `close_names_the_oldest_earlier_open_year_whatever_the_ids`, `close_sees_a_concurrent_reopening_of_an_earlier_year`. +- (ii) `FIND_EARLIER_OPEN_SQL` sans `FOR UPDATE` → **2 rouges** : 13 a et 13 c. +- (iii) étape (b') retirée → **observation**, sur une variante temporaire du 13 a (W verrouille N, lance + la clôture, puis lit les postérieurs) : avec le code réel, aucun interblocage ; sous la mutation, **W + — la réouverture — reçoit un 1213** (soit un 500 côté route, `reopen` n'étant pas rejouée). Le 13 a tel + que monté reste vert, comme prévu. Variante retirée. +- (iv) garde de `create` neutralisée → **3 rouges** : `create_is_refused_before_a_closed_year`, 13 b1, 13 b2. +- (viii) `EARLIER_FISCAL_YEAR_OPEN` routé dans la branche « déjà clôturé » → Vitest **rouge** + (« refus serveur EARLIER_FISCAL_YEAR_OPEN… »). +- (ix) enveloppe de la clôture ramenée à **une** tentative (`retry_on_deadlock_with(…, 1, …)`, c'est-à-dire + sans rejeu, le nom restant visible du volet (c)) → test HTTP 9 **rouge** ; (x) idem pour la création → + test HTTP 10 **rouge**. ⚠️ Forme de la mutation choisie pour garder l'appel nommé : un appel nu ferait + rougir le test **et** changerait le texte que lit le registre ; l'effet mesuré est le même (aucun rejeu). +- Après restauration : fichier de tests du dépôt 53/53, tests HTTP de rejeu 2/2, Vitest 9/9. + +**Triage des tests existants (AC 21)** — au gate complet, **aucun rouge** à trier hors des deux tests +repris d'avance : `reopen_lifo_three_years_intercalated` (FY3 clos **par SQL**, catégorie (a), état +hérité) et `reopen_close_concurrent_is_serialized` (remplacé par le 13 a). Les autres sites de +l'inventaire (`fiscal_years_e2e.rs`, `reconciliation_e2e.rs`, `invoice_settlement.rs`, +`supplier_invoices_repository.rs`, `opening_balances_*`, `invoice_echeancier_e2e.rs`, les deux tests du +`mod tests` de `journal_entries.rs` sur la base partagée) clôturent dans l'ordre ou un exercice seul : +verts. Aucune création ne rougit en `LATER_FISCAL_YEAR_CLOSED`. Doc-comments des deux tests 15-8a/15-8b +« clôture **en cours** » réécrits (transition simulée par SQL). Motifs `["ORDER BY start_date ASC", "FOR +UPDATE"]` de `journal_entries_modification.rs` **laissés tels quels** (aucune clôture ne tourne pendant +leurs attentes). + +**Documentation** : manuel utilisateur (création, clôture dans l'ordre, item « Verrouille l'exercice » +réécrit, réouverture, `:752`, `:762`), manuel administrateur (paragraphe « Clôture dans l'ordre »), +`api-external.md` (ligne `409 EARLIER_FISCAL_YEAR_OPEN`, création parmi les routes de +`LATER_FISCAL_YEAR_CLOSED`, rejeu des deux routes), CHANGELOG `[0.13.0]` (`Modifié` : contrat d'API ; +`Corrigé` : le défaut). PDF régénérés (`make fr`), **contrôlés aplatis** : les phrases neuves présentes +dans les deux PDF ; la phrase réfutée « clôturer l'exercice suivant fige aussi celui-ci » absente (0). +Un code `\texttt` qui débordait de la marge dans le PDF administrateur (tronqué en +`EARLIER_FISCAL_YEAR_OP`) a été rendu sécable (`\allowbreak`) et revérifié. La brochure, régénérée par +`make fr` sans changement de source, n'est **pas** commitée. README inchangé (correctif, vérifié). + +**Gates au dernier commit de code** (`2c69ce4b` porte la doc et les PDF ; le dernier commit de code est +`f0bee2a0` — aucun code n'a changé depuis, la suite E2E a tourné sur `2c69ce4b`) : +- bases `kesh_1512a` / `kesh_e2e_1512a` remises à zéro (DROP/CREATE, migrations, seed) ; +- `scripts/test-fast.sh` (fmt + clippy `-D warnings` + nextest) : **2897 / 2897, 4 ignorés** ; +- frontend : `npm run check` 0 erreur (27 avertissements préexistants), `lint-i18n-ownership` PASS, + `test:unit` **1094 / 1094** (112 fichiers), `build` OK ; +- E2E complet (backend `:3012` sur `kesh_e2e_1512a`, secrets `openssl rand`, montage complet de + `docs/testing.md` — SMTP, inbox, documents ; `smtpConfigured:true`), lancé vers 00:44 UTC (backend + démarré à 00:18 UTC, journal clos à 00:59 UTC ; heure corrigée en revue P1, A7) : **245 + passés, 9 échecs, 19 ignorés** (14,7 min). Les 9, jugés **fichier par fichier** contre + `docs/testing.md` § « Les échecs attendus » : sept KF-029 (#97 — `mode-expert.spec.ts:26`, `:41`, + `onboarding-path-b.spec.ts:65`, `:92`, `onboarding.spec.ts:57`, `:77`, `:150`) et deux KF-045 + (#421 — `invoices.spec.ts:415`, `:439`, attendus **avant 12:00 UTC**). Aucun huitième variable, + aucun échec hors liste. Le scénario réécrit `fiscal-years.spec.ts:59` (clôture dans l'ordre) passe. + Backend arrêté par son PID. + +**Points à vérifier en revue** : +- La garde de création (AC 5) n'est pas seule à verrouiller : en 13 b1/b2 et au test HTTP, c'est + `find_overlapping` qui bute — à lire contre l'AC 13 b (« la création postérieure attend Y »). +- La mutation (ix)/(x) a été jouée par réduction à une tentative, non par appel nu (voir plus haut). +- R7 de P4 (fiche 15-1a) reste à l'orchestrateur ; l'issue des « autres textes de réouverture sans + ordre » (C122) est **#569**. + +### Remédiation de la revue de code P1 (2026-10-09) + +Rapports : `target/gate-logs/15-12a-review-p1-{B,E,A}.md` (worktree). Choix : C-15-12a-2, -3, -4 ; +suivi #569 ajouté à C122. + +- **A1 (MEDIUM) = B-2 = E1** — test **13 b3** `close_waits_for_a_creation_whose_guard_holds_the_later_year` + (W : garde de `create` puis `INSERT`, sans `find_overlapping` ; clôture attendue en (c) ; + `EarlierFiscalYearOpen` nommant X). **Mutation (xi)** — `FOR UPDATE` retiré de + `find_later_closed_in_tx` — **jouée : 1 rouge, le 13 b3** (« état fautif atteint : X ouvert sous Y + clos ») ; 13 b1 et 13 b2 **verts** sous elle, ce qui confirme le constat de la revue. Restaurée par + copie puis `touch`. Fiche (AC 3, AC 6, AC 13 b, AC 19), commentaire du site de la garde + (`fiscal_years.rs`), doc de `create`, de `close` (« ce que cette forme perd ») et de + `find_later_closed_in_tx`, doc-comments des 13 b1 / b2 : chacun dit ce qu'il prouve. Branche + inatteignable de `jamais_x_ouvert_sous_y_clos` commentée ; le fantôme validé entre (a) et (c) écrit + « sans test ». +- **B-1 (MEDIUM) = E4** — `error-later-fiscal-year-closed` (4 locales + repli Rust) ne nomme plus que la + modification et la suppression ; assertion ajoutée à + `a_closed_later_year_freezes_the_entry_until_reopened` de `journal_entry_reversal_e2e.rs` (contient « ne peut être modifiée ni + supprimée », pas « enregistr »). Grep par la valeur (« enregistr », « recorded », « erfasst », + « registrat ») : plus aucun site hors fiches et registre. La 15-12b élargira le texte (C-15-12a-3). +- **B-5** — deux clés voisines gardées, pourquoi écrit au mapping ; registre italien tranché par le + glossaire (tutoiement des clés de l'écran des exercices), C-15-12a-3. +- **B-3** — infobulle portée par une enveloppe `` (bouton désactivé = `pointer-events: + none`), pour « Clôturer » **et** « Réouvrir » ; Vitest et E2E assertent l'enveloppe. Mutation + « `title` retiré de l'enveloppe » jouée : Vitest rouge. +- **A6** — Vitest « refus serveur LATER_FISCAL_YEAR_CLOSED à la création : message affiché dans la + boîte » ; mutation « `createError = err.message` remplacé » jouée : rouge. +- **E5** — `create_ignores_the_closed_years_of_another_company` (mutation « `company_id` neutralisé dans + `FIND_LATER_CLOSED_SQL` » jouée : seul ce test rougit), `create_right_after_a_closed_year_is_allowed` + (frontière : antérieur clos adjacent ; le cas postérieur adjacent est déjà + `create_is_refused_before_a_closed_year`), HTTP `both_refusals_reach_a_read_write_api_key` (409 et 400 + par clé `read-write`). +- **A2** — références à `reopen_close_concurrent_is_serialized` (`opening_balances_e2e.rs`, + `opening_balances_repository.rs` ×2) : « l'ancien …, remplacé par la Story 15-12a ». +- **A3 / E3** — #569 cité dans la fiche (Hors périmètre, Points à vérifier), au registre (C122) et en + commentaire aux trois replis Rust d'`errors.rs` (règlement, rapprochement, facture fournisseur). + #568 (prédicteurs, 15-12b) non cité dans `api-external.md` : hors de ce que dit la ligne. +- **A4** — `api-external.md:253` borné aux chemins d'écriture d'une écriture comptable. +- **A7** — heure du run E2E corrigée (vers 00:44 UTC), origine du 2879 précisée. +- **E2** — doc de module : cycle réouverture ↔ contre-passation d'un postérieur nommé (non rejouée, 500 + possible, préexistant). **B-6** — coût de (b') écrit au doc de `close`. **B-4** — assertion + disjonctive du 13 d expliquée dans son doc-comment. +- **A5 / B-7** — écrits, sans changement : (ix)/(x) jouées par réduction à une tentative, l'appel nu + reste une variante **non jouée** ; le cycle `reopen` ↔ contre-passation est préexistant (E2). +- **Non traité** : aucun. + +**Tests ajoutés par cette remédiation** (recomptés `grep -cE '#\[(sqlx|tokio)::test'`, `1f477526` → +arbre de travail) : `fiscal_years_repository.rs` 53 → 56 (+3), `fiscal_years_e2e.rs` 44 → 45 (+1) ; +Vitest `fiscal-years-page.test.ts` 9 → 10 (+1). Backend +4 (2897 → 2901), frontend +1 (1094 → 1095). + +**Gates (arbre de la remédiation, commité tel quel)** : +- `cargo fmt --all -- --check` vert ; `cargo clippy --workspace --all-targets -- -D warnings` vert ; +- bases `kesh_1512a` / `kesh_e2e_1512a` remises à zéro (DROP/CREATE, migrations, seed) ; + `scripts/test-fast.sh` : **2901 / 2901, 4 ignorés** (`target/gate-logs/15-12a-gate-backend-p1.log`) ; +- frontend : `check` 0 erreur (27 avertissements préexistants), `lint-i18n-ownership` PASS, + `test:unit` **1095 / 1095** (112 fichiers), `build` OK (`…/15-12a-gate-frontend-p1.log`) ; +- E2E complet (backend `:3012`, `kesh_e2e_1512a`, montage complet, `smtpConfigured:true`), vers 01:34 (fin à + 01:44 UTC) : **245 passés, 9 échecs, 19 ignorés** (10,2 min). Les 9, fichier par fichier contre + `docs/testing.md` : sept KF-029 (`mode-expert.spec.ts:26`, `:41`, `onboarding-path-b.spec.ts:65`, + `:92`, `onboarding.spec.ts:57`, `:77`, `:150`) et deux KF-045 (`invoices.spec.ts:415`, `:439`, run + avant 12:00 UTC). Aucun hors liste ; `fiscal-years.spec.ts:59` vert. Backend arrêté par son PID. +- Manuels non touchés (le texte promettait déjà l'infobulle, désormais tenue) : PDF non régénérés. + +### Clôture — rebase sur `origin/main` et gates finaux (2026-10-09) + +**Rebase** de la branche (11 commits) de `5e4bec50` sur `de285ea8` (porte 15-7a1 et 15-11b ; la +15-6a n'était pas encore sur `main`). Conflits, tous de registre ou de document : +`epic-15-choix-autonomes.md` (union, 276 entrées `## C…`, aucun identifiant en double — vérifié par +`grep -E '^## C' | sort | uniq -d`), `sprint-status.yaml` deux fois (lignes `last_updated` de la +branche renumérotées (27) et (28) derrière les (24)-(26) de `main` ; ligne 15-11b de `main` gardée, +lignes 15-12 / 15-12a / 15-12b de la branche gardées), `admin-manual.pdf` (binaire : `.tex` fusionné +sans conflit, PDF régénéré après rebase, contrôlé aplati — « Comment .env atteint Kesh » de la 15-11b +et « du plus ancien vers le plus récent » de cette story y figurent). `CHANGELOG.md`, `messages.ftl` +et `audit_route_registry.rs` fusionnés sans conflit (`main` n'a pas touché `audit_route_registry.rs` +depuis la base : partition inchangée). Aucun code de la branche ne lit l'environnement +(`git diff origin/main -- '*.rs' | grep 'env::var'` vide) : la règle `config::env_nonempty` de la +15-11b n'est pas en jeu. + +**Gates complets sur l'état rebasé** (dernier commit de code : `160ff2f0`, la remédiation P1 rebasée ; +aucun code ne change après) : +- frontend : `check` 0 erreur (27 avertissements préexistants), `lint-i18n-ownership` PASS, + `test:unit` **1095 / 1095** (112 fichiers), `build` OK (`target/gate-logs/15-12a-gate-frontend-final.log`) ; +- bases `kesh_1512a` / `kesh_e2e_1512a` remises à zéro (DROP/CREATE, migrations, seed), attente des + gates des autres agents (`wait-kesh.sh`) ; `scripts/test-fast.sh` (fmt + clippy + nextest) : + **2934 / 2934, 4 ignorés** (`…/15-12a-gate-backend-final.log`) ; +- E2E complet (backend `:3012` sur `kesh_e2e_1512a`, secrets `openssl rand`, SMTP factice, + `KESH_INBOX_DIR`/`KESH_DOCUMENTS_DIR` sous `target/e2e-dirs` du worktree, `smtpConfigured:true`), + 02:27-02:37 UTC : **244 passés, 10 échecs, 19 ignorés** (10,2 min, `…/15-12a-e2e-final.log`). Fichier + par fichier contre `docs/testing.md` : sept KF-029 (`mode-expert.spec.ts:26`, `:41`, + `onboarding-path-b.spec.ts:65`, `:92`, `onboarding.spec.ts:57`, `:77`, `:150`), deux KF-045 + (`invoices.spec.ts:415`, `:439`, run avant 12:00 UTC) et **`xss-token-protection.spec.ts:82`, faute + de montage** : `KESH_TEST_MODE=true` manquait **côté runner** (`docs/testing.md` § prérequis — la spec + lit la variable dans Playwright). Rejouée avec la variable contre le même backend : **3 / 3 passés** + (`…/15-12a-e2e-final-xss.log`). Aucun échec hors liste ; les 6 tests de `fiscal-years.spec.ts` verts. + Backend arrêté par son PID. + +### Intégration sur `39b52628` (2026-10-09) + +`main` a avancé à `39b52628` (15-6a mergée, #572) après la poussée de la clôture : nouveau rebase des +12 commits de la branche. Conflits : +- `epic-15-choix-autonomes.md` : union (316 entrées `## C…`, aucun identifiant en double, vérifié par + `sort | uniq -d`) ; +- `sprint-status.yaml`, trois fois : lignes `last_updated` de la branche renumérotées (30), (31), (32) + derrière les (27)-(29) de la 15-6a, sans doublon ; lignes 15-6a de `main` et 15-12 / 15-12a / 15-12b + de la branche gardées ; +- **code** — `crates/kesh-api/tests/rejeu_interblocage_e2e.rs` : les deux branches ajoutaient leurs + tests en fin de fichier (15-6a : test 15, l'avoir victime ; 15-12a : tests 9 et 10, clôture et + création victimes, et l'aide `exercice_sql`). Les deux intentions gardées : en-tête de module de la + 15-12a, puis le bloc de la 15-6a, puis celui de la 15-12a (fusion vérifiée contre la base commune : + seuls l'en-tête et les deux ajouts diffèrent) ; +- `admin-manual.pdf`, `user-manual.pdf` : `.tex` fusionnés sans conflit, les deux PDF régénérés après + rebase et contrôlés aplatis — témoins de la 15-6a (« Compte de différences d'arrondi », « Avant + d'archiver un compte de produit, pensez aux avoirs », « Avoir refusé : compte débiteurs »), de la + 15-11b (« Comment .env atteint Kesh ») et de cette story (« du plus ancien vers le plus récent ») + présents. +`CHANGELOG.md` (une rubrique `[0.13.0]` par section), `messages.ftl` (aucune clé en double dans les +quatre locales), `errors.rs` (Rust et dépôt) fusionnés sans conflit. + +**Gates complets sur l'état rebasé** : +- frontend : `check` 0 erreur (27 avertissements préexistants), `lint-i18n-ownership` PASS, + `test:unit` **1095 / 1095**, `build` OK (`target/gate-logs/15-12a-gate-frontend-39b5.log`) ; +- `wait-kesh.sh`, bases `kesh_1512a` / `kesh_e2e_1512a` remises à zéro ; `scripts/test-fast.sh` : + **2951 / 2951, 4 ignorés** (`…/15-12a-gate-backend-39b5.log`) ; +- E2E complet (backend `:3012`, `kesh_e2e_1512a`, secrets `openssl rand`, SMTP factice, répertoires + sous `target/e2e-dirs`, `KESH_TEST_MODE=true` **côté runner** cette fois), 02:57-03:07 UTC : **245 + passés, 9 échecs, 19 ignorés** — sept KF-029 (`mode-expert.spec.ts:26`, `:41`, + `onboarding-path-b.spec.ts:65`, `:92`, `onboarding.spec.ts:57`, `:77`, `:150`) et deux KF-045 + (`invoices.spec.ts:415`, `:439`, avant 12:00 UTC). Aucun hors liste ; les 6 tests de + `fiscal-years.spec.ts` verts. Backend arrêté par son PID. + +### File List + +- `crates/kesh-db/src/errors.rs`, `crates/kesh-db/src/repositories/fiscal_years.rs`, + `crates/kesh-db/src/repositories/journal_entries.rs` (doc-comment) +- `crates/kesh-db/tests/fiscal_years_repository.rs`, `crates/kesh-db/tests/journal_entries_modification.rs` +- `crates/kesh-api/src/errors.rs`, `crates/kesh-api/src/routes/fiscal_years.rs` +- `crates/kesh-api/tests/fiscal_years_e2e.rs`, `crates/kesh-api/tests/rejeu_interblocage_e2e.rs`, + `crates/kesh-api/tests/audit_route_registry.rs` (doc-comment) +- `crates/kesh-i18n/locales/{fr-CH,de-CH,it-CH,en-CH}/messages.ftl` +- `frontend/src/routes/(app)/settings/fiscal-years/+page.svelte`, `…/fiscal-years-page.test.ts` +- `frontend/src/lib/features/journal-entries/blocker-messages.ts`, `frontend/src/lib/shared/i18n-keys.test.ts` +- `frontend/tests/e2e/fiscal-years.spec.ts` +- revue P1 : `crates/kesh-api/tests/journal_entry_reversal_e2e.rs`, + `crates/kesh-api/tests/opening_balances_e2e.rs`, `crates/kesh-db/tests/opening_balances_repository.rs` +- `docs/manual/fr/{user,admin}-manual.{tex,pdf}`, `docs/api-external.md`, + `docs/MULTI-TENANT-SCOPING-PATTERNS.md`, `CHANGELOG.md` +- `_bmad-output/implementation-artifacts/{15-12a-cloture-dans-l-ordre.md, 15-12-cloture-dans-l-ordre.md, + sprint-status.yaml, epic-15-choix-autonomes.md}` + +## Change Log + +- 2026-10-09 — **Intégration sur `39b52628`** (15-6a) : rebase, conflit de code dans + `rejeu_interblocage_e2e.rs` résolu en gardant les deux ajouts, PDF régénérés ; gates complets : + backend 2951/2951, Vitest 1095/1095, E2E 245 / 9 attendus (Dev Agent Record). + +- 2026-10-09 — **Clôture** : boucle de revue de code close. Trend : **P1** (Sonnet ×3, lentilles B, E, A) + 0 C / 0 H / **2 MEDIUM** distincts d'origine + LOW → remédiation `37784da4` (rebasée `160ff2f0`) → + **P2 ciblée** (Haiku, une lentille sur la seule remédiation, prompt `15-12a-review-prompt-p2-ciblee.md`, + rapport `target/gate-logs/15-12a-review-p2-ciblee.md`) : **0** ; l'orchestrateur a vérifié lui-même le + test 13 b3 (garde réelle `find_later_closed_in_tx`, attente observée à (c) par + `attendre_une_requete_en_cours`, verdict `EarlierFiscalYearOpen` nommant X). La passe ciblée n'ayant + produit aucune remédiation, aucune ligne de production ne bouge après elle : boucle close. Rebase sur + `de285ea8` (15-7a1, 15-11b), gates complets sur l'état rebasé : backend 2934/2934, Vitest 1095/1095, + E2E 244 / 10 échecs dont 9 attendus et 1 de montage rejoué vert (Dev Agent Record, § « Clôture »). + Statut `done`. L'issue #543 reste ouverte : la 15-12b la fermera. + +- 2026-10-09 — **Revue de code P1** (Sonnet ×3, contexte frais, lentilles B, E, A ; prompt + `15-12a-review-prompt-p1.md`). Bruts : B 0 / 0 / 1 MEDIUM / 6 LOW, E 0 / 0 / 0 / 5 LOW, A 0 / 0 / 1 + MEDIUM / 6 LOW ; après dédoublonnage (B-1 = E4 ; A1 = B-2 = E1) : **0 CRITICAL, 0 HIGH, 2 MEDIUM** + distincts, le reste LOW. Les deux MEDIUM sont d'**origine** (aucun n'est né d'une remédiation) : le + verrou de la garde de création n'était prouvé par aucun test (A1 → test 13 b3, mutation (xi) rouge) ; + le message neutre promettait plus que le code (B-1 → texte borné à la modification et à la + suppression). Tous les LOW traités ou écrits (Dev Agent Record, § « Remédiation de la revue de code + P1 »). Choix C-15-12a-2 à -4. Gates complets sur l'arbre remédié : backend 2901/2901, Vitest + 1095/1095, E2E 245 / 9 attendus. ⚠️ La remédiation touche du code de production (texte du repli Rust + et des `.ftl`, enveloppe des boutons de l'écran) : la boucle n'est pas close — passe ciblée suivante. +- 2026-10-09 — **Développement** (T1-T10) : clôture dans l'ordre, garde de création, 409 + `EARLIER_FISCAL_YEAR_OPEN`, message neutre et message de création, rejeu des deux routes, écran, + E2E, documentation. Gates au dernier commit de code : backend 2897/2897, Vitest 1094/1094, E2E 245 / + 9 attendus (7 KF-029 + 2 KF-045). Mutations (i), (ii), (iv), (viii), (ix), (x) tuées, (iii) + observée. Statut `review`. +- 2026-10-09 — **T0 (développement)** : 15 des 16 LOW de la validation P4 appliqués à la fiche (F1 = R5 + constante `LOCK_IN_COMPANY_SQL` ; F2 bornes de `i18n-keys.test.ts` ; F3 textes DE/IT/EN, glossaire, + message propre à la création — C-15-12a-1 ; F4/F7 manuel ; F5 immutabilité de `start_date` ; F6 qui + libère W au 13 d, trois tentatives, `1205` ; R1, R2 numéros de ligne ; R3 mutations des 13 b / 13 d ; + R4 désignations ; R6 inventaire hors périmètre ; R8 `sprint-status.yaml` — entrées 15-12 reportées sur + la branche ; R9 cycle clôture ↔ renommage). R7 (fiche 15-1a) laissé à l'orchestrateur ; frontière 15-1a + mise à jour sur sa demande (découpage 15-1a-i / 15-1a-ii, C125). Numéros de ligne relocalisés sur + `5e4bec50`. Mesures T0 au Dev Agent Record : aucune ne change une règle ni un AC sur le fond — b2 + interbloque (l'AC 6 le prévoyait), la création se laisse forcer en victime (test HTTP écrit). + +- 2026-10-09 — **Créée au découpage de la 15-12** (remédiation de la validation P2, décision de + l'orchestrateur, C107). Historique de la 15-12 (création C89 ; validation P1, Opus ×2 : 1 HIGH / 4 + MEDIUM / 14 LOW distincts, C100) : Change Log de l'index `15-12-cloture-dans-l-ordre.md`. + **Validation P2** (Sonnet ×2, contexte frais ; rapports `target/gate-logs/15-12-p2-{R,F}.md`) : R 0 / 0 + / 2 MEDIUM / 5 LOW, F 0 / 0 / 3 MEDIUM / 8 LOW ; après dédoublonnage (R1 = F1, R5 = F6, R7 = F9 ; R3 + rattaché à F3) : **0 CRITICAL, 0 HIGH, 4 MEDIUM, 10 LOW distincts** (14 sur 18 bruts). Les quatre + MEDIUM sont d'**origine** ; deux LOW sont des **résidus de la remédiation P1** (R4 : propagation + incomplète de C100 à l'ancienne clé ; F7 : « 8 modules » resté en tête de la ligne du registre). Trend : P1 1 HIGH / 4 MEDIUM → P2 0 HIGH / 4 MEDIUM. Traités **ici** : **F3** (+ R3) — + découpage (C107), AC 9 déplacé dans cette fiche ; **R2** — preuve HTTP de l'enveloppe de la clôture, + mutation (ix) tuée par elle, (x) pour la création (C109) ; **F2** — `ORDER BY start_date ASC` dans + `OPEN_COVERING_DATE_SQL`, `EXPLAIN` en T0 (C110) ; **R4** et **F5** — message neutre avec la + marche à suivre, ancienne clé alignée aux cinq sites relevés (C111) ; **R5 = F6** — manuel + administrateur recalé (`:1381-1382`), fichiers en vol complétés ; **R6** et **F11** — portée de + l'invariant déclarée, frontière avec la 15-1a écrite (C112 ; la fiche 15-1a n'est **pas** modifiée) ; + **R7 = F9** — ce que voit `attendre_une_requete_en_cours`, motifs discriminants par étape et par test ; + **F4** — `MULTI-TENANT-SCOPING-PATTERNS.md` à l'AC 14 ; **F10** (part A) — inventaire des tests par + recherche à l'AC 21. Traités dans la 15-12b : R1 = F1, F8 (lot), F10 (part B). F7 : `sprint-status.yaml`. + Comptes : **15 AC** (1-7, 9, 13, 14, 17, 19, 21, 22, 23 — numéros d'origine), **11 tâches** (T0-T10), + recomptés. Passe suivante : cf. index. +- 2026-10-09 — **Validation P3** (Opus ×2, contexte frais, sur les **deux** fiches : R chasseur de + régressions, F adversaire plein périmètre ; rapports `target/gate-logs/15-12-p3-R.md` et `…-p3-F.md`). + Bruts : R 0 / 0 / 1 MEDIUM / 8 LOW, F 0 / 0 / 3 MEDIUM / 8 LOW ; après dédoublonnage (R1 = F3 ; R3 + rattaché à F2) : **0 CRITICAL, 0 HIGH, 3 MEDIUM, 15 LOW distincts**. ⚠️ **Deux des trois MEDIUM sont nés + de la remédiation P2** — F2 porte sur le « par construction » posé par C110, R1 = F3 sur l'inventaire + « par recherche » ajouté en P2 (F10) — ; F1 est d'origine. Trend : P1 1 HIGH / 4 MEDIUM → P2 0 HIGH / + 4 MEDIUM → P3 0 HIGH / 3 MEDIUM. Traités **ici** : **F2** (MEDIUM, décision de l'orchestrateur) — + `close` verrouille ses antérieurs **un par un par clé primaire** dans l'ordre lu sans verrou, puis Y, + puis relit sous verrou (AC 3 réécrit, étapes (a)-(b)-(b')-(c)-(d)-(e)) ; ce que la forme perd (verrous + de clé suivante, fantômes) et ce qui le rend, écrits ; `FORCE INDEX` écarté ; `OPEN_COVERING_DATE_SQL` + **sans** `ORDER BY` (C119, **révise C110**) ; AC 6, 13 (motifs par étape, point d'arrêt (c) + déterministe), 14, 19 (mutations (ii) et (iii) redéfinies), 21, T0, T1 et Dev Notes alignés ; + « par construction » retiré partout où il qualifiait un parcours ; **R3** (deux index, non trois) dans + la même réécriture ; **F5** — cycle création ↔ contre-passation / annulations nommé (AC 3, 6, 14, Dev + Notes) ; **F6** — Y disparu entre (a) et (c) → `NotFound` par `fetch_optional`, test ; **F4** — points + (ii) et (iii bis) du doc-comment du registre ajoutés à la propagation de l'AC 6 ; **R2** — inventaire + de l'AC 21 étendu à la création (aucun cas trouvé, lus) et *pathspec* `'crates/*.rs'` (couvre + `lib.rs`) ; **R5** — doc-comments de `FIND_LATER_CLOSED_SQL` et `find_later_closed_in_tx` à l'AC 14 ; + **R7** — chevauchement : `400 VALIDATION_ERROR`, message `error-fiscal-year-overlap` (non + `FY_OVERLAP`) ; **R8** — `user-manual.tex:708` : la proposition « clôturer l'exercice suivant fige + aussi celui-ci » réécrite ; **F8** — autres textes qui prescrivent une réouverture sans ordre : hors + périmètre, sites listés, issue à ouvrir (C122) ; **F9** — section « Dérogation règle de splitting » + (C121, dérogation acceptée par l'orchestrateur) ; **F10** — CHANGELOG : défaut sous `Corrigé`, contrat + sous `Modifié` (C123) ; **F11** — nom complet « Exercice CI 2020-2030 » à l'E2E. Frontière 15-1a mise + à jour (C113 tranche l'état hérité ; C114 à aligner sur C119 ; la 15-1a ne dit plus la clôture « non + rejouée »). Traités dans la 15-12b : F1, R1 = F3, R4, R6, F7, et la part B de F9. **R9** (la fiche + 15-11a, mergée, renvoie #551/#552 à « story 15-12 » — `15-11a-compose-transmet-la-configuration.md:36`, + `:1274-1275` —, alors que la 15-12b les attribue à la 15-13) : **résidu historique**, non corrigé — + la fiche est sur `main`, et la réécrire n'apporterait rien. `main` a avancé à `5e4bec50` (15-5d) : les + relevés refaits en P3 le disent. Comptes : **15 AC** (1-7, 9, 13, 14, 17, 19, 21, 22, 23), **11 + tâches** (T0-T10), recomptés par la commande de l'index. Passe suivante : cf. index. diff --git a/_bmad-output/implementation-artifacts/15-12a-review-prompt-p1.md b/_bmad-output/implementation-artifacts/15-12a-review-prompt-p1.md new file mode 100644 index 000000000..e94dc8e2f --- /dev/null +++ b/_bmad-output/implementation-artifacts/15-12a-review-prompt-p1.md @@ -0,0 +1,37 @@ +# Prompt — revue de code P1, Story 15-12a + +*Versionné le 2026-10-09. Trois lentilles (Sonnet), contexte frais chacune, en lecture seule (bmad-code-review : +Blind Hunter, Edge Case Hunter, Acceptance Auditor). Rotation D6 : passes complètes Sonnet ↔ Opus ; Haiku en passe ciblée.* + +Worktree `/home/gcorbaz/devel/kesh-15-12a`, branche `story/15-12a-cloture-dans-l-ordre`. **Le diff à revoir** : `git -C /home/gcorbaz/devel/kesh-15-12a diff 5e4bec50..1f477526` (un seul diff aplati ; le code, +les tests, les manuels, la doc — les fiches `_bmad-output/` sont le contexte, pas l'objet). **Fiche** : +`_bmad-output/implementation-artifacts/15-12a-cloture-dans-l-ordre.md` (AC, tâches, Dev Agent Record, Change Log). Issues (par +`gh api repos/guycorbaz/kesh/issues/N`) : #543. Registre : `epic-15-choix-autonomes.md`. Règles : `CLAUDE.md`. Choix C107 à C123, C-15-12a-1. Code touché : crates/kesh-db/src/repositories/fiscal_years.rs (protocole de verrouillage de close (a)-(e), reopen LIFO, garde de create, LOCK_IN_COMPANY_SQL partagé avec update_name), routes fiscal_years (rejeu de close et create), AppError::FiscalYearBeforeClosedYear, écran des exercices, tests de concurrence 13 a-d et HTTP 9-10, manuels et PDF. Axes : le protocole C119 contre son code réel (fenêtres entre (b) et (b'), relecture (d), ordre réel des verrous, cycles avec create/reopen/update_name/contre-passation) ; les tests de concurrence prouvent-ils ce qu'ils disent (point 1 : la création bute dans find_overlapping et non dans sa garde — l'AC 13 b est-il encore prouvé ?) ; mutations (ix)/(x) jouées par une seule tentative ; le message neuf dans les 4 locales et les replis ; le manuel et le PDF aplati ; #568/#569 cités. + +## Lentilles + +- **B — Blind Hunter** : le diff seul, sans la fiche. Défauts de correction, régressions, erreurs de concurrence + (verrous, REPEATABLE READ, ordre d'acquisition), erreurs rendues au client, tests qui passeraient à vide (un test qui + ne mord pas sur la mutation qu'il prétend couvrir), code mort, duplication (DRY), doc-comments devenus faux. +- **E — Edge Case Hunter** : chaque branche et chaque borne du code modifié — entrées vides, nulles, multiples, en + doublon, d'une autre société, archivées ; chemins par clé d'API ; lots partiellement en échec ; locales ; et les + **chemins NON modifiés qui devraient l'être** (inventorier les sites non résolus de la même famille par `grep`). +- **A — Acceptance Auditor** : chaque AC de la fiche contre le code ET les tests (un AC sans test qui le prouve est + un finding) ; le Dev Agent Record ne déclare-t-il que ce qui a tourné (chiffres recomptés : `grep -c '#\[sqlx::test\]\|#\[test\]\|#\[tokio::test\]'` aux deux bornes) ; + le **manuel** (`docs/manual/fr/*.tex` **et PDF aplatis** : `pdftotext -nopgbrk f.pdf - | tr '\n' ' ' | tr -s ' '` vers + `target/gate-logs/`, ligatures ff/fi/fl normalisées), `docs/api-external.md`, CHANGELOG, i18n 4 locales. + +## Ce que tu rends + +Rapport complet dans `target/gate-logs/15-12a-review-p1-.md` : findings numérotés, sévérité +(CRITICAL/HIGH/MEDIUM/LOW), `fichier:ligne`, **preuve** (sortie de `grep -nF` pour toute affirmation de présence ou +d'absence ; code cité relu), correction proposée ; ⛔ **axes exercés ET non exercés**. Dernier message : le chemin, le +bilan par sévérité, une ligne par MEDIUM+. + +## Interdits + +⛔ N'écris aucun fichier hors `target/gate-logs/`. Aucune commande qui écrit dans le dépôt ou dans une base, ni +aucune commande qui compile ou exécute des tests : `scripts/*` (dont `scripts/prepare-release.sh`), `make`, +`latexmk`, `git commit`/`add`/`checkout`/`stash`/`apply`, `sqlx`, `cargo`, `npm`, `npx`, +`gh issue create`/`comment`/`edit`, SQL d'écriture. Autorisés : lecture, `grep`, `sed -n`, `git log`/`show`/`diff`, +`gh api` en lecture, `pdftotext` vers `target/gate-logs/`. diff --git a/_bmad-output/implementation-artifacts/15-12a-review-prompt-p2-ciblee.md b/_bmad-output/implementation-artifacts/15-12a-review-prompt-p2-ciblee.md new file mode 100644 index 000000000..a5f84bc8a --- /dev/null +++ b/_bmad-output/implementation-artifacts/15-12a-review-prompt-p2-ciblee.md @@ -0,0 +1,43 @@ +# Prompt — revue de code P2 ciblée, Story 15-12a + +*Versionné le 2026-10-09. Passe ciblée (CLAUDE.md § « La passe ciblée ») : une seule lentille (Haiku), contexte +frais, en lecture seule, braquée sur le seul commit de la remédiation P1.* + +Worktree `/home/gcorbaz/devel/kesh-15-12a`, branche `story/15-12a-cloture-dans-l-ordre`. **Objet** : +`git show 37784da4` (diff unique ; ouvre les fichiers à `HEAD` autour des hunks). Rapports remédiés : +`target/gate-logs/15-12a-review-p1-{B,E,A}.md`. Choix C-15-12a-2 à 4. + +⚠️ Tu relis du CODE livré ET une fiche : un défaut est ce que le code fait de faux, un test qui ne prouve pas ce +qu'il dit, ou un texte qui contredit le code. Ne signale pas comme manquant ce qu'une AUTRE story prescrit. + +## Lentille unique — chasseur de régressions de la remédiation + +1. **Test 13 b3** (`close_waits_for_a_creation_whose_guard_holds_the_later_year`, + `crates/kesh-db/tests/fiscal_years_repository.rs` ou voisin — `grep -rn` le nom) : W rejoue-t-il exactement la + garde de `create` (même requête, `FOR UPDATE`) ? L'attente de la clôture à l'étape (c) est-elle observée + (motif de `attendre_une_requete_en_cours`) et non supposée ? Le test peut-il passer à vide (clôture qui + n'attend pas, verdict obtenu par une autre voie) ? Dépend-il du plan de l'optimiseur ? +2. **Message `error-later-fiscal-year-closed`** (4 `messages.ftl` + repli `crates/kesh-api/src/errors.rs`) : + le texte borné « modifiée ni supprimée » est-il juste dans les 4 langues, et cohérent avec ce que le code + refuse ? Un seul registre par message (l'italien tutoyé, cf. `docs/i18n-glossaire.md`) ? +3. **Enveloppe ``** autour des boutons « Clôturer »/« Réouvrir » + (`frontend/src/routes/(app)/settings/fiscal-years/+page.svelte`) : le bouton reste-t-il cliquable quand il + est actif ? La mise en page de la cellule change-t-elle ? Les tests Vitest et E2E lisent-ils le bon élément ? +4. **Tests neufs** `create_ignores_the_closed_years_of_another_company`, `create_right_after_a_closed_year_is_allowed`, + `both_refusals_reach_a_read_write_api_key` : prouvent-ils ce qu'ils disent ? +5. **Doc-comments neufs** de `crates/kesh-db/src/repositories/fiscal_years.rs` (module, `create`, `close`, + `find_later_closed_in_tx`) : exacts contre le code, en particulier « aucun montage simple ne force le + fantôme » et le coût de (b') ? + +## Ce que tu rends + +Findings numérotés (CRITICAL/HIGH/MEDIUM/LOW), `fichier:ligne`, **preuve** (sortie de `grep -nF` ; code relu cité), +correction proposée. ⛔ **Liste des axes exercés ET non exercés** — un « 0 » sans elle ne compte pas. Rapport +complet dans `target/gate-logs/15-12a-review-p2-ciblee.md` ; dernier message : le chemin, le bilan, une ligne par MEDIUM+. + +## Interdits + +⛔ N'écris aucun fichier hors `target/gate-logs/`. Aucune commande qui écrit dans le dépôt ou dans une base, ni qui +compile ou exécute des tests : `scripts/*` (dont `scripts/prepare-release.sh`), `make`, `latexmk`, +`git commit`/`add`/`checkout`/`stash`, `sqlx`, `cargo`, `npm`, `npx`, `gh issue create`/`comment`/`edit`, SQL +d'écriture. Autorisés : lecture, `grep`, `sed -n`, `git log`/`show`/`diff`. diff --git a/_bmad-output/implementation-artifacts/15-12a-validate-prompt-p4.md b/_bmad-output/implementation-artifacts/15-12a-validate-prompt-p4.md new file mode 100644 index 000000000..70b9f94f2 --- /dev/null +++ b/_bmad-output/implementation-artifacts/15-12a-validate-prompt-p4.md @@ -0,0 +1,35 @@ +# Prompt — validation P4 de la spec, Story 15-12a + +*Versionné le 2026-10-09. Deux lentilles (Sonnet), contexte frais chacune, en lecture seule. Rotation (décision D6 de la +rétro 25) : passes complètes Sonnet ↔ Opus ; Haiku réservé à une passe ciblée de fin de boucle.* + +Dépôt `/home/gcorbaz/devel/kesh`, branche `story/15-5-gardes-postabilite-serveur`. **Fiche** : `_bmad-output/implementation-artifacts/15-12a-cloture-dans-l-ordre.md`. La dernière remédiation est le commit `c7a5dbcc` (`git show c7a5dbcc`) — lis le Change Log de la fiche. +Issues, par `gh api repos/guycorbaz/kesh/issues/N` (et `/comments`) — `gh issue view` échoue ici : #543. +Choix autonomes : `_bmad-output/implementation-artifacts/epic-15-choix-autonomes.md`. Règles : `CLAUDE.md`. Code de référence : `origin/main` = `5e4bec50`. Index `15-12-cloture-dans-l-ordre.md`, fiche sœur `15-12b-filet-sous-un-bilan-clos.md`. Choix C100, C107 à C123. **Axe prioritaire** : le protocole de verrouillage de la clôture refondu par C119 — (a) lecture sans verrou, (b) liste des antérieurs triée sans verrou, (b') verrous un par un par clé primaire en boucle Rust, (c) Y par clé primaire, (d) relecture verrouillante `FIND_EARLIER_OPEN_SQL … FOR UPDATE`, (e) écriture. Cherche : fenêtres entre (b) et (b') (exercice créé, supprimé, renommé, changé de statut), fantômes que (d) ne verrait pas, cycles avec la création, `reopen`, la contre-passation et les écritures, ce que le rejeu absorbe ou non, les tests 13 d et AC 6 (point d'arrêt (c)), et ce que la fiche dit perdre. Puis le reste de la fiche contre le code et la 15-12b ; le manuel, PDF aplati compris. +Aucun code de la story n'est encore écrit. + +## Lentilles + +- **R — Regression hunter** : la dernière remédiation (`git show c7a5dbcc`) a-t-elle introduit des défauts ? Contradictions entre AC, tâches, Dev Notes et choix du registre ; frontière avec les fiches sœurs ; faits recopiés sans vérification (numéros de ligne, fonctions, codes, clés i18n) — revérifie-les au code (`grep -nF`) ; recompte des AC et tâches ; implémentable sans deviner ? +- **F — Full-scope adversary** : la conception elle-même. Les chemins de code qui écrivent ou lisent ce que la story + change (inventorier les sites NON résolus, pas énumérer ceux qui marchent) ; transactions, verrous, ordre des + contrôles ; tests existants qui changeront de sens ; frontend qui lit les codes ou champs touchés (`grep -rn` dans + `frontend/src`) ; i18n 4 locales et replis ; le **manuel** (`docs/manual/fr/*.tex` **et le PDF aplati** : + `pdftotext docs/manual/fr/user-manual.pdf - | tr '\n' ' ' | tr -s ' '` vers `target/gate-logs/`) ; + `docs/api-external.md` ; CHANGELOG ; la règle de découpage (§ « Règle de splitting préventif », amendement D5). + +## Ce que tu rends + +Findings numérotés avec sévérité (CRITICAL/HIGH/MEDIUM/LOW), `fichier:ligne` (fiche et code), **preuve** (commande et +sortie, ou code cité relu), correction proposée. 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** — un « 0 finding » sans elle ne compte pas. +⛔ **Écris ton rapport complet** dans `target/gate-logs/15-12a-p4-.md` (R ou F ; autorisé), et rends +comme dernier message **seulement** : ce chemin, le bilan par sévérité, et une ligne par finding MEDIUM ou plus. + +## 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`/`stash`, +`sqlx`, `cargo test`/`nextest`/`build`, `npm run`, `npx`, `gh issue create`/`comment`/`edit`, aucune requête SQL +d'écriture. Autorisés : lecture, `grep`, `sed -n`, `git log`/`show`/`diff`, `gh api` en lecture, `pdftotext` +vers `target/gate-logs/`. diff --git a/_bmad-output/implementation-artifacts/15-12b-filet-sous-un-bilan-clos.md b/_bmad-output/implementation-artifacts/15-12b-filet-sous-un-bilan-clos.md new file mode 100644 index 000000000..43e405b38 --- /dev/null +++ b/_bmad-output/implementation-artifacts/15-12b-filet-sous-un-bilan-clos.md @@ -0,0 +1,705 @@ +# Story 15.12b : Ne plus écrire sous un bilan clos — le filet des données héritées, son écran, sa réparation + +Status: ready-for-dev + + + +**Issues** : **ferme #543** (P1 — « on peut écrire dans un exercice ouvert alors qu'un exercice +postérieur est déjà clos — le bilan clos change en silence », relevée par la validation P3 de la 15-8 ; +ses deux commentaires listent les flux sans garde). La PR porte `closes #543` (mot-clé **dans la PR** : +le dépôt merge en squash). Voisines citées, **non traitées ici** : #551 et #552 (Story 15-13). + +**Dépend de** la **15-12a** (clôture dans l'ordre : `DbError::EarlierFiscalYearOpen`, la clé +`error-later-fiscal-year-closed` et le mapping de l'AC 9, l'invariant I sur lequel repose la preuve du +filet, la clôture ordonnée de la réparation), de la 15-8a et de la 15-8b (`LaterFiscalYearClosed`, +`find_later_closed_in_tx` / `find_later_closed`, garde du `PUT` et du `DELETE`), de la 15-5e1/15-5e2 +(rejeu de toutes les routes qui écrivent au journal, registre à deux colonnes). Toutes mergées sauf la +15-12a. + +**Touche des fichiers que d'autres stories en vol touchent** : `docs/manual/fr/user-manual.tex` et +`admin-manual.{tex,pdf}` (la 15-11a, mergée, a modifié le manuel administrateur ; la 15-12a aussi), +`CHANGELOG.md` (rubriques de `[0.13.0]`), `crates/kesh-db/src/repositories/journal_entries.rs` (la 15-5d +ne fait qu'y lire, les 15-6/15-6a n'y touchent pas — T0 le vérifie sur leurs branches ; **la 15-1a** y +pose la garde `ENTRY_LETTERED` dans `delete_in_tx`, cf. frontière). En cas de rebase : garder les deux +côtés, **régénérer** les PDF après le rebase (conflit binaire). + +## Story + +En tant que **comptable** dont l'installation porte déjà un exercice ouvert suivi d'un exercice clôturé +(données d'une v0.12.x, restauration d'une sauvegarde), +je veux que **Kesh refuse toute écriture et toute suppression d'écriture dans un exercice dont un +exercice postérieur est clôturé**, et m'indique comment rétablir l'ordre, +afin que **le bilan d'un exercice clôturé ne puisse plus changer en silence** — le bilan est cumulatif +depuis l'origine (`crates/kesh-report/src/balance_sheet.rs:8-11`). + +### Le défaut, établi au code + +- La 15-12a rend l'état « N ouvert, N+1 clos » **inatteignable** depuis un état sain. Mais les + installations qui le portent déjà ne repassent par aucune transition. +- Dans cet état, seuls le `PUT` et le `DELETE` d'une écriture refusent (`LATER_FISCAL_YEAR_CLOSED`, + 15-8a/15-8b). **Dix-neuf** routes créent une écriture dans N et la **dévalidation** en supprime une, + sans contrôle (inventaire fermé, AC 12). + +### Conception retenue (C89, C100) + +- **Le filet** : la garde `LATER_FISCAL_YEAR_CLOSED` de la 15-8a est **généralisée** aux deux points de + passage de toutes les écritures du journal — `journal_entries::create_in_tx_inner` et + `journal_entries::delete_in_tx` — ; les trois voies d'acceptation par lot du rapprochement rendent le + refus dans `failed[]`. +- **L'écran des exercices signale** l'état et la marche à suivre pour le réparer (clôturer l'exercice + ouvert le plus ancien, puis les suivants dans l'ordre ; ou faire rouvrir les exercices clôturés, en + commençant par le plus récent — C100). + +### Frontière avec la 15-12a + +La 15-12a tient l'invariant I (clôture, création, réouverture), rend la clôture rejouée et pose le +message neutre de `LATER_FISCAL_YEAR_CLOSED` (AC 9 : clé `error-later-fiscal-year-closed`). Cette fiche +**lit** cette clé et ce mapping sans les modifier ; elle complète les doc-comments que la 15-12a a écrits +sans le filet (module `fiscal_years.rs`, `DbError::LaterFiscalYearClosed`, `find_later_closed`) et la +documentation (AC 23, part B). La dépendance est à sens unique : B → A. + +### Frontière avec la 15-1a (C112) + +- La 15-1a **n'exige pas** cette fiche : son prérequis réel est la 15-12a. Le filet couvre la création et + la suppression d'écritures, **pas** le lettrage : dans l'état hérité, un lettrage d'un groupe tout en N + sous un N+1 clos n'est refusé par rien d'ici (à la 15-1a de le tolérer, de le garder ou de l'écrire). +- Ordre écrit : **15-12a → 15-12b → 15-1a**. Motif : les deux fiches touchent `delete_in_tx` — cette fiche + y lève la condition `enforce_ownership` de la lecture des postérieurs clos (AC 10, étape 2-bis), la + 15-1a y pose `ENTRY_LETTERED` (étape 3-ter-bis) hors de ce drapeau. +- **Précédence des deux refus, tranchée (C117)** : **« exercice postérieur clos » (2-bis) avant + `ENTRY_LETTERED` (3-ter-bis)** — l'état des exercices parle avant la marque, comme `FISCAL_YEAR_CLOSED` + avant `LATER_FISCAL_YEAR_CLOSED` ; l'inverse dirait « délettrez d'abord » à qui ne pourrait de toute + façon rien supprimer. **La seconde des deux stories mergée** l'écrit au doc-comment « Ordre des refus » + de `delete_in_tx` (et à la liste de précédence de `unvalidate`, AC 10) et la teste par une **paire** : + écriture lettrée dans N, N+1 clos, `enforce_ownership` à `false` **et** à `true` → + `LaterFiscalYearClosed` ; mutation « permuter les étapes » observée rouge. **Dans l'ordre préféré + (15-12a → 15-12b → 15-1a), c'est la 15-1a qui le fait** ; cette fiche n'a alors rien à écrire sur + `ENTRY_LETTERED`. ⚠️ **Si la 15-1a est mergée avant cette fiche**, la paire, la mutation et le + doc-comment reviennent ici : T0 le constate (`grep -n "ENTRY_LETTERED\|EntryLettered" + crates/kesh-db/src/repositories/journal_entries.rs`), et T1 les prend. + +## Acceptance Criteria + +### Le filet — écrire sous un bilan clos est refusé partout + +8. **Point de passage de toute création d'écriture.** `journal_entries::create_in_tx_inner` + (`journal_entries.rs:258`, appelé par `create_in_tx` et par la contre-passation) refuse + `DbError::LaterFiscalYearClosed` (plus proche postérieur clos) quand un exercice de `start_date` + postérieure à celle de l'exercice de l'écriture est clôturé. Placement : **après** le verdict + « exercice clos » de l'étape 1, **avant** le contrôle des bornes de date et le verrou de période — + précédence `NotFound` → `FISCAL_YEAR_CLOSED` → `LATER_FISCAL_YEAR_CLOSED` → + `DATE_OUTSIDE_FISCAL_YEAR` → `PERIOD_LOCKED`, celle du `PUT` (`journal_entries.rs:1378-1403`). + **Lecture NON verrouillante** (`fiscal_years::find_later_closed`, sur `&mut **tx`), et c'est un + choix (C89, preuve réécrite en C100 et aux Dev Notes) : la sûreté repose sur **(α)** l'invariant I, + vrai de **tout état validé** depuis un état sain — tenu par la 15-12a, dont la lecture verrouillante + de la clôture est l'instrument face à la course réouverture/clôture (test 13 a de la 15-12a) — et + **(β)** le verrou de son exercice que l'écrivain tient dès l'étape 1 : aucun postérieur ne peut se + clôturer tant que l'exercice de l'écrivain est ouvert, et il l'est jusqu'au `COMMIT`. La lecture n'a + donc à voir que l'état **hérité**, qui ne peut que se résorber. Un verrou ici ajouterait des verrous + d'intervalle sur `fiscal_years` aux dix-neuf routes — dont la place est le sujet de toute la 15-5e — + pour aucun gain de garantie. Le message est celui de l'AC 9 de la 15-12a (inchangé ici). + +10. **Point de passage de toute suppression d'écriture.** `journal_entries::delete_in_tx` lit les + exercices postérieurs clos **sans condition** (aujourd'hui `if enforce_ownership`, + `journal_entries.rs:1681`) : la **dévalidation** d'une facture (`invoices::unvalidate`, seul + appelant à passer `false`, `invoices.rs:1659`) refuse désormais `LATER_FISCAL_YEAR_CLOSED`. La + lecture reste la verrouillante `find_later_closed_in_tx` déjà en place (code existant, testé par la + 15-8b ; la dévalidation est rare). **Précédence** : les cinq motifs propres de la facture + (`invoices.rs:1462-1474`, contrôlés `:1505-1600`) parlent **avant** `delete_in_tx`, dont les + gardes suivent — `INVOICE_HAS_SETTLEMENTS → INVOICE_CREDITED → INVOICE_HAS_REMINDERS → + INVOICE_EMAILED → MATCHED_BANK_TRANSACTION → FISCAL_YEAR_CLOSED → LATER_FISCAL_YEAR_CLOSED → + ENTRY_IS_REVERSED → PERIOD_LOCKED` (à relire au code en T0 et à écrire telle quelle au + doc-comment de `unvalidate` — avec `ENTRY_LETTERED` à son rang si la 15-1a est mergée avant) ; test + de paire : facture **envoyée** sous un postérieur clos → `INVOICE_EMAILED`. **Le test qui fixait le + comportement actuel** (`journal_entries.rs:3427`, choix C-15-8-29 — « pour qu'il rougisse quand #543 + sera corrigée ») **est inversé**, et les commentaires `C-15-8-29` (`invoices.rs:1656`, + `journal_entries.rs:1642`, `:1680`, `:3427`) sont réécrits. + +11. **Acceptation par lot du rapprochement — trois voies** *(réécrit en P2 : R1 = F1, F8 ; C108)*. Un + refus `LaterFiscalYearClosed` sorti de `journal_entries::create_in_tx` dans **chacune** des trois + implémentations devient un `FailedProposal` **`LATER_FISCAL_YEAR_CLOSED`** avec `details: { + fiscalYearId, fiscalYearName }` (et `projectId` quand la voie le connaît — cf. la voie « règle » + ci-dessous) — jamais + `DATABASE_ERROR` (§ *Pattern batch* du `CLAUDE.md`, qui déclare les trois `accept_one_*` + inviolables). HTTP `200`, les autres propositions du lot passent. Sites (relevés sur `8f9811d8`, + `crates/kesh-api/src/routes/reconciliation.rs`, `grep -nF "journal_entries::create_in_tx("`) : + - **facture** — `accept_one_invoice`, `create_in_tx` `:1631`, erreur passée à + `project_error_to_failed_proposal` (`:1634`) ; + - **ventilé** — `accept_one_split`, `create_in_tx` `:2158`, idem (`:2164`) ; + - **règle** — `accept_one_rule`, `create_in_tx` `:2528`, **branche en ligne** `:2530-2555` : son + seul cas particulier est `PeriodLocked` (`:2538-2548`), puis un repli `DATABASE_ERROR` + (`:2549-2553`). Elle n'emprunte **pas** `project_error_to_failed_proposal`, et ne doit pas + l'emprunter : le bras `NotFound → PROJECT_NOT_FOUND` de ce mapper mal-étiquetterait un `NotFound` + étranger au projet (commentaire `:2531-2534`, doc-comment du mapper `:180-181`). **`projectId` : + `None`** (R4, C123) — patron de son `PeriodLocked` (`:2541-2546`), qui passe `None` alors que la + règle a un `default_project_id` : les deux refus de la même branche publient les mêmes `details`, + et le constructeur **omet** `projectId` quand il vaut `None` (convention de + `period_locked_failed_proposal`, `:186-193`). Le test de la voie « règle » asserte cette absence. + ⚠️ **La commande rend cinq sites, pas trois** (R4 ; relevé `5e4bec50`, numéros identiques) : s'y + ajoutent `:3304` (`post_manual_once`) et `:3756` (`post_split_once`), les tentatives uniques des + routes **unitaires** `POST /reconciliation/manual` et `/split`. Hors du lot : leur refus + `LaterFiscalYearClosed` sort par le **mapping global** (`ReconciliationError::Db` → + `AppError::Database`, puis `400 LATER_FISCAL_YEAR_CLOSED`, message de l'AC 9 de la 15-12a — T0 relit + la conversion de chacune des deux routes), comme toute écriture refusée (AC 12) — rien à y ajouter, + à ne pas chercher comme une voie manquante. + **Forme imposée** (patron de `period_locked_failed_proposal`, `:186-193` — « un seul constructeur + pour les DEUX sites ») : un constructeur `later_fiscal_year_closed_failed_proposal(bank_transaction_id, + project_id, fiscal_year_id, fiscal_year_name)`, appelé par un bras de + `project_error_to_failed_proposal` (à côté de `PeriodLocked`, `:282-290`) **et** par la branche en + ligne de `accept_one_rule` (à côté de son `PeriodLocked`). Le code y est un **littéral** + `"LATER_FISCAL_YEAR_CLOSED"` (F8 : visible au `grep` du décompte, ce que `err.error_code()` ne serait + pas). **Tests, un par voie** (`reconciliation_e2e.rs` ou fichier du lot) : facture, ventilé, règle — + chacun dans `failed[]` avec code et `details`, une seconde proposition saine du même lot acceptée, + aucune écriture créée pour la proposition refusée (compte avant/après). + **Côté écran**, `failed-proposal-label.ts` reçoit le code — **tranché (C100)** : clé neuve + `reconciliation-failed-later-fiscal-year-closed` (« Exercice postérieur « { $name } » clôturé »), + `$name` lu dans `details.fiscalYearName` (repli sans nom si le champ manque, testé). La clé globale + de l'AC 9 ne convient pas : elle porte une phrase entière, trop longue pour une ligne d'échec. La + fonction lit alors `details` pour **trois** codes : le doc-comment « Deux codes seulement… » (`:24`) + est réécrit. **Recompte du doc-comment « Les 26 codes »** (`:9-13`) : sa formule (les littéraux de + `grep -ohE 'error_code: "[A-Z_]+"' crates/kesh-api/src/routes/reconciliation.rs | sort -u`, plus + `ACCOUNT_NOT_POSTABLE` posé par `DbError::error_code()`) **reste** ; elle rend 25 littéraux sur + `8f9811d8`, **26** avec le littéral neuf — total attendu **27**, recompté par la commande et non + incrémenté. + +12. **Inventaire fermé des écrivains, et preuve qu'aucun ne contourne le filet.** Les **22** routes + `Rejouee` et les **2** `Exemptee` de production du registre (`audit_route_registry.rs:168-301`) + se répartissent ainsi — table à reproduire **telle que recomptée en T0** dans les Dev Notes du Dev + Agent Record. Commande de recompte **bornée à `LIB_ROUTES`** — + `awk '/^const LIB_ROUTES/,/^const TEST_ENDPOINT_ROUTES/' crates/kesh-api/tests/audit_route_registry.rs | grep -c 'Rejouee)'` + (→ 22) et `… | grep -c 'Exemptee('` (→ 2) ; ⚠️ sur le fichier entier, les mêmes `grep -c` rendent + **25** et **6** (routes de test, `:304-305`, et mentions du doc-comment), et une borne `/^\];/` + déborde sur les routes de test (→ 4), la fin de `LIB_ROUTES` s'écrivant `),];` : + + | Point de passage | Routes | + |---|---| + | `create_in_tx_inner` (AC 8) — 19 | `journal_entries::create_journal_entry`, `reverse_journal_entry` ; `opening_balances::generate_opening_balances`, `complete_opening_balances` ; `credit_notes::create_credit_note` ; `supplier_invoices::create_supplier_invoice`, `pay_supplier_invoice`, `cancel_supplier_invoice`, `cancel_supplier_invoice_settlement` ; `imported_supplier_invoices::complete_import` ; `payment_batches::confirm_payment_batch` ; `invoices::validate_invoice_handler`, `settle_invoice_handler`, `write_off_invoice_handler`, `cancel_invoice_settlement_handler` ; `reconciliation::post_accept`, `post_manual`, `post_split`, `post_cancel_reconciliation` | + | `update` (garde 15-8a, inchangée) — 1 | `journal_entries::update_journal_entry` | + | `delete_in_tx` (AC 10) — 2 | `journal_entries::delete_journal_entry` (garde 15-8b), `invoices::unvalidate_invoice_handler` (**neuf**) | + | Hors filet, écrit — 2 | `admin::full_import` (remplace toute la base : c'est une **source** de l'état fautif, traitée par la détection de l'AC 15) ; `onboarding::reset` (efface la société entière) | + + **Ce que T0 doit prouver, et pas seulement relire** : qu'aucune instruction d'écriture sur + `journal_entries` / `journal_entry_lines` hors tests n'échappe à ces trois points de passage. + Commande de départ, élargie au SQL dynamique et à `TRUNCATE` : + `grep -rnE "(DELETE FROM|UPDATE|INSERT INTO|INSERT IGNORE INTO|REPLACE INTO|TRUNCATE( TABLE)?) +\`?(journal_entr(y|ies)|\{)" crates --include=*.rs` + — sites hors `mod tests` / `tests/` attendus : `journal_entries.rs` (insertions de + `create_in_tx_inner` ; `UPDATE`/`DELETE` des lignes de `update` ; `DELETE` de `delete_in_tx` ; + `delete_all_by_company` `:1859` — **tranché** : aucun appelant de production, `grep -rn + "delete_all_by_company"` ne rend que des `mod tests`), `journal_entry_number_sequences.rs` + (compteur, pas d'écriture comptable), `kesh-seed` (effacement de la démo, `lib.rs:257-272`) ; et les + trois sites **dynamiques**, que le motif sur le nom de table ne voit pas : `kesh-db/src/backup.rs:457` + (``DELETE FROM `{table}` ``) et `:509` (``INSERT INTO `{table}` ``) — c'est `admin::full_import`, + hors filet (table ci-dessus) —, et `kesh-db/src/test_fixtures.rs:401` (`TRUNCATE TABLE {table}`, + routes `/reset` et `/seed` du mode test, absentes en production). **Faux positifs connus** (F7 ; + relevé `5e4bec50`), que le motif `TRUNCATE( TABLE)? +\{` attrape dans le nom de constante + `TABLES_TO_TRUNCATE {` d'une boucle `for` — aucune instruction SQL : `kesh-api/src/admin_backup/export.rs:55`, + `admin_backup/import.rs:118`, `:280`, `:367`, `kesh-db/src/backup.rs:453`, `test_fixtures.rs:400` + (les instructions de ces boucles sont les sites dynamiques ci-dessus). Les sites de `accounts.rs` + (`:1215`, `:1221`) et `invoices.rs` (`:4380`, `:4423`) sont dans leur `mod tests`. Tout **autre** site + est un **trou** à fermer dans la story ou à écrire comme angle mort assumé (règle « inventorier les + sites non résolus » du `CLAUDE.md`). ⚠️ **Le lettrage de la 15-1a** (un `UPDATE` de `journal_entry_lines`, + colonnes de lettrage) n'existe pas encore sur `main` : s'il est mergé avant cette fiche, T0 le trouve + par cette commande et l'écrit (hors filet, motif à la frontière ci-dessus). La contre-passation + (`reverse_in_tx` / `reverse_owned_in_tx` → `reverse_in_tx_inner` → `create_in_tx_inner`, + `journal_entries.rs:2313`) est datée du **jour** : elle tombe dans l'exercice ouvert du jour, et le + filet ne la refuse que si cet exercice-là a un postérieur clos — c'est voulu (contre-passer dans + l'exercice courant ne touche aucun bilan clos). ⚠️ Ce refus-là, les **prédicteurs d'annulation** + ne l'annoncent pas : angle mort assumé, AC 18 (F1, C120). + +### Les installations déjà dans l'état fautif + +15. **Détection à l'écran, sans changement d'API.** La page `/settings/fiscal-years` calcule, depuis la + liste qu'elle charge déjà, l'ensemble des exercices **ouverts suivis d'un exercice clos** (la + fonction `nearestLaterClosed` existe, `+page.svelte:93-106`). S'il est non vide, un **bandeau + d'avertissement** (`role="alert"`, `data-testid="fiscal-year-out-of-order"`) s'affiche au-dessus du + tableau, **pour tous les rôles** (Consultation compris : c'est une information sur l'état des + comptes). Il nomme `{open}` — le **plus ancien** exercice ouvert, qui est toujours concerné dès que + l'état est fautif (tout exercice ouvert antérieur à un exercice ouvert concerné l'est aussi) —, son + plus proche postérieur clos `{closed}`, et le plus récent exercice clos `{latest}`. Marche à suivre + (C100), **dans cet ordre** : + 1. **la réparation directe** : clôturer « {open} » si ses comptes sont arrêtés — l'AC 1 de la 15-12a + l'accepte, aucun exercice antérieur à « {open} » n'étant ouvert ; geste ouvert au Comptable ; + puis, s'il en reste, les exercices ouverts suivants, du plus ancien au plus récent (le bandeau se + recalcule à chaque rechargement de la liste) ; + 2. **sinon** : un administrateur rouvre les exercices clôturés postérieurs à « {open} », **en + commençant par le plus récent** (« {latest} ») et jusqu'à « {closed} » — c'est le seul ordre que + la garde LIFO accepte : rouvrir « {closed} » d'abord est refusé dès qu'un exercice plus récent est + clos. ⛔ Le bandeau ne prescrit **jamais** de rouvrir « {closed} » en premier. + Texte fr-CH de référence : « L'exercice « { $open } » est ouvert alors qu'un exercice postérieur, + « { $closed } », est clôturé : rien ne peut y être enregistré tant que l'ordre n'est pas rétabli. + Clôturez « { $open } » si ses comptes sont arrêtés, puis les exercices ouverts suivants, du plus + ancien au plus récent. Sinon, un administrateur rouvre les exercices clôturés, en commençant par le + plus récent, « { $latest } » : Kesh ne rouvre un exercice que si aucun exercice plus récent n'est + clôturé. » Clé `fiscal-year-out-of-order-warning` (variables `$open`, `$closed`, `$latest`), + 4 locales ; test Vitest du texte et des trois noms, avec un cas où `{closed}` ≠ `{latest}`. + ⚠️ Pas de détection au démarrage, pas de refus à l'import (C89). Le message de refus (AC 9 de la + 15-12a) ne renvoie pas à ce bandeau : il porte sa propre marche à suivre (C111). + +16. **Réparation exerçable** (test `kesh-db`, état fautif posé par `UPDATE fiscal_years SET status = + 'Closed'` direct, comme les tests de la 15-8a) : N ouvert, N+1 clos → écrire dans N refusé + (`LaterFiscalYearClosed`) ; `close(N+2)` (ouvert) refusé (`EarlierFiscalYearOpen`, N) ; + `reopen(N+1)` **accepté** (LIFO : rien de postérieur clos) ; `close(N)` accepté ; écriture dans N + refusée (`FiscalYearClosed`) ; `close(N+1)` accepté. État final sain. **Second scénario — la + réparation directe du bandeau** : N ouvert (tous ses antérieurs clos), N+1 et N+2 clos → + `reopen(N+1)` **refusé** (LIFO, N+2 clos : le geste que le bandeau ne doit pas prescrire) ; + `close(N)` **accepté** → état sain, écriture dans N refusée `FiscalYearClosed`. + +### Les autres écrans + +18. **Inventaire des sites NON résolus** (règle du `CLAUDE.md`). L'inventaire ne part **pas** des formes + traitées (`grep FISCAL_YEAR_CLOSED`) mais des **appelants** : pour chacune des 22 routes de l'AC 12, + sa fonction cliente dans `frontend/src/lib` (grep de son chemin d'API) puis chaque écran qui + l'appelle, et, pour chaque écran, ce qu'il fait d'une erreur de code inconnu. Verdict écrit par + écran au Dev Agent Record : « retombe sur le message du serveur » (rien à faire, vérifié au code) ou + « changé » (un test). Relevé de la validation P1 sur `9cb5083b`, à refaire en T0 : + - **changé** — `invoices/[id]/+page.svelte`, boîte de validation : elle ne se ferme que sur + `FISCAL_YEAR_INVALID` et `CONFIGURATION_REQUIRED` (`:429-433`) et resterait ouverte sur un refus + que rien n'y rend corrigeable ; `LATER_FISCAL_YEAR_CLOSED` s'ajoute à la liste (test Vitest ou + de composant) ; + - **inchangés, vérifiés** — `notify.ts` (`notifyMissingFiscalYearOrFallback`, `:71-72`/`:100-104` : + égalité stricte sur `FISCAL_YEAR_INVALID`, `NO_FISCAL_YEAR`, `FISCAL_YEAR_CLOSED` ; le nouveau + code y retombe **déjà** sur le message du serveur — aucun test à écrire pour un changement qui + n'existe pas) ; `payment-batches/[id]`, `settings/opening-balances`, `supplier-invoices/import` + (`completeErrorLabel` → `default: err.message`), dévalidation de `invoices/[id]` (`:359`) : + `err.message` ; + - **à relever en T0** — les autres appelants (saisie et contre-passation des écritures, règlements + et soldes, avoirs, factures fournisseur et leurs annulations, rapprochement manuel, ventilé et + son annulation), et les dix fichiers qui citent `FISCAL_YEAR_CLOSED` (`grep -rln + "FISCAL_YEAR_CLOSED" frontend/src --include=*.ts --include=*.svelte | grep -v test`), à titre de + contrôle : un `switch` qui y traite `FISCAL_YEAR_CLOSED` peut avoir à traiter le nouveau code. + + **Les prédicteurs serveur** *(ajouté en P3 : F1, C120)* — l'inventaire part aussi des champs d'API + qui **annoncent** à l'écran si une annulation est possible, calculés par la fonction même qui + refuse : `cancelBlockedBy` (`routes/invoices.rs:1410`, `routes/reconciliation.rs:3925`), + `settlementCancelBlockedBy` et `cancelBlockedBy` (`routes/supplier_invoices.rs:93`, `:111`) — tous + issus de la queue commune `settlement_cancellation::settlement_entry_cancel_blocker` (rangs 2 à 5 ; + `supplier_invoices::supplier_invoice_cancel_blocker` la compose) —, et `reversalBlockedBy` + (`journal_entries::reversal_blockers`, fiche d'écriture). Relevé `5e4bec50`. **Site non résolu** : le + rang 5 (`NoOpenFiscalYearToday`) contrôle qu'un exercice **ouvert** couvre le jour où la + contre-passation serait datée, **pas** qu'aucun exercice postérieur à celui-ci n'est clos. Dans + l'état hérité où l'exercice du jour est suivi d'un exercice clos — un exercice **futur** clôturé + d'avance —, les quatre écrans annoncent l'annulation possible et le clic rend `400 + LATER_FISCAL_YEAR_CLOSED` (filet de l'AC 8), avec le message de l'AC 9 de la 15-12a, qui porte sa + propre marche à suivre. `reversalBlockedBy` ne prédit déjà pas l'exercice du jour : même trou, déjà + ouvert. **Tranché : angle mort assumé, écrit** (C120), non un rang neuf — le rang est tracé par l'issue + **#568** (P3), que le doc-comment et `api-external.md` citent : + - **où il est écrit** : au doc-comment de `settlement_entry_cancel_blocker`, à côté de la « Limite + assumée » existante du verrou de période du jour (`settlement_cancellation.rs:38-40`) — même + nature : un refus du socle, au clic, que la lecture ne reproduit pas ; dans `docs/api-external.md`, + aux deux paragraphes qui documentent ces champs (`:353`, `:361`) et à celui des règlements client + (`:319`) ; au Dev Agent Record ; + - **ce qui le fixe** : un test (`kesh-db`, état posé par SQL) qui asserte, dans cet état, + `settlement_entry_cancel_blocker` → `None` **et** l'annulation → `LaterFiscalYearClosed` — + patron C-15-8-29 : il rougit le jour où le rang est ajouté, et son doc-comment le dit ; + - **pourquoi pas le rang** : il ajouterait une variante à `SettlementCancelBlocker`, trois bras aux + `match` exhaustifs de `kesh-api/src/errors.rs` (`:2988`, `:3519`, `:3556` sur `5e4bec50`), trois + `switch` exhaustifs (`never`) côté écran (`settlement-cancel-blocked.ts`, + `reconciliation-cancel.ts`, `invoice-cancel.ts`) avec leurs types, des clés ×4 et la documentation + des champs — cinq modules de premier niveau de plus pour une fiche qui en compte déjà dix (§ + *Dérogation*), pour un état hérité étroit (un exercice futur clos) dont le refus au clic est + exact et explique la marche à suivre. **Issue à ouvrir par l'orchestrateur** (P3 : écran qui + annonce un geste que le serveur refuse) pour ajouter le rang hors de cette story. + +### Ce qui doit être prouvé, et le reste + +19. **Mutations — part B** (chacune jouée, observée rouge, restaurée — ⚠️ toucher le fichier après + restauration, cargo garde le binaire muté) : (v) retirer le filet de `create_in_tx_inner` → les tests + de l'AC 20 rouges ; (vi) rétablir `if enforce_ownership` dans `delete_in_tx` → test de dévalidation + rouge ; **(vii-a)** retirer le bras `LaterFiscalYearClosed` de `project_error_to_failed_proposal` → + tests **facture et ventilé** de l'AC 11 rouges (`DATABASE_ERROR`) — chacun des deux doit rougir, + sinon l'un ne passe pas par la voie qu'il prétend exercer ; **(vii-b)** retirer l'appel du + constructeur dans la branche en ligne d'`accept_one_rule` → test **règle** de l'AC 11 rouge. Les + mutations (i)-(iv), (viii)-(x) sont dans la 15-12a. Résultat de chaque mutation au Dev Agent Record. + +20. **Le filet, flux par flux.** Un test **par point de passage et par famille** — pas les 22 routes, + mais au moins : saisie manuelle (`POST /journal-entries`, HTTP `400` et corps complet, message de + l'AC 9), validation d'une facture, règlement client, paiement fournisseur, complément des soldes de + départ, contre-passation **par la route** (`POST /journal-entries/{id}/reverse`, datée du jour : + patron de la 15-8b, `journal_entries.rs:3444-3455` — un exercice postérieur **clos**, commençant le + lendemain de la fin de l'exercice qui couvre le jour, posé par SQL direct, fait de l'exercice du jour + l'exercice fautif), dévalidation, acceptation par lot (`failed[]` — les trois voies de l'AC 11). État + fautif posé par SQL direct. Chaque test asserte aussi qu'**aucune** écriture n'a été créée ou + supprimée (compte avant/après) — un refus qui laisse une trace partielle n'est pas un refus. + +21. **Tests existants à reprendre — part B** (ceux que le filet change de sens : un exercice postérieur + clos posé par SQL, puis une écriture ou une suppression — dévalidation — dans un exercice antérieur) + — triage au gate, chaque rouge classé (a) scénario désormais refusé à bon droit → réécrit + (l'écriture placée dans un exercice sans postérieur clos, ou le refus asserté si le test vise le + filet), ou (b) défaut réel → corrigé. *(Réécrit en P3 : R1 = F3.)* + **Inventaire par le SYMPTÔME, non par une forme** — le symptôme est « un exercice `Closed` en base de + test ». La P2 cherchait la seule graphie `SET status = 'Closed'` : elle ne voyait ni les aides qui + lient le statut par `?` (`set_status`, appelée par `exercice_de`), ni les `INSERT … 'Closed'`, ni + `ON DUPLICATE KEY UPDATE status = 'Closed'`. Son « 20 sites » était le compte d'**une forme**, non de + la classe. Commande (relevé sur `5e4bec50`) : + `git grep -nE "'Closed'|\"Closed\"|status = \?" -- 'crates/*.rs'` — **72 lignes** (70 sur + `8f9811d8`), triées en deux : + - **28 lignes qui ne posent rien** : code de production qui lit ou écrit le statut + (`fiscal_years.rs` ×6, `journal_entries.rs` `:53`, `:322`, `:663`, `:1097`, `:1378`, `:1689`, + `accounts.rs:438`, `settlement_cancellation.rs:81`, `entities/fiscal_year.rs` ×2, + `imported_supplier_invoices.rs:281` — autre table), doc-comments de routes + (`routes/fiscal_years.rs` ×2, `routes/opening_balances.rs:95`), assertions sur une réponse + (`fiscal_years_e2e.rs:681`, `opening_balances_e2e.rs:965`, `:1100`, `fiscal_years_repository.rs:318`, + `:753`), constructeurs en mémoire sans base (`opening_complement.rs:730`, `:749`), + `vat_report.rs:510` ; + - **44 lignes — 42 sites — qui posent un exercice clos en base de test** (deux sites s'écrivent sur + deux lignes : `journal_entries.rs:3258-3259`, `:3452-3453`). Chacun lu, avec la question qui décide : + *le test écrit-il ou dévalide-t-il, après la pose, dans un exercice **antérieur** à l'exercice + clos ?* + + | Sites (relevé `5e4bec50`) | Ce que le test fait après la pose | Verdict | + |---|---|---| + | `journal_entries.rs` `mod tests` `:3452-3453` (`la_devalidation_ne_voit_pas_l_exercice_posterieur`, C-15-8-29) | dévalidation (`delete_in_tx`, `enforce_ownership = false`) d'une écriture de N sous N+1 clos | **change de sens** — inversé par l'AC 10 (résolu) | + | `journal_entries.rs` `:3258-3259` (aide de précédence 15-8b), `:3269` ; `journal_entries_modification.rs:395`, `:499` | `delete_in_tx(…, true)` ou `update` : gardes 15-8a/15-8b, inchangées | inchangé ; doc-comment réécrit par la 15-12a (AC 21) | + | `journal_entry_reversal_e2e.rs` aide `set_status` `:1372` et ses appels `:1846`, `:2086`, `:2135`, `:2526`, `:2593` ; aide `exercice_de` (`:1353`, qui crée puis appelle `set_status`) aux appels clos `:1863-1865`, `:2077`, `:2510`, `:2585` | `PUT` / `DELETE` (gardes 15-8a/15-8b) ; les écritures et contre-passations de ces tests sont faites **avant** la pose, ou dans l'exercice du jour quand le seul exercice clos lui est **antérieur** (`:2135` : N-1 clos, écritures dans l'exercice courant) | inchangé — ⚠️ l'AC 21 de la P2 le disait « un seul exercice » : faux pour ce fichier, mais le verdict tient pour une autre raison | + | `journal_entry_reversal_e2e.rs:1103`, `:2723`, `:2894` | `:1103`, `:2723` : un seul exercice ; `:2894` : clôt l'exercice **passé** puis contre-passe dans l'exercice courant, postérieur | inchangé | + | `opening_complement_repository.rs` aide `set_status` `:197`, appels `:288`, `:311`, `:484`, `:685` | clôt le **premier** exercice (`co.fy[0]`) ; aucun exercice ne le précède | inchangé | + | `opening_balances_e2e.rs:1492` (`WHERE company_id = ?`) | clôt **tous** les exercices : aucun ouvert ne précède un clos | inchangé | + | `reconciliation_manual_e2e.rs:264` (aide `insert_closed_fiscal_year`, appelée `:1023`) | seul exercice de la société, clos | inchangé | + | `accounts_e2e.rs:363` ; `accounts.rs` `mod tests` `:1453`, `:1562`, `:1753` ; `period_lock_e2e.rs:608`, `:709` ; `invoice_settlement.rs:1212` ; `invoice_write_off.rs:756` ; `invoices_validate_vat.rs:1330` ; `reconciliation_cancel.rs:133` ; `supplier_invoices_repository.rs:1660`, `:1966`, `:2240` ; `invoices.rs` `mod tests` `:4876`, `:4954` | un seul exercice dans le montage (société propre, ou exercice du jour sur la base partagée) : aucun postérieur | inchangé | + + **Liste fermée des sites qui changent de sens : un seul**, `journal_entries.rs` `:3452-3453`, résolu + par l'AC 10. `invoices_validate_vat.rs:1330` et `supplier_invoices_repository.rs:2240` sont **neufs** + depuis `8f9811d8` (15-5d) ; les numéros d'`invoices.rs` `mod tests` ont glissé (`:4797` → `:4876`, + `:4875` → `:4954`). T0 refait la commande sur le `main` du moment et trie **chaque ligne neuve** avec + la même question ; la table recomptée va au Dev Agent Record. Le triage au gate reste le filet des + rouges ; cet inventaire est celui des tests qui changeraient de sens **sans** rougir. + ⚠️ **Pollution de la base partagée, aggravée** : le filet rend fatal, pour **toute** création + d'écriture de la société, un exercice clos **postérieur** laissé par un test interrompu, et + `ensure_open_fiscal_year` (`journal_entries.rs:2483-2523`) ne répare que l'exercice qui **couvre** le + jour. Décision (C100) : les tests qui posent un postérieur clos le retirent en fin de test (patron + 15-8a), et le mode d'échec est écrit dans `docs/testing.md` § « Base de dev jetable » (la remise à + zéro inconditionnelle du `CLAUDE.md` le couvre) ; pas de réparation ajoutée à + `ensure_open_fiscal_year`, qui masquerait un résidu au lieu de le signaler. + +23. **Documentation — part B.** + - **Manuel utilisateur** (`docs/manual/fr/user-manual.tex`) : l'item « Verrouille l'exercice » + (`:708`) perd la parenthèse « pour la modification et la suppression seulement… limite suivie par + l'issue #543 » et dit que **rien** ne s'écrit plus dans un exercice suivi d'un exercice clos ; + § « Réouverture » (`:717-723`) — **la procédure de réparation** d'un état hérité, **celle du + bandeau** (AC 15, dans le même ordre : d'abord clôturer l'exercice ouvert le plus ancien ; sinon + rouvrir à partir du plus récent clos — jamais « rouvrir l'exercice clos le plus proche » en + premier) ; conditions de modification (`:494-500`) — retirer « ⚠️ Cette garde ne couvre encore… + #543 » ; soldes de départ (`:752`) — « Si un exercice suivant est déjà clôturé, son bilan reporté + l'est aussi » devient faux : réécrire (le complément est refusé tant qu'un exercice postérieur est + clôturé) ; note de la contre-passation (`:627`) relue ; **liste des refus de la dévalidation** + (`:1314-1337`, « Le refus nomme toujours son motif », puce de l'exercice clos `:1333`) — puce neuve + « écriture d'un exercice **suivi d'un exercice clôturé** », à son rang de précédence (AC 10, après + « exercice clos »). **Inventaire des sites par recherche, non par énumération** : `grep -nE + "exercice (est )?(clos|clôturé|fermé)|exercice postérieur|période verrouillée|refuse" + user-manual.tex` ; chaque site rendu est soit modifié, soit écrit « inchangé » au Dev Agent Record + avec son motif (relevé P1 : `:352`, `:540`, `:582`, `:593`, `:1174`, `:1394`, `:1416`, `:1610` — + liste « motif du refus en clair : … etc. » du lot, qui reste vraie —, `:1698-1702`, `:2219-2228` + restent vrais ; la part A a déjà traité `:697-723` pour la clôture). + - **Manuel admin** (`admin-manual.tex`, recalé sur `8f9811d8`) : § « Export/import d'installation via + l'interface Kesh » (`:1641`), « Importer (restaurer la même installation) » (`:1657`), « Reprises de + données rejouées à l'import » (`:1674`) — une sauvegarde d'une version antérieure peut porter des + exercices clôturés dans le désordre : l'écran le signale, voici la réparation (celle du bandeau, + même ordre). Le PDF admin est aplati et contrôlé comme l'utilisateur. + - **PDF régénérés** (`make fr` dans `docs/manual/`), contrôlés **aplatis** + (`pdftotext f.pdf - | tr '\n' ' ' | tr -s ' '`) : plus aucune mention « #543 » ni « limite » + attachée à la garde ; les phrases neuves présentes. + - **`docs/api-external.md`** — inventaire des sites **par recherche** : `grep -nE + "FISCAL_YEAR_CLOSED|FISCAL_YEAR_INVALID|PERIOD_LOCKED|failed: \[|ordre de précédence" + docs/api-external.md` rend tous les tableaux et listes de refus des routes qui écrivent au journal ; + **chaque** site rendu est traité ou écrit « inchangé, motif » au Dev Agent Record. Relevé + `8f9811d8` (numéros identiques à `9cb5083b`) : + - `:253` — l'avertissement « ne garde aujourd'hui que ce `PUT` et la suppression… #543 » est + supprimé ; `:238-251` (`PUT`) et `:270-279` (`DELETE`) : déjà conformes ; + - **dévalidation** `:287-297` — ligne `LATER_FISCAL_YEAR_CLOSED` (`400`) après `FISCAL_YEAR_CLOSED` + (précédence de l'AC 10) ; la table des `details` (`:301-309`) gagne sa forme propre + (`fiscalYearId` / `fiscalYearName`, **pas** `documentId` / `documentNumber`) ; la phrase `:311` + (« Les **cinq autres** codes… 10 lignes en tout, 5 avec `details` et 5 sans ») est **recomptée + depuis le tableau** (attendu : 11 lignes, 6 avec `details`, 5 sans — à vérifier au tableau + final, pas à recopier d'ici) ; + - **annulation d'un règlement client** `:321-331`, **solde du reste** `:338-347`, **annulation + d'un rapprochement** `:381-391` — ligne `LATER_FISCAL_YEAR_CLOSED` (pour les annulations : la + contre-passation datée du jour, refusée si l'exercice du jour est suivi d'un exercice clos) ; + - **listes** `:355` (annulation du règlement fournisseur) et `:363` (annulation d'une facture + fournisseur, « dans l'ordre de précédence ») — le code inséré **à son rang**, relu au code en + T0 (il naît de `create_in_tx_inner`, donc après `FISCAL_YEAR_INVALID`) ; + - **acceptation par lot** `:371` — `LATER_FISCAL_YEAR_CLOSED` cité parmi les `errorCode` de + `failed[]`, avec ses `details`, pour les trois voies (sans `projectId` pour la voie « règle », + AC 11) ; + - **prédicteurs** `:319` (règlements client, `cancelBlockedBy`), `:353` (`settlementCancelBlockedBy`), + `:361` (`cancelBlockedBy` de la facture fournisseur) — l'angle mort de l'AC 18 : le champ peut + dire l'annulation possible et le clic rendre `LATER_FISCAL_YEAR_CLOSED` (C120) ; + - `:484` (tableau des erreurs) — routes concernées : toute écriture au journal (dont + `POST /journal-entries`, qui n'a pas de section propre), la dévalidation, `failed[]` de + `POST /reconciliation/accept` ; retrait de « ne garde pas encore les autres chemins (#543) » + (la 15-12a y a déjà ajouté `EARLIER_FISCAL_YEAR_OPEN` et la création d'exercice). + - **`CHANGELOG.md` `[0.13.0]`** : l'entrée de la 15-12a sous `### Corrigé` est complétée (#543 : le + filet, l'écran, la réparation des états hérités — `closes #543`) ; la parenthèse de l'entrée #532 + (`:15`, « garde posée sur la modification et la suppression : la saisie, la contre-passation… ne la + portent pas encore, #543 ») est réécrite — **propagation** : `grep -rn "543"` sur `CHANGELOG.md docs + crates frontend/src` ne doit plus rendre de « limite connue » (relevé `8f9811d8` : `CHANGELOG.md:15`, + `api-external.md:253`, `:484`, `user-manual.tex:500`, `:708`, commentaires `C-15-8-29`). + +## Tasks / Subtasks + +- [ ] **T0 — Relevés au sol sur le `main` du moment** (AC 11, 12, 18, 21) *(ex-T0, part B)* + - [ ] Refaire les numéros de ligne cités ; recompter le registre (22 / 2) ; produire la table de l'AC 12. + - [ ] Inventaire des écritures SQL sur `journal_entries` / `journal_entry_lines` hors tests (AC 12) ; + `delete_all_by_company` reconfirmé ; lettrage de la 15-1a s'il est mergé. + - [ ] Les trois sites `create_in_tx` du lot et leur traitement d'erreur (AC 11) ; les deux routes + unitaires (`post_manual_once`, `post_split_once`) et leur conversion d'erreur. + - [ ] Relever les sites frontend et les prédicteurs serveur de l'AC 18 ; refaire la commande de + l'AC 21 par le symptôme et trier chaque ligne neuve. + - [ ] Constater si la 15-1a est mergée (`grep -n "ENTRY_LETTERED\|EntryLettered" + crates/kesh-db/src/repositories/journal_entries.rs`) : si oui, la paire de précédence de C117 + revient à cette fiche (frontière). +- [ ] **T1 — Le filet** (AC 8, 10, 12, 20) *(ex-T4, sans le message, passé à la 15-12a)* — + `create_in_tx_inner`, `delete_in_tx` sans condition, test C-15-8-29 inversé, précédence de la + dévalidation et sa paire, tests flux par flux ; doc-comments complétés (module `fiscal_years.rs`, + `DbError::LaterFiscalYearClosed`, `find_later_closed` — second appelant et raison de l'absence de + verrou —, étape 1 de `create_in_tx_inner`) ; ligne `admin::full_import` du registre (« exclusif » + devient « exclusif des autres imports seulement »). **Vérification que le filet n'ajoute aucun + verrou** (R6, reprise de l'ancien AC 14) : `grep -rnE "find_later_closed(_in_tx)?\(" + crates/kesh-db/src` — la version `_in_tx` n'est appelée que par `update`, `delete_in_tx`, `reopen` + et `create` d'exercice ; la version non verrouillante, que par le motif d'écran de + `GET /journal-entries/{id}` et par `create_in_tx_inner` (neuf). Résultat au Dev Agent Record. + Angle mort des prédicteurs écrit (AC 18, C120) : doc-comment de + `settlement_entry_cancel_blocker`, test qui le fixe. +- [ ] **T2 — Lot de rapprochement** (AC 11) *(ex-T5)* — constructeur, bras de + `project_error_to_failed_proposal`, branche d'`accept_one_rule`, libellé écran, recompte des codes + (27), tests Rust par voie et Vitest. +- [ ] **T3 — Écran : bandeau et autres écrans** (AC 15, 18) *(ex-T7, part B)* — bandeau (deux voies, + trois noms), clé `fiscal-year-out-of-order-warning` ×4, inventaire de l'AC 18 par les appelants + (boîte de validation d'une facture) ; `npm run lint-i18n-ownership` ; Vitest. +- [ ] **T4 — Réparation** (AC 16) *(ex-T8)*. +- [ ] **T5 — Tests existants** (AC 21, part B) *(ex-T9, part B)* — triage écrit, test par test, `mod + tests` de `src/` compris ; mode d'échec de la base partagée écrit dans `docs/testing.md`. +- [ ] **T6 — Mutations** (AC 19, part B) *(ex-T11, part B)*. +- [ ] **T7 — Documentation** (AC 23, part B) *(ex-T12, part B)* — manuels + PDF aplatis, + `api-external.md`, CHANGELOG, grep `543`. +- [ ] **T8 — Gates** *(ex-T13)* — base remise à zéro (DROP/CREATE de **ses** bases, jamais de + redémarrage du conteneur) ; `scripts/test-fast.sh` complet (story `kesh-db` : gate complet même en + cours de boucle) ; frontend (`check`, `lint-i18n-ownership`, `test:unit`, `build`) ; E2E complet au + **dernier commit de code**, jugé fichier par fichier contre `docs/testing.md` § « Les échecs + attendus ». Ne déclarer que ce qui a tourné. + +## Dev Notes + +### Pourquoi deux points de passage suffisent + +Découverte de la spécification (15-12, T0) : les dix-neuf routes qui créent une écriture passent toutes +par `create_in_tx_inner`, et les deux qui en suppriment une par `delete_in_tx`. Le filet coûte donc +deux sites et non dix-neuf ; l'inventaire de l'AC 12 est ce qui le prouve, et c'est à T0 de le refaire. + +### La preuve du filet non verrouillant (AC 8) + +*(Réécrite en P1, C100 ; répartie à la découpe : (α) est tenue par la 15-12a.)* + +1. **(α) L'invariant I** est vrai de tout état **validé** obtenu par les transitions de l'application + depuis un état sain (clôture : AC 1 ; création : AC 5 ; réouverture : LIFO — tous de la 15-12a). + C'est lui qui dépend de la lecture verrouillante de la clôture, face à la course réouverture/clôture + (15-12a, test 13 a, mutation (ii)). +2. **(β) Le verrou de l'écrivain** : un écrivain dans N tient le verrou `FOR UPDATE` de N dès + l'étape 1 de `create_in_tx_inner` (`journal_entries.rs:310-317`) — ou avant, par + `find_open_covering_date` — et lit son statut **sous ce verrou** (verdict « exercice clos » de + l'étape 1, sur l'état courant, non sur la vue). +3. Sous (α), un exercice L > N ne peut être clos qu'une fois N clos ; sous (β), N reste ouvert jusqu'au + `COMMIT` de l'écrivain. Donc, si l'état était sain quand l'écrivain a lu N ouvert sous verrou, aucun + postérieur clos n'apparaît avant son `COMMIT`, et la lecture du filet — même sur une vue plus + ancienne — n'a rien de neuf à voir. Si l'état était **hérité** fautif, il ne peut que se résorber + (réouverture LIFO, clôture de N) : une vue ancienne ne produit qu'un **refus de trop**, jamais une + acceptation de trop. +4. ⚠️ **Angle mort assumé — la restauration en vol** (C100). `admin::full_import` ne se sérialise + qu'avec les autres imports (verrou `_kesh_version id = 1 FOR UPDATE`, `routes/admin.rs:173-179`), + **pas** avec les écrivains : un écrivain dont la vue précède une restauration peut lire par le filet + la table `fiscal_years` d'avant, alors que son verrou porte sur la ligne restaurée. Rien dans le code + ne l'exclut ; c'est le cas, préexistant et plus large, de toute écriture en vol pendant une + restauration. Écrit tel quel au doc-comment du module et à la ligne `admin::full_import` du registre. +5. Si une future transition écrit `fiscal_years.status` sans passer par `close` / `reopen`, la preuve + tombe : le doc-comment du module le dit (15-12a, AC 7). + +### Ordre des verrous — ce qui change, ce qui ne change pas + +- **Change** : `delete_in_tx` prend les postérieurs (`find_later_closed_in_tx`) aussi pour la + dévalidation. +- **Ne change pas** : les dix-neuf flux de création (lecture non verrouillante) ; `update` ; `reopen`. +- Dévalidation contre clôture : aucun cycle trouvé à la lecture **sous le plan par l'index** — la + dévalidation tient N par clé primaire puis parcourt les postérieurs ; la clôture d'un Y > N (15-12a, + C119) verrouille ses antérieurs un par un, **N compris**, avant Y : elle attend N sans rien tenir que + la dévalidation demande (ses verrous déjà pris sont antérieurs à N). Sous un plan qui ne suit pas + l'index, le parcours des postérieurs peut demander des antérieurs que la clôture tient : cycle + possible, absorbé par le rejeu des **deux** routes (`unvalidate_invoice_handler` est `Rejouee`, la + clôture le devient). T0 le confirme. La ligne `DELETE` de + `docs/MULTI-TENANT-SCOPING-PATTERNS.md` (`:327`, « No known cycle ») est relue et reste vraie, ou est + corrigée. + +### Codes et messages + +| Situation | Code | HTTP | Clé | +|---|---|---|---| +| Écriture / dévalidation sous un postérieur clos | `LATER_FISCAL_YEAR_CLOSED` | 400 | `error-later-fiscal-year-closed` (posée par la 15-12a) | +| Idem, dans `failed[]` d'un lot (trois voies) | `LATER_FISCAL_YEAR_CLOSED` | 200 | `reconciliation-failed-later-fiscal-year-closed` (neuve, `$name`) | +| Bandeau d'état hérité | — | — | `fiscal-year-out-of-order-warning` | + +### Ce qui doit être préservé + +- Le comportement de la 15-8a/15-8b (`PUT`, `DELETE`, écran de la fiche) — codes, `details`, ordre. +- Les tests qui posent l'état fautif par SQL : ils sont les seuls moyens de l'exercer, ne pas les + « simplifier » en passant par l'API. +- `admin::full_import` : accepte une sauvegarde fautive (C89). + +### Hors périmètre, et écrit + +- Une route de réparation automatique — la réparation est comptable (rouvrir ou clôturer), elle passe + par les gestes existants. +- La détection de l'état hérité ailleurs que sur l'écran des exercices (tableau de bord, journal au + démarrage) — C89. +- Le lettrage dans l'état hérité (15-1a, C112). + +### Dérogation règle de splitting + +*(Section rédigée en P3 — F9, décision de l'orchestrateur, C121.)* + +- **Le critère franchi** : le premier critère de la § « Règle de splitting préventif » du `CLAUDE.md` + (plus de 5 modules). Recompté en P3, au même critère que la 15-12a (un module dont seul un + doc-comment change compte) : **10** modules de premier niveau — `kesh-db/repositories/journal_entries`, + `kesh-db/repositories/invoices` (doc-comment de `unvalidate`, commentaires C-15-8-29), + `kesh-db/repositories/fiscal_years` et `kesh-db/errors` (doc-comments complétés), + `kesh-db/repositories/settlement_cancellation` (doc-comment de l'angle mort, AC 18 — **neuf en P3**, + C120) ; `kesh-api/routes/reconciliation` ; `kesh-i18n` ; `frontend/routes/(app)/settings/fiscal-years` + (bandeau), `frontend/features/reconciliation`, `frontend/routes/(app)/invoices/[id]` (boîte de + validation). Hors décompte : tests, doc, écrans de l'AC 18 au verdict « inchangé ». +- **Pourquoi l'exception codifiée s'applique** : la story a **déjà** été découpée une fois (C107), selon + la seule couture naturelle — l'ordre (15-12a) et le filet (cette fiche). Un second découpage + séparerait le filet de son inventaire (AC 8, 10, 12 : les deux points de passage ne prouvent rien sans + la preuve qu'aucun écrivain ne les contourne), ou le lot de ses voies (AC 11), ou le bandeau de la + réparation qu'il prescrit (AC 15, 16) : des sous-stories qui ne seraient **pas testables isolément** + — l'exception que le `CLAUDE.md` écrit (« merges intermédiaires impossibles à tester en isolation »). + Sept des dix modules ne portent qu'un ou deux sites (trois doc-comments, un bras et un constructeur, + un libellé, une condition de fermeture, des clés). +- **Risque accepté** : une revue de code qui embrasse dix modules relit moins finement chacun ; la + remédiation d'une passe peut introduire un défaut dans un module que la passe suivante ne relit pas. + Mitigation : la passe ciblée sur chaque remédiation (§ *La passe ciblée*), les inventaires par le + symptôme (AC 12, 18, 21, 23) refaits à chaque passe, et le rang neuf des prédicteurs **sorti** de la + story (C120) plutôt qu'ajouté à cinq modules de plus. +- **Signal déclaré au Project Lead** (Guy), dans cette fiche et au registre (C121). + +### Tests — ce qui rendrait un test vert sans rien prouver + +- Un état fautif posé **dans la transaction même** de l'écrivain : il verrait sa propre écriture ; + le poser par une connexion distincte, validée. +- Un test de refus qui ne compte pas les écritures avant/après (AC 20). +- Un test du lot qui ne passe que par une voie (facture) en croyant couvrir les trois (AC 11, mutations + (vii-a) et (vii-b)). +- **Angle mort assumé — le bandeau n'a pas d'E2E** (R6, phrase de l'ancien AC 22 rendue à cette fiche) : + l'état fautif ne s'obtient pas par l'API (la 15-12a le rend inatteignable), et le montage E2E n'a pas + de SQL direct ; le bandeau de l'AC 15 est couvert par Vitest (texte, trois noms, cas `{closed}` ≠ + `{latest}`), **pas** par Playwright. Écrit au Dev Agent Record. +- Une mutation restaurée par `cp`/`mv` sans `touch` : le binaire muté reste en cache. + +### Fichiers touchés (prévision) + +`crates/kesh-db/src/repositories/journal_entries.rs`, `crates/kesh-db/src/repositories/invoices.rs` +(commentaires C-15-8-29, précédence de `unvalidate`), `crates/kesh-db/src/repositories/fiscal_years.rs` +et `crates/kesh-db/src/errors.rs` (doc-comments complétés), +`crates/kesh-db/src/repositories/settlement_cancellation.rs` (doc-comment de l'angle mort, AC 18) et le +test qui le fixe, `crates/kesh-api/src/routes/reconciliation.rs`, +`crates/kesh-api/tests/audit_route_registry.rs` (ligne `admin::full_import`), tests listés aux AC 11, +16, 20, 21, `crates/kesh-i18n/locales/{fr-CH,de-CH,it-CH,en-CH}/messages.ftl`, +`frontend/src/routes/(app)/settings/fiscal-years/+page.svelte` et son test, +`frontend/src/lib/features/reconciliation/failed-proposal-label.ts` et son test, +`frontend/src/routes/(app)/invoices/[id]/+page.svelte` (AC 18), autres sites de l'AC 18 si T0 en trouve, +`docs/manual/fr/{user,admin}-manual.{tex,pdf}`, `docs/api-external.md`, `docs/testing.md` (mode d'échec +de la base partagée, AC 21), `CHANGELOG.md`. **Aucune migration** (P1-P8 sans objet). + +### References + +- Issue #543 et ses deux commentaires (15-8a, 15-8b). +- `crates/kesh-db/src/repositories/fiscal_years.rs` — `find_later_closed_in_tx` `:646`, + `FIND_LATER_CLOSED_SQL` `:668`, `find_later_closed` `:678`. +- `crates/kesh-db/src/repositories/journal_entries.rs` — `create_in_tx_inner` `:258` (étape 1 + `:308-335`), `update` `:1260` (refus `:1378-1403`), `delete_in_tx` `:1648` (`:1681`). +- `crates/kesh-api/src/routes/reconciliation.rs` — `period_locked_failed_proposal` `:193`, + `project_error_to_failed_proposal` `:244`, `accept_one_invoice` `:1225`, `accept_one_split` `:1879`, + `accept_one_rule` `:2282`. +- `crates/kesh-api/tests/audit_route_registry.rs:168-301`. +- `crates/kesh-report/src/balance_sheet.rs:1-20` (bilan cumulatif). +- Fiches `15-12a-cloture-dans-l-ordre.md` (prérequis), `15-12-cloture-dans-l-ordre.md` (index ; version + complète au commit `dae3a618`), `15-8a-modifier-une-ecriture.md`, `15-8b-supprimer-une-ecriture.md` + (C-15-8-22, C-15-8-29), `15-5e1-socle-rejeu.md`, `15-1a-socle-lettrage.md` (frontière). +- `CLAUDE.md` § Pattern batch, § Règle de splitting, § Inventorier les sites non résolus, + § Propagation post-patch. + +## Dev Agent Record + +### Agent Model Used + +### Debug Log References + +### Completion Notes List + +### File List + +## Change Log + +- 2026-10-09 — **Créée au découpage de la 15-12** (remédiation de la validation P2, décision de + l'orchestrateur, C107). Historique de la 15-12 (création C89 ; validation P1, Opus ×2 : 1 HIGH / 4 + MEDIUM / 14 LOW distincts, C100) : Change Log de l'index `15-12-cloture-dans-l-ordre.md`. + **Validation P2** (Sonnet ×2, contexte frais ; rapports `target/gate-logs/15-12-p2-{R,F}.md`) : **0 + CRITICAL, 0 HIGH, 4 MEDIUM, 10 LOW distincts** (14 sur 18 bruts ; détail au Change Log de la 15-12a). + Trend : P1 1 HIGH / 4 MEDIUM → P2 0 HIGH / 4 MEDIUM. Traités **ici** : **R1 = F1** (MEDIUM) — l'AC 11 + nomme les trois voies, dont la branche en ligne d'`accept_one_rule`, un constructeur unique, un test + par voie, mutations (vii-a) et (vii-b) (C108) ; **F8** — code posé par un littéral, recompte à 27 par + la commande ; **F10** (part B) — 20 sites de tests relevés par recherche à l'AC 21 (dont les 8 de la + lentille, dans 5 fichiers, et 4 sites non cités de fichiers cités) ; **R6** (part B) — frontière avec + la 15-1a sur `delete_in_tx` et le lettrage (C112) ; **R5 = F6** (part B) — manuel administrateur + recalé (`:1641`, `:1657`, `:1674`). Le reste (F3/R3 découpage, R2, F2, R4/F5, R7/F9, F4, F11) est dans + la 15-12a. Comptes : **11 AC** (8, 10, 11, 12, 15, 16, 18, 19, 20, 21, 23 — numéros d'origine), + **9 tâches** (T0-T8), recomptés. Passe suivante : cf. index. +- 2026-10-09 — **Validation P3** (Opus ×2, contexte frais, sur les deux fiches ; rapports + `target/gate-logs/15-12-p3-{R,F}.md`) : **0 CRITICAL, 0 HIGH, 3 MEDIUM, 15 LOW distincts** (détail et + origine au Change Log de la 15-12a ; deux MEDIUM nés de la remédiation P2). Trend : P1 1 HIGH / 4 + MEDIUM → P2 0 HIGH / 4 MEDIUM → P3 0 HIGH / 3 MEDIUM. Traités **ici** : **R1 = F3** (MEDIUM) — AC 21 + part B réécrit **par le symptôme** (`'Closed'`, `"Closed"`, `status = ?` des aides `set_status` / + `exercice_de`, `INSERT … 'Closed'`, `ON DUPLICATE KEY UPDATE`) : 72 lignes sur `5e4bec50`, 28 qui ne + posent rien, 44 lignes = 42 sites qui posent un exercice clos, **tous lus** ; liste fermée des sites qui + changent de sens : **un seul** (`journal_entries.rs:3452-3453`, C-15-8-29), résolu par l'AC 10 ; « 20 + sites » requalifié en compte d'une forme ; la lecture « un seul exercice » de + `journal_entry_reversal_e2e.rs` corrigée (le verdict « inchangé » tient pour une autre raison) ; + **F1** (MEDIUM) — prédicteurs serveur ajoutés à l'inventaire de l'AC 18 ; le rang « exercice du jour + suivi d'un exercice clos » **non** ajouté : angle mort assumé, écrit au doc-comment de + `settlement_entry_cancel_blocker`, dans `api-external.md` et fixé par un test, issue à ouvrir (C120 — + l'option préférée par l'orchestrateur, écartée pour son coût : cinq modules de plus) ; **C117** — + précédence 2-bis avant 3-ter-bis dans `delete_in_tx`, écrite et testée par la 15-1a dans l'ordre + préféré, par cette fiche si la 15-1a est mergée d'abord (frontière, T0) ; **R4** — AC 11 : les deux + sites unitaires (`:3304`, `:3756`) nommés et écartés du lot, `projectId` omis pour la voie « règle » + (C123) ; **F7** — faux positifs de la commande de l'AC 12 listés ; **R6** — angle mort « bandeau sans + E2E » rendu aux Dev Notes, vérification « le filet n'ajoute aucun verrou » rendue à la T1 ; **F9** + (part B) — section « Dérogation règle de splitting », modules recomptés : **10** (le doc-comment de + `settlement_cancellation` s'ajoute, C120 ; C121) ; références `FIND_LATER_CLOSED_SQL` `:668`. + Comptes : **11 AC** (8, 10, 11, 12, 15, 16, 18, 19, 20, 21, 23), **9 tâches** (T0-T8), recomptés. + Passe suivante : cf. index. + +- **2026-10-09 — validation P4 ciblée (Haiku, prompt `15-12b-validate-prompt-p4-ciblee.md`)** : rapport + `target/gate-logs/15-12b-p4-ciblee.md`, 3 MEDIUM + 1 LOW annoncés. **Les trois MEDIUM sont écartés comme + erreurs de catégorie**, vérifiées par l'orchestrateur : la lentille a cherché **dans le code** le test + `la_devalidation_ne_voit_pas_l_exercice_posterieur` (M1), le doc-comment C120 de + `settlement_entry_cancel_blocker` (M2) et l'entrée CHANGELOG qui ferme #543 (M3) — trois livrables que + cette fiche **prescrit** et que le développement écrira ; aucun n'est un défaut de la spec. Son + inventaire « 60 lignes » est faux : la commande de l'AC 21 rejouée sur `origin/main` (`5e4bec50`) rend + **72** lignes, dont les deux de `journal_entries.rs:3452-3453`, conformément à la fiche. Axes repris par + l'orchestrateur : (1) recompte ci-dessus ; (2) C120 — **un LOW réel** : l'issue du rang écarté (**#568**, + ouverte le 2026-10-09) n'était pas citée ; ajoutée au paragraphe C120, avec obligation de la citer au + doc-comment et dans `api-external.md` ; (3)-(5) AC 11/12/18, C117, dérogation (10 modules) et rubriques + du CHANGELOG relus au diff `c7a5dbcc` : cohérents. **Bilan P4 : 0 au-dessus de LOW — validation close.** + Trend : P1 (15-12 entière) 1 HIGH / 4 MEDIUM → P2 0 HIGH / 4 MEDIUM → P3 0 HIGH / 3 MEDIUM → P4 ciblée 0. + Modèles : Opus, Sonnet ×2, Opus ×2, Haiku (ciblée). + diff --git a/_bmad-output/implementation-artifacts/15-12b-validate-prompt-p4-ciblee.md b/_bmad-output/implementation-artifacts/15-12b-validate-prompt-p4-ciblee.md new file mode 100644 index 000000000..70022e44d --- /dev/null +++ b/_bmad-output/implementation-artifacts/15-12b-validate-prompt-p4-ciblee.md @@ -0,0 +1,36 @@ +# Prompt — validation P4 ciblée de la spec, Story 15-12b + +*Versionné le 2026-10-09. Passe ciblée (CLAUDE.md § « La passe ciblée ») : une seule lentille (Haiku), contexte +frais, en lecture seule, braquée sur la seule remédiation P3 de la fiche 15-12b. Aucune règle du filet n'a changé en P3.* + +Dépôt `/home/gcorbaz/devel/kesh`, branche `story/15-5-gardes-postabilite-serveur`. **Objet** : +`git show c7a5dbcc -- _bmad-output/implementation-artifacts/15-12b-filet-sous-un-bilan-clos.md` (lis ce diff ; +ouvre la fiche autour des hunks). Rapports remédiés : `target/gate-logs/15-12-p3-R.md` et `15-12-p3-F.md`. +Choix C117, C120 à C123. Code de référence : `origin/main` (`git show origin/main:`), `5e4bec50`. + +## Lentille unique — chasseur de régressions de la remédiation + +1. **Inventaire de l'AC 21 part B** refait par le symptôme (72 lignes, 44 lignes / 42 sites qui posent un exercice + clos, un seul qui change de sens : `journal_entries.rs:3452-3453`) : rejoue la commande de la fiche et recompte ; + prends trois sites au hasard dans la table et vérifie leur classement au code. +2. **C120 (angle mort de `settlement_entry_cancel_blocker`)** : le doc-comment, `api-external.md` (`:319`, `:353`, + `:361`) et le test sur le patron C-15-8-29 sont-ils prescrits de façon implémentable et cohérente ? L'issue #568 + est-elle citée ? +3. **AC 11 (trois voies du lot, `projectId` omis pour la règle, C123)**, **AC 12**, **AC 18** : cohérence entre eux, + avec les tâches et avec le code (`grep -nF` sur `accept_one_rule`, `project_error_to_failed_proposal`). +4. **C117** (précédence 2-bis avant 3-ter-bis, la story mergée en second l'écrit) et la **dérogation** (10 modules, + C121) : recompte des modules ; la section est-elle conforme à l'exception du CLAUDE.md ? +5. **CHANGELOG** : `Corrigé` vs `Modifié` (C123) bien prescrits. + +## Ce que tu rends + +Findings numérotés (CRITICAL/HIGH/MEDIUM/LOW), `fichier:ligne`, **preuve** (sortie de `grep -nF` ; code relu cité), +correction proposée. ⛔ **Liste des axes exercés ET non exercés** — un « 0 » sans elle ne compte pas. Rapport +complet dans `target/gate-logs/15-12b-p4-ciblee.md` ; dernier message : le chemin, le bilan, une ligne par MEDIUM+. + +## Interdits + +⛔ N'écris aucun fichier hors `target/gate-logs/`. Aucune commande qui écrit dans le dépôt ou dans une base, ni qui +compile ou exécute des tests : `scripts/*` (dont `scripts/prepare-release.sh`), `make`, `latexmk`, +`git commit`/`add`/`checkout`/`stash`, `sqlx`, `cargo`, `npm`, `npx`, `gh issue create`/`comment`/`edit`, SQL +d'écriture. Autorisés : lecture, `grep`, `sed -n`, `git log`/`show`/`diff`, `gh api` en lecture. diff --git a/_bmad-output/implementation-artifacts/epic-15-choix-autonomes.md b/_bmad-output/implementation-artifacts/epic-15-choix-autonomes.md index 8143ff9a4..0120b5321 100644 --- a/_bmad-output/implementation-artifacts/epic-15-choix-autonomes.md +++ b/_bmad-output/implementation-artifacts/epic-15-choix-autonomes.md @@ -4442,3 +4442,554 @@ l'import (#458–#461). ce qui interdit de clore après elle) ; un test du second `Invariant` par désactivation de la contrainte (`SET check_constraint_checks = 0`) — il monterait un état que la base interdit, sans valeur de preuve. - **Réversibilité** : totale ; la dette se solde par une story de quelques lignes. +## C100 — 15-12, remédiation de la validation P1 : révise C89 (preuve, ordre des verrous, réparation, dérogation) +- **Contexte** : validation P1 de la 15-12 (lentilles R et F, Opus) — 1 HIGH, 4 MEDIUM, 14 LOW distincts, + aucun recyclé. Trois points de C89 sont faux ou mal fondés : la preuve du filet (point 4) prêtait à la + lecture verrouillante de la clôture un rôle dans le scénario « écrivain dans N », où elle n'en a + aucun ; l'ordre des verrous (point 3) affirmait que toutes les acquisitions d'exercice deviennent + ascendantes ; la réparation (point 5) prescrivait de rouvrir le plus proche postérieur clos, que la + garde LIFO refuse dès qu'un plus récent est clos. L'argument de non-découpage (point 6) reposait sur + la preuve réfutée. +- **Retenu** : + 1. **Preuve** : la sûreté du filet repose sur (α) l'invariant I sur tout état validé — c'est lui qui + dépend de la lecture verrouillante de la clôture, face à la course réouverture/clôture — et (β) le + verrou de son exercice tenu par l'écrivain. Test 13 a **ordonné** (réouverture de N simulée et non + validée, `close(L)` lancée, bloquée, puis validation de la réouverture → refus + `EarlierFiscalYearOpen(N)`), qui tue la mutation (ii) sur l'état final ; l'ancien 13 c (écrivain dans + N) est retiré ; la course libre `reopen_close_concurrent_is_serialized` est remplacée. + 2. **Test « refus parasite »** (ex-13 d, désormais 13 c) : forme **ordonnée** plutôt que l'ensemble + des issues admises — seule à garder contre le refus parasite, son objet. + 3. **Ordre des verrous** : la clôture est ascendante comme `reopen`, la garde 15-8a et + `find_open_covering_date` pris seuls ; la contre-passation (`reverse_in_tx_inner`) et les quatre + annulations qui la portent verrouillent l'exercice de l'origine puis parcourent depuis le premier + exercice (ordre inverse) : cycle possible avec la clôture, résolu par le rejeu des deux côtés (cinq + routes `Rejouee`, clôture enveloppée par l'AC 6) ; test 13 d à deux connexions, victime forcée si + InnoDB le permet (mesure en T0), mutation (ix). Le cycle réouverture/contre-passation, préexistant, + reste nommé au point (iv) du registre. + 4. **Réparation de l'état hérité** : d'abord clôturer l'exercice ouvert le plus ancien (l'AC 1 + l'accepte, geste du Comptable), puis les suivants dans l'ordre ; sinon, un administrateur rouvre + les exercices clos **à partir du plus récent** (seul ordre que LIFO accepte). Bandeau à trois noms + (`$open`, `$closed`, `$latest`) ; message global neutre, sans prescription de réouverture et + valable à la création d'un exercice. + 5. **Restauration en vol** : angle mort assumé, écrit (le verrou de `full_import` ne sérialise qu'avec + les autres imports) — et non plus « exclue ». + 6. **Clé du lot** : `reconciliation-failed-later-fiscal-year-closed` avec `$name` lu dans + `details.fiscalYearName` (troisième code lu par `details`). + 7. **Base partagée** : les tests qui posent un postérieur clos le retirent ; mode d'échec écrit dans + `docs/testing.md` ; pas de réparation dans `ensure_open_fiscal_year` (masquerait un résidu). + 8. **Pas de découpage**, sur un argument neuf : décompte refait, **11** modules ; la coupe A/B laisse + 7 et 8 modules (aucune moitié sous le seuil) et le remède de la règle (patron puis déploiement + mécanique) n'a pas de prise ; les deux moitiés réécriraient les mêmes paragraphes de doc ; les + défauts de P1 sont d'origine et se rangent par thème. **Déclencheur de repli élargi** : premier + défaut recyclé, quel qu'en soit l'objet, ou non-convergence (D5) → 15-12a / 15-12b. +- **Écartées** : asserter l'ensemble des issues au 13 c (ne garde plus contre le refus parasite) ; + forcer l'entrelacement du 13 a par un verrou d'intervalle sur `audit_log` (proposition F1 — plus + fragile que la réouverture simulée, et dépendante de la place de l'audit dans `reopen`) ; garder « tout + est ascendant » en rendant les annulations ascendantes (refonte de cinq flux rejoués pour un cycle + que le rejeu résout déjà) ; prescrire la seule réouverture (geste réservé à l'administrateur, plus + long, et faux dans l'ordre d'origine) ; découper maintenant en 15-12a/15-12b (cf. point 8). +- **Réversible** : oui (fiche seulement ; code non écrit). Le Project Lead peut imposer la coupe. + +## C101 — 15-1a : la migration du lettrage relève `kesh_version_min_required` à 0.13.0 (révise C90), et ce que cela fait à la release +- **Contexte** : validation P1 de la 15-1a (Opus ×2), findings R-1 = F1 (HIGH). C90 avait conclu « pas de + bump » sur la liste P3 et nommé un risque inexact (une v0.12.1 ne modifie ni ne supprime aucune écriture + manuelle — `PUT`/`DELETE` y rendent `ENTRY_IS_POSTED`, vérifié sur le tag). Le risque réel : une v0.12.1 + relancée sur une base où la 15-1a2 a posé des groupes `document` annule un règlement, un paiement ou un + rapprochement **sans dissoudre le groupe** — facture due, grand livre « soldée », faux rattachement + muet. Précédent `20260814000001` : bump sur un changement de sémantique d'écriture, sans opération P3. +- **Retenu** (décision de l'orchestrateur) : dernière instruction `UPDATE _kesh_version SET + kesh_version_min_required = '0.13.0' WHERE id = 1;` dans la migration de la **15-1a** (c'est elle qui + introduit l'état) ; P2-bis : les dix crates à `0.13.0` dans le même commit ; P7 : `EXEMPT_MIGRATIONS`, + `Durable`, patron `post_restore.rs:512-525` ; gate runtime complet ; `migrations_fresh_install.rs:244` + passe de `0.10.0` à `0.13.0` ; CHANGELOG : avertissement de non-retour (précédent v0.10.0). +- **Conséquence pour la release** (lue dans `scripts/prepare-release.sh`, non exécuté) : le script refuse + quand la version des crates égale la cible (« version cible identique », `exit 1`) **avant** son + pré-vol. `prepare-release.sh 0.13.0` refusera donc, et **ne lancera ni** la datation du CHANGELOG **ni** + le contrôle des exemptions périssables. La v0.13.0 se publiera comme la v0.10.0 (`a80a36c1`) : + CHANGELOG daté à la main **et** contrôle périssable lancé à la main (`cargo run -q -p kesh-db --example + perishable_exemptions`, puis la recherche de tag dans l'intervalle). ⚠️ À trancher hors story (chore) : + apprendre au script le cas « déjà bumpé » (sauter l'étape 1, garder le pré-vol), ou écrire la + procédure manuelle dans la checklist de release. +- **Écartées** : maintenir « pas de bump » (contraire au précédent et à la définition P1) ; bumper dans la + 15-1a2 seulement (la 15-1a aurait livré des colonnes qu'un ancien binaire ignore, et la 15-1a2 écrit déjà + des données, P7 classe A) ; risque accepté sans bump (faux rattachement muet). +- **Réversible** : oui tant que non développé ; une fois publiée, une migration ne se modifie plus (P8). + +## C102 — 15-1a : la garde `ENTRY_LETTERED` hors du drapeau `enforce_ownership`, et son motif d'écran (révise C97) +- **Contexte** : findings R-3 = F2 (la garde posée dans `modification_guard` n'est pas évaluée par + `invoices::unvalidate`, qui appelle `delete_in_tx(…, false)`) et R-2 = F3 (HIGH : `modification_guard` + sert aussi `modification_blocker`, le `GET` de la fiche, hors transaction — le motif sortirait vide à + l'écran et un `FOR UPDATE` y attendrait derrière tout lettrage). +- **Retenu** : variante `ModificationGuard::Lettered { code }` (`code()` = `ENTRY_LETTERED`, `label()` = le + code), rendue par une fonction **distincte** `lettering_guard(conn, company_id, id, Lecture)` ; appel + **inconditionnel** dans `delete_in_tx` (étape 3-ter-bis) et `update_in_tx`, `Lecture::Conseil` (sans + verrou) dans `modification_blocker`. Frontend dès la 15-1a : union `ModificationBlocker`, + `modificationBlockerLabel`, `editRefusalOutcome` → `'stale'`, `ATTENDU` 11 → 12, clé + `journal-entries-modify-blocked-lettered` quatre locales, « onze » → « douze ». Test + `delete_in_tx(…, false)` sur écriture lettrée → refus. +- **Écartées** : le motif dans `modification_guard` (manque la dévalidation) ; une garde dans + `unvalidate` seul (un point de passage de plus à tenir) ; exclure le motif de l'écran (bouton offert, + `PUT` refusé) ; deux fonctions de lecture (elles divergeraient) — un paramètre. +- **Réversible** : oui (fiche seulement). + +## C103 — 15-1a : séquence des verrous du lettrage — premier acte verrouillant, exercices exclusifs dans le sens de la clôture, exercice tenu en mode système (révise R7 de la reprise) +- **Contexte** : findings R-5 = F4 (découvrir les en-têtes par une lecture ordinaire ouvre la vue avant + le verrou ; un nombre de lignes affectées inattendu finissait en `Invariant` → 500), R-6 = F7 (verrou + partagé suivi d'un exclusif : interblocage d'escalade structurel ; `ORDER BY id` contraire à la clôture + de la 15-12, `start_date`), R-4 (R7 et AC5 en désaccord sur le mode système). +- **Retenu** : (1) premier acte = une lecture `FOR UPDATE` **jointe** lignes ⋈ en-têtes, scopée par + société, qui découvre et verrouille d'un coup — aucune lecture ordinaire avant ; plan vérifié par + `EXPLAIN` ; (2) mode `Manual` : exercices des lignes `FOR UPDATE ORDER BY start_date, id` ; (3) mode + `System { held_open_fiscal_year_id }` : **aucun** verrou d'exercice dans la primitive — l'appelant tient + déjà un exercice ouvert couvrant une ligne et le passe ; vérification sans requête, manquement → + `Invariant` (défaut d'appelant) ; (4) compte d'`UPDATE` inattendu → 409 `LETTERING_CONCURRENT_CHANGE`. + ⚠️ **Affinement de la décision 7 de l'orchestrateur** (« exercices `FOR UPDATE ORDER BY start_date` ») : + appliquée telle quelle au mode système, la contre-passation — qui tient l'exercice **du jour** quand elle + lettre — reprendrait ensuite les exercices **antérieurs** de l'origine, à rebours de la clôture de la + 15-12, qui n'est pas rejouée. Interblocage résiduel avec `update_in_tx`/`delete_in_tx` (ligne avant + en-tête dans l'acte 1) : nommé, absorbé par le rejeu (les trois routes sont `Rejouee`). +- **Écartées** : lecture ordinaire de découverte puis comparaison (contraire à la doctrine « le verrou + d'abord ») ; lignes `FOR UPDATE` puis en-têtes en deux actes (même cycle, une requête de plus) ; + verrouiller les exercices en mode système (cycle avec la clôture) ; `Invariant` sur le compte d'`UPDATE`. +- **Réversible** : oui (fiche seulement). + +## C104 — 15-1a : la lettrabilité est exigée à la création d'un groupe, jamais à sa dissolution (révise C96) +- **Contexte** : finding F8 (+ R-21) — un compte lettrable peut cesser de l'être : retypage d'un compte + mouvementé (`confirm_retype`, Story 25-2-a) ou rattachement d'un `bank_accounts`. La fiche ne disait pas + ce que deviennent les groupes existants (mémoire « invariant dans le temps »). +- **Retenu** : les groupes existants restent **intacts** (le solde constaté reste vrai) et + **dissolubles** ; la dissolution ne contrôle pas la lettrabilité ; l'invariant `lettering_invariants` + ne la contrôle pas, par décision ; test : retyper 1100 en `Expense` puis `DELETE` → 204, idem après + rattachement bancaire. Ce que la 15-1b affiche pour un tel compte est sa question. +- **Écartées** : dissoudre d'office au retypage (écriture cachée dans un geste de plan comptable) ; + refuser le retypage d'un compte lettré (bloque une correction légitime) ; groupe indissoluble. +- **Réversible** : oui. + +## C105 — 15-1a : lettrer comme délettrer exige au moins une ligne sur un exercice ouvert ; la 15-12 passe avant (révise C94 et D3) +- **Contexte** : findings F6 (lettrer deux lignes d'exercices clos réécrit les postes ouverts « au » d'une + date close — la vue ne connaît pas la date du lettrage — exactement ce que C94 refuse au délettrage) et + R-7 = F5 (C94 repose sur « les clos forment un préfixe », C89, Story 15-12, non déclarée en dépendance ; + l'état « N ouvert, N+1 clos » est atteignable aujourd'hui). +- **Retenu** : règle **symétrique** en mode `Manual` — un groupe entièrement dans des exercices clos ne se + crée ni ne se défait (409 `LETTERING_FISCAL_YEARS_CLOSED`, une clé) ; à cheval, permis. En mode + système, garanti par l'exercice tenu (C103). **La 15-12 est un prérequis** : ordre **15-12 → 15-1a → + 15-1a2 → 15-1b → 15-1c**, écrit dans la fiche, l'index `15-1-lettrage.md`, `epics.md` et + `sprint-status.yaml`. FR86 (« tant que l'exercice est ouvert ») est **interprété** pour un groupe à + cheval, consigné aux Dev Notes. ⚠️ **D3 (« lettrer toujours permis ») était un arbitrage de Guy + (août)** : cette révision, prise en autonomie, est à lui présenter à la revue finale. Point laissé à la + 15-1a2 : sa migration de rattrapage poserait-elle des groupes historiques entièrement clos ? + (recommandation du socle : non, par symétrie). +- **Écartées** : asymétrie acceptée (lettrer libre) — la réécriture du passé est la même dans les deux + sens ; dater le lettrage (une colonne de plus, et une vue « au » plus lourde) ; durcir AC5 sans la 15-12 + (« refus si une ligne est antérieure à un exercice clos ») — duplique la 15-12 au lieu d'en dépendre. +- **Réversible** : oui (fiche seulement) ; revenir à D3 coûte le retrait d'une branche de refus. + +## C106 — 15-1a : un groupe `reversal` contenant une ligne de pièce n'est pas dissoluble à la main ; la promesse « paiement à lettrer » quitte la documentation (révise C97 et C93) +- **Contexte** : findings F9 (la contre-passation d'une écriture de pièce lettre `{L, L'}` ; une + dissolution manuelle ouvrirait la paire pour toujours, R5 interdisant de la relettrer) et F10 (le + manuel `:1169`, `:1696` et `api-external.md` `:325`, `:386` disent le règlement d'une facture créditée + « paiement **à lettrer** » ; R5 l'interdit — motif `OwnedBySettlement` — et le groupe `document` ne peut + le prendre). +- **Retenu** : dissolution manuelle d'un groupe `reversal` dont une ligne relève d'une pièce (motifs + `OwnedBy*`, rangs 3 à 6) → 409 `LETTERING_LINE_OWNED_BY_DOCUMENT` (clé réutilisée) ; test nommé. Le + manuel et l'API sont réécrits **dans la 15-1a** sans promesse de lettrage manuel ; le **traitement** + du règlement d'une facture créditée est renvoyé à la 15-1a2 (section « Reçu de la 15-1a » de sa fiche : + exception à R5, groupe incluant l'avoir, ou cas ouvert et dit). +- **Écartées** : exempter de R5 la relettre `{L, L'}` (une seconde voie de lettrage manuel de lignes de + pièce) ; laisser la promesse au manuel jusqu'à la 15-1a2 (documentation fausse dès la 15-1a). +- **Réversible** : oui. + +## C107 — 15-12 découpée en 15-12a (l'ordre) et 15-12b (le filet) à la validation P2 (révise C89 point 6 et C100 point 8) +- **Contexte** : validation P2 de la 15-12 (Sonnet ×2 ; rapports `target/gate-logs/15-12-p2-{R,F}.md`) — + F3 (MEDIUM) : la dérogation au découpage ne relève pas de l'exception que le `CLAUDE.md` codifie (cycles + Cargo, merges intermédiaires intestables) ; R3 (LOW) : « la dépendance B → A est à sens unique » est + fausse (l'AC 5 de A rendait le message de l'AC 9, rangé dans B ; doc-comments de A citant le filet). + **Décision de l'orchestrateur**, inscrite ici : découper maintenant, quand la coupe ne coûte que des + fiches (aucun code écrit), plutôt qu'au premier défaut recyclé, où elle coûterait une branche entamée. +- **Retenu** : coupe de repli déjà décrite par la fiche, avec l'AC 9 (message neutre et sa clé) déplacé + dans la **15-12a**, et toute dépendance A → B réglée (A livrable et testable seule) : + - **15-12a** « l'ordre » (`refs #543`) : AC 1-7, 9, 13, 14, 17, 22, et la part A des AC 19, 21, 23 ; + - **15-12b** « le filet » (`closes #543`) : AC 8, 10, 11, 12, 15, 16, 18, 20, et la part B des AC 19, + 21, 23. + Numérotation des AC **conservée** de la 15-12 (références croisées stables : « AC 13 b », « mutation + (ii) ») ; un AC partagé porte la mention « part A » / « part B ». Tâches renumérotées par fiche, chacune + avec son origine (« ex-T4 »). La fiche `15-12-cloture-dans-l-ordre.md` devient un index ; sa version + complète avant découpage est au commit `dae3a618`. +- **Ce que la coupe ne règle pas, et qui est dit** : recomptés au même critère (un module dont seul un doc-comment change compte), A touche 8 + modules de premier niveau et B 9 ; les deux dépassent encore 5. Le remède de la règle (patron puis déploiement mécanique) n'a pas de + prise ; un découpage plus fin séparerait des AC qui se testent ensemble (l'ordre de `close` et sa + concurrence ; le filet et son inventaire). Signal déclaré au Project Lead, dans les deux fiches. +- **Écartées** : garder la story entière (dérogation hors de l'exception codifiée) ; découper en trois + (écran à part) — l'écran des exercices porte à la fois le bouton (A) et le bandeau (B), un tiers écran + dépendrait des deux autres. +- **Réversible** : oui (fiches seulement). + +## C108 — 15-12b : le refus `LATER_FISCAL_YEAR_CLOSED` du lot, trois voies, un seul constructeur, code littéral +- **Contexte** : R1 = F1 (MEDIUM) — `accept_one_rule` (`routes/reconciliation.rs:2528-2555` sur `8f9811d8`) + n'emprunte pas `project_error_to_failed_proposal` : son repli en ligne rend `DATABASE_ERROR`. F8 (LOW) : + le décompte « 26 codes » de `failed-proposal-label.ts` ne compte que les littéraux + `ACCOUNT_NOT_POSTABLE`. +- **Retenu** : un constructeur unique `later_fiscal_year_closed_failed_proposal` (patron de + `period_locked_failed_proposal`, `:186-193` : « un seul constructeur pour les DEUX sites »), qui pose le + code par un **littéral** `"LATER_FISCAL_YEAR_CLOSED"` (visible au `grep` du décompte, qui passe à 27 : + 26 littéraux + `ACCOUNT_NOT_POSTABLE`), appelé par un bras de `project_error_to_failed_proposal` + (facture, ventilé) **et** par la branche en ligne de `accept_one_rule`, à côté de son `PeriodLocked`. + Un test par voie ; mutations (vii-a) et (vii-b). +- **Écartées** : aiguiller l'`Err(e)` de la règle vers `project_error_to_failed_proposal` — son bras + `NotFound → PROJECT_NOT_FOUND` mal-étiquetterait un `NotFound` étranger au projet, ce que le commentaire + de la règle (`:2531-2534`) et le doc-comment du mapper (`:180-181`) écartent expressément ; poser le code par `err.error_code()` (invisible au + `grep` du décompte). +- **Réversible** : oui. + +## C109 — 15-12a : la preuve de l'enveloppe de la clôture est un test HTTP ; celle de la création, un test HTTP si T0 trouve un interblocage forçable, sinon la revue +- **Contexte** : R2 (MEDIUM) — la mutation (ix) « clôture hors de son enveloppe » ne peut être tuée par un + test `kesh-db` : l'enveloppe y serait écrite par le test lui-même. `close_fiscal_year` et + `create_fiscal_year` restent `SansEcritureAuJournal`, que le volet (c) du registre n'examine pas. +- **Retenu** : test `fiscal_year_close_is_replayed_when_it_is_the_deadlock_victim` dans + `crates/kesh-api/tests/rejeu_interblocage_e2e.rs`, patron des tests 2 à 5 (transaction lourde qui tient N, + la route vue en attente à l'étape (c), la transaction demande M que la requête (b) a verrouillé, + témoin `warn!` de `kesh_db::retry` nommant `fiscal_years::close`). Pour la création : même patron si la + mesure de l'AC 13 b montre un interblocage dont la victime se laisse forcer ; sinon la mutation (x) est + écrite « tenue par revue » au Dev Agent Record et au point (vi) du registre, comme `onboarding::finalize`. + Le test 13 d (`kesh-db`) garde son objet — le cycle existe et se résout — sans prétendre tuer (ix). +- **Écartées** : retirer (ix) et tenir les deux enveloppes par la revue (un rouge possible vaut mieux + qu'une relecture) ; classer les deux routes `Rejouee` (elles n'écrivent pas au journal : le registre + mentirait sur sa première colonne). +- **Réversible** : oui. + +## C110 — 15-12a : `OPEN_COVERING_DATE_SQL` reçoit `ORDER BY start_date ASC` +- **Contexte** : F2 (MEDIUM) — l'ordre « ascendant » de `find_open_covering_date` était affirmé (AC 3, 13 d, + 14) sans que la requête le tienne : pas d'`ORDER BY`, trois index utilisables sur `company_id` + (`uq_fiscal_years_company_name`, `uq_fiscal_years_company_start_date`, index de la FK), plan au choix de + l'optimiseur. +- **Retenu** : `… AND status = 'Open' ORDER BY start_date ASC LIMIT 1` (constante partagée avec + `has_open_covering_date`, lecture seule : résultat inchangé, une seule ligne peut correspondre, les + exercices ne se chevauchant pas). **Sans `, id`** : `(company_id, start_date)` est unique + (`uq_fiscal_years_company_start_date`), le départage ne peut pas servir — et les deux autres requêtes du + module (`FIND_LATER_CLOSED_SQL`, la neuve `FIND_EARLIER_OPEN_SQL`) s'écrivent `ORDER BY start_date ASC`. + `EXPLAIN` des trois requêtes mesuré en T0 (index `uq_fiscal_years_company_start_date`, pas de + `filesort`), écrit au Dev Agent Record. +- **Écartées** : écrire que l'ordre dépend du plan (le doc-comment canonique de l'AC 14 et le test 13 d + reposeraient sur un fait non tenu) ; `FORCE INDEX` (fige un nom d'index dans une requête). +- **Réversible** : oui. + +## C111 — 15-12a : le message neutre garde le conseil de la contre-passation et prescrit l'ordre LIFO ; l'ancienne clé est alignée +- **Contexte** : F5 (LOW) — le message neutre de l'AC 9 faisait perdre au `PUT`/`DELETE` le conseil de la + contre-passation, et la fiche renvoyait à un bandeau qui ne s'affiche pas à la création d'un exercice + (état sain). R4 (LOW) — l'ancienne clé `journal-entries-modify-blocked-later-fiscal-year-closed` + prescrit encore « Un administrateur peut rouvrir cet exercice », le geste que C100 a réfuté. +- **Retenu** : message de la clé neuve terminé par la marche à suivre valable dans tous les cas (corriger + une écriture par contre-passation ; sinon, un administrateur rouvre les exercices clôturés en commençant + par le plus récent) ; l'ancienne clé, toujours lue par la fiche d'écriture, reçoit la même prescription, + aux quatre catalogues et dans le repli de `blocker-messages.ts`. +- **Écartées** : garder l'ancienne clé telle quelle et l'écrire comme limite (texte faux dans l'état même + où il s'affiche) ; renvoyer au bandeau (absent à la création). +- **Réversible** : oui. + +## C112 — Ordre 15-12a → 15-12b → 15-1a ; le prérequis réel de la 15-1a est la 15-12a +- **Contexte** : C105 faisait de la 15-12 un prérequis de la 15-1a (« les clos forment un préfixe »). R6 et + F11 (LOW) : la 15-1a dit la clôture « non rejouée » (vrai sur `main`, faux dès la 15-12a), et l'invariant I + n'est garanti que pour les états sains. +- **Retenu** : **la 15-12a est le prérequis réel** (elle tient l'invariant et rend la clôture rejouée). La + 15-12b n'est pas requise par la 15-1a — son filet couvre la création et la suppression d'écritures, pas + le lettrage — mais **passe avant de préférence** : les deux touchent `journal_entries::delete_in_tx` + (la 15-12b lève la condition `enforce_ownership` sur la lecture des postérieurs clos, la 15-1a y pose + `ENTRY_LETTERED` hors de ce drapeau) ; la seconde mergée écrit la précédence des deux refus. Ordre écrit : + **15-12a → 15-12b → 15-1a → 15-1a2 → 15-1b → 15-1c**. Ce que la 15-1a doit porter (fiche en validation, + non modifiée ici) est rendu à l'orchestrateur : corriger « la clôture n'est pas rejouée » et dire ce que + devient sa règle des exercices dans l'état hérité (tolérer, garder comme 15-8a, ou écrire la limite). +- **Écartées** : faire de la 15-12b un prérequis dur (rien dans la 15-1a ne lit le filet). +- **Réversible** : oui. + +## C113 — 15-1a : une ligne est « en période ouverte » si son exercice est ouvert, qu'aucun exercice postérieur n'est clos et que sa date dépasse le verrou de période ; lettrer comme délettrer en exigent une (révise C105) +- **Contexte** : validation P2 de la 15-1a (Sonnet ×2 ; `target/gate-logs/15-1a-p2-{R,F}.md`). F-3 (MEDIUM) : + R7 permettait de lettrer et délettrer dans une période verrouillée (« la marque n'est pas une écriture »), + au rebours de l'argument F6 qui fonde le refus pour les exercices clos — la vue « au » d'une date (15-1b) + compte soldé à X tout groupe entièrement daté ≤ X, quelle que soit la date de pose ; lettrer deux lignes + du 15 mars sous un verrou au 31 mars réécrit les « postes ouverts au 31.03 » remis au fiduciaire. Le + manuel (`user-manual.tex:578-583`) dit que verrouiller « fige » la période. S'y ajoute le point reçu de + la 15-12 (C112) : dans l'**état hérité** (sauvegarde v0.12.x, SQL direct), un groupe tout entier dans + N ouvert sous N+1 clos passait la règle « au moins une ligne sur un exercice ouvert », et ni la 15-12a + ni la 15-12b ne le gardent. +- **Retenu** (décision de l'orchestrateur pour le verrou de période ; choix de l'agent pour l'état + hérité) : une ligne est **« en période ouverte »** ssi (i) son exercice est `Open`, (ii) aucun exercice + postérieur n'est `Closed` (patron de la garde 15-8a — on **garde** l'état hérité plutôt que de le + tolérer ou d'écrire la limite), (iii) sa date est strictement postérieure à `books_locked_through` + (seuil inclusif, comme partout). En mode `Manual`, lettrer comme délettrer exigent au moins une telle + ligne ; sinon 409 **`LETTERING_ALL_LINES_IN_CLOSED_PERIODS`** (une clé, qui **remplace** + `LETTERING_FISCAL_YEARS_CLOSED` — elle ne disait pas le verrou). La borne se lit ordinairement, après + les verrous d'exercices (même tolérance qu'à la création et au `PUT`). Les informations (ii) viennent + de la même lecture verrouillante que les exercices (C114). En mode `System`, non évaluée par la + primitive : la ligne de l'exercice tenu vient d'être écrite et a passé les gardes de la création ; le + (ii) n'y est garanti qu'avec la 15-12b — limite écrite. Manuel : phrase symétrique au § du verrou de + période ; tests nommés (borne exacte, état hérité posé par SQL). Nom « en période ouverte » choisi pour + ne pas se confondre avec « ligne ouverte » (non lettrée). +- **Écartées** : garder « permis » sous le verrou et écrire que la vue « au D » est réécrite après coup + (contredit le manuel et l'argument F6) ; deux codes distincts (exercice / verrou) — une ligne du groupe + peut être close pour une raison, une autre pour l'autre, le refus porte sur le groupe ; tolérer l'état + hérité (la vue « au » de N+1 serait réécrite, précisément ce que la règle protège) ; écrire la limite + sans garde (une requête suffit à la fermer). +- **Réversible** : oui (fiche seulement) ; revenir à « permis » sous le verrou retire la condition (iii). + +## C114 — 15-1a : les exercices se verrouillent par un parcours ascendant de `(company_id, start_date)`, pas par un `ORDER BY` sur une liste d'`id` (révise C103 point 2) +- **Contexte** : R2-2 = F-5 (MEDIUM / LOW) — `SELECT … WHERE id IN (…) … ORDER BY start_date, id FOR + UPDATE` ne fixe pas l'ordre d'acquisition : InnoDB verrouille au fil du parcours (ici la clé + primaire) et trie ensuite, soit l'ordre des `id`, celui que C103 voulait écarter. Décision de + l'orchestrateur : s'aligner sur la 15-12a (C110) ou verrouiller un par un après tri en Rust ; choisir la + forme qui garantit l'ordre et écrire la mesure T0. +- **Retenu** : (a) `MIN(start_date)` des exercices des lignes, lu **sans verrou** (`start_date` immuable, + 15-12a AC 3 (a)), après l'acte 1 ; (b) `SELECT id, start_date, status FROM fiscal_years WHERE company_id + = ? AND start_date >= ? ORDER BY start_date ASC FOR UPDATE` — parcours d'intervalle de + `uq_fiscal_years_company_start_date`, qui verrouille l'exercice le plus ancien du groupe **et tous les + postérieurs** dans l'ordre chronologique, et rend d'un coup l'information « exercice postérieur clos » + de C113. `EXPLAIN` mesuré en T0 (attendu : `range` sur cet index, sans `filesort`), écrit au Dev Agent + Record ; **repli écrit d'avance** si le plan diffère : `(id, start_date)` lus sans verrou, tri en Rust, + verrous un par un. Test avec deux exercices aux `id` inversés par rapport aux dates, face à `close`. + Pas de `, id` (`(company_id, start_date)` est unique, C110). +- **Écartées** : la forme « un par un » d'emblée (plus de requêtes, et elle ne rend pas l'information + « postérieur clos » sans une requête de plus) ; `FORCE INDEX` (fige un nom d'index — écarté aussi par + C110) ; garder `ORDER BY` sur la liste d'`id` en se fiant au plan. +- **Réversible** : oui. + +## C115 — 15-1a : le motif du bump `min_required` réécrit au plus juste, et ce que `sqlx` fait déjà (révise le motif de C101, pas sa décision) +- **Contexte** : F-1 (MEDIUM) — C101 et la fiche disaient la v0.12.1 « seul binaire antérieur publié » + ne modifiant aucune écriture manuelle. Faux : v0.10.0, v0.11.0, v0.11.1 sont publiés, routent `PUT` et + `DELETE /journal-entries/{id}` (`v0.11.1:crates/kesh-api/src/lib.rs:337-338`, sans `ENTRY_IS_POSTED`) + et démarrent contre `min_required = '0.10.0'`. Le motif ira dans l'en-tête de la migration, que P8 fige. + En vérifiant, l'agent a relevé un fait que ni C101 ni la passe n'avaient écrit : `kesh_db::MIGRATOR` + garde `ignore_missing = false` (défaut de `sqlx` 0.8.6, `Migrator::run` → + `validate_applied_migrations` → `MigrateError::VersionMissing`), si bien qu'un binaire antérieur + refuse **déjà** de démarrer contre une base portant une migration qu'il ne connaît pas + (`kesh-api/src/main.rs:138`) ; et l'import de la v0.12.1 refuse une colonne inconnue + (`check_schema_compat` (c1), `unknownColumns`). +- **Retenu** : la décision de bumper tient (P1/P2 ne présument pas de `sqlx`, et le bump rend le refus + explicite, précoce — avant `sqlx`, en nommant les versions — et porté au manifeste des sauvegardes). + Le motif écrit : tout binaire publié antérieur (v0.10.0 à v0.12.1) ignore le lettrage ; les v0.10.0 à + v0.11.1 modifient et suppriment des écritures manuelles, les v0.12.x annulent règlements, paiements et + rapprochements, sans dissoudre les groupes. **En-tête de migration arrêté dans la fiche** (cinq lignes) + pour être recopié tel quel. ⚠️ **Signalé à l'orchestrateur** : la prémisse de la § « Migration breaking + policy » du `CLAUDE.md` (« un binaire antérieur pourrait démarrer et corrompre ») n'est pas vraie sous + le réglage actuel de `sqlx` — le bump n'est pas la seule barrière, il en est la forme lisible. Ce n'est + pas à cette story de réécrire la politique. +- **Écartées** : garder le motif « v0.12.1 seule » (faux, et figé par P8) ; renoncer au bump au motif + que `sqlx` refuse déjà (la politique l'exige, et un futur `set_ignore_missing(true)` rouvrirait le + risque en silence). +- **Réversible** : oui tant que la migration n'est pas écrite ; ensuite, non (P8). + +## C116 — 15-1a : la contre-passation relit les lignes de l'écriture inverse après les avoir lettrées +- **Contexte** : F-4 (MEDIUM) — `reverse_in_tx_inner` rend `created`, lu par `create_in_tx_inner` avant + tout lettrage ; la route en fait son `201`. L'API, ouverte aux clés, dirait `letteringCode: null` sur une + ligne lettrée en base ; aucun test d'AC9 ne lisait le corps. +- **Retenu** : si au moins un groupe a été posé, `created.lines` est remplacé par une relecture `SELECT + {LINE_COLUMNS} … WHERE entry_id = ? ORDER BY line_order` dans la transaction, **dans + `reverse_in_tx_inner`** (tous les appelants de `reverse_in_tx` en profitent) ; AC9 (a) asserte le corps + du `201`. +- **Écartées** : relire dans la route seule (les autres appelants garderaient une valeur fausse) ; + recalculer les champs en mémoire depuis le retour de `create_group_in_tx` (deux sources de la même + vérité — la base et un calcul). +- **Réversible** : oui. + +## C117 — 15-1a / 15-12b : précédence des refus dans `delete_in_tx`, écrite et testée par la seconde des deux à merger +- **Contexte** : reçu de la remédiation de la 15-12 (`9b403aaf`) — la 15-12b rend l'étape 2-bis + (« exercice postérieur clos ») inconditionnelle ; la 15-1a pose 3-ter-bis (`ENTRY_LETTERED`), + inconditionnelle aussi. Les deux se recouvrent sur la dévalidation et sur la route. +- **Retenu** : ordre **2-bis avant 3-ter-bis** (l'état des exercices parle avant la marque, comme + `FISCAL_YEAR_CLOSED` avant `LATER_FISCAL_YEAR_CLOSED`). La seconde story mergée l'écrit au doc-comment + « Ordre des refus » de `delete_in_tx` et le teste par une paire (écriture lettrée dans N, N+1 clos, + `enforce_ownership` à `false` et à `true` → `LaterFiscalYearClosed`), mutation « permuter les étapes ». + Dans l'ordre préféré (C112), c'est la 15-1a (T0 relève l'état, T4 l'applique). ⚠️ Si la 15-1a est + mergée la première, la paire revient à la 15-12b : **à reporter dans la fiche 15-12b par + l'orchestrateur** (fiche en validation P3, non modifiée ici). +- **Écartées** : 3-ter-bis avant 2-bis (dirait « délettrez d'abord » à qui ne pourrait de toute façon + rien supprimer — et le délettrage serait lui-même refusé, C113 (ii)). +- **Réversible** : oui. + +## C118 — 15-1a : pas de découpage malgré le premier critère franchi au grain des modules métier ; couture écrite +- **Contexte** : F-13 (LOW) — l'argument de P1 (« amendement D5 : aucun recyclage ») ne qualifiait que le + second critère ; au grain des modules métier, le premier est franchi (une dizaine) ; F-1 et F-2 sont + des faits écrits par la remédiation de P1. +- **Retenu** : pas de découpage. La couture naturelle — (i) schéma, primitive, routes, audit, exports ; + (ii) gardes d'AC8, R6, frontend, manuel — produit deux stories qui touchent toutes deux + `repositories/journal_entries` et `kesh-api`, la seconde intestable sans la première ; les défauts nés + de P1 sont des faits recopiés (un motif, une liste de tests), non une règle métier qui ne converge + pas. **Déclencheur écrit** : si la P3 trouve un défaut né d'un correctif de P2 sur une règle métier + (R7, AC4, AC5, AC8), découper selon cette couture avant toute P4. Signal déclaré au Project Lead. +- **Écartées** : découper maintenant sur le modèle de C107 (la 15-12 avait deux moitiés indépendantes ; + ici la seconde dépend entièrement de la première). +- **Réversible** : oui (fiches seulement). + +## C119 — 15-12a : les exercices antérieurs se verrouillent un par un par clé primaire, dans l'ordre lu sans verrou, puis relecture verrouillante après l'exercice clôturé (révise C110 ; à reporter sur C114) +- **Contexte** : validation P3 de la 15-12 (Opus ×2 ; `target/gate-logs/15-12-p3-{R,F}.md`). F2 (MEDIUM) — + « ascendant **par construction** » était faux : un `ORDER BY` fixe l'ordre du résultat, InnoDB verrouille + dans l'ordre du **parcours** choisi par l'optimiseur, et le dépôt a déjà mesuré un `filesort` qui + verrouille tous les exercices d'une société (`opening_complement.rs:278-281`). L'ordre des `id` diverge + de celui des `start_date` dès qu'un exercice antérieur est créé après un postérieur. **Décision de + l'orchestrateur** : garantir l'ordre indépendamment du plan ; choisir entre `FORCE INDEX` + `EXPLAIN` + mesuré et testé, et une lecture des `id` triés sans verrou suivie de verrous un par un — de préférence la + forme dont la garantie ne dépend d'aucune mesure. +- **Retenu** : `close` en six temps — (a) `start_date` de Y sans verrou ; (b) `LIST_EARLIER_SQL` : `SELECT + id … WHERE company_id = ? AND start_date < ? ORDER BY start_date ASC`, sans verrou, tous statuts ; (b') + `LOCK_EARLIER_BY_ID_SQL` : `SELECT id … WHERE id = ? AND company_id = ? FOR UPDATE`, un par un dans cet + ordre, par une boucle Rust (un exercice disparu est ignoré) ; (c) Y par clé primaire `FOR UPDATE` + (`fetch_optional` → `NotFound`), verdict « déjà clos » ; (d) `FIND_EARLIER_OPEN_SQL … FOR UPDATE`, + **relecture verrouillante** qui rend le verdict `EarlierFiscalYearOpen` sur l'état validé ; (e) `UPDATE`, + audit. L'ordre des antérieurs **connus** est celui du code. `OPEN_COVERING_DATE_SQL` reste **sans** + `ORDER BY` (C110 révisé : un `ORDER BY` n'aurait fixé que le résultat, déjà unique, et rien ne dépend de + l'ordre de ses verrous). `EXPLAIN` de (d) et de `FIND_LATER_CLOSED_SQL` mesuré en T0 à titre + **descriptif**, dans les deux régimes (une / plusieurs sociétés). +- **Ce qui est perdu, écrit dans la fiche** : (1) les verrous de clé suivante et d'intervalle d'un + parcours — un exercice antérieur **créé** après la vue de (a) (fantôme) n'est ni listé ni verrouillé ; + (d) le lit (lecture verrouillante : état validé), et une création postérieure à la prise de Y l'attend + (sa garde `find_later_closed_in_tx` examine Y), sauf si un exercice clos s'interpose, auquel cas elle + est refusée ; (2) une requête par antérieur ; (3) (d) reste un parcours dont les acquisitions **nouvelles** + dépendent du plan — fantômes, ou autres lignes de la société sous un mauvais plan —, mais elle vient + quand la clôture tient Y et les antérieurs connus : un cycle qui y naît est absorbé par le rejeu de la + clôture ; l'invariant I n'en dépend pas. Effets de bord favorables : le point d'arrêt des tests 13 d et + de l'AC 6 devient (c), déterministe ; l'hypothèse « le parcours garde le verrou d'une ligne qu'il ne + retient pas » n'a plus d'objet (M est verrouillé explicitement). +- **Pour la 15-1a (C114, à réviser par l'orchestrateur)** : même forme — lire sans verrou les `id` (et + `start_date`) des exercices concernés, `ORDER BY start_date ASC` ; les verrouiller **un par un** par + `SELECT … WHERE id = ? AND company_id = ? FOR UPDATE` dans cet ordre, en lisant `status` sous le verrou ; + puis une relecture verrouillante **seulement** si un fantôme peut changer le verdict. Pour la règle + « en période ouverte » de C113, l'information « postérieur clos » se lit sur les lignes verrouillées ; un + exercice postérieur **créé** pendant ce temps naît `Open` et ne peut être clôturé tant qu'un antérieur + ouvert tenu par le lettrage l'est (la clôture, C119, le verrouille avant) — à vérifier par la 15-1a sur + sa propre liste, non supposé ici. Le « repli écrit d'avance » de C114 devient la forme principale ; le + parcours `start_date >= ? ORDER BY start_date ASC FOR UPDATE` et son `EXPLAIN` attendu sont abandonnés. +- **Écartées** : `FORCE INDEX (uq_fiscal_years_company_start_date)` avec test sur l'`EXPLAIN` — la garantie + y reste une mesure, valable pour la version de MariaDB testée, et fige un nom d'index (le dépôt emploie + `FORCE INDEX (PRIMARY)` en 15-5d pour **borner** des verrous partagés, pas pour ordonner des verrous + exclusifs) ; garder le parcours et écrire que l'ordre dépend du plan (l'argument contre le cycle avec + `reopen`, non rejouée, n'aurait plus de fondement) ; passer la transaction de clôture en `READ + COMMITTED` pour relire sans verrou l'état validé (nouveau patron d'isolation dans un dépôt qui raisonne + partout en `REPEATABLE READ`). +- **Réversible** : oui (fiches seulement). + +## C120 — 15-12b : les prédicteurs d'annulation ne connaissent pas le filet — angle mort assumé, non un rang neuf +- **Contexte** : F1 (MEDIUM) — `settlement_cancellation::settlement_entry_cancel_blocker` (queue commune + de `cancelBlockedBy`, `settlementCancelBlockedBy`) contrôle au rang 5 qu'un exercice ouvert couvre le + jour, pas qu'aucun postérieur à celui-ci n'est clos ; dans cet état hérité, quatre écrans annoncent + l'annulation possible et le clic rend `400 LATER_FISCAL_YEAR_CLOSED`. Option préférée de + l'orchestrateur : un rang après `NoOpenFiscalYearToday`, avec test ; sinon l'angle mort écrit. +- **Retenu** : **angle mort assumé, écrit** — au doc-comment de `settlement_entry_cancel_blocker`, à côté + de la « Limite assumée » existante du verrou de période du jour (même nature : refus du socle au clic, + non reproduit par la lecture) ; dans `docs/api-external.md` (`:319`, `:353`, `:361`) ; un test qui fixe le + comportement (prédicteur `None`, annulation `LaterFiscalYearClosed`), patron C-15-8-29, pour qu'il + rougisse le jour où le rang est ajouté. **Issue à ouvrir** (P3) pour le rang. +- **Pourquoi pas l'option préférée** : le rang ajoute une variante à `SettlementCancelBlocker`, trois bras + aux `match` exhaustifs de `kesh-api/src/errors.rs`, trois `switch` exhaustifs (`never`) côté écran + (`settlement-cancel-blocked.ts`, `reconciliation-cancel.ts`, `invoice-cancel.ts`) et leurs types, des + clés ×4 et la documentation des champs : cinq modules de premier niveau de plus, sur une fiche que la + dérogation (C121) vient d'accepter à dix — pour un état hérité étroit (un exercice **futur** clôturé + d'avance) dont le refus au clic est exact et porte sa marche à suivre (AC 9 de la 15-12a). Le module a + déjà un précédent d'angle mort de même nature. +- **Écartées** : le rang (ci-dessus) ; ne rien écrire (site non résolu et non écrit — la règle du + `CLAUDE.md` l'interdit). +- **Réversible** : oui — l'issue porte le rang ; le test qui fixe l'angle mort rougira alors. + +## C121 — 15-12a et 15-12b : dérogation à la règle de découpage, écrite dans la forme codifiée +- **Contexte** : F9 (LOW) — les deux fiches filles franchissent encore le premier critère (8 et 9 + modules ; 10 pour la 15-12b après C120) avec un « signal déclaré » qui n'avait pas la forme que le + `CLAUDE.md` exige (section « Dérogation règle de splitting », risque accepté). **Décision de + l'orchestrateur** : dérogation **acceptée** — la story a déjà été découpée une fois selon la seule couture + naturelle (C107) ; un second découpage ferait des stories non testables isolément (exception écrite du + `CLAUDE.md`). +- **Retenu** : section « Dérogation règle de splitting » dans chaque fiche (modules recomptés, motif, + risque accepté — une revue moins fine par module ; mitigation : passe ciblée sur chaque remédiation, + inventaires refaits à chaque passe), signal déclaré à Guy. +- **Écartées** : redécouper (sous-stories intestables seules) ; garder le seul « signal déclaré » (forme + non codifiée). +- **Réversible** : oui (fiches seulement). + +## C122 — 15-12a : les autres textes qui prescrivent une réouverture sans ordre restent hors périmètre, avec une issue +- **Contexte** : F8 (LOW) — le symptôme que C111 corrige pour l'ancienne clé (« rouvrez l'exercice », + geste refusé par LIFO dès qu'un exercice plus récent est clos) vit aussi dans les messages d'annulation + refusée pour exercice clos (règlement, rapprochement, facture fournisseur), dans + `error-fiscal-year-reopen-blocked`, `error-opening-balances-first-year-closed`, leurs replis Rust et + frontend, et quatre phrases du manuel utilisateur (relevé par la valeur sur `5e4bec50`, liste dans la + fiche 15-12a, § « Hors périmètre »). +- **Retenu** : hors périmètre, préexistant, **écrit** dans la 15-12a avec la liste des sites ; **issue à + ouvrir** (P3) pour les aligner sur la prescription de C111. +- **Écartées** : les aligner ici (cinq clés ×4, trois replis, trois modules frontend et le manuel de plus, + hors du défaut #543) ; ne rien écrire. +- **Réversible** : oui. +- **Suivi** *(ajouté en revue P1 de la 15-12a, A3)* : l'issue est + [#569](https://github.com/guycorbaz/kesh/issues/569) ; la fiche 15-12a et les trois replis Rust + d'`errors.rs` la citent. + +## C123 — 15-12b / 15-12a : deux tranchages de forme (projectId de la voie « règle », rubriques du CHANGELOG) +- **Contexte** : R4 (LOW) — la fiche promettait `projectId` « s'il y a un projet » alors que la branche en + ligne d'`accept_one_rule` passe `None` à son `PeriodLocked` (la règle a un `default_project_id`). F10 + (LOW) — la 15-12a rangeait sous `### Corrigé` un changement de contrat d'API (refus neufs, clôture ouverte + aux clés). +- **Retenu** : (1) voie « règle » : `projectId` **omis** (`None`), comme son `PeriodLocked` — les deux refus + de la même branche publient les mêmes `details` ; le test l'asserte ; (2) CHANGELOG : le défaut sous + `### Corrigé`, le changement de contrat sous `### Modifié`, patron des entrées #532 de `[0.13.0]`. +- **Écartées** : (1) passer `default_project_id` au seul refus neuf (deux refus d'une même branche aux + `details` divergents) ; (2) tout sous `Corrigé` (une intégration par clé chercherait le changement de + comportement sous `Modifié`). +- **Réversible** : oui. + +## C-15-12a-1 — 15-12a (T0) : un message propre au refus de création d'un exercice +- **Contexte** : F3 de la validation P4 (LOW) — le texte de l'AC 9 (`error-later-fiscal-year-closed`), + qui sert au `PUT` et au `DELETE` d'une écriture, servait aussi à `POST /fiscal-years` (AC 5) ; sa phrase + « une écriture se corrige alors par une contre-passation » ne répond à rien à l'écran de création. + L'AC 9 exige de garder ce conseil pour les écritures (F5 de P2) ; le message ne pouvait donc pas devenir + commun en le perdant. L'orchestrateur demandait un « message adapté à la création ». +- **Retenu** : une clé dédiée, `error-fiscal-year-create-later-closed` (×4 locales, repli Rust), rendue + par une variante d'`AppError` (`FiscalYearBeforeClosedYear { fiscal_year_id, fiscal_year_name }`) que + `map_create_error` produit à partir de `DbError::LaterFiscalYearClosed`. **Code (`LATER_FISCAL_YEAR_CLOSED`), + statut (400) et `details` inchangés** — une intégration ne voit que le texte changer. La 15-12b ne lit + que la clé de l'AC 9, qui ne bouge pas. +- **Écartées** : un seul message sans conseil de contre-passation (contredit l'AC 9) ; un message unique + allongé des deux cas (plus long, et à moitié hors sujet à chaque fois) ; un champ de plus dans + `DbError::LaterFiscalYearClosed` pour choisir le texte (fait porter à la couche base une décision + d'affichage). +- **Réversible** : oui (une variante et une clé à retirer). + +## C-15-12a-2 — 15-12a (revue P1) : la preuve du verrou de la garde de création isole la garde +- **Contexte** : A1 (MEDIUM) = B-2 = E1 — dans les 13 b1 / b2, la création bute dès `find_overlapping` + (verrou de borne de son parcours) et ne lit Y clos dans sa garde qu'après ; la mutation « garde non + verrouillante » y reste verte. Le verrou de la garde n'était prouvé par aucun test. +- **Retenu** : un test 13 b3 où W rejoue la garde puis l'`INSERT` de `create` **sans** le pré-contrôle + `find_overlapping`, dont le verrou masquerait celui de la garde ; la clôture lancée entre les deux doit + attendre en (c) et nommer X. Mutation (xi) (`FOR UPDATE` retiré de `find_later_closed_in_tx`) jouée : + seul le 13 b3 rougit. Le cas « fantôme validé entre (a) et (c) » reste sans test, écrit comme tel. +- **Écartées** : rejouer tous les gestes de `create` (le pré-contrôle tiendrait Y et le test ne + prouverait rien de la garde) ; seulement écrire le `FOR UPDATE` de la garde « défensif, non éprouvé » + (E1) — le test est bon marché et déterministe. +- **Réversible** : oui (un test). + +## C-15-12a-3 — 15-12a (revue P1) : le message neutre ne promet que ce que le code garde +- **Contexte** : B-1 (MEDIUM) = E4 — `error-later-fiscal-year-closed` disait « rien ne peut être + enregistré, modifié ou supprimé avant sa date de début », alors que seuls `PUT` et `DELETE` le gardent + (la saisie le sera par la 15-12b), contrairement au manuel (`user-manual.tex:712`) et à + `api-external.md`. L'AC 9 avait écrit le texte large en comptant sur la 15-12b avant le tag. +- **Retenu** : « aucune écriture datée avant sa date de début ne peut être modifiée ni supprimée tant + qu'il l'est. Une telle écriture se corrige par une contre-passation ; … », aux quatre locales et au + repli Rust ; assertion ajoutée au test HTTP de contre-passation (`journal_entry_reversal_e2e.rs`) qui + refuse « enregistr ». **La 15-12b, en posant sa garde de saisie, élargira ce texte** (à reporter dans + sa fiche par l'orchestrateur : la fiche 15-12b dit le message « inchangé ici »). +- **Deux clés voisines, gardées** (B-5) : `journal-entries-modify-blocked-later-fiscal-year-closed` est + l'explication d'écran d'une écriture affichée (« cette écriture ») ; `error-later-fiscal-year-closed` + la réponse du serveur, qui ne présuppose pas l'objet. Le pourquoi est écrit au mapping + (`kesh-api/src/errors.rs`). **Registre italien** : les clés neuves de l'écran des exercices + (`Chiudi prima…`) restent au tutoiement, registre que le glossaire mesure comme celui de l'italien + (`docs/i18n-glossaire.md` § Registre) et qu'emploient leurs voisines (`Riapri prima…`) ; la famille + `journal-entries-modify-blocked-*` (« Correggetela ») est au registre de courtoisie depuis avant la + story. Chaque message tient un seul registre ; l'alignement du fichier relève des rollouts, comme le dit + le glossaire. +- **Écartées** : garder le texte large jusqu'à la 15-12b (faux entre deux merges, et contraire au + manuel) ; unifier les deux clés (l'une présuppose l'écriture, l'autre non) ; passer les clés neuves au + vouvoiement (elles détonneraient sur leur propre écran, et contre le glossaire). +- **Réversible** : oui (textes). + +## C-15-12a-4 — 15-12a (revue P1) : l'infobulle d'un bouton désactivé portée par une enveloppe +- **Contexte** : B-3 (LOW) — `button.svelte` pose `disabled:pointer-events-none` : le `title` d'un bouton + désactivé ne s'affiche pas au survol, alors que le manuel promet l'infobulle de « Clôturer ». Même + défaut, préexistant, sur « Réouvrir ». +- **Retenu** : chaque bouton enveloppé d'un `` qui reçoit le survol ; le + bouton garde son `title` (tests existants). Appliqué aux **deux** boutons (propagation du symptôme). + Vitest et E2E assertent l'attribut de l'enveloppe ; un `title` natif ne se voit pas dans Playwright. +- **Écartées** : retirer la promesse du manuel (le défaut resterait) ; retirer `pointer-events-none` du + composant partagé (effet sur tout le produit) ; un composant d'infobulle (hors proportion). +- **Réversible** : oui. diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index 7b07563f5..dd2b2b7cd 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -1,3 +1,7 @@ +# last_updated: 2026-10-09 (33) (15-12a intégrée sur 39b52628 (15-6a) — conflit de code rejeu_interblocage_e2e.rs : deux ajouts gardés ; gates sur l'état rebasé : backend 2951/2951, Vitest 1095/1095, E2E 245 / 7 KF-029 + 2 KF-045) +# last_updated: 2026-10-09 (32) (15-12a done — revue close P1 Sonnet ×3 2 MEDIUM → P2 ciblée Haiku 0 ; rebasée sur de285ea8 ; gates 2934/2934, Vitest 1095/1095, E2E 244 / 9 attendus + 1 de montage rejoué vert) +# last_updated: 2026-10-09 (31) (15-12a développée, en review — gates backend 2897/2897, Vitest 1094/1094, E2E 245 / 9 attendus ; choix C-15-12a-1) +# last_updated: 2026-10-09 (30) (15-12a en développement — entrées 15-12 / 15-12a / 15-12b reportées sur la branche de la story ; T0 : LOW de P4 appliqués, mesures au Dev Agent Record ; choix C-15-12a-1) # last_updated: 2026-10-09 (29) (renuméroté au rebase sur de285ea8) (15-6a done — revue de code P1 close, rebasée sur ec745d0c ; gates 2906/2906, Vitest 1091/1091, E2E 245 / 7 KF-029 + 2 invoices.spec.ts liés à l'heure UTC) # last_updated: 2026-10-09 (28) (renuméroté au rebase sur de285ea8) (15-6a développée, en review — l'avoir crédite la créance et l'arrondi de la vente, comptes verrouillés en partage avant l'exercice ; gates au commit de code : backend 2895/2895, Vitest 1091/1091, E2E 247 / 7 KF-029 ; choix C-15-6a-2, C-15-6a-3) # last_updated: 2026-10-08 (27) (numéroté (19) sur sa branche, renuméroté (24) au rebase sur 5e4bec50, puis (27) au rebase sur de285ea8) (15-6a : T0 fait sur 9cb5083b — route de l'avoir déjà rejouée (15-5e2), helper de verrou de la 15-5d en partagé seul et non mergé → story en attente de la 15-5d ; C-15-6a-1) @@ -362,6 +366,9 @@ development_status: 15-11-configuration-transmise: split # 2026-10-08 DÉCOUPÉE après la validation P3 (Sonnet ×2 : R 2 MEDIUM / 7 LOW, F 4 MEDIUM / 6 LOW ; signal D5 levé deux fois, par recyclage — P1→P2 4/8 et P2→P3 3/4 MEDIUM distincts nés de la remédiation précédente, concentrés dans la machinerie du test qui lit le code) en 15-11a + 15-11b, choix C77. Fiche index : Change Logs P1–P3 conservés (trend : P1 8 MEDIUM distincts → P2 7 + 1 → P3 4 + F-4 processus) ; version complète au commit d69fdcca. Historique : C71–C73 (création), C75 (P1), C76 (P2). #550. 15-11a-compose-transmet-la-configuration: done # 2026-10-08 — INTÉGRÉE sur origin/main 9cb5083b (15-5e2) : gates backend 2857/2857 (4 ignorés), frontend 1086, E2E 241 / 13 (7 KF-029 + 6 hors liste verts rejoués seuls, KF-053) ; PDF admin régénéré ; C-15-11a-7. Avant : REVUE DE CODE P1 (Sonnet ×3 : 1 MEDIUM / 5 LOW distincts) REMÉDIÉE au commit 47a1a656 (vide = défaut pour KESH_ADMIN_BACKUP_DIR, KESH_LANG et cinq numériques ; doc-comments rattachés ; angles morts écrits ; C-15-11a-6), rebasée sur ec675288 (15-5e1 + 15-8b) ; gates : backend 2854/2854, frontend 1086, E2E 247 / 7 KF-029. Passe ciblée sur 47a1a656 à lancer. Avant : DÉVELOPPÉE (worktree kesh-15-11a) : gates backend 2827/2827, frontend vert, E2E 244 / 7 KF-029 ; 28 mutations rouges ; C-15-11a-1 à 5. 2026-10-08 — VALIDATION CLOSE (P4, Sonnet ×2 : 0 au-dessus de LOW ; R 14 LOW, F 9 LOW, 20 distincts, tous appliqués — $ entre apostrophes simples (dotenvy lit pa$$word comme pa, mesuré), motif de vérification des placeholders élargi aux espaces de tête (.? *), coordination avec la 15-7b3 sur admin-manual.tex:1704-1717, listes HOTE/MARIADB nommées, extraction de la source d'un montage spécifiée, M23 réécrite, ligatures normalisées ; trend P1 1 HIGH/2 MEDIUM → P2 5 MEDIUM → P3 6 MEDIUM → P4 0 ; 16 AC, 10 tâches, 28 mutations, 6 tests unitaires AC16, 3 modules ; C84). closes #550 #557, refs #534 #551 #552 #558. VALIDATION P3 (Opus ×2) : R 3 MEDIUM / 10 LOW, F 4 MEDIUM / 8 LOW ; 20 distincts (6 MEDIUM, 14 LOW), tous traités ; signal D5 par recyclage (recette de déplacement en défaut trois passes de suite : P1 1 HIGH → P2 5 MEDIUM → P3 7 MEDIUM bruts dont 2 recyclés) → AC4 ABANDONNÉE (décision de l'orchestrateur) : docker-compose.prod.yml garde ses montages fixes ./documents ./inbox ./log, recette et encadré retirés, KESH_*_HOST_DIR marquées sans effet en prod, test qui garde l'état (M14 réécrite, M28), version configurable → #558 ; AC16 : forme <…> refusée pour JWT et mot de passe admin (is_template_placeholder, M26), ordre admin (M27), vérification des placeholders par docker compose config ; Overfull borné aux dix tableaux ; 16 AC, 10 tâches, 28 mutations, 6 tests unitaires AC16, 3 modules ; C83 (révise C73, C77, C79, C81). VALIDATION P2 (Sonnet ×2) : 5 MEDIUM / 12 LOW distincts, tous traités ; AC16 étendue au mot de passe admin (constante TEMPLATE_PLACEHOLDERS, InsecureAdminPassword réutilisée, placeholder contrôlé avant la longueur, M24-M25) ; recette de déplacement réécrite (boucle sur trois dossiers, dernière affectation, guillemets/\r/commentaire retirés, refus chemin non absolu et dossier absent, rsync -rt --no-perms --no-owner --no-group, diff -rq, stat avant/après, ≤ 76 caractères) rejouée sous dash/bash/BusyBox, non mesurée sur DSM ; AC12 j : mise en page des dix tableaux de sec:env-vars reprise de la 15-7b2 (qui n'a plus à le faire) ; propagation du placeholder (README de crate, compose de prod, manuel :537-548, :662, :690) ; DOCKER_START.md en docker compose ; brochure ; signal D5 : HIGH → MEDIUM, 3 des 5 MEDIUM nés de la remédiation P1, déclaré, pas de découpage ; 16 AC, 10 tâches, 25 mutations, 3 modules ; C81. VALIDATION P1 (Opus ×2) : 1 HIGH / 2 MEDIUM / 11 LOW distincts, tous traités ; liste « relisez » à trois sources (interpolations du compose : KESH_*_HOST_DIR de prod — avertissement en tête et en gras du CHANGELOG et du manuel, recette rsync —, KESH_LOG_FILE_PATH, KESH_ADMIN_PASSWORD) ; AC16 : config.rs refuse un KESH_JWT_SECRET contenant GENERATE_ME (même variante InsecureJwtSecret, test qui lit le gabarit, CHANGELOG Sécurité) ; DOCKER_START.md (admin/admin, démarrage sans .env) ; KESH_ADMIN_PASSWORD de prod en ${…:-} ; liste AJOUTS ; $ → $$ mesuré ; 16 AC, 10 tâches, 23 mutations, 3 modules (un seul comportement du code modifié) ; C79. Avant P1 : En validation (statut de convention de l'epic). Créée par le découpage de la 15-11 (C77). Les 16 variables manquantes transmises par les deux compose de production en ${KESH_X:-} (C75), KESH_LOG_FILE_PATH en ${X-défaut} (vide signifiant), KESH_ADMIN_PASSWORD sans défaut dans docker-compose.yml (C76), image: dans docker-compose.yml, montages d'hôte de prod, fantôme KESH_ADMIN_RESET retiré, .env.example, manuel admin (règle de transmission, up -d, « relisez votre .env » fermée — dont le refus de main.rs:316-323, R3-2 —, trois gestes de mise à jour, KESH_JWT_SECRET obligatoire — F-10), DOCKER_START.md, docs/ci.md, CHANGELOG Corrigé, docker compose config -q en CI. Test configuration_transmise.rs SIMPLE (ne lit pas le code Rust) : liste fermée LUES de 41 variables écrite en dur, (T)(V)(E)(F texte)(S), yaml-rust2. (alors 15 AC, 10 tâches, 19 mutations, 3 modules, textes seulement). Merge seul sans danger (aucun refus de démarrer sur un ajout vide, vérifié). AVANT la 15-7b2 (C-15-7-51). 15-11b-lecture-unique-des-variables: done # 2026-10-09 — DONE : revue de code close (P1 Sonnet ×3 1 MEDIUM / 14 LOW → P2 ciblée Haiku 0) ; rebasée sur 5e4bec50 ; gates 2902/2902, Vitest 1091/1091, E2E 247 / 7 KF-029 ; C-15-11b-3, C-15-11b-4. DÉVELOPPÉE (worktree kesh-15-11b) : config::env_nonempty seule lecture (vide = absent, trim), 33 sites basculés, test (L) lexical à 22 entrées, LUES retirée (égalité assertée au T1), (F) étendu au code ; gates backend 2877/2877 (4 ignorés), E2E 247 / 7 KF-029, frontend non touché ; 17 mutations (16 rouges, 1 verte) ; C-15-11b-1, C-15-11b-2. Revue de code à lancer. T0 : alignement sur le livré de la 15-11a (34 sites, 7 opt_trimmed_env, tests « valeur vide » remplacés ; C-15-11b-1). 2026-10-08 — VALIDATION CLOSE (P3 ciblée, MEDIUM reclassé LOW). refs #550. VALIDATION P2 (Sonnet ×2) : 2 MEDIUM distincts non recyclés (R-1 = F-2 appel qualifié d'env_nonempty sans use ; F-1 API de tracing-subscriber : EnvFilter, Builder, init/try_init surveillés) / 11 LOW traités ; promesse « pour le code du workspace », angles morts des dépendances listés ; 22 entrées, 17 mutations (16 rouges, 1 verte) ; 6 AC, 7 tâches, 4 modules ; C80. VALIDATION P1 (Opus ×2) : 6 MEDIUM distincts / 16 LOW traités ; signal D5 (recyclage R-1 né de R3-7) → test LEXICAL : jetons surveillés (env, dotenvy, from_default_env, indirections) relevés dans tout le flux de jetons, emplacement par visiteur syn, liste fermée EMPLACEMENTS_AUTORISES (17 entrées, nombre exact) — machinerie sémantique (formes d'appel, INDIRECTIONS, imports, macros) retirée ; tests « valeur vide » discriminants + capture tracing ; 6 AC, 7 tâches, 15 mutations (14 rouges, 1 verte), 4 modules ; C78. Avant P1 : En validation (statut de convention de l'epic). Créée par le découpage de la 15-11 (C77). DÉPEND de 15-11a ET 15-5e1 (un seul syn, lock régénéré). Fonction config::env_nonempty (vide = absent, trim, C75), migration des 36 sites de lecture, test (L) par syn (INDIRECTIONS — site = lecture directe ou appel non littéral d'env_nonempty, R3-7 —, imports, macros, cfg(test)) et (F) étendu aux littéraux du code et des macros avec la même exclusion cfg(test) (F-2), proc-macro2 en dev-dépendance (F-3) ; remplace la liste LUES de la 15-11a (égalité assertée au T1) ; .env.example, manuel :648/:1306, CHANGELOG Modifié (« hors Docker » pour RUST_LOG="", F-9). 6 AC, 7 tâches, 11 mutations (10 rouges, 1 verte), 4 modules. Attendue avant le tag v0.13.0. + 15-12-cloture-dans-l-ordre: split # 2026-10-09 DÉCOUPÉE à la remédiation de la validation P2 (Sonnet ×2 : R 2 MEDIUM / 5 LOW, F 3 MEDIUM / 8 LOW → 0 C / 0 H / 4 MEDIUM / 10 LOW distincts ; F3 + R3 : la dérogation au découpage ne relevait pas de l'exception codifiée, la dépendance A/B n'était pas à sens unique — décision de l'orchestrateur, C107) en 15-12a (l'ordre, refs #543) + 15-12b (le filet, closes #543). Fiche devenue index ; version complète au commit dae3a618. Remédiation P2 portée dans les deux fiches (C108-C112). Historique : 2026-10-08 — closes #543 (P1). SPÉCIFIÉE (C89), EN VALIDATION. Clôture refusée tant qu'un exercice antérieur est ouvert (409 EARLIER_FISCAL_YEAR_OPEN), création refusée sous un exercice postérieur clos, filet LATER_FISCAL_YEAR_CLOSED généralisé aux deux points de passage du journal (create_in_tx_inner sans verrou, delete_in_tx sans condition — la dévalidation), lot de rapprochement, bandeau d'état hérité et bouton désactivé à l'écran des exercices. 23 AC, T0-T13. Dérogation au découpage (8 modules), coupe de repli 15-12a/15-12b. 2026-10-09 VALIDATION P1 (Opus ×2) : 1 HIGH / 4 MEDIUM / 14 LOW distincts, aucun recyclé, tous remédiés (C100 : preuve du filet refondue, tests de concurrence ordonnés, cycle clôture/contre-passation, réparation dans l'ordre LIFO, inventaire doc par recherche) ; dérogation recomptée (11 modules) et réargumentée, repli élargi au premier défaut recyclé. Prochaine : P2 complète (Sonnet). + 15-12a-cloture-dans-l-ordre: done # 2026-10-09 — refs #543 (la 15-12b fermera l'issue). DONE : revue de code close (P1 Sonnet ×3 2 MEDIUM d'origine → P2 ciblée Haiku 0) ; rebasée sur de285ea8 (15-7a1, 15-11b) ; gates sur l'état rebasé : backend 2934/2934, Vitest 1095/1095, E2E 244 / 7 KF-029 + 2 KF-045 du matin + xss-token-protection:82 (montage, rejouée verte). Développement : mutations (i) (ii) (iv) (viii) (ix) (x) (xi) tuées, (iii) observée. (worktree kesh-15-12a, branche story/15-12a-cloture-dans-l-ordre). Validation close (P4 Sonnet ×2 : 0 au-dessus de LOW ; 15 des 16 LOW appliqués en T0, tous sauf R7 — fiche 15-1a, à l'orchestrateur). Invariant I : clôture refusée tant qu'un antérieur est ouvert (409 EARLIER_FISCAL_YEAR_OPEN), création refusée sous un postérieur clos (400 LATER_FISCAL_YEAR_CLOSED, message propre à la création — C-15-12a-1), antérieurs verrouillés un par un par clé primaire puis relecture verrouillante (C119), clôture et création rejouées. Ordre : 15-12a → 15-12b → 15-1a-i → 15-1a-ii. + 15-12b-filet-sous-un-bilan-clos: ready-for-dev # 2026-10-09 — closes #543 (P1). Créée au découpage de la 15-12 (C107), EN VALIDATION. ⛔ DÉPEND de 15-12a. Filet LATER_FISCAL_YEAR_CLOSED aux deux points de passage du journal (create_in_tx_inner sans verrou, delete_in_tx sans condition — la dévalidation), inventaire fermé des écrivains, lot de rapprochement aux trois voies dont accept_one_rule (C108), bandeau d'état hérité et réparation LIFO, autres écrans, prédicteurs d'annulation en angle mort (C120). 11 AC (numéros d'origine), 9 tâches ; 10 modules (dérogation écrite, C121). Avant la 15-1a de préférence (delete_in_tx partagé, C112 ; précédence C117). P3 (Opus ×2) remédiée le 2026-10-09. Prochaine : P4 ciblée (une lentille) sur le diff de la remédiation P3. epic-15-retrospective: optional # --- v0.2 : Epics ajoutés au scoping 2026-06-04 (cf. _bmad-output/planning-artifacts/epic-17.md) --- # Ordre d'exécution v0.2 : E17 (infra) DÉMARRE, puis E11 TVA, E16 Facturation, E12, E13, E14, E15. diff --git a/crates/kesh-api/src/errors.rs b/crates/kesh-api/src/errors.rs index fbefb45f3..46af25475 100644 --- a/crates/kesh-api/src/errors.rs +++ b/crates/kesh-api/src/errors.rs @@ -325,6 +325,19 @@ pub enum AppError { date: String, }, + /// Story 15-12a (#543, choix C-15-12a-1) — la **création** d'un exercice est + /// refusée : un exercice postérieur à sa date de début est clôturé. Même + /// code, même statut et mêmes `details` que `DbError::LaterFiscalYearClosed` + /// (`400 LATER_FISCAL_YEAR_CLOSED`), mais un message **propre à la création** + /// (`error-fiscal-year-create-later-closed`) : celui des écritures conseille + /// une contre-passation, qui n'a pas d'objet ici. Produite par + /// `routes::fiscal_years::map_create_error`. + #[error("Exercice postérieur {fiscal_year_name} clôturé — création refusée")] + FiscalYearBeforeClosedYear { + fiscal_year_id: i64, + fiscal_year_name: String, + }, + // --- Story 4.1 --- /// Un contact avec ce numéro IDE (CHE) existe déjà dans la même company. /// Code client dédié (`IDE_ALREADY_EXISTS`) pour UX précise côté form, @@ -1088,6 +1101,33 @@ fn build_response(status: StatusCode, code: &'static str, message: &str) -> Resp .into_response() } +/// Corps `400 LATER_FISCAL_YEAR_CLOSED` (Stories 15-8a, 15-12a) : un seul +/// gabarit pour les deux messages — celui des écritures +/// (`error-later-fiscal-year-closed`) et celui de la création d'un exercice +/// (`error-fiscal-year-create-later-closed`) —, pour que leurs `details` ne +/// divergent jamais. +fn later_fiscal_year_closed_response( + fiscal_year_id: i64, + fiscal_year_name: &str, + key: &str, + fallback: &str, +) -> Response { + let mut args = FluentArgs::new(); + args.set("name", fiscal_year_name.to_string()); + let message = t_args(key, fallback, &args); + let body = serde_json::json!({ + "error": { + "code": "LATER_FISCAL_YEAR_CLOSED", + "message": message, + "details": { + "fiscalYearId": fiscal_year_id, + "fiscalYearName": fiscal_year_name, + }, + } + }); + (StatusCode::BAD_REQUEST, Json(body)).into_response() +} + /// Clé i18n et repli d'un motif de contre-passation (Story 24-4a), partagés /// par le refus de contre-passation et le refus de modification (Story 15-8a, /// D2 — mêmes codes, mêmes messages : ils nomment déjà le chemin de la pièce). @@ -1491,6 +1531,21 @@ impl IntoResponse for AppError { build_response(StatusCode::BAD_REQUEST, "FISCAL_YEAR_CLOSED", &fallback) } + AppError::FiscalYearBeforeClosedYear { + fiscal_year_id, + fiscal_year_name, + } => { + let fallback = format!( + "L'exercice « {fiscal_year_name} », postérieur, est clôturé, et son bilan reprend tout ce qui le précède : aucun exercice ne peut être créé avant sa date de début tant qu'il l'est. Pour créer celui-ci, un administrateur rouvre d'abord les exercices clôturés, en commençant par le plus récent." + ); + later_fiscal_year_closed_response( + fiscal_year_id, + &fiscal_year_name, + "error-fiscal-year-create-later-closed", + &fallback, + ) + } + AppError::DateOutsideFiscalYear { date } => { let fallback = format!("La date {date} n'est pas dans l'exercice courant de cette écriture."); @@ -2841,26 +2896,60 @@ impl IntoResponse for AppError { ) } // Story 15-8a (#532, C-15-8-22) — un exercice POSTÉRIEUR est - // clos : son bilan cumulatif reprend l'écriture. 400, comme + // clos : son bilan cumulatif reprend ce qui le précède. 400, comme // `FISCAL_YEAR_CLOSED` : l'état d'un exercice, pas un conflit sur - // l'écriture. + // l'objet. Story 15-12a (AC 9) : message neutre, clé + // `error-later-fiscal-year-closed` — il ne présuppose pas que + // l'objet visé existe, garde le conseil de contre-passation et + // ne prescrit jamais de rouvrir l'exercice nommé (la garde LIFO + // le refuserait dès qu'un plus récent est clos). La création + // d'un exercice a son propre message + // (`AppError::FiscalYearBeforeClosedYear`). Revue P1 (B-1) : le + // texte ne nomme que la modification et la suppression — les + // seuls gestes que garde aujourd'hui `LaterFiscalYearClosed` sur + // une écriture (`journal_entries::update` / `delete_in_tx`) ; la + // saisie y entrera avec la garde de la 15-12b (#543), qui + // élargira ce texte. + // + // Pourquoi deux clés voisines (revue P1, B-5) : + // `journal-entries-modify-blocked-later-fiscal-year-closed` est + // l'explication **d'écran**, posée avant tout geste sur une + // écriture affichée (« cette écriture ») ; celle-ci est la + // réponse du **serveur**, qui ne présuppose pas que l'objet visé + // existe. Même prescription dans les deux (contre-passation, puis + // réouverture en commençant par le plus récent). DbError::LaterFiscalYearClosed { fiscal_year_id, fiscal_year_name, } => { let fallback = format!( - "L'exercice postérieur {fiscal_year_name} est clôturé, et son bilan reprend cette écriture : elle reste figée tant qu'il l'est. Un administrateur peut rouvrir cet exercice ; sinon, corrigez par une contre-passation." + "L'exercice « {fiscal_year_name} », postérieur, est clôturé, et son bilan reprend tout ce qui le précède : aucune écriture datée avant sa date de début ne peut être modifiée ni supprimée tant qu'il l'est. Une telle écriture se corrige par une contre-passation ; sinon, un administrateur rouvre les exercices clôturés, en commençant par le plus récent." ); - let mut args = FluentArgs::new(); - args.set("name", fiscal_year_name.clone()); - let message = t_args( - "journal-entries-modify-blocked-later-fiscal-year-closed", + later_fiscal_year_closed_response( + fiscal_year_id, + &fiscal_year_name, + "error-later-fiscal-year-closed", &fallback, - &args, + ) + } + // Story 15-12a (#543) — la clôture d'un exercice précédé d'un + // exercice ouvert. 409, comme les autres refus de transition de + // l'écran des exercices, mais un code DÉDIÉ : l'écran traduit + // tout `ILLEGAL_STATE_TRANSITION` de la clôture en « déjà + // clôturé », et une intégration doit pouvoir distinguer les deux. + DbError::EarlierFiscalYearOpen { + fiscal_year_id, + fiscal_year_name, + } => { + let fallback = format!( + "Clôturez d'abord l'exercice « {fiscal_year_name} », plus ancien et encore ouvert : le bilan est cumulatif, et un exercice ne se clôt qu'après tous ceux qui le précèdent." ); + let mut args = FluentArgs::new(); + args.set("name", fiscal_year_name.clone()); + let message = t_args("error-fiscal-year-close-earlier-open", &fallback, &args); let body = serde_json::json!({ "error": { - "code": "LATER_FISCAL_YEAR_CLOSED", + "code": "EARLIER_FISCAL_YEAR_OPEN", "message": message, "details": { "fiscalYearId": fiscal_year_id, @@ -2868,7 +2957,7 @@ impl IntoResponse for AppError { }, } }); - (StatusCode::BAD_REQUEST, Json(body)).into_response() + (StatusCode::CONFLICT, Json(body)).into_response() } // Story 25-2-b-1 (#440) — la dévalidation refusée. // @@ -2973,6 +3062,9 @@ impl IntoResponse for AppError { "supplier-invoices-settlement-cancel-blocked-not-paid", "Cette facture fournisseur n'est pas payée : il n'y a pas de règlement à annuler.", ), + // #569 : ce texte prescrit « rouvrir l'exercice » sans l'ordre qu'impose + // la garde LIFO (en commençant par le plus récent) — hors périmètre de la + // Story 15-12a, qui n'aligne que les messages de `LATER_FISCAL_YEAR_CLOSED` (C122). SettlementCancelBlocker::FiscalYearClosed => ( "settlement-cancel-blocked-fiscal-year-closed", "Ce règlement appartient à un exercice clôturé : un administrateur doit rouvrir l'exercice pour pouvoir l'annuler.", @@ -3482,6 +3574,9 @@ fn reconciliation_cancel_blocked_text( "supplier-invoices-settlement-cancel-blocked-not-paid", "Cette facture fournisseur n'est pas payée : il n'y a pas de règlement à annuler.", ), + // #569 : ce texte prescrit « rouvrir l'exercice » sans l'ordre qu'impose + // la garde LIFO (en commençant par le plus récent) — hors périmètre de la + // Story 15-12a, qui n'aligne que les messages de `LATER_FISCAL_YEAR_CLOSED` (C122). SettlementCancelBlocker::FiscalYearClosed => ( "reconciliation-cancel-blocked-fiscal-year-closed", "Ce rapprochement appartient à un exercice clôturé : un administrateur doit rouvrir l'exercice pour pouvoir l'annuler.", @@ -3519,6 +3614,9 @@ fn supplier_invoice_cancel_blocked_text( "supplier-invoices-cancel-blocked-cancelled", "Cette facture fournisseur est déjà annulée.", ), + // #569 : ce texte prescrit « rouvrir l'exercice » sans l'ordre qu'impose + // la garde LIFO (en commençant par le plus récent) — hors périmètre de la + // Story 15-12a, qui n'aligne que les messages de `LATER_FISCAL_YEAR_CLOSED` (C122). SettlementCancelBlocker::FiscalYearClosed => ( "supplier-invoices-cancel-blocked-fiscal-year-closed", "Cette facture appartient à un exercice clôturé : un administrateur doit rouvrir l'exercice pour pouvoir l'annuler.", diff --git a/crates/kesh-api/src/routes/fiscal_years.rs b/crates/kesh-api/src/routes/fiscal_years.rs index de476f969..2c27981e1 100644 --- a/crates/kesh-api/src/routes/fiscal_years.rs +++ b/crates/kesh-api/src/routes/fiscal_years.rs @@ -133,6 +133,16 @@ fn map_create_error(err: DbError) -> AppError { "FK violation impossible (JWT scope) — investigate: {m}" )) } + // Story 15-12a (#543, C-15-12a-1) — un exercice postérieur est clos : + // même code et mêmes `details` que le mapping global, message propre + // à la création. + DbError::LaterFiscalYearClosed { + fiscal_year_id, + fiscal_year_name, + } => AppError::FiscalYearBeforeClosedYear { + fiscal_year_id, + fiscal_year_name, + }, // Tout le reste (Sqlx, ConnectionUnavailable, NotFound, ...) → mapping standard. other => AppError::from(other), } @@ -236,9 +246,17 @@ pub async fn create_fiscal_year( end_date: req.end_date, }; - let fy = fiscal_years::create(&state.pool, current_user.user_id, new) - .await - .map_err(map_create_error)?; + // Story 15-12a (AC 6) — rejouée sur interblocage : la création verrouille + // ses antérieurs (pré-contrôles) puis ses postérieurs (garde de + // l'invariant I) ; elle peut former un cycle avec une clôture ou une + // contre-passation. Transaction unique, sûre à relancer. + let fy = kesh_db::retry::retry_on_deadlock("fiscal_years::create", || { + let new = new.clone(); + let pool = state.pool.clone(); + async move { fiscal_years::create(&pool, current_user.user_id, new).await } + }) + .await + .map_err(map_create_error)?; Ok((StatusCode::CREATED, Json(FiscalYearResponse::from(fy)))) } @@ -275,7 +293,14 @@ pub async fn update_fiscal_year( /// `POST /api/v1/fiscal-years/{id}/close` — transition Open → Closed (Comptable+). /// /// Renvoie 409 `ILLEGAL_STATE_TRANSITION` si l'exercice est déjà clos -/// (mappé automatiquement via `AppError::Database(DbError::IllegalStateTransition)`). +/// (mappé automatiquement via `AppError::Database(DbError::IllegalStateTransition)`), +/// et 409 `EARLIER_FISCAL_YEAR_OPEN` si un exercice antérieur est encore ouvert +/// (Story 15-12a, #543 — `details` nomment le plus ancien, à clôturer d'abord). +/// +/// Rejouée sur interblocage (Story 15-12a, AC 6) : la clôture verrouille ses +/// antérieurs puis l'exercice, et forme un cycle avec une contre-passation ou +/// une annulation d'une écriture de l'exercice (preuve : +/// `rejeu_interblocage_e2e.rs`). pub async fn close_fiscal_year( State(state): State, Extension(current_user): Extension, @@ -285,12 +310,14 @@ pub async fn close_fiscal_year( .await? .ok_or(AppError::Database(DbError::NotFound))?; - let fy = fiscal_years::close( - &state.pool, - current_user.user_id, - current_user.company_id, - id, - ) + let fy = kesh_db::retry::retry_on_deadlock("fiscal_years::close", || { + fiscal_years::close( + &state.pool, + current_user.user_id, + current_user.company_id, + id, + ) + }) .await?; Ok(Json(FiscalYearResponse::from(fy))) } diff --git a/crates/kesh-api/tests/audit_route_registry.rs b/crates/kesh-api/tests/audit_route_registry.rs index 0bdaeb8bf..9a2c6a5b9 100644 --- a/crates/kesh-api/tests/audit_route_registry.rs +++ b/crates/kesh-api/tests/audit_route_registry.rs @@ -55,10 +55,11 @@ //! C'est la classification de l'inventaire de l'AC1, recontrôlée en revue. //! - **(ii)** que le 1213 **atteint** le prédicat : le volet (c) est statique — //! il voit l'appel, pas le chemin de l'erreur ; une conversion faite dans la -//! fermeture le passerait. Seuls les tests 2 à 5 et 7 de -//! `rejeu_interblocage_e2e.rs` le prouvent dynamiquement, pour cinq routes de -//! la famille `DbError` (quatre qui écrivent au journal, et l'enregistrement -//! des réglages de facturation). Pour la famille `AppError`, le test 1 éprouve +//! fermeture le passerait. Seuls les tests 2 à 5, 7, 9 et 10 de +//! `rejeu_interblocage_e2e.rs` le prouvent dynamiquement, pour sept routes de +//! la famille `DbError` (quatre qui écrivent au journal, l'enregistrement des +//! réglages de facturation, et — Story 15-12a — la clôture et la création +//! d'un exercice, enveloppe `kesh_db::retry`). Pour la famille `AppError`, le test 1 éprouve //! le prédicat et l'enveloppe, et le test 8 le chemin propre aux routes à //! verrou nommé sur `reconciliation::manual` (1213 levé sous le verrou, //! `match` du handler, `RELEASE_LOCK`, `rollback`, nouvelle tentative) ; pour @@ -69,8 +70,9 @@ //! l'enveloppe **enveloppe l'écriture** : le volet (c) est vrai dès qu'un //! appel de ce nom figure dans le corps. Un handler `Rejouee` qui //! envelopperait une lecture et ferait l'écriture hors de la fermeture -//! resterait vert. Ont une preuve dynamique : les cinq routes des tests 2 à 5 -//! et 7 de `rejeu_interblocage_e2e.rs` et `reconciliation::manual` (test 8 du +//! resterait vert. Ont une preuve dynamique : les sept routes des tests 2 à +//! 5, 7, 9 et 10 de `rejeu_interblocage_e2e.rs` (dont `close_fiscal_year` et +//! `create_fiscal_year`, Story 15-12a) et `reconciliation::manual` (test 8 du //! même fichier, Story 15-5e2) ; //! `accept_replays_the_batch_when_it_is_the_deadlock_victim` //! (`reconciliation_e2e.rs`) pour `reconciliation::accept` ; @@ -94,19 +96,34 @@ //! que **toute route mutante a été examinée**. Autres candidates nommées en //! revue P1 de la 15-5e2 (L-5), sans cycle démontré : `PUT /invoices/{id}` //! quand le projet change (sentinelle, projet, facture), -//! `POST /projects/{id}/archive`, `POST /fiscal-years/{id}/close` et -//! `/reopen` — elles prennent des verrous que les flux rejoués prennent aussi. +//! `POST /projects/{id}/archive` et `POST /fiscal-years/{id}/reopen` — elles +//! prennent des verrous que les flux rejoués prennent aussi. Pour `/reopen`, +//! le cycle est nommé : la réouverture tient Y-1 puis parcourt les +//! postérieurs ; une contre-passation tient une origine postérieure puis +//! parcourt depuis le premier exercice (préexistant). Et le **renommage** +//! (`PUT /fiscal-years/{id}`, Story 15-12a) : il tient l'exercice puis son +//! homonyme, la clôture tient ses antérieurs puis l'exercice — renommer un +//! exercice au nom d'un antérieur pendant sa clôture peut faire du renommage +//! la victime, en 500, sur un renommage que le pré-contrôle aurait refusé. +//! `POST /fiscal-years/{id}/close` n'est plus de la liste : elle est rejouée +//! (point (vi)). //! - **(v)** quoi que ce soit d'une route `GET` : l'extracteur ne balaie que //! `post`, `put`, `delete` et `patch`. Aucune route `GET` n'écrit au journal //! aujourd'hui (remontée de l'AC1 : des `POST` et un `DELETE`) ; l'angle mort //! est du même ordre que celui de l'audit (`GET /invoices/{id}/pdf`, plus bas). -//! - **(vi)** **deux routes `SansEcritureAuJournal` sont rejouées quand même** : -//! `onboarding::finalize` (enveloppe `AppError`, Story 15-5e2) et +//! - **(vi)** **quatre routes `SansEcritureAuJournal` sont rejouées quand +//! même** : `onboarding::finalize` (enveloppe `AppError`, Story 15-5e2), //! `company_invoice_settings::update_invoice_settings` (enveloppe `DbError`, -//! exposée par l'avance des réglages de la saisie fournisseur — choix C70). La -//! colonne dit l'inventaire de l'AC1, pas la présence d'une enveloppe, et le -//! volet (c) ne les examine pas : la seconde est tenue par le test 7 de -//! `rejeu_interblocage_e2e.rs`, la première par sa revue — et le volet +//! exposée par l'avance des réglages de la saisie fournisseur — choix C70), +//! et — Story 15-12a, #543 — `fiscal_years::create_fiscal_year` et +//! `fiscal_years::close_fiscal_year` (enveloppe `DbError` +//! `kesh_db::retry::retry_on_deadlock`, opérations `fiscal_years::create` / +//! `fiscal_years::close` : la clôture dans l'ordre et la garde de création +//! forment des cycles avec la contre-passation et entre elles). La colonne +//! dit l'inventaire de l'AC1, pas la présence d'une enveloppe, et le volet +//! (c) ne les examine pas : `update_invoice_settings` est tenue par le test +//! 7 de `rejeu_interblocage_e2e.rs`, la clôture et la création d'un exercice +//! par ses tests 9 et 10, `onboarding::finalize` par sa revue — et le volet //! (c bis) interdit qu'elle revienne à un `retry_with` à prédicat écrit en //! ligne. //! - **(vii)** — **angles morts assumés du volet (c bis)** (revue P1 de la diff --git a/crates/kesh-api/tests/fiscal_years_e2e.rs b/crates/kesh-api/tests/fiscal_years_e2e.rs index 7cf42d9b6..34dc7602b 100644 --- a/crates/kesh-api/tests/fiscal_years_e2e.rs +++ b/crates/kesh-api/tests/fiscal_years_e2e.rs @@ -1903,3 +1903,198 @@ async fn path_b_finalize_designates_the_charts_marked_accounts(pool: MySqlPool) assert_eq!(designated, Some(expected), "{column} désigné d'office"); } } + +// --------------------------------------------------------------------------- +// Story 15-12a (#543) — les exercices se clôturent dans l'ordre +// --------------------------------------------------------------------------- + +/// Crée un exercice `année` (1er janvier – 31 décembre) via l'API ; rend son id. +async fn create_fy(app: &TestApp, token: &str, year: i32) -> i64 { + let resp = app + .client + .post(app.url("/api/v1/fiscal-years")) + .header("Authorization", auth(token)) + .json(&json!({ + "name": format!("Exercice {year}"), + "startDate": format!("{year}-01-01"), + "endDate": format!("{year}-12-31"), + })) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 201, "création de l'exercice {year}"); + let body: serde_json::Value = resp.json().await.unwrap(); + body["id"].as_i64().unwrap() +} + +async fn close_fy(app: &TestApp, token: &str, id: i64) -> reqwest::Response { + app.client + .post(app.url(&format!("/api/v1/fiscal-years/{id}/close"))) + .header("Authorization", auth(token)) + .send() + .await + .unwrap() +} + +/// AC 4 — la clôture d'un exercice précédé d'un exercice ouvert rend **409 +/// `EARLIER_FISCAL_YEAR_OPEN`**, corps complet : `details` nommant l'exercice à +/// clôturer d'abord, message de `error-fiscal-year-close-earlier-open`. +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn close_with_an_earlier_open_year_returns_409_earlier_fiscal_year_open(pool: MySqlPool) { + let (app, token) = bootstrap_admin(&pool).await; + let y2026 = create_fy(&app, &token, 2026).await; + let y2027 = create_fy(&app, &token, 2027).await; + + let resp = close_fy(&app, &token, y2027).await; + assert_eq!(resp.status(), 409); + let body: serde_json::Value = resp.json().await.unwrap(); + assert_eq!(body["error"]["code"], "EARLIER_FISCAL_YEAR_OPEN"); + assert_eq!(body["error"]["details"]["fiscalYearId"], y2026); + assert_eq!(body["error"]["details"]["fiscalYearName"], "Exercice 2026"); + let msg = body["error"]["message"].as_str().unwrap_or(""); + assert!( + msg.contains("« Exercice 2026 », plus ancien et encore ouvert"), + "message attendu de error-fiscal-year-close-earlier-open, obtenu : {msg}" + ); + + // Dans l'ordre : 200, puis 200. + assert_eq!(close_fy(&app, &token, y2026).await.status(), 200); + assert_eq!(close_fy(&app, &token, y2027).await.status(), 200); +} + +/// AC 5 — `POST /fiscal-years` sous un exercice clos rend **400 +/// `LATER_FISCAL_YEAR_CLOSED`**, corps complet, message **propre à la +/// création** (`error-fiscal-year-create-later-closed`, C-15-12a-1). +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn create_before_a_closed_year_returns_400_later_fiscal_year_closed(pool: MySqlPool) { + let (app, token) = bootstrap_admin(&pool).await; + let y2026 = create_fy(&app, &token, 2026).await; + assert_eq!(close_fy(&app, &token, y2026).await.status(), 200); + + let resp = app + .client + .post(app.url("/api/v1/fiscal-years")) + .header("Authorization", auth(&token)) + .json(&json!({ + "name": "Exercice 2025", + "startDate": "2025-01-01", + "endDate": "2025-12-31" + })) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 400); + let body: serde_json::Value = resp.json().await.unwrap(); + assert_eq!(body["error"]["code"], "LATER_FISCAL_YEAR_CLOSED"); + assert_eq!(body["error"]["details"]["fiscalYearId"], y2026); + assert_eq!(body["error"]["details"]["fiscalYearName"], "Exercice 2026"); + let msg = body["error"]["message"].as_str().unwrap_or(""); + assert!( + msg.contains("« Exercice 2026 », postérieur, est clôturé") + && msg.contains("aucun exercice ne peut être créé avant sa date de début"), + "message de création attendu, obtenu : {msg}" + ); + let n: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM fiscal_years WHERE name = 'Exercice 2025'") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(n, 0, "rien n'est inséré"); +} + +/// AC 4 / AC 5 (revue P1, E5) — les deux refus neufs atteignent une +/// **intégration** : avec une clé d'API `read-write`, la clôture hors d'ordre rend +/// `409 EARLIER_FISCAL_YEAR_OPEN` et la création sous un exercice clos `400 +/// LATER_FISCAL_YEAR_CLOSED` (le « changement de contrat » du CHANGELOG). +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn both_refusals_reach_a_read_write_api_key(pool: MySqlPool) { + let (app, token) = bootstrap_admin(&pool).await; + let y2026 = create_fy(&app, &token, 2026).await; + let y2027 = create_fy(&app, &token, 2027).await; + + let create = app + .client + .post(app.url("/api/v1/settings/api-keys")) + .header("Authorization", auth(&token)) + .json(&json!({ "name": "integration", "scope": "read-write" })) + .send() + .await + .unwrap(); + assert_eq!(create.status(), 201); + let key = create.json::().await.unwrap()["key"] + .as_str() + .unwrap() + .to_string(); + let bearer = format!("Bearer {key}"); + + let resp = app + .client + .post(app.url(&format!("/api/v1/fiscal-years/{y2027}/close"))) + .header("Authorization", &bearer) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 409, "clôture hors d'ordre par clé"); + let body: serde_json::Value = resp.json().await.unwrap(); + assert_eq!(body["error"]["code"], "EARLIER_FISCAL_YEAR_OPEN"); + assert_eq!(body["error"]["details"]["fiscalYearId"], y2026); + + let resp = app + .client + .post(app.url(&format!("/api/v1/fiscal-years/{y2026}/close"))) + .header("Authorization", &bearer) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200, "clôture dans l'ordre par clé"); + + let resp = app + .client + .post(app.url("/api/v1/fiscal-years")) + .header("Authorization", &bearer) + .json(&json!({ + "name": "Exercice 2025", + "startDate": "2025-01-01", + "endDate": "2025-12-31" + })) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 400, "création sous un exercice clos par clé"); + let body: serde_json::Value = resp.json().await.unwrap(); + assert_eq!(body["error"]["code"], "LATER_FISCAL_YEAR_CLOSED"); + assert_eq!(body["error"]["details"]["fiscalYearId"], y2026); +} + +/// AC 5 — précédence : une demande qui **chevauche** un exercice et précède un +/// exercice clos rend **`400 VALIDATION_ERROR`**, message +/// `error-fiscal-year-overlap` — le pré-contrôle existant parle d'abord. +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn create_overlapping_and_before_a_closed_year_returns_the_overlap(pool: MySqlPool) { + let (app, token) = bootstrap_admin(&pool).await; + let y2024 = create_fy(&app, &token, 2024).await; + let y2026 = create_fy(&app, &token, 2026).await; + assert_eq!(close_fy(&app, &token, y2024).await.status(), 200); + assert_eq!(close_fy(&app, &token, y2026).await.status(), 200); + + let resp = app + .client + .post(app.url("/api/v1/fiscal-years")) + .header("Authorization", auth(&token)) + .json(&json!({ + "name": "Exercice décalé", + "startDate": "2024-07-01", + "endDate": "2025-06-30" + })) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 400); + let body: serde_json::Value = resp.json().await.unwrap(); + assert_eq!(body["error"]["code"], "VALIDATION_ERROR"); + let msg = body["error"]["message"].as_str().unwrap_or(""); + assert!( + msg.contains("chevauche") || msg.contains("overlap"), + "message de chevauchement attendu, obtenu : {msg}" + ); +} diff --git a/crates/kesh-api/tests/journal_entry_reversal_e2e.rs b/crates/kesh-api/tests/journal_entry_reversal_e2e.rs index 238e40f64..d6ed26470 100644 --- a/crates/kesh-api/tests/journal_entry_reversal_e2e.rs +++ b/crates/kesh-api/tests/journal_entry_reversal_e2e.rs @@ -1883,6 +1883,13 @@ async fn a_closed_later_year_freezes_the_entry_until_reopened(pool: MySqlPool) { .contains(&format!("Exercice {}", annee + 1)), "{body}" ); + // Revue P1 (B-1) : le message ne promet que ce que le code garde — la + // modification et la suppression, non la saisie. + let msg = body["error"]["message"].as_str().unwrap_or_default(); + assert!( + msg.contains("ne peut être modifiée ni supprimée") && !msg.contains("enregistr"), + "{body}" + ); let ecran = detail(&app, &token, id).await; assert_eq!(ecran["modifiable"], false); assert_eq!(ecran["modificationBlockedBy"], "LATER_FISCAL_YEAR_CLOSED"); diff --git a/crates/kesh-api/tests/opening_balances_e2e.rs b/crates/kesh-api/tests/opening_balances_e2e.rs index 37dd5de9a..67832cdc5 100644 --- a/crates/kesh-api/tests/opening_balances_e2e.rs +++ b/crates/kesh-api/tests/opening_balances_e2e.rs @@ -1019,8 +1019,9 @@ async fn status_rbac_consultation_403_unauth_401(pool: MySqlPool) { /// Deux générations HTTP simultanées sur la même company vierge : exactement /// une réussit (201), l'autre reçoit 409 `ALREADY_HAS_ENTRIES` — état final = -/// UNE écriture (sérialisation par `fiscal_years FOR UPDATE`, miroir -/// `reopen_close_concurrent_is_serialized` 14-2). +/// UNE écriture (sérialisation par `fiscal_years FOR UPDATE`, miroir de +/// l'ancien `reopen_close_concurrent_is_serialized` de 14-2, course libre +/// remplacée par la Story 15-12a — test 13 a de `fiscal_years_repository.rs`). #[sqlx::test(migrations = "../kesh-db/test-schema")] async fn post_concurrent_generation_only_one_succeeds(pool: MySqlPool) { let (app, token) = bootstrap_admin(&pool).await; diff --git a/crates/kesh-api/tests/rejeu_interblocage_e2e.rs b/crates/kesh-api/tests/rejeu_interblocage_e2e.rs index 94b1ccc16..070120fcf 100644 --- a/crates/kesh-api/tests/rejeu_interblocage_e2e.rs +++ b/crates/kesh-api/tests/rejeu_interblocage_e2e.rs @@ -1,5 +1,6 @@ //! Rejeu sur interblocage — Story 15-5e1 (AC4, tests 1 à 5 et 7 ; #463, #491, -//! #536 pour la validation). +//! #536 pour la validation) ; Story 15-12a (tests 9 et 10 : clôture et création +//! d'un exercice ; #543). //! //! Chaque test « route victime » monte un interblocage **déterministe** sur le //! patron de `accept_replays_the_batch_when_it_is_the_deadlock_victim` @@ -1239,3 +1240,141 @@ async fn credit_note_route_replays_when_it_is_the_deadlock_victim(pool: MySqlPoo ); capture.exiger_un_rejeu("credit_notes::create"); } + +// ============================================================ +// Tests 9 et 10 — clôture et création d'un exercice victimes (Story 15-12a, +// AC 6 ; #543) +// ============================================================ + +/// Insère un exercice `année` (1er janvier – 31 décembre) de statut `statut`, +/// par SQL : le montage pose un état, il ne teste pas la création. +async fn exercice_sql(pool: &MySqlPool, company_id: i64, annee: i32, statut: &str) -> i64 { + sqlx::query( + "INSERT INTO fiscal_years (company_id, name, start_date, end_date, status) \ + VALUES (?, ?, ?, ?, ?)", + ) + .bind(company_id) + .bind(format!("Exercice {annee} (rejeu)")) + .bind(NaiveDate::from_ymd_opt(annee, 1, 1).unwrap()) + .bind(NaiveDate::from_ymd_opt(annee, 12, 31).unwrap()) + .bind(statut) + .execute(pool) + .await + .unwrap() + .last_insert_id() as i64 +} + +/// Test 9 — `POST /fiscal-years/{N}/close`, victime, est **rejouée**. +/// +/// Montage : M **clos**, N **ouvert**, M < N (antérieurs à l'exercice +/// 2020-2030 du semis). La transaction lourde tient N ; la clôture, lancée en +/// tâche, verrouille M **par sa clé primaire** à l'étape (b') — explicitement, +/// quel que soit son statut — puis est vue en attente de N à l'étape (c) +/// (motif de `LOCK_IN_COMPANY_SQL` ; l'exécution étant séquentielle, voir (c) +/// en cours prouve que (b') a rendu et tient M). La transaction demande M : +/// cycle ; InnoDB annule la clôture, plus légère ; la transaction obtient M +/// (`.expect`), annule ; la route rejoue et rend **200**, N clos. +/// +/// ⛔ **Tue la mutation (ix)** (appeler `fiscal_years::close` hors de son +/// enveloppe dans la route) : 500, et pas de témoin de rejeu. +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn fiscal_year_close_is_replayed_when_it_is_the_deadlock_victim(pool: MySqlPool) { + let ctx = monter(&pool).await; + let capture = CaptureRejeu::installer(); + let m = exercice_sql(&pool, ctx.company_id(), 2010, "Closed").await; + let n = exercice_sql(&pool, ctx.company_id(), 2015, "Open").await; + + let mut lourde = transaction_lourde(&pool).await; + sqlx::query("SELECT id FROM fiscal_years WHERE id = ? FOR UPDATE") + .bind(n) + .fetch_one(&mut *lourde) + .await + .unwrap(); + + let route = requete_en_tache( + &ctx, + reqwest::Method::POST, + &format!("/api/v1/fiscal-years/{n}/close"), + None, + ); + let (status, corps) = victime( + &pool, + lourde, + route, + &["SELECT id, company_id", "WHERE id = ", "FOR UPDATE"], + "SELECT id FROM fiscal_years WHERE id = ? FOR UPDATE", + m, + ) + .await; + + assert_eq!(status, 200, "⛔ l'interblocage doit être rejoué : {corps}"); + assert_eq!(corps["status"], "Closed"); + let audits: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM audit_log WHERE action = 'fiscal_year.closed' AND entity_id = ?", + ) + .bind(n) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(audits, 1, "une seule entrée d'audit"); + capture.exiger_un_rejeu("fiscal_years::close"); +} + +/// Test 10 — `POST /fiscal-years`, victime, est **rejouée**. +/// +/// Montage (mesuré en T0, Story 15-12a) : M **clos**, Y **ouvert**, M < X < Y. +/// La transaction lourde tient Y ; la création de X, lancée en tâche, lit — donc +/// verrouille — M dans son pré-contrôle de chevauchement, et bute sur Y (borne +/// de ce parcours, ou sa garde `find_later_closed_in_tx` : le motif tolère les +/// deux, seule la création s'exécute pendant l'attente). La transaction demande +/// M : cycle ; la création, plus légère, est annulée ; la transaction obtient M, +/// annule ; la route rejoue et rend **201**. +/// +/// ⛔ **Tue la mutation (x)** (appeler `fiscal_years::create` hors de son +/// enveloppe dans la route) : 500, et pas de témoin de rejeu. +#[sqlx::test(migrations = "../kesh-db/test-schema")] +async fn fiscal_year_creation_is_replayed_when_it_is_the_deadlock_victim(pool: MySqlPool) { + let ctx = monter(&pool).await; + let capture = CaptureRejeu::installer(); + let m = exercice_sql(&pool, ctx.company_id(), 2010, "Closed").await; + let y = exercice_sql(&pool, ctx.company_id(), 2015, "Open").await; + + let mut lourde = transaction_lourde(&pool).await; + sqlx::query("SELECT id FROM fiscal_years WHERE id = ? FOR UPDATE") + .bind(y) + .fetch_one(&mut *lourde) + .await + .unwrap(); + + let route = requete_en_tache( + &ctx, + reqwest::Method::POST, + "/api/v1/fiscal-years", + Some(json!({ + "name": "Exercice 2012", + "startDate": "2012-01-01", + "endDate": "2012-12-31" + })), + ); + let (status, corps) = victime( + &pool, + lourde, + route, + &["fiscal_years", "FOR UPDATE"], + "SELECT id FROM fiscal_years WHERE id = ? FOR UPDATE", + m, + ) + .await; + + assert_eq!(status, 201, "⛔ l'interblocage doit être rejoué : {corps}"); + assert_eq!(corps["name"], "Exercice 2012"); + let n: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM fiscal_years WHERE company_id = ? AND name = 'Exercice 2012'", + ) + .bind(ctx.company_id()) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(n, 1, "un seul exercice créé"); + capture.exiger_un_rejeu("fiscal_years::create"); +} diff --git a/crates/kesh-db/src/errors.rs b/crates/kesh-db/src/errors.rs index 7032edaf2..3b96850eb 100644 --- a/crates/kesh-db/src/errors.rs +++ b/crates/kesh-db/src/errors.rs @@ -549,21 +549,46 @@ pub enum DbError { #[error("Exercice clôturé — modification interdite (CO art. 957-964)")] FiscalYearClosed, - /// Un exercice **postérieur** à celui de l'écriture est clôturé (Story - /// 15-8a, #532, C-15-8-22). + /// Un exercice **postérieur** est clôturé (Story 15-8a, #532, C-15-8-22 ; + /// Story 15-12a, #543). /// - /// Le bilan est **cumulatif** (`kesh-report/src/balance_sheet.rs`) : modifier - /// une écriture de N change le bilan de tout exercice postérieur. Or « N - /// ouvert, N+1 clos » est atteignable (`fiscal_years::close` ne regarde pas - /// les exercices antérieurs). Le refus nomme le **plus proche** postérieur - /// clos. Mappé vers HTTP **400** `LATER_FISCAL_YEAR_CLOSED` — l'état d'un - /// exercice, comme `FISCAL_YEAR_CLOSED`, pas un conflit sur l'écriture. + /// Le bilan est **cumulatif** (`kesh-report/src/balance_sheet.rs`) : toucher + /// à ce qui précède un exercice clos change son bilan. Rendue par : + /// - le `PUT` et le `DELETE` d'une écriture (`journal_entries::update`, + /// `delete_in_tx`) dont un exercice postérieur est clos ; + /// - la **création** d'un exercice (`fiscal_years::create`, Story 15-12a) + /// dont la date de début précède celle d'un exercice clos. + /// + /// Depuis la Story 15-12a, `fiscal_years::close` refuse de clôturer tant + /// qu'un exercice antérieur est ouvert ([`DbError::EarlierFiscalYearOpen`]) : + /// « N ouvert, N+1 clos » n'est plus atteignable **à partir d'un état sain**. + /// Il subsiste dans les données héritées (version antérieure, sauvegarde + /// restaurée) — d'où ces gardes. Le refus nomme le **plus proche** + /// postérieur clos. Mappé vers HTTP **400** `LATER_FISCAL_YEAR_CLOSED` — + /// l'état d'un exercice, comme `FISCAL_YEAR_CLOSED`, pas un conflit sur + /// l'objet. #[error("Exercice postérieur {fiscal_year_name} clôturé — écriture figée")] LaterFiscalYearClosed { fiscal_year_id: i64, fiscal_year_name: String, }, + /// La clôture d'un exercice est refusée : un exercice **antérieur** de la + /// même société est encore ouvert (Story 15-12a, #543). + /// + /// Invariant I (`repositories::fiscal_years`, doc du module) : les exercices + /// clôturés forment un **préfixe** de l'ordre chronologique. La variante + /// nomme le **plus ancien** antérieur ouvert — celui qu'il faut clôturer + /// d'abord (un plus proche serait refusé à son tour). Mappée vers HTTP + /// **409** `EARLIER_FISCAL_YEAR_OPEN` : un refus de transition, comme la + /// garde LIFO de la réouverture, mais avec un code dédié — l'écran traduit + /// tout `ILLEGAL_STATE_TRANSITION` de la clôture en « déjà clôturé ». + #[error("Exercice antérieur {fiscal_year_name} encore ouvert — clôture refusée")] + EarlierFiscalYearOpen { + fiscal_year_id: i64, + fiscal_year_name: String, + }, + /// Un ou plusieurs comptes référencés sont archivés ou n'appartiennent /// pas à la company courante. Variante dédiée pour exposer un message /// UX clair sans leak du détail interne. @@ -1036,6 +1061,7 @@ impl DbError { Self::IllegalStateTransition(_) => "ILLEGAL_STATE_TRANSITION", Self::FiscalYearClosed => "FISCAL_YEAR_CLOSED", Self::LaterFiscalYearClosed { .. } => "LATER_FISCAL_YEAR_CLOSED", + Self::EarlierFiscalYearOpen { .. } => "EARLIER_FISCAL_YEAR_OPEN", Self::InactiveOrInvalidAccounts => "INACTIVE_OR_INVALID_ACCOUNTS", Self::AccountsNotPostable(_) => "ACCOUNT_NOT_POSTABLE", Self::DesignatedAccountsNotPostable(_) => "ACCOUNT_NOT_POSTABLE", diff --git a/crates/kesh-db/src/repositories/fiscal_years.rs b/crates/kesh-db/src/repositories/fiscal_years.rs index 42e952042..d01a76725 100644 --- a/crates/kesh-db/src/repositories/fiscal_years.rs +++ b/crates/kesh-db/src/repositories/fiscal_years.rs @@ -8,11 +8,81 @@ //! **trace d'audit** (`fiscal_year.reopened`). La réouverture reste disciplinée //! par une garde LIFO (aucun exercice postérieur ne doit rester clos). //! +//! ## Story 15-12a — l'invariant I : les exercices se clôturent dans l'ordre +//! +//! **Invariant I** : *pour chaque société, les exercices clôturés forment un +//! préfixe de l'ordre chronologique* — aucun exercice ouvert ne précède un +//! exercice clôturé. Le bilan est cumulatif depuis l'origine +//! (`kesh-report/src/balance_sheet.rs`) : toute écriture de N figure dans le +//! bilan de N+1, si bien qu'« N ouvert, N+1 clos » laisserait un bilan clos +//! changer en silence (#543). +//! +//! Trois transitions le tiennent : +//! - **clôture** ([`close`]) : refusée tant qu'un exercice antérieur est ouvert +//! ([`DbError::EarlierFiscalYearOpen`]) — le préfixe ne s'allonge que par son +//! premier exercice ouvert ; +//! - **création** ([`create`]) : refusée si un exercice postérieur à la date de +//! début demandée est clôturé ([`DbError::LaterFiscalYearClosed`]) ; +//! - **réouverture** ([`reopen`]) : garde LIFO (Story 14-2) — le préfixe ne +//! raccourcit que par son dernier exercice. +//! +//! ⚠️ **Portée** : I est vrai de tout état obtenu **par ces trois transitions à +//! partir d'un état sain**. Il ne l'est pas des installations qui portent déjà +//! l'état fautif — données d'une version antérieure, restauration d'une +//! sauvegarde (`admin::full_import`, qui accepte une sauvegarde fautive) — ni +//! d'un état écrit hors de [`close`] / [`reopen`] (SQL direct, future +//! transition qui écrirait `fiscal_years.status` : la preuve tomberait). Ces +//! états ne repassent par aucune transition et ne peuvent que se résorber +//! (clôture du plus ancien ouvert, réouvertures LIFO). Tout consommateur de +//! l'invariant doit **tolérer** l'état hérité ou l'écrire comme limite. +//! [`create_for_seed`] et [`create_if_absent_in_tx`] ne peuvent pas produire +//! l'état fautif (une société neuve, un seul exercice ; ou aucun exercice +//! préexistant) et ne portent pas la garde. +//! +//! ⚠️ **Angle mort assumé — la restauration en vol** : `admin::full_import` ne +//! se sérialise qu'avec les autres imports (verrou `_kesh_version`), pas avec +//! la clôture ni les écrivains — cas préexistant de toute écriture en vol +//! pendant une restauration. +//! //! ## Story 3.7 — Lock ordering & audit //! //! Les fns mutatrices [`create`], [`update_name`], [`close`], [`reopen`] ouvrent //! leur propre transaction interne et auditent via `audit_log::insert_in_tx`. //! Ces fonctions mutatrices ne verrouillent que `fiscal_years` (et l'audit). +//! +//! **Ordre des verrous** (Story 15-12a, C119) : +//! - **la clôture** acquiert ses exercices antérieurs connus **un par un, par +//! clé primaire, dans l'ordre chronologique — ordre fixé par le code**, puis +//! l'exercice, puis relit les antérieurs ouverts sous verrou (étapes (a)-(e) +//! du doc-comment de [`close`]) ; +//! - **la réouverture, la garde 15-8a (`journal_entries::update` / +//! `delete_in_tx`) et la création** prennent leur exercice (ou leurs +//! pré-contrôles) puis **parcourent** les postérieurs +//! (`FIND_LATER_CLOSED_SQL`), dans l'ordre du **plan** — ascendant sous l'index +//! `uq_fiscal_years_company_start_date`, constaté par `EXPLAIN` (descriptif, +//! Story 15-12a T0) ; +//! - [`find_open_covering_date`] parcourt un intervalle dans l'ordre du plan : +//! convention de fréquence. +//! +//! ⛔ Ce n'est **pas** « l'ordre ascendant de toutes les acquisitions » : un +//! `ORDER BY` fixe le résultat d'un parcours, pas l'ordre de ses verrous. Les +//! flux qui verrouillent l'exercice d'une **origine** puis cherchent +//! l'exercice **du jour** — la contre-passation +//! (`journal_entries::reverse_in_tx_inner`) et les quatre annulations qui la +//! portent — prennent l'ordre inverse : leurs cycles avec la clôture **et avec +//! la création** existent, et se résolvent par le **rejeu** des deux côtés (les +//! cinq routes de ces flux et `create_fiscal_year` / `close_fiscal_year` sont +//! rejouées ; Story 15-5e1, Pattern 5 : l'ordre réduit la fréquence, le rejeu +//! est la défense). Deux flux **non rejoués** gardent un cycle possible, +//! écrits au registre des routes (`audit_route_registry.rs`, point (iv)) : le +//! renommage ([`update_name`]) avec la clôture (il tient l'exercice puis son +//! homonyme) — rare, et sans enjeu ; et la **réouverture** ([`reopen`]) avec la +//! contre-passation d'une écriture d'un exercice postérieur : la réouverture +//! tient Z puis parcourt les postérieurs, la contre-passation tient son origine +//! Q > Z puis parcourt depuis le premier exercice, donc demande Z — si la +//! réouverture est la victime, l'administrateur reçoit un 500 et recommence +//! (préexistant à la Story 15-12a, qui ne le change pas). +//! //! [`find_open_covering_date`], en revanche, verrouille l'exercice **au sein //! d'une transaction métier** qui prend d'autres verrous (validation d'une //! facture, règlements, rapprochements, écritures…) : l'ordre de chaque flux @@ -45,6 +115,47 @@ const FIND_BY_ID_SQL: &str = "SELECT id, company_id, name, start_date, end_date, const LIST_BY_COMPANY_SQL: &str = "SELECT id, company_id, name, start_date, end_date, status, created_at, updated_at \ FROM fiscal_years WHERE company_id = ? ORDER BY start_date DESC LIMIT ?"; +/// Verrou d'un exercice **par clé primaire, scopé à la société** — texte unique +/// de [`update_name`], [`reopen`] et de l'étape (c) de [`close`] (Story 15-12a, +/// extrait des deux copies qui existaient). +/// +/// ⚠️ Les tests de concurrence de la Story 15-12a reconnaissent l'étape (c) de +/// la clôture à ce texte (`attendre_une_requete_en_cours`, motifs +/// `"SELECT id, company_id"`, `"WHERE id = "`, `"FOR UPDATE"`) : sa liste de +/// colonnes l'oppose à [`LOCK_EARLIER_BY_ID_SQL`]. La changer, c'est changer +/// leurs motifs. +const LOCK_IN_COMPANY_SQL: &str = "SELECT id, company_id, name, start_date, end_date, status, created_at, updated_at \ + FROM fiscal_years WHERE id = ? AND company_id = ? FOR UPDATE"; + +/// Étape (a) de [`close`] : date de début de l'exercice, **sans verrou**. +const START_DATE_IN_COMPANY_SQL: &str = + "SELECT start_date FROM fiscal_years WHERE id = ? AND company_id = ?"; + +/// Étape (b) de [`close`] : les exercices **antérieurs**, **tous statuts**, sans +/// verrou, dans l'ordre chronologique (Story 15-12a, C119). +const LIST_EARLIER_SQL: &str = "SELECT id FROM fiscal_years \ + WHERE company_id = ? AND start_date < ? ORDER BY start_date ASC"; + +/// Étape (b') de [`close`] : verrou d'un antérieur **par clé primaire** — la +/// boucle Rust qui l'exécute fixe l'ordre des verrous, que l'optimiseur ne +/// choisit plus (C119). Liste de colonnes réduite à `id` : c'est ce qui +/// l'oppose, dans les motifs des tests, à [`LOCK_IN_COMPANY_SQL`]. +const LOCK_EARLIER_BY_ID_SQL: &str = + "SELECT id FROM fiscal_years WHERE id = ? AND company_id = ? FOR UPDATE"; + +/// Étape (d) de [`close`] : le **plus ancien** exercice antérieur **ouvert**, en +/// lecture **verrouillante** — donc sur l'état validé le plus récent, non sur la +/// vue fixée à l'étape (a). C'est elle qui rend le verdict +/// [`DbError::EarlierFiscalYearOpen`]. +/// +/// ⚠️ Porte aussi le texte `ORDER BY start_date ASC` … `FOR UPDATE` que les +/// tests de la Story 15-8a attendent pour [`FIND_LATER_CLOSED_SQL`] : aucune +/// clôture ne tourne pendant leurs attentes (vérifié, Story 15-12a T0). +const FIND_EARLIER_OPEN_SQL: &str = "SELECT id, company_id, name, start_date, end_date, status, created_at, updated_at \ + FROM fiscal_years \ + WHERE company_id = ? AND start_date < ? AND status = 'Open' \ + ORDER BY start_date ASC LIMIT 1 FOR UPDATE"; + // --- Story 3.7 — clés Invariant namespacées (Pass 2 HP2-M4) --- /// Pré-check `create` a détecté un chevauchement avec un exercice existant. @@ -144,9 +255,20 @@ fn build_audit_entry( /// 1. `tx = pool.begin()` /// 2. `find_overlapping FOR UPDATE` → `Invariant(FY_OVERLAP_KEY)` si chevauchement /// 3. `find_by_name FOR UPDATE` → `Invariant(FY_NAME_DUPLICATE_KEY)` si nom déjà pris -/// 4. INSERT -/// 5. INSERT audit_log avec snapshot direct -/// 6. COMMIT +/// 4. **Garde de l'invariant I** (Story 15-12a, #543) : [`find_later_closed_in_tx`] +/// (lecture verrouillante) → `DbError::LaterFiscalYearClosed` nommant le plus +/// proche exercice **postérieur clos** — un exercice ne se crée pas avant un +/// exercice clôturé. Après les pré-contrôles : un chevauchement parle d'abord. +/// 5. INSERT +/// 6. INSERT audit_log avec snapshot direct +/// 7. COMMIT +/// +/// Le verrou de l'étape 4 est éprouvé par le test 13 b3 de la Story 15-12a +/// (voir le commentaire au site). Interblocages possibles avec une clôture +/// concurrente (mesuré, Story 15-12a T0 : quand un exercice antérieur existe) et avec une contre-passation ou une +/// annulation d'une écriture postérieure : la route appelle cette fonction dans +/// `retry_on_deadlock("fiscal_years::create", …)` (transaction unique, sûre à +/// relancer). /// /// Les contraintes DB (`uq_fiscal_years_company_name`, /// `uq_fiscal_years_company_start_date`, `chk_fiscal_years_dates`) restent @@ -185,6 +307,25 @@ pub async fn create( return Err(DbError::Invariant(FY_NAME_DUPLICATE_KEY.to_string())); } + // Garde de l'invariant I (Story 15-12a) : aucun exercice clôturé après + // celui qu'on crée. Lecture verrouillante : elle tient les postérieurs + // qu'elle parcourt jusqu'au COMMIT, si bien qu'une clôture concurrente d'un + // postérieur attend la création (puis la voit en relecture (d)) ou + // interbloque avec elle (et la route rejoue). Ce que prouve chaque test + // (revue P1, A1) : le 13 b3 (`close_waits_for_a_creation_whose_guard_holds_ + // the_later_year`) prouve CE verrou — il rougit si on le retire ; les + // 13 b1 / b2 prouvent la propriété « jamais X ouvert sous Y clos » contre une + // clôture déjà en cours, mais la création y bute dès `find_overlapping` + // (verrou de borne de son parcours, dépendant du plan) et lit ensuite Y clos + // ici : ils tueraient le retrait de la garde, non son seul verrou. + if let Some(later) = find_later_closed_in_tx(&mut tx, new.company_id, new.start_date).await? { + tx.rollback().await.map_err(map_db_error)?; + return Err(DbError::LaterFiscalYearClosed { + fiscal_year_id: later.id, + fiscal_year_name: later.name, + }); + } + let id = insert_fiscal_year_in_tx(&mut tx, &new).await?; let fy = fetch_fiscal_year_in_tx(&mut tx, id).await?; @@ -206,6 +347,10 @@ pub async fn create( /// `bulk_create_from_chart` : le contexte système (seed) ne génère pas /// d'entrée d'audit. La tx interne fait toujours les pré-checks d'overlap /// et de nom pour respecter les UNIQUE constraints même en seed. +/// +/// **Sans la garde de l'invariant I** (Story 15-12a) : le seed crée un seul +/// exercice dans une société neuve (`kesh-seed`), il ne peut pas produire un +/// exercice ouvert antérieur à un exercice clos. pub async fn create_for_seed(pool: &MySqlPool, new: NewFiscalYear) -> Result { let mut tx = pool.begin().await.map_err(map_db_error)?; @@ -247,6 +392,9 @@ pub async fn create_for_seed(pool: &MySqlPool, new: NewFiscalYear) -> Result, user_id: i64, @@ -359,15 +507,12 @@ pub async fn update_name( // SELECT FOR UPDATE scopé `(id, company_id)` — fige le before-snapshot // ET empêche toute mutation cross-tenant si le pre-check du handler est // contourné (Code Review Pass 1 F2). - let before_opt = sqlx::query_as::<_, FiscalYear>( - "SELECT id, company_id, name, start_date, end_date, status, created_at, updated_at \ - FROM fiscal_years WHERE id = ? AND company_id = ? FOR UPDATE", - ) - .bind(id) - .bind(company_id) - .fetch_optional(&mut *tx) - .await - .map_err(map_db_error)?; + let before_opt = sqlx::query_as::<_, FiscalYear>(LOCK_IN_COMPANY_SQL) + .bind(id) + .bind(company_id) + .fetch_optional(&mut *tx) + .await + .map_err(map_db_error)?; let before = match before_opt { None => { @@ -641,8 +786,21 @@ pub async fn find_overlapping( /// (company_id, start_date)` l'est). Le `FOR UPDATE` s'appuie donc sur le /// **next-key locking** InnoDB du range scan `start_date > ?` pour verrouiller /// le gap et empêcher une clôture concurrente d'un exercice postérieur de se -/// glisser après le check. Hypothèse **testée** par le test de course -/// concurrente `reopen`/`close` (`fiscal_years_repository.rs`). +/// glisser après le check. L'hypothèse vaut sous le plan par l'index +/// `uq_fiscal_years_company_start_date`, constaté par `EXPLAIN` (descriptif, +/// Story 15-12a T0, une et plusieurs sociétés). Ce qui l'éprouve aujourd'hui : +/// pour la garde de [`create`], le test 13 b3 de la Story 15-12a +/// (`close_waits_for_a_creation_whose_guard_holds_the_later_year` — sous un plan +/// quelconque, le parcours tient le postérieur ouvert qu'il lit) ; pour la +/// modification et la suppression, les tests de concurrence de la Story 15-8a +/// (`journal_entries_modification.rs`, modification et suppression contre une +/// clôture en cours d'un exercice postérieur — transition simulée par SQL). +/// L'ancien test de course `reopen`/`close` a été remplacé par la Story 15-12a +/// (test 13 a, qui éprouve la relecture verrouillante de la clôture, non cette +/// hypothèse-ci). +/// +/// Appelée par [`reopen`] (garde LIFO), [`create`] (garde de l'invariant I, +/// Story 15-12a) et `journal_entries::update` / `delete_in_tx` (garde 15-8a). pub async fn find_later_closed_in_tx( tx: &mut sqlx::Transaction<'_, sqlx::MySql>, company_id: i64, @@ -664,7 +822,10 @@ pub async fn find_later_closed_in_tx( /// ⚠️ Les tests de concurrence de la Story 15-8a reconnaissent la requête /// verrouillante à son texte (`attendre_une_requete_en_cours`, motifs /// `ORDER BY start_date ASC` et `FOR UPDATE`) : le changer, c'est changer leurs -/// motifs. +/// motifs. Depuis la Story 15-12a, [`FIND_EARLIER_OPEN_SQL`] porte aussi ce +/// texte ; les attentes de la 15-8a restent sans ambiguïté tant qu'aucune +/// clôture ne tourne pendant elles (vérifié en T0 ; sinon, passer au motif +/// `"start_date > "`). const FIND_LATER_CLOSED_SQL: &str = "SELECT id, company_id, name, start_date, end_date, status, created_at, updated_at \ FROM fiscal_years \ WHERE company_id = ? AND start_date > ? AND status = 'Closed' \ @@ -754,22 +915,88 @@ pub async fn list_by_company( .map_err(map_db_error) } -/// Clôture un exercice (`Open` → `Closed`) avec audit log. +/// Clôture un exercice (`Open` → `Closed`) avec audit log — **dans l'ordre** : +/// refusée tant qu'un exercice antérieur de la société est ouvert (Story 15-12a, +/// #543 ; invariant I, doc du module). /// -/// Transaction atomique avec guard SQL `WHERE status = 'Open'`. La réouverture -/// d'un exercice clos est possible depuis la Story 14-2 (via [`reopen`], Admin + -/// motif + audit) ; `close` lui-même reste inchangé (verrou Comptable+ sans -/// motif). Re-clore un exercice déjà clos = erreur d'idempotence (voir plus bas). +/// La réouverture d'un exercice clos est possible depuis la Story 14-2 (via +/// [`reopen`], Admin + motif + audit) ; `close` reste un verrou Comptable+ sans +/// motif. Re-clore un exercice déjà clos = erreur d'idempotence. /// -/// Story 3.7 Code Review Pass 1 F2 — `company_id` ajouté à la signature et -/// au SQL `WHERE` pour défense en profondeur multi-tenant. Toute tentative -/// de clôture cross-tenant (handler bypass, futur caller direct) est -/// rejetée avec `NotFound` même si l'`id` existe sous une autre company. +/// Story 3.7 Code Review Pass 1 F2 — `company_id` scope toutes les requêtes : +/// une clôture cross-tenant est rejetée en `NotFound`. +/// +/// # Étapes, et l'ordre des verrous (Story 15-12a, C119) +/// +/// - **(a)** lecture **non verrouillante** de `start_date` de Y +/// (`START_DATE_IN_COMPANY_SQL`) — `start_date` est immuable (aucun `UPDATE` +/// ne l'écrit) ; absente → `NotFound` ; +/// - **(b)** liste des exercices **antérieurs**, **tous statuts**, sans verrou +/// (`LIST_EARLIER_SQL`, `ORDER BY start_date ASC`) — un antérieur clos dans la +/// vue peut être en cours de réouverture ; +/// - **(b')** **chacun verrouillé, un par un, dans cet ordre**, par clé primaire +/// (`LOCK_EARLIER_BY_ID_SQL`), dans une boucle Rust : l'ordre des verrous est +/// fixé par le code, non par le plan de l'optimiseur. Un exercice listé puis +/// disparu (`onboarding::reset`) est ignoré : la relecture (d) fait foi ; +/// - **(c)** verrou de Y par clé primaire (`LOCK_IN_COMPANY_SQL`) — Y disparu +/// depuis (a) → `NotFound` ; Y clos → « déjà clos » +/// (`IllegalStateTransition`) ; +/// - **(d)** **relecture verrouillante** du plus ancien antérieur ouvert +/// (`FIND_EARLIER_OPEN_SQL`) : lecture verrouillante, donc sur l'état +/// **validé le plus récent**, non sur la vue fixée en (a). C'est elle qui rend +/// le verdict [`DbError::EarlierFiscalYearOpen`] — l'invariant I en dépend, +/// quel que soit le plan ; +/// - **(e)** `UPDATE`, audit `fiscal_year.closed`, `COMMIT`. +/// +/// **Pourquoi un par un** : une lecture verrouillante par intervalle +/// (`… ORDER BY start_date ASC FOR UPDATE`) ne fixe que l'ordre de son +/// **résultat** ; InnoDB verrouille dans l'ordre du **parcours**, que choisit +/// l'optimiseur (index `uq_fiscal_years_company_start_date`, l'autre index +/// préfixé par `company_id`, ou la clé primaire suivie d'un `filesort`). L'ordre +/// des `id` diverge de celui des dates dès qu'un exercice antérieur est créé +/// après un postérieur. +/// +/// **Ce que cette forme perd, et ce qui le rend** : +/// - les verrous de clé suivante d'un parcours : un exercice antérieur à Y +/// **créé** après la vue de (a) — un fantôme — n'est ni listé en (b) ni +/// verrouillé en (b'). La relecture (d) le voit (état validé) — non éprouvé +/// par un test : aucun montage simple ne force une création validée entre (a) +/// et (c), la session qui arrête la clôture tenant un exercice que la +/// création parcourt aussi (revue P1, B-2). Et une +/// telle création ne peut plus se valider une fois Y tenu par (c) : sa garde +/// [`find_later_closed_in_tx`] examine Y et l'attend — sauf si un exercice +/// clos s'interpose, auquel cas elle est refusée de toute façon. Dans les +/// tests, la création bute même plus tôt, dans `find_overlapping`, dont le +/// parcours s'arrête sur Y (13 b1) ou sur M (13 b2). Réciproquement, une +/// création dont la garde a passé **tient** Y : la clôture l'attend en (c) et +/// la voit en (d) (13 b3, qui prouve le verrou de la garde) ; +/// - un coût : (b') tient en écriture **tous** les antérieurs, clos ou non, +/// jusqu'au `COMMIT` ; l'insertion d'une ligne de journal dans l'un d'eux +/// (verrou partagé de clé étrangère sur l'exercice parent) attend la clôture +/// (revue P1, B-6). Une clôture est brève et rare : accepté ; +/// - une requête par exercice antérieur (quelques-unes par société) ; +/// - (d) reste un parcours d'intervalle, dont les acquisitions **nouvelles** +/// (les fantômes, et sous un autre plan d'autres lignes de la société) +/// dépendent du plan : un cycle qui y naîtrait est absorbé par le rejeu. +/// +/// **Pourquoi cet ordre** : la clôture acquiert ses antérieurs **avant** Y. +/// Verrouiller Y d'abord l'exposerait au cycle avec une réouverture de Y-1 (qui +/// tient Y-1 puis demande les postérieurs) — et `reopen` n'est pas rejouée. +/// +/// ⚠️ **Ce n'est pas l'ordre de tous les flux** : la contre-passation et les +/// quatre annulations tiennent l'exercice d'une origine, puis parcourent depuis +/// le premier exercice pour trouver celui du jour. Cycle possible avec la +/// clôture quel que soit le plan (mesuré en T0 : la victime est la clôture) — +/// résolu par le **rejeu** des deux côtés : la route appelle cette fonction +/// dans `retry_on_deadlock("fiscal_years::close", …)` (au plus trois +/// tentatives ; un dépassement du délai d'attente, `1205`, n'est pas rejoué). /// /// Retourne : -/// - `DbError::NotFound` si l'exercice n'existe pas dans cette company -/// - `DbError::IllegalStateTransition` si l'exercice est déjà clos -/// (le guard `WHERE status = 'Open'` a échoué — transition interdite) +/// - `DbError::NotFound` si l'exercice n'existe pas dans cette company ; +/// - `DbError::IllegalStateTransition` s'il est déjà clos (prime sur la garde : +/// l'état de l'exercice lui-même parle d'abord, même dans un état hérité) ; +/// - `DbError::EarlierFiscalYearOpen` si un exercice antérieur est ouvert — le +/// **plus ancien**, qu'il faut clôturer d'abord. pub async fn close( pool: &MySqlPool, user_id: i64, @@ -778,6 +1005,74 @@ pub async fn close( ) -> Result { let mut tx = pool.begin().await.map_err(map_db_error)?; + // (a) Date de début, sans verrou. + let start_date: Option = sqlx::query_scalar(START_DATE_IN_COMPANY_SQL) + .bind(id) + .bind(company_id) + .fetch_optional(&mut *tx) + .await + .map_err(map_db_error)?; + let Some(start_date) = start_date else { + tx.rollback().await.map_err(map_db_error)?; + return Err(DbError::NotFound); + }; + + // (b) Les antérieurs, tous statuts, sans verrou, dans l'ordre chronologique. + let earlier: Vec = sqlx::query_scalar(LIST_EARLIER_SQL) + .bind(company_id) + .bind(start_date) + .fetch_all(&mut *tx) + .await + .map_err(map_db_error)?; + + // (b') Chacun verrouillé, un par un, dans cet ordre — l'ordre des verrous est + // celui de cette boucle. Un exercice disparu entre-temps est ignoré. + for earlier_id in earlier { + let _: Option = sqlx::query_scalar(LOCK_EARLIER_BY_ID_SQL) + .bind(earlier_id) + .bind(company_id) + .fetch_optional(&mut *tx) + .await + .map_err(map_db_error)?; + } + + // (c) L'exercice lui-même, par clé primaire. + let target = sqlx::query_as::<_, FiscalYear>(LOCK_IN_COMPANY_SQL) + .bind(id) + .bind(company_id) + .fetch_optional(&mut *tx) + .await + .map_err(map_db_error)?; + let Some(target) = target else { + tx.rollback().await.map_err(map_db_error)?; + return Err(DbError::NotFound); + }; + if target.status == FiscalYearStatus::Closed { + tx.rollback().await.map_err(map_db_error)?; + // Story 14-2 (D6) : re-clore un exercice déjà clos reste une erreur + // d'idempotence. Le `Display` (log-only) n'atteint jamais le client. + return Err(DbError::IllegalStateTransition(format!( + "fiscal_year {id} déjà clos (re-clôture no-op)" + ))); + } + + // (d) Relecture verrouillante : le plus ancien antérieur ouvert, sur l'état + // validé le plus récent. + let earliest_open = sqlx::query_as::<_, FiscalYear>(FIND_EARLIER_OPEN_SQL) + .bind(company_id) + .bind(target.start_date) + .fetch_optional(&mut *tx) + .await + .map_err(map_db_error)?; + if let Some(open) = earliest_open { + tx.rollback().await.map_err(map_db_error)?; + return Err(DbError::EarlierFiscalYearOpen { + fiscal_year_id: open.id, + fiscal_year_name: open.name, + }); + } + + // (e) La transition. let rows_affected = sqlx::query( "UPDATE fiscal_years SET status = 'Closed' \ WHERE id = ? AND company_id = ? AND status = 'Open'", @@ -788,38 +1083,14 @@ pub async fn close( .await .map_err(map_db_error)? .rows_affected(); - if rows_affected == 0 { - // Soit l'exercice n'existe pas dans cette company, soit il est déjà clos. - let current: Option<(String,)> = - sqlx::query_as("SELECT status FROM fiscal_years WHERE id = ? AND company_id = ?") - .bind(id) - .bind(company_id) - .fetch_optional(&mut *tx) - .await - .map_err(map_db_error)?; + // Défensif : Y est tenu par (c) et lu ouvert ; seul un trigger + // inattendu ou une isolation plus faible mènerait ici. tx.rollback().await.map_err(map_db_error)?; - return match current { - None => Err(DbError::NotFound), - // Story 14-2 (D6) : re-clore un exercice déjà clos reste une erreur - // d'idempotence de `close`. Le message (log-only) ne prétend PLUS - // que la réouverture est impossible — elle l'est désormais via - // `reopen` (Admin + motif + audit). Le `Display` n'atteint jamais le - // client (`kesh-db/errors.rs` : « Ne jamais exposer le Display »). - Some((status,)) if status == "Closed" => Err(DbError::IllegalStateTransition(format!( - "fiscal_year {id} déjà clos (re-clôture no-op)" - ))), - // Défensif : sous REPEATABLE READ InnoDB et dans la même transaction, - // le SELECT post-UPDATE devrait voir cohérent. Cette branche ne peut - // survenir que via un trigger inattendu ou une isolation plus faible. - Some((status,)) if status == "Open" => Err(DbError::Invariant(format!( - "fiscal_year {id} est Open mais l'UPDATE n'a affecté aucune ligne \ - (race condition ou trigger inattendu)" - ))), - Some((status,)) => Err(DbError::Invariant(format!( - "fiscal_year {id} a un statut inattendu hors schéma : {status}" - ))), - }; + return Err(DbError::Invariant(format!( + "fiscal_year {id} est Open mais l'UPDATE n'a affecté aucune ligne \ + (race condition ou trigger inattendu)" + ))); } let fy = fetch_fiscal_year_in_tx(&mut tx, id).await?; @@ -881,15 +1152,12 @@ pub async fn reopen( // SELECT FOR UPDATE scopé `(id, company_id)` — fige le before-snapshot ET // empêche toute mutation cross-tenant si le pré-check du handler est // contourné (miroir `update_name`). - let before_opt = sqlx::query_as::<_, FiscalYear>( - "SELECT id, company_id, name, start_date, end_date, status, created_at, updated_at \ - FROM fiscal_years WHERE id = ? AND company_id = ? FOR UPDATE", - ) - .bind(id) - .bind(company_id) - .fetch_optional(&mut *tx) - .await - .map_err(map_db_error)?; + let before_opt = sqlx::query_as::<_, FiscalYear>(LOCK_IN_COMPANY_SQL) + .bind(id) + .bind(company_id) + .fetch_optional(&mut *tx) + .await + .map_err(map_db_error)?; let before = match before_opt { None => { diff --git a/crates/kesh-db/src/repositories/journal_entries.rs b/crates/kesh-db/src/repositories/journal_entries.rs index 4e3629b3f..9d5a5a6d6 100644 --- a/crates/kesh-db/src/repositories/journal_entries.rs +++ b/crates/kesh-db/src/repositories/journal_entries.rs @@ -2172,6 +2172,20 @@ pub async fn reverse_owned_in_tx( } /// La contre-passation, **écrite une seule fois** (25-3-zero, 25-3-a-1). +/// +/// ⚠️ **Ordre des verrous et cycles** (Story 15-12a, #543) : l'étape (1) +/// verrouille l'origine et **son** exercice par clé primaire ; l'étape (4) +/// cherche l'exercice **du jour** (`find_open_covering_date`), dont le parcours +/// part du premier exercice de la société et verrouille donc les exercices +/// **antérieurs** à l'origine **après** elle. C'est l'ordre inverse de la +/// clôture (`fiscal_years::close`, qui tient ses antérieurs puis l'exercice) et +/// de la création (`fiscal_years::create`, qui tient ses antérieurs puis ses +/// postérieurs) : une contre-passation d'une écriture de N peut interbloquer +/// avec `close(N)` ou avec la création d'un exercice antérieur à N. Ces cycles +/// existent quel que soit le plan, et se résolvent par le **rejeu** des deux +/// côtés : les routes de la contre-passation et des quatre annulations qui +/// passent par ici sont `Rejouee`, la clôture et la création d'un exercice le +/// sont aussi (Story 15-12a, AC 6 ; test 13 d de `fiscal_years_repository.rs`). async fn reverse_in_tx_inner( tx: &mut sqlx::Transaction<'_, sqlx::MySql>, company_id: i64, @@ -2295,7 +2309,9 @@ async fn reverse_in_tx_inner( ) .collect(); - // (4) Exercice ouvert du JOUR. + // (4) Exercice ouvert du JOUR — verrouillé APRÈS l'exercice de l'origine : + // cycle possible avec une clôture ou une création d'exercice, résolu par le + // rejeu (doc-comment ci-dessus, Story 15-12a). let today = Utc::now().date_naive(); let fy = super::fiscal_years::find_open_covering_date(tx, company_id, today) .await? diff --git a/crates/kesh-db/tests/fiscal_years_repository.rs b/crates/kesh-db/tests/fiscal_years_repository.rs index 8c20d4778..34493a872 100644 --- a/crates/kesh-db/tests/fiscal_years_repository.rs +++ b/crates/kesh-db/tests/fiscal_years_repository.rs @@ -11,7 +11,11 @@ use kesh_db::repositories::fiscal_years::{ FY_OVERLAP_KEY, FY_REOPEN_ALREADY_OPEN_KEY, FY_REOPEN_LIFO_BLOCKED_KEY, }; use kesh_db::repositories::{audit_log, companies, fiscal_years, users}; +use kesh_db::retry::retry_on_deadlock; +use kesh_db::test_fixtures::attendre_une_requete_en_cours; use sqlx::MySqlPool; +use std::sync::Arc; +use std::sync::atomic::{AtomicU32, Ordering}; fn sample_new_company() -> NewCompany { NewCompany { @@ -869,6 +873,10 @@ async fn reopen_lifo_permissive_when_later_open(pool: MySqlPool) { /// P4-LOW — LIFO 3 exercices intercalés (Closed-Open-Closed) : reopen FY1 bloqué /// en citant FY3 (le plus proche postérieur clos via ORDER BY start_date ASC /// LIMIT 1 — prouve que la query n'a pas besoin de notion d'adjacence). +/// +/// ⚠️ Story 15-12a : « FY2 ouvert sous FY3 clos » est l'état **hérité** que la +/// clôture refuse désormais de produire — FY3 est donc clos **par SQL**. Le test +/// garde la garde LIFO pour les données qui portent déjà cet état. #[sqlx::test(migrations = "./test-schema")] async fn reopen_lifo_three_years_intercalated(pool: MySqlPool) { let company_id = create_company(&pool).await; @@ -884,13 +892,11 @@ async fn reopen_lifo_three_years_intercalated(pool: MySqlPool) { y3.company_id = company_id; let fy3 = fiscal_years::create(&pool, user_id, y3).await.unwrap(); - // FY1 clos, FY2 ouvert, FY3 clos. + // FY1 clos, FY2 ouvert, FY3 clos — ce dernier par SQL (état hérité). fiscal_years::close(&pool, user_id, company_id, fy1.id) .await .unwrap(); - fiscal_years::close(&pool, user_id, company_id, fy3.id) - .await - .unwrap(); + poser_clos(&pool, fy3.id).await; // Rouvrir FY1 est bloqué : FY3 (postérieur, clos) existe — même avec FY2 // ouvert intercalé, la garde ne dépend pas de l'adjacence. @@ -1047,71 +1053,6 @@ async fn close_already_closed_message_no_longer_claims_reopen_forbidden(pool: My } } -/// P1-M6 — course concurrente `reopen(FY_N)` vs `close(FY_{N+1})` sérialisée par -/// le `FOR UPDATE` : l'issue est déterministe (pas de deadlock, pas de panic) et -/// l'état final est cohérent. Documente l'hypothèse next-key locking de la -/// garde LIFO (filtre `status` non indexé). -#[sqlx::test(migrations = "./test-schema")] -async fn reopen_close_concurrent_is_serialized(pool: MySqlPool) { - let company_id = create_company(&pool).await; - let user_id = create_admin_user(&pool, company_id).await; - - // FY_N clos, FY_{N+1} ouvert. - let mut y2025 = ny("Exercice 2025", 2025); - y2025.company_id = company_id; - let fy2025 = fiscal_years::create(&pool, user_id, y2025).await.unwrap(); - let mut y2026 = ny("Exercice 2026", 2026); - y2026.company_id = company_id; - let fy2026 = fiscal_years::create(&pool, user_id, y2026).await.unwrap(); - fiscal_years::close(&pool, user_id, company_id, fy2025.id) - .await - .unwrap(); - - // reopen(FY2025) [garde LIFO lit FY2026] vs close(FY2026) [Open→Closed], - // concurrents. Le FOR UPDATE sérialise → pas de deadlock DANS CE MONTAGE - // (deux transactions sur la seule table `fiscal_years`), chaque future - // résout en Ok ou en une DbError définie. - let p1 = pool.clone(); - let p2 = pool.clone(); - let reopen_fut = tokio::spawn(async move { - fiscal_years::reopen(&p1, user_id, company_id, fy2025.id, "motif".to_string()).await - }); - let close_fut = - tokio::spawn(async move { fiscal_years::close(&p2, user_id, company_id, fy2026.id).await }); - - let (reopen_res, close_res) = tokio::join!(reopen_fut, close_fut); - let reopen_res = reopen_res.expect("reopen task should not panic"); - let close_res = close_res.expect("close task should not panic"); - - // Aucune des deux ne doit produire une erreur inattendue (Sqlx/deadlock). - // reopen : soit Ok (a gagné la course avant close), soit LIFO blocked (close - // a commit d'abord). close : Ok (FY2026 était Open). - assert!( - reopen_res.is_ok() - || matches!(&reopen_res, Err(DbError::Invariant(k)) if k == FY_REOPEN_LIFO_BLOCKED_KEY), - "reopen outcome must be Ok or LIFO-blocked, got {reopen_res:?}" - ); - assert!( - close_res.is_ok(), - "close(FY2026 Open) should succeed, got {close_res:?}" - ); - - // État final : chaque exercice a un statut valide (pas de corruption). - let fy2025_final = fiscal_years::find_by_id(&pool, fy2025.id) - .await - .unwrap() - .unwrap(); - let fy2026_final = fiscal_years::find_by_id(&pool, fy2026.id) - .await - .unwrap() - .unwrap(); - assert_eq!(fy2026_final.status, FiscalYearStatus::Closed); - assert!(matches!( - fy2025_final.status, - FiscalYearStatus::Open | FiscalYearStatus::Closed - )); -} - // --------------------------------------------------------------------------- // find_first_by_company() — Story 14-4 (bilan d'ouverture) // --------------------------------------------------------------------------- @@ -1192,3 +1133,792 @@ async fn find_first_by_company_is_tenant_scoped(pool: MySqlPool) { assert_eq!(first_a.name, "Exercice A 2025"); assert_eq!(first_a.company_id, company_a); } + +// --------------------------------------------------------------------------- +// Story 15-12a (#543) — les exercices se clôturent dans l'ordre (invariant I) +// --------------------------------------------------------------------------- + +/// Pose `status = 'Closed'` **par SQL**, hors de `close` : le seul moyen, depuis +/// la Story 15-12a, de fabriquer l'état hérité « exercice ouvert suivi d'un +/// exercice clos ». +async fn poser_clos(pool: &MySqlPool, id: i64) { + sqlx::query("UPDATE fiscal_years SET status = 'Closed' WHERE id = ?") + .bind(id) + .execute(pool) + .await + .unwrap(); +} + +/// Crée l'exercice `année` (1er janvier – 31 décembre) de la société. +async fn exercice(pool: &MySqlPool, user_id: i64, company_id: i64, annee: i32) -> i64 { + let mut new = ny(&format!("Exercice {annee}"), annee); + new.company_id = company_id; + fiscal_years::create(pool, user_id, new).await.unwrap().id +} + +async fn statut(pool: &MySqlPool, id: i64) -> Option { + fiscal_years::find_by_id(pool, id) + .await + .unwrap() + .map(|fy| fy.status) +} + +/// Nombre de lignes d'audit `action` pour l'exercice `id`. +async fn audits(pool: &MySqlPool, action: &str, id: i64) -> i64 { + sqlx::query_scalar( + "SELECT COUNT(*) FROM audit_log WHERE action = ? AND entity_type = 'fiscal_year' \ + AND entity_id = ?", + ) + .bind(action) + .bind(id) + .fetch_one(pool) + .await + .unwrap() +} + +/// Nombre d'exercices de la société. +async fn nombre_exercices(pool: &MySqlPool, company_id: i64) -> i64 { + sqlx::query_scalar("SELECT COUNT(*) FROM fiscal_years WHERE company_id = ?") + .bind(company_id) + .fetch_one(pool) + .await + .unwrap() +} + +/// Motif `attendre_une_requete_en_cours` de l'étape (c) de `close` — le verrou +/// de l'exercice par clé primaire (`LOCK_IN_COMPANY_SQL`) : sa liste de colonnes +/// l'oppose à l'étape (b'), `WHERE id = ` aux requêtes par société, `FOR UPDATE` +/// à l'étape (a). +const MOTIF_ETAPE_C: &[&str] = &["SELECT id, company_id", "WHERE id = ", "FOR UPDATE"]; + +/// Motif du pré-contrôle de chevauchement de la création (`find_overlapping`) — +/// `end_date >= ` l'oppose à toutes les requêtes de `close`. +const MOTIF_CHEVAUCHEMENT: &[&str] = &["end_date >= ", "LIMIT 1 FOR UPDATE"]; + +/// AC 1 — refus : un exercice antérieur est ouvert → `EarlierFiscalYearOpen`, +/// **rien d'écrit** (ni statut, ni audit `fiscal_year.closed`). +/// +/// ⛔ **Tue la mutation (i)** (retirer la garde de `close`). +#[sqlx::test(migrations = "./test-schema")] +async fn close_is_refused_while_an_earlier_year_is_open(pool: MySqlPool) { + let company_id = create_company(&pool).await; + let user_id = create_admin_user(&pool, company_id).await; + let y2025 = exercice(&pool, user_id, company_id, 2025).await; + let y2026 = exercice(&pool, user_id, company_id, 2026).await; + + let result = fiscal_years::close(&pool, user_id, company_id, y2026).await; + match result { + Err(DbError::EarlierFiscalYearOpen { + fiscal_year_id, + fiscal_year_name, + }) => { + assert_eq!(fiscal_year_id, y2025); + assert_eq!(fiscal_year_name, "Exercice 2025"); + } + other => panic!("attendu EarlierFiscalYearOpen, obtenu {other:?}"), + } + assert_eq!(statut(&pool, y2026).await, Some(FiscalYearStatus::Open)); + assert_eq!(audits(&pool, "fiscal_year.closed", y2026).await, 0); + + // Dans l'ordre, les deux passent. + fiscal_years::close(&pool, user_id, company_id, y2025) + .await + .unwrap(); + fiscal_years::close(&pool, user_id, company_id, y2026) + .await + .unwrap(); + assert_eq!(statut(&pool, y2026).await, Some(FiscalYearStatus::Closed)); +} + +/// AC 1 — le refus nomme le **plus ancien** antérieur ouvert, et le trouve par +/// la **date**, non par l'`id` : 2024 est créé **après** 2025, ses `id` sont +/// donc inversés par rapport aux dates. +#[sqlx::test(migrations = "./test-schema")] +async fn close_names_the_oldest_earlier_open_year_whatever_the_ids(pool: MySqlPool) { + let company_id = create_company(&pool).await; + let user_id = create_admin_user(&pool, company_id).await; + let y2025 = exercice(&pool, user_id, company_id, 2025).await; + let y2024 = exercice(&pool, user_id, company_id, 2024).await; + let y2026 = exercice(&pool, user_id, company_id, 2026).await; + assert!(y2024 > y2025, "montage : 2024 doit avoir le plus grand id"); + + let result = fiscal_years::close(&pool, user_id, company_id, y2026).await; + assert!( + matches!(&result, Err(DbError::EarlierFiscalYearOpen { fiscal_year_id, fiscal_year_name }) + if *fiscal_year_id == y2024 && fiscal_year_name == "Exercice 2024"), + "attendu EarlierFiscalYearOpen nommant 2024, obtenu {result:?}" + ); + // 2025 se clôture-t-il ? Non : 2024 le précède, ouvert. + let result = fiscal_years::close(&pool, user_id, company_id, y2025).await; + assert!( + matches!(&result, Err(DbError::EarlierFiscalYearOpen { fiscal_year_id, .. }) if *fiscal_year_id == y2024), + "attendu EarlierFiscalYearOpen nommant 2024, obtenu {result:?}" + ); +} + +/// AC 1 — les exercices d'une **autre société** sont ignorés. +#[sqlx::test(migrations = "./test-schema")] +async fn close_ignores_the_open_years_of_another_company(pool: MySqlPool) { + let company_id = create_company(&pool).await; + let user_id = create_admin_user(&pool, company_id).await; + let other = create_other_company(&pool).await; + let other_user = create_admin_user(&pool, other).await; + let _autre_2025 = exercice(&pool, other_user, other, 2025).await; + let y2026 = exercice(&pool, user_id, company_id, 2026).await; + + fiscal_years::close(&pool, user_id, company_id, y2026) + .await + .unwrap(); + assert_eq!(statut(&pool, y2026).await, Some(FiscalYearStatus::Closed)); +} + +/// AC 2 — précédence, paire `NotFound` > `EarlierFiscalYearOpen` : un exercice +/// inexistant, ou d'une autre société, rend `NotFound` même si la société a un +/// exercice ouvert antérieur à tout. +#[sqlx::test(migrations = "./test-schema")] +async fn close_precedence_not_found_before_earlier_open(pool: MySqlPool) { + let company_id = create_company(&pool).await; + let user_id = create_admin_user(&pool, company_id).await; + let _y2020 = exercice(&pool, user_id, company_id, 2020).await; + let other = create_other_company(&pool).await; + let other_user = create_admin_user(&pool, other).await; + let autre_2026 = exercice(&pool, other_user, other, 2026).await; + + let missing = fiscal_years::close(&pool, user_id, company_id, 999_999).await; + assert!(matches!(missing, Err(DbError::NotFound)), "{missing:?}"); + let cross = fiscal_years::close(&pool, user_id, company_id, autre_2026).await; + assert!(matches!(cross, Err(DbError::NotFound)), "{cross:?}"); + assert_eq!( + statut(&pool, autre_2026).await, + Some(FiscalYearStatus::Open) + ); +} + +/// AC 2 — précédence, paire « déjà clos » > `EarlierFiscalYearOpen` : dans +/// l'état **hérité** (2025 ouvert, 2026 clos par SQL), re-clore 2026 répond +/// « déjà clos » — l'état de l'exercice lui-même parle d'abord. +#[sqlx::test(migrations = "./test-schema")] +async fn close_precedence_already_closed_before_earlier_open(pool: MySqlPool) { + let company_id = create_company(&pool).await; + let user_id = create_admin_user(&pool, company_id).await; + let _y2025 = exercice(&pool, user_id, company_id, 2025).await; + let y2026 = exercice(&pool, user_id, company_id, 2026).await; + poser_clos(&pool, y2026).await; + + let result = fiscal_years::close(&pool, user_id, company_id, y2026).await; + assert!( + matches!(result, Err(DbError::IllegalStateTransition(_))), + "attendu IllegalStateTransition, obtenu {result:?}" + ); +} + +/// AC 3 (c), F6 — l'exercice **disparaît** entre l'étape (a) et l'étape (c) : +/// `NotFound`, jamais une panique. W tient Y ; la clôture bute en (c) ; W +/// supprime Y (sans écriture) et valide. +#[sqlx::test(migrations = "./test-schema")] +async fn close_returns_not_found_when_the_year_vanishes_before_its_lock(pool: MySqlPool) { + let company_id = create_company(&pool).await; + let user_id = create_admin_user(&pool, company_id).await; + let y2026 = exercice(&pool, user_id, company_id, 2026).await; + + let mut w = pool.begin().await.unwrap(); + sqlx::query("SELECT id FROM fiscal_years WHERE id = ? FOR UPDATE") + .bind(y2026) + .execute(&mut *w) + .await + .unwrap(); + let cloture = { + let pool = pool.clone(); + tokio::spawn(async move { fiscal_years::close(&pool, user_id, company_id, y2026).await }) + }; + assert!( + attendre_une_requete_en_cours(&pool, MOTIF_ETAPE_C, || cloture.is_finished()).await, + "la clôture doit être vue en cours à l'étape (c)" + ); + sqlx::query("DELETE FROM fiscal_years WHERE id = ?") + .bind(y2026) + .execute(&mut *w) + .await + .unwrap(); + w.commit().await.unwrap(); + + let result = cloture.await.expect("la clôture ne doit pas paniquer"); + assert!(matches!(result, Err(DbError::NotFound)), "{result:?}"); +} + +/// AC 5 — création : refusée si un exercice **postérieur** est clos ; le refus +/// nomme le **plus proche** ; rien n'est inséré, rien n'est audité. +/// +/// ⛔ **Tue la mutation (iv)** (retirer la garde de `create`). +#[sqlx::test(migrations = "./test-schema")] +async fn create_is_refused_before_a_closed_year(pool: MySqlPool) { + let company_id = create_company(&pool).await; + let user_id = create_admin_user(&pool, company_id).await; + let y2026 = exercice(&pool, user_id, company_id, 2026).await; + let y2027 = exercice(&pool, user_id, company_id, 2027).await; + fiscal_years::close(&pool, user_id, company_id, y2026) + .await + .unwrap(); + fiscal_years::close(&pool, user_id, company_id, y2027) + .await + .unwrap(); + let avant = nombre_exercices(&pool, company_id).await; + + let mut new = ny("Exercice 2025", 2025); + new.company_id = company_id; + let result = fiscal_years::create(&pool, user_id, new).await; + match result { + Err(DbError::LaterFiscalYearClosed { + fiscal_year_id, + fiscal_year_name, + }) => { + assert_eq!(fiscal_year_id, y2026, "le plus proche postérieur clos"); + assert_eq!(fiscal_year_name, "Exercice 2026"); + } + other => panic!("attendu LaterFiscalYearClosed, obtenu {other:?}"), + } + assert_eq!(nombre_exercices(&pool, company_id).await, avant); + let audits_creation: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM audit_log WHERE action = 'fiscal_year.created' \ + AND JSON_UNQUOTE(JSON_EXTRACT(details_json, '$.name')) = 'Exercice 2025'", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(audits_creation, 0); +} + +/// AC 5 — un exercice postérieur **ouvert** ne gêne pas : créer un exercice +/// antérieur reste permis (l'invariant I ne porte que sur les clos). +#[sqlx::test(migrations = "./test-schema")] +async fn create_before_an_open_year_is_allowed(pool: MySqlPool) { + let company_id = create_company(&pool).await; + let user_id = create_admin_user(&pool, company_id).await; + let _y2026 = exercice(&pool, user_id, company_id, 2026).await; + let y2025 = exercice(&pool, user_id, company_id, 2025).await; + assert_eq!(statut(&pool, y2025).await, Some(FiscalYearStatus::Open)); +} + +/// AC 5 — précédence : une demande qui **chevauche** un exercice et précède un +/// exercice clos rend le chevauchement (pré-contrôle existant, qui parle +/// d'abord). +#[sqlx::test(migrations = "./test-schema")] +async fn create_precedence_overlap_before_later_closed(pool: MySqlPool) { + let company_id = create_company(&pool).await; + let user_id = create_admin_user(&pool, company_id).await; + let y2024 = exercice(&pool, user_id, company_id, 2024).await; + let y2026 = exercice(&pool, user_id, company_id, 2026).await; + // 2025 n'existe pas : clôturer 2024 puis 2026 respecte l'ordre. + fiscal_years::close(&pool, user_id, company_id, y2024) + .await + .unwrap(); + fiscal_years::close(&pool, user_id, company_id, y2026) + .await + .unwrap(); + + let new = NewFiscalYear { + company_id, + name: "Exercice décalé".into(), + start_date: NaiveDate::from_ymd_opt(2024, 7, 1).unwrap(), + end_date: NaiveDate::from_ymd_opt(2025, 6, 30).unwrap(), + }; + let result = fiscal_years::create(&pool, user_id, new).await; + assert!( + matches!(&result, Err(DbError::Invariant(k)) if k == FY_OVERLAP_KEY), + "attendu FY_OVERLAP_KEY, obtenu {result:?}" + ); +} + +/// AC 13 a — **la preuve de l'invariant I sous concurrence** : une réouverture +/// de N **en cours** contre `close(L)` (N clos, L ouvert, L > N). +/// +/// W refait les gestes de `reopen` sans les valider (verrou de N, lecture des +/// postérieurs clos, `UPDATE … 'Open'`). La clôture est lancée dans son +/// enveloppe et vue **en cours** (l'aide voit une requête qui s'exécute ; c'est +/// le montage — W tient N, que l'étape (b') demande — qui en fait une attente). +/// W valide : la relecture verrouillante (d) lit N **ouvert** → refus. +/// +/// ⛔ **Tue la mutation (ii)** (relecture (d) non verrouillante) : la clôture +/// lirait (d) dans sa vue, fixée en (a) avant la validation de W — N clos —, et +/// passerait : état final « N ouvert, L clos ». Remplace l'ancien +/// `reopen_close_concurrent_is_serialized`, course libre qui admettait cet état. +#[sqlx::test(migrations = "./test-schema")] +async fn close_sees_a_concurrent_reopening_of_an_earlier_year(pool: MySqlPool) { + let company_id = create_company(&pool).await; + let user_id = create_admin_user(&pool, company_id).await; + let n = exercice(&pool, user_id, company_id, 2025).await; + let l = exercice(&pool, user_id, company_id, 2026).await; + fiscal_years::close(&pool, user_id, company_id, n) + .await + .unwrap(); + let n_start = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(); + + let mut w = pool.begin().await.unwrap(); + sqlx::query("SELECT id FROM fiscal_years WHERE id = ? FOR UPDATE") + .bind(n) + .execute(&mut *w) + .await + .unwrap(); + assert!( + fiscal_years::find_later_closed_in_tx(&mut w, company_id, n_start) + .await + .unwrap() + .is_none() + ); + sqlx::query("UPDATE fiscal_years SET status = 'Open' WHERE id = ?") + .bind(n) + .execute(&mut *w) + .await + .unwrap(); + + let cloture = { + let pool = pool.clone(); + tokio::spawn(async move { + retry_on_deadlock("fiscal_years::close", || { + fiscal_years::close(&pool, user_id, company_id, l) + }) + .await + }) + }; + assert!( + attendre_une_requete_en_cours(&pool, &["fiscal_years", "FOR UPDATE"], || cloture + .is_finished()) + .await, + "la clôture doit être vue en cours, bloquée sur N" + ); + w.commit().await.unwrap(); + + let result = cloture.await.unwrap(); + assert!( + matches!(&result, Err(DbError::EarlierFiscalYearOpen { fiscal_year_id, .. }) if *fiscal_year_id == n), + "attendu EarlierFiscalYearOpen nommant N, obtenu {result:?}" + ); + assert_eq!(statut(&pool, n).await, Some(FiscalYearStatus::Open)); + assert_eq!(statut(&pool, l).await, Some(FiscalYearStatus::Open)); + assert_eq!(audits(&pool, "fiscal_year.closed", l).await, 0); +} + +/// AC 13 c — une clôture de N **en cours** contre `close(N+1)` (N et N+1 +/// ouverts) : garde contre un refus **parasite**. W pose `'Closed'` sur N sans +/// valider ; `close(N+1)` est vue en cours, bloquée sur N à l'étape (b') ; W +/// valide ; `close(N+1)` **réussit**. Forme ordonnée : lancées librement, +/// `close(N+1)` pourrait lire N ouvert la première et refuser légitimement. +/// +/// ⛔ **Tue la mutation (ii)** : sous une relecture (d) non verrouillante, la +/// vue fixée en (a) montre N ouvert → refus parasite. +#[sqlx::test(migrations = "./test-schema")] +async fn close_after_a_concurrent_close_of_the_previous_year_succeeds(pool: MySqlPool) { + let company_id = create_company(&pool).await; + let user_id = create_admin_user(&pool, company_id).await; + let n = exercice(&pool, user_id, company_id, 2025).await; + let suivant = exercice(&pool, user_id, company_id, 2026).await; + + let mut w = pool.begin().await.unwrap(); + sqlx::query("UPDATE fiscal_years SET status = 'Closed' WHERE id = ?") + .bind(n) + .execute(&mut *w) + .await + .unwrap(); + let cloture = { + let pool = pool.clone(); + tokio::spawn(async move { + retry_on_deadlock("fiscal_years::close", || { + fiscal_years::close(&pool, user_id, company_id, suivant) + }) + .await + }) + }; + assert!( + attendre_une_requete_en_cours(&pool, &["fiscal_years", "FOR UPDATE"], || cloture + .is_finished()) + .await, + "la clôture de N+1 doit être vue en cours, bloquée sur N" + ); + w.commit().await.unwrap(); + + let result = cloture.await.unwrap(); + assert!(result.is_ok(), "close(N+1) doit réussir : {result:?}"); + assert_eq!(statut(&pool, n).await, Some(FiscalYearStatus::Closed)); + assert_eq!(statut(&pool, suivant).await, Some(FiscalYearStatus::Closed)); +} + +/// Déroule le test 13 b : `create(X)` contre `close(Y)`, X antérieur à Y, tous +/// deux dans leur enveloppe de rejeu. Entrelacement forcé : W tient Y ; la +/// clôture est vue en étape (c) ; la création est vue à l'étape où elle bute +/// (`motif_creation` — dans les deux configurations mesurées, son pré-contrôle +/// de chevauchement : sur Y en b1, sur M en b2) ; W valide. Rend les deux +/// issues et le nombre de tentatives de chaque côté. +async fn creation_contre_cloture( + pool: &MySqlPool, + user_id: i64, + company_id: i64, + y: i64, + x_annee: i32, + motif_creation: &[&str], +) -> ( + Result, + Result, + u32, + u32, +) { + let mut w = pool.begin().await.unwrap(); + sqlx::query("SELECT id FROM fiscal_years WHERE id = ? FOR UPDATE") + .bind(y) + .execute(&mut *w) + .await + .unwrap(); + + let essais_cloture = Arc::new(AtomicU32::new(0)); + let cloture = { + let pool = pool.clone(); + let essais = essais_cloture.clone(); + tokio::spawn(async move { + retry_on_deadlock("fiscal_years::close", || { + essais.fetch_add(1, Ordering::SeqCst); + fiscal_years::close(&pool, user_id, company_id, y) + }) + .await + }) + }; + assert!( + attendre_une_requete_en_cours(pool, MOTIF_ETAPE_C, || cloture.is_finished()).await, + "la clôture doit être vue à l'étape (c), bloquée sur Y" + ); + + let essais_creation = Arc::new(AtomicU32::new(0)); + let creation = { + let pool = pool.clone(); + let essais = essais_creation.clone(); + let mut new = ny(&format!("Exercice {x_annee}"), x_annee); + new.company_id = company_id; + tokio::spawn(async move { + retry_on_deadlock("fiscal_years::create", || { + essais.fetch_add(1, Ordering::SeqCst); + fiscal_years::create(&pool, user_id, new.clone()) + }) + .await + }) + }; + assert!( + attendre_une_requete_en_cours(pool, motif_creation, || creation.is_finished()).await, + "la création doit être vue en cours, bloquée ({motif_creation:?})" + ); + w.commit().await.unwrap(); + + let c = cloture.await.unwrap(); + let k = creation.await.unwrap(); + ( + c, + k, + essais_cloture.load(Ordering::SeqCst), + essais_creation.load(Ordering::SeqCst), + ) +} + +/// Asserte la propriété du 13 b : **jamais** « X ouvert, Y clos », et des +/// issues cohérentes de part et d'autre. +async fn jamais_x_ouvert_sous_y_clos( + pool: &MySqlPool, + company_id: i64, + y: i64, + x_annee: i32, + cloture: &Result, + creation: &Result, +) { + let x: Option = + sqlx::query_scalar("SELECT id FROM fiscal_years WHERE company_id = ? AND name = ?") + .bind(company_id) + .bind(format!("Exercice {x_annee}")) + .fetch_optional(pool) + .await + .unwrap(); + let y_clos = statut(pool, y).await == Some(FiscalYearStatus::Closed); + assert!( + !(y_clos && x.is_some()), + "état fautif atteint : X ouvert sous Y clos (clôture {cloture:?}, création {creation:?})" + ); + match (cloture, creation) { + (Ok(_), Err(DbError::LaterFiscalYearClosed { fiscal_year_id, .. })) => { + assert_eq!(*fiscal_year_id, y); + } + // Issue admise par la propriété, mais inatteignable dans les montages + // 13 b1 / b2 : la clôture y est déjà en (c) quand la création démarre, + // et la création bute avant de valider (revue P1, B-2). L'ordre inverse + // — création tenant Y, clôture qui l'attend — est le 13 b3. + (Err(DbError::EarlierFiscalYearOpen { fiscal_year_id, .. }), Ok(fy)) => { + assert_eq!(*fiscal_year_id, fy.id); + } + other => panic!("issues incohérentes : {other:?}"), + } +} + +/// AC 13 b1 — `create(X)` contre `close(Y)`, **aucun exercice antérieur à X**. +/// +/// **Mécanisme observé** (ce test, trois exécutions) : **sérialisation** — le +/// pré-contrôle `find_overlapping` de la création lit Y (la borne de son +/// parcours) et l'attend derrière la clôture ; la clôture valide ; la création +/// reprend, lit Y clos dans sa garde et refuse (`LaterFiscalYearClosed`), en une +/// seule tentative. (À la main, en T0, la création avait passé ce pré-contrôle +/// et buté dans sa garde : même issue, autre point d'arrêt — le test fait foi.) +/// Aucune mutation propre : la propriété est tenue par les gardes (i) et (iv) +/// ensemble. ⚠️ Il ne prouve **pas** le verrou de la garde de `create` (la +/// mutation (xi) le laisse vert) : c'est le 13 b3. +#[sqlx::test(migrations = "./test-schema")] +async fn create_against_close_without_earlier_year_never_leaves_an_open_year_under_a_closed_one( + pool: MySqlPool, +) { + let company_id = create_company(&pool).await; + let user_id = create_admin_user(&pool, company_id).await; + let y = exercice(&pool, user_id, company_id, 2026).await; + + let (c, k, essais_c, essais_k) = + creation_contre_cloture(&pool, user_id, company_id, y, 2025, MOTIF_CHEVAUCHEMENT).await; + eprintln!("13 b1 : tentatives clôture = {essais_c}, création = {essais_k}"); + jamais_x_ouvert_sous_y_clos(&pool, company_id, y, 2025, &c, &k).await; +} + +/// AC 13 b2 — `create(X)` contre `close(Y)`, **un exercice clos M antérieur à +/// X**. +/// +/// **Mécanisme observé** (T0, puis ce test) : **interblocage** — la clôture +/// tient M (étape (b')) ; la création lit M dans `find_overlapping` et l'attend +/// (c'est là qu'elle est vue en cours, après avoir posé le verrou d'index de +/// M) ; la clôture, Y obtenu, demande en étape (d) ce verrou d'index : cycle. Victime observée : la création, qui, rejouée, lit Y +/// clos et refuse. Aucune mutation propre (gardes (i) et (iv) ensemble) ; +/// vert sous la mutation (xi), comme le 13 b1 — le verrou de la garde est +/// prouvé par le 13 b3. +#[sqlx::test(migrations = "./test-schema")] +async fn create_against_close_with_a_closed_earlier_year_never_leaves_an_open_year_under_a_closed_one( + pool: MySqlPool, +) { + let company_id = create_company(&pool).await; + let user_id = create_admin_user(&pool, company_id).await; + let m = exercice(&pool, user_id, company_id, 2020).await; + fiscal_years::close(&pool, user_id, company_id, m) + .await + .unwrap(); + let y = exercice(&pool, user_id, company_id, 2026).await; + + let (c, k, essais_c, essais_k) = + creation_contre_cloture(&pool, user_id, company_id, y, 2025, MOTIF_CHEVAUCHEMENT).await; + eprintln!("13 b2 : tentatives clôture = {essais_c}, création = {essais_k}"); + jamais_x_ouvert_sous_y_clos(&pool, company_id, y, 2025, &c, &k).await; +} + +/// AC 13 b3 — **le verrou de la garde de `create`** : une création de X en +/// cours, garde passée, contre `close(Y)` (Y ouvert, X antérieur à Y, rien +/// d'autre). +/// +/// W refait les gestes de `create` qui suivent ses pré-contrôles, sans les +/// valider : la garde [`fiscal_years::find_later_closed_in_tx`] (lecture +/// verrouillante, rend rien — Y est ouvert), puis l'`INSERT` de X. La clôture +/// est lancée **entre les deux**, dans son enveloppe, et vue à l'étape (c) : +/// c'est la garde de W qui tient Y. W insère X et valide ; la relecture (d) lit +/// X **ouvert** → `EarlierFiscalYearOpen` nommant X. +/// +/// ⚠️ W ne rejoue **pas** le pré-contrôle `find_overlapping` : dans les 13 b1 / +/// b2, c'est lui qui bute (sur Y, borne de son parcours, ou sur M), si bien que +/// son verrou masque celui de la garde — une garde non verrouillante y passerait +/// (revue P1, A1). Ce test isole la garde ; il ne prouve rien du pré-contrôle, +/// dont le verrou de borne dépend du plan. +/// +/// ⛔ **Tue la mutation (xi)** (garde de `create` non verrouillante : `FOR +/// UPDATE` retiré de `find_later_closed_in_tx`) : W ne tient plus Y, la clôture +/// n'est pas vue en (c) — elle passe (c), puis (d), dont les verrous +/// d'intervalle font attendre l'`INSERT` de W jusqu'à son `COMMIT` : état final +/// « X ouvert, Y clos ». Rouge sur l'état final (assertion placée avant celle +/// de l'attente, pour que le message nomme la propriété). +#[sqlx::test(migrations = "./test-schema")] +async fn close_waits_for_a_creation_whose_guard_holds_the_later_year(pool: MySqlPool) { + let company_id = create_company(&pool).await; + let user_id = create_admin_user(&pool, company_id).await; + let y = exercice(&pool, user_id, company_id, 2026).await; + let x_start = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(); + + let mut w = pool.begin().await.unwrap(); + assert!( + fiscal_years::find_later_closed_in_tx(&mut w, company_id, x_start) + .await + .unwrap() + .is_none(), + "Y est ouvert : la garde ne refuse pas" + ); + + let cloture = { + let pool = pool.clone(); + tokio::spawn(async move { + retry_on_deadlock("fiscal_years::close", || { + fiscal_years::close(&pool, user_id, company_id, y) + }) + .await + }) + }; + let vue_en_c = + attendre_une_requete_en_cours(&pool, MOTIF_ETAPE_C, || cloture.is_finished()).await; + + // Les gestes restants de `create`, que la clôture attende ou non : l'état + // final fait foi. + let x = sqlx::query( + "INSERT INTO fiscal_years (company_id, name, start_date, end_date) VALUES (?, ?, ?, ?)", + ) + .bind(company_id) + .bind("Exercice 2025") + .bind(x_start) + .bind(NaiveDate::from_ymd_opt(2025, 12, 31).unwrap()) + .execute(&mut *w) + .await + .unwrap() + .last_insert_id(); + let x = i64::try_from(x).unwrap(); + w.commit().await.unwrap(); + + let result = cloture.await.unwrap(); + assert!( + !(statut(&pool, y).await == Some(FiscalYearStatus::Closed) + && statut(&pool, x).await == Some(FiscalYearStatus::Open)), + "état fautif atteint : X ouvert sous Y clos (clôture {result:?})" + ); + assert!( + vue_en_c, + "la clôture doit être vue à l'étape (c), bloquée sur Y que tient la garde de W" + ); + assert!( + matches!(&result, Err(DbError::EarlierFiscalYearOpen { fiscal_year_id, .. }) if *fiscal_year_id == x), + "attendu EarlierFiscalYearOpen nommant X, obtenu {result:?}" + ); + assert_eq!(statut(&pool, y).await, Some(FiscalYearStatus::Open)); + assert_eq!(audits(&pool, "fiscal_year.closed", y).await, 0); +} + +/// AC 5 (revue P1, E5) — la garde de `create` est **bornée à la société** : un +/// exercice clos d'une autre société ne refuse rien. +/// +/// ⛔ Tue la mutation « `company_id` neutralisé dans le filtre de +/// `FIND_LATER_CLOSED_SQL` » (jouée en revue P1 : seul ce test rougit). +#[sqlx::test(migrations = "./test-schema")] +async fn create_ignores_the_closed_years_of_another_company(pool: MySqlPool) { + let autre = create_other_company(&pool).await; + let autre_user = create_admin_user(&pool, autre).await; + let y_autre = exercice(&pool, autre_user, autre, 2026).await; + fiscal_years::close(&pool, autre_user, autre, y_autre) + .await + .unwrap(); + + let company_id = create_company(&pool).await; + let user_id = create_admin_user(&pool, company_id).await; + let mut new = ny("Exercice 2025", 2025); + new.company_id = company_id; + let result = fiscal_years::create(&pool, user_id, new).await; + assert!( + result.is_ok(), + "un exercice clos d'une autre société ne garde rien : {result:?}" + ); +} + +/// AC 5 (revue P1, E5) — **frontière de date** de la garde de `create` : un +/// exercice clos qui finit **la veille** du début du nouveau (antérieur et +/// adjacent) ne le refuse pas ; seul un postérieur clos le refuse. Le cas +/// symétrique — le nouveau finit la veille du début d'un exercice clos — est +/// `create_is_refused_before_a_closed_year` (2025-12-31 / 2026-01-01). +#[sqlx::test(migrations = "./test-schema")] +async fn create_right_after_a_closed_year_is_allowed(pool: MySqlPool) { + let company_id = create_company(&pool).await; + let user_id = create_admin_user(&pool, company_id).await; + let y2025 = exercice(&pool, user_id, company_id, 2025).await; + fiscal_years::close(&pool, user_id, company_id, y2025) + .await + .unwrap(); + + let mut new = ny("Exercice 2026", 2026); + new.company_id = company_id; + let result = fiscal_years::create(&pool, user_id, new).await; + assert!( + result.is_ok(), + "un exercice clos antérieur et adjacent ne garde rien : {result:?}" + ); +} + +/// AC 13 d — `close(N)` contre une **contre-passation** (cycle de l'AC 3 ; M +/// clos, N ouvert, T ouvert couvrant le jour du serveur, M < N < T). +/// +/// W refait les deux temps de `reverse_in_tx_inner` : verrou de N par clé +/// primaire, puis `find_open_covering_date(today)`, dont le parcours part du +/// premier exercice et contient M. La clôture est lancée entre les deux et vue +/// en étape (c) — ce qui prouve que l'étape (b') a rendu et tient M. W demande +/// alors M : **interblocage**. Victime mesurée (T0) : la clôture, que W soit +/// alourdie ou non — W obtient M. ⚠️ La clôture rejouée rebuterait en (b') sur +/// M, que W tient : le test **annule W d'abord**, puis attend la clôture. +/// +/// **Objet** : le cycle existe et se résout — la clôture, dans son enveloppe, +/// finit acceptée. Il ne tue **aucune** mutation : l'enveloppe y est écrite par +/// le test ; c'est le test HTTP de rejeu (`rejeu_interblocage_e2e.rs`) qui tue +/// la mutation (ix). Son assertion d'interblocage est **disjonctive** (W victime, +/// ou la clôture rejouée une fois) : exhaustive dès que le cycle est forcé, elle +/// ne dépend pas du choix de victime d'InnoDB — c'est voulu (revue P1, B-4). +#[sqlx::test(migrations = "./test-schema")] +async fn close_against_a_reversal_deadlocks_and_the_replay_resolves_it(pool: MySqlPool) { + use chrono::Datelike; + let company_id = create_company(&pool).await; + let user_id = create_admin_user(&pool, company_id).await; + let today = chrono::Utc::now().date_naive(); + let m = exercice(&pool, user_id, company_id, today.year() - 6).await; + let n = exercice(&pool, user_id, company_id, today.year() - 5).await; + let _t = exercice(&pool, user_id, company_id, today.year()).await; + fiscal_years::close(&pool, user_id, company_id, m) + .await + .unwrap(); + + let mut w = pool.begin().await.unwrap(); + sqlx::query("SELECT id FROM fiscal_years WHERE id = ? FOR UPDATE") + .bind(n) + .execute(&mut *w) + .await + .unwrap(); + + let essais = Arc::new(AtomicU32::new(0)); + let cloture = { + let pool = pool.clone(); + let essais = essais.clone(); + tokio::spawn(async move { + retry_on_deadlock("fiscal_years::close", || { + essais.fetch_add(1, Ordering::SeqCst); + fiscal_years::close(&pool, user_id, company_id, n) + }) + .await + }) + }; + assert!( + attendre_une_requete_en_cours(&pool, MOTIF_ETAPE_C, || cloture.is_finished()).await, + "la clôture doit être vue à l'étape (c), bloquée sur N" + ); + + // Second temps de la contre-passation : l'exercice du jour. + let w_issue = fiscal_years::find_open_covering_date(&mut w, company_id, today).await; + let w_victime = match &w_issue { + Ok(fy) => { + assert!(fy.is_some(), "T couvre le jour"); + false + } + Err(e) => { + assert!( + kesh_db::retry::is_deadlock_error(e), + "W ne peut échouer que par un interblocage : {e:?}" + ); + true + } + }; + w.rollback().await.unwrap(); + + let result = cloture.await.unwrap(); + assert!( + result.is_ok(), + "la clôture doit finir acceptée : {result:?}" + ); + assert_eq!(statut(&pool, n).await, Some(FiscalYearStatus::Closed)); + // Le cycle a bien eu lieu : l'une des deux transactions en a été victime. + let tentatives = essais.load(Ordering::SeqCst); + assert!( + w_victime || tentatives == 2, + "un interblocage était attendu (tentatives de la clôture : {tentatives}, W victime : {w_victime})" + ); +} diff --git a/crates/kesh-db/tests/journal_entries_modification.rs b/crates/kesh-db/tests/journal_entries_modification.rs index 9a0f63e49..479d33eb2 100644 --- a/crates/kesh-db/tests/journal_entries_modification.rs +++ b/crates/kesh-db/tests/journal_entries_modification.rs @@ -378,7 +378,10 @@ async fn update_waits_for_a_concurrent_reversal_then_refuses(pool: MySqlPool) { ); } -/// AC 8 · C-15-8-22 — la clôture **en cours** d'un exercice postérieur : le +/// AC 8 · C-15-8-22 — la clôture **en cours** d'un exercice postérieur +/// (transition **simulée par SQL** : la clôture la refuse depuis la Story +/// 15-12a, N étant ouvert ; le test garde la propriété du verrou pour les +/// données héritées) : le /// `PUT` l'attend sur le verrou d'intervalle des exercices postérieurs, puis la /// voit et refuse — `LaterFiscalYearClosed` nommant N+1, écriture inchangée. /// @@ -482,7 +485,9 @@ async fn delete_waits_for_a_concurrent_reversal_then_refuses(pool: MySqlPool) { } /// Story 15-8b, AC 5 · C-15-8-22 — la clôture **en cours** d'un exercice -/// postérieur : la suppression l'attend sur le verrou d'intervalle des +/// postérieur (transition **simulée par SQL** : la clôture la refuse depuis la +/// Story 15-12a ; le test garde la propriété du verrou pour les données +/// héritées) : la suppression l'attend sur le verrou d'intervalle des /// exercices postérieurs (étape 2-bis), puis la voit et refuse — /// `LaterFiscalYearClosed` nommant N+1, écriture intacte. /// diff --git a/crates/kesh-db/tests/opening_balances_repository.rs b/crates/kesh-db/tests/opening_balances_repository.rs index f8cda3d45..418798949 100644 --- a/crates/kesh-db/tests/opening_balances_repository.rs +++ b/crates/kesh-db/tests/opening_balances_repository.rs @@ -4,8 +4,9 @@ //! `accounts::find_types_by_ids_in_tx`) et la fn dédiée atomique //! `journal_entries::create_opening_entry` (garde « company vierge » + //! statut exercice sous `fiscal_years FOR UPDATE`, P1-C1/P3-BH3-1), -//! y compris la course concurrente (P1-M3-BH, miroir -//! `reopen_close_concurrent_is_serialized` de 14-2). +//! y compris la course concurrente (P1-M3-BH, miroir de +//! l'ancien `reopen_close_concurrent_is_serialized` de 14-2, course libre +//! remplacée par la Story 15-12a — test 13 a de `fiscal_years_repository.rs`). use chrono::NaiveDate; use kesh_db::entities::account::AccountType; @@ -498,8 +499,9 @@ async fn create_opening_entry_not_found_for_missing_fy(pool: MySqlPool) { ); } -/// Course concurrente (P1-M3-BH, miroir `reopen_close_concurrent_is_serialized` -/// de 14-2) : deux générations simultanées sur la même company vierge — le +/// Course concurrente (P1-M3-BH, miroir de l'ancien +/// `reopen_close_concurrent_is_serialized` de 14-2, remplacé par la Story +/// 15-12a) : deux générations simultanées sur la même company vierge — le /// `fiscal_years FOR UPDATE` + garde `count_by_company` sous le lock /// sérialisent : exactement UNE écriture créée, l'autre reçoit /// `ALREADY_HAS_ENTRIES`. diff --git a/crates/kesh-i18n/locales/de-CH/messages.ftl b/crates/kesh-i18n/locales/de-CH/messages.ftl index be17eb204..a5b3a70d8 100644 --- a/crates/kesh-i18n/locales/de-CH/messages.ftl +++ b/crates/kesh-i18n/locales/de-CH/messages.ftl @@ -380,7 +380,8 @@ journal-entries-delete-blocked-reversed = Diese Buchung wurde storniert: Sie kan journal-entries-edit-conflict = Diese Buchung wurde inzwischen geändert: Die Detailansicht wurde neu geladen. journal-entries-line-account-unusable = Konto archiviert oder nicht bebuchbar — bitte ersetzen journal-entries-modify-blocked-fiscal-year-closed = Das Geschäftsjahr dieser Buchung ist abgeschlossen: Sie kann nicht mehr geändert werden. Korrigieren Sie sie durch eine Stornobuchung. -journal-entries-modify-blocked-later-fiscal-year-closed = Das spätere Geschäftsjahr { $name } ist abgeschlossen, und seine Bilanz enthält diese Buchung: Sie bleibt fixiert, solange es abgeschlossen ist. Eine Administratorin oder ein Administrator kann dieses Geschäftsjahr wieder eröffnen; andernfalls korrigieren Sie durch eine Stornobuchung. +journal-entries-modify-blocked-later-fiscal-year-closed = Das spätere Geschäftsjahr { $name } ist abgeschlossen, und seine Bilanz enthält diese Buchung: Sie bleibt fixiert, solange es abgeschlossen ist. Korrigieren Sie sie durch eine Stornobuchung; andernfalls eröffnet eine Administratorin oder ein Administrator die abgeschlossenen Geschäftsjahre wieder, beginnend mit dem neuesten. +error-later-fiscal-year-closed = Das spätere Geschäftsjahr „{ $name }“ ist abgeschlossen, und seine Bilanz enthält alles, was ihm vorangeht: Eine Buchung vor seinem Beginn kann weder geändert noch gelöscht werden, solange es abgeschlossen ist. Eine solche Buchung wird durch eine Stornobuchung korrigiert; andernfalls eröffnet eine Administratorin oder ein Administrator die abgeschlossenen Geschäftsjahre wieder, beginnend mit dem neuesten. journal-entries-modify-blocked-period-locked = Die Periode ist bis zum { $date } gesperrt: Diese Buchung, datiert in der Periode, bleibt fixiert. Korrigieren Sie sie durch eine Stornobuchung. journal-entries-modify-blocked-detached-settlement = Diese Zahlung gehört zu einer stornierten Lieferantenrechnung: Das Geld ist abgeflossen, sie bleibt unveränderlich. Korrigieren Sie sie durch eine Stornobuchung. journal-entries-period-locked = Die Buchungen sind bis zum { $lockedThrough } gesperrt; diese ist auf den { $attempted } datiert. @@ -866,10 +867,13 @@ fiscal-year-reopen-motif-label = Grund der Wiedereröffnung fiscal-year-reopen-confirmation-action = Geschäftsjahr wieder öffnen fiscal-year-reopened = Geschäftsjahr wieder geöffnet. fiscal-year-reopen-blocked-later-closed = Öffnen Sie zuerst das Geschäftsjahr „{ $name }“, das neuer und noch geschlossen ist. +fiscal-year-close-blocked-earlier-open = Schliessen Sie zuerst das Geschäftsjahr „{ $name }“ ab, das älter und noch offen ist. error-fiscal-year-reopen-motif-empty = Der Grund der Wiedereröffnung ist erforderlich. error-fiscal-year-reopen-motif-too-long = Der Grund der Wiedereröffnung ist zu lang (max. 500 Zeichen). error-fiscal-year-already-open = Dieses Geschäftsjahr ist bereits offen. error-fiscal-year-reopen-blocked = Wiedereröffnung nicht möglich: ein späteres Geschäftsjahr ist geschlossen; öffnen Sie dieses zuerst. +error-fiscal-year-close-earlier-open = Schliessen Sie zuerst das Geschäftsjahr „{ $name }“ ab, das älter und noch offen ist: Die Bilanz ist kumulativ, und ein Geschäftsjahr wird erst nach allen vorangehenden abgeschlossen. +error-fiscal-year-create-later-closed = Das spätere Geschäftsjahr „{ $name }“ ist abgeschlossen, und seine Bilanz enthält alles, was ihm vorangeht: Vor seinem Beginn kann kein Geschäftsjahr erstellt werden, solange es abgeschlossen ist. Um dieses zu erstellen, eröffnet eine Administratorin oder ein Administrator zuerst die abgeschlossenen Geschäftsjahre wieder, beginnend mit dem neuesten. error-fiscal-year-overlap = Dieses Geschäftsjahr überschneidet sich mit einem bestehenden Jahr. error-fiscal-year-name-duplicate = Ein Geschäftsjahr mit diesem Namen existiert bereits. error-fiscal-year-name-empty = Der Name des Geschäftsjahres ist erforderlich. diff --git a/crates/kesh-i18n/locales/en-CH/messages.ftl b/crates/kesh-i18n/locales/en-CH/messages.ftl index 2c71f5829..06f0c3666 100644 --- a/crates/kesh-i18n/locales/en-CH/messages.ftl +++ b/crates/kesh-i18n/locales/en-CH/messages.ftl @@ -380,7 +380,8 @@ journal-entries-delete-blocked-reversed = This entry has been reversed: it can n journal-entries-edit-conflict = This entry was modified in the meantime: the page has been reloaded. journal-entries-line-account-unusable = Account archived or not postable — replace it journal-entries-modify-blocked-fiscal-year-closed = The fiscal year of this entry is closed: it can no longer be modified. Correct it with a reversal. -journal-entries-modify-blocked-later-fiscal-year-closed = The later fiscal year { $name } is closed, and its balance sheet includes this entry: it stays fixed while that year is closed. An administrator can reopen that fiscal year; otherwise, correct it with a reversal. +journal-entries-modify-blocked-later-fiscal-year-closed = The later fiscal year { $name } is closed, and its balance sheet includes this entry: it stays fixed while that year is closed. Correct it with a reversal; otherwise, an administrator reopens the closed fiscal years, starting with the most recent. +error-later-fiscal-year-closed = The later fiscal year "{ $name }" is closed, and its balance sheet includes everything before it: no entry dated before its start date can be changed or deleted while it is closed. Such an entry is corrected with a reversal; otherwise, an administrator reopens the closed fiscal years, starting with the most recent. journal-entries-modify-blocked-period-locked = The period is locked through { $date }: this entry, dated within it, stays fixed. Correct it with a reversal. journal-entries-modify-blocked-detached-settlement = This payment belongs to a cancelled supplier invoice: the money has left, it stays fixed. Correct it with a reversal. journal-entries-period-locked = Entries are locked through { $lockedThrough }; this one is dated { $attempted }. @@ -866,10 +867,13 @@ fiscal-year-reopen-motif-label = Reason for reopening fiscal-year-reopen-confirmation-action = Reopen fiscal year fiscal-year-reopened = Fiscal year reopened. fiscal-year-reopen-blocked-later-closed = First reopen fiscal year "{ $name }", which is more recent and still closed. +fiscal-year-close-blocked-earlier-open = First close fiscal year "{ $name }", which is earlier and still open. error-fiscal-year-reopen-motif-empty = The reason for reopening is required. error-fiscal-year-reopen-motif-too-long = The reason for reopening is too long (500 characters maximum). error-fiscal-year-already-open = This fiscal year is already open. error-fiscal-year-reopen-blocked = Cannot reopen: a later fiscal year is closed; reopen it first. +error-fiscal-year-close-earlier-open = First close fiscal year "{ $name }", which is earlier and still open: the balance sheet is cumulative, and a fiscal year can only be closed after all those before it. +error-fiscal-year-create-later-closed = The later fiscal year "{ $name }" is closed, and its balance sheet includes everything before it: no fiscal year can be created before its start date while it is closed. To create this one, an administrator first reopens the closed fiscal years, starting with the most recent. error-fiscal-year-overlap = This fiscal year overlaps with an existing one. error-fiscal-year-name-duplicate = A fiscal year with this name already exists. error-fiscal-year-name-empty = The fiscal year name is required. diff --git a/crates/kesh-i18n/locales/fr-CH/messages.ftl b/crates/kesh-i18n/locales/fr-CH/messages.ftl index 6944c99cf..7fb521b82 100644 --- a/crates/kesh-i18n/locales/fr-CH/messages.ftl +++ b/crates/kesh-i18n/locales/fr-CH/messages.ftl @@ -374,7 +374,8 @@ journal-entries-delete-blocked-reversed = Cette écriture a été contre-passée journal-entries-edit-conflict = Cette écriture a été modifiée entre-temps : la fiche a été rechargée. journal-entries-line-account-unusable = Compte archivé ou non imputable — à remplacer journal-entries-modify-blocked-fiscal-year-closed = L’exercice de cette écriture est clôturé : elle est figée. Corrigez-la par une contre-passation. -journal-entries-modify-blocked-later-fiscal-year-closed = L’exercice postérieur { $name } est clôturé, et son bilan reprend cette écriture : elle reste figée tant qu’il l’est. Un administrateur peut rouvrir cet exercice ; sinon, corrigez par une contre-passation. +journal-entries-modify-blocked-later-fiscal-year-closed = L’exercice postérieur { $name } est clôturé, et son bilan reprend cette écriture : elle reste figée tant qu’il l’est. Corrigez-la par une contre-passation ; sinon, un administrateur rouvre les exercices clôturés, en commençant par le plus récent. +error-later-fiscal-year-closed = L’exercice « { $name } », postérieur, est clôturé, et son bilan reprend tout ce qui le précède : aucune écriture datée avant sa date de début ne peut être modifiée ni supprimée tant qu’il l’est. Une telle écriture se corrige par une contre-passation ; sinon, un administrateur rouvre les exercices clôturés, en commençant par le plus récent. journal-entries-modify-blocked-period-locked = La période est verrouillée jusqu’au { $date } : cette écriture, datée dans la période, reste figée. Corrigez-la par une contre-passation. journal-entries-modify-blocked-detached-settlement = Ce paiement appartient à une facture fournisseur annulée : l’argent est sorti, il reste figé. Corrigez-le par une contre-passation. journal-entries-period-locked = Les écritures sont verrouillées jusqu'au { $lockedThrough } ; celle-ci est datée du { $attempted }. @@ -917,10 +918,13 @@ fiscal-year-reopen-motif-label = Motif de la réouverture fiscal-year-reopen-confirmation-action = Rouvrir l’exercice fiscal-year-reopened = Exercice rouvert. fiscal-year-reopen-blocked-later-closed = Rouvrez d’abord l’exercice « { $name } », plus récent et encore clôturé. +fiscal-year-close-blocked-earlier-open = Clôturez d’abord l’exercice « { $name } », plus ancien et encore ouvert. error-fiscal-year-reopen-motif-empty = Le motif de réouverture est obligatoire. error-fiscal-year-reopen-motif-too-long = Le motif de réouverture est trop long (500 caractères maximum). error-fiscal-year-already-open = Cet exercice est déjà ouvert. error-fiscal-year-reopen-blocked = Réouverture impossible : un exercice postérieur est clôturé ; rouvrez-le d’abord. +error-fiscal-year-close-earlier-open = Clôturez d’abord l’exercice « { $name } », plus ancien et encore ouvert : le bilan est cumulatif, et un exercice ne se clôt qu’après tous ceux qui le précèdent. +error-fiscal-year-create-later-closed = L’exercice « { $name } », postérieur, est clôturé, et son bilan reprend tout ce qui le précède : aucun exercice ne peut être créé avant sa date de début tant qu’il l’est. Pour créer celui-ci, un administrateur rouvre d’abord les exercices clôturés, en commençant par le plus récent. error-fiscal-year-overlap = Cet exercice chevauche un exercice existant. error-fiscal-year-name-duplicate = Un exercice avec ce nom existe déjà. error-fiscal-year-name-empty = Le nom de l’exercice est obligatoire. diff --git a/crates/kesh-i18n/locales/it-CH/messages.ftl b/crates/kesh-i18n/locales/it-CH/messages.ftl index 94fc69e4b..9e88c0edb 100644 --- a/crates/kesh-i18n/locales/it-CH/messages.ftl +++ b/crates/kesh-i18n/locales/it-CH/messages.ftl @@ -380,7 +380,8 @@ journal-entries-delete-blocked-reversed = Questa scrittura è stata stornata: no journal-entries-edit-conflict = Questa scrittura è stata modificata nel frattempo: la scheda è stata ricaricata. journal-entries-line-account-unusable = Conto archiviato o non imputabile — da sostituire journal-entries-modify-blocked-fiscal-year-closed = L’esercizio di questa scrittura è chiuso: non si modifica più. Correggetela con uno storno. -journal-entries-modify-blocked-later-fiscal-year-closed = L’esercizio successivo { $name } è chiuso, e il suo bilancio riprende questa scrittura: resta bloccata finché lo è. Un amministratore può riaprire quell’esercizio; altrimenti, correggete con uno storno. +journal-entries-modify-blocked-later-fiscal-year-closed = L’esercizio successivo { $name } è chiuso, e il suo bilancio riprende questa scrittura: resta bloccata finché lo è. Correggetela con uno storno; altrimenti, un amministratore riapre gli esercizi chiusi, cominciando dal più recente. +error-later-fiscal-year-closed = L’esercizio successivo « { $name } » è chiuso, e il suo bilancio riprende tutto ciò che lo precede: nessuna scrittura datata prima della sua data d’inizio può essere modificata o eliminata finché lo è. Una tale scrittura si corregge con uno storno; altrimenti, un amministratore riapre gli esercizi chiusi, cominciando dal più recente. journal-entries-modify-blocked-period-locked = Il periodo è bloccato fino al { $date }: questa scrittura, datata nel periodo, resta bloccata. Correggetela con uno storno. journal-entries-modify-blocked-detached-settlement = Questo pagamento appartiene a una fattura fornitore annullata: il denaro è uscito, resta bloccato. Correggetelo con uno storno. journal-entries-period-locked = Le scritture sono bloccate fino al { $lockedThrough }; questa è datata { $attempted }. @@ -866,10 +867,13 @@ fiscal-year-reopen-motif-label = Motivo della riapertura fiscal-year-reopen-confirmation-action = Riapri l’esercizio fiscal-year-reopened = Esercizio riaperto. fiscal-year-reopen-blocked-later-closed = Riapri prima l’esercizio « { $name } », più recente e ancora chiuso. +fiscal-year-close-blocked-earlier-open = Chiudi prima l’esercizio « { $name } », più vecchio e ancora aperto. error-fiscal-year-reopen-motif-empty = Il motivo della riapertura è obbligatorio. error-fiscal-year-reopen-motif-too-long = Il motivo della riapertura è troppo lungo (massimo 500 caratteri). error-fiscal-year-already-open = Questo esercizio è già aperto. error-fiscal-year-reopen-blocked = Riapertura impossibile: un esercizio successivo è chiuso; riaprilo prima. +error-fiscal-year-close-earlier-open = Chiudi prima l’esercizio « { $name } », più vecchio e ancora aperto: il bilancio è cumulativo, e un esercizio si chiude solo dopo tutti quelli che lo precedono. +error-fiscal-year-create-later-closed = L’esercizio successivo « { $name } » è chiuso, e il suo bilancio riprende tutto ciò che lo precede: nessun esercizio può essere creato prima della sua data d’inizio finché lo è. Per crearlo, un amministratore riapre prima gli esercizi chiusi, cominciando dal più recente. error-fiscal-year-overlap = Questo esercizio si sovrappone a un esercizio esistente. error-fiscal-year-name-duplicate = Un esercizio con questo nome esiste già. error-fiscal-year-name-empty = Il nome dell’esercizio è obbligatorio. diff --git a/docs/MULTI-TENANT-SCOPING-PATTERNS.md b/docs/MULTI-TENANT-SCOPING-PATTERNS.md index 6877aad20..04a649e28 100644 --- a/docs/MULTI-TENANT-SCOPING-PATTERNS.md +++ b/docs/MULTI-TENANT-SCOPING-PATTERNS.md @@ -320,7 +320,7 @@ Rationale: this matches the natural dependency direction (state machine → tena | `kesh_seed::seed_demo` | companies (count-validation only — released before destructive ops) | `kesh-seed/src/lib.rs` | | `POST /supplier-invoices` (create) | see the canonical doc-comment of `supplier_invoices::create_in_tx` (« Ordre des verrous ») — the order is written there only | `repositories/supplier_invoices.rs::create_in_tx` (Story 19-3, 15-5e1) | | `POST /projects/*` (create/update/archive/unarchive) | companies (sentinel) → projects | `routes/projects.rs` (Story 19-1) | -| `fiscal_years::create / update_name / close / find_*_locked` | fiscal_years only (single table, internal tx) — `FOR UPDATE` locks pour pré-check unicité/overlap et figer le before-snapshot d'audit log. Pas de chaîne cross-table. | `kesh-db/src/repositories/fiscal_years.rs` | +| `fiscal_years::create / update_name / close / find_*_locked` | fiscal_years only (single table, internal tx). **`close`** (Story 15-12a, #543) : exercices antérieurs lus sans verrou puis verrouillés **un par un par clé primaire** (`start_date` croissant — ordre fixé par le code), puis l'exercice, puis relecture verrouillante des antérieurs ouverts ; **`create`** : pré-contrôles (chevauchement, nom) puis exercices postérieurs clos (`find_later_closed_in_tx`) ; **`update_name`** : l'exercice puis son homonyme ; **`find_open_covering_date`** : parcours, ordre du plan. ⚠️ Cycles **clôture / contre-passation** et **création / contre-passation** (et les quatre annulations qui portent la contre-passation) : celle-ci tient l'exercice d'une origine puis parcourt depuis le premier exercice — résolus par le **rejeu des deux côtés** (Pattern 5 : l'ordre réduit la fréquence, le rejeu est la défense ; `create_fiscal_year` et `close_fiscal_year` sont rejouées depuis la 15-12a). | `kesh-db/src/repositories/fiscal_years.rs` | | `invoices::validate_invoice` | see the canonical doc-comment of `invoices::validate_invoice` (« Ordre des locks ») — the order is written there only | `kesh-db/src/repositories/invoices.rs` | | `POST /credit-notes` | see the doc-comment of `credit_notes::create_credit_note` (« Ordre des locks ») — the order is written there only; all the accounts the credit note writes are locked **in share mode, by id, before the fiscal year** (Story 15-6a); replayed on deadlock (Story 15-5e2) | `repositories/credit_notes.rs::create_credit_note` | | `POST /reconciliation/accept` (`accept_batch`) | **Per proposal**: rounding account (`rounding_account_for_write`), then fiscal year (`find_open_covering_date`) for an invoice proposal (`accept_one_invoice`); fiscal year, then sentinel and projects for a rule or split proposal (`accept_one_rule`, `accept_one_split`). **Between proposals**, in the same transaction (one savepoint per proposal): the fiscal year held by proposal *n* precedes the rounding account, the sentinel or the projects of proposal *n+1* — **fiscal year → rounding account** at the batch level, the reverse of `invoices::validate_invoice`: that is the cycle of #536, closed by replaying both sides | `routes/reconciliation.rs` | diff --git a/docs/api-external.md b/docs/api-external.md index 67d0062cb..63cf83bf5 100644 --- a/docs/api-external.md +++ b/docs/api-external.md @@ -250,7 +250,7 @@ Refus, **dans l'ordre où ils parlent** — 15 lignes : Les refus `409` d'une pièce portent `details.documentId` (l'identifiant de la pièce — pour `DETACHED_SUPPLIER_SETTLEMENT`, celui de la facture fournisseur annulée) et `details.documentNumber` (son numéro, quand elle en a un). `LATER_FISCAL_YEAR_CLOSED` porte `details.fiscalYearId` et `details.fiscalYearName` — le plus proche exercice postérieur clos. -⚠️ **`LATER_FISCAL_YEAR_CLOSED` ne garde aujourd'hui que ce `PUT` et la suppression (`DELETE`, ci-dessous).** La saisie (`POST /journal-entries`, clés comprises), la contre-passation, les règlements, la dévalidation et le complément des soldes de départ acceptent encore une écriture dans un exercice ouvert alors qu'un exercice postérieur est clôturé — dont le bilan reporté change alors. Limite connue, suivie par l'issue [#543](https://github.com/guycorbaz/kesh/issues/543). +⚠️ **Parmi les chemins d'écriture d'une écriture comptable, `LATER_FISCAL_YEAR_CLOSED` ne garde aujourd'hui que ce `PUT` et la suppression (`DELETE`, ci-dessous)** (il refuse aussi la création d'un exercice, `POST /fiscal-years`). La saisie (`POST /journal-entries`, clés comprises), la contre-passation, les règlements, la dévalidation et le complément des soldes de départ acceptent encore une écriture dans un exercice ouvert alors qu'un exercice postérieur est clôturé — dont le bilan reporté change alors. Limite connue, suivie par l'issue [#543](https://github.com/guycorbaz/kesh/issues/543). ⚠️ **Une écriture refusée se corrige par contre-passation** (`POST /journal-entries/{id}/reverse`), sauf l'écriture d'une pièce, qui se corrige depuis sa pièce. ⚠️ **Une écriture sur un compte archivé ou devenu non imputable reste modifiable** — à condition de remplacer ce compte : l'enregistrement refuse tant qu'une ligne le vise. ⚠️ Un interblocage avec une écriture concurrente est rejoué par le serveur ; s'il persiste, la réponse est un `500` — réessayez. @@ -493,11 +493,12 @@ Les erreurs sont renvoyées en JSON avec ce format : | `400` | `VALIDATION_ERROR` | Corps de requête invalide (champ manquant, valeur hors limites, …). | | `400` | `INACTIVE_OR_INVALID_ACCOUNTS` | Un compte référencé est inconnu, archivé, d'une autre company ou d'un mauvais type (par exemple un compte de charge qui n'est pas de charge) ; anti-énumération : le compte n'est pas nommé. | | `400` | `ACCOUNT_NOT_POSTABLE` | Un compte de la company, **actif**, n'est **pas imputable** — compte de regroupement, de résultat ou de clôture. `details.rejected[{accountId, accountNumber}]` nomme chaque compte refusé, triés par numéro. Rendu par `POST /journal-entries`, `PUT /journal-entries/{id}` (depuis la v0.13.0 — un compte devenu non imputable est refusé même sur une ligne inchangée), `POST /opening-balances`, `POST /invoices/{id}/settlements` (compte interne), `POST /supplier-invoices` (compte de charge), `POST /imported-supplier-invoices/{id}/complete` (compte de charge), `POST /supplier-invoices/{id}/pay` (compte interne), `POST /reconciliation/manual` et `POST /reconciliation/split` (compte de contrepartie), `POST /reconciliation/rules` et `PATCH /reconciliation/rules/{id}` (compte de contrepartie — au `PATCH`, seulement si le compte change ou si la règle est réactivée), `POST /bank-accounts`, `PUT /bank-accounts/{id}` et `PATCH /bank-accounts/{id}` (compte comptable lié — au `PUT` et au `PATCH`, seulement s'il change). Rendu aussi, avec le même détail, par `POST /invoices/{id}/validate` (créance, ou TVA due si la facture en porte), `POST /supplier-invoices` et `POST /imported-supplier-invoices/{id}/complete` (compte créanciers, ou TVA récupérable si la facture en porte) quand un compte **désigné dans les réglages de facturation** est devenu non imputable depuis sa désignation : le message renvoie alors à *Paramètres → Facturation*, où un administrateur doit le remplacer (depuis la v0.13.0 ; ce refus vient après tous les autres, exercice compris). Dans `failed[]` de `POST /reconciliation/accept`, le même code et le même détail pour une proposition `split` ou `rule`. Un compte à la fois inconnu, archivé ou d'un mauvais type **et** non imputable rend `INACTIVE_OR_INVALID_ACCOUNTS`, et un compte archivé ou invalide sur une autre ligne de la même écriture prime lui aussi sur un compte non imputable. *(Depuis la v0.13.0 ; ces refus rendaient auparavant `INACTIVE_OR_INVALID_ACCOUNTS`.)* | -| `400` | `LATER_FISCAL_YEAR_CLOSED` | `PUT` et `DELETE /journal-entries/{id}` : un exercice **postérieur** à celui de l'écriture est clôturé ; son bilan, cumulatif, reprend l'écriture. `details.fiscalYearId` / `details.fiscalYearName` nomment le plus proche. Ne garde pas encore les autres chemins d'écriture (#543). *(Depuis la v0.13.0.)* | +| `400` | `LATER_FISCAL_YEAR_CLOSED` | `PUT` et `DELETE /journal-entries/{id}` : un exercice **postérieur** à celui de l'écriture est clôturé ; son bilan, cumulatif, reprend l'écriture. `POST /fiscal-years` : un exercice postérieur à la date de début demandée est clôturé — un exercice ne se crée pas avant un exercice clôturé (message propre à la création). `details.fiscalYearId` / `details.fiscalYearName` nomment le plus proche. Ne garde pas encore les autres chemins d'écriture (#543). *(Depuis la v0.13.0.)* | +| `409` | `EARLIER_FISCAL_YEAR_OPEN` | `POST /fiscal-years/{id}/close` : un exercice **antérieur** est encore ouvert — les exercices se clôturent dans l'ordre, le bilan étant cumulatif. `details.fiscalYearId` / `details.fiscalYearName` nomment le **plus ancien** antérieur ouvert, à clôturer d'abord. ⚠️ **Changement de contrat** : une intégration qui clôturait hors d'ordre reçoit désormais ce refus. Un exercice déjà clos rend `409 ILLEGAL_STATE_TRANSITION`, qui parle d'abord. *(Depuis la v0.13.0.)* | | `409` | `DETACHED_SUPPLIER_SETTLEMENT` | `PUT` et `DELETE /journal-entries/{id}` : l'écriture est le paiement d'une facture fournisseur annulée — une sortie de banque réelle, qui se corrige par contre-passation. `details.documentId` est l'identifiant de la facture. *(Depuis la v0.13.0.)* | | `404` | `NOT_FOUND` | Ressource absente ou appartenant à une autre company (anti-énumération). Certaines ressources renvoient un code spécifique (ex. `ACCOUNT_NOT_FOUND`). | -**Interblocages : les écritures au journal sont rejouées.** Toute route qui écrit au journal comptable rejoue d'elle-même un interblocage transitoire avec une autre opération, sans le montrer. Ouvertes aux clés `read-write`, ce sont : `POST /journal-entries`, `PUT` et `DELETE /journal-entries/{id}`, `POST /journal-entries/{id}/reverse`, `POST /opening-balances`, `POST /opening-balances/complete`, `POST /invoices/{id}/validate`, `POST /invoices/{id}/unvalidate`, `POST /invoices/{id}/settlements`, `POST /invoices/{id}/settlements/{settlementId}/cancel`, `POST /invoices/{id}/write-off`, `POST /credit-notes`, `POST /supplier-invoices`, `POST /supplier-invoices/{id}/pay`, `POST /supplier-invoices/{id}/cancel`, `POST /supplier-invoices/{id}/settlement/cancel`, `POST /imported-supplier-invoices/{id}/complete`, `POST /payment-batches/{id}/confirm`, `POST /reconciliation/accept`, `POST /reconciliation/manual`, `POST /reconciliation/split` et `POST /reconciliation/transactions/{id}/cancel` (la restauration d'une sauvegarde et l'effacement des données de démonstration, réservés à l'interface d'administration, ne rejouent pas). Si l'interblocage persiste après trois tentatives, la requête finit en `500 INTERNAL_ERROR` ; rien n'a été écrit, et elle peut être renvoyée telle quelle. +**Interblocages : les écritures au journal sont rejouées.** Toute route qui écrit au journal comptable rejoue d'elle-même un interblocage transitoire avec une autre opération, sans le montrer. Ouvertes aux clés `read-write`, ce sont : `POST /journal-entries`, `PUT` et `DELETE /journal-entries/{id}`, `POST /journal-entries/{id}/reverse`, `POST /opening-balances`, `POST /opening-balances/complete`, `POST /invoices/{id}/validate`, `POST /invoices/{id}/unvalidate`, `POST /invoices/{id}/settlements`, `POST /invoices/{id}/settlements/{settlementId}/cancel`, `POST /invoices/{id}/write-off`, `POST /credit-notes`, `POST /supplier-invoices`, `POST /supplier-invoices/{id}/pay`, `POST /supplier-invoices/{id}/cancel`, `POST /supplier-invoices/{id}/settlement/cancel`, `POST /imported-supplier-invoices/{id}/complete`, `POST /payment-batches/{id}/confirm`, `POST /reconciliation/accept`, `POST /reconciliation/manual`, `POST /reconciliation/split` et `POST /reconciliation/transactions/{id}/cancel` — ainsi que la création et la clôture d'un exercice, `POST /fiscal-years` et `POST /fiscal-years/{id}/close`, qui n'écrivent pas au journal mais forment des interblocages avec la contre-passation (depuis la v0.13.0) (la restauration d'une sauvegarde et l'effacement des données de démonstration, réservés à l'interface d'administration, ne rejouent pas). Si l'interblocage persiste après trois tentatives, la requête finit en `500 INTERNAL_ERROR` ; rien n'a été écrit, et elle peut être renvoyée telle quelle. Une autre route peut, rarement, rendre `500` sur un conflit transitoire d'accès concurrent ; rien n'est alors écrit et la requête peut être renvoyée. diff --git a/docs/manual/fr/admin-manual.pdf b/docs/manual/fr/admin-manual.pdf index bfe2b9dcc..660210d3b 100644 Binary files a/docs/manual/fr/admin-manual.pdf and b/docs/manual/fr/admin-manual.pdf differ diff --git a/docs/manual/fr/admin-manual.tex b/docs/manual/fr/admin-manual.tex index c362e09d4..071198c99 100644 --- a/docs/manual/fr/admin-manual.tex +++ b/docs/manual/fr/admin-manual.tex @@ -1378,6 +1378,9 @@ \subsection{Configuration des exercices comptables} Chaque company doit définir au moins un exercice comptable (\emph{fiscal year}). Par défaut, l'exercice civil 1\textsuperscript{er} janvier -- 31 décembre est créé automatiquement à la création de la company. Pour d'autres exercices (décalés, courts, longs), aller dans \emph{Paramètres} → \emph{Exercices}. +\paragraph{Clôture dans l'ordre (depuis la v0.13.0).} +Les exercices se clôturent \textbf{du plus ancien vers le plus récent}, symétrique de la garde de réouverture ci-dessous~: la clôture d'un exercice est refusée tant qu'un exercice de date de début antérieure est encore ouvert (\texttt{409 EARLIER\_\allowbreak FISCAL\_\allowbreak YEAR\_\allowbreak OPEN}, qui nomme le plus ancien à clôturer d'abord), et la création d'un exercice est refusée si un exercice postérieur est déjà clôturé (\texttt{400 LATER\_\allowbreak FISCAL\_\allowbreak YEAR\_\allowbreak CLOSED}). Le bilan étant cumulatif, ces deux gardes empêchent qu'un exercice ouvert précède un exercice clôturé. ⚠️ Une \textbf{installation mise à jour} ou une \textbf{sauvegarde restaurée} peut porter cet état, hérité d'une version antérieure~: l'import l'accepte, et il se résorbe en clôturant le plus ancien exercice ouvert, ou en rouvrant les exercices clôturés du plus récent vers l'ancien. + \paragraph{Réouverture d'un exercice clôturé (Admin uniquement).} La clôture d'un exercice est un verrou réversible et tracé. La \textbf{réouverture} est réservée aux \textbf{administrateurs} — plus strict que la clôture, accessible aux comptables — et n'est \textbf{pas} disponible via une clé API (opération privilégiée). Elle se fait depuis \emph{Paramètres} → \emph{Exercices comptables}, bouton \emph{Réouvrir}, et exige un \textbf{motif obligatoire} conservé dans le journal d'audit (\texttt{audit\_log}, action \texttt{fiscal\_year.reopened}, avec l'état avant/après et l'auteur ; conservation 10 ans). Une \textbf{garde d'ordre} impose de rouvrir du plus récent vers l'ancien : si un exercice de date de début postérieure est encore clôturé, la réouverture est refusée jusqu'à ce que celui-ci soit rouvert d'abord. Un exercice rouvert redevient éditable et pourra être re-clôturé. Aucune procédure en ligne de commande n'est nécessaire ni disponible pour cette opération. diff --git a/docs/manual/fr/user-manual.pdf b/docs/manual/fr/user-manual.pdf index b7b0fbac2..3178889a2 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 afe84c8ce..cbcb01dad 100644 --- a/docs/manual/fr/user-manual.tex +++ b/docs/manual/fr/user-manual.tex @@ -696,16 +696,20 @@ \subsection{Création d'un nouvel exercice} Le nouvel exercice s'ouvre vide. Les comptes du bilan sont automatiquement reportés depuis le bilan de clôture de l'exercice précédent (si clôturé). +Un exercice \textbf{ne se crée pas avant un exercice clôturé} (depuis la v0.13.0)~: si un exercice dont la date de début est postérieure à celle demandée est déjà clôturé, Kesh refuse la création et nomme cet exercice --- son bilan, cumulatif, reprendrait tout ce que le nouvel exercice contiendrait. Pour créer malgré tout un exercice antérieur, un administrateur rouvre d'abord les exercices clôturés, en commençant par le plus récent. + \subsection{Clôture d'un exercice} \keshscreenshot{exercice-cloture.png}{Écran de clôture d'un exercice comptable.} % TODO capture: ecran cloture exercice \emph{Administration} → \emph{Exercices comptables} → sélectionner l'exercice → \emph{Clôturer}. +Les exercices se clôturent \textbf{dans l'ordre} (depuis la v0.13.0)~: un exercice ne se clôture qu'après \textbf{tous ceux qui le précèdent}. Tant qu'un exercice antérieur est encore ouvert, le bouton \emph{Clôturer} est désactivé, et son infobulle nomme l'exercice à clôturer d'abord --- le plus ancien encore ouvert~; Kesh refuse de même la clôture demandée par l'API. Le bilan étant cumulatif depuis l'origine, clôturer un exercice en laissant ouvert un exercice antérieur laisserait son bilan changer après coup. + La clôture est un simple \textbf{verrou} : elle ne génère aucune écriture. Le report des soldes de bilan d'un exercice à l'autre est \textbf{calculé en temps réel} depuis l'origine (report à-nouveau automatique), sans écriture de clôture ni de report à passer. La clôture : \begin{itemize} - \item Verrouille l'exercice : plus aucune écriture ne peut y être \textbf{saisie} tant qu'il reste clôturé (immutabilité, CO art. 957-964). C'est aussi la clôture qui \textbf{ferme la modification}~: une écriture se modifie tant que son exercice est ouvert \textbf{et qu'aucun exercice postérieur n'est clôturé} --- le bilan étant cumulatif, clôturer l'exercice suivant fige aussi celui-ci (pour la modification et la suppression seulement~: les autres écritures restent possibles dans l'exercice ouvert, limite suivie par l'issue~\#543). La clôture ferme de même la \textbf{suppression} d'une écriture --- celle de l'exercice clôturé, et celle de tout exercice antérieur. + \item Verrouille l'exercice : plus aucune écriture ne peut y être \textbf{saisie} tant qu'il reste clôturé (immutabilité, CO art. 957-964). C'est aussi la clôture qui \textbf{ferme la modification}~: une écriture se modifie tant que son exercice est ouvert. Un exercice ne se clôturant qu'après tous ceux qui le précèdent, aucun exercice postérieur n'est clôturé tant que le sien est ouvert. Si des données antérieures à la v0.13.0 (installation mise à jour, sauvegarde restaurée) portent déjà un exercice clôturé après un exercice ouvert, cet exercice ouvert reste figé pour la modification --- le bilan étant cumulatif, le modifier réécrirait le bilan clos (pour la modification et la suppression seulement~: les autres écritures restent possibles dans l'exercice ouvert, limite suivie par l'issue~\#543). La clôture ferme de même la \textbf{suppression} d'une écriture --- celle de l'exercice clôturé, et celle de tout exercice antérieur. \item Inscrit l'opération dans le journal d'audit (\texttt{audit\_log}). \item N'affecte pas le report à-nouveau, qui reste calculé automatiquement — les rapports finaux (bilan, compte de résultat, balance) demeurent consultables et exportables (CSV/PDF). \end{itemize} @@ -719,7 +723,7 @@ \subsection{Réouverture d'un exercice clôturé} En cas d'erreur découverte après clôture, un \textbf{administrateur} peut rouvrir l'exercice directement depuis l'interface : \emph{Paramètres} → \emph{Exercices comptables} → bouton \emph{Réouvrir} (visible uniquement pour les administrateurs sur un exercice clôturé). Un \textbf{motif est obligatoire} ; il est conservé, avec l'auteur et l'horodatage, dans le journal d'audit (\texttt{audit\_log}, conservation 10 ans). L'exercice rouvert redevient modifiable --- ses écritures saisies à la main se modifient de nouveau, si aucun exercice postérieur n'est clôturé ---, puis pourra être re-clôturé. \begin{keshtip} -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. +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. C'est l'ordre inverse de la clôture, qui va du plus ancien vers le plus récent~: ensemble, ils garantissent qu'aucun exercice ouvert ne précède un exercice clôturé. Effectuer cette opération avec parcimonie pour préserver l'intégrité légale. \end{keshtip} \subsection{Reprise de comptabilité — soldes de départ}\label{sec:soldes-depart} @@ -749,7 +753,7 @@ \subsubsection{Compléter un compte oublié} Une fois l'ouverture générée, l'écran passe en mode \emph{compléter}~: sous le rappel de ce qui a été généré, il propose les comptes de bilan --- actifs et passifs --- \textbf{jamais mouvementés}~: en service, imputables, sans aucune ligne d'écriture dans aucun exercice. Il faut pour cela un compte de rôle \emph{Bénéfice/perte reporté} en service et imputable et, pour une régularisation, un exercice ouvert qui couvre la date du jour~; à défaut, l'écran dit ce qui manque. Saisissez le solde des seuls comptes oubliés, puis \emph{Compléter}~: Kesh crée une écriture OD «~Complément des soldes de départ~», dont il \textbf{calcule lui-même la contrepartie} sur le compte de rôle \emph{Bénéfice/perte reporté}~: elle équilibre l'ensemble des lignes saisies --- au crédit quand leurs débits l'emportent (un actif oublié, saisi au débit), au débit dans le cas contraire (un passif, saisi au crédit). Si les lignes saisies s'équilibrent entre elles, il n'y a pas de contrepartie. \begin{itemize} - \item \textbf{La date.} Si votre premier exercice est encore ouvert et que son premier jour n'est pas dans la période verrouillée, le complément est daté de ce premier jour~: le compte oublié faisait partie de l'ouverture, et les soldes d'ouverture --- donc tout rapport déjà tiré sur cet exercice --- en sont modifiés. Si un exercice suivant est déjà clôturé, son bilan reporté l'est aussi. Sinon, le complément est une \textbf{régularisation datée du jour}, dans l'exercice ouvert qui couvre cette date. La date annoncée par l'écran est indicative~; elle est confirmée à l'enregistrement. + \item \textbf{La date.} Si votre premier exercice est encore ouvert et que son premier jour n'est pas dans la période verrouillée, le complément est daté de ce premier jour~: le compte oublié faisait partie de l'ouverture, et les soldes d'ouverture --- donc tout rapport déjà tiré sur cet exercice --- en sont modifiés. Si un exercice suivant est déjà clôturé --- ce qui ne se rencontre plus qu'avec des données antérieures à la v0.13.0, les exercices se clôturant désormais dans l'ordre ---, son bilan reporté l'est aussi. Sinon, le complément est une \textbf{régularisation datée du jour}, dans l'exercice ouvert qui couvre cette date. La date annoncée par l'écran est indicative~; elle est confirmée à l'enregistrement. \item \textbf{Le jour est celui du serveur, en temps universel}~: entre minuit et une ou deux heures du matin, heure suisse, c'est encore la veille --- le 1\textsuperscript{er} janvier, une régularisation peut tomber dans l'exercice précédent, ou être refusée s'il est clôturé. \item \textbf{La numérotation.} Daté du premier jour, le complément porte pourtant le numéro suivant du journal~: dans cet exercice, l'ordre des numéros cesse de suivre l'ordre des dates. \end{itemize} @@ -759,7 +763,7 @@ \subsubsection{Compléter un compte oublié} \end{keshnote} \begin{keshtip} -Si votre premier exercice Kesh ne doit pas commencer au 1\textsuperscript{er} janvier de l'année courante (exercice décalé ou reprise antérieure), créez d'abord l'exercice voulu via \emph{Administration} → \emph{Exercices comptables} — l'écriture d'ouverture est datée au premier jour du \textbf{premier} exercice existant. Un exercice antérieur créé \emph{après} la génération devient à son tour le premier~: un complément serait alors daté de son premier jour, et non de celui de l'écriture d'ouverture. +Si votre premier exercice Kesh ne doit pas commencer au 1\textsuperscript{er} janvier de l'année courante (exercice décalé ou reprise antérieure), créez d'abord l'exercice voulu via \emph{Administration} → \emph{Exercices comptables} — l'écriture d'ouverture est datée au premier jour du \textbf{premier} exercice existant. Un exercice antérieur créé \emph{après} la génération devient à son tour le premier~: un complément serait alors daté de son premier jour, et non de celui de l'écriture d'ouverture. (Cette création est refusée si un exercice postérieur est déjà clôturé --- voir la création d'un nouvel exercice.) \end{keshtip} %%───────────────────────────────────────────────────────────────────────────── diff --git a/frontend/src/lib/features/journal-entries/blocker-messages.ts b/frontend/src/lib/features/journal-entries/blocker-messages.ts index b6bec6fe1..c8ee9b1ab 100644 --- a/frontend/src/lib/features/journal-entries/blocker-messages.ts +++ b/frontend/src/lib/features/journal-entries/blocker-messages.ts @@ -91,7 +91,7 @@ export function modificationBlockerLabel( case 'LATER_FISCAL_YEAR_CLOSED': return i18nMsg( 'journal-entries-modify-blocked-later-fiscal-year-closed', - 'L’exercice postérieur { $name } est clôturé, et son bilan reprend cette écriture : elle reste figée tant qu’il l’est. Un administrateur peut rouvrir cet exercice ; sinon, corrigez par une contre-passation.', + 'L’exercice postérieur { $name } est clôturé, et son bilan reprend cette écriture : elle reste figée tant qu’il l’est. Corrigez-la par une contre-passation ; sinon, un administrateur rouvre les exercices clôturés, en commençant par le plus récent.', { name: label ?? '' } ); case 'PERIOD_LOCKED': diff --git a/frontend/src/lib/shared/i18n-keys.test.ts b/frontend/src/lib/shared/i18n-keys.test.ts index 41faf04dd..2460c0f89 100644 --- a/frontend/src/lib/shared/i18n-keys.test.ts +++ b/frontend/src/lib/shared/i18n-keys.test.ts @@ -493,7 +493,12 @@ const ATTENDU = { // aux deux bornes) : son libellé (`settings-invoicing-payable-account`) et l'option // vide de son sélecteur (`settings-invoicing-select-none`). `sitesNonResolus`, // `relais`, `sitesGabarit` inchangés (aucun site dynamique ajouté). - sitesTotal: 1915, + // Story 15-12a (#543) : **1915 → 1916** (+1), le `title` du bouton « Clôturer » + // désactivé sous un exercice antérieur ouvert (`fiscal-year-close-blocked-earlier-open`) + // dans `routes/(app)/settings/fiscal-years/+page.svelte` (56 → 57, recompté par + // `grep -oE "\b(msg|i18nMsg)\("` aux deux bornes, `5e4bec50` et la branche). + // `sitesNonResolus`, `relais`, `sitesGabarit` inchangés (un littéral). + sitesTotal: 1916, sitesNonResolus: 31, relais: 6, sitesGabarit: 10, diff --git a/frontend/src/routes/(app)/settings/fiscal-years/+page.svelte b/frontend/src/routes/(app)/settings/fiscal-years/+page.svelte index 3ef635a46..e46d85d72 100644 --- a/frontend/src/routes/(app)/settings/fiscal-years/+page.svelte +++ b/frontend/src/routes/(app)/settings/fiscal-years/+page.svelte @@ -106,6 +106,26 @@ return best; } + /** + * Story 15-12a (#543) — le **plus ancien** exercice antérieur encore ouvert, + * ou `null`. Sœur de `nearestLaterClosed`, alignée sur la requête serveur de + * la clôture (`FIND_EARLIER_OPEN_SQL`, `ORDER BY start_date ASC LIMIT 1`) : + * c'est lui qu'il faut clôturer d'abord — en nommer un plus proche enverrait + * l'utilisateur sur un exercice que la même garde refuserait à son tour. Le + * serveur reste l'autorité (409 `EARLIER_FISCAL_YEAR_OPEN`). + */ + function earliestEarlierOpen(fy: FiscalYearResponse): FiscalYearResponse | null { + let best: FiscalYearResponse | null = null; + for (const other of fiscalYears) { + if (other.status === 'Open' && other.startDate < fy.startDate) { + if (best === null || other.startDate < best.startDate) { + best = other; + } + } + } + return best; + } + // --- Loading --- async function loadFiscalYears(): Promise { loading = true; @@ -223,9 +243,16 @@ closeTarget = null; } catch (err) { if (isApiError(err)) { - // Story 3.7 P3-M8 — mapping context-aware ILLEGAL_STATE_TRANSITION - // vers la clé i18n spécifique « déjà clôturé ». - if (err.code === 'ILLEGAL_STATE_TRANSITION') { + // Story 15-12a (#543) — un exercice antérieur est encore ouvert : + // le message du serveur le nomme. Traité AVANT la branche + // ILLEGAL_STATE_TRANSITION, qui dirait « déjà clôturé » à tort. + if (err.code === 'EARLIER_FISCAL_YEAR_OPEN') { + closeError = err.message; + notifyError(err.message); + closeOpen = false; + } else if (err.code === 'ILLEGAL_STATE_TRANSITION') { + // Story 3.7 P3-M8 — mapping context-aware ILLEGAL_STATE_TRANSITION + // vers la clé i18n spécifique « déjà clôturé ». closeError = msg( 'error-fiscal-year-already-closed', 'Cet exercice est déjà clôturé' @@ -348,35 +375,54 @@ >