@qingflow-tech/qingflow-cli 1.1.57 → 1.1.59
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 +1 -2
- package/skills/qingflow-cli/SKILL.md +319 -90
- 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_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 -2
- package/skills/qingflow-cli/reference/builder/30-schema-fields.md +5 -9
- package/skills/qingflow-cli/reference/builder/reference/app-delivery-sop.md +3 -3
- 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/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-common/SKILL.md +4 -7
- 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 +2 -2
- 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/schemas/views/v1alpha1/complete.json +3 -4
- package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/details/data-permission.json +3 -3
- package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/summary.json +2 -3
- package/src/qingflow_mcp/builder_facade/service.py +6 -43
- package/src/qingflow_mcp/builder_facade/views_extract.py +12 -2
- package/src/qingflow_mcp/cli/commands/__init__.py +1 -2
- 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/public_surface.py +0 -5
- package/src/qingflow_mcp/response_trim.py +1 -2
- package/src/qingflow_mcp/server.py +0 -2
- package/src/qingflow_mcp/server_app_user.py +0 -4
- package/src/qingflow_mcp/tools/record_tools.py +46 -11
- package/skills/qingflow-cli/reference/data/README.md +0 -32
- 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/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
|
@@ -18,7 +18,6 @@ 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,
|
|
22
21
|
_coerce_any_int,
|
|
23
22
|
_compile_code_block_binding_fields,
|
|
24
23
|
_compile_q_linker_binding_fields,
|
|
@@ -46,7 +45,6 @@ QUE_TYPE_TO_CANONICAL = {
|
|
|
46
45
|
11: "single_select",
|
|
47
46
|
12: "multi_select",
|
|
48
47
|
13: "attachment",
|
|
49
|
-
16: "rich_text",
|
|
50
48
|
18: "subtable",
|
|
51
49
|
19: "data_relation",
|
|
52
50
|
20: "q_linker",
|
|
@@ -58,7 +56,6 @@ QUE_TYPE_TO_CANONICAL = {
|
|
|
58
56
|
CANONICAL_TO_QUE_TYPE = {
|
|
59
57
|
"text": 2,
|
|
60
58
|
"long_text": 3,
|
|
61
|
-
"rich_text": 16,
|
|
62
59
|
"number": 8,
|
|
63
60
|
"amount": 8,
|
|
64
61
|
"link": 9,
|
|
@@ -83,10 +80,8 @@ SUBFIELD_TYPES = {
|
|
|
83
80
|
"text",
|
|
84
81
|
"long_text",
|
|
85
82
|
"number",
|
|
86
|
-
"amount",
|
|
87
83
|
"link",
|
|
88
84
|
"date",
|
|
89
|
-
"datetime",
|
|
90
85
|
"phone",
|
|
91
86
|
"email",
|
|
92
87
|
"single_choice",
|
|
@@ -99,43 +94,6 @@ SUBFIELD_TYPES = {
|
|
|
99
94
|
"department",
|
|
100
95
|
}
|
|
101
96
|
CHOICE_TYPES = {"single_choice", "single_select", "multi_select"}
|
|
102
|
-
|
|
103
|
-
# 数据关联筛选 dynamic_range 操作数的动态范围枚举(id → 名称),与后端
|
|
104
|
-
# Constants.DYNAMIC_TYPE(apaas-common Constants.java:4674)单一来源同步:
|
|
105
|
-
# 提取侧 judgeValueDetails.value 即此名称形态(实测 id=7 value="今年"),写回侧
|
|
106
|
-
# 按名称恢复 id——后端 checkRelationMatchRules(FormSvcImpl:10445)对 judgeType=8
|
|
107
|
-
# 校验 judgeValueDetails 非空且 id 非 null,丢失 id 即 400「筛选条件配置有误」。
|
|
108
|
-
# id=22「自定义」在 data_relation 筛选存量里未见(value 形态非固定),不映射名称。
|
|
109
|
-
DYNAMIC_RANGE_NAME_TO_ID = {
|
|
110
|
-
"今日": 1,
|
|
111
|
-
"昨日": 2,
|
|
112
|
-
"本周 (周日 ~ 周六)": 3,
|
|
113
|
-
"上周 (周日 ~ 周六)": 4,
|
|
114
|
-
"本月": 5,
|
|
115
|
-
"上月": 6,
|
|
116
|
-
"今年": 7,
|
|
117
|
-
"去年": 8,
|
|
118
|
-
"近7天": 9,
|
|
119
|
-
"近30天": 10,
|
|
120
|
-
"近90天": 11,
|
|
121
|
-
"本周 (周一 ~ 周日)": 12,
|
|
122
|
-
"上周 (周一 ~ 周日)": 13,
|
|
123
|
-
"明天": 14,
|
|
124
|
-
"下周(周日 ~ 周六)": 15,
|
|
125
|
-
"下月": 16,
|
|
126
|
-
"本季度": 17,
|
|
127
|
-
"下季度": 18,
|
|
128
|
-
"上季度": 19,
|
|
129
|
-
"明年": 20,
|
|
130
|
-
"下周 (周一 ~ 周日)": 21,
|
|
131
|
-
}
|
|
132
|
-
# 名称宽松匹配(剔内嵌空白)兜底:后端数组存在带/不带空格的变体(如 15 与 3/12 形态不一),
|
|
133
|
-
# 归一后仍唯一命中才采用,避免歧义名称误映射。
|
|
134
|
-
_DYNAMIC_RANGE_NORMALIZED = {}
|
|
135
|
-
for _name, _id in DYNAMIC_RANGE_NAME_TO_ID.items():
|
|
136
|
-
_key = "".join(_name.split())
|
|
137
|
-
_DYNAMIC_RANGE_NORMALIZED.setdefault(_key, _id)
|
|
138
|
-
|
|
139
97
|
INTEGRATION_TARGET_TYPES = {
|
|
140
98
|
"text",
|
|
141
99
|
"long_text",
|
|
@@ -704,9 +662,8 @@ class AppFormNormalizer:
|
|
|
704
662
|
]
|
|
705
663
|
if fields:
|
|
706
664
|
rows.append({"fields": fields})
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
# 读面 getFormInfoByAppKey 同样返回空 innerQuestions——按声明原样写,空↔空稳定。
|
|
665
|
+
if not rows:
|
|
666
|
+
raise AppFormFailure("UNSUPPORTED_LAYOUT", "existing section is empty", "NORMALIZE")
|
|
710
667
|
body.append(
|
|
711
668
|
{
|
|
712
669
|
"kind": "section",
|
|
@@ -723,26 +680,14 @@ class AppFormNormalizer:
|
|
|
723
680
|
]
|
|
724
681
|
if fields:
|
|
725
682
|
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
|
-
)
|
|
738
683
|
resource = {
|
|
739
684
|
"apiVersion": context.api_version,
|
|
740
685
|
"appKey": app_key,
|
|
741
686
|
"spec": {
|
|
742
687
|
"appName": _compat_label(form.get("formTitle"), "App", app_key),
|
|
743
688
|
"appDescription": form.get("formDesc") or None,
|
|
744
|
-
"dataTitleField": _selector(title_id, field_names.get(title_id or 0)) if title_id is not None
|
|
745
|
-
"dataCoverField": _selector(cover_id, field_names.get(cover_id or 0)) if cover_id is not None
|
|
689
|
+
"dataTitleField": _selector(title_id, field_names.get(title_id or 0)) if title_id is not None else None,
|
|
690
|
+
"dataCoverField": _selector(cover_id, field_names.get(cover_id or 0)) if cover_id is not None else None,
|
|
746
691
|
"attachments": self._attachments(form.get("formAttach")),
|
|
747
692
|
"serialNumber": self._serial_number(form.get("serialNumType"), form.get("serialNumConfig")),
|
|
748
693
|
"body": body,
|
|
@@ -974,6 +919,8 @@ class AppFormNormalizer:
|
|
|
974
919
|
reference = question.get("referenceConfig") if isinstance(question.get("referenceConfig"), dict) else {}
|
|
975
920
|
refer_questions = [item for item in reference.get("referQuestions") or [] if isinstance(item, dict)]
|
|
976
921
|
display_id = _positive_int(reference.get("referQueId"))
|
|
922
|
+
display = next((item for item in refer_questions if _positive_int(item.get("queId")) == display_id), None)
|
|
923
|
+
visible = [item for item in refer_questions if _positive_int(item.get("queAuth")) != 2]
|
|
977
924
|
target_app_key = str(reference.get("referAppKey") or "")
|
|
978
925
|
if not target_app_key.strip():
|
|
979
926
|
raise AppFormFailure(
|
|
@@ -985,37 +932,25 @@ class AppFormNormalizer:
|
|
|
985
932
|
relation_config: JSONObject = {
|
|
986
933
|
"targetAppKey": target_app_key,
|
|
987
934
|
"relationMode": "multiple" if _positive_int(reference.get("optionalDataNum")) not in {None, 1} else "single",
|
|
988
|
-
"
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
"
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
for item in sorted(
|
|
1001
|
-
refer_questions,
|
|
1002
|
-
key=lambda item: (
|
|
1003
|
-
item.get("ordinal") is None,
|
|
1004
|
-
item.get("ordinal") if isinstance(item.get("ordinal"), int) else 0,
|
|
1005
|
-
),
|
|
935
|
+
"displayField": _require_selector(
|
|
936
|
+
display_id,
|
|
937
|
+
(display or {}).get("queTitle"),
|
|
938
|
+
message="relation display field cannot be normalized",
|
|
939
|
+
details={"queId": que_id, "path": "/config/displayField"},
|
|
940
|
+
),
|
|
941
|
+
"visibleFields": [
|
|
942
|
+
_require_selector(
|
|
943
|
+
item.get("queId"),
|
|
944
|
+
item.get("queTitle"),
|
|
945
|
+
message="relation visible field cannot be normalized",
|
|
946
|
+
details={"queId": que_id},
|
|
1006
947
|
)
|
|
948
|
+
for item in visible
|
|
1007
949
|
],
|
|
1008
950
|
"beingDefaultFormulaAutoFillEnabled": reference.get("beingDefaultFormulaAutoFillEnabled"),
|
|
1009
951
|
"defaultValueMatchRules": reference.get("defaultValueMatchRules"),
|
|
1010
952
|
"defaultValues": question.get("queDefaultValues") if isinstance(question.get("queDefaultValues"), dict) else None,
|
|
1011
953
|
}
|
|
1012
|
-
if display_id is not None:
|
|
1013
|
-
relation_config["twoWayBoundField"] = _require_selector(
|
|
1014
|
-
display_id,
|
|
1015
|
-
next((item.get("queTitle") for item in refer_questions if _positive_int(item.get("queId")) == display_id), None),
|
|
1016
|
-
message="relation two-way bound field cannot be normalized",
|
|
1017
|
-
details={"queId": que_id, "path": "/config/twoWayBoundField"},
|
|
1018
|
-
)
|
|
1019
954
|
if supports_reference_default_formula:
|
|
1020
955
|
formula = reference.get("defaultValueFormula")
|
|
1021
956
|
relation_config["defaultValueFormula"] = formula if not isinstance(formula, str) or formula.strip() else None
|
|
@@ -1357,12 +1292,7 @@ class AppFormNormalizer:
|
|
|
1357
1292
|
que_id = _positive_int(question.get("queId"))
|
|
1358
1293
|
relation = next((item for item in relations if _positive_int(item.get("queId")) == que_id), None)
|
|
1359
1294
|
if relation is None:
|
|
1360
|
-
|
|
1361
|
-
# 对 displayedForm/displayedQuestion 为 null 的行 continue,40013 存量实证:displayed_que_id=0
|
|
1362
|
-
# 共 264 行、displayed_form/que 闭包外 98/116 行)。此时字段本体仍在 formQues 中,
|
|
1363
|
-
# 但关联配置不可回读——保留字段并回读空 config(source 省略);写回侧不重建该字段的
|
|
1364
|
-
# relation 行,与后端读面行为对称。库表级 diff 将此类行归入「历史数据保全」单列。
|
|
1365
|
-
return {}
|
|
1295
|
+
raise AppFormFailure("UNSUPPORTED_FIELD_CONFIG", "data_relation has no question relation", "NORMALIZE", details={"queId": que_id})
|
|
1366
1296
|
relation_type = _positive_int(relation.get("relationType")) or 1
|
|
1367
1297
|
displayed = relation.get("displayedQueInfo") if isinstance(relation.get("displayedQueInfo"), dict) else {}
|
|
1368
1298
|
alias = relation.get("aliasConfig") if isinstance(relation.get("aliasConfig"), dict) else {}
|
|
@@ -1463,25 +1393,15 @@ class AppFormNormalizer:
|
|
|
1463
1393
|
if isinstance(judge_type, bool) or not isinstance(judge_type, int) or judge_type not in BACKEND_TO_JUDGE_TYPE:
|
|
1464
1394
|
raise AppFormFailure("UNSUPPORTED_FIELD_CONFIG", "data_relation judgeType cannot be normalized", "NORMALIZE", details={"queId": que_id, "judgeType": judge_type})
|
|
1465
1395
|
condition: JSONObject = {
|
|
1466
|
-
"sourceField":
|
|
1396
|
+
"sourceField": _require_selector(value.get("queId"), value.get("queTitle"), message="data_relation source field cannot be normalized", details={"queId": que_id}),
|
|
1467
1397
|
"operator": BACKEND_TO_JUDGE_TYPE[judge_type],
|
|
1468
1398
|
}
|
|
1469
|
-
if condition["sourceField"] is None:
|
|
1470
|
-
# 存量读回形态:后端反解析出的条件源字段缺失(queId=0 且无名称,原公式
|
|
1471
|
-
# 如 #queId_0$)。省略 sourceField 保留 operator/operand 语义,写回时以
|
|
1472
|
-
# queId=0 透传(后端 matchRulesToFormula 重新生成 #queId_0$,往返对称)。
|
|
1473
|
-
del condition["sourceField"]
|
|
1474
1399
|
match_type = _positive_int(value.get("matchType")) or 1
|
|
1475
1400
|
if match_type == 1:
|
|
1476
1401
|
details = value.get("judgeValueDetails")
|
|
1477
1402
|
if isinstance(details, list):
|
|
1478
|
-
# value 为空串且 id 非空 → 悬空选项引用(选项已被删,id 仍指向已删
|
|
1479
|
-
# optId):以 id 形态保留(写回时透传为悬空引用,往返对称;约束 2:
|
|
1480
|
-
# 语义保真优先于「apply 通过」——不产出不可写回的空串声明)。
|
|
1481
1403
|
literal_values = [
|
|
1482
|
-
str(item
|
|
1483
|
-
if item.get("value") in (None, "") and item.get("id") is not None
|
|
1484
|
-
else str(item.get("value"))
|
|
1404
|
+
str(item.get("value") if item.get("value") is not None else item.get("id"))
|
|
1485
1405
|
for item in details
|
|
1486
1406
|
if isinstance(item, dict) and (item.get("value") is not None or item.get("id") is not None)
|
|
1487
1407
|
]
|
|
@@ -1732,10 +1652,7 @@ class AppFormCompiler:
|
|
|
1732
1652
|
selected = [field for field in target_fields if selector_id is not None and _positive_int(field.get("queId")) == selector_id]
|
|
1733
1653
|
if len(selected) == 1:
|
|
1734
1654
|
target_spec[key] = _selector(selected[0].get("queId"), selected[0].get("name"))
|
|
1735
|
-
|
|
1736
|
-
# FormSvcImpl.normalizeDataTitleQueId 会兑底到第一个可用字段,
|
|
1737
|
-
# current 与 declared 均为 null 时 target 保持 null 合法。
|
|
1738
|
-
data_title, data_cover = self._validate_target(target_spec, allow_missing_data_title=True)
|
|
1655
|
+
data_title, data_cover = self._validate_target(target_spec)
|
|
1739
1656
|
target_spec["dataTitleField"] = _selector(data_title.get("queId"), data_title.get("name")) if data_title is not None else None
|
|
1740
1657
|
target_spec["dataCoverField"] = _selector(data_cover.get("queId"), data_cover.get("name")) if data_cover is not None else None
|
|
1741
1658
|
return {"apiVersion": context.api_version, "appKey": app_key, "spec": target_spec}
|
|
@@ -1756,8 +1673,6 @@ class AppFormCompiler:
|
|
|
1756
1673
|
relation_context["questionRelations"] = relations
|
|
1757
1674
|
internal_fields, relations = _compile_q_linker_binding_fields(fields=internal_fields, current_schema=relation_context)
|
|
1758
1675
|
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
|
|
1761
1676
|
except (KeyError, ValueError) as error:
|
|
1762
1677
|
raise AppFormFailure("UNSUPPORTED_FIELD_CONFIG", str(error), "CONTRACT_PRECHECK") from error
|
|
1763
1678
|
self._assign_subfield_temp_ids(internal_fields)
|
|
@@ -2063,13 +1978,10 @@ class AppFormCompiler:
|
|
|
2063
1978
|
if any(present) and not all(present):
|
|
2064
1979
|
raise AppFormFailure("INVALID_ROW_WIDTH", "all fields in a row must provide width together", "CONTRACT_PRECHECK")
|
|
2065
1980
|
if all(present):
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
# 数据存在字段删除后留白的行,get 读出的原布局必须能原样 apply。
|
|
2071
|
-
if any(not (isinstance(field.get("width"), int) and 1 <= field["width"] <= 100) for field in fields):
|
|
2072
|
-
raise AppFormFailure("INVALID_ROW_WIDTH", "field widths must be an integer between 1 and 100", "CONTRACT_PRECHECK")
|
|
1981
|
+
if any(_positive_int(field.get("width")) not in {25, 50, 75, 100} for field in fields):
|
|
1982
|
+
raise AppFormFailure("INVALID_ROW_WIDTH", "field widths must be one of 25, 50, 75, or 100", "CONTRACT_PRECHECK")
|
|
1983
|
+
if sum(int(field["width"]) for field in fields) != 100:
|
|
1984
|
+
raise AppFormFailure("INVALID_ROW_WIDTH", "field widths in a row must total 100", "CONTRACT_PRECHECK")
|
|
2073
1985
|
return
|
|
2074
1986
|
widths = {
|
|
2075
1987
|
1: [100],
|
|
@@ -2088,23 +2000,24 @@ class AppFormCompiler:
|
|
|
2088
2000
|
) -> tuple[JSONObject | None, JSONObject | None]:
|
|
2089
2001
|
body = spec.get("body") or []
|
|
2090
2002
|
fields = list(_iter_fields(body))
|
|
2091
|
-
|
|
2092
|
-
# (40013 实测 1624 组同名),get 提出的合法形态必须能写回;name 解析的
|
|
2093
|
-
# 歧义由各 selector resolver 以 AMBIGUOUS_FIELD_SELECTOR 显式拒绝,
|
|
2094
|
-
# 不在目标校验层做 name 唯一性强校验。
|
|
2003
|
+
names: set[str] = set()
|
|
2095
2004
|
for field in fields:
|
|
2005
|
+
name = str(field.get("name") or "")
|
|
2006
|
+
if name in names:
|
|
2007
|
+
raise AppFormFailure("DUPLICATE_FIELD_NAME", f"duplicate top-level field name: {name}", "CONTRACT_PRECHECK")
|
|
2008
|
+
names.add(name)
|
|
2096
2009
|
if _field_type(field) == "subtable":
|
|
2010
|
+
sub_names: set[str] = set()
|
|
2097
2011
|
for subfield in field.get("subfields") or []:
|
|
2098
2012
|
if _field_type(subfield) not in SUBFIELD_TYPES:
|
|
2099
2013
|
raise AppFormFailure("INVALID_SPEC", "unsupported subtable field type", "CONTRACT_PRECHECK", details={"allowedTypes": sorted(SUBFIELD_TYPES)})
|
|
2014
|
+
if subfield.get("name") in sub_names:
|
|
2015
|
+
raise AppFormFailure("DUPLICATE_FIELD_NAME", f"duplicate subtable field name: {subfield.get('name')}", "CONTRACT_PRECHECK")
|
|
2016
|
+
sub_names.add(str(subfield.get("name") or ""))
|
|
2100
2017
|
title_selector = spec.get("dataTitleField")
|
|
2101
2018
|
data_title: JSONObject | None = None
|
|
2102
2019
|
if fields:
|
|
2103
|
-
|
|
2104
|
-
# FormSvcImpl.normalizeDataTitleQueId 对 null 自动 fallback 到第一个
|
|
2105
|
-
# 可用字段(findDefaultDataTitleQueId),apply 侧缺省完全合法;
|
|
2106
|
-
# get 读面在后端 dataTitleQueId 未设置时原样输出 null,round-trip 必须接受。
|
|
2107
|
-
if title_selector is None and allow_missing_data_title:
|
|
2020
|
+
if title_selector is None and allow_missing_data_title and "dataTitleField" not in spec:
|
|
2108
2021
|
pass
|
|
2109
2022
|
elif not isinstance(title_selector, dict):
|
|
2110
2023
|
raise AppFormFailure(
|
|
@@ -2155,8 +2068,6 @@ class AppFormCompiler:
|
|
|
2155
2068
|
relation_context["questionRelations"] = relations
|
|
2156
2069
|
internal_fields, relations = _compile_q_linker_binding_fields(fields=internal_fields, current_schema=relation_context)
|
|
2157
2070
|
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
|
|
2160
2071
|
except (KeyError, ValueError) as error:
|
|
2161
2072
|
raise AppFormFailure("UNSUPPORTED_FIELD_CONFIG", str(error), "COMPILE") from error
|
|
2162
2073
|
self._assign_subfield_temp_ids(internal_fields)
|
|
@@ -2213,8 +2124,7 @@ class AppFormCompiler:
|
|
|
2213
2124
|
rows.append([section])
|
|
2214
2125
|
payload["formQues"] = rows
|
|
2215
2126
|
self._validate_unique_temp_ids(rows)
|
|
2216
|
-
|
|
2217
|
-
data_title = _resolve_canonical_selector(target_data_title_selector, target_fields, "dataTitleField") if target_fields and isinstance(target_data_title_selector, dict) else None
|
|
2127
|
+
data_title = _resolve_canonical_selector(target["spec"]["dataTitleField"], target_fields, "dataTitleField") if target_fields else None
|
|
2218
2128
|
data_cover_selector = target["spec"].get("dataCoverField")
|
|
2219
2129
|
data_cover = _resolve_canonical_selector(data_cover_selector, target_fields, "dataCoverField") if isinstance(data_cover_selector, dict) else None
|
|
2220
2130
|
payload["dataTitleQueId"] = self._field_runtime_id(data_title, internal_fields)
|
|
@@ -2421,16 +2331,6 @@ class AppFormCompiler:
|
|
|
2421
2331
|
{
|
|
2422
2332
|
"target_app_key": config.get("targetAppKey"),
|
|
2423
2333
|
"relation_mode": config.get("relationMode") or "single",
|
|
2424
|
-
"two_way_bound_field": _snake_selector(config.get("twoWayBoundField")),
|
|
2425
|
-
"display_fields": [
|
|
2426
|
-
{
|
|
2427
|
-
"field": _snake_selector(item.get("field")),
|
|
2428
|
-
"auth": item.get("auth") or "visible",
|
|
2429
|
-
}
|
|
2430
|
-
for item in config.get("displayFields") or []
|
|
2431
|
-
if isinstance(item, dict)
|
|
2432
|
-
],
|
|
2433
|
-
# Deprecated input names stay available to the compiler compatibility translator.
|
|
2434
2334
|
"display_field": _snake_selector(config.get("displayField")),
|
|
2435
2335
|
"visible_fields": [_snake_selector(item) for item in config.get("visibleFields") or []],
|
|
2436
2336
|
"reference_default_formula_auto_fill": config.get("beingDefaultFormulaAutoFillEnabled"),
|
|
@@ -2449,7 +2349,7 @@ class AppFormCompiler:
|
|
|
2449
2349
|
def _question(self, profile: str, app_key: str, field: JSONObject, internal: JSONObject, temp_id: int, relations: list[JSONObject]) -> JSONObject:
|
|
2450
2350
|
field_type = _field_type(field)
|
|
2451
2351
|
que_type = CANONICAL_TO_QUE_TYPE[field_type]
|
|
2452
|
-
build_type = "boolean" if field_type == "single_choice" else "text" if field_type in {"link", "data_relation"
|
|
2352
|
+
build_type = "boolean" if field_type == "single_choice" else "text" if field_type in {"link", "data_relation"} else field_type
|
|
2453
2353
|
built_internal = deepcopy(internal)
|
|
2454
2354
|
built_internal["type"] = build_type
|
|
2455
2355
|
if build_type == "subtable":
|
|
@@ -2606,24 +2506,18 @@ class AppFormCompiler:
|
|
|
2606
2506
|
config = field.get("config") or {}
|
|
2607
2507
|
related_que_id = _positive_int(internal.get("data_relation_related_que_id"))
|
|
2608
2508
|
if related_que_id is None:
|
|
2609
|
-
|
|
2610
|
-
# 镜像字段(relatedQueId/queOriginType),透传保持字段级语义不失真。
|
|
2611
|
-
template_related = _positive_int((template or {}).get("relatedQueId"))
|
|
2612
|
-
if template_related is not None:
|
|
2613
|
-
question["relatedQueId"] = template_related
|
|
2614
|
-
else:
|
|
2615
|
-
question.pop("relatedQueId", None)
|
|
2509
|
+
question.pop("relatedQueId", None)
|
|
2616
2510
|
else:
|
|
2617
2511
|
question["relatedQueId"] = related_que_id
|
|
2618
2512
|
que_origin_type = _positive_int(internal.get("data_relation_origin_type"))
|
|
2619
2513
|
if que_origin_type is None:
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2514
|
+
raise AppFormFailure(
|
|
2515
|
+
"UNSUPPORTED_FIELD_CONFIG",
|
|
2516
|
+
"data_relation source type could not be derived",
|
|
2517
|
+
"COMPILE",
|
|
2518
|
+
details={"field": field.get("name")},
|
|
2519
|
+
)
|
|
2520
|
+
question["queOriginType"] = que_origin_type
|
|
2627
2521
|
aggregation = config.get("aggregation")
|
|
2628
2522
|
if aggregation is None:
|
|
2629
2523
|
question.pop("aggreType", None)
|
|
@@ -2638,76 +2532,23 @@ class AppFormCompiler:
|
|
|
2638
2532
|
|
|
2639
2533
|
def _reference_config(self, profile: str, config: JSONObject, template: JSONObject | None = None) -> JSONObject:
|
|
2640
2534
|
target_app_key = str(config.get("targetAppKey") or "")
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
]
|
|
2648
|
-
if explicit_display and deprecated_keys:
|
|
2649
|
-
raise AppFormFailure(
|
|
2650
|
-
"INVALID_SPEC",
|
|
2651
|
-
"relation cannot mix displayFields with deprecated displayField/visibleFields",
|
|
2652
|
-
"CONTRACT_PRECHECK",
|
|
2653
|
-
)
|
|
2654
|
-
if explicit_display:
|
|
2655
|
-
# 显式 displayFields(含空列表)按声明原样写:空列表 = 无展示字段,round-trip 空↔空稳定。
|
|
2656
|
-
# 后端语义(2026-09-23 沿源码定案):FormQuestionSvcImpl.creteOrUpdateReference 对空
|
|
2657
|
-
# referQuestions 走 CollectionUtils.isNotEmpty 跳过插入,读面 getReferenceConfig
|
|
2658
|
-
# 同样容忍空——空列表是合法存量形态(双向绑定 referQueId 非空、未配展示字段)。
|
|
2659
|
-
display_entries = [item for item in config["displayFields"] if isinstance(item, dict)]
|
|
2660
|
-
elif deprecated_keys:
|
|
2661
|
-
# v1alpha1 compatibility: the old displayField was both the main display field and the binding field.
|
|
2662
|
-
legacy_display = config.get("displayField") or config.get("display_field") or {}
|
|
2663
|
-
legacy_visible = config.get("visibleFields") or config.get("visible_fields") or []
|
|
2664
|
-
display_entries = [{"field": legacy_display, "auth": "visible"}]
|
|
2665
|
-
display_entries.extend({"field": item, "auth": "visible"} for item in legacy_visible if isinstance(item, dict))
|
|
2666
|
-
else:
|
|
2667
|
-
raise AppFormFailure(
|
|
2668
|
-
"INVALID_SPEC",
|
|
2669
|
-
"relation config requires displayFields (explicit empty list allowed for fields without display fields)",
|
|
2670
|
-
"CONTRACT_PRECHECK",
|
|
2671
|
-
)
|
|
2672
|
-
resolved_display = []
|
|
2673
|
-
seen_ids = set()
|
|
2674
|
-
for index, entry in enumerate(display_entries):
|
|
2675
|
-
selector = entry.get("field") if isinstance(entry, dict) else {}
|
|
2676
|
-
item = _resolve_backend_selector(selector or {}, candidates, f"displayFields[{index}].field")
|
|
2677
|
-
if item["queId"] in seen_ids:
|
|
2678
|
-
raise AppFormFailure(
|
|
2679
|
-
"INVALID_SPEC",
|
|
2680
|
-
f"displayFields[{index}].field resolves to duplicate queId {item['queId']}",
|
|
2681
|
-
"CONTRACT_PRECHECK",
|
|
2682
|
-
)
|
|
2683
|
-
seen_ids.add(item["queId"])
|
|
2684
|
-
auth = (entry.get("auth") if isinstance(entry, dict) else None) or "visible"
|
|
2685
|
-
auth_code = {"editable": 1, "hidden": 2, "visible": 3}.get(auth)
|
|
2686
|
-
if auth_code is None:
|
|
2687
|
-
raise AppFormFailure("INVALID_SPEC", "displayFields auth must be editable, hidden, or visible", "CONTRACT_PRECHECK")
|
|
2688
|
-
resolved_display.append((item, auth_code))
|
|
2689
|
-
two_way = config.get("twoWayBoundField") or config.get("two_way_bound_field")
|
|
2690
|
-
if two_way is None and (config.get("displayField") or config.get("display_field")):
|
|
2691
|
-
two_way = config.get("displayField") or config.get("display_field")
|
|
2692
|
-
resolved_two_way = _resolve_backend_selector(two_way, candidates, "twoWayBoundField") if two_way else None
|
|
2693
|
-
template_auth = (template or {}).get("referAuthQues") if isinstance((template or {}).get("referAuthQues"), list) else None
|
|
2535
|
+
target_form = self._read_form(profile, target_app_key, being_draft=False)
|
|
2536
|
+
candidates = _backend_question_candidates(target_form)
|
|
2537
|
+
display = _resolve_backend_selector(config.get("displayField") or {}, candidates, "displayField")
|
|
2538
|
+
visible = [_resolve_backend_selector(item, candidates, "visibleFields") for item in config.get("visibleFields") or []]
|
|
2539
|
+
if all(_positive_int(item.get("queId")) != _positive_int(display.get("queId")) for item in visible):
|
|
2540
|
+
visible.insert(0, display)
|
|
2694
2541
|
result = deepcopy(template or {})
|
|
2695
2542
|
result.update(
|
|
2696
2543
|
{
|
|
2697
2544
|
"referAppKey": target_app_key,
|
|
2698
|
-
"referQueId":
|
|
2545
|
+
"referQueId": display["queId"],
|
|
2699
2546
|
"optionalDataNum": 1 if config.get("relationMode", "single") == "single" else 2,
|
|
2700
2547
|
"referQuestions": [
|
|
2701
|
-
|
|
2702
|
-
|
|
2548
|
+
{"queId": item["queId"], "queTitle": item["queTitle"], "queType": item["queType"], "queAuth": 3, "ordinal": index}
|
|
2549
|
+
for index, item in enumerate(visible)
|
|
2703
2550
|
],
|
|
2704
|
-
|
|
2705
|
-
# 与展示字段轴(referQuestions)独立;声明未建模该轴,已有配置原样透传
|
|
2706
|
-
# (空列表同样原样保留——被默认值覆盖会改变存量语义,历史数据保全红线),
|
|
2707
|
-
# 仅新建字段(无模板)按展示字段默认 editable,避免删除重建丢权限配置。
|
|
2708
|
-
"referAuthQues": deepcopy(template_auth)
|
|
2709
|
-
if template_auth is not None
|
|
2710
|
-
else [{"queId": item["queId"], "queAuth": 1} for item, _ in resolved_display],
|
|
2551
|
+
"referAuthQues": [{"queId": item["queId"], "queAuth": 1} for item in visible],
|
|
2711
2552
|
"beingDefaultFormulaAutoFillEnabled": config.get("beingDefaultFormulaAutoFillEnabled"),
|
|
2712
2553
|
"defaultValueFormula": config.get("defaultValueFormula"),
|
|
2713
2554
|
"defaultValueMatchRules": deepcopy(config.get("defaultValueMatchRules")),
|
|
@@ -2725,7 +2566,8 @@ class AppFormCompiler:
|
|
|
2725
2566
|
target_app_key = str(relation.get("targetAppKey") or "")
|
|
2726
2567
|
if not target_app_key:
|
|
2727
2568
|
raise AppFormFailure("UNSUPPORTED_FIELD_CONFIG", "select scope relation requires targetAppKey", "COMPILE")
|
|
2728
|
-
|
|
2569
|
+
target_form = self._read_form(profile, target_app_key, being_draft=False)
|
|
2570
|
+
candidates = _backend_question_candidates(target_form)
|
|
2729
2571
|
displayed = _resolve_backend_selector(relation.get("displayedField") or {}, candidates, "selectScopeRelation.displayedField")
|
|
2730
2572
|
payload: JSONObject = {
|
|
2731
2573
|
"queId": _positive_int(internal.get("que_id")) or int(internal.get("que_temp_id") or 0),
|
|
@@ -2747,27 +2589,14 @@ class AppFormCompiler:
|
|
|
2747
2589
|
def _resolve_subfield_runtime_id(selector: JSONObject, internal_fields: list[JSONObject], location: str) -> int:
|
|
2748
2590
|
if not isinstance(selector, dict):
|
|
2749
2591
|
raise AppFormFailure("INVALID_FIELD_SELECTOR", f"{location} must be an object", "COMPILE")
|
|
2750
|
-
# 同 _resolve_backend_selector:name 两侧 strip(存量字段名带前后空格,queId 兜底唯一性)。
|
|
2751
2592
|
que_id = _positive_int(selector.get("queId"))
|
|
2752
|
-
name = str(selector.get("name") or "")
|
|
2593
|
+
name = str(selector.get("name") or "")
|
|
2753
2594
|
matches = [
|
|
2754
2595
|
item for item in internal_fields
|
|
2755
2596
|
if (que_id is None or _positive_int(item.get("que_id")) == que_id)
|
|
2756
|
-
and (not name or str(item.get("name") or "")
|
|
2597
|
+
and (not name or str(item.get("name") or "") == name)
|
|
2757
2598
|
]
|
|
2758
2599
|
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
|
-
)
|
|
2771
2600
|
raise AppFormFailure("INVALID_FIELD_SELECTOR", f"{location} must resolve to exactly one subfield", "COMPILE", details={"selector": selector, "matches": len(matches)})
|
|
2772
2601
|
return _positive_int(matches[0].get("que_id")) or int(matches[0].get("que_temp_id") or 0)
|
|
2773
2602
|
|
|
@@ -2798,13 +2627,6 @@ class AppFormCompiler:
|
|
|
2798
2627
|
continue
|
|
2799
2628
|
config = field.get("config") or {}
|
|
2800
2629
|
source = config.get("source") or {}
|
|
2801
|
-
if not source.get("kind"):
|
|
2802
|
-
# 存量读回形态:后端 GET 不回传该字段的关联配置(displayed 目标缺失被
|
|
2803
|
-
# getQuestionRelationsBeans continue 过滤)。normalizer 提取为无 source 的
|
|
2804
|
-
# 空 config;写回侧保持对称——不重建 relation 行(后端删除重建后自然无行)。
|
|
2805
|
-
internal.pop("data_relation_related_que_id", None)
|
|
2806
|
-
internal.pop("data_relation_origin_type", None)
|
|
2807
|
-
continue
|
|
2808
2630
|
field_id = _positive_int(internal.get("que_id")) or int(internal.get("que_temp_id") or 0) or None
|
|
2809
2631
|
relation: JSONObject = {
|
|
2810
2632
|
"queId": field_id,
|
|
@@ -2819,7 +2641,8 @@ class AppFormCompiler:
|
|
|
2819
2641
|
source_fields = available_internal_fields
|
|
2820
2642
|
source_fields_are_backend = False
|
|
2821
2643
|
if source.get("kind") == "app":
|
|
2822
|
-
|
|
2644
|
+
target_form = self._read_form(profile, str(source.get("appKey") or ""), being_draft=False)
|
|
2645
|
+
source_fields = _backend_question_candidates(target_form)
|
|
2823
2646
|
source_fields_are_backend = True
|
|
2824
2647
|
target = _resolve_backend_selector(source.get("field") or {}, source_fields, "source.field")
|
|
2825
2648
|
relation.update(
|
|
@@ -2935,32 +2758,25 @@ class AppFormCompiler:
|
|
|
2935
2758
|
current_fields: list[JSONObject],
|
|
2936
2759
|
source_fields_are_backend: bool,
|
|
2937
2760
|
) -> JSONObject:
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
location="sourceField",
|
|
2945
|
-
)
|
|
2946
|
-
else:
|
|
2947
|
-
# 存量读回形态:无源字段(原公式 #queId_0$)。以 queId=0 透传,后端
|
|
2948
|
-
# matchRulesToFormula 重新生成 #queId_0$,与原始库内公式往返对称。
|
|
2949
|
-
source = {}
|
|
2761
|
+
source = cls._resolve_data_relation_source_field(
|
|
2762
|
+
condition.get("sourceField") or {},
|
|
2763
|
+
source_fields=source_fields,
|
|
2764
|
+
source_fields_are_backend=source_fields_are_backend,
|
|
2765
|
+
location="sourceField",
|
|
2766
|
+
)
|
|
2950
2767
|
operand = condition.get("operand") or {}
|
|
2951
2768
|
operator = str(condition.get("operator") or "")
|
|
2952
2769
|
cls._validate_data_relation_operator(source, operator, operand)
|
|
2953
2770
|
payload: JSONObject = {
|
|
2954
|
-
"queId": cls._data_relation_field_ref(source)
|
|
2955
|
-
"queTitle": cls._data_relation_field_name(source)
|
|
2956
|
-
"queType": cls._data_relation_que_type(source)
|
|
2771
|
+
"queId": cls._data_relation_field_ref(source),
|
|
2772
|
+
"queTitle": cls._data_relation_field_name(source),
|
|
2773
|
+
"queType": cls._data_relation_que_type(source),
|
|
2957
2774
|
"judgeType": JUDGE_TYPE_TO_BACKEND[operator],
|
|
2958
2775
|
}
|
|
2959
2776
|
if operand.get("kind") == "literal":
|
|
2960
2777
|
judge_values, judge_value_details = cls._data_relation_literal_values(
|
|
2961
2778
|
source,
|
|
2962
2779
|
deepcopy(operand.get("values") or []),
|
|
2963
|
-
operator=operator,
|
|
2964
2780
|
)
|
|
2965
2781
|
payload.update(
|
|
2966
2782
|
{
|
|
@@ -2986,7 +2802,7 @@ class AppFormCompiler:
|
|
|
2986
2802
|
return payload
|
|
2987
2803
|
|
|
2988
2804
|
@staticmethod
|
|
2989
|
-
def _data_relation_literal_values(source: JSONObject, values: list[Any]
|
|
2805
|
+
def _data_relation_literal_values(source: JSONObject, values: list[Any]) -> tuple[list[str], list[JSONObject]]:
|
|
2990
2806
|
field_type = _canonical_backend_field_type(source)
|
|
2991
2807
|
options = [item for item in source.get("options") or [] if isinstance(item, dict)]
|
|
2992
2808
|
option_by_name = {
|
|
@@ -3016,13 +2832,6 @@ class AppFormCompiler:
|
|
|
3016
2832
|
if field_type in CHOICE_TYPES and options:
|
|
3017
2833
|
option = option_by_name.get(text) or option_by_id.get(text)
|
|
3018
2834
|
if option is None:
|
|
3019
|
-
# 悬空选项引用(选项已被删,id 仍指向已删 optId):透传为存量
|
|
3020
|
-
# 同构的悬空引用(judgeValues=[optId], details=[{id,value:""}]),
|
|
3021
|
-
# 后端接受该形态(生产库存量即如此),往返对称;不是选项校验失败。
|
|
3022
|
-
if text.isdigit() and _positive_int(text) is not None:
|
|
3023
|
-
judge_values.append(text)
|
|
3024
|
-
details.append({"id": int(text), "value": ""})
|
|
3025
|
-
continue
|
|
3026
2835
|
raise AppFormFailure(
|
|
3027
2836
|
"UNSUPPORTED_FIELD_CONFIG",
|
|
3028
2837
|
f"literal value {text!r} is not an option of data_relation source field "
|
|
@@ -3035,28 +2844,6 @@ class AppFormCompiler:
|
|
|
3035
2844
|
judge_values.append(str(option_id) if option_id is not None else option_name)
|
|
3036
2845
|
details.append(_without_none({"id": option_id, "value": option_name}))
|
|
3037
2846
|
continue
|
|
3038
|
-
if operator == "dynamic_range":
|
|
3039
|
-
# 动态范围(judgeType=8):后端 checkRelationMatchRules 要求
|
|
3040
|
-
# judgeValueDetails 非空且 id 非_null;提取侧 values 取自
|
|
3041
|
-
# judgeValueDetails.value(动态范围名称),写回必须恢复 id——
|
|
3042
|
-
# 名称→id 映射对齐 Constants.DYNAMIC_TYPE;数字字符串直通(id 形态)。
|
|
3043
|
-
range_id = DYNAMIC_RANGE_NAME_TO_ID.get(text)
|
|
3044
|
-
if range_id is None and text.isdigit():
|
|
3045
|
-
range_id = _positive_int(text)
|
|
3046
|
-
if range_id is None:
|
|
3047
|
-
# 宽松匹配(剔内嵌空白)兜底:数组存在带/不带空格变体
|
|
3048
|
-
range_id = _DYNAMIC_RANGE_NORMALIZED.get("".join(text.split()))
|
|
3049
|
-
if range_id is None:
|
|
3050
|
-
raise AppFormFailure(
|
|
3051
|
-
"UNSUPPORTED_FIELD_CONFIG",
|
|
3052
|
-
f"literal value {text!r} is not a known dynamic range of data_relation "
|
|
3053
|
-
f"source field {AppFormCompiler._data_relation_field_name(source)!r}",
|
|
3054
|
-
"CONTRACT_PRECHECK",
|
|
3055
|
-
details={"value": text, "allowedValues": sorted(DYNAMIC_RANGE_NAME_TO_ID)},
|
|
3056
|
-
)
|
|
3057
|
-
judge_values.append(str(range_id))
|
|
3058
|
-
details.append({"id": range_id, "value": text})
|
|
3059
|
-
continue
|
|
3060
2847
|
judge_values.append(text)
|
|
3061
2848
|
details.append({"value": text})
|
|
3062
2849
|
return judge_values, details
|
|
@@ -3079,13 +2866,7 @@ class AppFormCompiler:
|
|
|
3079
2866
|
|
|
3080
2867
|
@staticmethod
|
|
3081
2868
|
def _data_relation_field_name(field: JSONObject) -> str:
|
|
3082
|
-
|
|
3083
|
-
# (「父表·子字段」):写回按同形态组装,与提取(后端拼接的 queTitle)对称。
|
|
3084
|
-
title = str(field.get("queTitle") or field.get("name") or "")
|
|
3085
|
-
sup_title = str(field.get("_sup_que_title") or "")
|
|
3086
|
-
if sup_title and title:
|
|
3087
|
-
return f"{sup_title}·{title}"
|
|
3088
|
-
return title
|
|
2869
|
+
return str(field.get("queTitle") or field.get("name") or "")
|
|
3089
2870
|
|
|
3090
2871
|
@staticmethod
|
|
3091
2872
|
def _data_relation_que_type(field: JSONObject) -> int:
|
|
@@ -3143,11 +2924,7 @@ class AppFormCompiler:
|
|
|
3143
2924
|
organization_ops = common | {"belongs_to", "not_belongs_to", "subordinate_to", "not_subordinate_to", "subordinate_included", "subordinate_not_included", "contains_any", "contains_all"}
|
|
3144
2925
|
field_type = _canonical_backend_field_type(source)
|
|
3145
2926
|
if field_type in {"number", "amount", "date", "datetime"}:
|
|
3146
|
-
|
|
3147
|
-
# 对时间字段的存量语义是「属于时间范围」(judgeValues 形如
|
|
3148
|
-
# "2025-01-30 14:20:59~2026-01-30 14:20:59"),与组织字段的属于语义
|
|
3149
|
-
# 共用同一 judgeType——存量实证(如 82d9379e 申请时间筛选)。
|
|
3150
|
-
allowed = ordered_ops | ({"belongs_to", "not_belongs_to"} if field_type in {"date", "datetime"} else set())
|
|
2927
|
+
allowed = ordered_ops
|
|
3151
2928
|
elif field_type in {"member", "department"}:
|
|
3152
2929
|
allowed = organization_ops
|
|
3153
2930
|
else:
|
|
@@ -3259,34 +3036,6 @@ class AppFormCompiler:
|
|
|
3259
3036
|
raise AppFormFailure("FORM_READ_FAILED", "backend returned an invalid form", "READ")
|
|
3260
3037
|
return value
|
|
3261
3038
|
|
|
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
|
-
|
|
3290
3039
|
|
|
3291
3040
|
class AppFormReconciler:
|
|
3292
3041
|
RUNTIME_VERSION = "v1alpha1"
|
|
@@ -3453,17 +3202,7 @@ class AppFormReconciler:
|
|
|
3453
3202
|
)
|
|
3454
3203
|
publish_needed = not _published_equal(current, published)
|
|
3455
3204
|
except (QingflowApiError, RuntimeError, AppFormFailure) as error:
|
|
3456
|
-
|
|
3457
|
-
# published 侧元数据缺行(段落归属/自定义编号配置):发布动作
|
|
3458
|
-
# 会从 draft 侧复制自愈,推定需要发布而不是失败。
|
|
3459
|
-
publish_needed = True
|
|
3460
|
-
warnings.append({"code": "PUBLISHED_METADATA_GAP", "detail": str(error.message)})
|
|
3461
|
-
elif self._is_form_not_published(error):
|
|
3462
|
-
# 应用从未发布(40026):published 域不存在,对比跳过——
|
|
3463
|
-
# draft 一致即达标,不擅自发布从未发布的应用。
|
|
3464
|
-
warnings.append({"code": "APP_NOT_PUBLISHED", "detail": "published comparison skipped: application was never published"})
|
|
3465
|
-
else:
|
|
3466
|
-
raise AppFormFailure("PUBLISHED_FORM_READ_FAILED", str(error), "PRECHECK_PUBLISHED_READ") from error
|
|
3205
|
+
raise AppFormFailure("PUBLISHED_FORM_READ_FAILED", str(error), "PRECHECK_PUBLISHED_READ") from error
|
|
3467
3206
|
diagnostics["diff"] = self._diff(current, target)
|
|
3468
3207
|
|
|
3469
3208
|
if not form_changed and not publish_needed:
|
|
@@ -3564,9 +3303,7 @@ class AppFormReconciler:
|
|
|
3564
3303
|
details={"remainingDiff": deepcopy(diagnostics["remainingDiff"])},
|
|
3565
3304
|
)
|
|
3566
3305
|
|
|
3567
|
-
|
|
3568
|
-
# 发布自愈前任何一次 published 读都会失败,重读不会需得新证据。
|
|
3569
|
-
publish_needed = created or wrote_form or publish_needed
|
|
3306
|
+
publish_needed = created or wrote_form
|
|
3570
3307
|
if not publish_needed:
|
|
3571
3308
|
try:
|
|
3572
3309
|
published = self._published_resource(
|
|
@@ -3578,20 +3315,14 @@ class AppFormReconciler:
|
|
|
3578
3315
|
diagnostics["published"] = _published_equal(draft, published)
|
|
3579
3316
|
publish_needed = not diagnostics["published"]
|
|
3580
3317
|
except (QingflowApiError, RuntimeError, AppFormFailure) as error:
|
|
3581
|
-
|
|
3582
|
-
|
|
3583
|
-
|
|
3584
|
-
|
|
3585
|
-
|
|
3586
|
-
|
|
3587
|
-
|
|
3588
|
-
|
|
3589
|
-
"PUBLISHED_FORM_READ_FAILED",
|
|
3590
|
-
str(error),
|
|
3591
|
-
"AUTHORITATIVE_PUBLISHED_READ",
|
|
3592
|
-
status="partial_success",
|
|
3593
|
-
details=diagnostics,
|
|
3594
|
-
) from error
|
|
3318
|
+
self._finish_edit(profile, app_key, edit_version_no, diagnostics)
|
|
3319
|
+
raise AppFormFailure(
|
|
3320
|
+
"PUBLISHED_FORM_READ_FAILED",
|
|
3321
|
+
str(error),
|
|
3322
|
+
"AUTHORITATIVE_PUBLISHED_READ",
|
|
3323
|
+
status="partial_success",
|
|
3324
|
+
details=diagnostics,
|
|
3325
|
+
) from error
|
|
3595
3326
|
|
|
3596
3327
|
if publish_needed:
|
|
3597
3328
|
self._publish_and_verify(
|
|
@@ -3973,52 +3704,6 @@ class AppFormReconciler:
|
|
|
3973
3704
|
diagnostics["publishResponseAccepted"] = True
|
|
3974
3705
|
diagnostics["publishCount"] += 1
|
|
3975
3706
|
|
|
3976
|
-
@staticmethod
|
|
3977
|
-
def _is_published_metadata_gap(error: Exception) -> bool:
|
|
3978
|
-
"""Published 读面因 published 侧元数据缺行而无法归一化的已知形态。
|
|
3979
|
-
|
|
3980
|
-
后端发布链路(FormSvcImpl.publish)把 draft 侧元数据复制到 published 侧
|
|
3981
|
-
(section_question 先删后建、form_custom_serial_num_set 按 id 三分离)。
|
|
3982
|
-
published 侧对应表缺行时(dataset 闭包漏导/迁移中间态),published GET 呈现:
|
|
3983
|
-
- 段落(queType=24)与段内字段同行、innerQuestions 为空
|
|
3984
|
-
→ UNSUPPORTED_FIELD_TYPE queType=24(段落归属信息在 published 侧丢失);
|
|
3985
|
-
- serialNumType=2 但 serialNumConfig 无组件
|
|
3986
|
-
→ UNSUPPORTED_FIELD_CONFIG "custom serial number has no components"。
|
|
3987
|
-
属数据缺口而非声明不可表达:推定需要发布,由发布动作自愈元数据
|
|
3988
|
-
(draft 读面不受影响——draft 形态段落恒为独立行)。
|
|
3989
|
-
"""
|
|
3990
|
-
if not isinstance(error, AppFormFailure) or error.phase != "NORMALIZE":
|
|
3991
|
-
return False
|
|
3992
|
-
details = error.details if isinstance(error.details, dict) else {}
|
|
3993
|
-
if error.error_code == "UNSUPPORTED_FIELD_TYPE" and _positive_int(details.get("queType")) == 24:
|
|
3994
|
-
return True
|
|
3995
|
-
return error.error_code == "UNSUPPORTED_FIELD_CONFIG" and "custom serial number has no components" in str(error.message)
|
|
3996
|
-
|
|
3997
|
-
@staticmethod
|
|
3998
|
-
def _is_form_not_published(error: Exception) -> bool:
|
|
3999
|
-
"""published 读面因应用从未发布而 40026。
|
|
4000
|
-
|
|
4001
|
-
后端语义(FormSvcImpl.getFormInfo):appPublishStatus == PUBLISH_STATUS_CREATED
|
|
4002
|
-
时 published GET 恒抛 FORM_NOT_PUBLISHED(40026)——合法存量形态(应用创建后
|
|
4003
|
-
从未发布),draft 域恒可读、published 域不存在。此时 published 对比应跳过:
|
|
4004
|
-
draft 一致即达标,apply 不擅自发布从未发布的应用(发布是独立产品动作,
|
|
4005
|
-
由使用者显式决定)。"""
|
|
4006
|
-
if isinstance(error, QingflowApiError):
|
|
4007
|
-
return str(error.backend_code) == "40026"
|
|
4008
|
-
if isinstance(error, AppFormFailure):
|
|
4009
|
-
details = error.details if isinstance(error.details, dict) else {}
|
|
4010
|
-
transport = details.get("transportError") if isinstance(details.get("transportError"), dict) else details
|
|
4011
|
-
return str(transport.get("backend_code")) == "40026" or str(transport.get("code")) == "40026"
|
|
4012
|
-
if isinstance(error, RuntimeError):
|
|
4013
|
-
# tools 层经 raise_tool_error 把 QingflowApiError 包成 RuntimeError,
|
|
4014
|
-
# message 是 as_json 字符串(含 backend_code);解析识别,不吞其他错误。
|
|
4015
|
-
try:
|
|
4016
|
-
payload = json.loads(str(error))
|
|
4017
|
-
except ValueError:
|
|
4018
|
-
return False
|
|
4019
|
-
return isinstance(payload, dict) and str(payload.get("backend_code")) == "40026"
|
|
4020
|
-
return False
|
|
4021
|
-
|
|
4022
3707
|
def _published_matches(
|
|
4023
3708
|
self,
|
|
4024
3709
|
profile: str,
|
|
@@ -4445,46 +4130,19 @@ def _snake_code_block(value: JSONObject) -> JSONObject:
|
|
|
4445
4130
|
|
|
4446
4131
|
|
|
4447
4132
|
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
|
-
"""
|
|
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
|
-
|
|
4133
|
+
result = []
|
|
4481
4134
|
for item in form.get("baseQues") or []:
|
|
4482
4135
|
if isinstance(item, dict):
|
|
4483
|
-
|
|
4136
|
+
result.append(item)
|
|
4484
4137
|
for row in form.get("formQues") or []:
|
|
4485
4138
|
for item in row if isinstance(row, list) else []:
|
|
4486
|
-
if isinstance(item, dict):
|
|
4487
|
-
|
|
4139
|
+
if not isinstance(item, dict):
|
|
4140
|
+
continue
|
|
4141
|
+
if _positive_int(item.get("queType")) == 24:
|
|
4142
|
+
for inner in item.get("innerQuestions") or []:
|
|
4143
|
+
result.extend(nested for nested in inner if isinstance(inner, list) and isinstance(nested, dict))
|
|
4144
|
+
else:
|
|
4145
|
+
result.append(item)
|
|
4488
4146
|
return result
|
|
4489
4147
|
|
|
4490
4148
|
|
|
@@ -4500,72 +4158,30 @@ def _canonical_backend_field_type(field: JSONObject) -> str:
|
|
|
4500
4158
|
return QUE_TYPE_TO_CANONICAL.get(que_type or -1, "")
|
|
4501
4159
|
|
|
4502
4160
|
|
|
4503
|
-
def _backend_selector_titles(item: JSONObject) -> list[str]:
|
|
4504
|
-
"""后端匹配规则里该字段可能出现的 queTitle 形态。
|
|
4505
|
-
|
|
4506
|
-
matchRules 的筛选源字段(表格子字段)在存量里带父表名前缀("父表·子字段",
|
|
4507
|
-
FormSvcImpl 组装 judgeQueDetail/queTitle 时拼接),顶层字段无前缀。
|
|
4508
|
-
"""
|
|
4509
|
-
titles = [str(item.get("queTitle") or "")]
|
|
4510
|
-
sup_title = str(item.get("_sup_que_title") or "")
|
|
4511
|
-
if sup_title:
|
|
4512
|
-
titles.append(f"{sup_title}·{titles[0]}")
|
|
4513
|
-
return titles
|
|
4514
|
-
|
|
4515
|
-
|
|
4516
4161
|
def _resolve_backend_selector(selector: JSONObject, candidates: list[JSONObject], location: str) -> JSONObject:
|
|
4517
|
-
# name 比较两侧 strip:存量字段名带前导/尾随空格(后端无空白校验),声明里的
|
|
4518
|
-
# name 与候选 queTitle 一个空格即失配;queId 已同时给出,空格差异不构成歧义。
|
|
4519
4162
|
que_id = _positive_int(selector.get("queId"))
|
|
4520
|
-
name = str(selector.get("name") or "")
|
|
4163
|
+
name = str(selector.get("name") or "")
|
|
4521
4164
|
matches = [
|
|
4522
4165
|
item
|
|
4523
4166
|
for item in candidates
|
|
4524
4167
|
if (que_id is None or _positive_int(item.get("queId")) == que_id)
|
|
4525
|
-
and (not name or
|
|
4168
|
+
and (not name or str(item.get("queTitle") or "") == name)
|
|
4526
4169
|
]
|
|
4527
4170
|
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
|
-
)
|
|
4542
4171
|
raise AppFormFailure("INVALID_FIELD_SELECTOR", f"{location} must resolve to exactly one field", "CONTRACT_PRECHECK", details={"selector": selector, "matches": len(matches)})
|
|
4543
4172
|
return matches[0]
|
|
4544
4173
|
|
|
4545
4174
|
|
|
4546
4175
|
def _resolve_canonical_selector(selector: JSONObject, candidates: list[JSONObject], location: str) -> JSONObject:
|
|
4547
|
-
# 同 _resolve_backend_selector:name 两侧 strip(存量字段名带前后空格,queId 兜底唯一性)。
|
|
4548
4176
|
que_id = _positive_int(selector.get("queId"))
|
|
4549
|
-
name = str(selector.get("name") or "")
|
|
4177
|
+
name = str(selector.get("name") or "")
|
|
4550
4178
|
matches = [
|
|
4551
4179
|
item
|
|
4552
4180
|
for item in candidates
|
|
4553
4181
|
if (que_id is None or _positive_int(item.get("queId")) == que_id)
|
|
4554
|
-
and (not name or str(item.get("name") or "")
|
|
4182
|
+
and (not name or str(item.get("name") or "") == name)
|
|
4555
4183
|
]
|
|
4556
4184
|
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
|
-
)
|
|
4569
4185
|
raise AppFormFailure(
|
|
4570
4186
|
"INVALID_FIELD_SELECTOR",
|
|
4571
4187
|
f"{location} must resolve to exactly one top-level field",
|
|
@@ -4576,27 +4192,14 @@ def _resolve_canonical_selector(selector: JSONObject, candidates: list[JSONObjec
|
|
|
4576
4192
|
|
|
4577
4193
|
|
|
4578
4194
|
def _resolve_internal_selector(selector: JSONObject, fields: list[JSONObject], location: str) -> JSONObject:
|
|
4579
|
-
# 同 _resolve_backend_selector:name 两侧 strip(存量字段名带前后空格,queId 兜底唯一性)。
|
|
4580
4195
|
que_id = _positive_int(selector.get("queId") or selector.get("que_id"))
|
|
4581
|
-
name = str(selector.get("name") or "")
|
|
4196
|
+
name = str(selector.get("name") or "")
|
|
4582
4197
|
matches = [
|
|
4583
4198
|
item
|
|
4584
4199
|
for item in fields
|
|
4585
4200
|
if (que_id is None or _positive_int(item.get("que_id")) == que_id)
|
|
4586
|
-
and (not name or str(item.get("name") or "")
|
|
4201
|
+
and (not name or str(item.get("name") or "") == name)
|
|
4587
4202
|
]
|
|
4588
4203
|
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
|
-
)
|
|
4601
4204
|
raise AppFormFailure("INVALID_FIELD_SELECTOR", f"{location} must resolve to exactly one field", "COMPILE", details={"selector": selector, "matches": len(matches)})
|
|
4602
4205
|
return matches[0]
|