Compare commits

...

16 Commits

Author SHA1 Message Date
gitea-actions
49a1c07ed1 chore: bump version to v0.1.28
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m10s
2026-03-12 10:23:20 +00:00
9fe2397386 feat : ajout d'une date d'entrée pour les employés
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-12 11:23:09 +01:00
gitea-actions
bf3f7b35a5 chore: bump version to v0.1.27
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m15s
2026-03-12 09:37:13 +00:00
5c251800fa Merge remote-tracking branch 'origin/develop' into develop
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
2026-03-12 10:37:03 +01:00
e34e928264 fix : calcule des congés en cours d'acquisition au prorata (date début contrat) 2026-03-12 10:36:49 +01:00
gitea-actions
f7dc9b6988 chore: bump version to v0.1.26
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m11s
2026-03-11 16:34:29 +00:00
b0de877b27 Merge remote-tracking branch 'origin/develop' into develop
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
2026-03-11 17:34:15 +01:00
59f05717bf fix : prise en compte des congés au provisionnel sauf pour les en cours d'acquisition 2026-03-11 17:34:07 +01:00
gitea-actions
f96fd64767 chore: bump version to v0.1.25
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m12s
2026-03-11 16:27:05 +00:00
523d4f296b fix : prise en compte des congés au provisionnel
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
2026-03-11 17:26:48 +01:00
gitea-actions
3994be6556 chore: bump version to v0.1.24
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m8s
2026-03-11 12:53:39 +00:00
f46eeaa893 fix : prise en compte des jours de congés sur l'année N-1 même si on a pas d'historique de contrat sur l'année N-1
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
2026-03-11 13:53:29 +01:00
gitea-actions
eb703272c7 chore: bump version to v0.1.23
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m11s
2026-03-11 10:41:38 +00:00
6629eb98cb fix : on ne prend plus en compte les jour de congé du samedi et dimanche pour les forfaits dans le calcule des RTT
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
2026-03-11 11:41:26 +01:00
gitea-actions
029bc03a5a chore: bump version to v0.1.22
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m12s
2026-03-11 09:05:51 +00:00
82e575fff0 fix : plus de date de fin obligatoire sur les contrats interim
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
2026-03-11 10:05:41 +01:00
17 changed files with 419 additions and 56 deletions

View File

@@ -7,7 +7,18 @@
"Bash(docker compose:*)", "Bash(docker compose:*)",
"Bash(make test:*)", "Bash(make test:*)",
"Bash(grep:*)", "Bash(grep:*)",
"Bash(docker exec:*)" "Bash(docker exec:*)",
"Bash(php8.3 bin/phpunit tests/State/EmployeeWriteProcessorTest.php --filter=testSetsEntryDateOnNewEmployee 2>&1)",
"Read(//usr/bin/**)",
"Read(//usr/local/bin/**)",
"Bash(command -v php8.2)",
"Bash(command -v php8.1)",
"Bash(ls /usr/bin/php*)",
"Read(//opt/**)",
"Read(//home/m-tristan/.nix-profile/**)",
"Read(//home/m-tristan/.local/bin/**)",
"Bash(env)",
"Bash(ls /home/m-tristan/workspace/SIRH/docker* /home/m-tristan/workspace/SIRH/Makefile 2>/dev/null; cat /home/m-tristan/workspace/SIRH/Makefile 2>/dev/null | grep -E \"\\(phpunit|test|php\\)\" | head -20)"
] ]
} }
} }

View File

@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.1.21' app.version: '0.1.28'

View File

@@ -169,11 +169,13 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
- acquis annuel samedi: `5` - acquis annuel samedi: `5`
- en cours d'acquisition jours: `25/12 = 2,08` jours/mois - en cours d'acquisition jours: `25/12 = 2,08` jours/mois
- en cours d'acquisition samedis: `5/12 = 0,42` samedi/mois (non detaille en UI) - en cours d'acquisition samedis: `5/12 = 0,42` samedi/mois (non detaille en UI)
- en cas de début/fin en cours de mois, l'acquisition est proratisée au nombre de jours calendaires couverts dans le mois
- samedis acquis affiches: uniquement `opening_saturdays` (report N-1) - samedis acquis affiches: uniquement `opening_saturdays` (report N-1)
- contrat `4h`: - contrat `4h`:
- acquis annuel CP: `10` - acquis annuel CP: `10`
- acquis annuel samedi: `0` - acquis annuel samedi: `0`
- en cours d'acquisition: `0.83` jour/mois - en cours d'acquisition: `0.83` jour/mois
- en cas de début/fin en cours de mois, l'acquisition est proratisée au nombre de jours calendaires couverts dans le mois
- contrat `FORFAIT`: - contrat `FORFAIT`:
- base annuelle: `jours ouvrés de l'exercice (lundi-vendredi, hors jours fériés métropole) - 218` - base annuelle: `jours ouvrés de l'exercice (lundi-vendredi, hors jours fériés métropole) - 218`
- prorata: en cas de démarrage/fin de contrat en cours d'année civile, le calcul ne couvre que l'intervalle actif du contrat dans l'année - prorata: en cas de démarrage/fin de contrat en cours d'année civile, le calcul ne couvre que l'intervalle actif du contrat dans l'année
@@ -198,13 +200,16 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
- lecture des compteurs: - lecture des compteurs:
- `acquis` = droits reportés de l'exercice N-1 (après application des règles de soldé) - `acquis` = droits reportés de l'exercice N-1 (après application des règles de soldé)
- `en cours d'acquisition` = total droits générés sur l'exercice N (jours + samedis en cours), sans detail séparé en UI - `en cours d'acquisition` = total droits générés sur l'exercice N (jours + samedis en cours), sans detail séparé en UI
- `en cours d'acquisition` est arrêté au dernier jour du mois précédent
- règle de consommation: - règle de consommation:
- les absences s'imputent d'abord sur `acquis`, puis sur `en cours d'acquisition` - les absences s'imputent d'abord sur `acquis`, puis sur `en cours d'acquisition`
- la prise sur `en cours d'acquisition` est autorisée (usage anticipé) - la prise sur `en cours d'acquisition` est autorisée (usage anticipé)
- `en cours d'acquisition` peut devenir négatif si la prise dépasse le généré (ex: `2.08 - 3 = -0.92`), puis se reconstitue avec les acquisitions suivantes - `en cours d'acquisition` peut devenir négatif si la prise dépasse le généré (ex: `2.08 - 3 = -0.92`), puis se reconstitue avec les acquisitions suivantes
- date d'arret de calcul: - date d'arret de calcul:
- les compteurs sont calculés jusqu'au dernier jour du mois précédent (le mois en cours est exclu) - `reste à prendre` est calculé en prévisionnel jusqu'à la fin de l'exercice
- exemple: au `04/03/2026`, l'arret de calcul est le `28/02/2026` (ou `29/02` en année bissextile) - les absences futures déjà posées sur l'exercice sont déduites du `reste à prendre`
- `en cours d'acquisition` reste calculé jusqu'au dernier jour du mois précédent
- exemple: au `11/03/2026`, l'exercice `2026` déduit les absences posées jusqu'au `31/05/2026`, mais l'acquisition reste arrêtée au `28/02/2026`
- hors périmètre phase 1: `INTERIM` (retour non supporté) - hors périmètre phase 1: `INTERIM` (retour non supporté)
- onglet `RTT`: - onglet `RTT`:
- endpoint de synthèse: `GET /api/employees/{id}/rtt-summary?year=YYYY` - endpoint de synthèse: `GET /api/employees/{id}/rtt-summary?year=YYYY`

