Coverage for domain/report_builder.py: 89%
138 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-04 14:06 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-04 14:06 +0000
1"""Pure Report Builder engine: field/metric catalogs, validation, filtering,
2grouping, sorting, totals, and CSV export (add-report-builder-api).
4Port of arive-spec's report-builder logic (`src/domain/reports.ts`,
5`reportRows`/`validateReportDef`/`runReport`/`reportToCsv` in `src/app.ts`), now
6operating on plain row dicts rather than mock loan refs. Pure, side-effect-free.
7Zero dependencies on Django or external databases.
8"""
10from __future__ import annotations
12from dataclasses import dataclass, field
13from decimal import ROUND_HALF_UP, Decimal
14from typing import Any, cast
16from domain.result import AppError, Result, err, ok
18_CENTS = Decimal("0.01")
21@dataclass(frozen=True)
22class ReportField:
23 key: str
24 label: str
25 type: str # "text" | "currency" | "percent" | "number" | "date"
26 filterable: bool
27 groupable: bool
30# The 17-field catalog (reports.mock.ts REPORT_FIELDS).
31REPORT_FIELDS: tuple[ReportField, ...] = (
32 ReportField("loan_number", "Loan #", "text", True, False),
33 ReportField("borrower_name", "Borrower", "text", True, False),
34 ReportField("stage", "Stage", "text", True, True),
35 ReportField("purpose", "Purpose", "text", True, True),
36 ReportField("occupancy", "Occupancy", "text", True, True),
37 ReportField("state_code", "State", "text", True, True),
38 ReportField("county", "County", "text", True, True),
39 ReportField("loan_officer", "Loan officer", "text", True, True),
40 ReportField("amount", "Loan amount", "currency", True, False),
41 ReportField("value", "Property value", "currency", True, False),
42 ReportField("ltv", "LTV", "percent", True, False),
43 ReportField("rate", "Rate", "percent", True, False),
44 ReportField("lock", "Lock status", "text", True, True),
45 ReportField("conditions", "Conditions", "number", True, False),
46 ReportField("tasks", "Open tasks", "number", True, False),
47 ReportField("month", "Month opened", "text", True, True),
48 ReportField("created_at", "Created", "date", True, False),
49)
50_FIELD_BY_KEY: dict[str, ReportField] = {f.key: f for f in REPORT_FIELDS}
53@dataclass(frozen=True)
54class Metric:
55 key: str
56 label: str
57 type: str
60METRICS: tuple[Metric, ...] = (
61 Metric("count", "Count", "number"),
62 Metric("volume", "Total volume", "currency"),
63 Metric("avg_amount", "Avg loan amount", "currency"),
64 Metric("avg_rate", "Avg rate", "percent"),
65 Metric("avg_ltv", "Avg LTV", "percent"),
66)
67_METRIC_BY_KEY: dict[str, Metric] = {m.key: m for m in METRICS}
68DEFAULT_METRICS = ("count", "volume", "avg_rate")
71@dataclass
72class ReportFilter:
73 field: str
74 op: str # "eq" | "neq" | "contains" | "gte" | "lte"
75 value: Any
78@dataclass
79class ReportDefInput:
80 name: str
81 columns: list[str] = field(default_factory=list)
82 filters: list[ReportFilter] = field(default_factory=list)
83 group_by: str | None = None
84 metrics: list[str] = field(default_factory=list)
85 sort: dict | None = None # {"field": str, "dir": "asc" | "desc"}
88@dataclass
89class ReportResult:
90 mode: str # "detail" | "grouped"
91 columns: list[dict]
92 rows: list[dict]
93 totals: dict | None
94 row_count: int
97def validate_report_def(d: ReportDefInput) -> Result:
98 """Port of arive-spec `validateReportDef`."""
99 errors: list[AppError] = []
100 if not d.name or not d.name.strip():
101 errors.append(AppError("VALIDATION", "Report name is required.", "name"))
102 if d.group_by:
103 gf = _FIELD_BY_KEY.get(d.group_by)
104 if gf is None or not gf.groupable:
105 errors.append(AppError("VALIDATION", f'"{d.group_by}" can\'t be grouped.', "group_by"))
106 elif not d.columns:
107 errors.append(
108 AppError("VALIDATION", "Pick at least one column (or a group-by).", "columns")
109 )
110 for c in d.columns:
111 if c not in _FIELD_BY_KEY:
112 errors.append(AppError("VALIDATION", f'Unknown column "{c}".', "columns"))
113 for f in d.filters:
114 ff = _FIELD_BY_KEY.get(f.field)
115 if ff is None or not ff.filterable: 115 ↛ 113line 115 didn't jump to line 113 because the condition on line 115 was always true
116 errors.append(AppError("VALIDATION", f'"{f.field}" can\'t be filtered.', "filters"))
117 return err(*errors) if errors else ok()
120def filter_matches(row: dict, f: ReportFilter) -> bool:
121 """Filter match; gte/lte on a date field compares the raw ISO strings."""
122 v = row.get(f.field)
123 is_date = (_FIELD_BY_KEY.get(f.field) or ReportField("", "", "", False, False)).type == "date"
124 if f.op == "eq":
125 return str(v if v is not None else "").lower() == str(f.value).lower()
126 if f.op == "neq":
127 return str(v if v is not None else "").lower() != str(f.value).lower()
128 if f.op == "contains":
129 return str(f.value).lower() in str(v if v is not None else "").lower()
130 if f.op == "gte":
131 if is_date: 131 ↛ 132line 131 didn't jump to line 132 because the condition on line 131 was never true
132 return str(v if v is not None else "") >= str(f.value)
133 return v is not None and Decimal(str(v)) >= Decimal(str(f.value))
134 if f.op == "lte": 134 ↛ 138line 134 didn't jump to line 138 because the condition on line 134 was always true
135 if is_date: 135 ↛ 137line 135 didn't jump to line 137 because the condition on line 135 was always true
136 return v is not None and str(v) <= str(f.value)
137 return v is not None and Decimal(str(v)) <= Decimal(str(f.value))
138 return False
141def _nums(rows: list[dict], key: str) -> list[Decimal]:
142 return [Decimal(str(r.get(key) or 0)) for r in rows]
145def metric_value(metric: str, rows: list[dict]) -> Decimal:
146 if metric == "count":
147 return Decimal(len(rows))
148 if not rows:
149 return Decimal("0")
150 if metric == "volume":
151 return sum(_nums(rows, "amount"), Decimal("0"))
152 if metric == "avg_amount":
153 return (sum(_nums(rows, "amount"), Decimal("0")) / len(rows)).quantize(
154 Decimal("1"), rounding=ROUND_HALF_UP
155 )
156 if metric == "avg_rate":
157 return (sum(_nums(rows, "rate"), Decimal("0")) / len(rows)).quantize(
158 _CENTS, rounding=ROUND_HALF_UP
159 )
160 if metric == "avg_ltv": 160 ↛ 164line 160 didn't jump to line 164 because the condition on line 160 was always true
161 return (sum(_nums(rows, "ltv"), Decimal("0")) / len(rows)).quantize(
162 _CENTS, rounding=ROUND_HALF_UP
163 )
164 raise ValueError(f"Unknown metric: {metric}")
167def _column(key: str) -> dict:
168 f = _FIELD_BY_KEY[key]
169 return {"key": f.key, "label": f.label, "type": f.type}
172def _metric_column(key: str) -> dict:
173 m = _METRIC_BY_KEY[key]
174 return {"key": m.key, "label": m.label, "type": m.type}
177def run_report(d: ReportDefInput, rows: list[dict]) -> ReportResult:
178 """Port of arive-spec `runReport`: filter, then group-with-metrics or
179 detail-with-columns, then sort, then totals.
181 Assumes ``d`` already passed ``validate_report_def`` — callers raise on an
182 invalid definition before reaching this function, same as every other pure
183 domain module in this backend (e.g. ``domain.validation``).
184 """
185 data = [r for r in rows if all(filter_matches(r, f) for f in d.filters)]
187 if d.group_by:
188 metrics = d.metrics or list(DEFAULT_METRICS)
189 groups: dict[str, list[dict]] = {}
190 for r in data:
191 key = str(r.get(d.group_by) or "—")
192 groups.setdefault(key, []).append(r)
194 group_rows = [
195 {d.group_by: key, **{m: metric_value(m, rs) for m in metrics}}
196 for key, rs in groups.items()
197 ]
198 sort_key = (
199 d.sort["field"]
200 if d.sort and d.sort.get("field") in metrics
201 else ("volume" if "volume" in metrics else metrics[0])
202 )
203 asc = bool(d.sort and d.sort.get("dir") == "asc")
204 group_rows.sort(key=lambda r: cast(Decimal, r.get(sort_key) or Decimal("0")), reverse=not asc)
206 return ReportResult(
207 mode="grouped",
208 columns=[_column(d.group_by), *[_metric_column(m) for m in metrics]],
209 rows=group_rows,
210 totals={d.group_by: "Total", **{m: metric_value(m, data) for m in metrics}},
211 row_count=len(data),
212 )
214 if d.sort and d.sort.get("field") in _FIELD_BY_KEY: 214 ↛ 215line 214 didn't jump to line 215 because the condition on line 214 was never true
215 field_key = d.sort["field"]
216 asc = d.sort.get("dir") == "asc"
217 numeric = _FIELD_BY_KEY[field_key].type in ("number", "currency", "percent")
219 def sort_key(r: dict):
220 v = r.get(field_key)
221 if numeric:
222 return Decimal(str(v)) if v is not None else Decimal("0")
223 return str(v) if v is not None else ""
225 data = sorted(data, key=sort_key, reverse=not asc)
227 total_amount = sum(_nums(data, "amount"), Decimal("0")) if "amount" in d.columns else None
228 return ReportResult(
229 mode="detail",
230 columns=[_column(c) for c in d.columns],
231 rows=[{c: r.get(c) for c in d.columns} for r in data],
232 totals=(
233 {d.columns[0]: "Total", "amount": total_amount} if total_amount is not None else None
234 ),
235 row_count=len(data),
236 )
239def _csv_escape(v: Any) -> str:
240 s = "" if v is None else str(v)
241 if any(ch in s for ch in (",", '"', "\n")):
242 return '"' + s.replace('"', '""') + '"'
243 return s
246def to_csv(result: ReportResult) -> str:
247 """Port of arive-spec `reportToCsv`: header labels, rows, then totals."""
248 lines = [",".join(_csv_escape(c["label"]) for c in result.columns)]
249 for row in result.rows:
250 lines.append(",".join(_csv_escape(row.get(c["key"])) for c in result.columns))
251 if result.totals:
252 lines.append(
253 ",".join(_csv_escape(result.totals.get(c["key"], "")) for c in result.columns)
254 )
255 return "\n".join(lines)