418 lines
16 KiB
Python
418 lines
16 KiB
Python
"""
|
|
@helper InvoiceGenerator (Backend/app/core/invoice_generator.py)
|
|
@purpose Utilities to compile standard Letter-sized PDF invoices and compact 80mm thermal roll receipts on-the-fly using ReportLab.
|
|
"""
|
|
from io import BytesIO
|
|
from reportlab.lib.pagesizes import letter
|
|
from reportlab.lib import colors
|
|
from reportlab.lib.units import inch
|
|
from PIL import Image as PILImage
|
|
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, PageBreak, Image as RLImage
|
|
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
|
from reportlab.lib.enums import TA_CENTER, TA_RIGHT, TA_LEFT
|
|
from datetime import datetime
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
|
|
def get_invoice_logo_element(max_w=2.0 * inch, max_h=0.8 * inch):
|
|
"""
|
|
Finds the latest uploaded invoice branding logo in uploads/invoice_branding/logo/
|
|
and returns a ReportLab RLImage element.
|
|
"""
|
|
backend_root = Path(__file__).resolve().parents[2]
|
|
logo_dir = backend_root / "uploads" / "invoice_branding" / "logo"
|
|
|
|
if logo_dir.exists():
|
|
# Get active non-variant files sorted by modification time
|
|
files = [f for f in logo_dir.glob("*.*") if not any(s in f.name for s in ["_thumbnail", "_medium", "_large", "_raw"])]
|
|
if not files:
|
|
files = list(logo_dir.glob("*.*"))
|
|
|
|
files = sorted(files, key=os.path.getmtime, reverse=True)
|
|
for logo_file in files:
|
|
try:
|
|
pil_img = PILImage.open(logo_file)
|
|
bio = BytesIO()
|
|
if pil_img.mode in ("RGBA", "P"):
|
|
pil_img = pil_img.convert("RGBA")
|
|
else:
|
|
pil_img = pil_img.convert("RGB")
|
|
|
|
pil_img.save(bio, format="PNG")
|
|
bio.seek(0)
|
|
|
|
w, h = pil_img.size
|
|
if w <= 0 or h <= 0:
|
|
continue
|
|
aspect = h / float(w)
|
|
render_w = max_w
|
|
render_h = max_w * aspect
|
|
if render_h > max_h:
|
|
render_h = max_h
|
|
render_w = max_h / aspect
|
|
|
|
return RLImage(bio, width=render_w, height=render_h)
|
|
except Exception as e:
|
|
print(f"Error loading logo {logo_file}: {e}")
|
|
continue
|
|
return None
|
|
|
|
def get_invoice_branding_data(db=None):
|
|
company_name = "iFixKart"
|
|
gstin = "33AAAAA0000A1Z5"
|
|
address = "Offline Main Store Counter, Chennai | +91 9876543210"
|
|
gst_rate = 18.0
|
|
|
|
if db:
|
|
try:
|
|
from app.models.SettingModel import Setting
|
|
setting = db.query(Setting).filter(Setting.setting_key == "invoice_branding").first()
|
|
if setting and setting.setting_value:
|
|
val = setting.setting_value
|
|
if isinstance(val, dict):
|
|
company_name = val.get("companyName") or company_name
|
|
gstin = val.get("gstin") or gstin
|
|
address = val.get("storeAddress") or address
|
|
if "gstRate" in val and val["gstRate"] is not None:
|
|
try:
|
|
gst_rate = float(val["gstRate"])
|
|
except (ValueError, TypeError):
|
|
pass
|
|
except Exception:
|
|
pass
|
|
|
|
return {
|
|
"companyName": company_name,
|
|
"gstin": gstin,
|
|
"storeAddress": address,
|
|
"gstRate": gst_rate
|
|
}
|
|
|
|
def generate_invoice_pdf(order, customer, order_items, db=None) -> bytes:
|
|
"""
|
|
Generate a professional standard Letter-size GST Invoice on-the-fly.
|
|
"""
|
|
buffer = BytesIO()
|
|
doc = SimpleDocTemplate(
|
|
buffer,
|
|
pagesize=letter,
|
|
rightMargin=36,
|
|
leftMargin=36,
|
|
topMargin=36,
|
|
bottomMargin=36
|
|
)
|
|
|
|
styles = getSampleStyleSheet()
|
|
|
|
# Custom styles
|
|
title_style = ParagraphStyle(
|
|
'InvoiceTitle',
|
|
parent=styles['Heading1'],
|
|
fontName='Helvetica-Bold',
|
|
fontSize=22,
|
|
textColor=colors.HexColor('#1b2559'),
|
|
spaceAfter=4
|
|
)
|
|
|
|
subtitle_style = ParagraphStyle(
|
|
'InvoiceSubtitle',
|
|
parent=styles['Normal'],
|
|
fontName='Helvetica-Bold',
|
|
fontSize=10,
|
|
textColor=colors.HexColor('#e4382f'),
|
|
spaceAfter=10
|
|
)
|
|
|
|
label_style = ParagraphStyle(
|
|
'MetaLabel',
|
|
parent=styles['Normal'],
|
|
fontName='Helvetica-Bold',
|
|
fontSize=9,
|
|
textColor=colors.HexColor('#94a3b8'),
|
|
spaceAfter=3
|
|
)
|
|
|
|
text_style = ParagraphStyle(
|
|
'MetaText',
|
|
parent=styles['Normal'],
|
|
fontName='Helvetica',
|
|
fontSize=9,
|
|
textColor=colors.HexColor('#1e293b'),
|
|
spaceAfter=3
|
|
)
|
|
|
|
bold_text_style = ParagraphStyle(
|
|
'MetaTextBold',
|
|
parent=styles['Normal'],
|
|
fontName='Helvetica-Bold',
|
|
fontSize=9,
|
|
textColor=colors.HexColor('#1e293b'),
|
|
spaceAfter=3
|
|
)
|
|
|
|
story = []
|
|
|
|
# 1. Header (Logo/Title & Metadata)
|
|
logo_element = get_invoice_logo_element(max_w=2.2 * inch, max_h=0.85 * inch)
|
|
|
|
left_cell = []
|
|
if logo_element:
|
|
left_cell.append(logo_element)
|
|
else:
|
|
left_cell.append(Paragraph("GST INVOICE", title_style))
|
|
left_cell.append(Paragraph("iFixKart Solutions Platform", subtitle_style))
|
|
|
|
header_data = [
|
|
[
|
|
left_cell,
|
|
Paragraph(f"<b>GST INVOICE</b><br/><b>Invoice No:</b> INV-{order.order_no.split('-')[-1]}<br/><b>Date:</b> {order.created_at.strftime('%d-%b-%Y')}<br/><b>Status:</b> {order.status}", ParagraphStyle('RightText', parent=text_style, alignment=TA_RIGHT))
|
|
]
|
|
]
|
|
|
|
header_table = Table(header_data, colWidths=[3.5 * inch, 4.0 * inch])
|
|
header_table.setStyle(TableStyle([
|
|
('VALIGN', (0,0), (-1,-1), 'TOP'),
|
|
('BOTTOMPADDING', (0,0), (-1,-1), 0),
|
|
('TOPPADDING', (0,0), (-1,-1), 0),
|
|
]))
|
|
story.append(header_table)
|
|
story.append(Spacer(1, 15))
|
|
|
|
# 2. Billing & Store Coordinates
|
|
branding = get_invoice_branding_data(db)
|
|
seller_html = f"<b>{branding['companyName']}</b><br/>{branding['storeAddress']}<br/><b>GSTIN:</b> {branding['gstin']}"
|
|
|
|
# Parse shipping address
|
|
cust_addr_str = "Customer Billing Details"
|
|
if order.shipping_address_json:
|
|
try:
|
|
addr = json.loads(order.shipping_address_json)
|
|
cust_addr_str = f"<b>{addr.get('full_name')}</b><br/>{addr.get('street_address')}<br/>{addr.get('city')}, {addr.get('state')} - {addr.get('pincode')}<br/>Phone: {addr.get('phone')}"
|
|
except Exception:
|
|
cust_addr_str = order.shipping_address_json
|
|
|
|
details_data = [
|
|
[
|
|
Paragraph("SELLER (IFIXKART STORE)", label_style),
|
|
Paragraph("BILLED TO (CUSTOMER)", label_style)
|
|
],
|
|
[
|
|
Paragraph(seller_html, text_style),
|
|
Paragraph(cust_addr_str, text_style)
|
|
]
|
|
]
|
|
details_table = Table(details_data, colWidths=[3.75 * inch, 3.75 * inch])
|
|
details_table.setStyle(TableStyle([
|
|
('VALIGN', (0,0), (-1,-1), 'TOP'),
|
|
('BACKGROUND', (0,0), (-1,-1), colors.HexColor('#f8fafc')),
|
|
('PADDING', (0,0), (-1,-1), 10),
|
|
('BOX', (0,0), (-1,-1), 0.5, colors.HexColor('#e2e8f0')),
|
|
]))
|
|
story.append(details_table)
|
|
story.append(Spacer(1, 20))
|
|
|
|
# 3. Items Table
|
|
th_style = ParagraphStyle('TH', parent=styles['Normal'], fontName='Helvetica-Bold', fontSize=9, textColor=colors.white)
|
|
th_right = ParagraphStyle('THR', parent=styles['Normal'], fontName='Helvetica-Bold', fontSize=9, textColor=colors.white, alignment=TA_RIGHT)
|
|
|
|
td_style = ParagraphStyle('TD', parent=styles['Normal'], fontName='Helvetica', fontSize=9, textColor=colors.HexColor('#334155'))
|
|
td_right = ParagraphStyle('TDR', parent=styles['Normal'], fontName='Helvetica', fontSize=9, textColor=colors.HexColor('#334155'), alignment=TA_RIGHT)
|
|
|
|
table_data = [[
|
|
Paragraph("S.No", th_style),
|
|
Paragraph("Item SKU & Name", th_style),
|
|
Paragraph("Unit Price", th_right),
|
|
Paragraph("Qty", th_right),
|
|
Paragraph("Total Price", th_right)
|
|
]]
|
|
|
|
for idx, item in enumerate(order_items):
|
|
table_data.append([
|
|
Paragraph(str(idx + 1), td_style),
|
|
Paragraph(f"<b>{item.sku}</b> - {item.product_name}", td_style),
|
|
Paragraph(f"Rs. {float(item.unit_price):.2f}", td_right),
|
|
Paragraph(str(item.quantity), td_right),
|
|
Paragraph(f"Rs. {float(item.total_price):.2f}", td_right)
|
|
])
|
|
|
|
items_table = Table(table_data, colWidths=[0.5 * inch, 3.8 * inch, 1.1 * inch, 0.6 * inch, 1.5 * inch])
|
|
items_table.setStyle(TableStyle([
|
|
('BACKGROUND', (0,0), (-1,0), colors.HexColor('#1b2559')),
|
|
('ALIGN', (0,0), (-1,-1), 'LEFT'),
|
|
('VALIGN', (0,0), (-1,-1), 'MIDDLE'),
|
|
('BOTTOMPADDING', (0,0), (-1,-1), 8),
|
|
('TOPPADDING', (0,0), (-1,-1), 8),
|
|
('GRID', (0,0), (-1,-1), 0.5, colors.HexColor('#cbd5e1')),
|
|
]))
|
|
story.append(items_table)
|
|
story.append(Spacer(1, 15))
|
|
|
|
# 4. Totals & Tax Calculation Breakdown
|
|
subtotal = float(order.total_amount)
|
|
tax_amount = float(order.tax_amount)
|
|
gst_rate = float(branding.get("gstRate", 18.0))
|
|
half_rate = round(gst_rate / 2.0, 1)
|
|
cgst = round(tax_amount / 2, 2)
|
|
sgst = round(tax_amount / 2, 2)
|
|
igst = 0.0
|
|
|
|
totals_data = [
|
|
[Paragraph("", text_style), Paragraph("Taxable Value:", label_style), Paragraph(f"Rs. {subtotal:.2f}", td_right)],
|
|
[Paragraph("", text_style), Paragraph(f"CGST ({half_rate:.1f}%):", label_style), Paragraph(f"Rs. {cgst:.2f}", td_right)],
|
|
[Paragraph("", text_style), Paragraph(f"SGST ({half_rate:.1f}%):", label_style), Paragraph(f"Rs. {sgst:.2f}", td_right)],
|
|
[Paragraph("", text_style), Paragraph("IGST (0.0%):", label_style), Paragraph(f"Rs. {igst:.2f}", td_right)],
|
|
[Paragraph("", text_style), Paragraph("<b>Grand Total:</b>", ParagraphStyle('GrandLabel', parent=label_style, fontSize=11, textColor=colors.HexColor('#1b2559'))), Paragraph(f"<b>Rs. {float(order.final_amount):.2f}</b>", ParagraphStyle('GrandVal', parent=td_right, fontSize=11, fontName='Helvetica-Bold', textColor=colors.HexColor('#1b2559')))]
|
|
]
|
|
|
|
totals_table = Table(totals_data, colWidths=[4.2 * inch, 1.8 * inch, 1.5 * inch])
|
|
totals_table.setStyle(TableStyle([
|
|
('VALIGN', (0,0), (-1,-1), 'MIDDLE'),
|
|
('LINEBELOW', (1,0), (-1,-2), 0.5, colors.HexColor('#e2e8f0')),
|
|
('TOPPADDING', (0,0), (-1,-1), 4),
|
|
('BOTTOMPADDING', (0,0), (-1,-1), 4),
|
|
]))
|
|
story.append(totals_table)
|
|
story.append(Spacer(1, 50))
|
|
|
|
# 5. Legal Footer
|
|
footer_text = Paragraph(
|
|
"This is a computer-generated GST Tax Invoice. No signature is required. Thank you for choosing iFixKart!",
|
|
ParagraphStyle('Footer', parent=styles['Normal'], fontName='Helvetica-Oblique', fontSize=8, textColor=colors.HexColor('#94a3b8'), alignment=TA_CENTER)
|
|
)
|
|
story.append(footer_text)
|
|
|
|
doc.build(story)
|
|
pdf_bytes = buffer.getvalue()
|
|
buffer.close()
|
|
return pdf_bytes
|
|
|
|
def generate_thermal_invoice_pdf(order, customer, order_items) -> bytes:
|
|
"""
|
|
Generate an 80mm thermal roll print receipt (walking invoice format) on-the-fly.
|
|
Page width is exactly 80mm (approx 226 pt). Page length is dynamic/extended (e.g. 450 pt).
|
|
"""
|
|
buffer = BytesIO()
|
|
|
|
# 80mm roll size: 226pt wide, 450pt tall
|
|
doc = SimpleDocTemplate(
|
|
buffer,
|
|
pagesize=(226, 450),
|
|
rightMargin=10,
|
|
leftMargin=10,
|
|
topMargin=15,
|
|
bottomMargin=15
|
|
)
|
|
|
|
styles = getSampleStyleSheet()
|
|
|
|
title_style = ParagraphStyle(
|
|
'ThermalTitle',
|
|
parent=styles['Heading2'],
|
|
fontName='Helvetica-Bold',
|
|
fontSize=12,
|
|
textColor=colors.black,
|
|
alignment=TA_CENTER,
|
|
spaceAfter=2
|
|
)
|
|
|
|
subtitle_style = ParagraphStyle(
|
|
'ThermalSubtitle',
|
|
parent=styles['Normal'],
|
|
fontName='Helvetica-Bold',
|
|
fontSize=8,
|
|
textColor=colors.black,
|
|
alignment=TA_CENTER,
|
|
spaceAfter=10
|
|
)
|
|
|
|
text_style = ParagraphStyle(
|
|
'ThermalText',
|
|
parent=styles['Normal'],
|
|
fontName='Helvetica',
|
|
fontSize=7,
|
|
textColor=colors.black,
|
|
spaceAfter=2
|
|
)
|
|
|
|
text_right = ParagraphStyle(
|
|
'ThermalTextRight',
|
|
parent=text_style,
|
|
alignment=TA_RIGHT
|
|
)
|
|
|
|
story = []
|
|
|
|
# 1. Header
|
|
thermal_logo = get_invoice_logo_element(max_w=1.8 * inch, max_h=0.6 * inch)
|
|
if thermal_logo:
|
|
thermal_logo.hAlign = 'CENTER'
|
|
story.append(thermal_logo)
|
|
story.append(Spacer(1, 4))
|
|
|
|
story.append(Paragraph("iFixKart Retail POS", title_style))
|
|
story.append(Paragraph("Solutions Pvt Ltd - Store #1<br/>100 Tech Arcade Main Road, Chennai<br/>GSTIN: 33AAFCI8824J1ZP", subtitle_style))
|
|
story.append(Spacer(1, 5))
|
|
|
|
# 2. Transaction Meta Info
|
|
story.append(Paragraph(f"<b>Invoice:</b> walk_inv_{order.order_id[:8]}", text_style))
|
|
story.append(Paragraph(f"<b>Order No:</b> {order.order_no}", text_style))
|
|
story.append(Paragraph(f"<b>Date:</b> {order.created_at.strftime('%d-%b-%Y %H:%M')}", text_style))
|
|
payment_method = getattr(order, "payment_method", None) or "COD"
|
|
story.append(Paragraph(f"<b>Payment:</b> {payment_method} ({order.payment_status})", text_style))
|
|
story.append(Spacer(1, 10))
|
|
|
|
# 3. Item List Header
|
|
item_header = [
|
|
[Paragraph("<b>Item Description</b>", text_style), Paragraph("<b>Qty</b>", text_right), Paragraph("<b>Total</b>", text_right)]
|
|
]
|
|
|
|
# 4. Item List Rows
|
|
for item in order_items:
|
|
# Truncate long names to save receipt slip width
|
|
short_name = item.product_name[:24] + '..' if len(item.product_name) > 26 else item.product_name
|
|
item_header.append([
|
|
Paragraph(f"{item.sku}<br/>{short_name}", text_style),
|
|
Paragraph(str(item.quantity), text_right),
|
|
Paragraph(f"Rs. {float(item.total_price):.1f}", text_right)
|
|
])
|
|
|
|
# Table Widths: item=120pt, qty=30pt, total=56pt -> Total 206pt
|
|
items_table = Table(item_header, colWidths=[120, 30, 56])
|
|
items_table.setStyle(TableStyle([
|
|
('LINEBELOW', (0,0), (-1,0), 0.5, colors.black),
|
|
('LINEBELOW', (0,-1), (-1,-1), 0.5, colors.black),
|
|
('PADDING', (0,0), (-1,-1), 3),
|
|
('VALIGN', (0,0), (-1,-1), 'MIDDLE'),
|
|
]))
|
|
story.append(items_table)
|
|
story.append(Spacer(1, 8))
|
|
|
|
# 5. Taxes & Total Breakout
|
|
tax_amount = float(order.tax_amount)
|
|
cgst = round(tax_amount / 2, 2)
|
|
sgst = round(tax_amount / 2, 2)
|
|
|
|
totals_data = [
|
|
[Paragraph("Taxable Value:", text_style), Paragraph(f"Rs. {float(order.total_amount):.2f}", text_right)],
|
|
[Paragraph("CGST (9%):", text_style), Paragraph(f"Rs. {cgst:.2f}", text_right)],
|
|
[Paragraph("SGST (9%):", text_style), Paragraph(f"Rs. {sgst:.2f}", text_right)],
|
|
[Paragraph("<b>Grand Total:</b>", ParagraphStyle('GrandLabelThermal', parent=text_style, fontName='Helvetica-Bold', fontSize=9)), Paragraph(f"<b>Rs. {float(order.final_amount):.2f}</b>", ParagraphStyle('GrandValThermal', parent=text_right, fontName='Helvetica-Bold', fontSize=9))]
|
|
]
|
|
|
|
totals_table = Table(totals_data, colWidths=[110, 96])
|
|
totals_table.setStyle(TableStyle([
|
|
('LINEABOVE', (0,-1), (-1,-1), 0.5, colors.black),
|
|
('PADDING', (0,0), (-1,-1), 2),
|
|
]))
|
|
story.append(totals_table)
|
|
story.append(Spacer(1, 20))
|
|
|
|
# 6. Thermal Footer
|
|
story.append(Paragraph("Thank you for your purchase!", ParagraphStyle('F1', parent=text_style, fontName='Helvetica-Bold', alignment=TA_CENTER)))
|
|
story.append(Paragraph("For support: support@ifixkart.com", ParagraphStyle('F2', parent=text_style, alignment=TA_CENTER)))
|
|
|
|
doc.build(story)
|
|
pdf_bytes = buffer.getvalue()
|
|
buffer.close()
|
|
return pdf_bytes
|