Compare commits

...

5 Commits

Author SHA1 Message Date
gitea-actions
1858817649 chore: bump version to v0.1.31
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m12s
2026-03-12 15:58:05 +00:00
99f0f191f4 Merge remote-tracking branch 'origin/develop' into develop
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
2026-03-12 16:57:55 +01:00
96617f04bc fix : style du drawer de suspension 2026-03-12 16:57:45 +01:00
gitea-actions
25d961c367 chore: bump version to v0.1.30
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m13s
2026-03-12 15:46:17 +00:00
38f09914cb feat : ajout des suspensions et des jours de présence
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-12 16:46:06 +01:00
26 changed files with 2970 additions and 22 deletions

View File

@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.29'
app.version: '0.1.31'

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,187 @@
# Suspension de contrat — Design Spec
## Objectif
Permettre de suspendre un contrat employé. Une suspension empêche l'acquisition de congés durant la période concernée (prorata). S'applique aux CDI/CDD non forfait et aux forfaits 218.
## Contraintes
- Plusieurs suspensions possibles par période de contrat
- Pas de suppression de suspension (hors scope)
- Règle de calcul : on exclut les jours de suspension jusqu'au dernier mois complet terminé (cohérent avec la règle existante)
- Suspension sans date de fin = suspension en cours indéfiniment (exclut les mois jusqu'au dernier mois terminé)
- Les suspensions ne doivent pas se chevaucher sur une même période de contrat
## Modèle de données
### Nouvelle entité `ContractSuspension`
Nouvelle table `contract_suspensions` :
| Colonne | Type | Nullable | Description |
|---------|------|----------|-------------|
| `id` | SERIAL | non | PK |
| `contract_period_id` | INT | non | FK vers `employee_contract_periods`, CASCADE delete |
| `start_date` | DATE | non | Début de suspension |
| `end_date` | DATE | oui | Fin de suspension (null = en cours) |
| `comment` | TEXT | oui | Commentaire libre |
| `created_at` | TIMESTAMP | non | Date de création technique |
Index : `(contract_period_id, start_date)`.
Relation : `EmployeeContractPeriod` ← OneToMany → `ContractSuspension`.
## Backend — API
### Endpoint dédié
Les suspensions sont gérées via un endpoint dédié plutôt que via les champs transients Employee. Cela évite de complexifier le `EmployeeWriteProcessor` et permet de gérer N suspensions proprement.
**Nouvel ApiResource `ContractSuspension` :**
- `POST /api/contract_suspensions` — créer une suspension (body : `contractPeriod` IRI, `startDate`, `endDate`, `comment`)
- `PATCH /api/contract_suspensions/{id}` — modifier une suspension existante
- Security : `ROLE_ADMIN`
- Pagination désactivée
**Processor custom `ContractSuspensionWriteProcessor` :**
- Résout la période de contrat depuis l'IRI
- Validation :
- `startDate` requis
- `endDate >= startDate` si renseigné
- `startDate >= period.startDate`
- Pour les CDD/contrats avec date de fin : `startDate` et `endDate` dans les bornes de la période
- Pas de chevauchement avec les autres suspensions de la même période
- Rejet si la période de contrat est déjà clôturée (date de fin dans le passé)
### Lecture
Exposer les suspensions dans la sérialisation de l'Employee :
- `Employee::getCurrentSuspensions(): array` — retourne les suspensions de la période de contrat courante, groupe `employee:read`
- Ajouter les suspensions au `ContractHistoryItem` via `EmployeeContractPeriod::getSuspensions()`
## Backend — Calcul des congés
### Deux points d'impact
Le calcul d'acquisition existe à **deux endroits** qui doivent tous les deux prendre en compte les suspensions :
1. **`EmployeeLeaveSummaryProvider::computeAccruedDaysFromStart()`** — affichage live des congés en cours d'acquisition
2. **`LeaveBalanceComputationService::computeAccruedDays()`** — utilisé par le rollover (`LeaveRolloverCommand`) pour calculer le solde de report
Les deux méthodes ont la même structure (boucle mois par mois) et doivent être modifiées de la même manière.
### Modification des méthodes de calcul
Pour les deux méthodes, ajouter un paramètre optionnel : `array $suspensions = []` (tableau de `{start: DateTimeImmutable, end: ?DateTimeImmutable}`).
Dans la boucle mois par mois, pour chaque mois :
1. Calculer les jours couverts par la période de contrat (existant)
2. Pour chaque suspension, calculer le nombre de jours suspendus qui tombent dans ce mois
3. Soustraire le total des jours suspendus
4. Le ratio du mois = max(0, jours couverts - jours suspendus) / jours dans le mois
Cela gère automatiquement les suspensions qui commencent/finissent en milieu de mois (prorata).
Une suspension sans date de fin utilise la date de fin de calcul comme borne (dernier jour du mois précédent, cohérent avec la règle existante).
**Note :** chaque méthode est appelée deux fois — une pour les jours, une pour les samedis. La soustraction de suspension s'applique aux deux appels.
### Impact sur les forfaits 218
Pour les forfaits, les jours acquis en début d'exercice (ex: 34 jours pour 2026) sont réduits au prorata des jours de suspension.
Calcul : `jours acquis = base × (jours ouvrés effectifs / jours ouvrés totaux de l'exercice)`
`jours ouvrés effectifs = jours ouvrés totaux - jours ouvrés suspendus`.
Cela impacte `EmployeeLeaveSummaryProvider` dans la branche forfait et `LeaveBalanceComputationService` dans le calcul forfait de `computeDynamicClosingForYear()`.
### Passage des données de suspension aux méthodes
- **`EmployeeLeaveSummaryProvider`** : le provider a accès aux périodes de contrat via l'Employee. Il doit résoudre les suspensions de la période couvrant l'exercice et les passer aux méthodes de calcul.
- **`LeaveBalanceComputationService`** : le service utilise `$employee->getContractHistory()`. Il doit trouver les suspensions de la période couvrant l'exercice. L'accès au repository `EmployeeContractPeriodRepository` est déjà injecté — ajouter l'accès au repository `ContractSuspensionRepository` ou passer par la relation Doctrine.
### Impact sur la bascule d'exercice (rollover au 01/06)
Le rollover (`LeaveRolloverCommand`) appelle `LeaveBalanceComputationService::computeDynamicClosingForYear()` qui appelle `computeAccruedDays()`. En modifiant `computeAccruedDays()` pour accepter et traiter les suspensions, le rollover prendra automatiquement en compte les suspensions. Les jours acquis au rollover reflèteront la déduction.
**Exemple CDI :** exercice 2027 (juin 2026 - mai 2027), 2 suspensions totalisant 3 mois → au lieu de 25j acquis, l'employé bascule avec ~18.75j (9 mois effectifs × 2.083j/mois).
### Règles non impactées
- INTERIM : pas de congés gérés
## Frontend — UI
### Bouton et drawer
Le bouton "Clôturer" devient **"Modifier"**. Il ouvre le drawer existant avec le titre **"Modifier le contrat"**. Le bouton **"+ Ajouter"** (création de nouveau contrat) reste inchangé.
Le drawer contient 2 onglets :
**Onglet "Clôturer"** — contenu identique à l'actuel (type contrat, temps de travail, début contrat en readonly, date fin, commentaire, checkbox solde de tout compte).
**Onglet "Suspendre"** — formulaires empilés :
- Pour chaque suspension existante : un formulaire pré-rempli avec les 3 champs (date début, date fin, commentaire) et un bouton **"Modifier"**
- En bas : un bouton **"+ Ajouter"** qui ajoute un nouveau formulaire vide avec les 3 champs et un bouton **"Ajouter"**
- Chaque formulaire est indépendant (soumission individuelle)
### Champs par formulaire de suspension
- Date de début (required, input date)
- Date de fin (optionnel, input date)
- Commentaire (optionnel, textarea)
### Données nécessaires côté frontend
Nouveau type `ContractSuspension` (DTO) :
```typescript
type ContractSuspension = {
id: number
startDate: string
endDate?: string | null
comment?: string | null
}
```
Ajouter au type `Employee` (DTO) :
- `currentSuspensions?: ContractSuspension[]`
Ajouter au type `ContractHistoryItem` :
- `suspensions?: ContractSuspension[]`
Nouveau service `frontend/services/contractSuspensions.ts` :
- `createSuspension(payload)` — POST
- `updateSuspension(id, payload)` — PATCH
## Exemples de calcul
### CDI/CDD non forfait
Contrat CDI démarré le 01/06/2026, exercice 2027 (juin 2026 - mai 2027).
Accrual : 25j / 12 mois = 2.083j/mois.
Sans suspension au 12/03/2027 (9 mois complets : juin-février) :
- En cours d'acquisition = 9 × 2.083 = 18.75j
Avec 2 suspensions (01/01 au 31/01 + 01/03 au 31/03 = 2 mois) au 12/04/2027 (10 mois complets - 2 suspendus = 8 mois effectifs) :
- En cours d'acquisition = 8 × 2.083 = 16.67j
Samedis (5/12 par mois) :
- Sans suspension : 9 × 0.417 = 3.75j
- Avec 2 suspensions : 8 × 0.417 = 3.33j
### Forfait 218
Exercice 2026 (année civile), 34 jours acquis, 252 jours ouvrés dans l'année.
Suspension de 2 mois (44 jours ouvrés).
- Jours ouvrés effectifs = 252 - 44 = 208
- Jours acquis = 34 × (208 / 252) = 28.06j
## Hors scope
- Suppression d'une suspension
- Affichage de la suspension dans l'historique des contrats (les données sont sérialisées mais pas de rendu spécifique dans le tableau historique)
- Auto-fermeture des suspensions lors de la clôture du contrat

View File

@@ -17,7 +17,7 @@
</button>
</div>
<div class="p-6">
<div class="overflow-y-auto p-6" style="max-height: calc(100% - 65px)">
<slot />
</div>
</div>

View File

@@ -31,7 +31,7 @@
:disabled="isContractSubmitting || !canCloseCurrentContract"
@click="onOpenCloseContractDrawer"
>
Clôturer
Modifier
</button>
<button
type="button"
@@ -43,7 +43,30 @@
</button>
</div>
<AppDrawer :model-value="isContractDrawerOpen" title="Clôturer le contrat" @update:model-value="onUpdateContractDrawerOpen">
<AppDrawer :model-value="isContractDrawerOpen" title="Modifier le contrat" @update:model-value="onUpdateContractDrawerOpen">
<div class="mb-4 flex border-b border-neutral-200">
<button
type="button"
class="pb-2 px-4 border-b-2 font-semibold"
:class="drawerTab === 'close'
? 'border-primary-500 text-primary-500'
: 'border-transparent text-primary-500/50 hover:text-primary-500'"
@click="drawerTab = 'close'"
>
Clôturer
</button>
<button
type="button"
class="pb-2 px-4 border-b-2 font-semibold"
:class="drawerTab === 'suspend'
? 'border-primary-500 text-primary-500'
: 'border-transparent text-primary-500/50 hover:text-primary-500'"
@click="drawerTab = 'suspend'"
>
Suspendre
</button>
</div>
<div v-if="drawerTab === 'close'">
<form class="space-y-4" @submit.prevent="onSubmitCloseContract">
<div>
<label class="text-md font-semibold text-neutral-700" for="contract-nature">
@@ -128,6 +151,62 @@
</button>
</div>
</form>
</div>
<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-primary-500/50 px-4 py-3 text-base font-semibold text-primary-500/50 transition hover:border-primary-500 hover:text-primary-500"
@click="onAddSuspensionForm"
>
+ Ajouter une suspension
</button>
</div>
</AppDrawer>
<AppDrawer :model-value="isCreateContractDrawerOpen" title="Ajouter un contrat" @update:model-value="onUpdateCreateContractDrawerOpen">
@@ -195,6 +274,13 @@
import type { Contract } from '~/services/dto/contract'
import type { ContractHistoryItem } from '~/services/dto/employee'
type SuspensionForm = {
id: number | null
startDate: string
endDate: string
comment: string
}
type ContractForm = {
contractId: number | ''
contractName: string
@@ -213,7 +299,7 @@ type CreateContractForm = {
endDate: string
}
defineProps<{
const props = defineProps<{
contractHistory: ContractHistoryItem[]
contractNatureLabel: (value?: 'CDI' | 'CDD' | 'INTERIM') => string
contractHistoryLabel: (item: ContractHistoryItem) => string
@@ -245,5 +331,16 @@ defineProps<{
onUpdateCreateContractDrawerOpen: (open: boolean) => void
onSubmitCloseContract: () => void
onSubmitCreateContract: () => void
suspensionForms: SuspensionForm[]
isSuspensionSubmitting: boolean
onSubmitSuspension: (index: number) => void
onAddSuspensionForm: () => void
currentContractPeriodId?: number | null
}>()
const drawerTab = ref<'close' | 'suspend'>('close')
watch(() => props.isContractDrawerOpen, (open) => {
if (open) drawerTab.value = 'close'
})
</script>

View File

@@ -29,7 +29,7 @@
</div>
<div class="mt-8 min-h-0 flex-1 overflow-y-auto pr-2">
<div class="grid grid-cols-4 gap-10">
<div v-for="month in months" :key="month.label" class="rounded-md bg-tertiary-500 text-primary-500">
<div v-for="month in months" :key="month.label" class="rounded-md bg-tertiary-500 text-primary-500 flex flex-col justify-between">
<div class="flex justify-center rounded-t-md bg-primary-500 py-1 font-bold uppercase text-white">
{{ month.label }}
</div>
@@ -54,6 +54,7 @@
</div>
</template>
</div>
<div class="px-2 py-2 text-center border-t border-primary-500">Jours de présence : {{ summary?.presenceDaysByMonth?.[month.monthKey] ?? 0 }}</div>
</div>
</div>
</div>
@@ -259,9 +260,12 @@ const months = computed(() => {
cells.push(null)
}
const monthKey = `${monthYear}-${String(monthIndex + 1).padStart(2, '0')}`
return {
label,
cells
cells,
monthKey
}
})
})

View File

@@ -12,6 +12,7 @@ import { getEmployee, updateEmployee } from '~/services/employees'
import { listPublicHolidays } from '~/services/public-holidays'
import { formatNullableYmdToFr, getTodayYmd, shiftYmd } from '~/utils/date'
import { contractNatureLabel, isContractNature, requiresContractEndDate, showsContractEndDate } from '~/utils/contract'
import { createSuspension, updateSuspension } from '~/services/contractSuspensions'
export const useEmployeeDetailPage = () => {
const route = useRoute()
@@ -29,6 +30,16 @@ export const useEmployeeDetailPage = () => {
const isCreateContractDrawerOpen = ref(false)
const isCreateContractSubmitting = ref(false)
type SuspensionForm = {
id: number | null
startDate: string
endDate: string
comment: string
}
const suspensionForms = ref<SuspensionForm[]>([])
const isSuspensionSubmitting = ref(false)
const contractForm = reactive({
contractId: '' as number | '',
contractName: '',
@@ -83,6 +94,11 @@ export const useEmployeeDetailPage = () => {
return history.find((item) => item.startDate <= today && (!item.endDate || item.endDate >= today)) ?? null
})
const currentActiveContractPeriodId = computed<number | null>(() => {
const period = currentActiveContractPeriod.value
return period?.periodId ?? null
})
const canCloseCurrentContract = computed(() => {
const active = currentActiveContractPeriod.value
if (!active) return false
@@ -130,6 +146,16 @@ export const useEmployeeDetailPage = () => {
validationTouched.endDate = false
}
const hydrateSuspensionForms = () => {
const current = employee.value?.currentSuspensions ?? []
suspensionForms.value = current.map(s => ({
id: s.id,
startDate: s.startDate,
endDate: s.endDate ?? '',
comment: s.comment ?? ''
}))
}
const hydrateContractFormFromCurrent = () => {
const current = employee.value
const active = currentActiveContractPeriod.value
@@ -149,6 +175,7 @@ export const useEmployeeDetailPage = () => {
if (!employee.value || !canCloseCurrentContract.value) return
hydrateContractFormFromCurrent()
resetContractValidation()
hydrateSuspensionForms()
isContractDrawerOpen.value = true
}
@@ -301,6 +328,45 @@ export const useEmployeeDetailPage = () => {
}
}
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
}
}
const addSuspensionForm = () => {
suspensionForms.value.push({
id: null,
startDate: '',
endDate: '',
comment: ''
})
}
const submitFractionedDays = async (days: number) => {
if (!employee.value) return
const year = leaveSummary.value?.year ?? undefined
@@ -368,6 +434,11 @@ export const useEmployeeDetailPage = () => {
submitContractUpdate,
submitCreateContract,
submitFractionedDays,
submitRttPayment
submitRttPayment,
suspensionForms,
isSuspensionSubmitting,
submitSuspension,
addSuspensionForm,
currentActiveContractPeriodId
}
}

View File

@@ -91,6 +91,11 @@
:on-update-create-contract-drawer-open="setCreateContractDrawerOpen"
:on-submit-close-contract="submitContractUpdate"
:on-submit-create-contract="submitCreateContract"
:suspension-forms="suspensionForms"
:is-suspension-submitting="isSuspensionSubmitting"
:on-submit-suspension="submitSuspension"
:on-add-suspension-form="addSuspensionForm"
:current-contract-period-id="currentActiveContractPeriodId"
/>
<EmployeesLeaveTab
v-else-if="showLeaveTab && activeTab === 'leave'"
@@ -149,7 +154,12 @@ const {
submitContractUpdate,
submitCreateContract,
submitFractionedDays,
submitRttPayment
submitRttPayment,
suspensionForms,
isSuspensionSubmitting,
submitSuspension,
addSuspensionForm,
currentActiveContractPeriodId
} = useEmployeeDetailPage()
useHead(() => ({

View File

@@ -0,0 +1,38 @@
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', {
contractPeriodId: payload.contractPeriodId,
startDate: payload.startDate,
endDate: payload.endDate ?? null,
comment: payload.comment ?? null
}, {
toastSuccessKey: 'Suspension créée',
toastErrorKey: 'Erreur lors de la création de la suspension'
})
}
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: 'Suspension modifiée',
toastErrorKey: 'Erreur lors de la modification de la suspension'
})
}

View File

@@ -10,5 +10,6 @@ export type EmployeeLeaveSummary = {
takenSaturdays: number
fractionedDays: number
accruingDays: number
presenceDaysByMonth: Record<string, number>
}

View File

@@ -1,6 +1,13 @@
import type { Site } from './site'
import type { Contract } from './contract'
export type ContractSuspension = {
id: number
startDate: string
endDate?: string | null
comment?: string | null
}
export type ContractHistoryItem = {
contractId?: number | null
contractName?: string | null
@@ -9,6 +16,8 @@ export type ContractHistoryItem = {
startDate: string
endDate?: string | null
comment?: string | null
periodId?: number | null
suspensions?: ContractSuspension[]
}
export type Employee = {
@@ -23,4 +32,5 @@ export type Employee = {
contractHistory?: ContractHistoryItem[]
displayOrder?: number
entryDate?: string | null
currentSuspensions?: ContractSuspension[]
}

View File

@@ -0,0 +1,34 @@
<?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');
}
}

View File

@@ -31,4 +31,7 @@ final class EmployeeLeaveSummary
public float $takenSaturdays = 0.0;
public float $fractionedDays = 0.0;
public float $accruingDays = 0.0;
/** @var array<string, float> YYYY-MM => count (0.5 for half-days) */
public array $presenceDaysByMonth = [];
}

