Files
SIRH/docs/superpowers/plans/2026-03-12-contract-suspension.md
tristan 38f09914cb
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
feat : ajout des suspensions et des jours de présence
2026-03-12 16:46:06 +01:00

1551 lines
48 KiB
Markdown

# Contract Suspension Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Allow suspending employee contracts, with multiple suspensions per contract period. Suspensions reduce accruing leave days (CDI/CDD non forfait) and acquired days (forfait 218) prorata.
**Architecture:** New `ContractSuspension` entity (OneToMany from `EmployeeContractPeriod`). Dedicated API endpoint for CRUD. Leave calculation modified in both `EmployeeLeaveSummaryProvider` (live display) and `LeaveBalanceComputationService` (rollover). Frontend drawer gets tabbed UI with Clôturer/Suspendre.
**Tech Stack:** Symfony + API Platform + Doctrine (backend), Nuxt 4 + Vue 3 + TypeScript (frontend)
**Spec:** `docs/superpowers/specs/2026-03-12-contract-suspension-design.md`
**No commits, no pushes — user handles git.**
---
## File Structure
| File | Action | Responsibility |
|------|--------|----------------|
| `src/Entity/ContractSuspension.php` | Create | New entity with startDate, endDate, comment |
| `src/Repository/ContractSuspensionRepository.php` | Create | Repository for ContractSuspension |
| `src/Entity/EmployeeContractPeriod.php` | Modify | Add OneToMany relation to suspensions |
| `migrations/Version20260312140000.php` | Create | Create contract_suspensions table |
| `src/State/ContractSuspensionWriteProcessor.php` | Create | Custom processor: validation + persistence |
| `tests/State/ContractSuspensionWriteProcessorTest.php` | Create | Tests for suspension processor |
| `src/Dto/Employees/ContractHistoryItem.php` | Modify | Add periodId + suspensions array |
| `src/Entity/Employee.php` | Modify | Add getContractPeriods(), getCurrentSuspensions() |
| `src/Service/Leave/SuspensionDaysCalculator.php` | Create | Extract suspension day counting logic (shared) |
| `tests/Service/Leave/SuspensionDaysCalculatorTest.php` | Create | Tests for suspension calculator |
| `src/State/EmployeeLeaveSummaryProvider.php` | Modify | Use SuspensionDaysCalculator in accrual + forfait |
| `src/Service/Leave/LeaveBalanceComputationService.php` | Modify | Use SuspensionDaysCalculator in accrual + forfait |
| `tests/State/EmployeeLeaveSummaryProviderTest.php` | Modify | Test suspension impact on leave |
| `frontend/services/dto/employee.ts` | Modify | Add ContractSuspension type and fields |
| `frontend/services/contractSuspensions.ts` | Create | API service for suspension CRUD |
| `frontend/components/employees/ContractTab.vue` | Modify | Tabbed drawer (Clôturer/Suspendre) |
| `frontend/composables/useEmployeeDetailPage.ts` | Modify | Suspension form state + submit logic |
---
## Chunk 1: Entity, Migration, Repository
### Task 1: ContractSuspension Entity
**Files:**
- Create: `src/Entity/ContractSuspension.php`
- Create: `src/Repository/ContractSuspensionRepository.php`
- [ ] **Step 1: Create the repository**
```php
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\ContractSuspension;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<ContractSuspension>
*/
class ContractSuspensionRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ContractSuspension::class);
}
}
```
- [ ] **Step 2: Create the entity**
```php
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Repository\ContractSuspensionRepository;
use App\State\ContractSuspensionWriteProcessor;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Context;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
#[ApiResource(
operations: [
new GetCollection(),
new Post(processor: ContractSuspensionWriteProcessor::class),
new Patch(processor: ContractSuspensionWriteProcessor::class),
],
normalizationContext: ['groups' => ['suspension:read']],
denormalizationContext: ['groups' => ['suspension:write']],
paginationEnabled: false,
security: "is_granted('ROLE_ADMIN')",
)]
#[ORM\Entity(repositoryClass: ContractSuspensionRepository::class)]
#[ORM\Table(name: 'contract_suspensions')]
#[ORM\Index(columns: ['contract_period_id', 'start_date'], name: 'idx_suspension_period_start')]
class ContractSuspension
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
#[Groups(['suspension:read', 'employee:read'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: EmployeeContractPeriod::class, inversedBy: 'suspensions')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Groups(['suspension:write'])]
private ?EmployeeContractPeriod $contractPeriod = null;
#[ORM\Column(type: 'date_immutable')]
#[Groups(['suspension:read', 'suspension:write', 'employee:read'])]
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
private DateTimeImmutable $startDate;
#[ORM\Column(type: 'date_immutable', nullable: true)]
#[Groups(['suspension:read', 'suspension:write', 'employee:read'])]
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
private ?DateTimeImmutable $endDate = null;
#[ORM\Column(type: 'text', nullable: true)]
#[Groups(['suspension:read', 'suspension:write', 'employee:read'])]
private ?string $comment = null;
#[ORM\Column(type: 'datetime_immutable')]
private DateTimeImmutable $createdAt;
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
$this->startDate = new DateTimeImmutable('today');
}
public function getId(): ?int
{
return $this->id;
}
public function getContractPeriod(): ?EmployeeContractPeriod
{
return $this->contractPeriod;
}
public function setContractPeriod(?EmployeeContractPeriod $contractPeriod): self
{
$this->contractPeriod = $contractPeriod;
return $this;
}
public function getStartDate(): DateTimeImmutable
{
return $this->startDate;
}
public function setStartDate(DateTimeImmutable $startDate): self
{
$this->startDate = $startDate;
return $this;
}
public function getEndDate(): ?DateTimeImmutable
{
return $this->endDate;
}
public function setEndDate(?DateTimeImmutable $endDate): self
{
$this->endDate = $endDate;
return $this;
}
public function getComment(): ?string
{
return $this->comment;
}
public function setComment(?string $comment): self
{
$this->comment = $comment;
return $this;
}
public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;
}
}
```
- [ ] **Step 3: Verify entity is syntactically correct**
Run: `docker exec php-sirh-fpm php -l src/Entity/ContractSuspension.php`
Expected: No syntax errors
---
### Task 2: Add OneToMany relation on EmployeeContractPeriod
**Files:**
- Modify: `src/Entity/EmployeeContractPeriod.php`
- [ ] **Step 1: Add the suspensions collection**
Add import at top of file (after existing `use` statements):
```php
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
```
Add property after `$comment` (after line 44):
```php
/**
* @var Collection<int, ContractSuspension>
*/
#[ORM\OneToMany(mappedBy: 'contractPeriod', targetEntity: ContractSuspension::class, cascade: ['persist', 'remove'])]
private Collection $suspensions;
```
In the constructor (line 49-53), add after `$this->startDate = ...`:
```php
$this->suspensions = new ArrayCollection();
```
Add getter after `setComment()` method (after line 153):
```php
/**
* @return Collection<int, ContractSuspension>
*/
public function getSuspensions(): Collection
{
return $this->suspensions;
}
```
---
### Task 3: Migration
**Files:**
- Create: `migrations/Version20260312140000.php`
- [ ] **Step 1: Create the migration file**
```php
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260312140000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create contract_suspensions table';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE contract_suspensions (
id SERIAL PRIMARY KEY,
contract_period_id INT NOT NULL REFERENCES employee_contract_periods(id) ON DELETE CASCADE,
start_date DATE NOT NULL,
end_date DATE DEFAULT NULL,
comment TEXT DEFAULT NULL,
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL
)');
$this->addSql('CREATE INDEX idx_suspension_period_start ON contract_suspensions (contract_period_id, start_date)');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE contract_suspensions');
}
}
```
- [ ] **Step 2: Run migration**
Run: `docker exec php-sirh-fpm php bin/console doctrine:migrations:migrate --no-interaction`
Expected: Migration applied successfully
- [ ] **Step 3: Verify schema is in sync**
Run: `docker exec php-sirh-fpm php bin/console doctrine:schema:validate`
Expected: OK (or only pre-existing warnings)
---
### Task 4: Expose suspensions in Employee read API
**Files:**
- Modify: `src/Dto/Employees/ContractHistoryItem.php`
- Modify: `src/Entity/Employee.php`
- [ ] **Step 1: Add periodId and suspensions to ContractHistoryItem DTO**
In `src/Dto/Employees/ContractHistoryItem.php`, add two new constructor parameters after `$comment` (line 25):
```php
#[Groups(['employee:read'])]
public ?string $comment = null,
#[Groups(['employee:read'])]
public ?int $periodId = null,
#[Groups(['employee:read'])]
public array $suspensions = [],
```
- [ ] **Step 2: Update Employee::getContractHistory() to include suspensions**
In `src/Entity/Employee.php`, find the `getContractHistory()` method (around line 253). In the `array_map` callback, update the `ContractHistoryItem` construction to pass suspensions:
Replace the current `return new ContractHistoryItem(...)` block with:
```php
$suspensionData = array_map(
static fn (ContractSuspension $s): array => [
'id' => $s->getId(),
'startDate' => $s->getStartDate()->format('Y-m-d'),
'endDate' => $s->getEndDate()?->format('Y-m-d'),
'comment' => $s->getComment(),
],
$period->getSuspensions()->toArray()
);
return new ContractHistoryItem(
contractId: $contract?->getId(),
contractName: $contract?->getName(),
weeklyHours: $contract?->getWeeklyHours(),
contractNature: $period->getContractNatureEnum()->value,
startDate: $period->getStartDate()->format('Y-m-d'),
endDate: $period->getEndDate()?->format('Y-m-d'),
comment: $period->getComment(),
periodId: $period->getId(),
suspensions: $suspensionData,
);
```
Add `use App\Entity\ContractSuspension;` at the top of `Employee.php` if not already present.
- [ ] **Step 3: Add getContractPeriods() getter to Employee**
In `src/Entity/Employee.php`, add a public getter for the `$contractPeriods` collection (needed by leave calculation to access suspensions):
```php
/**
* @return Collection<int, EmployeeContractPeriod>
*/
public function getContractPeriods(): Collection
{
return $this->contractPeriods;
}
```
- [ ] **Step 4: Add getCurrentSuspensions() to Employee**
In `src/Entity/Employee.php`, add a new method after `getCurrentContractEndDate()` (around line 247):
```php
/**
* @return list<array{id: int|null, startDate: string, endDate: string|null, comment: string|null}>
*/
#[Groups(['employee:read'])]
public function getCurrentSuspensions(): array
{
$currentPeriod = $this->resolveCurrentContractPeriod();
if (null === $currentPeriod) {
return [];
}
return array_values(array_map(
static fn (ContractSuspension $s): array => [
'id' => $s->getId(),
'startDate' => $s->getStartDate()->format('Y-m-d'),
'endDate' => $s->getEndDate()?->format('Y-m-d'),
'comment' => $s->getComment(),
],
$currentPeriod->getSuspensions()->toArray()
));
}
```
- [ ] **Step 5: Verify no syntax errors**
Run: `docker exec php-sirh-fpm php -l src/Entity/Employee.php && docker exec php-sirh-fpm php -l src/Dto/Employees/ContractHistoryItem.php`
Expected: No syntax errors
---
## Chunk 2: Suspension API Processor
### Task 5: ContractSuspensionWriteProcessor
**Files:**
- Create: `src/State/ContractSuspensionWriteProcessor.php`
- Create: `tests/State/ContractSuspensionWriteProcessorTest.php`
- [ ] **Step 1: Write the test — valid suspension creation**
```php
<?php
declare(strict_types=1);
namespace App\Tests\State;
use ApiPlatform\Metadata\Post;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Contract;
use App\Entity\ContractSuspension;
use App\Entity\Employee;
use App\Entity\EmployeeContractPeriod;
use App\Enum\ContractNature;
use App\State\ContractSuspensionWriteProcessor;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use ReflectionProperty;
/**
* @internal
*/
final class ContractSuspensionWriteProcessorTest extends TestCase
{
public function testPersistsValidSuspension(): void
{
$period = $this->buildPeriodWithId(1, '2026-01-01', null);
$suspension = new ContractSuspension();
$suspension->setContractPeriod($period);
$suspension->setStartDate(new DateTimeImmutable('2026-03-01'));
$suspension->setEndDate(new DateTimeImmutable('2026-04-30'));
$suspension->setComment('Congé sans solde');
$persistProcessor = $this->createMock(ProcessorInterface::class);
$persistProcessor->expects(self::once())->method('process')->willReturn($suspension);
$entityManager = $this->createStub(EntityManagerInterface::class);
$processor = new ContractSuspensionWriteProcessor($persistProcessor, $entityManager);
$result = $processor->process($suspension, new Post());
self::assertSame($suspension, $result);
}
public function testRejectsEndDateBeforeStartDate(): void
{
$period = $this->buildPeriodWithId(1, '2026-01-01', null);
$suspension = new ContractSuspension();
$suspension->setContractPeriod($period);
$suspension->setStartDate(new DateTimeImmutable('2026-05-01'));
$suspension->setEndDate(new DateTimeImmutable('2026-03-01'));
$persistProcessor = $this->createStub(ProcessorInterface::class);
$entityManager = $this->createStub(EntityManagerInterface::class);
$processor = new ContractSuspensionWriteProcessor($persistProcessor, $entityManager);
$this->expectException(\Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException::class);
$processor->process($suspension, new Post());
}
public function testRejectsStartDateBeforePeriodStart(): void
{
$period = $this->buildPeriodWithId(1, '2026-06-01', null);
$suspension = new ContractSuspension();
$suspension->setContractPeriod($period);
$suspension->setStartDate(new DateTimeImmutable('2026-01-01'));
$persistProcessor = $this->createStub(ProcessorInterface::class);
$entityManager = $this->createStub(EntityManagerInterface::class);
$processor = new ContractSuspensionWriteProcessor($persistProcessor, $entityManager);
$this->expectException(\Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException::class);
$processor->process($suspension, new Post());
}
public function testRejectsOverlappingSuspension(): void
{
$period = $this->buildPeriodWithId(1, '2026-01-01', null);
$existing = new ContractSuspension();
$existing->setContractPeriod($period);
$existing->setStartDate(new DateTimeImmutable('2026-03-01'));
$existing->setEndDate(new DateTimeImmutable('2026-04-30'));
$period->getSuspensions()->add($existing);
$suspension = new ContractSuspension();
$suspension->setContractPeriod($period);
$suspension->setStartDate(new DateTimeImmutable('2026-04-01'));
$suspension->setEndDate(new DateTimeImmutable('2026-05-31'));
$persistProcessor = $this->createStub(ProcessorInterface::class);
$entityManager = $this->createStub(EntityManagerInterface::class);
$processor = new ContractSuspensionWriteProcessor($persistProcessor, $entityManager);
$this->expectException(\Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException::class);
$processor->process($suspension, new Post());
}
public function testRejectsClosedPeriod(): void
{
$period = $this->buildPeriodWithId(1, '2025-01-01', '2025-12-31');
$suspension = new ContractSuspension();
$suspension->setContractPeriod($period);
$suspension->setStartDate(new DateTimeImmutable('2025-06-01'));
$suspension->setEndDate(new DateTimeImmutable('2025-07-31'));
$persistProcessor = $this->createStub(ProcessorInterface::class);
$entityManager = $this->createStub(EntityManagerInterface::class);
$processor = new ContractSuspensionWriteProcessor($persistProcessor, $entityManager);
$this->expectException(\Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException::class);
$processor->process($suspension, new Post());
}
private function buildPeriodWithId(int $id, string $start, ?string $end): EmployeeContractPeriod
{
$period = new EmployeeContractPeriod();
$period->setStartDate(new DateTimeImmutable($start));
if (null !== $end) {
$period->setEndDate(new DateTimeImmutable($end));
}
$period->setContractNature(ContractNature::CDI);
$ref = new ReflectionProperty($period, 'id');
$ref->setValue($period, $id);
return $period;
}
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `docker exec php-sirh-fpm php bin/phpunit tests/State/ContractSuspensionWriteProcessorTest.php`
Expected: FAIL — class ContractSuspensionWriteProcessor not found
- [ ] **Step 3: Implement the processor**
```php
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\ContractSuspension;
use App\Entity\EmployeeContractPeriod;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
final readonly class ContractSuspensionWriteProcessor implements ProcessorInterface
{
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private ProcessorInterface $persistProcessor,
private EntityManagerInterface $entityManager,
) {}
public function process(
mixed $data,
Operation $operation,
array $uriVariables = [],
array $context = []
): mixed {
if (!$data instanceof ContractSuspension) {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
$period = $data->getContractPeriod();
if (!$period instanceof EmployeeContractPeriod) {
throw new UnprocessableEntityHttpException('contractPeriod is required.');
}
$this->validate($data, $period);
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
private function validate(ContractSuspension $suspension, EmployeeContractPeriod $period): void
{
$startDate = $suspension->getStartDate();
$endDate = $suspension->getEndDate();
$periodEnd = $period->getEndDate();
if ($periodEnd instanceof DateTimeImmutable && $periodEnd < new DateTimeImmutable('today')) {
throw new UnprocessableEntityHttpException('Impossible de suspendre une période de contrat clôturée.');
}
if ($endDate instanceof DateTimeImmutable && $endDate < $startDate) {
throw new UnprocessableEntityHttpException('La date de fin doit être postérieure à la date de début.');
}
if ($startDate < $period->getStartDate()) {
throw new UnprocessableEntityHttpException('La suspension ne peut pas commencer avant le début du contrat.');
}
$periodEnd = $period->getEndDate();
if ($periodEnd instanceof DateTimeImmutable) {
if ($startDate > $periodEnd) {
throw new UnprocessableEntityHttpException('La suspension ne peut pas commencer après la fin du contrat.');
}
if ($endDate instanceof DateTimeImmutable && $endDate > $periodEnd) {
throw new UnprocessableEntityHttpException('La suspension ne peut pas se terminer après la fin du contrat.');
}
}
$this->validateNoOverlap($suspension, $period);
}
private function validateNoOverlap(ContractSuspension $suspension, EmployeeContractPeriod $period): void
{
$start = $suspension->getStartDate();
$end = $suspension->getEndDate();
foreach ($period->getSuspensions() as $existing) {
if ($existing->getId() === $suspension->getId() && null !== $suspension->getId()) {
continue;
}
$existingStart = $existing->getStartDate();
$existingEnd = $existing->getEndDate();
if (null === $end && null === $existingEnd) {
throw new UnprocessableEntityHttpException('Les suspensions ne peuvent pas se chevaucher.');
}
if (null === $end) {
if ($start <= $existingEnd) {
throw new UnprocessableEntityHttpException('Les suspensions ne peuvent pas se chevaucher.');
}
continue;
}
if (null === $existingEnd) {
if ($existingStart <= $end) {
throw new UnprocessableEntityHttpException('Les suspensions ne peuvent pas se chevaucher.');
}
continue;
}
if ($start <= $existingEnd && $end >= $existingStart) {
throw new UnprocessableEntityHttpException('Les suspensions ne peuvent pas se chevaucher.');
}
}
}
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `docker exec php-sirh-fpm php bin/phpunit tests/State/ContractSuspensionWriteProcessorTest.php`
Expected: All 5 tests PASS
---
## Chunk 3: Leave Calculation — SuspensionDaysCalculator
### Task 6: SuspensionDaysCalculator (shared logic)
Both `EmployeeLeaveSummaryProvider` and `LeaveBalanceComputationService` need the same logic to subtract suspended days from a month. Extract this into a small dedicated service.
**Files:**
- Create: `src/Service/Leave/SuspensionDaysCalculator.php`
- Create: `tests/Service/Leave/SuspensionDaysCalculatorTest.php`
- [ ] **Step 1: Write the test**
```php
<?php
declare(strict_types=1);
namespace App\Tests\Service\Leave;
use App\Entity\ContractSuspension;
use App\Entity\EmployeeContractPeriod;
use App\Service\Leave\SuspensionDaysCalculator;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
/**
* @internal
*/
final class SuspensionDaysCalculatorTest extends TestCase
{
public function testNoSuspensionsReturnsZero(): void
{
$calc = new SuspensionDaysCalculator();
$result = $calc->countSuspendedDaysInMonth(
new DateTimeImmutable('2026-03-01'),
new DateTimeImmutable('2026-03-31'),
[]
);
self::assertSame(0, $result);
}
public function testFullMonthSuspension(): void
{
$calc = new SuspensionDaysCalculator();
$suspension = $this->buildSuspension('2026-03-01', '2026-03-31');
$result = $calc->countSuspendedDaysInMonth(
new DateTimeImmutable('2026-03-01'),
new DateTimeImmutable('2026-03-31'),
[$suspension]
);
self::assertSame(31, $result);
}
public function testPartialMonthSuspension(): void
{
$calc = new SuspensionDaysCalculator();
$suspension = $this->buildSuspension('2026-03-10', '2026-03-20');
$result = $calc->countSuspendedDaysInMonth(
new DateTimeImmutable('2026-03-01'),
new DateTimeImmutable('2026-03-31'),
[$suspension]
);
self::assertSame(11, $result);
}
public function testSuspensionSpanningMultipleMonths(): void
{
$calc = new SuspensionDaysCalculator();
$suspension = $this->buildSuspension('2026-02-15', '2026-04-10');
// March fully covered
$result = $calc->countSuspendedDaysInMonth(
new DateTimeImmutable('2026-03-01'),
new DateTimeImmutable('2026-03-31'),
[$suspension]
);
self::assertSame(31, $result);
}
public function testSuspensionWithoutEndDate(): void
{
$calc = new SuspensionDaysCalculator();
$suspension = $this->buildSuspension('2026-03-15', null);
$result = $calc->countSuspendedDaysInMonth(
new DateTimeImmutable('2026-03-01'),
new DateTimeImmutable('2026-03-31'),
[$suspension]
);
self::assertSame(17, $result);
}
public function testMultipleSuspensionsInSameMonth(): void
{
$calc = new SuspensionDaysCalculator();
$s1 = $this->buildSuspension('2026-03-01', '2026-03-10');
$s2 = $this->buildSuspension('2026-03-20', '2026-03-25');
$result = $calc->countSuspendedDaysInMonth(
new DateTimeImmutable('2026-03-01'),
new DateTimeImmutable('2026-03-31'),
[$s1, $s2]
);
self::assertSame(16, $result);
}
public function testSuspensionOutsideMonthReturnsZero(): void
{
$calc = new SuspensionDaysCalculator();
$suspension = $this->buildSuspension('2026-01-01', '2026-01-31');
$result = $calc->countSuspendedDaysInMonth(
new DateTimeImmutable('2026-03-01'),
new DateTimeImmutable('2026-03-31'),
[$suspension]
);
self::assertSame(0, $result);
}
public function testCountSuspendedBusinessDays(): void
{
$calc = new SuspensionDaysCalculator();
// March 2-6, 2026 = Mon-Fri = 5 business days
$suspension = $this->buildSuspension('2026-03-02', '2026-03-06');
$result = $calc->countSuspendedBusinessDays(
new DateTimeImmutable('2026-01-01'),
new DateTimeImmutable('2026-12-31'),
[$suspension],
[]
);
self::assertSame(5, $result);
}
private function buildSuspension(string $start, ?string $end): ContractSuspension
{
$s = new ContractSuspension();
$s->setStartDate(new DateTimeImmutable($start));
if (null !== $end) {
$s->setEndDate(new DateTimeImmutable($end));
}
return $s;
}
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `docker exec php-sirh-fpm php bin/phpunit tests/Service/Leave/SuspensionDaysCalculatorTest.php`
Expected: FAIL — class not found
- [ ] **Step 3: Implement SuspensionDaysCalculator**
```php
<?php
declare(strict_types=1);
namespace App\Service\Leave;
use App\Entity\ContractSuspension;
use DateTimeImmutable;
final class SuspensionDaysCalculator
{
/**
* Count calendar days suspended within a month window [monthStart, monthEnd].
*
* @param list<ContractSuspension> $suspensions
*/
public function countSuspendedDaysInMonth(
DateTimeImmutable $monthStart,
DateTimeImmutable $monthEnd,
array $suspensions
): int {
$total = 0;
foreach ($suspensions as $suspension) {
$sStart = $suspension->getStartDate();
$sEnd = $suspension->getEndDate() ?? $monthEnd;
$overlapStart = $sStart > $monthStart ? $sStart : $monthStart;
$overlapEnd = $sEnd < $monthEnd ? $sEnd : $monthEnd;
if ($overlapStart > $overlapEnd) {
continue;
}
$total += ((int) $overlapEnd->diff($overlapStart)->format('%a')) + 1;
}
return $total;
}
/**
* Count business days (Mon-Fri, excl. public holidays) suspended within a period.
*
* @param list<ContractSuspension> $suspensions
* @param array<string, string> $publicHolidays map of Y-m-d => label
*/
public function countSuspendedBusinessDays(
DateTimeImmutable $periodStart,
DateTimeImmutable $periodEnd,
array $suspensions,
array $publicHolidays
): int {
$total = 0;
foreach ($suspensions as $suspension) {
$sStart = $suspension->getStartDate();
$sEnd = $suspension->getEndDate() ?? $periodEnd;
$overlapStart = $sStart > $periodStart ? $sStart : $periodStart;
$overlapEnd = $sEnd < $periodEnd ? $sEnd : $periodEnd;
if ($overlapStart > $overlapEnd) {
continue;
}
for ($cursor = $overlapStart; $cursor <= $overlapEnd; $cursor = $cursor->modify('+1 day')) {
$weekDay = (int) $cursor->format('N');
$dayKey = $cursor->format('Y-m-d');
if ($weekDay <= 5 && !isset($publicHolidays[$dayKey])) {
++$total;
}
}
}
return $total;
}
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `docker exec php-sirh-fpm php bin/phpunit tests/Service/Leave/SuspensionDaysCalculatorTest.php`
Expected: All 8 tests PASS
---
## Chunk 4: Integrate suspension into leave calculation
### Task 7: Modify EmployeeLeaveSummaryProvider
**Files:**
- Modify: `src/State/EmployeeLeaveSummaryProvider.php`
- [ ] **Step 1: Inject SuspensionDaysCalculator**
Add `use App\Service\Leave\SuspensionDaysCalculator;` to imports.
Add to constructor: `private SuspensionDaysCalculator $suspensionDaysCalculator`
- [ ] **Step 2: Modify computeAccruedDaysFromStart signature**
Change method signature at line 333 to add suspensions parameter:
```php
private function computeAccruedDaysFromStart(
float $acquiredDays,
float $accrualPerMonth,
DateTimeImmutable $periodStart,
?DateTimeImmutable $periodEnd,
array $suspensions = []
): float {
```
- [ ] **Step 3: Add suspension subtraction in the month loop**
Inside the while loop (after line 358 `$coveredDays = ...`), add:
```php
$suspendedDays = $this->suspensionDaysCalculator->countSuspendedDaysInMonth($monthStart, $monthEnd, $suspensions);
$coveredDays = max(0, $coveredDays - $suspendedDays);
```
- [ ] **Step 4: Resolve suspensions and pass to computeAccruedDaysFromStart calls**
Find where `computeAccruedDaysFromStart` is called (around lines 173-187). Before those calls, resolve the suspensions for the current period:
```php
$suspensions = $this->resolveSuspensionsForPeriod($employee, $effectiveFrom, $to);
```
Then pass `$suspensions` as the 5th argument to both calls:
```php
$generatedDays = $leavePolicy['accrualPerMonth'] > 0.0
? $this->computeAccruedDaysFromStart(
$leavePolicy['acquiredDays'],
$leavePolicy['accrualPerMonth'],
$effectiveFrom,
$accrualCalculationEnd,
$suspensions
)
: 0.0;
$generatedSaturdays = $leavePolicy['saturdayAccrualPerMonth'] > 0.0
? $this->computeAccruedDaysFromStart(
$leavePolicy['acquiredSaturdays'],
$leavePolicy['saturdayAccrualPerMonth'],
$effectiveFrom,
$accrualCalculationEnd,
$suspensions
)
: 0.0;
```
- [ ] **Step 5: Modify the forfait branch to subtract suspended business days**
In the forfait branch (around line 225-234), replace `$acquiredDays = $carryDays + $leavePolicy['acquiredDays'];` (line 227) with:
```php
$acquiredDays = $carryDays + $leavePolicy['acquiredDays'];
$suspensions = $this->resolveSuspensionsForPeriod($employee, $from, $to);
if ([] !== $suspensions) {
$publicHolidays = $this->buildPublicHolidayMap($from, $to);
$suspendedBusinessDays = $this->suspensionDaysCalculator->countSuspendedBusinessDays($from, $to, $suspensions, $publicHolidays);
$totalBusinessDays = $this->countBusinessDays($from, $to);
if ($totalBusinessDays > 0) {
$effectiveBusinessDays = $totalBusinessDays - $suspendedBusinessDays;
$acquiredDays = $carryDays + (float) max(0, $effectiveBusinessDays - self::FORFAIT_TARGET_WORKED_DAYS);
}
}
```
- [ ] **Step 6: Add resolveSuspensionsForPeriod helper method**
Add at the end of the class:
```php
/**
* @return list<ContractSuspension>
*/
private function resolveSuspensionsForPeriod(Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to): array
{
$suspensions = [];
foreach ($employee->getContractPeriods() as $period) {
$periodStart = $period->getStartDate();
$periodEnd = $period->getEndDate();
if ($periodStart > $to) {
continue;
}
if ($periodEnd instanceof DateTimeImmutable && $periodEnd < $from) {
continue;
}
foreach ($period->getSuspensions() as $suspension) {
$suspensions[] = $suspension;
}
}
return $suspensions;
}
```
Note: `Employee::getContractPeriods()` was already added in Task 4 Step 3.
Add `use App\Entity\ContractSuspension;` import in `EmployeeLeaveSummaryProvider.php`.
- [ ] **Step 7: Run existing leave tests**
Run: `docker exec php-sirh-fpm php bin/phpunit tests/State/EmployeeLeaveSummaryProviderTest.php`
Expected: All existing tests still pass (no regression — employees without suspensions should behave identically)
---
### Task 8: Modify LeaveBalanceComputationService
**Files:**
- Modify: `src/Service/Leave/LeaveBalanceComputationService.php`
- [ ] **Step 1: Inject SuspensionDaysCalculator**
Add `use App\Entity\ContractSuspension;` and `use App\Service\Leave\SuspensionDaysCalculator;` to imports.
Add to constructor: `private SuspensionDaysCalculator $suspensionDaysCalculator`
- [ ] **Step 2: Modify computeAccruedDays signature**
Change method signature at line 261 to add suspensions:
```php
private function computeAccruedDays(
float $annualCap,
float $accrualPerMonth,
DateTimeImmutable $periodStart,
DateTimeImmutable $periodEnd,
array $suspensions = []
): float {
```
- [ ] **Step 3: Add suspension subtraction in the month loop**
Inside the while loop (after line 282 `$coveredDays = ...`), add:
```php
$suspendedDays = $this->suspensionDaysCalculator->countSuspendedDaysInMonth($monthStart, $monthEnd, $suspensions);
$coveredDays = max(0, $coveredDays - $suspendedDays);
```
- [ ] **Step 4: Resolve suspensions in computeDynamicClosingForYear and pass to calls**
In `computeDynamicClosingForYear()`, before the calls to `computeAccruedDays` (around lines 79-90), add:
```php
$suspensions = $this->resolveSuspensionsForEmployeePeriod($employee, $from, $to);
```
Pass `$suspensions` as 5th argument to both `computeAccruedDays` calls:
```php
$generatedDays = $this->computeAccruedDays(
$this->resolveAnnualDays($employee),
$this->resolveDaysAccrualPerMonth($employee),
$effectiveFrom,
$to,
$suspensions
);
$generatedSaturdays = $this->computeAccruedDays(
$this->resolveAnnualSaturdays($employee),
$this->resolveSaturdayAccrualPerMonth($employee),
$effectiveFrom,
$to,
$suspensions
);
```
- [ ] **Step 5: Modify the forfait branch**
In the forfait section (around line 69-76), after `$acquiredDays = $carryDays + (float) max(0, $this->countBusinessDays($from, $to) - self::FORFAIT_TARGET_WORKED_DAYS) + $fractionedDays;`, replace with:
```php
$totalBusinessDays = $this->countBusinessDays($from, $to);
$suspensions = $this->resolveSuspensionsForEmployeePeriod($employee, $from, $to);
$suspendedBusinessDays = 0;
if ([] !== $suspensions) {
$publicHolidays = $this->buildPublicHolidayMap($from, $to);
$suspendedBusinessDays = $this->suspensionDaysCalculator->countSuspendedBusinessDays($from, $to, $suspensions, $publicHolidays);
}
$effectiveBusinessDays = $totalBusinessDays - $suspendedBusinessDays;
$acquiredDays = $carryDays + (float) max(0, $effectiveBusinessDays - self::FORFAIT_TARGET_WORKED_DAYS) + $fractionedDays;
```
- [ ] **Step 6: Add resolveSuspensionsForEmployeePeriod helper**
```php
/**
* @return list<ContractSuspension>
*/
private function resolveSuspensionsForEmployeePeriod(Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to): array
{
$suspensions = [];
foreach ($employee->getContractPeriods() as $period) {
$periodStart = $period->getStartDate();
$periodEnd = $period->getEndDate();
if ($periodStart > $to) {
continue;
}
if ($periodEnd instanceof DateTimeImmutable && $periodEnd < $from) {
continue;
}
foreach ($period->getSuspensions() as $suspension) {
$suspensions[] = $suspension;
}
}
return $suspensions;
}
```
- [ ] **Step 7: Run all tests**
Run: `docker exec php-sirh-fpm php bin/phpunit`
Expected: All tests pass
---
## Chunk 5: Frontend — DTO, Service, Drawer UI
### Task 9: Frontend types and API service
**Files:**
- Modify: `frontend/services/dto/employee.ts`
- Create: `frontend/services/contractSuspensions.ts`
- [ ] **Step 1: Add ContractSuspension type and update Employee DTO**
In `frontend/services/dto/employee.ts`, add the type and update Employee:
```typescript
export type ContractSuspension = {
id: number
startDate: string
endDate?: string | null
comment?: string | null
}
```
Add to `Employee` type:
```typescript
currentSuspensions?: ContractSuspension[]
```
Add to `ContractHistoryItem` type:
```typescript
suspensions?: ContractSuspension[]
```
- [ ] **Step 2: Create suspension API service**
```typescript
import type { ContractSuspension } from './dto/employee'
export const createSuspension = async (payload: {
contractPeriodId: number
startDate: string
endDate?: string | null
comment?: string | null
}) => {
const api = useApi()
return api.post<ContractSuspension>('/contract_suspensions', {
contractPeriod: `/api/employee_contract_periods/${payload.contractPeriodId}`,
startDate: payload.startDate,
endDate: payload.endDate ?? null,
comment: payload.comment ?? null
}, {
toastSuccessKey: 'success.suspension.create',
toastErrorKey: 'errors.suspension.create'
})
}
export const updateSuspension = async (
id: number,
payload: {
startDate: string
endDate?: string | null
comment?: string | null
}
) => {
const api = useApi()
return api.patch<ContractSuspension>(`/contract_suspensions/${id}`, {
startDate: payload.startDate,
endDate: payload.endDate ?? null,
comment: payload.comment ?? null
}, {
toastSuccessKey: 'success.suspension.update',
toastErrorKey: 'errors.suspension.update'
})
}
```
---
### Task 10: Modify ContractTab.vue — tabbed drawer
**Files:**
- Modify: `frontend/components/employees/ContractTab.vue`
- Modify: `frontend/composables/useEmployeeDetailPage.ts`
- [ ] **Step 1: Add suspension props to ContractTab.vue**
In the `defineProps` section of `ContractTab.vue`, add new props using the TypeScript generics syntax (matching the existing codebase pattern):
```typescript
type SuspensionForm = {
id: number | null
startDate: string
endDate: string
comment: string
}
```
Add to the existing `defineProps` (which uses TypeScript generics):
```typescript
// Add these to the existing defineProps<{ ... }>()
suspensionForms: SuspensionForm[]
isSuspensionSubmitting: boolean
onSubmitSuspension: (index: number) => void
onAddSuspensionForm: () => void
currentContractPeriodId?: number | null
```
- [ ] **Step 2: Change button label from "Clôturer" to "Modifier"**
In the template, find the "Clôturer" button (around lines 28-35). Change the button text:
From: `Clôturer`
To: `Modifier`
- [ ] **Step 3: Change drawer title and add tabs**
Replace the drawer title from `"Clôturer le contrat"` to `"Modifier le contrat"`.
Inside the `AppDrawer`, wrap the existing close-contract form content in a tab structure. Add at the start of the drawer content:
```vue
<div class="mb-4 flex border-b border-neutral-200">
<button
class="pb-2 px-4 border-b-2 font-semibold"
:class="drawerTab === 'close'
? 'border-primary-500 text-primary-500'
: 'border-transparent text-neutral-400 hover:text-neutral-600'"
@click="drawerTab = 'close'"
>
Clôturer
</button>
<button
class="pb-2 px-4 border-b-2 font-semibold"
:class="drawerTab === 'suspend'
? 'border-primary-500 text-primary-500'
: 'border-transparent text-neutral-400 hover:text-neutral-600'"
@click="drawerTab = 'suspend'"
>
Suspendre
</button>
</div>
```
Add a `ref` in the script:
```typescript
const drawerTab = ref<'close' | 'suspend'>('close')
```
Wrap the existing close-contract form content in `<div v-if="drawerTab === 'close'">...</div>`.
- [ ] **Step 4: Add Suspendre tab content**
After the close tab div, add:
```vue
<div v-if="drawerTab === 'suspend'" class="space-y-6">
<div
v-for="(form, index) in suspensionForms"
:key="form.id ?? `new-${index}`"
class="space-y-4 rounded-lg border border-neutral-200 p-4"
>
<div>
<label class="text-md font-semibold text-neutral-700">
Date de début <span class="text-red-600">*</span>
</label>
<input
v-model="form.startDate"
type="date"
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
/>
</div>
<div>
<label class="text-md font-semibold text-neutral-700">
Date de fin
</label>
<input
v-model="form.endDate"
type="date"
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
/>
</div>
<div>
<label class="text-md font-semibold text-neutral-700">
Commentaire
</label>
<textarea
v-model="form.comment"
rows="3"
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
/>
</div>
<button
type="button"
class="w-full rounded-md bg-primary-500 px-4 py-2 text-base font-semibold text-white transition hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-60"
:disabled="!form.startDate || isSuspensionSubmitting"
@click="onSubmitSuspension(index)"
>
{{ form.id ? 'Modifier' : '+ Ajouter' }}
</button>
</div>
<button
type="button"
class="w-full rounded-md border-2 border-dashed border-neutral-300 px-4 py-3 text-base font-semibold text-neutral-500 transition hover:border-primary-500 hover:text-primary-500"
@click="onAddSuspensionForm"
>
+ Ajouter une suspension
</button>
</div>
```
---
### Task 11: Suspension form logic in composable
**Files:**
- Modify: `frontend/composables/useEmployeeDetailPage.ts`
- [ ] **Step 1: Add suspension imports and state**
Add imports:
```typescript
import { createSuspension, updateSuspension } from '~/services/contractSuspensions'
```
Add reactive state alongside the existing contract form state:
```typescript
type SuspensionForm = {
id: number | null
startDate: string
endDate: string
comment: string
}
const suspensionForms = ref<SuspensionForm[]>([])
const isSuspensionSubmitting = ref(false)
```
- [ ] **Step 2: Add hydration logic**
Add a function to populate suspension forms from current employee data:
```typescript
const hydrateSuspensionForms = () => {
const current = employee.value?.currentSuspensions ?? []
suspensionForms.value = current.map(s => ({
id: s.id,
startDate: s.startDate,
endDate: s.endDate ?? '',
comment: s.comment ?? ''
}))
}
```
Call `hydrateSuspensionForms()` inside `openCloseContractDrawer()` (which becomes `openModifyContractDrawer()`).
- [ ] **Step 3: Add submit function**
```typescript
const submitSuspension = async (index: number) => {
const form = suspensionForms.value[index]
if (!form || !form.startDate) return
const periodId = currentActiveContractPeriodId.value
if (!periodId) return
isSuspensionSubmitting.value = true
try {
if (form.id) {
await updateSuspension(form.id, {
startDate: form.startDate,
endDate: form.endDate || null,
comment: form.comment || null
})
} else {
await createSuspension({
contractPeriodId: periodId,
startDate: form.startDate,
endDate: form.endDate || null,
comment: form.comment || null
})
}
await loadEmployee()
hydrateSuspensionForms()
} finally {
isSuspensionSubmitting.value = false
}
}
```
- [ ] **Step 4: Add addSuspensionForm function**
```typescript
const addSuspensionForm = () => {
suspensionForms.value.push({
id: null,
startDate: '',
endDate: '',
comment: ''
})
}
```
- [ ] **Step 5: Resolve currentActiveContractPeriodId**
Add a computed that extracts the period ID from the already-existing `currentActiveContractPeriod` computed (the `periodId` field was already added to `ContractHistoryItem` in Task 4 and Task 9):
```typescript
const currentActiveContractPeriodId = computed<number | null>(() => {
const period = currentActiveContractPeriod.value
return period?.periodId ?? null
})
```
Where `currentActiveContractPeriod` already exists in the composable (around line 80).
- [ ] **Step 6: Export new values from composable**
Add to the return object:
```typescript
suspensionForms,
isSuspensionSubmitting,
submitSuspension,
addSuspensionForm,
currentActiveContractPeriodId,
```
- [ ] **Step 7: Pass new props from [id].vue to ContractTab**
In `frontend/pages/employees/[id].vue`, destructure the new values from the composable and pass them as props to `<EmployeesContractTab>`:
```vue
:suspension-forms="suspensionForms"
:is-suspension-submitting="isSuspensionSubmitting"
:on-submit-suspension="submitSuspension"
:on-add-suspension-form="addSuspensionForm"
:current-contract-period-id="currentActiveContractPeriodId"
```
- [ ] **Step 8: Verify in browser**
1. Open an employee detail page
2. Click "Modifier" button — drawer should open with 2 tabs
3. Click "Suspendre" tab — should show "+ Ajouter une suspension" button
4. Click it, fill in dates, submit — suspension should be created
5. Reopen drawer — suspension should be pre-filled
6. Check leave tab — accruing days should be reduced
---
## Chunk 6: Add i18n toast keys
### Task 12: Add toast translation keys
**Files:**
- Find and modify the i18n file(s) for success/error messages
- [ ] **Step 1: Locate i18n files**
Search for existing toast keys like `success.employee.create` to find the translation file.
- [ ] **Step 2: Add suspension keys**
Add:
```
success.suspension.create: "Suspension créée"
success.suspension.update: "Suspension modifiée"
errors.suspension.create: "Erreur lors de la création de la suspension"
errors.suspension.update: "Erreur lors de la modification de la suspension"
```
**Note:** If the project uses the toast key as a fallback label when no translation file is found, adding these keys to the translation file is optional — the key string itself will be displayed. Check the `useApi` composable behavior to confirm.