@qingflow-tech/qingflow-cli 1.1.60 → 1.1.61-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/package.json +1 -1
- package/pyproject.toml +3 -1
- package/skills/qingflow-cli/SKILL.md +120 -8
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_DATA_RETRIEVAL_WORKFLOW.md +1 -1
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_EXPLORATION_REPORT.md +1 -1
- package/skills/qingflow-cli/reference/builder/20-build-complete-system.md +2 -2
- package/skills/qingflow-cli/reference/builder/30-schema-fields.md +9 -5
- package/skills/qingflow-cli/reference/builder/reference/app-delivery-sop.md +3 -3
- package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_DATA_RETRIEVAL_WORKFLOW.md +4 -4
- package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_EXPLORATION_REPORT.md +1 -1
- package/skills/qingflow-cli/reference/data/README.md +20 -0
- package/skills/qingflow-cli/reference/record/QINGFLOW_CLI_RECORD_SPECIALIZED_WORKFLOW.md +7 -1
- package/skills/qingflow-common/SKILL.md +5 -2
- package/src/qingflow_mcp/app_form_compat.py +3 -0
- package/src/qingflow_mcp/app_form_validator.py +69 -17
- package/src/qingflow_mcp/backend_client.py +33 -8
- package/src/qingflow_mcp/builder_facade/app_form.py +497 -100
- package/src/qingflow_mcp/builder_facade/schemas/app_form/manifest.json +1 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/complete.json +64 -30
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/address.json +4 -8
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/amount.json +4 -8
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/attachment.json +6 -10
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/code_block.json +6 -10
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/data_relation.json +11 -18
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/date.json +4 -8
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/datetime.json +4 -8
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/department.json +6 -10
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/email.json +4 -8
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/link.json +4 -8
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/long_text.json +4 -8
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/member.json +6 -10
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/multi_select.json +4 -8
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/number.json +4 -8
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/phone.json +4 -8
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/q_linker.json +6 -10
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/relation.json +46 -15
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/rich_text.json +146 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/single_choice.json +4 -8
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/single_select.json +4 -8
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/subtable.json +61 -27
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/text.json +4 -8
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/formula.json +43 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/summary.json +7 -12
- package/src/qingflow_mcp/builder_facade/service.py +39 -1
- package/src/qingflow_mcp/cli/commands/data.py +13 -3
- package/src/qingflow_mcp/cli/formatters.py +7 -0
- package/src/qingflow_mcp/cli/main.py +34 -1
- package/src/qingflow_mcp/cloud_context.py +16 -1
- package/src/qingflow_mcp/public_surface.py +67 -8
- package/src/qingflow_mcp/response_trim.py +2 -1
- package/src/qingflow_mcp/server.py +13 -7
- package/src/qingflow_mcp/server_app_builder.py +9 -5
- package/src/qingflow_mcp/server_app_user.py +10 -6
- package/src/qingflow_mcp/server_cloud.py +43 -7
- package/src/qingflow_mcp/tool_catalog.py +387 -0
- package/src/qingflow_mcp/tool_catalog_i18n.py +185 -0
- package/src/qingflow_mcp/tool_policy.py +197 -0
- package/src/qingflow_mcp/tools/data_files.py +174 -0
- package/src/qingflow_mcp/tools/data_tools.py +42 -13
- package/src/qingflow_mcp/tools/record_tools.py +14 -72
- package/src/qingflow_mcp/tools/storage_download.py +89 -0
|
@@ -18,6 +18,7 @@ from ..tools.app_tools import AppTools
|
|
|
18
18
|
from ..tools.package_tools import PackageTools
|
|
19
19
|
from .app_form_schema import AppFormContractError, AppFormSchemaContext, AppFormSchemaRegistry
|
|
20
20
|
from .service import (
|
|
21
|
+
AmbiguousFieldSelector,
|
|
21
22
|
_coerce_any_int,
|
|
22
23
|
_compile_code_block_binding_fields,
|
|
23
24
|
_compile_q_linker_binding_fields,
|
|
@@ -45,6 +46,7 @@ QUE_TYPE_TO_CANONICAL = {
|
|
|
45
46
|
11: "single_select",
|
|
46
47
|
12: "multi_select",
|
|
47
48
|
13: "attachment",
|
|
49
|
+
16: "rich_text",
|
|
48
50
|
18: "subtable",
|
|
49
51
|
19: "data_relation",
|
|
50
52
|
20: "q_linker",
|
|
@@ -56,6 +58,7 @@ QUE_TYPE_TO_CANONICAL = {
|
|
|
56
58
|
CANONICAL_TO_QUE_TYPE = {
|
|
57
59
|
"text": 2,
|
|
58
60
|
"long_text": 3,
|
|
61
|
+
"rich_text": 16,
|
|
59
62
|
"number": 8,
|
|
60
63
|
"amount": 8,
|
|
61
64
|
"link": 9,
|
|
@@ -80,8 +83,10 @@ SUBFIELD_TYPES = {
|
|
|
80
83
|
"text",
|
|
81
84
|
"long_text",
|
|
82
85
|
"number",
|
|
86
|
+
"amount",
|
|
83
87
|
"link",
|
|
84
88
|
"date",
|
|
89
|
+
"datetime",
|
|
85
90
|
"phone",
|
|
86
91
|
"email",
|
|
87
92
|
"single_choice",
|
|
@@ -94,6 +99,43 @@ SUBFIELD_TYPES = {
|
|
|
94
99
|
"department",
|
|
95
100
|
}
|
|
96
101
|
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
|
+
|
|
97
139
|
INTEGRATION_TARGET_TYPES = {
|
|
98
140
|
"text",
|
|
99
141
|
"long_text",
|
|
@@ -662,8 +704,9 @@ class AppFormNormalizer:
|
|
|
662
704
|
]
|
|
663
705
|
if fields:
|
|
664
706
|
rows.append({"fields": fields})
|
|
665
|
-
|
|
666
|
-
|
|
707
|
+
# 空 section 是合法存量形态:后端 updateFormInfo 对空 innerQuestions 仅产出
|
|
708
|
+
# 零行 draft_section_question(段落本身作为 queType=24 question 照常保存),
|
|
709
|
+
# 读面 getFormInfoByAppKey 同样返回空 innerQuestions——按声明原样写,空↔空稳定。
|
|
667
710
|
body.append(
|
|
668
711
|
{
|
|
669
712
|
"kind": "section",
|
|
@@ -680,14 +723,26 @@ class AppFormNormalizer:
|
|
|
680
723
|
]
|
|
681
724
|
if fields:
|
|
682
725
|
body.append({"kind": "row", "fields": fields})
|
|
726
|
+
# 空表单 + 悬空 dataTitleQueId/dataCoverQueId(模板安装出的空壳应用常见:
|
|
727
|
+
# 后端 GET 原样吐出指向已不存在字段的 id)读面 canonical 化为 null——
|
|
728
|
+
# 写回 null 后端 normalizeDataTitleQueId 兜底、空表单落库等价,避免
|
|
729
|
+
# validate/apply 的 INVALID_DATA_TITLE_FIELD 拒掉自家写面(round-trip 断裂)。
|
|
730
|
+
has_fields = any(
|
|
731
|
+
bool(block.get("fields")) for block in body if block.get("kind") == "row"
|
|
732
|
+
) or any(
|
|
733
|
+
bool(row.get("fields"))
|
|
734
|
+
for block in body
|
|
735
|
+
if block.get("kind") == "section"
|
|
736
|
+
for row in block.get("rows") or []
|
|
737
|
+
)
|
|
683
738
|
resource = {
|
|
684
739
|
"apiVersion": context.api_version,
|
|
685
740
|
"appKey": app_key,
|
|
686
741
|
"spec": {
|
|
687
742
|
"appName": _compat_label(form.get("formTitle"), "App", app_key),
|
|
688
743
|
"appDescription": form.get("formDesc") or 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,
|
|
744
|
+
"dataTitleField": _selector(title_id, field_names.get(title_id or 0)) if title_id is not None and has_fields else None,
|
|
745
|
+
"dataCoverField": _selector(cover_id, field_names.get(cover_id or 0)) if cover_id is not None and has_fields else None,
|
|
691
746
|
"attachments": self._attachments(form.get("formAttach")),
|
|
692
747
|
"serialNumber": self._serial_number(form.get("serialNumType"), form.get("serialNumConfig")),
|
|
693
748
|
"body": body,
|
|
@@ -919,8 +974,6 @@ class AppFormNormalizer:
|
|
|
919
974
|
reference = question.get("referenceConfig") if isinstance(question.get("referenceConfig"), dict) else {}
|
|
920
975
|
refer_questions = [item for item in reference.get("referQuestions") or [] if isinstance(item, dict)]
|
|
921
976
|
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]
|
|
924
977
|
target_app_key = str(reference.get("referAppKey") or "")
|
|
925
978
|
if not target_app_key.strip():
|
|
926
979
|
raise AppFormFailure(
|
|
@@ -932,25 +985,37 @@ class AppFormNormalizer:
|
|
|
932
985
|
relation_config: JSONObject = {
|
|
933
986
|
"targetAppKey": target_app_key,
|
|
934
987
|
"relationMode": "multiple" if _positive_int(reference.get("optionalDataNum")) not in {None, 1} else "single",
|
|
935
|
-
"
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
988
|
+
"displayFields": [
|
|
989
|
+
{
|
|
990
|
+
"field": _require_selector(
|
|
991
|
+
item.get("queId"),
|
|
992
|
+
item.get("queTitle"),
|
|
993
|
+
message="relation display field cannot be normalized",
|
|
994
|
+
details={"queId": que_id, "path": "/config/displayFields"},
|
|
995
|
+
),
|
|
996
|
+
"auth": {1: "editable", 2: "hidden", 3: "visible"}.get(
|
|
997
|
+
_positive_int(item.get("queAuth")), "visible"
|
|
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
|
+
),
|
|
947
1006
|
)
|
|
948
|
-
for item in visible
|
|
949
1007
|
],
|
|
950
1008
|
"beingDefaultFormulaAutoFillEnabled": reference.get("beingDefaultFormulaAutoFillEnabled"),
|
|
951
1009
|
"defaultValueMatchRules": reference.get("defaultValueMatchRules"),
|
|
952
1010
|
"defaultValues": question.get("queDefaultValues") if isinstance(question.get("queDefaultValues"), dict) else None,
|
|
953
1011
|
}
|
|
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
|
+
)
|
|
954
1019
|
if supports_reference_default_formula:
|
|
955
1020
|
formula = reference.get("defaultValueFormula")
|
|
956
1021
|
relation_config["defaultValueFormula"] = formula if not isinstance(formula, str) or formula.strip() else None
|
|
@@ -1292,7 +1357,12 @@ class AppFormNormalizer:
|
|
|
1292
1357
|
que_id = _positive_int(question.get("queId"))
|
|
1293
1358
|
relation = next((item for item in relations if _positive_int(item.get("queId")) == que_id), None)
|
|
1294
1359
|
if relation is None:
|
|
1295
|
-
|
|
1360
|
+
# 后端 GET 读面会过滤 displayed 目标缺失的 relation 行(FormSvcImpl#getQuestionRelationsBeans
|
|
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 {}
|
|
1296
1366
|
relation_type = _positive_int(relation.get("relationType")) or 1
|
|
1297
1367
|
displayed = relation.get("displayedQueInfo") if isinstance(relation.get("displayedQueInfo"), dict) else {}
|
|
1298
1368
|
alias = relation.get("aliasConfig") if isinstance(relation.get("aliasConfig"), dict) else {}
|
|
@@ -1393,15 +1463,25 @@ class AppFormNormalizer:
|
|
|
1393
1463
|
if isinstance(judge_type, bool) or not isinstance(judge_type, int) or judge_type not in BACKEND_TO_JUDGE_TYPE:
|
|
1394
1464
|
raise AppFormFailure("UNSUPPORTED_FIELD_CONFIG", "data_relation judgeType cannot be normalized", "NORMALIZE", details={"queId": que_id, "judgeType": judge_type})
|
|
1395
1465
|
condition: JSONObject = {
|
|
1396
|
-
"sourceField":
|
|
1466
|
+
"sourceField": _selector(value.get("queId"), value.get("queTitle")) or None,
|
|
1397
1467
|
"operator": BACKEND_TO_JUDGE_TYPE[judge_type],
|
|
1398
1468
|
}
|
|
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"]
|
|
1399
1474
|
match_type = _positive_int(value.get("matchType")) or 1
|
|
1400
1475
|
if match_type == 1:
|
|
1401
1476
|
details = value.get("judgeValueDetails")
|
|
1402
1477
|
if isinstance(details, list):
|
|
1478
|
+
# value 为空串且 id 非空 → 悬空选项引用(选项已被删,id 仍指向已删
|
|
1479
|
+
# optId):以 id 形态保留(写回时透传为悬空引用,往返对称;约束 2:
|
|
1480
|
+
# 语义保真优先于「apply 通过」——不产出不可写回的空串声明)。
|
|
1403
1481
|
literal_values = [
|
|
1404
|
-
str(item
|
|
1482
|
+
str(item["id"])
|
|
1483
|
+
if item.get("value") in (None, "") and item.get("id") is not None
|
|
1484
|
+
else str(item.get("value"))
|
|
1405
1485
|
for item in details
|
|
1406
1486
|
if isinstance(item, dict) and (item.get("value") is not None or item.get("id") is not None)
|
|
1407
1487
|
]
|
|
@@ -1652,7 +1732,10 @@ class AppFormCompiler:
|
|
|
1652
1732
|
selected = [field for field in target_fields if selector_id is not None and _positive_int(field.get("queId")) == selector_id]
|
|
1653
1733
|
if len(selected) == 1:
|
|
1654
1734
|
target_spec[key] = _selector(selected[0].get("queId"), selected[0].get("name"))
|
|
1655
|
-
|
|
1735
|
+
# target 层面同样放行 dataTitleField=null:后端写链
|
|
1736
|
+
# FormSvcImpl.normalizeDataTitleQueId 会兑底到第一个可用字段,
|
|
1737
|
+
# current 与 declared 均为 null 时 target 保持 null 合法。
|
|
1738
|
+
data_title, data_cover = self._validate_target(target_spec, allow_missing_data_title=True)
|
|
1656
1739
|
target_spec["dataTitleField"] = _selector(data_title.get("queId"), data_title.get("name")) if data_title is not None else None
|
|
1657
1740
|
target_spec["dataCoverField"] = _selector(data_cover.get("queId"), data_cover.get("name")) if data_cover is not None else None
|
|
1658
1741
|
return {"apiVersion": context.api_version, "appKey": app_key, "spec": target_spec}
|
|
@@ -1673,6 +1756,8 @@ class AppFormCompiler:
|
|
|
1673
1756
|
relation_context["questionRelations"] = relations
|
|
1674
1757
|
internal_fields, relations = _compile_q_linker_binding_fields(fields=internal_fields, current_schema=relation_context)
|
|
1675
1758
|
self._validate_unique_relation_targets(relations, internal_fields)
|
|
1759
|
+
except AmbiguousFieldSelector as error:
|
|
1760
|
+
raise AppFormFailure("AMBIGUOUS_FIELD_SELECTOR", str(error), "CONTRACT_PRECHECK", details={"candidateQueIds": error.candidates}) from error
|
|
1676
1761
|
except (KeyError, ValueError) as error:
|
|
1677
1762
|
raise AppFormFailure("UNSUPPORTED_FIELD_CONFIG", str(error), "CONTRACT_PRECHECK") from error
|
|
1678
1763
|
self._assign_subfield_temp_ids(internal_fields)
|
|
@@ -1978,10 +2063,13 @@ class AppFormCompiler:
|
|
|
1978
2063
|
if any(present) and not all(present):
|
|
1979
2064
|
raise AppFormFailure("INVALID_ROW_WIDTH", "all fields in a row must provide width together", "CONTRACT_PRECHECK")
|
|
1980
2065
|
if all(present):
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
2066
|
+
# 宽度取值:任意 1-100 整数(后端 html_width 无取值校验,存量存在
|
|
2067
|
+
# 33/34/20/30 等值,33+33+34=100 的三等分历史布局);仅保留
|
|
2068
|
+
# 「全有或全无」约束。行总和不强制 =100:后端写链无总和校验
|
|
2069
|
+
# (QuestionConstant.DEFAULT_QUE_WIDTH=100 仅为新建默认值),存量
|
|
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")
|
|
1985
2073
|
return
|
|
1986
2074
|
widths = {
|
|
1987
2075
|
1: [100],
|
|
@@ -2000,24 +2088,23 @@ class AppFormCompiler:
|
|
|
2000
2088
|
) -> tuple[JSONObject | None, JSONObject | None]:
|
|
2001
2089
|
body = spec.get("body") or []
|
|
2002
2090
|
fields = list(_iter_fields(body))
|
|
2003
|
-
|
|
2091
|
+
# 方案 A(duplicate-field-name):同父级允许同名——后端以 queId 为字段身份
|
|
2092
|
+
# (40013 实测 1624 组同名),get 提出的合法形态必须能写回;name 解析的
|
|
2093
|
+
# 歧义由各 selector resolver 以 AMBIGUOUS_FIELD_SELECTOR 显式拒绝,
|
|
2094
|
+
# 不在目标校验层做 name 唯一性强校验。
|
|
2004
2095
|
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)
|
|
2009
2096
|
if _field_type(field) == "subtable":
|
|
2010
|
-
sub_names: set[str] = set()
|
|
2011
2097
|
for subfield in field.get("subfields") or []:
|
|
2012
2098
|
if _field_type(subfield) not in SUBFIELD_TYPES:
|
|
2013
2099
|
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 ""))
|
|
2017
2100
|
title_selector = spec.get("dataTitleField")
|
|
2018
2101
|
data_title: JSONObject | None = None
|
|
2019
2102
|
if fields:
|
|
2020
|
-
|
|
2103
|
+
# dataTitleField=null(键存在值为空)与省略键同权放行:后端写链
|
|
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:
|
|
2021
2108
|
pass
|
|
2022
2109
|
elif not isinstance(title_selector, dict):
|
|
2023
2110
|
raise AppFormFailure(
|
|
@@ -2068,6 +2155,8 @@ class AppFormCompiler:
|
|
|
2068
2155
|
relation_context["questionRelations"] = relations
|
|
2069
2156
|
internal_fields, relations = _compile_q_linker_binding_fields(fields=internal_fields, current_schema=relation_context)
|
|
2070
2157
|
self._validate_unique_relation_targets(relations, internal_fields)
|
|
2158
|
+
except AmbiguousFieldSelector as error:
|
|
2159
|
+
raise AppFormFailure("AMBIGUOUS_FIELD_SELECTOR", str(error), "COMPILE", details={"candidateQueIds": error.candidates}) from error
|
|
2071
2160
|
except (KeyError, ValueError) as error:
|
|
2072
2161
|
raise AppFormFailure("UNSUPPORTED_FIELD_CONFIG", str(error), "COMPILE") from error
|
|
2073
2162
|
self._assign_subfield_temp_ids(internal_fields)
|
|
@@ -2124,7 +2213,8 @@ class AppFormCompiler:
|
|
|
2124
2213
|
rows.append([section])
|
|
2125
2214
|
payload["formQues"] = rows
|
|
2126
2215
|
self._validate_unique_temp_ids(rows)
|
|
2127
|
-
|
|
2216
|
+
target_data_title_selector = target["spec"]["dataTitleField"]
|
|
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
|
|
2128
2218
|
data_cover_selector = target["spec"].get("dataCoverField")
|
|
2129
2219
|
data_cover = _resolve_canonical_selector(data_cover_selector, target_fields, "dataCoverField") if isinstance(data_cover_selector, dict) else None
|
|
2130
2220
|
payload["dataTitleQueId"] = self._field_runtime_id(data_title, internal_fields)
|
|
@@ -2331,6 +2421,16 @@ class AppFormCompiler:
|
|
|
2331
2421
|
{
|
|
2332
2422
|
"target_app_key": config.get("targetAppKey"),
|
|
2333
2423
|
"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.
|
|
2334
2434
|
"display_field": _snake_selector(config.get("displayField")),
|
|
2335
2435
|
"visible_fields": [_snake_selector(item) for item in config.get("visibleFields") or []],
|
|
2336
2436
|
"reference_default_formula_auto_fill": config.get("beingDefaultFormulaAutoFillEnabled"),
|
|
@@ -2349,7 +2449,7 @@ class AppFormCompiler:
|
|
|
2349
2449
|
def _question(self, profile: str, app_key: str, field: JSONObject, internal: JSONObject, temp_id: int, relations: list[JSONObject]) -> JSONObject:
|
|
2350
2450
|
field_type = _field_type(field)
|
|
2351
2451
|
que_type = CANONICAL_TO_QUE_TYPE[field_type]
|
|
2352
|
-
build_type = "boolean" if field_type == "single_choice" else "text" if field_type in {"link", "data_relation"} else field_type
|
|
2452
|
+
build_type = "boolean" if field_type == "single_choice" else "text" if field_type in {"link", "data_relation", "rich_text"} else field_type
|
|
2353
2453
|
built_internal = deepcopy(internal)
|
|
2354
2454
|
built_internal["type"] = build_type
|
|
2355
2455
|
if build_type == "subtable":
|
|
@@ -2506,18 +2606,24 @@ class AppFormCompiler:
|
|
|
2506
2606
|
config = field.get("config") or {}
|
|
2507
2607
|
related_que_id = _positive_int(internal.get("data_relation_related_que_id"))
|
|
2508
2608
|
if related_que_id is None:
|
|
2509
|
-
|
|
2609
|
+
# 存量读回形态(后端 GET 不回传该字段的 relation):模板里保留着后端
|
|
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)
|
|
2510
2616
|
else:
|
|
2511
2617
|
question["relatedQueId"] = related_que_id
|
|
2512
2618
|
que_origin_type = _positive_int(internal.get("data_relation_origin_type"))
|
|
2513
2619
|
if que_origin_type is None:
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
"
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2620
|
+
template_origin = _positive_int((template or {}).get("queOriginType"))
|
|
2621
|
+
if template_origin is not None:
|
|
2622
|
+
question["queOriginType"] = template_origin
|
|
2623
|
+
else:
|
|
2624
|
+
question.pop("queOriginType", None)
|
|
2625
|
+
else:
|
|
2626
|
+
question["queOriginType"] = que_origin_type
|
|
2521
2627
|
aggregation = config.get("aggregation")
|
|
2522
2628
|
if aggregation is None:
|
|
2523
2629
|
question.pop("aggreType", None)
|
|
@@ -2532,23 +2638,76 @@ class AppFormCompiler:
|
|
|
2532
2638
|
|
|
2533
2639
|
def _reference_config(self, profile: str, config: JSONObject, template: JSONObject | None = None) -> JSONObject:
|
|
2534
2640
|
target_app_key = str(config.get("targetAppKey") or "")
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2641
|
+
candidates, _target_form = self._target_form_candidates(profile, target_app_key)
|
|
2642
|
+
explicit_display = isinstance(config.get("displayFields"), list)
|
|
2643
|
+
deprecated_keys = [
|
|
2644
|
+
key
|
|
2645
|
+
for key in ("displayField", "display_field", "visibleFields", "visible_fields")
|
|
2646
|
+
if key in config and config.get(key) is not None
|
|
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
|
|
2541
2694
|
result = deepcopy(template or {})
|
|
2542
2695
|
result.update(
|
|
2543
2696
|
{
|
|
2544
2697
|
"referAppKey": target_app_key,
|
|
2545
|
-
"referQueId":
|
|
2698
|
+
"referQueId": resolved_two_way.get("queId") if resolved_two_way else None,
|
|
2546
2699
|
"optionalDataNum": 1 if config.get("relationMode", "single") == "single" else 2,
|
|
2547
2700
|
"referQuestions": [
|
|
2548
|
-
|
|
2549
|
-
|
|
2701
|
+
{"queId": item["queId"], "queTitle": item["queTitle"], "queType": item["queType"], "queAuth": auth, "ordinal": index}
|
|
2702
|
+
for index, (item, auth) in enumerate(resolved_display)
|
|
2550
2703
|
],
|
|
2551
|
-
|
|
2704
|
+
# 选择引用数据时的列表权限轴(draft_reference_que_auth,含 section/parent 结构)
|
|
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],
|
|
2552
2711
|
"beingDefaultFormulaAutoFillEnabled": config.get("beingDefaultFormulaAutoFillEnabled"),
|
|
2553
2712
|
"defaultValueFormula": config.get("defaultValueFormula"),
|
|
2554
2713
|
"defaultValueMatchRules": deepcopy(config.get("defaultValueMatchRules")),
|
|
@@ -2566,8 +2725,7 @@ class AppFormCompiler:
|
|
|
2566
2725
|
target_app_key = str(relation.get("targetAppKey") or "")
|
|
2567
2726
|
if not target_app_key:
|
|
2568
2727
|
raise AppFormFailure("UNSUPPORTED_FIELD_CONFIG", "select scope relation requires targetAppKey", "COMPILE")
|
|
2569
|
-
target_form = self.
|
|
2570
|
-
candidates = _backend_question_candidates(target_form)
|
|
2728
|
+
candidates, target_form = self._target_form_candidates(profile, target_app_key)
|
|
2571
2729
|
displayed = _resolve_backend_selector(relation.get("displayedField") or {}, candidates, "selectScopeRelation.displayedField")
|
|
2572
2730
|
payload: JSONObject = {
|
|
2573
2731
|
"queId": _positive_int(internal.get("que_id")) or int(internal.get("que_temp_id") or 0),
|
|
@@ -2589,14 +2747,27 @@ class AppFormCompiler:
|
|
|
2589
2747
|
def _resolve_subfield_runtime_id(selector: JSONObject, internal_fields: list[JSONObject], location: str) -> int:
|
|
2590
2748
|
if not isinstance(selector, dict):
|
|
2591
2749
|
raise AppFormFailure("INVALID_FIELD_SELECTOR", f"{location} must be an object", "COMPILE")
|
|
2750
|
+
# 同 _resolve_backend_selector:name 两侧 strip(存量字段名带前后空格,queId 兜底唯一性)。
|
|
2592
2751
|
que_id = _positive_int(selector.get("queId"))
|
|
2593
|
-
name = str(selector.get("name") or "")
|
|
2752
|
+
name = str(selector.get("name") or "").strip()
|
|
2594
2753
|
matches = [
|
|
2595
2754
|
item for item in internal_fields
|
|
2596
2755
|
if (que_id is None or _positive_int(item.get("que_id")) == que_id)
|
|
2597
|
-
and (not name or str(item.get("name") or "") == name)
|
|
2756
|
+
and (not name or str(item.get("name") or "").strip() == name)
|
|
2598
2757
|
]
|
|
2599
2758
|
if len(matches) != 1:
|
|
2759
|
+
if que_id is None and matches:
|
|
2760
|
+
raise AppFormFailure(
|
|
2761
|
+
"AMBIGUOUS_FIELD_SELECTOR",
|
|
2762
|
+
f"{location} matches multiple subfields with the same name; use queId to disambiguate",
|
|
2763
|
+
"COMPILE",
|
|
2764
|
+
details={
|
|
2765
|
+
"selector": selector,
|
|
2766
|
+
"candidateQueIds": sorted(
|
|
2767
|
+
{_positive_int(item.get("que_id")) or 0 for item in matches}
|
|
2768
|
+
),
|
|
2769
|
+
},
|
|
2770
|
+
)
|
|
2600
2771
|
raise AppFormFailure("INVALID_FIELD_SELECTOR", f"{location} must resolve to exactly one subfield", "COMPILE", details={"selector": selector, "matches": len(matches)})
|
|
2601
2772
|
return _positive_int(matches[0].get("que_id")) or int(matches[0].get("que_temp_id") or 0)
|
|
2602
2773
|
|
|
@@ -2627,6 +2798,13 @@ class AppFormCompiler:
|
|
|
2627
2798
|
continue
|
|
2628
2799
|
config = field.get("config") or {}
|
|
2629
2800
|
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
|
|
2630
2808
|
field_id = _positive_int(internal.get("que_id")) or int(internal.get("que_temp_id") or 0) or None
|
|
2631
2809
|
relation: JSONObject = {
|
|
2632
2810
|
"queId": field_id,
|
|
@@ -2641,8 +2819,7 @@ class AppFormCompiler:
|
|
|
2641
2819
|
source_fields = available_internal_fields
|
|
2642
2820
|
source_fields_are_backend = False
|
|
2643
2821
|
if source.get("kind") == "app":
|
|
2644
|
-
|
|
2645
|
-
source_fields = _backend_question_candidates(target_form)
|
|
2822
|
+
source_fields, _target_form = self._target_form_candidates(profile, str(source.get("appKey") or ""))
|
|
2646
2823
|
source_fields_are_backend = True
|
|
2647
2824
|
target = _resolve_backend_selector(source.get("field") or {}, source_fields, "source.field")
|
|
2648
2825
|
relation.update(
|
|
@@ -2758,25 +2935,32 @@ class AppFormCompiler:
|
|
|
2758
2935
|
current_fields: list[JSONObject],
|
|
2759
2936
|
source_fields_are_backend: bool,
|
|
2760
2937
|
) -> JSONObject:
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2938
|
+
source_selector = condition.get("sourceField") or {}
|
|
2939
|
+
if source_selector.get("queId") or str(source_selector.get("name") or "").strip():
|
|
2940
|
+
source = cls._resolve_data_relation_source_field(
|
|
2941
|
+
source_selector,
|
|
2942
|
+
source_fields=source_fields,
|
|
2943
|
+
source_fields_are_backend=source_fields_are_backend,
|
|
2944
|
+
location="sourceField",
|
|
2945
|
+
)
|
|
2946
|
+
else:
|
|
2947
|
+
# 存量读回形态:无源字段(原公式 #queId_0$)。以 queId=0 透传,后端
|
|
2948
|
+
# matchRulesToFormula 重新生成 #queId_0$,与原始库内公式往返对称。
|
|
2949
|
+
source = {}
|
|
2767
2950
|
operand = condition.get("operand") or {}
|
|
2768
2951
|
operator = str(condition.get("operator") or "")
|
|
2769
2952
|
cls._validate_data_relation_operator(source, operator, operand)
|
|
2770
2953
|
payload: JSONObject = {
|
|
2771
|
-
"queId": cls._data_relation_field_ref(source),
|
|
2772
|
-
"queTitle": cls._data_relation_field_name(source),
|
|
2773
|
-
"queType": cls._data_relation_que_type(source),
|
|
2954
|
+
"queId": cls._data_relation_field_ref(source) or 0,
|
|
2955
|
+
"queTitle": cls._data_relation_field_name(source) or None,
|
|
2956
|
+
"queType": cls._data_relation_que_type(source) if source else None,
|
|
2774
2957
|
"judgeType": JUDGE_TYPE_TO_BACKEND[operator],
|
|
2775
2958
|
}
|
|
2776
2959
|
if operand.get("kind") == "literal":
|
|
2777
2960
|
judge_values, judge_value_details = cls._data_relation_literal_values(
|
|
2778
2961
|
source,
|
|
2779
2962
|
deepcopy(operand.get("values") or []),
|
|
2963
|
+
operator=operator,
|
|
2780
2964
|
)
|
|
2781
2965
|
payload.update(
|
|
2782
2966
|
{
|
|
@@ -2802,7 +2986,7 @@ class AppFormCompiler:
|
|
|
2802
2986
|
return payload
|
|
2803
2987
|
|
|
2804
2988
|
@staticmethod
|
|
2805
|
-
def _data_relation_literal_values(source: JSONObject, values: list[Any]) -> tuple[list[str], list[JSONObject]]:
|
|
2989
|
+
def _data_relation_literal_values(source: JSONObject, values: list[Any], *, operator: str = "") -> tuple[list[str], list[JSONObject]]:
|
|
2806
2990
|
field_type = _canonical_backend_field_type(source)
|
|
2807
2991
|
options = [item for item in source.get("options") or [] if isinstance(item, dict)]
|
|
2808
2992
|
option_by_name = {
|
|
@@ -2832,6 +3016,13 @@ class AppFormCompiler:
|
|
|
2832
3016
|
if field_type in CHOICE_TYPES and options:
|
|
2833
3017
|
option = option_by_name.get(text) or option_by_id.get(text)
|
|
2834
3018
|
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
|
|
2835
3026
|
raise AppFormFailure(
|
|
2836
3027
|
"UNSUPPORTED_FIELD_CONFIG",
|
|
2837
3028
|
f"literal value {text!r} is not an option of data_relation source field "
|
|
@@ -2844,6 +3035,28 @@ class AppFormCompiler:
|
|
|
2844
3035
|
judge_values.append(str(option_id) if option_id is not None else option_name)
|
|
2845
3036
|
details.append(_without_none({"id": option_id, "value": option_name}))
|
|
2846
3037
|
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
|
|
2847
3060
|
judge_values.append(text)
|
|
2848
3061
|
details.append({"value": text})
|
|
2849
3062
|
return judge_values, details
|
|
@@ -2866,7 +3079,13 @@ class AppFormCompiler:
|
|
|
2866
3079
|
|
|
2867
3080
|
@staticmethod
|
|
2868
3081
|
def _data_relation_field_name(field: JSONObject) -> str:
|
|
2869
|
-
|
|
3082
|
+
# matchRules 筛选源字段的 queTitle 在存量里对表格子字段带父表名前缀
|
|
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
|
|
2870
3089
|
|
|
2871
3090
|
@staticmethod
|
|
2872
3091
|
def _data_relation_que_type(field: JSONObject) -> int:
|
|
@@ -2924,7 +3143,11 @@ class AppFormCompiler:
|
|
|
2924
3143
|
organization_ops = common | {"belongs_to", "not_belongs_to", "subordinate_to", "not_subordinate_to", "subordinate_included", "subordinate_not_included", "contains_any", "contains_all"}
|
|
2925
3144
|
field_type = _canonical_backend_field_type(source)
|
|
2926
3145
|
if field_type in {"number", "amount", "date", "datetime"}:
|
|
2927
|
-
|
|
3146
|
+
# date/datetime 补 belongs_to/not_belongs_to:后端 judgeType=13(JUDGE_BELONG_TO)
|
|
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())
|
|
2928
3151
|
elif field_type in {"member", "department"}:
|
|
2929
3152
|
allowed = organization_ops
|
|
2930
3153
|
else:
|
|
@@ -3036,6 +3259,34 @@ class AppFormCompiler:
|
|
|
3036
3259
|
raise AppFormFailure("FORM_READ_FAILED", "backend returned an invalid form", "READ")
|
|
3037
3260
|
return value
|
|
3038
3261
|
|
|
3262
|
+
def _target_form_candidates(self, profile: str, app_key: str) -> tuple[list[JSONObject], JSONObject]:
|
|
3263
|
+
"""跨表引用解析候选集:目标表 草稿∪发布 并集(queId 为 key,草稿覆盖)。
|
|
3264
|
+
|
|
3265
|
+
后端写链对跨表 queId(referQuestions/displayedQueId/matchRules 源字段)无
|
|
3266
|
+
存在性校验、原样落库(FormSvcImpl.updateFormInfo);链上唯一读目标字段的
|
|
3267
|
+
Formula.matchRulesToFormula 取 question∪draft_question 且草稿覆盖,编辑器
|
|
3268
|
+
候选接口(/question/{appKey}/relation)同样支持 beingDraft 草稿态。get 按
|
|
3269
|
+
草稿姿态提取声明,apply 只读发布会漏掉目标表「只加在草稿、从未发布」的字段
|
|
3270
|
+
(round-trip 断裂);只读草稿又会漏掉发布后草稿里删掉的旧字段——并集与
|
|
3271
|
+
后端写链语义一致。草稿读失败(目标表无草稿等)退回仅发布态。
|
|
3272
|
+
"""
|
|
3273
|
+
draft_form: JSONObject = {}
|
|
3274
|
+
try:
|
|
3275
|
+
draft_form = self._read_form(profile, app_key, being_draft=True)
|
|
3276
|
+
except AppFormFailure:
|
|
3277
|
+
draft_form = {}
|
|
3278
|
+
published_form = self._read_form(profile, app_key, being_draft=False)
|
|
3279
|
+
merged: list[JSONObject] = []
|
|
3280
|
+
seen: set[int] = set()
|
|
3281
|
+
for item in [*_backend_question_candidates(draft_form), *_backend_question_candidates(published_form)]:
|
|
3282
|
+
que_id = _positive_int(item.get("queId"))
|
|
3283
|
+
if que_id is not None:
|
|
3284
|
+
if que_id in seen:
|
|
3285
|
+
continue
|
|
3286
|
+
seen.add(que_id)
|
|
3287
|
+
merged.append(item)
|
|
3288
|
+
return merged, (draft_form or published_form)
|
|
3289
|
+
|
|
3039
3290
|
|
|
3040
3291
|
class AppFormReconciler:
|
|
3041
3292
|
RUNTIME_VERSION = "v1alpha1"
|
|
@@ -3202,7 +3453,17 @@ class AppFormReconciler:
|
|
|
3202
3453
|
)
|
|
3203
3454
|
publish_needed = not _published_equal(current, published)
|
|
3204
3455
|
except (QingflowApiError, RuntimeError, AppFormFailure) as error:
|
|
3205
|
-
|
|
3456
|
+
if self._is_published_metadata_gap(error):
|
|
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
|
|
3206
3467
|
diagnostics["diff"] = self._diff(current, target)
|
|
3207
3468
|
|
|
3208
3469
|
if not form_changed and not publish_needed:
|
|
@@ -3303,7 +3564,9 @@ class AppFormReconciler:
|
|
|
3303
3564
|
details={"remainingDiff": deepcopy(diagnostics["remainingDiff"])},
|
|
3304
3565
|
)
|
|
3305
3566
|
|
|
3306
|
-
|
|
3567
|
+
# precheck 阶段推定的 publish_needed(published 元数据缺口)必须保留:
|
|
3568
|
+
# 发布自愈前任何一次 published 读都会失败,重读不会需得新证据。
|
|
3569
|
+
publish_needed = created or wrote_form or publish_needed
|
|
3307
3570
|
if not publish_needed:
|
|
3308
3571
|
try:
|
|
3309
3572
|
published = self._published_resource(
|
|
@@ -3315,14 +3578,20 @@ class AppFormReconciler:
|
|
|
3315
3578
|
diagnostics["published"] = _published_equal(draft, published)
|
|
3316
3579
|
publish_needed = not diagnostics["published"]
|
|
3317
3580
|
except (QingflowApiError, RuntimeError, AppFormFailure) as error:
|
|
3318
|
-
self.
|
|
3319
|
-
|
|
3320
|
-
"
|
|
3321
|
-
|
|
3322
|
-
"
|
|
3323
|
-
|
|
3324
|
-
|
|
3325
|
-
|
|
3581
|
+
if self._is_published_metadata_gap(error):
|
|
3582
|
+
publish_needed = True
|
|
3583
|
+
warnings.append({"code": "PUBLISHED_METADATA_GAP", "detail": str(error.message)})
|
|
3584
|
+
elif self._is_form_not_published(error):
|
|
3585
|
+
warnings.append({"code": "APP_NOT_PUBLISHED", "detail": "published comparison skipped: application was never published"})
|
|
3586
|
+
else:
|
|
3587
|
+
self._finish_edit(profile, app_key, edit_version_no, diagnostics)
|
|
3588
|
+
raise AppFormFailure(
|
|
3589
|
+
"PUBLISHED_FORM_READ_FAILED",
|
|
3590
|
+
str(error),
|
|
3591
|
+
"AUTHORITATIVE_PUBLISHED_READ",
|
|
3592
|
+
status="partial_success",
|
|
3593
|
+
details=diagnostics,
|
|
3594
|
+
) from error
|
|
3326
3595
|
|
|
3327
3596
|
if publish_needed:
|
|
3328
3597
|
self._publish_and_verify(
|
|
@@ -3704,6 +3973,52 @@ class AppFormReconciler:
|
|
|
3704
3973
|
diagnostics["publishResponseAccepted"] = True
|
|
3705
3974
|
diagnostics["publishCount"] += 1
|
|
3706
3975
|
|
|
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
|
+
|
|
3707
4022
|
def _published_matches(
|
|
3708
4023
|
self,
|
|
3709
4024
|
profile: str,
|
|
@@ -4130,19 +4445,46 @@ def _snake_code_block(value: JSONObject) -> JSONObject:
|
|
|
4130
4445
|
|
|
4131
4446
|
|
|
4132
4447
|
def _backend_question_candidates(form: JSONObject) -> list[JSONObject]:
|
|
4133
|
-
|
|
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
|
+
|
|
4134
4481
|
for item in form.get("baseQues") or []:
|
|
4135
4482
|
if isinstance(item, dict):
|
|
4136
|
-
|
|
4483
|
+
_visit(item)
|
|
4137
4484
|
for row in form.get("formQues") or []:
|
|
4138
4485
|
for item in row if isinstance(row, list) else []:
|
|
4139
|
-
if
|
|
4140
|
-
|
|
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)
|
|
4486
|
+
if isinstance(item, dict):
|
|
4487
|
+
_visit(item)
|
|
4146
4488
|
return result
|
|
4147
4489
|
|
|
4148
4490
|
|
|
@@ -4158,30 +4500,72 @@ def _canonical_backend_field_type(field: JSONObject) -> str:
|
|
|
4158
4500
|
return QUE_TYPE_TO_CANONICAL.get(que_type or -1, "")
|
|
4159
4501
|
|
|
4160
4502
|
|
|
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
|
+
|
|
4161
4516
|
def _resolve_backend_selector(selector: JSONObject, candidates: list[JSONObject], location: str) -> JSONObject:
|
|
4517
|
+
# name 比较两侧 strip:存量字段名带前导/尾随空格(后端无空白校验),声明里的
|
|
4518
|
+
# name 与候选 queTitle 一个空格即失配;queId 已同时给出,空格差异不构成歧义。
|
|
4162
4519
|
que_id = _positive_int(selector.get("queId"))
|
|
4163
|
-
name = str(selector.get("name") or "")
|
|
4520
|
+
name = str(selector.get("name") or "").strip()
|
|
4164
4521
|
matches = [
|
|
4165
4522
|
item
|
|
4166
4523
|
for item in candidates
|
|
4167
4524
|
if (que_id is None or _positive_int(item.get("queId")) == que_id)
|
|
4168
|
-
and (not name or
|
|
4525
|
+
and (not name or name in {title.strip() for title in _backend_selector_titles(item)})
|
|
4169
4526
|
]
|
|
4170
4527
|
if len(matches) != 1:
|
|
4528
|
+
# 方案 A:后端允许同父级同名(queId 为身份)。name-only 命中多个时显式报
|
|
4529
|
+
# 歧义并给出候选 queId,禁止静默取第一个。
|
|
4530
|
+
if que_id is None and matches:
|
|
4531
|
+
raise AppFormFailure(
|
|
4532
|
+
"AMBIGUOUS_FIELD_SELECTOR",
|
|
4533
|
+
f"{location} matches multiple fields with the same name; use queId to disambiguate",
|
|
4534
|
+
"CONTRACT_PRECHECK",
|
|
4535
|
+
details={
|
|
4536
|
+
"selector": selector,
|
|
4537
|
+
"candidateQueIds": sorted(
|
|
4538
|
+
{_positive_int(item.get("queId")) or 0 for item in matches}
|
|
4539
|
+
),
|
|
4540
|
+
},
|
|
4541
|
+
)
|
|
4171
4542
|
raise AppFormFailure("INVALID_FIELD_SELECTOR", f"{location} must resolve to exactly one field", "CONTRACT_PRECHECK", details={"selector": selector, "matches": len(matches)})
|
|
4172
4543
|
return matches[0]
|
|
4173
4544
|
|
|
4174
4545
|
|
|
4175
4546
|
def _resolve_canonical_selector(selector: JSONObject, candidates: list[JSONObject], location: str) -> JSONObject:
|
|
4547
|
+
# 同 _resolve_backend_selector:name 两侧 strip(存量字段名带前后空格,queId 兜底唯一性)。
|
|
4176
4548
|
que_id = _positive_int(selector.get("queId"))
|
|
4177
|
-
name = str(selector.get("name") or "")
|
|
4549
|
+
name = str(selector.get("name") or "").strip()
|
|
4178
4550
|
matches = [
|
|
4179
4551
|
item
|
|
4180
4552
|
for item in candidates
|
|
4181
4553
|
if (que_id is None or _positive_int(item.get("queId")) == que_id)
|
|
4182
|
-
and (not name or str(item.get("name") or "") == name)
|
|
4554
|
+
and (not name or str(item.get("name") or "").strip() == name)
|
|
4183
4555
|
]
|
|
4184
4556
|
if len(matches) != 1:
|
|
4557
|
+
if que_id is None and matches:
|
|
4558
|
+
raise AppFormFailure(
|
|
4559
|
+
"AMBIGUOUS_FIELD_SELECTOR",
|
|
4560
|
+
f"{location} matches multiple fields with the same name; use queId to disambiguate",
|
|
4561
|
+
"CONTRACT_PRECHECK",
|
|
4562
|
+
details={
|
|
4563
|
+
"selector": selector,
|
|
4564
|
+
"candidateQueIds": sorted(
|
|
4565
|
+
{_positive_int(item.get("queId")) or 0 for item in matches}
|
|
4566
|
+
),
|
|
4567
|
+
},
|
|
4568
|
+
)
|
|
4185
4569
|
raise AppFormFailure(
|
|
4186
4570
|
"INVALID_FIELD_SELECTOR",
|
|
4187
4571
|
f"{location} must resolve to exactly one top-level field",
|
|
@@ -4192,14 +4576,27 @@ def _resolve_canonical_selector(selector: JSONObject, candidates: list[JSONObjec
|
|
|
4192
4576
|
|
|
4193
4577
|
|
|
4194
4578
|
def _resolve_internal_selector(selector: JSONObject, fields: list[JSONObject], location: str) -> JSONObject:
|
|
4579
|
+
# 同 _resolve_backend_selector:name 两侧 strip(存量字段名带前后空格,queId 兜底唯一性)。
|
|
4195
4580
|
que_id = _positive_int(selector.get("queId") or selector.get("que_id"))
|
|
4196
|
-
name = str(selector.get("name") or "")
|
|
4581
|
+
name = str(selector.get("name") or "").strip()
|
|
4197
4582
|
matches = [
|
|
4198
4583
|
item
|
|
4199
4584
|
for item in fields
|
|
4200
4585
|
if (que_id is None or _positive_int(item.get("que_id")) == que_id)
|
|
4201
|
-
and (not name or str(item.get("name") or "") == name)
|
|
4586
|
+
and (not name or str(item.get("name") or "").strip() == name)
|
|
4202
4587
|
]
|
|
4203
4588
|
if len(matches) != 1:
|
|
4589
|
+
if que_id is None and matches:
|
|
4590
|
+
raise AppFormFailure(
|
|
4591
|
+
"AMBIGUOUS_FIELD_SELECTOR",
|
|
4592
|
+
f"{location} matches multiple fields with the same name; use queId to disambiguate",
|
|
4593
|
+
"COMPILE",
|
|
4594
|
+
details={
|
|
4595
|
+
"selector": selector,
|
|
4596
|
+
"candidateQueIds": sorted(
|
|
4597
|
+
{_positive_int(item.get("que_id")) or 0 for item in matches}
|
|
4598
|
+
),
|
|
4599
|
+
},
|
|
4600
|
+
)
|
|
4204
4601
|
raise AppFormFailure("INVALID_FIELD_SELECTOR", f"{location} must resolve to exactly one field", "COMPILE", details={"selector": selector, "matches": len(matches)})
|
|
4205
4602
|
return matches[0]
|