Compare commits

...

2 Commits

Author SHA1 Message Date
gitea-actions
51bf155b0e chore: bump version to v0.1.88
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 56s
2026-04-17 06:59:10 +00:00
1095421424 feat : modification des exports PDF et affichage du type de contrat sur l'écran des heures
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-04-17 08:58:58 +02:00
20 changed files with 769 additions and 84 deletions

View File

@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.1.87' app.version: '0.1.88'

View File

@@ -1,5 +1,5 @@
<template> <template>
<AppDrawer v-model="drawerOpen" title="Export heures annuelles"> <AppDrawer v-model="drawerOpen" title="Export heures">
<form class="space-y-4" @submit.prevent="handleSubmit"> <form class="space-y-4" @submit.prevent="handleSubmit">
<div> <div>
<label class="text-md font-semibold text-neutral-700" for="yearly-hours-year"> <label class="text-md font-semibold text-neutral-700" for="yearly-hours-year">
@@ -14,6 +14,20 @@
</select> </select>
</div> </div>
<div>
<label class="text-md font-semibold text-neutral-700" for="yearly-hours-month">
Mois
</label>
<select
id="yearly-hours-month"
v-model="selectedMonth"
:class="selectFieldClass"
>
<option value="">Toute l'année</option>
<option v-for="m in months" :key="m.value" :value="m.value">{{ m.label }}</option>
</select>
</div>
<div class="flex justify-center pt-2"> <div class="flex justify-center pt-2">
<button <button
type="submit" type="submit"
@@ -37,7 +51,7 @@ const props = defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
(event: 'update:modelValue', value: boolean): void (event: 'update:modelValue', value: boolean): void
(event: 'submit', year: number): void (event: 'submit', payload: { year: number; month: number | null }): void
}>() }>()
const drawerOpen = computed({ const drawerOpen = computed({
@@ -47,13 +61,31 @@ const drawerOpen = computed({
const currentYear = new Date().getFullYear() const currentYear = new Date().getFullYear()
const years = Array.from({ length: 6 }, (_, i) => currentYear - i) const years = Array.from({ length: 6 }, (_, i) => currentYear - i)
const months = [
{ value: 1, label: 'Janvier' },
{ value: 2, label: 'Février' },
{ value: 3, label: 'Mars' },
{ value: 4, label: 'Avril' },
{ value: 5, label: 'Mai' },
{ value: 6, label: 'Juin' },
{ value: 7, label: 'Juillet' },
{ value: 8, label: 'Août' },
{ value: 9, label: 'Septembre' },
{ value: 10, label: 'Octobre' },
{ value: 11, label: 'Novembre' },
{ value: 12, label: 'Décembre' }
]
const selectedYear = ref(currentYear) const selectedYear = ref(currentYear)
const selectedMonth = ref<number | ''>('')
const baseInputClass = 'mt-2 w-full rounded-md border px-3 py-2 text-md text-neutral-900' const baseInputClass = 'mt-2 w-full rounded-md border px-3 py-2 text-md text-neutral-900'
const selectFieldClass = computed(() => `${baseInputClass} border-neutral-300`) const selectFieldClass = computed(() => `${baseInputClass} border-neutral-300`)
const handleSubmit = () => { const handleSubmit = () => {
emit('submit', selectedYear.value) emit('submit', {
year: selectedYear.value,
month: selectedMonth.value === '' ? null : selectedMonth.value
})
} }
watch( watch(
@@ -61,6 +93,7 @@ watch(
(isOpen) => { (isOpen) => {
if (!isOpen) { if (!isOpen) {
selectedYear.value = currentYear selectedYear.value = currentYear
selectedMonth.value = ''
} }
} }
) )

View File

@@ -42,7 +42,9 @@
<span class="font-normal text-neutral-600">({{ contractLabel(employee) }})</span> <span class="font-normal text-neutral-600">({{ contractLabel(employee) }})</span>
</p> </p>
<p class="text-neutral-500 truncate inline-flex items-center gap-2"> <p class="text-neutral-500 truncate inline-flex items-center gap-2">
<span>{{ employee.site?.name ?? 'Sans site' }}</span> <span>
{{ employee.site?.name ?? 'Sans site' }}<span v-if="employee.currentContractNature"> {{ contractNatureLabel(employee.currentContractNature) }}</span>
</span>
<span <span
v-if="isAdmin && (rows[employee.id]?.isSiteValid ?? false)" v-if="isAdmin && (rows[employee.id]?.isSiteValid ?? false)"
class="rounded-full bg-green-500 flex justify-center item-center text-white p-0.5" class="rounded-full bg-green-500 flex justify-center item-center text-white p-0.5"
@@ -170,6 +172,7 @@
import type { Employee } from '~/services/dto/employee' import type { Employee } from '~/services/dto/employee'
import TimeSelect from '~/components/ui/TimeSelect.vue' import TimeSelect from '~/components/ui/TimeSelect.vue'
import type { DriverHourRow } from '~/services/dto/work-hour' import type { DriverHourRow } from '~/services/dto/work-hour'
import { contractNatureLabel } from '~/utils/contract'
const rows = defineModel<Record<number, DriverHourRow>>('rows', { required: true }) const rows = defineModel<Record<number, DriverHourRow>>('rows', { required: true })
const bulkValidationInput = ref<HTMLInputElement | null>(null) const bulkValidationInput = ref<HTMLInputElement | null>(null)

View File

@@ -33,7 +33,9 @@
{{ row.firstName }} {{ row.lastName }} {{ row.firstName }} {{ row.lastName }}
<span class="font-normal text-neutral-600">({{ row.contractName ?? '-' }})</span> <span class="font-normal text-neutral-600">({{ row.contractName ?? '-' }})</span>
</p> </p>
<p class="text-[11px] text-neutral-500 truncate">{{ row.siteName ?? 'Sans site' }}</p> <p class="text-[11px] text-neutral-500 truncate">
{{ row.siteName ?? 'Sans site' }}<span v-if="row.contractNature"> {{ contractNatureLabel(row.contractNature) }}</span>
</p>
</div> </div>
<div <div
@@ -89,6 +91,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { WeeklyWorkHourSummary } from '~/services/dto/work-hour' import type { WeeklyWorkHourSummary } from '~/services/dto/work-hour'
import { contractNatureLabel } from '~/utils/contract'
const getDailyCellStyle = (daily: { const getDailyCellStyle = (daily: {
hasAbsence?: boolean hasAbsence?: boolean

View File

@@ -0,0 +1,113 @@
<template>
<div class="rounded-md border border-neutral-200 bg-neutral-50 p-3 space-y-2">
<div class="flex items-center justify-between">
<p class="text-md font-semibold text-neutral-700">
Jours travaillés <span v-if="!disabled" class="text-red-600">*</span>
</p>
<p class="text-sm" :class="totalIsValid ? 'text-green-700' : 'text-red-600'">
{{ formatTotal(totalMinutes) }} / {{ formatTotal(expectedMinutes) }}
</p>
</div>
<p v-if="!disabled" class="text-xs text-neutral-500">Somme requise = {{ expectedMinutes / 60 }}h (total hebdo du contrat).</p>
<div class="space-y-1">
<div v-for="day in days" :key="day.iso" class="flex items-center gap-3">
<label class="inline-flex items-center gap-2 min-w-[120px]">
<input
:checked="day.active"
type="checkbox"
class="h-4 w-4 rounded border-neutral-300 text-primary-500 focus:ring-primary-500"
:disabled="disabled"
@change="onToggleDay(day.iso, ($event.target as HTMLInputElement).checked)"
/>
<span class="text-md text-neutral-700">{{ day.label }}</span>
</label>
<input
:value="day.time"
type="time"
step="60"
class="rounded-md border border-neutral-300 bg-white px-2 py-1 text-md text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20 disabled:bg-neutral-100 disabled:text-neutral-400"
:disabled="disabled || !day.active"
@input="onChangeTime(day.iso, ($event.target as HTMLInputElement).value)"
/>
</div>
</div>
<p v-if="!totalIsValid" class="text-sm text-red-600">
La somme des heures par jour doit égaler exactement {{ expectedMinutes / 60 }}h.
</p>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(defineProps<{
modelValue: Record<number, number> | null
contractWeeklyHours: number | null
disabled?: boolean
}>(), { disabled: false })
const emit = defineEmits<{
'update:modelValue': [value: Record<number, number>]
}>()
const DAY_LABELS: Record<number, string> = { 1: 'Lundi', 2: 'Mardi', 3: 'Mercredi', 4: 'Jeudi', 5: 'Vendredi' }
const expectedMinutes = computed(() => (props.contractWeeklyHours ?? 0) * 60)
const days = computed(() => {
const raw = props.modelValue ?? {}
return [1, 2, 3, 4, 5].map((iso) => {
const active = Object.prototype.hasOwnProperty.call(raw, iso)
const minutes = Number(raw[iso] ?? 0)
return {
iso,
label: DAY_LABELS[iso],
active,
time: active ? minutesToTime(minutes) : '00:00',
}
})
})
const totalMinutes = computed(() => {
const raw = props.modelValue ?? {}
return Object.values(raw).reduce((sum, n) => sum + (Number(n) || 0), 0)
})
const totalIsValid = computed(() => totalMinutes.value === expectedMinutes.value && expectedMinutes.value > 0)
function minutesToTime(minutes: number): string {
const h = Math.floor(minutes / 60)
const m = minutes % 60
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
}
function timeToMinutes(value: string): number {
const [h, m] = value.split(':').map(Number)
return (h || 0) * 60 + (m || 0)
}
function onToggleDay(iso: number, active: boolean) {
const next = { ...(props.modelValue ?? {}) }
if (active) {
next[iso] = next[iso] ?? 0
} else {
delete next[iso]
}
emit('update:modelValue', next)
}
function onChangeTime(iso: number, value: string) {
const next = { ...(props.modelValue ?? {}) }
const minutes = timeToMinutes(value)
next[iso] = minutes
emit('update:modelValue', next)
}
function formatTotal(min: number): string {
const h = Math.floor(min / 60)
const m = min % 60
return m === 0 ? `${h}h` : `${h}h${String(m).padStart(2, '0')}`
}
defineExpose({ totalIsValid, totalMinutes })
</script>

View File

@@ -43,7 +43,9 @@
<span class="font-normal text-neutral-600">({{ contractLabel(employee) }})</span> <span class="font-normal text-neutral-600">({{ contractLabel(employee) }})</span>
</p> </p>
<p class="text-neutral-500 truncate inline-flex items-center gap-2"> <p class="text-neutral-500 truncate inline-flex items-center gap-2">
<span>{{ employee.site?.name ?? 'Sans site' }}</span> <span>
{{ employee.site?.name ?? 'Sans site' }}<span v-if="employee.currentContractNature"> {{ contractNatureLabel(employee.currentContractNature) }}</span>
</span>
<span <span
v-if="isAdmin && (rows[employee.id]?.isSiteValid ?? false)" v-if="isAdmin && (rows[employee.id]?.isSiteValid ?? false)"
class="rounded-full bg-green-500 flex justify-center item-center text-white p-0.5" class="rounded-full bg-green-500 flex justify-center item-center text-white p-0.5"
@@ -196,6 +198,7 @@
import type {Employee} from '~/services/dto/employee' import type {Employee} from '~/services/dto/employee'
import TimeSelect from '~/components/ui/TimeSelect.vue' import TimeSelect from '~/components/ui/TimeSelect.vue'
import type {HourRow} from './types' import type {HourRow} from './types'
import { contractNatureLabel } from '~/utils/contract'
const rows = defineModel<Record<number, HourRow>>('rows', {required: true}) const rows = defineModel<Record<number, HourRow>>('rows', {required: true})
const bulkValidationInput = ref<HTMLInputElement | null>(null) const bulkValidationInput = ref<HTMLInputElement | null>(null)

View File

@@ -29,7 +29,9 @@
{{ row.firstName }} {{ row.lastName }} {{ row.firstName }} {{ row.lastName }}
<span class="font-normal text-neutral-600">({{ row.contractName ?? '-' }})</span> <span class="font-normal text-neutral-600">({{ row.contractName ?? '-' }})</span>
</p> </p>
<p class="text-[11px] text-neutral-500 truncate">{{ row.siteName ?? 'Sans site' }}</p> <p class="text-[11px] text-neutral-500 truncate">
{{ row.siteName ?? 'Sans site' }}<span v-if="row.contractNature"> {{ contractNatureLabel(row.contractNature) }}</span>
</p>
</div> </div>
<div <div
@@ -81,6 +83,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { WeeklyWorkHourSummary } from '~/services/dto/work-hour' import type { WeeklyWorkHourSummary } from '~/services/dto/work-hour'
import { CONTRACT_TYPES, type ContractType } from '~/services/dto/contract' import { CONTRACT_TYPES, type ContractType } from '~/services/dto/contract'
import { contractNatureLabel } from '~/utils/contract'
const isInterimContract = (contractType?: ContractType | null) => { const isInterimContract = (contractType?: ContractType | null) => {
return contractType === CONTRACT_TYPES.INTERIM return contractType === CONTRACT_TYPES.INTERIM

View File

@@ -108,6 +108,7 @@
@delete="deleteAbsenceFromDrawer" @delete="deleteAbsenceFromDrawer"
@cancel="closeAbsenceDrawer" @cancel="closeAbsenceDrawer"
/> />
</div> </div>
</template> </template>

View File

@@ -17,7 +17,7 @@
<h1 class="text-[32px] font-bold">{{ employee.firstName }} {{ employee.lastName }}</h1> <h1 class="text-[32px] font-bold">{{ employee.firstName }} {{ employee.lastName }}</h1>
<button <button
class="inline-flex items-center justify-center rounded-md p-1 transition-colors duration-150 focus:outline-none focus-visible:ring-2 bg-primary-500 hover:bg-secondary-500 active:bg-primary-500 text-white cursor-pointer" class="inline-flex items-center justify-center rounded-md p-1 transition-colors duration-150 focus:outline-none focus-visible:ring-2 bg-primary-500 hover:bg-secondary-500 active:bg-primary-500 text-white cursor-pointer"
title="Export heures annuelles" title="Export heures"
@click="isYearlyHoursDrawerOpen = true" @click="isYearlyHoursDrawerOpen = true"
> >
<Icon name="mdi:printer" size="24" /> <Icon name="mdi:printer" size="24" />
@@ -321,9 +321,10 @@ const {
submitDeleteObservation submitDeleteObservation
} = useEmployeeDetailPage() } = useEmployeeDetailPage()
const handleYearlyHoursPrint = async (year: number) => { const handleYearlyHoursPrint = async (payload: { year: number; month: number | null }) => {
if (!employee.value) return if (!employee.value) return
await printPdf(`/yearly-hours/print?employeeId=${employee.value.id}&year=${year}`) const monthParam = null !== payload.month ? `&month=${payload.month}` : ''
await printPdf(`/yearly-hours/print?employeeId=${employee.value.id}&year=${payload.year}${monthParam}`)
isYearlyHoursDrawerOpen.value = false isYearlyHoursDrawerOpen.value = false
} }

View File

@@ -115,6 +115,7 @@
@delete="deleteAbsenceFromDrawer" @delete="deleteAbsenceFromDrawer"
@cancel="closeAbsenceDrawer" @cancel="closeAbsenceDrawer"
/> />
</div> </div>
</template> </template>

View File

@@ -87,6 +87,7 @@ export type WeeklyWorkHourRowSummary = {
weeklyDinnerCount?: number weeklyDinnerCount?: number
weeklyOvernightCount?: number weeklyOvernightCount?: number
hasContractForWeek?: boolean hasContractForWeek?: boolean
contractNature?: 'CDI' | 'CDD' | 'INTERIM' | null
} }
export type WeeklyWorkHourSummary = { export type WeeklyWorkHourSummary = {

View File

@@ -34,5 +34,6 @@ final class WeeklySummaryRow
public int $weeklyDinnerCount = 0, public int $weeklyDinnerCount = 0,
public int $weeklyOvernightCount = 0, public int $weeklyOvernightCount = 0,
public bool $hasContractForWeek = true, public bool $hasContractForWeek = true,
public ?string $contractNature = null,
) {} ) {}
} }

View File

@@ -9,6 +9,8 @@ use ApiPlatform\State\ProviderInterface;
use App\Dto\WorkHours\WorkMetrics; use App\Dto\WorkHours\WorkMetrics;
use App\Entity\Employee; use App\Entity\Employee;
use App\Entity\WorkHour; use App\Entity\WorkHour;
use App\Enum\ContractNature;
use App\Enum\ContractType;
use App\Enum\TrackingMode; use App\Enum\TrackingMode;
use App\Repository\AbsenceRepository; use App\Repository\AbsenceRepository;
use App\Repository\EmployeeRepository; use App\Repository\EmployeeRepository;
@@ -62,8 +64,22 @@ class EmployeeYearlyHoursPrintProvider implements ProviderInterface
} }
$year = (int) $yearRaw; $year = (int) $yearRaw;
$from = new DateTimeImmutable("{$year}-01-01"); $monthRaw = (string) $request->query->get('month', '');
$to = new DateTimeImmutable("{$year}-12-31"); $month = null;
if ('' !== $monthRaw) {
if (!preg_match('/^(?:0?[1-9]|1[0-2])$/', $monthRaw)) {
throw new UnprocessableEntityHttpException('month must be between 1 and 12.');
}
$month = (int) $monthRaw;
}
if (null !== $month) {
$from = new DateTimeImmutable(sprintf('%d-%02d-01', $year, $month));
$to = $from->modify('last day of this month');
} else {
$from = new DateTimeImmutable("{$year}-01-01");
$to = new DateTimeImmutable("{$year}-12-31");
}
$days = $this->buildDays($from, $to); $days = $this->buildDays($from, $to);
$workHours = $this->workHourRepository->findByDateRangeAndEmployees($from, $to, [$employee]); $workHours = $this->workHourRepository->findByDateRangeAndEmployees($from, $to, [$employee]);
@@ -83,28 +99,39 @@ class EmployeeYearlyHoursPrintProvider implements ProviderInterface
$absenceData, $absenceData,
); );
$employeeName = trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? '')); $employeeName = trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? ''));
$contractLabel = $this->buildContractLabel($employee);
$options = new Options(); $options = new Options();
$options->set('isRemoteEnabled', true); $options->set('isRemoteEnabled', true);
$dompdf = new Dompdf($options); $dompdf = new Dompdf($options);
$html = $this->twig->render('employee-yearly-hours/print.html.twig', [ $html = $this->twig->render('employee-yearly-hours/print.html.twig', [
'employeeName' => $employeeName, 'employeeName' => $employeeName,
'year' => $year, 'contractLabel' => $contractLabel,
'segments' => $segments, 'year' => $year,
'month' => $month,
'segments' => $segments,
]); ]);
$dompdf->loadHtml($html); $dompdf->loadHtml($html);
$dompdf->setPaper('A4', 'portrait'); $dompdf->setPaper('A4', 'portrait');
$dompdf->render(); $dompdf->render();
$filename = sprintf( $filename = null !== $month
'%s_%s_%d.pdf', ? sprintf(
$this->sanitizeFilename($employee->getLastName() ?? ''), '%s_%s_%d-%02d.pdf',
$this->sanitizeFilename($employee->getFirstName() ?? ''), $this->sanitizeFilename($employee->getLastName() ?? ''),
$year, $this->sanitizeFilename($employee->getFirstName() ?? ''),
); $year,
$month,
)
: sprintf(
'%s_%s_%d.pdf',
$this->sanitizeFilename($employee->getLastName() ?? ''),
$this->sanitizeFilename($employee->getFirstName() ?? ''),
$year,
);
return new Response($dompdf->output(), Response::HTTP_OK, [ return new Response($dompdf->output(), Response::HTTP_OK, [
'Content-Type' => 'application/pdf', 'Content-Type' => 'application/pdf',
@@ -112,6 +139,36 @@ class EmployeeYearlyHoursPrintProvider implements ProviderInterface
]); ]);
} }
private function buildContractLabel(Employee $employee): ?string
{
$contract = $employee->getContract();
if (null === $contract) {
return null;
}
$natureRaw = $employee->getCurrentContractNature();
$nature = ContractNature::tryFrom($natureRaw) ?? ContractNature::CDI;
$natureLabel = match ($nature) {
ContractNature::CDI => 'CDI',
ContractNature::CDD => 'CDD',
ContractNature::INTERIM => 'Intérim',
};
$contractType = $contract->getType();
if (ContractType::FORFAIT === $contractType) {
return $natureLabel.' Forfait';
}
$weeklyHours = $contract->getWeeklyHours();
if (null !== $weeklyHours && $weeklyHours > 0) {
return sprintf('%s %d heures', $natureLabel, $weeklyHours);
}
$name = $contract->getName();
return null !== $name && '' !== $name ? $natureLabel.' '.$name : $natureLabel;
}
/** /**
* @return list<string> * @return list<string>
*/ */
@@ -211,13 +268,44 @@ class EmployeeYearlyHoursPrintProvider implements ProviderInterface
$currentRows = []; $currentRows = [];
$currentName = null; $currentName = null;
// Crop the output window to [first data day, today] to avoid padding the
// export with empty rows (notably weekends before the first saisie or after today).
$firstDataDate = null;
foreach ($days as $date) { foreach ($days as $date) {
$contract = $contractsByDate[$date] ?? null; $hasRow = null !== ($workHoursByDate[$date] ?? null)
$isDriver = $driverByDate[$date] ?? false; || ($absenceData['hasDayAbsence'][$date] ?? false);
$wh = $workHoursByDate[$date] ?? null; if ($hasRow) {
$hasData = null !== $wh || ($absenceData['hasDayAbsence'][$date] ?? false); $firstDataDate = $date;
if (!$hasData) { break;
}
}
if (null === $firstDataDate) {
return [];
}
$todayYmd = new DateTimeImmutable('today')->format('Y-m-d');
foreach ($days as $date) {
if ($date < $firstDataDate || $date > $todayYmd) {
continue;
}
$contract = $contractsByDate[$date] ?? null;
$isDriver = $driverByDate[$date] ?? false;
$wh = $workHoursByDate[$date] ?? null;
$hasData = null !== $wh || ($absenceData['hasDayAbsence'][$date] ?? false);
$isoDay = (int) new DateTimeImmutable($date)->format('N');
$isWeekend = $isoDay >= 6;
// Keep weekend rows even when empty so the reader can distinguish
// worked vs non-worked Saturdays/Sundays at a glance.
if (!$hasData && !$isWeekend) {
continue;
}
if (!$hasData && null === $contract) {
continue; continue;
} }
@@ -244,6 +332,7 @@ class EmployeeYearlyHoursPrintProvider implements ProviderInterface
$row = [ $row = [
'date' => new DateTimeImmutable($date)->format('d/m/Y'), 'date' => new DateTimeImmutable($date)->format('d/m/Y'),
'absenceLabel' => $absenceLabel, 'absenceLabel' => $absenceLabel,
'isWeekend' => $isWeekend,
]; ];
if ('presence' === $mode) { if ('presence' === $mode) {

View File

@@ -18,12 +18,14 @@ use App\Repository\MileageAllowanceRepository;
use App\Repository\ObservationRepository; use App\Repository\ObservationRepository;
use App\Repository\WorkHourRepository; use App\Repository\WorkHourRepository;
use App\Service\Contracts\EmployeeContractResolver; use App\Service\Contracts\EmployeeContractResolver;
use App\Service\PublicHolidayServiceInterface;
use DateInterval; use DateInterval;
use DateTimeImmutable; use DateTimeImmutable;
use Dompdf\Dompdf; use Dompdf\Dompdf;
use Dompdf\Options; use Dompdf\Options;
use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Throwable;
use Twig\Environment; use Twig\Environment;
class SalaryRecapPrintProvider implements ProviderInterface class SalaryRecapPrintProvider implements ProviderInterface
@@ -39,6 +41,7 @@ class SalaryRecapPrintProvider implements ProviderInterface
private MileageAllowanceRepository $mileageAllowanceRepository, private MileageAllowanceRepository $mileageAllowanceRepository,
private ObservationRepository $observationRepository, private ObservationRepository $observationRepository,
private EmployeeContractResolver $contractResolver, private EmployeeContractResolver $contractResolver,
private PublicHolidayServiceInterface $publicHolidayService,
) {} ) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
@@ -71,6 +74,7 @@ class SalaryRecapPrintProvider implements ProviderInterface
$days = $this->buildDays($from, $to); $days = $this->buildDays($from, $to);
$contractMap = $this->contractResolver->resolveForEmployeesAndDays($employees, $days); $contractMap = $this->contractResolver->resolveForEmployeesAndDays($employees, $days);
$driverMap = $this->contractResolver->resolveIsDriverForEmployeesAndDays($employees, $days); $driverMap = $this->contractResolver->resolveIsDriverForEmployeesAndDays($employees, $days);
$holidayMap = $this->buildHolidayMap($from, $to);
$workHourMap = $this->buildWorkHourMap($workHours); $workHourMap = $this->buildWorkHourMap($workHours);
$absenceMap = $this->buildAbsenceMap($absences); $absenceMap = $this->buildAbsenceMap($absences);
@@ -79,7 +83,7 @@ class SalaryRecapPrintProvider implements ProviderInterface
$mileageMap = $this->buildMileageMap($mileages); $mileageMap = $this->buildMileageMap($mileages);
$observationMap = $this->buildObservationMap($observations); $observationMap = $this->buildObservationMap($observations);
$siteGroups = $this->aggregateBySite($employees, $days, $contractMap, $driverMap, $workHourMap, $absenceMap, $rttPaymentMap, $bonusMap, $mileageMap, $observationMap); $siteGroups = $this->aggregateBySite($employees, $days, $contractMap, $driverMap, $workHourMap, $absenceMap, $rttPaymentMap, $bonusMap, $mileageMap, $observationMap, $holidayMap);
$options = new Options(); $options = new Options();
$options->set('isRemoteEnabled', true); $options->set('isRemoteEnabled', true);
@@ -208,6 +212,29 @@ class SalaryRecapPrintProvider implements ProviderInterface
return $map; return $map;
} }
/**
* @return array<string, string> Y-m-d → label
*/
private function buildHolidayMap(DateTimeImmutable $from, DateTimeImmutable $to): array
{
$map = [];
$startYear = (int) $from->format('Y');
$endYear = (int) $to->format('Y');
try {
for ($year = $startYear; $year <= $endYear; ++$year) {
$holidays = $this->publicHolidayService->getHolidaysDayByYears('metropole', (string) $year);
foreach ($holidays as $date => $label) {
$map[(string) $date] = (string) $label;
}
}
} catch (Throwable) {
return [];
}
return $map;
}
/** /**
* @return array<int, string> * @return array<int, string>
*/ */
@@ -236,6 +263,7 @@ class SalaryRecapPrintProvider implements ProviderInterface
array $bonusMap, array $bonusMap,
array $mileageMap, array $mileageMap,
array $observationMap, array $observationMap,
array $holidayMap,
): array { ): array {
$siteGroups = []; $siteGroups = [];
@@ -257,6 +285,7 @@ class SalaryRecapPrintProvider implements ProviderInterface
$bonusMap[$employeeId] ?? 0.0, $bonusMap[$employeeId] ?? 0.0,
$mileageMap[$employeeId] ?? 0.0, $mileageMap[$employeeId] ?? 0.0,
$observationMap[$employeeId] ?? '', $observationMap[$employeeId] ?? '',
$holidayMap,
); );
if (!isset($siteGroups[$siteId])) { if (!isset($siteGroups[$siteId])) {
@@ -285,18 +314,20 @@ class SalaryRecapPrintProvider implements ProviderInterface
float $bonusAmount, float $bonusAmount,
float $mileageKm, float $mileageKm,
string $observation, string $observation,
array $holidayMap,
): array { ): array {
$contractName = null; $contractName = null;
$presenceDays = 0.0; $presenceDays = 0.0;
$nightMinutesTotal = 0; $nightMinutesTotal = 0;
$nightBasketCount = 0; $nightBasketCount = 0;
$sundayMinutesTotal = 0; $sundayMinutesTotal = 0;
$isDriverAnyDay = false; $holidayMinutesTotal = 0;
$driverBreakfast = 0; $isDriverAnyDay = false;
$driverMeals = 0; $driverBreakfast = 0;
$driverOvernight = 0; $driverMeals = 0;
$driverSaturdays = 0; $driverOvernight = 0;
$isForfait = false; $driverSaturdays = 0;
$isForfait = false;
foreach ($days as $date) { foreach ($days as $date) {
$contract = $contractsByDate[$date] ?? null; $contract = $contractsByDate[$date] ?? null;
@@ -318,10 +349,13 @@ class SalaryRecapPrintProvider implements ProviderInterface
$dayOfWeek = (int) new DateTimeImmutable($date)->format('N'); $dayOfWeek = (int) new DateTimeImmutable($date)->format('N');
$isHoliday = isset($holidayMap[$date]);
if ($isDriver) { if ($isDriver) {
$nightMinutesTotal += $wh->getNightHoursMinutes() ?? 0; $nightMinutesTotal += $wh->getNightHoursMinutes() ?? 0;
$dayMin = $wh->getDayHoursMinutes() ?? 0; $dayMin = $wh->getDayHoursMinutes() ?? 0;
$nightMin = $wh->getNightHoursMinutes() ?? 0; $nightMin = $wh->getNightHoursMinutes() ?? 0;
$workshopMin = $wh->getWorkshopHoursMinutes() ?? 0;
if (($nightMin > $dayMin && $nightMin > 0) || $nightMin >= 240) { if (($nightMin > $dayMin && $nightMin > 0) || $nightMin >= 240) {
++$nightBasketCount; ++$nightBasketCount;
} }
@@ -336,12 +370,16 @@ class SalaryRecapPrintProvider implements ProviderInterface
++$driverOvernight; ++$driverOvernight;
} }
if (6 === $dayOfWeek && ($dayMin > 0 || $nightMin > 0 || ($wh->getWorkshopHoursMinutes() ?? 0) > 0)) { if (6 === $dayOfWeek && ($dayMin > 0 || $nightMin > 0 || $workshopMin > 0)) {
++$driverSaturdays; ++$driverSaturdays;
} }
if (7 === $dayOfWeek) { if (7 === $dayOfWeek) {
$sundayMinutesTotal += $dayMin + $nightMin + ($wh->getWorkshopHoursMinutes() ?? 0); $sundayMinutesTotal += $dayMin + $nightMin + $workshopMin;
}
if ($isHoliday) {
$holidayMinutesTotal += $dayMin + $nightMin + $workshopMin;
} }
} else { } else {
$metrics = $this->computeNightMinutes($wh); $metrics = $this->computeNightMinutes($wh);
@@ -359,6 +397,10 @@ class SalaryRecapPrintProvider implements ProviderInterface
$sundayMinutesTotal += $this->computeOverflowAfterMidnight($wh); $sundayMinutesTotal += $this->computeOverflowAfterMidnight($wh);
} }
if ($isHoliday) {
$holidayMinutesTotal += $metrics['dayMinutes'] + $metrics['nightMinutes'];
}
if ($isForfait) { if ($isForfait) {
if ($wh->getIsPresentMorning()) { if ($wh->getIsPresentMorning()) {
$presenceDays += 0.5; $presenceDays += 0.5;
@@ -373,9 +415,10 @@ class SalaryRecapPrintProvider implements ProviderInterface
$conges = $this->countAbsencesByCode($absences, ['C']); $conges = $this->countAbsencesByCode($absences, ['C']);
$maladie = $this->countAbsencesByCode($absences, ['M', 'AT']); $maladie = $this->countAbsencesByCode($absences, ['M', 'AT']);
$nightHours = round($nightMinutesTotal / 60, 2); $nightHours = round($nightMinutesTotal / 60, 2);
$paidHours = round($rttPaidMinutes / 60, 2); $paidHours = round($rttPaidMinutes / 60, 2);
$sundayHours = round($sundayMinutesTotal / 60, 2); $sundayHours = round($sundayMinutesTotal / 60, 2);
$holidayHours = round($holidayMinutesTotal / 60, 2);
return [ return [
'lastName' => mb_strimwidth($employee->getLastName() ?? '', 0, 15, '...'), 'lastName' => mb_strimwidth($employee->getLastName() ?? '', 0, 15, '...'),
@@ -387,6 +430,7 @@ class SalaryRecapPrintProvider implements ProviderInterface
'nightBasketCount' => $nightBasketCount, 'nightBasketCount' => $nightBasketCount,
'paidHours' => $paidHours, 'paidHours' => $paidHours,
'sundayHours' => $sundayHours, 'sundayHours' => $sundayHours,
'holidayHours' => $holidayHours,
'bonusAmount' => $bonusAmount, 'bonusAmount' => $bonusAmount,
'congesCount' => $conges['count'], 'congesCount' => $conges['count'],
'congesDates' => $conges['dates'], 'congesDates' => $conges['dates'],

View File

@@ -369,6 +369,7 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
weeklyDinnerCount: $weeklyDinnerCount, weeklyDinnerCount: $weeklyDinnerCount,
weeklyOvernightCount: $weeklyOvernightCount, weeklyOvernightCount: $weeklyOvernightCount,
hasContractForWeek: $hasContractForWeek, hasContractForWeek: $hasContractForWeek,
contractNature: $weekAnchorContractNature->value,
); );
} }

View File

@@ -14,10 +14,24 @@
font-size: 9px; font-size: 9px;
} }
.title-bar {
position: relative;
margin: 0 0 4mm 0;
}
h1 { h1 {
text-align: center; text-align: center;
font-size: 16px; font-size: 16px;
margin: 0 0 4mm 0; margin: 0;
}
.export-date {
position: absolute;
top: 0;
right: 0;
font-size: 9px;
color: #333;
padding-top: 4px;
} }
h2 { h2 {
@@ -54,11 +68,70 @@
td.time { text-align: center; } td.time { text-align: center; }
td.presence { text-align: center; } td.presence { text-align: center; }
td.total { text-align: center; font-weight: bold; } td.total { text-align: center; font-weight: bold; }
tr.weekend td { background: #f3f3f3; color: #555; }
tr.weekend td.date { color: #333; }
.signature-footer {
page-break-inside: avoid;
margin-top: 6mm;
}
.signature-intro {
text-align: center;
font-weight: 700;
margin-bottom: 6mm;
font-size: 11px;
}
.signature-blocks {
display: table;
width: 100%;
table-layout: fixed;
border-collapse: separate;
border-spacing: 4mm 0;
}
.signature-block {
display: table-cell;
border: 1px solid #0a0a0a;
padding: 3mm;
vertical-align: top;
width: 33.33%;
}
.signature-block .title {
text-align: center;
font-weight: 700;
font-size: 11px;
margin-bottom: 7mm;
text-decoration: underline;
}
.signature-block .line {
margin-bottom: 2mm;
font-size: 10px;
}
.signature-block .signature-line {
margin-top: 6mm;
margin-bottom: 18mm;
font-size: 10px;
}
</style> </style>
</head> </head>
<body> <body>
<h1>{{ employeeName }} - {{ year }}</h1> {% set months = {
1:'Janvier', 2:'Février', 3:'Mars', 4:'Avril', 5:'Mai', 6:'Juin',
7:'Juillet', 8:'Août', 9:'Septembre', 10:'Octobre', 11:'Novembre', 12:'Décembre'
} %}
<div class="title-bar">
<h1>
{{ employeeName }}{% if contractLabel %} - {{ contractLabel }}{% endif %}<br>
{% if month %}{{ months[month] }} {{ year }}{% else %}{{ year }}{% endif %}
</h1>
<div class="export-date">Exporté le {{ "now"|date('d/m/Y') }} à {{ "now"|date('H:i:s') }}</div>
</div>
{% for segment in segments %} {% for segment in segments %}
{% if segments|length > 1 %} {% if segments|length > 1 %}
@@ -78,7 +151,7 @@
</thead> </thead>
<tbody> <tbody>
{% for row in segment.rows %} {% for row in segment.rows %}
<tr> <tr class="{{ row.isWeekend ? 'weekend' : '' }}">
<td class="date">{{ row.date }}</td> <td class="date">{{ row.date }}</td>
<td class="absence">{{ row.absenceLabel ?? '' }}</td> <td class="absence">{{ row.absenceLabel ?? '' }}</td>
<td class="presence">{{ row.presentMorning ? 'X' : '' }}</td> <td class="presence">{{ row.presentMorning ? 'X' : '' }}</td>
@@ -102,7 +175,7 @@
</thead> </thead>
<tbody> <tbody>
{% for row in segment.rows %} {% for row in segment.rows %}
<tr> <tr class="{{ row.isWeekend ? 'weekend' : '' }}">
<td class="date">{{ row.date }}</td> <td class="date">{{ row.date }}</td>
<td class="absence">{{ row.absenceLabel ?? '' }}</td> <td class="absence">{{ row.absenceLabel ?? '' }}</td>
<td class="time">{{ row.dayHours }}</td> <td class="time">{{ row.dayHours }}</td>
@@ -130,7 +203,7 @@
</thead> </thead>
<tbody> <tbody>
{% for row in segment.rows %} {% for row in segment.rows %}
<tr> <tr class="{{ row.isWeekend ? 'weekend' : '' }}">
<td class="date">{{ row.date }}</td> <td class="date">{{ row.date }}</td>
<td class="absence">{{ row.absenceLabel ?? '' }}</td> <td class="absence">{{ row.absenceLabel ?? '' }}</td>
<td class="time">{{ row.morningFrom }}</td> <td class="time">{{ row.morningFrom }}</td>
@@ -147,5 +220,36 @@
{% endif %} {% endif %}
{% endfor %} {% endfor %}
<div class="signature-footer">
<div class="signature-intro">
Nom + Prénom<br>
Signature avec mention « bon pour accord »
</div>
<div class="signature-blocks">
<div class="signature-block">
<p class="title">Direction</p>
<p class="line">Nom : ...............</p>
<p class="line">Prénom : ...............</p>
<p class="line">Mention : ........................................</p>
<p class="signature-line">Signature :</p>
</div>
<div class="signature-block">
<p class="title">Responsable usine</p>
<p class="line">Nom : ...............</p>
<p class="line">Prénom : ...............</p>
<p class="line">Mention : ........................................</p>
<p class="signature-line">Signature :</p>
</div>
<div class="signature-block">
<p class="title">Salarié</p>
<p class="line">Nom : ...............</p>
<p class="line">Prénom : ...............</p>
<p class="line">Mention : ........................................</p>
<p class="signature-line">Signature :</p>
</div>
</div>
</div>
</body> </body>
</html> </html>

View File

@@ -28,13 +28,22 @@
.date-box { .date-box {
position: absolute; position: absolute;
top: 0; top: 0;
right: 0; left: 0;
border: 2px solid #000; border: 2px solid #000;
padding: 4px 12px; padding: 4px 12px;
font-size: 14px; font-size: 14px;
font-weight: 700; font-weight: 700;
} }
.export-date {
position: absolute;
top: 0;
right: 0;
font-size: 10px;
color: #333;
padding-top: 6px;
}
table.recap { table.recap {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
@@ -77,8 +86,9 @@
<body> <body>
<div class="title-bar"> <div class="title-bar">
<h1>RECAPITULATIF CONGES & RTT</h1>
<div class="date-box">{{ today|date('d/m/Y') }}</div> <div class="date-box">{{ today|date('d/m/Y') }}</div>
<h1>RECAPITULATIF CONGES & RTT</h1>
<div class="export-date">Exporté le {{ "now"|date('d/m/Y') }} à {{ "now"|date('H:i:s') }}</div>
</div> </div>
<table class="recap"> <table class="recap">

View File

@@ -11,7 +11,7 @@
margin: 0; margin: 0;
padding: 2mm; padding: 2mm;
font-family: Helvetica, sans-serif; font-family: Helvetica, sans-serif;
font-size: 10px; font-size: 9px;
} }
.title-bar { .title-bar {
@@ -28,7 +28,7 @@
.month-box { .month-box {
position: absolute; position: absolute;
top: 0; top: 0;
right: 0; left: 0;
border: 2px solid #000; border: 2px solid #000;
padding: 4px 12px; padding: 4px 12px;
font-size: 14px; font-size: 14px;
@@ -36,16 +36,25 @@
text-transform: uppercase; text-transform: uppercase;
} }
.export-date {
position: absolute;
top: 0;
right: 0;
font-size: 10px;
color: #333;
padding-top: 6px;
}
table.recap { table.recap {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
table-layout: auto; table-layout: auto;
border: 4px solid #0a0a0a; border: 2px solid #0a0a0a;
} }
th, td { th, td {
border: 2px solid #0a0a0a; border: 1px solid #0a0a0a;
padding: 3px 3px; padding: 2px 2px;
vertical-align: middle; vertical-align: middle;
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
@@ -60,7 +69,7 @@
thead th { thead th {
text-align: center; text-align: center;
font-weight: 700; font-weight: 700;
font-size: 10px; font-size: 9px;
white-space: normal; white-space: normal;
} }
@@ -74,16 +83,16 @@
text-align: left; text-align: left;
white-space: normal; white-space: normal;
word-break: break-word; word-break: break-word;
font-size: 10px; font-size: 9px;
} }
td.obs { td.obs {
text-align: left; text-align: left;
white-space: normal; white-space: normal;
word-break: break-word; word-break: break-word;
font-size: 9px; font-size: 8px;
} }
tbody td { font-size: 10px; } tbody td { font-size: 9px; }
</style> </style>
</head> </head>
<body> <body>
@@ -94,43 +103,45 @@
} %} } %}
<div class="title-bar"> <div class="title-bar">
<h1>RECAPITULATIF SALAIRE DU {{ from|date('d/m/Y') }} au {{ to|date('d/m/Y') }}</h1>
<div class="month-box">{{ months[from|date('n')|number_format] }} {{ from|date('Y') }}</div> <div class="month-box">{{ months[from|date('n')|number_format] }} {{ from|date('Y') }}</div>
<h1>RECAPITULATIF SALAIRE DU {{ from|date('d/m/Y') }} au {{ to|date('d/m/Y') }}</h1>
<div class="export-date">Exporté le {{ "now"|date('d/m/Y') }} à {{ "now"|date('H:i:s') }}</div>
</div> </div>
<table class="recap"> <table class="recap">
<thead> <thead>
<tr> <tr>
<th rowspan="2" style="width: 24mm; text-align: left;">Nom</th> <th rowspan="2" style="width: 20mm; text-align: left;">Nom</th>
<th rowspan="2" style="width: 12mm;">Base</th> <th rowspan="2" style="width: 10mm;">Base</th>
<th rowspan="2" style="width: 12mm;">Jour de<br>présence<br>Cadre</th> <th rowspan="2" style="width: 10mm;">Jour de<br>présence<br>Cadre</th>
<th rowspan="2" style="width: 9mm;">Frais<br>Kms</th> <th rowspan="2" style="width: 8mm;">Frais<br>Kms</th>
<th rowspan="2" style="width: 9mm;">Heures<br>de<br>nuit</th> <th rowspan="2" style="width: 8mm;">Heures<br>de<br>nuit</th>
<th rowspan="2" style="width: 9mm;">Panier<br>de<br>nuit</th> <th rowspan="2" style="width: 8mm;">Panier<br>de<br>nuit</th>
<th rowspan="2" style="width: 12mm;">Heures<br>payés</th> <th rowspan="2" style="width: 10mm;">Heures<br>payés</th>
<th rowspan="2" style="width: 9mm;">Heures<br>dim.</th> <th rowspan="2" style="width: 8mm;">Heures<br>férié</th>
<th rowspan="2" style="width: 9mm;">Prime</th> <th rowspan="2" style="width: 8mm;">Heures<br>dim.</th>
<th rowspan="2" style="width: 8mm;">Prime</th>
<th colspan="2">Congés</th> <th colspan="2">Congés</th>
<th colspan="2">Maladie</th> <th colspan="2">Maladie</th>
<th colspan="4">CHAUFFEUR</th> <th colspan="4">CHAUFFEUR</th>
<th rowspan="2" style="width: 26mm;">Observations</th> <th rowspan="2" style="width: 20mm;">Observations</th>
</tr> </tr>
<tr> <tr>
<th style="width: 10mm;">Nbre</th> <th style="width: 8mm;">Nbre</th>
<th style="width: 26mm;">Date</th> <th style="width: 22mm;">Date</th>
<th style="width: 10mm;">Nbre</th> <th style="width: 8mm;">Nbre</th>
<th style="width: 26mm;">Date</th> <th style="width: 22mm;">Date</th>
<th style="width: 8mm;">PDJ</th> <th style="width: 7mm;">PDJ</th>
<th style="width: 10mm;">REPAS</th> <th style="width: 9mm;">REPAS</th>
<th style="width: 12mm;">NUITEE</th> <th style="width: 10mm;">NUITEE</th>
<th style="width: 12mm;">samedi</th> <th style="width: 10mm;">samedi</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for siteId, group in siteGroups %} {% for siteId, group in siteGroups %}
{% set siteColor = group.color ?? '#B3E5FC' %} {% set siteColor = group.color ?? '#B3E5FC' %}
<tr class="site-header"> <tr class="site-header">
<td style="background: {{ siteColor }}; text-align: left;" colspan="18"> <td style="background: {{ siteColor }}; text-align: left;" colspan="19">
{{ group.name }} {{ group.name }}
</td> </td>
</tr> </tr>
@@ -143,6 +154,7 @@
<td class="num">{{ row.nightHours > 0 ? row.nightHours : '' }}</td> <td class="num">{{ row.nightHours > 0 ? row.nightHours : '' }}</td>
<td class="num">{{ row.nightBasketCount > 0 ? row.nightBasketCount : '' }}</td> <td class="num">{{ row.nightBasketCount > 0 ? row.nightBasketCount : '' }}</td>
<td class="num">{{ row.paidHours > 0 ? row.paidHours : '' }}</td> <td class="num">{{ row.paidHours > 0 ? row.paidHours : '' }}</td>
<td class="num">{{ row.holidayHours > 0 ? row.holidayHours : '' }}</td>
<td class="num">{{ row.sundayHours > 0 ? row.sundayHours : '' }}</td> <td class="num">{{ row.sundayHours > 0 ? row.sundayHours : '' }}</td>
<td class="num">{{ row.bonusAmount > 0 ? row.bonusAmount ~ ' €' : '' }}</td> <td class="num">{{ row.bonusAmount > 0 ? row.bonusAmount ~ ' €' : '' }}</td>
<td class="num">{{ row.congesCount > 0 ? row.congesCount : '' }}</td> <td class="num">{{ row.congesCount > 0 ? row.congesCount : '' }}</td>
@@ -157,7 +169,7 @@
</tr> </tr>
{% else %} {% else %}
<tr> <tr>
<td colspan="18">Aucun employé.</td> <td colspan="19">Aucun employé.</td>
</tr> </tr>
{% endfor %} {% endfor %}
{% endfor %} {% endfor %}

View File

@@ -94,6 +94,87 @@ final class EmployeeContractPeriodValidatorTest extends TestCase
$this->validator->assertNextStartDateCompatible(new DateTimeImmutable('2026-03-10'), $currentPeriod); $this->validator->assertNextStartDateCompatible(new DateTimeImmutable('2026-03-10'), $currentPeriod);
} }
public function testAssertWorkDaysHoursAcceptsNullForStandardContract(): void
{
$this->validator->assertWorkDaysHours($this->buildContract(35), ContractNature::CDI, null);
self::assertTrue(true); // no exception
}
public function testAssertWorkDaysHoursRejectsScheduleOn35hContract(): void
{
$this->expectException(UnprocessableEntityHttpException::class);
$this->validator->assertWorkDaysHours($this->buildContract(35), ContractNature::CDI, [1 => 120]);
}
public function testAssertWorkDaysHoursRejectsScheduleOnForfaitContract(): void
{
$this->expectException(UnprocessableEntityHttpException::class);
$this->validator->assertWorkDaysHours($this->buildContract(null, Contract::TRACKING_PRESENCE), ContractNature::CDI, [1 => 120]);
}
public function testAssertWorkDaysHoursAcceptsNullForInterim(): void
{
$this->validator->assertWorkDaysHours($this->buildContract(4), ContractNature::INTERIM, null);
self::assertTrue(true);
}
public function testAssertWorkDaysHoursRequiresScheduleForCustomContract(): void
{
$this->expectException(UnprocessableEntityHttpException::class);
$this->expectExceptionMessage('workDaysHours is required');
$this->validator->assertWorkDaysHours($this->buildContract(4), ContractNature::CDI, null);
}
public function testAssertWorkDaysHoursRequiresScheduleForCustomContractOnEmptyArray(): void
{
$this->expectException(UnprocessableEntityHttpException::class);
$this->expectExceptionMessage('workDaysHours is required');
$this->validator->assertWorkDaysHours($this->buildContract(4), ContractNature::CDI, []);
}
public function testAssertWorkDaysHoursRejectsIsoOutsideOneToFive(): void
{
$this->expectException(UnprocessableEntityHttpException::class);
$this->expectExceptionMessage('iso weekdays 1-5');
$this->validator->assertWorkDaysHours($this->buildContract(4), ContractNature::CDI, [6 => 120, 7 => 120]);
}
public function testAssertWorkDaysHoursRejectsIsoZero(): void
{
$this->expectException(UnprocessableEntityHttpException::class);
$this->expectExceptionMessage('iso weekdays 1-5');
$this->validator->assertWorkDaysHours($this->buildContract(4), ContractNature::CDI, [0 => 240]);
}
public function testAssertWorkDaysHoursRejectsNegativeMinutes(): void
{
$this->expectException(UnprocessableEntityHttpException::class);
$this->expectExceptionMessage('non-negative integer minutes');
$this->validator->assertWorkDaysHours($this->buildContract(4), ContractNature::CDI, [1 => -120, 4 => 360]);
}
public function testAssertWorkDaysHoursRejectsSumMismatch(): void
{
$this->expectException(UnprocessableEntityHttpException::class);
$this->expectExceptionMessage('total must equal contract weekly hours');
$this->validator->assertWorkDaysHours($this->buildContract(4), ContractNature::CDI, [1 => 60, 4 => 60]);
}
public function testAssertWorkDaysHoursAcceptsValidScheduleFor4hContract(): void
{
$this->validator->assertWorkDaysHours($this->buildContract(4), ContractNature::CDI, [1 => 120, 4 => 120]);
self::assertTrue(true);
}
private function buildContract(?int $weeklyHours, string $trackingMode = Contract::TRACKING_TIME): Contract
{
return new Contract()
->setName('Test')
->setTrackingMode($trackingMode)
->setWeeklyHours($weeklyHours)
;
}
private function buildCurrentPeriod(string $startDate, ?string $endDate): EmployeeContractPeriod private function buildCurrentPeriod(string $startDate, ?string $endDate): EmployeeContractPeriod
{ {
$contract = new Contract() $contract = new Contract()

View File

@@ -0,0 +1,181 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service\WorkHours;
use App\Entity\Contract;
use App\Service\Contracts\EmployeeContractResolver;
use App\Service\PublicHolidayServiceInterface;
use App\Service\WorkHours\DailyReferenceMinutesResolver;
use App\Service\WorkHours\HolidayVirtualHoursResolver;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
use RuntimeException;
/**
* @internal
*/
final class HolidayVirtualHoursResolverTest extends TestCase
{
private HolidayVirtualHoursResolver $resolver;
protected function setUp(): void
{
$holidayService = $this->createStub(PublicHolidayServiceInterface::class);
$holidayService->method('getHolidaysDayByYears')->willReturnCallback(
static fn (string $zone, string $year): array => [
// Mon 14/07/2025 (lundi)
'2025-07-14' => '14 juillet',
// Fri 15/08/2025 (vendredi)
'2025-08-15' => '15 août',
// Sat 11/11/2025 (samedi)
'2025-11-15' => 'Samedi test',
// Thu 25/12/2025
'2025-12-25' => 'Noël',
]
);
$this->resolver = new HolidayVirtualHoursResolver(
new DailyReferenceMinutesResolver(),
$holidayService,
$this->createStub(EmployeeContractResolver::class),
);
}
public function testReturnsZeroWhenContractIsNull(): void
{
self::assertSame(0, $this->resolver->resolveVirtualCredit(null, new DateTimeImmutable('2025-07-14')));
}
public function testReturnsZeroForForfaitPresenceContract(): void
{
$contract = new Contract()
->setName('Forfait')
->setTrackingMode('PRESENCE')
->setWeeklyHours(null)
;
self::assertSame(0, $this->resolver->resolveVirtualCredit($contract, new DateTimeImmutable('2025-07-14')));
}
public function testReturnsZeroWhenDayIsNotHoliday(): void
{
$contract = $this->build35hContract();
self::assertSame(0, $this->resolver->resolveVirtualCredit($contract, new DateTimeImmutable('2025-07-07')));
}
public function testReturnsZeroWhenHolidayFallsOnSaturday(): void
{
$contract = $this->build35hContract();
self::assertSame(0, $this->resolver->resolveVirtualCredit($contract, new DateTimeImmutable('2025-11-15')));
}
public function test35hMondayGetsSevenHours(): void
{
$contract = $this->build35hContract();
self::assertSame(7 * 60, $this->resolver->resolveVirtualCredit($contract, new DateTimeImmutable('2025-07-14')));
}
public function test39hMondayGetsEightHours(): void
{
$contract = $this->build39hContract();
self::assertSame(8 * 60, $this->resolver->resolveVirtualCredit($contract, new DateTimeImmutable('2025-07-14')));
}
public function test39hFridayGetsSevenHours(): void
{
$contract = $this->build39hContract();
self::assertSame(7 * 60, $this->resolver->resolveVirtualCredit($contract, new DateTimeImmutable('2025-08-15')));
}
public function testCustomContractUsesProRataReference(): void
{
$contract = new Contract()
->setName('28h')
->setTrackingMode('TIME')
->setWeeklyHours(28)
;
// 28h / 5 = 5.6h = 336 min
self::assertSame(336, $this->resolver->resolveVirtualCredit($contract, new DateTimeImmutable('2025-07-14')));
}
public function testInterimContractAlsoReceivesCredit(): void
{
$contract = new Contract()
->setName('Interim')
->setTrackingMode('TIME')
->setWeeklyHours(35)
;
self::assertSame(7 * 60, $this->resolver->resolveVirtualCredit($contract, new DateTimeImmutable('2025-07-14')));
}
public function testEffectiveDailyMinutesReturnsActualWhenGreaterThanReference(): void
{
$contract = $this->build39hContract();
// 10h worked on a férié Monday with 39h contract (ref = 8h)
self::assertSame(600, $this->resolver->resolveEffectiveDailyMinutes($contract, new DateTimeImmutable('2025-07-14'), 600));
}
public function testEffectiveDailyMinutesReturnsReferenceWhenActualLower(): void
{
$contract = $this->build39hContract();
// 4h worked on a férié Monday with 39h contract (ref = 8h) → 8h
self::assertSame(8 * 60, $this->resolver->resolveEffectiveDailyMinutes($contract, new DateTimeImmutable('2025-07-14'), 240));
}
public function testEffectiveDailyMinutesDelegatesWhenRuleDoesNotApply(): void
{
$contract = $this->build39hContract();
// Non-holiday day: rule does not apply, return actual
self::assertSame(420, $this->resolver->resolveEffectiveDailyMinutes($contract, new DateTimeImmutable('2025-07-07'), 420));
}
public function testFallsBackGracefullyWhenHolidayServiceFails(): void
{
$failingService = $this->createStub(PublicHolidayServiceInterface::class);
$failingService->method('getHolidaysDayByYears')->willThrowException(new RuntimeException('boom'));
$resolver = new HolidayVirtualHoursResolver(
new DailyReferenceMinutesResolver(),
$failingService,
$this->createStub(EmployeeContractResolver::class),
);
self::assertSame(0, $resolver->resolveVirtualCredit($this->build35hContract(), new DateTimeImmutable('2025-07-14')));
}
public function testScheduledWorkdayGetsCreditOnHoliday(): void
{
// 4h contract, schedule Mon 2h + Thu 2h
$contract = new Contract()->setName('4h')->setTrackingMode('TIME')->setWeeklyHours(4);
// Holiday 2025-07-14 is a Monday → 120 min credit
self::assertSame(120, $this->resolver->resolveVirtualCredit($contract, new DateTimeImmutable('2025-07-14'), false, [1 => 120, 4 => 120]));
}
public function testUnscheduledWorkdayGetsZeroOnHoliday(): void
{
$contract = new Contract()->setName('4h')->setTrackingMode('TIME')->setWeeklyHours(4);
// Holiday 2025-07-14 is a Monday but schedule only Tue+Fri → 0
self::assertSame(0, $this->resolver->resolveVirtualCredit($contract, new DateTimeImmutable('2025-07-14'), false, [2 => 120, 5 => 120]));
}
private function build35hContract(): Contract
{
return new Contract()
->setName('35h')
->setTrackingMode('TIME')
->setWeeklyHours(35)
;
}
private function build39hContract(): Contract
{
return new Contract()
->setName('39h')
->setTrackingMode('TIME')
->setWeeklyHours(39)
;
}
}