loko/loko/contracts/services_teams.py

575 lines
20 KiB
Python

"""
Service de gestion temporelle et de planification des équipes.
Gère les appartenances continues, l'historique d'équipe, les absences journalières et la duplication.
"""
import calendar
from collections import defaultdict
from datetime import timedelta, date
from typing import Optional, List, Dict, Set, Any
from django.db import transaction
from django.db.models import Q
from django.utils import timezone
from contracts.models import (
CompanyMember,
CompanyTeam,
CompanyMemberHistory,
CompanyMemberAbsence,
)
def get_member_teams_for_date(member: CompanyMember, target_date: date) -> List[CompanyTeam]:
"""
Retourne la liste des équipes auxquelles appartient le membre à la date donnée (selon CompanyMemberHistory).
Retourne [] si le membre n'est dans aucune équipe ce jour-là.
Prend en compte la continuité (report théorique si leave_date is None).
"""
histories = list(
CompanyMemberHistory.objects
.filter(member=member, join_date__lte=target_date)
.filter(Q(leave_date__isnull=True) | Q(leave_date__gte=target_date))
.select_related('team')
.order_by('-join_date', '-id')
)
if not histories:
return []
teams = []
seen_team_ids = set()
for h in histories:
if h.team and h.team_id not in seen_team_ids:
seen_team_ids.add(h.team_id)
teams.append(h.team)
return teams
def get_member_team_for_date(member: CompanyMember, target_date: date) -> Optional[CompanyTeam]:
"""
Retourne la première équipe active à laquelle appartient le membre à la date donnée.
Conservé pour rétro-compatibilité. Utiliser get_member_teams_for_date pour le support multi-équipes.
"""
teams = get_member_teams_for_date(member, target_date)
return teams[0] if teams else None
@transaction.atomic
def set_member_team_schedule(
member: CompanyMember,
team_id: Optional[int],
target_date: date,
changed_by=None,
sync_current: bool = True,
action: str = 'move',
unassign_team_id: Optional[int] = None,
) -> Optional[CompanyMemberHistory]:
"""
Planifie l'appartenance d'un membre à une équipe à partir de `target_date`.
- Si team_id is None :
- si unassign_team_id est fourni : désassigne uniquement de cette équipe à target_date.
- sinon : désassigne de toutes les équipes à target_date.
- Si team_id is not None :
- action='move' (défaut) : clôture/retire les autres équipes à target_date et affecte team_id.
- action='add' : ajoute team_id sans impacter les autres équipes existantes (multi-équipes).
"""
today = timezone.now().date()
if team_id is None:
# Désassignation
if unassign_team_id is not None:
# Désassignation ciblée d'une seule équipe
if target_date > today:
CompanyMemberHistory.objects.filter(
member=member,
team_id=unassign_team_id,
join_date=target_date,
).delete()
else:
CompanyMemberHistory.objects.filter(
member=member,
team_id=unassign_team_id,
join_date=target_date,
).delete()
prev_leave = target_date - timedelta(days=1)
CompanyMemberHistory.objects.filter(
member=member,
team_id=unassign_team_id,
join_date__lt=target_date,
).filter(
Q(leave_date__isnull=True) | Q(leave_date__gte=target_date)
).update(leave_date=prev_leave)
if target_date <= today:
member.teams.remove(unassign_team_id)
else:
# Désassignation globale de toutes les équipes
if target_date > today:
CompanyMemberHistory.objects.filter(
member=member,
join_date=target_date,
).delete()
else:
CompanyMemberHistory.objects.filter(
member=member,
join_date=target_date,
).delete()
prev_leave = target_date - timedelta(days=1)
CompanyMemberHistory.objects.filter(
member=member,
join_date__lt=target_date,
).filter(
Q(leave_date__isnull=True) | Q(leave_date__gte=target_date)
).update(leave_date=prev_leave)
if target_date <= today:
member.teams.clear()
_sync_teams_if_needed(member, target_date, today, sync_current)
return None
# team_id is not None
if action == 'move':
# En mode 'move', on remplace/clôture les affectations des autres équipes
if target_date > today:
CompanyMemberHistory.objects.filter(
member=member,
join_date=target_date,
).exclude(team_id=team_id).delete()
else:
CompanyMemberHistory.objects.filter(
member=member,
join_date=target_date,
).exclude(team_id=team_id).delete()
prev_leave = target_date - timedelta(days=1)
CompanyMemberHistory.objects.filter(
member=member,
join_date__lt=target_date,
).exclude(team_id=team_id).filter(
Q(leave_date__isnull=True) | Q(leave_date__gte=target_date)
).update(leave_date=prev_leave)
# Trouver le prochain jalon futur pour cette équipe
next_hist = (
CompanyMemberHistory.objects
.filter(member=member, team_id=team_id, join_date__gt=target_date)
.order_by('join_date')
.first()
)
next_leave_bound = (next_hist.join_date - timedelta(days=1)) if next_hist else None
# Vérifier s'il existe déjà un enregistrement pour cette équipe débutant à target_date
existing_hists = list(
CompanyMemberHistory.objects.filter(
member=member,
team_id=team_id,
join_date=target_date,
).order_by('id')
)
if existing_hists:
new_history = existing_hists[0]
# Nettoyer les doublons éventuels
if len(existing_hists) > 1:
for extra in existing_hists[1:]:
extra.delete()
# S'assurer que leave_date est cohérent avec le prochain jalon futur
if new_history.leave_date != next_leave_bound:
new_history.leave_date = next_leave_bound
new_history.save(update_fields=['leave_date'])
else:
new_history = CompanyMemberHistory.objects.create(
member=member,
team_id=team_id,
join_date=target_date,
leave_date=next_leave_bound,
changed_by=changed_by,
)
_sync_teams_if_needed(member, target_date, today, sync_current)
return new_history
@transaction.atomic
def set_member_team_period_schedule(
member: CompanyMember,
team_id: Optional[int],
start_date: date,
end_date: date,
action: str = 'move',
unassign_team_id: Optional[int] = None,
changed_by=None,
weekdays_only: bool = False,
) -> int:
"""
Affecte ou désaffecte un membre sur une plage de dates continue [start_date, end_date].
Permet de définir des semaines complètes ou des mois entiers en une seule opération.
Ne modifie pas les jours passés (< aujourd'hui).
Retourne le nombre de jours mis à jour.
"""
today = timezone.now().date()
num_days = (end_date - start_date).days + 1
updated_count = 0
for i in range(num_days):
d = start_date + timedelta(days=i)
if d < today:
continue
if weekdays_only and d.weekday() >= 5:
continue
set_member_team_schedule(
member=member,
team_id=team_id,
target_date=d,
changed_by=changed_by,
sync_current=False,
action=action,
unassign_team_id=unassign_team_id,
)
updated_count += 1
if start_date <= today <= end_date:
sync_member_current_teams(member, today)
return updated_count
def _sync_teams_if_needed(member: CompanyMember, target_date: date, today: date, sync_current: bool):
"""Synchronise member.teams si la date modifiée impacte la date d'aujourd'hui."""
if sync_current and target_date <= today:
sync_member_current_teams(member, today)
def sync_member_current_teams(member: CompanyMember, for_date: Optional[date] = None):
"""
Synchronise le champ ManyToMany member.teams avec l'ensemble des équipes actives
dans CompanyMemberHistory pour la date spécifiée (par défaut aujourd'hui).
"""
if for_date is None:
for_date = timezone.now().date()
has_history = CompanyMemberHistory.objects.filter(member=member).exists()
if not has_history:
current_teams = list(member.teams.all())
if current_teams:
for t in current_teams:
CompanyMemberHistory.objects.get_or_create(
member=member,
team=t,
leave_date__isnull=True,
defaults={'join_date': for_date}
)
return
active_teams = get_member_teams_for_date(member, for_date)
current_team_ids = set(member.teams.values_list('id', flat=True))
target_team_ids = {t.id for t in active_teams}
if current_team_ids != target_team_ids:
member.teams.set(list(target_team_ids))
def sync_all_members_current_teams(for_date: Optional[date] = None) -> int:
"""
Synchronise le champ ManyToMany member.teams pour tous les membres
selon les équipes actives dans CompanyMemberHistory à la date donnée (aujourd'hui par défaut).
Retourne le nombre de membres synchronisés.
"""
if for_date is None:
for_date = timezone.now().date()
members = CompanyMember.objects.all().prefetch_related('teams')
count = 0
for m in members:
sync_member_current_teams(m, for_date)
count += 1
return count
def get_teams_composition_for_period(
team_ids: Set[int],
start_date: date,
end_date: date,
) -> Dict[tuple, List[Dict[str, Any]]]:
"""
Calcule la composition journalière de chaque équipe sur une période donnée [start_date, end_date].
- Pour aujourd'hui et les jours passés : utilise les affectations actives courantes et historiques.
- Pour les jours futurs : n'affiche que les journées qui ont été explicitement planifiées ou dupliquées (join_date == d).
Prend en charge la multi-appartenance (un membre peut être dans plusieurs équipes le même jour).
Retourne un dict: (team_id, date) -> [{'id': member_id, 'name': member_name, 'user_id': user_id, 'is_multi_team': bool, 'other_teams': [{'id': t_id, 'name': t_name}]}]
"""
today = timezone.now().date()
histories = (
CompanyMemberHistory.objects
.filter(
team_id__in=team_ids,
join_date__lte=end_date,
)
.filter(
Q(leave_date__isnull=True) | Q(leave_date__gte=start_date)
)
.select_related('member', 'member__user')
)
# Récupérer les rôles et informations utiles
member_histories = defaultdict(lambda: {'member': None, 'histories': []})
for h in histories:
member_histories[h.member_id]['member'] = h.member
member_histories[h.member_id]['histories'].append({
'id': h.id,
'team_id': h.team_id,
'join_date': h.join_date,
'leave_date': h.leave_date,
})
num_days = (end_date - start_date).days + 1
team_compositions_sets = defaultdict(set)
member_teams_by_day = defaultdict(lambda: defaultdict(set))
teams_map = {t.id: t.name for t in CompanyTeam.objects.filter(id__in=team_ids)}
for i in range(num_days):
d = start_date + timedelta(days=i)
for m_id, m_data in member_histories.items():
member = m_data['member']
# Trouver les historiques actifs à la date d
active = []
for h in m_data['histories']:
if d <= today:
if h['join_date'] <= d and (h['leave_date'] is None or h['leave_date'] >= d):
active.append(h)
else:
# Pour les jours futurs : affiché uniquement si explicitement planifié pour ce jour (join_date == d)
if h['join_date'] == d:
active.append(h)
if not active:
continue
# Grouper par équipe pour éliminer les doublons au sein d'une même équipe
active_by_team = defaultdict(list)
for h in active:
active_by_team[h['team_id']].append(h)
for t_id in active_by_team.keys():
team_compositions_sets[(t_id, d)].add(
(member.id, member.name, getattr(member.user, 'id', None))
)
member_teams_by_day[d][member.id].add(t_id)
result = defaultdict(list)
for (t_id, d), m_set in team_compositions_sets.items():
sorted_members = sorted(list(m_set), key=lambda x: x[1])
day_members = []
for mid, mname, uid in sorted_members:
all_teams_for_m = member_teams_by_day[d].get(mid, set())
is_multi = len(all_teams_for_m) > 1
other_teams = [
{'id': other_id, 'name': teams_map.get(other_id, '')}
for other_id in all_teams_for_m
if other_id != t_id
]
day_members.append({
'id': mid,
'name': mname,
'user_id': uid,
'is_multi_team': is_multi,
'other_teams': other_teams,
})
result[(t_id, d)] = day_members
return result
def get_member_absences_for_period(
member_ids: Optional[Set[int]],
start_date: date,
end_date: date,
) -> Dict[tuple, Dict[str, Any]]:
"""
Récupère toutes les absences sur la période [start_date, end_date].
Retourne un dict: (member_id, date) -> {'reason': str, 'reason_display': str, 'comment': str}
"""
qs = CompanyMemberAbsence.objects.filter(
date__range=(start_date, end_date)
)
if member_ids is not None:
qs = qs.filter(member_id__in=member_ids)
absences = {}
for a in qs:
absences[(a.member_id, a.date)] = {
'id': a.id,
'reason': a.reason,
'reason_display': a.get_reason_display(),
'comment': a.comment,
}
return absences
@transaction.atomic
def toggle_member_absence(
member: CompanyMember,
target_date: date,
is_absent: bool = True,
reason: str = 'conge',
comment: str = '',
created_by=None,
) -> Optional[CompanyMemberAbsence]:
"""Active ou désactive l'absence d'un membre pour une date donnée."""
if is_absent:
obj, _ = CompanyMemberAbsence.objects.update_or_create(
member=member,
date=target_date,
defaults={
'reason': reason or 'conge',
'comment': comment or '',
'created_by': created_by,
}
)
return obj
else:
CompanyMemberAbsence.objects.filter(member=member, date=target_date).delete()
return None
@transaction.atomic
def duplicate_day_schedule(
source_date: date,
target_date: date,
allowed_team_ids: Optional[Set[int]] = None,
copy_absences: bool = False,
changed_by=None,
) -> Dict[str, int]:
"""
Duplique l'affectation des équipes d'une journée source vers une journée cible.
Si allowed_team_ids est fourni, seules les équipes autorisées sont dupliquées.
"""
# 1. Trouver tous les membres et leur équipe sur source_date
all_members = CompanyMember.objects.all()
if allowed_team_ids is not None:
# Membres qui étaient dans les équipes autorisées ou non assignés
pass
assigned_count = 0
absences_count = 0
for member in all_members:
source_teams = get_member_teams_for_date(member, source_date)
if allowed_team_ids is not None:
source_teams = [t for t in source_teams if t.id in allowed_team_ids]
if source_teams:
for idx, team in enumerate(source_teams):
# La première équipe réinitialise les anciennes affectations, les suivantes s'ajoutent
action = 'move' if idx == 0 else 'add'
set_member_team_schedule(
member=member,
team_id=team.id,
target_date=target_date,
changed_by=changed_by,
sync_current=False,
action=action,
)
assigned_count += 1
else:
# Membre non assigné le jour source : le désassigner aussi sur le jour cible s'il était dans une équipe autorisée
target_teams = get_member_teams_for_date(member, target_date)
if any(t.id in allowed_team_ids for t in target_teams) if allowed_team_ids else bool(target_teams):
set_member_team_schedule(
member=member,
team_id=None,
target_date=target_date,
changed_by=changed_by,
sync_current=False,
)
if copy_absences:
source_absences = CompanyMemberAbsence.objects.filter(date=source_date)
if allowed_team_ids is not None:
allowed_member_ids = {
m.id for m in all_members
if any(t.id in allowed_team_ids for t in get_member_teams_for_date(m, source_date))
}
source_absences = source_absences.filter(member_id__in=allowed_member_ids)
for a in source_absences:
CompanyMemberAbsence.objects.update_or_create(
member=a.member,
date=target_date,
defaults={
'reason': a.reason,
'comment': a.comment,
'created_by': changed_by,
}
)
absences_count += 1
# Synchronisation globale pour aujourd'hui
today = timezone.now().date()
if target_date <= today:
for m in all_members:
sync_member_current_teams(m, today)
return {'assigned_count': assigned_count, 'absences_count': absences_count}
@transaction.atomic
def duplicate_week_schedule(
source_monday: date,
target_monday: date,
allowed_team_ids: Optional[Set[int]] = None,
copy_absences: bool = False,
changed_by=None,
) -> Dict[str, int]:
"""Duplique une semaine complète (7 jours) vers une autre semaine."""
total_assigned = 0
total_absences = 0
for i in range(7):
s_day = source_monday + timedelta(days=i)
t_day = target_monday + timedelta(days=i)
res = duplicate_day_schedule(
source_date=s_day,
target_date=t_day,
allowed_team_ids=allowed_team_ids,
copy_absences=copy_absences,
changed_by=changed_by,
)
total_assigned += res['assigned_count']
total_absences += res['absences_count']
return {'assigned_count': total_assigned, 'absences_count': total_absences}
@transaction.atomic
def duplicate_month_schedule(
source_year: int,
source_month: int,
target_year: int,
target_month: int,
allowed_team_ids: Optional[Set[int]] = None,
copy_absences: bool = False,
changed_by=None,
) -> Dict[str, int]:
"""
Duplique la configuration des équipes d'un mois source vers un mois cible (jour par jour).
Gère automatiquement les longueurs de mois différentes (28 à 31 jours).
"""
_, src_days = calendar.monthrange(source_year, source_month)
_, tgt_days = calendar.monthrange(target_year, target_month)
days_to_copy = min(src_days, tgt_days)
total_assigned = 0
total_absences = 0
for day_num in range(1, days_to_copy + 1):
s_day = date(source_year, source_month, day_num)
t_day = date(target_year, target_month, day_num)
res = duplicate_day_schedule(
source_date=s_day,
target_date=t_day,
allowed_team_ids=allowed_team_ids,
copy_absences=copy_absences,
changed_by=changed_by,
)
total_assigned += res['assigned_count']
total_absences += res['absences_count']
return {'assigned_count': total_assigned, 'absences_count': total_absences}