fix(interventions): assign controller only when intervention status changes to finished
This commit is contained in:
parent
8df84704dc
commit
3920648000
3 changed files with 212 additions and 96 deletions
|
|
@ -658,21 +658,39 @@ class Intervention(models.Model):
|
|||
if user_config and not user_config.is_intern:
|
||||
self.created_by_provider = True
|
||||
|
||||
# Attribution automatique de l'équipe de contrôle
|
||||
if (
|
||||
self.contract and getattr(self.contract, 'auto_assign_control_team', False)
|
||||
and not self.assigned_control_team_id
|
||||
):
|
||||
# Attribution automatique de l'équipe de contrôle et du contrôleur
|
||||
if self.contract and getattr(self.contract, 'auto_assign_control_team', False):
|
||||
try:
|
||||
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:
|
||||
self.assigned_control_team = ctrl_team
|
||||
self.assigned_controller = ctrl_member
|
||||
|
||||
# 1. Équipe de contrôle : attribuée dès la création ou sauvegarde si non définie
|
||||
if not self.assigned_control_team_id:
|
||||
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:
|
||||
import logging
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -12,95 +12,101 @@ def resolve_control_team_and_member(
|
|||
thematic=None,
|
||||
maintain_type=None,
|
||||
locations=None,
|
||||
target_team=None,
|
||||
):
|
||||
"""
|
||||
Détermine l'équipe de contrôle (CompanyTeam) et le contrôleur (CompanyMember) :
|
||||
1. Équipes candidates gérant l'opération 'controls' (OPERATION_CONTROLS).
|
||||
2. Filtrage thématique si l'équipe restreint ses thématiques.
|
||||
3. Filtrage par type de maintenance (corrective, preventive, ameliorative) si l'équipe restreint ses types.
|
||||
4. Résolution spatiale si une géométrie est définie (cible et équipes) :
|
||||
- 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.
|
||||
5. Calendrier d'assignation à la date scheduled_date :
|
||||
1. Si target_team est fourni, restreint la recherche des membres à cette équipe.
|
||||
Sinon :
|
||||
- Équipes candidates gérant l'opération 'controls' (OPERATION_CONTROLS).
|
||||
- Filtrage thématique si l'équipe restreint ses thématiques.
|
||||
- Filtrage par type de maintenance (corrective, preventive, ameliorative) si l'équipe restreint ses types.
|
||||
- Résolution spatiale si une géométrie est définie (cible et équipes) :
|
||||
- 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.
|
||||
- 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 le premier membre disponible de cette équipe.
|
||||
"""
|
||||
from contracts.models import CompanyTeam, CompanyMember
|
||||
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')
|
||||
candidate_teams = [t for t in teams_qs if t.handles_operation(CompanyTeam.OPERATION_CONTROLS)]
|
||||
|
||||
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
|
||||
if target_team is not None:
|
||||
eligible_teams = [target_team]
|
||||
else:
|
||||
# Fallback sans géométrie : toutes les équipes candidates
|
||||
eligible_teams = candidate_teams
|
||||
teams_qs = CompanyTeam.objects.all().select_related('company').prefetch_related('thematics', 'maintain_types')
|
||||
candidate_teams = [t for t in teams_qs if t.handles_operation(CompanyTeam.OPERATION_CONTROLS)]
|
||||
|
||||
if not eligible_teams:
|
||||
return None, None
|
||||
if not candidate_teams:
|
||||
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:
|
||||
return None, None
|
||||
return (target_team if target_team else (eligible_teams[0] if eligible_teams else None)), None
|
||||
|
||||
if hasattr(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))
|
||||
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:
|
||||
return None, None
|
||||
return (target_team, None) if target_team else (None, None)
|
||||
|
||||
best_member_id = best_members[0]['id']
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
puis appelle resolve_control_team_and_member.
|
||||
|
|
@ -149,12 +155,20 @@ def resolve_control_team_and_member_for_intervention(intervention):
|
|||
except Exception as e:
|
||||
logger.debug(f"Error transforming intervention coordinates to 3812: {e}")
|
||||
|
||||
target_date = (
|
||||
intervention.planned_begin_time
|
||||
or intervention.begin_time
|
||||
or intervention.creation_time
|
||||
or timezone.now().date()
|
||||
)
|
||||
if target_date is None:
|
||||
if intervention.status == 'finished':
|
||||
target_date = (
|
||||
getattr(intervention, 'end_time', None)
|
||||
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 []
|
||||
|
||||
|
|
@ -165,4 +179,5 @@ def resolve_control_team_and_member_for_intervention(intervention):
|
|||
thematic=intervention.thematic,
|
||||
maintain_type=intervention.maintain_type,
|
||||
locations=locs,
|
||||
target_team=target_team,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -104,8 +104,7 @@ class ControlAssignmentTests(TestCase):
|
|||
self.contract_no_auto.thematics.add(self.thematic_structures)
|
||||
|
||||
def test_auto_assignment_structures_corrective(self):
|
||||
"""Vérifie l'attribution automatique pour une intervention corrective Structures avec contrat activé."""
|
||||
# Coordonnées au centre de la zone (Bruxelles ~ 4.35, 50.85 -> en Lambert 72 ~ 150000, 170000)
|
||||
"""Vérifie l'attribution de l'équipe à la création et du contrôleur au statut terminé."""
|
||||
p_poly = Polygon(((149000, 169000), (151000, 169000), (151000, 171000), (149000, 171000), (149000, 169000)))
|
||||
geom = MultiPolygon([p_poly], srid=3812)
|
||||
|
||||
|
|
@ -117,6 +116,30 @@ class ControlAssignmentTests(TestCase):
|
|||
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_controller, self.member_ctrl)
|
||||
|
||||
|
|
@ -126,6 +149,7 @@ class ControlAssignmentTests(TestCase):
|
|||
maintain_type='preventive',
|
||||
thematic=self.thematic_structures,
|
||||
contract=self.contract_auto,
|
||||
status='finished',
|
||||
planned_begin_time=timezone.now(),
|
||||
)
|
||||
self.assertIsNone(itv.assigned_control_team)
|
||||
|
|
@ -138,6 +162,7 @@ class ControlAssignmentTests(TestCase):
|
|||
maintain_type='preventive',
|
||||
thematic=self.thematic_structures,
|
||||
contract=self.contract_auto,
|
||||
status='finished',
|
||||
planned_begin_time=timezone.now(),
|
||||
)
|
||||
self.assertEqual(itv.assigned_control_team, self.team_ctrl)
|
||||
|
|
@ -150,6 +175,7 @@ class ControlAssignmentTests(TestCase):
|
|||
maintain_type='ameliorative',
|
||||
thematic=self.thematic_structures,
|
||||
contract=self.contract_auto,
|
||||
status='finished',
|
||||
planned_begin_time=timezone.now(),
|
||||
)
|
||||
self.assertEqual(itv.assigned_control_team, self.team_ctrl)
|
||||
|
|
@ -161,6 +187,7 @@ class ControlAssignmentTests(TestCase):
|
|||
maintain_type='corrective',
|
||||
thematic=self.thematic_roads,
|
||||
contract=self.contract_auto,
|
||||
status='finished',
|
||||
planned_begin_time=timezone.now(),
|
||||
)
|
||||
self.assertIsNone(itv.assigned_control_team)
|
||||
|
|
@ -173,6 +200,7 @@ class ControlAssignmentTests(TestCase):
|
|||
maintain_type='corrective',
|
||||
thematic=self.thematic_roads,
|
||||
contract=self.contract_auto,
|
||||
status='finished',
|
||||
planned_begin_time=timezone.now(),
|
||||
)
|
||||
self.assertEqual(itv.assigned_control_team, self.team_ctrl)
|
||||
|
|
@ -184,6 +212,7 @@ class ControlAssignmentTests(TestCase):
|
|||
maintain_type='corrective',
|
||||
thematic=self.thematic_structures,
|
||||
contract=self.contract_no_auto,
|
||||
status='finished',
|
||||
planned_begin_time=timezone.now(),
|
||||
)
|
||||
self.assertIsNone(itv.assigned_control_team)
|
||||
|
|
@ -196,6 +225,7 @@ class ControlAssignmentTests(TestCase):
|
|||
thematic=self.thematic_structures,
|
||||
contract=self.contract_auto,
|
||||
geom=None,
|
||||
status='finished',
|
||||
planned_begin_time=timezone.now(),
|
||||
)
|
||||
self.assertEqual(itv.assigned_control_team, self.team_ctrl)
|
||||
|
|
@ -255,3 +285,56 @@ class ControlAssignmentTests(TestCase):
|
|||
itv.status = 'in_progress'
|
||||
itv.save(update_fields=['status'])
|
||||
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)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue