changes
This commit is contained in:
237
app/services/statement_generation.py
Normal file
237
app/services/statement_generation.py
Normal file
@@ -0,0 +1,237 @@
|
||||
"""
|
||||
Statement generation helpers extracted from API layer.
|
||||
|
||||
These functions encapsulate database access, validation, and file generation
|
||||
for billing statements so API endpoints can remain thin controllers.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional, Tuple, List, Dict, Any
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone, date
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from app.models.files import File
|
||||
from app.models.ledger import Ledger
|
||||
|
||||
|
||||
def _safe_round(value: Optional[float]) -> float:
|
||||
try:
|
||||
return round(float(value or 0.0), 2)
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
|
||||
def parse_period_month(period: Optional[str]) -> Optional[Tuple[date, date]]:
|
||||
"""Parse period in the form YYYY-MM and return (start_date, end_date) inclusive.
|
||||
Returns None when period is not provided or invalid.
|
||||
"""
|
||||
if not period:
|
||||
return None
|
||||
import re as _re
|
||||
m = _re.fullmatch(r"(\d{4})-(\d{2})", str(period).strip())
|
||||
if not m:
|
||||
return None
|
||||
year = int(m.group(1))
|
||||
month = int(m.group(2))
|
||||
if month < 1 or month > 12:
|
||||
return None
|
||||
from calendar import monthrange
|
||||
last_day = monthrange(year, month)[1]
|
||||
return date(year, month, 1), date(year, month, last_day)
|
||||
|
||||
|
||||
def render_statement_html(
|
||||
*,
|
||||
file_no: str,
|
||||
client_name: Optional[str],
|
||||
matter: Optional[str],
|
||||
as_of_iso: str,
|
||||
period: Optional[str],
|
||||
totals: Dict[str, float],
|
||||
unbilled_entries: List[Dict[str, Any]],
|
||||
) -> str:
|
||||
"""Create a simple, self-contained HTML statement string.
|
||||
|
||||
The API constructs pydantic models for totals and entries; this helper accepts
|
||||
primitive dicts to avoid coupling to API types.
|
||||
"""
|
||||
|
||||
def _fmt(val: Optional[float]) -> str:
|
||||
try:
|
||||
return f"{float(val or 0):.2f}"
|
||||
except Exception:
|
||||
return "0.00"
|
||||
|
||||
rows: List[str] = []
|
||||
for e in unbilled_entries:
|
||||
date_val = e.get("date")
|
||||
date_str = date_val.isoformat() if hasattr(date_val, "isoformat") else (date_val or "")
|
||||
rows.append(
|
||||
f"<tr><td>{date_str}</td><td>{e.get('t_code','')}</td><td>{str(e.get('description','')).replace('<','<').replace('>','>')}</td>"
|
||||
f"<td style='text-align:right'>{_fmt(e.get('quantity'))}</td><td style='text-align:right'>{_fmt(e.get('rate'))}</td><td style='text-align:right'>{_fmt(e.get('amount'))}</td></tr>"
|
||||
)
|
||||
rows_html = "\n".join(rows) if rows else "<tr><td colspan='6' style='text-align:center;color:#666'>No unbilled entries</td></tr>"
|
||||
|
||||
period_html = f"<div><strong>Period:</strong> {period}</div>" if period else ""
|
||||
|
||||
html = f"""
|
||||
<!DOCTYPE html>
|
||||
<html lang=\"en\">
|
||||
<head>
|
||||
<meta charset=\"utf-8\" />
|
||||
<title>Statement {file_no}</title>
|
||||
<style>
|
||||
body {{ font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica, Arial, sans-serif; margin: 24px; }}
|
||||
h1 {{ margin: 0 0 8px 0; }}
|
||||
.meta {{ color: #444; margin-bottom: 16px; }}
|
||||
table {{ border-collapse: collapse; width: 100%; }}
|
||||
th, td {{ border: 1px solid #ddd; padding: 8px; font-size: 14px; }}
|
||||
th {{ background: #f6f6f6; text-align: left; }}
|
||||
.totals {{ margin: 16px 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 8px; }}
|
||||
.totals div {{ background: #fafafa; border: 1px solid #eee; padding: 8px; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Statement</h1>
|
||||
<div class=\"meta\">\n <div><strong>File:</strong> {file_no}</div>\n <div><strong>Client:</strong> {client_name or ''}</div>\n <div><strong>Matter:</strong> {matter or ''}</div>\n <div><strong>As of:</strong> {as_of_iso}</div>\n {period_html}
|
||||
</div>
|
||||
|
||||
<div class=\"totals\">\n <div><strong>Charges (billed)</strong><br/>${_fmt(totals.get('charges_billed'))}</div>\n <div><strong>Charges (unbilled)</strong><br/>${_fmt(totals.get('charges_unbilled'))}</div>\n <div><strong>Charges (total)</strong><br/>${_fmt(totals.get('charges_total'))}</div>\n <div><strong>Payments</strong><br/>${_fmt(totals.get('payments'))}</div>\n <div><strong>Trust balance</strong><br/>${_fmt(totals.get('trust_balance'))}</div>\n <div><strong>Current balance</strong><br/>${_fmt(totals.get('current_balance'))}</div>
|
||||
</div>
|
||||
|
||||
<h2>Unbilled Entries</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Code</th>
|
||||
<th>Description</th>
|
||||
<th style=\"text-align:right\">Qty</th>
|
||||
<th style=\"text-align:right\">Rate</th>
|
||||
<th style=\"text-align:right\">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows_html}
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
return html
|
||||
|
||||
|
||||
def generate_single_statement(
|
||||
file_no: str,
|
||||
period: Optional[str],
|
||||
db: Session,
|
||||
) -> Dict[str, Any]:
|
||||
"""Generate a statement for a single file and write an HTML artifact to exports/.
|
||||
|
||||
Returns a dict matching the "GeneratedStatementMeta" schema expected by the API layer.
|
||||
Raises HTTPException on not found or internal errors.
|
||||
"""
|
||||
file_obj = (
|
||||
db.query(File)
|
||||
.options(joinedload(File.owner))
|
||||
.filter(File.file_no == file_no)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not file_obj:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"File {file_no} not found",
|
||||
)
|
||||
|
||||
# Optional period filtering (YYYY-MM)
|
||||
date_range = parse_period_month(period)
|
||||
q = db.query(Ledger).filter(Ledger.file_no == file_no)
|
||||
if date_range:
|
||||
start_date, end_date = date_range
|
||||
q = q.filter(Ledger.date >= start_date).filter(Ledger.date <= end_date)
|
||||
entries: List[Ledger] = q.all()
|
||||
|
||||
CHARGE_TYPES = {"2", "3", "4"}
|
||||
charges_billed = sum(e.amount for e in entries if e.t_type in CHARGE_TYPES and e.billed == "Y")
|
||||
charges_unbilled = sum(e.amount for e in entries if e.t_type in CHARGE_TYPES and e.billed != "Y")
|
||||
charges_total = charges_billed + charges_unbilled
|
||||
payments_total = sum(e.amount for e in entries if e.t_type == "5")
|
||||
trust_balance = file_obj.trust_bal or 0.0
|
||||
current_balance = charges_total - payments_total
|
||||
|
||||
unbilled_entries: List[Dict[str, Any]] = [
|
||||
{
|
||||
"id": e.id,
|
||||
"date": e.date,
|
||||
"t_code": e.t_code,
|
||||
"t_type": e.t_type,
|
||||
"description": e.note,
|
||||
"quantity": e.quantity or 0.0,
|
||||
"rate": e.rate or 0.0,
|
||||
"amount": e.amount,
|
||||
}
|
||||
for e in entries
|
||||
if e.t_type in CHARGE_TYPES and e.billed != "Y"
|
||||
]
|
||||
|
||||
client_name: Optional[str] = None
|
||||
if file_obj.owner:
|
||||
client_name = f"{file_obj.owner.first or ''} {file_obj.owner.last}".strip()
|
||||
|
||||
as_of_iso = datetime.now(timezone.utc).isoformat()
|
||||
totals_dict: Dict[str, float] = {
|
||||
"charges_billed": _safe_round(charges_billed),
|
||||
"charges_unbilled": _safe_round(charges_unbilled),
|
||||
"charges_total": _safe_round(charges_total),
|
||||
"payments": _safe_round(payments_total),
|
||||
"trust_balance": _safe_round(trust_balance),
|
||||
"current_balance": _safe_round(current_balance),
|
||||
}
|
||||
|
||||
# Render HTML
|
||||
html = render_statement_html(
|
||||
file_no=file_no,
|
||||
client_name=client_name or None,
|
||||
matter=file_obj.regarding,
|
||||
as_of_iso=as_of_iso,
|
||||
period=period,
|
||||
totals=totals_dict,
|
||||
unbilled_entries=unbilled_entries,
|
||||
)
|
||||
|
||||
# Ensure exports directory and write file
|
||||
exports_dir = Path("exports")
|
||||
try:
|
||||
exports_dir.mkdir(exist_ok=True)
|
||||
except Exception:
|
||||
# Best-effort: if cannot create, bubble up internal error
|
||||
raise HTTPException(status_code=500, detail="Unable to create exports directory")
|
||||
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S_%f")
|
||||
safe_file_no = str(file_no).replace("/", "_").replace("\\", "_")
|
||||
filename = f"statement_{safe_file_no}_{timestamp}.html"
|
||||
export_path = exports_dir / filename
|
||||
html_bytes = html.encode("utf-8")
|
||||
with open(export_path, "wb") as f:
|
||||
f.write(html_bytes)
|
||||
|
||||
size = export_path.stat().st_size
|
||||
|
||||
return {
|
||||
"file_no": file_no,
|
||||
"client_name": client_name or None,
|
||||
"as_of": as_of_iso,
|
||||
"period": period,
|
||||
"totals": totals_dict,
|
||||
"unbilled_count": len(unbilled_entries),
|
||||
"export_path": str(export_path),
|
||||
"filename": filename,
|
||||
"size": size,
|
||||
"content_type": "text/html",
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user