1542 lines
No EOL
70 KiB
Python
1542 lines
No EOL
70 KiB
Python
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]) |