From 80c875be9a216d7a94a102389f5e163433571c18 Mon Sep 17 00:00:00 2001 From: Ronan Lenouvel Date: Sat, 17 Oct 2026 12:36:52 +0200 Subject: [PATCH 1/7] =?UTF-8?q?=E2=9C=A8=20feat(Export):=20export=20CSV=20?= =?UTF-8?q?complet=20d'un=20exercice,=20cellules=20neutralis=C3=A9es,=20le?= =?UTF-8?q?cture=20par=20paquets=20(#362)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Accounting/Export/CsvResponse.php | 57 ++++++++ src/Accounting/Export/CsvSafe.php | 25 ++++ src/Accounting/Export/LedgerExportService.php | 92 +++++++++++++ .../Repository/EntryRepositoryInterface.php | 7 + .../Repository/MysqlEntryRepository.php | 15 +++ tests/Accounting/Export/CsvSafeTest.php | 55 ++++++++ .../Export/LedgerExportServiceTest.php | 123 ++++++++++++++++++ 7 files changed, 374 insertions(+) create mode 100644 src/Accounting/Export/CsvResponse.php create mode 100644 src/Accounting/Export/CsvSafe.php create mode 100644 src/Accounting/Export/LedgerExportService.php create mode 100644 tests/Accounting/Export/CsvSafeTest.php create mode 100644 tests/Accounting/Export/LedgerExportServiceTest.php diff --git a/src/Accounting/Export/CsvResponse.php b/src/Accounting/Export/CsvResponse.php new file mode 100644 index 0000000..6450f90 --- /dev/null +++ b/src/Accounting/Export/CsvResponse.php @@ -0,0 +1,57 @@ +> $rows */ + public function __construct(private readonly iterable $rows, string $filename) + { + parent::__construct('', 200, [ + 'Content-Type' => 'text/csv; charset=utf-8', + 'Content-Disposition' => 'attachment; filename="' . $filename . '"', + 'X-Content-Type-Options' => 'nosniff', + 'Cache-Control' => 'private, no-store', + ]); + } + + public function body(): string + { + $out = fopen('php://temp', 'w+'); + if ($out === false) { + return ''; + } + $this->write($out); + rewind($out); + + return (string) stream_get_contents($out); + } + + public function send(): void + { + $this->sendHeaders(); + $out = fopen('php://output', 'w'); + if ($out !== false) { + $this->write($out); + fclose($out); + } + } + + /** @param resource $out */ + private function write($out): void + { + fwrite($out, "\xEF\xBB\xBF"); + foreach ($this->rows as $row) { + fputcsv($out, $row, ';', '"', '', "\r\n"); + } + } +} diff --git a/src/Accounting/Export/CsvSafe.php b/src/Accounting/Export/CsvSafe.php new file mode 100644 index 0000000..b0bc1fc --- /dev/null +++ b/src/Accounting/Export/CsvSafe.php @@ -0,0 +1,25 @@ +> l'en-tête puis une ligne par écriture, par numéro + * @throws FiscalYearNotFoundException + */ + public function rows(User $actor, int $yearId): iterable + { + $this->access->assertAdmin($actor); + $this->years->find($yearId) ?? throw new FiscalYearNotFoundException(); + $this->logger->info('Comptabilité : export du livre', ['actor' => $actor->id(), 'year' => $yearId]); + + return $this->generate($yearId); + } + + /** @return \Generator> */ + private function generate(int $yearId): \Generator + { + $names = []; + foreach ($this->members->listActive(self::MEMBER_LIMIT) as $member) { + $names[$member->id] = $member->displayName; + } + yield self::HEADER; + $numbers = []; + foreach ($this->entries->eachByYear($yearId) as $entry) { + $numbers[$entry->id] = $entry->number; + yield $this->row($entry, $names, $numbers); + } + } + + /** + * @param array $names + * @param array $numbers numéros des écritures déjà lues, par identifiant + * @return list + */ + private function row(AccountingEntry $entry, array $names, array $numbers): array + { + $isRecette = $entry->direction === Direction::Recette; + $amount = intdiv($entry->amountCents, 100) . ',' . str_pad((string) ($entry->amountCents % 100), 2, '0', STR_PAD_LEFT); + + return [ + (string) $entry->number, + $entry->occurredOn->format('d/m/Y'), + CsvSafe::cell($entry->label), + $entry->category->label(), + $entry->direction->label(), + $isRecette ? $amount : '', + $isRecette ? '' : $amount, + $entry->paymentMethod->label(), + CsvSafe::cell($entry->voucherRef ?? ''), + CsvSafe::cell($entry->note ?? ''), + $entry->memberUserId === null ? '' : CsvSafe::cell($names[$entry->memberUserId] ?? 'Ancien membre'), + $entry->periodMonth === null ? '' : MonthLabel::of($entry->periodMonth), + $entry->reversesEntryId === null ? '' : (isset($numbers[$entry->reversesEntryId]) ? (string) $numbers[$entry->reversesEntryId] : 'autre exercice'), + ]; + } +} diff --git a/src/Accounting/Repository/EntryRepositoryInterface.php b/src/Accounting/Repository/EntryRepositoryInterface.php index c42ee40..0dc1bda 100644 --- a/src/Accounting/Repository/EntryRepositoryInterface.php +++ b/src/Accounting/Repository/EntryRepositoryInterface.php @@ -26,6 +26,13 @@ public function reversalOf(int $id): ?int; /** @return list par numéro, au plus `$limit` */ public function listByYear(int $fiscalYearId, int $limit): array; + /** + * Toutes les écritures de l'exercice par numéro, lues par paquets (jamais tout en mémoire) : pour l'export complet. + * + * @return \Generator + */ + public function eachByYear(int $fiscalYearId): \Generator; + public function totals(int $fiscalYearId): LedgerTotals; /** Mouvement net de la BANQUE (tout sauf les espèces) de l'exercice jusqu'à `$date` incluse : recettes moins dépenses. */ diff --git a/src/Accounting/Repository/MysqlEntryRepository.php b/src/Accounting/Repository/MysqlEntryRepository.php index 5aa56ef..1042c7b 100644 --- a/src/Accounting/Repository/MysqlEntryRepository.php +++ b/src/Accounting/Repository/MysqlEntryRepository.php @@ -83,6 +83,21 @@ public function listByYear(int $fiscalYearId, int $limit): array return array_map($this->hydrate(...), $statement->fetchAll(\PDO::FETCH_ASSOC)); } + public function eachByYear(int $fiscalYearId): \Generator + { + $after = 0; + do { + $statement = $this->pdo->prepare('SELECT * FROM accounting_entries WHERE fiscal_year_id = :year AND entry_number > :after ORDER BY entry_number LIMIT 500'); + $statement->execute(['year' => $fiscalYearId, 'after' => $after]); + $rows = $statement->fetchAll(\PDO::FETCH_ASSOC); + foreach ($rows as $row) { + $entry = $this->hydrate($row); + $after = $entry->number; + yield $entry; + } + } while (count($rows) === 500); + } + public function totals(int $fiscalYearId): LedgerTotals { $statement = $this->pdo->prepare( diff --git a/tests/Accounting/Export/CsvSafeTest.php b/tests/Accounting/Export/CsvSafeTest.php new file mode 100644 index 0000000..e5fac74 --- /dev/null +++ b/tests/Accounting/Export/CsvSafeTest.php @@ -0,0 +1,55 @@ + */ + public static function dangerous(): iterable + { + yield 'égal' => ['=1+1', "'=1+1"]; + yield 'plus' => ['+33612345678', "'+33612345678"]; + yield 'moins' => ['-2+3', "'-2+3"]; + yield 'arobase' => ['@SUM(A1)', "'@SUM(A1)"]; + yield 'tabulation' => ["\t=1", "'\t=1"]; + yield 'retour chariot' => ["\r=1", "'\r=1"]; + yield 'saut de ligne' => ["\n=1", "'\n=1"]; + yield 'espaces puis égal' => [' =1+1', "' =1+1"]; + yield 'espace insécable puis égal' => ["\u{a0}=1", "'\u{a0}=1"]; + yield 'DDE' => ['=cmd|\' /C calc\'!A0', "'=cmd|' /C calc'!A0"]; + yield 'hyperlien' => ['=HYPERLINK("http://x.test","a")', "'=HYPERLINK(\"http://x.test\",\"a\")"]; + } + + #[Test] + #[DataProvider('dangerous')] + public function testADangerousStartIsNeutralised(string $input, string $expected): void + { + self::assertSame($expected, CsvSafe::cell($input)); + } + + /** @return iterable */ + public static function harmless(): iterable + { + yield 'texte' => ['Cotisation']; + yield 'vide' => ['']; + yield 'accents' => ['Électricité de novembre']; + yield 'signe au milieu' => ['Loyer = 166 €']; + yield 'tiret au milieu' => ['Loyer - octobre']; + yield 'point-virgule' => ['a;b']; + } + + #[Test] + #[DataProvider('harmless')] + public function testAnOrdinaryTextIsUntouched(string $input): void + { + self::assertSame($input, CsvSafe::cell($input)); + } +} diff --git a/tests/Accounting/Export/LedgerExportServiceTest.php b/tests/Accounting/Export/LedgerExportServiceTest.php new file mode 100644 index 0000000..7fbaaed --- /dev/null +++ b/tests/Accounting/Export/LedgerExportServiceTest.php @@ -0,0 +1,123 @@ +export = new LedgerExportService(new AdminAccess(), $this->yearRepository, $this->entryRepository, new MysqlMemberDuesRepository($this->pdo), new NullLogger()); + } + + /** @return list> */ + private function rows(int $year): array + { + return iterator_to_array($this->export->rows($this->admin, $year), false); + } + + #[Test] + public function testTheExportHasAHeaderAndOneLinePerEntryWithFrenchAmounts(): void + { + $year = $this->openYear(); + $this->record($year, ['amount' => '1234,50']); + $this->expense($year, '156,00'); + + $rows = $this->rows($year); + + self::assertCount(3, $rows); + self::assertSame('Numéro', $rows[0][0]); + self::assertSame(['1', '05/11/2025', 'Cotisation', 'Cotisations', 'Argent reçu', '1234,50', '', 'Virement'], array_slice($rows[1], 0, 8)); + self::assertSame(['2', '05/11/2025', 'Loyer', 'Loyer', 'Argent dépensé', '', '156,00', 'Virement'], array_slice($rows[2], 0, 8)); + } + + #[Test] + public function testHostileTextIsNeutralisedInEveryTextColumn(): void + { + $year = $this->openYear(); + $this->record($year, ['label' => '=HYPERLINK("http://x.test","a")', 'voucherRef' => '+33612345678', 'note' => '@SUM(A1)']); + + $row = $this->rows($year)[1]; + + self::assertSame("'=HYPERLINK(\"http://x.test\",\"a\")", $row[2]); + self::assertSame("'+33612345678", $row[8]); + self::assertSame("'@SUM(A1)", $row[9]); + } + + #[Test] + public function testAReversalPointsToTheOriginalNumberAndADuesLineNamesTheMember(): void + { + $year = $this->openYear(); + $dues = $this->record($year, ['category' => 'cotisations', 'memberUserId' => (string) $this->musician->id(), 'periodMonth' => '2025-11']); + $this->entries->reverse($this->admin, $dues->id, ['reason' => 'Erreur', 'occurredOn' => '2025-11-06'], $this->clock); + + [, $original, $reversal] = $this->rows($year); + + self::assertSame('Musicien', $original[10]); + self::assertSame('novembre 2025', $original[11]); + self::assertSame('1', $reversal[12]); + } + + #[Test] + public function testOnlyAdministratorsExportAndAnUnknownYearIsRefusedBeforeAnyLine(): void + { + $year = $this->openYear(); + foreach ([$this->musician, $this->gone] as $actor) { + try { + $this->export->rows($actor, $year); + self::fail('refus attendu'); + } catch (AccessDeniedException) { + } + } + $this->expectException(FiscalYearNotFoundException::class); + $this->export->rows($this->admin, 99999999); + } + + #[Test] + public function testTheCsvResponseIsAFrenchSpreadsheetAttachmentWithAFixedName(): void + { + $year = $this->openYear(); + $this->record($year, ['label' => 'Libellé; avec "guillemets"']); + + $response = new CsvResponse($this->export->rows($this->admin, $year), 'comptabilite.csv'); + $body = $response->body(); + + self::assertStringStartsWith("\xEF\xBB\xBF", $body); + self::assertStringContainsString("Numéro;Date;Libellé;", $body); + self::assertStringContainsString('"Libellé; avec ""guillemets"""', $body); + self::assertStringContainsString("\"deux\nlignes\"", (new CsvResponse([["deux\nlignes"]], 'x.csv'))->body()); + self::assertSame('attachment; filename="comptabilite.csv"', $response->headers()['Content-Disposition']); + self::assertSame('nosniff', $response->headers()['X-Content-Type-Options']); + self::assertStringContainsString('no-store', $response->headers()['Cache-Control']); + } + + #[Test] + public function testMoreThanOnePageOfEntriesIsStreamedWithoutLossOrDuplicate(): void + { + $year = $this->openYear(); + $insert = $this->pdo->prepare('INSERT INTO accounting_entries (fiscal_year_id, entry_number, occurred_on, label, amount_cents, direction, category, payment_method, created_by_user_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'); + for ($i = 1; $i <= 1203; ++$i) { + $insert->execute([$year, $i, '2025-11-05', "Ligne {$i}", 100, 'recette', 'cotisations', 'virement', $this->admin->id(), '2025-11-05 10:00:00']); + } + + $rows = $this->rows($year); + + self::assertCount(1204, $rows); + self::assertSame(range(1, 1203), array_map('intval', array_column(array_slice($rows, 1), 0))); + } +} From a2137e702363b7659de297891ad2dc34144baecc Mon Sep 17 00:00:00 2001 From: Ronan Lenouvel Date: Sat, 17 Oct 2026 12:36:53 +0200 Subject: [PATCH 2/7] =?UTF-8?q?=E2=9C=A8=20feat(YearReportView):=20rapport?= =?UTF-8?q?=20financier=20d'un=20exercice=20et=20texte=20des=20chiffres=20?= =?UTF-8?q?pour=20l'AG=20(#362)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Accounting/Presenter/YearReportView.php | 92 ++++++++++++++++++ .../Presenter/YearReportViewTest.php | 95 +++++++++++++++++++ 2 files changed, 187 insertions(+) create mode 100644 src/Accounting/Presenter/YearReportView.php create mode 100644 tests/Accounting/Presenter/YearReportViewTest.php diff --git a/src/Accounting/Presenter/YearReportView.php b/src/Accounting/Presenter/YearReportView.php new file mode 100644 index 0000000..f26143e --- /dev/null +++ b/src/Accounting/Presenter/YearReportView.php @@ -0,0 +1,92 @@ + $entries par numéro + * @param array $memberNames noms des comptes actifs par identifiant + * @return array + */ + public function build(FiscalYear $year, LedgerTotals $totals, array $entries, array $memberNames, string $associationName): array + { + $balance = $year->openingBalanceCents + $totals->netCents(); + + return [ + 'associationName' => $associationName, + 'title' => 'Rapport financier — exercice ' . $year->label, + 'period' => 'du ' . $year->startsOn->format('d/m/Y') . ' au ' . $year->endsOn->format('d/m/Y'), + 'banner' => $year->isOpen() ? 'Exercice en cours : ces chiffres sont provisoires.' : null, + 'statusLine' => $year->status->label() . ($year->approvedOn !== null ? ' le ' . $year->approvedOn->format('d/m/Y') : ''), + 'opening' => $this->money->format($year->openingBalanceCents), + 'recettes' => $this->money->format($totals->recettesCents), + 'depenses' => $this->money->format($totals->depensesCents), + 'closing' => $this->money->format($year->closingBalanceCents ?? $balance), + 'closingLabel' => $year->isOpen() ? 'Solde actuel' : 'Solde final', + 'recetteLines' => $this->lines($totals, Direction::Recette), + 'depenseLines' => $this->lines($totals, Direction::Depense), + 'entries' => array_map(fn (AccountingEntry $e): array => [ + 'number' => $e->number, + 'date' => $e->occurredOn->format('d/m/Y'), + 'label' => $e->label, + 'category' => $e->category->label(), + 'amount' => $this->money->format($e->direction === Direction::Recette ? $e->amountCents : -$e->amountCents, true), + 'detail' => $e->periodMonth === null ? '' : 'Cotisation de ' . ($memberNames[$e->memberUserId ?? 0] ?? 'un ancien membre') . ' pour ' . MonthLabel::of($e->periodMonth), + ], $entries), + ]; + } + + /** Texte à reprendre dans le rapport financier du compte rendu d'assemblée : chiffres de l'exercice, sans aucun nom. */ + public function figuresText(FiscalYear $year, LedgerTotals $totals): string + { + $balance = $year->openingBalanceCents + $totals->netCents(); + $final = $year->closingBalanceCents ?? $balance; + $lines = [ + 'Chiffres de l\'exercice ' . $year->label . ' (du ' . $year->startsOn->format('d/m/Y') . ' au ' . $year->endsOn->format('d/m/Y') . ')' . ($year->isOpen() ? ', provisoires : l\'exercice n\'est pas clôturé' : '') . ' :', + 'Solde d\'ouverture : ' . $this->money->format($year->openingBalanceCents), + 'Recettes : ' . $this->money->format($totals->recettesCents), + ]; + foreach ($this->lines($totals, Direction::Recette) as $line) { + $lines[] = '- ' . $line['label'] . ' : ' . $line['amount']; + } + $lines[] = 'Dépenses : ' . $this->money->format($totals->depensesCents); + foreach ($this->lines($totals, Direction::Depense) as $line) { + $lines[] = '- ' . $line['label'] . ' : ' . $line['amount']; + } + $lines[] = ($year->isOpen() ? 'Solde actuel' : 'Solde final') . ' : ' . $this->money->format($final); + + return implode("\n", $lines); + } + + /** @return list les catégories d'un sens, montants positifs */ + private function lines(LedgerTotals $totals, Direction $direction): array + { + $lines = []; + foreach ($totals->netByCategory as $value => $net) { + $category = EntryCategory::tryFrom($value); + if ($category !== null && $category->direction() === $direction) { + $lines[] = ['label' => $category->label(), 'amount' => $this->money->format(abs($net))]; + } + } + + return $lines; + } +} diff --git a/tests/Accounting/Presenter/YearReportViewTest.php b/tests/Accounting/Presenter/YearReportViewTest.php new file mode 100644 index 0000000..87f8df0 --- /dev/null +++ b/tests/Accounting/Presenter/YearReportViewTest.php @@ -0,0 +1,95 @@ + 2000, 'loyer' => -3000]); + } + + private function view(): YearReportView + { + return new YearReportView(new MoneyFormatter()); + } + + #[Test] + public function testTheFiguresAreExactAndSplitByDirection(): void + { + $report = $this->view()->build($this->year(), $this->totals(), [], [], 'Association'); + + self::assertSame("100,00\u{a0}€", $report['opening']); + self::assertSame("20,00\u{a0}€", $report['recettes']); + self::assertSame("30,00\u{a0}€", $report['depenses']); + self::assertSame("90,00\u{a0}€", $report['closing']); + self::assertSame([['label' => 'Cotisations', 'amount' => "20,00\u{a0}€"]], $report['recetteLines']); + self::assertSame([['label' => 'Loyer', 'amount' => "30,00\u{a0}€"]], $report['depenseLines']); + self::assertSame('Association', $report['associationName']); + } + + #[Test] + public function testAnOpenYearIsAnnouncedAsProvisionalAndAnApprovedOneNamesTheDate(): void + { + self::assertStringContainsString('provisoires', (string) $this->view()->build($this->year(FiscalYearStatus::Ouvert), $this->totals(), [], [], '')['banner']); + self::assertNull($this->view()->build($this->year(), $this->totals(), [], [], '')['banner']); + $approved = $this->view()->build($this->year(FiscalYearStatus::Approuve, new \DateTimeImmutable('2026-12-12')), $this->totals(), [], [], ''); + self::assertStringContainsString('12/12/2026', $approved['statusLine']); + } + + #[Test] + public function testTheJournalLinesCarrySignedAmountsAndTheMemberName(): void + { + $entry = new AccountingEntry(7, 1, 1, new \DateTimeImmutable('2025-11-05'), 'Cotisation', 2000, Direction::Recette, EntryCategory::Cotisations, PaymentMethod::Virement, null, null, null, 5, new \DateTimeImmutable('2025-11-01')); + + $report = $this->view()->build($this->year(), $this->totals(), [$entry], [5 => 'Jeanne'], ''); + + self::assertSame('Cotisation de Jeanne pour novembre 2025', $report['entries'][0]['detail']); + self::assertSame("+20,00\u{a0}€", $report['entries'][0]['amount']); + self::assertSame('05/11/2025', $report['entries'][0]['date']); + } + + #[Test] + public function testTheFiguresTextIsPlainTextForTheAssemblyMinutes(): void + { + $text = $this->view()->figuresText($this->year(), $this->totals()); + + self::assertStringContainsString('exercice 2025-2026', $text); + self::assertStringContainsString("Solde d'ouverture : 100,00\u{a0}€", $text); + self::assertStringContainsString("Recettes : 20,00\u{a0}€", $text); + self::assertStringContainsString("Dépenses : 30,00\u{a0}€", $text); + self::assertStringContainsString("Solde final : 90,00\u{a0}€", $text); + self::assertStringContainsString("- Cotisations : 20,00\u{a0}€", $text); + self::assertStringContainsString("- Loyer : 30,00\u{a0}€", $text); + self::assertStringNotContainsString('<', $text); + } + + #[Test] + public function testAnOpenYearFiguresTextSaysTheBalanceIsProvisional(): void + { + $text = $this->view()->figuresText($this->year(FiscalYearStatus::Ouvert), $this->totals()); + + self::assertStringContainsString('provisoire', $text); + self::assertStringContainsString("Solde actuel : 90,00\u{a0}€", $text); + } +} From fbbe866357e088b0d6843094da6a71ca770c798d Mon Sep 17 00:00:00 2001 From: Ronan Lenouvel Date: Sat, 17 Oct 2026 12:36:53 +0200 Subject: [PATCH 3/7] =?UTF-8?q?=E2=9C=A8=20feat(Report):=20rapport=20impri?= =?UTF-8?q?mable,=20export=20CSV=20et=20chiffres=20de=20l'exercice=20en=20?= =?UTF-8?q?lecture=20seule=20(#362)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/routes.php | 4 + config/services.php | 22 ++++ .../Controller/ReportController.php | 73 +++++++++++ .../Http/AccountingReportHttpTest.php | 124 ++++++++++++++++++ 4 files changed, 223 insertions(+) create mode 100644 src/Accounting/Controller/ReportController.php create mode 100644 tests/Accounting/Http/AccountingReportHttpTest.php diff --git a/config/routes.php b/config/routes.php index 880f1bc..bebae9e 100644 --- a/config/routes.php +++ b/config/routes.php @@ -8,6 +8,7 @@ use App\Accounting\Controller\Api\StatementApiController; use App\Accounting\Controller\CotisationPageController; use App\Accounting\Controller\RecurringPageController; +use App\Accounting\Controller\ReportController; use App\Accounting\Controller\StatementPageController; use App\Association\Controller\Api\AssociationBoardApiController; use App\Association\Controller\Api\AssociationTextApiController; @@ -66,6 +67,8 @@ ['GET', '/admin/accounting', [AccountingPageController::class, 'index']], ['GET', '/admin/accounting/recurring', [RecurringPageController::class, 'index']], ['GET', '/admin/accounting/cotisations', [CotisationPageController::class, 'index']], + ['GET', '/admin/accounting/years/{id}/report', [ReportController::class, 'report']], + ['GET', '/admin/accounting/years/{id}/export.csv', [ReportController::class, 'csv']], ['GET', '/admin/accounting/statements', [StatementPageController::class, 'index']], ['GET', '/admin/accounting/statements/{id}/file', [StatementPageController::class, 'download']], ['GET', '/admin/board', [AssociationBoardPageController::class, 'index']], @@ -130,6 +133,7 @@ ['DELETE', '/api/admin/assemblies/{id}', [AssemblyApiController::class, 'destroy']], ['POST', '/api/admin/accounting/years', [AccountingApiController::class, 'createYear']], ['POST', '/api/admin/accounting/statements', [StatementApiController::class, 'store']], + ['GET', '/api/admin/accounting/figures', [ReportController::class, 'figures']], ['POST', '/api/admin/accounting/cotisations/exemptions', [AccountingApiController::class, 'setExemption']], ['POST', '/api/admin/accounting/recurring', [RecurringApiController::class, 'create']], ['PUT', '/api/admin/accounting/recurring/{id}', [RecurringApiController::class, 'update']], diff --git a/config/services.php b/config/services.php index bd5bdd8..483e9fe 100644 --- a/config/services.php +++ b/config/services.php @@ -8,6 +8,9 @@ use App\Accounting\Controller\Api\StatementApiController; use App\Accounting\Controller\CotisationPageController; use App\Accounting\Controller\RecurringPageController; +use App\Accounting\Controller\ReportController; +use App\Accounting\Export\LedgerExportService; +use App\Accounting\Presenter\YearReportView; use App\Accounting\Presenter\CotisationView; use App\Accounting\Service\Dues\CotisationService; use App\Accounting\Presenter\RecurringView; @@ -717,6 +720,25 @@ $c->get(LoggerInterface::class), )); + $container->set(LedgerExportService::class, fn ($c) => new LedgerExportService( + $c->get(AdminAccess::class), + $c->get(FiscalYearRepositoryInterface::class), + $c->get(EntryRepositoryInterface::class), + $c->get(MemberDuesRepositoryInterface::class), + $c->get(LoggerInterface::class), + )); + + $container->set(ReportController::class, fn ($c) => new ReportController( + $c->get(TemplateRendererInterface::class), + $c->get(AuthGuard::class), + $c->get(FiscalYearService::class), + $c->get(EntryService::class), + $c->get(CotisationService::class), + $c->get(LedgerExportService::class), + new YearReportView(new MoneyFormatter()), + (string) ($config['assembly']['association_name'] ?? ''), + )); + $container->set(CotisationPageController::class, fn ($c) => new CotisationPageController( $c->get(TemplateRendererInterface::class), $c->get(CsrfTokenManager::class), diff --git a/src/Accounting/Controller/ReportController.php b/src/Accounting/Controller/ReportController.php new file mode 100644 index 0000000..6502576 --- /dev/null +++ b/src/Accounting/Controller/ReportController.php @@ -0,0 +1,73 @@ +authGuard->requireRole(UserRole::Admin); + $summary = $this->years->summary($admin, StrictId::orDenied($id)); + + return new Response($this->renderer->render('admin/accounting/report', [ + 'report' => $this->view->build($summary['year'], $summary['totals'], $this->entries->list($admin, $summary['year']->id), $this->dues->memberNames($admin), $this->associationName), + 'backHref' => '/admin/accounting?year=' . $summary['year']->id, + ])); + } + + public function csv(Request $request, string $id): Response + { + $admin = $this->authGuard->requireRole(UserRole::Admin); + $yearId = StrictId::orDenied($id); + + return new CsvResponse($this->export->rows($admin, $yearId), 'comptabilite-exercice-' . $yearId . '.csv'); + } + + /** Chiffres de l'exercice demandé (`year`) ou, à défaut, du plus récent, en texte brut. */ + public function figures(Request $request): JsonResponse + { + $admin = $this->authGuard->requireRole(UserRole::Admin); + $requested = $request->query('year'); + $yearId = $requested === null ? ($this->years->list($admin)[0]->id ?? null) : StrictId::orDenied($requested); + if ($yearId === null) { + throw new FiscalYearNotFoundException(); + } + $summary = $this->years->summary($admin, $yearId); + + return new JsonResponse(['label' => $summary['year']->label, 'text' => $this->view->figuresText($summary['year'], $summary['totals'])]); + } +} diff --git a/tests/Accounting/Http/AccountingReportHttpTest.php b/tests/Accounting/Http/AccountingReportHttpTest.php new file mode 100644 index 0000000..060ce65 --- /dev/null +++ b/tests/Accounting/Http/AccountingReportHttpTest.php @@ -0,0 +1,124 @@ +makeUser('admin', UserRole::Admin); + $this->makeUser('musicien', UserRole::Musicien); + } + + private function year(): int + { + [, $body] = $this->call('admin', 'POST', '/api/admin/accounting/years', ['label' => '2025-2026', 'startsOn' => '2025-10-01', 'endsOn' => '2026-09-30', 'openingBalance' => '100,00']); + $id = (int) $this->json($body)['id']; + $this->call('admin', 'POST', "/api/admin/accounting/years/{$id}/entries", ['occurredOn' => '2025-11-05', 'label' => '=HYPERLINK("http://x.test")', 'amount' => '20,00', 'direction' => 'recette', 'category' => 'cotisations', 'paymentMethod' => 'virement']); + $this->call('admin', 'POST', "/api/admin/accounting/years/{$id}/entries", ['occurredOn' => '2025-11-06', 'label' => '', 'amount' => '5,00', 'direction' => 'depense', 'category' => 'loyer', 'paymentMethod' => 'virement', 'voucherRef' => 'F-1']); + + return $id; + } + + /** @return list */ + private function urls(int|string $id): array + { + return ["/admin/accounting/years/{$id}/report", "/admin/accounting/years/{$id}/export.csv"]; + } + + #[Test] + public function testTheReportShowsTheFiguresAndEscapesEverything(): void + { + $id = $this->year(); + + [$status, $html] = $this->call('admin', 'GET', "/admin/accounting/years/{$id}/report"); + + self::assertSame(200, $status); + self::assertStringContainsString("100,00\u{a0}€", $html); + self::assertStringContainsString('provisoires', $html); + self::assertStringNotContainsString(' + + diff --git a/templates/admin/assemblies/edit.php b/templates/admin/assemblies/edit.php index 8d8eced..9506b31 100644 --- a/templates/admin/assemblies/edit.php +++ b/templates/admin/assemblies/edit.php @@ -165,6 +165,7 @@
+
From 47d7f36c47e8b4c8f1daf1ad22546047da48f945 Mon Sep 17 00:00:00 2001 From: Ronan Lenouvel Date: Sat, 17 Oct 2026 12:36:53 +0200 Subject: [PATCH 5/7] =?UTF-8?q?=E2=9C=A8=20feat(Report):=20bouton=20=C2=AB?= =?UTF-8?q?=20Reprendre=20les=20chiffres=20de=20l'exercice=20=C2=BB=20du?= =?UTF-8?q?=20compte=20rendu=20d'AG=20(#362)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../js/admin/accounting/admin-figures.js | 33 +++++++++++++++++++ public/assets/js/admin/accounting/figures.js | 13 ++++++++ .../js/admin/accounting/figures.test.js | 21 ++++++++++++ public/assets/js/app.js | 2 ++ 4 files changed, 69 insertions(+) create mode 100644 public/assets/js/admin/accounting/admin-figures.js create mode 100644 public/assets/js/admin/accounting/figures.js create mode 100644 public/assets/js/admin/accounting/figures.test.js diff --git a/public/assets/js/admin/accounting/admin-figures.js b/public/assets/js/admin/accounting/admin-figures.js new file mode 100644 index 0000000..5a93f8a --- /dev/null +++ b/public/assets/js/admin/accounting/admin-figures.js @@ -0,0 +1,33 @@ +/** + * Bouton « Reprendre les chiffres de l'exercice » du compte rendu d'AG (#362) : lit les chiffres de l'exercice le plus récent (lecture seule) + * et les ajoute au rapport financier, sans rien enregistrer : la personne relit puis enregistre le brouillon comme d'habitude. + */ +import { apiFetch } from '../../core/api.js'; +import { showToast } from '../../core/toast.js'; +import { mergeFigures } from './figures.js'; + +async function onClick(event) { + const button = event.target.closest('[data-import-figures]'); + if (button === null) { + return; + } + const field = document.getElementById('assembly-financialReport'); + if (field === null) { + return; + } + button.disabled = true; + try { + const figures = await apiFetch('/api/admin/accounting/figures'); + field.value = mergeFigures(field.value, figures.text); + field.dispatchEvent(new Event('input', { bubbles: true })); + showToast(`Chiffres de l'exercice ${figures.label} ajoutés : relisez puis enregistrez.`, 'success'); + } catch (error) { + showToast(error?.status === 404 ? 'Aucun exercice dans la comptabilité.' : 'Impossible de reprendre les chiffres.', 'error'); + } finally { + button.disabled = false; + } +} + +export function initAdminFigures(root = document) { + root.addEventListener('click', onClick); +} diff --git a/public/assets/js/admin/accounting/figures.js b/public/assets/js/admin/accounting/figures.js new file mode 100644 index 0000000..a0b75b5 --- /dev/null +++ b/public/assets/js/admin/accounting/figures.js @@ -0,0 +1,13 @@ +/** + * Chiffres de l'exercice repris dans le rapport financier d'un compte rendu d'AG (#362), sans DOM : comment le texte reçu rejoint ce qui est + * déjà écrit. On n'écrase jamais la saisie : le texte s'ajoute après, et une seconde reprise ne le duplique pas. + */ +export function mergeFigures(current, figures) { + const written = current.trim(); + const incoming = figures.trim(); + if (incoming === '' || written.includes(incoming)) { + return current; + } + + return written === '' ? incoming : `${written}\n\n${incoming}`; +} diff --git a/public/assets/js/admin/accounting/figures.test.js b/public/assets/js/admin/accounting/figures.test.js new file mode 100644 index 0000000..eb3c345 --- /dev/null +++ b/public/assets/js/admin/accounting/figures.test.js @@ -0,0 +1,21 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mergeFigures } from './figures.js'; + +test('un rapport vide reçoit les chiffres tels quels', () => { + assert.equal(mergeFigures('', 'Recettes : 20,00 €'), 'Recettes : 20,00 €'); + assert.equal(mergeFigures(' \n', 'Recettes : 20,00 €'), 'Recettes : 20,00 €'); +}); + +test('la saisie existante est conservée : les chiffres s\'ajoutent après une ligne vide', () => { + assert.equal(mergeFigures('Le trésorier présente les comptes.', 'Recettes : 20,00 €'), 'Le trésorier présente les comptes.\n\nRecettes : 20,00 €'); +}); + +test('une seconde reprise ne duplique pas les chiffres', () => { + const once = mergeFigures('Intro', 'Recettes : 20,00 €'); + assert.equal(mergeFigures(once, 'Recettes : 20,00 €'), once); +}); + +test('un texte reçu vide ne change rien', () => { + assert.equal(mergeFigures('Intro', ' '), 'Intro'); +}); diff --git a/public/assets/js/app.js b/public/assets/js/app.js index 553bf6e..5923c37 100644 --- a/public/assets/js/app.js +++ b/public/assets/js/app.js @@ -14,6 +14,7 @@ import { initAdminAccounting } from './admin/accounting/admin-accounting.js'; import { initAdminStatements } from './admin/accounting/admin-statements.js'; import { initAdminRecurring } from './admin/accounting/admin-recurring.js'; import { initAdminCotisations } from './admin/accounting/admin-cotisations.js'; +import { initAdminFigures } from './admin/accounting/admin-figures.js'; import { initAdminBoard } from './admin/board/admin-board.js'; import { initAdminTexts } from './admin/texts/admin-texts.js'; import { initAdminAssemblies } from './admin/assembly/admin-assemblies.js'; @@ -48,6 +49,7 @@ document.addEventListener('DOMContentLoaded', () => { initAdminStatements(); initAdminRecurring(); initAdminCotisations(); + initAdminFigures(); initAdminBoard(); initAdminTexts(); initBookingsBadge(); From d366687a995e4d7f86ef95d1d54ca7f425e3fc4b Mon Sep 17 00:00:00 2001 From: Ronan Lenouvel Date: Sat, 17 Oct 2026 12:36:53 +0200 Subject: [PATCH 6/7] =?UTF-8?q?=E2=9C=85=20test(IdorMatrix):=20refus=20du?= =?UTF-8?q?=20rapport,=20de=20l'export=20et=20des=20chiffres=20(#362)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/Security/IdorMatrixTest.php | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/Security/IdorMatrixTest.php b/tests/Security/IdorMatrixTest.php index 7b5108d..b95fc04 100644 --- a/tests/Security/IdorMatrixTest.php +++ b/tests/Security/IdorMatrixTest.php @@ -499,7 +499,7 @@ public function testAccountingPageIsForAdministratorsOnly(): void #[Test] public function testStatementPagesAndDownloadAreForAdministratorsOnly(): void { - foreach (['/admin/accounting/statements', '/admin/accounting/statements/1/file', '/admin/accounting/recurring', '/admin/accounting/cotisations'] as $path) { + foreach (['/admin/accounting/statements', '/admin/accounting/statements/1/file', '/admin/accounting/recurring', '/admin/accounting/cotisations', '/admin/accounting/years/1/report', '/admin/accounting/years/1/export.csv'] as $path) { self::assertSame(302, $this->call('anon', 'GET', $path)[0], "anonyme {$path}"); foreach (['stranger', 'memberA', 'managerA', 'outsiderB', 'dual'] as $actor) { self::assertSame(403, $this->call($actor, 'GET', $path)[0], "{$actor} {$path}"); @@ -511,6 +511,13 @@ public function testStatementPagesAndDownloadAreForAdministratorsOnly(): void self::assertSame(404, $this->call('admin', 'GET', '/admin/accounting/statements/' . self::MISSING_ID . '/file')[0]); foreach (['0', '-1', 'abc', '1e0', '007', '99999999999999999999', '%00'] as $malformed) { self::assertSame(403, $this->call('admin', 'GET', "/admin/accounting/statements/{$malformed}/file")[0], $malformed); + self::assertSame(403, $this->call('admin', 'GET', "/admin/accounting/years/{$malformed}/report")[0], $malformed); + self::assertSame(403, $this->call('admin', 'GET', "/admin/accounting/years/{$malformed}/export.csv")[0], $malformed); + } + self::assertSame(404, $this->call('admin', 'GET', '/admin/accounting/years/' . self::MISSING_ID . '/report')[0]); + self::assertSame(401, $this->call('anon', 'GET', '/api/admin/accounting/figures')[0]); + foreach (['stranger', 'memberA', 'managerA', 'outsiderB', 'dual'] as $actor) { + self::assertSame(403, $this->call($actor, 'GET', '/api/admin/accounting/figures')[0], $actor); } } From 05c431452ab8fdff0ec5c1d0bf1162382ce29b0a Mon Sep 17 00:00:00 2001 From: Ronan Lenouvel Date: Sat, 17 Oct 2026 12:36:53 +0200 Subject: [PATCH 7/7] =?UTF-8?q?=F0=9F=93=96=20docs(Accounting):=20rapport,?= =?UTF-8?q?=20export=20CSV=20et=20chiffres=20de=20l'AG=20(#362)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/architecture.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.claude/architecture.md b/.claude/architecture.md index eea3b15..cef4d96 100644 --- a/.claude/architecture.md +++ b/.claude/architecture.md @@ -487,3 +487,10 @@ Migration 044 : `member_user_id` et `period_month` sur `accounting_entries` (les - **Un adhérent, un mois** : vérifié par `EntryService` sous le verrou de l'exercice (pas de clé unique en base : une contre-passation puis une nouvelle saisie sont légitimes). Une cotisation annulée libère le mois. Limite connue : deux exercices différents saisis au même instant pour le même mois ne sont pas sérialisés (administrateurs seulement, négligeable). - **Tableau calculé depuis le livre** (`Service/Dues/CotisationService`) : payée / dispensée / à payer (mois atteint) / à venir, chaque état écrit en toutes lettres. Une dispense (réversible) ne peut pas viser un mois déjà payé. Aucun nom dans les journaux ni les e-mails. - **Anonymisation d'un compte** : la ligne reste, l'écriture est intacte ; le journal affiche « un ancien membre » si le nom n'est plus listé. Pas de modèle récurrent « cotisation » : le tableau montre déjà les adhérents attendus (YAGNI). + +## Comptabilité : rapport, export et chiffres de l'AG (#362, livraison 9) + +`GET /admin/accounting/years/{id}/report` (rapport financier imprimable, art. 11), `GET /admin/accounting/years/{id}/export.csv` (export complet, art. 13), `GET /api/admin/accounting/figures[?year=]` (texte des chiffres de l'exercice) : `ReportController`, `YearReportView`, `src/Accounting/Export/` (`CsvSafe`, `CsvResponse`, `LedgerExportService`). Administrateurs seulement, tout en lecture (`GET`, aucun effet de bord). +- **CSV pour tableur** : `;`, virgule décimale, UTF-8 avec BOM, colonnes Recette et Dépense (montants sans signe, faciles à additionner), guillemets et retours à la ligne protégés ; **tout texte saisi** (libellé, pièce, note, nom d'adhérent) passe par `CsvSafe` (apostrophe devant `=`, `+`, `-`, `@`, tabulation, retour, même après des espaces). Lecture par paquets de 500 (`eachByYear`, jamais tout en mémoire) ; nom de fichier fixé par le serveur (`comptabilite-exercice-{id}.csv`), `nosniff`, `Cache-Control: private, no-store`. Droits et exercice sont jugés AVANT le premier octet. +- **Rapport imprimable** : page autonome à fond blanc (comme le compte rendu d'AG), soldes, catégories par sens, journal ; un exercice ouvert est annoncé « provisoire ». Aucun script ni style en ligne. +- **Chiffres de l'AG** : le bouton « Reprendre les chiffres de l'exercice » du compte rendu d'AG ajoute le texte à la FIN du rapport financier (jamais d'écrasement, pas de doublon), sans rien enregistrer ; aucun nom dans ce texte.