@qingflow-tech/qingflow-cli 1.1.60 → 1.1.61-beta.2

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