reports: add PDF generation infra (fpdf2); Phone Book CSV/PDF export; Payments - Detailed report with preview and PDF grouped by deposit date; update Dockerfile for deps; smoke-tested in Docker
This commit is contained in:
@@ -8,6 +8,8 @@ WORKDIR /app
|
|||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y \
|
||||||
gcc \
|
gcc \
|
||||||
curl \
|
curl \
|
||||||
|
libjpeg62-turbo \
|
||||||
|
libfreetype6 \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Copy requirements first for better layer caching
|
# Copy requirements first for better layer caching
|
||||||
|
|||||||
Binary file not shown.
BIN
app/__pycache__/reporting.cpython-313.pyc
Normal file
BIN
app/__pycache__/reporting.cpython-313.pyc
Normal file
Binary file not shown.
102
app/main.py
102
app/main.py
@@ -31,6 +31,7 @@ from structlog import contextvars as structlog_contextvars
|
|||||||
from .database import create_tables, get_db, get_database_url
|
from .database import create_tables, get_db, get_database_url
|
||||||
from .models import User, Case, Client, Phone, Transaction, Document, Payment, ImportLog
|
from .models import User, Case, Client, Phone, Transaction, Document, Payment, ImportLog
|
||||||
from .auth import authenticate_user, get_current_user_from_session
|
from .auth import authenticate_user, get_current_user_from_session
|
||||||
|
from .reporting import build_phone_book_pdf, build_payments_detailed_pdf
|
||||||
from .logging_config import setup_logging
|
from .logging_config import setup_logging
|
||||||
from .schemas import (
|
from .schemas import (
|
||||||
ClientOut,
|
ClientOut,
|
||||||
@@ -2271,7 +2272,7 @@ async def phone_book_report(
|
|||||||
request: Request,
|
request: Request,
|
||||||
client_ids: List[int] | None = Query(None),
|
client_ids: List[int] | None = Query(None),
|
||||||
q: str | None = Query(None, description="Filter by name/company"),
|
q: str | None = Query(None, description="Filter by name/company"),
|
||||||
format: str | None = Query(None, description="csv for CSV output"),
|
format: str | None = Query(None, description="csv or pdf for export"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
user = get_current_user_from_session(request.session)
|
user = get_current_user_from_session(request.session)
|
||||||
@@ -2313,6 +2314,14 @@ async def phone_book_report(
|
|||||||
headers={"Content-Disposition": "attachment; filename=phone_book.csv"},
|
headers={"Content-Disposition": "attachment; filename=phone_book.csv"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if format == "pdf":
|
||||||
|
pdf_bytes = build_phone_book_pdf(clients)
|
||||||
|
return Response(
|
||||||
|
content=pdf_bytes,
|
||||||
|
media_type="application/pdf",
|
||||||
|
headers={"Content-Disposition": "attachment; filename=phone_book.pdf"},
|
||||||
|
)
|
||||||
|
|
||||||
logger.info("phone_book_render", count=len(clients))
|
logger.info("phone_book_render", count=len(clients))
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"report_phone_book.html",
|
"report_phone_book.html",
|
||||||
@@ -2320,6 +2329,97 @@ async def phone_book_report(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------
|
||||||
|
# Reports: Payments - Detailed
|
||||||
|
# ------------------------------
|
||||||
|
|
||||||
|
@app.get("/reports/payments-detailed")
|
||||||
|
async def payments_detailed_report(
|
||||||
|
request: Request,
|
||||||
|
from_date: str | None = Query(None, description="YYYY-MM-DD"),
|
||||||
|
to_date: str | None = Query(None, description="YYYY-MM-DD"),
|
||||||
|
file_no: str | None = Query(None, description="Case file number"),
|
||||||
|
format: str | None = Query(None, description="pdf for PDF output"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
user = get_current_user_from_session(request.session)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse(url="/login", status_code=302)
|
||||||
|
|
||||||
|
query = (
|
||||||
|
db.query(Payment)
|
||||||
|
.join(Case, Payment.case_id == Case.id)
|
||||||
|
.join(Client, Case.client_id == Client.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
filters = []
|
||||||
|
if from_date:
|
||||||
|
try:
|
||||||
|
dt = datetime.strptime(from_date, "%Y-%m-%d")
|
||||||
|
filters.append(Payment.payment_date >= dt)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
if to_date:
|
||||||
|
try:
|
||||||
|
dt = datetime.strptime(to_date, "%Y-%m-%d")
|
||||||
|
filters.append(Payment.payment_date <= dt)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
if file_no:
|
||||||
|
filters.append(Case.file_no.ilike(f"%{file_no}%"))
|
||||||
|
|
||||||
|
if filters:
|
||||||
|
query = query.filter(and_(*filters))
|
||||||
|
|
||||||
|
# For grouping by deposit date, order by date then id
|
||||||
|
payments = (
|
||||||
|
query.options(joinedload(Payment.case).joinedload(Case.client))
|
||||||
|
.order_by(Payment.payment_date.asc().nulls_last(), Payment.id.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
if format == "pdf":
|
||||||
|
pdf_bytes = build_payments_detailed_pdf(payments)
|
||||||
|
return Response(
|
||||||
|
content=pdf_bytes,
|
||||||
|
media_type="application/pdf",
|
||||||
|
headers={"Content-Disposition": "attachment; filename=payments_detailed.pdf"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build preview groups for template: [{date, total, items}]
|
||||||
|
groups: list[dict[str, Any]] = []
|
||||||
|
from collections import defaultdict
|
||||||
|
grouped: dict[str, list[Payment]] = defaultdict(list)
|
||||||
|
for p in payments:
|
||||||
|
key = p.payment_date.date().isoformat() if p.payment_date else "(No Date)"
|
||||||
|
grouped[key].append(p)
|
||||||
|
overall_total = sum((p.amount or 0.0) for p in payments)
|
||||||
|
for key in sorted(grouped.keys()):
|
||||||
|
items = grouped[key]
|
||||||
|
total_amt = sum((p.amount or 0.0) for p in items)
|
||||||
|
groups.append({"date": key, "total": total_amt, "items": items})
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"payments_detailed_render",
|
||||||
|
from_date=from_date,
|
||||||
|
to_date=to_date,
|
||||||
|
file_no=file_no,
|
||||||
|
count=len(payments),
|
||||||
|
)
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"payments_detailed.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"user": user,
|
||||||
|
"groups": groups,
|
||||||
|
"overall_total": overall_total,
|
||||||
|
"from_date": from_date,
|
||||||
|
"to_date": to_date,
|
||||||
|
"file_no": file_no,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
# ------------------------------
|
# ------------------------------
|
||||||
# JSON API: list/filter endpoints
|
# JSON API: list/filter endpoints
|
||||||
# ------------------------------
|
# ------------------------------
|
||||||
|
|||||||
166
app/reporting.py
Normal file
166
app/reporting.py
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
"""
|
||||||
|
Reporting utilities for generating PDF documents.
|
||||||
|
|
||||||
|
Provides PDF builders used by report endpoints (phone book, payments detailed).
|
||||||
|
Uses fpdf2 to generate simple tabular PDFs with automatic pagination.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from io import BytesIO
|
||||||
|
from typing import Iterable, List, Dict, Any, Tuple
|
||||||
|
|
||||||
|
from fpdf import FPDF
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
# Local imports are type-only to avoid circular import costs at import time
|
||||||
|
from .models import Client, Payment
|
||||||
|
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SimplePDF(FPDF):
|
||||||
|
"""Small helper subclass to set defaults and provide header/footer hooks."""
|
||||||
|
|
||||||
|
def __init__(self, title: str):
|
||||||
|
super().__init__(orientation="P", unit="mm", format="Letter")
|
||||||
|
self.title = title
|
||||||
|
self.set_auto_page_break(auto=True, margin=15)
|
||||||
|
self.set_margins(left=12, top=12, right=12)
|
||||||
|
|
||||||
|
def header(self): # type: ignore[override]
|
||||||
|
self.set_font("helvetica", "B", 12)
|
||||||
|
self.cell(0, 8, self.title, ln=1, align="L")
|
||||||
|
self.ln(2)
|
||||||
|
|
||||||
|
def footer(self): # type: ignore[override]
|
||||||
|
self.set_y(-12)
|
||||||
|
self.set_font("helvetica", size=8)
|
||||||
|
self.set_text_color(120)
|
||||||
|
self.cell(0, 8, f"Page {self.page_no()}", align="R")
|
||||||
|
|
||||||
|
|
||||||
|
def _output_pdf_bytes(pdf: FPDF) -> bytes:
|
||||||
|
"""Return the PDF content as bytes.
|
||||||
|
|
||||||
|
fpdf2's output(dest='S') returns a str; encode to latin-1 per fpdf guidance.
|
||||||
|
"""
|
||||||
|
content_str = pdf.output(dest="S") # type: ignore[no-untyped-call]
|
||||||
|
if isinstance(content_str, bytes):
|
||||||
|
return content_str
|
||||||
|
return content_str.encode("latin-1")
|
||||||
|
|
||||||
|
|
||||||
|
def build_phone_book_pdf(clients: List[Client]) -> bytes:
|
||||||
|
"""Build a Phone Book PDF from a list of `Client` records with phones."""
|
||||||
|
logger.info("pdf_phone_book_start", count=len(clients))
|
||||||
|
|
||||||
|
pdf = SimplePDF(title="Phone Book")
|
||||||
|
pdf.add_page()
|
||||||
|
|
||||||
|
# Table header
|
||||||
|
pdf.set_font("helvetica", "B", 10)
|
||||||
|
headers = ["Name", "Company", "Phone Type", "Phone Number"]
|
||||||
|
widths = [55, 55, 35, 45]
|
||||||
|
for h, w in zip(headers, widths):
|
||||||
|
pdf.cell(w, 8, h, border=1)
|
||||||
|
pdf.ln(8)
|
||||||
|
|
||||||
|
pdf.set_font("helvetica", size=10)
|
||||||
|
for client in clients:
|
||||||
|
rows: List[Tuple[str, str, str, str]] = []
|
||||||
|
name = f"{client.last_name or ''}, {client.first_name or ''}".strip(", ")
|
||||||
|
company = client.company or ""
|
||||||
|
if getattr(client, "phones", None):
|
||||||
|
for p in client.phones: # type: ignore[attr-defined]
|
||||||
|
rows.append((name, company, p.phone_type or "", p.phone_number or ""))
|
||||||
|
else:
|
||||||
|
rows.append((name, company, "", ""))
|
||||||
|
|
||||||
|
for c0, c1, c2, c3 in rows:
|
||||||
|
pdf.cell(widths[0], 7, c0[:35], border=1)
|
||||||
|
pdf.cell(widths[1], 7, c1[:35], border=1)
|
||||||
|
pdf.cell(widths[2], 7, c2[:18], border=1)
|
||||||
|
pdf.cell(widths[3], 7, c3[:24], border=1)
|
||||||
|
pdf.ln(7)
|
||||||
|
|
||||||
|
logger.info("pdf_phone_book_done", pages=pdf.page_no())
|
||||||
|
return _output_pdf_bytes(pdf)
|
||||||
|
|
||||||
|
|
||||||
|
def build_payments_detailed_pdf(payments: List[Payment]) -> bytes:
|
||||||
|
"""Build a Payments - Detailed PDF grouped by deposit (payment) date.
|
||||||
|
|
||||||
|
Groups by date portion of `payment_date`. Includes per-day totals and overall total.
|
||||||
|
"""
|
||||||
|
logger.info("pdf_payments_detailed_start", count=len(payments))
|
||||||
|
|
||||||
|
# Group payments by date
|
||||||
|
grouped: Dict[date, List[Payment]] = {}
|
||||||
|
for p in payments:
|
||||||
|
d = p.payment_date.date() if p.payment_date else None
|
||||||
|
if d is None:
|
||||||
|
# Place undated at epoch-ish bucket None-equivalent: skip grouping
|
||||||
|
continue
|
||||||
|
grouped.setdefault(d, []).append(p)
|
||||||
|
|
||||||
|
dates_sorted = sorted(grouped.keys())
|
||||||
|
overall_total = sum((p.amount or 0.0) for p in payments)
|
||||||
|
|
||||||
|
pdf = SimplePDF(title="Payments - Detailed")
|
||||||
|
pdf.add_page()
|
||||||
|
|
||||||
|
pdf.set_font("helvetica", size=10)
|
||||||
|
pdf.cell(0, 6, f"Total Amount: ${overall_total:,.2f}", ln=1)
|
||||||
|
pdf.ln(1)
|
||||||
|
|
||||||
|
for d in dates_sorted:
|
||||||
|
day_items = grouped[d]
|
||||||
|
day_total = sum((p.amount or 0.0) for p in day_items)
|
||||||
|
|
||||||
|
# Section header per date
|
||||||
|
pdf.set_font("helvetica", "B", 11)
|
||||||
|
pdf.cell(0, 7, f"Deposit Date: {d.isoformat()} — Total: ${day_total:,.2f}", ln=1)
|
||||||
|
|
||||||
|
# Table header
|
||||||
|
pdf.set_font("helvetica", "B", 10)
|
||||||
|
headers = ["File #", "Client", "Type", "Description", "Amount"]
|
||||||
|
widths = [28, 50, 18, 80, 18]
|
||||||
|
for h, w in zip(headers, widths):
|
||||||
|
pdf.cell(w, 7, h, border=1)
|
||||||
|
pdf.ln(7)
|
||||||
|
|
||||||
|
pdf.set_font("helvetica", size=10)
|
||||||
|
for p in day_items:
|
||||||
|
file_no = p.case.file_no if p.case else ""
|
||||||
|
client = ""
|
||||||
|
if p.case and p.case.client:
|
||||||
|
client = f"{p.case.client.last_name or ''}, {p.case.client.first_name or ''}".strip(", ")
|
||||||
|
ptype = p.payment_type or ""
|
||||||
|
desc = (p.description or "").replace("\n", " ")
|
||||||
|
amt = f"${(p.amount or 0.0):,.2f}"
|
||||||
|
|
||||||
|
# Row cells
|
||||||
|
pdf.cell(widths[0], 6, file_no[:14], border=1)
|
||||||
|
pdf.cell(widths[1], 6, client[:28], border=1)
|
||||||
|
pdf.cell(widths[2], 6, ptype[:8], border=1)
|
||||||
|
|
||||||
|
# Description as MultiCell: compute remaining width before amount
|
||||||
|
x_before = pdf.get_x()
|
||||||
|
y_before = pdf.get_y()
|
||||||
|
pdf.multi_cell(widths[3], 6, desc[:300], border=1)
|
||||||
|
# Move to amount cell position (right side) aligning with the top of description row
|
||||||
|
x_after = x_before + widths[3] + widths[0] + widths[1] + widths[2]
|
||||||
|
# Reset cursor to top of the description cell's first line row to draw amount
|
||||||
|
pdf.set_xy(x_after, y_before)
|
||||||
|
pdf.cell(widths[4], 6, amt, border=1, align="R")
|
||||||
|
pdf.ln(0) # continue after multicell handled line advance
|
||||||
|
|
||||||
|
pdf.ln(3)
|
||||||
|
|
||||||
|
logger.info("pdf_payments_detailed_done", pages=pdf.page_no())
|
||||||
|
return _output_pdf_bytes(pdf)
|
||||||
|
|
||||||
|
|
||||||
73
app/templates/payments_detailed.html
Normal file
73
app/templates/payments_detailed.html
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Payments - Detailed · Delphi Database{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row g-3 align-items-center mb-3">
|
||||||
|
<div class="col-auto">
|
||||||
|
<h2 class="mb-0">Payments - Detailed</h2>
|
||||||
|
</div>
|
||||||
|
<div class="col ms-auto">
|
||||||
|
<form class="row g-2" method="get">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<input type="date" class="form-control" name="from_date" value="{{ from_date or '' }}" placeholder="From">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<input type="date" class="form-control" name="to_date" value="{{ to_date or '' }}" placeholder="To">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<input type="text" class="form-control" name="file_no" value="{{ file_no or '' }}" placeholder="File #">
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<button class="btn btn-outline-primary" type="submit"><i class="bi bi-search me-1"></i>Filter</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 d-flex justify-content-end gap-2">
|
||||||
|
<a class="btn btn-outline-secondary btn-sm" href="/reports/payments-detailed?format=pdf{% if from_date %}&from_date={{ from_date }}{% endif %}{% if to_date %}&to_date={{ to_date }}{% endif %}{% if file_no %}&file_no={{ file_no | urlencode }}{% endif %}"><i class="bi bi-file-earmark-pdf me-1"></i>Download PDF</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if groups and groups|length > 0 %}
|
||||||
|
{% for group in groups %}
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header d-flex align-items-center">
|
||||||
|
<div class="fw-semibold">Deposit Date: {{ group.date }}</div>
|
||||||
|
<div class="ms-auto">Daily total: ${{ '%.2f'|format(group.total) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm align-middle mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th style="width: 140px;">File #</th>
|
||||||
|
<th>Client</th>
|
||||||
|
<th style="width: 120px;">Type</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th class="text-end" style="width: 160px;">Amount</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for p in group.items %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ p.case.file_no if p.case else '' }}</td>
|
||||||
|
<td>{% set client = p.case.client if p.case else None %}{% if client %}{{ client.last_name }}, {{ client.first_name }}{% else %}<span class="text-muted">—</span>{% endif %}</td>
|
||||||
|
<td>{{ p.payment_type or '' }}</td>
|
||||||
|
<td>{{ p.description or '' }}</td>
|
||||||
|
<td class="text-end">${{ '%.2f'|format(p.amount or 0) }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
<div class="text-end fw-semibold">Overall total: ${{ '%.2f'|format(overall_total or 0) }}</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-muted">No payments for selected filters.</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
@@ -13,6 +13,9 @@
|
|||||||
<a class="btn btn-outline-secondary btn-sm" href="/reports/phone-book?format=csv{% for id in client_ids %}&client_ids={{ id }}{% endfor %}{% if q %}&q={{ q | urlencode }}{% endif %}">
|
<a class="btn btn-outline-secondary btn-sm" href="/reports/phone-book?format=csv{% for id in client_ids %}&client_ids={{ id }}{% endfor %}{% if q %}&q={{ q | urlencode }}{% endif %}">
|
||||||
<i class="bi bi-filetype-csv me-1"></i>Download CSV
|
<i class="bi bi-filetype-csv me-1"></i>Download CSV
|
||||||
</a>
|
</a>
|
||||||
|
<a class="btn btn-outline-secondary btn-sm" href="/reports/phone-book?format=pdf{% for id in client_ids %}&client_ids={{ id }}{% endfor %}{% if q %}&q={{ q | urlencode }}{% endif %}">
|
||||||
|
<i class="bi bi-file-earmark-pdf me-1"></i>Download PDF
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ jinja2==3.1.2
|
|||||||
aiofiles==23.2.1
|
aiofiles==23.2.1
|
||||||
structlog==24.1.0
|
structlog==24.1.0
|
||||||
itsdangerous==2.2.0
|
itsdangerous==2.2.0
|
||||||
|
fpdf2>=2.7,<3
|
||||||
|
|||||||
Reference in New Issue
Block a user