feat: implement automatic contract post assignment for interventions based on operation templates
This commit is contained in:
parent
cfd11654ab
commit
be04a5e4c0
9 changed files with 482 additions and 3 deletions
|
|
@ -90,6 +90,35 @@ def _get_symptom_autoclose_inline():
|
||||||
return SymptomAutoCloseInline
|
return SymptomAutoCloseInline
|
||||||
|
|
||||||
|
|
||||||
|
def _get_operation_template_posts_inline():
|
||||||
|
from interventions.models import ContractOperationTemplatePost, OperationTemplate
|
||||||
|
from django.db.models import Q
|
||||||
|
|
||||||
|
class ContractOperationTemplatePostInline(admin.TabularInline):
|
||||||
|
model = ContractOperationTemplatePost
|
||||||
|
extra = 0
|
||||||
|
fields = ('operation_template', 'contract_post', 'default_quantity', 'comment')
|
||||||
|
verbose_name = "Poste par défaut (Template d'opérations)"
|
||||||
|
verbose_name_plural = "Postes par défaut (Templates d'opérations)"
|
||||||
|
|
||||||
|
def formfield_for_foreignkey(self, db_field, request, **kwargs):
|
||||||
|
if db_field.name == "contract_post":
|
||||||
|
contract_id = request.resolver_match.kwargs.get('object_id') if request.resolver_match else None
|
||||||
|
if contract_id:
|
||||||
|
kwargs["queryset"] = ContractPost.objects.filter(contract_id=contract_id).order_by('order_number')
|
||||||
|
elif db_field.name == "operation_template":
|
||||||
|
contract_id = request.resolver_match.kwargs.get('object_id') if request.resolver_match else None
|
||||||
|
if contract_id:
|
||||||
|
contract = Contract.objects.filter(pk=contract_id).first()
|
||||||
|
if contract and contract.thematics.exists():
|
||||||
|
kwargs["queryset"] = OperationTemplate.objects.filter(
|
||||||
|
Q(thematic__in=contract.thematics.all()) | Q(thematic__isnull=True)
|
||||||
|
).distinct().order_by('name')
|
||||||
|
return super().formfield_for_foreignkey(db_field, request, **kwargs)
|
||||||
|
|
||||||
|
return ContractOperationTemplatePostInline
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Company)
|
@admin.register(Company)
|
||||||
class CompanyAdmin(admin.ModelAdmin):
|
class CompanyAdmin(admin.ModelAdmin):
|
||||||
list_display = ('name', 'contact_email', 'phone_number')
|
list_display = ('name', 'contact_email', 'phone_number')
|
||||||
|
|
@ -102,7 +131,7 @@ class ContractAdmin(admin.ModelAdmin):
|
||||||
filter_horizontal = ('thematics',)
|
filter_horizontal = ('thematics',)
|
||||||
|
|
||||||
def get_inlines(self, request, obj=None):
|
def get_inlines(self, request, obj=None):
|
||||||
return [_get_pause_reason_inline(), _get_symptom_autoclose_inline()] + list(super().get_inlines(request, obj))
|
return [_get_pause_reason_inline(), _get_symptom_autoclose_inline(), _get_operation_template_posts_inline()] + list(super().get_inlines(request, obj))
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, {
|
(None, {
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ from .models import (
|
||||||
InspectionConfiguration, InspectionConfigurationContract,
|
InspectionConfiguration, InspectionConfigurationContract,
|
||||||
OperationTemplate, OperationTemplateItem, OperationType,
|
OperationTemplate, OperationTemplateItem, OperationType,
|
||||||
OperationTemplateAssetModelFilter,
|
OperationTemplateAssetModelFilter,
|
||||||
|
ContractOperationTemplatePost,
|
||||||
Operation, OperationPhoto
|
Operation, OperationPhoto
|
||||||
)
|
)
|
||||||
from .services import generate_interventions_excel
|
from .services import generate_interventions_excel
|
||||||
|
|
@ -777,13 +778,23 @@ class OperationTemplateAdminForm(forms.ModelForm):
|
||||||
js = ('interventions/admin_operation_template_dynamic.js',)
|
js = ('interventions/admin_operation_template_dynamic.js',)
|
||||||
|
|
||||||
|
|
||||||
|
class OperationTemplateContractPostInline(admin.TabularInline):
|
||||||
|
model = ContractOperationTemplatePost
|
||||||
|
extra = 0
|
||||||
|
fields = ('contract', 'contract_post', 'default_quantity', 'comment')
|
||||||
|
verbose_name = "Poste de contrat par défaut"
|
||||||
|
verbose_name_plural = "Postes de contrats par défaut"
|
||||||
|
autocomplete_fields = ('contract', 'contract_post')
|
||||||
|
|
||||||
|
|
||||||
@admin.register(OperationTemplate)
|
@admin.register(OperationTemplate)
|
||||||
class OperationTemplateAdmin(admin.ModelAdmin):
|
class OperationTemplateAdmin(admin.ModelAdmin):
|
||||||
form = OperationTemplateAdminForm
|
form = OperationTemplateAdminForm
|
||||||
list_display = ('code', 'name', 'description', 'thematic', 'team', 'categories_list', 'models_list')
|
list_display = ('code', 'name', 'description', 'thematic', 'team', 'categories_list', 'models_list')
|
||||||
|
search_fields = ('name', 'code', 'description')
|
||||||
list_filter = ('thematic', 'team', 'categories')
|
list_filter = ('thematic', 'team', 'categories')
|
||||||
filter_horizontal = ('categories',)
|
filter_horizontal = ('categories',)
|
||||||
inlines = [OperationTemplateItemInline]
|
inlines = [OperationTemplateItemInline, OperationTemplateContractPostInline]
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, {
|
(None, {
|
||||||
'fields': ('code', 'name', 'description', 'thematic', 'team', 'guided_template')
|
'fields': ('code', 'name', 'description', 'thematic', 'team', 'guided_template')
|
||||||
|
|
@ -813,6 +824,19 @@ class OperationTemplateAdmin(admin.ModelAdmin):
|
||||||
return ', '.join(str(f) for f in filters) + ('...' if obj.asset_model_filters.count() > 3 else '')
|
return ', '.join(str(f) for f in filters) + ('...' if obj.asset_model_filters.count() > 3 else '')
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ContractOperationTemplatePost)
|
||||||
|
class ContractOperationTemplatePostAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('contract', 'operation_template', 'contract_post', 'default_quantity', 'comment')
|
||||||
|
list_filter = ('contract', 'operation_template', 'contract__thematics')
|
||||||
|
search_fields = (
|
||||||
|
'contract__contract_number',
|
||||||
|
'operation_template__name',
|
||||||
|
'contract_post__code',
|
||||||
|
'contract_post__description_fr',
|
||||||
|
)
|
||||||
|
autocomplete_fields = ('contract', 'operation_template', 'contract_post')
|
||||||
|
|
||||||
|
|
||||||
@admin.register(OperationTemplateItem)
|
@admin.register(OperationTemplateItem)
|
||||||
class OperationTemplateItemAdmin(admin.ModelAdmin):
|
class OperationTemplateItemAdmin(admin.ModelAdmin):
|
||||||
list_display = ('code', 'get_name', 'template', 'status_set', 'measurement_definition', 'is_generic', 'order')
|
list_display = ('code', 'get_name', 'template', 'status_set', 'measurement_definition', 'is_generic', 'order')
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
# Generated by Django 6.0.7 on 2026-08-19 13:11
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('contracts', '0003_contract_allow_team_members_edit'),
|
||||||
|
('interventions', '0004_alter_intervention_type'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ContractOperationTemplatePost',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('default_quantity', models.DecimalField(decimal_places=2, default=1, max_digits=10, verbose_name='Quantité par défaut')),
|
||||||
|
('comment', models.CharField(blank=True, max_length=1024, null=True, verbose_name='Commentaire par défaut')),
|
||||||
|
('contract', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='operation_template_posts', to='contracts.contract', verbose_name='Contrat')),
|
||||||
|
('contract_post', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='operation_template_posts', to='contracts.contractpost', verbose_name='Poste de contrat')),
|
||||||
|
('operation_template', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contract_posts', to='interventions.operationtemplate', verbose_name="Template d'opérations")),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': "Poste par défaut (Contrat × Template d'opérations)",
|
||||||
|
'verbose_name_plural': "Postes par défaut (Contrats × Templates d'opérations)",
|
||||||
|
'ordering': ['contract', 'operation_template', 'contract_post__order_number'],
|
||||||
|
'unique_together': {('contract', 'operation_template', 'contract_post')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -2,6 +2,7 @@ from django.contrib.gis.db import models
|
||||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
from django.utils.translation import get_language, gettext_lazy as _
|
from django.utils.translation import get_language, gettext_lazy as _
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.db.models import Sum, F, Q
|
from django.db.models import Sum, F, Q
|
||||||
|
|
@ -1678,6 +1679,65 @@ class OperationTemplateItem(models.Model):
|
||||||
return f"{self.template.name} - {self.name_fr}"
|
return f"{self.template.name} - {self.name_fr}"
|
||||||
|
|
||||||
|
|
||||||
|
class ContractOperationTemplatePost(models.Model):
|
||||||
|
"""
|
||||||
|
Associe un poste de contrat à un template d'opérations avec une quantité par défaut.
|
||||||
|
Utilisé lors de la création d'interventions préventives pour pré-remplir les postes de contrat.
|
||||||
|
"""
|
||||||
|
contract = models.ForeignKey(
|
||||||
|
Contract,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='operation_template_posts',
|
||||||
|
verbose_name=_("Contrat"),
|
||||||
|
)
|
||||||
|
operation_template = models.ForeignKey(
|
||||||
|
OperationTemplate,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='contract_posts',
|
||||||
|
verbose_name=_("Template d'opérations"),
|
||||||
|
)
|
||||||
|
contract_post = models.ForeignKey(
|
||||||
|
ContractPost,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='operation_template_posts',
|
||||||
|
verbose_name=_("Poste de contrat"),
|
||||||
|
)
|
||||||
|
default_quantity = models.DecimalField(
|
||||||
|
max_digits=10,
|
||||||
|
decimal_places=2,
|
||||||
|
default=1,
|
||||||
|
verbose_name=_("Quantité par défaut"),
|
||||||
|
)
|
||||||
|
comment = models.CharField(
|
||||||
|
max_length=1024,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name=_("Commentaire par défaut"),
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = [('contract', 'operation_template', 'contract_post')]
|
||||||
|
ordering = ['contract', 'operation_template', 'contract_post__order_number']
|
||||||
|
verbose_name = _("Poste par défaut (Contrat × Template d'opérations)")
|
||||||
|
verbose_name_plural = _("Postes par défaut (Contrats × Templates d'opérations)")
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.contract.contract_number} × {self.operation_template.name} → {self.contract_post.code} ({self.default_quantity})"
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
super().clean()
|
||||||
|
if self.contract_post_id and self.contract_id:
|
||||||
|
if self.contract_post.contract_id != self.contract_id:
|
||||||
|
raise ValidationError({
|
||||||
|
'contract_post': _("Ce poste n'appartient pas au contrat sélectionné.")
|
||||||
|
})
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
if self.contract_post_id and not self.contract_id:
|
||||||
|
self.contract = self.contract_post.contract
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
EMAIL_LOG_TYPE_CHOICES = {
|
EMAIL_LOG_TYPE_CHOICES = {
|
||||||
'transmission': _('Envoi de la fiche'),
|
'transmission': _('Envoi de la fiche'),
|
||||||
'reminder': _('Rappel'),
|
'reminder': _('Rappel'),
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ from assets.models import AssetCategory
|
||||||
from interventions.models import (
|
from interventions.models import (
|
||||||
Intervention,
|
Intervention,
|
||||||
InterventionContractPost,
|
InterventionContractPost,
|
||||||
|
ContractOperationTemplatePost,
|
||||||
InterventionPlanificationTimeLine,
|
InterventionPlanificationTimeLine,
|
||||||
InterventionRealizationTimeLine,
|
InterventionRealizationTimeLine,
|
||||||
Operation,
|
Operation,
|
||||||
|
|
@ -882,6 +883,287 @@ class PreventiveTemplateTeamAssignmentTests(TestCase):
|
||||||
self.assertGreaterEqual(len(data['teams']), 2)
|
self.assertGreaterEqual(len(data['teams']), 2)
|
||||||
|
|
||||||
|
|
||||||
|
class ContractOperationTemplatePostTests(TestCase):
|
||||||
|
"""Test associating ContractPosts with default quantities to OperationTemplates and preventive interventions."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
User = get_user_model()
|
||||||
|
self.user = User.objects.create_user(username='test_user_posts', password='password123')
|
||||||
|
self.thematic, _ = Thematic.objects.get_or_create(
|
||||||
|
code='trafficlights',
|
||||||
|
defaults={'name_fr': 'Feux', 'name_nl': 'Verkeerslichten'}
|
||||||
|
)
|
||||||
|
self.role, _ = Role.objects.get_or_create(name='manager')
|
||||||
|
self.user_config = UserConfig.objects.create(user=self.user, is_intern=True)
|
||||||
|
self.user_config.roles.add(self.role)
|
||||||
|
|
||||||
|
self.company = Company.objects.create(name='Provider Co')
|
||||||
|
self.contract = Contract.objects.create(
|
||||||
|
contract_number='CTR-TEST-001',
|
||||||
|
company=self.company,
|
||||||
|
start_date=timezone.now().date(),
|
||||||
|
end_date=timezone.now().date() + timedelta(days=365),
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
self.contract.thematics.add(self.thematic)
|
||||||
|
self.user_config.contracts.add(self.contract)
|
||||||
|
UserThematics.objects.create(user_config=self.user_config, thematic=self.thematic, can_edit_interventions=True)
|
||||||
|
|
||||||
|
self.contract_other = Contract.objects.create(
|
||||||
|
contract_number='CTR-OTHER-002',
|
||||||
|
company=self.company,
|
||||||
|
start_date=timezone.now().date(),
|
||||||
|
end_date=timezone.now().date() + timedelta(days=365),
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.post_1 = ContractPost.objects.create(
|
||||||
|
contract=self.contract,
|
||||||
|
code='P01',
|
||||||
|
description_fr='Poste 1 FR',
|
||||||
|
description_nl='Post 1 NL',
|
||||||
|
order_number=1,
|
||||||
|
unit='pc',
|
||||||
|
unit_price=Decimal('10.00'),
|
||||||
|
)
|
||||||
|
self.post_2 = ContractPost.objects.create(
|
||||||
|
contract=self.contract,
|
||||||
|
code='P02',
|
||||||
|
description_fr='Poste 2 FR',
|
||||||
|
description_nl='Post 2 NL',
|
||||||
|
order_number=2,
|
||||||
|
unit='m',
|
||||||
|
unit_price=Decimal('25.50'),
|
||||||
|
)
|
||||||
|
self.post_3 = ContractPost.objects.create(
|
||||||
|
contract=self.contract,
|
||||||
|
code='P03',
|
||||||
|
description_fr='Poste 3 FR',
|
||||||
|
description_nl='Post 3 NL',
|
||||||
|
order_number=3,
|
||||||
|
unit='h',
|
||||||
|
unit_price=Decimal('50.00'),
|
||||||
|
)
|
||||||
|
self.post_other = ContractPost.objects.create(
|
||||||
|
contract=self.contract_other,
|
||||||
|
code='P_OTHER',
|
||||||
|
description_fr='Poste Autre Contrat',
|
||||||
|
description_nl='Post Andere',
|
||||||
|
order_number=1,
|
||||||
|
unit='pc',
|
||||||
|
unit_price=Decimal('100.00'),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.template_a = OperationTemplate.objects.create(
|
||||||
|
name='Template A',
|
||||||
|
thematic=self.thematic
|
||||||
|
)
|
||||||
|
self.template_b = OperationTemplate.objects.create(
|
||||||
|
name='Template B',
|
||||||
|
thematic=self.thematic
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_model_str_and_save_clean(self):
|
||||||
|
"""Test model validation, clean method, auto-contract and string representation."""
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
|
# Auto-populates contract if only contract_post is set
|
||||||
|
mapping = ContractOperationTemplatePost(
|
||||||
|
operation_template=self.template_a,
|
||||||
|
contract_post=self.post_1,
|
||||||
|
default_quantity=Decimal('2.00'),
|
||||||
|
comment='Commentaire test'
|
||||||
|
)
|
||||||
|
mapping.save()
|
||||||
|
self.assertEqual(mapping.contract, self.contract)
|
||||||
|
self.assertIn('CTR-TEST-001', str(mapping))
|
||||||
|
self.assertIn('Template A', str(mapping))
|
||||||
|
self.assertIn('P01', str(mapping))
|
||||||
|
|
||||||
|
# Invalid: contract_post belongs to another contract
|
||||||
|
invalid_mapping = ContractOperationTemplatePost(
|
||||||
|
contract=self.contract,
|
||||||
|
operation_template=self.template_a,
|
||||||
|
contract_post=self.post_other,
|
||||||
|
default_quantity=Decimal('1.00')
|
||||||
|
)
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
invalid_mapping.clean()
|
||||||
|
|
||||||
|
def test_assign_contract_posts_from_templates_single(self):
|
||||||
|
"""Test assigning default posts to an intervention from a single template."""
|
||||||
|
from interventions.views.helpers import _assign_contract_posts_from_templates
|
||||||
|
|
||||||
|
ContractOperationTemplatePost.objects.create(
|
||||||
|
contract=self.contract,
|
||||||
|
operation_template=self.template_a,
|
||||||
|
contract_post=self.post_1,
|
||||||
|
default_quantity=Decimal('3.00'),
|
||||||
|
comment='Nettoyage standard'
|
||||||
|
)
|
||||||
|
ContractOperationTemplatePost.objects.create(
|
||||||
|
contract=self.contract,
|
||||||
|
operation_template=self.template_a,
|
||||||
|
contract_post=self.post_2,
|
||||||
|
default_quantity=Decimal('1.50'),
|
||||||
|
)
|
||||||
|
|
||||||
|
intervention = Intervention.objects.create(
|
||||||
|
title='Intervention Test',
|
||||||
|
contract=self.contract,
|
||||||
|
thematic=self.thematic,
|
||||||
|
maintain_type='preventive',
|
||||||
|
created_by=self.user
|
||||||
|
)
|
||||||
|
|
||||||
|
_assign_contract_posts_from_templates(intervention, [self.template_a])
|
||||||
|
|
||||||
|
posts = list(intervention.interv_contract_posts.all().order_by('contract_post__order_number'))
|
||||||
|
self.assertEqual(len(posts), 2)
|
||||||
|
self.assertEqual(posts[0].contract_post, self.post_1)
|
||||||
|
self.assertEqual(posts[0].quantity, Decimal('3.00'))
|
||||||
|
self.assertEqual(posts[0].comment, 'Nettoyage standard')
|
||||||
|
self.assertEqual(posts[1].contract_post, self.post_2)
|
||||||
|
self.assertEqual(posts[1].quantity, Decimal('1.50'))
|
||||||
|
|
||||||
|
def test_assign_contract_posts_deduplication_first_found(self):
|
||||||
|
"""
|
||||||
|
When multiple templates are chosen and share a post,
|
||||||
|
the first one encountered must be kept, and subsequent duplicates ignored.
|
||||||
|
"""
|
||||||
|
from interventions.views.helpers import _assign_contract_posts_from_templates
|
||||||
|
|
||||||
|
# Template A defines Post 1 (qty 2) and Post 2 (qty 5)
|
||||||
|
ContractOperationTemplatePost.objects.create(
|
||||||
|
contract=self.contract,
|
||||||
|
operation_template=self.template_a,
|
||||||
|
contract_post=self.post_1,
|
||||||
|
default_quantity=Decimal('2.00'),
|
||||||
|
comment='From Template A'
|
||||||
|
)
|
||||||
|
ContractOperationTemplatePost.objects.create(
|
||||||
|
contract=self.contract,
|
||||||
|
operation_template=self.template_a,
|
||||||
|
contract_post=self.post_2,
|
||||||
|
default_quantity=Decimal('5.00'),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Template B defines Post 1 (qty 10 with other comment) and Post 3 (qty 1)
|
||||||
|
ContractOperationTemplatePost.objects.create(
|
||||||
|
contract=self.contract,
|
||||||
|
operation_template=self.template_b,
|
||||||
|
contract_post=self.post_1,
|
||||||
|
default_quantity=Decimal('10.00'),
|
||||||
|
comment='From Template B'
|
||||||
|
)
|
||||||
|
ContractOperationTemplatePost.objects.create(
|
||||||
|
contract=self.contract,
|
||||||
|
operation_template=self.template_b,
|
||||||
|
contract_post=self.post_3,
|
||||||
|
default_quantity=Decimal('1.00'),
|
||||||
|
)
|
||||||
|
|
||||||
|
intervention = Intervention.objects.create(
|
||||||
|
title='Intervention Multi-Templates',
|
||||||
|
contract=self.contract,
|
||||||
|
thematic=self.thematic,
|
||||||
|
maintain_type='preventive',
|
||||||
|
created_by=self.user
|
||||||
|
)
|
||||||
|
|
||||||
|
# Pass templates in order [A, B]
|
||||||
|
_assign_contract_posts_from_templates(intervention, [self.template_a, self.template_b])
|
||||||
|
|
||||||
|
posts = list(intervention.interv_contract_posts.all().order_by('contract_post__order_number'))
|
||||||
|
self.assertEqual(len(posts), 3)
|
||||||
|
|
||||||
|
# Post 1 must come from Template A (qty 2, first found)
|
||||||
|
self.assertEqual(posts[0].contract_post, self.post_1)
|
||||||
|
self.assertEqual(posts[0].quantity, Decimal('2.00'))
|
||||||
|
self.assertEqual(posts[0].comment, 'From Template A')
|
||||||
|
|
||||||
|
# Post 2 from Template A
|
||||||
|
self.assertEqual(posts[1].contract_post, self.post_2)
|
||||||
|
self.assertEqual(posts[1].quantity, Decimal('5.00'))
|
||||||
|
|
||||||
|
# Post 3 from Template B
|
||||||
|
self.assertEqual(posts[2].contract_post, self.post_3)
|
||||||
|
self.assertEqual(posts[2].quantity, Decimal('1.00'))
|
||||||
|
|
||||||
|
def test_assign_contract_posts_empty_or_no_contract(self):
|
||||||
|
"""Test that helper handles empty templates or no contract gracefully."""
|
||||||
|
from interventions.views.helpers import _assign_contract_posts_from_templates
|
||||||
|
|
||||||
|
intervention_no_contract = Intervention.objects.create(
|
||||||
|
title='No Contract',
|
||||||
|
thematic=self.thematic,
|
||||||
|
created_by=self.user
|
||||||
|
)
|
||||||
|
_assign_contract_posts_from_templates(intervention_no_contract, [self.template_a])
|
||||||
|
self.assertEqual(intervention_no_contract.interv_contract_posts.count(), 0)
|
||||||
|
|
||||||
|
intervention = Intervention.objects.create(
|
||||||
|
title='With Contract',
|
||||||
|
contract=self.contract,
|
||||||
|
thematic=self.thematic,
|
||||||
|
created_by=self.user
|
||||||
|
)
|
||||||
|
_assign_contract_posts_from_templates(intervention, [])
|
||||||
|
self.assertEqual(intervention.interv_contract_posts.count(), 0)
|
||||||
|
|
||||||
|
def test_preventive_creation_flow_auto_creates_posts(self):
|
||||||
|
"""Test full preventive intervention creation flow automatically populates posts."""
|
||||||
|
from assets.models import TrafficLightIntersection
|
||||||
|
loc = TrafficLightIntersection.objects.create(code='TL-POST-01', name_fr='Carrefour Test')
|
||||||
|
|
||||||
|
ContractOperationTemplatePost.objects.create(
|
||||||
|
contract=self.contract,
|
||||||
|
operation_template=self.template_a,
|
||||||
|
contract_post=self.post_1,
|
||||||
|
default_quantity=Decimal('4.00'),
|
||||||
|
comment='Auto Post'
|
||||||
|
)
|
||||||
|
|
||||||
|
self.client.login(username='test_user_posts', password='password123')
|
||||||
|
url = reverse('interventions:intervention_add_preventive', kwargs={'thematic_code': 'trafficlights'})
|
||||||
|
|
||||||
|
session = self.client.session
|
||||||
|
session['intervention_drafts'] = {
|
||||||
|
'draft-posts': {
|
||||||
|
'locations': [loc.id],
|
||||||
|
'assets': [],
|
||||||
|
'form_data': {},
|
||||||
|
'timestamp': time.time(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
session.save()
|
||||||
|
|
||||||
|
post_data = {
|
||||||
|
'title': 'Preventive With Posts',
|
||||||
|
'status': 'in_preparation',
|
||||||
|
'type': 'care',
|
||||||
|
'contract': self.contract.id,
|
||||||
|
'operation_templates': [str(self.template_a.id)],
|
||||||
|
'expected_begin_time': '2026-08-20T08:00',
|
||||||
|
'expected_end_time': '2026-08-20T17:00',
|
||||||
|
'recurrence_count': 0,
|
||||||
|
'type_of_selected_elements': 'locations',
|
||||||
|
'intervention_creation_mode': 'one_per_element',
|
||||||
|
'draft_id': 'draft-posts',
|
||||||
|
}
|
||||||
|
response = self.client.post(url, post_data)
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
|
intervention = Intervention.objects.filter(location_code='TL-POST-01').first()
|
||||||
|
self.assertIsNotNone(intervention)
|
||||||
|
posts = list(intervention.interv_contract_posts.all())
|
||||||
|
self.assertEqual(len(posts), 1)
|
||||||
|
self.assertEqual(posts[0].contract_post, self.post_1)
|
||||||
|
self.assertEqual(posts[0].quantity, Decimal('4.00'))
|
||||||
|
self.assertEqual(posts[0].comment, 'Auto Post')
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1868,6 +1868,7 @@ def interventions_add(request, thematic_code):
|
||||||
intervention.equipment_operation_templates.add(*templates)
|
intervention.equipment_operation_templates.add(*templates)
|
||||||
_assign_guided_template(intervention, templates)
|
_assign_guided_template(intervention, templates)
|
||||||
_assign_team_from_templates(intervention, templates)
|
_assign_team_from_templates(intervention, templates)
|
||||||
|
_assign_contract_posts_from_templates(intervention, templates)
|
||||||
_add_operations_from_templates(intervention, templates, only_generic=True)
|
_add_operations_from_templates(intervention, templates, only_generic=True)
|
||||||
if asset_pairs:
|
if asset_pairs:
|
||||||
resolved_assets = _resolve_assets_from_pairs(asset_pairs)
|
resolved_assets = _resolve_assets_from_pairs(asset_pairs)
|
||||||
|
|
@ -1884,6 +1885,8 @@ def interventions_add(request, thematic_code):
|
||||||
)
|
)
|
||||||
if resolved_contract:
|
if resolved_contract:
|
||||||
intervention.contract = resolved_contract
|
intervention.contract = resolved_contract
|
||||||
|
if inspection_config and templates:
|
||||||
|
_assign_contract_posts_from_templates(intervention, templates)
|
||||||
if resolved_provider and not intervention.assigned_provider:
|
if resolved_provider and not intervention.assigned_provider:
|
||||||
intervention.assigned_provider = resolved_provider
|
intervention.assigned_provider = resolved_provider
|
||||||
if resolved_contract or resolved_provider:
|
if resolved_contract or resolved_provider:
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,7 @@ from interventions.models import (InterventionSubscription, Intervention, Interv
|
||||||
InterventionOccupation,
|
InterventionOccupation,
|
||||||
Operation, OperationTemplate, OperationTemplateItem, OperationStatus, OperationStatusSet, MeasurementDefinition,
|
Operation, OperationTemplate, OperationTemplateItem, OperationStatus, OperationStatusSet, MeasurementDefinition,
|
||||||
GuidedOperationTemplate, GuidedOperationData, GuidedOperationMedia,
|
GuidedOperationTemplate, GuidedOperationData, GuidedOperationMedia,
|
||||||
|
ContractOperationTemplatePost,
|
||||||
InterventionPostPeriodQuantity, InterventionEmailLog, InterventionAssignment,
|
InterventionPostPeriodQuantity, InterventionEmailLog, InterventionAssignment,
|
||||||
DirectContractRouting)
|
DirectContractRouting)
|
||||||
from interventions.models import STATUS_CHOICES, STATUS_ORDERS, MAINTAIN_CHOICES, TYPE_CHOICES, ORIGIN_TYPE_CHOICES, PAUSE_REASON_CHOICES, get_type_choices
|
from interventions.models import STATUS_CHOICES, STATUS_ORDERS, MAINTAIN_CHOICES, TYPE_CHOICES, ORIGIN_TYPE_CHOICES, PAUSE_REASON_CHOICES, get_type_choices
|
||||||
|
|
@ -145,6 +146,7 @@ __all__ = [
|
||||||
'_apply_realization_side_effects',
|
'_apply_realization_side_effects',
|
||||||
'_asset_matches_filters',
|
'_asset_matches_filters',
|
||||||
'_asset_matches_template_filters',
|
'_asset_matches_template_filters',
|
||||||
|
'_assign_contract_posts_from_templates',
|
||||||
'_assign_guided_template',
|
'_assign_guided_template',
|
||||||
'_assign_team_from_templates',
|
'_assign_team_from_templates',
|
||||||
'_attach_equipment_templates',
|
'_attach_equipment_templates',
|
||||||
|
|
@ -1123,6 +1125,49 @@ def _assign_team_from_templates(intervention, templates):
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _assign_contract_posts_from_templates(intervention, templates):
|
||||||
|
"""
|
||||||
|
Associe les postes de contrat par défaut configurés pour les OperationTemplates donnés
|
||||||
|
sur le contrat de l'intervention.
|
||||||
|
Si plusieurs templates ont des postes en doublon, le premier trouvé est conservé.
|
||||||
|
"""
|
||||||
|
if not templates:
|
||||||
|
return
|
||||||
|
|
||||||
|
contract = intervention.contract or (intervention.order.contract if intervention.order else None)
|
||||||
|
if not contract:
|
||||||
|
return
|
||||||
|
|
||||||
|
existing_post_ids = set(
|
||||||
|
intervention.interv_contract_posts.values_list('contract_post_id', flat=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
posts_to_create = []
|
||||||
|
for template in templates:
|
||||||
|
template_posts = (
|
||||||
|
ContractOperationTemplatePost.objects
|
||||||
|
.filter(contract=contract, operation_template=template)
|
||||||
|
.select_related('contract_post')
|
||||||
|
.order_by('contract_post__order_number', 'id')
|
||||||
|
)
|
||||||
|
for tp in template_posts:
|
||||||
|
if tp.contract_post_id not in existing_post_ids:
|
||||||
|
posts_to_create.append(
|
||||||
|
InterventionContractPost(
|
||||||
|
intervention=intervention,
|
||||||
|
contract_post=tp.contract_post,
|
||||||
|
quantity=tp.default_quantity,
|
||||||
|
comment=tp.comment or '',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
existing_post_ids.add(tp.contract_post_id)
|
||||||
|
|
||||||
|
if posts_to_create:
|
||||||
|
InterventionContractPost.objects.bulk_create(posts_to_create)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _get_equipment_templates_for_intervention(intervention):
|
def _get_equipment_templates_for_intervention(intervention):
|
||||||
templates = list(intervention.equipment_operation_templates.all())
|
templates = list(intervention.equipment_operation_templates.all())
|
||||||
if templates:
|
if templates:
|
||||||
|
|
|
||||||
|
|
@ -449,6 +449,7 @@ def intervention_add_preventive(request, thematic_code):
|
||||||
_attach_equipment_templates(intervention, operation_templates)
|
_attach_equipment_templates(intervention, operation_templates)
|
||||||
_assign_guided_template(intervention, operation_templates)
|
_assign_guided_template(intervention, operation_templates)
|
||||||
_assign_team_from_templates(intervention, operation_templates)
|
_assign_team_from_templates(intervention, operation_templates)
|
||||||
|
_assign_contract_posts_from_templates(intervention, operation_templates)
|
||||||
|
|
||||||
# 1) Lien générique intervention ↔ localisation
|
# 1) Lien générique intervention ↔ localisation
|
||||||
ct = ContentType.objects.get_for_model(loc.__class__)
|
ct = ContentType.objects.get_for_model(loc.__class__)
|
||||||
|
|
@ -576,6 +577,7 @@ def intervention_add_preventive(request, thematic_code):
|
||||||
_attach_equipment_templates(intervention, operation_templates)
|
_attach_equipment_templates(intervention, operation_templates)
|
||||||
_assign_guided_template(intervention, operation_templates)
|
_assign_guided_template(intervention, operation_templates)
|
||||||
_assign_team_from_templates(intervention, operation_templates)
|
_assign_team_from_templates(intervention, operation_templates)
|
||||||
|
_assign_contract_posts_from_templates(intervention, operation_templates)
|
||||||
|
|
||||||
# 1) Lien générique Asset ↔ Intervention
|
# 1) Lien générique Asset ↔ Intervention
|
||||||
InterventionAsset.objects.get_or_create(
|
InterventionAsset.objects.get_or_create(
|
||||||
|
|
@ -647,6 +649,7 @@ def intervention_add_preventive(request, thematic_code):
|
||||||
_attach_equipment_templates(intervention, operation_templates)
|
_attach_equipment_templates(intervention, operation_templates)
|
||||||
_assign_guided_template(intervention, operation_templates)
|
_assign_guided_template(intervention, operation_templates)
|
||||||
_assign_team_from_templates(intervention, operation_templates)
|
_assign_team_from_templates(intervention, operation_templates)
|
||||||
|
_assign_contract_posts_from_templates(intervention, operation_templates)
|
||||||
|
|
||||||
# Utiliser le titre de base
|
# Utiliser le titre de base
|
||||||
intervention.title = title
|
intervention.title = title
|
||||||
|
|
|
||||||
|
|
@ -486,8 +486,9 @@ def process_worksheet(
|
||||||
|
|
||||||
# Lien template d'opérations
|
# Lien template d'opérations
|
||||||
intervention.equipment_operation_templates.add(template)
|
intervention.equipment_operation_templates.add(template)
|
||||||
from interventions.views.helpers import _assign_team_from_templates
|
from interventions.views.helpers import _assign_team_from_templates, _assign_contract_posts_from_templates
|
||||||
_assign_team_from_templates(intervention, [template])
|
_assign_team_from_templates(intervention, [template])
|
||||||
|
_assign_contract_posts_from_templates(intervention, [template])
|
||||||
|
|
||||||
# Timelines
|
# Timelines
|
||||||
InterventionTimeLine.objects.create(
|
InterventionTimeLine.objects.create(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue