@qingflow-tech/qingflow-cli 1.1.51 → 1.1.54

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 (106) hide show
  1. package/README.md +4 -4
  2. package/npm/lib/runtime.mjs +29 -13
  3. package/package.json +1 -1
  4. package/pyproject.toml +8 -2
  5. package/skills/qingflow-cli/SKILL.md +93 -322
  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_BUILDER_VIEWS_WORKFLOW.md +3 -9
  10. package/skills/qingflow-cli/reference/QINGFLOW_CLI_DATA_RETRIEVAL_WORKFLOW.md +3 -137
  11. package/skills/qingflow-cli/reference/QINGFLOW_CLI_EXPLORATION_REPORT.md +6 -4
  12. package/skills/qingflow-cli/reference/QINGFLOW_CLI_FIELD_DATA_TYPES.md +14 -14
  13. package/skills/qingflow-cli/reference/QINGFLOW_CLI_MEMBER_CHEATSHEET.md +20 -19
  14. package/skills/qingflow-cli/reference/QINGFLOW_CLI_ONE_SHOT_CHEATSHEET.md +18 -14
  15. package/skills/qingflow-cli/reference/QINGFLOW_CLI_RECORD_CREATE_WORKFLOW.md +7 -12
  16. package/skills/qingflow-cli/reference/QINGFLOW_CLI_RECORD_UPDATE_WORKFLOW.md +2 -1
  17. package/skills/qingflow-cli/reference/builder/20-build-complete-system.md +3 -2
  18. package/skills/qingflow-cli/reference/builder/30-schema-fields.md +9 -5
  19. package/skills/qingflow-cli/reference/builder/50-views.md +192 -191
  20. package/skills/qingflow-cli/reference/builder/80-buttons-associated-resources.md +2 -0
  21. package/skills/qingflow-cli/reference/builder/README.md +1 -1
  22. package/skills/qingflow-cli/reference/builder/reference/app-delivery-sop.md +6 -6
  23. package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_ADMIN_CHEATSHEET.md +1 -1
  24. package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_DATA_RETRIEVAL_WORKFLOW.md +76 -95
  25. package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_EXPLORATION_REPORT.md +6 -4
  26. package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_FIELD_DATA_TYPES.md +13 -13
  27. package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_MEMBER_CHEATSHEET.md +20 -19
  28. package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_ONE_SHOT_CHEATSHEET.md +18 -14
  29. package/skills/qingflow-cli/reference/data/README.md +32 -0
  30. package/skills/qingflow-cli/reference/examples/README.md +1 -1
  31. package/skills/qingflow-cli/reference/examples/views/views_apply_v1alpha1.example.json +14 -0
  32. package/skills/qingflow-cli/reference/examples/views/views_upsert_table_minimal.example.json +30 -36
  33. package/skills/qingflow-cli/reference/record/QINGFLOW_CLI_RECORD_SPECIALIZED_WORKFLOW.md +50 -0
  34. package/skills/qingflow-cli/reference/record/QINGFLOW_CLI_RECORD_UPDATE_WORKFLOW.md +2 -1
  35. package/skills/qingflow-cli/reference/record/analysis/README.md +21 -113
  36. package/skills/qingflow-cli/reference/record/analysis/analysis-gotchas.md +10 -10
  37. package/skills/qingflow-cli/reference/record/analysis/analysis-patterns.md +9 -9
  38. package/skills/qingflow-cli/reference/record/analysis/business-context.md +1 -1
  39. package/skills/qingflow-cli/reference/record/analysis/confidence-reporting.md +10 -10
  40. package/skills/qingflow-cli/reference/record/analysis/data-access-playbook.md +52 -80
  41. package/skills/qingflow-cli/reference/record/analysis/pandas-recipes.md +2 -2
  42. package/skills/qingflow-common/SKILL.md +7 -4
  43. package/src/qingflow_mcp/app_form_compat.py +3 -0
  44. package/src/qingflow_mcp/app_form_validator.py +69 -17
  45. package/src/qingflow_mcp/builder_facade/app_form.py +497 -100
  46. package/src/qingflow_mcp/builder_facade/models.py +227 -17
  47. package/src/qingflow_mcp/builder_facade/schemas/app_form/manifest.json +1 -0
  48. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/complete.json +64 -30
  49. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/address.json +4 -8
  50. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/amount.json +4 -8
  51. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/attachment.json +6 -10
  52. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/code_block.json +6 -10
  53. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/data_relation.json +11 -18
  54. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/date.json +4 -8
  55. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/datetime.json +4 -8
  56. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/department.json +6 -10
  57. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/email.json +4 -8
  58. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/link.json +4 -8
  59. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/long_text.json +4 -8
  60. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/member.json +6 -10
  61. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/multi_select.json +4 -8
  62. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/number.json +4 -8
  63. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/phone.json +4 -8
  64. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/q_linker.json +6 -10
  65. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/relation.json +46 -15
  66. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/rich_text.json +146 -0
  67. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/single_choice.json +4 -8
  68. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/single_select.json +4 -8
  69. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/subtable.json +61 -27
  70. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/text.json +4 -8
  71. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/formula.json +43 -0
  72. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/summary.json +7 -12
  73. package/src/qingflow_mcp/builder_facade/schemas/views/manifest.json +42 -0
  74. package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/complete.json +1245 -0
  75. package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/details/button-config.json +53 -0
  76. package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/details/data-permission.json +60 -0
  77. package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/details/display.json +73 -0
  78. package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/details/operation-permission.json +48 -0
  79. package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/details/query-condition.json +47 -0
  80. package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/summary.json +319 -0
  81. package/src/qingflow_mcp/builder_facade/service.py +1709 -518
  82. package/src/qingflow_mcp/builder_facade/views_extract.py +595 -0
  83. package/src/qingflow_mcp/builder_facade/views_schema.py +400 -0
  84. package/src/qingflow_mcp/cli/commands/__init__.py +2 -1
  85. package/src/qingflow_mcp/cli/commands/builder.py +76 -55
  86. package/src/qingflow_mcp/cli/commands/data.py +134 -0
  87. package/src/qingflow_mcp/cli/commands/record.py +1 -1
  88. package/src/qingflow_mcp/cli/context.py +3 -0
  89. package/src/qingflow_mcp/cli/formatters.py +97 -1
  90. package/src/qingflow_mcp/public_surface.py +8 -0
  91. package/src/qingflow_mcp/response_trim.py +11 -2
  92. package/src/qingflow_mcp/server.py +2 -0
  93. package/src/qingflow_mcp/server_app_builder.py +34 -10
  94. package/src/qingflow_mcp/server_app_user.py +4 -0
  95. package/src/qingflow_mcp/solution/compiler/view_compiler.py +2 -1
  96. package/src/qingflow_mcp/solution/executor.py +96 -8
  97. package/src/qingflow_mcp/solution/spec_models.py +1 -0
  98. package/src/qingflow_mcp/tools/ai_builder_tools.py +218 -398
  99. package/src/qingflow_mcp/tools/csv_export.py +168 -0
  100. package/src/qingflow_mcp/tools/data_tools.py +284 -0
  101. package/src/qingflow_mcp/tools/record_tools.py +11 -46
  102. package/src/qingflow_mcp/tools/solution_tools.py +2 -0
  103. package/src/qingflow_mcp/tools/view_tools.py +0 -6
  104. package/src/qingflow_mcp/tools/view_writer.py +44 -0
  105. package/skills/qingflow-cli/reference/examples/views/views_batch_full.example.json +0 -159
  106. package/skills/qingflow-cli/reference/views_upsert_table_minimal.example.json +0 -42
@@ -15,6 +15,8 @@ from typing import Any, NoReturn, cast
15
15
  from urllib.parse import quote_plus, unquote_plus
16
16
  from uuid import uuid4
17
17
 
18
+ from pydantic import ValidationError
19
+
18
20
  from ..backend_client import BackendRequestContext
19
21
  from ..config import get_flow_edit_maintenance_threshold
20
22
  from ..errors import QingflowApiError, backend_code_int, backend_code_value_int, is_auth_like_error
@@ -26,7 +28,7 @@ from ..solution.compiler.chart_compiler import qingbi_workspace_visible_auth
26
28
  from ..solution.compiler.form_compiler import build_question, default_form_payload, default_member_auth
27
29
  from ..solution.compiler.icon_utils import encode_workspace_icon_with_defaults, workspace_icon_config
28
30
  from ..solution.compiler.view_compiler import VIEW_TYPE_MAP
29
- from ..solution.executor import _build_viewgraph_questions, _compact_dict, extract_field_map
31
+ from ..solution.executor import _build_viewgraph_questions, _compact_dict, _flatten_viewgraph_questions, extract_field_map
30
32
  from ..solution.spec_models import FieldType, FormLayoutRowSpec, FormLayoutSectionSpec, ViewSpec
31
33
  from ..tools.app_tools import AppTools
32
34
  from ..tools.custom_button_tools import CustomButtonTools
@@ -41,6 +43,8 @@ from ..tools.workflow_tools import WorkflowTools
41
43
  from . import flow_validation
42
44
  from . import workflow_spec as workflow_spec_api
43
45
  from .button_style_catalog import button_style_catalog_payload
46
+ from ..tools.view_writer import ViewWriter
47
+ from .views_schema import ViewsContractError, compact_none_values, views_schema_registry
44
48
  from .models import (
45
49
  AppChartsReadResponse,
46
50
  AppFieldsReadResponse,
@@ -159,9 +163,14 @@ FIELD_TYPE_TO_QUESTION_TYPE: dict[str, int] = {
159
163
  FieldType.relation.value: 25,
160
164
  }
161
165
 
166
+ # code_block / q_linker 输出目标字段类型。后端写链无类型白名单(FormSvcImpl 原样
167
+ # 落库),运行时 DataSvcImpl.getAnswerValueByString:attachment 按拆分 URL 写入、
168
+ # subtable 整表组装(均有专门分支),rich_text/data_relation 走 Question.canImport
169
+ # 兜底按字符串写入(不报错)——四者为存量合法配置,与 validator 侧白名单同口径。
162
170
  INTEGRATION_OUTPUT_TARGET_FIELD_TYPES: tuple[str, ...] = (
163
171
  FieldType.text.value,
164
172
  FieldType.long_text.value,
173
+ "rich_text",
165
174
  FieldType.number.value,
166
175
  FieldType.amount.value,
167
176
  FieldType.date.value,
@@ -169,6 +178,9 @@ INTEGRATION_OUTPUT_TARGET_FIELD_TYPES: tuple[str, ...] = (
169
178
  FieldType.single_select.value,
170
179
  FieldType.multi_select.value,
171
180
  FieldType.boolean.value,
181
+ FieldType.attachment.value,
182
+ "data_relation",
183
+ FieldType.subtable.value,
172
184
  )
173
185
 
174
186
  MATCH_TYPE_ACCURACY = 1
@@ -290,6 +302,7 @@ class AiBuilderFacade:
290
302
  self.buttons = buttons
291
303
  self.packages = packages
292
304
  self.views = views
305
+ self._view_writer = ViewWriter(views.sessions, views.backend)
293
306
  self.workflows = workflows
294
307
  self.portals = portals
295
308
  self.charts = charts
@@ -3072,14 +3085,27 @@ class AiBuilderFacade:
3072
3085
  member_ids=visibility.external_selectors.member_ids,
3073
3086
  member_emails=visibility.external_selectors.member_emails,
3074
3087
  )
3075
- self._raise_visibility_resolution_issues(
3076
- [
3088
+ # 冗余引用降级:提取器对同一成员同时产出 uid/email/name 三通道(uid 必有且直通解析,
3089
+ # 保证成员不丢);email/name 走后端通讯录搜索,受数据/权限边界影响解析可能失败。
3090
+ # 这类失败降级为非阻断——只有 uid/dept_id 直通也覆盖不了的引用才真正阻断。
3091
+ _SOFT_RESOLUTION_KINDS = {"member_email", "member_name", "department_name", "role", "external_member_email"}
3092
+ _NOT_FOUND_RESOLUTION_CODES = {"MEMBER_NOT_FOUND", "AMBIGUOUS_MEMBER", "ROLE_NOT_FOUND", "AMBIGUOUS_ROLE", "DEPARTMENT_NOT_FOUND", "AMBIGUOUS_DEPARTMENT"}
3093
+ blocking_issues = [
3094
+ issue
3095
+ for issue in [
3077
3096
  *member_resolution["issues"],
3078
3097
  *role_resolution["issues"],
3079
3098
  *department_resolution["issues"],
3080
3099
  *external_member_resolution["issues"],
3081
3100
  ]
3082
- )
3101
+ # 冗余 email/name 引用「找不到」降级为非阻断(uid/id 直通已保主体不丢);
3102
+ # 权限拒绝/查询失败类(LOOKUP_FAILED/PERMISSION_DENIED)仍阻断
3103
+ if not (
3104
+ str(issue.get("kind") or "") in _SOFT_RESOLUTION_KINDS
3105
+ and str(issue.get("error_code") or "") in _NOT_FOUND_RESOLUTION_CODES
3106
+ )
3107
+ ]
3108
+ self._raise_visibility_resolution_issues(blocking_issues)
3083
3109
 
3084
3110
  external_depart_entries: list[dict[str, Any]] = []
3085
3111
  for dept_id in visibility.external_selectors.dept_ids:
@@ -3220,7 +3246,7 @@ class AiBuilderFacade:
3220
3246
  }
3221
3247
  )
3222
3248
  condition_matrix, condition_issues = _build_flow_condition_matrix(
3223
- current_fields_by_name=field_name_to_field,
3249
+ fields_by_que_id=field_name_to_field,
3224
3250
  node=normalized_node,
3225
3251
  )
3226
3252
  issues.extend({**issue, "node_id": node.get("id")} for issue in condition_issues)
@@ -3907,7 +3933,7 @@ class AiBuilderFacade:
3907
3933
  "reason_path": f"patch_buttons[{index}].button_id",
3908
3934
  "button_id": selector_id,
3909
3935
  "message": "button_id does not exist in the current app draft",
3910
- "next_action": "call app_get and use custom_buttons[].button_id",
3936
+ "next_action": "call app_get_buttons with button_text to reuse an existing custom button; create only if absent via app_custom_buttons_apply",
3911
3937
  }
3912
3938
  issues.append(issue)
3913
3939
  results.append({"index": index, "status": "failed", **issue})
@@ -4211,7 +4237,7 @@ class AiBuilderFacade:
4211
4237
  "reason_path": f"upsert_buttons[{index}].button_id",
4212
4238
  "button_id": button_id,
4213
4239
  "message": "button_id does not exist in the current app draft",
4214
- "next_action": "call app_get and use custom_buttons[].button_id",
4240
+ "next_action": "call app_get_buttons with button_text to reuse an existing custom button; create only if absent via app_custom_buttons_apply",
4215
4241
  }
4216
4242
  )
4217
4243
  continue
@@ -4282,7 +4308,7 @@ class AiBuilderFacade:
4282
4308
  "reason_path": f"remove_buttons[{index}].button_id",
4283
4309
  "button_id": selector_id,
4284
4310
  "message": "button_id does not exist in the current app draft",
4285
- "next_action": "call app_get and use custom_buttons[].button_id",
4311
+ "next_action": "call app_get_buttons with button_text to reuse an existing custom button; create only if absent via app_custom_buttons_apply",
4286
4312
  }
4287
4313
  )
4288
4314
  continue
@@ -7155,10 +7181,14 @@ class AiBuilderFacade:
7155
7181
  fields = cast(list[dict[str, Any]], state["parsed"]["fields"])
7156
7182
  return _chart_field_names_by_id_from_public_fields(app_key=app_key, fields=fields), None
7157
7183
 
7158
- def app_get_buttons(self, *, profile: str, app_key: str) -> JSONObject:
7184
+ def app_get_buttons(self, *, profile: str, app_key: str, button_text: str | None = None) -> JSONObject:
7159
7185
  self.apps._require_app_key(app_key)
7160
7186
  warnings: list[dict[str, Any]] = []
7161
7187
  verification: dict[str, Any] = {"buttons_loaded": True, "view_configs_loaded": True}
7188
+ normalized_text = str(button_text or "").strip() or None
7189
+ normalized_args: dict[str, Any] = {"app_key": app_key}
7190
+ if normalized_text is not None:
7191
+ normalized_args["button_text"] = normalized_text
7162
7192
  try:
7163
7193
  raw = self.buttons.custom_button_list(profile=profile, app_key=app_key, being_draft=True)
7164
7194
  except (QingflowApiError, RuntimeError) as error:
@@ -7166,11 +7196,18 @@ class AiBuilderFacade:
7166
7196
  return _failed_from_api_error(
7167
7197
  "BUTTONS_READ_FAILED",
7168
7198
  api_error,
7169
- normalized_args={"app_key": app_key},
7199
+ normalized_args=normalized_args,
7170
7200
  details={"app_key": app_key},
7171
7201
  suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
7172
7202
  )
7173
7203
  buttons = raw.get("items") if isinstance(raw.get("items"), list) else []
7204
+ total_count = len(buttons)
7205
+ if normalized_text is not None:
7206
+ buttons = [
7207
+ item
7208
+ for item in buttons
7209
+ if isinstance(item, dict) and str(item.get("buttonText") or item.get("button_text") or "").strip() == normalized_text
7210
+ ]
7174
7211
  view_configs: list[dict[str, Any]] = []
7175
7212
  view_config_read_errors: list[dict[str, Any]] = []
7176
7213
  try:
@@ -7190,7 +7227,7 @@ class AiBuilderFacade:
7190
7227
  if view_config_read_errors:
7191
7228
  verification["view_configs_loaded"] = False
7192
7229
  warnings.append(_warning("BUTTON_VIEW_CONFIGS_PARTIAL", "some view button binding configs could not be read back"))
