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:
parent
41679d82e9
commit
927c049d02
12 changed files with 170 additions and 19 deletions
|
|
@ -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")
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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."
|
||||
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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."
|
||||
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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."
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
]
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue