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

1542 lines
No EOL
70 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

from django.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])