7193
- return {
7230
+ response: JSONObject = {
7194
7231
  "status": "success",
7195
7232
  "error_code": None,
7196
7233
  "recoverable": False,
@@ -7205,6 +7242,11 @@ class AiBuilderFacade:
7205
7242
  "verification": verification,
7206
7243
  "details": {"view_config_read_errors": view_config_read_errors} if view_config_read_errors else {},
7207
7244
  }
7245
+ if normalized_text is not None:
7246
+ response["button_text_filter"] = normalized_text
7247
+ response["total_count"] = total_count
7248
+ verification["button_text_exact_match"] = True
7249
+ return response
7208
7250
 
7209
7251
  def app_get_associated_resources(self, *, profile: str, app_key: str) -> JSONObject:
7210
7252
  self.apps._require_app_key(app_key)
@@ -8430,11 +8472,7 @@ class AiBuilderFacade:
8430
8472
  try:
8431
8473
  schema, _schema_source = self._read_schema_with_fallback(profile=profile, app_key=app_key)
8432
8474
  parsed_schema = _parse_schema(schema)
8433
- current_fields_by_name = {
8434
- str(field.get("name") or ""): field
8435
- for field in parsed_schema.get("fields") or []
8436
- if isinstance(field, dict) and str(field.get("name") or "")
8437
- }
8475
+ fields_by_que_id = _collect_fields_by_que_id([], schema=schema)
8438
8476
  existing_views, _views_unavailable = self._load_views_result(profile=profile, app_key=app_key, tolerate_404=False)
8439
8477
  except (QingflowApiError, RuntimeError) as error:
8440
8478
  api_error = _coerce_api_error(error)
@@ -8525,7 +8563,7 @@ class AiBuilderFacade:
8525
8563
  view_binding = _custom_button_view_binding_to_view_button_patch(binding=binding, button_id=button_id)
8526
8564
  dto, binding_issues = _serialize_view_button_binding(
8527
8565
  binding=view_binding,
8528
- current_fields_by_name=current_fields_by_name,
8566
+ fields_by_que_id=fields_by_que_id,
8529
8567
  valid_custom_button_ids=valid_custom_button_ids,
8530
8568
  allow_unverified_custom_button_id=allow_unverified_numeric_button_ids,
8531
8569
  )
@@ -9455,49 +9493,37 @@ class AiBuilderFacade:
9455
9493
  return fields_result
9456
9494
  current_fields = fields_result.get("fields", [])
9457
9495
  field_names = {str(field.get("name") or "") for field in current_fields}
9458
- current_fields_by_name = {
9459
- str(field.get("name") or ""): field
9460
- for field in current_fields
9461
- if isinstance(field, dict) and str(field.get("name") or "")
9462
- }
9496
+ fields_by_que_id = _collect_fields_by_que_id(current_fields)
9463
9497
  upsert_views = [public_view_upsert_payload(view) for view in request.upsert_views]
9464
9498
  patch_views = [public_view_partial_payload(patch) for patch in request.patch_views]
9465
9499
  if request.preset is not None:
9466
9500
  upsert_views = _build_views_preset(request.preset, list(field_names))
9467
9501
  blocking_issues: list[dict[str, Any]] = []
9468
9502
  for patch in upsert_views:
9469
- raw_columns = [str(name or "").strip() for name in (patch.get("columns") or []) if str(name or "").strip()]
9470
- columns = _filter_known_system_view_columns(raw_columns)
9471
- if patch.get("type") in {"table", "card"} and raw_columns and not columns:
9472
- blocking_issues.append(
9473
- {
9474
- "error_code": "VALIDATION_ERROR",
9475
- "view_name": patch.get("name"),
9476
- "message": "view columns must include at least one real app field; system columns cannot be applied directly",
9477
- "ignored_system_columns": [name for name in raw_columns if name in _KNOWN_SYSTEM_VIEW_COLUMNS],
9478
- }
9479
- )
9480
- missing_columns = [name for name in columns if name not in field_names]
9503
+ # queId 形态:columns 是 int 数组,系统列 queId<=0 天然排除
9504
+ raw_columns = [que for que in (patch.get("columns") or []) if isinstance(que, int) and que > 0]
9505
+ columns = raw_columns
9506
+ missing_columns = [que for que in columns if que not in fields_by_que_id]
9481
9507
  if missing_columns:
9482
9508
  blocking_issues.append({"error_code": "UNKNOWN_VIEW_FIELD", "view_name": patch.get("name"), "missing_fields": missing_columns})
9483
- group_by = patch.get("group_by")
9484
- if group_by and group_by not in field_names:
9485
- blocking_issues.append({"error_code": "UNKNOWN_VIEW_FIELD", "view_name": patch.get("name"), "missing_fields": [group_by]})
9486
- start_field = str(patch.get("start_field") or "").strip()
9487
- end_field = str(patch.get("end_field") or "").strip()
9488
- title_field = str(patch.get("title_field") or "").strip()
9509
+ group_que_id = patch.get("group_que_id")
9510
+ if group_que_id and group_que_id not in fields_by_que_id:
9511
+ blocking_issues.append({"error_code": "UNKNOWN_VIEW_FIELD", "view_name": patch.get("name"), "missing_fields": [group_que_id]})
9512
+ start_que_id = patch.get("start_que_id")
9513
+ end_que_id = patch.get("end_que_id")
9514
+ title_que_id = patch.get("title_que_id")
9489
9515
  if patch.get("type") == "gantt":
9490
9516
  missing_required = []
9491
- if not start_field:
9492
- missing_required.append("start_field")
9493
- if not end_field:
9494
- missing_required.append("end_field")
9517
+ if not start_que_id:
9518
+ missing_required.append("start_que_id")
9519
+ if not end_que_id:
9520
+ missing_required.append("end_que_id")
9495
9521
  if missing_required:
9496
9522
  blocking_issues.append({"error_code": "INVALID_GANTT_CONFIG", "view_name": patch.get("name"), "missing_fields": missing_required})
9497
- missing_gantt_fields = [name for name in (start_field, end_field, title_field) if name and name not in field_names]
9523
+ missing_gantt_fields = [que for que in (start_que_id, end_que_id, title_que_id) if que and que not in fields_by_que_id]
9498
9524
  if missing_gantt_fields:
9499
9525
  blocking_issues.append({"error_code": "UNKNOWN_VIEW_FIELD", "view_name": patch.get("name"), "missing_fields": missing_gantt_fields})
9500
- translated_filters, filter_issues = _build_view_filter_groups(current_fields_by_name=current_fields_by_name, filters=patch.get("filters") or [])
9526
+ translated_filters, filter_issues = _build_view_filter_groups(fields_by_que_id=fields_by_que_id, filters=patch.get("filters") or [])
9501
9527
  if filter_issues:
9502
9528
  blocking_issues.extend(
9503
9529
  {
@@ -9505,11 +9531,12 @@ class AiBuilderFacade:
9505
9531
  "view_name": patch.get("name"),
9506
9532
  }
9507
9533
  for issue in filter_issues
9534
+ if issue.get("error_code")
9508
9535
  )
9509
9536
  if translated_filters:
9510
9537
  patch["filters"] = [dict(rule) for rule in (patch.get("filters") or [])]
9511
9538
  _, _, query_condition_issues = _build_view_query_conditions_payload(
9512
- current_fields_by_name=current_fields_by_name,
9539
+ fields_by_que_id=fields_by_que_id,
9513
9540
  query_conditions=patch.get("query_conditions"),
9514
9541
  )
9515
9542
  if query_condition_issues:
@@ -11796,6 +11823,26 @@ class AiBuilderFacade:
11796
11823
  result["retry_payload"] = self._view_action_buttons_retry_payload(profile=profile, app_key=app_key, intents=retry_intents) if retry_intents else None
11797
11824
  return result
11798
11825
 
11826
+ def _apply_view_create(self, *, profile: str, payload: JSONObject) -> str | None:
11827
+ """创建一个视图并从后端响应中提取 view key。"""
11828
+ create_result = self._view_writer.create_view(profile=profile, payload=payload)
11829
+ raw_created = create_result.get("result")
11830
+ if isinstance(raw_created, dict):
11831
+ raw_view_key = raw_created.get("viewgraphKey") or raw_created.get("viewKey")
11832
+ return str(raw_view_key).strip() if raw_view_key is not None and str(raw_view_key).strip() else None
11833
+ if isinstance(raw_created, str):
11834
+ return raw_created.strip() or None
11835
+ return None
11836
+
11837
+ def _apply_view_update(self, *, profile: str, view_key: str, payload: JSONObject) -> None:
11838
+ """更新一个已有视图。"""
11839
+ self.views.view_update(profile=profile, viewgraph_key=view_key, payload=payload)
11840
+
11841
+ def _apply_view_remove(self, *, profile: str, view_key: str) -> JSONObject:
11842
+ """删除一个视图并执行单 view key 读回验证。"""
11843
+ self.views.view_delete(profile=profile, viewgraph_key=view_key)
11844
+ return self._verify_view_deleted_by_key(profile=profile, view_key=view_key)
11845
+
11799
11846
  def app_views_apply(
11800
11847
  self,
11801
11848
  *,
@@ -11805,15 +11852,91 @@ class AiBuilderFacade:
11805
11852
  patch_views: list[ViewPartialPatch] | None = None,
11806
11853
  remove_views: list[str],
11807
11854
  publish: bool = True,
11855
+ schema_version: str | None = None,
11808
11856
  ) -> JSONObject:
11809
11857
  patch_views = patch_views or []
11858
+ operation_values = (
11859
+ ("upsert", "upsert_views", upsert_views, "VIEW_BATCH_CREATE_UNSUPPORTED"),
11860
+ ("patch", "patch_views", patch_views, "VIEW_BATCH_OPERATION_UNSUPPORTED"),
11861
+ ("remove", "remove_views", remove_views, "VIEW_BATCH_OPERATION_UNSUPPORTED"),
11862
+ )
11863
+ selected_operation_types = [operation for operation, _field_name, values, _error_code in operation_values if values]
11864
+ selected_operations = [field_name for _operation, field_name, values, _error_code in operation_values if values]
11810
11865
  normalized_args = {
11811
11866
  "app_key": app_key,
11812
- "upsert_views": [public_view_upsert_payload(patch) for patch in upsert_views],
11867
+ "upsert_view": public_view_upsert_payload(upsert_views[0]) if upsert_views else None,
11813
11868
  "patch_views": [public_view_partial_payload(patch) for patch in patch_views],
11814
11869
  "remove_views": list(remove_views),
11815
11870
  "publish": publish,
11871
+ "schema_version": schema_version,
11816
11872
  }
11873
+ if len(selected_operations) > 1:
11874
+ return _failed(
11875
+ "VIEW_SINGLE_OPERATION_UNSUPPORTED",
11876
+ "app_views_apply supports one view operation per call; choose upsert_view, patch_views, or remove_views",
11877
+ normalized_args=normalized_args,
11878
+ details={"app_key": app_key, "operations": selected_operations},
11879
+ suggested_next_call=None,
11880
+ )
11881
+ operation = selected_operation_types[0] if selected_operation_types else None
11882
+ for operation_name, _field_name, values, error_code in operation_values:
11883
+ if len(values) <= 1:
11884
+ continue
11885
+ first_value = values[0]
11886
+ if operation_name == "upsert":
11887
+ suggested_value: Any = public_view_upsert_payload(first_value)
11888
+ elif operation_name == "patch":
11889
+ suggested_value = [public_view_partial_payload(first_value)]
11890
+ else:
11891
+ suggested_value = [str(first_value)]
11892
+ argument_name = "upsert_view" if operation_name == "upsert" else f"{operation_name}_views"
11893
+ suggested_arguments: JSONObject = {
11894
+ "profile": profile,
11895
+ "app_key": app_key,
11896
+ argument_name: suggested_value,
11897
+ "publish": publish,
11898
+ }
11899
+ if schema_version is not None:
11900
+ suggested_arguments["schema_version"] = schema_version
11901
+ return _failed(
11902
+ error_code,
11903
+ (
11904
+ "app_views_apply supports creating or fully updating one view per call"
11905
+ if operation_name == "upsert"
11906
+ else f"app_views_apply supports at most one {operation_name} per call"
11907
+ ),
11908
+ normalized_args=normalized_args,
11909
+ details={
11910
+ "app_key": app_key,
11911
+ f"max_{operation_name}_views": 1,
11912
+ f"received_{operation_name}_views": len(values),
11913
+ },
11914
+ suggested_next_call={
11915
+ "tool_name": "app_views_apply",
11916
+ "arguments": suggested_arguments,
11917
+ },
11918
+ )
11919
+ schema_context = None
11920
+ if schema_version is not None:
11921
+ try:
11922
+ schema_context = views_schema_registry().resolve_schema_version(schema_version)
11923
+ for upsert in upsert_views:
11924
+ schema_context.validate_upsert(
11925
+ compact_none_values(public_view_upsert_payload(upsert))
11926
+ )
11927
+ except ViewsContractError as error:
11928
+ return _failed(
11929
+ error.error_code,
11930
+ str(error.message),
11931
+ normalized_args=normalized_args,
11932
+ details={
11933
+ "app_key": app_key,
11934
+ "schema_version": schema_version,
11935
+ "reason_path": error.path or "",
11936
+ **(error.details or {}),
11937
+ },
11938
+ suggested_next_call={"tool_name": "app_views_schema", "arguments": {"schema_version": schema_version, "schema_kind": "complete"}},
11939
+ )
11817
11940
  apply_started_at = time.perf_counter()
11818
11941
  permission_ms = 0
11819
11942
  view_state_read_ms = 0
@@ -11828,6 +11951,8 @@ class AiBuilderFacade:
11828
11951
  permission_outcomes: list[PermissionCheckOutcome] = []
11829
11952
 
11830
11953
  def finalize(response: JSONObject) -> JSONObject:
11954
+ if schema_context is not None:
11955
+ response["schemaVersion"] = schema_context.schema_version
11831
11956
  return _apply_permission_outcomes(response, *permission_outcomes)
11832
11957
 
11833
11958
  def attach_duration(response: JSONObject) -> JSONObject:
@@ -12044,7 +12169,25 @@ class AiBuilderFacade:
12044
12169
  return finish(create_permission_outcome.block)
12045
12170
  permission_outcomes.append(create_permission_outcome)
12046
12171
  parsed_schema = _parse_schema(schema)
12047
- field_names = {field["name"] for field in parsed_schema["fields"]}
12172
+
12173
+ def _register_subfield_names(field: dict[str, Any], index: dict[str, dict[str, Any]]) -> None:
12174
+ # 表格字段(queType 18)的子字段是视图列/筛选/排序的合法引用面(viewgraph_question 有独立行);
12175
+ # _parse_schema 只把顶层字段入列,这里递归并入引用索引(不改 fields 布局面)
12176
+ for sub in field.get("subfields") or []:
12177
+ if isinstance(sub, dict) and str(sub.get("name") or "").strip():
12178
+ index.setdefault(str(sub["name"]), sub)
12179
+ _register_subfield_names(sub, index)
12180
+
12181
+ field_name_to_field: dict[str, dict[str, Any]] = {}
12182
+ for field in parsed_schema["fields"]:
12183
+ if isinstance(field, dict) and str(field.get("name") or "").strip():
12184
+ field_name_to_field[str(field["name"])] = field
12185
+ _register_subfield_names(field, field_name_to_field)
12186
+ field_names = set(field_name_to_field)
12187
+ # views 契约全 queId 化后,列/筛选/排序/配置引用的是 queId(int);
12188
+ # fields_by_que_id 必须以 que_id 为键,与 _build_view_filter_groups 等消费方对齐。
12189
+ # 递归收集(顶层+表格子字段;段落内字段经 _parse_schema 已展平到 fields)。
12190
+ fields_by_que_id = _collect_fields_by_que_id([], schema=schema)
12048
12191
  if patch_views:
12049
12192
  expanded_views, patch_issues, patch_results = self._expand_view_partial_patches(
12050
12193
  profile=profile,
@@ -12064,14 +12207,18 @@ class AiBuilderFacade:
12064
12207
  suggested_next_call={"tool_name": "view_get", "arguments": {"profile": profile, "view_key": patch_issues[0].get("view_key") or "VIEW_KEY"}},
12065
12208
  )
12066
12209
  )
12067
- upsert_views = [*upsert_views, *expanded_views]
12068
- normalized_args["upsert_views"] = [public_view_upsert_payload(patch) for patch in upsert_views]
12210
+ if len(expanded_views) > 1:
12211
+ return finalize(_failed(
12212
+ "VIEW_BATCH_OPERATION_UNSUPPORTED",
12213
+ "app_views_apply supports hydrating one patch view per call",
12214
+ normalized_args=normalized_args,
12215
+ details={"app_key": app_key, "received_patch_views": len(expanded_views)},
12216
+ suggested_next_call=None,
12217
+ ))
12218
+ upsert_views = expanded_views
12219
+ operation = "upsert" if expanded_views else "patch"
12220
+ normalized_args["upsert_view"] = public_view_upsert_payload(expanded_views[0]) if expanded_views else None
12069
12221
  normalized_args["patch_results"] = patch_results
12070
- current_fields_by_name = {
12071
- str(field.get("name") or ""): field
12072
- for field in parsed_schema["fields"]
12073
- if isinstance(field, dict) and str(field.get("name") or "")
12074
- }
12075
12222
  requires_custom_button_validation = any(
12076
12223
  any(binding.button_type == PublicViewButtonType.custom for binding in (patch.buttons or []))
12077
12224
  for patch in upsert_views
@@ -12167,8 +12314,8 @@ class AiBuilderFacade:
12167
12314
  associated_resource_inventory_ms += _duration_ms(associated_resource_inventory_started_at)
12168
12315
  removed: list[str] = []
12169
12316
  removed_keys: set[str] = set()
12170
- view_results: list[dict[str, Any]] = []
12171
- failed_views: list[dict[str, Any]] = []
12317
+ view_result: dict[str, Any] | None = None
12318
+ failure_result: dict[str, Any] | None = None
12172
12319
  action_button_intents: list[dict[str, Any]] = list(patch_action_button_intents)
12173
12320
 
12174
12321
  def record_action_button_intent(*, patch: ViewUpsertPatch, view_key: str | None, index: int) -> None:
@@ -12186,10 +12333,17 @@ class AiBuilderFacade:
12186
12333
  )
12187
12334
 
12188
12335
  view_mutation_started_at = time.perf_counter()
12189
- for selector in remove_views:
12190
- selector_text = str(selector or "").strip()
12336
+ if operation == "remove" and remove_views:
12337
+ selector_text = str(remove_views[0] or "").strip()
12191
12338
  if not selector_text:
12192
- continue
12339
+ view_mutation_ms += _duration_ms(view_mutation_started_at)
12340
+ return finish(_failed(
12341
+ "VIEW_NOT_FOUND",
12342
+ "remove_views item did not match an existing view name or view_key",
12343
+ normalized_args=normalized_args,
12344
+ details={"app_key": app_key},
12345
+ suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
12346
+ ))
12193
12347
  key_match = existing_by_key.get(selector_text)
12194
12348
  matches = [key_match] if isinstance(key_match, dict) else existing_by_name.get(selector_text, [])
12195
12349
  if len(matches) > 1:
@@ -12216,44 +12370,40 @@ class AiBuilderFacade:
12216
12370
  "error_code": "VIEW_NOT_FOUND",
12217
12371
  "message": "remove_views item did not match an existing view name or view_key",
12218
12372
  }
12219
- failed_views.append(failed_view)
12220
- view_results.append(deepcopy(failed_view))
12221
- continue
12222
- if len(matches) == 1:
12373
+ failure_result = failed_view
12374
+ view_result = deepcopy(failed_view)
12375
+ else:
12223
12376
  key = _extract_view_key(matches[0])
12224
12377
  removed_name = _extract_view_name(matches[0]) or selector_text
12225
12378
  try:
12226
- self.views.view_delete(profile=profile, viewgraph_key=key)
12227
- delete_readback = self._verify_view_deleted_by_key(profile=profile, view_key=key)
12379
+ delete_readback = self._apply_view_remove(profile=profile, view_key=key)
12228
12380
  removed.append(removed_name)
12229
12381
  if key:
12230
12382
  removed_keys.add(key)
12231
12383
  existing_by_key.pop(key, None)
12232
12384
  existing_by_name.pop(removed_name, None)
12233
- view_results.append(
12234
- {
12235
- "name": removed_name,
12236
- "view_key": key,
12237
- "type": None,
12238
- "status": delete_readback.get("status") or "readback_pending",
12239
- "operation": "delete",
12240
- "delete_executed": True,
12241
- "readback_status": delete_readback.get("readback_status"),
12242
- "safe_to_retry_delete": False,
12243
- **(
12244
- {
12245
- "error_code": delete_readback.get("error_code"),
12246
- "message": delete_readback.get("message"),
12247
- "request_id": delete_readback.get("request_id"),
12248
- "backend_code": delete_readback.get("backend_code"),
12249
- "http_status": delete_readback.get("http_status"),
12250
- "transport_error": delete_readback.get("transport_error"),
12251
- }
12252
- if delete_readback.get("readback_status") != "deleted"
12253
- else {}
12254
- ),
12255
- }
12256
- )
12385
+ view_result = {
12386
+ "name": removed_name,
12387
+ "view_key": key,
12388
+ "type": None,
12389
+ "status": delete_readback.get("status") or "readback_pending",
12390
+ "operation": "delete",
12391
+ "delete_executed": True,
12392
+ "readback_status": delete_readback.get("readback_status"),
12393
+ "safe_to_retry_delete": False,
12394
+ **(
12395
+ {
12396
+ "error_code": delete_readback.get("error_code"),
12397
+ "message": delete_readback.get("message"),
12398
+ "request_id": delete_readback.get("request_id"),
12399
+ "backend_code": delete_readback.get("backend_code"),
12400
+ "http_status": delete_readback.get("http_status"),
12401
+ "transport_error": delete_readback.get("transport_error"),
12402
+ }
12403
+ if delete_readback.get("readback_status") != "deleted"
12404
+ else {}
12405
+ ),
12406
+ }
12257
12407
  except (QingflowApiError, RuntimeError) as error:
12258
12408
  api_error = _coerce_api_error(error)
12259
12409
  failed_view = {
@@ -12270,8 +12420,8 @@ class AiBuilderFacade:
12270
12420
  "backend_code": api_error.backend_code,
12271
12421
  "http_status": None if api_error.http_status == 404 else api_error.http_status,
12272
12422
  }
12273
- failed_views.append(failed_view)
12274
- view_results.append(deepcopy(failed_view))
12423
+ failure_result = failed_view
12424
+ view_result = deepcopy(failed_view)
12275
12425
  created: list[str] = []
12276
12426
  updated: list[str] = []
12277
12427
  existing_view_list = [
@@ -12281,95 +12431,129 @@ class AiBuilderFacade:
12281
12431
  and _extract_view_name(view) not in removed
12282
12432
  and _extract_view_key(view) not in removed_keys
12283
12433
  ]
12284
- for ordinal, patch in enumerate(upsert_views, start=1):
12285
- apply_columns = _resolve_view_visible_field_names(patch)
12286
- ignored_system_columns = [
12287
- name
12288
- for name in [str(value or "").strip() for value in patch.columns]
12289
- if name in _KNOWN_SYSTEM_VIEW_COLUMNS
12290
- ]
12434
+ stale_warnings: list[dict[str, Any]] = []
12435
+ for ordinal, patch in ((1, upsert_views[0]),) if operation == "upsert" and upsert_views else ():
12436
+ apply_columns = _resolve_view_visible_que_ids(patch)
12291
12437
  if patch.type in {PublicViewType.table, PublicViewType.card} and patch.columns and not apply_columns:
12292
12438
  return _failed(
12293
12439
  "VALIDATION_ERROR",
12294
12440
  "view columns must include at least one real app field; system columns cannot be applied directly",
12295
12441
  normalized_args=normalized_args,
12296
- details={
12297
- "app_key": app_key,
12298
- "view_name": patch.name,
12299
- "ignored_system_columns": ignored_system_columns,
12300
- },
12442
+ details={"app_key": app_key, "view_name": patch.name},
12301
12443
  )
12302
- missing_columns = [name for name in apply_columns if name not in field_names]
12444
+ # 死引用(字段已从表单删除,后端保存视图时原样透传)降级为警告透传,不再整视图拒绝
12445
+ missing_columns = [
12446
+ que for que in apply_columns
12447
+ if not _is_known_system_view_que_id(que) and que not in fields_by_que_id
12448
+ ]
12303
12449
  if missing_columns:
12304
- return _failed(
12305
- "UNKNOWN_VIEW_FIELD",
12306
- "view columns reference unknown fields",
12307
- normalized_args=normalized_args,
12308
- details={
12309
- "app_key": app_key,
12310
- "view_name": patch.name,
12311
- "missing_fields": missing_columns,
12312
- "ignored_system_columns": ignored_system_columns,
12313
- },
12314
- missing_fields=missing_columns,
12315
- suggested_next_call={"tool_name": "app_get_fields", "arguments": {"profile": profile, "app_key": app_key}},
12450
+ stale_warnings.append(
12451
+ _stale_view_reference_warning(scope="columns", view_name=patch.name, field_ids=missing_columns)
12316
12452
  )
12317
- if patch.group_by and patch.group_by not in field_names:
12318
- return _failed(
12319
- "UNKNOWN_VIEW_FIELD",
12320
- f"group_by references unknown field '{patch.group_by}'",
12321
- normalized_args=normalized_args,
12322
- details={
12323
- "app_key": app_key,
12324
- "view_name": patch.name,
12325
- "missing_fields": [patch.group_by],
12326
- },
12327
- missing_fields=[patch.group_by],
12328
- suggested_next_call={"tool_name": "app_get_fields", "arguments": {"profile": profile, "app_key": app_key}},
12453
+ if patch.group_que_id and patch.group_que_id not in fields_by_que_id:
12454
+ stale_warnings.append(
12455
+ _stale_view_reference_warning(scope="group_que_id", view_name=patch.name, field_ids=[patch.group_que_id])
12329
12456
  )
12330
- for gantt_field_name in (patch.start_field, patch.end_field, patch.title_field):
12331
- if gantt_field_name and gantt_field_name not in field_names:
12332
- return _failed(
12333
- "UNKNOWN_VIEW_FIELD",
12334
- f"gantt configuration references unknown field '{gantt_field_name}'",
12335
- normalized_args=normalized_args,
12336
- details={
12337
- "app_key": app_key,
12338
- "view_name": patch.name,
12339
- "missing_fields": [gantt_field_name],
12340
- },
12341
- missing_fields=[gantt_field_name],
12342
- suggested_next_call={"tool_name": "app_get_fields", "arguments": {"profile": profile, "app_key": app_key}},
12457
+ for ref_scope, ref_que_id in (
12458
+ ("gantt.start_que_id", patch.start_que_id),
12459
+ ("gantt.end_que_id", patch.end_que_id),
12460
+ ("title_que_id", patch.title_que_id),
12461
+ ):
12462
+ # 系统字段(提交人/提交时间等)可作标题/时间轴;业务字段死引用仅警告透传
12463
+ if ref_que_id and not _is_known_system_view_que_id(ref_que_id) and ref_que_id not in fields_by_que_id:
12464
+ stale_warnings.append(
12465
+ _stale_view_reference_warning(scope=ref_scope, view_name=patch.name, field_ids=[ref_que_id])
12466
+ )
12467
+ for group_name, config_que_ids in _iter_view_config_group_field_names(patch):
12468
+ missing_config_fields = [
12469
+ que for que in config_que_ids
12470
+ if que not in fields_by_que_id and not _is_known_system_view_que_id(que)
12471
+ ]
12472
+ if missing_config_fields:
12473
+ stale_warnings.append(
12474
+ _stale_view_reference_warning(scope=group_name, view_name=patch.name, field_ids=missing_config_fields)
12475
+ )
12476
+ hierarchy_root_filter_groups: list[list[dict[str, Any]]] | None = None
12477
+ if patch.hierarchy is not None and patch.hierarchy.root_filter:
12478
+ hierarchy_root_filter_groups = []
12479
+ for root_filter_group in patch.hierarchy.root_filter:
12480
+ translated_group, hierarchy_filter_issues = _build_view_filter_groups(
12481
+ fields_by_que_id=fields_by_que_id,
12482
+ filters=root_filter_group,
12343
12483
  )
12484
+ for issue in hierarchy_filter_issues:
12485
+ if issue.get("warning_code") == "STALE_OPTION_REFERENCE":
12486
+ stale_warnings.append(
12487
+ _stale_view_reference_warning(
12488
+ scope="hierarchy.root_filter",
12489
+ view_name=patch.name,
12490
+ values=issue.get("details", {}).get("unresolved_values") or [],
12491
+ option=True,
12492
+ )
12493
+ )
12494
+ hierarchy_filter_errors = [issue for issue in hierarchy_filter_issues if issue.get("error_code")]
12495
+ if hierarchy_filter_errors:
12496
+ first_issue = hierarchy_filter_errors[0]
12497
+ return _failed(
12498
+ str(first_issue.get("error_code") or "UNKNOWN_VIEW_FIELD"),
12499
+ "view hierarchy root_filter references invalid fields or values",
12500
+ normalized_args=normalized_args,
12501
+ details={
12502
+ "app_key": app_key,
12503
+ "view_name": patch.name,
12504
+ **first_issue,
12505
+ },
12506
+ missing_fields=list(first_issue.get("missing_fields") or []),
12507
+ suggested_next_call={"tool_name": "app_get_fields", "arguments": {"profile": profile, "app_key": app_key}},
12508
+ )
12509
+ hierarchy_root_filter_groups.extend(translated_group)
12344
12510
  translated_filters: list[list[dict[str, Any]]] | None
12345
12511
  filter_issues: list[dict[str, Any]]
12346
12512
  if patch.preserve_filters:
12347
12513
  translated_filters = None
12348
12514
  filter_issues = []
12515
+ elif patch.filters and isinstance(patch.filters[0], list):
12516
+ # 嵌套形态 [[rule,...],[rule,...]]:组间 OR、组内 AND(viewgraphLimit 多组),
12517
+ # 复用 button_limit 的多组编译;后端简易模式会从多组规则自动派生公式
12518
+ translated_filters, filter_issues = _build_view_button_limit_groups(
12519
+ fields_by_que_id=fields_by_que_id, groups=patch.filters
12520
+ )
12349
12521
  else:
12350
- translated_filters, filter_issues = _build_view_filter_groups(current_fields_by_name=current_fields_by_name, filters=patch.filters)
12522
+ translated_filters, filter_issues = _build_view_filter_groups(fields_by_que_id=fields_by_que_id, filters=patch.filters)
12351
12523
  if filter_issues:
12352
- first_issue = filter_issues[0]
12353
- return _failed(
12354
- str(first_issue.get("error_code") or "UNKNOWN_VIEW_FIELD"),
12355
- "view filters reference invalid fields or values",
12356
- normalized_args=normalized_args,
12357
- details={
12358
- "app_key": app_key,
12359
- "view_name": patch.name,
12360
- **first_issue,
12361
- },
12362
- missing_fields=list(first_issue.get("missing_fields") or []),
12363
- allowed_values=first_issue.get("allowed_values") or {"view_types": [member.value for member in PublicViewType], "view.filter.operator": [member.value for member in ViewFilterOperator]},
12364
- suggested_next_call={"tool_name": "app_get_fields", "arguments": {"profile": profile, "app_key": app_key}},
12365
- )
12524
+ for issue in filter_issues:
12525
+ if issue.get("warning_code") == "STALE_OPTION_REFERENCE":
12526
+ stale_warnings.append(
12527
+ _stale_view_reference_warning(
12528
+ scope="filters",
12529
+ view_name=patch.name,
12530
+ values=issue.get("details", {}).get("unresolved_values") or [],
12531
+ option=True,
12532
+ )
12533
+ )
12534
+ filter_errors = [issue for issue in filter_issues if issue.get("error_code")]
12535
+ if filter_errors:
12536
+ first_issue = filter_errors[0]
12537
+ return _failed(
12538
+ str(first_issue.get("error_code") or "UNKNOWN_VIEW_FIELD"),
12539
+ "view filters reference invalid fields or values",
12540
+ normalized_args=normalized_args,
12541
+ details={
12542
+ "app_key": app_key,
12543
+ "view_name": patch.name,
12544
+ **first_issue,
12545
+ },
12546
+ missing_fields=list(first_issue.get("missing_fields") or []),
12547
+ allowed_values=first_issue.get("allowed_values") or {"view_types": [member.value for member in PublicViewType], "view.filter.operator": [member.value for member in ViewFilterOperator]},
12548
+ suggested_next_call={"tool_name": "app_get_fields", "arguments": {"profile": profile, "app_key": app_key}},
12549
+ )
12366
12550
  if patch.preserve_query_conditions:
12367
12551
  query_condition_payload = None
12368
12552
  expected_query_conditions = None
12369
12553
  query_condition_issues = []
12370
12554
  else:
12371
12555
  query_condition_payload, expected_query_conditions, query_condition_issues = _build_view_query_conditions_payload(
12372
- current_fields_by_name=current_fields_by_name,
12556
+ fields_by_que_id=fields_by_que_id,
12373
12557
  query_conditions=patch.query_conditions,
12374
12558
  )
12375
12559
  if query_condition_issues:
@@ -12426,7 +12610,7 @@ class AiBuilderFacade:
12426
12610
  expected_button_summary = None
12427
12611
  elif patch.buttons is not None:
12428
12612
  explicit_button_dtos, button_issues = _build_view_button_dtos(
12429
- current_fields_by_name=current_fields_by_name,
12613
+ fields_by_que_id=fields_by_que_id,
12430
12614
  bindings=patch.buttons,
12431
12615
  valid_custom_button_ids=valid_custom_button_ids,
12432
12616
  )
@@ -12443,7 +12627,10 @@ class AiBuilderFacade:
12443
12627
  },
12444
12628
  missing_fields=list(first_issue.get("missing_fields") or []),
12445
12629
  allowed_values=first_issue.get("allowed_values") or {"view_types": [member.value for member in PublicViewType], "view.filter.operator": [member.value for member in ViewFilterOperator]},
12446
- suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
12630
+ suggested_next_call={
12631
+ "tool_name": "app_get_buttons",
12632
+ "arguments": {"profile": profile, "app_key": app_key},
12633
+ },
12447
12634
  )
12448
12635
  expected_button_summary = _normalize_expected_view_buttons_for_compare(
12449
12636
  explicit_button_dtos or [],
@@ -12484,6 +12671,10 @@ class AiBuilderFacade:
12484
12671
  existing_key = _extract_view_key(matched_existing_view)
12485
12672
  created_key: str | None = None
12486
12673
  system_view_list_type = _resolve_system_view_list_type(view_key=existing_key, view_name=patch.name) if existing_key else None
12674
+ if system_view_list_type is not None and os.environ.get("QINGFLOW_VIEWS_APPLY_SKIP_SYSTEM_VIEW_SYNC", "").strip().lower() in {"1", "true", "yes", "on"}:
12675
+ # 环境受限开关:默认视图列序同步依赖 app apply/baseInfo 回读,在快照/脱敏环境上该读面可能不可用;
12676
+ # 视图写主体(view_update)不受影响,仅跳过同步与尾部校验。
12677
+ system_view_list_type = None
12487
12678
  operation_phase = "view_update" if existing_key else "view_create"
12488
12679
  query_condition_payload_for_apply = deepcopy(query_condition_payload)
12489
12680
  expected_query_conditions_for_verify = deepcopy(expected_query_conditions)
@@ -12517,16 +12708,17 @@ class AiBuilderFacade:
12517
12708
  views=self.views,
12518
12709
  profile=profile,
12519
12710
  source_viewgraph_key=existing_key,
12711
+ hierarchy_filter_groups=hierarchy_root_filter_groups,
12520
12712
  schema=schema,
12521
12713
  patch=patch,
12522
12714
  view_filters=translated_filters,
12523
- current_fields_by_name=current_fields_by_name,
12715
+ fields_by_que_id=fields_by_que_id,
12524
12716
  auth_override=view_auth_override,
12525
12717
  explicit_button_dtos=explicit_button_dtos,
12526
12718
  query_condition_payload=query_condition_payload_for_apply,
12527
12719
  associated_resources_payload=associated_resources_payload_for_apply,
12528
12720
  )
12529
- self.views.view_update(profile=profile, viewgraph_key=existing_key, payload=payload)
12721
+ self._apply_view_update(profile=profile, view_key=existing_key, payload=payload)
12530
12722
  system_view_sync: dict[str, Any] | None = None
12531
12723
  if system_view_list_type is not None and patch.type.value == "table":
12532
12724
  operation_phase = "default_view_apply_config_sync"
@@ -12537,7 +12729,9 @@ class AiBuilderFacade:
12537
12729
  payload=payload,
12538
12730
  list_type=system_view_list_type,
12539
12731
  schema=schema,
12540
- visible_field_names=apply_columns,
12732
+ visible_field_names=[
12733
+ str((fields_by_que_id.get(que) or {}).get("name") or "") for que in apply_columns if que in fields_by_que_id
12734
+ ],
12541
12735
  )
