fix(teams): record automatic leave date on team reassignment and reconcile history

This commit is contained in:
kdeterme 2026-09-30 09:24:09 +02:00
parent a08ef0f035
commit 79154085ee
4 changed files with 280 additions and 68 deletions

View file

@ -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),
]

View file

@ -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,29 +111,24 @@ 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,
@ -102,17 +140,10 @@ def set_member_team_schedule(
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,
@ -122,23 +153,19 @@ def set_member_team_schedule(
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:
# 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()
else:
CompanyMemberHistory.objects.filter(
member=member,
join_date=target_date,
).exclude(team_id=team_id).delete()
prev_leave = target_date - timedelta(days=1)
# Enregistre automatiquement la sortie la veille pour les autres équipes
CompanyMemberHistory.objects.filter(
member=member,
join_date__lt=target_date,
@ -146,7 +173,24 @@ def set_member_team_schedule(
Q(leave_date__isnull=True) | Q(leave_date__gte=target_date)
).update(leave_date=prev_leave)
# Trouver le prochain jalon futur pour cette équipe
# 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)
@ -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:

View file

@ -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)

View file

@ -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;
}