feat(reporting) : add transverse Reporting module (DBAL read-only, back)
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.
This commit is contained in:
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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'],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Reporting\Application\DTO;
|
||||
|
||||
/**
|
||||
* Absences approuvees agregees par type sur une periode.
|
||||
*/
|
||||
final readonly class AbsencesByTypeOutput
|
||||
{
|
||||
public function __construct(
|
||||
public string $type,
|
||||
public int $count,
|
||||
public float $totalDays,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Reporting\Application\DTO;
|
||||
|
||||
/**
|
||||
* Nombre de taches non archivees regroupees par statut.
|
||||
*
|
||||
* `statusId` peut etre null pour les taches sans statut (LEFT JOIN).
|
||||
*/
|
||||
final readonly class TasksByStatusOutput
|
||||
{
|
||||
public function __construct(
|
||||
public ?int $statusId,
|
||||
public string $statusLabel,
|
||||
public int $count,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Reporting\Application\DTO;
|
||||
|
||||
/**
|
||||
* Heures travaillees agregees par projet sur une periode.
|
||||
*/
|
||||
final readonly class TimePerProjectOutput
|
||||
{
|
||||
public function __construct(
|
||||
public int $projectId,
|
||||
public string $projectCode,
|
||||
public string $projectName,
|
||||
public float $hours,
|
||||
public int $entryCount,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Reporting\Application\DTO;
|
||||
|
||||
/**
|
||||
* Heures travaillees agregees par utilisateur sur une periode.
|
||||
*/
|
||||
final readonly class TimePerUserOutput
|
||||
{
|
||||
public function __construct(
|
||||
public int $userId,
|
||||
public string $username,
|
||||
public string $fullName,
|
||||
public float $hours,
|
||||
public int $entryCount,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Reporting\Infrastructure\ApiPlatform\Resource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use App\Module\Reporting\Application\DTO\AbsencesByTypeOutput;
|
||||
use App\Module\Reporting\Infrastructure\ApiPlatform\State\AbsencesByTypeProvider;
|
||||
|
||||
/**
|
||||
* Rapport en lecture seule : absences approuvees par type sur une periode.
|
||||
*
|
||||
* Filtres query-param : ?from=YYYY-MM-DD&to=YYYY-MM-DD&userId=
|
||||
* (bornes facultatives, sans defaut — un filtre absent n'applique pas la clause).
|
||||
*
|
||||
* Aucune entite Doctrine sous-jacente : le provider lit via DBAL et retourne
|
||||
* directement une liste de AbsencesByTypeOutput. Pagination desactivee.
|
||||
*/
|
||||
#[ApiResource(
|
||||
shortName: 'ReportAbsencesByType',
|
||||
operations: [
|
||||
new GetCollection(
|
||||
uriTemplate: '/reports/absences-by-type',
|
||||
paginationEnabled: false,
|
||||
security: "is_granted('reporting.view')",
|
||||
provider: AbsencesByTypeProvider::class,
|
||||
),
|
||||
],
|
||||
output: AbsencesByTypeOutput::class,
|
||||
)]
|
||||
final class AbsencesByTypeResource {}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Reporting\Infrastructure\ApiPlatform\Resource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use App\Module\Reporting\Application\DTO\TasksByStatusOutput;
|
||||
use App\Module\Reporting\Infrastructure\ApiPlatform\State\TasksByStatusProvider;
|
||||
|
||||
/**
|
||||
* Rapport en lecture seule : nombre de taches non archivees par statut.
|
||||
*
|
||||
* Filtre query-param : ?projectId=
|
||||
*
|
||||
* Aucune entite Doctrine sous-jacente : le provider lit via DBAL et retourne
|
||||
* directement une liste de TasksByStatusOutput. Pagination desactivee.
|
||||
*/
|
||||
#[ApiResource(
|
||||
shortName: 'ReportTasksByStatus',
|
||||
operations: [
|
||||
new GetCollection(
|
||||
uriTemplate: '/reports/tasks-by-status',
|
||||
paginationEnabled: false,
|
||||
security: "is_granted('reporting.view')",
|
||||
provider: TasksByStatusProvider::class,
|
||||
),
|
||||
],
|
||||
output: TasksByStatusOutput::class,
|
||||
)]
|
||||
final class TasksByStatusResource {}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Reporting\Infrastructure\ApiPlatform\Resource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use App\Module\Reporting\Application\DTO\TimePerProjectOutput;
|
||||
use App\Module\Reporting\Infrastructure\ApiPlatform\State\TimePerProjectProvider;
|
||||
|
||||
/**
|
||||
* Rapport en lecture seule : heures travaillees par projet sur une periode.
|
||||
*
|
||||
* Filtres query-param : ?from=YYYY-MM-DD&to=YYYY-MM-DD&userId=&projectId=
|
||||
* (from/to par defaut = mois courant si absents).
|
||||
*
|
||||
* Aucune entite Doctrine sous-jacente : le provider lit via DBAL et retourne
|
||||
* directement une liste de TimePerProjectOutput. Pagination desactivee
|
||||
* (volume borne par le nombre de projets).
|
||||
*/
|
||||
#[ApiResource(
|
||||
shortName: 'ReportTimePerProject',
|
||||
operations: [
|
||||
new GetCollection(
|
||||
uriTemplate: '/reports/time-per-project',
|
||||
paginationEnabled: false,
|
||||
security: "is_granted('reporting.view')",
|
||||
provider: TimePerProjectProvider::class,
|
||||
),
|
||||
],
|
||||
output: TimePerProjectOutput::class,
|
||||
)]
|
||||
final class TimePerProjectResource {}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Reporting\Infrastructure\ApiPlatform\Resource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use App\Module\Reporting\Application\DTO\TimePerUserOutput;
|
||||
use App\Module\Reporting\Infrastructure\ApiPlatform\State\TimePerUserProvider;
|
||||
|
||||
/**
|
||||
* Rapport en lecture seule : heures travaillees par utilisateur sur une periode.
|
||||
*
|
||||
* Filtres query-param : ?from=YYYY-MM-DD&to=YYYY-MM-DD&projectId=
|
||||
* (from/to par defaut = mois courant si absents).
|
||||
*
|
||||
* Aucune entite Doctrine sous-jacente : le provider lit via DBAL et retourne
|
||||
* directement une liste de TimePerUserOutput. Pagination desactivee.
|
||||
*/
|
||||
#[ApiResource(
|
||||
shortName: 'ReportTimePerUser',
|
||||
operations: [
|
||||
new GetCollection(
|
||||
uriTemplate: '/reports/time-per-user',
|
||||
paginationEnabled: false,
|
||||
security: "is_granted('reporting.view')",
|
||||
provider: TimePerUserProvider::class,
|
||||
),
|
||||
],
|
||||
output: TimePerUserOutput::class,
|
||||
)]
|
||||
final class TimePerUserResource {}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Reporting\Infrastructure\ApiPlatform\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Module\Reporting\Application\DTO\AbsencesByTypeOutput;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
/**
|
||||
* Provider DBAL (lecture seule) du rapport absences-par-type.
|
||||
*
|
||||
* N'importe AUCUNE entite : couplage au seul schema SQL physique. La valeur du
|
||||
* statut filtre ('approved') correspond au backing-value de
|
||||
* App\Module\Absence\Domain\Enum\AbsenceStatus::Approved, mais est ecrite ici
|
||||
* en litteral pour eviter tout import inter-module. Tous les filtres sont
|
||||
* passes en bound params. Les bornes de date sont facultatives et sans defaut.
|
||||
*
|
||||
* @implements ProviderInterface<AbsencesByTypeOutput>
|
||||
*/
|
||||
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<AbsencesByTypeOutput>
|
||||
*/
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
|
||||
{
|
||||
/** @var array<string, mixed> $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<array<string, mixed>> $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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Reporting\Infrastructure\ApiPlatform\State;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
|
||||
/**
|
||||
* Validation stricte des filtres query-param partages par les providers de reporting.
|
||||
*
|
||||
* Toutes les valeurs validees ici sont destinees a etre passees en bound params
|
||||
* (jamais interpolees dans le SQL). On rejette en 400 explicite tout input
|
||||
* malforme plutot que de laisser PostgreSQL lever une 500.
|
||||
*/
|
||||
trait ReportFilterTrait
|
||||
{
|
||||
/**
|
||||
* Valide une date au format strict YYYY-MM-DD.
|
||||
*
|
||||
* @param mixed $value valeur brute issue des query-params
|
||||
*
|
||||
* @return null|string la date validee, ou null si absente/vide
|
||||
*/
|
||||
private function validateDate(mixed $value, string $paramName): ?string
|
||||
{
|
||||
if (null === $value || '' === $value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!is_string($value)) {
|
||||
throw new BadRequestHttpException(sprintf('Filtre "%s" invalide : date YYYY-MM-DD attendue.', $paramName));
|
||||
}
|
||||
|
||||
$date = DateTimeImmutable::createFromFormat('!Y-m-d', $value);
|
||||
|
||||
if (false === $date || $date->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<string, mixed> $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];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Reporting\Infrastructure\ApiPlatform\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Module\Reporting\Application\DTO\TasksByStatusOutput;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
/**
|
||||
* Provider DBAL (lecture seule) du rapport taches-par-statut.
|
||||
*
|
||||
* N'importe AUCUNE entite : couplage au seul schema SQL physique. Le filtre
|
||||
* projectId est passe en bound param.
|
||||
*
|
||||
* @implements ProviderInterface<TasksByStatusOutput>
|
||||
*/
|
||||
final readonly class TasksByStatusProvider implements ProviderInterface
|
||||
{
|
||||
use ReportFilterTrait;
|
||||
|
||||
public function __construct(
|
||||
#[Autowire(service: 'doctrine.dbal.default_connection')]
|
||||
private Connection $connection,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return list<TasksByStatusOutput>
|
||||
*/
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
|
||||
{
|
||||
/** @var array<string, mixed> $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<array<string, mixed>> $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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Reporting\Infrastructure\ApiPlatform\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Module\Reporting\Application\DTO\TimePerProjectOutput;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
/**
|
||||
* Provider DBAL (lecture seule) du rapport heures-par-projet.
|
||||
*
|
||||
* N'importe AUCUNE entite : couplage au seul schema SQL physique. Tous les
|
||||
* filtres sont passes en bound params (jamais interpoles).
|
||||
*
|
||||
* @implements ProviderInterface<TimePerProjectOutput>
|
||||
*/
|
||||
final readonly class TimePerProjectProvider implements ProviderInterface
|
||||
{
|
||||
use ReportFilterTrait;
|
||||
|
||||
public function __construct(
|
||||
#[Autowire(service: 'doctrine.dbal.default_connection')]
|
||||
private Connection $connection,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return list<TimePerProjectOutput>
|
||||
*/
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
|
||||
{
|
||||
/** @var array<string, mixed> $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<array<string, mixed>> $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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Reporting\Infrastructure\ApiPlatform\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Module\Reporting\Application\DTO\TimePerUserOutput;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
/**
|
||||
* Provider DBAL (lecture seule) du rapport heures-par-utilisateur.
|
||||
*
|
||||
* N'importe AUCUNE entite : couplage au seul schema SQL physique. La table
|
||||
* "user" est un mot reserve PostgreSQL et reste systematiquement quotee. Tous
|
||||
* les filtres sont passes en bound params.
|
||||
*
|
||||
* @implements ProviderInterface<TimePerUserOutput>
|
||||
*/
|
||||
final readonly class TimePerUserProvider implements ProviderInterface
|
||||
{
|
||||
use ReportFilterTrait;
|
||||
|
||||
public function __construct(
|
||||
#[Autowire(service: 'doctrine.dbal.default_connection')]
|
||||
private Connection $connection,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return list<TimePerUserOutput>
|
||||
*/
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
|
||||
{
|
||||
/** @var array<string, mixed> $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<array<string, mixed>> $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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Reporting;
|
||||
|
||||
use App\Shared\Domain\Module\ModuleInterface;
|
||||
|
||||
/**
|
||||
* Module de reporting transverse en lecture seule.
|
||||
*
|
||||
* Ce module n'introduit AUCUNE entite Doctrine ni migration : il agrege les
|
||||
* donnees existantes (time_entry, project, task, absence_request, "user") via
|
||||
* des requetes DBAL en lecture seule. Il n'importe deliberement aucune entite
|
||||
* d'un autre module — couplage uniquement au schema SQL physique partage.
|
||||
*/
|
||||
final class ReportingModule implements ModuleInterface
|
||||
{
|
||||
public static function id(): string
|
||||
{
|
||||
return 'reporting';
|
||||
}
|
||||
|
||||
public static function label(): string
|
||||
{
|
||||
return 'Rapports';
|
||||
}
|
||||
|
||||
public static function isRequired(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{code: string, label: string}>
|
||||
*/
|
||||
public static function permissions(): array
|
||||
{
|
||||
return [
|
||||
['code' => 'reporting.view', 'label' => 'Consulter les rapports'],
|
||||
['code' => 'reporting.export', 'label' => 'Exporter les rapports'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Functional\Module\Reporting;
|
||||
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class ReportingApiTest extends WebTestCase
|
||||
{
|
||||
public function testGetCollectionRequiresAuthentication(): void
|
||||
{
|
||||
$client = self::createClient();
|
||||
|
||||
$client->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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user