12542
12736
  if not bool(system_view_sync.get("verified")):
12543
12737
  failure_entry = {
@@ -12562,24 +12756,23 @@ class AiBuilderFacade:
12562
12756
  "apply_columns": apply_columns,
12563
12757
  },
12564
12758
  }
12565
- failed_views.append(failure_entry)
12566
- view_results.append(failure_entry)
12759
+ failure_result = failure_entry
12760
+ view_result = failure_entry
12567
12761
  continue
12568
12762
  updated.append(patch.name)
12569
- view_results.append(
12570
- {
12571
- "name": patch.name,
12572
- "view_key": existing_key,
12573
- "type": patch.type.value,
12574
- "status": "updated",
12575
- "expected_filters": deepcopy(translated_filters),
12576
- "expected_buttons": deepcopy(expected_button_summary),
12577
- "expected_query_conditions": deepcopy(expected_query_conditions_for_verify),
12578
- "expected_associated_resources": deepcopy(expected_associated_resources_for_verify),
12579
- "system_view_sync": system_view_sync,
12580
- "apply_columns": deepcopy(apply_columns),
12581
- }
12582
- )
12763
+ view_result = {
12764
+ "name": patch.name,
12765
+ "view_key": existing_key,
12766
+ "type": patch.type.value,
12767
+ "status": "updated",
12768
+ "expected_filters": deepcopy(translated_filters),
12769
+ "expected_buttons": deepcopy(expected_button_summary),
12770
+ "expected_query_conditions": deepcopy(expected_query_conditions_for_verify),
12771
+ "expected_associated_resources": deepcopy(expected_associated_resources_for_verify),
12772
+ "expected_pinned": patch.pinned,
12773
+ "system_view_sync": system_view_sync,
12774
+ "apply_columns": deepcopy(apply_columns),
12775
+ }
12583
12776
  record_action_button_intent(patch=patch, view_key=existing_key, index=ordinal - 1)
12584
12777
  else:
12585
12778
  template_key = _pick_view_template_key(existing_view_list, desired_type=patch.type.value)
@@ -12591,50 +12784,45 @@ class AiBuilderFacade:
12591
12784
  views=self.views,
12592
12785
  profile=profile,
12593
12786
  source_viewgraph_key=created_key,
12787
+ hierarchy_filter_groups=hierarchy_root_filter_groups,
12594
12788
  schema=schema,
12595
12789
  patch=patch,
12596
12790
  view_filters=translated_filters,
12597
- current_fields_by_name=current_fields_by_name,
12791
+ fields_by_que_id=fields_by_que_id,
12598
12792
  auth_override=view_auth_override,
12599
12793
  explicit_button_dtos=explicit_button_dtos,
12600
12794
  query_condition_payload=query_condition_payload_for_apply,
12601
12795
  associated_resources_payload=associated_resources_payload_for_apply,
12602
12796
  )
12603
- self.views.view_update(profile=profile, viewgraph_key=created_key, payload=payload)
12797
+ self._apply_view_update(profile=profile, view_key=created_key, payload=payload)
12604
12798
  else:
12605
- payload = _build_view_create_payload(
12799
+ payload = _build_view_creation_payload(
12606
12800
  app_key=app_key,
12607
12801
  base_info=base,
12608
12802
  schema=schema,
12609
12803
  patch=patch,
12610
12804
  ordinal=ordinal,
12611
12805
  view_filters=translated_filters,
12612
- current_fields_by_name=current_fields_by_name,
12806
+ fields_by_que_id=fields_by_que_id,
12613
12807
  auth_override=view_auth_override,
12614
12808
  explicit_button_dtos=explicit_button_dtos,
12615
12809
  query_condition_payload=query_condition_payload_for_apply,
12616
12810
  associated_resources_payload=associated_resources_payload_for_apply,
12811
+ hierarchy_filter_groups=hierarchy_root_filter_groups,
12617
12812
  )
12618
- create_result = self.views.view_create(profile=profile, payload=payload)
12619
- raw_created = create_result.get("result")
12620
- if isinstance(raw_created, dict):
12621
- raw_view_key = raw_created.get("viewgraphKey") or raw_created.get("viewKey")
12622
- created_key = str(raw_view_key).strip() if raw_view_key is not None and str(raw_view_key).strip() else None
12623
- elif isinstance(raw_created, str):
12624
- created_key = raw_created.strip() or None
12813
+ created_key = self._apply_view_create(profile=profile, payload=payload)
12625
12814
  created.append(patch.name)
12626
- view_results.append(
12627
- {
12628
- "name": patch.name,
12629
- "view_key": created_key,
12630
- "type": patch.type.value,
12631
- "status": "created",
12632
- "expected_filters": deepcopy(translated_filters),
12633
- "expected_buttons": deepcopy(expected_button_summary),
12634
- "expected_query_conditions": deepcopy(expected_query_conditions_for_verify),
12635
- "expected_associated_resources": deepcopy(expected_associated_resources_for_verify),
12636
- }
12637
- )
12815
+ view_result = {
12816
+ "name": patch.name,
12817
+ "view_key": created_key,
12818
+ "type": patch.type.value,
12819
+ "status": "created",
12820
+ "expected_filters": deepcopy(translated_filters),
12821
+ "expected_buttons": deepcopy(expected_button_summary),
12822
+ "expected_query_conditions": deepcopy(expected_query_conditions_for_verify),
12823
+ "expected_associated_resources": deepcopy(expected_associated_resources_for_verify),
12824
+ "expected_pinned": patch.pinned,
12825
+ }
12638
12826
  record_action_button_intent(patch=patch, view_key=created_key, index=ordinal - 1)
12639
12827
  except (QingflowApiError, RuntimeError) as error:
12640
12828
  api_error = _coerce_api_error(error)
@@ -12661,17 +12849,20 @@ class AiBuilderFacade:
12661
12849
  patch=patch,
12662
12850
  ordinal=ordinal,
12663
12851
  view_filters=translated_filters,
12664
- current_fields_by_name=current_fields_by_name,
12852
+ hierarchy_filter_groups=hierarchy_root_filter_groups,
12853
+ fields_by_que_id=fields_by_que_id,
12665
12854
  auth_override=view_auth_override,
12666
12855
  explicit_button_dtos=fallback_button_dtos,
12667
12856
  query_condition_payload=query_condition_payload_for_apply,
12668
12857
  associated_resources_payload=associated_resources_payload_for_apply,
12669
12858
  )
12670
- self.views.view_update(profile=profile, viewgraph_key=target_key, payload=fallback_payload)
12859
+ self._apply_view_update(profile=profile, view_key=target_key, payload=fallback_payload)
12671
12860
  system_view_sync: dict[str, Any] | None = None
12672
12861
  fallback_system_view_list_type = (
12673
12862
  _resolve_system_view_list_type(view_key=target_key, view_name=patch.name)
12674
- if patch.type.value == "table" and target_key
12863
+ if patch.type.value == "table"
12864
+ and target_key
12865
+ and os.environ.get("QINGFLOW_VIEWS_APPLY_SKIP_SYSTEM_VIEW_SYNC", "").strip().lower() not in {"1", "true", "yes", "on"}
12675
12866
  else None
12676
12867
  )
12677
12868
  if fallback_system_view_list_type is not None:
@@ -12683,7 +12874,9 @@ class AiBuilderFacade:
12683
12874
  payload=fallback_payload,
12684
12875
  list_type=fallback_system_view_list_type,
12685
12876
  schema=schema,
12686
- visible_field_names=apply_columns,
12877
+ visible_field_names=[
12878
+ str((fields_by_que_id.get(que) or {}).get("name") or "") for que in apply_columns if que in fields_by_que_id
12879
+ ],
12687
12880
  )
12688
12881
  if not bool(system_view_sync.get("verified")):
12689
12882
  failure_entry = {
@@ -12708,44 +12901,42 @@ class AiBuilderFacade:
12708
12901
  "apply_columns": apply_columns,
12709
12902
  },
12710
12903
  }
12711
- failed_views.append(failure_entry)
12712
- view_results.append(failure_entry)
12904
+ failure_result = failure_entry
12905
+ view_result = failure_entry
12713
12906
  continue
12714
12907
  if existing_key:
12715
12908
  updated.append(patch.name)
12716
- view_results.append(
12717
- {
12718
- "name": patch.name,
12719
- "view_key": existing_key,
12720
- "type": patch.type.value,
12721
- "status": "updated",
12722
- "fallback_applied": True,
12723
- "expected_filters": deepcopy(translated_filters),
12724
- "expected_buttons": deepcopy(expected_button_summary),
12725
- "expected_query_conditions": deepcopy(expected_query_conditions_for_verify),
12726
- "expected_associated_resources": deepcopy(expected_associated_resources_for_verify),
12727
- "system_view_sync": system_view_sync,
12728
- "apply_columns": deepcopy(apply_columns),
12729
- }
12730
- )
12909
+ view_result = {
12910
+ "name": patch.name,
12911
+ "view_key": existing_key,
12912
+ "type": patch.type.value,
12913
+ "status": "updated",
12914
+ "fallback_applied": True,
12915
+ "expected_filters": deepcopy(translated_filters),
12916
+ "expected_buttons": deepcopy(expected_button_summary),
12917
+ "expected_query_conditions": deepcopy(expected_query_conditions_for_verify),
12918
+ "expected_associated_resources": deepcopy(expected_associated_resources_for_verify),
12919
+ "expected_pinned": patch.pinned,
12920
+ "system_view_sync": system_view_sync,
12921
+ "apply_columns": deepcopy(apply_columns),
12922
+ }
12731
12923
  record_action_button_intent(patch=patch, view_key=existing_key, index=ordinal - 1)
12732
12924
  else:
12733
12925
  created.append(patch.name)
12734
- view_results.append(
12735
- {
12736
- "name": patch.name,
12737
- "view_key": created_key,
12738
- "type": patch.type.value,
12739
- "status": "created",
12740
- "fallback_applied": True,
12741
- "expected_filters": deepcopy(translated_filters),
12742
- "expected_buttons": deepcopy(expected_button_summary),
12743
- "expected_query_conditions": deepcopy(expected_query_conditions_for_verify),
12744
- "expected_associated_resources": deepcopy(expected_associated_resources_for_verify),
12745
- "system_view_sync": system_view_sync,
12746
- "apply_columns": deepcopy(apply_columns),
12747
- }
12748
- )
12926
+ view_result = {
12927
+ "name": patch.name,
12928
+ "view_key": created_key,
12929
+ "type": patch.type.value,
12930
+ "status": "created",
12931
+ "fallback_applied": True,
12932
+ "expected_filters": deepcopy(translated_filters),
12933
+ "expected_buttons": deepcopy(expected_button_summary),
12934
+ "expected_query_conditions": deepcopy(expected_query_conditions_for_verify),
12935
+ "expected_associated_resources": deepcopy(expected_associated_resources_for_verify),
12936
+ "expected_pinned": patch.pinned,
12937
+ "system_view_sync": system_view_sync,
12938
+ "apply_columns": deepcopy(apply_columns),
12939
+ }
12749
12940
  record_action_button_intent(patch=patch, view_key=created_key, index=ordinal - 1)
12750
12941
  continue
12751
12942
  fallback_payload = _build_minimal_view_payload(
@@ -12754,16 +12945,16 @@ class AiBuilderFacade:
12754
12945
  patch=patch,
12755
12946
  ordinal=ordinal,
12756
12947
  view_filters=translated_filters,
12757
- current_fields_by_name=current_fields_by_name,
12948
+ hierarchy_filter_groups=hierarchy_root_filter_groups,
12949
+ fields_by_que_id=fields_by_que_id,
12758
12950
  auth_override=view_auth_override,
12759
12951
  explicit_button_dtos=explicit_button_dtos,
12760
12952
  query_condition_payload=query_condition_payload_for_apply,
12761
12953
  associated_resources_payload=associated_resources_payload_for_apply,
12762
12954
  )
12763
- self.views.view_create(profile=profile, payload=fallback_payload)
12955
+ self._apply_view_create(profile=profile, payload=fallback_payload)
12764
12956
  created.append(patch.name)
12765
- view_results.append(
12766
- {
12957
+ view_result = {
12767
12958
  "name": patch.name,
12768
12959
  "view_key": created_key,
12769
12960
  "type": patch.type.value,
@@ -12773,7 +12964,6 @@ class AiBuilderFacade:
12773
12964
  "expected_query_conditions": deepcopy(expected_query_conditions_for_verify),
12774
12965
  "expected_associated_resources": deepcopy(expected_associated_resources_for_verify),
12775
12966
  }
12776
- )
12777
12967
  record_action_button_intent(patch=patch, view_key=created_key, index=ordinal - 1)
12778
12968
  continue
12779
12969
  except (QingflowApiError, RuntimeError) as fallback_error:
@@ -12799,11 +12989,16 @@ class AiBuilderFacade:
12799
12989
  "view_name": patch.name,
12800
12990
  "view_type": patch.type.value,
12801
12991
  "columns": patch.columns,
12802
- "group_by": patch.group_by,
12803
- "filters": [item.model_dump(mode="json") for item in patch.filters],
12804
- "start_field": patch.start_field,
12805
- "end_field": patch.end_field,
12806
- "title_field": patch.title_field,
12992
+ "group_que_id": patch.group_que_id,
12993
+ "filters": [
12994
+ (item.model_dump(mode="json") if hasattr(item, "model_dump") else item)
12995
+ if not isinstance(item, list)
12996
+ else [sub.model_dump(mode="json") if hasattr(sub, "model_dump") else sub for sub in item]
12997
+ for item in patch.filters
12998
+ ],
12999
+ "start_que_id": patch.start_que_id,
13000
+ "end_que_id": patch.end_que_id,
13001
+ "title_que_id": patch.title_que_id,
12807
13002
  "operation": "sync_default_view" if operation_phase == "default_view_apply_config_sync" else ("update" if existing_key or created_key else "create"),
12808
13003
  "list_type": system_view_list_type,
12809
13004
  "transport_error": {
@@ -12813,24 +13008,69 @@ class AiBuilderFacade:
12813
13008
  },
12814
13009
  **_view_apply_failure_diagnostics(
12815
13010
  patch=patch,
12816
- current_fields_by_name=current_fields_by_name,
13011
+ fields_by_que_id=fields_by_que_id,
12817
13012
  backend_code=api_error.backend_code,
12818
13013
  operation_phase=operation_phase,
12819
13014
  ),
12820
13015
  },
12821
13016
  }
12822
- failed_views.append(failure_entry)
12823
- view_results.append(failure_entry)
13017
+ failure_result = failure_entry
13018
+ view_result = failure_entry
12824
13019
  continue
12825
13020
  view_mutation_ms += _duration_ms(view_mutation_started_at)
13021
+ for item in ((view_result,) if view_result is not None else ()):
13022
+ requested_pinned = item.get("expected_pinned")
13023
+ view_key = str(item.get("view_key") or "").strip()
13024
+ if (
13025
+ str(item.get("status") or "") not in {"created", "updated"}
13026
+ or not isinstance(requested_pinned, bool)
13027
+ or not view_key
13028
+ ):
13029
+ continue
13030
+ try:
13031
+ pin_result = self._set_view_pinned(
13032
+ profile=profile,
13033
+ app_key=app_key,
13034
+ view_key=view_key,
13035
+ pinned=requested_pinned,
13036
+ )
13037
+ item["pinned_state"] = pin_result
13038
+ if not bool(pin_result.get("verified")):
13039
+ failure_result = {
13040
+ "name": item.get("name"),
13041
+ "view_key": view_key,
13042
+ "type": item.get("type"),
13043
+ "status": "failed",
13044
+ "error_code": "VIEW_PIN_STATE_READBACK_MISMATCH",
13045
+ "message": "view configuration was saved, but its pinned state did not verify",
13046
+ "operation": "set_pinned",
13047
+ "details": pin_result,
13048
+ }
13049
+ except (QingflowApiError, RuntimeError, ValueError) as error:
13050
+ api_error = _coerce_api_error(error)
13051
+ pin_failure = {
13052
+ "name": item.get("name"),
13053
+ "view_key": view_key,
13054
+ "type": item.get("type"),
13055
+ "status": "failed",
13056
+ "error_code": "VIEW_PIN_STATE_APPLY_FAILED",
13057
+ "message": "view configuration was saved, but updating its pinned state failed",
13058
+ "request_id": api_error.request_id,
13059
+ "backend_code": api_error.backend_code,
13060
+ "http_status": api_error.http_status,
13061
+ "operation": "set_pinned",
13062
+ "details": {"requested_pinned": requested_pinned},
13063
+ }
13064
+ item["pinned_state"] = {"requested_pinned": requested_pinned, "verified": False}
13065
+ failure_result = pin_failure
12826
13066
  successful_action_view_keys = {
12827
13067
  str(item.get("view_key") or "").strip()
12828
- for item in view_results
13068
+ for item in ((view_result,) if view_result is not None else ())
12829
13069
  if str(item.get("status") or "") in {"created", "updated"} and str(item.get("view_key") or "").strip()
12830
13070
  }
12831
13071
  successful_action_view_names = {
12832
13072
  str(item.get("name") or "").strip()
12833
- for item in view_results
13073
+ for item in ((view_result,) if view_result is not None else ())
12834
13074
  if str(item.get("status") or "") in {"created", "updated"} and str(item.get("name") or "").strip()
12835
13075
  }