View File

@@ -23,5 +23,9 @@ final class ContractHistoryItem
public ?string $endDate,
#[Groups(['employee:read'])]
public ?string $comment = null,
#[Groups(['employee:read'])]
public ?int $periodId = null,
#[Groups(['employee:read'])]
public array $suspensions = [],
) {}
}

View File

@@ -0,0 +1,140 @@
<?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')]
private ?EmployeeContractPeriod $contractPeriod = null;
#[Groups(['suspension:write'])]
private ?int $contractPeriodId = 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 getContractPeriodId(): ?int
{
return $this->contractPeriodId;
}
public function setContractPeriodId(?int $contractPeriodId): self
{
$this->contractPeriodId = $contractPeriodId;
return $this;
}
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;
}
}

View File

@@ -263,6 +263,36 @@ class Employee
return $this->resolveCurrentContractPeriod()?->getEndDate()?->format('Y-m-d');
}
/**
* @return list<array{id: null|int, startDate: string, endDate: null|string, comment: null|string}>
*/
#[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()
));
}
/**
* @return Collection<int, EmployeeContractPeriod>
*/
public function getContractPeriods(): Collection
{
return $this->contractPeriods;
}
/**
* @return list<ContractHistoryItem>
*/
@@ -279,6 +309,16 @@ class Employee
static function (EmployeeContractPeriod $period): ContractHistoryItem {
$contract = $period->getContract();
$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(),
@@ -287,6 +327,8 @@ class Employee
startDate: $period->getStartDate()->format('Y-m-d'),
endDate: $period->getEndDate()?->format('Y-m-d'),
comment: $period->getComment(),
periodId: $period->getId(),
suspensions: $suspensionData,
);
},
$periods