View File

@@ -162,9 +162,9 @@
<input id="create-contract-start-date" v-model="createContractForm.startDate" type="date" :class="createContractStartDateFieldClass" /> <input id="create-contract-start-date" v-model="createContractForm.startDate" type="date" :class="createContractStartDateFieldClass" />
</div> </div>
<div v-if="requiresCreateContractEndDate"> <div v-if="showsCreateContractEndDate">
<label class="text-md font-semibold text-neutral-700" for="create-contract-end-date"> <label class="text-md font-semibold text-neutral-700" for="create-contract-end-date">
Fin contrat <span class="text-red-600">*</span> Fin contrat <span v-if="requiresCreateContractEndDate" class="text-red-600">*</span>
</label> </label>
<input id="create-contract-end-date" v-model="createContractForm.endDate" type="date" :class="createContractEndDateFieldClass" /> <input id="create-contract-end-date" v-model="createContractForm.endDate" type="date" :class="createContractEndDateFieldClass" />
</div> </div>
@@ -235,6 +235,7 @@ defineProps<{
createContractNatureFieldClass: string createContractNatureFieldClass: string
createContractFieldClass: string createContractFieldClass: string
createContractStartDateFieldClass: string createContractStartDateFieldClass: string
showsCreateContractEndDate: boolean
requiresCreateContractEndDate: boolean requiresCreateContractEndDate: boolean
createContractEndDateFieldClass: string createContractEndDateFieldClass: string
isCreateContractFormValid: boolean isCreateContractFormValid: boolean

View File

@@ -11,7 +11,7 @@ import { getEmployeeRttSummary, createRttPayment } from '~/services/employee-rtt
import { getEmployee, updateEmployee } from '~/services/employees' import { getEmployee, updateEmployee } from '~/services/employees'
import { listPublicHolidays } from '~/services/public-holidays' import { listPublicHolidays } from '~/services/public-holidays'
import { formatNullableYmdToFr, getTodayYmd, shiftYmd } from '~/utils/date' import { formatNullableYmdToFr, getTodayYmd, shiftYmd } from '~/utils/date'
import { contractNatureLabel, isContractNature, requiresContractEndDate } from '~/utils/contract' import { contractNatureLabel, isContractNature, requiresContractEndDate, showsContractEndDate } from '~/utils/contract'
export const useEmployeeDetailPage = () => { export const useEmployeeDetailPage = () => {
const route = useRoute() const route = useRoute()
@@ -99,6 +99,7 @@ export const useEmployeeDetailPage = () => {
const isContractEndDateValid = computed(() => contractForm.endDate !== '') const isContractEndDateValid = computed(() => contractForm.endDate !== '')
const showContractEndDateError = computed(() => validationTouched.endDate && !isContractEndDateValid.value) const showContractEndDateError = computed(() => validationTouched.endDate && !isContractEndDateValid.value)
const showsCreateContractEndDate = computed(() => showsContractEndDate(createContractForm.contractNature))
const requiresCreateContractEndDate = computed(() => requiresContractEndDate(createContractForm.contractNature)) const requiresCreateContractEndDate = computed(() => requiresContractEndDate(createContractForm.contractNature))
const isCreateContractValid = computed(() => createContractForm.contractId !== '') const isCreateContractValid = computed(() => createContractForm.contractId !== '')
const isCreateContractNatureValid = computed(() => isContractNature(createContractForm.contractNature)) const isCreateContractNatureValid = computed(() => isContractNature(createContractForm.contractNature))
@@ -314,8 +315,8 @@ export const useEmployeeDetailPage = () => {
await loadEmployee() await loadEmployee()
} }
watch(requiresCreateContractEndDate, (required) => { watch(showsCreateContractEndDate, (shows) => {
if (!required) { if (!shows) {
createContractForm.endDate = '' createContractForm.endDate = ''
} }
}) })
@@ -353,6 +354,7 @@ export const useEmployeeDetailPage = () => {
createContractNatureFieldClass, createContractNatureFieldClass,
createContractFieldClass, createContractFieldClass,
createContractStartDateFieldClass, createContractStartDateFieldClass,
showsCreateContractEndDate,
requiresCreateContractEndDate, requiresCreateContractEndDate,
createContractEndDateFieldClass, createContractEndDateFieldClass,
isCreateContractFormValid, isCreateContractFormValid,

View File

@@ -12,9 +12,12 @@
<div v-else class="flex min-h-0 flex-1 flex-col"> <div v-else class="flex min-h-0 flex-1 flex-col">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h1 class="text-4xl font-bold text-primary-500">{{ employee.firstName }} {{ employee.lastName }}</h1> <div>
<h1 class="text-[32px] font-bold">{{ employee.firstName }} {{ employee.lastName }}</h1>
<p>Date d'entrée : {{ employee.entryDate ? employee.entryDate.split('-').reverse().join('/') : '-' }}</p>
</div>
<div class="text-right"> <div class="text-right">
<p class="font-bold text-[20px]">{{ contractNatureLabel(employee.currentContractNature) }} {{ employeeContractWorkLabel }}</p> <p class="font-bold text-[22px]">{{ contractNatureLabel(employee.currentContractNature) }} {{ employeeContractWorkLabel }}</p>
<p class="text-[18px]">{{ employee.site?.name ?? '-' }}</p> <p class="text-[18px]">{{ employee.site?.name ?? '-' }}</p>
</div> </div>
</div> </div>
@@ -78,6 +81,7 @@
:create-contract-nature-field-class="createContractNatureFieldClass" :create-contract-nature-field-class="createContractNatureFieldClass"
:create-contract-field-class="createContractFieldClass" :create-contract-field-class="createContractFieldClass"
:create-contract-start-date-field-class="createContractStartDateFieldClass" :create-contract-start-date-field-class="createContractStartDateFieldClass"
:shows-create-contract-end-date="showsCreateContractEndDate"
:requires-create-contract-end-date="requiresCreateContractEndDate" :requires-create-contract-end-date="requiresCreateContractEndDate"
:create-contract-end-date-field-class="createContractEndDateFieldClass" :create-contract-end-date-field-class="createContractEndDateFieldClass"
:is-create-contract-form-valid="isCreateContractFormValid" :is-create-contract-form-valid="isCreateContractFormValid"
@@ -131,6 +135,7 @@ const {
createContractNatureFieldClass, createContractNatureFieldClass,
createContractFieldClass, createContractFieldClass,
createContractStartDateFieldClass, createContractStartDateFieldClass,
showsCreateContractEndDate,
requiresCreateContractEndDate, requiresCreateContractEndDate,
createContractEndDateFieldClass, createContractEndDateFieldClass,
isCreateContractFormValid, isCreateContractFormValid,

View File

@@ -51,6 +51,7 @@
<p><strong>Type:</strong> {{ contractNatureLabel(employee.currentContractNature) }}</p> <p><strong>Type:</strong> {{ contractNatureLabel(employee.currentContractNature) }}</p>
<p><strong>Temps de travail:</strong> {{ employee.contract?.name ?? '-' }}</p> <p><strong>Temps de travail:</strong> {{ employee.contract?.name ?? '-' }}</p>
<p><strong>Site:</strong> {{ employee.site?.name ?? '-' }}</p> <p><strong>Site:</strong> {{ employee.site?.name ?? '-' }}</p>
<p><strong>Date d'entrée :</strong> {{ employee.entryDate ? employee.entryDate.split('-').reverse().join('/') : '-' }}</p>
</div> </div>
</div> </div>
</NuxtLink> </NuxtLink>
@@ -154,7 +155,7 @@
La date de début est obligatoire. La date de début est obligatoire.
</p> </p>
</div> </div>
<div v-if="requiresContractEndDateComputed"> <div v-if="showsContractEndDateComputed">
<label class="text-md font-semibold text-neutral-700" for="contract-end-date"> <label class="text-md font-semibold text-neutral-700" for="contract-end-date">
Fin contrat Fin contrat
<span v-if="requiresContractEndDateComputed" class="text-red-600">*</span> <span v-if="requiresContractEndDateComputed" class="text-red-600">*</span>
@@ -166,7 +167,7 @@
:class="contractEndDateFieldClass" :class="contractEndDateFieldClass"
/> />
<p v-if="showContractEndDateError" class="mt-1 text-sm text-red-600"> <p v-if="showContractEndDateError" class="mt-1 text-sm text-red-600">
La date de fin est obligatoire pour un CDD ou un intérim. La date de fin est obligatoire pour un CDD.
</p> </p>
</div> </div>
</template> </template>
@@ -199,7 +200,7 @@ import {listContracts} from '~/services/contracts'
import {createEmployee, deleteEmployee, listEmployees, updateEmployee} from '~/services/employees' import {createEmployee, deleteEmployee, listEmployees, updateEmployee} from '~/services/employees'
import {listSites} from '~/services/sites' import {listSites} from '~/services/sites'
import SiteFilterSelector from '~/components/SiteFilterSelector.vue' import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
import {contractNatureLabel, isContractNature, requiresContractEndDate} from '~/utils/contract' import {contractNatureLabel, isContractNature, requiresContractEndDate, showsContractEndDate} from '~/utils/contract'
useHead({ useHead({
title: 'Employés' title: 'Employés'
@@ -264,6 +265,7 @@ const isSiteValid = computed(() => form.siteId !== '')
const isContractValid = computed(() => form.contractId !== '') const isContractValid = computed(() => form.contractId !== '')
const isContractNatureValid = computed(() => isContractNature(form.contractNature)) const isContractNatureValid = computed(() => isContractNature(form.contractNature))
const isContractStartDateValid = computed(() => form.contractStartDate !== '') const isContractStartDateValid = computed(() => form.contractStartDate !== '')
const showsContractEndDateComputed = computed(() => showsContractEndDate(form.contractNature))
const requiresContractEndDateComputed = computed(() => requiresContractEndDate(form.contractNature)) const requiresContractEndDateComputed = computed(() => requiresContractEndDate(form.contractNature))
const isContractEndDateValid = computed(() => { const isContractEndDateValid = computed(() => {
if (!requiresContractEndDateComputed.value) return true if (!requiresContractEndDateComputed.value) return true
@@ -429,7 +431,7 @@ const handleSubmit = async () => {
contractId: Number(form.contractId), contractId: Number(form.contractId),
contractNature: form.contractNature, contractNature: form.contractNature,
contractStartDate: form.contractStartDate, contractStartDate: form.contractStartDate,
contractEndDate: requiresContractEndDateComputed.value ? form.contractEndDate : null contractEndDate: form.contractEndDate || null
}) })
} }
@@ -460,8 +462,8 @@ watch(isDrawerOpen, (isOpen) => {
} }
}) })
watch(requiresContractEndDateComputed, (required) => { watch(showsContractEndDateComputed, (shows) => {
if (!required) { if (!shows) {
form.contractEndDate = '' form.contractEndDate = ''
} }
}) })

View File

@@ -22,4 +22,5 @@ export type Employee = {
currentContractEndDate?: string | null currentContractEndDate?: string | null
contractHistory?: ContractHistoryItem[] contractHistory?: ContractHistoryItem[]
displayOrder?: number displayOrder?: number
entryDate?: string | null
} }

View File

@@ -8,10 +8,14 @@ export const contractNatureLabel = (value?: ContractNature) => {
return 'CDI' return 'CDI'
} }
export const requiresContractEndDate = (nature: ContractNature) => { export const showsContractEndDate = (nature: ContractNature) => {
return nature === 'CDD' || nature === 'INTERIM' return nature === 'CDD' || nature === 'INTERIM'
} }
export const requiresContractEndDate = (nature: ContractNature) => {
return nature === 'CDD'
}
export const isContractNature = (value: string): value is ContractNature => { export const isContractNature = (value: string): value is ContractNature => {
return (CONTRACT_NATURES as readonly string[]).includes(value) return (CONTRACT_NATURES as readonly string[]).includes(value)
} }

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260312120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add entry_date column to employees table';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE employees ADD entry_date DATE DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE employees DROP entry_date');
}
}

