| """ |
| report.py β PDF period reports for Qx-SmartLedger. |
| |
| A comprehensive, trader-friendly statement for a period: every transaction (full |
| visibility, marked inflow/outflow), a true income statement (Sales β COGS = Gross |
| Profit β Operating Expenses = Net Profit), a cash-flow summary, current business |
| standing, and a grounded coaching insight. Reuses the receipt's branded scaffolding. |
| """ |
|
|
| import os |
| import logging |
| from typing import Any, Dict, List, Optional |
|
|
| from fpdf import FPDF |
|
|
| from receipt import _ensure_logo, _safe, _fmt_money, DOMAIN, DOMAIN_URL, POWERED_BY |
|
|
| logger = logging.getLogger(__name__) |
|
|
| _REPORT_DIR = "temp_reports" |
|
|
|
|
| def _section(pdf: FPDF, title: str, epw: float) -> None: |
| pdf.ln(3) |
| pdf.set_font("Helvetica", "B", 12) |
| pdf.set_text_color(30, 30, 30) |
| pdf.cell(0, 7, _safe(title), ln=1) |
| pdf.set_draw_color(210, 210, 210) |
| pdf.line(pdf.l_margin, pdf.get_y(), pdf.l_margin + epw, pdf.get_y()) |
| pdf.ln(2) |
| pdf.set_text_color(0, 0, 0) |
|
|
|
|
| def generate_period_report_pdf(report: Dict[str, Any], |
| vendor: Dict[str, Any], |
| currency: str = "") -> Optional[str]: |
| """Render a period report PDF and return its file path.""" |
| cur = "" if currency in (None, "?", "null", "None") else str(currency) |
| label = str(report.get("period_label") or "All Time") |
|
|
| try: |
| os.makedirs(_REPORT_DIR, exist_ok=True) |
| pdf = FPDF(format="A4") |
| pdf.set_auto_page_break(auto=True, margin=18) |
| pdf.add_page() |
| epw = pdf.w - 2 * pdf.l_margin |
|
|
| def _row(label_text, value, bold=False, indent=0, color=None): |
| """One label/value line. Positions with set_x (NOT a width-0 spacer cell: |
| fpdf treats cell width 0 as 'extend to the right margin', which used to push |
| every non-indented label off the page).""" |
| pdf.set_font("Helvetica", "B" if bold else "", 11 if bold else 10) |
| if color: |
| pdf.set_text_color(*color) |
| pdf.set_x(pdf.l_margin + 8 * indent) |
| pdf.cell(epw * 0.7 - 8 * indent, 6, _safe(label_text), border=0) |
| pdf.cell(epw * 0.3, 6, _safe(_fmt_money(value, cur)), border=0, align="R", ln=1) |
| if color: |
| pdf.set_text_color(0, 0, 0) |
|
|
| def _rule(shade=210): |
| pdf.set_draw_color(shade, shade, shade) |
| pdf.line(pdf.l_margin, pdf.get_y(), pdf.l_margin + epw, pdf.get_y()) |
| pdf.ln(1) |
|
|
| |
| logo = _ensure_logo() |
| if logo: |
| try: |
| pdf.image(logo, x=pdf.l_margin, y=10, w=34) |
| except Exception as e: |
| logger.warning(f"report: logo embed failed: {e}") |
| pdf.set_xy(pdf.l_margin, 12) |
| pdf.set_font("Helvetica", "B", 20) |
| pdf.cell(0, 11, "BUSINESS REPORT", align="R", ln=1) |
| pdf.set_font("Helvetica", "", 11) |
| pdf.cell(0, 6, _safe(vendor.get("business_name") or "Your Business"), align="R", ln=1) |
| pdf.cell(0, 6, _safe(f"Period: {label}"), align="R", ln=1) |
| pdf.ln(2) |
| pdf.set_draw_color(150, 150, 150) |
| pdf.line(pdf.l_margin, pdf.get_y(), pdf.l_margin + epw, pdf.get_y()) |
|
|
| income = report.get("income", {}) or {} |
| cash_flow = report.get("cash_flow", {}) or {} |
|
|
| |
| _section(pdf, "Income Statement", epw) |
| _row("Sales revenue", income.get("sales", 0), bold=True) |
| _row("Less: Cost of goods sold", income.get("cogs", 0), indent=1) |
| _rule() |
| _row("Gross profit", income.get("gross_profit", 0), bold=True) |
| _row("Less: Operating expenses", income.get("operating_expenses", 0), indent=1) |
| for cat, amt in (income.get("by_category") or {}).items(): |
| _row(f"- {str(cat).title()}", amt, indent=2) |
| _rule() |
| net = income.get("net_profit", report.get("net", 0)) |
| _row("NET PROFIT", net, bold=True, color=((0, 120, 0) if (net or 0) >= 0 else (180, 0, 0))) |
|
|
| unknown = income.get("cogs_unknown") or [] |
| if unknown: |
| pdf.ln(1) |
| pdf.set_font("Helvetica", "I", 8) |
| pdf.set_text_color(120, 120, 120) |
| shown = ", ".join(str(u).title() for u in unknown[:6]) |
| pdf.multi_cell(epw, 4, _safe(f"COGS excludes items with no recorded cost price: {shown}. " |
| "Add a cost when you stock them for an exact gross profit.")) |
| pdf.set_text_color(0, 0, 0) |
|
|
| |
| _section(pdf, "Cash Flow (this period)", epw) |
| _row("Stock purchased (outflow)", cash_flow.get("stock_purchases", 0), indent=1) |
| if cash_flow.get("assets"): |
| _row("Equipment / assets bought (outflow)", cash_flow.get("assets", 0), indent=1) |
|
|
| |
| standing = report.get("standing", {}) or {} |
| _section(pdf, "Business Standing (current)", epw) |
| _row("Cash on hand", standing.get("cash_on_hand", 0), bold=True) |
| _row("Stock on hand (value)", standing.get("stock_value", 0)) |
| _row("Owed to you (receivables)", standing.get("owed_to_you", 0)) |
| _row("You owe (payables)", standing.get("you_owe", 0)) |
|
|
| |
| txns: List[Dict[str, Any]] = report.get("transactions", []) or [] |
| _section(pdf, f"All Transactions ({report.get('txn_count', len(txns))})", epw) |
| if not txns: |
| pdf.set_font("Helvetica", "I", 10) |
| pdf.cell(0, 6, "No transactions in this period.", ln=1) |
| else: |
| c_date, c_type, c_flow, c_desc, c_amt = ( |
| epw * 0.15, epw * 0.15, epw * 0.10, epw * 0.40, epw * 0.20) |
| pdf.set_font("Helvetica", "B", 9) |
| pdf.set_fill_color(238, 238, 238) |
| pdf.cell(c_date, 7, "Date", border=0, fill=True) |
| pdf.cell(c_type, 7, "Type", border=0, fill=True) |
| pdf.cell(c_flow, 7, "In/Out", border=0, fill=True) |
| pdf.cell(c_desc, 7, "Details", border=0, fill=True) |
| pdf.cell(c_amt, 7, "Amount", border=0, align="R", fill=True, ln=1) |
| pdf.set_font("Helvetica", "", 9) |
| for t in txns: |
| desc = str(t.get("description") or "") |
| if t.get("party"): |
| desc = f"{desc} ({t['party']})" if desc else str(t["party"]) |
| if len(desc) > 44: |
| desc = desc[:43] + "β¦" |
| flow = (t.get("flow") or "").lower() |
| flow_label = "In" if flow == "in" else ("Out" if flow == "out" else "β") |
| amt = t.get("amount", 0) |
| amt_str = _fmt_money(amt, cur) |
| if flow == "in": |
| amt_str = "+" + amt_str |
| elif flow == "out": |
| amt_str = "-" + amt_str |
| pdf.cell(c_date, 6, _safe(t.get("date", "")), border="B") |
| pdf.cell(c_type, 6, _safe(t.get("type", "")), border="B") |
| if flow == "in": |
| pdf.set_text_color(0, 120, 0) |
| elif flow == "out": |
| pdf.set_text_color(180, 0, 0) |
| pdf.cell(c_flow, 6, _safe(flow_label), border="B") |
| pdf.set_text_color(0, 0, 0) |
| pdf.cell(c_desc, 6, _safe(desc), border="B") |
| pdf.cell(c_amt, 6, _safe(amt_str), border="B", align="R", ln=1) |
|
|
| |
| insight = (report.get("insight") or "").strip() |
| if insight: |
| _section(pdf, "Insights & Ideas", epw) |
| pdf.set_font("Helvetica", "", 10) |
| pdf.multi_cell(epw, 5.5, _safe(insight)) |
|
|
| |
| pdf.set_auto_page_break(False) |
| pdf.set_y(pdf.h - 14) |
| pdf.set_draw_color(200, 200, 200) |
| pdf.line(pdf.l_margin, pdf.get_y(), pdf.l_margin + epw, pdf.get_y()) |
| pdf.ln(2) |
| pdf.set_font("Helvetica", "", 9) |
| pdf.set_text_color(120, 120, 120) |
| pdf.cell(0, 5, _safe(f"{DOMAIN} | {POWERED_BY}"), align="C", link=DOMAIN_URL) |
|
|
| out_path = os.path.join(_REPORT_DIR, f"report_{label.lower().replace(' ', '_')}.pdf") |
| pdf.output(out_path) |
| return out_path |
| except Exception as e: |
| logger.error(f"generate_period_report_pdf failed: {e}", exc_info=True) |
| return None |
|
|