feat(absences) : fondation backend du module de gestion des absences

Module type Payfit (étapes 1+2 de la spec V1) : demande d'absence, validation
admin, soldes à jour.

- Enums : AbsenceType, AbsenceStatus, HalfDay, ContractType, FamilySituation
- Entités : AbsencePolicy, AbsenceBalance, AbsenceRequest + champs RH sur User
- Services : PublicHolidayProvider (fériés FR métropole en PHP pur, Computus),
  AbsenceDayCalculator (décompte jours ouvrés/ouvrables + demi-journées, TDD),
  AbsenceBalanceService (périodes + pending/taken/recrédit)
- API Platform : providers/processors (création, approve/reject/cancel) + RBAC
  me/admin, contrôleurs preview (dry-run), upload/download justificatif, calendrier
- Migrations : une par table + colonnes RH user (DEFAULT puis DROP DEFAULT)
- Fixtures : 5 policies par défaut, salariés démo, soldes et demandes
- Tests unitaires : PublicHolidayProvider, AbsenceDayCalculator (12 tests)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matthieu
2026-05-21 14:45:14 +02:00
parent 325a7b07f9
commit de98924fd3
32 changed files with 2554 additions and 3 deletions
+163
View File
@@ -0,0 +1,163 @@
<?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\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: User::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Groups(['absence_balance:read'])]
private ?User $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;
#[ORM\Column(type: Types::FLOAT)]
#[Groups(['absence_balance:read', 'absence_balance:write'])]
private float $acquired = 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;
#[Groups(['absence_balance:read'])]
public function getAvailable(): float
{
return $this->acquired - $this->taken;
}
#[Groups(['absence_balance:read'])]
public function getLabel(): string
{
return $this->type->label();
}
public function getId(): ?int
{
return $this->id;
}
public function getUser(): ?User
{
return $this->user;
}
public function setUser(?User $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 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;
}
}
+171
View File
@@ -0,0 +1,171 @@
<?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;
}
}
+326
View File
@@ -0,0 +1,326 @@
<?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\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: User::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Groups(['absence_request:read'])]
private ?User $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: User::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
#[Groups(['absence_request:read'])]
private ?User $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(): ?User
{
return $this->user;
}
public function setUser(?User $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(): ?User
{
return $this->reviewedBy;
}
public function setReviewedBy(?User $reviewedBy): static
{
$this->reviewedBy = $reviewedBy;
return $this;
}
}
+173 -3
View File
@@ -10,6 +10,8 @@ use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Enum\ContractType;
use App\Enum\FamilySituation;
use App\Repository\UserRepository;
use App\State\MeProvider;
use App\State\UserPasswordHasherProcessor;
@@ -48,11 +50,11 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['me:read', 'task:read', 'user:list', 'time_entry:read', 'client_ticket:read'])]
#[Groups(['me:read', 'task:read', 'user:list', 'time_entry:read', 'client_ticket:read', 'absence_request:read', 'absence_balance:read'])]
private ?int $id = null;
#[ORM\Column(length: 180, unique: true)]
#[Groups(['me:read', 'task:read', 'user:list', 'user:write', 'time_entry:read', 'client_ticket:read'])]
#[Groups(['me:read', 'task:read', 'user:list', 'user:write', 'time_entry:read', 'client_ticket:read', 'absence_request:read', 'absence_balance:read'])]
private ?string $username = null;
/** @var list<string> */
@@ -87,6 +89,54 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[Groups(['me:read', 'user:list', 'user:write'])]
private Collection $allowedProjects;
// --- HR / absence management fields ---
/** Whether this user is an employee subject to absence management. */
#[ORM\Column]
#[Groups(['me:read', 'user:list', 'user:write'])]
private bool $isEmployee = false;
/** Hiring date — start of paid-leave acquisition. */
#[ORM\Column(type: Types::DATE_IMMUTABLE, nullable: true)]
#[Groups(['me:read', 'user:list', 'user:write'])]
private ?DateTimeImmutable $hireDate = null;
#[ORM\Column(type: Types::DATE_IMMUTABLE, nullable: true)]
#[Groups(['me:read', 'user:list', 'user:write'])]
private ?DateTimeImmutable $endDate = null;
#[ORM\Column(type: Types::STRING, length: 16, nullable: true, enumType: ContractType::class)]
#[Groups(['me:read', 'user:list', 'user:write'])]
private ?ContractType $contractType = null;
/** Work-time ratio: 1.0 = full time, 0.8 = 4 days out of 5. */
#[ORM\Column(type: Types::FLOAT)]
#[Groups(['me:read', 'user:list', 'user:write'])]
private float $workTimeRatio = 1.0;
/** Yearly paid-leave entitlement in worked days (default 25 = jours ouvrés). */
#[ORM\Column(type: Types::FLOAT)]
#[Groups(['me:read', 'user:list', 'user:write'])]
private float $annualLeaveDays = 25.0;
/** Reference period start as MM-DD (default 06-01, 1st of June). */
#[ORM\Column(length: 5)]
#[Groups(['me:read', 'user:list', 'user:write'])]
private string $referencePeriodStart = '06-01';
/** Paid-leave already acquired when the module is rolled out. */
#[ORM\Column(type: Types::FLOAT)]
#[Groups(['me:read', 'user:list', 'user:write'])]
private float $initialLeaveBalance = 0.0;
#[ORM\Column(type: Types::STRING, length: 16, nullable: true, enumType: FamilySituation::class)]
#[Groups(['me:read', 'user:list', 'user:write'])]
private ?FamilySituation $familySituation = null;
#[ORM\Column]
#[Groups(['me:read', 'user:list', 'user:write'])]
private int $nbChildren = 0;
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
@@ -217,7 +267,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $this;
}
#[Groups(['me:read', 'task:read', 'user:list', 'time_entry:read', 'client_ticket:read'])]
#[Groups(['me:read', 'task:read', 'user:list', 'time_entry:read', 'client_ticket:read', 'absence_request:read', 'absence_balance:read'])]
public function getAvatarUrl(): ?string
{
if (null === $this->avatarFileName) {
@@ -243,4 +293,124 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
{
$this->plainPassword = null;
}
public function isEmployee(): bool
{
return $this->isEmployee;
}
public function setIsEmployee(bool $isEmployee): static
{
$this->isEmployee = $isEmployee;
return $this;
}
public function getHireDate(): ?DateTimeImmutable
{
return $this->hireDate;
}
public function setHireDate(?DateTimeImmutable $hireDate): static
{
$this->hireDate = $hireDate;
return $this;
}
public function getEndDate(): ?DateTimeImmutable
{
return $this->endDate;
}
public function setEndDate(?DateTimeImmutable $endDate): static
{
$this->endDate = $endDate;
return $this;
}
public function getContractType(): ?ContractType
{
return $this->contractType;
}
public function setContractType(?ContractType $contractType): static
{
$this->contractType = $contractType;
return $this;
}
public function getWorkTimeRatio(): float
{
return $this->workTimeRatio;
}
public function setWorkTimeRatio(float $workTimeRatio): static
{
$this->workTimeRatio = $workTimeRatio;
return $this;
}
public function getAnnualLeaveDays(): float
{
return $this->annualLeaveDays;
}
public function setAnnualLeaveDays(float $annualLeaveDays): static
{
$this->annualLeaveDays = $annualLeaveDays;
return $this;
}
public function getReferencePeriodStart(): string
{
return $this->referencePeriodStart;
}
public function setReferencePeriodStart(string $referencePeriodStart): static
{
$this->referencePeriodStart = $referencePeriodStart;
return $this;
}
public function getInitialLeaveBalance(): float
{
return $this->initialLeaveBalance;
}
public function setInitialLeaveBalance(float $initialLeaveBalance): static
{
$this->initialLeaveBalance = $initialLeaveBalance;
return $this;
}
public function getFamilySituation(): ?FamilySituation
{
return $this->familySituation;
}
public function setFamilySituation(?FamilySituation $familySituation): static
{
$this->familySituation = $familySituation;
return $this;
}
public function getNbChildren(): int
{
return $this->nbChildren;
}
public function setNbChildren(int $nbChildren): static
{
$this->nbChildren = $nbChildren;
return $this;
}
}