@qingflow-tech/qingflow-cli 1.1.61-beta.5 → 1.1.61
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/package.json +1 -1
- package/pyproject.toml +1 -2
- package/skills/qingflow-cli/SKILL.md +2 -104
- package/skills/qingflow-cli/reference/builder/20-build-complete-system.md +2 -2
- package/skills/qingflow-cli/reference/builder/30-schema-fields.md +5 -22
- package/skills/qingflow-cli/reference/builder/70-portal.md +16 -37
- package/skills/qingflow-cli/reference/builder/reference/app-delivery-sop.md +3 -3
- package/skills/qingflow-cli/reference/examples/portal/portal_sections_all_types.example.json +7 -6
- package/src/qingflow_mcp/app_form_compat.py +0 -3
- package/src/qingflow_mcp/app_form_validator.py +20 -89
- package/src/qingflow_mcp/builder_facade/app_form.py +112 -629
- package/src/qingflow_mcp/builder_facade/models.py +13 -164
- package/src/qingflow_mcp/builder_facade/schemas/app_form/manifest.json +1 -15
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/complete.json +38 -112
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/address.json +8 -31
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/amount.json +10 -6
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/attachment.json +10 -33
- 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 +10 -6
- 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 +12 -8
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/relation.json +15 -73
- 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 +29 -90
- 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 +19 -27
- package/src/qingflow_mcp/builder_facade/service.py +72 -1143
- package/src/qingflow_mcp/cli/commands/builder.py +5 -114
- package/src/qingflow_mcp/cli/main.py +1 -34
- package/src/qingflow_mcp/cloud_context.py +1 -16
- package/src/qingflow_mcp/public_surface.py +8 -69
- package/src/qingflow_mcp/response_trim.py +1 -2
- package/src/qingflow_mcp/server.py +6 -12
- package/src/qingflow_mcp/server_app_builder.py +6 -41
- package/src/qingflow_mcp/server_app_user.py +5 -9
- package/src/qingflow_mcp/server_cloud.py +7 -43
- package/src/qingflow_mcp/solution/compiler/icon_utils.py +0 -73
- package/src/qingflow_mcp/solution/executor.py +2 -5
- package/src/qingflow_mcp/tools/ai_builder_tools.py +12 -110
- package/src/qingflow_mcp/tools/portal_tools.py +54 -4
- package/src/qingflow_mcp/tools/solution_tools.py +0 -2
- package/skills/qingflow-cli/reference/examples/portal/portal_apply_v1alpha1.example.json +0 -100
- package/src/qingflow_mcp/builder_facade/portals_schema.py +0 -397
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/ai_content_classification.json +0 -146
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/ai_content_extraction.json +0 -146
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/ai_content_generation.json +0 -146
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/ai_document_parsing.json +0 -146
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/ai_image_generation.json +0 -146
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/ai_image_recognition.json +0 -146
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/ai_machine_translation.json +0 -146
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/ai_text_summary.json +0 -146
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/date_duration.json +0 -146
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/embed_field.json +0 -146
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/image_select.json +0 -146
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/location.json +0 -146
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/ocr.json +0 -146
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/rich_text.json +0 -146
- package/src/qingflow_mcp/builder_facade/schemas/portals/manifest.json +0 -59
- package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/complete.json +0 -1397
- package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/components/carousel.json +0 -193
- package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/components/chart.json +0 -643
- package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/components/favourite.json +0 -203
- package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/components/filter.json +0 -268
- package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/components/grid.json +0 -265
- package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/components/link.json +0 -131
- package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/components/task.json +0 -199
- package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/components/text.json +0 -123
- package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/components/view.json +0 -196
- package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/summary.json +0 -800
- package/src/qingflow_mcp/tool_catalog.py +0 -387
- package/src/qingflow_mcp/tool_catalog_i18n.py +0 -185
- package/src/qingflow_mcp/tool_policy.py +0 -197
- package/src/qingflow_mcp/tools/portal_writer.py +0 -59
|
@@ -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",
|
|
@@ -54,29 +52,10 @@ QUE_TYPE_TO_CANONICAL = {
|
|
|
54
52
|
22: "department",
|
|
55
53
|
25: "relation",
|
|
56
54
|
26: "code_block",
|
|
57
|
-
# 透传型字段(r10 读面 65 app 缺口):声明层 EmptyConfig,round-trip 经后端
|
|
58
|
-
# 模板保真(选项/技能配置列随模板透传);语义 = QuestionTypeEnum 14/15/17/23
|
|
59
|
-
# 与 Constants QUE_TYPE_EMBED_FIELD(27)、AI 系 30-37(AI_CONTENT_GENERATION
|
|
60
|
-
# /EXTRACTION/CLASSIFICATION、AI_MACHINE_TRANSLATION、AI_IMAGE_RECOGNITION
|
|
61
|
-
# /GENERATION、AI_DOCUMENT_PARSING、AI_TEXT_SUMMARY)
|
|
62
|
-
14: "date_duration",
|
|
63
|
-
15: "image_select",
|
|
64
|
-
17: "location",
|
|
65
|
-
23: "ocr",
|
|
66
|
-
27: "embed_field",
|
|
67
|
-
30: "ai_content_generation",
|
|
68
|
-
31: "ai_content_extraction",
|
|
69
|
-
32: "ai_content_classification",
|
|
70
|
-
33: "ai_machine_translation",
|
|
71
|
-
34: "ai_image_recognition",
|
|
72
|
-
35: "ai_image_generation",
|
|
73
|
-
36: "ai_document_parsing",
|
|
74
|
-
37: "ai_text_summary",
|
|
75
55
|
}
|
|
76
56
|
CANONICAL_TO_QUE_TYPE = {
|
|
77
57
|
"text": 2,
|
|
78
58
|
"long_text": 3,
|
|
79
|
-
"rich_text": 16,
|
|
80
59
|
"number": 8,
|
|
81
60
|
"amount": 8,
|
|
82
61
|
"link": 9,
|
|
@@ -96,29 +75,13 @@ CANONICAL_TO_QUE_TYPE = {
|
|
|
96
75
|
"data_relation": 19,
|
|
97
76
|
"relation": 25,
|
|
98
77
|
"subtable": 18,
|
|
99
|
-
|
|
100
|
-
"date_duration": 14,
|
|
101
|
-
"image_select": 15,
|
|
102
|
-
"location": 17,
|
|
103
|
-
"ocr": 23,
|
|
104
|
-
"embed_field": 27,
|
|
105
|
-
"ai_content_generation": 30,
|
|
106
|
-
"ai_content_extraction": 31,
|
|
107
|
-
"ai_content_classification": 32,
|
|
108
|
-
"ai_machine_translation": 33,
|
|
109
|
-
"ai_image_recognition": 34,
|
|
110
|
-
"ai_image_generation": 35,
|
|
111
|
-
"ai_document_parsing": 36,
|
|
112
|
-
"ai_text_summary": 37,
|
|
113
78
|
}
|
|
114
79
|
SUBFIELD_TYPES = {
|
|
115
80
|
"text",
|
|
116
81
|
"long_text",
|
|
117
82
|
"number",
|
|
118
|
-
"amount",
|
|
119
83
|
"link",
|
|
120
84
|
"date",
|
|
121
|
-
"datetime",
|
|
122
85
|
"phone",
|
|
123
86
|
"email",
|
|
124
87
|
"single_choice",
|
|
@@ -131,43 +94,6 @@ SUBFIELD_TYPES = {
|
|
|
131
94
|
"department",
|
|
132
95
|
}
|
|
133
96
|
CHOICE_TYPES = {"single_choice", "single_select", "multi_select"}
|
|
134
|
-
|
|
135
|
-
# 数据关联筛选 dynamic_range 操作数的动态范围枚举(id → 名称),与后端
|
|
136
|
-
# Constants.DYNAMIC_TYPE(apaas-common Constants.java:4674)单一来源同步:
|
|
137
|
-
# 提取侧 judgeValueDetails.value 即此名称形态(实测 id=7 value="今年"),写回侧
|
|
138
|
-
# 按名称恢复 id——后端 checkRelationMatchRules(FormSvcImpl:10445)对 judgeType=8
|
|
139
|
-
# 校验 judgeValueDetails 非空且 id 非 null,丢失 id 即 400「筛选条件配置有误」。
|
|
140
|
-
# id=22「自定义」在 data_relation 筛选存量里未见(value 形态非固定),不映射名称。
|
|
141
|
-
DYNAMIC_RANGE_NAME_TO_ID = {
|
|
142
|
-
"今日": 1,
|
|
143
|
-
"昨日": 2,
|
|
144
|
-
"本周 (周日 ~ 周六)": 3,
|
|
145
|
-
"上周 (周日 ~ 周六)": 4,
|
|
146
|
-
"本月": 5,
|
|
147
|
-
"上月": 6,
|
|
148
|
-
"今年": 7,
|
|
149
|
-
"去年": 8,
|
|
150
|
-
"近7天": 9,
|
|
151
|
-
"近30天": 10,
|
|
152
|
-
"近90天": 11,
|
|
153
|
-
"本周 (周一 ~ 周日)": 12,
|
|
154
|
-
"上周 (周一 ~ 周日)": 13,
|
|
155
|
-
"明天": 14,
|
|
156
|
-
"下周(周日 ~ 周六)": 15,
|
|
157
|
-
"下月": 16,
|
|
158
|
-
"本季度": 17,
|
|
159
|
-
"下季度": 18,
|
|
160
|
-
"上季度": 19,
|
|
161
|
-
"明年": 20,
|
|
162
|
-
"下周 (周一 ~ 周日)": 21,
|
|
163
|
-
}
|
|
164
|
-
# 名称宽松匹配(剔内嵌空白)兜底:后端数组存在带/不带空格的变体(如 15 与 3/12 形态不一),
|
|
165
|
-
# 归一后仍唯一命中才采用,避免歧义名称误映射。
|
|
166
|
-
_DYNAMIC_RANGE_NORMALIZED = {}
|
|
167
|
-
for _name, _id in DYNAMIC_RANGE_NAME_TO_ID.items():
|
|
168
|
-
_key = "".join(_name.split())
|
|
169
|
-
_DYNAMIC_RANGE_NORMALIZED.setdefault(_key, _id)
|
|
170
|
-
|
|
171
97
|
INTEGRATION_TARGET_TYPES = {
|
|
172
98
|
"text",
|
|
173
99
|
"long_text",
|
|
@@ -736,9 +662,8 @@ class AppFormNormalizer:
|
|
|
736
662
|
]
|
|
737
663
|
if fields:
|
|
738
664
|
rows.append({"fields": fields})
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
# 读面 getFormInfoByAppKey 同样返回空 innerQuestions——按声明原样写,空↔空稳定。
|
|
665
|
+
if not rows:
|
|
666
|
+
raise AppFormFailure("UNSUPPORTED_LAYOUT", "existing section is empty", "NORMALIZE")
|
|
742
667
|
body.append(
|
|
743
668
|
{
|
|
744
669
|
"kind": "section",
|
|
@@ -755,26 +680,14 @@ class AppFormNormalizer:
|
|
|
755
680
|
]
|
|
756
681
|
if fields:
|
|
757
682
|
body.append({"kind": "row", "fields": fields})
|
|
758
|
-
# 空表单 + 悬空 dataTitleQueId/dataCoverQueId(模板安装出的空壳应用常见:
|
|
759
|
-
# 后端 GET 原样吐出指向已不存在字段的 id)读面 canonical 化为 null——
|
|
760
|
-
# 写回 null 后端 normalizeDataTitleQueId 兜底、空表单落库等价,避免
|
|
761
|
-
# validate/apply 的 INVALID_DATA_TITLE_FIELD 拒掉自家写面(round-trip 断裂)。
|
|
762
|
-
has_fields = any(
|
|
763
|
-
bool(block.get("fields")) for block in body if block.get("kind") == "row"
|
|
764
|
-
) or any(
|
|
765
|
-
bool(row.get("fields"))
|
|
766
|
-
for block in body
|
|
767
|
-
if block.get("kind") == "section"
|
|
768
|
-
for row in block.get("rows") or []
|
|
769
|
-
)
|
|
770
683
|
resource = {
|
|
771
684
|
"apiVersion": context.api_version,
|
|
772
685
|
"appKey": app_key,
|
|
773
686
|
"spec": {
|
|
774
687
|
"appName": _compat_label(form.get("formTitle"), "App", app_key),
|
|
775
688
|
"appDescription": form.get("formDesc") or None,
|
|
776
|
-
"dataTitleField": _selector(title_id, field_names.get(title_id or 0)) if title_id is not None
|
|
777
|
-
"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,
|
|
778
691
|
"attachments": self._attachments(form.get("formAttach")),
|
|
779
692
|
"serialNumber": self._serial_number(form.get("serialNumType"), form.get("serialNumConfig")),
|
|
780
693
|
"body": body,
|
|
@@ -1006,6 +919,8 @@ class AppFormNormalizer:
|
|
|
1006
919
|
reference = question.get("referenceConfig") if isinstance(question.get("referenceConfig"), dict) else {}
|
|
1007
920
|
refer_questions = [item for item in reference.get("referQuestions") or [] if isinstance(item, dict)]
|
|
1008
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]
|
|
1009
924
|
target_app_key = str(reference.get("referAppKey") or "")
|
|
1010
925
|
if not target_app_key.strip():
|
|
1011
926
|
raise AppFormFailure(
|
|
@@ -1017,37 +932,25 @@ class AppFormNormalizer:
|
|
|
1017
932
|
relation_config: JSONObject = {
|
|
1018
933
|
"targetAppKey": target_app_key,
|
|
1019
934
|
"relationMode": "multiple" if _positive_int(reference.get("optionalDataNum")) not in {None, 1} else "single",
|
|
1020
|
-
"
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
"
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
for item in sorted(
|
|
1033
|
-
refer_questions,
|
|
1034
|
-
key=lambda item: (
|
|
1035
|
-
item.get("ordinal") is None,
|
|
1036
|
-
item.get("ordinal") if isinstance(item.get("ordinal"), int) else 0,
|
|
1037
|
-
),
|
|
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},
|
|
1038
947
|
)
|
|
948
|
+
for item in visible
|
|
1039
949
|
],
|
|
1040
950
|
"beingDefaultFormulaAutoFillEnabled": reference.get("beingDefaultFormulaAutoFillEnabled"),
|
|
1041
951
|
"defaultValueMatchRules": reference.get("defaultValueMatchRules"),
|
|
1042
952
|
"defaultValues": question.get("queDefaultValues") if isinstance(question.get("queDefaultValues"), dict) else None,
|
|
1043
953
|
}
|
|
1044
|
-
if display_id is not None:
|
|
1045
|
-
relation_config["twoWayBoundField"] = _require_selector(
|
|
1046
|
-
display_id,
|
|
1047
|
-
next((item.get("queTitle") for item in refer_questions if _positive_int(item.get("queId")) == display_id), None),
|
|
1048
|
-
message="relation two-way bound field cannot be normalized",
|
|
1049
|
-
details={"queId": que_id, "path": "/config/twoWayBoundField"},
|
|
1050
|
-
)
|
|
1051
954
|
if supports_reference_default_formula:
|
|
1052
955
|
formula = reference.get("defaultValueFormula")
|
|
1053
956
|
relation_config["defaultValueFormula"] = formula if not isinstance(formula, str) or formula.strip() else None
|
|
@@ -1259,20 +1162,11 @@ class AppFormNormalizer:
|
|
|
1259
1162
|
name = match.group("name").strip()
|
|
1260
1163
|
if name:
|
|
1261
1164
|
token = match.group("token").strip()
|
|
1262
|
-
# 名字两侧 strip 匹配:存量字段名带尾随空格(如 am4oh08v2401
|
|
1263
|
-
# 「答:Answer 」),模板引用名无空格——严格相等会丢 queId 锚定
|
|
1264
1165
|
matching_ids = [
|
|
1265
1166
|
que_id
|
|
1266
1167
|
for que_id, field_name in field_names.items()
|
|
1267
|
-
if
|
|
1168
|
+
if field_name == name and _encrypt_question_id(que_id) == token
|
|
1268
1169
|
]
|
|
1269
|
-
if not matching_ids:
|
|
1270
|
-
# 悬空引用(字段已删/改名,模板仍留旧引用):原文透传——占位符
|
|
1271
|
-
# 化会丢 token 无法复原(加密不可逆),后端写链对模板原样落库;
|
|
1272
|
-
# 不注册 input(声明无此输入,写回与 B0 模板逐字一致,
|
|
1273
|
-
# r10 实测 9gtommb3ng01 等 6 app「inputs[N].field could not
|
|
1274
|
-
# be resolved」即此形态)
|
|
1275
|
-
return match.group(0)
|
|
1276
1170
|
json_path_inputs.setdefault(
|
|
1277
1171
|
name,
|
|
1278
1172
|
{
|
|
@@ -1285,21 +1179,11 @@ class AppFormNormalizer:
|
|
|
1285
1179
|
|
|
1286
1180
|
request_values[request_key] = Q_LINKER_VALUE_REFERENCE_RE.sub(replace_reference, raw_value)
|
|
1287
1181
|
inputs.extend(json_path_inputs.values())
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
# 自定义模式 url 空的 2 app 脓数据仍拒绝,入排除账本)。
|
|
1294
|
-
config_mode = _positive_int(config.get("configMode")) or 1
|
|
1295
|
-
if config_mode == 1:
|
|
1296
|
-
request_url = _required_text(
|
|
1297
|
-
request_values["url"],
|
|
1298
|
-
message="Q-Linker request URL is empty",
|
|
1299
|
-
details={"queId": source_id, "path": "/config/qLinkerBinding/request/url"},
|
|
1300
|
-
)
|
|
1301
|
-
else:
|
|
1302
|
-
request_url = str(request_values["url"] or "")
|
|
1182
|
+
request_url = _required_text(
|
|
1183
|
+
request_values["url"],
|
|
1184
|
+
message="Q-Linker request URL is empty",
|
|
1185
|
+
details={"queId": source_id, "path": "/config/qLinkerBinding/request/url"},
|
|
1186
|
+
)
|
|
1303
1187
|
request = _without_none(
|
|
1304
1188
|
{
|
|
1305
1189
|
"url": request_url,
|
|
@@ -1408,12 +1292,7 @@ class AppFormNormalizer:
|
|
|
1408
1292
|
que_id = _positive_int(question.get("queId"))
|
|
1409
1293
|
relation = next((item for item in relations if _positive_int(item.get("queId")) == que_id), None)
|
|
1410
1294
|
if relation is None:
|
|
1411
|
-
|
|
1412
|
-
# 对 displayedForm/displayedQuestion 为 null 的行 continue,40013 存量实证:displayed_que_id=0
|
|
1413
|
-
# 共 264 行、displayed_form/que 闭包外 98/116 行)。此时字段本体仍在 formQues 中,
|
|
1414
|
-
# 但关联配置不可回读——保留字段并回读空 config(source 省略);写回侧不重建该字段的
|
|
1415
|
-
# relation 行,与后端读面行为对称。库表级 diff 将此类行归入「历史数据保全」单列。
|
|
1416
|
-
return {}
|
|
1295
|
+
raise AppFormFailure("UNSUPPORTED_FIELD_CONFIG", "data_relation has no question relation", "NORMALIZE", details={"queId": que_id})
|
|
1417
1296
|
relation_type = _positive_int(relation.get("relationType")) or 1
|
|
1418
1297
|
displayed = relation.get("displayedQueInfo") if isinstance(relation.get("displayedQueInfo"), dict) else {}
|
|
1419
1298
|
alias = relation.get("aliasConfig") if isinstance(relation.get("aliasConfig"), dict) else {}
|
|
@@ -1514,25 +1393,15 @@ class AppFormNormalizer:
|
|
|
1514
1393
|
if isinstance(judge_type, bool) or not isinstance(judge_type, int) or judge_type not in BACKEND_TO_JUDGE_TYPE:
|
|
1515
1394
|
raise AppFormFailure("UNSUPPORTED_FIELD_CONFIG", "data_relation judgeType cannot be normalized", "NORMALIZE", details={"queId": que_id, "judgeType": judge_type})
|
|
1516
1395
|
condition: JSONObject = {
|
|
1517
|
-
"sourceField":
|
|
1396
|
+
"sourceField": _require_selector(value.get("queId"), value.get("queTitle"), message="data_relation source field cannot be normalized", details={"queId": que_id}),
|
|
1518
1397
|
"operator": BACKEND_TO_JUDGE_TYPE[judge_type],
|
|
1519
1398
|
}
|
|
1520
|
-
if condition["sourceField"] is None:
|
|
1521
|
-
# 存量读回形态:后端反解析出的条件源字段缺失(queId=0 且无名称,原公式
|
|
1522
|
-
# 如 #queId_0$)。省略 sourceField 保留 operator/operand 语义,写回时以
|
|
1523
|
-
# queId=0 透传(后端 matchRulesToFormula 重新生成 #queId_0$,往返对称)。
|
|
1524
|
-
del condition["sourceField"]
|
|
1525
1399
|
match_type = _positive_int(value.get("matchType")) or 1
|
|
1526
1400
|
if match_type == 1:
|
|
1527
1401
|
details = value.get("judgeValueDetails")
|
|
1528
1402
|
if isinstance(details, list):
|
|
1529
|
-
# value 为空串且 id 非空 → 悬空选项引用(选项已被删,id 仍指向已删
|
|
1530
|
-
# optId):以 id 形态保留(写回时透传为悬空引用,往返对称;约束 2:
|
|
1531
|
-
# 语义保真优先于「apply 通过」——不产出不可写回的空串声明)。
|
|
1532
1403
|
literal_values = [
|
|
1533
|
-
str(item
|
|
1534
|
-
if item.get("value") in (None, "") and item.get("id") is not None
|
|
1535
|
-
else str(item.get("value"))
|
|
1404
|
+
str(item.get("value") if item.get("value") is not None else item.get("id"))
|
|
1536
1405
|
for item in details
|
|
1537
1406
|
if isinstance(item, dict) and (item.get("value") is not None or item.get("id") is not None)
|
|
1538
1407
|
]
|
|
@@ -1783,10 +1652,7 @@ class AppFormCompiler:
|
|
|
1783
1652
|
selected = [field for field in target_fields if selector_id is not None and _positive_int(field.get("queId")) == selector_id]
|
|
1784
1653
|
if len(selected) == 1:
|
|
1785
1654
|
target_spec[key] = _selector(selected[0].get("queId"), selected[0].get("name"))
|
|
1786
|
-
|
|
1787
|
-
# FormSvcImpl.normalizeDataTitleQueId 会兑底到第一个可用字段,
|
|
1788
|
-
# current 与 declared 均为 null 时 target 保持 null 合法。
|
|
1789
|
-
data_title, data_cover = self._validate_target(target_spec, allow_missing_data_title=True)
|
|
1655
|
+
data_title, data_cover = self._validate_target(target_spec)
|
|
1790
1656
|
target_spec["dataTitleField"] = _selector(data_title.get("queId"), data_title.get("name")) if data_title is not None else None
|
|
1791
1657
|
target_spec["dataCoverField"] = _selector(data_cover.get("queId"), data_cover.get("name")) if data_cover is not None else None
|
|
1792
1658
|
return {"apiVersion": context.api_version, "appKey": app_key, "spec": target_spec}
|
|
@@ -1807,8 +1673,6 @@ class AppFormCompiler:
|
|
|
1807
1673
|
relation_context["questionRelations"] = relations
|
|
1808
1674
|
internal_fields, relations = _compile_q_linker_binding_fields(fields=internal_fields, current_schema=relation_context)
|
|
1809
1675
|
self._validate_unique_relation_targets(relations, internal_fields)
|
|
1810
|
-
except AmbiguousFieldSelector as error:
|
|
1811
|
-
raise AppFormFailure("AMBIGUOUS_FIELD_SELECTOR", str(error), "CONTRACT_PRECHECK", details={"candidateQueIds": error.candidates}) from error
|
|
1812
1676
|
except (KeyError, ValueError) as error:
|
|
1813
1677
|
raise AppFormFailure("UNSUPPORTED_FIELD_CONFIG", str(error), "CONTRACT_PRECHECK") from error
|
|
1814
1678
|
self._assign_subfield_temp_ids(internal_fields)
|
|
@@ -1822,9 +1686,7 @@ class AppFormCompiler:
|
|
|
1822
1686
|
if not isinstance(item, dict) or item.get("source") != "json_path":
|
|
1823
1687
|
continue
|
|
1824
1688
|
selected = _resolve_internal_selector(item.get("field") or {}, internal_fields, f"qLinkerBinding.inputs[{index}].field")
|
|
1825
|
-
|
|
1826
|
-
# 如 r10 am4oh08v2401「答:Answer 」),严格相等误报 key 不一致
|
|
1827
|
-
if str(item.get("key") or "").strip() != str(selected.get("name") or "").strip():
|
|
1689
|
+
if str(item.get("key") or "") != str(selected.get("name") or ""):
|
|
1828
1690
|
raise AppFormFailure(
|
|
1829
1691
|
"UNSUPPORTED_FIELD_CONFIG",
|
|
1830
1692
|
"json_path input key must equal the selected field name for lossless readback",
|
|
@@ -2116,13 +1978,10 @@ class AppFormCompiler:
|
|
|
2116
1978
|
if any(present) and not all(present):
|
|
2117
1979
|
raise AppFormFailure("INVALID_ROW_WIDTH", "all fields in a row must provide width together", "CONTRACT_PRECHECK")
|
|
2118
1980
|
if all(present):
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
# 数据存在字段删除后留白的行,get 读出的原布局必须能原样 apply。
|
|
2124
|
-
if any(not (isinstance(field.get("width"), int) and 1 <= field["width"] <= 100) for field in fields):
|
|
2125
|
-
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")
|
|
2126
1985
|
return
|
|
2127
1986
|
widths = {
|
|
2128
1987
|
1: [100],
|
|
@@ -2141,23 +2000,24 @@ class AppFormCompiler:
|
|
|
2141
2000
|
) -> tuple[JSONObject | None, JSONObject | None]:
|
|
2142
2001
|
body = spec.get("body") or []
|
|
2143
2002
|
fields = list(_iter_fields(body))
|
|
2144
|
-
|
|
2145
|
-
# (40013 实测 1624 组同名),get 提出的合法形态必须能写回;name 解析的
|
|
2146
|
-
# 歧义由各 selector resolver 以 AMBIGUOUS_FIELD_SELECTOR 显式拒绝,
|
|
2147
|
-
# 不在目标校验层做 name 唯一性强校验。
|
|
2003
|
+
names: set[str] = set()
|
|
2148
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)
|
|
2149
2009
|
if _field_type(field) == "subtable":
|
|
2010
|
+
sub_names: set[str] = set()
|
|
2150
2011
|
for subfield in field.get("subfields") or []:
|
|
2151
2012
|
if _field_type(subfield) not in SUBFIELD_TYPES:
|
|
2152
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 ""))
|
|
2153
2017
|
title_selector = spec.get("dataTitleField")
|
|
2154
2018
|
data_title: JSONObject | None = None
|
|
2155
2019
|
if fields:
|
|
2156
|
-
|
|
2157
|
-
# FormSvcImpl.normalizeDataTitleQueId 对 null 自动 fallback 到第一个
|
|
2158
|
-
# 可用字段(findDefaultDataTitleQueId),apply 侧缺省完全合法;
|
|
2159
|
-
# get 读面在后端 dataTitleQueId 未设置时原样输出 null,round-trip 必须接受。
|
|
2160
|
-
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:
|
|
2161
2021
|
pass
|
|
2162
2022
|
elif not isinstance(title_selector, dict):
|
|
2163
2023
|
raise AppFormFailure(
|
|
@@ -2208,8 +2068,6 @@ class AppFormCompiler:
|
|
|
2208
2068
|
relation_context["questionRelations"] = relations
|
|
2209
2069
|
internal_fields, relations = _compile_q_linker_binding_fields(fields=internal_fields, current_schema=relation_context)
|
|
2210
2070
|
self._validate_unique_relation_targets(relations, internal_fields)
|
|
2211
|
-
except AmbiguousFieldSelector as error:
|
|
2212
|
-
raise AppFormFailure("AMBIGUOUS_FIELD_SELECTOR", str(error), "COMPILE", details={"candidateQueIds": error.candidates}) from error
|
|
2213
2071
|
except (KeyError, ValueError) as error:
|
|
2214
2072
|
raise AppFormFailure("UNSUPPORTED_FIELD_CONFIG", str(error), "COMPILE") from error
|
|
2215
2073
|
self._assign_subfield_temp_ids(internal_fields)
|
|
@@ -2266,8 +2124,7 @@ class AppFormCompiler:
|
|
|
2266
2124
|
rows.append([section])
|
|
2267
2125
|
payload["formQues"] = rows
|
|
2268
2126
|
self._validate_unique_temp_ids(rows)
|
|
2269
|
-
|
|
2270
|
-
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
|
|
2271
2128
|
data_cover_selector = target["spec"].get("dataCoverField")
|
|
2272
2129
|
data_cover = _resolve_canonical_selector(data_cover_selector, target_fields, "dataCoverField") if isinstance(data_cover_selector, dict) else None
|
|
2273
2130
|
payload["dataTitleQueId"] = self._field_runtime_id(data_title, internal_fields)
|
|
@@ -2474,16 +2331,6 @@ class AppFormCompiler:
|
|
|
2474
2331
|
{
|
|
2475
2332
|
"target_app_key": config.get("targetAppKey"),
|
|
2476
2333
|
"relation_mode": config.get("relationMode") or "single",
|
|
2477
|
-
"two_way_bound_field": _snake_selector(config.get("twoWayBoundField")),
|
|
2478
|
-
"display_fields": [
|
|
2479
|
-
{
|
|
2480
|
-
"field": _snake_selector(item.get("field")),
|
|
2481
|
-
"auth": item.get("auth") or "visible",
|
|
2482
|
-
}
|
|
2483
|
-
for item in config.get("displayFields") or []
|
|
2484
|
-
if isinstance(item, dict)
|
|
2485
|
-
],
|
|
2486
|
-
# Deprecated input names stay available to the compiler compatibility translator.
|
|
2487
2334
|
"display_field": _snake_selector(config.get("displayField")),
|
|
2488
2335
|
"visible_fields": [_snake_selector(item) for item in config.get("visibleFields") or []],
|
|
2489
2336
|
"reference_default_formula_auto_fill": config.get("beingDefaultFormulaAutoFillEnabled"),
|
|
@@ -2502,18 +2349,7 @@ class AppFormCompiler:
|
|
|
2502
2349
|
def _question(self, profile: str, app_key: str, field: JSONObject, internal: JSONObject, temp_id: int, relations: list[JSONObject]) -> JSONObject:
|
|
2503
2350
|
field_type = _field_type(field)
|
|
2504
2351
|
que_type = CANONICAL_TO_QUE_TYPE[field_type]
|
|
2505
|
-
build_type =
|
|
2506
|
-
"boolean" if field_type == "single_choice"
|
|
2507
|
-
else "text" if field_type in {
|
|
2508
|
-
"link", "data_relation", "rich_text",
|
|
2509
|
-
# 透传型新类型(r10 读面覆盖):build 走 text 兜底,类型/配置经模板保真
|
|
2510
|
-
"date_duration", "image_select", "location", "ocr", "embed_field",
|
|
2511
|
-
"ai_content_generation", "ai_content_extraction", "ai_content_classification",
|
|
2512
|
-
"ai_machine_translation", "ai_image_recognition", "ai_image_generation",
|
|
2513
|
-
"ai_document_parsing", "ai_text_summary",
|
|
2514
|
-
}
|
|
2515
|
-
else field_type
|
|
2516
|
-
)
|
|
2352
|
+
build_type = "boolean" if field_type == "single_choice" else "text" if field_type in {"link", "data_relation"} else field_type
|
|
2517
2353
|
built_internal = deepcopy(internal)
|
|
2518
2354
|
built_internal["type"] = build_type
|
|
2519
2355
|
if build_type == "subtable":
|
|
@@ -2670,24 +2506,18 @@ class AppFormCompiler:
|
|
|
2670
2506
|
config = field.get("config") or {}
|
|
2671
2507
|
related_que_id = _positive_int(internal.get("data_relation_related_que_id"))
|
|
2672
2508
|
if related_que_id is None:
|
|
2673
|
-
|
|
2674
|
-
# 镜像字段(relatedQueId/queOriginType),透传保持字段级语义不失真。
|
|
2675
|
-
template_related = _positive_int((template or {}).get("relatedQueId"))
|
|
2676
|
-
if template_related is not None:
|
|
2677
|
-
question["relatedQueId"] = template_related
|
|
2678
|
-
else:
|
|
2679
|
-
question.pop("relatedQueId", None)
|
|
2509
|
+
question.pop("relatedQueId", None)
|
|
2680
2510
|
else:
|
|
2681
2511
|
question["relatedQueId"] = related_que_id
|
|
2682
2512
|
que_origin_type = _positive_int(internal.get("data_relation_origin_type"))
|
|
2683
2513
|
if que_origin_type is None:
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
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
|
|
2691
2521
|
aggregation = config.get("aggregation")
|
|
2692
2522
|
if aggregation is None:
|
|
2693
2523
|
question.pop("aggreType", None)
|
|
@@ -2702,76 +2532,23 @@ class AppFormCompiler:
|
|
|
2702
2532
|
|
|
2703
2533
|
def _reference_config(self, profile: str, config: JSONObject, template: JSONObject | None = None) -> JSONObject:
|
|
2704
2534
|
target_app_key = str(config.get("targetAppKey") or "")
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
]
|
|
2712
|
-
if explicit_display and deprecated_keys:
|
|
2713
|
-
raise AppFormFailure(
|
|
2714
|
-
"INVALID_SPEC",
|
|
2715
|
-
"relation cannot mix displayFields with deprecated displayField/visibleFields",
|
|
2716
|
-
"CONTRACT_PRECHECK",
|
|
2717
|
-
)
|
|
2718
|
-
if explicit_display:
|
|
2719
|
-
# 显式 displayFields(含空列表)按声明原样写:空列表 = 无展示字段,round-trip 空↔空稳定。
|
|
2720
|
-
# 后端语义(2026-09-23 沿源码定案):FormQuestionSvcImpl.creteOrUpdateReference 对空
|
|
2721
|
-
# referQuestions 走 CollectionUtils.isNotEmpty 跳过插入,读面 getReferenceConfig
|
|
2722
|
-
# 同样容忍空——空列表是合法存量形态(双向绑定 referQueId 非空、未配展示字段)。
|
|
2723
|
-
display_entries = [item for item in config["displayFields"] if isinstance(item, dict)]
|
|
2724
|
-
elif deprecated_keys:
|
|
2725
|
-
# v1alpha1 compatibility: the old displayField was both the main display field and the binding field.
|
|
2726
|
-
legacy_display = config.get("displayField") or config.get("display_field") or {}
|
|
2727
|
-
legacy_visible = config.get("visibleFields") or config.get("visible_fields") or []
|
|
2728
|
-
display_entries = [{"field": legacy_display, "auth": "visible"}]
|
|
2729
|
-
display_entries.extend({"field": item, "auth": "visible"} for item in legacy_visible if isinstance(item, dict))
|
|
2730
|
-
else:
|
|
2731
|
-
raise AppFormFailure(
|
|
2732
|
-
"INVALID_SPEC",
|
|
2733
|
-
"relation config requires displayFields (explicit empty list allowed for fields without display fields)",
|
|
2734
|
-
"CONTRACT_PRECHECK",
|
|
2735
|
-
)
|
|
2736
|
-
resolved_display = []
|
|
2737
|
-
seen_ids = set()
|
|
2738
|
-
for index, entry in enumerate(display_entries):
|
|
2739
|
-
selector = entry.get("field") if isinstance(entry, dict) else {}
|
|
2740
|
-
item = _resolve_backend_selector(selector or {}, candidates, f"displayFields[{index}].field")
|
|
2741
|
-
if item["queId"] in seen_ids:
|
|
2742
|
-
raise AppFormFailure(
|
|
2743
|
-
"INVALID_SPEC",
|
|
2744
|
-
f"displayFields[{index}].field resolves to duplicate queId {item['queId']}",
|
|
2745
|
-
"CONTRACT_PRECHECK",
|
|
2746
|
-
)
|
|
2747
|
-
seen_ids.add(item["queId"])
|
|
2748
|
-
auth = (entry.get("auth") if isinstance(entry, dict) else None) or "visible"
|
|
2749
|
-
auth_code = {"editable": 1, "hidden": 2, "visible": 3}.get(auth)
|
|
2750
|
-
if auth_code is None:
|
|
2751
|
-
raise AppFormFailure("INVALID_SPEC", "displayFields auth must be editable, hidden, or visible", "CONTRACT_PRECHECK")
|
|
2752
|
-
resolved_display.append((item, auth_code))
|
|
2753
|
-
two_way = config.get("twoWayBoundField") or config.get("two_way_bound_field")
|
|
2754
|
-
if two_way is None and (config.get("displayField") or config.get("display_field")):
|
|
2755
|
-
two_way = config.get("displayField") or config.get("display_field")
|
|
2756
|
-
resolved_two_way = _resolve_backend_selector(two_way, candidates, "twoWayBoundField") if two_way else None
|
|
2757
|
-
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)
|
|
2758
2541
|
result = deepcopy(template or {})
|
|
2759
2542
|
result.update(
|
|
2760
2543
|
{
|
|
2761
2544
|
"referAppKey": target_app_key,
|
|
2762
|
-
"referQueId":
|
|
2545
|
+
"referQueId": display["queId"],
|
|
2763
2546
|
"optionalDataNum": 1 if config.get("relationMode", "single") == "single" else 2,
|
|
2764
2547
|
"referQuestions": [
|
|
2765
|
-
|
|
2766
|
-
|
|
2548
|
+
{"queId": item["queId"], "queTitle": item["queTitle"], "queType": item["queType"], "queAuth": 3, "ordinal": index}
|
|
2549
|
+
for index, item in enumerate(visible)
|
|
2767
2550
|
],
|
|
2768
|
-
|
|
2769
|
-
# 与展示字段轴(referQuestions)独立;声明未建模该轴,已有配置原样透传
|
|
2770
|
-
# (空列表同样原样保留——被默认值覆盖会改变存量语义,历史数据保全红线),
|
|
2771
|
-
# 仅新建字段(无模板)按展示字段默认 editable,避免删除重建丢权限配置。
|
|
2772
|
-
"referAuthQues": deepcopy(template_auth)
|
|
2773
|
-
if template_auth is not None
|
|
2774
|
-
else [{"queId": item["queId"], "queAuth": 1} for item, _ in resolved_display],
|
|
2551
|
+
"referAuthQues": [{"queId": item["queId"], "queAuth": 1} for item in visible],
|
|
2775
2552
|
"beingDefaultFormulaAutoFillEnabled": config.get("beingDefaultFormulaAutoFillEnabled"),
|
|
2776
2553
|
"defaultValueFormula": config.get("defaultValueFormula"),
|
|
2777
2554
|
"defaultValueMatchRules": deepcopy(config.get("defaultValueMatchRules")),
|
|
@@ -2789,7 +2566,8 @@ class AppFormCompiler:
|
|
|
2789
2566
|
target_app_key = str(relation.get("targetAppKey") or "")
|
|
2790
2567
|
if not target_app_key:
|
|
2791
2568
|
raise AppFormFailure("UNSUPPORTED_FIELD_CONFIG", "select scope relation requires targetAppKey", "COMPILE")
|
|
2792
|
-
|
|
2569
|
+
target_form = self._read_form(profile, target_app_key, being_draft=False)
|
|
2570
|
+
candidates = _backend_question_candidates(target_form)
|
|
2793
2571
|
displayed = _resolve_backend_selector(relation.get("displayedField") or {}, candidates, "selectScopeRelation.displayedField")
|
|
2794
2572
|
payload: JSONObject = {
|
|
2795
2573
|
"queId": _positive_int(internal.get("que_id")) or int(internal.get("que_temp_id") or 0),
|
|
@@ -2811,27 +2589,14 @@ class AppFormCompiler:
|
|
|
2811
2589
|
def _resolve_subfield_runtime_id(selector: JSONObject, internal_fields: list[JSONObject], location: str) -> int:
|
|
2812
2590
|
if not isinstance(selector, dict):
|
|
2813
2591
|
raise AppFormFailure("INVALID_FIELD_SELECTOR", f"{location} must be an object", "COMPILE")
|
|
2814
|
-
# 同 _resolve_backend_selector:name 两侧 strip(存量字段名带前后空格,queId 兜底唯一性)。
|
|
2815
2592
|
que_id = _positive_int(selector.get("queId"))
|
|
2816
|
-
name = str(selector.get("name") or "")
|
|
2593
|
+
name = str(selector.get("name") or "")
|
|
2817
2594
|
matches = [
|
|
2818
2595
|
item for item in internal_fields
|
|
2819
2596
|
if (que_id is None or _positive_int(item.get("que_id")) == que_id)
|
|
2820
|
-
and (not name or str(item.get("name") or "")
|
|
2597
|
+
and (not name or str(item.get("name") or "") == name)
|
|
2821
2598
|
]
|
|
2822
2599
|
if len(matches) != 1:
|
|
2823
|
-
if que_id is None and matches:
|
|
2824
|
-
raise AppFormFailure(
|
|
2825
|
-
"AMBIGUOUS_FIELD_SELECTOR",
|
|
2826
|
-
f"{location} matches multiple subfields with the same name; use queId to disambiguate",
|
|
2827
|
-
"COMPILE",
|
|
2828
|
-
details={
|
|
2829
|
-
"selector": selector,
|
|
2830
|
-
"candidateQueIds": sorted(
|
|
2831
|
-
{_positive_int(item.get("que_id")) or 0 for item in matches}
|
|
2832
|
-
),
|
|
2833
|
-
},
|
|
2834
|
-
)
|
|
2835
2600
|
raise AppFormFailure("INVALID_FIELD_SELECTOR", f"{location} must resolve to exactly one subfield", "COMPILE", details={"selector": selector, "matches": len(matches)})
|
|
2836
2601
|
return _positive_int(matches[0].get("que_id")) or int(matches[0].get("que_temp_id") or 0)
|
|
2837
2602
|
|
|
@@ -2862,13 +2627,6 @@ class AppFormCompiler:
|
|
|
2862
2627
|
continue
|
|
2863
2628
|
config = field.get("config") or {}
|
|
2864
2629
|
source = config.get("source") or {}
|
|
2865
|
-
if not source.get("kind"):
|
|
2866
|
-
# 存量读回形态:后端 GET 不回传该字段的关联配置(displayed 目标缺失被
|
|
2867
|
-
# getQuestionRelationsBeans continue 过滤)。normalizer 提取为无 source 的
|
|
2868
|
-
# 空 config;写回侧保持对称——不重建 relation 行(后端删除重建后自然无行)。
|
|
2869
|
-
internal.pop("data_relation_related_que_id", None)
|
|
2870
|
-
internal.pop("data_relation_origin_type", None)
|
|
2871
|
-
continue
|
|
2872
2630
|
field_id = _positive_int(internal.get("que_id")) or int(internal.get("que_temp_id") or 0) or None
|
|
2873
2631
|
relation: JSONObject = {
|
|
2874
2632
|
"queId": field_id,
|
|
@@ -2883,23 +2641,10 @@ class AppFormCompiler:
|
|
|
2883
2641
|
source_fields = available_internal_fields
|
|
2884
2642
|
source_fields_are_backend = False
|
|
2885
2643
|
if source.get("kind") == "app":
|
|
2886
|
-
|
|
2644
|
+
target_form = self._read_form(profile, str(source.get("appKey") or ""), being_draft=False)
|
|
2645
|
+
source_fields = _backend_question_candidates(target_form)
|
|
2887
2646
|
source_fields_are_backend = True
|
|
2888
|
-
|
|
2889
|
-
# 原样落库(fillMatchRulesList 读链按全局 question∪draft_question
|
|
2890
|
-
# 解析)。解析不到时 queType=None → 写侧 origin_type 回退 current
|
|
2891
|
-
# 模板值(见 data_relation relatedQueId/queOriginType 组装)。
|
|
2892
|
-
try:
|
|
2893
|
-
target = _resolve_backend_selector(source.get("field") or {}, source_fields, "source.field")
|
|
2894
|
-
except AppFormFailure as error:
|
|
2895
|
-
if error.error_code != "INVALID_FIELD_SELECTOR" or _positive_int((source.get("field") or {}).get("queId")) is None:
|
|
2896
|
-
raise
|
|
2897
|
-
target = {
|
|
2898
|
-
"queId": _positive_int(source["field"].get("queId")),
|
|
2899
|
-
"queTitle": str(source["field"].get("name") or "").strip() or None,
|
|
2900
|
-
"queType": None,
|
|
2901
|
-
"_app_form_unresolved": True,
|
|
2902
|
-
}
|
|
2647
|
+
target = _resolve_backend_selector(source.get("field") or {}, source_fields, "source.field")
|
|
2903
2648
|
relation.update(
|
|
2904
2649
|
{
|
|
2905
2650
|
"displayedQueId": target["queId"],
|
|
@@ -3013,32 +2758,25 @@ class AppFormCompiler:
|
|
|
3013
2758
|
current_fields: list[JSONObject],
|
|
3014
2759
|
source_fields_are_backend: bool,
|
|
3015
2760
|
) -> JSONObject:
|
|
3016
|
-
|
|
3017
|
-
|
|
3018
|
-
|
|
3019
|
-
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
location="sourceField",
|
|
3023
|
-
)
|
|
3024
|
-
else:
|
|
3025
|
-
# 存量读回形态:无源字段(原公式 #queId_0$)。以 queId=0 透传,后端
|
|
3026
|
-
# matchRulesToFormula 重新生成 #queId_0$,与原始库内公式往返对称。
|
|
3027
|
-
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
|
+
)
|
|
3028
2767
|
operand = condition.get("operand") or {}
|
|
3029
2768
|
operator = str(condition.get("operator") or "")
|
|
3030
2769
|
cls._validate_data_relation_operator(source, operator, operand)
|
|
3031
2770
|
payload: JSONObject = {
|
|
3032
|
-
"queId": cls._data_relation_field_ref(source)
|
|
3033
|
-
"queTitle": cls._data_relation_field_name(source)
|
|
3034
|
-
"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),
|
|
3035
2774
|
"judgeType": JUDGE_TYPE_TO_BACKEND[operator],
|
|
3036
2775
|
}
|
|
3037
2776
|
if operand.get("kind") == "literal":
|
|
3038
2777
|
judge_values, judge_value_details = cls._data_relation_literal_values(
|
|
3039
2778
|
source,
|
|
3040
2779
|
deepcopy(operand.get("values") or []),
|
|
3041
|
-
operator=operator,
|
|
3042
2780
|
)
|
|
3043
2781
|
payload.update(
|
|
3044
2782
|
{
|
|
@@ -3064,7 +2802,7 @@ class AppFormCompiler:
|
|
|
3064
2802
|
return payload
|
|
3065
2803
|
|
|
3066
2804
|
@staticmethod
|
|
3067
|
-
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]]:
|
|
3068
2806
|
field_type = _canonical_backend_field_type(source)
|
|
3069
2807
|
options = [item for item in source.get("options") or [] if isinstance(item, dict)]
|
|
3070
2808
|
option_by_name = {
|
|
@@ -3094,13 +2832,6 @@ class AppFormCompiler:
|
|
|
3094
2832
|
if field_type in CHOICE_TYPES and options:
|
|
3095
2833
|
option = option_by_name.get(text) or option_by_id.get(text)
|
|
3096
2834
|
if option is None:
|
|
3097
|
-
# 悬空选项引用(选项已被删,id 仍指向已删 optId):透传为存量
|
|
3098
|
-
# 同构的悬空引用(judgeValues=[optId], details=[{id,value:""}]),
|
|
3099
|
-
# 后端接受该形态(生产库存量即如此),往返对称;不是选项校验失败。
|
|
3100
|
-
if text.isdigit() and _positive_int(text) is not None:
|
|
3101
|
-
judge_values.append(text)
|
|
3102
|
-
details.append({"id": int(text), "value": ""})
|
|
3103
|
-
continue
|
|
3104
2835
|
raise AppFormFailure(
|
|
3105
2836
|
"UNSUPPORTED_FIELD_CONFIG",
|
|
3106
2837
|
f"literal value {text!r} is not an option of data_relation source field "
|
|
@@ -3113,28 +2844,6 @@ class AppFormCompiler:
|
|
|
3113
2844
|
judge_values.append(str(option_id) if option_id is not None else option_name)
|
|
3114
2845
|
details.append(_without_none({"id": option_id, "value": option_name}))
|
|
3115
2846
|
continue
|
|
3116
|
-
if operator == "dynamic_range":
|
|
3117
|
-
# 动态范围(judgeType=8):后端 checkRelationMatchRules 要求
|
|
3118
|
-
# judgeValueDetails 非空且 id 非_null;提取侧 values 取自
|
|
3119
|
-
# judgeValueDetails.value(动态范围名称),写回必须恢复 id——
|
|
3120
|
-
# 名称→id 映射对齐 Constants.DYNAMIC_TYPE;数字字符串直通(id 形态)。
|
|
3121
|
-
range_id = DYNAMIC_RANGE_NAME_TO_ID.get(text)
|
|
3122
|
-
if range_id is None and text.isdigit():
|
|
3123
|
-
range_id = _positive_int(text)
|
|
3124
|
-
if range_id is None:
|
|
3125
|
-
# 宽松匹配(剔内嵌空白)兜底:数组存在带/不带空格变体
|
|
3126
|
-
range_id = _DYNAMIC_RANGE_NORMALIZED.get("".join(text.split()))
|
|
3127
|
-
if range_id is None:
|
|
3128
|
-
raise AppFormFailure(
|
|
3129
|
-
"UNSUPPORTED_FIELD_CONFIG",
|
|
3130
|
-
f"literal value {text!r} is not a known dynamic range of data_relation "
|
|
3131
|
-
f"source field {AppFormCompiler._data_relation_field_name(source)!r}",
|
|
3132
|
-
"CONTRACT_PRECHECK",
|
|
3133
|
-
details={"value": text, "allowedValues": sorted(DYNAMIC_RANGE_NAME_TO_ID)},
|
|
3134
|
-
)
|
|
3135
|
-
judge_values.append(str(range_id))
|
|
3136
|
-
details.append({"id": range_id, "value": text})
|
|
3137
|
-
continue
|
|
3138
2847
|
judge_values.append(text)
|
|
3139
2848
|
details.append({"value": text})
|
|
3140
2849
|
return judge_values, details
|
|
@@ -3148,23 +2857,7 @@ class AppFormCompiler:
|
|
|
3148
2857
|
location: str,
|
|
3149
2858
|
) -> JSONObject:
|
|
3150
2859
|
if source_fields_are_backend:
|
|
3151
|
-
|
|
3152
|
-
return _resolve_backend_selector(selector, source_fields, location)
|
|
3153
|
-
except AppFormFailure as error:
|
|
3154
|
-
# 目标表单候选集(草稿∪发布 formQues)解析不到、但 selector 带正
|
|
3155
|
-
# queId:按后端语义透传——读链 fillMatchRulesList 按 queId 查
|
|
3156
|
-
# question∪draft_question 全局表,查不到容忍置空(new Question());
|
|
3157
|
-
# 写链 updateFormInfo 对 matchRules/sortConfig 源 queId 无存在性
|
|
3158
|
-
# 校验、原样落库。存量里该形态 = 引用指向目标表单 GET 响应之外
|
|
3159
|
-
# 的字段(跨表/已删,如 r8 实测 00e0e0d8 筛选源),严格拒绝即
|
|
3160
|
-
# round-trip 断裂。queType 置 None 与后端「查不到置空」同构。
|
|
3161
|
-
if error.error_code == "INVALID_FIELD_SELECTOR" and _positive_int(selector.get("queId")) is not None:
|
|
3162
|
-
return {
|
|
3163
|
-
"queId": _positive_int(selector.get("queId")),
|
|
3164
|
-
"queTitle": str(selector.get("name") or "").strip() or None,
|
|
3165
|
-
"_app_form_unresolved": True,
|
|
3166
|
-
}
|
|
3167
|
-
raise
|
|
2860
|
+
return _resolve_backend_selector(selector, source_fields, location)
|
|
3168
2861
|
return _resolve_internal_selector(selector, source_fields, location)
|
|
3169
2862
|
|
|
3170
2863
|
@staticmethod
|
|
@@ -3173,20 +2866,10 @@ class AppFormCompiler:
|
|
|
3173
2866
|
|
|
3174
2867
|
@staticmethod
|
|
3175
2868
|
def _data_relation_field_name(field: JSONObject) -> str:
|
|
3176
|
-
|
|
3177
|
-
# (「父表·子字段」):写回按同形态组装,与提取(后端拼接的 queTitle)对称。
|
|
3178
|
-
title = str(field.get("queTitle") or field.get("name") or "")
|
|
3179
|
-
sup_title = str(field.get("_sup_que_title") or "")
|
|
3180
|
-
if sup_title and title:
|
|
3181
|
-
return f"{sup_title}·{title}"
|
|
3182
|
-
return title
|
|
2869
|
+
return str(field.get("queTitle") or field.get("name") or "")
|
|
3183
2870
|
|
|
3184
2871
|
@staticmethod
|
|
3185
|
-
def _data_relation_que_type(field: JSONObject) -> int
|
|
3186
|
-
if field.get("_app_form_unresolved"):
|
|
3187
|
-
# 透传形态(目标表单候选集解析不到):后端 fillMatchRulesList 对查
|
|
3188
|
-
# 不到的 queId 置空 queType 继续出参,写链不校验——同构置 None。
|
|
3189
|
-
return None
|
|
2872
|
+
def _data_relation_que_type(field: JSONObject) -> int:
|
|
3190
2873
|
backend_type = _positive_int(field.get("queType"))
|
|
3191
2874
|
if backend_type is not None:
|
|
3192
2875
|
return backend_type
|
|
@@ -3240,25 +2923,10 @@ class AppFormCompiler:
|
|
|
3240
2923
|
ordered_ops = common | {"greater_than", "greater_or_equal", "less_than", "less_or_equal", "dynamic_range", "decimal_range"}
|
|
3241
2924
|
organization_ops = common | {"belongs_to", "not_belongs_to", "subordinate_to", "not_subordinate_to", "subordinate_included", "subordinate_not_included", "contains_any", "contains_all"}
|
|
3242
2925
|
field_type = _canonical_backend_field_type(source)
|
|
3243
|
-
if
|
|
3244
|
-
|
|
3245
|
-
# operator×源类型组合无校验——跳过类型白名单,仅保留 operand 形状
|
|
3246
|
-
# 校验(下方),与「读面保留、写面透传」的存量形态处理同口径。
|
|
3247
|
-
allowed = common | text_ops | ordered_ops | organization_ops
|
|
3248
|
-
elif field_type in {"number", "amount", "date", "datetime"}:
|
|
3249
|
-
# date/datetime 补 belongs_to/not_belongs_to:后端 judgeType=13(JUDGE_BELONG_TO)
|
|
3250
|
-
# 对时间字段的存量语义是「属于时间范围」(judgeValues 形如
|
|
3251
|
-
# "2025-01-30 14:20:59~2026-01-30 14:20:59"),与组织字段的属于语义
|
|
3252
|
-
# 共用同一 judgeType——存量实证(如 82d9379e 申请时间筛选)。
|
|
3253
|
-
allowed = ordered_ops | ({"belongs_to", "not_belongs_to"} if field_type in {"date", "datetime"} else set())
|
|
2926
|
+
if field_type in {"number", "amount", "date", "datetime"}:
|
|
2927
|
+
allowed = ordered_ops
|
|
3254
2928
|
elif field_type in {"member", "department"}:
|
|
3255
2929
|
allowed = organization_ops
|
|
3256
|
-
elif field_type in {"text", "multi_select"}:
|
|
3257
|
-
# text/multi_select 补 belongs_to/not_belongs_to:judgeType=13 同为多态
|
|
3258
|
-
# 语义(选项「属于」集合),后端写链对 operator×源类型无校验——存量
|
|
3259
|
-
# 实证 r8:bjqj2bge2g02(multi_select 资产类型 belongs_to 当前字段)、
|
|
3260
|
-
# e4f644fe(text 客户等级 belongs_to literal ["BC"])。
|
|
3261
|
-
allowed = text_ops | {"belongs_to", "not_belongs_to"}
|
|
3262
2930
|
else:
|
|
3263
2931
|
allowed = text_ops
|
|
3264
2932
|
if operator not in allowed:
|
|
@@ -3272,10 +2940,7 @@ class AppFormCompiler:
|
|
|
3272
2940
|
if operator in EMPTY_OPERATORS:
|
|
3273
2941
|
if operand.get("kind") != "literal" or values not in ([], None):
|
|
3274
2942
|
raise AppFormFailure("UNSUPPORTED_FIELD_CONFIG", "empty-value operators require an empty literal operand", "CONTRACT_PRECHECK")
|
|
3275
|
-
elif operand.get("kind") == "literal" and not values
|
|
3276
|
-
# subordinate 两算子放行空 literal:存量「设置后清空」形态(r13 实证
|
|
3277
|
-
# 41ce0ad8)——后端写链仅校验 dynamic_range(checkRelationMatchRules),
|
|
3278
|
-
# formula 对空 judgeValueDetails 渲染 [],读回解析对称,往返保真
|
|
2943
|
+
elif operand.get("kind") == "literal" and not values:
|
|
3279
2944
|
raise AppFormFailure("UNSUPPORTED_FIELD_CONFIG", "literal data_relation operands must contain at least one value", "CONTRACT_PRECHECK")
|
|
3280
2945
|
|
|
3281
2946
|
@staticmethod
|
|
@@ -3371,42 +3036,6 @@ class AppFormCompiler:
|
|
|
3371
3036
|
raise AppFormFailure("FORM_READ_FAILED", "backend returned an invalid form", "READ")
|
|
3372
3037
|
return value
|
|
3373
3038
|
|
|
3374
|
-
def _target_form_candidates(self, profile: str, app_key: str) -> tuple[list[JSONObject], JSONObject]:
|
|
3375
|
-
"""跨表引用解析候选集:目标表 草稿∪发布 并集(queId 为 key,草稿覆盖)。
|
|
3376
|
-
|
|
3377
|
-
后端写链对跨表 queId(referQuestions/displayedQueId/matchRules 源字段)无
|
|
3378
|
-
存在性校验、原样落库(FormSvcImpl.updateFormInfo);链上唯一读目标字段的
|
|
3379
|
-
Formula.matchRulesToFormula 取 question∪draft_question 且草稿覆盖,编辑器
|
|
3380
|
-
候选接口(/question/{appKey}/relation)同样支持 beingDraft 草稿态。get 按
|
|
3381
|
-
草稿姿态提取声明,apply 只读发布会漏掉目标表「只加在草稿、从未发布」的字段
|
|
3382
|
-
(round-trip 断裂);只读草稿又会漏掉发布后草稿里删掉的旧字段——并集与
|
|
3383
|
-
后端写链语义一致。草稿读失败(目标表无草稿等)退回仅发布态。
|
|
3384
|
-
"""
|
|
3385
|
-
draft_form: JSONObject = {}
|
|
3386
|
-
try:
|
|
3387
|
-
draft_form = self._read_form(profile, app_key, being_draft=True)
|
|
3388
|
-
except AppFormFailure:
|
|
3389
|
-
draft_form = {}
|
|
3390
|
-
published_form = self._read_form(profile, app_key, being_draft=False)
|
|
3391
|
-
merged: list[JSONObject] = []
|
|
3392
|
-
seen: set[int] = set()
|
|
3393
|
-
for item in [*_backend_question_candidates(draft_form), *_backend_question_candidates(published_form)]:
|
|
3394
|
-
raw_id = item.get("queId")
|
|
3395
|
-
if isinstance(raw_id, int) and not isinstance(raw_id, bool):
|
|
3396
|
-
# queId 是字段身份,身份语义覆盖 0 与负数:0 是系统「编号」虚拟行
|
|
3397
|
-
# (que_type=8,GET 草稿/发布两态各带一行)、负数是系统行(-1 数据来源
|
|
3398
|
-
# 等,补导后物理在表)。只按正整数去重会让 queId=0 的「编号」在
|
|
3399
|
-
# 并集里出现两次,by_name 引用恒定命中 2 个候选 → AMBIGUOUS_
|
|
3400
|
-
# FIELD_SELECTOR(r13 103 app data_relation/relation 深层配置写回
|
|
3401
|
-
# 全量失败根因)。同 queId 即同字段,草稿优先(与正整数身份同
|
|
3402
|
-
# 语义);同名不同 queId 的真实用户字段仍双候选保留,by_name 歧义
|
|
3403
|
-
# 照常显式拒绝(存量脏形态不消歧,禁止静默取第一个)。
|
|
3404
|
-
if raw_id in seen:
|
|
3405
|
-
continue
|
|
3406
|
-
seen.add(raw_id)
|
|
3407
|
-
merged.append(item)
|
|
3408
|
-
return merged, (draft_form or published_form)
|
|
3409
|
-
|
|
3410
3039
|
|
|
3411
3040
|
class AppFormReconciler:
|
|
3412
3041
|
RUNTIME_VERSION = "v1alpha1"
|
|
@@ -3573,17 +3202,7 @@ class AppFormReconciler:
|
|
|
3573
3202
|
)
|
|
3574
3203
|
publish_needed = not _published_equal(current, published)
|
|
3575
3204
|
except (QingflowApiError, RuntimeError, AppFormFailure) as error:
|
|
3576
|
-
|
|
3577
|
-
# published 侧元数据缺行(段落归属/自定义编号配置):发布动作
|
|
3578
|
-
# 会从 draft 侧复制自愈,推定需要发布而不是失败。
|
|
3579
|
-
publish_needed = True
|
|
3580
|
-
warnings.append({"code": "PUBLISHED_METADATA_GAP", "detail": str(error.message)})
|
|
3581
|
-
elif self._is_form_not_published(error):
|
|
3582
|
-
# 应用从未发布(40026):published 域不存在,对比跳过——
|
|
3583
|
-
# draft 一致即达标,不擅自发布从未发布的应用。
|
|
3584
|
-
warnings.append({"code": "APP_NOT_PUBLISHED", "detail": "published comparison skipped: application was never published"})
|
|
3585
|
-
else:
|
|
3586
|
-
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
|
|
3587
3206
|
diagnostics["diff"] = self._diff(current, target)
|
|
3588
3207
|
|
|
3589
3208
|
if not form_changed and not publish_needed:
|
|
@@ -3684,9 +3303,7 @@ class AppFormReconciler:
|
|
|
3684
3303
|
details={"remainingDiff": deepcopy(diagnostics["remainingDiff"])},
|
|
3685
3304
|
)
|
|
3686
3305
|
|
|
3687
|
-
|
|
3688
|
-
# 发布自愈前任何一次 published 读都会失败,重读不会需得新证据。
|
|
3689
|
-
publish_needed = created or wrote_form or publish_needed
|
|
3306
|
+
publish_needed = created or wrote_form
|
|
3690
3307
|
if not publish_needed:
|
|
3691
3308
|
try:
|
|
3692
3309
|
published = self._published_resource(
|
|
@@ -3698,20 +3315,14 @@ class AppFormReconciler:
|
|
|
3698
3315
|
diagnostics["published"] = _published_equal(draft, published)
|
|
3699
3316
|
publish_needed = not diagnostics["published"]
|
|
3700
3317
|
except (QingflowApiError, RuntimeError, AppFormFailure) as error:
|
|
3701
|
-
|
|
3702
|
-
|
|
3703
|
-
|
|
3704
|
-
|
|
3705
|
-
|
|
3706
|
-
|
|
3707
|
-
|
|
3708
|
-
|
|
3709
|
-
"PUBLISHED_FORM_READ_FAILED",
|
|
3710
|
-
str(error),
|
|
3711
|
-
"AUTHORITATIVE_PUBLISHED_READ",
|
|
3712
|
-
status="partial_success",
|
|
3713
|
-
details=diagnostics,
|
|
3714
|
-
) 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
|
|
3715
3326
|
|
|
3716
3327
|
if publish_needed:
|
|
3717
3328
|
self._publish_and_verify(
|
|
@@ -4093,52 +3704,6 @@ class AppFormReconciler:
|
|
|
4093
3704
|
diagnostics["publishResponseAccepted"] = True
|
|
4094
3705
|
diagnostics["publishCount"] += 1
|
|
4095
3706
|
|
|
4096
|
-
@staticmethod
|
|
4097
|
-
def _is_published_metadata_gap(error: Exception) -> bool:
|
|
4098
|
-
"""Published 读面因 published 侧元数据缺行而无法归一化的已知形态。
|
|
4099
|
-
|
|
4100
|
-
后端发布链路(FormSvcImpl.publish)把 draft 侧元数据复制到 published 侧
|
|
4101
|
-
(section_question 先删后建、form_custom_serial_num_set 按 id 三分离)。
|
|
4102
|
-
published 侧对应表缺行时(dataset 闭包漏导/迁移中间态),published GET 呈现:
|
|
4103
|
-
- 段落(queType=24)与段内字段同行、innerQuestions 为空
|
|
4104
|
-
→ UNSUPPORTED_FIELD_TYPE queType=24(段落归属信息在 published 侧丢失);
|
|
4105
|
-
- serialNumType=2 但 serialNumConfig 无组件
|
|
4106
|
-
→ UNSUPPORTED_FIELD_CONFIG "custom serial number has no components"。
|
|
4107
|
-
属数据缺口而非声明不可表达:推定需要发布,由发布动作自愈元数据
|
|
4108
|
-
(draft 读面不受影响——draft 形态段落恒为独立行)。
|
|
4109
|
-
"""
|
|
4110
|
-
if not isinstance(error, AppFormFailure) or error.phase != "NORMALIZE":
|
|
4111
|
-
return False
|
|
4112
|
-
details = error.details if isinstance(error.details, dict) else {}
|
|
4113
|
-
if error.error_code == "UNSUPPORTED_FIELD_TYPE" and _positive_int(details.get("queType")) == 24:
|
|
4114
|
-
return True
|
|
4115
|
-
return error.error_code == "UNSUPPORTED_FIELD_CONFIG" and "custom serial number has no components" in str(error.message)
|
|
4116
|
-
|
|
4117
|
-
@staticmethod
|
|
4118
|
-
def _is_form_not_published(error: Exception) -> bool:
|
|
4119
|
-
"""published 读面因应用从未发布而 40026。
|
|
4120
|
-
|
|
4121
|
-
后端语义(FormSvcImpl.getFormInfo):appPublishStatus == PUBLISH_STATUS_CREATED
|
|
4122
|
-
时 published GET 恒抛 FORM_NOT_PUBLISHED(40026)——合法存量形态(应用创建后
|
|
4123
|
-
从未发布),draft 域恒可读、published 域不存在。此时 published 对比应跳过:
|
|
4124
|
-
draft 一致即达标,apply 不擅自发布从未发布的应用(发布是独立产品动作,
|
|
4125
|
-
由使用者显式决定)。"""
|
|
4126
|
-
if isinstance(error, QingflowApiError):
|
|
4127
|
-
return str(error.backend_code) == "40026"
|
|
4128
|
-
if isinstance(error, AppFormFailure):
|
|
4129
|
-
details = error.details if isinstance(error.details, dict) else {}
|
|
4130
|
-
transport = details.get("transportError") if isinstance(details.get("transportError"), dict) else details
|
|
4131
|
-
return str(transport.get("backend_code")) == "40026" or str(transport.get("code")) == "40026"
|
|
4132
|
-
if isinstance(error, RuntimeError):
|
|
4133
|
-
# tools 层经 raise_tool_error 把 QingflowApiError 包成 RuntimeError,
|
|
4134
|
-
# message 是 as_json 字符串(含 backend_code);解析识别,不吞其他错误。
|
|
4135
|
-
try:
|
|
4136
|
-
payload = json.loads(str(error))
|
|
4137
|
-
except ValueError:
|
|
4138
|
-
return False
|
|
4139
|
-
return isinstance(payload, dict) and str(payload.get("backend_code")) == "40026"
|
|
4140
|
-
return False
|
|
4141
|
-
|
|
4142
3707
|
def _published_matches(
|
|
4143
3708
|
self,
|
|
4144
3709
|
profile: str,
|
|
@@ -4565,46 +4130,19 @@ def _snake_code_block(value: JSONObject) -> JSONObject:
|
|
|
4565
4130
|
|
|
4566
4131
|
|
|
4567
4132
|
def _backend_question_candidates(form: JSONObject) -> list[JSONObject]:
|
|
4568
|
-
|
|
4569
|
-
|
|
4570
|
-
遍历与后端对齐:FormSvcImpl.fillMatchRulesList 按 supId 全局解析父表标题、
|
|
4571
|
-
不看容器嵌套层级,FormQuestionSvcImpl 的候选集同样把段落内字段展开——因此
|
|
4572
|
-
表格(queType=18)无论位于 formQues 顶层、baseQues 还是段落(queType=24)
|
|
4573
|
-
内部,其 subQuestions 都是合法候选;段落本身不作为候选。
|
|
4574
|
-
"""
|
|
4575
|
-
result: list[JSONObject] = []
|
|
4576
|
-
|
|
4577
|
-
def _visit(item: JSONObject) -> None:
|
|
4578
|
-
que_type = _positive_int(item.get("queType"))
|
|
4579
|
-
if que_type == 24:
|
|
4580
|
-
for inner in item.get("innerQuestions") or []:
|
|
4581
|
-
for nested in inner if isinstance(inner, list) else []:
|
|
4582
|
-
if isinstance(nested, dict):
|
|
4583
|
-
_visit(nested)
|
|
4584
|
-
return
|
|
4585
|
-
result.append(item)
|
|
4586
|
-
# 表格(queType=18)子字段同样是合法的目标字段:后端 data_relation
|
|
4587
|
-
# 运行时按 queId 解析目标字段并把表格子字段纳入 targetQuestionMap
|
|
4588
|
-
# (DataRelationCalService 先按 queId 查 question、再把表格的
|
|
4589
|
-
# subQuestions 合入)。displayedQueId 指向表格子字段是存量合法
|
|
4590
|
-
# 配置,提取能读出、编译必须能写回(两闸门同口径)。
|
|
4591
|
-
# matchRules 的筛选源字段(sourceField)在存量里带父表名前缀
|
|
4592
|
-
# (「父表·子字段」,_data_relation_field_name 按同形态写回)。
|
|
4593
|
-
if que_type == 18:
|
|
4594
|
-
table_title = str(item.get("queTitle") or "")
|
|
4595
|
-
for sub in item.get("subQuestions") or []:
|
|
4596
|
-
if isinstance(sub, dict):
|
|
4597
|
-
prefixed = dict(sub)
|
|
4598
|
-
prefixed["_sup_que_title"] = table_title
|
|
4599
|
-
result.append(prefixed)
|
|
4600
|
-
|
|
4133
|
+
result = []
|
|
4601
4134
|
for item in form.get("baseQues") or []:
|
|
4602
4135
|
if isinstance(item, dict):
|
|
4603
|
-
|
|
4136
|
+
result.append(item)
|
|
4604
4137
|
for row in form.get("formQues") or []:
|
|
4605
4138
|
for item in row if isinstance(row, list) else []:
|
|
4606
|
-
if isinstance(item, dict):
|
|
4607
|
-
|
|
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)
|
|
4608
4146
|
return result
|
|
4609
4147
|
|
|
4610
4148
|
|
|
@@ -4620,72 +4158,30 @@ def _canonical_backend_field_type(field: JSONObject) -> str:
|
|
|
4620
4158
|
return QUE_TYPE_TO_CANONICAL.get(que_type or -1, "")
|
|
4621
4159
|
|
|
4622
4160
|
|
|
4623
|
-
def _backend_selector_titles(item: JSONObject) -> list[str]:
|
|
4624
|
-
"""后端匹配规则里该字段可能出现的 queTitle 形态。
|
|
4625
|
-
|
|
4626
|
-
matchRules 的筛选源字段(表格子字段)在存量里带父表名前缀("父表·子字段",
|
|
4627
|
-
FormSvcImpl 组装 judgeQueDetail/queTitle 时拼接),顶层字段无前缀。
|
|
4628
|
-
"""
|
|
4629
|
-
titles = [str(item.get("queTitle") or "")]
|
|
4630
|
-
sup_title = str(item.get("_sup_que_title") or "")
|
|
4631
|
-
if sup_title:
|
|
4632
|
-
titles.append(f"{sup_title}·{titles[0]}")
|
|
4633
|
-
return titles
|
|
4634
|
-
|
|
4635
|
-
|
|
4636
4161
|
def _resolve_backend_selector(selector: JSONObject, candidates: list[JSONObject], location: str) -> JSONObject:
|
|
4637
|
-
# name 比较两侧 strip:存量字段名带前导/尾随空格(后端无空白校验),声明里的
|
|
4638
|
-
# name 与候选 queTitle 一个空格即失配;queId 已同时给出,空格差异不构成歧义。
|
|
4639
4162
|
que_id = _positive_int(selector.get("queId"))
|
|
4640
|
-
name = str(selector.get("name") or "")
|
|
4163
|
+
name = str(selector.get("name") or "")
|
|
4641
4164
|
matches = [
|
|
4642
4165
|
item
|
|
4643
4166
|
for item in candidates
|
|
4644
4167
|
if (que_id is None or _positive_int(item.get("queId")) == que_id)
|
|
4645
|
-
and (not name or
|
|
4168
|
+
and (not name or str(item.get("queTitle") or "") == name)
|
|
4646
4169
|
]
|
|
4647
4170
|
if len(matches) != 1:
|
|
4648
|
-
# 方案 A:后端允许同父级同名(queId 为身份)。name-only 命中多个时显式报
|
|
4649
|
-
# 歧义并给出候选 queId,禁止静默取第一个。
|
|
4650
|
-
if que_id is None and matches:
|
|
4651
|
-
raise AppFormFailure(
|
|
4652
|
-
"AMBIGUOUS_FIELD_SELECTOR",
|
|
4653
|
-
f"{location} matches multiple fields with the same name; use queId to disambiguate",
|
|
4654
|
-
"CONTRACT_PRECHECK",
|
|
4655
|
-
details={
|
|
4656
|
-
"selector": selector,
|
|
4657
|
-
"candidateQueIds": sorted(
|
|
4658
|
-
{_positive_int(item.get("queId")) or 0 for item in matches}
|
|
4659
|
-
),
|
|
4660
|
-
},
|
|
4661
|
-
)
|
|
4662
4171
|
raise AppFormFailure("INVALID_FIELD_SELECTOR", f"{location} must resolve to exactly one field", "CONTRACT_PRECHECK", details={"selector": selector, "matches": len(matches)})
|
|
4663
4172
|
return matches[0]
|
|
4664
4173
|
|
|
4665
4174
|
|
|
4666
4175
|
def _resolve_canonical_selector(selector: JSONObject, candidates: list[JSONObject], location: str) -> JSONObject:
|
|
4667
|
-
# 同 _resolve_backend_selector:name 两侧 strip(存量字段名带前后空格,queId 兜底唯一性)。
|
|
4668
4176
|
que_id = _positive_int(selector.get("queId"))
|
|
4669
|
-
name = str(selector.get("name") or "")
|
|
4177
|
+
name = str(selector.get("name") or "")
|
|
4670
4178
|
matches = [
|
|
4671
4179
|
item
|
|
4672
4180
|
for item in candidates
|
|
4673
4181
|
if (que_id is None or _positive_int(item.get("queId")) == que_id)
|
|
4674
|
-
and (not name or str(item.get("name") or "")
|
|
4182
|
+
and (not name or str(item.get("name") or "") == name)
|
|
4675
4183
|
]
|
|
4676
4184
|
if len(matches) != 1:
|
|
4677
|
-
if que_id is None and matches:
|
|
4678
|
-
raise AppFormFailure(
|
|
4679
|
-
"AMBIGUOUS_FIELD_SELECTOR",
|
|
4680
|
-
f"{location} matches multiple fields with the same name; use queId to disambiguate",
|
|
4681
|
-
"CONTRACT_PRECHECK",
|
|
4682
|
-
details={
|
|
4683
|
-
"selector": selector,
|
|
4684
|
-
"candidateQueIds": sorted(
|
|
4685
|
-
{_positive_int(item.get("queId")) or 0 for item in matches}
|
|
4686
|
-
),
|
|
4687
|
-
},
|
|
4688
|
-
)
|
|
4689
4185
|
raise AppFormFailure(
|
|
4690
4186
|
"INVALID_FIELD_SELECTOR",
|
|
4691
4187
|
f"{location} must resolve to exactly one top-level field",
|
|
@@ -4696,27 +4192,14 @@ def _resolve_canonical_selector(selector: JSONObject, candidates: list[JSONObjec
|
|
|
4696
4192
|
|
|
4697
4193
|
|
|
4698
4194
|
def _resolve_internal_selector(selector: JSONObject, fields: list[JSONObject], location: str) -> JSONObject:
|
|
4699
|
-
# 同 _resolve_backend_selector:name 两侧 strip(存量字段名带前后空格,queId 兜底唯一性)。
|
|
4700
4195
|
que_id = _positive_int(selector.get("queId") or selector.get("que_id"))
|
|
4701
|
-
name = str(selector.get("name") or "")
|
|
4196
|
+
name = str(selector.get("name") or "")
|
|
4702
4197
|
matches = [
|
|
4703
4198
|
item
|
|
4704
4199
|
for item in fields
|
|
4705
4200
|
if (que_id is None or _positive_int(item.get("que_id")) == que_id)
|
|
4706
|
-
and (not name or str(item.get("name") or "")
|
|
4201
|
+
and (not name or str(item.get("name") or "") == name)
|
|
4707
4202
|
]
|
|
4708
4203
|
if len(matches) != 1:
|
|
4709
|
-
if que_id is None and matches:
|
|
4710
|
-
raise AppFormFailure(
|
|
4711
|
-
"AMBIGUOUS_FIELD_SELECTOR",
|
|
4712
|
-
f"{location} matches multiple fields with the same name; use queId to disambiguate",
|
|
4713
|
-
"COMPILE",
|
|
4714
|
-
details={
|
|
4715
|
-
"selector": selector,
|
|
4716
|
-
"candidateQueIds": sorted(
|
|
4717
|
-
{_positive_int(item.get("que_id")) or 0 for item in matches}
|
|
4718
|
-
),
|
|
4719
|
-
},
|
|
4720
|
-
)
|
|
4721
4204
|
raise AppFormFailure("INVALID_FIELD_SELECTOR", f"{location} must resolve to exactly one field", "COMPILE", details={"selector": selector, "matches": len(matches)})
|
|
4722
4205
|
return matches[0]
|