@qingflow-tech/qingflow-cli 1.1.50-beta.5 → 1.1.51

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.
Files changed (110) hide show
  1. package/README.md +4 -4
  2. package/npm/lib/runtime.mjs +13 -29
  3. package/package.json +1 -1
  4. package/pyproject.toml +2 -8
  5. package/skills/qingflow-cli/SKILL.md +322 -93
  6. package/skills/qingflow-cli/manifest.yaml +1 -1
  7. package/skills/qingflow-cli/reference/00-INDEX.md +4 -4
  8. package/skills/qingflow-cli/reference/QINGFLOW_CLI_ADMIN_CHEATSHEET.md +1 -1
  9. package/skills/qingflow-cli/reference/QINGFLOW_CLI_BUILDER_VIEWS_WORKFLOW.md +9 -3
  10. package/skills/qingflow-cli/reference/QINGFLOW_CLI_DATA_RETRIEVAL_WORKFLOW.md +137 -3
  11. package/skills/qingflow-cli/reference/QINGFLOW_CLI_EXPLORATION_REPORT.md +4 -6
  12. package/skills/qingflow-cli/reference/QINGFLOW_CLI_FIELD_DATA_TYPES.md +14 -14
  13. package/skills/qingflow-cli/reference/QINGFLOW_CLI_MEMBER_CHEATSHEET.md +19 -20
  14. package/skills/qingflow-cli/reference/QINGFLOW_CLI_ONE_SHOT_CHEATSHEET.md +14 -18
  15. package/skills/qingflow-cli/reference/QINGFLOW_CLI_RECORD_CREATE_WORKFLOW.md +12 -7
  16. package/skills/qingflow-cli/reference/QINGFLOW_CLI_RECORD_UPDATE_WORKFLOW.md +1 -2
  17. package/skills/qingflow-cli/reference/builder/20-build-complete-system.md +2 -3
  18. package/skills/qingflow-cli/reference/builder/30-schema-fields.md +5 -9
  19. package/skills/qingflow-cli/reference/builder/50-views.md +191 -192
  20. package/skills/qingflow-cli/reference/builder/80-buttons-associated-resources.md +0 -2
  21. package/skills/qingflow-cli/reference/builder/README.md +1 -1
  22. package/skills/qingflow-cli/reference/builder/reference/app-delivery-sop.md +6 -6
  23. package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_ADMIN_CHEATSHEET.md +1 -1
  24. package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_DATA_RETRIEVAL_WORKFLOW.md +95 -76
  25. package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_EXPLORATION_REPORT.md +4 -6
  26. package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_FIELD_DATA_TYPES.md +13 -13
  27. package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_MEMBER_CHEATSHEET.md +19 -20
  28. package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_ONE_SHOT_CHEATSHEET.md +14 -18
  29. package/skills/qingflow-cli/reference/examples/README.md +1 -1
  30. package/skills/qingflow-cli/reference/examples/views/views_batch_full.example.json +159 -0
  31. package/skills/qingflow-cli/reference/examples/views/views_upsert_table_minimal.example.json +36 -30
  32. package/skills/qingflow-cli/reference/record/QINGFLOW_CLI_RECORD_UPDATE_WORKFLOW.md +1 -2
  33. package/skills/qingflow-cli/reference/record/analysis/README.md +113 -21
  34. package/skills/qingflow-cli/reference/record/analysis/analysis-gotchas.md +10 -10
  35. package/skills/qingflow-cli/reference/record/analysis/analysis-patterns.md +9 -9
  36. package/skills/qingflow-cli/reference/record/analysis/business-context.md +1 -1
  37. package/skills/qingflow-cli/reference/record/analysis/confidence-reporting.md +10 -10
  38. package/skills/qingflow-cli/reference/record/analysis/data-access-playbook.md +80 -52
  39. package/skills/qingflow-cli/reference/record/analysis/pandas-recipes.md +2 -2
  40. package/skills/qingflow-cli/reference/views_upsert_table_minimal.example.json +42 -0
  41. package/skills/qingflow-common/SKILL.md +4 -7
  42. package/src/qingflow_mcp/__init__.py +1 -1
  43. package/src/qingflow_mcp/app_form_compat.py +0 -3
  44. package/src/qingflow_mcp/app_form_validator.py +17 -69
  45. package/src/qingflow_mcp/builder_facade/app_form.py +100 -497
  46. package/src/qingflow_mcp/builder_facade/models.py +17 -227
  47. package/src/qingflow_mcp/builder_facade/schemas/app_form/manifest.json +0 -1
  48. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/complete.json +30 -64
  49. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/address.json +8 -4
  50. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/amount.json +8 -4
  51. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/attachment.json +10 -6
  52. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/code_block.json +10 -6
  53. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/data_relation.json +18 -11
  54. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/date.json +8 -4
  55. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/datetime.json +8 -4
  56. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/department.json +10 -6
  57. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/email.json +8 -4
  58. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/link.json +8 -4
  59. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/long_text.json +8 -4
  60. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/member.json +10 -6
  61. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/multi_select.json +8 -4
  62. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/number.json +8 -4
  63. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/phone.json +8 -4
  64. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/q_linker.json +10 -6
  65. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/relation.json +15 -46
  66. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/single_choice.json +8 -4
  67. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/single_select.json +8 -4
  68. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/subtable.json +27 -61
  69. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/text.json +8 -4
  70. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/formula.json +0 -43
  71. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/summary.json +12 -7
  72. package/src/qingflow_mcp/builder_facade/service.py +539 -1671
  73. package/src/qingflow_mcp/builder_facade/workflow_spec.py +1 -1
  74. package/src/qingflow_mcp/cli/commands/__init__.py +1 -2
  75. package/src/qingflow_mcp/cli/commands/builder.py +55 -76
  76. package/src/qingflow_mcp/cli/commands/record.py +1 -1
  77. package/src/qingflow_mcp/cli/context.py +0 -3
  78. package/src/qingflow_mcp/cli/formatters.py +1 -97
  79. package/src/qingflow_mcp/config.py +1 -0
  80. package/src/qingflow_mcp/public_surface.py +0 -8
  81. package/src/qingflow_mcp/response_trim.py +2 -11
  82. package/src/qingflow_mcp/server.py +0 -2
  83. package/src/qingflow_mcp/server_app_builder.py +10 -34
  84. package/src/qingflow_mcp/server_app_user.py +0 -4
  85. package/src/qingflow_mcp/solution/compiler/view_compiler.py +1 -2
  86. package/src/qingflow_mcp/solution/executor.py +8 -96
  87. package/src/qingflow_mcp/solution/spec_models.py +0 -1
  88. package/src/qingflow_mcp/tools/ai_builder_tools.py +398 -218
  89. package/src/qingflow_mcp/tools/base.py +42 -10
  90. package/src/qingflow_mcp/tools/record_tools.py +46 -11
  91. package/src/qingflow_mcp/tools/solution_tools.py +0 -2
  92. package/src/qingflow_mcp/tools/view_tools.py +6 -0
  93. package/skills/qingflow-cli/reference/data/README.md +0 -32
  94. package/skills/qingflow-cli/reference/examples/views/views_apply_v1alpha1.example.json +0 -14
  95. package/skills/qingflow-cli/reference/record/QINGFLOW_CLI_RECORD_SPECIALIZED_WORKFLOW.md +0 -50
  96. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/rich_text.json +0 -146
  97. package/src/qingflow_mcp/builder_facade/schemas/views/manifest.json +0 -42
  98. package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/complete.json +0 -1245
  99. package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/details/button-config.json +0 -53
  100. package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/details/data-permission.json +0 -60
  101. package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/details/display.json +0 -73
  102. package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/details/operation-permission.json +0 -48
  103. package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/details/query-condition.json +0 -47
  104. package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/summary.json +0 -319
  105. package/src/qingflow_mcp/builder_facade/views_extract.py +0 -592
  106. package/src/qingflow_mcp/builder_facade/views_schema.py +0 -400
  107. package/src/qingflow_mcp/cli/commands/data.py +0 -134
  108. package/src/qingflow_mcp/tools/csv_export.py +0 -168
  109. package/src/qingflow_mcp/tools/data_tools.py +0 -284
  110. package/src/qingflow_mcp/tools/view_writer.py +0 -44