View File

@@ -7,6 +7,8 @@ namespace App\Entity;
use App\Enum\ContractNature;
use App\Repository\EmployeeContractPeriodRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: EmployeeContractPeriodRepository::class)]
@@ -43,13 +45,20 @@ class EmployeeContractPeriod
#[ORM\Column(type: 'text', nullable: true)]
private ?string $comment = null;
/**
* @var Collection<int, ContractSuspension>
*/
#[ORM\OneToMany(mappedBy: 'contractPeriod', targetEntity: ContractSuspension::class, cascade: ['persist', 'remove'])]
private Collection $suspensions;
#[ORM\Column(type: 'datetime_immutable')]
private DateTimeImmutable $createdAt;
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
$this->startDate = new DateTimeImmutable('today');
$this->createdAt = new DateTimeImmutable();
$this->startDate = new DateTimeImmutable('today');
$this->suspensions = new ArrayCollection();
}
public function getId(): ?int
@@ -151,4 +160,12 @@ class EmployeeContractPeriod
return $this;
}
/**
* @return Collection<int, ContractSuspension>
*/
public function getSuspensions(): Collection
{
return $this->suspensions;
}
}

View File

@@ -0,0 +1,20 @@
<?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);
}
}

View File

@@ -138,6 +138,48 @@ final class WorkHourRepository extends ServiceEntityRepository implements WorkHo
return $qb->getQuery()->getOneOrNullResult();
}
/**
* @return array<string, float> YYYY-MM => presence day count (0.5 for half-days)
*/
public function countPresenceDaysByMonth(Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to): array
{
$sql = <<<'SQL'
SELECT TO_CHAR(work_date, 'YYYY-MM') AS month,
SUM(
CASE
WHEN (morning_from IS NOT NULL OR is_present_morning = true)
AND (afternoon_from IS NOT NULL OR is_present_afternoon = true)
THEN 1.0
WHEN (morning_from IS NOT NULL OR is_present_morning = true)
OR (afternoon_from IS NOT NULL OR is_present_afternoon = true)
THEN 0.5
ELSE 0
END
) AS cnt
FROM work_hours
WHERE employee_id = :employee
AND work_date >= :from
AND work_date <= :to
AND (morning_from IS NOT NULL OR is_present_morning = true
OR afternoon_from IS NOT NULL OR is_present_afternoon = true)
GROUP BY month
SQL;
$conn = $this->getEntityManager()->getConnection();
$rows = $conn->fetchAllAssociative($sql, [
'employee' => $employee->getId(),
'from' => $from->format('Y-m-d'),
'to' => $to->format('Y-m-d'),
]);
$result = [];
foreach ($rows as $row) {
$result[(string) $row['month']] = (float) $row['cnt'];
}
return $result;
}
public function hasPendingSiteValidationForSiteAndDate(int $siteId, DateTimeInterface $date): bool
{
$workDate = DateTimeImmutable::createFromInterface($date);

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Service\Leave;
use App\Entity\Absence;
use App\Entity\ContractSuspension;
use App\Entity\Employee;
use App\Enum\LeaveRuleCode;
use App\Repository\AbsenceRepository;
@@ -29,6 +30,7 @@ final readonly class LeaveBalanceComputationService
private EmployeeContractPeriodRepository $periodRepository,
private EmployeeLeaveBalanceRepository $leaveBalanceRepository,
private PublicHolidayServiceInterface $publicHolidayService,
private SuspensionDaysCalculator $suspensionDaysCalculator,
) {}
/**
@@ -67,7 +69,18 @@ final readonly class LeaveBalanceComputationService
$fractionedDays = $this->resolveFractionedDays($employee, $ruleCode, $year);
if (LeaveRuleCode::FORFAIT_218 === $ruleCode) {
$acquiredDays = $carryDays + (float) max(0, $this->countBusinessDays($from, $to) - self::FORFAIT_TARGET_WORKED_DAYS) + $fractionedDays;
$totalBusinessDays = $this->countBusinessDays($from, $to);
$baseAcquiredDays = (float) max(0, $totalBusinessDays - self::FORFAIT_TARGET_WORKED_DAYS);
$suspensions = $this->resolveSuspensionsForEmployeePeriod($employee, $from, $to);
$acquiredDays = $carryDays + $baseAcquiredDays + $fractionedDays;
if ([] !== $suspensions) {
$totalMonths = $this->countFractionalMonths($from, $to);
$suspendedMonths = $this->countSuspendedFractionalMonths($from, $to, $suspensions);
if ($totalMonths > 0) {
$ratio = max(0.0, ($totalMonths - $suspendedMonths) / $totalMonths);
$acquiredDays = $carryDays + $baseAcquiredDays * $ratio + $fractionedDays;
}
}
$absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $effectiveFrom, $to);
[$takenDays] = $this->computeTakenAbsences($absences, $effectiveFrom, $to, false, false);
$previousRemainingDays = max(0.0, $acquiredDays - $takenDays);
@@ -76,17 +89,20 @@ final readonly class LeaveBalanceComputationService
continue;
}
$suspensions = $this->resolveSuspensionsForEmployeePeriod($employee, $from, $to);
$generatedDays = $this->computeAccruedDays(
$this->resolveAnnualDays($employee),
$this->resolveDaysAccrualPerMonth($employee),
$effectiveFrom,
$to
$to,
$suspensions
);
$generatedSaturdays = $this->computeAccruedDays(
$this->resolveAnnualSaturdays($employee),
$this->resolveSaturdayAccrualPerMonth($employee),
$effectiveFrom,
$to
$to,
$suspensions
);
$absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $effectiveFrom, $to);
@@ -262,7 +278,8 @@ final readonly class LeaveBalanceComputationService
float $annualCap,
float $accrualPerMonth,
DateTimeImmutable $periodStart,
DateTimeImmutable $periodEnd
DateTimeImmutable $periodEnd,
array $suspensions = []
): float {
if ($accrualPerMonth <= 0.0 || $periodEnd < $periodStart) {
return 0.0;
@@ -280,6 +297,10 @@ final readonly class LeaveBalanceComputationService
}
$coveredDays = ((int) $monthEnd->diff($monthStart)->format('%a')) + 1;
if ([] !== $suspensions) {
$suspendedDays = $this->suspensionDaysCalculator->countSuspendedDaysInMonth($monthStart, $monthEnd, $suspensions);
$coveredDays = max(0, $coveredDays - $suspendedDays);
}
$daysInMonth = (int) $cursor->format('t');
$coveredMonths += $coveredDays / $daysInMonth;
@@ -404,6 +425,80 @@ final readonly class LeaveBalanceComputationService
return [$takenDays, $takenSaturdays];
}
private function countFractionalMonths(DateTimeImmutable $from, DateTimeImmutable $to): float
{
$from = $this->normalizeDate($from);
$to = $this->normalizeDate($to);
$months = 0.0;
$cursor = $from->modify('first day of this month')->setTime(0, 0);
while ($cursor <= $to) {
$monthStart = $cursor > $from ? $cursor : $from;
$monthEnd = $cursor->modify('last day of this month')->setTime(0, 0);
if ($monthEnd > $to) {
$monthEnd = $to;
}
$coveredDays = ((int) $monthEnd->diff($monthStart)->format('%a')) + 1;
$daysInMonth = (int) $cursor->format('t');
$months += $coveredDays / $daysInMonth;
$cursor = $cursor->modify('first day of next month');
}
return $months;
}
/**
* @param list<ContractSuspension> $suspensions
*/
private function countSuspendedFractionalMonths(DateTimeImmutable $from, DateTimeImmutable $to, array $suspensions): float
{
$from = $this->normalizeDate($from);
$to = $this->normalizeDate($to);
$months = 0.0;
$cursor = $from->modify('first day of this month')->setTime(0, 0);
while ($cursor <= $to) {
$monthStart = $cursor > $from ? $cursor : $from;
$monthEnd = $cursor->modify('last day of this month')->setTime(0, 0);
if ($monthEnd > $to) {
$monthEnd = $to;
}
$daysInMonth = (int) $cursor->format('t');
$suspendedDays = $this->suspensionDaysCalculator->countSuspendedDaysInMonth($monthStart, $monthEnd, $suspensions);
$months += $suspendedDays / $daysInMonth;
$cursor = $cursor->modify('first day of next month');
}
return $months;
}
/**
* @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;
}
/**
* @return array{bool, bool}
*/

