Spaces:
Sleeping
Sleeping
| 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 | |