Deploy AIOS web (React glide grid + FastAPI slice)
Browse filesThis view is limited to 50 files because it contains too many changes. Β See raw diff
- RELEASES.json +1 -1
- VERSION +1 -1
- api/ai_enrich.py +96 -0
- api/ai_review.py +69 -25
- api/main.py +13 -0
- api/odoo_relational.py +27 -2
- api/providers.py +138 -11
- api/routes_admin.py +0 -0
- api/routes_geo.py +369 -0
- api/routes_nav.py +0 -0
- api/routes_products.py +200 -8
- api/routes_script_views.py +439 -329
- api/routes_tables.py +189 -5
- platform/aios_grid.py +394 -5
- platform/aios_grid_fields.json +548 -534
- platform/core/grid_events.py +31 -1
- platform/core/user_tables.py +46 -0
- platform/core/users.py +532 -525
- platform/harness/datastore.py +0 -0
- platform/harness/semantic.py +0 -0
- platform/model/metrics/sales.yml +149 -108
- platform/model/metrics/stock.yml +57 -0
- platform/model/topics/odoo_agents.yml +32 -0
- platform/model/topics/odoo_products.yml +51 -0
- platform/model/topics/sales_lines.yml +13 -0
- platform/model/topics/stock_moves.yml +72 -0
- platform/modules/agent.py +385 -277
- platform/modules/product_data.py +411 -0
- platform/modules/products.py +172 -0
- requirements.txt +55 -47
- web/index.html +46 -14
- web/src/App.tsx +41 -30
- web/src/account/SubscriptionPage.tsx +49 -47
- web/src/account/UsagePage.tsx +297 -297
- web/src/account/account.css +372 -372
- web/src/account/accountApi.ts +188 -188
- web/src/customer-grid/CatalogView.tsx +0 -0
- web/src/customer-grid/ColumnMenu.tsx +0 -0
- web/src/customer-grid/CustomerGrid.tsx +0 -0
- web/src/customer-grid/GridChat.tsx +512 -0
- web/src/customer-grid/MapView.tsx +0 -0
- web/src/customer-grid/ScriptViewPanel.tsx +85 -1
- web/src/customer-grid/Toolbar.tsx +10 -0
- web/src/customer-grid/catalog.css +91 -0
- web/src/customer-grid/chatDock.css +52 -0
- web/src/customer-grid/gridChat.css +294 -0
- web/src/customer-grid/gridChat.ts +461 -0
- web/src/customer-grid/map.css +209 -0
- web/src/customer-grid/mapProjection.ts +677 -632
- web/src/customer-grid/scriptView.css +509 -479
RELEASES.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
{
|
| 2 |
-
"current": "
|
| 3 |
"releases": [
|
| 4 |
{
|
| 5 |
"version": "v29",
|
|
|
|
| 1 |
{
|
| 2 |
+
"current": "2de35b7",
|
| 3 |
"releases": [
|
| 4 |
{
|
| 5 |
"version": "v29",
|
VERSION
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
|
|
|
|
| 1 |
+
2de35b7
|
api/ai_enrich.py
CHANGED
|
@@ -314,6 +314,102 @@ def _ask(provider, model, prompt, max_tokens, timeout):
|
|
| 314 |
return '', None, f"{provider['name']} failed: {type(exc).__name__}"
|
| 315 |
|
| 316 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 317 |
def on_change_fields(defn, changed_keys):
|
| 318 |
"""Which `ai_enrich` columns in this table want a run because one of their inputs moved.
|
| 319 |
|
|
|
|
| 314 |
return '', None, f"{provider['name']} failed: {type(exc).__name__}"
|
| 315 |
|
| 316 |
|
| 317 |
+
# ===========================================================================
|
| 318 |
+
# W37-T37 (R4 / C7) -- THE GEOCODE FIELD's pure half: turning a record into ONE query string.
|
| 319 |
+
#
|
| 320 |
+
# β WHY IT IS HERE AND NOT IN `routes_geo.py`. This module's contract, stated in its own header,
|
| 321 |
+
# is that everything in it is a pure function of data it is handed -- which is what lets a gate
|
| 322 |
+
# exercise it with no network, no store and no server. Composing an address is exactly that shape.
|
| 323 |
+
# `routes_geo.py` owns the part that cannot be pure: the outbound call, the throttle and the cache.
|
| 324 |
+
#
|
| 325 |
+
# β WHY IT COMPOSES AT ALL, rather than sending the one column the field names. The field config
|
| 326 |
+
# is `{addressField, country?}` -- a single column -- but a customers table stores an address across
|
| 327 |
+
# six of them. B MEASURED the fill rates on the real 3,641-row mirror (W37-T11):
|
| 328 |
+
#
|
| 329 |
+
# street 96.2% Β· city 96.0% Β· zip 94.6% Β· country 95.6% Β· state 92.0% Β· street2 5.2%
|
| 330 |
+
# street AND city together: 95.9% <- the pair this feature actually needs
|
| 331 |
+
#
|
| 332 |
+
# Sending `street` alone would ask a global geocoder to place "123 Main Street" with no town, which
|
| 333 |
+
# resolves confidently and often wrongly. So the named column LEADS and the conventional siblings
|
| 334 |
+
# follow, which is also how a person would write the address down.
|
| 335 |
+
# ===========================================================================
|
| 336 |
+
|
| 337 |
+
#: The sibling columns appended after the named one, in the order an address is spoken. β A LIST
|
| 338 |
+
#: rather than a formatted string: a template would have to decide what to do about the ~4% of rows
|
| 339 |
+
#: missing any one part, and the answer is always "leave it out", never "leave a gap".
|
| 340 |
+
GEOCODE_PARTS = ('street2', 'city', 'state', 'zip', 'postal_code', 'country')
|
| 341 |
+
|
| 342 |
+
#: β B's SECOND MEASURED TRAP. Odoo serves `state` as its DISPLAY name, country code included:
|
| 343 |
+
#: `New York (US)`, `British Columbia (CA)`. A naive join yields "SCARSDALE, New York (US)", and the
|
| 344 |
+
#: parenthetical is noise to a geocoder at best. Stripped here, once, rather than at each caller.
|
| 345 |
+
_PAREN_TAIL = re.compile(r'\s*\([^)]*\)\s*$')
|
| 346 |
+
|
| 347 |
+
|
| 348 |
+
def geocode_query(row, cfg, seen_parts=None):
|
| 349 |
+
"""One record plus a geocode field's config -> the query string, or '' when there is nothing.
|
| 350 |
+
|
| 351 |
+
β RETURNS '' RATHER THAN A PARTIAL GUESS when the named column is empty. B measured ~150 of
|
| 352 |
+
3,641 customers with no street at all, and the honest UI for those is "no address on file", not
|
| 353 |
+
a request that spends a second of a shared service's budget to place a bare postcode.
|
| 354 |
+
|
| 355 |
+
β `seen_parts=None` RESOLVES `GEOCODE_PARTS` AT CALL TIME, and that is not style. It was
|
| 356 |
+
`seen_parts=GEOCODE_PARTS`, which binds the tuple once at import, so the module constant stopped
|
| 357 |
+
being the live answer the moment anything wanted to vary it: the gate's own negative control set
|
| 358 |
+
`ai_enrich.GEOCODE_PARTS = ()` and the function carried on composing the full address. A default
|
| 359 |
+
argument that captures a module constant is a SECOND COPY of it with an earlier timestamp, and
|
| 360 |
+
the copy is the one that runs ([[a-constant-two-features-share]]). Caught by that control."""
|
| 361 |
+
if not isinstance(row, dict) or not isinstance(cfg, dict):
|
| 362 |
+
return ''
|
| 363 |
+
if seen_parts is None:
|
| 364 |
+
seen_parts = GEOCODE_PARTS
|
| 365 |
+
key = str(cfg.get('addressField') or '').strip()
|
| 366 |
+
if not key:
|
| 367 |
+
return ''
|
| 368 |
+
lead = _clean_part(row.get(key))
|
| 369 |
+
if not lead:
|
| 370 |
+
return ''
|
| 371 |
+
parts = [lead]
|
| 372 |
+
for k in seen_parts:
|
| 373 |
+
if k == key:
|
| 374 |
+
continue
|
| 375 |
+
v = _clean_part(row.get(k))
|
| 376 |
+
# β The dedupe is not tidiness. `country` is often both the field's own value and already
|
| 377 |
+
# present in the street line on an imported record, and "USA, USA" measurably degrades a
|
| 378 |
+
# free-form geocoder's confidence.
|
| 379 |
+
if v and v.lower() not in {p.lower() for p in parts}:
|
| 380 |
+
parts.append(v)
|
| 381 |
+
return ', '.join(parts)
|
| 382 |
+
|
| 383 |
+
|
| 384 |
+
def _clean_part(v):
|
| 385 |
+
"""One address component, trimmed and stripped of a trailing country parenthetical."""
|
| 386 |
+
if v is None:
|
| 387 |
+
return ''
|
| 388 |
+
s = re.sub(r'\s+', ' ', str(v)).strip()
|
| 389 |
+
if not s or s.lower() in ('false', 'none', 'null'):
|
| 390 |
+
return ''
|
| 391 |
+
return _PAREN_TAIL.sub('', s).strip()
|
| 392 |
+
|
| 393 |
+
|
| 394 |
+
def geocode_plan(rows, cfg, coord_key=None, overwrite=False):
|
| 395 |
+
"""Which records a run would actually ask about, and why the rest are being left out.
|
| 396 |
+
|
| 397 |
+
β THIS IS THE R3 GUARD IN ITS ARITHMETIC FORM. It is handed the records a PERSON chose; it
|
| 398 |
+
never reads a table. `skipped` is returned rather than swallowed so the surface can say "142 to
|
| 399 |
+
look up, 9 have no address, 31 already have coordinates" instead of a bare number."""
|
| 400 |
+
run, skipped = [], {}
|
| 401 |
+
for rid, row in (rows or {}).items():
|
| 402 |
+
q = geocode_query(row, cfg)
|
| 403 |
+
if not q:
|
| 404 |
+
skipped['no_address'] = skipped.get('no_address', 0) + 1
|
| 405 |
+
continue
|
| 406 |
+
if coord_key and not overwrite and str((row or {}).get(coord_key) or '').strip():
|
| 407 |
+
skipped['already_located'] = skipped.get('already_located', 0) + 1
|
| 408 |
+
continue
|
| 409 |
+
run.append((str(rid), q))
|
| 410 |
+
return {'run': run, 'skipped': skipped}
|
| 411 |
+
|
| 412 |
+
|
| 413 |
def on_change_fields(defn, changed_keys):
|
| 414 |
"""Which `ai_enrich` columns in this table want a run because one of their inputs moved.
|
| 415 |
|
api/ai_review.py
CHANGED
|
@@ -17,15 +17,23 @@ the work. Nothing here can move a card somewhere the review does not already per
|
|
| 17 |
last rather than absent β it is the quality backstop, not the default. `AIOS_AI_REVIEW_PROVIDER`
|
| 18 |
pins one; `AIOS_AI_MODEL` overrides the model.
|
| 19 |
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
The Messages shape below is the current one: `x-api-key` + `anthropic-version: 2023-06-01`, and
|
| 31 |
`stop_reason: "refusal"` is checked BEFORE reading `content` β a refusal answers HTTP 200 with an
|
|
@@ -41,9 +49,13 @@ import requests
|
|
| 41 |
|
| 42 |
#: The ladder. Order IS the policy (R14) β cheapest capable first, Anthropic last.
|
| 43 |
PROVIDERS = [
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
{"name": "groq", "env": "GROQ_API_KEY", "shape": "openai",
|
| 45 |
"url": "https://api.groq.com/openai/v1/chat/completions",
|
| 46 |
-
"model": "
|
| 47 |
{"name": "cerebras", "env": "CEREBRAS_API_KEY", "shape": "openai",
|
| 48 |
"url": "https://api.cerebras.ai/v1/chat/completions",
|
| 49 |
"model": "gpt-oss-120b"},
|
|
@@ -161,16 +173,35 @@ def _call_openai(p, model, system, user, timeout):
|
|
| 161 |
return str(((choices[0] or {}).get("message") or {}).get("content") or ""), "", body
|
| 162 |
|
| 163 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
def _call_anthropic(p, model, system, user, timeout):
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
# β stop_reason FIRST. A safety refusal is a successful 200 with an EMPTY content list, so
|
| 175 |
# reading content[0] before this check turns a refusal into an IndexError inside a run.
|
| 176 |
# β A refusal IS billed and its body carries `usage`, so the body rides back on this branch too.
|
|
@@ -444,6 +475,9 @@ def draft_flow(*, prompt, catalog, required, triggers, tables, chat=None, timeou
|
|
| 444 |
`routes_automation`'s draft door) are in lane D's fence. C7 says E adds the ledger line here and
|
| 445 |
D asserts it; the two keywords are what D has to pass for the line to be attributable.
|
| 446 |
"""
|
|
|
|
|
|
|
|
|
|
| 447 |
import usage_ledger # noqa: PLC0415
|
| 448 |
text = str(prompt or "").strip()[:MAX_PROMPT_CHARS]
|
| 449 |
if not text:
|
|
@@ -487,13 +521,18 @@ def draft_flow(*, prompt, catalog, required, triggers, tables, chat=None, timeou
|
|
| 487 |
# 402), so without this branch the drafter cannot answer at all.
|
| 488 |
# β NO `effort` ON THIS DOOR β `output_config.effort` errors on Haiku 4.5, the tier this
|
| 489 |
# ladder runs. The parameter is the caller's to send, which is why the wire takes it.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 490 |
try:
|
| 491 |
if p["shape"] == "anthropic":
|
| 492 |
req = _prov.anthropic_request(
|
| 493 |
model=p["model"], key=os.environ[p["env"]].strip(), system=None,
|
| 494 |
messages=messages, tools=tools, max_tokens=1500, tool_choice="required")
|
| 495 |
-
|
| 496 |
-
|
| 497 |
else:
|
| 498 |
r = requests.post(p["url"], timeout=tmo,
|
| 499 |
headers={"Authorization": f"Bearer {os.environ[p['env']].strip()}",
|
|
@@ -501,19 +540,24 @@ def draft_flow(*, prompt, catalog, required, triggers, tables, chat=None, timeou
|
|
| 501 |
json={"model": p["model"], "messages": messages, "tools": tools,
|
| 502 |
"tool_choice": "required", "temperature": 0.1,
|
| 503 |
"max_tokens": 1500})
|
|
|
|
|
|
|
| 504 |
except Exception as e: # noqa: BLE001
|
| 505 |
problems.append(f"{p['name']}: {type(e).__name__}")
|
| 506 |
continue
|
| 507 |
-
if
|
| 508 |
# β A SENTENCE, NOT `HTTP 402` (R4's third clause). The owner read the status codes off
|
| 509 |
# this very door. `refusal_sentence` also decides whether this was a CREDIT failure, and
|
| 510 |
# the memo makes the next turn SKIP the empty account instead of paying for it again.
|
| 511 |
-
|
|
|
|
|
|
|
|
|
|
| 512 |
_prov.mark_no_credit(p["name"])
|
| 513 |
-
problems.append(_prov.refusal_sentence(p["name"],
|
| 514 |
continue
|
| 515 |
try:
|
| 516 |
-
body =
|
| 517 |
if p["shape"] == "anthropic":
|
| 518 |
_text, args, _refused = _prov.anthropic_read(body)
|
| 519 |
if _refused:
|
|
|
|
| 17 |
last rather than absent β it is the quality backstop, not the default. `AIOS_AI_REVIEW_PROVIDER`
|
| 18 |
pins one; `AIOS_AI_MODEL` overrides the model.
|
| 19 |
|
| 20 |
+
ββ THE ANTHROPIC LEG IS ON THE OFFICIAL SDK NOW (D-346, W37-T39, 2026-08-19). β THIS PARAGRAPH
|
| 21 |
+
USED TO ARGUE THE OPPOSITE and is rewritten rather than deleted, because a comment left asserting
|
| 22 |
+
the reverse of its own code is [[two-gates-can-assert-opposite-things]] with no gate to catch it.
|
| 23 |
+
The retired text said raw HTTP was *"a deliberate deviation β¦ booked as a DEBT line so the
|
| 24 |
+
integrator can overturn it deliberately rather than by drift"*. This is that overturn.
|
| 25 |
+
|
| 26 |
+
Its two reasons were real and are both answered rather than waved away:
|
| 27 |
+
1. *"one SDK leg beside three hand-rolled ones is two implementations of the same call."* It is
|
| 28 |
+
not, because the leg does not live here: `providers.anthropic_send` owns the wire, prefers
|
| 29 |
+
the SDK, and normalises BOTH transports to one `(status, body)` pair. This file gained no
|
| 30 |
+
Anthropic knowledge β it lost some.
|
| 31 |
+
2. *"adding a pinned dependency rebuilds the container, a real deploy risk."* Still true, so the
|
| 32 |
+
import is LAZY and its absence is a FALLBACK, not a crash: with no `anthropic` package the
|
| 33 |
+
same raw POST runs and `LAST_ANTHROPIC_TRANSPORT` reports `'http'`. The pin is owed in BOTH
|
| 34 |
+
manifests ([[pin-deps-space-rebuilds]]) and is outside every worker fence this wave.
|
| 35 |
+
β The other three legs stay OpenAI-chat-shaped over `requests` β they have no shared SDK, and that
|
| 36 |
+
half of the original reasoning never expired.
|
| 37 |
|
| 38 |
The Messages shape below is the current one: `x-api-key` + `anthropic-version: 2023-06-01`, and
|
| 39 |
`stop_reason: "refusal"` is checked BEFORE reading `content` β a refusal answers HTTP 200 with an
|
|
|
|
| 49 |
|
| 50 |
#: The ladder. Order IS the policy (R14) β cheapest capable first, Anthropic last.
|
| 51 |
PROVIDERS = [
|
| 52 |
+
# ββ D-345, FIXED W37-T39 (2026-08-19). This read `llama-3.3-70b-versatile` and Groq RETIRED
|
| 53 |
+
# it: a real POST with the live key answers HTTP 404 `model_not_found`, and the id is absent
|
| 54 |
+
# from `GET /openai/v1/models`. β THE `openai/` PREFIX IS PART OF GROQ'S ID and is NOT a typo
|
| 55 |
+
# for the cerebras rung below, which serves the same family bare as `gpt-oss-120b`.
|
| 56 |
{"name": "groq", "env": "GROQ_API_KEY", "shape": "openai",
|
| 57 |
"url": "https://api.groq.com/openai/v1/chat/completions",
|
| 58 |
+
"model": "openai/gpt-oss-120b"},
|
| 59 |
{"name": "cerebras", "env": "CEREBRAS_API_KEY", "shape": "openai",
|
| 60 |
"url": "https://api.cerebras.ai/v1/chat/completions",
|
| 61 |
"model": "gpt-oss-120b"},
|
|
|
|
| 173 |
return str(((choices[0] or {}).get("message") or {}).get("content") or ""), "", body
|
| 174 |
|
| 175 |
|
| 176 |
+
#: What transport the last Anthropic call actually used: `'sdk'`, `'http'`, or `''` before any.
|
| 177 |
+
#: β PROCESS-LOCAL AND FOR REPORTING ONLY. It exists so `GET /meta` and `verify_web_agent` can say
|
| 178 |
+
#: WHICH path ran rather than inferring it from a requirements file nobody in this lane can edit.
|
| 179 |
+
LAST_ANTHROPIC_TRANSPORT = ""
|
| 180 |
+
|
| 181 |
+
|
| 182 |
def _call_anthropic(p, model, system, user, timeout):
|
| 183 |
+
"""One classification turn on the Messages API, through `providers.anthropic_send`.
|
| 184 |
+
|
| 185 |
+
β D-346 (W37-T39): this used to hand-roll the POST. It now goes through the shared wire, which
|
| 186 |
+
prefers the OFFICIAL `anthropic` SDK and falls back to the same raw POST when the package is
|
| 187 |
+
absent. The shape of what comes back is unchanged, deliberately: `anthropic_send` normalises
|
| 188 |
+
both transports to `(status, body)`, so every line below this call is untouched.
|
| 189 |
+
"""
|
| 190 |
+
global LAST_ANTHROPIC_TRANSPORT
|
| 191 |
+
import providers as _prov # noqa: PLC0415
|
| 192 |
+
# β NO TOOLS ON THIS PATH. The builder omits `tool_choice` for exactly that case, so there is
|
| 193 |
+
# nothing to strip here β the rule lives in `anthropic_request`, beside the thing it constrains.
|
| 194 |
+
req = _prov.anthropic_request(
|
| 195 |
+
model=model, key=os.environ[p["env"]].strip(), system=system,
|
| 196 |
+
messages=[{"role": "user", "content": user}], tools=[], max_tokens=300)
|
| 197 |
+
status, body, transport = _prov.anthropic_send(req, timeout=timeout)
|
| 198 |
+
LAST_ANTHROPIC_TRANSPORT = transport
|
| 199 |
+
if status >= 400 or status == 0:
|
| 200 |
+
# β THE SENTENCE COMES FROM THE LADDER, NOT FROM HERE. `refusal_sentence` already names the
|
| 201 |
+
# vendor and the action for every status R4 enumerated, so a bare "anthropic answered 402"
|
| 202 |
+
# (which is what this line used to say, and what the owner quoted back at us) cannot recur.
|
| 203 |
+
return "", _prov.refusal_sentence("anthropic", status, json.dumps(body)[:400]), None
|
| 204 |
+
body = body or {}
|
| 205 |
# β stop_reason FIRST. A safety refusal is a successful 200 with an EMPTY content list, so
|
| 206 |
# reading content[0] before this check turns a refusal into an IndexError inside a run.
|
| 207 |
# β A refusal IS billed and its body carries `usage`, so the body rides back on this branch too.
|
|
|
|
| 475 |
`routes_automation`'s draft door) are in lane D's fence. C7 says E adds the ledger line here and
|
| 476 |
D asserts it; the two keywords are what D has to pass for the line to be attributable.
|
| 477 |
"""
|
| 478 |
+
# β DECLARED AT THE TOP OF THE FUNCTION, not beside the assignment inside the provider loop.
|
| 479 |
+
# It parses either way; a reader scanning for the declaration does not look inside a `for`.
|
| 480 |
+
global LAST_ANTHROPIC_TRANSPORT
|
| 481 |
import usage_ledger # noqa: PLC0415
|
| 482 |
text = str(prompt or "").strip()[:MAX_PROMPT_CHARS]
|
| 483 |
if not text:
|
|
|
|
| 521 |
# 402), so without this branch the drafter cannot answer at all.
|
| 522 |
# β NO `effort` ON THIS DOOR β `output_config.effort` errors on Haiku 4.5, the tier this
|
| 523 |
# ladder runs. The parameter is the caller's to send, which is why the wire takes it.
|
| 524 |
+
# β D-346 (W37-T39): BOTH shapes now yield the SAME `(status, body)` pair, so every branch
|
| 525 |
+
# below reads one thing. The anthropic side gets there through `anthropic_send` (official
|
| 526 |
+
# SDK when installed, the identical raw POST when not); the openai side still posts here
|
| 527 |
+
# because there is no shared SDK across three different vendors on that wire.
|
| 528 |
+
global LAST_ANTHROPIC_TRANSPORT
|
| 529 |
try:
|
| 530 |
if p["shape"] == "anthropic":
|
| 531 |
req = _prov.anthropic_request(
|
| 532 |
model=p["model"], key=os.environ[p["env"]].strip(), system=None,
|
| 533 |
messages=messages, tools=tools, max_tokens=1500, tool_choice="required")
|
| 534 |
+
status, body, transport = _prov.anthropic_send(req, timeout=tmo)
|
| 535 |
+
LAST_ANTHROPIC_TRANSPORT = transport
|
| 536 |
else:
|
| 537 |
r = requests.post(p["url"], timeout=tmo,
|
| 538 |
headers={"Authorization": f"Bearer {os.environ[p['env']].strip()}",
|
|
|
|
| 540 |
json={"model": p["model"], "messages": messages, "tools": tools,
|
| 541 |
"tool_choice": "required", "temperature": 0.1,
|
| 542 |
"max_tokens": 1500})
|
| 543 |
+
status = r.status_code
|
| 544 |
+
body = r.json() if (r.content and r.status_code == 200) else {"_text": r.text}
|
| 545 |
except Exception as e: # noqa: BLE001
|
| 546 |
problems.append(f"{p['name']}: {type(e).__name__}")
|
| 547 |
continue
|
| 548 |
+
if status != 200:
|
| 549 |
# β A SENTENCE, NOT `HTTP 402` (R4's third clause). The owner read the status codes off
|
| 550 |
# this very door. `refusal_sentence` also decides whether this was a CREDIT failure, and
|
| 551 |
# the memo makes the next turn SKIP the empty account instead of paying for it again.
|
| 552 |
+
# β The body is re-serialised for the substring test because the SDK path never had a
|
| 553 |
+
# `.text` β that is the one thing this normalisation costs, and it costs nothing else.
|
| 554 |
+
detail = json.dumps(body)[:600] if isinstance(body, dict) else str(body)[:600]
|
| 555 |
+
if _prov.is_credit_failure(status, detail):
|
| 556 |
_prov.mark_no_credit(p["name"])
|
| 557 |
+
problems.append(_prov.refusal_sentence(p["name"], status, detail))
|
| 558 |
continue
|
| 559 |
try:
|
| 560 |
+
body = body or {}
|
| 561 |
if p["shape"] == "anthropic":
|
| 562 |
_text, args, _refused = _prov.anthropic_read(body)
|
| 563 |
if _refused:
|
api/main.py
CHANGED
|
@@ -87,6 +87,7 @@ import routes_usage # noqa: E402 (wave 35 R9/C7 β the AI usage meter; E's ro
|
|
| 87 |
import routes_feedback # noqa: E402 (wave 35 R8/C6 β feedback to the operator plane; E's router)
|
| 88 |
import routes_agent_harness # noqa: E402 (wave 36 R8/C4 β the agent harness store; E's router)
|
| 89 |
import routes_script_views # noqa: E402 (wave 36 R3/R10/C3 β script Views; E's router)
|
|
|
|
| 90 |
from core import grid_events # noqa: E402
|
| 91 |
# D-315 / D-305 (2026-08-18) β the two store REFUSALS get their own app-level handlers below.
|
| 92 |
# β Neither is a `StoreUnavailable` subclass, on purpose: routes that degrade a store outage into a
|
|
@@ -395,6 +396,18 @@ app.include_router(routes_agent_harness.router) # R8 / C4 β versioned agent h
|
|
| 395 |
# Mounting it does not put a ten-second wait anywhere near the event loop; the router's own header
|
| 396 |
# says why that is not a style choice.
|
| 397 |
app.include_router(routes_script_views.router) # R3 / R10 / C3 β code-script database Views
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 398 |
|
| 399 |
|
| 400 |
# --- DEPRECATED ALIASES (removed when S2's shell flips; kept so the current bundle keeps working)
|
|
|
|
| 87 |
import routes_feedback # noqa: E402 (wave 35 R8/C6 β feedback to the operator plane; E's router)
|
| 88 |
import routes_agent_harness # noqa: E402 (wave 36 R8/C4 β the agent harness store; E's router)
|
| 89 |
import routes_script_views # noqa: E402 (wave 36 R3/R10/C3 β script Views; E's router)
|
| 90 |
+
import routes_geo # noqa: E402 (wave 37 R14/A11 β the keyless map provider seam; D's router)
|
| 91 |
from core import grid_events # noqa: E402
|
| 92 |
# D-315 / D-305 (2026-08-18) β the two store REFUSALS get their own app-level handlers below.
|
| 93 |
# β Neither is a `StoreUnavailable` subclass, on purpose: routes that degrade a store outage into a
|
|
|
|
| 396 |
# Mounting it does not put a ten-second wait anywhere near the event loop; the router's own header
|
| 397 |
# says why that is not a style choice.
|
| 398 |
app.include_router(routes_script_views.router) # R3 / R10 / C3 β code-script database Views
|
| 399 |
+
# ββ WAVE 37 (R14, amendment A11) β THE KEYLESS MAP PROVIDER SEAM, mounted on `ASK D-1` in the
|
| 400 |
+
# same wave that created `routes_geo.py`. NINTH consecutive wave in which this block is the
|
| 401 |
+
# artefact the protocol nearly loses, and the first in which the ask that carries it was itself
|
| 402 |
+
# lost: D raised it inside a ``` fence, so `wave_board.py` blanked it and the bus never delivered
|
| 403 |
+
# it (D-366). The integrator found it reading the peer mailbox by hand.
|
| 404 |
+
# β Placement, as for every line above: ABOVE `app.mount("/", _AppStatic(...), html=True)` at the
|
| 405 |
+
# end of this file, or GET answers 404 and POST answers 405 while every one of its own gates is
|
| 406 |
+
# green. D is adding a `web_map` leg that reads THIS FILE and asserts this line β keep it.
|
| 407 |
+
# β R14 put the provider behind a seam precisely so swapping Google in later is a config change:
|
| 408 |
+
# tiles = OpenStreetMap Β· routing = OSRM Β· geocoding = Nominatim, none of them keyed, all of them
|
| 409 |
+
# rate-limited by POLITENESS rather than by a bill (~1 req/s, and bulk harvesting is forbidden).
|
| 410 |
+
app.include_router(routes_geo.router) # R14 / A11 β D's router, A's line (map providers)
|
| 411 |
|
| 412 |
|
| 413 |
# --- DEPRECATED ALIASES (removed when S2's shell flips; kept so the current bundle keeps working)
|
api/odoo_relational.py
CHANGED
|
@@ -636,8 +636,25 @@ def customer_fields():
|
|
| 636 |
"default": True, "pinned": True},
|
| 637 |
{"key": JOIN_KEY, "label": "Odoo ID", "type": "int", "source": "overlay",
|
| 638 |
"default": False, "description": "The `res.partner` id. Also this row's id."},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 639 |
{"key": "city", "label": "City", "type": "text", "source": "overlay", "default": True},
|
| 640 |
{"key": "state", "label": "State", "type": "text", "source": "overlay", "default": True},
|
|
|
|
| 641 |
{"key": "country", "label": "Country", "type": "text", "source": "overlay",
|
| 642 |
"default": False},
|
| 643 |
{"key": "agent", "label": "Sales agent", "type": "text", "source": "overlay",
|
|
@@ -976,8 +993,13 @@ def read_customers(cur, excluded=None):
|
|
| 976 |
# the mirror carries. Absent β the leg is dropped WHOLE, exactly like the agent join above.
|
| 977 |
rank_leg = (" OR (p.customer_rank > 0 AND p.active) "
|
| 978 |
if {"customer_rank", "active"} <= have else "")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 979 |
sql = (f"SELECT p.id, p.name, {_col(have, 'p.city')}, {_col(have, 'p.state_name')}, "
|
| 980 |
-
f" {_col(have, 'p.country_name')}, {agent}, {agent_id_col} "
|
|
|
|
| 981 |
"FROM res_partner p "
|
| 982 |
f"{join}"
|
| 983 |
"WHERE p.id IN ("
|
|
@@ -988,13 +1010,16 @@ def read_customers(cur, excluded=None):
|
|
| 988 |
f"{rank_leg}")
|
| 989 |
out = []
|
| 990 |
for r in cur.execute(sql).fetchall():
|
| 991 |
-
(pid, name, city, state, country, agent, agent_id) = r
|
| 992 |
out.append({
|
| 993 |
"_id": str(pid),
|
| 994 |
"customer": str(name or ""),
|
| 995 |
JOIN_KEY: int(pid),
|
|
|
|
|
|
|
| 996 |
"city": str(city or ""),
|
| 997 |
"state": str(state or ""),
|
|
|
|
| 998 |
"country": str(country or ""),
|
| 999 |
"agent": str(agent or ""),
|
| 1000 |
AGENT_JOIN_KEY: int(agent_id) if agent_id else "",
|
|
|
|
| 636 |
"default": True, "pinned": True},
|
| 637 |
{"key": JOIN_KEY, "label": "Odoo ID", "type": "int", "source": "overlay",
|
| 638 |
"default": False, "description": "The `res.partner` id. Also this row's id."},
|
| 639 |
+
# ββ W37-T11 / D-165 β THE POSTAL ADDRESS, which this table declared nowhere and served
|
| 640 |
+
# never. `verify_odoo_relational._prove_customer_address` recorded the blocker in its own
|
| 641 |
+
# docstring on 2026-08-12 β *"`street`/`street2`/`zip` are not among [the mirror's 16
|
| 642 |
+
# columns] β¦ blocked on a `sync_all()` backfill no ticket in wave 30 owns"*. That backfill
|
| 643 |
+
# HAS since run (`_sync_state` carries `res_partner.cols.β¦,street,street2,β¦,zip`, done
|
| 644 |
+
# 2026-08-15), so the half that could not be built now can be.
|
| 645 |
+
# β THIS IS ALSO LANE D'S DEPENDENCY. R4 puts addressβcoordinates in an enrichment field,
|
| 646 |
+
# and a geocoder pointed at a column that does not exist enriches nothing.
|
| 647 |
+
# β `default: False` on the postal lines, `True` on city/state β the same split
|
| 648 |
+
# `vendor_fields` made and for the same reason: four address columns on by default push
|
| 649 |
+
# the useful ones off the first screen. A locked database still allows fields, so every
|
| 650 |
+
# one is a click away in the picker.
|
| 651 |
+
{"key": "street", "label": "Street", "type": "text", "source": "overlay",
|
| 652 |
+
"default": False},
|
| 653 |
+
{"key": "street2", "label": "Street 2", "type": "text", "source": "overlay",
|
| 654 |
+
"default": False},
|
| 655 |
{"key": "city", "label": "City", "type": "text", "source": "overlay", "default": True},
|
| 656 |
{"key": "state", "label": "State", "type": "text", "source": "overlay", "default": True},
|
| 657 |
+
{"key": "zip", "label": "ZIP", "type": "text", "source": "overlay", "default": False},
|
| 658 |
{"key": "country", "label": "Country", "type": "text", "source": "overlay",
|
| 659 |
"default": False},
|
| 660 |
{"key": "agent", "label": "Sales agent", "type": "text", "source": "overlay",
|
|
|
|
| 993 |
# the mirror carries. Absent β the leg is dropped WHOLE, exactly like the agent join above.
|
| 994 |
rank_leg = (" OR (p.customer_rank > 0 AND p.active) "
|
| 995 |
if {"customer_rank", "active"} <= have else "")
|
| 996 |
+
# ββ W37-T11 / D-165 β the POSTAL columns join city/state/country here. They ride `_col` like
|
| 997 |
+
# every other optional column, so a mirror predating the 2026-08-15 backfill degrades the CELL
|
| 998 |
+
# to blank rather than failing the spawn with a Binder error (see `columns()`'s header for the
|
| 999 |
+
# live 500 that rule was written after).
|
| 1000 |
sql = (f"SELECT p.id, p.name, {_col(have, 'p.city')}, {_col(have, 'p.state_name')}, "
|
| 1001 |
+
f" {_col(have, 'p.country_name')}, {agent}, {agent_id_col}, "
|
| 1002 |
+
f" {_col(have, 'p.street')}, {_col(have, 'p.street2')}, {_col(have, 'p.zip')} "
|
| 1003 |
"FROM res_partner p "
|
| 1004 |
f"{join}"
|
| 1005 |
"WHERE p.id IN ("
|
|
|
|
| 1010 |
f"{rank_leg}")
|
| 1011 |
out = []
|
| 1012 |
for r in cur.execute(sql).fetchall():
|
| 1013 |
+
(pid, name, city, state, country, agent, agent_id, street, street2, zipc) = r
|
| 1014 |
out.append({
|
| 1015 |
"_id": str(pid),
|
| 1016 |
"customer": str(name or ""),
|
| 1017 |
JOIN_KEY: int(pid),
|
| 1018 |
+
"street": str(street or ""),
|
| 1019 |
+
"street2": str(street2 or ""),
|
| 1020 |
"city": str(city or ""),
|
| 1021 |
"state": str(state or ""),
|
| 1022 |
+
"zip": str(zipc or ""),
|
| 1023 |
"country": str(country or ""),
|
| 1024 |
"agent": str(agent or ""),
|
| 1025 |
AGENT_JOIN_KEY: int(agent_id) if agent_id else "",
|
api/providers.py
CHANGED
|
@@ -39,6 +39,12 @@ import time
|
|
| 39 |
from dataclasses import dataclass, field
|
| 40 |
from typing import Callable
|
| 41 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
#: β APPROXIMATE, AND DELIBERATELY SO. These are list prices per RECORD in USD, used only to RANK
|
| 43 |
#: providers and to estimate a run's spend for the operator. They are not billing truth β the
|
| 44 |
#: vendor's own dashboard is. Wrong by 2x still ranks correctly; wrong by 100x does not, which is
|
|
@@ -352,6 +358,8 @@ LLM_PROVIDERS: dict[str, LlmProvider] = {
|
|
| 352 |
# β Claude Opus 5, $5 / $25 per million tokens (2026-06 list). Override per deployment with
|
| 353 |
# `AIOS_ANTHROPIC_MODEL` β the id is read at call time, so a cheaper tier
|
| 354 |
# (`claude-sonnet-5`, $3 / $15) is an environment change, not a release.
|
|
|
|
|
|
|
| 355 |
model=os.environ.get("AIOS_ANTHROPIC_MODEL") or "claude-opus-5",
|
| 356 |
wire="anthropic",
|
| 357 |
caps={
|
|
@@ -367,24 +375,48 @@ LLM_PROVIDERS: dict[str, LlmProvider] = {
|
|
| 367 |
"cerebras": LlmProvider(
|
| 368 |
name="cerebras", label="Cerebras", env="CEREBRAS_API_KEY",
|
| 369 |
url="https://api.cerebras.ai/v1/chat/completions",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 370 |
model="gpt-oss-120b", wire="openai",
|
| 371 |
caps={
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 372 |
"llm_tool_calling": Capability(True, 0.0,
|
| 373 |
"DECLARED, not measured: this account's tool-capable "
|
| 374 |
-
"model per the wave-32 ladder note"
|
|
|
|
| 375 |
"llm_chat": Capability(True, 0.0, "OpenAI-compatible"),
|
| 376 |
"llm_json_mode": Capability(True, 0.0, "response_format json_object"),
|
| 377 |
}),
|
| 378 |
"groq": LlmProvider(
|
| 379 |
name="groq", label="Groq", env="GROQ_API_KEY",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 380 |
url="https://api.groq.com/openai/v1/chat/completions",
|
| 381 |
-
model="
|
| 382 |
caps={
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 383 |
"llm_tool_calling": Capability(True, 0.0,
|
| 384 |
-
"
|
| 385 |
-
"
|
| 386 |
-
"with the call in `failed_generation`; the repo never "
|
| 387 |
-
"recorded which. Flip this to False the day it is"),
|
| 388 |
"llm_chat": Capability(True, 0.0, "OpenAI-compatible"),
|
| 389 |
"llm_json_mode": Capability(True, 0.0, "response_format json_object"),
|
| 390 |
}),
|
|
@@ -393,7 +425,11 @@ LLM_PROVIDERS: dict[str, LlmProvider] = {
|
|
| 393 |
url="https://openrouter.ai/api/v1/chat/completions",
|
| 394 |
model="openai/gpt-4o-mini", wire="openai",
|
| 395 |
caps={
|
| 396 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 397 |
"llm_chat": Capability(True, 0.0, "OpenAI-compatible"),
|
| 398 |
"llm_json_mode": Capability(True, 0.0, "response_format json_object"),
|
| 399 |
}),
|
|
@@ -538,16 +574,25 @@ def anthropic_request(*, model, key, system, messages, tools, max_tokens,
|
|
| 538 |
turns = [{"role": ("assistant" if m.get("role") == "assistant" else "user"),
|
| 539 |
"content": str(m.get("content") or "")}
|
| 540 |
for m in messages if m.get("role") != "system" and str(m.get("content") or "").strip()]
|
|
|
|
|
|
|
|
|
|
| 541 |
body = {
|
| 542 |
"model": str(model),
|
| 543 |
"max_tokens": int(max_tokens),
|
| 544 |
"system": system_text,
|
| 545 |
"messages": turns,
|
| 546 |
-
"tools":
|
| 547 |
-
"description": t["function"]["description"],
|
| 548 |
-
"input_schema": t["function"]["parameters"]} for t in (tools or [])],
|
| 549 |
-
"tool_choice": {"type": "any" if tool_choice in ("required", "any") else "auto"},
|
| 550 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 551 |
if effort:
|
| 552 |
body["output_config"] = {"effort": str(effort)}
|
| 553 |
return {"url": LLM_PROVIDERS["anthropic"].url,
|
|
@@ -557,6 +602,88 @@ def anthropic_request(*, model, key, system, messages, tools, max_tokens,
|
|
| 557 |
"json": body}
|
| 558 |
|
| 559 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 560 |
def anthropic_read(body):
|
| 561 |
"""`(text, tool_input, refusal)` out of a Messages API answer.
|
| 562 |
|
|
|
|
| 39 |
from dataclasses import dataclass, field
|
| 40 |
from typing import Callable
|
| 41 |
|
| 42 |
+
# β TOP-LEVEL AND SAFE, unlike the `anthropic` import in `anthropic_sdk()`: `requests` is pinned in
|
| 43 |
+
# BOTH manifests and is already a transitive dependency of `huggingface_hub`, so it cannot be the
|
| 44 |
+
# line that stops a container booting. That asymmetry is the whole reason the two are imported
|
| 45 |
+
# differently β see `anthropic_sdk()`.
|
| 46 |
+
import requests
|
| 47 |
+
|
| 48 |
#: β APPROXIMATE, AND DELIBERATELY SO. These are list prices per RECORD in USD, used only to RANK
|
| 49 |
#: providers and to estimate a run's spend for the operator. They are not billing truth β the
|
| 50 |
#: vendor's own dashboard is. Wrong by 2x still ranks correctly; wrong by 100x does not, which is
|
|
|
|
| 358 |
# β Claude Opus 5, $5 / $25 per million tokens (2026-06 list). Override per deployment with
|
| 359 |
# `AIOS_ANTHROPIC_MODEL` β the id is read at call time, so a cheaper tier
|
| 360 |
# (`claude-sonnet-5`, $3 / $15) is an environment change, not a release.
|
| 361 |
+
# β W37-T39 re-verified this default against the vendor rather than the docs: a real
|
| 362 |
+
# Messages POST answers HTTP 200, and `claude-opus-5` is in `GET /v1/models`.
|
| 363 |
model=os.environ.get("AIOS_ANTHROPIC_MODEL") or "claude-opus-5",
|
| 364 |
wire="anthropic",
|
| 365 |
caps={
|
|
|
|
| 375 |
"cerebras": LlmProvider(
|
| 376 |
name="cerebras", label="Cerebras", env="CEREBRAS_API_KEY",
|
| 377 |
url="https://api.cerebras.ai/v1/chat/completions",
|
| 378 |
+
# β STILL A LIVE ID, and W37-T39 checked rather than assumed: `gpt-oss-120b` is one of the
|
| 379 |
+
# two ids `GET https://api.cerebras.ai/v1/models` returns. What it is NOT is callable on
|
| 380 |
+
# this account β a real POST answers `HTTP 402 payment_required`, which `mark_no_credit`
|
| 381 |
+
# already knows how to survive (R4: skipped, not tried).
|
| 382 |
model="gpt-oss-120b", wire="openai",
|
| 383 |
caps={
|
| 384 |
+
# β STILL "DECLARED, NOT MEASURED", DELIBERATELY. The 2026-08-19 sweep could not
|
| 385 |
+
# measure this rung: a 402 comes back before any tool is considered, so there is no
|
| 386 |
+
# observation to record. Leaving the old wording is the honest answer β upgrading it
|
| 387 |
+
# to MEASURED beside its two neighbours would be claiming an account balance as
|
| 388 |
+
# evidence about a capability.
|
| 389 |
"llm_tool_calling": Capability(True, 0.0,
|
| 390 |
"DECLARED, not measured: this account's tool-capable "
|
| 391 |
+
"model per the wave-32 ladder note. β 2026-08-19: the "
|
| 392 |
+
"account answers 402, so this stays unmeasured"),
|
| 393 |
"llm_chat": Capability(True, 0.0, "OpenAI-compatible"),
|
| 394 |
"llm_json_mode": Capability(True, 0.0, "response_format json_object"),
|
| 395 |
}),
|
| 396 |
"groq": LlmProvider(
|
| 397 |
name="groq", label="Groq", env="GROQ_API_KEY",
|
| 398 |
+
# ββ D-345, FIXED W37-T39 (2026-08-19): this read `llama-3.3-70b-versatile` and Groq
|
| 399 |
+
# RETIRED it. Measured, not inferred β a real POST with the live key answered
|
| 400 |
+
# `HTTP 404 {"code":"model_not_found"}`, and `GET /openai/v1/models` returns 13 ids with
|
| 401 |
+
# that one absent. β THE STATUS IS THE EVIDENCE AND 404 IS THE ONLY ONE THAT LICENSES A
|
| 402 |
+
# RENAME: cerebras answered 402 on the same sweep, which is a BILLING fact about the
|
| 403 |
+
# account and says nothing about its model id (`gpt-oss-120b` is in its /models list).
|
| 404 |
+
# "Fixing" an id behind a 401/402 is how a second bug ships behind a green probe.
|
| 405 |
+
# β THE PREFIX IS NOT A TYPO: Groq serves this model as `openai/gpt-oss-120b` while
|
| 406 |
+
# Cerebras serves the same family bare as `gpt-oss-120b`. The two rungs are DELIBERATELY
|
| 407 |
+
# spelled differently and a sweep that "normalises" them breaks one of them.
|
| 408 |
url="https://api.groq.com/openai/v1/chat/completions",
|
| 409 |
+
model="openai/gpt-oss-120b", wire="openai",
|
| 410 |
caps={
|
| 411 |
+
# β MEASURED 2026-08-19, W37-T39, and this comment used to say the opposite. The old
|
| 412 |
+
# text read *"DECLARED, not measured β¦ `routes_query._FAILED_GEN` exists because SOME
|
| 413 |
+
# provider on this wire answers 400 with the call in `failed_generation`; the repo
|
| 414 |
+
# never recorded which. Flip this to False the day it is"*. It is recorded now: on
|
| 415 |
+
# this id Groq answers HTTP 200 with a TYPED `tool_calls` block carrying parsed
|
| 416 |
+
# `arguments`, so it is not the provider `_FAILED_GEN` apologises for.
|
| 417 |
"llm_tool_calling": Capability(True, 0.0,
|
| 418 |
+
"MEASURED 2026-08-19: HTTP 200, typed tool_calls block "
|
| 419 |
+
"with parsed arguments; no failed_generation recovery"),
|
|
|
|
|
|
|
| 420 |
"llm_chat": Capability(True, 0.0, "OpenAI-compatible"),
|
| 421 |
"llm_json_mode": Capability(True, 0.0, "response_format json_object"),
|
| 422 |
}),
|
|
|
|
| 425 |
url="https://openrouter.ai/api/v1/chat/completions",
|
| 426 |
model="openai/gpt-4o-mini", wire="openai",
|
| 427 |
caps={
|
| 428 |
+
# β MEASURED 2026-08-19, W37-T39 (was "DECLARED, not measured"): HTTP 200 with a typed
|
| 429 |
+
# `tool_calls` block. So on the OpenAI wire BOTH reachable rungs tool-call cleanly, and
|
| 430 |
+
# `_FAILED_GEN` is still holding a door nobody on this ladder has been seen to use.
|
| 431 |
+
"llm_tool_calling": Capability(True, 0.0,
|
| 432 |
+
"MEASURED 2026-08-19: HTTP 200, typed tool_calls block"),
|
| 433 |
"llm_chat": Capability(True, 0.0, "OpenAI-compatible"),
|
| 434 |
"llm_json_mode": Capability(True, 0.0, "response_format json_object"),
|
| 435 |
}),
|
|
|
|
| 574 |
turns = [{"role": ("assistant" if m.get("role") == "assistant" else "user"),
|
| 575 |
"content": str(m.get("content") or "")}
|
| 576 |
for m in messages if m.get("role") != "system" and str(m.get("content") or "").strip()]
|
| 577 |
+
wire_tools = [{"name": t["function"]["name"],
|
| 578 |
+
"description": t["function"]["description"],
|
| 579 |
+
"input_schema": t["function"]["parameters"]} for t in (tools or [])]
|
| 580 |
body = {
|
| 581 |
"model": str(model),
|
| 582 |
"max_tokens": int(max_tokens),
|
| 583 |
"system": system_text,
|
| 584 |
"messages": turns,
|
| 585 |
+
"tools": wire_tools,
|
|
|
|
|
|
|
|
|
|
| 586 |
}
|
| 587 |
+
# β W37-T39: `tool_choice` IS OMITTED WHEN THERE ARE NO TOOLS, and the builder decides that
|
| 588 |
+
# rather than each caller. The Messages API refuses `tool_choice` beside an empty `tools` list,
|
| 589 |
+
# so this used to be a `req["json"].pop("tool_choice", None)` at the one call site that sends no
|
| 590 |
+
# tools β a rule living outside the thing it constrains, which is [[limit-with-no-enforcer]]:
|
| 591 |
+
# the next no-tools caller inherits a 400 that reads like a model problem, not a shape problem.
|
| 592 |
+
# β Safe for all three callers today, checked rather than assumed: `routes_query` and
|
| 593 |
+
# `ai_review.draft_flow` both pass a non-empty tool list and keep the key exactly as before.
|
| 594 |
+
if wire_tools:
|
| 595 |
+
body["tool_choice"] = {"type": "any" if tool_choice in ("required", "any") else "auto"}
|
| 596 |
if effort:
|
| 597 |
body["output_config"] = {"effort": str(effort)}
|
| 598 |
return {"url": LLM_PROVIDERS["anthropic"].url,
|
|
|
|
| 602 |
"json": body}
|
| 603 |
|
| 604 |
|
| 605 |
+
#: Cached result of `import anthropic`: the module, or `False` once it is known to be absent.
|
| 606 |
+
#: β `None` is NOT the "absent" sentinel β a plain falsy check would re-attempt the import on every
|
| 607 |
+
#: call, and a failed import is not cheap. `False` says "asked and answered".
|
| 608 |
+
_SDK: object = None
|
| 609 |
+
|
| 610 |
+
|
| 611 |
+
def anthropic_sdk():
|
| 612 |
+
"""The official `anthropic` SDK, or `None` if this deployment does not carry it.
|
| 613 |
+
|
| 614 |
+
ββ D-346, W37-T39. THIS FUNCTION IS THE WHOLE OF THE FIX AND ALSO THE WHOLE OF ITS RISK.
|
| 615 |
+
`aios-web/requirements.txt` is what the Dockerfile installs and it is OUTSIDE every lane's
|
| 616 |
+
fence this wave, so the pin is another session's edit. A hard `import anthropic` at module
|
| 617 |
+
scope would therefore turn a missing line in a manifest into a container that cannot boot at
|
| 618 |
+
all: the API imports this module on every request path.
|
| 619 |
+
|
| 620 |
+
So the import is LAZY and its absence is REPORTED rather than fatal β `anthropic_send` falls
|
| 621 |
+
back to the raw `requests` POST that has always worked, and says which transport ran. When the
|
| 622 |
+
pin lands the SDK path takes over with no further change.
|
| 623 |
+
β The fallback is a BRIDGE, not a second implementation: both transports send the SAME body
|
| 624 |
+
`anthropic_request()` built and return the SAME `(status, body)` pair, so `anthropic_read`,
|
| 625 |
+
`is_credit_failure` and `refusal_sentence` each keep exactly one normalizer
|
| 626 |
+
([[one-question-two-normalizers]]).
|
| 627 |
+
"""
|
| 628 |
+
global _SDK
|
| 629 |
+
if _SDK is None:
|
| 630 |
+
try:
|
| 631 |
+
import anthropic as _mod # noqa: PLC0415 - deliberately lazy; see the docstring
|
| 632 |
+
_SDK = _mod
|
| 633 |
+
except Exception:
|
| 634 |
+
_SDK = False
|
| 635 |
+
return _SDK or None
|
| 636 |
+
|
| 637 |
+
|
| 638 |
+
def anthropic_send(req, timeout=None, use_sdk=None):
|
| 639 |
+
"""POST one `anthropic_request()` dict. Returns `(status, body, transport)`.
|
| 640 |
+
|
| 641 |
+
β `transport` is `'sdk'` or `'http'` and it is RETURNED, not logged and forgotten: a reader
|
| 642 |
+
who cannot tell which path ran cannot tell whether the requirements pin reached the container
|
| 643 |
+
[[report-the-cause-before-you-fix-it]]. β A gate must assert on THIS RETURN VALUE, never on
|
| 644 |
+
`ai_review.LAST_ANTHROPIC_TRANSPORT` β that global is a convenience for `GET /meta`, and a
|
| 645 |
+
check keyed to it passes on a stale value written by an earlier check in the same process.
|
| 646 |
+
|
| 647 |
+
`status` is an int and `body` is the parsed JSON dict in BOTH paths, including on an error β
|
| 648 |
+
the SDK raises where `requests` returns, and normalising that difference here is the only
|
| 649 |
+
reason this function exists rather than the caller branching on transport.
|
| 650 |
+
|
| 651 |
+
β `use_sdk=False` forces the fallback. It exists so a gate can exercise BOTH transports
|
| 652 |
+
without assigning to `_SDK`: a test that mutates a module global and then throws leaves every
|
| 653 |
+
later check in that process running on the wrong path and passing for the wrong reason.
|
| 654 |
+
"""
|
| 655 |
+
sdk = None if use_sdk is False else anthropic_sdk()
|
| 656 |
+
payload = dict(req.get("json") or {})
|
| 657 |
+
if sdk is None:
|
| 658 |
+
r = requests.post(req["url"], headers=req["headers"], json=payload,
|
| 659 |
+
timeout=(timeout or 60))
|
| 660 |
+
try:
|
| 661 |
+
return r.status_code, (r.json() if r.content else {}), "http"
|
| 662 |
+
except Exception:
|
| 663 |
+
return r.status_code, {"_text": (r.text or "")[:2000]}, "http"
|
| 664 |
+
|
| 665 |
+
key = (req.get("headers") or {}).get("x-api-key") or ""
|
| 666 |
+
try:
|
| 667 |
+
client = sdk.Anthropic(api_key=key, timeout=float(timeout or 60))
|
| 668 |
+
msg = client.messages.create(**payload)
|
| 669 |
+
# β `.model_dump()` is what makes ONE reader serve both transports: it hands back the same
|
| 670 |
+
# wire-shaped dict the raw POST parses out of the response body.
|
| 671 |
+
return 200, msg.model_dump(), "sdk"
|
| 672 |
+
except Exception as exc:
|
| 673 |
+
# β THE STATUS IS THE PRODUCT HERE. Every sentence the reader sees is chosen by
|
| 674 |
+
# `refusal_sentence(status)`, so an exception that loses its code turns "Anthropic is out
|
| 675 |
+
# of credit" into "Anthropic did not answer" β the exact regression R4 was written to end.
|
| 676 |
+
status = int(getattr(exc, "status_code", 0) or 0)
|
| 677 |
+
body = getattr(exc, "body", None)
|
| 678 |
+
if not isinstance(body, dict):
|
| 679 |
+
body = {"_text": str(exc)[:2000]}
|
| 680 |
+
if not status:
|
| 681 |
+
# No status at all = it never reached the vendor (DNS, TLS, timeout). 408 is the one
|
| 682 |
+
# code `_STATUS_WORDS` already words as a reachability problem rather than a refusal.
|
| 683 |
+
status = 408 if isinstance(exc, getattr(sdk, "APIConnectionError", ())) else 0
|
| 684 |
+
return status, body, "sdk"
|
| 685 |
+
|
| 686 |
+
|
| 687 |
def anthropic_read(body):
|
| 688 |
"""`(text, tool_input, refusal)` out of a Messages API answer.
|
| 689 |
|
api/routes_admin.py
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
api/routes_geo.py
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""routes_geo.py β the MAP PROVIDER SEAM (wave 37, R3/R14, tickets T35/T36/T37).
|
| 2 |
+
|
| 3 |
+
β WHY THIS FILE EXISTS AT ALL, and why the map does not simply call a vendor.
|
| 4 |
+
|
| 5 |
+
R14 (owner, 2026-08-19): no Google Maps API key this wave. The map work is not dropped, it is
|
| 6 |
+
retargeted to providers that need no key: OpenStreetMap tiles, OSRM routing, Nominatim geocoding.
|
| 7 |
+
The deferral only holds if swapping a paid vendor back in is a CONFIG change rather than a rewrite,
|
| 8 |
+
so every provider is a (base URL + attribution) pair read from the environment and served from ONE
|
| 9 |
+
door. The renderer receives a URL template and a credit line; it knows nothing about who is behind
|
| 10 |
+
them. That is the seam.
|
| 11 |
+
|
| 12 |
+
tiles AIOS_MAP_TILE_URL default https://tile.openstreetmap.org/{z}/{x}/{y}.png
|
| 13 |
+
geocode AIOS_GEOCODE_URL default https://nominatim.openstreetmap.org/search
|
| 14 |
+
routing AIOS_ROUTE_URL default https://router.project-osrm.org/route/v1/driving/
|
| 15 |
+
|
| 16 |
+
β R3'S ON-DEMAND RULE, AND WHY IT IS STRUCTURAL HERE RATHER THAN A COMMENT.
|
| 17 |
+
R3 forbids geocoding a whole table automatically. Under R14 the reason moves from money to manners
|
| 18 |
+
and gets sharper: Nominatim's usage policy allows roughly one request per second and explicitly
|
| 19 |
+
forbids bulk harvesting, so a loop over 3,636 customers does not produce a bill, it produces a
|
| 20 |
+
BLOCKED tenant. Three mechanisms enforce it, none of them advisory:
|
| 21 |
+
|
| 22 |
+
1. `/geo/geocode` takes an explicit LIST OF ADDRESSES from the caller. It cannot read a table, so
|
| 23 |
+
there is no code path from "a database exists" to "its rows were geocoded". Somebody had to
|
| 24 |
+
choose the records.
|
| 25 |
+
2. `MAX_BATCH` refuses an oversized list with a named error rather than truncating it. The client
|
| 26 |
+
drives the loop and can therefore SHOW the wait, which is the half of the ticket a comment
|
| 27 |
+
cannot satisfy.
|
| 28 |
+
3. `_throttle()` blocks in-process until the minimum interval has elapsed, so even a caller that
|
| 29 |
+
ignores everything above cannot exceed the published rate.
|
| 30 |
+
|
| 31 |
+
β AND A CACHE, because the policy asks for one: an address geocoded once is answered from memory
|
| 32 |
+
for the rest of the container's life. It is the cheapest way to be a good citizen and it makes a
|
| 33 |
+
re-run of the same records free rather than merely legal.
|
| 34 |
+
|
| 35 |
+
β NOTHING HERE PROXIES TILES. Tiles are fetched by the BROWSER, straight from the provider, which
|
| 36 |
+
is what every OSM client does and what the tile policy expects. Proxying them through this app
|
| 37 |
+
would put a free tier in the path of every pan and would breach the same policy it looks like it is
|
| 38 |
+
respecting. This door serves the tile URL, never the tile.
|
| 39 |
+
"""
|
| 40 |
+
import os
|
| 41 |
+
import re
|
| 42 |
+
import threading
|
| 43 |
+
import time
|
| 44 |
+
from collections import OrderedDict
|
| 45 |
+
|
| 46 |
+
import requests
|
| 47 |
+
from fastapi import APIRouter, Body, Depends
|
| 48 |
+
|
| 49 |
+
from deps import Session, err, require_session
|
| 50 |
+
|
| 51 |
+
router = APIRouter(prefix="/api/v1")
|
| 52 |
+
|
| 53 |
+
# --------------------------------------------------------------------- the seam
|
| 54 |
+
#
|
| 55 |
+
# Every value below is an environment override with a keyless default. Pointing the map at a paid
|
| 56 |
+
# vendor is three variables on the Space and no code change, which is what makes R14 a deferral.
|
| 57 |
+
|
| 58 |
+
_TILE_URL_DEFAULT = "https://tile.openstreetmap.org/{z}/{x}/{y}.png"
|
| 59 |
+
_TILE_CREDIT_DEFAULT = "Β© OpenStreetMap contributors"
|
| 60 |
+
_TILE_CREDIT_HREF_DEFAULT = "https://www.openstreetmap.org/copyright"
|
| 61 |
+
_GEOCODE_URL_DEFAULT = "https://nominatim.openstreetmap.org/search"
|
| 62 |
+
_ROUTE_URL_DEFAULT = "https://router.project-osrm.org/route/v1/driving/"
|
| 63 |
+
|
| 64 |
+
#: Both policies require a real, identifying User-Agent. A generic one is how a shared service
|
| 65 |
+
#: decides an application is a scraper, so this carries the product name and a contact URL.
|
| 66 |
+
_UA_DEFAULT = "AIOS-Loopable/1.0 (+https://runloopable.com)"
|
| 67 |
+
|
| 68 |
+
#: Nominatim publishes one request per second. 1100 ms leaves room for clock jitter rather than
|
| 69 |
+
#: sitting exactly on the published edge.
|
| 70 |
+
_MIN_INTERVAL_MS_DEFAULT = 1100
|
| 71 |
+
|
| 72 |
+
#: The most addresses one call may carry. Small on purpose: the client loops and shows progress,
|
| 73 |
+
#: and no single request can sit on the connection for a minute waiting out the throttle.
|
| 74 |
+
MAX_BATCH = 25
|
| 75 |
+
|
| 76 |
+
#: Tile zoom the provider actually serves. OSM stops at 19.
|
| 77 |
+
_TILE_MAX_Z_DEFAULT = 19
|
| 78 |
+
|
| 79 |
+
_CACHE_MAX = 4096
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def _env(name, default):
|
| 83 |
+
return (os.environ.get(name) or "").strip() or default
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def _env_int(name, default):
|
| 87 |
+
try:
|
| 88 |
+
n = int((os.environ.get(name) or "").strip())
|
| 89 |
+
return n if n > 0 else default
|
| 90 |
+
except (TypeError, ValueError):
|
| 91 |
+
return default
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def provider_config():
|
| 95 |
+
"""The whole seam as one dict. `GET /geo/providers` serves it and the gate reads it."""
|
| 96 |
+
return {
|
| 97 |
+
"tiles": {
|
| 98 |
+
"url": _env("AIOS_MAP_TILE_URL", _TILE_URL_DEFAULT),
|
| 99 |
+
"attribution": _env("AIOS_MAP_TILE_ATTRIBUTION", _TILE_CREDIT_DEFAULT),
|
| 100 |
+
"attributionUrl": _env("AIOS_MAP_TILE_ATTRIBUTION_URL", _TILE_CREDIT_HREF_DEFAULT),
|
| 101 |
+
"maxZoom": _env_int("AIOS_MAP_TILE_MAX_Z", _TILE_MAX_Z_DEFAULT),
|
| 102 |
+
},
|
| 103 |
+
"geocode": {
|
| 104 |
+
"available": True,
|
| 105 |
+
"maxBatch": MAX_BATCH,
|
| 106 |
+
"minIntervalMs": _env_int("AIOS_GEOCODE_MIN_INTERVAL_MS", _MIN_INTERVAL_MS_DEFAULT),
|
| 107 |
+
# ββ THE NUMBER THE SURFACE MUST QUOTE, AND IT IS NOT THE RATE LIMIT.
|
| 108 |
+
# T37 asks for the estimated wait to be shown BEFORE a run. The obvious source is
|
| 109 |
+
# `minIntervalMs`, and it is wrong: 1.1 s is a floor on POLITENESS, not a prediction of
|
| 110 |
+
# LATENCY. One real lookup was MEASURED at 3.28 s end to end on 2026-08-19. Quoting the
|
| 111 |
+
# limit would promise a minute and take three, and a progress estimate that runs out
|
| 112 |
+
# before the work does reads as a hang rather than as a wait.
|
| 113 |
+
"secondsPerAddress": float(_env("AIOS_GEOCODE_SECONDS_EACH", "3.3")),
|
| 114 |
+
"attribution": _env("AIOS_GEOCODE_ATTRIBUTION", _TILE_CREDIT_DEFAULT),
|
| 115 |
+
},
|
| 116 |
+
"route": {
|
| 117 |
+
"available": True,
|
| 118 |
+
"attribution": _env("AIOS_ROUTE_ATTRIBUTION", "Routing by OSRM"),
|
| 119 |
+
},
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
# ------------------------------------------------------------------ the throttle
|
| 124 |
+
#
|
| 125 |
+
# β ONE lock and ONE timestamp for the whole process, shared by geocoding and routing, because the
|
| 126 |
+
# rate limit belongs to the SERVICE and not to the endpoint. Two independent throttles would each
|
| 127 |
+
# stay honest and together double the published rate.
|
| 128 |
+
|
| 129 |
+
_gate = threading.Lock()
|
| 130 |
+
_last_call = [0.0]
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def _throttle(min_interval_ms):
|
| 134 |
+
"""Block until the minimum interval since the last outbound call has elapsed.
|
| 135 |
+
|
| 136 |
+
Returns the seconds actually waited, so a caller can report the wait rather than hide it."""
|
| 137 |
+
wait = 0.0
|
| 138 |
+
with _gate:
|
| 139 |
+
gap = min_interval_ms / 1000.0
|
| 140 |
+
now = time.monotonic()
|
| 141 |
+
due = _last_call[0] + gap
|
| 142 |
+
if now < due:
|
| 143 |
+
wait = due - now
|
| 144 |
+
time.sleep(wait)
|
| 145 |
+
_last_call[0] = time.monotonic()
|
| 146 |
+
return wait
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
# --------------------------------------------------------------------- the cache
|
| 150 |
+
|
| 151 |
+
_cache = OrderedDict()
|
| 152 |
+
_cache_lock = threading.Lock()
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def _cache_key(q, country):
|
| 156 |
+
return (re.sub(r"\s+", " ", str(q or "")).strip().lower(), str(country or "").strip().lower())
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def _cache_get(key):
|
| 160 |
+
with _cache_lock:
|
| 161 |
+
if key not in _cache:
|
| 162 |
+
return None
|
| 163 |
+
_cache.move_to_end(key)
|
| 164 |
+
return _cache[key]
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def _cache_put(key, value):
|
| 168 |
+
with _cache_lock:
|
| 169 |
+
_cache[key] = value
|
| 170 |
+
_cache.move_to_end(key)
|
| 171 |
+
while len(_cache) > _CACHE_MAX:
|
| 172 |
+
_cache.popitem(last=False)
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def geocode_one(address, country=None, timeout=12):
|
| 176 |
+
"""One address to {lat, lon, label} or None.
|
| 177 |
+
|
| 178 |
+
β THE ONLY PLACE AN OUTBOUND GEOCODE HAPPENS. `ai_enrich.py`'s geocode field calls this rather
|
| 179 |
+
than reaching for `requests` itself, so the throttle and the cache cannot be walked around by a
|
| 180 |
+
second caller. A cache hit costs no request and no wait."""
|
| 181 |
+
q = re.sub(r"\s+", " ", str(address or "")).strip()
|
| 182 |
+
if not q:
|
| 183 |
+
return None
|
| 184 |
+
key = _cache_key(q, country)
|
| 185 |
+
hit = _cache_get(key)
|
| 186 |
+
if hit is not None:
|
| 187 |
+
return dict(hit) if hit else None
|
| 188 |
+
|
| 189 |
+
cfg = provider_config()["geocode"]
|
| 190 |
+
_throttle(cfg["minIntervalMs"])
|
| 191 |
+
params = {"q": q, "format": "jsonv2", "limit": 1}
|
| 192 |
+
if country:
|
| 193 |
+
params["countrycodes"] = str(country).strip().lower()
|
| 194 |
+
try:
|
| 195 |
+
r = requests.get(
|
| 196 |
+
_env("AIOS_GEOCODE_URL", _GEOCODE_URL_DEFAULT),
|
| 197 |
+
params=params,
|
| 198 |
+
headers={"User-Agent": _env("AIOS_GEO_USER_AGENT", _UA_DEFAULT),
|
| 199 |
+
"Accept": "application/json"},
|
| 200 |
+
timeout=timeout,
|
| 201 |
+
)
|
| 202 |
+
r.raise_for_status()
|
| 203 |
+
rows = r.json()
|
| 204 |
+
except Exception:
|
| 205 |
+
# β A transport failure is NOT cached. Caching it would turn one bad minute into a
|
| 206 |
+
# permanently empty column that no re-run could ever repair.
|
| 207 |
+
return None
|
| 208 |
+
if not isinstance(rows, list) or not rows:
|
| 209 |
+
# A genuine "no such place" IS cached, as a negative: asking again gets the same answer and
|
| 210 |
+
# spends another second of a shared service's budget to hear it.
|
| 211 |
+
_cache_put(key, {})
|
| 212 |
+
return None
|
| 213 |
+
top = rows[0] or {}
|
| 214 |
+
try:
|
| 215 |
+
lat = float(top.get("lat"))
|
| 216 |
+
lon = float(top.get("lon"))
|
| 217 |
+
except (TypeError, ValueError):
|
| 218 |
+
_cache_put(key, {})
|
| 219 |
+
return None
|
| 220 |
+
if not (-90 <= lat <= 90) or not (-180 <= lon <= 180):
|
| 221 |
+
_cache_put(key, {})
|
| 222 |
+
return None
|
| 223 |
+
out = {"lat": lat, "lon": lon, "label": str(top.get("display_name") or q)}
|
| 224 |
+
_cache_put(key, out)
|
| 225 |
+
return dict(out)
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
# ---------------------------------------------------------------------- the doors
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
@router.get("/geo/providers")
|
| 232 |
+
def geo_providers(session: Session = Depends(require_session)):
|
| 233 |
+
"""What the map should draw with, and who to credit for it.
|
| 234 |
+
|
| 235 |
+
Session gated like every other door here. The values are not secret, but an unauthenticated
|
| 236 |
+
endpoint on this app is a door somebody eventually hangs something else on."""
|
| 237 |
+
return provider_config()
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
@router.post("/geo/geocode")
|
| 241 |
+
def geo_geocode(body: dict = Body(...), session: Session = Depends(require_session)):
|
| 242 |
+
"""Turn a bounded list of addresses into coordinates.
|
| 243 |
+
|
| 244 |
+
β Takes ADDRESSES, never a table key, a view id or a filter. There is deliberately no shape of
|
| 245 |
+
request that means "geocode everything", which is R3 expressed as an API rather than as a
|
| 246 |
+
warning."""
|
| 247 |
+
items = body.get("addresses")
|
| 248 |
+
# β THE SECOND SHAPE, AND IT EXISTS TO KEEP ONE COMPOSER. A caller may send whole RECORDS plus
|
| 249 |
+
# the geocode field's config instead of finished address strings, and the server builds the
|
| 250 |
+
# query with `ai_enrich.geocode_query`. The alternative was a TypeScript twin of that function
|
| 251 |
+
# on the client, and an address composer that exists twice will disagree the first time either
|
| 252 |
+
# copy learns about a new column ([[one-question-two-normalizers]]) -- which is exactly the trap
|
| 253 |
+
# B's measured `New York (US)` suffix would spring, silently, in whichever copy forgot.
|
| 254 |
+
if items is None:
|
| 255 |
+
records = body.get("records")
|
| 256 |
+
cfg = body.get("config") or {}
|
| 257 |
+
if isinstance(records, list) and records:
|
| 258 |
+
import ai_enrich
|
| 259 |
+
items = []
|
| 260 |
+
for rec in records:
|
| 261 |
+
if not isinstance(rec, dict):
|
| 262 |
+
continue
|
| 263 |
+
q = ai_enrich.geocode_query(rec.get("row") or {}, cfg)
|
| 264 |
+
# β A record with no usable address is REPORTED, not dropped: it comes back
|
| 265 |
+
# `found: false` with an empty address, so the caller can name it on screen instead
|
| 266 |
+
# of quietly returning fewer answers than it asked questions.
|
| 267 |
+
items.append({"key": rec.get("key"), "address": q})
|
| 268 |
+
if not body.get("country") and cfg.get("country"):
|
| 269 |
+
body = {**body, "country": cfg.get("country")}
|
| 270 |
+
if not isinstance(items, list) or not items:
|
| 271 |
+
raise err(400, "no_addresses", "Send at least one address to look up.")
|
| 272 |
+
if len(items) > MAX_BATCH:
|
| 273 |
+
raise err(
|
| 274 |
+
400,
|
| 275 |
+
"batch_too_large",
|
| 276 |
+
f"Look up at most {MAX_BATCH} addresses per request. "
|
| 277 |
+
f"The map sends them in batches of {MAX_BATCH} so the wait stays visible.",
|
| 278 |
+
)
|
| 279 |
+
country = body.get("country")
|
| 280 |
+
out = []
|
| 281 |
+
started = time.monotonic()
|
| 282 |
+
for raw in items:
|
| 283 |
+
if isinstance(raw, dict):
|
| 284 |
+
key, addr = raw.get("key"), raw.get("address")
|
| 285 |
+
else:
|
| 286 |
+
key, addr = None, raw
|
| 287 |
+
# β AN EMPTY QUERY COSTS NO REQUEST AND CARRIES ITS OWN REASON. Nominatim's policy is a
|
| 288 |
+
# budget shared with everyone else using it, and asking it to place "" would spend a second
|
| 289 |
+
# of that budget to be told nothing. `reason` is what lets the surface say "no address on
|
| 290 |
+
# file" rather than "not found", which are different facts and want different actions.
|
| 291 |
+
if not str(addr or "").strip():
|
| 292 |
+
out.append({"key": key, "address": "", "found": False, "lat": None, "lon": None,
|
| 293 |
+
"label": None, "reason": "no_address"})
|
| 294 |
+
continue
|
| 295 |
+
hit = geocode_one(addr, country=country)
|
| 296 |
+
out.append({"key": key, "address": str(addr or ""), "found": bool(hit),
|
| 297 |
+
"reason": None if hit else "not_found",
|
| 298 |
+
"lat": hit["lat"] if hit else None,
|
| 299 |
+
"lon": hit["lon"] if hit else None,
|
| 300 |
+
"label": hit["label"] if hit else None})
|
| 301 |
+
elapsed = time.monotonic() - started
|
| 302 |
+
return {"results": out,
|
| 303 |
+
"found": sum(1 for r in out if r["found"]),
|
| 304 |
+
"asked": len(out),
|
| 305 |
+
"secondsElapsed": round(elapsed, 2),
|
| 306 |
+
"attribution": provider_config()["geocode"]["attribution"]}
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
@router.post("/geo/route")
|
| 310 |
+
def geo_route(body: dict = Body(...), session: Session = Depends(require_session)):
|
| 311 |
+
"""Road distance, duration and the drawn line, for stops ALREADY put in order.
|
| 312 |
+
|
| 313 |
+
β THE ORDERING IS NOT DONE HERE. `mapProjection.planRoute` sequences the stops on the client,
|
| 314 |
+
for free, and keeps working when this service does not answer. This door adds the half that
|
| 315 |
+
arithmetic cannot produce: what the ROADS actually cost. Splitting it that way is why the Start
|
| 316 |
+
picker and the round-trip toggle keep working with no network at all."""
|
| 317 |
+
stops = body.get("stops")
|
| 318 |
+
if not isinstance(stops, list) or len(stops) < 2:
|
| 319 |
+
raise err(400, "too_few_stops", "A route needs at least two stops with a location.")
|
| 320 |
+
if len(stops) > 25:
|
| 321 |
+
raise err(400, "too_many_stops",
|
| 322 |
+
"Route at most 25 stops at once. Narrow the selection and try again.")
|
| 323 |
+
pairs = []
|
| 324 |
+
for s in stops:
|
| 325 |
+
try:
|
| 326 |
+
lat, lon = float(s["lat"]), float(s["lon"])
|
| 327 |
+
except (TypeError, ValueError, KeyError, IndexError):
|
| 328 |
+
raise err(400, "bad_stop", "Every stop needs a numeric latitude and longitude.")
|
| 329 |
+
if not (-90 <= lat <= 90) or not (-180 <= lon <= 180):
|
| 330 |
+
raise err(400, "bad_stop", "Every stop needs a latitude and longitude on the globe.")
|
| 331 |
+
pairs.append(f"{lon:.6f},{lat:.6f}")
|
| 332 |
+
|
| 333 |
+
base = _env("AIOS_ROUTE_URL", _ROUTE_URL_DEFAULT)
|
| 334 |
+
_throttle(_env_int("AIOS_GEOCODE_MIN_INTERVAL_MS", _MIN_INTERVAL_MS_DEFAULT))
|
| 335 |
+
try:
|
| 336 |
+
r = requests.get(
|
| 337 |
+
base.rstrip("/") + "/" + ";".join(pairs),
|
| 338 |
+
params={"overview": "simplified", "geometries": "geojson", "steps": "false"},
|
| 339 |
+
headers={"User-Agent": _env("AIOS_GEO_USER_AGENT", _UA_DEFAULT)},
|
| 340 |
+
timeout=20,
|
| 341 |
+
)
|
| 342 |
+
r.raise_for_status()
|
| 343 |
+
data = r.json()
|
| 344 |
+
except Exception:
|
| 345 |
+
raise err(502, "route_service_unavailable",
|
| 346 |
+
"The routing service did not answer. The stop order and the straight line "
|
| 347 |
+
"distance are still on the map.")
|
| 348 |
+
routes = (data or {}).get("routes") or []
|
| 349 |
+
if not routes:
|
| 350 |
+
raise err(
|
| 351 |
+
502,
|
| 352 |
+
"no_route",
|
| 353 |
+
"No road route connects these stops. They may be on different land masses, or one of "
|
| 354 |
+
"them may be far from any road.",
|
| 355 |
+
)
|
| 356 |
+
top = routes[0]
|
| 357 |
+
geom = ((top.get("geometry") or {}).get("coordinates")) or []
|
| 358 |
+
line = []
|
| 359 |
+
for c in geom:
|
| 360 |
+
try:
|
| 361 |
+
line.append([float(c[0]), float(c[1])])
|
| 362 |
+
except (TypeError, ValueError, IndexError):
|
| 363 |
+
continue
|
| 364 |
+
return {
|
| 365 |
+
"km": round(float(top.get("distance") or 0.0) / 1000.0, 1),
|
| 366 |
+
"minutes": int(round(float(top.get("duration") or 0.0) / 60.0)),
|
| 367 |
+
"line": line,
|
| 368 |
+
"attribution": provider_config()["route"]["attribution"],
|
| 369 |
+
}
|
api/routes_nav.py
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
api/routes_products.py
CHANGED
|
@@ -115,15 +115,190 @@ def scoped_pool(session: Session):
|
|
| 115 |
return pids, team_id, rows_src, fields_base
|
| 116 |
|
| 117 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
def product_assembly(session: Session, scope: str = "product", storage_key: str = "",
|
| 119 |
consume_corrections: bool = True):
|
| 120 |
"""The product topic's mirror of `routes_customers.grid_assembly` β SAME g-dict keys, so
|
| 121 |
the /workspace and events routes consume either interchangeably.
|
| 122 |
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
|
| 128 |
β WAVE 19 / R9 β `lists` IS NO LONGER EMPTY. Wave 16 passed `with_cohorts=False` because
|
| 129 |
there was one customer-keyed cohort bucket and product pids are CRC32 hashes of SKU codes;
|
|
@@ -172,12 +347,29 @@ def product_assembly(session: Session, scope: str = "product", storage_key: str
|
|
| 172 |
fields = [f for f in fields if f.get("key") not in hidden]
|
| 173 |
rows_src = [perm_scope.strip_row(r, hidden) for r in rows_src]
|
| 174 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
return {"rows_src": rows_src, "pids": pids, "ws": ws, "workspace": workspace,
|
| 176 |
"fields": fields, "views": views, "lists": lists,
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
"derived": aios_grid.cohort_cells(lists),
|
| 180 |
-
"measures": [], "measure_sets": {}, "today": time.strftime("%Y-%m-%d"),
|
| 181 |
"team_id": team_id}
|
| 182 |
|
| 183 |
|
|
|
|
| 115 |
return pids, team_id, rows_src, fields_base
|
| 116 |
|
| 117 |
|
| 118 |
+
#: The semantic ENTITY topic this grid IS. `model/topics/odoo_products.yml` names the same store
|
| 119 |
+
#: key in its `grid:` field, so the binding is stated at both ends rather than inferred.
|
| 120 |
+
TOPIC = "product_data"
|
| 121 |
+
SEM_TOPIC = "odoo_products"
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def _measure_err(tag, e):
|
| 125 |
+
try:
|
| 126 |
+
import harness.telemetry as _tel
|
| 127 |
+
_tel.error(f"api:{tag}", e)
|
| 128 |
+
except Exception:
|
| 129 |
+
pass
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def _measure_stamp(rt, team_id):
|
| 133 |
+
"""The cached pool's build timestamp β the DATA STAMP in every memo key, so a pool refresh
|
| 134 |
+
invalidates memoised measure answers exactly when the underlying rows changed. Same
|
| 135 |
+
discipline as `routes_customers._pool_stamp`, over THIS route's cache key."""
|
| 136 |
+
entry = rt.pool_cache.get(("product_pool", team_id))
|
| 137 |
+
return entry[0] if isinstance(entry, tuple) and entry else 0
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def product_measures():
|
| 141 |
+
"""The measure OFFER for this database, or `[]` when the semantic layer cannot answer.
|
| 142 |
+
|
| 143 |
+
ββ W37-T10 / owner item 4 (R1) β this is what turns `measures: []` into a real catalogue.
|
| 144 |
+
Owner: *"Odoo products should have a lookbsck metrics like sales etc."* The list is DERIVED
|
| 145 |
+
from `model/topics/odoo_products.yml`'s `measures:` binding, never written here β a second
|
| 146 |
+
list would be a second definition of the same fact.
|
| 147 |
+
|
| 148 |
+
β `[]` ON FAILURE IS THE CORRECT DEGRADE and it is not silent: `entity_measures` refuses a key
|
| 149 |
+
it cannot prove, the reasons are readable through `semantic.entity_measure_refusals`, and the
|
| 150 |
+
client's own rule (`offerMeasure={measures.length > 0}`) then hides the Metric kind rather
|
| 151 |
+
than offering a column that could only render blank.
|
| 152 |
+
"""
|
| 153 |
+
try:
|
| 154 |
+
from harness import semantic as sem
|
| 155 |
+
return sem.entity_measures(SEM_TOPIC)
|
| 156 |
+
except Exception as e: # noqa: BLE001
|
| 157 |
+
_measure_err("product-measure-offer", e)
|
| 158 |
+
return []
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def _measure_cells(fields, rows_src, team_id, today, stamp, memo, offer):
|
| 162 |
+
"""`{pid: {field key: value}}` for every measure column on this grid.
|
| 163 |
+
|
| 164 |
+
β THIS IS THE HALF THAT MAKES THE FEATURE REAL. Serving the OFFER alone flips the client's
|
| 165 |
+
`offerMeasure` on and lets a user mint a Metric column that would then be blank forever β
|
| 166 |
+
this repo's most repeated defect ([[reachable-is-not-the-same-as-built]]). Nothing else
|
| 167 |
+
computes product-grain measure values: `core.measure_resolve.column_values` is CUSTOMER-grain
|
| 168 |
+
(it resolves through `harness.measure_filter`, whose entity is the partner), which is exactly
|
| 169 |
+
what this route's docstring used to record as an honest absence.
|
| 170 |
+
|
| 171 |
+
β ONE QUERY PER WINDOW, not per column. Two Metric columns over the same 90 days are one
|
| 172 |
+
grouped scan; the fan-out to field keys happens in memory afterwards.
|
| 173 |
+
|
| 174 |
+
β THE JOIN IS BY SKU CODE, per contract C1's trap. `sales_lines.product_code` reproduces this
|
| 175 |
+
pool's identity (`default_code`, or `pid:<odoo id>` for the 33 codeless actives) and `pid` is
|
| 176 |
+
a CRC32 of it β so the map is `row['code'] -> row['pid']`, taken off the rows we already hold.
|
| 177 |
+
Grouping by Odoo's `product_id` instead would key cells on a value no grid row carries.
|
| 178 |
+
"""
|
| 179 |
+
import aios_grid
|
| 180 |
+
|
| 181 |
+
mfields = aios_grid.measure_fields_of(fields)
|
| 182 |
+
if not mfields or not offer:
|
| 183 |
+
return {}
|
| 184 |
+
from harness import semantic as sem
|
| 185 |
+
from harness import windows as _wn
|
| 186 |
+
|
| 187 |
+
by_key = {m["key"]: m for m in offer}
|
| 188 |
+
codes = {r["pid"]: r["code"] for r in rows_src if r.get("pid") is not None}
|
| 189 |
+
|
| 190 |
+
# Group the columns by WINDOW: same window, same scan.
|
| 191 |
+
by_window = {}
|
| 192 |
+
for f in mfields:
|
| 193 |
+
spec = f.get("measure") or {}
|
| 194 |
+
if spec.get("key") not in by_key:
|
| 195 |
+
continue # not offered for this caller -> the column stays blank, honestly
|
| 196 |
+
w = _wn.normalize(spec.get("window"))
|
| 197 |
+
rng = _wn.resolve(spec.get("window"), today) if w is not None else None
|
| 198 |
+
if rng is None:
|
| 199 |
+
# β NEVER WIDEN TO ALL TIME. An unresolvable window is an unanswerable column, and
|
| 200 |
+
# silently answering a different question is worse than a blank one.
|
| 201 |
+
continue
|
| 202 |
+
by_window.setdefault((rng[0], rng[1]), []).append(f)
|
| 203 |
+
|
| 204 |
+
out = {}
|
| 205 |
+
for (dfrom, dto), group in by_window.items():
|
| 206 |
+
keys = tuple(sorted({f["measure"]["key"] for f in group}))
|
| 207 |
+
mkey = ("entity", SEM_TOPIC, stamp, team_id, dfrom, dto, keys)
|
| 208 |
+
if mkey not in memo:
|
| 209 |
+
try:
|
| 210 |
+
memo[mkey] = sem.entity_measure_values(
|
| 211 |
+
SEM_TOPIC, list(keys), date_from=dfrom, date_to=dto, team_id=team_id,
|
| 212 |
+
# β SERVICES ARE IN THIS CATALOGUE (37 active), so they must NOT be excluded
|
| 213 |
+
# here. The service filter exists to stop Delivery Charges polluting a SKU
|
| 214 |
+
# RANKING; on a per-row column the row IS the service product, and excluding
|
| 215 |
+
# it would print a FALSE 0 β which under C1's rule asserts "sold nothing".
|
| 216 |
+
exclude_services=False, offer=offer)
|
| 217 |
+
except Exception as e: # noqa: BLE001
|
| 218 |
+
_measure_err("product-measure-column", e)
|
| 219 |
+
# Same memo discipline as `measure_resolve.column_values`: a PERMANENT failure
|
| 220 |
+
# memoises, a TRANSIENT one (store still warming after a restart) must not, or
|
| 221 |
+
# the cells stay blank for the rest of the session long after the data landed.
|
| 222 |
+
transient = False
|
| 223 |
+
try:
|
| 224 |
+
from harness import datastore as _ds
|
| 225 |
+
transient = not _ds.ready()
|
| 226 |
+
except Exception: # noqa: BLE001
|
| 227 |
+
transient = False
|
| 228 |
+
if transient:
|
| 229 |
+
continue
|
| 230 |
+
memo[mkey] = None
|
| 231 |
+
vals = memo[mkey]
|
| 232 |
+
if vals is None:
|
| 233 |
+
continue
|
| 234 |
+
# C1's empty-window rule, applied to EVERY row β not only the ones with a group. 72% of
|
| 235 |
+
# this catalogue has no group in a 90-day window, so this loop IS the common case.
|
| 236 |
+
filled = {pid: sem.entity_zero_fill(vals.get(code), list(keys), offer)
|
| 237 |
+
for pid, code in codes.items()}
|
| 238 |
+
for f in group:
|
| 239 |
+
k, fkey = f["measure"]["key"], f["key"]
|
| 240 |
+
for pid, cell in filled.items():
|
| 241 |
+
if k in cell:
|
| 242 |
+
out.setdefault(pid, {})[fkey] = cell[k]
|
| 243 |
+
if len(memo) > 100: # a lifetime cache, not a leak
|
| 244 |
+
memo.clear()
|
| 245 |
+
return out
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
def _measure_condition_sets(views, offer, rows_src, team_id, today):
|
| 249 |
+
"""`{rule id: [pid, β¦]}` for every measure CONDITION across this session's saved views.
|
| 250 |
+
|
| 251 |
+
β THE ANSWER IS KEYED BY pid, NOT BY SKU CODE. `entity_measure_sets` answers in the SOURCE
|
| 252 |
+
dim's own values (the code) because it cannot know how a grid hashes identity; the map back
|
| 253 |
+
is this route's job, exactly as it is for the column cells.
|
| 254 |
+
"""
|
| 255 |
+
if not offer:
|
| 256 |
+
return {}
|
| 257 |
+
from harness import semantic as sem
|
| 258 |
+
|
| 259 |
+
admitted = {m["key"] for m in offer}
|
| 260 |
+
by_id = {}
|
| 261 |
+
for v in views or []:
|
| 262 |
+
cfg = (v.get("config") or {})
|
| 263 |
+
for rule in sem.entity_measure_leaves(cfg.get("filters"), admitted):
|
| 264 |
+
rid = str(rule.get("id") or "")
|
| 265 |
+
if rid:
|
| 266 |
+
by_id[rid] = rule # last statement of the question wins
|
| 267 |
+
if not by_id:
|
| 268 |
+
return {}
|
| 269 |
+
pid_by_code = {r["code"]: r["pid"] for r in rows_src if r.get("pid") is not None}
|
| 270 |
+
try:
|
| 271 |
+
sets = sem.entity_measure_sets(
|
| 272 |
+
SEM_TOPIC, list(by_id.values()), today, team_id=team_id,
|
| 273 |
+
keys_by_id=list(pid_by_code), offer=offer, exclude_services=False)
|
| 274 |
+
except Exception as e: # noqa: BLE001
|
| 275 |
+
_measure_err("product-measure-sets", e)
|
| 276 |
+
return {}
|
| 277 |
+
# A code the pool does not carry is dropped rather than keyed on None β the same rule the
|
| 278 |
+
# cell path applies, for the same reason.
|
| 279 |
+
return {rid: sorted(pid_by_code[c] for c in codes if c in pid_by_code)
|
| 280 |
+
for rid, codes in sets.items()}
|
| 281 |
+
|
| 282 |
+
|
| 283 |
def product_assembly(session: Session, scope: str = "product", storage_key: str = "",
|
| 284 |
consume_corrections: bool = True):
|
| 285 |
"""The product topic's mirror of `routes_customers.grid_assembly` β SAME g-dict keys, so
|
| 286 |
the /workspace and events routes consume either interchangeably.
|
| 287 |
|
| 288 |
+
ββ W37-T10 / OWNER ITEM 4 (R1) β `measures` IS NO LONGER EMPTY, and the header above it used
|
| 289 |
+
to record the opposite as a topic fact. It was one: `core.measure_resolve` is CUSTOMER-grain,
|
| 290 |
+
so nothing here could resolve a product-grain measure. What changed is that the ENTITY path
|
| 291 |
+
exists now (`semantic.entity_measures` / `entity_measure_values`, bound in
|
| 292 |
+
`model/topics/odoo_products.yml`), so this surface serves a real six-metric catalogue AND the
|
| 293 |
+
cells to go with it β the offer and the resolver landing together, deliberately, because an
|
| 294 |
+
offer whose values nobody computes is a column that renders blank forever.
|
| 295 |
+
|
| 296 |
+
One deliberate absence remains, and it is a real one:
|
| 297 |
+
* `measure_sets` stays EMPTY β that is the measure CONDITION channel (a filter answered
|
| 298 |
+
server-side as a pid set), which still runs through the customer-grain
|
| 299 |
+
`measure_resolve.condition_sets`. So a Metric COLUMN works here; a Metric FILTER does
|
| 300 |
+
not yet. Stated rather than left to be discovered: `clean_filter_tree` drops a measure
|
| 301 |
+
condition whose key is not in `measure_keys`, so the refusal is already fail-closed.
|
| 302 |
|
| 303 |
β WAVE 19 / R9 β `lists` IS NO LONGER EMPTY. Wave 16 passed `with_cohorts=False` because
|
| 304 |
there was one customer-keyed cohort bucket and product pids are CRC32 hashes of SKU codes;
|
|
|
|
| 347 |
fields = [f for f in fields if f.get("key") not in hidden]
|
| 348 |
rows_src = [perm_scope.strip_row(r, hidden) for r in rows_src]
|
| 349 |
|
| 350 |
+
today = time.strftime("%Y-%m-%d")
|
| 351 |
+
stamp = _measure_stamp(session.runtime, team_id)
|
| 352 |
+
measures = product_measures()
|
| 353 |
+
|
| 354 |
+
# R9: the Cohorts column's cells, built from THIS topic's lists. β W37-T10 β the measure
|
| 355 |
+
# half of this same read-only channel is no longer empty; the customer assembly merges its
|
| 356 |
+
# measure columns here for exactly the same reason and through the same door.
|
| 357 |
+
derived = aios_grid.cohort_cells(lists)
|
| 358 |
+
for pid, cells in _measure_cells(fields, rows_src, team_id, today, stamp,
|
| 359 |
+
session.runtime.measure_memo, measures).items():
|
| 360 |
+
derived.setdefault(pid, {}).update(cells)
|
| 361 |
+
|
| 362 |
+
# β THE CONDITION CHANNEL SHIPS WITH THE OFFER, NOT AFTER IT. `routes_grid` derives
|
| 363 |
+
# `measure_keys` from this same list, so the moment `measures` is non-empty the client's
|
| 364 |
+
# filter builder offers measure conditions β and an id missing from `measureSets` renders as
|
| 365 |
+
# PENDING ("Calculatingβ¦") and matches nothing, permanently. Serving the offer without this
|
| 366 |
+
# would trade one honest absence for a spinner that never resolves.
|
| 367 |
+
measure_sets = _measure_condition_sets(views, measures, rows_src, team_id, today)
|
| 368 |
+
|
| 369 |
return {"rows_src": rows_src, "pids": pids, "ws": ws, "workspace": workspace,
|
| 370 |
"fields": fields, "views": views, "lists": lists,
|
| 371 |
+
"derived": derived,
|
| 372 |
+
"measures": measures, "measure_sets": measure_sets, "today": today,
|
|
|
|
|
|
|
| 373 |
"team_id": team_id}
|
| 374 |
|
| 375 |
|
api/routes_script_views.py
CHANGED
|
@@ -1,329 +1,439 @@
|
|
| 1 |
-
"""routes_script_views.py β CONTRACT C3: a database View that is a PYTHON SCRIPT (R3 / R5 / R10).
|
| 2 |
-
|
| 3 |
-
Owner item 6, verbatim (2026-08-18): *"Add code script as an interface (database View) so a user
|
| 4 |
-
can build whatever they want through the Agent chat interface. be able to create any dashboard
|
| 5 |
-
they want. User should have the ability to see the code AND the dashboard output of course⦠Limit
|
| 6 |
-
the code script View per database⦠Any agent can add into more AI script, so we can see different
|
| 7 |
-
versions or different things the AI code for us."*
|
| 8 |
-
|
| 9 |
-
GET /api/v1/script-views?database=K the views bound to ONE database
|
| 10 |
-
POST /api/v1/script-views create one {database, name?, source}
|
| 11 |
-
GET /api/v1/script-views/{id} one view, its source and its history
|
| 12 |
-
PUT /api/v1/script-views/{id} a NEW VERSION of the source
|
| 13 |
-
DELETE /api/v1/script-views/{id} drop it
|
| 14 |
-
POST /api/v1/script-views/{id}/run run it -> {ok, spec | error, stdout, ms}
|
| 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 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
"""
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
""
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
if not isinstance(
|
| 255 |
-
raise err(
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""routes_script_views.py β CONTRACT C3: a database View that is a PYTHON SCRIPT (R3 / R5 / R10).
|
| 2 |
+
|
| 3 |
+
Owner item 6, verbatim (2026-08-18): *"Add code script as an interface (database View) so a user
|
| 4 |
+
can build whatever they want through the Agent chat interface. be able to create any dashboard
|
| 5 |
+
they want. User should have the ability to see the code AND the dashboard output of course⦠Limit
|
| 6 |
+
the code script View per database⦠Any agent can add into more AI script, so we can see different
|
| 7 |
+
versions or different things the AI code for us."*
|
| 8 |
+
|
| 9 |
+
GET /api/v1/script-views?database=K the views bound to ONE database
|
| 10 |
+
POST /api/v1/script-views create one {database, name?, source}
|
| 11 |
+
GET /api/v1/script-views/{id} one view, its source and its history
|
| 12 |
+
PUT /api/v1/script-views/{id} a NEW VERSION of the source
|
| 13 |
+
DELETE /api/v1/script-views/{id} drop it
|
| 14 |
+
POST /api/v1/script-views/{id}/run run it -> {ok, spec | error, stdout, ms}
|
| 15 |
+
POST /api/v1/script-views/{id}/revert go back to an earlier version {version}
|
| 16 |
+
|
| 17 |
+
ββ **R3 IS "SCOPED, NOT CAPPED", AND THE TWO HALVES POINT OPPOSITE WAYS.** *Scoped*: a view may
|
| 18 |
+
read ONLY the database it lives in, and a script that names another database is REFUSED with a
|
| 19 |
+
message naming both. *Not capped*: there is **no limit on how many script views a database may
|
| 20 |
+
carry**, because that is how an agent offers three attempts and the owner picks one. So nothing
|
| 21 |
+
below counts views. What IS bounded is what makes them big β one source is capped, and one view's
|
| 22 |
+
edit history is capped and REPORTS what it dropped.
|
| 23 |
+
|
| 24 |
+
β **THE RUN IS THE CALLER'S, NEVER THE AUTHOR'S (R5).** `script_sandbox.run_view` is handed
|
| 25 |
+
`session.user`, so a script written by an administrator and opened by a scoped analyst reads the
|
| 26 |
+
ANALYST's rows. The author decides what the code does; the reader decides what it can see.
|
| 27 |
+
β And the reverse case is safe rather than lucky: a narrowly-scoped author cannot write a script
|
| 28 |
+
that exfiltrates anything, because the only thing a script can return is a render spec drawn on
|
| 29 |
+
the screen of the person who ran it. There is no network, no file and no second reader.
|
| 30 |
+
|
| 31 |
+
β **`run` IS `def`, NOT `async def`.** It waits on a subprocess for up to ten seconds; as a
|
| 32 |
+
coroutine that would block the event loop for every other request in the container. FastAPI runs a
|
| 33 |
+
plain `def` in the threadpool, which is what makes one slow script one slow REQUEST.
|
| 34 |
+
"""
|
| 35 |
+
import threading
|
| 36 |
+
from datetime import datetime, timezone
|
| 37 |
+
|
| 38 |
+
from fastapi import APIRouter, Body, Depends
|
| 39 |
+
|
| 40 |
+
from deps import Session, err, require_session
|
| 41 |
+
|
| 42 |
+
router = APIRouter(prefix="/api/v1")
|
| 43 |
+
|
| 44 |
+
#: The tenant's script views: `{id: record}`. Per tenant, so it rides `runtime.store_key`.
|
| 45 |
+
VIEWS_KEY = "script_views"
|
| 46 |
+
|
| 47 |
+
MAX_NAME = 80
|
| 48 |
+
MAX_SOURCE_BYTES = 128 * 1024
|
| 49 |
+
|
| 50 |
+
#: Edit history per view. β NOT a cap on the NUMBER of views (R3 forbids that) β a cap on how far
|
| 51 |
+
#: back ONE view's source is kept. Past this the oldest go and `trimmed` counts them, so a reader
|
| 52 |
+
#: can see the history is partial instead of concluding the view was only ever saved twice.
|
| 53 |
+
MAX_HISTORY = 40
|
| 54 |
+
|
| 55 |
+
#: β HOW MANY SCRIPTS MAY BE RUNNING IN THIS CONTAINER AT ONCE, and it is a REPORTED refusal
|
| 56 |
+
#: rather than a queue. Each run is a real subprocess with a ten-second wall clock; without this,
|
| 57 |
+
#: holding down refresh forks until the box gives up, and the tenant's ONE FastAPI process is what
|
| 58 |
+
#: gives up. A 429 that says so is honest; an unbounded fork is not.
|
| 59 |
+
MAX_CONCURRENT_RUNS = 4
|
| 60 |
+
_RUN_SLOTS = threading.BoundedSemaphore(MAX_CONCURRENT_RUNS)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _now():
|
| 64 |
+
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _all(rt):
|
| 68 |
+
"""`{id: record}` for one tenant. `{}` on any failure β an unreadable bucket must degrade to
|
| 69 |
+
"this database has no script views", never to a 500 on the view rail."""
|
| 70 |
+
try:
|
| 71 |
+
found = rt.get(VIEWS_KEY) or {}
|
| 72 |
+
except Exception: # noqa: BLE001
|
| 73 |
+
return {}
|
| 74 |
+
return found if isinstance(found, dict) else {}
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def _database_ok(session, database):
|
| 78 |
+
"""Does this database EXIST, and may this caller read it? Answered by C1, never by a list.
|
| 79 |
+
|
| 80 |
+
β `perm_scope.may_read` ALONE IS NOT ENOUGH and the reason is easy to miss: it answers True
|
| 81 |
+
for an ADMIN on any key at all, including one no database answers to. So a create validated
|
| 82 |
+
with `may_read` would let an administrator bind a view to a typo and leave an orphan nothing
|
| 83 |
+
can ever run. `scoped_fields` is the cheap half of C1 (a `ut_*` definition, no rows) and it
|
| 84 |
+
RAISES `UnknownTable`, which is exactly the question being asked.
|
| 85 |
+
"""
|
| 86 |
+
import core.perm_scope as perm_scope
|
| 87 |
+
try:
|
| 88 |
+
perm_scope.scoped_fields(session.user, database, st=session.runtime)
|
| 89 |
+
except perm_scope.UnknownTable:
|
| 90 |
+
raise err(404, "no_database", f"there is no database '{database}' in this workspace")
|
| 91 |
+
except perm_scope.Denied:
|
| 92 |
+
raise err(403, "forbidden", f"your account may not read '{database}'")
|
| 93 |
+
except perm_scope.Unresolvable as exc:
|
| 94 |
+
# The database is real and cannot be served under this call's constraints. Standing rule
|
| 95 |
+
# 1's second sentence: report the cause and the recommendation, never a bare refusal.
|
| 96 |
+
raise err(409, "unresolvable", str(exc)) from None
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def _clean_source(raw, *, allow_empty=False):
|
| 100 |
+
"""The stored source, or a 400. `allow_empty` is CREATE's alone and the asymmetry is the point.
|
| 101 |
+
|
| 102 |
+
ββ W37-T41 β WHY CREATE MAY BE EMPTY AND SAVE MAY NOT.
|
| 103 |
+
Picking "Custom View" in the mode picker mints the view immediately, before a line of code
|
| 104 |
+
exists, so that `mode === 'script'` always implies a real id and no reader has to carry a
|
| 105 |
+
"the id might be missing" branch (the create semantics handed to lane C in mailbox E-7).
|
| 106 |
+
A view that has been created and not yet written is therefore a REAL, legible state: the editor
|
| 107 |
+
shows its starter placeholder and the Run control is right there.
|
| 108 |
+
A PUT is a different act. It appends a VERSION to a history capped at 40, and blanking a
|
| 109 |
+
working script by saving nothing over it is not an edit anybody means to make. So the guard
|
| 110 |
+
stays exactly where it was on that door, and a person who wants the view gone deletes it.
|
| 111 |
+
|
| 112 |
+
β THIS WAS FOUND BY THE GATE, NOT BY READING. `core.script_sandbox.check_source("")` returns
|
| 113 |
+
None, so "an empty script is storable" looked true and was written into a mailbox answer another
|
| 114 |
+
lane was about to build on. The refusal was HERE, one layer above, in a function the sandbox
|
| 115 |
+
knows nothing about. Two validators for one question, disagreeing
|
| 116 |
+
[[one-question-two-normalizers]] β and the one that would have bitten a person is the one no
|
| 117 |
+
unit of this feature was asserting.
|
| 118 |
+
"""
|
| 119 |
+
source = str(raw or "")
|
| 120 |
+
if not source.strip() and not allow_empty:
|
| 121 |
+
raise err(400, "no_source", "a script view needs some code")
|
| 122 |
+
if len(source.encode("utf-8", "replace")) > MAX_SOURCE_BYTES:
|
| 123 |
+
raise err(413, "source_too_long",
|
| 124 |
+
f"a script view is at most {MAX_SOURCE_BYTES // 1024} KB of code")
|
| 125 |
+
return source
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def _row(rec, *, source=False):
|
| 129 |
+
"""One view as the list door reports it. β NO SOURCE unless asked: the rail lists names."""
|
| 130 |
+
out = {"id": rec.get("id") or "", "database": rec.get("database") or "",
|
| 131 |
+
"name": rec.get("name") or "", "author": rec.get("author") or "",
|
| 132 |
+
"version": int(rec.get("version") or 1),
|
| 133 |
+
"created": rec.get("created") or "", "updated": rec.get("updated") or "",
|
| 134 |
+
"versions": len(rec.get("history") or []) + 1,
|
| 135 |
+
"trimmed": int(rec.get("trimmed") or 0),
|
| 136 |
+
# β W37-T46: which version this one was RESTORED from, when it was. Present on the
|
| 137 |
+
# row (not only the history) because the editor's header is where a person reads "what
|
| 138 |
+
# am I looking at", and "v5, restored from v2" is the sentence that makes a roll-back
|
| 139 |
+
# legible as an event rather than as a coincidence of matching code.
|
| 140 |
+
"restoredFrom": rec.get("restoredFrom")}
|
| 141 |
+
if source:
|
| 142 |
+
out["source"] = rec.get("source") or ""
|
| 143 |
+
out["history"] = [{"version": int(h.get("version") or 0), "author": h.get("author") or "",
|
| 144 |
+
"created": h.get("created") or "",
|
| 145 |
+
"bytes": len(str(h.get("source") or "").encode("utf-8", "replace"))}
|
| 146 |
+
for h in reversed(rec.get("history") or []) if isinstance(h, dict)]
|
| 147 |
+
return out
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def _limits():
|
| 151 |
+
return {"maxSourceBytes": MAX_SOURCE_BYTES, "maxName": MAX_NAME,
|
| 152 |
+
"maxHistory": MAX_HISTORY, "maxConcurrentRuns": MAX_CONCURRENT_RUNS,
|
| 153 |
+
# β SAID OUT LOUD, because R3's "not capped" half is the one a reader assumes wrong.
|
| 154 |
+
"maxViewsPerDatabase": None}
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def _put(session, view_id, mutate):
|
| 158 |
+
"""Read-modify-write ONE view, synchronously β the client re-reads the rail immediately."""
|
| 159 |
+
def _set(cur):
|
| 160 |
+
cur = dict(cur or {})
|
| 161 |
+
nxt = mutate(cur.get(view_id) if isinstance(cur.get(view_id), dict) else None)
|
| 162 |
+
if nxt is None:
|
| 163 |
+
cur.pop(view_id, None)
|
| 164 |
+
else:
|
| 165 |
+
cur[view_id] = nxt
|
| 166 |
+
return cur
|
| 167 |
+
|
| 168 |
+
session.runtime.update(VIEWS_KEY, _set, flush="sync")
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def _mine_or_admin(session, rec):
|
| 172 |
+
"""Who may EDIT or DELETE a view: its author, or an administrator.
|
| 173 |
+
|
| 174 |
+
β RUNNING IS A DIFFERENT QUESTION and deliberately wider β anybody who may read the database
|
| 175 |
+
may run any view on it, under their OWN scope. That is the whole of "so we can see different
|
| 176 |
+
versions or different things the AI code for us": a colleague's attempt is worth nothing if
|
| 177 |
+
only its author can open it.
|
| 178 |
+
"""
|
| 179 |
+
if session.admin or str(rec.get("author") or "") == session.uname:
|
| 180 |
+
return
|
| 181 |
+
raise err(403, "forbidden", "only the author or an administrator can change this script view")
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
# ββ the routes ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 185 |
+
@router.get("/script-views")
|
| 186 |
+
def list_script_views(database: str = "", session: Session = Depends(require_session)):
|
| 187 |
+
"""Every script view bound to ONE database, newest first. `database` is required."""
|
| 188 |
+
key = str(database or "").strip()
|
| 189 |
+
if not key:
|
| 190 |
+
raise err(400, "no_database", "name the database whose script views you want")
|
| 191 |
+
_database_ok(session, key)
|
| 192 |
+
rows = [_row(rec) for rec in _all(session.runtime).values()
|
| 193 |
+
if isinstance(rec, dict) and str(rec.get("database") or "") == key]
|
| 194 |
+
rows.sort(key=lambda r: (r["created"], r["id"]), reverse=True)
|
| 195 |
+
return {"database": key, "views": rows, "limits": _limits()}
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
@router.post("/script-views")
|
| 199 |
+
def create_script_view(body: dict = Body(default=None),
|
| 200 |
+
session: Session = Depends(require_session)):
|
| 201 |
+
"""Create one. β THE SOURCE IS CHECKED BEFORE IT IS STORED, not first at run time.
|
| 202 |
+
|
| 203 |
+
A script view that cannot be run is a broken feature the reader discovers by pressing a button,
|
| 204 |
+
and the agent that wrote it is long gone by then. `check_source` is pure and costs no process,
|
| 205 |
+
so the refusal arrives while the author still has the code in front of them.
|
| 206 |
+
"""
|
| 207 |
+
import secrets # noqa: PLC0415
|
| 208 |
+
import core.script_sandbox as sandbox # noqa: PLC0415
|
| 209 |
+
|
| 210 |
+
body = body if isinstance(body, dict) else {}
|
| 211 |
+
database = str(body.get("database") or "").strip()
|
| 212 |
+
if not database:
|
| 213 |
+
raise err(400, "no_database", "a script view is bound to one database")
|
| 214 |
+
_database_ok(session, database)
|
| 215 |
+
# β is CREATE's alone - see . The picker mints a view the
|
| 216 |
+
# moment a person chooses the mode, so the empty source is the normal first state, not a slip.
|
| 217 |
+
source = _clean_source(body.get("source"), allow_empty=True)
|
| 218 |
+
refusal = sandbox.check_source(source)
|
| 219 |
+
if refusal is not None:
|
| 220 |
+
raise err(400, refusal.code, refusal.message)
|
| 221 |
+
|
| 222 |
+
view_id = "sv_" + secrets.token_urlsafe(9)
|
| 223 |
+
rec = {"id": view_id, "database": database,
|
| 224 |
+
"name": " ".join(str(body.get("name") or "Script view").split())[:MAX_NAME],
|
| 225 |
+
"source": source, "author": session.uname, "version": 1,
|
| 226 |
+
"created": _now(), "updated": _now(), "history": [], "trimmed": 0}
|
| 227 |
+
_put(session, view_id, lambda _prior: rec)
|
| 228 |
+
fresh = _all(session.runtime).get(view_id)
|
| 229 |
+
if not isinstance(fresh, dict):
|
| 230 |
+
# The store took the write and did not record it. A 200 here would tell the author their
|
| 231 |
+
# script was saved when it was not.
|
| 232 |
+
raise err(503, "store_unavailable", "the script view was NOT created")
|
| 233 |
+
return {"view": _row(fresh, source=True), "limits": _limits()}
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
@router.get("/script-views/{view_id}")
|
| 237 |
+
def get_script_view(view_id: str, session: Session = Depends(require_session)):
|
| 238 |
+
"""One view WITH its source and its edit history. Owner item 6's *"see the code"* half."""
|
| 239 |
+
rec = _all(session.runtime).get(str(view_id))
|
| 240 |
+
if not isinstance(rec, dict):
|
| 241 |
+
raise err(404, "no_view", "there is no script view with that id")
|
| 242 |
+
_database_ok(session, str(rec.get("database") or ""))
|
| 243 |
+
return {"view": _row(rec, source=True), "limits": _limits()}
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
@router.put("/script-views/{view_id}")
|
| 247 |
+
def update_script_view(view_id: str, body: dict = Body(default=None),
|
| 248 |
+
session: Session = Depends(require_session)):
|
| 249 |
+
"""A NEW VERSION of one view's source. The prior source is KEPT, never replaced in place."""
|
| 250 |
+
import core.script_sandbox as sandbox # noqa: PLC0415
|
| 251 |
+
|
| 252 |
+
view_id = str(view_id)
|
| 253 |
+
rec = _all(session.runtime).get(view_id)
|
| 254 |
+
if not isinstance(rec, dict):
|
| 255 |
+
raise err(404, "no_view", "there is no script view with that id")
|
| 256 |
+
_mine_or_admin(session, rec)
|
| 257 |
+
body = body if isinstance(body, dict) else {}
|
| 258 |
+
source = _clean_source(body.get("source"))
|
| 259 |
+
refusal = sandbox.check_source(source)
|
| 260 |
+
if refusal is not None:
|
| 261 |
+
raise err(400, refusal.code, refusal.message)
|
| 262 |
+
|
| 263 |
+
def _mutate(prior):
|
| 264 |
+
prior = dict(prior or rec)
|
| 265 |
+
history = list(prior.get("history") or [])
|
| 266 |
+
history.append({"version": int(prior.get("version") or 1),
|
| 267 |
+
"source": prior.get("source") or "",
|
| 268 |
+
"author": prior.get("author") or "", "created": prior.get("updated") or ""})
|
| 269 |
+
dropped = max(0, len(history) - MAX_HISTORY)
|
| 270 |
+
prior["history"] = history[dropped:] if dropped else history
|
| 271 |
+
prior["trimmed"] = int(prior.get("trimmed") or 0) + dropped
|
| 272 |
+
prior["source"] = source
|
| 273 |
+
prior["version"] = int(prior.get("version") or 1) + 1
|
| 274 |
+
prior["updated"] = _now()
|
| 275 |
+
if body.get("name"):
|
| 276 |
+
prior["name"] = " ".join(str(body["name"]).split())[:MAX_NAME]
|
| 277 |
+
return prior
|
| 278 |
+
|
| 279 |
+
_put(session, view_id, _mutate)
|
| 280 |
+
fresh = _all(session.runtime).get(view_id)
|
| 281 |
+
if not isinstance(fresh, dict):
|
| 282 |
+
raise err(503, "store_unavailable", "the new version was NOT saved")
|
| 283 |
+
return {"view": _row(fresh, source=True), "limits": _limits()}
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
@router.post("/script-views/{view_id}/revert")
|
| 287 |
+
def revert_script_view(view_id: str, body: dict = Body(default=None),
|
| 288 |
+
session: Session = Depends(require_session)):
|
| 289 |
+
"""Put one view back to an earlier version. D-338 / W37-T46.
|
| 290 |
+
|
| 291 |
+
ββ A ROLL-BACK IS A NEW VERSION, NEVER A DELETE OF THE ONES AFTER IT, and that is the whole
|
| 292 |
+
point of the ticket rather than an implementation detail. The history is what lets a person
|
| 293 |
+
trust an agent with their code: if reverting destroyed the versions it stepped over, one
|
| 294 |
+
mistaken revert would cost exactly what the history existed to protect, and there would be no
|
| 295 |
+
way back from the way back. So this appends, and `restoredFrom` records where the text came
|
| 296 |
+
from β the same posture the agent-harness roll-back already takes, so a reader who has seen one
|
| 297 |
+
is not surprised by the other.
|
| 298 |
+
|
| 299 |
+
β IT LIVES ON THE SERVER BECAUSE THE HISTORY DOES. The list door deliberately serves history
|
| 300 |
+
entries WITHOUT their source (`_row`: the rail lists names), so a client cannot assemble an old
|
| 301 |
+
version's text to re-PUT it. Handing the source out just so the client could send it straight
|
| 302 |
+
back would widen a payload for a round trip that does not need to exist, and would make the
|
| 303 |
+
revert non-atomic: two calls, and a failure between them leaves the person looking at code that
|
| 304 |
+
is not what is stored.
|
| 305 |
+
β `_clean_source` is NOT re-run. The text being restored was already validated when it was
|
| 306 |
+
first saved, and a source that a later, stricter rule would now reject is exactly the source a
|
| 307 |
+
person is most likely to want back. `check_source` still runs, because the SANDBOX's refusal is
|
| 308 |
+
about what the code would DO and that must never be bypassed by a route.
|
| 309 |
+
"""
|
| 310 |
+
import core.script_sandbox as sandbox # noqa: PLC0415
|
| 311 |
+
|
| 312 |
+
view_id = str(view_id)
|
| 313 |
+
rec = _all(session.runtime).get(view_id)
|
| 314 |
+
if not isinstance(rec, dict):
|
| 315 |
+
raise err(404, "no_view", "there is no script view with that id")
|
| 316 |
+
_mine_or_admin(session, rec)
|
| 317 |
+
body = body if isinstance(body, dict) else {}
|
| 318 |
+
try:
|
| 319 |
+
want = int(body.get("version"))
|
| 320 |
+
except (TypeError, ValueError):
|
| 321 |
+
raise err(400, "no_version", "say which version to go back to") from None
|
| 322 |
+
|
| 323 |
+
history = [h for h in (rec.get("history") or []) if isinstance(h, dict)]
|
| 324 |
+
match = next((h for h in history if int(h.get("version") or 0) == want), None)
|
| 325 |
+
if match is None:
|
| 326 |
+
# β TWO REASONS A VERSION IS MISSING AND THEY ARE NOT THE SAME FACT. One was never
|
| 327 |
+
# written; the other was TRIMMED off the 40-deep history and the record even counts how
|
| 328 |
+
# many. Saying "that version is gone" for the first would be a lie a person could act on.
|
| 329 |
+
trimmed = int(rec.get("trimmed") or 0)
|
| 330 |
+
if trimmed and want <= trimmed:
|
| 331 |
+
raise err(410, "version_trimmed",
|
| 332 |
+
f"version {want} is older than this view's history keeps "
|
| 333 |
+
f"({trimmed} earlier versions have been dropped)")
|
| 334 |
+
raise err(404, "no_version", f"this view has no version {want}")
|
| 335 |
+
|
| 336 |
+
source = str(match.get("source") or "")
|
| 337 |
+
refusal = sandbox.check_source(source)
|
| 338 |
+
if refusal is not None:
|
| 339 |
+
# An older version that today's sandbox refuses. The person is told which version and why,
|
| 340 |
+
# rather than being handed a 400 about code they did not just type.
|
| 341 |
+
raise err(400, refusal.code, f"version {want} cannot be restored: {refusal.message}")
|
| 342 |
+
|
| 343 |
+
def _mutate(prior):
|
| 344 |
+
prior = dict(prior or rec)
|
| 345 |
+
history_now = list(prior.get("history") or [])
|
| 346 |
+
history_now.append({"version": int(prior.get("version") or 1),
|
| 347 |
+
"source": prior.get("source") or "",
|
| 348 |
+
"author": prior.get("author") or "",
|
| 349 |
+
"created": prior.get("updated") or ""})
|
| 350 |
+
dropped = max(0, len(history_now) - MAX_HISTORY)
|
| 351 |
+
prior["history"] = history_now[dropped:] if dropped else history_now
|
| 352 |
+
prior["trimmed"] = int(prior.get("trimmed") or 0) + dropped
|
| 353 |
+
prior["source"] = source
|
| 354 |
+
prior["version"] = int(prior.get("version") or 1) + 1
|
| 355 |
+
prior["updated"] = _now()
|
| 356 |
+
# β ON THE RECORD, so the history reads as WHAT HAPPENED rather than as a version that
|
| 357 |
+
# mysteriously matches an older one. Without it a reader sees v5 and v2 with identical
|
| 358 |
+
# code and no way to tell a revert from a coincidence.
|
| 359 |
+
prior["restoredFrom"] = want
|
| 360 |
+
return prior
|
| 361 |
+
|
| 362 |
+
_put(session, view_id, _mutate)
|
| 363 |
+
fresh = _all(session.runtime).get(view_id)
|
| 364 |
+
if not isinstance(fresh, dict):
|
| 365 |
+
raise err(503, "store_unavailable", "the roll-back was NOT saved")
|
| 366 |
+
return {"view": _row(fresh, source=True), "restoredFrom": want, "limits": _limits()}
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
@router.delete("/script-views/{view_id}")
|
| 370 |
+
def delete_script_view(view_id: str, session: Session = Depends(require_session)):
|
| 371 |
+
view_id = str(view_id)
|
| 372 |
+
rec = _all(session.runtime).get(view_id)
|
| 373 |
+
if not isinstance(rec, dict):
|
| 374 |
+
raise err(404, "no_view", "there is no script view with that id")
|
| 375 |
+
_mine_or_admin(session, rec)
|
| 376 |
+
_put(session, view_id, lambda _prior: None)
|
| 377 |
+
return {"deleted": view_id}
|
| 378 |
+
|
| 379 |
+
|
| 380 |
+
@router.post("/script-views/{view_id}/run")
|
| 381 |
+
def run_script_view(view_id: str, body: dict = Body(default=None),
|
| 382 |
+
session: Session = Depends(require_session)):
|
| 383 |
+
"""CONTRACT C3's run door: `{ok, spec | error, stdout, ms}`.
|
| 384 |
+
|
| 385 |
+
β `spec` IS A DESCRIPTION THE CLIENT DRAWS. It is never HTML and never text the browser
|
| 386 |
+
executes β the sandbox refuses a spec carrying an `html`, `script`, `src`, `href` or `on*` key
|
| 387 |
+
before this function ever sees it, so a renderer cannot be talked into running something by a
|
| 388 |
+
script that was itself perfectly well behaved.
|
| 389 |
+
|
| 390 |
+
β AND IT IS PLAIN `def`, NOT `async def` β see this module's header. A ten-second subprocess
|
| 391 |
+
wait on the event loop is a ten-second outage for the whole container.
|
| 392 |
+
"""
|
| 393 |
+
import core.script_sandbox as sandbox # noqa: PLC0415
|
| 394 |
+
|
| 395 |
+
rec = _all(session.runtime).get(str(view_id))
|
| 396 |
+
if not isinstance(rec, dict):
|
| 397 |
+
raise err(404, "no_view", "there is no script view with that id")
|
| 398 |
+
database = str(rec.get("database") or "")
|
| 399 |
+
# A DRAFT run: the editor sends unsaved code so the author can try it before committing to it.
|
| 400 |
+
# It is checked exactly as a stored one is, because "unsaved" is not a permission.
|
| 401 |
+
draft = (body or {}).get("source") if isinstance(body, dict) else None
|
| 402 |
+
source = _clean_source(draft) if draft else str(rec.get("source") or "")
|
| 403 |
+
# β NO `_database_ok` CALL HERE. `run_view` asks C1 the identical question a line later, and
|
| 404 |
+
# asking twice builds a registry topic's pool twice. The three refusals are translated below
|
| 405 |
+
# instead, which is the same wall reached through the same door.
|
| 406 |
+
|
| 407 |
+
if not _RUN_SLOTS.acquire(blocking=False):
|
| 408 |
+
raise err(429, "busy",
|
| 409 |
+
f"{MAX_CONCURRENT_RUNS} script views are already running on this server. "
|
| 410 |
+
f"Try again in a moment")
|
| 411 |
+
try:
|
| 412 |
+
out = sandbox.run_view(session.user, database, source, st=session.runtime)
|
| 413 |
+
finally:
|
| 414 |
+
_RUN_SLOTS.release()
|
| 415 |
+
|
| 416 |
+
# β C1'S THREE REFUSALS ARE HTTP STATUSES, NOT `ok:false`. "You may not read this database"
|
| 417 |
+
# answered 200 would be a permission decision the client has to go looking for, and every
|
| 418 |
+
# other door in this app answers 403 for it. Everything BELOW this line is a well-formed
|
| 419 |
+
# request whose ANSWER is that the script did not produce a view β that is a 200 with
|
| 420 |
+
# `ok:false`, the shape `routes_web_agent.test` already uses for the same reason.
|
| 421 |
+
if out.get("code") == "unknown_table":
|
| 422 |
+
raise err(404, "no_database", out.get("message") or f"there is no database '{database}'")
|
| 423 |
+
if out.get("code") == "denied":
|
| 424 |
+
raise err(403, "forbidden", out.get("message") or "your account may not read that database")
|
| 425 |
+
if out.get("code") == "unresolvable":
|
| 426 |
+
refusal = err(409, "unresolvable", out.get("message") or "these rows cannot be served")
|
| 427 |
+
refusal.detail["error"]["limit"] = out.get("limit") or {}
|
| 428 |
+
raise refusal
|
| 429 |
+
|
| 430 |
+
answer = {"ok": bool(out.get("ok")), "spec": out.get("spec"),
|
| 431 |
+
"stdout": out.get("stdout") or "", "truncated": bool(out.get("truncated")),
|
| 432 |
+
"ms": int(out.get("ms") or 0), "code": out.get("code") or "",
|
| 433 |
+
# β `caps` RIDES ON THE ANSWER (standing rule 1). On a POSIX host all three limits
|
| 434 |
+
# were applied; on a Windows host the memory and CPU ones were not, and a screen
|
| 435 |
+
# that claims an enforcement which did not happen is the failure the rule is about.
|
| 436 |
+
"caps": out.get("caps") or {}}
|
| 437 |
+
if not answer["ok"]:
|
| 438 |
+
answer["error"] = out.get("message") or "the script view did not produce a view"
|
| 439 |
+
return answer
|
api/routes_tables.py
CHANGED
|
@@ -630,7 +630,23 @@ def ut_write_ctx(session: Session, table_key: str):
|
|
| 630 |
return {"rows_src": [], "pids": pids, "ws": ws, "workspace": workspace,
|
| 631 |
"fields": fields, "views": views, "lists": lists, "hidden": hidden,
|
| 632 |
"derived": aios_grid.cohort_cells(lists),
|
| 633 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 634 |
# β W31-T20 β the write door reads this to refuse a PID-BEARING event loudly rather
|
| 635 |
# than letting `allowed_pids` swallow it as a no-op. See `routes_grid`'s ut_ branch.
|
| 636 |
"limits": limits, "defn": defn}
|
|
@@ -741,16 +757,154 @@ def ut_assembly(session: Session, table_key: str, storage_key: str = "",
|
|
| 741 |
# so the closure covers this user's `custom_` and `measure_` columns too.
|
| 742 |
fields, rows_src, hidden = _ut_field_wall(session, table_key, fields, rows_src)
|
| 743 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 744 |
return {"rows_src": rows_src, "pids": pids, "ws": ws, "workspace": workspace,
|
| 745 |
"fields": fields, "views": views, "lists": lists, "hidden": hidden,
|
| 746 |
-
|
| 747 |
-
"
|
| 748 |
-
"
|
|
|
|
| 749 |
# β ALWAYS PRESENT, EMPTY WHEN THERE IS NOTHING TO SAY β a key a consumer has to test
|
| 750 |
# for is a key a consumer forgets to test for, and this one carries a refusal.
|
| 751 |
"limits": limits, "defn": defn}
|
| 752 |
|
| 753 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 754 |
def ut_label(defn, key, meta=None):
|
| 755 |
"""THE name of a user table, resolved ONCE (wave 20, item 6a).
|
| 756 |
|
|
@@ -857,11 +1011,38 @@ def _rollup_source_offer():
|
|
| 857 |
"description": m.get("description") or ""})
|
| 858 |
|
| 859 |
out = []
|
|
|
|
| 860 |
for tkey, t in sorted(topics.items()):
|
| 861 |
dims = ((t.get("store") or {}).get("dims") or {})
|
| 862 |
measures = by_topic.get(tkey) or []
|
| 863 |
if not dims or not measures:
|
| 864 |
continue
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 865 |
out.append({
|
| 866 |
"key": tkey,
|
| 867 |
"label": t.get("label") or tkey,
|
|
@@ -883,7 +1064,10 @@ def _rollup_source_offer():
|
|
| 883 |
windows = [{"key": k, "label": W.WINDOW_LABELS.get(k, k).format(n="N")}
|
| 884 |
for k in ut.ROLLUP_SOURCE_WINDOWS]
|
| 885 |
offer = {"topics": out, "windows": windows}
|
| 886 |
-
|
|
|
|
|
|
|
|
|
|
| 887 |
return offer
|
| 888 |
|
| 889 |
|
|
|
|
| 630 |
return {"rows_src": [], "pids": pids, "ws": ws, "workspace": workspace,
|
| 631 |
"fields": fields, "views": views, "lists": lists, "hidden": hidden,
|
| 632 |
"derived": aios_grid.cohort_cells(lists),
|
| 633 |
+
# β W37-T12 β the OFFER, because `routes_grid` reads `g["measures"]` as the ADMISSION
|
| 634 |
+
# list for `clean_measure_field`: without it a Metric create on this surface is
|
| 635 |
+
# refused `measure_not_offered` even though the read door offers the kind.
|
| 636 |
+
# β THE CELLS ARE DELIBERATELY ABSENT HERE. This ctx validates a write and
|
| 637 |
+
# materialises no rows at all (W30-T30 removed a full table build from the write
|
| 638 |
+
# path); computing measure values would put that work straight back. `measure_sets`
|
| 639 |
+
# stays empty for the same reason β a write is not a render.
|
| 640 |
+
# β AND THE ASYMMETRY IS SAFE, WHICH IS NOT OBVIOUS AND WAS CHECKED RATHER THAN
|
| 641 |
+
# ASSUMED. `routes_grid._ctx` derives TWO things from this dict and they have
|
| 642 |
+
# different jobs: `measure_keys` (from `measures`) is the ADMISSION list
|
| 643 |
+
# `clean_filter_tree` uses β non-empty here, so a saved measure CONDITION survives the
|
| 644 |
+
# write, which is the failure that comment records for the customer path. Empty
|
| 645 |
+
# `resolved_ids` (from `measure_sets`) only makes `view_upsert` answer "rerender",
|
| 646 |
+
# so the next `/workspace` β which DOES compute the sets, in `ut_assembly` β resolves
|
| 647 |
+
# it. Conservative in the right direction: one extra repaint, never a dropped filter.
|
| 648 |
+
"measures": ut_measures(table_key),
|
| 649 |
+
"measure_sets": {}, "today": time.strftime("%Y-%m-%d"),
|
| 650 |
# β W31-T20 β the write door reads this to refuse a PID-BEARING event loudly rather
|
| 651 |
# than letting `allowed_pids` swallow it as a no-op. See `routes_grid`'s ut_ branch.
|
| 652 |
"limits": limits, "defn": defn}
|
|
|
|
| 757 |
# so the closure covers this user's `custom_` and `measure_` columns too.
|
| 758 |
fields, rows_src, hidden = _ut_field_wall(session, table_key, fields, rows_src)
|
| 759 |
|
| 760 |
+
# ββ W37-T12 / owner item 4 (R1) β `measures` IS NO LONGER UNCONDITIONALLY EMPTY. A database
|
| 761 |
+
# whose entity topic declares a `measures:` binding (today `ut_odoo_agents`) serves a real
|
| 762 |
+
# lookback catalogue AND the cells to go with it; every other user table still serves `[]`,
|
| 763 |
+
# which is the honest answer for a hand-typed one rather than a descope.
|
| 764 |
+
today = time.strftime("%Y-%m-%d")
|
| 765 |
+
measures = ut_measures(table_key)
|
| 766 |
+
derived = aios_grid.cohort_cells(lists) # R9: this table's own lists
|
| 767 |
+
for _pid, _cells in _ut_measure_cells(table_key, fields, pids, today, measures,
|
| 768 |
+
session.runtime.measure_memo).items():
|
| 769 |
+
derived.setdefault(_pid, {}).update(_cells)
|
| 770 |
+
|
| 771 |
return {"rows_src": rows_src, "pids": pids, "ws": ws, "workspace": workspace,
|
| 772 |
"fields": fields, "views": views, "lists": lists, "hidden": hidden,
|
| 773 |
+
"derived": derived,
|
| 774 |
+
"measures": measures,
|
| 775 |
+
"measure_sets": _ut_measure_sets(table_key, views, measures, pids, today),
|
| 776 |
+
"today": today,
|
| 777 |
# β ALWAYS PRESENT, EMPTY WHEN THERE IS NOTHING TO SAY β a key a consumer has to test
|
| 778 |
# for is a key a consumer forgets to test for, and this one carries a refusal.
|
| 779 |
"limits": limits, "defn": defn}
|
| 780 |
|
| 781 |
|
| 782 |
+
def _ut_measure_err(tag, e):
|
| 783 |
+
try:
|
| 784 |
+
import harness.telemetry as _tel
|
| 785 |
+
_tel.error(f"api:{tag}", e)
|
| 786 |
+
except Exception: # noqa: BLE001
|
| 787 |
+
pass
|
| 788 |
+
|
| 789 |
+
|
| 790 |
+
def ut_measures(table_key):
|
| 791 |
+
"""The lookback-measure OFFER for a user database, or `[]` β W37-T12 / owner item 4 (R1).
|
| 792 |
+
|
| 793 |
+
β THE SAME ENGINE THE PRODUCT GRID USES (`semantic.entity_measures`), reached through the
|
| 794 |
+
topic that DESCRIBES this database. A table with no entity topic, or a topic with no
|
| 795 |
+
`measures:` binding, gets `[]` β which is the honest answer for a hand-typed database and is
|
| 796 |
+
what keeps the Metric kind hidden there (`ColumnMenu`'s `offerMeasure={measures.length > 0}`).
|
| 797 |
+
|
| 798 |
+
β NOT A `{table_key: [keys]}` MAP IN THIS FILE. The binding is declared in the model
|
| 799 |
+
(`model/topics/odoo_agents.yml`'s `measures:` block) and the topic already names its own grid,
|
| 800 |
+
so a list here would be a second definition of one fact β the argument `_rollup_source_offer`
|
| 801 |
+
makes about itself one screen down.
|
| 802 |
+
"""
|
| 803 |
+
try:
|
| 804 |
+
from harness import semantic as sem
|
| 805 |
+
topic = sem.topic_for_grid(table_key)
|
| 806 |
+
return sem.entity_measures(topic) if topic else []
|
| 807 |
+
except Exception as e: # noqa: BLE001
|
| 808 |
+
_ut_measure_err("ut-measure-offer", e)
|
| 809 |
+
return []
|
| 810 |
+
|
| 811 |
+
|
| 812 |
+
def _ut_measure_cells(table_key, fields, pids, today, offer, memo, team_id=None):
|
| 813 |
+
"""`{pid: {field key: value}}` for this database's Metric columns.
|
| 814 |
+
|
| 815 |
+
β THE JOIN NEEDS NO MAP HERE, and that is worth stating because the product grid DOES need
|
| 816 |
+
one. `sales_lines.agent` is `rp.agent_id` β a `res.partner` id β and a `ut_odoo_agents` row's
|
| 817 |
+
`pid` IS its `res.partner` id (`scoped_pool` sets `pid = int(rid)` and `read_agents` keys
|
| 818 |
+
`_id` on the partner id). So the group key and the pid are the same integer. The product grid
|
| 819 |
+
keys on `default_code` instead, which is exactly why contract C1 calls the join key a trap:
|
| 820 |
+
the right answer differs per database and must be read off the binding, never assumed.
|
| 821 |
+
"""
|
| 822 |
+
import aios_grid
|
| 823 |
+
|
| 824 |
+
mfields = aios_grid.measure_fields_of(fields)
|
| 825 |
+
if not mfields or not offer:
|
| 826 |
+
return {}
|
| 827 |
+
from harness import semantic as sem
|
| 828 |
+
from harness import windows as _wn
|
| 829 |
+
|
| 830 |
+
topic = sem.topic_for_grid(table_key)
|
| 831 |
+
by_key = {m["key"]: m for m in offer}
|
| 832 |
+
by_window = {}
|
| 833 |
+
for f in mfields:
|
| 834 |
+
spec = f.get("measure") or {}
|
| 835 |
+
if spec.get("key") not in by_key:
|
| 836 |
+
continue
|
| 837 |
+
rng = _wn.resolve(spec.get("window"), today) if _wn.normalize(spec.get("window")) else None
|
| 838 |
+
if rng is None:
|
| 839 |
+
continue # an unresolvable window is unanswerable β never widen to all time
|
| 840 |
+
by_window.setdefault((rng[0], rng[1]), []).append(f)
|
| 841 |
+
|
| 842 |
+
out = {}
|
| 843 |
+
for (dfrom, dto), group in by_window.items():
|
| 844 |
+
keys = tuple(sorted({f["measure"]["key"] for f in group}))
|
| 845 |
+
mkey = ("entity", topic, table_key, today, team_id, dfrom, dto, keys)
|
| 846 |
+
if mkey not in memo:
|
| 847 |
+
try:
|
| 848 |
+
memo[mkey] = sem.entity_measure_values(topic, list(keys), date_from=dfrom,
|
| 849 |
+
date_to=dto, team_id=team_id, offer=offer)
|
| 850 |
+
except Exception as e: # noqa: BLE001
|
| 851 |
+
_ut_measure_err("ut-measure-column", e)
|
| 852 |
+
transient = False
|
| 853 |
+
try:
|
| 854 |
+
from harness import datastore as _ds
|
| 855 |
+
transient = not _ds.ready()
|
| 856 |
+
except Exception: # noqa: BLE001
|
| 857 |
+
transient = False
|
| 858 |
+
if transient:
|
| 859 |
+
continue # a warming store must not memoise as a permanent failure
|
| 860 |
+
memo[mkey] = None
|
| 861 |
+
vals = memo[mkey]
|
| 862 |
+
if vals is None:
|
| 863 |
+
continue
|
| 864 |
+
# C1's empty-window rule for EVERY row, not only the grouped ones.
|
| 865 |
+
filled = {pid: sem.entity_zero_fill(vals.get(pid), list(keys), offer) for pid in pids}
|
| 866 |
+
for f in group:
|
| 867 |
+
k, fkey = f["measure"]["key"], f["key"]
|
| 868 |
+
for pid, cell in filled.items():
|
| 869 |
+
if k in cell:
|
| 870 |
+
out.setdefault(pid, {})[fkey] = cell[k]
|
| 871 |
+
if len(memo) > 100:
|
| 872 |
+
memo.clear()
|
| 873 |
+
return out
|
| 874 |
+
|
| 875 |
+
|
| 876 |
+
def _ut_measure_sets(table_key, views, offer, pids, today, team_id=None):
|
| 877 |
+
"""`{rule id: [pid, β¦]}` for the measure CONDITIONS in this database's saved views.
|
| 878 |
+
|
| 879 |
+
β SHIPS WITH THE OFFER, NEVER AFTER IT. `routes_grid` derives `measure_keys` from the same
|
| 880 |
+
list, so a non-empty `measures` also opens the measure condition in the filter builder β and a
|
| 881 |
+
rule id missing from `measureSets` renders PENDING ("Calculatingβ¦") and matches nothing,
|
| 882 |
+
permanently. Offer and sets are one feature.
|
| 883 |
+
"""
|
| 884 |
+
if not offer:
|
| 885 |
+
return {}
|
| 886 |
+
from harness import semantic as sem
|
| 887 |
+
|
| 888 |
+
topic = sem.topic_for_grid(table_key)
|
| 889 |
+
admitted = {m["key"] for m in offer}
|
| 890 |
+
by_id = {}
|
| 891 |
+
for v in views or []:
|
| 892 |
+
for rule in sem.entity_measure_leaves((v.get("config") or {}).get("filters"), admitted):
|
| 893 |
+
rid = str(rule.get("id") or "")
|
| 894 |
+
if rid:
|
| 895 |
+
by_id[rid] = rule
|
| 896 |
+
if not by_id:
|
| 897 |
+
return {}
|
| 898 |
+
try:
|
| 899 |
+
sets = sem.entity_measure_sets(topic, list(by_id.values()), today, team_id=team_id,
|
| 900 |
+
keys_by_id=list(pids), offer=offer)
|
| 901 |
+
except Exception as e: # noqa: BLE001
|
| 902 |
+
_ut_measure_err("ut-measure-sets", e)
|
| 903 |
+
return {}
|
| 904 |
+
return {rid: sorted(int(k) for k in ks if str(k).lstrip("-").isdigit())
|
| 905 |
+
for rid, ks in sets.items()}
|
| 906 |
+
|
| 907 |
+
|
| 908 |
def ut_label(defn, key, meta=None):
|
| 909 |
"""THE name of a user table, resolved ONCE (wave 20, item 6a).
|
| 910 |
|
|
|
|
| 1011 |
"description": m.get("description") or ""})
|
| 1012 |
|
| 1013 |
out = []
|
| 1014 |
+
indeterminate = False
|
| 1015 |
for tkey, t in sorted(topics.items()):
|
| 1016 |
dims = ((t.get("store") or {}).get("dims") or {})
|
| 1017 |
measures = by_topic.get(tkey) or []
|
| 1018 |
if not dims or not measures:
|
| 1019 |
continue
|
| 1020 |
+
# ββ W37-T13 / /validate-wave 2026-08-19 β THE SOURCE TABLE MUST BE LIVE, AND THIS DOOR
|
| 1021 |
+
# DID NOT ASK. The docstring above already promises *"ONLY COMBINATIONS THAT CAN RESOLVE
|
| 1022 |
+
# ARE OFFERED"*; adding `stock_move` to `datastore.ENTITIES` broke that promise on the one
|
| 1023 |
+
# door nobody re-read. `semantic._one_binding_offer` β the MEASURE-CATALOG door β refuses
|
| 1024 |
+
# the same bindings with a stated cause, so the wave shipped **two doors onto one
|
| 1025 |
+
# vocabulary with only one of them guarded**, and the unguarded one is the picker.
|
| 1026 |
+
# MEASURED: `_rollup_source_offer()` listed `stock_moves` with both measures and all four
|
| 1027 |
+
# dims while `royal.duckdb` has no `stock_move` table at all, so choosing it raised
|
| 1028 |
+
# `CatalogException: Table with name stock_move does not exist!` β five of the fifty
|
| 1029 |
+
# (topic, measure, dim) triples in `verify_odoo_relational`. A user meets a raw catalog
|
| 1030 |
+
# error, or a column that looks configured and is blank forever
|
| 1031 |
+
# ([[permitted-is-not-answerable]]).
|
| 1032 |
+
# β REUSES `_source_ready` rather than re-deriving readiness: one question, one
|
| 1033 |
+
# normalizer. `None` (the store could not be asked) refuses TOO β fail closed, exactly as
|
| 1034 |
+
# the measure door does.
|
| 1035 |
+
_tbl = (t.get("store") or {}).get("table")
|
| 1036 |
+
if _tbl:
|
| 1037 |
+
_rdy = sem._source_ready(_tbl)
|
| 1038 |
+
if _rdy is not True:
|
| 1039 |
+
# β AND AN INDETERMINATE ANSWER MUST NOT BE CACHED. `_source_ready`'s own comment
|
| 1040 |
+
# records the measured version of this: a cold container with no mirror yet
|
| 1041 |
+
# refused every binding, the refusal was memoised, and the grid served no measures
|
| 1042 |
+
# long after the mirror arrived. Here the same shape would freeze a truncated
|
| 1043 |
+
# PICKER for the life of the process.
|
| 1044 |
+
indeterminate = indeterminate or (_rdy is None)
|
| 1045 |
+
continue
|
| 1046 |
out.append({
|
| 1047 |
"key": tkey,
|
| 1048 |
"label": t.get("label") or tkey,
|
|
|
|
| 1064 |
windows = [{"key": k, "label": W.WINDOW_LABELS.get(k, k).format(n="N")}
|
| 1065 |
for k in ut.ROLLUP_SOURCE_WINDOWS]
|
| 1066 |
offer = {"topics": out, "windows": windows}
|
| 1067 |
+
# β Cache only a DEFINITE answer (see the readiness block above): if any topic was dropped
|
| 1068 |
+
# because the store could not be asked, this offer is a snapshot of an unknown, not a fact.
|
| 1069 |
+
if not indeterminate:
|
| 1070 |
+
_ROLLUP_CACHE["offer"] = offer
|
| 1071 |
return offer
|
| 1072 |
|
| 1073 |
|
platform/aios_grid.py
CHANGED
|
@@ -24,6 +24,7 @@ Design notes:
|
|
| 24 |
injected-HTML path remains a read/local-write fallback when component assets are absent.
|
| 25 |
"""
|
| 26 |
import json
|
|
|
|
| 27 |
import re
|
| 28 |
from pathlib import Path
|
| 29 |
|
|
@@ -89,6 +90,22 @@ def _round(v):
|
|
| 89 |
return round(v) if isinstance(v, (int, float)) and not isinstance(v, bool) else v
|
| 90 |
|
| 91 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
# Types a USER may create from the column menu (owner item 7, 2026-07-26). Mirrors
|
| 93 |
# customer-grid/types.ts CREATABLE_TYPES; verify_fields_contract.py holds the two in step.
|
| 94 |
# select β a single-select with its own `options` (the "Status" a user wants to add; distinct
|
|
@@ -415,6 +432,52 @@ def _clean_formula(raw, valid_keys=None):
|
|
| 415 |
return s
|
| 416 |
|
| 417 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 418 |
def _field_extras(saved, ftype):
|
| 419 |
"""createdBy / permissions / format / scope β the validated passthrough the created strata
|
| 420 |
share (wave 5). `createdBy` is only ever WRITTEN host-side (the handler stamps it); here it
|
|
@@ -434,6 +497,27 @@ def _field_extras(saved, ftype):
|
|
| 434 |
out["format"] = fmt
|
| 435 |
if saved.get("scope") == "cohort":
|
| 436 |
out["scope"] = "cohort"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 437 |
corrected_from = saved.get("labelCorrectedFrom")
|
| 438 |
correction_id = saved.get("labelCorrectionId")
|
| 439 |
if (isinstance(corrected_from, str) and corrected_from.strip()
|
|
@@ -752,7 +836,10 @@ def rows_from_pool(pool_rows, fields=None, overlays=None, derived=None):
|
|
| 752 |
if field.get("derived"):
|
| 753 |
continue # not on the pool row β filled from `derived` below
|
| 754 |
v = r.get(k)
|
| 755 |
-
|
|
|
|
|
|
|
|
|
|
| 756 |
saved = overlays.get(str(pid), {}) or {}
|
| 757 |
for field in overlay_fields:
|
| 758 |
row[field["key"]] = saved.get(field["key"], "")
|
|
@@ -789,6 +876,19 @@ COHORT_FIELD = '__cohort__'
|
|
| 789 |
#: a set op reaching a column leaf would fall through the client engine's switch to "no
|
| 790 |
#: narrowing", so keeping the vocabularies apart makes the existing fail-closed drop do the work.
|
| 791 |
COHORT_OPS = {'anyOf', 'allOf', 'noneOf'}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 792 |
#: The single-cohort ops this leaf shipped with, kept as PERMANENT aliases and REWRITTEN here:
|
| 793 |
#: `is part of [one]` is `is any of [that one]`, so a saved view keeps answering and upgrades the
|
| 794 |
#: next time it is written. Mirrors types.ts COHORT_OP_ALIASES.
|
|
@@ -912,8 +1012,32 @@ def _clean_rhs(raw, valid_keys):
|
|
| 912 |
#: written into `iconShapes.ts`, and now machine-enforced in BOTH directions by
|
| 913 |
#: `verify_icons.py::mode_parity` β offering an unmounted mode is red, and mounting an unoffered
|
| 914 |
#: one is red too, so the hold cannot outlive its reason the way wave 27's did).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 915 |
DISPLAY_MODES = {'grid', 'list', 'calendar', 'kanban', 'map', 'dashboard', 'chart',
|
| 916 |
-
'timeseries', 'catalog', 'swipe', 'form'}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 917 |
|
| 918 |
#: ββ WAVE-29 R7 (owner item 10, contracts C3/C4) β THE FORM INTERFACE's stored spec, at
|
| 919 |
#: `views[<id>].config.display.form`. The public door (`aios-web/api/routes_forms.py`) has read
|
|
@@ -926,6 +1050,166 @@ DISPLAY_MODES = {'grid', 'list', 'calendar', 'kanban', 'map', 'dashboard', 'char
|
|
| 926 |
#: so one tenant setting its token to another tenant's value would silently receive that tenant's
|
| 927 |
#: submissions. The token is minted server-side and lives in a bucket no client write can reach;
|
| 928 |
#: `_clean_form` drops any `token` key that arrives here, rather than validating its shape.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 929 |
FORM_ACCESS = ('public', 'emails')
|
| 930 |
MAX_FORM_FIELDS = 60
|
| 931 |
MAX_FORM_EMAILS = 200
|
|
@@ -1373,7 +1657,11 @@ def _clean_display(raw, valid_keys):
|
|
| 1373 |
# saved 'dashboard' view reads back as 'chart' from here on; nothing writes 'dashboard'.
|
| 1374 |
mode = _LEGACY_MODES.get(mode, mode)
|
| 1375 |
out = {'mode': mode}
|
| 1376 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1377 |
if raw.get(ref) in valid_keys:
|
| 1378 |
out[ref] = raw[ref]
|
| 1379 |
# ββ C-DISP (wave 2026-08-02) βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -1508,6 +1796,12 @@ def _clean_display(raw, valid_keys):
|
|
| 1508 |
catalogs = _clean_catalogs(raw.get('catalogs'), valid_keys)
|
| 1509 |
if catalogs:
|
| 1510 |
out['catalogs'] = catalogs
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1511 |
# ββ β WAVE-27 C3 (item 8 / R2): the swipe binding ββββββββββββββββββββββββββββββββββββ
|
| 1512 |
# `{fieldKey, leftOption, rightOption}` β WHOLE-KEY drop, never a partial one, and that
|
| 1513 |
# asymmetry against `charts`/`calendarMetrics` above is the point rather than an oversight.
|
|
@@ -1536,6 +1830,26 @@ def _clean_display(raw, valid_keys):
|
|
| 1536 |
# `kanbanClamp` law) wearing a control that promises a choice.
|
| 1537 |
if left and right and left.casefold() != right.casefold():
|
| 1538 |
out['swipe'] = {'fieldKey': f_key, 'leftOption': left, 'rightOption': right}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1539 |
# ββ ββ WAVE-29 R7 (item 10): the FORM spec β see `_clean_form` for why the token is not here.
|
| 1540 |
form = _clean_form(raw.get('form'), valid_keys)
|
| 1541 |
if form:
|
|
@@ -1743,7 +2057,59 @@ def clean_item_folders(raw, folders, valid_ids):
|
|
| 1743 |
return out
|
| 1744 |
|
| 1745 |
|
| 1746 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1747 |
"""Recursively validate an UNTRUSTED filter tree (conditions + nested groups).
|
| 1748 |
|
| 1749 |
Returns a clean tree of leaf conditions ({colId, op, value, value2}) and
|
|
@@ -1762,6 +2128,14 @@ def clean_filter_tree(raw, valid_keys, depth=1, budget=None, cohort_ids=None):
|
|
| 1762 |
condition that can only match nothing, so `List is not [deleted]` would show an empty table
|
| 1763 |
forever with no way to tell why. `None` means this host has no cohorts, and then every
|
| 1764 |
cohort leaf is dropped: fail-closed, like every other unknown key.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1765 |
"""
|
| 1766 |
if budget is None:
|
| 1767 |
budget = [MAX_FILTER_NODES]
|
|
@@ -1776,7 +2150,7 @@ def clean_filter_tree(raw, valid_keys, depth=1, budget=None, cohort_ids=None):
|
|
| 1776 |
continue # too deep -> drop
|
| 1777 |
budget[0] -= 1
|
| 1778 |
children = clean_filter_tree(node['children'], valid_keys,
|
| 1779 |
-
depth + 1, budget, cohort_ids)
|
| 1780 |
if children:
|
| 1781 |
out.append({'conj': 'or' if node.get('conj') == 'or' else 'and',
|
| 1782 |
'children': children})
|
|
@@ -1793,6 +2167,21 @@ def clean_filter_tree(raw, valid_keys, depth=1, budget=None, cohort_ids=None):
|
|
| 1793 |
out.append({'colId': COHORT_FIELD, 'op': op,
|
| 1794 |
'value': ','.join(named), 'value2': ''})
|
| 1795 |
continue
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1796 |
if node.get('colId') in valid_keys and node.get('op') in FILTER_OPS:
|
| 1797 |
budget[0] -= 1
|
| 1798 |
# `or ''` would be wrong here: it maps every FALSY value to '', and '' is the
|
|
|
|
| 24 |
injected-HTML path remains a read/local-write fallback when component assets are absent.
|
| 25 |
"""
|
| 26 |
import json
|
| 27 |
+
import math
|
| 28 |
import re
|
| 29 |
from pathlib import Path
|
| 30 |
|
|
|
|
| 90 |
return round(v) if isinstance(v, (int, float)) and not isinstance(v, bool) else v
|
| 91 |
|
| 92 |
|
| 93 |
+
def _round2(v):
|
| 94 |
+
"""D-153 (W37, asked by lane B) β MONEY AND PERCENT KEEP THEIR DECIMALS.
|
| 95 |
+
|
| 96 |
+
`_round`'s whole-number convention is right for counts and wrong for currency: `first_cost`
|
| 97 |
+
3.47 shipped as `3`, so every money cell on every Odoo grid was a whole dollar.
|
| 98 |
+
|
| 99 |
+
β `pct` IS AFFECTED TOO, and that is the half most likely to be dropped. `cells.ts` renders a
|
| 100 |
+
percent as `num(v).toFixed(1) + "%"`, so a `yoy_pct` of 12.34 was `round()`ed to 12 and painted
|
| 101 |
+
"12.0%" β one decimal of a number that HAS one, which reads as precision rather than as loss.
|
| 102 |
+
β NOT written as a `_round(v, nd=0)` default argument, deliberately: `round(v, 0)` returns a
|
| 103 |
+
FLOAT where `round(v)` returns an INT, so every integer column's wire shape would change under
|
| 104 |
+
an edit that looks purely cosmetic.
|
| 105 |
+
"""
|
| 106 |
+
return round(v, 2) if isinstance(v, (int, float)) and not isinstance(v, bool) else v
|
| 107 |
+
|
| 108 |
+
|
| 109 |
# Types a USER may create from the column menu (owner item 7, 2026-07-26). Mirrors
|
| 110 |
# customer-grid/types.ts CREATABLE_TYPES; verify_fields_contract.py holds the two in step.
|
| 111 |
# select β a single-select with its own `options` (the "Status" a user wants to add; distinct
|
|
|
|
| 432 |
return s
|
| 433 |
|
| 434 |
|
| 435 |
+
def _clean_geocode(raw):
|
| 436 |
+
"""One `geocode` bag β the stored shape, or None (dropped).
|
| 437 |
+
|
| 438 |
+
ββ W37-T22 (owner item 15 / ruling R4, contract C7). THE ONE validator for the geocode
|
| 439 |
+
pseudo-kind, called by BOTH field doors: `_field_extras` here (the Odoo grids) and
|
| 440 |
+
`core/user_tables._clean_field` via `_ag_geocode` (every `ut_*` user database).
|
| 441 |
+
|
| 442 |
+
β IT WAS INLINE IN `_field_extras` UNTIL 2026-08-19 AND THAT IS WHY IT SHIPPED HALF-DONE.
|
| 443 |
+
`user_tables._clean_field` is a strict allowlist that returns only the bags it NAMES, so a
|
| 444 |
+
geocode column created on a user database was stored WITHOUT its `{addressField}` β the
|
| 445 |
+
*"created, named, configured, gone"* failure the `image` type shipped as in wave 19. Extracting
|
| 446 |
+
it rather than copying the rules is [[one-question-two-normalizers]]: a second copy is how the
|
| 447 |
+
two doors come to disagree about what a valid bag is.
|
| 448 |
+
|
| 449 |
+
β WHOLE-KEY DROP (the `swipe` construction): a geocode column with no address column is not
|
| 450 |
+
a degraded column, it is a column no run can ever fill, and the honest state for that is the
|
| 451 |
+
unconfigured one the client refuses at `canCreate`.
|
| 452 |
+
|
| 453 |
+
β THE FIELD'S `type` IS `text` AND THERE IS NO `geocode` FieldType. The kind is a PSEUDO-KIND
|
| 454 |
+
in the create picker (the `measure` construction), so `CUSTOM_FIELD_TYPES`, `UT_FIELD_TYPES`,
|
| 455 |
+
`CREATABLE_TYPES`, the shape/label tables and the cell renderer are all untouched β three
|
| 456 |
+
parity gates chain over those sets and none permits a partial landing. This is also why the
|
| 457 |
+
bag takes `format`'s posture and NOT `formula`'s: there is no type to pair it with, so an
|
| 458 |
+
absent bag is an ordinary text column rather than a refusal.
|
| 459 |
+
|
| 460 |
+
β `addressField` is NOT checked against the table's keys: neither door is given `valid_keys`,
|
| 461 |
+
and the split this module already draws puts that loss on the surface that RENDERS. The
|
| 462 |
+
enricher must SAY the address column is gone rather than geocode an empty string.
|
| 463 |
+
"""
|
| 464 |
+
if not isinstance(raw, dict):
|
| 465 |
+
return None
|
| 466 |
+
addr = raw.get("addressField")
|
| 467 |
+
if not isinstance(addr, str) or not addr.strip():
|
| 468 |
+
return None
|
| 469 |
+
out = {"addressField": addr.strip()[:80]}
|
| 470 |
+
# D-3 β the optional ISO 3166-1 alpha-2 hint, mapped straight onto the geocoder's
|
| 471 |
+
# `countrycodes` parameter. NORMALISED to upper case and stored only when it is exactly two
|
| 472 |
+
# letters: absent means "anywhere", and `""` would be that state's second spelling. A
|
| 473 |
+
# three-letter or numeric code is REFUSED rather than truncated, because a truncated code is
|
| 474 |
+
# a valid code for a different country.
|
| 475 |
+
cc = raw.get("country")
|
| 476 |
+
if isinstance(cc, str) and re.fullmatch(r"[A-Za-z]{2}", cc.strip() or ""):
|
| 477 |
+
out["country"] = cc.strip().upper()
|
| 478 |
+
return out
|
| 479 |
+
|
| 480 |
+
|
| 481 |
def _field_extras(saved, ftype):
|
| 482 |
"""createdBy / permissions / format / scope β the validated passthrough the created strata
|
| 483 |
share (wave 5). `createdBy` is only ever WRITTEN host-side (the handler stamps it); here it
|
|
|
|
| 497 |
out["format"] = fmt
|
| 498 |
if saved.get("scope") == "cohort":
|
| 499 |
out["scope"] = "cohort"
|
| 500 |
+
# ββ ββ W37-T22 (owner item 15 / ruling R4, contract C7): THE GEOCODE COLUMN'S CONFIG ββ
|
| 501 |
+
#
|
| 502 |
+
# `{addressField}` β which column this one reads. WHOLE-KEY drop (the `swipe` construction):
|
| 503 |
+
# a geocode column with no address column is not a degraded one, it is a column no run can
|
| 504 |
+
# ever fill, and the honest state for that is the unconfigured one the client refuses at
|
| 505 |
+
# `canCreate`.
|
| 506 |
+
#
|
| 507 |
+
# β THE FIELD'S `type` IS `text` AND THERE IS NO `geocode` FieldType. The kind is a PSEUDO-KIND
|
| 508 |
+
# in the create picker (the `measure` construction), so `CUSTOM_FIELD_TYPES`, `UT_FIELD_TYPES`,
|
| 509 |
+
# `CREATABLE_TYPES`, the shape/label tables and the cell renderer are all untouched β three
|
| 510 |
+
# parity gates chain over those sets and none permits a partial landing.
|
| 511 |
+
# β `addressField` is NOT checked against this table's keys: `_field_extras` is not given
|
| 512 |
+
# `valid_keys`, and the split this module already draws puts that loss on the surface that
|
| 513 |
+
# RENDERS ("what each mode MEANS ... is the client's business"). The enricher must SAY the
|
| 514 |
+
# address column is gone rather than geocode an empty string.
|
| 515 |
+
# β EXTRACTED to `_clean_geocode` 2026-08-19 (/validate-wave, W37-T22 clause 1). The rules
|
| 516 |
+
# did not change; what changed is that `core/user_tables.py` now reaches THIS function through
|
| 517 |
+
# `_ag_geocode` instead of carrying a second copy. See `_clean_geocode`'s own note.
|
| 518 |
+
geo = _clean_geocode(saved.get("geocode"))
|
| 519 |
+
if geo:
|
| 520 |
+
out["geocode"] = geo
|
| 521 |
corrected_from = saved.get("labelCorrectedFrom")
|
| 522 |
correction_id = saved.get("labelCorrectionId")
|
| 523 |
if (isinstance(corrected_from, str) and corrected_from.strip()
|
|
|
|
| 836 |
if field.get("derived"):
|
| 837 |
continue # not on the pool row β filled from `derived` below
|
| 838 |
v = r.get(k)
|
| 839 |
+
# D-153 β three roundings, not two: text/status/date pass through verbatim, currency
|
| 840 |
+
# and pct keep two decimals (`_round2`), everything else stays a whole number.
|
| 841 |
+
row[k] = v if field["type"] in {"text", "status", "date"} else (
|
| 842 |
+
_round2(v) if field["type"] in {"currency", "pct"} else _round(v))
|
| 843 |
saved = overlays.get(str(pid), {}) or {}
|
| 844 |
for field in overlay_fields:
|
| 845 |
row[field["key"]] = saved.get(field["key"], "")
|
|
|
|
| 876 |
#: a set op reaching a column leaf would fall through the client engine's switch to "no
|
| 877 |
#: narrowing", so keeping the vocabularies apart makes the existing fail-closed drop do the work.
|
| 878 |
COHORT_OPS = {'anyOf', 'allOf', 'noneOf'}
|
| 879 |
+
|
| 880 |
+
#: ββ WAVE-37 T26 (owner item 14, contract C4) β THE VIEW-MEMBERSHIP LEAF's reserved
|
| 881 |
+
#: pseudo-column and its operators. Mirrors `customer-grid/types.ts` VIEW_FIELD / VIEW_OPS.
|
| 882 |
+
#:
|
| 883 |
+
#: β DISJOINT FROM `FILTER_OPS`, for the reason COHORT_OPS is: `matchFilter` answers an operator
|
| 884 |
+
#: it does not know by NOT NARROWING, so an op that reached a column leaf would widen the result
|
| 885 |
+
#: under an authoritative count. Keeping the two vocabularies apart means a column leaf carrying
|
| 886 |
+
#: `inView` is dropped by the same fail-closed path that drops `dropTable`.
|
| 887 |
+
#: β The leaf names exactly ONE view (singular where a cohort leaf names a set), so there is no
|
| 888 |
+
#: all-or-nothing rule to get wrong here β the id either resolves or the leaf goes.
|
| 889 |
+
VIEW_FIELD = '__view__'
|
| 890 |
+
VIEW_OPS = {'inView', 'notInView'}
|
| 891 |
+
MAX_VIEW_ID = 64
|
| 892 |
#: The single-cohort ops this leaf shipped with, kept as PERMANENT aliases and REWRITTEN here:
|
| 893 |
#: `is part of [one]` is `is any of [that one]`, so a saved view keeps answering and upgrades the
|
| 894 |
#: next time it is written. Mirrors types.ts COHORT_OP_ALIASES.
|
|
|
|
| 1012 |
#: written into `iconShapes.ts`, and now machine-enforced in BOTH directions by
|
| 1013 |
#: `verify_icons.py::mode_parity` β offering an unmounted mode is red, and mounting an unoffered
|
| 1014 |
#: one is red too, so the hold cannot outlive its reason the way wave 27's did).
|
| 1015 |
+
#: ββ WAVE-37 T20 (owner item 10, ruling R2, contract C2) β 'script', and the reason it lands
|
| 1016 |
+
#: here is NOT the one the PRD gives. The PRD says the Custom View is invisible because this set
|
| 1017 |
+
#: omits the name so `_clean_display` drops the mode on every write. MEASURED, that write never
|
| 1018 |
+
#: happens: a script view is a rail PROJECTION built client-side by
|
| 1019 |
+
#: `CustomerGrid.tsx::scriptProjectionView`, its panel is gated on `activeScriptId !== null`
|
| 1020 |
+
#: (a row in E's per-database store), there is no `displayMode === "script"` branch at all, and the
|
| 1021 |
+
#: autosave effect reads `views.find(...)` while the projections are appended to `railViews` ALONE.
|
| 1022 |
+
#: Nothing was ever dropped, because nothing was ever sent.
|
| 1023 |
+
#: β WHAT THE NAME ACTUALLY BUYS is the PRECONDITION for the create door the owner asked for: a
|
| 1024 |
+
#: script view can only be offered through the display-mode picker once a view STORED as
|
| 1025 |
+
#: `mode: 'script'` survives a round trip, and until this line it could not. The picker entry
|
| 1026 |
+
#: itself is W37-T41 (lane E) plus `CREATABLE_MODES`, and `_test/icons.test.ts::HELD_MODES` keeps
|
| 1027 |
+
#: the hold with its release condition rewritten to say so.
|
| 1028 |
+
#: β CONTRACT C2 MAKES THIS SET AND `customer-grid/types.ts::DISPLAY_MODES` IDENTICAL NAME FOR
|
| 1029 |
+
#: NAME, and `verify_fields_contract.py` now asserts it in BOTH directions. That retires the staged
|
| 1030 |
+
#: hold every mode from `timeseries` to `form` was landed with β a client-first name is now RED
|
| 1031 |
+
#: rather than merely risky. The protection the hold gave is structural instead: both halves are in
|
| 1032 |
+
#: one lane's fence, so they ship in one change, and whether a mode may be CREATED is a separate
|
| 1033 |
+
#: question answered by `CREATABLE_MODES` / `HELD_MODES`.
|
| 1034 |
DISPLAY_MODES = {'grid', 'list', 'calendar', 'kanban', 'map', 'dashboard', 'chart',
|
| 1035 |
+
'timeseries', 'catalog', 'swipe', 'form', 'script'}
|
| 1036 |
+
|
| 1037 |
+
#: W37-T20 (C2) β the cap on `display.script.id`. Script-view ids are minted server-side as
|
| 1038 |
+
#: `'sv_' + secrets.token_urlsafe(9)` (`routes_script_views.py::create_script_view`), so ~15 chars;
|
| 1039 |
+
#: 64 is a bound on a hostile payload, not an opinion about the format.
|
| 1040 |
+
MAX_SCRIPT_ID = 64
|
| 1041 |
|
| 1042 |
#: ββ WAVE-29 R7 (owner item 10, contracts C3/C4) β THE FORM INTERFACE's stored spec, at
|
| 1043 |
#: `views[<id>].config.display.form`. The public door (`aios-web/api/routes_forms.py`) has read
|
|
|
|
| 1050 |
#: so one tenant setting its token to another tenant's value would silently receive that tenant's
|
| 1051 |
#: submissions. The token is minted server-side and lives in a bucket no client write can reach;
|
| 1052 |
#: `_clean_form` drops any `token` key that arrives here, rather than validating its shape.
|
| 1053 |
+
#: ββ WAVE-37 T21 (owner items 1/12, rulings R5+R6, contract C3) β THE PER-VIEW CATALOG SPEC,
|
| 1054 |
+
#: stored at `views[<id>].config.display.catalog`.
|
| 1055 |
+
#:
|
| 1056 |
+
#: ββ IT IS **`catalog`**, SINGULAR, AND IT IS NOT `catalogs`. Both keys live on `display`, they
|
| 1057 |
+
#: are one letter apart, and that is the single most likely mistake anybody touching this file will
|
| 1058 |
+
#: make. They are different things and BOTH are live:
|
| 1059 |
+
#: Β· `catalogs` (wave-18 C6) β a LIST of up to 12 print artifacts, each with its own paper,
|
| 1060 |
+
#: brand and pages of kind cover/intro/section/gallery. Authored CONTENT. UNTOUCHED by T21.
|
| 1061 |
+
#: Β· `catalog` (wave-37 C3) β THIS view's catalog SETTINGS: which column is the picture, which
|
| 1062 |
+
#: is the title (R5: per view, so two catalog views on one database may differ), plus the page
|
| 1063 |
+
#: order and the free-placed items R6 asks for.
|
| 1064 |
+
#: Neither replaces the other, and neither validator reads the other's key.
|
| 1065 |
+
#:
|
| 1066 |
+
#: β THE MIGRATION IS A RULE ABOUT WHAT THIS FUNCTION MUST **NOT** DO (R6, C3). An item authored
|
| 1067 |
+
#: before free placement has no `x`/`y`, and an ABSENT coordinate must read back ABSENT β never
|
| 1068 |
+
#: normalised to 0. Defaulting to zero would pile every pre-existing item at the origin, which is
|
| 1069 |
+
#: "a catalog that silently loses its layout is a FAIL, not a migration" stated as code. The
|
| 1070 |
+
#: RENDERER turns an absent coordinate into flow position N; the host's whole job is to not destroy
|
| 1071 |
+
#: the distinction it reads.
|
| 1072 |
+
#: β `w`/`h` carry the OPPOSITE asymmetry, deliberately: a zero WIDTH is an invisible item, so 0
|
| 1073 |
+
#: is refused and absent means "the renderer's default size". A zero X is the top-left corner and is
|
| 1074 |
+
#: a real, storable position. Two keys of one shape with two rules β the `kanbanClamp` family β
|
| 1075 |
+
#: which is why each is written out rather than looped.
|
| 1076 |
+
MAX_CATALOG_SECTIONS = 20
|
| 1077 |
+
#: A bound on a hostile payload, not an opinion about the renderer's coordinate space: D owns what
|
| 1078 |
+
#: the numbers MEAN. Stored to 2 decimals, finer than any print surface resolves.
|
| 1079 |
+
MAX_CATALOG_COORD = 10000
|
| 1080 |
+
#: The page `order` bound. ABSENT => that page's ARRAY INDEX, and `order` WINS where present; ties
|
| 1081 |
+
#: break by array index. Decided HERE rather than left to the renderer, because two readers guessing
|
| 1082 |
+
#: differently about page order is exactly the divergence contract C3 exists to close.
|
| 1083 |
+
MAX_CATALOG_ORDER = 9999
|
| 1084 |
+
|
| 1085 |
+
|
| 1086 |
+
def _clean_catalog_coord(raw, allow_zero):
|
| 1087 |
+
"""One free-placement number, or None. `allow_zero` False is the w/h leg (see the header).
|
| 1088 |
+
|
| 1089 |
+
β THE ROUNDING IS `floor(v * 100 + 0.5) / 100` AND NOT `round(v, 2)`, AND THAT IS THE WHOLE
|
| 1090 |
+
POINT OF THIS FUNCTION EXISTING SEPARATELY. Python's `round` is BANKER'S rounding and
|
| 1091 |
+
JavaScript's `Math.round` is half-up-toward-+Infinity, so the two engines disagree on every
|
| 1092 |
+
exact half: `round(0.125, 2)` is 0.12 here and 0.13 in the browser. One dragged item landing two
|
| 1093 |
+
hundredths apart on the two engines is a `display` object that never compares equal β the
|
| 1094 |
+
silent mirror drift contract C2 exists to prevent, arriving through arithmetic instead of
|
| 1095 |
+
through a missing key. The expression below is `Math.floor(v * 100 + 0.5) / 100` written in
|
| 1096 |
+
Python, evaluated in the same IEEE doubles, so the two agree bit for bit.
|
| 1097 |
+
β AND AN INTEGRAL RESULT IS EMITTED AS AN `int`: `12.0` serialises as `12.0` in Python and
|
| 1098 |
+
`12` in JSON.stringify, which is one stored value with two spellings.
|
| 1099 |
+
"""
|
| 1100 |
+
if isinstance(raw, bool) or not isinstance(raw, (int, float)):
|
| 1101 |
+
return None
|
| 1102 |
+
if raw != raw or raw in (float("inf"), float("-inf")): # NaN / +-inf
|
| 1103 |
+
return None
|
| 1104 |
+
val = math.floor(float(raw) * 100 + 0.5) / 100
|
| 1105 |
+
if val > MAX_CATALOG_COORD or val < -MAX_CATALOG_COORD:
|
| 1106 |
+
return None
|
| 1107 |
+
if not allow_zero and val <= 0:
|
| 1108 |
+
return None
|
| 1109 |
+
return int(val) if val == int(val) else val
|
| 1110 |
+
|
| 1111 |
+
|
| 1112 |
+
def _clean_catalog_item(raw):
|
| 1113 |
+
"""One free-placed item: `{code, x?, y?, w?, h?}`. `code` REQUIRED; every coordinate optional.
|
| 1114 |
+
|
| 1115 |
+
Key EMISSION ORDER is fixed and mirrored byte-for-byte by `types.ts::cleanCatalogSpec` β an
|
| 1116 |
+
accepted save has to read back identically on both engines, and this pair is where that is
|
| 1117 |
+
decided.
|
| 1118 |
+
"""
|
| 1119 |
+
if not isinstance(raw, dict):
|
| 1120 |
+
return None
|
| 1121 |
+
code = raw.get("code")
|
| 1122 |
+
if not isinstance(code, str) or not code.strip():
|
| 1123 |
+
return None
|
| 1124 |
+
out = {"code": code.strip()[:CATALOG_CODE_MAX]}
|
| 1125 |
+
for key, allow_zero in (("x", True), ("y", True), ("w", False), ("h", False)):
|
| 1126 |
+
val = _clean_catalog_coord(raw.get(key), allow_zero)
|
| 1127 |
+
if val is not None:
|
| 1128 |
+
out[key] = val
|
| 1129 |
+
return out
|
| 1130 |
+
|
| 1131 |
+
|
| 1132 |
+
def _clean_catalog_spec(raw, valid_keys):
|
| 1133 |
+
"""C3 β `display.catalog`, fail-closed. See the header block above for what it is NOT.
|
| 1134 |
+
|
| 1135 |
+
Field refs follow the `dateField`/`stackField` rule this module already runs: a ref naming a
|
| 1136 |
+
deleted column is dropped INDIVIDUALLY and the renderer falls back to its per-mode default. It
|
| 1137 |
+
never costs the user their pages β the `charts` per-entry-drop precedent, applied to the one
|
| 1138 |
+
payload here that is AUTHORED rather than derived from the rows.
|
| 1139 |
+
"""
|
| 1140 |
+
if not isinstance(raw, dict):
|
| 1141 |
+
return None
|
| 1142 |
+
out = {}
|
| 1143 |
+
for ref in ("imageField", "titleField"):
|
| 1144 |
+
if raw.get(ref) in valid_keys:
|
| 1145 |
+
out[ref] = raw[ref]
|
| 1146 |
+
pages_raw = raw.get("pages")
|
| 1147 |
+
pages = []
|
| 1148 |
+
if isinstance(pages_raw, list):
|
| 1149 |
+
# ONE cumulative item budget across the whole spec, spent in page order then section order
|
| 1150 |
+
# β the `_clean_catalogs` construction, so a 40-page catalog cannot smuggle 20,000 items
|
| 1151 |
+
# past a per-page cap.
|
| 1152 |
+
budget = MAX_CATALOG_CODES
|
| 1153 |
+
seen_pages = set()
|
| 1154 |
+
for raw_page in pages_raw:
|
| 1155 |
+
if len(pages) >= MAX_CATALOG_PAGES:
|
| 1156 |
+
break
|
| 1157 |
+
if not isinstance(raw_page, dict):
|
| 1158 |
+
continue
|
| 1159 |
+
pid = str(raw_page.get("id") or "").strip()[:CATALOG_ID_MAX]
|
| 1160 |
+
if not pid or pid in seen_pages:
|
| 1161 |
+
continue
|
| 1162 |
+
seen_pages.add(pid)
|
| 1163 |
+
page = {"id": pid}
|
| 1164 |
+
order = raw_page.get("order")
|
| 1165 |
+
if (isinstance(order, int) and not isinstance(order, bool)
|
| 1166 |
+
and 0 <= order <= MAX_CATALOG_ORDER):
|
| 1167 |
+
page["order"] = order
|
| 1168 |
+
sections_raw = raw_page.get("sections")
|
| 1169 |
+
sections = []
|
| 1170 |
+
if isinstance(sections_raw, list):
|
| 1171 |
+
seen_sections = set()
|
| 1172 |
+
for raw_section in sections_raw:
|
| 1173 |
+
if len(sections) >= MAX_CATALOG_SECTIONS:
|
| 1174 |
+
break
|
| 1175 |
+
if not isinstance(raw_section, dict):
|
| 1176 |
+
continue
|
| 1177 |
+
sid = str(raw_section.get("id") or "").strip()[:CATALOG_ID_MAX]
|
| 1178 |
+
if not sid or sid in seen_sections:
|
| 1179 |
+
continue
|
| 1180 |
+
seen_sections.add(sid)
|
| 1181 |
+
section = {"id": sid}
|
| 1182 |
+
items_raw = raw_section.get("items")
|
| 1183 |
+
items = []
|
| 1184 |
+
if isinstance(items_raw, list) and budget > 0:
|
| 1185 |
+
for raw_item in items_raw:
|
| 1186 |
+
item = _clean_catalog_item(raw_item)
|
| 1187 |
+
if item is None:
|
| 1188 |
+
continue
|
| 1189 |
+
items.append(item)
|
| 1190 |
+
budget -= 1
|
| 1191 |
+
if budget <= 0:
|
| 1192 |
+
break
|
| 1193 |
+
# β NOT de-duplicated by `code`. The same product legitimately appears twice in
|
| 1194 |
+
# one section at two positions β that is what free placement IS β and wave-18's
|
| 1195 |
+
# "deduped WITHIN a page" rule was written for a LISTING, where a repeat is a
|
| 1196 |
+
# mistake. Here it is the feature.
|
| 1197 |
+
if items:
|
| 1198 |
+
section["items"] = items
|
| 1199 |
+
sections.append(section)
|
| 1200 |
+
# An EMPTY section is KEPT: a person adds a New Section and then drags into it, and
|
| 1201 |
+
# dropping it would delete the thing they just made, between two autosaves.
|
| 1202 |
+
if sections:
|
| 1203 |
+
page["sections"] = sections
|
| 1204 |
+
pages.append(page)
|
| 1205 |
+
if pages:
|
| 1206 |
+
out["pages"] = pages
|
| 1207 |
+
# A spec that reduces to nothing is DROPPED, never stored as `{}`. "No image field, no title
|
| 1208 |
+
# field, no pages" IS the unconfigured state, and `{}` would be its second spelling β the
|
| 1209 |
+
# no-churn law every literal-only key in `_clean_display` follows.
|
| 1210 |
+
return out or None
|
| 1211 |
+
|
| 1212 |
+
|
| 1213 |
FORM_ACCESS = ('public', 'emails')
|
| 1214 |
MAX_FORM_FIELDS = 60
|
| 1215 |
MAX_FORM_EMAILS = 200
|
|
|
|
| 1657 |
# saved 'dashboard' view reads back as 'chart' from here on; nothing writes 'dashboard'.
|
| 1658 |
mode = _LEGACY_MODES.get(mode, mode)
|
| 1659 |
out = {'mode': mode}
|
| 1660 |
+
# β W37-T28 (owner item 15) β `coordField`: ONE column holding `"lat,lon"`, so a map is no
|
| 1661 |
+
# longer a customers-only feature. It joins this loop rather than getting a clause of its own
|
| 1662 |
+
# because it IS a field ref with the same drop-if-deleted rule; `customer-grid/types.ts`
|
| 1663 |
+
# gains it in the SAME change, which is contract C2's whole point.
|
| 1664 |
+
for ref in ('dateField', 'stackField', 'titleField', 'colorField', 'sizeField', 'coordField'):
|
| 1665 |
if raw.get(ref) in valid_keys:
|
| 1666 |
out[ref] = raw[ref]
|
| 1667 |
# ββ C-DISP (wave 2026-08-02) βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 1796 |
catalogs = _clean_catalogs(raw.get('catalogs'), valid_keys)
|
| 1797 |
if catalogs:
|
| 1798 |
out['catalogs'] = catalogs
|
| 1799 |
+
# ββ ββ WAVE-37 C3 (T21) β `catalog`, SINGULAR, and it is NOT the line above it. See the
|
| 1800 |
+
# header on `_clean_catalog_spec` for the two-keys-one-letter-apart warning; this call site is
|
| 1801 |
+
# where a reader is most likely to "fix" one into the other.
|
| 1802 |
+
catalog = _clean_catalog_spec(raw.get('catalog'), valid_keys)
|
| 1803 |
+
if catalog:
|
| 1804 |
+
out['catalog'] = catalog
|
| 1805 |
# ββ β WAVE-27 C3 (item 8 / R2): the swipe binding ββββββββββββββββββββββββββββββββββββ
|
| 1806 |
# `{fieldKey, leftOption, rightOption}` β WHOLE-KEY drop, never a partial one, and that
|
| 1807 |
# asymmetry against `charts`/`calendarMetrics` above is the point rather than an oversight.
|
|
|
|
| 1830 |
# `kanbanClamp` law) wearing a control that promises a choice.
|
| 1831 |
if left and right and left.casefold() != right.casefold():
|
| 1832 |
out['swipe'] = {'fieldKey': f_key, 'leftOption': left, 'rightOption': right}
|
| 1833 |
+
# ββ ββ WAVE-37 T20 (owner item 10 / R2, contract C2): WHICH SCRIPT THIS VIEW RENDERS ββ
|
| 1834 |
+
#
|
| 1835 |
+
# `{id}` β the row in E's per-database script store (`/api/v1/script-views`) whose source this
|
| 1836 |
+
# view draws. WHOLE-KEY drop, the `swipe` construction rather than the `charts` one: a script
|
| 1837 |
+
# view with no id is not a degraded script view, it is a panel with nothing to run, and the
|
| 1838 |
+
# honest state for that is the unconfigured one the client already has a surface for.
|
| 1839 |
+
#
|
| 1840 |
+
# β THE ID IS DELIBERATELY NOT CHECKED AGAINST `valid_keys`, AND THAT IS NOT AN OVERSIGHT.
|
| 1841 |
+
# `valid_keys` is this table's FIELD keys; a script id names a record in a DIFFERENT store that
|
| 1842 |
+
# this function cannot reach. So the host bounds the shape and nothing else, and the renderer
|
| 1843 |
+
# owns the one loss this is blind to β the script row was deleted β which it must SHOW rather
|
| 1844 |
+
# than fall back to another script (the `viewModes.tsx` house rule `_clean_swipe`'s note cites).
|
| 1845 |
+
# β Mirrored key-for-key by `customer-grid/types.ts::cleanDisplay`. That file rebuilds the
|
| 1846 |
+
# config key by key on every autosave, so a key accepted HERE and unknown THERE is erased on the
|
| 1847 |
+
# next column resize β which is the half of the round trip this docstring's W33 note warns about.
|
| 1848 |
+
script = raw.get('script')
|
| 1849 |
+
if isinstance(script, dict):
|
| 1850 |
+
s_id = script.get('id')
|
| 1851 |
+
if isinstance(s_id, str) and s_id.strip():
|
| 1852 |
+
out['script'] = {'id': s_id.strip()[:MAX_SCRIPT_ID]}
|
| 1853 |
# ββ ββ WAVE-29 R7 (item 10): the FORM spec β see `_clean_form` for why the token is not here.
|
| 1854 |
form = _clean_form(raw.get('form'), valid_keys)
|
| 1855 |
if form:
|
|
|
|
| 2057 |
return out
|
| 2058 |
|
| 2059 |
|
| 2060 |
+
def view_refs_of(nodes):
|
| 2061 |
+
"""C4 β every view id a tree REFERS to, deduped, in order. Mirrors `types.ts::viewRefsOf`.
|
| 2062 |
+
|
| 2063 |
+
β An INACTIVE leaf (no view chosen yet) is not a reference, on both engines: counting one
|
| 2064 |
+
would refuse a save the moment somebody opened the condition and had not picked anything.
|
| 2065 |
+
"""
|
| 2066 |
+
out = []
|
| 2067 |
+
for node in nodes or []:
|
| 2068 |
+
if not isinstance(node, dict):
|
| 2069 |
+
continue
|
| 2070 |
+
if 'children' in node:
|
| 2071 |
+
for vid in view_refs_of(node.get('children')):
|
| 2072 |
+
if vid not in out:
|
| 2073 |
+
out.append(vid)
|
| 2074 |
+
continue
|
| 2075 |
+
if node.get('colId') != VIEW_FIELD:
|
| 2076 |
+
continue
|
| 2077 |
+
vid = str(node.get('value') or '').strip()[:MAX_VIEW_ID]
|
| 2078 |
+
if vid and vid not in out:
|
| 2079 |
+
out.append(vid)
|
| 2080 |
+
return out
|
| 2081 |
+
|
| 2082 |
+
|
| 2083 |
+
def view_filter_cycle(view_id, nodes, filters_of):
|
| 2084 |
+
"""ββ W37-T26 / C4 β THE CYCLE REFUSAL's host half. Mirrors `types.ts::viewFilterCycle`.
|
| 2085 |
+
|
| 2086 |
+
View X filtered on "is in View Y" where Y is filtered on X cannot be resolved by anything:
|
| 2087 |
+
X needs Y needs X. Returns the CYCLE PATH (ids, starting and ending at `view_id`) or None.
|
| 2088 |
+
|
| 2089 |
+
β THE CLIENT REFUSES THE SAME SHAPE AND THAT IS NOT DUPLICATION. The browser's guard is what
|
| 2090 |
+
a PERSON sees and is the only one that can explain itself; this one is what makes the rule true
|
| 2091 |
+
for a caller that POSTs a view directly, which is every guard-in-the-browser's blind spot.
|
| 2092 |
+
β A view referring to ITSELF is a cycle of length one and is caught by the same walk β also
|
| 2093 |
+
the case a person reaches most easily, by duplicating a view.
|
| 2094 |
+
"""
|
| 2095 |
+
def walk(current, path, seen):
|
| 2096 |
+
refs = view_refs_of(nodes if current == view_id else filters_of(current))
|
| 2097 |
+
for nxt in refs:
|
| 2098 |
+
if nxt == view_id:
|
| 2099 |
+
return path + [nxt]
|
| 2100 |
+
if nxt in seen:
|
| 2101 |
+
continue
|
| 2102 |
+
seen.add(nxt)
|
| 2103 |
+
found = walk(nxt, path + [nxt], seen)
|
| 2104 |
+
if found:
|
| 2105 |
+
return found
|
| 2106 |
+
return None
|
| 2107 |
+
|
| 2108 |
+
return walk(view_id, [view_id], {view_id})
|
| 2109 |
+
|
| 2110 |
+
|
| 2111 |
+
def clean_filter_tree(raw, valid_keys, depth=1, budget=None, cohort_ids=None,
|
| 2112 |
+
visible_view_ids=None):
|
| 2113 |
"""Recursively validate an UNTRUSTED filter tree (conditions + nested groups).
|
| 2114 |
|
| 2115 |
Returns a clean tree of leaf conditions ({colId, op, value, value2}) and
|
|
|
|
| 2128 |
condition that can only match nothing, so `List is not [deleted]` would show an empty table
|
| 2129 |
forever with no way to tell why. `None` means this host has no cohorts, and then every
|
| 2130 |
cohort leaf is dropped: fail-closed, like every other unknown key.
|
| 2131 |
+
|
| 2132 |
+
ββ W37-T26 (C4) β `visible_view_ids` is the same parameter one leaf over: the views this
|
| 2133 |
+
caller may name in a `__view__` membership leaf. A leaf naming anything else is DROPPED here
|
| 2134 |
+
for the identical reason, and `None` (this host has no views to offer) drops every one.
|
| 2135 |
+
β DEFAULTS TO `None` SO EVERY EXISTING CALLER IS BYTE-IDENTICAL. Three callers pass nothing
|
| 2136 |
+
(`routes_admin`, `routes_query` twice) and each of them is a surface with no saved-view
|
| 2137 |
+
vocabulary of its own; under the fail-closed rule they drop the leaf, which is correct rather
|
| 2138 |
+
than merely safe.
|
| 2139 |
"""
|
| 2140 |
if budget is None:
|
| 2141 |
budget = [MAX_FILTER_NODES]
|
|
|
|
| 2150 |
continue # too deep -> drop
|
| 2151 |
budget[0] -= 1
|
| 2152 |
children = clean_filter_tree(node['children'], valid_keys,
|
| 2153 |
+
depth + 1, budget, cohort_ids, visible_view_ids)
|
| 2154 |
if children:
|
| 2155 |
out.append({'conj': 'or' if node.get('conj') == 'or' else 'and',
|
| 2156 |
'children': children})
|
|
|
|
| 2167 |
out.append({'colId': COHORT_FIELD, 'op': op,
|
| 2168 |
'value': ','.join(named), 'value2': ''})
|
| 2169 |
continue
|
| 2170 |
+
if node.get('colId') == VIEW_FIELD: # C4: a view-membership leaf
|
| 2171 |
+
# β THE SAME ALL-OR-NOTHING THE COHORT ARM ABOVE APPLIES, AND FOR THE SAME REASON.
|
| 2172 |
+
# A leaf naming a view this caller cannot see is DROPPED rather than kept, because a
|
| 2173 |
+
# kept one can only ever match nothing: `View is not [a view you cannot see]` would
|
| 2174 |
+
# show an empty table forever with no way to tell why. Dropping it puts the view back
|
| 2175 |
+
# in its honest unfiltered state, which is what `clean_filter_tree`'s own docstring
|
| 2176 |
+
# already promises for cohorts.
|
| 2177 |
+
# β `visible_views` is what the caller passes; `None` means this host does not know,
|
| 2178 |
+
# and then EVERY view leaf is dropped β fail-closed, like every other unknown key.
|
| 2179 |
+
v_op = node.get('op')
|
| 2180 |
+
v_id = str(node.get('value') or '').strip()[:MAX_VIEW_ID]
|
| 2181 |
+
if v_op in VIEW_OPS and v_id and v_id in (visible_view_ids or ()):
|
| 2182 |
+
budget[0] -= 1
|
| 2183 |
+
out.append({'colId': VIEW_FIELD, 'op': v_op, 'value': v_id, 'value2': ''})
|
| 2184 |
+
continue
|
| 2185 |
if node.get('colId') in valid_keys and node.get('op') in FILTER_OPS:
|
| 2186 |
budget[0] -= 1
|
| 2187 |
# `or ''` would be wrong here: it maps every FALSY value to '', and '' is the
|
platform/aios_grid_fields.json
CHANGED
|
@@ -1,534 +1,548 @@
|
|
| 1 |
-
{
|
| 2 |
-
"_comment": "CANONICAL field contract for the AIOS Airtable-style grid β the SINGLE source of truth. Consumed by platform/aios_grid.py (embed/Space host) and aios-web/api/main.py (standalone API), and regenerated into aios-web/web/public/sample_customers.json. Edit HERE only, then run aios-web/verify_fields_contract.py. source=odoo is READ-ONLY; source=overlay is the editable stratum (notes/tags) outside Odoo. type in {text,status,select,currency,int,date,pct} (select = a fixed-choice READ-ONLY brand attribute; dba is the first, wave 2026-08-02). `description` (wave 5) is the CANONICAL per-field description β every field must carry one, and since wave 7 (owner W8, 2026-07-28) every description is ONE SHORT PLAIN sentence (two only when a fact would otherwise mislead): what the field IS, nothing else β no filter tips, no '(none)' coaching, no rationale; the user's workspace NOTE overrides it in the (i) hover, never in this file. BUILDER FACT (documented here, deliberately NOT in user-facing text): blank text attributes display as '(none)', so `is '(none)'` β not `is empty` β finds the blanks on agent/city/state/country/zip/payment_terms/pricelist/tags. filterable:false = the CONDITION BUILDER does not offer it (still displayed, still sortable); every such field must have a replacement declared in aios-web/verify_fields_contract.py. 2026-07-27 partner attributes: country/zip/payment_terms/pricelist/tags/customer_since all ship default:false. zip is TEXT because a postal code has leading zeros. Odoo's credit_limit (1% populated) and user_id salesperson (2%) are deliberately ABSENT; agent_ids is the salesperson field and AR is where credit exposure comes from. Wave-5 item 8 (2026-07-27): ltm_rev and at_risk are DELETED β LTM's replacement is a creatable Sales measure column (the demo column IS Sales Β· the last 12 months), at_risk's replacement is a formula field, e.g. MAX(0, {revenue_ly} - {revenue_ytd}). Wave-6 item 8 (2026-07-27, the no-buildable-presets rule): revenue_ytd, revenue_ly, orders_24m, aov and yoy_pct are DELETED β every one is self-buildable, so a frozen pre-set beside the builder was two ways to ask one question. Replacements (recorded in verify_fields_contract.py): creatable measure columns for Sales / Orders / Avg order $ over any period (harness/measure_filter.py ADMITTED carries revenue, orders and the composite aov), and a formula over two measure columns for YoY, e.g. ({sales_ytd} - {sales_ly}) / {sales_ly}. Stale view colIds naming the five self-heal on the next autosave (the established rule).",
|
| 3 |
-
"fields": [
|
| 4 |
-
{
|
| 5 |
-
"key": "customer",
|
| 6 |
-
"label": "Customer",
|
| 7 |
-
"type": "text",
|
| 8 |
-
"source": "odoo",
|
| 9 |
-
"pinned": true,
|
| 10 |
-
"default": true,
|
| 11 |
-
"description": "The customer's name in Odoo. One row per customer who ordered in the last 24 months."
|
| 12 |
-
},
|
| 13 |
-
{
|
| 14 |
-
"key": "partner_id",
|
| 15 |
-
"label": "Odoo ID",
|
| 16 |
-
"type": "int",
|
| 17 |
-
"source": "odoo",
|
| 18 |
-
"derived": true,
|
| 19 |
-
"default": false,
|
| 20 |
-
"description": "The Odoo res.partner id β the key every Odoo document joins on. DERIVED: this row's pid IS the partner id, so a stored copy would be a second source."
|
| 21 |
-
},
|
| 22 |
-
{
|
| 23 |
-
"key": "odoo_status",
|
| 24 |
-
"label": "Odoo record",
|
| 25 |
-
"type": "status",
|
| 26 |
-
"source": "odoo",
|
| 27 |
-
"default": false,
|
| 28 |
-
"options": [
|
| 29 |
-
"Active",
|
| 30 |
-
"Archived"
|
| 31 |
-
],
|
| 32 |
-
"description": "Whether this customer still exists in Odoo. Archived means deleted there."
|
| 33 |
-
},
|
| 34 |
-
{
|
| 35 |
-
"key": "agent",
|
| 36 |
-
"label": "Agent",
|
| 37 |
-
"type": "text",
|
| 38 |
-
"source": "odoo",
|
| 39 |
-
"default": true,
|
| 40 |
-
"description": "The sales agent who owns this account."
|
| 41 |
-
},
|
| 42 |
-
{
|
| 43 |
-
"key": "dba",
|
| 44 |
-
"label": "DBA",
|
| 45 |
-
"type": "select",
|
| 46 |
-
"source": "odoo",
|
| 47 |
-
"default": false,
|
| 48 |
-
"options": [
|
| 49 |
-
"Fisch",
|
| 50 |
-
"Royal",
|
| 51 |
-
"Both"
|
| 52 |
-
],
|
| 53 |
-
"description": "The brand this customer buys from - Fisch, Royal, or both. Amazon-channel orders are not a DBA."
|
| 54 |
-
},
|
| 55 |
-
{
|
| 56 |
-
"key": "salesperson",
|
| 57 |
-
"label": "Salesperson",
|
| 58 |
-
"type": "text",
|
| 59 |
-
"source": "odoo",
|
| 60 |
-
"default": false,
|
| 61 |
-
"description": "Who keyed in most of this customer's orders β not the Agent, who owns the account."
|
| 62 |
-
},
|
| 63 |
-
{
|
| 64 |
-
"key": "street",
|
| 65 |
-
"label": "Street",
|
| 66 |
-
"type": "text",
|
| 67 |
-
"source": "odoo",
|
| 68 |
-
"default": false,
|
| 69 |
-
"description": "First address line, from res.partner directly - not the geocoder, so a customer the map cannot place still shows its address."
|
| 70 |
-
},
|
| 71 |
-
{
|
| 72 |
-
"key": "street2",
|
| 73 |
-
"label": "Street 2",
|
| 74 |
-
"type": "text",
|
| 75 |
-
"source": "odoo",
|
| 76 |
-
"default": false,
|
| 77 |
-
"description": "Second address line (suite, unit, floor) on the customer's Odoo address."
|
| 78 |
-
},
|
| 79 |
-
{
|
| 80 |
-
"key": "city",
|
| 81 |
-
"label": "City",
|
| 82 |
-
"type": "text",
|
| 83 |
-
"source": "odoo",
|
| 84 |
-
"default": true,
|
| 85 |
-
"description": "City on the customer's Odoo address."
|
| 86 |
-
},
|
| 87 |
-
{
|
| 88 |
-
"key": "state",
|
| 89 |
-
"label": "State",
|
| 90 |
-
"type": "text",
|
| 91 |
-
"source": "odoo",
|
| 92 |
-
"default": true,
|
| 93 |
-
"description": "State or province on the customer's Odoo address."
|
| 94 |
-
},
|
| 95 |
-
{
|
| 96 |
-
"key": "country",
|
| 97 |
-
"label": "Country",
|
| 98 |
-
"type": "text",
|
| 99 |
-
"source": "odoo",
|
| 100 |
-
"default": false,
|
| 101 |
-
"description": "Country on the customer's Odoo address."
|
| 102 |
-
},
|
| 103 |
-
{
|
| 104 |
-
"key": "zip",
|
| 105 |
-
"label": "ZIP",
|
| 106 |
-
"type": "text",
|
| 107 |
-
"source": "odoo",
|
| 108 |
-
"default": false,
|
| 109 |
-
"description": "Postal code on the customer's Odoo address."
|
| 110 |
-
},
|
| 111 |
-
{
|
| 112 |
-
"key": "customer_since",
|
| 113 |
-
"label": "Customer since",
|
| 114 |
-
"type": "date",
|
| 115 |
-
"source": "odoo",
|
| 116 |
-
"default": false,
|
| 117 |
-
"description": "When this customer was first set up in Odoo."
|
| 118 |
-
},
|
| 119 |
-
{
|
| 120 |
-
"key": "tags",
|
| 121 |
-
"label": "Tags",
|
| 122 |
-
"type": "text",
|
| 123 |
-
"source": "odoo",
|
| 124 |
-
"default": false,
|
| 125 |
-
"description": "Odoo labels on this customer, comma-separated."
|
| 126 |
-
},
|
| 127 |
-
{
|
| 128 |
-
"key": "pricelist",
|
| 129 |
-
"label": "Price list",
|
| 130 |
-
"type": "text",
|
| 131 |
-
"source": "odoo",
|
| 132 |
-
"default": false,
|
| 133 |
-
"description": "The price list this customer buys on."
|
| 134 |
-
},
|
| 135 |
-
{
|
| 136 |
-
"key": "payment_terms",
|
| 137 |
-
"label": "Payment terms",
|
| 138 |
-
"type": "text",
|
| 139 |
-
"source": "odoo",
|
| 140 |
-
"default": false,
|
| 141 |
-
"description": "Payment terms on this customer's account β Net 30, for example."
|
| 142 |
-
},
|
| 143 |
-
{
|
| 144 |
-
"key": "last_order",
|
| 145 |
-
"label": "Last order",
|
| 146 |
-
"type": "date",
|
| 147 |
-
"source": "odoo",
|
| 148 |
-
"default": true,
|
| 149 |
-
"description": "Date of the most recent confirmed order."
|
| 150 |
-
},
|
| 151 |
-
{
|
| 152 |
-
"key": "overdue_days",
|
| 153 |
-
"label": "Overdue days",
|
| 154 |
-
"type": "int",
|
| 155 |
-
"source": "odoo",
|
| 156 |
-
"default": true,
|
| 157 |
-
"description": "How many days late this customer is running against their own usual ordering rhythm."
|
| 158 |
-
},
|
| 159 |
-
{
|
| 160 |
-
"_note": "filterable:false β DERIVED ANALYTIC: est_missed is min(cycles missed, 3) x AOV, a score we compute rather than an object the business has, so a condition on it would read as a fact about the customer when it is a fact about our arithmetic. It still displays and still sorts. Until wave 6 this flag also covered the frozen-window presets (revenue_ytd / revenue_ly / orders_24m / aov / yoy_pct); those are now DELETED outright under the owner's no-buildable-presets rule β see _comment. est_missed itself STAYS: no creatable measure or formula reproduces the cadence model behind it.",
|
| 161 |
-
"key": "est_missed",
|
| 162 |
-
"label": "Est. missed $",
|
| 163 |
-
"type": "currency",
|
| 164 |
-
"source": "odoo",
|
| 165 |
-
"default": true,
|
| 166 |
-
"agg": "sum",
|
| 167 |
-
"filterable": false,
|
| 168 |
-
"description": "Estimated sales missed while quiet: missed orders (capped at 3) times average order value. An estimate, not money owed."
|
| 169 |
-
},
|
| 170 |
-
{
|
| 171 |
-
"_note": "wave 21 R1 β KEY UNCHANGED, LABEL RENAMED. The computation is a DISJOINT split (ar.py credit_exposure): this column is only the not-yet-due residual, its sibling is the past-grace residual, and the two sum to the total. Under the label 'AR open $' the majority-late book read as 'Overdue > Open', which is nonsense in AR vocabulary β 'open' universally means the total. The label now says what the number is; the key stays so saved views and filters keep working.",
|
| 172 |
-
"key": "ar_open",
|
| 173 |
-
"label": "AR current $",
|
| 174 |
-
"type": "currency",
|
| 175 |
-
"source": "odoo",
|
| 176 |
-
"default": false,
|
| 177 |
-
"description": "Invoiced money owed but not yet due (a 5-day grace applies before it counts as overdue)."
|
| 178 |
-
},
|
| 179 |
-
{
|
| 180 |
-
"key": "ar_overdue",
|
| 181 |
-
"label": "AR overdue $",
|
| 182 |
-
"type": "currency",
|
| 183 |
-
"source": "odoo",
|
| 184 |
-
"default": false,
|
| 185 |
-
"description": "Invoiced money past due β same basis as the Collections page."
|
| 186 |
-
},
|
| 187 |
-
{
|
| 188 |
-
"_note": "wave 21 R1 β the TOTAL, added beside the rename above. AR current $ + AR overdue $, i.e. what most people mean by 'open AR'. Composed from the same ar.credit_exposure rows the siblings use, so it is transitively reconciled by ar.validate()'s residual read_group tie β no second oracle.",
|
| 189 |
-
"key": "ar_outstanding",
|
| 190 |
-
"label": "AR outstanding $",
|
| 191 |
-
"type": "currency",
|
| 192 |
-
"source": "odoo",
|
| 193 |
-
"default": false,
|
| 194 |
-
"description": "Total invoiced money owed right now: AR current $ plus AR overdue $."
|
| 195 |
-
},
|
| 196 |
-
{
|
| 197 |
-
"key": "ar_exposure",
|
| 198 |
-
"label": "Credit exposure $",
|
| 199 |
-
"type": "currency",
|
| 200 |
-
"source": "odoo",
|
| 201 |
-
"default": false,
|
| 202 |
-
"description": "The most you could be out if they stopped paying today: open, overdue, draft and not-yet-invoiced."
|
| 203 |
-
},
|
| 204 |
-
{
|
| 205 |
-
"key": "ar_aged_1_30",
|
| 206 |
-
"label": "1-30 days $",
|
| 207 |
-
"type": "currency",
|
| 208 |
-
"source": "odoo",
|
| 209 |
-
"default": false,
|
| 210 |
-
"description": "Overdue between 1 and 30 days. The four aging buckets sum to AR overdue $."
|
| 211 |
-
},
|
| 212 |
-
{
|
| 213 |
-
"key": "ar_aged_31_60",
|
| 214 |
-
"label": "31-60 days $",
|
| 215 |
-
"type": "currency",
|
| 216 |
-
"source": "odoo",
|
| 217 |
-
"default": false,
|
| 218 |
-
"description": "Overdue between 31 and 60 days. The four aging buckets sum to AR overdue $."
|
| 219 |
-
},
|
| 220 |
-
{
|
| 221 |
-
"key": "ar_aged_61_90",
|
| 222 |
-
"label": "61-90 days $",
|
| 223 |
-
"type": "currency",
|
| 224 |
-
"source": "odoo",
|
| 225 |
-
"default": false,
|
| 226 |
-
"description": "Overdue between 61 and 90 days. The four aging buckets sum to AR overdue $."
|
| 227 |
-
},
|
| 228 |
-
{
|
| 229 |
-
"key": "ar_aged_90_plus",
|
| 230 |
-
"label": "90+ days $",
|
| 231 |
-
"type": "currency",
|
| 232 |
-
"source": "odoo",
|
| 233 |
-
"default": false,
|
| 234 |
-
"description": "Overdue by more than 90 days. The four aging buckets sum to AR overdue $."
|
| 235 |
-
},
|
| 236 |
-
{
|
| 237 |
-
"key": "days_to_pay",
|
| 238 |
-
"label": "Days to pay",
|
| 239 |
-
"type": "int",
|
| 240 |
-
"source": "odoo",
|
| 241 |
-
"default": false,
|
| 242 |
-
"description": "Average days to pay an invoice in full. Blank means no fully paid invoice yet."
|
| 243 |
-
},
|
| 244 |
-
{
|
| 245 |
-
"key": "top_category",
|
| 246 |
-
"label": "Top category",
|
| 247 |
-
"type": "text",
|
| 248 |
-
"source": "odoo",
|
| 249 |
-
"default": false,
|
| 250 |
-
"description": "The category this customer spent the most on in the last 12 months."
|
| 251 |
-
},
|
| 252 |
-
{
|
| 253 |
-
"key": "top_category_pct",
|
| 254 |
-
"label": "Top category %",
|
| 255 |
-
"type": "pct",
|
| 256 |
-
"source": "odoo",
|
| 257 |
-
"default": false,
|
| 258 |
-
"description": "Share of last-12-months spend that went to the top category."
|
| 259 |
-
},
|
| 260 |
-
{
|
| 261 |
-
"key": "sku_count",
|
| 262 |
-
"label": "SKUs bought",
|
| 263 |
-
"type": "int",
|
| 264 |
-
"source": "odoo",
|
| 265 |
-
"default": false,
|
| 266 |
-
"description": "Distinct products bought in the last 12 months."
|
| 267 |
-
},
|
| 268 |
-
{
|
| 269 |
-
"key": "top_sku",
|
| 270 |
-
"label": "Top SKU",
|
| 271 |
-
"type": "text",
|
| 272 |
-
"source": "odoo",
|
| 273 |
-
"default": false,
|
| 274 |
-
"description": "The product this customer spent the most on in the last 12 months."
|
| 275 |
-
},
|
| 276 |
-
{
|
| 277 |
-
"key": "days_since",
|
| 278 |
-
"label": "Days since order",
|
| 279 |
-
"type": "int",
|
| 280 |
-
"source": "odoo",
|
| 281 |
-
"default": false,
|
| 282 |
-
"description": "Days since the last confirmed order."
|
| 283 |
-
},
|
| 284 |
-
{
|
| 285 |
-
"key": "typical_gap_days",
|
| 286 |
-
"label": "Typical gap days",
|
| 287 |
-
"type": "int",
|
| 288 |
-
"source": "odoo",
|
| 289 |
-
"default": false,
|
| 290 |
-
"description": "Days this customer usually goes between orders, from their own history."
|
| 291 |
-
},
|
| 292 |
-
{
|
| 293 |
-
"key": "notes",
|
| 294 |
-
"label": "Notes",
|
| 295 |
-
"type": "text",
|
| 296 |
-
"source": "overlay",
|
| 297 |
-
"default": false,
|
| 298 |
-
"description": "Your notes on this customer. Saved in this app only, visible only to you."
|
| 299 |
-
}
|
| 300 |
-
],
|
| 301 |
-
"_product_comment": "ADDITIVE, wave 15 C-TOPIC. The PRODUCT table's field contract. Kept as a SEPARATE top-level key rather than restructuring `fields` into {customer_data, product_data}: both existing readers (aios_grid._load_fields, aios-web/api/main.py) index doc['fields'] directly, and reshaping that mid-wave would break the embed for a cosmetic gain. The keyed shape can arrive when both readers move in ONE commit; until then this is the product half and `fields` is the customer half.",
|
| 302 |
-
"_product_removed_buy_now": "OWNER, 2026-08-03: 'Buy signal' (key buy_now, a select of Buy now / OK) is NO LONGER A PRESET FIELD. It never earned one: it is a formula over two columns that are both still right here, and the platform has a formula field type for exactly that. THE FORMULA, which reproduces the retired column row for row (modules/product_data.validate proves the equivalence, and goes red if it ever stops holding): IF({lead_days} > 0, IF({dos} < {lead_days}, \"Buy now\", \"OK\"), \"\") . Every branch matches the old server rule, including the blanks - the formula engine refuses a comparison against a blank rather than coercing it to 0, so a SKU with no days-of-supply or no lead time comes out empty, which is 'we do not know' and not 'you are fine'. NOTE the column is still COMPUTED in product_data.pool(): it ships nowhere (rows_from_pool projects strictly through this contract, so no Field means no cell on the wire) and exists only as validate()'s oracle. A formula field is PER-USER, so nothing shared may filter on it - the Buy list view filters on dos/lead_days directly (_seed_wave17).",
|
| 303 |
-
"product_data": {
|
| 304 |
-
"identity": "pid",
|
| 305 |
-
"business_key": "code",
|
| 306 |
-
"fields": [
|
| 307 |
-
{
|
| 308 |
-
"key": "code",
|
| 309 |
-
"label": "SKU",
|
| 310 |
-
"type": "text",
|
| 311 |
-
"source": "odoo",
|
| 312 |
-
"pinned": true,
|
| 313 |
-
"default": true,
|
| 314 |
-
"description": "The SKU code β the product's real business key. `pid` is a stable CRC32 of it because the grid keys on an integer."
|
| 315 |
-
},
|
| 316 |
-
{
|
| 317 |
-
"key": "product",
|
| 318 |
-
"label": "Product",
|
| 319 |
-
"type": "text",
|
| 320 |
-
"source": "odoo",
|
| 321 |
-
"default": true,
|
| 322 |
-
"description": "Product name as it appears in Odoo."
|
| 323 |
-
},
|
| 324 |
-
{
|
| 325 |
-
"key": "category",
|
| 326 |
-
"label": "Category",
|
| 327 |
-
"type": "select",
|
| 328 |
-
"source": "odoo",
|
| 329 |
-
"default": true,
|
| 330 |
-
"description": "Product category; '(uncategorized)' when Odoo carries none."
|
| 331 |
-
},
|
| 332 |
-
{
|
| 333 |
-
"key": "supplier",
|
| 334 |
-
"label": "Supplier",
|
| 335 |
-
"type": "text",
|
| 336 |
-
"source": "overlay",
|
| 337 |
-
"default": true,
|
| 338 |
-
"description": "Who makes it. Editable here and shared with everyone in the workspace; seeded from the inventory mastersheet.",
|
| 339 |
-
"shared": true
|
| 340 |
-
},
|
| 341 |
-
{
|
| 342 |
-
"key": "origin_country",
|
| 343 |
-
"label": "Country",
|
| 344 |
-
"type": "text",
|
| 345 |
-
"source": "overlay",
|
| 346 |
-
"default": false,
|
| 347 |
-
"description": "Country of origin. Editable here and shared with everyone; seeded from the inventory mastersheet.",
|
| 348 |
-
"shared": true
|
| 349 |
-
},
|
| 350 |
-
{
|
| 351 |
-
"key": "lead_days",
|
| 352 |
-
"label": "Lead time (days)",
|
| 353 |
-
"type": "int",
|
| 354 |
-
"source": "overlay",
|
| 355 |
-
"default": true,
|
| 356 |
-
"description": "Order-to-arrival days for this supplier. Drives the buy signal. Editable and shared with everyone.",
|
| 357 |
-
"shared": true
|
| 358 |
-
},
|
| 359 |
-
{
|
| 360 |
-
"key": "first_cost",
|
| 361 |
-
"label": "First cost",
|
| 362 |
-
"type": "currency",
|
| 363 |
-
"source": "overlay",
|
| 364 |
-
"default": false,
|
| 365 |
-
"description": "Quoted unit cost at origin, before freight and duty. Editable and shared with everyone.",
|
| 366 |
-
"shared": true
|
| 367 |
-
},
|
| 368 |
-
{
|
| 369 |
-
"key": "price_fisch",
|
| 370 |
-
"label": "Fisch price",
|
| 371 |
-
"type": "currency",
|
| 372 |
-
"source": "odoo",
|
| 373 |
-
"description": "Fisch pricelist price for this SKU. Blank when that list prices it nowhere."
|
| 374 |
-
},
|
| 375 |
-
{
|
| 376 |
-
"key": "price_royal_1",
|
| 377 |
-
"label": "Royal 1 price",
|
| 378 |
-
"type": "currency",
|
| 379 |
-
"source": "odoo",
|
| 380 |
-
"description": "Royal 1 pricelist price for this SKU. Blank when that list prices it nowhere."
|
| 381 |
-
},
|
| 382 |
-
{
|
| 383 |
-
"key": "price_royal_2",
|
| 384 |
-
"label": "Royal 2 price",
|
| 385 |
-
"type": "currency",
|
| 386 |
-
"source": "odoo",
|
| 387 |
-
"description": "Royal 2 pricelist price for this SKU. Blank when that list prices it nowhere."
|
| 388 |
-
},
|
| 389 |
-
{
|
| 390 |
-
"key": "
|
| 391 |
-
"label": "
|
| 392 |
-
"type": "
|
| 393 |
-
"source": "odoo",
|
| 394 |
-
"
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
"
|
| 399 |
-
"
|
| 400 |
-
"
|
| 401 |
-
"
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
"
|
| 406 |
-
"
|
| 407 |
-
"
|
| 408 |
-
"
|
| 409 |
-
"description": "Year-
|
| 410 |
-
},
|
| 411 |
-
{
|
| 412 |
-
"key": "
|
| 413 |
-
"label": "
|
| 414 |
-
"type": "
|
| 415 |
-
"source": "odoo",
|
| 416 |
-
"description": "
|
| 417 |
-
},
|
| 418 |
-
{
|
| 419 |
-
"key": "
|
| 420 |
-
"label": "
|
| 421 |
-
"type": "
|
| 422 |
-
"source": "odoo",
|
| 423 |
-
"description": "
|
| 424 |
-
},
|
| 425 |
-
{
|
| 426 |
-
"key": "
|
| 427 |
-
"label": "
|
| 428 |
-
"type": "int",
|
| 429 |
-
"source": "odoo",
|
| 430 |
-
"description": "Units
|
| 431 |
-
},
|
| 432 |
-
{
|
| 433 |
-
"key": "
|
| 434 |
-
"label": "
|
| 435 |
-
"type": "
|
| 436 |
-
"source": "odoo",
|
| 437 |
-
"description": "
|
| 438 |
-
},
|
| 439 |
-
{
|
| 440 |
-
"key": "
|
| 441 |
-
"label": "
|
| 442 |
-
"type": "
|
| 443 |
-
"source": "odoo",
|
| 444 |
-
"description": "
|
| 445 |
-
},
|
| 446 |
-
{
|
| 447 |
-
"key": "
|
| 448 |
-
"label": "
|
| 449 |
-
"type": "
|
| 450 |
-
"source": "odoo",
|
| 451 |
-
"description": "
|
| 452 |
-
},
|
| 453 |
-
{
|
| 454 |
-
"key": "
|
| 455 |
-
"label": "
|
| 456 |
-
"type": "
|
| 457 |
-
"source": "odoo",
|
| 458 |
-
"description": "
|
| 459 |
-
},
|
| 460 |
-
{
|
| 461 |
-
"key": "
|
| 462 |
-
"label": "
|
| 463 |
-
"type": "int",
|
| 464 |
-
"source": "odoo",
|
| 465 |
-
"
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
"
|
| 470 |
-
"
|
| 471 |
-
"
|
| 472 |
-
"
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
"
|
| 477 |
-
"
|
| 478 |
-
"
|
| 479 |
-
"
|
| 480 |
-
"
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
"
|
| 485 |
-
"
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
"
|
| 491 |
-
"
|
| 492 |
-
"
|
| 493 |
-
"
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
"
|
| 503 |
-
"
|
| 504 |
-
"
|
| 505 |
-
"
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
"
|
| 515 |
-
"
|
| 516 |
-
"
|
| 517 |
-
"
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
"
|
| 527 |
-
"
|
| 528 |
-
"
|
| 529 |
-
"
|
| 530 |
-
"
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"_comment": "CANONICAL field contract for the AIOS Airtable-style grid β the SINGLE source of truth. Consumed by platform/aios_grid.py (embed/Space host) and aios-web/api/main.py (standalone API), and regenerated into aios-web/web/public/sample_customers.json. Edit HERE only, then run aios-web/verify_fields_contract.py. source=odoo is READ-ONLY; source=overlay is the editable stratum (notes/tags) outside Odoo. type in {text,status,select,currency,int,date,pct} (select = a fixed-choice READ-ONLY brand attribute; dba is the first, wave 2026-08-02). `description` (wave 5) is the CANONICAL per-field description β every field must carry one, and since wave 7 (owner W8, 2026-07-28) every description is ONE SHORT PLAIN sentence (two only when a fact would otherwise mislead): what the field IS, nothing else β no filter tips, no '(none)' coaching, no rationale; the user's workspace NOTE overrides it in the (i) hover, never in this file. BUILDER FACT (documented here, deliberately NOT in user-facing text): blank text attributes display as '(none)', so `is '(none)'` β not `is empty` β finds the blanks on agent/city/state/country/zip/payment_terms/pricelist/tags. filterable:false = the CONDITION BUILDER does not offer it (still displayed, still sortable); every such field must have a replacement declared in aios-web/verify_fields_contract.py. 2026-07-27 partner attributes: country/zip/payment_terms/pricelist/tags/customer_since all ship default:false. zip is TEXT because a postal code has leading zeros. Odoo's credit_limit (1% populated) and user_id salesperson (2%) are deliberately ABSENT; agent_ids is the salesperson field and AR is where credit exposure comes from. Wave-5 item 8 (2026-07-27): ltm_rev and at_risk are DELETED β LTM's replacement is a creatable Sales measure column (the demo column IS Sales Β· the last 12 months), at_risk's replacement is a formula field, e.g. MAX(0, {revenue_ly} - {revenue_ytd}). Wave-6 item 8 (2026-07-27, the no-buildable-presets rule): revenue_ytd, revenue_ly, orders_24m, aov and yoy_pct are DELETED β every one is self-buildable, so a frozen pre-set beside the builder was two ways to ask one question. Replacements (recorded in verify_fields_contract.py): creatable measure columns for Sales / Orders / Avg order $ over any period (harness/measure_filter.py ADMITTED carries revenue, orders and the composite aov), and a formula over two measure columns for YoY, e.g. ({sales_ytd} - {sales_ly}) / {sales_ly}. Stale view colIds naming the five self-heal on the next autosave (the established rule).",
|
| 3 |
+
"fields": [
|
| 4 |
+
{
|
| 5 |
+
"key": "customer",
|
| 6 |
+
"label": "Customer",
|
| 7 |
+
"type": "text",
|
| 8 |
+
"source": "odoo",
|
| 9 |
+
"pinned": true,
|
| 10 |
+
"default": true,
|
| 11 |
+
"description": "The customer's name in Odoo. One row per customer who ordered in the last 24 months."
|
| 12 |
+
},
|
| 13 |
+
{
|
| 14 |
+
"key": "partner_id",
|
| 15 |
+
"label": "Odoo ID",
|
| 16 |
+
"type": "int",
|
| 17 |
+
"source": "odoo",
|
| 18 |
+
"derived": true,
|
| 19 |
+
"default": false,
|
| 20 |
+
"description": "The Odoo res.partner id β the key every Odoo document joins on. DERIVED: this row's pid IS the partner id, so a stored copy would be a second source."
|
| 21 |
+
},
|
| 22 |
+
{
|
| 23 |
+
"key": "odoo_status",
|
| 24 |
+
"label": "Odoo record",
|
| 25 |
+
"type": "status",
|
| 26 |
+
"source": "odoo",
|
| 27 |
+
"default": false,
|
| 28 |
+
"options": [
|
| 29 |
+
"Active",
|
| 30 |
+
"Archived"
|
| 31 |
+
],
|
| 32 |
+
"description": "Whether this customer still exists in Odoo. Archived means deleted there."
|
| 33 |
+
},
|
| 34 |
+
{
|
| 35 |
+
"key": "agent",
|
| 36 |
+
"label": "Agent",
|
| 37 |
+
"type": "text",
|
| 38 |
+
"source": "odoo",
|
| 39 |
+
"default": true,
|
| 40 |
+
"description": "The sales agent who owns this account."
|
| 41 |
+
},
|
| 42 |
+
{
|
| 43 |
+
"key": "dba",
|
| 44 |
+
"label": "DBA",
|
| 45 |
+
"type": "select",
|
| 46 |
+
"source": "odoo",
|
| 47 |
+
"default": false,
|
| 48 |
+
"options": [
|
| 49 |
+
"Fisch",
|
| 50 |
+
"Royal",
|
| 51 |
+
"Both"
|
| 52 |
+
],
|
| 53 |
+
"description": "The brand this customer buys from - Fisch, Royal, or both. Amazon-channel orders are not a DBA."
|
| 54 |
+
},
|
| 55 |
+
{
|
| 56 |
+
"key": "salesperson",
|
| 57 |
+
"label": "Salesperson",
|
| 58 |
+
"type": "text",
|
| 59 |
+
"source": "odoo",
|
| 60 |
+
"default": false,
|
| 61 |
+
"description": "Who keyed in most of this customer's orders β not the Agent, who owns the account."
|
| 62 |
+
},
|
| 63 |
+
{
|
| 64 |
+
"key": "street",
|
| 65 |
+
"label": "Street",
|
| 66 |
+
"type": "text",
|
| 67 |
+
"source": "odoo",
|
| 68 |
+
"default": false,
|
| 69 |
+
"description": "First address line, from res.partner directly - not the geocoder, so a customer the map cannot place still shows its address."
|
| 70 |
+
},
|
| 71 |
+
{
|
| 72 |
+
"key": "street2",
|
| 73 |
+
"label": "Street 2",
|
| 74 |
+
"type": "text",
|
| 75 |
+
"source": "odoo",
|
| 76 |
+
"default": false,
|
| 77 |
+
"description": "Second address line (suite, unit, floor) on the customer's Odoo address."
|
| 78 |
+
},
|
| 79 |
+
{
|
| 80 |
+
"key": "city",
|
| 81 |
+
"label": "City",
|
| 82 |
+
"type": "text",
|
| 83 |
+
"source": "odoo",
|
| 84 |
+
"default": true,
|
| 85 |
+
"description": "City on the customer's Odoo address."
|
| 86 |
+
},
|
| 87 |
+
{
|
| 88 |
+
"key": "state",
|
| 89 |
+
"label": "State",
|
| 90 |
+
"type": "text",
|
| 91 |
+
"source": "odoo",
|
| 92 |
+
"default": true,
|
| 93 |
+
"description": "State or province on the customer's Odoo address."
|
| 94 |
+
},
|
| 95 |
+
{
|
| 96 |
+
"key": "country",
|
| 97 |
+
"label": "Country",
|
| 98 |
+
"type": "text",
|
| 99 |
+
"source": "odoo",
|
| 100 |
+
"default": false,
|
| 101 |
+
"description": "Country on the customer's Odoo address."
|
| 102 |
+
},
|
| 103 |
+
{
|
| 104 |
+
"key": "zip",
|
| 105 |
+
"label": "ZIP",
|
| 106 |
+
"type": "text",
|
| 107 |
+
"source": "odoo",
|
| 108 |
+
"default": false,
|
| 109 |
+
"description": "Postal code on the customer's Odoo address."
|
| 110 |
+
},
|
| 111 |
+
{
|
| 112 |
+
"key": "customer_since",
|
| 113 |
+
"label": "Customer since",
|
| 114 |
+
"type": "date",
|
| 115 |
+
"source": "odoo",
|
| 116 |
+
"default": false,
|
| 117 |
+
"description": "When this customer was first set up in Odoo."
|
| 118 |
+
},
|
| 119 |
+
{
|
| 120 |
+
"key": "tags",
|
| 121 |
+
"label": "Tags",
|
| 122 |
+
"type": "text",
|
| 123 |
+
"source": "odoo",
|
| 124 |
+
"default": false,
|
| 125 |
+
"description": "Odoo labels on this customer, comma-separated."
|
| 126 |
+
},
|
| 127 |
+
{
|
| 128 |
+
"key": "pricelist",
|
| 129 |
+
"label": "Price list",
|
| 130 |
+
"type": "text",
|
| 131 |
+
"source": "odoo",
|
| 132 |
+
"default": false,
|
| 133 |
+
"description": "The price list this customer buys on."
|
| 134 |
+
},
|
| 135 |
+
{
|
| 136 |
+
"key": "payment_terms",
|
| 137 |
+
"label": "Payment terms",
|
| 138 |
+
"type": "text",
|
| 139 |
+
"source": "odoo",
|
| 140 |
+
"default": false,
|
| 141 |
+
"description": "Payment terms on this customer's account β Net 30, for example."
|
| 142 |
+
},
|
| 143 |
+
{
|
| 144 |
+
"key": "last_order",
|
| 145 |
+
"label": "Last order",
|
| 146 |
+
"type": "date",
|
| 147 |
+
"source": "odoo",
|
| 148 |
+
"default": true,
|
| 149 |
+
"description": "Date of the most recent confirmed order."
|
| 150 |
+
},
|
| 151 |
+
{
|
| 152 |
+
"key": "overdue_days",
|
| 153 |
+
"label": "Overdue days",
|
| 154 |
+
"type": "int",
|
| 155 |
+
"source": "odoo",
|
| 156 |
+
"default": true,
|
| 157 |
+
"description": "How many days late this customer is running against their own usual ordering rhythm."
|
| 158 |
+
},
|
| 159 |
+
{
|
| 160 |
+
"_note": "filterable:false β DERIVED ANALYTIC: est_missed is min(cycles missed, 3) x AOV, a score we compute rather than an object the business has, so a condition on it would read as a fact about the customer when it is a fact about our arithmetic. It still displays and still sorts. Until wave 6 this flag also covered the frozen-window presets (revenue_ytd / revenue_ly / orders_24m / aov / yoy_pct); those are now DELETED outright under the owner's no-buildable-presets rule β see _comment. est_missed itself STAYS: no creatable measure or formula reproduces the cadence model behind it.",
|
| 161 |
+
"key": "est_missed",
|
| 162 |
+
"label": "Est. missed $",
|
| 163 |
+
"type": "currency",
|
| 164 |
+
"source": "odoo",
|
| 165 |
+
"default": true,
|
| 166 |
+
"agg": "sum",
|
| 167 |
+
"filterable": false,
|
| 168 |
+
"description": "Estimated sales missed while quiet: missed orders (capped at 3) times average order value. An estimate, not money owed."
|
| 169 |
+
},
|
| 170 |
+
{
|
| 171 |
+
"_note": "wave 21 R1 β KEY UNCHANGED, LABEL RENAMED. The computation is a DISJOINT split (ar.py credit_exposure): this column is only the not-yet-due residual, its sibling is the past-grace residual, and the two sum to the total. Under the label 'AR open $' the majority-late book read as 'Overdue > Open', which is nonsense in AR vocabulary β 'open' universally means the total. The label now says what the number is; the key stays so saved views and filters keep working.",
|
| 172 |
+
"key": "ar_open",
|
| 173 |
+
"label": "AR current $",
|
| 174 |
+
"type": "currency",
|
| 175 |
+
"source": "odoo",
|
| 176 |
+
"default": false,
|
| 177 |
+
"description": "Invoiced money owed but not yet due (a 5-day grace applies before it counts as overdue)."
|
| 178 |
+
},
|
| 179 |
+
{
|
| 180 |
+
"key": "ar_overdue",
|
| 181 |
+
"label": "AR overdue $",
|
| 182 |
+
"type": "currency",
|
| 183 |
+
"source": "odoo",
|
| 184 |
+
"default": false,
|
| 185 |
+
"description": "Invoiced money past due β same basis as the Collections page."
|
| 186 |
+
},
|
| 187 |
+
{
|
| 188 |
+
"_note": "wave 21 R1 β the TOTAL, added beside the rename above. AR current $ + AR overdue $, i.e. what most people mean by 'open AR'. Composed from the same ar.credit_exposure rows the siblings use, so it is transitively reconciled by ar.validate()'s residual read_group tie β no second oracle.",
|
| 189 |
+
"key": "ar_outstanding",
|
| 190 |
+
"label": "AR outstanding $",
|
| 191 |
+
"type": "currency",
|
| 192 |
+
"source": "odoo",
|
| 193 |
+
"default": false,
|
| 194 |
+
"description": "Total invoiced money owed right now: AR current $ plus AR overdue $."
|
| 195 |
+
},
|
| 196 |
+
{
|
| 197 |
+
"key": "ar_exposure",
|
| 198 |
+
"label": "Credit exposure $",
|
| 199 |
+
"type": "currency",
|
| 200 |
+
"source": "odoo",
|
| 201 |
+
"default": false,
|
| 202 |
+
"description": "The most you could be out if they stopped paying today: open, overdue, draft and not-yet-invoiced."
|
| 203 |
+
},
|
| 204 |
+
{
|
| 205 |
+
"key": "ar_aged_1_30",
|
| 206 |
+
"label": "1-30 days $",
|
| 207 |
+
"type": "currency",
|
| 208 |
+
"source": "odoo",
|
| 209 |
+
"default": false,
|
| 210 |
+
"description": "Overdue between 1 and 30 days. The four aging buckets sum to AR overdue $."
|
| 211 |
+
},
|
| 212 |
+
{
|
| 213 |
+
"key": "ar_aged_31_60",
|
| 214 |
+
"label": "31-60 days $",
|
| 215 |
+
"type": "currency",
|
| 216 |
+
"source": "odoo",
|
| 217 |
+
"default": false,
|
| 218 |
+
"description": "Overdue between 31 and 60 days. The four aging buckets sum to AR overdue $."
|
| 219 |
+
},
|
| 220 |
+
{
|
| 221 |
+
"key": "ar_aged_61_90",
|
| 222 |
+
"label": "61-90 days $",
|
| 223 |
+
"type": "currency",
|
| 224 |
+
"source": "odoo",
|
| 225 |
+
"default": false,
|
| 226 |
+
"description": "Overdue between 61 and 90 days. The four aging buckets sum to AR overdue $."
|
| 227 |
+
},
|
| 228 |
+
{
|
| 229 |
+
"key": "ar_aged_90_plus",
|
| 230 |
+
"label": "90+ days $",
|
| 231 |
+
"type": "currency",
|
| 232 |
+
"source": "odoo",
|
| 233 |
+
"default": false,
|
| 234 |
+
"description": "Overdue by more than 90 days. The four aging buckets sum to AR overdue $."
|
| 235 |
+
},
|
| 236 |
+
{
|
| 237 |
+
"key": "days_to_pay",
|
| 238 |
+
"label": "Days to pay",
|
| 239 |
+
"type": "int",
|
| 240 |
+
"source": "odoo",
|
| 241 |
+
"default": false,
|
| 242 |
+
"description": "Average days to pay an invoice in full. Blank means no fully paid invoice yet."
|
| 243 |
+
},
|
| 244 |
+
{
|
| 245 |
+
"key": "top_category",
|
| 246 |
+
"label": "Top category",
|
| 247 |
+
"type": "text",
|
| 248 |
+
"source": "odoo",
|
| 249 |
+
"default": false,
|
| 250 |
+
"description": "The category this customer spent the most on in the last 12 months."
|
| 251 |
+
},
|
| 252 |
+
{
|
| 253 |
+
"key": "top_category_pct",
|
| 254 |
+
"label": "Top category %",
|
| 255 |
+
"type": "pct",
|
| 256 |
+
"source": "odoo",
|
| 257 |
+
"default": false,
|
| 258 |
+
"description": "Share of last-12-months spend that went to the top category."
|
| 259 |
+
},
|
| 260 |
+
{
|
| 261 |
+
"key": "sku_count",
|
| 262 |
+
"label": "SKUs bought",
|
| 263 |
+
"type": "int",
|
| 264 |
+
"source": "odoo",
|
| 265 |
+
"default": false,
|
| 266 |
+
"description": "Distinct products bought in the last 12 months."
|
| 267 |
+
},
|
| 268 |
+
{
|
| 269 |
+
"key": "top_sku",
|
| 270 |
+
"label": "Top SKU",
|
| 271 |
+
"type": "text",
|
| 272 |
+
"source": "odoo",
|
| 273 |
+
"default": false,
|
| 274 |
+
"description": "The product this customer spent the most on in the last 12 months."
|
| 275 |
+
},
|
| 276 |
+
{
|
| 277 |
+
"key": "days_since",
|
| 278 |
+
"label": "Days since order",
|
| 279 |
+
"type": "int",
|
| 280 |
+
"source": "odoo",
|
| 281 |
+
"default": false,
|
| 282 |
+
"description": "Days since the last confirmed order."
|
| 283 |
+
},
|
| 284 |
+
{
|
| 285 |
+
"key": "typical_gap_days",
|
| 286 |
+
"label": "Typical gap days",
|
| 287 |
+
"type": "int",
|
| 288 |
+
"source": "odoo",
|
| 289 |
+
"default": false,
|
| 290 |
+
"description": "Days this customer usually goes between orders, from their own history."
|
| 291 |
+
},
|
| 292 |
+
{
|
| 293 |
+
"key": "notes",
|
| 294 |
+
"label": "Notes",
|
| 295 |
+
"type": "text",
|
| 296 |
+
"source": "overlay",
|
| 297 |
+
"default": false,
|
| 298 |
+
"description": "Your notes on this customer. Saved in this app only, visible only to you."
|
| 299 |
+
}
|
| 300 |
+
],
|
| 301 |
+
"_product_comment": "ADDITIVE, wave 15 C-TOPIC. The PRODUCT table's field contract. Kept as a SEPARATE top-level key rather than restructuring `fields` into {customer_data, product_data}: both existing readers (aios_grid._load_fields, aios-web/api/main.py) index doc['fields'] directly, and reshaping that mid-wave would break the embed for a cosmetic gain. The keyed shape can arrive when both readers move in ONE commit; until then this is the product half and `fields` is the customer half.",
|
| 302 |
+
"_product_removed_buy_now": "OWNER, 2026-08-03: 'Buy signal' (key buy_now, a select of Buy now / OK) is NO LONGER A PRESET FIELD. It never earned one: it is a formula over two columns that are both still right here, and the platform has a formula field type for exactly that. THE FORMULA, which reproduces the retired column row for row (modules/product_data.validate proves the equivalence, and goes red if it ever stops holding): IF({lead_days} > 0, IF({dos} < {lead_days}, \"Buy now\", \"OK\"), \"\") . Every branch matches the old server rule, including the blanks - the formula engine refuses a comparison against a blank rather than coercing it to 0, so a SKU with no days-of-supply or no lead time comes out empty, which is 'we do not know' and not 'you are fine'. NOTE the column is still COMPUTED in product_data.pool(): it ships nowhere (rows_from_pool projects strictly through this contract, so no Field means no cell on the wire) and exists only as validate()'s oracle. A formula field is PER-USER, so nothing shared may filter on it - the Buy list view filters on dos/lead_days directly (_seed_wave17).",
|
| 303 |
+
"product_data": {
|
| 304 |
+
"identity": "pid",
|
| 305 |
+
"business_key": "code",
|
| 306 |
+
"fields": [
|
| 307 |
+
{
|
| 308 |
+
"key": "code",
|
| 309 |
+
"label": "SKU",
|
| 310 |
+
"type": "text",
|
| 311 |
+
"source": "odoo",
|
| 312 |
+
"pinned": true,
|
| 313 |
+
"default": true,
|
| 314 |
+
"description": "The SKU code β the product's real business key. `pid` is a stable CRC32 of it because the grid keys on an integer."
|
| 315 |
+
},
|
| 316 |
+
{
|
| 317 |
+
"key": "product",
|
| 318 |
+
"label": "Product",
|
| 319 |
+
"type": "text",
|
| 320 |
+
"source": "odoo",
|
| 321 |
+
"default": true,
|
| 322 |
+
"description": "Product name as it appears in Odoo."
|
| 323 |
+
},
|
| 324 |
+
{
|
| 325 |
+
"key": "category",
|
| 326 |
+
"label": "Category",
|
| 327 |
+
"type": "select",
|
| 328 |
+
"source": "odoo",
|
| 329 |
+
"default": true,
|
| 330 |
+
"description": "Product category; '(uncategorized)' when Odoo carries none."
|
| 331 |
+
},
|
| 332 |
+
{
|
| 333 |
+
"key": "supplier",
|
| 334 |
+
"label": "Supplier",
|
| 335 |
+
"type": "text",
|
| 336 |
+
"source": "overlay",
|
| 337 |
+
"default": true,
|
| 338 |
+
"description": "Who makes it. Editable here and shared with everyone in the workspace; seeded from the inventory mastersheet.",
|
| 339 |
+
"shared": true
|
| 340 |
+
},
|
| 341 |
+
{
|
| 342 |
+
"key": "origin_country",
|
| 343 |
+
"label": "Country",
|
| 344 |
+
"type": "text",
|
| 345 |
+
"source": "overlay",
|
| 346 |
+
"default": false,
|
| 347 |
+
"description": "Country of origin. Editable here and shared with everyone; seeded from the inventory mastersheet.",
|
| 348 |
+
"shared": true
|
| 349 |
+
},
|
| 350 |
+
{
|
| 351 |
+
"key": "lead_days",
|
| 352 |
+
"label": "Lead time (days)",
|
| 353 |
+
"type": "int",
|
| 354 |
+
"source": "overlay",
|
| 355 |
+
"default": true,
|
| 356 |
+
"description": "Order-to-arrival days for this supplier. Drives the buy signal. Editable and shared with everyone.",
|
| 357 |
+
"shared": true
|
| 358 |
+
},
|
| 359 |
+
{
|
| 360 |
+
"key": "first_cost",
|
| 361 |
+
"label": "First cost",
|
| 362 |
+
"type": "currency",
|
| 363 |
+
"source": "overlay",
|
| 364 |
+
"default": false,
|
| 365 |
+
"description": "Quoted unit cost at origin, before freight and duty. Editable and shared with everyone.",
|
| 366 |
+
"shared": true
|
| 367 |
+
},
|
| 368 |
+
{
|
| 369 |
+
"key": "price_fisch",
|
| 370 |
+
"label": "Fisch price",
|
| 371 |
+
"type": "currency",
|
| 372 |
+
"source": "odoo",
|
| 373 |
+
"description": "Fisch pricelist price for this SKU. Blank when that list prices it nowhere."
|
| 374 |
+
},
|
| 375 |
+
{
|
| 376 |
+
"key": "price_royal_1",
|
| 377 |
+
"label": "Royal 1 price",
|
| 378 |
+
"type": "currency",
|
| 379 |
+
"source": "odoo",
|
| 380 |
+
"description": "Royal 1 pricelist price for this SKU. Blank when that list prices it nowhere."
|
| 381 |
+
},
|
| 382 |
+
{
|
| 383 |
+
"key": "price_royal_2",
|
| 384 |
+
"label": "Royal 2 price",
|
| 385 |
+
"type": "currency",
|
| 386 |
+
"source": "odoo",
|
| 387 |
+
"description": "Royal 2 pricelist price for this SKU. Blank when that list prices it nowhere."
|
| 388 |
+
},
|
| 389 |
+
{
|
| 390 |
+
"key": "tier_prices",
|
| 391 |
+
"label": "Tier prices",
|
| 392 |
+
"type": "json",
|
| 393 |
+
"source": "odoo",
|
| 394 |
+
"description": "Every live pricelist that prices this SKU today, as [{pricelist, unit_price}] at qty 1. Blank when no list prices it. This is the honest SET; the three Fisch/Royal columns beside it are the DECLARED subset and cannot show a price on a list the contract does not name."
|
| 395 |
+
},
|
| 396 |
+
{
|
| 397 |
+
"key": "units",
|
| 398 |
+
"label": "Units",
|
| 399 |
+
"type": "json",
|
| 400 |
+
"source": "odoo",
|
| 401 |
+
"description": "The units of measure this SKU is really sold in, as [{name, qty}] where qty is in the product's own unit. Blank means it is sold in ONE unit, not that data is missing - only 1,150 of 5,873 active SKUs (19.6%) carry a unit tier."
|
| 402 |
+
},
|
| 403 |
+
{
|
| 404 |
+
"key": "rev_ytd",
|
| 405 |
+
"label": "Revenue YTD",
|
| 406 |
+
"type": "currency",
|
| 407 |
+
"source": "odoo",
|
| 408 |
+
"default": true,
|
| 409 |
+
"description": "Year-to-date revenue for this SKU, BU-scoped when the caller is."
|
| 410 |
+
},
|
| 411 |
+
{
|
| 412 |
+
"key": "rev_ly",
|
| 413 |
+
"label": "Revenue LY",
|
| 414 |
+
"type": "currency",
|
| 415 |
+
"source": "odoo",
|
| 416 |
+
"description": "Same period last year β seasonal wholesale compares like for like."
|
| 417 |
+
},
|
| 418 |
+
{
|
| 419 |
+
"key": "yoy_pct",
|
| 420 |
+
"label": "YoY %",
|
| 421 |
+
"type": "pct",
|
| 422 |
+
"source": "odoo",
|
| 423 |
+
"description": "Year-over-year change; null when last year was zero (a ratio to zero is not a number)."
|
| 424 |
+
},
|
| 425 |
+
{
|
| 426 |
+
"key": "qty_ytd",
|
| 427 |
+
"label": "Units YTD",
|
| 428 |
+
"type": "int",
|
| 429 |
+
"source": "odoo",
|
| 430 |
+
"description": "Units sold year to date."
|
| 431 |
+
},
|
| 432 |
+
{
|
| 433 |
+
"key": "orders_ytd",
|
| 434 |
+
"label": "Orders YTD",
|
| 435 |
+
"type": "int",
|
| 436 |
+
"source": "odoo",
|
| 437 |
+
"description": "Distinct orders containing this SKU, year to date."
|
| 438 |
+
},
|
| 439 |
+
{
|
| 440 |
+
"key": "on_hand",
|
| 441 |
+
"label": "On hand",
|
| 442 |
+
"type": "int",
|
| 443 |
+
"source": "odoo",
|
| 444 |
+
"description": "Units in stock. CONSOLIDATED β one physical warehouse, not brand-tagged, so this column is ABSENT for a BU-scoped caller rather than silently company-wide."
|
| 445 |
+
},
|
| 446 |
+
{
|
| 447 |
+
"key": "unit_cost",
|
| 448 |
+
"label": "Unit cost",
|
| 449 |
+
"type": "currency",
|
| 450 |
+
"source": "odoo",
|
| 451 |
+
"description": "Inventory unit cost. Consolidated; absent for a BU-scoped caller."
|
| 452 |
+
},
|
| 453 |
+
{
|
| 454 |
+
"key": "inv_value",
|
| 455 |
+
"label": "Stock value",
|
| 456 |
+
"type": "currency",
|
| 457 |
+
"source": "odoo",
|
| 458 |
+
"description": "On-hand value at cost. Consolidated; absent for a BU-scoped caller."
|
| 459 |
+
},
|
| 460 |
+
{
|
| 461 |
+
"key": "qty_ltm",
|
| 462 |
+
"label": "Units LTM",
|
| 463 |
+
"type": "int",
|
| 464 |
+
"source": "odoo",
|
| 465 |
+
"description": "Units sold in the last twelve months. Consolidated; absent for a BU-scoped caller."
|
| 466 |
+
},
|
| 467 |
+
{
|
| 468 |
+
"key": "dos",
|
| 469 |
+
"label": "Days of supply",
|
| 470 |
+
"type": "int",
|
| 471 |
+
"source": "odoo",
|
| 472 |
+
"description": "Days of supply at the LTM rate; null means it never sells through. Consolidated; absent for a BU-scoped caller."
|
| 473 |
+
},
|
| 474 |
+
{
|
| 475 |
+
"key": "cover_gap_d",
|
| 476 |
+
"label": "Cover gap (days)",
|
| 477 |
+
"type": "int",
|
| 478 |
+
"source": "odoo",
|
| 479 |
+
"default": false,
|
| 480 |
+
"description": "Days of supply minus lead time. Negative means it runs out before a reorder lands."
|
| 481 |
+
},
|
| 482 |
+
{
|
| 483 |
+
"key": "stock_bucket",
|
| 484 |
+
"label": "Stock status",
|
| 485 |
+
"type": "select",
|
| 486 |
+
"source": "odoo",
|
| 487 |
+
"description": "Dead / excess / healthy bucket from the inventory module. Consolidated; absent for a BU-scoped caller."
|
| 488 |
+
},
|
| 489 |
+
{
|
| 490 |
+
"key": "needs_pricing",
|
| 491 |
+
"label": "Needs pricing",
|
| 492 |
+
"type": "select",
|
| 493 |
+
"source": "overlay",
|
| 494 |
+
"default": false,
|
| 495 |
+
"options": [
|
| 496 |
+
"Yes"
|
| 497 |
+
],
|
| 498 |
+
"shared": true,
|
| 499 |
+
"description": "Team-maintained. A SKU carries βYesβ when it appears on the NEEDS PRICING tab of the 2027 catalog workbook; no value means it is not on that list. Shared with everyone in the workspace β the whole point of R8 is that the TEAM's work lands in the system, not one importer's private column."
|
| 500 |
+
},
|
| 501 |
+
{
|
| 502 |
+
"key": "march_pricelist",
|
| 503 |
+
"label": "March pricelist",
|
| 504 |
+
"type": "select",
|
| 505 |
+
"source": "overlay",
|
| 506 |
+
"default": false,
|
| 507 |
+
"options": [
|
| 508 |
+
"Yes"
|
| 509 |
+
],
|
| 510 |
+
"shared": true,
|
| 511 |
+
"description": "Team-maintained. A SKU carries βYesβ when it appears on the March Pricelist tab of the 2027 catalog workbook; no value means it is not on that list. Shared with everyone in the workspace β the whole point of R8 is that the TEAM's work lands in the system, not one importer's private column."
|
| 512 |
+
},
|
| 513 |
+
{
|
| 514 |
+
"key": "price_changes",
|
| 515 |
+
"label": "Price changes",
|
| 516 |
+
"type": "select",
|
| 517 |
+
"source": "overlay",
|
| 518 |
+
"default": false,
|
| 519 |
+
"options": [
|
| 520 |
+
"Yes"
|
| 521 |
+
],
|
| 522 |
+
"shared": true,
|
| 523 |
+
"description": "Team-maintained. A SKU carries βYesβ when it appears on the Price Changes tab of the 2027 catalog workbook; no value means it is not on that list. Shared with everyone in the workspace β the whole point of R8 is that the TEAM's work lands in the system, not one importer's private column."
|
| 524 |
+
},
|
| 525 |
+
{
|
| 526 |
+
"key": "closeouts",
|
| 527 |
+
"label": "Closeouts",
|
| 528 |
+
"type": "select",
|
| 529 |
+
"source": "overlay",
|
| 530 |
+
"default": false,
|
| 531 |
+
"options": [
|
| 532 |
+
"Yes"
|
| 533 |
+
],
|
| 534 |
+
"shared": true,
|
| 535 |
+
"description": "Team-maintained. A SKU carries βYesβ when it appears on the Closeouts tab of the 2027 catalog workbook; no value means it is not on that list. Shared with everyone in the workspace β the whole point of R8 is that the TEAM's work lands in the system, not one importer's private column."
|
| 536 |
+
},
|
| 537 |
+
{
|
| 538 |
+
"key": "notes",
|
| 539 |
+
"label": "Notes",
|
| 540 |
+
"type": "text",
|
| 541 |
+
"source": "overlay",
|
| 542 |
+
"default": false,
|
| 543 |
+
"shared": true,
|
| 544 |
+
"description": "Team-maintained. The 2027 workbook's own non-Odoo columns (Product Description, Packing) folded into one field. Shared with everyone in the workspace."
|
| 545 |
+
}
|
| 546 |
+
]
|
| 547 |
+
}
|
| 548 |
+
}
|
platform/core/grid_events.py
CHANGED
|
@@ -801,9 +801,39 @@ def handle_one(event, ctx):
|
|
| 801 |
# else is dropped here. Leaving it in would persist a condition the engine can only
|
| 802 |
# answer with "nothing", so `Cohort is not [a list I deleted]` would show an empty table
|
| 803 |
# forever with nothing on screen explaining why.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 804 |
filters = _ag.clean_filter_tree(cfg.get('filters'),
|
| 805 |
valid_keys | set(measure_keys),
|
| 806 |
-
cohort_ids=set(cohort_ids)
|
|
|
|
| 807 |
sorts = []
|
| 808 |
for rule in list(cfg.get('sorts') or [])[:20]:
|
| 809 |
if (isinstance(rule, dict) and rule.get('colId') in valid_keys
|
|
|
|
| 801 |
# else is dropped here. Leaving it in would persist a condition the engine can only
|
| 802 |
# answer with "nothing", so `Cohort is not [a list I deleted]` would show an empty table
|
| 803 |
# forever with nothing on screen explaining why.
|
| 804 |
+
# ββ W37-T26 / CONTRACT C4 β `visible_view_ids` is the same PERMISSION list one leaf over:
|
| 805 |
+
# a `__view__` membership leaf naming a view this caller cannot see is dropped here, for
|
| 806 |
+
# the identical reason the cohort line above gives. Without it the arm is fail-closed and
|
| 807 |
+
# every view leaf is dropped on save, so the condition would vanish on the next read β
|
| 808 |
+
# D-90's shape exactly.
|
| 809 |
+
# β `ctx.visible_views` is already populated (`routes_grid.py` passes it, `:714` reads it),
|
| 810 |
+
# so this needs no new plumbing; it is the value nobody had handed to the validator.
|
| 811 |
+
# β Edited by lane C, not this file's owner: `platform/core/grid_events.py` is in NO fence
|
| 812 |
+
# and lane C was the last session open. See `mailbox/C.md` NOTE C-24.
|
| 813 |
+
_view_ids = {str(v.get('id')) for v in (visible_views or ())
|
| 814 |
+
if isinstance(v, dict) and v.get('id')}
|
| 815 |
+
# ββ THE CYCLE REFUSAL'S HOST HALF (C4), and it is the ticket's `done-when` rather than a
|
| 816 |
+
# guard bolted on afterwards. View X filtered on "is in View Y" where Y is filtered on X
|
| 817 |
+
# cannot be resolved by anything: X needs Y needs X. The browser refuses it too, with a
|
| 818 |
+
# sentence a person reads; this one is what makes the rule true for a caller that POSTs a
|
| 819 |
+
# view directly, which is every guard-in-the-browser's blind spot.
|
| 820 |
+
# β REFUSED, NOT REPAIRED. Dropping the offending leaf would save a view the person did not
|
| 821 |
+
# write and leave the panel showing a condition the config does not carry.
|
| 822 |
+
_cycle = _ag.view_filter_cycle(
|
| 823 |
+
view_id, cfg.get('filters'),
|
| 824 |
+
lambda vid: next((v.get('config', {}).get('filters')
|
| 825 |
+
for v in (visible_views or ())
|
| 826 |
+
if isinstance(v, dict) and str(v.get('id')) == vid), None))
|
| 827 |
+
if _cycle:
|
| 828 |
+
_refuse(ctx, 'view_cycle',
|
| 829 |
+
'that would make a loop: ' + ' refers to '.join(_cycle) +
|
| 830 |
+
'. A view cannot filter on a view that filters on it, because neither can '
|
| 831 |
+
'be worked out without the other.', view_id)
|
| 832 |
+
return False
|
| 833 |
filters = _ag.clean_filter_tree(cfg.get('filters'),
|
| 834 |
valid_keys | set(measure_keys),
|
| 835 |
+
cohort_ids=set(cohort_ids),
|
| 836 |
+
visible_view_ids=_view_ids)
|
| 837 |
sorts = []
|
| 838 |
for rule in list(cfg.get('sorts') or [])[:20]:
|
| 839 |
if (isinstance(rule, dict) and rule.get('colId') in valid_keys
|
platform/core/user_tables.py
CHANGED
|
@@ -732,6 +732,24 @@ def _ag_formula(raw):
|
|
| 732 |
return _agf._clean_formula(raw)
|
| 733 |
|
| 734 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 735 |
#: Display formats a field may declare. β DISPLAY ONLY β none of these touches the stored value,
|
| 736 |
#: which stays the scalar the fold or the mapper wrote. That separation is the whole safety of the
|
| 737 |
#: feature: a `thousands` setting can never make a number wrong, only easier to read.
|
|
@@ -934,6 +952,16 @@ def clean_fields(raw):
|
|
| 934 |
entry['aiEnrich'] = ae
|
| 935 |
# C3: derived, never typed beside the config. Same call as the other door.
|
| 936 |
entry['automation'] = _field_agent_binding(entry)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 937 |
if entry is None:
|
| 938 |
seen.discard(key)
|
| 939 |
continue
|
|
@@ -2255,6 +2283,24 @@ def _clean_field(raw, previous=None):
|
|
| 2255 |
# a `field_agent` bag that disagrees with the column. Every OTHER automation bag is untouched.
|
| 2256 |
if ftype == 'ai_enrich':
|
| 2257 |
out['automation'] = _field_agent_binding(out)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2258 |
return out
|
| 2259 |
|
| 2260 |
|
|
|
|
| 732 |
return _agf._clean_formula(raw)
|
| 733 |
|
| 734 |
|
| 735 |
+
def _ag_geocode(raw):
|
| 736 |
+
"""`aios_grid._clean_geocode`, reached exactly the way `_ag_formula` reaches its validator.
|
| 737 |
+
|
| 738 |
+
ββ W37-T22 clause 1, landed by /validate-wave 2026-08-19. The geocode PSEUDO-KIND was
|
| 739 |
+
offered in `ColumnMenu` and cleaned in `aios_grid._field_extras`, but THIS module β the
|
| 740 |
+
allowlist every `ut_*` create and patch goes through β named no `geocode` arm, so the bag was
|
| 741 |
+
stripped on every user database and the kind had to be gated OFF there. That gap is what made
|
| 742 |
+
the ticket's *"the kind is offered on ANY database that has a text field"* clause false.
|
| 743 |
+
|
| 744 |
+
β ONE call site for the function-local import, shared by BOTH field doors (`clean_fields` and
|
| 745 |
+
`_clean_field`), for the reason `_ag_formula` states: `core` must not import `aios_grid` at
|
| 746 |
+
module level, and a third and fourth copy of `import aios_grid as _agX` is how one of them
|
| 747 |
+
ends up calling a different validator.
|
| 748 |
+
"""
|
| 749 |
+
import aios_grid as _agg
|
| 750 |
+
return _agg._clean_geocode(raw)
|
| 751 |
+
|
| 752 |
+
|
| 753 |
#: Display formats a field may declare. β DISPLAY ONLY β none of these touches the stored value,
|
| 754 |
#: which stays the scalar the fold or the mapper wrote. That separation is the whole safety of the
|
| 755 |
#: feature: a `thousands` setting can never make a number wrong, only easier to read.
|
|
|
|
| 952 |
entry['aiEnrich'] = ae
|
| 953 |
# C3: derived, never typed beside the config. Same call as the other door.
|
| 954 |
entry['automation'] = _field_agent_binding(entry)
|
| 955 |
+
# ββ W37-T22 (R4 / C7) β THE GEOCODE BAG RIDES THIS DOOR TOO, written in the SAME change
|
| 956 |
+
# as `_clean_field`'s arm rather than a wave later. That is the only difference between
|
| 957 |
+
# this entry and the six above it (`pinned`, `code`, `link`/`rollup`, `formula`,
|
| 958 |
+
# `aiEnrich`), every one of which was a bag this validator dropped until somebody found a
|
| 959 |
+
# column that rendered and computed nothing forever.
|
| 960 |
+
# β NO type/bag pairing: geocode is a pseudo-kind on a `text` column (see `_clean_geocode`).
|
| 961 |
+
if entry is not None:
|
| 962 |
+
geo = (_ag_geocode(f.get('geocode')) if f.get('geocode') is not None else None)
|
| 963 |
+
if geo and ftype == 'text':
|
| 964 |
+
entry['geocode'] = geo
|
| 965 |
if entry is None:
|
| 966 |
seen.discard(key)
|
| 967 |
continue
|
|
|
|
| 2283 |
# a `field_agent` bag that disagrees with the column. Every OTHER automation bag is untouched.
|
| 2284 |
if ftype == 'ai_enrich':
|
| 2285 |
out['automation'] = _field_agent_binding(out)
|
| 2286 |
+
# ββ W37-T22 (R4 / C7) β THE GEOCODE PSEUDO-KIND'S BAG, added 2026-08-19 by /validate-wave.
|
| 2287 |
+
# Posture is `format`'s and NOT `formula`'s, and that is the whole design: geocode has no
|
| 2288 |
+
# FieldType to pair with (the column's `type` is `text`), so an absent bag is an ordinary text
|
| 2289 |
+
# column rather than a refusal. Enforcing a pairing here would refuse every text column in
|
| 2290 |
+
# every user database.
|
| 2291 |
+
# β `'geocode' in raw` rather than `raw.get('geocode')`, exactly like `format` and
|
| 2292 |
+
# `description`: an explicit `{}`/None CLEARS the address column while OMITTING the key keeps
|
| 2293 |
+
# what is stored. A PATCH that sends only a label must not silently unconfigure the column β
|
| 2294 |
+
# that is the same rule `agg` and `optionColors` are written for.
|
| 2295 |
+
# β Inheritance is guarded on `ftype == 'text'` for the reason `prev_type == ftype` guards
|
| 2296 |
+
# `link`/`rollup`/`formula`: retyping a geocode column to a number must not resurrect an
|
| 2297 |
+
# address binding through `prev` that nothing will ever read again.
|
| 2298 |
+
geocode_raw = (raw.get('geocode') if 'geocode' in raw
|
| 2299 |
+
else (prev.get('geocode') if ftype == 'text' else None))
|
| 2300 |
+
if geocode_raw is not None:
|
| 2301 |
+
geo = _ag_geocode(geocode_raw)
|
| 2302 |
+
if geo and ftype == 'text':
|
| 2303 |
+
out['geocode'] = geo
|
| 2304 |
return out
|
| 2305 |
|
| 2306 |
|
platform/core/users.py
CHANGED
|
@@ -1,525 +1,532 @@
|
|
| 1 |
-
"""Per-user accounts for the platform, persisted in the HF Dataset store (users.json).
|
| 2 |
-
|
| 3 |
-
Passwords are salted + PBKDF2-HMAC-SHA256 (200k iterations) β never stored or logged in plaintext.
|
| 4 |
-
A bootstrap 'admin' account is seeded from APP_PASSWORD so the owner can always log in and create
|
| 5 |
-
users; APP_PASSWORD also works as an emergency master for 'admin' if the registry is unreachable.
|
| 6 |
-
|
| 7 |
-
Each account carries BU access ('all' or a list of team-ids [5=Fisch, 6=Royal]) which drives
|
| 8 |
-
allowed_bus() β the basis for per-Business-Unit permissioning (a Royal-only user never sees Fisch).
|
| 9 |
-
"""
|
| 10 |
-
import os
|
| 11 |
-
import hmac
|
| 12 |
-
import hashlib
|
| 13 |
-
import secrets
|
| 14 |
-
|
| 15 |
-
import core.store as store
|
| 16 |
-
|
| 17 |
-
BU_LABELS = {5: 'Fisch', 6: 'Royal'}
|
| 18 |
-
_ITER = 200_000
|
| 19 |
-
|
| 20 |
-
#: Wave 15 C-PERM β the explicit-resolution marker. Mirrors `core.perm_scope.PERMS_VERSION`;
|
| 21 |
-
#: kept as a literal here so `users` does not import the permission layer it is read by.
|
| 22 |
-
PERMS_VERSION = 1
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
def _hash(pw, salt):
|
| 26 |
-
return hashlib.pbkdf2_hmac('sha256', str(pw).encode('utf-8'), bytes.fromhex(salt), _ITER).hex()
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
def _record(pw, name, role, bus, active=True, modules='all', agent=None, email=None,
|
| 30 |
-
perms=None, tenant='royal-imports', platform_admin=False):
|
| 31 |
-
salt = secrets.token_hex(16)
|
| 32 |
-
rec = {'salt': salt, 'hash': _hash(pw, salt), 'name': name, 'role': role,
|
| 33 |
-
'bus': bus, 'active': active, 'modules': modules,
|
| 34 |
-
'agent': agent or None, 'email': email or None,
|
| 35 |
-
# Wave 18 (C1-TENANT, R1): the account's COMPANY. Absent == 'royal-imports' on every
|
| 36 |
-
# pre-wave record β no migration. Login binds the session to THIS value; the posted
|
| 37 |
-
# tenant field can hint but never override it.
|
| 38 |
-
'tenant': str(tenant or 'royal-imports').strip().lower()}
|
| 39 |
-
if platform_admin is True:
|
| 40 |
-
# Wave 19 (R3): the PLATFORM-operator flag β half of `core.platform_admin`'s double lock
|
| 41 |
-
# (the other half is `tenant == 'loopable'`). Written ONLY for True, so every record that
|
| 42 |
-
# is not deliberately promoted keeps its pre-wave shape and answers False by absence.
|
| 43 |
-
# There is no UI writer and there never should be: it is set by provisioning, on purpose.
|
| 44 |
-
rec['platform_admin'] = True
|
| 45 |
-
if perms is not None:
|
| 46 |
-
# Wave 15 C-PERM. A record written WITH perms is migrated by construction β the marker
|
| 47 |
-
# and the block are set together, here, so no writer can create one without the other.
|
| 48 |
-
# (`core.perm_scope` reads an unmarked record as legacy, so a block without its marker
|
| 49 |
-
# would be silently ignored; a marker without a block would deny everything.)
|
| 50 |
-
rec['perms'] = perms
|
| 51 |
-
rec['perms_v'] = PERMS_VERSION
|
| 52 |
-
return rec
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
def registry():
|
| 56 |
-
return store.get('users')
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
def ensure_bootstrap():
|
| 60 |
-
"""Seed an 'admin' account from APP_PASSWORD ONLY on a truly fresh store (no users file yet).
|
| 61 |
-
Idempotent; no-op if the store is unavailable (the app then falls back to the master-password
|
| 62 |
-
path in verify()).
|
| 63 |
-
|
| 64 |
-
Critically, this NEVER overwrites an existing registry: it seeds only when store.exists('users')
|
| 65 |
-
is definitively False. A transient read failure at startup used to return {} and make this
|
| 66 |
-
re-seed just {admin} over the real accounts β that is the bug that wiped users on restart."""
|
| 67 |
-
if not store.available():
|
| 68 |
-
return
|
| 69 |
-
if store.exists('users'): # present, or uncertain -> never clobber
|
| 70 |
-
return
|
| 71 |
-
try:
|
| 72 |
-
reg = store.get('users', fresh=True)
|
| 73 |
-
except Exception:
|
| 74 |
-
return
|
| 75 |
-
if reg:
|
| 76 |
-
return
|
| 77 |
-
master = os.environ.get('APP_PASSWORD', '')
|
| 78 |
-
if not master:
|
| 79 |
-
return
|
| 80 |
-
try:
|
| 81 |
-
store.put('users', {'admin': _record(master, 'Administrator', 'admin', 'all')})
|
| 82 |
-
except Exception:
|
| 83 |
-
pass
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
def _public(username, u):
|
| 87 |
-
return {'username': username, 'name': u.get('name', username),
|
| 88 |
-
'role': u.get('role', 'user'), 'bus': u.get('bus', 'all'),
|
| 89 |
-
'modules': u.get('modules', 'all'),
|
| 90 |
-
'agent': u.get('agent'), 'email': u.get('email'),
|
| 91 |
-
# Wave 18 (C1-TENANT): the session's tenant binding travels on the projection or it
|
| 92 |
-
# does not travel β the same rule the perms block states below.
|
| 93 |
-
'tenant': str(u.get('tenant') or 'royal-imports').strip().lower(),
|
| 94 |
-
# Wave 14 C-AVATAR: the profile photo is public-safe by definition (it is served
|
| 95 |
-
# to every grid session via the workspace map); without it here the API session's
|
| 96 |
-
# user record silently drops it and /me can never show your own photo.
|
| 97 |
-
'avatar': u.get('avatar') or None,
|
| 98 |
-
# β WAVE 15 C-PERM β THE WALL TRAVELS ON THIS PROJECTION OR IT DOES NOT TRAVEL.
|
| 99 |
-
# `deps._user_for` builds every API session from `_public()`, so a `perms` block
|
| 100 |
-
# dropped here is a restricted account served as an unrestricted one β silently, on
|
| 101 |
-
# every route, with nothing to notice. `perms_v` must ride ALONG WITH it and for the
|
| 102 |
-
# same reason inverted: the marker without the block denies everything, the block
|
| 103 |
-
# without the marker is ignored. Two keys, one fact, never separated.
|
| 104 |
-
# `verify_api` asserts a restricted user's SESSION OBJECT carries both, at the mount
|
| 105 |
-
# rather than by grep β a projection is exactly the kind of wiring that looks
|
| 106 |
-
# present in three files and is absent in the one that runs.
|
| 107 |
-
**({'perms': u['perms']} if isinstance(u.get('perms'), dict) else {}),
|
| 108 |
-
**({'perms_v': int(u['perms_v'] or 0)} if u.get('perms_v') else {}),
|
| 109 |
-
# β WAVE 19 R3 β THE SAME RULE, ON A NEW FIELD. `deps._user_for` builds every API
|
| 110 |
-
# session from this projection, so the platform-admin flag travels here or
|
| 111 |
-
# `core.platform_admin.is_platform_admin(session.user)` is blind and the Loopable
|
| 112 |
-
# admin plane 403s its own operator. Carried ONLY when the record says True, so a
|
| 113 |
-
# session dict for any other account is byte-identical to its pre-wave shape.
|
| 114 |
-
# Not a client leak: `routes_auth._public_user` is a whitelist projection and does
|
| 115 |
-
# not name this key, so it reaches no browser via /login or /me β the client's copy
|
| 116 |
-
# is the separate `platformAdmin` bool on GET /settings, which is derived from this.
|
| 117 |
-
**({'platform_admin': True} if u.get('platform_admin') is True else {}),
|
| 118 |
-
'epoch': int(u.get('epoch') or 0)}
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
# ------------------------------------------------------------------ session revocation (X3)
|
| 122 |
-
# The API's session cookie is SIGNED AND STATELESS: there is no server-side session table to
|
| 123 |
-
# delete from, so "log this user out everywhere" needs a number that lives with the account. The
|
| 124 |
-
# cookie carries the epoch it was minted under; bumping the account's epoch makes every
|
| 125 |
-
# outstanding cookie for that user fail verification on its next use. Absent == 0, so every
|
| 126 |
-
# record written before this wave is valid without a migration.
|
| 127 |
-
def epoch(username):
|
| 128 |
-
"""The current session epoch for `username`. None when there is no such account.
|
| 129 |
-
|
| 130 |
-
None is NOT 0. 0 is "this account exists and has never been revoked"; None is "no record" β
|
| 131 |
-
which the session verifier must treat as a reason to refuse, not as a default to compare
|
| 132 |
-
against. (The APP_PASSWORD emergency-master admin has no record at all; the verifier handles
|
| 133 |
-
that case explicitly rather than inventing an epoch for it here.)
|
| 134 |
-
"""
|
| 135 |
-
username = (username or '').strip().lower()
|
| 136 |
-
try:
|
| 137 |
-
u = (store.get('users') or {}).get(username)
|
| 138 |
-
except Exception:
|
| 139 |
-
return None
|
| 140 |
-
return int((u or {}).get('epoch') or 0) if u else None
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
def bump_epoch(username):
|
| 144 |
-
"""Revoke every outstanding API session for this account."""
|
| 145 |
-
username = (username or '').strip().lower()
|
| 146 |
-
|
| 147 |
-
def _set(reg):
|
| 148 |
-
u = reg.get(username)
|
| 149 |
-
if u:
|
| 150 |
-
u['epoch'] = int(u.get('epoch') or 0) + 1
|
| 151 |
-
return reg
|
| 152 |
-
store.update('users', _set)
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
def verify(username, pw):
|
| 156 |
-
"""Return a public user dict on success, else None. APP_PASSWORD is an emergency master for the
|
| 157 |
-
'admin' login even if the store is unreachable, so the owner is never locked out."""
|
| 158 |
-
username = (username or '').strip().lower()
|
| 159 |
-
if not username or not pw:
|
| 160 |
-
return None
|
| 161 |
-
master = os.environ.get('APP_PASSWORD', '')
|
| 162 |
-
try:
|
| 163 |
-
# read fresh so accounts created moments ago (UI or out-of-band) are recognised at once
|
| 164 |
-
reg = store.get('users', fresh=True)
|
| 165 |
-
except Exception:
|
| 166 |
-
reg = {}
|
| 167 |
-
u = reg.get(username)
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
#
|
| 188 |
-
#
|
| 189 |
-
#
|
| 190 |
-
#
|
| 191 |
-
#
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
#
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
#
|
| 300 |
-
#
|
| 301 |
-
#
|
| 302 |
-
#
|
| 303 |
-
#
|
| 304 |
-
#
|
| 305 |
-
#
|
| 306 |
-
#
|
| 307 |
-
#
|
| 308 |
-
#
|
| 309 |
-
#
|
| 310 |
-
#
|
| 311 |
-
#
|
| 312 |
-
#
|
| 313 |
-
#
|
| 314 |
-
#
|
| 315 |
-
#
|
| 316 |
-
#
|
| 317 |
-
#
|
| 318 |
-
#
|
| 319 |
-
#
|
| 320 |
-
#
|
| 321 |
-
#
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
#:
|
| 328 |
-
#
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
"""
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
u['
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
`
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
"""
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Per-user accounts for the platform, persisted in the HF Dataset store (users.json).
|
| 2 |
+
|
| 3 |
+
Passwords are salted + PBKDF2-HMAC-SHA256 (200k iterations) β never stored or logged in plaintext.
|
| 4 |
+
A bootstrap 'admin' account is seeded from APP_PASSWORD so the owner can always log in and create
|
| 5 |
+
users; APP_PASSWORD also works as an emergency master for 'admin' if the registry is unreachable.
|
| 6 |
+
|
| 7 |
+
Each account carries BU access ('all' or a list of team-ids [5=Fisch, 6=Royal]) which drives
|
| 8 |
+
allowed_bus() β the basis for per-Business-Unit permissioning (a Royal-only user never sees Fisch).
|
| 9 |
+
"""
|
| 10 |
+
import os
|
| 11 |
+
import hmac
|
| 12 |
+
import hashlib
|
| 13 |
+
import secrets
|
| 14 |
+
|
| 15 |
+
import core.store as store
|
| 16 |
+
|
| 17 |
+
BU_LABELS = {5: 'Fisch', 6: 'Royal'}
|
| 18 |
+
_ITER = 200_000
|
| 19 |
+
|
| 20 |
+
#: Wave 15 C-PERM β the explicit-resolution marker. Mirrors `core.perm_scope.PERMS_VERSION`;
|
| 21 |
+
#: kept as a literal here so `users` does not import the permission layer it is read by.
|
| 22 |
+
PERMS_VERSION = 1
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _hash(pw, salt):
|
| 26 |
+
return hashlib.pbkdf2_hmac('sha256', str(pw).encode('utf-8'), bytes.fromhex(salt), _ITER).hex()
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _record(pw, name, role, bus, active=True, modules='all', agent=None, email=None,
|
| 30 |
+
perms=None, tenant='royal-imports', platform_admin=False):
|
| 31 |
+
salt = secrets.token_hex(16)
|
| 32 |
+
rec = {'salt': salt, 'hash': _hash(pw, salt), 'name': name, 'role': role,
|
| 33 |
+
'bus': bus, 'active': active, 'modules': modules,
|
| 34 |
+
'agent': agent or None, 'email': email or None,
|
| 35 |
+
# Wave 18 (C1-TENANT, R1): the account's COMPANY. Absent == 'royal-imports' on every
|
| 36 |
+
# pre-wave record β no migration. Login binds the session to THIS value; the posted
|
| 37 |
+
# tenant field can hint but never override it.
|
| 38 |
+
'tenant': str(tenant or 'royal-imports').strip().lower()}
|
| 39 |
+
if platform_admin is True:
|
| 40 |
+
# Wave 19 (R3): the PLATFORM-operator flag β half of `core.platform_admin`'s double lock
|
| 41 |
+
# (the other half is `tenant == 'loopable'`). Written ONLY for True, so every record that
|
| 42 |
+
# is not deliberately promoted keeps its pre-wave shape and answers False by absence.
|
| 43 |
+
# There is no UI writer and there never should be: it is set by provisioning, on purpose.
|
| 44 |
+
rec['platform_admin'] = True
|
| 45 |
+
if perms is not None:
|
| 46 |
+
# Wave 15 C-PERM. A record written WITH perms is migrated by construction β the marker
|
| 47 |
+
# and the block are set together, here, so no writer can create one without the other.
|
| 48 |
+
# (`core.perm_scope` reads an unmarked record as legacy, so a block without its marker
|
| 49 |
+
# would be silently ignored; a marker without a block would deny everything.)
|
| 50 |
+
rec['perms'] = perms
|
| 51 |
+
rec['perms_v'] = PERMS_VERSION
|
| 52 |
+
return rec
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def registry():
|
| 56 |
+
return store.get('users')
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def ensure_bootstrap():
|
| 60 |
+
"""Seed an 'admin' account from APP_PASSWORD ONLY on a truly fresh store (no users file yet).
|
| 61 |
+
Idempotent; no-op if the store is unavailable (the app then falls back to the master-password
|
| 62 |
+
path in verify()).
|
| 63 |
+
|
| 64 |
+
Critically, this NEVER overwrites an existing registry: it seeds only when store.exists('users')
|
| 65 |
+
is definitively False. A transient read failure at startup used to return {} and make this
|
| 66 |
+
re-seed just {admin} over the real accounts β that is the bug that wiped users on restart."""
|
| 67 |
+
if not store.available():
|
| 68 |
+
return
|
| 69 |
+
if store.exists('users'): # present, or uncertain -> never clobber
|
| 70 |
+
return
|
| 71 |
+
try:
|
| 72 |
+
reg = store.get('users', fresh=True)
|
| 73 |
+
except Exception:
|
| 74 |
+
return
|
| 75 |
+
if reg:
|
| 76 |
+
return
|
| 77 |
+
master = os.environ.get('APP_PASSWORD', '')
|
| 78 |
+
if not master:
|
| 79 |
+
return
|
| 80 |
+
try:
|
| 81 |
+
store.put('users', {'admin': _record(master, 'Administrator', 'admin', 'all')})
|
| 82 |
+
except Exception:
|
| 83 |
+
pass
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def _public(username, u):
|
| 87 |
+
return {'username': username, 'name': u.get('name', username),
|
| 88 |
+
'role': u.get('role', 'user'), 'bus': u.get('bus', 'all'),
|
| 89 |
+
'modules': u.get('modules', 'all'),
|
| 90 |
+
'agent': u.get('agent'), 'email': u.get('email'),
|
| 91 |
+
# Wave 18 (C1-TENANT): the session's tenant binding travels on the projection or it
|
| 92 |
+
# does not travel β the same rule the perms block states below.
|
| 93 |
+
'tenant': str(u.get('tenant') or 'royal-imports').strip().lower(),
|
| 94 |
+
# Wave 14 C-AVATAR: the profile photo is public-safe by definition (it is served
|
| 95 |
+
# to every grid session via the workspace map); without it here the API session's
|
| 96 |
+
# user record silently drops it and /me can never show your own photo.
|
| 97 |
+
'avatar': u.get('avatar') or None,
|
| 98 |
+
# β WAVE 15 C-PERM β THE WALL TRAVELS ON THIS PROJECTION OR IT DOES NOT TRAVEL.
|
| 99 |
+
# `deps._user_for` builds every API session from `_public()`, so a `perms` block
|
| 100 |
+
# dropped here is a restricted account served as an unrestricted one β silently, on
|
| 101 |
+
# every route, with nothing to notice. `perms_v` must ride ALONG WITH it and for the
|
| 102 |
+
# same reason inverted: the marker without the block denies everything, the block
|
| 103 |
+
# without the marker is ignored. Two keys, one fact, never separated.
|
| 104 |
+
# `verify_api` asserts a restricted user's SESSION OBJECT carries both, at the mount
|
| 105 |
+
# rather than by grep β a projection is exactly the kind of wiring that looks
|
| 106 |
+
# present in three files and is absent in the one that runs.
|
| 107 |
+
**({'perms': u['perms']} if isinstance(u.get('perms'), dict) else {}),
|
| 108 |
+
**({'perms_v': int(u['perms_v'] or 0)} if u.get('perms_v') else {}),
|
| 109 |
+
# β WAVE 19 R3 β THE SAME RULE, ON A NEW FIELD. `deps._user_for` builds every API
|
| 110 |
+
# session from this projection, so the platform-admin flag travels here or
|
| 111 |
+
# `core.platform_admin.is_platform_admin(session.user)` is blind and the Loopable
|
| 112 |
+
# admin plane 403s its own operator. Carried ONLY when the record says True, so a
|
| 113 |
+
# session dict for any other account is byte-identical to its pre-wave shape.
|
| 114 |
+
# Not a client leak: `routes_auth._public_user` is a whitelist projection and does
|
| 115 |
+
# not name this key, so it reaches no browser via /login or /me β the client's copy
|
| 116 |
+
# is the separate `platformAdmin` bool on GET /settings, which is derived from this.
|
| 117 |
+
**({'platform_admin': True} if u.get('platform_admin') is True else {}),
|
| 118 |
+
'epoch': int(u.get('epoch') or 0)}
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
# ------------------------------------------------------------------ session revocation (X3)
|
| 122 |
+
# The API's session cookie is SIGNED AND STATELESS: there is no server-side session table to
|
| 123 |
+
# delete from, so "log this user out everywhere" needs a number that lives with the account. The
|
| 124 |
+
# cookie carries the epoch it was minted under; bumping the account's epoch makes every
|
| 125 |
+
# outstanding cookie for that user fail verification on its next use. Absent == 0, so every
|
| 126 |
+
# record written before this wave is valid without a migration.
|
| 127 |
+
def epoch(username):
|
| 128 |
+
"""The current session epoch for `username`. None when there is no such account.
|
| 129 |
+
|
| 130 |
+
None is NOT 0. 0 is "this account exists and has never been revoked"; None is "no record" β
|
| 131 |
+
which the session verifier must treat as a reason to refuse, not as a default to compare
|
| 132 |
+
against. (The APP_PASSWORD emergency-master admin has no record at all; the verifier handles
|
| 133 |
+
that case explicitly rather than inventing an epoch for it here.)
|
| 134 |
+
"""
|
| 135 |
+
username = (username or '').strip().lower()
|
| 136 |
+
try:
|
| 137 |
+
u = (store.get('users') or {}).get(username)
|
| 138 |
+
except Exception:
|
| 139 |
+
return None
|
| 140 |
+
return int((u or {}).get('epoch') or 0) if u else None
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def bump_epoch(username):
|
| 144 |
+
"""Revoke every outstanding API session for this account."""
|
| 145 |
+
username = (username or '').strip().lower()
|
| 146 |
+
|
| 147 |
+
def _set(reg):
|
| 148 |
+
u = reg.get(username)
|
| 149 |
+
if u:
|
| 150 |
+
u['epoch'] = int(u.get('epoch') or 0) + 1
|
| 151 |
+
return reg
|
| 152 |
+
store.update('users', _set)
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def verify(username, pw):
|
| 156 |
+
"""Return a public user dict on success, else None. APP_PASSWORD is an emergency master for the
|
| 157 |
+
'admin' login even if the store is unreachable, so the owner is never locked out."""
|
| 158 |
+
username = (username or '').strip().lower()
|
| 159 |
+
if not username or not pw:
|
| 160 |
+
return None
|
| 161 |
+
master = os.environ.get('APP_PASSWORD', '')
|
| 162 |
+
try:
|
| 163 |
+
# read fresh so accounts created moments ago (UI or out-of-band) are recognised at once
|
| 164 |
+
reg = store.get('users', fresh=True)
|
| 165 |
+
except Exception:
|
| 166 |
+
reg = {}
|
| 167 |
+
u = reg.get(username)
|
| 168 |
+
# β KEY FIRST, EMAIL SECOND, AND SINCE WAVE 37 THAT ORDER IS A DECISION RATHER THAN AN ACCIDENT.
|
| 169 |
+
# R8 lets a username BE an email address, so a typed string can now match a registry KEY and a
|
| 170 |
+
# different account's `email` field at the same time. The key wins, here, by construction: this
|
| 171 |
+
# branch is only reached when `reg.get(username)` missed. `routes_admin.py::create_user` refuses
|
| 172 |
+
# to CREATE either collision (`username_shadows_email` / `email_shadows_username`) so the
|
| 173 |
+
# ambiguity cannot be introduced through the product; this line is what decides it for any
|
| 174 |
+
# record that arrived some other way.
|
| 175 |
+
if u is None and '@' in username:
|
| 176 |
+
# Wave 18 (R1): the login box takes a username OR an email β admin@nurilab.id signs in
|
| 177 |
+
# without knowing the slug an admin chose. First case-insensitive email match wins;
|
| 178 |
+
# ambiguity is an admin data problem, not a login feature.
|
| 179 |
+
for k, r in reg.items():
|
| 180 |
+
if isinstance(r, dict) and str(r.get('email') or '').strip().lower() == username:
|
| 181 |
+
username, u = k, r
|
| 182 |
+
break
|
| 183 |
+
if u and u.get('active', True) and hmac.compare_digest(_hash(pw, u['salt']), u['hash']):
|
| 184 |
+
return _public(username, u)
|
| 185 |
+
# emergency master: admin + APP_PASSWORD always works (covers first run / store outage)
|
| 186 |
+
if username == 'admin' and master and hmac.compare_digest(str(pw), master):
|
| 187 |
+
# β CARRY THE RECORD'S CURRENT EPOCH when there is a record to read, so the session cookie
|
| 188 |
+
# the API mints from this dict AGREES with the stored account.
|
| 189 |
+
#
|
| 190 |
+
# This is not what stops the emergency lockout β `deps._user_for`'s master fallback does
|
| 191 |
+
# that, and a negative control confirmed the lockout is gone with or without this line.
|
| 192 |
+
# What it fixes is subtler and is a SCOPE question: a cookie whose epoch disagrees with the
|
| 193 |
+
# record falls through to that master fallback, which hands back a SYNTHETIC identity
|
| 194 |
+
# (`bus: 'all'`, `modules: 'all'`). An admin whose record narrows either field would
|
| 195 |
+
# therefore be silently WIDENED to consolidated, all-module access for the life of that
|
| 196 |
+
# session. Matching the epoch means the record branch wins and the account's real scope
|
| 197 |
+
# applies, leaving the master fallback as the true last resort it is meant to be.
|
| 198 |
+
# 0 when the store is unreachable, which is the case this branch was written for.
|
| 199 |
+
return {'username': 'admin', 'name': 'Administrator', 'role': 'admin', 'bus': 'all',
|
| 200 |
+
'modules': 'all', 'epoch': int((reg.get('admin') or {}).get('epoch') or 0)}
|
| 201 |
+
return None
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def create_user(username, pw, name, role='user', bus='all', modules='all',
|
| 205 |
+
agent=None, email=None, tenant=None, platform_admin=None):
|
| 206 |
+
"""Create β or, from app.py's dialog, OVERWRITE β an account.
|
| 207 |
+
|
| 208 |
+
β X3: OVERWRITING AN ACCOUNT MUST NOT RESURRECT ITS OLD SESSIONS. `_record()` builds a fresh
|
| 209 |
+
record with no `epoch` key, i.e. absent == 0. So re-saving an existing username used to reset
|
| 210 |
+
the epoch to 0, and every cookie minted before that account's last password rotation started
|
| 211 |
+
verifying again β a silent un-revocation. `app.py`'s "Add / update a user" calls this function
|
| 212 |
+
for BOTH add and update, so the hole was reachable from the shipped UI.
|
| 213 |
+
|
| 214 |
+
Epoch revocation is only ever as strong as the NARROWEST write path that touches the record, so
|
| 215 |
+
the carry-and-bump lives here rather than in each caller: an overwrite is at least as
|
| 216 |
+
session-invalidating as a password change, and it usually IS one.
|
| 217 |
+
"""
|
| 218 |
+
username = (username or '').strip().lower()
|
| 219 |
+
if not username or not pw:
|
| 220 |
+
raise ValueError('username and password are required')
|
| 221 |
+
|
| 222 |
+
def _add(reg):
|
| 223 |
+
prior = reg.get(username) or {}
|
| 224 |
+
rec = _record(pw, name or username, role, bus, modules=modules,
|
| 225 |
+
agent=agent, email=email,
|
| 226 |
+
# An overwrite that names no tenant KEEPS the account's company β a
|
| 227 |
+
# rename must never quietly move a user between tenants.
|
| 228 |
+
tenant=(tenant or prior.get('tenant') or 'royal-imports'),
|
| 229 |
+
# Wave 19 (R3): CARRIED, for the same reason `epoch` is carried below β
|
| 230 |
+
# `_record` builds a FRESH record, so re-running the provisioner (it is
|
| 231 |
+
# documented as idempotent) or saving an account through this function
|
| 232 |
+
# would silently DEMOTE a platform admin and lock the operator out of
|
| 233 |
+
# their own plane. None = leave as it was; True/False = set it deliberately.
|
| 234 |
+
platform_admin=(prior.get('platform_admin') is True
|
| 235 |
+
if platform_admin is None else platform_admin is True))
|
| 236 |
+
if prior:
|
| 237 |
+
rec['epoch'] = int(prior.get('epoch') or 0) + 1
|
| 238 |
+
reg[username] = rec
|
| 239 |
+
return reg
|
| 240 |
+
store.update('users', _add)
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
def set_password(username, pw):
|
| 244 |
+
username = (username or '').strip().lower()
|
| 245 |
+
|
| 246 |
+
def _set(reg):
|
| 247 |
+
u = reg.get(username)
|
| 248 |
+
if u:
|
| 249 |
+
u['salt'] = secrets.token_hex(16)
|
| 250 |
+
u['hash'] = _hash(pw, u['salt'])
|
| 251 |
+
# X3: a password change revokes every outstanding API session for the account. Bumped
|
| 252 |
+
# INSIDE the same read-modify-write as the hash so the two can never disagree β a
|
| 253 |
+
# separate update() could rotate the password and leave old cookies live if the second
|
| 254 |
+
# write failed.
|
| 255 |
+
u['epoch'] = int(u.get('epoch') or 0) + 1
|
| 256 |
+
return reg
|
| 257 |
+
store.update('users', _set)
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
def set_active(username, active):
|
| 261 |
+
username = (username or '').strip().lower()
|
| 262 |
+
|
| 263 |
+
def _set(reg):
|
| 264 |
+
if username in reg:
|
| 265 |
+
reg[username]['active'] = bool(active)
|
| 266 |
+
# X3: DEACTIVATION must kill live sessions, not just future logins β otherwise a
|
| 267 |
+
# disabled account keeps working until its cookie expires. Bumped on reactivation too:
|
| 268 |
+
# cheap, and it means a re-enabled account never resurrects a stale cookie.
|
| 269 |
+
reg[username]['epoch'] = int(reg[username].get('epoch') or 0) + 1
|
| 270 |
+
return reg
|
| 271 |
+
store.update('users', _set)
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
def set_platform_admin(username, on):
|
| 275 |
+
"""Promote/demote a PLATFORM administrator (wave 19, R3) without touching the password.
|
| 276 |
+
|
| 277 |
+
The narrow write, deliberately: `create_user` is the destructive path (fresh salt, fresh
|
| 278 |
+
hash, bumped epoch) and promoting somebody should not sign them out or rotate a credential.
|
| 279 |
+
Cleared by REMOVING the key, so a demoted record goes back to its pre-wave shape rather than
|
| 280 |
+
carrying a `False` that reads as "somebody considered this".
|
| 281 |
+
|
| 282 |
+
β This is the flag only. It grants nothing on its own β `core.platform_admin` also demands
|
| 283 |
+
the `loopable` tenant, and there is no code path anywhere that moves an account between
|
| 284 |
+
tenants, which is what makes the second lock hold.
|
| 285 |
+
"""
|
| 286 |
+
username = (username or '').strip().lower()
|
| 287 |
+
|
| 288 |
+
def _set(reg):
|
| 289 |
+
u = reg.get(username)
|
| 290 |
+
if u:
|
| 291 |
+
if on is True:
|
| 292 |
+
u['platform_admin'] = True
|
| 293 |
+
else:
|
| 294 |
+
u.pop('platform_admin', None)
|
| 295 |
+
return reg
|
| 296 |
+
store.update('users', _set)
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
# ------------------------------------------------------------------ activity stamps (wave 19 R4)
|
| 300 |
+
# "When did this account last sign in, and is anyone actually using it?" β the two questions the
|
| 301 |
+
# Loopable admin plane exists to answer and that NOTHING in the product could answer before this
|
| 302 |
+
# wave (there is no login history, no audit log, no request log anywhere).
|
| 303 |
+
#
|
| 304 |
+
# β THIS IS THE FIRST HIGH-FREQUENCY WRITER `users.json` HAS EVER HAD, and that bucket also holds
|
| 305 |
+
# every password hash, `active`, `epoch` and the permission blocks. Three rules follow, and the
|
| 306 |
+
# second one is a correctness rule, not a performance one:
|
| 307 |
+
#
|
| 308 |
+
# 1. **In-place mutation of ONE key.** Never `_record()`, never a whole-record replace: a stamp
|
| 309 |
+
# that rebuilt the record would reset the salt/hash (locking the user out) or the epoch
|
| 310 |
+
# (silently un-revoking every cookie ever minted for them). `set_password`'s docstring
|
| 311 |
+
# explains why that class of bug is worth naming out loud.
|
| 312 |
+
#
|
| 313 |
+
# 2. **SYNCHRONOUS FLUSH β deliberately NOT the async path, and this reverses my first draft.**
|
| 314 |
+
# `store.update(flush='async')` rebases on the PROCESS CACHE once a key is `_owned`
|
| 315 |
+
# (`core/store.py:284`) and its worker uploads that whole cached blob. For a
|
| 316 |
+
# table-workspace key, written by one process, that is exactly right. For `users` it is a
|
| 317 |
+
# silent-revert machine: tenant #0's Streamlit host writes the SAME file, so an API process
|
| 318 |
+
# holding a cache from an hour ago would, on its next stamp, upload a blob in which a
|
| 319 |
+
# password rotation or a deactivation performed in the other host simply never happened.
|
| 320 |
+
# A telemetry stamp must not be able to resurrect a disabled account. `flush='sync'` does a
|
| 321 |
+
# FRESH strict read inside the store's lock and then uploads, which is the same discipline
|
| 322 |
+
# every other `users` writer already uses.
|
| 323 |
+
#
|
| 324 |
+
# 3. **OFF THE REQUEST THREAD, so rule 2 costs nothing.** A sync commit is a hub round-trip, and
|
| 325 |
+
# neither a sign-in nor a random request an hour later should wait for it. Each stamp runs on
|
| 326 |
+
# a short-lived daemon thread; `flush_stamps()` is how a test or a shutdown waits for them.
|
| 327 |
+
# Everything is fail-silent: a stamp is telemetry and may never turn a good login into a
|
| 328 |
+
# failed one β the plane shows an honest "never" instead.
|
| 329 |
+
def _now_iso():
|
| 330 |
+
import datetime as _dt
|
| 331 |
+
return _dt.datetime.now(_dt.timezone.utc).isoformat(timespec='seconds')
|
| 332 |
+
|
| 333 |
+
|
| 334 |
+
#: Live stamp threads, so `flush_stamps()` can join them. Bounded by construction β one thread per
|
| 335 |
+
#: stamp, and a stamp is at most one per login plus one per account per hour per process.
|
| 336 |
+
_STAMPS = []
|
| 337 |
+
_STAMPS_LOCK = __import__('threading').Lock()
|
| 338 |
+
|
| 339 |
+
|
| 340 |
+
def _stamp(username, fields):
|
| 341 |
+
"""Merge `fields` into ONE account record, on a background thread, with a fresh read."""
|
| 342 |
+
username = (username or '').strip().lower()
|
| 343 |
+
if not username or not fields:
|
| 344 |
+
return None
|
| 345 |
+
|
| 346 |
+
def _set(reg):
|
| 347 |
+
u = reg.get(username)
|
| 348 |
+
if isinstance(u, dict):
|
| 349 |
+
u.update(fields)
|
| 350 |
+
return reg
|
| 351 |
+
|
| 352 |
+
def _work():
|
| 353 |
+
try:
|
| 354 |
+
store.update('users', _set) # sync: fresh strict read + blocking upload
|
| 355 |
+
except Exception:
|
| 356 |
+
pass # a lost stamp is a lost stamp, never an error
|
| 357 |
+
import threading
|
| 358 |
+
t = threading.Thread(target=_work, daemon=True, name=f'user-stamp:{username}')
|
| 359 |
+
with _STAMPS_LOCK:
|
| 360 |
+
_STAMPS[:] = [x for x in _STAMPS if x.is_alive()]
|
| 361 |
+
_STAMPS.append(t)
|
| 362 |
+
t.start()
|
| 363 |
+
return t
|
| 364 |
+
|
| 365 |
+
|
| 366 |
+
def flush_stamps(timeout=10.0):
|
| 367 |
+
"""Block until outstanding stamp writes have been applied. For gates and shutdown hooks β
|
| 368 |
+
the app never needs it, exactly like `core.store.flush`."""
|
| 369 |
+
with _STAMPS_LOCK:
|
| 370 |
+
pending = list(_STAMPS)
|
| 371 |
+
for t in pending:
|
| 372 |
+
t.join(timeout=timeout)
|
| 373 |
+
return all(not t.is_alive() for t in pending)
|
| 374 |
+
|
| 375 |
+
|
| 376 |
+
def touch_login(username, when=None):
|
| 377 |
+
"""Stamp `last_login` (ISO-8601, UTC, OFFSET-BEARING) on a SUCCESSFUL login.
|
| 378 |
+
|
| 379 |
+
`username` must be the RESOLVED account key, not what the person typed: `verify()` accepts an
|
| 380 |
+
email address and resolves it to the registry key, so stamping the typed identifier would
|
| 381 |
+
write a stamp onto a key that does not exist and create a phantom account in the registry.
|
| 382 |
+
|
| 383 |
+
`last_active` rides along β signing in IS activity, and setting both here means the plane's
|
| 384 |
+
two columns agree the moment somebody logs in rather than an hour later.
|
| 385 |
+
"""
|
| 386 |
+
stamp = when or _now_iso()
|
| 387 |
+
return _stamp(username, {'last_login': stamp, 'last_active': stamp})
|
| 388 |
+
|
| 389 |
+
|
| 390 |
+
def touch_active(username, when=None):
|
| 391 |
+
"""Stamp `last_active` β "this session did something". Throttled BY THE CALLER (`deps.py`
|
| 392 |
+
holds a process-local last-seen map), so this is not a store round-trip per request."""
|
| 393 |
+
return _stamp(username, {'last_active': when or _now_iso()})
|
| 394 |
+
|
| 395 |
+
|
| 396 |
+
def set_access(username, role=None, bus=None, modules=None, agent=None, email=None,
|
| 397 |
+
name=None, perms=None):
|
| 398 |
+
"""Update access fields. agent/email: pass '' to clear, None to leave unchanged β
|
| 399 |
+
the userβagent link scopes the Customer List page / digests to that agent's book.
|
| 400 |
+
|
| 401 |
+
`name` follows the same None-means-unchanged idiom. It is here because it had no setter at all:
|
| 402 |
+
a display name could previously only be changed by re-creating the record through
|
| 403 |
+
`create_user`, i.e. by also resetting the password (and, before the fix above, the session
|
| 404 |
+
epoch). Y4's `PATCH {name?}` needs the narrow write, not the destructive one."""
|
| 405 |
+
username = (username or '').strip().lower()
|
| 406 |
+
|
| 407 |
+
def _set(reg):
|
| 408 |
+
u = reg.get(username)
|
| 409 |
+
if u:
|
| 410 |
+
if name is not None:
|
| 411 |
+
u['name'] = name
|
| 412 |
+
if role is not None:
|
| 413 |
+
u['role'] = role
|
| 414 |
+
if bus is not None:
|
| 415 |
+
u['bus'] = bus
|
| 416 |
+
if modules is not None:
|
| 417 |
+
u['modules'] = modules
|
| 418 |
+
if agent is not None:
|
| 419 |
+
u['agent'] = agent or None
|
| 420 |
+
if email is not None:
|
| 421 |
+
u['email'] = email or None
|
| 422 |
+
if perms is not None:
|
| 423 |
+
# Wave 15 C-PERM. Writing perms MIGRATES the record: the marker goes on in the
|
| 424 |
+
# same read-modify-write, so a record can never end up with one and not the
|
| 425 |
+
# other (see `_record`). Whole-block replace, matching the PUT route's shape β
|
| 426 |
+
# a merge would make "remove this restriction" unexpressible.
|
| 427 |
+
u['perms'] = perms
|
| 428 |
+
u['perms_v'] = PERMS_VERSION
|
| 429 |
+
return reg
|
| 430 |
+
store.update('users', _set)
|
| 431 |
+
|
| 432 |
+
|
| 433 |
+
def allowed_bus_labels(user):
|
| 434 |
+
"""BU labels this user may select. 'all' -> All+Fisch+Royal; a single BU -> just that BU (no
|
| 435 |
+
'All', so the other BU is never reachable); multiple -> All + each."""
|
| 436 |
+
bus = (user or {}).get('bus', 'all')
|
| 437 |
+
if bus == 'all':
|
| 438 |
+
return ['All', 'Fisch', 'Royal']
|
| 439 |
+
labels = [BU_LABELS[b] for b in bus if b in BU_LABELS]
|
| 440 |
+
if not labels:
|
| 441 |
+
return ['All', 'Fisch', 'Royal']
|
| 442 |
+
return (['All'] + labels) if len(labels) > 1 else labels
|
| 443 |
+
|
| 444 |
+
|
| 445 |
+
def assignable_people(tenant=None):
|
| 446 |
+
"""Display names for `user`-typed overlay columns β the tenant's ACTIVE accounts.
|
| 447 |
+
|
| 448 |
+
Moved from app.py (2026-07-31) so both hosts serve the same choices. Resolved on every
|
| 449 |
+
call rather than persisted with the column: a snapshot would keep offering people who
|
| 450 |
+
have left and never offer people who joined. Deactivated accounts are excluded; a value
|
| 451 |
+
already stored on a row is untouched β history should still say who owned something.
|
| 452 |
+
|
| 453 |
+
Wave 18 (C1-TENANT): pass `tenant` to scope the choices to ONE company β the user registry
|
| 454 |
+
is a global control-plane bucket, and a Nurilab picker offering Royal's staff is a
|
| 455 |
+
cross-tenant name leak.
|
| 456 |
+
|
| 457 |
+
ββ WAVE 33 (W33-T37) β A BLANK `tenant` NOW RETURNS NOTHING. It used to skip the filter and
|
| 458 |
+
return EVERY tenant's staff: `if want and β¦` is structurally fail-OPEN, and the exemption was
|
| 459 |
+
written for "None = unscoped (the Streamlit host, tenant #0's process)" β a host DELETED at
|
| 460 |
+
EXIT-6. So the sanctioned caller no longer exists, and what was left is a whole-platform
|
| 461 |
+
roster one forgotten kwarg away, with no error to notice
|
| 462 |
+
([[gate-must-go-red-not-crash]]'s sibling: a wall that answers instead of refusing).
|
| 463 |
+
β The direction is the safety: this can only ever NARROW. All six live call sites pass
|
| 464 |
+
`session.tenant`, which `aios_session.read` refuses to admit blank, so nothing legitimate
|
| 465 |
+
changes β and a future caller that forgets gets an empty picker somebody notices instead of a
|
| 466 |
+
leak nobody does.
|
| 467 |
+
"""
|
| 468 |
+
return sorted({str(u.get('name') or n) for n, u in _tenant_accounts(tenant)})
|
| 469 |
+
|
| 470 |
+
|
| 471 |
+
def set_avatar(username, data_url):
|
| 472 |
+
"""Set (or clear, with None/'') the user's profile photo β a data URL (wave 14 C-AVATAR,
|
| 473 |
+
[[loopable-wave14-split]] item 11). Stored VERBATIM; the API route owns validation (mime +
|
| 474 |
+
decoded size) because this value is served back to every grid session. Cleared by removing
|
| 475 |
+
the key, so records without a photo keep their pre-wave shape."""
|
| 476 |
+
username = (username or '').strip().lower()
|
| 477 |
+
|
| 478 |
+
def _set(reg):
|
| 479 |
+
u = reg.get(username)
|
| 480 |
+
if u:
|
| 481 |
+
if data_url:
|
| 482 |
+
u['avatar'] = str(data_url)
|
| 483 |
+
else:
|
| 484 |
+
u.pop('avatar', None)
|
| 485 |
+
return reg
|
| 486 |
+
store.update('users', _set)
|
| 487 |
+
|
| 488 |
+
|
| 489 |
+
def avatar_map(tenant=None):
|
| 490 |
+
"""{display name -> avatar data URL} for ACTIVE accounts with a photo β the companion of
|
| 491 |
+
`assignable_people()`, keyed by the SAME vocabulary: a `user` cell stores the display
|
| 492 |
+
name, so the display name is the only join a renderer has. Two active accounts sharing a
|
| 493 |
+
display name share one option; the first WITH a photo wins the key rather than a coin
|
| 494 |
+
flip deciding whether the option has a face. `tenant` scopes it exactly as
|
| 495 |
+
`assignable_people(tenant)` does, and for the same leak β including wave 33's fail-closed
|
| 496 |
+
blank, which both take from the ONE resolver below rather than each writing `if want and β¦`.
|
| 497 |
+
"""
|
| 498 |
+
out = {}
|
| 499 |
+
for username, u in sorted(_tenant_accounts(tenant), key=lambda kv: kv[0]):
|
| 500 |
+
av = u.get('avatar')
|
| 501 |
+
nm = str(u.get('name') or username)
|
| 502 |
+
if av and nm not in out:
|
| 503 |
+
out[nm] = str(av)
|
| 504 |
+
return out
|
| 505 |
+
|
| 506 |
+
|
| 507 |
+
def _tenant_accounts(tenant):
|
| 508 |
+
"""`[(username, record), β¦]` β the ACTIVE accounts of exactly ONE tenant. FAIL-CLOSED.
|
| 509 |
+
|
| 510 |
+
β THE ONE PLACE THE TENANT FILTER FOR THE USER REGISTRY IS WRITTEN. It was written twice,
|
| 511 |
+
identically, in `assignable_people` and `avatar_map`, and both copies were fail-OPEN on a
|
| 512 |
+
blank slug in the same way (`if want and β¦`). Two copies of a wall is two places for one of
|
| 513 |
+
them to be fixed [[one-question-two-normalizers]]; `routes_shares._people` is a THIRD copy of
|
| 514 |
+
the same predicate and is lane C's to fold in.
|
| 515 |
+
|
| 516 |
+
β `str(u.get('tenant') or 'royal-imports')` is kept on the RECORD side deliberately: a
|
| 517 |
+
pre-wave-18 account genuinely has no `tenant` key and IS tenant #0's, and `users._public`
|
| 518 |
+
normalises it the same way. What changed is the WANT side β a blank want is now a refusal
|
| 519 |
+
rather than a wildcard.
|
| 520 |
+
"""
|
| 521 |
+
want = str(tenant or '').strip().lower()
|
| 522 |
+
if not want:
|
| 523 |
+
# Not an error and not everything: nobody. See `assignable_people`'s note β the one
|
| 524 |
+
# caller this exemption was written for (the Streamlit host) was deleted at EXIT-6.
|
| 525 |
+
return []
|
| 526 |
+
try:
|
| 527 |
+
reg = registry() or {}
|
| 528 |
+
except Exception:
|
| 529 |
+
return []
|
| 530 |
+
return [(n, u) for n, u in reg.items()
|
| 531 |
+
if isinstance(u, dict) and u.get('active') is not False
|
| 532 |
+
and str(u.get('tenant') or 'royal-imports').strip().lower() == want]
|
platform/harness/datastore.py
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
platform/harness/semantic.py
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
platform/model/metrics/sales.yml
CHANGED
|
@@ -1,108 +1,149 @@
|
|
| 1 |
-
# Metrics: sales β the core wholesale metrics, defined ONCE (OM-0). Every surface (pages, the
|
| 2 |
-
# metric dictionary, the Analyst, MCP) resolves these by key through harness/semantic.py.
|
| 3 |
-
# Fields per metric:
|
| 4 |
-
# key/label/description β identity + the human definition (visibility = trust)
|
| 5 |
-
# agg + field β sum | count_distinct over the topic's entity
|
| 6 |
-
# agg: ratio β numerator/denominator are metric KEYS (resolved recursively)
|
| 7 |
-
# agg: derived + expr β arithmetic over metric keys (safe parser; +,-,*,/ and parens only)
|
| 8 |
-
# format β usd | int | pct (rendering hint for surfaces)
|
| 9 |
-
# ai_context β what a small model must know to use the metric correctly
|
| 10 |
-
# validate β the INDEPENDENT Odoo cross-check contract (named method implemented in
|
| 11 |
-
# harness/semantic.py _VALIDATORS; a metric that can't tie out says so)
|
| 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 |
-
description: "
|
| 39 |
-
ai_context: "
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Metrics: sales β the core wholesale metrics, defined ONCE (OM-0). Every surface (pages, the
|
| 2 |
+
# metric dictionary, the Analyst, MCP) resolves these by key through harness/semantic.py.
|
| 3 |
+
# Fields per metric:
|
| 4 |
+
# key/label/description β identity + the human definition (visibility = trust)
|
| 5 |
+
# agg + field β sum | count_distinct over the topic's entity
|
| 6 |
+
# agg: ratio β numerator/denominator are metric KEYS (resolved recursively)
|
| 7 |
+
# agg: derived + expr β arithmetic over metric keys (safe parser; +,-,*,/ and parens only)
|
| 8 |
+
# format β usd | int | pct (rendering hint for surfaces)
|
| 9 |
+
# ai_context β what a small model must know to use the metric correctly
|
| 10 |
+
# validate β the INDEPENDENT Odoo cross-check contract (named method implemented in
|
| 11 |
+
# harness/semantic.py _VALIDATORS; a metric that can't tie out says so)
|
| 12 |
+
# empty β W37 C1's EMPTY-WINDOW FAMILY. See below; a metric an ENTITY topic
|
| 13 |
+
# offers as a lookback column MUST declare one.
|
| 14 |
+
#
|
| 15 |
+
# ββ `empty:` β WHAT A ROW WITH NO ACTIVITY IN THE WINDOW RENDERS AS (wave 37, contract C1).
|
| 16 |
+
# Measured and load-bearing: only 1,646 of 5,836 active products sold in the last 90 days, so a
|
| 17 |
+
# per-SKU lookback metric has NO GROUP for 72% of the catalogue. Get the default wrong and every
|
| 18 |
+
# product grid reads as broken. Two values, and the difference is whether the blank cell would be
|
| 19 |
+
# a TRUE STATEMENT:
|
| 20 |
+
# empty: zero ADDITIVE β units, revenue, margin $, COGS. "It sold nothing" is a real
|
| 21 |
+
# measurement, so 0 is the honest cell and a blank would hide a fact.
|
| 22 |
+
# empty: blank RATIO / DERIVED-FROM-A-RATIO β GM %, ASP. A 0% margin on zero sales is a
|
| 23 |
+
# FALSE statement, not a missing one. β AND THE GUARD IS THE DENOMINATOR, NOT
|
| 24 |
+
# THE MISSING GROUP: `semantic._post_compute` returns 0.0 for `num/0`, so a SKU
|
| 25 |
+
# that DID sell at $0 would print "0.0%" with a group behind it. The resolver
|
| 26 |
+
# blanks on a zero denominator, which is the only reading that is never a lie.
|
| 27 |
+
# β A metric with no `empty:` is REFUSED by `entity_measures()` rather than defaulted β a
|
| 28 |
+
# defaulted family is a guess about truth, and C1 says a metric that cannot say which family it
|
| 29 |
+
# is in does not ship.
|
| 30 |
+
topic: sales_lines
|
| 31 |
+
metrics:
|
| 32 |
+
- key: revenue
|
| 33 |
+
label: Revenue
|
| 34 |
+
agg: sum
|
| 35 |
+
field: price_subtotal
|
| 36 |
+
format: usd
|
| 37 |
+
empty: zero
|
| 38 |
+
description: "Untaxed revenue of confirmed wholesale order lines (the owner's 'sales' number)."
|
| 39 |
+
ai_context: "Always untaxed; excludes Amazon (GIFTWARE DEALS) and unconfirmed orders. Filter one BU via team_id (Fisch=5, Royal=6)."
|
| 40 |
+
validate:
|
| 41 |
+
method: order_level_revenue
|
| 42 |
+
note: "Ξ£ line price_subtotal must equal Ξ£ parent-order amount_untaxed under the same scope, to the cent (line vs order basis β the built-in cross-check)."
|
| 43 |
+
|
| 44 |
+
- key: units
|
| 45 |
+
label: Units sold
|
| 46 |
+
agg: sum
|
| 47 |
+
field: product_uom_qty
|
| 48 |
+
format: int
|
| 49 |
+
empty: zero
|
| 50 |
+
description: "Total quantity across confirmed wholesale order lines."
|
| 51 |
+
ai_context: "Mixed UoMs are summed as ordered quantity; for weight/case analysis convert per product UoM first."
|
| 52 |
+
|
| 53 |
+
- key: margin
|
| 54 |
+
label: Gross margin $
|
| 55 |
+
agg: sum
|
| 56 |
+
field: margin
|
| 57 |
+
format: usd
|
| 58 |
+
empty: zero
|
| 59 |
+
description: "Line revenue minus line cost (Odoo Margin module), summed."
|
| 60 |
+
ai_context: "margin is read_group-aggregatable (Margin module installed). COGS = revenue - margin. purchase_price is per-UNIT cost β never sum it as a total."
|
| 61 |
+
|
| 62 |
+
- key: cogs
|
| 63 |
+
label: COGS
|
| 64 |
+
agg: derived
|
| 65 |
+
expr: "revenue - margin"
|
| 66 |
+
format: usd
|
| 67 |
+
empty: zero
|
| 68 |
+
description: "Cost of goods sold, derived: revenue minus gross margin."
|
| 69 |
+
ai_context: "Derived, not pulled β Odoo carries cost on lines as margin; COGS is the difference."
|
| 70 |
+
|
| 71 |
+
- key: margin_pct
|
| 72 |
+
label: Gross margin %
|
| 73 |
+
agg: ratio
|
| 74 |
+
numerator: margin
|
| 75 |
+
denominator: revenue
|
| 76 |
+
format: pct
|
| 77 |
+
empty: blank
|
| 78 |
+
description: "Gross margin as a share of revenue."
|
| 79 |
+
ai_context: "Compare across BUs/categories at the same scope only. β TWO ZERO-REVENUE BEHAVIOURS, deliberately: the scalar/analyst path returns 0 (semantic._post_compute's guard), while an ENTITY LOOKBACK COLUMN renders BLANK (empty: blank) β on a grid, a printed 0.0% beside 5,836 products would assert a margin nobody measured."
|
| 80 |
+
|
| 81 |
+
# β W37-T10 β ASP, the fifth SHIP-FIRST per-SKU metric (proto/P3). Blended $18.76 over 1,646
|
| 82 |
+
# SKUs in the 90 days to 2026-08-19. A RATIO of two same-topic base measures, so it survives a
|
| 83 |
+
# GROUPED store_query (only a CROSS-TOPIC component is refused β that is what stops `aov`,
|
| 84 |
+
# whose denominator `orders` lives on sales_orders).
|
| 85 |
+
# β UNITS ARE AS-ORDERED, mixed UoM. `units` says so and this inherits it: a SKU sold in cases
|
| 86 |
+
# and in singles has an ASP blended across both, which is the true average selling price of a
|
| 87 |
+
# line unit and NOT a per-piece price.
|
| 88 |
+
- key: asp
|
| 89 |
+
label: Average selling price
|
| 90 |
+
agg: ratio
|
| 91 |
+
numerator: revenue
|
| 92 |
+
denominator: units
|
| 93 |
+
format: usd
|
| 94 |
+
empty: blank
|
| 95 |
+
description: "Revenue per unit sold β the blended average selling price."
|
| 96 |
+
ai_context: "revenue Γ· units at identical scope. Mixed units of measure are summed as ordered quantity, so this is per ordered unit, not per piece. Blank when nothing sold: an ASP of $0 on zero units is a false statement, not a missing one."
|
| 97 |
+
|
| 98 |
+
- key: orders
|
| 99 |
+
label: Orders
|
| 100 |
+
topic: sales_orders
|
| 101 |
+
agg: count
|
| 102 |
+
format: int
|
| 103 |
+
description: "Confirmed wholesale orders in the window (order-header count β what the scorecards show)."
|
| 104 |
+
ai_context: "Order-level count; slightly higher than distinct-orders-from-lines because a few confirmed orders carry zero lines (a data-health artifact the reconciliation contract counts exactly)."
|
| 105 |
+
validate:
|
| 106 |
+
method: order_count
|
| 107 |
+
note: "Order-level count must equal distinct line-parent orders + line-less orders, exactly (surfaces empty orders instead of hiding them in a tolerance)."
|
| 108 |
+
|
| 109 |
+
- key: customers
|
| 110 |
+
label: Active customers
|
| 111 |
+
agg: count_distinct
|
| 112 |
+
field: order_partner_id
|
| 113 |
+
format: int
|
| 114 |
+
empty: zero
|
| 115 |
+
description: "Distinct customers with at least one confirmed wholesale order line in the window."
|
| 116 |
+
ai_context: "Customer = the order's partner. Agent attribution uses res.partner.agent_ids, NOT the order user_id."
|
| 117 |
+
|
| 118 |
+
- key: aov
|
| 119 |
+
label: Average order value
|
| 120 |
+
agg: ratio
|
| 121 |
+
numerator: revenue
|
| 122 |
+
denominator: orders
|
| 123 |
+
format: usd
|
| 124 |
+
description: "Revenue per distinct order."
|
| 125 |
+
ai_context: "Ratio of two registered metrics at identical scope; never average per-order averages."
|
| 126 |
+
|
| 127 |
+
# β Wave 21 R2 β the FULLY-INVOICED basis, as separate metrics (owner ruling: picker entries,
|
| 128 |
+
# not a basis dropdown). Same topics, same scope, ONE predicate narrower: the order's Odoo
|
| 129 |
+
# invoice_status = 'invoiced'. `store_filter_sql` filters the store path (sum/count CASE);
|
| 130 |
+
# `live_domain` filters the live path β BOTH or store_parity compares two different questions.
|
| 131 |
+
- key: revenue_invoiced
|
| 132 |
+
label: Sales β fully invoiced
|
| 133 |
+
agg: sum
|
| 134 |
+
field: price_subtotal
|
| 135 |
+
store_filter_sql: "o.invoice_status = 'invoiced'"
|
| 136 |
+
live_domain: [["order_id.invoice_status", "=", "invoiced"]]
|
| 137 |
+
format: usd
|
| 138 |
+
description: "Untaxed revenue of confirmed wholesale order lines whose parent order Odoo marks fully invoiced (invoice_status = invoiced)."
|
| 139 |
+
ai_context: "Same scope as revenue, narrowed by the ORDER-level fully-invoiced flag. This is NOT posted-invoice-line revenue: a partially invoiced order is excluded entirely until Odoo flips the flag. Reconciled store-vs-live per BU by store_parity."
|
| 140 |
+
|
| 141 |
+
- key: orders_invoiced
|
| 142 |
+
label: Orders β fully invoiced
|
| 143 |
+
topic: sales_orders
|
| 144 |
+
agg: count
|
| 145 |
+
store_filter_sql: "o.invoice_status = 'invoiced'"
|
| 146 |
+
live_domain: [["invoice_status", "=", "invoiced"]]
|
| 147 |
+
format: int
|
| 148 |
+
description: "Confirmed wholesale orders Odoo marks fully invoiced (invoice_status = invoiced)."
|
| 149 |
+
ai_context: "Order-header count under the orders scope plus the fully-invoiced flag; partially invoiced and not-yet-invoiced orders are excluded. Reconciled store-vs-live per BU by store_parity."
|
platform/model/metrics/stock.yml
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Metrics: stock β physical movement (W37-T13, owner item 4 / ruling R1).
|
| 2 |
+
#
|
| 3 |
+
# ββ THE DIRECTION LIVES IN `store_filter_sql`, WHICH IS WHY THESE ARE TWO METRICS AND NOT ONE
|
| 4 |
+
# METRIC WITH A PARAMETER. The model's filtered-measure support compiles `store_filter_sql` into
|
| 5 |
+
# `sum(CASE WHEN <predicate> THEN <field> ELSE 0 END)`, so IN and OUT are the SAME sum over the
|
| 6 |
+
# SAME rows under two different predicates β one scan answers both, and neither can drift from the
|
| 7 |
+
# other's scope because they share every other clause.
|
| 8 |
+
#
|
| 9 |
+
# β EACH PREDICATE IS TWO-SIDED, and that is the ticket's headline trap (`proto/P1-stock-moves.md`):
|
| 10 |
+
# `internal -> internal` is **58% of all moves**. A one-sided test (`destination is internal`) counts
|
| 11 |
+
# every internal transfer as an arrival AND a one-sided `source is internal` counts it as a
|
| 12 |
+
# departure β both columns roughly double, nothing errors, and the totals stay internally
|
| 13 |
+
# consistent with each other, which is what makes it survive review.
|
| 14 |
+
#
|
| 15 |
+
# β `usage` COMES FROM `stock_location`, NOT from the move. `location_id` mirrors as an id plus a
|
| 16 |
+
# NAME; there is no usage on the move row, so `stock_location` is joined in the topic precisely to
|
| 17 |
+
# make this predicate expressible. If that join is ever dropped, these two metrics do not go red β
|
| 18 |
+
# they go NULL-predicate, which SQL treats as false, and both columns silently read ZERO.
|
| 19 |
+
#
|
| 20 |
+
# β ADJUSTMENTS AND SCRAP ARE INCLUDED, DELIBERATELY, AND THE ALTERNATIVE WAS MEASURED. They are
|
| 21 |
+
# ~30% of IN and ~20% of OUT, and the two obvious exclusions are NOT equivalent β the 5,320-unit
|
| 22 |
+
# gap between them is entirely `Virtual Locations/Scrap`. A physical arrival is an arrival however
|
| 23 |
+
# it was booked, so the honest default is to count it and let the `source`/`destination` dims
|
| 24 |
+
# separate it. Excluding it silently would be a different number under the same label.
|
| 25 |
+
topic: stock_moves
|
| 26 |
+
metrics:
|
| 27 |
+
- key: stock_in
|
| 28 |
+
label: Stock moved in
|
| 29 |
+
agg: sum
|
| 30 |
+
field: quantity_done
|
| 31 |
+
store_filter_sql: "sd.usage = 'internal' AND COALESCE(sl.usage, '') <> 'internal'"
|
| 32 |
+
format: int
|
| 33 |
+
empty: zero
|
| 34 |
+
description: "Units that ARRIVED at an internal location from outside it, over the window."
|
| 35 |
+
ai_context: "Receipts, returns inward and inventory adjustments upward. Two-sided by construction: an internal-to-internal transfer is NOT an arrival. Quantities are in each product's own unit, so never sum this across SKUs."
|
| 36 |
+
|
| 37 |
+
- key: stock_out
|
| 38 |
+
label: Stock moved out
|
| 39 |
+
agg: sum
|
| 40 |
+
field: quantity_done
|
| 41 |
+
store_filter_sql: "sl.usage = 'internal' AND COALESCE(sd.usage, '') <> 'internal'"
|
| 42 |
+
format: int
|
| 43 |
+
empty: zero
|
| 44 |
+
description: "Units that LEFT an internal location for outside it, over the window."
|
| 45 |
+
ai_context: "Deliveries, scrap and adjustments downward. Two-sided by construction: an internal-to-internal transfer is NOT a departure. Quantities are in each product's own unit, so never sum this across SKUs."
|
| 46 |
+
|
| 47 |
+
# β NET IS DERIVED, never a third sum. Deriving it guarantees `net = in - out` exactly, where a
|
| 48 |
+
# separately-summed net could disagree with its own two components under a filter and nothing
|
| 49 |
+
# would say which was right.
|
| 50 |
+
- key: stock_net
|
| 51 |
+
label: Stock moved net
|
| 52 |
+
agg: derived
|
| 53 |
+
expr: "stock_in - stock_out"
|
| 54 |
+
format: int
|
| 55 |
+
empty: zero
|
| 56 |
+
description: "Arrivals minus departures over the window β the period change in units held."
|
| 57 |
+
ai_context: "Derived from stock_in and stock_out, so it always reconciles to them. It is a MOVEMENT figure, not the on-hand balance: the balance is `product_data.on_hand` from stock.quant."
|
platform/model/topics/odoo_agents.yml
CHANGED
|
@@ -21,6 +21,38 @@ store:
|
|
| 21 |
# NO date_col β a registry is not a dated event stream. Stated rather than
|
| 22 |
# omitted, so its absence reads as a fact and not as an unfinished file.
|
| 23 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
# key / label / type / kind, derived from the grid contract. `kind` says where the
|
| 25 |
# value COMES FROM: a `data` column is stored on the row; a `rollup` is computed
|
| 26 |
# from another topic; a `link` points at another database.
|
|
|
|
| 21 |
# NO date_col β a registry is not a dated event stream. Stated rather than
|
| 22 |
# omitted, so its absence reads as a fact and not as an unfinished file.
|
| 23 |
|
| 24 |
+
# ββ W37-T12 / owner item 4 (R1) β THE LOOKBACK-MEASURE BINDING, the agent half of
|
| 25 |
+
# *"It should apply to Odoo agents database as well."* Same contract as
|
| 26 |
+
# `odoo_products.yml`: a FACT topic, and the dim of that topic which carries THIS
|
| 27 |
+
# entity's identity.
|
| 28 |
+
#
|
| 29 |
+
# ββ WHICH AGENT SOURCE, STATED β because there are THREE in this Odoo and they name
|
| 30 |
+
# DIFFERENT PEOPLE (`proto/P3-metric-catalog.md`: 9 agents carry route-1 revenue, 12
|
| 31 |
+
# carry commission lines, 17 carry the flag). This binds ROUTE 1, the CUSTOMER-MASTER
|
| 32 |
+
# BOOK: `sales_lines.agent` is `rp.agent_id`, the customer's assigned agent, so every
|
| 33 |
+
# order of that customer counts toward their agent. That is "whose book is this" and it
|
| 34 |
+
# is the right question for a column on the AGENT REGISTRY.
|
| 35 |
+
# β It is NOT commission. `account.invoice.line.agent` (topic `commission_lines`) is the
|
| 36 |
+
# per-INVOICE-LINE credited agent and answers a different question; `sales_lines.yml`'s
|
| 37 |
+
# own `agent` dim comment carries the full disagreement and says never to "fix" one by
|
| 38 |
+
# reading the other.
|
| 39 |
+
#
|
| 40 |
+
# β THE JOIN NEEDS NO NEW DIM, unlike the product side. `sales_lines.agent` declares a
|
| 41 |
+
# `name_col`, so `store_query` emits `agent_id` β which IS `res.partner.id`, which IS
|
| 42 |
+
# this grid's `odoo_id` identity. Product needed `product_code` because its grid keys on
|
| 43 |
+
# `default_code`; this one already keys on the same integer the fact topic groups by.
|
| 44 |
+
measures:
|
| 45 |
+
source: sales_lines
|
| 46 |
+
dim: agent
|
| 47 |
+
keys: [revenue, units, margin, cogs, margin_pct, asp, customers]
|
| 48 |
+
not_yet:
|
| 49 |
+
- key: orders / aov
|
| 50 |
+
cause: "`orders` lives on topic `sales_orders`, so under a `group_by` it is a CROSS-TOPIC measure and `semantic.store_query` refuses it (scalar-only). `aov` inherits the refusal through its denominator"
|
| 51 |
+
fix: "add an ORDER-COUNT metric to `sales_lines` itself β `count_distinct` over `l.order_id` β which answers the same question at this grain in one pass"
|
| 52 |
+
- key: commission_amount
|
| 53 |
+
cause: "the commission basis lives on `account.invoice.line.agent` (topic `commission_lines`), a different topic AND a different grain; `invoice_lines` deduplicates to at most one agent per line"
|
| 54 |
+
fix: "bind a SECOND measures block per source once the contract allows more than one, and label the columns so the two routes can never be read as the same number"
|
| 55 |
+
|
| 56 |
# key / label / type / kind, derived from the grid contract. `kind` says where the
|
| 57 |
# value COMES FROM: a `data` column is stored on the row; a `rollup` is computed
|
| 58 |
# from another topic; a `link` points at another database.
|
platform/model/topics/odoo_products.yml
CHANGED
|
@@ -26,6 +26,57 @@ store:
|
|
| 26 |
category: {label: "Category"}
|
| 27 |
supplier: {label: "Supplier"}
|
| 28 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
# key / label / type / kind, derived from the grid contract. `kind` says where the
|
| 30 |
# value COMES FROM: a `data` column is stored on the row; a `rollup` is computed
|
| 31 |
# from another topic; a `link` points at another database.
|
|
|
|
| 26 |
category: {label: "Category"}
|
| 27 |
supplier: {label: "Supplier"}
|
| 28 |
|
| 29 |
+
# ββ W37-T10 / owner item 4 (ruling R1) β THE LOOKBACK-MEASURE BINDING. Owner: *"Odoo products
|
| 30 |
+
# should have a lookbsck metrics like sales etc."* This block is what turns `measures: []` on the
|
| 31 |
+
# product workspace into a real catalogue, and it is DECLARED HERE rather than hand-written in a
|
| 32 |
+
# route for the reason `_rollup_source_offer` gives about itself: a second list is a second
|
| 33 |
+
# definition of the same fact, and the two drift the day somebody adds a metric.
|
| 34 |
+
#
|
| 35 |
+
# β THE JOIN KEY IS THE TRAP CONTRACT C1 NAMES, and this is where it is answered. `source` is a
|
| 36 |
+
# FACT topic; `dim` is the dim of THAT topic which carries THIS entity's identity. `sales_lines`
|
| 37 |
+
# has two candidates and only one is right: `product` groups by Odoo's `product_id`, while this
|
| 38 |
+
# database's identity (see `scope.identity` above) is `default_code`. Binding to `product` would
|
| 39 |
+
# key every cell on a number no grid row carries β a column of nulls that reads as "never sold".
|
| 40 |
+
#
|
| 41 |
+
# β A SINGLE-TOPIC GROUPED QUERY, which is why `semantic.store_query`'s cross-topic refusal does
|
| 42 |
+
# not bite: `revenue` lives ON `sales_lines` and we group `sales_lines` by its OWN dim. The same
|
| 43 |
+
# refusal is real and DOES bite `aov`/`orders` (denominator `orders` lives on `sales_orders`), so
|
| 44 |
+
# those two are absent from `keys` below and `entity_measures()` re-proves that rather than
|
| 45 |
+
# trusting this list.
|
| 46 |
+
# β A LIST SINCE W37-T13: an entity's columns legitimately come from more than ONE fact topic.
|
| 47 |
+
# Sales movement is `sales_lines`; physical movement is `stock_moves`. Same grid, same join key
|
| 48 |
+
# (`product_code`), different tables β forcing stock into the sales topic would have defined a
|
| 49 |
+
# metric against rows it does not have.
|
| 50 |
+
measures:
|
| 51 |
+
- source: sales_lines
|
| 52 |
+
dim: product_code
|
| 53 |
+
# β The SHIP-FIRST six of `proto/P3-metric-catalog.md`, and they cost ONE grouped query
|
| 54 |
+
# together (measured 0.22 s over 1,670 groups against the mirror; 2.55 s live).
|
| 55 |
+
keys: [units, revenue, margin, cogs, margin_pct, asp]
|
| 56 |
+
# β REPORTED, NOT SILENTLY DROPPED (standing rule 1's second sentence, applied to a catalogue).
|
| 57 |
+
# Each of these is a real metric `proto/P3-metric-catalog.md` measured and this wave does not
|
| 58 |
+
# ship, with the CAUSE and the fix β so the next session extends the list instead of
|
| 59 |
+
# re-measuring, and nobody reads the six as "all Odoo can answer".
|
| 60 |
+
not_yet:
|
| 61 |
+
- key: days_since_last_sale
|
| 62 |
+
cause: "needs `agg: max` over a DATE, which `semantic._measure_sql` does not implement (sum | count | count_distinct only), and a date-typed measure could not render anyway: `aios_grid.MEASURE_FIELD_TYPES` is {currency, int, pct}"
|
| 63 |
+
fix: "add `agg: max` to `_measure_sql` and express the metric as an INT β `date_diff('day', max(date), today)` β so it renders as a number of days rather than a date. Measured live at 4.61 s over 3,348 SKUs"
|
| 64 |
+
- key: distinct_orders
|
| 65 |
+
cause: "`__count` on the live path is LINE count, not ORDER count (12,707 lines vs 12,645 distinct SKU-order pairs); the honest figure needs a 2-level groupby measured at 10.87 s live"
|
| 66 |
+
fix: "on the STORE path this is `count(DISTINCT l.order_id)` in one pass β add it as a `count_distinct` metric on `sales_lines` with field `order_id`"
|
| 67 |
+
|
| 68 |
+
# ββ W37-T13 β PHYSICAL MOVEMENT. `stock.move` and `stock_location` are mirrored now (the
|
| 69 |
+
# `not_yet` entry that used to sit here said "not mirrored, so there is no store binding to
|
| 70 |
+
# group"; that is what changed). The direction rule is TWO-SIDED and lives in the metrics'
|
| 71 |
+
# `store_filter_sql` β see `model/metrics/stock.yml`, which carries the 58% trap in full.
|
| 72 |
+
- source: stock_moves
|
| 73 |
+
dim: product_code
|
| 74 |
+
keys: [stock_in, stock_out, stock_net]
|
| 75 |
+
not_yet:
|
| 76 |
+
- key: stock_in_excl_adjustments
|
| 77 |
+
cause: "adjustments and scrap are ~30% of IN / ~20% of OUT and the two obvious exclusions are NOT equivalent - the 5,320-unit gap between them is entirely `Virtual Locations/Scrap`, so the choice is a business ruling rather than a filter"
|
| 78 |
+
fix: "ask the owner which exclusion they mean, then add it as a SEPARATE metric with `store_filter_sql` narrowed on `sl.usage`/`sd.usage` - never by changing what `stock_in` means"
|
| 79 |
+
|
| 80 |
# key / label / type / kind, derived from the grid contract. `kind` says where the
|
| 81 |
# value COMES FROM: a `data` column is stored on the row; a `rollup` is computed
|
| 82 |
# from another topic; a `link` points at another database.
|
platform/model/topics/sales_lines.yml
CHANGED
|
@@ -42,6 +42,19 @@ store:
|
|
| 42 |
dims:
|
| 43 |
order_partner: {col: "l.order_partner_id", name_col: "l.order_partner_name", label: "Customer"}
|
| 44 |
product: {col: "l.product_id", name_col: "l.product_name", label: "Product"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
# team names via CASE (crm_team is not synced; ids are baked tenant scope like the team filter)
|
| 46 |
team: {col: "o.team_id",
|
| 47 |
name_col: "CASE o.team_id WHEN 5 THEN 'Fisch' WHEN 6 THEN 'Royal' ELSE CAST(o.team_id AS VARCHAR) END",
|
|
|
|
| 42 |
dims:
|
| 43 |
order_partner: {col: "l.order_partner_id", name_col: "l.order_partner_name", label: "Customer"}
|
| 44 |
product: {col: "l.product_id", name_col: "l.product_name", label: "Product"}
|
| 45 |
+
# ββ W37-T10 β THE SKU-CODE DIM, added because a per-SKU metric has to group by the key the
|
| 46 |
+
# PRODUCT GRID actually carries. `product` above groups by Odoo's `product_id`; the
|
| 47 |
+
# `odoo_products` database's identity is `default_code` (its own topic file says so), and a
|
| 48 |
+
# metric keyed on the wrong one is a column of nulls that reads as "this never sold".
|
| 49 |
+
# β `COALESCE(NULLIF(...))` REPRODUCES THE GRID'S IDENTITY EXACTLY, including the codeless
|
| 50 |
+
# fallback: `modules/product_data.py` keys an uncoded active product as `pid:<odoo id>`, so
|
| 51 |
+
# this expression has to as well or those rows silently lose their metrics.
|
| 52 |
+
# β It also MERGES a re-SKUed pair: an archived record and the active one sharing a code are
|
| 53 |
+
# one product to the grid, and grouping by `product_id` would split them into two.
|
| 54 |
+
# β NO `name_col` β the code IS its own label, so `store_query` emits the bare `product_code`
|
| 55 |
+
# (the value-keyed shape `rollup_sql.group_values` handles beside `payment_state`).
|
| 56 |
+
product_code: {col: "COALESCE(NULLIF(pp.default_code, ''), 'pid:' || CAST(pp.id AS VARCHAR))",
|
| 57 |
+
label: "SKU code"}
|
| 58 |
# team names via CASE (crm_team is not synced; ids are baked tenant scope like the team filter)
|
| 59 |
team: {col: "o.team_id",
|
| 60 |
name_col: "CASE o.team_id WHEN 5 THEN 'Fisch' WHEN 6 THEN 'Royal' ELSE CAST(o.team_id AS VARCHAR) END",
|
platform/model/topics/stock_moves.yml
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ββ W37-T13 (owner: stock moved IN and OUT, per SKU, over a lookback window).
|
| 2 |
+
#
|
| 3 |
+
# A DATED EVENT STREAM at move grain β the counterpart to `odoo_products`, which is a
|
| 4 |
+
# catalogue and says so ("a catalogue has no date dimension; ask sales_lines for
|
| 5 |
+
# movement"). This is the topic that answers movement for stock, as `sales_lines` does
|
| 6 |
+
# for revenue.
|
| 7 |
+
#
|
| 8 |
+
# β EVERY CLAUSE HERE WAS MEASURED, in `proto/P1-stock-moves.md`, against live Odoo.
|
| 9 |
+
# Do not "simplify" one without re-reading it β three of them are traps that produce a
|
| 10 |
+
# plausible wrong number rather than an error.
|
| 11 |
+
key: stock_moves
|
| 12 |
+
label: Stock movement
|
| 13 |
+
entity: stock.move
|
| 14 |
+
domain_builder: ~
|
| 15 |
+
scope:
|
| 16 |
+
state: "done moves ONLY β a draft/waiting/assigned move is an intention, not a movement"
|
| 17 |
+
direction: >-
|
| 18 |
+
DIRECTION IS A PROPERTY OF THE PAIR OF LOCATIONS, never of one. A move is IN when it
|
| 19 |
+
arrives at an internal location from a non-internal one, and OUT when it leaves an
|
| 20 |
+
internal location for a non-internal one. internal->internal is 58% of all moves and
|
| 21 |
+
is NEITHER; counting it as both is the defect a one-sided domain produces.
|
| 22 |
+
not_picking_code: >-
|
| 23 |
+
`picking_code` is NOT the discriminator. It is store=False, so a groupby faults, and
|
| 24 |
+
('picking_code','=',False) returns 0 while 5,800 pickingless done moves exist - the
|
| 25 |
+
count that would warn you also reads 0.
|
| 26 |
+
scale: "236k rows over 1,095 days; cost is FLAT in window length (30d 2.1s .. 1095d 4.0s live)"
|
| 27 |
+
no_bu: >-
|
| 28 |
+
`stock.move` has no team_id. Stock is HQ-consolidated, like AR and Inventory - stated
|
| 29 |
+
as a fact rather than left to read as a missing filter.
|
| 30 |
+
|
| 31 |
+
grain: "one row per done stock move; time-filterable by move date; NOT BU-filterable"
|
| 32 |
+
|
| 33 |
+
store:
|
| 34 |
+
table: stock_move
|
| 35 |
+
alias: sm
|
| 36 |
+
# β BOTH LOCATION JOINS, and they are the whole of the direction rule. `sl` is where it
|
| 37 |
+
# came FROM, `sd` is where it went TO. Dropping either makes every direction metric
|
| 38 |
+
# silently one-sided.
|
| 39 |
+
join: >-
|
| 40 |
+
LEFT JOIN stock_location sl ON sl.id = sm.location_id
|
| 41 |
+
LEFT JOIN stock_location sd ON sd.id = sm.location_dest_id
|
| 42 |
+
LEFT JOIN product_product pp ON pp.id = sm.product_id
|
| 43 |
+
date_col: "sm.date"
|
| 44 |
+
# NO team_col β see scope.no_bu. A BU-scoped caller is REFUSED by `store_query` with a
|
| 45 |
+
# sentence rather than being served a company-wide number wearing a unit's label.
|
| 46 |
+
scope_sql: "sm.state = 'done'"
|
| 47 |
+
dims:
|
| 48 |
+
# β THE SAME CODE-KEYED SHAPE `sales_lines.product_code` uses, and for the same reason:
|
| 49 |
+
# the product grid's identity is `default_code`, with `pid:<odoo id>` for the codeless
|
| 50 |
+
# actives. A dim keyed on Odoo's `product_id` would key every cell on a value no grid
|
| 51 |
+
# row carries (contract C1's join-key trap).
|
| 52 |
+
product_code: {col: "COALESCE(NULLIF(pp.default_code, ''), 'pid:' || CAST(pp.id AS VARCHAR))",
|
| 53 |
+
label: "SKU code"}
|
| 54 |
+
product: {col: "sm.product_id", name_col: "pp.name", label: "Product"}
|
| 55 |
+
source: {col: "sm.location_id", name_col: "sl.complete_name", label: "From location"}
|
| 56 |
+
destination: {col: "sm.location_dest_id", name_col: "sd.complete_name", label: "To location"}
|
| 57 |
+
|
| 58 |
+
ai_context: >
|
| 59 |
+
Physical stock movement at move grain, from Odoo `stock.move`, done moves only.
|
| 60 |
+
Use it for "what came in", "what went out", "what moved" over a window, per SKU or per location.
|
| 61 |
+
β DIRECTION IS A PAIR TEST, never a single location: `stock_in` counts moves ARRIVING at an
|
| 62 |
+
internal location from a non-internal one, `stock_out` counts moves LEAVING an internal location
|
| 63 |
+
for a non-internal one, and internal-to-internal transfers (58% of all moves) are in NEITHER.
|
| 64 |
+
Quantities are `quantity_done` β what actually moved β in the product's own unit of measure, so
|
| 65 |
+
units are NOT comparable across SKUs and must never be summed into one total across products.
|
| 66 |
+
β THIS TOPIC HAS NO BUSINESS UNIT. `stock.move` carries no team, so a question scoped to Fisch
|
| 67 |
+
or Royal cannot be answered here and must be refused rather than answered company-wide.
|
| 68 |
+
β Adjustments (`inventory` locations) and scrap are ~30% of IN and ~20% of OUT. `stock_in` and
|
| 69 |
+
`stock_out` INCLUDE them, because a physical arrival is an arrival however it was booked; use
|
| 70 |
+
the `source`/`destination` dims to separate them, and say which you did.
|
| 71 |
+
For SALES movement use sales_lines (revenue and units ordered); this topic is the warehouse,
|
| 72 |
+
and the two legitimately disagree because an order is not a shipment.
|
platform/modules/agent.py
CHANGED
|
@@ -1,277 +1,385 @@
|
|
| 1 |
-
"""Agent module β per-agent (res.partner.agent_ids) analytics.
|
| 2 |
-
|
| 3 |
-
An *agent* owns a **book** of customers (the same attribute the Customers module slices by). This
|
| 4 |
-
module reports that book the way Sales/Customers/SKU report the whole company: a period scorecard
|
| 5 |
-
with custom date windows (Today / WTD / Last week / MTD / QTD / YTD / any custom range), a sales
|
| 6 |
-
trend, returns, top SKUs (with profit/order) and the FULL customer list β INCLUDING inactive
|
| 7 |
-
accounts (no recent orders) so a rep sees who they've stopped selling to.
|
| 8 |
-
|
| 9 |
-
Scope: reuses the Sales `order_domain` (Fisch+Royal, excluded accounts removed, state sale/done)
|
| 10 |
-
so numbers tie to every other module. Returns are consolidated (credit notes aren't BU-tagged);
|
| 11 |
-
everything else is BU-filterable via team_id.
|
| 12 |
-
"""
|
| 13 |
-
import sys
|
| 14 |
-
import datetime as dt
|
| 15 |
-
from pathlib import Path
|
| 16 |
-
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 17 |
-
import core.odoo as O
|
| 18 |
-
import core.periods as P
|
| 19 |
-
import modules.sales as sales_mod
|
| 20 |
-
import modules.customers as cust_mod
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
def options(t=None, team_id=None):
|
| 24 |
-
"""Agent names with book activity β for the page/drawer picker."""
|
| 25 |
-
return cust_mod.agent_options(t, team_id)
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
def _book(name):
|
| 29 |
-
"""frozenset of every partner id assigned to the agent (incl. inactive). None only for 'All'."""
|
| 30 |
-
return cust_mod.agent_partner_ids(name)
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
def _rev(date_from, date_to, team_id, book):
|
| 34 |
-
return O.sum_field('sale.order', sales_mod.order_domain(date_from, date_to, team_id, partner_ids=book),
|
| 35 |
-
'amount_untaxed')
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
def _orders(date_from, date_to, team_id, book):
|
| 39 |
-
return O.get_odoo().search_count('sale.order', sales_mod.order_domain(date_from, date_to, team_id, partner_ids=book))
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
def _custs(date_from, date_to, team_id, book):
|
| 43 |
-
return O.distinct_count('sale.order', sales_mod.order_domain(date_from, date_to, team_id, partner_ids=book),
|
| 44 |
-
'partner_id')
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
# ------------------------------------------------------------ period scorecard (custom dating)
|
| 48 |
-
# Today / WTD / Last week / MTD / QTD / YTD β each carries its window so the UI can decompose it
|
| 49 |
-
# and so "what did this agent sell this/last week" is a click, not a date-math exercise.
|
| 50 |
-
_PERIODS = [('Today', 'today'), ('Week to date', 'wtd'), ('Last week', 'lwk'),
|
| 51 |
-
('Month to date', 'mtd'), ('Quarter to date', 'qtd'), ('Year to date', 'ytd')]
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
def _window(key, t):
|
| 55 |
-
if key == 'today':
|
| 56 |
-
return P._d(t), P._d(t)
|
| 57 |
-
if key == 'lwk': # the full prior MonβSun week
|
| 58 |
-
f, _tt = P.wtd(t)
|
| 59 |
-
start = dt.date.fromisoformat(f) - dt.timedelta(days=7)
|
| 60 |
-
return start.isoformat(), (dt.date.fromisoformat(f) - dt.timedelta(days=1)).isoformat()
|
| 61 |
-
return {'wtd': P.wtd, 'mtd': P.mtd, 'qtd': P.qtd, 'ytd': P.ytd}[key](t)
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
def scorecard(name, t=None, team_id=None):
|
| 65 |
-
"""Book revenue (+ YoY same-period), orders for Today / WTD / Last week / MTD / QTD / YTD."""
|
| 66 |
-
t = t or P.today()
|
| 67 |
-
book = _book(name)
|
| 68 |
-
out = []
|
| 69 |
-
for label, key in _PERIODS:
|
| 70 |
-
f, tt = _window(key, t)
|
| 71 |
-
wk = key in ('today', 'wtd', 'lwk') # weekday-align the short windows' LY compare
|
| 72 |
-
cf, ct = P.shift_year(f, tt, weeks=wk)
|
| 73 |
-
rev, rev_ly = _rev(f, tt, team_id, book), _rev(cf, ct, team_id, book)
|
| 74 |
-
out.append({'key': key, 'label': label, 'date_from': f, 'date_to': tt, 'cmp_from': cf, 'cmp_to': ct,
|
| 75 |
-
'revenue': rev, 'revenue_ly': rev_ly, 'yoy_pct': P.yoy_pct(rev, rev_ly),
|
| 76 |
-
'orders': _orders(f, tt, team_id, book)})
|
| 77 |
-
return out
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
def headline(name, date_from, date_to, team_id=None):
|
| 81 |
-
"""Book KPIs for an ARBITRARY window (custom dating): revenue + YoY (same window LY), orders,
|
| 82 |
-
active customers, AOV and returns $ / return rate."""
|
| 83 |
-
book = _book(name)
|
| 84 |
-
cf, ct = P.shift_year(date_from, date_to, weeks=False)
|
| 85 |
-
rev, rev_ly = _rev(date_from, date_to, team_id, book), _rev(cf, ct, team_id, book)
|
| 86 |
-
orders = _orders(date_from, date_to, team_id, book)
|
| 87 |
-
ret = sales_mod._returns_amt(date_from, date_to, book)
|
| 88 |
-
return {'date_from': date_from, 'date_to': date_to, 'cmp_from': cf, 'cmp_to': ct,
|
| 89 |
-
'revenue': rev, 'revenue_ly': rev_ly,
|
| 90 |
-
'yoy_pct': P.yoy_pct(rev, rev_ly), 'orders': orders,
|
| 91 |
-
'customers': _custs(date_from, date_to, team_id, book), 'aov': (rev / orders) if orders else 0.0,
|
| 92 |
-
'returns': ret, 'return_rate_pct': (ret / rev * 100.0) if rev else 0.0}
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
# ------------------------------------------------------------ sales trend
|
| 96 |
-
def _book_monthly_rev(book, date_from, date_to, team_id=None):
|
| 97 |
-
g = O.read_group('sale.order', sales_mod.order_domain(date_from, date_to, team_id, partner_ids=book),
|
| 98 |
-
['amount_untaxed:sum'], ['date_order:month'], lazy=False)
|
| 99 |
-
out = {}
|
| 100 |
-
for r in g:
|
| 101 |
-
ym = ((r.get('__range') or {}).get('date_order:month') or {}).get('from', '')[:7]
|
| 102 |
-
if ym:
|
| 103 |
-
out[ym] = r.get('amount_untaxed') or 0.0
|
| 104 |
-
return out
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
def monthly(name, n=13, t=None, team_id=None):
|
| 108 |
-
"""Book sales per month vs the same month last year (one month-grouped query over 2 years)."""
|
| 109 |
-
t = t or P.today()
|
| 110 |
-
book = _book(name)
|
| 111 |
-
mrev = _book_monthly_rev(book, dt.date(t.year - 2, t.month, 1).isoformat(), t.isoformat(), team_id)
|
| 112 |
-
rows = []
|
| 113 |
-
for ym, _s, _e in P.month_starts(n, t):
|
| 114 |
-
y, m = int(ym[:4]) - 1, int(ym[5:7])
|
| 115 |
-
this, last = mrev.get(ym, 0.0), mrev.get(f'{y:04d}-{m:02d}', 0.0)
|
| 116 |
-
rows.append({'month': ym, 'revenue': this, 'revenue_ly': last, 'yoy_pct': P.yoy_pct(this, last)})
|
| 117 |
-
return rows
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
# ------------------------------------------------------------ full customer book (incl. inactive)
|
| 121 |
-
def customers(name, t=None, team_id=None):
|
| 122 |
-
"""EVERY customer in the agent's book, including inactive accounts (no YTD/LY orders) β those
|
| 123 |
-
show $0 with status 'Inactive'/'Dormant'. Each row is clickable to the customer drawer and
|
| 124 |
-
carries recency so a rep can see who's gone quiet. Sorted by YTD revenue desc (inactive last)."""
|
| 125 |
-
t = t or P.today()
|
| 126 |
-
book = _book(name)
|
| 127 |
-
yf, yt = P.ytd(t)
|
| 128 |
-
lf, lt = P.ytd_last_year(t)
|
| 129 |
-
this = cust_mod._cust_rev(yf, yt, team_id, book)
|
| 130 |
-
last = cust_mod._cust_rev(lf, lt, team_id, book)
|
| 131 |
-
lastord = cust_mod._last_order_dates(None, None, team_id, book) # all-time last order = recency
|
| 132 |
-
ids = list(book) if book is not None else list(set(this) | set(last))
|
| 133 |
-
attrs = cust_mod._partner_attrs(set(ids))
|
| 134 |
-
namemap = {r['id']: r.get('name') for r in O.search_read('res.partner', [('id', 'in', ids)], ['name'])}
|
| 135 |
-
rows = []
|
| 136 |
-
for p in ids:
|
| 137 |
-
tr = this.get(p, {}).get('rev', 0.0)
|
| 138 |
-
lr = last.get(p, {}).get('rev', 0.0)
|
| 139 |
-
a = attrs.get(p, {})
|
| 140 |
-
lo = lastord.get(p, '')
|
| 141 |
-
recency = (t - dt.date.fromisoformat(lo)).days if lo else None
|
| 142 |
-
status = 'Active' if tr > 0 else ('Dormant' if (lr > 0 or lo) else 'Inactive')
|
| 143 |
-
rows.append({'pid': p, 'customer': namemap.get(p) or (this.get(p) or last.get(p) or {}).get('name', '?'),
|
| 144 |
-
'rev_ytd': tr, 'rev_ly': lr, 'change': tr - lr, 'yoy_pct': P.yoy_pct(tr, lr),
|
| 145 |
-
'orders': this.get(p, {}).get('orders', 0), 'last_order': lo, 'recency_days': recency,
|
| 146 |
-
'status': status, 'city': a.get('city', '(none)'), 'state': a.get('state', '(none)'),
|
| 147 |
-
'agent': a.get('agent', name)})
|
| 148 |
-
rows.sort(key=lambda r: (r['rev_ytd'] <= 0, -r['rev_ytd'], -(r['rev_ly'])))
|
| 149 |
-
return rows
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
# ------------------------------------------------------------ top SKUs (with profit/order)
|
| 153 |
-
def top_skus(name, t=None, team_id=None, top=30):
|
| 154 |
-
"""The book's top SKUs YTD (line-level), each with revenue, units, margin and profit/order."""
|
| 155 |
-
t = t or P.today()
|
| 156 |
-
book = _book(name)
|
| 157 |
-
if book is not None and not book:
|
| 158 |
-
return []
|
| 159 |
-
yf, yt = P.ytd(t)
|
| 160 |
-
lex = [('order_partner_id', 'in', list(book))] if book is not None else None
|
| 161 |
-
return sales_mod.decompose(yf, yt, team_id, line_extra=lex, top=top)['skus']
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
# ------------------------------------------------------------ returns (book-scoped, consolidated)
|
| 165 |
-
def returns_trend(name, n=13, t=None):
|
| 166 |
-
return sales_mod.returns_monthly(n, t, partner_ids=_book(name))
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
def returns_headline(name, t=None):
|
| 170 |
-
return sales_mod.returns_headline(t, partner_ids=_book(name))
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
# ------------------------------------------------------------ all-agents rollup (the page table)
|
| 174 |
-
def _returns_by_agent(t=None):
|
| 175 |
-
"""{agent_name: returns$ YTD} β credit notes mapped to each customer's agent."""
|
| 176 |
-
by_p = sales_mod.returns_by_partner(t)
|
| 177 |
-
attrs = cust_mod._partner_attrs(list(by_p))
|
| 178 |
-
agg = {}
|
| 179 |
-
for pid, amt in by_p.items():
|
| 180 |
-
a = (attrs.get(pid) or {}).get('agent') or '(none)'
|
| 181 |
-
agg[a] = agg.get(a, 0.0) + amt
|
| 182 |
-
return agg
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
def rollup(t=None, team_id=None):
|
| 186 |
-
"""Every agent ranked by YTD book revenue (+ YoY, customers, orders) with returns $ and return
|
| 187 |
-
rate. Reuses the Customers MECE agent rollup, so Ξ£(agents) == total YTD revenue."""
|
| 188 |
-
rows = cust_mod.by_dimension('agent', t, team_id=team_id)
|
| 189 |
-
ret = _returns_by_agent(t)
|
| 190 |
-
for r in rows:
|
| 191 |
-
r['agent'] = r['group']
|
| 192 |
-
r['returns'] = ret.get(r['group'], 0.0)
|
| 193 |
-
r['return_rate_pct'] = (r['returns'] / r['revenue'] * 100.0) if r.get('revenue') else 0.0
|
| 194 |
-
return rows
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
# ------------------------------------------------------------ VALIDATION
|
| 198 |
-
def validate(t=None, team_id=None):
|
| 199 |
-
"""Reconcile the agent rollup to Odoo. (1) Ξ£(agent book revenue) == total YTD revenue β the
|
| 200 |
-
rollup is MECE over customers. (2) A sampled agent's scorecard YTD == its headline YTD."""
|
| 201 |
-
t = t or P.today()
|
| 202 |
-
yf, yt = P.ytd(t)
|
| 203 |
-
checks = []
|
| 204 |
-
rows = rollup(t, team_id=team_id)
|
| 205 |
-
agent_sum = sum(r['revenue'] for r in rows)
|
| 206 |
-
total = O.sum_field('sale.order', sales_mod.order_domain(yf, yt, team_id), 'amount_untaxed')
|
| 207 |
-
checks.append({'check': 'YTD revenue: Ξ£(agent book) == total', 'a': round(agent_sum, 2),
|
| 208 |
-
'b': round(total, 2), 'gap': round(agent_sum - total, 2),
|
| 209 |
-
'ok': abs(agent_sum - total) <= max(1.0, 0.001 * (total or 1))})
|
| 210 |
-
# sampled agent: scorecard YTD == headline YTD for the same window
|
| 211 |
-
sample = next((r['agent'] for r in rows if r['agent'] not in ('(none)',)), None)
|
| 212 |
-
if sample:
|
| 213 |
-
sc_ytd = next((s['revenue'] for s in scorecard(sample, t, team_id) if s['key'] == 'ytd'), 0.0)
|
| 214 |
-
hl = headline(sample, yf, yt, team_id)['revenue']
|
| 215 |
-
checks.append({'check': f'Agent "{sample}": scorecard YTD == headline YTD', 'a': round(sc_ytd, 2),
|
| 216 |
-
'b': round(hl, 2), 'gap': round(sc_ytd - hl, 2), 'ok': abs(sc_ytd - hl) <= 1.0})
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Agent module β per-agent (res.partner.agent_ids) analytics.
|
| 2 |
+
|
| 3 |
+
An *agent* owns a **book** of customers (the same attribute the Customers module slices by). This
|
| 4 |
+
module reports that book the way Sales/Customers/SKU report the whole company: a period scorecard
|
| 5 |
+
with custom date windows (Today / WTD / Last week / MTD / QTD / YTD / any custom range), a sales
|
| 6 |
+
trend, returns, top SKUs (with profit/order) and the FULL customer list β INCLUDING inactive
|
| 7 |
+
accounts (no recent orders) so a rep sees who they've stopped selling to.
|
| 8 |
+
|
| 9 |
+
Scope: reuses the Sales `order_domain` (Fisch+Royal, excluded accounts removed, state sale/done)
|
| 10 |
+
so numbers tie to every other module. Returns are consolidated (credit notes aren't BU-tagged);
|
| 11 |
+
everything else is BU-filterable via team_id.
|
| 12 |
+
"""
|
| 13 |
+
import sys
|
| 14 |
+
import datetime as dt
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 17 |
+
import core.odoo as O
|
| 18 |
+
import core.periods as P
|
| 19 |
+
import modules.sales as sales_mod
|
| 20 |
+
import modules.customers as cust_mod
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def options(t=None, team_id=None):
|
| 24 |
+
"""Agent names with book activity β for the page/drawer picker."""
|
| 25 |
+
return cust_mod.agent_options(t, team_id)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _book(name):
|
| 29 |
+
"""frozenset of every partner id assigned to the agent (incl. inactive). None only for 'All'."""
|
| 30 |
+
return cust_mod.agent_partner_ids(name)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _rev(date_from, date_to, team_id, book):
|
| 34 |
+
return O.sum_field('sale.order', sales_mod.order_domain(date_from, date_to, team_id, partner_ids=book),
|
| 35 |
+
'amount_untaxed')
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _orders(date_from, date_to, team_id, book):
|
| 39 |
+
return O.get_odoo().search_count('sale.order', sales_mod.order_domain(date_from, date_to, team_id, partner_ids=book))
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _custs(date_from, date_to, team_id, book):
|
| 43 |
+
return O.distinct_count('sale.order', sales_mod.order_domain(date_from, date_to, team_id, partner_ids=book),
|
| 44 |
+
'partner_id')
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
# ------------------------------------------------------------ period scorecard (custom dating)
|
| 48 |
+
# Today / WTD / Last week / MTD / QTD / YTD β each carries its window so the UI can decompose it
|
| 49 |
+
# and so "what did this agent sell this/last week" is a click, not a date-math exercise.
|
| 50 |
+
_PERIODS = [('Today', 'today'), ('Week to date', 'wtd'), ('Last week', 'lwk'),
|
| 51 |
+
('Month to date', 'mtd'), ('Quarter to date', 'qtd'), ('Year to date', 'ytd')]
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _window(key, t):
|
| 55 |
+
if key == 'today':
|
| 56 |
+
return P._d(t), P._d(t)
|
| 57 |
+
if key == 'lwk': # the full prior MonβSun week
|
| 58 |
+
f, _tt = P.wtd(t)
|
| 59 |
+
start = dt.date.fromisoformat(f) - dt.timedelta(days=7)
|
| 60 |
+
return start.isoformat(), (dt.date.fromisoformat(f) - dt.timedelta(days=1)).isoformat()
|
| 61 |
+
return {'wtd': P.wtd, 'mtd': P.mtd, 'qtd': P.qtd, 'ytd': P.ytd}[key](t)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def scorecard(name, t=None, team_id=None):
|
| 65 |
+
"""Book revenue (+ YoY same-period), orders for Today / WTD / Last week / MTD / QTD / YTD."""
|
| 66 |
+
t = t or P.today()
|
| 67 |
+
book = _book(name)
|
| 68 |
+
out = []
|
| 69 |
+
for label, key in _PERIODS:
|
| 70 |
+
f, tt = _window(key, t)
|
| 71 |
+
wk = key in ('today', 'wtd', 'lwk') # weekday-align the short windows' LY compare
|
| 72 |
+
cf, ct = P.shift_year(f, tt, weeks=wk)
|
| 73 |
+
rev, rev_ly = _rev(f, tt, team_id, book), _rev(cf, ct, team_id, book)
|
| 74 |
+
out.append({'key': key, 'label': label, 'date_from': f, 'date_to': tt, 'cmp_from': cf, 'cmp_to': ct,
|
| 75 |
+
'revenue': rev, 'revenue_ly': rev_ly, 'yoy_pct': P.yoy_pct(rev, rev_ly),
|
| 76 |
+
'orders': _orders(f, tt, team_id, book)})
|
| 77 |
+
return out
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def headline(name, date_from, date_to, team_id=None):
|
| 81 |
+
"""Book KPIs for an ARBITRARY window (custom dating): revenue + YoY (same window LY), orders,
|
| 82 |
+
active customers, AOV and returns $ / return rate."""
|
| 83 |
+
book = _book(name)
|
| 84 |
+
cf, ct = P.shift_year(date_from, date_to, weeks=False)
|
| 85 |
+
rev, rev_ly = _rev(date_from, date_to, team_id, book), _rev(cf, ct, team_id, book)
|
| 86 |
+
orders = _orders(date_from, date_to, team_id, book)
|
| 87 |
+
ret = sales_mod._returns_amt(date_from, date_to, book)
|
| 88 |
+
return {'date_from': date_from, 'date_to': date_to, 'cmp_from': cf, 'cmp_to': ct,
|
| 89 |
+
'revenue': rev, 'revenue_ly': rev_ly,
|
| 90 |
+
'yoy_pct': P.yoy_pct(rev, rev_ly), 'orders': orders,
|
| 91 |
+
'customers': _custs(date_from, date_to, team_id, book), 'aov': (rev / orders) if orders else 0.0,
|
| 92 |
+
'returns': ret, 'return_rate_pct': (ret / rev * 100.0) if rev else 0.0}
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
# ------------------------------------------------------------ sales trend
|
| 96 |
+
def _book_monthly_rev(book, date_from, date_to, team_id=None):
|
| 97 |
+
g = O.read_group('sale.order', sales_mod.order_domain(date_from, date_to, team_id, partner_ids=book),
|
| 98 |
+
['amount_untaxed:sum'], ['date_order:month'], lazy=False)
|
| 99 |
+
out = {}
|
| 100 |
+
for r in g:
|
| 101 |
+
ym = ((r.get('__range') or {}).get('date_order:month') or {}).get('from', '')[:7]
|
| 102 |
+
if ym:
|
| 103 |
+
out[ym] = r.get('amount_untaxed') or 0.0
|
| 104 |
+
return out
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def monthly(name, n=13, t=None, team_id=None):
|
| 108 |
+
"""Book sales per month vs the same month last year (one month-grouped query over 2 years)."""
|
| 109 |
+
t = t or P.today()
|
| 110 |
+
book = _book(name)
|
| 111 |
+
mrev = _book_monthly_rev(book, dt.date(t.year - 2, t.month, 1).isoformat(), t.isoformat(), team_id)
|
| 112 |
+
rows = []
|
| 113 |
+
for ym, _s, _e in P.month_starts(n, t):
|
| 114 |
+
y, m = int(ym[:4]) - 1, int(ym[5:7])
|
| 115 |
+
this, last = mrev.get(ym, 0.0), mrev.get(f'{y:04d}-{m:02d}', 0.0)
|
| 116 |
+
rows.append({'month': ym, 'revenue': this, 'revenue_ly': last, 'yoy_pct': P.yoy_pct(this, last)})
|
| 117 |
+
return rows
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
# ------------------------------------------------------------ full customer book (incl. inactive)
|
| 121 |
+
def customers(name, t=None, team_id=None):
|
| 122 |
+
"""EVERY customer in the agent's book, including inactive accounts (no YTD/LY orders) β those
|
| 123 |
+
show $0 with status 'Inactive'/'Dormant'. Each row is clickable to the customer drawer and
|
| 124 |
+
carries recency so a rep can see who's gone quiet. Sorted by YTD revenue desc (inactive last)."""
|
| 125 |
+
t = t or P.today()
|
| 126 |
+
book = _book(name)
|
| 127 |
+
yf, yt = P.ytd(t)
|
| 128 |
+
lf, lt = P.ytd_last_year(t)
|
| 129 |
+
this = cust_mod._cust_rev(yf, yt, team_id, book)
|
| 130 |
+
last = cust_mod._cust_rev(lf, lt, team_id, book)
|
| 131 |
+
lastord = cust_mod._last_order_dates(None, None, team_id, book) # all-time last order = recency
|
| 132 |
+
ids = list(book) if book is not None else list(set(this) | set(last))
|
| 133 |
+
attrs = cust_mod._partner_attrs(set(ids))
|
| 134 |
+
namemap = {r['id']: r.get('name') for r in O.search_read('res.partner', [('id', 'in', ids)], ['name'])}
|
| 135 |
+
rows = []
|
| 136 |
+
for p in ids:
|
| 137 |
+
tr = this.get(p, {}).get('rev', 0.0)
|
| 138 |
+
lr = last.get(p, {}).get('rev', 0.0)
|
| 139 |
+
a = attrs.get(p, {})
|
| 140 |
+
lo = lastord.get(p, '')
|
| 141 |
+
recency = (t - dt.date.fromisoformat(lo)).days if lo else None
|
| 142 |
+
status = 'Active' if tr > 0 else ('Dormant' if (lr > 0 or lo) else 'Inactive')
|
| 143 |
+
rows.append({'pid': p, 'customer': namemap.get(p) or (this.get(p) or last.get(p) or {}).get('name', '?'),
|
| 144 |
+
'rev_ytd': tr, 'rev_ly': lr, 'change': tr - lr, 'yoy_pct': P.yoy_pct(tr, lr),
|
| 145 |
+
'orders': this.get(p, {}).get('orders', 0), 'last_order': lo, 'recency_days': recency,
|
| 146 |
+
'status': status, 'city': a.get('city', '(none)'), 'state': a.get('state', '(none)'),
|
| 147 |
+
'agent': a.get('agent', name)})
|
| 148 |
+
rows.sort(key=lambda r: (r['rev_ytd'] <= 0, -r['rev_ytd'], -(r['rev_ly'])))
|
| 149 |
+
return rows
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
# ------------------------------------------------------------ top SKUs (with profit/order)
|
| 153 |
+
def top_skus(name, t=None, team_id=None, top=30):
|
| 154 |
+
"""The book's top SKUs YTD (line-level), each with revenue, units, margin and profit/order."""
|
| 155 |
+
t = t or P.today()
|
| 156 |
+
book = _book(name)
|
| 157 |
+
if book is not None and not book:
|
| 158 |
+
return []
|
| 159 |
+
yf, yt = P.ytd(t)
|
| 160 |
+
lex = [('order_partner_id', 'in', list(book))] if book is not None else None
|
| 161 |
+
return sales_mod.decompose(yf, yt, team_id, line_extra=lex, top=top)['skus']
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
# ------------------------------------------------------------ returns (book-scoped, consolidated)
|
| 165 |
+
def returns_trend(name, n=13, t=None):
|
| 166 |
+
return sales_mod.returns_monthly(n, t, partner_ids=_book(name))
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def returns_headline(name, t=None):
|
| 170 |
+
return sales_mod.returns_headline(t, partner_ids=_book(name))
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
# ------------------------------------------------------------ all-agents rollup (the page table)
|
| 174 |
+
def _returns_by_agent(t=None):
|
| 175 |
+
"""{agent_name: returns$ YTD} β credit notes mapped to each customer's agent."""
|
| 176 |
+
by_p = sales_mod.returns_by_partner(t)
|
| 177 |
+
attrs = cust_mod._partner_attrs(list(by_p))
|
| 178 |
+
agg = {}
|
| 179 |
+
for pid, amt in by_p.items():
|
| 180 |
+
a = (attrs.get(pid) or {}).get('agent') or '(none)'
|
| 181 |
+
agg[a] = agg.get(a, 0.0) + amt
|
| 182 |
+
return agg
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def rollup(t=None, team_id=None):
|
| 186 |
+
"""Every agent ranked by YTD book revenue (+ YoY, customers, orders) with returns $ and return
|
| 187 |
+
rate. Reuses the Customers MECE agent rollup, so Ξ£(agents) == total YTD revenue."""
|
| 188 |
+
rows = cust_mod.by_dimension('agent', t, team_id=team_id)
|
| 189 |
+
ret = _returns_by_agent(t)
|
| 190 |
+
for r in rows:
|
| 191 |
+
r['agent'] = r['group']
|
| 192 |
+
r['returns'] = ret.get(r['group'], 0.0)
|
| 193 |
+
r['return_rate_pct'] = (r['returns'] / r['revenue'] * 100.0) if r.get('revenue') else 0.0
|
| 194 |
+
return rows
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
# ------------------------------------------------------------ VALIDATION
|
| 198 |
+
def validate(t=None, team_id=None):
|
| 199 |
+
"""Reconcile the agent rollup to Odoo. (1) Ξ£(agent book revenue) == total YTD revenue β the
|
| 200 |
+
rollup is MECE over customers. (2) A sampled agent's scorecard YTD == its headline YTD."""
|
| 201 |
+
t = t or P.today()
|
| 202 |
+
yf, yt = P.ytd(t)
|
| 203 |
+
checks = []
|
| 204 |
+
rows = rollup(t, team_id=team_id)
|
| 205 |
+
agent_sum = sum(r['revenue'] for r in rows)
|
| 206 |
+
total = O.sum_field('sale.order', sales_mod.order_domain(yf, yt, team_id), 'amount_untaxed')
|
| 207 |
+
checks.append({'check': 'YTD revenue: Ξ£(agent book) == total', 'a': round(agent_sum, 2),
|
| 208 |
+
'b': round(total, 2), 'gap': round(agent_sum - total, 2),
|
| 209 |
+
'ok': abs(agent_sum - total) <= max(1.0, 0.001 * (total or 1))})
|
| 210 |
+
# sampled agent: scorecard YTD == headline YTD for the same window
|
| 211 |
+
sample = next((r['agent'] for r in rows if r['agent'] not in ('(none)',)), None)
|
| 212 |
+
if sample:
|
| 213 |
+
sc_ytd = next((s['revenue'] for s in scorecard(sample, t, team_id) if s['key'] == 'ytd'), 0.0)
|
| 214 |
+
hl = headline(sample, yf, yt, team_id)['revenue']
|
| 215 |
+
checks.append({'check': f'Agent "{sample}": scorecard YTD == headline YTD', 'a': round(sc_ytd, 2),
|
| 216 |
+
'b': round(hl, 2), 'gap': round(sc_ytd - hl, 2), 'ok': abs(sc_ytd - hl) <= 1.0})
|
| 217 |
+
checks.extend(validate_measures(t=t, team_id=team_id))
|
| 218 |
+
return checks
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def validate_measures(t=None, team_id=None, days=90):
|
| 222 |
+
"""ββ W37-T12 β the minted PER-AGENT lookback columns, against a DIRECT Odoo aggregate.
|
| 223 |
+
|
| 224 |
+
β WHICH AGENT SOURCE, AND THE TICKET REQUIRES IT SAID OUT LOUD: this reconciles ROUTE 1, the
|
| 225 |
+
CUSTOMER-MASTER BOOK (`res.partner.agent_ids` -> the mirror's `res_partner.agent_id`, which is
|
| 226 |
+
`sales_lines`' `agent` dim). It is NOT the OCA commission route below β measured 2026-08-19,
|
| 227 |
+
9 agents carry route-1 revenue against 12 on commission lines and 17 carrying the flag, so the
|
| 228 |
+
two produce materially different rankings and a check that mixed them would be comparing two
|
| 229 |
+
different questions and calling the gap an error.
|
| 230 |
+
|
| 231 |
+
β THE ORACLE IS THE ORDER HEADER, not the mirror the columns are served from. `sale.order`
|
| 232 |
+
grouped by `partner_id`, mapped to each customer's agent CLIENT-SIDE β a different model, a
|
| 233 |
+
different grain and a different code path from `store_query`'s line-level sum, so agreement
|
| 234 |
+
between them is evidence rather than tautology.
|
| 235 |
+
β Windowed to the MIRROR'S newest order, like `product_data.validate_measures`, so the
|
| 236 |
+
residual is about EDITS to a shared period and not about orders the mirror has never seen.
|
| 237 |
+
"""
|
| 238 |
+
from harness import datastore as DS
|
| 239 |
+
from harness import semantic as sem
|
| 240 |
+
|
| 241 |
+
t = t or P.today()
|
| 242 |
+
checks = []
|
| 243 |
+
try:
|
| 244 |
+
if not DS.ready():
|
| 245 |
+
return [{'check': 'agent lookback measures reconcile to Odoo', 'a': 'no mirror',
|
| 246 |
+
'b': '-', 'ok': False,
|
| 247 |
+
'detail': 'the tenant store is not readable, so this is UNPROVEN, which standing rule '
|
| 248 |
+
'8 does not accept as green'}]
|
| 249 |
+
except Exception as e: # noqa: BLE001
|
| 250 |
+
return [{'check': 'agent lookback measures reconcile to Odoo', 'a': type(e).__name__,
|
| 251 |
+
'b': '-', 'ok': False, 'detail': str(e)[:200]}]
|
| 252 |
+
|
| 253 |
+
offer = sem.entity_measures('odoo_agents')
|
| 254 |
+
checks.append({'check': 'the agent measure OFFER is non-empty and every key resolves '
|
| 255 |
+
'(owner item 4 / R1)',
|
| 256 |
+
'a': len(offer), 'b': '>0', 'ok': bool(offer),
|
| 257 |
+
'detail': {'keys': [m['key'] for m in offer],
|
| 258 |
+
'refused': sem.entity_measure_refusals('odoo_agents')}})
|
| 259 |
+
if not offer:
|
| 260 |
+
return checks
|
| 261 |
+
|
| 262 |
+
con = DS.ro_cursor()
|
| 263 |
+
try:
|
| 264 |
+
newest = con.execute('SELECT max(date_order) FROM sale_order').fetchone()
|
| 265 |
+
finally:
|
| 266 |
+
con.close()
|
| 267 |
+
d_to = t - dt.timedelta(days=2)
|
| 268 |
+
if newest and newest[0]:
|
| 269 |
+
try:
|
| 270 |
+
d_to = min(d_to, dt.date.fromisoformat(str(newest[0])[:10]) - dt.timedelta(days=1))
|
| 271 |
+
except ValueError:
|
| 272 |
+
pass
|
| 273 |
+
d_from = d_to - dt.timedelta(days=days)
|
| 274 |
+
DF, DT = d_from.isoformat(), d_to.isoformat()
|
| 275 |
+
|
| 276 |
+
ours = sem.entity_measure_values('odoo_agents', ['revenue'], date_from=DF, date_to=DT,
|
| 277 |
+
team_id=team_id, offer=offer)
|
| 278 |
+
# THE ORACLE β order headers, grouped by customer, mapped to that customer's agent here.
|
| 279 |
+
o = O.get_odoo()
|
| 280 |
+
grp = o.read_group('sale.order', sales_mod.order_domain(DF, DT, team_id),
|
| 281 |
+
['partner_id', 'amount_untaxed:sum'], ['partner_id'], lazy=False)
|
| 282 |
+
pids = sorted({r['partner_id'][0] for r in grp if r.get('partner_id')})
|
| 283 |
+
agent_of = {}
|
| 284 |
+
for i in range(0, len(pids), 500):
|
| 285 |
+
for p in o.search_read('res.partner', [('id', 'in', pids[i:i + 500])],
|
| 286 |
+
['id', 'agent_ids']):
|
| 287 |
+
ag = (p.get('agent_ids') or [])
|
| 288 |
+
if ag:
|
| 289 |
+
agent_of[p['id']] = ag[0] # the Customers-module convention: agent_ids[0]
|
| 290 |
+
theirs = {}
|
| 291 |
+
for r in grp:
|
| 292 |
+
if not r.get('partner_id'):
|
| 293 |
+
continue
|
| 294 |
+
a = agent_of.get(r['partner_id'][0])
|
| 295 |
+
if a is not None:
|
| 296 |
+
theirs[a] = theirs.get(a, 0.0) + r['amount_untaxed']
|
| 297 |
+
|
| 298 |
+
ours_tot = round(sum(c.get('revenue', 0) for c in ours.values()), 2)
|
| 299 |
+
theirs_tot = round(sum(theirs.values()), 2)
|
| 300 |
+
# β ORDER-HEADER vs LINE-SUM is a REAL basis difference (an order's untaxed total includes
|
| 301 |
+
# lines this topic's service filter drops), so the tolerance is a stated 3% rather than a
|
| 302 |
+
# cent β and the FIGURE is reported so a drift is readable instead of absorbed.
|
| 303 |
+
gap = ours_tot - theirs_tot
|
| 304 |
+
checks.append({
|
| 305 |
+
'check': 'per-agent revenue (BOOK route) vs an INDEPENDENT Odoo order-header aggregate '
|
| 306 |
+
'mapped through res.partner.agent_ids',
|
| 307 |
+
'a': ours_tot, 'b': theirs_tot, 'gap': round(gap, 2),
|
| 308 |
+
'ok': bool(theirs_tot) and abs(gap) <= 0.03 * theirs_tot,
|
| 309 |
+
'detail': {'window': [DF, DT], 'agents_ours': len(ours), 'agents_theirs': len(theirs),
|
| 310 |
+
'gap_pct': round(gap / theirs_tot * 100, 3) if theirs_tot else None,
|
| 311 |
+
'route': 'customer-master book (res.partner.agent_ids), NOT the OCA '
|
| 312 |
+
'commission table; the two name different people'}})
|
| 313 |
+
# β AND PER AGENT, because a total can agree while every row is keyed wrong β the join-key
|
| 314 |
+
# trap contract C1 names. Here the key is the `res.partner` id at both ends.
|
| 315 |
+
off_by = sorted(((abs(ours.get(a, {}).get('revenue', 0.0) - v), a)
|
| 316 |
+
for a, v in theirs.items() if v), reverse=True)
|
| 317 |
+
bad = [(a, round(ours.get(a, {}).get('revenue', 0.0), 2), round(theirs[a], 2))
|
| 318 |
+
for d, a in off_by if d > 0.03 * theirs[a]]
|
| 319 |
+
checks.append({
|
| 320 |
+
'check': 'each agent\'s own figure ties (the C1 join-key test: a wrong key agrees in '
|
| 321 |
+
'total and disagrees on every row)',
|
| 322 |
+
'a': len(bad), 'b': 0, 'ok': not bad,
|
| 323 |
+
'detail': {'worst': [(a, ours_v, th_v) for a, ours_v, th_v in bad[:5]],
|
| 324 |
+
'agents_compared': len(theirs)}})
|
| 325 |
+
return checks
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
# ------------------------------------------------------------ INVOICE-LINE ATTRIBUTION (2026-07-28)
|
| 329 |
+
# A SECOND agent source. Everything above this line attributes by BOOK β the customer's assigned
|
| 330 |
+
# agent (res.partner.agent_ids) β over confirmed SALES ORDERS. This section attributes per INVOICE
|
| 331 |
+
# LINE, from the OCA sale-commission module, via the semantic layer (topics invoice_lines /
|
| 332 |
+
# commission_lines). The two disagree on purpose and answer different questions:
|
| 333 |
+
#
|
| 334 |
+
# book -> "whose customer is this / who owns the relationship" (order basis)
|
| 335 |
+
# invoice -> "what was actually credited to an agent on the billing" + the ONLY source that can
|
| 336 |
+
# say what is NOT allocated to an agent (invoice basis)
|
| 337 |
+
#
|
| 338 |
+
# β A NAME ON A COMMISSION LINE IS NOT NECESSARILY AN AGENT β `res.partner.agent` is the flag.
|
| 339 |
+
# "Anna" and "Shantal Erlich" are internal SALESPEOPLE who carry commission lines; the `agent`
|
| 340 |
+
# dim excludes them and `include_salespeople` folds them back in as a clearly-labelled variant.
|
| 341 |
+
# See [[invoice-line-agent-commission]].
|
| 342 |
+
|
| 343 |
+
_ALLOC_LABEL = {'agent': 'Allocated to an agent', 'salesperson': 'Salesperson only',
|
| 344 |
+
'none': 'Not allocated'}
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
def invoice_line_rollup(t=None, team_id=None, include_salespeople=False):
|
| 348 |
+
"""Per-name invoice-line revenue + the MECE allocation split, for a YTD window.
|
| 349 |
+
|
| 350 |
+
Returns {'by_agent': [...], 'allocation': [...], 'total': float, 'allocated': float,
|
| 351 |
+
'unallocated': float, 'basis': str} β or {'error': msg} when the tenant store is not
|
| 352 |
+
ready (this path is store-only; there is no live fallback that stays honest about the
|
| 353 |
+
unallocated bucket).
|
| 354 |
+
"""
|
| 355 |
+
import harness.semantic as S
|
| 356 |
+
t = t or P.today()
|
| 357 |
+
yf, yt = P.ytd(t)
|
| 358 |
+
dim = 'commission_name' if include_salespeople else 'agent'
|
| 359 |
+
try:
|
| 360 |
+
by = S.store_query('invoice_lines', ['invoiced_line_sales'], group_by=[dim],
|
| 361 |
+
date_from=yf, date_to=yt, team_id=team_id, limit=200).get('rows') or []
|
| 362 |
+
alloc = S.store_query('invoice_lines', ['invoiced_line_sales'], group_by=['allocation'],
|
| 363 |
+
date_from=yf, date_to=yt, team_id=team_id, limit=10).get('rows') or []
|
| 364 |
+
tot = (S.store_query('invoice_lines', ['invoiced_line_sales'], date_from=yf, date_to=yt,
|
| 365 |
+
team_id=team_id).get('rows') or [{}])[0].get('invoiced_line_sales') or 0.0
|
| 366 |
+
except Exception as e: # store not ready / model error β say so, don't fake
|
| 367 |
+
return {'error': str(e)}
|
| 368 |
+
# store_query row shape: the DIM KEY carries the display NAME and `<dim>_id` the raw value
|
| 369 |
+
# (allocation -> 'Salesperson only', allocation_id -> 'salesperson'). Reading `<dim>_name`
|
| 370 |
+
# returns None for every row and silently renders the whole table as "no agent".
|
| 371 |
+
rows = [{'agent': (r.get(dim) or '(no agent on the line)'),
|
| 372 |
+
'revenue': r.get('invoiced_line_sales') or 0.0} for r in by]
|
| 373 |
+
rows.sort(key=lambda r: -r['revenue'])
|
| 374 |
+
amap = {r.get('allocation_id') or 'none': (r.get('invoiced_line_sales') or 0.0) for r in alloc}
|
| 375 |
+
allocated = amap.get('agent', 0.0)
|
| 376 |
+
return {
|
| 377 |
+
'by_agent': rows,
|
| 378 |
+
'allocation': [{'bucket': _ALLOC_LABEL[k], 'revenue': amap.get(k, 0.0)}
|
| 379 |
+
for k in ('agent', 'salesperson', 'none') if k in amap or True],
|
| 380 |
+
'total': tot, 'allocated': allocated, 'unallocated': tot - allocated,
|
| 381 |
+
'strict_none': amap.get('none', 0.0), 'salesperson_only': amap.get('salesperson', 0.0),
|
| 382 |
+
'window': (yf, yt),
|
| 383 |
+
'basis': ('invoice line Β· commission names incl. salespeople' if include_salespeople
|
| 384 |
+
else 'invoice line Β· real agents only'),
|
| 385 |
+
}
|
platform/modules/product_data.py
CHANGED
|
@@ -51,6 +51,7 @@ template and this deliberately mirrors its signature and its row shape.
|
|
| 51 |
carries no team, so a catalogue cannot be BU-shaped at all β which is exactly why the two
|
| 52 |
sources are JOINED rather than merged.
|
| 53 |
"""
|
|
|
|
| 54 |
import json
|
| 55 |
import zlib
|
| 56 |
from pathlib import Path
|
|
@@ -391,6 +392,17 @@ def pool(team_id=None, t=None):
|
|
| 391 |
bu_share = {} if consolidated else _bu_ltm_share(t, team_id)
|
| 392 |
sup = supplier_master()
|
| 393 |
prices, _price_report = _pricelist_by_code()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 394 |
cat = _catalogue_by_code()
|
| 395 |
# The LEFT side of the join, indexed by the same code key. β A code here that the catalogue
|
| 396 |
# does not carry belongs to a product no ACTIVE record claims β archived, and R12 keeps those
|
|
@@ -437,6 +449,19 @@ def pool(team_id=None, t=None):
|
|
| 437 |
p = prices.get(code) or {}
|
| 438 |
for _col, _name in products.PRICELIST_COLUMNS:
|
| 439 |
row[_col] = p.get(_col)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 440 |
# Wave 17 R3 β the supplier master, on every pull (it is catalogue data, not stock).
|
| 441 |
s = sup.get(code) or {}
|
| 442 |
row.update({
|
|
@@ -801,4 +826,390 @@ def validate(team_id=None, t=None):
|
|
| 801 |
# have silently dropped. 0 today does not make that filter correct.
|
| 802 |
"cover_gap_rounds_to_zero": len(rounding_would_miss)},
|
| 803 |
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 804 |
return checks
|
|
|
|
| 51 |
carries no team, so a catalogue cannot be BU-shaped at all β which is exactly why the two
|
| 52 |
sources are JOINED rather than merged.
|
| 53 |
"""
|
| 54 |
+
import datetime as _dt
|
| 55 |
import json
|
| 56 |
import zlib
|
| 57 |
from pathlib import Path
|
|
|
|
| 392 |
bu_share = {} if consolidated else _bu_ltm_share(t, team_id)
|
| 393 |
sup = supplier_master()
|
| 394 |
prices, _price_report = _pricelist_by_code()
|
| 395 |
+
# β W37-T14 / T15. β MEASURED COST, stated because it lands on a scope's FIRST build:
|
| 396 |
+
# tier prices 15.1 s + packagings 8.1 s on top of the ~44 s consolidated build. Only the
|
| 397 |
+
# first build for a scope blocks (`routes_products._pool_for`'s stale-while-refresh), and
|
| 398 |
+
# both degrade to `{}` on a read failure rather than taking the grid down β the same
|
| 399 |
+
# asymmetry `pricelist_by_code` documents: a column is not the ROW SET.
|
| 400 |
+
try:
|
| 401 |
+
_prods = products.active_products() # ONE read, shared by both (see its docstring)
|
| 402 |
+
except Exception: # noqa: BLE001
|
| 403 |
+
_prods = None
|
| 404 |
+
tiers, _tier_report = products.tier_prices_by_code(_prods)
|
| 405 |
+
packs, _pack_report = products.packagings_by_code(_prods)
|
| 406 |
cat = _catalogue_by_code()
|
| 407 |
# The LEFT side of the join, indexed by the same code key. β A code here that the catalogue
|
| 408 |
# does not carry belongs to a product no ACTIVE record claims β archived, and R12 keeps those
|
|
|
|
| 449 |
p = prices.get(code) or {}
|
| 450 |
for _col, _name in products.PRICELIST_COLUMNS:
|
| 451 |
row[_col] = p.get(_col)
|
| 452 |
+
# ββ W37-T14 / T15 β THE HONEST SETS, beside the three declared columns rather than
|
| 453 |
+
# instead of them. The columns above answer "what does Fisch charge"; these answer "how
|
| 454 |
+
# many prices/units does this SKU actually have", which the columns structurally cannot:
|
| 455 |
+
# they are named after THIS tenant's lists, so a price on any other list is invisible.
|
| 456 |
+
# β A `json` cell is a STRING on the wire (the type's own contract), so these are dumped
|
| 457 |
+
# here rather than handed over as lists β a bare list would round-trip through the overlay
|
| 458 |
+
# as something `grid_events` refuses to re-parse.
|
| 459 |
+
# β BLANK, NOT "[]" β an empty cell reads as "sold in one unit / not priced on any list",
|
| 460 |
+
# and an empty JSON array on screen reads as a bug.
|
| 461 |
+
_tiers = tiers.get(code)
|
| 462 |
+
row["tier_prices"] = json.dumps(_tiers, ensure_ascii=False) if _tiers else None
|
| 463 |
+
_units = packs.get(code)
|
| 464 |
+
row["units"] = json.dumps(_units, ensure_ascii=False) if _units else None
|
| 465 |
# Wave 17 R3 β the supplier master, on every pull (it is catalogue data, not stock).
|
| 466 |
s = sup.get(code) or {}
|
| 467 |
row.update({
|
|
|
|
| 826 |
# have silently dropped. 0 today does not make that filter correct.
|
| 827 |
"cover_gap_rounds_to_zero": len(rounding_would_miss)},
|
| 828 |
})
|
| 829 |
+
checks.extend(validate_measures(t=t, team_id=team_id,
|
| 830 |
+
pool_codes={r["code"] for r in rows}))
|
| 831 |
+
checks.extend(validate_price_and_unit_cells(rows))
|
| 832 |
+
checks.extend(validate_stock_measures(t=t))
|
| 833 |
+
return checks
|
| 834 |
+
|
| 835 |
+
|
| 836 |
+
def validate_stock_measures(t=None, days=90, sample=4):
|
| 837 |
+
"""ββ W37-T13 β `stock_in` / `stock_out` per SKU, against a DIRECT live Odoo `read_group`.
|
| 838 |
+
|
| 839 |
+
β THE TWO-SIDED DOMAIN IS REPRODUCED ON THE LIVE SIDE, and getting it wrong there would hide
|
| 840 |
+
exactly the defect this validates. `location_id.usage` is a DOT-PATH FILTER, which Odoo
|
| 841 |
+
supports; a dot-path GROUPBY faults, which is why the direction is expressed as two separate
|
| 842 |
+
filtered reads rather than one grouped-by-usage read (`proto/P1-stock-moves.md`, gotcha 1).
|
| 843 |
+
|
| 844 |
+
β AND THE DIRECTION SPLIT IS ASSERTED, NOT ASSUMED. A one-sided domain produces IN == OUT for
|
| 845 |
+
every internal transfer, so a run where the two columns agree everywhere is the signature of
|
| 846 |
+
the bug rather than of a quiet warehouse. The last leg requires a SKU where they genuinely
|
| 847 |
+
differ β the ticket's own `done-when` clause, and the only one a total cannot fake.
|
| 848 |
+
"""
|
| 849 |
+
from harness import datastore as DS
|
| 850 |
+
from harness import semantic as sem
|
| 851 |
+
|
| 852 |
+
t = t or P.today()
|
| 853 |
+
checks = []
|
| 854 |
+
offer = sem.entity_measures("odoo_products")
|
| 855 |
+
keys = [m["key"] for m in offer if m["key"].startswith("stock_")]
|
| 856 |
+
if not keys:
|
| 857 |
+
why = [r for r in sem.entity_measure_refusals("odoo_products")
|
| 858 |
+
if str(r.get("key", "")).startswith("stock_")]
|
| 859 |
+
return [{"check": "the product catalogue offers Stock moved in / out (W37-T13)",
|
| 860 |
+
"ours": 0, "theirs": 3, "ok": False,
|
| 861 |
+
# β The refusal carries its own cause β reported, not inferred from an absence.
|
| 862 |
+
"detail": {"refusals": why or "no stock binding declared"}}]
|
| 863 |
+
checks.append({"check": "the product measure catalogue offers the stock-movement keys "
|
| 864 |
+
"(W37-T13)", "ours": sorted(keys), "theirs": 3,
|
| 865 |
+
"ok": {"stock_in", "stock_out"} <= set(keys)})
|
| 866 |
+
|
| 867 |
+
con = DS.ro_cursor()
|
| 868 |
+
try:
|
| 869 |
+
newest = con.execute("SELECT max(date) FROM stock_move").fetchone()
|
| 870 |
+
finally:
|
| 871 |
+
con.close()
|
| 872 |
+
d_to = t - _dt.timedelta(days=2)
|
| 873 |
+
if newest and newest[0]:
|
| 874 |
+
try:
|
| 875 |
+
d_to = min(d_to, _dt.date.fromisoformat(str(newest[0])[:10]) - _dt.timedelta(days=1))
|
| 876 |
+
except ValueError:
|
| 877 |
+
pass
|
| 878 |
+
d_from = d_to - _dt.timedelta(days=days)
|
| 879 |
+
DF, DT = d_from.isoformat(), d_to.isoformat()
|
| 880 |
+
ours = sem.entity_measure_values("odoo_products", ["stock_in", "stock_out"],
|
| 881 |
+
date_from=DF, date_to=DT, offer=offer)
|
| 882 |
+
|
| 883 |
+
o = O.get_odoo()
|
| 884 |
+
base = [("state", "=", "done"),
|
| 885 |
+
("date", ">=", f"{DF} 00:00:00"), ("date", "<=", f"{DT} 23:59:59")]
|
| 886 |
+
IN = base + [("location_dest_id.usage", "=", "internal"),
|
| 887 |
+
("location_id.usage", "!=", "internal")]
|
| 888 |
+
OUT = base + [("location_id.usage", "=", "internal"),
|
| 889 |
+
("location_dest_id.usage", "!=", "internal")]
|
| 890 |
+
live = {}
|
| 891 |
+
for dom, side in ((IN, "stock_in"), (OUT, "stock_out")):
|
| 892 |
+
for r in o.read_group("stock.move", dom, ["product_id", "quantity_done:sum"],
|
| 893 |
+
["product_id"], lazy=False):
|
| 894 |
+
if not r.get("product_id"):
|
| 895 |
+
continue
|
| 896 |
+
live.setdefault(r["product_id"][0], {})[side] = r["quantity_done"]
|
| 897 |
+
code_of = _codes_of_odoo_products(o, list(live))
|
| 898 |
+
by_code = {}
|
| 899 |
+
for pid_, v in live.items():
|
| 900 |
+
c = code_of.get(pid_, f"pid:{pid_}")
|
| 901 |
+
d = by_code.setdefault(c, {"stock_in": 0.0, "stock_out": 0.0})
|
| 902 |
+
for k in ("stock_in", "stock_out"):
|
| 903 |
+
d[k] += v.get(k, 0.0)
|
| 904 |
+
|
| 905 |
+
for side in ("stock_in", "stock_out"):
|
| 906 |
+
a = round(sum(c.get(side, 0) for c in ours.values()), 2)
|
| 907 |
+
b = round(sum(c.get(side, 0) for c in by_code.values()), 2)
|
| 908 |
+
checks.append({
|
| 909 |
+
"check": f"{side}: the mirror's per-SKU total vs a DIRECT Odoo read_group under the "
|
| 910 |
+
f"SAME two-sided location domain",
|
| 911 |
+
"ours": a, "theirs": b, "gap": round(a - b, 2),
|
| 912 |
+
"ok": bool(b) and abs(a - b) <= 0.02 * b,
|
| 913 |
+
"detail": {"window": [DF, DT], "skus_ours": len(ours), "skus_odoo": len(by_code)}})
|
| 914 |
+
|
| 915 |
+
# β THE NAMED SKU, and it is chosen for DIFFERING β see the docstring.
|
| 916 |
+
diff = sorted(((abs((c.get("stock_in") or 0) - (c.get("stock_out") or 0)), k)
|
| 917 |
+
for k, c in ours.items()), reverse=True)[:sample]
|
| 918 |
+
named = []
|
| 919 |
+
for _d, k in diff:
|
| 920 |
+
named.append({"sku": k,
|
| 921 |
+
"ours": {s: round(ours[k].get(s, 0), 2) for s in ("stock_in", "stock_out")},
|
| 922 |
+
"odoo": {s: round((by_code.get(k) or {}).get(s, 0), 2)
|
| 923 |
+
for s in ("stock_in", "stock_out")}})
|
| 924 |
+
off = [n for n in named
|
| 925 |
+
if any(abs(n["ours"][s] - n["odoo"][s]) > max(0.01, 0.02 * (n["odoo"][s] or 1))
|
| 926 |
+
for s in ("stock_in", "stock_out"))]
|
| 927 |
+
checks.append({
|
| 928 |
+
"check": f"each NAMED SKU's in/out ties to Odoo ({len(named)} SKUs, picked for the "
|
| 929 |
+
f"largest in-vs-out difference)",
|
| 930 |
+
"ours": len(off), "theirs": 0, "ok": not off,
|
| 931 |
+
"detail": {"named": named[:3], "mismatched": off[:2]}})
|
| 932 |
+
genuinely_split = [n for n in named if n["ours"]["stock_in"] != n["ours"]["stock_out"]]
|
| 933 |
+
checks.append({
|
| 934 |
+
"check": "β the DIRECTION SPLIT is real: at least one SKU where IN and OUT genuinely "
|
| 935 |
+
"differ. A one-sided domain makes them equal for every internal transfer, so "
|
| 936 |
+
"all-equal is the SIGNATURE OF THE BUG, not a quiet warehouse",
|
| 937 |
+
"ours": len(genuinely_split), "theirs": ">=1", "ok": bool(genuinely_split),
|
| 938 |
+
"detail": {"example": genuinely_split[0] if genuinely_split else None}})
|
| 939 |
+
return checks
|
| 940 |
+
|
| 941 |
+
|
| 942 |
+
def validate_price_and_unit_cells(rows, sample=6):
|
| 943 |
+
"""ββ W37-T14 / T15 β the `Tier prices` and `Units` cells, against a FRESH Odoo read.
|
| 944 |
+
|
| 945 |
+
β THE ORACLE IS ASKED PER SKU, not in bulk, and deliberately so: the builders group a
|
| 946 |
+
bulk read in Python, so re-running the same bulk read would re-run the same grouping and
|
| 947 |
+
could only ever agree with itself. Asking Odoo for ONE SKU's price rules is a different
|
| 948 |
+
question shape and can actually disagree ([[no-unverifiable-aggregates]]).
|
| 949 |
+
|
| 950 |
+
β WHAT IS NOT PROVEN HERE, said rather than implied: that a PERSON sees the cell. These are
|
| 951 |
+
`json` columns on the product grid and the render is the client's; the data half is what a
|
| 952 |
+
module `validate()` can reach.
|
| 953 |
+
"""
|
| 954 |
+
checks = []
|
| 955 |
+
priced = [r for r in rows if r.get("tier_prices")]
|
| 956 |
+
united = [r for r in rows if r.get("units")]
|
| 957 |
+
checks.append({
|
| 958 |
+
"check": "the product grid serves a Tier-prices cell (W37-T14) and a Units cell (T15); "
|
| 959 |
+
"a DECLARED column that is never filled is the defect these replace",
|
| 960 |
+
"ours": {"with_tier_prices": len(priced), "with_units": len(united), "rows": len(rows)},
|
| 961 |
+
"theirs": ">0 each",
|
| 962 |
+
# β Units are legitimately sparse (19.6% measured), so the floor is existence, not a rate.
|
| 963 |
+
"ok": bool(priced) and bool(united),
|
| 964 |
+
"detail": {"multi_price_skus": sum(1 for r in priced
|
| 965 |
+
if len(json.loads(r["tier_prices"])) > 1)},
|
| 966 |
+
})
|
| 967 |
+
if not priced:
|
| 968 |
+
return checks
|
| 969 |
+
o = O.get_odoo()
|
| 970 |
+
pls = {p["id"]: str(p.get("name") or "").strip()
|
| 971 |
+
for p in O.search_read("product.pricelist", [], ["id", "name"])}
|
| 972 |
+
today = P.today().isoformat()
|
| 973 |
+
# Prefer SKUs that carry MORE THAN ONE price β the ticket's own subject.
|
| 974 |
+
cand = sorted(priced, key=lambda r: -len(json.loads(r["tier_prices"])))[:sample]
|
| 975 |
+
bad = []
|
| 976 |
+
for r in cand:
|
| 977 |
+
mine = sorted((t["pricelist"], round(float(t["unit_price"]), 2))
|
| 978 |
+
for t in json.loads(r["tier_prices"]))
|
| 979 |
+
pid = r.get("product_id")
|
| 980 |
+
tmpl = None
|
| 981 |
+
if pid:
|
| 982 |
+
rec = o.search_read("product.product", [("id", "=", pid)], ["product_tmpl_id"])
|
| 983 |
+
tmpl = O.m2o_id(rec[0].get("product_tmpl_id")) if rec else None
|
| 984 |
+
dom = [("compute_price", "=", "fixed"),
|
| 985 |
+
"|", ("date_start", "=", False), ("date_start", "<=", today),
|
| 986 |
+
"|", ("date_end", "=", False), ("date_end", ">=", today),
|
| 987 |
+
("fixed_price", ">", 0),
|
| 988 |
+
"|", ("product_id", "=", pid), ("product_tmpl_id", "=", tmpl)]
|
| 989 |
+
live = o.search_read("product.pricelist.item", dom,
|
| 990 |
+
["pricelist_id", "fixed_price", "min_quantity", "applied_on"])
|
| 991 |
+
best = {}
|
| 992 |
+
for it in live:
|
| 993 |
+
nm = pls.get(O.m2o_id(it.get("pricelist_id")), "?")
|
| 994 |
+
q = it.get("min_quantity") or 0.0
|
| 995 |
+
if nm not in best or q < best[nm][0]:
|
| 996 |
+
best[nm] = (q, round(it.get("fixed_price") or 0.0, 2))
|
| 997 |
+
theirs = sorted((nm, v) for nm, (q, v) in best.items())
|
| 998 |
+
# β A code carried by TWO active products legitimately holds MORE entries than a single
|
| 999 |
+
# product's rules (D-309 / `2112-12`), so ours is a SUPERSET, never an equality.
|
| 1000 |
+
if not set(theirs) <= set(mine):
|
| 1001 |
+
bad.append({"sku": r.get("code"), "ours": mine, "odoo": theirs})
|
| 1002 |
+
checks.append({
|
| 1003 |
+
"check": f"each sampled SKU's Tier prices contain every live Odoo price for it "
|
| 1004 |
+
f"({len(cand)} SKUs, chosen for having the MOST prices)",
|
| 1005 |
+
"ours": len(bad), "theirs": 0, "ok": not bad,
|
| 1006 |
+
"detail": {"mismatches": bad[:3],
|
| 1007 |
+
"sampled": [r.get("code") for r in cand]},
|
| 1008 |
+
})
|
| 1009 |
+
return checks
|
| 1010 |
+
|
| 1011 |
+
|
| 1012 |
+
def _codes_of_odoo_products(o, ids):
|
| 1013 |
+
"""`{odoo product id: the identity key the GRID uses}` β `default_code`, or `pid:<id>`.
|
| 1014 |
+
|
| 1015 |
+
β BATCHED, 500 at a time. One call per id is ~1,700 XML-RPC round trips on this window and
|
| 1016 |
+
turns a 5-second reconciliation into a coffee break.
|
| 1017 |
+
β `active in [True, False]`: a re-SKUed line points at the ARCHIVED record and the grid
|
| 1018 |
+
merges it under the surviving code, so an active-only read would key it `pid:<id>` and
|
| 1019 |
+
manufacture a mismatch this check would then report as a defect.
|
| 1020 |
+
"""
|
| 1021 |
+
ids = sorted({i for i in ids if i})
|
| 1022 |
+
out = {}
|
| 1023 |
+
for i in range(0, len(ids), 500):
|
| 1024 |
+
for p in o.search_read('product.product',
|
| 1025 |
+
[('id', 'in', ids[i:i + 500]), ('active', 'in', [True, False])],
|
| 1026 |
+
['default_code']):
|
| 1027 |
+
out[p['id']] = p.get('default_code') or f"pid:{p['id']}"
|
| 1028 |
+
return {i: out.get(i, f"pid:{i}") for i in ids}
|
| 1029 |
+
|
| 1030 |
+
|
| 1031 |
+
def validate_measures(t=None, team_id=None, days=90, pool_codes=None):
|
| 1032 |
+
"""ββ W37-T10 β THE MINTED LOOKBACK MEASURES, against a DIRECT Odoo `read_group`.
|
| 1033 |
+
|
| 1034 |
+
Standing rule 8: a number that does not tie to Odoo does not ship. These columns are minted
|
| 1035 |
+
from the tenant MIRROR (`semantic.entity_measure_values`), so the oracle has to be the live
|
| 1036 |
+
ERP and nothing derived from the mirror β otherwise both sides come from the same place and
|
| 1037 |
+
the check cannot fail, which is the self-sealing shape `validate()`'s own header records
|
| 1038 |
+
costing a wave.
|
| 1039 |
+
|
| 1040 |
+
ββ THE MIRROR IS BEHIND LIVE, ALWAYS, AND THAT IS NOT A DEFECT β so a bare equality here
|
| 1041 |
+
would be RED every day and would teach everyone to ignore it. The reconciliation is therefore
|
| 1042 |
+
two-legged, and the second leg is the one that carries the meaning:
|
| 1043 |
+
|
| 1044 |
+
leg 1 totals agree within the lag, and the lag is REPORTED as a number, not a tolerance;
|
| 1045 |
+
leg 2 β EVERY line-level difference traces to a line ODOO WROTE AFTER THE MIRROR'S OWN
|
| 1046 |
+
WATERMARK. This is what makes the check falsifiable: a join bug produces differences
|
| 1047 |
+
on lines the mirror holds perfectly, and leg 2 goes red on the first one.
|
| 1048 |
+
|
| 1049 |
+
β Do NOT "fix" leg 2 by filtering the live side on `write_date <= watermark` and comparing
|
| 1050 |
+
totals β MEASURED 2026-08-19, that is a far worse instrument: confirming an order touches its
|
| 1051 |
+
lines' `write_date` without changing a value, so the filter drops thousands of lines the
|
| 1052 |
+
mirror holds correctly and the gap grows from $425 to $140,030.
|
| 1053 |
+
"""
|
| 1054 |
+
from harness import datastore as DS
|
| 1055 |
+
from harness import semantic as sem
|
| 1056 |
+
|
| 1057 |
+
t = t or P.today()
|
| 1058 |
+
checks = []
|
| 1059 |
+
try:
|
| 1060 |
+
if not DS.ready():
|
| 1061 |
+
return [{"check": "product lookback measures reconcile to Odoo",
|
| 1062 |
+
"ours": "no mirror", "theirs": "-", "ok": False,
|
| 1063 |
+
"detail": "the tenant store is not readable, so the measures are UNPROVEN, "
|
| 1064 |
+
"and an unproven aggregate is exactly what standing rule 8 bars"}]
|
| 1065 |
+
except Exception as e: # noqa: BLE001
|
| 1066 |
+
return [{"check": "product lookback measures reconcile to Odoo",
|
| 1067 |
+
"ours": f"{type(e).__name__}", "theirs": "-", "ok": False, "detail": str(e)[:200]}]
|
| 1068 |
+
|
| 1069 |
+
# β THE WINDOW END COMES FROM THE MIRROR, NOT FROM `today`, and this was measured the wrong
|
| 1070 |
+
# way round first. A window ending today reaches past what the mirror has ever seen: orders
|
| 1071 |
+
# placed since the last sync exist live and NOWHERE in the store, so the totals leg reported
|
| 1072 |
+
# a 2.69% "lag" that was really "the last three days do not exist here yet". Anchoring on the
|
| 1073 |
+
# mirror's own newest order makes the comparison one about EDITS to a shared period β which
|
| 1074 |
+
# is the only difference that could indicate a join bug.
|
| 1075 |
+
con = DS.ro_cursor()
|
| 1076 |
+
try:
|
| 1077 |
+
_newest = con.execute("SELECT max(date_order) FROM sale_order").fetchone()
|
| 1078 |
+
finally:
|
| 1079 |
+
con.close()
|
| 1080 |
+
d_to = t - _dt.timedelta(days=2)
|
| 1081 |
+
if _newest and _newest[0]:
|
| 1082 |
+
_n = str(_newest[0])[:10]
|
| 1083 |
+
try:
|
| 1084 |
+
# one day INSIDE the mirror's newest order: the final day may be half-synced.
|
| 1085 |
+
d_to = min(d_to, _dt.date.fromisoformat(_n) - _dt.timedelta(days=1))
|
| 1086 |
+
except ValueError:
|
| 1087 |
+
pass
|
| 1088 |
+
d_from = d_to - _dt.timedelta(days=days)
|
| 1089 |
+
DF, DT = d_from.isoformat(), d_to.isoformat()
|
| 1090 |
+
|
| 1091 |
+
offer = sem.entity_measures("odoo_products")
|
| 1092 |
+
checks.append({
|
| 1093 |
+
"check": "the product measure OFFER is non-empty and every key it names resolves in the "
|
| 1094 |
+
"semantic model (owner item 4 / R1)",
|
| 1095 |
+
"ours": len(offer), "theirs": ">0", "ok": len(offer) > 0,
|
| 1096 |
+
"detail": {"keys": [m["key"] for m in offer],
|
| 1097 |
+
# β The REPORTING half of standing rule 1: a declared key that dropped out
|
| 1098 |
+
# says why, rather than being quietly absent from a list nobody diffs.
|
| 1099 |
+
"refused": sem.entity_measure_refusals("odoo_products")},
|
| 1100 |
+
})
|
| 1101 |
+
if not offer:
|
| 1102 |
+
return checks
|
| 1103 |
+
missing_family = [m["key"] for m in offer if m.get("empty") not in ("zero", "blank")]
|
| 1104 |
+
checks.append({
|
| 1105 |
+
"check": "every offered measure declares an EMPTY-WINDOW family (C1: additive->0, "
|
| 1106 |
+
"ratio->blank), because 72% of this catalogue has no group in a 90-day window",
|
| 1107 |
+
"ours": len(missing_family), "theirs": 0, "ok": not missing_family,
|
| 1108 |
+
"detail": {"undeclared": missing_family},
|
| 1109 |
+
})
|
| 1110 |
+
|
| 1111 |
+
store = sem.entity_measure_values("odoo_products", ["revenue", "units", "margin"],
|
| 1112 |
+
date_from=DF, date_to=DT, exclude_services=False,
|
| 1113 |
+
offer=offer)
|
| 1114 |
+
o = O.get_odoo()
|
| 1115 |
+
# β ASKED OF ODOO DIRECTLY, grouped by Odoo's OWN product id β deliberately NOT by the SKU
|
| 1116 |
+
# code the mirror joins on, so the oracle cannot inherit our join key.
|
| 1117 |
+
g = o.read_group('sale.order.line', O.sale_line_domain(DF, DT),
|
| 1118 |
+
['product_id', 'price_subtotal:sum', 'product_uom_qty:sum', 'margin:sum'],
|
| 1119 |
+
['product_id'], lazy=False)
|
| 1120 |
+
live_tot = {"revenue": round(sum(r['price_subtotal'] for r in g), 2),
|
| 1121 |
+
"units": round(sum(r['product_uom_qty'] for r in g), 2),
|
| 1122 |
+
"margin": round(sum(r['margin'] for r in g), 2)}
|
| 1123 |
+
ours_tot = {k: round(sum(v.get(k, 0) for v in store.values()), 2)
|
| 1124 |
+
for k in ("revenue", "units", "margin")}
|
| 1125 |
+
|
| 1126 |
+
# leg 2 β the falsifiable one. Every discrepant LINE must post-date the mirror's watermark.
|
| 1127 |
+
con = DS.ro_cursor()
|
| 1128 |
+
try:
|
| 1129 |
+
wm = con.execute("SELECT cursor_wd FROM _sync_state WHERE entity = 'sale_order_line'"
|
| 1130 |
+
).fetchone()
|
| 1131 |
+
wm = wm[0] if wm else None
|
| 1132 |
+
rows = con.execute(
|
| 1133 |
+
"SELECT l.id, l.price_subtotal FROM sale_order_line l "
|
| 1134 |
+
"JOIN sale_order o ON o.id = l.order_id "
|
| 1135 |
+
"WHERE o.state IN ('sale','done') AND o.team_id IN (5,6) "
|
| 1136 |
+
" AND l.product_id IS NOT NULL "
|
| 1137 |
+
" AND l.order_partner_id NOT IN "
|
| 1138 |
+
" (SELECT id FROM res_partner WHERE name LIKE 'GIFTWARE%') "
|
| 1139 |
+
" AND CAST(o.date_order AS TIMESTAMP) >= ? AND CAST(o.date_order AS TIMESTAMP) <= ?",
|
| 1140 |
+
[f"{DF} 00:00:00", f"{DT} 23:59:59"]).fetchall()
|
| 1141 |
+
finally:
|
| 1142 |
+
con.close()
|
| 1143 |
+
mine = {r[0]: (r[1] or 0.0) for r in rows}
|
| 1144 |
+
theirs = {r['id']: (r['price_subtotal'] or 0.0) for r in
|
| 1145 |
+
o.search_read('sale.order.line', O.sale_line_domain(DF, DT),
|
| 1146 |
+
['id', 'price_subtotal', 'write_date'])}
|
| 1147 |
+
wd = {r['id']: str(r['write_date']) for r in
|
| 1148 |
+
o.search_read('sale.order.line', O.sale_line_domain(DF, DT), ['id', 'write_date'])}
|
| 1149 |
+
discrepant = [i for i, v in theirs.items()
|
| 1150 |
+
if i not in mine or abs(mine[i] - v) >= 0.005]
|
| 1151 |
+
unexplained = [i for i in discrepant if not wm or wd.get(i, '') <= str(wm)]
|
| 1152 |
+
checks.append({
|
| 1153 |
+
"check": "every per-SKU measure difference vs live Odoo traces to a line Odoo wrote "
|
| 1154 |
+
"AFTER the mirror's watermark, because a join bug would differ on a mirrored line",
|
| 1155 |
+
"ours": len(unexplained), "theirs": 0, "ok": not unexplained,
|
| 1156 |
+
"detail": {"lines_compared": len(theirs), "discrepant": len(discrepant),
|
| 1157 |
+
"explained_by_mirror_lag": len(discrepant) - len(unexplained),
|
| 1158 |
+
"watermark": str(wm), "window": [DF, DT],
|
| 1159 |
+
"unexplained_line_ids": unexplained[:10]},
|
| 1160 |
+
})
|
| 1161 |
+
# ββ THE LEG THAT CATCHES A WRONG JOIN KEY, and neither leg above can. Both of those sum the
|
| 1162 |
+
# same LINES whatever dim they were grouped by, so swapping `dim: product_code` for
|
| 1163 |
+
# `dim: product` (contract C1's trap, an Odoo id where the grid carries a SKU code) leaves
|
| 1164 |
+
# them both green while every cell on the screen goes blank. This one asks: does the answer
|
| 1165 |
+
# arrive under a key a GRID ROW ACTUALLY HAS, and is the value right FOR THAT SKU?
|
| 1166 |
+
code_of = _codes_of_odoo_products(o, [r['product_id'][0] for r in g if r.get('product_id')])
|
| 1167 |
+
by_code = {}
|
| 1168 |
+
for r in g:
|
| 1169 |
+
if not r.get('product_id'):
|
| 1170 |
+
continue
|
| 1171 |
+
c = code_of.get(r['product_id'][0], f"pid:{r['product_id'][0]}")
|
| 1172 |
+
by_code[c] = by_code.get(c, 0.0) + r['price_subtotal']
|
| 1173 |
+
# β The pool is the CALLER'S when it has one (it has already paid for it), and read fresh
|
| 1174 |
+
# otherwise β `validate_measures` is runnable on its own, and a leg that silently skips when
|
| 1175 |
+
# called directly is a leg nobody runs ([[gate-can-report-green-on-nothing]]).
|
| 1176 |
+
pool_codes = set(pool_codes) if pool_codes is not None else {
|
| 1177 |
+
r["code"] for r in pool(team_id=team_id, t=t)}
|
| 1178 |
+
keyed_to_a_row = [c for c in store if c in pool_codes]
|
| 1179 |
+
top = sorted(store.items(), key=lambda kv: -(kv[1].get("revenue") or 0))[:10]
|
| 1180 |
+
spot = [{"sku": c,
|
| 1181 |
+
"ours": round(v.get("revenue") or 0.0, 2),
|
| 1182 |
+
"odoo": round(by_code.get(c, 0.0), 2)} for c, v in top]
|
| 1183 |
+
# A named SKU may legitimately differ by a line Odoo edited after the watermark; the
|
| 1184 |
+
# assertion is that MOST of the top ten tie exactly and NONE is off by an order of magnitude,
|
| 1185 |
+
# which is what a mis-keyed join looks like (0.00 against a five-figure number).
|
| 1186 |
+
exact = sum(1 for s in spot if abs(s["ours"] - s["odoo"]) < 0.005)
|
| 1187 |
+
checks.append({
|
| 1188 |
+
"check": "the measure answer is KEYED TO THE GRID'S OWN IDENTITY (C1's join-key trap): "
|
| 1189 |
+
"every group key is a SKU code a pool row carries, and the top-10 SKUs' revenue "
|
| 1190 |
+
"ties to Odoo for THAT SKU",
|
| 1191 |
+
"ours": {"keys_matching_a_pool_row": len(keyed_to_a_row), "of": len(store),
|
| 1192 |
+
"top10_exact": exact},
|
| 1193 |
+
"theirs": {"keys_matching_a_pool_row": len(store), "of": len(store), "top10_exact": 10},
|
| 1194 |
+
# β Not `== len(store)`: a code whose ONLY product record is archived legitimately has
|
| 1195 |
+
# revenue and no grid row (R12 keeps archived out), which `validate()`'s own
|
| 1196 |
+
# "outside the grid" leg already reconciles. A mis-keyed join lands at ~0, not at 99%.
|
| 1197 |
+
"ok": len(store) > 0 and len(keyed_to_a_row) / len(store) > 0.95 and exact >= 8,
|
| 1198 |
+
"detail": {"spot_checks": spot,
|
| 1199 |
+
"keys_with_no_pool_row": sorted(set(store) - pool_codes)[:10]},
|
| 1200 |
+
})
|
| 1201 |
+
checks.append({
|
| 1202 |
+
"check": "product lookback totals vs a DIRECT Odoo read_group, where the residual is mirror "
|
| 1203 |
+
"lag and is REPORTED as a figure, never absorbed into a tolerance",
|
| 1204 |
+
"ours": ours_tot, "theirs": live_tot,
|
| 1205 |
+
# The assertion is on leg 2; this leg is red only if the lag is implausibly large, which
|
| 1206 |
+
# is the shape that means "the mirror stopped" rather than "the mirror is a day behind".
|
| 1207 |
+
"ok": abs(ours_tot["revenue"] - live_tot["revenue"]) <= max(
|
| 1208 |
+
0.01, live_tot["revenue"] * 0.02),
|
| 1209 |
+
"detail": {"revenue_lag": round(ours_tot["revenue"] - live_tot["revenue"], 2),
|
| 1210 |
+
"revenue_lag_pct": round(
|
| 1211 |
+
(ours_tot["revenue"] - live_tot["revenue"]) / live_tot["revenue"] * 100, 4)
|
| 1212 |
+
if live_tot["revenue"] else None,
|
| 1213 |
+
"skus_in_grid_answer": len(store)},
|
| 1214 |
+
})
|
| 1215 |
return checks
|
platform/modules/products.py
CHANGED
|
@@ -393,6 +393,178 @@ def pricelist_by_code():
|
|
| 393 |
return out, report
|
| 394 |
|
| 395 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 396 |
def catalogue_count():
|
| 397 |
"""The INDEPENDENT population oracle: Odoo's own count of active products.
|
| 398 |
|
|
|
|
| 393 |
return out, report
|
| 394 |
|
| 395 |
|
| 396 |
+
def active_products(limit=50000):
|
| 397 |
+
"""`[{id, default_code, product_tmpl_id}]` for every ACTIVE product β read ONCE and shared.
|
| 398 |
+
|
| 399 |
+
β RAISES ON A SHORT PULL, like `catalogue()` and for the same reason: a truncated product read
|
| 400 |
+
renders as a plausible smaller set of priced SKUs with nothing reporting it.
|
| 401 |
+
β It exists so `tier_prices_by_code` and `packagings_by_code` can be called from ONE pool
|
| 402 |
+
build without each paying for its own copy of the same 5,873-row read β measured at ~4 s each
|
| 403 |
+
on this connection, on a path that is somebody's first page load.
|
| 404 |
+
"""
|
| 405 |
+
dom = [('active', '=', True)]
|
| 406 |
+
prods = O.search_read('product.product', dom, ['id', 'default_code', 'product_tmpl_id'],
|
| 407 |
+
limit=limit)
|
| 408 |
+
n = O.get_odoo().search_count('product.product', dom)
|
| 409 |
+
if len(prods) != n:
|
| 410 |
+
raise ValueError(f"products.active_products: the product pull is TRUNCATED. Read "
|
| 411 |
+
f"{len(prods)} rows against a search_count of {n}.")
|
| 412 |
+
return prods
|
| 413 |
+
|
| 414 |
+
|
| 415 |
+
def tier_prices_by_code(prods=None):
|
| 416 |
+
"""`({code: [{pricelist, unit_price}]}, report)` β EVERY live price a SKU really has.
|
| 417 |
+
|
| 418 |
+
ββ W37-T14 (owner: multiple prices per SKU). `pricelist_by_code` above answers a DIFFERENT
|
| 419 |
+
question and both are needed: it fills three DECLARED columns (`price_fisch`, `price_royal_1`,
|
| 420 |
+
`price_royal_2`) and therefore cannot show a price on a list the contract does not name. This
|
| 421 |
+
one is the honest set β one entry per pricelist that actually prices this SKU today.
|
| 422 |
+
|
| 423 |
+
β IT READS EVERY LIVE PRICELIST, not `PRICELIST_COLUMNS`. Hardcoding this tenant's three list
|
| 424 |
+
names into "how many prices does a SKU have" is exactly what the ticket forbids, and it is what
|
| 425 |
+
would make the answer wrong for tenant #1 on the day they are onboarded.
|
| 426 |
+
|
| 427 |
+
β ROWS ARE TEMPLATE-SCOPED (`proto/P2-pricing-uom.md`): joining on `product_id` drops 99.9% of
|
| 428 |
+
price rows, because `applied_on` is `1_product` on 10,454 of 10,468 items and `1_product` means
|
| 429 |
+
the TEMPLATE. A variant rule (`0_product_variant`, 13 rows) is the more specific statement and
|
| 430 |
+
wins, matching `pricecomp._tier_for` and `pricelist_by_code`.
|
| 431 |
+
|
| 432 |
+
β FILTER BY `pricelist_id`, NEVER BY `active`: the default `product.pricelist.item` count hides
|
| 433 |
+
2,580 archived items, so an `active` filter reads as a smaller, plausible, wrong set.
|
| 434 |
+
|
| 435 |
+
β THE BASE TIER, at qty 1, for the same reason `pricelist_by_code` gives: a catalogue cell has
|
| 436 |
+
no quantity in hand. Measured: only 10 of 10,468 items carry a quantity break at all, so this
|
| 437 |
+
is very nearly the whole story rather than a simplification.
|
| 438 |
+
β CARDINALITY 1..3 TODAY, mode 3 β and `Royal 2` carries 2,605 price rows against **0 customers
|
| 439 |
+
and 0 orders**, so a SKU reading "3 tiers" is catalogue-true and commercially misleading. The
|
| 440 |
+
entry keeps the list NAME so a reader can see which tier it is rather than a bare count.
|
| 441 |
+
"""
|
| 442 |
+
report = {"lists": [], "rules_total": 0, "rules_not_fixed": 0, "rules_out_of_date": 0,
|
| 443 |
+
"rules_global": 0, "skus_with_no_price": 0, "identity_breaks": 0}
|
| 444 |
+
try:
|
| 445 |
+
pls = {p['id']: str(p.get('name') or '').strip()
|
| 446 |
+
for p in O.search_read('product.pricelist', [], ['id', 'name'])}
|
| 447 |
+
report["lists"] = sorted(pls.values())
|
| 448 |
+
today = P.today().isoformat()
|
| 449 |
+
dom = [('pricelist_id', 'in', sorted(pls)), ('compute_price', '=', 'fixed'),
|
| 450 |
+
('applied_on', 'in', ['0_product_variant', '1_product']),
|
| 451 |
+
'|', ('date_start', '=', False), ('date_start', '<=', today),
|
| 452 |
+
'|', ('date_end', '=', False), ('date_end', '>=', today),
|
| 453 |
+
('fixed_price', '>', 0)]
|
| 454 |
+
rules = O.search_read('product.pricelist.item', dom,
|
| 455 |
+
['pricelist_id', 'product_id', 'product_tmpl_id', 'applied_on',
|
| 456 |
+
'fixed_price', 'min_quantity'])
|
| 457 |
+
|
| 458 |
+
# R6's second sentence: what this reader cannot see is COUNTED, never dropped.
|
| 459 |
+
def _n(extra):
|
| 460 |
+
try:
|
| 461 |
+
return O.get_odoo().search_count('product.pricelist.item', extra)
|
| 462 |
+
except Exception: # noqa: BLE001
|
| 463 |
+
return -1 # -1 reads as "not measured", never as zero
|
| 464 |
+
report["rules_total"] = _n([])
|
| 465 |
+
report["rules_not_fixed"] = _n([('compute_price', '!=', 'fixed')])
|
| 466 |
+
report["rules_global"] = _n([('applied_on', '=', '3_global')])
|
| 467 |
+
report["rules_out_of_date"] = _n(['|', ('date_end', '!=', False),
|
| 468 |
+
('date_start', '!=', False)])
|
| 469 |
+
|
| 470 |
+
by_var, by_tmpl = {}, {}
|
| 471 |
+
for r in rules:
|
| 472 |
+
plid = O.m2o_id(r.get('pricelist_id'))
|
| 473 |
+
if r.get('applied_on') == '0_product_variant' and r.get('product_id'):
|
| 474 |
+
by_var.setdefault((plid, O.m2o_id(r['product_id'])), []).append(r)
|
| 475 |
+
elif r.get('product_tmpl_id'):
|
| 476 |
+
by_tmpl.setdefault((plid, O.m2o_id(r['product_tmpl_id'])), []).append(r)
|
| 477 |
+
|
| 478 |
+
prods = active_products() if prods is None else prods
|
| 479 |
+
except Exception as e: # noqa: BLE001
|
| 480 |
+
report["error"] = f"{type(e).__name__}: {str(e)[:200]}"
|
| 481 |
+
return {}, report
|
| 482 |
+
|
| 483 |
+
out = {}
|
| 484 |
+
for p in prods:
|
| 485 |
+
code = (str(p['default_code']).strip() if p.get('default_code') else f"pid:{p['id']}")
|
| 486 |
+
tmpl = O.m2o_id(p.get('product_tmpl_id'))
|
| 487 |
+
tiers = []
|
| 488 |
+
for plid, name in sorted(pls.items(), key=lambda kv: kv[1].lower()):
|
| 489 |
+
cands = by_var.get((plid, p['id'])) or by_tmpl.get((plid, tmpl))
|
| 490 |
+
if not cands:
|
| 491 |
+
continue
|
| 492 |
+
base = min(cands, key=lambda r: r.get('min_quantity') or 0.0)
|
| 493 |
+
tiers.append({"pricelist": name, "unit_price": round(base.get('fixed_price') or 0.0, 2)})
|
| 494 |
+
if tiers:
|
| 495 |
+
# β A code carried by TWO active products (D-309: `2112-12`) MERGES here, because the
|
| 496 |
+
# grid is keyed by code and one code is one row. The prices are UNIONED rather than
|
| 497 |
+
# one silently winning β a SKU that really does have two different Fisch prices should
|
| 498 |
+
# show both, and hiding one is how a $60 price disappeared behind a $24 one.
|
| 499 |
+
prior = out.get(code)
|
| 500 |
+
if prior is None:
|
| 501 |
+
out[code] = tiers
|
| 502 |
+
else:
|
| 503 |
+
seen = {(t["pricelist"], t["unit_price"]) for t in prior}
|
| 504 |
+
for t in tiers:
|
| 505 |
+
if (t["pricelist"], t["unit_price"]) not in seen:
|
| 506 |
+
prior.append(t)
|
| 507 |
+
report["identity_breaks"] += 1
|
| 508 |
+
else:
|
| 509 |
+
report["skus_with_no_price"] += 1
|
| 510 |
+
return out, report
|
| 511 |
+
|
| 512 |
+
|
| 513 |
+
def packagings_by_code(prods=None):
|
| 514 |
+
"""`({code: [{name, qty}]}, report)` β the UNITS a SKU is really sold in (W37-T15).
|
| 515 |
+
|
| 516 |
+
β IT COMES FROM `product.packaging` ALONE, and `proto/P2-pricing-uom.md` measured why the
|
| 517 |
+
obvious alternative is a dead end: the `uom.uom` Config category holds 26 conversion-bearing
|
| 518 |
+
units (`Case-Packed 12` β¦ `Pallet-Packed 4,800`) referenced by **0 products, 0 sale lines and
|
| 519 |
+
0 stock moves**, and every unit actually in use has `factor_inv = 1`. Building on `uom.uom`
|
| 520 |
+
conversions yields a column of 1s.
|
| 521 |
+
|
| 522 |
+
β `qty > 1` IS A FILTER, NOT A TIDY-UP. 6,283 of 7,471 packaging rows are `qty = 1.0` β a
|
| 523 |
+
packaging that packs one of something is not a unit tier β plus real junk (one-character
|
| 524 |
+
names). Without it "units per SKU" reads **5,859** SKUs instead of **1,150**.
|
| 525 |
+
|
| 526 |
+
β 133 ROWS ARE ORPHANS (`product_id = False`) and would collapse under a `groupby(product_id)`
|
| 527 |
+
into ONE fake product carrying 133 packagings. Dropped, and counted.
|
| 528 |
+
|
| 529 |
+
β HONEST EMPTY: only **1,150 of 5,873 SKUs (19.6%)** have any unit tier, so this cell is
|
| 530 |
+
legitimately blank for 80% of the catalogue β "this SKU is sold in one unit", never "missing".
|
| 531 |
+
The 19.6% is not decoration: units are transacted on 77.9% of confirmed sale lines.
|
| 532 |
+
β `qty` is denominated in the product's OWN `uom_id` (matched on 7,338/7,338).
|
| 533 |
+
"""
|
| 534 |
+
report = {"rows_total": 0, "rows_orphan": 0, "rows_qty_le_1": 0, "skus_with_units": 0}
|
| 535 |
+
try:
|
| 536 |
+
rows = O.search_read('product.packaging', [], ['id', 'name', 'qty', 'product_id'])
|
| 537 |
+
except Exception as e: # noqa: BLE001
|
| 538 |
+
report["error"] = f"{type(e).__name__}: {str(e)[:200]}"
|
| 539 |
+
return {}, report
|
| 540 |
+
report["rows_total"] = len(rows)
|
| 541 |
+
by_pid = {}
|
| 542 |
+
for r in rows:
|
| 543 |
+
pid = O.m2o_id(r.get('product_id'))
|
| 544 |
+
if not pid:
|
| 545 |
+
report["rows_orphan"] += 1
|
| 546 |
+
continue
|
| 547 |
+
if (r.get('qty') or 0) <= 1:
|
| 548 |
+
report["rows_qty_le_1"] += 1
|
| 549 |
+
continue
|
| 550 |
+
by_pid.setdefault(pid, []).append(
|
| 551 |
+
{"name": str(r.get('name') or '').strip(), "qty": float(r.get('qty') or 0)})
|
| 552 |
+
try:
|
| 553 |
+
prods = active_products() if prods is None else prods
|
| 554 |
+
except Exception as e: # noqa: BLE001
|
| 555 |
+
report["error"] = f"{type(e).__name__}: {str(e)[:200]}"
|
| 556 |
+
return {}, report
|
| 557 |
+
out = {}
|
| 558 |
+
for p in prods:
|
| 559 |
+
got = by_pid.get(p['id'])
|
| 560 |
+
if not got:
|
| 561 |
+
continue
|
| 562 |
+
code = (str(p['default_code']).strip() if p.get('default_code') else f"pid:{p['id']}")
|
| 563 |
+
out.setdefault(code, []).extend(sorted(got, key=lambda u: u["qty"]))
|
| 564 |
+
report["skus_with_units"] = len(out)
|
| 565 |
+
return out, report
|
| 566 |
+
|
| 567 |
+
|
| 568 |
def catalogue_count():
|
| 569 |
"""The INDEPENDENT population oracle: Odoo's own count of active products.
|
| 570 |
|
requirements.txt
CHANGED
|
@@ -1,47 +1,55 @@
|
|
| 1 |
-
# AIOS web API β the FastAPI backend + the platform data-layer deps it reuses.
|
| 2 |
-
#
|
| 3 |
-
# β STREAMLIT IS GONE (EXIT-6, 2026-08-04) β do not add it back. The line here read
|
| 4 |
-
# `streamlit==1.58.0` with the comment "included only because the shared modules/core may import
|
| 5 |
-
# it at load; it is never served". That stopped being true when the shared layer was cleaned, and
|
| 6 |
-
# it stayed in THIS file β the one the Dockerfile actually installs β while `api/requirements.txt`
|
| 7 |
-
# had already dropped it and a gate reported the omission "verified". ~250 MB of image for a
|
| 8 |
-
# package nothing imported.
|
| 9 |
-
#
|
| 10 |
-
# The lesson is the file, not the package: the PINNED intent and the SHIPPED manifest are two
|
| 11 |
-
# different documents, and checking only the one you wrote is how the other rots.
|
| 12 |
-
# `api/verify_no_streamlit.py` now checks BOTH, and proves the API imports with streamlit,
|
| 13 |
-
# plotly, altair, openpyxl, reportlab and jinja2 all blocked at sys.meta_path.
|
| 14 |
-
# No altair/plotly/reportlab either β those were Streamlit UI/export only. openpyxl RETURNED in
|
| 15 |
-
# wave 21 (C5) as a LAZY dep of routes_uploads.py β the boot proof above still holds because the
|
| 16 |
-
# import lives inside the handler, and the gate now asserts this line exists here.
|
| 17 |
-
fastapi>=0.139
|
| 18 |
-
uvicorn[standard]>=0.30
|
| 19 |
-
python-dotenv>=1.0
|
| 20 |
-
pandas>=2.0
|
| 21 |
-
requests>=2.28
|
| 22 |
-
huggingface_hub>=0.20
|
| 23 |
-
duckdb>=1.0
|
| 24 |
-
pyyaml>=6.0
|
| 25 |
-
pillow>=10.0
|
| 26 |
-
beautifulsoup4>=4.12
|
| 27 |
-
lxml>=5.0
|
| 28 |
-
cryptography>=42.0
|
| 29 |
-
# β WAVE 20 (R1 / D-4) β THE POSTGRES DRIVER, AND IT IS LOAD-BEARING IN THIS FILE SPECIFICALLY.
|
| 30 |
-
# `core/store_pg.py` is the store backend from this wave on, and it is deliberately FAIL-CLOSED:
|
| 31 |
-
# `_pool()` raises rather than falling back to the HF file store, so a container that gets
|
| 32 |
-
# `STORE_BACKEND=pg` without this line does not degrade β it refuses every request that touches
|
| 33 |
-
# the store, which is every authenticated request.
|
| 34 |
-
#
|
| 35 |
-
# β AND THIS IS THE FILE THAT MATTERS: the Dockerfile does `COPY requirements.txt` from the
|
| 36 |
-
# Space root, i.e. THIS manifest, not `api/requirements.txt`. That header's own streamlit story
|
| 37 |
-
# is the same defect in the other direction β the pinned intent and the shipped manifest are two
|
| 38 |
-
# documents. psycopg is now in BOTH, and `ops/verify_portability.py` gates the pair.
|
| 39 |
-
psycopg[binary,pool]>=3.2
|
| 40 |
-
# β WAVE 21 (item 11, C5) β .xlsx preview for Select-from-file; lazy-imported in
|
| 41 |
-
# routes_uploads.py only. verify_no_streamlit asserts this line in BOTH manifests.
|
| 42 |
-
openpyxl>=3.1
|
| 43 |
-
# β AND ITS TRANSPORT, learned from a RUNTIME_ERROR on the first v6 boot: FastAPI demands
|
| 44 |
-
# python-multipart AT IMPORT TIME for any route declaring File(...)/Form(...). Every local gate
|
| 45 |
-
# was green because the dev box happens to have it for unrelated reasons β the container
|
| 46 |
-
# installs exactly this file. The environment-parity twin of the streamlit lesson above.
|
| 47 |
-
python-multipart>=0.0.20
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# AIOS web API β the FastAPI backend + the platform data-layer deps it reuses.
|
| 2 |
+
#
|
| 3 |
+
# β STREAMLIT IS GONE (EXIT-6, 2026-08-04) β do not add it back. The line here read
|
| 4 |
+
# `streamlit==1.58.0` with the comment "included only because the shared modules/core may import
|
| 5 |
+
# it at load; it is never served". That stopped being true when the shared layer was cleaned, and
|
| 6 |
+
# it stayed in THIS file β the one the Dockerfile actually installs β while `api/requirements.txt`
|
| 7 |
+
# had already dropped it and a gate reported the omission "verified". ~250 MB of image for a
|
| 8 |
+
# package nothing imported.
|
| 9 |
+
#
|
| 10 |
+
# The lesson is the file, not the package: the PINNED intent and the SHIPPED manifest are two
|
| 11 |
+
# different documents, and checking only the one you wrote is how the other rots.
|
| 12 |
+
# `api/verify_no_streamlit.py` now checks BOTH, and proves the API imports with streamlit,
|
| 13 |
+
# plotly, altair, openpyxl, reportlab and jinja2 all blocked at sys.meta_path.
|
| 14 |
+
# No altair/plotly/reportlab either β those were Streamlit UI/export only. openpyxl RETURNED in
|
| 15 |
+
# wave 21 (C5) as a LAZY dep of routes_uploads.py β the boot proof above still holds because the
|
| 16 |
+
# import lives inside the handler, and the gate now asserts this line exists here.
|
| 17 |
+
fastapi>=0.139
|
| 18 |
+
uvicorn[standard]>=0.30
|
| 19 |
+
python-dotenv>=1.0
|
| 20 |
+
pandas>=2.0
|
| 21 |
+
requests>=2.28
|
| 22 |
+
huggingface_hub>=0.20
|
| 23 |
+
duckdb>=1.0
|
| 24 |
+
pyyaml>=6.0
|
| 25 |
+
pillow>=10.0
|
| 26 |
+
beautifulsoup4>=4.12
|
| 27 |
+
lxml>=5.0
|
| 28 |
+
cryptography>=42.0
|
| 29 |
+
# β WAVE 20 (R1 / D-4) β THE POSTGRES DRIVER, AND IT IS LOAD-BEARING IN THIS FILE SPECIFICALLY.
|
| 30 |
+
# `core/store_pg.py` is the store backend from this wave on, and it is deliberately FAIL-CLOSED:
|
| 31 |
+
# `_pool()` raises rather than falling back to the HF file store, so a container that gets
|
| 32 |
+
# `STORE_BACKEND=pg` without this line does not degrade β it refuses every request that touches
|
| 33 |
+
# the store, which is every authenticated request.
|
| 34 |
+
#
|
| 35 |
+
# β AND THIS IS THE FILE THAT MATTERS: the Dockerfile does `COPY requirements.txt` from the
|
| 36 |
+
# Space root, i.e. THIS manifest, not `api/requirements.txt`. That header's own streamlit story
|
| 37 |
+
# is the same defect in the other direction β the pinned intent and the shipped manifest are two
|
| 38 |
+
# documents. psycopg is now in BOTH, and `ops/verify_portability.py` gates the pair.
|
| 39 |
+
psycopg[binary,pool]>=3.2
|
| 40 |
+
# β WAVE 21 (item 11, C5) β .xlsx preview for Select-from-file; lazy-imported in
|
| 41 |
+
# routes_uploads.py only. verify_no_streamlit asserts this line in BOTH manifests.
|
| 42 |
+
openpyxl>=3.1
|
| 43 |
+
# β AND ITS TRANSPORT, learned from a RUNTIME_ERROR on the first v6 boot: FastAPI demands
|
| 44 |
+
# python-multipart AT IMPORT TIME for any route declaring File(...)/Form(...). Every local gate
|
| 45 |
+
# was green because the dev box happens to have it for unrelated reasons β the container
|
| 46 |
+
# installs exactly this file. The environment-parity twin of the streamlit lesson above.
|
| 47 |
+
python-multipart>=0.0.20
|
| 48 |
+
# β WAVE 37 (D-346, answering `ASK E-1`) β THE OFFICIAL ANTHROPIC SDK. `ai_review.py` called the
|
| 49 |
+
# Messages API over raw `requests`, which meant re-implementing retries, streaming and error
|
| 50 |
+
# taxonomy by hand and drifting from them silently. E ships the SDK behind a LAZY import with the
|
| 51 |
+
# raw-HTTP call still there as the fallback, so the feature works with or without this line β what
|
| 52 |
+
# the line does is turn the fallback into dead weight instead of the live path.
|
| 53 |
+
# β BOTH MANIFESTS, NOT ONE. This file's own streamlit story at the top is exactly what pinning
|
| 54 |
+
# one of them looks like a year later.
|
| 55 |
+
anthropic>=0.96
|
web/index.html
CHANGED
|
@@ -1,14 +1,46 @@
|
|
| 1 |
-
<!doctype html>
|
| 2 |
-
<html lang="en">
|
| 3 |
-
<head>
|
| 4 |
-
<meta charset="UTF-8" />
|
| 5 |
-
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
| 6 |
-
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
-
<title>Loopable</title>
|
| 8 |
-
</head>
|
| 9 |
-
<body>
|
| 10 |
-
<
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
+
<title>Loopable</title>
|
| 8 |
+
</head>
|
| 9 |
+
<body>
|
| 10 |
+
<!-- ββ WAVE 37 Β· T48 β THE BOOT SKELETON, AND EVERY CHOICE HERE IS FORCED BY WHEN IT HAS TO
|
| 11 |
+
PAINT. Measured on the deployed build (proto/P4-first-load.md Β§A.2): `#root` was EMPTY and
|
| 12 |
+
the screen white for 6.0β8.0 s of an 11.6β16.0 s first open, while one 580 KB entry chunk
|
| 13 |
+
downloaded and parsed. The `<body>` was two empty divs, so there was nothing to show.
|
| 14 |
+
|
| 15 |
+
β INLINE STYLES, NOT A CLASS. `index.css` is its own ~324 KB render-blocking chunk that
|
| 16 |
+
took 3.3 s in that same trace β a skeleton styled from it would wait for the exact asset
|
| 17 |
+
whose arrival it exists to cover, and paint white until then. Inline is the only thing that
|
| 18 |
+
can render from the HTML alone.
|
| 19 |
+
|
| 20 |
+
β INSIDE `#root`, NOT BESIDE IT. `createRoot(...).render()` REPLACES the container's
|
| 21 |
+
children, so this is removed by the mount itself. A skeleton outside `#root` needs JS to
|
| 22 |
+
take it away, which is a second thing that can fail and a flash-of-two-uis if it does.
|
| 23 |
+
|
| 24 |
+
β NO ANIMATION. A shimmer would be a keyframe the CSS chunk has not delivered yet, and a
|
| 25 |
+
static block that never moves is honest about a page that is still downloading; a frozen
|
| 26 |
+
shimmer would read as a hung app. `aria-hidden` + `role="presentation"`: a screen reader
|
| 27 |
+
should hear the app, not its scaffolding. -->
|
| 28 |
+
<div id="root"><div aria-hidden="true" role="presentation" style="height:100vh;display:flex;flex-direction:column;background:#ffffff">
|
| 29 |
+
<div style="height:44px;flex:none;border-bottom:1px solid #e8e8ea;display:flex;align-items:center;padding:0 14px">
|
| 30 |
+
<div style="width:22px;height:22px;border-radius:11px;background:#efedf9"></div>
|
| 31 |
+
<div style="width:96px;height:12px;margin-left:10px;border-radius:3px;background:#efedf9"></div>
|
| 32 |
+
</div>
|
| 33 |
+
<div style="flex:1;display:flex;min-height:0">
|
| 34 |
+
<div style="width:288px;flex:none;border-right:1px solid #e8e8ea;padding:14px 12px">
|
| 35 |
+
<div style="height:11px;width:64%;border-radius:3px;background:#f1f0f4;margin-bottom:14px"></div>
|
| 36 |
+
<div style="height:11px;width:78%;border-radius:3px;background:#f4f3f7;margin-bottom:10px"></div>
|
| 37 |
+
<div style="height:11px;width:54%;border-radius:3px;background:#f4f3f7;margin-bottom:10px"></div>
|
| 38 |
+
<div style="height:11px;width:70%;border-radius:3px;background:#f4f3f7"></div>
|
| 39 |
+
</div>
|
| 40 |
+
<div style="flex:1;background:#fafafb"></div>
|
| 41 |
+
</div>
|
| 42 |
+
</div></div>
|
| 43 |
+
<div id="portal"></div>
|
| 44 |
+
<script type="module" src="/src/main.tsx"></script>
|
| 45 |
+
</body>
|
| 46 |
+
</html>
|
web/src/App.tsx
CHANGED
|
@@ -1,30 +1,41 @@
|
|
| 1 |
-
import
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Suspense, lazy } from "react";
|
| 2 |
+
// ββ WAVE 37 Β· T48 β LAZY, AND IT IS THE LAST OF THE THREE EAGER IMPORTS. `CustomerGrid` (7,715
|
| 3 |
+
// lines) plus glide-data-grid sat in the ENTRY chunk because these three files imported it at the
|
| 4 |
+
// top level, while twelve other surfaces were already `lazy()`. Measured on the deployed build
|
| 5 |
+
// (`proto/P4-first-load.md` Β§A.2): the entry chunk was 580,102 B on the wire and took 5.6-5.8 s,
|
| 6 |
+
// during which `#root` was EMPTY and the screen was white β 6.0-8.0 s of the 11.6-16.0 s first open.
|
| 7 |
+
// β THE EMBED PAYS THIS TOO, and it is the one path where the grid IS the page, so its fallback is
|
| 8 |
+
// deliberately the same skeleton shape the shell uses rather than `null`: a blank iframe is
|
| 9 |
+
// indistinguishable from a broken one.
|
| 10 |
+
const CustomerGrid = lazy(() => import("./customer-grid/CustomerGrid"));
|
| 11 |
+
import { OverlayProvider } from "./customer-grid/OverlaySurface";
|
| 12 |
+
import { isStreamlitComponent } from "./customer-grid/hostBridge";
|
| 13 |
+
import Shell from "./shell/Shell";
|
| 14 |
+
|
| 15 |
+
// HOSTED = either Streamlit path (the component iframe, or the legacy injected
|
| 16 |
+
// payload). There the surrounding app owns all chrome and this tree renders the
|
| 17 |
+
// BARE grid, byte-for-byte the behavior the embed always had. Only the
|
| 18 |
+
// standalone build gets the shell (wave 4, the strangler frame). Computed once
|
| 19 |
+
// at module level, like useCustomerData's own mode constants β the host mode
|
| 20 |
+
// cannot change within a page lifetime.
|
| 21 |
+
// EXPORTED (EXIT wave 1): main.tsx needs the same answer to decide whether to
|
| 22 |
+
// install the standalone event sink, and two copies of a mode predicate is how
|
| 23 |
+
// the two halves of "standalone-only" end up disagreeing.
|
| 24 |
+
export const HOSTED =
|
| 25 |
+
isStreamlitComponent() ||
|
| 26 |
+
typeof (window as unknown as { __AIOS_GRID__?: unknown }).__AIOS_GRID__ !== "undefined";
|
| 27 |
+
|
| 28 |
+
export default function App() {
|
| 29 |
+
if (HOSTED) {
|
| 30 |
+
return (
|
| 31 |
+
<div style={{ width: "100%", height: "100%", overflow: "hidden" }}>
|
| 32 |
+
<OverlayProvider>
|
| 33 |
+
<Suspense fallback={<div className="lp-boot" aria-hidden="true" />}>
|
| 34 |
+
<CustomerGrid />
|
| 35 |
+
</Suspense>
|
| 36 |
+
</OverlayProvider>
|
| 37 |
+
</div>
|
| 38 |
+
);
|
| 39 |
+
}
|
| 40 |
+
return <Shell />;
|
| 41 |
+
}
|
web/src/account/SubscriptionPage.tsx
CHANGED
|
@@ -1,47 +1,49 @@
|
|
| 1 |
-
// ---------------------------------------------------------------------------
|
| 2 |
-
// account/SubscriptionPage.tsx β WAVE 35 Β· W35-T20 (owner item 13, contract C6).
|
| 3 |
-
//
|
| 4 |
-
// Owner item 13, verbatim: *"add below settings the following button: Feedback β¦,
|
| 5 |
-
// Usage credits β¦, and a Subscription module β¦ Specialized for the Royal Imports
|
| 6 |
-
// tenant, do not show it."*
|
| 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 |
-
|
|
|
|
|
|
|
|
|
| 1 |
+
// ---------------------------------------------------------------------------
|
| 2 |
+
// account/SubscriptionPage.tsx β WAVE 35 Β· W35-T20 (owner item 13, contract C6).
|
| 3 |
+
//
|
| 4 |
+
// Owner item 13, verbatim: *"add below settings the following button: Feedback β¦,
|
| 5 |
+
// Usage credits β¦, and a Subscription module β¦ Specialized for the Royal Imports
|
| 6 |
+
// tenant, do not show it."*
|
| 7 |
+
// β "verbatim" IS THE POINT: W37-T02 renamed that surface to "Usage" and this quote KEEPS the
|
| 8 |
+
// owner's own wording. A quote edited to match a later rename is no longer evidence of anything.
|
| 9 |
+
//
|
| 10 |
+
// This is the third of those three, and the only one with nothing behind it yet.
|
| 11 |
+
// So it says so, in the words this app already uses for a boarded door
|
| 12 |
+
// (`Shell.tsx`'s templates note: "Under construction. β¦ will open here in a later
|
| 13 |
+
// release."). One voice for one state β DESIGN.md 1's "variety is a defect".
|
| 14 |
+
//
|
| 15 |
+
// β THIS PAGE DOES NOT HIDE ITSELF, AND THAT IS THE TICKET'S OWN TRAP.
|
| 16 |
+
// C6 puts the Royal Imports rule in TWO places that are both the frame's: the
|
| 17 |
+
// account MENU does not draw the row, and the ROUTE refuses. A third opinion here
|
| 18 |
+
// would be a third thing to keep in step, and the day they disagree the reader
|
| 19 |
+
// gets a menu row that opens a page which says it does not exist. The page renders
|
| 20 |
+
// the same for every tenant that can reach it, because reaching it IS the decision.
|
| 21 |
+
//
|
| 22 |
+
// β AND IT TAKES NO PROPS, WHICH IS ALSO A DECISION rather than an omission.
|
| 23 |
+
// The wave's rule is that a prop must be REQUIRED, never optional β an optional one
|
| 24 |
+
// degrades to "the page does not exist", which is indistinguishable from never
|
| 25 |
+
// built. That rule constrains the props that EXIST; it does not ask for a prop that
|
| 26 |
+
// carries nothing. There is nothing this page needs from the frame: it holds no
|
| 27 |
+
// state, makes no call, and takes no decision the frame has already taken. A
|
| 28 |
+
// `tenant` prop here would be exactly the second opinion the paragraph above
|
| 29 |
+
// refuses. The mount is proven by A's `verify_wiring` row (W2), not by a signature.
|
| 30 |
+
// ---------------------------------------------------------------------------
|
| 31 |
+
|
| 32 |
+
import "./account.css";
|
| 33 |
+
|
| 34 |
+
export default function SubscriptionPage() {
|
| 35 |
+
return (
|
| 36 |
+
<div className="acct-page">
|
| 37 |
+
<h1 className="acct-title">Subscription</h1>
|
| 38 |
+
<div className="acct-col">
|
| 39 |
+
{/* β TWO SHORT SENTENCES AND NO TOUR (DESIGN.md 4 / wave-22 R13: the app does not
|
| 40 |
+
narrate itself). The reader arrived here on purpose and wants one fact: is there
|
| 41 |
+
anything to do? There is not, and every further word would be spent on someone
|
| 42 |
+
who is already leaving. No feature list, no roadmap, no "in the meantime". */}
|
| 43 |
+
<p className="acct-note">
|
| 44 |
+
Under construction. Plans, billing and invoices will open here in a later release.
|
| 45 |
+
</p>
|
| 46 |
+
</div>
|
| 47 |
+
</div>
|
| 48 |
+
);
|
| 49 |
+
}
|
web/src/account/UsagePage.tsx
CHANGED
|
@@ -1,297 +1,297 @@
|
|
| 1 |
-
// ---------------------------------------------------------------------------
|
| 2 |
-
// account/UsagePage.tsx β WAVE 35 Β· W35-T19 (owner item 13, ruling R9, C6/C7).
|
| 3 |
-
//
|
| 4 |
-
// R9: ONE meter for every AI surface. It REPORTS weekly usage against an
|
| 5 |
-
// allowance and does not cut anyone off this wave; over the allowance the
|
| 6 |
-
// product still works and the bar is red.
|
| 7 |
-
//
|
| 8 |
-
// ββ THIS PAGE MAY NOT INVENT A FIGURE, AND THAT IS THE WHOLE TICKET.
|
| 9 |
-
// Every number here comes off `GET /usage` β the week, the reset date, the
|
| 10 |
-
// allowance, each surface's tokens and calls. Nothing is derived from a clock,
|
| 11 |
-
// nothing is estimated, and a surface with no ledger lines shows **0**, not a
|
| 12 |
-
// blank and not an omission (E-9 guarantees all four surfaces are always
|
| 13 |
-
// present, so there is no missing-row branch to get wrong).
|
| 14 |
-
// [[no-unverifiable-aggregates]]
|
| 15 |
-
//
|
| 16 |
-
// β AND `unmeasured` IS RENDERED, NOT SWALLOWED. Those are real calls whose
|
| 17 |
-
// provider declined to report a token count, so the total beside them is a
|
| 18 |
-
// FLOOR. A page that hides them presents the floor as a complete figure, which
|
| 19 |
-
// is the cost-surprise failure one step removed β the same reason
|
| 20 |
-
// `usage_ledger` books an unmeasured call as unknown rather than as zero.
|
| 21 |
-
//
|
| 22 |
-
// β SURFACE/READ SPLIT, as on Home and Starred: `renderToStaticMarkup` runs no
|
| 23 |
-
// effect, so a shot of the fetching component photographs only its spinner.
|
| 24 |
-
// ---------------------------------------------------------------------------
|
| 25 |
-
|
| 26 |
-
import { useCallback, useEffect, useRef, useState } from "react";
|
| 27 |
-
|
| 28 |
-
import { fmt } from "../ui/fmt";
|
| 29 |
-
import { loadUsage } from "./accountApi";
|
| 30 |
-
import type { Usage, UsageSurface } from "./accountApi";
|
| 31 |
-
import "./account.css";
|
| 32 |
-
|
| 33 |
-
/**
|
| 34 |
-
* The four surfaces C7 names, in the order R9 lists them.
|
| 35 |
-
*
|
| 36 |
-
* β AN UNKNOWN KEY IS HUMANISED, NEVER DROPPED. Dropping it would hide real usage from a meter
|
| 37 |
-
* whose whole job is to account for it, and printing the raw key would put an internal identifier
|
| 38 |
-
* on screen. The day a fifth LLM entry point lands (this product added two in one wave) it shows
|
| 39 |
-
* up here as a readable name with the right number beside it, and somebody can then decide what
|
| 40 |
-
* to call it.
|
| 41 |
-
*/
|
| 42 |
-
const SURFACE_LABEL: Record<string, string> = {
|
| 43 |
-
assistant: "Assistant",
|
| 44 |
-
field_agent: "Field agents",
|
| 45 |
-
ai_review: "Reviews",
|
| 46 |
-
automation_draft: "Drafting",
|
| 47 |
-
};
|
| 48 |
-
const ORDER = ["assistant", "field_agent", "ai_review", "automation_draft"];
|
| 49 |
-
|
| 50 |
-
export function surfaceLabel(key: string): string {
|
| 51 |
-
const known = SURFACE_LABEL[key];
|
| 52 |
-
if (known) return known;
|
| 53 |
-
const words = key.replace(/[_-]+/g, " ").trim();
|
| 54 |
-
return words ? words.charAt(0).toUpperCase() + words.slice(1) : key;
|
| 55 |
-
}
|
| 56 |
-
|
| 57 |
-
/** C7's order first, then anything the server added, alphabetically. */
|
| 58 |
-
export function orderSurfaces(surfaces: UsageSurface[]): UsageSurface[] {
|
| 59 |
-
const rank = (s: UsageSurface) => {
|
| 60 |
-
const i = ORDER.indexOf(s.surface);
|
| 61 |
-
return i === -1 ? ORDER.length : i;
|
| 62 |
-
};
|
| 63 |
-
return [...surfaces].sort(
|
| 64 |
-
(a, b) => rank(a) - rank(b) || surfaceLabel(a.surface).localeCompare(surfaceLabel(b.surface))
|
| 65 |
-
);
|
| 66 |
-
}
|
| 67 |
-
|
| 68 |
-
/**
|
| 69 |
-
* The bar's fill, as a percentage, CLAMPED to 100.
|
| 70 |
-
*
|
| 71 |
-
* β The clamp is what keeps "over the allowance" a COLOUR rather than a bar that overflows its
|
| 72 |
-
* own track and paints across the page. The number above the bar is unclamped and is where the
|
| 73 |
-
* overage is actually read.
|
| 74 |
-
* β An allowance of 0 (unset) yields 0 rather than a division by zero: no allowance means the
|
| 75 |
-
* meter has nothing to measure against, and a full red bar would be an assertion nobody made.
|
| 76 |
-
*/
|
| 77 |
-
export function barPct(value: number, of: number): number {
|
| 78 |
-
if (!(of > 0)) return 0;
|
| 79 |
-
return Math.max(0, Math.min(100, (value / of) * 100));
|
| 80 |
-
}
|
| 81 |
-
|
| 82 |
-
export type UsageLoad =
|
| 83 |
-
| { phase: "loading" }
|
| 84 |
-
| { phase: "ready"; usage: Usage }
|
| 85 |
-
| { phase: "failed"; message: string };
|
| 86 |
-
|
| 87 |
-
function Bar({ pct, over }: { pct: number; over: boolean }) {
|
| 88 |
-
return (
|
| 89 |
-
<span className="acct-bar" aria-hidden="true">
|
| 90 |
-
<span className={"acct-bar-fill" + (over ? " is-over" : "")} style={{ width: `${pct}%` }} />
|
| 91 |
-
</span>
|
| 92 |
-
);
|
| 93 |
-
}
|
| 94 |
-
|
| 95 |
-
export function UsageSurfaceRows({ usage }: { usage: Usage }) {
|
| 96 |
-
// β SCALED TO THE LARGEST SURFACE, NOT TO THE WEEK'S TOTAL, and the difference is legibility
|
| 97 |
-
// rather than accuracy. `total` is the week's whole figure and can legitimately exceed the sum
|
| 98 |
-
// of these four (unattributed calls are counted there and belong to nobody), so scaling to it
|
| 99 |
-
// shrinks every bar by an amount the reader cannot see the cause of. Scaled to the biggest row,
|
| 100 |
-
// the bars answer the question a row of bars is actually asked: which surface is spending most.
|
| 101 |
-
// The absolute number sits beside each one, so nothing here is only readable as a shape.
|
| 102 |
-
const biggest = usage.surfaces.reduce((m, s) => Math.max(m, s.tokens), 0);
|
| 103 |
-
return (
|
| 104 |
-
<div className="acct-rows">
|
| 105 |
-
{orderSurfaces(usage.surfaces).map((s) => (
|
| 106 |
-
<div key={s.surface} className="acct-row">
|
| 107 |
-
<span className="acct-row-name">{surfaceLabel(s.surface)}</span>
|
| 108 |
-
<Bar pct={barPct(s.tokens, biggest)} over={false} />
|
| 109 |
-
<span className="acct-row-num">{fmt.int(s.tokens)}</span>
|
| 110 |
-
<span className="acct-row-sub">
|
| 111 |
-
{fmt.int(s.calls)} {s.calls === 1 ? "call" : "calls"}
|
| 112 |
-
{/* β NOT HOVER-ONLY. A surface whose provider declined to report tokens has a
|
| 113 |
-
token number that is a floor, and D-253 is a booked defect whose entire content
|
| 114 |
-
is "the explanation was satisfied only on hover". */}
|
| 115 |
-
{s.unmeasured > 0 ? `, ${fmt.int(s.unmeasured)} not measured` : ""}
|
| 116 |
-
</span>
|
| 117 |
-
</div>
|
| 118 |
-
))}
|
| 119 |
-
</div>
|
| 120 |
-
);
|
| 121 |
-
}
|
| 122 |
-
|
| 123 |
-
export function UsageView({
|
| 124 |
-
load,
|
| 125 |
-
scope,
|
| 126 |
-
canSeeTenant,
|
| 127 |
-
onScope,
|
| 128 |
-
onRetry,
|
| 129 |
-
}: {
|
| 130 |
-
load: UsageLoad;
|
| 131 |
-
scope: "me" | "tenant";
|
| 132 |
-
/**
|
| 133 |
-
* Whether the workspace view is reachable AT ALL for this account.
|
| 134 |
-
*
|
| 135 |
-
* β THE TOGGLE IS NOT DRAWN WHEN IT IS NOT, and that is R8's fake-affordance rule applied to a
|
| 136 |
-
* permission: `?scope=tenant` is admin-only and 403s for everybody else, so an always-visible
|
| 137 |
-
* control would be a button most accounts can only be refused by.
|
| 138 |
-
*/
|
| 139 |
-
canSeeTenant: boolean;
|
| 140 |
-
onScope: (scope: "me" | "tenant") => void;
|
| 141 |
-
onRetry: () => void;
|
| 142 |
-
}) {
|
| 143 |
-
return (
|
| 144 |
-
<div className="acct-page">
|
| 145 |
-
<h1 className="acct-title">Usage
|
| 146 |
-
<div className="acct-col">
|
| 147 |
-
{canSeeTenant ? (
|
| 148 |
-
<div className="acct-scope" role="group" aria-label="Whose usage">
|
| 149 |
-
{(["me", "tenant"] as const).map((key) => (
|
| 150 |
-
<button
|
| 151 |
-
key={key}
|
| 152 |
-
type="button"
|
| 153 |
-
className={"acct-scope-btn" + (scope === key ? " is-on" : "")}
|
| 154 |
-
aria-pressed={scope === key}
|
| 155 |
-
onClick={() => onScope(key)}
|
| 156 |
-
>
|
| 157 |
-
{key === "me" ? "You" : "Workspace"}
|
| 158 |
-
</button>
|
| 159 |
-
))}
|
| 160 |
-
</div>
|
| 161 |
-
) : null}
|
| 162 |
-
{load.phase === "loading" ? (
|
| 163 |
-
<div className="acct-loading">
|
| 164 |
-
<span className="lp-spin lp-spin--lg" role="status" aria-label="Loading" />
|
| 165 |
-
</div>
|
| 166 |
-
) : load.phase === "failed" ? (
|
| 167 |
-
// β NOT A ZERO METER. A failed read and a quiet week look identical on screen unless
|
| 168 |
-
// this branch exists, and a meter reading zero is the most reassuring possible lie.
|
| 169 |
-
<div className="acct-failed">
|
| 170 |
-
<p className="acct-note">{load.message}</p>
|
| 171 |
-
<button type="button" className="acct-retry" onClick={onRetry}>
|
| 172 |
-
Try again
|
| 173 |
-
</button>
|
| 174 |
-
</div>
|
| 175 |
-
) : (
|
| 176 |
-
<>
|
| 177 |
-
<div className="acct-meter">
|
| 178 |
-
{/* β THE METER SAYS WHOSE USAGE IT IS, IN WORDS, and that is not decoration. The
|
| 179 |
-
allowance is a WORKSPACE number; the total beside it is this account's own
|
| 180 |
-
unless the scope says otherwise. A meter that shows a personal figure against a
|
| 181 |
-
shared limit without saying so is a ratio nobody can read correctly, and it is
|
| 182 |
-
the one way this page can be wrong while every number on it is right. */}
|
| 183 |
-
<div className="acct-meter-head">
|
| 184 |
-
<span className="acct-meter-who">
|
| 185 |
-
{load.usage.scope === "tenant" ? "This workspace" : "You"}, this week
|
| 186 |
-
</span>
|
| 187 |
-
</div>
|
| 188 |
-
<div className="acct-meter-head">
|
| 189 |
-
<span className={"acct-meter-value" + (load.usage.over ? " is-over" : "")}>
|
| 190 |
-
{fmt.int(load.usage.total)}
|
| 191 |
-
</span>
|
| 192 |
-
<span className="acct-meter-of">
|
| 193 |
-
of {fmt.int(load.usage.allowance)} tokens
|
| 194 |
-
</span>
|
| 195 |
-
</div>
|
| 196 |
-
<Bar pct={barPct(load.usage.total, load.usage.allowance)} over={load.usage.over} />
|
| 197 |
-
<div className="acct-meter-foot">
|
| 198 |
-
{/* Both facts are the SERVER'S: the week it counted and the date it rolls over.
|
| 199 |
-
A week boundary computed in the browser can disagree with the bucketing that
|
| 200 |
-
produced the number above it, and then the page states a period nobody
|
| 201 |
-
measured. */}
|
| 202 |
-
<span>
|
| 203 |
-
Week {load.usage.week}. Resets {fmt.date(load.usage.resets)}.
|
| 204 |
-
</span>
|
| 205 |
-
<span className="acct-meter-calls">
|
| 206 |
-
{fmt.int(load.usage.calls)} {load.usage.calls === 1 ? "call" : "calls"}
|
| 207 |
-
</span>
|
| 208 |
-
</div>
|
| 209 |
-
</div>
|
| 210 |
-
|
| 211 |
-
{/* β R9's whole point, in one line: over the allowance it STILL WORKS. The bar is
|
| 212 |
-
red, nothing is cut off, and the sentence says which of those two is true so
|
| 213 |
-
nobody has to infer it from a colour. */}
|
| 214 |
-
{load.usage.over ? (
|
| 215 |
-
<p className="acct-error" role="status">
|
| 216 |
-
This workspace is over its weekly allowance. Nothing is cut off; the AI surfaces
|
| 217 |
-
keep working.
|
| 218 |
-
</p>
|
| 219 |
-
) : null}
|
| 220 |
-
|
| 221 |
-
{/* The server's own sentence, present only when something really was unmeasured. */}
|
| 222 |
-
{load.usage.note ? <p className="acct-note acct-note--inline">{load.usage.note}</p> : null}
|
| 223 |
-
|
| 224 |
-
<h2 className="acct-section">By surface</h2>
|
| 225 |
-
<UsageSurfaceRows usage={load.usage} />
|
| 226 |
-
|
| 227 |
-
{load.usage.unattributed > 0 ? (
|
| 228 |
-
// β LABELLED A WIRING GAP, NOT USAGE (E-9). These are AI calls this container could
|
| 229 |
-
// not attribute to an account. Folding them into somebody's total would be an
|
| 230 |
-
// invented attribution, and leaving them out entirely would understate the week.
|
| 231 |
-
<p className="acct-note acct-note--inline">
|
| 232 |
-
{fmt.int(load.usage.unattributed)} tokens this week could not be attributed to an
|
| 233 |
-
account. That is a wiring gap in the meter, not somebody's usage.
|
| 234 |
-
</p>
|
| 235 |
-
) : null}
|
| 236 |
-
</>
|
| 237 |
-
)}
|
| 238 |
-
</div>
|
| 239 |
-
</div>
|
| 240 |
-
);
|
| 241 |
-
}
|
| 242 |
-
|
| 243 |
-
export default function UsagePage() {
|
| 244 |
-
const [load, setLoad] = useState<UsageLoad>({ phase: "loading" });
|
| 245 |
-
const [scope, setScope] = useState<"me" | "tenant">("me");
|
| 246 |
-
/**
|
| 247 |
-
* Whether the workspace view exists for this account, ANSWERED BY ASKING ONCE.
|
| 248 |
-
*
|
| 249 |
-
* β WHY A PROBE RATHER THAN A ROLE CHECK. The frame knows the role, but these pages take no
|
| 250 |
-
* props by contract (C6's trap: a page that decides its own visibility is a second opinion that
|
| 251 |
-
* will one day disagree with the route). The route itself is the authority, so the page asks it
|
| 252 |
-
* once: a 200 means the door is open and the toggle is real; a 403 means it is not and no
|
| 253 |
-
* control is drawn. β That 403 is an ANSWER, not an error β it is never shown to the reader.
|
| 254 |
-
* β AND IT IS WHAT GIVES `?scope=tenant` A DOOR. E built it admin-only; without this it would
|
| 255 |
-
* be a finished capability with no caller, which is this repo's most-repeated failure
|
| 256 |
-
* [[reachable-is-not-the-same-as-built]].
|
| 257 |
-
*/
|
| 258 |
-
const [canSeeTenant, setCanSeeTenant] = useState(false);
|
| 259 |
-
const gen = useRef(0);
|
| 260 |
-
|
| 261 |
-
const read = useCallback((which: "me" | "tenant") => {
|
| 262 |
-
const mine = gen.current + 1;
|
| 263 |
-
gen.current = mine;
|
| 264 |
-
setLoad({ phase: "loading" });
|
| 265 |
-
void loadUsage(which === "tenant" ? "tenant" : undefined).then((r) => {
|
| 266 |
-
if (gen.current !== mine) return;
|
| 267 |
-
setLoad(r.ok ? { phase: "ready", usage: r.value } : { phase: "failed", message: r.message });
|
| 268 |
-
});
|
| 269 |
-
}, []);
|
| 270 |
-
|
| 271 |
-
useEffect(() => {
|
| 272 |
-
read(scope);
|
| 273 |
-
return () => {
|
| 274 |
-
gen.current += 1;
|
| 275 |
-
};
|
| 276 |
-
}, [read, scope]);
|
| 277 |
-
|
| 278 |
-
useEffect(() => {
|
| 279 |
-
let live = true;
|
| 280 |
-
void loadUsage("tenant").then((r) => {
|
| 281 |
-
if (live) setCanSeeTenant(r.ok);
|
| 282 |
-
});
|
| 283 |
-
return () => {
|
| 284 |
-
live = false;
|
| 285 |
-
};
|
| 286 |
-
}, []);
|
| 287 |
-
|
| 288 |
-
return (
|
| 289 |
-
<UsageView
|
| 290 |
-
load={load}
|
| 291 |
-
scope={scope}
|
| 292 |
-
canSeeTenant={canSeeTenant}
|
| 293 |
-
onScope={setScope}
|
| 294 |
-
onRetry={() => read(scope)}
|
| 295 |
-
/>
|
| 296 |
-
);
|
| 297 |
-
}
|
|
|
|
| 1 |
+
// ---------------------------------------------------------------------------
|
| 2 |
+
// account/UsagePage.tsx β WAVE 35 Β· W35-T19 (owner item 13, ruling R9, C6/C7).
|
| 3 |
+
//
|
| 4 |
+
// R9: ONE meter for every AI surface. It REPORTS weekly usage against an
|
| 5 |
+
// allowance and does not cut anyone off this wave; over the allowance the
|
| 6 |
+
// product still works and the bar is red.
|
| 7 |
+
//
|
| 8 |
+
// ββ THIS PAGE MAY NOT INVENT A FIGURE, AND THAT IS THE WHOLE TICKET.
|
| 9 |
+
// Every number here comes off `GET /usage` β the week, the reset date, the
|
| 10 |
+
// allowance, each surface's tokens and calls. Nothing is derived from a clock,
|
| 11 |
+
// nothing is estimated, and a surface with no ledger lines shows **0**, not a
|
| 12 |
+
// blank and not an omission (E-9 guarantees all four surfaces are always
|
| 13 |
+
// present, so there is no missing-row branch to get wrong).
|
| 14 |
+
// [[no-unverifiable-aggregates]]
|
| 15 |
+
//
|
| 16 |
+
// β AND `unmeasured` IS RENDERED, NOT SWALLOWED. Those are real calls whose
|
| 17 |
+
// provider declined to report a token count, so the total beside them is a
|
| 18 |
+
// FLOOR. A page that hides them presents the floor as a complete figure, which
|
| 19 |
+
// is the cost-surprise failure one step removed β the same reason
|
| 20 |
+
// `usage_ledger` books an unmeasured call as unknown rather than as zero.
|
| 21 |
+
//
|
| 22 |
+
// β SURFACE/READ SPLIT, as on Home and Starred: `renderToStaticMarkup` runs no
|
| 23 |
+
// effect, so a shot of the fetching component photographs only its spinner.
|
| 24 |
+
// ---------------------------------------------------------------------------
|
| 25 |
+
|
| 26 |
+
import { useCallback, useEffect, useRef, useState } from "react";
|
| 27 |
+
|
| 28 |
+
import { fmt } from "../ui/fmt";
|
| 29 |
+
import { loadUsage } from "./accountApi";
|
| 30 |
+
import type { Usage, UsageSurface } from "./accountApi";
|
| 31 |
+
import "./account.css";
|
| 32 |
+
|
| 33 |
+
/**
|
| 34 |
+
* The four surfaces C7 names, in the order R9 lists them.
|
| 35 |
+
*
|
| 36 |
+
* β AN UNKNOWN KEY IS HUMANISED, NEVER DROPPED. Dropping it would hide real usage from a meter
|
| 37 |
+
* whose whole job is to account for it, and printing the raw key would put an internal identifier
|
| 38 |
+
* on screen. The day a fifth LLM entry point lands (this product added two in one wave) it shows
|
| 39 |
+
* up here as a readable name with the right number beside it, and somebody can then decide what
|
| 40 |
+
* to call it.
|
| 41 |
+
*/
|
| 42 |
+
const SURFACE_LABEL: Record<string, string> = {
|
| 43 |
+
assistant: "Assistant",
|
| 44 |
+
field_agent: "Field agents",
|
| 45 |
+
ai_review: "Reviews",
|
| 46 |
+
automation_draft: "Drafting",
|
| 47 |
+
};
|
| 48 |
+
const ORDER = ["assistant", "field_agent", "ai_review", "automation_draft"];
|
| 49 |
+
|
| 50 |
+
export function surfaceLabel(key: string): string {
|
| 51 |
+
const known = SURFACE_LABEL[key];
|
| 52 |
+
if (known) return known;
|
| 53 |
+
const words = key.replace(/[_-]+/g, " ").trim();
|
| 54 |
+
return words ? words.charAt(0).toUpperCase() + words.slice(1) : key;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
/** C7's order first, then anything the server added, alphabetically. */
|
| 58 |
+
export function orderSurfaces(surfaces: UsageSurface[]): UsageSurface[] {
|
| 59 |
+
const rank = (s: UsageSurface) => {
|
| 60 |
+
const i = ORDER.indexOf(s.surface);
|
| 61 |
+
return i === -1 ? ORDER.length : i;
|
| 62 |
+
};
|
| 63 |
+
return [...surfaces].sort(
|
| 64 |
+
(a, b) => rank(a) - rank(b) || surfaceLabel(a.surface).localeCompare(surfaceLabel(b.surface))
|
| 65 |
+
);
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
/**
|
| 69 |
+
* The bar's fill, as a percentage, CLAMPED to 100.
|
| 70 |
+
*
|
| 71 |
+
* β The clamp is what keeps "over the allowance" a COLOUR rather than a bar that overflows its
|
| 72 |
+
* own track and paints across the page. The number above the bar is unclamped and is where the
|
| 73 |
+
* overage is actually read.
|
| 74 |
+
* β An allowance of 0 (unset) yields 0 rather than a division by zero: no allowance means the
|
| 75 |
+
* meter has nothing to measure against, and a full red bar would be an assertion nobody made.
|
| 76 |
+
*/
|
| 77 |
+
export function barPct(value: number, of: number): number {
|
| 78 |
+
if (!(of > 0)) return 0;
|
| 79 |
+
return Math.max(0, Math.min(100, (value / of) * 100));
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
export type UsageLoad =
|
| 83 |
+
| { phase: "loading" }
|
| 84 |
+
| { phase: "ready"; usage: Usage }
|
| 85 |
+
| { phase: "failed"; message: string };
|
| 86 |
+
|
| 87 |
+
function Bar({ pct, over }: { pct: number; over: boolean }) {
|
| 88 |
+
return (
|
| 89 |
+
<span className="acct-bar" aria-hidden="true">
|
| 90 |
+
<span className={"acct-bar-fill" + (over ? " is-over" : "")} style={{ width: `${pct}%` }} />
|
| 91 |
+
</span>
|
| 92 |
+
);
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
export function UsageSurfaceRows({ usage }: { usage: Usage }) {
|
| 96 |
+
// β SCALED TO THE LARGEST SURFACE, NOT TO THE WEEK'S TOTAL, and the difference is legibility
|
| 97 |
+
// rather than accuracy. `total` is the week's whole figure and can legitimately exceed the sum
|
| 98 |
+
// of these four (unattributed calls are counted there and belong to nobody), so scaling to it
|
| 99 |
+
// shrinks every bar by an amount the reader cannot see the cause of. Scaled to the biggest row,
|
| 100 |
+
// the bars answer the question a row of bars is actually asked: which surface is spending most.
|
| 101 |
+
// The absolute number sits beside each one, so nothing here is only readable as a shape.
|
| 102 |
+
const biggest = usage.surfaces.reduce((m, s) => Math.max(m, s.tokens), 0);
|
| 103 |
+
return (
|
| 104 |
+
<div className="acct-rows">
|
| 105 |
+
{orderSurfaces(usage.surfaces).map((s) => (
|
| 106 |
+
<div key={s.surface} className="acct-row">
|
| 107 |
+
<span className="acct-row-name">{surfaceLabel(s.surface)}</span>
|
| 108 |
+
<Bar pct={barPct(s.tokens, biggest)} over={false} />
|
| 109 |
+
<span className="acct-row-num">{fmt.int(s.tokens)}</span>
|
| 110 |
+
<span className="acct-row-sub">
|
| 111 |
+
{fmt.int(s.calls)} {s.calls === 1 ? "call" : "calls"}
|
| 112 |
+
{/* β NOT HOVER-ONLY. A surface whose provider declined to report tokens has a
|
| 113 |
+
token number that is a floor, and D-253 is a booked defect whose entire content
|
| 114 |
+
is "the explanation was satisfied only on hover". */}
|
| 115 |
+
{s.unmeasured > 0 ? `, ${fmt.int(s.unmeasured)} not measured` : ""}
|
| 116 |
+
</span>
|
| 117 |
+
</div>
|
| 118 |
+
))}
|
| 119 |
+
</div>
|
| 120 |
+
);
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
export function UsageView({
|
| 124 |
+
load,
|
| 125 |
+
scope,
|
| 126 |
+
canSeeTenant,
|
| 127 |
+
onScope,
|
| 128 |
+
onRetry,
|
| 129 |
+
}: {
|
| 130 |
+
load: UsageLoad;
|
| 131 |
+
scope: "me" | "tenant";
|
| 132 |
+
/**
|
| 133 |
+
* Whether the workspace view is reachable AT ALL for this account.
|
| 134 |
+
*
|
| 135 |
+
* β THE TOGGLE IS NOT DRAWN WHEN IT IS NOT, and that is R8's fake-affordance rule applied to a
|
| 136 |
+
* permission: `?scope=tenant` is admin-only and 403s for everybody else, so an always-visible
|
| 137 |
+
* control would be a button most accounts can only be refused by.
|
| 138 |
+
*/
|
| 139 |
+
canSeeTenant: boolean;
|
| 140 |
+
onScope: (scope: "me" | "tenant") => void;
|
| 141 |
+
onRetry: () => void;
|
| 142 |
+
}) {
|
| 143 |
+
return (
|
| 144 |
+
<div className="acct-page">
|
| 145 |
+
<h1 className="acct-title">Usage</h1>
|
| 146 |
+
<div className="acct-col">
|
| 147 |
+
{canSeeTenant ? (
|
| 148 |
+
<div className="acct-scope" role="group" aria-label="Whose usage">
|
| 149 |
+
{(["me", "tenant"] as const).map((key) => (
|
| 150 |
+
<button
|
| 151 |
+
key={key}
|
| 152 |
+
type="button"
|
| 153 |
+
className={"acct-scope-btn" + (scope === key ? " is-on" : "")}
|
| 154 |
+
aria-pressed={scope === key}
|
| 155 |
+
onClick={() => onScope(key)}
|
| 156 |
+
>
|
| 157 |
+
{key === "me" ? "You" : "Workspace"}
|
| 158 |
+
</button>
|
| 159 |
+
))}
|
| 160 |
+
</div>
|
| 161 |
+
) : null}
|
| 162 |
+
{load.phase === "loading" ? (
|
| 163 |
+
<div className="acct-loading">
|
| 164 |
+
<span className="lp-spin lp-spin--lg" role="status" aria-label="Loading" />
|
| 165 |
+
</div>
|
| 166 |
+
) : load.phase === "failed" ? (
|
| 167 |
+
// β NOT A ZERO METER. A failed read and a quiet week look identical on screen unless
|
| 168 |
+
// this branch exists, and a meter reading zero is the most reassuring possible lie.
|
| 169 |
+
<div className="acct-failed">
|
| 170 |
+
<p className="acct-note">{load.message}</p>
|
| 171 |
+
<button type="button" className="acct-retry" onClick={onRetry}>
|
| 172 |
+
Try again
|
| 173 |
+
</button>
|
| 174 |
+
</div>
|
| 175 |
+
) : (
|
| 176 |
+
<>
|
| 177 |
+
<div className="acct-meter">
|
| 178 |
+
{/* β THE METER SAYS WHOSE USAGE IT IS, IN WORDS, and that is not decoration. The
|
| 179 |
+
allowance is a WORKSPACE number; the total beside it is this account's own
|
| 180 |
+
unless the scope says otherwise. A meter that shows a personal figure against a
|
| 181 |
+
shared limit without saying so is a ratio nobody can read correctly, and it is
|
| 182 |
+
the one way this page can be wrong while every number on it is right. */}
|
| 183 |
+
<div className="acct-meter-head">
|
| 184 |
+
<span className="acct-meter-who">
|
| 185 |
+
{load.usage.scope === "tenant" ? "This workspace" : "You"}, this week
|
| 186 |
+
</span>
|
| 187 |
+
</div>
|
| 188 |
+
<div className="acct-meter-head">
|
| 189 |
+
<span className={"acct-meter-value" + (load.usage.over ? " is-over" : "")}>
|
| 190 |
+
{fmt.int(load.usage.total)}
|
| 191 |
+
</span>
|
| 192 |
+
<span className="acct-meter-of">
|
| 193 |
+
of {fmt.int(load.usage.allowance)} tokens
|
| 194 |
+
</span>
|
| 195 |
+
</div>
|
| 196 |
+
<Bar pct={barPct(load.usage.total, load.usage.allowance)} over={load.usage.over} />
|
| 197 |
+
<div className="acct-meter-foot">
|
| 198 |
+
{/* Both facts are the SERVER'S: the week it counted and the date it rolls over.
|
| 199 |
+
A week boundary computed in the browser can disagree with the bucketing that
|
| 200 |
+
produced the number above it, and then the page states a period nobody
|
| 201 |
+
measured. */}
|
| 202 |
+
<span>
|
| 203 |
+
Week {load.usage.week}. Resets {fmt.date(load.usage.resets)}.
|
| 204 |
+
</span>
|
| 205 |
+
<span className="acct-meter-calls">
|
| 206 |
+
{fmt.int(load.usage.calls)} {load.usage.calls === 1 ? "call" : "calls"}
|
| 207 |
+
</span>
|
| 208 |
+
</div>
|
| 209 |
+
</div>
|
| 210 |
+
|
| 211 |
+
{/* β R9's whole point, in one line: over the allowance it STILL WORKS. The bar is
|
| 212 |
+
red, nothing is cut off, and the sentence says which of those two is true so
|
| 213 |
+
nobody has to infer it from a colour. */}
|
| 214 |
+
{load.usage.over ? (
|
| 215 |
+
<p className="acct-error" role="status">
|
| 216 |
+
This workspace is over its weekly allowance. Nothing is cut off; the AI surfaces
|
| 217 |
+
keep working.
|
| 218 |
+
</p>
|
| 219 |
+
) : null}
|
| 220 |
+
|
| 221 |
+
{/* The server's own sentence, present only when something really was unmeasured. */}
|
| 222 |
+
{load.usage.note ? <p className="acct-note acct-note--inline">{load.usage.note}</p> : null}
|
| 223 |
+
|
| 224 |
+
<h2 className="acct-section">By surface</h2>
|
| 225 |
+
<UsageSurfaceRows usage={load.usage} />
|
| 226 |
+
|
| 227 |
+
{load.usage.unattributed > 0 ? (
|
| 228 |
+
// β LABELLED A WIRING GAP, NOT USAGE (E-9). These are AI calls this container could
|
| 229 |
+
// not attribute to an account. Folding them into somebody's total would be an
|
| 230 |
+
// invented attribution, and leaving them out entirely would understate the week.
|
| 231 |
+
<p className="acct-note acct-note--inline">
|
| 232 |
+
{fmt.int(load.usage.unattributed)} tokens this week could not be attributed to an
|
| 233 |
+
account. That is a wiring gap in the meter, not somebody's usage.
|
| 234 |
+
</p>
|
| 235 |
+
) : null}
|
| 236 |
+
</>
|
| 237 |
+
)}
|
| 238 |
+
</div>
|
| 239 |
+
</div>
|
| 240 |
+
);
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
export default function UsagePage() {
|
| 244 |
+
const [load, setLoad] = useState<UsageLoad>({ phase: "loading" });
|
| 245 |
+
const [scope, setScope] = useState<"me" | "tenant">("me");
|
| 246 |
+
/**
|
| 247 |
+
* Whether the workspace view exists for this account, ANSWERED BY ASKING ONCE.
|
| 248 |
+
*
|
| 249 |
+
* β WHY A PROBE RATHER THAN A ROLE CHECK. The frame knows the role, but these pages take no
|
| 250 |
+
* props by contract (C6's trap: a page that decides its own visibility is a second opinion that
|
| 251 |
+
* will one day disagree with the route). The route itself is the authority, so the page asks it
|
| 252 |
+
* once: a 200 means the door is open and the toggle is real; a 403 means it is not and no
|
| 253 |
+
* control is drawn. β That 403 is an ANSWER, not an error β it is never shown to the reader.
|
| 254 |
+
* β AND IT IS WHAT GIVES `?scope=tenant` A DOOR. E built it admin-only; without this it would
|
| 255 |
+
* be a finished capability with no caller, which is this repo's most-repeated failure
|
| 256 |
+
* [[reachable-is-not-the-same-as-built]].
|
| 257 |
+
*/
|
| 258 |
+
const [canSeeTenant, setCanSeeTenant] = useState(false);
|
| 259 |
+
const gen = useRef(0);
|
| 260 |
+
|
| 261 |
+
const read = useCallback((which: "me" | "tenant") => {
|
| 262 |
+
const mine = gen.current + 1;
|
| 263 |
+
gen.current = mine;
|
| 264 |
+
setLoad({ phase: "loading" });
|
| 265 |
+
void loadUsage(which === "tenant" ? "tenant" : undefined).then((r) => {
|
| 266 |
+
if (gen.current !== mine) return;
|
| 267 |
+
setLoad(r.ok ? { phase: "ready", usage: r.value } : { phase: "failed", message: r.message });
|
| 268 |
+
});
|
| 269 |
+
}, []);
|
| 270 |
+
|
| 271 |
+
useEffect(() => {
|
| 272 |
+
read(scope);
|
| 273 |
+
return () => {
|
| 274 |
+
gen.current += 1;
|
| 275 |
+
};
|
| 276 |
+
}, [read, scope]);
|
| 277 |
+
|
| 278 |
+
useEffect(() => {
|
| 279 |
+
let live = true;
|
| 280 |
+
void loadUsage("tenant").then((r) => {
|
| 281 |
+
if (live) setCanSeeTenant(r.ok);
|
| 282 |
+
});
|
| 283 |
+
return () => {
|
| 284 |
+
live = false;
|
| 285 |
+
};
|
| 286 |
+
}, []);
|
| 287 |
+
|
| 288 |
+
return (
|
| 289 |
+
<UsageView
|
| 290 |
+
load={load}
|
| 291 |
+
scope={scope}
|
| 292 |
+
canSeeTenant={canSeeTenant}
|
| 293 |
+
onScope={setScope}
|
| 294 |
+
onRetry={() => read(scope)}
|
| 295 |
+
/>
|
| 296 |
+
);
|
| 297 |
+
}
|
web/src/account/account.css
CHANGED
|
@@ -1,372 +1,372 @@
|
|
| 1 |
-
/* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 2 |
-
account/account.css β WAVE 35, owner item 13: the three surfaces that hang off the
|
| 3 |
-
account button (Feedback, Usage
|
| 4 |
-
|
| 5 |
-
β ITS OWN STYLESHEET, NOT index.css. That file belongs to the INTEGRATOR for the whole
|
| 6 |
-
wave, and the wave's own rule is that a new surface brings its own sheet. Every selector
|
| 7 |
-
here is .acct-*; nothing in this file redefines a .home-*, .shell-* or .cg-* rule, so
|
| 8 |
-
nothing here can win or lose a source-order fight with the shared sheet.
|
| 9 |
-
|
| 10 |
-
β .acct-page RESTATES .shell-home's five box declarations rather than reusing that class,
|
| 11 |
-
and that is deliberate rather than lazy. .shell-home is Home's box by name and by its own
|
| 12 |
-
region header; borrowing it would make every future Home layout change silently a change
|
| 13 |
-
to three account pages. The VALUES are copied on purpose β DESIGN.md 2's sibling rule
|
| 14 |
-
("a panel matches its sibling's rendered values, not merely the scale") is what makes the
|
| 15 |
-
account pages read as the same system as Home, and .shell-main is overflow:hidden, so a
|
| 16 |
-
page that does not own its own scroll box simply loses everything below the fold.
|
| 17 |
-
|
| 18 |
-
β EVERY font-size IS A --lp-fs-* TOKEN. verify_ui globs src/**.css (every stylesheet in
|
| 19 |
-
the tree, not just index.css) and goes RED on a literal β wave-21 R5.
|
| 20 |
-
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 21 |
-
|
| 22 |
-
.acct-page {
|
| 23 |
-
height: 100%;
|
| 24 |
-
overflow-y: auto;
|
| 25 |
-
padding: 34px 44px 56px;
|
| 26 |
-
box-sizing: border-box;
|
| 27 |
-
background: var(--lp-wash);
|
| 28 |
-
}
|
| 29 |
-
|
| 30 |
-
/* A short plain noun, same size and weight as .home-title, because it is the same role on a
|
| 31 |
-
sibling surface. DESIGN.md 2: headers are 1 to 3 words, never a sentence. */
|
| 32 |
-
.acct-title {
|
| 33 |
-
margin: 0 0 20px;
|
| 34 |
-
font-size: var(--lp-fs-lg);
|
| 35 |
-
font-weight: 650;
|
| 36 |
-
color: var(--lp-ink);
|
| 37 |
-
}
|
| 38 |
-
|
| 39 |
-
/* THE ONE COLUMN. Every account surface is a reading-width column, not a full-bleed page:
|
| 40 |
-
a composer, a meter list and a note are all narrow objects, and stretching them to a
|
| 41 |
-
1600px window is how a page stops looking like it was designed. The reference the owner
|
| 42 |
-
gave for the assistant uses the same centred column for the same reason. */
|
| 43 |
-
.acct-col {
|
| 44 |
-
max-width: 640px;
|
| 45 |
-
}
|
| 46 |
-
|
| 47 |
-
/* ββ the honest note (Subscription today; any boarded account surface tomorrow) βββββββββ
|
| 48 |
-
ONE containment layer: a hairline panel on the wash, no card inside a card, no shadow
|
| 49 |
-
(DESIGN.md 4). It is a panel rather than a bare paragraph so the emptiness reads as
|
| 50 |
-
deliberate instead of as a page that failed to load. */
|
| 51 |
-
.acct-note {
|
| 52 |
-
margin: 0;
|
| 53 |
-
padding: 16px 18px;
|
| 54 |
-
border: 1px solid var(--lp-line);
|
| 55 |
-
border-radius: var(--lp-r-md);
|
| 56 |
-
background: var(--lp-surface);
|
| 57 |
-
font-size: var(--lp-fs-xs);
|
| 58 |
-
line-height: var(--lp-lh);
|
| 59 |
-
color: var(--lp-muted);
|
| 60 |
-
}
|
| 61 |
-
|
| 62 |
-
/* ββ loading, and the way back from a failed read βββββββββββββββββββββββββββββββββββββββββββ */
|
| 63 |
-
.acct-loading {
|
| 64 |
-
display: flex;
|
| 65 |
-
align-items: center;
|
| 66 |
-
justify-content: center;
|
| 67 |
-
min-height: 180px;
|
| 68 |
-
}
|
| 69 |
-
.acct-failed {
|
| 70 |
-
display: flex;
|
| 71 |
-
align-items: center;
|
| 72 |
-
gap: 12px;
|
| 73 |
-
}
|
| 74 |
-
/* DESIGN.md 4's quiet button, with the UA chrome reset explicitly (the wave-19 "mystery outer
|
| 75 |
-
lines and tinted fill" rule). */
|
| 76 |
-
.acct-retry {
|
| 77 |
-
flex: 0 0 auto;
|
| 78 |
-
padding: 6px 12px;
|
| 79 |
-
border: 1px solid var(--lp-line);
|
| 80 |
-
border-radius: var(--lp-r-md);
|
| 81 |
-
background: var(--lp-surface);
|
| 82 |
-
color: var(--lp-ink);
|
| 83 |
-
font: inherit;
|
| 84 |
-
font-size: var(--lp-fs-2xs);
|
| 85 |
-
font-weight: 600;
|
| 86 |
-
cursor: pointer;
|
| 87 |
-
}
|
| 88 |
-
.acct-retry:hover {
|
| 89 |
-
background: var(--lp-surface-2);
|
| 90 |
-
border-color: var(--lp-blue-deep);
|
| 91 |
-
}
|
| 92 |
-
|
| 93 |
-
/* ββ the two answers a write can give βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 94 |
-
Green = positive, red = negative: the fixed semantics (DESIGN.md 3), on the DEEP companions
|
| 95 |
-
because a pastel on white is not a light aesthetic, it is an unreadable one. */
|
| 96 |
-
.acct-ok,
|
| 97 |
-
.acct-error {
|
| 98 |
-
margin: 0 0 12px;
|
| 99 |
-
padding: 9px 12px;
|
| 100 |
-
border-radius: var(--lp-r-md);
|
| 101 |
-
font-size: var(--lp-fs-2xs);
|
| 102 |
-
line-height: var(--lp-lh);
|
| 103 |
-
}
|
| 104 |
-
.acct-ok {
|
| 105 |
-
border: 1px solid var(--lp-green);
|
| 106 |
-
background: var(--lp-green-tint);
|
| 107 |
-
color: var(--lp-green-deep);
|
| 108 |
-
}
|
| 109 |
-
.acct-error {
|
| 110 |
-
border: 1px solid var(--lp-red);
|
| 111 |
-
background: var(--lp-red-tint);
|
| 112 |
-
color: var(--lp-red-deep);
|
| 113 |
-
}
|
| 114 |
-
|
| 115 |
-
/* ββ the composer βββββββββββββββββββββββββββββ
|
| 116 |
-
The ASSISTANT'S anatomy in this module's own rules: one rounded field that contains its own
|
| 117 |
-
control row, so the category and the send button read as part of the thing being written
|
| 118 |
-
rather than as chrome around it. `assistant.css` is another session's file this wave and is
|
| 119 |
-
deliberately not imported; the shape is borrowed, the rules are local.
|
| 120 |
-
β The radius is smaller than the assistant's 22px pill on purpose: this field is a paragraph
|
| 121 |
-
box, not a one-line prompt, and a heavily rounded tall box reads as a speech bubble. */
|
| 122 |
-
.acct-field {
|
| 123 |
-
border: 1px solid var(--lp-line);
|
| 124 |
-
border-radius: var(--lp-r-lg);
|
| 125 |
-
background: var(--lp-surface);
|
| 126 |
-
padding: 12px 10px 10px 14px;
|
| 127 |
-
}
|
| 128 |
-
.acct-field:focus-within {
|
| 129 |
-
border-color: var(--lp-blue-solid);
|
| 130 |
-
}
|
| 131 |
-
.acct-prompt {
|
| 132 |
-
display: block;
|
| 133 |
-
width: 100%;
|
| 134 |
-
border: 0;
|
| 135 |
-
outline: none;
|
| 136 |
-
padding: 0 6px 0 0;
|
| 137 |
-
background: transparent;
|
| 138 |
-
color: var(--lp-ink);
|
| 139 |
-
font: inherit;
|
| 140 |
-
font-size: var(--lp-fs-sm);
|
| 141 |
-
line-height: var(--lp-lh);
|
| 142 |
-
resize: vertical;
|
| 143 |
-
box-sizing: border-box;
|
| 144 |
-
}
|
| 145 |
-
.acct-prompt::placeholder {
|
| 146 |
-
color: var(--lp-muted);
|
| 147 |
-
}
|
| 148 |
-
.acct-field-foot {
|
| 149 |
-
display: flex;
|
| 150 |
-
align-items: center;
|
| 151 |
-
gap: 10px;
|
| 152 |
-
margin-top: 10px;
|
| 153 |
-
}
|
| 154 |
-
.acct-pick {
|
| 155 |
-
display: inline-flex;
|
| 156 |
-
align-items: center;
|
| 157 |
-
gap: 6px;
|
| 158 |
-
min-width: 0;
|
| 159 |
-
}
|
| 160 |
-
.acct-pick-label {
|
| 161 |
-
color: var(--lp-muted);
|
| 162 |
-
font-size: var(--lp-fs-2xs);
|
| 163 |
-
}
|
| 164 |
-
.acct-select {
|
| 165 |
-
max-width: 220px;
|
| 166 |
-
padding: 4px 8px;
|
| 167 |
-
border: 1px solid var(--lp-line);
|
| 168 |
-
border-radius: var(--lp-r-sm);
|
| 169 |
-
background: var(--lp-surface);
|
| 170 |
-
color: var(--lp-ink);
|
| 171 |
-
font: inherit;
|
| 172 |
-
font-size: var(--lp-fs-2xs);
|
| 173 |
-
cursor: pointer;
|
| 174 |
-
}
|
| 175 |
-
/* Tabular figures so the count does not jitter as it climbs. Red only once the limit is actually
|
| 176 |
-
passed: a counter that warns before anything is wrong trains people to ignore it. */
|
| 177 |
-
.acct-count {
|
| 178 |
-
color: var(--lp-muted);
|
| 179 |
-
font-size: var(--lp-fs-3xs);
|
| 180 |
-
font-variant-numeric: tabular-nums;
|
| 181 |
-
}
|
| 182 |
-
.acct-count.is-over {
|
| 183 |
-
color: var(--lp-red-deep);
|
| 184 |
-
font-weight: 600;
|
| 185 |
-
}
|
| 186 |
-
/* The send button takes the ink fill when it can send and the wash when it cannot, which is the
|
| 187 |
-
assistant's own rule: the control says whether there is anything to send before it is pressed. */
|
| 188 |
-
.acct-send {
|
| 189 |
-
flex: 0 0 auto;
|
| 190 |
-
margin-left: auto;
|
| 191 |
-
width: 30px;
|
| 192 |
-
height: 30px;
|
| 193 |
-
display: inline-flex;
|
| 194 |
-
align-items: center;
|
| 195 |
-
justify-content: center;
|
| 196 |
-
border: 0;
|
| 197 |
-
border-radius: var(--lp-r-pill);
|
| 198 |
-
background: var(--lp-ink);
|
| 199 |
-
color: var(--lp-surface);
|
| 200 |
-
font: inherit;
|
| 201 |
-
cursor: pointer;
|
| 202 |
-
}
|
| 203 |
-
.acct-send:disabled {
|
| 204 |
-
background: var(--lp-surface-2);
|
| 205 |
-
color: var(--lp-muted);
|
| 206 |
-
cursor: default;
|
| 207 |
-
}
|
| 208 |
-
.acct-send-icon {
|
| 209 |
-
fill: none;
|
| 210 |
-
stroke: currentColor;
|
| 211 |
-
stroke-width: 1.7;
|
| 212 |
-
stroke-linecap: round;
|
| 213 |
-
stroke-linejoin: round;
|
| 214 |
-
}
|
| 215 |
-
|
| 216 |
-
/* ββ the usage meter (R9) βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 217 |
-
One containment layer, hairline, no shadow. The bar is the only new SHAPE on these pages, and
|
| 218 |
-
the ticket asks for it by name ("over the allowance the bar is red"). */
|
| 219 |
-
.acct-note--inline {
|
| 220 |
-
margin: 12px 0 0;
|
| 221 |
-
padding: 9px 12px;
|
| 222 |
-
font-size: var(--lp-fs-2xs);
|
| 223 |
-
}
|
| 224 |
-
.acct-scope {
|
| 225 |
-
display: inline-flex;
|
| 226 |
-
gap: 2px;
|
| 227 |
-
margin-bottom: 14px;
|
| 228 |
-
padding: 2px;
|
| 229 |
-
border: 1px solid var(--lp-line);
|
| 230 |
-
border-radius: var(--lp-r-md);
|
| 231 |
-
background: var(--lp-surface);
|
| 232 |
-
}
|
| 233 |
-
/* Selection is TINT plus WEIGHT with the ink left alone, which is `.shell-nav-item.is-active`'s
|
| 234 |
-
rule. β NOT `--lp-blue-deep` on `--lp-blue-tint`: that pair measures 2.96:1, under the 4.5:1
|
| 235 |
-
text bar and under the 3:1 graphical one, and this control holds words. */
|
| 236 |
-
.acct-scope-btn {
|
| 237 |
-
padding: 4px 12px;
|
| 238 |
-
border: 0;
|
| 239 |
-
border-radius: var(--lp-r-sm);
|
| 240 |
-
background: transparent;
|
| 241 |
-
color: var(--lp-muted);
|
| 242 |
-
font: inherit;
|
| 243 |
-
font-size: var(--lp-fs-2xs);
|
| 244 |
-
font-weight: 600;
|
| 245 |
-
cursor: pointer;
|
| 246 |
-
}
|
| 247 |
-
.acct-scope-btn:hover {
|
| 248 |
-
background: var(--lp-surface-2);
|
| 249 |
-
}
|
| 250 |
-
.acct-scope-btn.is-on {
|
| 251 |
-
background: var(--lp-blue-tint);
|
| 252 |
-
color: var(--lp-ink);
|
| 253 |
-
}
|
| 254 |
-
|
| 255 |
-
.acct-meter {
|
| 256 |
-
padding: 16px 18px;
|
| 257 |
-
border: 1px solid var(--lp-line);
|
| 258 |
-
border-radius: var(--lp-r-md);
|
| 259 |
-
background: var(--lp-surface);
|
| 260 |
-
}
|
| 261 |
-
.acct-meter-head {
|
| 262 |
-
display: flex;
|
| 263 |
-
align-items: baseline;
|
| 264 |
-
gap: 8px;
|
| 265 |
-
flex-wrap: wrap;
|
| 266 |
-
}
|
| 267 |
-
.acct-meter-who {
|
| 268 |
-
font-size: var(--lp-fs-2xs);
|
| 269 |
-
font-weight: 600;
|
| 270 |
-
color: var(--lp-muted);
|
| 271 |
-
margin-bottom: 4px;
|
| 272 |
-
}
|
| 273 |
-
/* Tabular figures on every financial-grade number (DESIGN.md 2), so a total that climbs does not
|
| 274 |
-
make the label beside it dance. Weight 650, never a display weight. */
|
| 275 |
-
.acct-meter-value {
|
| 276 |
-
font-size: var(--lp-fs-xl);
|
| 277 |
-
font-weight: 650;
|
| 278 |
-
color: var(--lp-ink);
|
| 279 |
-
font-variant-numeric: tabular-nums;
|
| 280 |
-
}
|
| 281 |
-
/* Red = negative/over, the fixed semantic, on the deep companion. */
|
| 282 |
-
.acct-meter-value.is-over {
|
| 283 |
-
color: var(--lp-red-deep);
|
| 284 |
-
}
|
| 285 |
-
.acct-meter-of {
|
| 286 |
-
font-size: var(--lp-fs-xs);
|
| 287 |
-
color: var(--lp-muted);
|
| 288 |
-
font-variant-numeric: tabular-nums;
|
| 289 |
-
}
|
| 290 |
-
.acct-meter-foot {
|
| 291 |
-
display: flex;
|
| 292 |
-
align-items: baseline;
|
| 293 |
-
justify-content: space-between;
|
| 294 |
-
gap: 12px;
|
| 295 |
-
margin-top: 8px;
|
| 296 |
-
font-size: var(--lp-fs-2xs);
|
| 297 |
-
color: var(--lp-muted);
|
| 298 |
-
}
|
| 299 |
-
.acct-meter-calls {
|
| 300 |
-
flex: 0 0 auto;
|
| 301 |
-
font-variant-numeric: tabular-nums;
|
| 302 |
-
}
|
| 303 |
-
|
| 304 |
-
/* The track is the wash so an empty meter still reads as a meter rather than as a missing
|
| 305 |
-
element; the fill is the brand at rest and red once the allowance is passed. */
|
| 306 |
-
.acct-bar {
|
| 307 |
-
display: block;
|
| 308 |
-
width: 100%;
|
| 309 |
-
height: 8px;
|
| 310 |
-
margin-top: 10px;
|
| 311 |
-
border-radius: var(--lp-r-pill);
|
| 312 |
-
background: var(--lp-wash);
|
| 313 |
-
overflow: hidden;
|
| 314 |
-
}
|
| 315 |
-
.acct-bar-fill {
|
| 316 |
-
display: block;
|
| 317 |
-
height: 100%;
|
| 318 |
-
border-radius: var(--lp-r-pill);
|
| 319 |
-
background: var(--lp-primary);
|
| 320 |
-
transition: width 0.18s var(--lp-fold-e);
|
| 321 |
-
}
|
| 322 |
-
.acct-bar-fill.is-over {
|
| 323 |
-
background: var(--lp-red-deep);
|
| 324 |
-
}
|
| 325 |
-
|
| 326 |
-
/* A short plain noun, muted, sentence case, matching `.home-section-title` exactly: the same role
|
| 327 |
-
on a sibling surface takes the same rendered values (DESIGN.md 2). */
|
| 328 |
-
.acct-section {
|
| 329 |
-
margin: 22px 0 9px;
|
| 330 |
-
font-size: var(--lp-fs-2xs);
|
| 331 |
-
font-weight: 600;
|
| 332 |
-
color: var(--lp-muted);
|
| 333 |
-
}
|
| 334 |
-
.acct-rows {
|
| 335 |
-
display: flex;
|
| 336 |
-
flex-direction: column;
|
| 337 |
-
gap: 2px;
|
| 338 |
-
}
|
| 339 |
-
/* One row per surface: name, share bar, tokens, calls. Fixed columns so the four numbers form a
|
| 340 |
-
column a reader can scan rather than four numbers at four different left edges. */
|
| 341 |
-
.acct-row {
|
| 342 |
-
display: grid;
|
| 343 |
-
grid-template-columns: 120px 1fr 92px 150px;
|
| 344 |
-
align-items: center;
|
| 345 |
-
gap: 12px;
|
| 346 |
-
padding: 8px 2px;
|
| 347 |
-
border-bottom: 1px solid var(--lp-line);
|
| 348 |
-
}
|
| 349 |
-
.acct-row:last-child {
|
| 350 |
-
border-bottom: 0;
|
| 351 |
-
}
|
| 352 |
-
.acct-row-name {
|
| 353 |
-
font-size: var(--lp-fs-xs);
|
| 354 |
-
font-weight: 600;
|
| 355 |
-
color: var(--lp-ink);
|
| 356 |
-
}
|
| 357 |
-
/* Numbers right-aligned, text left (DESIGN.md 2), tabular so the column lines up digit for digit. */
|
| 358 |
-
.acct-row-num {
|
| 359 |
-
text-align: right;
|
| 360 |
-
font-size: var(--lp-fs-xs);
|
| 361 |
-
color: var(--lp-ink);
|
| 362 |
-
font-variant-numeric: tabular-nums;
|
| 363 |
-
}
|
| 364 |
-
.acct-row-sub {
|
| 365 |
-
font-size: var(--lp-fs-2xs);
|
| 366 |
-
color: var(--lp-muted);
|
| 367 |
-
font-variant-numeric: tabular-nums;
|
| 368 |
-
}
|
| 369 |
-
.acct-row .acct-bar {
|
| 370 |
-
margin-top: 0;
|
| 371 |
-
height: 6px;
|
| 372 |
-
}
|
|
|
|
| 1 |
+
/* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 2 |
+
account/account.css β WAVE 35, owner item 13: the three surfaces that hang off the
|
| 3 |
+
account button (Feedback, Usage, Subscription).
|
| 4 |
+
|
| 5 |
+
β ITS OWN STYLESHEET, NOT index.css. That file belongs to the INTEGRATOR for the whole
|
| 6 |
+
wave, and the wave's own rule is that a new surface brings its own sheet. Every selector
|
| 7 |
+
here is .acct-*; nothing in this file redefines a .home-*, .shell-* or .cg-* rule, so
|
| 8 |
+
nothing here can win or lose a source-order fight with the shared sheet.
|
| 9 |
+
|
| 10 |
+
β .acct-page RESTATES .shell-home's five box declarations rather than reusing that class,
|
| 11 |
+
and that is deliberate rather than lazy. .shell-home is Home's box by name and by its own
|
| 12 |
+
region header; borrowing it would make every future Home layout change silently a change
|
| 13 |
+
to three account pages. The VALUES are copied on purpose β DESIGN.md 2's sibling rule
|
| 14 |
+
("a panel matches its sibling's rendered values, not merely the scale") is what makes the
|
| 15 |
+
account pages read as the same system as Home, and .shell-main is overflow:hidden, so a
|
| 16 |
+
page that does not own its own scroll box simply loses everything below the fold.
|
| 17 |
+
|
| 18 |
+
β EVERY font-size IS A --lp-fs-* TOKEN. verify_ui globs src/**.css (every stylesheet in
|
| 19 |
+
the tree, not just index.css) and goes RED on a literal β wave-21 R5.
|
| 20 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 21 |
+
|
| 22 |
+
.acct-page {
|
| 23 |
+
height: 100%;
|
| 24 |
+
overflow-y: auto;
|
| 25 |
+
padding: 34px 44px 56px;
|
| 26 |
+
box-sizing: border-box;
|
| 27 |
+
background: var(--lp-wash);
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
/* A short plain noun, same size and weight as .home-title, because it is the same role on a
|
| 31 |
+
sibling surface. DESIGN.md 2: headers are 1 to 3 words, never a sentence. */
|
| 32 |
+
.acct-title {
|
| 33 |
+
margin: 0 0 20px;
|
| 34 |
+
font-size: var(--lp-fs-lg);
|
| 35 |
+
font-weight: 650;
|
| 36 |
+
color: var(--lp-ink);
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
/* THE ONE COLUMN. Every account surface is a reading-width column, not a full-bleed page:
|
| 40 |
+
a composer, a meter list and a note are all narrow objects, and stretching them to a
|
| 41 |
+
1600px window is how a page stops looking like it was designed. The reference the owner
|
| 42 |
+
gave for the assistant uses the same centred column for the same reason. */
|
| 43 |
+
.acct-col {
|
| 44 |
+
max-width: 640px;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
/* ββ the honest note (Subscription today; any boarded account surface tomorrow) βββββββββ
|
| 48 |
+
ONE containment layer: a hairline panel on the wash, no card inside a card, no shadow
|
| 49 |
+
(DESIGN.md 4). It is a panel rather than a bare paragraph so the emptiness reads as
|
| 50 |
+
deliberate instead of as a page that failed to load. */
|
| 51 |
+
.acct-note {
|
| 52 |
+
margin: 0;
|
| 53 |
+
padding: 16px 18px;
|
| 54 |
+
border: 1px solid var(--lp-line);
|
| 55 |
+
border-radius: var(--lp-r-md);
|
| 56 |
+
background: var(--lp-surface);
|
| 57 |
+
font-size: var(--lp-fs-xs);
|
| 58 |
+
line-height: var(--lp-lh);
|
| 59 |
+
color: var(--lp-muted);
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
/* ββ loading, and the way back from a failed read βββββββββββββββββββββββββββββββββββββββββββ */
|
| 63 |
+
.acct-loading {
|
| 64 |
+
display: flex;
|
| 65 |
+
align-items: center;
|
| 66 |
+
justify-content: center;
|
| 67 |
+
min-height: 180px;
|
| 68 |
+
}
|
| 69 |
+
.acct-failed {
|
| 70 |
+
display: flex;
|
| 71 |
+
align-items: center;
|
| 72 |
+
gap: 12px;
|
| 73 |
+
}
|
| 74 |
+
/* DESIGN.md 4's quiet button, with the UA chrome reset explicitly (the wave-19 "mystery outer
|
| 75 |
+
lines and tinted fill" rule). */
|
| 76 |
+
.acct-retry {
|
| 77 |
+
flex: 0 0 auto;
|
| 78 |
+
padding: 6px 12px;
|
| 79 |
+
border: 1px solid var(--lp-line);
|
| 80 |
+
border-radius: var(--lp-r-md);
|
| 81 |
+
background: var(--lp-surface);
|
| 82 |
+
color: var(--lp-ink);
|
| 83 |
+
font: inherit;
|
| 84 |
+
font-size: var(--lp-fs-2xs);
|
| 85 |
+
font-weight: 600;
|
| 86 |
+
cursor: pointer;
|
| 87 |
+
}
|
| 88 |
+
.acct-retry:hover {
|
| 89 |
+
background: var(--lp-surface-2);
|
| 90 |
+
border-color: var(--lp-blue-deep);
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
/* ββ the two answers a write can give βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 94 |
+
Green = positive, red = negative: the fixed semantics (DESIGN.md 3), on the DEEP companions
|
| 95 |
+
because a pastel on white is not a light aesthetic, it is an unreadable one. */
|
| 96 |
+
.acct-ok,
|
| 97 |
+
.acct-error {
|
| 98 |
+
margin: 0 0 12px;
|
| 99 |
+
padding: 9px 12px;
|
| 100 |
+
border-radius: var(--lp-r-md);
|
| 101 |
+
font-size: var(--lp-fs-2xs);
|
| 102 |
+
line-height: var(--lp-lh);
|
| 103 |
+
}
|
| 104 |
+
.acct-ok {
|
| 105 |
+
border: 1px solid var(--lp-green);
|
| 106 |
+
background: var(--lp-green-tint);
|
| 107 |
+
color: var(--lp-green-deep);
|
| 108 |
+
}
|
| 109 |
+
.acct-error {
|
| 110 |
+
border: 1px solid var(--lp-red);
|
| 111 |
+
background: var(--lp-red-tint);
|
| 112 |
+
color: var(--lp-red-deep);
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
/* ββ the composer βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 116 |
+
The ASSISTANT'S anatomy in this module's own rules: one rounded field that contains its own
|
| 117 |
+
control row, so the category and the send button read as part of the thing being written
|
| 118 |
+
rather than as chrome around it. `assistant.css` is another session's file this wave and is
|
| 119 |
+
deliberately not imported; the shape is borrowed, the rules are local.
|
| 120 |
+
β The radius is smaller than the assistant's 22px pill on purpose: this field is a paragraph
|
| 121 |
+
box, not a one-line prompt, and a heavily rounded tall box reads as a speech bubble. */
|
| 122 |
+
.acct-field {
|
| 123 |
+
border: 1px solid var(--lp-line);
|
| 124 |
+
border-radius: var(--lp-r-lg);
|
| 125 |
+
background: var(--lp-surface);
|
| 126 |
+
padding: 12px 10px 10px 14px;
|
| 127 |
+
}
|
| 128 |
+
.acct-field:focus-within {
|
| 129 |
+
border-color: var(--lp-blue-solid);
|
| 130 |
+
}
|
| 131 |
+
.acct-prompt {
|
| 132 |
+
display: block;
|
| 133 |
+
width: 100%;
|
| 134 |
+
border: 0;
|
| 135 |
+
outline: none;
|
| 136 |
+
padding: 0 6px 0 0;
|
| 137 |
+
background: transparent;
|
| 138 |
+
color: var(--lp-ink);
|
| 139 |
+
font: inherit;
|
| 140 |
+
font-size: var(--lp-fs-sm);
|
| 141 |
+
line-height: var(--lp-lh);
|
| 142 |
+
resize: vertical;
|
| 143 |
+
box-sizing: border-box;
|
| 144 |
+
}
|
| 145 |
+
.acct-prompt::placeholder {
|
| 146 |
+
color: var(--lp-muted);
|
| 147 |
+
}
|
| 148 |
+
.acct-field-foot {
|
| 149 |
+
display: flex;
|
| 150 |
+
align-items: center;
|
| 151 |
+
gap: 10px;
|
| 152 |
+
margin-top: 10px;
|
| 153 |
+
}
|
| 154 |
+
.acct-pick {
|
| 155 |
+
display: inline-flex;
|
| 156 |
+
align-items: center;
|
| 157 |
+
gap: 6px;
|
| 158 |
+
min-width: 0;
|
| 159 |
+
}
|
| 160 |
+
.acct-pick-label {
|
| 161 |
+
color: var(--lp-muted);
|
| 162 |
+
font-size: var(--lp-fs-2xs);
|
| 163 |
+
}
|
| 164 |
+
.acct-select {
|
| 165 |
+
max-width: 220px;
|
| 166 |
+
padding: 4px 8px;
|
| 167 |
+
border: 1px solid var(--lp-line);
|
| 168 |
+
border-radius: var(--lp-r-sm);
|
| 169 |
+
background: var(--lp-surface);
|
| 170 |
+
color: var(--lp-ink);
|
| 171 |
+
font: inherit;
|
| 172 |
+
font-size: var(--lp-fs-2xs);
|
| 173 |
+
cursor: pointer;
|
| 174 |
+
}
|
| 175 |
+
/* Tabular figures so the count does not jitter as it climbs. Red only once the limit is actually
|
| 176 |
+
passed: a counter that warns before anything is wrong trains people to ignore it. */
|
| 177 |
+
.acct-count {
|
| 178 |
+
color: var(--lp-muted);
|
| 179 |
+
font-size: var(--lp-fs-3xs);
|
| 180 |
+
font-variant-numeric: tabular-nums;
|
| 181 |
+
}
|
| 182 |
+
.acct-count.is-over {
|
| 183 |
+
color: var(--lp-red-deep);
|
| 184 |
+
font-weight: 600;
|
| 185 |
+
}
|
| 186 |
+
/* The send button takes the ink fill when it can send and the wash when it cannot, which is the
|
| 187 |
+
assistant's own rule: the control says whether there is anything to send before it is pressed. */
|
| 188 |
+
.acct-send {
|
| 189 |
+
flex: 0 0 auto;
|
| 190 |
+
margin-left: auto;
|
| 191 |
+
width: 30px;
|
| 192 |
+
height: 30px;
|
| 193 |
+
display: inline-flex;
|
| 194 |
+
align-items: center;
|
| 195 |
+
justify-content: center;
|
| 196 |
+
border: 0;
|
| 197 |
+
border-radius: var(--lp-r-pill);
|
| 198 |
+
background: var(--lp-ink);
|
| 199 |
+
color: var(--lp-surface);
|
| 200 |
+
font: inherit;
|
| 201 |
+
cursor: pointer;
|
| 202 |
+
}
|
| 203 |
+
.acct-send:disabled {
|
| 204 |
+
background: var(--lp-surface-2);
|
| 205 |
+
color: var(--lp-muted);
|
| 206 |
+
cursor: default;
|
| 207 |
+
}
|
| 208 |
+
.acct-send-icon {
|
| 209 |
+
fill: none;
|
| 210 |
+
stroke: currentColor;
|
| 211 |
+
stroke-width: 1.7;
|
| 212 |
+
stroke-linecap: round;
|
| 213 |
+
stroke-linejoin: round;
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
/* ββ the usage meter (R9) βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 217 |
+
One containment layer, hairline, no shadow. The bar is the only new SHAPE on these pages, and
|
| 218 |
+
the ticket asks for it by name ("over the allowance the bar is red"). */
|
| 219 |
+
.acct-note--inline {
|
| 220 |
+
margin: 12px 0 0;
|
| 221 |
+
padding: 9px 12px;
|
| 222 |
+
font-size: var(--lp-fs-2xs);
|
| 223 |
+
}
|
| 224 |
+
.acct-scope {
|
| 225 |
+
display: inline-flex;
|
| 226 |
+
gap: 2px;
|
| 227 |
+
margin-bottom: 14px;
|
| 228 |
+
padding: 2px;
|
| 229 |
+
border: 1px solid var(--lp-line);
|
| 230 |
+
border-radius: var(--lp-r-md);
|
| 231 |
+
background: var(--lp-surface);
|
| 232 |
+
}
|
| 233 |
+
/* Selection is TINT plus WEIGHT with the ink left alone, which is `.shell-nav-item.is-active`'s
|
| 234 |
+
rule. β NOT `--lp-blue-deep` on `--lp-blue-tint`: that pair measures 2.96:1, under the 4.5:1
|
| 235 |
+
text bar and under the 3:1 graphical one, and this control holds words. */
|
| 236 |
+
.acct-scope-btn {
|
| 237 |
+
padding: 4px 12px;
|
| 238 |
+
border: 0;
|
| 239 |
+
border-radius: var(--lp-r-sm);
|
| 240 |
+
background: transparent;
|
| 241 |
+
color: var(--lp-muted);
|
| 242 |
+
font: inherit;
|
| 243 |
+
font-size: var(--lp-fs-2xs);
|
| 244 |
+
font-weight: 600;
|
| 245 |
+
cursor: pointer;
|
| 246 |
+
}
|
| 247 |
+
.acct-scope-btn:hover {
|
| 248 |
+
background: var(--lp-surface-2);
|
| 249 |
+
}
|
| 250 |
+
.acct-scope-btn.is-on {
|
| 251 |
+
background: var(--lp-blue-tint);
|
| 252 |
+
color: var(--lp-ink);
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
.acct-meter {
|
| 256 |
+
padding: 16px 18px;
|
| 257 |
+
border: 1px solid var(--lp-line);
|
| 258 |
+
border-radius: var(--lp-r-md);
|
| 259 |
+
background: var(--lp-surface);
|
| 260 |
+
}
|
| 261 |
+
.acct-meter-head {
|
| 262 |
+
display: flex;
|
| 263 |
+
align-items: baseline;
|
| 264 |
+
gap: 8px;
|
| 265 |
+
flex-wrap: wrap;
|
| 266 |
+
}
|
| 267 |
+
.acct-meter-who {
|
| 268 |
+
font-size: var(--lp-fs-2xs);
|
| 269 |
+
font-weight: 600;
|
| 270 |
+
color: var(--lp-muted);
|
| 271 |
+
margin-bottom: 4px;
|
| 272 |
+
}
|
| 273 |
+
/* Tabular figures on every financial-grade number (DESIGN.md 2), so a total that climbs does not
|
| 274 |
+
make the label beside it dance. Weight 650, never a display weight. */
|
| 275 |
+
.acct-meter-value {
|
| 276 |
+
font-size: var(--lp-fs-xl);
|
| 277 |
+
font-weight: 650;
|
| 278 |
+
color: var(--lp-ink);
|
| 279 |
+
font-variant-numeric: tabular-nums;
|
| 280 |
+
}
|
| 281 |
+
/* Red = negative/over, the fixed semantic, on the deep companion. */
|
| 282 |
+
.acct-meter-value.is-over {
|
| 283 |
+
color: var(--lp-red-deep);
|
| 284 |
+
}
|
| 285 |
+
.acct-meter-of {
|
| 286 |
+
font-size: var(--lp-fs-xs);
|
| 287 |
+
color: var(--lp-muted);
|
| 288 |
+
font-variant-numeric: tabular-nums;
|
| 289 |
+
}
|
| 290 |
+
.acct-meter-foot {
|
| 291 |
+
display: flex;
|
| 292 |
+
align-items: baseline;
|
| 293 |
+
justify-content: space-between;
|
| 294 |
+
gap: 12px;
|
| 295 |
+
margin-top: 8px;
|
| 296 |
+
font-size: var(--lp-fs-2xs);
|
| 297 |
+
color: var(--lp-muted);
|
| 298 |
+
}
|
| 299 |
+
.acct-meter-calls {
|
| 300 |
+
flex: 0 0 auto;
|
| 301 |
+
font-variant-numeric: tabular-nums;
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
/* The track is the wash so an empty meter still reads as a meter rather than as a missing
|
| 305 |
+
element; the fill is the brand at rest and red once the allowance is passed. */
|
| 306 |
+
.acct-bar {
|
| 307 |
+
display: block;
|
| 308 |
+
width: 100%;
|
| 309 |
+
height: 8px;
|
| 310 |
+
margin-top: 10px;
|
| 311 |
+
border-radius: var(--lp-r-pill);
|
| 312 |
+
background: var(--lp-wash);
|
| 313 |
+
overflow: hidden;
|
| 314 |
+
}
|
| 315 |
+
.acct-bar-fill {
|
| 316 |
+
display: block;
|
| 317 |
+
height: 100%;
|
| 318 |
+
border-radius: var(--lp-r-pill);
|
| 319 |
+
background: var(--lp-primary);
|
| 320 |
+
transition: width 0.18s var(--lp-fold-e);
|
| 321 |
+
}
|
| 322 |
+
.acct-bar-fill.is-over {
|
| 323 |
+
background: var(--lp-red-deep);
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
/* A short plain noun, muted, sentence case, matching `.home-section-title` exactly: the same role
|
| 327 |
+
on a sibling surface takes the same rendered values (DESIGN.md 2). */
|
| 328 |
+
.acct-section {
|
| 329 |
+
margin: 22px 0 9px;
|
| 330 |
+
font-size: var(--lp-fs-2xs);
|
| 331 |
+
font-weight: 600;
|
| 332 |
+
color: var(--lp-muted);
|
| 333 |
+
}
|
| 334 |
+
.acct-rows {
|
| 335 |
+
display: flex;
|
| 336 |
+
flex-direction: column;
|
| 337 |
+
gap: 2px;
|
| 338 |
+
}
|
| 339 |
+
/* One row per surface: name, share bar, tokens, calls. Fixed columns so the four numbers form a
|
| 340 |
+
column a reader can scan rather than four numbers at four different left edges. */
|
| 341 |
+
.acct-row {
|
| 342 |
+
display: grid;
|
| 343 |
+
grid-template-columns: 120px 1fr 92px 150px;
|
| 344 |
+
align-items: center;
|
| 345 |
+
gap: 12px;
|
| 346 |
+
padding: 8px 2px;
|
| 347 |
+
border-bottom: 1px solid var(--lp-line);
|
| 348 |
+
}
|
| 349 |
+
.acct-row:last-child {
|
| 350 |
+
border-bottom: 0;
|
| 351 |
+
}
|
| 352 |
+
.acct-row-name {
|
| 353 |
+
font-size: var(--lp-fs-xs);
|
| 354 |
+
font-weight: 600;
|
| 355 |
+
color: var(--lp-ink);
|
| 356 |
+
}
|
| 357 |
+
/* Numbers right-aligned, text left (DESIGN.md 2), tabular so the column lines up digit for digit. */
|
| 358 |
+
.acct-row-num {
|
| 359 |
+
text-align: right;
|
| 360 |
+
font-size: var(--lp-fs-xs);
|
| 361 |
+
color: var(--lp-ink);
|
| 362 |
+
font-variant-numeric: tabular-nums;
|
| 363 |
+
}
|
| 364 |
+
.acct-row-sub {
|
| 365 |
+
font-size: var(--lp-fs-2xs);
|
| 366 |
+
color: var(--lp-muted);
|
| 367 |
+
font-variant-numeric: tabular-nums;
|
| 368 |
+
}
|
| 369 |
+
.acct-row .acct-bar {
|
| 370 |
+
margin-top: 0;
|
| 371 |
+
height: 6px;
|
| 372 |
+
}
|
web/src/account/accountApi.ts
CHANGED
|
@@ -1,188 +1,188 @@
|
|
| 1 |
-
// ---------------------------------------------------------------------------
|
| 2 |
-
// account/accountApi.ts β WAVE 35, owner item 13: the wire behind the two account
|
| 3 |
-
// surfaces that have one (Feedback, contract C6/R8; Usage
|
| 4 |
-
//
|
| 5 |
-
// β THE CATEGORY VOCABULARY IS THE SERVER'S AND IS FETCHED, NOT DECLARED HERE.
|
| 6 |
-
// That is a decision, not an omission: a client constant beside a server enum is
|
| 7 |
-
// two lists that drift, and the day they do the dropdown offers a value the door
|
| 8 |
-
// refuses. Asked and settled in mailbox B-1 / E-9 β the server serves the list,
|
| 9 |
-
// this file renders it, and there is no local fallback to drift TO.
|
| 10 |
-
//
|
| 11 |
-
// β AND THERE IS NO TENANT-SIDE READ OF A SUBMISSION AT ALL (R8). No "my
|
| 12 |
-
// feedback" list, no edit, no delete: the operator plane is the only reader. The
|
| 13 |
-
// 201 is the receipt. If that ever feels like a gap, it is R8, not a missing
|
| 14 |
-
// endpoint.
|
| 15 |
-
//
|
| 16 |
-
// β NOTHING ON THE USAGE SIDE IS COMPUTED HERE. The allowance, the week and the
|
| 17 |
-
// reset date all ride the payload (E-9), because a week boundary computed in the
|
| 18 |
-
// browser can disagree with the server's own bucketing β and then the page shows
|
| 19 |
-
// a number that traces to no ledger line, which is exactly what R9's meter must
|
| 20 |
-
// never do.
|
| 21 |
-
// ---------------------------------------------------------------------------
|
| 22 |
-
|
| 23 |
-
import { API_V1, CREDENTIALS, UNAUTHORIZED_EVENT, checkTenant, signal } from "../apiContract";
|
| 24 |
-
|
| 25 |
-
/** The same discriminated result the other feature modules use. */
|
| 26 |
-
export type Result<T> =
|
| 27 |
-
| { ok: true; value: T }
|
| 28 |
-
| { ok: false; status: number; message: string };
|
| 29 |
-
|
| 30 |
-
async function call<T>(
|
| 31 |
-
path: string,
|
| 32 |
-
init: RequestInit,
|
| 33 |
-
read: (body: unknown) => T
|
| 34 |
-
): Promise<Result<T>> {
|
| 35 |
-
let res: Response;
|
| 36 |
-
try {
|
| 37 |
-
res = await fetch(`${API_V1}${path}`, { credentials: CREDENTIALS, ...init });
|
| 38 |
-
} catch {
|
| 39 |
-
return { ok: false, status: 0, message: "Cannot reach the server." };
|
| 40 |
-
}
|
| 41 |
-
if (checkTenant(res)) return { ok: false, status: 0, message: "Reloading." };
|
| 42 |
-
if (res.status === 401) signal(UNAUTHORIZED_EVENT);
|
| 43 |
-
const body = (await res.json().catch(() => null)) as unknown;
|
| 44 |
-
if (!res.ok) {
|
| 45 |
-
// β A 4xx MESSAGE IS POLICY AND IS SHOWN; a 5xx message is an internal detail and is not.
|
| 46 |
-
// `session.ts` states the same rule for sign-in, and E names each 400 case ("unknown
|
| 47 |
-
// category", "empty text", "over maxChars"), so the server's sentence is the useful one.
|
| 48 |
-
const detail = (body as { error?: { message?: string } } | null)?.error?.message;
|
| 49 |
-
return {
|
| 50 |
-
ok: false,
|
| 51 |
-
status: res.status,
|
| 52 |
-
message:
|
| 53 |
-
res.status >= 500 || !detail
|
| 54 |
-
? res.status >= 500
|
| 55 |
-
? "Something went wrong on our side. Try again in a moment."
|
| 56 |
-
: `The server answered ${res.status}.`
|
| 57 |
-
: detail,
|
| 58 |
-
};
|
| 59 |
-
}
|
| 60 |
-
return { ok: true, value: read(body) };
|
| 61 |
-
}
|
| 62 |
-
|
| 63 |
-
const row = (v: unknown): Record<string, unknown> =>
|
| 64 |
-
v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : {};
|
| 65 |
-
const str = (v: unknown): string => (typeof v === "string" ? v : "");
|
| 66 |
-
const int = (v: unknown): number =>
|
| 67 |
-
typeof v === "number" && Number.isFinite(v) && v >= 0 ? Math.floor(v) : 0;
|
| 68 |
-
|
| 69 |
-
// ββ FEEDBACK (R8, C6) βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 70 |
-
|
| 71 |
-
export interface FeedbackCategory {
|
| 72 |
-
key: string;
|
| 73 |
-
label: string;
|
| 74 |
-
}
|
| 75 |
-
|
| 76 |
-
export interface FeedbackForm {
|
| 77 |
-
categories: FeedbackCategory[];
|
| 78 |
-
maxChars: number;
|
| 79 |
-
}
|
| 80 |
-
|
| 81 |
-
/**
|
| 82 |
-
* `GET /api/v1/feedback/form` β the category list and the length limit, both the server's.
|
| 83 |
-
*
|
| 84 |
-
* β A CATEGORY WITH NO LABEL IS DROPPED, never rendered as its key: an internal identifier in a
|
| 85 |
-
* dropdown is the same defect `parsePages` refuses one layer up ("a nav row with no name is a
|
| 86 |
-
* door with no sign on it"). And an EMPTY list is a real answer the caller must handle, not a
|
| 87 |
-
* reason to invent one.
|
| 88 |
-
*/
|
| 89 |
-
export function parseFeedbackForm(body: unknown): FeedbackForm {
|
| 90 |
-
const r = row(body);
|
| 91 |
-
const categories = (Array.isArray(r.categories) ? r.categories : [])
|
| 92 |
-
.map(row)
|
| 93 |
-
.filter((c) => str(c.key) !== "" && str(c.label) !== "")
|
| 94 |
-
.map((c) => ({ key: str(c.key), label: str(c.label) }));
|
| 95 |
-
return { categories, maxChars: int(r.maxChars) };
|
| 96 |
-
}
|
| 97 |
-
|
| 98 |
-
export function loadFeedbackForm(): Promise<Result<FeedbackForm>> {
|
| 99 |
-
return call("/feedback/form", {}, parseFeedbackForm);
|
| 100 |
-
}
|
| 101 |
-
|
| 102 |
-
export function sendFeedback(category: string, text: string): Promise<Result<{ id: string }>> {
|
| 103 |
-
return call(
|
| 104 |
-
"/feedback",
|
| 105 |
-
{
|
| 106 |
-
method: "POST",
|
| 107 |
-
headers: { "Content-Type": "application/json" },
|
| 108 |
-
body: JSON.stringify({ category, text }),
|
| 109 |
-
},
|
| 110 |
-
(b) => ({ id: str(row(b).id) })
|
| 111 |
-
);
|
| 112 |
-
}
|
| 113 |
-
|
| 114 |
-
// ββ USAGE (R9, C7) ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 115 |
-
|
| 116 |
-
/** One AI surface's week. Every surface is present at zero when unused (E-9), so nothing here
|
| 117 |
-
* branches on a missing row. */
|
| 118 |
-
export interface UsageSurface {
|
| 119 |
-
surface: string;
|
| 120 |
-
calls: number;
|
| 121 |
-
tokens: number;
|
| 122 |
-
tokensIn: number;
|
| 123 |
-
tokensOut: number;
|
| 124 |
-
/**
|
| 125 |
-
* Calls whose provider declined to report a token count.
|
| 126 |
-
*
|
| 127 |
-
* β NOT ZERO-PADDING. These are real calls with an UNKNOWN cost, which is why the total beside
|
| 128 |
-
* them is a FLOOR rather than a figure. A page that hides this presents the floor as complete,
|
| 129 |
-
* and that is the cost-surprise failure one step removed.
|
| 130 |
-
*/
|
| 131 |
-
unmeasured: number;
|
| 132 |
-
}
|
| 133 |
-
|
| 134 |
-
export interface Usage {
|
| 135 |
-
/** ISO year-week, UTC. A string that sorts chronologically, so nothing here parses a date. */
|
| 136 |
-
week: string;
|
| 137 |
-
/** The UTC date the counters roll over, as the server states it. */
|
| 138 |
-
resets: string;
|
| 139 |
-
allowance: number;
|
| 140 |
-
total: number;
|
| 141 |
-
over: boolean;
|
| 142 |
-
calls: number;
|
| 143 |
-
unmeasured: number;
|
| 144 |
-
/** `"me"` or `"tenant"`. Which question the numbers answer, decided by the server. */
|
| 145 |
-
scope: string;
|
| 146 |
-
surfaces: UsageSurface[];
|
| 147 |
-
/** The server's own sentence about `unmeasured`. Empty when there is nothing unmeasured. */
|
| 148 |
-
note: string;
|
| 149 |
-
/**
|
| 150 |
-
* Admin only: AI calls this container could not attribute to an account.
|
| 151 |
-
*
|
| 152 |
-
* β LABEL IT A WIRING GAP, NOT USAGE (E-9). It is the meter reporting on ITSELF, and folding it
|
| 153 |
-
* into somebody's total would be an invented attribution.
|
| 154 |
-
*/
|
| 155 |
-
unattributed: number;
|
| 156 |
-
}
|
| 157 |
-
|
| 158 |
-
export function parseUsage(body: unknown): Usage {
|
| 159 |
-
const r = row(body);
|
| 160 |
-
const surfaces = (Array.isArray(r.surfaces) ? r.surfaces : [])
|
| 161 |
-
.map(row)
|
| 162 |
-
.filter((s) => str(s.surface) !== "")
|
| 163 |
-
.map((s) => ({
|
| 164 |
-
surface: str(s.surface),
|
| 165 |
-
calls: int(s.calls),
|
| 166 |
-
tokens: int(s.tokens),
|
| 167 |
-
tokensIn: int(s.tokens_in),
|
| 168 |
-
tokensOut: int(s.tokens_out),
|
| 169 |
-
unmeasured: int(s.unmeasured),
|
| 170 |
-
}));
|
| 171 |
-
return {
|
| 172 |
-
week: str(r.week),
|
| 173 |
-
resets: str(r.resets),
|
| 174 |
-
allowance: int(r.allowance),
|
| 175 |
-
total: int(r.total),
|
| 176 |
-
over: r.over === true,
|
| 177 |
-
calls: int(r.calls),
|
| 178 |
-
unmeasured: int(r.unmeasured),
|
| 179 |
-
scope: str(r.scope) || "me",
|
| 180 |
-
surfaces,
|
| 181 |
-
note: str(r.note),
|
| 182 |
-
unattributed: int(r.unattributed),
|
| 183 |
-
};
|
| 184 |
-
}
|
| 185 |
-
|
| 186 |
-
export function loadUsage(scope?: "tenant"): Promise<Result<Usage>> {
|
| 187 |
-
return call(scope === "tenant" ? "/usage?scope=tenant" : "/usage", {}, parseUsage);
|
| 188 |
-
}
|
|
|
|
| 1 |
+
// ---------------------------------------------------------------------------
|
| 2 |
+
// account/accountApi.ts β WAVE 35, owner item 13: the wire behind the two account
|
| 3 |
+
// surfaces that have one (Feedback, contract C6/R8; Usage, C7/R9).
|
| 4 |
+
//
|
| 5 |
+
// β THE CATEGORY VOCABULARY IS THE SERVER'S AND IS FETCHED, NOT DECLARED HERE.
|
| 6 |
+
// That is a decision, not an omission: a client constant beside a server enum is
|
| 7 |
+
// two lists that drift, and the day they do the dropdown offers a value the door
|
| 8 |
+
// refuses. Asked and settled in mailbox B-1 / E-9 β the server serves the list,
|
| 9 |
+
// this file renders it, and there is no local fallback to drift TO.
|
| 10 |
+
//
|
| 11 |
+
// β AND THERE IS NO TENANT-SIDE READ OF A SUBMISSION AT ALL (R8). No "my
|
| 12 |
+
// feedback" list, no edit, no delete: the operator plane is the only reader. The
|
| 13 |
+
// 201 is the receipt. If that ever feels like a gap, it is R8, not a missing
|
| 14 |
+
// endpoint.
|
| 15 |
+
//
|
| 16 |
+
// β NOTHING ON THE USAGE SIDE IS COMPUTED HERE. The allowance, the week and the
|
| 17 |
+
// reset date all ride the payload (E-9), because a week boundary computed in the
|
| 18 |
+
// browser can disagree with the server's own bucketing β and then the page shows
|
| 19 |
+
// a number that traces to no ledger line, which is exactly what R9's meter must
|
| 20 |
+
// never do.
|
| 21 |
+
// ---------------------------------------------------------------------------
|
| 22 |
+
|
| 23 |
+
import { API_V1, CREDENTIALS, UNAUTHORIZED_EVENT, checkTenant, signal } from "../apiContract";
|
| 24 |
+
|
| 25 |
+
/** The same discriminated result the other feature modules use. */
|
| 26 |
+
export type Result<T> =
|
| 27 |
+
| { ok: true; value: T }
|
| 28 |
+
| { ok: false; status: number; message: string };
|
| 29 |
+
|
| 30 |
+
async function call<T>(
|
| 31 |
+
path: string,
|
| 32 |
+
init: RequestInit,
|
| 33 |
+
read: (body: unknown) => T
|
| 34 |
+
): Promise<Result<T>> {
|
| 35 |
+
let res: Response;
|
| 36 |
+
try {
|
| 37 |
+
res = await fetch(`${API_V1}${path}`, { credentials: CREDENTIALS, ...init });
|
| 38 |
+
} catch {
|
| 39 |
+
return { ok: false, status: 0, message: "Cannot reach the server." };
|
| 40 |
+
}
|
| 41 |
+
if (checkTenant(res)) return { ok: false, status: 0, message: "Reloading." };
|
| 42 |
+
if (res.status === 401) signal(UNAUTHORIZED_EVENT);
|
| 43 |
+
const body = (await res.json().catch(() => null)) as unknown;
|
| 44 |
+
if (!res.ok) {
|
| 45 |
+
// β A 4xx MESSAGE IS POLICY AND IS SHOWN; a 5xx message is an internal detail and is not.
|
| 46 |
+
// `session.ts` states the same rule for sign-in, and E names each 400 case ("unknown
|
| 47 |
+
// category", "empty text", "over maxChars"), so the server's sentence is the useful one.
|
| 48 |
+
const detail = (body as { error?: { message?: string } } | null)?.error?.message;
|
| 49 |
+
return {
|
| 50 |
+
ok: false,
|
| 51 |
+
status: res.status,
|
| 52 |
+
message:
|
| 53 |
+
res.status >= 500 || !detail
|
| 54 |
+
? res.status >= 500
|
| 55 |
+
? "Something went wrong on our side. Try again in a moment."
|
| 56 |
+
: `The server answered ${res.status}.`
|
| 57 |
+
: detail,
|
| 58 |
+
};
|
| 59 |
+
}
|
| 60 |
+
return { ok: true, value: read(body) };
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
const row = (v: unknown): Record<string, unknown> =>
|
| 64 |
+
v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : {};
|
| 65 |
+
const str = (v: unknown): string => (typeof v === "string" ? v : "");
|
| 66 |
+
const int = (v: unknown): number =>
|
| 67 |
+
typeof v === "number" && Number.isFinite(v) && v >= 0 ? Math.floor(v) : 0;
|
| 68 |
+
|
| 69 |
+
// ββ FEEDBACK (R8, C6) βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 70 |
+
|
| 71 |
+
export interface FeedbackCategory {
|
| 72 |
+
key: string;
|
| 73 |
+
label: string;
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
export interface FeedbackForm {
|
| 77 |
+
categories: FeedbackCategory[];
|
| 78 |
+
maxChars: number;
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
/**
|
| 82 |
+
* `GET /api/v1/feedback/form` β the category list and the length limit, both the server's.
|
| 83 |
+
*
|
| 84 |
+
* β A CATEGORY WITH NO LABEL IS DROPPED, never rendered as its key: an internal identifier in a
|
| 85 |
+
* dropdown is the same defect `parsePages` refuses one layer up ("a nav row with no name is a
|
| 86 |
+
* door with no sign on it"). And an EMPTY list is a real answer the caller must handle, not a
|
| 87 |
+
* reason to invent one.
|
| 88 |
+
*/
|
| 89 |
+
export function parseFeedbackForm(body: unknown): FeedbackForm {
|
| 90 |
+
const r = row(body);
|
| 91 |
+
const categories = (Array.isArray(r.categories) ? r.categories : [])
|
| 92 |
+
.map(row)
|
| 93 |
+
.filter((c) => str(c.key) !== "" && str(c.label) !== "")
|
| 94 |
+
.map((c) => ({ key: str(c.key), label: str(c.label) }));
|
| 95 |
+
return { categories, maxChars: int(r.maxChars) };
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
export function loadFeedbackForm(): Promise<Result<FeedbackForm>> {
|
| 99 |
+
return call("/feedback/form", {}, parseFeedbackForm);
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
export function sendFeedback(category: string, text: string): Promise<Result<{ id: string }>> {
|
| 103 |
+
return call(
|
| 104 |
+
"/feedback",
|
| 105 |
+
{
|
| 106 |
+
method: "POST",
|
| 107 |
+
headers: { "Content-Type": "application/json" },
|
| 108 |
+
body: JSON.stringify({ category, text }),
|
| 109 |
+
},
|
| 110 |
+
(b) => ({ id: str(row(b).id) })
|
| 111 |
+
);
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
// ββ USAGE (R9, C7) ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 115 |
+
|
| 116 |
+
/** One AI surface's week. Every surface is present at zero when unused (E-9), so nothing here
|
| 117 |
+
* branches on a missing row. */
|
| 118 |
+
export interface UsageSurface {
|
| 119 |
+
surface: string;
|
| 120 |
+
calls: number;
|
| 121 |
+
tokens: number;
|
| 122 |
+
tokensIn: number;
|
| 123 |
+
tokensOut: number;
|
| 124 |
+
/**
|
| 125 |
+
* Calls whose provider declined to report a token count.
|
| 126 |
+
*
|
| 127 |
+
* β NOT ZERO-PADDING. These are real calls with an UNKNOWN cost, which is why the total beside
|
| 128 |
+
* them is a FLOOR rather than a figure. A page that hides this presents the floor as complete,
|
| 129 |
+
* and that is the cost-surprise failure one step removed.
|
| 130 |
+
*/
|
| 131 |
+
unmeasured: number;
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
export interface Usage {
|
| 135 |
+
/** ISO year-week, UTC. A string that sorts chronologically, so nothing here parses a date. */
|
| 136 |
+
week: string;
|
| 137 |
+
/** The UTC date the counters roll over, as the server states it. */
|
| 138 |
+
resets: string;
|
| 139 |
+
allowance: number;
|
| 140 |
+
total: number;
|
| 141 |
+
over: boolean;
|
| 142 |
+
calls: number;
|
| 143 |
+
unmeasured: number;
|
| 144 |
+
/** `"me"` or `"tenant"`. Which question the numbers answer, decided by the server. */
|
| 145 |
+
scope: string;
|
| 146 |
+
surfaces: UsageSurface[];
|
| 147 |
+
/** The server's own sentence about `unmeasured`. Empty when there is nothing unmeasured. */
|
| 148 |
+
note: string;
|
| 149 |
+
/**
|
| 150 |
+
* Admin only: AI calls this container could not attribute to an account.
|
| 151 |
+
*
|
| 152 |
+
* β LABEL IT A WIRING GAP, NOT USAGE (E-9). It is the meter reporting on ITSELF, and folding it
|
| 153 |
+
* into somebody's total would be an invented attribution.
|
| 154 |
+
*/
|
| 155 |
+
unattributed: number;
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
export function parseUsage(body: unknown): Usage {
|
| 159 |
+
const r = row(body);
|
| 160 |
+
const surfaces = (Array.isArray(r.surfaces) ? r.surfaces : [])
|
| 161 |
+
.map(row)
|
| 162 |
+
.filter((s) => str(s.surface) !== "")
|
| 163 |
+
.map((s) => ({
|
| 164 |
+
surface: str(s.surface),
|
| 165 |
+
calls: int(s.calls),
|
| 166 |
+
tokens: int(s.tokens),
|
| 167 |
+
tokensIn: int(s.tokens_in),
|
| 168 |
+
tokensOut: int(s.tokens_out),
|
| 169 |
+
unmeasured: int(s.unmeasured),
|
| 170 |
+
}));
|
| 171 |
+
return {
|
| 172 |
+
week: str(r.week),
|
| 173 |
+
resets: str(r.resets),
|
| 174 |
+
allowance: int(r.allowance),
|
| 175 |
+
total: int(r.total),
|
| 176 |
+
over: r.over === true,
|
| 177 |
+
calls: int(r.calls),
|
| 178 |
+
unmeasured: int(r.unmeasured),
|
| 179 |
+
scope: str(r.scope) || "me",
|
| 180 |
+
surfaces,
|
| 181 |
+
note: str(r.note),
|
| 182 |
+
unattributed: int(r.unattributed),
|
| 183 |
+
};
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
export function loadUsage(scope?: "tenant"): Promise<Result<Usage>> {
|
| 187 |
+
return call(scope === "tenant" ? "/usage?scope=tenant" : "/usage", {}, parseUsage);
|
| 188 |
+
}
|
web/src/customer-grid/CatalogView.tsx
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
web/src/customer-grid/ColumnMenu.tsx
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
web/src/customer-grid/CustomerGrid.tsx
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
web/src/customer-grid/GridChat.tsx
ADDED
|
@@ -0,0 +1,512 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ---------------------------------------------------------------------------
|
| 2 |
+
// customer-grid / GridChat.tsx β the docked chat for ANY database view (W37-T40, R2, R10, C6).
|
| 3 |
+
//
|
| 4 |
+
// Owner items 10 and 11 are ONE feature (ruling R2): the chat IS the builder. This is its shell β
|
| 5 |
+
// the conversation surface every later ticket in the lane hangs a power off. T42 gives it the
|
| 6 |
+
// schema to answer from, T43 the view controls, T44 the Custom View's code, T45 the confirmed
|
| 7 |
+
// record edit.
|
| 8 |
+
//
|
| 9 |
+
// ββ IT REACHES INTO NOTHING. Contract C6: every action is a callback the host supplies, and
|
| 10 |
+
// EVERY PROP IS REQUIRED. An optional prop is how a panel ships mounted-but-inert β the feature is
|
| 11 |
+
// whole, the gate is green, and the one line that would have passed the callback was never
|
| 12 |
+
// written. This repo shipped five of those in a single wave. A required prop makes the omission a
|
| 13 |
+
// compile error in the mounting file, which is where a person can see it.
|
| 14 |
+
//
|
| 15 |
+
// β THE SHAPE IS THE ONE THIS PRODUCT ALREADY HAS, not a third chat look. `AutomationChat` and
|
| 16 |
+
// `AssistantPage` both render a centred opening while the transcript is empty, then a log with the
|
| 17 |
+
// SAME composer moved to the foot; `FieldAgentChat` reuses the same composer classes rather than
|
| 18 |
+
// drawing a second one. Copying that is not laziness, it is the reason the owner cannot tell which
|
| 19 |
+
// surface was built by whom.
|
| 20 |
+
//
|
| 21 |
+
// β NO `import.meta.env` ANYWHERE IN THIS FILE'S GRAPH, and the constraint is load-bearing rather
|
| 22 |
+
// than stylistic: the gate renders this component from a CommonJS build, and `import.meta` cannot
|
| 23 |
+
// be required from one. `AutomationChat`'s own header records that exact crash. The imports below
|
| 24 |
+
// are `react` + `./gridChat` + the stylesheet, and nothing else.
|
| 25 |
+
// ---------------------------------------------------------------------------
|
| 26 |
+
|
| 27 |
+
import { useCallback, useEffect, useRef, useState } from "react";
|
| 28 |
+
|
| 29 |
+
import {
|
| 30 |
+
answered,
|
| 31 |
+
asked,
|
| 32 |
+
composerHint,
|
| 33 |
+
editSummary,
|
| 34 |
+
emptyChat,
|
| 35 |
+
failed,
|
| 36 |
+
isBusy,
|
| 37 |
+
noted,
|
| 38 |
+
proposedEdits,
|
| 39 |
+
readIntent,
|
| 40 |
+
runSentence,
|
| 41 |
+
wrote,
|
| 42 |
+
} from "./gridChat";
|
| 43 |
+
import type {
|
| 44 |
+
GridChatAnswer,
|
| 45 |
+
GridChatEdit,
|
| 46 |
+
GridChatField,
|
| 47 |
+
GridChatRunResult,
|
| 48 |
+
GridChatState,
|
| 49 |
+
GridChatTurn,
|
| 50 |
+
} from "./gridChat";
|
| 51 |
+
import "./gridChat.css";
|
| 52 |
+
|
| 53 |
+
// β RE-EXPORTED SO THE MOUNTING FILE TAKES ONE IMPORT. The types live in `gridChat.ts` (they have
|
| 54 |
+
// to: the state machine is typed by them and that module must stay React-free), but a host that
|
| 55 |
+
// had to import the component from here and its types from there would be one refactor away from
|
| 56 |
+
// the two drifting. Published to C in mailbox E-4 as a single import line.
|
| 57 |
+
export type {
|
| 58 |
+
GridChatAnswer,
|
| 59 |
+
GridChatEdit,
|
| 60 |
+
GridChatField,
|
| 61 |
+
GridChatRunResult,
|
| 62 |
+
GridChatState,
|
| 63 |
+
GridChatTurn,
|
| 64 |
+
};
|
| 65 |
+
|
| 66 |
+
/**
|
| 67 |
+
* What the host asks the chat to do about the view. β DELIBERATELY NARROW: this is the vocabulary
|
| 68 |
+
* `assistant build_view` already speaks, minus what it cannot express. There is no `rhs` in that
|
| 69 |
+
* tool schema, so a field-versus-field condition is not sayable
|
| 70 |
+
* [[assistant-build-view-is-a-subset]] β W37-T43 has to SAY it cannot rather than fail quietly.
|
| 71 |
+
*/
|
| 72 |
+
export interface GridChatViewSpec {
|
| 73 |
+
/**
|
| 74 |
+
* β RESERVED AND DELIBERATELY NOT POPULATED BY `readIntent` YET. The panel understands
|
| 75 |
+
* sort/group/colour from a sentence; a real FILTER needs the condition vocabulary the host owns,
|
| 76 |
+
* so this is the slot C fills rather than a field the panel quietly leaves empty. Named here so
|
| 77 |
+
* it reads as a contract with a known gap instead of a channel nobody noticed was dead
|
| 78 |
+
* [[flag-shipped-without-its-writer]].
|
| 79 |
+
*/
|
| 80 |
+
filter?: unknown;
|
| 81 |
+
sortBy?: string;
|
| 82 |
+
groupBy?: string;
|
| 83 |
+
colorBy?: string;
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
export interface GridChatProps {
|
| 87 |
+
/** The host owns the toggle. The panel never closes itself except through `onClose`. */
|
| 88 |
+
open: boolean;
|
| 89 |
+
onClose: () => void;
|
| 90 |
+
/** What this chat is reading, in the person's words. Appears in the header and the placeholder. */
|
| 91 |
+
databaseLabel: string;
|
| 92 |
+
/** The columns the chat may reason about. The host decides; the chat never discovers. */
|
| 93 |
+
schema: GridChatField[];
|
| 94 |
+
rowCount: number;
|
| 95 |
+
/**
|
| 96 |
+
* β R10's BOUND, EXPRESSED AS AN AFFORDANCE. `false` means the edit power is not OFFERED, not
|
| 97 |
+
* that it is offered and then refused. The chat is not a privilege escalation: a caller whose
|
| 98 |
+
* own permissions would refuse the write must not be invited to compose one
|
| 99 |
+
* [[permitted-is-not-answerable]].
|
| 100 |
+
*/
|
| 101 |
+
canEdit: boolean;
|
| 102 |
+
/** (a) answer a question about this database. */
|
| 103 |
+
onDescribe: (question: string) => Promise<GridChatAnswer>;
|
| 104 |
+
/** (b) change filter, sort, group or colour on the ACTIVE view. */
|
| 105 |
+
onApplyView: (spec: GridChatViewSpec) => void;
|
| 106 |
+
/** (b, undo half) put the view back the way it was. */
|
| 107 |
+
onUndoView: () => void;
|
| 108 |
+
/** (d) write the cells the person just confirmed. β Reached only from the confirm control. */
|
| 109 |
+
onWriteCells: (edits: GridChatEdit[]) => Promise<void>;
|
| 110 |
+
/** (c) run the Custom View's code and report what happened. */
|
| 111 |
+
onRunScript: (code: string) => Promise<GridChatRunResult>;
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
/** The send mark. β The Assistant's own path, point for point: a second arrow drawn slightly
|
| 115 |
+
* differently is precisely how two surfaces stop looking like one product. */
|
| 116 |
+
function SendMark() {
|
| 117 |
+
return (
|
| 118 |
+
<svg viewBox="0 0 16 16" width="15" height="15" aria-hidden="true" fill="none">
|
| 119 |
+
<path d="M8 13V3.6M4 7.4 8 3.4l4 4" stroke="currentColor" strokeWidth="1.5"
|
| 120 |
+
strokeLinecap="round" strokeLinejoin="round" />
|
| 121 |
+
</svg>
|
| 122 |
+
);
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
export default function GridChat(props: GridChatProps) {
|
| 126 |
+
const {
|
| 127 |
+
open, onClose, databaseLabel, schema, rowCount, canEdit,
|
| 128 |
+
onDescribe, onApplyView, onUndoView, onWriteCells, onRunScript,
|
| 129 |
+
} = props;
|
| 130 |
+
|
| 131 |
+
const [state, setState] = useState<GridChatState>(emptyChat);
|
| 132 |
+
const [draft, setDraft] = useState("");
|
| 133 |
+
const [writing, setWriting] = useState(false);
|
| 134 |
+
const logRef = useRef<HTMLDivElement | null>(null);
|
| 135 |
+
const busy = isBusy(state);
|
| 136 |
+
|
| 137 |
+
// β THE UNUSED-POWER GUARD. `onApplyView`, `onUndoView` and `onRunScript` are wired by W37-T43
|
| 138 |
+
// and W37-T44; naming them here keeps them REQUIRED props from the first mount, so C's wiring
|
| 139 |
+
// lands with the shell rather than being remembered two tickets later. `void` is the honest
|
| 140 |
+
// spelling of "held, not yet called" and it survives `noUnusedLocals`.
|
| 141 |
+
// β WHERE EACH CALLBACK IS ACTUALLY REACHED, because saying so is the only thing that stops a
|
| 142 |
+
// required prop becoming a decorative one: `onApplyView` from `send` (T43), `onUndoView` from the
|
| 143 |
+
// Undo control in the foot row, `onRunScript` from `runScript` below, which `send` calls when the
|
| 144 |
+
// host returns generated code (T44), `onWriteCells` from the confirm and the undo (T45, R10).
|
| 145 |
+
|
| 146 |
+
// The transcript follows the newest turn. Guarded on the ref because the log is not rendered at
|
| 147 |
+
// all while the conversation is empty (see the opening below).
|
| 148 |
+
useEffect(() => {
|
| 149 |
+
const log = logRef.current;
|
| 150 |
+
if (log) log.scrollTop = log.scrollHeight;
|
| 151 |
+
}, [state.turns.length, busy]);
|
| 152 |
+
|
| 153 |
+
// β DECLARED BEFORE `send`, AND THE ORDER IS LOAD-BEARING RATHER THAN TIDY. `send`'s
|
| 154 |
+
// dependency array names `runScript`, and a dependency array is evaluated AT RENDER, not
|
| 155 |
+
// when the callback fires. With `runScript` declared after `send` that array reads a `const`
|
| 156 |
+
// in its temporal dead zone and the panel throws `Cannot access 'runScript' before
|
| 157 |
+
// initialization` on its FIRST paint. β `tsc` does not catch it: it is a runtime TDZ, and
|
| 158 |
+
// the build was green with the crash in it.
|
| 159 |
+
/**
|
| 160 |
+
* β W37-T44 β THE CHAT WRITES THE CUSTOM VIEW'S CODE AND RUNS IT.
|
| 161 |
+
* β THE SANDBOX IS NOT WIDENED TO MAKE A GENERATED SCRIPT WORK. `onRunScript` is the host's
|
| 162 |
+
* ordinary run door, the same one a hand-typed script goes through, so a script the chat wrote
|
| 163 |
+
* has exactly the powers a person's own script has and no more. A failure is REPORTED and the
|
| 164 |
+
* code is left in the editor to be fixed by hand, never blanked.
|
| 165 |
+
*/
|
| 166 |
+
const runScript = useCallback((code: string) => {
|
| 167 |
+
void (async () => {
|
| 168 |
+
try {
|
| 169 |
+
const result = await onRunScript(code);
|
| 170 |
+
setState((s) => noted(s, runSentence(result)));
|
| 171 |
+
} catch (err) {
|
| 172 |
+
const why = err instanceof Error && err.message ? err.message : "";
|
| 173 |
+
setState((s) => noted(s, runSentence({ ok: false, error: why })));
|
| 174 |
+
}
|
| 175 |
+
})();
|
| 176 |
+
}, [onRunScript]);
|
| 177 |
+
|
| 178 |
+
const send = useCallback(() => {
|
| 179 |
+
const said = draft.trim();
|
| 180 |
+
if (!said || busy) return;
|
| 181 |
+
setState((s) => asked(s, said));
|
| 182 |
+
setDraft("");
|
| 183 |
+
|
| 184 |
+
// ββ W37-T42/T43/T44 β READ THE SENTENCE BEFORE SPENDING ANYTHING ON IT.
|
| 185 |
+
// β THE REFUSAL BRANCH IS THE POINT OF T42 AND IT COSTS NOTHING. D-339's defect is an agent
|
| 186 |
+
// that cannot read the database answering from words alone and inventing a number. The panel
|
| 187 |
+
// HOLDS the schema, so a question naming a field this database does not have is answered here,
|
| 188 |
+
// truthfully, without a model call that could only guess.
|
| 189 |
+
const intent = readIntent(schema, databaseLabel, said);
|
| 190 |
+
if (intent.refusal) {
|
| 191 |
+
setState((s) => answered(s, said, { text: intent.refusal!, unanswerable: true }));
|
| 192 |
+
return;
|
| 193 |
+
}
|
| 194 |
+
// β ...and a question the panel already knows the answer to is not a paid round trip either.
|
| 195 |
+
if (intent.localAnswer) {
|
| 196 |
+
setState((s) => answered(s, said, { text: intent.localAnswer! }));
|
| 197 |
+
return;
|
| 198 |
+
}
|
| 199 |
+
if (intent.kind === "view") {
|
| 200 |
+
// T43: the VIEW changes through the host's callback (C6), and what could NOT be expressed is
|
| 201 |
+
// named rather than dropped [[assistant-build-view-is-a-subset]].
|
| 202 |
+
if (intent.view) onApplyView(intent.view);
|
| 203 |
+
const applied = intent.view
|
| 204 |
+
? "The view has been changed. Ask again or press Undo to put it back."
|
| 205 |
+
: "I could not tell which field you meant, so the view is unchanged.";
|
| 206 |
+
setState((s) => answered(s, said, {
|
| 207 |
+
text: intent.cannot ? `${applied} ${intent.cannot}` : applied,
|
| 208 |
+
}));
|
| 209 |
+
return;
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
void (async () => {
|
| 213 |
+
try {
|
| 214 |
+
const answer = await onDescribe(said);
|
| 215 |
+
// β THE PROPOSAL BRANCH IS HERE AND NOT IN THE HOST. A host that returned edits and also
|
| 216 |
+
// wrote them would make the confirm cosmetic; the panel decides that edits mean "show the
|
| 217 |
+
// person first", so there is exactly one place that decision can be got wrong.
|
| 218 |
+
// β NO CAST. `GridChatAnswer.edits` is a declared part of the contract now, so the host
|
| 219 |
+
// reads the same channel this line does; it used to be an `as` and the two could drift.
|
| 220 |
+
const edits = answer?.edits;
|
| 221 |
+
setState((s) => (canEdit && Array.isArray(edits) && edits.length
|
| 222 |
+
? proposedEdits(s, said, answer.text, edits)
|
| 223 |
+
: answered(s, said, answer)));
|
| 224 |
+
// β W37-T44 β AND THIS BRANCH IS THE WHOLE POINT OF THE TICKET. The first draft classified
|
| 225 |
+
// a build request correctly and then had nothing consume the classification: `runScript`
|
| 226 |
+
// was defined and `void`ed, so `onRunScript` was unreachable from every path while the
|
| 227 |
+
// ticket claimed it routed. That is [[reachable-is-not-the-same-as-built]], and `void` is
|
| 228 |
+
// what hid it from tsc. The host authors the code; the panel runs it and reports.
|
| 229 |
+
if (typeof answer?.script === "string" && answer.script.trim())
|
| 230 |
+
runScript(answer.script);
|
| 231 |
+
} catch (err) {
|
| 232 |
+
const sentence = err instanceof Error ? err.message : "";
|
| 233 |
+
setState((s) => failed(s, said, sentence));
|
| 234 |
+
}
|
| 235 |
+
})();
|
| 236 |
+
}, [draft, busy, canEdit, onDescribe, onApplyView, runScript, schema, databaseLabel]);
|
| 237 |
+
|
| 238 |
+
const confirmWrite = useCallback((edits: GridChatEdit[]) => {
|
| 239 |
+
if (writing) return;
|
| 240 |
+
setWriting(true);
|
| 241 |
+
void (async () => {
|
| 242 |
+
try {
|
| 243 |
+
await onWriteCells(edits);
|
| 244 |
+
// β `wrote` rather than `noted`: the note it appends CARRIES THE INVERSE, built from the
|
| 245 |
+
// `before` values the person confirmed. An undo assembled after the write would have
|
| 246 |
+
// nothing left to read [[capture-the-inverse-before-the-write]].
|
| 247 |
+
setState((s) => wrote(s, edits));
|
| 248 |
+
} catch (err) {
|
| 249 |
+
const sentence = err instanceof Error && err.message
|
| 250 |
+
? err.message
|
| 251 |
+
: "That write was refused, so nothing changed.";
|
| 252 |
+
setState((s) => noted(s, sentence));
|
| 253 |
+
} finally {
|
| 254 |
+
setWriting(false);
|
| 255 |
+
}
|
| 256 |
+
})();
|
| 257 |
+
}, [onWriteCells, writing]);
|
| 258 |
+
|
| 259 |
+
if (!open) return null;
|
| 260 |
+
return (
|
| 261 |
+
<GridChatView
|
| 262 |
+
state={state}
|
| 263 |
+
draft={draft}
|
| 264 |
+
writing={writing}
|
| 265 |
+
databaseLabel={databaseLabel}
|
| 266 |
+
schema={schema}
|
| 267 |
+
rowCount={rowCount}
|
| 268 |
+
canEdit={canEdit}
|
| 269 |
+
logRef={logRef}
|
| 270 |
+
onClose={onClose}
|
| 271 |
+
onDraft={setDraft}
|
| 272 |
+
onSend={send}
|
| 273 |
+
onUndoView={onUndoView}
|
| 274 |
+
onConfirmWrite={confirmWrite}
|
| 275 |
+
onDeclineWrite={() => setState((s) => noted(s, "Nothing was written."))}
|
| 276 |
+
/>
|
| 277 |
+
);
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
/**
|
| 281 |
+
* ββ THE PRESENTATIONAL HALF, AND IT IS SPLIT OUT FOR A REASON A GATE CAN USE.
|
| 282 |
+
*
|
| 283 |
+
* `<GridChat>` owns the state; this owns the pixels and takes the state as a prop. That split is
|
| 284 |
+
* what makes T40's `done-when` observable at all: the only renderer available in this repo is
|
| 285 |
+
* `react-dom/server`, which fires no events and runs no effects, so a gate can never DRIVE a
|
| 286 |
+
* stateful component into its in-flight or error state. It can render THIS one into any state it
|
| 287 |
+
* likes, because the state is an argument.
|
| 288 |
+
*
|
| 289 |
+
* β THE HOST NEVER IMPORTS THIS. `GridChat` is the default export and the contract C6 describes;
|
| 290 |
+
* every prop on it is required, and nothing here weakens that. Splitting the render out is the
|
| 291 |
+
* opposite of an optional prop: it adds a seam for the gate without adding a way for a mount to
|
| 292 |
+
* be silently inert.
|
| 293 |
+
*/
|
| 294 |
+
export function GridChatView(props: {
|
| 295 |
+
state: GridChatState;
|
| 296 |
+
draft: string;
|
| 297 |
+
writing: boolean;
|
| 298 |
+
databaseLabel: string;
|
| 299 |
+
schema: GridChatField[];
|
| 300 |
+
rowCount: number;
|
| 301 |
+
canEdit: boolean;
|
| 302 |
+
// β Typed exactly as `useRef<HTMLDivElement | null>(null)` produces it, and OPTIONAL only
|
| 303 |
+
// here: a server render has no DOM to scroll, so the gate omits it. The stateful shell
|
| 304 |
+
// above always passes one.
|
| 305 |
+
logRef?: React.Ref<HTMLDivElement>;
|
| 306 |
+
onClose: () => void;
|
| 307 |
+
onDraft: (text: string) => void;
|
| 308 |
+
onSend: () => void;
|
| 309 |
+
onUndoView: () => void;
|
| 310 |
+
onConfirmWrite: (edits: GridChatEdit[]) => void;
|
| 311 |
+
onDeclineWrite: () => void;
|
| 312 |
+
}) {
|
| 313 |
+
const {
|
| 314 |
+
state, draft, writing, databaseLabel, schema, rowCount, canEdit, logRef,
|
| 315 |
+
onClose, onDraft, onSend, onUndoView, onConfirmWrite, onDeclineWrite,
|
| 316 |
+
} = props;
|
| 317 |
+
const busy = isBusy(state);
|
| 318 |
+
const hint = composerHint(databaseLabel, canEdit);
|
| 319 |
+
|
| 320 |
+
const composer = (
|
| 321 |
+
<div className="cg-chat-composer">
|
| 322 |
+
<textarea
|
| 323 |
+
className="cg-chat-input"
|
| 324 |
+
value={draft}
|
| 325 |
+
rows={2}
|
| 326 |
+
placeholder={hint}
|
| 327 |
+
aria-label={hint}
|
| 328 |
+
disabled={busy}
|
| 329 |
+
onChange={(e) => onDraft(e.target.value)}
|
| 330 |
+
onKeyDown={(e) => {
|
| 331 |
+
if (e.key === "Enter" && !e.shiftKey) {
|
| 332 |
+
e.preventDefault();
|
| 333 |
+
onSend();
|
| 334 |
+
}
|
| 335 |
+
}}
|
| 336 |
+
/>
|
| 337 |
+
<div className="cg-chat-composer-foot">
|
| 338 |
+
{/* β THE SCOPE LINE, NOT A DISCLAIMER. It says what the chat is reading, which is the one
|
| 339 |
+
thing a person cannot tell by looking at a docked panel. */}
|
| 340 |
+
<span className="cg-chat-scope">
|
| 341 |
+
{rowCount === 1 ? "1 record" : `${rowCount} records`}
|
| 342 |
+
{schema.length ? `, ${schema.length === 1 ? "1 field" : `${schema.length} fields`}` : ""}
|
| 343 |
+
</span>
|
| 344 |
+
<span className="cg-chat-spacer" />
|
| 345 |
+
{/* β W37-T43's "and the change is undoable", as a control rather than a promise. It sits
|
| 346 |
+
beside Send because that is where the person is after asking for a view change, and it
|
| 347 |
+
calls the host's own undo so there is one place a view can be put back. */}
|
| 348 |
+
<button
|
| 349 |
+
type="button"
|
| 350 |
+
className="cg-chat-ghost"
|
| 351 |
+
onClick={onUndoView}
|
| 352 |
+
title="Put the view back the way it was"
|
| 353 |
+
>
|
| 354 |
+
Undo view
|
| 355 |
+
</button>
|
| 356 |
+
<button
|
| 357 |
+
type="button"
|
| 358 |
+
className="cg-chat-send"
|
| 359 |
+
onClick={onSend}
|
| 360 |
+
disabled={busy || draft.trim() === ""}
|
| 361 |
+
aria-label="Send"
|
| 362 |
+
title="Send"
|
| 363 |
+
>
|
| 364 |
+
<SendMark />
|
| 365 |
+
</button>
|
| 366 |
+
</div>
|
| 367 |
+
</div>
|
| 368 |
+
);
|
| 369 |
+
|
| 370 |
+
return (
|
| 371 |
+
<aside className="cg-chat" aria-label={`Chat about ${databaseLabel}`}>
|
| 372 |
+
<header className="cg-chat-head">
|
| 373 |
+
<span className="cg-chat-title">{databaseLabel}</span>
|
| 374 |
+
<button
|
| 375 |
+
type="button"
|
| 376 |
+
className="cg-chat-close"
|
| 377 |
+
onClick={onClose}
|
| 378 |
+
aria-label="Close the chat"
|
| 379 |
+
title="Close the chat"
|
| 380 |
+
>
|
| 381 |
+
<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true" fill="none">
|
| 382 |
+
<path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="1.5"
|
| 383 |
+
strokeLinecap="round" />
|
| 384 |
+
</svg>
|
| 385 |
+
</button>
|
| 386 |
+
</header>
|
| 387 |
+
|
| 388 |
+
{/* β THE LOG IS NOT RENDERED AT ALL WHEN EMPTY, and `hidden` would be the bug. `.cg-chat-log`
|
| 389 |
+
sets `display: flex`, and an author rule beats the user-agent `[hidden] { display: none }`
|
| 390 |
+
whatever its specificity. `hidden` would take the log out of the accessibility tree and
|
| 391 |
+
leave it PAINTED: an empty flex band shoving the centred opening off centre. That exact
|
| 392 |
+
defect was found and written down one wave ago in `AutomationChat`, and it is invisible to
|
| 393 |
+
any assertion that reads markup as text [[ui-invisible-to-assertions]]. */}
|
| 394 |
+
{state.turns.length ? (
|
| 395 |
+
<div className="cg-chat-log" ref={logRef}>
|
| 396 |
+
{state.turns.map((turn, i) => (
|
| 397 |
+
<div className="cg-chat-turn" key={i}>
|
| 398 |
+
<div
|
| 399 |
+
className={
|
| 400 |
+
"cg-chat-msg " +
|
| 401 |
+
(turn.error ? "is-error" : turn.who === "you" ? "is-you"
|
| 402 |
+
: turn.who === "note" ? "is-note" : "is-assistant")
|
| 403 |
+
}
|
| 404 |
+
>
|
| 405 |
+
{turn.text}
|
| 406 |
+
</div>
|
| 407 |
+
|
| 408 |
+
{/* β UNDO, OFFERED FROM THE TRANSCRIPT. R10 says the person confirms before a write;
|
| 409 |
+
this is the other half of taking them seriously. A confirmed write they did not
|
| 410 |
+
mean stays reversible in the place they are already looking, rather than behind a
|
| 411 |
+
toast that faded while they read the result.
|
| 412 |
+
β IT ROUTES THROUGH THE SAME `onWriteCells` the original went through, so the
|
| 413 |
+
undo passes the caller's permission wall exactly as the write did. An undo with
|
| 414 |
+
its own door would be a second write path, and the one nobody tested. */}
|
| 415 |
+
{canEdit && turn.undo?.length ? (
|
| 416 |
+
<div className="cg-chat-undo">
|
| 417 |
+
<button
|
| 418 |
+
type="button"
|
| 419 |
+
className="cg-chat-ghost"
|
| 420 |
+
disabled={writing}
|
| 421 |
+
onClick={() => onConfirmWrite(turn.undo || [])}
|
| 422 |
+
>
|
| 423 |
+
Put it back
|
| 424 |
+
</button>
|
| 425 |
+
</div>
|
| 426 |
+
) : null}
|
| 427 |
+
|
| 428 |
+
{/* ββ R10 ON SCREEN: EXACTLY WHAT WILL BE WRITTEN, BEFORE ANYTHING IS. Not a count,
|
| 429 |
+
not a summary of a summary. The person sees the record, the field, the old value
|
| 430 |
+
and the new one, and nothing reaches `onWriteCells` until they press Write. */}
|
| 431 |
+
{/* ββ `canEdit` IS CHECKED HERE, IN THE VIEW, AND NOT ONLY WHERE THE PROPOSAL IS
|
| 432 |
+
BUILT. The stateful shell already refuses to build a proposal for a caller who
|
| 433 |
+
may not write, so this looked redundant and was left out of the first draft; the
|
| 434 |
+
gate caught it. The wall has to sit where the CONTROL is drawn, because the view
|
| 435 |
+
renders whatever state it is handed and a second caller, a replayed transcript or
|
| 436 |
+
a permission that changed mid-session would otherwise paint a Write button for
|
| 437 |
+
somebody the store is going to refuse [[permitted-is-not-answerable]]. A wall
|
| 438 |
+
that depends on its caller's discipline is not a wall. */}
|
| 439 |
+
{canEdit && turn.edits?.length ? (
|
| 440 |
+
<div className="cg-chat-edits">
|
| 441 |
+
<p className="cg-chat-edits-head">{editSummary(turn.edits)}</p>
|
| 442 |
+
<table className="cg-chat-edits-table">
|
| 443 |
+
<thead>
|
| 444 |
+
<tr><th>Record</th><th>Field</th><th>Now</th><th>Would become</th></tr>
|
| 445 |
+
</thead>
|
| 446 |
+
<tbody>
|
| 447 |
+
{turn.edits.map((edit, j) => (
|
| 448 |
+
<tr key={j}>
|
| 449 |
+
<td>{edit.rowLabel || edit.rowId}</td>
|
| 450 |
+
<td>{edit.fieldLabel || edit.field}</td>
|
| 451 |
+
<td className="cg-chat-before">{edit.before || "empty"}</td>
|
| 452 |
+
<td className="cg-chat-after">{edit.after || "empty"}</td>
|
| 453 |
+
</tr>
|
| 454 |
+
))}
|
| 455 |
+
</tbody>
|
| 456 |
+
</table>
|
| 457 |
+
<div className="cg-chat-edits-act">
|
| 458 |
+
<button
|
| 459 |
+
type="button"
|
| 460 |
+
className="cg-chat-ghost"
|
| 461 |
+
disabled={writing}
|
| 462 |
+
onClick={onDeclineWrite}
|
| 463 |
+
>
|
| 464 |
+
Leave it
|
| 465 |
+
</button>
|
| 466 |
+
<button
|
| 467 |
+
type="button"
|
| 468 |
+
className="cg-chat-go"
|
| 469 |
+
disabled={writing}
|
| 470 |
+
onClick={() => onConfirmWrite(turn.edits || [])}
|
| 471 |
+
>
|
| 472 |
+
{writing ? "Writing" : "Write these"}
|
| 473 |
+
</button>
|
| 474 |
+
</div>
|
| 475 |
+
</div>
|
| 476 |
+
) : null}
|
| 477 |
+
</div>
|
| 478 |
+
))}
|
| 479 |
+
|
| 480 |
+
{/* β `.lp-spin` is the ONE loading mark in this application. A second spinner vocabulary
|
| 481 |
+
in a new surface is how an app stops having one. */}
|
| 482 |
+
{busy ? (
|
| 483 |
+
<div className="cg-chat-pending" role="status">
|
| 484 |
+
<span className="lp-spin" aria-hidden="true" />
|
| 485 |
+
Reading {databaseLabel}
|
| 486 |
+
</div>
|
| 487 |
+
) : null}
|
| 488 |
+
</div>
|
| 489 |
+
) : null}
|
| 490 |
+
|
| 491 |
+
{/* The centred opening, exactly the shape `AssistantPage` and `AutomationChat` already use.
|
| 492 |
+
β NO DECORATIVE MARK: the reference image has a soft dot ring, and neither of this
|
| 493 |
+
product's two existing chat surfaces does. A mark on the third one is the difference the
|
| 494 |
+
owner would notice. */}
|
| 495 |
+
{state.turns.length ? null : (
|
| 496 |
+
<div className="cg-chat-opening">
|
| 497 |
+
<h2 className="cg-chat-opening-h">How can I help?</h2>
|
| 498 |
+
<p className="cg-chat-opening-p">
|
| 499 |
+
{canEdit
|
| 500 |
+
? "Ask about the records in front of you, change what the view shows, or say what to change. Nothing is written until you confirm it."
|
| 501 |
+
: "Ask about the records in front of you, or change what the view shows."}
|
| 502 |
+
</p>
|
| 503 |
+
{composer}
|
| 504 |
+
</div>
|
| 505 |
+
)}
|
| 506 |
+
|
| 507 |
+
{/* β ONE composer const, rendered in exactly one place at a time. Two on screen at once is
|
| 508 |
+
the defect this pattern exists to make impossible. */}
|
| 509 |
+
{state.turns.length ? <div className="cg-chat-foot">{composer}</div> : null}
|
| 510 |
+
</aside>
|
| 511 |
+
);
|
| 512 |
+
}
|
web/src/customer-grid/MapView.tsx
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
web/src/customer-grid/ScriptViewPanel.tsx
CHANGED
|
@@ -113,6 +113,23 @@ function ScriptRefusal({ run }: { run: ScriptRun }) {
|
|
| 113 |
);
|
| 114 |
}
|
| 115 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
export function ScriptOutput({ run }: { run: ScriptRun | null }) {
|
| 117 |
if (run === null)
|
| 118 |
return <p className="cg-script-empty">Run the script to see what it draws.</p>;
|
|
@@ -165,16 +182,31 @@ export const ScriptView = memo(function ScriptView({
|
|
| 165 |
run,
|
| 166 |
saving,
|
| 167 |
readOnly = false,
|
|
|
|
|
|
|
| 168 |
onRun,
|
| 169 |
onSave,
|
|
|
|
| 170 |
}: {
|
| 171 |
view: ScriptViewRecord | null;
|
| 172 |
running: boolean;
|
| 173 |
run: ScriptRun | null;
|
| 174 |
saving?: boolean;
|
| 175 |
readOnly?: boolean;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
onRun: (draft: string) => void;
|
| 177 |
onSave?: (source: string) => void;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
}) {
|
| 179 |
const [draft, setDraft] = useState(view?.source ?? "");
|
| 180 |
const loadedId = useRef<string | null>(null);
|
|
@@ -186,11 +218,16 @@ export const ScriptView = memo(function ScriptView({
|
|
| 186 |
setDraft(view.source);
|
| 187 |
}, [view]);
|
| 188 |
const dirty = view !== null && draft !== view.source;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
if (view === null)
|
| 190 |
return (
|
| 191 |
<div className="cg-script">
|
| 192 |
<p className="cg-script-empty cg-script-empty--pad">
|
| 193 |
-
This script view could not be opened.
|
| 194 |
</p>
|
| 195 |
</div>
|
| 196 |
);
|
|
@@ -202,6 +239,9 @@ export const ScriptView = memo(function ScriptView({
|
|
| 202 |
<span>Code</span>
|
| 203 |
<span className="cg-script-meta">
|
| 204 |
{`v${view.version}`}
|
|
|
|
|
|
|
|
|
|
| 205 |
{dirty ? ", unsaved changes" : ""}
|
| 206 |
</span>
|
| 207 |
<span className="cg-script-spacer" />
|
|
@@ -222,11 +262,55 @@ export const ScriptView = memo(function ScriptView({
|
|
| 222 |
{running ? "Running" : "Run"}
|
| 223 |
</button>
|
| 224 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
<textarea
|
| 226 |
className="cg-script-code"
|
| 227 |
value={draft}
|
| 228 |
spellCheck={false}
|
| 229 |
readOnly={readOnly}
|
|
|
|
| 230 |
aria-label="Script source"
|
| 231 |
onChange={(e) => setDraft(e.target.value)}
|
| 232 |
/>
|
|
|
|
| 113 |
);
|
| 114 |
}
|
| 115 |
|
| 116 |
+
/**
|
| 117 |
+
* ββ W37-T41 β THE SHAPE OF A SCRIPT, SHOWN AS A PLACEHOLDER RATHER THAN PREFILLED.
|
| 118 |
+
*
|
| 119 |
+
* Picking Custom View mints a view with an EMPTY source (verified: `script_sandbox.check_source("")`
|
| 120 |
+
* accepts it, which is why "mint on pick" was safe to hand lane C in mailbox E-7). Without this, the
|
| 121 |
+
* person who has just created one meets a blank box and a Run button, which is not an invitation.
|
| 122 |
+
*
|
| 123 |
+
* β A PLACEHOLDER, NEVER A PREFILLED `value`. Prefilling would make the view DIRTY the moment it
|
| 124 |
+
* opened, arm Save on code nobody wrote, and put a version nobody asked for into a 40-deep history.
|
| 125 |
+
* A placeholder vanishes on the first keystroke and is never stored.
|
| 126 |
+
* β It is the two lines the sandbox's own fixture uses, so the example a person is shown is the
|
| 127 |
+
* example the gate proves runs [[test-double-built-from-the-producer]].
|
| 128 |
+
*/
|
| 129 |
+
const SCRIPT_STARTER =
|
| 130 |
+
"rows = scoped_table()\nemit({'kind': 'kpi', 'label': 'Records', 'value': len(rows)})";
|
| 131 |
+
|
| 132 |
+
|
| 133 |
export function ScriptOutput({ run }: { run: ScriptRun | null }) {
|
| 134 |
if (run === null)
|
| 135 |
return <p className="cg-script-empty">Run the script to see what it draws.</p>;
|
|
|
|
| 182 |
run,
|
| 183 |
saving,
|
| 184 |
readOnly = false,
|
| 185 |
+
loading = false,
|
| 186 |
+
reverting = false,
|
| 187 |
onRun,
|
| 188 |
onSave,
|
| 189 |
+
onRevert,
|
| 190 |
}: {
|
| 191 |
view: ScriptViewRecord | null;
|
| 192 |
running: boolean;
|
| 193 |
run: ScriptRun | null;
|
| 194 |
saving?: boolean;
|
| 195 |
readOnly?: boolean;
|
| 196 |
+
/** β The host knows whether a fetch is outstanding; the panel cannot tell that from a null view.
|
| 197 |
+
* Defaulted so no existing caller breaks, which is safe HERE because the default is the older,
|
| 198 |
+
* more pessimistic sentence rather than a silently disabled feature. */
|
| 199 |
+
loading?: boolean;
|
| 200 |
+
reverting?: boolean;
|
| 201 |
onRun: (draft: string) => void;
|
| 202 |
onSave?: (source: string) => void;
|
| 203 |
+
/**
|
| 204 |
+
* β W37-T46 / D-338. Optional for the SAME reason `onSave` is: a read-only reader gets the
|
| 205 |
+
* editor without the controls, and a caller that cannot revert simply does not pass this. The
|
| 206 |
+
* control is not rendered at all when it is absent, rather than rendered and refused
|
| 207 |
+
* [[permitted-is-not-answerable]].
|
| 208 |
+
*/
|
| 209 |
+
onRevert?: (version: number) => void;
|
| 210 |
}) {
|
| 211 |
const [draft, setDraft] = useState(view?.source ?? "");
|
| 212 |
const loadedId = useRef<string | null>(null);
|
|
|
|
| 218 |
setDraft(view.source);
|
| 219 |
}, [view]);
|
| 220 |
const dirty = view !== null && draft !== view.source;
|
| 221 |
+
// β W37-T41: "STILL LOADING" AND "COULD NOT BE OPENED" ARE DIFFERENT FACTS AND USED TO SHARE A
|
| 222 |
+
// SENTENCE. With mint-on-pick (E-7) the normal path now passes through `view === null` for as
|
| 223 |
+
// long as the fetch takes, so the old copy told every person who created a Custom View that it
|
| 224 |
+
// was broken, for a moment, every time. A state that renames itself is exactly what cost this
|
| 225 |
+
// product a QA pass one wave ago [[a-store-outage-renames-itself-at-every-layer]].
|
| 226 |
if (view === null)
|
| 227 |
return (
|
| 228 |
<div className="cg-script">
|
| 229 |
<p className="cg-script-empty cg-script-empty--pad">
|
| 230 |
+
{loading === true ? "Opening this view." : "This script view could not be opened."}
|
| 231 |
</p>
|
| 232 |
</div>
|
| 233 |
);
|
|
|
|
| 239 |
<span>Code</span>
|
| 240 |
<span className="cg-script-meta">
|
| 241 |
{`v${view.version}`}
|
| 242 |
+
{/* β "v5" alone cannot tell a roll-back from a coincidence of matching code. This is
|
| 243 |
+
the sentence that makes the history read as what happened. */}
|
| 244 |
+
{view.restoredFrom ? `, restored from v${view.restoredFrom}` : ""}
|
| 245 |
{dirty ? ", unsaved changes" : ""}
|
| 246 |
</span>
|
| 247 |
<span className="cg-script-spacer" />
|
|
|
|
| 262 |
{running ? "Running" : "Run"}
|
| 263 |
</button>
|
| 264 |
</div>
|
| 265 |
+
{/* ββ W37-T41 β THE PLACEHOLDER IS THE CREATE FLOW'S WHOLE EMPTY STATE, and it is the
|
| 266 |
+
half that made item 10 worth reopening. Picking Custom View mints a view with an
|
| 267 |
+
EMPTY source (verified: `script_sandbox.check_source("")` accepts it, which is why
|
| 268 |
+
"mint on pick" was safe to hand lane C in mailbox E-7). Without this the person who
|
| 269 |
+
just created one meets a blank box and a Run button, which is not an invitation, it
|
| 270 |
+
is a puzzle.
|
| 271 |
+
β A PLACEHOLDER, NOT A PREFILLED `value`. Prefilling would make the view DIRTY the
|
| 272 |
+
moment it opened, arm Save on code nobody wrote, and put a version nobody asked for
|
| 273 |
+
into a 40-deep history. It vanishes on the first keystroke and is never saved. */}
|
| 274 |
+
{/* ββ W37-T46 / D-338 β THE CONTROL THAT WAS MISSING. The API already returned
|
| 275 |
+
`history` newest-first and the header already showed `v<n>`; there was simply no way
|
| 276 |
+
to go back, which is the whole of D-338. It matters more now that a chat writes the
|
| 277 |
+
code (R2): generation without roll-back means one bad answer costs somebody their
|
| 278 |
+
view, and the person cannot tell in advance which answer that will be.
|
| 279 |
+
β THE STRIP IS ABSENT, NOT DISABLED, WHEN THERE IS NOTHING TO GO BACK TO. A first
|
| 280 |
+
version has no history, and a row of dead controls reads as a broken feature rather
|
| 281 |
+
than as an empty one.
|
| 282 |
+
β `trimmed` is PRINTED when it is non-zero. A history capped at 40 that silently
|
| 283 |
+
showed 40 would let a reader conclude the view was only ever saved 40 times; saying
|
| 284 |
+
how many were dropped is the difference between a partial record and a wrong one. */}
|
| 285 |
+
{(view.history?.length ?? 0) > 0 ? (
|
| 286 |
+
<div className="cg-script-history" aria-label="Earlier versions">
|
| 287 |
+
<span className="cg-script-history-lead">Earlier</span>
|
| 288 |
+
{[...(view.history ?? [])].map((h) => (
|
| 289 |
+
<button
|
| 290 |
+
type="button"
|
| 291 |
+
key={h.version}
|
| 292 |
+
className="cg-script-history-item"
|
| 293 |
+
disabled={readOnly || reverting || onRevert === undefined}
|
| 294 |
+
title={`Go back to version ${h.version}, saved by ${h.author || "somebody"}`}
|
| 295 |
+
onClick={() => onRevert?.(h.version)}
|
| 296 |
+
>
|
| 297 |
+
{`v${h.version}`}
|
| 298 |
+
</button>
|
| 299 |
+
))}
|
| 300 |
+
{view.trimmed > 0 ? (
|
| 301 |
+
<span className="cg-script-history-trimmed">
|
| 302 |
+
{`${view.trimmed} older ${view.trimmed === 1 ? "version" : "versions"} dropped`}
|
| 303 |
+
</span>
|
| 304 |
+
) : null}
|
| 305 |
+
{reverting ? <span className="cg-script-history-trimmed">Going back</span> : null}
|
| 306 |
+
</div>
|
| 307 |
+
) : null}
|
| 308 |
<textarea
|
| 309 |
className="cg-script-code"
|
| 310 |
value={draft}
|
| 311 |
spellCheck={false}
|
| 312 |
readOnly={readOnly}
|
| 313 |
+
placeholder={SCRIPT_STARTER}
|
| 314 |
aria-label="Script source"
|
| 315 |
onChange={(e) => setDraft(e.target.value)}
|
| 316 |
/>
|
web/src/customer-grid/Toolbar.tsx
CHANGED
|
@@ -111,6 +111,14 @@ export interface ToolbarProps {
|
|
| 111 |
unresolvedCount?: number;
|
| 112 |
/** Owner item 5 β the cohorts this user has, for `Where [Cohort] [is part of] [β¦]`. */
|
| 113 |
lists?: { id: string; name: string }[];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
/**
|
| 115 |
* Item 12 (C-LOCK) β this view is LOCKED to a cohort. Present = say so in the filter
|
| 116 |
* builder, because the lock narrows the list and is not one of the conditions shown there:
|
|
@@ -565,6 +573,7 @@ export default function Toolbar({
|
|
| 565 |
pendingMeasureCount = 0,
|
| 566 |
unresolvedCount = 0,
|
| 567 |
lists = [],
|
|
|
|
| 568 |
cohortLock,
|
| 569 |
copyViews,
|
| 570 |
onCopyConfig,
|
|
@@ -737,6 +746,7 @@ export default function Toolbar({
|
|
| 737 |
fields={fields}
|
| 738 |
measures={measures}
|
| 739 |
cohorts={lists}
|
|
|
|
| 740 |
filters={filterTree}
|
| 741 |
onChange={onFilterTree}
|
| 742 |
statusValues={statusValues}
|
|
|
|
| 111 |
unresolvedCount?: number;
|
| 112 |
/** Owner item 5 β the cohorts this user has, for `Where [Cohort] [is part of] [β¦]`. */
|
| 113 |
lists?: { id: string; name: string }[];
|
| 114 |
+
/** ββ W37-T26 (C4) β the views a `Where [View] [is] [β¦]` condition may name. A PASS-THROUGH:
|
| 115 |
+
* this toolbar mounts `FilterBuilderPanel`, so without these two lines the leaf evaluates,
|
| 116 |
+
* persists and refuses cycles while being impossible to REACH β mounted-but-never-offered.
|
| 117 |
+
* β Empty = not offered, which is what a server-windowed grid passes (the SQL compiler has no
|
| 118 |
+
* arm for the pseudo-column, so a leaf there would be INACTIVE and inactive means every row).
|
| 119 |
+
* β Edited by lane C, not this file's owner: `Toolbar.tsx` is in NO fence and lane C was the
|
| 120 |
+
* last session open. See `mailbox/C.md` NOTE C-24. */
|
| 121 |
+
viewChoices?: { id: string; name: string }[];
|
| 122 |
/**
|
| 123 |
* Item 12 (C-LOCK) β this view is LOCKED to a cohort. Present = say so in the filter
|
| 124 |
* builder, because the lock narrows the list and is not one of the conditions shown there:
|
|
|
|
| 573 |
pendingMeasureCount = 0,
|
| 574 |
unresolvedCount = 0,
|
| 575 |
lists = [],
|
| 576 |
+
viewChoices = [],
|
| 577 |
cohortLock,
|
| 578 |
copyViews,
|
| 579 |
onCopyConfig,
|
|
|
|
| 746 |
fields={fields}
|
| 747 |
measures={measures}
|
| 748 |
cohorts={lists}
|
| 749 |
+
viewChoices={viewChoices}
|
| 750 |
filters={filterTree}
|
| 751 |
onChange={onFilterTree}
|
| 752 |
statusValues={statusValues}
|
web/src/customer-grid/catalog.css
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* customer-grid/catalog.css β W37-T32/T33, the catalog designer's new gestures.
|
| 2 |
+
*
|
| 3 |
+
* β WHY THIS FILE AND NOT index.css: contract C8. `index.css` is 12k+ lines and belongs to lane A
|
| 4 |
+
* this wave, so every other lane adds styles in its own per-surface stylesheet imported by its own
|
| 5 |
+
* component. `scriptView.css`, `navExtras.css` and this wave's `map.css` are the precedent.
|
| 6 |
+
*
|
| 7 |
+
* β NOTHING PRINT-RELATED MAY LAND HERE WITHOUT ITS SCAN COMING WITH IT. `catalog.test.ts` reads
|
| 8 |
+
* `src/index.css` BY NAME for the print-isolation rules (`html.cg-cat-printing body > *`,
|
| 9 |
+
* `print-color-adjust: exact`, the tokens declared inside `.cg-cat-page`). A print rule moved into
|
| 10 |
+
* this file would be invisible to that check, which is a gate quietly getting smaller. Everything
|
| 11 |
+
* below is SCREEN-ONLY designer chrome, on purpose.
|
| 12 |
+
*/
|
| 13 |
+
|
| 14 |
+
/* --- dragging a page to reorder it (T32 / R6) -----------------------------
|
| 15 |
+
*
|
| 16 |
+
* β PAGES REORDER, THEY DO NOT FREE-FLOAT. A printed document is a sequence, so a page has a
|
| 17 |
+
* position IN that sequence; only the images inside a section carry free coordinates (T33). The
|
| 18 |
+
* affordance therefore has to read as "this row moves between other rows", never as "this thing
|
| 19 |
+
* floats", which is why the drop target is a LINE and not a box.
|
| 20 |
+
*/
|
| 21 |
+
|
| 22 |
+
.cg-cat-pagerow {
|
| 23 |
+
/* The grab affordance sits on the row, not on the button inside it: a draggable <button> starts
|
| 24 |
+
a drag in some browsers, fires a click in others, and does neither reliably in Firefox. */
|
| 25 |
+
cursor: grab;
|
| 26 |
+
position: relative;
|
| 27 |
+
border-radius: 5px;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
.cg-cat-pagerow:active {
|
| 31 |
+
cursor: grabbing;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
.cg-cat-pagerow.is-dragging {
|
| 35 |
+
/* Faded, NOT hidden. A row that vanishes while you hold it makes the list jump under the cursor
|
| 36 |
+
and you lose the place you were aiming at. */
|
| 37 |
+
opacity: 0.42;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
.cg-cat-pagerow.is-dropzone::before {
|
| 41 |
+
/* The insertion LINE. A highlighted box would say "drop it into this page"; a line above the row
|
| 42 |
+
says "it lands here, before this one", which is what actually happens. */
|
| 43 |
+
content: "";
|
| 44 |
+
position: absolute;
|
| 45 |
+
left: 2px;
|
| 46 |
+
right: 2px;
|
| 47 |
+
top: -2px;
|
| 48 |
+
height: 2px;
|
| 49 |
+
border-radius: 2px;
|
| 50 |
+
background: #1f4e78;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
/* --- free-placed items inside a section (T33 / R6) ------------------------
|
| 54 |
+
*
|
| 55 |
+
* ββ A COORDINATE-FREE ITEM IS NOT AN ITEM AT THE ORIGIN. Everything authored before this wave
|
| 56 |
+
* has no `x`/`y`, and the renderer reads that absence as "flow position N" so an existing catalog
|
| 57 |
+
* keeps printing exactly as it did. These rules only ever apply to an item that HAS coordinates;
|
| 58 |
+
* the flow layout is untouched and stays the default.
|
| 59 |
+
*/
|
| 60 |
+
|
| 61 |
+
/* ββ DELIBERATELY *NOT* `position: relative`, AND THIS IS THE LOAD-BEARING LINE.
|
| 62 |
+
*
|
| 63 |
+
* It was relative, which made the GRID the containing block for every placed item. The grid's
|
| 64 |
+
* height comes from its in-flow content, and placing an item REMOVES it from that flow: place them
|
| 65 |
+
* all and the grid collapses to nothing, so `top: 96%` becomes 96% of zero and every placed image
|
| 66 |
+
* piles at the top. A feedback loop between the layout and the coordinates it is measured in.
|
| 67 |
+
*
|
| 68 |
+
* With the grid static, an absolutely-positioned item resolves against `.cg-cat-page`, which is
|
| 69 |
+
* `position: relative` with an EXPLICIT `height: var(--cat-h)` and `overflow: hidden`. That box is
|
| 70 |
+
* the sheet of paper: fixed, independent of how much flows inside it, and identical in the designer
|
| 71 |
+
* and in the print tree. A percentage of it means the same thing forever.
|
| 72 |
+
* β The class stays because it MARKS the state for the gate and for the eye. It must not acquire a
|
| 73 |
+
* `position` later; that is the bug above, coming back.
|
| 74 |
+
*/
|
| 75 |
+
.cg-cat-slot-free {
|
| 76 |
+
position: static;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
.cg-cat-item-free {
|
| 80 |
+
position: absolute;
|
| 81 |
+
cursor: grab;
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
.cg-cat-item-free:active {
|
| 85 |
+
cursor: grabbing;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
.cg-cat-item-free.is-dragging {
|
| 89 |
+
opacity: 0.55;
|
| 90 |
+
outline: 1px dashed #1f4e78;
|
| 91 |
+
}
|
web/src/customer-grid/chatDock.css
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* customer-grid/chatDock.css β W37-T29 (contract C6 + C8): the chat's DOCK and its LAUNCHER.
|
| 2 |
+
*
|
| 3 |
+
* β A SEPARATE SHEET FROM E's `gridChat.css` ON PURPOSE, and the split is the contract rather
|
| 4 |
+
* than taste. C8 hands `index.css` to lane A alone and tells every other lane to add styles in its
|
| 5 |
+
* own per-surface sheet; E owns how the PANEL looks and this file owns where the HOST puts it and
|
| 6 |
+
* how a person opens it. Two lanes editing one sheet is the collision C8 exists to prevent.
|
| 7 |
+
*
|
| 8 |
+
* ββ THE LAUNCHER IS THE WHOLE REASON THIS FILE EXISTS. A panel with no control that opens it is
|
| 9 |
+
* mounted-but-never-offered, which is this repo's most repeated defect: one wave shipped five whole,
|
| 10 |
+
* correct, unreachable features past every gate. E's sheet cannot carry the launcher, because the
|
| 11 |
+
* launcher is not part of the panel.
|
| 12 |
+
* β Tokens only, never fresh hexes (DESIGN.md: navy #1F4E78 + gold #C8A24B). No emojis, no
|
| 13 |
+
* ALL-CAPS chrome.
|
| 14 |
+
*/
|
| 15 |
+
|
| 16 |
+
/* The dock: a flex child of `.cg-shell`, seated between the views rail and the grid, so the panel
|
| 17 |
+
reads as part of the workspace rather than as an overlay floating on top of it. E's `.cg-chat`
|
| 18 |
+
brings its own width floor, ceiling and left border; this wrapper only owns the seat. */
|
| 19 |
+
.cg-chat-dock {
|
| 20 |
+
display: flex;
|
| 21 |
+
min-height: 0;
|
| 22 |
+
align-self: stretch;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
/* The launcher. A rail-height strip rather than a floating bubble: a bubble would sit over the
|
| 26 |
+
grid's own rows, which is the one place a person is trying to read. */
|
| 27 |
+
.cg-chat-open {
|
| 28 |
+
display: flex;
|
| 29 |
+
align-items: center;
|
| 30 |
+
gap: 6px;
|
| 31 |
+
align-self: flex-start;
|
| 32 |
+
margin: 8px 0 0 8px;
|
| 33 |
+
padding: 6px 10px;
|
| 34 |
+
border: 1px solid var(--lp-border, #e4e7ec);
|
| 35 |
+
border-radius: 6px;
|
| 36 |
+
background: var(--lp-surface, #ffffff);
|
| 37 |
+
color: var(--lp-ink, #1f2937);
|
| 38 |
+
font-size: var(--lp-fs-xs);
|
| 39 |
+
line-height: 1.2;
|
| 40 |
+
cursor: pointer;
|
| 41 |
+
white-space: nowrap;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
.cg-chat-open:hover {
|
| 45 |
+
border-color: var(--lp-navy, #1f4e78);
|
| 46 |
+
color: var(--lp-navy, #1f4e78);
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
.cg-chat-open:focus-visible {
|
| 50 |
+
outline: 2px solid var(--lp-gold, #c8a24b);
|
| 51 |
+
outline-offset: 1px;
|
| 52 |
+
}
|
web/src/customer-grid/gridChat.css
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* customer-grid/gridChat.css β the docked chat panel (W37-T40, contract C8).
|
| 2 |
+
*
|
| 3 |
+
* β MODULE-LOCAL BY CONTRACT, not by preference. C8 hands `index.css` (12k+ lines) to lane A
|
| 4 |
+
* alone this wave, and every other lane adds styles in its own per-surface sheet imported by its
|
| 5 |
+
* own component. The precedent already exists next door in `scriptView.css` and `navExtras.css`.
|
| 6 |
+
* β Colours come from the design tokens (navy #1F4E78 + gold #C8A24B, DESIGN.md), never from fresh
|
| 7 |
+
* hexes: a surface that invents its own palette is the thing the constitution exists to prevent.
|
| 8 |
+
* Only the tokens this file actually uses are named.
|
| 9 |
+
* ββ NO FALLBACK LITERAL INSIDE A `font:` SHORTHAND, and this sheet shipped ten of them before
|
| 10 |
+
* `web_ui` said so (lane A, mailbox A-47). A fallback there is a SECOND SOURCE OF TRUTH for a size
|
| 11 |
+
* that silently becomes THE size the day the token is renamed, and `index.css` always loads, so it
|
| 12 |
+
* can never actually fire. Sizes are therefore `var(--lp-fs-xs)` bare; colours keep their fallback
|
| 13 |
+
* because a missing colour is a legibility failure rather than a silent wrong number.
|
| 14 |
+
* β NO EMOJIS AND NO ALL-CAPS CHROME (DESIGN.md). */
|
| 15 |
+
|
| 16 |
+
.cg-chat {
|
| 17 |
+
display: flex;
|
| 18 |
+
flex-direction: column;
|
| 19 |
+
height: 100%;
|
| 20 |
+
min-height: 0;
|
| 21 |
+
/* β A FLOOR AND A CEILING, NOT A FIXED WIDTH. The host docks this beside a grid whose columns
|
| 22 |
+
are the reason the person is here; a panel that cannot give width back squeezes the thing it
|
| 23 |
+
is talking about. Below the floor the transcript stops being readable, so the floor is real. */
|
| 24 |
+
min-width: 280px;
|
| 25 |
+
max-width: 420px;
|
| 26 |
+
flex: 0 0 340px;
|
| 27 |
+
background: var(--lp-surface, #ffffff);
|
| 28 |
+
border-left: 1px solid var(--lp-border, #e4e7ec);
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
/* Below 1024px a docked panel and a grid cannot both be usable, so the panel takes the surface.
|
| 32 |
+
β It does NOT hide itself: hiding would leave the host's toggle saying "open" with nothing on
|
| 33 |
+
screen, which is a worse failure than a full-width panel the person can close. */
|
| 34 |
+
@media (max-width: 1024px) {
|
| 35 |
+
.cg-chat { flex: 1 1 auto; max-width: none; border-left: 0; }
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
.cg-chat-head {
|
| 39 |
+
display: flex;
|
| 40 |
+
align-items: center;
|
| 41 |
+
gap: 8px;
|
| 42 |
+
padding: 10px 12px;
|
| 43 |
+
border-bottom: 1px solid var(--lp-border, #e4e7ec);
|
| 44 |
+
background: var(--lp-surface, #ffffff);
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
.cg-chat-title {
|
| 48 |
+
font: 600 var(--lp-fs-xs)/1.4 Inter, system-ui, sans-serif;
|
| 49 |
+
color: var(--lp-text, #1d2939);
|
| 50 |
+
/* The database name can be long and the header is narrow. Truncating beats wrapping into a
|
| 51 |
+
two-line header that shifts the whole panel down. */
|
| 52 |
+
overflow: hidden;
|
| 53 |
+
text-overflow: ellipsis;
|
| 54 |
+
white-space: nowrap;
|
| 55 |
+
flex: 1 1 auto;
|
| 56 |
+
min-width: 0;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
.cg-chat-close {
|
| 60 |
+
display: inline-flex;
|
| 61 |
+
align-items: center;
|
| 62 |
+
justify-content: center;
|
| 63 |
+
width: 24px;
|
| 64 |
+
height: 24px;
|
| 65 |
+
border: 0;
|
| 66 |
+
border-radius: 6px;
|
| 67 |
+
background: transparent;
|
| 68 |
+
color: var(--lp-text-muted, #667085);
|
| 69 |
+
cursor: pointer;
|
| 70 |
+
}
|
| 71 |
+
.cg-chat-close:hover { background: var(--lp-surface-sunken, #f2f4f7); color: var(--lp-text, #1d2939); }
|
| 72 |
+
|
| 73 |
+
/* ββ the transcript βββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 74 |
+
|
| 75 |
+
.cg-chat-log {
|
| 76 |
+
display: flex;
|
| 77 |
+
flex-direction: column;
|
| 78 |
+
gap: 10px;
|
| 79 |
+
flex: 1 1 auto;
|
| 80 |
+
min-height: 0;
|
| 81 |
+
overflow-y: auto;
|
| 82 |
+
padding: 14px 12px;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
.cg-chat-turn { display: flex; flex-direction: column; gap: 6px; }
|
| 86 |
+
|
| 87 |
+
.cg-chat-msg {
|
| 88 |
+
max-width: 92%;
|
| 89 |
+
padding: 8px 10px;
|
| 90 |
+
border-radius: 10px;
|
| 91 |
+
font: 400 var(--lp-fs-xs)/1.55 Inter, system-ui, sans-serif;
|
| 92 |
+
white-space: pre-wrap;
|
| 93 |
+
/* β A pasted column name or an id has no spaces to break on and would otherwise push the panel
|
| 94 |
+
wider than its own max-width. */
|
| 95 |
+
overflow-wrap: anywhere;
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
.cg-chat-msg.is-you {
|
| 99 |
+
align-self: flex-end;
|
| 100 |
+
background: var(--lp-navy, #1f4e78);
|
| 101 |
+
color: #ffffff;
|
| 102 |
+
}
|
| 103 |
+
.cg-chat-msg.is-assistant {
|
| 104 |
+
align-self: flex-start;
|
| 105 |
+
background: var(--lp-surface-sunken, #f2f4f7);
|
| 106 |
+
color: var(--lp-text, #1d2939);
|
| 107 |
+
}
|
| 108 |
+
/* β The panel talking about ITSELF (a write landed, a proposal was declined) is neither side of
|
| 109 |
+
the conversation, so it is centred and quiet rather than dressed as an answer. */
|
| 110 |
+
.cg-chat-msg.is-note {
|
| 111 |
+
align-self: center;
|
| 112 |
+
max-width: 100%;
|
| 113 |
+
background: transparent;
|
| 114 |
+
color: var(--lp-text-muted, #667085);
|
| 115 |
+
font-size: var(--lp-fs-2xs, 11px);
|
| 116 |
+
text-align: center;
|
| 117 |
+
padding: 2px 4px;
|
| 118 |
+
}
|
| 119 |
+
/* β An error is a KIND OF TURN, in the transcript where it can be re-read while the person
|
| 120 |
+
rephrases. Not a toast that has already faded by then. */
|
| 121 |
+
.cg-chat-msg.is-error {
|
| 122 |
+
align-self: flex-start;
|
| 123 |
+
background: #fef3f2;
|
| 124 |
+
color: #b42318;
|
| 125 |
+
border: 1px solid #fecdca;
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
.cg-chat-pending {
|
| 129 |
+
display: flex;
|
| 130 |
+
align-items: center;
|
| 131 |
+
gap: 8px;
|
| 132 |
+
padding: 2px 2px 4px;
|
| 133 |
+
font: 400 var(--lp-fs-2xs)/1.4 Inter, system-ui, sans-serif;
|
| 134 |
+
color: var(--lp-text-muted, #667085);
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
/* ββ the write confirm (R10) ββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 138 |
+
|
| 139 |
+
.cg-chat-edits {
|
| 140 |
+
border: 1px solid var(--lp-border, #e4e7ec);
|
| 141 |
+
border-radius: 10px;
|
| 142 |
+
background: var(--lp-surface, #ffffff);
|
| 143 |
+
padding: 10px;
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
.cg-chat-edits-head {
|
| 147 |
+
margin: 0 0 8px;
|
| 148 |
+
font: 600 var(--lp-fs-2xs)/1.4 Inter, system-ui, sans-serif;
|
| 149 |
+
color: var(--lp-text, #1d2939);
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
.cg-chat-edits-table {
|
| 153 |
+
width: 100%;
|
| 154 |
+
border-collapse: collapse;
|
| 155 |
+
font: 400 var(--lp-fs-3xs)/1.45 Inter, system-ui, sans-serif;
|
| 156 |
+
/* A proposal can name more cells than fit; it scrolls rather than being clipped, because a
|
| 157 |
+
confirm the person cannot fully read is not a confirm. */
|
| 158 |
+
display: block;
|
| 159 |
+
max-height: 220px;
|
| 160 |
+
overflow: auto;
|
| 161 |
+
}
|
| 162 |
+
.cg-chat-edits-table th {
|
| 163 |
+
text-align: left;
|
| 164 |
+
font-weight: 600;
|
| 165 |
+
color: var(--lp-text-muted, #667085);
|
| 166 |
+
padding: 0 8px 4px 0;
|
| 167 |
+
position: sticky;
|
| 168 |
+
top: 0;
|
| 169 |
+
background: var(--lp-surface, #ffffff);
|
| 170 |
+
}
|
| 171 |
+
.cg-chat-edits-table td {
|
| 172 |
+
padding: 3px 8px 3px 0;
|
| 173 |
+
color: var(--lp-text, #1d2939);
|
| 174 |
+
border-top: 1px solid var(--lp-border, #e4e7ec);
|
| 175 |
+
vertical-align: top;
|
| 176 |
+
overflow-wrap: anywhere;
|
| 177 |
+
}
|
| 178 |
+
/* β The two values are told apart by WEIGHT and colour, never by colour alone: a red/green pair
|
| 179 |
+
is unreadable to a substantial minority of people, and this is a confirm before a write. */
|
| 180 |
+
.cg-chat-before { color: var(--lp-text-muted, #667085); text-decoration: line-through; }
|
| 181 |
+
.cg-chat-after { color: var(--lp-navy, #1f4e78); font-weight: 600; }
|
| 182 |
+
|
| 183 |
+
/* β Centred under the note it belongs to, and quiet: an undo control that shouted would read as
|
| 184 |
+
a warning that something went wrong, when what happened is exactly what the person confirmed. */
|
| 185 |
+
.cg-chat-undo { display: flex; justify-content: center; margin-top: 2px; }
|
| 186 |
+
|
| 187 |
+
.cg-chat-edits-act {
|
| 188 |
+
display: flex;
|
| 189 |
+
justify-content: flex-end;
|
| 190 |
+
gap: 8px;
|
| 191 |
+
margin-top: 10px;
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
.cg-chat-ghost,
|
| 195 |
+
.cg-chat-go {
|
| 196 |
+
border-radius: 7px;
|
| 197 |
+
padding: 5px 12px;
|
| 198 |
+
font: 500 var(--lp-fs-2xs)/1.2 Inter, system-ui, sans-serif;
|
| 199 |
+
cursor: pointer;
|
| 200 |
+
}
|
| 201 |
+
.cg-chat-ghost {
|
| 202 |
+
border: 1px solid var(--lp-border, #e4e7ec);
|
| 203 |
+
background: var(--lp-surface, #ffffff);
|
| 204 |
+
color: var(--lp-text, #1d2939);
|
| 205 |
+
}
|
| 206 |
+
.cg-chat-go {
|
| 207 |
+
border: 1px solid var(--lp-navy, #1f4e78);
|
| 208 |
+
background: var(--lp-navy, #1f4e78);
|
| 209 |
+
color: #ffffff;
|
| 210 |
+
}
|
| 211 |
+
.cg-chat-ghost:disabled,
|
| 212 |
+
.cg-chat-go:disabled { opacity: 0.55; cursor: default; }
|
| 213 |
+
|
| 214 |
+
/* ββ the opening and the composer βββββββββββββββββββββββββββββββββββββββββ */
|
| 215 |
+
|
| 216 |
+
.cg-chat-opening {
|
| 217 |
+
display: flex;
|
| 218 |
+
flex-direction: column;
|
| 219 |
+
justify-content: center;
|
| 220 |
+
flex: 1 1 auto;
|
| 221 |
+
min-height: 0;
|
| 222 |
+
gap: 10px;
|
| 223 |
+
padding: 16px 12px;
|
| 224 |
+
text-align: center;
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
.cg-chat-opening-h {
|
| 228 |
+
margin: 0;
|
| 229 |
+
font: 600 var(--lp-fs-lg)/1.3 Inter, system-ui, sans-serif;
|
| 230 |
+
color: var(--lp-text, #1d2939);
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
.cg-chat-opening-p {
|
| 234 |
+
margin: 0 0 6px;
|
| 235 |
+
font: 400 var(--lp-fs-2xs)/1.55 Inter, system-ui, sans-serif;
|
| 236 |
+
color: var(--lp-text-muted, #667085);
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
.cg-chat-foot { padding: 10px 12px 12px; border-top: 1px solid var(--lp-border, #e4e7ec); }
|
| 240 |
+
|
| 241 |
+
.cg-chat-composer {
|
| 242 |
+
border: 1px solid var(--lp-border, #e4e7ec);
|
| 243 |
+
border-radius: 12px;
|
| 244 |
+
background: var(--lp-surface, #ffffff);
|
| 245 |
+
padding: 8px;
|
| 246 |
+
text-align: left;
|
| 247 |
+
}
|
| 248 |
+
.cg-chat-composer:focus-within { border-color: var(--lp-navy, #1f4e78); }
|
| 249 |
+
|
| 250 |
+
.cg-chat-input {
|
| 251 |
+
width: 100%;
|
| 252 |
+
border: 0;
|
| 253 |
+
outline: 0;
|
| 254 |
+
resize: none;
|
| 255 |
+
background: transparent;
|
| 256 |
+
color: var(--lp-text, #1d2939);
|
| 257 |
+
font: 400 var(--lp-fs-xs)/1.5 Inter, system-ui, sans-serif;
|
| 258 |
+
}
|
| 259 |
+
.cg-chat-input::placeholder { color: var(--lp-text-muted, #667085); }
|
| 260 |
+
.cg-chat-input:disabled { color: var(--lp-text-muted, #667085); }
|
| 261 |
+
|
| 262 |
+
/* β The foot row sits INSIDE the composer's border, which is the shape `cg-agent-composer-foot`
|
| 263 |
+
already established. A second card outline under the input would read as two controls. */
|
| 264 |
+
.cg-chat-composer-foot {
|
| 265 |
+
display: flex;
|
| 266 |
+
align-items: center;
|
| 267 |
+
gap: 8px;
|
| 268 |
+
margin-top: 6px;
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
.cg-chat-scope {
|
| 272 |
+
font: 400 var(--lp-fs-3xs)/1.3 Inter, system-ui, sans-serif;
|
| 273 |
+
color: var(--lp-text-muted, #667085);
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
.cg-chat-spacer { flex: 1 1 auto; }
|
| 277 |
+
|
| 278 |
+
.cg-chat-send {
|
| 279 |
+
display: inline-flex;
|
| 280 |
+
align-items: center;
|
| 281 |
+
justify-content: center;
|
| 282 |
+
width: 26px;
|
| 283 |
+
height: 26px;
|
| 284 |
+
border: 0;
|
| 285 |
+
border-radius: 50%;
|
| 286 |
+
background: var(--lp-navy, #1f4e78);
|
| 287 |
+
color: #ffffff;
|
| 288 |
+
cursor: pointer;
|
| 289 |
+
}
|
| 290 |
+
.cg-chat-send:disabled {
|
| 291 |
+
background: var(--lp-surface-sunken, #f2f4f7);
|
| 292 |
+
color: var(--lp-text-muted, #667085);
|
| 293 |
+
cursor: default;
|
| 294 |
+
}
|
web/src/customer-grid/gridChat.ts
ADDED
|
@@ -0,0 +1,461 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ---------------------------------------------------------------------------
|
| 2 |
+
// customer-grid / gridChat.ts β the chat panel's state, as pure functions (W37-T40, R2, C6).
|
| 3 |
+
//
|
| 4 |
+
// ββ WHY THE STATE LIVES HERE AND NOT IN THE COMPONENT. `<GridChat>` has to demonstrate three
|
| 5 |
+
// things its `done-when` names: a message history, an in-flight state and an error state. If those
|
| 6 |
+
// transitions live in `useState` callbacks inside the .tsx, the only way to assert on them is to
|
| 7 |
+
// render React and match emitted markup β which pins the compiler's output rather than the
|
| 8 |
+
// behaviour, and goes red on a refactor that changed nothing a person can see. Here they are plain
|
| 9 |
+
// functions over a plain object, so the gate checks the STATE MACHINE without React in the process
|
| 10 |
+
// at all, and the render only has to prove the three states paint.
|
| 11 |
+
//
|
| 12 |
+
// ββ NOTHING IN THIS FILE TALKS TO THE NETWORK, THE GRID, OR THE STORE β contract C6, and it is
|
| 13 |
+
// structural rather than a promise. There is no `fetch` here and no import of anything that has
|
| 14 |
+
// one: every action the chat can take arrives as a callback the host supplies. That is what lets
|
| 15 |
+
// the host WALL the chat by simply not passing a power, instead of the chat asking permission and
|
| 16 |
+
// being told no [[permitted-is-not-answerable]].
|
| 17 |
+
//
|
| 18 |
+
// β NO EM DASH OR EN DASH IN ANY STRING THAT REACHES A SCREEN (standing rule 2). The sentences
|
| 19 |
+
// below are user-facing copy, and `web_prose` cannot see a wrapped JSX sentence, so the discipline
|
| 20 |
+
// is on the author. Commas, colons and full stops carry the same weight and are not AI slop.
|
| 21 |
+
// ---------------------------------------------------------------------------
|
| 22 |
+
|
| 23 |
+
/** One column the chat is allowed to know about. The host decides what goes in this list. */
|
| 24 |
+
export interface GridChatField {
|
| 25 |
+
key: string;
|
| 26 |
+
label: string;
|
| 27 |
+
type: string;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
/**
|
| 31 |
+
* What a host callback hands back for a question.
|
| 32 |
+
*
|
| 33 |
+
* β `text` IS REQUIRED AND `unanswerable` IS THE HONEST OUT. D-339's lesson is that an agent
|
| 34 |
+
* which cannot read the database answers from words alone and invents a number; a host that cannot
|
| 35 |
+
* answer must be able to SAY so rather than return an empty string the panel renders as silence.
|
| 36 |
+
*/
|
| 37 |
+
export interface GridChatAnswer {
|
| 38 |
+
text: string;
|
| 39 |
+
/** Set when the question named something this database does not have. Rendered as an answer,
|
| 40 |
+
* not as a failure: the person asked a reasonable question about the wrong column. */
|
| 41 |
+
unanswerable?: boolean;
|
| 42 |
+
/**
|
| 43 |
+
* ββ R10's PROPOSAL CHANNEL, DECLARED RATHER THAN CAST. The panel reads this to decide that an
|
| 44 |
+
* answer is a proposed WRITE and must be shown for confirmation instead of rendered as prose.
|
| 45 |
+
*
|
| 46 |
+
* β IT WAS A CAST FIRST, AND THAT WAS THE BUG-IN-WAITING. `GridChat` read
|
| 47 |
+
* `(answer as {edits?: ...}).edits` while this interface said nothing about it, so the contract
|
| 48 |
+
* lived in one file and the reader in another. The host reads the PUBLISHED type; a host that
|
| 49 |
+
* never sees `edits` never sends `edits`, the whole confirm path is dead on arrival, and nothing
|
| 50 |
+
* anywhere fails [[flag-shipped-without-its-writer]]. Typing it is the difference between a
|
| 51 |
+
* channel and a coincidence.
|
| 52 |
+
* β Its presence is a PROPOSAL, never an instruction: nothing is written until the person
|
| 53 |
+
* confirms, and a caller with `canEdit:false` is not shown the control at all.
|
| 54 |
+
*/
|
| 55 |
+
edits?: GridChatEdit[];
|
| 56 |
+
/**
|
| 57 |
+
* ββ W37-T44's GENERATION CHANNEL. The host authored a Custom View's code; the panel RUNS it
|
| 58 |
+
* through `onRunScript` and reports what happened.
|
| 59 |
+
*
|
| 60 |
+
* β DECLARED, NOT INFERRED, FOR THE SAME REASON `edits` IS. A channel the reader consumes and
|
| 61 |
+
* the published type does not mention is a channel the host never fills, and nothing anywhere
|
| 62 |
+
* fails [[flag-shipped-without-its-writer]] β which is exactly what a `void`ed callback and a
|
| 63 |
+
* confident `observed:` line looked like in the first draft of this ticket.
|
| 64 |
+
* β AUTHORING IS THE HOST'S, RUNNING IS THE PANEL'S. That split is what keeps the sandbox
|
| 65 |
+
* un-widened: the code goes through the ordinary run door a hand-typed script uses.
|
| 66 |
+
*/
|
| 67 |
+
script?: string;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
/** The outcome of running a Custom View's code. `error` is a sentence, never a stack trace. */
|
| 71 |
+
export interface GridChatRunResult {
|
| 72 |
+
ok: boolean;
|
| 73 |
+
error?: string;
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
/** One cell the chat proposes to write. R10: the person sees `before` and `after` before anything. */
|
| 77 |
+
export interface GridChatEdit {
|
| 78 |
+
rowId: string;
|
| 79 |
+
rowLabel: string;
|
| 80 |
+
field: string;
|
| 81 |
+
fieldLabel: string;
|
| 82 |
+
before: string;
|
| 83 |
+
after: string;
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
/** Who said it. `note` is the panel talking about itself (a confirm outcome, a cancelled write). */
|
| 87 |
+
export type GridChatWho = "you" | "assistant" | "note";
|
| 88 |
+
|
| 89 |
+
/**
|
| 90 |
+
* One turn. β `error` is a KIND OF TURN rather than a banner, and that is deliberate: a failed
|
| 91 |
+
* question is part of the conversation a person is having. They will rephrase and try again, and a
|
| 92 |
+
* toast that has already faded cannot be re-read while they do. `AutomationChat` made the same
|
| 93 |
+
* call for the same reason; two chat surfaces disagreeing about where failure lives is how one
|
| 94 |
+
* product stops looking like one product.
|
| 95 |
+
*/
|
| 96 |
+
export interface GridChatTurn {
|
| 97 |
+
who: GridChatWho;
|
| 98 |
+
text: string;
|
| 99 |
+
error?: boolean;
|
| 100 |
+
/** Present only on a turn that is proposing writes. The panel renders the before/after list. */
|
| 101 |
+
edits?: GridChatEdit[];
|
| 102 |
+
/**
|
| 103 |
+
* ββ THE INVERSE OF A WRITE THAT ALREADY HAPPENED, CARRIED ON THE NOTE THAT REPORTS IT.
|
| 104 |
+
*
|
| 105 |
+
* β IT IS BUILT FROM THE PROPOSAL, BEFORE THE WRITE, NOT READ BACK AFTER IT
|
| 106 |
+
* [[capture-the-inverse-before-the-write]]. Once the cells are written the old values are gone
|
| 107 |
+
* from the store, so an undo assembled afterwards can only re-read what it just wrote and undo
|
| 108 |
+
* nothing. Every `GridChatEdit` already carries `before` because the person had to SEE it in
|
| 109 |
+
* order to confirm, which means the inverse costs nothing extra and cannot be stale.
|
| 110 |
+
*/
|
| 111 |
+
undo?: GridChatEdit[];
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
/**
|
| 115 |
+
* The whole panel state.
|
| 116 |
+
*
|
| 117 |
+
* β `pending` IS THE IN-FLIGHT STATE AND IT IS THE QUESTION ITSELF, not a boolean. A boolean can
|
| 118 |
+
* go stale against the transcript: two rapid sends with `busy: true` leave no record of which
|
| 119 |
+
* question is actually outstanding, and the answer lands under whichever turn happens to be last.
|
| 120 |
+
* Holding the text means the panel can always say what it is waiting on, and `answered()` can
|
| 121 |
+
* refuse an answer that arrived after the person moved on.
|
| 122 |
+
*/
|
| 123 |
+
export interface GridChatState {
|
| 124 |
+
turns: GridChatTurn[];
|
| 125 |
+
pending: string | null;
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
export function emptyChat(): GridChatState {
|
| 129 |
+
return { turns: [], pending: null };
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
export function isBusy(state: GridChatState): boolean {
|
| 133 |
+
return state.pending !== null;
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
/**
|
| 137 |
+
* The person sent something. Returns the new state, or the SAME state when there is nothing to
|
| 138 |
+
* send or a question is already outstanding.
|
| 139 |
+
*
|
| 140 |
+
* β ONE QUESTION AT A TIME, AND THE REFUSAL IS SILENT BY DESIGN. The composer disables its own
|
| 141 |
+
* send button while `pending`, so a second send can only arrive from a key repeat or a double
|
| 142 |
+
* click. Answering that with an error turn would put a scolding message in a transcript the person
|
| 143 |
+
* did not mean to write in. Returning the same object also lets the caller skip the re-render.
|
| 144 |
+
*/
|
| 145 |
+
export function asked(state: GridChatState, said: string): GridChatState {
|
| 146 |
+
const text = String(said || "").trim();
|
| 147 |
+
if (!text || state.pending !== null) return state;
|
| 148 |
+
return { turns: [...state.turns, { who: "you", text }], pending: text };
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
/**
|
| 152 |
+
* A host callback answered. `forQuestion` is the text `asked()` returned in `pending`.
|
| 153 |
+
*
|
| 154 |
+
* β AN ANSWER TO A QUESTION NOBODY IS WAITING ON IS DROPPED. A slow first answer that lands after
|
| 155 |
+
* the person cancelled and asked something else would otherwise be appended under the new question
|
| 156 |
+
* and read as its answer. Checking the text is enough here because `asked()` refuses to start a
|
| 157 |
+
* second question while one is outstanding, so at most one can ever be in flight.
|
| 158 |
+
*/
|
| 159 |
+
export function answered(
|
| 160 |
+
state: GridChatState,
|
| 161 |
+
forQuestion: string,
|
| 162 |
+
answer: GridChatAnswer,
|
| 163 |
+
): GridChatState {
|
| 164 |
+
if (state.pending === null || state.pending !== forQuestion) return state;
|
| 165 |
+
const said = String(answer?.text || "").trim();
|
| 166 |
+
return {
|
| 167 |
+
turns: [...state.turns, {
|
| 168 |
+
who: "assistant",
|
| 169 |
+
// β An empty answer is a defect in the host, not a blank bubble for the reader to interpret.
|
| 170 |
+
text: said || "That came back empty, so there is nothing to show. Try asking again.",
|
| 171 |
+
error: !said,
|
| 172 |
+
}],
|
| 173 |
+
pending: null,
|
| 174 |
+
};
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
/**
|
| 178 |
+
* A host callback failed. `sentence` is what the person reads.
|
| 179 |
+
*
|
| 180 |
+
* β THE SENTENCE IS THE PRODUCT. `HTTP 402` on a screen tells a reader nothing they can do, and
|
| 181 |
+
* the owner has quoted a raw status back at us twice. A caller with nothing better than an
|
| 182 |
+
* exception name passes nothing and gets the fallback below, which at least says what happened and
|
| 183 |
+
* what to do next.
|
| 184 |
+
*/
|
| 185 |
+
export function failed(
|
| 186 |
+
state: GridChatState,
|
| 187 |
+
forQuestion: string,
|
| 188 |
+
sentence?: string,
|
| 189 |
+
): GridChatState {
|
| 190 |
+
if (state.pending === null || state.pending !== forQuestion) return state;
|
| 191 |
+
const said = String(sentence || "").trim();
|
| 192 |
+
return {
|
| 193 |
+
turns: [...state.turns, {
|
| 194 |
+
who: "assistant",
|
| 195 |
+
text: said || "That did not go through. Nothing was changed, so it is safe to try again.",
|
| 196 |
+
error: true,
|
| 197 |
+
}],
|
| 198 |
+
pending: null,
|
| 199 |
+
};
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
/** The panel telling the person what it just did. Never in flight, never an error. */
|
| 203 |
+
export function noted(state: GridChatState, text: string): GridChatState {
|
| 204 |
+
const said = String(text || "").trim();
|
| 205 |
+
if (!said) return state;
|
| 206 |
+
return { ...state, turns: [...state.turns, { who: "note", text: said }] };
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
/**
|
| 210 |
+
* The assistant is proposing cell writes and wants a confirm (R10).
|
| 211 |
+
*
|
| 212 |
+
* β THE PROPOSAL IS A TURN, not a modal. A modal would cover the transcript the person needs in
|
| 213 |
+
* order to judge the proposal, and it would vanish on a mis-click taking the reasoning with it.
|
| 214 |
+
* β NOTHING IS WRITTEN BY THIS FUNCTION. It appends a turn carrying the before/after list; the
|
| 215 |
+
* host's `onWriteCells` is reached only from the confirm control, which is W37-T45's half.
|
| 216 |
+
*/
|
| 217 |
+
export function proposedEdits(
|
| 218 |
+
state: GridChatState,
|
| 219 |
+
forQuestion: string,
|
| 220 |
+
text: string,
|
| 221 |
+
edits: GridChatEdit[],
|
| 222 |
+
): GridChatState {
|
| 223 |
+
if (state.pending === null || state.pending !== forQuestion) return state;
|
| 224 |
+
const rows = (edits || []).filter((e) => e && e.rowId && e.field);
|
| 225 |
+
if (!rows.length) return answered(state, forQuestion, { text });
|
| 226 |
+
return {
|
| 227 |
+
turns: [...state.turns, {
|
| 228 |
+
who: "assistant",
|
| 229 |
+
text: String(text || "").trim() || "Here is exactly what this would write.",
|
| 230 |
+
edits: rows,
|
| 231 |
+
}],
|
| 232 |
+
pending: null,
|
| 233 |
+
};
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
/**
|
| 237 |
+
* The inverse of a set of edits: what would put every cell back.
|
| 238 |
+
*
|
| 239 |
+
* β CALL THIS BEFORE THE WRITE, NOT AFTER. It reads `before`, which only exists on the proposal
|
| 240 |
+
* the person confirmed; after the write the store holds `after` and there is nothing left to
|
| 241 |
+
* invert from [[capture-the-inverse-before-the-write]].
|
| 242 |
+
* β `rowLabel` and `fieldLabel` are carried through unchanged, so an undo confirm reads in the
|
| 243 |
+
* same words the original proposal did rather than in keys.
|
| 244 |
+
*/
|
| 245 |
+
export function invertEdits(edits: GridChatEdit[]): GridChatEdit[] {
|
| 246 |
+
return (edits || [])
|
| 247 |
+
.filter((e) => e && e.rowId && e.field)
|
| 248 |
+
.map((e) => ({ ...e, before: e.after, after: e.before }));
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
/**
|
| 252 |
+
* A confirmed write landed. The note it appends CARRIES ITS OWN INVERSE, so undo is offered from
|
| 253 |
+
* the transcript rather than from a toast that has already gone.
|
| 254 |
+
*/
|
| 255 |
+
export function wrote(state: GridChatState, edits: GridChatEdit[]): GridChatState {
|
| 256 |
+
const rows = (edits || []).filter((e) => e && e.rowId && e.field);
|
| 257 |
+
if (!rows.length) return state;
|
| 258 |
+
return {
|
| 259 |
+
...state,
|
| 260 |
+
turns: [...state.turns, {
|
| 261 |
+
who: "note",
|
| 262 |
+
text: `Written. ${editSummary(rows)}`,
|
| 263 |
+
undo: invertEdits(rows),
|
| 264 |
+
}],
|
| 265 |
+
};
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
/**
|
| 269 |
+
* The one-line summary a confirm control shows beside the before/after list.
|
| 270 |
+
*
|
| 271 |
+
* β It counts CELLS and RECORDS separately because they are different risks: twenty cells on one
|
| 272 |
+
* record is a correction, one cell on twenty records is a sweep. A single "20 changes" hides which
|
| 273 |
+
* of those the person is about to approve.
|
| 274 |
+
*/
|
| 275 |
+
export function editSummary(edits: GridChatEdit[]): string {
|
| 276 |
+
const rows = (edits || []).filter((e) => e && e.rowId && e.field);
|
| 277 |
+
if (!rows.length) return "Nothing to write.";
|
| 278 |
+
const records = new Set(rows.map((e) => e.rowId)).size;
|
| 279 |
+
const cellWord = rows.length === 1 ? "cell" : "cells";
|
| 280 |
+
const recordWord = records === 1 ? "record" : "records";
|
| 281 |
+
return `${rows.length} ${cellWord} on ${records} ${recordWord}.`;
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
/**
|
| 285 |
+
* What the composer invites the person to type.
|
| 286 |
+
*
|
| 287 |
+
* β IT NAMES THE DATABASE. A chat docked beside a grid is ambiguous about what it is reading the
|
| 288 |
+
* moment a person has two tabs open, and the fix is one word in the placeholder rather than a
|
| 289 |
+
* second header line.
|
| 290 |
+
*/
|
| 291 |
+
export function composerHint(databaseLabel: string, canEdit: boolean): string {
|
| 292 |
+
const where = String(databaseLabel || "").trim() || "this database";
|
| 293 |
+
return canEdit
|
| 294 |
+
? `Ask about ${where}, or say what to change`
|
| 295 |
+
: `Ask about ${where}`;
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 299 |
+
// ββ THE INTENT LAYER (W37-T42 Β· T43 Β· T44) β WHAT THE CHAT KNOWS WITHOUT SPENDING ANYTHING
|
| 300 |
+
//
|
| 301 |
+
// ββ ITS JOB IS TO REFUSE HONESTLY BEFORE A MODEL CALL, NOT TO BE CLEVER. D-339's lesson is that
|
| 302 |
+
// an agent which cannot read the database answers from WORDS ALONE and invents a number; the fix
|
| 303 |
+
// is not a better guess, it is knowing what this database actually has and saying so when the
|
| 304 |
+
// question names something it does not. The panel holds the schema, so that answer costs nothing
|
| 305 |
+
// and cannot be wrong in the direction that matters.
|
| 306 |
+
// β EVERYTHING HERE IS PURE AND LOCAL. Anything that needs a model, a query or a write goes out
|
| 307 |
+
// through a host callback (C6). This layer only decides WHICH callback, and catches the questions
|
| 308 |
+
// that should never reach one.
|
| 309 |
+
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 310 |
+
|
| 311 |
+
/** What the panel decided a sentence was asking for. */
|
| 312 |
+
export type GridChatIntentKind = "schema" | "view" | "script" | "ask";
|
| 313 |
+
|
| 314 |
+
export interface GridChatIntent {
|
| 315 |
+
kind: GridChatIntentKind;
|
| 316 |
+
/** A complete answer the panel can give with NO model call. Present only when it truly can. */
|
| 317 |
+
localAnswer?: string;
|
| 318 |
+
/** Set when the sentence names something this database does not have. */
|
| 319 |
+
refusal?: string;
|
| 320 |
+
/** For `view`: what the panel understood, in the vocabulary the host's callback takes. */
|
| 321 |
+
view?: { sortBy?: string; groupBy?: string; colorBy?: string };
|
| 322 |
+
/** For `view`: what was asked for and CANNOT be expressed, named rather than dropped. */
|
| 323 |
+
cannot?: string;
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
const norm = (s: string) => String(s || "").toLowerCase().trim();
|
| 327 |
+
|
| 328 |
+
/**
|
| 329 |
+
* Which known fields a sentence mentions, by label or by key.
|
| 330 |
+
*
|
| 331 |
+
* β LABEL FIRST AND LONGEST FIRST. A person types the label they can see ("Sales rep"), not the
|
| 332 |
+
* key (`agent_id`), and matching short keys first would let a field called `n` match the word "in".
|
| 333 |
+
*/
|
| 334 |
+
export function fieldsNamed(schema: GridChatField[], said: string): GridChatField[] {
|
| 335 |
+
const text = norm(said);
|
| 336 |
+
if (!text) return [];
|
| 337 |
+
return (schema || [])
|
| 338 |
+
.filter((f) => f && (f.label || f.key))
|
| 339 |
+
.slice()
|
| 340 |
+
.sort((a, b) => (b.label || b.key).length - (a.label || a.key).length)
|
| 341 |
+
.filter((f) => {
|
| 342 |
+
const label = norm(f.label);
|
| 343 |
+
const key = norm(f.key);
|
| 344 |
+
return (label.length > 2 && text.includes(label))
|
| 345 |
+
|| (key.length > 2 && text.includes(key));
|
| 346 |
+
});
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
/**
|
| 350 |
+
* A quoted name that this database does not have, if the sentence contains one.
|
| 351 |
+
*
|
| 352 |
+
* β ONLY QUOTED NAMES, DELIBERATELY. Guessing that any capitalised phrase is a column would refuse
|
| 353 |
+
* perfectly good questions ("how many are in New York?"), and a refusal that fires on a real
|
| 354 |
+
* question is worse than a model call that answers it. Quotes are the one place a person has said
|
| 355 |
+
* "this is a name" out loud, so the panel can be certain enough to answer instead of asking.
|
| 356 |
+
* β This is the D-339 half the panel can do alone. Everything unquoted still goes to the host,
|
| 357 |
+
* which knows the data and can say `unanswerable` with better information than a regex has.
|
| 358 |
+
*/
|
| 359 |
+
export function unknownFieldNamed(schema: GridChatField[], said: string): string | null {
|
| 360 |
+
const quoted = String(said || "").match(/"[^"]{1,60}"|'[^']{1,60}'/g) || [];
|
| 361 |
+
const known = new Set((schema || []).flatMap((f) => [norm(f.label), norm(f.key)]));
|
| 362 |
+
for (const raw of quoted) {
|
| 363 |
+
const typed = raw.slice(1, -1).trim();
|
| 364 |
+
// β COMPARE LOWERCASED, RETURN AS TYPED, AND THE FIRST DRAFT DID NOT. It returned the
|
| 365 |
+
// normalised form, so somebody who wrote "Supplier Rep" was told there is no field called
|
| 366 |
+
// "supplier rep" β their own words handed back mangled, which reads as the system having
|
| 367 |
+
// misheard them rather than as an honest answer. Matching is case-insensitive because a person
|
| 368 |
+
// should not have to match a label's capitalisation; the MESSAGE quotes them exactly.
|
| 369 |
+
if (typed && !known.has(norm(typed))) return typed;
|
| 370 |
+
}
|
| 371 |
+
return null;
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
/** The plain-language field list, for a question the panel can answer with no model call at all. */
|
| 375 |
+
export function schemaSentence(schema: GridChatField[], databaseLabel: string): string {
|
| 376 |
+
const where = String(databaseLabel || "").trim() || "this database";
|
| 377 |
+
const names = (schema || []).map((f) => f.label || f.key).filter(Boolean);
|
| 378 |
+
if (!names.length) return where + " has no fields the chat can see.";
|
| 379 |
+
const word = names.length === 1 ? "field" : "fields";
|
| 380 |
+
return where + " has " + names.length + " " + word + ": " + names.join(", ") + ".";
|
| 381 |
+
}
|
| 382 |
+
|
| 383 |
+
/** Sentences that are asking what is here, rather than asking about the data. */
|
| 384 |
+
const SCHEMA_WORDS = ["what fields", "which fields", "what columns", "which columns",
|
| 385 |
+
"list the fields", "list the columns", "what can you see"];
|
| 386 |
+
/** Sentences asking for the VIEW to change rather than for an answer. */
|
| 387 |
+
const VIEW_WORDS = ["sort by", "sorted by", "group by", "grouped by", "colour by", "color by",
|
| 388 |
+
"show me only", "filter"];
|
| 389 |
+
/** Sentences asking for code. */
|
| 390 |
+
const SCRIPT_WORDS = ["build me", "make me", "write a script", "custom view", "dashboard",
|
| 391 |
+
"calculator", "chart"];
|
| 392 |
+
|
| 393 |
+
/**
|
| 394 |
+
* Read a sentence. β THE ORDER IS THE POLICY: refuse first, answer locally second, route third.
|
| 395 |
+
*
|
| 396 |
+
* β A DELIBERATELY SHALLOW READER. It is not trying to understand the question, because the host's
|
| 397 |
+
* model does that. It is trying to catch the two cases where going to the model is WRONG: a name
|
| 398 |
+
* this database does not have (which would come back as an invented number), and a question the
|
| 399 |
+
* panel already knows the answer to (which would be a paid round trip for a list it is holding).
|
| 400 |
+
*/
|
| 401 |
+
export function readIntent(
|
| 402 |
+
schema: GridChatField[],
|
| 403 |
+
databaseLabel: string,
|
| 404 |
+
said: string,
|
| 405 |
+
): GridChatIntent {
|
| 406 |
+
const text = norm(said);
|
| 407 |
+
const where = String(databaseLabel || "").trim() || "this database";
|
| 408 |
+
const missing = unknownFieldNamed(schema, said);
|
| 409 |
+
if (missing) {
|
| 410 |
+
const names = (schema || []).map((f) => f.label || f.key).filter(Boolean);
|
| 411 |
+
return {
|
| 412 |
+
kind: "ask",
|
| 413 |
+
refusal: 'There is no field called "' + missing + '" in ' + where + ". "
|
| 414 |
+
+ (names.length
|
| 415 |
+
? "The fields here are: " + names.join(", ") + "."
|
| 416 |
+
: "This database has no fields the chat can see."),
|
| 417 |
+
};
|
| 418 |
+
}
|
| 419 |
+
if (SCHEMA_WORDS.some((w) => text.includes(w)))
|
| 420 |
+
return { kind: "schema", localAnswer: schemaSentence(schema, databaseLabel) };
|
| 421 |
+
|
| 422 |
+
if (VIEW_WORDS.some((w) => text.includes(w))) {
|
| 423 |
+
const named = fieldsNamed(schema, said);
|
| 424 |
+
const view: { sortBy?: string; groupBy?: string; colorBy?: string } = {};
|
| 425 |
+
if (named.length) {
|
| 426 |
+
if (text.includes("group")) view.groupBy = named[0].key;
|
| 427 |
+
else if (text.includes("colour") || text.includes("color")) view.colorBy = named[0].key;
|
| 428 |
+
else if (text.includes("sort")) view.sortBy = named[0].key;
|
| 429 |
+
}
|
| 430 |
+
// β WHAT IT CANNOT SAY, SAID OUT LOUD. `assistant build_view` has no `rhs` in its tool schema,
|
| 431 |
+
// so a condition comparing one field to another is not expressible anywhere in this product
|
| 432 |
+
// [[assistant-build-view-is-a-subset]]. Dropping it silently would leave a person looking at a
|
| 433 |
+
// view that quietly ignored half their sentence, which is the failure this note exists to stop.
|
| 434 |
+
const fieldVsField = named.length >= 2 && /\b(than|versus|vs\.?|compared to)\b/.test(text);
|
| 435 |
+
return {
|
| 436 |
+
kind: "view",
|
| 437 |
+
view: Object.keys(view).length ? view : undefined,
|
| 438 |
+
cannot: fieldVsField
|
| 439 |
+
? "This build cannot compare one field against another in a view filter, so that part was "
|
| 440 |
+
+ "left out. Everything else was applied."
|
| 441 |
+
: undefined,
|
| 442 |
+
};
|
| 443 |
+
}
|
| 444 |
+
if (SCRIPT_WORDS.some((w) => text.includes(w))) return { kind: "script" };
|
| 445 |
+
return { kind: "ask" };
|
| 446 |
+
}
|
| 447 |
+
|
| 448 |
+
/**
|
| 449 |
+
* The sentence shown when a script the chat wrote is run.
|
| 450 |
+
*
|
| 451 |
+
* β THE VIEW IS NOT BLANKED ON FAILURE (W37-T44's own `done-when`). A script that raised is a fact
|
| 452 |
+
* about the code, and the code is still there to be edited; replacing the panel with nothing would
|
| 453 |
+
* take away the one thing the person needs in order to fix it.
|
| 454 |
+
*/
|
| 455 |
+
export function runSentence(result: GridChatRunResult): string {
|
| 456 |
+
if (result && result.ok) return "That ran. The view is showing what it drew.";
|
| 457 |
+
const why = String((result && result.error) || "").trim();
|
| 458 |
+
return why
|
| 459 |
+
? "That script did not run: " + why + " The code is still in the editor, so you can change it."
|
| 460 |
+
: "That script did not produce a view. The code is still in the editor, so you can change it.";
|
| 461 |
+
}
|
web/src/customer-grid/map.css
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* customer-grid/map.css β W37-T35/T36, the real-tile basemap and its credit line.
|
| 2 |
+
*
|
| 3 |
+
* β WHY THIS FILE EXISTS AND NOT A BLOCK IN index.css: contract C8. `index.css` is 12k+ lines and
|
| 4 |
+
* belongs to lane A this wave, so every other lane adds styles in its own per-surface stylesheet
|
| 5 |
+
* imported by its own component. `scriptView.css` and `navExtras.css` are the precedent.
|
| 6 |
+
*
|
| 7 |
+
* β NOTHING HERE MAY ADD `vector-effect: non-scaling-stroke` TO A `.cg-map*` RULE. The map divides
|
| 8 |
+
* every stroke by the zoom in the markup (`hairline`), so a CSS rule that also cancels the scale
|
| 9 |
+
* double-compensates, and that is the wave-9 "blurry when zoomed in" bug. `map.test.ts` scans
|
| 10 |
+
* `index.css` for it and cannot see this file, so `verify_map.py::css_scan` applies the same rule
|
| 11 |
+
* here. A new stylesheet that escapes an existing gate is a gate quietly getting smaller.
|
| 12 |
+
*/
|
| 13 |
+
|
| 14 |
+
/* --- the tile layer -------------------------------------------------------
|
| 15 |
+
*
|
| 16 |
+
* Tiles are <image> elements INSIDE the same transformed <g> the pins live in, so they pan and
|
| 17 |
+
* zoom with the geography for free and no second camera can drift out of step with the first.
|
| 18 |
+
*/
|
| 19 |
+
|
| 20 |
+
.cg-map-tiles {
|
| 21 |
+
/* A raster basemap is a photograph of a map, so it must never intercept a gesture: every
|
| 22 |
+
pointer event belongs to the pins and the marquee above it. */
|
| 23 |
+
pointer-events: none;
|
| 24 |
+
/* β THE DARK-MODE SEAM, and it is deliberately INERT today. This app ships no dark theme (there
|
| 25 |
+
is not one `prefers-color-scheme` rule in `index.css`), so an inverting media query here would
|
| 26 |
+
turn the basemap negative for anyone whose OS is dark while the rest of the product stayed
|
| 27 |
+
light, which is a defect rather than a feature. Setting `--cg-tile-filter` is one line on the
|
| 28 |
+
day a theme arrives. */
|
| 29 |
+
filter: var(--cg-tile-filter, none);
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
.cg-map-tile {
|
| 33 |
+
/* Tile edges are cut to the pixel, so any smoothing at the seam paints a visible grid over the
|
| 34 |
+
whole basemap. */
|
| 35 |
+
image-rendering: -webkit-optimize-contrast;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
/* The tile the browser has not fetched yet. Without this a pan shows the page background through
|
| 39 |
+
the gaps, which reads as holes in the map rather than as loading. */
|
| 40 |
+
.cg-map-tilebed {
|
| 41 |
+
fill: #eef2f7;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
/* --- attribution ----------------------------------------------------------
|
| 45 |
+
*
|
| 46 |
+
* β THIS IS A LICENCE TERM, NOT DECORATION. OpenStreetMap's terms require visible credit wherever
|
| 47 |
+
* its tiles are shown, and the same applies to whatever provider replaces it: the text and the
|
| 48 |
+
* link both come from the server's provider config, so a swap carries its own credit with it and
|
| 49 |
+
* cannot leave the previous one on screen.
|
| 50 |
+
*/
|
| 51 |
+
|
| 52 |
+
.cg-map-credit {
|
| 53 |
+
position: absolute;
|
| 54 |
+
right: 6px;
|
| 55 |
+
bottom: 4px;
|
| 56 |
+
z-index: 2;
|
| 57 |
+
padding: 1px 6px;
|
| 58 |
+
border-radius: 4px;
|
| 59 |
+
background: rgba(255, 255, 255, 0.82);
|
| 60 |
+
/* β A TOKEN, NOT A LITERAL (R5, and A-53 caught the 10.5px this replaced). A hardcoded size
|
| 61 |
+
becomes THE size the day the type scale moves, so a stylesheet that invents one is a second
|
| 62 |
+
source of truth. `--lp-fs-4xs` is the 10px step, which is what a credit line wants. */
|
| 63 |
+
font: 400 var(--lp-fs-4xs)/1.5 Inter, system-ui, sans-serif;
|
| 64 |
+
color: #5b6270;
|
| 65 |
+
letter-spacing: 0.1px;
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
.cg-map-credit a {
|
| 69 |
+
color: #4a5568;
|
| 70 |
+
text-decoration: none;
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
.cg-map-credit a:hover {
|
| 74 |
+
text-decoration: underline;
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
/* --- the route line (T36) -------------------------------------------------
|
| 78 |
+
*
|
| 79 |
+
* The ROAD geometry, as distinct from `.cg-map-route-line`, which is the straight-line plan drawn
|
| 80 |
+
* from arithmetic alone. Two different claims about the same stops, so two different looks: the
|
| 81 |
+
* road route is solid and confident, the straight-line plan stays dashed and provisional.
|
| 82 |
+
*/
|
| 83 |
+
|
| 84 |
+
.cg-map-road {
|
| 85 |
+
fill: none;
|
| 86 |
+
stroke: #1f4e78;
|
| 87 |
+
stroke-opacity: 0.85;
|
| 88 |
+
stroke-linecap: round;
|
| 89 |
+
stroke-linejoin: round;
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
.cg-map-road-halo {
|
| 93 |
+
fill: none;
|
| 94 |
+
stroke: #ffffff;
|
| 95 |
+
stroke-opacity: 0.75;
|
| 96 |
+
stroke-linecap: round;
|
| 97 |
+
stroke-linejoin: round;
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
/* --- the ordered stop list (T36) ------------------------------------------
|
| 101 |
+
*
|
| 102 |
+
* The route's stops as a row of numbered chips with move controls. Inline in the map bar rather
|
| 103 |
+
* than in a panel: reordering is a thing people do WHILE looking at the map, and a dialog over the
|
| 104 |
+
* picture would hide the answer they are reordering against.
|
| 105 |
+
*/
|
| 106 |
+
|
| 107 |
+
.cg-map-stoplist {
|
| 108 |
+
display: inline-flex;
|
| 109 |
+
align-items: center;
|
| 110 |
+
gap: 4px;
|
| 111 |
+
flex-wrap: wrap;
|
| 112 |
+
max-width: 460px;
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
.cg-map-stopitem {
|
| 116 |
+
display: inline-flex;
|
| 117 |
+
align-items: center;
|
| 118 |
+
gap: 2px;
|
| 119 |
+
padding: 1px 2px 1px 4px;
|
| 120 |
+
border: 1px solid #dfe3ea;
|
| 121 |
+
border-radius: 11px;
|
| 122 |
+
background: #fff;
|
| 123 |
+
font: 500 var(--lp-fs-3xs)/1.6 Inter, system-ui, sans-serif;
|
| 124 |
+
color: #2c3340;
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
.cg-map-stopn {
|
| 128 |
+
display: inline-grid;
|
| 129 |
+
place-items: center;
|
| 130 |
+
width: 15px;
|
| 131 |
+
height: 15px;
|
| 132 |
+
border-radius: 50%;
|
| 133 |
+
background: #1f4e78;
|
| 134 |
+
color: #fff;
|
| 135 |
+
font-size: var(--lp-fs-5xs);
|
| 136 |
+
font-weight: 600;
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
.cg-map-stopname {
|
| 140 |
+
max-width: 108px;
|
| 141 |
+
overflow: hidden;
|
| 142 |
+
text-overflow: ellipsis;
|
| 143 |
+
white-space: nowrap;
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
.cg-map-stopmove {
|
| 147 |
+
display: inline-grid;
|
| 148 |
+
place-items: center;
|
| 149 |
+
width: 15px;
|
| 150 |
+
height: 15px;
|
| 151 |
+
padding: 0;
|
| 152 |
+
border: 0;
|
| 153 |
+
border-radius: 3px;
|
| 154 |
+
background: none;
|
| 155 |
+
color: #6b7280;
|
| 156 |
+
cursor: pointer;
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
.cg-map-stopmove:hover:not(:disabled) {
|
| 160 |
+
background: #eef2f7;
|
| 161 |
+
color: #1f4e78;
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
.cg-map-stopmove:disabled {
|
| 165 |
+
/* Visible but inert at the ends of the list. Removing the control instead would make the row
|
| 166 |
+
change width as a stop moves, so the button you are aiming at walks away from the cursor. */
|
| 167 |
+
opacity: 0.28;
|
| 168 |
+
cursor: default;
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
.cg-map-stopmove svg {
|
| 172 |
+
width: 11px;
|
| 173 |
+
height: 11px;
|
| 174 |
+
fill: none;
|
| 175 |
+
stroke: currentColor;
|
| 176 |
+
stroke-width: 1.7;
|
| 177 |
+
stroke-linecap: round;
|
| 178 |
+
stroke-linejoin: round;
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
/* --- the address lookup (T37) ---------------------------------------------
|
| 182 |
+
*
|
| 183 |
+
* β THE PROGRESS LINE IS A REQUIREMENT, NOT A COURTESY. The free geocoder allows about one
|
| 184 |
+
* request a second, so a run over a real selection takes minutes. A spinner would make that read
|
| 185 |
+
* as a hang; a count that moves reads as work. The note beside it says WHY it is slow, because
|
| 186 |
+
* "this product is slow" and "this service asks us to be polite" are different facts.
|
| 187 |
+
*/
|
| 188 |
+
|
| 189 |
+
.cg-map-geo {
|
| 190 |
+
display: inline-flex;
|
| 191 |
+
align-items: center;
|
| 192 |
+
gap: 8px;
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
.cg-map-geo-run {
|
| 196 |
+
font: 500 var(--lp-fs-2xs)/1.5 Inter, system-ui, sans-serif;
|
| 197 |
+
color: #1f4e78;
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
.cg-map-geo-note {
|
| 201 |
+
font: 400 var(--lp-fs-3xs)/1.5 Inter, system-ui, sans-serif;
|
| 202 |
+
color: #6b7280;
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
/* The lookup offered from the EMPTY state. A map with nothing on it is exactly where this feature
|
| 206 |
+
is needed, and the control used to be unreachable there (see MapView's `geoControl`). */
|
| 207 |
+
.cg-map-geo-empty {
|
| 208 |
+
margin-top: 10px;
|
| 209 |
+
}
|
web/src/customer-grid/mapProjection.ts
CHANGED
|
@@ -1,632 +1,677 @@
|
|
| 1 |
-
// ---------------------------------------------------------------------------
|
| 2 |
-
// customer-grid / mapProjection.ts
|
| 3 |
-
// Wave-8 I2/I6 β the map's geometry, split from MapView so it can be tested
|
| 4 |
-
// under node without React.
|
| 5 |
-
//
|
| 6 |
-
// WHY THIS EXISTS AS ITS OWN LAYER. The wave-7 map derived its projection from
|
| 7 |
-
// the DATA's bounding box (MapView.tsx:96-118, an equirectangular fit). That is
|
| 8 |
-
// fine for a static scatter and wrong for everything wave 8 asks of it:
|
| 9 |
-
//
|
| 10 |
-
// - the map re-projected on every filter change, so the whole picture jumped
|
| 11 |
-
// whenever a condition was edited;
|
| 12 |
-
// - box-select needs a stable screen<->point mapping DURING a drag, which a
|
| 13 |
-
// projection memoised over `points` is not;
|
| 14 |
-
// - a data-fit projection distorts real geography β state outlines drawn
|
| 15 |
-
// through it visibly skew once you zoom into one metro.
|
| 16 |
-
//
|
| 17 |
-
// So the two concerns are separated:
|
| 18 |
-
// PROJECTION fixed Web Mercator, world -> a fixed square. Never changes.
|
| 19 |
-
// VIEW {k, tx, ty} β zoom and pan, an affine transform ON TOP.
|
| 20 |
-
// The old data-fit becomes the INITIAL VIEW value rather than the projection,
|
| 21 |
-
// which is what makes zoom/pan and hit-testing tractable at all.
|
| 22 |
-
// ---------------------------------------------------------------------------
|
| 23 |
-
|
| 24 |
-
/** Base resolution of the projected world square, in SVG user units. */
|
| 25 |
-
export const WORLD = 4096;
|
| 26 |
-
|
| 27 |
-
/** Mercator blows up at the poles; every web map clamps. */
|
| 28 |
-
const MAX_LAT = 85.05112878;
|
| 29 |
-
|
| 30 |
-
export interface Pt {
|
| 31 |
-
x: number;
|
| 32 |
-
y: number;
|
| 33 |
-
}
|
| 34 |
-
|
| 35 |
-
/** A pan/zoom transform: screen = view.t + view.k * projected. */
|
| 36 |
-
export interface View {
|
| 37 |
-
k: number;
|
| 38 |
-
tx: number;
|
| 39 |
-
ty: number;
|
| 40 |
-
}
|
| 41 |
-
|
| 42 |
-
/** lon/lat -> the fixed projected plane (0..WORLD on both axes). */
|
| 43 |
-
export function project(lon: number, lat: number): Pt {
|
| 44 |
-
const clamped = Math.max(-MAX_LAT, Math.min(MAX_LAT, lat));
|
| 45 |
-
const rad = (clamped * Math.PI) / 180;
|
| 46 |
-
const x = (lon + 180) / 360;
|
| 47 |
-
const y = 0.5 - Math.log(Math.tan(Math.PI / 4 + rad / 2)) / (2 * Math.PI);
|
| 48 |
-
return { x: x * WORLD, y: y * WORLD };
|
| 49 |
-
}
|
| 50 |
-
|
| 51 |
-
/** The fixed projected plane -> lon/lat. The exact inverse of `project`. */
|
| 52 |
-
export function unproject(p: Pt): { lon: number; lat: number } {
|
| 53 |
-
const t = 0.5 - p.y / WORLD;
|
| 54 |
-
const rad = 2 * Math.atan(Math.exp(2 * Math.PI * t)) - Math.PI / 2;
|
| 55 |
-
return { lon: (p.x / WORLD) * 360 - 180, lat: (rad * 180) / Math.PI };
|
| 56 |
-
}
|
| 57 |
-
|
| 58 |
-
/** Screen -> the fixed projected plane, through a view. The inverse of `toScreen`. */
|
| 59 |
-
export function fromScreen(s: Pt, view: View): Pt {
|
| 60 |
-
return { x: (s.x - view.tx) / view.k, y: (s.y - view.ty) / view.k };
|
| 61 |
-
}
|
| 62 |
-
|
| 63 |
-
// -------------------------------------------------------- the Google hand-off
|
| 64 |
-
//
|
| 65 |
-
// The honest answer to "I need to see the actual street". A licence-free offline
|
| 66 |
-
// vector basemap stops at roughly metro scale (see BASEMAP_DETAIL_K), and the
|
| 67 |
-
// alternative β bundling a tile renderer β costs 270 KB gz, an API key or a
|
| 68 |
-
// hosted planet file, and sends every customer's coordinates to a third party on
|
| 69 |
-
// every pan. A LINK costs none of that: nothing ships, nothing is fetched, and
|
| 70 |
-
// the coordinates travel only if the user deliberately clicks.
|
| 71 |
-
//
|
| 72 |
-
// β Every one of these must be rendered with rel="noopener noreferrer" β that
|
| 73 |
-
// strips the Referer, so the destination never learns which tenant or which
|
| 74 |
-
// deployment the click came from, and it denies the opened tab window.opener.
|
| 75 |
-
|
| 76 |
-
/**
|
| 77 |
-
* Google Maps' zoom level for our zoom `k`.
|
| 78 |
-
*
|
| 79 |
-
* Google measures a world 256 * 2^z px wide; ours is WORLD units wide, painted
|
| 80 |
-
* at `k` and then ~1.08 CSS px per unit. Equating the two:
|
| 81 |
-
* 256 * 2^z = WORLD * k * 1.08 -> z = log2(WORLD * 1.08 * k / 256)
|
| 82 |
-
* Clamped to Google's own 0..21. A fitted US book (k~1.36) hands over z5, the
|
| 83 |
-
* country; the detail cap (k=32) hands over z9, a metro β i.e. the hand-off
|
| 84 |
-
* starts exactly where our basemap runs out, which is the point of it.
|
| 85 |
-
*/
|
| 86 |
-
export function googleZoomForK(k: number): number {
|
| 87 |
-
if (!Number.isFinite(k) || k <= 0) return 4;
|
| 88 |
-
const z = Math.log2((WORLD * 1.08 * k) / 256);
|
| 89 |
-
return Math.max(0, Math.min(21, Math.round(z)));
|
| 90 |
-
}
|
| 91 |
-
|
| 92 |
-
/** True only for a coordinate Google can actually be sent. */
|
| 93 |
-
export function isPlottable(lat: number | null, lon: number | null): boolean {
|
| 94 |
-
return (
|
| 95 |
-
lat != null && lon != null &&
|
| 96 |
-
Number.isFinite(lat) && Number.isFinite(lon) &&
|
| 97 |
-
Math.abs(lat) <= 90 && Math.abs(lon) <= 180
|
| 98 |
-
);
|
| 99 |
-
}
|
| 100 |
-
|
| 101 |
-
/**
|
| 102 |
-
* A dropped pin at exactly the coordinate WE plotted.
|
| 103 |
-
*
|
| 104 |
-
* β Deliberately by lat/lon and never by customer name or address: a name search
|
| 105 |
-
* can resolve somewhere else entirely, and then the app's map and the link
|
| 106 |
-
* disagree about where a customer is. The pin is the geocode; the link is the
|
| 107 |
-
* same geocode. `api=1` is Google's documented, stable URL contract.
|
| 108 |
-
*/
|
| 109 |
-
export function googleMapsUrl(lat: number, lon: number, zoom?: number): string {
|
| 110 |
-
const at = `${lat.toFixed(6)},${lon.toFixed(6)}`;
|
| 111 |
-
return zoom == null
|
| 112 |
-
? `https://www.google.com/maps/search/?api=1&query=${at}`
|
| 113 |
-
: `https://www.google.com/maps/@${at},${Math.round(zoom)}z`;
|
| 114 |
-
}
|
| 115 |
-
|
| 116 |
-
/** Directions to a customer β the version a rep on the road actually wants. */
|
| 117 |
-
export function googleDirectionsUrl(lat: number, lon: number): string {
|
| 118 |
-
return `https://www.google.com/maps/dir/?api=1&destination=${lat.toFixed(6)},${lon.toFixed(6)}`;
|
| 119 |
-
}
|
| 120 |
-
|
| 121 |
-
/** Projected point -> screen, through a view. */
|
| 122 |
-
export function toScreen(p: Pt, view: View): Pt {
|
| 123 |
-
return { x: view.tx + p.x * view.k, y: view.ty + p.y * view.k };
|
| 124 |
-
}
|
| 125 |
-
|
| 126 |
-
/**
|
| 127 |
-
* The view that frames `pts` inside a w x h viewport with `pad` px of margin.
|
| 128 |
-
* `minSpan` stops a single point (or one city) from zooming to street level:
|
| 129 |
-
* one customer should still look like a PLACE, not a full-bleed dot β the
|
| 130 |
-
* wave-7 behaviour, kept.
|
| 131 |
-
*/
|
| 132 |
-
export function fitView(
|
| 133 |
-
pts: Pt[],
|
| 134 |
-
w: number,
|
| 135 |
-
h: number,
|
| 136 |
-
pad = 40,
|
| 137 |
-
minSpan = WORLD / 90
|
| 138 |
-
): View | null {
|
| 139 |
-
if (pts.length === 0) return null;
|
| 140 |
-
let minX = Infinity;
|
| 141 |
-
let maxX = -Infinity;
|
| 142 |
-
let minY = Infinity;
|
| 143 |
-
let maxY = -Infinity;
|
| 144 |
-
for (const p of pts) {
|
| 145 |
-
minX = Math.min(minX, p.x);
|
| 146 |
-
maxX = Math.max(maxX, p.x);
|
| 147 |
-
minY = Math.min(minY, p.y);
|
| 148 |
-
maxY = Math.max(maxY, p.y);
|
| 149 |
-
}
|
| 150 |
-
let spanX = Math.max(maxX - minX, minSpan);
|
| 151 |
-
let spanY = Math.max(maxY - minY, minSpan);
|
| 152 |
-
const cx = (minX + maxX) / 2;
|
| 153 |
-
const cy = (minY + maxY) / 2;
|
| 154 |
-
spanX *= 1.16; // breathing room so edge pins are not on the frame
|
| 155 |
-
spanY *= 1.16;
|
| 156 |
-
const k = Math.min((w - pad * 2) / spanX, (h - pad * 2) / spanY);
|
| 157 |
-
return { k, tx: w / 2 - cx * k, ty: h / 2 - cy * k };
|
| 158 |
-
}
|
| 159 |
-
|
| 160 |
-
/** Zoom by `factor` while holding the point under (mx, my) still β the gesture
|
| 161 |
-
* every map has and the reason zoom cannot be a plain scale on the group. */
|
| 162 |
-
export function zoomAt(view: View, factor: number, mx: number, my: number, kMin: number, kMax: number): View {
|
| 163 |
-
const k = Math.max(kMin, Math.min(kMax, view.k * factor));
|
| 164 |
-
if (k === view.k) return view;
|
| 165 |
-
return {
|
| 166 |
-
k,
|
| 167 |
-
tx: mx - ((mx - view.tx) * k) / view.k,
|
| 168 |
-
ty: my - ((my - view.ty) * k) / view.k,
|
| 169 |
-
};
|
| 170 |
-
}
|
| 171 |
-
|
| 172 |
-
// ---------------------------------------------------------------- stroke width
|
| 173 |
-
//
|
| 174 |
-
// β THE MAP HAS EXACTLY ONE STROKE-WIDTH MECHANISM, AND THIS IS IT.
|
| 175 |
-
//
|
| 176 |
-
// Everything painted inside `<g transform="... scale(k)">` is scaled by k, so a
|
| 177 |
-
// line meant to read 1.1 px on screen must be handed 1.1/k. That is `hairline`.
|
| 178 |
-
// SVG offers a SECOND way to the same end β the CSS `vector-effect:
|
| 179 |
-
// non-scaling-stroke`, which makes the renderer ignore the transform when it
|
| 180 |
-
// strokes. Either works. Using BOTH cancels the zoom twice, and the line then
|
| 181 |
-
// gets THINNER the further you zoom IN.
|
| 182 |
-
//
|
| 183 |
-
// Wave 9 found exactly that, and it is the "blurry when zoomed" bug the owner
|
| 184 |
-
// reported. `.cg-map-land` carried the CSS property AND `hair(1.1)`. Measured
|
| 185 |
-
// against the real constants (WORLD 4096, viewBox 1000x620, ~1.10 CSS px per
|
| 186 |
-
// viewBox unit, fitted k ~1.359): the coastline painted 0.89 CSS px at the
|
| 187 |
-
// fitted view, 0.445 at 2x, 0.089 at 10x and 0.015 at the zoom cap β below
|
| 188 |
-
// ~0.5 px a stroke is an anti-aliased smear and below ~0.2 px a ghost. The
|
| 189 |
-
// graticule, the lakes and the pins were all correct, because they use
|
| 190 |
-
// `hairline` alone. The bug hid precisely because two idioms coexisted on
|
| 191 |
-
// different elements of the same picture.
|
| 192 |
-
//
|
| 193 |
-
// So the rule is singular now, and it is GATED rather than merely commented:
|
| 194 |
-
// `paintedStroke` must be flat across the whole zoom range, and
|
| 195 |
-
// `scalingConflicts` re-reads the real stylesheet so the CSS half cannot come
|
| 196 |
-
// back either. If a future element genuinely wants `non-scaling-stroke`, that
|
| 197 |
-
// is a deliberate change to this rule β change the comment and the gate, not
|
| 198 |
-
// just the stylesheet.
|
| 199 |
-
|
| 200 |
-
/** Stroke width to hand an element drawn INSIDE the zoomed group. */
|
| 201 |
-
export function hairline(basePx: number, k: number): number {
|
| 202 |
-
return basePx / k;
|
| 203 |
-
}
|
| 204 |
-
|
| 205 |
-
/**
|
| 206 |
-
* A dash pattern for a line drawn INSIDE the zoomed group.
|
| 207 |
-
*
|
| 208 |
-
* β Exactly the same trap as stroke width, and it caught me: `stroke-dasharray`
|
| 209 |
-
* in CSS is in USER units, so inside `scale(k)` a "5 4" dash becomes 5k on and
|
| 210 |
-
* 4k off. At a regional fit that is a 70 px dash and a 55 px gap β the route
|
| 211 |
-
* line renders as a few disconnected strokes floating between the stops, which
|
| 212 |
-
* reads as a broken polyline rather than a scaled dash. Every length handed to
|
| 213 |
-
* the transformed group goes through `hairline`, dashes included.
|
| 214 |
-
*/
|
| 215 |
-
export function dashPattern(onPx: number, offPx: number, k: number): string {
|
| 216 |
-
return `${hairline(onPx, k)} ${hairline(offPx, k)}`;
|
| 217 |
-
}
|
| 218 |
-
|
| 219 |
-
/**
|
| 220 |
-
* What the renderer actually paints, in screen units, for a `hairline` width at
|
| 221 |
-
* zoom k β i.e. the attribute multiplied by the group's scale. Not circular: it
|
| 222 |
-
* models the SVG pipeline, which is the thing the invariant is about. It must
|
| 223 |
-
* return `basePx` at EVERY k, and the gate sweeps the range to prove it.
|
| 224 |
-
*/
|
| 225 |
-
export function paintedStroke(basePx: number, k: number): number {
|
| 226 |
-
return hairline(basePx, k) * k;
|
| 227 |
-
}
|
| 228 |
-
|
| 229 |
-
/**
|
| 230 |
-
* The CSS half of the same invariant: any `.cg-map*` rule that declares
|
| 231 |
-
* `vector-effect: non-scaling-stroke` is double-compensating against
|
| 232 |
-
* `hairline`. Returns the offending selectors (empty = clean) so the gate can
|
| 233 |
-
* name them. Comments are stripped first so a commented-out example cannot trip
|
| 234 |
-
* it.
|
| 235 |
-
*/
|
| 236 |
-
export function scalingConflicts(css: string): string[] {
|
| 237 |
-
const bad: string[] = [];
|
| 238 |
-
for (const chunk of css.replace(/\/\*[\s\S]*?\*\//g, "").split("}")) {
|
| 239 |
-
const brace = chunk.indexOf("{");
|
| 240 |
-
if (brace < 0) continue;
|
| 241 |
-
const selector = chunk.slice(0, brace);
|
| 242 |
-
if (!selector.includes(".cg-map")) continue;
|
| 243 |
-
if (/vector-effect\s*:[^;]*non-scaling/i.test(chunk.slice(brace + 1)))
|
| 244 |
-
bad.push(selector.trim().replace(/\s+/g, " "));
|
| 245 |
-
}
|
| 246 |
-
return bad;
|
| 247 |
-
}
|
| 248 |
-
|
| 249 |
-
/**
|
| 250 |
-
* How far in the VENDORED basemap is still worth showing, as an absolute zoom.
|
| 251 |
-
*
|
| 252 |
-
* DERIVED, not chosen by feel. The geometry in mapGeometry.ts is Natural Earth
|
| 253 |
-
* 50m simplified at 0.02 degrees, giving a ~13 km median vertex spacing. With
|
| 254 |
-
* the viewBox painting ~1.08 CSS px per unit at latitude 39, one screen pixel is
|
| 255 |
-
* ~7041/k metres, so a 13 km segment measures ~1.85*k pixels. At k = 32 that is
|
| 256 |
-
* a ~60 px straight run and ~10 px of simplification error β coarse but still
|
| 257 |
-
* unmistakably a shape. Past it the coastline degenerates into long straight
|
| 258 |
-
* lines and the user is zooming into an empty polygon, which is the opposite of
|
| 259 |
-
* the sharpness this was asked for.
|
| 260 |
-
*
|
| 261 |
-
* β This is an HONESTY limit and it is the reason the map stops where it does:
|
| 262 |
-
* street-level detail is not available from any licence-free offline vector set.
|
| 263 |
-
* It needs a tile provider β a runtime network dependency, an API key and an
|
| 264 |
-
* attribution obligation β which is the owner's call, not a silent addition.
|
| 265 |
-
*/
|
| 266 |
-
export const BASEMAP_DETAIL_K = 32;
|
| 267 |
-
|
| 268 |
-
/**
|
| 269 |
-
* The camera's zoom range for a given fitted zoom.
|
| 270 |
-
*
|
| 271 |
-
* `kMin` β zoom OUT to four times the data's own extent for context, but never
|
| 272 |
-
* past the point where the whole projected world already fits: beyond that
|
| 273 |
-
* there is nothing further to reveal, only empty margin. (The wave-8 rule was a
|
| 274 |
-
* flat `fit.k * 0.6`, which locked you in at barely half a step out.)
|
| 275 |
-
*
|
| 276 |
-
* `kMax` β how far IN. This is a HONESTY limit as much as a UX one: zooming
|
| 277 |
-
* past the resolution of the basemap actually vendored just shows a bigger
|
| 278 |
-
* empty polygon, so `detailK` caps it. Pass `Infinity` for no cap.
|
| 279 |
-
*/
|
| 280 |
-
export function zoomLimits(
|
| 281 |
-
fitK: number,
|
| 282 |
-
viewH: number,
|
| 283 |
-
detailK = Infinity
|
| 284 |
-
): { kMin: number; kMax: number } {
|
| 285 |
-
const worldFit = viewH / WORLD;
|
| 286 |
-
const kMin = Math.min(fitK, Math.max(worldFit, fitK * 0.25));
|
| 287 |
-
return { kMin, kMax: Math.max(fitK, Math.min(fitK * 60, detailK)) };
|
| 288 |
-
}
|
| 289 |
-
|
| 290 |
-
/**
|
| 291 |
-
* Graticule opacity at zoom k. The 10-degree grid earns its place on a
|
| 292 |
-
* zoomed-OUT world view, where it is the only thing giving scale. Once wave 9
|
| 293 |
-
* vendored real state borders it became noise the moment you zoom into the
|
| 294 |
-
* country: two competing line systems over the same picture. So it fades out
|
| 295 |
-
* before the borders take over rather than fighting them.
|
| 296 |
-
*/
|
| 297 |
-
export function graticuleOpacity(k: number): number {
|
| 298 |
-
return Math.max(0, Math.min(1, (1.5 - k) / 0.9));
|
| 299 |
-
}
|
| 300 |
-
|
| 301 |
-
/**
|
| 302 |
-
* Where to put a hover card of `w` x `h` for a pin at (sx, sy), in screen space.
|
| 303 |
-
*
|
| 304 |
-
* Prefers ABOVE the pin, flips below when there is no room, and clamps inside
|
| 305 |
-
* the viewport on both axes β a card that runs off the frame is a card whose
|
| 306 |
-
* numbers cannot be read, and the pins nearest the edge are exactly the ones a
|
| 307 |
-
* territory question is usually about.
|
| 308 |
-
*/
|
| 309 |
-
export function cardBox(
|
| 310 |
-
sx: number, sy: number, w: number, h: number,
|
| 311 |
-
viewW: number, viewH: number, gap = 14, pad = 6
|
| 312 |
-
): Pt {
|
| 313 |
-
const above = sy - h - gap;
|
| 314 |
-
// β Both axes clamp UNCONDITIONALLY. Clamping only the "flipped below" branch
|
| 315 |
-
// looks right and is not: a pin panned off the BOTTOM of the frame still has
|
| 316 |
-
// acres of room "above" it, passes the room check, and places the card far
|
| 317 |
-
// below the viewport. Caught by the every-corner leg, never by a screenshot.
|
| 318 |
-
return {
|
| 319 |
-
x: Math.max(pad, Math.min(viewW - w - pad, sx - w / 2)),
|
| 320 |
-
y: Math.max(pad, Math.min(viewH - h - pad, above >= pad ? above : sy + gap)),
|
| 321 |
-
};
|
| 322 |
-
}
|
| 323 |
-
|
| 324 |
-
// ---------------------------------------------------------- route planning
|
| 325 |
-
//
|
| 326 |
-
// I18-R. Sequencing a visit order is a TRAVELLING SALESMAN problem, and it is
|
| 327 |
-
// pure arithmetic: no data, no service, no dependency, no cost. The half that
|
| 328 |
-
// costs money is turning an order into ROAD distances, and this deliberately
|
| 329 |
-
// does not attempt that β see `routeNote` and the mailbox's tier analysis
|
| 330 |
-
// (Google's route matrix bills per element: a 30-stop run is ~$4.50, a full
|
| 331 |
-
// 1,550-customer matrix ~$12,000).
|
| 332 |
-
|
| 333 |
-
/** A stop, in the coordinates the host geocoded β never projected units. */
|
| 334 |
-
export interface GeoStop {
|
| 335 |
-
lat: number;
|
| 336 |
-
lon: number;
|
| 337 |
-
}
|
| 338 |
-
|
| 339 |
-
/**
|
| 340 |
-
* β THE SEAM. Everything below takes distance as a FUNCTION and knows nothing
|
| 341 |
-
* else about it. Swapping in real road distances later (a self-hosted OSRM
|
| 342 |
-
* matrix, precomputed and cached) is then a one-line change at the call site
|
| 343 |
-
* rather than a rewrite of the sequencer.
|
| 344 |
-
*/
|
| 345 |
-
export type StopDistance = (a: GeoStop, b: GeoStop) => number;
|
| 346 |
-
|
| 347 |
-
const EARTH_R_KM = 6371.0088;
|
| 348 |
-
|
| 349 |
-
/**
|
| 350 |
-
* Great-circle distance in km.
|
| 351 |
-
*
|
| 352 |
-
* β MUST be computed on lon/lat, NOT as euclidean distance in projected WORLD
|
| 353 |
-
* units. Mercator stretches by 1/cos(latitude): across this book's range
|
| 354 |
-
* (lat 25-49) that is a 0.91 -> 0.66 swing, ~38%, which systematically ranks
|
| 355 |
-
* north-south pairs against east-west ones. The resulting route looks entirely
|
| 356 |
-
* plausible and is wrong, which is the worst kind of wrong.
|
| 357 |
-
*/
|
| 358 |
-
export const haversineKm: StopDistance = (a, b) => {
|
| 359 |
-
const rad = Math.PI / 180;
|
| 360 |
-
const dLat = (b.lat - a.lat) * rad;
|
| 361 |
-
const dLon = (b.lon - a.lon) * rad;
|
| 362 |
-
const s =
|
| 363 |
-
Math.sin(dLat / 2) ** 2 +
|
| 364 |
-
Math.cos(a.lat * rad) * Math.cos(b.lat * rad) * Math.sin(dLon / 2) ** 2;
|
| 365 |
-
return 2 * EARTH_R_KM * Math.asin(Math.min(1, Math.sqrt(s)));
|
| 366 |
-
};
|
| 367 |
-
|
| 368 |
-
/** Total length of a tour. `roundTrip` adds the closing edge back to the start. */
|
| 369 |
-
export function tourLength(
|
| 370 |
-
order: number[], stops: GeoStop[], dist: StopDistance, roundTrip = false
|
| 371 |
-
): number {
|
| 372 |
-
if (order.length < 2) return 0;
|
| 373 |
-
let km = 0;
|
| 374 |
-
for (let i = 1; i < order.length; i++) km += dist(stops[order[i - 1]], stops[order[i]]);
|
| 375 |
-
if (roundTrip) km += dist(stops[order[order.length - 1]], stops[order[0]]);
|
| 376 |
-
return km;
|
| 377 |
-
}
|
| 378 |
-
|
| 379 |
-
/** Greedy construction: from `start`, repeatedly hop to the nearest unvisited stop. */
|
| 380 |
-
export function nearestNeighbourOrder(
|
| 381 |
-
stops: GeoStop[], dist: StopDistance, start = 0
|
| 382 |
-
): number[] {
|
| 383 |
-
const n = stops.length;
|
| 384 |
-
if (n === 0) return [];
|
| 385 |
-
const from = Math.max(0, Math.min(n - 1, Math.round(start) || 0));
|
| 386 |
-
const seen = new Array<boolean>(n).fill(false);
|
| 387 |
-
const order = [from];
|
| 388 |
-
seen[from] = true;
|
| 389 |
-
for (let k = 1; k < n; k++) {
|
| 390 |
-
const last = order[order.length - 1];
|
| 391 |
-
let best = -1;
|
| 392 |
-
let bestD = Infinity;
|
| 393 |
-
for (let i = 0; i < n; i++) {
|
| 394 |
-
if (seen[i]) continue;
|
| 395 |
-
const d = dist(stops[last], stops[i]);
|
| 396 |
-
if (d < bestD) { bestD = d; best = i; }
|
| 397 |
-
}
|
| 398 |
-
if (best < 0) break;
|
| 399 |
-
seen[best] = true;
|
| 400 |
-
order.push(best);
|
| 401 |
-
}
|
| 402 |
-
return order;
|
| 403 |
-
}
|
| 404 |
-
|
| 405 |
-
/**
|
| 406 |
-
* 2-opt: repeatedly reverse a segment when doing so shortens the tour.
|
| 407 |
-
*
|
| 408 |
-
* Index 0 is PINNED β it is the origin the user chose, and silently re-rooting
|
| 409 |
-
* their route would be a worse bug than a slightly longer one. Only strictly
|
| 410 |
-
* improving moves are accepted, which is what makes "never returns a tour
|
| 411 |
-
* longer than the one it was given" a guarantee the gate can assert rather than
|
| 412 |
-
* a hope.
|
| 413 |
-
*/
|
| 414 |
-
export function twoOptOrder(
|
| 415 |
-
order: number[], stops: GeoStop[], dist: StopDistance,
|
| 416 |
-
roundTrip = false, maxPasses = 24
|
| 417 |
-
): number[] {
|
| 418 |
-
const n = order.length;
|
| 419 |
-
const cur = order.slice();
|
| 420 |
-
if (n < 4) return cur;
|
| 421 |
-
const D = (a: number, b: number) => dist(stops[a], stops[b]);
|
| 422 |
-
for (let pass = 0; pass < maxPasses; pass++) {
|
| 423 |
-
let improved = false;
|
| 424 |
-
for (let i = 1; i < n - 1; i++) {
|
| 425 |
-
for (let j = i + 1; j < n; j++) {
|
| 426 |
-
const a = cur[i - 1], b = cur[i], c = cur[j];
|
| 427 |
-
let delta: number;
|
| 428 |
-
if (j === n - 1 && !roundTrip) {
|
| 429 |
-
// Reversing the tail of an OPEN path only re-hangs the entry edge:
|
| 430 |
-
// there is no closing edge to pay for.
|
| 431 |
-
delta = D(a, c) - D(a, b);
|
| 432 |
-
} else {
|
| 433 |
-
const d = cur[(j + 1) % n];
|
| 434 |
-
delta = D(a, c) + D(b, d) - D(a, b) - D(c, d);
|
| 435 |
-
}
|
| 436 |
-
if (delta < -1e-9) {
|
| 437 |
-
for (let lo = i, hi = j; lo < hi; lo++, hi--) {
|
| 438 |
-
const t = cur[lo]; cur[lo] = cur[hi]; cur[hi] = t;
|
| 439 |
-
}
|
| 440 |
-
improved = true;
|
| 441 |
-
}
|
| 442 |
-
}
|
| 443 |
-
}
|
| 444 |
-
if (!improved) break;
|
| 445 |
-
}
|
| 446 |
-
return cur;
|
| 447 |
-
}
|
| 448 |
-
|
| 449 |
-
/** Construct then improve. Returns the visit order and its length. */
|
| 450 |
-
export function planRoute(
|
| 451 |
-
stops: GeoStop[], dist: StopDistance,
|
| 452 |
-
opts: { start?: number; roundTrip?: boolean } = {}
|
| 453 |
-
): { order: number[]; km: number } {
|
| 454 |
-
const roundTrip = !!opts.roundTrip;
|
| 455 |
-
if (stops.length === 0) return { order: [], km: 0 };
|
| 456 |
-
const nn = nearestNeighbourOrder(stops, dist, opts.start ?? 0);
|
| 457 |
-
const order = twoOptOrder(nn, stops, dist, roundTrip);
|
| 458 |
-
return { order, km: tourLength(order, stops, dist, roundTrip) };
|
| 459 |
-
}
|
| 460 |
-
|
| 461 |
-
// --------------------------------------------------- handing the route over
|
| 462 |
-
//
|
| 463 |
-
// MEASURED 2026-07-29, do not re-derive: Google Maps URLs need NO API key and
|
| 464 |
-
// cost NOTHING, but they carry at most 9 waypoints on desktop and 3 on mobile
|
| 465 |
-
// browsers, inside a 2,048-character URL.
|
| 466 |
-
|
| 467 |
-
export const ROUTE_WAYPOINTS_DESKTOP = 9;
|
| 468 |
-
export const ROUTE_WAYPOINTS_MOBILE = 3;
|
| 469 |
-
export const MAX_MAPS_URL = 2048;
|
| 470 |
-
|
| 471 |
-
/** How many STOPS can ride the free URL: the waypoints plus the two endpoints
|
| 472 |
-
* (a round trip returns to its origin, so the origin is not also a waypoint). */
|
| 473 |
-
export function routeStopCap(coarsePointer: boolean, roundTrip = false): number {
|
| 474 |
-
const w = coarsePointer ? ROUTE_WAYPOINTS_MOBILE : ROUTE_WAYPOINTS_DESKTOP;
|
| 475 |
-
return roundTrip ? w + 1 : w + 2;
|
| 476 |
-
}
|
| 477 |
-
|
| 478 |
-
/**
|
| 479 |
-
* Build the free Google directions URL for an ORDERED list of stops.
|
| 480 |
-
*
|
| 481 |
-
* Returns `used` alongside the url so the caller can say "first 11 of 23" on
|
| 482 |
-
* screen. It never silently drops a stop; truncation is a fact the UI states
|
| 483 |
-
* ([[no-unverifiable-aggregates]]). Shrinks further if the character budget
|
| 484 |
-
* binds, which it can with a long tail of 6-dp coordinates.
|
| 485 |
-
*/
|
| 486 |
-
export function googleRouteUrl(
|
| 487 |
-
stops: GeoStop[],
|
| 488 |
-
opts: { roundTrip?: boolean; coarsePointer?: boolean } = {}
|
| 489 |
-
): { url: string; used: number } | null {
|
| 490 |
-
if (stops.length < 2) return null;
|
| 491 |
-
const roundTrip = !!opts.roundTrip;
|
| 492 |
-
const at = (s: GeoStop) => `${s.lat.toFixed(6)},${s.lon.toFixed(6)}`;
|
| 493 |
-
let used = Math.min(stops.length, routeStopCap(!!opts.coarsePointer, roundTrip));
|
| 494 |
-
for (;;) {
|
| 495 |
-
const chosen = stops.slice(0, used);
|
| 496 |
-
const origin = chosen[0];
|
| 497 |
-
const dest = roundTrip ? origin : chosen[chosen.length - 1];
|
| 498 |
-
const mids = roundTrip ? chosen.slice(1) : chosen.slice(1, -1);
|
| 499 |
-
const url =
|
| 500 |
-
`https://www.google.com/maps/dir/?api=1&origin=${at(origin)}` +
|
| 501 |
-
`&destination=${at(dest)}` +
|
| 502 |
-
(mids.length ? `&waypoints=${mids.map(at).join("|")}` : "") +
|
| 503 |
-
`&travelmode=driving`;
|
| 504 |
-
if (url.length <= MAX_MAPS_URL || used <= 2) return { url, used };
|
| 505 |
-
used -= 1;
|
| 506 |
-
}
|
| 507 |
-
}
|
| 508 |
-
|
| 509 |
-
/** An svg's own bounding box in client px β the `getBoundingClientRect()` half
|
| 510 |
-
* of the conversion below, taken as plain data so the maths stays testable
|
| 511 |
-
* without a DOM. */
|
| 512 |
-
export interface FrameRect {
|
| 513 |
-
left: number;
|
| 514 |
-
top: number;
|
| 515 |
-
width: number;
|
| 516 |
-
height: number;
|
| 517 |
-
}
|
| 518 |
-
|
| 519 |
-
/**
|
| 520 |
-
* Client px -> the svg's own user-space coords. Every pointer gesture on the
|
| 521 |
-
* map β marquee, rubber band, cursor-anchored wheel zoom β starts here.
|
| 522 |
-
*
|
| 523 |
-
* β It is NOT `(client / frame) * viewBox`. MapView paints with
|
| 524 |
-
* `preserveAspectRatio="xMidYMid meet"`, so the viewBox is scaled UNIFORMLY by
|
| 525 |
-
* the tighter of the two axes and then CENTRED, leaving a letterbox band on the
|
| 526 |
-
* other axis. Wave 8 (`f6f45a6`) bolted a stretch-to-fill conversion onto that
|
| 527 |
-
* `meet` svg: the binding axis came out right and the other carried BOTH a
|
| 528 |
-
* wrong scale and a missing offset. On a 1400x600 frame the full 0..1000
|
| 529 |
-
* x-range collapsed into ~154..846 β so a marquee at either edge caught
|
| 530 |
-
* NOTHING, the rubber band lagged the cursor by ~150 px, and cursor-anchored
|
| 531 |
-
* zoom drifted, all from this one function. `zoomAt` and the hit test were
|
| 532 |
-
* always correct; they were being handed the wrong point.
|
| 533 |
-
*
|
| 534 |
-
* β The result is deliberately NOT clamped to the viewBox. A drag that begins
|
| 535 |
-
* in the letterbox band is a real gesture β everything from the painted edge
|
| 536 |
-
* inward must still be caught β and clamping re-breaks exactly the edge
|
| 537 |
-
* marquee this exists to fix.
|
| 538 |
-
*/
|
| 539 |
-
export function clientToUser(
|
| 540 |
-
clientX: number,
|
| 541 |
-
clientY: number,
|
| 542 |
-
rect: FrameRect,
|
| 543 |
-
viewW: number,
|
| 544 |
-
viewH: number
|
| 545 |
-
): Pt {
|
| 546 |
-
const s = Math.min(rect.width / viewW, rect.height / viewH);
|
| 547 |
-
if (!(s > 0) || !Number.isFinite(s)) return { x: 0, y: 0 };
|
| 548 |
-
const offX = (rect.width - viewW * s) / 2;
|
| 549 |
-
const offY = (rect.height - viewH * s) / 2;
|
| 550 |
-
return { x: (clientX - rect.left - offX) / s, y: (clientY - rect.top - offY) / s };
|
| 551 |
-
}
|
| 552 |
-
|
| 553 |
-
/**
|
| 554 |
-
* Is `p` inside the closed polygon `poly`? Even-odd ray casting (the crossing
|
| 555 |
-
* number), in the same user-space units `toScreen` returns.
|
| 556 |
-
*
|
| 557 |
-
* β It is NOT a bounding-box test, and that difference IS the feature. A lasso
|
| 558 |
-
* drawn as a C or a horseshoe must EXCLUDE whatever sits in its mouth β
|
| 559 |
-
* otherwise it is the rectangle marquee wearing a lasso's name, which is the
|
| 560 |
-
* one thing a person drawing a loop by hand would never expect. The gate
|
| 561 |
-
* asserts exactly that case, over a shape whose bounding box gives a different
|
| 562 |
-
* answer: a convex test polygon would make the control inert.
|
| 563 |
-
*
|
| 564 |
-
* The ray is cast along +x from `p`, and each edge that straddles `p.y` and
|
| 565 |
-
* crosses to the LEFT of nothing / RIGHT of `p.x` flips the parity. A point
|
| 566 |
-
* exactly on a vertex or an edge may fall either way: this selects pins under a
|
| 567 |
-
* hand-drawn path, where a half-pixel tie carries no meaning and an epsilon to
|
| 568 |
-
* break it would be a number nobody could justify.
|
| 569 |
-
*/
|
| 570 |
-
export function pointInPolygon(p: Pt, poly: Pt[]): boolean {
|
| 571 |
-
if (poly.length < 3) return false;
|
| 572 |
-
let inside = false;
|
| 573 |
-
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
|
| 574 |
-
const a = poly[i];
|
| 575 |
-
const b = poly[j];
|
| 576 |
-
const straddles = a.y > p.y !== b.y > p.y;
|
| 577 |
-
if (straddles && p.x < ((b.x - a.x) * (p.y - a.y)) / (b.y - a.y) + a.x) inside = !inside;
|
| 578 |
-
}
|
| 579 |
-
return inside;
|
| 580 |
-
}
|
| 581 |
-
|
| 582 |
-
/**
|
| 583 |
-
* The axis-aligned bounds of a freehand path, in `normRect`'s own {x0,y0,x1,y1}
|
| 584 |
-
* shape so one mis-click rule can measure either gesture. An empty path is a
|
| 585 |
-
* zero box rather than an Infinity one β the caller's "did this move at all"
|
| 586 |
-
* test must answer NO, not NaN.
|
| 587 |
-
*/
|
| 588 |
-
export function pathBounds(pts: Pt[]) {
|
| 589 |
-
if (pts.length === 0) return { x0: 0, y0: 0, x1: 0, y1: 0 };
|
| 590 |
-
let x0 = pts[0].x;
|
| 591 |
-
let y0 = pts[0].y;
|
| 592 |
-
let x1 = pts[0].x;
|
| 593 |
-
let y1 = pts[0].y;
|
| 594 |
-
for (const p of pts) {
|
| 595 |
-
if (p.x < x0) x0 = p.x;
|
| 596 |
-
if (p.x > x1) x1 = p.x;
|
| 597 |
-
if (p.y < y0) y0 = p.y;
|
| 598 |
-
if (p.y > y1) y1 = p.y;
|
| 599 |
-
}
|
| 600 |
-
return { x0, y0, x1, y1 };
|
| 601 |
-
}
|
| 602 |
-
|
| 603 |
-
/** Screen-space rect (any two corners) -> normalized {x0,y0,x1,y1}. */
|
| 604 |
-
export function normRect(ax: number, ay: number, bx: number, by: number) {
|
| 605 |
-
return {
|
| 606 |
-
x0: Math.min(ax, bx),
|
| 607 |
-
y0: Math.min(ay, by),
|
| 608 |
-
x1: Math.max(ax, bx),
|
| 609 |
-
y1: Math.max(ay, by),
|
| 610 |
-
};
|
| 611 |
-
}
|
| 612 |
-
|
| 613 |
-
/**
|
| 614 |
-
* Bubble radius for a value under a sqrt scale (I5). AREA is proportional to
|
| 615 |
-
* the value, which is the only honest way to size a circle β radius-proportional
|
| 616 |
-
* bubbles overstate large values by the square, the classic bubble-chart lie.
|
| 617 |
-
* `null`/non-finite gets `rNull`: a value-less row is drawn small, never hidden
|
| 618 |
-
* and never faked (rule 8b).
|
| 619 |
-
*/
|
| 620 |
-
export function bubbleRadius(
|
| 621 |
-
v: number | null,
|
| 622 |
-
min: number,
|
| 623 |
-
max: number,
|
| 624 |
-
rMin: number,
|
| 625 |
-
rMax: number,
|
| 626 |
-
rNull: number
|
| 627 |
-
): number {
|
| 628 |
-
if (v == null || !Number.isFinite(v)) return rNull;
|
| 629 |
-
if (!(max > min)) return (rMin + rMax) / 2;
|
| 630 |
-
const t = Math.max(0, Math.min(1, (v - min) / (max - min)));
|
| 631 |
-
return Math.sqrt(rMin * rMin + t * (rMax * rMax - rMin * rMin));
|
| 632 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ---------------------------------------------------------------------------
|
| 2 |
+
// customer-grid / mapProjection.ts
|
| 3 |
+
// Wave-8 I2/I6 β the map's geometry, split from MapView so it can be tested
|
| 4 |
+
// under node without React.
|
| 5 |
+
//
|
| 6 |
+
// WHY THIS EXISTS AS ITS OWN LAYER. The wave-7 map derived its projection from
|
| 7 |
+
// the DATA's bounding box (MapView.tsx:96-118, an equirectangular fit). That is
|
| 8 |
+
// fine for a static scatter and wrong for everything wave 8 asks of it:
|
| 9 |
+
//
|
| 10 |
+
// - the map re-projected on every filter change, so the whole picture jumped
|
| 11 |
+
// whenever a condition was edited;
|
| 12 |
+
// - box-select needs a stable screen<->point mapping DURING a drag, which a
|
| 13 |
+
// projection memoised over `points` is not;
|
| 14 |
+
// - a data-fit projection distorts real geography β state outlines drawn
|
| 15 |
+
// through it visibly skew once you zoom into one metro.
|
| 16 |
+
//
|
| 17 |
+
// So the two concerns are separated:
|
| 18 |
+
// PROJECTION fixed Web Mercator, world -> a fixed square. Never changes.
|
| 19 |
+
// VIEW {k, tx, ty} β zoom and pan, an affine transform ON TOP.
|
| 20 |
+
// The old data-fit becomes the INITIAL VIEW value rather than the projection,
|
| 21 |
+
// which is what makes zoom/pan and hit-testing tractable at all.
|
| 22 |
+
// ---------------------------------------------------------------------------
|
| 23 |
+
|
| 24 |
+
/** Base resolution of the projected world square, in SVG user units. */
|
| 25 |
+
export const WORLD = 4096;
|
| 26 |
+
|
| 27 |
+
/** Mercator blows up at the poles; every web map clamps. */
|
| 28 |
+
const MAX_LAT = 85.05112878;
|
| 29 |
+
|
| 30 |
+
export interface Pt {
|
| 31 |
+
x: number;
|
| 32 |
+
y: number;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
/** A pan/zoom transform: screen = view.t + view.k * projected. */
|
| 36 |
+
export interface View {
|
| 37 |
+
k: number;
|
| 38 |
+
tx: number;
|
| 39 |
+
ty: number;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
/** lon/lat -> the fixed projected plane (0..WORLD on both axes). */
|
| 43 |
+
export function project(lon: number, lat: number): Pt {
|
| 44 |
+
const clamped = Math.max(-MAX_LAT, Math.min(MAX_LAT, lat));
|
| 45 |
+
const rad = (clamped * Math.PI) / 180;
|
| 46 |
+
const x = (lon + 180) / 360;
|
| 47 |
+
const y = 0.5 - Math.log(Math.tan(Math.PI / 4 + rad / 2)) / (2 * Math.PI);
|
| 48 |
+
return { x: x * WORLD, y: y * WORLD };
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
/** The fixed projected plane -> lon/lat. The exact inverse of `project`. */
|
| 52 |
+
export function unproject(p: Pt): { lon: number; lat: number } {
|
| 53 |
+
const t = 0.5 - p.y / WORLD;
|
| 54 |
+
const rad = 2 * Math.atan(Math.exp(2 * Math.PI * t)) - Math.PI / 2;
|
| 55 |
+
return { lon: (p.x / WORLD) * 360 - 180, lat: (rad * 180) / Math.PI };
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
/** Screen -> the fixed projected plane, through a view. The inverse of `toScreen`. */
|
| 59 |
+
export function fromScreen(s: Pt, view: View): Pt {
|
| 60 |
+
return { x: (s.x - view.tx) / view.k, y: (s.y - view.ty) / view.k };
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
// -------------------------------------------------------- the Google hand-off
|
| 64 |
+
//
|
| 65 |
+
// The honest answer to "I need to see the actual street". A licence-free offline
|
| 66 |
+
// vector basemap stops at roughly metro scale (see BASEMAP_DETAIL_K), and the
|
| 67 |
+
// alternative β bundling a tile renderer β costs 270 KB gz, an API key or a
|
| 68 |
+
// hosted planet file, and sends every customer's coordinates to a third party on
|
| 69 |
+
// every pan. A LINK costs none of that: nothing ships, nothing is fetched, and
|
| 70 |
+
// the coordinates travel only if the user deliberately clicks.
|
| 71 |
+
//
|
| 72 |
+
// β Every one of these must be rendered with rel="noopener noreferrer" β that
|
| 73 |
+
// strips the Referer, so the destination never learns which tenant or which
|
| 74 |
+
// deployment the click came from, and it denies the opened tab window.opener.
|
| 75 |
+
|
| 76 |
+
/**
|
| 77 |
+
* Google Maps' zoom level for our zoom `k`.
|
| 78 |
+
*
|
| 79 |
+
* Google measures a world 256 * 2^z px wide; ours is WORLD units wide, painted
|
| 80 |
+
* at `k` and then ~1.08 CSS px per unit. Equating the two:
|
| 81 |
+
* 256 * 2^z = WORLD * k * 1.08 -> z = log2(WORLD * 1.08 * k / 256)
|
| 82 |
+
* Clamped to Google's own 0..21. A fitted US book (k~1.36) hands over z5, the
|
| 83 |
+
* country; the detail cap (k=32) hands over z9, a metro β i.e. the hand-off
|
| 84 |
+
* starts exactly where our basemap runs out, which is the point of it.
|
| 85 |
+
*/
|
| 86 |
+
export function googleZoomForK(k: number): number {
|
| 87 |
+
if (!Number.isFinite(k) || k <= 0) return 4;
|
| 88 |
+
const z = Math.log2((WORLD * 1.08 * k) / 256);
|
| 89 |
+
return Math.max(0, Math.min(21, Math.round(z)));
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
/** True only for a coordinate Google can actually be sent. */
|
| 93 |
+
export function isPlottable(lat: number | null, lon: number | null): boolean {
|
| 94 |
+
return (
|
| 95 |
+
lat != null && lon != null &&
|
| 96 |
+
Number.isFinite(lat) && Number.isFinite(lon) &&
|
| 97 |
+
Math.abs(lat) <= 90 && Math.abs(lon) <= 180
|
| 98 |
+
);
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
/**
|
| 102 |
+
* A dropped pin at exactly the coordinate WE plotted.
|
| 103 |
+
*
|
| 104 |
+
* β Deliberately by lat/lon and never by customer name or address: a name search
|
| 105 |
+
* can resolve somewhere else entirely, and then the app's map and the link
|
| 106 |
+
* disagree about where a customer is. The pin is the geocode; the link is the
|
| 107 |
+
* same geocode. `api=1` is Google's documented, stable URL contract.
|
| 108 |
+
*/
|
| 109 |
+
export function googleMapsUrl(lat: number, lon: number, zoom?: number): string {
|
| 110 |
+
const at = `${lat.toFixed(6)},${lon.toFixed(6)}`;
|
| 111 |
+
return zoom == null
|
| 112 |
+
? `https://www.google.com/maps/search/?api=1&query=${at}`
|
| 113 |
+
: `https://www.google.com/maps/@${at},${Math.round(zoom)}z`;
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
/** Directions to a customer β the version a rep on the road actually wants. */
|
| 117 |
+
export function googleDirectionsUrl(lat: number, lon: number): string {
|
| 118 |
+
return `https://www.google.com/maps/dir/?api=1&destination=${lat.toFixed(6)},${lon.toFixed(6)}`;
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
/** Projected point -> screen, through a view. */
|
| 122 |
+
export function toScreen(p: Pt, view: View): Pt {
|
| 123 |
+
return { x: view.tx + p.x * view.k, y: view.ty + p.y * view.k };
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
/**
|
| 127 |
+
* The view that frames `pts` inside a w x h viewport with `pad` px of margin.
|
| 128 |
+
* `minSpan` stops a single point (or one city) from zooming to street level:
|
| 129 |
+
* one customer should still look like a PLACE, not a full-bleed dot β the
|
| 130 |
+
* wave-7 behaviour, kept.
|
| 131 |
+
*/
|
| 132 |
+
export function fitView(
|
| 133 |
+
pts: Pt[],
|
| 134 |
+
w: number,
|
| 135 |
+
h: number,
|
| 136 |
+
pad = 40,
|
| 137 |
+
minSpan = WORLD / 90
|
| 138 |
+
): View | null {
|
| 139 |
+
if (pts.length === 0) return null;
|
| 140 |
+
let minX = Infinity;
|
| 141 |
+
let maxX = -Infinity;
|
| 142 |
+
let minY = Infinity;
|
| 143 |
+
let maxY = -Infinity;
|
| 144 |
+
for (const p of pts) {
|
| 145 |
+
minX = Math.min(minX, p.x);
|
| 146 |
+
maxX = Math.max(maxX, p.x);
|
| 147 |
+
minY = Math.min(minY, p.y);
|
| 148 |
+
maxY = Math.max(maxY, p.y);
|
| 149 |
+
}
|
| 150 |
+
let spanX = Math.max(maxX - minX, minSpan);
|
| 151 |
+
let spanY = Math.max(maxY - minY, minSpan);
|
| 152 |
+
const cx = (minX + maxX) / 2;
|
| 153 |
+
const cy = (minY + maxY) / 2;
|
| 154 |
+
spanX *= 1.16; // breathing room so edge pins are not on the frame
|
| 155 |
+
spanY *= 1.16;
|
| 156 |
+
const k = Math.min((w - pad * 2) / spanX, (h - pad * 2) / spanY);
|
| 157 |
+
return { k, tx: w / 2 - cx * k, ty: h / 2 - cy * k };
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
/** Zoom by `factor` while holding the point under (mx, my) still β the gesture
|
| 161 |
+
* every map has and the reason zoom cannot be a plain scale on the group. */
|
| 162 |
+
export function zoomAt(view: View, factor: number, mx: number, my: number, kMin: number, kMax: number): View {
|
| 163 |
+
const k = Math.max(kMin, Math.min(kMax, view.k * factor));
|
| 164 |
+
if (k === view.k) return view;
|
| 165 |
+
return {
|
| 166 |
+
k,
|
| 167 |
+
tx: mx - ((mx - view.tx) * k) / view.k,
|
| 168 |
+
ty: my - ((my - view.ty) * k) / view.k,
|
| 169 |
+
};
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
// ---------------------------------------------------------------- stroke width
|
| 173 |
+
//
|
| 174 |
+
// β THE MAP HAS EXACTLY ONE STROKE-WIDTH MECHANISM, AND THIS IS IT.
|
| 175 |
+
//
|
| 176 |
+
// Everything painted inside `<g transform="... scale(k)">` is scaled by k, so a
|
| 177 |
+
// line meant to read 1.1 px on screen must be handed 1.1/k. That is `hairline`.
|
| 178 |
+
// SVG offers a SECOND way to the same end β the CSS `vector-effect:
|
| 179 |
+
// non-scaling-stroke`, which makes the renderer ignore the transform when it
|
| 180 |
+
// strokes. Either works. Using BOTH cancels the zoom twice, and the line then
|
| 181 |
+
// gets THINNER the further you zoom IN.
|
| 182 |
+
//
|
| 183 |
+
// Wave 9 found exactly that, and it is the "blurry when zoomed" bug the owner
|
| 184 |
+
// reported. `.cg-map-land` carried the CSS property AND `hair(1.1)`. Measured
|
| 185 |
+
// against the real constants (WORLD 4096, viewBox 1000x620, ~1.10 CSS px per
|
| 186 |
+
// viewBox unit, fitted k ~1.359): the coastline painted 0.89 CSS px at the
|
| 187 |
+
// fitted view, 0.445 at 2x, 0.089 at 10x and 0.015 at the zoom cap β below
|
| 188 |
+
// ~0.5 px a stroke is an anti-aliased smear and below ~0.2 px a ghost. The
|
| 189 |
+
// graticule, the lakes and the pins were all correct, because they use
|
| 190 |
+
// `hairline` alone. The bug hid precisely because two idioms coexisted on
|
| 191 |
+
// different elements of the same picture.
|
| 192 |
+
//
|
| 193 |
+
// So the rule is singular now, and it is GATED rather than merely commented:
|
| 194 |
+
// `paintedStroke` must be flat across the whole zoom range, and
|
| 195 |
+
// `scalingConflicts` re-reads the real stylesheet so the CSS half cannot come
|
| 196 |
+
// back either. If a future element genuinely wants `non-scaling-stroke`, that
|
| 197 |
+
// is a deliberate change to this rule β change the comment and the gate, not
|
| 198 |
+
// just the stylesheet.
|
| 199 |
+
|
| 200 |
+
/** Stroke width to hand an element drawn INSIDE the zoomed group. */
|
| 201 |
+
export function hairline(basePx: number, k: number): number {
|
| 202 |
+
return basePx / k;
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
/**
|
| 206 |
+
* A dash pattern for a line drawn INSIDE the zoomed group.
|
| 207 |
+
*
|
| 208 |
+
* β Exactly the same trap as stroke width, and it caught me: `stroke-dasharray`
|
| 209 |
+
* in CSS is in USER units, so inside `scale(k)` a "5 4" dash becomes 5k on and
|
| 210 |
+
* 4k off. At a regional fit that is a 70 px dash and a 55 px gap β the route
|
| 211 |
+
* line renders as a few disconnected strokes floating between the stops, which
|
| 212 |
+
* reads as a broken polyline rather than a scaled dash. Every length handed to
|
| 213 |
+
* the transformed group goes through `hairline`, dashes included.
|
| 214 |
+
*/
|
| 215 |
+
export function dashPattern(onPx: number, offPx: number, k: number): string {
|
| 216 |
+
return `${hairline(onPx, k)} ${hairline(offPx, k)}`;
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
/**
|
| 220 |
+
* What the renderer actually paints, in screen units, for a `hairline` width at
|
| 221 |
+
* zoom k β i.e. the attribute multiplied by the group's scale. Not circular: it
|
| 222 |
+
* models the SVG pipeline, which is the thing the invariant is about. It must
|
| 223 |
+
* return `basePx` at EVERY k, and the gate sweeps the range to prove it.
|
| 224 |
+
*/
|
| 225 |
+
export function paintedStroke(basePx: number, k: number): number {
|
| 226 |
+
return hairline(basePx, k) * k;
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
/**
|
| 230 |
+
* The CSS half of the same invariant: any `.cg-map*` rule that declares
|
| 231 |
+
* `vector-effect: non-scaling-stroke` is double-compensating against
|
| 232 |
+
* `hairline`. Returns the offending selectors (empty = clean) so the gate can
|
| 233 |
+
* name them. Comments are stripped first so a commented-out example cannot trip
|
| 234 |
+
* it.
|
| 235 |
+
*/
|
| 236 |
+
export function scalingConflicts(css: string): string[] {
|
| 237 |
+
const bad: string[] = [];
|
| 238 |
+
for (const chunk of css.replace(/\/\*[\s\S]*?\*\//g, "").split("}")) {
|
| 239 |
+
const brace = chunk.indexOf("{");
|
| 240 |
+
if (brace < 0) continue;
|
| 241 |
+
const selector = chunk.slice(0, brace);
|
| 242 |
+
if (!selector.includes(".cg-map")) continue;
|
| 243 |
+
if (/vector-effect\s*:[^;]*non-scaling/i.test(chunk.slice(brace + 1)))
|
| 244 |
+
bad.push(selector.trim().replace(/\s+/g, " "));
|
| 245 |
+
}
|
| 246 |
+
return bad;
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
/**
|
| 250 |
+
* How far in the VENDORED basemap is still worth showing, as an absolute zoom.
|
| 251 |
+
*
|
| 252 |
+
* DERIVED, not chosen by feel. The geometry in mapGeometry.ts is Natural Earth
|
| 253 |
+
* 50m simplified at 0.02 degrees, giving a ~13 km median vertex spacing. With
|
| 254 |
+
* the viewBox painting ~1.08 CSS px per unit at latitude 39, one screen pixel is
|
| 255 |
+
* ~7041/k metres, so a 13 km segment measures ~1.85*k pixels. At k = 32 that is
|
| 256 |
+
* a ~60 px straight run and ~10 px of simplification error β coarse but still
|
| 257 |
+
* unmistakably a shape. Past it the coastline degenerates into long straight
|
| 258 |
+
* lines and the user is zooming into an empty polygon, which is the opposite of
|
| 259 |
+
* the sharpness this was asked for.
|
| 260 |
+
*
|
| 261 |
+
* β This is an HONESTY limit and it is the reason the map stops where it does:
|
| 262 |
+
* street-level detail is not available from any licence-free offline vector set.
|
| 263 |
+
* It needs a tile provider β a runtime network dependency, an API key and an
|
| 264 |
+
* attribution obligation β which is the owner's call, not a silent addition.
|
| 265 |
+
*/
|
| 266 |
+
export const BASEMAP_DETAIL_K = 32;
|
| 267 |
+
|
| 268 |
+
/**
|
| 269 |
+
* The camera's zoom range for a given fitted zoom.
|
| 270 |
+
*
|
| 271 |
+
* `kMin` β zoom OUT to four times the data's own extent for context, but never
|
| 272 |
+
* past the point where the whole projected world already fits: beyond that
|
| 273 |
+
* there is nothing further to reveal, only empty margin. (The wave-8 rule was a
|
| 274 |
+
* flat `fit.k * 0.6`, which locked you in at barely half a step out.)
|
| 275 |
+
*
|
| 276 |
+
* `kMax` β how far IN. This is a HONESTY limit as much as a UX one: zooming
|
| 277 |
+
* past the resolution of the basemap actually vendored just shows a bigger
|
| 278 |
+
* empty polygon, so `detailK` caps it. Pass `Infinity` for no cap.
|
| 279 |
+
*/
|
| 280 |
+
export function zoomLimits(
|
| 281 |
+
fitK: number,
|
| 282 |
+
viewH: number,
|
| 283 |
+
detailK = Infinity
|
| 284 |
+
): { kMin: number; kMax: number } {
|
| 285 |
+
const worldFit = viewH / WORLD;
|
| 286 |
+
const kMin = Math.min(fitK, Math.max(worldFit, fitK * 0.25));
|
| 287 |
+
return { kMin, kMax: Math.max(fitK, Math.min(fitK * 60, detailK)) };
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
/**
|
| 291 |
+
* Graticule opacity at zoom k. The 10-degree grid earns its place on a
|
| 292 |
+
* zoomed-OUT world view, where it is the only thing giving scale. Once wave 9
|
| 293 |
+
* vendored real state borders it became noise the moment you zoom into the
|
| 294 |
+
* country: two competing line systems over the same picture. So it fades out
|
| 295 |
+
* before the borders take over rather than fighting them.
|
| 296 |
+
*/
|
| 297 |
+
export function graticuleOpacity(k: number): number {
|
| 298 |
+
return Math.max(0, Math.min(1, (1.5 - k) / 0.9));
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
/**
|
| 302 |
+
* Where to put a hover card of `w` x `h` for a pin at (sx, sy), in screen space.
|
| 303 |
+
*
|
| 304 |
+
* Prefers ABOVE the pin, flips below when there is no room, and clamps inside
|
| 305 |
+
* the viewport on both axes β a card that runs off the frame is a card whose
|
| 306 |
+
* numbers cannot be read, and the pins nearest the edge are exactly the ones a
|
| 307 |
+
* territory question is usually about.
|
| 308 |
+
*/
|
| 309 |
+
export function cardBox(
|
| 310 |
+
sx: number, sy: number, w: number, h: number,
|
| 311 |
+
viewW: number, viewH: number, gap = 14, pad = 6
|
| 312 |
+
): Pt {
|
| 313 |
+
const above = sy - h - gap;
|
| 314 |
+
// β Both axes clamp UNCONDITIONALLY. Clamping only the "flipped below" branch
|
| 315 |
+
// looks right and is not: a pin panned off the BOTTOM of the frame still has
|
| 316 |
+
// acres of room "above" it, passes the room check, and places the card far
|
| 317 |
+
// below the viewport. Caught by the every-corner leg, never by a screenshot.
|
| 318 |
+
return {
|
| 319 |
+
x: Math.max(pad, Math.min(viewW - w - pad, sx - w / 2)),
|
| 320 |
+
y: Math.max(pad, Math.min(viewH - h - pad, above >= pad ? above : sy + gap)),
|
| 321 |
+
};
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
// ---------------------------------------------------------- route planning
|
| 325 |
+
//
|
| 326 |
+
// I18-R. Sequencing a visit order is a TRAVELLING SALESMAN problem, and it is
|
| 327 |
+
// pure arithmetic: no data, no service, no dependency, no cost. The half that
|
| 328 |
+
// costs money is turning an order into ROAD distances, and this deliberately
|
| 329 |
+
// does not attempt that β see `routeNote` and the mailbox's tier analysis
|
| 330 |
+
// (Google's route matrix bills per element: a 30-stop run is ~$4.50, a full
|
| 331 |
+
// 1,550-customer matrix ~$12,000).
|
| 332 |
+
|
| 333 |
+
/** A stop, in the coordinates the host geocoded β never projected units. */
|
| 334 |
+
export interface GeoStop {
|
| 335 |
+
lat: number;
|
| 336 |
+
lon: number;
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
/**
|
| 340 |
+
* β THE SEAM. Everything below takes distance as a FUNCTION and knows nothing
|
| 341 |
+
* else about it. Swapping in real road distances later (a self-hosted OSRM
|
| 342 |
+
* matrix, precomputed and cached) is then a one-line change at the call site
|
| 343 |
+
* rather than a rewrite of the sequencer.
|
| 344 |
+
*/
|
| 345 |
+
export type StopDistance = (a: GeoStop, b: GeoStop) => number;
|
| 346 |
+
|
| 347 |
+
const EARTH_R_KM = 6371.0088;
|
| 348 |
+
|
| 349 |
+
/**
|
| 350 |
+
* Great-circle distance in km.
|
| 351 |
+
*
|
| 352 |
+
* β MUST be computed on lon/lat, NOT as euclidean distance in projected WORLD
|
| 353 |
+
* units. Mercator stretches by 1/cos(latitude): across this book's range
|
| 354 |
+
* (lat 25-49) that is a 0.91 -> 0.66 swing, ~38%, which systematically ranks
|
| 355 |
+
* north-south pairs against east-west ones. The resulting route looks entirely
|
| 356 |
+
* plausible and is wrong, which is the worst kind of wrong.
|
| 357 |
+
*/
|
| 358 |
+
export const haversineKm: StopDistance = (a, b) => {
|
| 359 |
+
const rad = Math.PI / 180;
|
| 360 |
+
const dLat = (b.lat - a.lat) * rad;
|
| 361 |
+
const dLon = (b.lon - a.lon) * rad;
|
| 362 |
+
const s =
|
| 363 |
+
Math.sin(dLat / 2) ** 2 +
|
| 364 |
+
Math.cos(a.lat * rad) * Math.cos(b.lat * rad) * Math.sin(dLon / 2) ** 2;
|
| 365 |
+
return 2 * EARTH_R_KM * Math.asin(Math.min(1, Math.sqrt(s)));
|
| 366 |
+
};
|
| 367 |
+
|
| 368 |
+
/** Total length of a tour. `roundTrip` adds the closing edge back to the start. */
|
| 369 |
+
export function tourLength(
|
| 370 |
+
order: number[], stops: GeoStop[], dist: StopDistance, roundTrip = false
|
| 371 |
+
): number {
|
| 372 |
+
if (order.length < 2) return 0;
|
| 373 |
+
let km = 0;
|
| 374 |
+
for (let i = 1; i < order.length; i++) km += dist(stops[order[i - 1]], stops[order[i]]);
|
| 375 |
+
if (roundTrip) km += dist(stops[order[order.length - 1]], stops[order[0]]);
|
| 376 |
+
return km;
|
| 377 |
+
}
|
| 378 |
+
|
| 379 |
+
/** Greedy construction: from `start`, repeatedly hop to the nearest unvisited stop. */
|
| 380 |
+
export function nearestNeighbourOrder(
|
| 381 |
+
stops: GeoStop[], dist: StopDistance, start = 0
|
| 382 |
+
): number[] {
|
| 383 |
+
const n = stops.length;
|
| 384 |
+
if (n === 0) return [];
|
| 385 |
+
const from = Math.max(0, Math.min(n - 1, Math.round(start) || 0));
|
| 386 |
+
const seen = new Array<boolean>(n).fill(false);
|
| 387 |
+
const order = [from];
|
| 388 |
+
seen[from] = true;
|
| 389 |
+
for (let k = 1; k < n; k++) {
|
| 390 |
+
const last = order[order.length - 1];
|
| 391 |
+
let best = -1;
|
| 392 |
+
let bestD = Infinity;
|
| 393 |
+
for (let i = 0; i < n; i++) {
|
| 394 |
+
if (seen[i]) continue;
|
| 395 |
+
const d = dist(stops[last], stops[i]);
|
| 396 |
+
if (d < bestD) { bestD = d; best = i; }
|
| 397 |
+
}
|
| 398 |
+
if (best < 0) break;
|
| 399 |
+
seen[best] = true;
|
| 400 |
+
order.push(best);
|
| 401 |
+
}
|
| 402 |
+
return order;
|
| 403 |
+
}
|
| 404 |
+
|
| 405 |
+
/**
|
| 406 |
+
* 2-opt: repeatedly reverse a segment when doing so shortens the tour.
|
| 407 |
+
*
|
| 408 |
+
* Index 0 is PINNED β it is the origin the user chose, and silently re-rooting
|
| 409 |
+
* their route would be a worse bug than a slightly longer one. Only strictly
|
| 410 |
+
* improving moves are accepted, which is what makes "never returns a tour
|
| 411 |
+
* longer than the one it was given" a guarantee the gate can assert rather than
|
| 412 |
+
* a hope.
|
| 413 |
+
*/
|
| 414 |
+
export function twoOptOrder(
|
| 415 |
+
order: number[], stops: GeoStop[], dist: StopDistance,
|
| 416 |
+
roundTrip = false, maxPasses = 24
|
| 417 |
+
): number[] {
|
| 418 |
+
const n = order.length;
|
| 419 |
+
const cur = order.slice();
|
| 420 |
+
if (n < 4) return cur;
|
| 421 |
+
const D = (a: number, b: number) => dist(stops[a], stops[b]);
|
| 422 |
+
for (let pass = 0; pass < maxPasses; pass++) {
|
| 423 |
+
let improved = false;
|
| 424 |
+
for (let i = 1; i < n - 1; i++) {
|
| 425 |
+
for (let j = i + 1; j < n; j++) {
|
| 426 |
+
const a = cur[i - 1], b = cur[i], c = cur[j];
|
| 427 |
+
let delta: number;
|
| 428 |
+
if (j === n - 1 && !roundTrip) {
|
| 429 |
+
// Reversing the tail of an OPEN path only re-hangs the entry edge:
|
| 430 |
+
// there is no closing edge to pay for.
|
| 431 |
+
delta = D(a, c) - D(a, b);
|
| 432 |
+
} else {
|
| 433 |
+
const d = cur[(j + 1) % n];
|
| 434 |
+
delta = D(a, c) + D(b, d) - D(a, b) - D(c, d);
|
| 435 |
+
}
|
| 436 |
+
if (delta < -1e-9) {
|
| 437 |
+
for (let lo = i, hi = j; lo < hi; lo++, hi--) {
|
| 438 |
+
const t = cur[lo]; cur[lo] = cur[hi]; cur[hi] = t;
|
| 439 |
+
}
|
| 440 |
+
improved = true;
|
| 441 |
+
}
|
| 442 |
+
}
|
| 443 |
+
}
|
| 444 |
+
if (!improved) break;
|
| 445 |
+
}
|
| 446 |
+
return cur;
|
| 447 |
+
}
|
| 448 |
+
|
| 449 |
+
/** Construct then improve. Returns the visit order and its length. */
|
| 450 |
+
export function planRoute(
|
| 451 |
+
stops: GeoStop[], dist: StopDistance,
|
| 452 |
+
opts: { start?: number; roundTrip?: boolean } = {}
|
| 453 |
+
): { order: number[]; km: number } {
|
| 454 |
+
const roundTrip = !!opts.roundTrip;
|
| 455 |
+
if (stops.length === 0) return { order: [], km: 0 };
|
| 456 |
+
const nn = nearestNeighbourOrder(stops, dist, opts.start ?? 0);
|
| 457 |
+
const order = twoOptOrder(nn, stops, dist, roundTrip);
|
| 458 |
+
return { order, km: tourLength(order, stops, dist, roundTrip) };
|
| 459 |
+
}
|
| 460 |
+
|
| 461 |
+
// --------------------------------------------------- handing the route over
|
| 462 |
+
//
|
| 463 |
+
// MEASURED 2026-07-29, do not re-derive: Google Maps URLs need NO API key and
|
| 464 |
+
// cost NOTHING, but they carry at most 9 waypoints on desktop and 3 on mobile
|
| 465 |
+
// browsers, inside a 2,048-character URL.
|
| 466 |
+
|
| 467 |
+
export const ROUTE_WAYPOINTS_DESKTOP = 9;
|
| 468 |
+
export const ROUTE_WAYPOINTS_MOBILE = 3;
|
| 469 |
+
export const MAX_MAPS_URL = 2048;
|
| 470 |
+
|
| 471 |
+
/** How many STOPS can ride the free URL: the waypoints plus the two endpoints
|
| 472 |
+
* (a round trip returns to its origin, so the origin is not also a waypoint). */
|
| 473 |
+
export function routeStopCap(coarsePointer: boolean, roundTrip = false): number {
|
| 474 |
+
const w = coarsePointer ? ROUTE_WAYPOINTS_MOBILE : ROUTE_WAYPOINTS_DESKTOP;
|
| 475 |
+
return roundTrip ? w + 1 : w + 2;
|
| 476 |
+
}
|
| 477 |
+
|
| 478 |
+
/**
|
| 479 |
+
* Build the free Google directions URL for an ORDERED list of stops.
|
| 480 |
+
*
|
| 481 |
+
* Returns `used` alongside the url so the caller can say "first 11 of 23" on
|
| 482 |
+
* screen. It never silently drops a stop; truncation is a fact the UI states
|
| 483 |
+
* ([[no-unverifiable-aggregates]]). Shrinks further if the character budget
|
| 484 |
+
* binds, which it can with a long tail of 6-dp coordinates.
|
| 485 |
+
*/
|
| 486 |
+
export function googleRouteUrl(
|
| 487 |
+
stops: GeoStop[],
|
| 488 |
+
opts: { roundTrip?: boolean; coarsePointer?: boolean } = {}
|
| 489 |
+
): { url: string; used: number } | null {
|
| 490 |
+
if (stops.length < 2) return null;
|
| 491 |
+
const roundTrip = !!opts.roundTrip;
|
| 492 |
+
const at = (s: GeoStop) => `${s.lat.toFixed(6)},${s.lon.toFixed(6)}`;
|
| 493 |
+
let used = Math.min(stops.length, routeStopCap(!!opts.coarsePointer, roundTrip));
|
| 494 |
+
for (;;) {
|
| 495 |
+
const chosen = stops.slice(0, used);
|
| 496 |
+
const origin = chosen[0];
|
| 497 |
+
const dest = roundTrip ? origin : chosen[chosen.length - 1];
|
| 498 |
+
const mids = roundTrip ? chosen.slice(1) : chosen.slice(1, -1);
|
| 499 |
+
const url =
|
| 500 |
+
`https://www.google.com/maps/dir/?api=1&origin=${at(origin)}` +
|
| 501 |
+
`&destination=${at(dest)}` +
|
| 502 |
+
(mids.length ? `&waypoints=${mids.map(at).join("|")}` : "") +
|
| 503 |
+
`&travelmode=driving`;
|
| 504 |
+
if (url.length <= MAX_MAPS_URL || used <= 2) return { url, used };
|
| 505 |
+
used -= 1;
|
| 506 |
+
}
|
| 507 |
+
}
|
| 508 |
+
|
| 509 |
+
/** An svg's own bounding box in client px β the `getBoundingClientRect()` half
|
| 510 |
+
* of the conversion below, taken as plain data so the maths stays testable
|
| 511 |
+
* without a DOM. */
|
| 512 |
+
export interface FrameRect {
|
| 513 |
+
left: number;
|
| 514 |
+
top: number;
|
| 515 |
+
width: number;
|
| 516 |
+
height: number;
|
| 517 |
+
}
|
| 518 |
+
|
| 519 |
+
/**
|
| 520 |
+
* Client px -> the svg's own user-space coords. Every pointer gesture on the
|
| 521 |
+
* map β marquee, rubber band, cursor-anchored wheel zoom β starts here.
|
| 522 |
+
*
|
| 523 |
+
* β It is NOT `(client / frame) * viewBox`. MapView paints with
|
| 524 |
+
* `preserveAspectRatio="xMidYMid meet"`, so the viewBox is scaled UNIFORMLY by
|
| 525 |
+
* the tighter of the two axes and then CENTRED, leaving a letterbox band on the
|
| 526 |
+
* other axis. Wave 8 (`f6f45a6`) bolted a stretch-to-fill conversion onto that
|
| 527 |
+
* `meet` svg: the binding axis came out right and the other carried BOTH a
|
| 528 |
+
* wrong scale and a missing offset. On a 1400x600 frame the full 0..1000
|
| 529 |
+
* x-range collapsed into ~154..846 β so a marquee at either edge caught
|
| 530 |
+
* NOTHING, the rubber band lagged the cursor by ~150 px, and cursor-anchored
|
| 531 |
+
* zoom drifted, all from this one function. `zoomAt` and the hit test were
|
| 532 |
+
* always correct; they were being handed the wrong point.
|
| 533 |
+
*
|
| 534 |
+
* β The result is deliberately NOT clamped to the viewBox. A drag that begins
|
| 535 |
+
* in the letterbox band is a real gesture β everything from the painted edge
|
| 536 |
+
* inward must still be caught β and clamping re-breaks exactly the edge
|
| 537 |
+
* marquee this exists to fix.
|
| 538 |
+
*/
|
| 539 |
+
export function clientToUser(
|
| 540 |
+
clientX: number,
|
| 541 |
+
clientY: number,
|
| 542 |
+
rect: FrameRect,
|
| 543 |
+
viewW: number,
|
| 544 |
+
viewH: number
|
| 545 |
+
): Pt {
|
| 546 |
+
const s = Math.min(rect.width / viewW, rect.height / viewH);
|
| 547 |
+
if (!(s > 0) || !Number.isFinite(s)) return { x: 0, y: 0 };
|
| 548 |
+
const offX = (rect.width - viewW * s) / 2;
|
| 549 |
+
const offY = (rect.height - viewH * s) / 2;
|
| 550 |
+
return { x: (clientX - rect.left - offX) / s, y: (clientY - rect.top - offY) / s };
|
| 551 |
+
}
|
| 552 |
+
|
| 553 |
+
/**
|
| 554 |
+
* Is `p` inside the closed polygon `poly`? Even-odd ray casting (the crossing
|
| 555 |
+
* number), in the same user-space units `toScreen` returns.
|
| 556 |
+
*
|
| 557 |
+
* β It is NOT a bounding-box test, and that difference IS the feature. A lasso
|
| 558 |
+
* drawn as a C or a horseshoe must EXCLUDE whatever sits in its mouth β
|
| 559 |
+
* otherwise it is the rectangle marquee wearing a lasso's name, which is the
|
| 560 |
+
* one thing a person drawing a loop by hand would never expect. The gate
|
| 561 |
+
* asserts exactly that case, over a shape whose bounding box gives a different
|
| 562 |
+
* answer: a convex test polygon would make the control inert.
|
| 563 |
+
*
|
| 564 |
+
* The ray is cast along +x from `p`, and each edge that straddles `p.y` and
|
| 565 |
+
* crosses to the LEFT of nothing / RIGHT of `p.x` flips the parity. A point
|
| 566 |
+
* exactly on a vertex or an edge may fall either way: this selects pins under a
|
| 567 |
+
* hand-drawn path, where a half-pixel tie carries no meaning and an epsilon to
|
| 568 |
+
* break it would be a number nobody could justify.
|
| 569 |
+
*/
|
| 570 |
+
export function pointInPolygon(p: Pt, poly: Pt[]): boolean {
|
| 571 |
+
if (poly.length < 3) return false;
|
| 572 |
+
let inside = false;
|
| 573 |
+
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
|
| 574 |
+
const a = poly[i];
|
| 575 |
+
const b = poly[j];
|
| 576 |
+
const straddles = a.y > p.y !== b.y > p.y;
|
| 577 |
+
if (straddles && p.x < ((b.x - a.x) * (p.y - a.y)) / (b.y - a.y) + a.x) inside = !inside;
|
| 578 |
+
}
|
| 579 |
+
return inside;
|
| 580 |
+
}
|
| 581 |
+
|
| 582 |
+
/**
|
| 583 |
+
* The axis-aligned bounds of a freehand path, in `normRect`'s own {x0,y0,x1,y1}
|
| 584 |
+
* shape so one mis-click rule can measure either gesture. An empty path is a
|
| 585 |
+
* zero box rather than an Infinity one β the caller's "did this move at all"
|
| 586 |
+
* test must answer NO, not NaN.
|
| 587 |
+
*/
|
| 588 |
+
export function pathBounds(pts: Pt[]) {
|
| 589 |
+
if (pts.length === 0) return { x0: 0, y0: 0, x1: 0, y1: 0 };
|
| 590 |
+
let x0 = pts[0].x;
|
| 591 |
+
let y0 = pts[0].y;
|
| 592 |
+
let x1 = pts[0].x;
|
| 593 |
+
let y1 = pts[0].y;
|
| 594 |
+
for (const p of pts) {
|
| 595 |
+
if (p.x < x0) x0 = p.x;
|
| 596 |
+
if (p.x > x1) x1 = p.x;
|
| 597 |
+
if (p.y < y0) y0 = p.y;
|
| 598 |
+
if (p.y > y1) y1 = p.y;
|
| 599 |
+
}
|
| 600 |
+
return { x0, y0, x1, y1 };
|
| 601 |
+
}
|
| 602 |
+
|
| 603 |
+
/** Screen-space rect (any two corners) -> normalized {x0,y0,x1,y1}. */
|
| 604 |
+
export function normRect(ax: number, ay: number, bx: number, by: number) {
|
| 605 |
+
return {
|
| 606 |
+
x0: Math.min(ax, bx),
|
| 607 |
+
y0: Math.min(ay, by),
|
| 608 |
+
x1: Math.max(ax, bx),
|
| 609 |
+
y1: Math.max(ay, by),
|
| 610 |
+
};
|
| 611 |
+
}
|
| 612 |
+
|
| 613 |
+
/**
|
| 614 |
+
* Bubble radius for a value under a sqrt scale (I5). AREA is proportional to
|
| 615 |
+
* the value, which is the only honest way to size a circle β radius-proportional
|
| 616 |
+
* bubbles overstate large values by the square, the classic bubble-chart lie.
|
| 617 |
+
* `null`/non-finite gets `rNull`: a value-less row is drawn small, never hidden
|
| 618 |
+
* and never faked (rule 8b).
|
| 619 |
+
*/
|
| 620 |
+
export function bubbleRadius(
|
| 621 |
+
v: number | null,
|
| 622 |
+
min: number,
|
| 623 |
+
max: number,
|
| 624 |
+
rMin: number,
|
| 625 |
+
rMax: number,
|
| 626 |
+
rNull: number
|
| 627 |
+
): number {
|
| 628 |
+
if (v == null || !Number.isFinite(v)) return rNull;
|
| 629 |
+
if (!(max > min)) return (rMin + rMax) / 2;
|
| 630 |
+
const t = Math.max(0, Math.min(1, (v - min) / (max - min)));
|
| 631 |
+
return Math.sqrt(rMin * rMin + t * (rMax * rMax - rMin * rMin));
|
| 632 |
+
}
|
| 633 |
+
|
| 634 |
+
/**
|
| 635 |
+
* W37-T34 / contract C7 β ONE cell holding BOTH numbers: `"40.712780,-74.006110"`.
|
| 636 |
+
*
|
| 637 |
+
* β IT LIVES HERE AND NOT IN MapView.tsx, AND THAT PLACEMENT IS THE POINT. `MapView.tsx`
|
| 638 |
+
* imports `./cells`, which imports glide, so nothing in it can be loaded by a node gate: a pure
|
| 639 |
+
* function stranded there is unreachable by every one of this gate's compiled-artifact
|
| 640 |
+
* mutations. That is the wave-29 scar verbatim, where a client-px conversion living in the .tsx
|
| 641 |
+
* shipped broken and green for four waves. Amendment A15 moved this file into lane D's fence
|
| 642 |
+
* precisely so this function could sit where a test can reach it.
|
| 643 |
+
*
|
| 644 |
+
* β WHY A PAIR IN ONE CELL RATHER THAN TWO COLUMNS. The map was built for the customers table,
|
| 645 |
+
* which happens to carry `lat` and `lon` as separate mirrored columns, and reading those two names
|
| 646 |
+
* directly is the whole reason a map could not be opened on anything else. The geocode field (R4)
|
| 647 |
+
* writes a coordinate as a VALUE, so it is one cell like every other cell: sortable, exportable,
|
| 648 |
+
* readable by a formula, and copyable between databases. Two columns would have made the map's
|
| 649 |
+
* schema a requirement on every table that ever wants a pin.
|
| 650 |
+
*
|
| 651 |
+
* Tolerant on the way IN and strict on the way OUT: any precision, an optional space after the
|
| 652 |
+
* comma, and a leading plus are all accepted because a person will type them, while anything off
|
| 653 |
+
* the globe is refused rather than clamped. Clamping would put a pin at a real place that the data
|
| 654 |
+
* never named, which is worse than no pin ([[no-unverifiable-aggregates]]).
|
| 655 |
+
*/
|
| 656 |
+
export function parseLatLon(v: unknown): { lat: number; lon: number } | null {
|
| 657 |
+
if (v == null) return null;
|
| 658 |
+
const s = String(v).trim();
|
| 659 |
+
if (!s) return null;
|
| 660 |
+
const parts = s.split(",");
|
| 661 |
+
if (parts.length !== 2) return null;
|
| 662 |
+
const lat = Number(parts[0].trim());
|
| 663 |
+
const lon = Number(parts[1].trim());
|
| 664 |
+
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
| 665 |
+
if (Math.abs(lat) > 90 || Math.abs(lon) > 180) return null;
|
| 666 |
+
return { lat, lon };
|
| 667 |
+
}
|
| 668 |
+
|
| 669 |
+
/** Minutes as a person says them. 95 reads as "1 h 35 m"; nobody plans a day in minutes past an
|
| 670 |
+
* hour, and "95 min" is a number you have to do arithmetic on before it means anything. */
|
| 671 |
+
export function formatDuration(minutes: number): string {
|
| 672 |
+
const m = Math.max(0, Math.round(minutes));
|
| 673 |
+
if (m < 60) return `${m} min`;
|
| 674 |
+
const h = Math.floor(m / 60);
|
| 675 |
+
const rest = m % 60;
|
| 676 |
+
return rest === 0 ? `${h} h` : `${h} h ${rest} min`;
|
| 677 |
+
}
|
web/src/customer-grid/scriptView.css
CHANGED
|
@@ -1,479 +1,509 @@
|
|
| 1 |
-
/* customer-grid/scriptView.css β owner item 6, the code-script View.
|
| 2 |
-
*
|
| 3 |
-
* β MODULE-LOCAL BY RULING, not by preference. `index.css` is a 12k-line shared file and this
|
| 4 |
-
* wave's PRD hands it to ONE session; a new surface that grew it would be three lanes editing the
|
| 5 |
-
* monolith at once. It is also item 13's smallest real win: the monolith stops growing.
|
| 6 |
-
* β Colours come from the design tokens (navy #1F4E78 + gold #C8A24B, DESIGN.md), never from
|
| 7 |
-
* fresh hexes: a surface that invents its own palette is the thing the constitution exists to
|
| 8 |
-
* prevent. Only the tokens this file actually uses are named. */
|
| 9 |
-
|
| 10 |
-
.cg-script {
|
| 11 |
-
display: flex;
|
| 12 |
-
flex-direction: column;
|
| 13 |
-
height: 100%;
|
| 14 |
-
min-height: 0;
|
| 15 |
-
background: var(--lp-surface, #ffffff);
|
| 16 |
-
}
|
| 17 |
-
|
| 18 |
-
/* The two halves the owner asked for by name: "the code AND the dashboard output of course."
|
| 19 |
-
Side by side above 900px, stacked below it, and the SPLIT is the point either way. */
|
| 20 |
-
.cg-script-body {
|
| 21 |
-
display: grid;
|
| 22 |
-
grid-template-columns: minmax(280px, 5fr) minmax(320px, 7fr);
|
| 23 |
-
gap: 1px;
|
| 24 |
-
flex: 1 1 auto;
|
| 25 |
-
min-height: 0;
|
| 26 |
-
background: var(--lp-border, #e4e7ec);
|
| 27 |
-
}
|
| 28 |
-
@media (max-width: 900px) {
|
| 29 |
-
.cg-script-body { grid-template-columns: 1fr; grid-template-rows: minmax(180px, 2fr) 3fr; }
|
| 30 |
-
}
|
| 31 |
-
|
| 32 |
-
.cg-script-pane {
|
| 33 |
-
display: flex;
|
| 34 |
-
flex-direction: column;
|
| 35 |
-
min-width: 0;
|
| 36 |
-
min-height: 0;
|
| 37 |
-
background: var(--lp-surface, #ffffff);
|
| 38 |
-
}
|
| 39 |
-
|
| 40 |
-
.cg-script-pane-head {
|
| 41 |
-
display: flex;
|
| 42 |
-
align-items: center;
|
| 43 |
-
gap: 8px;
|
| 44 |
-
padding: 8px 12px;
|
| 45 |
-
border-bottom: 1px solid var(--lp-border, #e4e7ec);
|
| 46 |
-
font: 500 var(--lp-fs-2xs)/1.4 Inter, system-ui, sans-serif;
|
| 47 |
-
color: var(--lp-text-muted, #667085);
|
| 48 |
-
}
|
| 49 |
-
.cg-script-pane-head .cg-script-spacer { flex: 1 1 auto; }
|
| 50 |
-
|
| 51 |
-
/* β A textarea, not a highlighted editor. A syntax highlighter is a second parser of the same
|
| 52 |
-
source, and the two disagree the first time either learns a token; the owner asked to SEE the
|
| 53 |
-
code, which a monospaced pane does. */
|
| 54 |
-
.cg-script-code {
|
| 55 |
-
flex: 1 1 auto;
|
| 56 |
-
min-height: 0;
|
| 57 |
-
width: 100%;
|
| 58 |
-
resize: none;
|
| 59 |
-
border: 0;
|
| 60 |
-
outline: none;
|
| 61 |
-
padding: 12px;
|
| 62 |
-
font: 400 var(--lp-fs-xs)/1.6 "SFMono-Regular", Menlo, Consolas, monospace;
|
| 63 |
-
color: var(--lp-text, #1d2939);
|
| 64 |
-
background: var(--lp-surface-sunken, #fbfcfd);
|
| 65 |
-
tab-size: 4;
|
| 66 |
-
white-space: pre;
|
| 67 |
-
overflow: auto;
|
| 68 |
-
}
|
| 69 |
-
.cg-script-code:focus-visible { box-shadow: inset 0 0 0 2px var(--lp-navy, #1f4e78); }
|
| 70 |
-
|
| 71 |
-
.cg-script-out {
|
| 72 |
-
flex: 1 1 auto;
|
| 73 |
-
min-height: 0;
|
| 74 |
-
overflow: auto;
|
| 75 |
-
padding: 14px 16px;
|
| 76 |
-
}
|
| 77 |
-
|
| 78 |
-
.cg-script-run {
|
| 79 |
-
display: inline-flex;
|
| 80 |
-
align-items: center;
|
| 81 |
-
gap: 6px;
|
| 82 |
-
height: 26px;
|
| 83 |
-
padding: 0 12px;
|
| 84 |
-
border: 0;
|
| 85 |
-
border-radius: 6px;
|
| 86 |
-
background: var(--lp-navy, #1f4e78);
|
| 87 |
-
color: #ffffff;
|
| 88 |
-
font: 500 var(--lp-fs-2xs)/1 Inter, system-ui, sans-serif;
|
| 89 |
-
cursor: pointer;
|
| 90 |
-
}
|
| 91 |
-
.cg-script-run:disabled { opacity: 0.55; cursor: default; }
|
| 92 |
-
.cg-script-ghost {
|
| 93 |
-
height: 26px;
|
| 94 |
-
padding: 0 10px;
|
| 95 |
-
border: 1px solid var(--lp-border, #e4e7ec);
|
| 96 |
-
border-radius: 6px;
|
| 97 |
-
background: transparent;
|
| 98 |
-
color: var(--lp-text, #1d2939);
|
| 99 |
-
font: 500 var(--lp-fs-2xs)/1 Inter, system-ui, sans-serif;
|
| 100 |
-
cursor: pointer;
|
| 101 |
-
}
|
| 102 |
-
.cg-script-ghost:disabled { opacity: 0.5; cursor: default; }
|
| 103 |
-
|
| 104 |
-
.cg-script-meta {
|
| 105 |
-
font: 400 var(--lp-fs-3xs)/1.5 Inter, system-ui, sans-serif;
|
| 106 |
-
color: var(--lp-text-muted, #667085);
|
| 107 |
-
}
|
| 108 |
-
|
| 109 |
-
/* A refusal is CONTENT, not a toast: the reader asked a question and this is the answer, so it
|
| 110 |
-
stays on screen next to the code that caused it. */
|
| 111 |
-
.cg-script-refusal {
|
| 112 |
-
border: 1px solid var(--lp-border, #e4e7ec);
|
| 113 |
-
border-left: 3px solid var(--lp-gold, #c8a24b);
|
| 114 |
-
border-radius: 6px;
|
| 115 |
-
padding: 12px 14px;
|
| 116 |
-
background: var(--lp-surface-sunken, #fbfcfd);
|
| 117 |
-
}
|
| 118 |
-
.cg-script-refusal h4 {
|
| 119 |
-
margin: 0 0 6px;
|
| 120 |
-
font: 600 var(--lp-fs-xs)/1.4 Inter, system-ui, sans-serif;
|
| 121 |
-
color: var(--lp-text, #1d2939);
|
| 122 |
-
}
|
| 123 |
-
.cg-script-refusal p { margin: 0 0 4px; font: 400 var(--lp-fs-xs)/1.6 Inter, system-ui, sans-serif; }
|
| 124 |
-
.cg-script-refusal dl {
|
| 125 |
-
margin: 8px 0 0;
|
| 126 |
-
display: grid;
|
| 127 |
-
grid-template-columns: auto 1fr;
|
| 128 |
-
gap: 2px 10px;
|
| 129 |
-
font: 400 var(--lp-fs-2xs)/1.5 Inter, system-ui, sans-serif;
|
| 130 |
-
}
|
| 131 |
-
.cg-script-refusal dt { color: var(--lp-text-muted, #667085); }
|
| 132 |
-
.cg-script-refusal dd { margin: 0; }
|
| 133 |
-
|
| 134 |
-
.cg-script-title {
|
| 135 |
-
margin: 0 0 10px;
|
| 136 |
-
font: 600 var(--lp-fs-sm)/1.4 Inter, system-ui, sans-serif;
|
| 137 |
-
color: var(--lp-text, #1d2939);
|
| 138 |
-
}
|
| 139 |
-
|
| 140 |
-
.cg-script-table { width: 100%; border-collapse: collapse; }
|
| 141 |
-
.cg-script-table th,
|
| 142 |
-
.cg-script-table td {
|
| 143 |
-
border-bottom: 1px solid var(--lp-border, #e4e7ec);
|
| 144 |
-
padding: 6px 10px;
|
| 145 |
-
text-align: left;
|
| 146 |
-
font: 400 var(--lp-fs-xs)/1.5 Inter, system-ui, sans-serif;
|
| 147 |
-
vertical-align: top;
|
| 148 |
-
}
|
| 149 |
-
.cg-script-table th {
|
| 150 |
-
font-weight: 600;
|
| 151 |
-
color: var(--lp-text-muted, #667085);
|
| 152 |
-
position: sticky;
|
| 153 |
-
top: 0;
|
| 154 |
-
background: var(--lp-surface, #ffffff);
|
| 155 |
-
}
|
| 156 |
-
|
| 157 |
-
.cg-script-metrics {
|
| 158 |
-
display: grid;
|
| 159 |
-
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
| 160 |
-
gap: 10px;
|
| 161 |
-
}
|
| 162 |
-
.cg-script-metric {
|
| 163 |
-
border: 1px solid var(--lp-border, #e4e7ec);
|
| 164 |
-
border-radius: 8px;
|
| 165 |
-
padding: 10px 12px;
|
| 166 |
-
}
|
| 167 |
-
.cg-script-metric-label {
|
| 168 |
-
font: 500 var(--lp-fs-3xs)/1.4 Inter, system-ui, sans-serif;
|
| 169 |
-
color: var(--lp-text-muted, #667085);
|
| 170 |
-
}
|
| 171 |
-
.cg-script-metric-value {
|
| 172 |
-
margin-top: 4px;
|
| 173 |
-
font: 600 var(--lp-fs-lg)/1.2 Inter, system-ui, sans-serif;
|
| 174 |
-
color: var(--lp-text, #1d2939);
|
| 175 |
-
}
|
| 176 |
-
.cg-script-metric-note {
|
| 177 |
-
margin-top: 2px;
|
| 178 |
-
font: 400 var(--lp-fs-3xs)/1.4 Inter, system-ui, sans-serif;
|
| 179 |
-
color: var(--lp-text-muted, #667085);
|
| 180 |
-
}
|
| 181 |
-
|
| 182 |
-
.cg-script-bars { display: flex; flex-direction: column; gap: 8px; }
|
| 183 |
-
.cg-script-bar { display: grid; grid-template-columns: minmax(80px, 22%) 1fr auto; gap: 10px;
|
| 184 |
-
align-items: center; font: 400 var(--lp-fs-xs)/1.4 Inter, system-ui, sans-serif; }
|
| 185 |
-
.cg-script-bar-track {
|
| 186 |
-
height: 10px;
|
| 187 |
-
border-radius: 5px;
|
| 188 |
-
background: var(--lp-surface-sunken, #f2f4f7);
|
| 189 |
-
overflow: hidden;
|
| 190 |
-
}
|
| 191 |
-
.cg-script-bar-fill { height: 100%; border-radius: 5px; background: var(--lp-navy, #1f4e78); }
|
| 192 |
-
.cg-script-bar-value { color: var(--lp-text-muted, #667085); font-variant-numeric: tabular-nums; }
|
| 193 |
-
|
| 194 |
-
.cg-script-text {
|
| 195 |
-
margin: 0;
|
| 196 |
-
font: 400 var(--lp-fs-xs)/1.7 Inter, system-ui, sans-serif;
|
| 197 |
-
color: var(--lp-text, #1d2939);
|
| 198 |
-
white-space: pre-wrap;
|
| 199 |
-
word-break: break-word;
|
| 200 |
-
}
|
| 201 |
-
|
| 202 |
-
.cg-script-stdout {
|
| 203 |
-
margin: 14px 0 0;
|
| 204 |
-
padding: 10px 12px;
|
| 205 |
-
border-radius: 6px;
|
| 206 |
-
background: var(--lp-surface-sunken, #fbfcfd);
|
| 207 |
-
border: 1px solid var(--lp-border, #e4e7ec);
|
| 208 |
-
font: 400 var(--lp-fs-3xs)/1.6 "SFMono-Regular", Menlo, Consolas, monospace;
|
| 209 |
-
color: var(--lp-text-muted, #667085);
|
| 210 |
-
white-space: pre-wrap;
|
| 211 |
-
word-break: break-word;
|
| 212 |
-
max-height: 220px;
|
| 213 |
-
overflow: auto;
|
| 214 |
-
}
|
| 215 |
-
|
| 216 |
-
.cg-script-empty {
|
| 217 |
-
font: 400 var(--lp-fs-xs)/1.6 Inter, system-ui, sans-serif;
|
| 218 |
-
color: var(--lp-text-muted, #667085);
|
| 219 |
-
}
|
| 220 |
-
/* The whole-panel empty state needs its own padding: there is no `.cg-script-out` around it.
|
| 221 |
-
Kept HERE rather than inline, so the renderer's one and only computed style stays the bar
|
| 222 |
-
width, which is arithmetic on a validated number and is asserted as the only one. */
|
| 223 |
-
.cg-script-empty--pad { padding: 16px; }
|
| 224 |
-
|
| 225 |
-
/* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 226 |
-
W36-T05 β THE VIEW AGENT PANEL, left of the View navigation.
|
| 227 |
-
|
| 228 |
-
β `.cg-agent-panel` IS A CROSS-FENCE CLASS NAME (wiring W8, F's DONE B-3). `index.css` folds
|
| 229 |
-
the main rail on `.shell-root:has(.cg-agent-panel) .shell-side`. Renaming it here silently
|
| 230 |
-
un-folds the rail and leaves 187px of grid at 1440, with nothing going red. Its WIDTH lives
|
| 231 |
-
here because the panel is B's; the FOLD lives in index.css because the rail is F's.
|
| 232 |
-
β ~565px is the reference's own column at 1919px wide, and it is what R14's arithmetic is
|
| 233 |
-
built on: 48 (folded nav) + 565 (this) + 344 (views rail) = 957, leaving 483px of grid.
|
| 234 |
-
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 235 |
-
.cg-agent-panel {
|
| 236 |
-
display: flex;
|
| 237 |
-
flex-direction: column;
|
| 238 |
-
flex: 0 0 565px;
|
| 239 |
-
width: 565px;
|
| 240 |
-
max-width: 46vw;
|
| 241 |
-
min-height: 0;
|
| 242 |
-
border-right: 1px solid var(--lp-border, #e4e7ec);
|
| 243 |
-
background: var(--lp-surface, #ffffff);
|
| 244 |
-
}
|
| 245 |
-
|
| 246 |
-
.cg-agent-head {
|
| 247 |
-
display: flex;
|
| 248 |
-
align-items: center;
|
| 249 |
-
gap: 8px;
|
| 250 |
-
padding: 10px 14px;
|
| 251 |
-
border-bottom: 1px solid var(--lp-border, #e4e7ec);
|
| 252 |
-
}
|
| 253 |
-
.cg-agent-title { font: 600 var(--lp-fs-xs)/1.4 Inter, system-ui, sans-serif; color: var(--lp-text, #1d2939); }
|
| 254 |
-
.cg-agent-spacer { flex: 1 1 auto; }
|
| 255 |
-
.cg-agent-icon {
|
| 256 |
-
display: inline-flex;
|
| 257 |
-
align-items: center;
|
| 258 |
-
justify-content: center;
|
| 259 |
-
width: 26px;
|
| 260 |
-
height: 26px;
|
| 261 |
-
border: 0;
|
| 262 |
-
border-radius: 6px;
|
| 263 |
-
background: transparent;
|
| 264 |
-
color: var(--lp-text-muted, #667085);
|
| 265 |
-
cursor: pointer;
|
| 266 |
-
}
|
| 267 |
-
.cg-agent-icon:hover { background: var(--lp-surface-sunken, #f2f4f7); }
|
| 268 |
-
|
| 269 |
-
.cg-agent-body { flex: 1 1 auto; min-height: 0; overflow: auto; padding: 16px; }
|
| 270 |
-
|
| 271 |
-
/* The empty state: centred, one soft mark, one question. Whitespace is the design. */
|
| 272 |
-
.cg-agent-empty {
|
| 273 |
-
height: 100%;
|
| 274 |
-
display: flex;
|
| 275 |
-
flex-direction: column;
|
| 276 |
-
align-items: center;
|
| 277 |
-
justify-content: center;
|
| 278 |
-
gap: 14px;
|
| 279 |
-
}
|
| 280 |
-
.cg-agent-mark {
|
| 281 |
-
width: 54px;
|
| 282 |
-
height: 54px;
|
| 283 |
-
border-radius: 50%;
|
| 284 |
-
border: 2px dotted var(--lp-gold, #c8a24b);
|
| 285 |
-
opacity: 0.55;
|
| 286 |
-
}
|
| 287 |
-
.cg-agent-ask {
|
| 288 |
-
margin: 0;
|
| 289 |
-
font: 500 var(--lp-fs-md)/1.4 Inter, system-ui, sans-serif;
|
| 290 |
-
color: var(--lp-text-muted, #667085);
|
| 291 |
-
}
|
| 292 |
-
|
| 293 |
-
.cg-agent-listhead {
|
| 294 |
-
margin: 0 0 8px;
|
| 295 |
-
font: 500 var(--lp-fs-3xs)/1.4 Inter, system-ui, sans-serif;
|
| 296 |
-
color: var(--lp-text-muted, #667085);
|
| 297 |
-
}
|
| 298 |
-
.cg-agent-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column;
|
| 299 |
-
gap: 4px; }
|
| 300 |
-
/* The suggestion row from the reference: icon tile, bold title, muted subtitle, an arrow right. */
|
| 301 |
-
.cg-agent-row {
|
| 302 |
-
display: flex;
|
| 303 |
-
align-items: center;
|
| 304 |
-
gap: 10px;
|
| 305 |
-
width: 100%;
|
| 306 |
-
padding: 8px 10px;
|
| 307 |
-
border: 0;
|
| 308 |
-
border-radius: 8px;
|
| 309 |
-
background: transparent;
|
| 310 |
-
text-align: left;
|
| 311 |
-
cursor: pointer;
|
| 312 |
-
}
|
| 313 |
-
.cg-agent-row:hover { background: var(--lp-surface-sunken, #f2f4f7); }
|
| 314 |
-
.cg-agent-row-tile {
|
| 315 |
-
display: inline-flex;
|
| 316 |
-
align-items: center;
|
| 317 |
-
justify-content: center;
|
| 318 |
-
width: 28px;
|
| 319 |
-
height: 28px;
|
| 320 |
-
flex: 0 0 28px;
|
| 321 |
-
border-radius: 7px;
|
| 322 |
-
background: var(--lp-surface-sunken, #f2f4f7);
|
| 323 |
-
color: var(--lp-navy, #1f4e78);
|
| 324 |
-
}
|
| 325 |
-
.cg-agent-row-text { display: flex; flex-direction: column; min-width: 0; flex: 1 1 auto; }
|
| 326 |
-
.cg-agent-row-title {
|
| 327 |
-
font: 600 var(--lp-fs-xs)/1.4 Inter, system-ui, sans-serif;
|
| 328 |
-
color: var(--lp-text, #1d2939);
|
| 329 |
-
overflow: hidden;
|
| 330 |
-
text-overflow: ellipsis;
|
| 331 |
-
white-space: nowrap;
|
| 332 |
-
}
|
| 333 |
-
.cg-agent-row-sub {
|
| 334 |
-
font: 400 var(--lp-fs-3xs)/1.4 Inter, system-ui, sans-serif;
|
| 335 |
-
color: var(--lp-text-muted, #667085);
|
| 336 |
-
}
|
| 337 |
-
.cg-agent-row-go { color: var(--lp-text-muted, #667085); }
|
| 338 |
-
|
| 339 |
-
.cg-agent-error {
|
| 340 |
-
margin: 12px 0 0;
|
| 341 |
-
font: 400 var(--lp-fs-xs)/1.6 Inter, system-ui, sans-serif;
|
| 342 |
-
color: var(--lp-text, #1d2939);
|
| 343 |
-
border-left: 3px solid var(--lp-gold, #c8a24b);
|
| 344 |
-
padding-left: 10px;
|
| 345 |
-
}
|
| 346 |
-
|
| 347 |
-
/* ONE card border around the input AND its affordances, which is what the reference does. */
|
| 348 |
-
.cg-agent-composer {
|
| 349 |
-
margin: 0 16px;
|
| 350 |
-
border: 1px solid var(--lp-border, #e4e7ec);
|
| 351 |
-
border-radius: 12px;
|
| 352 |
-
background: var(--lp-surface, #ffffff);
|
| 353 |
-
}
|
| 354 |
-
.cg-agent-input {
|
| 355 |
-
display: block;
|
| 356 |
-
width: 100%;
|
| 357 |
-
border: 0;
|
| 358 |
-
outline: none;
|
| 359 |
-
resize: none;
|
| 360 |
-
padding: 12px 14px 6px;
|
| 361 |
-
background: transparent;
|
| 362 |
-
font: 400 var(--lp-fs-xs)/1.6 Inter, system-ui, sans-serif;
|
| 363 |
-
color: var(--lp-text, #1d2939);
|
| 364 |
-
}
|
| 365 |
-
.cg-agent-composer:focus-within { border-color: var(--lp-navy, #1f4e78); }
|
| 366 |
-
.cg-agent-composer-foot {
|
| 367 |
-
display: flex;
|
| 368 |
-
align-items: center;
|
| 369 |
-
gap: 8px;
|
| 370 |
-
padding: 6px 8px 8px 12px;
|
| 371 |
-
}
|
| 372 |
-
.cg-agent-pill {
|
| 373 |
-
font: 400 var(--lp-fs-3xs)/1.4 Inter, system-ui, sans-serif;
|
| 374 |
-
color: var(--lp-text-muted, #667085);
|
| 375 |
-
border: 1px solid var(--lp-border, #e4e7ec);
|
| 376 |
-
border-radius: 999px;
|
| 377 |
-
padding: 3px 9px;
|
| 378 |
-
}
|
| 379 |
-
.cg-agent-send {
|
| 380 |
-
display: inline-flex;
|
| 381 |
-
align-items: center;
|
| 382 |
-
justify-content: center;
|
| 383 |
-
width: 30px;
|
| 384 |
-
height: 30px;
|
| 385 |
-
border: 0;
|
| 386 |
-
border-radius: 50%;
|
| 387 |
-
background: var(--lp-navy, #1f4e78);
|
| 388 |
-
color: #ffffff;
|
| 389 |
-
cursor: pointer;
|
| 390 |
-
}
|
| 391 |
-
.cg-agent-send:disabled { opacity: 0.4; cursor: default; }
|
| 392 |
-
|
| 393 |
-
.cg-agent-foot {
|
| 394 |
-
margin: 8px 16px 14px;
|
| 395 |
-
font: 400 var(--lp-fs-3xs)/1.5 Inter, system-ui, sans-serif;
|
| 396 |
-
color: var(--lp-text-muted, #667085);
|
| 397 |
-
}
|
| 398 |
-
|
| 399 |
-
/* The robot, beside the views rail's own minimize toggle (W35-T28 keeps that one FIRST). */
|
| 400 |
-
.cg-agent-open {
|
| 401 |
-
display: inline-flex;
|
| 402 |
-
align-items: center;
|
| 403 |
-
justify-content: center;
|
| 404 |
-
width: 26px;
|
| 405 |
-
height: 26px;
|
| 406 |
-
border: 0;
|
| 407 |
-
border-radius: 6px;
|
| 408 |
-
background: transparent;
|
| 409 |
-
color: var(--lp-text-muted, #667085);
|
| 410 |
-
cursor: pointer;
|
| 411 |
-
}
|
| 412 |
-
.cg-agent-open:hover { background: var(--lp-surface-sunken, #f2f4f7); }
|
| 413 |
-
.cg-agent-open.is-on { background: var(--lp-surface-sunken, #f2f4f7); color: var(--lp-navy, #1f4e78); }
|
| 414 |
-
|
| 415 |
-
/* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 416 |
-
W36-T07 β THE FIELD AGENT, inside the column menu's create pane.
|
| 417 |
-
β It reuses the `cg-agent-*` composer above rather than growing a second chat look. What is
|
| 418 |
-
added here is only what a conversation inside a 320px menu needs that a full-height panel
|
| 419 |
-
does not: a bounded scroll, a proposal card, and the two-button accept row.
|
| 420 |
-
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 421 |
-
.cg-fagent { display: flex; flex-direction: column; gap: 10px; min-width: 0; }
|
| 422 |
-
.cg-fagent-body { max-height: 320px; overflow: auto; }
|
| 423 |
-
.cg-fagent-turns { list-style: none; margin: 0 0 10px; padding: 0; display: flex;
|
| 424 |
-
flex-direction: column; gap: 6px; }
|
| 425 |
-
.cg-fagent-turn { display: flex; justify-content: flex-end; }
|
| 426 |
-
.cg-fagent-said {
|
| 427 |
-
max-width: 88%;
|
| 428 |
-
padding: 6px 10px;
|
| 429 |
-
border-radius: 10px 10px 2px 10px;
|
| 430 |
-
background: var(--lp-surface-sunken, #f2f4f7);
|
| 431 |
-
font: 400 var(--lp-fs-2xs)/1.5 Inter, system-ui, sans-serif;
|
| 432 |
-
color: var(--lp-text, #1d2939);
|
| 433 |
-
word-break: break-word;
|
| 434 |
-
}
|
| 435 |
-
.cg-fagent-card {
|
| 436 |
-
border: 1px solid var(--lp-border, #e4e7ec);
|
| 437 |
-
border-left: 3px solid var(--lp-gold, #c8a24b);
|
| 438 |
-
border-radius: 8px;
|
| 439 |
-
padding: 10px 12px;
|
| 440 |
-
}
|
| 441 |
-
.cg-fagent-card-head {
|
| 442 |
-
margin: 0 0 6px;
|
| 443 |
-
font: 600 var(--lp-fs-2xs)/1.4 Inter, system-ui, sans-serif;
|
| 444 |
-
color: var(--lp-text, #1d2939);
|
| 445 |
-
}
|
| 446 |
-
.cg-fagent-def {
|
| 447 |
-
margin: 0;
|
| 448 |
-
display: grid;
|
| 449 |
-
grid-template-columns: auto 1fr;
|
| 450 |
-
gap: 2px 10px;
|
| 451 |
-
font: 400 var(--lp-fs-2xs)/1.5 Inter, system-ui, sans-serif;
|
| 452 |
-
}
|
| 453 |
-
.cg-fagent-def dt { color: var(--lp-text-muted, #667085); }
|
| 454 |
-
.cg-fagent-def dd { margin: 0; color: var(--lp-text, #1d2939); }
|
| 455 |
-
.cg-fagent-why,
|
| 456 |
-
.cg-fagent-missing {
|
| 457 |
-
margin: 8px 0 0;
|
| 458 |
-
padding-left: 16px;
|
| 459 |
-
font: 400 var(--lp-fs-3xs)/1.55 Inter, system-ui, sans-serif;
|
| 460 |
-
color: var(--lp-text-muted, #667085);
|
| 461 |
-
}
|
| 462 |
-
/* What is still OPEN reads differently from what was decided: the reader has to answer these. */
|
| 463 |
-
.cg-fagent-missing { color: var(--lp-text, #1d2939); }
|
| 464 |
-
.cg-fagent-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 10px; }
|
| 465 |
-
/* The agent's door in the create pane. A quiet row, not a second primary button. */
|
| 466 |
-
.cg-fagent-open {
|
| 467 |
-
display: inline-flex;
|
| 468 |
-
align-items: center;
|
| 469 |
-
gap: 6px;
|
| 470 |
-
width: 100%;
|
| 471 |
-
padding: 7px 10px;
|
| 472 |
-
border: 1px dashed var(--lp-border, #e4e7ec);
|
| 473 |
-
border-radius: 8px;
|
| 474 |
-
background: transparent;
|
| 475 |
-
color: var(--lp-text-muted, #667085);
|
| 476 |
-
font: 500 var(--lp-fs-2xs)/1.4 Inter, system-ui, sans-serif;
|
| 477 |
-
cursor: pointer;
|
| 478 |
-
}
|
| 479 |
-
.cg-fagent-open:hover { border-color: var(--lp-navy, #1f4e78); color: var(--lp-navy, #1f4e78); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* customer-grid/scriptView.css β owner item 6, the code-script View.
|
| 2 |
+
*
|
| 3 |
+
* β MODULE-LOCAL BY RULING, not by preference. `index.css` is a 12k-line shared file and this
|
| 4 |
+
* wave's PRD hands it to ONE session; a new surface that grew it would be three lanes editing the
|
| 5 |
+
* monolith at once. It is also item 13's smallest real win: the monolith stops growing.
|
| 6 |
+
* β Colours come from the design tokens (navy #1F4E78 + gold #C8A24B, DESIGN.md), never from
|
| 7 |
+
* fresh hexes: a surface that invents its own palette is the thing the constitution exists to
|
| 8 |
+
* prevent. Only the tokens this file actually uses are named. */
|
| 9 |
+
|
| 10 |
+
.cg-script {
|
| 11 |
+
display: flex;
|
| 12 |
+
flex-direction: column;
|
| 13 |
+
height: 100%;
|
| 14 |
+
min-height: 0;
|
| 15 |
+
background: var(--lp-surface, #ffffff);
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
/* The two halves the owner asked for by name: "the code AND the dashboard output of course."
|
| 19 |
+
Side by side above 900px, stacked below it, and the SPLIT is the point either way. */
|
| 20 |
+
.cg-script-body {
|
| 21 |
+
display: grid;
|
| 22 |
+
grid-template-columns: minmax(280px, 5fr) minmax(320px, 7fr);
|
| 23 |
+
gap: 1px;
|
| 24 |
+
flex: 1 1 auto;
|
| 25 |
+
min-height: 0;
|
| 26 |
+
background: var(--lp-border, #e4e7ec);
|
| 27 |
+
}
|
| 28 |
+
@media (max-width: 900px) {
|
| 29 |
+
.cg-script-body { grid-template-columns: 1fr; grid-template-rows: minmax(180px, 2fr) 3fr; }
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
.cg-script-pane {
|
| 33 |
+
display: flex;
|
| 34 |
+
flex-direction: column;
|
| 35 |
+
min-width: 0;
|
| 36 |
+
min-height: 0;
|
| 37 |
+
background: var(--lp-surface, #ffffff);
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
.cg-script-pane-head {
|
| 41 |
+
display: flex;
|
| 42 |
+
align-items: center;
|
| 43 |
+
gap: 8px;
|
| 44 |
+
padding: 8px 12px;
|
| 45 |
+
border-bottom: 1px solid var(--lp-border, #e4e7ec);
|
| 46 |
+
font: 500 var(--lp-fs-2xs)/1.4 Inter, system-ui, sans-serif;
|
| 47 |
+
color: var(--lp-text-muted, #667085);
|
| 48 |
+
}
|
| 49 |
+
.cg-script-pane-head .cg-script-spacer { flex: 1 1 auto; }
|
| 50 |
+
|
| 51 |
+
/* β A textarea, not a highlighted editor. A syntax highlighter is a second parser of the same
|
| 52 |
+
source, and the two disagree the first time either learns a token; the owner asked to SEE the
|
| 53 |
+
code, which a monospaced pane does. */
|
| 54 |
+
.cg-script-code {
|
| 55 |
+
flex: 1 1 auto;
|
| 56 |
+
min-height: 0;
|
| 57 |
+
width: 100%;
|
| 58 |
+
resize: none;
|
| 59 |
+
border: 0;
|
| 60 |
+
outline: none;
|
| 61 |
+
padding: 12px;
|
| 62 |
+
font: 400 var(--lp-fs-xs)/1.6 "SFMono-Regular", Menlo, Consolas, monospace;
|
| 63 |
+
color: var(--lp-text, #1d2939);
|
| 64 |
+
background: var(--lp-surface-sunken, #fbfcfd);
|
| 65 |
+
tab-size: 4;
|
| 66 |
+
white-space: pre;
|
| 67 |
+
overflow: auto;
|
| 68 |
+
}
|
| 69 |
+
.cg-script-code:focus-visible { box-shadow: inset 0 0 0 2px var(--lp-navy, #1f4e78); }
|
| 70 |
+
|
| 71 |
+
.cg-script-out {
|
| 72 |
+
flex: 1 1 auto;
|
| 73 |
+
min-height: 0;
|
| 74 |
+
overflow: auto;
|
| 75 |
+
padding: 14px 16px;
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
.cg-script-run {
|
| 79 |
+
display: inline-flex;
|
| 80 |
+
align-items: center;
|
| 81 |
+
gap: 6px;
|
| 82 |
+
height: 26px;
|
| 83 |
+
padding: 0 12px;
|
| 84 |
+
border: 0;
|
| 85 |
+
border-radius: 6px;
|
| 86 |
+
background: var(--lp-navy, #1f4e78);
|
| 87 |
+
color: #ffffff;
|
| 88 |
+
font: 500 var(--lp-fs-2xs)/1 Inter, system-ui, sans-serif;
|
| 89 |
+
cursor: pointer;
|
| 90 |
+
}
|
| 91 |
+
.cg-script-run:disabled { opacity: 0.55; cursor: default; }
|
| 92 |
+
.cg-script-ghost {
|
| 93 |
+
height: 26px;
|
| 94 |
+
padding: 0 10px;
|
| 95 |
+
border: 1px solid var(--lp-border, #e4e7ec);
|
| 96 |
+
border-radius: 6px;
|
| 97 |
+
background: transparent;
|
| 98 |
+
color: var(--lp-text, #1d2939);
|
| 99 |
+
font: 500 var(--lp-fs-2xs)/1 Inter, system-ui, sans-serif;
|
| 100 |
+
cursor: pointer;
|
| 101 |
+
}
|
| 102 |
+
.cg-script-ghost:disabled { opacity: 0.5; cursor: default; }
|
| 103 |
+
|
| 104 |
+
.cg-script-meta {
|
| 105 |
+
font: 400 var(--lp-fs-3xs)/1.5 Inter, system-ui, sans-serif;
|
| 106 |
+
color: var(--lp-text-muted, #667085);
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
/* A refusal is CONTENT, not a toast: the reader asked a question and this is the answer, so it
|
| 110 |
+
stays on screen next to the code that caused it. */
|
| 111 |
+
.cg-script-refusal {
|
| 112 |
+
border: 1px solid var(--lp-border, #e4e7ec);
|
| 113 |
+
border-left: 3px solid var(--lp-gold, #c8a24b);
|
| 114 |
+
border-radius: 6px;
|
| 115 |
+
padding: 12px 14px;
|
| 116 |
+
background: var(--lp-surface-sunken, #fbfcfd);
|
| 117 |
+
}
|
| 118 |
+
.cg-script-refusal h4 {
|
| 119 |
+
margin: 0 0 6px;
|
| 120 |
+
font: 600 var(--lp-fs-xs)/1.4 Inter, system-ui, sans-serif;
|
| 121 |
+
color: var(--lp-text, #1d2939);
|
| 122 |
+
}
|
| 123 |
+
.cg-script-refusal p { margin: 0 0 4px; font: 400 var(--lp-fs-xs)/1.6 Inter, system-ui, sans-serif; }
|
| 124 |
+
.cg-script-refusal dl {
|
| 125 |
+
margin: 8px 0 0;
|
| 126 |
+
display: grid;
|
| 127 |
+
grid-template-columns: auto 1fr;
|
| 128 |
+
gap: 2px 10px;
|
| 129 |
+
font: 400 var(--lp-fs-2xs)/1.5 Inter, system-ui, sans-serif;
|
| 130 |
+
}
|
| 131 |
+
.cg-script-refusal dt { color: var(--lp-text-muted, #667085); }
|
| 132 |
+
.cg-script-refusal dd { margin: 0; }
|
| 133 |
+
|
| 134 |
+
.cg-script-title {
|
| 135 |
+
margin: 0 0 10px;
|
| 136 |
+
font: 600 var(--lp-fs-sm)/1.4 Inter, system-ui, sans-serif;
|
| 137 |
+
color: var(--lp-text, #1d2939);
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
.cg-script-table { width: 100%; border-collapse: collapse; }
|
| 141 |
+
.cg-script-table th,
|
| 142 |
+
.cg-script-table td {
|
| 143 |
+
border-bottom: 1px solid var(--lp-border, #e4e7ec);
|
| 144 |
+
padding: 6px 10px;
|
| 145 |
+
text-align: left;
|
| 146 |
+
font: 400 var(--lp-fs-xs)/1.5 Inter, system-ui, sans-serif;
|
| 147 |
+
vertical-align: top;
|
| 148 |
+
}
|
| 149 |
+
.cg-script-table th {
|
| 150 |
+
font-weight: 600;
|
| 151 |
+
color: var(--lp-text-muted, #667085);
|
| 152 |
+
position: sticky;
|
| 153 |
+
top: 0;
|
| 154 |
+
background: var(--lp-surface, #ffffff);
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
.cg-script-metrics {
|
| 158 |
+
display: grid;
|
| 159 |
+
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
| 160 |
+
gap: 10px;
|
| 161 |
+
}
|
| 162 |
+
.cg-script-metric {
|
| 163 |
+
border: 1px solid var(--lp-border, #e4e7ec);
|
| 164 |
+
border-radius: 8px;
|
| 165 |
+
padding: 10px 12px;
|
| 166 |
+
}
|
| 167 |
+
.cg-script-metric-label {
|
| 168 |
+
font: 500 var(--lp-fs-3xs)/1.4 Inter, system-ui, sans-serif;
|
| 169 |
+
color: var(--lp-text-muted, #667085);
|
| 170 |
+
}
|
| 171 |
+
.cg-script-metric-value {
|
| 172 |
+
margin-top: 4px;
|
| 173 |
+
font: 600 var(--lp-fs-lg)/1.2 Inter, system-ui, sans-serif;
|
| 174 |
+
color: var(--lp-text, #1d2939);
|
| 175 |
+
}
|
| 176 |
+
.cg-script-metric-note {
|
| 177 |
+
margin-top: 2px;
|
| 178 |
+
font: 400 var(--lp-fs-3xs)/1.4 Inter, system-ui, sans-serif;
|
| 179 |
+
color: var(--lp-text-muted, #667085);
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
.cg-script-bars { display: flex; flex-direction: column; gap: 8px; }
|
| 183 |
+
.cg-script-bar { display: grid; grid-template-columns: minmax(80px, 22%) 1fr auto; gap: 10px;
|
| 184 |
+
align-items: center; font: 400 var(--lp-fs-xs)/1.4 Inter, system-ui, sans-serif; }
|
| 185 |
+
.cg-script-bar-track {
|
| 186 |
+
height: 10px;
|
| 187 |
+
border-radius: 5px;
|
| 188 |
+
background: var(--lp-surface-sunken, #f2f4f7);
|
| 189 |
+
overflow: hidden;
|
| 190 |
+
}
|
| 191 |
+
.cg-script-bar-fill { height: 100%; border-radius: 5px; background: var(--lp-navy, #1f4e78); }
|
| 192 |
+
.cg-script-bar-value { color: var(--lp-text-muted, #667085); font-variant-numeric: tabular-nums; }
|
| 193 |
+
|
| 194 |
+
.cg-script-text {
|
| 195 |
+
margin: 0;
|
| 196 |
+
font: 400 var(--lp-fs-xs)/1.7 Inter, system-ui, sans-serif;
|
| 197 |
+
color: var(--lp-text, #1d2939);
|
| 198 |
+
white-space: pre-wrap;
|
| 199 |
+
word-break: break-word;
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
.cg-script-stdout {
|
| 203 |
+
margin: 14px 0 0;
|
| 204 |
+
padding: 10px 12px;
|
| 205 |
+
border-radius: 6px;
|
| 206 |
+
background: var(--lp-surface-sunken, #fbfcfd);
|
| 207 |
+
border: 1px solid var(--lp-border, #e4e7ec);
|
| 208 |
+
font: 400 var(--lp-fs-3xs)/1.6 "SFMono-Regular", Menlo, Consolas, monospace;
|
| 209 |
+
color: var(--lp-text-muted, #667085);
|
| 210 |
+
white-space: pre-wrap;
|
| 211 |
+
word-break: break-word;
|
| 212 |
+
max-height: 220px;
|
| 213 |
+
overflow: auto;
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
.cg-script-empty {
|
| 217 |
+
font: 400 var(--lp-fs-xs)/1.6 Inter, system-ui, sans-serif;
|
| 218 |
+
color: var(--lp-text-muted, #667085);
|
| 219 |
+
}
|
| 220 |
+
/* The whole-panel empty state needs its own padding: there is no `.cg-script-out` around it.
|
| 221 |
+
Kept HERE rather than inline, so the renderer's one and only computed style stays the bar
|
| 222 |
+
width, which is arithmetic on a validated number and is asserted as the only one. */
|
| 223 |
+
.cg-script-empty--pad { padding: 16px; }
|
| 224 |
+
|
| 225 |
+
/* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 226 |
+
W36-T05 β THE VIEW AGENT PANEL, left of the View navigation.
|
| 227 |
+
|
| 228 |
+
β `.cg-agent-panel` IS A CROSS-FENCE CLASS NAME (wiring W8, F's DONE B-3). `index.css` folds
|
| 229 |
+
the main rail on `.shell-root:has(.cg-agent-panel) .shell-side`. Renaming it here silently
|
| 230 |
+
un-folds the rail and leaves 187px of grid at 1440, with nothing going red. Its WIDTH lives
|
| 231 |
+
here because the panel is B's; the FOLD lives in index.css because the rail is F's.
|
| 232 |
+
β ~565px is the reference's own column at 1919px wide, and it is what R14's arithmetic is
|
| 233 |
+
built on: 48 (folded nav) + 565 (this) + 344 (views rail) = 957, leaving 483px of grid.
|
| 234 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 235 |
+
.cg-agent-panel {
|
| 236 |
+
display: flex;
|
| 237 |
+
flex-direction: column;
|
| 238 |
+
flex: 0 0 565px;
|
| 239 |
+
width: 565px;
|
| 240 |
+
max-width: 46vw;
|
| 241 |
+
min-height: 0;
|
| 242 |
+
border-right: 1px solid var(--lp-border, #e4e7ec);
|
| 243 |
+
background: var(--lp-surface, #ffffff);
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
.cg-agent-head {
|
| 247 |
+
display: flex;
|
| 248 |
+
align-items: center;
|
| 249 |
+
gap: 8px;
|
| 250 |
+
padding: 10px 14px;
|
| 251 |
+
border-bottom: 1px solid var(--lp-border, #e4e7ec);
|
| 252 |
+
}
|
| 253 |
+
.cg-agent-title { font: 600 var(--lp-fs-xs)/1.4 Inter, system-ui, sans-serif; color: var(--lp-text, #1d2939); }
|
| 254 |
+
.cg-agent-spacer { flex: 1 1 auto; }
|
| 255 |
+
.cg-agent-icon {
|
| 256 |
+
display: inline-flex;
|
| 257 |
+
align-items: center;
|
| 258 |
+
justify-content: center;
|
| 259 |
+
width: 26px;
|
| 260 |
+
height: 26px;
|
| 261 |
+
border: 0;
|
| 262 |
+
border-radius: 6px;
|
| 263 |
+
background: transparent;
|
| 264 |
+
color: var(--lp-text-muted, #667085);
|
| 265 |
+
cursor: pointer;
|
| 266 |
+
}
|
| 267 |
+
.cg-agent-icon:hover { background: var(--lp-surface-sunken, #f2f4f7); }
|
| 268 |
+
|
| 269 |
+
.cg-agent-body { flex: 1 1 auto; min-height: 0; overflow: auto; padding: 16px; }
|
| 270 |
+
|
| 271 |
+
/* The empty state: centred, one soft mark, one question. Whitespace is the design. */
|
| 272 |
+
.cg-agent-empty {
|
| 273 |
+
height: 100%;
|
| 274 |
+
display: flex;
|
| 275 |
+
flex-direction: column;
|
| 276 |
+
align-items: center;
|
| 277 |
+
justify-content: center;
|
| 278 |
+
gap: 14px;
|
| 279 |
+
}
|
| 280 |
+
.cg-agent-mark {
|
| 281 |
+
width: 54px;
|
| 282 |
+
height: 54px;
|
| 283 |
+
border-radius: 50%;
|
| 284 |
+
border: 2px dotted var(--lp-gold, #c8a24b);
|
| 285 |
+
opacity: 0.55;
|
| 286 |
+
}
|
| 287 |
+
.cg-agent-ask {
|
| 288 |
+
margin: 0;
|
| 289 |
+
font: 500 var(--lp-fs-md)/1.4 Inter, system-ui, sans-serif;
|
| 290 |
+
color: var(--lp-text-muted, #667085);
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
.cg-agent-listhead {
|
| 294 |
+
margin: 0 0 8px;
|
| 295 |
+
font: 500 var(--lp-fs-3xs)/1.4 Inter, system-ui, sans-serif;
|
| 296 |
+
color: var(--lp-text-muted, #667085);
|
| 297 |
+
}
|
| 298 |
+
.cg-agent-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column;
|
| 299 |
+
gap: 4px; }
|
| 300 |
+
/* The suggestion row from the reference: icon tile, bold title, muted subtitle, an arrow right. */
|
| 301 |
+
.cg-agent-row {
|
| 302 |
+
display: flex;
|
| 303 |
+
align-items: center;
|
| 304 |
+
gap: 10px;
|
| 305 |
+
width: 100%;
|
| 306 |
+
padding: 8px 10px;
|
| 307 |
+
border: 0;
|
| 308 |
+
border-radius: 8px;
|
| 309 |
+
background: transparent;
|
| 310 |
+
text-align: left;
|
| 311 |
+
cursor: pointer;
|
| 312 |
+
}
|
| 313 |
+
.cg-agent-row:hover { background: var(--lp-surface-sunken, #f2f4f7); }
|
| 314 |
+
.cg-agent-row-tile {
|
| 315 |
+
display: inline-flex;
|
| 316 |
+
align-items: center;
|
| 317 |
+
justify-content: center;
|
| 318 |
+
width: 28px;
|
| 319 |
+
height: 28px;
|
| 320 |
+
flex: 0 0 28px;
|
| 321 |
+
border-radius: 7px;
|
| 322 |
+
background: var(--lp-surface-sunken, #f2f4f7);
|
| 323 |
+
color: var(--lp-navy, #1f4e78);
|
| 324 |
+
}
|
| 325 |
+
.cg-agent-row-text { display: flex; flex-direction: column; min-width: 0; flex: 1 1 auto; }
|
| 326 |
+
.cg-agent-row-title {
|
| 327 |
+
font: 600 var(--lp-fs-xs)/1.4 Inter, system-ui, sans-serif;
|
| 328 |
+
color: var(--lp-text, #1d2939);
|
| 329 |
+
overflow: hidden;
|
| 330 |
+
text-overflow: ellipsis;
|
| 331 |
+
white-space: nowrap;
|
| 332 |
+
}
|
| 333 |
+
.cg-agent-row-sub {
|
| 334 |
+
font: 400 var(--lp-fs-3xs)/1.4 Inter, system-ui, sans-serif;
|
| 335 |
+
color: var(--lp-text-muted, #667085);
|
| 336 |
+
}
|
| 337 |
+
.cg-agent-row-go { color: var(--lp-text-muted, #667085); }
|
| 338 |
+
|
| 339 |
+
.cg-agent-error {
|
| 340 |
+
margin: 12px 0 0;
|
| 341 |
+
font: 400 var(--lp-fs-xs)/1.6 Inter, system-ui, sans-serif;
|
| 342 |
+
color: var(--lp-text, #1d2939);
|
| 343 |
+
border-left: 3px solid var(--lp-gold, #c8a24b);
|
| 344 |
+
padding-left: 10px;
|
| 345 |
+
}
|
| 346 |
+
|
| 347 |
+
/* ONE card border around the input AND its affordances, which is what the reference does. */
|
| 348 |
+
.cg-agent-composer {
|
| 349 |
+
margin: 0 16px;
|
| 350 |
+
border: 1px solid var(--lp-border, #e4e7ec);
|
| 351 |
+
border-radius: 12px;
|
| 352 |
+
background: var(--lp-surface, #ffffff);
|
| 353 |
+
}
|
| 354 |
+
.cg-agent-input {
|
| 355 |
+
display: block;
|
| 356 |
+
width: 100%;
|
| 357 |
+
border: 0;
|
| 358 |
+
outline: none;
|
| 359 |
+
resize: none;
|
| 360 |
+
padding: 12px 14px 6px;
|
| 361 |
+
background: transparent;
|
| 362 |
+
font: 400 var(--lp-fs-xs)/1.6 Inter, system-ui, sans-serif;
|
| 363 |
+
color: var(--lp-text, #1d2939);
|
| 364 |
+
}
|
| 365 |
+
.cg-agent-composer:focus-within { border-color: var(--lp-navy, #1f4e78); }
|
| 366 |
+
.cg-agent-composer-foot {
|
| 367 |
+
display: flex;
|
| 368 |
+
align-items: center;
|
| 369 |
+
gap: 8px;
|
| 370 |
+
padding: 6px 8px 8px 12px;
|
| 371 |
+
}
|
| 372 |
+
.cg-agent-pill {
|
| 373 |
+
font: 400 var(--lp-fs-3xs)/1.4 Inter, system-ui, sans-serif;
|
| 374 |
+
color: var(--lp-text-muted, #667085);
|
| 375 |
+
border: 1px solid var(--lp-border, #e4e7ec);
|
| 376 |
+
border-radius: 999px;
|
| 377 |
+
padding: 3px 9px;
|
| 378 |
+
}
|
| 379 |
+
.cg-agent-send {
|
| 380 |
+
display: inline-flex;
|
| 381 |
+
align-items: center;
|
| 382 |
+
justify-content: center;
|
| 383 |
+
width: 30px;
|
| 384 |
+
height: 30px;
|
| 385 |
+
border: 0;
|
| 386 |
+
border-radius: 50%;
|
| 387 |
+
background: var(--lp-navy, #1f4e78);
|
| 388 |
+
color: #ffffff;
|
| 389 |
+
cursor: pointer;
|
| 390 |
+
}
|
| 391 |
+
.cg-agent-send:disabled { opacity: 0.4; cursor: default; }
|
| 392 |
+
|
| 393 |
+
.cg-agent-foot {
|
| 394 |
+
margin: 8px 16px 14px;
|
| 395 |
+
font: 400 var(--lp-fs-3xs)/1.5 Inter, system-ui, sans-serif;
|
| 396 |
+
color: var(--lp-text-muted, #667085);
|
| 397 |
+
}
|
| 398 |
+
|
| 399 |
+
/* The robot, beside the views rail's own minimize toggle (W35-T28 keeps that one FIRST). */
|
| 400 |
+
.cg-agent-open {
|
| 401 |
+
display: inline-flex;
|
| 402 |
+
align-items: center;
|
| 403 |
+
justify-content: center;
|
| 404 |
+
width: 26px;
|
| 405 |
+
height: 26px;
|
| 406 |
+
border: 0;
|
| 407 |
+
border-radius: 6px;
|
| 408 |
+
background: transparent;
|
| 409 |
+
color: var(--lp-text-muted, #667085);
|
| 410 |
+
cursor: pointer;
|
| 411 |
+
}
|
| 412 |
+
.cg-agent-open:hover { background: var(--lp-surface-sunken, #f2f4f7); }
|
| 413 |
+
.cg-agent-open.is-on { background: var(--lp-surface-sunken, #f2f4f7); color: var(--lp-navy, #1f4e78); }
|
| 414 |
+
|
| 415 |
+
/* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 416 |
+
W36-T07 β THE FIELD AGENT, inside the column menu's create pane.
|
| 417 |
+
β It reuses the `cg-agent-*` composer above rather than growing a second chat look. What is
|
| 418 |
+
added here is only what a conversation inside a 320px menu needs that a full-height panel
|
| 419 |
+
does not: a bounded scroll, a proposal card, and the two-button accept row.
|
| 420 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 421 |
+
.cg-fagent { display: flex; flex-direction: column; gap: 10px; min-width: 0; }
|
| 422 |
+
.cg-fagent-body { max-height: 320px; overflow: auto; }
|
| 423 |
+
.cg-fagent-turns { list-style: none; margin: 0 0 10px; padding: 0; display: flex;
|
| 424 |
+
flex-direction: column; gap: 6px; }
|
| 425 |
+
.cg-fagent-turn { display: flex; justify-content: flex-end; }
|
| 426 |
+
.cg-fagent-said {
|
| 427 |
+
max-width: 88%;
|
| 428 |
+
padding: 6px 10px;
|
| 429 |
+
border-radius: 10px 10px 2px 10px;
|
| 430 |
+
background: var(--lp-surface-sunken, #f2f4f7);
|
| 431 |
+
font: 400 var(--lp-fs-2xs)/1.5 Inter, system-ui, sans-serif;
|
| 432 |
+
color: var(--lp-text, #1d2939);
|
| 433 |
+
word-break: break-word;
|
| 434 |
+
}
|
| 435 |
+
.cg-fagent-card {
|
| 436 |
+
border: 1px solid var(--lp-border, #e4e7ec);
|
| 437 |
+
border-left: 3px solid var(--lp-gold, #c8a24b);
|
| 438 |
+
border-radius: 8px;
|
| 439 |
+
padding: 10px 12px;
|
| 440 |
+
}
|
| 441 |
+
.cg-fagent-card-head {
|
| 442 |
+
margin: 0 0 6px;
|
| 443 |
+
font: 600 var(--lp-fs-2xs)/1.4 Inter, system-ui, sans-serif;
|
| 444 |
+
color: var(--lp-text, #1d2939);
|
| 445 |
+
}
|
| 446 |
+
.cg-fagent-def {
|
| 447 |
+
margin: 0;
|
| 448 |
+
display: grid;
|
| 449 |
+
grid-template-columns: auto 1fr;
|
| 450 |
+
gap: 2px 10px;
|
| 451 |
+
font: 400 var(--lp-fs-2xs)/1.5 Inter, system-ui, sans-serif;
|
| 452 |
+
}
|
| 453 |
+
.cg-fagent-def dt { color: var(--lp-text-muted, #667085); }
|
| 454 |
+
.cg-fagent-def dd { margin: 0; color: var(--lp-text, #1d2939); }
|
| 455 |
+
.cg-fagent-why,
|
| 456 |
+
.cg-fagent-missing {
|
| 457 |
+
margin: 8px 0 0;
|
| 458 |
+
padding-left: 16px;
|
| 459 |
+
font: 400 var(--lp-fs-3xs)/1.55 Inter, system-ui, sans-serif;
|
| 460 |
+
color: var(--lp-text-muted, #667085);
|
| 461 |
+
}
|
| 462 |
+
/* What is still OPEN reads differently from what was decided: the reader has to answer these. */
|
| 463 |
+
.cg-fagent-missing { color: var(--lp-text, #1d2939); }
|
| 464 |
+
.cg-fagent-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 10px; }
|
| 465 |
+
/* The agent's door in the create pane. A quiet row, not a second primary button. */
|
| 466 |
+
.cg-fagent-open {
|
| 467 |
+
display: inline-flex;
|
| 468 |
+
align-items: center;
|
| 469 |
+
gap: 6px;
|
| 470 |
+
width: 100%;
|
| 471 |
+
padding: 7px 10px;
|
| 472 |
+
border: 1px dashed var(--lp-border, #e4e7ec);
|
| 473 |
+
border-radius: 8px;
|
| 474 |
+
background: transparent;
|
| 475 |
+
color: var(--lp-text-muted, #667085);
|
| 476 |
+
font: 500 var(--lp-fs-2xs)/1.4 Inter, system-ui, sans-serif;
|
| 477 |
+
cursor: pointer;
|
| 478 |
+
}
|
| 479 |
+
.cg-fagent-open:hover { border-color: var(--lp-navy, #1f4e78); color: var(--lp-navy, #1f4e78); }
|
| 480 |
+
|
| 481 |
+
/* ββ W37-T46 / D-338: the roll-back strip βββββββββββββββββββββββββββββββββββ
|
| 482 |
+
β It sits between the head row and the code, where a person looking for "what came before" is
|
| 483 |
+
already looking. A separate panel or a modal would put the history somewhere you have to go
|
| 484 |
+
rather than somewhere you are. */
|
| 485 |
+
.cg-script-history {
|
| 486 |
+
display: flex;
|
| 487 |
+
align-items: center;
|
| 488 |
+
flex-wrap: wrap;
|
| 489 |
+
gap: 6px;
|
| 490 |
+
padding: 6px 12px;
|
| 491 |
+
border-bottom: 1px solid var(--lp-border, #e4e7ec);
|
| 492 |
+
background: var(--lp-surface-sunken, #fbfcfd);
|
| 493 |
+
}
|
| 494 |
+
.cg-script-history-lead,
|
| 495 |
+
.cg-script-history-trimmed {
|
| 496 |
+
font: 400 var(--lp-fs-3xs)/1.3 Inter, system-ui, sans-serif;
|
| 497 |
+
color: var(--lp-text-muted, #667085);
|
| 498 |
+
}
|
| 499 |
+
.cg-script-history-item {
|
| 500 |
+
border: 1px solid var(--lp-border, #e4e7ec);
|
| 501 |
+
border-radius: 6px;
|
| 502 |
+
background: var(--lp-surface, #ffffff);
|
| 503 |
+
color: var(--lp-navy, #1f4e78);
|
| 504 |
+
font: 500 var(--lp-fs-3xs)/1.2 Inter, system-ui, sans-serif;
|
| 505 |
+
padding: 2px 7px;
|
| 506 |
+
cursor: pointer;
|
| 507 |
+
}
|
| 508 |
+
.cg-script-history-item:hover:enabled { border-color: var(--lp-navy, #1f4e78); }
|
| 509 |
+
.cg-script-history-item:disabled { opacity: 0.5; cursor: default; }
|