@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.
Files changed (85) hide show
  1. package/README.md +3 -3
  2. package/package.json +1 -1
  3. package/pyproject.toml +1 -2
  4. package/skills/qingflow-cli/SKILL.md +2 -104
  5. package/skills/qingflow-cli/reference/builder/20-build-complete-system.md +2 -2
  6. package/skills/qingflow-cli/reference/builder/30-schema-fields.md +5 -22
  7. package/skills/qingflow-cli/reference/builder/70-portal.md +16 -37
  8. package/skills/qingflow-cli/reference/builder/reference/app-delivery-sop.md +3 -3
  9. package/skills/qingflow-cli/reference/examples/portal/portal_sections_all_types.example.json +7 -6
  10. package/src/qingflow_mcp/app_form_compat.py +0 -3
  11. package/src/qingflow_mcp/app_form_validator.py +20 -89
  12. package/src/qingflow_mcp/builder_facade/app_form.py +112 -629
  13. package/src/qingflow_mcp/builder_facade/models.py +13 -164
  14. package/src/qingflow_mcp/builder_facade/schemas/app_form/manifest.json +1 -15
  15. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/complete.json +38 -112
  16. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/address.json +8 -31
  17. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/amount.json +10 -6
  18. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/attachment.json +10 -33
  19. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/code_block.json +10 -6
  20. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/data_relation.json +18 -11
  21. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/date.json +8 -4
  22. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/datetime.json +8 -4
  23. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/department.json +10 -6
  24. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/email.json +8 -4
  25. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/link.json +8 -4
  26. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/long_text.json +8 -4
  27. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/member.json +10 -6
  28. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/multi_select.json +8 -4
  29. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/number.json +10 -6
  30. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/phone.json +8 -4
  31. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/q_linker.json +12 -8
  32. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/relation.json +15 -73
  33. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/single_choice.json +8 -4
  34. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/single_select.json +8 -4
  35. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/subtable.json +29 -90
  36. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/text.json +8 -4
  37. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/formula.json +0 -43
  38. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/summary.json +19 -27
  39. package/src/qingflow_mcp/builder_facade/service.py +72 -1143
  40. package/src/qingflow_mcp/cli/commands/builder.py +5 -114
  41. package/src/qingflow_mcp/cli/main.py +1 -34
  42. package/src/qingflow_mcp/cloud_context.py +1 -16
  43. package/src/qingflow_mcp/public_surface.py +8 -69
  44. package/src/qingflow_mcp/response_trim.py +1 -2
  45. package/src/qingflow_mcp/server.py +6 -12
  46. package/src/qingflow_mcp/server_app_builder.py +6 -41
  47. package/src/qingflow_mcp/server_app_user.py +5 -9
  48. package/src/qingflow_mcp/server_cloud.py +7 -43
  49. package/src/qingflow_mcp/solution/compiler/icon_utils.py +0 -73
  50. package/src/qingflow_mcp/solution/executor.py +2 -5
  51. package/src/qingflow_mcp/tools/ai_builder_tools.py +12 -110
  52. package/src/qingflow_mcp/tools/portal_tools.py +54 -4
  53. package/src/qingflow_mcp/tools/solution_tools.py +0 -2
  54. package/skills/qingflow-cli/reference/examples/portal/portal_apply_v1alpha1.example.json +0 -100
  55. package/src/qingflow_mcp/builder_facade/portals_schema.py +0 -397
  56. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/ai_content_classification.json +0 -146
  57. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/ai_content_extraction.json +0 -146
  58. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/ai_content_generation.json +0 -146
  59. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/ai_document_parsing.json +0 -146
  60. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/ai_image_generation.json +0 -146
  61. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/ai_image_recognition.json +0 -146
  62. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/ai_machine_translation.json +0 -146
  63. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/ai_text_summary.json +0 -146
  64. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/date_duration.json +0 -146
  65. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/embed_field.json +0 -146
  66. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/image_select.json +0 -146
  67. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/location.json +0 -146
  68. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/ocr.json +0 -146
  69. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/rich_text.json +0 -146
  70. package/src/qingflow_mcp/builder_facade/schemas/portals/manifest.json +0 -59
  71. package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/complete.json +0 -1397
  72. package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/components/carousel.json +0 -193
  73. package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/components/chart.json +0 -643
  74. package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/components/favourite.json +0 -203
  75. package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/components/filter.json +0 -268
  76. package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/components/grid.json +0 -265
  77. package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/components/link.json +0 -131
  78. package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/components/task.json +0 -199
  79. package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/components/text.json +0 -123
  80. package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/components/view.json +0 -196
  81. package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/summary.json +0 -800
  82. package/src/qingflow_mcp/tool_catalog.py +0 -387
  83. package/src/qingflow_mcp/tool_catalog_i18n.py +0 -185
  84. package/src/qingflow_mcp/tool_policy.py +0 -197
  85. 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
