Compare commits

...

6 Commits

Author SHA1 Message Date
gitea-actions
b5e7395760 chore: bump version to v0.1.16
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m26s
2026-03-02 09:34:21 +00:00
380c72c242 fix : règle de calcule des heures travaillées sur les contrats Forfait
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-02 10:33:42 +01:00
gitea-actions
107417a571 chore: bump version to v0.1.15
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m16s
2026-02-27 09:22:05 +00:00
5ff7e356be fix : validation RH qui invalidé les sites
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-02-27 10:21:54 +01:00
gitea-actions
635e24e9e1 chore: bump version to v0.1.14
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m11s
2026-02-26 16:15:25 +00:00
4d90f2cb42 feat : ajout du nouveau système de contrat et ajout de filtre d'impression
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
2026-02-26 17:15:13 +01:00
28 changed files with 884 additions and 128 deletions

134
AGENTS.md
View File

@@ -1,6 +1,6 @@
# AGENTS.md
État des lieux opérationnel du projet SIRH (backend + frontend), à utiliser comme base sur les prochaines interventions.
État des lieux opérationnel du projet SIRH (backend + frontend), mis à jour après les évolutions sur heures/absences/validations.
## 1) Stack et structure
@@ -25,81 +25,107 @@ Arborescence clé:
### Contrats
- Entité: `Contract`
- Champs principaux: `name`, `trackingMode`, `weeklyHours`, `isActive`
- Champs principaux: `name`, `trackingMode`, `weeklyHours`, `isActive`, `type`
- `trackingMode`:
- `TIME`: suivi par heures
- `PRESENCE`: suivi présence demi-journées/journées
- `TIME`: suivi en heures
- `PRESENCE`: suivi en demi-journées/journées
- Enums backend:
- `App\Enum\TrackingMode`
- `App\Enum\ContractType` (`FORFAIT`, `35H`, `39H`, `INTERIM`, `CUSTOM`)
- `Contract::getType()` est exposé en API (`contract:read`, `employee:read`)
- `App\Enum\ContractType` (`FORFAIT`, `THIRTY_FIVE_HOURS`, `THIRTY_NINE_HOURS`, `INTERIM`, `CUSTOM`)
- Historique de contrat par employé:
- table `employee_contract_periods`
- résolu par `App\Service\Contracts\EmployeeContractResolver`
### Heures / absences
- Les absences sont découpées en enregistrements journaliers (pas de période unique stockée).
- Une ligne dheures validée est verrouillée côté métier.
- Règles de crédit absence (`countAsWorkedHours=true`) gérées dans `WorkedHoursCreditPolicy`:
- contrats présence: crédit en unités de présence
- contrats temps: crédit en minutes selon règles contrat (35h, 39h, 4h, fallback)
- Les absences sont stockées en **lignes journalières** (découpage automatique dans `AbsenceWriteProcessor`).
- Les absences `countAsWorkedHours=true` créditent:
- minutes (contrats TIME)
- unités de présence (contrats PRESENCE)
- Les absences AM/PM effacent les plages horaires concernées.
## 4) Écrans principaux
## 4) Validations (important)
### Page Heures (`frontend/pages/hours.vue`)
- Vue Jour + Vue Semaine (semaine réservée admin)
- Toolbar dédiée: `frontend/components/hours/HoursToolbar.vue`
- Vue jour: `frontend/components/hours/HoursDayView.vue`
- Vue semaine: `frontend/components/hours/HoursWeekView.vue`
- Logique page: `frontend/composables/useHoursPage.ts`
### Validation RH (admin)
- Champ: `work_hours.is_valid`
- Endpoint API Platform standard: `PATCH /api/work_hours/{id}`
- Gérée côté front par `updateWorkHourValidation`.
### Points UX déjà en place
- Toolbar semaine: raccourcis semaine précédente / actuelle / suivante
- Légende absences affichée dans la toolbar (admin + vue semaine)
- Cellules semaine avec absence: couleur du type dabsence (plus rouge fixe)
- Pour user non-admin: restrictions dédition selon validations/absences
### Validation site (chef de site)
- Champ: `work_hours.is_site_valid`
- Endpoint dédié: `PATCH /api/work_hours/{id}/site-validation`
- Processor: `src/State/WorkHourSiteValidationProcessor.php`
- Autorisé uniquement aux utilisateurs "Sites" (ni `ROLE_ADMIN`, ni `ROLE_SELF`) dans leur scope site.
## 5) API / calculs hebdo
### Règles de verrouillage
- `is_valid=true`: ligne verrouillée pour tout le monde (admin inclus pour saisie heures/absence; peut toujours décocher validation RH).
- `is_site_valid=true`:
- non-admin: ligne verrouillée (heures + absences)
- admin: ligne éditable
- Toute modification de ligne (heures/présence/absence) remet:
- `is_site_valid=false`
- `is_valid=false`
## 5) Page Heures (front)
- Page: `frontend/pages/hours.vue`
- Composable principal: `frontend/composables/useHoursPage.ts`
- Composants:
- `frontend/components/hours/HoursToolbar.vue`
- `frontend/components/hours/HoursDayView.vue`
- `frontend/components/hours/HoursWeekView.vue`
### Comportement par profil (vue jour)
- Admin:
- colonne RH avec checkbox
- badge `Site validé` affiché près du site
- Chef de site:
- colonne `Validation site` avec checkbox
- colonne RH en lecture (`Validé`/`-`)
- Employé:
- colonne `Validation site` en lecture
- colonne RH en lecture
## 6) Résumé hebdo / calculs
- Provider: `src/State/WorkHourWeeklySummaryProvider.php`
- DTOs:
- `src/Dto/WorkHours/WeeklySummaryRow.php`
- `src/Dto/WorkHours/WeeklyDaySummary.php`
- Le résumé hebdo renvoie notamment:
- `trackingMode`
- `contractName`
- `contractType`
- détails journaliers (jour/nuit/total, présence, absence label/couleur)
- Inclut: contrat résolu par jour, absences, crédits, jour/nuit/total, majorations, récup.
### Heures supp
- Règles métier:
- contrats <= 35h: tranche 25% de 35h à 43h, puis 50% au-delà
- contrats >= 39h: tranche 25% de 39h à 43h, puis 50% au-delà
- contrats `INTERIM`: pas de bonus 25/50 ni récup
Règles majorations:
- Contrats <= 35h: +25% de 35h à 43h, +50% au-delà
- Contrats >= 39h: +25% de 39h à 43h, +50% au-delà
- `INTERIM`: pas de 25% / 50% / récup
## 6) Conventions techniques
## 7) Migrations sensibles
- Favoriser DTO explicites plutôt que tableaux associatifs bruts.
- Utiliser les interfaces repository dans providers/processors testés.
- Centraliser les règles métier dans services/providers backend plutôt que dupliquer côté front.
- Front: éviter les calculs métier lourds; consommer les champs API déjà calculés.
- `migrations/Version20260226183000.php`
- ajoute `work_hours.is_site_valid BOOLEAN NOT NULL DEFAULT FALSE`
- non destructive (pas de perte de données)
## 7) Tests et qualité
## 8) Points de vigilance prod
- Les TU backend passent actuellement via `make test`.
- Le build frontend passe via `npm run build`.
- À chaque évolution métier:
- mettre à jour les tests provider/processor/service impactés
- maintenir la cohérence des DTO TypeScript (`frontend/services/dto/*`)
- Toujours exécuter migration avant déploiement code backend/front lié.
- Après déploiement backend, si route manquante côté runtime:
- `php bin/console cache:clear && php bin/console cache:warmup`
- Vérifier présence route:
- `/api/work_hours/{id}/site-validation` (PATCH)
## 8) Fichiers sensibles (à lire avant modif)
## 9) Conventions techniques
- Favoriser DTO explicites plutôt que tableaux associatifs.
- Garder règles métier dans backend (providers/processors/services), front orienté affichage/interaction.
- Maintenir alignement backend DTO PHP / frontend DTO TS (`frontend/services/dto/*`).
- Mettre à jour TU si signature constructor/service change.
## 10) Fichiers à lire avant modification
- `src/State/WorkHourBulkUpsertProcessor.php`
- `src/State/AbsenceWriteProcessor.php`
- `src/State/WorkHourSiteValidationProcessor.php`
- `src/State/WorkHourWeeklySummaryProvider.php`
- `src/Service/WorkHours/WorkedHoursCreditPolicy.php`
- `src/State/AbsenceWriteProcessor.php`
- `src/State/WorkHourBulkUpsertProcessor.php`
- `frontend/composables/useHoursPage.ts`
- `frontend/components/hours/HoursDayView.vue`
- `frontend/components/hours/HoursWeekView.vue`
## 9) Décisions de conception actuelles
- Les absences sont stockées par jour (facilite verrouillage/édition fine).
- Les règles de calcul (crédits, majorations, récup) sont portées côté backend.
- Le front reste centré sur laffichage/interaction et réutilise les données enrichies de lAPI.

