From b3b29fd75317a4010974a61d7c8a72b895304460 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Sun, 21 Jun 2026 00:08:43 +0200 Subject: [PATCH] feat(reporting) : add transverse Reporting module (DBAL read-only, back) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LST-59 (3.1) backend. New native reporting module that aggregates across TimeTracking/ProjectManagement/Absence with ZERO direct inter-module imports — coupled only to the physical SQL schema via read-only DBAL (AuditLog provider pattern). - 4 read-only reports (ApiResource + DBAL provider + readonly DTO, paginationEnabled false, security reporting.view): /api/reports/ {time-per-project, time-per-user, tasks-by-status, absences-by-type}. All filters bound-param, dates validated YYYY-MM-DD (default = current month), int filters validated by regex (cs-fixer-stable). - No Doctrine entity, no migration. ReportFilterTrait centralises validation. Absence status compared by literal 'approved' to avoid importing the enum. - ReportingModule registered (id reporting, reporting.view/export perms); sidebar /reporting item gated by module + permission (ROLE_ADMIN section). 169 tests green (163 + 6), 4 routes exposed, cs-fixer clean. --- config/modules.php | 2 + config/sidebar.php | 1 + .../Application/DTO/AbsencesByTypeOutput.php | 17 ++++ .../Application/DTO/TasksByStatusOutput.php | 19 ++++ .../Application/DTO/TimePerProjectOutput.php | 19 ++++ .../Application/DTO/TimePerUserOutput.php | 19 ++++ .../Resource/AbsencesByTypeResource.php | 33 +++++++ .../Resource/TasksByStatusResource.php | 32 +++++++ .../Resource/TimePerProjectResource.php | 34 +++++++ .../Resource/TimePerUserResource.php | 33 +++++++ .../State/AbsencesByTypeProvider.php | 82 ++++++++++++++++ .../ApiPlatform/State/ReportFilterTrait.php | 89 +++++++++++++++++ .../State/TasksByStatusProvider.php | 67 +++++++++++++ .../State/TimePerProjectProvider.php | 81 ++++++++++++++++ .../ApiPlatform/State/TimePerUserProvider.php | 79 +++++++++++++++ src/Module/Reporting/ReportingModule.php | 44 +++++++++ .../Module/Reporting/ReportingApiTest.php | 96 +++++++++++++++++++ 17 files changed, 747 insertions(+) create mode 100644 src/Module/Reporting/Application/DTO/AbsencesByTypeOutput.php create mode 100644 src/Module/Reporting/Application/DTO/TasksByStatusOutput.php create mode 100644 src/Module/Reporting/Application/DTO/TimePerProjectOutput.php create mode 100644 src/Module/Reporting/Application/DTO/TimePerUserOutput.php create mode 100644 src/Module/Reporting/Infrastructure/ApiPlatform/Resource/AbsencesByTypeResource.php create mode 100644 src/Module/Reporting/Infrastructure/ApiPlatform/Resource/TasksByStatusResource.php create mode 100644 src/Module/Reporting/Infrastructure/ApiPlatform/Resource/TimePerProjectResource.php create mode 100644 src/Module/Reporting/Infrastructure/ApiPlatform/Resource/TimePerUserResource.php create mode 100644 src/Module/Reporting/Infrastructure/ApiPlatform/State/AbsencesByTypeProvider.php create mode 100644 src/Module/Reporting/Infrastructure/ApiPlatform/State/ReportFilterTrait.php create mode 100644 src/Module/Reporting/Infrastructure/ApiPlatform/State/TasksByStatusProvider.php create mode 100644 src/Module/Reporting/Infrastructure/ApiPlatform/State/TimePerProjectProvider.php create mode 100644 src/Module/Reporting/Infrastructure/ApiPlatform/State/TimePerUserProvider.php create mode 100644 src/Module/Reporting/ReportingModule.php create mode 100644 tests/Functional/Module/Reporting/ReportingApiTest.php diff --git a/config/modules.php b/config/modules.php index 27ca805..b0b6e90 100644 --- a/config/modules.php +++ b/config/modules.php @@ -13,6 +13,7 @@ use App\Module\Directory\DirectoryModule; use App\Module\Integration\IntegrationModule; use App\Module\Mail\MailModule; use App\Module\ProjectManagement\ProjectManagementModule; +use App\Module\Reporting\ReportingModule; use App\Module\TimeTracking\TimeTrackingModule; return [ @@ -23,4 +24,5 @@ return [ DirectoryModule::class, MailModule::class, IntegrationModule::class, + ReportingModule::class, ]; diff --git a/config/sidebar.php b/config/sidebar.php index 835e132..2f1e15f 100644 --- a/config/sidebar.php +++ b/config/sidebar.php @@ -38,6 +38,7 @@ return [ ['label' => 'sidebar.admin.teamAbsences', 'to' => '/team-absences', 'icon' => 'mdi:calendar-account-outline', 'module' => 'absence'], ['label' => 'sidebar.admin.directory', 'to' => '/directory', 'icon' => 'mdi:contact-multiple-outline', 'module' => 'directory'], ['label' => 'sidebar.admin.administration', 'to' => '/admin', 'icon' => 'mdi:cog-outline', 'permission' => 'core.users.view'], + ['label' => 'sidebar.admin.reporting', 'to' => '/reporting', 'icon' => 'mdi:chart-line', 'module' => 'reporting', 'permission' => 'reporting.view'], ], ], ]; diff --git a/src/Module/Reporting/Application/DTO/AbsencesByTypeOutput.php b/src/Module/Reporting/Application/DTO/AbsencesByTypeOutput.php new file mode 100644 index 0000000..2064eed --- /dev/null +++ b/src/Module/Reporting/Application/DTO/AbsencesByTypeOutput.php @@ -0,0 +1,17 @@ + + */ +final readonly class AbsencesByTypeProvider implements ProviderInterface +{ + use ReportFilterTrait; + + /** + * Backing-value de AbsenceStatus::Approved (litteral pour rester decouple). + */ + private const string STATUS_APPROVED = 'approved'; + + public function __construct( + #[Autowire(service: 'doctrine.dbal.default_connection')] + private Connection $connection, + ) {} + + /** + * @return list + */ + public function provide(Operation $operation, array $uriVariables = [], array $context = []): array + { + /** @var array $filters */ + $filters = $context['filters'] ?? []; + + $from = $this->validateDate($filters['from'] ?? null, 'from'); + $to = $this->validateDate($filters['to'] ?? null, 'to'); + $userId = $this->validatePositiveInt($filters['userId'] ?? null, 'userId'); + + $qb = $this->connection->createQueryBuilder() + ->select( + 'ar.type AS type', + 'COUNT(ar.id) AS cnt', + 'COALESCE(SUM(ar.counted_days), 0) AS total_days', + ) + ->from('absence_request', 'ar') + ->where('ar.status = :status') + ->setParameter('status', self::STATUS_APPROVED) + ->groupBy('ar.type') + ->orderBy('total_days', 'DESC') + ; + + if (null !== $from) { + $qb->andWhere('ar.start_date >= :from')->setParameter('from', $from); + } + if (null !== $to) { + $qb->andWhere('ar.end_date < :to')->setParameter('to', $to); + } + if (null !== $userId) { + $qb->andWhere('ar.user_id = :userId')->setParameter('userId', $userId); + } + + /** @var list> $rows */ + $rows = $qb->executeQuery()->fetchAllAssociative(); + + return array_map(static fn (array $row): AbsencesByTypeOutput => new AbsencesByTypeOutput( + type: (string) $row['type'], + count: (int) $row['cnt'], + totalDays: round((float) $row['total_days'], 2), + ), $rows); + } +} diff --git a/src/Module/Reporting/Infrastructure/ApiPlatform/State/ReportFilterTrait.php b/src/Module/Reporting/Infrastructure/ApiPlatform/State/ReportFilterTrait.php new file mode 100644 index 0000000..82da962 --- /dev/null +++ b/src/Module/Reporting/Infrastructure/ApiPlatform/State/ReportFilterTrait.php @@ -0,0 +1,89 @@ +format('Y-m-d') !== $value) { + throw new BadRequestHttpException(sprintf('Filtre "%s" invalide : date YYYY-MM-DD attendue (recu: "%s").', $paramName, $value)); + } + + return $value; + } + + /** + * Valide un identifiant entier strictement positif. + * + * @param mixed $value valeur brute issue des query-params + * + * @return null|int l'identifiant valide, ou null si absent/vide + */ + private function validatePositiveInt(mixed $value, string $paramName): ?int + { + if (null === $value || '' === $value) { + return null; + } + + // Les query-params arrivent sous forme de chaine : on valide donc le + // format decimal positif via regex (rejette "1.5", "abc", "-3", "01"...) + // plutot qu'une comparaison de type juggling sensible au cs-fixer. + if ((!is_string($value) && !is_int($value)) || 1 !== preg_match('/^[1-9][0-9]*$/', (string) $value)) { + throw new BadRequestHttpException(sprintf('Filtre "%s" invalide : entier positif attendu.', $paramName)); + } + + return (int) $value; + } + + /** + * Resout la plage [from, to[ avec defaut = mois courant si les deux bornes sont absentes. + * + * @param array $filters + * + * @return array{0: string, 1: string} [from inclus, to exclus] au format YYYY-MM-DD + */ + private function resolveDateRange(array $filters): array + { + $from = $this->validateDate($filters['from'] ?? null, 'from'); + $to = $this->validateDate($filters['to'] ?? null, 'to'); + + if (null === $from) { + $from = new DateTimeImmutable('first day of this month')->format('Y-m-d'); + } + if (null === $to) { + $to = new DateTimeImmutable('first day of next month')->format('Y-m-d'); + } + + return [$from, $to]; + } +} diff --git a/src/Module/Reporting/Infrastructure/ApiPlatform/State/TasksByStatusProvider.php b/src/Module/Reporting/Infrastructure/ApiPlatform/State/TasksByStatusProvider.php new file mode 100644 index 0000000..9727f50 --- /dev/null +++ b/src/Module/Reporting/Infrastructure/ApiPlatform/State/TasksByStatusProvider.php @@ -0,0 +1,67 @@ + + */ +final readonly class TasksByStatusProvider implements ProviderInterface +{ + use ReportFilterTrait; + + public function __construct( + #[Autowire(service: 'doctrine.dbal.default_connection')] + private Connection $connection, + ) {} + + /** + * @return list + */ + public function provide(Operation $operation, array $uriVariables = [], array $context = []): array + { + /** @var array $filters */ + $filters = $context['filters'] ?? []; + + $projectId = $this->validatePositiveInt($filters['projectId'] ?? null, 'projectId'); + + $qb = $this->connection->createQueryBuilder() + ->select( + 's.id AS status_id', + 's.label AS status_label', + 'COUNT(t.id) AS cnt', + ) + ->from('task', 't') + ->leftJoin('t', 'task_status', 's', 't.status_id = s.id') + ->where('t.archived = false') + ->groupBy('s.id') + ->addGroupBy('s.label') + ->orderBy('cnt', 'DESC') + ; + + if (null !== $projectId) { + $qb->andWhere('t.project_id = :projectId')->setParameter('projectId', $projectId); + } + + /** @var list> $rows */ + $rows = $qb->executeQuery()->fetchAllAssociative(); + + return array_map(static fn (array $row): TasksByStatusOutput => new TasksByStatusOutput( + statusId: null !== $row['status_id'] ? (int) $row['status_id'] : null, + statusLabel: null !== $row['status_label'] ? (string) $row['status_label'] : 'Sans statut', + count: (int) $row['cnt'], + ), $rows); + } +} diff --git a/src/Module/Reporting/Infrastructure/ApiPlatform/State/TimePerProjectProvider.php b/src/Module/Reporting/Infrastructure/ApiPlatform/State/TimePerProjectProvider.php new file mode 100644 index 0000000..e7f217a --- /dev/null +++ b/src/Module/Reporting/Infrastructure/ApiPlatform/State/TimePerProjectProvider.php @@ -0,0 +1,81 @@ + + */ +final readonly class TimePerProjectProvider implements ProviderInterface +{ + use ReportFilterTrait; + + public function __construct( + #[Autowire(service: 'doctrine.dbal.default_connection')] + private Connection $connection, + ) {} + + /** + * @return list + */ + public function provide(Operation $operation, array $uriVariables = [], array $context = []): array + { + /** @var array $filters */ + $filters = $context['filters'] ?? []; + + [$from, $to] = $this->resolveDateRange($filters); + $userId = $this->validatePositiveInt($filters['userId'] ?? null, 'userId'); + $projectId = $this->validatePositiveInt($filters['projectId'] ?? null, 'projectId'); + + $qb = $this->connection->createQueryBuilder() + ->select( + 'p.id AS project_id', + 'p.code AS project_code', + 'p.name AS project_name', + 'COALESCE(SUM(EXTRACT(EPOCH FROM (te.stopped_at - te.started_at)) / 3600), 0) AS hours', + 'COUNT(te.id) AS entry_count', + ) + ->from('time_entry', 'te') + ->innerJoin('te', 'project', 'p', 'te.project_id = p.id') + ->where('te.stopped_at IS NOT NULL') + ->andWhere('te.started_at >= :from') + ->andWhere('te.started_at < :to') + ->setParameter('from', $from) + ->setParameter('to', $to) + ->groupBy('p.id') + ->addGroupBy('p.code') + ->addGroupBy('p.name') + ->orderBy('hours', 'DESC') + ; + + if (null !== $userId) { + $qb->andWhere('te.user_id = :userId')->setParameter('userId', $userId); + } + if (null !== $projectId) { + $qb->andWhere('te.project_id = :projectId')->setParameter('projectId', $projectId); + } + + /** @var list> $rows */ + $rows = $qb->executeQuery()->fetchAllAssociative(); + + return array_map(static fn (array $row): TimePerProjectOutput => new TimePerProjectOutput( + projectId: (int) $row['project_id'], + projectCode: (string) $row['project_code'], + projectName: (string) $row['project_name'], + hours: round((float) $row['hours'], 2), + entryCount: (int) $row['entry_count'], + ), $rows); + } +} diff --git a/src/Module/Reporting/Infrastructure/ApiPlatform/State/TimePerUserProvider.php b/src/Module/Reporting/Infrastructure/ApiPlatform/State/TimePerUserProvider.php new file mode 100644 index 0000000..dc49581 --- /dev/null +++ b/src/Module/Reporting/Infrastructure/ApiPlatform/State/TimePerUserProvider.php @@ -0,0 +1,79 @@ + + */ +final readonly class TimePerUserProvider implements ProviderInterface +{ + use ReportFilterTrait; + + public function __construct( + #[Autowire(service: 'doctrine.dbal.default_connection')] + private Connection $connection, + ) {} + + /** + * @return list + */ + public function provide(Operation $operation, array $uriVariables = [], array $context = []): array + { + /** @var array $filters */ + $filters = $context['filters'] ?? []; + + [$from, $to] = $this->resolveDateRange($filters); + $projectId = $this->validatePositiveInt($filters['projectId'] ?? null, 'projectId'); + + $qb = $this->connection->createQueryBuilder() + ->select( + 'u.id AS user_id', + 'u.username AS username', + "TRIM(COALESCE(u.first_name, '') || ' ' || COALESCE(u.last_name, '')) AS full_name", + 'COALESCE(SUM(EXTRACT(EPOCH FROM (te.stopped_at - te.started_at)) / 3600), 0) AS hours', + 'COUNT(te.id) AS entry_count', + ) + ->from('time_entry', 'te') + ->innerJoin('te', '"user"', 'u', 'te.user_id = u.id') + ->where('te.stopped_at IS NOT NULL') + ->andWhere('te.started_at >= :from') + ->andWhere('te.started_at < :to') + ->setParameter('from', $from) + ->setParameter('to', $to) + ->groupBy('u.id') + ->addGroupBy('u.username') + ->addGroupBy('u.first_name') + ->addGroupBy('u.last_name') + ->orderBy('hours', 'DESC') + ; + + if (null !== $projectId) { + $qb->andWhere('te.project_id = :projectId')->setParameter('projectId', $projectId); + } + + /** @var list> $rows */ + $rows = $qb->executeQuery()->fetchAllAssociative(); + + return array_map(static fn (array $row): TimePerUserOutput => new TimePerUserOutput( + userId: (int) $row['user_id'], + username: (string) $row['username'], + fullName: (string) $row['full_name'], + hours: round((float) $row['hours'], 2), + entryCount: (int) $row['entry_count'], + ), $rows); + } +} diff --git a/src/Module/Reporting/ReportingModule.php b/src/Module/Reporting/ReportingModule.php new file mode 100644 index 0000000..d5eca17 --- /dev/null +++ b/src/Module/Reporting/ReportingModule.php @@ -0,0 +1,44 @@ + + */ + public static function permissions(): array + { + return [ + ['code' => 'reporting.view', 'label' => 'Consulter les rapports'], + ['code' => 'reporting.export', 'label' => 'Exporter les rapports'], + ]; + } +} diff --git a/tests/Functional/Module/Reporting/ReportingApiTest.php b/tests/Functional/Module/Reporting/ReportingApiTest.php new file mode 100644 index 0000000..d67594b --- /dev/null +++ b/tests/Functional/Module/Reporting/ReportingApiTest.php @@ -0,0 +1,96 @@ +request('GET', '/api/reports/time-per-project?from=2026-01-01&to=2027-01-01'); + + self::assertResponseStatusCodeSame(401); + } + + public function testAdminCanListTimePerProject(): void + { + $client = self::createClient(); + $this->loginUser($client, 'admin'); + + $client->request('GET', '/api/reports/time-per-project?from=2026-01-01&to=2027-01-01'); + + self::assertResponseIsSuccessful(); + $data = json_decode($client->getResponse()->getContent(), true); + self::assertArrayHasKey('member', $data); + + // Le rapport est non pagine : la structure hydra expose tout de meme un + // tableau `member`. Si des projets ont du temps logge, chaque membre + // porte au minimum projectId et hours. + foreach ($data['member'] as $row) { + self::assertArrayHasKey('projectId', $row); + self::assertArrayHasKey('hours', $row); + self::assertIsInt($row['projectId']); + } + } + + public function testValidProjectIdFilterIsAccepted(): void + { + $client = self::createClient(); + $this->loginUser($client, 'admin'); + + // projectId arrive en chaine ("1") : la validation doit l'accepter (200), + // garde-fou contre une regression de type-juggling sur le filtre entier. + $client->request('GET', '/api/reports/time-per-project?from=2026-01-01&to=2027-01-01&projectId=1'); + + self::assertResponseIsSuccessful(); + } + + public function testInvalidProjectIdFilterIsRejected(): void + { + $client = self::createClient(); + $this->loginUser($client, 'admin'); + + $client->request('GET', '/api/reports/time-per-project?projectId=abc'); + + self::assertResponseStatusCodeSame(400); + } + + public function testInvalidDateFilterIsRejected(): void + { + $client = self::createClient(); + $this->loginUser($client, 'admin'); + + $client->request('GET', '/api/reports/time-per-project?from=not-a-date'); + + self::assertResponseStatusCodeSame(400); + } + + public function testUserWithoutPermissionIsForbidden(): void + { + $client = self::createClient(); + $this->loginUser($client, 'alice'); + + $client->request('GET', '/api/reports/time-per-project?from=2026-01-01&to=2027-01-01'); + + self::assertResponseStatusCodeSame(403); + } + + private function loginUser(KernelBrowser $client, string $username): void + { + $em = self::getContainer()->get(EntityManagerInterface::class); + $user = $em->getRepository(User::class)->findOneBy(['username' => $username]); + self::assertInstanceOf(User::class, $user); + $client->loginUser($user); + } +}