View File

@@ -0,0 +1,77 @@
<?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;
}
}

View File

@@ -0,0 +1,122 @@
<?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 && null !== $data->getContractPeriodId()) {
$period = $this->entityManager->find(EmployeeContractPeriod::class, $data->getContractPeriodId());
if ($period instanceof EmployeeContractPeriod) {
$data->setContractPeriod($period);
}
}
if (!$period instanceof EmployeeContractPeriod) {
throw new UnprocessableEntityHttpException('contractPeriodId is required.');
}
$this->validate($data, $period);
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
private function validate(ContractSuspension $suspension, EmployeeContractPeriod $period): void
{
// Compare as Y-m-d strings to avoid timezone issues between Doctrine and API Platform DateTimeImmutable
$startDate = $suspension->getStartDate()->format('Y-m-d');
$endDate = $suspension->getEndDate()?->format('Y-m-d');
$periodStart = $period->getStartDate()->format('Y-m-d');
$periodEnd = $period->getEndDate()?->format('Y-m-d');
if (null !== $periodEnd && $periodEnd < new DateTimeImmutable('today')->format('Y-m-d')) {
throw new UnprocessableEntityHttpException('Impossible de suspendre une période de contrat clôturée.');
}
if (null !== $endDate && $endDate < $startDate) {
throw new UnprocessableEntityHttpException('La date de fin doit être postérieure à la date de début.');
}
if ($startDate < $periodStart) {
throw new UnprocessableEntityHttpException('La suspension ne peut pas commencer avant le début du contrat.');
}
if (null !== $periodEnd) {
if ($startDate > $periodEnd) {
throw new UnprocessableEntityHttpException('La suspension ne peut pas commencer après la fin du contrat.');
}
if (null !== $endDate && $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()->format('Y-m-d');
$end = $suspension->getEndDate()?->format('Y-m-d');
foreach ($period->getSuspensions() as $existing) {
if ($existing->getId() === $suspension->getId() && null !== $suspension->getId()) {
continue;
}
$existingStart = $existing->getStartDate()->format('Y-m-d');
$existingEnd = $existing->getEndDate()?->format('Y-m-d');
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.');
}
}
}
}