View File

@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.13'
app.version: '0.1.16'

View File

@@ -54,6 +54,48 @@
</p>
</div>
<div class="space-y-2">
<p class="text-md font-semibold text-neutral-700">
Type de contrat <span class="text-red-600">*</span>
</p>
<div class="flex flex-wrap gap-4 rounded-md border border-neutral-300 px-3 py-2">
<div v-for="nature in contractNatures" :key="nature.value" class="flex items-center gap-2">
<label class="text-md" :for="`print-contract-nature-${nature.value}`">{{ nature.label }}</label>
<input
:id="`print-contract-nature-${nature.value}`"
v-model="printForm.contractNatures"
:value="nature.value"
type="checkbox"
class="h-4 w-4"
/>
</div>
</div>
<p v-if="showContractNaturesError" class="text-sm text-red-600">
Sélectionne au moins un type de contrat.
</p>
</div>
<div class="space-y-2">
<p class="text-md font-semibold text-neutral-700">
Temps de travail <span class="text-red-600">*</span>
</p>
<div class="flex flex-wrap gap-4 rounded-md border border-neutral-300 px-3 py-2">
<div v-for="workContract in workContracts" :key="workContract.id" class="flex items-center gap-2">
<label class="text-md" :for="`print-work-contract-${workContract.id}`">{{ workContract.name }}</label>
<input
:id="`print-work-contract-${workContract.id}`"
v-model="printForm.workContractIds"
:value="workContract.id"
type="checkbox"
class="h-4 w-4"
/>
</div>
</div>
<p v-if="showWorkContractsError" class="text-sm text-red-600">
Sélectionne au moins un temps de travail.
</p>
</div>
<div class="flex justify-end gap-3 pt-2">
<button
type="button"
@@ -84,13 +126,27 @@ type SiteOption = {
color: string
}
type ContractNatureOption = {
value: 'CDI' | 'CDD' | 'INTERIM'
label: string
}
type WorkContractOption = {
id: number
name: string
}
const props = defineProps<{
modelValue: boolean
sites: SiteOption[]
contractNatures: ContractNatureOption[]
workContracts: WorkContractOption[]
printForm: {
from: string
to: string
siteIds: number[]
contractNatures: Array<'CDI' | 'CDD' | 'INTERIM'>
workContractIds: number[]
}
}>()
@@ -110,19 +166,36 @@ const printForm = toRef(props, 'printForm')
const validationTouched = reactive({
from: false,
to: false,
sites: false
sites: false,
contractNatures: false,
workContracts: false
})
const isFromValid = computed(() => printForm.value.from.trim() !== '')
const isToValid = computed(() => printForm.value.to.trim() !== '')
const isSitesValid = computed(() => printForm.value.siteIds.length > 0)
const isContractNaturesValid = computed(() => {
if (props.contractNatures.length === 0) return true
return printForm.value.contractNatures.length > 0
})
const isWorkContractsValid = computed(() => {
if (props.workContracts.length === 0) return true
return printForm.value.workContractIds.length > 0
})
const isFormValid = computed(
() => isFromValid.value && isToValid.value && isSitesValid.value
() =>
isFromValid.value &&
isToValid.value &&
isSitesValid.value &&
isContractNaturesValid.value &&
isWorkContractsValid.value
)
const showFromError = computed(() => validationTouched.from && !isFromValid.value)
const showToError = computed(() => validationTouched.to && !isToValid.value)
const showSitesError = computed(() => validationTouched.sites && !isSitesValid.value)
const showContractNaturesError = computed(() => validationTouched.contractNatures && !isContractNaturesValid.value)
const showWorkContractsError = computed(() => validationTouched.workContracts && !isWorkContractsValid.value)
const baseInputClass =
'mt-2 w-full rounded-md border px-3 py-2 text-md text-neutral-900'
@@ -150,6 +223,8 @@ const handleSubmit = () => {
validationTouched.from = true
validationTouched.to = true
validationTouched.sites = true
validationTouched.contractNatures = true
validationTouched.workContracts = true
if (!isFormValid.value) return
emit('submit')
}
@@ -166,6 +241,8 @@ watch(
validationTouched.from = false
validationTouched.to = false
validationTouched.sites = false
validationTouched.contractNatures = false
validationTouched.workContracts = false
}
}
)

View File

