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
-209
View File
@@ -1,209 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use App\Enum\AbsenceType;
use App\Repository\AbsenceBalanceRepository;
use App\Shared\Domain\Contract\UserInterface;
use App\State\AbsenceBalanceProvider;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
/**
* Per-employee, per-type leave balance for a given reference period.
*/
#[ApiResource(
operations: [
new GetCollection(
paginationEnabled: false,
security: "is_granted('ROLE_USER')",
provider: AbsenceBalanceProvider::class,
),
new Get(
security: "is_granted('ROLE_USER')",
provider: AbsenceBalanceProvider::class,
),
new Patch(security: "is_granted('ROLE_ADMIN')"),
],
normalizationContext: ['groups' => ['absence_balance:read']],
denormalizationContext: ['groups' => ['absence_balance:write']],
)]
#[ORM\Entity(repositoryClass: AbsenceBalanceRepository::class)]
#[ORM\Table(name: 'absence_balance')]
#[ORM\UniqueConstraint(name: 'uniq_absence_balance_user_type_period', columns: ['user_id', 'type', 'period'])]
class AbsenceBalance
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['absence_balance:read'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Groups(['absence_balance:read'])]
private ?UserInterface $user = null;
#[ORM\Column(type: Types::STRING, length: 32, enumType: AbsenceType::class)]
#[Groups(['absence_balance:read'])]
private AbsenceType $type;
/** Reference period, e.g. "2025-2026" for paid leave or "2025" for yearly. */
#[ORM\Column(length: 16)]
#[Groups(['absence_balance:read'])]
private ?string $period = null;
/** Days acquired during the *previous* reference period (Congés N-1): fully available to take. */
#[ORM\Column(type: Types::FLOAT)]
#[Groups(['absence_balance:read', 'absence_balance:write'])]
private float $acquired = 0.0;
/** Days being accrued during the *current* reference period (Congés N): "en cours d'acquisition". */
#[ORM\Column(type: Types::FLOAT)]
#[Groups(['absence_balance:read', 'absence_balance:write'])]
private float $acquiring = 0.0;
#[ORM\Column(type: Types::FLOAT)]
#[Groups(['absence_balance:read', 'absence_balance:write'])]
private float $taken = 0.0;
/** Sum of days in PENDING requests, for information. */
#[ORM\Column(type: Types::FLOAT)]
#[Groups(['absence_balance:read'])]
private float $pending = 0.0;
/** Last month (format YYYY-MM) for which the monthly accrual was applied. */
#[ORM\Column(length: 7, nullable: true)]
private ?string $lastAccruedMonth = null;
/** Total entitlement for the period, both finalized (N-1) and in-progress (N). */
#[Groups(['absence_balance:read'])]
public function getAcquiredTotal(): float
{
return $this->acquired + $this->acquiring;
}
/**
* Days the employee can still take: in this organisation the days being
* accrued (N) are posable too, so they count towards what is available.
*/
#[Groups(['absence_balance:read'])]
public function getAvailable(): float
{
return $this->acquired + $this->acquiring - $this->taken;
}
#[Groups(['absence_balance:read'])]
public function getLabel(): string
{
return $this->type->label();
}
public function getId(): ?int
{
return $this->id;
}
public function getUser(): ?UserInterface
{
return $this->user;
}
public function setUser(?UserInterface $user): static
{
$this->user = $user;
return $this;
}
public function getType(): AbsenceType
{
return $this->type;
}
public function setType(AbsenceType $type): static
{
$this->type = $type;
return $this;
}
public function getPeriod(): ?string
{
return $this->period;
}
public function setPeriod(string $period): static
{
$this->period = $period;
return $this;
}
public function getAcquired(): float
{
return $this->acquired;
}
public function setAcquired(float $acquired): static
{
$this->acquired = $acquired;
return $this;
}
public function getAcquiring(): float
{
return $this->acquiring;
}
public function setAcquiring(float $acquiring): static
{
$this->acquiring = $acquiring;
return $this;
}
public function getTaken(): float
{
return $this->taken;
}
public function setTaken(float $taken): static
{
$this->taken = $taken;
return $this;
}
public function getPending(): float
{
return $this->pending;
}
public function setPending(float $pending): static
{
$this->pending = $pending;
return $this;
}
public function getLastAccruedMonth(): ?string
{
return $this->lastAccruedMonth;
}
public function setLastAccruedMonth(?string $lastAccruedMonth): static
{
$this->lastAccruedMonth = $lastAccruedMonth;
return $this;
}
}
-171
View File
@@ -1,171 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use App\Enum\AbsenceType;
use App\Repository\AbsencePolicyRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
/**
* Per-type configuration of absence rules. Overrides the legal defaults and
* lets an admin tune days/year, days/event, notice period, etc.
*/
#[ApiResource(
operations: [
new GetCollection(
paginationEnabled: false,
security: "is_granted('ROLE_USER')",
),
new Get(security: "is_granted('ROLE_USER')"),
new Patch(security: "is_granted('ROLE_ADMIN')"),
],
normalizationContext: ['groups' => ['absence_policy:read']],
denormalizationContext: ['groups' => ['absence_policy:write']],
order: ['type' => 'ASC'],
)]
#[ORM\Entity(repositoryClass: AbsencePolicyRepository::class)]
#[ORM\Table(name: 'absence_policy')]
#[ORM\UniqueConstraint(name: 'uniq_absence_policy_type', columns: ['type'])]
class AbsencePolicy
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['absence_policy:read'])]
private ?int $id = null;
#[ORM\Column(type: Types::STRING, length: 32, enumType: AbsenceType::class)]
#[Groups(['absence_policy:read', 'absence_balance:read', 'absence_request:read'])]
private AbsenceType $type;
/** Yearly entitlement (e.g. 25 for paid leave); null when not relevant. */
#[ORM\Column(type: Types::FLOAT, nullable: true)]
#[Groups(['absence_policy:read', 'absence_policy:write'])]
private ?float $daysPerYear = null;
/** Days granted per event (e.g. 4 for marriage); null when not relevant. */
#[ORM\Column(type: Types::FLOAT, nullable: true)]
#[Groups(['absence_policy:read', 'absence_policy:write'])]
private ?float $daysPerEvent = null;
#[ORM\Column]
#[Groups(['absence_policy:read', 'absence_policy:write'])]
private bool $justificationRequired = false;
/** Minimum notice period in days (e.g. 30 for paid leave, 0 for sick leave). */
#[ORM\Column]
#[Groups(['absence_policy:read', 'absence_policy:write'])]
private int $noticeDays = 0;
/** true => "jours ouvrés" (Mon-Fri), false => "jours ouvrables" (Mon-Sat). */
#[ORM\Column]
#[Groups(['absence_policy:read', 'absence_policy:write'])]
private bool $countWorkingDaysOnly = true;
#[ORM\Column]
#[Groups(['absence_policy:read', 'absence_policy:write'])]
private bool $active = true;
#[Groups(['absence_policy:read'])]
public function getLabel(): string
{
return $this->type->label();
}
public function getId(): ?int
{
return $this->id;
}
public function getType(): AbsenceType
{
return $this->type;
}
public function setType(AbsenceType $type): static
{
$this->type = $type;
return $this;
}
public function getDaysPerYear(): ?float
{
return $this->daysPerYear;
}
public function setDaysPerYear(?float $daysPerYear): static
{
$this->daysPerYear = $daysPerYear;
return $this;
}
public function getDaysPerEvent(): ?float
{
return $this->daysPerEvent;
}
public function setDaysPerEvent(?float $daysPerEvent): static
{
$this->daysPerEvent = $daysPerEvent;
return $this;
}
public function isJustificationRequired(): bool
{
return $this->justificationRequired;
}
public function setJustificationRequired(bool $justificationRequired): static
{
$this->justificationRequired = $justificationRequired;
return $this;
}
public function getNoticeDays(): int
{
return $this->noticeDays;
}
public function setNoticeDays(int $noticeDays): static
{
$this->noticeDays = $noticeDays;
return $this;
}
public function isCountWorkingDaysOnly(): bool
{
return $this->countWorkingDaysOnly;
}
public function setCountWorkingDaysOnly(bool $countWorkingDaysOnly): static
{
$this->countWorkingDaysOnly = $countWorkingDaysOnly;
return $this;
}
public function isActive(): bool
{
return $this->active;
}
public function setActive(bool $active): static
{
$this->active = $active;
return $this;
}
}
-327
View File
@@ -1,327 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Enum\AbsenceStatus;
use App\Enum\AbsenceType;
use App\Enum\HalfDay;
use App\Repository\AbsenceRequestRepository;
use App\Shared\Domain\Contract\UserInterface;
use App\State\AbsenceCancelProcessor;
use App\State\AbsenceRequestProcessor;
use App\State\AbsenceRequestProvider;
use App\State\AbsenceReviewProcessor;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
operations: [
new GetCollection(
paginationEnabled: false,
security: "is_granted('ROLE_USER')",
provider: AbsenceRequestProvider::class,
),
new Get(
security: "is_granted('ROLE_USER')",
provider: AbsenceRequestProvider::class,
),
new Post(
security: "is_granted('ROLE_USER')",
processor: AbsenceRequestProcessor::class,
),
new Patch(
uriTemplate: '/absence_requests/{id}/approve',
security: "is_granted('ROLE_ADMIN')",
processor: AbsenceReviewProcessor::class,
provider: AbsenceRequestProvider::class,
),
new Patch(
uriTemplate: '/absence_requests/{id}/reject',
security: "is_granted('ROLE_ADMIN')",
processor: AbsenceReviewProcessor::class,
provider: AbsenceRequestProvider::class,
),
new Patch(
uriTemplate: '/absence_requests/{id}/cancel',
security: "is_granted('ROLE_USER')",
processor: AbsenceCancelProcessor::class,
provider: AbsenceRequestProvider::class,
),
new Delete(security: "is_granted('ROLE_ADMIN')"),
],
normalizationContext: ['groups' => ['absence_request:read']],
denormalizationContext: ['groups' => ['absence_request:write']],
order: ['createdAt' => 'DESC'],
)]
#[ORM\Entity(repositoryClass: AbsenceRequestRepository::class)]
#[ORM\Table(name: 'absence_request')]
class AbsenceRequest
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['absence_request:read'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Groups(['absence_request:read'])]
private ?UserInterface $user = null;
#[ORM\Column(type: Types::STRING, length: 32, enumType: AbsenceType::class)]
#[Groups(['absence_request:read', 'absence_request:write'])]
#[Assert\NotNull]
private ?AbsenceType $type = null;
#[ORM\Column(type: Types::DATE_IMMUTABLE)]
#[Groups(['absence_request:read', 'absence_request:write'])]
#[Assert\NotNull]
private ?DateTimeImmutable $startDate = null;
#[ORM\Column(type: Types::DATE_IMMUTABLE)]
#[Groups(['absence_request:read', 'absence_request:write'])]
#[Assert\NotNull]
private ?DateTimeImmutable $endDate = null;
#[ORM\Column(type: Types::STRING, length: 16, nullable: true, enumType: HalfDay::class)]
#[Groups(['absence_request:read', 'absence_request:write'])]
private ?HalfDay $startHalfDay = null;
#[ORM\Column(type: Types::STRING, length: 16, nullable: true, enumType: HalfDay::class)]
#[Groups(['absence_request:read', 'absence_request:write'])]
private ?HalfDay $endHalfDay = null;
/** Number of deducted days, computed server-side at creation. */
#[ORM\Column(type: Types::FLOAT)]
#[Groups(['absence_request:read'])]
private float $countedDays = 0.0;
#[ORM\Column(type: Types::TEXT, nullable: true)]
#[Groups(['absence_request:read', 'absence_request:write'])]
private ?string $reason = null;
#[ORM\Column(length: 255, nullable: true)]
#[Groups(['absence_request:read'])]
private ?string $justificationFileName = null;
#[ORM\Column(type: Types::STRING, length: 16, enumType: AbsenceStatus::class)]
#[Groups(['absence_request:read'])]
private AbsenceStatus $status = AbsenceStatus::Pending;
#[ORM\Column(type: Types::TEXT, nullable: true)]
#[Groups(['absence_request:read', 'absence_request:write'])]
private ?string $rejectionReason = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
#[Groups(['absence_request:read'])]
private ?DateTimeImmutable $createdAt = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
#[Groups(['absence_request:read'])]
private ?DateTimeImmutable $reviewedAt = null;
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
#[Groups(['absence_request:read'])]
private ?UserInterface $reviewedBy = null;
#[Groups(['absence_request:read'])]
public function getLabel(): ?string
{
return $this->type?->label();
}
#[Groups(['absence_request:read'])]
public function getJustificationUrl(): ?string
{
if (null === $this->justificationFileName) {
return null;
}
return '/api/absence_requests/'.$this->id.'/justificatif';
}
public function getId(): ?int
{
return $this->id;
}
public function getUser(): ?UserInterface
{
return $this->user;
}
public function setUser(?UserInterface $user): static
{
$this->user = $user;
return $this;
}
public function getType(): ?AbsenceType
{
return $this->type;
}
public function setType(?AbsenceType $type): static
{
$this->type = $type;
return $this;
}
public function getStartDate(): ?DateTimeImmutable
{
return $this->startDate;
}
public function setStartDate(?DateTimeImmutable $startDate): static
{
$this->startDate = $startDate;
return $this;
}
public function getEndDate(): ?DateTimeImmutable
{
return $this->endDate;
}
public function setEndDate(?DateTimeImmutable $endDate): static
{
$this->endDate = $endDate;
return $this;
}
public function getStartHalfDay(): ?HalfDay
{
return $this->startHalfDay;
}
public function setStartHalfDay(?HalfDay $startHalfDay): static
{
$this->startHalfDay = $startHalfDay;
return $this;
}
public function getEndHalfDay(): ?HalfDay
{
return $this->endHalfDay;
}
public function setEndHalfDay(?HalfDay $endHalfDay): static
{
$this->endHalfDay = $endHalfDay;
return $this;
}
public function getCountedDays(): float
{
return $this->countedDays;
}
public function setCountedDays(float $countedDays): static
{
$this->countedDays = $countedDays;
return $this;
}
public function getReason(): ?string
{
return $this->reason;
}
public function setReason(?string $reason): static
{
$this->reason = $reason;
return $this;
}
public function getJustificationFileName(): ?string
{
return $this->justificationFileName;
}
public function setJustificationFileName(?string $justificationFileName): static
{
$this->justificationFileName = $justificationFileName;
return $this;
}
public function getStatus(): AbsenceStatus
{
return $this->status;
}
public function setStatus(AbsenceStatus $status): static
{
$this->status = $status;
return $this;
}
public function getRejectionReason(): ?string
{
return $this->rejectionReason;
}
public function setRejectionReason(?string $rejectionReason): static
{
$this->rejectionReason = $rejectionReason;
return $this;
}
public function getCreatedAt(): ?DateTimeImmutable
{
return $this->createdAt;
}
public function setCreatedAt(DateTimeImmutable $createdAt): static
{
$this->createdAt = $createdAt;
return $this;
}
public function getReviewedAt(): ?DateTimeImmutable
{
return $this->reviewedAt;
}
public function setReviewedAt(?DateTimeImmutable $reviewedAt): static
{
$this->reviewedAt = $reviewedAt;
return $this;
}
public function getReviewedBy(): ?UserInterface
{
return $this->reviewedBy;
}
public function setReviewedBy(?UserInterface $reviewedBy): static
{
$this->reviewedBy = $reviewedBy;
return $this;
}
}