fsanyoto commited on
Commit
79cd247
Β·
verified Β·
1 Parent(s): 4adefb7

Deploy AIOS web (React glide grid + FastAPI slice)

Browse files
RELEASES.json CHANGED
@@ -1,6 +1,12 @@
1
  {
2
- "current": "v30 (71776ca)",
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
- v30 (71776ca)
 
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
- _touch_active(user.get("username"))
 
 
 
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
- counts = _meta.refresh(rt, why)
 
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(why, _rt)
 
 
 
 
 
 
 
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 are rebuilt
944
- # AFTER the mirror they are derived from, in the same pass and in that order β€” deriving
945
- # from a mirror this loop is about to advance would publish a worklist one cycle stale
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 must
950
- # not skip a rebuild that had nothing wrong with it. Two independent failures, two
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
- _rebuild_odoo_relational("resync")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 refresh(rt, why="boot", tenant_key=None, log=print):
244
- """The one call a caller wants: open this tenant's mirror, plan, apply. -> written counts.
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
- for p in built.get("problems") or []:
 
260
  log(f"[meta_relational] PROBLEM {p}")
261
- if built.get("problems"):
262
- built = {k: v for k, v in built.items() if k != "problems"}
263
- written = apply_plan(rt, built)
264
- for key, counts in written.items():
 
 
 
 
 
 
 
 
 
 
 
265
  log(f"[meta_relational] {why}: {key:<24} {counts}")
266
- return written
 
 
 
 
 
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
- user = users.verify(uname, pw) if (uname and pw) else None
 
 
 
 
 
 
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
- users.touch_login(user["username"])
 
 
 
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
- _deps.note_active(user["username"])
 
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?, stops?}`
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
- return round(v, 2) if isinstance(v, (int, float)) and not isinstance(v, bool) else v
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
 
108
 
109
  # Types a USER may create from the column menu (owner item 7, 2026-07-26). Mirrors
 
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": "tier_prices",
391
- "label": "Tier prices",
392
- "type": "json",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
393
  "source": "odoo",
394
- "description": "Every live pricelist that prices this SKU today, as [{pricelist, unit_price}] at qty 1. Blank when no list prices it. This is the honest SET; the three Fisch/Royal columns beside it are the DECLARED subset and cannot show a price on a list the contract does not name."
395
  },
396
  {
397
- "key": "units",
398
- "label": "Units",
399
- "type": "json",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
400
  "source": "odoo",
401
- "description": "The units of measure this SKU is really sold in, as [{name, qty}] where qty is in the product's own unit. Blank means it is sold in ONE unit, not that data is missing - only 1,150 of 5,873 active SKUs (19.6%) carry a unit tier."
402
  },
403
  {
404
  "key": "rev_ytd",
 
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` opens a connection directly to apply the DDL, and any future tool can.
111
- # The guard is on the STORE INTERFACE β€” the four functions every product write crosses β€” not on
112
- # the database handle. A caller that reaches past the interface reaches past the guard.
 
 
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`'s twin β€” see `PgStore.get_projection` for why the saving does
297
- NOT transfer and why that is stated rather than implied.
298
-
299
- β›” Present because `verify_store_pg.IFACE` names it, which is the mechanism working as designed:
300
- a capability only the HF backend had would 500 on `/nav` at the cutover instead of failing here,
301
- at review, with no database attached.
 
 
 
 
 
 
 
302
  """
303
  import core.store as _hf # noqa: PLC0415 β€” lazy: _project lives with the class
304
- return _hf._project(get(name, tenant_slug=tenant_slug),
305
- frozenset(str(d) for d in drop))
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- ⚠ **The saving does NOT transfer, and pretending otherwise would be the lie.** On HF the
513
- whole tenant document is one JSON blob in memory and the cost is the 28.6 MB deep copy this
514
- skips. Here `get` is a SELECT that has already materialised the row, so projecting after the
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
- import core.store as _hf # noqa: PLC0415 β€” lazy: _project lives with the class
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", "partner_id", "team_id", "user_id", "state",
160
- "amount_untaxed", "invoice_status", "write_date"],
 
 
 
 
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, 0)` reproduces `aios_grid._round`, which is Python's round() =
199
- BANKER'S rounding. DuckDB's plain ROUND() rounds half AWAY from zero, so ROUND(1234.5)=1235
200
- while the grid shows 1234 β€” `eq 1234` would then match on screen and miss in SQL. The grid
201
- displays rounded values; filters must agree with what is on screen.
 
 
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
- return f"COALESCE(round_even(TRY_CAST({e} AS DOUBLE), 0), 0)"
 
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
- -- "-- Optional:" marker; the tail below it is psql-variable syntax). Tenant provisioning,
14
- -- jsonb/bytea round-trips and 80 concurrent FOR-UPDATE writes all proven. The CUTOVER remains
15
- -- parked behind C1e's triggers; `STORE_BACKEND` still defaults to hf.
 
 
 
 
 
 
 
 
 
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
- -- Tenant #0, always. Its slug is the one the session cookie already carries.
167
- INSERT INTO control.tenants (slug, name)
168
- VALUES ('royal-imports', 'Royal Imports')
169
- ON CONFLICT (slug) DO NOTHING;
170
- SELECT control.provision_tenant_schema('royal-imports');
171
-
172
- -- Optional: `psql -v slug=<a-slug>` provisions one more tenant schema in the same transaction.
173
- -- `:'slug'` is unset in the plain invocation, so this block is skipped there.
 
 
 
 
 
 
 
 
 
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 = [render_scope(s["scope_sql"].strip())]
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 columns from.
 
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"]), []).append(k)
 
1264
  if len(groups) > 1:
1265
  merged = {}
1266
- for (src_, dim_), ks in groups.items():
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
- sig = (mkey, None if w is None else tuple(sorted(w.items())))
 
 
 
 
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, RI+FFS scope)
 
 
 
 
 
 
 
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
- packs, _pack_report = products.packagings_by_code(_prods)
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
- row["tier_prices"] = json.dumps(_tiers, ensure_ascii=False) if _tiers else None
512
- _units = packs.get(code)
513
- row["units"] = json.dumps(_units, ensure_ascii=False) if _units else None
 
 
 
 
 
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
- sku_rev = products._sku_rev(yf, yt, team_id) or {}
 
 
 
 
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
- """⭐⭐ W37-T14 / T15 β€” the `Tier prices` and `Units` cells, against a FRESH Odoo read.
1195
 
1196
- β›” THE ORACLE IS ASKED PER SKU, not in bulk, and deliberately so: the builders group a
1197
- bulk read in Python, so re-running the same bulk read would re-run the same grouping and
1198
- could only ever agree with itself. Asking Odoo for ONE SKU's price rules is a different
1199
- question shape and can actually disagree ([[no-unverifiable-aggregates]]).
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("tier_prices")]
1207
- united = [r for r in rows if r.get("units")]
1208
  checks.append({
1209
- "check": "the product grid serves a Tier-prices cell (W37-T14) and a Units cell (T15); "
1210
- "a DECLARED column that is never filled is the defect these replace",
1211
- "ours": {"with_tier_prices": len(priced), "with_units": len(united), "rows": len(rows)},
1212
- "theirs": ">0 each",
1213
- # ⚠ Units are legitimately sparse (19.6% measured), so the floor is existence, not a rate.
1214
- "ok": bool(priced) and bool(united),
1215
- "detail": {"multi_price_skus": sum(1 for r in priced
1216
- if len(json.loads(r["tier_prices"])) > 1)},
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 MORE THAN ONE price β€” the ticket's own subject.
1225
- cand = sorted(priced, key=lambda r: -len(json.loads(r["tier_prices"])))[:sample]
 
1226
  bad = []
1227
  for r in cand:
1228
- mine = sorted((t["pricelist"], round(float(t["unit_price"]), 2))
1229
- for t in json.loads(r["tier_prices"]))
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", "fixed_price", "min_quantity", "applied_on"])
1242
- best = {}
 
1243
  for it in live:
1244
- nm = pls.get(O.m2o_id(it.get("pricelist_id")), "?")
1245
- q = it.get("min_quantity") or 0.0
1246
- if nm not in best or q < best[nm][0]:
1247
- best[nm] = (q, round(it.get("fixed_price") or 0.0, 2))
1248
- theirs = sorted((nm, v) for nm, (q, v) in best.items())
 
 
 
 
 
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 Tier prices contain every live Odoo price for it "
1255
- f"({len(cand)} SKUs, chosen for having the MOST prices)",
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
- g = o.read_group('sale.order.line', O.sale_line_domain(DF, DT),
 
 
 
 
 
 
 
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') AND o.team_id IN (5,6) "
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), ['id', 'write_date'])}
 
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), RI+FFS scope, excluded accounts removed β€” reusing sale_line_domain.
 
 
 
 
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
- ⚠ `all_channels=True` keeps the excluded house accounts (GIFTWARE DEALS, the Amazon channel)
47
- IN the read. Only the replenishment caller wants that; see `sale_line_domain`'s own note."""
 
 
 
 
 
 
 
 
 
 
 
 
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 pricelist that actually prices this SKU today.
 
 
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
- ⚠ THE BASE TIER, at qty 1, for the same reason `pricelist_by_code` gives: a catalogue cell has
604
- no quantity in hand. Measured: only 10 of 10,468 items carry a quantity break at all, so this
605
- is very nearly the whole story rather than a simplification.
606
- ⚠ CARDINALITY 1..3 TODAY, mode 3 β€” and `Royal 2` carries 2,605 price rows against **0 customers
607
- and 0 orders**, so a SKU reading "3 tiers" is catalogue-true and commercially misleading. The
608
- entry keeps the list NAME so a reader can see which tier it is rather than a bare count.
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
- base = min(cands, key=lambda r: r.get('min_quantity') or 0.0)
661
- tiers.append({"pricelist": name, "unit_price": round(base.get('fixed_price') or 0.0, 2)})
 
 
 
 
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
- if (t["pricelist"], t["unit_price"]) not in seen:
 
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', O.sale_line_domain(date_from, date_to, team_id, extra=_NO_SVC),
 
 
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
- return O.sale_line_domain(date_from, date_to, team_id, extra=[('product_id', 'in', pids)] + _NO_SVC)
 
 
 
 
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
- sku = _sku_rev(yf, yt, team_id)
 
 
 
 
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), 'price_subtotal')
 
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), 'price_subtotal')
 
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 getCellContent = useGetCellContent(
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
- visibleCols.length,
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={visibleCols}
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={onColumnResize}
6867
- onColumnMoved={onColumnMoved}
6868
- onColumnProposeMove={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 routable = useMemo(
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 - routable.length;
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(routable.map((p) => p.pid));
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, routable]);
589
 
590
  const plan = useMemo(() => {
591
- if (!routeOn || routable.length < 2) return null;
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
- for (let i = 1; i < routable.length; i++)
598
- if (routable[i].lon < routable[start].lon) start = i;
599
- if (routeStartPid != null) {
600
- const i = routable.findIndex((p) => p.pid === routeStartPid);
601
- if (i >= 0) start = i;
 
 
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(routable.map((p, i) => [p.pid, i]));
 
 
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 = [...picked, ...order.filter((i) => !seen.has(i))];
 
 
 
 
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(",") + (roundTrip ? "|rt" : ""),
 
 
 
665
  };
666
- }, [routeOn, routable, roundTrip, routeStartPid, coarsePointer, routeOrder]);
 
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={routable.length < 2}
1361
  title={
1362
- routable.length < 2
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 ({routable.length.toLocaleString()} stops)
1368
  </button>
1369
  ) : (
1370
  plan && (
1371
  <>
1372
  <span className="cg-map-route-sum">
1373
- <strong>{plan.ordered.length.toLocaleString()}</strong> stops Β·{" "}
 
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
- ` (first ${plan.link.used} of ${plan.ordered.length})`}
 
 
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">{i + 1}</span>
 
 
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
- setSaveTarget(e.target.value);
 
 
 
 
 
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 || (!saveTarget && !saveName.trim())}
 
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> β€” who can reach it, and what they can do with it.{" "}
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(`${nameOf(pick)} can now ${pickRole} this ${KIND_WORD[kind]}.`);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 the owner of this {KIND_WORD[kind]} β€” or an administrator β€” can change who
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
- /** The three shareable kinds. Closed vocabulary: the server 400s anything else. */
24
- export const SHARE_KINDS = ["view", "folder", "database"] as const;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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). */