Spaces:
Sleeping
Sleeping
from reportlab.lib.pagesizes import A4 | |
from reportlab.pdfgen import canvas | |
from reportlab.lib import colors | |
from reportlab.lib.units import cm | |
from io import BytesIO | |
from app.db.models import Invoice | |
import logging | |
import os | |
import datetime | |
# Set up logging | |
logger = logging.getLogger(__name__) | |
class InvoiceService: | |
def generate_pdf(data: Invoice) -> bytes: | |
try: | |
buffer = BytesIO() | |
pdf = canvas.Canvas(buffer, pagesize=A4) | |
page_width, page_height = A4 | |
# Constants | |
HEADER_BLUE = (0.29, 0.45, 0.68) | |
BLUE_LIGHT = (1.5, 1.5, 1) | |
WHITE = (1, 1, 1) | |
BLACK = (0, 0, 0) | |
MARGIN = 30 | |
LINE_HEIGHT = 20 | |
BOX_PADDING = 10 | |
STANDARD_FONT_SIZE = 10 | |
BOTTOM_MARGIN = 50 # Minimum margin at the bottom of the page | |
# Helper function to draw centered text | |
def draw_centered_text(pdf, text, x, y, width, font="Helvetica", size=STANDARD_FONT_SIZE): | |
text_width = pdf.stringWidth(text, font, size) | |
pdf.drawString(x + (width - text_width) / 2, y, text) | |
# Helper function to draw a bordered box | |
def draw_box(pdf, x, y, width, height, fill_color=None, stroke_color=BLACK): | |
if fill_color: | |
pdf.setFillColorRGB(*fill_color) | |
pdf.rect(x, y, width, height, fill=1, stroke=0) | |
pdf.setFillColorRGB(*stroke_color) | |
pdf.rect(x, y, width, height, stroke=1) | |
# Add this helper function after the existing helper functions | |
def draw_wrapped_text(pdf, text, x, y, width, font="Helvetica", size=STANDARD_FONT_SIZE): | |
"""Draw text wrapped to fit within a given width.""" | |
pdf.setFont(font, size) | |
words = text.split() | |
lines = [] | |
current_line = [] | |
for word in words: | |
current_line.append(word) | |
line_width = pdf.stringWidth(' '.join(current_line), font, size) | |
if line_width > width: | |
current_line.pop() # Remove last word | |
if current_line: # Only add if there are words | |
lines.append(' '.join(current_line)) | |
current_line = [word] # Start new line with the word that didn't fit | |
if current_line: # Add the last line | |
lines.append(' '.join(current_line)) | |
return lines | |
# Get the absolute path to the logo file | |
current_dir = os.path.dirname(os.path.abspath(__file__)) | |
logo_path = os.path.join(current_dir, "..", "static", "logo.png") | |
# Top section layout | |
top_margin = page_height - 120 | |
# Left side: Logo - moved far left and up | |
if os.path.exists(logo_path): | |
pdf.drawImage(logo_path, MARGIN, top_margin - 10, width=100, height=100) | |
# Right side: DEVIS and Client Box - moved far right and up | |
pdf.setFont("Helvetica-Bold", 36) | |
devis_text = "DEVIS" | |
devis_width = pdf.stringWidth(devis_text, "Helvetica-Bold", 36) | |
devis_x = page_width - devis_width - MARGIN - 10 | |
devis_y = top_margin + 50 | |
pdf.drawString(devis_x, devis_y, devis_text) | |
# Client info box - moved right under DEVIS | |
box_width = 200 | |
box_height = 80 | |
box_x = page_width - box_width - MARGIN + 10 | |
box_y = devis_y - 120 | |
# Draw client box | |
pdf.rect(box_x, box_y, box_width, box_height, stroke=1) | |
# Client Info | |
pdf.setFont("Helvetica", STANDARD_FONT_SIZE) | |
client_info = [ | |
data.client_name, | |
data.project, | |
data.address, | |
data.ville, # Add ville here | |
data.client_phone | |
] | |
available_height = box_height - BOX_PADDING * 2 | |
y_position = box_y + box_height - BOX_PADDING | |
for text in client_info: | |
if text: | |
# Calculate available width for text | |
available_width = box_width - BOX_PADDING * 2 | |
# Get wrapped lines | |
lines = draw_wrapped_text(pdf, str(text), box_x, y_position, available_width) | |
# Draw each line | |
for line in lines: | |
text_width = pdf.stringWidth(line, "Helvetica", STANDARD_FONT_SIZE) | |
x = box_x + (box_width - text_width) / 2 | |
pdf.drawString(x, y_position, line) | |
y_position -= STANDARD_FONT_SIZE + 2 # Add some spacing between lines | |
# Add extra spacing between different info pieces | |
y_position -= 2 | |
# Info boxes (Date, N° Devis, PLANCHER) - adjusted starting position | |
info_y = top_margin - 50 | |
box_label_width = 120 | |
box_value_width = 80 | |
for label, value in [ | |
("Date du devis :", data.date.strftime("%d/%m/%Y")), | |
("N° Devis :", data.invoice_number), | |
("PLANCHER :", data.frame_number or "PH RDC") | |
]: | |
draw_box(pdf, MARGIN, info_y, box_label_width, LINE_HEIGHT, fill_color=HEADER_BLUE) | |
pdf.setFillColorRGB(*WHITE) | |
pdf.drawString(MARGIN + BOX_PADDING, info_y + 6, label) | |
draw_box(pdf, MARGIN + box_label_width, info_y, box_value_width, LINE_HEIGHT, fill_color=WHITE) | |
pdf.setFillColorRGB(*BLACK) | |
draw_centered_text(pdf, str(value), MARGIN + box_label_width, info_y + 6, box_value_width) | |
info_y -= 25 | |
# Table headers | |
table_y = info_y - 30 | |
headers = [ | |
("Description", 150), | |
("Unité", 50), | |
("NBRE", 50), | |
("ML/Qté", 60), | |
("P.U", 60), | |
("Total HT", 170) | |
] | |
total_width = sum(width for _, width in headers) | |
table_x = (page_width - total_width) / 2 # Center table | |
draw_box(pdf, table_x, table_y, total_width, LINE_HEIGHT, fill_color=HEADER_BLUE) | |
pdf.setFillColorRGB(*WHITE) | |
current_x = table_x | |
for title, width in headers: | |
draw_box(pdf, current_x, table_y, width, LINE_HEIGHT) | |
pdf.setFillColorRGB(*WHITE) | |
draw_centered_text(pdf, title, current_x, table_y + 6, width) | |
current_x += width | |
# Draw sections and items | |
current_y = table_y - LINE_HEIGHT - 10 | |
def draw_section_header2(title): | |
nonlocal current_y | |
draw_box(pdf, table_x, current_y, total_width, LINE_HEIGHT, fill_color=WHITE) | |
pdf.setFont("Helvetica-Bold", 9) | |
pdf.setFillColorRGB(*BLACK) | |
pdf.drawString(table_x + BOX_PADDING, current_y + 6, title) | |
current_y -= LINE_HEIGHT | |
pdf.setFont("Helvetica", STANDARD_FONT_SIZE) | |
def format_currency(value): | |
return "{:,.2f}".format(value).replace(",", " ") | |
def draw_item_row(item, indent=False): | |
nonlocal current_y | |
pdf.setFillColorRGB(*BLACK) | |
current_x = table_x | |
draw_box(pdf, current_x, current_y, total_width, LINE_HEIGHT, fill_color=WHITE) | |
cells = [ | |
(" " + item.description if indent else item.description, 150), | |
(item.unit, 50), | |
(str(item.quantity), 50), | |
(f"{item.length:.2f}", 60), | |
(f"{format_currency(item.unit_price)}", 60), | |
(f"{format_currency(item.total_price)}", 170) | |
] | |
for i, (value, width) in enumerate(cells): | |
draw_box(pdf, current_x, current_y, width, LINE_HEIGHT) | |
if i == len(cells) - 1: | |
pdf.setFont("Helvetica-Bold", STANDARD_FONT_SIZE) | |
else: | |
pdf.setFont("Helvetica", STANDARD_FONT_SIZE) | |
if isinstance(value, str) and value.startswith(" "): | |
pdf.drawString(current_x + 20, current_y + 6, value.strip()) | |
else: | |
draw_centered_text(pdf, str(value), current_x, current_y + 6, width) | |
current_x += width | |
pdf.setFont("Helvetica", STANDARD_FONT_SIZE) | |
current_y -= LINE_HEIGHT | |
# Check if we need a new page | |
if current_y < BOTTOM_MARGIN: | |
pdf.showPage() | |
current_y = page_height - MARGIN | |
# Redraw headers on the new page | |
draw_box(pdf, table_x, current_y, total_width, LINE_HEIGHT, fill_color=HEADER_BLUE) | |
pdf.setFillColorRGB(*WHITE) | |
current_x = table_x | |
for title, width in headers: | |
draw_box(pdf, current_x, current_y, width, LINE_HEIGHT) | |
pdf.setFillColorRGB(*WHITE) | |
draw_centered_text(pdf, title, current_x, current_y + 6, width) | |
current_x += width | |
current_y -= LINE_HEIGHT | |
# Draw sections | |
sections = [ | |
("POUTRELLES", "PCP"), | |
("HOURDIS", "HOURDIS"), | |
("PANNEAU TREILLIS SOUDES", "PTS"), | |
("AGGLOS", "AGGLOS") | |
] | |
for section_title, keyword in sections: | |
# Get items for this section | |
items = [i for i in data.items if keyword in i.description] | |
# Only draw section if it has items | |
if items: | |
draw_section_header2(section_title) | |
for item in items: | |
draw_item_row(item, indent=(keyword != "lfflflflf")) | |
# NB box with text | |
nb_box_width = 200 | |
nb_box_height = 80 | |
pdf.setFillColorRGB(*BLACK) | |
pdf.rect(20, current_y - nb_box_height, nb_box_width, nb_box_height, stroke=1) | |
pdf.setFont("Helvetica-Bold", STANDARD_FONT_SIZE) | |
pdf.drawString(30, current_y - nb_box_height + 60, "NB:") | |
# Add the new text | |
pdf.setFont("Helvetica", STANDARD_FONT_SIZE) | |
nb_text = "Toute modification apportée aux plans BA initialement fournis, entraine automatiquement la modification de ce devis." | |
words = nb_text.split() | |
lines = [] | |
current_line = [] | |
for word in words: | |
current_line.append(word) | |
if pdf.stringWidth(' '.join(current_line), "Helvetica", STANDARD_FONT_SIZE) > nb_box_width - 20: | |
current_line.pop() | |
lines.append(' '.join(current_line)) | |
current_line = [word] | |
if current_line: | |
lines.append(' '.join(current_line)) | |
for i, line in enumerate(lines): | |
pdf.drawString(30, current_y - nb_box_height + 45 - (i * 10), line) | |
# ADD text after the NB box | |
pdf.setFont("Helvetica-Bold", 9) | |
pdf.drawString(30, current_y - nb_box_height - 15, "Validité du devis : 1 mois") | |
# Totals section | |
pdf.setFont("Helvetica-Bold", 12) | |
current_y -= 20 | |
totals_table_width = 300 | |
row_height = 20 | |
# Define the widths for the left and right boxes | |
left_box_width = 130 # Width of the left box | |
right_box_width = totals_table_width - left_box_width # Remaining width for the right box | |
for i, (label1, label2, value) in enumerate([ | |
("Total", "H.T", f"{format_currency(data.total_ht)} DH"), | |
("TVA", "20%", f"{format_currency(data.tax)} DH"), | |
("Total", "TTC", f"{format_currency(data.total_ttc)} DH") | |
]): | |
y = current_y - (i * row_height) | |
totals_x = (page_width - totals_table_width) - 27 | |
# Draw the left box | |
draw_box(pdf, totals_x, y, left_box_width, row_height) | |
# Draw the right box | |
draw_box(pdf, totals_x + left_box_width, y, right_box_width, row_height) | |
# Draw left-aligned labels in the left box | |
pdf.drawString(totals_x + 10, y + 6, f"{label1} {label2}") | |
# Calculate the center x position for the right box | |
center_x = totals_x + left_box_width + (right_box_width / 2) | |
# Draw centered value in the right box | |
pdf.drawCentredString(center_x, y + 6, value) | |
# Footer | |
## add the firt line text | |
pdf.setFont("Helvetica", STANDARD_FONT_SIZE - 3) | |
footer_text = "CARRIPREFA" | |
pdf.drawCentredString(page_width / 2, 40, footer_text) | |
pdf.setFont("Helvetica", STANDARD_FONT_SIZE - 3) | |
footer_text = "Douar Ait Laarassi Tidili, Cercle El Kelâa, Route de Safi, Km 14-40000 Marrakech" | |
pdf.drawCentredString(page_width / 2 + 20, 30, footer_text) | |
# Add commercial info to footer | |
commercial_info = dict( | |
salah="06 62 29 99 78", | |
khaled="06 66 24 80 94", | |
ismail="06 66 24 50 15", | |
jamal="06 70 08 36 50" | |
) | |
if data.commercial and data.commercial.lower() != 'divers': | |
commercial_text = f"Commercial: {data.commercial.upper()}" | |
commercial_phone = f"Tél: {commercial_info.get(data.commercial.lower(), '')}" | |
pdf.drawString(MARGIN + 10, 30, commercial_text) | |
pdf.drawString(MARGIN + 10, 20, commercial_phone) | |
footer_contact = "Tél: 05 24 01 33 34 Fax : 05 24 01 33 29 E-mail : [email protected]" | |
pdf.drawCentredString(page_width / 2 + 10, 20, footer_contact) | |
pdf.drawString(page_width - 100, 30, f"Date: {datetime.datetime.now().strftime('%d/%m/%Y %H:%M')}") | |
pdf.drawString(page_width - 100, 20, f"Page {pdf.getPageNumber()}/{pdf.getPageNumber()}") | |
else: | |
footer_contact = "Tél: 05 24 01 33 34 Fax : 05 24 01 33 29 E-mail : [email protected]" | |
pdf.drawCentredString(page_width / 2 + 10, 20, footer_contact) | |
pdf.drawString(page_width - 100, 30, f"Date: {datetime.datetime.now().strftime('%d/%m/%Y %H:%M')}") | |
pdf.drawString(page_width - 100, 20, f"Page {pdf.getPageNumber()}/{pdf.getPageNumber()}") | |
pdf.save() | |
buffer.seek(0) | |
return buffer.getvalue() | |
except Exception as e: | |
logger.error(f"Error in PDF generation: {str(e)}", exc_info=True) | |
raise |