fix(interventions): assign controller only when intervention status changes to finished

This commit is contained in:
kdeterme 2026-10-01 09:53:06 +02:00
parent 8df84704dc
commit 3920648000
3 changed files with 212 additions and 96 deletions

View file

@ -658,21 +658,39 @@ class Intervention(models.Model):
if user_config and not user_config.is_intern: if user_config and not user_config.is_intern:
self.created_by_provider = True self.created_by_provider = True
# Attribution automatique de l'équipe de contrôle # Attribution automatique de l'équipe de contrôle et du contrôleur
if ( if self.contract and getattr(self.contract, 'auto_assign_control_team', False):
self.contract and getattr(self.contract, 'auto_assign_control_team', False)
and not self.assigned_control_team_id
):
try: try:
from interventions.services_control_assignment import resolve_control_team_and_member_for_intervention from interventions.services_control_assignment import resolve_control_team_and_member_for_intervention
ctrl_team, ctrl_member = resolve_control_team_and_member_for_intervention(self)
if ctrl_team: # 1. Équipe de contrôle : attribuée dès la création ou sauvegarde si non définie
self.assigned_control_team = ctrl_team if not self.assigned_control_team_id:
self.assigned_controller = ctrl_member ctrl_team, _ = resolve_control_team_and_member_for_intervention(self)
if ctrl_team:
self.assigned_control_team = ctrl_team
if 'update_fields' in kwargs and kwargs['update_fields'] is not None:
kwargs['update_fields'] = set(kwargs['update_fields']) | {'assigned_control_team'}
# 2. Contrôleur : attribué UNIQUEMENT lorsque l'intervention passe au statut 'terminé' ('finished')
if status_changed_to_finished and not self.assigned_controller_id:
if self.assigned_control_team:
_, ctrl_member = resolve_control_team_and_member_for_intervention(
self, target_team=self.assigned_control_team
)
else:
ctrl_team, ctrl_member = resolve_control_team_and_member_for_intervention(self)
if ctrl_team and not self.assigned_control_team_id:
self.assigned_control_team = ctrl_team
if 'update_fields' in kwargs and kwargs['update_fields'] is not None:
kwargs['update_fields'] = set(kwargs['update_fields']) | {'assigned_control_team'}
if ctrl_member:
self.assigned_controller = ctrl_member
if 'update_fields' in kwargs and kwargs['update_fields'] is not None:
kwargs['update_fields'] = set(kwargs['update_fields']) | {'assigned_controller'}
except Exception: except Exception:
import logging import logging
logging.getLogger(__name__).exception( logging.getLogger(__name__).exception(
f"Error auto-assigning control team for intervention {self.code}" f"Error auto-assigning control team/controller for intervention {self.code}"
) )
super().save(*args, **kwargs) super().save(*args, **kwargs)

View file

