smart-w / report.py
initiumzim
Fix CRUD-by-id, unified money model, service hard-stop, PDF report
a4f1096
Raw
History Blame Contribute Delete
9.41 kB
"""
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