View File

@@ -14,7 +14,9 @@ use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Context;
use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
#[ApiResource( #[ApiResource(
normalizationContext: ['groups' => ['employee:read', 'site:read']], normalizationContext: ['groups' => ['employee:read', 'site:read']],
@@ -57,6 +59,11 @@ class Employee
#[Groups(['employee:read', 'employee:write'])] #[Groups(['employee:read', 'employee:write'])]
private int $displayOrder = 0; private int $displayOrder = 0;
#[ORM\Column(type: 'date_immutable', nullable: true)]
#[Groups(['employee:read'])]
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
private ?DateTimeImmutable $entryDate = null;
#[ORM\Column(type: 'datetime_immutable')] #[ORM\Column(type: 'datetime_immutable')]
private DateTimeImmutable $createdAt; private DateTimeImmutable $createdAt;
@@ -166,6 +173,18 @@ class Employee
return $this; return $this;
} }
public function getEntryDate(): ?DateTimeImmutable
{
return $this->entryDate;
}
public function setEntryDate(?DateTimeImmutable $entryDate): self
{
$this->entryDate = $entryDate;
return $this;
}
public function getContractNature(): ?string public function getContractNature(): ?string
{ {
return $this->contractNature; return $this->contractNature;

View File

@@ -117,14 +117,14 @@ final readonly class LeaveBalanceComputationService
{ {
if (LeaveRuleCode::FORFAIT_218 === $ruleCode) { if (LeaveRuleCode::FORFAIT_218 === $ruleCode) {
return [ return [
new DateTimeImmutable(sprintf('%d-01-01', $year)), new DateTimeImmutable(sprintf('%d-01-01 00:00:00', $year)),
new DateTimeImmutable(sprintf('%d-12-31', $year)), new DateTimeImmutable(sprintf('%d-12-31 00:00:00', $year)),
]; ];
} }
return [ return [
new DateTimeImmutable(sprintf('%d-06-01', $year - 1)), new DateTimeImmutable(sprintf('%d-06-01 00:00:00', $year - 1)),
new DateTimeImmutable(sprintf('%d-05-31', $year)), new DateTimeImmutable(sprintf('%d-05-31 00:00:00', $year)),
]; ];
} }
@@ -145,7 +145,7 @@ final readonly class LeaveBalanceComputationService
$oldestStartDate = null; $oldestStartDate = null;
foreach ($history as $item) { foreach ($history as $item) {
$start = DateTimeImmutable::createFromFormat('Y-m-d', $item->startDate); $start = $this->parseYmdDate($item->startDate);
if (!$start) { if (!$start) {
continue; continue;
} }
@@ -197,14 +197,14 @@ final readonly class LeaveBalanceComputationService
): ?DateTimeImmutable { ): ?DateTimeImmutable {
$earliest = null; $earliest = null;
foreach ($employee->getContractHistory() as $period) { foreach ($employee->getContractHistory() as $period) {
$start = DateTimeImmutable::createFromFormat('Y-m-d', $period->startDate); $start = $this->parseYmdDate($period->startDate);
if (!$start) { if (!$start) {
continue; continue;
} }
$end = null; $end = null;
if (null !== $period->endDate && '' !== trim($period->endDate)) { if (null !== $period->endDate && '' !== trim($period->endDate)) {
$end = DateTimeImmutable::createFromFormat('Y-m-d', $period->endDate); $end = $this->parseYmdDate($period->endDate);
} }
if ($start > $to) { if ($start > $to) {
@@ -268,11 +268,37 @@ final readonly class LeaveBalanceComputationService
return 0.0; return 0.0;
} }
$monthsElapsed = ((int) $periodEnd->format('Y') - (int) $periodStart->format('Y')) * 12 $periodStart = $this->normalizeDate($periodStart);
+ ((int) $periodEnd->format('n') - (int) $periodStart->format('n')) $periodEnd = $this->normalizeDate($periodEnd);
+ 1; $coveredMonths = 0.0;
$cursor = $periodStart->modify('first day of this month')->setTime(0, 0);
while ($cursor <= $periodEnd) {
$monthStart = $cursor > $periodStart ? $cursor : $periodStart;
$monthEnd = $cursor->modify('last day of this month')->setTime(0, 0);
if ($monthEnd > $periodEnd) {
$monthEnd = $periodEnd;
}
return min($annualCap, $monthsElapsed * $accrualPerMonth); $coveredDays = ((int) $monthEnd->diff($monthStart)->format('%a')) + 1;
$daysInMonth = (int) $cursor->format('t');
$coveredMonths += $coveredDays / $daysInMonth;
$cursor = $cursor->modify('first day of next month');
}
return min($annualCap, $coveredMonths * $accrualPerMonth);
}
private function parseYmdDate(string $value): ?DateTimeImmutable
{
$date = DateTimeImmutable::createFromFormat('!Y-m-d', trim($value));
return $date instanceof DateTimeImmutable ? $date : null;
}
private function normalizeDate(DateTimeImmutable $date): DateTimeImmutable
{
return $date->setTime(0, 0);
} }
private function countBusinessDays(DateTimeImmutable $from, DateTimeImmutable $to): int private function countBusinessDays(DateTimeImmutable $from, DateTimeImmutable $to): int
@@ -349,14 +375,25 @@ final readonly class LeaveBalanceComputationService
} }
for ($cursor = $rangeStart; $cursor <= $rangeEnd; $cursor = $cursor->modify('+1 day')) { for ($cursor = $rangeStart; $cursor <= $rangeEnd; $cursor = $cursor->modify('+1 day')) {
$dayOfWeek = (int) $cursor->format('N');
if ($splitSaturdays) {
if (7 === $dayOfWeek) {
continue;
}
} else {
if ($dayOfWeek >= 6) {
continue;
}
}
[$am, $pm] = $this->resolveSegmentsForDate($absence, $cursor->format('Y-m-d')); [$am, $pm] = $this->resolveSegmentsForDate($absence, $cursor->format('Y-m-d'));
$dayAmount = ($am ? 0.5 : 0.0) + ($pm ? 0.5 : 0.0); $dayAmount = ($am ? 0.5 : 0.0) + ($pm ? 0.5 : 0.0);
if ($dayAmount <= 0.0) { if ($dayAmount <= 0.0) {
continue; continue;
} }
$isSaturday = $splitSaturdays && '6' === $cursor->format('N'); if ($splitSaturdays && 6 === $dayOfWeek) {
if ($isSaturday) {
$takenSaturdays += $dayAmount; $takenSaturdays += $dayAmount;
} else { } else {
$takenDays += $dayAmount; $takenDays += $dayAmount;

View File

@@ -163,18 +163,19 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
$effectiveFrom = $this->resolveEffectivePeriodStart($employee, $from, $to); $effectiveFrom = $this->resolveEffectivePeriodStart($employee, $from, $to);
$hasShiftedStart = $effectiveFrom > $from; $hasShiftedStart = $effectiveFrom > $from;
if ($hasShiftedStart) { if ($hasShiftedStart && null === $openingBalance) {
$carryDays = 0.0; $carryDays = 0.0;
$carrySaturdays = 0.0; $carrySaturdays = 0.0;
} }
$calculationEnd = $this->resolveCalculationEndDate($leavePolicy['ruleCode'], $year, $to, $employee); $accrualCalculationEnd = $this->resolveAccrualCalculationEndDate($leavePolicy['ruleCode'], $year, $to, $employee);
$generatedDays = $leavePolicy['accrualPerMonth'] > 0.0 $takenCalculationEnd = $this->resolveTakenCalculationEndDate($to, $employee);
$generatedDays = $leavePolicy['accrualPerMonth'] > 0.0
? $this->computeAccruedDaysFromStart( ? $this->computeAccruedDaysFromStart(
$leavePolicy['acquiredDays'], $leavePolicy['acquiredDays'],
$leavePolicy['accrualPerMonth'], $leavePolicy['accrualPerMonth'],
$effectiveFrom, $effectiveFrom,
$calculationEnd $accrualCalculationEnd
) )
: 0.0; : 0.0;
$generatedSaturdays = $leavePolicy['saturdayAccrualPerMonth'] > 0.0 $generatedSaturdays = $leavePolicy['saturdayAccrualPerMonth'] > 0.0
@@ -182,14 +183,14 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
$leavePolicy['acquiredSaturdays'], $leavePolicy['acquiredSaturdays'],
$leavePolicy['saturdayAccrualPerMonth'], $leavePolicy['saturdayAccrualPerMonth'],
$effectiveFrom, $effectiveFrom,
$calculationEnd $accrualCalculationEnd
) )
: 0.0; : 0.0;
$absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $from, $to); $absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $from, $to);
[$takenDays, $takenSaturdays] = $this->computeTakenAbsences( [$takenDays, $takenSaturdays] = $this->computeTakenAbsences(
$absences, $absences,
$effectiveFrom, $effectiveFrom,
$calculationEnd, $takenCalculationEnd,
$leavePolicy['countOnlyCp'], $leavePolicy['countOnlyCp'],
$leavePolicy['splitSaturdays'] $leavePolicy['splitSaturdays']
); );
@@ -279,14 +280,14 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
): ?DateTimeImmutable { ): ?DateTimeImmutable {
$earliest = null; $earliest = null;
foreach ($employee->getContractHistory() as $period) { foreach ($employee->getContractHistory() as $period) {
$start = DateTimeImmutable::createFromFormat('Y-m-d', $period->startDate); $start = $this->parseYmdDate($period->startDate);
if (!$start instanceof DateTimeImmutable) { if (!$start instanceof DateTimeImmutable) {
continue; continue;
} }
$end = null; $end = null;
if (null !== $period->endDate && '' !== trim($period->endDate)) { if (null !== $period->endDate && '' !== trim($period->endDate)) {
$end = DateTimeImmutable::createFromFormat('Y-m-d', $period->endDate); $end = $this->parseYmdDate($period->endDate);
} }
if ($start > $to) { if ($start > $to) {
@@ -343,17 +344,28 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
return 0.0; return 0.0;
} }
$monthsElapsed = ((int) $periodEnd->format('Y') - (int) $periodStart->format('Y')) * 12 $periodStart = $this->normalizeDate($periodStart);
+ ((int) $periodEnd->format('n') - (int) $periodStart->format('n')) $periodEnd = $this->normalizeDate($periodEnd);
+ 1; $coveredMonths = 0.0;
if ($monthsElapsed < 0) { $cursor = $periodStart->modify('first day of this month')->setTime(0, 0);
return 0.0; while ($cursor <= $periodEnd) {
$monthStart = $cursor > $periodStart ? $cursor : $periodStart;
$monthEnd = $cursor->modify('last day of this month')->setTime(0, 0);
if ($monthEnd > $periodEnd) {
$monthEnd = $periodEnd;
}
$coveredDays = ((int) $monthEnd->diff($monthStart)->format('%a')) + 1;
$daysInMonth = (int) $cursor->format('t');
$coveredMonths += $coveredDays / $daysInMonth;
$cursor = $cursor->modify('first day of next month');
} }
return min($acquiredDays, $monthsElapsed * $accrualPerMonth); return min($acquiredDays, $coveredMonths * $accrualPerMonth);
} }
private function resolveCalculationEndDate( private function resolveAccrualCalculationEndDate(
string $ruleCode, string $ruleCode,
int $year, int $year,
DateTimeImmutable $periodEnd, DateTimeImmutable $periodEnd,
@@ -372,6 +384,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
$lastDayPreviousMonth = $today $lastDayPreviousMonth = $today
->modify('first day of this month') ->modify('first day of this month')
->modify('-1 day') ->modify('-1 day')
->setTime(0, 0)
; ;
$end = $lastDayPreviousMonth < $periodEnd ? $lastDayPreviousMonth : $periodEnd; $end = $lastDayPreviousMonth < $periodEnd ? $lastDayPreviousMonth : $periodEnd;
} }
@@ -379,7 +392,25 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
// Cap at contract end date if the employee has left. // Cap at contract end date if the employee has left.
$contractEndRaw = $employee->getCurrentContractEndDate(); $contractEndRaw = $employee->getCurrentContractEndDate();
if (null !== $end && null !== $contractEndRaw && '' !== trim($contractEndRaw)) { if (null !== $end && null !== $contractEndRaw && '' !== trim($contractEndRaw)) {
$contractEnd = DateTimeImmutable::createFromFormat('Y-m-d', $contractEndRaw); $contractEnd = $this->parseYmdDate($contractEndRaw);
if ($contractEnd instanceof DateTimeImmutable && $contractEnd < $end) {
$end = $contractEnd;
}
}
return $end;
}
private function resolveTakenCalculationEndDate(
DateTimeImmutable $periodEnd,
Employee $employee
): ?DateTimeImmutable {
$end = $periodEnd;
// Cap at contract end date if the employee has left.
$contractEndRaw = $employee->getCurrentContractEndDate();
if (null !== $end && null !== $contractEndRaw && '' !== trim($contractEndRaw)) {
$contractEnd = $this->parseYmdDate($contractEndRaw);
if ($contractEnd instanceof DateTimeImmutable && $contractEnd < $end) { if ($contractEnd instanceof DateTimeImmutable && $contractEnd < $end) {
$end = $contractEnd; $end = $contractEnd;
} }
@@ -501,8 +532,8 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
private function resolveLeavePeriodBounds(int $leaveYear): array private function resolveLeavePeriodBounds(int $leaveYear): array
{ {
// Exercice CP "2026" = du 1er juin 2025 au 31 mai 2026. // Exercice CP "2026" = du 1er juin 2025 au 31 mai 2026.
$from = new DateTimeImmutable(sprintf('%d-06-01', $leaveYear - 1)); $from = new DateTimeImmutable(sprintf('%d-06-01 00:00:00', $leaveYear - 1));
$to = new DateTimeImmutable(sprintf('%d-05-31', $leaveYear)); $to = new DateTimeImmutable(sprintf('%d-05-31 00:00:00', $leaveYear));
return [$from, $to]; return [$from, $to];
} }
@@ -512,12 +543,12 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
*/ */
private function resolveForfaitYearBounds(Employee $employee, int $year): array private function resolveForfaitYearBounds(Employee $employee, int $year): array
{ {
$from = new DateTimeImmutable(sprintf('%d-01-01', $year)); $from = new DateTimeImmutable(sprintf('%d-01-01 00:00:00', $year));
$to = new DateTimeImmutable(sprintf('%d-12-31', $year)); $to = new DateTimeImmutable(sprintf('%d-12-31 00:00:00', $year));
$contractStartRaw = $employee->getCurrentContractStartDate(); $contractStartRaw = $employee->getCurrentContractStartDate();
if (null !== $contractStartRaw && '' !== trim($contractStartRaw)) { if (null !== $contractStartRaw && '' !== trim($contractStartRaw)) {
$contractStart = DateTimeImmutable::createFromFormat('Y-m-d', $contractStartRaw); $contractStart = $this->parseYmdDate($contractStartRaw);
if ($contractStart instanceof DateTimeImmutable && $contractStart > $from) { if ($contractStart instanceof DateTimeImmutable && $contractStart > $from) {
$from = $contractStart; $from = $contractStart;
} }
@@ -525,7 +556,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
$contractEndRaw = $employee->getCurrentContractEndDate(); $contractEndRaw = $employee->getCurrentContractEndDate();
if (null !== $contractEndRaw && '' !== trim($contractEndRaw)) { if (null !== $contractEndRaw && '' !== trim($contractEndRaw)) {
$contractEnd = DateTimeImmutable::createFromFormat('Y-m-d', $contractEndRaw); $contractEnd = $this->parseYmdDate($contractEndRaw);
if ($contractEnd instanceof DateTimeImmutable && $contractEnd < $to) { if ($contractEnd instanceof DateTimeImmutable && $contractEnd < $to) {
$to = $contractEnd; $to = $contractEnd;
} }
@@ -563,7 +594,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
$oldestStartDate = null; $oldestStartDate = null;
foreach ($history as $item) { foreach ($history as $item) {
$start = DateTimeImmutable::createFromFormat('Y-m-d', $item->startDate); $start = $this->parseYmdDate($item->startDate);
if (!$start) { if (!$start) {
continue; continue;
} }
@@ -592,6 +623,18 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
return $firstYear; return $firstYear;
} }
private function parseYmdDate(string $value): ?DateTimeImmutable
{
$date = DateTimeImmutable::createFromFormat('!Y-m-d', trim($value));
return $date instanceof DateTimeImmutable ? $date : null;
}
private function normalizeDate(DateTimeImmutable $date): DateTimeImmutable
{
return $date->setTime(0, 0);
}
/** /**
* @param list<Absence> $absences * @param list<Absence> $absences
* *
@@ -632,14 +675,27 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
} }
for ($cursor = $rangeStart; $cursor <= $rangeEnd; $cursor = $cursor->modify('+1 day')) { for ($cursor = $rangeStart; $cursor <= $rangeEnd; $cursor = $cursor->modify('+1 day')) {
$dayOfWeek = (int) $cursor->format('N');
if ($splitSaturdays) {
// Mode CDI/CDD : dimanche ignoré, samedi compté séparément.
if (7 === $dayOfWeek) {
continue;
}
} else {
// Mode forfait : seuls les jours ouvrés (lun-ven) comptent.
if ($dayOfWeek >= 6) {
continue;
}
}
[$am, $pm] = $this->resolveSegmentsForDate($absence, $cursor->format('Y-m-d')); [$am, $pm] = $this->resolveSegmentsForDate($absence, $cursor->format('Y-m-d'));
$dayAmount = ($am ? 0.5 : 0.0) + ($pm ? 0.5 : 0.0); $dayAmount = ($am ? 0.5 : 0.0) + ($pm ? 0.5 : 0.0);
if ($dayAmount <= 0.0) { if ($dayAmount <= 0.0) {
continue; continue;
} }
$isSaturday = $splitSaturdays && '6' === $cursor->format('N'); if ($splitSaturdays && 6 === $dayOfWeek) {
if ($isSaturday && $splitSaturdays) {
$takenSaturdays += $dayAmount; $takenSaturdays += $dayAmount;
} else { } else {
$takenDays += $dayAmount; $takenDays += $dayAmount;

View File

@@ -68,6 +68,9 @@ final readonly class EmployeeWriteProcessor implements ProcessorInterface
nature: $nature nature: $nature
); );
$data->setEntryDate($startDate);
$this->entityManager->flush();
return $result; return $result;
} }

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service\Leave;
use App\Service\Leave\LeaveBalanceComputationService;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
/**
* @internal
*/
final class LeaveBalanceComputationServiceTest extends TestCase
{
public function testComputeAccruedDaysProratesPartialFirstMonth(): void
{
$service = new ReflectionClass(LeaveBalanceComputationService::class)->newInstanceWithoutConstructor();
$method = new ReflectionClass(LeaveBalanceComputationService::class)->getMethod('computeAccruedDays');
$result = $method->invoke(
$service,
25.0,
25.0 / 12.0,
new DateTimeImmutable('2025-06-10'),
new DateTimeImmutable('2026-02-28')
);
self::assertEqualsWithDelta(18.125, $result, 0.0001);
}
public function testComputeAccruedDaysTotalMatchesAlainCase(): void
{
$service = new ReflectionClass(LeaveBalanceComputationService::class)->newInstanceWithoutConstructor();
$method = new ReflectionClass(LeaveBalanceComputationService::class)->getMethod('computeAccruedDays');
$days = $method->invoke(
$service,
25.0,
25.0 / 12.0,
new DateTimeImmutable('2025-06-10'),
new DateTimeImmutable('2026-02-28')
);
$saturdays = $method->invoke(
$service,
5.0,
5.0 / 12.0,
new DateTimeImmutable('2025-06-10'),
new DateTimeImmutable('2026-02-28')
);
self::assertEqualsWithDelta(21.75, $days + $saturdays, 0.0001);
}
public function testComputeAccruedDaysIncludesLastDayOfMonthDespiteTimeComponents(): void
{
$service = new ReflectionClass(LeaveBalanceComputationService::class)->newInstanceWithoutConstructor();
$method = new ReflectionClass(LeaveBalanceComputationService::class)->getMethod('computeAccruedDays');
$result = $method->invoke(
$service,
25.0,
25.0 / 12.0,
new DateTimeImmutable('2026-02-01 12:50:18'),
new DateTimeImmutable('2026-02-28 00:00:00')
);
self::assertEqualsWithDelta(25.0 / 12.0, $result, 0.0001);
}
}

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Tests\State;
use App\State\EmployeeLeaveSummaryProvider;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
/**
* @internal
*/
final class EmployeeLeaveSummaryProviderTest extends TestCase
{
public function testComputeAccruedDaysFromStartProratesPartialFirstMonth(): void
{
$provider = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->newInstanceWithoutConstructor();
$method = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->getMethod('computeAccruedDaysFromStart');
$result = $method->invoke(
$provider,
25.0,
25.0 / 12.0,
new DateTimeImmutable('2025-06-10'),
new DateTimeImmutable('2026-02-28')
);
self::assertEqualsWithDelta(18.125, $result, 0.0001);
}
public function testComputeAccruingDaysTotalMatchesAlainCase(): void
{
$provider = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->newInstanceWithoutConstructor();
$method = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->getMethod('computeAccruedDaysFromStart');
$days = $method->invoke(
$provider,
25.0,
25.0 / 12.0,
new DateTimeImmutable('2025-06-10'),
new DateTimeImmutable('2026-02-28')
);
$saturdays = $method->invoke(
$provider,
5.0,
5.0 / 12.0,
new DateTimeImmutable('2025-06-10'),
new DateTimeImmutable('2026-02-28')
);
self::assertEqualsWithDelta(21.75, $days + $saturdays, 0.0001);
}
public function testComputeAccruedDaysFromStartIncludesLastDayOfMonth(): void
{
$provider = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->newInstanceWithoutConstructor();
$method = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->getMethod('computeAccruedDaysFromStart');
$result = $method->invoke(
$provider,
25.0,
25.0 / 12.0,
new DateTimeImmutable('2026-02-01 12:50:18'),
new DateTimeImmutable('2026-02-28 00:00:00')
);
self::assertEqualsWithDelta(25.0 / 12.0, $result, 0.0001);
}
}

View File

@@ -6,6 +6,7 @@ namespace App\Tests\State;
use ApiPlatform\Metadata\Delete; use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\State\ProcessorInterface; use ApiPlatform\State\ProcessorInterface;
use App\Entity\Contract; use App\Entity\Contract;
use App\Entity\Employee; use App\Entity\Employee;
@@ -194,6 +195,54 @@ final class EmployeeWriteProcessorTest extends TestCase
self::assertSame($employee, $result); self::assertSame($employee, $result);
} }
public function testSetsEntryDateOnNewEmployee(): void
{
$employee = new Employee();
$employee->setFirstName('Jane');
$employee->setLastName('Doe');
$employee->setContractStartDate('2026-04-01');
$employee->setContractNature('CDI');
$contract = new Contract()
->setName('35h')
->setTrackingMode(Contract::TRACKING_TIME)
->setWeeklyHours(35)
;
$employee->setContract($contract);
$persistProcessor = $this->createMock(ProcessorInterface::class);
$removeProcessor = $this->createStub(ProcessorInterface::class);
$entityManager = $this->createStub(EntityManagerInterface::class);
$periodRepository = $this->createStub(EmployeeContractPeriodReadRepositoryInterface::class);
$changeRequestFactory = new EmployeeContractChangeRequestFactory();
$periodManager = $this->createMock(EmployeeContractPeriodManagerInterface::class);
$persistProcessor
->expects(self::once())
->method('process')
->willReturn($employee)
;
$periodManager
->expects(self::once())
->method('ensureContractPeriodExists')
;
$processor = new EmployeeWriteProcessor(
$persistProcessor,
$removeProcessor,
$entityManager,
$periodRepository,
$changeRequestFactory,
$periodManager
);
$processor->process($employee, new Post());
self::assertNotNull($employee->getEntryDate());
self::assertSame('2026-04-01', $employee->getEntryDate()->format('Y-m-d'));
}
public function testDeleteOperationDelegatesToRemoveProcessor(): void public function testDeleteOperationDelegatesToRemoveProcessor(): void
{ {
$employee = $this->buildEmployeeWithId(45); $employee = $this->buildEmployeeWithId(45);