575 lines
20 KiB
Python
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}
|