refactor(project-management) : introduce Project/Task/TaskTag/Client contracts

Tranche 1 of LST-65 (ProjectManagement module migration). Decouples the
TimeTracking module from the core-business entities before they move, with
no entity relocation yet — keeps the diff minimal and the risk isolated.

- New read contracts in Shared/Domain/Contract (minimal surface, aligned on
  the entities' real nullable signatures): ProjectInterface (id/code/name),
  TaskInterface (id/number/title), TaskTagInterface (id/label/color),
  ClientInterface (id/name).
- Project/Task/TaskTag/Client implement their contract (entities stay in
  src/Entity for now). Project.client typed as ClientInterface.
- TimeEntry (TimeTracking) now references ProjectInterface/TaskInterface/
  TaskTagInterface instead of the concrete entities; repository + DQL
  untouched in behaviour.
- resolve_target_entities maps the 4 contracts to the legacy entities (will
  be repointed to the module in tranche 2).
- Adds the migration plan doc.

159 tests green, mapping valid, cs-fixer clean.
This commit is contained in:
Matthieu
2026-06-20 16:34:15 +02:00
parent 1b652ef680
commit f119ec30ca
13 changed files with 189 additions and 32 deletions
@@ -13,12 +13,12 @@ use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
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\ProjectInterface;
use App\Shared\Domain\Contract\TaskInterface;
use App\Shared\Domain\Contract\TaskTagInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Contract\UserInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
@@ -91,18 +91,18 @@ class TimeEntry implements TimestampableInterface, BlamableInterface
#[Groups(['time_entry:read', 'time_entry:write'])]
private ?UserInterface $user = null;
#[ORM\ManyToOne(targetEntity: Project::class)]
#[ORM\ManyToOne(targetEntity: ProjectInterface::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
#[Groups(['time_entry:read', 'time_entry:write'])]
private ?Project $project = null;
private ?ProjectInterface $project = null;
#[ORM\ManyToOne(targetEntity: Task::class)]
#[ORM\ManyToOne(targetEntity: TaskInterface::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
#[Groups(['time_entry:read', 'time_entry:write'])]
private ?Task $task = null;
private ?TaskInterface $task = null;
/** @var Collection<int, TaskTag> */
#[ORM\ManyToMany(targetEntity: TaskTag::class)]
/** @var Collection<int, TaskTagInterface> */
#[ORM\ManyToMany(targetEntity: TaskTagInterface::class)]
#[ORM\JoinTable(
name: 'time_entry_task_type',
joinColumns: [new ORM\JoinColumn(name: 'time_entry_id', referencedColumnName: 'id')],
@@ -181,37 +181,37 @@ class TimeEntry implements TimestampableInterface, BlamableInterface
return $this;
}
public function getProject(): ?Project
public function getProject(): ?ProjectInterface
{
return $this->project;
}
public function setProject(?Project $project): static
public function setProject(?ProjectInterface $project): static
{
$this->project = $project;
return $this;
}
public function getTask(): ?Task
public function getTask(): ?TaskInterface
{
return $this->task;
}
public function setTask(?Task $task): static
public function setTask(?TaskInterface $task): static
{
$this->task = $task;
return $this;
}
/** @return Collection<int, TaskTag> */
/** @return Collection<int, TaskTagInterface> */
public function getTags(): Collection
{
return $this->tags;
}
public function addTag(TaskTag $tag): static
public function addTag(TaskTagInterface $tag): static
{
if (!$this->tags->contains($tag)) {
$this->tags->add($tag);
@@ -220,7 +220,7 @@ class TimeEntry implements TimestampableInterface, BlamableInterface
return $this;
}
public function removeTag(TaskTag $tag): static
public function removeTag(TaskTagInterface $tag): static
{
$this->tags->removeElement($tag);
@@ -4,8 +4,8 @@ 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\ProjectInterface;
use App\Shared\Domain\Contract\UserInterface;
use DateTimeImmutable;
use Doctrine\ORM\QueryBuilder;
@@ -19,9 +19,9 @@ interface TimeEntryRepositoryInterface
public function findActiveByUser(UserInterface $user): ?TimeEntry;
/**
* @param null|UserInterface[] $users
* @param null|Project[] $projects
* @param null|int[] $tagIds
* @param null|UserInterface[] $users
* @param null|ProjectInterface[] $projects
* @param null|int[] $tagIds
*
* @return TimeEntry[]
*/
@@ -4,9 +4,9 @@ declare(strict_types=1);
namespace App\Module\TimeTracking\Infrastructure\Doctrine;
use App\Entity\Project;
use App\Module\TimeTracking\Domain\Entity\TimeEntry;
use App\Module\TimeTracking\Domain\Repository\TimeEntryRepositoryInterface;
use App\Shared\Domain\Contract\ProjectInterface;
use App\Shared\Domain\Contract\UserInterface;
use DateTimeImmutable;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
@@ -36,9 +36,9 @@ class DoctrineTimeEntryRepository extends ServiceEntityRepository implements Tim
}
/**
* @param null|UserInterface[] $users
* @param null|Project[] $projects
* @param null|int[] $tagIds
* @param null|UserInterface[] $users
* @param null|ProjectInterface[] $projects
* @param null|int[] $tagIds
*
* @return TimeEntry[]
*/