@ -12,95 +12,101 @@ def resolve_control_team_and_member(
thematic=None, thematic=None,
maintain_type=None, maintain_type=None,
locations=None, locations=None,
target_team=None,
): ):
""" """
Détermine l'équipe de contrôle (CompanyTeam) et le contrôleur (CompanyMember) : Détermine l'équipe de contrôle (CompanyTeam) et le contrôleur (CompanyMember) :
1. Équipes candidates gérant l'opération 'controls' (OPERATION_CONTROLS). 1. Si target_team est fourni, restreint la recherche des membres à cette équipe.
2. Filtrage thématique si l'équipe restreint ses thématiques. Sinon :
3. Filtrage par type de maintenance (corrective, preventive, ameliorative) si l'équipe restreint ses types. - Équipes candidates gérant l'opération 'controls' (OPERATION_CONTROLS).
4. Résolution spatiale si une géométrie est définie (cible et équipes) : - Filtrage thématique si l'équipe restreint ses thématiques.
- team.geom.intersects(target_geom) ou team.geom.contains(point). - Filtrage par type de maintenance (corrective, preventive, ameliorative) si l'équipe restreint ses types.
- Fallback sur toutes les équipes candidates si aucune géométrie n'est définie. - Résolution spatiale si une géométrie est définie (cible et équipes) :
5. Calendrier d'assignation à la date scheduled_date : - team.geom.intersects(target_geom) ou team.geom.contains(point).
- Fallback sur toutes les équipes candidates si aucune géométrie n'est définie.
2. Calendrier d'assignation à la date scheduled_date :
- Évalue le nombre d'agents planifiés et non absents. - Évalue le nombre d'agents planifiés et non absents.
- Si aucun agent n'est disponible ou aucune date : retourne (None, None). - Si aucun agent n'est disponible ou aucune date : retourne (target_team, None) ou (None, None).
- Sélectionne l'équipe ayant le plus d'agents disponibles. - Sélectionne l'équipe ayant le plus d'agents disponibles.
- Sélectionne le premier membre disponible de cette équipe. - Sélectionne le premier membre disponible de cette équipe.
""" """
from contracts.models import CompanyTeam, CompanyMember from contracts.models import CompanyTeam, CompanyMember
from contracts.services_teams import get_teams_composition_for_period, get_member_absences_for_period from contracts.services_teams import get_teams_composition_for_period, get_member_absences_for_period
teams_qs = CompanyTeam.objects.all().select_related('company').prefetch_related('thematics', 'maintain_types') if target_team is not None:
candidate_teams = [t for t in teams_qs if t.handles_operation(CompanyTeam.OPERATION_CONTROLS)] eligible_teams = [target_team]
if not candidate_teams:
return None, None
# 1. Filtrage thématique si l'équipe restreint ses thématiques
if thematic:
candidate_teams = [
t for t in candidate_teams
if not t.thematics.all() or any(thm.id == thematic.id for thm in t.thematics.all())
]
if not candidate_teams:
return None, None
# 2. Filtrage par type de maintenance si l'équipe restreint ses types de maintenance
if maintain_type:
candidate_teams = [
t for t in candidate_teams
if t.handles_maintain_type(maintain_type)
]
if not candidate_teams:
return None, None
# 2. Résolution spatiale si géométrie définie
has_target_geom = bool(target_geom or point or locations)
teams_with_geom = [t for t in candidate_teams if t.geom is not None]
if has_target_geom and teams_with_geom:
spatial_matches = []
for team in teams_with_geom:
matched = False
if target_geom and team.geom:
try:
matched = team.geom.intersects(target_geom)
except Exception as e:
logger.debug(f"Error checking team {team.id} geom intersects: {e}")
if not matched and point and team.geom:
try:
matched = team.geom.contains(point)
except Exception as e:
logger.debug(f"Error checking team {team.id} geom contains point: {e}")
if not matched and locations and team.geom:
for loc in locations:
loc_geom = getattr(loc, 'geom', None)
if loc_geom:
try:
if team.geom.intersects(loc_geom):
matched = True
break
except Exception:
pass
if matched:
spatial_matches.append(team)
eligible_teams = spatial_matches
else: else:
# Fallback sans géométrie : toutes les équipes candidates teams_qs = CompanyTeam.objects.all().select_related('company').prefetch_related('thematics', 'maintain_types')
eligible_teams = candidate_teams candidate_teams = [t for t in teams_qs if t.handles_operation(CompanyTeam.OPERATION_CONTROLS)]
if not eligible_teams: if not candidate_teams:
return None, None return None, None
# 3. Calendrier d'assignation # 1. Filtrage thématique si l'équipe restreint ses thématiques
if thematic:
candidate_teams = [
t for t in candidate_teams
if not t.thematics.all() or any(thm.id == thematic.id for thm in t.thematics.all())
]
if not candidate_teams:
return None, None
# 2. Filtrage par type de maintenance si l'équipe restreint ses types de maintenance
if maintain_type:
candidate_teams = [
t for t in candidate_teams
if t.handles_maintain_type(maintain_type)
]
if not candidate_teams:
return None, None
# 3. Résolution spatiale si géométrie définie
has_target_geom = bool(target_geom or point or locations)
teams_with_geom = [t for t in candidate_teams if t.geom is not None]
if has_target_geom and teams_with_geom:
spatial_matches = []
for team in teams_with_geom:
matched = False
if target_geom and team.geom:
try:
matched = team.geom.intersects(target_geom)
except Exception as e:
logger.debug(f"Error checking team {team.id} geom intersects: {e}")
if not matched and point and team.geom:
try:
matched = team.geom.contains(point)
except Exception as e:
logger.debug(f"Error checking team {team.id} geom contains point: {e}")
if not matched and locations and team.geom:
for loc in locations:
loc_geom = getattr(loc, 'geom', None)
if loc_geom:
try:
if team.geom.intersects(loc_geom):
matched = True
break
except Exception:
pass
if matched:
spatial_matches.append(team)
eligible_teams = spatial_matches
else:
# Fallback sans géométrie : toutes les équipes candidates
eligible_teams = candidate_teams
if not eligible_teams:
return None, None
# 4. Calendrier d'assignation
if not scheduled_date: if not scheduled_date:
return None, None return (target_team if target_team else (eligible_teams[0] if eligible_teams else None)), None
if hasattr(scheduled_date, 'date'): if hasattr(scheduled_date, 'date'):
target_d = scheduled_date.date() target_d = scheduled_date.date()
@ -125,9 +131,9 @@ def resolve_control_team_and_member(
scored_teams.sort(key=lambda item: (-item[0], item[1].name)) scored_teams.sort(key=lambda item: (-item[0], item[1].name))
best_len, best_team, best_members = scored_teams[0] best_len, best_team, best_members = scored_teams[0]
# Règle stricte : si aucun agent disponible ce jour-là, ne pas assigner # Règle stricte pour le contrôleur : si aucun agent disponible ce jour-là, membre = None
if best_len == 0 or not best_members: if best_len == 0 or not best_members:
return None, None return (target_team, None) if target_team else (None, None)
best_member_id = best_members[0]['id'] best_member_id = best_members[0]['id']
best_member = CompanyMember.objects.filter(id=best_member_id).first() best_member = CompanyMember.objects.filter(id=best_member_id).first()
@ -135,7 +141,7 @@ def resolve_control_team_and_member(
return best_team, best_member return best_team, best_member
def resolve_control_team_and_member_for_intervention(intervention): def resolve_control_team_and_member_for_intervention(intervention, target_date=None, target_team=None):
""" """
Extrait la géométrie, la localisation et la date de l'intervention Extrait la géométrie, la localisation et la date de l'intervention
puis appelle resolve_control_team_and_member. puis appelle resolve_control_team_and_member.
@ -149,12 +155,20 @@ def resolve_control_team_and_member_for_intervention(intervention):
except Exception as e: except Exception as e:
logger.debug(f"Error transforming intervention coordinates to 3812: {e}") logger.debug(f"Error transforming intervention coordinates to 3812: {e}")
target_date = ( if target_date is None:
intervention.planned_begin_time if intervention.status == 'finished':
or intervention.begin_time target_date = (
or intervention.creation_time getattr(intervention, 'end_time', None)
or timezone.now().date() or getattr(intervention, 'planned_end_time', None)
) or timezone.now().date()
)
else:
target_date = (
intervention.planned_begin_time
or intervention.begin_time
or intervention.creation_time
or timezone.now().date()
)
locs = list(intervention.locations.all()) if (intervention.pk and hasattr(intervention, 'locations')) else [] locs = list(intervention.locations.all()) if (intervention.pk and hasattr(intervention, 'locations')) else []
@ -165,4 +179,5 @@ def resolve_control_team_and_member_for_intervention(intervention):
thematic=intervention.thematic, thematic=intervention.thematic,
maintain_type=intervention.maintain_type, maintain_type=intervention.maintain_type,
locations=locs, locations=locs,
target_team=target_team,
) )