@@ -52,14 +52,15 @@
</span>
</p>
</div>
<div class="pl-2 min-w-0 self-stretch flex flex-col justify-between py-0.5">
<p
class="w-full min-w-0 text-sm text-neutral-700 truncate"
:class="getRowAbsenceLabel(employee.id) ? '' : 'invisible'"
<div class="pl-2 min-w-0 self-stretch flex flex-col gap-1 justify-between py-0.5">
<p
class="w-full min-w-0 rounded-md px-2 py-1 text-xs truncate"
:class="getRowAbsenceLabel(employee.id) ? 'text-white' : 'invisible'"
:title="getRowAbsenceLabel(employee.id) || ''"
>
:style="getRowAbsenceStyle(employee.id)"
>
{{ getRowAbsenceLabel(employee.id) || '—' }}
</p>
</p>
<button
type="button"
class="self-start text-left text-xs font-semibold underline"
@@ -197,6 +198,7 @@ const props = defineProps<{
onToggleValidationBulk: (checked: boolean) => void
getRowMetrics: (employeeId: number) => { dayMinutes: number; nightMinutes: number; totalMinutes: number }
getRowAbsenceLabel: (employeeId: number) => string
getRowAbsenceStyle: (employeeId: number) => { backgroundColor: string } | undefined
getPresenceDayValue: (employeeId: number) => string
onAbsenceClick: (employeeId: number) => void
formatMinutes: (minutes: number) => string

View File

@@ -419,10 +419,19 @@ export const useHoursPage = () => {
return `${dayRow.absenceLabel} (journée)`
}
const getRowAbsenceStyle = (employeeId: number) => {
const dayRow = dayContextByEmployeeId.value.get(employeeId)
if (!dayRow?.absenceLabel) return undefined
return { backgroundColor: dayRow.absenceColor || '#dc2626' }
}
const getPresenceDayValue = (employeeId: number) => {
const row = rows.value[employeeId]
const basePresence = (row?.isPresentMorning ? 0.5 : 0) + (row?.isPresentAfternoon ? 0.5 : 0)
const creditedPresence = dayContextByEmployeeId.value.get(employeeId)?.creditedPresenceUnits ?? 0
const dayRow = dayContextByEmployeeId.value.get(employeeId)
const absentMorning = dayRow?.absentMorning ?? false
const absentAfternoon = dayRow?.absentAfternoon ?? false
const basePresence = ((row?.isPresentMorning && !absentMorning) ? 0.5 : 0) + ((row?.isPresentAfternoon && !absentAfternoon) ? 0.5 : 0)
const creditedPresence = dayRow?.creditedPresenceUnits ?? 0
const total = Math.min(1, basePresence + creditedPresence)
return Number.isInteger(total) ? String(total) : total.toFixed(1)
}
@@ -964,6 +973,7 @@ export const useHoursPage = () => {
toggleValidationBulk,
getRowMetrics,
getRowAbsenceLabel,
getRowAbsenceStyle,
getPresenceDayValue,
openAbsenceDrawer,
submitAbsence,

View File

@@ -86,6 +86,8 @@
<AbsencePrintDrawer
v-model="isPrintOpen"
:sites="sites"
:contract-natures="contractNatureOptions"
:work-contracts="workContractOptions"
:print-form="printForm"
@submit="handlePrint"
@cancel="closePrint"
@@ -221,7 +223,25 @@ const form = reactive({
const printForm = reactive({
from: '',
to: '',
siteIds: [] as number[]
siteIds: [] as number[],
contractNatures: [] as Array<'CDI' | 'CDD' | 'INTERIM'>,
workContractIds: [] as number[]
})
const contractNatureOptions = [
{ value: 'CDI' as const, label: 'CDI' },
{ value: 'CDD' as const, label: 'CDD' },
{ value: 'INTERIM' as const, label: 'Intérim' }
]
const workContractOptions = computed(() => {
const byId = new Map<number, { id: number; name: string }>()
for (const employee of employees.value) {
const contract = employee.contract
if (!contract?.id) continue
byId.set(contract.id, { id: contract.id, name: contract.name })
}
return Array.from(byId.values()).sort((a, b) => a.name.localeCompare(b.name, 'fr'))
})
// Remet le formulaire à zéro.
@@ -249,6 +269,8 @@ const openPrint = () => {
printForm.from = monthStart
printForm.to = monthEnd
printForm.siteIds = [...selectedSiteIds.value]
printForm.contractNatures = contractNatureOptions.map((item) => item.value)
printForm.workContractIds = workContractOptions.value.map((item) => item.id)
isPrintOpen.value = true
}
@@ -657,6 +679,12 @@ const handlePrint = async () => {
if (printForm.siteIds.length > 0) {
params.set('sites', printForm.siteIds.join(','))
}
if (printForm.contractNatures.length > 0) {
params.set('contractNatures', printForm.contractNatures.join(','))
}
if (printForm.workContractIds.length > 0) {
params.set('workContracts', printForm.workContractIds.join(','))
}
await printPdf(`/absences/print?${params.toString()}`)
isPrintOpen.value = false
}

View File

@@ -11,7 +11,7 @@
<button
type="button"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
@click="isDrawerOpen = true"
@click="openCreate"
>
Ajouter un employé
</button>
@@ -32,10 +32,11 @@
<div v-else class="flex-1 min-h-0 rounded-lg border border-neutral-200 bg-white overflow-hidden">
<div class="h-full overflow-auto">
<div class="min-w-[900px]">
<div class="grid grid-cols-[120px_1fr_1fr_220px_200px] gap-4 border-b border-neutral-200 bg-tertiary-500 px-6 py-3 text-md font-semibold text-neutral-700 sticky top-0 z-10">
<div class="grid grid-cols-[120px_1fr_1fr_180px_180px_200px] gap-4 border-b border-neutral-200 bg-tertiary-500 px-6 py-3 text-md font-semibold text-neutral-700 sticky top-0 z-10">
<span class="text-left">Prénom</span>
<span class="text-left">Nom</span>
<span class="text-left">Site</span>
<span class="text-left">Nature</span>
<span class="text-left">Contrat</span>
<span class="text-right">Actions</span>
</div>
@@ -46,7 +47,7 @@
<div
v-for="employee in filteredEmployees"
:key="employee.id"
class="grid grid-cols-[120px_1fr_1fr_220px_200px] items-center gap-4 border-b border-neutral-100 px-6 py-3 text-md text-neutral-800 last:border-b-0"
class="grid grid-cols-[120px_1fr_1fr_180px_180px_200px] items-center gap-4 border-b border-neutral-100 px-6 py-3 text-md text-neutral-800 last:border-b-0"
>
<span>{{ employee.firstName }}</span>
<span>{{ employee.lastName }}</span>
@@ -57,6 +58,7 @@
>
{{ employee.site?.name ?? '-' }}
</span>
<span>{{ contractNatureLabel(employee.currentContractNature) }}</span>
<span>{{ employee.contract?.name ?? '-' }}</span>
<div class="flex items-center justify-end gap-2">
<button
@@ -128,24 +130,72 @@
Le site est obligatoire.
</p>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="contract">
Contrat <span class="text-red-600">*</span>
</label>
<select
id="contract"
v-model="form.contractId"
:class="contractFieldClass"
>
<option value="">Sélectionner un contrat</option>
<option v-for="contract in contracts" :key="contract.id" :value="contract.id">
{{ contract.name }}
</option>
</select>
<p v-if="showContractError" class="mt-1 text-sm text-red-600">
Le contrat est obligatoire.
</p>
</div>
<template v-if="!editingEmployee">
<div>
<label class="text-md font-semibold text-neutral-700" for="contract-nature">
Type de contrat <span class="text-red-600">*</span>
</label>
<select
id="contract-nature"
v-model="form.contractNature"
:class="contractNatureFieldClass"
>
<option value="CDI">CDI</option>
<option value="CDD">CDD</option>
<option value="INTERIM">Intérim</option>
</select>
<p v-if="showContractNatureError" class="mt-1 text-sm text-red-600">
Le type de contrat est obligatoire.
</p>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="contract">
Temps de travail <span class="text-red-600">*</span>
</label>
<select
id="contract"
v-model="form.contractId"
:class="contractFieldClass"
>
<option value="">Sélectionner un contrat</option>
<option v-for="contract in contracts" :key="contract.id" :value="contract.id">
{{ contract.name }}
</option>
</select>
<p v-if="showContractError" class="mt-1 text-sm text-red-600">
Le temps de travail est obligatoire.
</p>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="contract-start-date">
Début contrat <span class="text-red-600">*</span>
</label>
<input
id="contract-start-date"
v-model="form.contractStartDate"
type="date"
:class="contractStartDateFieldClass"
/>
<p v-if="showContractStartDateError" class="mt-1 text-sm text-red-600">
La date de début est obligatoire.
</p>
</div>
<div v-if="requiresContractEndDate">
<label class="text-md font-semibold text-neutral-700" for="contract-end-date">
Fin contrat
<span v-if="requiresContractEndDate" class="text-red-600">*</span>
</label>
<input
id="contract-end-date"
v-model="form.contractEndDate"
type="date"
:class="contractEndDateFieldClass"
/>
<p v-if="showContractEndDateError" class="mt-1 text-sm text-red-600">
La date de fin est obligatoire pour un CDD ou un intérim.
</p>
</div>
</template>
<div class="flex justify-end gap-3 pt-2">
<button
type="button"
@@ -212,26 +262,54 @@ const filteredEmployees = computed(() => {
})
})
const contractNatureLabel = (value?: 'CDI' | 'CDD' | 'INTERIM') => {
if (value === 'CDD') return 'CDD'
if (value === 'INTERIM') return 'Intérim'
return 'CDI'
}
const form = reactive({
firstName: '',
lastName: '',
siteId: '' as number | '',
contractId: '' as number | ''
contractId: '' as number | '',
contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM',
contractStartDate: '',
contractEndDate: ''
})
const validationTouched = reactive({
firstName: false,
lastName: false,
siteId: false,
contractId: false
contractId: false,
contractNature: false,
contractStartDate: false,
contractEndDate: false
})
const isFirstNameValid = computed(() => form.firstName.trim() !== '')
const isLastNameValid = computed(() => form.lastName.trim() !== '')
const isSiteValid = computed(() => form.siteId !== '')
const isContractValid = computed(() => form.contractId !== '')
const isContractNatureValid = computed(() => ['CDI', 'CDD', 'INTERIM'].includes(form.contractNature))
const isContractStartDateValid = computed(() => form.contractStartDate !== '')
const requiresContractEndDate = computed(() => form.contractNature === 'CDD' || form.contractNature === 'INTERIM')
const isContractEndDateValid = computed(() => {
if (!requiresContractEndDate.value) return true
return form.contractEndDate !== ''
})
const isFormValid = computed(
() => isFirstNameValid.value && isLastNameValid.value && isSiteValid.value && isContractValid.value
() =>
isFirstNameValid.value &&
isLastNameValid.value &&
isSiteValid.value &&
(editingEmployee.value
? true
: (isContractValid.value &&
isContractNatureValid.value &&
isContractStartDateValid.value &&
isContractEndDateValid.value))
)
const showFirstNameError = computed(
@@ -246,6 +324,15 @@ const showSiteError = computed(
const showContractError = computed(
() => validationTouched.contractId && !isContractValid.value
)
const showContractNatureError = computed(
() => !editingEmployee.value && validationTouched.contractNature && !isContractNatureValid.value
)
const showContractStartDateError = computed(
() => !editingEmployee.value && validationTouched.contractStartDate && !isContractStartDateValid.value
)
const showContractEndDateError = computed(
() => !editingEmployee.value && validationTouched.contractEndDate && !isContractEndDateValid.value
)
const baseInputClass =
'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20'
@@ -277,6 +364,26 @@ const contractFieldClass = computed(() => {
}
return `${baseClass} border-neutral-300`
})
const contractNatureFieldClass = computed(() => {
const baseClass =
'mt-2 w-full rounded-md border bg-white px-3 py-2 text-md text-neutral-900'
if (showContractNatureError.value) {
return `${baseClass} border-red-500`
}
return `${baseClass} border-neutral-300`
})
const contractStartDateFieldClass = computed(() => {
if (showContractStartDateError.value) {
return `${baseInputClass} border-red-500`
}
return `${baseInputClass} border-neutral-300`
})
const contractEndDateFieldClass = computed(() => {
if (showContractEndDateError.value) {
return `${baseInputClass} border-red-500`
}
return `${baseInputClass} border-neutral-300`
})
const submitButtonClass = computed(() => {
if (isSubmitting.value || !isFormValid.value) {
@@ -304,6 +411,9 @@ const loadContracts = async () => {
onMounted(async () => {
await Promise.all([loadEmployees(), loadSites(), loadContracts()])
if (form.contractStartDate === '') {
form.contractStartDate = new Date().toISOString().slice(0, 10)
}
})
watch(sites, (nextSites) => {
@@ -324,7 +434,12 @@ const handleSubmit = async () => {
validationTouched.firstName = true
validationTouched.lastName = true
validationTouched.siteId = true
validationTouched.contractId = true
if (!editingEmployee.value) {
validationTouched.contractId = true
validationTouched.contractNature = true
validationTouched.contractStartDate = true
validationTouched.contractEndDate = true
}
if (!isFormValid.value) return
isSubmitting.value = true
@@ -334,14 +449,17 @@ const handleSubmit = async () => {
firstName: form.firstName,
lastName: form.lastName,
siteId: form.siteId === '' ? null : Number(form.siteId),
contractId: Number(form.contractId)
contractId: editingEmployee.value.contract?.id ?? Number(form.contractId)
})
} else {
await createEmployee({
firstName: form.firstName,
lastName: form.lastName,
siteId: form.siteId === '' ? null : Number(form.siteId),
contractId: Number(form.contractId)
contractId: Number(form.contractId),
contractNature: form.contractNature,
contractStartDate: form.contractStartDate,
contractEndDate: requiresContractEndDate.value ? form.contractEndDate : null
})
}
@@ -349,6 +467,9 @@ const handleSubmit = async () => {
form.lastName = ''
form.siteId = ''
form.contractId = ''
form.contractNature = 'CDI'
form.contractStartDate = new Date().toISOString().slice(0, 10)
form.contractEndDate = ''
editingEmployee.value = null
isDrawerOpen.value = false
await loadEmployees()
@@ -363,6 +484,15 @@ watch(isDrawerOpen, (isOpen) => {
validationTouched.lastName = false
validationTouched.siteId = false
validationTouched.contractId = false
validationTouched.contractNature = false
validationTouched.contractStartDate = false
validationTouched.contractEndDate = false
}
})
watch(requiresContractEndDate, (required) => {
if (!required) {
form.contractEndDate = ''
}
})
@@ -371,7 +501,18 @@ const openEdit = (employee: Employee) => {
form.firstName = employee.firstName
form.lastName = employee.lastName
form.siteId = employee.site?.id ?? ''
form.contractId = employee.contract?.id ?? ''
isDrawerOpen.value = true
}
const openCreate = () => {
editingEmployee.value = null
form.firstName = ''
form.lastName = ''
form.siteId = ''
form.contractId = ''
form.contractNature = 'CDI'
form.contractStartDate = new Date().toISOString().slice(0, 10)
form.contractEndDate = ''
isDrawerOpen.value = true
}

View File

@@ -61,6 +61,7 @@
:on-toggle-validation-bulk="toggleValidationBulk"
:get-row-metrics="getRowMetrics"
:get-row-absence-label="getRowAbsenceLabel"
:get-row-absence-style="getRowAbsenceStyle"
:get-presence-day-value="getPresenceDayValue"
:on-absence-click="openAbsenceDrawer"
:format-minutes="formatMinutes"
@@ -163,6 +164,7 @@ const {
toggleValidationBulk,
getRowMetrics,
getRowAbsenceLabel,
getRowAbsenceStyle,
getPresenceDayValue,
openAbsenceDrawer,
submitAbsence,

View File

@@ -7,5 +7,8 @@ export type Employee = {
lastName: string
site: Site
contract?: Contract | null
currentContractNature?: 'CDI' | 'CDD' | 'INTERIM'
currentContractStartDate?: string | null
currentContractEndDate?: string | null
displayOrder?: number
}

View File

@@ -70,6 +70,7 @@ export type WorkHourDayContextRow = {
employeeId: number
hasContractAtDate: boolean
absenceLabel?: string | null
absenceColor?: string | null
absenceHalf?: 'AM' | 'PM' | null
absentMorning: boolean
absentAfternoon: boolean

View File

@@ -26,13 +26,19 @@ export const createEmployee = async (payload: {
lastName: string
siteId?: number | null
contractId: number
contractNature?: 'CDI' | 'CDD' | 'INTERIM'
contractStartDate?: string
contractEndDate?: string | null
}) => {
const api = useApi()
return api.post<Employee>('/employees', {
firstName: payload.firstName,
lastName: payload.lastName,
site: payload.siteId ? `/api/sites/${payload.siteId}` : null,
contract: `/api/contracts/${payload.contractId}`
contract: `/api/contracts/${payload.contractId}`,
contractNature: payload.contractNature,
contractStartDate: payload.contractStartDate,
contractEndDate: payload.contractEndDate ?? null
}, {
toastSuccessKey: 'success.employee.create',
toastErrorKey: 'errors.employee.create'
@@ -46,6 +52,9 @@ export const updateEmployee = async (
lastName: string
siteId?: number | null
contractId: number
contractNature?: 'CDI' | 'CDD' | 'INTERIM'
contractStartDate?: string
contractEndDate?: string | null
displayOrder?: number
}
) => {
@@ -55,6 +64,9 @@ export const updateEmployee = async (
lastName: payload.lastName,
site: payload.siteId ? `/api/sites/${payload.siteId}` : null,
contract: `/api/contracts/${payload.contractId}`,
contractNature: payload.contractNature,
contractStartDate: payload.contractStartDate,
contractEndDate: payload.contractEndDate ?? null,
displayOrder: payload.displayOrder
}, {
toastSuccessKey: 'success.employee.update',

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260226203000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add contract nature to employee contract periods';
}
public function up(Schema $schema): void
{
$this->addSql("ALTER TABLE employee_contract_periods ADD contract_nature VARCHAR(20) DEFAULT 'CDI' NOT NULL");
$this->addSql("
UPDATE employee_contract_periods p
SET contract_nature = 'INTERIM'
FROM contracts c
WHERE p.contract_id = c.id
AND LOWER(c.name) LIKE '%interim%'
");
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE employee_contract_periods DROP contract_nature');
}
}

View File

@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
use Doctrine\Migrations\Exception\IrreversibleMigration;
final class Version20260226210000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Reassign legacy INTERIM contract to a TIME 35h contract and remove legacy INTERIM contract row';
}
public function up(Schema $schema): void
{
$this->addSql(
<<<'SQL'
DO $$
DECLARE
legacy_interim_contract_id INT;
target_time_35h_contract_id INT;
BEGIN
-- Contrat legacy "Intérim" (ancien modèle).
SELECT c.id
INTO legacy_interim_contract_id
FROM contracts c
WHERE LOWER(c.name) LIKE '%interim%'
ORDER BY c.id
LIMIT 1;
-- Si déjà supprimé, on ne fait rien.
IF legacy_interim_contract_id IS NULL THEN
RETURN;
END IF;
-- Contrat cible: suivi horaire 35h.
SELECT c.id
INTO target_time_35h_contract_id
FROM contracts c
WHERE c.tracking_mode = 'TIME'
AND c.weekly_hours = 35
AND c.id <> legacy_interim_contract_id
ORDER BY
CASE WHEN LOWER(c.name) = '35h' THEN 0 ELSE 1 END,
c.id
LIMIT 1;
IF target_time_35h_contract_id IS NULL THEN
RAISE EXCEPTION 'No TIME 35h contract found to replace legacy INTERIM contract id=%', legacy_interim_contract_id;
END IF;
-- Ré-assigne l'historique de périodes.
UPDATE employee_contract_periods
SET contract_id = target_time_35h_contract_id
WHERE contract_id = legacy_interim_contract_id;
-- Ré-assigne le pointeur actuel employé (compat legacy / affichage).
UPDATE employees
SET contract_id = target_time_35h_contract_id
WHERE contract_id = legacy_interim_contract_id;
-- Garde-fou FK avant suppression.
IF EXISTS (SELECT 1 FROM employee_contract_periods p WHERE p.contract_id = legacy_interim_contract_id)
OR EXISTS (SELECT 1 FROM employees e WHERE e.contract_id = legacy_interim_contract_id) THEN
RAISE EXCEPTION 'Legacy INTERIM contract id=% is still referenced', legacy_interim_contract_id;
END IF;
DELETE FROM contracts WHERE id = legacy_interim_contract_id;
END $$;
SQL
);
}
public function down(Schema $schema): void
{
throw new IrreversibleMigration('This migration performs data reassignment and contract deletion.');
}
}

View File

@@ -18,6 +18,8 @@ use App\State\AbsencePrintProvider;
new QueryParameter(key: 'from', required: true),
new QueryParameter(key: 'to', required: true),
new QueryParameter(key: 'sites', required: false),
new QueryParameter(key: 'contractNatures', required: false),
new QueryParameter(key: 'workContracts', required: false),
],
security: "is_granted('ROLE_ADMIN')"
),

View File

@@ -26,6 +26,7 @@ final class WorkHourDayContext
* @var list<array{
* employeeId:int,
* absenceLabel:?string,
* absenceColor:?string,
* absenceHalf:?string,
* absentMorning:bool,
* absentAfternoon:bool,

View File

@@ -10,6 +10,7 @@ final class DayContextRow
public int $employeeId,
public bool $hasContractAtDate = true,
public ?string $absenceLabel = null,
public ?string $absenceColor = null,
public ?string $absenceHalf = null,
public bool $absentMorning = false,
public bool $absentAfternoon = false,
@@ -19,6 +20,7 @@ final class DayContextRow
public function addAbsence(
?string $label,
?string $color,
bool $morning,
bool $afternoon,
int $creditedMinutes,
@@ -35,6 +37,14 @@ final class DayContextRow
$this->absenceLabel = 'Absences multiples';
}
// Si plusieurs types d'absence différents sont fusionnés sur la même journée,
// on retire la couleur métier spécifique.
if (null === $this->absenceColor) {
$this->absenceColor = $color;
} elseif ($color !== $this->absenceColor) {
$this->absenceColor = null;
}
// AM/PM seulement pour les demi-journées, null pour journée complète.
$this->absenceHalf = $this->resolveHalfLabel($this->absentMorning, $this->absentAfternoon);
// Cumule les minutes créditées par les absences "comptées comme travaillées".
@@ -48,6 +58,7 @@ final class DayContextRow
* employeeId:int,
* hasContractAtDate:bool,
* absenceLabel:?string,
* absenceColor:?string,
* absenceHalf:?string,
* absentMorning:bool,
* absentAfternoon:bool,
@@ -61,6 +72,7 @@ final class DayContextRow
'employeeId' => $this->employeeId,
'hasContractAtDate' => $this->hasContractAtDate,
'absenceLabel' => $this->absenceLabel,
'absenceColor' => $this->absenceColor,
'absenceHalf' => $this->absenceHalf,
'absentMorning' => $this->absentMorning,
'absentAfternoon' => $this->absentAfternoon,

View File

@@ -6,9 +6,12 @@ namespace App\Entity;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use App\Enum\ContractNature;
use App\Repository\EmployeeRepository;
use App\State\EmployeeWriteProcessor;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
@@ -56,9 +59,25 @@ class Employee
#[ORM\Column(type: 'datetime_immutable')]
private DateTimeImmutable $createdAt;
/**
* @var Collection<int, EmployeeContractPeriod>
*/
#[ORM\OneToMany(mappedBy: 'employee', targetEntity: EmployeeContractPeriod::class)]
private Collection $contractPeriods;
#[Groups(['employee:write'])]
private ?string $contractNature = null;
#[Groups(['employee:write'])]
private ?string $contractStartDate = null;
#[Groups(['employee:write'])]
private ?string $contractEndDate = null;
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
$this->createdAt = new DateTimeImmutable();
$this->contractPeriods = new ArrayCollection();
}
public function getId(): ?int
@@ -130,4 +149,81 @@ class Employee
return $this;
}
public function getContractNature(): ?string
{
return $this->contractNature;
}
public function setContractNature(?string $contractNature): self
{
$this->contractNature = $contractNature;
return $this;
}
public function getContractStartDate(): ?string
{
return $this->contractStartDate;
}
public function setContractStartDate(?string $contractStartDate): self
{
$this->contractStartDate = $contractStartDate;
return $this;
}
public function getContractEndDate(): ?string
{
return $this->contractEndDate;
}
public function setContractEndDate(?string $contractEndDate): self
{
$this->contractEndDate = $contractEndDate;
return $this;
}
#[Groups(['employee:read'])]
public function getCurrentContractNature(): string
{
return $this->resolveCurrentContractPeriod()?->getContractNatureEnum()->value ?? ContractNature::CDI->value;
}
#[Groups(['employee:read'])]
public function getCurrentContractStartDate(): ?string
{
return $this->resolveCurrentContractPeriod()?->getStartDate()->format('Y-m-d');
}
#[Groups(['employee:read'])]
public function getCurrentContractEndDate(): ?string
{
return $this->resolveCurrentContractPeriod()?->getEndDate()?->format('Y-m-d');
}
private function resolveCurrentContractPeriod(): ?EmployeeContractPeriod
{
$today = new DateTimeImmutable('today');
$current = null;
foreach ($this->contractPeriods as $period) {
if ($period->getStartDate() > $today) {
continue;
}
$endDate = $period->getEndDate();
if (null !== $endDate && $endDate < $today) {
continue;
}
if (null === $current || $period->getStartDate() > $current->getStartDate()) {
$current = $period;
}
}
return $current;
}
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Entity;
use App\Enum\ContractNature;
use App\Repository\EmployeeContractPeriodRepository;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
@@ -19,7 +20,7 @@ class EmployeeContractPeriod
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Employee::class)]
#[ORM\ManyToOne(targetEntity: Employee::class, inversedBy: 'contractPeriods')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private ?Employee $employee = null;
@@ -33,6 +34,9 @@ class EmployeeContractPeriod
#[ORM\Column(type: 'date_immutable', nullable: true)]
private ?DateTimeImmutable $endDate = null;
#[ORM\Column(type: 'string', length: 20, options: ['default' => ContractNature::CDI->value])]
private string $contractNature = ContractNature::CDI->value;
#[ORM\Column(type: 'datetime_immutable')]
private DateTimeImmutable $createdAt;
@@ -95,6 +99,24 @@ class EmployeeContractPeriod
return $this;
}
public function getContractNature(): string
{
return $this->contractNature;
}
public function getContractNatureEnum(): ContractNature
{
return ContractNature::tryFrom($this->contractNature) ?? ContractNature::CDI;
}
public function setContractNature(ContractNature|string $contractNature): self
{
$value = $contractNature instanceof ContractNature ? $contractNature->value : $contractNature;
$this->contractNature = ContractNature::tryFrom($value)?->value ?? ContractNature::CDI->value;
return $this;
}
public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Enum;
enum ContractNature: string
{
case CDI = 'CDI';
case CDD = 'CDD';
case INTERIM = 'INTERIM';
public function requiresEndDate(): bool
{
return self::CDD === $this || self::INTERIM === $this;
}
}

View File

@@ -85,6 +85,8 @@ final class EmployeeRepository extends ServiceEntityRepository implements Employ
$qb = $this->createQueryBuilder('e')
->leftJoin('e.site', 's')
->addSelect('s')
->leftJoin('e.contract', 'c')
->addSelect('c')
->orderBy('s.displayOrder', 'ASC')
->addOrderBy('s.name', 'ASC')
->addOrderBy('e.displayOrder', 'ASC')

View File

@@ -6,6 +6,7 @@ namespace App\Service\Contracts;
use App\Entity\Contract;
use App\Entity\Employee;
use App\Enum\ContractNature;
use App\Repository\EmployeeContractPeriodRepository;
use DateTimeImmutable;
@@ -22,6 +23,13 @@ readonly class EmployeeContractResolver
return $period?->getContract();
}
public function resolveNatureForEmployeeAndDate(Employee $employee, DateTimeImmutable $date): ContractNature
{
$period = $this->periodRepository->findOneCoveringDate($employee, $date);
return $period?->getContractNatureEnum() ?? ContractNature::CDI;
}
/**
* @param list<Employee> $employees
* @param list<string> $days
@@ -68,4 +76,51 @@ readonly class EmployeeContractResolver
return $resolved;
}
/**
* @param list<Employee> $employees
* @param list<string> $days
*
* @return array<int, array<string, ContractNature>>
*/
public function resolveNaturesForEmployeesAndDays(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] = ContractNature::CDI;
}
}
$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();
if (!$employeeId) {
continue;
}
$start = $period->getStartDate()->format('Y-m-d');
$end = $period->getEndDate()?->format('Y-m-d') ?? '9999-12-31';
$nature = $period->getContractNatureEnum();
foreach ($days as $day) {
if ($day < $start || $day > $end) {
continue;
}
$resolved[$employeeId][$day] = $nature;
}
}
return $resolved;
}
}