@@ -1,78 +1,106 @@
1
- # data CSV 取数状态机
1
+ # Data Access Playbook
2
2
 
3
- ## 必须路径
3
+ This file is the operational state machine for `record_access`.
4
4
 
5
- 1. 明确 `app_key` 与指标意图。
6
- 2. 从当前 data schema 或已确认上下文选择字段 id。
7
- 3. 构造 columns、where、sort JSON 文件。
8
- 4. 执行 `data query --all --output csv`。
9
- 5. 读取全部 `files[].local_path` 并在 Python/pandas 中合并。
5
+ ## Required Sequence
10
6
 
11
- CLI 发现与调用:
7
+ 1. `app_get`
8
+ 2. choose `view_id` from `accessible_views`
9
+ 3. `record_browse_schema_get(app_key, view_id)`
10
+ 4. build `record_access.columns / where / order_by` from field ids
11
+ 5. run `record_access`
12
+ 6. read every CSV shard with Python
13
+
14
+ Do not call `record_access` with field titles, guessed ids, page controls, row limits, or profile.
15
+ CSV columns are readable and field-id anchored, for example `项目状态__field_343283094`; do not look for extra `schema.json` or README files.
16
+
17
+ ## Finding `record_access`
18
+
19
+ `record_access` can be available as an MCP tool or as a CLI subcommand. If the MCP surface does not show a top-level `record_access` tool, look under the Qingflow CLI record group before choosing any other path:
20
+
21
+ ```bash
22
+ qingflow record --help
23
+ qingflow record access --help
24
+ ```
25
+
26
+ The CLI call is:
12
27
 