View file

@ -104,8 +104,7 @@ class ControlAssignmentTests(TestCase):
self.contract_no_auto.thematics.add(self.thematic_structures) self.contract_no_auto.thematics.add(self.thematic_structures)
def test_auto_assignment_structures_corrective(self): def test_auto_assignment_structures_corrective(self):
"""Vérifie l'attribution automatique pour une intervention corrective Structures avec contrat activé.""" """Vérifie l'attribution de l'équipe à la création et du contrôleur au statut terminé."""
# Coordonnées au centre de la zone (Bruxelles ~ 4.35, 50.85 -> en Lambert 72 ~ 150000, 170000)
p_poly = Polygon(((149000, 169000), (151000, 169000), (151000, 171000), (149000, 171000), (149000, 169000))) p_poly = Polygon(((149000, 169000), (151000, 169000), (151000, 171000), (149000, 171000), (149000, 169000)))
geom = MultiPolygon([p_poly], srid=3812) geom = MultiPolygon([p_poly], srid=3812)
@ -117,6 +116,30 @@ class ControlAssignmentTests(TestCase):
planned_begin_time=timezone.now(), planned_begin_time=timezone.now(),
) )
# À la création (statut à traiter), seule l'équipe de contrôle est assignée
self.assertEqual(itv.assigned_control_team, self.team_ctrl)
self.assertIsNone(itv.assigned_controller)
# Lors du passage au statut 'terminé', le contrôleur disponible est automatiquement assigné
itv.status = 'finished'
itv.save()
itv.refresh_from_db()
self.assertEqual(itv.assigned_controller, self.member_ctrl)
def test_auto_assignment_when_created_directly_finished(self):
"""Vérifie l'attribution directe de l'équipe et du contrôleur si créée au statut terminé."""
p_poly = Polygon(((149000, 169000), (151000, 169000), (151000, 171000), (149000, 171000), (149000, 169000)))
geom = MultiPolygon([p_poly], srid=3812)
itv = Intervention.objects.create(
maintain_type='corrective',
thematic=self.thematic_structures,
contract=self.contract_auto,
geom=geom,
status='finished',
planned_begin_time=timezone.now(),
)
self.assertEqual(itv.assigned_control_team, self.team_ctrl) self.assertEqual(itv.assigned_control_team, self.team_ctrl)
self.assertEqual(itv.assigned_controller, self.member_ctrl) self.assertEqual(itv.assigned_controller, self.member_ctrl)
@ -126,6 +149,7 @@ class ControlAssignmentTests(TestCase):
maintain_type='preventive', maintain_type='preventive',
thematic=self.thematic_structures, thematic=self.thematic_structures,
contract=self.contract_auto, contract=self.contract_auto,
status='finished',
planned_begin_time=timezone.now(), planned_begin_time=timezone.now(),
) )
self.assertIsNone(itv.assigned_control_team) self.assertIsNone(itv.assigned_control_team)
@ -138,6 +162,7 @@ class ControlAssignmentTests(TestCase):
maintain_type='preventive', maintain_type='preventive',
thematic=self.thematic_structures, thematic=self.thematic_structures,
contract=self.contract_auto, contract=self.contract_auto,
status='finished',
planned_begin_time=timezone.now(), planned_begin_time=timezone.now(),
) )
self.assertEqual(itv.assigned_control_team, self.team_ctrl) self.assertEqual(itv.assigned_control_team, self.team_ctrl)
@ -150,6 +175,7 @@ class ControlAssignmentTests(TestCase):
maintain_type='ameliorative', maintain_type='ameliorative',
thematic=self.thematic_structures, thematic=self.thematic_structures,
contract=self.contract_auto, contract=self.contract_auto,
status='finished',
planned_begin_time=timezone.now(), planned_begin_time=timezone.now(),
) )
self.assertEqual(itv.assigned_control_team, self.team_ctrl) self.assertEqual(itv.assigned_control_team, self.team_ctrl)
@ -161,6 +187,7 @@ class ControlAssignmentTests(TestCase):
maintain_type='corrective', maintain_type='corrective',
thematic=self.thematic_roads, thematic=self.thematic_roads,
contract=self.contract_auto, contract=self.contract_auto,
status='finished',
planned_begin_time=timezone.now(), planned_begin_time=timezone.now(),
) )
self.assertIsNone(itv.assigned_control_team) self.assertIsNone(itv.assigned_control_team)
@ -173,6 +200,7 @@ class ControlAssignmentTests(TestCase):
maintain_type='corrective', maintain_type='corrective',
thematic=self.thematic_roads, thematic=self.thematic_roads,
contract=self.contract_auto, contract=self.contract_auto,
status='finished',
planned_begin_time=timezone.now(), planned_begin_time=timezone.now(),
) )
self.assertEqual(itv.assigned_control_team, self.team_ctrl) self.assertEqual(itv.assigned_control_team, self.team_ctrl)
@ -184,6 +212,7 @@ class ControlAssignmentTests(TestCase):
maintain_type='corrective', maintain_type='corrective',
thematic=self.thematic_structures, thematic=self.thematic_structures,
contract=self.contract_no_auto, contract=self.contract_no_auto,
status='finished',
planned_begin_time=timezone.now(), planned_begin_time=timezone.now(),
) )
self.assertIsNone(itv.assigned_control_team) self.assertIsNone(itv.assigned_control_team)
@ -196,6 +225,7 @@ class ControlAssignmentTests(TestCase):
thematic=self.thematic_structures, thematic=self.thematic_structures,
contract=self.contract_auto, contract=self.contract_auto,
geom=None, geom=None,
status='finished',
planned_begin_time=timezone.now(), planned_begin_time=timezone.now(),
) )
self.assertEqual(itv.assigned_control_team, self.team_ctrl) self.assertEqual(itv.assigned_control_team, self.team_ctrl)
@ -255,3 +285,56 @@ class ControlAssignmentTests(TestCase):
itv.status = 'in_progress' itv.status = 'in_progress'
itv.save(update_fields=['status']) itv.save(update_fields=['status'])
self.assertFalse(can_control_intervention(self.user_ctrl, itv)) self.assertFalse(can_control_intervention(self.user_ctrl, itv))
def test_controller_not_assigned_during_in_progress_or_on_pause(self):
"""Le contrôleur ne doit pas être assigné aux statuts intermédiaires (to_be_processed, in_progress, on_pause)."""
itv = Intervention.objects.create(
maintain_type='corrective',
thematic=self.thematic_structures,
contract=self.contract_auto,
status='to_be_processed',
planned_begin_time=timezone.now(),
)
self.assertEqual(itv.assigned_control_team, self.team_ctrl)
self.assertIsNone(itv.assigned_controller)
# Passage à in_progress
itv.status = 'in_progress'
itv.save()
itv.refresh_from_db()
self.assertIsNone(itv.assigned_controller)
# Passage à on_pause
itv.status = 'on_pause'
itv.pause_reason = 'weather'
itv.save()
itv.refresh_from_db()
self.assertIsNone(itv.assigned_controller)
# Passage à finished -> contrôleur assigné
itv.status = 'finished'
itv.save()
itv.refresh_from_db()
self.assertEqual(itv.assigned_controller, self.member_ctrl)
def test_controller_manual_not_overwritten_on_finished(self):
"""Un contrôleur assigné manuellement ne doit pas être écrasé lors du passage à terminé."""
user_other = self.User.objects.create_user(username='other_ctrl', password='pwd')
other_ctrl = CompanyMember.objects.create(
name='Autre Contrôleur',
user=user_other
)
itv = Intervention.objects.create(
maintain_type='corrective',
thematic=self.thematic_structures,
contract=self.contract_auto,
status='in_progress',
assigned_control_team=self.team_ctrl,
assigned_controller=other_ctrl,
planned_begin_time=timezone.now(),
)
itv.status = 'finished'
itv.save()
itv.refresh_from_db()
self.assertEqual(itv.assigned_controller, other_ctrl)