diff --git a/.changeset/acceptance-17-rc2-test-plan.md b/.changeset/acceptance-17-rc2-test-plan.md new file mode 100644 index 00000000..80600355 --- /dev/null +++ b/.changeset/acceptance-17-rc2-test-plan.md @@ -0,0 +1,4 @@ +--- +--- + +docs: revised 17.0 GA acceptance test plan for rc.2 (docs-only, no package changes) diff --git a/docs/acceptance-17/HANDOFF.md b/docs/acceptance-17/HANDOFF.md new file mode 100644 index 00000000..ba9d4c3c --- /dev/null +++ b/docs/acceptance-17/HANDOFF.md @@ -0,0 +1,120 @@ +# rc.2 验收 —— 交接说明 + +> ✅ **本轮验收已全部完成(8/8 节)。** 本页最初为中途交接而写,现保留作为"如何重跑/续验" +> 的操作指南与踩坑清单。结论看 [test-report.md](./test-report.md);GA 复归清单看其 §11。 +> **一句话结论:当前状态不建议 GA 放行** —— 权限模型默认开放(#703/#704 p0)、线索 UI 无法 +> 创建(#688 p0)、所有权/写模型破碎(#548/#700/#705)、系统驱动自动化全断含审批绕过(#684)。 + +给接手复验或修复的下一位(或下一个会话)。**先读这一页,再读别的。** + +- **PR**:[#676](https://github.com/objectstack-ai/hotcrm/pull/676)(draft),分支 + `claude/hotcrm-17-rc1-acceptance-hj869o`,基于 `main` @ 5a78f88 +- **被测版本**:`@objectstack/*` **17.0.0-rc.2**(任务原文说 rc.1,但 main 已由 #663 升到 rc.2) +- **纪律**:**只测不修** —— 不改 `src/`,发现问题建 issue。上一轮 `src/` 零改动,请保持 + +## 一、这一页之外该读什么 + +| 文件 | 用途 | +|---|---| +| [test-plan.md](./test-plan.md) | 验收计划(8 节),已按 rc.2 修订 | +| [test-report.md](./test-report.md) | 报告。**§10 列出未执行项,§11 是 GA 最小回归清单** | +| [parallel-task-packages.md](./parallel-task-packages.md) | 剩余工作的 P1–P5 任务包,**直接照着投** | +| [evidence/](./evidence/) | 上一轮的执行记录与准备资产(见下) | + +## 二、进度:8/8 节全部完成 + +| 计划节 | 状态 | +|---|---| +| 0 自动化基线 | ✅ verify 1280 例、e2e 16/16、冷启动 0 ERROR | +| 1 对象列表/详情 | ✅ 15/17 通过(2 项曾因无种子阻塞,W1 自建记录后补测通过) | +| 2 对象 CRUD | ✅ REST 17/17 全量通过 | +| 3 actions 全量(26 个) | ✅ 25 通过 / 1 失败(#508) | +| 4 仪表盘/报表 | ✅ 5 仪表盘 56 widget、10 报表全部出数 | +| 5 flows 全量(24 条) | ✅ 24/24 通过;#684 判定:用户路径健康/系统路径全断 | +| 6 profile 权限矩阵 | ✅ 188 探针,**矩阵不成立**,5 平台根因(#703-707) | +| 7 i18n 四语言 | ✅ zh-CN 完整,ja/es 有实质缺口 | +| 8 阻塞 issue 复测 | ✅ 10/10:9 解除 / 1 仍复现(#508) | + +**若要复验或修复后回归**,按 [parallel-task-packages.md](./parallel-task-packages.md) 的 +P1–P5 包重跑对应部分即可;每包自带记录前缀与隔离约定。 + +## 三、上手三步 + +```bash +cd /home/user/hotcrm # 若是新容器,先 clone 并 checkout 上述分支 +pnpm install +pnpm dev # 端口 4001,等 /api/v1/health 返回 200(约 30s) +``` + +鉴权(**这一条能省你半小时**): + +```bash +TOKEN=$(curl -s -X POST http://localhost:4001/api/v1/auth/sign-in/email \ + -H 'Content-Type: application/json' \ + -d '{"email":"admin@objectos.ai","password":"admin123"}' | node -pe 'JSON.parse(require("fs").readFileSync(0)).token') +``` + +然后照 [parallel-task-packages.md](./parallel-task-packages.md) 投 **P2 → P3 → P4 → P5**。 +写数据的包之间要么串行,要么各用各的前缀(P2 用 `RC1ACC-P2-`,依此类推)。 + +## 四、踩过的坑,别再踩一遍 + +1. **鉴权端点是 `/api/v1/auth/sign-in/email`**。直觉的 `/api/v1/auth/login` 返回 **500** + 而不是 404,极易误判成"服务挂了"。 +2. **三个入口默认三个不同的 SQLite 库**:`objectstack dev` → `dev.db`; + `objectstack start` → `objectstack.db`;migrate CLI → `standalone.db`。 + 所以 `os migrate plan` **默认根本没看 dev server 的库**;复测 #528 必须显式传 + `--database-url file:/home/user/hotcrm/.objectstack/data/dev.db`。 + 另:`migrate plan` 自称 dry-run,却会**新建** `standalone.db` 文件。 +3. **控制台 SPA 首次导航要等 8–10 秒**。等太短会读到"列表页没有 New 按钮"这类假象 —— + 上一轮踩过,是测试假象不是缺陷。 +4. **看板拖拽必须用真实鼠标 API**(`mouse.down` + 多步插值 `move` + `up`); + 合成 `dispatchEvent` 不触发 dnd 库。用真实 API 是**可以**自动化验证的 + (rc.0 报告说"不可自动化",已被本轮推翻)。 +5. **行项目 rollup 是 async**,别在写入的同一 tick 断言父记录金额。 +6. **表单弹窗是 `[role="dialog"]`,lookup 选择器会再开一层**,要 `.last()`。 +7. **删除测试数据要按子→父顺序**,而且:报名过市场活动的线索删不掉(#696), + 得先删 campaign member。 +8. **别做这三件事**:重启/重建 dev server(会毁掉别人的在途数据)、`pnpm demo:reset`、 + `os migrate apply`。#526 的验证要另起独立端口 + 独立库。 + +## 五、必须先看的两条判定 + +- **#688(p0)** console 表单无视 `visibleOn` 却强制其 `required` —— + **`crm_lead` 在界面上完全无法创建**。这是应用的头号演示路径,GA 前必须修。 +- **#684** 记录变更流缺 `runAs: 'system'`,系统驱动的写入下自动化被拒。 + **P3 包的关键任务**:验证**用户会话驱动**的写入路径是否正常 —— 这决定 #684 是 + "系统写入下自动化失效"还是更严重。结论请回填到 #684 评论。 + +## 六、一条悬而未决、**故意没立案**的观察 + +admin 删除部分记录返回 **403 FORBIDDEN "insufficient privileges to delete"**, +尤其是活动类 action 创建的 `crm_event`(而 W1 用 REST 自建的 event 删除是 200, +差别在创建路径)。 + +**没立案的原因**:它很可能是既有 **#548**(app `owner` lookup 与平台 `owner_id` 不一致) +的症状,未做隔离验证前立新案有重复风险。**做 P4 时请优先验证这一条**, +然后要么回填 #548,要么据此另立新案。 + +## 七、evidence/ 里有什么 + +| 文件 | 内容 | +|---|---| +| `retest-playbook.md` | 10 条阻塞 issue 的可执行复现手册。**注意:它的 auth 章节是错的**(猜了 `/auth/login`),用本页第三节的端点 | +| `permission-matrix.md` | 5 profile × 17 对象的权限期望矩阵 + 测试用户创建方式,**P4 的输入** | +| `r1-objects.md` | 对象列表/详情逐项结果 | +| `r2-analytics.md` | 仪表盘 widget 级 + 报表逐项结果,含 #520/#523 的判定依据 | +| `r3-i18n.md` | 四语言矩阵、lint 量化、locale 切换机制 | +| `w1-crud.md` | 17 对象 CRUD 矩阵 + C1–C6 定向检查全文 | +| `w1-ledger.md` | W1 的数据台账(已全部清理,留作格式范例) | + +**截图没有入库**:约 200 张、33MB,留在上一轮容器的 `/tmp` 里,**已随容器回收丢失**。 +报告与 issue 里按文件名引用它们,那些引用现在只表示"当时拍过",无法再打开。 +需要视觉证据的判定(尤其 #688)请在你的环境重拍。 + +## 八、上一轮结束时的环境状态 + +- 上一轮在 W2(actions)开始不久因额度耗尽终止,W2 留下的测试记录已尽力清理, + 仍有少量 `RC1ACC-W2-` 残留(6 event / 2 case / 2 opportunity / 4 contact / 5 lead / 1 account)。 + 那个库是容器内临时 SQLite,**已随容器回收**,你会拿到全新种子库。 +- PR #676 的订阅已退订(避免 Vercel bot 反复唤醒会话消耗额度)。你若要盯 CI,自行重新订阅。 diff --git a/docs/acceptance-17/evidence/README.md b/docs/acceptance-17/evidence/README.md new file mode 100644 index 00000000..e8c93d12 --- /dev/null +++ b/docs/acceptance-17/evidence/README.md @@ -0,0 +1,35 @@ +# evidence/ —— rc.2 验收的执行记录与准备资产 + +本目录是 2026-08-05 那轮验收的原始产物,供复核判定、或接手时直接复用。 +入口页是 [../HANDOFF.md](../HANDOFF.md),结论页是 [../test-report.md](../test-report.md)。 + +## 准备资产(下一轮可直接用) + +| 文件 | 内容 | 用途 | +|---|---|---| +| `retest-playbook.md` | 10 条阻塞 issue 的可执行复现手册,逐条给出 REST 命令、FIXED/仍复现 的判据、上游 issue 关联、是否写数据 | P2/P5 包的输入。**其 auth 章节有误** —— 它猜测端点是 `/auth/login`,实际是 `/api/v1/auth/sign-in/email` | +| `permission-matrix.md` | 5 profile × 17 对象的声明期望矩阵、字段级权限、sharing 规则、可 API 校验的断言清单、测试用户创建方式 | P4 包的输入 | + +## 执行记录 + +| 文件 | 执行者 | 范围 | +|---|---|---| +| `r1-objects.md` | R1(只读) | 14 个带视图对象的列表/详情 + 3 个子对象经父相关列表 | +| `r2-analytics.md` | R2(只读) | 5 仪表盘(widget 级)+ 10 报表;含 #520 与 #523 的解除判定依据 | +| `r3-i18n.md` | R3(只读) | en / zh-CN / ja-JP / es-ES 四语言矩阵、lint 量化、locale 切换机制 | +| `w1-crud.md` | W1(写) | 17 对象全量 CRUD 矩阵 + C1–C6 定向检查 + 6 条新发现的完整论证 | +| `w1-ledger.md` | W1(写) | 数据台账。记录已全部清理,保留作为台账格式范例 | + +W2(actions + 阻塞复测)在开始不久后因额度耗尽被终止,无有效结论,故无记录文件。 + +## 关于截图 + +四位执行者共产出约 **200 张截图(33MB)**,**未入库**,留在当时容器的 `/tmp` scratchpad 中, +**已随容器回收而丢失**。 + +上述记录文件、[../test-report.md](../test-report.md) 以及本轮所立的 issue 中, +仍按文件名引用这些截图(如 `shots/uif-crm_lead-02-after-submit.png`)。 +这些引用现在只说明"当时确实拍过该证据",**无法再打开**。 + +需要视觉证据的判定 —— 尤其 **#688**(`crm_lead` UI 无法创建)—— 请在接手环境重新截取。 +纯 REST 判定不受影响:状态码与响应体都完整写在记录里。 diff --git a/docs/acceptance-17/evidence/permission-matrix.md b/docs/acceptance-17/evidence/permission-matrix.md new file mode 100644 index 00000000..1fa285bf --- /dev/null +++ b/docs/acceptance-17/evidence/permission-matrix.md @@ -0,0 +1,212 @@ +# HotCRM 17.0 rc.2 — Declared Permission / Sharing Matrix (for acceptance testing) + +Sources (all read on branch `claude/hotcrm-17-rc1-acceptance-hj869o`): + +- `/home/user/hotcrm/src/profiles/sales-rep.profile.ts`, `sales-manager.profile.ts`, `service-agent.profile.ts`, `marketing-user.profile.ts`, `system-admin.profile.ts` (also `guest-portal.profile.ts`, insert-only, out of scope here) +- `/home/user/hotcrm/src/sharing/account.sharing.ts`, `case.sharing.ts`, `opportunity.sharing.ts`, `campaign.sharing.ts`, `positions.ts`, `demo-staffing.ts` +- `/home/user/hotcrm/src/objects/*.object.ts` (`sharingModel`, `owner` fields) +- `/home/user/hotcrm/test/authorization-coverage.test.ts` (declared invariants), plus `test/sharing-coverage.test.ts` and `test/demo-staffing.test.ts` for #549/#548 context +- `/home/user/hotcrm/scripts/demo-staff.ts` (test-user creation path) + +Platform semantics that the metadata relies on (as documented in-repo): + +- Permission sets are **explicit-allow only**. An object absent from a profile's `objects` map is denied at the object-CRUD gate *before* OWD/sharing/`view_all_data` are consulted. +- On a `private`-OWD object, the row baseline is **owner-only** (keyed on the platform `owner_id` column, NOT the app-authored `owner` lookup — see #548 below); `viewAllRecords: true` widens to all rows; sharing rules (`sys_record_share`) only ever widen. +- `controlled_by_parent` objects (contact, campaign_member, event_attendee, opportunity/quote line items) derive row access from their parent (ADR-0055); `readScope`/`viewAllRecords` are inert there and the coverage test forbids authoring a scope on them. +- 17.0: `allowExport` is a hard opt-in gate (unset = export DENIED even for admins; `viewAllRecords`/`modifyAllRecords` do not substitute). Export is read-derived: it never widens rows. +- Install-time binding: **a permission set whose name matches a position is bound to that position** (`test/demo-staffing.test.ts`). Plus the platform's additive `member_default` set for every org member (grants e.g. baseline crm_account read at `own` depth — see `src/sharing/demo-staffing.ts` header). + +## 1. Object-level matrix + +OWD per object (from `sharingModel` in `src/objects/*.object.ts`): + +| Object | OWD | Owner field notes | +|---|---|---| +| crm_account | private | app `owner` lookup + `renewal_owner`; platform `owner_id` is what sharing reads | +| crm_campaign | public_read | `owner` lookup | +| crm_campaign_member | controlled_by_parent (parent: crm_campaign) | no owner field (was `private` pre-#488) | +| crm_case | private | `owner` lookup | +| crm_contact | controlled_by_parent (master-detail: crm_account) | `owner` lookup (rows still follow the account) | +| crm_contract | private | `owner` lookup | +| crm_event | private | `owner` lookup (comment: #548 Option B decided, not implemented) | +| crm_event_attendee | controlled_by_parent (parent: crm_event) | — | +| crm_forecast | private | `owner` lookup | +| crm_knowledge_article | public_read | — | +| crm_lead | private | `owner` | +| crm_opportunity | private | `owner`; `is_private` checkbox honoured only by RLS (see §3) | +| crm_opportunity_line_item | controlled_by_parent (parent: crm_opportunity) | — | +| crm_product | public_read | — | +| crm_quote | private | `owner` | +| crm_quote_line_item | controlled_by_parent (parent: crm_quote) | — | +| crm_task | private | `owner` | + +Legend: `C R U D` = allowCreate/Read/Edit/Delete; `VA` = viewAllRecords; `MA` = modifyAllRecords; `own` = explicit `readScope: 'own'`; `X` = allowExport; `—` = **no grant at all (object-level 403 for this profile)**; `deny` = grant present with every flag false. + +| Object (OWD) | sales_rep | sales_manager | service_agent | marketing_user | system_admin | +|---|---|---|---|---|---| +| crm_lead (private) | CRU-, own, X | CRUD, VA+MA, X | -R--, VA, X | CRU-, VA, X | CRUD, VA+MA, X | +| crm_account (private) | CRU-, own, X | CRUD, VA+MA, X | -R--, VA, X | -R--, VA, X | CRUD, VA+MA, X | +| crm_contact (cbp) | CRU-, X (rows follow account) | CRUD, VA+MA, X | -RU-, VA, X | CRU-, VA, X | CRUD, VA+MA, X | +| crm_opportunity (private) | CRU-, own, X | CRUD, VA+MA, X (+RLS is_private) | **deny** (explicit all-false) | -R--, VA, X (+RLS is_private) | CRUD, VA+MA, X | +| crm_quote (private) | CRU-, own | CRUD, VA+MA | — | — | CRUD, VA+MA | +| crm_contract (private) | -R--, own | CRU-, VA | — | — | CRUD, VA+MA | +| crm_product (public_read) | -R--, VA | CRU-, VA | -R--, VA | — | CRUD, VA+MA | +| crm_campaign (public_read) | -R--, VA | CRU-, VA | — | CRU-, VA (+RLS update-widening) | CRUD, VA+MA | +| crm_case (private) | -R--, own, X | -R--, VA, X | CRU-, own, X | — | CRUD, VA+MA, X | +| crm_task (private) | CRUD, own | CRUD, VA+MA | CRUD, own | — | CRUD, VA+MA | +| crm_event (private) | CRUD, own | CRUD, VA+MA | CRUD, own | — | CRUD, VA+MA | +| crm_event_attendee (cbp) | CRUD | CRUD | CRUD | — | CRUD, VA+MA | +| crm_forecast (private) | -R--, own | CRU-, VA+MA | — | — | CRUD, VA+MA | +| crm_knowledge_article (public_read) | -R--, VA | -R--, VA | CRU-, VA | -R--, VA | CRUD, VA+MA | +| crm_opportunity_line_item (cbp) | CRUD (via parent) | CRUD (via parent) | — | — | CRUD, VA+MA | +| crm_quote_line_item (cbp) | CRUD (via parent) | CRUD (via parent) | — | — | CRUD, VA+MA | +| crm_campaign_member (cbp) | -R-- | -R-- | — | CRU- | CRUD, VA+MA | + +`system_admin` additionally declares `systemPermissions: [view_setup, manage_users, customize_application, view_all_data, modify_all_data, manage_profiles, manage_roles, manage_sharing]`. + +Export-bit invariant (pinned by `authorization-coverage.test.ts`): `allowExport` exists exactly where allowRead exists AND the app ships an export surface — the surface set is exactly `crm_account, crm_case, crm_contact, crm_lead, crm_opportunity`. No other object carries the bit on any set; guest_portal carries none. + +## 2. Field-level security (FLS) — complete declared list + +There are NO other FLS declarations besides these (`fields` maps in the five profiles; system_admin declares none): + +| Field | sales_rep | sales_manager | service_agent | marketing_user | +|---|---|---|---|---| +| crm_account.health_score | read-only | read+write | read-only | read-only | +| crm_account.annual_revenue | read-only | (unauthored) | (unauthored) | (unauthored) | +| crm_account.description | read+write | (unauthored) | (unauthored) | (unauthored) | +| crm_opportunity.amount | read+write | read+write | (no read on object at all) | read-only | +| crm_opportunity.probability | read+write | (unauthored) | — | (unauthored) | +| crm_quote.internal_notes | read+write (stays writable after quote sent, per quote.hook) | read+write | (no object grant) | (no object grant) | +| crm_case.internal_notes | **MASKED (readable: false, editable: false)** | read-only | read+write | (no object grant) | +| crm_case.is_sla_violated | (unauthored) | (unauthored) | read-only | — | +| crm_case.resolution_time_hours | (unauthored) | (unauthored) | read-only | — | + +Notes for the tester: + +- The only fully **masked** field in the app is `crm_case.internal_notes` for **sales_rep**. Expect it absent/nulled in rep reads of a case they otherwise can read. The coverage test guarantees no view filters/sorts on it (a masked field in a filter throws `field_predicate_denied`). +- `crm_opportunity.amount` is NOT masked for anyone who can read opportunities — the differences are editability (marketing read-only; rep/manager/admin writable). +- FLS is only authored where the same set holds allowRead on the object (test-pinned), so e.g. marketing has no case FLS because it has no case grant. + +## 3. Sharing rules and row-level security policies + +### Criteria sharing rules (9 total, all `type: 'criteria'`, all target positions) + +| # | Rule name | File | Object | Condition (CEL) | Level | Target position | +|---|---|---|---|---|---|---| +| 1 | account_team_sharing | src/sharing/account.sharing.ts | crm_account | `record.type == "customer" && record.is_active == true` | edit | sales_manager | +| 2 | north_america_territory | src/sharing/account.sharing.ts | crm_account | `record.billing_country in ["US","CA","MX"]` | edit | na_sales_team | +| 3 | europe_territory | src/sharing/account.sharing.ts | crm_account | `record.billing_country in ["UK","DE","FR","IT","ES"]` (deliberately `UK` not `GB`) | edit | eu_sales_team | +| 4 | campaign_leadership_manager | src/sharing/campaign.sharing.ts | crm_campaign | `record.status in ["planning","in_progress"] && record.is_active == true` | edit | marketing_manager | +| 5 | campaign_leadership_director | src/sharing/campaign.sharing.ts | crm_campaign | same as #4 | edit | marketing_director | +| 6 | case_escalation_sharing | src/sharing/case.sharing.ts | crm_case | `record.priority == "critical" && record.is_closed == false` | edit | service_manager | +| 7 | case_director_sharing | src/sharing/case.sharing.ts | crm_case | same as #6 | **read** | service_director | +| 8 | opportunity_sales_sharing | src/sharing/opportunity.sharing.ts | crm_opportunity | `!(record.stage in ["closed_won","closed_lost"]) && record.amount >= 100000` | read | sales_director | +| 9 | opportunity_executive_sharing | src/sharing/opportunity.sharing.ts | crm_opportunity | same as #8 | read | executive | + +Positions are FLAT (ADR-0090 D3 — no hierarchy, no roll-up), which is why every leadership rung (executive, service_director, marketing_manager, marketing_director) has its own explicit rule. Declared positions (`src/sharing/positions.ts`): executive, sales_director, sales_manager, sales_rep, service_director, service_manager, service_agent, marketing_director, marketing_manager, marketing_user, na_sales_team, eu_sales_team. The territory team positions are record groupings only — no permission set binds to them. + +Rules #4/#5 target a `public_read` object at `edit` level — read adds nothing there; what these rules actually add is WRITE access to live campaigns for marketing leadership. The `authorization-coverage` test pins that a share on a public_read object must not be read-level. + +### Row-level security policies (in `rowLevelSecurity` of profiles) + +| Policy | Set | Object | Op | Predicate (`using`) | +|---|---|---|---|---| +| opportunity_private_owner_only | sales_manager | crm_opportunity | select | `is_private == false \|\| owner == current_user.id` | +| opportunity_private_owner_only_marketing | marketing_user | crm_opportunity | select | `is_private == false \|\| owner == current_user.id` | +| marketing_campaign_updates | marketing_user | crm_campaign | update | `id != null` (all-rows widener vs member_default's owner-only-writes) | +| marketing_campaign_member_updates | marketing_user | crm_campaign_member | update | `id != null` | + +The **is_private rule**: `crm_opportunity.is_private` is settable in the opportunity form, and the ONLY thing enforcing it is these two RLS policies — carried by exactly the two sets holding `viewAllRecords` on opportunities (sales_manager, marketing_user). Compiles to `{$or:[{is_private:false},{owner:}]}`. Note the predicate keys on the app `owner` lookup, not platform `owner_id` (relevant to #548). system_admin carries **no** such policy — an admin (viewAllRecords + view_all_data) is expected to see private deals. sales_rep needs no policy (own-scope already excludes others' deals). The sharing rules #8/#9 vs the RLS interact: a sales_director share on a >=100k deal that is ALSO is_private is only filtered for sets carrying the RLS — sales_director holds no persona set here, so if a tester stands one up, check which layer wins. + +## 4. Testable expectations per profile + +Assume seed data: 9 accounts (6 NA, 2 EU (UK/DE), 1 no-territory SG); `demo_bootstrap` assigns ownership of ALL seeded records to the first user (dev admin). API base `http://localhost:4001`, cookie auth via `POST /api/v1/auth/sign-in/email`. Query via `POST /api/v1/data//query`. + +### sales_rep (e.g. RC1ACC-rep, position `sales_rep` only, no territory) + +1. `GET/query crm_forecast` → only rows where `owner_id` = self (freshly created user owning nothing: 0 rows, NOT a 403). +2. `query crm_account` → only self-owned accounts (0 for a new user with no territory position); after creating an account, exactly that one. +3. `query crm_opportunity` → own only; another user's >=100k deal must NOT appear (rep holds no leadership position). +4. `query crm_product` and `crm_knowledge_article` and `crm_campaign` → ALL rows (viewAllRecords on public_read catalogs). +5. Read an own case → `internal_notes` masked (absent/null); write to `crm_case.internal_notes` → denied/ignored (FLS). +6. Create opportunity + line items (POST crm_opportunity, crm_opportunity_line_item) → 2xx; line items on someone ELSE's opportunity → denied (parent-derived write). +7. Export: list-export/report-export on crm_opportunity → allowed but contains only own book. Export on crm_quote → denied (`EXPORT_NOT_PERMITTED`; no allowExport bit). +8. NEGATIVE: `DELETE crm_account/` → 403 (allowDelete false). +9. NEGATIVE: `PATCH crm_account.health_score` → denied (FLS read-only); `PATCH crm_account.annual_revenue` → denied. +10. NEGATIVE (with `na_sales_team` added, after rule evaluate): reads the 6 NA accounts not owned + their contacts, but the accounts' quotes/contracts/tasks/events related lists stay empty (own-only children) — **this is #549, expected, do NOT file**. + +### sales_manager (RC1ACC-mgr, position `sales_manager`) + +1. `query crm_opportunity` → ALL non-private deals org-wide (viewAllRecords), and edit/delete on any of them succeeds (modifyAllRecords). +2. `query crm_opportunity` with a deal flagged `is_private=true` owned by someone else → row HIDDEN (RLS); own private deal → visible. +3. `query crm_forecast` → every rep's snapshots; PATCH a rep's forecast committed number → 2xx. +4. Read case → `internal_notes` visible; PATCH `crm_case.internal_notes` → denied (FLS read-only for this set). +5. Via sharing rule #1 (`account_team_sharing`): after rule evaluation, holds edit share on all active customer accounts (redundant with viewAllRecords for read — verify via `sys_record_share` rows or `POST /api/v1/security/explain`). +6. Submitting an opportunity >= $100k for approval → `manager_review` node resolves to this user (non-empty approver slate). +7. NEGATIVE: `POST crm_case` → 403 (allowCreate false); `PATCH crm_case` non-internal fields → 403 (allowEdit false). +8. NEGATIVE: `DELETE crm_contract`, `DELETE crm_product`, `DELETE crm_campaign` → 403 (allowDelete false on all three). +9. NEGATIVE: quote export → `EXPORT_NOT_PERMITTED` (no allowExport on crm_quote despite full CRUD+MA — 17.0 gate). + +### service_agent (RC1ACC-agent, position `service_agent`) + +1. `query crm_account` / `crm_contact` / `crm_lead` → ALL rows (viewAllRecords context reads); PATCH contact → 2xx, PATCH account → 403 (allowEdit false). +2. `query crm_case` → own queue only; another agent's non-critical case hidden. +3. Critical open case owned by another agent → still hidden for the agent (escalation rules target service_manager/service_director positions, NOT service_agent). +4. Knowledge: create + edit own draft article → 2xx; read all published → all rows; DELETE article → 403. +5. `log_call`/`log_meeting` on a case → creates crm_event → 2xx (#592 grant). +6. Read a case → `internal_notes` readable AND writable; `is_sla_violated`, `resolution_time_hours` read-only (PATCH denied). +7. NEGATIVE: `query crm_opportunity` → 403 object-level (explicit all-false deny — before OWD is even consulted). +8. NEGATIVE: `query crm_quote`, `crm_contract`, `crm_forecast`, `crm_campaign_member` → 403 (no grant at all). +9. NEGATIVE: on a territory/org-readable account detail page, tasks/events related lists show only agent-owned rows even though the account is fully readable — **#549, expected**. + +### marketing_user (RC1ACC-mkt, position `marketing_user`) + +1. `query crm_lead`, `crm_contact` → all rows, editable; DELETE lead → 403. +2. `query crm_opportunity` → all NON-private rows (viewAllRecords + RLS); private deal owned by someone else → hidden; amount readable but PATCH amount → denied (FLS read-only + allowEdit false anyway). +3. "Add to Campaign" action: enrolls leads into a campaign the user did NOT create → 2xx (needs RLS wideners `marketing_campaign_updates` + `marketing_campaign_member_updates`; if this 403s, the RLS widening regressed). +4. PATCH a `crm_campaign_member.status` created by the enrollment flow (system-created row) → 2xx (same widener). +5. Export lead/contact/account/opportunity lists → allowed; campaign export → denied (no bit). +6. NEGATIVE: `query crm_case`, `crm_task`, `crm_quote`, `crm_product`, `crm_event`, `crm_forecast` → 403 (no grants). +7. NEGATIVE: `POST crm_account` → 403 (allowCreate false); `DELETE crm_campaign` → 403. +8. NEGATIVE: DELETE a crm_campaign_member row → 403 (delete reserved to manager/admin — actually only system_admin grants it). + +### system_admin (dev admin or RC1ACC-admin, see §5 binding caveat) + +1. Full CRUD on every one of the 17 objects, all rows, including is_private opportunities (no RLS on this set) — `query crm_opportunity` must include private deals owned by others. +2. Masked fields do NOT apply: reads `crm_case.internal_notes` etc. everywhere. +3. Export on crm_lead/account/contact/opportunity/case → allowed; export on crm_quote/campaign/task/... → `EXPORT_NOT_PERMITTED` **even for admin** (17.0: modifyAllRecords does not substitute for allowExport — expected, not a bug). +4. Setup surfaces reachable (view_setup, manage_users, manage_sharing). +5. NEGATIVE-ish: any NEW object added without a system_admin grant would 403 for admin too (explicit-allow-only); rc acceptance can spot-check one uncatalogued sys-adjacent surface if present. + +### Known-broken / do-not-refile areas + +- **#548 — two ownership columns.** Every object carries the app-authored `owner` lookup (drives "My …" views, notifications, analytics owner axis) AND the platform `owner_id` (the ONLY column the sharing service's private-OWD baseline reads). The `owner_id` migration is "decided (Option B) but not implemented" (`event.object.ts` comment). Consequence to expect: **editing the `owner` field on a record does not move row-level access** — the old owner may keep reading it and the new owner may not see it, and RLS `owner == current_user.id` (is_private policies) diverges from OWD `owner_id` behavior after a reassignment. Import mappings also still target `owner`. Attribute all reassignment-visibility anomalies to #548; do not file duplicates. +- **#549 — sharing rules do not cascade to children.** Territory/team rules are authored on `crm_account` ONLY. A rep/agent who receives an account via territory/team share reads the account + its contacts (controlled_by_parent), but quotes, contracts, tasks, events on it stay own-only, and opportunities widen only via the >=$100k leadership rules; cases only via the critical-escalation rules. Related lists that look "missing rows" on shared accounts are the pinned status quo (`test/sharing-coverage.test.ts` ACCOUNT_CHILD_COVERAGE ledger: contact=derived, opportunity=partial, case=partial, quote/contract/task/event=own_only). Open business decision — do not file duplicates. +- Also note (#621 history): territory rules filter on the flat `crm_account.billing_country` projection maintained by `account.hook.ts` — an account whose `billing_address.country` is typo'd/lowercase gets normalized (trim+uppercase); EU list uses `UK` not `GB`. + +## 5. Creating test users (RC1ACC-*) + +How the app itself does it (`scripts/demo-staff.ts` driving `src/sharing/demo-staffing.ts`, all idempotent, local-server-only guard): + +1. **Sign in as dev admin**: `POST /api/v1/auth/sign-in/email` with `admin@objectos.ai` / `admin123` (exists only under `NODE_ENV=development`, server from `pnpm dev` on port 4001). Keep the session cookie; send `Origin: http://localhost:4001` (better-auth rejects other origins; use hostname `localhost`, not `127.0.0.1` — the latter 403s INVALID_ORIGIN). +2. **Create user**: `POST /api/v1/auth/admin/create-user` with `{email, password (>=8 chars), name, mustChangePassword: false}`. This is the ONLY sanctioned path — direct `sys_user` inserts are refused by the ADR-0092 write guard and would be un-loginable anyway. It also creates the `sys_member` row (role `member`) automatically. Without `mustChangePassword: false` every subsequent call 403s PASSWORD_EXPIRED. +3. **Bind positions**: `POST /api/v1/data/sys_user_position` with `{user_id, position, organization_id}` (org id read from the admin's own `sys_member` row). **Profile binding = position binding**: a permission set whose name matches a position is bound to it at install time, so giving a user position `sales_rep` gives them the SalesRepProfile set. +4. **Re-evaluate sharing rules** (mandatory — seeded rows were written `isSystem: true`, so the grant-materialising hook skipped them): list `sys_sharing_rule` where `active = true`, then `POST /api/v1/sharing/rules//evaluate` for each (diff-based, safe to rerun). +5. Verify per user with `POST /api/v1/security/explain` and/or `POST /api/v1/data/crm_account/query` as that user. + +Recommended RC1ACC roster (mirrors DemoOrgStaffing's shape; password e.g. `rc1acc-Pass1`): + +| Email | Positions | Bound profile | +|---|---|---| +| RC1ACC-rep@objectos.ai | `sales_rep` (optionally + `na_sales_team` for territory tests) | sales_rep | +| RC1ACC-mgr@objectos.ai | `sales_manager` | sales_manager | +| RC1ACC-agent@objectos.ai | `service_agent` | service_agent | +| RC1ACC-mkt@objectos.ai | `marketing_user` | marketing_user | +| RC1ACC-admin@objectos.ai | (no CRM position — see caveat) | system_admin | + +**system_admin caveat**: `system_admin` is NOT in `CrmPositions`, so the name-match binding does not apply; the set binds to the platform admin anchor. `create-user` provisions `sys_member` role `member`, so a freshly created RC1ACC-admin will NOT hold the system_admin set — either run admin assertions as the dev-admin seed account (`admin@objectos.ai`, which demo_bootstrap makes owner of all seed records), or elevate the new user's member role through Setup → Users before testing. Verify with `POST /api/v1/security/explain` that the set actually resolved before attributing failures. + +Alternative: extend `DemoOrgStaffing` locally (scratch copy) and run `pnpm demo:staff --url http://localhost:4001` — but note the shipped table only covers rep/rep/manager, and the script's verify step asserts territory behavior you may not want for all five users; direct API calls per steps 1-4 are cleaner for RC1ACC. + +Keep test users NON-owners of seeded records (ownership stays with the dev admin) — a share proven against a record the user owns proves nothing, since private OWD already admits the owner. diff --git a/docs/acceptance-17/evidence/r1-objects.md b/docs/acceptance-17/evidence/r1-objects.md new file mode 100644 index 00000000..0bf6dfc9 --- /dev/null +++ b/docs/acceptance-17/evidence/r1-objects.md @@ -0,0 +1,47 @@ +# HotCRM 17.0.0-rc.2 Acceptance — Executor R1 + +Run: 2026-08-05 · Server http://localhost:4001 · Console `/_console/apps/app.objectstack.hotcrm/` · Auth: admin@objectos.ai (Bearer) +Routes verified: list = `/_console/apps/app.objectstack.hotcrm/`, detail = `...//record/`. +Screenshots in this directory (`/tmp/claude-0/-home-user/f9de7acc-06e5-5667-b535-06e82c336458/scratchpad/r1/`). Raw data: `raw-results.json`. + +## Environment-wide console noise (present on every page, NOT app defects) + +- `console.error: Failed to load resource: net::ERR_TUNNEL_CONNECTION_FAILED` — Sentry telemetry (`o4510356161757184.ingest.us.sentry.io/.../envelope/`) blocked by the sandbox agent proxy. Environment artifact. +- `console.error: Failed to load resource: 404` on first load only — `GET /assets/crm-favicon.ico` → 404. Minor packaging nit: the favicon declared in app branding is not served. No functional impact. + +No other console.error and zero `pageerror` across all 17 objects' list + detail pages. + +## Main objects (14) + +| object | REST status/rows (limit=5) | list render | detail render | console errors (deduped) | verdict | +|---|---|---|---|---|---| +| crm_account | 200 / 5 (total 9) | PASS, 9 rows grid + footer "9 records" (`crm_account-list.png`) | PASS — Details/Related/Attachments/Activity tabs, field groups Basic Info/Financials/Contact Info (`crm_account-detail.png`) | env noise only | 通过 — fully healthy | +| crm_contact | 200 / 5 (total 9) | PASS, grouped-by-Account grid, 9 accounts groups, footer "9 records" (`crm_contact-list.png`) | PASS — Details/Related(3)/Attachments tabs, Identity/Account & Role/Comm-Prefs groups (`crm_contact-detail.png`) | env noise only | 通过 — default view groups by account (by design) | +| crm_lead | 200 / 5 (total 21) | PASS, 21 rows (`crm_lead-list.png`) | PASS — Details/Related/Activity (`crm_lead-detail.png`) | env noise only | 通过 | +| crm_opportunity | 200 / 5 (total 23) | PASS, 10 rows — default "Open Deals" filtered view, footer "10 records" (`crm_opportunity-list.png`) | PASS — stage path, Details/Related(3)/Activity, Products side panel (`crm_opportunity-detail.png`) | env noise only | 通过 — filtered default view is intentional | +| crm_product | 200 / 5 (total 13) | PASS, 13 rows (`crm_product-list.png`) | PASS — Details/Related (`crm_product-detail.png`) | env noise only | 通过 | +| crm_quote | 200 / 5 (total 5) | PASS, 5 rows (`crm_quote-list.png`) | PASS — status path Draft→Accepted, Details/Related(3)/Attachments (`crm_quote-detail.png`) | env noise only | 通过 | +| crm_contract | 200 / 4 (total 4) | PASS, 4 rows (`crm_contract-list.png`) | PASS — status path, Details/Attachments, Contract Info/Parties/Terms & Dates groups; no Related tab (layout choice) (`crm_contract-detail.png`) | env noise only | 通过 | +| crm_case | 200 / 5 (total 38) | PASS, 38 rows, All Cases + Service Workflow/SLA Calendar/Timeline views listed (`crm_case-list.png`) | PASS — Details/Related/Activity (`crm_case-detail.png`) | env noise only | 通过 | +| crm_campaign | 200 / 5 (total 7) | PASS, 7 rows (`crm_campaign-list.png`) | PASS — status path Planning→In Progress, Details/Related(14) (`crm_campaign-detail.png`) | env noise only | 通过 | +| crm_forecast | 200 / 5 (total 8) | PASS, 8 rows (`crm_forecast-list.png`) | PASS — tab-less layout: Snapshot/Amounts/Source groups + Discussion timeline (`crm_forecast-detail.png`) | env noise only | 通过 | +| crm_knowledge_article | 200 / 4 (total 4) | PASS, 4 rows (`crm_knowledge_article-list.png`) | PASS — status path Draft→Published, Article Information + rendered rich-text Content (`crm_knowledge_article-detail.png`) | env noise only | 通过 | +| crm_task | 200 / 5 (total 7) | PASS, 7 rows (`crm_task-list.png`) | PASS — status path, Related Records/Recurrence/Progress & Effort/System groups (`crm_task-detail.png`) | env noise only | 通过 | +| crm_event | 200 / 0 (total 0) | PASS — view renders, clean "Nothing here yet" empty state; Grid/Calendar/Timeline/Kanban view tabs present (`crm_event-list.png`) | 阻塞 — no seed record to open | env noise only | 阻塞 — UI healthy, seed data absent (0 rows); detail untestable | +| crm_event_attendee | 200 / 0 (total 0) | PASS — renders empty state "Nothing here yet" (`crm_event_attendee-list.png`) | 阻塞 — no seed record to open | env noise only | 阻塞 — UI healthy, seed data absent (0 rows); detail untestable | + +## Child objects (3) — REST + parent related list + +| object | REST status/rows | parent related-list check | verdict | +|---|---|---|---| +| crm_opportunity_line_item | 200 / 5 (total 74) | Opportunity "Wayne Q1 Expansion" (UNGq93kVo03CO3nk) → Related → Products: "Opportunity Line Item 3" table with product names/qty/price/total (`opportunity_related_expanded.png`; collapsed state `opportunity_related.png`) | 通过 — note: right-hand "Products" summary side panel lists raw record IDs (e.g. `-PBylY-NARjgK6dS`) instead of display names — cosmetic | +| crm_quote_line_item | 200 / 5 (total 16) | Quote QTE-0001 (76ZW4qQ9oTHG97_Y) → Related: "Quote Line Item 3" table, product names + qty + price + total (`quote_related.png`) | 通过 | +| crm_campaign_member | 200 / 5 (total 51) | Campaign CPG-0001 (YyaCORMnaaoQ91Qd) → Related: "Campaign Member 12", Lead/Contact/Status/Response Date columns, paginated Page 1 of 3 (`campaign_related.png`) | 通过 | + +Note: the tested opportunity has no quotes attached (its "Quotes" related section is legitimately empty — REST confirms no crm_quote rows point at UNGq93kVo03CO3nk); "Open Tasks" expands to "Task 0 — No related records found", correct. + +## Summary + +- 15/17 objects 通过; crm_event and crm_event_attendee 阻塞 for detail-page testing only because the seed ships 0 rows (REST 200, empty-state list view renders correctly). +- Zero application console errors or page errors anywhere. Only environment noise: proxy-blocked Sentry telemetry and a 404 on `/assets/crm-favicon.ico` (minor packaging nit worth a look). +- Minor cosmetic finding: opportunity detail "Products" side-panel widget renders raw line-item record IDs instead of display labels. diff --git a/docs/acceptance-17/evidence/r2-analytics.md b/docs/acceptance-17/evidence/r2-analytics.md new file mode 100644 index 00000000..a9f861b0 --- /dev/null +++ b/docs/acceptance-17/evidence/r2-analytics.md @@ -0,0 +1,170 @@ +# R2 — HotCRM 17.0.0-rc.2 Acceptance: Dashboards & Reports + +- Date: 2026-08-05 · Executor: R2 (read-only) · Server: http://localhost:4001 (not restarted) +- Auth: POST /api/v1/auth/sign-in/email → Bearer token; Playwright authenticated via `context.request.post` (cookies shared with browser). +- Analytics REST path used by the UI (captured from network): **`POST /api/v1/analytics/dataset/query`** — every observed call returned **200**. +- Seed baseline (REST cross-checks, `GET /api/v1/data/?limit=1` → `total`): crm_case **38**, crm_opportunity **23**, crm_lead **21**, crm_account **9**, crm_contact **9**, crm_task **7**, crm_forecast **8**, crm_product **13**, **crm_event 0 (no seeds)**. +- Noise excluded from verdicts: Sentry `o4510356161757184.ingest.us.sentry.io` requests fail with `net::ERR_TUNNEL_CONNECTION_FAILED` (outbound proxy blocks them; unrelated to app) and `GET /assets/crm-favicon.ico` → 404 (cosmetic asset miss, appears on report/dashboard pages). +- Scripts & artifacts: `/tmp/claude-0/-home-user/f9de7acc-06e5-5667-b535-06e82c336458/scratchpad/r2/` (`dash.js`, `dash2.js`, `filters.js`, `reports.js`, `dash_results.json`, `dash2_results.json`, `filter_results.json`, `report_results.json`, PNGs referenced below). + +Verdict legend: 通过 = pass, 失败 = fail, 阻塞 = blocked. + +--- + +## SCOPE A — Dashboards + +### 1. crm_overview_dashboard (`/_console/apps/app.objectstack.hotcrm/dashboard/crm_overview_dashboard`) — 通过 +Screenshots: `dash_crm_overview_dashboard.png` (top), `dash_crm_overview_dashboard_mid.png`, `dash_crm_overview_dashboard_bottom.png` (below fold). + +| Widget | Verdict | Evidence | +|---|---|---| +| Total Revenue (metric) | 通过 | 610,000 (this quarter) | +| Active Deals (metric) | 通过 | 7 | +| Won Deals (metric) | 通过 | 4 | +| Avg Deal Size (metric) | 通过 | 152,500 | +| Revenue Trends (area) | 通过 | SVG rendered, month x-axis 2026-01…2026-08, y-axis 0–600K | +| Lead Source (donut) | 通过 | Donut with 7 legend entries (Cold Call, Content/Blog, Email Campaign, Event/Trade Show, Partner, Referral, Web) | +| Pipeline by Stage (funnel) | 通过 | Funnel with Qualification / Needs Analysis / Proposal / Negotiation segments | +| Top Products (bar) | 通过 | Bars for Service/Software/Subscription/Support, y-axis 0–120K | +| Pipeline by Owner (table) | 通过 | Row: Dev Admin · 2,910,000 · 7 · 415,714 | + +Console errors: only favicon 404 + Sentry tunnel noise. Failed app requests: none (all analytics queries 200). + +### 2. executive_dashboard — 通过 +Screenshot: `dash_executive_dashboard.png`. + +| Widget | Verdict | Evidence | +|---|---|---| +| Total Revenue (YTD) | 通过 | 1,290,000 | +| Active Accounts | 通过 | 9 (matches REST total) | +| Total Contacts | 通过 | 9 (matches REST total) | +| Open Leads | 通过 | 21 (matches REST total) | +| Revenue Trend (area) | 通过 | Month buckets 2026-01…2026-08, y 0–600K | +| Revenue by Industry (donut) | 通过 | 6 industry segments | +| Pipeline by Stage (funnel) | 通过 | 4 stage segments | +| New Accounts (bar) | 通过 | 2026-08 bucket, y 0–12 | +| Accounts by Industry (table) | 通过 | 9 data rows, e.g. Technology · 30,000,000 · 2 | + +Console errors: Sentry noise only. HTTP errors: none. + +### 3. sales_dashboard — 通过 +Screenshots: `dash_sales_dashboard.png`, `dash_sales_dashboard_mid.png`, `dash_sales_dashboard_bottom.png`. + +| Widget | Verdict | Evidence | +|---|---|---| +| Total Pipeline | 通过 | 2,910,000 | +| Closed Won (QTD) | 通过 | 610,000 | +| Open Opportunities | 通过 | 7 | +| Win Rate (12M) | 通过 | 62% | +| Deals Won (12M) | 通过 | 8 | +| Deals Lost (12M) | 通过 | 5 | +| Monthly Revenue Trend (area) | 通过 | Month buckets 2026-01…2026-08 | +| Pipeline by Forecast Category (h-bar) | 通过 | Axis 0–2.4M, bars rendered | +| Lead Source (donut) | 通过 | 7 segments | +| Open Pipeline by Owner (table) | 通过 | Dev Admin · 2,910,000 · 7 · 55% | +| Quota Attainment by Rep (table) | 通过* | "— · 1,500,000 · 820,000 · 55%" — values correct; owner label renders "—" (see Observations O2) | +| Win / Loss by Rep (table) | 通过 | Dev Admin · 8 · 5 · 13 · 62% · 1,290,000 | +| Win / Loss by Lead Source (table) | 通过 | 8 data rows, e.g. Web · 2 · 1 · 3 · 67% · 470,000 | +| Why We Lose (donut) | 通过 | 5 loss-reason segments | +| Pipeline by Stage × Lead Source (pivot) | 通过 | Cross-tab renders, e.g. Negotiation × Partner 1,200,000; row/col totals present | + +Console errors: Sentry noise only. HTTP errors: none. + +### 4. service_dashboard — 通过 (see #520 signal below) +Screenshots: `dash_service_dashboard.png`, `dash_service_dashboard_mid.png`, `dash_service_dashboard_bottom.png`. + +| Widget | Verdict | Evidence | +|---|---|---| +| Open Cases (metric) | 通过 | **30** (REST: 38 cases total, 30 with `is_closed=false` — exact match) | +| Critical Cases (metric) | 通过 | **7** (REST: 9 critical, 7 open critical) | +| Avg Resolution Time (metric) | 通过 | **45.0** h | +| SLA Violations (metric) | 通过 | **3** | +| Cases by Status (donut) | 通过 | 5 segments: Escalated / In Progress / New / Resolved / Waiting on Customer | +| Cases by Priority (pie) | 通过 | 4 segments: Critical / High / Low / Medium | +| Cases by Origin (bar) | 通过 | Chat/Email/Phone/Portal/Web bars, y 0–12 | +| Daily Case Volume (area) | 通过 | Day buckets Jul 6 → Aug 4, non-empty series | +| SLA Compliance (gauge) | 通过 | Shows 0.0% SLA Violation Rate — **data-consistent, not a bug**: widget filters `is_closed: true`, and all 3 SLA-violated cases are open (Open Cases by Priority shows Critical 42.9% ≈ 3/7); closed cases have zero violations | +| Open Cases by Priority (table) | 通过 | Medium 9 · 0.0% / Critical 7 · 42.9% / High 7 · 0.0% / Low 7 · 0.0% | + +Console errors: Sentry noise only. HTTP errors: none; analytics queries 200 (e.g. payload `{"datasetName":"case_metrics","selection":{"measures":["case_count"],"runtimeFilter":{"$and":[{"is_closed":false}...]}}}`). + +### 5. sales_activity_dashboard — 通过 (empty event widgets are seed-consistent) +Screenshots: `dash_sales_activity_dashboard.png`, `dash_sales_activity_dashboard_mid.png`, `dash_sales_activity_dashboard_bottom.png`. + +| Widget | Verdict | Evidence | +|---|---|---| +| Interactions Logged | 通过 | 0 — correct: `GET /api/v1/data/crm_event?limit=1` → `"total":0` (no event seeds) | +| Meetings Booked | 通过 | 0 (same — no crm_event seeds) | +| Customer Minutes | 通过 | 0 (same) | +| Tasks Completed | 通过 | 1 (crm_task has 7 records; 1 matches widget filter) | +| Activity by Rep (bar) | 通过 | Renders clean "No rows" empty state (correct for 0 events; no error) | +| Activity Volume by Week (area) | 通过 | "No rows" empty state | +| Activity Mix (donut) | 通过 | "No rows" empty state | +| Where the Activity Lands (bar) | 通过 | "No rows" empty state | +| Interactions on Deals | 通过 | 0 (no events) | +| Open Deals | 通过 | 10 | +| Quiet 30+/60+/90+ Days | 通过 | 0 / 0 / 0 — correct: all 9 accounts have `last_activity_date` between 2026-07-15 and 2026-08-04 (REST-verified), i.e. none quiet 30+ days | + +Console errors: Sentry noise only. HTTP errors: none. Verdict note: every widget renders and the zeros/empties are provably data-driven (REST cross-check), not query failures — no failing request or console error on this page. + +### Global-filter tests (2 dashboards, before/after) + +**crm_overview_dashboard — date range** (`filter_crm_before.png` → `filter_crm_after_year.png`): +"This quarter" → "This year" changed every dateRange-bound KPI: +- Total Revenue 610,000 → **1,290,000** +- Active Deals 7 → **10** +- Won Deals 4 → **8** +- Avg Deal Size 152,500 → **161,250** + +**crm_overview_dashboard — Owner filter** (`filter_crm_after_owner.png`): +Owner: All → Dev Admin. Numbers unchanged — expected, since REST shows a single owner (`6pQQ0XGw…`) owns all 23 opportunities. The filter is provably applied server-side: captured query payload +`{"datasetName":"opportunity_metrics","selection":{"measures":["total_amount"],"runtimeFilter":{"$and":[{"stage":"closed_won"},{"$and":[{"close_date":{"$gte":"2026-01-01","$lte":"2026-12-31"}},{"owner":"6pQQ0XGw6t294Qme6cbucdEM8giap4ew"}]}]}}}` → 200. + +**service_dashboard — Priority filter** (`filter_svc_before.png` → `filter_svc_after_priority.png`): +Priority: All → Critical changed the numbers: +- Open Cases 30 → **7** +- Avg Resolution Time 45.0 → **48.0** h +- SLA Violations 3 → 3 (all violated cases are critical — consistent with Critical row's 42.9% rate) +Captured payload: `{"datasetName":"case_metrics","selection":{"measures":["case_count"],"runtimeFilter":{"$and":[{"is_closed":false},{"priority":"critical"}]}}}` → 200. + +### #520 signal (service_dashboard datetime window filter — acceptance) +**PASS.** In rc.0 every service_dashboard widget was empty (hotcrm#520 / upstream objectstack#3912: SQLite datetime filter values coerced to epoch-ms INTEGER against ISO-TEXT columns, so the injected date window matched nothing). On rc.2 every widget shows real numbers from the 38 seeded cases: **Open Cases 30, Critical Cases 7, Avg Resolution 45.0 h, SLA Violations 3**, plus fully populated status/priority/origin charts, a 30-day Daily Case Volume series with day buckets Jul 6–Aug 4, and the Open Cases by Priority table (Medium 9 / Critical 7 / High 7 / Low 7). These exactly match the REST ground truth (38 total, 30 open, 8 closed; 9 critical). All `POST /api/v1/analytics/dataset/query` calls returned 200 with no console errors. Note the shipped design in `src/dashboards/service.dashboard.ts` deliberately omits the dashboard `dateRange` (per the in-file #460/#3912 analysis) — the read-side acceptance signal here is the widgets computing over datetime-stamped case data and returning correct non-zero numbers, which they do; additionally the `created_date`-bucketed Daily Case Volume window (last 30 days) populates correctly, showing the datetime day-bucketing path works. + +--- + +## SCOPE B — Reports (all at `/_console/apps/app.objectstack.hotcrm/report/`) + +| Report | Type | Verdict | Evidence (screenshot `report_.png`) | +|---|---|---|---| +| accounts_by_industry_type | matrix | 通过 | 9 rows × Customer/Prospect/Partner column groups, both measures populated (Total 9 accounts / 109,800,000); grand totals correct | +| cases_by_status_priority | summary + bar | 通过 | 22 rows (status × priority), both measures (Cases, Avg Resolution (h)) resolve; bar chart renders with populated axes | +| sla_performance | summary + column | 通过 | 4 priority rows + total, measures Cases / SLA Violation Rate / Avg Resolution (h) all resolve (closed-only scope → 0.0% rates, consistent with gauge); chart renders | +| cases_opened_by_day_priority | **matrix (day)** | 通过 | See #523 below — 30 day columns `2026-07-06`…`2026-08-04`, chronological, Total 38 = seeded case count | +| lead_inflow_by_month_source | **matrix (month)** | 通过 | See #523 below — month columns `2026-02`…`2026-08`, chronological; Total 20 (21 leads minus 1 never-contacted, excluded by `last_contacted_date ≠ null` filter — correct) | +| opportunities_by_stage | summary + bar | 通过 | 6 stage rows + total (5,065,000 · 68%); bar chart with labeled stage x-axis and 0–2M y-axis, no blank axes | +| won_opportunities_by_owner | summary + column | 通过 | Dev Admin · 1,290,000 + total row; column chart renders | +| pipeline_coverage_by_quarter | **matrix (quarter)** | 通过 | See #523 below — quarter columns `2026-Q3`, `2026-Q4`, ordered; per-cell amount+count, totals row 2,910,000+865,000=3,775,000 · 10 opps | +| opportunity_funnel_owner_stage | summary (2-level) + funnel | 通过 | 6 owner→stage rows + total; multi-level grouping works; funnel chart renders | +| customer_churn_signals | joined (3 blocks) | 通过 | Block 3 (Recently Lost) has data: Dev Admin · 75,000 · 1. Blocks 1–2 show explicit empty state "The dataset returned no rows for this report's scope" — **data-correct, not a failure**: REST shows all 9 accounts have `last_activity_date` ≥ 2026-07-15 (< 60 days ago), so the 60+/90+-day quiet populations are genuinely empty | + +Console/network per report: zero failing app requests and zero app console errors on all 10 (only the favicon-404 + Sentry tunnel noise documented above); all analytics queries 200. + +### #523 signal (matrix date-column granularity & ordering — acceptance) +**PASS.** All three date-columned matrix reports bucket properly and order chronologically — no "one column per raw date/timestamp" symptom: +- **lead_inflow_by_month_source**: columns are calendar months `2026-02, 2026-03, 2026-04, 2026-05, 2026-06, 2026-07, 2026-08` — exactly 7 columns for 20 contacted leads whose raw `last_contacted_date` values are distinct datetimes; multiple leads aggregate into shared month cells (e.g. 2026-05 total = 4). Screenshot `report_lead_inflow_by_month_source.png`. +- **pipeline_coverage_by_quarter**: columns are quarters `2026-Q3, 2026-Q4` (ordered), each carrying both measures; 10 open opportunities with distinct close dates collapse into 2 quarter columns. Screenshot `report_pipeline_coverage_by_quarter.png`. +- **cases_opened_by_day_priority**: columns are day buckets `2026-07-06` … `2026-08-04`, strictly chronological, with same-day cases aggregating into one column (grand-total row sums per day incl. 2/3-per-day cells; overall Total 38 = all seeded cases). Screenshot `report_cases_opened_by_day_priority.png`. + +--- + +## Observations (non-blocking) +- **O1**: `GET /assets/crm-favicon.ico` → 404 on app pages (console error noise; cosmetic). +- **O2**: Quota Attainment by Rep (sales_dashboard, `forecast_metrics`) shows owner as "—": seeded `crm_forecast` records have `owner_id` set but the `owner` field itself `null` (REST-verified), so the dimension label is empty. Data/seed modeling quirk, not a render failure; numbers are correct. +- **O3**: opportunities_by_stage chart shows the raw measure id `total_amount` as x-axis footer label instead of the friendly label — cosmetic. +- **O4**: crm_event has zero seeded records, leaving 7 of 13 sales_activity_dashboard widgets at 0/"No rows". If a populated activity dashboard is wanted for the demo, event seeds would need to be added (out of scope for this read-only run). + +## Summary +- Scope A: 5/5 dashboards 通过 (56 widgets checked; every widget renders; all zeros/empties REST-verified as data-correct). Global filters verified on 2 dashboards with before/after screenshots and captured query payloads. +- Scope B: 10/10 reports 通过; measures resolve, charts render with populated axes. +- **#520: PASS** — service_dashboard fully populated (30/7/45.0/3 over 38 seeded cases). +- **#523: PASS** — month/quarter/day matrix buckets proper and chronologically ordered. diff --git a/docs/acceptance-17/evidence/r3-i18n.md b/docs/acceptance-17/evidence/r3-i18n.md new file mode 100644 index 00000000..2e1bbcf6 --- /dev/null +++ b/docs/acceptance-17/evidence/r3-i18n.md @@ -0,0 +1,131 @@ +# R3 — i18n / locale acceptance, HotCRM 17.0.0-rc.2 + +Executor: R3. Read-only for business data — nothing created/updated/deleted; only the UI +locale was toggled (session-scoped) and it ends restored to `en` (`shots/zz-restored-en.png`). +Server: http://localhost:4001 (dev, untouched). Date: 2026-08-05. +Evidence: screenshots in `shots/`, structured text dumps in `run-log.json`, full lint output +in `lint-full.txt`, runner in `run.mjs`. + +## How the console switches locale (discovered, not guessed) + +1. UI switcher: top-right **avatar (DA) → PREFERENCES → Language** — a custom row (not a + `role=menuitem`) opening a list: English / 中文 / 日本語 / 한국어 / Deutsch / Français / + Español / Português / Русский / العربية. Found by dumping the avatar-dropdown contents + (`shots/explore-avatar-menu.png`, `shots/explore-lang-menu.png`). The row's own label + localizes (语言/言語/Idioma) once switched. +2. The console uses short codes; the server maps them to the app bundle: + `GET /api/v1/i18n/translations/zh` → `{"locale":"zh-CN","requestedLocale":"zh",…}`. + `GET /api/v1/i18n/locales` lists `en` (default), `zh-CN`, `ja-JP`, `es-ES` — exactly what + `/home/user/hotcrm/src/translations/crm.translation.ts` declares. +3. **The choice does not persist**: nothing is written to localStorage or + `sys_user_preference`; a fresh session (and any full page reload) requests + `/api/v1/i18n/translations/en` again (verified by network capture). The test runner + therefore switched once per locale and navigated exclusively client-side (sidebar/row + clicks); a `page.goto` mid-pass silently reverts the UI to English. +4. App strings (objects/fields/options/views/actions/nav/dashboards) come from the HotCRM + bundle; console chrome (Search box, New/Import/Edit/Cancel, validation scaffolding) + comes from the console's built-in translations. + +## Static baseline (src/translations/) + +- `en.ts` 951 lines; `zh-CN.ts` 1345 lines — zh is a **superset**: full `navigation` map + (all `nav_*` ids + `group_work/insights/approvals`), a `pages` section, **11 `_sections` + blocks**, case status/priority option sets, account CS fields, extra dashboard widgets. +- `ja-JP.ts` / `es-ES.ts` (~926 lines) mirror **en's shape, not zh's**: no `nav_*` labels, no + `pages`, only 1 `_sections` block, no case status/priority options, no account + tier/segment/health_score/renewal fields, missing action params (send_email, + create_campaign, mass_update_stage incl. its 7 stage options). +- `objectstack lint` with i18n checks ON (repo's `pnpm lint` passes `--skip-i18n`): + **723 warnings — 710 i18n: 355 for ja-JP + 355 for es-ES, 0 for zh-CN.** + Breakdown (both locales combined): 338 missing-option, 140 missing-field, + 68 missing-navigation, 50 missing-page, 44 missing-action, 26 missing-view, + 24 missing-widget, 20 missing-object. (Remaining 13 = duplicate activity-action name + warnings, not i18n.) + +## Matrix — check item × locale + +Verdicts: **T** translated · **EF** English-fallback · **MIX** mixed · (no raw-key leaks like +`objects.crm_account.label` were observed anywhere in the run). + +| Check item | en (baseline) | zh-CN | ja-JP | es-ES | +|---|---|---|---|---| +| ① Main nav (groups + items) | OK — `shots/en-1-nav.png` | **T** — all groups+items (`shots/zh-CN-1-nav.png`) | **MIX→EF** — groups 営業/活動 translated, "My Work/Insights/Approvals" + **all 23 item labels English** (`shots/ja-JP-1-nav.png`) | **MIX→EF** — Ventas/Actividad/Servicio/Marketing translated, "My Work/Insights/Approvals" + all items English (`shots/es-ES-1-nav.png`) | +| ② crm_account list: view tabs | OK — `shots/en-2-account-list.png` | **T** 全部客户/客户卡片/客户地图/企业客户 (`shots/zh-CN-2-account-list.png`) | **T** 全取引先/取引先カード/… (`shots/ja-JP-2-account-list.png`) | **T** Todas las Cuentas/… (`shots/es-ES-2-account-list.png`) | +| ② crm_account list: column headers | OK | **T** (all 8) | **MIX** — translated except **Health Score, Next Renewal Date** (missing fields); object description + Healthy/At Risk chips English | **MIX** — same two columns + chips + description English | +| ② crm_opportunity list: tabs + columns | OK — `shots/en-2-oppty-list.png` | **T** (`shots/zh-CN-2-oppty-list.png`) | **T** (`shots/ja-JP-2-oppty-list.png`) | **T** (`shots/es-ES-2-oppty-list.png`) | +| ③ Account detail: header + log_call/log_meeting/schedule_meeting | OK — `shots/en-3-account-detail.png` | **T** 记录通话/记录会议/安排会议; tabs+sections translated (`shots/zh-CN-3-account-detail.png`) | **MIX** — 3 action buttons translated; **tabs (Details/Related/Attachments) & section headers (Basic Information/Financials/Contact Information) + DISPLAY TITLE English** (`shots/ja-JP-3-account-detail.png`) | **MIX** — same pattern (`shots/es-ES-3-account-detail.png`) | +| ③ Case detail: header + close_case | OK — `shots/en-3-case-detail.png` | **T** 关闭工单/记录通话/编辑; status stepper, tabs, sections all translated (`shots/zh-CN-3-case-detail.png`) | **MIX** — ケースをクローズ/電話を記録 translated; tabs/sections + **status stepper values (New/In Progress/…) English** (`shots/ja-JP-3-case-detail.png`) | **MIX** — Cerrar Caso/Registrar llamada translated; same English leftovers (`shots/es-ES-3-case-detail.png`) | +| ④ crm_case New form, empty submit → validation | OK — `shots/en-4-validation.png` | **T** messages (主题不能为空 …) + toast; **but** form tabs Case/SLA/Resolution English (`shots/zh-CN-4-validation.png`) | **T** messages (件名は必須です …) + toast; status/priority **options English**; tabs English (`shots/ja-JP-4-validation.png`) | **T** messages (Asunto es obligatorio …) + toast; options English; tabs English (`shots/es-ES-4-validation.png`) | +| ⑤ log_call param dialog (opened, screenshotted, closed via Esc — never executed) | OK — `shots/en-5-param-dialog.png` | **MIX** — all 5 param labels + subtitle + 取消/确认 translated, **dialog title "Log a Call" English** (`shots/zh-CN-5-param-dialog.png`) | **MIX** — params translated, **title English** (`shots/ja-JP-5-param-dialog.png`) | **MIX** — params translated, **title English** (`shots/es-ES-5-param-dialog.png`) | + +Form was closed with Escape after capture; empty-submit writes nothing (server rejected +client-side; record count unchanged at 9 accounts / 10 opptys / same case list). + +## Matches known issue #NNN + +- **#661 (list-view tab labels hardcoded English, all non-en locales): NOT reproduced — + appears FIXED in rc.2.** View tabs are translated in zh/ja/es on both crm_account and + crm_opportunity (screenshots row ② above; also case-list tabs 全部工单/… in + `run-log.json`). Recommend closing after confirmation by the issue owner. +- **#645 (untranslated select option labels): CONFIRMED for ja-JP / es-ES, resolved for + zh-CN.** Visible instances: case Status/Priority dropdown options English in the ja/es + create form (`shots/ja-JP-4-validation.png`, `shots/es-ES-4-validation.png`), case detail + status stepper English (`shots/ja-JP-3-case-detail.png`, `shots/es-ES-3-case-detail.png`), + Health Score chips Healthy/At Risk English in ja/es lists. Current measure: lint reports + **169 missing option keys per locale** (ja, es; zh = 0) — substantially more than the 34 + recorded in the issue; scope has grown, not shrunk. +- **#494 (--skip-i18n hides warnings): CONFIRMED and count outdated.** `pnpm lint` still + passes `--skip-i18n`; running without it yields **710 i18n warnings** (355 ja + 355 es), + not the 205 recorded — the debt has grown. Additional blind spot found: lint has **no + check for `_sections`** — ja/es lack 10 of zh's 11 `_sections` blocks and visibly render + English section headers (row ③), with zero warnings emitted. + +## New findings (distinct from #661/#645/#494) + +1. **Action param-dialog title ignores the translated action label** — the page button is + localized (记录通话 / 電話を記録 / Registrar llamada) but the dialog it opens is titled + "Log a Call" in every non-en locale, while subtitle, all param labels and buttons are + localized. Evidence: `shots/zh-CN-5-param-dialog.png`, `shots/ja-JP-5-param-dialog.png`, + `shots/es-ES-5-param-dialog.png` (+ `run-log.json` `param_dialog`). Looks like the dialog + header resolves the raw action `label` instead of the `_actions..label` key. + Platform/console-level; affects every object action with params. +2. **Create-form section tabs are untranslatable and English in ALL locales, including the + otherwise-100 %-complete zh-CN** — "Case • / SLA / Resolution" on the New Case form + (`shots/zh-CN-4-validation.png`). These come from form/section metadata that has no + translation key path (zh's `_sections` translations cover the detail page, which renders + 工单信息/状态与SLA correctly, but not the create-form tab strip). +3. **Raw object API name leaks into a user-facing placeholder** — the Contact lookup on the + New Case form reads "Select crm_account first" / 请先选择crm_account / まず crm_account を + 選択してください / Seleccione primero crm_account in all four locales: the console + interpolates the object API name instead of its translated label. Evidence: all + `*-4a-case-new-form.png` / `*-4-validation.png`, `run-log.json` `form_buttons`. +4. **English validation toast joins field names with the CJK enumeration comma** — + "Please check the highlighted fields: Subject、Account、Status…" in the **en** locale + (`shots/en-4-validation.png`). Console `validation.formInvalid` joiner is hardcoded "、". +5. **Locale does not persist** — reload/new session always returns to `en`; no per-user + preference is stored (see "How the console switches locale" §3). Console-level UX gap; + makes every non-en locale effectively demo-only. +6. **Console chrome strings English in every non-en locale (zh included)**: view-mode + switcher Grid/Gallery/Kanban/Calendar/Timeline, "More actions", "Add reaction", "Clear", + "Close", footer aggregates "Sum:"/"Avg:". Evidence: `shots/zh-CN-2-account-list.png`, + `run-log.json` `case_list_toolbar`/`account_detail_buttons` per locale. +7. **Dashboard filter labels and stat-card captions hardcoded English in all locales incl. + zh** — "Owner:", "Lead Source:" filter chips (values 全部/すべて/Todo ARE translated) and + KPI sub-captions "Total Amount / Accounts / Contacts / Leads" + (`shots/zh-CN-1-nav.png`, `shots/ja-JP-1-nav.png`, `shots/es-ES-1-nav.png`). Widget titles + and descriptions themselves are correctly translated. +8. **es-ES validation grammar** — "Cuenta es obligatorio" (should inflect: *obligatoria*); + the console template concatenates without gender agreement (`shots/es-ES-4-validation.png`). + Minor, console-level. +9. **ja/es sidebar nav items all English** (34 missing `nav_*`/group keys each) and + **ja/es detail-page tabs/section headers English** — strictly these are part of the + known "es/ja lag zh-CN" debt measured under #494, but they are the two most visible + regressions for a Japanese/Spanish user and worth an explicit work item: ja-JP and es-ES + were authored against en.ts's shape and never received zh-CN's later additions + (navigation map, pages, `_sections`, CS fields, case status/priority options). + +## Cleanup / state + +- Locale restored to `en` (`shots/zz-restored-en.png`); choice is session-scoped anyway. +- No business data touched: the only form submissions were empty (rejected client-side, + nothing persisted); dialogs closed via Escape without executing any action. diff --git a/docs/acceptance-17/evidence/retest-playbook.md b/docs/acceptance-17/evidence/retest-playbook.md new file mode 100644 index 00000000..f695454c --- /dev/null +++ b/docs/acceptance-17/evidence/retest-playbook.md @@ -0,0 +1,424 @@ +# hotcrm 17.0 rc.2 acceptance — retest playbook + +Target: local hotcrm dev server at `http://localhost:4001`. + +## Common setup (auth) + +None of the ten issues states the auth endpoint explicitly. Assumed per task brief: + +```bash +BASE=http://localhost:4001 +curl -s -c /tmp/claude-0/-home-user/f9de7acc-06e5-5667-b535-06e82c336458/scratchpad/cookies.txt \ + -H 'Content-Type: application/json' \ + -X POST $BASE/api/v1/auth/login \ + -d '{"email":"admin@objectos.ai","password":"admin123"}' +# NEEDS DISCOVERY: verify this path (e.g. against server route list / login page network tab) +# before trusting 404s from the calls below. +CK="-b /tmp/claude-0/-home-user/f9de7acc-06e5-5667-b535-06e82c336458/scratchpad/cookies.txt" +``` + +All curl below assume `$BASE` and `$CK` are set. Record IDs (a case id, a lead id, an +opportunity id) must be fetched first from list endpoints (`/api/v1/data/` per +issue #510's description of the data path) — issues do not pin concrete IDs. + +--- + +## #508 — mass_update_stage is wired but cannot execute — every bulk-action invocation path is broken in console 16.1.0 + +**Labels:** bug, prio:p1, upstream:objectstack — **WRITES** (stage update on opportunities) + +Three invocation paths, all broken (verified live 2026-07-28 against 16.1.0 console): + +1. **Modal-path defect (REST-observable, RO probe):** + ```bash + curl -s $CK -o /dev/null -w '%{http_code}\n' \ + "$BASE/api/v1/meta/object/mass_update_stage" + ``` + Issue says the console resolves the action's `target` as an OBJECT name and this GET + returns **400** ("Error loading form"). This probe only mirrors the console's wrong call; + the real fix signal is in the UI paths below. +2. **Selection-bar bulk button — UI only.** Select rows on the opportunity list, click + "Mass Update Stage" in the selection bar. Broken = zero network requests + generic + "Action completed successfully" toast (pure client no-op). +3. **Header toolbar button — UI only.** Multi-select then click the list_toolbar button. + Broken = client-side rejection "This action runs on a single record — select exactly + one row"; `input.selectedIds` never delivered. + +**Direct REST invocation (the closure criterion from the issue):** +```bash +curl -s $CK -H 'Content-Type: application/json' \ + -X POST "$BASE/api/v1/actions/crm_opportunity/mass_update_stage" \ + -d '{"selectedIds":["",""],"params":{"stage":""}}' +# JSON body shape is not spelled out in the issue — selectedIds is the field named in it; +# param name for the stage needs discovery from the action metadata. +``` + +**Minimal Playwright outline (bulk-selection delivery cannot be tested via REST):** +open opportunity list → check 2+ row checkboxes → click "Mass Update Stage" in the +selection bar → assert a `POST /api/v1/actions/crm_opportunity/mass_update_stage` +request fires and its payload contains `selectedIds`. + +**FIXED:** selection-bar click POSTs `/api/v1/actions/crm_opportunity/mass_update_stage` +with `selectedIds`, AND the body's UPDATEs apply (stages actually change). Note the issue's +explicit second condition: sandbox-body UPDATEs are additionally blocked by sharing +middleware when execution context has no user identity ("FORBIDDEN: insufficient +privileges to update") — that must also be fixed (same defect as #521) for full closure. +**STILL BROKEN:** no network request on click / success toast with no request / FORBIDDEN +from the body. + +**Upstream refs:** none numbered in this issue (platform console/runtime defect, split +from hotcrm#491; app-side state lives on branch `claude/views-pages-dashboards-refs-850098` — +action now script-typed with `ctx.recordId` fallback). The FORBIDDEN half is +objectstack#3914 territory per #521. + +--- + +## #509 — Global (objectless) body actions are unreachable — runtime registers them under 'global' but the dispatcher only probes '*' + +**Labels:** bug, prio:p1, upstream:objectstack — **WRITES if fixed** (log_call/log_meeting create activity records); the broken-state probe is effectively RO + +> Note: the issue body was truncated in retrieval (cuts off inside the "Dispatch +> (`POST /api/v1/actions/…`)" section) and the issue has no comments. The dispatch half +> below is corroborated by #522's source-audit comment (same defect, 17.0-rc side). + +App ships two objectless actions: `log_call` and `log_meeting` (locations: +`record_header`, `list_item`, `record_related` — buttons render everywhere, as designed). +Runtime registration keys objectless actions under literal `'global'` +(`collectBundleActions` → `ql.registerAction`); the dispatcher probes a different key, so +dispatch never finds them. + +**Repro (REST):** +```bash +# Normal object-scoped dispatch of a global action — broken path +curl -s $CK -H 'Content-Type: application/json' \ + -X POST "$BASE/api/v1/actions/crm_case/log_call" \ + -d '{"recordId":"","params":{}}' + +# Registration-key probe — per #522's audit this ACCIDENTALLY works because it hits +# the literal 'global' registration key: +curl -s $CK -H 'Content-Type: application/json' \ + -X POST "$BASE/api/v1/actions/global/log_call" \ + -d '{"recordId":"","params":{}}' +``` + +**FIXED:** the first call executes the action body (inner `success:true`, activity/log +record written for the case). +**STILL BROKEN:** inner failure of the "not found" family (on 17.0-rc the exact shape is +documented in #522: HTTP 200, `{"success":true,"data":{"success":false,"error":"Action +'log_call' on object '*' not found"}}`), while the `/actions/global/log_call` probe +succeeds — proving the registration/dispatch key mismatch persists. + +**Upstream refs:** none visible in the retrievable text (split from hotcrm#491; +"upstream `@objectstack/runtime` bug"). The 17.0-rc twin #522 tracks it upstream as +objectstack#3913. + +--- + +## #510 — Analytics/dashboard query path resolves no user token — personal ("my …") dashboard widgets are impossible + +**Labels:** bug, prio:p1, upstream:objectstack — **RO** + +The list path (`/api/v1/data/...`) resolves `{current_user_id}`; the analytics path +(`/api/v1/analytics/...`) resolves NO user token — the literal string reaches the SQL +`WHERE`, matches nothing, widget shows 0. Empirical baseline from the issue: +`{current_user}` → 0, `{current_user_id}` → 0, no owner filter → 10,100,081. Date-macro +tokens DO resolve on the same path (so it is specifically user tokens). + +**Repro:** the issue names the path only as `/api/v1/analytics/...` — the exact +endpoint/verb/body **need discovery** (open any dashboard in the console and copy the +analytics request it makes, then vary the filter). Three-way comparison, same dataset +(crm_case / case_metrics): + +```bash +# 1) no owner filter -> expect large count +# 2) filter owner={current_user_id} -> broken: 0 ; fixed: >0, scoped to admin's rows +# 3) control via LIST path, which already resolves the token: +curl -s $CK "$BASE/api/v1/data/crm_case?filter=..." # e.g. the my_open_cases list view +``` + +**FIXED:** analytics response with `filter: { owner: '{current_user_id}', is_closed: false }` +returns the same rows the list-path "My Cases" view returns for admin (and the generated +SQL in the analytics response shows a substituted user id, not the literal token). Then, +per the issue's closure note: restore the widget on `service_dashboard` and retitle it +back to "My Open Cases by Priority". +**STILL BROKEN:** 0 rows with the token; literal `{current_user_id}` string visible in +the generated SQL. + +**Upstream refs:** none recorded — the crm.app.ts note says "Filed upstream" but no +issue link was ever captured; this hotcrm issue exists precisely to track that gap. + +--- + +## #520 — [17.0-rc] datetime 时间窗过滤返回空集(客服仪表盘全空) + +**Labels:** bug, prio:p1, upstream:objectstack — **RO** + +17.0 driver converts datetime comparison params to epoch-ms while the column stores ISO +text (`2026-07-26T00:00:00.000Z`) → comparison always false. Widget SQL: +`WHERE is_closed = ? AND created_date >= ? AND created_date <= ?`. `date`-typed fields +(close_date) are unaffected. Follow-up comment proved it also on 16.1.0 and found the rule +is directional: **`$lte` on a datetime matches 0 rows; `$gte` silently matches ALL rows** +(lower bound not applied). + +**Repro (analytics query on the `case_metrics` dataset the service_dashboard widgets use — +exact analytics endpoint needs the same discovery as #510; vary only the created_date filter):** + +| filter on `created_date` | broken (16.1/17.0-rc) | fixed | +|---|---|---| +| none | 38 rows | 38 | +| `$gte: 2026-05-01` only | 38 (unfiltered!) | rows actually ≥ bound | +| `$lte: 2026-07-30` only | **0** | ~38 | +| `$gte`+`$lte` last-30-days window | **0** | 29–30 (seed rows in window) | + +**SQL cross-check (definitive, RO):** +```bash +sqlite3 "SELECT typeof(created_date), count(*) FROM crm_case GROUP BY 1" +# broken & fixed: text|38 — column stays ISO text +sqlite3 "SELECT count(*) FROM crm_case WHERE is_closed=0 + AND created_date >= '2026-07-06T00:00:00.000Z' AND created_date <= '2026-08-05T23:59:59.999Z'" +# manual ISO binding returns rows either way; the platform query must now match it +``` +UI signal: service dashboard widgets non-zero with a `dateRange` on `created_date`. +Note: hotcrm #546 removed the `dateRange` block from `service_dashboard` on 16.1.0 and +added a CI guard failing any dashboard that windows a `datetime` field — to retest you +must re-add a dateRange (or query the analytics endpoint directly). + +**FIXED means BOTH upstreams landed:** window returns in-window rows, AND a bare +`YYYY-MM-DD` `$lte` upper bound does not drop same-day records created after 00:00. +**STILL BROKEN:** `$lte` half → 0 rows, or `$gte` half unfiltered, or same-day drop. + +**Upstream refs:** +- objectstack#3912 — this exact defect. **Closed as completed 2026-07-29, fix in the 17.0 train** → primary retest target for rc.2. +- objectstack#3777 — **open**, bug/priority:p1: bare-date `$lte` on datetime drops same-day records. Explicit precondition: #3912 alone does NOT make datetime windows safe. +- Lineage: introduced in 9.10.0 (objectstack#2034), detonated by 17.0 server-side placeholder resolution (objectstack#3582). +- Repro docs: branch `upgrade/objectstack-17`, `docs/upgrade-17/test-report.md` §3 P1. + +--- + +## #521 — [17.0-rc] script action 的 body 写库被 FORBIDDEN(admin 用户) + +**Labels:** bug, **prio:p0**, upstream:objectstack — **WRITES** (closes a case when fixed) + +Object-level script actions (close_case etc.) fail at execution: +`FORBIDDEN: insufficient privileges to update crm_case` — even as built-in admin. Audit +line claims `body executes TRUSTED (context-less engine, RLS/FLS-bypassing)` then +`[BodyRunner] sandboxed action threw ... FORBIDDEN`. Root cause (source-confirmed): +`ctx.api` falls back to a facade with no ExecutionContext/isSystem (body-runner.ts:161-184); +plugin-sharing `canEdit` rejects on `!context.userId` (sharing-service.ts:255); hook +bodies have an `isSystem:true` fallback (engine.ts:978-981) that action bodies lack. + +**Repro (issue path is UI: case detail → 关闭工单 → fill resolution → confirm; REST +equivalent of the same action dispatch):** +```bash +curl -s $CK -H 'Content-Type: application/json' \ + -X POST "$BASE/api/v1/actions/crm_case/close_case" \ + -d '{"recordId":"","params":{"resolution":"rc.2 acceptance retest"}}' +# param name for the resolution field needs discovery from the action metadata +``` + +**FIXED:** action succeeds (no FORBIDDEN anywhere in response or server log) and +`GET $BASE/api/v1/data/crm_case/` shows the case closed with the resolution set. +**STILL BROKEN:** red error in UI / response or `[BodyRunner]` server-log line containing +`FORBIDDEN: insufficient privileges to update crm_case`. +**Caution from the audit comment:** objects with public sharing or no owner field pass by +accident and mask the bug — retest on crm_case (owned + sharing-governed), not a +permissive object. + +**Upstream refs:** objectstack#3914 (filed 2026-07-29; no fix-status update in comments — +still pending as of last comment). Affects close_case / escalate_case / send_email / +mass_update_stage / create_campaign. Repro docs: `upgrade/objectstack-17` test-report §3 P2. + +--- + +## #522 — [17.0-rc] 全局(无 objectName)action 无法派发,且 UI 把失败当成功静默吞掉 + +**Labels:** bug, prio:p1, upstream:objectstack — **WRITES if fixed** (log_call writes an activity); broken-state probe effectively RO + +**Repro (exact, from issue):** +```bash +curl -s $CK -H 'Content-Type: application/json' \ + -X POST "$BASE/api/v1/actions/crm_case/log_call" \ + -d '{"recordId":"","params":{}}' +``` +Broken response — **HTTP 200** with: +```json +{"success":true,"data":{"success":false,"error":"Action 'log_call' on object '*' not found"}} +``` +Same for `export_csv`; flow-type actions through this endpoint give the same (misleading) +error. Registration-key probe (from the audit comment — accidentally works while broken): +`POST $BASE/api/v1/actions/global/log_call`. + +**FIXED (defect 1, dispatch):** inner `data.success:true` and the action body actually ran +(call logged). **STILL BROKEN:** inner `"not found on object '*'"`. +**FIXED (defect 2, envelope/UI):** a failing handler surfaces as an error to the user — +either a non-200 / outer `success:false`, or (console half, objectui#2958) the UI reads +the inner envelope and shows an error toast instead of silently closing. UI half needs a +browser check: trigger 全局 action from a record header while it still fails server-side +and assert an error is displayed (no silent modal close). + +**Upstream refs:** +- objectstack#3913 — runtime half: registration key `'global'` (app-plugin.ts:640-644) vs REST fallback lookup `'*'` (domains/actions.ts:195-204), exact-match engine (engine.ts:731-746); all handler failures wrapped into HTTP 200 via `deps.success()`. Filed 2026-07-29; no closure noted in comments. +- objectui#2958 — console only checks the outer envelope. Filed; no closure noted. +- Repro docs: `upgrade/objectstack-17` test-report §3 P3. + +--- + +## #524 — [17.0-rc] flow 型 action 菜单项无响应;screen flow 服务端失败无任何 UI 回显 + +**Labels:** bug, **prio:p0**, upstream:objectstack — **WRITES** (lead conversion creates account/contact/opportunity on success) + +Two phenomena; **phenomenon 1 is already RESOLVED CRM-side** (per 2026-07-30 comment), +issue kept open as regression-retest hook for phenomenon 2. + +**Phenomenon 1 (dead "Convert Lead" menu item) — retest is a regression check only:** +cause was legacy string `rowActions: ['convert_lead']` in the lead view (removed by #535, +merged via #537). Verified on 16.1.0: single 「转化线索」 entry, confirm dialog appears, +and network shows `POST /api/v1/automation/lead_conversion/trigger → 200 OK` with the +"Conversion Details" first screen rendering. objectui#2960 (string rowActions dispatched +as a type → silent no-op + green toast) still exists upstream but hotcrm no longer hits it. +- **REGRESSED:** an untranslated "Convert Lead" row-menu entry that clicks with zero + requests + green success toast. + +**Phenomenon 2 (screen-flow server failure with zero UI feedback) — UI-only, still blocked +on platform.** Original repro: lead list → row menu → 转化线索 → fill → Submit; server log +shows `Insert operation failed {"object":"crm_account","error":"industry must be one of: ..."}` +while the UI closes the modal with no error and lead status unchanged. +**Important caveat from the comments:** the original failure root cause (#531 industry +enum) was fixed via shared picklists (#490/#516), so the original path may no longer fail — +**you must construct another server-side failure** (any insert/validation failure inside +the flow) to retest the silent-swallow behavior. + +**Minimal Playwright outline:** +1. Arrange a flow step that will fail server-side (e.g. seed/point the conversion at data + violating a validation rule). +2. Lead list → row menu → 转化线索 → fill Conversion Details → Submit. +3. Capture server log confirming the failure occurred. +4. FIXED: UI shows an error (toast/inline), modal does not silently close, lead unchanged + is explained. STILL BROKEN: modal closes, no message, no state change, nothing in UI. + +**Upstream refs:** +- objectui#2958 — `useConsoleActionRuntime.tsx:479-494` treats `{success:false}` (no status/screen) as terminal success; open. +- objectstack#3915 — REST `/api/v1/actions` endpoint has no flow dispatch (MCP path does); open. +- objectstack#3913 — failures wrapped in 200 envelope (shared with #522); open. +- objectui#2960 — string rowActions path; still valid upstream but no longer needed for hotcrm. +- hotcrm #531 (industry enum, root cause of the original failure) — fixed by #490/#516. +- Repro docs: `upgrade/objectstack-17` test-report §3 P5. + +--- + +## #525 — [17.0-rc] 多 tab 新建表单校验失败后丢失已填值且布局错乱 + +**Labels:** bug, **prio:p0**, upstream:objectstack (assignee: yinlianghui) — **WRITES-intent** (attempts case create; while broken nothing persists — no cleanup needed unless fixed path completes a create) + +**UI-only by nature** — REST create works fine (issue states this explicitly); the defect +is the console multi-tab modal (each tab its own react-hook-form + `
` sharing a +formId; footer submits only the first form; Radix TabsContent without forceMount unmounts +and destroys values on tab switch — ModalForm.tsx:518 KNOWN LIMITATION). + +**Critical retest caveats from the comments (2026-07-31):** +- On current `main` (16.1.0, after #515) `description` (required) moved to the FIRST tab — + the "required fields spread across tabs" premise no longer exists there, and NONE of the + three phenomena reproduce on main. +- The original repro was made on `upgrade/objectstack-17` at `dd70aff0`, **79 commits + behind main** (its form lacked `description` entirely — #506). **Merge main into the + upgrade branch first, then retest**; phenomenon ① may vanish, leaving ②③ as the real + objectui scope. To exercise ②③ you need a form whose required fields genuinely span + tabs (or temporarily author one). + +**Minimal Playwright outline (17.0-rc console, post-merge):** +1. Case list → 新建 (Case / SLA / Resolution tabs). +2. Fill Case tab: subject=`MULTITAB-PROBE-RC2`, account, status, priority → Submit. +3. Assert error identifies the missing field AND its tab (① fixed) vs a bare + `description is required` with no tab hint (① broken). +4. Switch to Resolution, fill description, Submit. +5. FIXED: previously filled values retained (subject still `MULTITAB-PROBE-RC2`), single + remaining-field error, tabs render intact, messages localized (中文). + STILL BROKEN: `subject is required; description is required; status is required; + priority is required` (all values lost), then tab headers vanish / broken scrolling; + English engine messages. + +**Upstream refs:** +- objectui#2959 — the multi-tab form defect as filed; open. +- objectui#2153 — closed, but the explicit-sections path was NOT fixed in RC. +- objectstack#3918 — runtime dispatcher drops ValidationError `fields[]` and degrades to 500 (REST path unaffected); filed, no closure noted. +- Repro docs: `upgrade/objectstack-17` test-report §3 P6. + +--- + +## #526 — [17.0-rc] os migrate apply 对运行中服务正在使用的 SQLite 库缺少占用检测 + +**Labels:** bug, prio:p2, upstream:objectstack — **WRITES** (runs DDL against the dev DB; do this on a disposable copy/seed, never the shared dev DB) + +**CLI repro (no REST):** +1. Start the dev server so it holds the SQLite DB open. +2. Against the SAME database file, run `os migrate apply` including a rebuild-type change + (e.g. `replace_unique_index`). +3. Broken: no occupancy warning, no refusal — apply proceeds. Also (audit comment): + `runtime.start()` runs schema-sync DDL BEFORE the confirmation prompt. + +**FIXED:** CLI detects the DB is in use and warns or refuses unless `--force` (the +issue's requested behavior). **STILL BROKEN:** silent apply with server running. +Note the audit correction: the risk is stale prepared statements / SQLITE_BUSY, not file +inode replacement (replace_unique_index is pure index DDL; SQLite column rebuilds swap +tables inside the same file, sql-driver.ts:2816-2877) — data corruption was never actually +reproduced (preventive report). + +**Upstream refs:** objectstack#3917 (filed 2026-07-29 with corrected facts; no closure +noted). Repro docs: `upgrade/objectstack-17` test-report §3 P7. + +--- + +## #528 — [CRM] 9 个孤儿 __search 列待清理(17.0 收紧搜索伴生列供给条件后遗留) + +**Labels:** bug, metadata, prio:p2 (assignee: yinlianghui; state: reopened) — **RO** (plan/inspect ONLY — the original cleanup advice is RESCINDED) + +**⚠️ Verdict reversed in comments: DO NOT run `os migrate apply --allow-destructive`.** +The 9 `__search` columns (crm_competitor, crm_opportunity_line_item, crm_quote_line_item, +crm_task, sys_metadata, …) are NOT orphans — they are live pinyin-search companion columns +supplied by the dev runtime. `os migrate plan` misjudges them because the migrate CLI and +the dev runtime disagree on the schema view. Destructive apply would delete working search +columns (and dev would likely recreate them). Issue was auto-closed 2026-07-30 by a stray +`Closes #528` in a #542 commit message and manually reopened — the reopened state is +correct. `docs/MAINTENANCE.md` §3.1 (main) records this as a known false positive +("do not touch until #3955 lands"). + +**Retest (all read-only):** +```bash +# 1) plan against the live dev DB — broken: 9 objects flagged with DB-only __search columns +os migrate plan +# 2) column-level evidence: +sqlite3 "PRAGMA table_info(crm_task)" # broken: 34 cols incl. __search +# 3) control: a fresh DB created by migrate itself shows 33 cols, no __search, and plan +# reports 0 destructive (per the comment's two-fresh-DB experiment); a dev-runtime-built +# fresh DB shows 34 incl. __search. Also note the reported asymmetry: a brand-new DB +# immediately shows 3 relax_unique_index drift entries. +``` + +**FIXED (i.e. objectstack#3955 landed):** `os migrate plan` no longer flags the runtime's +`__search` companion columns as DB-only/destructive (CLI and runtime schema views agree), +and the fresh-DB asymmetry is gone. **STILL BROKEN:** plan still lists the 9 `__search` +columns as destructive drops. In no case apply the drop. + +**Upstream refs:** objectstack#3955 (open, blocking; includes the repro table and the +relax_unique_index asymmetry). Related process refs: hotcrm #542 (maintenance manual), +#526 (stop the server before any apply). Repro docs: `upgrade/objectstack-17` +test-report §4 L2. + +--- + +## Upstream fix-status summary (as claimed in the comments read above) + +| Upstream issue | Subject | Claimed status | +|---|---|---| +| objectstack#3912 | datetime filter params → epoch-ms vs ISO text (#520) | **Closed as completed 2026-07-29, fix in 17.0 train** — prime rc.2 retest | +| objectstack#3777 | bare-date `$lte` drops same-day datetime rows (#520 precondition) | **Open**, bug/priority:p1 | +| objectstack#3913 | global-action key mismatch + failures in 200 envelope (#509/#522/#524) | Filed, no closure noted | +| objectstack#3914 | action body ctx.api without user identity → FORBIDDEN (#521/#508) | Filed, no closure noted | +| objectstack#3915 | REST actions endpoint lacks flow dispatch (#524) | Filed, no closure noted | +| objectstack#3917 | migrate apply lacks in-use DB detection (#526) | Filed, no closure noted | +| objectstack#3918 | dispatcher drops ValidationError fields[], degrades to 500 (#525) | Filed, no closure noted | +| objectstack#3955 | migrate CLI vs dev runtime schema-view mismatch (#528) | **Open, blocking #528** | +| objectui#2958 | console reads only outer success envelope (#522/#524) | Filed, no closure noted | +| objectui#2959 | multi-tab modal form value loss/layout (#525) | Filed, no closure noted | +| objectui#2960 | string rowActions dispatched as type → silent no-op (#524) | Filed; hotcrm no longer affected (#535/#537) | +| objectui#2153 | earlier multi-tab issue | Closed, but explicit-sections path not fixed in RC | diff --git a/docs/acceptance-17/evidence/w1-crud.md b/docs/acceptance-17/evidence/w1-crud.md new file mode 100644 index 00000000..61d042e5 --- /dev/null +++ b/docs/acceptance-17/evidence/w1-crud.md @@ -0,0 +1,379 @@ +# RC1ACC-W1 — HotCRM 17.0.0-rc.2 acceptance, WRITE phase + +Executor: **W1**. Scope: full CRUD over all 17 CRM objects (REST primary, console UI secondary) plus +targeted checks C1–C6. + +- Server: `http://localhost:4001` (already running; never restarted). +- Evidence: `results.json`, `results_phase2.json` (every REST call with status + body), + `ui_forms_results.json`, `deletes.json`, `shots/*.png` (77 screenshots), `LEDGER.md`. +- Data isolation: every record prefixed `RC1ACC-W1-`. **0 leftovers**, **no seeded record touched**. + +--- + +## 1. Per-object CRUD matrix + +REST = `POST/GET/PATCH/DELETE /api/v1/data/`. "UI form" = console New form. + +| Object | POST | GET | PATCH | DELETE | UI form | Verdict | Evidence | +|---|---|---|---|---|---|---|---| +| crm_account | 201 | 200 | 200 | 200 | **201** | 通过 | `C0nPYZmo0VY1XZ1q`; UI create `xr_uuhWHBy20Yl-A`; 2nd UI attempt correctly 409 `UNIQUE_VIOLATION` on duplicate name | +| crm_contact | 201 | 200 | 200 | 200 | n/t | 通过 | `QpQV0XJFcR1WkU7A`; requires masterDetail parent `crm_account` (see NEW-4) | +| crm_lead | 201 | 200 | 200 | 200 | **failed** | 失败 (UI) / 通过 (REST) | REST `DAZbCqzw-wxQwV2T` clean; **UI New form cannot be submitted at all** → NEW-1 | +| crm_opportunity | 201 | 200 | 200 | 200 | **201** | 通过 | `x-OZsTliHqIUeSjh`; UI `M5zWCn0cfE7HgseX`; C1 + C4 both pass | +| crm_product | 201 | 200 | 200 | 200 | n/t | 通过 | `FC-tjJLj_3wvPLId` | +| crm_quote | 201 | 200 | 200 | 200 | n/t | 通过 | `61XYdhuseVfTikfL`; rollup verified (C5) | +| crm_contract | 201 | 200 | 200 | 200 | partial | 通过 (REST) / 阻塞 (UI) | `Jf7e0OOFRfm-g2gg`; UI create not completed — my driver failed to reach `Status` on the Parties tab. Validation + cross-tab behaviour fully captured (C2) | +| crm_case | 201 | 200 | 200 | 200 | **201** | 通过 | `XDtvf66Sf3ge5zZb`; UI `J2RCLvqD1bFUHv5w` | +| crm_campaign | 201 | 200 | 200 | 200 | n/t | 通过 | `KUTaiMnn-oYO9rcO` | +| crm_forecast | **400 → 201** | 200 | 200 | 200 | n/t | 通过 (with note) | First POST 400 `INVALID_FIELD` for `name` — correct, object has no `name` column (nameField is the `display_title` formula). Retry without `name` → 201 `HPj0OVb3sckgicFh`. See NEW-2 | +| crm_knowledge_article | 201 | 200 | 200 | 200 | n/t | 通过 | `eCH1gIWCmOpDDZZK` | +| crm_task | 201 | 200 | 200 | 200 | n/t | 通过 | `MI_idX5qwudf7xT2` | +| crm_event | 201 | 200 | 200 | 200 | **201** | 通过 | `5iaCvHN1DccFu0bG`; UI `jLfNMx5361zHbj_1`; C6 unblocks R1 | +| crm_event_attendee | 201 | 200 | 200 | 200 | n/t | 通过 | `oitrSpODtdT2xeLq` (EA-00001); C6 | +| crm_opportunity_line_item | 201 | 200 | 200 | 200 | n/t | 通过 | `AI0J0WMC_mc9h_5L`, `XJ1GzZGM-83omZBV`; rollup verified (C5) | +| crm_quote_line_item | 201 | 200 | 200 | 200 | n/t | 通过 | `N5F0MwLxnPObA1qC`; rollup verified (C5) | +| crm_campaign_member | 201 | 200 | 200 | 200 | n/t | 通过 | `_cDCxbMUxtUk716-` (CM-00001) | + +**REST layer: 17/17 objects pass full C-R-U-D.** No object failed a REST create, read, update or delete +(the single forecast 400 was a correct rejection of a field that does not exist). + +UI forms exercised: crm_account, crm_opportunity, crm_case, crm_event (all created successfully), +crm_lead (**blocked**, NEW-1), crm_contract (validation + tab behaviour captured; create not completed — +test-driver limitation, not a product failure). + +--- + +## 2. Targeted checks + +### C1 — opportunity hook derivation — 通过 + +| Step | Call | Result | +|---|---|---| +| Create `stage=proposal, amount=10000` | POST 201 | `probability=60`, `expected_revenue=6000`, `forecast_category=commit`, `stage_entry_date=2026-08-05` | +| Close **without** `win_reason` | PATCH **400** | `{"error":"Win Reason is required","code":"VALIDATION_FAILED","fields":[{"field":"win_reason","code":"required"}]}` | +| Close **with** `win_reason=best_fit` | PATCH 200 | `probability=100`, `expected_revenue=10000`, `forecast_category=closed`, `close_date=2026-08-05` (stamped) | + +All derivations correct. The e2e suite's claim that an unreasoned close is rejected is confirmed at the +REST layer. Probability tracks stage as the single source of truth; `close_date` is stamped on +`closed_won` as documented in `opportunity.hook.ts`. + +### C2 — multi-tab case form (#525 signal) — 通过, and the original repro is NOT constructible + +**Current crm_case New-form layout** (tabs from `role=tab`, fields by visibility per tab): + +| Tab | Fields | Required fields | +|---|---|---| +| **Case** (tab 1) | Case Number, Subject\*, Account\*, Contact, Status\*, Priority\*, Case Origin, Case Owner, Description\* | **all 5** | +| **SLA** (tab 2) | Created Date, First Response Date, SLA Due Date, Resolution Time (Hours), SLA Violated, Escalated, Escalation Reason, Parent Case | none | +| **Resolution** (tab 3) | Resolution, Internal Notes, Customer Satisfaction, Customer Feedback, Customer Signature, Closed Date, Is Closed | none | + +**Every required field lives on tab 1.** This confirms the retest playbook's note that #515 moved +`description` to tab 1. A cross-tab required-field failure therefore **cannot be constructed on +crm_case** on current main — stated explicitly as instructed, rather than forced. + +I still ran the value-preservation half of #525 on crm_case, and then found the **cross-tab layout does +still exist on crm_contract**, so I ran the real repro there: + +**crm_contract New form** — required fields genuinely split across two tabs: + +| Tab | Required fields | +|---|---| +| **Parties** (tab 1) | Account\*, Primary Contact\*, Status\* | +| **Terms** (tab 2) | Contract Term (Months)\*, Start Date\*, End Date\*, Contract Value\* | +| Signing & Documents | none | +| Notes | none | + +Experiment (screenshots `uif2-contract-before-fail` → `uif2-contract-survival`): +1. Filled `Account` + `Primary Contact` on **Parties**. +2. Filled `Special Terms` sentinel on **Notes**. +3. Submitted with the **Terms** tab required fields empty. +4. → Blocked client-side. **Zero network writes fired.** Errors: `Status is required`, + `Contract Term (Months) is required`, `Start Date is required`, `End Date is required`, + `Contract Value is required`. +5. Switched back to other tabs and re-read every value. + +**Answer to the #525 question: filled values on other tabs DO survive the failed submit — there is no +data loss.** +- `Special Terms` (text, Notes tab) = `"RC1ACC-W1-CROSSTAB-SENTINEL"` — survived. +- The same holds on crm_case: `Internal Notes` and `Resolution` both survived a failed submit + (`c2d-05-survival-check`). +- The underlying **lookup values also survived** — proven by the second submit, which reported *only* + `Status is required` and did **not** re-report `Account`/`Primary Contact`. + +One display-level caveat is recorded as NEW-5: the lookup **buttons** re-render showing `"Select..."` +after the failed submit even though the value is retained. + +### C3 — lead conversion — 通过 (rc.0 industry mismatch is FIXED) + +Ran `convert_lead` end-to-end in the UI twice. Flow: `Convert Lead` button → `Confirm Action` dialog → +`POST /api/v1/automation/lead_conversion/trigger` 200 (`status:"paused"`, screen node +`Conversion Details`) → screen submit → `.../runs//resume` 200. + +**Run 1** — lead industry `nonprofit` (label "Non-profit" — label/value divergence, exercises #626 +normalized-name matching), `createOpportunity` left false: + +| Assertion | Result | +|---|---| +| Lead marked converted | `status=converted`, `is_converted=true`, `converted_date=2026-08-05T02:55:55.774Z` | +| Account created | `XnswVU4p3NRlVnMg` "RC1ACC-W1-Navy Systems Corp", **`industry=nonprofit`** | +| Contact created | `wFyQmEQI49uFyp9v` "RC1ACC-W1-Grace Hopper" | +| Opportunity | not created (checkbox false) — correct for the input | +| Flow summary | `acted:5, skipped:3`, no errors in devserver.log | + +**Run 2** — lead industry `media` (label "Media & Entertainment"), `createOpportunity` **ticked**: + +| Assertion | Result | +|---|---| +| Lead marked converted | `is_converted=true` | +| Account | `T0zMC76xUMvcD--T` "RC1ACC-W1-Bletchley Analytics", **`industry=media`** | +| Contact | `DLYEv33yitW1UPf0` "RC1ACC-W1-Alan Turing" | +| **Opportunity** | `8uv9Mk4OrC4n_cmN` "RC1ACC-W1-Converted Opp", `amount=42000`, `stage=prospecting` | +| Flow summary | `acted:6, skipped:3` | + +**Industry mapping verdict: PASS.** Both `nonprofit` and `media` — values whose display labels differ +materially from the stored value — mapped cleanly from `crm_lead.industry` onto `crm_account.industry` +with no enum error. `crm_account` accepts the same `INDUSTRY_OPTIONS` picklist as `crm_lead`. The rc.0 +failure mode did not recur, and `devserver.log` contains no `lead_conversion` or industry errors. + +One new defect was found in the screen-flow UI along the way — see **NEW-6** (silent no-op submit). + +### C4 — kanban drag — 通过 (drag verifiable and working), but the contract board does not exist + +**Part 1 — there is no contract status board.** `crm_contract` offers exactly four visualizations in +the console: **Grid, Calendar, Gantt, Timeline** (`c4-01-contract-views`). `src/views/contract.view.ts` +declares `all_contracts`, `renewal_calendar`, `contract_gantt`, `contract_timeline` — no kanban. +`grep -rn "kanban" src/views/` shows boards only on **opportunity** (`pipeline_kanban`), **task** +(`task_board`), **quote** and **knowledge_article**. So the C4 premise is not constructible for +contracts on current main. Not a regression I can attribute — just an absent view. + +**Part 2 — drag itself is verifiable and works.** Ran the equivalent test on +`crm_opportunity/view/pipeline_kanban` (same dnd library, status-grouped board), dragging **my own** +card only — never a seeded record — with Playwright's real mouse API (`mouse.move` → `mouse.down` → +30 interpolated `mouse.move` steps → `mouse.up`). + +| Attempt | Target | PATCH fired? | Result | +|---|---|---|---| +| 1 | dropped over Closed Lost | **yes** | `PATCH /api/v1/data/crm_opportunity/M5zWCn0cfE7HgseX` **400** `{"error":"Loss Reason is required","code":"VALIDATION_FAILED"}` — server correctly refused an unreasoned close (mirrors C1) | +| 2 | Needs Analysis column | **yes** | `PATCH ...` **200** | + +Post-drag read-back of attempt 2: +`stage=needs_analysis`, `probability=40`, `expected_revenue=10000` (25000 × 40%), +`forecast_category=best_case`, `stage_entry_date=2026-08-05`. + +**Verdict: kanban drag-and-drop works, fires a real PATCH, and the opportunity hook re-derives every +dependent field correctly.** rc.0's observation stands: synthetic `dispatchEvent` is ignored by the dnd +library, but the real mouse API drives it correctly — so this **is** automatable, contrary to a +"not verifiable by automation" conclusion. + +### C5 — line items on parents (`controlled_by_parent`, #547 grants) — 通过 + +Both line-item objects create, read, update and delete cleanly with the parent lookup supplied +(`crm_opportunity`/`crm_quote` + `crm_product`). No permission error — #547's grants hold. + +**Quote rollup** (`quote_total_rollup`, afterInsert/afterUpdate/afterDelete): + +| Step | Parent quote | +|---|---| +| QLI created qty=2 × 500 | `subtotal=1000`, `total_price=1000`, `discount_amount=0` | +| QLI updated qty=2→4 | `subtotal=2000`, `total_price=2000` | + +**Opportunity rollup** (`opportunity_amount_rollup`) — tested on an **open** deal, since the hook +deliberately skips closed deals: + +| Step | Parent opportunity | +|---|---| +| Created `amount=1`, stage `qualification` | `amount=1` | +| OLI created qty=4 × 250 | `amount=1000`, `expected_revenue=250` | +| OLI updated qty=4→10 | `amount=2500`, `expected_revenue=625` | + +Both rollups are declared `async: true`, so a read issued immediately after the write can observe the +pre-rollup value; re-reading after a short delay shows the settled figure. That is documented intent, +not a defect — worth knowing for other executors writing assertions. + +Adding a line item to a **closed** opportunity correctly leaves `amount` untouched (the hook's +"a closed deal's value is settled" guard) — observed and confirmed against the hook source. + +### C6 — crm_event / crm_event_attendee — 通过 (R1's 阻塞 is now unblocked) + +Both objects had **zero** rows at start (confirms #671). Created via REST: +- `crm_event` `5iaCvHN1DccFu0bG` "RC1ACC-W1-Discovery Call" (POST 201) +- `crm_event_attendee` `oitrSpODtdT2xeLq` EA-00001 (POST 201), linked to the event + a contact + +Both **detail pages render correctly**: +- `shots/c6-02-event-detail.png` — title, Event Type / Status / Start / Assigned To highlight strip, + a working status path control (Planned → Held → Cancelled → No Show), Details + Related(1) tabs, + Event Information, Schedule, Related Records (resolved to "ACC-000002 - RC1ACC-W1-Acme Industrial"), + Owner, and the Discussion feed. +- `shots/c6-04-attendee-detail.png` — EA-00001, Event / Attendee Type / Response / Organizer strip, + Attendee panel with the contact resolved to "RC1ACC-W1-Ada Lovelace", and a Discussion feed showing + the field-history entry `Response: No Response → Accepted`. + +List pages for both objects also render (`c6-01`, `c6-03`). A `crm_event` record was additionally +created through the console **New form** (`jLfNMx5361zHbj_1`, POST 201) — the event UI form works. + +**R1 should re-run its event/event_attendee detail-page checks: the pages are sound; the blocker was +purely the absence of seed rows (#671), not a page defect.** Note both objects are back to 0 rows after +my cleanup, so R1 will need to create a row again (or #671 should be fixed with seeds). + +--- + +## 3. Suspected NEW findings + +### NEW-1 — `visibleOn` is ignored on console form fields, making crm_lead impossible to create in the UI — **HIGH** + +**Reproduction** +1. Console → HotCRM → Leads → **New**. +2. Fill First Name, Last Name, Company, Email; set Status = New. +3. Click **Create**. + +**Observed** — submit is blocked with five errors for fields that should not apply to a brand-new lead: +`Disqualification Reason is required`, `Duplicate Of is required`, `Duplicate Of Lead is required`, +`Duplicate Of Contact is required`, `Duplicate Status is required`. All five render on the form with a +red required asterisk. Screenshot: `shots/uif-crm_lead-02-after-submit.png`. Zero network writes fire. +The form even prints its own helper text **"Required when status is Unqualified"** directly above the +error **"Disqualification Reason is required"** — self-contradictory on screen. + +**There is no way to satisfy the form**: it demands the new lead be declared a duplicate of *both* an +existing lead *and* an existing contact simultaneously — mutually exclusive by design +(`duplicate_of_lead` and `duplicate_of_contact` are gated on `duplicate_of_type` being one or the other). +**crm_lead cannot be created through the console at all.** REST creation is unaffected (201). + +**Attribution: PLATFORM (console form renderer).** The CRM metadata is correct. Every one of the five +fields is declared in `src/views/lead.view.ts` with a `visibleOn` predicate that is false for a new lead: + +```ts +// src/views/lead.view.ts +{ field: 'disqualification_reason', required: true, visibleOn: 'status == "unqualified"' }, +// DUPLICATE_LINK_FIELDS: +{ field: 'duplicate_of_type', required: true, visibleOn: 'disqualification_reason == "duplicate"' }, +{ field: 'duplicate_of_lead', required: true, visibleOn: 'duplicate_of_type == "crm_lead"' }, +{ field: 'duplicate_of_contact', required: true, visibleOn: 'duplicate_of_type == "crm_contact"' }, +{ field: 'duplicate_status', required: true, visibleOn: 'disqualification_reason == "duplicate"' }, +``` + +The renderer displays these fields when their `visibleOn` is false **and** enforces their `required: true`. +The object-level counterparts use `requiredWhen` predicates (`lead.object.ts:369,375`) and are correctly +*not* enforced by the server. So: server honours the condition, form does not. + +Likely the single highest-impact UI defect in this run — it blocks the primary "new lead" path, which is +the app's headline demo flow. + +### NEW-2 — `crm_forecast.display_title` (the object's own `nameField`) evaluates to `null` for every row — **MEDIUM** + +**Reproduction** +``` +GET /api/v1/data/crm_forecast +``` +Every row — my created row **and all 8 seeded rows** — returns `display_title: null`, while +`period_label` and `period_start` are both populated: +``` +{'id':'-kN4v3de7J32S1B0','period_label':'Q3 2026','period_start':'2026-07-01','display_title':None} +{'id':'CJpIhxXM5hq7-Dbq','period_label':'Aug 2026','period_start':'2026-08-01','display_title':None} +``` +Mine: `period_label='RC1ACC-W1-Q3 2026'`, `period_start='2026-08-05'` → `display_title=None`. + +`forecast.object.ts` sets `nameField: 'display_title'` with +``expression: F`record.period_label + " (" + text(record.period_start) + ")"` ``. + +**Contrast** — other formula fields evaluate fine: +`crm_contact.full_name` = `"RC1ACC-W1-Ada Lovelace"`, `crm_lead.full_name` = `"RC1ACC-W1-Grace Hopper"`, +`crm_opportunity.days_in_stage` = `0`. + +The distinguishing feature of the failing expression is the **`text()` coercion of a `date` field**; +the working ones concatenate text or compute numerics. Suspected: `text()` over a date returns +null/undefined and poisons the whole concatenation. + +**Attribution: likely PLATFORM (formula engine `text()` on a date operand).** Consequence is +user-visible — forecast records have no resolvable record title anywhere the nameField is used. +Not fully isolated; a one-line formula unit test would settle it. + +### NEW-3 — misleading error text and object name when a cascade-blocked delete fails — **LOW** + +**Reproduction** (converted-lead chain still intact) +1. `DELETE /api/v1/data/crm_opportunity/` → + **400** `"Cannot edit a converted lead (attempted: converted_opportunity). Make changes on the converted record…"` + — deleting an *opportunity* reports a *lead* edit error. +2. `DELETE /api/v1/data/crm_account/` → + **400** `"Cannot delete contact: still referenced by 1 open opportunity(ies), 0 active quote(s), 0 active contract(s)"` + — deleting an **account** reports **"Cannot delete contact"**. The object name in the message is + simply wrong (it is the cascade child's guard message surfacing unmodified). + +All three succeeded on retry once the chain was torn down in the right order, so this is cosmetic — +but the account message names the wrong object, which will send people down the wrong path. +**Attribution: CRM (hook/guard messages in `lead.hook.ts` / `contact.hook.ts`) surfaced verbatim by the +platform's cascade layer.** + +### NEW-4 — missing required master-detail parent returns 403 PERMISSION_DENIED, not a 400 required-field error — **LOW** + +**Reproduction** +``` +POST /api/v1/data/crm_contact {"first_name":"X","last_name":"Y","email":"x@example.com"} +``` +→ **403** `{"error":"[Security] Access denied: insert on 'crm_contact' requires edit access to its master record (detail record has no master reference)","code":"PERMISSION_DENIED"}` + +`crm_contact.crm_account` is `Field.masterDetail('crm_account', { required: true, … })`, so the record is +genuinely invalid — but the response presents a **validation** problem as a **security** problem. +Every other missing-required-field case in this run returned a clean +`400 VALIDATION_FAILED` with a `fields[]` array (e.g. win_reason in C1, `name` on forecast). +A UI or integration mapping 403 → "you lack permission" will mislead the user. +**Attribution: PLATFORM (master-detail insert guard runs before required-field validation).** + +### NEW-5 — lookup fields re-render as "Select..." after a failed form submit although the value is retained — **LOW/MEDIUM (cosmetic but alarming)** + +**Reproduction** +1. Console → Contracts → **New**. +2. On **Parties**, pick Account and Primary Contact via the lookup pickers. +3. Fill something on another tab; submit with the **Terms** tab required fields empty → validation fails. +4. Return to **Parties**. + +**Observed** — both lookup buttons display `"Select..."` as if cleared +(`shots/uif2-contract-survival.png`), while plain text fields on other tabs keep their values. + +**The data is NOT lost** — the very next submit reported only `Status is required` and did **not** +re-report `Account`/`Primary Contact`, proving the form state still held both references. So this is a +**display regression in the lookup control after re-mount**, not data loss. It nonetheless reads as data +loss to a user, who will re-pick the values. +**Attribution: PLATFORM (console lookup widget loses its display label on re-mount; value survives).** + +### NEW-6 — screen-flow submit is a silent no-op when a conditionally-revealed required field is empty — **MEDIUM** + +**Reproduction** +1. Open any qualified lead → **Convert Lead** → **Continue**. +2. On the `Conversion Details` screen, tick **Create Opportunity?**. This reveals two new fields: + **Opportunity Name \*** (required) and **Opportunity Amount**. +3. Leave **Opportunity Name** empty and click **Submit**. + +**Observed** — nothing happens. Measured over 15 s: **0 `resume` requests**, **0 visible error messages** +(`c3f-03-silent-noop.png`), dialog stays open, no field highlight, no toast. The user has no indication +why Submit does nothing. Filling Opportunity Name and resubmitting works immediately +(`POST .../runs//resume` **200**, `acted:6`). + +This cost real diagnosis time — my first two conversion attempts appeared to "hang" and the lead stayed +unconverted with no error anywhere, including the server log. +**Attribution: PLATFORM (screen-flow renderer suppresses required-field validation feedback).** +Distinct from NEW-1: here the conditional reveal works correctly, but the validation message is missing. + +--- + +## 4. Known open issues — behaviour observed + +| Issue | Match? | Note | +|---|---|---| +| **#620** `owner` `cel os.user.id` default fails to evaluate | matches | Warnings present throughout the run; `owner` still resolved to the acting user on every object I created, so no functional impact observed in this phase | +| **#617** hooks run over seed rows | matches | `case_escalation` `runAs:'user'` REFUSED errors are in the log from boot, unrelated to my writes | +| **#650** decision-node singular condition inert | not exercised | The lead_conversion flow's decision path behaved correctly for both branches (opportunity created only when requested) | +| **#664** address fields render as raw JSON | matches | `billing_address` returns as a JSON blob; contract form exposes flattened Street/City/State/ZIP/Country alongside a "Billing Address" field | +| **#671** crm_event has no seeds | **confirmed** | `crm_event` and `crm_event_attendee` both had 0 rows at start. C6 created rows, proved the pages work, then removed them — **so the condition is back**. Recommend seeding both objects | + +Not re-filed, per instructions. + +## 5. Notes for other executors + +- Account **name uniqueness IS enforced** (`409 UNIQUE_VIOLATION` on a duplicate name), despite the + comment in `account.object.ts` saying `account_name_unique` was removed in 7.6 — a normalized-name + unique index (`name_normalized`) appears to still be live. Behaviour is correct; only the comment is stale. +- Both line-item rollups are **async** — do not assert parent totals in the same tick as the child write. +- The console SPA needs ~8–10 s on first navigation; shorter waits produce a false + "no New button on the list page" reading. I hit this and it was a test artifact, not a defect. +- The console form dialog is itself `[role="dialog"]`; lookup pickers open as a **second** dialog. + Target `.last()`. diff --git a/docs/acceptance-17/evidence/w1-ledger.md b/docs/acceptance-17/evidence/w1-ledger.md new file mode 100644 index 00000000..7ba90753 --- /dev/null +++ b/docs/acceptance-17/evidence/w1-ledger.md @@ -0,0 +1,56 @@ +# RC1ACC-W1 Data Ledger + +All records created by W1 during the 17.0.0-rc.2 acceptance run. **All deleted; 0 leftovers verified.** +Final verification scan: `LEFTOVER RC1ACC-W1 RECORDS: 0` across all 17 objects. + +| # | Object | ID | Name / label | DELETE | +|---|--------|----|--------------|--------| +| 1 | crm_account | C0nPYZmo0VY1XZ1q | RC1ACC-W1-Acme Industrial | 200 | +| 2 | crm_account | xr_uuhWHBy20Yl-A | RC1ACC-W1-UI Account (UI form) | 200 | +| 3 | crm_account | XnswVU4p3NRlVnMg | RC1ACC-W1-Navy Systems Corp (C3 convert) | 200 | +| 4 | crm_account | T0zMC76xUMvcD--T | RC1ACC-W1-Bletchley Analytics (C3 convert) | 400 → **200 on retry** | +| 5 | crm_contact | QpQV0XJFcR1WkU7A | RC1ACC-W1-Ada Lovelace | 200 | +| 6 | crm_contact | wFyQmEQI49uFyp9v | RC1ACC-W1-Grace Hopper (C3 convert) | 200 | +| 7 | crm_contact | DLYEv33yitW1UPf0 | RC1ACC-W1-Alan Turing (C3 convert) | 400 → **200 on retry** | +| 8 | crm_lead | DAZbCqzw-wxQwV2T | RC1ACC-W1-Grace Hopper | 200 | +| 9 | crm_lead | Vdevm-f8EtXaVTff | RC1ACC-W1-Alan Turing | 200 | +| 10 | crm_opportunity | x-OZsTliHqIUeSjh | RC1ACC-W1-Platform Deal (C1) | 200 | +| 11 | crm_opportunity | f1RWTRo2X-T6SF8O | RC1ACC-W1-Open Rollup Deal (C5) | 200 | +| 12 | crm_opportunity | M5zWCn0cfE7HgseX | RC1ACC-W1-UI Opp (UI form + C4 drag) | 200 | +| 13 | crm_opportunity | OYGW35Bu4tPbjLaX | RC1ACC-W1-UI Opp (dup from timed-out run) | 200 | +| 14 | crm_opportunity | 8uv9Mk4OrC4n_cmN | RC1ACC-W1-Converted Opp (C3) | 400 → **200 on retry** | +| 15 | crm_product | FC-tjJLj_3wvPLId | RC1ACC-W1-Enterprise Seat | 200 | +| 16 | crm_quote | 61XYdhuseVfTikfL | RC1ACC-W1-Q1 Quote | 200 | +| 17 | crm_contract | Jf7e0OOFRfm-g2gg | CTR-0001 (RC1ACC-W1-Master Subscription) | 200 | +| 18 | crm_case | XDtvf66Sf3ge5zZb | RC1ACC-W1-Login failure | 200 | +| 19 | crm_case | J2RCLvqD1bFUHv5w | RC1ACC-W1-UI Case (UI form) | 200 | +| 20 | crm_campaign | KUTaiMnn-oYO9rcO | RC1ACC-W1-Summer Webinar | 200 | +| 21 | crm_forecast | HPj0OVb3sckgicFh | RC1ACC-W1-Q3 2026 | 200 | +| 22 | crm_knowledge_article | eCH1gIWCmOpDDZZK | RC1ACC-W1-How to reset SSO | 200 | +| 23 | crm_task | MI_idX5qwudf7xT2 | RC1ACC-W1-Follow up call | 200 | +| 24 | crm_event | 5iaCvHN1DccFu0bG | RC1ACC-W1-Discovery Call (C6) | 200 | +| 25 | crm_event | jLfNMx5361zHbj_1 | RC1ACC-W1-UI Event (UI form) | 200 | +| 26 | crm_event_attendee | oitrSpODtdT2xeLq | EA-00001 (C6) | 200 | +| 27 | crm_opportunity_line_item | AI0J0WMC_mc9h_5L | RC1ACC-W1-OLI | 200 | +| 28 | crm_opportunity_line_item | XJ1GzZGM-83omZBV | RC1ACC-W1-OLI2 (C5) | 200 | +| 29 | crm_quote_line_item | N5F0MwLxnPObA1qC | RC1ACC-W1-QLI | 200 | +| 30 | crm_campaign_member | _cDCxbMUxtUk716- | CM-00001 | 200 | + +## Hook/flow side-effect records (created BY my test data, also removed) + +| Object | ID | Subject | DELETE | +|--------|----|---------|--------| +| crm_task | eLuXDag-mtdP2ZSp | Follow up with qualified lead (DAZbCqzw-wxQwV2T) | 200 | +| crm_task | EqLaX3OvzpmCpuH3 | Escalated case XDtvf66Sf3ge5zZb needs attention | 200 | +| crm_task | zZtM2WoDw0O1RpGA | Activate new customer for opportunity x-OZsTliHqIUeSjh | 200 | + +Also deleted: one throwaway probe account `7KfnnI5MSmeMOU2X` (RC1ACC-W1-Probe), DELETE 200. + +**No seeded record was modified or deleted at any point.** + +## Delete-ordering note + +Three deletes returned 400 on the first pass purely because of ordering — the converted-lead chain +(lead → account/contact/opportunity) must be torn down opportunity-last-but-lead-first. All three +succeeded on retry once the converted lead row was gone. See NEW-3 in RESULTS.md for the misleading +error text this surfaces. diff --git a/docs/acceptance-17/parallel-task-packages.md b/docs/acceptance-17/parallel-task-packages.md new file mode 100644 index 00000000..dca12cfa --- /dev/null +++ b/docs/acceptance-17/parallel-task-packages.md @@ -0,0 +1,115 @@ +# rc.2 验收:可并行执行的任务包 + +本轮验收由单一会话串行编排(写数据的阶段互斥执行)。本文件是**备选投放方式**: +每个任务包自带数据隔离约定,可分别开一个 chat 并行跑,互不干扰。 + +## 通用前置(每个包都要做) + +```bash +cd /home/user/hotcrm +# dev server 若未运行: +pnpm dev # 端口 4001;等 /api/v1/health 返回 200(约 30s) + +# 鉴权(注意:不是 /auth/login,那个路径会返回 500) +TOKEN=$(curl -s -X POST http://localhost:4001/api/v1/auth/sign-in/email \ + -H 'Content-Type: application/json' \ + -d '{"email":"admin@objectos.ai","password":"admin123"}' | node -pe 'JSON.parse(require("fs").readFileSync(0)).token') +# 之后所有请求带:-H "Authorization: Bearer $TOKEN" +``` + +浏览器测试:Playwright,`executablePath: '/opt/pw-browsers/chromium'`;用 +`context.request.post` 打同一鉴权端点,让 cookie 落到浏览器上下文。 + +## 数据隔离总则 + +| 约定 | 说明 | +|---|---| +| **记录前缀** | 每个包用**自己的前缀**(下表 P1–P5),写进记录的名称/主题/标题字段 | +| **禁止改种子** | 不修改、不删除任何种子记录;只删自己建的 | +| **台账** | 每包维护 `LEDGER.md`(对象 + id + 名称),结束时按台账清理并记录 DELETE 状态码 | +| **共享 dev server** | 所有包共用 4001 的同一个库。**不要重启 server、不要 `demo:reset`、不要 `os migrate apply`** —— 会毁掉其它包的在途数据 | +| **只读包例外** | P5 只读,可与任意包同时跑 | + +> 若要真正物理隔离,改用各自的库与端口: +> `pnpm exec objectstack dev -p 40NN -d file:/tmp/rc2-PN/data.db`(首启会自动灌种子)。 +> 代价是每包一次冷启动(约 30s)+ 各自的种子基线数字不同。 + +--- + +## P1 — 对象 CRUD 全量(17 对象) + +- **前缀**:`RC1ACC-P1-` +- **写入**:是 +- **范围**:17 个对象各跑 Create→Read→Update→Delete;REST 为主证据,6 个核心对象 + (account/opportunity/case/lead/contract/event)加测 UI 表单 +- **重点**: + - 商机 hook:stage=proposal + amount=10000 → probability/expected_revenue 派生; + 改 closed_won **必须带 `win_reason`**(否则 400,e2e 已固化该规则) + - 工单多 tab 表单(#525 现场):记录当前 tab 结构与必填字段所在 tab;若因 #515 已把 + `description` 移到第一个 tab 而**无法构造**跨 tab 校验失败,如实说明并给出当前布局证据 + - `crm_event` / `crm_event_attendee` 种子为 0 行(#671),需自建记录才能测详情页 + - 合同看板拖拽:必须用 `mouse.move/down/up` 真实鼠标事件(合成事件 dnd 库不吃); + 若确实无法自动化,写"不可自动化验证",不得判通过或失败 + +## P2 — Actions 全量(26 个)+ 相关阻塞项复测 + +- **前缀**:`RC1ACC-P2-` +- **写入**:是 +- **范围**:12 个业务 action + 14 个活动类 action(log_call/log_meeting/schedule_meeting × + lead/contact/account/opportunity/case + 全局入口) +- **同时复测**:#521(script action 写库 FORBIDDEN)、#522/#509(全局 action 派发 + + 200 信封吞错)、#508(批量 action 三条调用路径)、#524(flow 型 action) +- **判据**:入口可见 → 参数弹窗 → 执行 → 数据联动正确 → **失败时 UI 必须有可见报错** + (rc.0 的静默吞错是重点回归点:HTTP 200 但内层 `{"success":false}` 且弹窗直接关闭) + +## P3 — Flows 全量(24 条) + +- **前缀**:`RC1ACC-P3-` +- **写入**:是 +- **范围**:按触发方式分三组(record-change 16 条 / scheduled 7 条 / bootstrap 1 条), + 逐条构造触发条件并留服务端日志证据 +- **重点**:已立案的 **#684** —— 7 条记录变更流未声明 `runAs: 'system'`,被系统写入触发时 + 数据操作被拒。**必须验证用户会话驱动的写入路径是否正常**,这决定 #684 的严重度; + 结论请回填到 #684 的评论 +- **注意**:`case_escalation` 有自触发重入警告(与已关闭的 #507 同形),留意是否复发 + +## P4 — Profile 权限矩阵(5 profiles) + +- **前缀**:`RC1ACC-P4-`(含测试用户名) +- **写入**:是(建用户 + 建记录) +- **范围**:sales_rep / sales_manager / service_agent / marketing_user / system_admin + 各建一个用户,实测对象级 CRUD、readScope/OWD、sharing 规则、字段级权限 +- **建用户方式**:`POST /api/v1/auth/admin/create-user` + `sys_user_position` 绑定 + (profile 绑定即 position 绑定,权限集名 = position 名),之后**必须触发 sharing 规则重算**; + `system_admin` 无对应 position,断言请用 dev-admin 种子账号 +- **已知缺口(不要重复立案)**:#548(owner 重指派不移动访问权)、#549(territory/team 共享 + 的客户,其报价/任务/合同相关列表 rep 读不到 —— 这是**现状即预期**,per #549 台账) +- **易错点**:17.0 的 `allowExport` 反转 —— 即便 system_admin 对 crm_quote/campaign/task + 也会得到 `EXPORT_NOT_PERMITTED`;唯一被掩码的字段是 `crm_case.internal_notes`(对 sales_rep) + +## P5 — 只读复核(可与任何包并行) + +- **前缀**:无(不写数据) +- **范围**:#510(分析查询路径解析不到用户 token,"我的…" widget 不可能)、 + #528(孤儿 `__search` 列)、#520/#523 的二次确认 +- **已知**:全新 rc.2 库上 PRAGMA 扫描得到 **5 个 `__search` 列** + (crm_event / crm_opportunity / crm_opportunity_line_item / crm_quote_line_item / crm_task), + 且都是 rc.2 运行时自建、非旧版遗留 +- **注意**:`os migrate plan` 默认打到 `standalone.db`,**不是** dev server 用的 `dev.db`; + 要复测 #528 必须 `--database-url file:/home/user/hotcrm/.objectstack/data/dev.db`。 + **只跑 `plan`,绝不跑 `apply`**(#526 未验;且会破坏其它包的数据) + +--- + +## 证据规范(所有包一致) + +每项判定必须附以下之一,不接受"看起来正常": + +1. **HTTP 状态码 + 响应体**(REST 路径的首选证据) +2. **服务端日志行**(`/tmp/.../scratchpad/devserver.log`,按时间戳定位) +3. **截图**(UI 路径必须有) + +判定值:**通过 / 失败 / 阻塞**(阻塞 = 被其它缺陷挡住,无法执行到判定点)。 + +宣告"缺失/损坏/不可达"这类严重结论前,必须用截图或服务端权威响应复核 —— +导航后立即做 DOM 查询会因 React 未水合返回空结果,rc.0 那轮就因此产生过误判。 diff --git a/docs/acceptance-17/test-plan.md b/docs/acceptance-17/test-plan.md new file mode 100644 index 00000000..0e6cfb2f --- /dev/null +++ b/docs/acceptance-17/test-plan.md @@ -0,0 +1,145 @@ +# HotCRM × ObjectStack 17.0 GA 验收测试计划(rc.2 / 当前 main) + +> 修订自 `upgrade/objectstack-17` 分支的 `docs/upgrade-17/test-plan.md`(rc.0 底本)。 +> 本版针对 **当前 main**:`@objectstack/*` **17.0.0-rc.2**(#579 升 rc.1,#663 升 rc.2), +> `engines.protocol ^17.0.0-rc.2`。任务原文以 rc.1 为目标;main 已于 2026-08-03 前进到 +> rc.2,验收以当前 main 为准,判定注明 rc.2。 + +- **环境**:远程沙箱独立容器,分支 `claude/hotcrm-17-rc1-acceptance-hj869o`;dev server + `objectstack dev -p 4001`,SQLite 库 `.objectstack/data`(全新种子);登录 + `admin@objectos.ai / admin123` +- **原则**:**只测不修** —— 发现问题建 issue(bug 标签;平台侧加 `[17.0-rc][疑似平台]`), + 不改 `src/` 代码;与既有 issue 台账去重后再立新 +- **数据隔离**:所有测试写入的记录名/主题统一加前缀 **`RC1ACC-`**(按执行者再加分段号,如 + `RC1ACC-P3-`),便于并行会话隔离与事后清理;不修改、不删除种子记录(删除测试只删自建记录) +- **证据规范**(沿用 rc.0 报告):每项判定必须附 **请求状态码 / 服务端·控制台输出 / 截图** + 之一;不接受"看起来正常"。判定值:**通过 / 失败 / 阻塞**(阻塞=被其它缺陷挡住无法执行) + +## 与 rc.0 底本的差异(2026-08-05 修订) + +| 变化 | 说明 | +|---|---| +| AI agents 已裁撤(#512) | 旧计划 §7「AI(2 agents/6 skills)」删除;skills 仍在,仅验证元数据加载 | +| competitor 模块已移除(#551) | 对象清单删去 `crm_competitor` | +| 活动模型上线(#592/#670) | 新增 `crm_event`、`crm_event_attendee` 对象、activity 仪表盘、14 个活动类 action | +| rc.0 发现的 #527/#529/#530/#531/#535 已修复关闭 | 不再复测;#520 系列见 §8 | +| 上游 #3912/#3913/#3914/#3915/#3955 已随 rc.1/rc.2 发布 | §8 阻塞项预期部分解除,逐条实证 | +| 元数据普查(`pnpm validate`,rc.2) | **17 对象/344 字段/1 app/14 视图/8 页面/5 仪表盘/10 报表/26 actions/24 flows/12 岗位/6 权限集** | + +## 0. 静态与自动化基线(先行,必须全绿) + +| 项 | 内容 | 结果判据 | +|---|---|---| +| S1 | `pnpm verify`(validate+typecheck+lint+hygiene+build+vitest) | 退出码 0(vitest 52 文件 / 1281 例) | +| S2 | `pnpm test:e2e`(Playwright headless,同 CI e2e.yml) | 全过 | +| S3 | dev server 冷启动 + 种子加载 | 无 ERROR,登录可用 | + +## 1. 对象:列表 + 详情(14 个带视图对象;只读,可并行) + +每对象:① 列表视图渲染且有种子数据 ② 打开一条详情不报错 ③ 控制台无新增报错。 + +`crm_account` `crm_contact` `crm_lead` `crm_opportunity` `crm_product` `crm_quote` +`crm_contract` `crm_case` `crm_campaign` `crm_forecast` `crm_knowledge_article` +`crm_task` `crm_event` `crm_event_attendee` + +3 个无独立视图的子对象经父记录相关列表验证:`crm_opportunity_line_item`(商机详情)、 +`crm_quote_line_item`(报价详情)、`crm_campaign_member`(市场活动详情)。 + +## 2. 对象:新建 / 编辑 / 删除(17 个对象全量;写,串行) + +每对象:新建(必填校验+保存)→ 编辑(字段回显+保存)→ 删除自建记录。重点: + +| 项 | 重点 | +|---|---| +| C1 商机 | stage→probability/expected_revenue 派生 hook;closed_won 盖章 close_date | +| C2 工单 | **多 tab 表单**(Case/SLA/Resolution)——rc.0 的 #525 场景,校验失败后切 tab 值不丢 | +| C3 线索 | 新建 + convert_lead screen flow 全程(industry 越界映射已修 #626 的回归) | +| C4 合同 | 新建 + 状态看板拖拽(rc.0 未验证项 O1,人工/Playwright 拖一次) | +| C5 行项目 | 商机/报价行项目经相关列表增删(controlled_by_parent 写权限,#547 修复回归) | +| C6 事件 | crm_event 新建 + 与会人(event_attendee)联动(#592 新功能首测) | + +## 3. Actions(26 个全量;写,串行) + +12 个业务 action + 14 个活动类 action(`log_call`/`log_meeting`/`schedule_meeting` × +lead/contact/account/opportunity/case 表面 + 全局)。每个:入口可见 → 参数弹窗 → 执行 → +数据联动正确 → 失败时有可见报错(rc.0 P3 静默吞错的回归点)。 + +| 组 | Action | +|---|---| +| 工单 | `escalate_case`、`close_case`(#521 FORBIDDEN 场景回归) | +| 线索 | `convert_lead`(screen flow)、`schedule_followup`、`create_campaign` | +| 商机 | `clone_opportunity`、`mass_update_stage`(列表批量,#508 回归)、`generate_quote` | +| 联系人 | `send_email`、`mark_primary` | +| 市场活动 | `enroll_leads` | +| 活动类 | 各对象 `log_call`/`log_meeting`/`schedule_meeting` 至少每 action 名一测,全局入口单测(#522/#509 回归);产物落 `sys_activity`/`crm_event` 并出现在时间线 | + +## 4. 仪表盘(5)与报表(10)(只读,可并行) + +- 仪表盘:`crm_overview` / `executive` / `sales` / `service` / `sales_activity`(新)。 + 每个:渲染、KPI 出数、图表出数;全局过滤器(Owner、日期范围)改变后数字变化; + `service` 仪表盘为 #520 datetime 过滤的判定现场 +- 报表:10 个全量打开、图表按度量出数;矩阵日期报表(`lead_inflow_by_month_source`、 + `pipeline_coverage_by_quarter`、`cases_opened_by_day_priority`)为 #523 粒度问题判定现场 + +## 5. Flows(24 个全量;写,串行) + +每条构造触发条件并留证(记录前缀 `RC1ACC-`)。按触发方式分组: + +- **record-change(即时可测)**:`lead_assignment`、`lead_conversion`(screen)、 + `contact_welcome`、`opportunity_won_alert`、`opportunity_stagnation`(条件版)、 + `opportunity_approval`/`opportunity_approval_on_create`(审批,岗位需有人:#652 已配)、 + `case_escalation`/`case_escalation_on_create`、`case_csat_followup`、 + `campaign_enrollment`、`campaign_completion`、`quote_generation`、`escalate_case`、 + `close_case`、`schedule_followup`、`task_urgent_alert` +- **scheduled(构造到期数据 + 触发一次调度或断言注册)**:`case_sla_monitor`、 + `contract_expiration`、`contract_renewal`、`quote_expiration`、`task_due_reminder`、 + `forecast_snapshot`、`opportunity_stagnation`(定时分支) +- **bootstrap**:`demo_bootstrap`(冷启动日志断言,#663 修复回归) + +判据:目标写入发生(任务/事件/字段变更/通知),或调度注册可见;失败必须有服务端日志佐证。 + +## 6. 【新增】Profile 权限矩阵实测(写,串行,置于 CRUD/Action 之后) + +为 5 个 profile 各建一个测试用户(用户名前缀 `RC1ACC-`):`sales_rep` / `sales_manager` / +`service_agent` / `marketing_user` / `system_admin`,逐一登录(或带 token 调 REST)实测: + +1. **对象级 CRUD**:每用户 × 17 对象,读/建/改/删与 6 个权限集声明(`src/profiles/`)一致 +2. **readScope / OWD**:own-only(如 rep 的 forecast)、org-wide、`controlled_by_parent` + 三类各取代表对象验证行集 +3. **sharing 规则**:territory/team/岗位共享(`src/sharing/`)——#549 已知缺口对照, + private 商机(`is_private`)对 org-wide 读者隐藏(#547 回归) +4. **字段级权限**:`crm_account.health_score`、`crm_case.internal_notes`、 + `crm_quote.internal_notes`、`crm_opportunity.amount` 的掩码/只读按 #547 声明生效 +5. **owner 重指派**:改 Owner 后访问是否随动 —— #548(p0,已立案)判定现场,结果记录到 + 该 issue,不重复立案 + +## 7. i18n 四语言抽查(只读,可并行) + +`en` / `zh-CN` / `es-ES` / `ja-JP` 各:主导航 + 核心对象列表/表单 + **action 标签** + +**校验错误文案**(重点,rc.0 的 P6 附带项)。已知台账:#494(--skip-i18n 隐藏 205 警告)、 +#645(34 个选项未译)、#661(列表 tab 标签无翻译面)——只验证是否恶化,不重复立案。 + +## 8. 【关键】平台阻塞 issue 复测(在 rc.2 逐条重跑复现步骤) + +上游 #3912/#3913/#3914/#3915/#3955 的修复已随 rc.1/rc.2 到达;每条按原 issue 复现步骤重跑, +在 issue 评论:**已解除(附证据,可关闭)** 或 **仍复现(注明 rc.2 + 新证据)**。 + +| issue | 内容 | 上游修复 | 预期 | +|---|---|---|---| +| #520 | datetime 时间窗过滤空集(客服仪表盘) | #3912(closed) | 解除 | +| #521 | script action 写库 FORBIDDEN | #3914(closed) | 解除 | +| #522 | 全局 action 无法派发 + 200 信封吞错 | #3913(closed) | 解除 | +| #524 | flow 型 action 死菜单 + screen flow 失败静默 | #3915(closed)+ objectui#2958/#2960 | 部分解除 | +| #525 | 多 tab 表单丢值(objectui#2959) | 待验 | 待验 | +| #526 | migrate 占用检测(#3917) | 待验 | 待验 | +| #528 | 9 个孤儿 `__search` 列(#3955 closed) | #3955 | 解除/可清理 | +| #508 | 批量 action 全路径不可执行(16.1 console) | 17.0 console | 待验 | +| #509 | 全局 body action 注册键 'global' vs '*' | #3913 | 解除 | +| #510 | 仪表盘查询无用户 token,"my" widget 不可能 | 待验 | 待验 | + +## 9. 产出 + +1. `docs/acceptance-17/test-report.md`:结论 / 结果矩阵 / 平台问题 / CRM 遗留 / 诚实记录 + (结构沿用 rc.0 报告),提交 PR(附空 frontmatter changeset;Refs 不用 Closes) +2. 新发现逐条建 issue,与 §7/§8 台账及 #664/#661/#656/#650/#620/#617 等既有 issue 去重 +3. **GA 发布时需重跑的最小回归清单**(报告附录):从本轮失败/解除项中提炼 diff --git a/docs/acceptance-17/test-report.md b/docs/acceptance-17/test-report.md new file mode 100644 index 00000000..9afaa7f0 --- /dev/null +++ b/docs/acceptance-17/test-report.md @@ -0,0 +1,479 @@ +# HotCRM × ObjectStack 17.0 GA 验收报告(rc.2) + +- **日期**:2026-08-05 +- **被测版本**:`@objectstack/*` **17.0.0-rc.2**(`engines.protocol ^17.0.0-rc.2`)。 + 任务下达时的目标是 rc.1,但 `main` 已于 2026-08-03 由 #663 前进到 rc.2,**本轮以 rc.2 为准**, + 所有判定均注明 rc.2。rc.1 相关的上游修复(#3912/#3913/#3914/#3915/#3955)全部包含在内。 +- **被测代码**:`main` @ 5a78f88(`feat(activity): first-class events…` #670) +- **环境**:远程沙箱,`objectstack dev -p 4001`,SQLite `.objectstack/data`(全新种子); + 登录 `admin@objectos.ai`,鉴权走 `POST /api/v1/auth/sign-in/email` → Bearer token +- **测试计划**:[test-plan.md](./test-plan.md);**接手请先读 [HANDOFF.md](./HANDOFF.md)**;原始记录见 [evidence/](./evidence/) +- **纪律**:只测不修 —— 未改动任何 `src/` 代码;新发现逐条立 issue;测试数据统一 `RC1ACC-` 前缀 + +## 结论(TL;DR) + +> **全部 8 节执行完毕。** + +- **平台侧:阻塞 issue 复测 10/10 完成 —— 9 条解除、1 条仍复现。** rc.0 的三个升级阻断项 + (#520 datetime 过滤、#521 action 写库 FORBIDDEN、#522 全局 action 派发)**全部解除**; + 仍复现的是 #508(批量 action 无任何可用调用路径,平台选择传递 + CRM body 签名两半都缺, + 但 rc.0 的假成功已消失、所有失败可见)。 +- **基线全绿**:`pnpm verify` 退出码 0(vitest 1280 通过)、Playwright e2e 16/16、 + 冷启动 0 ERROR。 +- **数据层健康**:REST 层 **17/17 对象全量 CRUD 通过**,商机 hook 派生、行项目 rollup、 + 线索转化(含 rc.0 失败的 industry 映射)、看板拖拽均正确。 +- **UI 层有一个 p0 缺陷**:console 表单无视 `visibleOn` 却强制其 `required`, + 导致 **`crm_lead` 在界面上完全无法创建**(#688)——这是应用的头号演示路径。 +- **分析层健康**:5 仪表盘 56 个 widget、10 报表全部出数;所有空值均经 REST 反查证实 + 为数据本身如此,而非查询缺陷。 +- **i18n 分化明显**:zh-CN 完整;ja-JP / es-ES 各缺 355 条,导航与详情页分节标题仍是英文; + 四语言的校验文案均已正确翻译。 +- **26 个 action 25 通过 / 1 失败**(§6b):11 个业务 action 与 15 个活动类 action 均验证到 + 真实数据产物;唯一失败即 #508。rc.0 的"静默吞错"标志性缺陷已专项探测、未复现。 +- **24/24 flows 通过**(§6c),含审批全链与 7 条定时扫;**#684 判定**:用户路径健康、 + 系统路径全断并实证**审批绕过**($150K 流建商机无审批直接放行)—— 建议升级该 issue 严重度。 +- **权限矩阵大面积不成立(§6d)** —— 这是全场最重的结论:平台 `member_default` 通配授权把 + 应用的对象门在建/读/改三轴上整体架空(任何 profile 都能在任何对象建记录,#703); + `controlled_by_parent` 读取全组织开放,service_agent 在 0 个可见商机下读全部 74 条行项目 + 价格(#704);`modifyAllRecords` 与 edit 级共享均不给写(#705);#547 验证过的 campaign + 写扩权回归失效(#706)。**完好的层**:FLS 100% 匹配、四个真实角色的导出位精确、 + private OWD 的读行集与 is_private RLS 精确。#548 系统化坐实:所有权分裂在三列上, + 完整移交不可能(已评论)。 +- **本轮共立 23 个新 issue**(#680–#684、#688–#693、#696、#698、#700–#709),另给 15 个 + 既有 issue 回填 rc.2 实测判定。 + +**对 GA 的建议:当前状态不建议放行。** rc.0 时代的平台阻断项虽已基本清空(唯 #508 待修), +但本轮新确立的阻断级问题更重: +1. **#703/#704(p0,平台)** 权限模型默认开放 —— 任何成员在任何对象上可建可读(含行项目价格 + 泄漏),应用声明的安全姿态是纸面文章; +2. **#688(p0,console)** 线索 UI 完全无法创建; +3. **#548/#700/#705 一族** 所有权与写门破碎:owner 移交不可能、定时扫产物出生即不可改删、 + 经理改不了下属记录; +4. **#684** 系统驱动写入下自动化全断,含审批绕过实证。 +这四组修复并按 §11 清单复归前,17.0 GA 不应发布带权限声明的 HotCRM。 + +## 1. 自动化基线 + +| 项 | 命令 | 结果 | 证据 | +|---|---|---|---| +| S1 | `pnpm verify`(validate+typecheck+lint+hygiene+build+vitest) | ✅ 通过 | 退出码 0;vitest **52 文件 / 1280 通过 / 1 跳过**,耗时 26.24s | +| S2 | `pnpm test:e2e`(Playwright headless,同 CI `e2e.yml`) | ✅ 通过 | **16/16 通过**,耗时 1.0m,含 `pnpm build` + `objectstack start` 冷启动 | +| S3 | dev server 冷启动 + 种子 + 登录 | ✅ 通过 | `/api/v1/health` 200;启动日志 **0 条 ERROR**;登录返回 token,`/api/v1/data/crm_account` 200 | + +`pnpm validate` 元数据普查(rc.2):**17 对象 / 344 字段 / 1 应用 / 14 视图 / 8 页面 / +5 仪表盘 / 10 报表 / 26 actions / 24 flows / 12 岗位 / 6 权限集**。 + +validate 的 5 条警告均为既有已知项(2 个审批流的岗位空槽风险 ×4、`crm_campaign_member` +的 basic 分组全被提升到高亮条),非本轮新增。 + +## 2. 结果矩阵 + +| 计划项 | 结果 | 说明 | +|---|---|---| +| 0. 自动化基线 | ✅ 通过 | 见 §1,全绿 | +| 1. 14 对象列表 + 详情 | ✅ 12 通过 / 2 阻塞 | 见 §3 | +| 1. 3 子对象(经父相关列表) | ✅ 通过 | 见 §3 | +| 2. 17 对象 CRUD | ✅ REST 17/17 通过 / ❌ UI 1 项失败 | 见 §6;`crm_lead` UI 新建完全不可用(#688) | +| 3. 26 actions | ✅ 25 通过 / 1 失败 | 见 §6b;唯一失败是 `mass_update_stage`(#508 仍复现) | +| 4. 5 仪表盘 | ✅ 通过 | 56 widget 全渲染出数,见 §4 | +| 4. 10 报表 | ✅ 通过 | 见 §4 | +| 5. 24 flows | ✅ **24/24 通过** | 见 §6c;#684 判定:用户路径健康 / 系统路径全断且实证审批绕过 | +| 6. Profile 权限矩阵 | ❌ **矩阵不成立** | 188 探针:134 匹配 / **38 真实不匹配**,5 个平台根因,见 §6d;FLS 与导出位是唯二完好层 | +| 7. i18n 四语言 | ⚠ 部分通过 | 见 §5,zh-CN 完整,ja/es 有实质缺口 | +| 8. 阻塞 issue 复测 | ✅ **10/10 完成** | **9 解除 / 1 仍复现**(#508);逐条判定见 §7,均已评论到 issue | + +## 3. 对象:列表与详情(执行者 R1) + +**15/17 通过,2 阻塞;全站零应用层控制台错误、零 pageerror。** + +14 个带视图对象中 12 个完全通过:列表渲染有数据、详情页字段分组/相关列表/时间线正常。 +逐对象 REST 行数与截图见执行记录。 + +**阻塞 2 项**:`crm_event`、`crm_event_attendee` —— REST 200 但种子 **0 行**,列表空状态 +渲染正确,详情页无记录可开故无法测试。对应既有 issue **#671**(活动模型缺演示种子, +#592 的后续),不重复立案;详情页由写阶段自建记录后补测。 + +3 个子对象经父记录相关列表验证通过:`crm_opportunity_line_item`(74 行,商机详情 +Products 列表)、`crm_quote_line_item`(16 行)、`crm_campaign_member`(51 行,分页 1/3)。 + +**环境噪声(非应用缺陷,已排除)**:Sentry 遥测被沙箱代理拦截(`ERR_TUNNEL_CONNECTION_FAILED`)。 + +**新发现(低)**: +- `GET /assets/crm-favicon.ico` → 404,应用品牌声明的 favicon 未被服务(打包疏漏,无功能影响) +- 商机详情右侧 Products 汇总侧栏显示原始记录 ID(如 `-PBylY-NARjgK6dS`)而非显示名 + +## 4. 仪表盘与报表(执行者 R2) + +**5/5 仪表盘(共 56 widget)、10/10 报表全部通过。** 所有 widget 经 +`POST /api/v1/analytics/dataset/query` 取数,全部 200,无应用控制台错误。 + +**关键:所有为空的 widget 均经 REST 反查证实"数据本就如此",不是渲染或查询缺陷** —— +`sales_activity` 的事件类 widget 为空是因 `crm_event` 0 行(#671);SLA 达标率 0.0% 是因 +3 条违约工单均未关闭而该仪表盘按 `is_closed:true` 取数,逻辑自洽。 + +**全局过滤器实测通过**(2 个仪表盘,前后截图):CRM 总览日期范围 This quarter→This year, +营收 610,000→1,290,000、成交数 7→10、赢单 4→8;客服仪表盘 Priority→Critical, +未结工单 30→7。Owner 过滤器经捕获的查询载荷证明已注入(种子数据仅单一 owner, +故数字不变属预期)。 + +**报表**:10 个全部渲染、度量解析正确、坐标轴有数。`customer_churn_signals` 的两个空区块 +经反查证实为数据正确(所有客户 21 天内均有活动)。 + +**低优先观察**:`forecast_metrics` 的 owner 标签显示 "—"(种子 `crm_forecast.owner` 为 null); +一个报表图表的坐标轴显示原始度量名 `total_amount`。 + +## 5. i18n 四语言(执行者 R3) + +**zh-CN 完整,ja-JP / es-ES 有实质缺口,校验文案四语言均正确翻译。** + +locale 切换路径(实测发现,非猜测):头像菜单 → Preferences → Language;控制台用短码, +服务端映射(`GET /api/v1/i18n/translations/zh` → `{"locale":"zh-CN"}`); +`GET /api/v1/i18n/locales` 返回 en/zh-CN/ja-JP/es-ES,与 `src/translations/crm.translation.ts` 一致。 + +| 检查项 | zh-CN | ja-JP | es-ES | +|---|---|---|---| +| 主导航 | ✅ 全译 | ❌ 23 个条目全英文 | ❌ 同左 | +| 列表视图 tab 标签 | ✅ | ✅ | ✅ | +| 列表列头 | ✅ | ⚠ 缺 Health Score / Next Renewal Date | ⚠ 同左 | +| 详情页 tab / 分组标题 | ✅ | ❌ 英文 | ❌ 英文 | +| Action 按钮标签 | ✅ | ✅ | ✅ | +| **校验错误文案** | ✅ 主题不能为空 | ✅ 件名は必須です | ✅ Asunto es obligatorio | +| Action 参数弹窗 | ⚠ 标题英文 | ⚠ 标题英文 | ⚠ 标题英文 | + +**量化**:`objectstack lint` 关闭 `--skip-i18n` 后 **710 条 i18n 警告**(ja-JP 355 + es-ES 355, +zh-CN 0),远超 #494 记录的 205 条。缺失选项键 **169 个/语言**,远超 #645 记录的 34 个。 +根因:`ja-JP.ts` / `es-ES.ts` 按 `en.ts` 的形状编写(~926 行),从未同步 `zh-CN.ts`(1345 行) +后来新增的导航映射、`pages`、11 个 `_sections` 中的 10 个、CS 字段与工单状态/优先级选项集。 + +**既有 issue 复核结果**: +- **#661 未复现,疑似已修** —— 列表视图 tab 标签在 zh/ja/es 三语言下均已翻译(已在 issue 评论,建议 owner 确认后关闭) +- **#645 确认仍在**(ja/es),计数已更新至 169/语言(已评论) +- **#494 确认仍在**,计数已更新至 710(已评论),并发现 lint **完全没有 `_sections` 检查**这一盲区 + +**本轮新立 issue**:#680(action 参数弹窗标题不翻译)、#681(语言选择不持久化,刷新即回退 +英文,使非英语 locale 实际只能演示)、#682(控制台 chrome 层 i18n 缺陷:lookup 占位符泄露 +原始 API 名 `crm_account`、视图/工具栏字符串、en 文案误用顿号、es 性数不一致)、 +#683(创建表单分节 tab 无翻译面 + lint `_sections` 盲区)。 + +## 6. 对象 CRUD 与定向检查(执行者 W1) + +**REST 层 17/17 对象全量 C-R-U-D 通过**,无一对象在增删改查任一环节失败。 +测试数据全部 `RC1ACC-W1-` 前缀,结束时 30 条台账记录 + 3 条 hook 派生任务全部删除, +**零残留、未触碰任何种子记录**。 + +UI 表单已验证可用:客户、商机、工单、事件(均创建成功);**线索 UI 新建失败**(#688)。 + +| 定向检查 | 结果 | 关键证据 | +|---|---|---| +| C1 商机 hook 派生 | ✅ 通过 | `proposal/10000` → probability=60、expected_revenue=6000;无 `win_reason` 关闭被 **400** 拒绝;补齐后 → probability=100、expected_revenue=10000、close_date 盖章 | +| C2 多 tab 表单(#525) | ✅ 通过 | **无数据丢失**,详见下文 | +| C3 线索转化(industry 映射) | ✅ 通过 | 两轮转化,`nonprofit` 与 `media` 均干净落到 `crm_account.industry`,rc.0 的枚举越界未复发 | +| C4 看板拖拽 | ✅ 通过 | 真实鼠标 API 触发真实 PATCH,hook 正确重算 | +| C5 行项目与 rollup(#547) | ✅ 通过 | 报价/商机 rollup 均正确;`controlled_by_parent` 写权限无阻碍 | +| C6 事件/与会人 | ✅ 通过 | 两个详情页渲染完好,R1 的阻塞纯属无种子数据(#671) | + +**C2 —— #525 的结论是"不复现"**:`crm_case` 的原始复现路径已不可构造(#515 把 5 个必填 +字段全移到第一个 tab)。W1 转而发现 `crm_contract` 仍存在真正的跨 tab 必填布局 +(Parties: 客户/主要联系人/状态;Terms: 期限/起止日期/合同额),在那里跑了真复现: +**其它 tab 上已填的值在校验失败后全部存活**(哨兵字符串完好;lookup 值也存活—— +第二次提交只报 `Status is required`,不再重报客户/联系人),失败提交零网络写入,tab 头未错乱。 +残留问题是 lookup 控件重挂载后显示回 "Select..." 但值仍在(#692)。 + +**C4 的两点修正**(推翻 rc.0 的记录):① `crm_contract` **根本没有状态看板** —— +只有 Grid/Calendar/Gantt/Timeline,该前提在当前 main 不存在;② 看板拖拽**可以自动化验证**, +在 `pipeline_kanban` 上用真实鼠标 API(mouse.down + 30 步插值 move + up)触发了真实 +`PATCH` 200,读回 `stage=needs_analysis / probability=40 / expected_revenue=10000`。 +rc.0 的观察项 O1「合成事件不触发 dnd」成立,但由此得出的「不可自动化」结论应予撤回。 + +**新发现(已立案)**:#688(p0,`visibleOn` 被无视致 `crm_lead` UI 完全无法创建)、 +#689(未声明字段由 SQL 失败兜底,hook 已先执行,完整 INSERT 连值落日志)、 +#690(`crm_forecast.display_title` 全行为 null)、#691(缺失 master-detail 父记录返回 403 而非 400)、 +#692(lookup 显示回退)、#693(级联删除报错指向错误对象)。 + +## 6b. Actions 全量(执行者 W2B) + +**26 个 action:25 通过 / 1 失败。** 测试数据 `RC1ACC-W2B-` 前缀(96 行台账,逐行记录最终去向)。 + +- **11 个业务 action 全部通过**,每个都验证到真实数据效果:工单升级/关闭(字段联动 + + 按钮可见性规则)、线索转化(客户+联系人+商机三件套)、跟进任务 + `next_followup_date` + 盖章、报价生成(QTE-0003 + 商机 stage→proposal)、克隆、`send_email`(sys_email 落库 + status=sent + 时间线 + 双向 recency 盖章)、`mark_primary`、市场活动报名(含去重:二次 + 执行 `count:0,skipped:1`)。 +- **15 个活动类 action(3 种 × 5 对象)全部通过**:每个组合验证 `crm_event` + + `sys_activity` + `crm_event_attendee` 三类产物;工单 `first_response_date` 首次通话 + 盖章、后续不重复盖章;`schedule_meeting` 的日期+时间对拼成 UTC 精确无误。 +- **唯一失败:`mass_update_stage`**(#508 仍复现,见 §7)。 +- **rc.0 的标志性缺陷(200 包裹内层失败 + 弹窗静默关闭)已专项探测,未复现**: + `/actions` 路径的失败现在返回 400/404 并显示红色错误 toast。 +- 已知项确认:#673(`sys_activity.actor_name` 存原始用户 id)行为未变,且 UI 时间线 + 直接显示该原始 id。 + +**W2B 的新发现**:#698(工单自动编号计数器落后于既有数据,REST 建单连续 409 且每次失败 +烧号,实测重试 25 次才成功;另发现种子行 `organization_id=NULL` 的口径不一致)、 +#682 追加(时间线钻取渲染原始 key `detail.viewSource`;Activity tab 计数只数审计行)、 +**#548 的定量证据**(见 §8)。 + +**流程性限制**:`sys_activity` / `sys_email` 按设计拒绝 REST DELETE(405),本轮产生的 +77 条时间线/邮件工件无法经 API 清理 —— 写阶段验收要想全清,需要管理员清除通道或测试租户隔离。 + +## 6c. Flows 全量(执行者 W3) + +**24/24 通过。** 测试数据 `RC1ACC-W3-` 前缀;判定基座是 `GET /api/v1/automation//runs` +返回的逐节点运行记录(含 `trigger.userId`)—— 每条"触发/拒绝"结论都有 run 行或日志行背书。 + +| 组 | 结果 | +|---|---| +| 6 条 screen flow(W2B 已深测,轻确认) | ✅ 全过,含报价折扣计算(200000×0.9)与转化联动 | +| 9 条 record-change(用户会话驱动) | ✅ 全过:trigger.userId=admin,数据操作全部执行,通知实际送达(sys_inbox_message + sys_email sent) | +| 2 条审批流 | ✅ 全过:批准/驳回双路径、`sys_approval_request` 创建、`RECORD_LOCKED` 锁定强制、批后解锁、≤$500K 跳过 director 签核的分层路由、阈值下不触发的对照 | +| 7 条 scheduled | ✅ 全过:cron 注册有据(当天 07:30/08:00 实际执行记录)+ 人工触发扫全部验证到效果(SLA 违约升级、到期任务提醒+去重、合同续约三件套+幂等、报价/合同过期、活动完结、预测快照);两条 00:00/01:00 槽位因服务器当时未运行仅注册可证 | +| demo_bootstrap | ✅ #663 修复确认:list+assignment 形态每 10 分钟全绿,无 findOne 报错 | + +**审批流的环境保留**:本库 `sys_user_position` 为空(未跑 demo:staff),审批按声明的 +`onEmptyApprovers: 'admin_rescue'` 由 admin 裁决 —— 岗位实际路由在本安装不可验,已按声明行为通过。 + +**#684 判定(已评论至 issue,建议升级严重度)**:用户会话路径**健康**(全部 record-change 流 +trigger.userId 就位、零 REFUSED,且用户身份穿越多跳链与挂起恢复);系统路径**全断**,并在窗口内 +实证**审批绕过**:$150K 的流建商机以 `approval_status='not_required'`、不锁定、无审批请求直接放行 +—— 恰是 on-create 审批流存在的目的场景。潜在延伸:经 Close Case 按钮关闭的工单,其 CSAT 流以 +无用户状态挂在 P1D 定时器上,到期通知可能被拒(#684 评论 N4)。 + +**W3 的新发现(已立案)**:**#700**(p1,自动化引擎系统扫插入的行 owner_id/organization_id/ +created_by 全 NULL,出生即 admin 不可改删;demo_bootstrap 十分钟自愈是演示拐杖,真实安装没有)、 +#701(记录变更流重入自身,拦住死循环的是引擎 loop-breaker 而非作者写的 start condition)、 +#702(启动重播种 × 快照流互动产生无 owner 幻影预测行)。#698 的 409 风暴当日未再现 +(计数器已被烧穿越过种子区,机制未修,新库仍会复现 —— 已评论)。 + +**清理**:36 条业务记录删除(200);**1 条定时扫创建的 crm_forecast 行被 #700 阻塞留存** +(demo_bootstrap 不认领 crm_forecast,故永不自愈 —— #700 最纯净的活体证据);按设计不可删的 +sys_* 通知/审批工件与 2 条无用户的 CSAT 挂起 run 留存并记录;未触碰 W2/W2B 残留与种子数据。 + +## 6d. Profile 权限矩阵(执行者 W4)—— 矩阵不成立 + +**188 个探针(每用户各自 token):134 匹配 / 38 真实不匹配 / 13 信息项。** +测试用户 4 个(`rc1acc-w4-{rep,mgr,agent,mkt}@test.local`,留在库中未删);system_admin +断言以 dev-admin(org owner)执行 —— 纯 CRM system_admin 集用户无法经 create-user 构造, +该保留已记录。 + +**5 个根因解释全部 38 处不匹配(逐条已立案)**: + +| # | 根因 | 后果 | issue | +|---|---|---|---| +| 1 | 平台 `member_default` 集带 `*` 通配 allowCreate/Read/Edit,并集合并进每个成员 | 对象门在建/读/改三轴失效:**21 个建拒探针全部 201**(含 service_agent 在显式全否商机上建单);public_read 对象对无授权者返回**全部行** | **#703(p0)** | +| 2 | `controlled_by_parent` 读取全组织开放(插入侧却正确按父门控) | service_agent 0 可见商机 → 读全部 74 行项目价格;rep 1 可见客户 → 读全部 14 联系人 | **#704(p0)** | +| 3 | `modifyAllRecords` 对行级写门无效;edit 级 `sys_record_share` 实际只给读 | sales_manager 改不了任何非本人创建的记录;团队/领地/升级三条 edit 共享全部只读 | **#705(p1)** | +| 4 | #547 验证过的 `marketing_campaign_updates` RLS 写扩权回归失效(member 扩权仍好) | marketing 改不了非本人建的 campaign;错误形状显示 sharing 中间件在 RLS 之前抢答 | **#706(p1)** | +| 5 | org-admin 路径绕过 17.0 allowExport 硬门 | admin 导出 quote/campaign/task 均 200(四个真实角色的导出位全部精确) | **#707(p2)** | + +**完好的层(与声明 100% 一致)**:字段级安全(internal_notes 掩码、只读字段、过滤谓词拒绝 +全部精确)、四个真实角色的导出位、private OWD 的读行集(own 与 viewAll 均为精确行集)、 +is_private RLS(五角色列表+单记录 GET 双验)、共享规则的**读**扩权与撤销(含钩子即时命中 +新建记录)、删除轴、审批 slate 解析。 + +**#548 系统化判定(已评论至 issue)**:所有权分裂在**三列** —— `owner`(仅 UI 与 +is_private 谓词)、`owner_id`(可见性)、`created_by`(经 member_default 通配 RLS 共同门 +控写)。改 `owner` 不移动任何访问;直接改 `owner_id` 只移动读;**完整所有权移交在任何 API +面上不可能**。NULL `owner_id` 行对 admin 的改删双 403 复现;demo_bootstrap 对 crm_task +数分钟自愈、对 crm_event 永不(留有活体标本 `m2amex4D0LZYYr16`)。 + +**连带发现**:**#708(p1)** demo_bootstrap 认领扫把 owner 未显式设置的用户新建记录改判给 +第一个用户(rep 的报价 10 分钟内被静默没收 —— #620 的 owner 默认失效使用户记录与无主种子 +在 `owner` 列上不可区分);**#709(p2)** 矩阵文档承诺了平台无法兑现的写权(marketing 的 +contact 建被 master-detail 门拒;agent/mkt 的"可编辑"期望在 owner_id+created_by 写门下 +不可实现)。#549 现状确认(领地共享客户的子相关列表为空 —— 但注意对 cbp 子对象该问题 +方向相反:所有人读所有行,见根因 2)。 + +**环境注记**:后台演示流使种子计数在会话中漂移(无 W4 写入时 accounts 13→12 等), +故所有行集断言按记录 ID 隶属验证而非总数;≥100k 商机创建即自动送审并 RECORD_LOCKED +(admin 亦不可 PATCH,删除不受锁限);可能残留 1 条指向已删 fixture 的 sys_record_share。 + +## 7. 平台阻塞 issue 复测 + +**10/10 完成:9 条解除,1 条仍复现。** 每条判定已评论到对应 issue(含证据与建议)。 + +| issue | 判定 | 关键证据 | +|---|---|---| +| #520 | ✅ 解除 | 客服仪表盘 38 工单全部出数,时间窗序列正常 | +| #523 | ✅ 解除 | 矩阵报表按月/季/日正确分桶有序 | +| #521 | ✅ 解除 | 全程 **0 条 FORBIDDEN**;script body 对 crm_contact 与受共享治理的 crm_case 的 UPDATE 均持久化 | +| #522 | ✅ 解除 | 按对象派发全通;`/actions/global/...` 干净 404(ADR-0110 治理文案);失败非 200 + 红 toast | +| #509 | ✅ 解除 | 与 #522 同一证据基座;'global' vs '*' 幽灵消失 | +| #508 | ❌ **仍复现** | 多选零请求 + "select exactly one row";REST `selectedIds` 无法送达(顶层被忽略、params 被校验器拒);单选路径死于 CRM 侧 `update(id,{stage})` 签名 bug。**诚实性已修**(无假成功),功能仍缺 | +| #524 | ✅ 解除 | 菜单项活;工程化构造的服务端失败(amount=0)以红 toast 逐字回显,线索未变 | +| #510 | ✅ 解除 | `{current_user_id}` 解析为绑定参数(32 行 ≡ 字面 id ≡ 对照);未知 token 响亮报错并给建议。CRM 侧"My"widget 恢复仍是待办 | +| #526 | ✅ 解除 | plan 预警占用 + apply 拒绝 + `--yes` 确认门,全链验证 | +| #528 | ✅ 条件消失 | 新库 5 个 `__search` 列全活跃、plan in-sync;孤儿仅存于 rc.0 旧库 | + +残留的上游小尾巴(不阻塞,已在各 issue 记录):automation resume 路径仍 200 包裹内层失败 +(console 已会读内层信封,用户可见错误);migrate 占用拒绝退出码为 0。 + +> 已取得判定的细节: + +- **#520(datetime 时间窗过滤返回空集)—— 已解除。** rc.0 下客服仪表盘全空;rc.2 下 + 38 条种子工单全部出数:未结 30 / 紧急 7 / 平均解决 45.0h / SLA 违约 3,30 天日粒度 + 时间序列有数,与 REST 直查结果一致。上游 objectstack#3912 已关闭。 +- **#523(矩阵报表日期粒度缺失)—— 已解除。** `lead_inflow_by_month_source` 正确按月分桶 + (2026-02…2026-08),`pipeline_coverage_by_quarter` 按季(2026-Q3/Q4), + `cases_opened_by_day_priority` 按日(2026-07-06…2026-08-04),均按时间正序且同期记录 + 聚合入同一列,rc.0 的"逐原始日期爆列"现象消失。 +- **#526(migrate 对占用中的库缺少保护)—— 已解除。** 隔离环境(4009 端口独立库)全链验证: + ① `plan` 对运行中的库**预警占用**(报出持有 pid 与 wal/shm 证据);② `apply --allow-destructive` + 在任何 DDL 前**拒绝执行**并提示 `--force` 逃生门,已植入的漂移列未被触碰;③ 即便服务已停, + apply 也要求显式 `--yes` 确认 —— rc.0 的"确认前即发 DDL"行为消失;④ 停服 + `--yes` 后正确 + 应用并删除漂移列。残留小缺口:占用拒绝的退出码为 0(脚本会误判成功),已在 issue 评论中记录。 +- **#528(孤儿 `__search` 列)—— 新库上条件已不存在。** 全新 rc.2 库 5 个 `__search` 列 + 全部活跃,`migrate plan` 对运行中的库报 in-sync、零孤儿(对照:故意植入的垃圾列**被**正确 + 标记为 destructive,证明检测器本身工作正常)。上游 #3955 修复确认;issue 里的"9 个孤儿" + 仅存在于 rc.0 时代的旧库,建议按"仅限遗留库"重新界定或关闭。 + +## 8. CRM 侧遗留与新发现 + +> 本节在全部阶段完成后定稿。已立案: + +- **#684**(本轮新立,p1)记录变更流漏了 `runAs: 'system'`:10/21 个流文件已为**定时流** + 加上该声明并附相同注释,但**记录变更流被系统写入触发时同样没有用户**这一情形被漏掉。 + 单次启动+种子+一轮 CRUD 写入即产生 **73 条 `[runAs]` 拒绝警告与 12 次运行失败** + (`case_escalation` 38/3、`opportunity_approval` 23/9)。影响:种子加载、流触发流、 + 未来的集成/webhook 写入都会静默丢失这些自动化。用户会话路径的判定由 flows 阶段(W3)补齐。 +- **#548 在本轮取得定量证据**(已评论到 issue):action 沙箱 `ctx.api` 插入只盖 app 层 + `owner`,从不盖平台 `owner_id` —— 19 条 action 创建的 `crm_event` 全部 `owner_id=NULL`, + admin 对其 DELETE 与修复性 PATCH 均 403;级联进一步使 9 条父记录(2 商机/1 工单/2 联系人/ + 1 客户/2 线索)不可删除。**实际语义:任何被记录过通话/会议的记录,对所有人永久不可删除。** + 对照:REST 直建的 event 删除 200 —— 差别就在创建路径是否走平台 owner 盖章。 +- **#696**(本轮新立)报名过市场活动的线索/联系人无法删除(级联置空 campaign_member 引用 + 后违反其自身校验);#698(本轮新立)工单自动编号计数器落后 + 失败烧号。 +- **权限/所有权模型一族(本轮最重,§6d 详述)**:#703(通配授权架空对象门,p0)、 + #704(cbp 读全组织开放,行项目价格泄漏,p0)、#705(modifyAllRecords 与 edit 共享不给写)、 + #706(#547 的 campaign 写扩权回归)、#707(admin 绕过导出硬门)、#708(demo_bootstrap + 没收用户记录)、#709(矩阵文档承诺平台无法兑现的写权)。#700(定时扫产物 owner_id=NULL, + 与 #548 同根不同写入方)。 +- **rc.0 遗留观察项本轮状态**:O3(时间线显示原始用户 id)= 既有 #673,确认未变且 UI 可见; + O4/O5(浮点/Markdown 格式)未专门复验;L1–L4 未在本轮范围。 + +## 9. 诚实记录(测试过程说明) + +- **一个假设先被证伪、随后又被翻转过来 —— 完整经过如下**,因为中途任一步停手都会写出错误结论: + 1. dev 日志出现 `SqliteError: … table crm_forecast has no column named name` + 并以 `[REST] Unhandled error` 抛出,我推断"未声明字段被直送 SQL 层"。 + 2. 用对照探针**证伪**:`POST /api/v1/data/crm_account` 带虚构字段返回干净的 + **400 `INVALID_FIELD`**;`crm_forecast` 带 `name` 同样 **400**。看上去校验是正确的, + 我据此撤回了假设,并明确不立案。 + 3. 但随后核对 W1 的精确请求记录发现:那次 400 的时间戳是 02:26:22.917,SqliteError 是 + .994 —— **同一次请求的两个层次,不是两次请求**。回查我自己那次探针的日志, + 同样留下了完整的 `SqliteError` + `[REST] Unhandled error`。 + 4. **结论**:原假设方向是对的,但症状判断错了 —— 客户端拿到的 400 **是 SQL 失败被翻译后的产物**, + 而非 schema 校验的结果。佐证:失败的 INSERT 里含有调用方从未提交的 `period_label` + (由 `forecast.hook.ts:89` 派生),证明 **beforeInsert hook 在请求被判定非法之前就已执行**。 + 已按此立案 **#689**(含 hook 副作用暴露、日志噪声、字段值明文落日志三点后果)。 +- 教训:一次"干净的 400"不足以证明校验发生在正确的层次;必须同时看服务端日志。 +- 上述探针创建的 2 条记录(1 客户、1 预测)已 `DELETE` 并经 GET 404 验证清除; + W1 的 30 条台账记录 + 3 条 hook 派生任务同样已清理,全库零残留。 +- **未予断言的一点**:被拒绝的那次 INSERT 中自动编号取值为 `ACC-000003`,而该编号已被种子 + 客户占用。这暗示自动编号是插入时计算而非预留,但我没有隔离验证,因此**没有**把它写成缺陷, + 只在 #689 里留了一句提示。 +- `display_title` 是公式字段,写入时被拒为 `Unknown field` —— 报错措辞对只读计算字段有误导性 + (低优先观察项,未单独立案)。 +- **rc.0 报告的两处记录经本轮推翻**,已在 §6 注明:合同状态看板在当前 main 并不存在; + 看板拖拽**可以**自动化验证(rc.0 结论「不可自动化」应撤回)。 +- **W1 的一条发现被 W2B 推翻并撤回**:W1 曾报告"screen flow 在条件显隐的必填字段留空时 + 静默无操作"(0 请求、0 提示)。W2B 复跑同一场景:客户端以红 toast「Please fill: + Opportunity Name」拦截、弹窗保持打开 —— **不静默、不可复现**。已在 #524 评论中声明撤回, + 以 rc.2 行为为准。两次观察相隔数小时、同一构建,差异原因未明(W1 可能受当时表单状态干扰); + 按证据规则,不可复现的发现不予保留。 + +## 10. 阶段完成度(续跑更新) + +首轮在 W2 开始后因额度耗尽中断;额度恢复后续跑,当前状态: + +| 项 | 状态 | +|---|---| +| 26 个 actions 全量 | ✅ 已完成(W2B,§6b)—— 中断的 W2 无有效结论,已由 W2B 全量重做 | +| 阻塞 issue 复测 10 条 | ✅ 已完成(§7,9 解除 / 1 仍复现) | +| #526 / #528 CLI 复测 | ✅ 已完成(隔离端口+独立库,§7) | +| 24 条 flows 全量 | ✅ 已完成(W3,§6c)—— 24/24 通过,#684 判定已评论 | +| 5 profile 权限矩阵 | ✅ 已完成(W4,§6d)—— 矩阵不成立,5 根因 7 新 issue | + +若本会话再次中断,剩余工作按 [parallel-task-packages.md](./parallel-task-packages.md) +的 P3/P4 包投放即可。 + +### 终止时的环境状态(诚实记录) + +- W2 被终止时留下了测试记录,已尽力清理:campaign member 8 条、部分线索/联系人/市场活动 + 已删除(200)。**仍有残留**:6 条 `crm_event`、2 条 `crm_case`、2 条 `crm_opportunity`、 + 2 条 `crm_contact`、5 条 `crm_lead`、1 条 `crm_account`,均带 `RC1ACC-W2-` 前缀。 + 该库是容器内的临时 SQLite(`.objectstack/data/dev.db`,已 gitignore),随容器回收, + 不影响仓库与其它环境。 +- **清理受阻本身暴露了两个问题**,其一已立案: + - **#696**(已立案):报名过市场活动的线索/联系人无法删除 —— 级联把 campaign_member + 的引用置空后违反其自身校验规则。删掉 campaign member 后线索即可删除(400 → 200), + 机制已证实。 + - **当时未立案的观察,后经 W2B/W4 坐实**:admin 对活动类 action 创建的 `crm_event` 删除 + 返回 **403 FORBIDDEN**。W2B 与 W4 隔离验证确认这是 **#548 的机制**(action 沙箱与 + 自动化引擎的系统上下文插入都不盖 `owner_id`,而访问键控 `owner_id`),证据已系统化 + 评论到 #548,并把"不同写入方"的自动化引擎路径单列为 #700。当初"先不立案、避免重复" + 的判断被后续证据证明是对的 —— 它确实是 #548 而非新缺陷。 + +## 11. 附录 A:GA 发布时需重跑的最小回归清单 + +从本轮结果提炼。**打 ★ 的是本轮已验证通过、GA 时只需确认未回归的项;打 ⚠ 的是本轮未验证或 +已知失败,GA 前必须真正跑通。** + +### A. 自动化闸门(必须全绿,约 3 分钟) + +1. ★ `pnpm verify` —— 退出码 0,vitest 52 文件 / 1280 通过 +2. ★ `pnpm test:e2e` —— 16/16(含冷启动 + 种子) + +### B. 平台阻塞项回归(rc.0 的三个升级阻断项) + +3. ★ **#520** 客服仪表盘出数:未结工单、紧急数、SLA 违约数非零,30 天时间序列有数 + —— datetime 时间窗过滤的判定现场 +4. ★ **#523** 三个矩阵报表的日期列按月/季/日正确分桶且有序,无"逐原始日期爆列" +5. ★ **#521** 带写库 body 的 script action(如 `close_case`)执行后**写入确实落库** + (本轮:全程 0 FORBIDDEN,已解除) +6. ★ **#522 / #509** 全局 action 派发 + 失败非 200 + UI 可见报错(本轮已解除) +7. ⚠ **#508** `mass_update_stage` 三条调用路径 —— **唯一仍复现的阻塞项**,GA 前须修 + (平台不传 `selectedIds` + CRM `update(id,{stage})` 签名 bug 两半都缺) + +### C. 核心业务链路(每条都有真实数据效果判据) + +8. ★ 商机生命周期:`proposal/10000` → probability=60、expected_revenue=6000; + 无 `win_reason` 关闭被 400 拒绝;补齐后 probability=100、close_date 盖章 +9. ★ 线索转化 screen flow 端到端:线索标记 converted,客户/联系人(按需商机)真实生成, + **industry 枚举跨对象映射不报错**(rc.0 的失败点) +10. ★ 行项目 rollup:报价/商机行项目增改后父记录金额重算(注意 rollup 是 **async**, + 勿在同一 tick 断言) +11. ★ 24 条 flows 用户会话驱动路径(本轮 24/24 通过);⚠ **#684** 系统驱动路径仍全断, + 含审批绕过 —— 修复后须复验 `runAs:'system'` 缺失的 7 条记录变更流 + +### C2. 权限与所有权模型(本轮最重的失败区,GA 前必须逐条复归) + +12. ⚠ **#703(p0)** 任何 profile 不能在其被拒对象上建记录(当前 21/21 建拒探针都 201) +13. ⚠ **#704(p0)** `controlled_by_parent` 读取须随父可见性,不得全组织开放 + (当前 service_agent 在 0 可见商机下读全部 74 行项目价格) +14. ⚠ **#705** sales_manager 的 `modifyAllRecords` 与 edit 级共享须真正给写(当前全 403) +15. ⚠ **#706** marketing 的 campaign 写扩权(#547 曾验证通过,rc.2 回归) +16. ⚠ **#548 / #700 / #708** 所有权:owner 重指派应移动访问;定时扫/action 创建的记录 + 不得 owner_id=NULL 而对 admin 不可改删;用户记录不被 demo_bootstrap 没收 +17. ★ FLS(掩码/只读/过滤谓词拒绝)、private OWD 读行集、is_private RLS、四角色导出位、 + 共享**读**扩权与撤销 —— 本轮 100% 匹配,GA 时确认未回归 + +### D. UI 可用性(本轮发现的重灾区) + +18. ⚠ **#688(p0)** 从界面新建线索必须能成功 —— 当前完全不可用 +19. ★ 多 tab 表单校验失败后,其它 tab 已填值不丢(在 `crm_contract` 上测, + `crm_case` 已无跨 tab 必填布局) +20. ★ 看板拖拽触发真实 PATCH 且 hook 重算(用真实鼠标 API,合成事件无效) +21. ⚠ 删除链路:报名过市场活动的线索可删除(#696);action 创建的记录 admin 能删除 + (当前被 #548/#700 阻塞) + +### E. i18n(抽查即可) + +22. ⚠ 四语言的 **action 标签与校验文案**(本轮:标签与文案均正确, + 但参数弹窗标题仍是英文 #680) +23. ⚠ `objectstack lint` 不带 `--skip-i18n` 的警告数**不高于 710**(本轮基线), + 确认债务未继续增长 + +### 执行顺序建议 + +A → B → C → C2 → D → E。A 不绿则其余不必开始;B 或 C2 中任一 p0 失败即为**升级阻断**, +应在 GA 前解决或明确接受。