"""harness/tables.py — a topic-backed TABLE for the grid (CG-2). The grid's first table (customers) is built from `modules.customer_data.pool()`: one row per customer, the whole book shipped as component args, filtered client-side. That works at 1,550 rows and does not work at line grain — `sales_lines` is 201,558 rows / ~89 MB. This module builds the SECOND table: rows come from `semantic.store_rows`, so the fetch is WINDOWED and the filtering is SQL. Its whole job is to hand the grid a payload in the same host-neutral shape the React component already speaks, plus the one thing a windowed table must carry that a whole-book table never needed: counts = {matched, total, shown, windowed} `matched` and `total` come from their own queries over the FULL scope; `shown` is the size of this window. The component renders "showing SHOWN of MATCHED (filtered from TOTAL)" from those numbers and NEVER from `rows.length` — which is the difference between an honest window and a silent `[:N]` wearing a total ([[no-unverifiable-aggregates]]). The field CONTRACT is derived from the topic, not hand-written: labels and types come from the model's dims and metrics, so a new metric cannot arrive as an untyped, unlabelled column and the grid can never disagree with the semantic layer about what a column means. """ from harness import semantic as _sem #: Topic-backed tables the grid can open. `key` is the storage key its saved views live under — #: separate from the customer table's, so the two tables' views can never collide. TOPIC_TABLES = { "sales_lines": { "label": "Sales lines", "storage_key": "sales_lines_table_workspace", # the columns worth defaulting to visible, in order; everything else stays available "default_visible": ["date", "order_partner", "product", "category", "state", "agent", "team", "revenue", "units", "margin"], # a line-grain table is unusable sorted by nothing in particular "default_sorts": [{"colId": "date", "dir": "desc"}], }, } #: how many rows one window carries. Small enough that the payload stays a few hundred KB at #: ~440 B/row, large enough to scroll. The honest counts make the window visible rather than #: silent, so this is a performance knob and not a correctness one. PAGE_ROWS = 200 def _labels(topic): """colId -> human label, taken from the MODEL (dim labels, metric labels).""" t = _sem.topics()[topic] out = {} for key, d in ((t.get("store") or {}).get("dims") or {}).items(): out[key] = d.get("label") or key.replace("_", " ").title() out[f"{key}_id"] = f"{out[key]} id" out["date"] = "Date" for k, m in _sem.metrics().items(): if m.get("topic") == topic: out[k] = m.get("label") or k return out def table_fields(topic): """The grid field contract for a topic, derived from the semantic model. Every column is `source='odoo'` — read-only. A line is a fact from the ERP; unlike the customer table there is no overlay stratum here (notes/tags hang off a CUSTOMER, not off an individual order line), so the two-strata split simply does not arise. """ cfg = TOPIC_TABLES[topic] cols = _sem.store_columns(topic, grain="row") labels = _labels(topic) order = [k for k in cfg["default_visible"] if k in cols] order += [k for k in cols if k not in order] return [{ "key": k, "label": labels.get(k, k.replace("_", " ").title()), "type": cols[k]["type"], "source": "odoo", "default": k in cfg["default_visible"], } for k in order] def table_payload(topic, config=None, offset=0, limit=None, team_id=None, date_from=None, date_to=None): """Fetch ONE window of a topic-backed table plus its scope-wide counts and totals. `config` is a grid ViewConfig (filters / filterConj / sorts / search) — the same shape the customer table persists, so a saved view means the same thing on either table. Returns the component payload: {fields, rows, counts, aggregates, storageKey}. """ if topic not in TOPIC_TABLES: raise _sem.ModelError(f"no topic-backed table registered for {topic!r}") cfg = TOPIC_TABLES[topic] config = config or {} limit = PAGE_ROWS if limit is None else limit got = _sem.store_rows( topic, filter_tree=config.get("filters") or [], filter_conj=config.get("filterConj") or "and", search=config.get("search") or None, sorts=config.get("sorts") or cfg["default_sorts"], member_ids=config.get("memberPids") or None, limit=limit, offset=offset, team_id=team_id, date_from=date_from, date_to=date_to, ) rows = [] for i, r in enumerate(got["rows"]): # `pid` is the grid's row identity contract (selection, detail, overlay all key on it). # At line grain that identity is the order LINE id, which store_rows returns as _rid. row = {k: v for k, v in r.items() if k != "_rid"} row["pid"] = r["_rid"] rows.append(row) return { "fields": table_fields(topic), "rows": rows, # THE honest window. `matched`/`total` are scope-wide queries; `shown` is this window. # A caller must never recompute `matched` from len(rows). "counts": { "shown": len(rows), "matched": got["row_count"], "total": got["total_count"], "windowed": True, }, "aggregates": got["aggregates"], # over the whole FILTERED scope, not the window "storageKey": cfg["storage_key"], "label": cfg["label"], "window": got["window"], }