13
28
  ```bash
14
- qingflow data --help
15
- qingflow data query --help
16
- qingflow --json data query \
29
+ qingflow record access \
17
30
  --app-key APP_KEY \
31
+ --view-id VIEW_ID \
18
32
  --columns-file columns.json \
19
33
  --where-file where.json \
20
- --sort-file sort.json \
21
- --all --output csv
34
+ --order-by-file order_by.json \
35
+ --json
22
36
  ```
23
37
 
24
- columns 是字段 id 数组,例如 `[2, 18]`;where 和 sort 是带 `field_id` 的对象数组。`--all` 与 `--output csv` 必须同时出现。
38
+ This is the same analysis path: it writes CSV shards and metadata for Python. Do not replace it with list browsing, export, QingBI, or aggregate helpers just because the MCP tool is not visible.
39
+
40
+ ## Request Patterns
25
41
 
26
- ## 真实输出字段
42
+ ### Count or distribution
27
43
 
28
- 分析前先读取:
44
+ Fetch the grouping field and any time/business filter field.
29
45
 
30
- - `status`
31
- - `files[].local_path`、`files[].row_count`、`files[].part`
32
- - `row_count`
33
- - `complete`
34
- - `truncated`
35
- - `safe_for_final_conclusion`
36
- - `fetched_pages`
37
- - `stopped_reason`
38
- - `fields`
46
+ ```json
47
+ {
48
+ "app_key": "APP_KEY",
49
+ "view_id": "system:all",
50
+ "columns": [{ "field_id": 18 }],
51
+ "where": [{ "field_id": 2, "op": "between", "value": ["2026-05-01", "2026-05-31"] }],
52
+ "order_by": []
53
+ }
54
+ ```
39
55
 
40
- 输出还可能包含 `local_dir`、访问上下文和动态提示;这些内容保持原样解释,不在本地补造。
56
+ ### Trend
41
57
 
42
- ## 状态决策
58
+ Fetch the date/time field plus metric fields.
43
59
 
44
- | 状态 | 含义 | 动作 |
60
+ ```json
61
+ {
62
+ "app_key": "APP_KEY",
63
+ "view_id": "system:all",
64
+ "columns": [{ "field_id": 2 }, { "field_id": 18 }],
65
+ "where": [{ "field_id": 2, "op": "between", "value": ["2026-01-01", "2026-12-31"] }],
66
+ "order_by": [{ "field_id": 2, "direction": "asc" }]
67
+ }
68
+ ```
69
+
70
+ ### Ratio
71
+
72
+ If numerator and denominator use different filters, run separate `record_access` calls. Only compute the ratio after both source datasets are complete and compatible.
73
+
74
+ ## Status Decisions
75
+
76
+ | Status | Meaning | Agent action |
45
77
  |---|---|---|
