git add loko/contracts/ loko/interventions/

git commit -m "feat(interventions): configure team maintain types for control auto-assignment and remove structures limitation"
This commit is contained in:
kdeterme 2026-09-30 19:20:59 +02:00
parent 41679d82e9
commit 927c049d02
12 changed files with 170 additions and 19 deletions

View file

@ -314,23 +314,27 @@ class CompanyTeamAdminForm(forms.ModelForm):
class CompanyTeamAdmin(gis_admin.GISModelAdmin):
form = CompanyTeamAdminForm
list_display = (
'name', 'company', 'thematics_list', 'operation_types_list', 'has_geom',
'name', 'company', 'thematics_list', 'maintain_types_list', 'operation_types_list', 'has_geom',
'members_count', 'can_generate_daily_report', 'photos_required',
'restrict_today_interventions_visibility', 'visibility_start_hour',
'restrict_to_assigned_interventions'
)
search_fields = ('name', 'company__name')
list_filter = (
'company', 'can_generate_daily_report', 'photos_required',
'company', 'maintain_types', 'can_generate_daily_report', 'photos_required',
'restrict_today_interventions_visibility', 'restrict_to_assigned_interventions'
)
filter_horizontal = ('thematics', 'asset_categories')
filter_horizontal = ('thematics', 'asset_categories', 'maintain_types')
inlines = [CompanyTeamMemberInline]
def thematics_list(self, obj):
return ", ".join([thematic.name for thematic in obj.thematics.all()])
thematics_list.short_description = 'Thématiques'
def maintain_types_list(self, obj):
return obj.get_maintain_types_display()
maintain_types_list.short_description = _("Types de maintenance")
def operation_types_list(self, obj):
return obj.get_operation_types_display() or "—"
operation_types_list.short_description = _("Opérations gérées")

View file

@ -2375,3 +2375,11 @@ msgstr "If enabled, a control team is automatically assigned to corrective inter
msgid "Contrôleur assigné"
msgstr "Assigned controller"
#: contracts/models.py:450
msgid "Si activé, une équipe de contrôle est automatiquement assignée aux interventions de ce contrat."
msgstr "If enabled, a control team is automatically assigned to interventions for this contract."
#: contracts/models.py:93
msgid "Types de maintenance pris en charge par cette équipe (corrective, préventive, améliorative). Si aucun n'est sélectionné, tous les types sont pris en charge."
msgstr "Maintenance types handled by this team (corrective, preventive, ameliorative). If none is selected, all types are supported."

View file

@ -2425,3 +2425,11 @@ msgstr "Si activé, une équipe de contrôle est automatiquement assignée aux i
msgid "Contrôleur assigné"
msgstr "Contrôleur assigné"
#: contracts/models.py:450
msgid "Si activé, une équipe de contrôle est automatiquement assignée aux interventions de ce contrat."
msgstr "Si activé, une équipe de contrôle est automatiquement assignée aux interventions de ce contrat."
#: contracts/models.py:93
msgid "Types de maintenance pris en charge par cette équipe (corrective, préventive, améliorative). Si aucun n'est sélectionné, tous les types sont pris en charge."
msgstr "Types de maintenance pris en charge par cette équipe (corrective, préventive, améliorative). Si aucun n'est sélectionné, tous les types sont pris en charge."

View file

@ -2381,3 +2381,11 @@ msgstr "Indien geactiveerd wordt automatisch een controleteam toegewezen aan de
msgid "Contrôleur assigné"
msgstr "Toegewezen controleur"
#: contracts/models.py:450
msgid "Si activé, une équipe de contrôle est automatiquement assignée aux interventions de ce contrat."
msgstr "Indien geactiveerd wordt automatisch een controleteam toegewezen aan de interventies van dit contract."
#: contracts/models.py:93
msgid "Types de maintenance pris en charge par cette équipe (corrective, préventive, améliorative). Si aucun n'est sélectionné, tous les types sont pris en charge."
msgstr "Onderhoudstypes ondersteund door dit team (correctief, preventief, verbeterend). Indien niets is geselecteerd, worden alle types ondersteund."

View file