View File

@@ -8,6 +8,7 @@ use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\EmployeeLeaveSummary;
use App\Entity\Absence;
use App\Entity\ContractSuspension;
use App\Entity\Employee;
use App\Entity\User;
use App\Enum\ContractNature;
@@ -17,8 +18,10 @@ use App\Repository\AbsenceRepository;
use App\Repository\EmployeeContractPeriodRepository;
use App\Repository\EmployeeLeaveBalanceRepository;
use App\Repository\EmployeeRepository;
use App\Repository\WorkHourRepository;
use App\Security\EmployeeScopeService;
use App\Service\Leave\LeaveBalanceComputationService;
use App\Service\Leave\SuspensionDaysCalculator;
use App\Service\PublicHolidayServiceInterface;
use DateTimeImmutable;
use Symfony\Bundle\SecurityBundle\Security;
@@ -50,6 +53,8 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
private EmployeeLeaveBalanceRepository $leaveBalanceRepository,
private LeaveBalanceComputationService $leaveBalanceComputationService,
private PublicHolidayServiceInterface $publicHolidayService,
private SuspensionDaysCalculator $suspensionDaysCalculator,
private WorkHourRepository $workHourRepository,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): EmployeeLeaveSummary
@@ -97,6 +102,9 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
$summary->remainingDays = $yearSummary['remainingDays'] + $fractionedDays;
$summary->remainingSaturdays = $yearSummary['remainingSaturdays'];
[$periodFrom, $periodTo] = $this->resolvePeriodBounds($employee, $year);
$summary->presenceDaysByMonth = $this->workHourRepository->countPresenceDaysByMonth($employee, $periodFrom, $periodTo);
return $summary;
}
@@ -170,12 +178,14 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
$accrualCalculationEnd = $this->resolveAccrualCalculationEndDate($leavePolicy['ruleCode'], $year, $to, $employee);
$takenCalculationEnd = $this->resolveTakenCalculationEndDate($to, $employee);
$suspensions = $this->resolveSuspensionsForPeriod($employee, $effectiveFrom, $to);
$generatedDays = $leavePolicy['accrualPerMonth'] > 0.0
? $this->computeAccruedDaysFromStart(
$leavePolicy['acquiredDays'],
$leavePolicy['accrualPerMonth'],
$effectiveFrom,
$accrualCalculationEnd
$accrualCalculationEnd,
$suspensions
)
: 0.0;
$generatedSaturdays = $leavePolicy['saturdayAccrualPerMonth'] > 0.0
@@ -183,7 +193,8 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
$leavePolicy['acquiredSaturdays'],
$leavePolicy['saturdayAccrualPerMonth'],
$effectiveFrom,
$accrualCalculationEnd
$accrualCalculationEnd,
$suspensions
)
: 0.0;
$absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $from, $to);
@@ -224,7 +235,16 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
$previousRemainingSaturdays = $remainingAcquiredSaturdays + $remainingGeneratedSaturdays;
} else {
// Forfait: no "en cours d'acquisition" counter, all rights are in acquired.
$acquiredDays = $carryDays + $leavePolicy['acquiredDays'];
$acquiredDays = $carryDays + $leavePolicy['acquiredDays'];
$suspensions = $this->resolveSuspensionsForPeriod($employee, $from, $to);
if ([] !== $suspensions) {
$totalMonths = $this->countFractionalMonths($from, $to);
$suspendedMonths = $this->countSuspendedFractionalMonths($from, $to, $suspensions);
if ($totalMonths > 0) {
$ratio = max(0.0, ($totalMonths - $suspendedMonths) / $totalMonths);
$acquiredDays = $carryDays + $leavePolicy['acquiredDays'] * $ratio;
}
}
$accruingDays = 0.0;
$remainingDays = max(0.0, $acquiredDays - $takenDays);
$acquiredSaturdays = 0.0;
@@ -334,7 +354,8 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
float $acquiredDays,
float $accrualPerMonth,
DateTimeImmutable $periodStart,
?DateTimeImmutable $periodEnd
?DateTimeImmutable $periodEnd,
array $suspensions = []
): float {
if ($accrualPerMonth <= 0.0) {
return $acquiredDays;
@@ -356,6 +377,10 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
}
$coveredDays = ((int) $monthEnd->diff($monthStart)->format('%a')) + 1;
if ([] !== $suspensions) {
$suspendedDays = $this->suspensionDaysCalculator->countSuspendedDaysInMonth($monthStart, $monthEnd, $suspensions);
$coveredDays = max(0, $coveredDays - $suspendedDays);
}
$daysInMonth = (int) $cursor->format('t');
$coveredMonths += $coveredDays / $daysInMonth;
@@ -706,6 +731,80 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
return [$takenDays, $takenSaturdays];
}
private function countFractionalMonths(DateTimeImmutable $from, DateTimeImmutable $to): float
{
$from = $this->normalizeDate($from);
$to = $this->normalizeDate($to);
$months = 0.0;
$cursor = $from->modify('first day of this month')->setTime(0, 0);
while ($cursor <= $to) {
$monthStart = $cursor > $from ? $cursor : $from;
$monthEnd = $cursor->modify('last day of this month')->setTime(0, 0);
if ($monthEnd > $to) {
$monthEnd = $to;
}
$coveredDays = ((int) $monthEnd->diff($monthStart)->format('%a')) + 1;
$daysInMonth = (int) $cursor->format('t');
$months += $coveredDays / $daysInMonth;
$cursor = $cursor->modify('first day of next month');
}
return $months;
}
/**
* @param list<ContractSuspension> $suspensions
*/
private function countSuspendedFractionalMonths(DateTimeImmutable $from, DateTimeImmutable $to, array $suspensions): float
{
$from = $this->normalizeDate($from);
$to = $this->normalizeDate($to);
$months = 0.0;
$cursor = $from->modify('first day of this month')->setTime(0, 0);
while ($cursor <= $to) {
$monthStart = $cursor > $from ? $cursor : $from;
$monthEnd = $cursor->modify('last day of this month')->setTime(0, 0);
if ($monthEnd > $to) {
$monthEnd = $to;
}
$daysInMonth = (int) $cursor->format('t');
$suspendedDays = $this->suspensionDaysCalculator->countSuspendedDaysInMonth($monthStart, $monthEnd, $suspensions);
$months += $suspendedDays / $daysInMonth;
$cursor = $cursor->modify('first day of next month');
}
return $months;
}
/**
* @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;
}
/**
* @return array{bool, bool}
*/

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Tests\Service\Leave;
use App\Service\Leave\LeaveBalanceComputationService;
use App\Service\Leave\SuspensionDaysCalculator;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
@@ -16,7 +17,7 @@ final class LeaveBalanceComputationServiceTest extends TestCase
{
public function testComputeAccruedDaysProratesPartialFirstMonth(): void
{
$service = new ReflectionClass(LeaveBalanceComputationService::class)->newInstanceWithoutConstructor();
$service = $this->createServiceWithoutConstructor();
$method = new ReflectionClass(LeaveBalanceComputationService::class)->getMethod('computeAccruedDays');
$result = $method->invoke(
@@ -32,7 +33,7 @@ final class LeaveBalanceComputationServiceTest extends TestCase
public function testComputeAccruedDaysTotalMatchesAlainCase(): void
{
$service = new ReflectionClass(LeaveBalanceComputationService::class)->newInstanceWithoutConstructor();
$service = $this->createServiceWithoutConstructor();
$method = new ReflectionClass(LeaveBalanceComputationService::class)->getMethod('computeAccruedDays');
$days = $method->invoke(
@@ -55,7 +56,7 @@ final class LeaveBalanceComputationServiceTest extends TestCase
public function testComputeAccruedDaysIncludesLastDayOfMonthDespiteTimeComponents(): void
{
$service = new ReflectionClass(LeaveBalanceComputationService::class)->newInstanceWithoutConstructor();
$service = $this->createServiceWithoutConstructor();
$method = new ReflectionClass(LeaveBalanceComputationService::class)->getMethod('computeAccruedDays');
$result = $method->invoke(
@@ -68,4 +69,15 @@ final class LeaveBalanceComputationServiceTest extends TestCase
self::assertEqualsWithDelta(25.0 / 12.0, $result, 0.0001);
}
private function createServiceWithoutConstructor(): LeaveBalanceComputationService
{
$rc = new ReflectionClass(LeaveBalanceComputationService::class);
$service = $rc->newInstanceWithoutConstructor();
$prop = $rc->getProperty('suspensionDaysCalculator');
$prop->setValue($service, new SuspensionDaysCalculator());
return $service;
}
}

View File

@@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service\Leave;
use App\Entity\ContractSuspension;
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;
}
}

View File

@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace App\Tests\State;
use ApiPlatform\Metadata\Post;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\ContractSuspension;
use App\Entity\EmployeeContractPeriod;
use App\Enum\ContractNature;
use App\State\ContractSuspensionWriteProcessor;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use ReflectionProperty;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
/**
* @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(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(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(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(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;
}
}