1067 lines
No EOL
43 KiB
Python
1067 lines
No EOL
43 KiB
Python
from django.db import models
|
||
from django.db.models import Sum, F, FloatField, ExpressionWrapper, Q
|
||
from django.utils.text import slugify
|
||
from django.conf import settings
|
||
from django.utils.translation import gettext_lazy as _
|
||
from django.utils.translation import get_language
|
||
from django.utils import timezone
|
||
|
||
from common.models import Thematic
|
||
|
||
import os
|
||
from datetime import datetime
|
||
from decimal import Decimal
|
||
|
||
from common.storages import PrivateMediaStorage
|
||
private_storage = PrivateMediaStorage()
|
||
|
||
|
||
|
||
class Company(models.Model):
|
||
"""Modèle représentant une société ou un organisme"""
|
||
name = models.CharField(max_length=255, unique=True)
|
||
address = models.TextField(blank=True, null=True)
|
||
contact_email = models.EmailField(blank=True, null=True)
|
||
phone_number = models.CharField(max_length=20, blank=True, null=True)
|
||
logo = models.ImageField(upload_to='company_logos/', blank=True, null=True)
|
||
|
||
def __str__(self):
|
||
return self.name
|
||
|
||
class Meta:
|
||
verbose_name = "Société/Organisme"
|
||
verbose_name_plural = "Sociétés/Organismes"
|
||
ordering = ['name']
|
||
|
||
|
||
# Choix des types de maintenance (doit correspondre à MAINTAIN_CHOICES dans interventions.models)
|
||
MAINTAIN_TYPE_CHOICES = [
|
||
('corrective', _('Maintenance corrective')),
|
||
('preventive', _('Maintenance préventive')),
|
||
('ameliorative', _('Maintenance améliorative')),
|
||
]
|
||
|
||
|
||
class MaintainType(models.Model):
|
||
"""Modèle représentant un type de maintenance pour les équipes"""
|
||
code = models.CharField(max_length=50, unique=True, help_text=_("Code du type de maintenance (corrective, preventive, ameliorative)"))
|
||
name_fr = models.CharField(max_length=100, verbose_name=_("Nom (FR)"))
|
||
name_nl = models.CharField(max_length=100, verbose_name=_("Nom (NL)"))
|
||
|
||
def __str__(self):
|
||
lang = get_language()
|
||
if lang and lang.startswith('nl'):
|
||
return self.name_nl or self.name_fr
|
||
return self.name_fr
|
||
|
||
def get_name(self):
|
||
lang = get_language()
|
||
if lang and lang.startswith('nl'):
|
||
return self.name_nl or self.name_fr
|
||
return self.name_fr
|
||
|
||
class Meta:
|
||
verbose_name = _("Type de maintenance")
|
||
verbose_name_plural = _("Types de maintenance")
|
||
ordering = ['code']
|
||
|
||
|
||
class CompanyTeam(models.Model):
|
||
"""Modèle représentant une équipe au sein d'une société ou d'un organisme"""
|
||
name = models.CharField(max_length=255, unique=True)
|
||
company = models.ForeignKey(Company, on_delete=models.CASCADE, related_name="teams")
|
||
thematics = models.ManyToManyField('common.Thematic', blank=True, related_name="company_teams")
|
||
asset_categories = models.ManyToManyField(
|
||
'assets.AssetCategory',
|
||
blank=True,
|
||
related_name='managing_teams',
|
||
verbose_name=_("Catégories d'assets gérées"),
|
||
help_text=_("Catégories d'assets dont cette équipe est responsable."),
|
||
)
|
||
is_assets_manager = models.BooleanField(
|
||
default=False,
|
||
verbose_name=_("Gestionnaire d'assets"),
|
||
help_text=_("Indique si cette équipe est gestionnaire des catégories d'assets associées."),
|
||
)
|
||
is_provider = models.BooleanField(
|
||
default=False,
|
||
verbose_name=_("Prestataire"),
|
||
help_text=_("Indique si cette équipe est un prestataire extérieur."),
|
||
)
|
||
can_generate_daily_report = models.BooleanField(
|
||
default=False,
|
||
verbose_name=_("Peut générer le rapport journalier"),
|
||
help_text=_("Indique si les membres de cette équipe peuvent générer un rapport quotidien des interventions."),
|
||
)
|
||
photos_required = models.BooleanField(
|
||
default=False,
|
||
verbose_name=_("Photos obligatoires"),
|
||
help_text=_("Si activé, au moins une photo doit être ajoutée à l'intervention avant de pouvoir la terminer."),
|
||
)
|
||
restrict_today_interventions_visibility = models.BooleanField(
|
||
default=False,
|
||
verbose_name=_("Restreindre la visibilité aux interventions du jour"),
|
||
help_text=_("Si activé, les interventions ne sont visibles qu'à partir d'une certaine heure, et uniquement pour le jour même."),
|
||
)
|
||
visibility_start_hour = models.TimeField(
|
||
null=True,
|
||
blank=True,
|
||
verbose_name=_("Heure de début de visibilité"),
|
||
help_text=_("Heure locale à partir de laquelle les interventions du jour deviennent visibles (ex: 06:00:00)."),
|
||
)
|
||
restrict_to_assigned_interventions = models.BooleanField(
|
||
default=False,
|
||
verbose_name=_("Restreindre aux interventions assignées"),
|
||
help_text=_("Si activé, les membres de cette équipe ne voient que les interventions assignées à l'équipe ou directement à un membre."),
|
||
)
|
||
|
||
def __str__(self):
|
||
return f"{self.name} ({self.company.name})"
|
||
|
||
class Meta:
|
||
verbose_name = _("Équipe de société/organisme")
|
||
verbose_name_plural = _("Équipes de sociétés/organismes")
|
||
ordering = ['name']
|
||
|
||
|
||
class CompanyMember(models.Model):
|
||
"""Modèle représentant un membre d'une équipe de société/organisme"""
|
||
name = models.CharField(max_length=255)
|
||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
|
||
teams = models.ManyToManyField(
|
||
CompanyTeam,
|
||
blank=True,
|
||
related_name="members",
|
||
verbose_name=_("Équipes"),
|
||
help_text=_("Équipes auxquelles ce membre appartient."),
|
||
)
|
||
visible_maintain_types = models.ManyToManyField(
|
||
MaintainType,
|
||
blank=True,
|
||
related_name="company_members",
|
||
verbose_name=_("Types de maintenance visibles"),
|
||
help_text=_("Types de maintenance visibles par défaut pour cet utilisateur. Si vide, tous les types sont visibles. Les interventions assignées à l'utilisateur restent toujours visibles.")
|
||
)
|
||
|
||
def __str__(self):
|
||
teams_list = ", ".join(t.name for t in self.teams.all())
|
||
return f"{self.name} ({teams_list or '—'})"
|
||
|
||
@property
|
||
def teams_display(self):
|
||
"""Retourne les noms de toutes les équipes associées séparés par une virgule."""
|
||
return ", ".join(t.name for t in self.teams.all())
|
||
|
||
@property
|
||
def company_ids_str(self):
|
||
"""Retourne une liste d'identifiants d'entreprises des équipes sous forme de chaîne de caractères."""
|
||
return ",".join(str(t.company_id) for t in self.teams.all() if t.company_id)
|
||
|
||
def get_visible_maintain_type_codes(self):
|
||
"""Retourne la liste des codes de types de maintenance visibles.
|
||
Si aucun n'est défini, retourne None (tous visibles)."""
|
||
codes = list(self.visible_maintain_types.values_list('code', flat=True))
|
||
return codes if codes else None
|
||
|
||
class Meta:
|
||
verbose_name = "Membre de société/organisme"
|
||
verbose_name_plural = "Membres de sociétés/organismes"
|
||
ordering = ['name']
|
||
|
||
|
||
class CompanyMemberHistory(models.Model):
|
||
"""Historique des appartenances d'un membre à une équipe"""
|
||
member = models.ForeignKey(
|
||
CompanyMember,
|
||
on_delete=models.CASCADE,
|
||
related_name='team_history',
|
||
verbose_name=_("Membre"),
|
||
)
|
||
team = models.ForeignKey(
|
||
CompanyTeam,
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name='member_history',
|
||
verbose_name=_("Équipe"),
|
||
)
|
||
join_date = models.DateField(verbose_name=_("Date d'entrée"))
|
||
leave_date = models.DateField(null=True, blank=True, verbose_name=_("Date de sortie"))
|
||
changed_by = models.ForeignKey(
|
||
settings.AUTH_USER_MODEL,
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name='member_team_changes',
|
||
verbose_name=_("Modifié par"),
|
||
)
|
||
|
||
def __str__(self):
|
||
team_name = self.team.name if self.team else "—"
|
||
leave = self.leave_date.isoformat() if self.leave_date else "…"
|
||
return f"{self.member.name} → {team_name} ({self.join_date} / {leave})"
|
||
|
||
class Meta:
|
||
verbose_name = _("Historique d'équipe d'un membre")
|
||
verbose_name_plural = _("Historiques d'équipe des membres")
|
||
ordering = ['member', '-join_date']
|
||
|
||
|
||
class UserCompanyTeamAccess(models.Model):
|
||
"""Permissions d'accès d'un utilisateur à une équipe de société/organisme."""
|
||
user_config = models.ForeignKey(
|
||
'common.UserConfig',
|
||
on_delete=models.CASCADE,
|
||
related_name='team_accesses',
|
||
verbose_name=_("Configuration utilisateur"),
|
||
)
|
||
team = models.ForeignKey(
|
||
CompanyTeam,
|
||
on_delete=models.CASCADE,
|
||
related_name='user_accesses',
|
||
verbose_name=_("Équipe"),
|
||
)
|
||
can_view = models.BooleanField(
|
||
default=False,
|
||
verbose_name=_("Peut consulter"),
|
||
help_text=_("L'utilisateur peut voir cette équipe et ses membres."),
|
||
)
|
||
can_edit = models.BooleanField(
|
||
default=False,
|
||
verbose_name=_("Peut éditer"),
|
||
help_text=_("L'utilisateur peut créer/supprimer des équipes et des membres, et déplacer des membres entre équipes."),
|
||
)
|
||
|
||
def __str__(self):
|
||
return f"{self.user_config.user.username} → {self.team.name}"
|
||
|
||
class Meta:
|
||
unique_together = ('user_config', 'team')
|
||
verbose_name = _("Accès utilisateur à une équipe")
|
||
verbose_name_plural = _("Accès utilisateurs aux équipes")
|
||
|
||
|
||
class Contract(models.Model):
|
||
"""Modèle représentant un contrat"""
|
||
company = models.ForeignKey(Company, on_delete=models.CASCADE, related_name="contracts")
|
||
contract_number = models.CharField(max_length=100, unique=True)
|
||
start_date = models.DateField()
|
||
end_date = models.DateField()
|
||
description = models.TextField(blank=True, null=True)
|
||
executive_officer = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="executive_officer_contracts")
|
||
committed_amount = models.DecimalField(max_digits=10, decimal_places=2, default=0, blank=True, null=True)
|
||
internal_link = models.URLField(blank=True, null=True, help_text="Lien vers le dossier interne")
|
||
notification_emails = models.CharField(blank=True, null=True, max_length=1000)
|
||
cc_notification_emails = models.CharField(
|
||
blank=True,
|
||
null=True,
|
||
max_length=1000,
|
||
verbose_name=_("Emails en copie (CC)"),
|
||
help_text=_("Adresses email à ajouter en copie des notifications (séparées par des virgules ou points-virgules)"),
|
||
)
|
||
send_email_notifications = models.BooleanField(
|
||
default=True,
|
||
help_text=_("Envoyer des notifications liées aux interventions par email."),
|
||
)
|
||
signatory_name = models.CharField(
|
||
max_length=255,
|
||
blank=True,
|
||
null=True,
|
||
verbose_name=_("Nom du signataire (prestataire)"),
|
||
help_text=_("Nom de la personne qui signe les documents côté prestataire."),
|
||
)
|
||
signatory_role = models.CharField(
|
||
max_length=255,
|
||
blank=True,
|
||
null=True,
|
||
verbose_name=_("Rôle du signataire (prestataire)"),
|
||
help_text=_("Rôle/fonction de la personne qui signe les documents côté prestataire."),
|
||
)
|
||
contact_phone = models.CharField(
|
||
max_length=50,
|
||
blank=True,
|
||
null=True,
|
||
verbose_name=_("Téléphone de contact"),
|
||
help_text=_("Numéro de téléphone du technicien de garde ou contact principal pour les urgences."),
|
||
)
|
||
thematics = models.ManyToManyField(
|
||
Thematic,
|
||
blank=True,
|
||
related_name='contracts',
|
||
verbose_name=_("Thématiques"),
|
||
help_text=_("Thématiques associées à ce contrat."),
|
||
)
|
||
is_active = models.BooleanField(
|
||
default=True,
|
||
verbose_name=_("Actif"),
|
||
help_text=_("Indique si le contrat est actif."),
|
||
)
|
||
needs_approval = models.BooleanField(
|
||
default=False,
|
||
verbose_name=_("Nécessite une approbation"),
|
||
help_text=_("Indique si les interventions liées à ce contrat nécessitent une approbation avant d'être transmises pour traitement."),
|
||
)
|
||
needs_checking = models.BooleanField(
|
||
default=False,
|
||
verbose_name=_("Nécessite une vérification"),
|
||
help_text=_("Indique si les interventions liées à ce contrat nécessitent une vérification avant d'être transmises pour traitement."),
|
||
)
|
||
uses_occupations = models.BooleanField(
|
||
default=False,
|
||
verbose_name=_("Utilise les occupations"),
|
||
help_text=_("Indique si les interventions liées à ce contrat utilisent la gestion des occupations (temps passé par membre)."),
|
||
)
|
||
uses_intervention_manager = models.BooleanField(
|
||
default=False,
|
||
verbose_name=_("Utilise les gestionnaires d'intervention"),
|
||
help_text=_("Indique si les interventions liées à ce contrat utilisent un gestionnaire d'intervention."),
|
||
)
|
||
occupations_required = models.BooleanField(
|
||
default=False,
|
||
verbose_name=_("Occupations obligatoires"),
|
||
help_text=_("Si activé, au moins une occupation doit être enregistrée avant de terminer une intervention corrective liée à ce contrat."),
|
||
)
|
||
warn_if_no_prestation_on_processed = models.BooleanField(
|
||
default=False,
|
||
verbose_name=_("Avertir si aucune prestation au passage à 'Traité'"),
|
||
help_text=_("Si activé, un avertissement est affiché lorsque l'utilisateur tente de passer une intervention au statut 'Traité' sans avoir encodé de prestation."),
|
||
)
|
||
send_daily_summary = models.BooleanField(
|
||
default=False,
|
||
verbose_name=_("Envoi groupé journalier"),
|
||
help_text=_("Si activé, les fiches d'intervention du jour sont regroupées dans un seul email envoyé en fin de journée, plutôt qu'un email par intervention."),
|
||
)
|
||
daily_summary_hour = models.PositiveSmallIntegerField(
|
||
default=18,
|
||
verbose_name=_("Heure d'envoi du résumé journalier"),
|
||
help_text=_("Heure (0-23) à laquelle le résumé journalier des interventions est envoyé (heure locale)."),
|
||
)
|
||
email_only_if_urgent = models.BooleanField(
|
||
default=False,
|
||
verbose_name=_("Emails uniquement si priorité urgente"),
|
||
help_text=_("Si activé, les notifications email ne sont envoyées que pour les interventions de priorité 'Urgent'."),
|
||
)
|
||
|
||
def __str__(self):
|
||
return f"Contrat {self.contract_number} - {self.company.name}"
|
||
|
||
class Meta:
|
||
ordering = ['contract_number']
|
||
|
||
def get_total_realized_amount(self):
|
||
"""
|
||
Retourne la somme de tous les montants prestés sur les interventions
|
||
associées à toutes les commandes du contrat.
|
||
Prend en compte les postes avec coefficient multipliable.
|
||
"""
|
||
from interventions.models import InterventionContractPost
|
||
posts = InterventionContractPost.objects.filter(
|
||
intervention__order__contract=self
|
||
).select_related('contract_post', 'intervention')
|
||
|
||
total = Decimal('0')
|
||
for post in posts:
|
||
total += post.total_price
|
||
|
||
return float(total)
|
||
|
||
def get_total_validated_amount(self):
|
||
"""
|
||
Retourne la somme des montants validés (statut = 'validated')
|
||
sur toutes les interventions liées aux commandes du contrat (peu importe leur statut).
|
||
Prend en compte les postes avec coefficient multipliable.
|
||
"""
|
||
from interventions.models import InterventionContractPost
|
||
posts = InterventionContractPost.objects.filter(
|
||
intervention__order__contract=self,
|
||
control_status='validated'
|
||
).select_related('contract_post', 'intervention')
|
||
|
||
total = Decimal('0')
|
||
for post in posts:
|
||
total += post.total_price
|
||
|
||
return float(total)
|
||
|
||
def get_total_remaining_validated_amount(self):
|
||
"""
|
||
Retourne la somme des montants validés (statut = 'validated')
|
||
sur les interventions liées aux commandes du contrat au statut "validated".
|
||
Prend en compte les postes avec coefficient multipliable.
|
||
"""
|
||
from interventions.models import InterventionContractPost
|
||
posts = InterventionContractPost.objects.filter(
|
||
intervention__order__contract=self,
|
||
intervention__status='validated',
|
||
control_status='validated'
|
||
).select_related('contract_post', 'intervention')
|
||
|
||
total = Decimal('0')
|
||
for post in posts:
|
||
total += post.total_price
|
||
|
||
return float(total)
|
||
|
||
|
||
class ContractPost(models.Model):
|
||
"""Modèle représentant un poste dans un contrat"""
|
||
contract = models.ForeignKey(Contract, on_delete=models.CASCADE, related_name="posts")
|
||
code = models.CharField(max_length=255)
|
||
description_fr = models.TextField()
|
||
description_nl = models.TextField()
|
||
order_number = models.PositiveIntegerField()
|
||
unit = models.CharField(max_length=255)
|
||
unit_price = models.DecimalField(max_digits=10, decimal_places=2)
|
||
total_quantity_used = models.DecimalField(max_digits=10, decimal_places=2, default=0)
|
||
post_type = models.CharField(max_length=100, blank=True, null=True)
|
||
is_coefficient_multipliable = models.BooleanField(default=False)
|
||
is_amount_to_be_justified = models.BooleanField(default=False)
|
||
coeff_amount_to_be_justified = models.DecimalField(max_digits=10, decimal_places=2, default=1, blank=True, null=True)
|
||
|
||
def __str__(self):
|
||
return f"{self.contract.contract_number} --- {self.code}"
|
||
|
||
def get_description_for_language(self, lang=None):
|
||
current_lang = lang or get_language() or 'fr'
|
||
if current_lang.startswith('nl'):
|
||
return self.description_nl
|
||
return self.description_fr
|
||
|
||
class Meta:
|
||
ordering = ['order_number']
|
||
|
||
|
||
|
||
CONTRACT_ORDER_STATUS_CHOICES = [
|
||
('pending', _('En préparation')),
|
||
('sent', _('Envoyé')),
|
||
('completed', _('Terminé')),
|
||
('invoiced', _('Comptabilisé')),
|
||
('paid', _('Payé')),
|
||
('cancelled', _('Annulé')),
|
||
]
|
||
|
||
def contract_order_upload_path(instance, filename):
|
||
# Exemple : "orders/CONTRAT-123/bon_commande.pdf"
|
||
contract_code = slugify(instance.contract.contract_number) # ou tout autre champ unique du contrat
|
||
return f"orders/{contract_code}/{filename}"
|
||
|
||
class ContractOrder(models.Model):
|
||
"""Modèle représentant une commande dans un contrat"""
|
||
contract = models.ForeignKey(Contract, on_delete=models.CASCADE, related_name="orders")
|
||
order_code = models.CharField(max_length=100)
|
||
description = models.TextField(blank=True, null=True)
|
||
order_date = models.DateField()
|
||
delivery_date = models.DateField()
|
||
total_amount = models.DecimalField(max_digits=10, decimal_places=2, default=0, blank=True, null=True)
|
||
order_status = models.CharField(max_length=50, choices=CONTRACT_ORDER_STATUS_CHOICES, default='pending')
|
||
order_file = models.FileField(upload_to=contract_order_upload_path, blank=True, null=True)
|
||
ordered_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="contract_orders")
|
||
projects = models.ManyToManyField('projects.Project', blank=True, related_name="orders", verbose_name=_("Projets"))
|
||
|
||
def __str__(self):
|
||
return f"Contrat {self.contract.contract_number} - {self.order_code}"
|
||
|
||
class Meta:
|
||
constraints = [
|
||
models.UniqueConstraint(fields=['contract', 'order_code'], name='unique_order_per_contract')
|
||
]
|
||
|
||
ordering = ['-order_date']
|
||
|
||
def get_total_realized_amount(self):
|
||
"""
|
||
Montant presté : somme de toutes les quantités * prix unitaire
|
||
pour les interventions liées à cette commande, indépendamment du contrôle.
|
||
Inclut également les commandes directes (DirectOrderPost).
|
||
Prend en compte les postes avec coefficient multipliable.
|
||
"""
|
||
from interventions.models import InterventionContractPost
|
||
posts = InterventionContractPost.objects.filter(
|
||
intervention__order=self
|
||
).select_related('contract_post', 'intervention')
|
||
|
||
total = Decimal('0')
|
||
for post in posts:
|
||
total += post.total_price
|
||
|
||
for direct_post in self.direct_posts.select_related('contract_post'):
|
||
total += direct_post.total_price
|
||
|
||
return float(total)
|
||
|
||
def get_total_validated_amount(self):
|
||
"""
|
||
Montant validé : uniquement les postes dont le contrôle a été validé
|
||
et dont l'intervention est au statut validé.
|
||
Inclut les commandes directes validées.
|
||
Prend en compte les postes avec coefficient multipliable.
|
||
"""
|
||
from interventions.models import InterventionContractPost
|
||
posts = InterventionContractPost.objects.filter(
|
||
intervention__order=self,
|
||
intervention__status__in=['validated', 'invoiced', 'closed'], # Inclure les interventions déjà facturées ou payées
|
||
control_status='validated'
|
||
).select_related('contract_post', 'intervention')
|
||
|
||
total = Decimal('0')
|
||
for post in posts:
|
||
total += post.total_price
|
||
|
||
for direct_post in self.direct_posts.filter(control_status='validated').select_related('contract_post'):
|
||
total += direct_post.total_price
|
||
|
||
return float(total)
|
||
|
||
def get_total_remaining_validated_amount(self):
|
||
"""
|
||
Retourne la somme des montants validés (statut = 'validated')
|
||
sur les interventions liées aux commandes du contrat au statut "validated".
|
||
Inclut les commandes directes validées.
|
||
Prend en compte les postes avec coefficient multipliable.
|
||
"""
|
||
from interventions.models import InterventionContractPost
|
||
posts = InterventionContractPost.objects.filter(
|
||
intervention__order=self,
|
||
intervention__status='validated',
|
||
control_status='validated'
|
||
).select_related('contract_post', 'intervention')
|
||
|
||
total = Decimal('0')
|
||
for post in posts:
|
||
total += post.total_price
|
||
|
||
for direct_post in self.direct_posts.filter(control_status='validated').select_related('contract_post'):
|
||
total += direct_post.total_price
|
||
|
||
return float(total)
|
||
|
||
def get_quote_total_amount(self):
|
||
quote = getattr(self, "quote", None)
|
||
if not quote:
|
||
return Decimal("0")
|
||
return quote.get_total_amount()
|
||
|
||
def get_post_consumption_summary(self, include_progress_states=False):
|
||
"""Retourne un récapitulatif de la consommation des postes.
|
||
Prend en compte les postes avec coefficient multipliable et les commandes directes."""
|
||
from interventions.models import InterventionContractPost, InterventionPostPeriodQuantity
|
||
from django.db.models import Sum
|
||
|
||
# Récupérer tous les postes avec leurs interventions pour calculer les montants
|
||
all_posts = InterventionContractPost.objects.filter(
|
||
intervention__order=self
|
||
).select_related('contract_post', 'intervention')
|
||
|
||
# Calculer les quantités consommées par poste
|
||
consumption_map = {}
|
||
# Calculer les montants consommés (en utilisant total_price qui gère les coefficients)
|
||
amount_map = {}
|
||
|
||
# Récupérer les interventions qui ont des périodes
|
||
interventions_with_periods = set(
|
||
InterventionPostPeriodQuantity.objects.filter(
|
||
intervention__order=self
|
||
).values_list('intervention_id', flat=True).distinct()
|
||
)
|
||
|
||
# Récupérer les quantités des périodes par intervention et par poste
|
||
period_quantities = {}
|
||
if interventions_with_periods:
|
||
period_data = InterventionPostPeriodQuantity.objects.filter(
|
||
intervention__order=self
|
||
).values('intervention_id', 'contract_post_id').annotate(
|
||
total_quantity=Sum('quantity')
|
||
)
|
||
for row in period_data:
|
||
key = (row['intervention_id'], row['contract_post_id'])
|
||
period_quantities[key] = row['total_quantity'] or Decimal("0")
|
||
|
||
for post in all_posts:
|
||
post_id = post.contract_post_id
|
||
intervention_id = post.intervention_id
|
||
|
||
# Si l'intervention a des périodes, utiliser la somme des périodes
|
||
# sinon utiliser la quantité du poste
|
||
if intervention_id in interventions_with_periods:
|
||
key = (intervention_id, post_id)
|
||
quantity = period_quantities.get(key, Decimal("0"))
|
||
if post.intervention.status_order < 50: # Intervention pas encore validée
|
||
quantity += Decimal(post.quantity or 0)
|
||
total_price = quantity * (post.contract_post.unit_price or Decimal("0"))
|
||
if post.contract_post.is_amount_to_be_justified and post.contract_post.coeff_amount_to_be_justified:
|
||
total_price = total_price * post.contract_post.coeff_amount_to_be_justified
|
||
else:
|
||
quantity = post.quantity or Decimal("0")
|
||
total_price = post.total_price or Decimal("0")
|
||
|
||
consumption_map[post_id] = consumption_map.get(post_id, Decimal("0")) + quantity
|
||
amount_map[post_id] = amount_map.get(post_id, Decimal("0")) + total_price
|
||
|
||
# Ajouter les commandes directes au récapitulatif
|
||
for direct_post in self.direct_posts.select_related('contract_post'):
|
||
post_id = direct_post.contract_post_id
|
||
# Include both current quantity and already-invoiced quantity
|
||
quantity = (direct_post.quantity or Decimal("0")) + (direct_post.invoiced_quantity or Decimal("0"))
|
||
unit_price = direct_post.contract_post.unit_price or Decimal("0")
|
||
total_price = quantity * unit_price
|
||
consumption_map[post_id] = consumption_map.get(post_id, Decimal("0")) + quantity
|
||
amount_map[post_id] = amount_map.get(post_id, Decimal("0")) + total_price
|
||
|
||
quote = getattr(self, "quote", None)
|
||
quote_items = {}
|
||
if quote:
|
||
for item in quote.items.select_related("contract_post"):
|
||
quote_items[item.contract_post_id] = item
|
||
|
||
# Récupérer les états d'avancement si demandé
|
||
progress_states_data = {}
|
||
if include_progress_states:
|
||
progress_states = self.progress_states.all().order_by('created_at')
|
||
for ps in progress_states:
|
||
ps_items_map = {}
|
||
for item in ps.items.select_related('contract_post'):
|
||
ps_items_map[item.contract_post_id] = item.quantity
|
||
progress_states_data[ps.id] = {
|
||
'state': ps,
|
||
'items': ps_items_map
|
||
}
|
||
|
||
post_ids = set(consumption_map.keys()) | set(quote_items.keys())
|
||
|
||
# Ajouter les postes des états d'avancement
|
||
if include_progress_states:
|
||
for ps_data in progress_states_data.values():
|
||
post_ids.update(ps_data['items'].keys())
|
||
|
||
if not post_ids:
|
||
return [], []
|
||
|
||
posts = (
|
||
ContractPost.objects.filter(id__in=post_ids)
|
||
.select_related("contract")
|
||
)
|
||
|
||
summary = []
|
||
for post in posts:
|
||
consumed = Decimal(consumption_map.get(post.id) or 0)
|
||
quote_item = quote_items.get(post.id)
|
||
planned = Decimal("0")
|
||
if quote_item and quote_item.quantity is not None:
|
||
planned = Decimal(quote_item.quantity)
|
||
|
||
remaining = planned - consumed
|
||
consumed_amount = amount_map.get(post.id, Decimal("0"))
|
||
|
||
item_data = {
|
||
"contract_post": post,
|
||
"consumed_quantity": consumed,
|
||
"planned_quantity": planned,
|
||
"remaining_quantity": remaining,
|
||
"consumed_amount": consumed_amount,
|
||
}
|
||
|
||
# Ajouter les quantités des états d'avancement pour ce poste
|
||
if include_progress_states:
|
||
item_data["progress_states"] = {}
|
||
for ps_id, ps_data in progress_states_data.items():
|
||
item_data["progress_states"][ps_id] = Decimal(ps_data['items'].get(post.id, 0))
|
||
|
||
summary.append(item_data)
|
||
|
||
summary.sort(key=lambda item: item["contract_post"].order_number)
|
||
|
||
if include_progress_states:
|
||
# Retourner aussi la liste des états d'avancement
|
||
states_list = [ps_data['state'] for ps_data in progress_states_data.values()]
|
||
return summary, states_list
|
||
|
||
return summary
|
||
|
||
|
||
class ContractOrderQuote(models.Model):
|
||
order = models.OneToOneField(ContractOrder, on_delete=models.CASCADE, related_name="quote")
|
||
created_by = models.ForeignKey(
|
||
settings.AUTH_USER_MODEL,
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name="created_quotes",
|
||
)
|
||
created_at = models.DateTimeField(auto_now_add=True)
|
||
updated_at = models.DateTimeField(auto_now=True)
|
||
is_finalized = models.BooleanField(default=False)
|
||
finalized_at = models.DateTimeField(null=True, blank=True)
|
||
finalized_by = models.ForeignKey(
|
||
settings.AUTH_USER_MODEL,
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name="finalized_quotes",
|
||
)
|
||
|
||
def __str__(self):
|
||
return f"Devis {self.order.order_code}"
|
||
|
||
def get_total_amount(self):
|
||
total = Decimal("0")
|
||
for item in self.items.select_related("contract_post"):
|
||
total += item.get_total_amount()
|
||
return total
|
||
|
||
def finalize(self, user=None):
|
||
if self.is_finalized:
|
||
return
|
||
|
||
from django.utils import timezone
|
||
|
||
self.is_finalized = True
|
||
self.finalized_at = timezone.now()
|
||
if user is not None:
|
||
self.finalized_by = user
|
||
self.save(update_fields=["is_finalized", "finalized_at", "finalized_by", "updated_at"])
|
||
|
||
def get_consumed_quantities(self, exclude_intervention_id=None, include_current_intervention_periods=None):
|
||
from interventions.models import InterventionContractPost, InterventionPostPeriodQuantity
|
||
|
||
# Calculer les quantités consommées par les autres interventions
|
||
qs = InterventionContractPost.objects.filter(intervention__order=self.order)
|
||
if exclude_intervention_id:
|
||
qs = qs.exclude(intervention_id=exclude_intervention_id)
|
||
|
||
rows = qs.values("contract_post").annotate(total_quantity=Sum("quantity"))
|
||
consumption_map = {
|
||
row["contract_post"]: row["total_quantity"] or Decimal("0")
|
||
for row in rows
|
||
}
|
||
|
||
# Ajouter les quantités validées des périodes précédentes de l'intervention en cours
|
||
if include_current_intervention_periods:
|
||
period_qs = InterventionPostPeriodQuantity.objects.filter(
|
||
intervention_id=include_current_intervention_periods,
|
||
intervention__order=self.order
|
||
)
|
||
period_rows = period_qs.values("contract_post").annotate(total_quantity=Sum("quantity"))
|
||
|
||
for row in period_rows:
|
||
post_id = row["contract_post"]
|
||
period_qty = row["total_quantity"] or Decimal("0")
|
||
consumption_map[post_id] = consumption_map.get(post_id, Decimal("0")) + period_qty
|
||
|
||
return consumption_map
|
||
|
||
def get_remaining_items(self, exclude_intervention_id=None, include_zero=False, include_current_intervention_periods=None):
|
||
consumption_map = self.get_consumed_quantities(
|
||
exclude_intervention_id=exclude_intervention_id,
|
||
include_current_intervention_periods=include_current_intervention_periods
|
||
)
|
||
items = []
|
||
for quote_item in self.items.select_related("contract_post"):
|
||
planned = Decimal(quote_item.quantity or 0)
|
||
consumed = Decimal(consumption_map.get(quote_item.contract_post_id) or 0)
|
||
remaining = planned - consumed
|
||
if include_zero or remaining != 0:
|
||
items.append({
|
||
"quote_item": quote_item,
|
||
"contract_post": quote_item.contract_post,
|
||
"planned_quantity": planned,
|
||
"consumed_quantity": consumed,
|
||
"remaining_quantity": remaining,
|
||
})
|
||
|
||
items.sort(key=lambda item: item["contract_post"].order_number)
|
||
return items
|
||
|
||
|
||
class ContractOrderQuoteItem(models.Model):
|
||
quote = models.ForeignKey(ContractOrderQuote, on_delete=models.CASCADE, related_name="items")
|
||
contract_post = models.ForeignKey(ContractPost, on_delete=models.CASCADE)
|
||
quantity = models.DecimalField(max_digits=10, decimal_places=2)
|
||
|
||
class Meta:
|
||
unique_together = ("quote", "contract_post")
|
||
ordering = ["contract_post__order_number"]
|
||
|
||
def __str__(self):
|
||
return f"{self.contract_post.code} - {self.quantity}"
|
||
|
||
def get_total_amount(self):
|
||
unit_price = self.contract_post.unit_price or Decimal("0")
|
||
quantity = Decimal(self.quantity or 0)
|
||
return (unit_price * quantity).quantize(Decimal("0.01"))
|
||
|
||
|
||
|
||
ORDER_DOCUMENT_TYPE_CHOICES = [
|
||
('invoice', 'Facture'),
|
||
('claim_declaration', 'Déclaration de créance'),
|
||
('state_of_works', "État de travaux"),
|
||
('posts_detail', "Détail des postes"),
|
||
('other', 'Autre'),
|
||
]
|
||
|
||
def order_document_upload_path(instance, filename):
|
||
# On récupère l'année et le mois actuels
|
||
now = timezone.now()
|
||
year = now.strftime("%Y")
|
||
month = now.strftime("%m")
|
||
|
||
# On construit le chemin avec l'ID de l'intervention (attention : doit être déjà enregistrée)
|
||
order_code = instance.order.order_code if instance.order else "unknown"
|
||
return f"intervention_documents/{year}/{order_code}/{filename}"
|
||
|
||
class ContractOrderDocument(models.Model):
|
||
"""Modèle représentant un document attaché à une commande de contrat"""
|
||
order = models.ForeignKey(ContractOrder, on_delete=models.CASCADE, related_name="documents")
|
||
title = models.CharField(max_length=255)
|
||
document = models.FileField(upload_to=order_document_upload_path, storage=private_storage)
|
||
document_type = models.CharField(max_length=50, choices=ORDER_DOCUMENT_TYPE_CHOICES, default='other')
|
||
uploaded_at = models.DateTimeField(auto_now_add=True)
|
||
progress_state = models.ForeignKey(
|
||
'ContractOrderProgressState',
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name='ps_documents',
|
||
help_text=_("État d'avancement associé à ce document, le cas échéant"),
|
||
)
|
||
|
||
def __str__(self):
|
||
return f"{self.title} ({self.order.order_code})"
|
||
|
||
def is_claim_declaration(self):
|
||
"""Retourne True si ce document est une déclaration de créance"""
|
||
return self.document_type == 'claim_declaration'
|
||
|
||
|
||
def claim_attachment_upload_path(instance, filename):
|
||
"""Chemin de stockage pour les pièces jointes des déclarations de créance"""
|
||
now = timezone.now()
|
||
year = now.strftime("%Y")
|
||
order_code = instance.claim_document.order.order_code if instance.claim_document else "unknown"
|
||
return f"claim_attachments/{year}/{order_code}/{filename}"
|
||
|
||
|
||
class ClaimDeclarationAttachment(models.Model):
|
||
"""Modèle représentant une pièce jointe (justificatif) pour une déclaration de créance"""
|
||
claim_document = models.ForeignKey(
|
||
ContractOrderDocument,
|
||
on_delete=models.CASCADE,
|
||
related_name="attachments",
|
||
limit_choices_to={'document_type': 'claim_declaration'},
|
||
verbose_name=_("Déclaration de créance"),
|
||
)
|
||
title = models.CharField(
|
||
max_length=255,
|
||
verbose_name=_("Titre"),
|
||
help_text=_("Titre ou description du justificatif"),
|
||
)
|
||
document = models.FileField(
|
||
upload_to=claim_attachment_upload_path,
|
||
storage=private_storage,
|
||
verbose_name=_("Fichier"),
|
||
)
|
||
uploaded_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Date d'ajout"))
|
||
uploaded_by = models.ForeignKey(
|
||
settings.AUTH_USER_MODEL,
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name="uploaded_claim_attachments",
|
||
verbose_name=_("Ajouté par"),
|
||
)
|
||
|
||
def __str__(self):
|
||
return f"{self.title} ({self.claim_document.title})"
|
||
|
||
class Meta:
|
||
verbose_name = _("Pièce jointe de déclaration de créance")
|
||
verbose_name_plural = _("Pièces jointes de déclarations de créance")
|
||
ordering = ['uploaded_at']
|
||
|
||
|
||
class ContractOrderProgressState(models.Model):
|
||
"""Modèle représentant un état d'avancement pour une commande de contrat"""
|
||
order = models.ForeignKey(ContractOrder, on_delete=models.CASCADE, related_name="progress_states")
|
||
index = models.PositiveIntegerField(null=True, blank=True, help_text=_("Numéro d'ordre de l'état d'avancement pour cette commande"))
|
||
created_at = models.DateTimeField(auto_now_add=True)
|
||
created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True,
|
||
related_name="created_progress_states")
|
||
|
||
def __str__(self):
|
||
return f"État d'avancement - {self.order.order_code} - {self.created_at.strftime('%d/%m/%Y')}"
|
||
|
||
class Meta:
|
||
verbose_name = "État d'avancement"
|
||
verbose_name_plural = "États d'avancement"
|
||
ordering = ['created_at']
|
||
|
||
|
||
class ContractOrderProgressStateItem(models.Model):
|
||
"""Modèle représentant les quantités d'un poste dans un état d'avancement"""
|
||
progress_state = models.ForeignKey(ContractOrderProgressState, on_delete=models.CASCADE, related_name="items")
|
||
contract_post = models.ForeignKey(ContractPost, on_delete=models.CASCADE)
|
||
quantity = models.DecimalField(max_digits=10, decimal_places=2, default=0,
|
||
help_text=_("Quantité consommée à cette date"))
|
||
|
||
def __str__(self):
|
||
return f"{self.contract_post.code} - {self.quantity}"
|
||
|
||
class Meta:
|
||
verbose_name = "Item d'état d'avancement"
|
||
verbose_name_plural = "Items d'états d'avancement"
|
||
unique_together = ("progress_state", "contract_post")
|
||
ordering = ['contract_post__order_number']
|
||
|
||
|
||
def order_attachment_upload_path(instance, filename):
|
||
now = timezone.now()
|
||
year = now.strftime("%Y")
|
||
month = now.strftime("%m")
|
||
order_code = slugify(instance.order.order_code) if instance.order else "unknown"
|
||
return f"order_attachments/{year}/{month}/{order_code}/{filename}"
|
||
|
||
|
||
DIRECT_ORDER_POST_STATUS_CHOICES = [
|
||
('pending', _('En attente de validation')),
|
||
('validated', _('Approuvé')),
|
||
('refused', _('Refusé')),
|
||
('invoiced', _('Facturé')),
|
||
]
|
||
|
||
|
||
class DirectOrderPost(models.Model):
|
||
"""Commande directe d'un poste de marché sans passer par une intervention."""
|
||
order = models.ForeignKey(
|
||
ContractOrder,
|
||
on_delete=models.CASCADE,
|
||
related_name='direct_posts',
|
||
verbose_name=_("Commande"),
|
||
)
|
||
contract_post = models.ForeignKey(
|
||
ContractPost,
|
||
on_delete=models.CASCADE,
|
||
related_name='direct_order_posts',
|
||
verbose_name=_("Poste de marché"),
|
||
)
|
||
quantity = models.DecimalField(
|
||
max_digits=10,
|
||
decimal_places=2,
|
||
verbose_name=_("Quantité"),
|
||
)
|
||
comment = models.TextField(blank=True, null=True, verbose_name=_("Commentaire"))
|
||
control_status = models.CharField(
|
||
max_length=20,
|
||
choices=DIRECT_ORDER_POST_STATUS_CHOICES,
|
||
default='pending',
|
||
verbose_name=_("Statut de validation"),
|
||
)
|
||
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Date de création"))
|
||
created_by = models.ForeignKey(
|
||
settings.AUTH_USER_MODEL,
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name='created_direct_order_posts',
|
||
verbose_name=_("Créé par"),
|
||
)
|
||
validated_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Date de validation"))
|
||
validated_by = models.ForeignKey(
|
||
settings.AUTH_USER_MODEL,
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name='validated_direct_order_posts',
|
||
verbose_name=_("Validé par"),
|
||
)
|
||
invoiced_quantity = models.DecimalField(
|
||
max_digits=10,
|
||
decimal_places=2,
|
||
default=Decimal('0'),
|
||
verbose_name=_("Quantité déjà facturée"),
|
||
help_text=_("Quantité cumulée sur les déclarations de créance précédentes."),
|
||
)
|
||
|
||
@property
|
||
def total_price(self):
|
||
"""Calcule le montant total du poste direct.
|
||
Reprend la même logique que InterventionContractPost.total_price,
|
||
mais appliquée aux autres postes directs de la même commande."""
|
||
cp = self.contract_post
|
||
qty = Decimal(self.quantity or 0)
|
||
unit_price = cp.unit_price or Decimal('0')
|
||
|
||
if cp.is_coefficient_multipliable and cp.post_type:
|
||
effective_qty = Decimal('1') if qty > 0 else Decimal('0')
|
||
same_type_posts = DirectOrderPost.objects.filter(
|
||
order=self.order,
|
||
contract_post__post_type=cp.post_type,
|
||
).exclude(
|
||
contract_post__is_coefficient_multipliable=True
|
||
).select_related('contract_post')
|
||
total_same_type_cost = sum(
|
||
Decimal(p.quantity or 0) * (p.contract_post.unit_price or Decimal('0'))
|
||
for p in same_type_posts
|
||
)
|
||
return effective_qty * unit_price * total_same_type_cost
|
||
|
||
base = qty * unit_price
|
||
if cp.is_amount_to_be_justified and cp.coeff_amount_to_be_justified:
|
||
return base * cp.coeff_amount_to_be_justified
|
||
return base
|
||
|
||
def __str__(self):
|
||
return f"{self.contract_post.code} - {self.quantity} ({self.order.order_code})"
|
||
|
||
class Meta:
|
||
verbose_name = _("Commande directe – poste")
|
||
verbose_name_plural = _("Commandes directes – postes")
|
||
ordering = ['contract_post__order_number']
|
||
unique_together = ('order', 'contract_post')
|
||
|
||
|
||
class ContractOrderAttachment(models.Model):
|
||
"""Document générique (photo, PDF, …) associé à une commande, avec vignette."""
|
||
order = models.ForeignKey(
|
||
ContractOrder,
|
||
on_delete=models.CASCADE,
|
||
related_name="attachments",
|
||
verbose_name=_("Commande"),
|
||
)
|
||
file = models.FileField(
|
||
upload_to=order_attachment_upload_path,
|
||
storage=private_storage,
|
||
verbose_name=_("Fichier"),
|
||
max_length=255,
|
||
)
|
||
thumbnail = models.ImageField(
|
||
upload_to=order_attachment_upload_path,
|
||
storage=private_storage,
|
||
null=True,
|
||
blank=True,
|
||
max_length=255,
|
||
verbose_name=_("Vignette"),
|
||
)
|
||
upload_date = models.DateTimeField(auto_now_add=True, verbose_name=_("Date d'ajout"))
|
||
uploaded_by = models.ForeignKey(
|
||
settings.AUTH_USER_MODEL,
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name="uploaded_order_attachments",
|
||
verbose_name=_("Ajouté par"),
|
||
)
|
||
|
||
@property
|
||
def filename(self):
|
||
import os
|
||
return os.path.basename(self.file.name)
|
||
|
||
def __str__(self):
|
||
return f"{self.filename} ({self.order.order_code})"
|
||
|
||
class Meta:
|
||
verbose_name = _("Pièce jointe de commande")
|
||
verbose_name_plural = _("Pièces jointes de commande")
|
||
ordering = ["upload_date"]
|
||
|