feat(absence) : migrate Absence domain into module (back)

LST-66 (2.3) backend. Behaviour-preserving move of the absences domain into
src/Module/Absence/. API operations, securities, routes and the 10 MCP tool
names are unchanged.

- 3 entities + 3 enums moved to Domain/{Entity,Enum}; user relations stay on
  UserInterface. 3 repositories split into Domain/Repository interfaces +
  Doctrine impls (bound in services.yaml); find() kept off interfaces
  (findById instead).
- Pure services (AbsenceDayCalculator, PublicHolidayProvider) -> Domain/Service;
  AbsenceBalanceService -> Application/Service; State (5), controllers (5),
  10 MCP tools and AccrueLeaveCommand -> Infrastructure/.
- New LeaveProfileInterface contract (Shared) exposes the HR getters used by
  AbsenceBalanceService/AccrueLeaveCommand; User implements it -> Absence no
  longer imports the concrete Core User. MCP tools/command inject
  UserRepositoryInterface (findById) instead of the concrete repository.
- Timestampable/Blamable added to AbsenceBalance and AbsencePolicy (additive
  migration: created_at/updated_at + created_by/updated_by FK ON DELETE SET
  NULL + COMMENT). AbsenceRequest untouched (already has createdAt/reviewedAt).
- AbsenceModule registered (id absence, 4 RBAC perms, not re-wired); doctrine
  mapping added; team-absences sidebar item gated by the module.

161 tests green, mapping valid, no API route regression, cs-fixer clean.
This commit is contained in:
Matthieu
2026-06-20 18:32:02 +02:00
parent 7446b7dca9
commit 306cfd34cd
51 changed files with 514 additions and 209 deletions
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Module\Absence\Infrastructure\Doctrine;
use App\Module\Absence\Domain\Entity\AbsenceBalance;
use App\Module\Absence\Domain\Enum\AbsenceType;
use App\Module\Absence\Domain\Repository\AbsenceBalanceRepositoryInterface;
use App\Shared\Domain\Contract\UserInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<AbsenceBalance>
*/
class DoctrineAbsenceBalanceRepository extends ServiceEntityRepository implements AbsenceBalanceRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, AbsenceBalance::class);
}
public function findById(int $id): ?AbsenceBalance
{
return $this->find($id);
}
public function findOneForPeriod(UserInterface $user, AbsenceType $type, string $period): ?AbsenceBalance
{
return $this->findOneBy([
'user' => $user,
'type' => $type,
'period' => $period,
]);
}
}
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Module\Absence\Infrastructure\Doctrine;
use App\Module\Absence\Domain\Entity\AbsencePolicy;
use App\Module\Absence\Domain\Enum\AbsenceType;
use App\Module\Absence\Domain\Repository\AbsencePolicyRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<AbsencePolicy>
*/
class DoctrineAbsencePolicyRepository extends ServiceEntityRepository implements AbsencePolicyRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, AbsencePolicy::class);
}
public function findById(int $id): ?AbsencePolicy
{
return $this->find($id);
}
public function findOneByType(AbsenceType $type): ?AbsencePolicy
{
return $this->findOneBy(['type' => $type]);
}
}
@@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
namespace App\Module\Absence\Infrastructure\Doctrine;
use App\Module\Absence\Domain\Entity\AbsenceRequest;
use App\Module\Absence\Domain\Enum\AbsenceStatus;
use App\Module\Absence\Domain\Enum\AbsenceType;
use App\Module\Absence\Domain\Repository\AbsenceRequestRepositoryInterface;
use App\Shared\Domain\Contract\UserInterface;
use DateTimeInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<AbsenceRequest>
*/
class DoctrineAbsenceRequestRepository extends ServiceEntityRepository implements AbsenceRequestRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, AbsenceRequest::class);
}
public function findById(int $id): ?AbsenceRequest
{
return $this->find($id);
}
/**
* Whether the user already has a PENDING or APPROVED absence that overlaps
* the given date range. Two ranges overlap when start_a <= end_b and
* end_a >= start_b.
*/
public function hasOverlap(
UserInterface $user,
DateTimeInterface $startDate,
DateTimeInterface $endDate,
?int $excludeId = null,
): bool {
$qb = $this->createQueryBuilder('a')
->select('COUNT(a.id)')
->andWhere('a.user = :user')
->andWhere('a.status IN (:statuses)')
->andWhere('a.startDate <= :endDate')
->andWhere('a.endDate >= :startDate')
->setParameter('user', $user)
->setParameter('statuses', [AbsenceStatus::Pending, AbsenceStatus::Approved])
->setParameter('startDate', $startDate->format('Y-m-d'))
->setParameter('endDate', $endDate->format('Y-m-d'))
;
if (null !== $excludeId) {
$qb->andWhere('a.id != :excludeId')->setParameter('excludeId', $excludeId);
}
return (int) $qb->getQuery()->getSingleScalarResult() > 0;
}
/**
* Absences (approved or pending) overlapping a date range, all employees —
* used by the admin calendar view.
*
* @return AbsenceRequest[]
*/
public function findInRange(DateTimeInterface $from, DateTimeInterface $to): array
{
return $this->createQueryBuilder('a')
->andWhere('a.status IN (:statuses)')
->andWhere('a.startDate <= :to')
->andWhere('a.endDate >= :from')
->setParameter('statuses', [AbsenceStatus::Pending, AbsenceStatus::Approved])
->setParameter('from', $from->format('Y-m-d'))
->setParameter('to', $to->format('Y-m-d'))
->orderBy('a.startDate', 'ASC')
->getQuery()
->getResult()
;
}
/**
* @return AbsenceRequest[]
*/
public function findFiltered(
?UserInterface $user = null,
?AbsenceStatus $status = null,
?AbsenceType $type = null,
?DateTimeInterface $from = null,
?DateTimeInterface $to = null,
): array {
$qb = $this->createQueryBuilder('a')->orderBy('a.startDate', 'DESC');
if (null !== $user) {
$qb->andWhere('a.user = :user')->setParameter('user', $user);
}
if (null !== $status) {
$qb->andWhere('a.status = :status')->setParameter('status', $status);
}
if (null !== $type) {
$qb->andWhere('a.type = :type')->setParameter('type', $type);
}
if (null !== $from) {
$qb->andWhere('a.endDate >= :from')->setParameter('from', $from->format('Y-m-d'));
}
if (null !== $to) {
$qb->andWhere('a.startDate <= :to')->setParameter('to', $to->format('Y-m-d'));
}
return $qb->getQuery()->getResult();
}
}