feat(pdf): refactor PDF generation with new SagePDFGenerator class

This commit is contained in:
Fanilo-Nantenaina 2026-01-13 16:30:34 +03:00
parent b17e4abf12
commit a2c85a211a
9 changed files with 618 additions and 212 deletions

View file

@ -18,7 +18,7 @@ services:
DATABASE_URL: "sqlite+aiosqlite:///./data/sage_dataven.db" DATABASE_URL: "sqlite+aiosqlite:///./data/sage_dataven.db"
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"] test: ["CMD", "curl", "-f", "http://localhost:8000/"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3

View file

@ -16,7 +16,7 @@ services:
DATABASE_URL: "sqlite+aiosqlite:///./data/sage_prod.db" DATABASE_URL: "sqlite+aiosqlite:///./data/sage_prod.db"
restart: always restart: always
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8004/health"] test: ["CMD", "curl", "-f", "http://localhost:8004/"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 5 retries: 5

View file

@ -16,7 +16,7 @@ services:
DATABASE_URL: "sqlite+aiosqlite:///./data/sage_staging.db" DATABASE_URL: "sqlite+aiosqlite:///./data/sage_staging.db"
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8002/health"] test: ["CMD", "curl", "-f", "http://localhost:8002/"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3

View file

@ -5,6 +5,7 @@ from datetime import datetime, timedelta
import smtplib import smtplib
import ssl import ssl
import socket import socket
import os
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email.mime.application import MIMEApplication from email.mime.application import MIMEApplication
@ -12,14 +13,67 @@ from config.config import settings
import logging import logging
from reportlab.lib.pagesizes import A4 from reportlab.lib.pagesizes import A4
from reportlab.pdfgen import canvas from reportlab.pdfgen import canvas
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from io import BytesIO from io import BytesIO
from reportlab.lib.units import mm from reportlab.lib.units import mm
from reportlab.lib.colors import HexColor from reportlab.lib.colors import HexColor, Color
from PIL import Image
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
FONT_PATH = os.getenv("SAGE_FONT_PATH", "/sage/pdfs/Sage_Text-Medium.ttf")
LOGO_PATH = os.getenv("SAGE_LOGO_PATH", "/sage/pdfs/logo.png")
FONT_FALLBACK_PATHS = [
"/sage/assets/Sage_Text-Medium.ttf",
"./sage/assets/Sage_Text-Medium.ttf",
"/sage/assets/Sage_Text-Bold.ttf",
"./sage/assets/Sage_Text-Bold.ttf",
]
LOGO_FALLBACK_PATHS = [
"/sage/assets/logo.png",
"./sage/assets/logo.png",
]
_sage_font_registered = False
def _find_file(primary_path: str, fallback_paths: list) -> str | None:
"""Recherche un fichier dans les chemins possibles."""
all_paths = [primary_path] + fallback_paths
for path in all_paths:
if path and os.path.exists(path):
return path
return None
def _register_sage_font():
"""Enregistre la police Sage si disponible."""
global _sage_font_registered
if _sage_font_registered:
return True
font_path = _find_file(FONT_PATH, FONT_FALLBACK_PATHS)
if font_path:
try:
pdfmetrics.registerFont(TTFont("SageText", font_path))
pdfmetrics.registerFont(TTFont("SageText-Bold", font_path))
_sage_font_registered = True
logger.info(f"Police Sage enregistrée: {font_path}")
return True
except Exception as e:
logger.warning(f"Impossible d'enregistrer la police Sage: {e}")
logger.info("Utilisation de Helvetica comme police de fallback")
return False
class EmailQueue: class EmailQueue:
def __init__(self): def __init__(self):
self.queue = queue.Queue() self.queue = queue.Queue()
@ -205,252 +259,604 @@ class EmailQueue:
if not doc: if not doc:
raise Exception(f"Document {doc_id} introuvable") raise Exception(f"Document {doc_id} introuvable")
buffer = BytesIO() # Générer le PDF avec le nouveau générateur
pdf = canvas.Canvas(buffer, pagesize=A4) generator = SagePDFGenerator(doc, type_doc)
width, height = A4 return generator.generate()
# Couleurs
green_color = HexColor("#2A6F4F")
gray_400 = HexColor("#9CA3AF")
gray_600 = HexColor("#4B5563")
gray_800 = HexColor("#1F2937")
# Marges class SagePDFGenerator:
margin = 8 * mm # Couleurs Sage
content_width = width - 2 * margin SAGE_GREEN = HexColor("#00D639")
SAGE_GREEN_DARK = HexColor("#00B830")
GRAY_50 = HexColor("#F9FAFB")
GRAY_100 = HexColor("#F3F4F6")
GRAY_200 = HexColor("#E5E7EB")
GRAY_300 = HexColor("#D1D5DB")
GRAY_400 = HexColor("#9CA3AF")
GRAY_500 = HexColor("#6B7280")
GRAY_600 = HexColor("#4B5563")
GRAY_700 = HexColor("#374151")
GRAY_800 = HexColor("#1F2937")
GRAY_900 = HexColor("#111827")
y = height - margin COMPANY_NAME = "Bijou S.A.S"
COMPANY_ADDRESS_1 = "123 Avenue de la République"
COMPANY_ADDRESS_2 = "75011 Paris, France"
COMPANY_EMAIL = "contact@bijou.com"
# ===== HEADER ===== # Labels des types de documents
y -= 20 * mm TYPE_LABELS = {
0: "Devis",
10: "Commande",
30: "Bon de Livraison",
50: "Avoir",
60: "Facture",
}
# Logo/Nom entreprise à gauche def __init__(self, doc_data: dict, type_doc: int):
pdf.setFont("Helvetica-Bold", 18) self.doc = doc_data
pdf.setFillColor(green_color) self.type_doc = type_doc
pdf.drawString(margin, y, "Bijou S.A.S") self.type_label = self.TYPE_LABELS.get(type_doc, "Document")
# Informations document à droite # Configuration de la page
pdf.setFillColor(gray_800) self.page_width, self.page_height = A4
pdf.setFont("Helvetica-Bold", 20) self.margin = 15 * mm
numero = doc.get("numero") or "BROUILLON" self.content_width = self.page_width - 2 * self.margin
pdf.drawRightString(width - margin, y, numero.upper())
y -= 7 * mm # État du générateur
pdf.setFont("Helvetica", 9) self.buffer = BytesIO()
pdf.setFillColor(gray_600) self.pdf = None
self.current_y = 0
self.page_number = 1
self.total_pages = 1 # Sera calculé
date_str = doc.get("date") or datetime.now().strftime("%d/%m/%Y") # Initialiser les polices
pdf.drawRightString(width - margin, y, f"Date : {date_str}") _register_sage_font()
self.use_sage_font = _sage_font_registered
y -= 5 * mm def _get_font(self, bold: bool = False) -> str:
date_livraison = ( """Retourne le nom de la police à utiliser."""
doc.get("date_livraison") or doc.get("date_echeance") or date_str if self.use_sage_font:
return "SageText-Bold" if bold else "SageText"
return "Helvetica-Bold" if bold else "Helvetica"
def _draw_text(
self,
x: float,
y: float,
text: str,
font_size: int = 9,
bold: bool = False,
color: Color = None,
align: str = "left",
):
self.pdf.setFont(self._get_font(bold), font_size)
if color:
self.pdf.setFillColor(color)
if align == "right":
self.pdf.drawRightString(x, y, str(text))
elif align == "center":
self.pdf.drawCentredString(x, y, str(text))
else:
self.pdf.drawString(x, y, str(text))
def _draw_logo(
self,
x: float,
y: float,
max_width: float = 50 * mm,
max_height: float = 20 * mm,
):
"""Dessine le logo Sage ou le nom de l'entreprise."""
logo_path = _find_file(LOGO_PATH, LOGO_FALLBACK_PATHS)
if logo_path:
try:
with Image.open(logo_path) as img:
img_width, img_height = img.size
ratio = min(max_width / img_width, max_height / img_height)
draw_width = img_width * ratio
draw_height = img_height * ratio
self.pdf.drawImage(
logo_path,
x,
y - draw_height,
width=draw_width,
height=draw_height,
preserveAspectRatio=True,
mask="auto",
) )
pdf.drawRightString(width - margin, y, f"Validité : {date_livraison}") return draw_height
y -= 5 * mm except Exception as e:
reference = doc.get("reference") or "" logger.warning(f"Impossible de charger le logo: {e}")
pdf.drawRightString(width - margin, y, f"Réf : {reference}")
# ===== ADDRESSES ===== self._draw_text(
y -= 20 * mm x, y - 8 * mm, "Sage", font_size=24, bold=True, color=self.SAGE_GREEN
)
return 10 * mm
# Émetteur (gauche) def _new_page(self):
col1_x = margin """Crée une nouvelle page."""
col2_x = margin + content_width / 2 + 6 * mm if self.pdf:
col_width = content_width / 2 - 6 * mm self.pdf.showPage()
self.page_number += 1
self.current_y = self.page_height - self.margin
pdf.setFont("Helvetica-Bold", 8) def _draw_header(self):
pdf.setFillColor(gray_400) y = self.page_height - self.margin
pdf.drawString(col1_x, y, "ÉMETTEUR")
y_emetteur = y - 5 * mm logo_height = self._draw_logo(
pdf.setFont("Helvetica-Bold", 10) self.margin, y, max_width=55 * mm, max_height=22 * mm
pdf.setFillColor(gray_800) )
pdf.drawString(col1_x, y_emetteur, "Bijou S.A.S")
right_x = self.page_width - self.margin
numero = self.doc.get("numero") or "BROUILLON"
self._draw_text(
right_x,
y - 6 * mm,
numero.upper(),
font_size=20,
bold=True,
color=self.GRAY_800,
align="right",
)
date_str = self.doc.get("date") or datetime.now().strftime("%d/%m/%Y")
self._draw_text(
right_x,
y - 14 * mm,
f"Date : {date_str}",
font_size=9,
color=self.GRAY_600,
align="right",
)
date_validite = (
self.doc.get("date_livraison") or self.doc.get("date_echeance") or date_str
)
self._draw_text(
right_x,
y - 20 * mm,
f"Validité : {date_validite}",
font_size=9,
color=self.GRAY_600,
align="right",
)
reference = self.doc.get("reference") or ""
self._draw_text(
right_x,
y - 26 * mm,
f"Réf : {reference}",
font_size=9,
color=self.GRAY_600,
align="right",
)
return y - max(logo_height + 10 * mm, 35 * mm)
def _draw_addresses(self, y: float) -> float:
col1_x = self.margin
col2_x = self.margin + self.content_width / 2 + 8 * mm
col_width = self.content_width / 2 - 8 * mm
self._draw_text(
col1_x, y, "ÉMETTEUR", font_size=8, bold=True, color=self.GRAY_400
)
y_emetteur = y - 6 * mm
self._draw_text(
col1_x,
y_emetteur,
self.COMPANY_NAME,
font_size=10,
bold=True,
color=self.GRAY_800,
)
y_emetteur -= 5 * mm y_emetteur -= 5 * mm
pdf.setFont("Helvetica", 9) self._draw_text(
pdf.setFillColor(gray_600) col1_x, y_emetteur, self.COMPANY_ADDRESS_1, font_size=9, color=self.GRAY_600
pdf.drawString(col1_x, y_emetteur, "123 Avenue de la République") )
y_emetteur -= 4 * mm y_emetteur -= 4 * mm
pdf.drawString(col1_x, y_emetteur, "75011 Paris, France") self._draw_text(
col1_x, y_emetteur, self.COMPANY_ADDRESS_2, font_size=9, color=self.GRAY_600
)
y_emetteur -= 5 * mm y_emetteur -= 5 * mm
pdf.drawString(col1_x, y_emetteur, "contact@bijou.com") self._draw_text(
col1_x, y_emetteur, self.COMPANY_EMAIL, font_size=9, color=self.GRAY_600
# Destinataire (droite, avec fond gris)
box_y = y - 4 * mm
box_height = 28 * mm
pdf.setFillColorRGB(0.97, 0.97, 0.97) # bg-gray-50
pdf.roundRect(
col2_x, box_y - box_height, col_width, box_height, 3 * mm, fill=1, stroke=0
) )
pdf.setFillColor(gray_400) box_padding = 4 * mm
pdf.setFont("Helvetica-Bold", 8) box_height = 26 * mm
pdf.drawString(col2_x + 4 * mm, y, "DESTINATAIRE") box_y = y - 3 * mm
y_dest = y - 5 * mm # Fond gris arrondi
pdf.setFont("Helvetica-Bold", 10) self.pdf.setFillColor(self.GRAY_50)
pdf.setFillColor(gray_800) self.pdf.roundRect(
client_name = doc.get("client_intitule") or "Client" col2_x - box_padding,
pdf.drawString(col2_x + 4 * mm, y_dest, client_name) box_y - box_height,
col_width + 2 * box_padding,
box_height + box_padding,
3 * mm,
fill=1,
stroke=0,
)
self._draw_text(
col2_x, y, "DESTINATAIRE", font_size=8, bold=True, color=self.GRAY_400
)
y_dest = y - 6 * mm
client_name = (
self.doc.get("client_intitule") or self.doc.get("client_nom") or "Client"
)
self._draw_text(
col2_x, y_dest, client_name, font_size=10, bold=True, color=self.GRAY_800
)
y_dest -= 5 * mm y_dest -= 5 * mm
pdf.setFont("Helvetica", 9) client_adresse = (
pdf.setFillColor(gray_600) self.doc.get("client_adresse") or self.doc.get("adresse_livraison") or ""
pdf.drawString(col2_x + 4 * mm, y_dest, "10 rue des Clients") )
if client_adresse:
if len(client_adresse) > 40:
client_adresse = client_adresse[:37] + "..."
self._draw_text(
col2_x, y_dest, client_adresse, font_size=9, color=self.GRAY_600
)
y_dest -= 4 * mm y_dest -= 4 * mm
pdf.drawString(col2_x + 4 * mm, y_dest, "75001 Paris")
# ===== LIGNES D'ARTICLES ===== client_cp = self.doc.get("client_cp") or ""
y = min(y_emetteur, y_dest) - 20 * mm client_ville = self.doc.get("client_ville") or ""
if client_cp or client_ville:
ville_complete = f"{client_cp} {client_ville}".strip()
self._draw_text(
col2_x, y_dest, ville_complete, font_size=9, color=self.GRAY_600
)
# En-têtes des colonnes return min(y_emetteur, box_y - box_height) - 15 * mm
col_designation = margin
col_quantite = width - margin - 80 * mm
col_prix_unit = width - margin - 64 * mm
col_taux_taxe = width - margin - 40 * mm
col_montant = width - margin - 24 * mm
pdf.setFont("Helvetica-Bold", 9) def _draw_table_header(self, y: float) -> float:
pdf.setFillColor(gray_800) col_des = self.margin
pdf.drawString(col_designation, y, "Désignation") col_qte = self.margin + 95 * mm
pdf.drawRightString(col_quantite, y, "Qté") col_pu = self.margin + 115 * mm
pdf.drawRightString(col_prix_unit, y, "Prix Unit. HT") col_rem = self.margin + 140 * mm
pdf.drawRightString(col_taux_taxe, y, "TVA") col_tva = self.margin + 157 * mm
pdf.drawRightString(col_montant, y, "Montant HT") col_mt = self.page_width - self.margin
y -= 7 * mm self.pdf.setStrokeColor(self.GRAY_200)
self.pdf.setLineWidth(0.5)
self.pdf.line(
self.margin, y - 3 * mm, self.page_width - self.margin, y - 3 * mm
)
# Lignes d'articles self._draw_text(
pdf.setFont("Helvetica", 8) col_des, y, "Désignation", font_size=9, bold=True, color=self.GRAY_700
lignes = doc.get("lignes", []) )
self._draw_text(
col_qte,
y,
"Qté",
font_size=9,
bold=True,
color=self.GRAY_700,
align="right",
)
self._draw_text(
col_pu,
y,
"Prix Unit. HT",
font_size=9,
bold=True,
color=self.GRAY_700,
align="right",
)
self._draw_text(
col_rem,
y,
"Remise",
font_size=9,
bold=True,
color=self.GRAY_700,
align="right",
)
self._draw_text(
col_tva,
y,
"TVA",
font_size=9,
bold=True,
color=self.GRAY_700,
align="right",
)
self._draw_text(
col_mt,
y,
"Montant HT",
font_size=9,
bold=True,
color=self.GRAY_700,
align="right",
)
for ligne in lignes: return y - 8 * mm
if y < 60 * mm: # Nouvelle page si nécessaire
pdf.showPage() def _draw_line_item(self, y: float, ligne: dict, alternate: bool = False) -> float:
y = height - margin - 20 * mm col_des = self.margin
pdf.setFont("Helvetica", 8) col_qte = self.margin + 95 * mm
col_pu = self.margin + 115 * mm
col_rem = self.margin + 140 * mm
col_tva = self.margin + 157 * mm
col_mt = self.page_width - self.margin
if alternate:
self.pdf.setFillColor(self.GRAY_50)
self.pdf.rect(
self.margin - 2 * mm,
y - 5 * mm,
self.content_width + 4 * mm,
12 * mm,
fill=1,
stroke=0,
)
designation = ( designation = (
ligne.get("designation") or ligne.get("designation_article") or "" ligne.get("designation")
or ligne.get("designation_article")
or ligne.get("article_designation")
or ""
)
if len(designation) > 50:
designation = designation[:47] + "..."
self._draw_text(
col_des, y, designation, font_size=9, bold=True, color=self.GRAY_800
) )
if len(designation) > 60:
designation = designation[:57] + "..."
pdf.setFillColor(gray_800)
pdf.setFont("Helvetica-Bold", 8)
pdf.drawString(col_designation, y, designation)
y -= 4 * mm
# Description (si différente)
description = ligne.get("description", "") description = ligne.get("description", "")
if description and description != designation: line_height = 6 * mm
pdf.setFont("Helvetica", 7)
pdf.setFillColor(gray_600)
if len(description) > 70:
description = description[:67] + "..."
pdf.drawString(col_designation, y, description)
y -= 4 * mm
# Valeurs if description and description != designation:
y += 4 * mm # Remonter pour aligner avec la désignation y -= 4 * mm
pdf.setFont("Helvetica", 8) if len(description) > 60:
pdf.setFillColor(gray_800) description = description[:57] + "..."
self._draw_text(col_des, y, description, font_size=8, color=self.GRAY_500)
line_height += 4 * mm
quantite = ligne.get("quantite") or 0 quantite = ligne.get("quantite") or 0
pdf.drawRightString(col_quantite, y, str(quantite)) self._draw_text(
col_qte,
y + (4 * mm if description and description != designation else 0),
str(quantite),
font_size=9,
color=self.GRAY_800,
align="right",
)
prix_unit = ligne.get("prix_unitaire_ht") or ligne.get("prix_unitaire", 0) prix_unit = ligne.get("prix_unitaire_ht") or ligne.get("prix_unitaire") or 0
pdf.drawRightString(col_prix_unit, y, f"{prix_unit:.2f}") self._draw_text(
col_pu,
y + (4 * mm if description and description != designation else 0),
f"{prix_unit:.2f}",
font_size=9,
color=self.GRAY_800,
align="right",
)
taux_taxe = ligne.get("taux_taxe1") or 20 remise = ligne.get("remise_pourcentage") or ligne.get("remise") or 0
pdf.drawRightString(col_taux_taxe, y, f"{taux_taxe}%") remise_text = f"{remise:.0f}%" if remise > 0 else ""
remise_color = self.SAGE_GREEN_DARK if remise > 0 else self.GRAY_400
self._draw_text(
col_rem,
y + (4 * mm if description and description != designation else 0),
remise_text,
font_size=9,
color=remise_color,
align="right",
)
montant = ligne.get("montant_ligne_ht") or ligne.get("montant_ht", 0) # TVA
pdf.setFont("Helvetica-Bold", 8) taux_tva = ligne.get("taux_taxe1") or ligne.get("taux_tva") or 20
pdf.drawRightString(col_montant, y, f"{montant:.2f}") self._draw_text(
col_tva,
y + (4 * mm if description and description != designation else 0),
f"{taux_tva:.0f}%",
font_size=9,
color=self.GRAY_600,
align="right",
)
y -= 8 * mm montant_ht = ligne.get("montant_ligne_ht") or ligne.get("montant_ht") or 0
self._draw_text(
col_mt,
y + (4 * mm if description and description != designation else 0),
f"{montant_ht:.2f}",
font_size=9,
bold=True,
color=self.GRAY_800,
align="right",
)
# Si aucune ligne return y - line_height - 2 * mm
if not lignes:
pdf.setFont("Helvetica-Oblique", 9)
pdf.setFillColor(gray_400)
pdf.drawCentredString(width / 2, y, "Aucune ligne")
y -= 15 * mm
# ===== TOTAUX ===== def _draw_totals(self, y: float) -> float:
y -= 10 * mm totals_x = self.page_width - self.margin - 60 * mm
right_x = self.page_width - self.margin
totals_x = width - margin - 64 * mm self.pdf.setStrokeColor(self.GRAY_200)
totals_label_width = 40 * mm self.pdf.setLineWidth(0.5)
self.pdf.line(totals_x - 5 * mm, y + 5 * mm, right_x, y + 5 * mm)
pdf.setFont("Helvetica", 9) self._draw_text(totals_x, y, "Total HT", font_size=9, color=self.GRAY_600)
pdf.setFillColor(gray_600) total_ht = self.doc.get("total_ht_net") or self.doc.get("total_ht") or 0
self._draw_text(
# Total HT right_x,
pdf.drawString(totals_x, y, "Total HT") y,
total_ht = doc.get("total_ht_net") or doc.get("total_ht") or 0 f"{total_ht:.2f}",
pdf.drawRightString(width - margin, y, f"{total_ht:.2f}") font_size=9,
color=self.GRAY_800,
align="right",
)
y -= 6 * mm y -= 6 * mm
remise_globale = (
self.doc.get("remise_globale") or self.doc.get("montant_remise") or 0
)
if remise_globale > 0:
self._draw_text(totals_x, y, "Remise", font_size=9, color=self.GRAY_600)
self._draw_text(
right_x,
y,
f"-{remise_globale:.2f}",
font_size=9,
color=self.SAGE_GREEN_DARK,
align="right",
)
y -= 6 * mm
# TVA # TVA
pdf.drawString(totals_x, y, "TVA") total_ttc = self.doc.get("total_ttc") or 0
total_ttc = doc.get("total_ttc") or 0 tva = total_ttc - total_ht + remise_globale
tva = total_ttc - total_ht taux_tva_global = self.doc.get("taux_tva_principal") or 20
pdf.drawRightString(width - margin, y, f"{tva:.2f}") self._draw_text(
totals_x,
y,
f"TVA ({taux_tva_global:.0f}%)",
font_size=9,
color=self.GRAY_600,
)
self._draw_text(
right_x, y, f"{tva:.2f}", font_size=9, color=self.GRAY_800, align="right"
)
y -= 8 * mm y -= 10 * mm
# Ligne de séparation self.pdf.setStrokeColor(self.GRAY_300)
pdf.setStrokeColor(gray_400) self.pdf.setLineWidth(1)
pdf.line(totals_x, y + 2 * mm, width - margin, y + 2 * mm) self.pdf.line(totals_x - 5 * mm, y + 4 * mm, right_x, y + 4 * mm)
# Net à payer self._draw_text(
pdf.setFont("Helvetica-Bold", 12) totals_x, y, "Net à payer", font_size=12, bold=True, color=self.SAGE_GREEN
pdf.setFillColor(green_color) )
pdf.drawString(totals_x, y, "Net à payer") self._draw_text(
pdf.drawRightString(width - margin, y, f"{total_ttc:.2f}") right_x,
y,
f"{total_ttc:.2f}",
font_size=12,
bold=True,
color=self.SAGE_GREEN,
align="right",
)
# ===== NOTES ===== return y - 15 * mm
notes = doc.get("notes_publique") or doc.get("notes")
if notes: def _draw_notes(self, y: float) -> float:
y -= 15 * mm notes = (
pdf.setStrokeColor(gray_400) self.doc.get("notes_publique")
pdf.line(margin, y + 5 * mm, width - margin, y + 5 * mm) or self.doc.get("notes")
or self.doc.get("commentaire")
)
if not notes:
return y
self.pdf.setStrokeColor(self.GRAY_200)
self.pdf.setLineWidth(0.5)
self.pdf.line(
self.margin, y + 5 * mm, self.page_width - self.margin, y + 5 * mm
)
self._draw_text(
self.margin,
y,
"NOTES & CONDITIONS",
font_size=8,
bold=True,
color=self.GRAY_400,
)
y -= 5 * mm y -= 5 * mm
pdf.setFont("Helvetica-Bold", 8)
pdf.setFillColor(gray_400)
pdf.drawString(margin, y, "NOTES & CONDITIONS")
y -= 5 * mm
pdf.setFont("Helvetica", 8)
pdf.setFillColor(gray_600)
# Gérer les sauts de ligne dans les notes
for line in notes.split("\n"): for line in notes.split("\n"):
if y < 25 * mm: if y < 30 * mm:
break break
pdf.drawString(margin, y, line[:100]) line = line.strip()
if line:
if len(line) > 90:
line = line[:87] + "..."
self._draw_text(self.margin, y, line, font_size=8, color=self.GRAY_600)
y -= 4 * mm y -= 4 * mm
# ===== FOOTER ===== return y
pdf.setFont("Helvetica", 7)
pdf.setFillColor(gray_400)
pdf.drawCentredString(width / 2, 15 * mm, "Page 1 / 1")
pdf.save() def _draw_footer(self):
buffer.seek(0) footer_y = 12 * mm
return buffer.read() page_text = f"Page {self.page_number} / {self.total_pages}"
self._draw_text(
self.page_width / 2,
footer_y,
page_text,
font_size=8,
color=self.GRAY_400,
align="center",
)
def generate(self) -> bytes:
self.pdf = canvas.Canvas(self.buffer, pagesize=A4)
self.current_y = self._draw_header()
self.current_y = self._draw_addresses(self.current_y)
self.current_y = self._draw_table_header(self.current_y)
lignes = self.doc.get("lignes", [])
if not lignes:
self.pdf.setFont(self._get_font(), 9)
self.pdf.setFillColor(self.GRAY_400)
self.pdf.drawCentredString(
self.page_width / 2, self.current_y, "Aucune ligne"
)
self.current_y -= 15 * mm
else:
for idx, ligne in enumerate(lignes):
if self.current_y < 70 * mm:
self._draw_footer()
self._new_page()
self.current_y = self._draw_table_header(
self.page_height - self.margin - 10 * mm
)
self.current_y = self._draw_line_item(
self.current_y, ligne, alternate=(idx % 2 == 1)
)
if self.current_y < 60 * mm:
self._draw_footer()
self._new_page()
self.current_y = self.page_height - self.margin - 20 * mm
self.current_y = self._draw_totals(self.current_y)
self.current_y = self._draw_notes(self.current_y)
self._draw_footer()
self.pdf.save()
self.buffer.seek(0)
return self.buffer.read()
email_queue = EmailQueue() email_queue = EmailQueue()

