From 79154085ee10bde7540151fd1524f2e23135b529 Mon Sep 17 00:00:00 2001 From: kdeterme Date: Wed, 30 Sep 2026 09:24:09 +0200 Subject: [PATCH] fix(teams): record automatic leave date on team reassignment and reconcile history --- ...0012_reconcile_team_history_leave_dates.py | 44 +++++ loko/contracts/services_teams.py | 182 +++++++++++------- loko/contracts/test_planning.py | 118 ++++++++++++ .../calendar/interventions_calendar.js | 4 +- 4 files changed, 280 insertions(+), 68 deletions(-) create mode 100644 loko/contracts/migrations/0012_reconcile_team_history_leave_dates.py diff --git a/loko/contracts/migrations/0012_reconcile_team_history_leave_dates.py b/loko/contracts/migrations/0012_reconcile_team_history_leave_dates.py new file mode 100644 index 0000000..4b86a1a --- /dev/null +++ b/loko/contracts/migrations/0012_reconcile_team_history_leave_dates.py @@ -0,0 +1,44 @@ +from datetime import timedelta +from django.db import migrations + + +def reconcile_histories(apps, schema_editor): + CompanyMemberHistory = apps.get_model('contracts', 'CompanyMemberHistory') + CompanyMember = apps.get_model('contracts', 'CompanyMember') + + for member in CompanyMember.objects.all(): + histories = list( + CompanyMemberHistory.objects + .filter(member=member) + .order_by('join_date', 'id') + ) + if len(histories) <= 1: + continue + + for i, h in enumerate(histories): + 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 reverse_reconcile(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ('contracts', '0011_contract_municipalities'), + ] + + operations = [ + migrations.RunPython(reconcile_histories, reverse_reconcile), + ] diff --git a/loko/contracts/services_teams.py b/loko/contracts/services_teams.py index 4191a2c..7de31b9 100644 --- a/loko/contracts/services_teams.py +++ b/loko/contracts/services_teams.py @@ -53,6 +53,49 @@ def get_member_team_for_date(member: CompanyMember, target_date: date) -> Option 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, @@ -68,91 +111,92 @@ def set_member_team_schedule( - 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 - 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) + 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 - 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() + 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/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) + # 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() - # 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() - ) + # 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 @@ -183,6 +227,7 @@ def set_member_team_schedule( changed_by=changed_by, ) + reconcile_member_team_history(member) _sync_teams_if_needed(member, target_date, today, sync_current) return new_history @@ -246,6 +291,8 @@ def sync_member_current_teams(member: CompanyMember, for_date: Optional[date] = 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()) @@ -271,11 +318,14 @@ 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: diff --git a/loko/contracts/test_planning.py b/loko/contracts/test_planning.py index f6c1556..8fec0c3 100644 --- a/loko/contracts/test_planning.py +++ b/loko/contracts/test_planning.py @@ -18,6 +18,8 @@ from contracts.services_teams import ( duplicate_month_schedule, set_member_team_period_schedule, sync_member_current_teams, + reconcile_member_team_history, + reconcile_all_members_team_histories, ) User = get_user_model() @@ -714,6 +716,122 @@ class TeamPlanningAjaxViewsTests(TestCase): comp_after = get_teams_composition_for_period({self.team_a.id}, today, today) self.assertNotIn(member_test.id, [m['id'] for m in comp_after.get((self.team_a.id, today), [])]) + def test_assign_future_team_automatically_records_leave_date_on_previous_team(self): + """ + Vérifie que lorsqu'un membre est assigné à une autre équipe dans le futur (demain) : + 1. L'ancienne équipe reçoit automatiquement une date de sortie la veille (today). + 2. Le membre n'est plus "En poste" dans l'ancienne équipe. + 3. Demain, le membre n'appartient qu'à la nouvelle équipe. + """ + today = timezone.now().date() + tomorrow = today + timedelta(days=1) + + # 1. Alice est dans Team A aujourd'hui + h_a = set_member_team_schedule(self.member_alice, self.team_a.id, today) + self.assertIsNone(h_a.leave_date) + self.assertEqual(get_member_teams_for_date(self.member_alice, today), [self.team_a]) + + # 2. Le coordinateur planifie Alice dans Team B pour demain + h_b = set_member_team_schedule(self.member_alice, self.team_b.id, tomorrow) + + # 3. L'historique de Team A DOIT avoir automatiquement une sortie enregistrée à today + h_a.refresh_from_db() + self.assertEqual(h_a.leave_date, today) + + # 4. Aujourd'hui, Alice est toujours dans Team A + self.assertEqual(get_member_teams_for_date(self.member_alice, today), [self.team_a]) + + # 5. Demain, Alice est UNIQUEMENT dans Team B (pas dans Team A en plus !) + tomorrow_teams = get_member_teams_for_date(self.member_alice, tomorrow) + self.assertEqual(tomorrow_teams, [self.team_b]) + self.assertNotIn(self.team_a, tomorrow_teams) + + def test_faton_scenario_team_change_and_reconciliation(self): + """ + Reproduit le cas réel de Faton : + - 29/09 : Team 2 (sortie 29/09) + - 30/09 : Team 4 (initialement sans sortie) + - 01/10 : Team 2 (assigné pour demain) + - 02/10 : Team 2 (assigné pour après-demain) + Vérifie que Team 4 reçoit automatiquement sa sortie au 30/09 et que le 01/10 Faton n'est pas dans Team 4. + """ + d_29 = date(2026, 9, 29) + d_30 = date(2026, 9, 30) + d_01 = date(2026, 10, 1) + d_02 = date(2026, 10, 2) + + team_2 = self.team_a + team_4 = self.team_b + + # 1. Assignation 29/09 + set_member_team_schedule(self.member_alice, team_2.id, d_29) + # 2. Assignation 30/09 Team 4 -> clôture Team 2 au 29/09 + set_member_team_schedule(self.member_alice, team_4.id, d_30) + # 3. Assignation 01/10 Team 2 -> DOIT clôturer Team 4 au 30/09 + set_member_team_schedule(self.member_alice, team_2.id, d_01) + # 4. Assignation 02/10 Team 2 -> DOIT borner Team 2 au 01/10 + set_member_team_schedule(self.member_alice, team_2.id, d_02) + + h_team_4 = CompanyMemberHistory.objects.get(member=self.member_alice, team=team_4, join_date=d_30) + self.assertEqual(h_team_4.leave_date, d_30) + + h_team_2_01 = CompanyMemberHistory.objects.get(member=self.member_alice, team=team_2, join_date=d_01) + self.assertEqual(h_team_2_01.leave_date, d_01) + + h_team_2_02 = CompanyMemberHistory.objects.get(member=self.member_alice, team=team_2, join_date=d_02) + self.assertIsNone(h_team_2_02.leave_date) + + # Le 01/10, Alice est dans Team 2, et JAMAIS dans Team 4 + teams_01 = get_member_teams_for_date(self.member_alice, d_01) + self.assertEqual(teams_01, [team_2]) + self.assertNotIn(team_4, teams_01) + + # Le 02/10, Alice est dans Team 2 + teams_02 = get_member_teams_for_date(self.member_alice, d_02) + self.assertEqual(teams_02, [team_2]) + + def test_continuity_when_unassigned_and_team_not_modified(self): + """ + Règle : "S'il n'est assigné à aucune équipe, et que la composition de l'équipe n'a pas été modifiée, + alors il reste affecté à la même équipe." + """ + today = timezone.now().date() + tomorrow = today + timedelta(days=1) + day_after = today + timedelta(days=2) + + # Alice est affectée à Team A aujourd'hui + set_member_team_schedule(self.member_alice, self.team_a.id, today) + + # Ni Alice ni Team A ne sont modifiées pour demain + # Alice doit donc rester affectée à Team A demain et après-demain + self.assertEqual(get_member_team_for_date(self.member_alice, tomorrow), self.team_a) + self.assertEqual(get_member_teams_for_date(self.member_alice, tomorrow), [self.team_a]) + self.assertEqual(get_member_team_for_date(self.member_alice, day_after), self.team_a) + + def test_reconcile_existing_unclosed_histories_helper(self): + """ + Vérifie que reconcile_member_team_history assainit des enregistrements existants ouverts. + """ + d1 = date(2026, 9, 20) + d2 = date(2026, 9, 25) + d3 = date(2026, 9, 30) + + # Créer manuellement des historiques corrompus sans leave_date + h1 = CompanyMemberHistory.objects.create(member=self.member_alice, team=self.team_a, join_date=d1, leave_date=None) + h2 = CompanyMemberHistory.objects.create(member=self.member_alice, team=self.team_b, join_date=d2, leave_date=None) + h3 = CompanyMemberHistory.objects.create(member=self.member_alice, team=self.team_a, join_date=d3, leave_date=None) + + reconcile_member_team_history(self.member_alice) + + h1.refresh_from_db() + h2.refresh_from_db() + h3.refresh_from_db() + + self.assertEqual(h1.leave_date, d2 - timedelta(days=1)) + self.assertEqual(h2.leave_date, d3 - timedelta(days=1)) + self.assertIsNone(h3.leave_date) + + diff --git a/loko/interventions/static/interventions/calendar/interventions_calendar.js b/loko/interventions/static/interventions/calendar/interventions_calendar.js index a284863..d9dfcb1 100644 --- a/loko/interventions/static/interventions/calendar/interventions_calendar.js +++ b/loko/interventions/static/interventions/calendar/interventions_calendar.js @@ -5637,7 +5637,7 @@ function assignMemberToDailyTeam(memberId, teamId, dateStr, action) { member_id: parseInt(memberId), team_id: action === 'unassign' ? null : (teamId ? parseInt(teamId) : null), date: dateStr, - action: action === 'unassign' ? 'move' : (action || 'add'), + action: action === 'unassign' ? 'move' : (action || 'move'), unassign_team_id: action === 'unassign' ? parseInt(teamId) : null, }) }) @@ -5678,7 +5678,7 @@ document.addEventListener('click', function(e) { if (!memberId) return; const { teamId, dateStr } = _currentTeamDailyModalState; if (teamId && dateStr) { - assignMemberToDailyTeam(memberId, teamId, dateStr, 'add'); + assignMemberToDailyTeam(memberId, teamId, dateStr, 'move'); } return; }