Skip to content

fix(sharing): key the territory rules off a flat billing_country column (#621) - #637

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-621-territory-sharing-conditions
Aug 2, 2026
Merged

fix(sharing): key the territory rules off a flat billing_country column (#621)#637
os-zhuang merged 2 commits into
mainfrom
claude/issue-621-territory-sharing-conditions

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #621

先摸清翻译器到底支持什么(issue 的第一句要求)

issue 猜测障碍是 in [...] 成员运算符,实测证明猜错了。用平台自己的
compileCelToFilter(即 plugin-sharing 播种时调用的那个函数)逐条测量,得到下面这张矩阵——
两个嫌疑对象是分开验证的:

谓词(针对 crm_account) 能否编译 编译结果
record.type == "customer" && record.is_active == true {"$and":[{"type":"customer"},{"is_active":true}]}
record.type in ["customer","partner"] {"type":{"$in":[…]}}
record.billing_country in ["US","CA","MX"] {"billing_country":{"$in":[…]}}
record.type != "customer" {"type":{"$ne":"customer"}}
record.annual_revenue > 1000 / >= $gt / $gte
a == x || b == y $or
!(record.type == "customer") $not
record.type == null {"type":{"$null":true}}
record.name.startsWith("A") $startsWith
record.billing_address.country == "US" unsupported: cross-object/nested field path … is not pushdown-able
record.billing_address.country in ["US","CA","MX"] 同上
has(record.type) && record.type == "customer" unsupported operator "call"

结论:障碍是 address 复合值上的嵌套路径,不是 in [...]address 字段整体存成一列
({street, city, state, postalCode, country, countryCode, formatted}),查询无法下推到它内部。

于是:

  • 方案 A 走不通——把 in [...] 改写成 == 的析取,在同一条嵌套路径上照样编译失败(表里已验证)。
  • 方案 B 是唯一可行路径,按 issue 的原话落地:在 crm_account 上加扁平的 billing_country,规则改为读它。
  • 方案 C 没有碰——一行都没删,按认领意见办。

改了什么

crm_account.billing_country:billing_address.country 的只读投影(去空格 + 转大写),由
account_protection 钩子在每一次携带地址的写入时重算,不携带地址的写入则完全不动它
(否则任何无关编辑都会把这一列清空、把客户悄悄踢出区域)。

只读 country 这一个槽位,故意不读 countryCode:后者装 ISO 3166-1 alpha-2,英国是 GB,
而 Europe 规则写的是 UK——优先用 ISO 槽位会让英国客户悄无声息地掉出自己的区域。因此本 PR
只改变"国家从哪一列读",不改变"哪些客户属于哪个区域"。

投影逻辑写成内联而非模块级 helper:钩子体必须能降级成 metadata-only(无自由标识符),
test/action-sandbox.test.ts 对每个注册钩子强制这一点——第一版抽成 helper 时它直接报红。

其余:4 个语系的标签 + help、账户表单 Locations 区段(管理员能直接看到区域匹配用的值)、
crm_account 上的 billing_country 索引、管理员文档(英文 + zh-Hans + zh-Hant)与销售文档、changeset。

测试(issue 点名要求的那条)

新增 test/sharing-seeding.test.ts——现有 test/sharing-coverage.test.ts 断言的是声明的形状,
所以它在 9 条规则里有 2 条什么都不做时依然全绿。新文件断言的是播种的结果,并且用平台自己的
编译器而不是正则:

  • seeds all of them — seeded + 0 skipped,复刻 seeder 的每一条跳过分支(名称/对象缺失、
    recipient 不可映射、type: 'owner'、条件不可翻译、条件不收窄任何字段);
  • 每条规则过滤的字段必须是目标对象上真实存在的扁平列(这一条比平台的 isMatchAllCriteria
    更严,只会误伤平台本来也会丢弃的规则,不会放过平台会丢弃的);
  • 每条规则授予的 position 必须真的声明过;
  • 上面那张算子矩阵,measured 而非记忆——平台若放宽嵌套路径支持,这里会红,billing_country 就可以重新考虑。

另外在 hooks-runtime-sales.test.ts 加了投影的逐形状回归(大小写、空白、缺 country、null 地址、
非对象值、非字符串 country、无关更新不清空、清空地址则清空投影、系统写入同样投影),
action-sandbox.test.ts 加了在真实 QuickJS 沙箱里跑降级后钩子体的验证——直接调 handler
证明不了降级形态还能工作。

验证

pnpm test        41 files / 935 passed | 1 skipped
pnpm typecheck   exit 0
pnpm validate    ✓ Validation passed (896ms)
pnpm lint        exit 0(1 warning / 13 suggestions,均为既有项)
pnpm hygiene     ✓ source hygiene clean
pnpm build       ✓ Skipping legacy runtime bundle (all 25 callables are body-only)
pnpm test:coverage  branches 81.22 ≥ 78 · account.hook.ts 94.11

真机复现与验证(objectstack dev --fresh -p … --seed-admin):

修复前 —

WARN [sharing-rule] skipped (missing or untranslatable CEL condition — never seeded as match-all)
  {"rule":"north_america_territory","condition":{…"record.billing_address.country in [\"US\", \"CA\", \"MX\"]"}}
WARN [sharing-rule] skipped … {"rule":"europe_territory", …}

修复后 —

INFO [sharing-rule] declared rules seeded into sys_sharing_rule {"seeded":9,"skipped":0,"total":9}

播种出来的行确实带着可下推的过滤器:

{"name":"europe_territory","object_name":"crm_account",
 "criteria_json":"{\"billing_country\":{\"$in\":[\"UK\",\"DE\",\"FR\",\"IT\",\"ES\"]}}",
 "recipient_type":"position","recipient_id":"eu_sales_team","access_level":"edit","active":true}

走真实 REST 写入路径建一条客户,billing_address.country = " de " → 读回 billing_country: "DE"
(落在上面的 criteria 内),随后一次无关的 PATCH(只改 phone)后仍是 "DE"

需要维护者知道的两件事

  1. 本轮没动 src/data/index.ts(归 Forecast seed upserts on period_label, which is no longer a unique key #613),但实测发现:种子数据里没有任何一条客户带
    billing_address
    。所以在 demo 数据集上,这两条区域规则现在虽然被正确安装、条件也能真的求值,
    却仍然匹配 0 条记录。已另行开 issue 记录,需要串到后续轮次。
  2. has() 在 sharing 条件里编译不过(unsupported operator "call")。Flow start conditions carry no has() guards — measure whether the abort-and-skip class of #630 reaches them #633 想给 9 条 sharing rule
    has() 守卫——那会让每一条被守卫的规则都变成不可翻译、从而被静默跳过,正是本 issue 的缺陷
    被放大到全部 9 条。这一点已作为断言钉在 test/sharing-seeding.test.ts 里,并已评论到 Flow start conditions carry no has() guards — measure whether the abort-and-skip class of #630 reaches them #633

本 PR 未触碰 #633 的守卫问题,也未编辑 content/docs/releases/

🤖 Generated with Claude Code

https://claude.ai/code/session_019SS7C5SXpniKeCApxgARyf


Generated by Claude Code

…mn (#621)

Both territory sharing rules were declared against
`record.billing_address.country`, a path that reaches inside the composite
`address` value. A sharing rule's condition is compiled to a pushdown-able
query filter by `compileCelToFilter`, which rejects every such path, and
`plugin-sharing` then refuses to seed the rule rather than degrade it to
match-all. The result was silent: `seeded: 7, skipped: 2, total: 9` on every
boot, with `na_sales_team` / `eu_sales_team` receiving no criteria-based
account access at all while the metadata and the admin docs said they did.

Measured, the blocker is the NESTED PATH and not the `in [...]` operator:
`in`, `==`, `!=`, ordering, `&&`, `||`, `!`, `startsWith()` and `== null` all
compile against a flat field, while `==` on the nested path fails exactly like
`in` does. Issue #621's option A (rewriting `in [...]` as a disjunction of
`==`) could therefore never have worked; only a flat column can.

`crm_account` now carries `billing_country`, a readonly projection of
`billing_address.country` (trimmed, upper-cased) maintained by
`account_protection` on every write that carries the address and left untouched
by writes that do not. Only the `country` slot is read — `countryCode` holds
ISO 3166-1 alpha-2, where the UK is `GB`, and the Europe rule is authored
against `UK`, so preferring the ISO slot would silently evict UK accounts from
their own territory. Territory membership is unchanged; only the column the
rules read from is.

`test/sharing-seeding.test.ts` asserts the seeded OUTCOME rather than the
declared shape — it compiles every declared rule with the platform's own
compiler, requires `seeded + 0 skipped`, and requires every field a rule
filters on to be a real flat column of its object. It also pins the measured
operator matrix, including that `has()` does not compile in a sharing
condition (load-bearing for #633).

Verified on a fresh boot: `{"seeded":9,"skipped":0,"total":9}`, and the seeded
`europe_territory` row carries
`criteria_json: {"billing_country":{"$in":["UK","DE","FR","IT","ES"]}}`. An
account POSTed with `billing_address.country = " de "` reads back
`billing_country: "DE"`, and an unrelated PATCH leaves it intact.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SS7C5SXpniKeCApxgARyf
@vercel

vercel Bot commented Aug 2, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
hotcrm Ignored Ignored Aug 2, 2026 3:39pm

Request Review

@github-actions github-actions Bot added ci/cd CI plumbing and the verification pipeline dependencies Dependency bumps and lockfile changes metadata Declarative metadata — schema, security posture, UI surfaces backend Server-side behaviour — hooks, flows, actions labels Aug 2, 2026
…ependency

`package-lock.json` is maintained separately from `pnpm-lock.yaml` for the
StackBlitz demo (WebContainers cannot run this repo's pnpm), and
`scripts/check-stackblitz-lock.mjs` compares its root entry against
package.json. Adding `@objectstack/formula` to devDependencies made it stale.

Regenerated with the exact command the gate prints:

  d=$(mktemp -d) && cp package.json "$d" \
    && (cd "$d" && npm install --package-lock-only --ignore-scripts) \
    && cp "$d/package-lock.json" .

The root `devDependencies` delta is exactly the intended addition. The command
also re-resolves every floating `^` range against the registry, so ten
transitive entries moved to newer patch/minor versions (@babel/parser and
@babel/types 7.29.7 to 7.29.8, @napi-rs/wasm-runtime, jose, js-yaml x3,
magicast, tinyexec, tsx). No entry was added or removed, and no @objectstack/*
package moved. That churn is inherent to the documented regeneration step —
the script's own header notes a regenerated lockfile "shifts with the npm
version", which is why it compares the root entry rather than diffing the
file — so it is left as generated rather than hand-trimmed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SS7C5SXpniKeCApxgARyf
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backend Server-side behaviour — hooks, flows, actions ci/cd CI plumbing and the verification pipeline dependencies Dependency bumps and lockfile changes metadata Declarative metadata — schema, security posture, UI surfaces

Projects

None yet

2 participants