from django.http import HttpResponse, Http404 from django.shortcuts import get_object_or_404, redirect from django.core.files.base import ContentFile from django.conf import settings from django.utils.text import slugify from django.contrib import messages from django.utils.translation import gettext as _, ngettext, get_language from django.utils import timezone from django.utils.formats import date_format from django.db.models import Sum import os import io import pytz from datetime import datetime from decimal import Decimal from num2words import num2words from fpdf import FPDF from openpyxl import Workbook from openpyxl.styles import Font, Alignment, PatternFill, Border, Side from openpyxl.utils import get_column_letter from contracts.models import Company, Contract, ContractOrder, ContractOrderDocument, ContractOrderProgressState, ContractOrderProgressStateItem from contracts.permissions import user_can_generate_order_documents def calculate_order_totals(order, interventions_filter=None, include_validated_periods=False): """ Calcule les totaux ventilés pour une commande en tenant compte des coefficients multiplicateurs et des sommes à justifier (SAJ). Les postes coefficient multiplicateur (is_coefficient_multipliable=True) représentent un supplément calculé par intervention. Leur total_price inclut déjà le calcul: 1 × coefficient × somme_des_coûts_postes_même_type Structure des totaux: - Total revisable: postes normaux (sans les coefficients multiplicateurs ni SAJ) - Postes coefficient multiplicateur: suppléments calculés par intervention - SAJ sans coefficient: sommes à justifier sans coefficient applicable - SAJ avec coefficient: sommes à justifier regroupées par type avec coefficient Args: order: ContractOrder interventions_filter: filtre optionnel pour les interventions (ex: {'status': 'validated'}) include_validated_periods: si True, inclut les périodes validées des interventions Returns: dict avec les différents totaux et informations sur les périodes """ from interventions.models import InterventionContractPost, InterventionPostPeriodQuantity # Filtrer les interventions interventions_qs = order.interventions.all() if interventions_filter: interventions_qs = interventions_qs.filter(**interventions_filter) intervention_ids = list(interventions_qs.values_list('id', flat=True)) # Structure pour suivre les périodes incluses included_periods = {} # intervention_id -> set of period_numbers # Si demandé, inclure aussi les périodes validées (non facturées) period_posts = [] interventions_with_periods = set() if include_validated_periods: # Récupérer les périodes validées de TOUTES les interventions de la commande # (pas seulement celles filtrées) car on veut inclure les interventions # qui ont des périodes validées même si l'intervention n'est pas validée all_intervention_ids = list(order.interventions.values_list('id', flat=True)) period_quantities = InterventionPostPeriodQuantity.objects.filter( intervention_id__in=all_intervention_ids, status='validated' ).select_related('contract_post', 'intervention') # Grouper par intervention et période pour tracking for pq in period_quantities: if pq.intervention_id not in included_periods: included_periods[pq.intervention_id] = set() included_periods[pq.intervention_id].add(pq.period_number) interventions_with_periods.add(pq.intervention_id) # Créer un objet simulé pour compatibilité avec le code existant class PeriodPost: def __init__(self, period_quantity): self.contract_post = period_quantity.contract_post self.quantity = period_quantity.quantity self.intervention = period_quantity.intervention self.control_status = 'validated' @property def total_price(self): if not self.contract_post.unit_price: return Decimal("0.00") qty = Decimal(self.quantity or 0) # Si le poste est un coefficient multiplicateur, calculer le total en fonction des postes du même type if self.contract_post.is_coefficient_multipliable and self.contract_post.post_type: # Pour les postes avec coefficient, la quantité doit être 1 (sauf si 0 ou vide) effective_qty = Decimal('1') if qty > 0 else Decimal('0') # Ajouter les périodes validées du même type (exclure les coefficients) from collections import defaultdict same_type_periods = InterventionPostPeriodQuantity.objects.filter( intervention=self.intervention, contract_post__post_type=self.contract_post.post_type, status='validated' ).exclude( contract_post__is_coefficient_multipliable=True ).select_related('contract_post') # Grouper par poste pour éviter de compter plusieurs fois le même poste period_totals_by_post = defaultdict(Decimal) for p in same_type_periods: period_totals_by_post[p.contract_post_id] += Decimal(str(p.quantity or 0)) # Calculer le coût total des périodes total_same_type_cost = Decimal('0') for post_id, total_qty in period_totals_by_post.items(): # Trouver le contract_post correspondant cp = next((p.contract_post for p in same_type_periods if p.contract_post_id == post_id), None) if cp: total_same_type_cost += total_qty * (cp.unit_price or Decimal('0')) # Total = effective_qty × prix unitaire (coefficient) × somme des coûts return effective_qty * self.contract_post.unit_price * total_same_type_cost # Calcul standard base = qty * self.contract_post.unit_price if self.contract_post.is_amount_to_be_justified and self.contract_post.coeff_amount_to_be_justified: return base * self.contract_post.coeff_amount_to_be_justified return base period_posts.append(PeriodPost(pq)) # Récupérer tous les postes validés pour ces interventions # MAIS exclure les interventions qui ont des périodes validées (pour éviter le doublon) posts = InterventionContractPost.objects.filter( intervention_id__in=intervention_ids, control_status='validated' ).exclude( intervention_id__in=interventions_with_periods ).select_related('contract_post', 'intervention') # Combiner les postes d'intervention et les périodes all_posts = list(posts) + period_posts # Ajouter les commandes directes validées from contracts.models import DirectOrderPost direct_posts_qs = DirectOrderPost.objects.filter( order=order, control_status='validated', ).select_related('contract_post') all_posts = all_posts + list(direct_posts_qs) # Initialiser les accumulateurs total_revisable = Decimal('0') # Postes revisables (non SAJ, non coefficient) # Postes coefficient multiplicateur par code (suppléments) coefficient_posts_totals = {} # code -> {total, description, coefficient_value, post_type} # SAJ sans coefficient saj_without_coef = {} # code -> {total, description} # D'abord, identifier tous les postes coefficient et leurs types coefficient_post_types = set() for post in all_posts: cp = post.contract_post if cp.is_coefficient_multipliable and cp.post_type: coefficient_post_types.add(cp.post_type) if cp.code not in coefficient_posts_totals: coefficient_posts_totals[cp.code] = { 'code': cp.code, 'description': cp.get_description_for_language(), 'coefficient_value': cp.unit_price or Decimal('0'), # Le prix unitaire est le coefficient 'post_type': cp.post_type, 'total': Decimal('0'), } # Le total_price est calculé par la propriété du modèle # = coefficient × somme_des_coûts_postes_même_type (par intervention) coefficient_posts_totals[cp.code]['total'] += post.total_price or Decimal('0') # SAJ avec coefficient - regrouper par post_type saj_with_coef = {} # post_type -> {total, description, coefficient} # Calculer les totaux pour les autres postes for post in all_posts: cp = post.contract_post qty = post.quantity or Decimal('0') unit_price = cp.unit_price or Decimal('0') # Ignorer les postes coefficient (déjà traités) if cp.is_coefficient_multipliable: continue base_amount = qty * unit_price if cp.is_amount_to_be_justified: # Appliquer le coefficient coeff_amount_to_be_justified si défini coeff_saj = cp.coeff_amount_to_be_justified if cp.coeff_amount_to_be_justified else Decimal('1') saj_amount = base_amount * coeff_saj # Poste SAJ - vérifier si un coefficient s'applique à son type if cp.post_type and cp.post_type in coefficient_post_types: # SAJ avec coefficient - regrouper par post_type if cp.post_type not in saj_with_coef: # Trouver le coefficient correspondant coef_info = next( (c for c in coefficient_posts_totals.values() if c['post_type'] == cp.post_type), None ) coefficient_display = Decimal('1') + (coef_info['coefficient_value'] if coef_info else Decimal('0')) saj_with_coef[cp.post_type] = { 'post_type': cp.post_type, 'description': f"SAJ {cp.post_type}", # Description générique par type 'coefficient': coefficient_display, 'total': Decimal('0'), } saj_with_coef[cp.post_type]['total'] += saj_amount else: # SAJ sans coefficient if cp.code not in saj_without_coef: saj_without_coef[cp.code] = { 'code': cp.code, 'description': cp.get_description_for_language(), 'total': Decimal('0'), } saj_without_coef[cp.code]['total'] += saj_amount else: # Poste revisable normal (montant de base, sans multiplication) total_revisable += base_amount # Calculer les totaux total_coefficient_supplements = sum(d['total'] for d in coefficient_posts_totals.values()) total_saj_without_coef = sum(d['total'] for d in saj_without_coef.values()) total_saj_with_coef_base = sum(d['total'] for d in saj_with_coef.values()) # Pour les SAJ avec coefficient: # - La base est dans saj_with_coef[post_type]['total'] # - Le supplément (coefficient × base) est dans coefficient_posts_totals # Donc pour l'affichage, on montre base × (1 + coef), mais dans le total général, # le supplément est déjà compté dans coefficient_posts_totals. # Total général HTVA # = revisables + suppléments coefficients + SAJ sans coef + SAJ avec coef (base seulement) # Note: le supplément des SAJ avec coef est inclus dans total_coefficient_supplements total_general_htva = ( total_revisable + total_coefficient_supplements + # Suppléments des postes coefficient (inclut les suppléments SAJ) total_saj_without_coef + total_saj_with_coef_base # Base SAJ (le supplément est dans coefficient_posts_totals) ) return { 'total_revisable': total_revisable, # Postes revisables de base 'total_coefficient_supplements': total_coefficient_supplements, # Total des suppléments coefficient 'coefficient_posts_totals': coefficient_posts_totals, # Détails par poste coefficient 'total_saj_without_coef': total_saj_without_coef, # SAJ sans coefficient 'saj_without_coef': saj_without_coef, # Détails SAJ sans coef 'total_saj_with_coef_base': total_saj_with_coef_base, # SAJ avec coefficient (base seulement) 'saj_with_coef': saj_with_coef, # Détails SAJ avec coef (pour affichage: base × coefficient) 'total_general_htva': total_general_htva, 'included_periods': included_periods, # intervention_id -> set of period_numbers } ### Déclaration de créance ### class ClaimDeclarationPDF(FPDF): def __init__(self, company: Company, show_draft: bool = False, *args, **kwargs): super().__init__(*args, **kwargs) self.company = company # Stocker l’instance de Company self.show_draft = show_draft self.set_margins(left=15, top=10, right=15) # marges en mm def header(self): # Fonts self.add_font("arial", style="", fname=os.path.join(settings.BASE_DIR, "common", "static", "common", "arial.ttf")) self.add_font("arial", style="B", fname=os.path.join(settings.BASE_DIR, "common", "static", "common", "arial_bold.ttf")) self.add_font("arial", style="I", fname=os.path.join(settings.BASE_DIR, "common", "static", "common", "arial_italic.ttf")) # Logo de la société (s'il existe) if self.company.logo: logo_path = os.path.join(settings.MEDIA_ROOT, self.company.logo.name) if os.path.exists(logo_path): self.image(logo_path, x=10, y=8, w=40) # Badge DRAFT en haut à droite (preview seulement) if self.show_draft: _draw_draft_ribbon(self, text="DRAFT") def add_order_data(self, order: ContractOrder): brussels = pytz.timezone("Europe/Brussels") self.set_y(30) self.set_font(family="arial", style="B", size=14) self.cell(0, 10, _("Service public régional de Bruxelles"), new_y="NEXT", new_x="LMARGIN", align="C") self.cell(0, 10, _("Cahier des charges ") + order.contract.contract_number, new_y="NEXT", new_x="LMARGIN", align="C") self.set_font(family="arial", style="", size=12) today_str = datetime.today().strftime('%d/%m/%Y') self.set_y(55) self.cell(0, 10, _("Bruxelles: ") + today_str, new_y="NEXT", new_x="LMARGIN", align="L") self.set_font(family="arial", style="BU", size=14) self.ln(10) self.cell(0, 10, _("DECLARATION DE CREANCE"), align="C", new_y="NEXT", new_x="LMARGIN") self.set_font(family="arial", style="", size=12) self.ln(10) self.cell(25, 7, _("Concerne:")) self.set_font(family="arial", style="B", size=12) self.multi_cell(160, 7, order.contract.contract_number + " - " + order.order_code + " - " + order.description, align="L", new_y="NEXT", new_x="LMARGIN") self.set_font(family="arial", style="", size=12) self.ln(10) # Récupérer le nom et rôle du signataire depuis le contrat signatory_name = order.contract.signatory_name or "..." signatory_role = order.contract.signatory_role or "..." content = _( "Je soussigné %(signatory)s, %(role)s, de la firme %(company)s, " "déclare que le Service public régional de Bruxelles – Place Saint-Lazare 2 à 1035 Bruxelles, " "est redevable de la somme de :" ) % { "signatory": signatory_name, "role": signatory_role, "company": self.company.name, } self.multi_cell(0, 7, content, align="L", new_y="LAST", new_x="LMARGIN") self.ln(10) # Calculer les totaux ventilés (inclut les périodes validées) totals = calculate_order_totals(order, interventions_filter={'status': 'validated'}, include_validated_periods=True) # Afficher le détail des totaux self.set_font(family="arial", style="", size=10) line_height = 6 # Total général HTVA self.cell(130, line_height, _("Total général TVA non comprise :")) self.cell(50, line_height, f"{totals['total_general_htva']:,.2f} €".replace(",", " ").replace(".", ","), align="R", new_y="NEXT", new_x="LMARGIN") # TVA tva_amount = totals['total_general_htva'] * Decimal('0.21') self.cell(130, line_height, _("TVA 21 % :")) self.cell(50, line_height, f"{tva_amount:,.2f} €".replace(",", " ").replace(".", ","), align="R", new_y="NEXT", new_x="LMARGIN") # Total TVAC total_tvac = totals['total_general_htva'] + tva_amount self.ln(3) self.set_font(family="arial", style="B", size=12) amount = float(total_tvac) formatted_amount = f"{amount:,.2f}".replace(",", " ").replace(".", ",") self.cell(0, 10, f"{formatted_amount} € TVAC", align="R", new_y="NEXT", new_x="LMARGIN") self.set_font(family="arial", style="", size=12) euros = int(amount) centimes = int(round((amount - euros) * 100)) lang = (get_language() or "fr")[:2] num_lang = "nl" if lang == "nl" else "fr" # Parties en toutes lettres euros_words = num2words(euros, lang=num_lang) euro_label = ngettext("euro", "euros", euros) if centimes > 0: cent_words = num2words(centimes, lang=num_lang) cent_label = ngettext("centime", "centimes", centimes) conj = _("et") amount_words = f"{euros_words} {euro_label} {conj} {cent_words} {cent_label}" else: amount_words = f"{euros_words} {euro_label}" self.multi_cell(0, 7, _("Montant total, TVA incluse : %(amount)s") % {"amount": amount_words.capitalize()}, align="L") def generate_claim_declaration_pdf(order, output_path, *, show_draft: bool = False): company = order.contract.company pdf = ClaimDeclarationPDF(company=company, show_draft=show_draft) pdf.add_page() pdf.add_order_data(order) pdf.output(output_path) def preview_claim_declaration_pdf(request, order_id): order = get_object_or_404(ContractOrder, id=order_id) buffer = io.BytesIO() generate_claim_declaration_pdf(order, buffer, show_draft=True) buffer.seek(0) filename = _("Declaration_Creance_%(slug_contract_number)s_%(slug_order_code)s.pdf") % { "slug_contract_number": slugify(order.contract.contract_number), "slug_order_code": slugify(order.order_code) } response = HttpResponse(buffer.read(), content_type="application/pdf") response['Content-Disposition'] = f'inline; filename="{filename}"' return response def create_claim_declaration_document(order_id): """ Génère la Déclaration de créance en PDF, la sauvegarde au stockage, et crée un ContractOrderDocument lié à la commande. Retourne le document créé. """ order = get_object_or_404(ContractOrder, id=order_id) # 1) Génération PDF en mémoire buffer = io.BytesIO() generate_claim_declaration_pdf(order, buffer) # génère sans 'Draft' buffer.seek(0) # 2) Métadonnées document today = timezone.localdate() date_token = today.strftime("%Y%m%d") # pour le nom de fichier # Préfixe traduisible ET sûr pour un fichier (slugifié) fname_prefix = slugify(_("Declaration_Creance")) filename = f"{fname_prefix}_{slugify(order.contract.contract_number)}_{slugify(order.order_code)}_{date_token}.pdf" # Titre lisible avec date localisée title = "%(decl)s - %(code)s - %(date)s" % { "decl": _("Déclaration de créance"), "code": order.order_code, "date": date_format(timezone.now(), "d/m/Y"), # ou "DATE_FORMAT" si tu veux le format local } # 3) Création ContractOrderDocument + sauvegarde du fichier doc = ContractOrderDocument( order=order, title=title, document_type="claim_declaration", ) doc.document.save(filename, ContentFile(buffer.getvalue())) doc.save() return doc ### Etat de travaux ### class StateOfWorksPDF(FPDF): def __init__(self, company: Company, show_draft: bool = False, *args, **kwargs): super().__init__(*args, **kwargs) self.company = company # Stocker l’instance de Company self.show_draft = show_draft self.set_margins(left=10, top=20, right=10) # marges en mm self.set_auto_page_break(auto=True, margin=20) def header(self): # Fonts self.add_font("arial", style="", fname=os.path.join(settings.BASE_DIR, "common", "static", "common", "arial.ttf")) self.add_font("arial", style="B", fname=os.path.join(settings.BASE_DIR, "common", "static", "common", "arial_bold.ttf")) self.add_font("arial", style="I", fname=os.path.join(settings.BASE_DIR, "common", "static", "common", "arial_italic.ttf")) # Logo de la société (s'il existe) if self.company.logo: logo_path = os.path.join(settings.MEDIA_ROOT, self.company.logo.name) if os.path.exists(logo_path): self.image(logo_path, x=10, y=8, w=40) if self.show_draft: _draw_draft_ribbon(self, text="DRAFT") def add_first_page(self, order: ContractOrder): brussels = pytz.timezone("Europe/Brussels") self.set_y(30) self.set_font(family="arial", style="B", size=14) self.cell(0, 10, _("Service public régional de Bruxelles"), new_y="NEXT", new_x="LMARGIN", align="C") self.cell(0, 10, _("Cahier des charges ") + order.contract.contract_number, new_y="NEXT", new_x="LMARGIN", align="C") self.set_font(family="arial", style="", size=12) today_str = datetime.today().strftime('%d/%m/%Y') self.set_y(55) self.cell(0, 10, _("Bruxelles: ") + today_str, new_y="NEXT", new_x="LMARGIN", align="L") self.set_font(family="arial", style="BU", size=14) self.ln(10) self.cell(0, 10, _("ETAT DE TRAVAUX"), align="C", new_y="NEXT", new_x="LMARGIN") self.set_font(family="arial", style="B", size=12) self.ln(10) self.multi_cell(0, 7, order.contract.contract_number + " - " + order.order_code + " - " + order.description, align="C", new_y="NEXT", new_x="LMARGIN") def add_state_of_works_data(self, order: ContractOrder, included_periods=None): if included_periods is None: included_periods = {} total_general_htva = 0 # Accumulateur du total global # Logique d'inclusion des interventions : # 1. Toutes les interventions avec status='validated' (même sans postes validés) # 2. Toutes les interventions avec des périodes validées (quel que soit leur statut) from interventions.models import InterventionContractPost, InterventionPostPeriodQuantity # Toutes les interventions validées validated_interventions = order.interventions.filter(status='validated') # Toutes les interventions avec périodes validées (quel que soit leur statut) intervention_ids_with_validated_periods = InterventionPostPeriodQuantity.objects.filter( intervention__order=order, status='validated' ).values_list('intervention_id', flat=True).distinct() interventions_with_periods = order.interventions.filter(id__in=intervention_ids_with_validated_periods) # Combiner les deux querysets et éliminer les doublons interventions = (validated_interventions | interventions_with_periods).distinct().order_by('begin_time') for intervention in interventions: # Calculer la hauteur du bloc en fonction des postes validés et des périodes validées validated_posts_count = intervention.interv_contract_posts.filter(control_status='validated').count() periods_posts_count = 0 if intervention.id in included_periods: # Compter le nombre de postes distincts dans les périodes validées from interventions.models import InterventionPostPeriodQuantity periods_posts_count = InterventionPostPeriodQuantity.objects.filter( intervention=intervention, status='validated' ).values('contract_post_id').distinct().count() # Total des lignes de postes à afficher total_posts_lines = validated_posts_count + periods_posts_count # Si aucun poste ni période validée, prévoir une ligne pour le message if total_posts_lines == 0 and intervention.status == 'validated': total_posts_lines = 1 bloc_height = 40 + 7 * total_posts_lines if self.will_page_break(bloc_height): self.add_page() self.ln(5) # En-tête de l’intervention bt = intervention.begin_time if bt: local_dt = timezone.localtime(bt) # Choisis un format : "d/m/Y H:i" ou un format local Django comme "SHORT_DATETIME_FORMAT" formatted_dt = date_format(local_dt, "d/m/Y H:i") else: formatted_dt = "-" # Construire le texte du titre avec les périodes si elles existent title_interv_text = _("Intervention %(code)s du %(date)s") % { "code": intervention.code, "date": formatted_dt, } # Ajouter les périodes si elles existent pour cette intervention periods_for_intervention = included_periods.get(intervention.id, set()) if periods_for_intervention: # Trier les périodes et les formatter comme P1, P2, P3... sorted_periods = sorted(periods_for_intervention) periods_str = ", ".join([f"P{p}" for p in sorted_periods]) title_interv_text += f" ({_('périodes')} {periods_str})" self.set_font("arial", style="B", size=12) self.cell(0, 8, title_interv_text, ln=True) self.set_font("arial", style="", size=12) self.multi_cell(0, 7, intervention.title or "-", align="L") self.ln(2) # Table des postes self.set_font("arial", style="", size=10) self.cell(25, 7, _("Code poste")) self.cell(85, 7, _("Description")) self.cell(25, 7, _("PU"), align="R") self.cell(20, 7, _("Qté"), align="R") self.cell(25, 7, _("Total"), ln=True, align="R") # Ligne en pointillés sous les en-têtes current_y = self.get_y() self.set_draw_color(200, 200, 200) # gris clair self.set_dash_pattern(1, 1) # 1 mm trait / 1 mm espace self.line(10, current_y, 200, current_y) self.set_dash_pattern() # reset (ligne pleine ensuite) self.set_font("arial", style="", size=8) # Afficher les postes validés de l'intervention validated_posts = intervention.interv_contract_posts.filter(control_status='validated') total_htva = Decimal('0.00') has_content = False # Prioriser l'affichage des périodes validées si elles existent if intervention.id in included_periods: # Cas 1: L'intervention a des périodes validées - afficher le cumul des périodes from interventions.models import InterventionPostPeriodQuantity, InterventionContractPost period_quantities = InterventionPostPeriodQuantity.objects.filter( intervention=intervention, status='validated' ).select_related('contract_post') if period_quantities.exists(): has_content = True # Grouper par poste pour additionner les quantités de toutes les périodes from collections import defaultdict post_totals = defaultdict(lambda: {'quantity': Decimal('0.00'), 'contract_post': None}) for pq in period_quantities: post_totals[pq.contract_post_id]['quantity'] += Decimal(str(pq.quantity or 0)) post_totals[pq.contract_post_id]['contract_post'] = pq.contract_post # Afficher les postes groupés for post_id, data in post_totals.items(): contract_post = data['contract_post'] quantity = data['quantity'] unit_price = contract_post.unit_price or Decimal('0.00') # Vérifier si le poste est un coefficient multiplicateur if contract_post.is_coefficient_multipliable and contract_post.post_type: # Pour les postes avec coefficient, la quantité doit être 1 (sauf si 0 ou vide) effective_qty = Decimal('1') if quantity > 0 else Decimal('0') total_same_type_cost = 0 # Ajouter les périodes validées du même type (exclure les coefficients) same_type_periods = InterventionPostPeriodQuantity.objects.filter( intervention=intervention, contract_post__post_type=contract_post.post_type, status='validated' ).exclude( contract_post__is_coefficient_multipliable=True ).select_related('contract_post') # Grouper les périodes par poste et sommer period_totals = defaultdict(Decimal) for period in same_type_periods: period_totals[period.contract_post_id] += Decimal(str(period.quantity or 0)) # Ajouter le coût des périodes for period_post_id, period_qty in period_totals.items(): # Trouver le contract_post correspondant period_cp = next((p.contract_post for p in same_type_periods if p.contract_post_id == period_post_id), None) if period_cp: total_same_type_cost += period_qty * (period_cp.unit_price or Decimal('0')) # Total = effective_qty × prix unitaire (coefficient) × somme des coûts post_total = effective_qty * unit_price * total_same_type_cost else: # Calcul standard post_total = quantity * unit_price if contract_post.is_amount_to_be_justified and contract_post.coeff_amount_to_be_justified: post_total = post_total * contract_post.coeff_amount_to_be_justified self.cell(25, 7, contract_post.code) self.cell(85, 7, contract_post.get_description_for_language()[:50] + ("..." if len(contract_post.get_description_for_language()) > 40 else "")) self.cell(25, 7, f"{unit_price:,.2f} €".replace(",", " ").replace(".", ","), align="R") self.cell(20, 7, f"{quantity:.2f}", align="R") self.cell(25, 7, f"{post_total:,.2f} €".replace(",", " ").replace(".", ","), ln=True, align="R") total_htva += post_total elif validated_posts.exists(): # Cas 2: L'intervention a des postes validés (sans périodes validées) has_content = True for interv_post in validated_posts: self.cell(25, 7, interv_post.contract_post.code) self.cell(85, 7, interv_post.contract_post.get_description_for_language()[:50] + ("..." if len(interv_post.contract_post.get_description_for_language()) > 40 else "")) self.cell(25, 7, f"{interv_post.contract_post.unit_price:,.2f} €".replace(",", " ").replace(".", ","), align="R") self.cell(20, 7, f"{interv_post.quantity:.2f}" if interv_post.quantity else "0", align="R") # Utiliser total_price qui gère automatiquement les coefficients multipliables self.cell(25, 7, f"{interv_post.total_price:,.2f} €".replace(",", " ").replace(".", ","), ln=True, align="R") total_htva += interv_post.total_price # Si l'intervention est validée mais n'a ni postes ni périodes validées, afficher un message if not has_content and intervention.status == 'validated': self.set_font("arial", style="I", size=8) self.cell(0, 7, _("Aucun poste validé pour cette intervention"), ln=True) self.set_font("arial", style="", size=8) # Total de l'intervention total_general_htva += total_htva self.set_font("arial", style="B", size=10) self.cell(155, 7, _("Total HTVA :"), border=0, align="R") self.cell(25, 7, f"{total_htva:,.2f} €".replace(",", " ").replace(".", ","), border=0, align="R", new_x="LMARGIN", new_y="NEXT") # Ligne horizontale de séparation self.ln(5) current_y = self.get_y() self.set_draw_color(100, 100, 100) self.line(x1=10, y1=current_y, x2=200, y2=current_y) # Section Commandes directes from contracts.models import DirectOrderPost direct_posts_qs = list( order.direct_posts .filter(control_status='validated') .select_related('contract_post') .order_by('contract_post__order_number') ) if direct_posts_qs: direct_posts_total = Decimal('0.00') bloc_height = 40 + 7 * len(direct_posts_qs) if self.will_page_break(bloc_height): self.add_page() self.ln(5) self.set_font("arial", style="B", size=12) self.cell(0, 8, _("Commandes directes"), ln=True) self.ln(2) self.set_font("arial", style="", size=10) self.cell(25, 7, _("Code poste")) self.cell(85, 7, _("Description")) self.cell(25, 7, _("PU"), align="R") self.cell(20, 7, _("Qté"), align="R") self.cell(25, 7, _("Total"), ln=True, align="R") current_y = self.get_y() self.set_draw_color(200, 200, 200) self.set_dash_pattern(1, 1) self.line(10, current_y, 200, current_y) self.set_dash_pattern() self.set_font("arial", style="", size=8) for dp in direct_posts_qs: cp = dp.contract_post unit_price = cp.unit_price or Decimal('0.00') post_total = dp.total_price desc = cp.get_description_for_language() self.cell(25, 7, cp.code) self.cell(85, 7, desc[:50] + ("..." if len(desc) > 50 else "")) self.cell(25, 7, f"{unit_price:,.2f} €".replace(",", " ").replace(".", ","), align="R") self.cell(20, 7, f"{dp.quantity:.2f}", align="R") self.cell(25, 7, f"{post_total:,.2f} €".replace(",", " ").replace(".", ","), ln=True, align="R") direct_posts_total += post_total self.set_font("arial", style="B", size=10) self.cell(155, 7, _("Total HTVA :"), border=0, align="R") self.cell(25, 7, f"{direct_posts_total:,.2f} €".replace(",", " ").replace(".", ","), border=0, align="R", new_x="LMARGIN", new_y="NEXT") self.ln(5) current_y = self.get_y() self.set_draw_color(100, 100, 100) self.line(x1=10, y1=current_y, x2=200, y2=current_y) total_general_htva += direct_posts_total # Total général HTVA + TVAC à la fin if self.will_page_break(40): self.add_page() # Utiliser le total_general_htva accumulé depuis les interventions affichées tva_amount = total_general_htva * Decimal('0.21') total_tvac = total_general_htva + tva_amount from fpdf.enums import XPos, YPos self.ln(5) self.set_font("arial", style="B", size=10) # Coordonnées de départ start_x = 10 start_y = self.get_y() box_width = 190 line_height = 7 # Calculer la hauteur nécessaire pour le rectangle (seulement 3 lignes simples) num_lines = 3 # Total HTVA, TVA, TVAC # Dessin du fond gris clair self.set_fill_color(240, 240, 240) self.rect(x=start_x, y=start_y, w=box_width, h=line_height * num_lines + 5, style='F') label_x = 15 value_x = 160 value_width = 35 self.set_y(start_y + 2) self.set_font("arial", style="B", size=10) # Total général HTVA self.set_x(label_x) self.cell(value_x - label_x, line_height, _("Total général TVA non comprise :"), border=0) self.cell(value_width, line_height, f"{total_general_htva:,.2f} €".replace(",", " ").replace(".", ","), border=0, align="R", new_y="NEXT", new_x="LMARGIN") # TVA 21% self.set_x(label_x) self.cell(value_x - label_x, line_height, _("TVA 21 % :"), border=0) self.cell(value_width, line_height, f"{tva_amount:,.2f} €".replace(",", " ").replace(".", ","), border=0, align="R", new_y="NEXT", new_x="LMARGIN") # TOTAL TVAC self.set_x(label_x) self.cell(value_x - label_x, line_height, _("Total général TVAC :"), border=0) self.cell(value_width, line_height, f"{total_tvac:,.2f} €".replace(",", " ").replace(".", ","), border=0, align="R", new_y="NEXT", new_x="LMARGIN") self.set_text_color(0, 0, 0) # Reset couleur def generate_state_of_works_pdf(order, output_path, *, show_draft: bool = False): company = order.contract.company # Calculer les totaux pour obtenir included_periods totals = calculate_order_totals(order, interventions_filter={'status': 'validated'}, include_validated_periods=True) included_periods = totals.get('included_periods', {}) pdf = StateOfWorksPDF(company=company, show_draft=show_draft) pdf.add_page() pdf.add_first_page(order) pdf.add_page() pdf.add_state_of_works_data(order, included_periods) pdf.output(output_path) def preview_state_of_works_pdf(request, order_id): order = get_object_or_404(ContractOrder, id=order_id) buffer = io.BytesIO() generate_state_of_works_pdf(order, buffer, show_draft=True) buffer.seek(0) fname_prefix = slugify(_("Declaration_Creance")) filename = f"{fname_prefix}_{slugify(order.contract.contract_number)}_{slugify(order.order_code)}.pdf" response = HttpResponse(buffer.read(), content_type="application/pdf") response['Content-Disposition'] = f'inline; filename="{filename}"' return response def create_state_of_works_document(order_id): """ Génère l'État de travaux en PDF, la sauvegarde au stockage, et crée un ContractOrderDocument lié à la commande. """ order = get_object_or_404(ContractOrder, id=order_id) # 1) Génération PDF en mémoire buffer = io.BytesIO() generate_state_of_works_pdf(order, buffer) # génère sans 'Draft' buffer.seek(0) # 2) Métadonnées document today = timezone.localdate() date_token = today.strftime("%Y%m%d") # pour le nom de fichier # Préfixe traduisible ET sûr pour un fichier (slugifié) fname_prefix = slugify(_("Etat_de_travaux")) filename = f"{fname_prefix}_{slugify(order.contract.contract_number)}_{slugify(order.order_code)}_{date_token}.pdf" title_prefix = _("État de travaux") title = f"{title_prefix} - {order.order_code} - {datetime.now().strftime('%d/%m/%Y')}" # 3) Création ContractOrderDocument + sauvegarde du fichier doc = ContractOrderDocument( order=order, title=title, document_type="state_of_works", ) doc.document.save(filename, ContentFile(buffer.getvalue())) doc.save() return doc ### Devis ### class OrderQuotePDF(FPDF): def __init__(self, company: Company, show_draft: bool = False, *args, **kwargs): super().__init__(*args, **kwargs) self.company = company self.show_draft = show_draft self.set_margins(left=15, top=20, right=15) self.set_auto_page_break(auto=True, margin=20) def header(self): self.add_font("arial", style="", fname=os.path.join(settings.BASE_DIR, "common", "static", "common", "arial.ttf")) self.add_font("arial", style="B", fname=os.path.join(settings.BASE_DIR, "common", "static", "common", "arial_bold.ttf")) self.add_font("arial", style="I", fname=os.path.join(settings.BASE_DIR, "common", "static", "common", "arial_italic.ttf")) if self.company.logo: logo_path = os.path.join(settings.MEDIA_ROOT, self.company.logo.name) if os.path.exists(logo_path): self.image(logo_path, x=10, y=8, w=40) if self.show_draft: _draw_draft_ribbon(self, text="DRAFT") def footer(self): # Numéro de page centré en bas self.set_y(-12) self.set_font("arial", style="I", size=8) self.set_text_color(120, 120, 120) self.cell(0, 8, f"Page {self.page_no()}", align="C") def add_quote(self, order: ContractOrder, quote): """Render the quote as a table with columns: Poste (order number), Code, Description, Unité, PU, Qté, Total. """ lang = (get_language() or "fr")[:2] def format_money(val: Decimal) -> str: return f"{val:,.2f} €".replace(",", " ").replace(".", ",") self.set_y(35) self.set_font("arial", style="B", size=16) self.cell(0, 10, _("Devis de la commande %(order)s") % {"order": order.order_code}, align="C", new_x="LMARGIN", new_y="NEXT") self.set_font("arial", style="", size=11) contract_line = _("Contrat %(contract)s – Commande %(order)s") % { "contract": order.contract.contract_number, "order": order.order_code, } self.cell(0, 7, contract_line, new_x="LMARGIN", new_y="NEXT") if order.description: self.multi_cell(0, 6, order.description, align="L") self.ln(3) self.set_font("arial", style="B", size=12) self.cell(0, 7, _("Postes du devis"), new_x="LMARGIN", new_y="NEXT") self.ln(1) # Table header # Define column widths (landscape A4: effective width ~ 297 - margins (15*2) = 267 mm) col_w = { "order_number": 18, "code": 28, "description": 110, "unit": 16, "unit_price": 30, "quantity": 30, "total": 35, } header_h = 8 self.set_font("arial", style="B", size=9) self.set_fill_color(240, 240, 240) self.cell(col_w["order_number"], header_h, _("N° poste"), border=1, align="C", fill=True) self.cell(col_w["code"], header_h, _("Code"), border=1, align="C", fill=True) self.cell(col_w["description"], header_h, _("Description"), border=1, align="C", fill=True) self.cell(col_w["unit"], header_h, _("Unité"), border=1, align="C", fill=True) self.cell(col_w["unit_price"], header_h, _("PU"), border=1, align="C", fill=True) self.cell(col_w["quantity"], header_h, _("Quantité"), border=1, align="C", fill=True) self.cell(col_w["total"], header_h, _("Total"), border=1, align="C", fill=True, new_x="LMARGIN", new_y="NEXT") self.set_font("arial", style="", size=8) total_amount = Decimal("0") # Alternating row colors row_fill_colors = [(255,255,255), (245,245,245)] row_idx = 0 for item in quote.items.select_related("contract_post").order_by("contract_post__order_number"): post = item.contract_post description = post.description_fr or "" if lang == "nl": description = post.description_nl or post.description_fr or "" quantity = Decimal(item.quantity or 0) unit_price = Decimal(post.unit_price or 0) line_total = (unit_price * quantity).quantize(Decimal("0.01")) total_amount += line_total desc_text = description.strip() if description else "-" self.set_font("arial", style="", size=8) fill_rgb = row_fill_colors[row_idx % 2] self.set_fill_color(*fill_rgb) # Save current position for the row x_row = self.get_x() y_row = self.get_y() # Calculate description height using word wrap def wrap_text_fpdf(pdf, text, col_width): words = text.split() lines = [] current = '' for word in words: test_line = (current + ' ' + word) if current else word if pdf.get_string_width(test_line) < col_width - 2: current = test_line else: if current: lines.append(current) current = word if current: lines.append(current) return lines desc_lines = wrap_text_fpdf(self, desc_text, col_w["description"]) line_height = 4 padding_bottom = 2 desc_height = max(len(desc_lines) * line_height + padding_bottom, 8) # Draw order_number and code self.cell(col_w["order_number"], desc_height, str(post.order_number), border=1, align="C", fill=True) self.cell(col_w["code"], desc_height, post.code or "", border=1, align="L", fill=True) # Draw description cell (single cell, text manually word-wrapped) x_desc = self.get_x() y_desc = self.get_y() self.set_xy(x_desc, y_desc) self.rect(x_desc, y_desc, col_w["description"], desc_height, style='DF') for i, line in enumerate(desc_lines): self.set_xy(x_desc + 1, y_desc + 1 + i * line_height) self.cell(col_w["description"] - 2, line_height, line, border=0, align="L") # Add bottom padding self.set_xy(x_desc + col_w["description"], y_desc) # Draw the rest of the cells with the same height self.cell(col_w["unit"], desc_height, post.unit or "", border=1, align="C", fill=True) self.cell(col_w["unit_price"], desc_height, format_money(unit_price), border=1, align="R", fill=True) self.cell(col_w["quantity"], desc_height, f"{quantity:.2f}" if quantity else "0.00", border=1, align="R", fill=True) self.cell(col_w["total"], desc_height, format_money(line_total), border=1, align="R", fill=True, new_x="LMARGIN", new_y="NEXT") row_idx += 1 # Summary total, TVA, TVAC (aligned right under PU, Quantité, Total) self.ln(4) self.set_font("arial", style="B", size=10) tva = (total_amount * Decimal("0.21")).quantize(Decimal("0.01")) total_tvac = (total_amount + tva).quantize(Decimal("0.01")) # Position: start at right edge of PU column x_totals = self.l_margin + col_w["order_number"] + col_w["code"] + col_w["description"] + col_w["unit"] self.set_x(x_totals) self.cell(col_w["unit_price"], 6, "", border=0) self.cell(col_w["quantity"], 6, "", border=0) self.cell(col_w["total"], 6, _("Total HTVA : %(amount)s") % {"amount": format_money(total_amount)}, border=0, align="R", new_x="LMARGIN", new_y="NEXT") self.set_x(x_totals) self.cell(col_w["unit_price"], 6, "", border=0) self.cell(col_w["quantity"], 6, "", border=0) self.cell(col_w["total"], 6, _("TVA 21%% : %(amount)s") % {"amount": format_money(tva)}, border=0, align="R", new_x="LMARGIN", new_y="NEXT") self.set_x(x_totals) self.cell(col_w["unit_price"], 6, "", border=0) self.cell(col_w["quantity"], 6, "", border=0) self.cell(col_w["total"], 6, _("Total TVAC : %(amount)s") % {"amount": format_money(total_tvac)}, border=0, align="R", new_x="LMARGIN", new_y="NEXT") def generate_order_quote_pdf(order: ContractOrder, output_path, *, show_draft: bool = False): quote = getattr(order, "quote", None) if not quote or not quote.items.exists(): raise ValueError("No quote available for this order") # Landscape orientation for better table width pdf = OrderQuotePDF(company=order.contract.company, show_draft=show_draft, orientation='L') pdf.add_page() pdf.add_quote(order, quote) pdf.output(output_path) def preview_order_quote_pdf(request, order_id): order = get_object_or_404(ContractOrder, id=order_id) quote = getattr(order, "quote", None) if not quote or not quote.items.exists(): raise Http404(_("Aucun devis n'est disponible pour cette commande.")) buffer = io.BytesIO() generate_order_quote_pdf(order, buffer, show_draft=True) buffer.seek(0) filename = _("Devis_%(contract)s_%(order)s.pdf") % { "contract": slugify(order.contract.contract_number), "order": slugify(order.order_code), } response = HttpResponse(buffer.read(), content_type="application/pdf") response['Content-Disposition'] = f'inline; filename="{filename}"' return response def generate_posts_excel(order, incremental_map, output_path): """ Génère un fichier Excel contenant tous les postes du contrat avec leurs quantités incrémentales. Inclut tous les postes, même ceux avec quantité nulle. """ lang = (get_language() or "fr")[:2] wb = Workbook() ws = wb.active ws.title = _("Postes") # Styles header_font = Font(bold=True, color="FFFFFF") header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid") header_alignment = Alignment(horizontal="center", vertical="center") metadata_font = Font(bold=True, size=11) border = Border( left=Side(style='thin'), right=Side(style='thin'), top=Side(style='thin'), bottom=Side(style='thin') ) # Couleurs alternées pour les lignes light_fill = PatternFill(start_color="FFFFFF", end_color="FFFFFF", fill_type="solid") gray_fill = PatternFill(start_color="F2F2F2", end_color="F2F2F2", fill_type="solid") # Métadonnées en en-tête row_num = 1 ws.cell(row=row_num, column=1).value = _("Marché :") ws.cell(row=row_num, column=1).font = metadata_font ws.cell(row=row_num, column=2).value = order.contract.contract_number ws.merge_cells(start_row=row_num, start_column=2, end_row=row_num, end_column=7) row_num += 1 ws.cell(row=row_num, column=1).value = _("Description :") ws.cell(row=row_num, column=1).font = metadata_font ws.cell(row=row_num, column=2).value = order.contract.description or "" ws.merge_cells(start_row=row_num, start_column=2, end_row=row_num, end_column=7) row_num += 1 ws.cell(row=row_num, column=1).value = _("Commande :") ws.cell(row=row_num, column=1).font = metadata_font ws.cell(row=row_num, column=2).value = order.order_code ws.merge_cells(start_row=row_num, start_column=2, end_row=row_num, end_column=7) row_num += 1 ws.cell(row=row_num, column=1).value = _("Description :") ws.cell(row=row_num, column=1).font = metadata_font ws.cell(row=row_num, column=2).value = order.description or "" ws.merge_cells(start_row=row_num, start_column=2, end_row=row_num, end_column=7) row_num += 1 today = timezone.localdate() ws.cell(row=row_num, column=1).value = _("Date :") ws.cell(row=row_num, column=1).font = metadata_font ws.cell(row=row_num, column=2).value = date_format(today, "d/m/Y") ws.merge_cells(start_row=row_num, start_column=2, end_row=row_num, end_column=7) row_num += 2 # Ligne vide avant les en-têtes # En-têtes de colonnes header_row = row_num headers = [ _("N° poste"), _("Code"), _("Description"), _("Unité"), _("Prix unitaire"), _("Quantité"), _("Montant"), _("SAJ"), ] for col_num, header in enumerate(headers, 1): cell = ws.cell(row=header_row, column=col_num) cell.value = header cell.font = header_font cell.fill = header_fill cell.alignment = header_alignment cell.border = border # Largeurs de colonnes ws.column_dimensions['A'].width = 12 # N° poste ws.column_dimensions['B'].width = 15 # Code ws.column_dimensions['C'].width = 50 # Description ws.column_dimensions['D'].width = 10 # Unité ws.column_dimensions['E'].width = 14 # Prix unitaire ws.column_dimensions['F'].width = 10 # Quantité ws.column_dimensions['G'].width = 14 # Montant ws.column_dimensions['H'].width = 6 # SAJ # Récupérer tous les postes du contrat contract_posts = order.contract.posts.all().order_by('order_number') # Utiliser calculate_order_totals pour obtenir les totaux des postes coefficient (inclut les périodes validées) totals = calculate_order_totals(order, interventions_filter={'status': 'validated'}, include_validated_periods=True) row_num = header_row + 1 line_idx = 0 # Pour l'alternance des couleurs # D'abord, identifier les types de postes qui ont des coefficients coefficient_post_types = set() for post in contract_posts: if post.is_coefficient_multipliable and post.post_type: coefficient_post_types.add(post.post_type) for post in contract_posts: # Récupérer la quantité incrémentale pour ce poste incremental_qty = Decimal(incremental_map.get(post.id, 0)) # Description selon la langue description = post.description_fr or "" if lang == "nl": description = post.description_nl or post.description_fr or "" # Calcul du montant de base unit_price = Decimal(post.unit_price or 0) base_amount = unit_price * incremental_qty # Pour les postes SAJ, appliquer le coefficient coeff_amount_to_be_justified if post.is_amount_to_be_justified and post.coeff_amount_to_be_justified: actual_amount = base_amount * post.coeff_amount_to_be_justified else: actual_amount = base_amount # Marqueur SAJ is_saj = post.is_amount_to_be_justified is_coefficient_post = post.is_coefficient_multipliable # Couleur alternée row_fill = gray_fill if line_idx % 2 == 1 else light_fill # Remplir la ligne ws.cell(row=row_num, column=1).value = post.order_number ws.cell(row=row_num, column=2).value = post.code ws.cell(row=row_num, column=3).value = description ws.cell(row=row_num, column=4).value = post.unit or "" ws.cell(row=row_num, column=5).value = float(unit_price) ws.cell(row=row_num, column=5).number_format = '#,##0.00 €' ws.cell(row=row_num, column=6).value = float(incremental_qty) ws.cell(row=row_num, column=6).number_format = '0.00' # Pour les postes coefficient, afficher le supplément calculé if is_coefficient_post: coef_total = totals['coefficient_posts_totals'].get(post.code, {}).get('total', Decimal('0')) ws.cell(row=row_num, column=7).value = float(coef_total) ws.cell(row=row_num, column=7).number_format = '#,##0.00 €' else: ws.cell(row=row_num, column=7).value = float(actual_amount) ws.cell(row=row_num, column=7).number_format = '#,##0.00 €' ws.cell(row=row_num, column=8).value = "Oui" if is_saj else ("Coef." if is_coefficient_post else "") # Appliquer les bordures et la couleur de fond for col in range(1, 9): ws.cell(row=row_num, column=col).border = border ws.cell(row=row_num, column=col).alignment = Alignment(vertical="center") ws.cell(row=row_num, column=col).fill = row_fill row_num += 1 line_idx += 1 # Section des totaux ventilés row_num += 2 bold_font = Font(bold=True) # Total revisable de base ws.cell(row=row_num, column=5).value = _("Total général des fournitures et travaux revisables hors TVA :") ws.cell(row=row_num, column=5).font = bold_font ws.cell(row=row_num, column=5).alignment = Alignment(horizontal="right") ws.merge_cells(start_row=row_num, start_column=5, end_row=row_num, end_column=6) ws.cell(row=row_num, column=7).value = float(totals['total_revisable']) ws.cell(row=row_num, column=7).number_format = '#,##0.00 €' ws.cell(row=row_num, column=7).font = bold_font row_num += 1 # Coefficients de révision (suppléments) if totals['coefficient_posts_totals']: ws.cell(row=row_num, column=5).value = _("Coefficient de révision :") ws.cell(row=row_num, column=5).font = bold_font ws.cell(row=row_num, column=5).alignment = Alignment(horizontal="right") ws.merge_cells(start_row=row_num, start_column=5, end_row=row_num, end_column=6) row_num += 1 for code, coef_info in totals['coefficient_posts_totals'].items(): ws.cell(row=row_num, column=5).value = f" {coef_info['description']} :" ws.cell(row=row_num, column=5).alignment = Alignment(horizontal="right") ws.merge_cells(start_row=row_num, start_column=5, end_row=row_num, end_column=6) ws.cell(row=row_num, column=7).value = float(coef_info['total']) ws.cell(row=row_num, column=7).number_format = '#,##0.00 €' row_num += 1 # SAJ sans coefficient if totals['saj_without_coef']: ws.cell(row=row_num, column=5).value = _("Total général des fournitures et travaux non revisables hors TVA (SAJ) :") ws.cell(row=row_num, column=5).font = bold_font ws.cell(row=row_num, column=5).alignment = Alignment(horizontal="right") ws.merge_cells(start_row=row_num, start_column=5, end_row=row_num, end_column=6) ws.cell(row=row_num, column=7).value = float(totals['total_saj_without_coef']) ws.cell(row=row_num, column=7).number_format = '#,##0.00 €' ws.cell(row=row_num, column=7).font = bold_font row_num += 1 # SAJ avec coefficient for post_type, saj_data in totals['saj_with_coef'].items(): coef_formatted = f"{saj_data['coefficient']:.2f}".replace(".", ",") total_with_coef = saj_data['total'] * saj_data['coefficient'] label = _("Total général des %(type)s non revisables hors TVA (SAJ) avec coefficient %(coef)s :") % { "type": post_type, "coef": coef_formatted, } ws.cell(row=row_num, column=5).value = label ws.cell(row=row_num, column=5).font = bold_font ws.cell(row=row_num, column=5).alignment = Alignment(horizontal="right") ws.merge_cells(start_row=row_num, start_column=5, end_row=row_num, end_column=6) ws.cell(row=row_num, column=7).value = float(total_with_coef) ws.cell(row=row_num, column=7).number_format = '#,##0.00 €' ws.cell(row=row_num, column=7).font = bold_font row_num += 1 # Ligne de séparation row_num += 1 # Total général HTVA ws.cell(row=row_num, column=5).value = _("Total général TVA non comprise :") ws.cell(row=row_num, column=5).font = Font(bold=True, size=12) ws.cell(row=row_num, column=5).alignment = Alignment(horizontal="right") ws.merge_cells(start_row=row_num, start_column=5, end_row=row_num, end_column=6) ws.cell(row=row_num, column=7).value = float(totals['total_general_htva']) ws.cell(row=row_num, column=7).number_format = '#,##0.00 €' ws.cell(row=row_num, column=7).font = Font(bold=True, size=12) ws.cell(row=row_num, column=7).border = Border(top=Side(style='double')) row_num += 1 # TVA tva_amount = totals['total_general_htva'] * Decimal('0.21') ws.cell(row=row_num, column=5).value = _("TVA 21 % :") ws.cell(row=row_num, column=5).font = Font(bold=True, size=12) ws.cell(row=row_num, column=5).alignment = Alignment(horizontal="right") ws.merge_cells(start_row=row_num, start_column=5, end_row=row_num, end_column=6) ws.cell(row=row_num, column=7).value = float(tva_amount) ws.cell(row=row_num, column=7).number_format = '#,##0.00 €' ws.cell(row=row_num, column=7).font = Font(bold=True, size=12) row_num += 1 # Total TVAC total_tvac = totals['total_general_htva'] + tva_amount ws.cell(row=row_num, column=5).value = _("Total général TVAC :") ws.cell(row=row_num, column=5).font = Font(bold=True, size=12) ws.cell(row=row_num, column=5).alignment = Alignment(horizontal="right") ws.merge_cells(start_row=row_num, start_column=5, end_row=row_num, end_column=6) ws.cell(row=row_num, column=7).value = float(total_tvac) ws.cell(row=row_num, column=7).number_format = '#,##0.00 €' ws.cell(row=row_num, column=7).font = Font(bold=True, size=12) # Sauvegarder wb.save(output_path) def create_posts_excel_document(order_id, incremental_map): """ Génère le fichier Excel des postes et crée un ContractOrderDocument lié à la commande. """ order = get_object_or_404(ContractOrder, id=order_id) # Génération Excel en mémoire buffer = io.BytesIO() generate_posts_excel(order, incremental_map, buffer) buffer.seek(0) # Métadonnées document today = timezone.localdate() date_token = today.strftime("%Y%m%d") fname_prefix = slugify(_("Detail_postes")) filename = f"{fname_prefix}_{slugify(order.contract.contract_number)}_{slugify(order.order_code)}_{date_token}.xlsx" title = "%(detail)s - %(code)s - %(date)s" % { "detail": _("Détail des postes"), "code": order.order_code, "date": date_format(timezone.now(), "d/m/Y"), } # Création ContractOrderDocument + sauvegarde du fichier doc = ContractOrderDocument( order=order, title=title, document_type="posts_detail", ) doc.document.save(filename, ContentFile(buffer.getvalue())) doc.save() return doc def generate_documents_for_order(request, order_id): order = get_object_or_404(ContractOrder, id=order_id) if not user_can_generate_order_documents(request.user, order): messages.error(request, _("Vous n'avez pas la permission de générer des documents pour cette commande.")) return redirect("contracts:contracts_orders_detail", order_id=order.id) from interventions.models import InterventionContractPost, InterventionPostPeriodQuantity # Collecter les commandes directes validées AVANT génération des documents (pour les inclure # dans l'état de travaux/Excel et les réinitialiser seulement après) validated_direct_posts = list(order.direct_posts.filter(control_status='validated').select_related('contract_post')) # Créer le document de déclaration de créance claim_doc = create_claim_declaration_document(order.id) # Créer l'état d'avancement avec les quantités **incrémentales** depuis le dernier état # Déterminer le prochain index d'état d'avancement pour cette commande last_state = order.progress_states.order_by('-created_at').first() next_index = (last_state.index or 0) + 1 if last_state else 1 progress_state = ContractOrderProgressState.objects.create( order=order, created_by=request.user, index=next_index, ) # Associer la déclaration de créance et l'état de travaux à cet état d'avancement claim_doc.progress_state = progress_state claim_doc.save(update_fields=["progress_state"]) # Générer et lier l'état de travaux à ce même état d'avancement sow_doc = create_state_of_works_document(order.id) sow_doc.progress_state = progress_state sow_doc.save(update_fields=["progress_state"]) # Récupérer les quantités des postes pour les interventions VALIDÉES de cette déclaration # et les périodes validées (non facturées) incremental_map = {} validated_interventions = order.interventions.filter(status='validated') # Postes validés des interventions validées for intervention in validated_interventions: for interv_post in intervention.interv_contract_posts.filter(control_status='validated'): contract_post_id = interv_post.contract_post_id quantity = interv_post.quantity or 0 if contract_post_id in incremental_map: incremental_map[contract_post_id] += quantity else: incremental_map[contract_post_id] = quantity # Périodes validées (status='validated') de toutes les interventions de la commande all_intervention_ids = list(order.interventions.values_list('id', flat=True)) validated_periods = InterventionPostPeriodQuantity.objects.filter( intervention_id__in=all_intervention_ids, status='validated' ) for period_qty in validated_periods: contract_post_id = period_qty.contract_post_id quantity = period_qty.quantity or 0 if contract_post_id in incremental_map: incremental_map[contract_post_id] += quantity else: incremental_map[contract_post_id] = quantity # Ajouter les commandes directes validées à la carte incrémentale for dp in validated_direct_posts: contract_post_id = dp.contract_post_id quantity = dp.quantity or 0 if contract_post_id in incremental_map: incremental_map[contract_post_id] += quantity else: incremental_map[contract_post_id] = quantity # Générer et lier le fichier Excel des postes à cet état d'avancement excel_doc = create_posts_excel_document(order.id, incremental_map) excel_doc.progress_state = progress_state excel_doc.save(update_fields=["progress_state"]) # Créer les items d'état d'avancement uniquement pour le delta de cette déclaration for contract_post_id, incremental_qty in incremental_map.items(): ContractOrderProgressStateItem.objects.create( progress_state=progress_state, contract_post_id=contract_post_id, quantity=incremental_qty, ) # Mettre à jour le statut des interventions : # 1. Toutes les interventions validées (même sans postes validés) validated_interventions = order.interventions.filter(status='validated') for intervention in validated_interventions: intervention.status = 'invoiced' intervention.save() # 2. Toutes les interventions avec périodes validées (quel que soit leur statut) intervention_ids_with_validated_periods = validated_periods.values_list('intervention_id', flat=True).distinct() interventions_with_periods = order.interventions.filter(id__in=intervention_ids_with_validated_periods) for intervention in interventions_with_periods: # Passer à 'invoiced' seulement si pas déjà dans cet état if intervention.status != 'invoiced': intervention.status = 'invoiced' intervention.save() # Marquer les périodes validées comme 'invoiced' validated_periods.update(status='invoiced') # Enregistrer la quantité facturée et réinitialiser les commandes directes validées for dp in validated_direct_posts: dp.invoiced_quantity = (dp.invoiced_quantity or Decimal('0')) + dp.quantity dp.quantity = Decimal('0') dp.control_status = 'invoiced' dp.validated_at = None dp.validated_by = None dp.save(update_fields=['invoiced_quantity', 'quantity', 'control_status', 'validated_at', 'validated_by']) messages.success(request, _("Déclaration de créance créée et sauvegardée.")) return redirect("contracts:contracts_orders_detail", order_id=order.id) def _safe_set_alpha(pdf, alpha: float): """Set the transparency (alpha) for the PDF if supported by the FPDF instance.""" if hasattr(pdf, "set_alpha"): pdf.set_alpha(alpha) return True return False def _draw_draft_ribbon(pdf, text="DRAFT"): # Sauvegarder état prev_draw = (pdf.draw_color, pdf.fill_color, pdf.text_color) prev_font = (pdf.font_family, pdf.font_style, pdf.font_size_pt) # Géométrie du ruban (en mm) ribbon_w = 120 # longueur du bandeau ribbon_h = 18 # épaisseur du bandeau margin = 6 # marge par rapport aux bords # Point d’ancrage: coin supérieur droit xr = pdf.w - pdf.r_margin - margin yr = pdf.t_margin + margin # Transparence (si dispo) had_alpha = _safe_set_alpha(pdf, 0.25) # 25% opaque (75% transparent) # Rotation & dessin try: # fpdf2: contexte de rotation with pdf.rotation(-45, x=xr, y=yr): # Rectangle rouge (ou rose clair si pas d’alpha) if not had_alpha: pdf.set_fill_color(255, 200, 200) # fallback 'pseudo-transparence' else: pdf.set_fill_color(220, 0, 0) pdf.rect(x=xr - ribbon_w/2, y=yr - ribbon_h/2, w=ribbon_w, h=ribbon_h, style='F') # Texte pdf.set_font("arial", style="B", size=16) pdf.set_text_color(255, 255, 255) # texte blanc pour contraste pdf.set_xy(xr - ribbon_w/2, yr - ribbon_h/2 + 2) pdf.cell(w=ribbon_w, h=ribbon_h-4, txt=text, align="C") finally: # Reset transparence if had_alpha: pdf.set_alpha(1.0) # Reset couleurs & police pdf.set_draw_color(*prev_draw) pdf.set_fill_color(*prev_draw) # inoffensif si draw=fill couplés par fpdf pdf.set_text_color(*prev_draw) pdf.set_font(prev_font[0], style=prev_font[1], size=prev_font[2])