fix(teams): record automatic leave date on team reassignment and reconcile history
This commit is contained in:
parent
a08ef0f035
commit
79154085ee
4 changed files with 280 additions and 68 deletions
|
|
@ -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),
|
||||
]
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue