@qingflow-tech/qingflow-cli 1.1.50-beta.4 → 1.1.50-beta.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/skills/qingflow-cli/SKILL.md +37 -0
- package/skills/qingflow-cli/reference/builder/50-views.md +12 -0
- package/skills/qingflow-cli/reference/builder/80-buttons-associated-resources.md +2 -0
- package/src/qingflow_mcp/app_form_validator.py +53 -14
- package/src/qingflow_mcp/builder_facade/app_form.py +152 -47
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/complete.json +3 -3
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/address.json +1 -1
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/amount.json +1 -1
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/attachment.json +1 -1
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/code_block.json +3 -3
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/data_relation.json +3 -3
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/date.json +1 -1
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/datetime.json +1 -1
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/department.json +3 -3
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/email.json +1 -1
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/link.json +1 -1
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/long_text.json +1 -1
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/member.json +3 -3
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/multi_select.json +1 -1
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/number.json +1 -1
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/phone.json +1 -1
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/q_linker.json +3 -3
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/relation.json +3 -3
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/rich_text.json +1 -1
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/single_choice.json +1 -1
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/single_select.json +1 -1
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/subtable.json +3 -3
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/text.json +1 -1
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/formula.json +35 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/summary.json +2 -2
- package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/complete.json +1 -1
- package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/details/button-config.json +3 -3
- package/src/qingflow_mcp/builder_facade/service.py +170 -88
- package/src/qingflow_mcp/cli/commands/builder.py +4 -2
- package/src/qingflow_mcp/server_app_builder.py +2 -2
- package/src/qingflow_mcp/tools/ai_builder_tools.py +7 -5
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ Qingflow CLI 是轻流面向开发者和 AI Agent 的命令行工具。它把轻
|
|
|
7
7
|
## 安装
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
|
-
npm install @qingflow-tech/qingflow-cli@1.1.50-beta.
|
|
10
|
+
npm install @qingflow-tech/qingflow-cli@1.1.50-beta.5
|
|
11
11
|
```
|
|
12
12
|
|
|
13
13
|
推荐从官网复制这一条一键安装 Qingflow CLI:
|
|
@@ -21,7 +21,7 @@ npx qingflow-cli@latest install
|
|
|
21
21
|
## 运行
|
|
22
22
|
|
|
23
23
|
```bash
|
|
24
|
-
npx -y -p @qingflow-tech/qingflow-cli@1.1.50-beta.
|
|
24
|
+
npx -y -p @qingflow-tech/qingflow-cli@1.1.50-beta.5 qingflow
|
|
25
25
|
```
|
|
26
26
|
|
|
27
27
|
全局安装后可以直接运行:
|
|
@@ -62,7 +62,7 @@ qingflow-skills install --agent codex --scope user
|
|
|
62
62
|
如果你通过一次性 `npx -p` 执行 skills 安装,建议加 `--copy`,避免 symlink 指向 npm 临时执行缓存:
|
|
63
63
|
|
|
64
64
|
```bash
|
|
65
|
-
npx -y -p @qingflow-tech/qingflow-cli@1.1.50-beta.
|
|
65
|
+
npx -y -p @qingflow-tech/qingflow-cli@1.1.50-beta.5 qingflow-skills install --agent codex --scope user --copy
|
|
66
66
|
```
|
|
67
67
|
|
|
68
68
|
`qingflow-skills install` 默认使用 symlink,且遇到同名 skill 不会覆盖;只有显式传入 `--force` 才会替换。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@qingflow-tech/qingflow-cli",
|
|
3
|
-
"version": "1.1.50-beta.
|
|
3
|
+
"version": "1.1.50-beta.5",
|
|
4
4
|
"description": "Human-friendly Qingflow command line interface for auth, record operations, import, tasks, and stable builder flows.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/pyproject.toml
CHANGED
|
@@ -14,6 +14,43 @@ description: |
|
|
|
14
14
|
|
|
15
15
|
本 Skill 负责 CLI 入口、文件约定和业务分流。已知 `app_key` 时直接执行 `data query/get`。只有应用名称时,先用 `qingflow --json app list --query "应用名称"` 查找当前工作区的可见应用,并核对返回项的 `app_name/title`;目标明确且唯一时取其 `app_key` 再查询,否则请用户确认。不要把应用名称直接传给 `--app-key`,也不要为定位名称转向 builder 或切换工作区。字段能力、权限范围与下一步动作以当前工具 schema、data 响应和 `hints` 为准。
|
|
16
16
|
|
|
17
|
+
## 关联文件
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
| 文件 | 说明 |
|
|
21
|
+
| ------------------------------------------------------------------------------------------------ |---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
|
22
|
+
| `manifest.yaml` | 技能清单 |
|
|
23
|
+
| [reference/00-INDEX.md](./reference/00-INDEX.md) | **总导航**:按用户目标选择 record / task / builder 文档;复杂任务先读 |
|
|
24
|
+
| [reference/QINGFLOW_CLI_MEMBER_CHEATSHEET.md](./reference/core/QINGFLOW_CLI_MEMBER_CHEATSHEET.md) | **普通成员**主线:最短路径、`view_id`、与管理命令的差异预期 |
|
|
25
|
+
| [reference/QINGFLOW_CLI_FIELD_DATA_TYPES.md](./reference/core/QINGFLOW_CLI_FIELD_DATA_TYPES.md) | **字段数据类型**:`record schema …` 的 `fields[]`(`field_id`、`title`、`kind`、`options`…);`record list/get/access` 与写入 payload 的形态对照 |
|
|
26
|
+
| [reference/QINGFLOW_CLI_DATA_RETRIEVAL_WORKFLOW.md](./reference/core/QINGFLOW_CLI_DATA_RETRIEVAL_WORKFLOW.md) | **应用记录获取数据**:`app get`(`accessible_views`)→ `record schema browse` → `record list(query)` 模糊定位 / `record get` 详情页首屏上下文 / `record logs` 全量日志;分析取数转 `record_access` |
|
|
27
|
+
| [reference/QINGFLOW_CLI_TASK_CONTEXT_WORKFLOW.md](./reference/task/QINGFLOW_CLI_TASK_CONTEXT_WORKFLOW.md) | **任务(待办已办)上下文 SOP**:`task list` → `task get` → `task log` → `task report` → `task action`;只把 `task_id` 作为主定位参数 |
|
|
28
|
+
| [reference/QINGFLOW_CLI_RECORD_IMPORT_WORKFLOW.md](./reference/record/QINGFLOW_CLI_RECORD_IMPORT_WORKFLOW.md) | **批量导入 SOP**:`app_get`(**`import_capability`**)→ `record_import_template_get` → `record_import_verify` →(可选)`record_import_repair_local` → `record_import_start` → `record_import_status_get`;CLI `import` / `record schema import` |
|
|
29
|
+
| [reference/builder/README.md](./reference/builder/README.md) | **Builder 总导航**:完整系统、单应用、字段、布局、视图、报表、门户、按钮/关联资源、流程、发布校验 |
|
|
30
|
+
| [reference/builder/10-build-single-app.md](./reference/builder/10-build-single-app.md) | **单应用搭建主入口**:一个应用端到端交付顺序 |
|
|
31
|
+
| [reference/builder/20-build-complete-system.md](./reference/builder/20-build-complete-system.md) | **完整系统/应用包主入口**:多应用、跨应用 relation、门户闭环 |
|
|
32
|
+
| [reference/builder/30-schema-fields.md](./reference/builder/30-schema-fields.md) | **字段 / schema**:字段类型、直觉别名、数据标题/封面、schema 创建与更新 |
|
|
33
|
+
| [reference/builder/40-layout.md](./reference/builder/40-layout.md) | **布局维护**:仅用于已有应用的分组、`rows` 矩阵、布局修复;新建应用布局写在 `form` 内 |
|
|
34
|
+
| [reference/builder/50-views.md](./reference/builder/50-views.md) | **视图**:表格/卡片/看板/甘特、固定筛选、查询面板、视图 patch |
|
|
35
|
+
| [reference/builder/60-charts.md](./reference/builder/60-charts.md) | **报表 / QingBI**:指标卡、图表、语义聚合、筛选、排序、更新 |
|
|
36
|
+
| [reference/builder/70-portal.md](./reference/builder/70-portal.md) | **门户**:标准工作台、业务入口、指标、图表、视图组件、发布 |
|
|
37
|
+
| [reference/builder/80-buttons-associated-resources.md](./reference/builder/80-buttons-associated-resources.md) | **关联资源 / 非主链路按钮维护**:关联视图/报表、`match_mappings`、上下文传参;按钮配置看视图文档 `action_buttons`;创建自定义按钮前先 `button get --button-text` 查重复用 |
|
|
38
|
+
| [reference/builder/90-workflow.md](./reference/builder/90-workflow.md) | **流程**:WorkflowSpec、`patch_nodes`、角色/成员、流程回读 |
|
|
39
|
+
| [reference/builder/99-publish-verify.md](./reference/builder/99-publish-verify.md) | **发布与回读**:`publish verify`、partial/readback 处理、最终汇报口径 |
|
|
40
|
+
| [reference/QINGFLOW_CLI_RECORD_CREATE_WORKFLOW.md](./reference/record/QINGFLOW_CLI_RECORD_CREATE_WORKFLOW.md) | **新建记录**:`record schema insert -> record insert --items-file`;成员/部门/关联优先自然语言输入;细节见 [record/insert](./reference/record/insert/README.md) |
|
|
41
|
+
| [reference/QINGFLOW_CLI_RECORD_UPDATE_WORKFLOW.md](./reference/record/QINGFLOW_CLI_RECORD_UPDATE_WORKFLOW.md) | **更新记录**:主链路是 `record get` → 智能体按 `fields[]` 组 key/value → **`record update`**;`record schema update` 仅用于失败后诊断可写字段/路径 |
|
|
42
|
+
| [reference/QINGFLOW_CLI_RECORD_DELETE_WORKFLOW.md](./reference/record/QINGFLOW_CLI_RECORD_DELETE_WORKFLOW.md) | **删除记录**:`record list/get` 精确定位 → `record delete --app-key --record-id --view-id system:*`;批量用 `--record-ids-file` |
|
|
43
|
+
| [reference/record/analysis/README.md](./reference/record/analysis/README.md) | **数据分析**:最终统计结论、分析报告、趋势/排名/比例/分布统一走 `record access -> Python/pandas` |
|
|
44
|
+
| [reference/builder/reference/app-delivery-sop.md](./reference/builder/reference/app-delivery-sop.md) | **底层交付参考**:历史完整 SOP、权限、系统列和工具细节;不是主入口 |
|
|
45
|
+
| [reference/builder/reference/workspace-icons.md](./reference/builder/reference/workspace-icons.md) | **图标参考**:`builder icon catalog`;新建应用包、应用、门户必须显式选择非 `template` 的 `icon + color` |
|
|
46
|
+
| [reference/builder/reference/match-rules.md](./reference/builder/reference/match-rules.md) | **统一筛选 / 条件写法**:`filters`、`visible_when`、QingBI 图表筛选、工作流条件、`match_mappings`、`field_mappings` 的 `operator/value` 规则 |
|
|
47
|
+
| [reference/builder/workflow/README.md](./reference/builder/workflow/README.md) | **工作流搭建**:已有应用的流程建模、节点配置、`builder flow apply` 与 `patch_nodes` |
|
|
48
|
+
| [reference/builder/code-integrations/README.md](./reference/builder/code-integrations/README.md) | **代码块 / Q-Linker 配置**:字段别名、绑定、输入字段插入和配置排障 |
|
|
49
|
+
| [reference/QINGFLOW_CLI_ADMIN_CHEATSHEET.md](./reference/core/QINGFLOW_CLI_ADMIN_CHEATSHEET.md) | **管理员**:检索、导入等与成员差异(主技能正文不展开) |
|
|
50
|
+
| [reference/QINGFLOW_CLI_EXPLORATION_REPORT.md](./reference/core/QINGFLOW_CLI_EXPLORATION_REPORT.md) | CLI 实测与 argparse/服务端对齐备忘 |
|
|
51
|
+
| [reference/QINGFLOW_CLI_ONE_SHOT_CHEATSHEET.md](./reference/core/QINGFLOW_CLI_ONE_SHOT_CHEATSHEET.md) | **可选**:全角色「一次可复制」分项模板;默认执行技能**不必**打开 |
|
|
52
|
+
| [scripts/find-app-by-keyword.py](./scripts/find-app-by-keyword.py) | **旧版 CLI 兼容脚本**:当 `app list --query` 不可用时,用 `app list` 本地解析;默认优先使用 `qingflow --json app list --query <关键词>` |
|
|
53
|
+
| [scripts/builder-package-from-app-list.py](./scripts/builder-package-from-app-list.py) | **旧版 CLI 兼容脚本**:当 `builder package list` 不可用时,才从 `app list` 曲线反查 **`package_name` / `resolve-id`**;新版默认直接用 `qingflow --json builder package list --query <关键词>` |
|
|
17
54
|
## CLI 执行与 JSON 落盘
|
|
18
55
|
|
|
19
56
|
1. 已知业务目标时直接执行目标命令,不固定前置 `auth whoami`、`workspace get` 或 `workspace select`。只有业务命令明确返回未登录、会话失效或工作区错误后,才恢复上下文并重试原命令。
|
|
@@ -377,6 +377,16 @@ CLI 将参数与文件内容拼装为服务层载荷,**顶层键**如下:
|
|
|
377
377
|
- `card`:必须配置 `detail`,并在卡片行操作明确时配置 `list`;核心操作视图不要只给 `header`。
|
|
378
378
|
- `gantt`:不适合作为唯一核心操作视图;需要快捷按钮时同时提供配套 table/board 视图,在该操作视图中配置 `list` 和 `detail`。
|
|
379
379
|
|
|
380
|
+
### 6.4.1 绑定已有自定义按钮(`buttons`,查重优先)
|
|
381
|
+
|
|
382
|
+
视图声明里的 `buttons[]` 只做绑定(引用应用级自定义按钮的 `button_id`),不会创建按钮本体。绑定前必须查重,不要重复创建同名按钮:
|
|
383
|
+
|
|
384
|
+
1. **查询**:`qingflow --json builder button get --app-key APP --button-text 按钮文案`(MCP `app_get_buttons` 传 `button_text`),按文案精确匹配。
|
|
385
|
+
2. **有则复用**:唯一命中 → 取返回的 `button_id` 直接写进 `buttons[]`;同名多个 → 用 `button_id` 精确指定(避免歧义)。
|
|
386
|
+
3. **没有再创建**:`builder button apply` 创建(其自身也按文案去重),拿到 `button_id` 后回到视图声明绑定。
|
|
387
|
+
|
|
388
|
+
按钮本体维护(样式/跨视图复用/删除本体)见 [80-buttons-associated-resources.md](./80-buttons-associated-resources.md)。
|
|
389
|
+
|
|
380
390
|
### 6.5 关联视图/报表
|
|
381
391
|
|
|
382
392
|
新版默认不要通过 `app_views_apply.associated_resources` 配置关联资源。应用级关联资源池和视图展示开关统一走 **`app_associated_resources_apply`** / CLI **`builder associated-resource apply`**。
|
|
@@ -510,6 +520,8 @@ qingflow builder views apply \
|
|
|
510
520
|
| 现象 | 处理 |
|
|
511
521
|
|------|------|
|
|
512
522
|
| 列校验失败 / 无有效业务列 | **`app get fields`** 核对 **`name`**;避免 `columns` 仅含 **§7.1 系统列** |
|
|
523
|
+
| 响应含 **`STALE_FIELD_REFERENCE`** / **`STALE_OPTION_REFERENCE`** 警告 | 声明引用了表单中已删除的字段/选项(后端保存视图本就原样透传);apply 已照写并在 `warnings` 留痕,重放/迁移场景属预期,人工整理时再清理死引用 |
|
|
524
|
+
| 系统字段作标题/时间轴 | `title_que_id`/甘特 `start_que_id` 等可直接用系统字段 id(如 2=提交时间),与 `columns` 系统列同规则 |
|
|
513
525
|
| 重名视图 / 删错 | **`app get views`**;更新时带 **`view_key`** |
|
|
514
526
|
| 过滤器未生效疑虑 | 看响应 **`verification.view_filters_verified`**;必要时再 **`app get views`** |
|
|
515
527
|
| 查询条件未生效疑虑 | 看响应 **`verification.view_query_conditions_verified`**;不要把 `query_conditions` 当 `filters` |
|
|
@@ -23,6 +23,7 @@ app get -> app get views/charts/buttons/associated resources -> apply button or
|
|
|
23
23
|
|
|
24
24
|
## Custom buttons
|
|
25
25
|
|
|
26
|
+
- **查重优先(硬规则)**:创建任何自定义按钮前,先 `builder button get --app-key APP --button-text 文案`(MCP `app_get_buttons` 传 `button_text`)按文案精确查重。唯一命中 → 直接复用其 `button_id`,不要创建;未命中 → 再走 `builder button apply` 创建(apply 本身也按 `button_text` 去重:无 `button_id` 时唯一同名匹配会转为更新,双保险);同名多个 → 必须传 `button_id` 精确指定。禁止每次都新建同名按钮。
|
|
26
27
|
- For normal view-level business actions, prefer `builder views apply` with `action_buttons`.
|
|
27
28
|
- Use this standalone button tool for advanced cases: deleting button bodies, reusing the same button across many views, bulk reordering bindings, changing style independently, or configuring qRobot/wings when the user provides exact config.
|
|
28
29
|
- Use `upsert_buttons` for create/update and `patch_buttons` for small changes.
|
|
@@ -44,6 +45,7 @@ Start from user intent:
|
|
|
44
45
|
| Need | Preferred path |
|
|
45
46
|
| --- | --- |
|
|
46
47
|
| Create or patch a normal business button on one view | `builder views apply` with `action_buttons` |
|
|
48
|
+
| 查询/查重已有自定义按钮 | `builder button get --app-key APP --button-text 文案`(唯一命中复用其 `button_id`) |
|
|
47
49
|
| Set style, icon, cross-view reuse, remove button body, reorder bindings | `builder button apply` |
|
|
48
50
|
| Add qRobot/wings behavior | `builder button apply`, only with user-provided exact config |
|
|
49
51
|
| Clear a view's custom button bindings | `builder button apply` with `view_configs[].mode="replace"` and `buttons: []` |
|
|
@@ -11,9 +11,15 @@ from .app_form_compat import normalize_app_form_declaration
|
|
|
11
11
|
from .builder_facade.app_form_schema import AppFormContractError, compact_schema_errors, schema_error_message
|
|
12
12
|
|
|
13
13
|
|
|
14
|
+
#: code_block / q_linker 输出目标字段类型。后端写链无类型白名单(FormSvcImpl
|
|
15
|
+
#: 原样落库),运行时 DataSvcImpl.getAnswerValueByString:选项/数字/日期有专门
|
|
16
|
+
#: 分支;attachment 按拆分 URL 写入(专门分支);subtable 整表组装(专门分支);
|
|
17
|
+
#: rich_text/data_relation 走 Question.canImport 兜底按字符串写入(不报错)。
|
|
18
|
+
#: 四者为存量合法配置(提取读得出、apply 必须写回,两闸门同口径)。
|
|
14
19
|
_INTEGRATION_OUTPUT_TARGET_FIELD_TYPES = {
|
|
15
20
|
"text",
|
|
16
21
|
"long_text",
|
|
22
|
+
"rich_text",
|
|
17
23
|
"number",
|
|
18
24
|
"amount",
|
|
19
25
|
"date",
|
|
@@ -21,6 +27,9 @@ _INTEGRATION_OUTPUT_TARGET_FIELD_TYPES = {
|
|
|
21
27
|
"single_choice",
|
|
22
28
|
"single_select",
|
|
23
29
|
"multi_select",
|
|
30
|
+
"attachment",
|
|
31
|
+
"data_relation",
|
|
32
|
+
"subtable",
|
|
24
33
|
}
|
|
25
34
|
_Q_LINKER_TEMPLATE_RE = re.compile(r"\{\{(?P<key>[^{}\s]+)\}\}")
|
|
26
35
|
_FORMULA_IDENTIFIER_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
|
|
@@ -230,13 +239,20 @@ def _static_errors(payload: dict[str, Any]) -> list[tuple[str, str]]:
|
|
|
230
239
|
if isinstance(field, dict):
|
|
231
240
|
top_fields.append((f"{row_path}/{field_index}", field))
|
|
232
241
|
def resolve_local_selector(selector: Any) -> tuple[str, tuple[str, dict[str, Any]] | None]:
|
|
233
|
-
"""Resolve against the declared body, preserving backend-dependent ID-only cases.
|
|
242
|
+
"""Resolve against the declared body, preserving backend-dependent ID-only cases.
|
|
243
|
+
|
|
244
|
+
返回态:resolved(唯一定位)/ invalid(定位失败)/ ambiguous(方案 A:name-only
|
|
245
|
+
命中多个同名字段,调用方须报 AMBIGUOUS_FIELD_SELECTOR)/ unknown(存在未命名
|
|
246
|
+
字段,后端侧再判)/ schema(结构不完整,交给 Schema 校验)。
|
|
247
|
+
"""
|
|
234
248
|
if not isinstance(selector, dict):
|
|
235
249
|
return "schema", None
|
|
236
250
|
raw_que_id = selector.get("queId")
|
|
237
251
|
que_id = raw_que_id if isinstance(raw_que_id, int) and not isinstance(raw_que_id, bool) else None
|
|
238
252
|
raw_name = selector.get("name")
|
|
239
|
-
name
|
|
253
|
+
# name 两侧 strip:存量字段名带前导/尾随空格,queId 已同时给出不构成歧义
|
|
254
|
+
# (与 compiler 侧三个 resolve 函数同口径)。
|
|
255
|
+
name = raw_name.strip() if isinstance(raw_name, str) and raw_name.strip() else ""
|
|
240
256
|
if raw_que_id is not None and que_id is None:
|
|
241
257
|
return "schema", None
|
|
242
258
|
if raw_name is not None and not name:
|
|
@@ -245,21 +261,38 @@ def _static_errors(payload: dict[str, Any]) -> list[tuple[str, str]]:
|
|
|
245
261
|
matches = [(path, field) for path, field in top_fields if field.get("queId") == que_id]
|
|
246
262
|
if len(matches) != 1:
|
|
247
263
|
return "invalid", None
|
|
248
|
-
declared_name = str(matches[0][1].get("name") or "")
|
|
264
|
+
declared_name = str(matches[0][1].get("name") or "").strip()
|
|
249
265
|
if name and declared_name and name != declared_name:
|
|
250
266
|
return "invalid", None
|
|
251
267
|
return "resolved", matches[0]
|
|
252
268
|
if name:
|
|
253
|
-
matches = [(path, field) for path, field in top_fields if str(field.get("name") or "") == name]
|
|
269
|
+
matches = [(path, field) for path, field in top_fields if str(field.get("name") or "").strip() == name]
|
|
254
270
|
if len(matches) == 1:
|
|
255
271
|
return "resolved", matches[0]
|
|
256
272
|
if len(matches) > 1:
|
|
257
|
-
return "
|
|
273
|
+
return "ambiguous", None
|
|
258
274
|
if any(not str(field.get("name") or "") for _, field in top_fields):
|
|
259
275
|
return "unknown", None
|
|
260
276
|
return "invalid", None
|
|
261
277
|
return "schema", None
|
|
262
278
|
|
|
279
|
+
def ambiguity_message(selector: Any, subject: str) -> str:
|
|
280
|
+
name = str((selector or {}).get("name") or "").strip()
|
|
281
|
+
candidate_ids = sorted(
|
|
282
|
+
{
|
|
283
|
+
field.get("queId")
|
|
284
|
+
for _, field in top_fields
|
|
285
|
+
if str(field.get("name") or "").strip() == name
|
|
286
|
+
and isinstance(field.get("queId"), int)
|
|
287
|
+
and not isinstance(field.get("queId"), bool)
|
|
288
|
+
}
|
|
289
|
+
)
|
|
290
|
+
return (
|
|
291
|
+
f"AMBIGUOUS_FIELD_SELECTOR: {subject} name {name!r} matches multiple fields "
|
|
292
|
+
f"(candidate queIds: {candidate_ids}); duplicate names are allowed but "
|
|
293
|
+
"selectors must use queId to disambiguate"
|
|
294
|
+
)
|
|
295
|
+
|
|
263
296
|
title_selector = spec.get("dataTitleField")
|
|
264
297
|
if top_fields:
|
|
265
298
|
if "dataTitleField" not in spec and "appKey" in payload:
|
|
@@ -271,6 +304,8 @@ def _static_errors(payload: dict[str, Any]) -> list[tuple[str, str]]:
|
|
|
271
304
|
# 显式 selector。
|
|
272
305
|
if "appKey" not in payload:
|
|
273
306
|
errors.append(("/spec/dataTitleField", "dataTitleField must resolve to exactly one top-level field"))
|
|
307
|
+
elif resolve_local_selector(title_selector)[0] == "ambiguous":
|
|
308
|
+
errors.append(("/spec/dataTitleField", ambiguity_message(title_selector, "dataTitleField")))
|
|
274
309
|
elif resolve_local_selector(title_selector)[0] == "invalid":
|
|
275
310
|
errors.append(("/spec/dataTitleField", "dataTitleField must resolve to exactly one top-level field"))
|
|
276
311
|
elif title_selector is not None:
|
|
@@ -279,11 +314,15 @@ def _static_errors(payload: dict[str, Any]) -> list[tuple[str, str]]:
|
|
|
279
314
|
cover_selector = spec.get("dataCoverField")
|
|
280
315
|
if cover_selector is not None:
|
|
281
316
|
cover_resolution, cover = resolve_local_selector(cover_selector)
|
|
282
|
-
if cover_resolution == "
|
|
317
|
+
if cover_resolution == "ambiguous":
|
|
318
|
+
errors.append(("/spec/dataCoverField", ambiguity_message(cover_selector, "dataCoverField")))
|
|
319
|
+
elif cover_resolution == "invalid":
|
|
283
320
|
errors.append(("/spec/dataCoverField", "dataCoverField must resolve to exactly one top-level field"))
|
|
284
321
|
elif cover_resolution == "resolved" and cover is not None and _field_type(cover[1]) != "attachment":
|
|
285
322
|
errors.append(("/spec/dataCoverField", "dataCoverField must select a top-level attachment field"))
|
|
286
|
-
|
|
323
|
+
# 方案 A(duplicate-field-name):同父级允许同名(后端以 queId 为身份,40013
|
|
324
|
+
# 实测 1624 组),不再报 duplicate name;歧义在 selector 解析处以
|
|
325
|
+
# AMBIGUOUS_FIELD_SELECTOR 显式拒绝。
|
|
287
326
|
fields_by_id = {
|
|
288
327
|
field.get("queId"): field
|
|
289
328
|
for _, field in top_fields
|
|
@@ -291,12 +330,6 @@ def _static_errors(payload: dict[str, Any]) -> list[tuple[str, str]]:
|
|
|
291
330
|
}
|
|
292
331
|
output_targets: dict[str, tuple[str, str]] = {}
|
|
293
332
|
for path, field in top_fields:
|
|
294
|
-
name = str(field.get("name") or "")
|
|
295
|
-
if name:
|
|
296
|
-
if name in names:
|
|
297
|
-
errors.append((f"{path}/name", f"duplicate top-level field name; first declared at {names[name]}"))
|
|
298
|
-
else:
|
|
299
|
-
names[name] = f"{path}/name"
|
|
300
333
|
field_type = _field_type(field)
|
|
301
334
|
if field_type == "data_relation":
|
|
302
335
|
config = field.get("config") if isinstance(field.get("config"), dict) else {}
|
|
@@ -342,6 +375,9 @@ def _static_errors(payload: dict[str, Any]) -> list[tuple[str, str]]:
|
|
|
342
375
|
selector = item.get("field") if isinstance(item.get("field"), dict) else {}
|
|
343
376
|
input_path = f"{path}/config/{binding_name}/inputs/{index}"
|
|
344
377
|
resolution, selected = resolve_local_selector(selector)
|
|
378
|
+
if resolution == "ambiguous":
|
|
379
|
+
errors.append((f"{input_path}/field", ambiguity_message(selector, f"{binding_name}.inputs[{index}].field")))
|
|
380
|
+
continue
|
|
345
381
|
if resolution == "invalid":
|
|
346
382
|
errors.append((f"{input_path}/field", "input field must resolve to exactly one top-level field"))
|
|
347
383
|
continue
|
|
@@ -393,13 +429,16 @@ def _static_errors(payload: dict[str, Any]) -> list[tuple[str, str]]:
|
|
|
393
429
|
)
|
|
394
430
|
)
|
|
395
431
|
|
|
396
|
-
producer = name or str(field.get("queId") or field_type)
|
|
432
|
+
producer = str(field.get("name") or "") or str(field.get("queId") or field_type)
|
|
397
433
|
for index, item in enumerate(binding.get("outputs") or []):
|
|
398
434
|
if not isinstance(item, dict):
|
|
399
435
|
continue
|
|
400
436
|
selector = item.get("targetField")
|
|
401
437
|
target_path = f"{path}/config/{binding_name}/outputs/{index}/targetField"
|
|
402
438
|
resolution, selected = resolve_local_selector(selector)
|
|
439
|
+
if resolution == "ambiguous":
|
|
440
|
+
errors.append((target_path, ambiguity_message(selector, f"{binding_name}.outputs[{index}].targetField")))
|
|
441
|
+
continue
|
|
403
442
|
if resolution == "invalid":
|
|
404
443
|
errors.append((target_path, "targetField must resolve to exactly one top-level field"))
|
|
405
444
|
continue
|
|
@@ -18,6 +18,7 @@ from ..tools.app_tools import AppTools
|
|
|
18
18
|
from ..tools.package_tools import PackageTools
|
|
19
19
|
from .app_form_schema import AppFormContractError, AppFormSchemaContext, AppFormSchemaRegistry
|
|
20
20
|
from .service import (
|
|
21
|
+
AmbiguousFieldSelector,
|
|
21
22
|
_coerce_any_int,
|
|
22
23
|
_compile_code_block_binding_fields,
|
|
23
24
|
_compile_q_linker_binding_fields,
|
|
@@ -722,14 +723,26 @@ class AppFormNormalizer:
|
|
|
722
723
|
]
|
|
723
724
|
if fields:
|
|
724
725
|
body.append({"kind": "row", "fields": fields})
|
|
726
|
+
# 空表单 + 悬空 dataTitleQueId/dataCoverQueId(模板安装出的空壳应用常见:
|
|
727
|
+
# 后端 GET 原样吐出指向已不存在字段的 id)读面 canonical 化为 null——
|
|
728
|
+
# 写回 null 后端 normalizeDataTitleQueId 兜底、空表单落库等价,避免
|
|
729
|
+
# validate/apply 的 INVALID_DATA_TITLE_FIELD 拒掉自家写面(round-trip 断裂)。
|
|
730
|
+
has_fields = any(
|
|
731
|
+
bool(block.get("fields")) for block in body if block.get("kind") == "row"
|
|
732
|
+
) or any(
|
|
733
|
+
bool(row.get("fields"))
|
|
734
|
+
for block in body
|
|
735
|
+
if block.get("kind") == "section"
|
|
736
|
+
for row in block.get("rows") or []
|
|
737
|
+
)
|
|
725
738
|
resource = {
|
|
726
739
|
"apiVersion": context.api_version,
|
|
727
740
|
"appKey": app_key,
|
|
728
741
|
"spec": {
|
|
729
742
|
"appName": _compat_label(form.get("formTitle"), "App", app_key),
|
|
730
743
|
"appDescription": form.get("formDesc") or None,
|
|
731
|
-
"dataTitleField": _selector(title_id, field_names.get(title_id or 0)) if title_id is not None else None,
|
|
732
|
-
"dataCoverField": _selector(cover_id, field_names.get(cover_id or 0)) if cover_id is not None else None,
|
|
744
|
+
"dataTitleField": _selector(title_id, field_names.get(title_id or 0)) if title_id is not None and has_fields else None,
|
|
745
|
+
"dataCoverField": _selector(cover_id, field_names.get(cover_id or 0)) if cover_id is not None and has_fields else None,
|
|
733
746
|
"attachments": self._attachments(form.get("formAttach")),
|
|
734
747
|
"serialNumber": self._serial_number(form.get("serialNumType"), form.get("serialNumConfig")),
|
|
735
748
|
"body": body,
|
|
@@ -1743,6 +1756,8 @@ class AppFormCompiler:
|
|
|
1743
1756
|
relation_context["questionRelations"] = relations
|
|
1744
1757
|
internal_fields, relations = _compile_q_linker_binding_fields(fields=internal_fields, current_schema=relation_context)
|
|
1745
1758
|
self._validate_unique_relation_targets(relations, internal_fields)
|
|
1759
|
+
except AmbiguousFieldSelector as error:
|
|
1760
|
+
raise AppFormFailure("AMBIGUOUS_FIELD_SELECTOR", str(error), "CONTRACT_PRECHECK", details={"candidateQueIds": error.candidates}) from error
|
|
1746
1761
|
except (KeyError, ValueError) as error:
|
|
1747
1762
|
raise AppFormFailure("UNSUPPORTED_FIELD_CONFIG", str(error), "CONTRACT_PRECHECK") from error
|
|
1748
1763
|
self._assign_subfield_temp_ids(internal_fields)
|
|
@@ -2073,20 +2088,15 @@ class AppFormCompiler:
|
|
|
2073
2088
|
) -> tuple[JSONObject | None, JSONObject | None]:
|
|
2074
2089
|
body = spec.get("body") or []
|
|
2075
2090
|
fields = list(_iter_fields(body))
|
|
2076
|
-
|
|
2091
|
+
# 方案 A(duplicate-field-name):同父级允许同名——后端以 queId 为字段身份
|
|
2092
|
+
# (40013 实测 1624 组同名),get 提出的合法形态必须能写回;name 解析的
|
|
2093
|
+
# 歧义由各 selector resolver 以 AMBIGUOUS_FIELD_SELECTOR 显式拒绝,
|
|
2094
|
+
# 不在目标校验层做 name 唯一性强校验。
|
|
2077
2095
|
for field in fields:
|
|
2078
|
-
name = str(field.get("name") or "")
|
|
2079
|
-
if name in names:
|
|
2080
|
-
raise AppFormFailure("DUPLICATE_FIELD_NAME", f"duplicate top-level field name: {name}", "CONTRACT_PRECHECK")
|
|
2081
|
-
names.add(name)
|
|
2082
2096
|
if _field_type(field) == "subtable":
|
|
2083
|
-
sub_names: set[str] = set()
|
|
2084
2097
|
for subfield in field.get("subfields") or []:
|
|
2085
2098
|
if _field_type(subfield) not in SUBFIELD_TYPES:
|
|
2086
2099
|
raise AppFormFailure("INVALID_SPEC", "unsupported subtable field type", "CONTRACT_PRECHECK", details={"allowedTypes": sorted(SUBFIELD_TYPES)})
|
|
2087
|
-
if subfield.get("name") in sub_names:
|
|
2088
|
-
raise AppFormFailure("DUPLICATE_FIELD_NAME", f"duplicate subtable field name: {subfield.get('name')}", "CONTRACT_PRECHECK")
|
|
2089
|
-
sub_names.add(str(subfield.get("name") or ""))
|
|
2090
2100
|
title_selector = spec.get("dataTitleField")
|
|
2091
2101
|
data_title: JSONObject | None = None
|
|
2092
2102
|
if fields:
|
|
@@ -2145,6 +2155,8 @@ class AppFormCompiler:
|
|
|
2145
2155
|
relation_context["questionRelations"] = relations
|
|
2146
2156
|
internal_fields, relations = _compile_q_linker_binding_fields(fields=internal_fields, current_schema=relation_context)
|
|
2147
2157
|
self._validate_unique_relation_targets(relations, internal_fields)
|
|
2158
|
+
except AmbiguousFieldSelector as error:
|
|
2159
|
+
raise AppFormFailure("AMBIGUOUS_FIELD_SELECTOR", str(error), "COMPILE", details={"candidateQueIds": error.candidates}) from error
|
|
2148
2160
|
except (KeyError, ValueError) as error:
|
|
2149
2161
|
raise AppFormFailure("UNSUPPORTED_FIELD_CONFIG", str(error), "COMPILE") from error
|
|
2150
2162
|
self._assign_subfield_temp_ids(internal_fields)
|
|
@@ -2626,8 +2638,7 @@ class AppFormCompiler:
|
|
|
2626
2638
|
|
|
2627
2639
|
def _reference_config(self, profile: str, config: JSONObject, template: JSONObject | None = None) -> JSONObject:
|
|
2628
2640
|
target_app_key = str(config.get("targetAppKey") or "")
|
|
2629
|
-
|
|
2630
|
-
candidates = _backend_question_candidates(target_form)
|
|
2641
|
+
candidates, _target_form = self._target_form_candidates(profile, target_app_key)
|
|
2631
2642
|
explicit_display = isinstance(config.get("displayFields"), list)
|
|
2632
2643
|
deprecated_keys = [
|
|
2633
2644
|
key
|
|
@@ -2714,8 +2725,7 @@ class AppFormCompiler:
|
|
|
2714
2725
|
target_app_key = str(relation.get("targetAppKey") or "")
|
|
2715
2726
|
if not target_app_key:
|
|
2716
2727
|
raise AppFormFailure("UNSUPPORTED_FIELD_CONFIG", "select scope relation requires targetAppKey", "COMPILE")
|
|
2717
|
-
target_form = self.
|
|
2718
|
-
candidates = _backend_question_candidates(target_form)
|
|
2728
|
+
candidates, target_form = self._target_form_candidates(profile, target_app_key)
|
|
2719
2729
|
displayed = _resolve_backend_selector(relation.get("displayedField") or {}, candidates, "selectScopeRelation.displayedField")
|
|
2720
2730
|
payload: JSONObject = {
|
|
2721
2731
|
"queId": _positive_int(internal.get("que_id")) or int(internal.get("que_temp_id") or 0),
|
|
@@ -2737,14 +2747,27 @@ class AppFormCompiler:
|
|
|
2737
2747
|
def _resolve_subfield_runtime_id(selector: JSONObject, internal_fields: list[JSONObject], location: str) -> int:
|
|
2738
2748
|
if not isinstance(selector, dict):
|
|
2739
2749
|
raise AppFormFailure("INVALID_FIELD_SELECTOR", f"{location} must be an object", "COMPILE")
|
|
2750
|
+
# 同 _resolve_backend_selector:name 两侧 strip(存量字段名带前后空格,queId 兜底唯一性)。
|
|
2740
2751
|
que_id = _positive_int(selector.get("queId"))
|
|
2741
|
-
name = str(selector.get("name") or "")
|
|
2752
|
+
name = str(selector.get("name") or "").strip()
|
|
2742
2753
|
matches = [
|
|
2743
2754
|
item for item in internal_fields
|
|
2744
2755
|
if (que_id is None or _positive_int(item.get("que_id")) == que_id)
|
|
2745
|
-
and (not name or str(item.get("name") or "") == name)
|
|
2756
|
+
and (not name or str(item.get("name") or "").strip() == name)
|
|
2746
2757
|
]
|
|
2747
2758
|
if len(matches) != 1:
|
|
2759
|
+
if que_id is None and matches:
|
|
2760
|
+
raise AppFormFailure(
|
|
2761
|
+
"AMBIGUOUS_FIELD_SELECTOR",
|
|
2762
|
+
f"{location} matches multiple subfields with the same name; use queId to disambiguate",
|
|
2763
|
+
"COMPILE",
|
|
2764
|
+
details={
|
|
2765
|
+
"selector": selector,
|
|
2766
|
+
"candidateQueIds": sorted(
|
|
2767
|
+
{_positive_int(item.get("que_id")) or 0 for item in matches}
|
|
2768
|
+
),
|
|
2769
|
+
},
|
|
2770
|
+
)
|
|
2748
2771
|
raise AppFormFailure("INVALID_FIELD_SELECTOR", f"{location} must resolve to exactly one subfield", "COMPILE", details={"selector": selector, "matches": len(matches)})
|
|
2749
2772
|
return _positive_int(matches[0].get("que_id")) or int(matches[0].get("que_temp_id") or 0)
|
|
2750
2773
|
|
|
@@ -2796,8 +2819,7 @@ class AppFormCompiler:
|
|
|
2796
2819
|
source_fields = available_internal_fields
|
|
2797
2820
|
source_fields_are_backend = False
|
|
2798
2821
|
if source.get("kind") == "app":
|
|
2799
|
-
|
|
2800
|
-
source_fields = _backend_question_candidates(target_form)
|
|
2822
|
+
source_fields, _target_form = self._target_form_candidates(profile, str(source.get("appKey") or ""))
|
|
2801
2823
|
source_fields_are_backend = True
|
|
2802
2824
|
target = _resolve_backend_selector(source.get("field") or {}, source_fields, "source.field")
|
|
2803
2825
|
relation.update(
|
|
@@ -3237,6 +3259,34 @@ class AppFormCompiler:
|
|
|
3237
3259
|
raise AppFormFailure("FORM_READ_FAILED", "backend returned an invalid form", "READ")
|
|
3238
3260
|
return value
|
|
3239
3261
|
|
|
3262
|
+
def _target_form_candidates(self, profile: str, app_key: str) -> tuple[list[JSONObject], JSONObject]:
|
|
3263
|
+
"""跨表引用解析候选集:目标表 草稿∪发布 并集(queId 为 key,草稿覆盖)。
|
|
3264
|
+
|
|
3265
|
+
后端写链对跨表 queId(referQuestions/displayedQueId/matchRules 源字段)无
|
|
3266
|
+
存在性校验、原样落库(FormSvcImpl.updateFormInfo);链上唯一读目标字段的
|
|
3267
|
+
Formula.matchRulesToFormula 取 question∪draft_question 且草稿覆盖,编辑器
|
|
3268
|
+
候选接口(/question/{appKey}/relation)同样支持 beingDraft 草稿态。get 按
|
|
3269
|
+
草稿姿态提取声明,apply 只读发布会漏掉目标表「只加在草稿、从未发布」的字段
|
|
3270
|
+
(round-trip 断裂);只读草稿又会漏掉发布后草稿里删掉的旧字段——并集与
|
|
3271
|
+
后端写链语义一致。草稿读失败(目标表无草稿等)退回仅发布态。
|
|
3272
|
+
"""
|
|
3273
|
+
draft_form: JSONObject = {}
|
|
3274
|
+
try:
|
|
3275
|
+
draft_form = self._read_form(profile, app_key, being_draft=True)
|
|
3276
|
+
except AppFormFailure:
|
|
3277
|
+
draft_form = {}
|
|
3278
|
+
published_form = self._read_form(profile, app_key, being_draft=False)
|
|
3279
|
+
merged: list[JSONObject] = []
|
|
3280
|
+
seen: set[int] = set()
|
|
3281
|
+
for item in [*_backend_question_candidates(draft_form), *_backend_question_candidates(published_form)]:
|
|
3282
|
+
que_id = _positive_int(item.get("queId"))
|
|
3283
|
+
if que_id is not None:
|
|
3284
|
+
if que_id in seen:
|
|
3285
|
+
continue
|
|
3286
|
+
seen.add(que_id)
|
|
3287
|
+
merged.append(item)
|
|
3288
|
+
return merged, (draft_form or published_form)
|
|
3289
|
+
|
|
3240
3290
|
|
|
3241
3291
|
class AppFormReconciler:
|
|
3242
3292
|
RUNTIME_VERSION = "v1alpha1"
|
|
@@ -4395,33 +4445,46 @@ def _snake_code_block(value: JSONObject) -> JSONObject:
|
|
|
4395
4445
|
|
|
4396
4446
|
|
|
4397
4447
|
def _backend_question_candidates(form: JSONObject) -> list[JSONObject]:
|
|
4448
|
+
"""目标表字段的解析候选集(含表格子字段及其「父表·子字段」前缀变体)。
|
|
4449
|
+
|
|
4450
|
+
遍历与后端对齐:FormSvcImpl.fillMatchRulesList 按 supId 全局解析父表标题、
|
|
4451
|
+
不看容器嵌套层级,FormQuestionSvcImpl 的候选集同样把段落内字段展开——因此
|
|
4452
|
+
表格(queType=18)无论位于 formQues 顶层、baseQues 还是段落(queType=24)
|
|
4453
|
+
内部,其 subQuestions 都是合法候选;段落本身不作为候选。
|
|
4454
|
+
"""
|
|
4398
4455
|
result: list[JSONObject] = []
|
|
4456
|
+
|
|
4457
|
+
def _visit(item: JSONObject) -> None:
|
|
4458
|
+
que_type = _positive_int(item.get("queType"))
|
|
4459
|
+
if que_type == 24:
|
|
4460
|
+
for inner in item.get("innerQuestions") or []:
|
|
4461
|
+
for nested in inner if isinstance(inner, list) else []:
|
|
4462
|
+
if isinstance(nested, dict):
|
|
4463
|
+
_visit(nested)
|
|
4464
|
+
return
|
|
4465
|
+
result.append(item)
|
|
4466
|
+
# 表格(queType=18)子字段同样是合法的目标字段:后端 data_relation
|
|
4467
|
+
# 运行时按 queId 解析目标字段并把表格子字段纳入 targetQuestionMap
|
|
4468
|
+
# (DataRelationCalService 先按 queId 查 question、再把表格的
|
|
4469
|
+
# subQuestions 合入)。displayedQueId 指向表格子字段是存量合法
|
|
4470
|
+
# 配置,提取能读出、编译必须能写回(两闸门同口径)。
|
|
4471
|
+
# matchRules 的筛选源字段(sourceField)在存量里带父表名前缀
|
|
4472
|
+
# (「父表·子字段」,_data_relation_field_name 按同形态写回)。
|
|
4473
|
+
if que_type == 18:
|
|
4474
|
+
table_title = str(item.get("queTitle") or "")
|
|
4475
|
+
for sub in item.get("subQuestions") or []:
|
|
4476
|
+
if isinstance(sub, dict):
|
|
4477
|
+
prefixed = dict(sub)
|
|
4478
|
+
prefixed["_sup_que_title"] = table_title
|
|
4479
|
+
result.append(prefixed)
|
|
4480
|
+
|
|
4399
4481
|
for item in form.get("baseQues") or []:
|
|
4400
4482
|
if isinstance(item, dict):
|
|
4401
|
-
|
|
4483
|
+
_visit(item)
|
|
4402
4484
|
for row in form.get("formQues") or []:
|
|
4403
4485
|
for item in row if isinstance(row, list) else []:
|
|
4404
|
-
if
|
|
4405
|
-
|
|
4406
|
-
if _positive_int(item.get("queType")) == 24:
|
|
4407
|
-
for inner in item.get("innerQuestions") or []:
|
|
4408
|
-
result.extend(nested for nested in inner if isinstance(inner, list) and isinstance(nested, dict))
|
|
4409
|
-
else:
|
|
4410
|
-
result.append(item)
|
|
4411
|
-
# 表格(queType=18)子字段同样是合法的目标字段:后端 data_relation
|
|
4412
|
-
# 运行时按 queId 解析目标字段并把表格子字段纳入 targetQuestionMap
|
|
4413
|
-
# (DataRelationCalService 先按 queId 查 question、再把表格的
|
|
4414
|
-
# subQuestions 合入)。displayedQueId 指向表格子字段是存量合法
|
|
4415
|
-
# 配置,提取能读出、编译必须能写回(两闸门同口径)。
|
|
4416
|
-
# matchRules 的筛选源字段(sourceField)在存量里带父表名前缀
|
|
4417
|
-
# (「父表·子字段」,_data_relation_field_name 按同形态写回)。
|
|
4418
|
-
if _positive_int(item.get("queType")) == 18:
|
|
4419
|
-
table_title = str(item.get("queTitle") or "")
|
|
4420
|
-
for sub in item.get("subQuestions") or []:
|
|
4421
|
-
if isinstance(sub, dict):
|
|
4422
|
-
prefixed = dict(sub)
|
|
4423
|
-
prefixed["_sup_que_title"] = table_title
|
|
4424
|
-
result.append(prefixed)
|
|
4486
|
+
if isinstance(item, dict):
|
|
4487
|
+
_visit(item)
|
|
4425
4488
|
return result
|
|
4426
4489
|
|
|
4427
4490
|
|
|
@@ -4451,29 +4514,58 @@ def _backend_selector_titles(item: JSONObject) -> list[str]:
|
|
|
4451
4514
|
|
|
4452
4515
|
|
|
4453
4516
|
def _resolve_backend_selector(selector: JSONObject, candidates: list[JSONObject], location: str) -> JSONObject:
|
|
4517
|
+
# name 比较两侧 strip:存量字段名带前导/尾随空格(后端无空白校验),声明里的
|
|
4518
|
+
# name 与候选 queTitle 一个空格即失配;queId 已同时给出,空格差异不构成歧义。
|
|
4454
4519
|
que_id = _positive_int(selector.get("queId"))
|
|
4455
|
-
name = str(selector.get("name") or "")
|
|
4520
|
+
name = str(selector.get("name") or "").strip()
|
|
4456
4521
|
matches = [
|
|
4457
4522
|
item
|
|
4458
4523
|
for item in candidates
|
|
4459
4524
|
if (que_id is None or _positive_int(item.get("queId")) == que_id)
|
|
4460
|
-
and (not name or name in _backend_selector_titles(item))
|
|
4525
|
+
and (not name or name in {title.strip() for title in _backend_selector_titles(item)})
|
|
4461
4526
|
]
|
|
4462
4527
|
if len(matches) != 1:
|
|
4528
|
+
# 方案 A:后端允许同父级同名(queId 为身份)。name-only 命中多个时显式报
|
|
4529
|
+
# 歧义并给出候选 queId,禁止静默取第一个。
|
|
4530
|
+
if que_id is None and matches:
|
|
4531
|
+
raise AppFormFailure(
|
|
4532
|
+
"AMBIGUOUS_FIELD_SELECTOR",
|
|
4533
|
+
f"{location} matches multiple fields with the same name; use queId to disambiguate",
|
|
4534
|
+
"CONTRACT_PRECHECK",
|
|
4535
|
+
details={
|
|
4536
|
+
"selector": selector,
|
|
4537
|
+
"candidateQueIds": sorted(
|
|
4538
|
+
{_positive_int(item.get("queId")) or 0 for item in matches}
|
|
4539
|
+
),
|
|
4540
|
+
},
|
|
4541
|
+
)
|
|
4463
4542
|
raise AppFormFailure("INVALID_FIELD_SELECTOR", f"{location} must resolve to exactly one field", "CONTRACT_PRECHECK", details={"selector": selector, "matches": len(matches)})
|
|
4464
4543
|
return matches[0]
|
|
4465
4544
|
|
|
4466
4545
|
|
|
4467
4546
|
def _resolve_canonical_selector(selector: JSONObject, candidates: list[JSONObject], location: str) -> JSONObject:
|
|
4547
|
+
# 同 _resolve_backend_selector:name 两侧 strip(存量字段名带前后空格,queId 兜底唯一性)。
|
|
4468
4548
|
que_id = _positive_int(selector.get("queId"))
|
|
4469
|
-
name = str(selector.get("name") or "")
|
|
4549
|
+
name = str(selector.get("name") or "").strip()
|
|
4470
4550
|
matches = [
|
|
4471
4551
|
item
|
|
4472
4552
|
for item in candidates
|
|
4473
4553
|
if (que_id is None or _positive_int(item.get("queId")) == que_id)
|
|
4474
|
-
and (not name or str(item.get("name") or "") == name)
|
|
4554
|
+
and (not name or str(item.get("name") or "").strip() == name)
|
|
4475
4555
|
]
|
|
4476
4556
|
if len(matches) != 1:
|
|
4557
|
+
if que_id is None and matches:
|
|
4558
|
+
raise AppFormFailure(
|
|
4559
|
+
"AMBIGUOUS_FIELD_SELECTOR",
|
|
4560
|
+
f"{location} matches multiple fields with the same name; use queId to disambiguate",
|
|
4561
|
+
"CONTRACT_PRECHECK",
|
|
4562
|
+
details={
|
|
4563
|
+
"selector": selector,
|
|
4564
|
+
"candidateQueIds": sorted(
|
|
4565
|
+
{_positive_int(item.get("queId")) or 0 for item in matches}
|
|
4566
|
+
),
|
|
4567
|
+
},
|
|
4568
|
+
)
|
|
4477
4569
|
raise AppFormFailure(
|
|
4478
4570
|
"INVALID_FIELD_SELECTOR",
|
|
4479
4571
|
f"{location} must resolve to exactly one top-level field",
|
|
@@ -4484,14 +4576,27 @@ def _resolve_canonical_selector(selector: JSONObject, candidates: list[JSONObjec
|
|
|
4484
4576
|
|
|
4485
4577
|
|
|
4486
4578
|
def _resolve_internal_selector(selector: JSONObject, fields: list[JSONObject], location: str) -> JSONObject:
|
|
4579
|
+
# 同 _resolve_backend_selector:name 两侧 strip(存量字段名带前后空格,queId 兜底唯一性)。
|
|
4487
4580
|
que_id = _positive_int(selector.get("queId") or selector.get("que_id"))
|
|
4488
|
-
name = str(selector.get("name") or "")
|
|
4581
|
+
name = str(selector.get("name") or "").strip()
|
|
4489
4582
|
matches = [
|
|
4490
4583
|
item
|
|
4491
4584
|
for item in fields
|
|
4492
4585
|
if (que_id is None or _positive_int(item.get("que_id")) == que_id)
|
|
4493
|
-
and (not name or str(item.get("name") or "") == name)
|
|
4586
|
+
and (not name or str(item.get("name") or "").strip() == name)
|
|
4494
4587
|
]
|
|
4495
4588
|
if len(matches) != 1:
|
|
4589
|
+
if que_id is None and matches:
|
|
4590
|
+
raise AppFormFailure(
|
|
4591
|
+
"AMBIGUOUS_FIELD_SELECTOR",
|
|
4592
|
+
f"{location} matches multiple fields with the same name; use queId to disambiguate",
|
|
4593
|
+
"COMPILE",
|
|
4594
|
+
details={
|
|
4595
|
+
"selector": selector,
|
|
4596
|
+
"candidateQueIds": sorted(
|
|
4597
|
+
{_positive_int(item.get("que_id")) or 0 for item in matches}
|
|
4598
|
+
),
|
|
4599
|
+
},
|
|
4600
|
+
)
|
|
4496
4601
|
raise AppFormFailure("INVALID_FIELD_SELECTOR", f"{location} must resolve to exactly one field", "COMPILE", details={"selector": selector, "matches": len(matches)})
|
|
4497
4602
|
return matches[0]
|