feat(time-tracking) : migrate TimeEntry into TimeTracking module (back)

First business module of Phase 2 (LST-64, rodage). Strangler-style,
additive move — no behavioural change to the public API or MCP tools.

- New module App\Module\TimeTracking (TimeTrackingModule, id "time-tracking",
  declares time-tracking.entries.view/export permissions in the RBAC catalog;
  operation security left on ROLE_USER, not re-wired here).
- Move TimeEntry entity, repository (now interface + Doctrine impl bound in
  services.yaml), ActiveTimeEntryProvider, export service/controller and the
  4 MCP TimeEntry tools into the module. #[ApiResource] (operations, security,
  uriTemplates /time_entries/*), filters and serialization groups preserved.
- Doctrine mapping "TimeTracking" added; table time_entry unchanged.
- Sidebar item gated with module "time-tracking" (SidebarFilter disables the
  route when the module is inactive).
- Timestampable/Blamable adopted (first adopter): additive migration adds
  created_at/updated_at/created_by/updated_by (nullable, FK SET NULL) +
  COMMENT ON COLUMN. Functional test confirms created_at on persist and
  updated_at refresh on update — the suspected preUpdate recompute issue does
  not occur (Doctrine ORM 3.6.2 recomputes change sets after preUpdate).

159 tests green, schema mapping valid, php-cs-fixer clean.
This commit is contained in:
Matthieu
2026-06-20 16:16:13 +02:00
parent a88cb1bc35
commit d1516c3f5d
19 changed files with 298 additions and 36 deletions
+1 -1
View File
@@ -17,7 +17,6 @@ use App\Entity\TaskPriority;
use App\Entity\TaskRecurrence;
use App\Entity\TaskStatus;
use App\Entity\TaskTag;
use App\Entity\TimeEntry;
use App\Entity\Workflow;
use App\Entity\ZimbraConfiguration;
use App\Enum\AbsenceStatus;
@@ -27,6 +26,7 @@ use App\Enum\RecurrenceType;
use App\Enum\StatusCategory;
use App\Module\Core\Application\Rbac\RbacSeeder;
use App\Module\Core\Domain\Entity\User;
use App\Module\TimeTracking\Domain\Entity\TimeEntry;
use DateTimeImmutable;
use DateTimeZone;
use Doctrine\Bundle\FixturesBundle\Fixture;
+1 -1
View File
@@ -16,8 +16,8 @@ use App\Entity\TaskGroup;
use App\Entity\TaskPriority;
use App\Entity\TaskStatus;
use App\Entity\TaskTag;
use App\Entity\TimeEntry;
use App\Module\Core\Domain\Entity\User;
use App\Module\TimeTracking\Domain\Entity\TimeEntry;
use Doctrine\Common\Collections\Collection;
/**
@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace App\Entity;
namespace App\Module\TimeTracking\Domain\Entity;
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
@@ -13,9 +13,15 @@ use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Repository\TimeEntryRepository;
use App\Entity\Project;
use App\Entity\Task;
use App\Entity\TaskTag;
use App\Module\TimeTracking\Infrastructure\ApiPlatform\State\ActiveTimeEntryProvider;
use App\Module\TimeTracking\Infrastructure\Doctrine\DoctrineTimeEntryRepository;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Contract\UserInterface;
use App\State\ActiveTimeEntryProvider;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
@@ -52,10 +58,12 @@ use Symfony\Component\Serializer\Attribute\Groups;
)]
#[ApiFilter(SearchFilter::class, properties: ['user' => 'exact', 'project' => 'exact', 'tags' => 'exact'])]
#[ApiFilter(DateFilter::class, properties: ['startedAt'])]
#[ORM\Entity(repositoryClass: TimeEntryRepository::class)]
#[ORM\Entity(repositoryClass: DoctrineTimeEntryRepository::class)]
#[ORM\UniqueConstraint(name: 'uniq_active_timer', columns: ['user_id'], options: ['where' => '(stopped_at IS NULL)'])]
class TimeEntry
class TimeEntry implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Module\TimeTracking\Domain\Repository;
use App\Entity\Project;
use App\Module\TimeTracking\Domain\Entity\TimeEntry;
use App\Shared\Domain\Contract\UserInterface;
use DateTimeImmutable;
use Doctrine\ORM\QueryBuilder;
interface TimeEntryRepositoryInterface
{
public function createQueryBuilder(string $alias, ?string $indexBy = null): QueryBuilder;
public function findById(int $id): ?TimeEntry;
public function findActiveByUser(UserInterface $user): ?TimeEntry;
/**
* @param null|UserInterface[] $users
* @param null|Project[] $projects
* @param null|int[] $tagIds
*
* @return TimeEntry[]
*/
public function findForExport(
DateTimeImmutable $after,
DateTimeImmutable $before,
?array $users = null,
?array $projects = null,
?array $tagIds = null,
): array;
}
@@ -2,12 +2,12 @@
declare(strict_types=1);
namespace App\State;
namespace App\Module\TimeTracking\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\TimeEntry;
use App\Repository\TimeEntryRepository;
use App\Module\TimeTracking\Domain\Entity\TimeEntry;
use App\Module\TimeTracking\Domain\Repository\TimeEntryRepositoryInterface;
use Symfony\Bundle\SecurityBundle\Security;
/**
@@ -17,7 +17,7 @@ final readonly class ActiveTimeEntryProvider implements ProviderInterface
{
public function __construct(
private Security $security,
private TimeEntryRepository $timeEntryRepository,
private TimeEntryRepositoryInterface $timeEntryRepository,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
@@ -2,12 +2,12 @@
declare(strict_types=1);
namespace App\Controller;
namespace App\Module\TimeTracking\Infrastructure\Controller;
use App\Entity\Project;
use App\Module\Core\Domain\Entity\User;
use App\Repository\TimeEntryRepository;
use App\Service\TimeEntryExportService;
use App\Module\TimeTracking\Domain\Repository\TimeEntryRepositoryInterface;
use App\Module\TimeTracking\Infrastructure\Export\TimeEntryExportService;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Exception;
@@ -23,7 +23,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
class TimeEntryExportController extends AbstractController
{
public function __construct(
private readonly TimeEntryRepository $timeEntryRepository,
private readonly TimeEntryRepositoryInterface $timeEntryRepository,
private readonly TimeEntryExportService $exportService,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
@@ -2,10 +2,11 @@
declare(strict_types=1);
namespace App\Repository;
namespace App\Module\TimeTracking\Infrastructure\Doctrine;
use App\Entity\Project;
use App\Entity\TimeEntry;
use App\Module\TimeTracking\Domain\Entity\TimeEntry;
use App\Module\TimeTracking\Domain\Repository\TimeEntryRepositoryInterface;
use App\Shared\Domain\Contract\UserInterface;
use DateTimeImmutable;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
@@ -14,13 +15,18 @@ use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<TimeEntry>
*/
class TimeEntryRepository extends ServiceEntityRepository
class DoctrineTimeEntryRepository extends ServiceEntityRepository implements TimeEntryRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, TimeEntry::class);
}
public function findById(int $id): ?TimeEntry
{
return $this->find($id);
}
public function findActiveByUser(UserInterface $user): ?TimeEntry
{
return $this->findOneBy([
@@ -2,9 +2,9 @@
declare(strict_types=1);
namespace App\Service;
namespace App\Module\TimeTracking\Infrastructure\Export;
use App\Entity\TimeEntry;
use App\Module\TimeTracking\Domain\Entity\TimeEntry;
use DateTimeImmutable;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
@@ -2,15 +2,15 @@
declare(strict_types=1);
namespace App\Mcp\Tool\TimeEntry;
namespace App\Module\TimeTracking\Infrastructure\Mcp\Tool;
use App\Entity\TimeEntry;
use App\Mcp\Tool\Serializer;
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
use App\Module\TimeTracking\Domain\Entity\TimeEntry;
use App\Module\TimeTracking\Domain\Repository\TimeEntryRepositoryInterface;
use App\Repository\ProjectRepository;
use App\Repository\TaskRepository;
use App\Repository\TaskTagRepository;
use App\Repository\TimeEntryRepository;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
@@ -29,7 +29,7 @@ class CreateTimeEntryTool
private readonly ProjectRepository $projectRepository,
private readonly TaskRepository $taskRepository,
private readonly TaskTagRepository $taskTagRepository,
private readonly TimeEntryRepository $timeEntryRepository,
private readonly TimeEntryRepositoryInterface $timeEntryRepository,
private readonly Security $security,
) {}
@@ -2,9 +2,9 @@
declare(strict_types=1);
namespace App\Mcp\Tool\TimeEntry;
namespace App\Module\TimeTracking\Infrastructure\Mcp\Tool;
use App\Repository\TimeEntryRepository;
use App\Module\TimeTracking\Domain\Repository\TimeEntryRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
@@ -17,7 +17,7 @@ use function sprintf;
class DeleteTimeEntryTool
{
public function __construct(
private readonly TimeEntryRepository $timeEntryRepository,
private readonly TimeEntryRepositoryInterface $timeEntryRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
@@ -28,7 +28,7 @@ class DeleteTimeEntryTool
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$entry = $this->timeEntryRepository->find($id);
$entry = $this->timeEntryRepository->findById($id);
if (null === $entry) {
throw new InvalidArgumentException(sprintf('TimeEntry with ID %d not found.', $id));
@@ -2,10 +2,10 @@
declare(strict_types=1);
namespace App\Mcp\Tool\TimeEntry;
namespace App\Module\TimeTracking\Infrastructure\Mcp\Tool;
use App\Mcp\Tool\Serializer;
use App\Repository\TimeEntryRepository;
use App\Module\TimeTracking\Domain\Repository\TimeEntryRepositoryInterface;
use DateTimeImmutable;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
@@ -15,7 +15,7 @@ use Symfony\Component\Security\Core\Exception\AccessDeniedException;
class ListTimeEntriesTool
{
public function __construct(
private readonly TimeEntryRepository $timeEntryRepository,
private readonly TimeEntryRepositoryInterface $timeEntryRepository,
private readonly Security $security,
) {}
@@ -2,13 +2,13 @@
declare(strict_types=1);
namespace App\Mcp\Tool\TimeEntry;
namespace App\Module\TimeTracking\Infrastructure\Mcp\Tool;
use App\Mcp\Tool\Serializer;
use App\Module\TimeTracking\Domain\Repository\TimeEntryRepositoryInterface;
use App\Repository\ProjectRepository;
use App\Repository\TaskRepository;
use App\Repository\TaskTagRepository;
use App\Repository\TimeEntryRepository;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
@@ -22,7 +22,7 @@ use function sprintf;
class UpdateTimeEntryTool
{
public function __construct(
private readonly TimeEntryRepository $timeEntryRepository,
private readonly TimeEntryRepositoryInterface $timeEntryRepository,
private readonly ProjectRepository $projectRepository,
private readonly TaskRepository $taskRepository,
private readonly TaskTagRepository $taskTagRepository,
@@ -47,7 +47,7 @@ class UpdateTimeEntryTool
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$entry = $this->timeEntryRepository->find($id);
$entry = $this->timeEntryRepository->findById($id);
if (null === $entry) {
throw new InvalidArgumentException(sprintf('TimeEntry with ID %d not found.', $id));
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Module\TimeTracking;
use App\Shared\Domain\Module\ModuleInterface;
final class TimeTrackingModule implements ModuleInterface
{
public static function id(): string
{
return 'time-tracking';
}
public static function label(): string
{
return 'Suivi des temps';
}
public static function isRequired(): bool
{
return false;
}
/**
* Permissions RBAC fin du Module TimeTracking (2.1).
*
* Additif : alimente le catalogue RBAC. La sécurité des opérations API
* reste en ROLE_USER (non recâblée ici).
*
* @return list<array{code: string, label: string}>
*/
public static function permissions(): array
{
return [
['code' => 'time-tracking.entries.view', 'label' => 'Voir les saisies de temps'],
['code' => 'time-tracking.entries.export', 'label' => 'Exporter les saisies de temps'],
];
}
}