12836
13076
  skipped_action_button_intents = [
@@ -12881,12 +13121,12 @@ class AiBuilderFacade:
12881
13121
  action_buttons_failed = bool(action_button_intents) and not (action_buttons_verified and view_action_button_bindings_verified)
12882
13122
  action_buttons_retry_payload = action_buttons_result.get("retry_payload") if isinstance(action_buttons_result.get("retry_payload"), dict) else None
12883
13123
  needs_view_list_readback = bool(created or updated)
12884
- verified_view_result: list[dict[str, Any]] | None = []
13124
+ verified_views: list[dict[str, Any]] | None = []
12885
13125
  verified_views_unavailable = False
12886
13126
  if needs_view_list_readback:
12887
13127
  try:
12888
13128
  view_readback_started_at = time.perf_counter()
12889
- verified_view_result, verified_views_unavailable = self._load_views_result(profile=profile, app_key=app_key, tolerate_404=True)
13129
+ verified_views, verified_views_unavailable = self._load_views_result(profile=profile, app_key=app_key, tolerate_404=True)
12890
13130
  except (QingflowApiError, RuntimeError) as error:
12891
13131
  view_readback_ms += _duration_ms(view_readback_started_at)
12892
13132
  api_error = _coerce_api_error(error)
@@ -12900,23 +13140,23 @@ class AiBuilderFacade:
12900
13140
  view_readback_ms += _duration_ms(view_readback_started_at)
12901
13141
  verified_names = {
12902
13142
  _extract_view_name(item)
12903
- for item in (verified_view_result or [])
13143
+ for item in (verified_views or [])
12904
13144
  if isinstance(item, dict) and _extract_view_name(item)
12905
13145
  }
12906
13146
  verified_by_key = {
12907
13147
  _extract_view_key(item): item
12908
- for item in (verified_view_result or [])
13148
+ for item in (verified_views or [])
12909
13149
  if isinstance(item, dict) and _extract_view_key(item)
12910
13150
  }
12911
13151
  verified_view_keys_by_name: dict[str, list[str]] = {}
12912
- for item in (verified_view_result or []):
13152
+ for item in (verified_views or []):
12913
13153
  if not isinstance(item, dict):
12914
13154
  continue
12915
13155
  name = _extract_view_name(item)
12916
13156
  key = _extract_view_key(item)
12917
13157
  if name and key:
12918
13158
  verified_view_keys_by_name.setdefault(name, []).append(key)
12919
- verification_by_view: list[dict[str, Any]] = []
13159
+ verification_result: dict[str, Any] | None = None
12920
13160
  filter_readback_pending = False
12921
13161
  filter_mismatches: list[dict[str, Any]] = []
12922
13162
  query_condition_readback_pending = False
@@ -12943,7 +13183,7 @@ class AiBuilderFacade:
12943
13183
  return cached
12944
13184
 
12945
13185
  view_verification_started_at = time.perf_counter()
12946
- for item in view_results:
13186
+ for item in ((view_result,) if view_result is not None else ()):
12947
13187
  status = str(item.get("status") or "")
12948
13188
  name = str(item.get("name") or "")
12949
13189
  item_view_key = str(item.get("view_key") or "").strip()
@@ -12986,7 +13226,7 @@ class AiBuilderFacade:
12986
13226
  verification_entry["readback_ambiguous"] = True
12987
13227
  verification_entry["matching_view_keys"] = matched_keys
12988
13228
  filter_readback_pending = True
12989
- verification_by_view.append(verification_entry)
13229
+ verification_result = verification_entry
12990
13230
  continue
12991
13231
  try:
12992
13232
  config_result, config_error = read_view_config_for_verification(verification_key)
@@ -13004,11 +13244,11 @@ class AiBuilderFacade:
13004
13244
  filters_verified = filters_semantically_equivalent and actual_data_scope == expected_data_scope
13005
13245
  public_expected_filters = _public_view_filter_groups_from_match_rules(
13006
13246
  expected_filter_summary,
13007
- current_fields_by_name=current_fields_by_name,
13247
+ fields_by_que_id=fields_by_que_id,
13008
13248
  )
13009
13249
  public_actual_filters = _public_view_filter_groups_from_match_rules(
13010
13250
  semantic_actual_filters,
13011
- current_fields_by_name=current_fields_by_name,
13251
+ fields_by_que_id=fields_by_que_id,
13012
13252
  )
13013
13253
  verification_entry["filters_verified"] = filters_verified
13014
13254
  verification_entry["view_key"] = verification_key
@@ -13058,7 +13298,7 @@ class AiBuilderFacade:
13058
13298
  verification_entry["readback_ambiguous"] = True
13059
13299
  verification_entry["matching_view_keys"] = matched_keys
13060
13300
  query_condition_readback_pending = True
13061
- verification_by_view.append(verification_entry)
13301
+ verification_result = verification_entry
13062
13302
  continue
13063
13303
  try:
13064
13304
  config_result, config_error = read_view_config_for_verification(verification_key)
@@ -13107,7 +13347,7 @@ class AiBuilderFacade:
13107
13347
  verification_entry["readback_ambiguous"] = True
13108
13348
  verification_entry["matching_view_keys"] = matched_keys
13109
13349
  associated_resource_readback_pending = True
13110
- verification_by_view.append(verification_entry)
13350
+ verification_result = verification_entry
13111
13351
  continue
13112
13352
  try:
13113
13353
  config_result, config_error = read_view_config_for_verification(verification_key)
@@ -13162,7 +13402,7 @@ class AiBuilderFacade:
13162
13402
  verification_entry["readback_ambiguous"] = True
13163
13403
  verification_entry["matching_view_keys"] = matched_keys
13164
13404
  button_readback_pending = True
13165
- verification_by_view.append(verification_entry)
13405
+ verification_result = verification_entry
13166
13406
  continue
13167
13407
  try:
13168
13408
  config_result, config_error = read_view_config_for_verification(verification_key)
@@ -13210,51 +13450,45 @@ class AiBuilderFacade:
13210
13450
  "category": api_error.category,
13211
13451
  }
13212
13452
  button_readback_pending = True
13213
- verification_by_view.append(verification_entry)
13453
+ verification_result = verification_entry
13214
13454
  elif status == "removed":
13215
- verification_by_view.append(
13216
- {
13217
- "name": name,
13218
- "view_key": item.get("view_key"),
13219
- "type": item.get("type"),
13220
- "status": "removed",
13221
- "present_in_readback": False,
13222
- "removed_verified": True,
13223
- "delete_executed": bool(item.get("delete_executed")),
13224
- "readback_status": item.get("readback_status") or "deleted",
13225
- "safe_to_retry_delete": False,
13226
- }
13227
- )
13455
+ verification_result = {
13456
+ "name": name,
13457
+ "view_key": item.get("view_key"),
13458
+ "type": item.get("type"),
13459
+ "status": "removed",
13460
+ "present_in_readback": False,
13461
+ "removed_verified": True,
13462
+ "delete_executed": bool(item.get("delete_executed")),
13463
+ "readback_status": item.get("readback_status") or "deleted",
13464
+ "safe_to_retry_delete": False,
13465
+ }
13228
13466
  elif status == "readback_pending" and item.get("operation") == "delete":
13229
13467
  readback_status = str(item.get("readback_status") or "unavailable")
13230
- verification_by_view.append(
13231
- {
13232
- "name": name,
13233
- "view_key": item.get("view_key"),
13234
- "type": item.get("type"),
13235
- "status": "readback_pending",
13236
- "present_in_readback": True if readback_status == "still_exists" else None,
13237
- "removed_verified": False,
13238
- "delete_executed": bool(item.get("delete_executed")),
13239
- "readback_status": readback_status,
13240
- "safe_to_retry_delete": False,
13241
- "error_code": item.get("error_code"),
13242
- }
13243
- )
13468
+ verification_result = {
13469
+ "name": name,
13470
+ "view_key": item.get("view_key"),
13471
+ "type": item.get("type"),
13472
+ "status": "readback_pending",
13473
+ "present_in_readback": True if readback_status == "still_exists" else None,
13474
+ "removed_verified": False,
13475
+ "delete_executed": bool(item.get("delete_executed")),
13476
+ "readback_status": readback_status,
13477
+ "safe_to_retry_delete": False,
13478
+ "error_code": item.get("error_code"),
13479
+ }
13244
13480
  else:
13245
- verification_by_view.append(
13246
- {
13247
- "name": item.get("name"),
13248
- "type": item.get("type"),
13249
- "status": "failed",
13250
- "present_in_readback": None,
13251
- "error_code": item.get("error_code"),
13252
- }
13253
- )
13481
+ verification_result = {
13482
+ "name": item.get("name"),
13483
+ "type": item.get("type"),
13484
+ "status": "failed",
13485
+ "present_in_readback": None,
13486
+ "error_code": item.get("error_code"),
13487
+ }
13254
13488
  view_verification_ms += _duration_ms(view_verification_started_at)
13255
13489
  removed_delete_results = [
13256
13490
  item
13257
- for item in view_results
13491
+ for item in ((view_result,) if view_result is not None else ())
13258
13492
  if item.get("operation") == "delete" and bool(item.get("delete_executed"))
13259
13493
  ]
13260
13494
  removed_verified = all(str(item.get("readback_status") or "") == "deleted" for item in removed_delete_results)
@@ -13269,19 +13503,19 @@ class AiBuilderFacade:
13269
13503
  view_associated_resources_verified = verified and not associated_resource_readback_pending and not associated_resource_mismatches
13270
13504
  view_buttons_verified = verified and not button_readback_pending and not button_mismatches and not custom_button_readback_pending
13271
13505
  noop = not created and not updated and not removed and not action_button_write_executed
13272
- if failed_views:
13506
+ if failure_result is not None:
13273
13507
  successful_changes = bool(created or updated or removed or action_button_write_succeeded)