46
- | `success` 且完整性三项满足 | 请求范围完整可用 | 读取所有分片后形成总体结论 |
47
- | `needs_scope` | 无范围大查询被拒绝,未写 CSV | 读取 `scope.reported_total` 与 `scope.row_threshold`,请求时间或业务范围 |
48
- | `partial` | 已写部分 CSV,但未完成 | 只给局部观察,说明 `row_count` 与 `stopped_reason` |
49
- | `complete=false` | 未取完请求范围 | 不给总体结论 |
50
- | `truncated=true` | 提前停止 | 收窄范围后重试 |
51
- | `safe_for_final_conclusion=false` | 不适合最终总体结论 | 降级或重试 |
78
+ | `success` + `safe_for_final_conclusion=true` | Full retrieved scope is reliable | Give final conclusion |
79
+ | `needs_scope` | Tool refused large unbounded scan, no CSV | Ask for scope or retry with explicit period/business filter |
80
+ | `partial` | Some CSV files written, but not full data | Give only subset observation |
81
+ | `complete=false` | Not all requested data is available | Do not present full-population conclusion |
82
+ | `truncated=true` | Tool had to stop before full scope | Disclose and narrow scope |
52
83
 
53
- `needs_scope` 时 `files=[]`、`row_count=0`;不要寻找不存在的 CSV。partial 时保留的文件可用于局部观察,但不能使用“全部”“整体”“全量”等措辞。
84
+ ## `needs_scope` Recovery
54
85
 
55
- ## 请求示例
86
+ Use the returned `scope` object:
56
87
 
57
- `columns.json`:
88
+ - `reported_total`: explain why scope is needed
89
+ - `suggested_time_fields`: choose likely date fields
90
+ - `recommended_where_examples`: reuse if they match the user request
58
91
 
59
- ```json
60
- [2, 18]
61
- ```
92
+ If the user already provided a concrete month/quarter/year, retry with that period. If no business boundary is available, ask one short clarification.
62
93
 
63
- `where.json`(同一字段的范围使用两个公开操作符并按 AND 组合):
94
+ ## `partial` Recovery
64
95
 
65
- ```json
66
- [
67
- {"field_id": 2, "op": "gte", "value": "2026-05-01"},
68
- {"field_id": 2, "op": "lte", "value": "2026-05-31"}
69
- ]
70
- ```
96
+ You may read the files, but must label output as partial:
71
97
 
72
- `sort.json`:
98
+ - say which files/rows were analyzed
99
+ - do not use `全部`, `所有`, `整体`, or `全量`
100
+ - suggest narrowing time or business scope before final conclusion
73
101
 
74
- ```json
75
- [{"field_id": 2, "direction": "asc"}]
76
- ```
102
+ ## View Scope
103
+
104
+ For custom views, the result is scoped to that saved view. If `verification.view_filter_verified=false`, disclose that the saved-filter scope could not be fully verified.
77
105
 
78
- 若分子和分母使用不同筛选,分别执行兼容范围的 data query;两侧都满足完整性门禁后再计算比例。
106
+ For board/gantt views, switch to a table-style view or `system:all` plus explicit filters.
@@ -9,14 +9,14 @@ import pandas as pd
9
9
 
10
10
  files = [
11
11
  "/absolute/path/records-0001.csv",
12
- # 包含 data query 返回的每个 files[].local_path
12
+ # include every record_access.files[].local_path
13
13
  ]
14
14
 
15
15
  frames = [pd.read_csv(path, dtype=str, keep_default_na=False) for path in files]
16
16
  df = pd.concat(frames, ignore_index=True) if frames else pd.DataFrame()
17
17
 
18
18
  # Columns are readable and field-id anchored, e.g. 项目状态__field_343283094.
19
- fields = [] # 需要字段 id 元数据时使用 data query 返回的 fields
19
+ fields = [] # optionally paste record_access.fields here if you need field-id metadata
20
20
  field_by_id = {int(item["field_id"]): item for item in fields if "field_id" in item}
21
21
  title_by_col = {item["column_name"]: item["title"] for item in fields if item.get("column_name")}
