File size: 5,732 Bytes
bf8519f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
"""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"],
    }