Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5cced46254 | ||
| 07b84a2512 | |||
|
|
ca26b7f934 | ||
| 9cf978f0f2 | |||
|
|
ad9e8705ae | ||
| f8ca5e50a0 |
17
README.md
17
README.md
@@ -1,2 +1,19 @@
|
|||||||
# SIRH
|
# SIRH
|
||||||
Application de gestion des absences employée
|
Application de gestion des absences employée
|
||||||
|
|
||||||
|
## Importer un dump de prod en dev
|
||||||
|
Sur adminer fait un export bdd :
|
||||||
|
- Sortie : enregistrer
|
||||||
|
- Format : SQL
|
||||||
|
- Tables : DROP+CREATE, Incrément automatique, Déclencheurs
|
||||||
|
- Données : INSERT
|
||||||
|
|
||||||
|
Supprime la bdd et créer la bdd :
|
||||||
|
```shell
|
||||||
|
docker compose exec -T db psql -U root -d sirh -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
|
||||||
|
```
|
||||||
|
|
||||||
|
Remplie la base avec le dump :
|
||||||
|
```shell
|
||||||
|
docker compose exec -T db psql -U root -d sirh < sirh.sql
|
||||||
|
```
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.9'
|
app.version: '0.1.12'
|
||||||
|
|||||||
@@ -99,10 +99,11 @@
|
|||||||
:disabled="isRowLocked(employee.id) || (!isAdmin && isEveningLockedByAbsence(employee.id))"
|
:disabled="isRowLocked(employee.id) || (!isAdmin && isEveningLockedByAbsence(employee.id))"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="pl-2 self-stretch flex flex-col justify-between py-0.5">
|
<div class="pl-2 min-w-0 self-stretch flex flex-col justify-between py-0.5">
|
||||||
<p
|
<p
|
||||||
class="text-sm text-neutral-700 truncate"
|
class="w-full min-w-0 text-sm text-neutral-700 truncate"
|
||||||
:class="getRowAbsenceLabel(employee.id) ? '' : 'invisible'"
|
:class="getRowAbsenceLabel(employee.id) ? '' : 'invisible'"
|
||||||
|
:title="getRowAbsenceLabel(employee.id) || ''"
|
||||||
>
|
>
|
||||||
{{ getRowAbsenceLabel(employee.id) || '—' }}
|
{{ getRowAbsenceLabel(employee.id) || '—' }}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,15 +1,36 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="root" class="relative w-full">
|
<div ref="root" class="relative w-full">
|
||||||
<button
|
<div
|
||||||
ref="trigger"
|
ref="trigger"
|
||||||
type="button"
|
class="w-full flex items-center rounded-md border border-neutral-300 bg-white px-2 text-sm text-neutral-900 focus-within:border-primary-500"
|
||||||
class="w-full flex justify-between rounded-md border border-neutral-300 bg-white px-3 py-2 text-left text-sm text-neutral-900 focus:outline-none focus:border-primary-500 disabled:cursor-not-allowed disabled:bg-neutral-100 disabled:text-neutral-500"
|
:class="props.disabled ? 'cursor-not-allowed bg-neutral-100 text-neutral-500' : ''"
|
||||||
:disabled="props.disabled"
|
|
||||||
@click="toggleOpen"
|
|
||||||
>
|
>
|
||||||
{{ displayValue }}
|
<input
|
||||||
<Icon name="mdi:chevron-down" class="self-center"/>
|
ref="inputRef"
|
||||||
</button>
|
v-model="inputValue"
|
||||||
|
type="text"
|
||||||
|
inputmode="numeric"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:disabled="props.disabled"
|
||||||
|
class="h-9 w-full bg-transparent px-1 outline-none disabled:cursor-not-allowed"
|
||||||
|
@focus="openMenu"
|
||||||
|
@keydown.down.prevent="openMenuAndFocusFirst"
|
||||||
|
@keydown.enter.prevent="commitInput"
|
||||||
|
@keydown.esc.prevent="closeMenu"
|
||||||
|
@input="onInput($event)"
|
||||||
|
@blur="onInputBlur"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
tabindex="-1"
|
||||||
|
class="inline-flex h-8 w-8 items-center justify-center rounded text-neutral-600 hover:bg-tertiary-500 disabled:cursor-not-allowed"
|
||||||
|
:disabled="props.disabled"
|
||||||
|
@mousedown.prevent
|
||||||
|
@click="toggleOpen"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:chevron-down" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<div
|
<div
|
||||||
@@ -18,15 +39,11 @@
|
|||||||
class="fixed z-[120] overflow-y-auto rounded-md border border-neutral-300 bg-white shadow-sm"
|
class="fixed z-[120] overflow-y-auto rounded-md border border-neutral-300 bg-white shadow-sm"
|
||||||
:style="menuStyle"
|
:style="menuStyle"
|
||||||
>
|
>
|
||||||
<button
|
<button type="button" class="block w-full px-2 py-2 text-left text-sm hover:bg-tertiary-500" @click="selectValue('')">
|
||||||
type="button"
|
|
||||||
class="block w-full px-2 py-2 text-left text-sm hover:bg-tertiary-500"
|
|
||||||
@click="selectValue('')"
|
|
||||||
>
|
|
||||||
{{ placeholder }}
|
{{ placeholder }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-for="slot in timeSlots"
|
v-for="slot in filteredTimeSlots"
|
||||||
:key="slot"
|
:key="slot"
|
||||||
type="button"
|
type="button"
|
||||||
class="block w-full px-2 py-2 text-left text-sm hover:bg-tertiary-500"
|
class="block w-full px-2 py-2 text-left text-sm hover:bg-tertiary-500"
|
||||||
@@ -34,6 +51,9 @@
|
|||||||
>
|
>
|
||||||
{{ slot }}
|
{{ slot }}
|
||||||
</button>
|
</button>
|
||||||
|
<p v-if="filteredTimeSlots.length === 0" class="px-2 py-2 text-sm text-neutral-500">
|
||||||
|
Aucun résultat
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
</template>
|
</template>
|
||||||
@@ -55,7 +75,9 @@ const emit = defineEmits<{
|
|||||||
const root = ref<HTMLElement | null>(null)
|
const root = ref<HTMLElement | null>(null)
|
||||||
const trigger = ref<HTMLElement | null>(null)
|
const trigger = ref<HTMLElement | null>(null)
|
||||||
const menu = ref<HTMLElement | null>(null)
|
const menu = ref<HTMLElement | null>(null)
|
||||||
|
const inputRef = ref<HTMLInputElement | null>(null)
|
||||||
const isOpen = ref(false)
|
const isOpen = ref(false)
|
||||||
|
const inputValue = ref('')
|
||||||
const menuStyle = ref<Record<string, string>>({
|
const menuStyle = ref<Record<string, string>>({
|
||||||
top: '0px',
|
top: '0px',
|
||||||
left: '0px',
|
left: '0px',
|
||||||
@@ -73,7 +95,31 @@ const timeSlots = computed(() => {
|
|||||||
return slots
|
return slots
|
||||||
})
|
})
|
||||||
|
|
||||||
const displayValue = computed(() => props.modelValue || props.placeholder)
|
const filteredTimeSlots = computed(() => {
|
||||||
|
const query = inputValue.value.trim()
|
||||||
|
if (!query) return timeSlots.value
|
||||||
|
return timeSlots.value.filter((slot) => slot.includes(query))
|
||||||
|
})
|
||||||
|
|
||||||
|
const applyTimeMask = (value: string): string => {
|
||||||
|
const digits = value.replace(/\D/g, '').slice(0, 4)
|
||||||
|
if (digits.length <= 2) return digits
|
||||||
|
return `${digits.slice(0, 2)}:${digits.slice(2)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeTypedTime = (value: string): string | null => {
|
||||||
|
const trimmed = value.trim()
|
||||||
|
if (trimmed === '') return ''
|
||||||
|
|
||||||
|
// Accepte HH:MM ou H:MM puis normalise en HH:MM.
|
||||||
|
const match = trimmed.match(/^(\d{1,2}):(\d{2})$/)
|
||||||
|
if (!match) return null
|
||||||
|
const hours = Number(match[1])
|
||||||
|
const minutes = Number(match[2])
|
||||||
|
if (!Number.isInteger(hours) || !Number.isInteger(minutes)) return null
|
||||||
|
if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return null
|
||||||
|
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
const updateMenuPosition = () => {
|
const updateMenuPosition = () => {
|
||||||
const triggerEl = trigger.value
|
const triggerEl = trigger.value
|
||||||
@@ -103,10 +149,57 @@ const toggleOpen = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openMenu = () => {
|
||||||
|
if (props.disabled) return
|
||||||
|
if (!isOpen.value) {
|
||||||
|
isOpen.value = true
|
||||||
|
nextTick(updateMenuPosition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openMenuAndFocusFirst = () => {
|
||||||
|
openMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeMenu = () => {
|
||||||
|
isOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const commitInput = () => {
|
||||||
|
const normalized = normalizeTypedTime(inputValue.value)
|
||||||
|
if (normalized === null) {
|
||||||
|
inputValue.value = props.modelValue
|
||||||
|
closeMenu()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emit('update:modelValue', normalized)
|
||||||
|
inputValue.value = normalized
|
||||||
|
closeMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onInput = (event: Event) => {
|
||||||
|
const target = event.target as HTMLInputElement
|
||||||
|
const masked = applyTimeMask(target.value)
|
||||||
|
if (masked !== inputValue.value) {
|
||||||
|
inputValue.value = masked
|
||||||
|
}
|
||||||
|
openMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onInputBlur = () => {
|
||||||
|
// Laisse le temps au click menu de passer avant fermeture.
|
||||||
|
setTimeout(() => {
|
||||||
|
if (menu.value?.contains(document.activeElement)) return
|
||||||
|
commitInput()
|
||||||
|
}, 50)
|
||||||
|
}
|
||||||
|
|
||||||
const selectValue = (value: string) => {
|
const selectValue = (value: string) => {
|
||||||
if (props.disabled) return
|
if (props.disabled) return
|
||||||
emit('update:modelValue', value)
|
emit('update:modelValue', value)
|
||||||
|
inputValue.value = value
|
||||||
isOpen.value = false
|
isOpen.value = false
|
||||||
|
nextTick(() => inputRef.value?.focus())
|
||||||
}
|
}
|
||||||
|
|
||||||
const onDocumentClick = (event: MouseEvent) => {
|
const onDocumentClick = (event: MouseEvent) => {
|
||||||
@@ -139,6 +232,14 @@ watch(() => props.disabled, (disabled) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(value) => {
|
||||||
|
inputValue.value = value
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
document.addEventListener('click', onDocumentClick)
|
document.addEventListener('click', onDocumentClick)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -170,6 +170,10 @@
|
|||||||
import type { AbsenceType } from '~/services/dto/absence-type'
|
import type { AbsenceType } from '~/services/dto/absence-type'
|
||||||
import { createAbsenceType, deleteAbsenceType, listAbsenceTypes, updateAbsenceType } from '~/services/absence-types'
|
import { createAbsenceType, deleteAbsenceType, listAbsenceTypes, updateAbsenceType } from '~/services/absence-types'
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: 'Types d\'absences'
|
||||||
|
})
|
||||||
|
|
||||||
const isDrawerOpen = ref(false)
|
const isDrawerOpen = ref(false)
|
||||||
const isSubmitting = ref(false)
|
const isSubmitting = ref(false)
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
|
|||||||
@@ -111,6 +111,10 @@ import AbsencePrintDrawer from '~/components/AbsencePrintDrawer.vue'
|
|||||||
import EmployeeNameFilterInput from '~/components/EmployeeNameFilterInput.vue'
|
import EmployeeNameFilterInput from '~/components/EmployeeNameFilterInput.vue'
|
||||||
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
|
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: 'Calendrier'
|
||||||
|
})
|
||||||
|
|
||||||
// Données principales affichées dans la grille.
|
// Données principales affichées dans la grille.
|
||||||
const employees = ref<Employee[]>([])
|
const employees = ref<Employee[]>([])
|
||||||
const sites = computed(() => {
|
const sites = computed(() => {
|
||||||
|
|||||||
@@ -175,6 +175,9 @@ 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'
|
||||||
|
useHead({
|
||||||
|
title: 'Employés'
|
||||||
|
})
|
||||||
|
|
||||||
const isDrawerOpen = ref(false)
|
const isDrawerOpen = ref(false)
|
||||||
const isSubmitting = ref(false)
|
const isSubmitting = ref(false)
|
||||||
|
|||||||
@@ -159,4 +159,8 @@ const {
|
|||||||
formatMinutes,
|
formatMinutes,
|
||||||
handleSave
|
handleSave
|
||||||
} = useHoursPage()
|
} = useHoursPage()
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: 'Heures'
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -3,5 +3,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
useHead({
|
||||||
|
title: 'Tableau de bord'
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -49,6 +49,9 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
definePageMeta({layout: 'auth'})
|
definePageMeta({layout: 'auth'})
|
||||||
|
useHead({
|
||||||
|
title: 'Connexion'
|
||||||
|
})
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
|||||||
@@ -123,6 +123,10 @@
|
|||||||
import type { Site } from '~/services/dto/site'
|
import type { Site } from '~/services/dto/site'
|
||||||
import { createSite, deleteSite, listSites, updateSite, updateSiteOrder } from '~/services/sites'
|
import { createSite, deleteSite, listSites, updateSite, updateSiteOrder } from '~/services/sites'
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: 'Sites'
|
||||||
|
})
|
||||||
|
|
||||||
const isDrawerOpen = ref(false)
|
const isDrawerOpen = ref(false)
|
||||||
const isSubmitting = ref(false)
|
const isSubmitting = ref(false)
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
|
|||||||
@@ -209,6 +209,9 @@ import { createUser, listUsers, updateUser } from '~/services/users'
|
|||||||
import { createUserSiteRole, deleteUserSiteRole, listUserSiteRoles } from '~/services/user-site-roles'
|
import { createUserSiteRole, deleteUserSiteRole, listUserSiteRoles } from '~/services/user-site-roles'
|
||||||
|
|
||||||
definePageMeta({ middleware: ['admin'] })
|
definePageMeta({ middleware: ['admin'] })
|
||||||
|
useHead({
|
||||||
|
title: 'Utilisateurs'
|
||||||
|
})
|
||||||
|
|
||||||
const users = ref<User[]>([])
|
const users = ref<User[]>([])
|
||||||
const employees = ref<Employee[]>([])
|
const employees = ref<Employee[]>([])
|
||||||
|
|||||||
40
migrations/Version20260220133000.php
Normal file
40
migrations/Version20260220133000.php
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260220133000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add employee contract periods history table and seed current contracts';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('CREATE TABLE employee_contract_periods (id SERIAL NOT NULL, employee_id INT NOT NULL, contract_id INT NOT NULL, start_date DATE NOT NULL, end_date DATE DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))');
|
||||||
|
$this->addSql('CREATE INDEX idx_emp_contract_period_employee_start ON employee_contract_periods (employee_id, start_date)');
|
||||||
|
$this->addSql('CREATE INDEX idx_emp_contract_period_employee_end ON employee_contract_periods (employee_id, end_date)');
|
||||||
|
$this->addSql('CREATE INDEX IDX_831EED7A8C03F15C ON employee_contract_periods (employee_id)');
|
||||||
|
$this->addSql('CREATE INDEX IDX_831EED7A2576E0FD ON employee_contract_periods (contract_id)');
|
||||||
|
$this->addSql('ALTER TABLE employee_contract_periods ADD CONSTRAINT FK_831EED7A8C03F15C FOREIGN KEY (employee_id) REFERENCES employees (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
|
||||||
|
$this->addSql('ALTER TABLE employee_contract_periods ADD CONSTRAINT FK_831EED7A2576E0FD FOREIGN KEY (contract_id) REFERENCES contracts (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
|
||||||
|
|
||||||
|
// Initialise l\'historique avec le contrat actuel de chaque employé.
|
||||||
|
$this->addSql("INSERT INTO employee_contract_periods (employee_id, contract_id, start_date, end_date, created_at)
|
||||||
|
SELECT id, contract_id, DATE '1970-01-01', NULL, NOW()
|
||||||
|
FROM employees
|
||||||
|
WHERE contract_id IS NOT NULL");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE employee_contract_periods DROP CONSTRAINT FK_831EED7A8C03F15C');
|
||||||
|
$this->addSql('ALTER TABLE employee_contract_periods DROP CONSTRAINT FK_831EED7A2576E0FD');
|
||||||
|
$this->addSql('DROP TABLE employee_contract_periods');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ namespace App\Entity;
|
|||||||
use ApiPlatform\Metadata\ApiProperty;
|
use ApiPlatform\Metadata\ApiProperty;
|
||||||
use ApiPlatform\Metadata\ApiResource;
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
use App\Repository\EmployeeRepository;
|
use App\Repository\EmployeeRepository;
|
||||||
|
use App\State\EmployeeWriteProcessor;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
use Symfony\Component\Serializer\Attribute\Groups;
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
@@ -15,7 +16,8 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
|||||||
normalizationContext: ['groups' => ['employee:read', 'site:read']],
|
normalizationContext: ['groups' => ['employee:read', 'site:read']],
|
||||||
denormalizationContext: ['groups' => ['employee:write']],
|
denormalizationContext: ['groups' => ['employee:write']],
|
||||||
paginationEnabled: false,
|
paginationEnabled: false,
|
||||||
security: "is_granted('ROLE_ADMIN')"
|
security: "is_granted('ROLE_ADMIN')",
|
||||||
|
processor: EmployeeWriteProcessor::class,
|
||||||
)]
|
)]
|
||||||
#[ORM\Entity(repositoryClass: EmployeeRepository::class)]
|
#[ORM\Entity(repositoryClass: EmployeeRepository::class)]
|
||||||
#[ORM\Table(name: 'employees')]
|
#[ORM\Table(name: 'employees')]
|
||||||
|
|||||||
102
src/Entity/EmployeeContractPeriod.php
Normal file
102
src/Entity/EmployeeContractPeriod.php
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use App\Repository\EmployeeContractPeriodRepository;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
|
#[ORM\Entity(repositoryClass: EmployeeContractPeriodRepository::class)]
|
||||||
|
#[ORM\Table(name: 'employee_contract_periods')]
|
||||||
|
#[ORM\Index(columns: ['employee_id', 'start_date'], name: 'idx_emp_contract_period_employee_start')]
|
||||||
|
#[ORM\Index(columns: ['employee_id', 'end_date'], name: 'idx_emp_contract_period_employee_end')]
|
||||||
|
class EmployeeContractPeriod
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column(type: 'integer')]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: Employee::class)]
|
||||||
|
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||||
|
private ?Employee $employee = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: Contract::class)]
|
||||||
|
#[ORM\JoinColumn(nullable: false)]
|
||||||
|
private ?Contract $contract = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'date_immutable')]
|
||||||
|
private DateTimeImmutable $startDate;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'date_immutable', nullable: true)]
|
||||||
|
private ?DateTimeImmutable $endDate = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'datetime_immutable')]
|
||||||
|
private DateTimeImmutable $createdAt;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->createdAt = new DateTimeImmutable();
|
||||||
|
$this->startDate = new DateTimeImmutable('today');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEmployee(): ?Employee
|
||||||
|
{
|
||||||
|
return $this->employee;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setEmployee(?Employee $employee): self
|
||||||
|
{
|
||||||
|
$this->employee = $employee;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getContract(): ?Contract
|
||||||
|
{
|
||||||
|
return $this->contract;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setContract(?Contract $contract): self
|
||||||
|
{
|
||||||
|
$this->contract = $contract;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStartDate(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->startDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setStartDate(DateTimeImmutable $startDate): self
|
||||||
|
{
|
||||||
|
$this->startDate = $startDate;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEndDate(): ?DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->endDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setEndDate(?DateTimeImmutable $endDate): self
|
||||||
|
{
|
||||||
|
$this->endDate = $endDate;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCreatedAt(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->createdAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
75
src/Repository/EmployeeContractPeriodRepository.php
Normal file
75
src/Repository/EmployeeContractPeriodRepository.php
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\Employee;
|
||||||
|
use App\Entity\EmployeeContractPeriod;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<EmployeeContractPeriod>
|
||||||
|
*/
|
||||||
|
final class EmployeeContractPeriodRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, EmployeeContractPeriod::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<Employee> $employees
|
||||||
|
*
|
||||||
|
* @return list<EmployeeContractPeriod>
|
||||||
|
*/
|
||||||
|
public function findByEmployeesAndDateRange(array $employees, DateTimeImmutable $from, DateTimeImmutable $to): array
|
||||||
|
{
|
||||||
|
if ([] === $employees) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->createQueryBuilder('p')
|
||||||
|
->andWhere('p.employee IN (:employees)')
|
||||||
|
->andWhere('p.startDate <= :to')
|
||||||
|
->andWhere('p.endDate IS NULL OR p.endDate >= :from')
|
||||||
|
->setParameter('employees', $employees)
|
||||||
|
->setParameter('from', $from)
|
||||||
|
->setParameter('to', $to)
|
||||||
|
->orderBy('p.startDate', 'ASC')
|
||||||
|
->getQuery()
|
||||||
|
->getResult()
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findOneCoveringDate(Employee $employee, DateTimeImmutable $date): ?EmployeeContractPeriod
|
||||||
|
{
|
||||||
|
return $this->createQueryBuilder('p')
|
||||||
|
->andWhere('p.employee = :employee')
|
||||||
|
->andWhere('p.startDate <= :date')
|
||||||
|
->andWhere('p.endDate IS NULL OR p.endDate >= :date')
|
||||||
|
->setParameter('employee', $employee)
|
||||||
|
->setParameter('date', $date)
|
||||||
|
->orderBy('p.startDate', 'DESC')
|
||||||
|
->setMaxResults(1)
|
||||||
|
->getQuery()
|
||||||
|
->getOneOrNullResult()
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function closeOpenPeriods(Employee $employee, DateTimeImmutable $endDate): int
|
||||||
|
{
|
||||||
|
return $this->createQueryBuilder('p')
|
||||||
|
->update()
|
||||||
|
->set('p.endDate', ':endDate')
|
||||||
|
->andWhere('p.employee = :employee')
|
||||||
|
->andWhere('p.endDate IS NULL')
|
||||||
|
->setParameter('employee', $employee)
|
||||||
|
->setParameter('endDate', $endDate)
|
||||||
|
->getQuery()
|
||||||
|
->execute()
|
||||||
|
;
|
||||||
|
}
|
||||||
|
}
|
||||||
97
src/Service/Contracts/EmployeeContractResolver.php
Normal file
97
src/Service/Contracts/EmployeeContractResolver.php
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service\Contracts;
|
||||||
|
|
||||||
|
use App\Entity\Contract;
|
||||||
|
use App\Entity\Employee;
|
||||||
|
use App\Repository\EmployeeContractPeriodRepository;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use LogicException;
|
||||||
|
|
||||||
|
readonly class EmployeeContractResolver
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private EmployeeContractPeriodRepository $periodRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function resolveForEmployeeAndDate(Employee $employee, DateTimeImmutable $date): ?Contract
|
||||||
|
{
|
||||||
|
$period = $this->periodRepository->findOneCoveringDate($employee, $date);
|
||||||
|
$contract = $period?->getContract();
|
||||||
|
if (null === $contract) {
|
||||||
|
throw new LogicException(sprintf(
|
||||||
|
'Missing contract period for employee %d on %s.',
|
||||||
|
$employee->getId() ?? 0,
|
||||||
|
$date->format('Y-m-d')
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $contract;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<Employee> $employees
|
||||||
|
* @param list<string> $days
|
||||||
|
*
|
||||||
|
* @return array<int, array<string, ?Contract>>
|
||||||
|
*/
|
||||||
|
public function resolveForEmployeesAndDays(array $employees, array $days): array
|
||||||
|
{
|
||||||
|
$resolved = [];
|
||||||
|
if ([] === $employees || [] === $days) {
|
||||||
|
return $resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($employees as $employee) {
|
||||||
|
$employeeId = $employee->getId();
|
||||||
|
if (!$employeeId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($days as $day) {
|
||||||
|
$resolved[$employeeId][$day] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$from = new DateTimeImmutable(min($days));
|
||||||
|
$to = new DateTimeImmutable(max($days));
|
||||||
|
$periods = $this->periodRepository->findByEmployeesAndDateRange($employees, $from, $to);
|
||||||
|
foreach ($periods as $period) {
|
||||||
|
$employeeId = $period->getEmployee()?->getId();
|
||||||
|
$contract = $period->getContract();
|
||||||
|
if (!$employeeId || null === $contract) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$start = $period->getStartDate()->format('Y-m-d');
|
||||||
|
$end = $period->getEndDate()?->format('Y-m-d') ?? '9999-12-31';
|
||||||
|
foreach ($days as $day) {
|
||||||
|
if ($day < $start || $day > $end) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$resolved[$employeeId][$day] = $contract;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($employees as $employee) {
|
||||||
|
$employeeId = $employee->getId();
|
||||||
|
if (!$employeeId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($days as $day) {
|
||||||
|
if (null === ($resolved[$employeeId][$day] ?? null)) {
|
||||||
|
throw new LogicException(sprintf(
|
||||||
|
'Missing contract period for employee %d on %s.',
|
||||||
|
$employeeId,
|
||||||
|
$day
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $resolved;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,11 +6,16 @@ namespace App\Service\WorkHours;
|
|||||||
|
|
||||||
use App\Entity\Absence;
|
use App\Entity\Absence;
|
||||||
use App\Enum\TrackingMode;
|
use App\Enum\TrackingMode;
|
||||||
|
use App\Service\Contracts\EmployeeContractResolver;
|
||||||
use DateMalformedStringException;
|
use DateMalformedStringException;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
|
|
||||||
final class WorkedHoursCreditPolicy
|
final readonly class WorkedHoursCreditPolicy
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private EmployeeContractResolver $contractResolver,
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @throws DateMalformedStringException
|
* @throws DateMalformedStringException
|
||||||
*/
|
*/
|
||||||
@@ -23,14 +28,19 @@ final class WorkedHoursCreditPolicy
|
|||||||
}
|
}
|
||||||
|
|
||||||
$employee = $absence->getEmployee();
|
$employee = $absence->getEmployee();
|
||||||
|
if (null === $employee) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
$workDate = new DateTimeImmutable($dateYmd);
|
||||||
|
$contract = $this->contractResolver->resolveForEmployeeAndDate($employee, $workDate);
|
||||||
// Les contrats suivis en "présence" ne cumulent pas d'heures en minutes.
|
// Les contrats suivis en "présence" ne cumulent pas d'heures en minutes.
|
||||||
if (TrackingMode::TIME->value !== $employee?->getContract()?->getTrackingMode()) {
|
if (TrackingMode::TIME->value !== $contract?->getTrackingMode()) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
$weekday = (int) new DateTimeImmutable($dateYmd)->format('N');
|
$weekday = (int) $workDate->format('N');
|
||||||
// On applique la règle de crédit dépendante du contrat (35h / 39h / fallback).
|
// On applique la règle de crédit dépendante du contrat (35h / 39h / fallback).
|
||||||
$dayMinutes = $this->resolveContractDayMinutes($employee->getContract()?->getWeeklyHours(), $weekday);
|
$dayMinutes = $this->resolveContractDayMinutes($contract?->getWeeklyHours(), $weekday);
|
||||||
if ($dayMinutes <= 0) {
|
if ($dayMinutes <= 0) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -41,15 +51,26 @@ final class WorkedHoursCreditPolicy
|
|||||||
return (int) round(($dayMinutes / 2) * $halfUnits);
|
return (int) round(($dayMinutes / 2) * $halfUnits);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function computeCreditedPresenceUnits(Absence $absence, bool $absentMorning, bool $absentAfternoon): float
|
/**
|
||||||
{
|
* @throws DateMalformedStringException
|
||||||
|
*/
|
||||||
|
public function computeCreditedPresenceUnits(
|
||||||
|
Absence $absence,
|
||||||
|
string $dateYmd,
|
||||||
|
bool $absentMorning,
|
||||||
|
bool $absentAfternoon
|
||||||
|
): float {
|
||||||
$type = $absence->getType();
|
$type = $absence->getType();
|
||||||
if (!$type?->getCountAsWorkedHours()) {
|
if (!$type?->getCountAsWorkedHours()) {
|
||||||
return 0.0;
|
return 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
$employee = $absence->getEmployee();
|
$employee = $absence->getEmployee();
|
||||||
if (TrackingMode::PRESENCE->value !== $employee?->getContract()?->getTrackingMode()) {
|
if (null === $employee) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
$contract = $this->contractResolver->resolveForEmployeeAndDate($employee, new DateTimeImmutable($dateYmd));
|
||||||
|
if (TrackingMode::PRESENCE->value !== $contract?->getTrackingMode()) {
|
||||||
return 0.0;
|
return 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
121
src/State/EmployeeWriteProcessor.php
Normal file
121
src/State/EmployeeWriteProcessor.php
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\State;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\DeleteOperationInterface;
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProcessorInterface;
|
||||||
|
use App\Entity\Contract;
|
||||||
|
use App\Entity\Employee;
|
||||||
|
use App\Entity\EmployeeContractPeriod;
|
||||||
|
use App\Repository\EmployeeContractPeriodRepository;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
|
||||||
|
final readonly class EmployeeWriteProcessor implements ProcessorInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
||||||
|
private ProcessorInterface $persistProcessor,
|
||||||
|
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
|
||||||
|
private ProcessorInterface $removeProcessor,
|
||||||
|
private EntityManagerInterface $entityManager,
|
||||||
|
private EmployeeContractPeriodRepository $periodRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function process(
|
||||||
|
mixed $data,
|
||||||
|
Operation $operation,
|
||||||
|
array $uriVariables = [],
|
||||||
|
array $context = []
|
||||||
|
): mixed {
|
||||||
|
if ($operation instanceof DeleteOperationInterface) {
|
||||||
|
return $this->removeProcessor->process($data, $operation, $uriVariables, $context);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$data instanceof Employee) {
|
||||||
|
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||||
|
}
|
||||||
|
|
||||||
|
$isNew = null === $data->getId();
|
||||||
|
$previousContract = $this->resolvePreviousContract($data);
|
||||||
|
$result = $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||||
|
|
||||||
|
$currentContract = $data->getContract();
|
||||||
|
if (!$currentContract instanceof Contract) {
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
$today = new DateTimeImmutable('today');
|
||||||
|
if ($isNew) {
|
||||||
|
$this->ensureContractPeriodExists($data, $currentContract, $today);
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->isSameContract($previousContract, $currentContract)) {
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
$todayPeriod = $this->periodRepository->findOneCoveringDate($data, $today);
|
||||||
|
if (null !== $todayPeriod && null === $todayPeriod->getEndDate() && $todayPeriod->getStartDate() === $today) {
|
||||||
|
$todayPeriod->setContract($currentContract);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->periodRepository->closeOpenPeriods($data, $today->modify('-1 day'));
|
||||||
|
$this->createPeriod($data, $currentContract, $today);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolvePreviousContract(Employee $employee): ?Contract
|
||||||
|
{
|
||||||
|
if (null === $employee->getId()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$originalData = $this->entityManager->getUnitOfWork()->getOriginalEntityData($employee);
|
||||||
|
$original = $originalData['contract'] ?? null;
|
||||||
|
|
||||||
|
return $original instanceof Contract ? $original : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isSameContract(?Contract $first, ?Contract $second): bool
|
||||||
|
{
|
||||||
|
if (null === $first || null === $second) {
|
||||||
|
return $first === $second;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $first->getId() === $second->getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ensureContractPeriodExists(Employee $employee, Contract $contract, DateTimeImmutable $startDate): void
|
||||||
|
{
|
||||||
|
$covered = $this->periodRepository->findOneCoveringDate($employee, $startDate);
|
||||||
|
if (null !== $covered) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->createPeriod($employee, $contract, $startDate);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createPeriod(Employee $employee, Contract $contract, DateTimeImmutable $startDate): void
|
||||||
|
{
|
||||||
|
$period = new EmployeeContractPeriod()
|
||||||
|
->setEmployee($employee)
|
||||||
|
->setContract($contract)
|
||||||
|
->setStartDate($startDate)
|
||||||
|
->setEndDate(null)
|
||||||
|
;
|
||||||
|
|
||||||
|
$this->entityManager->persist($period);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ use App\Entity\WorkHour;
|
|||||||
use App\Enum\TrackingMode;
|
use App\Enum\TrackingMode;
|
||||||
use App\Repository\EmployeeRepository;
|
use App\Repository\EmployeeRepository;
|
||||||
use App\Repository\WorkHourRepository;
|
use App\Repository\WorkHourRepository;
|
||||||
|
use App\Service\Contracts\EmployeeContractResolver;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Bundle\SecurityBundle\Security;
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
@@ -27,6 +28,7 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
|
|||||||
private Security $security,
|
private Security $security,
|
||||||
private EmployeeRepository $employeeRepository,
|
private EmployeeRepository $employeeRepository,
|
||||||
private WorkHourRepository $workHourRepository,
|
private WorkHourRepository $workHourRepository,
|
||||||
|
private EmployeeContractResolver $contractResolver,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function process(
|
public function process(
|
||||||
@@ -75,7 +77,8 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
|
|||||||
throw new AccessDeniedHttpException(sprintf('Employee %d is outside your scope.', $employeeId));
|
throw new AccessDeniedHttpException(sprintf('Employee %d is outside your scope.', $employeeId));
|
||||||
}
|
}
|
||||||
|
|
||||||
$isPresenceTracking = TrackingMode::PRESENCE->value === $employee->getContract()?->getTrackingMode();
|
$contract = $this->contractResolver->resolveForEmployeeAndDate($employee, $workDate);
|
||||||
|
$isPresenceTracking = TrackingMode::PRESENCE->value === $contract?->getTrackingMode();
|
||||||
$normalized = $this->normalizeEntry($entry, $employeeId, $isPresenceTracking);
|
$normalized = $this->normalizeEntry($entry, $employeeId, $isPresenceTracking);
|
||||||
$existing = $existingByEmployeeId[$employeeId] ?? null;
|
$existing = $existingByEmployeeId[$employeeId] ?? null;
|
||||||
|
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ final readonly class WorkHourDayContextProvider implements ProviderInterface
|
|||||||
|
|
||||||
// Calcule le crédit d'heures selon la politique métier (type d'absence + contrat).
|
// Calcule le crédit d'heures selon la politique métier (type d'absence + contrat).
|
||||||
$creditedMinutes = $this->workedHoursCreditPolicy->computeCreditedMinutes($absence, $dateKey, $absentMorning, $absentAfternoon);
|
$creditedMinutes = $this->workedHoursCreditPolicy->computeCreditedMinutes($absence, $dateKey, $absentMorning, $absentAfternoon);
|
||||||
$creditedPresenceUnits = $this->workedHoursCreditPolicy->computeCreditedPresenceUnits($absence, $absentMorning, $absentAfternoon);
|
$creditedPresenceUnits = $this->workedHoursCreditPolicy->computeCreditedPresenceUnits($absence, $dateKey, $absentMorning, $absentAfternoon);
|
||||||
$rowsByEmployeeId[$employeeId]->addAbsence(
|
$rowsByEmployeeId[$employeeId]->addAbsence(
|
||||||
label: $absence->getType()?->getLabel(),
|
label: $absence->getType()?->getLabel(),
|
||||||
morning: $absentMorning,
|
morning: $absentMorning,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use App\Dto\WorkHours\WeeklyDaySummary;
|
|||||||
use App\Dto\WorkHours\WeeklySummaryRow;
|
use App\Dto\WorkHours\WeeklySummaryRow;
|
||||||
use App\Dto\WorkHours\WorkMetrics;
|
use App\Dto\WorkHours\WorkMetrics;
|
||||||
use App\Entity\Absence;
|
use App\Entity\Absence;
|
||||||
|
use App\Entity\Contract;
|
||||||
use App\Entity\Employee;
|
use App\Entity\Employee;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
use App\Entity\WorkHour;
|
use App\Entity\WorkHour;
|
||||||
@@ -19,6 +20,7 @@ use App\Enum\TrackingMode;
|
|||||||
use App\Repository\Contract\AbsenceReadRepositoryInterface;
|
use App\Repository\Contract\AbsenceReadRepositoryInterface;
|
||||||
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
|
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
|
||||||
use App\Repository\Contract\WorkHourReadRepositoryInterface;
|
use App\Repository\Contract\WorkHourReadRepositoryInterface;
|
||||||
|
use App\Service\Contracts\EmployeeContractResolver;
|
||||||
use App\Service\WorkHours\AbsenceSegmentsResolver;
|
use App\Service\WorkHours\AbsenceSegmentsResolver;
|
||||||
use App\Service\WorkHours\WorkedHoursCreditPolicy;
|
use App\Service\WorkHours\WorkedHoursCreditPolicy;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
@@ -37,6 +39,7 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
|||||||
private AbsenceReadRepositoryInterface $absenceRepository,
|
private AbsenceReadRepositoryInterface $absenceRepository,
|
||||||
private AbsenceSegmentsResolver $absenceSegmentsResolver,
|
private AbsenceSegmentsResolver $absenceSegmentsResolver,
|
||||||
private WorkedHoursCreditPolicy $workedHoursCreditPolicy,
|
private WorkedHoursCreditPolicy $workedHoursCreditPolicy,
|
||||||
|
private EmployeeContractResolver $contractResolver,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): WorkHourWeeklySummary
|
public function provide(Operation $operation, array $uriVariables = [], array $context = []): WorkHourWeeklySummary
|
||||||
@@ -58,7 +61,7 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
|||||||
$summary->weekStart = $weekStart->format('Y-m-d');
|
$summary->weekStart = $weekStart->format('Y-m-d');
|
||||||
$summary->weekEnd = $weekEnd->format('Y-m-d');
|
$summary->weekEnd = $weekEnd->format('Y-m-d');
|
||||||
$summary->days = $days;
|
$summary->days = $days;
|
||||||
$summary->rows = $this->buildRows($employees, $workHours, $absences, $days);
|
$summary->rows = $this->buildRows($employees, $workHours, $absences, $days, $anchorDate->format('Y-m-d'));
|
||||||
|
|
||||||
return $summary;
|
return $summary;
|
||||||
}
|
}
|
||||||
@@ -108,9 +111,10 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
|||||||
*
|
*
|
||||||
* @return list<WeeklySummaryRow>
|
* @return list<WeeklySummaryRow>
|
||||||
*/
|
*/
|
||||||
private function buildRows(array $employees, array $workHours, array $absences, array $days): array
|
private function buildRows(array $employees, array $workHours, array $absences, array $days, string $anchorDateYmd): array
|
||||||
{
|
{
|
||||||
$metricsByEmployeeDate = [];
|
$contractsByEmployeeDate = $this->contractResolver->resolveForEmployeesAndDays($employees, $days);
|
||||||
|
$metricsByEmployeeDate = [];
|
||||||
foreach ($workHours as $workHour) {
|
foreach ($workHours as $workHour) {
|
||||||
$employeeId = $workHour->getEmployee()?->getId();
|
$employeeId = $workHour->getEmployee()?->getId();
|
||||||
if (!$employeeId) {
|
if (!$employeeId) {
|
||||||
@@ -158,7 +162,7 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
|||||||
$creditedByEmployeeDate[$employeeId][$date] = ($creditedByEmployeeDate[$employeeId][$date] ?? 0)
|
$creditedByEmployeeDate[$employeeId][$date] = ($creditedByEmployeeDate[$employeeId][$date] ?? 0)
|
||||||
+ $this->workedHoursCreditPolicy->computeCreditedMinutes($absence, $date, $absentMorning, $absentAfternoon);
|
+ $this->workedHoursCreditPolicy->computeCreditedMinutes($absence, $date, $absentMorning, $absentAfternoon);
|
||||||
$creditedPresenceByEmployeeDate[$employeeId][$date] = ($creditedPresenceByEmployeeDate[$employeeId][$date] ?? 0.0)
|
$creditedPresenceByEmployeeDate[$employeeId][$date] = ($creditedPresenceByEmployeeDate[$employeeId][$date] ?? 0.0)
|
||||||
+ $this->workedHoursCreditPolicy->computeCreditedPresenceUnits($absence, $absentMorning, $absentAfternoon);
|
+ $this->workedHoursCreditPolicy->computeCreditedPresenceUnits($absence, $date, $absentMorning, $absentAfternoon);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,12 +179,20 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
|||||||
$weeklyPresenceCount = 0.0;
|
$weeklyPresenceCount = 0.0;
|
||||||
$daily = [];
|
$daily = [];
|
||||||
// Les contrats au suivi "présence" ne manipulent pas les heures, mais des demi-journées.
|
// Les contrats au suivi "présence" ne manipulent pas les heures, mais des demi-journées.
|
||||||
$isPresenceTracking = TrackingMode::PRESENCE->value === $employee->getContract()?->getTrackingMode();
|
$weekAnchorContract = $contractsByEmployeeDate[$employeeId][$anchorDateYmd]
|
||||||
|
?? $contractsByEmployeeDate[$employeeId][$days[0]]
|
||||||
|
?? null;
|
||||||
|
$employeeContractsByDate = [];
|
||||||
|
foreach ($days as $date) {
|
||||||
|
$employeeContractsByDate[$date] = $contractsByEmployeeDate[$employeeId][$date] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
foreach ($days as $date) {
|
foreach ($days as $date) {
|
||||||
$entry = $metricsByEmployeeDate[$employeeId][$date] ?? null;
|
$entry = $metricsByEmployeeDate[$employeeId][$date] ?? null;
|
||||||
$metrics = $entry['metrics'] ?? new WorkMetrics();
|
$metrics = $entry['metrics'] ?? new WorkMetrics();
|
||||||
$creditedMinutes = $creditedByEmployeeDate[$employeeId][$date] ?? 0;
|
$creditedMinutes = $creditedByEmployeeDate[$employeeId][$date] ?? 0;
|
||||||
|
$contractAtDate = $employeeContractsByDate[$date] ?? null;
|
||||||
|
$isPresenceTracking = TrackingMode::PRESENCE->value === $contractAtDate?->getTrackingMode();
|
||||||
// Les absences "comptées comme travaillées" alimentent le total du jour.
|
// Les absences "comptées comme travaillées" alimentent le total du jour.
|
||||||
$metrics->addCreditedMinutes($creditedMinutes);
|
$metrics->addCreditedMinutes($creditedMinutes);
|
||||||
$present = null;
|
$present = null;
|
||||||
@@ -210,18 +222,20 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$contractWeeklyHours = $employee->getContract()?->getWeeklyHours();
|
$isWeekPresenceTracking = TrackingMode::PRESENCE->value === $weekAnchorContract?->getTrackingMode();
|
||||||
$disableOvertimeBonuses = $this->hasDisabledOvertimeBonuses($employee);
|
$disableOvertimeBonuses = $this->hasDisabledOvertimeBonuses($weekAnchorContract);
|
||||||
$weeklyOvertimeTotalMinutes = $isPresenceTracking
|
$overtimeReferenceMinutes = $this->computeWeeklyOvertimeReferenceMinutes($days, $employeeContractsByDate);
|
||||||
|
$overtime25StartMinutes = $this->computeWeeklyOvertime25StartMinutes($days, $employeeContractsByDate);
|
||||||
|
$weeklyOvertimeTotalMinutes = $isWeekPresenceTracking
|
||||||
? 0
|
? 0
|
||||||
: $this->computeOvertimeTotalMinutes($weeklyTotalMinutes, $contractWeeklyHours);
|
: max(0, $weeklyTotalMinutes - $overtimeReferenceMinutes);
|
||||||
$weeklyOvertime25Minutes = ($isPresenceTracking || $disableOvertimeBonuses)
|
$weeklyOvertime25Minutes = ($isWeekPresenceTracking || $disableOvertimeBonuses)
|
||||||
? 0
|
? 0
|
||||||
: $this->computeOvertime25BonusMinutes($weeklyTotalMinutes, $contractWeeklyHours);
|
: $this->computeOvertime25BonusMinutes($weeklyTotalMinutes, $overtime25StartMinutes);
|
||||||
$weeklyOvertime50Minutes = ($isPresenceTracking || $disableOvertimeBonuses)
|
$weeklyOvertime50Minutes = ($isWeekPresenceTracking || $disableOvertimeBonuses)
|
||||||
? 0
|
? 0
|
||||||
: $this->computeOvertime50BonusMinutes($weeklyTotalMinutes);
|
: $this->computeOvertime50BonusMinutes($weeklyTotalMinutes);
|
||||||
$weeklyRecoveryMinutes = ($isPresenceTracking || $disableOvertimeBonuses)
|
$weeklyRecoveryMinutes = ($isWeekPresenceTracking || $disableOvertimeBonuses)
|
||||||
? 0
|
? 0
|
||||||
: $weeklyOvertimeTotalMinutes + $weeklyOvertime25Minutes + $weeklyOvertime50Minutes;
|
: $weeklyOvertimeTotalMinutes + $weeklyOvertime25Minutes + $weeklyOvertime50Minutes;
|
||||||
|
|
||||||
@@ -230,9 +244,9 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
|||||||
firstName: $employee->getFirstName(),
|
firstName: $employee->getFirstName(),
|
||||||
lastName: $employee->getLastName(),
|
lastName: $employee->getLastName(),
|
||||||
siteName: $employee->getSite()?->getName(),
|
siteName: $employee->getSite()?->getName(),
|
||||||
contractName: $employee->getContract()?->getName(),
|
contractName: $weekAnchorContract?->getName(),
|
||||||
contractType: $employee->getContract()?->getType()->value,
|
contractType: $weekAnchorContract?->getType()->value,
|
||||||
trackingMode: $employee->getContract()?->getTrackingMode(),
|
trackingMode: $weekAnchorContract?->getTrackingMode(),
|
||||||
daily: $daily,
|
daily: $daily,
|
||||||
weeklyDayMinutes: $weeklyDayMinutes,
|
weeklyDayMinutes: $weeklyDayMinutes,
|
||||||
weeklyNightMinutes: $weeklyNightMinutes,
|
weeklyNightMinutes: $weeklyNightMinutes,
|
||||||
@@ -344,25 +358,43 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
|||||||
return max(0, $end - $start);
|
return max(0, $end - $start);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function computeOvertimeTotalMinutes(int $weeklyTotalMinutes, ?int $contractWeeklyHours): int
|
/**
|
||||||
|
* @param array<string, ?Contract> $contractsByDate
|
||||||
|
*/
|
||||||
|
private function computeWeeklyOvertimeReferenceMinutes(array $days, array $contractsByDate): int
|
||||||
{
|
{
|
||||||
if (null === $contractWeeklyHours || $contractWeeklyHours <= 0) {
|
$total = 0;
|
||||||
return 0;
|
foreach ($days as $date) {
|
||||||
|
$isoDay = (int) new DateTimeImmutable($date)->format('N');
|
||||||
|
$contract = $contractsByDate[$date] ?? null;
|
||||||
|
$hours = $contract?->getWeeklyHours();
|
||||||
|
$referenceHours = (null !== $hours && $hours > 0) ? max(35, $hours) : null;
|
||||||
|
$total += $this->resolveDailyReferenceMinutes($referenceHours, $isoDay);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Règle métier: tout contrat < 35h est traité comme un 35h pour la base supp.
|
return $total;
|
||||||
$referenceHours = max(35, $contractWeeklyHours);
|
|
||||||
|
|
||||||
return max(0, $weeklyTotalMinutes - ($referenceHours * 60));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function computeOvertime25BonusMinutes(int $weeklyTotalMinutes, ?int $contractWeeklyHours): int
|
/**
|
||||||
|
* @param array<string, ?Contract> $contractsByDate
|
||||||
|
*/
|
||||||
|
private function computeWeeklyOvertime25StartMinutes(array $days, array $contractsByDate): int
|
||||||
{
|
{
|
||||||
// Règle métier:
|
$total = 0;
|
||||||
// - contrats <= 35h: 25% entre 35h et 43h
|
foreach ($days as $date) {
|
||||||
// - contrats >= 39h: 25% entre 39h et 43h
|
$isoDay = (int) new DateTimeImmutable($date)->format('N');
|
||||||
$startHours = (null !== $contractWeeklyHours && $contractWeeklyHours >= 39) ? 39 : 35;
|
$contract = $contractsByDate[$date] ?? null;
|
||||||
$trancheMinutes = max(0, min($weeklyTotalMinutes, 43 * 60) - ($startHours * 60));
|
$hours = $contract?->getWeeklyHours();
|
||||||
|
$startHours = (null !== $hours && $hours >= 39) ? 39 : 35;
|
||||||
|
$total += $this->resolveDailyReferenceMinutes($startHours, $isoDay);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $total;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function computeOvertime25BonusMinutes(int $weeklyTotalMinutes, int $startMinutes): int
|
||||||
|
{
|
||||||
|
$trancheMinutes = max(0, min($weeklyTotalMinutes, 43 * 60) - $startMinutes);
|
||||||
|
|
||||||
return (int) round($trancheMinutes * 0.25);
|
return (int) round($trancheMinutes * 0.25);
|
||||||
}
|
}
|
||||||
@@ -375,10 +407,9 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
|||||||
return (int) round($trancheMinutes * 0.5);
|
return (int) round($trancheMinutes * 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function hasDisabledOvertimeBonuses(Employee $employee): bool
|
private function hasDisabledOvertimeBonuses(?Contract $contract): bool
|
||||||
{
|
{
|
||||||
$contract = $employee->getContract();
|
$type = ContractType::resolve(
|
||||||
$type = ContractType::resolve(
|
|
||||||
$contract?->getName(),
|
$contract?->getName(),
|
||||||
$contract?->getTrackingMode(),
|
$contract?->getTrackingMode(),
|
||||||
$contract?->getWeeklyHours()
|
$contract?->getWeeklyHours()
|
||||||
@@ -386,4 +417,26 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
|||||||
|
|
||||||
return ContractType::INTERIM === $type;
|
return ContractType::INTERIM === $type;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function resolveDailyReferenceMinutes(?int $weeklyHours, int $isoWeekDay): int
|
||||||
|
{
|
||||||
|
// Week-end hors base de référence.
|
||||||
|
if ($isoWeekDay >= 6) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null === $weeklyHours || $weeklyHours <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (39 === $weeklyHours) {
|
||||||
|
return 5 === $isoWeekDay ? 7 * 60 : 8 * 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (35 === $weeklyHours) {
|
||||||
|
return 7 * 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) round(($weeklyHours * 60) / 5);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use App\Entity\Absence;
|
|||||||
use App\Entity\AbsenceType;
|
use App\Entity\AbsenceType;
|
||||||
use App\Entity\Contract;
|
use App\Entity\Contract;
|
||||||
use App\Entity\Employee;
|
use App\Entity\Employee;
|
||||||
|
use App\Service\Contracts\EmployeeContractResolver;
|
||||||
use App\Service\WorkHours\WorkedHoursCreditPolicy;
|
use App\Service\WorkHours\WorkedHoursCreditPolicy;
|
||||||
use DateTime;
|
use DateTime;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
@@ -19,7 +20,7 @@ final class WorkedHoursCreditPolicyTest extends TestCase
|
|||||||
{
|
{
|
||||||
public function testComputeCreditedMinutesFor35hHalfDay(): void
|
public function testComputeCreditedMinutesFor35hHalfDay(): void
|
||||||
{
|
{
|
||||||
$policy = new WorkedHoursCreditPolicy();
|
$policy = new WorkedHoursCreditPolicy($this->buildResolverStub());
|
||||||
$absence = $this->buildAbsence(trackMode: Contract::TRACKING_TIME, weeklyHours: 35, countAsWorked: true);
|
$absence = $this->buildAbsence(trackMode: Contract::TRACKING_TIME, weeklyHours: 35, countAsWorked: true);
|
||||||
|
|
||||||
$minutes = $policy->computeCreditedMinutes($absence, '2026-02-16', true, false);
|
$minutes = $policy->computeCreditedMinutes($absence, '2026-02-16', true, false);
|
||||||
@@ -29,7 +30,7 @@ final class WorkedHoursCreditPolicyTest extends TestCase
|
|||||||
|
|
||||||
public function testComputeCreditedMinutesFor4hContractFullDay(): void
|
public function testComputeCreditedMinutesFor4hContractFullDay(): void
|
||||||
{
|
{
|
||||||
$policy = new WorkedHoursCreditPolicy();
|
$policy = new WorkedHoursCreditPolicy($this->buildResolverStub());
|
||||||
$absence = $this->buildAbsence(trackMode: Contract::TRACKING_TIME, weeklyHours: 4, countAsWorked: true);
|
$absence = $this->buildAbsence(trackMode: Contract::TRACKING_TIME, weeklyHours: 4, countAsWorked: true);
|
||||||
|
|
||||||
$minutes = $policy->computeCreditedMinutes($absence, '2026-02-16', true, true);
|
$minutes = $policy->computeCreditedMinutes($absence, '2026-02-16', true, true);
|
||||||
@@ -39,21 +40,21 @@ final class WorkedHoursCreditPolicyTest extends TestCase
|
|||||||
|
|
||||||
public function testComputeCreditedPresenceUnitsForPresenceContract(): void
|
public function testComputeCreditedPresenceUnitsForPresenceContract(): void
|
||||||
{
|
{
|
||||||
$policy = new WorkedHoursCreditPolicy();
|
$policy = new WorkedHoursCreditPolicy($this->buildResolverStub());
|
||||||
$absence = $this->buildAbsence(trackMode: Contract::TRACKING_PRESENCE, weeklyHours: null, countAsWorked: true);
|
$absence = $this->buildAbsence(trackMode: Contract::TRACKING_PRESENCE, weeklyHours: null, countAsWorked: true);
|
||||||
|
|
||||||
$units = $policy->computeCreditedPresenceUnits($absence, true, false);
|
$units = $policy->computeCreditedPresenceUnits($absence, '2026-02-16', true, false);
|
||||||
|
|
||||||
self::assertSame(0.5, $units);
|
self::assertSame(0.5, $units);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testNoCreditWhenAbsenceTypeDoesNotCount(): void
|
public function testNoCreditWhenAbsenceTypeDoesNotCount(): void
|
||||||
{
|
{
|
||||||
$policy = new WorkedHoursCreditPolicy();
|
$policy = new WorkedHoursCreditPolicy($this->buildResolverStub());
|
||||||
$absence = $this->buildAbsence(trackMode: Contract::TRACKING_TIME, weeklyHours: 35, countAsWorked: false);
|
$absence = $this->buildAbsence(trackMode: Contract::TRACKING_TIME, weeklyHours: 35, countAsWorked: false);
|
||||||
|
|
||||||
self::assertSame(0, $policy->computeCreditedMinutes($absence, '2026-02-16', true, true));
|
self::assertSame(0, $policy->computeCreditedMinutes($absence, '2026-02-16', true, true));
|
||||||
self::assertSame(0.0, $policy->computeCreditedPresenceUnits($absence, true, true));
|
self::assertSame(0.0, $policy->computeCreditedPresenceUnits($absence, '2026-02-16', true, true));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function buildAbsence(string $trackMode, ?int $weeklyHours, bool $countAsWorked): Absence
|
private function buildAbsence(string $trackMode, ?int $weeklyHours, bool $countAsWorked): Absence
|
||||||
@@ -79,6 +80,18 @@ final class WorkedHoursCreditPolicyTest extends TestCase
|
|||||||
->setEmployee($employee)
|
->setEmployee($employee)
|
||||||
->setType($type)
|
->setType($type)
|
||||||
->setStartDate(new DateTime('2026-02-16'))
|
->setStartDate(new DateTime('2026-02-16'))
|
||||||
->setEndDate(new DateTime('2026-02-16'));
|
->setEndDate(new DateTime('2026-02-16'))
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildResolverStub(): EmployeeContractResolver
|
||||||
|
{
|
||||||
|
$resolver = $this->createStub(EmployeeContractResolver::class);
|
||||||
|
$resolver
|
||||||
|
->method('resolveForEmployeeAndDate')
|
||||||
|
->willReturnCallback(static fn (Employee $employee): ?Contract => $employee->getContract())
|
||||||
|
;
|
||||||
|
|
||||||
|
return $resolver;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use App\Entity\User;
|
|||||||
use App\Enum\HalfDay;
|
use App\Enum\HalfDay;
|
||||||
use App\Repository\Contract\AbsenceReadRepositoryInterface;
|
use App\Repository\Contract\AbsenceReadRepositoryInterface;
|
||||||
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
|
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
|
||||||
|
use App\Service\Contracts\EmployeeContractResolver;
|
||||||
use App\Service\WorkHours\AbsenceSegmentsResolver;
|
use App\Service\WorkHours\AbsenceSegmentsResolver;
|
||||||
use App\Service\WorkHours\WorkedHoursCreditPolicy;
|
use App\Service\WorkHours\WorkedHoursCreditPolicy;
|
||||||
use App\State\WorkHourDayContextProvider;
|
use App\State\WorkHourDayContextProvider;
|
||||||
@@ -53,7 +54,7 @@ final class WorkHourDayContextProviderTest extends TestCase
|
|||||||
$this->employeeRepository,
|
$this->employeeRepository,
|
||||||
$this->absenceRepository,
|
$this->absenceRepository,
|
||||||
new AbsenceSegmentsResolver(),
|
new AbsenceSegmentsResolver(),
|
||||||
new WorkedHoursCreditPolicy()
|
new WorkedHoursCreditPolicy($this->buildResolverStub())
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->expectException(AccessDeniedHttpException::class);
|
$this->expectException(AccessDeniedHttpException::class);
|
||||||
@@ -71,7 +72,7 @@ final class WorkHourDayContextProviderTest extends TestCase
|
|||||||
$this->employeeRepository,
|
$this->employeeRepository,
|
||||||
$this->absenceRepository,
|
$this->absenceRepository,
|
||||||
new AbsenceSegmentsResolver(),
|
new AbsenceSegmentsResolver(),
|
||||||
new WorkedHoursCreditPolicy()
|
new WorkedHoursCreditPolicy($this->buildResolverStub())
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->expectException(UnprocessableEntityHttpException::class);
|
$this->expectException(UnprocessableEntityHttpException::class);
|
||||||
@@ -95,7 +96,7 @@ final class WorkHourDayContextProviderTest extends TestCase
|
|||||||
$this->employeeRepository,
|
$this->employeeRepository,
|
||||||
$this->absenceRepository,
|
$this->absenceRepository,
|
||||||
new AbsenceSegmentsResolver(),
|
new AbsenceSegmentsResolver(),
|
||||||
new WorkedHoursCreditPolicy()
|
new WorkedHoursCreditPolicy($this->buildResolverStub())
|
||||||
);
|
);
|
||||||
|
|
||||||
$result = $provider->provide(new Get());
|
$result = $provider->provide(new Get());
|
||||||
@@ -151,4 +152,15 @@ final class WorkHourDayContextProviderTest extends TestCase
|
|||||||
$property->setAccessible(true);
|
$property->setAccessible(true);
|
||||||
$property->setValue($entity, $id);
|
$property->setValue($entity, $id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function buildResolverStub(): EmployeeContractResolver
|
||||||
|
{
|
||||||
|
$resolver = $this->createStub(EmployeeContractResolver::class);
|
||||||
|
$resolver
|
||||||
|
->method('resolveForEmployeeAndDate')
|
||||||
|
->willReturnCallback(static fn (Employee $employee): ?Contract => $employee->getContract())
|
||||||
|
;
|
||||||
|
|
||||||
|
return $resolver;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ use App\Enum\HalfDay;
|
|||||||
use App\Repository\Contract\AbsenceReadRepositoryInterface;
|
use App\Repository\Contract\AbsenceReadRepositoryInterface;
|
||||||
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
|
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
|
||||||
use App\Repository\Contract\WorkHourReadRepositoryInterface;
|
use App\Repository\Contract\WorkHourReadRepositoryInterface;
|
||||||
|
use App\Service\Contracts\EmployeeContractResolver;
|
||||||
use App\Service\WorkHours\AbsenceSegmentsResolver;
|
use App\Service\WorkHours\AbsenceSegmentsResolver;
|
||||||
use App\Service\WorkHours\WorkedHoursCreditPolicy;
|
use App\Service\WorkHours\WorkedHoursCreditPolicy;
|
||||||
use App\State\WorkHourWeeklySummaryProvider;
|
use App\State\WorkHourWeeklySummaryProvider;
|
||||||
@@ -58,7 +59,8 @@ final class WorkHourWeeklySummaryProviderTest extends TestCase
|
|||||||
$this->workHourRepository,
|
$this->workHourRepository,
|
||||||
$this->absenceRepository,
|
$this->absenceRepository,
|
||||||
new AbsenceSegmentsResolver(),
|
new AbsenceSegmentsResolver(),
|
||||||
new WorkedHoursCreditPolicy()
|
new WorkedHoursCreditPolicy($this->buildResolverStub()),
|
||||||
|
$this->buildResolverStub()
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->expectException(AccessDeniedHttpException::class);
|
$this->expectException(AccessDeniedHttpException::class);
|
||||||
@@ -117,7 +119,8 @@ final class WorkHourWeeklySummaryProviderTest extends TestCase
|
|||||||
$this->workHourRepository,
|
$this->workHourRepository,
|
||||||
$this->absenceRepository,
|
$this->absenceRepository,
|
||||||
new AbsenceSegmentsResolver(),
|
new AbsenceSegmentsResolver(),
|
||||||
new WorkedHoursCreditPolicy()
|
new WorkedHoursCreditPolicy($this->buildResolverStub()),
|
||||||
|
$this->buildWeeklyResolverStub($employees)
|
||||||
);
|
);
|
||||||
|
|
||||||
$result = $provider->provide(new Get());
|
$result = $provider->provide(new Get());
|
||||||
@@ -167,4 +170,50 @@ final class WorkHourWeeklySummaryProviderTest extends TestCase
|
|||||||
$property->setAccessible(true);
|
$property->setAccessible(true);
|
||||||
$property->setValue($entity, $id);
|
$property->setValue($entity, $id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function buildResolverStub(): EmployeeContractResolver
|
||||||
|
{
|
||||||
|
$resolver = $this->createStub(EmployeeContractResolver::class);
|
||||||
|
$resolver
|
||||||
|
->method('resolveForEmployeeAndDate')
|
||||||
|
->willReturnCallback(static fn (Employee $employee): ?Contract => $employee->getContract())
|
||||||
|
;
|
||||||
|
$resolver
|
||||||
|
->method('resolveForEmployeesAndDays')
|
||||||
|
->willReturn([])
|
||||||
|
;
|
||||||
|
|
||||||
|
return $resolver;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<Employee> $employees
|
||||||
|
*/
|
||||||
|
private function buildWeeklyResolverStub(array $employees): EmployeeContractResolver
|
||||||
|
{
|
||||||
|
$resolver = $this->createStub(EmployeeContractResolver::class);
|
||||||
|
$resolver
|
||||||
|
->method('resolveForEmployeeAndDate')
|
||||||
|
->willReturnCallback(static fn (Employee $employee): ?Contract => $employee->getContract())
|
||||||
|
;
|
||||||
|
$resolver
|
||||||
|
->method('resolveForEmployeesAndDays')
|
||||||
|
->willReturnCallback(static function (array $scopedEmployees, array $days): array {
|
||||||
|
$map = [];
|
||||||
|
foreach ($scopedEmployees as $employee) {
|
||||||
|
$employeeId = $employee->getId();
|
||||||
|
if (!$employeeId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach ($days as $day) {
|
||||||
|
$map[$employeeId][$day] = $employee->getContract();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $map;
|
||||||
|
})
|
||||||
|
;
|
||||||
|
|
||||||
|
return $resolver;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user