22
22
  ```
@@ -0,0 +1,42 @@
1
+ {
2
+ "views": [
3
+ {
4
+ "operation": "upsert",
5
+ "app_key": "APP_KEY",
6
+ "name": "示例业务操作视图_CLI模板",
7
+ "type": "table",
8
+ "columns": ["主记录编号", "主记录名称", "状态", "负责人"],
9
+ "filters": [
10
+ {"field_name": "状态", "operator": "neq", "value": "已关闭"}
11
+ ],
12
+ "query_conditions": {
13
+ "enabled": true,
14
+ "exact": false,
15
+ "hide_before_query": false,
16
+ "rows": [["主记录编号", "主记录名称"], ["状态", "负责人"]]
17
+ },
18
+ "action_buttons": [
19
+ {
20
+ "text": "行内创建子记录",
21
+ "action": "add_data",
22
+ "target_app_key": "TARGET_APP_KEY",
23
+ "field_mappings": [
24
+ {"source_field": "数据ID", "target_field": "关联主记录"}
25
+ ],
26
+ "default_values": {"目标状态字段": "REPLACE_WITH_TARGET_OPTION_LABEL"},
27
+ "placement": "list"
28
+ },
29
+ {
30
+ "text": "详情创建子记录",
31
+ "action": "add_data",
32
+ "target_app_key": "TARGET_APP_KEY",
33
+ "field_mappings": [
34
+ {"source_field": "数据ID", "target_field": "关联主记录"}
35
+ ],
36
+ "default_values": {"目标状态字段": "REPLACE_WITH_TARGET_OPTION_LABEL"},
37
+ "placement": "detail"
38
+ }
39
+ ]
40
+ }
41
+ ]
42
+ }
@@ -19,8 +19,7 @@ Do not require both surfaces. If only one is installed, complete the workflow on
19
19
  ## Shared Concepts
20
20
 
21
21
  - **AppForm** is the versioned declarative definition for an application form: app metadata, fields, sections, layout, settings, and field configuration. The same JSON declaration is used by CLI and Qingflow MCP.
22
- - **Data** 是既有应用中的普通业务数据。查询、单条读取和直接增删改默认使用视图无关的 data 入口,并以当次 schema、字段能力和访问范围为准。
23
- - **Record** 用于详情页附件/图片、完整日志、导入、代码块,以及用户明确指定的 record 命令;不要把普通应用数据工作路由到 record。
22
+ - **Record** is runtime data written to an existing app. Use the record schema before an insert, update, import, or analysis workflow.
24
23
  - **Task** is a workflow work item. Prefer task operations for approval, transfer, rollback, and assignee actions.
25
24
  - **Builder resource** is package, app, view, chart, portal, navigation, or workflow configuration. It is separate from record data.
26
25
  - **Schema version** is part of the public contract. Pin it for one AppForm or workflow operation and do not combine values from different versions.
@@ -31,13 +30,11 @@ Do not require both surfaces. If only one is installed, complete the workflow on
31
30
  | --- | --- | --- |
32
31
  | Form schema/read/apply/delete | `qingflow --json builder app-form ...` / `builder app delete` | `app_form_schema`, `app_form_get`, `app_form_apply`, `app_delete` |
33
32
  | AppForm static validation | `qingflow --json builder app-form validate --file ...` | Performed inside `app_form_apply`; no separate tool |
34
- | 普通应用数据查询与直接增删改 | `qingflow --json data ...` | Qingflow MCP `data_query/get/insert/update/delete` |
35
- | 日志、附件、导入、代码块或用户指定的 record 命令 | `qingflow --json record ...` / `import ...` | Qingflow MCP 对应 record/import 专项工具 |
36
- | 待办与流程动作 | `qingflow --json task ...` | Qingflow MCP task tools |
37
- | 免登录打开网页或指定业务页面 | `qingflow --json auth login-link [--redirect-path PATH]` | `get_login_link` |
33
+ | Record and task work | `qingflow --json record ...` / `task ...` | Qingflow MCP record and task tools |
34
+ | Open a workspace or confirmed business page | `qingflow --json auth login-link [--redirect-path PATH]` | `get_login_link` |
38
35
  | Package, view, portal, chart, workflow | `qingflow --json builder ...` | Qingflow MCP configuration tools |
39
36
 
40
- The operation names and payload schemas are defined by the public surface. Use the current Schema or tool input schema as the source of truth, not another command's shape or a backend payload.
37
+ The operation names and payload schemas are defined by the public surface. Use the current Schema or tool input schema as the source of truth, not a legacy command name or backend payload.
41
38
 
42
39
  ## Package Boundaries
43
40
 
@@ -5,7 +5,7 @@ from pathlib import Path
5
5
 
6
6
  __all__ = ["__version__"]
7
7
 
8
- _FALLBACK_VERSION = "0.2.0b1099"
8
+ _FALLBACK_VERSION = "0.2.0b1101"
9
9
 
10
10
 
11
11
  def _resolve_local_pyproject_version() -> str | None:
@@ -10,9 +10,6 @@ from .json_types import JSONObject
10
10
  TYPE_ALIASES = {
11
11
  "multiline": "long_text",
12
12
  "textarea": "long_text",
13
- "richtext": "rich_text",
14
- "rich-text": "rich_text",
15
- "rich_text": "rich_text",
16
13
  "radio": "single_choice",
17
14
  "boolean": "single_choice",
18
15
  "select": "single_select",
@@ -11,15 +11,9 @@ 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 必须写回,两闸门同口径)。
19
14
  _INTEGRATION_OUTPUT_TARGET_FIELD_TYPES = {
20
15
  "text",
21
16
  "long_text",
22
- "rich_text",
23
17
  "number",
24
18
  "amount",
25
19
  "date",
@@ -27,9 +21,6 @@ _INTEGRATION_OUTPUT_TARGET_FIELD_TYPES = {
27
21
  "single_choice",
28
22
  "single_select",
29
23
  "multi_select",
30
- "attachment",
31
- "data_relation",
32
- "subtable",
33
24
  }
34
25
  _Q_LINKER_TEMPLATE_RE = re.compile(r"\{\{(?P<key>[^{}\s]+)\}\}")
35
26
  _FORMULA_IDENTIFIER_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
@@ -227,32 +218,19 @@ def _static_errors(payload: dict[str, Any]) -> list[tuple[str, str]]:
227
218
  widths = ["width" in field for field in fields if isinstance(field, dict)]
228
219
  if widths and any(widths) and not all(widths):
229
220
  errors.append((row_path, "width must be present on every field in the row or omitted from all"))
230
- # 行宽契约(与后端语义对齐):任意 1-100 整数 + 全有或全无。
231
- # 后端 html_width 无取值/总和校验,存量存在 33/34 三等分与字段删除后
232
- # 留白(如 3×25=75)的行;get 读出的原布局必须能原样过校验
233
- # (apply(get(S)) ≡ S 命题的必要条件)。
234
- if widths and all(widths):
235
- bad = [f.get("width") for f in fields if isinstance(f, dict) and not (isinstance(f.get("width"), int) and 1 <= f["width"] <= 100)]
236
- if bad:
237
- errors.append((row_path, "field widths must be an integer between 1 and 100"))
221
+ if widths and all(widths) and sum(field.get("width", 0) for field in fields if isinstance(field, dict)) != 100:
222
+ errors.append((row_path, "field widths must total 100"))
238
223
  for field_index, field in enumerate(fields):
239
224
  if isinstance(field, dict):
240
225
  top_fields.append((f"{row_path}/{field_index}", field))
241
226
  def resolve_local_selector(selector: Any) -> tuple[str, tuple[str, dict[str, Any]] | None]:
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
- """
227
+ """Resolve against the declared body, preserving backend-dependent ID-only cases."""
248
228
  if not isinstance(selector, dict):
249
229
  return "schema", None
250
230
  raw_que_id = selector.get("queId")
251
231
  que_id = raw_que_id if isinstance(raw_que_id, int) and not isinstance(raw_que_id, bool) else None
252
232
  raw_name = selector.get("name")
253
- # name 两侧 strip:存量字段名带前导/尾随空格,queId 已同时给出不构成歧义
254
- # (与 compiler 侧三个 resolve 函数同口径)。
255
- name = raw_name.strip() if isinstance(raw_name, str) and raw_name.strip() else ""
233
+ name = raw_name if isinstance(raw_name, str) and raw_name else ""
256
234
  if raw_que_id is not None and que_id is None:
257
235
  return "schema", None
258
236
  if raw_name is not None and not name:
@@ -261,52 +239,26 @@ def _static_errors(payload: dict[str, Any]) -> list[tuple[str, str]]:
261
239
  matches = [(path, field) for path, field in top_fields if field.get("queId") == que_id]
262
240
  if len(matches) != 1:
263
241
  return "invalid", None
264
- declared_name = str(matches[0][1].get("name") or "").strip()
242
+ declared_name = str(matches[0][1].get("name") or "")
265
243
  if name and declared_name and name != declared_name:
266
244
  return "invalid", None
267
245
  return "resolved", matches[0]
268
246
  if name:
269
- matches = [(path, field) for path, field in top_fields if str(field.get("name") or "").strip() == name]
247
+ matches = [(path, field) for path, field in top_fields if str(field.get("name") or "") == name]
270
248
  if len(matches) == 1:
271
249
  return "resolved", matches[0]
272
250
  if len(matches) > 1:
273
- return "ambiguous", None
251
+ return "invalid", None
274
252
  if any(not str(field.get("name") or "") for _, field in top_fields):
275
253
  return "unknown", None
276
254
  return "invalid", None
277
255
  return "schema", None
278
256
 
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
-
296
257
  title_selector = spec.get("dataTitleField")
297
258
  if top_fields:
298
259
  if "dataTitleField" not in spec and "appKey" in payload:
299
260
  pass
300
- elif title_selector is None:
301
- # dataTitleField=null 仅在带 appKey 的更新/round-trip 场景放行:后端写链
302
- # FormSvcImpl.normalizeDataTitleQueId 对 null 自动 fallback 到第一个可用
303
- # 字段,get 读面在后端未设置时原样输出 null。创建场景(无 appKey)仍需
304
- # 显式 selector。
305
- if "appKey" not in payload:
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")))
309
- elif resolve_local_selector(title_selector)[0] == "invalid":
261
+ elif title_selector is None or resolve_local_selector(title_selector)[0] == "invalid":
310
262
  errors.append(("/spec/dataTitleField", "dataTitleField must resolve to exactly one top-level field"))
311
263
  elif title_selector is not None:
312
264
  errors.append(("/spec/dataTitleField", "dataTitleField must be null when body is empty"))
@@ -314,15 +266,11 @@ def _static_errors(payload: dict[str, Any]) -> list[tuple[str, str]]:
314
266
  cover_selector = spec.get("dataCoverField")
315
267
  if cover_selector is not None:
316
268
  cover_resolution, cover = resolve_local_selector(cover_selector)
317
- if cover_resolution == "ambiguous":
318
- errors.append(("/spec/dataCoverField", ambiguity_message(cover_selector, "dataCoverField")))
319
- elif cover_resolution == "invalid":
269
+ if cover_resolution == "invalid":
320
270
  errors.append(("/spec/dataCoverField", "dataCoverField must resolve to exactly one top-level field"))
321
271
  elif cover_resolution == "resolved" and cover is not None and _field_type(cover[1]) != "attachment":
322
272
  errors.append(("/spec/dataCoverField", "dataCoverField must select a top-level attachment field"))
323
- # 方案 A(duplicate-field-name):同父级允许同名(后端以 queId 为身份,40013
324
- # 实测 1624 组),不再报 duplicate name;歧义在 selector 解析处以
325
- # AMBIGUOUS_FIELD_SELECTOR 显式拒绝。
273
+ names: dict[str, str] = {}
326
274
  fields_by_id = {
327
275
  field.get("queId"): field
328
276
  for _, field in top_fields
@@ -330,6 +278,12 @@ def _static_errors(payload: dict[str, Any]) -> list[tuple[str, str]]:
330
278
  }
331
279
  output_targets: dict[str, tuple[str, str]] = {}
332
280
  for path, field in top_fields:
281
+ name = str(field.get("name") or "")
282
+ if name:
283
+ if name in names:
284
+ errors.append((f"{path}/name", f"duplicate top-level field name; first declared at {names[name]}"))
285
+ else:
286
+ names[name] = f"{path}/name"
333
287
  field_type = _field_type(field)
334
288
  if field_type == "data_relation":
335
289
  config = field.get("config") if isinstance(field.get("config"), dict) else {}
@@ -375,9 +329,6 @@ def _static_errors(payload: dict[str, Any]) -> list[tuple[str, str]]:
375
329
  selector = item.get("field") if isinstance(item.get("field"), dict) else {}
376
330
  input_path = f"{path}/config/{binding_name}/inputs/{index}"
377
331
  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
381
332
  if resolution == "invalid":
382
333
  errors.append((f"{input_path}/field", "input field must resolve to exactly one top-level field"))
383
334
  continue
@@ -429,16 +380,13 @@ def _static_errors(payload: dict[str, Any]) -> list[tuple[str, str]]:
429
380
  )
430
381
  )
431
382
 
432
- producer = str(field.get("name") or "") or str(field.get("queId") or field_type)
383
+ producer = name or str(field.get("queId") or field_type)
433
384
  for index, item in enumerate(binding.get("outputs") or []):
434
385
  if not isinstance(item, dict):
435
386
  continue
436
387
  selector = item.get("targetField")
437
388
  target_path = f"{path}/config/{binding_name}/outputs/{index}/targetField"
438
389
  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
442
390
  if resolution == "invalid":
443
391
  errors.append((target_path, "targetField must resolve to exactly one top-level field"))
444
392
  continue