- # 空 section 是合法存量形态:后端 updateFormInfo 对空 innerQuestions 仅产出
740
- # 零行 draft_section_question(段落本身作为 queType=24 question 照常保存),
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 and has_fields else None,
777
- "dataCoverField": _selector(cover_id, field_names.get(cover_id or 0)) if cover_id is not None and has_fields else 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
- "displayFields": [
1021
- {
1022
- "field": _require_selector(
1023
- item.get("queId"),
1024
- item.get("queTitle"),
1025
- message="relation display field cannot be normalized",
1026
- details={"queId": que_id, "path": "/config/displayFields"},
1027
- ),
1028
- "auth": {1: "editable", 2: "hidden", 3: "visible"}.get(
1029
- _positive_int(item.get("queAuth")), "visible"
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 str(field_name).strip() == name and _encrypt_question_id(que_id) == token
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
- # URL 空容忍边界(#208③):后端写链仅 configMode=1(自定义)校验 url/method/
1289
- # resultType 非空(FormQuestionSvcImpl.createQuestionRemoteLookUp:444 抛
1290
- # REMOTE_LOOK_UP_SETTING_WRONG);configMode=2 插件模板 / 3 连接中心 /
1291
- # 4 轻翼智能体的 URL 由模板侧提供,本表 url 空 是合法存量形态——非自定义
1292
- # 模式放行空 URL 透传(写回走模板透传,round-trip 保真;r12 归因 27 app;
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
- # 后端 GET 读面会过滤 displayed 目标缺失的 relation 行(FormSvcImpl#getQuestionRelationsBeans
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": _selector(value.get("queId"), value.get("queTitle")) or None,
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["id"])
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
- # target 层面同样放行 dataTitleField=null:后端写链
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
- # 两侧 strip:模板引用名提取时已 strip(存量字段名带尾随空格,
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
- # 宽度取值:任意 1-100 整数(后端 html_width 无取值校验,存量存在
2120
- # 33/34/20/30 等值,33+33+34=100 的三等分历史布局);仅保留
2121
- # 「全有或全无」约束。行总和不强制 =100:后端写链无总和校验
2122
- # (QuestionConstant.DEFAULT_QUE_WIDTH=100 仅为新建默认值),存量
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
- # 方案 A(duplicate-field-name):同父级允许同名——后端以 queId 为字段身份
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
- # dataTitleField=null(键存在值为空)与省略键同权放行:后端写链
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
- target_data_title_selector = target["spec"]["dataTitleField"]
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
- # 存量读回形态(后端 GET 不回传该字段的 relation):模板里保留着后端
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
- template_origin = _positive_int((template or {}).get("queOriginType"))
2685
- if template_origin is not None:
2686
- question["queOriginType"] = template_origin
2687
- else:
2688
- question.pop("queOriginType", None)
2689
- else:
2690
- question["queOriginType"] = que_origin_type
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
- candidates, _target_form = self._target_form_candidates(profile, target_app_key)
2706
- explicit_display = isinstance(config.get("displayFields"), list)
2707
- deprecated_keys = [
2708
- key
2709
- for key in ("displayField", "display_field", "visibleFields", "visible_fields")
2710
- if key in config and config.get(key) is not None
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": resolved_two_way.get("queId") if resolved_two_way else None,
2545
+ "referQueId": display["queId"],
2763
2546
  "optionalDataNum": 1 if config.get("relationMode", "single") == "single" else 2,
2764
2547
  "referQuestions": [
2765
- {"queId": item["queId"], "queTitle": item["queTitle"], "queType": item["queType"], "queAuth": auth, "ordinal": index}
2766
- for index, (item, auth) in enumerate(resolved_display)
2548
+ {"queId": item["queId"], "queTitle": item["queTitle"], "queType": item["queType"], "queAuth": 3, "ordinal": index}
2549
+ for index, item in enumerate(visible)
2767
2550
  ],
2768
- # 选择引用数据时的列表权限轴(draft_reference_que_auth,含 section/parent 结构)
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
- candidates, target_form = self._target_form_candidates(profile, target_app_key)
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 "").strip()
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 "").strip() == name)
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
- source_fields, _target_form = self._target_form_candidates(profile, str(source.get("appKey") or ""))
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
- # 展示字段同样按后端语义透传:写链对 displayedQueId 无存在性校验
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
- source_selector = condition.get("sourceField") or {}
3017
- if source_selector.get("queId") or str(source_selector.get("name") or "").strip():
3018
- source = cls._resolve_data_relation_source_field(
3019
- source_selector,
3020
- source_fields=source_fields,
3021
- source_fields_are_backend=source_fields_are_backend,
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) or 0,
3033
- "queTitle": cls._data_relation_field_name(source) or None,
3034
- "queType": cls._data_relation_que_type(source) if source else None,
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], *, operator: str = "") -> tuple[list[str], list[JSONObject]]:
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
- try:
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
- # matchRules 筛选源字段的 queTitle 在存量里对表格子字段带父表名前缀
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 | None:
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 source.get("_app_form_unresolved"):
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 and operator not in {"subordinate_to", "not_subordinate_to"}:
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
- if self._is_published_metadata_gap(error):
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
- # precheck 阶段推定的 publish_needed(published 元数据缺口)必须保留:
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
- if self._is_published_metadata_gap(error):
3702
- publish_needed = True
3703
- warnings.append({"code": "PUBLISHED_METADATA_GAP", "detail": str(error.message)})
3704
- elif self._is_form_not_published(error):
3705
- warnings.append({"code": "APP_NOT_PUBLISHED", "detail": "published comparison skipped: application was never published"})
3706
- else:
3707
- self._finish_edit(profile, app_key, edit_version_no, diagnostics)
3708
- raise AppFormFailure(
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
- _visit(item)
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
- _visit(item)
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 "").strip()
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 name in {title.strip() for title in _backend_selector_titles(item)})
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 "").strip()
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 "").strip() == name)
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 "").strip()
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 "").strip() == name)
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]