loko/streetup/contracts/models.py
2026-07-22 14:48:40 +02:00

1067 lines
No EOL
43 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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"]