loko/loko/contracts/services_teams.py

625 lines
22 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
def reconcile_member_team_history(member: CompanyMember):
"""
Assainit l'historique des équipes d'un membre.
Garantit qu'aucun enregistrement antérieur n'ait une sortie indéfinie (leave_date=None)
ou chevauchant une assignation ultérieure.
Enregistre automatiquement la sortie la veille de l'assignation suivante.
"""
histories = list(
CompanyMemberHistory.objects
.filter(member=member)
.order_by('join_date', 'id')
)
if len(histories) <= 1:
return
for i, h in enumerate(histories):
# Trouver le premier enregistrement ultérieur strict dans le temps
next_milestone = None
for next_h in histories[i + 1:]:
if next_h.join_date > h.join_date:
next_milestone = next_h
break
if next_milestone:
expected_leave = next_milestone.join_date - timedelta(days=1)
if h.leave_date is None or h.leave_date > expected_leave:
h.leave_date = expected_leave
h.save(update_fields=['leave_date'])
def reconcile_all_members_team_histories() -> int:
"""
Parcourt tous les membres et assainit leurs historiques d'équipes.
Retourne le nombre de membres traités.
"""
members = CompanyMember.objects.all()
count = 0
for m in members:
reconcile_member_team_history(m)
count += 1
return count
@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.
Enregistre automatiquement une date de sortie (la veille) pour les affectations actives.
- Si team_id is not None :
- action='move' (défaut) : clôture/retire les autres équipes à target_date et affecte team_id.
Enregistre automatiquement une date de sortie (la veille) pour l'ancienne équipe.
- action='add' : ajoute team_id sans impacter les autres équipes existantes (multi-équipes).
"""
today = timezone.now().date()
prev_leave = target_date - timedelta(days=1)
if team_id is None:
# Désassignation
if unassign_team_id is not None:
# Désassignation ciblée d'une seule équipe
CompanyMemberHistory.objects.filter(
member=member,
team_id=unassign_team_id,
join_date=target_date,
).delete()
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
CompanyMemberHistory.objects.filter(
member=member,
join_date=target_date,
).delete()
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()
reconcile_member_team_history(member)
_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 les affectations du même jour et on clôture les antérieures
CompanyMemberHistory.objects.filter(
member=member,
join_date=target_date,
).exclude(team_id=team_id).delete()
# Enregistre automatiquement la sortie la veille pour les autres équipes
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)
# Clôture également les enregistrements antérieurs de la même équipe pour éviter les doublons actifs
CompanyMemberHistory.objects.filter(
member=member,
team_id=team_id,
join_date__lt=target_date,
).filter(
Q(leave_date__isnull=True) | Q(leave_date__gte=target_date)
).update(leave_date=prev_leave)
# Trouver le prochain jalon futur pour ce membre
if action == 'move':
next_hist = (
CompanyMemberHistory.objects
.filter(member=member, join_date__gt=target_date)
.order_by('join_date')
.first()
)
else:
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,
)
reconcile_member_team_history(member)
_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()
reconcile_member_team_history(member)
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).
Assainit également les historiques des équipes pour tous les membres.
Retourne le nombre de membres synchronisés.
"""
if for_date is None:
for_date = timezone.now().date()
reconcile_all_members_team_histories()
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}