Louis Mazin 4db822aebe big maj
2026-10-05 17:52:28 +02:00

357 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Calcul unique des horaires prévus, partagé par la gestion et le récapitulatif."""
from copy import deepcopy
from datetime import date, datetime, time, timedelta
from math import lcm
from django.contrib.auth import get_user_model
from django.db import transaction
from rest_framework.exceptions import APIException, ValidationError
from .models import PlanningState, WorkSchedule, WorkScheduleSlot, ScheduleException, ExceptionSlot
class PlanningConflict(APIException):
status_code = 409
default_detail = 'Le planning a changé. Actualisez avant de réessayer.'
def lock_users(ids):
# Ordre stable pour un échange entre deux salariés (évite les interblocages).
list(get_user_model().objects.select_for_update().filter(pk__in=sorted(set(ids))).order_by('pk'))
def state_for(user_id):
return PlanningState.objects.get_or_create(user_id=user_id)[0]
def check_revision(user_id, expected):
state = state_for(user_id)
if state.revision != expected:
raise PlanningConflict()
return state
def bump_revision(user_id):
state = state_for(user_id)
state.revision += 1
state.save(update_fields=['revision'])
def slot_data(slot):
return dict(start_time=slot.start_time.strftime('%H:%M'), end_time=slot.end_time.strftime('%H:%M'), end_day_offset=slot.end_day_offset)
def slot_minutes(slot):
start_minutes = int(slot['start_time'][:2]) * 60 + int(slot['start_time'][3:5])
end_minutes = int(slot['end_time'][:2]) * 60 + int(slot['end_time'][3:5]) + int(slot.get('end_day_offset', 0)) * 1440
return end_minutes - start_minutes
def date_range(start, end):
while start <= end:
yield start
start += timedelta(days=1)
def load_plan(user_id):
return (
list(WorkSchedule.objects.filter(user_id=user_id).prefetch_related('slots')),
list(ScheduleException.objects.filter(user_id=user_id).prefetch_related('slots')),
)
def rule_matches(rule, day):
if day < rule.start_date or (rule.end_date and day > rule.end_date):
return False
if not rule.recurring:
return True
anchor = rule.start_date - timedelta(days=rule.start_date.weekday())
return day.weekday() in rule.weekdays and ((day - anchor).days // 7) % rule.every_weeks == 0
def schedule_week_index(schedule, day):
anchor = schedule.valid_from - timedelta(days=schedule.valid_from.weekday())
return ((day - anchor).days // 7) % schedule.cycle_weeks
def target_for_day(schedule, week_index, weekday):
row = next((item for item in schedule.day_targets if int(item.get('week_index', 0)) == week_index and int(item.get('weekday', -1)) == weekday), None)
return int(row.get('minutes', 0)) if row else 0
def target_for_week(schedule, week_index):
row = next((item for item in schedule.week_targets if int(item.get('week_index', -1)) == week_index), None)
return int(row.get('minutes', 0)) if row else 0
def effective_day(user_id, day, plan=None):
schedules, rules = plan if plan is not None else load_plan(user_id)
matches = [r for r in rules if rule_matches(r, day)]
# Ponctuel > récurrent > semaine type ; à priorité égale, dernière création.
rule = max(matches, key=lambda r: (not r.recurring, r.pk), default=None)
if rule:
source = 'exception'
week_target_minutes = None
if rule.kind == ScheduleException.Kind.WORK and rule.mode == ScheduleException.Mode.DAILY_HOURS:
target_minutes = int(rule.target_minutes or 0)
kind = 'work' if target_minutes > 0 else 'rest'
label = rule.label or ('Heures à effectuer' if target_minutes else 'Repos')
slots = []
schedule_mode = WorkSchedule.Mode.DAILY_HOURS
else:
kind = rule.kind
label = rule.label or rule.get_kind_display()
slots = [slot_data(s) for s in rule.slots.all()] if kind == ScheduleException.Kind.WORK else []
target_minutes = sum(slot_minutes(slot) for slot in slots) if slots else 0
schedule_mode = WorkSchedule.Mode.PRECISE
else:
schedule = next((s for s in schedules if s.valid_from <= day and (not s.valid_to or s.valid_to >= day)), None)
if not schedule:
return dict(
user_id=user_id,
date=day.isoformat(),
kind='undefined',
label='Emploi du temps non défini',
source='none',
schedule_mode='none',
target_minutes=None,
week_target_minutes=None,
slots=[],
)
source = 'schedule'
week_index = schedule_week_index(schedule, day)
if schedule.mode == WorkSchedule.Mode.DAILY_HOURS:
target_minutes = target_for_day(schedule, week_index, day.weekday())
week_target_minutes = sum(target_for_day(schedule, week_index, weekday) for weekday in range(7))
kind = 'work' if target_minutes > 0 else 'rest'
label = 'Heures à effectuer' if target_minutes else 'Repos'
slots = []
schedule_mode = WorkSchedule.Mode.DAILY_HOURS
elif schedule.mode == WorkSchedule.Mode.WEEKLY_HOURS:
week_target_minutes = target_for_week(schedule, week_index)
target_minutes = None
kind = 'weekly_target' if week_target_minutes > 0 else 'rest'
label = 'Total d’heures à effectuer cette semaine' if week_target_minutes > 0 else 'Repos'
slots = []
schedule_mode = WorkSchedule.Mode.WEEKLY_HOURS
else:
slots = [slot_data(s) for s in schedule.slots.all() if s.weekday == day.weekday() and s.week_index == week_index]
kind = 'work' if slots else 'rest'
label = 'Emploi du temps habituel' if slots else 'Repos'
target_minutes = sum(slot_minutes(slot) for slot in slots)
week_target_minutes = None
schedule_mode = WorkSchedule.Mode.PRECISE
return dict(
user_id=user_id,
date=day.isoformat(),
kind=kind,
label=label,
source=source,
schedule_mode=schedule_mode,
target_minutes=target_minutes,
week_target_minutes=week_target_minutes,
slots=slots,
)
def period(slot, day):
return (datetime.combine(day, time.fromisoformat(slot['start_time'])),
datetime.combine(day + timedelta(days=slot.get('end_day_offset', 0)), time.fromisoformat(slot['end_time'])))
def validate_calendar(user_id):
"""Vérifie une période complète de répétition entre chaque changement de règle.
Le PPCM combine le cycle actif et les exceptions récurrentes. On contrôle
aussi la jonction avec le jour précédent et le cycle suivant.
"""
plan = load_plan(user_id)
boundaries = set()
for schedule in plan[0]:
boundaries.add(schedule.valid_from)
if schedule.valid_to:
boundaries.add(schedule.valid_to + timedelta(days=1))
for rule in plan[1]:
boundaries.add(rule.start_date)
if rule.end_date:
boundaries.add(rule.end_date + timedelta(days=1))
days = set()
ordered = sorted(boundaries)
horizon = date(2100, 1, 1)
for index, boundary in enumerate(ordered):
following = ordered[index + 1] if index + 1 < len(ordered) else horizon
active = next((s for s in plan[0] if s.valid_from <= boundary and (not s.valid_to or s.valid_to >= boundary)), None)
cycle = active.cycle_weeks if active else 1
for rule in plan[1]:
if rule.recurring and rule.start_date <= boundary and (not rule.end_date or rule.end_date >= boundary):
cycle = lcm(cycle, rule.every_weeks)
finish = min(following, boundary + timedelta(weeks=cycle))
days.update(date_range(boundary - timedelta(days=1), finish))
cache = {}
def periods(day):
if day not in cache:
cache[day] = [period(s, day) for s in effective_day(user_id, day, plan)['slots']]
return cache[day]
for day in sorted(days):
intervals = sorted(periods(day) + periods(day + timedelta(days=1)))
for previous, current in zip(intervals, intervals[1:]):
if current[0] < previous[1]:
raise ValidationError({'slots': f'Chevauchement d’horaires le {current[0]:%d/%m/%Y}. Vérifiez aussi la veille et les horaires après minuit.'})
def effective_period(user_id, start, end):
plan = load_plan(user_id)
days = [effective_day(user_id, day, plan) for day in date_range(start, end)]
segments = []
# Une vacation commencée la veille peut continuer dans la période demandée.
for day in date_range(start - timedelta(days=1), end):
data = effective_day(user_id, day, plan)
for index, slot in enumerate(data['slots']):
begin, finish = period(slot, day)
cursor = begin
while cursor < finish:
boundary = datetime.combine(cursor.date() + timedelta(days=1), time.min)
stop = min(finish, boundary)
if start <= cursor.date() <= end:
segments.append(dict(
id=f'{user_id}-{day}-{index}-{cursor.date()}', date=cursor.date().isoformat(),
start_time=cursor.strftime('%H:%M'), end_time='24:00' if stop == boundary else stop.strftime('%H:%M'),
label=data['label'], source=data['source'], user_id=user_id,
))
cursor = stop
return dict(user_id=user_id, days=days, segments=segments)
@transaction.atomic
def save_schedule(actor, data):
user = data['user']
lock_users([user.pk])
check_revision(user.pk, data['expected_revision'])
start = data['valid_from']
schedule = WorkSchedule.objects.filter(user=user, valid_from=start).first()
if schedule is None:
previous = WorkSchedule.objects.filter(user=user, valid_from__lt=start).order_by('-valid_from').first()
following = WorkSchedule.objects.filter(user=user, valid_from__gt=start).order_by('valid_from').first()
if previous:
previous.valid_to = start - timedelta(days=1)
previous.save(update_fields=['valid_to', 'updated_at'])
schedule = WorkSchedule.objects.create(user=user, valid_from=start, valid_to=following.valid_from - timedelta(days=1) if following else None, created_by=actor)
schedule.slots.all().delete()
schedule.cycle_weeks = data.get('cycle_weeks', 1)
schedule.mode = data.get('mode', WorkSchedule.Mode.PRECISE)
schedule.day_targets = data.get('day_targets', [])
schedule.week_targets = data.get('week_targets', [])
WorkScheduleSlot.objects.bulk_create([WorkScheduleSlot(schedule=schedule, **slot) for slot in data['slots']])
schedule.save()
validate_calendar(user.pk)
bump_revision(user.pk)
return schedule
@transaction.atomic
def save_exception(actor, data, instance=None):
user = instance.user if instance else data['user']
lock_users([user.pk])
check_revision(user.pk, data['expected_revision'])
values = {key: data[key] for key in ['label', 'kind', 'start_date', 'end_date', 'recurring', 'weekdays', 'every_weeks']}
# Les changements créés manuellement dans la gestion utilisent des plages précises.
values['mode'] = ScheduleException.Mode.PRECISE
values['target_minutes'] = None
if instance:
instance = ScheduleException.objects.select_for_update().get(pk=instance.pk)
for key, value in values.items():
setattr(instance, key, value)
instance.save()
else:
instance = ScheduleException.objects.create(user=user, created_by=actor, **values)
instance.slots.all().delete()
ExceptionSlot.objects.bulk_create([ExceptionSlot(exception=instance, **slot) for slot in data['slots']])
validate_calendar(user.pk)
bump_revision(user.pk)
return instance
def snapshot(user_id, day):
result = effective_day(user_id, day)
# L'identité de la règle ne change pas le sens de la demande.
return {key: result[key] for key in ['user_id', 'date', 'kind', 'schedule_mode', 'target_minutes', 'week_target_minutes', 'slots']}
def request_changes(user, data):
def ensure_no_overlap(slots):
periods = []
for slot in slots:
start_minutes = int(slot['start_time'][:2]) * 60 + int(slot['start_time'][3:5])
end_minutes = start_minutes + slot_minutes(slot)
periods.append((start_minutes, end_minutes))
periods.sort()
if any(current[0] < previous[1] for previous, current in zip(periods, periods[1:])):
raise ValidationError({'proposed_slots': 'Les horaires ajoutés chevauchent des horaires déjà prévus ce jour-là.'})
def worked_minutes(row):
if row.get('schedule_mode') == WorkSchedule.Mode.DAILY_HOURS:
return int(row.get('target_minutes') or 0)
return sum(slot_minutes(slot) for slot in row.get('slots', []))
def swappable(row):
return row['kind'] not in ('absence', 'undefined', 'weekly_target')
before = [snapshot(user.pk, day) for day in date_range(data['start_date'], data['end_date'])]
if data['kind'] == 'swap':
source = before[0]
if not swappable(source):
raise ValidationError('Cette journée ne peut pas être échangée : elle doit être définie dans le planning.')
target_date = data.get('target_date')
if not target_date:
# L’employé choisit uniquement le collègue. Le responsable choisira
# la journée précise de ce collègue au moment de la validation.
return before, []
other = data['target_user']
target = snapshot(other.pk, target_date)
before.append(target)
if not all(swappable(row) for row in before):
raise ValidationError('Un échange nécessite deux journées définies, travaillées ou de repos.')
if not any(worked_minutes(row) > 0 for row in before):
raise ValidationError('Au moins une des deux journées doit être travaillée.')
after = deepcopy(before)
transferable = ['kind', 'slots', 'schedule_mode', 'target_minutes']
for key in transferable:
after[0][key] = deepcopy(before[1].get(key))
after[1][key] = deepcopy(before[0].get(key))
after[0]['week_target_minutes'] = None
after[1]['week_target_minutes'] = None
elif data['kind'] == 'move':
source = before[0]
target = snapshot(user.pk, data['target_date'])
before.append(target)
if source['kind'] != 'work' or worked_minutes(source) <= 0:
raise ValidationError('Choisissez une journée travaillée à déplacer.')
if target['kind'] in ('absence', 'undefined', 'weekly_target'):
raise ValidationError('Les heures ne peuvent pas être déplacées vers un jour en congé ou sans journée définie.')
if target.get('schedule_mode') == WorkSchedule.Mode.DAILY_HOURS and worked_minutes(target) > 0:
raise ValidationError('Le jour d’arrivée contient un nombre d’heures sans plage précise. Choisissez un jour de repos ou un jour avec des horaires précis.')
required = worked_minutes(source)
added = sum(slot_minutes(slot) for slot in data['proposed_slots'])
if required != added:
difference = required - added
raise ValidationError({'proposed_slots': f'Il reste {abs(difference)} minute(s) à {"ajouter" if difference > 0 else "retirer"}. Le total déplacé doit rester identique.'})
combined = [*target['slots'], *data['proposed_slots']]
if len(combined) > 10:
raise ValidationError({'proposed_slots': 'La journée d’arrivée ne peut pas contenir plus de 10 plages de travail.'})
ensure_no_overlap(combined)
after = deepcopy(before)
after[0].update(kind='rest', schedule_mode=WorkSchedule.Mode.PRECISE, target_minutes=0, week_target_minutes=None, slots=[])
after[1].update(kind='work', schedule_mode=WorkSchedule.Mode.PRECISE, target_minutes=sum(slot_minutes(slot) for slot in combined), week_target_minutes=None, slots=combined)
else:
after = deepcopy(before)
for row in after:
if data['kind'] == 'leave':
row.update(kind='absence', schedule_mode=WorkSchedule.Mode.PRECISE, target_minutes=0, week_target_minutes=None, slots=[])
else:
row.update(kind='work', schedule_mode=WorkSchedule.Mode.PRECISE, target_minutes=sum(slot_minutes(slot) for slot in data['proposed_slots']), week_target_minutes=None, slots=data['proposed_slots'])
return before, after