View File

@@ -60,11 +60,6 @@ final readonly class WorkedHoursCreditPolicy
bool $absentMorning,
bool $absentAfternoon
): float {
$type = $absence->getType();
if (!$type?->getCountAsWorkedHours()) {
return 0.0;
}
$employee = $absence->getEmployee();
if (null === $employee) {
return 0.0;
@@ -74,9 +69,14 @@ final readonly class WorkedHoursCreditPolicy
return 0.0;
}
$halfUnits = ($absentMorning ? 1 : 0) + ($absentAfternoon ? 1 : 0);
// Règle forfait:
// - demi-journée d'absence => 0.5 travaillé
// - journée complète d'absence => 0 travaillé
if ($absentMorning xor $absentAfternoon) {
return 0.5;
}
return $halfUnits * 0.5;
return 0.0;
}
public function resolveContractDayMinutes(?int $weeklyHours, int $isoWeekDay): int

View File

@@ -6,6 +6,7 @@ namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Enum\ContractNature;
use App\Enum\HalfDay;
use App\Repository\AbsenceRepository;
use App\Repository\EmployeeRepository;
@@ -53,9 +54,11 @@ class AbsencePrintProvider implements ProviderInterface
$fromDate = DateTimeImmutable::createFromFormat('Y-m-d', $from);
$toDate = DateTimeImmutable::createFromFormat('Y-m-d', $to);
$siteIds = $this->parseIds($request->query->get('sites'));
$siteIds = $this->parseIds($request->query->get('sites'));
$workContractIds = $this->parseIds($request->query->get('workContracts'));
$contractNatures = $this->parseContractNatures($request->query->get('contractNatures'));
$employees = $this->loadEmployees($siteIds);
$employees = $this->loadEmployees($siteIds, $contractNatures, $workContractIds);
$absences = $this->loadAbsences($fromDate, $toDate, $employees);
$days = $this->buildDays($fromDate, $toDate);
@@ -108,9 +111,19 @@ class AbsencePrintProvider implements ProviderInterface
return array_values(array_unique($ids));
}
private function loadEmployees(array $siteIds): array
private function loadEmployees(array $siteIds, array $contractNatures, array $workContractIds): array
{
return $this->employeeRepository->findForPrintBySiteIds($siteIds);
$employees = $this->employeeRepository->findForPrintBySiteIds($siteIds);
return array_values(array_filter($employees, static function ($employee) use ($contractNatures, $workContractIds): bool {
$employeeNature = (string) $employee->getCurrentContractNature();
$employeeContractId = $employee->getContract()?->getId();
$natureMatches = [] === $contractNatures || in_array($employeeNature, $contractNatures, true);
$contractMatches = [] === $workContractIds || (null !== $employeeContractId && in_array($employeeContractId, $workContractIds, true));
return $natureMatches && $contractMatches;
}));
}
private function loadAbsences(DateTimeImmutable $from, DateTimeImmutable $to, array $employees): array
@@ -209,4 +222,24 @@ class AbsencePrintProvider implements ProviderInterface
return $map;
}
/**
* @return list<string>
*/
private function parseContractNatures(?string $value): array
{
if (null === $value || '' === trim($value)) {
return [];
}
$values = [];
foreach (explode(',', $value) as $part) {
$nature = strtoupper(trim($part));
if (null !== ContractNature::tryFrom($nature)) {
$values[] = $nature;
}
}
return array_values(array_unique($values));
}
}

