""" 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) # ── Header ─────────────────────────────────────────────────────────── 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 {} # ── Income statement ───────────────────────────────────────────────── _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) # ── Cash flow ──────────────────────────────────────────────────────── _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) # ── Business standing ──────────────────────────────────────────────── 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)) # ── Transactions (full visibility, marked in/out) ──────────────────── 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) # ── Coaching insight ───────────────────────────────────────────────── 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)) # ── Footer ─────────────────────────────────────────────────────────── 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