mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-13 07:49:35 -05:00
974f5cdd50
Add quote_items.line_kind (item | expense | good) plus display_name, category, line_date, and sku so one table still drives totals, PDFs, and acceptance stock logic. - Migration 147: new columns with backfill line_kind=item - Quote create/edit: three sections; stock and warehouse only when a line item is sourced from inventory; shared JS partial - POST parsing, positions, and duplicate-quote copying for all fields - API v1 quote items accept the new fields with defaults for old clients - View, client portal, and PDF/fallback rendering use display_name and line metadata where relevant - Integration tests: stock POST shape; expense and good line creation Docs: extend INVENTORY_MANAGEMENT_PLAN QuoteItem and migration notes.
679 lines
24 KiB
Python
679 lines
24 KiB
Python
"""
|
|
Fallback PDF Generation utility for invoices using ReportLab
|
|
This is used when WeasyPrint is not available due to system dependencies
|
|
"""
|
|
|
|
import os
|
|
from datetime import datetime
|
|
|
|
from flask import current_app
|
|
from flask_babel import gettext as _
|
|
from reportlab.lib import colors
|
|
from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT
|
|
from reportlab.lib.pagesizes import A4
|
|
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
|
|
from reportlab.lib.units import cm
|
|
from reportlab.pdfgen import canvas
|
|
from reportlab.platypus import Image, Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
|
|
|
|
from app.models import Settings
|
|
|
|
|
|
class InvoicePDFGeneratorFallback:
|
|
"""Generate PDF invoices with company branding using ReportLab"""
|
|
|
|
def __init__(self, invoice, settings=None):
|
|
self.invoice = invoice
|
|
self.settings = settings or Settings.get_settings()
|
|
self.styles = getSampleStyleSheet()
|
|
self._setup_custom_styles()
|
|
|
|
def _setup_custom_styles(self):
|
|
"""Setup custom paragraph styles"""
|
|
self.styles.add(
|
|
ParagraphStyle(
|
|
name="CompanyName",
|
|
parent=self.styles["Heading1"],
|
|
fontSize=18,
|
|
spaceAfter=12,
|
|
textColor=colors.HexColor("#007bff"),
|
|
)
|
|
)
|
|
|
|
self.styles.add(
|
|
ParagraphStyle(
|
|
name="InvoiceTitle",
|
|
parent=self.styles["Heading1"],
|
|
fontSize=24,
|
|
spaceAfter=20,
|
|
textColor=colors.HexColor("#007bff"),
|
|
alignment=TA_RIGHT,
|
|
)
|
|
)
|
|
|
|
self.styles.add(
|
|
ParagraphStyle(
|
|
name="SectionHeader",
|
|
parent=self.styles["Heading2"],
|
|
fontSize=14,
|
|
spaceAfter=8,
|
|
textColor=colors.HexColor("#007bff"),
|
|
)
|
|
)
|
|
|
|
self.styles.add(ParagraphStyle(name="NormalText", parent=self.styles["Normal"], fontSize=10, spaceAfter=6))
|
|
|
|
def generate_pdf(self):
|
|
"""Generate PDF content and return as bytes"""
|
|
# Create a temporary file to store the PDF
|
|
import io
|
|
import tempfile
|
|
|
|
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp_file:
|
|
tmp_path = tmp_file.name
|
|
|
|
try:
|
|
# Generate the PDF
|
|
doc = SimpleDocTemplate(
|
|
tmp_path, pagesize=A4, rightMargin=2 * cm, leftMargin=2 * cm, topMargin=2 * cm, bottomMargin=2 * cm
|
|
)
|
|
|
|
# Build the story (content)
|
|
story = self._build_story()
|
|
|
|
# Build the PDF with page numbers
|
|
doc.build(story, onFirstPage=self._add_page_number, onLaterPages=self._add_page_number)
|
|
|
|
# Read the generated PDF
|
|
with open(tmp_path, "rb") as f:
|
|
pdf_bytes = f.read()
|
|
|
|
return pdf_bytes
|
|
|
|
finally:
|
|
# Clean up temporary file
|
|
if os.path.exists(tmp_path):
|
|
os.unlink(tmp_path)
|
|
|
|
def _build_story(self):
|
|
"""Build the PDF content story"""
|
|
story = []
|
|
|
|
# Header section
|
|
story.extend(self._build_header())
|
|
story.append(Spacer(1, 20))
|
|
|
|
# Client and project information
|
|
story.extend(self._build_client_section())
|
|
story.append(Spacer(1, 20))
|
|
|
|
# Invoice items table
|
|
story.extend(self._build_items_table())
|
|
story.append(Spacer(1, 20))
|
|
|
|
# Additional information
|
|
story.extend(self._build_additional_info())
|
|
story.append(Spacer(1, 20))
|
|
|
|
# Footer
|
|
story.extend(self._build_footer())
|
|
|
|
return story
|
|
|
|
def _build_header(self):
|
|
"""Build the header section with company info and invoice details"""
|
|
story = []
|
|
|
|
# Company information (left side)
|
|
company_info = []
|
|
company_info.append(Paragraph(self.settings.company_name, self.styles["CompanyName"]))
|
|
|
|
if self.settings.company_address:
|
|
company_info.append(Paragraph(self.settings.company_address, self.styles["NormalText"]))
|
|
|
|
if self.settings.company_email:
|
|
company_info.append(Paragraph(f"Email: {self.settings.company_email}", self.styles["NormalText"]))
|
|
|
|
if self.settings.company_phone:
|
|
company_info.append(Paragraph(f"Phone: {self.settings.company_phone}", self.styles["NormalText"]))
|
|
|
|
if self.settings.company_website:
|
|
company_info.append(Paragraph(f"Website: {self.settings.company_website}", self.styles["NormalText"]))
|
|
|
|
if self.settings.company_tax_id:
|
|
company_info.append(Paragraph(f"Tax ID: {self.settings.company_tax_id}", self.styles["NormalText"]))
|
|
|
|
if getattr(self.settings, "invoices_peppol_compliant", False):
|
|
sid = (getattr(self.settings, "peppol_sender_endpoint_id", None) or "").strip()
|
|
ssc = (getattr(self.settings, "peppol_sender_scheme_id", None) or "").strip()
|
|
if sid and ssc:
|
|
company_info.append(Paragraph(f"PEPPOL Endpoint: {ssc}:{sid}", self.styles["NormalText"]))
|
|
|
|
# Invoice information (right side)
|
|
invoice_info = []
|
|
|
|
# Add logo if available (top right) using Image flowable
|
|
if self.settings.has_logo():
|
|
logo_path = self.settings.get_logo_path()
|
|
if logo_path and os.path.exists(logo_path):
|
|
try:
|
|
img = Image(logo_path, width=4 * cm, height=2 * cm, kind="proportional")
|
|
invoice_info.append(img)
|
|
invoice_info.append(Spacer(1, 6))
|
|
except Exception:
|
|
# Fallback to text if image fails
|
|
invoice_info.append(Paragraph("[Company Logo]", self.styles["NormalText"]))
|
|
|
|
invoice_info.append(Paragraph(_("INVOICE"), self.styles["InvoiceTitle"]))
|
|
invoice_info.append(
|
|
Paragraph(_("Invoice #: %(num)s", num=self.invoice.invoice_number), self.styles["NormalText"])
|
|
)
|
|
try:
|
|
# Use DD.MM.YYYY format for invoices and quotes
|
|
issue_label = _("Issue Date: %(date)s", date=self.invoice.issue_date.strftime("%d.%m.%Y"))
|
|
due_label = _("Due Date: %(date)s", date=self.invoice.due_date.strftime("%d.%m.%Y"))
|
|
except Exception:
|
|
issue_label = _("Issue Date: %(date)s", date=self.invoice.issue_date.strftime("%d.%m.%Y"))
|
|
due_label = _("Due Date: %(date)s", date=self.invoice.due_date.strftime("%d.%m.%Y"))
|
|
invoice_info.append(Paragraph(issue_label, self.styles["NormalText"]))
|
|
invoice_info.append(Paragraph(due_label, self.styles["NormalText"]))
|
|
invoice_info.append(Paragraph(f"Status: {self.invoice.status.title()}", self.styles["NormalText"]))
|
|
|
|
# Create a table to layout company info and invoice info side by side
|
|
header_data = [[company_info, invoice_info]]
|
|
header_table = Table(header_data, colWidths=[9 * cm, 6 * cm])
|
|
header_table.setStyle(
|
|
TableStyle(
|
|
[
|
|
("ALIGN", (0, 0), (0, 0), "LEFT"),
|
|
("ALIGN", (1, 0), (1, 0), "RIGHT"),
|
|
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
|
]
|
|
)
|
|
)
|
|
|
|
story.append(header_table)
|
|
return story
|
|
|
|
def _build_client_section(self):
|
|
"""Build the client and project information section"""
|
|
story = []
|
|
|
|
# Client information
|
|
story.append(Paragraph("Bill To:", self.styles["SectionHeader"]))
|
|
story.append(Paragraph(self.invoice.client_name, self.styles["NormalText"]))
|
|
|
|
if self.invoice.client_email:
|
|
story.append(Paragraph(self.invoice.client_email, self.styles["NormalText"]))
|
|
|
|
if self.invoice.client_address:
|
|
story.append(Paragraph(self.invoice.client_address, self.styles["NormalText"]))
|
|
|
|
if getattr(self.settings, "invoices_peppol_compliant", False) and getattr(self.invoice, "client", None):
|
|
client = self.invoice.client
|
|
bvat = (client.get_custom_field("vat_id", "") or client.get_custom_field("tax_id", "") or "").strip()
|
|
if bvat:
|
|
story.append(Paragraph(f"VAT ID: {bvat}", self.styles["NormalText"]))
|
|
beid = (client.get_custom_field("peppol_endpoint_id", "") or "").strip()
|
|
bsc = (client.get_custom_field("peppol_scheme_id", "") or "").strip()
|
|
if beid and bsc:
|
|
story.append(Paragraph(f"PEPPOL Endpoint: {bsc}:{beid}", self.styles["NormalText"]))
|
|
|
|
story.append(Spacer(1, 12))
|
|
|
|
# Project information
|
|
story.append(Paragraph("Project:", self.styles["SectionHeader"]))
|
|
story.append(Paragraph(self.invoice.project.name, self.styles["NormalText"]))
|
|
|
|
if self.invoice.project.description:
|
|
story.append(Paragraph(self.invoice.project.description, self.styles["NormalText"]))
|
|
|
|
return story
|
|
|
|
def _build_items_table(self):
|
|
"""Build the invoice items table including extra goods"""
|
|
story = []
|
|
|
|
story.append(Paragraph(_("Invoice Items"), self.styles["SectionHeader"]))
|
|
|
|
# Table headers
|
|
headers = [_("Description"), _("Quantity (Hours)"), _("Unit Price"), _("Total Amount")]
|
|
|
|
# Table data
|
|
data = [headers]
|
|
|
|
# Add regular invoice items
|
|
for item in self.invoice.items:
|
|
row = [
|
|
item.description,
|
|
f"{item.quantity:.2f}",
|
|
self._format_currency(item.unit_price),
|
|
self._format_currency(item.total_amount),
|
|
]
|
|
data.append(row)
|
|
|
|
# Add extra goods
|
|
for good in self.invoice.extra_goods:
|
|
# Build description with additional details
|
|
description_parts = [good.name]
|
|
if good.description:
|
|
description_parts.append(f"\n{good.description}")
|
|
if good.sku:
|
|
description_parts.append(f"\nSKU: {good.sku}")
|
|
if good.category:
|
|
description_parts.append(f"\nCategory: {good.category.title()}")
|
|
|
|
description = "\n".join(description_parts)
|
|
|
|
row = [
|
|
description,
|
|
f"{good.quantity:.2f}",
|
|
self._format_currency(good.unit_price),
|
|
self._format_currency(good.total_amount),
|
|
]
|
|
data.append(row)
|
|
|
|
# Add expenses
|
|
expenses = (
|
|
self.invoice.expenses.all()
|
|
if hasattr(self.invoice.expenses, "all")
|
|
else list(self.invoice.expenses) if getattr(self.invoice, "expenses", None) else []
|
|
)
|
|
for expense in expenses:
|
|
description_parts = [expense.title]
|
|
if expense.description:
|
|
description_parts.append(f"\n{expense.description}")
|
|
if expense.category:
|
|
description_parts.append(f"\n{_('Expense')}: {expense.category.title()}")
|
|
if expense.vendor:
|
|
description_parts.append(f"\n{_('Vendor')}: {expense.vendor}")
|
|
if expense.expense_date:
|
|
description_parts.append(f"\n{_('Date')}: {expense.expense_date.strftime('%d.%m.%Y')}")
|
|
|
|
description = "\n".join(description_parts)
|
|
total = getattr(expense, "total_amount", expense.amount)
|
|
|
|
row = [
|
|
description,
|
|
"1",
|
|
self._format_currency(total),
|
|
self._format_currency(total),
|
|
]
|
|
data.append(row)
|
|
|
|
# Add totals
|
|
data.append(["", "", _("Subtotal:"), self._format_currency(self.invoice.subtotal)])
|
|
|
|
if self.invoice.tax_rate > 0:
|
|
data.append(
|
|
[
|
|
"",
|
|
"",
|
|
_("Tax (%(rate).2f%%):", rate=self.invoice.tax_rate),
|
|
self._format_currency(self.invoice.tax_amount),
|
|
]
|
|
)
|
|
|
|
data.append(["", "", _("Total Amount:"), self._format_currency(self.invoice.total_amount)])
|
|
|
|
# Create table
|
|
table = Table(data, colWidths=[9 * cm, 3 * cm, 3 * cm, 3 * cm], repeatRows=1)
|
|
table.setStyle(
|
|
TableStyle(
|
|
[
|
|
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#f8fafc")),
|
|
("TEXTCOLOR", (0, 0), (-1, 0), colors.HexColor("#475569")),
|
|
("ALIGN", (0, 0), (-1, -1), "LEFT"),
|
|
("ALIGN", (1, 1), (-1, -1), "RIGHT"),
|
|
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
|
|
("FONTSIZE", (0, 0), (-1, 0), 12),
|
|
("BOTTOMPADDING", (0, 0), (-1, 0), 12),
|
|
("BACKGROUND", (0, -3), (-1, -1), colors.HexColor("#eef2ff")),
|
|
("FONTNAME", (0, -3), (-1, -1), "Helvetica-Bold"),
|
|
("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#e2e8f0")),
|
|
("BOX", (0, 0), (-1, -1), 0.8, colors.HexColor("#e2e8f0")),
|
|
]
|
|
)
|
|
)
|
|
|
|
story.append(table)
|
|
return story
|
|
|
|
def _format_currency(self, value):
|
|
"""Format numeric currency with thousands separators and 2 decimals."""
|
|
try:
|
|
return f"{float(value):,.2f} {self.settings.currency}"
|
|
except Exception:
|
|
return f"{value} {self.settings.currency}"
|
|
|
|
def _add_page_number(self, canv, doc):
|
|
"""Add page number at the bottom-right of each page."""
|
|
page_num = canv.getPageNumber()
|
|
text = f"Page {page_num}"
|
|
|
|
# Get page dimensions for boundary checking
|
|
page_width = doc.pagesize[0]
|
|
page_height = doc.pagesize[1]
|
|
|
|
canv.saveState()
|
|
canv.setFont("Helvetica", 9)
|
|
try:
|
|
canv.setFillColor(colors.HexColor("#666666"))
|
|
except Exception:
|
|
pass
|
|
|
|
# Ensure page number is within page boundaries
|
|
x = min(doc.leftMargin + doc.width, page_width - 10)
|
|
y = max(doc.bottomMargin - 0.5 * cm, 10)
|
|
canv.drawRightString(x, y, text)
|
|
|
|
canv.restoreState()
|
|
|
|
def _build_additional_info(self):
|
|
"""Build additional information section"""
|
|
story = []
|
|
|
|
if self.invoice.notes:
|
|
story.append(Paragraph(_("Notes:"), self.styles["SectionHeader"]))
|
|
story.append(Paragraph(self.invoice.notes, self.styles["NormalText"]))
|
|
story.append(Spacer(1, 12))
|
|
|
|
if self.invoice.terms:
|
|
story.append(Paragraph(_("Terms:"), self.styles["SectionHeader"]))
|
|
story.append(Paragraph(self.invoice.terms, self.styles["NormalText"]))
|
|
story.append(Spacer(1, 12))
|
|
|
|
return story
|
|
|
|
def _build_footer(self):
|
|
"""Build the footer section"""
|
|
story = []
|
|
|
|
# Payment information
|
|
if self.settings.company_bank_info:
|
|
story.append(Paragraph(_("Payment Information:"), self.styles["SectionHeader"]))
|
|
story.append(Paragraph(self.settings.company_bank_info, self.styles["NormalText"]))
|
|
story.append(Spacer(1, 12))
|
|
|
|
# Terms and conditions
|
|
story.append(Paragraph(_("Terms & Conditions:"), self.styles["SectionHeader"]))
|
|
story.append(Paragraph(self.settings.invoice_terms, self.styles["NormalText"]))
|
|
|
|
return story
|
|
|
|
|
|
def format_quote_item_description_for_pdf(item) -> str:
|
|
"""Label for a quote line including expense/goods metadata (issue #585)."""
|
|
dn = getattr(item, "display_name", None)
|
|
desc = (getattr(item, "description", None) or "") or ""
|
|
lk = (getattr(item, "line_kind", None) or "item") or "item"
|
|
if dn:
|
|
text = str(dn)
|
|
if desc and str(desc) not in (str(dn), "-"):
|
|
text = f"{text} — {desc}"
|
|
else:
|
|
text = str(desc)
|
|
if lk == "expense" and getattr(item, "category", None):
|
|
text = f"{text} ({item.category})"
|
|
if lk == "good" and getattr(item, "sku", None):
|
|
text = f"{text} [SKU: {item.sku}]"
|
|
return text
|
|
|
|
|
|
class QuotePDFGeneratorFallback:
|
|
"""Generate PDF quotes with company branding using ReportLab"""
|
|
|
|
def __init__(self, quote, settings=None):
|
|
self.quote = quote
|
|
self.settings = settings or Settings.get_settings()
|
|
self.styles = getSampleStyleSheet()
|
|
self._setup_custom_styles()
|
|
|
|
def _setup_custom_styles(self):
|
|
"""Setup custom paragraph styles"""
|
|
self.styles.add(
|
|
ParagraphStyle(
|
|
name="CompanyName",
|
|
parent=self.styles["Heading1"],
|
|
fontSize=18,
|
|
spaceAfter=12,
|
|
textColor=colors.HexColor("#007bff"),
|
|
)
|
|
)
|
|
|
|
self.styles.add(
|
|
ParagraphStyle(
|
|
name="QuoteTitle",
|
|
parent=self.styles["Heading1"],
|
|
fontSize=24,
|
|
spaceAfter=20,
|
|
textColor=colors.HexColor("#007bff"),
|
|
alignment=TA_RIGHT,
|
|
)
|
|
)
|
|
|
|
self.styles.add(
|
|
ParagraphStyle(
|
|
name="SectionHeader",
|
|
parent=self.styles["Heading2"],
|
|
fontSize=14,
|
|
spaceAfter=8,
|
|
textColor=colors.HexColor("#007bff"),
|
|
)
|
|
)
|
|
|
|
self.styles.add(ParagraphStyle(name="NormalText", parent=self.styles["Normal"], fontSize=10, spaceAfter=6))
|
|
|
|
def generate_pdf(self):
|
|
"""Generate PDF content and return as bytes"""
|
|
import io
|
|
import tempfile
|
|
|
|
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp_file:
|
|
tmp_path = tmp_file.name
|
|
|
|
try:
|
|
doc = SimpleDocTemplate(
|
|
tmp_path, pagesize=A4, rightMargin=2 * cm, leftMargin=2 * cm, topMargin=2 * cm, bottomMargin=2 * cm
|
|
)
|
|
|
|
story = self._build_story()
|
|
doc.build(story, onFirstPage=self._add_page_number, onLaterPages=self._add_page_number)
|
|
|
|
with open(tmp_path, "rb") as f:
|
|
pdf_bytes = f.read()
|
|
|
|
return pdf_bytes
|
|
|
|
finally:
|
|
if os.path.exists(tmp_path):
|
|
os.unlink(tmp_path)
|
|
|
|
def _build_story(self):
|
|
"""Build the PDF content story"""
|
|
story = []
|
|
|
|
# Header
|
|
story.extend(self._build_header())
|
|
story.append(Spacer(1, 20))
|
|
|
|
# Client section
|
|
story.extend(self._build_client_section())
|
|
story.append(Spacer(1, 20))
|
|
|
|
# Quote items
|
|
story.extend(self._build_items_table())
|
|
story.append(Spacer(1, 20))
|
|
|
|
# Totals
|
|
story.extend(self._build_totals())
|
|
story.append(Spacer(1, 20))
|
|
|
|
# Additional info
|
|
story.extend(self._build_additional_info())
|
|
|
|
return story
|
|
|
|
def _build_header(self):
|
|
"""Build header section"""
|
|
story = []
|
|
|
|
# Company name and info
|
|
if self.settings.company_name:
|
|
story.append(Paragraph(self.settings.company_name, self.styles["CompanyName"]))
|
|
|
|
if self.settings.company_address:
|
|
story.append(Paragraph(self.settings.company_address.replace("\n", "<br/>"), self.styles["NormalText"]))
|
|
|
|
story.append(Spacer(1, 12))
|
|
|
|
# Quote title and number
|
|
quote_title = f"{_('QUOTE')} {self.quote.quote_number}"
|
|
story.append(Paragraph(quote_title, self.styles["QuoteTitle"]))
|
|
|
|
return story
|
|
|
|
def _build_client_section(self):
|
|
"""Build client information section"""
|
|
story = []
|
|
|
|
if self.quote.client:
|
|
story.append(Paragraph(_("Quote For:"), self.styles["SectionHeader"]))
|
|
story.append(Paragraph(self.quote.client.name, self.styles["NormalText"]))
|
|
if self.quote.client.address:
|
|
story.append(Paragraph(self.quote.client.address.replace("\n", "<br/>"), self.styles["NormalText"]))
|
|
if self.quote.client.email:
|
|
story.append(Paragraph(f"Email: {self.quote.client.email}", self.styles["NormalText"]))
|
|
|
|
story.append(Spacer(1, 12))
|
|
|
|
# Quote details
|
|
story.append(Paragraph(_("Quote Details:"), self.styles["SectionHeader"]))
|
|
story.append(Paragraph(f"{_('Title')}: {self.quote.title}", self.styles["NormalText"]))
|
|
story.append(
|
|
Paragraph(
|
|
f"{_('Date')}: {self.quote.created_at.strftime('%Y-%m-%d') if self.quote.created_at else 'N/A'}",
|
|
self.styles["NormalText"],
|
|
)
|
|
)
|
|
if self.quote.valid_until:
|
|
story.append(
|
|
Paragraph(
|
|
f"{_('Valid Until')}: {self.quote.valid_until.strftime('%Y-%m-%d')}", self.styles["NormalText"]
|
|
)
|
|
)
|
|
|
|
return story
|
|
|
|
def _build_items_table(self):
|
|
"""Build quote items table"""
|
|
story = []
|
|
|
|
story.append(Paragraph(_("Items:"), self.styles["SectionHeader"]))
|
|
|
|
# Table data
|
|
data = [[_("Description"), _("Quantity"), _("Unit Price"), _("Total")]]
|
|
|
|
for item in self.quote.items:
|
|
data.append(
|
|
[
|
|
format_quote_item_description_for_pdf(item),
|
|
str(item.quantity),
|
|
self._format_currency(item.unit_price),
|
|
self._format_currency(item.total_amount),
|
|
]
|
|
)
|
|
|
|
table = Table(data, colWidths=[8 * cm, 2 * cm, 3 * cm, 3 * cm])
|
|
table.setStyle(
|
|
TableStyle(
|
|
[
|
|
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#007bff")),
|
|
("TEXTCOLOR", (0, 0), (-1, 0), colors.whitesmoke),
|
|
("ALIGN", (0, 0), (-1, -1), "LEFT"),
|
|
("ALIGN", (1, 0), (-1, -1), "RIGHT"),
|
|
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
|
|
("FONTSIZE", (0, 0), (-1, 0), 10),
|
|
("BOTTOMPADDING", (0, 0), (-1, 0), 12),
|
|
("BACKGROUND", (0, 1), (-1, -1), colors.beige),
|
|
("GRID", (0, 0), (-1, -1), 1, colors.black),
|
|
]
|
|
)
|
|
)
|
|
|
|
story.append(table)
|
|
|
|
return story
|
|
|
|
def _build_totals(self):
|
|
"""Build totals section"""
|
|
story = []
|
|
|
|
# Calculate totals
|
|
self.quote.calculate_totals()
|
|
|
|
totals_data = [
|
|
[_("Subtotal:"), self._format_currency(self.quote.subtotal)],
|
|
]
|
|
|
|
if self.quote.tax_rate > 0:
|
|
totals_data.append([f"{_('Tax')} ({self.quote.tax_rate}%):", self._format_currency(self.quote.tax_amount)])
|
|
|
|
totals_data.append([_("Total:"), self._format_currency(self.quote.total_amount)])
|
|
|
|
totals_table = Table(totals_data, colWidths=[6 * cm, 4 * cm])
|
|
totals_table.setStyle(
|
|
TableStyle(
|
|
[
|
|
("ALIGN", (0, 0), (-1, -1), "RIGHT"),
|
|
("FONTNAME", (-1, -1), (-1, -1), "Helvetica-Bold"),
|
|
("FONTSIZE", (-1, -1), (-1, -1), 12),
|
|
("TOPPADDING", (0, 0), (-1, -1), 6),
|
|
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
|
|
]
|
|
)
|
|
)
|
|
|
|
story.append(Spacer(1, 12))
|
|
story.append(totals_table)
|
|
|
|
return story
|
|
|
|
def _build_additional_info(self):
|
|
"""Build additional information section"""
|
|
story = []
|
|
|
|
if self.quote.description:
|
|
story.append(Paragraph(_("Description:"), self.styles["SectionHeader"]))
|
|
story.append(Paragraph(self.quote.description.replace("\n", "<br/>"), self.styles["NormalText"]))
|
|
story.append(Spacer(1, 12))
|
|
|
|
if self.quote.terms:
|
|
story.append(Paragraph(_("Terms & Conditions:"), self.styles["SectionHeader"]))
|
|
story.append(Paragraph(self.quote.terms.replace("\n", "<br/>"), self.styles["NormalText"]))
|
|
|
|
return story
|
|
|
|
def _format_currency(self, value):
|
|
"""Format currency value"""
|
|
currency = self.quote.currency_code if self.quote.currency_code else "EUR"
|
|
return f"{currency} {float(value):.2f}"
|
|
|
|
def _add_page_number(self, canv, doc):
|
|
"""Add page number to PDF"""
|
|
page_num = canv.getPageNumber()
|
|
text = f"{_('Page')} {page_num}"
|
|
|
|
# Get page dimensions for boundary checking
|
|
page_width = doc.pagesize[0]
|
|
page_height = doc.pagesize[1]
|
|
|
|
canv.saveState()
|
|
canv.setFont("Helvetica", 9)
|
|
# Ensure page number is within page boundaries
|
|
x = min(page_width - 2 * cm, page_width - 10)
|
|
y = max(1 * cm, 10)
|
|
canv.drawRightString(x, y, text)
|
|
|
|
canv.restoreState()
|