13274
- first_failure = failed_views[0]
13508
+ first_failure = failure_result
13275
13509
  response = {
13276
13510
  "status": "partial_success" if successful_changes else "failed",
13277
13511
  "error_code": "VIEW_APPLY_PARTIAL" if successful_changes else "VIEW_APPLY_FAILED",
13278
13512
  "recoverable": True,
13279
- "message": "applied some view patches; at least one view failed" if successful_changes else "one or more view patches failed",
13513
+ "message": "view operation completed with a verification failure" if successful_changes else "view operation failed",
13280
13514
  "normalized_args": normalized_args,
13281
13515
  "missing_fields": [],
13282
13516
  "allowed_values": {"view_types": [member.value for member in PublicViewType], "view.filter.operator": [member.value for member in ViewFilterOperator]},
13283
13517
  "details": {
13284
- "per_view_results": view_results,
13518
+ "view_result": deepcopy(view_result) if view_result is not None else None,
13285
13519
  "filter_mismatches": filter_mismatches,
13286
13520
  "query_condition_mismatches": query_condition_mismatches,
13287
13521
  "associated_resource_mismatches": associated_resource_mismatches,
@@ -13299,7 +13533,8 @@ class AiBuilderFacade:
13299
13533
  "http_status": first_failure.get("http_status"),
13300
13534
  "noop": noop,
13301
13535
  "warnings": (
13302
- (
13536
+ list(stale_warnings)
13537
+ + (
13303
13538
  [_warning("VIEW_FILTERS_UNVERIFIED", "view definitions may exist, but saved filter behavior is not fully verified")]
13304
13539
  if (filter_readback_pending or filter_mismatches)
13305
13540
  else []
@@ -13339,13 +13574,14 @@ class AiBuilderFacade:
13339
13574
  "action_buttons_verified": action_buttons_verified,
13340
13575
  "view_button_bindings_verified": view_action_button_bindings_verified,
13341
13576
  "views_read_unavailable": verified_views_unavailable,
13342
- "by_view": verification_by_view,
13577
+ "view": deepcopy(verification_result) if verification_result is not None else None,
13343
13578
  "custom_button_readback_pending": custom_button_readback_pending,
13344
13579
  "custom_button_readback_pending_entries": deepcopy(custom_button_readback_pending_entries),
13345
13580
  },
13346
13581
  "app_key": app_key,
13347
13582
  "app_name": app_name,
13348
- "views_diff": {"created": created, "updated": updated, "removed": removed, "failed": failed_views},
13583
+ "view_result": deepcopy(view_result) if view_result is not None else None,
13584
+ "views_diff": {"created": created, "updated": updated, "removed": removed, "failed": [failure_result]},
13349
13585
  "verified": verified and view_filters_verified and view_query_conditions_verified and view_associated_resources_verified and view_buttons_verified and action_buttons_verified and view_action_button_bindings_verified,
13350
13586
  "write_executed": bool(created or updated or removed or action_button_write_executed),
13351
13587
  "write_succeeded": bool(created or updated or removed or action_button_write_succeeded),
@@ -13353,6 +13589,7 @@ class AiBuilderFacade:
13353
13589
  }
13354
13590
  return finish_with_publish(response)
13355
13591
  warnings: list[dict[str, Any]] = []
13592
+ warnings.extend(stale_warnings)
13356
13593
  if filter_readback_pending or filter_mismatches:
13357
13594
  warnings.append(_warning("VIEW_FILTERS_UNVERIFIED", "view definitions were applied, but saved filter behavior is not fully verified"))
13358
13595
  if query_condition_readback_pending or query_condition_mismatches:
@@ -13408,50 +13645,351 @@ class AiBuilderFacade:
13408
13645
  ),
13409
13646
  "normalized_args": normalized_args,
13410
13647
  "missing_fields": [],
13411
- "allowed_values": {"view_types": [member.value for member in PublicViewType], "view.filter.operator": [member.value for member in ViewFilterOperator], "view.associated_resources.limit_type": ["all", "select"]},
13648
+ "allowed_values": {"view_types": [member.value for member in PublicViewType], "view.filter.operator": [member.value for member in ViewFilterOperator], "view.associated_resources.limit_type": ["all", "select"]},
13649
+ "details": {
13650
+ **({"filter_mismatches": filter_mismatches} if filter_mismatches else {}),
13651
+ **({"query_condition_mismatches": query_condition_mismatches} if query_condition_mismatches else {}),
13652
+ **({"associated_resource_mismatches": associated_resource_mismatches} if associated_resource_mismatches else {}),
13653
+ **({"button_mismatches": button_mismatches} if button_mismatches else {}),
13654
+ "view_result": deepcopy(view_result) if view_result is not None else None,
13655
+ **({"action_buttons_result": action_buttons_result} if action_button_intents else {}),
13656
+ **(
13657
+ {"custom_button_readback_pending": deepcopy(custom_button_readback_pending_entries)}
13658
+ if custom_button_readback_pending_entries
13659
+ else {}
13660
+ ),
13661
+ },
13662
+ "request_id": None,
13663
+ "suggested_next_call": None if all_verified else (action_buttons_retry_payload if action_buttons_failed and action_buttons_retry_payload else {"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}}),
13664
+ "noop": noop,
13665
+ "warnings": warnings,
13666
+ "verification": {
13667
+ "views_verified": verified,
13668
+ "view_filters_verified": view_filters_verified,
13669
+ "view_query_conditions_verified": view_query_conditions_verified,
13670
+ "view_associated_resources_verified": view_associated_resources_verified,
13671
+ "view_buttons_verified": view_buttons_verified,
13672
+ "action_buttons_verified": action_buttons_verified,
13673
+ "view_button_bindings_verified": view_action_button_bindings_verified,
13674
+ "views_read_unavailable": verified_views_unavailable,
13675
+ "filter_readback_pending": filter_readback_pending,
13676
+ "query_condition_readback_pending": query_condition_readback_pending,
13677
+ "associated_resource_readback_pending": associated_resource_readback_pending,
13678
+ "button_readback_pending": button_readback_pending,
13679
+ "delete_readback_pending": delete_readback_pending,
13680
+ "custom_button_readback_pending": custom_button_readback_pending,
13681
+ "custom_button_readback_pending_entries": deepcopy(custom_button_readback_pending_entries),
13682
+ "view": deepcopy(verification_result) if verification_result is not None else None,
13683
+ },
13684
+ "app_key": app_key,
13685
+ "app_name": app_name,
13686
+ "view_result": deepcopy(view_result) if view_result is not None else None,
13687
+ "views_diff": {"created": created, "updated": updated, "removed": removed, "failed": []},
13688
+ "verified": all_verified,
13689
+ "write_executed": bool(created or updated or removed or action_button_write_executed),
13690
+ "write_succeeded": bool(created or updated or removed or action_button_write_succeeded),
13691
+ "safe_to_retry": not bool(created or updated or removed or action_button_write_executed),
13692
+ }
13693
+ return finish_with_publish(response)
13694
+
13695
+ def app_views_schema(
13696
+ self,
13697
+ *,
13698
+ schema_version: str | None = None,
13699
+ schema_kind: str | None = None,
13700
+ ) -> JSONObject:
13701
+ """读取版本化视图声明 schema;detail kind 用于获取专项配置定义。"""
13702
+ normalized_args = {"schema_version": schema_version, "schema_kind": schema_kind}
13703
+ try:
13704
+ payload = views_schema_registry().schema(schema_version=schema_version, schema_kind=schema_kind)
13705
+ except ViewsContractError as error:
13706
+ return _failed(
13707
+ error.error_code,
13708
+ str(error.message),
13709
+ normalized_args=normalized_args,
13710
+ details={"reason_path": error.path or "", **(error.details or {})},
13711
+ suggested_next_call={"tool_name": "app_views_schema", "arguments": {"schema_kind": "summary"}},
13712
+ )
13713
+ return {
13714
+ "status": "success",
13715
+ "error_code": None,
13716
+ "recoverable": False,
13717
+ "message": "read views declaration schema",
13718
+ "normalized_args": normalized_args,
13719
+ "missing_fields": [],
13720
+ "allowed_values": {"schema_kind": payload["supportedSchemaKinds"]},
13721
+ "details": {},
13722
+ "request_id": None,
13723
+ "suggested_next_call": None,
13724
+ "warnings": [],
13725
+ "verification": {"schema_loaded": True},
13726
+ **payload,
13727
+ }
13728
+
13729
+ def app_views_get(
13730
+ self,
13731
+ *,
13732
+ profile: str,
13733
+ app_key: str,
13734
+ schema_version: str | None = None,
13735
+ schema_kind: str = "summary",
13736
+ ) -> JSONObject:
13737
+ """读取应用的 canonical 视图声明数组(summary 级投影或 complete 反向编译)。"""
13738
+ normalized_args = {"app_key": app_key, "schema_version": schema_version, "schema_kind": schema_kind}
13739
+ try:
13740
+ context = views_schema_registry().resolve_schema_version(schema_version)
13741
+ except ViewsContractError as error:
13742
+ return _failed(
13743
+ error.error_code,
13744
+ str(error.message),
13745
+ normalized_args=normalized_args,
13746
+ details={"reason_path": error.path or "", **(error.details or {})},
13747
+ suggested_next_call={"tool_name": "app_views_schema", "arguments": {"schema_kind": "summary"}},
13748
+ )
13749
+ try:
13750
+ views, _views_unavailable = self._load_views_result(profile=profile, app_key=app_key, tolerate_404=False)
13751
+ except (QingflowApiError, RuntimeError) as error:
13752
+ api_error = _coerce_api_error(error)
13753
+ return _failed_from_api_error(
13754
+ "VIEWS_READ_FAILED",
13755
+ api_error,
13756
+ normalized_args=normalized_args,
13757
+ details=_with_state_read_blocked_details({"app_key": app_key}, resource="views", error=api_error),
13758
+ suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
13759
+ )
13760
+ schema, _schema_source = self._read_schema_with_fallback(profile=profile, app_key=app_key)
13761
+ parsed_schema = _parse_schema(schema)
13762
+ # 读面 summary 面向 agent 展示字段名:从原始 schema 递归收集(同名不丢);
13763
+ # 系统列(queId<=5)用公开名映射,未知系统列以 queId 展示兜底
13764
+ name_by_que_id: dict[int, str] = {
13765
+ **{system_id: name for system_id, name in _SYSTEM_VIEW_QUE_NAMES.items()},
13766
+ **_collect_field_id_to_name(schema),
13767
+ }
13768
+ normalized_kind = str(schema_kind or "summary").strip().lower()
13769
+ if normalized_kind == "complete":
13770
+ return self._app_views_get_complete(
13771
+ profile=profile,
13772
+ app_key=app_key,
13773
+ views=views,
13774
+ parsed_schema=parsed_schema,
13775
+ normalized_args=normalized_args,
13776
+ schema_version=schema_version,
13777
+ )
13778
+ projected: list[dict[str, Any]] = []
13779
+ scope_map = {"ALL": "all", "ONESELF": "oneself", "DEPT": "dept", "SUBORDINATE": "subordinate", "CUSTOM": "custom"}
13780
+ for view in views if isinstance(views, list) else []:
13781
+ if not isinstance(view, dict):
13782
+ continue
13783
+ entry: dict[str, Any] = {
13784
+ "view_key": _extract_view_key(view),
13785
+ "name": _extract_view_name(view),
13786
+ "type": _normalize_view_type_name(view.get("viewgraphType") or view.get("type")),
13787
+ }
13788
+ columns = [name_by_que_id.get(que_id, str(que_id)) for que_id in view.get("viewgraphQueIds") or [] if isinstance(que_id, int)]
13789
+ if columns:
13790
+ entry["columns"] = columns
13791
+ group_que_id = _coerce_positive_int(view.get("groupQueId"))
13792
+ if group_que_id is not None and group_que_id in name_by_que_id:
13793
+ entry["group_by"] = name_by_que_id[group_que_id]
13794
+ gantt_config = view.get("viewgraphGanttConfigVO")
13795
+ if isinstance(gantt_config, dict):
13796
+ for source, target in (
13797
+ ("startTimeQueId", "start_field"),
13798
+ ("endTimeQueId", "end_field"),
13799
+ ("titleQueId", "title_field"),
13800
+ ):
13801
+ gantt_que_id = _coerce_positive_int(gantt_config.get(source))
13802
+ if gantt_que_id is not None and gantt_que_id in name_by_que_id:
13803
+ entry[target] = name_by_que_id[gantt_que_id]
13804
+ title_que_id = _coerce_positive_int(view.get("titleQue"))
13805
+ if title_que_id is not None and title_que_id in name_by_que_id:
13806
+ entry.setdefault("title_field", name_by_que_id[title_que_id])
13807
+ data_permission_type = view.get("dataPermissionType")
13808
+ if data_permission_type:
13809
+ entry["data_permission"] = {
13810
+ "type": "config" if str(data_permission_type).upper() == "CONFIG" else "custom",
13811
+ "scope": scope_map.get(str(view.get("dataScope") or "").upper(), "all"),
13812
+ }
13813
+ projected.append(entry)
13814
+ return {
13815
+ "status": "success",
13816
+ "error_code": None,
13817
+ "recoverable": False,
13818
+ "message": "read canonical views declarations",
13819
+ "normalized_args": normalized_args,
13820
+ "missing_fields": [],
13821
+ "allowed_values": {"view_types": [member.value for member in PublicViewType]},
13822
+ "details": {},
13823
+ "request_id": None,
13824
+ "suggested_next_call": None,
13825
+ "warnings": [],
13826
+ "verification": {"views_read": len(projected)},
13827
+ "schemaVersion": context.schema_version,
13828
+ "apiVersion": context.api_version,
13829
+ "latestVersion": views_schema_registry().latest_version,
13830
+ "app_key": app_key,
13831
+ "views": projected,
13832
+ }
13833
+
13834
+ def _app_views_get_complete(
13835
+ self,
13836
+ *,
13837
+ profile: str,
13838
+ app_key: str,
13839
+ views: Any,
13840
+ parsed_schema: dict[str, Any],
13841
+ normalized_args: JSONObject,
13842
+ schema_version: str | None,
13843
+ ) -> JSONObject:
13844
+ """complete 档 canonical:逐视图 GET /view/{key}/viewConfig → 反向编译。"""
13845
+ from .views_extract import extract_complete_view_declaration, summarize_gaps
13846
+
13847
+ fields_by_que_id = _collect_fields_by_que_id(parsed_schema["fields"])
13848
+ projected: list[dict[str, Any]] = []
13849
+ read_errors: list[dict[str, Any]] = []
13850
+ all_gaps: list[dict[str, Any]] = []
13851
+ for view in views if isinstance(views, list) else []:
13852
+ if not isinstance(view, dict):
13853
+ continue
13854
+ view_key = _extract_view_key(view)
13855
+ view_name = _extract_view_name(view)
13856
+ if not view_key:
13857
+ continue
13858
+ if _resolve_system_view_list_type(view_key=view_key, view_name=view_name) is not None and not _normalize_view_type_name(view.get("viewgraphType") or view.get("type")):
13859
+ # 系统视图(列表/申请页等,viewgraphKey 为固定数字 id):不在公开声明语义内,直接跳过。
13860
+ continue
13861
+ try:
13862
+ config = self.views.view_get_config(profile=profile, viewgraph_key=view_key).get("result") or {}
13863
+ except (QingflowApiError, RuntimeError) as error:
13864
+ api_error = _coerce_api_error(error)
13865
+ read_errors.append(
13866
+ {
13867
+ "view_key": view_key,
13868
+ "name": view_name,
13869
+ "error_code": "VIEW_CONFIG_READ_FAILED",
13870
+ "http_status": api_error.http_status,
13871
+ "message": str(api_error.message or error),
13872
+ }
13873
+ )
13874
+ continue
13875
+ if not isinstance(config, dict) or not config:
13876
+ read_errors.append({"view_key": view_key, "name": view_name, "error_code": "VIEW_CONFIG_EMPTY", "message": "viewConfig read returned no data"})
13877
+ continue
13878
+ declaration, gaps = extract_complete_view_declaration(config, fields_by_que_id=fields_by_que_id)
13879
+ if not declaration:
13880
+ read_errors.append(
13881
+ {
13882
+ "view_key": view_key,
13883
+ "name": view_name,
13884
+ "error_code": "VIEW_NOT_EXTRACTABLE",
13885
+ "message": "view configuration cannot be represented completely by the public contract; see gaps",
13886
+ "gaps": gaps,
13887
+ }
13888
+ )
13889
+ continue
13890
+ if gaps:
13891
+ all_gaps.append({"view_key": view_key, "gaps": gaps})
13892
+ projected.append(declaration)
13893
+ gap_summary: dict[str, int] = {}
13894
+ for item in all_gaps:
13895
+ for kind, count in summarize_gaps(item["gaps"]).items():
13896
+ gap_summary[kind] = gap_summary.get(kind, 0) + count
13897
+ context = views_schema_registry().resolve_schema_version(schema_version)
13898
+ return {
13899
+ "status": "success",
13900
+ "error_code": None,
13901
+ "recoverable": False,
13902
+ "message": "read canonical views declarations (complete)",
13903
+ "normalized_args": normalized_args,
13904
+ "missing_fields": [],
13905
+ "allowed_values": {"view_types": [member.value for member in PublicViewType]},
13412
13906
  "details": {
13413
- **({"filter_mismatches": filter_mismatches} if filter_mismatches else {}),
13414
- **({"query_condition_mismatches": query_condition_mismatches} if query_condition_mismatches else {}),
13415
- **({"associated_resource_mismatches": associated_resource_mismatches} if associated_resource_mismatches else {}),
13416
- **({"button_mismatches": button_mismatches} if button_mismatches else {}),
13417
- **({"action_buttons_result": action_buttons_result} if action_button_intents else {}),
13418
- **(
13419
- {"custom_button_readback_pending": deepcopy(custom_button_readback_pending_entries)}
13420
- if custom_button_readback_pending_entries
13421
- else {}
13422
- ),
13907
+ "extraction_gap_summary": gap_summary,
13908
+ "extraction_gaps": all_gaps[:50],
13909
+ "config_read_errors": read_errors[:50],
13910
+ "config_read_error_count": len(read_errors),
13423
13911
  },
13424
13912
  "request_id": None,
13425
- "suggested_next_call": None if all_verified else (action_buttons_retry_payload if action_buttons_failed and action_buttons_retry_payload else {"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}}),
13426
- "noop": noop,
13427
- "warnings": warnings,
13428
- "verification": {
13429
- "views_verified": verified,
13430
- "view_filters_verified": view_filters_verified,
13431
- "view_query_conditions_verified": view_query_conditions_verified,
13432
- "view_associated_resources_verified": view_associated_resources_verified,
13433
- "view_buttons_verified": view_buttons_verified,
13434
- "action_buttons_verified": action_buttons_verified,
13435
- "view_button_bindings_verified": view_action_button_bindings_verified,
13436
- "views_read_unavailable": verified_views_unavailable,
13437
- "filter_readback_pending": filter_readback_pending,
13438
- "query_condition_readback_pending": query_condition_readback_pending,
13439
- "associated_resource_readback_pending": associated_resource_readback_pending,
13440
- "button_readback_pending": button_readback_pending,
13441
- "delete_readback_pending": delete_readback_pending,
13442
- "custom_button_readback_pending": custom_button_readback_pending,
13443
- "custom_button_readback_pending_entries": deepcopy(custom_button_readback_pending_entries),
13444
- "by_view": verification_by_view,
13445
- },
13913
+ "suggested_next_call": None,
13914
+ "warnings": [],
13915
+ "verification": {"views_read": len(projected), "views_with_gaps": len(all_gaps), "read_errors": len(read_errors)},
13916
+ "schemaVersion": context.schema_version,
13917
+ "apiVersion": context.api_version,
13918
+ "latestVersion": views_schema_registry().latest_version,
13446
13919
  "app_key": app_key,
13447
- "app_name": app_name,
13448
- "views_diff": {"created": created, "updated": updated, "removed": removed, "failed": []},
13449
- "verified": all_verified,
13450
- "write_executed": bool(created or updated or removed or action_button_write_executed),
13451
- "write_succeeded": bool(created or updated or removed or action_button_write_succeeded),
13452
- "safe_to_retry": not bool(created or updated or removed or action_button_write_executed),
13920
+ "views": projected,
13921
+ }
13922
+
13923
+ def app_views_validate(
13924
+ self,
13925
+ *,
13926
+ declaration: JSONObject,
13927
+ schema_version: str | None = None,
13928
+ ) -> JSONObject:
13929
+ """纯离线校验一条视图声明;不访问后端。"""
13930
+ normalized_args = {"schema_version": schema_version}
13931
+ resolved_version = str(schema_version or (declaration.get("schema_version") if isinstance(declaration, dict) else None) or "").strip() or None
13932
+ try:
13933
+ context = views_schema_registry().resolve_schema_version(resolved_version)
13934
+ except ViewsContractError as error:
13935
+ return _failed(
13936
+ error.error_code,
13937
+ str(error.message),
13938
+ normalized_args=normalized_args,
13939
+ details={"reason_path": error.path or "", **(error.details or {})},
13940
+ suggested_next_call={"tool_name": "app_views_schema", "arguments": {"schema_kind": "complete"}},
13941
+ )
13942
+ issues: list[dict[str, Any]] = []
13943
+ batch_keys = ("views", "upsert_views", "upsertViews", "apps")
13944
+ if not isinstance(declaration, dict):
13945
+ issues.append({"path": "/", "code": "INVALID_SPEC", "message": "views declaration must be a JSON object"})
13946
+ elif any(key in declaration for key in batch_keys):
13947
+ issues.append(
13948
+ {
13949
+ "path": "/",
13950
+ "code": "VIEW_BATCH_CREATE_UNSUPPORTED",
13951
+ "message": "views schema validates one view declaration; batch envelopes are unsupported",
13952
+ }
13953
+ )
13954
+ else:
13955
+ declaration_item = {
13956
+ key: value
13957
+ for key, value in declaration.items()
13958
+ if key not in {"schema_version", "schemaVersion", "apiVersion"}
13959
+ }
13960
+ try:
13961
+ parsed = ViewUpsertPatch.model_validate(declaration_item)
13962
+ context.validate_declaration(compact_none_values(public_view_upsert_payload(parsed)))
13963
+ except ValidationError as exc:
13964
+ first_error = exc.errors()[0]
13965
+ issues.append(
13966
+ {
13967
+ "path": f"/{'/'.join(str(part) for part in first_error.get('loc', ()))}".rstrip("/"),
13968
+ "code": "INVALID_SPEC",
13969
+ "message": str(first_error.get("msg") or exc),
13970
+ }
13971
+ )
13972
+ except ViewsContractError as error:
13973
+ issues.append({"path": error.path or "/", "code": error.error_code, "message": str(error.message)})
13974
+ ok = not issues
13975
+ return {
13976
+ "status": "success" if ok else "failed",
13977
+ "error_code": None if ok else issues[0]["code"],
13978
+ "recoverable": True,
13979
+ "message": "views declaration is valid" if ok else "views declaration failed validation",
13980
+ "normalized_args": normalized_args,
13981
+ "missing_fields": [],
13982
+ "allowed_values": {},
13983
+ "details": {"issues": issues},
13984
+ "request_id": None,
13985
+ "suggested_next_call": None,
13986
+ "warnings": [],
13987
+ "verification": {"validated_entries": 1 if ok else 0},
13988
+ "ok": ok,
13989
+ "schemaVersion": context.schema_version,
13990
+ "apiVersion": context.api_version,
13991
+ "issues": issues,
13453
13992
  }
13454
- return finish_with_publish(response)
13455
13993
 
13456
13994
  def app_publish_verify(
13457
13995
  self,
@@ -15429,6 +15967,76 @@ class AiBuilderFacade:
15429
15967
  result = legacy_normalized or normalized_views
15430
15968
  return (result, False, None) if include_error else (result, False)
15431
15969
 
15970
+ def _set_view_pinned(
15971
+ self,
15972
+ *,
15973
+ profile: str,
15974
+ app_key: str,
15975
+ view_key: str,
15976
+ pinned: bool,
15977
+ ) -> dict[str, Any]:
15978
+ """Move one view between the application-level fixed and ordinary lists."""
15979
+ target_key = str(view_key or "").strip()
15980
+ if not target_key:
15981
+ raise ValueError("view_key is required to update the pinned state")
15982
+ response = self.views.view_list(profile=profile, app_key=app_key)
15983
+ raw_groups = response.get("result") if isinstance(response, dict) else None
15984
+ if not isinstance(raw_groups, list):
15985
+ raise ValueError("view list does not expose ordinal groups")
15986
+
15987
+ group_keys: dict[str, list[str]] = {
15988
+ "FIXED_VIEW_LIST": [],
15989
+ "VIEW_LIST": [],
15990
+ }
15991
+ for group in raw_groups:
15992
+ if not isinstance(group, dict):
15993
+ continue
15994
+ ordinal_type = str(group.get("ordinalType") or "").strip()
15995
+ if ordinal_type not in group_keys:
15996
+ continue
15997
+ view_list = group.get("viewList")
15998
+ if not isinstance(view_list, list):
15999
+ continue
16000
+ group_keys[ordinal_type] = [
16001
+ key
16002
+ for item in view_list
16003
+ if isinstance(item, dict)
16004
+ and (key := _extract_view_key(item))
16005
+ and key != target_key
16006
+ ]
16007
+
16008
+ target_group = "FIXED_VIEW_LIST" if pinned else "VIEW_LIST"
16009
+ group_keys[target_group].append(target_key)
16010
+ self.views.view_reorder(
16011
+ profile=profile,
16012
+ app_key=app_key,
16013
+ payload=[
16014
+ {"ordinalType": "FIXED_VIEW_LIST", "viewKeyList": group_keys["FIXED_VIEW_LIST"]},
16015
+ {"ordinalType": "VIEW_LIST", "viewKeyList": group_keys["VIEW_LIST"]},
16016
+ ],
16017
+ )
16018
+
16019
+ readback = self.views.view_list(profile=profile, app_key=app_key)
16020
+ readback_groups = readback.get("result") if isinstance(readback, dict) else None
16021
+ actual_group: str | None = None
16022
+ if isinstance(readback_groups, list):
16023
+ for group in readback_groups:
16024
+ if not isinstance(group, dict):
16025
+ continue
16026
+ ordinal_type = str(group.get("ordinalType") or "").strip()
16027
+ for item in group.get("viewList") or []:
16028
+ if isinstance(item, dict) and _extract_view_key(item) == target_key:
16029
+ actual_group = ordinal_type
16030
+ break
16031
+ if actual_group is not None:
16032
+ break
16033
+ return {
16034
+ "requested_pinned": pinned,
16035
+ "actual_pinned": actual_group == "FIXED_VIEW_LIST" if actual_group is not None else None,
16036
+ "verified": actual_group == target_group,
16037
+ "actual_ordinal_type": actual_group,
16038
+ }
16039
+
15432
16040
  def _load_workflow_result(
15433
16041
  self,
15434
16042
  *,
@@ -16856,7 +17464,7 @@ def _resolve_custom_button_view_button_ref(
16856
17464
  "reason_path": reason_path,
16857
17465
  "button_ref": button_ref,
16858
17466
  "message": "button_ref id does not exist in the current app draft or was removed in this apply",
16859
- "next_action": "call app_get and use custom_buttons[].button_id, or use a client_key from upsert_buttons",
17467
+ "next_action": "call app_get_buttons with button_text to find an existing button_id, or use a client_key from upsert_buttons",
16860
17468
  }
16861
17469
  ref_text = str(button_ref or "").strip()
16862
17470
  if ref_text in client_key_map:
@@ -19226,40 +19834,44 @@ def _chart_apply_batching_context(
19226
19834
  def _view_apply_failure_diagnostics(
19227
19835
  *,
19228
19836
  patch: ViewUpsertPatch,
19229
- current_fields_by_name: dict[str, dict[str, Any]],
19837
+ fields_by_que_id: dict[int, dict[str, Any]],
19230
19838
  backend_code: Any,
19231
19839
  operation_phase: str,
19232
19840
  ) -> dict[str, Any]:
19233
- references: list[tuple[str, str]] = []
19841
+ references: list[tuple[int, str]] = []
19234
19842
 
19235
- def add_reference(name: Any, source: str) -> None:
19236
- text = str(name or "").strip()
19237
- if text:
19238
- references.append((text, source))
19843
+ def add_reference(value: Any, source: str) -> None:
19844
+ que_id = _coerce_positive_int(value)
19845
+ if que_id is not None:
19846
+ references.append((que_id, source))
19239
19847
 
19240
19848
  for column in patch.columns:
19241
19849
  add_reference(column, "columns")
19242
- add_reference(patch.group_by, "group_by")
19243
- add_reference(patch.start_field, "start_field")
19244
- add_reference(patch.end_field, "end_field")
19245
- add_reference(patch.title_field, "title_field")
19246
- for rule in patch.filters:
19247
- add_reference(rule.field_name, "filters")
19850
+ add_reference(patch.group_que_id, "group_que_id")
19851
+ add_reference(patch.start_que_id, "start_que_id")
19852
+ add_reference(patch.end_que_id, "end_que_id")
19853
+ add_reference(patch.title_que_id, "title_que_id")
19854
+ for item in patch.filters:
19855
+ if isinstance(item, list):
19856
+ for rule in item:
19857
+ add_reference(getattr(rule, "que_id", None), "filters")
19858
+ else:
19859
+ add_reference(getattr(item, "que_id", None), "filters")
19248
19860
 
19249
- seen: set[str] = set()
19861
+ seen: set[tuple[int, str]] = set()
19250
19862
  field_entries: list[dict[str, Any]] = []
19251
- for field_name, source in references:
19252
- key = f"{field_name}\0{source}"
19863
+ for que_id, source in references:
19864
+ key = (que_id, source)
19253
19865
  if key in seen:
19254
19866
  continue
19255
19867
  seen.add(key)
19256
- field = current_fields_by_name.get(field_name) or {}
19868
+ field = fields_by_que_id.get(que_id) or {}
19257
19869
  field_entries.append(
19258
19870
  {
19259
- "field_name": field_name,
19871
+ "field_name": str(field.get("name") or que_id),
19260
19872
  "source": source,
19261
19873
  "field_id": field.get("field_id"),
19262
- "que_id": field.get("que_id"),
19874
+ "que_id": field.get("que_id") or que_id,
19263
19875
  "field_type": field.get("type"),
19264
19876
  "que_type": field.get("que_type"),
19265
19877
  "exists_in_schema": bool(field),
@@ -19277,7 +19889,7 @@ def _view_apply_failure_diagnostics(
19277
19889
  "recommended_minimal_retry": {
19278
19890
  "name": patch.name,
19279
19891
  "type": patch.type.value,
19280
- "columns": [field_entries[0]["field_name"]] if field_entries else [],
19892
+ "columns": [field_entries[0]["que_id"]] if field_entries else [],
19281
19893
  **({"filters": [rule.model_dump(mode="json", exclude_none=True) for rule in patch.filters]} if patch.filters else {}),
19282
19894
  },
19283
19895
  }
@@ -20356,6 +20968,8 @@ def _normalize_portal_view_type(view: dict[str, Any]) -> str:
20356
20968
  "ganttView": "ganttView",
20357
20969
  "hierarchy": "hierarchyView",
20358
20970
  "hierarchyView": "hierarchyView",
20971
+ "calendar": "calendarView",
20972
+ "calendarView": "calendarView",
20359
20973
  }
20360
20974
  return mapping.get(raw, "tableView")
20361
20975
 
@@ -21186,8 +21800,10 @@ def _infer_field_type(question: dict[str, Any]) -> str:
21186
21800
 
21187
21801
 
21188
21802
  def _parse_field(question: dict[str, Any], *, field_id_hint: str | None = None) -> dict[str, Any]:
21189
- name = str(question.get("queTitle") or "").strip()
21190
21803
  que_id = _coerce_positive_int(question.get("queId"))
21804
+ # 无标题字段用「字段{que_id}」兜底命名,与提取器(eval_replay/views get)对称,
21805
+ # 否则视图列引用无标题字段时 UNKNOWN_VIEW_FIELD
21806
+ name = str(question.get("queTitle") or "").strip() or (f"字段{que_id}" if que_id else "")
21191
21807
  que_type = _coerce_positive_int(question.get("queType"))
21192
21808
  field_type = _infer_field_type(question)
21193
21809
  field_id = field_id_hint or f"field_{que_id or _slugify(name, default='x')}"
@@ -21357,6 +21973,17 @@ def _parse_schema(schema: dict[str, Any]) -> dict[str, Any]:
21357
21973
  for question in inner_row or []:
21358
21974
  if not isinstance(question, dict):
21359
21975
  continue
21976
+ if _coerce_positive_int(question.get("queType")) == 24:
21977
+ # 嵌套段落:不注册段落本身,递归解析其 innerQuestions
21978
+ for nested_row in question.get("innerQuestions", []) or []:
21979
+ for nested_question in (nested_row if isinstance(nested_row, list) else [nested_row]):
21980
+ if not isinstance(nested_question, dict):
21981
+ continue
21982
+ nested_field = _parse_field(nested_question)
21983
+ if str(nested_field["name"] or "").strip():
21984
+ fields_by_name.setdefault(nested_field["name"], nested_field)
21985
+ labels.append(nested_field["name"])
21986
+ continue
21360
21987
  field = _parse_field(question)
21361
21988
  fields_by_name.setdefault(field["name"], field)
21362
21989
  labels.append(field["name"])
@@ -21382,8 +22009,16 @@ def _parse_schema(schema: dict[str, Any]) -> dict[str, Any]:
21382
22009
  for question in row:
21383
22010
  if not isinstance(question, dict):
21384
22011
  continue
22012
+ if _coerce_positive_int(question.get("queType")) == 24:
22013
+ # 同行混入段落容器:不注册段落本身(它的子字段在自己 row),跳过避免同名占位挡住真实字段
22014
+ continue
21385
22015
  field = _parse_field(question)
21386
- fields_by_name.setdefault(field["name"], field)
22016
+ existing = fields_by_name.get(field["name"])
22017
+ # 同名冲突:保留信息更丰富的那个(表格字段带 subfields,被同名普通字段覆盖会丢子字段引用面);
22018
+ # 但 queId 索引必须收录全部同名字段(_collect_fields_by_que_id 按 queId 消费,同名不同 id 各自有效)
22019
+ if existing is None or (not existing.get("subfields") and field.get("subfields")):
22020
+ fields_by_name[field["name"]] = field
22021
+
21387
22022
  labels.append(field["name"])
21388
22023
  if labels:
21389
22024
  root_rows.append(labels)
@@ -22608,6 +23243,23 @@ def _apply_field_mutation(field: dict[str, Any], mutation: Any) -> None:
22608
23243
  field.pop("_question_rebuild_required", None)
22609
23244
 
22610
23245
 
23246
+ class AmbiguousFieldSelector(ValueError):
23247
+ """name-only selector 命中多个同名字段。
23248
+
23249
+ 方案 A(duplicate-field-name):后端以 queId 为字段身份、允许同父级同名;
23250
+ name 命中多个字段时禁止静默取第一个,必须显式失败并给出候选 queId。
23251
+ compiler 包装层(app_form)把它转写为 AMBIGUOUS_FIELD_SELECTOR 错误码。
23252
+ """
23253
+
23254
+ def __init__(self, location: str, candidates: list[int]) -> None:
23255
+ self.location = location
23256
+ self.candidates = candidates
23257
+ super().__init__(
23258
+ f"{location} matches multiple fields with the same name; "
23259
+ f"use queId to disambiguate (candidate queIds: {sorted(candidates)})"
23260
+ )
23261
+
23262
+
22611
23263
  def _resolve_field_selector_with_uniqueness(
22612
23264
  *,
22613
23265
  fields: list[dict[str, Any]],
@@ -22623,7 +23275,12 @@ def _resolve_field_selector_with_uniqueness(
22623
23275
  name = str(selector.name or "").strip()
22624
23276
  matched = [field for field in fields if str(field.get("name") or "").strip() == name]
22625
23277
  if len(matched) > 1:
22626
- raise ValueError(f"{location} matched multiple fields with the same name; use field_id or que_id")
23278
+ candidate_ids = [
23279
+ ref
23280
+ for candidate in matched
23281
+ if (ref := _coerce_any_int(candidate.get("que_id"))) is not None
23282
+ ]
23283
+ raise AmbiguousFieldSelector(location, candidate_ids)
22627
23284
  else:
22628
23285
  matched = []
22629
23286
  if len(matched) != 1:
@@ -26066,7 +26723,11 @@ def _merge_view_summary_with_config(
26066
26723
  return summary
26067
26724
  if isinstance(config.get("auth"), dict):
26068
26725
  summary["visibility_summary"] = _visibility_summary(_public_visibility_from_member_auth(config.get("auth")))
26069
- legacy_columns = [str(value) for value in (summary.get("columns") or []) if str(value or "").strip()]
26726
+ legacy_columns = [
26727
+ _SYSTEM_VIEW_QUE_NAMES.get(value if isinstance(value, int) else None, str(value))
26728
+ for value in (summary.get("columns") or [])
26729
+ if str(value or "").strip()
26730
+ ]
26070
26731
  question_entries = _extract_view_question_entries(config.get("viewgraphQuestions"))
26071
26732
  canonical_question_entries = _extract_view_question_entries(question_list)
26072
26733
  question_entries_by_id = {
@@ -26212,7 +26873,7 @@ def _merge_view_summary_with_config(
26212
26873
  )
26213
26874
  summary["filters"] = _public_view_filter_groups_from_match_rules(
26214
26875
  config.get("viewgraphLimit"),
26215
- current_fields_by_name=field_lookup,
26876
+ fields_by_que_id=field_lookup,
26216
26877
  )
26217
26878
  config_enriched = True
26218
26879
  if any(key in config for key in ("asosChartVisible", "asosChartConfig", "asosChartIdList", "limitType")):
@@ -26455,12 +27116,16 @@ def _normalize_print_tpls(value: Any) -> list[dict[str, Any]]:
26455
27116
  items: list[dict[str, Any]] = []
26456
27117
  for item in value:
26457
27118
  if isinstance(item, dict):
26458
- tpl_id = item.get("printTplId")
27119
+ tpl_id = item.get("printKey")
27120
+ if tpl_id is None:
27121
+ tpl_id = item.get("printTplId")
26459
27122
  if tpl_id is None:
26460
27123
  tpl_id = item.get("templateId")
26461
27124
  if tpl_id is None:
26462
27125
  tpl_id = item.get("id")
26463
- tpl_name = item.get("printTplName")
27126
+ tpl_name = item.get("printName")
27127
+ if tpl_name is None:
27128
+ tpl_name = item.get("printTplName")
26464
27129
  if tpl_name is None:
26465
27130
  tpl_name = item.get("templateName")
26466
27131
  if tpl_name is None:
@@ -26904,7 +27569,7 @@ def _build_grouped_view_button_config(button_config_dtos: list[dict[str, Any]])
26904
27569
 
26905
27570
  def _build_view_button_dtos(
26906
27571
  *,
26907
- current_fields_by_name: dict[str, dict[str, Any]],
27572
+ fields_by_que_id: dict[str, dict[str, Any]],
26908
27573
  bindings: list[ViewButtonBindingPatch],
26909
27574
  valid_custom_button_ids: set[int],
26910
27575
  ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
@@ -26913,7 +27578,7 @@ def _build_view_button_dtos(
26913
27578
  for binding in bindings:
26914
27579
  dto, binding_issues = _serialize_view_button_binding(
26915
27580
  binding=binding,
26916
- current_fields_by_name=current_fields_by_name,
27581
+ fields_by_que_id=fields_by_que_id,
26917
27582
  valid_custom_button_ids=valid_custom_button_ids,
26918
27583
  )
26919
27584
  if binding_issues:
@@ -26926,7 +27591,7 @@ def _build_view_button_dtos(
26926
27591
  def _serialize_view_button_binding(
26927
27592
  *,
26928
27593
  binding: ViewButtonBindingPatch,
26929
- current_fields_by_name: dict[str, dict[str, Any]],
27594
+ fields_by_que_id: dict[str, dict[str, Any]],
26930
27595
  valid_custom_button_ids: set[int],
26931
27596
  allow_unverified_custom_button_id: bool = False,
26932
27597
  ) -> tuple[dict[str, Any], list[dict[str, Any]]]:
@@ -26941,10 +27606,11 @@ def _serialize_view_button_binding(
26941
27606
  "reason_path": "buttons[].button_id",
26942
27607
  "missing_fields": [],
26943
27608
  "details": {"button_id": binding.button_id},
27609
+ "next_action": "call app_get_buttons with the intended button_text to reuse an existing custom button; create only if absent via app_custom_buttons_apply",
26944
27610
  }
26945
27611
  ]
26946
27612
  translated_limits, limit_issues = _build_view_button_limit_groups(
26947
- current_fields_by_name=current_fields_by_name,
27613
+ fields_by_que_id=fields_by_que_id,
26948
27614
  groups=binding.button_limit,
26949
27615
  )
26950
27616
  if limit_issues:
@@ -26970,7 +27636,7 @@ def _serialize_view_button_binding(
26970
27636
 
26971
27637
  def _build_view_button_limit_groups(
26972
27638
  *,
26973
- current_fields_by_name: dict[str, dict[str, Any]],
27639
+ fields_by_que_id: dict[str, dict[str, Any]],
26974
27640
  groups: list[list[ViewFilterRulePatch]],
26975
27641
  ) -> tuple[list[list[dict[str, Any]]], list[dict[str, Any]]]:
26976
27642
  translated_groups: list[list[dict[str, Any]]] = []
@@ -26984,14 +27650,14 @@ def _build_view_button_limit_groups(
26984
27650
  raw_rule = raw_rule.model_dump(mode="json")
26985
27651
  if not isinstance(raw_rule, dict):
26986
27652
  continue
26987
- field_name = str(raw_rule.get("field_name") or "").strip()
26988
- field = current_fields_by_name.get(field_name)
27653
+ rule_que_id = _coerce_positive_int(raw_rule.get("que_id"))
27654
+ field = fields_by_que_id.get(rule_que_id) if rule_que_id is not None else None
26989
27655
  if field is None:
26990
27656
  issues.append(
26991
27657
  {
26992
27658
  "error_code": "UNKNOWN_VIEW_FIELD",
26993
- "missing_fields": [field_name] if field_name else [],
26994
- "reason_path": "buttons[].button_limit[].field_name",
27659
+ "missing_fields": [str(raw_rule.get("que_id") or "")],
27660
+ "reason_path": "buttons[].button_limit[].que_id",
26995
27661
  }
26996
27662
  )
26997
27663
  continue
@@ -27001,6 +27667,11 @@ def _build_view_button_limit_groups(
27001
27667
  issue["reason_path"] = "buttons[].button_limit[].values"
27002
27668
  issues.append(issue)
27003
27669
  continue
27670
+ stale_warning = translated_rule.pop("_stale_warning", None)
27671
+ if stale_warning is not None:
27672
+ stale_warning = deepcopy(stale_warning)
27673
+ stale_warning["reason_path"] = "buttons[].button_limit[].values"
27674
+ issues.append(stale_warning)
27004
27675
  translated_rules.append(translated_rule)
27005
27676
  if translated_rules:
27006
27677
  translated_groups.append(translated_rules)
@@ -27162,7 +27833,7 @@ def _custom_button_view_configs_from_view_summaries(views: list[dict[str, Any]])
27162
27833
  placement = _public_view_button_placement(entry.get("config_type")) or entry.get("placement")
27163
27834
  raw_button_limit = deepcopy(entry.get("button_limit") or [])
27164
27835
  public_button_limit = (
27165
- _public_view_filter_groups_from_match_rules(raw_button_limit, current_fields_by_name=field_lookup)
27836
+ _public_view_filter_groups_from_match_rules(raw_button_limit, fields_by_que_id=field_lookup)
27166
27837
  if raw_button_limit
27167
27838
  else []
27168
27839
  )
@@ -27360,6 +28031,11 @@ def _field_id_for_name(fields: list[dict[str, Any]], name: str) -> str:
27360
28031
  for field in fields:
27361
28032
  if field["name"] == name:
27362
28033
  return field["field_id"]
28034
+ # 表格子字段是视图列/筛选的合法引用面:顶层未命中时递归 subfields
28035
+ for field in fields:
28036
+ for sub in field.get("subfields") or []:
28037
+ if isinstance(sub, dict) and sub.get("name") == name:
28038
+ return str(sub.get("field_id") or f"field_{sub.get('que_id') or ''}")
27363
28039
  raise KeyError(name)
27364
28040
 
27365
28041
 
@@ -27459,7 +28135,7 @@ def _build_public_workflow_spec(*, nodes: list[dict[str, Any]], transitions: lis
27459
28135
 
27460
28136
  def _build_flow_condition_matrix(
27461
28137
  *,
27462
- current_fields_by_name: dict[str, dict[str, Any]],
28138
+ fields_by_que_id: dict[str, dict[str, Any]],
27463
28139
  node: dict[str, Any],
27464
28140
  ) -> tuple[list[list[dict[str, Any]]], list[dict[str, Any]]]:
27465
28141
  if str(node.get("type") or "") != "condition":
@@ -27477,7 +28153,7 @@ def _build_flow_condition_matrix(
27477
28153
  if not isinstance(raw_rule, dict):
27478
28154
  continue
27479
28155
  field_name = str(raw_rule.get("field_name") or "").strip()
27480
- field = current_fields_by_name.get(field_name)
28156
+ field = fields_by_que_id.get(field_name)
27481
28157
  if field is None:
27482
28158
  issues.append(
27483
28159
  {
@@ -27524,7 +28200,9 @@ def _translate_flow_condition_rule(*, field: dict[str, Any], rule: dict[str, Any
27524
28200
  base["judgeType"] = JUDGE_LESS_OR_EQUAL
27525
28201
  base["judgeValues"] = [_stringify_condition_value(values[0])]
27526
28202
  elif operator == "is_empty":
27527
- base["judgeType"] = JUDGE_EQUAL
28203
+ # 选项/成员/部门类字段的空值,后端历史形态是 EQUAL_ANY(9)+空值(该类字段用
28204
+ # ANY 语义选值,"没选任何值"也是 9 判型);文本/数字类才是 EQUAL(0)+空值
28205
+ base["judgeType"] = JUDGE_EQUAL_ANY if field_type in INCLUDE_ANY_FLOW_FIELD_TYPES or field_type in {FieldType.single_select.value} else JUDGE_EQUAL
27528
28206
  base["judgeValues"] = []
27529
28207
  elif operator == "not_empty":
27530
28208
  base["judgeType"] = JUDGE_UNEQUAL
@@ -27553,7 +28231,7 @@ def _empty_view_query_conditions_payload() -> dict[str, Any]:
27553
28231
 
27554
28232
  def _build_view_query_conditions_payload(
27555
28233
  *,
27556
- current_fields_by_name: dict[str, dict[str, Any]],
28234
+ fields_by_que_id: dict[str, dict[str, Any]],
27557
28235
  query_conditions: Any,
27558
28236
  ) -> tuple[dict[str, Any] | None, dict[str, Any] | None, list[dict[str, Any]]]:
27559
28237
  if query_conditions is None:
@@ -27593,14 +28271,14 @@ def _build_view_query_conditions_payload(
27593
28271
  compiled_row: list[int] = []
27594
28272
  for column_index, selector in enumerate(row):
27595
28273
  field, issue = _resolve_query_condition_field(
27596
- current_fields_by_name=current_fields_by_name,
28274
+ fields_by_que_id=fields_by_que_id,
27597
28275
  selector=selector,
27598
28276
  reason_path=f"query_conditions.rows[{row_index}][{column_index}]",
27599
28277
  )
27600
28278
  if issue:
27601
28279
  issues.append(issue)
27602
28280
  continue
27603
- que_id = _coerce_positive_int(field.get("que_id"))
28281
+ que_id = _coerce_any_int(field.get("que_id"))
27604
28282
  if que_id is not None:
27605
28283
  compiled_row.append(que_id)
27606
28284
  if compiled_row:
@@ -27611,18 +28289,24 @@ def _build_view_query_conditions_payload(
27611
28289
  "queryConditionStatus": enabled,
27612
28290
  "queryConditionExact": exact,
27613
28291
  "hideBeforeQueryCondition": hide_before_query,
27614
- "queryCondition": rows if enabled else [],
28292
+ # 后端 ViewgraphInfo.queryCondition 是 List<ViewgraphQueryConditionConfig>({title?,queIds}),
28293
+ # 不能发裸 [[queId]](fastjson expect {, actual [ → 发现 #7)
28294
+ "queryCondition": [{"queIds": row} for row in rows] if enabled else [],
27615
28295
  }
27616
28296
  return payload, _normalize_view_query_conditions_for_compare(payload), []
27617
28297
 
27618
28298
 
27619
28299
  def _resolve_query_condition_field(
27620
28300
  *,
27621
- current_fields_by_name: dict[str, dict[str, Any]],
28301
+ fields_by_que_id: dict[str, dict[str, Any]],
27622
28302
  selector: Any,
27623
28303
  reason_path: str,
27624
28304
  ) -> tuple[dict[str, Any], dict[str, Any] | None]:
27625
- raw = str(selector or "").strip()
28305
+ # queId 0(编号)是合法选择器,不能用 `selector or ""` 当成空值。
28306
+ if selector is None or (isinstance(selector, str) and not selector.strip()):
28307
+ raw = ""
28308
+ else:
28309
+ raw = str(selector).strip()
27626
28310
  if not raw:
27627
28311
  return {}, {
27628
28312
  "error_code": "QUERY_CONDITION_FIELD_NOT_FOUND",
@@ -27630,8 +28314,8 @@ def _resolve_query_condition_field(
27630
28314
  "missing_fields": [],
27631
28315
  "message": "query condition field selector cannot be empty",
27632
28316
  }
27633
- fields = [field for field in current_fields_by_name.values() if isinstance(field, dict)]
27634
- field = current_fields_by_name.get(raw)
28317
+ fields = [field for field in fields_by_que_id.values() if isinstance(field, dict)]
28318
+ field = fields_by_que_id.get(raw)
27635
28319
  if field is None:
27636
28320
  for candidate in fields:
27637
28321
  if raw == str(candidate.get("field_id") or "").strip():
@@ -27642,7 +28326,10 @@ def _resolve_query_condition_field(
27642
28326
  field = candidate
27643
28327
  break
27644
28328
  if field is None:
27645
- subfield_parent = _find_subtable_subfield_parent(current_fields_by_name=current_fields_by_name, selector=raw)
28329
+ system_field = _system_query_condition_field(raw)
28330
+ if system_field is not None:
28331
+ return system_field, None
28332
+ subfield_parent = _find_subtable_subfield_parent(fields_by_que_id=fields_by_que_id, selector=raw)
27646
28333
  if subfield_parent is not None:
27647
28334
  return {}, {
27648
28335
  "error_code": "INVALID_QUERY_CONDITION_FIELD",
@@ -27674,8 +28361,8 @@ def _resolve_query_condition_field(
27674
28361
  "supported_field_types": QUERY_CONDITION_SUPPORTED_FIELD_TYPES_PUBLIC,
27675
28362
  "unsupported_field_types": QUERY_CONDITION_UNSUPPORTED_FIELD_TYPES_PUBLIC,
27676
28363
  }
27677
- que_id = _coerce_positive_int(field.get("que_id"))
27678
- if que_id is None:
28364
+ que_id = _coerce_any_int(field.get("que_id"))
28365
+ if que_id is None or (que_id <= 0 and not _is_known_system_view_que_id(que_id)):
27679
28366
  return {}, {
27680
28367
  "error_code": "INVALID_QUERY_CONDITION_FIELD",
27681
28368
  "reason_path": reason_path,
@@ -27688,12 +28375,37 @@ def _resolve_query_condition_field(
27688
28375
  return field, None
27689
28376
 
27690
28377
 
28378
+ _SYSTEM_QUERY_CONDITION_NAME_ALIASES = {
28379
+ "申请时间": 2,
28380
+ "申请人": 1,
28381
+ }
28382
+
28383
+
28384
+ def _system_query_condition_field(selector: str) -> dict[str, Any] | None:
28385
+ """已知系统字段可直接作为查询条件,不必出现在表单业务字段索引里。"""
28386
+ que_id = _coerce_any_int(selector)
28387
+ if que_id is None or not _is_known_system_view_que_id(que_id):
28388
+ que_id = _SYSTEM_QUERY_CONDITION_NAME_ALIASES.get(selector)
28389
+ if que_id is None:
28390
+ for system_id, system_name in _SYSTEM_VIEW_QUE_NAMES.items():
28391
+ if selector == system_name:
28392
+ que_id = system_id
28393
+ break
28394
+ if que_id is None or not _is_known_system_view_que_id(que_id):
28395
+ return None
28396
+ return {
28397
+ "que_id": que_id,
28398
+ "name": _SYSTEM_VIEW_QUE_NAMES.get(que_id, selector),
28399
+ "type": "text",
28400
+ }
28401
+
28402
+
27691
28403
  def _find_subtable_subfield_parent(
27692
28404
  *,
27693
- current_fields_by_name: dict[str, dict[str, Any]],
28405
+ fields_by_que_id: dict[str, dict[str, Any]],
27694
28406
  selector: str,
27695
28407
  ) -> dict[str, Any] | None:
27696
- for field in current_fields_by_name.values():
28408
+ for field in fields_by_que_id.values():
27697
28409
  if not isinstance(field, dict) or str(field.get("type") or "") != FieldType.subtable.value:
27698
28410
  continue
27699
28411
  for subfield in field.get("subfields") or []:
@@ -27754,13 +28466,18 @@ def _normalize_view_query_conditions_for_compare(config: Any) -> dict[str, Any]:
27754
28466
  rows: list[list[int]] = []
27755
28467
  if isinstance(raw_rows, list):
27756
28468
  for raw_row in raw_rows:
28469
+ if isinstance(raw_row, dict):
28470
+ # ViewgraphQueryConditionConfig 形态({title?,queIds})
28471
+ raw_row = raw_row.get("queIds") or []
27757
28472
  if not isinstance(raw_row, list):
27758
28473
  continue
27759
- compiled_row = [
27760
- field_id
27761
- for field_id in (_coerce_positive_int(item) for item in raw_row)
27762
- if field_id is not None
27763
- ]
28474
+ compiled_row = []
28475
+ for item in raw_row:
28476
+ field_id = _coerce_any_int(item)
28477
+ if field_id is None:
28478
+ continue
28479
+ if field_id > 0 or _is_known_system_view_que_id(field_id):
28480
+ compiled_row.append(field_id)
27764
28481
  if compiled_row:
27765
28482
  rows.append(compiled_row)
27766
28483
  enabled = bool(config.get("queryConditionStatus", bool(rows)))
@@ -27816,11 +28533,18 @@ def _build_view_associated_resources_payload(
27816
28533
  ]
27817
28534
  available_by_id = _associated_resource_index(available_resources)
27818
28535
  if limit_type == "all":
28536
+ # limitType=ALL 时后端保留原 idList 不清(语义忽略但存储保留);
28537
+ # 透传声明的 id 列表,避免 apply 清空→回读 idList 不匹配→READBACK_MISMATCH
28538
+ all_ids = [
28539
+ item_id
28540
+ for item_id in (_coerce_positive_int(raw) for raw in raw_item_ids)
28541
+ if item_id is not None
28542
+ ]
27819
28543
  payload = {
27820
28544
  "asosChartVisible": True,
27821
28545
  "asosChartConfig": {
27822
28546
  "limitType": ASSOCIATED_RESOURCE_LIMIT_TYPE_ALL,
27823
- "asosChartIdList": [],
28547
+ "asosChartIdList": all_ids,
27824
28548
  },
27825
28549
  }
27826
28550
  expected = _extract_view_associated_resources_config(payload, available_resources=available_resources)
@@ -27841,26 +28565,19 @@ def _build_view_associated_resources_payload(
27841
28565
  ]
27842
28566
  item_ids.append(item_id)
27843
28567
  missing_ids = [item_id for item_id in item_ids if item_id not in available_by_id]
27844
- if missing_ids:
27845
- return None, None, [
27846
- {
27847
- "error_code": "ASSOCIATED_RESOURCE_NOT_FOUND",
27848
- "reason_path": "associated_resources.associated_item_ids",
27849
- "missing_fields": [],
27850
- "invalid_associated_item_ids": missing_ids,
27851
- "available_associated_item_ids": sorted(available_by_id),
27852
- "message": "associated_resource references ids that are not in the app-level associated resource pool",
27853
- "next_action": "prefer app_associated_resources_apply for associated report/view display; it can resolve associated_item_id, chart_id/chart_key, or view_key",
27854
- }
27855
- ]
28568
+ # 兼容存量脏引用:关联资源 id 指向闭包外/已下架的图表或视图时,剔除后继续写而非整视图失败;
28569
+ # 剔除明细记入 expected 供回读校验与响应展示
28570
+ resolvable_ids = [item_id for item_id in item_ids if item_id in available_by_id]
27856
28571
  payload = {
27857
28572
  "asosChartVisible": True,
27858
28573
  "asosChartConfig": {
27859
28574
  "limitType": ASSOCIATED_RESOURCE_LIMIT_TYPE_SELECT,
27860
- "asosChartIdList": item_ids,
28575
+ "asosChartIdList": resolvable_ids,
27861
28576
  },
27862
28577
  }
27863
28578
  expected = _extract_view_associated_resources_config(payload, available_resources=available_resources)
28579
+ if isinstance(expected, dict) and missing_ids:
28580
+ expected["dropped_unresolvable_associated_item_ids"] = missing_ids
27864
28581
  return payload, expected, []
27865
28582
 
27866
28583
 
@@ -28597,6 +29314,10 @@ _VIEW_PARTIAL_PATCH_KEY_ALIASES = {
28597
29314
  "view_key": "view_key",
28598
29315
  "viewKey": "view_key",
28599
29316
  "viewgraphKey": "view_key",
29317
+ "pinned": "pinned",
29318
+ "fixed": "pinned",
29319
+ "is_pinned": "pinned",
29320
+ "isPinned": "pinned",
28600
29321
  "type": "type",
28601
29322
  "view_type": "type",
28602
29323
  "viewType": "type",
@@ -28604,17 +29325,25 @@ _VIEW_PARTIAL_PATCH_KEY_ALIASES = {
28604
29325
  "column_names": "columns",
28605
29326
  "columnNames": "columns",
28606
29327
  "fields": "columns",
28607
- "group_by": "group_by",
28608
- "groupBy": "group_by",
29328
+ "group_que_id": "group_que_id",
29329
+ "groupQueId": "group_que_id",
29330
+ "group_by": "group_que_id",
29331
+ "groupBy": "group_que_id",
28609
29332
  "filters": "filters",
28610
29333
  "filter_rules": "filters",
28611
29334
  "filterRules": "filters",
28612
- "start_field": "start_field",
28613
- "startField": "start_field",
28614
- "end_field": "end_field",
28615
- "endField": "end_field",
28616
- "title_field": "title_field",
28617
- "titleField": "title_field",
29335
+ "start_que_id": "start_que_id",
29336
+ "startQueId": "start_que_id",
29337
+ "start_field": "start_que_id",
29338
+ "startField": "start_que_id",
29339
+ "end_que_id": "end_que_id",
29340
+ "endQueId": "end_que_id",
29341
+ "end_field": "end_que_id",
29342
+ "endField": "end_que_id",
29343
+ "title_que_id": "title_que_id",
29344
+ "titleQueId": "title_que_id",
29345
+ "title_field": "title_que_id",
29346
+ "titleField": "title_que_id",
28618
29347
  "buttons": "buttons",
28619
29348
  "visibility": "visibility",
28620
29349
  "query_conditions": "query_conditions",
@@ -28701,13 +29430,14 @@ def _normalize_view_partial_unset(raw_unset: Any, *, reason_path: str) -> tuple[
28701
29430
 
28702
29431
  _VIEW_PARTIAL_SET_KEYS = {
28703
29432
  "name",
29433
+ "pinned",
28704
29434
  "type",
28705
29435
  "columns",
28706
- "group_by",
29436
+ "group_que_id",
28707
29437
  "filters",
28708
- "start_field",
28709
- "end_field",
28710
- "title_field",
29438
+ "start_que_id",
29439
+ "end_que_id",
29440
+ "title_que_id",
28711
29441
  "buttons",
28712
29442
  "visibility",
28713
29443
  "query_conditions",
@@ -28733,6 +29463,108 @@ def _field_name_by_id(field_names_by_id: dict[int, str], field_id: Any) -> str |
28733
29463
  return name or None
28734
29464
 
28735
29465
 
29466
+ def _collect_fields_by_que_id(fields: list[dict[str, Any]], *, schema: dict[str, Any] | None = None) -> dict[int, dict[str, Any]]:
29467
+ """构建 queId→field 索引。
29468
+
29469
+ 传 schema 时直接从原始 formQues/baseQues 递归收集(同名冲突不丢字段——名字索引
29470
+ 只保留第一个,但同名不同 queId 的字段对 queId 引用各自有效);
29471
+ 不传 schema 时从解析后的 fields 列表递归 subfields 构建。
29472
+ """
29473
+ index: dict[int, dict[str, Any]] = {}
29474
+
29475
+ type_by_question_type = {v: k for k, v in QUESTION_TYPE_TO_FIELD_TYPE.items()}
29476
+
29477
+ def collect_raw(question: dict[str, Any]) -> None:
29478
+ que_id = _coerce_positive_int(question.get("queId"))
29479
+ if que_id is not None and que_id not in index:
29480
+ que_type = _coerce_positive_int(question.get("queType"))
29481
+ # 用 _parse_field 解析(含 options/option_details,与 _parse_schema 对齐);
29482
+ # 直收原始 dict 的 queTitle/queType 不够——选项字段需 options 才能解析值→id
29483
+ parsed = _parse_field(question)
29484
+ index[que_id] = parsed
29485
+ for key in ("subQuestions", "innerQuestions"):
29486
+ for child in question.get(key) or []:
29487
+ if isinstance(child, list):
29488
+ for inner in child:
29489
+ if isinstance(inner, dict):
29490
+ collect_raw(inner)
29491
+ elif isinstance(child, dict):
29492
+ collect_raw(child)
29493
+
29494
+ if schema is not None:
29495
+ for base_question in schema.get("baseQues", []) or []:
29496
+ if isinstance(base_question, dict):
29497
+ collect_raw(base_question)
29498
+ for line in schema.get("formQues", []) or []:
29499
+ for question in (line if isinstance(line, list) else [line]):
29500
+ if isinstance(question, dict):
29501
+ collect_raw(question)
29502
+ return index
29503
+
29504
+ def collect(field: dict[str, Any]) -> None:
29505
+ que_id = _coerce_positive_int(field.get("que_id"))
29506
+ if que_id is not None and que_id not in index:
29507
+ index[que_id] = field
29508
+ for sub in field.get("subfields") or []:
29509
+ if isinstance(sub, dict):
29510
+ collect(sub)
29511
+
29512
+ for field in fields:
29513
+ if isinstance(field, dict):
29514
+ collect(field)
29515
+ return index
29516
+
29517
+
29518
+ def _collect_field_id_to_name(schema: dict[str, Any]) -> dict[int, str]:
29519
+ """从 form schema 收集 queId→queTitle(含子字段/段落内字段;同名时各 id 独立映射)。"""
29520
+ id_to_name: dict[int, str] = {}
29521
+
29522
+ def collect(question: dict[str, Any]) -> None:
29523
+ que_id = _coerce_positive_int(question.get("queId"))
29524
+ if que_id is not None and que_id not in id_to_name:
29525
+ title = str(question.get("queTitle") or "").strip()
29526
+ if title:
29527
+ id_to_name[que_id] = title
29528
+ for key in ("subQuestions", "innerQuestions"):
29529
+ for child in question.get(key) or []:
29530
+ if isinstance(child, list):
29531
+ for inner in child:
29532
+ if isinstance(inner, dict):
29533
+ collect(inner)
29534
+ elif isinstance(child, dict):
29535
+ collect(child)
29536
+
29537
+ for base_question in schema.get("baseQues", []) or []:
29538
+ if isinstance(base_question, dict):
29539
+ collect(base_question)
29540
+ for line in schema.get("formQues", []) or []:
29541
+ for question in (line if isinstance(line, list) else [line]):
29542
+ if isinstance(question, dict):
29543
+ collect(question)
29544
+ return id_to_name
29545
+
29546
+
29547
+ def _resolve_field_ref_preserving(
29548
+ config: dict[str, Any],
29549
+ config_key: str,
29550
+ name: str | None,
29551
+ field_map: dict[str, int],
29552
+ id_to_name: dict[int, str],
29553
+ ) -> int | None:
29554
+ """同名字段歧义消解(字段引用类:card title/cover、board group 等)。
29555
+
29556
+ 契约按字段名引用,同 form 内同名字段时按名解析会取到错误的一个。
29557
+ UPDATE 场景读面原 queId 可用:若原 queId 的字段名与声明名一致,保留原 queId(保真);
29558
+ 只有声明名与原值名字不同(用户真改了字段)才按名重新解析。
29559
+ """
29560
+ if not name:
29561
+ return None
29562
+ original = _coerce_positive_int(config.get(config_key))
29563
+ if original is not None and id_to_name.get(original) == name:
29564
+ return original
29565
+ return field_map.get(name)
29566
+
29567
+
28736
29568
  def _view_upsert_payload_from_existing_view(
28737
29569
  *,
28738
29570
  config: dict[str, Any],
@@ -28742,25 +29574,28 @@ def _view_upsert_payload_from_existing_view(
28742
29574
  ) -> dict[str, Any]:
28743
29575
  view_type = _normalize_view_type_name(config.get("viewgraphType") or summary.get("type"))
28744
29576
  configured_columns = [
28745
- name
28746
- for name in (_field_name_by_id(field_names_by_id, item) for item in (config.get("viewgraphQueIds") or []))
28747
- if name and name not in _KNOWN_SYSTEM_VIEW_COLUMNS
29577
+ que_id
29578
+ for item in (config.get("viewgraphQueIds") or [])
29579
+ if (que_id := _coerce_positive_int(item)) is not None
29580
+ and (field_names_by_id.get(que_id) or "") not in _KNOWN_SYSTEM_VIEW_COLUMNS
28748
29581
  ]
28749
29582
  columns = [
28750
- str(name or "").strip()
28751
- for name in (summary.get("apply_columns") or summary.get("columns") or [])
28752
- if str(name or "").strip() and str(name or "").strip() not in _KNOWN_SYSTEM_VIEW_COLUMNS
29583
+ que_id
29584
+ for item in (summary.get("apply_column_ids") or summary.get("columns") or [])
29585
+ if (que_id := _coerce_positive_int(item)) is not None
29586
+ and (field_names_by_id.get(que_id) or "") not in _KNOWN_SYSTEM_VIEW_COLUMNS
28753
29587
  ]
28754
29588
  if configured_columns and (not columns or len(configured_columns) >= len(columns)):
28755
29589
  columns = configured_columns
28756
29590
  display_config = summary.get("display_config") if isinstance(summary.get("display_config"), dict) else {}
28757
- group_by = str(summary.get("group_by") or display_config.get("group_by") or "").strip() or _field_name_by_id(field_names_by_id, config.get("groupQueId"))
28758
- title_field = str(display_config.get("title_field") or "").strip() or _field_name_by_id(field_names_by_id, config.get("titleQue"))
29591
+ group_que_id = _coerce_positive_int(config.get("groupQueId") or summary.get("group_que_id") or display_config.get("group_que_id"))
29592
+ title_que_id = _coerce_positive_int(config.get("titleQue") or display_config.get("title_que_id"))
28759
29593
  gantt_config = config.get("viewgraphGanttConfigVO") if isinstance(config.get("viewgraphGanttConfigVO"), dict) else {}
28760
- start_field = _field_name_by_id(field_names_by_id, gantt_config.get("startTimeQueId")) if isinstance(gantt_config, dict) else None
28761
- end_field = _field_name_by_id(field_names_by_id, gantt_config.get("endTimeQueId")) if isinstance(gantt_config, dict) else None
28762
- gantt_title = _field_name_by_id(field_names_by_id, gantt_config.get("titleQueId")) if isinstance(gantt_config, dict) else None
29594
+ start_que_id = _coerce_positive_int(gantt_config.get("startTimeQueId"))
29595
+ end_que_id = _coerce_positive_int(gantt_config.get("endTimeQueId"))
29596
+ gantt_title_que_id = _coerce_positive_int(gantt_config.get("titleQueId"))
28763
29597
  query_conditions = _extract_view_query_conditions_config(config)
29598
+ query_conditions["rows"] = query_conditions.get("row_field_ids") or []
28764
29599
  query_conditions.pop("row_field_ids", None)
28765
29600
  associated_resources = _extract_view_associated_resources_config(config)
28766
29601
  payload: dict[str, Any] = {
@@ -28768,7 +29603,6 @@ def _view_upsert_payload_from_existing_view(
28768
29603
  "view_key": view_key,
28769
29604
  "type": view_type or "table",
28770
29605
  "columns": columns,
28771
- "group_by": group_by,
28772
29606
  "filters": [],
28773
29607
  "query_conditions": query_conditions,
28774
29608
  "associated_resources": {
@@ -28777,15 +29611,17 @@ def _view_upsert_payload_from_existing_view(
28777
29611
  "associated_item_ids": list(associated_resources.get("configured_associated_item_ids") or []),
28778
29612
  },
28779
29613
  }
28780
- if title_field:
28781
- payload["title_field"] = title_field
29614
+ if group_que_id is not None:
29615
+ payload["group_que_id"] = group_que_id
29616
+ if title_que_id is not None:
29617
+ payload["title_que_id"] = title_que_id
28782
29618
  if view_type == "gantt":
28783
- if start_field:
28784
- payload["start_field"] = start_field
28785
- if end_field:
28786
- payload["end_field"] = end_field
28787
- if gantt_title:
28788
- payload["title_field"] = gantt_title
29619
+ if start_que_id is not None:
29620
+ payload["start_que_id"] = start_que_id
29621
+ if end_que_id is not None:
29622
+ payload["end_que_id"] = end_que_id
29623
+ if gantt_title_que_id is not None:
29624
+ payload["title_que_id"] = gantt_title_que_id
28789
29625
  return payload
28790
29626
 
28791
29627
 
@@ -28796,7 +29632,7 @@ def _first_present(mapping: dict[str, Any], *keys: str) -> Any:
28796
29632
  return None
28797
29633
 
28798
29634
 
28799
- def _build_view_create_payload(
29635
+ def _build_view_creation_payload(
28800
29636
  *,
28801
29637
  app_key: str,
28802
29638
  base_info: dict[str, Any],
@@ -28804,23 +29640,24 @@ def _build_view_create_payload(
28804
29640
  patch: ViewUpsertPatch,
28805
29641
  ordinal: int,
28806
29642
  view_filters: list[list[dict[str, Any]]] | None,
28807
- current_fields_by_name: dict[str, dict[str, Any]],
29643
+ fields_by_que_id: dict[str, dict[str, Any]],
28808
29644
  auth_override: dict[str, Any] | None = None,
28809
29645
  explicit_button_dtos: list[dict[str, Any]] | None = None,
28810
29646
  query_condition_payload: dict[str, Any] | None = None,
28811
29647
  associated_resources_payload: dict[str, Any] | None = None,
29648
+ hierarchy_filter_groups: list[list[dict[str, Any]]] | None = None,
28812
29649
  ) -> JSONObject:
28813
29650
  entity = _entity_spec_from_app(base_info=base_info, schema=schema, views=None)
28814
29651
  parsed_schema = _parse_schema(schema)
28815
- visible_field_names = _resolve_view_visible_field_names(patch)
28816
- field_ids = [_field_id_for_name(parsed_schema["fields"], name) for name in visible_field_names]
29652
+ visible_que_ids = _resolve_view_visible_que_ids(patch)
29653
+ field_ids = [f"field_{que}" for que in visible_que_ids]
28817
29654
  gantt_config = _build_public_gantt_payload(parsed_schema["fields"], extract_field_map(schema), patch)
28818
29655
  view_spec = ViewSpec(
28819
29656
  view_id=_slugify(patch.name, default=f"view_{uuid4().hex[:6]}"),
28820
29657
  name=patch.name,
28821
29658
  type=patch.type.value,
28822
29659
  field_ids=field_ids,
28823
- group_by_field_id=_field_id_for_name(_parse_schema(schema)["fields"], patch.group_by) if patch.group_by else None,
29660
+ group_by_field_id=f"field_{patch.group_que_id}" if patch.group_que_id else None,
28824
29661
  config=gantt_config,
28825
29662
  )
28826
29663
  from ..solution.spec_models import EntitySpec
@@ -28829,7 +29666,6 @@ def _build_view_create_payload(
28829
29666
  compiled = compile_views(EntitySpec.model_validate({**entity, "views": [view_spec.model_dump(mode="json")]}))[0]
28830
29667
  field_map = extract_field_map(schema)
28831
29668
  payload = deepcopy(compiled["create_payload"])
28832
- visible_que_ids = [field_map[field_name] for field_name in visible_field_names if field_name in field_map]
28833
29669
  payload["appKey"] = app_key
28834
29670
  payload["ordinal"] = ordinal
28835
29671
  payload["viewgraphQueIds"] = visible_que_ids
@@ -28839,19 +29675,27 @@ def _build_view_create_payload(
28839
29675
  payload.setdefault("viewgraphSorts", [{"queId": 0, "beingSortAscend": True, "queType": 8}])
28840
29676
  if patch.type.value in {"card", "board", "gantt"}:
28841
29677
  payload["beingShowTitleQue"] = True
28842
- payload["titleQue"] = gantt_config.get("titleQueId") or (visible_que_ids[0] if visible_que_ids else None)
29678
+ payload["titleQue"] = (
29679
+ _declared_title_que_id(patch)
29680
+ or gantt_config.get("titleQueId")
29681
+ or (visible_que_ids[0] if visible_que_ids else None)
29682
+ )
28843
29683
  if patch.type.value == "board":
28844
- payload["groupQueId"] = field_map.get(patch.group_by or "")
29684
+ payload["groupQueId"] = patch.group_que_id
28845
29685
  return _hydrate_view_backend_payload(
28846
29686
  payload=payload,
28847
29687
  view_type=patch.type.value,
28848
29688
  visible_que_id_values=visible_que_ids,
28849
- group_que_id=field_map.get(patch.group_by or "") if patch.group_by else None,
29689
+ group_que_id=patch.group_que_id,
28850
29690
  view_filters=view_filters,
28851
29691
  gantt_payload=gantt_config,
28852
29692
  button_config_dtos=explicit_button_dtos,
28853
29693
  query_condition_payload=query_condition_payload,
28854
29694
  associated_resources_payload=associated_resources_payload,
29695
+ patch=patch,
29696
+ field_map=field_map,
29697
+ fields_by_que_id=fields_by_que_id,
29698
+ hierarchy_filter_groups=hierarchy_filter_groups,
28855
29699
  )
28856
29700
 
28857
29701
 
@@ -28989,11 +29833,28 @@ def _normalize_view_type_name(value: Any) -> str:
28989
29833
  return "gantt"
28990
29834
  if "board" in normalized:
28991
29835
  return "board"
29836
+ if "hierarchy" in normalized:
29837
+ return "hierarchy"
29838
+ if "calendar" in normalized:
29839
+ return "calendar"
28992
29840
  if "card" in normalized:
28993
29841
  return "card"
28994
29842
  return "table"
28995
29843
 
28996
29844
 
29845
+ def _declared_title_que_id(patch: ViewUpsertPatch) -> int | None:
29846
+ """声明优先的标题字段解析:类型专属配置(card/gantt)→ 顶层 title_que_id。
29847
+
29848
+ 存量视图的标题字段可能不在展示列集内(如系统字段/独立引用),不得用「第一列」
29849
+ 覆盖声明值——第一列只作为无声明时的回退(#148 r2 一致率 titleQue 26 例根因)。
29850
+ """
29851
+ if patch.card is not None and patch.card.title_que_id:
29852
+ return patch.card.title_que_id
29853
+ if patch.gantt is not None and patch.gantt.title_que_id:
29854
+ return patch.gantt.title_que_id
29855
+ return patch.title_que_id or None
29856
+
29857
+
28997
29858
  def _build_view_update_payload(
28998
29859
  *,
28999
29860
  views: ViewTools,
@@ -29002,20 +29863,20 @@ def _build_view_update_payload(
29002
29863
  schema: dict[str, Any],
29003
29864
  patch: ViewUpsertPatch,
29004
29865
  view_filters: list[list[dict[str, Any]]] | None,
29005
- current_fields_by_name: dict[str, dict[str, Any]],
29866
+ fields_by_que_id: dict[str, dict[str, Any]],
29006
29867
  auth_override: dict[str, Any] | None = None,
29007
29868
  explicit_button_dtos: list[dict[str, Any]] | None = None,
29008
29869
  query_condition_payload: dict[str, Any] | None = None,
29009
29870
  associated_resources_payload: dict[str, Any] | None = None,
29871
+ hierarchy_filter_groups: list[list[dict[str, Any]]] | None = None,
29010
29872
  ) -> JSONObject:
29011
29873
  config_response = views.view_get_config(profile=profile, viewgraph_key=source_viewgraph_key)
29012
29874
  config = config_response.get("result") if isinstance(config_response.get("result"), dict) else {}
29013
29875
  payload = deepcopy(config)
29014
29876
  parsed_schema = _parse_schema(schema)
29015
29877
  field_map = extract_field_map(schema)
29016
- visible_field_names = _resolve_view_visible_field_names(patch)
29017
- visible_que_ids = [_field_id_for_name(parsed_schema["fields"], name) for name in visible_field_names]
29018
- visible_que_id_values = [field_map[name] for name in visible_field_names if name in field_map]
29878
+ visible_que_ids = _resolve_view_visible_que_ids(patch)
29879
+ visible_que_id_values = list(visible_que_ids)
29019
29880
  gantt_payload = _build_public_gantt_payload(parsed_schema["fields"], field_map, patch)
29020
29881
 
29021
29882
  for key in (
@@ -29037,8 +29898,25 @@ def _build_view_update_payload(
29037
29898
  payload["auth"] = deepcopy(auth_override if isinstance(auth_override, dict) else payload.get("auth") or default_member_auth())
29038
29899
  if "viewName" in payload:
29039
29900
  payload["viewName"] = patch.name
29040
- payload["viewgraphQueIds"] = visible_que_id_values
29041
- payload["viewgraphQuestions"] = _build_viewgraph_questions(schema, visible_que_id_values)
29901
+ # 保留系统列在业务列之间的位置;业务列顺序和增删仍由声明决定。
29902
+ existing_que_ids = dict.fromkeys(
29903
+ que_id
29904
+ for que_id in (_coerce_any_int(raw) for raw in (config.get("viewgraphQueIds") or []))
29905
+ if que_id is not None
29906
+ )
29907
+ business_que_ids = iter(que_id for que_id in visible_que_id_values if que_id > 5)
29908
+ merged_que_ids: list[int] = []
29909
+ for que_id in existing_que_ids:
29910
+ if que_id <= 5:
29911
+ merged_que_ids.append(que_id)
29912
+ elif que_id in visible_que_id_values:
29913
+ merged_que_ids.append(next(business_que_ids))
29914
+ merged_que_ids.extend(business_que_ids)
29915
+ merged_que_ids.extend(que_id for que_id in visible_que_id_values if que_id <= 5 and que_id not in merged_que_ids)
29916
+ payload["viewgraphQueIds"] = merged_que_ids
29917
+ payload["viewgraphQuestions"] = _build_viewgraph_questions(
29918
+ schema, merged_que_ids, existing_by_que_id=_flatten_viewgraph_questions(config.get("viewgraphQuestions"))
29919
+ )
29042
29920
  payload.setdefault("sortType", "defaultSort")
29043
29921
  payload.setdefault("viewgraphSorts", [{"queId": 0, "beingSortAscend": True, "queType": 8}])
29044
29922
  payload.setdefault("beingPinNavigate", True)
@@ -29062,31 +29940,93 @@ def _build_view_update_payload(
29062
29940
  elif normalized_type == "card":
29063
29941
  payload["viewgraphType"] = "cardView"
29064
29942
  payload["beingShowTitleQue"] = True
29065
- payload["titleQue"] = visible_que_id_values[0] if visible_que_id_values else None
29943
+ payload["titleQue"] = _declared_title_que_id(patch) or (visible_que_id_values[0] if visible_que_id_values else None)
29066
29944
  payload.pop("groupQueId", None)
29067
29945
  elif normalized_type == "board":
29068
29946
  payload["viewgraphType"] = "boardView"
29069
29947
  payload["beingShowTitleQue"] = True
29070
- payload["titleQue"] = visible_que_id_values[0] if visible_que_id_values else None
29071
- payload["groupQueId"] = field_map.get(patch.group_by or "")
29948
+ payload["titleQue"] = _declared_title_que_id(patch) or (visible_que_id_values[0] if visible_que_id_values else None)
29949
+ payload["groupQueId"] = patch.group_que_id
29072
29950
  elif normalized_type == "gantt":
29073
29951
  payload["viewgraphType"] = "ganttView"
29074
29952
  payload["beingShowTitleQue"] = True
29075
- payload["titleQue"] = gantt_payload.get("titleQueId") or (visible_que_id_values[0] if visible_que_id_values else None)
29953
+ payload["titleQue"] = _declared_title_que_id(patch) or gantt_payload.get("titleQueId") or (visible_que_id_values[0] if visible_que_id_values else None)
29954
+ payload.pop("groupQueId", None)
29955
+ elif normalized_type == "hierarchy":
29956
+ payload["viewgraphType"] = "hierarchyView"
29957
+ payload["beingShowTitleQue"] = True
29958
+ payload.pop("groupQueId", None)
29959
+ elif normalized_type == "calendar":
29960
+ payload["viewgraphType"] = "calendarView"
29961
+ payload["beingShowTitleQue"] = True
29962
+ payload.pop("groupQueId", None)
29963
+ elif normalized_type == "hierarchy":
29964
+ payload["viewgraphType"] = "hierarchyView"
29965
+ payload["beingShowTitleQue"] = True
29966
+ payload.pop("groupQueId", None)
29967
+ elif normalized_type == "calendar":
29968
+ payload["viewgraphType"] = "calendarView"
29969
+ payload["beingShowTitleQue"] = True
29076
29970
  payload.pop("groupQueId", None)
29077
29971
  return _hydrate_view_backend_payload(
29078
29972
  payload=payload,
29079
29973
  view_type=normalized_type,
29080
29974
  visible_que_id_values=visible_que_id_values,
29081
- group_que_id=field_map.get(patch.group_by or "") if patch.group_by else None,
29975
+ group_que_id=patch.group_que_id,
29082
29976
  view_filters=view_filters,
29083
29977
  gantt_payload=gantt_payload,
29084
29978
  button_config_dtos=button_config_dtos,
29085
29979
  query_condition_payload=query_condition_payload,
29086
29980
  associated_resources_payload=associated_resources_payload,
29981
+ patch=patch,
29982
+ field_map=field_map,
29983
+ fields_by_que_id=fields_by_que_id,
29984
+ hierarchy_filter_groups=hierarchy_filter_groups,
29985
+ schema=schema,
29087
29986
  )
29088
29987
 
29089
29988
 
29989
+ # 系统列 queId→公开名(后端语义;读面展示与 viewgraphQueIds 透传时的名称解析)
29990
+ _SYSTEM_VIEW_QUE_NAMES = {
29991
+ 0: "编号",
29992
+ 1: "提交人",
29993
+ 2: "提交时间",
29994
+ 3: "更新时间",
29995
+ 4: "流程标题",
29996
+ 5: "当前流程状态",
29997
+ -17: "数据ID",
29998
+ -19: "删除时间",
29999
+ -20: "删除人",
30000
+ -21: "处理节点",
30001
+ -22: "抄送节点",
30002
+ -23: "处理时间",
30003
+ -24: "抄送时间",
30004
+ -25: "接收时间",
30005
+ }
30006
+
30007
+ _SYSTEM_VIEW_QUE_IDS = frozenset(
30008
+ {
30009
+ *_SYSTEM_VIEW_QUE_NAMES,
30010
+ -1,
30011
+ -2,
30012
+ -3,
30013
+ -4,
30014
+ -5,
30015
+ -6,
30016
+ -7,
30017
+ -8,
30018
+ -18,
30019
+ -30,
30020
+ -31,
30021
+ }
30022
+ )
30023
+
30024
+
30025
+ def _is_known_system_view_que_id(value: Any) -> bool:
30026
+ que_id = _coerce_any_int(value)
30027
+ return que_id in _SYSTEM_VIEW_QUE_IDS if que_id is not None else False
30028
+
30029
+
29090
30030
  _KNOWN_SYSTEM_VIEW_COLUMNS = {
29091
30031
  "编号",
29092
30032
  "当前流程状态",
@@ -29110,16 +30050,16 @@ def _build_minimal_view_payload(
29110
30050
  patch: ViewUpsertPatch,
29111
30051
  ordinal: int,
29112
30052
  view_filters: list[list[dict[str, Any]]] | None,
29113
- current_fields_by_name: dict[str, dict[str, Any]],
30053
+ fields_by_que_id: dict[str, dict[str, Any]],
29114
30054
  auth_override: dict[str, Any] | None = None,
29115
30055
  explicit_button_dtos: list[dict[str, Any]] | None = None,
29116
30056
  query_condition_payload: dict[str, Any] | None = None,
29117
30057
  associated_resources_payload: dict[str, Any] | None = None,
30058
+ hierarchy_filter_groups: list[list[dict[str, Any]]] | None = None,
29118
30059
  ) -> JSONObject:
29119
30060
  field_map = extract_field_map(schema)
29120
30061
  parsed_schema = _parse_schema(schema)
29121
- visible_field_names = _resolve_view_visible_field_names(patch)
29122
- visible_que_id_values = [field_map[name] for name in visible_field_names if name in field_map]
30062
+ visible_que_id_values = _resolve_view_visible_que_ids(patch)
29123
30063
  gantt_payload = _build_public_gantt_payload(parsed_schema["fields"], field_map, patch)
29124
30064
  payload: JSONObject = {
29125
30065
  "appKey": app_key,
@@ -29129,6 +30069,8 @@ def _build_minimal_view_payload(
29129
30069
  "card": "cardView",
29130
30070
  "board": "boardView",
29131
30071
  "gantt": "ganttView",
30072
+ "hierarchy": "hierarchyView",
30073
+ "calendar": "calendarView",
29132
30074
  }[patch.type.value],
29133
30075
  "ordinal": ordinal,
29134
30076
  "viewgraphQueIds": visible_que_id_values,
@@ -29139,17 +30081,21 @@ def _build_minimal_view_payload(
29139
30081
  payload["beingShowTitleQue"] = True
29140
30082
  payload["titleQue"] = gantt_payload.get("titleQueId") or (visible_que_id_values[0] if visible_que_id_values else None)
29141
30083
  if patch.type.value == "board":
29142
- payload["groupQueId"] = field_map.get(patch.group_by or "")
30084
+ payload["groupQueId"] = patch.group_que_id
29143
30085
  return _hydrate_view_backend_payload(
29144
30086
  payload=payload,
29145
30087
  view_type=patch.type.value,
29146
30088
  visible_que_id_values=visible_que_id_values,
29147
- group_que_id=field_map.get(patch.group_by or "") if patch.group_by else None,
30089
+ group_que_id=patch.group_que_id,
29148
30090
  view_filters=view_filters,
29149
30091
  gantt_payload=gantt_payload,
29150
30092
  button_config_dtos=explicit_button_dtos,
29151
30093
  query_condition_payload=query_condition_payload,
29152
30094
  associated_resources_payload=associated_resources_payload,
30095
+ patch=patch,
30096
+ field_map=field_map,
30097
+ fields_by_que_id=fields_by_que_id,
30098
+ hierarchy_filter_groups=hierarchy_filter_groups,
29153
30099
  )
29154
30100
 
29155
30101
 
@@ -29164,6 +30110,11 @@ def _hydrate_view_backend_payload(
29164
30110
  button_config_dtos: list[dict[str, Any]] | None = None,
29165
30111
  query_condition_payload: dict[str, Any] | None = None,
29166
30112
  associated_resources_payload: dict[str, Any] | None = None,
30113
+ patch: ViewUpsertPatch | None = None,
30114
+ field_map: dict[str, int] | None = None,
30115
+ fields_by_que_id: dict[str, dict[str, Any]] | None = None,
30116
+ hierarchy_filter_groups: list[list[dict[str, Any]]] | None = None,
30117
+ schema: dict[str, Any] | None = None,
29167
30118
  ) -> JSONObject:
29168
30119
  data = deepcopy(payload)
29169
30120
  data.setdefault("beingPinNavigate", True)
@@ -29233,26 +30184,239 @@ def _hydrate_view_backend_payload(
29233
30184
  data["titleQue"] = (gantt_payload or {}).get("titleQueId") or (visible_que_id_values[0] if visible_que_id_values else None)
29234
30185
  data["groupQueId"] = None
29235
30186
  data["viewgraphGanttConfigVO"] = deepcopy(gantt_payload) if gantt_payload else None
30187
+ elif view_type == "hierarchy":
30188
+ data["viewgraphType"] = "hierarchyView"
30189
+ data["beingShowTitleQue"] = True
30190
+ data["groupQueId"] = None
30191
+ data["viewgraphGanttConfigVO"] = None
30192
+ elif view_type == "calendar":
30193
+ data["viewgraphType"] = "calendarView"
30194
+ data["beingShowTitleQue"] = True
30195
+ data["groupQueId"] = None
30196
+ data["viewgraphGanttConfigVO"] = None
29236
30197
  if view_filters is not None:
29237
30198
  data["viewgraphLimit"] = deepcopy(view_filters)
29238
30199
  data["viewgraphLimitType"] = 1
29239
30200
  data["viewgraphLimitFormula"] = ""
29240
30201
  data["dataPermissionType"] = "CUSTOM"
29241
30202
  data["dataScope"] = "CUSTOM" if view_filters else "ALL"
30203
+ if patch is not None:
30204
+ _apply_view_config_groups(
30205
+ data,
30206
+ patch=patch,
30207
+ view_type=view_type,
30208
+ visible_que_id_values=visible_que_id_values,
30209
+ field_map=field_map or {},
30210
+ fields_by_que_id=fields_by_que_id or {},
30211
+ hierarchy_filter_groups=hierarchy_filter_groups,
30212
+ config=payload if isinstance(payload, dict) else None,
30213
+ id_to_name=_collect_field_id_to_name(schema) if isinstance(schema, dict) else None,
30214
+ )
29242
30215
  _apply_view_query_conditions_payload(data, query_condition_payload)
29243
30216
  _apply_view_associated_resources_payload(data, associated_resources_payload)
29244
30217
  return data
29245
30218
 
29246
30219
 
29247
- def _resolve_view_visible_field_names(patch: ViewUpsertPatch) -> list[str]:
29248
- ordered: list[str] = []
29249
- for value in [*patch.columns, patch.title_field, patch.start_field, patch.end_field, patch.group_by]:
29250
- name = str(value or "").strip()
29251
- if name and name not in _KNOWN_SYSTEM_VIEW_COLUMNS and name not in ordered:
29252
- ordered.append(name)
30220
+ _DATA_PERMISSION_SCOPE_MAP = {
30221
+ "all": "ALL",
30222
+ "oneself": "ONESELF",
30223
+ "dept": "DEPT",
30224
+ "subordinate": "SUBORDINATE",
30225
+ "custom": "CUSTOM",
30226
+ }
30227
+
30228
+
30229
+ def _apply_view_config_groups(
30230
+ data: JSONObject,
30231
+ *,
30232
+ patch: ViewUpsertPatch,
30233
+ view_type: str,
30234
+ visible_que_id_values: list[int],
30235
+ field_map: dict[str, int],
30236
+ fields_by_que_id: dict[str, dict[str, Any]],
30237
+ hierarchy_filter_groups: list[list[dict[str, Any]]] | None,
30238
+ config: dict[str, Any] | None = None,
30239
+ id_to_name: dict[int, str] | None = None,
30240
+ ) -> None:
30241
+ """编译 complete 档新字段组:data_permission/sort/card/hierarchy/display/record/share。"""
30242
+ if patch.data_permission is not None:
30243
+ data["dataPermissionType"] = "CONFIG" if patch.data_permission.type == "config" else "CUSTOM"
30244
+ data["dataScope"] = _DATA_PERMISSION_SCOPE_MAP.get(patch.data_permission.scope, "ALL")
30245
+ if patch.sort is not None:
30246
+ if patch.sort:
30247
+ sorts: list[dict[str, Any]] = []
30248
+ for rule in patch.sort:
30249
+ field = fields_by_que_id.get(rule.que_id) or {}
30250
+ que_type = _coerce_positive_int(field.get("que_type")) or FIELD_TYPE_TO_QUESTION_TYPE.get(
30251
+ str(field.get("type") or FieldType.text.value), 2
30252
+ )
30253
+ sorts.append(
30254
+ {
30255
+ "queId": rule.que_id,
30256
+ "beingSortAscend": bool(rule.ascending),
30257
+ "queType": que_type,
30258
+ }
30259
+ )
30260
+ data["sortType"] = "customSort"
30261
+ data["viewgraphSorts"] = sorts
30262
+ else:
30263
+ data["sortType"] = "defaultSort"
30264
+ data["viewgraphSorts"] = [{"queId": 0, "beingSortAscend": True, "queType": 8}]
30265
+ card = patch.card
30266
+ config = config or {}
30267
+ id_to_name = id_to_name or {}
30268
+ if card is not None:
30269
+ if card.title_que_id is not None:
30270
+ data["titleQue"] = card.title_que_id
30271
+ if card.cover_que_id is not None:
30272
+ data["frontCoverQueId"] = card.cover_que_id
30273
+ if card.clipping_mode is not None:
30274
+ data["clippingMode"] = card.clipping_mode
30275
+ if card.image_adaption is not None:
30276
+ data["beingImageAdaption"] = card.image_adaption
30277
+ if card.show_field_title is not None:
30278
+ data["beingShowQueTitle"] = card.show_field_title
30279
+ if card.show_title is not None:
30280
+ data["beingShowTitleQue"] = card.show_title
30281
+ if card.show_cover is not None:
30282
+ data["beingShowCover"] = card.show_cover
30283
+ if card.group_que_id is not None:
30284
+ data["groupQueId"] = card.group_que_id
30285
+ if card.group_color is not None:
30286
+ data["beingGroupColor"] = card.group_color
30287
+ if patch.hierarchy is not None and view_type == "hierarchy":
30288
+ data["viewgraphHierarchyConfigVO"] = {
30289
+ "titleQueId": patch.hierarchy.title_que_id,
30290
+ "childrenQueId": patch.hierarchy.children_que_id,
30291
+ "rootNodeFilter": deepcopy(hierarchy_filter_groups) if hierarchy_filter_groups is not None else [],
30292
+ }
30293
+ data["titleQue"] = patch.hierarchy.title_que_id
30294
+ if patch.calendar is not None and view_type == "calendar":
30295
+ data["viewgraphCalendarConfigVO"] = {
30296
+ "titleQueId": patch.calendar.title_que_id,
30297
+ "dateQueId": patch.calendar.date_que_id,
30298
+ "colorQueId": patch.calendar.color_que_id,
30299
+ }
30300
+ data["titleQue"] = patch.calendar.title_que_id
30301
+ display = patch.display
30302
+ if display is not None:
30303
+ if display.row_height is not None:
30304
+ data["defaultRowHigh"] = display.row_height
30305
+ if display.pin_navigate is not None:
30306
+ data["beingPinNavigate"] = display.pin_navigate
30307
+ if display.remark is not None:
30308
+ data["remark"] = display.remark
30309
+ record = patch.record
30310
+ if record is not None:
30311
+ if record.audit_visible is not None:
30312
+ data["beingAuditRecordVisible"] = record.audit_visible
30313
+ if record.data_log_visible is not None:
30314
+ data["beingDataLogVisible"] = record.data_log_visible
30315
+ if record.qrobot_record_visible is not None:
30316
+ data["beingQrobotRecordVisible"] = record.qrobot_record_visible
30317
+ if record.workflow_future_visible is not None:
30318
+ data["beingWorkflowNodeFutureListVisible"] = record.workflow_future_visible
30319
+ if record.comment is not None:
30320
+ data["beingCommentStatus"] = record.comment
30321
+ share = patch.share
30322
+ if share is not None:
30323
+ if share.need_pass is not None:
30324
+ data["beingNeedPass"] = share.need_pass
30325
+ data["needPass"] = share.need_pass
30326
+ if share.password is None:
30327
+ # 声明未带密码(提取语义:不回传敏感值)——显式清空 payload 里读面带来的旧密码;
30328
+ # 后端 isNotBlank(空)跳过校验且不更新密码列,原密码保留(Java AbstractViewgraphStrategicService:424)
30329
+ data["viewgraphPass"] = ""
30330
+ data.pop("pass", None)
30331
+ if share.password is not None:
30332
+ data["viewgraphPass"] = share.password
30333
+ if share.force_login is not None:
30334
+ data["beingForceLogin"] = share.force_login
30335
+ if share.print_enabled is not None:
30336
+ data["beingPrintStatus"] = share.print_enabled
30337
+
30338
+
30339
+ def _stale_view_reference_warning(
30340
+ *,
30341
+ scope: str,
30342
+ view_name: str,
30343
+ field_ids: list[Any] | None = None,
30344
+ values: list[Any] | None = None,
30345
+ option: bool = False,
30346
+ ) -> dict[str, Any]:
30347
+ """存量死引用(已删字段/已删选项)透传警告:后端保存视图不校验引用存在性并原样透传,
30348
+ CLI 同口径透传写回,仅以警告留痕,不再整视图拒绝(apply(get(S))≡S 重放兼容)。"""
30349
+ warning = _warning(
30350
+ "STALE_OPTION_REFERENCE" if option else "STALE_FIELD_REFERENCE",
30351
+ (
30352
+ f"view {scope} references option values not present in the current form; original values passed through (backend tolerates stale references)"
30353
+ if option
30354
+ else f"view {scope} references fields not present in the current form; ids passed through (backend tolerates stale references)"
30355
+ ),
30356
+ scope=scope,
30357
+ view_name=view_name,
30358
+ )
30359
+ if field_ids:
30360
+ warning["field_ids"] = list(field_ids)
30361
+ if values:
30362
+ warning["values"] = list(values)
30363
+ return warning
30364
+
30365
+
30366
+ def _iter_view_config_group_field_names(patch: ViewUpsertPatch) -> list[tuple[str, list[int]]]:
30367
+ """枚举 complete 档新字段组引用的 queId(用于 UNKNOWN_VIEW_FIELD 校验)。"""
30368
+ groups: list[tuple[str, list[int]]] = []
30369
+ if patch.sort:
30370
+ groups.append(("sort", [rule.que_id for rule in patch.sort]))
30371
+ if patch.card is not None:
30372
+ groups.append(
30373
+ (
30374
+ "card",
30375
+ [
30376
+ que
30377
+ for que in (patch.card.title_que_id, patch.card.cover_que_id, patch.card.group_que_id)
30378
+ if que
30379
+ ],
30380
+ )
30381
+ )
30382
+ if patch.gantt is not None:
30383
+ gantt_ids = [
30384
+ que
30385
+ for que in (patch.gantt.title_que_id, patch.gantt.start_que_id, patch.gantt.end_que_id)
30386
+ if que
30387
+ ]
30388
+ gantt_ids.extend(group.que_id for group in patch.gantt.groups or [])
30389
+ for dep in patch.gantt.dependencies or []:
30390
+ gantt_ids.extend([dep.dependency_que_id, dep.predecessor_que_id])
30391
+ if gantt_ids:
30392
+ groups.append(("gantt", gantt_ids))
30393
+ if patch.hierarchy is not None:
30394
+ groups.append(("hierarchy", [patch.hierarchy.title_que_id, patch.hierarchy.children_que_id]))
30395
+ if patch.calendar is not None:
30396
+ calendar_ids = [patch.calendar.title_que_id, patch.calendar.date_que_id]
30397
+ if patch.calendar.color_que_id is not None:
30398
+ calendar_ids.append(patch.calendar.color_que_id)
30399
+ groups.append(("calendar", calendar_ids))
30400
+ return groups
30401
+
30402
+
30403
+
30404
+ def _resolve_view_visible_que_ids(patch: ViewUpsertPatch) -> list[int]:
30405
+ """收集视图引用的业务字段和已知系统字段 queId。"""
30406
+ ordered: list[int] = []
30407
+ extra_ids: list[int | None] = []
30408
+ if patch.hierarchy is not None:
30409
+ extra_ids.extend([patch.hierarchy.title_que_id, patch.hierarchy.children_que_id])
30410
+ if patch.calendar is not None:
30411
+ extra_ids.extend([patch.calendar.title_que_id, patch.calendar.date_que_id, patch.calendar.color_que_id])
30412
+ for value in [*patch.columns, patch.title_que_id, patch.start_que_id, patch.end_que_id, patch.group_que_id, *extra_ids]:
30413
+ que_id = _coerce_any_int(value)
30414
+ if que_id is not None and (que_id > 0 or _is_known_system_view_que_id(que_id)) and que_id not in ordered:
30415
+ ordered.append(que_id)
29253
30416
  return ordered
29254
30417
 
29255
30418
 
30419
+
29256
30420
  def _build_public_gantt_payload(
29257
30421
  fields: list[dict[str, Any]],
29258
30422
  field_map: dict[str, int],
@@ -29260,22 +30424,35 @@ def _build_public_gantt_payload(
29260
30424
  ) -> dict[str, Any]:
29261
30425
  if patch.type.value != "gantt":
29262
30426
  return {}
29263
- title_field_name = str((patch.title_field or (patch.columns[0] if patch.columns else "")) or "").strip()
29264
- start_field_name = str(patch.start_field or "").strip()
29265
- end_field_name = str(patch.end_field or "").strip()
30427
+ gantt_config = patch.gantt
30428
+ title_que_id = (
30429
+ getattr(gantt_config, "title_que_id", None)
30430
+ or patch.title_que_id
30431
+ or (patch.columns[0] if patch.columns else None)
30432
+ )
30433
+ start_que_id = getattr(gantt_config, "start_que_id", None) or patch.start_que_id
30434
+ end_que_id = getattr(gantt_config, "end_que_id", None) or patch.end_que_id
30435
+ dependency = gantt_config.dependencies[0] if gantt_config is not None and gantt_config.dependencies else None
30436
+ auto_calibration = bool(getattr(gantt_config, "auto_calibration", False) or False)
29266
30437
  return {
29267
- "titleQueId": field_map.get(title_field_name),
29268
- "startTimeQueId": field_map.get(start_field_name),
29269
- "endTimeQueId": field_map.get(end_field_name),
29270
- "defaultTimeDimension": "week",
29271
- "ganttGroupVOList": [],
30438
+ "titleQueId": title_que_id,
30439
+ "startTimeQueId": start_que_id,
30440
+ "endTimeQueId": end_que_id,
30441
+ "defaultTimeDimension": getattr(gantt_config, "default_time_dimension", None) or "week",
30442
+ "ganttGroupVOList": [
30443
+ {
30444
+ "groupQueId": group.que_id,
30445
+ "ordinal": group.ordinal if group.ordinal is not None else index,
30446
+ }
30447
+ for index, group in enumerate((gantt_config.groups if gantt_config is not None else []), start=1)
30448
+ ],
29272
30449
  "ganttDependencyVO": {
29273
- "dependencyQueId": None,
29274
- "predecessorTaskQueId": None,
29275
- "startEndOptionId": None,
29276
- "startStartOptionId": None,
29277
- "endEndOptionId": None,
29278
- "endStartOptionId": None,
30450
+ "dependencyQueId": dependency.dependency_que_id if dependency else None,
30451
+ "predecessorTaskQueId": dependency.predecessor_que_id if dependency else None,
30452
+ "startStartOptionId": dependency.start_start_option_id if dependency else None,
30453
+ "startEndOptionId": dependency.start_end_option_id if dependency else None,
30454
+ "endEndOptionId": dependency.end_end_option_id if dependency else None,
30455
+ "endStartOptionId": dependency.end_start_option_id if dependency else None,
29279
30456
  },
29280
30457
  "ganttAutoCalibrationVO": {
29281
30458
  "autoCalibrationRuleVO": {
@@ -29286,15 +30463,15 @@ def _build_public_gantt_payload(
29286
30463
  "endStartFinish": True,
29287
30464
  "endEndFinish": False,
29288
30465
  },
29289
- "beingAutoCalibration": False,
29290
- "userAutoCalibration": False,
30466
+ "beingAutoCalibration": auto_calibration,
30467
+ "userAutoCalibration": auto_calibration,
29291
30468
  },
29292
30469
  }
29293
30470
 
29294
30471
 
29295
30472
  def _build_view_filter_groups(
29296
30473
  *,
29297
- current_fields_by_name: dict[str, dict[str, Any]],
30474
+ fields_by_que_id: dict[str, dict[str, Any]],
29298
30475
  filters: list[Any],
29299
30476
  ) -> tuple[list[list[dict[str, Any]]], list[dict[str, Any]]]:
29300
30477
  translated_rules: list[dict[str, Any]] = []
@@ -29304,14 +30481,14 @@ def _build_view_filter_groups(
29304
30481
  raw_rule = raw_rule.model_dump(mode="json")
29305
30482
  if not isinstance(raw_rule, dict):
29306
30483
  continue
29307
- field_name = str(raw_rule.get("field_name") or "").strip()
29308
- field = current_fields_by_name.get(field_name)
30484
+ rule_que_id = _coerce_positive_int(raw_rule.get("que_id"))
30485
+ field = fields_by_que_id.get(rule_que_id) if rule_que_id is not None else None
29309
30486
  if field is None:
29310
30487
  issues.append(
29311
30488
  {
29312
30489
  "error_code": "UNKNOWN_VIEW_FIELD",
29313
- "missing_fields": [field_name] if field_name else [],
29314
- "reason_path": "filters[].field_name",
30490
+ "missing_fields": [str(raw_rule.get("que_id") or "")],
30491
+ "reason_path": "filters[].que_id",
29315
30492
  }
29316
30493
  )
29317
30494
  continue
@@ -29319,6 +30496,9 @@ def _build_view_filter_groups(
29319
30496
  if issue:
29320
30497
  issues.append(issue)
29321
30498
  continue
30499
+ stale_warning = translated_rule.pop("_stale_warning", None)
30500
+ if stale_warning is not None:
30501
+ issues.append(stale_warning)
29322
30502
  translated_rules.append(translated_rule)
29323
30503
  return ([translated_rules] if translated_rules else []), issues
29324
30504
 
@@ -29329,9 +30509,9 @@ def _translate_view_filter_rule(*, field: dict[str, Any], rule: dict[str, Any])
29329
30509
  field_type = str(field.get("type") or FieldType.text.value)
29330
30510
  que_id = _coerce_positive_int(field.get("que_id")) or 0
29331
30511
  que_type = _coerce_positive_int(field.get("que_type")) or FIELD_TYPE_TO_QUESTION_TYPE.get(field_type, 2)
29332
- judge_values, judge_value_details, issue = _resolve_view_filter_values(field=field, values=values)
29333
- if issue:
29334
- return {}, issue
30512
+ judge_values, judge_value_details, value_issue = _resolve_view_filter_values(field=field, values=values)
30513
+ if value_issue and value_issue.get("error_code"):
30514
+ return {}, value_issue
29335
30515
  payload: dict[str, Any] = {
29336
30516
  "queId": que_id,
29337
30517
  "queTitle": str(field.get("name") or ""),
@@ -29363,6 +30543,9 @@ def _translate_view_filter_rule(*, field: dict[str, Any], rule: dict[str, Any])
29363
30543
  elif operator == "not_empty":
29364
30544
  payload["judgeType"] = JUDGE_UNEQUAL
29365
30545
  payload["judgeValues"] = []
30546
+ if value_issue is not None:
30547
+ # 死选项透传:规则照常写入,warning 随 payload 携带由上层收集(_build_view_filter_groups 弹出)
30548
+ payload["_stale_warning"] = value_issue
29366
30549
  return payload, None
29367
30550
 
29368
30551
 
@@ -29393,21 +30576,23 @@ def _resolve_view_filter_values(
29393
30576
  unresolved_values.append(_stringify_condition_value(value))
29394
30577
  continue
29395
30578
  resolved_details.append({"id": resolved.get("id"), "value": str(resolved.get("value") or resolved.get("id"))})
29396
- if unresolved_values:
30579
+ if unresolved_values and not resolved_details:
30580
+ # 全部值都解析不出(选项已删):原值透传(后端对存量死选项引用原样保存),
30581
+ # 以 STALE_OPTION_REFERENCE 警告留痕,不再整视图失败
29397
30582
  return (
29398
- [],
29399
- [],
30583
+ [_stringify_condition_value(value) for value in values],
30584
+ _build_view_filter_value_details(values),
29400
30585
  {
29401
- "error_code": "UNKNOWN_VIEW_FILTER_VALUE",
30586
+ "warning_code": "STALE_OPTION_REFERENCE",
29402
30587
  "reason_path": "filters[].values",
29403
30588
  "missing_fields": [],
29404
- "allowed_values": {"filter_values": sorted(option_by_value.keys())},
29405
30589
  "details": {
29406
30590
  "field_name": str(field.get("name") or ""),
29407
30591
  "unresolved_values": unresolved_values,
29408
30592
  },
29409
30593
  },
29410
30594
  )
30595
+ # 部分值解析不出:剔除已删选项值继续写(兼容存量脏数据,不再整视图失败)
29411
30596
  return (
29412
30597
  [str(detail["id"]) for detail in resolved_details if detail.get("id") is not None],
29413
30598
  [_coerce_view_filter_value_detail(detail) for detail in resolved_details],
@@ -29451,6 +30636,10 @@ def _build_view_filter_value_details(values: list[Any]) -> list[dict[str, Any]]:
29451
30636
  details.append(_coerce_view_filter_value_detail({"id": item_id, "value": item_value if item_value is not None else str(item_id)}))
29452
30637
  elif isinstance(value, int):
29453
30638
  details.append(_coerce_view_filter_value_detail({"id": value, "value": str(value)}))
30639
+ elif isinstance(value, str):
30640
+ # text/number 字段:AnswerValue.id 是 Integer(选项 id),字符串值无 id;
30641
+ # 后端 formula getId()==null → 走 getValue() 分支构建 ""value"" 形式
30642
+ details.append(_coerce_view_filter_value_detail({"id": None, "value": value}))
29454
30643
  return details
29455
30644
 
29456
30645
 
@@ -29617,11 +30806,11 @@ def _view_filter_groups_semantic_readback(expected: Any, actual: Any) -> tuple[b
29617
30806
  def _public_view_filter_groups_from_match_rules(
29618
30807
  groups: Any,
29619
30808
  *,
29620
- current_fields_by_name: dict[str, dict[str, Any]],
30809
+ fields_by_que_id: dict[str, dict[str, Any]],
29621
30810
  ) -> list[list[dict[str, Any]]]:
29622
30811
  fields_by_que_id = {
29623
30812
  _coerce_positive_int(field.get("que_id")): field
29624
- for field in current_fields_by_name.values()
30813
+ for field in fields_by_que_id.values()
29625
30814
  if isinstance(field, dict) and _coerce_positive_int(field.get("que_id")) is not None
29626
30815
  }
29627
30816
  public_groups: list[list[dict[str, Any]]] = []
@@ -29632,7 +30821,7 @@ def _public_view_filter_groups_from_match_rules(
29632
30821
  field = fields_by_que_id.get(que_id) or {}
29633
30822
  values = _public_view_filter_rule_values(rule, field=field)
29634
30823
  public_rule: dict[str, Any] = {
29635
- "field_name": str(field.get("name") or rule.get("queTitle") or que_id),
30824
+ "que_id": que_id,
29636
30825
  "operator": _public_view_filter_operator_from_judge_type(rule.get("judgeType"), values=values),
29637
30826
  }
29638
30827
  if public_rule["operator"] == "in":
@@ -29657,6 +30846,8 @@ def _public_view_filter_operator_from_judge_type(judge_type: Any, *, values: lis
29657
30846
  if normalized in {JUDGE_INCLUDE, JUDGE_FUZZY_MATCH}:
29658
30847
  return "contains"
29659
30848
  if normalized in {JUDGE_EQUAL_ANY, JUDGE_INCLUDE_ANY}:
30849
+ # ANY 判型+空值 = "该字段未选任何值"的合法存量形态(judgeType 保真回写,
30850
+ # 转 is_empty 会丢判型——后端 9 与 0 是不同判定域,实测回读不同)
29660
30851
  return "in"
29661
30852
  if normalized == JUDGE_GREATER_OR_EQUAL:
29662
30853
  return "gte"