@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.
- package/README.md +4 -4
- package/npm/lib/runtime.mjs +13 -29
- package/package.json +1 -1
- package/pyproject.toml +2 -8
- package/skills/qingflow-cli/SKILL.md +322 -93
- package/skills/qingflow-cli/manifest.yaml +1 -1
- package/skills/qingflow-cli/reference/00-INDEX.md +4 -4
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_ADMIN_CHEATSHEET.md +1 -1
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_BUILDER_VIEWS_WORKFLOW.md +9 -3
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_DATA_RETRIEVAL_WORKFLOW.md +137 -3
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_EXPLORATION_REPORT.md +4 -6
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_FIELD_DATA_TYPES.md +14 -14
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_MEMBER_CHEATSHEET.md +19 -20
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_ONE_SHOT_CHEATSHEET.md +14 -18
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_RECORD_CREATE_WORKFLOW.md +12 -7
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_RECORD_UPDATE_WORKFLOW.md +1 -2
- package/skills/qingflow-cli/reference/builder/20-build-complete-system.md +2 -3
- package/skills/qingflow-cli/reference/builder/30-schema-fields.md +5 -9
- package/skills/qingflow-cli/reference/builder/50-views.md +191 -192
- package/skills/qingflow-cli/reference/builder/80-buttons-associated-resources.md +0 -2
- package/skills/qingflow-cli/reference/builder/README.md +1 -1
- package/skills/qingflow-cli/reference/builder/reference/app-delivery-sop.md +6 -6
- package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_ADMIN_CHEATSHEET.md +1 -1
- package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_DATA_RETRIEVAL_WORKFLOW.md +95 -76
- package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_EXPLORATION_REPORT.md +4 -6
- package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_FIELD_DATA_TYPES.md +13 -13
- package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_MEMBER_CHEATSHEET.md +19 -20
- package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_ONE_SHOT_CHEATSHEET.md +14 -18
- package/skills/qingflow-cli/reference/examples/README.md +1 -1
- package/skills/qingflow-cli/reference/examples/views/views_batch_full.example.json +159 -0
- package/skills/qingflow-cli/reference/examples/views/views_upsert_table_minimal.example.json +36 -30
- package/skills/qingflow-cli/reference/record/QINGFLOW_CLI_RECORD_UPDATE_WORKFLOW.md +1 -2
- package/skills/qingflow-cli/reference/record/analysis/README.md +113 -21
- package/skills/qingflow-cli/reference/record/analysis/analysis-gotchas.md +10 -10
- package/skills/qingflow-cli/reference/record/analysis/analysis-patterns.md +9 -9
- package/skills/qingflow-cli/reference/record/analysis/business-context.md +1 -1
- package/skills/qingflow-cli/reference/record/analysis/confidence-reporting.md +10 -10
- package/skills/qingflow-cli/reference/record/analysis/data-access-playbook.md +80 -52
- package/skills/qingflow-cli/reference/record/analysis/pandas-recipes.md +2 -2
- package/skills/qingflow-cli/reference/views_upsert_table_minimal.example.json +42 -0
- package/skills/qingflow-common/SKILL.md +4 -7
- package/src/qingflow_mcp/__init__.py +1 -1
- package/src/qingflow_mcp/app_form_compat.py +0 -3
- package/src/qingflow_mcp/app_form_validator.py +17 -69
- package/src/qingflow_mcp/builder_facade/app_form.py +100 -497
- package/src/qingflow_mcp/builder_facade/models.py +17 -227
- package/src/qingflow_mcp/builder_facade/schemas/app_form/manifest.json +0 -1
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/complete.json +30 -64
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/address.json +8 -4
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/amount.json +8 -4
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/attachment.json +10 -6
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/code_block.json +10 -6
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/data_relation.json +18 -11
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/date.json +8 -4
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/datetime.json +8 -4
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/department.json +10 -6
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/email.json +8 -4
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/link.json +8 -4
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/long_text.json +8 -4
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/member.json +10 -6
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/multi_select.json +8 -4
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/number.json +8 -4
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/phone.json +8 -4
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/q_linker.json +10 -6
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/relation.json +15 -46
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/single_choice.json +8 -4
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/single_select.json +8 -4
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/subtable.json +27 -61
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/text.json +8 -4
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/formula.json +0 -43
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/summary.json +12 -7
- package/src/qingflow_mcp/builder_facade/service.py +539 -1671
- package/src/qingflow_mcp/builder_facade/workflow_spec.py +1 -1
- package/src/qingflow_mcp/cli/commands/__init__.py +1 -2
- package/src/qingflow_mcp/cli/commands/builder.py +55 -76
- package/src/qingflow_mcp/cli/commands/record.py +1 -1
- package/src/qingflow_mcp/cli/context.py +0 -3
- package/src/qingflow_mcp/cli/formatters.py +1 -97
- package/src/qingflow_mcp/config.py +1 -0
- package/src/qingflow_mcp/public_surface.py +0 -8
- package/src/qingflow_mcp/response_trim.py +2 -11
- package/src/qingflow_mcp/server.py +0 -2
- package/src/qingflow_mcp/server_app_builder.py +10 -34
- package/src/qingflow_mcp/server_app_user.py +0 -4
- package/src/qingflow_mcp/solution/compiler/view_compiler.py +1 -2
- package/src/qingflow_mcp/solution/executor.py +8 -96
- package/src/qingflow_mcp/solution/spec_models.py +0 -1
- package/src/qingflow_mcp/tools/ai_builder_tools.py +398 -218
- package/src/qingflow_mcp/tools/base.py +42 -10
- package/src/qingflow_mcp/tools/record_tools.py +46 -11
- package/src/qingflow_mcp/tools/solution_tools.py +0 -2
- package/src/qingflow_mcp/tools/view_tools.py +6 -0
- package/skills/qingflow-cli/reference/data/README.md +0 -32
- package/skills/qingflow-cli/reference/examples/views/views_apply_v1alpha1.example.json +0 -14
- package/skills/qingflow-cli/reference/record/QINGFLOW_CLI_RECORD_SPECIALIZED_WORKFLOW.md +0 -50
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/rich_text.json +0 -146
- package/src/qingflow_mcp/builder_facade/schemas/views/manifest.json +0 -42
- package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/complete.json +0 -1245
- package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/details/button-config.json +0 -53
- package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/details/data-permission.json +0 -60
- package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/details/display.json +0 -73
- package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/details/operation-permission.json +0 -48
- package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/details/query-condition.json +0 -47
- package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/summary.json +0 -319
- package/src/qingflow_mcp/builder_facade/views_extract.py +0 -592
- package/src/qingflow_mcp/builder_facade/views_schema.py +0 -400
- package/src/qingflow_mcp/cli/commands/data.py +0 -134
- package/src/qingflow_mcp/tools/csv_export.py +0 -168
- package/src/qingflow_mcp/tools/data_tools.py +0 -284
- package/src/qingflow_mcp/tools/view_writer.py +0 -44
|
@@ -1,78 +1,106 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Data Access Playbook
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
This file is the operational state machine for `record_access`.
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
--
|
|
21
|
-
--
|
|
34
|
+
--order-by-file order_by.json \
|
|
35
|
+
--json
|
|
22
36
|
```
|
|
23
37
|
|
|
24
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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` |
|
|
48
|
-
| `partial` |
|
|
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`
|
|
84
|
+
## `needs_scope` Recovery
|
|
54
85
|
|
|
55
|
-
|
|
86
|
+
Use the returned `scope` object:
|
|
56
87
|
|
|
57
|
-
`
|
|
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
|
-
|
|
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
|
-
`
|
|
94
|
+
## `partial` Recovery
|
|
64
95
|
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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 = [] #
|
|
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
|
-
- **
|
|
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
|
-
|
|
|
35
|
-
|
|
|
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
|
|
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
|
|
|
@@ -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
|
-
|
|
231
|
-
|
|
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
|
-
|
|
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 "")
|
|
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 "")
|
|
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 "
|
|
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 == "
|
|
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
|
-
|
|
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 =
|
|
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
|