VortexCommerceBackend / app /services /pdf_service.py
hisham-cmd's picture
Full Sync: Explicit Commit Operations (Pure & Optimized)
b2be963 verified
Raw
History Blame Contribute Delete
14.2 kB
import io
import os
from datetime import datetime, timedelta
from reportlab.lib.pagesizes import letter
from reportlab.lib import colors
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, Image
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import inch
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
import arabic_reshaper
from bidi.algorithm import get_display
from app.models.order import Order, OrderStatus
from app.models.settings import StoreSettings
from sqlalchemy.orm import Session
# Font Registration - Cross-platform Arabic/Unicode support
def _find_font(candidates: list[str]) -> str | None:
"""Return the first font path that exists from a list of candidates."""
for path in candidates:
if os.path.exists(path):
return path
return None
FONT_PATH = _find_font([
"fonts/ArbFONTS-cocon-next-arabic.ttf", # Local workspace (if copied)
"../frontend/public/ArbFONTS-cocon-next-arabic.ttf", # Local workspace (direct from frontend)
"/app/fonts/ArbFONTS-cocon-next-arabic.ttf", # Deployed HF Spaces
"C:\\Windows\\Fonts\\arial.ttf", # Fallback Windows
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", # Fallback Linux
"/usr/share/fonts/TTF/DejaVuSans.ttf",
"/usr/share/fonts/dejavu-sans-fonts/DejaVuSans.ttf",
])
FONT_BOLD_PATH = _find_font([
"fonts/ArbFONTS-cocon-next-arabic.ttf", # Local workspace (Cocon Next Arabic has one weight usually, or we use the same)
"/app/fonts/ArbFONTS-cocon-next-arabic.ttf", # Deployed HF Spaces
"C:\\Windows\\Fonts\\arialbd.ttf", # Fallback Windows
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", # Fallback Linux
"/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
"/usr/share/fonts/dejavu-sans-fonts/DejaVuSans-Bold.ttf",
])
if FONT_PATH:
pdfmetrics.registerFont(TTFont('Arial', FONT_PATH))
if FONT_BOLD_PATH:
pdfmetrics.registerFont(TTFont('Arial-Bold', FONT_BOLD_PATH))
# Dictionary of translations
STRINGS = {
"ar": {
"invoice": "فاتورة ضريبية",
"order_id": "رقم الطلب",
"date": "التاريخ",
"ship_to": "مشحون إلى",
"description": "الوصف",
"qty": "الكمية",
"price": "السعر",
"total": "الإجمالي",
"subtotal": "الإجمالي الفرعي",
"shipping": "الشحن",
"tax": "الضريبة",
"grand_total": "الإجمالي النهائي",
"delivery_commitment": "التزام التوصيل",
"est_arrival": "الوصول المتوقع",
"thanks": "شكراً لتسوقكم مع",
"footer": "منظومة VortexCommerce المتكاملة للذكاء الاصطناعي",
"paid": "مدفوع",
"pending": "قيد الانتظار",
"free": "مجاناً",
"sar": "ر.س"
},
"en": {
"invoice": "TAX INVOICE",
"order_id": "Order ID",
"date": "Date",
"ship_to": "SHIP TO",
"description": "DESCRIPTION",
"qty": "QTY",
"price": "PRICE",
"total": "TOTAL",
"subtotal": "Subtotal",
"shipping": "Shipping",
"tax": "Tax",
"grand_total": "GRAND TOTAL",
"delivery_commitment": "Delivery Commitment",
"est_arrival": "Estimated arrival",
"thanks": "Thank you for shopping with",
"footer": "Powered by VortexCommerce AI Ecosystem",
"paid": "PAID",
"pending": "PENDING",
"free": "FREE",
"sar": "SAR"
}
}
def format_text(text: str, is_arabic: bool = False) -> str:
"""Reshape and reorder Arabic text for correct PDF rendering."""
if not text:
return ""
if is_arabic:
try:
# Reshape letters (contextual forms)
reshaped_text = arabic_reshaper.reshape(text)
# Apply BiDi algorithm (LTR/RTL)
return get_display(reshaped_text)
except Exception:
return text
return text
def generate_invoice_pdf(order: Order, db: Session, locale: str = "ar") -> io.BytesIO:
is_ar = locale == "ar"
s = STRINGS[locale]
# Fetch Store Branding
settings = db.query(StoreSettings).first()
store_name = settings.store_name if settings else "VortexCommerce"
# Robust Color Parsing
def get_color(hex_str, default):
try:
if not hex_str or not isinstance(hex_str, str): return default
if not hex_str.startswith("#"): hex_str = f"#{hex_str}"
return colors.HexColor(hex_str)
except:
return default
primary_color = get_color(settings.primary_color if settings else None, colors.HexColor("#046c4e"))
secondary_color = get_color(settings.secondary_color if settings else None, colors.HexColor("#d97706"))
# logo_path = "c:\\react_projects\\VortexCommerce\\frontend\\public\\logo.png"
# Note: We use relative path if possible or full path
logo_path = os.path.join("..", "frontend", "public", "logo.png")
if not os.path.exists(logo_path):
# Alternative search
logo_path = os.path.join("frontend", "public", "logo.png")
buffer = io.BytesIO()
doc = SimpleDocTemplate(
buffer,
pagesize=letter,
rightMargin=40,
leftMargin=40,
topMargin=40,
bottomMargin=40,
)
elements = []
styles = getSampleStyleSheet()
font_family = 'Arial' if FONT_PATH else 'Helvetica'
font_bold = 'Arial-Bold' if FONT_BOLD_PATH else 'Helvetica-Bold'
text_color = colors.HexColor("#1e293b")
muted_color = colors.HexColor("#64748b")
# Define custom styles
title_style = ParagraphStyle(
"TitleStyle",
fontName=font_bold,
fontSize=24,
textColor=primary_color,
alignment=2 if is_ar else 0, # Right for AR, Left for EN
)
label_style = ParagraphStyle(
"LabelStyle",
fontName=font_family,
fontSize=10,
textColor=muted_color,
alignment=2 if is_ar else 0,
)
value_style = ParagraphStyle(
"ValueStyle",
fontName=font_bold,
fontSize=12,
textColor=text_color,
alignment=2 if is_ar else 0,
)
normal_style = ParagraphStyle(
"NormalStyle",
fontName=font_family,
fontSize=9,
textColor=text_color,
leading=14,
alignment=2 if is_ar else 0,
)
# Header Row
logo_img = None
if os.path.exists(logo_path):
try:
logo_img = Image(logo_path, 1.2*inch, 1.2*inch)
except:
pass
header_invoice_text = format_text(s["invoice"], is_ar)
header_store_text = format_text(store_name, is_ar)
if is_ar:
header_data = [
[Paragraph(f"<b>{header_store_text}</b><br/><font size='8' color='{muted_color}'>Premium AI E-Commerce</font>", ParagraphStyle("Logo", alignment=0, fontName=font_bold, fontSize=14, textColor=primary_color)),
logo_img,
Paragraph(header_invoice_text, title_style)]
]
col_widths = [2.5*inch, 1.5*inch, 3*inch]
else:
header_data = [
[logo_img,
Paragraph(header_invoice_text, title_style),
Paragraph(f"<b>{header_store_text}</b><br/><font size='8' color='{muted_color}'>Premium AI E-Commerce</font>", ParagraphStyle("Logo", alignment=2, fontName=font_bold, fontSize=14, textColor=primary_color))]
]
col_widths = [1.5*inch, 2.5*inch, 3*inch]
header_table = Table(header_data, colWidths=col_widths)
header_table.setStyle(TableStyle([('VALIGN', (0,0), (-1,-1), 'MIDDLE')]))
elements.append(header_table)
elements.append(Spacer(1, 10))
elements.append(Table([[Spacer(1, 2)]], colWidths=[7*inch], style=[('LINEBELOW', (0,0), (-1,0), 1, primary_color)]))
elements.append(Spacer(1, 20))
# Order Info Section
is_paid = order.status in [OrderStatus.PROCESSING, OrderStatus.PREPARING, OrderStatus.SHIPPED, OrderStatus.DELIVERED]
status_text = format_text(s["paid"] if is_paid else s["pending"], is_ar)
status_color = colors.HexColor("#10b981") if is_paid else colors.HexColor("#f59e0b")
order_id_label = format_text(f"{s['order_id']}:", is_ar)
date_label = format_text(f"{s['date']}:", is_ar)
meta_data = [
[
Paragraph(f"<b>{order_id_label}</b> #{order.id:05d}<br/><b>{date_label}</b> {order.created_at.strftime('%Y-%m-%d')}", normal_style),
Paragraph(f"<font color='{status_color}'><b>{status_text}</b></font>", ParagraphStyle("Status", alignment=2 if not is_ar else 0, fontSize=18, fontName=font_bold))
]
]
meta_table = Table(meta_data, colWidths=[3.5*inch, 3.5*inch])
elements.append(meta_table)
elements.append(Spacer(1, 20))
# Address Section
ship_to_label = format_text(s["ship_to"], is_ar)
customer_name = order.user.name if order.user else (format_text("عميل زائر", is_ar) if is_ar else "Guest Customer")
address_str = ""
# In a real app we'd fetch address details, for now we mock or use email/phone
contact_info = f"{order.guest_email or ''} | {order.guest_phone or ''}"
info_data = [
[Paragraph(f"<b>{ship_to_label}</b>", label_style)],
[Paragraph(format_text(customer_name, is_ar), value_style)],
[Paragraph(contact_info, normal_style)]
]
info_table = Table(info_data, colWidths=[7*inch])
elements.append(info_table)
elements.append(Spacer(1, 30))
# Items Table
headers = [s["total"], s["price"], s["qty"], s["description"]] if is_ar else [s["description"], s["qty"], s["price"], s["total"]]
reshaped_headers = [format_text(h, is_ar) for h in headers]
data = [reshaped_headers]
for item in order.items:
prod_name = (item.product.name_ar if is_ar and item.product.name_ar else item.product.name_en) if item.product else "Product"
if item.variant_label:
prod_name = f"{prod_name} - {item.variant_label}"
prod_name = format_text(prod_name, is_ar)
item_total = f"{item.quantity * item.price:,.2f}"
item_price = f"{item.price:,.2f}"
if is_ar:
data.append([item_total, item_price, str(item.quantity), Paragraph(prod_name, normal_style)])
else:
data.append([Paragraph(prod_name, normal_style), str(item.quantity), item_price, item_total])
col_widths = [1.2*inch, 1.2*inch, 0.8*inch, 3.8*inch] if is_ar else [3.8*inch, 0.8*inch, 1.2*inch, 1.2*inch]
table = Table(data, colWidths=col_widths)
t_style = [
('BACKGROUND', (0, 0), (-1, 0), primary_color),
('TEXTCOLOR', (0, 0), (-1, 0), colors.white),
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
('FONTNAME', (0, 0), (-1, 0), font_bold),
('FONTSIZE', (0, 0), (-1, 0), 10),
('BOTTOMPADDING', (0, 0), (-1, 0), 12),
('TOPPADDING', (0, 0), (-1, 0), 12),
('GRID', (0, 0), (-1, -1), 0.5, colors.HexColor("#e2e8f0")),
('FONTNAME', (0, 1), (-1, -1), font_family),
('FONTSIZE', (0, 1), (-1, -1), 9),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
]
# Adjust alignment for descriptions
desc_idx = 3 if is_ar else 0
t_style.append(('ALIGN', (desc_idx, 1), (desc_idx, -1), 'RIGHT' if is_ar else 'LEFT'))
table.setStyle(TableStyle(t_style))
elements.append(table)
elements.append(Spacer(1, 20))
# Totals Summary
subtotal_val = order.total_price - order.tax - order.shipping_cost
shipping_text = format_text(s["free"], is_ar) if order.shipping_cost == 0 else f"{order.shipping_cost:,.2f}"
summary_data = []
summary_labels = [s["subtotal"], s["shipping"], s["tax"]]
summary_values = [f"{subtotal_val:,.2f}", shipping_text, f"{order.tax:,.2f}"]
for label, val in zip(summary_labels, summary_values):
lbl = format_text(f"{label}:", is_ar)
if is_ar:
summary_data.append([val, Paragraph(lbl, normal_style)])
else:
summary_data.append([Paragraph(lbl, normal_style), val])
# Grand Total Row
gt_label = format_text(f"{s['grand_total']}:", is_ar)
gt_val = f"{order.total_price:,.2f} {format_text(s['sar'], is_ar)}"
if is_ar:
summary_data.append([Paragraph(f"<b>{gt_val}</b>", ParagraphStyle("GT", fontName=font_bold, fontSize=14, textColor=primary_color, alignment=0)),
Paragraph(f"<b>{gt_label}</b>", ParagraphStyle("GTL", fontName=font_bold, fontSize=14, textColor=primary_color, alignment=2))])
else:
summary_data.append([Paragraph(f"<b>{gt_label}</b>", ParagraphStyle("GTL", fontName=font_bold, fontSize=14, textColor=primary_color, alignment=0)),
Paragraph(f"<b>{gt_val}</b>", ParagraphStyle("GT", fontName=font_bold, fontSize=14, textColor=primary_color, alignment=2))])
summary_table = Table(summary_data, colWidths=[1.5*inch, 1.5*inch] if is_ar else [1.5*inch, 1.5*inch])
summary_table.setStyle(TableStyle([
('ALIGN', (0,0), (-1,-1), 'RIGHT' if is_ar else 'LEFT'),
('VALIGN', (0,0), (-1,-1), 'MIDDLE'),
]))
outer_summary = Table([[Spacer(1,1), summary_table]], colWidths=[4*inch, 3*inch] if not is_ar else [3*inch, 4*inch])
elements.append(outer_summary)
# Footer
elements.append(Spacer(1, 60))
thanks_text = format_text(f"{s['thanks']} {store_name}!", is_ar)
footer_text = format_text(s["footer"], is_ar)
elements.append(Paragraph(f"<b>{thanks_text}</b>", ParagraphStyle("Thanks", alignment=1, fontName=font_bold, fontSize=14, textColor=secondary_color)))
elements.append(Paragraph(footer_text, ParagraphStyle("Footer", alignment=1, fontName=font_family, fontSize=8, textColor=muted_color, spaceBefore=5)))
# Build PDF
doc.build(elements)
buffer.seek(0)
return buffer