diff --git a/loko/contracts/admin.py b/loko/contracts/admin.py index ae7f56e..ea73720 100644 --- a/loko/contracts/admin.py +++ b/loko/contracts/admin.py @@ -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") diff --git a/loko/contracts/locale/en/LC_MESSAGES/django.mo b/loko/contracts/locale/en/LC_MESSAGES/django.mo index a0c431b..d464408 100644 Binary files a/loko/contracts/locale/en/LC_MESSAGES/django.mo and b/loko/contracts/locale/en/LC_MESSAGES/django.mo differ diff --git a/loko/contracts/locale/en/LC_MESSAGES/django.po b/loko/contracts/locale/en/LC_MESSAGES/django.po index 23cf9b3..32e5bae 100644 --- a/loko/contracts/locale/en/LC_MESSAGES/django.po +++ b/loko/contracts/locale/en/LC_MESSAGES/django.po @@ -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." + diff --git a/loko/contracts/locale/fr/LC_MESSAGES/django.mo b/loko/contracts/locale/fr/LC_MESSAGES/django.mo index fe4478f..271a5ba 100644 Binary files a/loko/contracts/locale/fr/LC_MESSAGES/django.mo and b/loko/contracts/locale/fr/LC_MESSAGES/django.mo differ diff --git a/loko/contracts/locale/fr/LC_MESSAGES/django.po b/loko/contracts/locale/fr/LC_MESSAGES/django.po index 4052f8e..7b1bad4 100644 --- a/loko/contracts/locale/fr/LC_MESSAGES/django.po +++ b/loko/contracts/locale/fr/LC_MESSAGES/django.po @@ -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." + diff --git a/loko/contracts/locale/nl/LC_MESSAGES/django.mo b/loko/contracts/locale/nl/LC_MESSAGES/django.mo index a53d5cc..43dec45 100644 Binary files a/loko/contracts/locale/nl/LC_MESSAGES/django.mo and b/loko/contracts/locale/nl/LC_MESSAGES/django.mo differ diff --git a/loko/contracts/locale/nl/LC_MESSAGES/django.po b/loko/contracts/locale/nl/LC_MESSAGES/django.po index b7eba89..64bdac2 100644 --- a/loko/contracts/locale/nl/LC_MESSAGES/django.po +++ b/loko/contracts/locale/nl/LC_MESSAGES/django.po @@ -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." + diff --git a/loko/contracts/migrations/0013_companyteam_maintain_types_and_more.py b/loko/contracts/migrations/0013_companyteam_maintain_types_and_more.py new file mode 100644 index 0000000..b10daa5 --- /dev/null +++ b/loko/contracts/migrations/0013_companyteam_maintain_types_and_more.py @@ -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), + ] diff --git a/loko/contracts/models.py b/loko/contracts/models.py index f35903f..c455b63 100644 --- a/loko/contracts/models.py +++ b/loko/contracts/models.py @@ -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, diff --git a/loko/interventions/models.py b/loko/interventions/models.py index 61ae7ca..19c7992 100644 --- a/loko/interventions/models.py +++ b/loko/interventions/models.py @@ -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: diff --git a/loko/interventions/services_control_assignment.py b/loko/interventions/services_control_assignment.py index e81392f..0189409 100644 --- a/loko/interventions/services_control_assignment.py +++ b/loko/interventions/services_control_assignment.py @@ -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, ) diff --git a/loko/interventions/tests/test_control_assignment.py b/loko/interventions/tests/test_control_assignment.py index 6ccab79..d874793 100644 --- a/loko/interventions/tests/test_control_assignment.py +++ b/loko/interventions/tests/test_control_assignment.py @@ -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(