@ -0,0 +1,38 @@
# Generated by Django 6.0.7 on 2026-09-30 17:16
from django.db import migrations, models
def seed_maintain_types(apps, schema_editor):
MaintainType = apps.get_model('contracts', 'MaintainType')
types_data = [
('corrective', 'Maintenance corrective', 'Correctief onderhoud'),
('preventive', 'Maintenance préventive', 'Preventief onderhoud'),
('ameliorative', 'Maintenance améliorative', 'Verbeterend onderhoud'),
]
for code, name_fr, name_nl in types_data:
MaintainType.objects.get_or_create(
code=code,
defaults={'name_fr': name_fr, 'name_nl': name_nl}
)
class Migration(migrations.Migration):
dependencies = [
('contracts', '0012_reconcile_team_history_leave_dates'),
]
operations = [
migrations.AddField(
model_name='companyteam',
name='maintain_types',
field=models.ManyToManyField(blank=True, help_text="Types de maintenance pris en charge par cette équipe (corrective, préventive, améliorative). Si aucun n'est sélectionné, tous les types sont pris en charge.", related_name='company_teams', to='contracts.maintaintype', verbose_name='Types de maintenance'),
),
migrations.AlterField(
model_name='contract',
name='auto_assign_control_team',
field=models.BooleanField(default=False, help_text='Si activé, une équipe de contrôle est automatiquement assignée aux interventions de ce contrat.', verbose_name='Assigner automatiquement une équipe de contrôle'),
),
migrations.RunPython(seed_maintain_types, migrations.RunPython.noop),
]

View file

@ -86,6 +86,13 @@ class CompanyTeam(models.Model):
verbose_name=_("Catégories d'assets gérées"),
help_text=_("Catégories d'assets dont cette équipe est responsable."),
)
maintain_types = models.ManyToManyField(
MaintainType,
blank=True,
related_name="company_teams",
verbose_name=_("Types de maintenance"),
help_text=_("Types de maintenance pris en charge par cette équipe (corrective, préventive, améliorative). Si aucun n'est sélectionné, tous les types sont pris en charge."),
)
geom = gis_models.MultiPolygonField(
srid=3812,
null=True,
@ -147,6 +154,23 @@ class CompanyTeam(models.Model):
mapping = dict(self.OPERATION_TYPES)
return ", ".join(str(mapping.get(op, op)) for op in (self.operation_types or []))
def handles_maintain_type(self, maintain_type_code: str) -> bool:
"""Indique si l'équipe prend en charge le type de maintenance donné.
Si aucun type n'est configuré, l'équipe prend en charge tous les types."""
if not maintain_type_code:
return True
types = list(self.maintain_types.all())
if not types:
return True
return any(t.code == maintain_type_code for t in types)
def get_maintain_types_display(self) -> str:
"""Retourne la liste lisible des types de maintenance gérés."""
types = list(self.maintain_types.all())
if not types:
return str(_("Tous"))
return ", ".join(str(t) for t in types)
def __str__(self):
return f"{self.name} ({self.company.name})"
@ -447,7 +471,7 @@ class Contract(models.Model):
auto_assign_control_team = models.BooleanField(
default=False,
verbose_name=_("Assigner automatiquement une équipe de contrôle"),
help_text=_("Si activé, une équipe de contrôle est automatiquement assignée aux interventions correctives de ce contrat."),
help_text=_("Si activé, une équipe de contrôle est automatiquement assignée aux interventions de ce contrat."),
)
is_blocked = models.BooleanField(
default=False,

View file

@ -658,11 +658,9 @@ 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 pour les interventions correctives Structures
# Attribution automatique de l'équipe de contrôle
if (
self.maintain_type == 'corrective'
and self.thematic and getattr(self.thematic, 'code', None) == 'structures'
and 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:

View file

@ -10,16 +10,18 @@ def resolve_control_team_and_member(
point=None,
scheduled_date=None,
thematic=None,
maintain_type=None,
locations=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. Résolution spatiale si une géométrie est définie (cible et équipes) :
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.
4. Calendrier d'assignation à la date scheduled_date :
5. 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).
- Sélectionne l'équipe ayant le plus d'agents disponibles.
@ -28,20 +30,31 @@ def resolve_control_team_and_member(
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')
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
# 1. Filtrage thématique si l'équipe restreint ses thématiques
if thematic:
filtered_thematic = [
candidate_teams = [
t for t in candidate_teams
if not t.thematics.exists() or t.thematics.filter(id=thematic.id).exists()
if not t.thematics.all() or any(thm.id == thematic.id for thm in t.thematics.all())
]
if filtered_thematic:
candidate_teams = filtered_thematic
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)
@ -150,5 +163,6 @@ def resolve_control_team_and_member_for_intervention(intervention):
point=point,
scheduled_date=target_date,
thematic=intervention.thematic,
maintain_type=intervention.maintain_type,
locations=locs,
)

View file

@ -6,7 +6,7 @@ from django.contrib.gis.geos import MultiPolygon, Polygon, Point
from common.models import Role, UserConfig, Thematic, UserContractAccess
from contracts.models import (
Company, CompanyTeam, CompanyMember, CompanyMemberHistory, CompanyMemberAbsence, Contract
Company, CompanyTeam, CompanyMember, CompanyMemberHistory, CompanyMemberAbsence, Contract, MaintainType
)
from interventions.models import Intervention, InterventionContractPost, ContractPost
from interventions.permissions import can_view_intervention, can_control_intervention
@ -30,6 +30,17 @@ class ControlAssignmentTests(TestCase):
code='roads', defaults={'name_fr': 'Voirie', 'name_nl': 'Wegen'}
)
# Types de maintenance
self.mt_corrective, _ = MaintainType.objects.get_or_create(
code='corrective', defaults={'name_fr': 'Maintenance corrective', 'name_nl': 'Correctief onderhoud'}
)
self.mt_preventive, _ = MaintainType.objects.get_or_create(
code='preventive', defaults={'name_fr': 'Maintenance préventive', 'name_nl': 'Preventief onderhoud'}
)
self.mt_ameliorative, _ = MaintainType.objects.get_or_create(
code='ameliorative', defaults={'name_fr': 'Maintenance améliorative', 'name_nl': 'Verbeterend onderhoud'}
)
# Société & Prestataire
self.company = Company.objects.create(name='Prestataire Structures')
self.org_intern = Company.objects.create(name='Organisme Interne Contrôle')
@ -51,6 +62,8 @@ class ControlAssignmentTests(TestCase):
operation_types=[CompanyTeam.OPERATION_CONTROLS],
geom=self.ctrl_geom
)
self.team_ctrl.thematics.add(self.thematic_structures)
self.team_ctrl.maintain_types.add(self.mt_corrective)
# Membre contrôleur
self.user_ctrl = self.User.objects.create_user(username='ctrl_agent', password='pwd')
@ -108,7 +121,7 @@ class ControlAssignmentTests(TestCase):
self.assertEqual(itv.assigned_controller, self.member_ctrl)
def test_no_auto_assignment_if_not_corrective(self):
"""Une intervention préventive ne doit pas recevoir d'attribution automatique de contrôle."""
"""Une intervention préventive ne doit pas être assignée si l'équipe ne gère que le correctif."""
itv = Intervention.objects.create(
maintain_type='preventive',
thematic=self.thematic_structures,
@ -118,8 +131,32 @@ class ControlAssignmentTests(TestCase):
self.assertIsNone(itv.assigned_control_team)
self.assertIsNone(itv.assigned_controller)
def test_auto_assignment_preventive_when_team_handles_preventive(self):
"""Une intervention préventive est assignée dès lors que l'équipe prend en charge le préventif."""
self.team_ctrl.maintain_types.add(self.mt_preventive)
itv = Intervention.objects.create(
maintain_type='preventive',
thematic=self.thematic_structures,
contract=self.contract_auto,
planned_begin_time=timezone.now(),
)
self.assertEqual(itv.assigned_control_team, self.team_ctrl)
self.assertEqual(itv.assigned_controller, self.member_ctrl)
def test_auto_assignment_when_team_has_no_maintain_types_restriction(self):
"""Une équipe sans restriction de types de maintenance prend en charge tous les types (y compris amélioratif)."""
self.team_ctrl.maintain_types.clear()
itv = Intervention.objects.create(
maintain_type='ameliorative',
thematic=self.thematic_structures,
contract=self.contract_auto,
planned_begin_time=timezone.now(),
)
self.assertEqual(itv.assigned_control_team, self.team_ctrl)
self.assertEqual(itv.assigned_controller, self.member_ctrl)
def test_no_auto_assignment_if_not_structures(self):
"""Une intervention d'une autre thématique ne doit pas recevoir d'attribution automatique de contrôle."""
"""Une intervention d'une autre thématique ne doit pas être assignée à une équipe restreinte à structures."""
itv = Intervention.objects.create(
maintain_type='corrective',
thematic=self.thematic_roads,
@ -129,6 +166,18 @@ class ControlAssignmentTests(TestCase):
self.assertIsNone(itv.assigned_control_team)
self.assertIsNone(itv.assigned_controller)
def test_auto_assignment_other_thematic_when_team_matches(self):
"""Une intervention d'une autre thématique (ex: voirie) est assignée si une équipe de contrôle correspondante existe."""
self.team_ctrl.thematics.add(self.thematic_roads)
itv = Intervention.objects.create(
maintain_type='corrective',
thematic=self.thematic_roads,
contract=self.contract_auto,
planned_begin_time=timezone.now(),
)
self.assertEqual(itv.assigned_control_team, self.team_ctrl)
self.assertEqual(itv.assigned_controller, self.member_ctrl)
def test_no_auto_assignment_if_contract_not_enabled(self):
"""Un contrat sans auto_assign_control_team ne doit pas déclencher l'attribution."""
itv = Intervention.objects.create(