Binary file not shown.

Binary file not shown.

BIN
sage/pdfs/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View file

@ -36,7 +36,7 @@ class UniversignDocumentService:
data = response.json() data = response.json()
documents = data.get("documents", []) documents = data.get("documents", [])
logger.info(f"{len(documents)} document(s) trouvé(s)") logger.info(f"{len(documents)} document(s) trouvé(s)")
# Log détaillé de chaque document # Log détaillé de chaque document
for idx, doc in enumerate(documents): for idx, doc in enumerate(documents):
@ -90,7 +90,7 @@ class UniversignDocumentService:
content_length = response.headers.get("Content-Length", "unknown") content_length = response.headers.get("Content-Length", "unknown")
logger.info( logger.info(
f"Téléchargement réussi: " f"Téléchargement réussi: "
f"Content-Type={content_type}, Size={content_length}" f"Content-Type={content_type}, Size={content_length}"
) )
@ -100,7 +100,7 @@ class UniversignDocumentService:
and "octet-stream" not in content_type.lower() and "octet-stream" not in content_type.lower()
): ):
logger.warning( logger.warning(
f"⚠️ Type de contenu inattendu: {content_type}. " f"Type de contenu inattendu: {content_type}. "
f"Tentative de lecture quand même..." f"Tentative de lecture quand même..."
) )
@ -146,7 +146,7 @@ class UniversignDocumentService:
if not force and transaction.signed_document_path: if not force and transaction.signed_document_path:
if os.path.exists(transaction.signed_document_path): if os.path.exists(transaction.signed_document_path):
logger.debug( logger.debug(
f"Document déjà téléchargé: {transaction.transaction_id}" f"Document déjà téléchargé: {transaction.transaction_id}"
) )
return True, None return True, None
@ -162,7 +162,7 @@ class UniversignDocumentService:
if not documents: if not documents:
error = "Aucun document trouvé dans la transaction Universign" error = "Aucun document trouvé dans la transaction Universign"
logger.warning(f"⚠️ {error}") logger.warning(f"{error}")
transaction.download_error = error transaction.download_error = error
await session.commit() await session.commit()
return False, error return False, error
@ -232,7 +232,7 @@ class UniversignDocumentService:
await session.commit() await session.commit()
logger.info( logger.info(
f"Document signé téléchargé: {filename} ({file_size / 1024:.1f} KB)" f"Document signé téléchargé: {filename} ({file_size / 1024:.1f} KB)"
) )
return True, None return True, None

