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