View File

@@ -10,10 +10,12 @@ use ApiPlatform\State\ProcessorInterface;
use App\Entity\Contract;
use App\Entity\Employee;
use App\Entity\EmployeeContractPeriod;
use App\Enum\ContractNature;
use App\Repository\EmployeeContractPeriodRepository;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
final readonly class EmployeeWriteProcessor implements ProcessorInterface
{
@@ -49,27 +51,46 @@ final readonly class EmployeeWriteProcessor implements ProcessorInterface
return $result;
}
$today = new DateTimeImmutable('today');
$today = new DateTimeImmutable('today');
$requestedContractNature = $this->resolveContractNature($data->getContractNature());
$requestedStartDate = $this->parseOptionalYmd($data->getContractStartDate(), 'contractStartDate');
$requestedEndDate = $this->parseOptionalYmd($data->getContractEndDate(), 'contractEndDate');
if ($isNew) {
$this->ensureContractPeriodExists($data, $currentContract, new DateTimeImmutable('1970-01-01'));
$startDate = $requestedStartDate ?? new DateTimeImmutable('1970-01-01');
$nature = $requestedContractNature ?? ContractNature::CDI;
$this->assertPeriodDates($startDate, $requestedEndDate, $nature);
$this->ensureContractPeriodExists($data, $currentContract, $startDate, $requestedEndDate, $nature);
return $result;
}
if ($this->isSameContract($previousContract, $currentContract)) {
$hasPeriodChangeRequest = null !== $requestedContractNature || null !== $requestedStartDate || null !== $requestedEndDate;
if ($this->isSameContract($previousContract, $currentContract) && !$hasPeriodChangeRequest) {
return $result;
}
$startDate = $requestedStartDate ?? $today;
$todayPeriod = $this->periodRepository->findOneCoveringDate($data, $today);
if (null !== $todayPeriod && null === $todayPeriod->getEndDate() && $todayPeriod->getStartDate()->format('Y-m-d') === $today->format('Y-m-d')) {
$nature = $requestedContractNature ?? $todayPeriod?->getContractNatureEnum() ?? ContractNature::CDI;
$endDate = $requestedEndDate;
$this->assertPeriodDates($startDate, $endDate, $nature);
if (
null !== $todayPeriod
&& null === $todayPeriod->getEndDate()
&& $todayPeriod->getStartDate()->format('Y-m-d') === $startDate->format('Y-m-d')
) {
$todayPeriod->setContract($currentContract);
$todayPeriod->setContractNature($nature);
$todayPeriod->setEndDate($endDate);
$this->entityManager->flush();
return $result;
}
$this->periodRepository->closeOpenPeriods($data, $today->modify('-1 day'));
$this->createPeriod($data, $currentContract, $today);
$this->periodRepository->closeOpenPeriods($data, $startDate->modify('-1 day'));
$this->createPeriod($data, $currentContract, $startDate, $endDate, $nature);
$this->entityManager->flush();
return $result;
@@ -96,26 +117,80 @@ final readonly class EmployeeWriteProcessor implements ProcessorInterface
return $first->getId() === $second->getId();
}
private function ensureContractPeriodExists(Employee $employee, Contract $contract, DateTimeImmutable $startDate): void
{
private function ensureContractPeriodExists(
Employee $employee,
Contract $contract,
DateTimeImmutable $startDate,
?DateTimeImmutable $endDate,
ContractNature $nature,
): void {
$covered = $this->periodRepository->findOneCoveringDate($employee, $startDate);
if (null !== $covered) {
return;
}
$this->createPeriod($employee, $contract, $startDate);
$this->createPeriod($employee, $contract, $startDate, $endDate, $nature);
$this->entityManager->flush();
}
private function createPeriod(Employee $employee, Contract $contract, DateTimeImmutable $startDate): void
{
private function createPeriod(
Employee $employee,
Contract $contract,
DateTimeImmutable $startDate,
?DateTimeImmutable $endDate,
ContractNature $nature,
): void {
$period = new EmployeeContractPeriod()
->setEmployee($employee)
->setContract($contract)
->setStartDate($startDate)
->setEndDate(null)
->setEndDate($endDate)
->setContractNature($nature)
;
$this->entityManager->persist($period);
}
private function resolveContractNature(?string $raw): ?ContractNature
{
if (null === $raw || '' === trim($raw)) {
return null;
}
return ContractNature::tryFrom(trim($raw))
?? throw new UnprocessableEntityHttpException('contractNature must be one of CDI, CDD, INTERIM.');
}
private function parseOptionalYmd(?string $raw, string $field): ?DateTimeImmutable
{
if (null === $raw || '' === trim($raw)) {
return null;
}
$value = trim($raw);
$date = DateTimeImmutable::createFromFormat('Y-m-d', $value);
if (!$date || $date->format('Y-m-d') !== $value) {
throw new UnprocessableEntityHttpException(sprintf('%s must use Y-m-d format.', $field));
}
return $date;
}
private function assertPeriodDates(
DateTimeImmutable $startDate,
?DateTimeImmutable $endDate,
ContractNature $nature
): void {
if (null !== $endDate && $endDate < $startDate) {
throw new UnprocessableEntityHttpException('contractEndDate cannot be before contractStartDate.');
}
if ($nature->requiresEndDate() && null === $endDate) {
throw new UnprocessableEntityHttpException('contractEndDate is required for CDD and INTERIM.');
}
if (ContractNature::CDI === $nature && null !== $endDate) {
throw new UnprocessableEntityHttpException('contractEndDate must be empty for CDI.');
}
}
}

View File

@@ -125,6 +125,14 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
continue;
}
// Si aucune donnée n'a changé, on ne touche pas la ligne:
// cela évite de perdre les validations existantes (site/RH) sur un simple enregistrement.
if (null !== $existing && $this->isSameAsExisting($existing, $normalized)) {
++$result->processed;
continue;
}
if ($this->isEntryEmpty($normalized)) {
// Convention choisie: une ligne vide supprime l'enregistrement existant.
if ($existing) {

View File

@@ -77,6 +77,7 @@ final readonly class WorkHourDayContextProvider implements ProviderInterface
$creditedPresenceUnits = $this->workedHoursCreditPolicy->computeCreditedPresenceUnits($absence, $dateKey, $absentMorning, $absentAfternoon);
$rowsByEmployeeId[$employeeId]->addAbsence(
label: $absence->getType()?->getLabel(),
color: $absence->getType()?->getColor(),
morning: $absentMorning,
afternoon: $absentAfternoon,
creditedMinutes: $creditedMinutes,

View File

@@ -15,6 +15,7 @@ use App\Entity\Contract;
use App\Entity\Employee;
use App\Entity\User;
use App\Entity\WorkHour;
use App\Enum\ContractNature;
use App\Enum\ContractType;
use App\Enum\TrackingMode;
use App\Repository\Contract\AbsenceReadRepositoryInterface;
@@ -113,8 +114,9 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
*/
private function buildRows(array $employees, array $workHours, array $absences, array $days, string $anchorDateYmd): array
{
$contractsByEmployeeDate = $this->contractResolver->resolveForEmployeesAndDays($employees, $days);
$metricsByEmployeeDate = [];
$contractsByEmployeeDate = $this->contractResolver->resolveForEmployeesAndDays($employees, $days);
$contractNaturesByEmployeeDate = $this->contractResolver->resolveNaturesForEmployeesAndDays($employees, $days);
$metricsByEmployeeDate = [];
foreach ($workHours as $workHour) {
$employeeId = $workHour->getEmployee()?->getId();
if (!$employeeId) {
@@ -133,6 +135,8 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
$creditedByEmployeeDate = [];
$creditedPresenceByEmployeeDate = [];
$absenceByEmployeeDate = [];
$absentMorningByEmployeeDate = [];
$absentAfternoonByEmployeeDate = [];
$absenceLabelByEmployeeDate = [];
$absenceColorByEmployeeDate = [];
foreach ($absences as $absence) {
@@ -151,7 +155,9 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
[$absentMorning, $absentAfternoon] = $this->absenceSegmentsResolver->resolveForDate($absence, $date);
if ($absentMorning || $absentAfternoon) {
$absenceByEmployeeDate[$employeeId][$date] = true;
$absenceByEmployeeDate[$employeeId][$date] = true;
$absentMorningByEmployeeDate[$employeeId][$date] = ($absentMorningByEmployeeDate[$employeeId][$date] ?? false) || $absentMorning;
$absentAfternoonByEmployeeDate[$employeeId][$date] = ($absentAfternoonByEmployeeDate[$employeeId][$date] ?? false) || $absentAfternoon;
if (!isset($absenceLabelByEmployeeDate[$employeeId][$date])) {
$absenceLabelByEmployeeDate[$employeeId][$date] = $absence->getType()?->getLabel();
}
@@ -182,6 +188,9 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
$weekAnchorContract = $contractsByEmployeeDate[$employeeId][$anchorDateYmd]
?? $contractsByEmployeeDate[$employeeId][$days[0]]
?? null;
$weekAnchorContractNature = $contractNaturesByEmployeeDate[$employeeId][$anchorDateYmd]
?? $contractNaturesByEmployeeDate[$employeeId][$days[0]]
?? ContractNature::CDI;
$employeeContractsByDate = [];
foreach ($days as $date) {
$employeeContractsByDate[$date] = $contractsByEmployeeDate[$employeeId][$date] ?? null;
@@ -197,8 +206,10 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
$metrics->addCreditedMinutes($creditedMinutes);
$present = null;
if ($isPresenceTracking) {
$morning = ($entry['isPresentMorning'] ?? false) ? 0.5 : 0.0;
$afternoon = ($entry['isPresentAfternoon'] ?? false) ? 0.5 : 0.0;
$absentMorning = $absentMorningByEmployeeDate[$employeeId][$date] ?? false;
$absentAfternoon = $absentAfternoonByEmployeeDate[$employeeId][$date] ?? false;
$morning = (($entry['isPresentMorning'] ?? false) && !$absentMorning) ? 0.5 : 0.0;
$afternoon = (($entry['isPresentAfternoon'] ?? false) && !$absentAfternoon) ? 0.5 : 0.0;
$creditedPresence = $creditedPresenceByEmployeeDate[$employeeId][$date] ?? 0.0;
$present = min(1.0, $morning + $afternoon + $creditedPresence);
}
@@ -223,7 +234,7 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
}
$isWeekPresenceTracking = TrackingMode::PRESENCE->value === $weekAnchorContract?->getTrackingMode();
$disableOvertimeBonuses = $this->hasDisabledOvertimeBonuses($weekAnchorContract);
$disableOvertimeBonuses = $this->hasDisabledOvertimeBonuses($weekAnchorContract, $weekAnchorContractNature);
$overtimeReferenceMinutes = $this->computeWeeklyOvertimeReferenceMinutes($days, $employeeContractsByDate);
$overtime25StartMinutes = $this->computeWeeklyOvertime25StartMinutes($days, $employeeContractsByDate);
$weeklyOvertimeTotalMinutes = $isWeekPresenceTracking
@@ -407,8 +418,12 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
return (int) round($trancheMinutes * 0.5);
}
private function hasDisabledOvertimeBonuses(?Contract $contract): bool
private function hasDisabledOvertimeBonuses(?Contract $contract, ContractNature $contractNature): bool
{
if (ContractNature::INTERIM === $contractNature) {
return true;
}
$type = ContractType::resolve(
$contract?->getName(),
$contract?->getTrackingMode(),

View File

@@ -135,7 +135,7 @@ final class WorkHourWeeklySummaryProviderTest extends TestCase
self::assertSame(210, $result->rows[0]->weeklyOvertime50Minutes);
self::assertSame(1230, $result->rows[0]->weeklyRecoveryMinutes);
self::assertSame(1.0, $result->rows[1]->weeklyPresenceCount);
self::assertSame(0.0, $result->rows[1]->weeklyPresenceCount);
self::assertTrue($result->rows[1]->daily[0]->hasAbsence);
self::assertSame('Congé', $result->rows[1]->daily[0]->absenceLabel);
self::assertSame('#000', $result->rows[1]->daily[0]->absenceColor);