@qingflow-tech/qingflow-cli 1.1.57 → 1.1.59

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