Deploy AIOS web (React glide grid + FastAPI slice)
Browse files- RELEASES.json +7 -1
- VERSION +1 -1
- api/deps.py +11 -1
- api/main.py +160 -16
- api/meta_relational.py +52 -9
- api/odoo_relational.py +12 -2
- api/routes_auth.py +13 -3
- api/routes_customers.py +28 -1
- api/routes_odoo_tables.py +2 -0
- api/routes_tables.py +5 -0
- platform/aios_grid.py +17 -1
- platform/aios_grid_fields.json +50 -8
- platform/core/store_pg.py +37 -21
- platform/core/users.py +44 -0
- platform/harness/datastore.py +6 -2
- platform/harness/filter_sql.py +8 -5
- platform/harness/pg/schema.sql +34 -12
- platform/harness/semantic.py +47 -10
- platform/model/topics/odoo_products.yml +46 -0
- platform/modules/backorders.py +72 -0
- platform/modules/inventory.py +9 -2
- platform/modules/product_data.py +92 -47
- platform/modules/products.py +72 -32
- web/src/customer-grid/ColumnMenu.tsx +160 -0
- web/src/customer-grid/CustomerGrid.tsx +106 -8
- web/src/customer-grid/MapView.tsx +158 -30
- web/src/shell/ShareDialog.tsx +58 -3
- web/src/shell/shareModel.ts +16 -2
RELEASES.json
CHANGED
|
@@ -1,6 +1,12 @@
|
|
| 1 |
{
|
| 2 |
-
"current": "
|
| 3 |
"releases": [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
{
|
| 5 |
"version": "v30",
|
| 6 |
"sha": "71776ca",
|
|
|
|
| 1 |
{
|
| 2 |
+
"current": "v31 (8880a49)",
|
| 3 |
"releases": [
|
| 4 |
+
{
|
| 5 |
+
"version": "v31",
|
| 6 |
+
"sha": "8880a49",
|
| 7 |
+
"date": "2026-08-21",
|
| 8 |
+
"subject": "v31: Wave 39 staging candidate"
|
| 9 |
+
},
|
| 10 |
{
|
| 11 |
"version": "v30",
|
| 12 |
"sha": "71776ca",
|
VERSION
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
|
|
|
|
| 1 |
+
v31 (8880a49)
|
api/deps.py
CHANGED
|
@@ -515,6 +515,13 @@ def _user_for(claims):
|
|
| 515 |
if not uname:
|
| 516 |
return None
|
| 517 |
claim_tenant = str(claims.get("t") or "").strip().lower()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 518 |
try:
|
| 519 |
reg = users.registry() or {}
|
| 520 |
except Exception:
|
|
@@ -649,7 +656,10 @@ def require_session(request: Request, response: Response) -> Session:
|
|
| 649 |
response.headers[_TENANT_HEADER] = str(claims["t"])
|
| 650 |
# R4: last AFTER the session is fully resolved β a stamp is only true of a request that was
|
| 651 |
# actually admitted, and putting it here means no failed-auth path can ever write one.
|
| 652 |
-
|
|
|
|
|
|
|
|
|
|
| 653 |
return Session(tenant=claims["t"], user=user, claims=claims, runtime=rt)
|
| 654 |
|
| 655 |
|
|
|
|
| 515 |
if not uname:
|
| 516 |
return None
|
| 517 |
claim_tenant = str(claims.get("t") or "").strip().lower()
|
| 518 |
+
# W39-T28 / A62. This MUST precede the registry read: qa-runner is specifically a
|
| 519 |
+
# zero-touch qa-b identity, not an account in tenant #0's `users` document. The helper
|
| 520 |
+
# rechecks both non-production deployment flags, so removing either invalidates its cookie.
|
| 521 |
+
qa = users.qa_identity(uname)
|
| 522 |
+
if (qa and claim_tenant == qa.get("tenant")
|
| 523 |
+
and int(claims.get("e") or 0) == int(qa.get("epoch") or 0)):
|
| 524 |
+
return qa
|
| 525 |
try:
|
| 526 |
reg = users.registry() or {}
|
| 527 |
except Exception:
|
|
|
|
| 656 |
response.headers[_TENANT_HEADER] = str(claims["t"])
|
| 657 |
# R4: last AFTER the session is fully resolved β a stamp is only true of a request that was
|
| 658 |
# actually admitted, and putting it here means no failed-auth path can ever write one.
|
| 659 |
+
# qa-runner has no global account record. Never let the normal activity telemetry create or
|
| 660 |
+
# touch one through the default tenant-#0 users store.
|
| 661 |
+
if not user.get("qa_runner"):
|
| 662 |
+
_touch_active(user.get("username"))
|
| 663 |
return Session(tenant=claims["t"], user=user, claims=claims, runtime=rt)
|
| 664 |
|
| 665 |
|
api/main.py
CHANGED
|
@@ -636,7 +636,7 @@ def _pull_meta(why):
|
|
| 636 |
if not _meta.token():
|
| 637 |
print(f"[aios-api] meta sync skipped ({why}): no META_ADS_ACCESS_TOKEN in this "
|
| 638 |
f"deployment - the connector is idle, not broken")
|
| 639 |
-
return
|
| 640 |
# β PASSED, NOT SET IN THE ENVIRONMENT. `meta_store.INSIGHTS_DAYS` binds at
|
| 641 |
# IMPORT, so an `os.environ.setdefault` here executed after the module was
|
| 642 |
# already loaded and changed NOTHING: every boot pulled 90 days instead of 7,
|
|
@@ -647,11 +647,13 @@ def _pull_meta(why):
|
|
| 647 |
print(f"[aios-api] meta sync PROBLEM ({why}): {p}")
|
| 648 |
print(f"[aios-api] meta sync done ({why}): "
|
| 649 |
+ ", ".join(f"{k}={v['in_mirror']}" for k, v in sorted(rep["tables"].items())))
|
|
|
|
| 650 |
except Exception as e: # noqa: BLE001
|
| 651 |
print(f"[aios-api] meta sync FAILED ({why}): {type(e).__name__}: {e}")
|
|
|
|
| 652 |
|
| 653 |
|
| 654 |
-
def _rebuild_meta_relational(why, rt):
|
| 655 |
"""Spawn/refresh the `ut_meta_*` locked databases off the SAME mirror the Odoo half just used.
|
| 656 |
|
| 657 |
β CALLED FROM INSIDE `_rebuild_odoo_relational`, ON PURPOSE, and the reason is D-29 rather than
|
|
@@ -668,17 +670,24 @@ def _rebuild_meta_relational(why, rt):
|
|
| 668 |
"""
|
| 669 |
try:
|
| 670 |
import meta_relational as _meta
|
| 671 |
-
|
|
|
|
| 672 |
if counts:
|
| 673 |
print(f"[aios-api] meta relational rebuild done ({why}): "
|
| 674 |
+ ", ".join(f"{k}={v}" for k, v in sorted(counts.items())))
|
|
|
|
|
|
|
|
|
|
| 675 |
try:
|
| 676 |
import automation_engine as _engine
|
| 677 |
_engine.refresh_relations(rt, log=lambda *_a: None)
|
| 678 |
except Exception as e: # noqa: BLE001
|
| 679 |
print(f"[aios-api] meta relation cells failed ({why}): {type(e).__name__}: {e}")
|
|
|
|
| 680 |
except Exception as e: # noqa: BLE001
|
| 681 |
print(f"[aios-api] meta relational rebuild FAILED ({why}): {type(e).__name__}: {e}")
|
|
|
|
|
|
|
| 682 |
|
| 683 |
|
| 684 |
def _sweep_automation_schemas(why):
|
|
@@ -800,7 +809,7 @@ def _sweep_automation_schemas(why):
|
|
| 800 |
print(f"[aios-api] schema sweep ({why}) done over {len(tenants)} tenant(s)")
|
| 801 |
|
| 802 |
|
| 803 |
-
def _rebuild_odoo_relational(why):
|
| 804 |
"""Spawn/refresh the four locked Odoo databases, then compute their cells. `why` is 'boot' or
|
| 805 |
'resync' and rides every log line, because "it failed" and "it failed at boot, before anyone
|
| 806 |
could have asked" are different diagnoses.
|
|
@@ -821,7 +830,7 @@ def _rebuild_odoo_relational(why):
|
|
| 821 |
import odoo_relational as _rel
|
| 822 |
from harness import runtime as _runtime
|
| 823 |
if not _rel.is_royal("royal-imports"):
|
| 824 |
-
return
|
| 825 |
_rt = _runtime.get_runtime("royal-imports")
|
| 826 |
counts = _rel.refresh(_rt, "royal-imports")
|
| 827 |
# ββ AND THEN COMPUTE THE CELLS, which `refresh` does NOT do.
|
|
@@ -853,13 +862,75 @@ def _rebuild_odoo_relational(why):
|
|
| 853 |
print(f"[aios-api] odoo source rollups failed ({why}): {type(e).__name__}: {e}")
|
| 854 |
print(f"[aios-api] odoo relational rebuild done ({why}): "
|
| 855 |
+ ", ".join(f"{k}={v}" for k, v in sorted((counts or {}).items())))
|
| 856 |
-
_rebuild_meta_relational(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 857 |
except Exception as e: # noqa: BLE001
|
| 858 |
# β The TYPE is named here as it is in the two inner handlers. The original printed only
|
| 859 |
# `{e}`, and a bare message is exactly what made D-107 unreadable from the outside: a
|
| 860 |
# `BinderException` about a missing column is a different action from a timeout or an auth
|
| 861 |
# failure, and the text alone often does not say which it was.
|
| 862 |
print(f"[aios-api] odoo relational rebuild failed ({why}): {type(e).__name__}: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 863 |
|
| 864 |
|
| 865 |
def _store_resync_loop():
|
|
@@ -868,6 +939,9 @@ def _store_resync_loop():
|
|
| 868 |
refreshed pool re-reads the freshly synced store."""
|
| 869 |
import time as _t
|
| 870 |
passes = 0
|
|
|
|
|
|
|
|
|
|
| 871 |
while True:
|
| 872 |
# ββ WAVE 32 Β· OWNER ITEM 11 / R11 β THE TENANT'S OWN CADENCE, NOT A HARDCODED 1800.
|
| 873 |
#
|
|
@@ -918,6 +992,8 @@ def _store_resync_loop():
|
|
| 918 |
pass
|
| 919 |
_t.sleep(_every)
|
| 920 |
passes += 1
|
|
|
|
|
|
|
| 921 |
try:
|
| 922 |
from harness import datastore as _ds
|
| 923 |
import core.odoo as _odoo
|
|
@@ -930,24 +1006,26 @@ def _store_resync_loop():
|
|
| 930 |
# setting a person chose into a recurring error in the log; a `continue` would skip
|
| 931 |
# the Meta pull and the relational rebuild below, which `manual` says nothing about.
|
| 932 |
if not _manual:
|
| 933 |
-
_ds.sync_all(log=lambda *a, **k: None)
|
| 934 |
# Wave 21 β every 4th pass (~2h): purge hard-deleted rows the cursor sync cannot
|
| 935 |
# see (the boot pass's comment has the measured case). Cheap id-sweep per entity;
|
| 936 |
# without it deleted Odoo lines inflate every sum on the mirror FOREVER.
|
| 937 |
# β INSIDE the branch: a reconcile is a pass over the mirror this loop was just
|
| 938 |
# told not to advance.
|
| 939 |
if passes % 4 == 0:
|
| 940 |
-
_ds.reconcile_deletes(log=lambda *a, **k: None)
|
| 941 |
except Exception as e: # noqa: BLE001
|
|
|
|
|
|
|
| 942 |
print(f"[aios-api] store resync failed: {e}")
|
| 943 |
-
# β WAVE 27 item 17 (E's ASK ->A, resolved): the Odoo RELATIONAL tables
|
| 944 |
-
# AFTER the mirror they
|
| 945 |
-
#
|
| 946 |
-
# every single time.
|
| 947 |
#
|
| 948 |
# β OUTSIDE the try/except above, NOT folded into it. A failing relational rebuild must
|
| 949 |
-
# not swallow the sync's error message, and β worse the other way β a sync failure
|
| 950 |
-
#
|
| 951 |
# independent logs. (`_rebuild_odoo_relational` carries its own handlers.)
|
| 952 |
#
|
| 953 |
# β THE REBUILD IS NOT THE FRESHNESS GUARANTEE β the rows carry a visible `_refreshed`
|
|
@@ -970,8 +1048,36 @@ def _store_resync_loop():
|
|
| 970 |
# correctly. A connector that can only ever be established at boot is one bad boot away
|
| 971 |
# from being permanently absent.
|
| 972 |
# β Cheap when there is nothing to do: no token => one line and return.
|
| 973 |
-
_pull_meta("resync")
|
| 974 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 975 |
|
| 976 |
|
| 977 |
def _prewarm():
|
|
@@ -1066,6 +1172,34 @@ def _seed_mirror_if_absent():
|
|
| 1066 |
return False
|
| 1067 |
|
| 1068 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1069 |
try:
|
| 1070 |
from harness import datastore as _ds_boot
|
| 1071 |
if not _ds_boot.DB_PATH.exists():
|
|
@@ -1075,6 +1209,16 @@ try:
|
|
| 1075 |
except Exception as e: # noqa: BLE001
|
| 1076 |
print(f"[aios-api] mirror seed not scheduled: {e}")
|
| 1077 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1078 |
if os.environ.get("AIOS_PREWARM") == "1":
|
| 1079 |
import threading as _threading
|
| 1080 |
_threading.Thread(target=_prewarm, daemon=True, name="prewarm").start()
|
|
|
|
| 636 |
if not _meta.token():
|
| 637 |
print(f"[aios-api] meta sync skipped ({why}): no META_ADS_ACCESS_TOKEN in this "
|
| 638 |
f"deployment - the connector is idle, not broken")
|
| 639 |
+
return False
|
| 640 |
# β PASSED, NOT SET IN THE ENVIRONMENT. `meta_store.INSIGHTS_DAYS` binds at
|
| 641 |
# IMPORT, so an `os.environ.setdefault` here executed after the module was
|
| 642 |
# already loaded and changed NOTHING: every boot pulled 90 days instead of 7,
|
|
|
|
| 647 |
print(f"[aios-api] meta sync PROBLEM ({why}): {p}")
|
| 648 |
print(f"[aios-api] meta sync done ({why}): "
|
| 649 |
+ ", ".join(f"{k}={v['in_mirror']}" for k, v in sorted(rep["tables"].items())))
|
| 650 |
+
return True
|
| 651 |
except Exception as e: # noqa: BLE001
|
| 652 |
print(f"[aios-api] meta sync FAILED ({why}): {type(e).__name__}: {e}")
|
| 653 |
+
return False
|
| 654 |
|
| 655 |
|
| 656 |
+
def _rebuild_meta_relational(why, rt, previous_fingerprint=None, force_relations=False):
|
| 657 |
"""Spawn/refresh the `ut_meta_*` locked databases off the SAME mirror the Odoo half just used.
|
| 658 |
|
| 659 |
β CALLED FROM INSIDE `_rebuild_odoo_relational`, ON PURPOSE, and the reason is D-29 rather than
|
|
|
|
| 670 |
"""
|
| 671 |
try:
|
| 672 |
import meta_relational as _meta
|
| 673 |
+
state = _meta.refresh_state(rt, why, previous_fingerprint=previous_fingerprint)
|
| 674 |
+
counts = state["written"]
|
| 675 |
if counts:
|
| 676 |
print(f"[aios-api] meta relational rebuild done ({why}): "
|
| 677 |
+ ", ".join(f"{k}={v}" for k, v in sorted(counts.items())))
|
| 678 |
+
# An Odoo refresh retains the established relation pass even when Meta is unchanged.
|
| 679 |
+
# The zero-Odoo path uses the default and skips only after Meta's exact-plan match.
|
| 680 |
+
if state["present"] and (state["changed"] or force_relations):
|
| 681 |
try:
|
| 682 |
import automation_engine as _engine
|
| 683 |
_engine.refresh_relations(rt, log=lambda *_a: None)
|
| 684 |
except Exception as e: # noqa: BLE001
|
| 685 |
print(f"[aios-api] meta relation cells failed ({why}): {type(e).__name__}: {e}")
|
| 686 |
+
return state
|
| 687 |
except Exception as e: # noqa: BLE001
|
| 688 |
print(f"[aios-api] meta relational rebuild FAILED ({why}): {type(e).__name__}: {e}")
|
| 689 |
+
return {"present": False, "known": False, "fingerprint": None,
|
| 690 |
+
"changed": True, "applied": False, "written": {}}
|
| 691 |
|
| 692 |
|
| 693 |
def _sweep_automation_schemas(why):
|
|
|
|
| 809 |
print(f"[aios-api] schema sweep ({why}) done over {len(tenants)} tenant(s)")
|
| 810 |
|
| 811 |
|
| 812 |
+
def _rebuild_odoo_relational(why, previous_meta_fingerprint=None, meta_state_out=None):
|
| 813 |
"""Spawn/refresh the four locked Odoo databases, then compute their cells. `why` is 'boot' or
|
| 814 |
'resync' and rides every log line, because "it failed" and "it failed at boot, before anyone
|
| 815 |
could have asked" are different diagnoses.
|
|
|
|
| 830 |
import odoo_relational as _rel
|
| 831 |
from harness import runtime as _runtime
|
| 832 |
if not _rel.is_royal("royal-imports"):
|
| 833 |
+
return False
|
| 834 |
_rt = _runtime.get_runtime("royal-imports")
|
| 835 |
counts = _rel.refresh(_rt, "royal-imports")
|
| 836 |
# ββ AND THEN COMPUTE THE CELLS, which `refresh` does NOT do.
|
|
|
|
| 862 |
print(f"[aios-api] odoo source rollups failed ({why}): {type(e).__name__}: {e}")
|
| 863 |
print(f"[aios-api] odoo relational rebuild done ({why}): "
|
| 864 |
+ ", ".join(f"{k}={v}" for k, v in sorted((counts or {}).items())))
|
| 865 |
+
meta_state = _rebuild_meta_relational(
|
| 866 |
+
why, _rt, previous_fingerprint=previous_meta_fingerprint, force_relations=True)
|
| 867 |
+
if isinstance(meta_state_out, dict):
|
| 868 |
+
meta_state_out.update(meta_state)
|
| 869 |
+
# The isolated cells/rollups/meta handlers above are deliberately allowed to report and
|
| 870 |
+
# continue. This return value answers the narrower resync-policy question: did the Odoo
|
| 871 |
+
# table refresh itself complete, so a confirmed zero-change pass may be skipped?
|
| 872 |
+
return True
|
| 873 |
except Exception as e: # noqa: BLE001
|
| 874 |
# β The TYPE is named here as it is in the two inner handlers. The original printed only
|
| 875 |
# `{e}`, and a bare message is exactly what made D-107 unreadable from the outside: a
|
| 876 |
# `BinderException` about a missing column is a different action from a timeout or an auth
|
| 877 |
# failure, and the text alone often does not say which it was.
|
| 878 |
print(f"[aios-api] odoo relational rebuild failed ({why}): {type(e).__name__}: {e}")
|
| 879 |
+
return False
|
| 880 |
+
|
| 881 |
+
|
| 882 |
+
# W39-T31 / A63. `sync_all` already knows whether Odoo supplied any change, but this loop used to
|
| 883 |
+
# rebuild every relational table (and its large `user_tables` document) after every poll anyway.
|
| 884 |
+
# A nonempty all-live zero report is the only evidence strong enough to skip that Odoo half. An
|
| 885 |
+
# absent report is not zero: it means manual mode, paused source, or a failed/unknown sync, all of
|
| 886 |
+
# which force the pre-existing rebuild path. `delete_report=None` is one of the ordinary passes
|
| 887 |
+
# between delete reconciliations; it is unknown, not falsely claimed clean.
|
| 888 |
+
_RESYNC_REBUILD_MAX_AGE_SECONDS = 24 * 60 * 60
|
| 889 |
+
|
| 890 |
+
|
| 891 |
+
def _resync_rebuild_reason(sync_report, delete_report, last_success_at, now):
|
| 892 |
+
"""Return a fail-safe Odoo-rebuild reason, or ``None`` for a proven no-op.
|
| 893 |
+
|
| 894 |
+
The policy stays pure so it can be tested without Odoo, DuckDB, or PostgreSQL. Hard deletes
|
| 895 |
+
are noticed by the existing every-fourth-pass reconciliation; an intervening pass does not
|
| 896 |
+
pretend that it checked them, preserving the old visibility bound.
|
| 897 |
+
"""
|
| 898 |
+
if not isinstance(sync_report, dict) or not sync_report:
|
| 899 |
+
return "sync-unconfirmed"
|
| 900 |
+
for report in sync_report.values():
|
| 901 |
+
if not isinstance(report, dict) or report.get("phase") != "live":
|
| 902 |
+
return "sync-unconfirmed"
|
| 903 |
+
try:
|
| 904 |
+
if int(report.get("pulled") or 0) > 0:
|
| 905 |
+
return "source-change"
|
| 906 |
+
except (TypeError, ValueError):
|
| 907 |
+
return "sync-unconfirmed"
|
| 908 |
+
if delete_report is not None:
|
| 909 |
+
if not isinstance(delete_report, dict):
|
| 910 |
+
return "sync-unconfirmed"
|
| 911 |
+
try:
|
| 912 |
+
if any(int(count or 0) > 0 for count in delete_report.values()):
|
| 913 |
+
return "delete-change"
|
| 914 |
+
except (TypeError, ValueError):
|
| 915 |
+
return "sync-unconfirmed"
|
| 916 |
+
if last_success_at is None or now - last_success_at >= _RESYNC_REBUILD_MAX_AGE_SECONDS:
|
| 917 |
+
return "daily-recovery"
|
| 918 |
+
return None
|
| 919 |
+
|
| 920 |
+
|
| 921 |
+
def _meta_rebuild_force_reason(pull_confirmed, last_fingerprint, last_applied_at, now):
|
| 922 |
+
"""Return why Meta must apply, or ``None`` when its exact plan may skip the write.
|
| 923 |
+
|
| 924 |
+
Graph reports fetched rows rather than changed rows. The fingerprint in `meta_relational`
|
| 925 |
+
proves equality later; this helper only expires stale/unknown permission to use it.
|
| 926 |
+
"""
|
| 927 |
+
if pull_confirmed is not True:
|
| 928 |
+
return "meta-unconfirmed"
|
| 929 |
+
if not isinstance(last_fingerprint, str) or not last_fingerprint:
|
| 930 |
+
return "meta-first"
|
| 931 |
+
if last_applied_at is None or now - last_applied_at >= _RESYNC_REBUILD_MAX_AGE_SECONDS:
|
| 932 |
+
return "meta-daily"
|
| 933 |
+
return None
|
| 934 |
|
| 935 |
|
| 936 |
def _store_resync_loop():
|
|
|
|
| 939 |
refreshed pool re-reads the freshly synced store."""
|
| 940 |
import time as _t
|
| 941 |
passes = 0
|
| 942 |
+
last_relational_success_at = None
|
| 943 |
+
last_meta_fingerprint = None
|
| 944 |
+
last_meta_applied_at = None
|
| 945 |
while True:
|
| 946 |
# ββ WAVE 32 Β· OWNER ITEM 11 / R11 β THE TENANT'S OWN CADENCE, NOT A HARDCODED 1800.
|
| 947 |
#
|
|
|
|
| 992 |
pass
|
| 993 |
_t.sleep(_every)
|
| 994 |
passes += 1
|
| 995 |
+
sync_report = None
|
| 996 |
+
delete_report = None
|
| 997 |
try:
|
| 998 |
from harness import datastore as _ds
|
| 999 |
import core.odoo as _odoo
|
|
|
|
| 1006 |
# setting a person chose into a recurring error in the log; a `continue` would skip
|
| 1007 |
# the Meta pull and the relational rebuild below, which `manual` says nothing about.
|
| 1008 |
if not _manual:
|
| 1009 |
+
sync_report = _ds.sync_all(log=lambda *a, **k: None)
|
| 1010 |
# Wave 21 β every 4th pass (~2h): purge hard-deleted rows the cursor sync cannot
|
| 1011 |
# see (the boot pass's comment has the measured case). Cheap id-sweep per entity;
|
| 1012 |
# without it deleted Odoo lines inflate every sum on the mirror FOREVER.
|
| 1013 |
# β INSIDE the branch: a reconcile is a pass over the mirror this loop was just
|
| 1014 |
# told not to advance.
|
| 1015 |
if passes % 4 == 0:
|
| 1016 |
+
delete_report = _ds.reconcile_deletes(log=lambda *a, **k: None)
|
| 1017 |
except Exception as e: # noqa: BLE001
|
| 1018 |
+
# A partial report must never be mistaken for a confirmed zero-change pass.
|
| 1019 |
+
sync_report = None
|
| 1020 |
print(f"[aios-api] store resync failed: {e}")
|
| 1021 |
+
# β WAVE 27 item 17 (E's ASK ->A, resolved): whenever the Odoo RELATIONAL tables need
|
| 1022 |
+
# refreshing, they run AFTER the mirror they derive from. A confirmed zero-change report
|
| 1023 |
+
# is the W39 exception; deriving before a changed mirror advances would publish a one-cycle
|
| 1024 |
+
# stale worklist every single time.
|
| 1025 |
#
|
| 1026 |
# β OUTSIDE the try/except above, NOT folded into it. A failing relational rebuild must
|
| 1027 |
+
# not swallow the sync's error message, and β worse the other way β a sync failure becomes
|
| 1028 |
+
# unconfirmed evidence and forces a recovery rebuild. Two independent failures, two
|
| 1029 |
# independent logs. (`_rebuild_odoo_relational` carries its own handlers.)
|
| 1030 |
#
|
| 1031 |
# β THE REBUILD IS NOT THE FRESHNESS GUARANTEE β the rows carry a visible `_refreshed`
|
|
|
|
| 1048 |
# correctly. A connector that can only ever be established at boot is one bad boot away
|
| 1049 |
# from being permanently absent.
|
| 1050 |
# β Cheap when there is nothing to do: no token => one line and return.
|
| 1051 |
+
_meta_pull_confirmed = _pull_meta("resync")
|
| 1052 |
+
_meta_now = _t.time()
|
| 1053 |
+
_meta_reason = _meta_rebuild_force_reason(
|
| 1054 |
+
_meta_pull_confirmed, last_meta_fingerprint, last_meta_applied_at, _meta_now)
|
| 1055 |
+
_meta_previous = last_meta_fingerprint if _meta_reason is None else None
|
| 1056 |
+
_reason = _resync_rebuild_reason(sync_report, delete_report,
|
| 1057 |
+
last_relational_success_at, _meta_now)
|
| 1058 |
+
_meta_state = {}
|
| 1059 |
+
if _reason is not None:
|
| 1060 |
+
print(f"[aios-api] odoo relational rebuild required ({_reason})")
|
| 1061 |
+
if _rebuild_odoo_relational("resync", _meta_previous, _meta_state):
|
| 1062 |
+
last_relational_success_at = _t.time()
|
| 1063 |
+
else:
|
| 1064 |
+
# A zero Odoo report says nothing about Meta. Its exact-plan fingerprint decides
|
| 1065 |
+
# whether this cadence may skip both the full-document write and relation pass.
|
| 1066 |
+
try:
|
| 1067 |
+
from harness import runtime as _runtime
|
| 1068 |
+
_meta_state = _rebuild_meta_relational(
|
| 1069 |
+
"resync", _runtime.get_runtime("royal-imports"), _meta_previous)
|
| 1070 |
+
except Exception as e: # noqa: BLE001
|
| 1071 |
+
print(f"[aios-api] meta relational refresh failed (resync): "
|
| 1072 |
+
f"{type(e).__name__}: {e}")
|
| 1073 |
+
if _meta_state.get("known"):
|
| 1074 |
+
last_meta_fingerprint = _meta_state["fingerprint"]
|
| 1075 |
+
if _meta_state.get("applied"):
|
| 1076 |
+
last_meta_applied_at = _t.time()
|
| 1077 |
+
else:
|
| 1078 |
+
# Unknown is never carried forward as equality evidence.
|
| 1079 |
+
last_meta_fingerprint = None
|
| 1080 |
+
last_meta_applied_at = None
|
| 1081 |
|
| 1082 |
|
| 1083 |
def _prewarm():
|
|
|
|
| 1172 |
return False
|
| 1173 |
|
| 1174 |
|
| 1175 |
+
def _provision_qa_sandbox(pg=None):
|
| 1176 |
+
"""Idempotently create the *Staging-only* qa-b schema after its write wall has allowed it.
|
| 1177 |
+
|
| 1178 |
+
This must stay separate from normal tenant provisioning: qa-b is an intentionally empty
|
| 1179 |
+
runtime fixture, not a customer. The guard runs before `_pool()` so a missing/revoked grant
|
| 1180 |
+
does not open a PostgreSQL connection merely to learn it should refuse. Live has neither
|
| 1181 |
+
deployment flag and returns before importing the PostgreSQL backend.
|
| 1182 |
+
"""
|
| 1183 |
+
if os.environ.get("AIOS_ENABLE_QA_TENANT") != "1":
|
| 1184 |
+
return False
|
| 1185 |
+
granted = {s.strip().lower() for s in
|
| 1186 |
+
str(os.environ.get("AIOS_SANDBOX_TENANTS") or "").split(",") if s.strip()}
|
| 1187 |
+
if "qa-b" not in granted or os.environ.get("STORE_BACKEND") != "pg":
|
| 1188 |
+
return False
|
| 1189 |
+
try:
|
| 1190 |
+
if pg is None:
|
| 1191 |
+
import core.store_pg as pg
|
| 1192 |
+
# β BEFORE `_pool`: a refused deploy never wakes Neon or gets as far as DDL.
|
| 1193 |
+
pg.check_write("qa-b", operation="qa schema provisioning")
|
| 1194 |
+
with pg._pool().connection() as con:
|
| 1195 |
+
con.execute("SELECT control.provision_tenant_schema(%s)", ("qa-b",))
|
| 1196 |
+
print("[aios-api] qa-b sandbox schema provisioned")
|
| 1197 |
+
return True
|
| 1198 |
+
except Exception as e: # noqa: BLE001 β boot must not die
|
| 1199 |
+
print(f"[aios-api] qa-b sandbox schema not provisioned: {type(e).__name__}: {e}")
|
| 1200 |
+
return False
|
| 1201 |
+
|
| 1202 |
+
|
| 1203 |
try:
|
| 1204 |
from harness import datastore as _ds_boot
|
| 1205 |
if not _ds_boot.DB_PATH.exists():
|
|
|
|
| 1209 |
except Exception as e: # noqa: BLE001
|
| 1210 |
print(f"[aios-api] mirror seed not scheduled: {e}")
|
| 1211 |
|
| 1212 |
+
# W39-T28 / A62. The deployment code hands these two flags only to an explicitly granted
|
| 1213 |
+
# non-public target (Staging today). Running this in a daemon means an unavailable QA schema
|
| 1214 |
+
# never blocks health/readiness; the SQL procedure is idempotent, and `_provision_qa_sandbox`
|
| 1215 |
+
# itself refuses before opening a pool on every other environment.
|
| 1216 |
+
if (os.environ.get("AIOS_ENABLE_QA_TENANT") == "1"
|
| 1217 |
+
and os.environ.get("STORE_BACKEND") == "pg"):
|
| 1218 |
+
import threading as _threading_qa
|
| 1219 |
+
_threading_qa.Thread(target=_provision_qa_sandbox, daemon=True,
|
| 1220 |
+
name="qa-sandbox-schema").start()
|
| 1221 |
+
|
| 1222 |
if os.environ.get("AIOS_PREWARM") == "1":
|
| 1223 |
import threading as _threading
|
| 1224 |
_threading.Thread(target=_prewarm, daemon=True, name="prewarm").start()
|
api/meta_relational.py
CHANGED
|
@@ -29,6 +29,8 @@ scale is 1 account Β· 55 campaigns Β· 115 ad sets Β· 227 ads Β· creatives Β· ~90
|
|
| 29 |
`ut_*` tables like `ut_odoo_agents`. β If a bigger account ever crosses the cap, `plan()` says so
|
| 30 |
out loud rather than truncating: R6's second sentence, and the check is at the bottom of `plan`.
|
| 31 |
"""
|
|
|
|
|
|
|
| 32 |
import os
|
| 33 |
import sys
|
| 34 |
from pathlib import Path
|
|
@@ -213,6 +215,27 @@ def plan(cur, rt=None):
|
|
| 213 |
return built
|
| 214 |
|
| 215 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 216 |
def apply_plan(rt, built, username="meta", today=None):
|
| 217 |
"""Create-or-merge every table in `built`, in ONE `flush="sync"` write.
|
| 218 |
|
|
@@ -240,12 +263,14 @@ def apply_plan(rt, built, username="meta", today=None):
|
|
| 240 |
return written
|
| 241 |
|
| 242 |
|
| 243 |
-
def
|
| 244 |
-
"""
|
| 245 |
|
| 246 |
Returns `{}` and says why when the mirror has no Meta tables β a tenant that never connected
|
| 247 |
Meta is a normal state, not a failure.
|
| 248 |
"""
|
|
|
|
|
|
|
| 249 |
tenant = tenant_key or getattr(getattr(rt, "tenant", None), "key", None) or "royal-imports"
|
| 250 |
path = datastore.path_for(tenant)
|
| 251 |
if Path(datastore.DB_PATH) != Path(path):
|
|
@@ -254,16 +279,34 @@ def refresh(rt, why="boot", tenant_key=None, log=print):
|
|
| 254 |
if not any(mirror_columns(cur, t) for t in SOURCE.values()):
|
| 255 |
log(f"[meta_relational] {why}: no Meta tables in {tenant}'s mirror β nothing to spawn "
|
| 256 |
f"(run `python platform/harness/meta_store.py --sync --tenant {tenant}` first)")
|
| 257 |
-
return
|
|
|
|
| 258 |
built = plan(cur, rt)
|
| 259 |
-
|
|
|
|
| 260 |
log(f"[meta_relational] PROBLEM {p}")
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 265 |
log(f"[meta_relational] {why}: {key:<24} {counts}")
|
| 266 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
|
| 268 |
|
| 269 |
def main(argv=None):
|
|
|
|
| 29 |
`ut_*` tables like `ut_odoo_agents`. β If a bigger account ever crosses the cap, `plan()` says so
|
| 30 |
out loud rather than truncating: R6's second sentence, and the check is at the bottom of `plan`.
|
| 31 |
"""
|
| 32 |
+
import hashlib
|
| 33 |
+
import json
|
| 34 |
import os
|
| 35 |
import sys
|
| 36 |
from pathlib import Path
|
|
|
|
| 215 |
return built
|
| 216 |
|
| 217 |
|
| 218 |
+
def plan_fingerprint(cur, built):
|
| 219 |
+
"""Digest the exact derived Meta content that would be published.
|
| 220 |
+
|
| 221 |
+
`meta_store.sync()` counts fetched rows, not changed rows, so it is not equality evidence.
|
| 222 |
+
This includes source-table absence, derived field definitions and every row, while treating
|
| 223 |
+
DuckDB's arbitrary row order as non-semantic. Callers fail safe to a refresh if it raises.
|
| 224 |
+
"""
|
| 225 |
+
tables = []
|
| 226 |
+
for key in SOURCE:
|
| 227 |
+
rows = built.get(key) or []
|
| 228 |
+
canonical_rows = sorted(
|
| 229 |
+
rows,
|
| 230 |
+
key=lambda row: json.dumps(row, sort_keys=True, ensure_ascii=True,
|
| 231 |
+
separators=(",", ":"), allow_nan=False),
|
| 232 |
+
)
|
| 233 |
+
tables.append({"key": key, "fields": _fields_for(cur, key), "rows": canonical_rows})
|
| 234 |
+
payload = json.dumps(tables, sort_keys=True, ensure_ascii=True,
|
| 235 |
+
separators=(",", ":"), allow_nan=False)
|
| 236 |
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
| 237 |
+
|
| 238 |
+
|
| 239 |
def apply_plan(rt, built, username="meta", today=None):
|
| 240 |
"""Create-or-merge every table in `built`, in ONE `flush="sync"` write.
|
| 241 |
|
|
|
|
| 263 |
return written
|
| 264 |
|
| 265 |
|
| 266 |
+
def refresh_state(rt, why="boot", tenant_key=None, previous_fingerprint=None, log=print):
|
| 267 |
+
"""Open, plan and conditionally apply the Meta mirror.
|
| 268 |
|
| 269 |
Returns `{}` and says why when the mirror has no Meta tables β a tenant that never connected
|
| 270 |
Meta is a normal state, not a failure.
|
| 271 |
"""
|
| 272 |
+
state = {"present": False, "known": False, "fingerprint": None,
|
| 273 |
+
"changed": False, "applied": False, "written": {}}
|
| 274 |
tenant = tenant_key or getattr(getattr(rt, "tenant", None), "key", None) or "royal-imports"
|
| 275 |
path = datastore.path_for(tenant)
|
| 276 |
if Path(datastore.DB_PATH) != Path(path):
|
|
|
|
| 279 |
if not any(mirror_columns(cur, t) for t in SOURCE.values()):
|
| 280 |
log(f"[meta_relational] {why}: no Meta tables in {tenant}'s mirror β nothing to spawn "
|
| 281 |
f"(run `python platform/harness/meta_store.py --sync --tenant {tenant}` first)")
|
| 282 |
+
return state
|
| 283 |
+
state["present"] = True
|
| 284 |
built = plan(cur, rt)
|
| 285 |
+
problems = built.get("problems") or []
|
| 286 |
+
for p in problems:
|
| 287 |
log(f"[meta_relational] PROBLEM {p}")
|
| 288 |
+
applicable = {k: v for k, v in built.items() if k != "problems"}
|
| 289 |
+
if not problems:
|
| 290 |
+
try:
|
| 291 |
+
state["fingerprint"] = plan_fingerprint(cur, applicable)
|
| 292 |
+
state["known"] = True
|
| 293 |
+
except Exception as e: # noqa: BLE001
|
| 294 |
+
log(f"[meta_relational] {why}: plan fingerprint unavailable; forcing refresh "
|
| 295 |
+
f"({type(e).__name__}: {e})")
|
| 296 |
+
if state["known"] and state["fingerprint"] == previous_fingerprint:
|
| 297 |
+
log(f"[meta_relational] {why}: Meta plan unchanged; skipped full user_tables write")
|
| 298 |
+
return state
|
| 299 |
+
state["changed"] = True
|
| 300 |
+
state["written"] = apply_plan(rt, applicable)
|
| 301 |
+
state["applied"] = True
|
| 302 |
+
for key, counts in state["written"].items():
|
| 303 |
log(f"[meta_relational] {why}: {key:<24} {counts}")
|
| 304 |
+
return state
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
def refresh(rt, why="boot", tenant_key=None, log=print):
|
| 308 |
+
"""Backward-compatible facade. Use `refresh_state` for egress-aware state."""
|
| 309 |
+
return refresh_state(rt, why, tenant_key, log=log)["written"]
|
| 310 |
|
| 311 |
|
| 312 |
def main(argv=None):
|
api/odoo_relational.py
CHANGED
|
@@ -399,6 +399,13 @@ def order_fields():
|
|
| 399 |
"default": False},
|
| 400 |
{"key": "order_date", "label": "Order date", "type": "date", "source": "overlay",
|
| 401 |
"default": True},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 402 |
{"key": "amount_untaxed", "label": "Order $", "type": "currency", "source": "overlay",
|
| 403 |
"default": True, "agg": "sum"},
|
| 404 |
{"key": "team", "label": "Business unit", "type": "select", "source": "overlay",
|
|
@@ -890,11 +897,12 @@ def read_orders(cur, excluded=None):
|
|
| 890 |
excluded = excluded if excluded is not None else excluded_ids(cur)
|
| 891 |
have = columns(cur, "sale_order")
|
| 892 |
sql = (f"SELECT id, name, date_order, partner_id, partner_name, {_col(have, 'team_name')}, "
|
| 893 |
-
f" state, amount_untaxed, {_col(have, 'invoice_status')} "
|
|
|
|
| 894 |
f"FROM sale_order WHERE {_CONFIRMED} AND partner_id IS NOT NULL")
|
| 895 |
out = []
|
| 896 |
for r in cur.execute(sql).fetchall():
|
| 897 |
-
(oid, name, when, pid, pname, team, state, untaxed, inv_status) = r
|
| 898 |
out.append({
|
| 899 |
"_id": str(oid),
|
| 900 |
"order_no": str(name or ""),
|
|
@@ -906,6 +914,8 @@ def read_orders(cur, excluded=None):
|
|
| 906 |
"team": str(team or ""),
|
| 907 |
"state": str(state or ""),
|
| 908 |
"invoice_status": str(inv_status or ""),
|
|
|
|
|
|
|
| 909 |
"wholesale_scope": _in_scope(pid, excluded),
|
| 910 |
})
|
| 911 |
return out
|
|
|
|
| 399 |
"default": False},
|
| 400 |
{"key": "order_date", "label": "Order date", "type": "date", "source": "overlay",
|
| 401 |
"default": True},
|
| 402 |
+
{"key": "commitment_date", "label": "Delivery date", "type": "date",
|
| 403 |
+
"source": "overlay", "default": True,
|
| 404 |
+
"description": "Odoo's promised customer delivery date. Blank only when no promise was entered."},
|
| 405 |
+
{"key": "delivery_status", "label": "Delivery status", "type": "select",
|
| 406 |
+
"source": "overlay", "default": True,
|
| 407 |
+
"options": ["pending", "started", "partial", "full"],
|
| 408 |
+
"description": "Odoo's fulfilment status: pending, started, partial, or full."},
|
| 409 |
{"key": "amount_untaxed", "label": "Order $", "type": "currency", "source": "overlay",
|
| 410 |
"default": True, "agg": "sum"},
|
| 411 |
{"key": "team", "label": "Business unit", "type": "select", "source": "overlay",
|
|
|
|
| 897 |
excluded = excluded if excluded is not None else excluded_ids(cur)
|
| 898 |
have = columns(cur, "sale_order")
|
| 899 |
sql = (f"SELECT id, name, date_order, partner_id, partner_name, {_col(have, 'team_name')}, "
|
| 900 |
+
f" state, amount_untaxed, {_col(have, 'invoice_status')}, "
|
| 901 |
+
f" {_col(have, 'commitment_date')}, {_col(have, 'delivery_status')} "
|
| 902 |
f"FROM sale_order WHERE {_CONFIRMED} AND partner_id IS NOT NULL")
|
| 903 |
out = []
|
| 904 |
for r in cur.execute(sql).fetchall():
|
| 905 |
+
(oid, name, when, pid, pname, team, state, untaxed, inv_status, commitment, delivery) = r
|
| 906 |
out.append({
|
| 907 |
"_id": str(oid),
|
| 908 |
"order_no": str(name or ""),
|
|
|
|
| 914 |
"team": str(team or ""),
|
| 915 |
"state": str(state or ""),
|
| 916 |
"invoice_status": str(inv_status or ""),
|
| 917 |
+
"commitment_date": _as_date(commitment),
|
| 918 |
+
"delivery_status": str(delivery or ""),
|
| 919 |
"wholesale_scope": _in_scope(pid, excluded),
|
| 920 |
})
|
| 921 |
return out
|
api/routes_auth.py
CHANGED
|
@@ -152,7 +152,13 @@ def login(request: Request, response: Response, body: dict = Body(default=None))
|
|
| 152 |
raise err(429, "too_many_attempts",
|
| 153 |
f"too many failed attempts β try again in {wait} seconds")
|
| 154 |
|
| 155 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
if not user:
|
| 157 |
# CONSTANT-TIME-ISH 401. `users.verify` returns immediately for an unknown username (no
|
| 158 |
# record, no PBKDF2) and burns ~200k iterations for a known one, so the response time
|
|
@@ -181,12 +187,16 @@ def login(request: Request, response: Response, body: dict = Body(default=None))
|
|
| 181 |
# stamping what was typed would write onto a key that does not exist. Fail-silent inside
|
| 182 |
# `touch_login` and a no-op for the emergency-master identity (which has no record to stamp),
|
| 183 |
# so the one thing this cannot do is turn a good credential into a failed sign-in.
|
| 184 |
-
|
|
|
|
|
|
|
|
|
|
| 185 |
# `touch_login` also sets `last_active`, so tell the session resolver it has been seen β
|
| 186 |
# otherwise the very next request stamps it again and the hourly throttle is off by one write
|
| 187 |
# per sign-in.
|
| 188 |
import deps as _deps
|
| 189 |
-
|
|
|
|
| 190 |
return {"user": _public_user(user)}
|
| 191 |
|
| 192 |
|
|
|
|
| 152 |
raise err(429, "too_many_attempts",
|
| 153 |
f"too many failed attempts β try again in {wait} seconds")
|
| 154 |
|
| 155 |
+
# W39-T28 / A62. Resolve this synthetic identity FIRST. The normal account registry is
|
| 156 |
+
# tenant #0's document on the PostgreSQL path, so even a harmless `verify()` read would make
|
| 157 |
+
# a qa-b proof touch the wrong schema. `qa_identity` is inert unless both staging flags and
|
| 158 |
+
# APP_PASSWORD are present; every ordinary username falls straight through to `verify()`.
|
| 159 |
+
user = users.qa_identity(uname, pw) if (uname and pw) else None
|
| 160 |
+
if not user:
|
| 161 |
+
user = users.verify(uname, pw) if (uname and pw) else None
|
| 162 |
if not user:
|
| 163 |
# CONSTANT-TIME-ISH 401. `users.verify` returns immediately for an unknown username (no
|
| 164 |
# record, no PBKDF2) and burns ~200k iterations for a known one, so the response time
|
|
|
|
| 187 |
# stamping what was typed would write onto a key that does not exist. Fail-silent inside
|
| 188 |
# `touch_login` and a no-op for the emergency-master identity (which has no record to stamp),
|
| 189 |
# so the one thing this cannot do is turn a good credential into a failed sign-in.
|
| 190 |
+
# The synthetic QA identity has no normal account record to stamp. More importantly, a
|
| 191 |
+
# registry stamp here would default to tenant #0 and make a qa-b proof touch the wrong schema.
|
| 192 |
+
if not user.get("qa_runner"):
|
| 193 |
+
users.touch_login(user["username"])
|
| 194 |
# `touch_login` also sets `last_active`, so tell the session resolver it has been seen β
|
| 195 |
# otherwise the very next request stamps it again and the hourly throttle is off by one write
|
| 196 |
# per sign-in.
|
| 197 |
import deps as _deps
|
| 198 |
+
if not user.get("qa_runner"):
|
| 199 |
+
_deps.note_active(user["username"])
|
| 200 |
return {"user": _public_user(user)}
|
| 201 |
|
| 202 |
|
api/routes_customers.py
CHANGED
|
@@ -21,6 +21,7 @@ SOURCE OF TRUTH; it does not make the two runtimes coherent. The fix is X4/Postg
|
|
| 21 |
owner-blocked on B-3), and no test here may claim read-your-writes ACROSS runtimes β a TestClient
|
| 22 |
proof is single-process and would report green on exactly the thing that is still broken.
|
| 23 |
"""
|
|
|
|
| 24 |
import time
|
| 25 |
|
| 26 |
from fastapi import APIRouter, Body, Depends
|
|
@@ -591,6 +592,23 @@ def _route_slug(label):
|
|
| 591 |
return f"{ROUTE_KEY_PREFIX}{slug[:48]}" if slug else ""
|
| 592 |
|
| 593 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 594 |
def _route_defs(session: Session):
|
| 595 |
"""This topic's route-order columns MINUS the ones this session was not granted.
|
| 596 |
|
|
@@ -634,6 +652,7 @@ def route_order_list(session: Session = Depends(module_gate(MODULE))):
|
|
| 634 |
"roundTrip": bool(route.get("roundTrip")),
|
| 635 |
"startPid": route.get("startPid"),
|
| 636 |
"stops": route.get("stops"),
|
|
|
|
| 637 |
"solvedAt": route.get("solvedAt") or "",
|
| 638 |
"solvedBy": defn.get("createdBy") or "",
|
| 639 |
# Who may re-solve it. The same creator-or-admin wall the write door enforces, said on
|
|
@@ -649,7 +668,7 @@ def route_order_write(body: dict = Body(default=None),
|
|
| 649 |
session: Session = Depends(module_gate(MODULE))):
|
| 650 |
"""Create (or re-solve) a tenant-wide route-order column and fill it, in ONE call.
|
| 651 |
|
| 652 |
-
`{label, field?, ranks: {"<pid>": <int>}, inputsHash, roundTrip?, startPid?,
|
| 653 |
|
| 654 |
β THE DEFINITION IS WRITTEN FIRST AND THE GRANT CLAIMED SECOND, which is `patch_shared_cell`'s
|
| 655 |
order and it is deliberate: the window where a column is MARKED and UNCLAIMED fails CLOSED
|
|
@@ -705,6 +724,12 @@ def route_order_write(body: dict = Body(default=None),
|
|
| 705 |
f"administrator. This one was planned by {owner or 'somebody else'}, and "
|
| 706 |
f"re-solving it would change the visit numbers for every account at once")
|
| 707 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 708 |
raw = body.get("ranks")
|
| 709 |
if not isinstance(raw, dict) or not raw:
|
| 710 |
raise err(400, "bad_ranks", 'expected {ranks: {"<pid>": <visit number>}}')
|
|
@@ -790,6 +815,7 @@ def route_order_write(body: dict = Body(default=None),
|
|
| 790 |
"startPid": int(body["startPid"]) if isinstance(body.get("startPid"), int)
|
| 791 |
and not isinstance(body.get("startPid"), bool) else None,
|
| 792 |
"stops": len(ranks),
|
|
|
|
| 793 |
"solvedAt": stamp,
|
| 794 |
},
|
| 795 |
}
|
|
@@ -830,5 +856,6 @@ def route_order_write(body: dict = Body(default=None),
|
|
| 830 |
out = {"ok": True, "field": key, "label": defn["label"], "type": "int",
|
| 831 |
"stops": len(ranks), "cleared": len(stale),
|
| 832 |
"rows_written": len(written), "inputsHash": defn["route"]["inputsHash"],
|
|
|
|
| 833 |
"solvedAt": stamp}
|
| 834 |
return out
|
|
|
|
| 21 |
owner-blocked on B-3), and no test here may claim read-your-writes ACROSS runtimes β a TestClient
|
| 22 |
proof is single-process and would report green on exactly the thing that is still broken.
|
| 23 |
"""
|
| 24 |
+
import math
|
| 25 |
import time
|
| 26 |
|
| 27 |
from fastapi import APIRouter, Body, Depends
|
|
|
|
| 592 |
return f"{ROUTE_KEY_PREFIX}{slug[:48]}" if slug else ""
|
| 593 |
|
| 594 |
|
| 595 |
+
def _clean_route_depot(raw):
|
| 596 |
+
"""The optional origin stored once on a route-order definition, never on a customer cell."""
|
| 597 |
+
if raw is None:
|
| 598 |
+
return None
|
| 599 |
+
if not isinstance(raw, dict):
|
| 600 |
+
raise err(400, "bad_depot", "a depot is an address with latitude and longitude, or null")
|
| 601 |
+
address = " ".join(str(raw.get("address") or "").split())[:200]
|
| 602 |
+
lat, lon = raw.get("lat"), raw.get("lon")
|
| 603 |
+
if (not address or isinstance(lat, bool) or isinstance(lon, bool) or
|
| 604 |
+
not isinstance(lat, (int, float)) or not isinstance(lon, (int, float))):
|
| 605 |
+
raise err(400, "bad_depot", "a depot needs an address and numeric latitude and longitude")
|
| 606 |
+
lat, lon = float(lat), float(lon)
|
| 607 |
+
if not (math.isfinite(lat) and math.isfinite(lon) and abs(lat) <= 90 and abs(lon) <= 180):
|
| 608 |
+
raise err(400, "bad_depot", "the depot latitude or longitude is outside the map")
|
| 609 |
+
return {"address": address, "lat": lat, "lon": lon}
|
| 610 |
+
|
| 611 |
+
|
| 612 |
def _route_defs(session: Session):
|
| 613 |
"""This topic's route-order columns MINUS the ones this session was not granted.
|
| 614 |
|
|
|
|
| 652 |
"roundTrip": bool(route.get("roundTrip")),
|
| 653 |
"startPid": route.get("startPid"),
|
| 654 |
"stops": route.get("stops"),
|
| 655 |
+
"depot": route.get("depot") if isinstance(route.get("depot"), dict) else None,
|
| 656 |
"solvedAt": route.get("solvedAt") or "",
|
| 657 |
"solvedBy": defn.get("createdBy") or "",
|
| 658 |
# Who may re-solve it. The same creator-or-admin wall the write door enforces, said on
|
|
|
|
| 668 |
session: Session = Depends(module_gate(MODULE))):
|
| 669 |
"""Create (or re-solve) a tenant-wide route-order column and fill it, in ONE call.
|
| 670 |
|
| 671 |
+
`{label, field?, ranks: {"<pid>": <int>}, inputsHash, roundTrip?, startPid?, depot?}`
|
| 672 |
|
| 673 |
β THE DEFINITION IS WRITTEN FIRST AND THE GRANT CLAIMED SECOND, which is `patch_shared_cell`'s
|
| 674 |
order and it is deliberate: the window where a column is MARKED and UNCLAIMED fails CLOSED
|
|
|
|
| 724 |
f"administrator. This one was planned by {owner or 'somebody else'}, and "
|
| 725 |
f"re-solving it would change the visit numbers for every account at once")
|
| 726 |
|
| 727 |
+
if "depot" in body:
|
| 728 |
+
depot = _clean_route_depot(body.get("depot"))
|
| 729 |
+
else:
|
| 730 |
+
prior_route = (existing or {}).get("route") if isinstance(existing, dict) else {}
|
| 731 |
+
depot = prior_route.get("depot") if isinstance(prior_route, dict) else None
|
| 732 |
+
|
| 733 |
raw = body.get("ranks")
|
| 734 |
if not isinstance(raw, dict) or not raw:
|
| 735 |
raise err(400, "bad_ranks", 'expected {ranks: {"<pid>": <visit number>}}')
|
|
|
|
| 815 |
"startPid": int(body["startPid"]) if isinstance(body.get("startPid"), int)
|
| 816 |
and not isinstance(body.get("startPid"), bool) else None,
|
| 817 |
"stops": len(ranks),
|
| 818 |
+
"depot": depot,
|
| 819 |
"solvedAt": stamp,
|
| 820 |
},
|
| 821 |
}
|
|
|
|
| 856 |
out = {"ok": True, "field": key, "label": defn["label"], "type": "int",
|
| 857 |
"stops": len(ranks), "cleared": len(stale),
|
| 858 |
"rows_written": len(written), "inputsHash": defn["route"]["inputsHash"],
|
| 859 |
+
"depot": defn["route"]["depot"],
|
| 860 |
"solvedAt": stamp}
|
| 861 |
return out
|
api/routes_odoo_tables.py
CHANGED
|
@@ -418,6 +418,8 @@ def _odoo_sources():
|
|
| 418 |
"customer": ("partner_name", _s),
|
| 419 |
rel.JOIN_KEY: ("partner_id", _i),
|
| 420 |
"order_date": ("date_order", rel._as_date),
|
|
|
|
|
|
|
| 421 |
"amount_untaxed": ("amount_untaxed", _n),
|
| 422 |
"team": ("team_name", _s),
|
| 423 |
"state": ("state", _s),
|
|
|
|
| 418 |
"customer": ("partner_name", _s),
|
| 419 |
rel.JOIN_KEY: ("partner_id", _i),
|
| 420 |
"order_date": ("date_order", rel._as_date),
|
| 421 |
+
"commitment_date": ("commitment_date", rel._as_date),
|
| 422 |
+
"delivery_status": ("delivery_status", _s),
|
| 423 |
"amount_untaxed": ("amount_untaxed", _n),
|
| 424 |
"team": ("team_name", _s),
|
| 425 |
"state": ("state", _s),
|
api/routes_tables.py
CHANGED
|
@@ -428,6 +428,11 @@ def patch_shared_cell(table_key: str, pid: int, body: dict = Body(default=None),
|
|
| 428 |
if not key:
|
| 429 |
raise err(400, "bad_request", "a field key is required")
|
| 430 |
_defn_or_refuse(session, table_key)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 431 |
from core import shared_overlay
|
| 432 |
import core.perm_scope as perm_scope
|
| 433 |
if not shared_overlay.is_shared(table_key, key, st=session.runtime):
|
|
|
|
| 428 |
if not key:
|
| 429 |
raise err(400, "bad_request", "a field key is required")
|
| 430 |
_defn_or_refuse(session, table_key)
|
| 431 |
+
import core.shares as shares
|
| 432 |
+
if not shares.may_edit("database", table_key, session.uname, is_admin=session.admin,
|
| 433 |
+
st=session.runtime):
|
| 434 |
+
raise err(403, "forbidden",
|
| 435 |
+
"an edit share on this database is required to write a tenant-wide cell")
|
| 436 |
from core import shared_overlay
|
| 437 |
import core.perm_scope as perm_scope
|
| 438 |
if not shared_overlay.is_shared(table_key, key, st=session.runtime):
|
platform/aios_grid.py
CHANGED
|
@@ -26,6 +26,7 @@ Design notes:
|
|
| 26 |
import json
|
| 27 |
import math
|
| 28 |
import re
|
|
|
|
| 29 |
from pathlib import Path
|
| 30 |
|
| 31 |
_HERE = Path(__file__).resolve().parent
|
|
@@ -103,7 +104,22 @@ def _round2(v):
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
|
| 108 |
|
| 109 |
# Types a USER may create from the column menu (owner item 7, 2026-07-26). Mirrors
|
|
|
|
| 26 |
import json
|
| 27 |
import math
|
| 28 |
import re
|
| 29 |
+
from decimal import Decimal, ROUND_HALF_EVEN, localcontext
|
| 30 |
from pathlib import Path
|
| 31 |
|
| 32 |
_HERE = Path(__file__).resolve().parent
|
|
|
|
| 104 |
FLOAT where `round(v)` returns an INT, so every integer column's wire shape would change under
|
| 105 |
an edit that looks purely cosmetic.
|
| 106 |
"""
|
| 107 |
+
if not isinstance(v, (int, float)) or isinstance(v, bool):
|
| 108 |
+
return v
|
| 109 |
+
if not math.isfinite(v):
|
| 110 |
+
return v
|
| 111 |
+
|
| 112 |
+
# `round(12.345, 2)` is 12.35 because its binary float is just above the decimal tie, while
|
| 113 |
+
# DuckDB's `round_even(..., 2)` (the filter and sort expression) correctly yields 12.34.
|
| 114 |
+
# Quantizing the user-visible decimal spelling preserves banker's rounding at cents in both
|
| 115 |
+
# engines. Keep an int an int: a currency value can be integral without changing its wire type.
|
| 116 |
+
decimal = Decimal(str(v))
|
| 117 |
+
# Decimal's default precision is 28 digits; keep the prior `round` behavior for an unusual
|
| 118 |
+
# but valid large finite float instead of making it an InvalidOperation at the payload edge.
|
| 119 |
+
with localcontext() as context:
|
| 120 |
+
context.prec = max(context.prec, decimal.adjusted() + 3)
|
| 121 |
+
rounded = decimal.quantize(Decimal("0.01"), rounding=ROUND_HALF_EVEN)
|
| 122 |
+
return int(rounded) if isinstance(v, int) else float(rounded)
|
| 123 |
|
| 124 |
|
| 125 |
# Types a USER may create from the column menu (owner item 7, 2026-07-26). Mirrors
|
platform/aios_grid_fields.json
CHANGED
|
@@ -387,18 +387,60 @@
|
|
| 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 |
-
"description": "
|
| 395 |
},
|
| 396 |
{
|
| 397 |
-
"key": "
|
| 398 |
-
"label": "
|
| 399 |
-
"type": "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 400 |
"source": "odoo",
|
| 401 |
-
"description": "
|
| 402 |
},
|
| 403 |
{
|
| 404 |
"key": "rev_ytd",
|
|
|
|
| 387 |
"description": "Royal 2 pricelist price for this SKU. Blank when that list prices it nowhere."
|
| 388 |
},
|
| 389 |
{
|
| 390 |
+
"key": "price_1",
|
| 391 |
+
"label": "Price 1",
|
| 392 |
+
"type": "currency",
|
| 393 |
+
"source": "odoo",
|
| 394 |
+
"description": "Cheapest live price for this SKU, across active pricelists."
|
| 395 |
+
},
|
| 396 |
+
{
|
| 397 |
+
"key": "unit_1",
|
| 398 |
+
"label": "Unit 1",
|
| 399 |
+
"type": "text",
|
| 400 |
+
"source": "odoo",
|
| 401 |
+
"description": "Package label for Price 1. Blank means Odoo has no package reference for that price."
|
| 402 |
+
},
|
| 403 |
+
{
|
| 404 |
+
"key": "price_2",
|
| 405 |
+
"label": "Price 2",
|
| 406 |
+
"type": "currency",
|
| 407 |
+
"source": "odoo",
|
| 408 |
+
"description": "Second-cheapest live price for this SKU."
|
| 409 |
+
},
|
| 410 |
+
{
|
| 411 |
+
"key": "unit_2",
|
| 412 |
+
"label": "Unit 2",
|
| 413 |
+
"type": "text",
|
| 414 |
"source": "odoo",
|
| 415 |
+
"description": "Package label for Price 2."
|
| 416 |
},
|
| 417 |
{
|
| 418 |
+
"key": "price_3",
|
| 419 |
+
"label": "Price 3",
|
| 420 |
+
"type": "currency",
|
| 421 |
+
"source": "odoo",
|
| 422 |
+
"description": "Third-cheapest live price for this SKU."
|
| 423 |
+
},
|
| 424 |
+
{
|
| 425 |
+
"key": "unit_3",
|
| 426 |
+
"label": "Unit 3",
|
| 427 |
+
"type": "text",
|
| 428 |
+
"source": "odoo",
|
| 429 |
+
"description": "Package label for Price 3."
|
| 430 |
+
},
|
| 431 |
+
{
|
| 432 |
+
"key": "pre_book_qty",
|
| 433 |
+
"label": "Pre-book qty",
|
| 434 |
+
"type": "int",
|
| 435 |
+
"source": "odoo",
|
| 436 |
+
"description": "Units on confirmed, not-fully-delivered wholesale orders promised after today. Delivery-charge service lines are excluded."
|
| 437 |
+
},
|
| 438 |
+
{
|
| 439 |
+
"key": "open_backlog_qty",
|
| 440 |
+
"label": "Open backlog qty",
|
| 441 |
+
"type": "int",
|
| 442 |
"source": "odoo",
|
| 443 |
+
"description": "Units still owed on confirmed, not-fully-delivered wholesale goods orders, including already-due promises. Delivery-charge service lines are excluded."
|
| 444 |
},
|
| 445 |
{
|
| 446 |
"key": "rev_ytd",
|
platform/core/store_pg.py
CHANGED
|
@@ -107,9 +107,11 @@ def _schema_for(tenant_slug=None):
|
|
| 107 |
# `get_projection`, `exists`, `download_bytes` and `revision` are deliberately unguarded.
|
| 108 |
#
|
| 109 |
# β WHAT THIS DOES NOT COVER, said here rather than discovered later: raw SQL through `_pool()`.
|
| 110 |
-
# `ops/seed_pg_from_hf.py`
|
| 111 |
-
#
|
| 112 |
-
#
|
|
|
|
|
|
|
| 113 |
# =============================================================================================
|
| 114 |
|
| 115 |
#: The tenant slugs whose `t_<slug>` schema holds REAL customer data.
|
|
@@ -293,16 +295,36 @@ def get(name, fresh=False, tenant_slug=None):
|
|
| 293 |
|
| 294 |
|
| 295 |
def get_projection(name, drop=(), tenant_slug=None):
|
| 296 |
-
"""`core.store.get_projection`
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 302 |
"""
|
| 303 |
import core.store as _hf # noqa: PLC0415 β lazy: _project lives with the class
|
| 304 |
-
|
| 305 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 306 |
|
| 307 |
|
| 308 |
def exists(name, tenant_slug=None):
|
|
@@ -509,17 +531,11 @@ class PgStore:
|
|
| 509 |
is exactly what `revision()`'s docstring means by *"a capability only the HF store had would
|
| 510 |
have 500'd at the cutover instead of at review"*, and this is the review.
|
| 511 |
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
fact saves the copy of the dropped keys and nothing on the wire. It is correct, not fast.
|
| 516 |
-
A cheaper pg projection is a `jsonb` column list in the SELECT itself β a real optimisation,
|
| 517 |
-
for the day D-4 actually flips, and not something to build blind against a database that
|
| 518 |
-
does not exist yet (blocker B-3).
|
| 519 |
"""
|
| 520 |
-
|
| 521 |
-
return _hf._project(get(name, tenant_slug=self.tenant_slug),
|
| 522 |
-
frozenset(str(d) for d in drop))
|
| 523 |
|
| 524 |
def _read_strict(self, name):
|
| 525 |
"""Interface parity with the HF Store. There, `get` is lenient (swallows a transient
|
|
|
|
| 107 |
# `get_projection`, `exists`, `download_bytes` and `revision` are deliberately unguarded.
|
| 108 |
#
|
| 109 |
# β WHAT THIS DOES NOT COVER, said here rather than discovered later: raw SQL through `_pool()`.
|
| 110 |
+
# `ops/seed_pg_from_hf.py` now calls `check_write(slug, operation="schema provisioning")` for
|
| 111 |
+
# every requested tenant before it opens its migration connection (D-462), but that is an explicit
|
| 112 |
+
# caller-side boundary, not a property of the handle. The guard remains on the STORE INTERFACE β
|
| 113 |
+
# the four functions every product write crosses β so any future raw-SQL writer must make the same
|
| 114 |
+
# explicit decision before it touches `_pool()`; it must not widen the low-level handle's contract.
|
| 115 |
# =============================================================================================
|
| 116 |
|
| 117 |
#: The tenant slugs whose `t_<slug>` schema holds REAL customer data.
|
|
|
|
| 295 |
|
| 296 |
|
| 297 |
def get_projection(name, drop=(), tenant_slug=None):
|
| 298 |
+
"""`core.store.get_projection` with the DROP performed inside Postgres.
|
| 299 |
+
|
| 300 |
+
`user_tables` is one JSONB value, and tenant #0's stored ``rows`` have measured at 28.6 MB
|
| 301 |
+
while the definitions a navigation or permission read needs are about 31 KB. Calling
|
| 302 |
+
:func:`get` and then projecting in Python therefore preserves the HF return shape but sends
|
| 303 |
+
all 28.6 MB through Neon's public proxy first β exactly the egress this operation exists to
|
| 304 |
+
avoid. The nested ``jsonb_each`` / ``jsonb_object_agg`` expression removes each requested
|
| 305 |
+
key from EVERY top-level table definition before the value leaves Postgres.
|
| 306 |
+
|
| 307 |
+
`_project` still owns the returned `_Projected` stamps and their loud ``KeyError`` semantics;
|
| 308 |
+
applying the same drop a second time is idempotent. Keeping that one owner means callers
|
| 309 |
+
retain identical behaviour on both backends. The ``CASE`` retains the generic store contract:
|
| 310 |
+
non-object JSON values are returned untouched rather than making ``jsonb_each`` error.
|
| 311 |
"""
|
| 312 |
import core.store as _hf # noqa: PLC0415 β lazy: _project lives with the class
|
| 313 |
+
|
| 314 |
+
drops = tuple(str(d) for d in drop)
|
| 315 |
+
with _pool().connection() as con:
|
| 316 |
+
row = con.execute(
|
| 317 |
+
_q('SELECT CASE '
|
| 318 |
+
"WHEN jsonb_typeof(kv.value) = 'object' THEN "
|
| 319 |
+
"COALESCE((SELECT jsonb_object_agg(e.entry_key, "
|
| 320 |
+
"CASE WHEN jsonb_typeof(e.entry_value) = 'object' "
|
| 321 |
+
'THEN e.entry_value - %s::text[] ELSE e.entry_value END) '
|
| 322 |
+
'FROM jsonb_each(kv.value) AS e(entry_key, entry_value)), '
|
| 323 |
+
"'{}'::jsonb) ELSE kv.value END FROM {tbl} AS kv WHERE kv.key = %s",
|
| 324 |
+
'store_kv', tenant_slug),
|
| 325 |
+
(list(drops), str(name))).fetchone()
|
| 326 |
+
data = dict(row[0]) if row and isinstance(row[0], dict) else (row[0] if row else {})
|
| 327 |
+
return _hf._project(data, frozenset(drops))
|
| 328 |
|
| 329 |
|
| 330 |
def exists(name, tenant_slug=None):
|
|
|
|
| 531 |
is exactly what `revision()`'s docstring means by *"a capability only the HF store had would
|
| 532 |
have 500'd at the cutover instead of at review"*, and this is the review.
|
| 533 |
|
| 534 |
+
On Postgres, this method delegates to the module-level JSONB query above. The requested
|
| 535 |
+
nested keys are removed before the value crosses the database connection; keeping the
|
| 536 |
+
tenant binding here ensures request-scoped callers receive that egress-saving path too.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 537 |
"""
|
| 538 |
+
return get_projection(name, drop=drop, tenant_slug=self.tenant_slug)
|
|
|
|
|
|
|
| 539 |
|
| 540 |
def _read_strict(self, name):
|
| 541 |
"""Interface parity with the HF Store. There, `get` is lenient (swallows a transient
|
platform/core/users.py
CHANGED
|
@@ -22,6 +22,50 @@ _ITER = 200_000
|
|
| 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 |
|
|
|
|
| 22 |
PERMS_VERSION = 1
|
| 23 |
|
| 24 |
|
| 25 |
+
# W39-T28 / A62. This is deliberately NOT another record in ``users``. The registry is the
|
| 26 |
+
# product's normal account plane and, on Postgres, tenant #0's schema; using it to mint a test
|
| 27 |
+
# identity would make a staging QA check depend on (and potentially mutate) a production-shaped
|
| 28 |
+
# account document. The identity exists only on an explicitly-granted non-production build.
|
| 29 |
+
_QA_TENANT = 'qa-b'
|
| 30 |
+
_QA_USERNAME = 'qa-runner'
|
| 31 |
+
_QA_TENANT_GRANT = 'AIOS_SANDBOX_TENANTS'
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def qa_identity(username, pw=None):
|
| 35 |
+
"""Return the strictly staging-only QA runner, or ``None``.
|
| 36 |
+
|
| 37 |
+
Both deployment knobs are required: registration without a schema grant may not authenticate
|
| 38 |
+
a writer, and a typed grant without the registration flag may not mint an identity. The
|
| 39 |
+
helper intentionally does not read or write the ordinary user registry. Live deployments
|
| 40 |
+
remove both flags, so even possession of ``APP_PASSWORD`` cannot turn this into a live login.
|
| 41 |
+
|
| 42 |
+
``pw=None`` is the session-resolution form: the signed cookie was authenticated at login;
|
| 43 |
+
the same deployment guards still have to hold when it is read back.
|
| 44 |
+
"""
|
| 45 |
+
uname = (username or '').strip().lower()
|
| 46 |
+
if uname != _QA_USERNAME:
|
| 47 |
+
return None
|
| 48 |
+
if os.environ.get('AIOS_ENABLE_QA_TENANT') != '1':
|
| 49 |
+
return None
|
| 50 |
+
granted = {s.strip().lower() for s in
|
| 51 |
+
str(os.environ.get(_QA_TENANT_GRANT) or '').split(',') if s.strip()}
|
| 52 |
+
if _QA_TENANT not in granted:
|
| 53 |
+
return None
|
| 54 |
+
master = os.environ.get('APP_PASSWORD', '')
|
| 55 |
+
if not master:
|
| 56 |
+
return None
|
| 57 |
+
if pw is not None and not hmac.compare_digest(str(pw), master):
|
| 58 |
+
return None
|
| 59 |
+
# ``user`` is intentional: this runner can create its own scratch database and field, but
|
| 60 |
+
# has no administration or account-enumeration capability. Its epoch is derived without
|
| 61 |
+
# exposing the secret; rotating APP_PASSWORD invalidates outstanding QA cookies just as an
|
| 62 |
+
# account-record epoch bump would, while still doing zero registry I/O.
|
| 63 |
+
qa_epoch = int.from_bytes(hashlib.sha256(master.encode('utf-8')).digest()[:4], 'big')
|
| 64 |
+
return {'username': _QA_USERNAME, 'name': 'QA Runner', 'role': 'user',
|
| 65 |
+
'bus': 'all', 'modules': ['customers'], 'tenant': _QA_TENANT,
|
| 66 |
+
'epoch': qa_epoch, 'qa_runner': True}
|
| 67 |
+
|
| 68 |
+
|
| 69 |
def _hash(pw, salt):
|
| 70 |
return hashlib.pbkdf2_hmac('sha256', str(pw).encode('utf-8'), bytes.fromhex(salt), _ITER).hex()
|
| 71 |
|
platform/harness/datastore.py
CHANGED
|
@@ -156,8 +156,12 @@ def use_path(path):
|
|
| 156 |
ENTITIES = {
|
| 157 |
"sale_order": {
|
| 158 |
"model": "sale.order", "archivable": False,
|
| 159 |
-
"fields": ["name", "date_order", "
|
| 160 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
},
|
| 162 |
"sale_order_line": {
|
| 163 |
"model": "sale.order.line", "archivable": False,
|
|
|
|
| 156 |
ENTITIES = {
|
| 157 |
"sale_order": {
|
| 158 |
"model": "sale.order", "archivable": False,
|
| 159 |
+
"fields": ["name", "date_order", "commitment_date", "delivery_status", "partner_id",
|
| 160 |
+
"team_id", "user_id", "state", "amount_untaxed", "invoice_status",
|
| 161 |
+
"write_date"],
|
| 162 |
+
# Existing orders do not receive a new write_date when our schema grows. Without this
|
| 163 |
+
# declared, resumable pass the two ALTERed columns remain NULL on every old order.
|
| 164 |
+
"backfill_fields": ["commitment_date", "delivery_status"],
|
| 165 |
},
|
| 166 |
"sale_order_line": {
|
| 167 |
"model": "sale.order.line", "archivable": False,
|
platform/harness/filter_sql.py
CHANGED
|
@@ -195,10 +195,12 @@ def _value_sql(spec):
|
|
| 195 |
comparison but ordered by the raw timestamp is the obvious way to get that wrong), so the
|
| 196 |
next sort key would break the tie in one engine and not the other.
|
| 197 |
|
| 198 |
-
numeric: `round_even(x,
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
|
|
|
|
|
|
| 202 |
date: truncate to the 10-char ISO shape the payload carries, so lexical compare is
|
| 203 |
chronological and a timestamp column cannot smuggle ' 00:00:00' into the comparison.
|
| 204 |
text: lowercased, matching `String(raw ?? "").toLowerCase()`.
|
|
@@ -206,7 +208,8 @@ def _value_sql(spec):
|
|
| 206 |
e = spec['sql']
|
| 207 |
t = spec['type']
|
| 208 |
if t in NUMERIC_TYPES:
|
| 209 |
-
|
|
|
|
| 210 |
if t == 'date':
|
| 211 |
return f"COALESCE(SUBSTR(CAST({e} AS VARCHAR), 1, 10), '')"
|
| 212 |
return f"LOWER(COALESCE(CAST({e} AS VARCHAR), ''))"
|
|
|
|
| 195 |
comparison but ordered by the raw timestamp is the obvious way to get that wrong), so the
|
| 196 |
next sort key would break the tie in one engine and not the other.
|
| 197 |
|
| 198 |
+
numeric: `round_even(x, places)` reproduces the displayed banker's rounding: `_round` at
|
| 199 |
+
zero decimal places, and `_round2` (Decimal quantization) for currency/pct at two. DuckDB's
|
| 200 |
+
plain ROUND() rounds half AWAY from zero, so ROUND(1234.5)=1235 while the grid shows 1234 β
|
| 201 |
+
`eq 1234` would then match on screen and miss in SQL. The grid displays rounded values;
|
| 202 |
+
filters and sort keys must agree with what is on screen. Currency and percentages retain two
|
| 203 |
+
decimal places; integral types retain zero.
|
| 204 |
date: truncate to the 10-char ISO shape the payload carries, so lexical compare is
|
| 205 |
chronological and a timestamp column cannot smuggle ' 00:00:00' into the comparison.
|
| 206 |
text: lowercased, matching `String(raw ?? "").toLowerCase()`.
|
|
|
|
| 208 |
e = spec['sql']
|
| 209 |
t = spec['type']
|
| 210 |
if t in NUMERIC_TYPES:
|
| 211 |
+
places = 2 if t in {'currency', 'pct'} else 0
|
| 212 |
+
return f"COALESCE(round_even(TRY_CAST({e} AS DOUBLE), {places}), 0)"
|
| 213 |
if t == 'date':
|
| 214 |
return f"COALESCE(SUBSTR(CAST({e} AS VARCHAR), 1, 10), '')"
|
| 215 |
return f"LOWER(COALESCE(CAST({e} AS VARCHAR), ''))"
|
platform/harness/pg/schema.sql
CHANGED
|
@@ -9,10 +9,19 @@
|
|
| 9 |
-- one file per tenant, because file-per-tenant IS the isolation model there.
|
| 10 |
--
|
| 11 |
-- β
RUN AGAINST A REAL SERVER 2026-08-04 (W19): applied end-to-end by verify_store_pg's
|
| 12 |
-
-- integration half against the owner's Neon (psycopg executes everything ABOVE the
|
| 13 |
-
--
|
| 14 |
-
-- jsonb/bytea round-trips and 80 concurrent FOR-UPDATE
|
| 15 |
-
-- parked behind C1e's triggers; `STORE_BACKEND` still
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
--
|
| 17 |
-- β CO-LOCATE (C1b). An Ashburn app with a European database adds ~90ms to every query and undoes
|
| 18 |
-- the reason the market pivot happened.
|
|
@@ -163,18 +172,31 @@ BEGIN
|
|
| 163 |
END;
|
| 164 |
$$;
|
| 165 |
|
| 166 |
-
--
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
--
|
| 173 |
-
--
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
\if :{?slug}
|
| 175 |
INSERT INTO control.tenants (slug, name) VALUES (:'slug', :'slug')
|
| 176 |
ON CONFLICT (slug) DO NOTHING;
|
| 177 |
SELECT control.provision_tenant_schema(:'slug');
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
\endif
|
| 179 |
|
| 180 |
COMMIT;
|
|
|
|
| 9 |
-- one file per tenant, because file-per-tenant IS the isolation model there.
|
| 10 |
--
|
| 11 |
-- β
RUN AGAINST A REAL SERVER 2026-08-04 (W19): applied end-to-end by verify_store_pg's
|
| 12 |
+
-- integration half against the owner's Neon (psycopg executes everything ABOVE the psql-only
|
| 13 |
+
-- TAIL SENTINEL near the bottom of this file; the tail beneath it is psql-variable syntax that
|
| 14 |
+
-- no driver can run). Tenant provisioning, jsonb/bytea round-trips and 80 concurrent FOR-UPDATE
|
| 15 |
+
-- writes all proven. The CUTOVER remains parked behind C1e's triggers; `STORE_BACKEND` still
|
| 16 |
+
-- defaults to hf.
|
| 17 |
+
--
|
| 18 |
+
-- β D-461 β THIS PARAGRAPH QUOTES THE SENTINEL ("-- Optional:"), AND THAT ONCE COST US THE
|
| 19 |
+
-- SCHEMA. The seeder split on that marker as a bare SUBSTRING, matched the quotation here
|
| 20 |
+
-- instead of the real one 160 lines below, and applied 916 of these ~10,000 characters:
|
| 21 |
+
-- comments, zero executable SQL, no provisioning function. The splitter is now LINE-ANCHORED
|
| 22 |
+
-- and asserts what it hands over, so the quotation above is deliberate and stays: it is the
|
| 23 |
+
-- standing proof, in the shipping file, that a prose mention no longer truncates anything.
|
| 24 |
+
-- What no line may do is BEGIN with the marker. Only the sentinel does.
|
| 25 |
--
|
| 26 |
-- β CO-LOCATE (C1b). An Ashburn app with a European database adds ~90ms to every query and undoes
|
| 27 |
-- the reason the market pivot happened.
|
|
|
|
| 172 |
END;
|
| 173 |
$$;
|
| 174 |
|
| 175 |
+
-- βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 176 |
+
-- ββ THE NEXT LINE IS THE TAIL SENTINEL. IT IS AN INTERFACE, NOT A COMMENT.
|
| 177 |
+
--
|
| 178 |
+
-- Every driver-based reader of this file (`ops/seed_pg_from_hf.py`, and verify_store_pg's
|
| 179 |
+
-- integration half through the same helper) truncates HERE, because everything below is psql
|
| 180 |
+
-- meta-syntax that psycopg cannot execute. The contract is exact: the reader cuts at the first
|
| 181 |
+
-- line that BEGINS, at column 0, with "-- Optional:".
|
| 182 |
+
--
|
| 183 |
+
-- β SO: never start a line in this file with that marker except the one below, and never move
|
| 184 |
+
-- the sentinel above the provisioning function. Discussing the marker mid-line is fine and this
|
| 185 |
+
-- comment does it deliberately, because D-461 was a bare substring split matching a quotation
|
| 186 |
+
-- 160 lines above the real thing. The seeder now asserts the surviving body still creates that
|
| 187 |
+
-- function and ABORTS otherwise, but an abort is a cutover that did not run.
|
| 188 |
+
-- βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 189 |
+
-- Optional: `psql -v slug=<a-slug>` provisions that tenant schema in the same transaction.
|
| 190 |
+
-- With no `slug`, plain psql retains the default tenant #0 provisioning below. Driver users stop
|
| 191 |
+
-- before this psql-only tail: the seeder provisions only the explicitly guarded tenant rows.
|
| 192 |
\if :{?slug}
|
| 193 |
INSERT INTO control.tenants (slug, name) VALUES (:'slug', :'slug')
|
| 194 |
ON CONFLICT (slug) DO NOTHING;
|
| 195 |
SELECT control.provision_tenant_schema(:'slug');
|
| 196 |
+
\else
|
| 197 |
+
INSERT INTO control.tenants (slug, name) VALUES ('royal-imports', 'Royal Imports')
|
| 198 |
+
ON CONFLICT (slug) DO NOTHING;
|
| 199 |
+
SELECT control.provision_tenant_schema('royal-imports');
|
| 200 |
\endif
|
| 201 |
|
| 202 |
COMMIT;
|
platform/harness/semantic.py
CHANGED
|
@@ -617,9 +617,31 @@ def store_columns(topic, include_measures=True, grain="aggregate"):
|
|
| 617 |
MAX_GROUPS = 200_000
|
| 618 |
|
| 619 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 620 |
def store_query(topic, measures, group_by=None, grain=None, date_from=None, date_to=None,
|
| 621 |
team_id=None, filters=None, sort=None, limit=1000, exclude_services=False,
|
| 622 |
-
filter_tree=None, filter_conj="and", today=None):
|
| 623 |
"""The semantic query over the tenant store β the engine behind the Analyst's
|
| 624 |
run_semantic_query tool and the saved-view re-runner. All keys whitelisted against the model."""
|
| 625 |
t = _model()["topics"].get(topic)
|
|
@@ -685,7 +707,7 @@ def store_query(topic, measures, group_by=None, grain=None, date_from=None, date
|
|
| 685 |
for k in base_keys:
|
| 686 |
select.append(f"{_measure_sql(_model()['metrics'][k], alias)} AS {k}")
|
| 687 |
|
| 688 |
-
where = [
|
| 689 |
# TYPE-CORRECT date bounds (the 2025-07-01 lesson): columns hold ISO strings in TWO shapes β
|
| 690 |
# datetimes ('YYYY-MM-DD HH:MM:SS', sale date_order) and bare dates ('YYYY-MM-DD', GL date).
|
| 691 |
# Lexical string comparison EXCLUDES the window's first day for bare dates ('2025-07-01' <
|
|
@@ -696,7 +718,7 @@ def store_query(topic, measures, group_by=None, grain=None, date_from=None, date
|
|
| 696 |
if date_to:
|
| 697 |
where.append(f"CAST({s['date_col']} AS TIMESTAMP) <= ?")
|
| 698 |
params.append(f"{date_to} 23:59:59")
|
| 699 |
-
if team_id:
|
| 700 |
if not s.get("team_col"):
|
| 701 |
raise ModelError(f"topic {topic!r} is company-level β it has no business-unit filter")
|
| 702 |
where.append(f"{s['team_col']} = ?"); params.append(int(team_id))
|
|
@@ -971,7 +993,8 @@ def topic_for_grid(grid_key):
|
|
| 971 |
|
| 972 |
|
| 973 |
def entity_measure_bindings(topic):
|
| 974 |
-
"""`[{source, dim, keys, not_yet}, β¦]` β every FACT topic this entity draws
|
|
|
|
| 975 |
|
| 976 |
ββ A LIST, NOT ONE BINDING, since W37-T13. An entity's columns legitimately come from more
|
| 977 |
than one fact topic: the product grid takes revenue/units/margin from `sales_lines` and stock
|
|
@@ -997,7 +1020,9 @@ def entity_measure_bindings(topic):
|
|
| 997 |
for b in raw:
|
| 998 |
if not isinstance(b, dict) or not b.get("source") or not b.get("dim"):
|
| 999 |
continue
|
|
|
|
| 1000 |
out.append({"source": str(b["source"]), "dim": str(b["dim"]),
|
|
|
|
| 1001 |
"keys": list(b.get("keys") or []), "not_yet": list(b.get("not_yet") or [])})
|
| 1002 |
return out
|
| 1003 |
|
|
@@ -1135,6 +1160,9 @@ def _one_binding_offer(topic, b, strict=False):
|
|
| 1135 |
raise _EntityMeasureError(
|
| 1136 |
f"{topic}: measure dim {b['dim']!r} is not a dim of {b['source']!r} "
|
| 1137 |
f"(allowed: {sorted(dims)})")
|
|
|
|
|
|
|
|
|
|
| 1138 |
# β THE TABLE CHECK, before any key is offered from this binding.
|
| 1139 |
_tbl = (src["store"] or {}).get("table")
|
| 1140 |
_rdy = _source_ready(_tbl) if _tbl else True
|
|
@@ -1215,6 +1243,9 @@ def _one_binding_offer(topic, b, strict=False):
|
|
| 1215 |
"description": m.get("description") or "",
|
| 1216 |
"topic": b["source"],
|
| 1217 |
"dim": b["dim"],
|
|
|
|
|
|
|
|
|
|
| 1218 |
})
|
| 1219 |
return out, refused
|
| 1220 |
|
|
@@ -1255,23 +1286,25 @@ def entity_measure_values(topic, keys, date_from=None, date_to=None, team_id=Non
|
|
| 1255 |
want = [k for k in keys if k in by_key]
|
| 1256 |
if not want:
|
| 1257 |
return {}
|
| 1258 |
-
# β ONE QUERY PER SOURCE TOPIC (W37-T13). Keys from `sales_lines` and keys from `stock_moves`
|
| 1259 |
# are different tables joined by different dims, so they cannot ride one scan β but every key
|
| 1260 |
# WITHIN a source still does, which is the whole reason the six product metrics cost one query.
|
| 1261 |
groups = {}
|
| 1262 |
for k in want:
|
| 1263 |
-
groups.setdefault((by_key[k]["topic"], by_key[k]["dim"]
|
|
|
|
| 1264 |
if len(groups) > 1:
|
| 1265 |
merged = {}
|
| 1266 |
-
for (
|
| 1267 |
for gk, cell in entity_measure_values(
|
| 1268 |
topic, ks, date_from=date_from, date_to=date_to, team_id=team_id,
|
| 1269 |
exclude_services=exclude_services, offer=offer).items():
|
| 1270 |
merged.setdefault(gk, {}).update(cell)
|
| 1271 |
return merged
|
| 1272 |
-
(src, dim), want = next(iter(groups.items()))
|
| 1273 |
res = store_query(src, want, group_by=[dim], date_from=date_from, date_to=date_to,
|
| 1274 |
-
team_id=team_id, exclude_services=exclude_services, limit=MAX_GROUPS
|
|
|
|
| 1275 |
# β A TRUNCATED GROUP SET IS A WRONG ANSWER PER ROW, not a short list β the same refusal
|
| 1276 |
# `rollup_sql.group_values` makes, for the same reason. Never write cells from one.
|
| 1277 |
if res.get("truncated"):
|
|
@@ -1372,7 +1405,11 @@ def entity_measure_sets(topic, rules, today, team_id=None, keys_by_id=None, offe
|
|
| 1372 |
def values_for(mkey, window):
|
| 1373 |
"""`{group key: value}` for one (measure, window), memoised within this call."""
|
| 1374 |
w = _wn.normalize(window)
|
| 1375 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1376 |
if sig not in cache:
|
| 1377 |
rng = _wn.resolve(window, today)
|
| 1378 |
if rng is None:
|
|
|
|
| 617 |
MAX_GROUPS = 200_000
|
| 618 |
|
| 619 |
|
| 620 |
+
def _scope_for_binding(store, scope_variant=None):
|
| 621 |
+
"""The effective store scope for one ENTITY measure binding.
|
| 622 |
+
|
| 623 |
+
Most callers use a FACT topic's declared `scope_sql` unchanged. An entity can instead opt
|
| 624 |
+
into one of this deliberately closed set of variants in its *binding* (not in the source
|
| 625 |
+
topic): W39-T11's Odoo-product movement columns include Amazon, while the `sales_lines`
|
| 626 |
+
topic remains the wholesale truth for customer grain, Analyst, saved views, and rollups.
|
| 627 |
+
|
| 628 |
+
`all_channels` is intentionally defined as confirmed sales only. It drops BOTH wholesale
|
| 629 |
+
fences (team and GIFTWARE partner) but does not broaden the fact to quotes/cancellations.
|
| 630 |
+
A BU argument must likewise be ignored under this variant: filtering team 5 or 6 after
|
| 631 |
+
opening the scope would silently throw Amazon (team 7) back out.
|
| 632 |
+
"""
|
| 633 |
+
if scope_variant is None:
|
| 634 |
+
return render_scope(store["scope_sql"].strip())
|
| 635 |
+
if scope_variant == "all_channels":
|
| 636 |
+
if store.get("table") != "sale_order_line":
|
| 637 |
+
raise ModelError("scope_variant 'all_channels' is only defined for sale_order_line")
|
| 638 |
+
return "o.state IN ('sale','done')"
|
| 639 |
+
raise ModelError(f"unknown entity-measure scope_variant {scope_variant!r}")
|
| 640 |
+
|
| 641 |
+
|
| 642 |
def store_query(topic, measures, group_by=None, grain=None, date_from=None, date_to=None,
|
| 643 |
team_id=None, filters=None, sort=None, limit=1000, exclude_services=False,
|
| 644 |
+
filter_tree=None, filter_conj="and", today=None, scope_variant=None):
|
| 645 |
"""The semantic query over the tenant store β the engine behind the Analyst's
|
| 646 |
run_semantic_query tool and the saved-view re-runner. All keys whitelisted against the model."""
|
| 647 |
t = _model()["topics"].get(topic)
|
|
|
|
| 707 |
for k in base_keys:
|
| 708 |
select.append(f"{_measure_sql(_model()['metrics'][k], alias)} AS {k}")
|
| 709 |
|
| 710 |
+
where = [_scope_for_binding(s, scope_variant)]
|
| 711 |
# TYPE-CORRECT date bounds (the 2025-07-01 lesson): columns hold ISO strings in TWO shapes β
|
| 712 |
# datetimes ('YYYY-MM-DD HH:MM:SS', sale date_order) and bare dates ('YYYY-MM-DD', GL date).
|
| 713 |
# Lexical string comparison EXCLUDES the window's first day for bare dates ('2025-07-01' <
|
|
|
|
| 718 |
if date_to:
|
| 719 |
where.append(f"CAST({s['date_col']} AS TIMESTAMP) <= ?")
|
| 720 |
params.append(f"{date_to} 23:59:59")
|
| 721 |
+
if team_id and scope_variant != "all_channels":
|
| 722 |
if not s.get("team_col"):
|
| 723 |
raise ModelError(f"topic {topic!r} is company-level β it has no business-unit filter")
|
| 724 |
where.append(f"{s['team_col']} = ?"); params.append(int(team_id))
|
|
|
|
| 993 |
|
| 994 |
|
| 995 |
def entity_measure_bindings(topic):
|
| 996 |
+
"""`[{source, dim, keys, scope_variant, not_yet}, β¦]` β every FACT topic this entity draws
|
| 997 |
+
columns from.
|
| 998 |
|
| 999 |
ββ A LIST, NOT ONE BINDING, since W37-T13. An entity's columns legitimately come from more
|
| 1000 |
than one fact topic: the product grid takes revenue/units/margin from `sales_lines` and stock
|
|
|
|
| 1020 |
for b in raw:
|
| 1021 |
if not isinstance(b, dict) or not b.get("source") or not b.get("dim"):
|
| 1022 |
continue
|
| 1023 |
+
variant = b.get("scope_variant")
|
| 1024 |
out.append({"source": str(b["source"]), "dim": str(b["dim"]),
|
| 1025 |
+
"scope_variant": str(variant) if variant is not None else None,
|
| 1026 |
"keys": list(b.get("keys") or []), "not_yet": list(b.get("not_yet") or [])})
|
| 1027 |
return out
|
| 1028 |
|
|
|
|
| 1160 |
raise _EntityMeasureError(
|
| 1161 |
f"{topic}: measure dim {b['dim']!r} is not a dim of {b['source']!r} "
|
| 1162 |
f"(allowed: {sorted(dims)})")
|
| 1163 |
+
# Validate the declared variant at OFFER time. An unanswerable binding must be refused
|
| 1164 |
+
# before the UI promises a metric column that only errors when its first cell is fetched.
|
| 1165 |
+
_scope_for_binding(src["store"], b.get("scope_variant"))
|
| 1166 |
# β THE TABLE CHECK, before any key is offered from this binding.
|
| 1167 |
_tbl = (src["store"] or {}).get("table")
|
| 1168 |
_rdy = _source_ready(_tbl) if _tbl else True
|
|
|
|
| 1243 |
"description": m.get("description") or "",
|
| 1244 |
"topic": b["source"],
|
| 1245 |
"dim": b["dim"],
|
| 1246 |
+
# The resolver carries this alongside source/dim so a second binding to the same
|
| 1247 |
+
# fact topic cannot accidentally borrow the source topic's default scope.
|
| 1248 |
+
"scope_variant": b.get("scope_variant"),
|
| 1249 |
})
|
| 1250 |
return out, refused
|
| 1251 |
|
|
|
|
| 1286 |
want = [k for k in keys if k in by_key]
|
| 1287 |
if not want:
|
| 1288 |
return {}
|
| 1289 |
+
# β ONE QUERY PER SOURCE TOPIC *and binding scope* (W37-T13/W39-T11). Keys from `sales_lines` and keys from `stock_moves`
|
| 1290 |
# are different tables joined by different dims, so they cannot ride one scan β but every key
|
| 1291 |
# WITHIN a source still does, which is the whole reason the six product metrics cost one query.
|
| 1292 |
groups = {}
|
| 1293 |
for k in want:
|
| 1294 |
+
groups.setdefault((by_key[k]["topic"], by_key[k]["dim"],
|
| 1295 |
+
by_key[k].get("scope_variant")), []).append(k)
|
| 1296 |
if len(groups) > 1:
|
| 1297 |
merged = {}
|
| 1298 |
+
for (_src, _dim, _variant), ks in groups.items():
|
| 1299 |
for gk, cell in entity_measure_values(
|
| 1300 |
topic, ks, date_from=date_from, date_to=date_to, team_id=team_id,
|
| 1301 |
exclude_services=exclude_services, offer=offer).items():
|
| 1302 |
merged.setdefault(gk, {}).update(cell)
|
| 1303 |
return merged
|
| 1304 |
+
(src, dim, scope_variant), want = next(iter(groups.items()))
|
| 1305 |
res = store_query(src, want, group_by=[dim], date_from=date_from, date_to=date_to,
|
| 1306 |
+
team_id=team_id, exclude_services=exclude_services, limit=MAX_GROUPS,
|
| 1307 |
+
scope_variant=scope_variant)
|
| 1308 |
# β A TRUNCATED GROUP SET IS A WRONG ANSWER PER ROW, not a short list β the same refusal
|
| 1309 |
# `rollup_sql.group_values` makes, for the same reason. Never write cells from one.
|
| 1310 |
if res.get("truncated"):
|
|
|
|
| 1405 |
def values_for(mkey, window):
|
| 1406 |
"""`{group key: value}` for one (measure, window), memoised within this call."""
|
| 1407 |
w = _wn.normalize(window)
|
| 1408 |
+
# The same resolver serves both Metric CELLS and Metric FILTERS. Keep the binding
|
| 1409 |
+
# scope in this cache identity so a future duplicate key cannot make conditions answer
|
| 1410 |
+
# against a differently scoped value than the cells render.
|
| 1411 |
+
sig = (mkey, by_key[mkey].get("scope_variant"),
|
| 1412 |
+
None if w is None else tuple(sorted(w.items())))
|
| 1413 |
if sig not in cache:
|
| 1414 |
rng = _wn.resolve(window, today)
|
| 1415 |
if rng is None:
|
platform/model/topics/odoo_products.yml
CHANGED
|
@@ -50,6 +50,12 @@ store:
|
|
| 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]
|
|
@@ -131,6 +137,46 @@ fields:
|
|
| 131 |
type: currency
|
| 132 |
kind: data
|
| 133 |
means: "Royal 2 pricelist price for this SKU. Blank when that list prices it nowhere."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
- key: rev_ytd
|
| 135 |
label: "Revenue YTD"
|
| 136 |
type: currency
|
|
|
|
| 50 |
measures:
|
| 51 |
- source: sales_lines
|
| 52 |
dim: product_code
|
| 53 |
+
# W39-T11 / R4: this is deliberately a BINDING choice, not a change to sales_lines'
|
| 54 |
+
# wholesale scope. Product movement must include Amazon; the same sales_lines topic also
|
| 55 |
+
# drives customer/Analyst work, which remains wholesale-only. semantic.py owns the small,
|
| 56 |
+
# closed vocabulary of variants and suppresses a caller's BU fence for all_channels too:
|
| 57 |
+
# crm.team 7 is not part of either wholesale BU.
|
| 58 |
+
scope_variant: all_channels
|
| 59 |
# β The SHIP-FIRST six of `proto/P3-metric-catalog.md`, and they cost ONE grouped query
|
| 60 |
# together (measured 0.22 s over 1,670 groups against the mirror; 2.55 s live).
|
| 61 |
keys: [units, revenue, margin, cogs, margin_pct, asp]
|
|
|
|
| 137 |
type: currency
|
| 138 |
kind: data
|
| 139 |
means: "Royal 2 pricelist price for this SKU. Blank when that list prices it nowhere."
|
| 140 |
+
- key: price_1
|
| 141 |
+
label: "Price 1"
|
| 142 |
+
type: currency
|
| 143 |
+
kind: data
|
| 144 |
+
means: "Cheapest live package price for this SKU across active pricelists."
|
| 145 |
+
- key: unit_1
|
| 146 |
+
label: "Unit 1"
|
| 147 |
+
type: text
|
| 148 |
+
kind: data
|
| 149 |
+
means: "Package label for Price 1; blank means Odoo has no package reference for that price."
|
| 150 |
+
- key: price_2
|
| 151 |
+
label: "Price 2"
|
| 152 |
+
type: currency
|
| 153 |
+
kind: data
|
| 154 |
+
means: "Second-cheapest live package price for this SKU."
|
| 155 |
+
- key: unit_2
|
| 156 |
+
label: "Unit 2"
|
| 157 |
+
type: text
|
| 158 |
+
kind: data
|
| 159 |
+
means: "Package label for Price 2."
|
| 160 |
+
- key: price_3
|
| 161 |
+
label: "Price 3"
|
| 162 |
+
type: currency
|
| 163 |
+
kind: data
|
| 164 |
+
means: "Third-cheapest live package price for this SKU."
|
| 165 |
+
- key: unit_3
|
| 166 |
+
label: "Unit 3"
|
| 167 |
+
type: text
|
| 168 |
+
kind: data
|
| 169 |
+
means: "Package label for Price 3."
|
| 170 |
+
- key: pre_book_qty
|
| 171 |
+
label: "Pre-book qty"
|
| 172 |
+
type: int
|
| 173 |
+
kind: data
|
| 174 |
+
means: "Units on confirmed, not-fully-delivered wholesale orders promised after today. Delivery-charge service lines are excluded."
|
| 175 |
+
- key: open_backlog_qty
|
| 176 |
+
label: "Open backlog qty"
|
| 177 |
+
type: int
|
| 178 |
+
kind: data
|
| 179 |
+
means: "Units still owed on confirmed, not-fully-delivered wholesale goods orders, including already-due promises. Delivery-charge service lines are excluded."
|
| 180 |
- key: rev_ytd
|
| 181 |
label: "Revenue YTD"
|
| 182 |
type: currency
|
platform/modules/backorders.py
CHANGED
|
@@ -41,6 +41,78 @@ def _open_pickings():
|
|
| 41 |
return picks, sched
|
| 42 |
|
| 43 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
def board(team_id=None, t=None):
|
| 45 |
"""The full open-line board + bucket rollup. Returns dict(rows, buckets, orders, coverage)."""
|
| 46 |
t = t or P.today()
|
|
|
|
| 41 |
return picks, sched
|
| 42 |
|
| 43 |
|
| 44 |
+
def sku_backlog(t=None, team_id=None):
|
| 45 |
+
"""`(pre_book_by_code, open_backlog_by_code, report)` for the product catalogue.
|
| 46 |
+
|
| 47 |
+
This is deliberately NOT `board()`'s ``not due`` bucket. That board enters through open
|
| 48 |
+
pickings and substitutes a picking scheduled date when an order has no commitment date;
|
| 49 |
+
pre-book means the stricter, stored-order fact ``commitment_date > end of t``. Both measures
|
| 50 |
+
are confirmed wholesale goods that Odoo still calls pending, started, or partial. Their
|
| 51 |
+
quantity is the positive part of ordered minus delivered, grouped by product and then by the
|
| 52 |
+
catalogue's code.
|
| 53 |
+
"""
|
| 54 |
+
t = t or P.today()
|
| 55 |
+
if isinstance(t, dt.datetime):
|
| 56 |
+
t = t.date()
|
| 57 |
+
end_of_day = f"{t.isoformat()} 23:59:59"
|
| 58 |
+
teams = [team_id] if team_id is not None else O.active_team_ids()
|
| 59 |
+
common = [
|
| 60 |
+
('state', 'in', ['sale', 'done']),
|
| 61 |
+
('product_id', '!=', False),
|
| 62 |
+
('order_id.delivery_status', 'in', ['pending', 'started', 'partial']),
|
| 63 |
+
('product_id.type', '!=', 'service'),
|
| 64 |
+
]
|
| 65 |
+
if teams:
|
| 66 |
+
common.append(('order_id.team_id', 'in', teams))
|
| 67 |
+
excluded = O.excluded_partner_ids()
|
| 68 |
+
if excluded:
|
| 69 |
+
common.append(('order_partner_id', 'not in', list(excluded)))
|
| 70 |
+
|
| 71 |
+
def _by_product(domain):
|
| 72 |
+
groups = O.read_group('sale.order.line', domain,
|
| 73 |
+
['product_uom_qty:sum', 'qty_delivered:sum'], ['product_id'],
|
| 74 |
+
lazy=False)
|
| 75 |
+
out = {}
|
| 76 |
+
for group in groups:
|
| 77 |
+
pid = O.m2o_id(group.get('product_id'))
|
| 78 |
+
if not pid:
|
| 79 |
+
continue
|
| 80 |
+
open_qty = max(0.0, float(group.get('product_uom_qty') or 0.0)
|
| 81 |
+
- float(group.get('qty_delivered') or 0.0))
|
| 82 |
+
if open_qty > 1e-9:
|
| 83 |
+
out[pid] = open_qty
|
| 84 |
+
return out
|
| 85 |
+
|
| 86 |
+
open_by_pid = _by_product(list(common))
|
| 87 |
+
pre_by_pid = _by_product(list(common) + [('order_id.commitment_date', '>', end_of_day)])
|
| 88 |
+
pids = sorted(set(open_by_pid) | set(pre_by_pid))
|
| 89 |
+
codes = {}
|
| 90 |
+
for ch in _chunk(pids, 500):
|
| 91 |
+
for product in O.search_read('product.product',
|
| 92 |
+
[('id', 'in', ch), ('active', 'in', [True, False])],
|
| 93 |
+
['default_code']):
|
| 94 |
+
pid = product.get('id')
|
| 95 |
+
codes[pid] = str(product.get('default_code') or f"pid:{pid}").strip()
|
| 96 |
+
|
| 97 |
+
def _by_code(by_pid):
|
| 98 |
+
out = {}
|
| 99 |
+
for pid, qty in by_pid.items():
|
| 100 |
+
code = codes.get(pid, f"pid:{pid}")
|
| 101 |
+
out[code] = out.get(code, 0.0) + qty
|
| 102 |
+
return out
|
| 103 |
+
|
| 104 |
+
pre_by_code, open_by_code = _by_code(pre_by_pid), _by_code(open_by_pid)
|
| 105 |
+
report = {
|
| 106 |
+
'as_of': t.isoformat(),
|
| 107 |
+
'team_ids': teams,
|
| 108 |
+
'pre_book_qty': round(sum(pre_by_code.values()), 2),
|
| 109 |
+
'pre_book_skus': len(pre_by_code),
|
| 110 |
+
'open_backlog_qty': round(sum(open_by_code.values()), 2),
|
| 111 |
+
'open_backlog_skus': len(open_by_code),
|
| 112 |
+
}
|
| 113 |
+
return pre_by_code, open_by_code, report
|
| 114 |
+
|
| 115 |
+
|
| 116 |
def board(team_id=None, t=None):
|
| 117 |
"""The full open-line board + bucket rollup. Returns dict(rows, buckets, orders, coverage)."""
|
| 118 |
t = t or P.today()
|
platform/modules/inventory.py
CHANGED
|
@@ -39,9 +39,16 @@ def _build(t=None):
|
|
| 39 |
prods = o.search_read('product.product', [('default_code', '!=', False)], fields)
|
| 40 |
catmap = _cat_main_map()
|
| 41 |
|
| 42 |
-
# LTM sales per product (line level
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
lf, lt = P.ltm(t)
|
| 44 |
-
s = o.read_group('sale.order.line', O.sale_line_domain(lf, lt),
|
| 45 |
['product_id', 'price_subtotal:sum', 'product_uom_qty:sum'],
|
| 46 |
['product_id'], lazy=False)
|
| 47 |
rev = {O.m2o_id(r['product_id']): (r.get('price_subtotal') or 0.0) for r in s if r.get('product_id')}
|
|
|
|
| 39 |
prods = o.search_read('product.product', [('default_code', '!=', False)], fields)
|
| 40 |
catmap = _cat_main_map()
|
| 41 |
|
| 42 |
+
# ββ W39-T10 β LTM sales per product, ALL CHANNELS (line level). Owner instruction 1 of
|
| 43 |
+
# wave 39: *"Metrics under Odoo products MUST include EVERYTHING, not just Royal and Fisch DBA,
|
| 44 |
+
# but also Amazon."* THIS LINE IS WHERE `qty_ltm` COMES FROM β `sku_inventory` groups it by SKU
|
| 45 |
+
# code and `product_data._inventory_by_code` puts it on the product grid, so a wholesale-only
|
| 46 |
+
# read here under-states velocity (and over-states days-of-supply) for every SKU Amazon moves.
|
| 47 |
+
# β Consolidated by construction: the shelf is one warehouse and carries no team. The BU
|
| 48 |
+
# re-scoping happens downstream in `product_data._rescope_inventory`, against a share whose
|
| 49 |
+
# DENOMINATOR was widened to match this read β see `product_data._bu_ltm_share`.
|
| 50 |
lf, lt = P.ltm(t)
|
| 51 |
+
s = o.read_group('sale.order.line', O.sale_line_domain(lf, lt, all_channels=True), # W39-T10
|
| 52 |
['product_id', 'price_subtotal:sum', 'product_uom_qty:sum'],
|
| 53 |
['product_id'], lazy=False)
|
| 54 |
rev = {O.m2o_id(r['product_id']): (r.get('price_subtotal') or 0.0) for r in s if r.get('product_id')}
|
platform/modules/product_data.py
CHANGED
|
@@ -61,6 +61,7 @@ import core.odoo as O
|
|
| 61 |
import core.periods as P
|
| 62 |
import core.shared_overlay as shared_overlay
|
| 63 |
import core.table_store as table_store
|
|
|
|
| 64 |
import modules.products as products
|
| 65 |
|
| 66 |
#: POOL-ROW KEYS that exist ONLY on a consolidated pull. See decision 2.
|
|
@@ -292,8 +293,35 @@ def _bu_ltm_share(t, team_id):
|
|
| 292 |
"""
|
| 293 |
try:
|
| 294 |
f, to = P.ltm(t)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 295 |
allq = {code: (r.get('qty') or 0.0)
|
| 296 |
-
for code, r in (products._sku_rev(f, to, None) or {}).items()}
|
| 297 |
buq = {code: (r.get('qty') or 0.0)
|
| 298 |
for code, r in (products._sku_rev(f, to, team_id) or {}).items()}
|
| 299 |
except Exception:
|
|
@@ -437,7 +465,7 @@ def pool(team_id=None, t=None):
|
|
| 437 |
except Exception: # noqa: BLE001
|
| 438 |
_prods = None
|
| 439 |
tiers, _tier_report = products.tier_prices_by_code(_prods)
|
| 440 |
-
|
| 441 |
cat = _catalogue_by_code()
|
| 442 |
# β THE BUY LIST'S DEMAND HORIZON (owner ruling 2026-08-19). BU-shaped through `team_id`, the
|
| 443 |
# same way the revenue columns are: a Fisch reader's reorder quantity must answer what FISCH
|
|
@@ -507,10 +535,15 @@ def pool(team_id=None, t=None):
|
|
| 507 |
# as something `grid_events` refuses to re-parse.
|
| 508 |
# β BLANK, NOT "[]" β an empty cell reads as "sold in one unit / not priced on any list",
|
| 509 |
# and an empty JSON array on screen reads as a bug.
|
| 510 |
-
_tiers = tiers.get(code)
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 514 |
# Wave 17 R3 β the supplier master, on every pull (it is catalogue data, not stock).
|
| 515 |
s = sup.get(code) or {}
|
| 516 |
row.update({
|
|
@@ -719,7 +752,11 @@ def validate(team_id=None, t=None):
|
|
| 719 |
}]
|
| 720 |
|
| 721 |
yf, yt = P.ytd(t)
|
| 722 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 723 |
in_pool = {r["code"] for r in rows}
|
| 724 |
outside = {c: v for c, v in sku_rev.items() if c not in in_pool}
|
| 725 |
ours = round(sum(r["rev_ytd"] or 0.0 for r in rows), 2)
|
|
@@ -1191,42 +1228,37 @@ def validate_stock_measures(t=None, days=90, sample=4):
|
|
| 1191 |
|
| 1192 |
|
| 1193 |
def validate_price_and_unit_cells(rows, sample=6):
|
| 1194 |
-
"""
|
| 1195 |
|
| 1196 |
-
|
| 1197 |
-
|
| 1198 |
-
|
| 1199 |
-
|
| 1200 |
-
|
| 1201 |
-
β WHAT IS NOT PROVEN HERE, said rather than implied: that a PERSON sees the cell. These are
|
| 1202 |
-
`json` columns on the product grid and the render is the client's; the data half is what a
|
| 1203 |
-
module `validate()` can reach.
|
| 1204 |
"""
|
| 1205 |
checks = []
|
| 1206 |
-
priced = [r for r in rows if r.get("
|
| 1207 |
-
|
| 1208 |
checks.append({
|
| 1209 |
-
"check": "the product grid serves
|
| 1210 |
-
|
| 1211 |
-
|
| 1212 |
-
"theirs": ">0
|
| 1213 |
-
|
| 1214 |
-
|
| 1215 |
-
"detail": {"
|
| 1216 |
-
|
| 1217 |
})
|
| 1218 |
if not priced:
|
| 1219 |
return checks
|
| 1220 |
o = O.get_odoo()
|
| 1221 |
-
pls = {p["id"]: str(p.get("name") or "").strip()
|
| 1222 |
-
for p in O.search_read("product.pricelist", [], ["id", "name"])}
|
| 1223 |
today = P.today().isoformat()
|
| 1224 |
-
# Prefer SKUs that carry
|
| 1225 |
-
cand = sorted(priced,
|
|
|
|
| 1226 |
bad = []
|
| 1227 |
for r in cand:
|
| 1228 |
-
mine = sorted((
|
| 1229 |
-
for
|
| 1230 |
pid = r.get("product_id")
|
| 1231 |
tmpl = None
|
| 1232 |
if pid:
|
|
@@ -1235,24 +1267,31 @@ def validate_price_and_unit_cells(rows, sample=6):
|
|
| 1235 |
dom = [("compute_price", "=", "fixed"),
|
| 1236 |
"|", ("date_start", "=", False), ("date_start", "<=", today),
|
| 1237 |
"|", ("date_end", "=", False), ("date_end", ">=", today),
|
|
|
|
| 1238 |
("fixed_price", ">", 0),
|
| 1239 |
"|", ("product_id", "=", pid), ("product_tmpl_id", "=", tmpl)]
|
| 1240 |
live = o.search_read("product.pricelist.item", dom,
|
| 1241 |
-
["pricelist_id", "
|
| 1242 |
-
|
|
|
|
| 1243 |
for it in live:
|
| 1244 |
-
|
| 1245 |
-
|
| 1246 |
-
|
| 1247 |
-
|
| 1248 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1249 |
# β A code carried by TWO active products legitimately holds MORE entries than a single
|
| 1250 |
# product's rules (D-309 / `2112-12`), so ours is a SUPERSET, never an equality.
|
| 1251 |
if not set(theirs) <= set(mine):
|
| 1252 |
bad.append({"sku": r.get("code"), "ours": mine, "odoo": theirs})
|
| 1253 |
checks.append({
|
| 1254 |
-
"check": f"each sampled SKU's
|
| 1255 |
-
f"({len(cand)} SKUs, chosen for
|
| 1256 |
"ours": len(bad), "theirs": 0, "ok": not bad,
|
| 1257 |
"detail": {"mismatches": bad[:3],
|
| 1258 |
"sampled": [r.get("code") for r in cand]},
|
|
@@ -1365,7 +1404,14 @@ def validate_measures(t=None, team_id=None, days=90, pool_codes=None):
|
|
| 1365 |
o = O.get_odoo()
|
| 1366 |
# β ASKED OF ODOO DIRECTLY, grouped by Odoo's OWN product id β deliberately NOT by the SKU
|
| 1367 |
# code the mirror joins on, so the oracle cannot inherit our join key.
|
| 1368 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1369 |
['product_id', 'price_subtotal:sum', 'product_uom_qty:sum', 'margin:sum'],
|
| 1370 |
['product_id'], lazy=False)
|
| 1371 |
live_tot = {"revenue": round(sum(r['price_subtotal'] for r in g), 2),
|
|
@@ -1383,20 +1429,19 @@ def validate_measures(t=None, team_id=None, days=90, pool_codes=None):
|
|
| 1383 |
rows = con.execute(
|
| 1384 |
"SELECT l.id, l.price_subtotal FROM sale_order_line l "
|
| 1385 |
"JOIN sale_order o ON o.id = l.order_id "
|
| 1386 |
-
"WHERE o.state IN ('sale','done')
|
| 1387 |
" AND l.product_id IS NOT NULL "
|
| 1388 |
-
" AND l.order_partner_id NOT IN "
|
| 1389 |
-
" (SELECT id FROM res_partner WHERE name LIKE 'GIFTWARE%') "
|
| 1390 |
" AND CAST(o.date_order AS TIMESTAMP) >= ? AND CAST(o.date_order AS TIMESTAMP) <= ?",
|
| 1391 |
[f"{DF} 00:00:00", f"{DT} 23:59:59"]).fetchall()
|
| 1392 |
finally:
|
| 1393 |
con.close()
|
| 1394 |
mine = {r[0]: (r[1] or 0.0) for r in rows}
|
| 1395 |
theirs = {r['id']: (r['price_subtotal'] or 0.0) for r in
|
| 1396 |
-
o.search_read('sale.order.line', O.sale_line_domain(DF, DT),
|
| 1397 |
['id', 'price_subtotal', 'write_date'])}
|
| 1398 |
wd = {r['id']: str(r['write_date']) for r in
|
| 1399 |
-
o.search_read('sale.order.line', O.sale_line_domain(DF, DT
|
|
|
|
| 1400 |
discrepant = [i for i, v in theirs.items()
|
| 1401 |
if i not in mine or abs(mine[i] - v) >= 0.005]
|
| 1402 |
unexplained = [i for i in discrepant if not wm or wd.get(i, '') <= str(wm)]
|
|
|
|
| 61 |
import core.periods as P
|
| 62 |
import core.shared_overlay as shared_overlay
|
| 63 |
import core.table_store as table_store
|
| 64 |
+
import modules.backorders as backorders
|
| 65 |
import modules.products as products
|
| 66 |
|
| 67 |
#: POOL-ROW KEYS that exist ONLY on a consolidated pull. See decision 2.
|
|
|
|
| 293 |
"""
|
| 294 |
try:
|
| 295 |
f, to = P.ltm(t)
|
| 296 |
+
# ββ W39-T10 β THE DENOMINATOR WIDENS, THE NUMERATOR DOES NOT, AND THE ASYMMETRY IS THE
|
| 297 |
+
# WHOLE POINT. `inventory._build` now reads ALL CHANNELS, so `consolidated qty_ltm` counts
|
| 298 |
+
# Amazon. `_rescope_inventory` computes a BU's units as `consolidated Γ share`, so `share`
|
| 299 |
+
# must be measured against THE SAME universe or the multiplication is mixed-scope.
|
| 300 |
+
# allq DENOMINATOR β consolidated, no explicit team -> all_channels=True
|
| 301 |
+
# buq NUMERATOR β explicit team 5|6, wholesale -> UNCHANGED, deliberately
|
| 302 |
+
# Amazon is crm.team 7. A Fisch or Royal numerator that counted team-7 units would claim
|
| 303 |
+
# units that unit never sold; a FLAT Fisch/Royal result after this change is the CORRECT
|
| 304 |
+
# answer, not a broken flag.
|
| 305 |
+
# β AND `share <= 1` IS STILL STRUCTURAL, NOT MERELY ARGUED β which is what lets this
|
| 306 |
+
# change keep the guarantee the docstring above was written to protect. The numerator's
|
| 307 |
+
# DOMAIN is a strict subset of the denominator's: `sale_line_domain(f, to, team_id)` is
|
| 308 |
+
# `sale_line_domain(f, to, None, all_channels=True)` plus an `order_id.team_id = N` clause
|
| 309 |
+
# plus an `order_partner_id not in (excluded)` clause. Same reader, same fields, same
|
| 310 |
+
# grouping, same `_NO_SVC` filter, nested domains β so `buq[code] <= allq[code]` cannot be
|
| 311 |
+
# violated, and the `min(1.0, ...)` clamp below is dead code kept as a belt.
|
| 312 |
+
# β THE OTHER DIRECTION IS NOT SAFE, and that is why the numerator must stay put: widening
|
| 313 |
+
# it (explicit team + `all_channels=True`) drops the excluded-partner clause from the
|
| 314 |
+
# NUMERATOR ONLY, which un-nests the domains and can push the share above 1 β re-opening
|
| 315 |
+
# the measured defect this docstring records, Fisch's LTM units exceeding the company's on
|
| 316 |
+
# 5 SKUs of 5,871.
|
| 317 |
+
# β THE DETECTING INVARIANT IS THE SUM: `share(5) + share(6) <= 1.0` per code, because
|
| 318 |
+
# `buq5 + buq6` IS the active-team universe. The per-share clamp cannot detect anything and
|
| 319 |
+
# a check written against it is green by construction. Gated, with the pro-ration defect
|
| 320 |
+
# reproduced as a negative control, in `verify_product_pool.py::section_amazon_all_channels`.
|
| 321 |
+
# MEASURED 2026-08-20 (2,852 codes): sum > 1.0 on ZERO codes; sum < 1.0 on 918 (was 71);
|
| 322 |
+
# e.g. VCAND10-WH-PK72 1.0 -> 0.002891 == 45 wholesale units / 15,566.7 all-channel.
|
| 323 |
allq = {code: (r.get('qty') or 0.0)
|
| 324 |
+
for code, r in (products._sku_rev(f, to, None, all_channels=True) or {}).items()}
|
| 325 |
buq = {code: (r.get('qty') or 0.0)
|
| 326 |
for code, r in (products._sku_rev(f, to, team_id) or {}).items()}
|
| 327 |
except Exception:
|
|
|
|
| 465 |
except Exception: # noqa: BLE001
|
| 466 |
_prods = None
|
| 467 |
tiers, _tier_report = products.tier_prices_by_code(_prods)
|
| 468 |
+
pre_book, open_backlog, _backlog_report = backorders.sku_backlog(t=t, team_id=team_id)
|
| 469 |
cat = _catalogue_by_code()
|
| 470 |
# β THE BUY LIST'S DEMAND HORIZON (owner ruling 2026-08-19). BU-shaped through `team_id`, the
|
| 471 |
# same way the revenue columns are: a Fisch reader's reorder quantity must answer what FISCH
|
|
|
|
| 535 |
# as something `grid_events` refuses to re-parse.
|
| 536 |
# β BLANK, NOT "[]" β an empty cell reads as "sold in one unit / not priced on any list",
|
| 537 |
# and an empty JSON array on screen reads as a bug.
|
| 538 |
+
_tiers = tiers.get(code) or []
|
| 539 |
+
for _n in range(1, 4):
|
| 540 |
+
_tier = _tiers[_n - 1] if len(_tiers) >= _n else None
|
| 541 |
+
row[f"price_{_n}"] = _tier.get("unit_price") if _tier else None
|
| 542 |
+
row[f"unit_{_n}"] = _tier.get("unit") if _tier else None
|
| 543 |
+
# W39-T14: these are open customer promises, not sales velocity. A missing code is an
|
| 544 |
+
# observed zero in the grouped backlog read, so 0.0 is the honest cell rather than blank.
|
| 545 |
+
row["pre_book_qty"] = pre_book.get(code, 0.0)
|
| 546 |
+
row["open_backlog_qty"] = open_backlog.get(code, 0.0)
|
| 547 |
# Wave 17 R3 β the supplier master, on every pull (it is catalogue data, not stock).
|
| 548 |
s = sup.get(code) or {}
|
| 549 |
row.update({
|
|
|
|
| 752 |
}]
|
| 753 |
|
| 754 |
yf, yt = P.ytd(t)
|
| 755 |
+
# β W39-T10 β ALL CHANNELS, BECAUSE THE THING IT RECONCILES IS. `pool()`'s revenue columns
|
| 756 |
+
# come from `products.directory()`, which reads all channels since this ticket. This oracle
|
| 757 |
+
# re-asks the same window; leaving it wholesale-only would put the whole Amazon delta into
|
| 758 |
+
# "revenue the grid dropped" and red a leg that is measuring nothing wrong.
|
| 759 |
+
sku_rev = products._sku_rev(yf, yt, team_id, all_channels=True) or {}
|
| 760 |
in_pool = {r["code"] for r in rows}
|
| 761 |
outside = {c: v for c, v in sku_rev.items() if c not in in_pool}
|
| 762 |
ours = round(sum(r["rev_ytd"] or 0.0 for r in rows), 2)
|
|
|
|
| 1228 |
|
| 1229 |
|
| 1230 |
def validate_price_and_unit_cells(rows, sample=6):
|
| 1231 |
+
"""Reconcile typed Price 1..3 / Unit 1..3 cells to a fresh, per-SKU Odoo read.
|
| 1232 |
|
| 1233 |
+
The pool groups price rules in bulk. This asks Odoo per SKU so a wrong grouping, a lost
|
| 1234 |
+
package price, or a missing unit has an oracle which cannot agree by reusing that grouping.
|
| 1235 |
+
The contract ships only the cheapest three pairs; duplicate SKU codes may make the pool a
|
| 1236 |
+
legitimate superset of a single product's rules.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1237 |
"""
|
| 1238 |
checks = []
|
| 1239 |
+
priced = [r for r in rows if r.get("price_1") is not None]
|
| 1240 |
+
with_first_pair = [r for r in rows if "price_1" in r and "unit_1" in r]
|
| 1241 |
checks.append({
|
| 1242 |
+
"check": "the product grid serves typed Price 1..3 and Unit 1..3 cells, not JSON cells",
|
| 1243 |
+
"ours": {"with_price_1": len(priced), "with_first_pair": len(with_first_pair),
|
| 1244 |
+
"rows": len(rows)},
|
| 1245 |
+
"theirs": {"price_1": ">0", "typed_first_pair": len(rows)},
|
| 1246 |
+
"ok": bool(priced) and len(with_first_pair) == len(rows)
|
| 1247 |
+
and not any("tier_prices" in r or "units" in r for r in rows),
|
| 1248 |
+
"detail": {"two_or_more_prices": sum(1 for r in rows if r.get("price_2") is not None),
|
| 1249 |
+
"three_prices": sum(1 for r in rows if r.get("price_3") is not None)},
|
| 1250 |
})
|
| 1251 |
if not priced:
|
| 1252 |
return checks
|
| 1253 |
o = O.get_odoo()
|
|
|
|
|
|
|
| 1254 |
today = P.today().isoformat()
|
| 1255 |
+
# Prefer SKUs that carry more than one price, the ticket's own subject.
|
| 1256 |
+
cand = sorted(priced,
|
| 1257 |
+
key=lambda r: -sum(r.get(f"price_{n}") is not None for n in range(1, 4)))[:sample]
|
| 1258 |
bad = []
|
| 1259 |
for r in cand:
|
| 1260 |
+
mine = sorted((round(float(r[f"price_{n}"]), 2), str(r.get(f"unit_{n}") or "").strip())
|
| 1261 |
+
for n in range(1, 4) if r.get(f"price_{n}") is not None)
|
| 1262 |
pid = r.get("product_id")
|
| 1263 |
tmpl = None
|
| 1264 |
if pid:
|
|
|
|
| 1267 |
dom = [("compute_price", "=", "fixed"),
|
| 1268 |
"|", ("date_start", "=", False), ("date_start", "<=", today),
|
| 1269 |
"|", ("date_end", "=", False), ("date_end", ">=", today),
|
| 1270 |
+
("applied_on", "in", ["0_product_variant", "1_product"]),
|
| 1271 |
("fixed_price", ">", 0),
|
| 1272 |
"|", ("product_id", "=", pid), ("product_tmpl_id", "=", tmpl)]
|
| 1273 |
live = o.search_read("product.pricelist.item", dom,
|
| 1274 |
+
["pricelist_id", "product_id", "product_tmpl_id", "fixed_price",
|
| 1275 |
+
"packaging_price", "packaging_id", "applied_on"])
|
| 1276 |
+
by_variant, by_template = {}, {}
|
| 1277 |
for it in live:
|
| 1278 |
+
plid = O.m2o_id(it.get("pricelist_id"))
|
| 1279 |
+
if it.get("applied_on") == "0_product_variant" and O.m2o_id(it.get("product_id")) == pid:
|
| 1280 |
+
by_variant.setdefault(plid, []).append(it)
|
| 1281 |
+
elif O.m2o_id(it.get("product_tmpl_id")) == tmpl:
|
| 1282 |
+
by_template.setdefault(plid, []).append(it)
|
| 1283 |
+
picked = [it for plid in sorted(set(by_variant) | set(by_template))
|
| 1284 |
+
for it in (by_variant.get(plid) or by_template.get(plid) or [])]
|
| 1285 |
+
theirs = sorted((round(float(it.get("packaging_price") or it.get("fixed_price") or 0.0), 2),
|
| 1286 |
+
str(O.m2o_name(it.get("packaging_id")) or "").strip())
|
| 1287 |
+
for it in picked)[:3]
|
| 1288 |
# β A code carried by TWO active products legitimately holds MORE entries than a single
|
| 1289 |
# product's rules (D-309 / `2112-12`), so ours is a SUPERSET, never an equality.
|
| 1290 |
if not set(theirs) <= set(mine):
|
| 1291 |
bad.append({"sku": r.get("code"), "ours": mine, "odoo": theirs})
|
| 1292 |
checks.append({
|
| 1293 |
+
"check": f"each sampled SKU's typed Price/Unit pairs contain every live Odoo pair "
|
| 1294 |
+
f"({len(cand)} SKUs, chosen for the most prices)",
|
| 1295 |
"ours": len(bad), "theirs": 0, "ok": not bad,
|
| 1296 |
"detail": {"mismatches": bad[:3],
|
| 1297 |
"sampled": [r.get("code") for r in cand]},
|
|
|
|
| 1404 |
o = O.get_odoo()
|
| 1405 |
# β ASKED OF ODOO DIRECTLY, grouped by Odoo's OWN product id β deliberately NOT by the SKU
|
| 1406 |
# code the mirror joins on, so the oracle cannot inherit our join key.
|
| 1407 |
+
# β W39-T11 deliberately widens only this ENTITY binding. `odoo_products.yml` opts its
|
| 1408 |
+
# sales_lines binding into semantic's `all_channels` variant: confirmed Amazon lines belong
|
| 1409 |
+
# in a SKU-shaped product measure, while the source topic itself stays wholesale-only for
|
| 1410 |
+
# customer grain / Analyst. There are THREE scopes here, and all have to move together:
|
| 1411 |
+
# (i) the semantic entity binding, (ii) the DuckDB mirror oracle below, and (iii) direct
|
| 1412 |
+
# Odoo's `sale_line_domain(..., all_channels=True)` reads. Leaving either oracle narrow
|
| 1413 |
+
# would make a correct all-channel cell look like an unexplained reconciliation failure.
|
| 1414 |
+
g = o.read_group('sale.order.line', O.sale_line_domain(DF, DT, all_channels=True),
|
| 1415 |
['product_id', 'price_subtotal:sum', 'product_uom_qty:sum', 'margin:sum'],
|
| 1416 |
['product_id'], lazy=False)
|
| 1417 |
live_tot = {"revenue": round(sum(r['price_subtotal'] for r in g), 2),
|
|
|
|
| 1429 |
rows = con.execute(
|
| 1430 |
"SELECT l.id, l.price_subtotal FROM sale_order_line l "
|
| 1431 |
"JOIN sale_order o ON o.id = l.order_id "
|
| 1432 |
+
"WHERE o.state IN ('sale','done') "
|
| 1433 |
" AND l.product_id IS NOT NULL "
|
|
|
|
|
|
|
| 1434 |
" AND CAST(o.date_order AS TIMESTAMP) >= ? AND CAST(o.date_order AS TIMESTAMP) <= ?",
|
| 1435 |
[f"{DF} 00:00:00", f"{DT} 23:59:59"]).fetchall()
|
| 1436 |
finally:
|
| 1437 |
con.close()
|
| 1438 |
mine = {r[0]: (r[1] or 0.0) for r in rows}
|
| 1439 |
theirs = {r['id']: (r['price_subtotal'] or 0.0) for r in
|
| 1440 |
+
o.search_read('sale.order.line', O.sale_line_domain(DF, DT, all_channels=True),
|
| 1441 |
['id', 'price_subtotal', 'write_date'])}
|
| 1442 |
wd = {r['id']: str(r['write_date']) for r in
|
| 1443 |
+
o.search_read('sale.order.line', O.sale_line_domain(DF, DT, all_channels=True),
|
| 1444 |
+
['id', 'write_date'])}
|
| 1445 |
discrepant = [i for i, v in theirs.items()
|
| 1446 |
if i not in mine or abs(mine[i] - v) >= 0.005]
|
| 1447 |
unexplained = [i for i in discrepant if not wm or wd.get(i, '') <= str(wm)]
|
platform/modules/products.py
CHANGED
|
@@ -7,7 +7,11 @@ Basket / co-purchase (MBA) is intentionally deferred β the existing client app
|
|
| 7 |
runtime-side, and a local co-occurrence pull over 74k LTM lines is the kind of heavy job the
|
| 8 |
project guardrails keep off the local PC. (See BACKLOG.)
|
| 9 |
|
| 10 |
-
All line-level (sale.order.line),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
Coverage (distinct customers per SKU) uses a 2-level read_group and is a touch slow (~15s/
|
| 12 |
window), so it's its own function the app calls lazily and caches.
|
| 13 |
"""
|
|
@@ -43,8 +47,20 @@ def _sku_rev(date_from, date_to, team_id=None, all_channels=False):
|
|
| 43 |
"""{sku_code: {'name','rev','qty','orders'}} over a window (services excluded,
|
| 44 |
duplicate product records merged by SKU code).
|
| 45 |
|
| 46 |
-
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
g = O.read_group('sale.order.line',
|
| 49 |
O.sale_line_domain(date_from, date_to, team_id, extra=_NO_SVC,
|
| 50 |
all_channels=all_channels),
|
|
@@ -136,8 +152,8 @@ def yoy_movers(t=None, limit=20, team_id=None):
|
|
| 136 |
t = t or P.today()
|
| 137 |
yf, yt = P.ytd(t)
|
| 138 |
lf, lt = P.ytd_last_year(t)
|
| 139 |
-
this = _sku_rev(yf, yt, team_id)
|
| 140 |
-
last = _sku_rev(lf, lt, team_id)
|
| 141 |
rows = []
|
| 142 |
for pid in set(this) | set(last):
|
| 143 |
tr = this.get(pid, {'rev': 0.0, 'name': last.get(pid, {}).get('name', '')})
|
|
@@ -155,8 +171,8 @@ def zombie_skus(t=None, limit=30, min_prior=1500.0, team_id=None):
|
|
| 155 |
t = t or P.today()
|
| 156 |
yf, yt = P.ytd(t)
|
| 157 |
lf, lt = P.ytd_last_year(t)
|
| 158 |
-
this = _sku_rev(yf, yt, team_id)
|
| 159 |
-
last = _sku_rev(lf, lt, team_id)
|
| 160 |
rows = []
|
| 161 |
for pid, lr in last.items():
|
| 162 |
if lr['rev'] < min_prior:
|
|
@@ -175,8 +191,8 @@ def new_winners(t=None, limit=20, min_this=1500.0, team_id=None):
|
|
| 175 |
t = t or P.today()
|
| 176 |
yf, yt = P.ytd(t)
|
| 177 |
lf, lt = P.ytd_last_year(t)
|
| 178 |
-
this = _sku_rev(yf, yt, team_id)
|
| 179 |
-
last = _sku_rev(lf, lt, team_id)
|
| 180 |
rows = []
|
| 181 |
for pid, tr in this.items():
|
| 182 |
if tr['rev'] < min_this:
|
|
@@ -194,7 +210,7 @@ def velocity_leaders(t=None, limit=25, team_id=None):
|
|
| 194 |
"""Top SKUs by LTM unit velocity (units/month) and revenue."""
|
| 195 |
t = t or P.today()
|
| 196 |
lf, lt = P.ltm(t)
|
| 197 |
-
sku = _sku_rev(lf, lt, team_id)
|
| 198 |
rows = [{'code': k, 'product': v['name'], 'units_ltm': v['qty'], 'units_per_mo': v['qty'] / 12.0,
|
| 199 |
'rev_ltm': v['rev'], 'orders_ltm': v['orders']} for k, v in sku.items()]
|
| 200 |
rows.sort(key=lambda x: -x['units_ltm'])
|
|
@@ -220,8 +236,8 @@ def directory(t=None, team_id=None):
|
|
| 220 |
t = t or P.today()
|
| 221 |
yf, yt = P.ytd(t)
|
| 222 |
lf, lt = P.ytd_last_year(t)
|
| 223 |
-
this = _sku_rev(yf, yt, team_id)
|
| 224 |
-
last = _sku_rev(lf, lt, team_id)
|
| 225 |
code_cat = _code_category()
|
| 226 |
rows = []
|
| 227 |
for code in set(this) | set(last):
|
|
@@ -581,12 +597,14 @@ def active_products(limit=50000):
|
|
| 581 |
|
| 582 |
|
| 583 |
def tier_prices_by_code(prods=None):
|
| 584 |
-
"""`({code: [{pricelist, unit_price}]}, report)` β EVERY live price a SKU really has.
|
| 585 |
|
| 586 |
ββ W37-T14 (owner: multiple prices per SKU). `pricelist_by_code` above answers a DIFFERENT
|
| 587 |
question and both are needed: it fills three DECLARED columns (`price_fisch`, `price_royal_1`,
|
| 588 |
`price_royal_2`) and therefore cannot show a price on a list the contract does not name. This
|
| 589 |
-
one is the honest set β one entry per
|
|
|
|
|
|
|
| 590 |
|
| 591 |
β IT READS EVERY LIVE PRICELIST, not `PRICELIST_COLUMNS`. Hardcoding this tenant's three list
|
| 592 |
names into "how many prices does a SKU have" is exactly what the ticket forbids, and it is what
|
|
@@ -600,15 +618,16 @@ def tier_prices_by_code(prods=None):
|
|
| 600 |
β FILTER BY `pricelist_id`, NEVER BY `active`: the default `product.pricelist.item` count hides
|
| 601 |
2,580 archived items, so an `active` filter reads as a smaller, plausible, wrong set.
|
| 602 |
|
| 603 |
-
β
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
β CARDINALITY
|
| 607 |
-
and 0 orders**, so a SKU reading
|
| 608 |
-
entry keeps the list NAME
|
| 609 |
"""
|
| 610 |
report = {"lists": [], "rules_total": 0, "rules_not_fixed": 0, "rules_out_of_date": 0,
|
| 611 |
-
"rules_global": 0, "skus_with_no_price": 0, "identity_breaks": 0
|
|
|
|
| 612 |
try:
|
| 613 |
pls = {p['id']: str(p.get('name') or '').strip()
|
| 614 |
for p in O.search_read('product.pricelist', [], ['id', 'name'])}
|
|
@@ -621,7 +640,8 @@ def tier_prices_by_code(prods=None):
|
|
| 621 |
('fixed_price', '>', 0)]
|
| 622 |
rules = O.search_read('product.pricelist.item', dom,
|
| 623 |
['pricelist_id', 'product_id', 'product_tmpl_id', 'applied_on',
|
| 624 |
-
'fixed_price', 'min_quantity'])
|
|
|
|
| 625 |
|
| 626 |
# R6's second sentence: what this reader cannot see is COUNTED, never dropped.
|
| 627 |
def _n(extra):
|
|
@@ -657,8 +677,12 @@ def tier_prices_by_code(prods=None):
|
|
| 657 |
cands = by_var.get((plid, p['id'])) or by_tmpl.get((plid, tmpl))
|
| 658 |
if not cands:
|
| 659 |
continue
|
| 660 |
-
|
| 661 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 662 |
if tiers:
|
| 663 |
# β A code carried by TWO active products (D-309: `2112-12`) MERGES here, because the
|
| 664 |
# grid is keyed by code and one code is one row. The prices are UNIONED rather than
|
|
@@ -668,13 +692,17 @@ def tier_prices_by_code(prods=None):
|
|
| 668 |
if prior is None:
|
| 669 |
out[code] = tiers
|
| 670 |
else:
|
| 671 |
-
seen = {(t["pricelist"], t["unit_price"]) for t in prior}
|
| 672 |
for t in tiers:
|
| 673 |
-
|
|
|
|
| 674 |
prior.append(t)
|
|
|
|
| 675 |
report["identity_breaks"] += 1
|
| 676 |
else:
|
| 677 |
report["skus_with_no_price"] += 1
|
|
|
|
|
|
|
| 678 |
return out, report
|
| 679 |
|
| 680 |
|
|
@@ -752,7 +780,9 @@ def _coverage(date_from, date_to, team_id=None):
|
|
| 752 |
"""{sku_code: distinct_customer_count} via 2-level read_group (slow-ish), merged by code.
|
| 753 |
(A customer buying two records sharing a code can count twice; duplicates are rare, and
|
| 754 |
code-level avoids the bigger error of a re-SKUed product reading as full coverage loss.)"""
|
| 755 |
-
g = O.read_group('sale.order.line',
|
|
|
|
|
|
|
| 756 |
['__count'], ['product_id', 'order_partner_id'], lazy=False)
|
| 757 |
codes = _code_map()
|
| 758 |
cov = {}
|
|
@@ -797,7 +827,11 @@ def _sku_product_ids(code):
|
|
| 797 |
|
| 798 |
|
| 799 |
def _sku_dom(pids, date_from, date_to, team_id=None):
|
| 800 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 801 |
|
| 802 |
|
| 803 |
def _sku_name_category(pids):
|
|
@@ -866,7 +900,7 @@ def sku_detail(code, t=None, team_id=None, n_months=13, allsku=None):
|
|
| 866 |
monthly.append({'month': ym, 'revenue': mrev.get(ym, 0.0),
|
| 867 |
'revenue_ly': mrev.get(f'{y:04d}-{m:02d}', 0.0)})
|
| 868 |
|
| 869 |
-
allsku = allsku if allsku is not None else _sku_rev(yf, yt, team_id)
|
| 870 |
total = sum(v['rev'] for v in allsku.values()) or 1.0
|
| 871 |
rank = next((i + 1 for i, (c, _v) in enumerate(sorted(allsku.items(), key=lambda kv: -kv[1]['rev']))
|
| 872 |
if c == code), None)
|
|
@@ -1046,10 +1080,15 @@ def validate(t=None, team_id=None):
|
|
| 1046 |
t = t or P.today()
|
| 1047 |
yf, yt = P.ytd(t)
|
| 1048 |
checks = []
|
| 1049 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1050 |
sku_sum = sum(v['rev'] for v in sku.values())
|
| 1051 |
line_total = O.sum_field('sale.order.line',
|
| 1052 |
-
O.sale_line_domain(yf, yt, team_id, extra=_NO_SVC
|
|
|
|
| 1053 |
checks.append({'check': 'SKU rev: Ξ£(per-SKU) == total line revenue, ex-services (YTD)',
|
| 1054 |
'a': round(sku_sum, 2), 'b': round(line_total, 2),
|
| 1055 |
'gap': round(sku_sum - line_total, 2),
|
|
@@ -1059,7 +1098,8 @@ def validate(t=None, team_id=None):
|
|
| 1059 |
movers_sum = sum(r['change'] for r in m['risers']) + sum(r['change'] for r in m['decliners'])
|
| 1060 |
lf, lt = P.ytd_last_year(t)
|
| 1061 |
last_total = O.sum_field('sale.order.line',
|
| 1062 |
-
O.sale_line_domain(lf, lt, team_id, extra=_NO_SVC
|
|
|
|
| 1063 |
checks.append({'check': 'SKU movers: Ξ£(Ξ) == (YTD β LY) total',
|
| 1064 |
'a': round(movers_sum, 2), 'b': round(line_total - last_total, 2),
|
| 1065 |
'gap': round(movers_sum - (line_total - last_total), 2),
|
|
|
|
| 7 |
runtime-side, and a local co-occurrence pull over 74k LTM lines is the kind of heavy job the
|
| 8 |
project guardrails keep off the local PC. (See BACKLOG.)
|
| 9 |
|
| 10 |
+
All line-level (sale.order.line), reusing `sale_line_domain`. β W39-T10: the SKU-shaped
|
| 11 |
+
readers here run ALL CHANNELS (`all_channels=True`) β owner: *"Metrics under Odoo products
|
| 12 |
+
MUST include EVERYTHING, not just Royal and Fisch DBA, but also Amazon."* The excluded
|
| 13 |
+
house accounts and the crm.team fence are therefore NOT applied on those reads; an
|
| 14 |
+
explicit `team_id` (a BU-scoped caller) still narrows to that one unit.
|
| 15 |
Coverage (distinct customers per SKU) uses a 2-level read_group and is a touch slow (~15s/
|
| 16 |
window), so it's its own function the app calls lazily and caches.
|
| 17 |
"""
|
|
|
|
| 47 |
"""{sku_code: {'name','rev','qty','orders'}} over a window (services excluded,
|
| 48 |
duplicate product records merged by SKU code).
|
| 49 |
|
| 50 |
+
ββ `all_channels=True` KEEPS THE AMAZON CHANNEL IN, and since W39-T10 that is what EVERY
|
| 51 |
+
SKU-shaped caller in this module passes. Owner, instruction 1 of wave 39: *"Metrics under Odoo
|
| 52 |
+
products MUST include EVERYTHING, not just Royal and Fisch DBA, but also Amazon. Applies to
|
| 53 |
+
everything in the Odoo product database."* It drops BOTH fences β the `crm.team` one (Amazon
|
| 54 |
+
sits on team 7) and the excluded-partner one; see `sale_line_domain`'s own note for why missing
|
| 55 |
+
the first makes the flag a silent no-op.
|
| 56 |
+
|
| 57 |
+
β THE PARAMETER STAYS, AND THE DEFAULT STAYS `False`. Flipping the default would widen every
|
| 58 |
+
reader that imports this module, including money-to-the-books ones β and R4 fences this ruling
|
| 59 |
+
to SKU-shaped surfaces: AR, collections and FP&A stay wholesale-only because Amazon settles
|
| 60 |
+
differently. The widening is therefore stated at each CALL SITE, where it is greppable.
|
| 61 |
+
|
| 62 |
+
β AN EXPLICIT `team_id` STILL WINS: a Fisch or Royal caller keeps getting its own unit, because
|
| 63 |
+
`sale_line_domain` re-adds the explicit team after dropping the fence."""
|
| 64 |
g = O.read_group('sale.order.line',
|
| 65 |
O.sale_line_domain(date_from, date_to, team_id, extra=_NO_SVC,
|
| 66 |
all_channels=all_channels),
|
|
|
|
| 152 |
t = t or P.today()
|
| 153 |
yf, yt = P.ytd(t)
|
| 154 |
lf, lt = P.ytd_last_year(t)
|
| 155 |
+
this = _sku_rev(yf, yt, team_id, all_channels=True) # W39-T10
|
| 156 |
+
last = _sku_rev(lf, lt, team_id, all_channels=True) # W39-T10
|
| 157 |
rows = []
|
| 158 |
for pid in set(this) | set(last):
|
| 159 |
tr = this.get(pid, {'rev': 0.0, 'name': last.get(pid, {}).get('name', '')})
|
|
|
|
| 171 |
t = t or P.today()
|
| 172 |
yf, yt = P.ytd(t)
|
| 173 |
lf, lt = P.ytd_last_year(t)
|
| 174 |
+
this = _sku_rev(yf, yt, team_id, all_channels=True) # W39-T10
|
| 175 |
+
last = _sku_rev(lf, lt, team_id, all_channels=True) # W39-T10
|
| 176 |
rows = []
|
| 177 |
for pid, lr in last.items():
|
| 178 |
if lr['rev'] < min_prior:
|
|
|
|
| 191 |
t = t or P.today()
|
| 192 |
yf, yt = P.ytd(t)
|
| 193 |
lf, lt = P.ytd_last_year(t)
|
| 194 |
+
this = _sku_rev(yf, yt, team_id, all_channels=True) # W39-T10
|
| 195 |
+
last = _sku_rev(lf, lt, team_id, all_channels=True) # W39-T10
|
| 196 |
rows = []
|
| 197 |
for pid, tr in this.items():
|
| 198 |
if tr['rev'] < min_this:
|
|
|
|
| 210 |
"""Top SKUs by LTM unit velocity (units/month) and revenue."""
|
| 211 |
t = t or P.today()
|
| 212 |
lf, lt = P.ltm(t)
|
| 213 |
+
sku = _sku_rev(lf, lt, team_id, all_channels=True) # W39-T10
|
| 214 |
rows = [{'code': k, 'product': v['name'], 'units_ltm': v['qty'], 'units_per_mo': v['qty'] / 12.0,
|
| 215 |
'rev_ltm': v['rev'], 'orders_ltm': v['orders']} for k, v in sku.items()]
|
| 216 |
rows.sort(key=lambda x: -x['units_ltm'])
|
|
|
|
| 236 |
t = t or P.today()
|
| 237 |
yf, yt = P.ytd(t)
|
| 238 |
lf, lt = P.ytd_last_year(t)
|
| 239 |
+
this = _sku_rev(yf, yt, team_id, all_channels=True) # W39-T10
|
| 240 |
+
last = _sku_rev(lf, lt, team_id, all_channels=True) # W39-T10
|
| 241 |
code_cat = _code_category()
|
| 242 |
rows = []
|
| 243 |
for code in set(this) | set(last):
|
|
|
|
| 597 |
|
| 598 |
|
| 599 |
def tier_prices_by_code(prods=None):
|
| 600 |
+
"""`({code: [{pricelist, unit_price, unit}]}, report)` β EVERY live price a SKU really has.
|
| 601 |
|
| 602 |
ββ W37-T14 (owner: multiple prices per SKU). `pricelist_by_code` above answers a DIFFERENT
|
| 603 |
question and both are needed: it fills three DECLARED columns (`price_fisch`, `price_royal_1`,
|
| 604 |
`price_royal_2`) and therefore cannot show a price on a list the contract does not name. This
|
| 605 |
+
one is the honest set β one entry per PRICE RULE that actually prices this SKU today. One
|
| 606 |
+
pricelist can carry Pack, Sleeve and Master rows for the same SKU; collapsing those rows to
|
| 607 |
+
a single base price loses the unit-specific prices the catalogue needs to show.
|
| 608 |
|
| 609 |
β IT READS EVERY LIVE PRICELIST, not `PRICELIST_COLUMNS`. Hardcoding this tenant's three list
|
| 610 |
names into "how many prices does a SKU have" is exactly what the ticket forbids, and it is what
|
|
|
|
| 618 |
β FILTER BY `pricelist_id`, NEVER BY `active`: the default `product.pricelist.item` count hides
|
| 619 |
2,580 archived items, so an `active` filter reads as a smaller, plausible, wrong set.
|
| 620 |
|
| 621 |
+
β GRAIN IS THE CONTRACT. `min_quantity` is not a packaging quantity here: Pack, Sleeve and
|
| 622 |
+
Master can all have the same quantity break while `packaging_price` carries 5.00, 50.00 and
|
| 623 |
+
2,500.00 respectively. Keep every candidate row and order the result by the actual price.
|
| 624 |
+
β CARDINALITY is therefore a count of PRICE/UNIT pairs, not pricelists. `Royal 2` carries
|
| 625 |
+
2,605 price rows against **0 customers and 0 orders**, so a SKU reading several pairs is
|
| 626 |
+
catalogue-true and commercially misleading. The entry keeps the list NAME for provenance.
|
| 627 |
"""
|
| 628 |
report = {"lists": [], "rules_total": 0, "rules_not_fixed": 0, "rules_out_of_date": 0,
|
| 629 |
+
"rules_global": 0, "skus_with_no_price": 0, "identity_breaks": 0,
|
| 630 |
+
"rules_with_packaging": 0}
|
| 631 |
try:
|
| 632 |
pls = {p['id']: str(p.get('name') or '').strip()
|
| 633 |
for p in O.search_read('product.pricelist', [], ['id', 'name'])}
|
|
|
|
| 640 |
('fixed_price', '>', 0)]
|
| 641 |
rules = O.search_read('product.pricelist.item', dom,
|
| 642 |
['pricelist_id', 'product_id', 'product_tmpl_id', 'applied_on',
|
| 643 |
+
'fixed_price', 'min_quantity', 'packaging_id', 'packaging_price'])
|
| 644 |
+
report["rules_with_packaging"] = sum(1 for r in rules if r.get("packaging_id"))
|
| 645 |
|
| 646 |
# R6's second sentence: what this reader cannot see is COUNTED, never dropped.
|
| 647 |
def _n(extra):
|
|
|
|
| 677 |
cands = by_var.get((plid, p['id'])) or by_tmpl.get((plid, tmpl))
|
| 678 |
if not cands:
|
| 679 |
continue
|
| 680 |
+
for rule in cands:
|
| 681 |
+
# `packaging_price` is the price OF this package (not the base-unit fixed price).
|
| 682 |
+
# An orphaned package still has a useful price; it carries an honest blank unit.
|
| 683 |
+
price = rule.get("packaging_price") or rule.get("fixed_price") or 0.0
|
| 684 |
+
tiers.append({"pricelist": name, "unit_price": round(price, 2),
|
| 685 |
+
"unit": str(O.m2o_name(rule.get("packaging_id")) or "").strip()})
|
| 686 |
if tiers:
|
| 687 |
# β A code carried by TWO active products (D-309: `2112-12`) MERGES here, because the
|
| 688 |
# grid is keyed by code and one code is one row. The prices are UNIONED rather than
|
|
|
|
| 692 |
if prior is None:
|
| 693 |
out[code] = tiers
|
| 694 |
else:
|
| 695 |
+
seen = {(t["pricelist"], t["unit_price"], t["unit"]) for t in prior}
|
| 696 |
for t in tiers:
|
| 697 |
+
ident = (t["pricelist"], t["unit_price"], t["unit"])
|
| 698 |
+
if ident not in seen:
|
| 699 |
prior.append(t)
|
| 700 |
+
seen.add(ident)
|
| 701 |
report["identity_breaks"] += 1
|
| 702 |
else:
|
| 703 |
report["skus_with_no_price"] += 1
|
| 704 |
+
for tiers in out.values():
|
| 705 |
+
tiers.sort(key=lambda t: (t["unit_price"], t["unit"].lower(), t["pricelist"].lower()))
|
| 706 |
return out, report
|
| 707 |
|
| 708 |
|
|
|
|
| 780 |
"""{sku_code: distinct_customer_count} via 2-level read_group (slow-ish), merged by code.
|
| 781 |
(A customer buying two records sharing a code can count twice; duplicates are rare, and
|
| 782 |
code-level avoids the bigger error of a re-SKUed product reading as full coverage loss.)"""
|
| 783 |
+
g = O.read_group('sale.order.line',
|
| 784 |
+
O.sale_line_domain(date_from, date_to, team_id, extra=_NO_SVC,
|
| 785 |
+
all_channels=True), # W39-T10
|
| 786 |
['__count'], ['product_id', 'order_partner_id'], lazy=False)
|
| 787 |
codes = _code_map()
|
| 788 |
cov = {}
|
|
|
|
| 827 |
|
| 828 |
|
| 829 |
def _sku_dom(pids, date_from, date_to, team_id=None):
|
| 830 |
+
# β W39-T10 β ALL CHANNELS, and it MUST move with `_sku_rev`. The drawer's buyer bridge is
|
| 831 |
+
# reconciled against the SKU's directory revenue in `validate()`; widening one reader and not
|
| 832 |
+
# the other turns that leg red while both numbers are individually defensible.
|
| 833 |
+
return O.sale_line_domain(date_from, date_to, team_id, extra=[('product_id', 'in', pids)] + _NO_SVC,
|
| 834 |
+
all_channels=True)
|
| 835 |
|
| 836 |
|
| 837 |
def _sku_name_category(pids):
|
|
|
|
| 900 |
monthly.append({'month': ym, 'revenue': mrev.get(ym, 0.0),
|
| 901 |
'revenue_ly': mrev.get(f'{y:04d}-{m:02d}', 0.0)})
|
| 902 |
|
| 903 |
+
allsku = allsku if allsku is not None else _sku_rev(yf, yt, team_id, all_channels=True) # W39-T10
|
| 904 |
total = sum(v['rev'] for v in allsku.values()) or 1.0
|
| 905 |
rank = next((i + 1 for i, (c, _v) in enumerate(sorted(allsku.items(), key=lambda kv: -kv[1]['rev']))
|
| 906 |
if c == code), None)
|
|
|
|
| 1080 |
t = t or P.today()
|
| 1081 |
yf, yt = P.ytd(t)
|
| 1082 |
checks = []
|
| 1083 |
+
# ββ W39-T10 β BOTH SIDES OF EVERY LEG MOVED TOGETHER. These legs reconcile a per-SKU sum
|
| 1084 |
+
# against an ungrouped Odoo total over THE SAME DOMAIN. Widening the readers above without
|
| 1085 |
+
# widening the oracles here would print a ~$1.3M gap and read as a data defect rather than as
|
| 1086 |
+
# a scope change β the "disagree in opposite directions" failure.
|
| 1087 |
+
sku = _sku_rev(yf, yt, team_id, all_channels=True) # W39-T10
|
| 1088 |
sku_sum = sum(v['rev'] for v in sku.values())
|
| 1089 |
line_total = O.sum_field('sale.order.line',
|
| 1090 |
+
O.sale_line_domain(yf, yt, team_id, extra=_NO_SVC,
|
| 1091 |
+
all_channels=True), 'price_subtotal') # W39-T10
|
| 1092 |
checks.append({'check': 'SKU rev: Ξ£(per-SKU) == total line revenue, ex-services (YTD)',
|
| 1093 |
'a': round(sku_sum, 2), 'b': round(line_total, 2),
|
| 1094 |
'gap': round(sku_sum - line_total, 2),
|
|
|
|
| 1098 |
movers_sum = sum(r['change'] for r in m['risers']) + sum(r['change'] for r in m['decliners'])
|
| 1099 |
lf, lt = P.ytd_last_year(t)
|
| 1100 |
last_total = O.sum_field('sale.order.line',
|
| 1101 |
+
O.sale_line_domain(lf, lt, team_id, extra=_NO_SVC,
|
| 1102 |
+
all_channels=True), 'price_subtotal') # W39-T10
|
| 1103 |
checks.append({'check': 'SKU movers: Ξ£(Ξ) == (YTD β LY) total',
|
| 1104 |
'a': round(movers_sum, 2), 'b': round(line_total - last_total, 2),
|
| 1105 |
'gap': round(movers_sum - (line_total - last_total), 2),
|
web/src/customer-grid/ColumnMenu.tsx
CHANGED
|
@@ -296,6 +296,28 @@ interface ColumnMenuProps {
|
|
| 296 |
* `offerGeocode` doc for why the two questions currently differ.
|
| 297 |
*/
|
| 298 |
geocodable: boolean;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 299 |
}
|
| 300 |
|
| 301 |
type CreatePosition = "left" | "right" | "end";
|
|
@@ -2018,6 +2040,7 @@ export default function ColumnMenu({
|
|
| 2018 |
onClearGroup,
|
| 2019 |
userOptions = [],
|
| 2020 |
measures = [],
|
|
|
|
| 2021 |
}: ColumnMenuProps) {
|
| 2022 |
const [pane, setPane] = useState<MenuPane>(schemaLocked ? "menu" : initialPane ?? "menu");
|
| 2023 |
const [note, setNote] = useState(field.note ?? "");
|
|
@@ -3817,6 +3840,73 @@ export default function ColumnMenu({
|
|
| 3817 |
!!viewer &&
|
| 3818 |
(viewer.isAdmin || (field.createdBy != null && field.createdBy === viewer.name));
|
| 3819 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3820 |
return (
|
| 3821 |
<AnchoredOverlay
|
| 3822 |
anchor={state.anchor}
|
|
@@ -3865,6 +3955,52 @@ export default function ColumnMenu({
|
|
| 3865 |
</button>
|
| 3866 |
)}
|
| 3867 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3868 |
<div className="cg-menu-sep" role="separator" aria-hidden />
|
| 3869 |
|
| 3870 |
{/* Group 1 β Duplicate Β· Insert left Β· Insert right (+ Add at end, same family). */}
|
|
@@ -4062,6 +4198,30 @@ export default function ColumnMenu({
|
|
| 4062 |
/>
|
| 4063 |
</button>
|
| 4064 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4065 |
</div>
|
| 4066 |
</AnchoredOverlay>
|
| 4067 |
);
|
|
|
|
| 296 |
* `offerGeocode` doc for why the two questions currently differ.
|
| 297 |
*/
|
| 298 |
geocodable: boolean;
|
| 299 |
+
/**
|
| 300 |
+
* ββ W39-T17 β THE SERVER-ISSUED TABLE KEY, and the ONLY reason "Share field" needs a prop at
|
| 301 |
+
* all: a column is not addressable on its own. A field share's oid is
|
| 302 |
+
* `"<table_key>:<field_key>"` (`core.shares.field_oid`), and nothing else in this file knows
|
| 303 |
+
* which database the column lives on β the only `topic` here belongs to the rollup source
|
| 304 |
+
* offer, which is a rollup's TARGET topic, not this column's table.
|
| 305 |
+
*
|
| 306 |
+
* ββ IT MUST BE `payload.workspace.storageKey` AND NOTHING ELSE, and a near-miss fails
|
| 307 |
+
* SILENTLY. `core.shares.split_field_oid` fails closed to `(None, None)` on any id it cannot
|
| 308 |
+
* split; `routes_shares._object_ref` then answers `route=None` and `_notify_new_grantees`
|
| 309 |
+
* returns EARLY, so the grant is written and NOBODY IS TOLD. The host's local fallback
|
| 310 |
+
* (`${LOCAL_KEY_PREFIX}${scope}`) is, in `CustomerGrid`'s own words, "a key the server never
|
| 311 |
+
* issues" β a share built on one is junk that looks like it worked.
|
| 312 |
+
*
|
| 313 |
+
* β ABSENT IS A REAL STATE AND IT WITHDRAWS THE ROW. A workspace read that 403s leaves no
|
| 314 |
+
* storageKey, and a query PREVIEW's columns are a projection rather than the database's own;
|
| 315 |
+
* both pass nothing, and the menu then offers no Share entry rather than one that cannot
|
| 316 |
+
* resolve. `shared_overlay` is keyed by exactly this spelling on both sides
|
| 317 |
+
* (`product_table_workspace` for a registry topic, the bare key for a `ut_*` database) and
|
| 318 |
+
* `_object_ref` strips the `_table_workspace` suffix itself, so it travels UNMODIFIED.
|
| 319 |
+
*/
|
| 320 |
+
tableKey?: string;
|
| 321 |
}
|
| 322 |
|
| 323 |
type CreatePosition = "left" | "right" | "end";
|
|
|
|
| 2040 |
onClearGroup,
|
| 2041 |
userOptions = [],
|
| 2042 |
measures = [],
|
| 2043 |
+
tableKey,
|
| 2044 |
}: ColumnMenuProps) {
|
| 2045 |
const [pane, setPane] = useState<MenuPane>(schemaLocked ? "menu" : initialPane ?? "menu");
|
| 2046 |
const [note, setNote] = useState(field.note ?? "");
|
|
|
|
| 3840 |
!!viewer &&
|
| 3841 |
(viewer.isAdmin || (field.createdBy != null && field.createdBy === viewer.name));
|
| 3842 |
|
| 3843 |
+
/**
|
| 3844 |
+
* ββ W39-T17 β THE SHARE ID FOR THIS COLUMN, or `null` if one cannot honestly be built.
|
| 3845 |
+
*
|
| 3846 |
+
* β THREE WAYS IT REFUSES, and every one of them would otherwise produce an id the server
|
| 3847 |
+
* accepts and then cannot resolve:
|
| 3848 |
+
* Β· no `tableKey` β the host has no SERVER-issued key (see that prop's note: the local
|
| 3849 |
+
* fallback is a key the server never issues, and a grant on one notifies nobody);
|
| 3850 |
+
* Β· an empty one β `shares.field_oid` raises on a blank half, so the row must not offer it;
|
| 3851 |
+
* Β· a field key containing `:` β `split_field_oid` refuses any oid whose FIELD half carries
|
| 3852 |
+
* the separator, so such a column is not shareable and saying so by omission beats a row
|
| 3853 |
+
* that 404s.
|
| 3854 |
+
*/
|
| 3855 |
+
const shareTable = (tableKey ?? "").trim();
|
| 3856 |
+
const shareOid =
|
| 3857 |
+
shareTable && field.key && !field.key.includes(":") ? `${shareTable}:${field.key}` : null;
|
| 3858 |
+
|
| 3859 |
+
/**
|
| 3860 |
+
* Open the SHELL's access editor for this column β the same dialog, roles and people picker a
|
| 3861 |
+
* view or a folder gets (W39-T17's done-when; `shell/ShareDialog.tsx` renders all four kinds).
|
| 3862 |
+
*
|
| 3863 |
+
* ββ THE EVENT NAME IS A HARD-CODED LITERAL AND MUST STAY ONE. `customer-grid/**` MUST NOT
|
| 3864 |
+
* import `shell/**` (this tree is host-neutral β the same rule `ViewSidebar.tsx::openShare`
|
| 3865 |
+
* states and obeys), so `shareModel.SHARE_OPEN_EVENT` is unreachable from here by design.
|
| 3866 |
+
* The literal is spelled once there and once here, and `verify_wiring.py`'s W39-W1 row pins
|
| 3867 |
+
* BOTH ENDS precisely because no shared constant can keep them in step.
|
| 3868 |
+
* β It degrades to NOTHING when no shell is listening: an event nobody hears opens no dialog
|
| 3869 |
+
* and throws no error, which is the right failure for a frame-level surface reached from a
|
| 3870 |
+
* column menu.
|
| 3871 |
+
*/
|
| 3872 |
+
const openFieldShare = () => {
|
| 3873 |
+
if (!shareOid) return;
|
| 3874 |
+
window.dispatchEvent(
|
| 3875 |
+
new CustomEvent("aios:share-open", {
|
| 3876 |
+
detail: { kind: "field", id: shareOid, label: field.label },
|
| 3877 |
+
})
|
| 3878 |
+
);
|
| 3879 |
+
};
|
| 3880 |
+
|
| 3881 |
+
/**
|
| 3882 |
+
* ββ W39-T17 / R8 β MAY THIS VIEWER DELETE THIS COLUMN? The client half of the server's wall,
|
| 3883 |
+
* spelled with the SAME comparison `canPermissions` above already uses rather than a second
|
| 3884 |
+
* one: `createdBy` is a login username while the people picker deals in display names
|
| 3885 |
+
* ([[login-resolves-email-writes-do-not]]), and one spelling means a mismatch lands identically
|
| 3886 |
+
* in both places instead of differently in each.
|
| 3887 |
+
*
|
| 3888 |
+
* β THE UNSTAMPED CASE ANSWERS "YES" ON PURPOSE, and it is a deliberate under-reach.
|
| 3889 |
+
* `routes_tables.delete_shared_field` reads `owner = str(defn.get("createdBy") or "")` and
|
| 3890 |
+
* refuses a non-admin when it is blank, so an UNSTAMPED shared column is really admin-only. But
|
| 3891 |
+
* the client cannot tell one from a base Odoo column, which carries no `createdBy` either and
|
| 3892 |
+
* is deletable by nobody at all β firing there would put a refusal on every source field on the
|
| 3893 |
+
* grid. So the sentence appears only where the client can NAME the person, which is the only
|
| 3894 |
+
* shape in which it is useful.
|
| 3895 |
+
* β IT DOES NOT DISABLE THE DELETE BUTTON, AND THE REASON IS THE `viewer === undefined` LEG,
|
| 3896 |
+
* not the stratum. The predicate can only fire where `createdBy` is stamped and differs from
|
| 3897 |
+
* the viewer, which IS the server-walled path β so "a `custom_` delete is client-side" would be
|
| 3898 |
+
* a rationale describing a case that never reaches here. What does reach here is a host that
|
| 3899 |
+
* supplied no `viewer` (the payload's field is optional): `mayDeleteField` then answers false
|
| 3900 |
+
* for the column's OWN CREATOR, and a disable on that would take a delete away from the one
|
| 3901 |
+
* person who certainly has it. Fail-closed is right for a SENTENCE and wrong for a CONTROL.
|
| 3902 |
+
* β So a `ut_*` link/rollup/formula created by somebody else shows this line WITH a live Delete
|
| 3903 |
+
* button beside it, and that button 403s. Stated rather than hidden: the server is the wall,
|
| 3904 |
+
* this is the courtesy half, and narrowing the button is a separate decision.
|
| 3905 |
+
*/
|
| 3906 |
+
const deleteOwner = (field.createdBy ?? "").trim();
|
| 3907 |
+
const mayDeleteField =
|
| 3908 |
+
!deleteOwner || (!!viewer && (viewer.isAdmin || deleteOwner === viewer.name));
|
| 3909 |
+
|
| 3910 |
return (
|
| 3911 |
<AnchoredOverlay
|
| 3912 |
anchor={state.anchor}
|
|
|
|
| 3955 |
</button>
|
| 3956 |
)}
|
| 3957 |
|
| 3958 |
+
{/* ββ W39-T17 β SHARE FIELD, directly beneath Edit field, and it opens the SAME dialog
|
| 3959 |
+
views and folders already use (one editor, one vocabulary: the R10 ruling behind
|
| 3960 |
+
`shell/ShareDialog.tsx`). NOT gated on `schemaLocked`: sharing a column changes no
|
| 3961 |
+
schema, it decides who can SEE it, which is the same reason Summary, Sort and Filter
|
| 3962 |
+
sit below without that gate.
|
| 3963 |
+
β NO `data-overlay-autofocus` HERE. The overlay's `initialFocus` selector is exactly
|
| 3964 |
+
that attribute, and a second row wearing it makes the opening focus ambiguous.
|
| 3965 |
+
β NOT GATED ON THE VIEWER EITHER, deliberately. The dialog itself states the refusal
|
| 3966 |
+
when `mayAdminister` is false ("NOT A HIDDEN EDITOR β a stated refusal"), and a hidden
|
| 3967 |
+
row would leave a collaborator guessing why the column they can see is not theirs to
|
| 3968 |
+
pass on. The one thing that DOES withdraw it is a missing server table key, because
|
| 3969 |
+
without one the id cannot be built. */}
|
| 3970 |
+
{shareOid && (
|
| 3971 |
+
<button
|
| 3972 |
+
type="button"
|
| 3973 |
+
onClick={() => {
|
| 3974 |
+
openFieldShare();
|
| 3975 |
+
onClose();
|
| 3976 |
+
}}
|
| 3977 |
+
>
|
| 3978 |
+
{/* β AN INLINE NODE, NOT A `MenuIcon` NAME, and that is forced rather than chosen.
|
| 3979 |
+
`MenuLabel`'s prop is `MenuIconName | ReactNode` and `ReactNode` admits any
|
| 3980 |
+
string, so `icon="share"` would COMPILE and paint an empty 16px box forever
|
| 3981 |
+
(`MENU_ICONS` has no such key, and `icons.tsx` is not this ticket's file). The
|
| 3982 |
+
three-node graph is the mark every product uses for this verb; borrowing the
|
| 3983 |
+
padlock that "Edit field permissions" already wears below would put two identical
|
| 3984 |
+
glyphs on two different questions. */}
|
| 3985 |
+
<MenuLabel
|
| 3986 |
+
icon={
|
| 3987 |
+
<svg width={16} height={16} viewBox="0 0 16 16" fill="none" aria-hidden>
|
| 3988 |
+
<circle cx="12" cy="3.6" r="1.9" stroke="currentColor" strokeWidth={1.3} />
|
| 3989 |
+
<circle cx="4" cy="8" r="1.9" stroke="currentColor" strokeWidth={1.3} />
|
| 3990 |
+
<circle cx="12" cy="12.4" r="1.9" stroke="currentColor" strokeWidth={1.3} />
|
| 3991 |
+
<path
|
| 3992 |
+
d="m5.75 7.1 4.5-2.6M5.75 8.9l4.5 2.6"
|
| 3993 |
+
stroke="currentColor"
|
| 3994 |
+
strokeWidth={1.3}
|
| 3995 |
+
strokeLinecap="round"
|
| 3996 |
+
/>
|
| 3997 |
+
</svg>
|
| 3998 |
+
}
|
| 3999 |
+
text="Share field"
|
| 4000 |
+
/>
|
| 4001 |
+
</button>
|
| 4002 |
+
)}
|
| 4003 |
+
|
| 4004 |
<div className="cg-menu-sep" role="separator" aria-hidden />
|
| 4005 |
|
| 4006 |
{/* Group 1 β Duplicate Β· Insert left Β· Insert right (+ Add at end, same family). */}
|
|
|
|
| 4198 |
/>
|
| 4199 |
</button>
|
| 4200 |
)}
|
| 4201 |
+
{/* ββ W39-T17 / R8 β A FIELD YOU CANNOT DELETE SAYS WHO CAN, which nothing in this menu
|
| 4202 |
+
did: the only place a creator was ever named is the permissions pane's parenthetical
|
| 4203 |
+
`(created by X)`, which a person has to open a different window to read.
|
| 4204 |
+
`routes_tables.delete_shared_field` refuses everyone but the stamped `createdBy` and
|
| 4205 |
+
an admin, and it refuses at the SERVER β so without this the menu either hides the row
|
| 4206 |
+
with no reason given or offers a button whose only outcome is a 403.
|
| 4207 |
+
β NOT A `MenuLabel` ROW, and the reason is measured in `index.css`:
|
| 4208 |
+
`.cg-column-actions button` is `white-space: nowrap` and `.cg-mi-text` ellipsises, so
|
| 4209 |
+
a sentence in a menu row would render as "Only alice or an administrβ¦" β the half that
|
| 4210 |
+
names the person is exactly the half that gets cut. `.cg-field-hint` is this file's
|
| 4211 |
+
existing wrapping prose class (36 uses), so no rule is owed in a stylesheet this
|
| 4212 |
+
ticket does not own.
|
| 4213 |
+
β NO DASH IN IT (standing rule 2): the nearest precedent, `ShareDialog`'s own
|
| 4214 |
+
refusal, carried two em dashes, and copying its shape is how the dash travels. */}
|
| 4215 |
+
{!mayDeleteField && (
|
| 4216 |
+
/* β The 8px inset is INLINE because it is the only thing this row needs from a
|
| 4217 |
+
stylesheet and `index.css` is not this ticket's file. `.cg-column-actions` has no
|
| 4218 |
+
horizontal padding of its own (its BUTTONS carry `0 8px`), so a bare hint would sit
|
| 4219 |
+
8px left of every label above it and read as belonging to the panel rather than to
|
| 4220 |
+
the Delete row it explains. */
|
| 4221 |
+
<p className="cg-field-hint" style={{ padding: "2px 8px 4px" }}>
|
| 4222 |
+
Only {deleteOwner} or an administrator can delete this field.
|
| 4223 |
+
</p>
|
| 4224 |
+
)}
|
| 4225 |
</div>
|
| 4226 |
</AnchoredOverlay>
|
| 4227 |
);
|
web/src/customer-grid/CustomerGrid.tsx
CHANGED
|
@@ -12,6 +12,8 @@ import type {
|
|
| 12 |
CellClickedEventArgs,
|
| 13 |
DataEditorRef,
|
| 14 |
EditableGridCell,
|
|
|
|
|
|
|
| 15 |
GridKeyEventArgs,
|
| 16 |
GridMouseEventArgs,
|
| 17 |
GridSelection,
|
|
@@ -131,6 +133,7 @@ import { DashboardView } from "../viz/DashboardView";
|
|
| 131 |
import { cleanCharts } from "../viz/chartData";
|
| 132 |
import type { ChartSpec } from "../viz/chartData";
|
| 133 |
import { MapView } from "./MapView";
|
|
|
|
| 134 |
/* βββ W18-C CATALOG βββ (owner item 4, contract C6) */
|
| 135 |
import { CatalogView } from "./CatalogView";
|
| 136 |
import { CATALOG_CODE_FIELD } from "./catalogData";
|
|
@@ -212,6 +215,24 @@ export const GRID_CHAT_OPEN_EVENT = "aios:grid-chat-open";
|
|
| 212 |
|
| 213 |
const ROW_PX: Record<RowHeightMode, number> = { short: 28, medium: 34, tall: 48 };
|
| 214 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
/**
|
| 216 |
* Wave-14 R7 β **a button label is ONE LINE.** Every button row in this file is a flex row with
|
| 217 |
* no width reservation, so on a narrow grid box the items shrink and their labels wrap:
|
|
@@ -1637,6 +1658,12 @@ function CustomerGridSurface({
|
|
| 1637 |
setColumnVisible,
|
| 1638 |
insertColumn,
|
| 1639 |
} = useGridColumns(fields, config, updateConfig);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1640 |
|
| 1641 |
/**
|
| 1642 |
* β Wave-20 owner item 4 (ruling R8, contract C-ADDROW) β **THE GHOST ROW.**
|
|
@@ -2653,7 +2680,7 @@ function CustomerGridSurface({
|
|
| 2653 |
return () => window.clearInterval(id);
|
| 2654 |
}, [pendingMeasureKeys]);
|
| 2655 |
const activePendingKeys = pendingGaveUp ? NO_PENDING_KEYS : pendingMeasureKeys;
|
| 2656 |
-
const
|
| 2657 |
displayRows,
|
| 2658 |
visibleCols,
|
| 2659 |
fieldByKey,
|
|
@@ -2666,12 +2693,39 @@ function CustomerGridSurface({
|
|
| 2666 |
activePendingKeys,
|
| 2667 |
pulse
|
| 2668 |
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2669 |
const { gridSelection, selectedPids, onGridSelectionChange, selectPids, togglePid,
|
| 2670 |
setActiveCell, clearSelection } =
|
| 2671 |
useGridSelection(
|
| 2672 |
displayRows,
|
| 2673 |
displayPidToIndex,
|
| 2674 |
-
|
| 2675 |
embeddedSelectable ? embeddedSelectedIds : []
|
| 2676 |
);
|
| 2677 |
const embeddedSelectedKey = embeddedSelectedIds.join(",");
|
|
@@ -3721,6 +3775,16 @@ function CustomerGridSurface({
|
|
| 3721 |
});
|
| 3722 |
return;
|
| 3723 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3724 |
const column = visibleCols[cell[0]];
|
| 3725 |
const field = column ? fieldByKey.get(column.id!) : undefined;
|
| 3726 |
// A picked field (select / assignee) opens its choices where the cell is. It cannot use
|
|
@@ -3785,7 +3849,7 @@ function CustomerGridSurface({
|
|
| 3785 |
: null;
|
| 3786 |
if (href) {
|
| 3787 |
event.preventDefault();
|
| 3788 |
-
window.open(href, "_blank", "noopener");
|
| 3789 |
return;
|
| 3790 |
}
|
| 3791 |
}
|
|
@@ -3809,7 +3873,7 @@ function CustomerGridSurface({
|
|
| 3809 |
// open a record; the hover Expand button below is its replacement, and half of this
|
| 3810 |
// change is a table whose records cannot be opened at all.
|
| 3811 |
},
|
| 3812 |
-
[displayRows, visibleCols, fieldByKey, canEditField, overlayEdits, lockedKey, togglePid,
|
| 3813 |
setActiveCell]
|
| 3814 |
);
|
| 3815 |
/**
|
|
@@ -3931,6 +3995,25 @@ function CustomerGridSurface({
|
|
| 3931 |
},
|
| 3932 |
[embedded, visibleCols]
|
| 3933 |
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3934 |
const onHeaderClicked = useCallback(
|
| 3935 |
(column: number, event: HeaderClickedEventArgs) => {
|
| 3936 |
if (event.isEdge) return;
|
|
@@ -6802,7 +6885,7 @@ function CustomerGridSurface({
|
|
| 6802 |
<>
|
| 6803 |
<DataEditor
|
| 6804 |
ref={gridRef}
|
| 6805 |
-
columns={
|
| 6806 |
getCellContent={getCellContent}
|
| 6807 |
rows={displayRows.length}
|
| 6808 |
theme={lightTheme}
|
|
@@ -6863,9 +6946,9 @@ function CustomerGridSurface({
|
|
| 6863 |
// `false` from here takes the write over from glide's per-cell path.
|
| 6864 |
onDelete={onGridDelete}
|
| 6865 |
validateCell={validateCell}
|
| 6866 |
-
onColumnResize={
|
| 6867 |
-
onColumnMoved={
|
| 6868 |
-
onColumnProposeMove={
|
| 6869 |
drawHeader={drawGridHeader}
|
| 6870 |
onHeaderMenuClick={(column, bounds) => openHeaderMenu(column, bounds)}
|
| 6871 |
onHeaderClicked={onHeaderClicked}
|
|
@@ -7804,6 +7887,21 @@ function CustomerGridSurface({
|
|
| 7804 |
onClearGroup={() => updateConfig({ ...config, groupBy: null })}
|
| 7805 |
userOptions={payload?.userOptions ?? []}
|
| 7806 |
measures={measures}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7807 |
/>
|
| 7808 |
)}
|
| 7809 |
|
|
|
|
| 12 |
CellClickedEventArgs,
|
| 13 |
DataEditorRef,
|
| 14 |
EditableGridCell,
|
| 15 |
+
GridCell,
|
| 16 |
+
GridColumn,
|
| 17 |
GridKeyEventArgs,
|
| 18 |
GridMouseEventArgs,
|
| 19 |
GridSelection,
|
|
|
|
| 133 |
import { cleanCharts } from "../viz/chartData";
|
| 134 |
import type { ChartSpec } from "../viz/chartData";
|
| 135 |
import { MapView } from "./MapView";
|
| 136 |
+
import { googleDirectionsUrl, isPlottable } from "./mapProjection";
|
| 137 |
/* βββ W18-C CATALOG βββ (owner item 4, contract C6) */
|
| 138 |
import { CatalogView } from "./CatalogView";
|
| 139 |
import { CATALOG_CODE_FIELD } from "./catalogData";
|
|
|
|
| 215 |
|
| 216 |
const ROW_PX: Record<RowHeightMode, number> = { short: 28, medium: 34, tall: 48 };
|
| 217 |
|
| 218 |
+
/**
|
| 219 |
+
* W39-T22 β a transport action, not a stored field. Coordinates already travel on every
|
| 220 |
+
* customer row for MapView, but deliberately are not columns. Keeping this synthetic column out
|
| 221 |
+
* of the workspace means a person cannot hide, reorder, rename, or accidentally persist it as
|
| 222 |
+
* though it were customer data.
|
| 223 |
+
*/
|
| 224 |
+
const DIRECTIONS_COLUMN_ID = "__directions__";
|
| 225 |
+
const DIRECTIONS_COLUMN: GridColumn = { id: DIRECTIONS_COLUMN_ID, title: "Go", width: 46 };
|
| 226 |
+
|
| 227 |
+
/** `null` means there is no honest Google destination, including a blank-address row. */
|
| 228 |
+
function directionsFor(row: Row): { lat: number; lon: number } | null {
|
| 229 |
+
const lat = row.lat;
|
| 230 |
+
const lon = row.lon;
|
| 231 |
+
return typeof lat === "number" && typeof lon === "number" && isPlottable(lat, lon)
|
| 232 |
+
? { lat, lon }
|
| 233 |
+
: null;
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
/**
|
| 237 |
* Wave-14 R7 β **a button label is ONE LINE.** Every button row in this file is a flex row with
|
| 238 |
* no width reservation, so on a narrow grid box the items shrink and their labels wrap:
|
|
|
|
| 1658 |
setColumnVisible,
|
| 1659 |
insertColumn,
|
| 1660 |
} = useGridColumns(fields, config, updateConfig);
|
| 1661 |
+
// W39-T22 β the transit link is an action, so it is appended to what Glide paints without
|
| 1662 |
+
// joining the saved-view column contract. `onCellClicked` below is the one tap door.
|
| 1663 |
+
const gridColumns = useMemo(
|
| 1664 |
+
() => scope === "customer" ? [...visibleCols, DIRECTIONS_COLUMN] : visibleCols,
|
| 1665 |
+
[scope, visibleCols]
|
| 1666 |
+
);
|
| 1667 |
|
| 1668 |
/**
|
| 1669 |
* β Wave-20 owner item 4 (ruling R8, contract C-ADDROW) β **THE GHOST ROW.**
|
|
|
|
| 2680 |
return () => window.clearInterval(id);
|
| 2681 |
}, [pendingMeasureKeys]);
|
| 2682 |
const activePendingKeys = pendingGaveUp ? NO_PENDING_KEYS : pendingMeasureKeys;
|
| 2683 |
+
const baseGetCellContent = useGetCellContent(
|
| 2684 |
displayRows,
|
| 2685 |
visibleCols,
|
| 2686 |
fieldByKey,
|
|
|
|
| 2693 |
activePendingKeys,
|
| 2694 |
pulse
|
| 2695 |
);
|
| 2696 |
+
const getCellContent = useCallback(
|
| 2697 |
+
(cell: Item): GridCell => {
|
| 2698 |
+
if (scope !== "customer" || cell[0] !== visibleCols.length) return baseGetCellContent(cell);
|
| 2699 |
+
const row = displayRows[cell[1]];
|
| 2700 |
+
const at = row?.kind === "data" ? directionsFor(row.record) : null;
|
| 2701 |
+
// A blank cell is deliberately not a disabled-looking `Go`: there is no destination to
|
| 2702 |
+
// offer, and a row whose address is blank must not acquire a plausible link to nowhere.
|
| 2703 |
+
if (!at) {
|
| 2704 |
+
return {
|
| 2705 |
+
kind: GridCellKind.Text,
|
| 2706 |
+
data: "",
|
| 2707 |
+
displayData: "",
|
| 2708 |
+
allowOverlay: false,
|
| 2709 |
+
readonly: true,
|
| 2710 |
+
};
|
| 2711 |
+
}
|
| 2712 |
+
return {
|
| 2713 |
+
kind: GridCellKind.Uri,
|
| 2714 |
+
data: googleDirectionsUrl(at.lat, at.lon),
|
| 2715 |
+
displayData: "Go",
|
| 2716 |
+
allowOverlay: false,
|
| 2717 |
+
readonly: true,
|
| 2718 |
+
hoverEffect: true,
|
| 2719 |
+
};
|
| 2720 |
+
},
|
| 2721 |
+
[baseGetCellContent, displayRows, scope, visibleCols.length]
|
| 2722 |
+
);
|
| 2723 |
const { gridSelection, selectedPids, onGridSelectionChange, selectPids, togglePid,
|
| 2724 |
setActiveCell, clearSelection } =
|
| 2725 |
useGridSelection(
|
| 2726 |
displayRows,
|
| 2727 |
displayPidToIndex,
|
| 2728 |
+
gridColumns.length,
|
| 2729 |
embeddedSelectable ? embeddedSelectedIds : []
|
| 2730 |
);
|
| 2731 |
const embeddedSelectedKey = embeddedSelectedIds.join(",");
|
|
|
|
| 3775 |
});
|
| 3776 |
return;
|
| 3777 |
}
|
| 3778 |
+
// W39-T22 β Glide paints links on its canvas, so this handler is the ACTUAL touch door.
|
| 3779 |
+
// The Go cell is virtual and always trails the persisted columns; no address-derived string
|
| 3780 |
+
// is used here because coordinates, not an address search, are what the map plotted.
|
| 3781 |
+
if (scope === "customer" && row.kind === "data" && cell[0] === visibleCols.length) {
|
| 3782 |
+
const at = directionsFor(row.record);
|
| 3783 |
+
if (!at) return;
|
| 3784 |
+
event.preventDefault();
|
| 3785 |
+
window.open(googleDirectionsUrl(at.lat, at.lon), "_blank", "noopener,noreferrer");
|
| 3786 |
+
return;
|
| 3787 |
+
}
|
| 3788 |
const column = visibleCols[cell[0]];
|
| 3789 |
const field = column ? fieldByKey.get(column.id!) : undefined;
|
| 3790 |
// A picked field (select / assignee) opens its choices where the cell is. It cannot use
|
|
|
|
| 3849 |
: null;
|
| 3850 |
if (href) {
|
| 3851 |
event.preventDefault();
|
| 3852 |
+
window.open(href, "_blank", "noopener,noreferrer");
|
| 3853 |
return;
|
| 3854 |
}
|
| 3855 |
}
|
|
|
|
| 3873 |
// open a record; the hover Expand button below is its replacement, and half of this
|
| 3874 |
// change is a table whose records cannot be opened at all.
|
| 3875 |
},
|
| 3876 |
+
[displayRows, visibleCols, scope, fieldByKey, canEditField, overlayEdits, lockedKey, togglePid,
|
| 3877 |
setActiveCell]
|
| 3878 |
);
|
| 3879 |
/**
|
|
|
|
| 3995 |
},
|
| 3996 |
[embedded, visibleCols]
|
| 3997 |
);
|
| 3998 |
+
const onGridColumnResize = useCallback(
|
| 3999 |
+
(column: GridColumn, newSize: number) => {
|
| 4000 |
+
if (column.id === DIRECTIONS_COLUMN_ID) return;
|
| 4001 |
+
onColumnResize(column, newSize);
|
| 4002 |
+
},
|
| 4003 |
+
[onColumnResize]
|
| 4004 |
+
);
|
| 4005 |
+
const onGridColumnMoved = useCallback(
|
| 4006 |
+
(from: number, to: number) => {
|
| 4007 |
+
if (from >= visibleCols.length || to >= visibleCols.length) return;
|
| 4008 |
+
onColumnMoved(from, to);
|
| 4009 |
+
},
|
| 4010 |
+
[onColumnMoved, visibleCols.length]
|
| 4011 |
+
);
|
| 4012 |
+
const onGridColumnProposeMove = useCallback(
|
| 4013 |
+
(from: number, to: number) =>
|
| 4014 |
+
from < visibleCols.length && to < visibleCols.length && onColumnProposeMove(from, to),
|
| 4015 |
+
[onColumnProposeMove, visibleCols.length]
|
| 4016 |
+
);
|
| 4017 |
const onHeaderClicked = useCallback(
|
| 4018 |
(column: number, event: HeaderClickedEventArgs) => {
|
| 4019 |
if (event.isEdge) return;
|
|
|
|
| 6885 |
<>
|
| 6886 |
<DataEditor
|
| 6887 |
ref={gridRef}
|
| 6888 |
+
columns={gridColumns}
|
| 6889 |
getCellContent={getCellContent}
|
| 6890 |
rows={displayRows.length}
|
| 6891 |
theme={lightTheme}
|
|
|
|
| 6946 |
// `false` from here takes the write over from glide's per-cell path.
|
| 6947 |
onDelete={onGridDelete}
|
| 6948 |
validateCell={validateCell}
|
| 6949 |
+
onColumnResize={onGridColumnResize}
|
| 6950 |
+
onColumnMoved={onGridColumnMoved}
|
| 6951 |
+
onColumnProposeMove={onGridColumnProposeMove}
|
| 6952 |
drawHeader={drawGridHeader}
|
| 6953 |
onHeaderMenuClick={(column, bounds) => openHeaderMenu(column, bounds)}
|
| 6954 |
onHeaderClicked={onHeaderClicked}
|
|
|
|
| 7887 |
onClearGroup={() => updateConfig({ ...config, groupBy: null })}
|
| 7888 |
userOptions={payload?.userOptions ?? []}
|
| 7889 |
measures={measures}
|
| 7890 |
+
/* ββ W39-T17 β THE TABLE HALF OF A FIELD SHARE'S OID, and it is deliberately NOT the
|
| 7891 |
+
`storageKey` const above. That one carries two spellings the share door cannot use:
|
| 7892 |
+
the query-preview key (`query:<binding>`, a projection rather than this database's
|
| 7893 |
+
own columns) and the local fallback `${LOCAL_KEY_PREFIX}${scope}`, which this file's
|
| 7894 |
+
own note at the declaration calls "a key the server never issues".
|
| 7895 |
+
β AND THE FALLBACK IS THE DANGEROUS ONE, not merely the untidy one:
|
| 7896 |
+
`core.shares.split_field_oid` fails closed on an id it cannot resolve,
|
| 7897 |
+
`routes_shares._object_ref` answers `route=None`, and `_notify_new_grantees` returns
|
| 7898 |
+
early β the grant lands and the receiver is NEVER TOLD. So this reads the SERVER's
|
| 7899 |
+
key directly and passes `undefined` when there is none, which withdraws the Share
|
| 7900 |
+
row rather than offering one that resolves to nothing.
|
| 7901 |
+
β Only this instance takes it. The "+" render below opens straight into the create
|
| 7902 |
+
form and never paints the action list, so the row it feeds is unreachable there β
|
| 7903 |
+
unlike `linkTargets`, which both create paths genuinely need. */
|
| 7904 |
+
tableKey={isQueryPreview ? undefined : payload?.workspace?.storageKey}
|
| 7905 |
/>
|
| 7906 |
)}
|
| 7907 |
|
web/src/customer-grid/MapView.tsx
CHANGED
|
@@ -144,6 +144,14 @@ const R_MIN = 3.2;
|
|
| 144 |
const R_MAX = 15;
|
| 145 |
const R_PLAIN = 4.5;
|
| 146 |
const R_NULL = 2.6;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
|
| 148 |
/**
|
| 149 |
* The pin palette: C1's cycle (blue -> green -> yellow -> red) then its
|
|
@@ -258,6 +266,9 @@ export function MapView({
|
|
| 258 |
* hands it back. Kept as pids rather than indices because the selection can change underneath it.
|
| 259 |
*/
|
| 260 |
const [routeOrder, setRouteOrder] = useState<number[] | null>(null);
|
|
|
|
|
|
|
|
|
|
| 261 |
/**
|
| 262 |
* W37-T36 β the ROAD answer, which is the half arithmetic cannot produce.
|
| 263 |
*
|
|
@@ -286,7 +297,7 @@ export function MapView({
|
|
| 286 |
* must not render "no saved orders".
|
| 287 |
*/
|
| 288 |
const [routeCols, setRouteCols] = useState<
|
| 289 |
-
{ key: string; label: string; inputsHash: string; mine: boolean }[] | null
|
| 290 |
>(null);
|
| 291 |
/** Which saved column a save writes to. `""` = a new one, named by `saveName`. */
|
| 292 |
const [saveTarget, setSaveTarget] = useState("");
|
|
@@ -560,7 +571,7 @@ export function MapView({
|
|
| 560 |
|
| 561 |
/** Selected pins that can actually be routed, in a STABLE order (by pid) β
|
| 562 |
* a Set's iteration order must not be what decides a route. */
|
| 563 |
-
const
|
| 564 |
() =>
|
| 565 |
selectedPids.size < 2
|
| 566 |
? []
|
|
@@ -569,9 +580,28 @@ export function MapView({
|
|
| 569 |
.sort((a, b) => a.pid - b.pid),
|
| 570 |
[points, selectedPids]
|
| 571 |
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 572 |
/** Selected records with no usable coordinate. Counted and shown, never
|
| 573 |
* folded silently into the stop total ([[no-unverifiable-aggregates]]). */
|
| 574 |
-
const unroutable = selectedPids.size -
|
| 575 |
|
| 576 |
/** Selected records with no usable coordinate, BY NAME. W37-T36: the chip used to give a bare
|
| 577 |
* count, which tells you that something is missing and not which visit you are about to fail to
|
|
@@ -581,24 +611,26 @@ export function MapView({
|
|
| 581 |
// β Read from `rows` and NOT from `points`: a record with no coordinate never becomes a point
|
| 582 |
// in the first place, so filtering the pin list for unplottable pins finds exactly nothing.
|
| 583 |
// That mistake would have shipped a chip whose count said 4 and whose names said none.
|
| 584 |
-
const ok = new Set(
|
| 585 |
return rows
|
| 586 |
.filter((r) => selectedPids.has(r.pid) && !ok.has(r.pid))
|
| 587 |
.map((r) => String(r[field.key] ?? "").trim() || `#${r.pid}`);
|
| 588 |
-
}, [rows, field.key, selectedPids,
|
| 589 |
|
| 590 |
const plan = useMemo(() => {
|
| 591 |
-
if (!routeOn ||
|
| 592 |
const stops: GeoStop[] = routable.map((p) => ({ lat: p.lat, lon: p.lon }));
|
| 593 |
// Default origin: the WESTERNMOST stop. Deterministic, stable while the user
|
| 594 |
// pans, and sayable out loud β unlike "whatever ended up at index 0". The
|
| 595 |
// picker below overrides it.
|
| 596 |
let start = 0;
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
|
|
|
|
|
|
|
| 602 |
}
|
| 603 |
const { order, km } = planRoute(stops, haversineKm, { start, roundTrip });
|
| 604 |
// ββ W38-T20 β THE HAND-REORDER STAYS IN **INDEX** SPACE, and that is a deliberate change of
|
|
@@ -616,12 +648,18 @@ export function MapView({
|
|
| 616 |
// selected. Anything the user has since deselected drops out; anything newly selected is
|
| 617 |
// appended in the planner's own order rather than being silently left off the route.
|
| 618 |
if (routeOrder) {
|
| 619 |
-
const idxByPid = new Map(
|
|
|
|
|
|
|
| 620 |
const picked = routeOrder
|
| 621 |
.map((pid) => idxByPid.get(pid))
|
| 622 |
.filter((i) => i !== undefined) as number[];
|
| 623 |
const seen = new Set(picked);
|
| 624 |
-
seq = [
|
|
|
|
|
|
|
|
|
|
|
|
|
| 625 |
}
|
| 626 |
const ordered = seq.map((i) => routable[i]);
|
| 627 |
// ββ THE PERMUTATION IS NOT THE RANK (A25 / A44). `seq[i]` is WHICH STOP is visited i-th;
|
|
@@ -636,7 +674,7 @@ export function MapView({
|
|
| 636 |
/** `{pid: visit number}` β what the route-order COLUMN stores, one integer per record. */
|
| 637 |
ranksByPid: routable.reduce((acc, p, i) => {
|
| 638 |
const n = ranks[i];
|
| 639 |
-
if (n != null) acc[p.pid] = n;
|
| 640 |
return acc;
|
| 641 |
}, {} as Record<number, number>),
|
| 642 |
/**
|
|
@@ -650,20 +688,24 @@ export function MapView({
|
|
| 650 |
// default applies, so keying on it would report STALE the moment somebody explicitly
|
| 651 |
// selects the stop that was ALREADY the origin β the numbers unchanged, the marker on.
|
| 652 |
// What the answer depends on is which stop the planner was actually given.
|
| 653 |
-
{ roundTrip, startPid: routable[start].pid, handOrder: routeOrder }
|
| 654 |
),
|
| 655 |
/** The origin the planner was given, so the saved column records the same one the
|
| 656 |
* fingerprint was taken over. */
|
| 657 |
-
startPid: routable[start].pid,
|
| 658 |
link: googleRouteUrl(
|
| 659 |
ordered.map((p) => ({ lat: p.lat, lon: p.lon })),
|
| 660 |
{ roundTrip, coarsePointer }
|
| 661 |
),
|
| 662 |
/** The identity of THIS set of stops in THIS order. The road answer is only about the plan
|
| 663 |
* whose key it carries; anything else on screen makes it stale rather than wrong. */
|
| 664 |
-
key: ordered.map((p) => p.pid).join(",") +
|
|
|
|
|
|
|
|
|
|
| 665 |
};
|
| 666 |
-
}, [routeOn, routable,
|
|
|
|
| 667 |
|
| 668 |
const roadStale = !!road && !!plan && road.key !== plan.key;
|
| 669 |
|
|
@@ -790,6 +832,7 @@ export function MapView({
|
|
| 790 |
inputsHash: plan.fingerprint,
|
| 791 |
roundTrip,
|
| 792 |
startPid: plan.startPid,
|
|
|
|
| 793 |
}),
|
| 794 |
});
|
| 795 |
const body = await res.json().catch(() => null);
|
|
@@ -916,10 +959,52 @@ export function MapView({
|
|
| 916 |
}
|
| 917 |
}, [coordField, onGeocoded, geocodable, geoBatch]);
|
| 918 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 919 |
const moveStop = useCallback(
|
| 920 |
(pid: number, delta: number) => {
|
| 921 |
-
if (!plan) return;
|
| 922 |
-
const ids = plan.ordered.map((p) => p.pid);
|
| 923 |
const at = ids.indexOf(pid);
|
| 924 |
const to = at + delta;
|
| 925 |
if (at < 0 || to < 0 || to >= ids.length) return;
|
|
@@ -1357,20 +1442,21 @@ export function MapView({
|
|
| 1357 |
setRouteOrder(null);
|
| 1358 |
askRoadRef.current = true;
|
| 1359 |
}}
|
| 1360 |
-
disabled={
|
| 1361 |
title={
|
| 1362 |
-
|
| 1363 |
? "At least two selected records need a location to plan a route"
|
| 1364 |
: "Order these stops into a route"
|
| 1365 |
}
|
| 1366 |
>
|
| 1367 |
-
Plan route ({
|
| 1368 |
</button>
|
| 1369 |
) : (
|
| 1370 |
plan && (
|
| 1371 |
<>
|
| 1372 |
<span className="cg-map-route-sum">
|
| 1373 |
-
<strong>{plan.
|
|
|
|
| 1374 |
{/* β W37-T36 β THE ROAD ANSWER REPLACES THE STRAIGHT LINE WHEN WE HAVE ONE,
|
| 1375 |
and the old caveat goes with it. The note underneath used to read
|
| 1376 |
"straight-line, not driving distance", which was the honest thing to say
|
|
@@ -1408,6 +1494,33 @@ export function MapView({
|
|
| 1408 |
</button>
|
| 1409 |
)}
|
| 1410 |
{roadErr && <span className="cg-cal-nodate">{roadErr}</span>}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1411 |
<label className="cg-map-route-opt">
|
| 1412 |
Start
|
| 1413 |
{/* β `value` is always set β a <select> without one renders its
|
|
@@ -1425,6 +1538,8 @@ export function MapView({
|
|
| 1425 |
))}
|
| 1426 |
</select>
|
| 1427 |
</label>
|
|
|
|
|
|
|
| 1428 |
<label className="cg-map-route-opt">
|
| 1429 |
<input
|
| 1430 |
type="checkbox"
|
|
@@ -1444,7 +1559,9 @@ export function MapView({
|
|
| 1444 |
{/* The free URL takes 9 waypoints on desktop and 3 on a
|
| 1445 |
phone. When the route is longer, SAY which part rides. */}
|
| 1446 |
{plan.link.used < plan.ordered.length &&
|
| 1447 |
-
|
|
|
|
|
|
|
| 1448 |
</a>
|
| 1449 |
)}
|
| 1450 |
{/* β W37-T36 β THE ORDER IS CHANGEABLE, and it has to be more than the Start
|
|
@@ -1455,12 +1572,15 @@ export function MapView({
|
|
| 1455 |
<span className="cg-map-stoplist">
|
| 1456 |
{plan.ordered.map((p, i) => (
|
| 1457 |
<span className="cg-map-stopitem" key={p.pid}>
|
| 1458 |
-
<span className="cg-map-stopn">
|
|
|
|
|
|
|
| 1459 |
<span className="cg-map-stopname">{p.title || `#${p.pid}`}</span>
|
|
|
|
| 1460 |
<button
|
| 1461 |
type="button"
|
| 1462 |
className="cg-map-stopmove"
|
| 1463 |
-
disabled={i === 0}
|
| 1464 |
aria-label={`Move ${p.title || `#${p.pid}`} earlier`}
|
| 1465 |
title="Visit this stop earlier"
|
| 1466 |
onClick={() => moveStop(p.pid, -1)}
|
|
@@ -1477,6 +1597,7 @@ export function MapView({
|
|
| 1477 |
>
|
| 1478 |
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M5.5 3.5 10.5 8l-5 4.5" /></svg>
|
| 1479 |
</button>
|
|
|
|
| 1480 |
</span>
|
| 1481 |
))}
|
| 1482 |
</span>
|
|
@@ -1504,7 +1625,12 @@ export function MapView({
|
|
| 1504 |
value={saveTarget}
|
| 1505 |
aria-label="Which visit order column to write"
|
| 1506 |
onChange={(e) => {
|
| 1507 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1508 |
setSaveMsg(null);
|
| 1509 |
setSaveErr(null);
|
| 1510 |
}}
|
|
@@ -1537,7 +1663,8 @@ export function MapView({
|
|
| 1537 |
<button
|
| 1538 |
type="button"
|
| 1539 |
className="cg-map-route-go"
|
| 1540 |
-
disabled={saveBusy || (!
|
|
|
|
| 1541 |
onClick={() => void saveRouteOrder()}
|
| 1542 |
title={
|
| 1543 |
saveTarget
|
|
@@ -1827,6 +1954,7 @@ export function MapView({
|
|
| 1827 |
picker's job, where it is visible and reversible. */}
|
| 1828 |
{plan &&
|
| 1829 |
plan.ordered.map((p, i) => {
|
|
|
|
| 1830 |
const s = toScreen(p.p, view);
|
| 1831 |
return (
|
| 1832 |
<g
|
|
@@ -1835,7 +1963,7 @@ export function MapView({
|
|
| 1835 |
transform={`translate(${s.x.toFixed(2)} ${(s.y - 13).toFixed(2)})`}
|
| 1836 |
>
|
| 1837 |
<circle r={7.5} />
|
| 1838 |
-
<text textAnchor="middle" dy="3.4">{i + 1}</text>
|
| 1839 |
</g>
|
| 1840 |
);
|
| 1841 |
})}
|
|
|
|
| 144 |
const R_MAX = 15;
|
| 145 |
const R_PLAIN = 4.5;
|
| 146 |
const R_NULL = 2.6;
|
| 147 |
+
/** A synthetic route-only id. Odoo record ids are positive, so zero cannot become a customer. */
|
| 148 |
+
const DEPOT_PID = 0;
|
| 149 |
+
|
| 150 |
+
interface RouteDepot {
|
| 151 |
+
address: string;
|
| 152 |
+
lat: number;
|
| 153 |
+
lon: number;
|
| 154 |
+
}
|
| 155 |
|
| 156 |
/**
|
| 157 |
* The pin palette: C1's cycle (blue -> green -> yellow -> red) then its
|
|
|
|
| 266 |
* hands it back. Kept as pids rather than indices because the selection can change underneath it.
|
| 267 |
*/
|
| 268 |
const [routeOrder, setRouteOrder] = useState<number[] | null>(null);
|
| 269 |
+
/** The optional origin belongs to a saved visit-order field, never to a customer row. */
|
| 270 |
+
const [depotAddress, setDepotAddress] = useState("");
|
| 271 |
+
const [depot, setDepot] = useState<RouteDepot | null>(null);
|
| 272 |
/**
|
| 273 |
* W37-T36 β the ROAD answer, which is the half arithmetic cannot produce.
|
| 274 |
*
|
|
|
|
| 297 |
* must not render "no saved orders".
|
| 298 |
*/
|
| 299 |
const [routeCols, setRouteCols] = useState<
|
| 300 |
+
{ key: string; label: string; inputsHash: string; mine: boolean; depot?: RouteDepot | null }[] | null
|
| 301 |
>(null);
|
| 302 |
/** Which saved column a save writes to. `""` = a new one, named by `saveName`. */
|
| 303 |
const [saveTarget, setSaveTarget] = useState("");
|
|
|
|
| 571 |
|
| 572 |
/** Selected pins that can actually be routed, in a STABLE order (by pid) β
|
| 573 |
* a Set's iteration order must not be what decides a route. */
|
| 574 |
+
const customerRoutable = useMemo(
|
| 575 |
() =>
|
| 576 |
selectedPids.size < 2
|
| 577 |
? []
|
|
|
|
| 580 |
.sort((a, b) => a.pid - b.pid),
|
| 581 |
[points, selectedPids]
|
| 582 |
);
|
| 583 |
+
/** The depot joins BOTH index-parallel arrays below at position zero, and only there. */
|
| 584 |
+
const depotPoint = useMemo((): MapPoint | null => {
|
| 585 |
+
if (!depot || !isPlottable(depot.lat, depot.lon)) return null;
|
| 586 |
+
return {
|
| 587 |
+
pid: DEPOT_PID,
|
| 588 |
+
title: "Depot",
|
| 589 |
+
p: project(depot.lat, depot.lon),
|
| 590 |
+
lat: depot.lat,
|
| 591 |
+
lon: depot.lon,
|
| 592 |
+
colorKey: null,
|
| 593 |
+
size: null,
|
| 594 |
+
colorVal: null,
|
| 595 |
+
sizeVal: null,
|
| 596 |
+
};
|
| 597 |
+
}, [depot]);
|
| 598 |
+
const routable = useMemo(
|
| 599 |
+
() => depotPoint ? [depotPoint, ...customerRoutable] : customerRoutable,
|
| 600 |
+
[depotPoint, customerRoutable]
|
| 601 |
+
);
|
| 602 |
/** Selected records with no usable coordinate. Counted and shown, never
|
| 603 |
* folded silently into the stop total ([[no-unverifiable-aggregates]]). */
|
| 604 |
+
const unroutable = selectedPids.size - customerRoutable.length;
|
| 605 |
|
| 606 |
/** Selected records with no usable coordinate, BY NAME. W37-T36: the chip used to give a bare
|
| 607 |
* count, which tells you that something is missing and not which visit you are about to fail to
|
|
|
|
| 611 |
// β Read from `rows` and NOT from `points`: a record with no coordinate never becomes a point
|
| 612 |
// in the first place, so filtering the pin list for unplottable pins finds exactly nothing.
|
| 613 |
// That mistake would have shipped a chip whose count said 4 and whose names said none.
|
| 614 |
+
const ok = new Set(customerRoutable.map((p) => p.pid));
|
| 615 |
return rows
|
| 616 |
.filter((r) => selectedPids.has(r.pid) && !ok.has(r.pid))
|
| 617 |
.map((r) => String(r[field.key] ?? "").trim() || `#${r.pid}`);
|
| 618 |
+
}, [rows, field.key, selectedPids, customerRoutable]);
|
| 619 |
|
| 620 |
const plan = useMemo(() => {
|
| 621 |
+
if (!routeOn || customerRoutable.length < 2) return null;
|
| 622 |
const stops: GeoStop[] = routable.map((p) => ({ lat: p.lat, lon: p.lon }));
|
| 623 |
// Default origin: the WESTERNMOST stop. Deterministic, stable while the user
|
| 624 |
// pans, and sayable out loud β unlike "whatever ended up at index 0". The
|
| 625 |
// picker below overrides it.
|
| 626 |
let start = 0;
|
| 627 |
+
if (!depotPoint) {
|
| 628 |
+
for (let i = 1; i < routable.length; i++)
|
| 629 |
+
if (routable[i].lon < routable[start].lon) start = i;
|
| 630 |
+
if (routeStartPid != null) {
|
| 631 |
+
const i = routable.findIndex((p) => p.pid === routeStartPid);
|
| 632 |
+
if (i >= 0) start = i;
|
| 633 |
+
}
|
| 634 |
}
|
| 635 |
const { order, km } = planRoute(stops, haversineKm, { start, roundTrip });
|
| 636 |
// ββ W38-T20 β THE HAND-REORDER STAYS IN **INDEX** SPACE, and that is a deliberate change of
|
|
|
|
| 648 |
// selected. Anything the user has since deselected drops out; anything newly selected is
|
| 649 |
// appended in the planner's own order rather than being silently left off the route.
|
| 650 |
if (routeOrder) {
|
| 651 |
+
const idxByPid = new Map(
|
| 652 |
+
routable.filter((p) => p.pid !== DEPOT_PID).map((p, i) => [p.pid, depotPoint ? i + 1 : i])
|
| 653 |
+
);
|
| 654 |
const picked = routeOrder
|
| 655 |
.map((pid) => idxByPid.get(pid))
|
| 656 |
.filter((i) => i !== undefined) as number[];
|
| 657 |
const seen = new Set(picked);
|
| 658 |
+
seq = [
|
| 659 |
+
...(depotPoint ? [0] : []),
|
| 660 |
+
...picked,
|
| 661 |
+
...order.filter((i) => !seen.has(i) && (!depotPoint || i !== 0)),
|
| 662 |
+
];
|
| 663 |
}
|
| 664 |
const ordered = seq.map((i) => routable[i]);
|
| 665 |
// ββ THE PERMUTATION IS NOT THE RANK (A25 / A44). `seq[i]` is WHICH STOP is visited i-th;
|
|
|
|
| 674 |
/** `{pid: visit number}` β what the route-order COLUMN stores, one integer per record. */
|
| 675 |
ranksByPid: routable.reduce((acc, p, i) => {
|
| 676 |
const n = ranks[i];
|
| 677 |
+
if (p.pid !== DEPOT_PID && n != null) acc[p.pid] = depotPoint ? n - 1 : n;
|
| 678 |
return acc;
|
| 679 |
}, {} as Record<number, number>),
|
| 680 |
/**
|
|
|
|
| 688 |
// default applies, so keying on it would report STALE the moment somebody explicitly
|
| 689 |
// selects the stop that was ALREADY the origin β the numbers unchanged, the marker on.
|
| 690 |
// What the answer depends on is which stop the planner was actually given.
|
| 691 |
+
{ roundTrip, startPid: depotPoint ? null : routable[start].pid, handOrder: routeOrder }
|
| 692 |
),
|
| 693 |
/** The origin the planner was given, so the saved column records the same one the
|
| 694 |
* fingerprint was taken over. */
|
| 695 |
+
startPid: depotPoint ? null : routable[start].pid,
|
| 696 |
link: googleRouteUrl(
|
| 697 |
ordered.map((p) => ({ lat: p.lat, lon: p.lon })),
|
| 698 |
{ roundTrip, coarsePointer }
|
| 699 |
),
|
| 700 |
/** The identity of THIS set of stops in THIS order. The road answer is only about the plan
|
| 701 |
* whose key it carries; anything else on screen makes it stale rather than wrong. */
|
| 702 |
+
key: ordered.map((p) => `${p.pid}@${p.lat.toFixed(6)},${p.lon.toFixed(6)}`).join(",") +
|
| 703 |
+
(roundTrip ? "|rt" : ""),
|
| 704 |
+
depot: depotPoint ? depot : null,
|
| 705 |
+
customerStops: customerRoutable.length,
|
| 706 |
};
|
| 707 |
+
}, [routeOn, routable, customerRoutable.length, depotPoint, depot, roundTrip, routeStartPid,
|
| 708 |
+
coarsePointer, routeOrder]);
|
| 709 |
|
| 710 |
const roadStale = !!road && !!plan && road.key !== plan.key;
|
| 711 |
|
|
|
|
| 832 |
inputsHash: plan.fingerprint,
|
| 833 |
roundTrip,
|
| 834 |
startPid: plan.startPid,
|
| 835 |
+
depot: plan.depot,
|
| 836 |
}),
|
| 837 |
});
|
| 838 |
const body = await res.json().catch(() => null);
|
|
|
|
| 959 |
}
|
| 960 |
}, [coordField, onGeocoded, geocodable, geoBatch]);
|
| 961 |
|
| 962 |
+
/** The depot has no row, so it uses the address-list form of the same bounded lookup and wait. */
|
| 963 |
+
const runDepotGeocode = useCallback(async () => {
|
| 964 |
+
const address = depotAddress.trim();
|
| 965 |
+
if (!address) return;
|
| 966 |
+
setGeo({ busy: true, done: 0, total: 1, found: 0, error: null });
|
| 967 |
+
setSaveErr(null);
|
| 968 |
+
try {
|
| 969 |
+
const res = await fetch("/api/v1/geo/geocode", {
|
| 970 |
+
method: "POST",
|
| 971 |
+
credentials: "same-origin",
|
| 972 |
+
headers: { "Content-Type": "application/json" },
|
| 973 |
+
body: JSON.stringify({
|
| 974 |
+
addresses: [{ key: "depot", address }],
|
| 975 |
+
country: coordField?.geocode?.country,
|
| 976 |
+
}),
|
| 977 |
+
});
|
| 978 |
+
const body = await res.json().catch(() => null);
|
| 979 |
+
if (!res.ok) {
|
| 980 |
+
setGeo((g) => ({
|
| 981 |
+
...g,
|
| 982 |
+
busy: false,
|
| 983 |
+
error: (body && body.detail && body.detail.error && body.detail.error.message) ||
|
| 984 |
+
"The depot address lookup did not answer.",
|
| 985 |
+
}));
|
| 986 |
+
return;
|
| 987 |
+
}
|
| 988 |
+
const result = (body?.results || []).find(
|
| 989 |
+
(r: { key?: string; found?: boolean; lat?: number; lon?: number }) => r?.key === "depot"
|
| 990 |
+
);
|
| 991 |
+
if (result?.found && isPlottable(Number(result.lat), Number(result.lon))) {
|
| 992 |
+
setDepot({ address, lat: Number(result.lat), lon: Number(result.lon) });
|
| 993 |
+
setGeo({ busy: false, done: 1, total: 1, found: 1, error: null });
|
| 994 |
+
} else {
|
| 995 |
+
setDepot(null);
|
| 996 |
+
setGeo({ busy: false, done: 1, total: 1, found: 0, error: null });
|
| 997 |
+
setSaveErr("The depot address could not be located. Check it and try again.");
|
| 998 |
+
}
|
| 999 |
+
} catch {
|
| 1000 |
+
setGeo((g) => ({ ...g, busy: false, error: "The depot address lookup could not be reached." }));
|
| 1001 |
+
}
|
| 1002 |
+
}, [depotAddress, coordField]);
|
| 1003 |
+
|
| 1004 |
const moveStop = useCallback(
|
| 1005 |
(pid: number, delta: number) => {
|
| 1006 |
+
if (!plan || pid === DEPOT_PID) return;
|
| 1007 |
+
const ids = plan.ordered.filter((p) => p.pid !== DEPOT_PID).map((p) => p.pid);
|
| 1008 |
const at = ids.indexOf(pid);
|
| 1009 |
const to = at + delta;
|
| 1010 |
if (at < 0 || to < 0 || to >= ids.length) return;
|
|
|
|
| 1442 |
setRouteOrder(null);
|
| 1443 |
askRoadRef.current = true;
|
| 1444 |
}}
|
| 1445 |
+
disabled={customerRoutable.length < 2}
|
| 1446 |
title={
|
| 1447 |
+
customerRoutable.length < 2
|
| 1448 |
? "At least two selected records need a location to plan a route"
|
| 1449 |
: "Order these stops into a route"
|
| 1450 |
}
|
| 1451 |
>
|
| 1452 |
+
Plan route ({customerRoutable.length.toLocaleString()} stops)
|
| 1453 |
</button>
|
| 1454 |
) : (
|
| 1455 |
plan && (
|
| 1456 |
<>
|
| 1457 |
<span className="cg-map-route-sum">
|
| 1458 |
+
<strong>{plan.customerStops.toLocaleString()}</strong> visits
|
| 1459 |
+
{plan.depot ? " + depot" : ""} Β·{" "}
|
| 1460 |
{/* β W37-T36 β THE ROAD ANSWER REPLACES THE STRAIGHT LINE WHEN WE HAVE ONE,
|
| 1461 |
and the old caveat goes with it. The note underneath used to read
|
| 1462 |
"straight-line, not driving distance", which was the honest thing to say
|
|
|
|
| 1494 |
</button>
|
| 1495 |
)}
|
| 1496 |
{roadErr && <span className="cg-cal-nodate">{roadErr}</span>}
|
| 1497 |
+
<label className="cg-map-route-opt">
|
| 1498 |
+
Depot address
|
| 1499 |
+
<input
|
| 1500 |
+
type="text"
|
| 1501 |
+
className="cg-map-route-start"
|
| 1502 |
+
value={depotAddress}
|
| 1503 |
+
placeholder="Optional route start"
|
| 1504 |
+
aria-label="Depot address for this visit order"
|
| 1505 |
+
maxLength={200}
|
| 1506 |
+
onChange={(e) => {
|
| 1507 |
+
setDepotAddress(e.target.value);
|
| 1508 |
+
setDepot(null);
|
| 1509 |
+
setRouteStartPid(null);
|
| 1510 |
+
}}
|
| 1511 |
+
/>
|
| 1512 |
+
</label>
|
| 1513 |
+
<button
|
| 1514 |
+
type="button"
|
| 1515 |
+
className="cg-map-route-go"
|
| 1516 |
+
disabled={geo.busy || !depotAddress.trim()}
|
| 1517 |
+
onClick={() => void runDepotGeocode()}
|
| 1518 |
+
title="Look up this depot once before saving the visit order"
|
| 1519 |
+
>
|
| 1520 |
+
{geo.busy ? `Looking up depot: ${geo.done} of ${geo.total}` : "Look up depot"}
|
| 1521 |
+
</button>
|
| 1522 |
+
{depot && <span className="cg-map-route-note">Starts at the saved depot.</span>}
|
| 1523 |
+
{!plan.depot && (
|
| 1524 |
<label className="cg-map-route-opt">
|
| 1525 |
Start
|
| 1526 |
{/* β `value` is always set β a <select> without one renders its
|
|
|
|
| 1538 |
))}
|
| 1539 |
</select>
|
| 1540 |
</label>
|
| 1541 |
+
)}
|
| 1542 |
+
{plan.depot && <span className="cg-map-route-note">Start: depot</span>}
|
| 1543 |
<label className="cg-map-route-opt">
|
| 1544 |
<input
|
| 1545 |
type="checkbox"
|
|
|
|
| 1559 |
{/* The free URL takes 9 waypoints on desktop and 3 on a
|
| 1560 |
phone. When the route is longer, SAY which part rides. */}
|
| 1561 |
{plan.link.used < plan.ordered.length &&
|
| 1562 |
+
(plan.depot
|
| 1563 |
+
? ` (depot plus first ${plan.link.used - 1} of ${plan.customerStops} visits)`
|
| 1564 |
+
: ` (first ${plan.link.used} of ${plan.customerStops} visits)`)}
|
| 1565 |
</a>
|
| 1566 |
)}
|
| 1567 |
{/* β W37-T36 β THE ORDER IS CHANGEABLE, and it has to be more than the Start
|
|
|
|
| 1572 |
<span className="cg-map-stoplist">
|
| 1573 |
{plan.ordered.map((p, i) => (
|
| 1574 |
<span className="cg-map-stopitem" key={p.pid}>
|
| 1575 |
+
<span className="cg-map-stopn">
|
| 1576 |
+
{p.pid === DEPOT_PID ? "" : i + 1 - (plan.depot ? 1 : 0)}
|
| 1577 |
+
</span>
|
| 1578 |
<span className="cg-map-stopname">{p.title || `#${p.pid}`}</span>
|
| 1579 |
+
{p.pid !== DEPOT_PID && <>
|
| 1580 |
<button
|
| 1581 |
type="button"
|
| 1582 |
className="cg-map-stopmove"
|
| 1583 |
+
disabled={i === (plan.depot ? 1 : 0)}
|
| 1584 |
aria-label={`Move ${p.title || `#${p.pid}`} earlier`}
|
| 1585 |
title="Visit this stop earlier"
|
| 1586 |
onClick={() => moveStop(p.pid, -1)}
|
|
|
|
| 1597 |
>
|
| 1598 |
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M5.5 3.5 10.5 8l-5 4.5" /></svg>
|
| 1599 |
</button>
|
| 1600 |
+
</>}
|
| 1601 |
</span>
|
| 1602 |
))}
|
| 1603 |
</span>
|
|
|
|
| 1625 |
value={saveTarget}
|
| 1626 |
aria-label="Which visit order column to write"
|
| 1627 |
onChange={(e) => {
|
| 1628 |
+
const next = e.target.value;
|
| 1629 |
+
const configured = (routeCols || []).find((f) => f.key === next)?.depot || null;
|
| 1630 |
+
setSaveTarget(next);
|
| 1631 |
+
setDepot(configured);
|
| 1632 |
+
setDepotAddress(configured?.address || "");
|
| 1633 |
+
setRouteStartPid(null);
|
| 1634 |
setSaveMsg(null);
|
| 1635 |
setSaveErr(null);
|
| 1636 |
}}
|
|
|
|
| 1663 |
<button
|
| 1664 |
type="button"
|
| 1665 |
className="cg-map-route-go"
|
| 1666 |
+
disabled={saveBusy || (!!depotAddress.trim() && !plan.depot) ||
|
| 1667 |
+
(!saveTarget && !saveName.trim())}
|
| 1668 |
onClick={() => void saveRouteOrder()}
|
| 1669 |
title={
|
| 1670 |
saveTarget
|
|
|
|
| 1954 |
picker's job, where it is visible and reversible. */}
|
| 1955 |
{plan &&
|
| 1956 |
plan.ordered.map((p, i) => {
|
| 1957 |
+
if (p.pid === DEPOT_PID) return null;
|
| 1958 |
const s = toScreen(p.p, view);
|
| 1959 |
return (
|
| 1960 |
<g
|
|
|
|
| 1963 |
transform={`translate(${s.x.toFixed(2)} ${(s.y - 13).toFixed(2)})`}
|
| 1964 |
>
|
| 1965 |
<circle r={7.5} />
|
| 1966 |
+
<text textAnchor="middle" dy="3.4">{i + 1 - (plan.depot ? 1 : 0)}</text>
|
| 1967 |
</g>
|
| 1968 |
);
|
| 1969 |
})}
|
web/src/shell/ShareDialog.tsx
CHANGED
|
@@ -26,10 +26,17 @@ import {
|
|
| 26 |
} from "./shareModel";
|
| 27 |
import type { ShareEntry, ShareKind, ShareRole, ShareState } from "./shareModel";
|
| 28 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
const KIND_WORD: Record<ShareKind, string> = {
|
| 30 |
view: "view",
|
| 31 |
folder: "folder",
|
| 32 |
database: "database",
|
|
|
|
| 33 |
};
|
| 34 |
|
| 35 |
/** What each role MEANS on each kind, in the reader's own terms. A role picker
|
|
@@ -49,6 +56,22 @@ const ROLE_BLURB: Record<ShareKind, Record<ShareRole, string>> = {
|
|
| 49 |
view: "Can open this database and read its records.",
|
| 50 |
edit: "Can add, edit and delete its records.",
|
| 51 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
};
|
| 53 |
|
| 54 |
export default function ShareDialog({
|
|
@@ -199,10 +222,23 @@ export default function ShareDialog({
|
|
| 199 |
`_clean_perms` 400s the rest), so the grant registry IS the only wall and the grant
|
| 200 |
is all-or-nothing. The audit's fix is "say so at BOTH doors"; `core/shares.py` and
|
| 201 |
`routes_shares.py` are the other two, and this is the one a PERSON reads. */}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
<p className="shell-newdb-sub">
|
| 203 |
-
<strong>{label}</strong>
|
| 204 |
{kind === "database"
|
| 205 |
? "Sharing a database shares all of it: every record and every column. There is no per-row or per-column limit on a database grant."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
: "Sharing never widens past this workspace: everyone here can already open the surface it lives on."}
|
| 207 |
</p>
|
| 208 |
|
|
@@ -313,7 +349,21 @@ export default function ShareDialog({
|
|
| 313 |
setEntries(next);
|
| 314 |
void save(next).then((ok) => {
|
| 315 |
if (ok) {
|
| 316 |
-
onToast(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 317 |
setPick("");
|
| 318 |
}
|
| 319 |
});
|
|
@@ -351,8 +401,13 @@ export default function ShareDialog({
|
|
| 351 |
// change this object's CONTENT still cannot change who else reaches
|
| 352 |
// it (the server's rule; this is the courtesy half). Saying why beats
|
| 353 |
// greying three controls and letting the reader guess.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 354 |
<p className="shell-share-note">
|
| 355 |
-
Only
|
| 356 |
it is shared with. You can still use it as your role allows.
|
| 357 |
</p>
|
| 358 |
)}
|
|
|
|
| 26 |
} from "./shareModel";
|
| 27 |
import type { ShareEntry, ShareKind, ShareRole, ShareState } from "./shareModel";
|
| 28 |
|
| 29 |
+
/**
|
| 30 |
+
* ββ W39-T17 β THIS MAP DOES NOT DEGRADE, IT PRINTS THE WORD `undefined`. A kind with no entry
|
| 31 |
+
* renders "Share undefined" in the heading AND "no longer has this undefined" in the revoke toast:
|
| 32 |
+
* two live strings, not one. `Record<ShareKind, string>` is what stops that being a discovery β
|
| 33 |
+
* add a member to `SHARE_KINDS` without one here and `tsc` names this line.
|
| 34 |
+
*/
|
| 35 |
const KIND_WORD: Record<ShareKind, string> = {
|
| 36 |
view: "view",
|
| 37 |
folder: "folder",
|
| 38 |
database: "database",
|
| 39 |
+
field: "field",
|
| 40 |
};
|
| 41 |
|
| 42 |
/** What each role MEANS on each kind, in the reader's own terms. A role picker
|
|
|
|
| 56 |
view: "Can open this database and read its records.",
|
| 57 |
edit: "Can add, edit and delete its records.",
|
| 58 |
},
|
| 59 |
+
/**
|
| 60 |
+
* ββ W39-T17 β A COLUMN GRANT IS A VISIBILITY GRANT, AND THE BLURBS SAY ONLY THAT.
|
| 61 |
+
*
|
| 62 |
+
* β MEASURED IN THE SERVER, NOT ASSUMED. `core.perm_scope.field_grant_hidden` is the ONLY
|
| 63 |
+
* consumer of a `field`-kind grant, and it tests `role is None` β nothing else. So `view` and
|
| 64 |
+
* `edit` are the SAME capability on a column today: both lift the column out of the hidden set
|
| 65 |
+
* and neither confers value editing, which `types.mayEditField` decides from the field's OWN
|
| 66 |
+
* `permissions.edit` (everyone / creator / admins). A blurb promising "can change its values"
|
| 67 |
+
* would be a sentence the product does not honour, so the `edit` line names the real wall
|
| 68 |
+
* instead. Booked as a finding; the picker is the shared one and narrowing it is not this
|
| 69 |
+
* ticket's mandate.
|
| 70 |
+
*/
|
| 71 |
+
field: {
|
| 72 |
+
view: "Can see this column on the records they can already open.",
|
| 73 |
+
edit: "Can see this column. Changing its values still follows the field's own permissions.",
|
| 74 |
+
},
|
| 75 |
};
|
| 76 |
|
| 77 |
export default function ShareDialog({
|
|
|
|
| 222 |
`_clean_perms` 400s the rest), so the grant registry IS the only wall and the grant
|
| 223 |
is all-or-nothing. The audit's fix is "say so at BOTH doors"; `core/shares.py` and
|
| 224 |
`routes_shares.py` are the other two, and this is the one a PERSON reads. */}
|
| 225 |
+
{/* β STANDING RULE 2 (D-490, W39-T17) β THIS LINE CARRIED AN EM DASH TO THE SCREEN AND
|
| 226 |
+
`verify_prose.py` SCORED THE FILE ZERO. The prose run WRAPS, and the gate's JSX pass
|
| 227 |
+
skips any run containing a newline [[web-prose-blind-to-wrapped-jsx]] β the same blind
|
| 228 |
+
spot already recorded twenty lines below. REWRITTEN, not stripped: the question the
|
| 229 |
+
dialog answers is now the sentence's subject, which needs no dash at all. */}
|
| 230 |
<p className="shell-newdb-sub">
|
| 231 |
+
Who can reach <strong>{label}</strong>, and what they can do with it.{" "}
|
| 232 |
{kind === "database"
|
| 233 |
? "Sharing a database shares all of it: every record and every column. There is no per-row or per-column limit on a database grant."
|
| 234 |
+
: kind === "field"
|
| 235 |
+
/* ββ W39-T17 β A COLUMN'S OWN SENTENCE, because the general one is BACKWARDS for it.
|
| 236 |
+
"Everyone here can already open the surface it lives on" is true of the DATABASE and
|
| 237 |
+
false of the column: `routes_tables.patch_shared_cell` stamps every grid-created
|
| 238 |
+
shared column `granted: True`, and `perm_scope.field_grant_hidden` then strips it
|
| 239 |
+
from every payload but its creator's and an admin's. So this door is not a widening
|
| 240 |
+
of something already visible, it is the ONLY way anybody else sees the column. */
|
| 241 |
+
? "A shared column starts out hidden from everybody else on this database. This is what lets the people you choose see it and its values."
|
| 242 |
: "Sharing never widens past this workspace: everyone here can already open the surface it lives on."}
|
| 243 |
</p>
|
| 244 |
|
|
|
|
| 349 |
setEntries(next);
|
| 350 |
void save(next).then((ok) => {
|
| 351 |
if (ok) {
|
| 352 |
+
onToast(
|
| 353 |
+
/* ββ W39-T17 β THE ROLE MUST NOT BE READ BACK AS A CAPABILITY ON A
|
| 354 |
+
COLUMN. Interpolated blind, this said "alice can now edit this
|
| 355 |
+
field", and that is false: `core.perm_scope.field_grant_hidden` is
|
| 356 |
+
the only consumer of a field grant and it tests `role is None`, so
|
| 357 |
+
both roles buy the SAME thing (the column stops being hidden) and
|
| 358 |
+
neither one lets anybody write a cell β `types.mayEditField` decides
|
| 359 |
+
that from the field's own `permissions.edit`. A toast is the one
|
| 360 |
+
sentence a person reads after granting, so it is the worst place to
|
| 361 |
+
promise a capability the product does not honour. It names the
|
| 362 |
+
visibility that actually changed instead. See `ROLE_BLURB.field`. */
|
| 363 |
+
kind === "field"
|
| 364 |
+
? `${nameOf(pick)} can now see this field.`
|
| 365 |
+
: `${nameOf(pick)} can now ${pickRole} this ${KIND_WORD[kind]}.`
|
| 366 |
+
);
|
| 367 |
setPick("");
|
| 368 |
}
|
| 369 |
});
|
|
|
|
| 401 |
// change this object's CONTENT still cannot change who else reaches
|
| 402 |
// it (the server's rule; this is the courtesy half). Saying why beats
|
| 403 |
// greying three controls and letting the reader guess.
|
| 404 |
+
// β STANDING RULE 2 (W39-T17) β TWO em dashes, live on screen, and `verify_prose`
|
| 405 |
+
// green over both for the wrapped-JSX reason recorded above. NOT a pre-existing
|
| 406 |
+
// violation this ticket may leave alone: adding `field` to `KIND_WORD` is what
|
| 407 |
+
// brings this sentence to a NEW surface, so it ships the dash rather than merely
|
| 408 |
+
// inheriting it. Rewritten with the owner in the subject, so no aside is needed.
|
| 409 |
<p className="shell-share-note">
|
| 410 |
+
Only this {KIND_WORD[kind]}'s owner and workspace administrators can change who
|
| 411 |
it is shared with. You can still use it as your role allows.
|
| 412 |
</p>
|
| 413 |
)}
|
web/src/shell/shareModel.ts
CHANGED
|
@@ -20,8 +20,22 @@
|
|
| 20 |
// dialog is a renderer over these functions and decides nothing.
|
| 21 |
// ---------------------------------------------------------------------------
|
| 22 |
|
| 23 |
-
/**
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
export type ShareKind = (typeof SHARE_KINDS)[number];
|
| 26 |
|
| 27 |
/** The two roles, the same two words the view rail already speaks (R10). */
|
|
|
|
| 20 |
// dialog is a renderer over these functions and decides nothing.
|
| 21 |
// ---------------------------------------------------------------------------
|
| 22 |
|
| 23 |
+
/**
|
| 24 |
+
* The FOUR shareable kinds. Closed vocabulary: the server 400s anything else β and
|
| 25 |
+
* `parseShareRequest` below fail-closes on a kind this tuple does not carry, so a missing member
|
| 26 |
+
* is a dialog that NEVER OPENS rather than one that opens and then 400s on save.
|
| 27 |
+
*
|
| 28 |
+
* ββ W39-T17 β `field` IS THE FOURTH, mirroring `core.shares.KINDS` (the server grew it in
|
| 29 |
+
* W38-T16; `routes_shares._owns_object` and `._object_ref` both carry the branch already).
|
| 30 |
+
*
|
| 31 |
+
* ββ A FIELD OID IS `"<table_key>:<field_key>"`, NEVER A BARE COLUMN KEY, and getting that wrong
|
| 32 |
+
* is SILENT. `core.shares.split_field_oid` fails closed to `(None, None)` on a bare key;
|
| 33 |
+
* `routes_shares._object_ref` then answers `route=None` and `_notify_new_grantees` RETURNS EARLY β
|
| 34 |
+
* so the grant lands and nobody is ever told about it. The emitter (`customer-grid/ColumnMenu.tsx`)
|
| 35 |
+
* builds the pair from the SERVER-issued `workspace.storageKey` and renders no Share row at all
|
| 36 |
+
* when it has none, because the local fallback key is one the server never issued.
|
| 37 |
+
*/
|
| 38 |
+
export const SHARE_KINDS = ["view", "folder", "database", "field"] as const;
|
| 39 |
export type ShareKind = (typeof SHARE_KINDS)[number];
|
| 40 |
|
| 41 |
/** The two roles, the same two words the view rail already speaks (R10). */
|