357 lines
16 KiB
Python
357 lines
16 KiB
Python
"""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
|