View file

@ -372,7 +372,7 @@ class UniversignSyncService:
# Vérifier la transition # Vérifier la transition
if not is_transition_allowed(previous_local_status, new_local_status): if not is_transition_allowed(previous_local_status, new_local_status):
logger.warning( logger.warning(
f"⚠️ Transition refusée: {previous_local_status}{new_local_status}" f"Transition refusée: {previous_local_status}{new_local_status}"
) )
new_local_status = resolve_status_conflict( new_local_status = resolve_status_conflict(
previous_local_status, new_local_status previous_local_status, new_local_status
@ -392,7 +392,7 @@ class UniversignSyncService:
universign_status_raw universign_status_raw
) )
except ValueError: except ValueError:
logger.warning(f"⚠️ Statut Universign inconnu: {universign_status_raw}") logger.warning(f"Statut Universign inconnu: {universign_status_raw}")
if new_local_status == "SIGNE": if new_local_status == "SIGNE":
transaction.universign_status = ( transaction.universign_status = (
UniversignTransactionStatus.COMPLETED UniversignTransactionStatus.COMPLETED
@ -415,7 +415,7 @@ class UniversignSyncService:
if new_local_status == "SIGNE" and not transaction.signed_at: if new_local_status == "SIGNE" and not transaction.signed_at:
transaction.signed_at = datetime.now() transaction.signed_at = datetime.now()
logger.info("Date de signature mise à jour") logger.info("Date de signature mise à jour")
if new_local_status == "REFUSE" and not transaction.refused_at: if new_local_status == "REFUSE" and not transaction.refused_at:
transaction.refused_at = datetime.now() transaction.refused_at = datetime.now()
@ -450,9 +450,9 @@ class UniversignSyncService:
) )
if download_success: if download_success:
logger.info("Document signé téléchargé et stocké") logger.info("Document signé téléchargé et stocké")
else: else:
logger.warning(f"⚠️ Échec téléchargement: {download_error}") logger.warning(f"Échec téléchargement: {download_error}")
except Exception as e: except Exception as e:
logger.error( logger.error(
@ -499,7 +499,7 @@ class UniversignSyncService:
) )
logger.info( logger.info(
f"Sync terminée: {transaction.transaction_id} | " f"Sync terminée: {transaction.transaction_id} | "
f"{previous_local_status}{new_local_status}" f"{previous_local_status}{new_local_status}"
) )
@ -557,9 +557,9 @@ class UniversignSyncService:
) )
if download_success: if download_success:
logger.info("Document signé téléchargé avec succès") logger.info("Document signé téléchargé avec succès")
else: else:
logger.warning(f"⚠️ Échec téléchargement: {download_error}") logger.warning(f"Échec téléchargement: {download_error}")
except Exception as e: except Exception as e:
logger.error( logger.error(