@qingflow-tech/qingflow-app-user-mcp 1.0.2 → 1.0.4

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 (56) hide show
  1. package/README.md +2 -2
  2. package/docs/local-agent-install.md +9 -3
  3. package/npm/lib/runtime.mjs +10 -3
  4. package/package.json +1 -1
  5. package/pyproject.toml +1 -1
  6. package/skills/qingflow-app-user/SKILL.md +21 -12
  7. package/skills/qingflow-app-user/references/data-gotchas.md +1 -1
  8. package/skills/qingflow-app-user/references/public-surface-sync.md +70 -0
  9. package/skills/qingflow-app-user/references/record-patterns.md +1 -1
  10. package/skills/qingflow-record-analysis/SKILL.md +44 -2
  11. package/skills/qingflow-record-insert/SKILL.md +3 -0
  12. package/skills/qingflow-record-update/SKILL.md +3 -0
  13. package/skills/qingflow-task-ops/SKILL.md +31 -10
  14. package/src/qingflow_mcp/__init__.py +33 -1
  15. package/src/qingflow_mcp/backend_client.py +109 -0
  16. package/src/qingflow_mcp/builder_facade/button_style_catalog.py +282 -0
  17. package/src/qingflow_mcp/builder_facade/models.py +58 -9
  18. package/src/qingflow_mcp/builder_facade/service.py +1711 -240
  19. package/src/qingflow_mcp/cli/commands/__init__.py +2 -1
  20. package/src/qingflow_mcp/cli/commands/app.py +47 -1
  21. package/src/qingflow_mcp/cli/commands/auth.py +63 -0
  22. package/src/qingflow_mcp/cli/commands/builder.py +11 -3
  23. package/src/qingflow_mcp/cli/commands/exports.py +111 -0
  24. package/src/qingflow_mcp/cli/commands/record.py +5 -5
  25. package/src/qingflow_mcp/cli/commands/task.py +701 -27
  26. package/src/qingflow_mcp/cli/commands/workspace.py +84 -0
  27. package/src/qingflow_mcp/cli/context.py +3 -0
  28. package/src/qingflow_mcp/cli/formatters.py +424 -50
  29. package/src/qingflow_mcp/cli/interaction.py +72 -0
  30. package/src/qingflow_mcp/cli/main.py +11 -1
  31. package/src/qingflow_mcp/cli/qingflow_login.py +116 -0
  32. package/src/qingflow_mcp/cli/terminal_ui.py +218 -0
  33. package/src/qingflow_mcp/config.py +1 -1
  34. package/src/qingflow_mcp/errors.py +4 -4
  35. package/src/qingflow_mcp/export_store.py +14 -0
  36. package/src/qingflow_mcp/id_utils.py +49 -0
  37. package/src/qingflow_mcp/public_surface.py +16 -1
  38. package/src/qingflow_mcp/response_trim.py +394 -9
  39. package/src/qingflow_mcp/server.py +26 -0
  40. package/src/qingflow_mcp/server_app_builder.py +15 -1
  41. package/src/qingflow_mcp/server_app_user.py +113 -0
  42. package/src/qingflow_mcp/session_store.py +126 -21
  43. package/src/qingflow_mcp/solution/compiler/form_compiler.py +2 -2
  44. package/src/qingflow_mcp/solution/executor.py +2 -2
  45. package/src/qingflow_mcp/tools/ai_builder_tools.py +107 -34
  46. package/src/qingflow_mcp/tools/app_tools.py +1 -0
  47. package/src/qingflow_mcp/tools/auth_tools.py +243 -9
  48. package/src/qingflow_mcp/tools/base.py +6 -2
  49. package/src/qingflow_mcp/tools/code_block_tools.py +2 -2
  50. package/src/qingflow_mcp/tools/custom_button_tools.py +0 -2
  51. package/src/qingflow_mcp/tools/export_tools.py +1565 -0
  52. package/src/qingflow_mcp/tools/import_tools.py +78 -4
  53. package/src/qingflow_mcp/tools/record_tools.py +551 -165
  54. package/src/qingflow_mcp/tools/resource_read_tools.py +154 -33
  55. package/src/qingflow_mcp/tools/task_context_tools.py +917 -141
  56. package/src/qingflow_mcp/tools/workspace_tools.py +141 -0
@@ -1,12 +1,16 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import base64
3
4
  from copy import deepcopy
4
5
  from dataclasses import dataclass, field
5
6
  import json
6
7
  import os
8
+ import random
7
9
  import re
10
+ import string
8
11
  import tempfile
9
12
  from typing import Any, cast
13
+ from urllib.parse import quote_plus, unquote_plus
10
14
  from uuid import uuid4
11
15
 
12
16
  from ..backend_client import BackendRequestContext
@@ -30,6 +34,7 @@ from ..tools.role_tools import RoleTools
30
34
  from ..tools.solution_tools import SolutionTools
31
35
  from ..tools.view_tools import ViewTools
32
36
  from ..tools.workflow_tools import WorkflowTools
37
+ from .button_style_catalog import button_style_catalog_payload
33
38
  from .models import (
34
39
  AppChartsReadResponse,
35
40
  AppFieldsReadResponse,
@@ -143,6 +148,7 @@ JUDGE_EQUAL_ANY = 9
143
148
  JUDGE_FUZZY_MATCH = 19
144
149
  JUDGE_INCLUDE_ANY = 20
145
150
  DEFAULT_TYPE_RELATION = 2
151
+ DEFAULT_TYPE_FORMULA = 3
146
152
  RELATION_TYPE_Q_LINKER = 2
147
153
  RELATION_TYPE_CODE_BLOCK = 3
148
154
 
@@ -440,6 +446,7 @@ class AiBuilderFacade:
440
446
  base = base_result.get("result") if isinstance(base_result.get("result"), dict) else {}
441
447
  summary = detail_result.get("summary") if isinstance(detail_result, dict) and isinstance(detail_result.get("summary"), dict) else {}
442
448
  source = detail if detail else base
449
+ layout_tag_items = _select_package_layout_tag_items(detail=detail, base=base)
443
450
  warnings: list[JSONObject] = []
444
451
  if detail_read_error is not None:
445
452
  warnings.append(
@@ -450,7 +457,7 @@ class AiBuilderFacade:
450
457
  "http_status": detail_read_error.http_status,
451
458
  }
452
459
  )
453
- public_items = _public_package_items_from_tag_items(source.get("tagItems") or base.get("tagItems"))
460
+ public_items = _public_package_items_from_tag_items(layout_tag_items)
454
461
  item_count = summary.get("itemCount")
455
462
  if not isinstance(item_count, int) or item_count < 0 or (item_count == 0 and public_items):
456
463
  item_count = len(public_items)
@@ -508,6 +515,8 @@ class AiBuilderFacade:
508
515
  }
509
516
  effective_package_id = _coerce_positive_int(package_id)
510
517
  created = False
518
+ create_result: JSONObject | None = None
519
+ update_result: JSONObject | None = None
511
520
  permission_outcomes: list[PermissionCheckOutcome] = []
512
521
 
513
522
  if effective_package_id is None:
@@ -598,11 +607,36 @@ class AiBuilderFacade:
598
607
  )
599
608
  except VisibilityResolutionError:
600
609
  expected_visibility = None
610
+ metadata_verified = True
611
+ if metadata_requested and update_result is not None:
612
+ metadata_verified = bool(update_result.get("verified"))
613
+ elif created and create_result is not None:
614
+ metadata_verified = bool(create_result.get("verified"))
615
+ layout_verified = True
616
+ if items is not None and layout_result is not None:
617
+ layout_verified = bool(layout_result.get("verified"))
618
+ response_verification: JSONObject = {
619
+ "package_exists": True,
620
+ "package_created": created,
621
+ "layout_applied": items is not None,
622
+ "metadata_verified": metadata_verified,
623
+ "layout_verified": layout_verified,
624
+ "visibility_verified": None
625
+ if expected_visibility is None
626
+ else _visibility_matches_expected(verification.get("visibility"), expected_visibility),
627
+ }
628
+ if isinstance(update_result, dict):
629
+ update_verification = update_result.get("verification")
630
+ if isinstance(update_verification, dict):
631
+ for key in ("package_name_verified", "package_icon_verified", "visibility_verified"):
632
+ if key in update_verification:
633
+ response_verification[key] = deepcopy(update_verification.get(key))
634
+ response_verified = metadata_verified and layout_verified and response_verification.get("visibility_verified") is not False
601
635
  response: JSONObject = {
602
- "status": "success",
636
+ "status": "success" if response_verified else "partial_success",
603
637
  "error_code": None,
604
638
  "recoverable": False,
605
- "message": "applied package",
639
+ "message": "applied package" if response_verified else "applied package with unverified readback",
606
640
  "normalized_args": normalized_args,
607
641
  "missing_fields": [],
608
642
  "allowed_values": {},
@@ -611,15 +645,8 @@ class AiBuilderFacade:
611
645
  "suggested_next_call": None,
612
646
  "noop": not (created or metadata_requested or items is not None),
613
647
  "warnings": [],
614
- "verification": {
615
- "package_exists": True,
616
- "package_created": created,
617
- "layout_applied": items is not None,
618
- "visibility_verified": None
619
- if expected_visibility is None
620
- else _visibility_matches_expected(verification.get("visibility"), expected_visibility),
621
- },
622
- "verified": True,
648
+ "verification": response_verification,
649
+ "verified": response_verified,
623
650
  **{
624
651
  key: deepcopy(value)
625
652
  for key, value in verification.items()
@@ -677,7 +704,7 @@ class AiBuilderFacade:
677
704
  )
678
705
  raw_current = current.get("result") if isinstance(current.get("result"), dict) else {}
679
706
  raw_current_base = current_base.get("result") if isinstance(current_base.get("result"), dict) else {}
680
- current_name = str(raw_current.get("tagName") or "").strip() or None
707
+ current_name = str(raw_current.get("tagName") or raw_current_base.get("tagName") or "").strip() or None
681
708
  desired_name = str(package_name or current_name or "").strip() or current_name or "未命名应用包"
682
709
  desired_icon = encode_workspace_icon_with_defaults(
683
710
  icon=icon,
@@ -718,27 +745,33 @@ class AiBuilderFacade:
718
745
  verification = self.package_get(profile=profile, package_id=tag_id)
719
746
  if verification.get("status") != "success":
720
747
  return verification
748
+ package_name_verified = str(verification.get("package_name") or "").strip() == desired_name
749
+ package_icon_verified = str(verification.get("icon") or "").strip() == desired_icon
750
+ visibility_verified = _visibility_matches_expected(
751
+ verification.get("visibility"),
752
+ _public_visibility_from_member_auth(desired_auth),
753
+ )
754
+ verified = package_name_verified and package_icon_verified and visibility_verified
721
755
  return {
722
- "status": "success",
756
+ "status": "success" if verified else "partial_success",
723
757
  "error_code": None,
724
758
  "recoverable": False,
725
- "message": "updated package",
759
+ "message": "updated package" if verified else "updated package with unverified readback",
726
760
  "normalized_args": normalized_args,
727
761
  "missing_fields": [],
728
762
  "allowed_values": {},
729
763
  "details": {},
730
764
  "request_id": update_result.get("request_id") if isinstance(update_result, dict) else None,
731
- "suggested_next_call": None,
765
+ "suggested_next_call": None if verified else {"tool_name": "package_get", "arguments": {"profile": profile, "package_id": tag_id}},
732
766
  "noop": False,
733
767
  "warnings": [],
734
768
  "verification": {
735
769
  "package_exists": True,
736
- "visibility_verified": _visibility_matches_expected(
737
- verification.get("visibility"),
738
- _public_visibility_from_member_auth(desired_auth),
739
- ),
770
+ "package_name_verified": package_name_verified,
771
+ "package_icon_verified": package_icon_verified,
772
+ "visibility_verified": visibility_verified,
740
773
  },
741
- "verified": True,
774
+ "verified": verified,
742
775
  **{
743
776
  key: deepcopy(value)
744
777
  for key, value in verification.items()
@@ -881,9 +914,7 @@ class AiBuilderFacade:
881
914
  if isinstance(current_base_result, dict) and isinstance(current_base_result.get("result"), dict)
882
915
  else {}
883
916
  )
884
- detail_tag_items = detail_raw.get("tagItems") if isinstance(detail_raw.get("tagItems"), list) else None
885
- base_tag_items = base_raw.get("tagItems") if isinstance(base_raw.get("tagItems"), list) else None
886
- raw_tag_items = detail_tag_items if detail_tag_items else base_tag_items
917
+ raw_tag_items = _select_package_layout_tag_items(detail=detail_raw, base=base_raw)
887
918
  if not isinstance(raw_tag_items, list):
888
919
  return _failed(
889
920
  "PACKAGE_LAYOUT_UNREADABLE",
@@ -1409,6 +1440,8 @@ class AiBuilderFacade:
1409
1440
  issues: list[dict[str, Any]] = []
1410
1441
  resolved: list[dict[str, Any]] = []
1411
1442
  seen_ids: set[int] = set()
1443
+ if not dept_ids and not dept_names:
1444
+ return {"department_entries": resolved, "issues": issues}
1412
1445
  listed = self.directory.directory_list_all_departments(
1413
1446
  profile=profile,
1414
1447
  parent_dept_id=None,
@@ -2321,6 +2354,26 @@ class AiBuilderFacade:
2321
2354
  **match,
2322
2355
  }
2323
2356
 
2357
+ def button_style_catalog_get(self, *, profile: str) -> JSONObject:
2358
+ return {
2359
+ "status": "success",
2360
+ "error_code": None,
2361
+ "recoverable": False,
2362
+ "message": "read button style catalog",
2363
+ "normalized_args": {},
2364
+ "missing_fields": [],
2365
+ "allowed_values": {},
2366
+ "details": {},
2367
+ "request_id": None,
2368
+ "suggested_next_call": None,
2369
+ "noop": False,
2370
+ "warnings": [],
2371
+ "verification": {"button_style_catalog_verified": True},
2372
+ "verified": True,
2373
+ "profile": profile,
2374
+ **button_style_catalog_payload(),
2375
+ }
2376
+
2324
2377
  def app_custom_button_list(self, *, profile: str, app_key: str) -> JSONObject:
2325
2378
  normalized_args = {"app_key": app_key}
2326
2379
  permission_outcomes: list[PermissionCheckOutcome] = []
@@ -2780,6 +2833,19 @@ class AiBuilderFacade:
2780
2833
  "can_copy_app": _coerce_optional_bool(base.get("copyAppStatus")),
2781
2834
  }
2782
2835
 
2836
+ def _derive_can_edit_app_base(self, *, profile: str, permission_summary: JSONObject) -> bool:
2837
+ if permission_summary.get("can_edit_app") is not True:
2838
+ return False
2839
+ tag_ids = _coerce_int_list(permission_summary.get("tag_ids"))
2840
+ for tag_id in tag_ids:
2841
+ try:
2842
+ package_permission = self._read_package_permission_summary(profile=profile, tag_id=tag_id)
2843
+ except (QingflowApiError, RuntimeError):
2844
+ return False
2845
+ if package_permission.get("can_edit_tag") is not True:
2846
+ return False
2847
+ return True
2848
+
2783
2849
  def _read_portal_permission_summary(self, *, dash_key: str, portal_result: dict[str, Any]) -> JSONObject:
2784
2850
  tag_ids = _coerce_int_list(portal_result.get("tagIds"))
2785
2851
  if not tag_ids:
@@ -2993,7 +3059,7 @@ class AiBuilderFacade:
2993
3059
 
2994
3060
  def app_read_summary(self, *, profile: str, app_key: str) -> JSONObject:
2995
3061
  try:
2996
- state = self._load_base_schema_state(profile=profile, app_key=app_key)
3062
+ base = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=True)
2997
3063
  except (QingflowApiError, RuntimeError) as error:
2998
3064
  api_error = _coerce_api_error(error)
2999
3065
  return _failed_from_api_error(
@@ -3003,26 +3069,55 @@ class AiBuilderFacade:
3003
3069
  details={"app_key": app_key},
3004
3070
  suggested_next_call={"tool_name": "app_resolve", "arguments": {"profile": profile, "app_key": app_key}},
3005
3071
  )
3006
- views, views_unavailable = self._load_views_result(profile=profile, app_key=app_key, tolerate_404=True)
3007
- workflow, workflow_unavailable = self._load_workflow_result(profile=profile, app_key=app_key, tolerate_404=True)
3008
- parsed = state["parsed"]
3072
+ base_result = base.get("result") if isinstance(base.get("result"), dict) else {}
3073
+ schema_unavailable = False
3074
+ try:
3075
+ schema_result, _schema_source = self._read_schema_with_fallback(profile=profile, app_key=app_key)
3076
+ parsed = _parse_schema(schema_result)
3077
+ except (QingflowApiError, RuntimeError) as error:
3078
+ api_error = _coerce_api_error(error)
3079
+ if api_error.http_status == 404 or _is_permission_restricted_api_error(api_error):
3080
+ schema_unavailable = True
3081
+ parsed = {"fields": [], "layout": {"sections": []}}
3082
+ else:
3083
+ return _failed_from_api_error(
3084
+ "APP_READ_FAILED",
3085
+ api_error,
3086
+ normalized_args={"app_key": app_key},
3087
+ details={"app_key": app_key},
3088
+ suggested_next_call={"tool_name": "app_resolve", "arguments": {"profile": profile, "app_key": app_key}},
3089
+ )
3090
+ views, views_unavailable = self._load_views_result(
3091
+ profile=profile,
3092
+ app_key=app_key,
3093
+ tolerate_404=True,
3094
+ tolerate_permission_restricted=True,
3095
+ )
3096
+ workflow, workflow_unavailable = self._load_workflow_result(
3097
+ profile=profile,
3098
+ app_key=app_key,
3099
+ tolerate_404=True,
3100
+ tolerate_permission_restricted=True,
3101
+ )
3009
3102
  verification_hints = _build_verification_hints(
3010
- tag_ids=_coerce_int_list(state["base"].get("tagIds")),
3103
+ tag_ids=_coerce_int_list(base_result.get("tagIds")),
3011
3104
  fields=parsed["fields"],
3012
3105
  layout=parsed["layout"],
3013
3106
  views=_summarize_views(views),
3014
3107
  )
3108
+ if schema_unavailable:
3109
+ verification_hints.append("schema_read_unavailable")
3015
3110
  if views_unavailable:
3016
3111
  verification_hints.append("views_read_unavailable")
3017
3112
  if workflow_unavailable:
3018
3113
  verification_hints.append("workflow_read_unavailable")
3019
3114
  response = AppReadSummaryResponse(
3020
3115
  app_key=app_key,
3021
- title=state["base"].get("formTitle"),
3022
- app_icon=str(state["base"].get("appIcon") or "").strip() or None,
3023
- visibility=_public_visibility_from_member_auth(state["base"].get("auth")),
3024
- tag_ids=_coerce_int_list(state["base"].get("tagIds")),
3025
- publish_status=state["base"].get("appPublishStatus"),
3116
+ title=base_result.get("formTitle"),
3117
+ app_icon=str(base_result.get("appIcon") or "").strip() or None,
3118
+ visibility=_public_visibility_from_member_auth(base_result.get("auth")),
3119
+ tag_ids=_coerce_int_list(base_result.get("tagIds")),
3120
+ publish_status=base_result.get("appPublishStatus"),
3026
3121
  field_count=len(parsed["fields"]),
3027
3122
  layout_section_count=len(parsed["layout"].get("sections", [])),
3028
3123
  view_count=len(_summarize_views(views)),
@@ -3044,10 +3139,11 @@ class AiBuilderFacade:
3044
3139
  "warnings": _warnings_from_verification_hints(verification_hints),
3045
3140
  "verification": {
3046
3141
  "app_exists": True,
3142
+ "schema_read_unavailable": schema_unavailable,
3047
3143
  "views_read_unavailable": views_unavailable,
3048
3144
  "workflow_read_unavailable": workflow_unavailable,
3049
3145
  },
3050
- "verified": not views_unavailable and not workflow_unavailable,
3146
+ "verified": not schema_unavailable and not views_unavailable and not workflow_unavailable,
3051
3147
  **response.model_dump(mode="json"),
3052
3148
  }
3053
3149
 
@@ -3060,8 +3156,9 @@ class AiBuilderFacade:
3060
3156
  permission_summary = self._read_app_permission_summary(profile=profile, app_key=app_key)
3061
3157
  result["message"] = "read app config summary"
3062
3158
  result["editability"] = {
3159
+ "can_edit_app_base": self._derive_can_edit_app_base(profile=profile, permission_summary=permission_summary),
3063
3160
  "can_edit_form": permission_summary.get("can_edit_app"),
3064
- "can_edit_flow": permission_summary.get("can_edit_app"),
3161
+ "can_edit_flow": permission_summary.get("can_manage_data"),
3065
3162
  "can_edit_views": permission_summary.get("can_manage_data"),
3066
3163
  "can_edit_charts": permission_summary.get("can_manage_data"),
3067
3164
  }
@@ -4630,7 +4727,19 @@ class AiBuilderFacade:
4630
4727
  )
4631
4728
  field = current_fields[matched]
4632
4729
  previous_name = field["name"]
4633
- _apply_field_mutation(field, patch.set)
4730
+ try:
4731
+ _apply_field_mutation(field, patch.set)
4732
+ except ValueError as error:
4733
+ return _failed(
4734
+ "VALIDATION_ERROR",
4735
+ str(error),
4736
+ normalized_args=normalized_args,
4737
+ details={
4738
+ "selector": patch.selector.model_dump(mode="json"),
4739
+ "app_key": target.app_key,
4740
+ },
4741
+ suggested_next_call={"tool_name": "app_get_fields", "arguments": {"profile": profile, "app_key": target.app_key}},
4742
+ )
4634
4743
  current_fields[matched] = field
4635
4744
  layout = _rename_field_in_layout(layout, previous_name, field["name"])
4636
4745
  updated.append(field["name"])
@@ -4789,12 +4898,22 @@ class AiBuilderFacade:
4789
4898
  response = _apply_permission_outcomes(response, relation_permission_outcome)
4790
4899
  return finalize(self._append_publish_result(profile=profile, app_key=target.app_key, publish=publish, response=response))
4791
4900
 
4792
- payload = _build_form_payload_from_fields(
4793
- title=effective_app_name,
4794
- current_schema=schema_result,
4795
- fields=current_fields,
4796
- layout=layout,
4797
- question_relations=compiled_question_relations,
4901
+ payload = (
4902
+ _build_form_payload_from_fields(
4903
+ title=effective_app_name,
4904
+ current_schema=schema_result,
4905
+ fields=current_fields,
4906
+ layout=layout,
4907
+ question_relations=compiled_question_relations,
4908
+ )
4909
+ if bool(resolved.get("created"))
4910
+ else _build_form_payload_for_edit_fields(
4911
+ title=effective_app_name,
4912
+ current_schema=schema_result,
4913
+ fields=current_fields,
4914
+ layout=layout,
4915
+ question_relations=compiled_question_relations,
4916
+ )
4798
4917
  )
4799
4918
  payload["editVersionNo"] = self._resolve_form_edit_version(
4800
4919
  profile=profile,
@@ -4897,12 +5016,22 @@ class AiBuilderFacade:
4897
5016
  },
4898
5017
  suggested_next_call={"tool_name": "app_get_fields", "arguments": {"profile": profile, "app_key": target.app_key}},
4899
5018
  )
4900
- rebound_payload = _build_form_payload_from_fields(
4901
- title=effective_app_name,
4902
- current_schema=rebound_schema,
4903
- fields=rebound_fields,
4904
- layout=rebound_layout,
4905
- question_relations=compiled_question_relations,
5019
+ rebound_payload = (
5020
+ _build_form_payload_from_fields(
5021
+ title=effective_app_name,
5022
+ current_schema=rebound_schema,
5023
+ fields=rebound_fields,
5024
+ layout=rebound_layout,
5025
+ question_relations=compiled_question_relations,
5026
+ )
5027
+ if bool(resolved.get("created"))
5028
+ else _build_form_payload_for_edit_fields(
5029
+ title=effective_app_name,
5030
+ current_schema=rebound_schema,
5031
+ fields=rebound_fields,
5032
+ layout=rebound_layout,
5033
+ question_relations=compiled_question_relations,
5034
+ )
4906
5035
  )
4907
5036
  rebound_payload["editVersionNo"] = self._resolve_form_edit_version(
4908
5037
  profile=profile,
@@ -6670,6 +6799,7 @@ class AiBuilderFacade:
6670
6799
 
6671
6800
  for patch in request.upsert_charts:
6672
6801
  try:
6802
+ config_update_requested = _chart_patch_updates_chart_config(patch)
6673
6803
  chart_visible_auth = (
6674
6804
  self._compile_visibility_to_chart_visible_auth(profile=profile, visibility=patch.visibility)
6675
6805
  if patch.visibility is not None
@@ -6773,18 +6903,17 @@ class AiBuilderFacade:
6773
6903
  existing_by_name.pop(old_name, None)
6774
6904
  existing_by_name.setdefault(patch.name, []).append(deepcopy(updated_chart))
6775
6905
 
6776
- config_payload = _build_public_chart_config_payload(
6777
- patch=patch,
6778
- app_key=app_key,
6779
- field_lookup=field_lookup,
6780
- qingbi_fields_by_id=qingbi_fields_by_id,
6781
- )
6782
- if isinstance(chart_visible_auth, dict):
6783
- raw_data_config = config_payload.get("rawDataConfigDTO")
6784
- if isinstance(raw_data_config, dict):
6785
- raw_data_config["authInfo"] = deepcopy(chart_visible_auth)
6786
- self.charts.qingbi_report_update_config(profile=profile, chart_id=chart_id, payload=config_payload)
6787
- if existing is not None and chart_id not in updated_ids:
6906
+ config_updated = False
6907
+ if existing is None or config_update_requested:
6908
+ config_payload = _build_public_chart_config_payload(
6909
+ patch=patch,
6910
+ app_key=app_key,
6911
+ field_lookup=field_lookup,
6912
+ qingbi_fields_by_id=qingbi_fields_by_id,
6913
+ )
6914
+ self.charts.qingbi_report_update_config(profile=profile, chart_id=chart_id, payload=config_payload)
6915
+ config_updated = True
6916
+ if existing is not None and chart_id not in updated_ids and config_updated:
6788
6917
  updated_ids.append(chart_id)
6789
6918
  if patch.question_config:
6790
6919
  self._request_backend(
@@ -6800,6 +6929,8 @@ class AiBuilderFacade:
6800
6929
  path=f"/chart/{chart_id}/user/config",
6801
6930
  json_body=patch.user_config,
6802
6931
  )
6932
+ if existing is not None and chart_id not in updated_ids and (patch.question_config or patch.user_config):
6933
+ updated_ids.append(chart_id)
6803
6934
  chart_results.append(
6804
6935
  {
6805
6936
  "chart_id": chart_id,
@@ -6956,11 +7087,12 @@ class AiBuilderFacade:
6956
7087
  permission_outcomes: list[PermissionCheckOutcome] = []
6957
7088
  dash_key = str(request.dash_key or "").strip()
6958
7089
  creating = not dash_key
7090
+ sections_requested = creating or bool(request.sections)
6959
7091
  verify_dash_name = creating or request.dash_name is not None
6960
7092
  verify_dash_icon = bool(request.icon or request.color)
6961
7093
  verify_auth = request.visibility is not None or request.auth is not None
6962
- verify_hide_copyright = request.hide_copyright is not None
6963
- verify_dash_global_config = request.dash_global_config is not None
7094
+ verify_hide_copyright = request.hide_copyright is not None and sections_requested
7095
+ verify_dash_global_config = request.dash_global_config is not None and sections_requested
6964
7096
  verify_tags = creating or request.package_tag_id is not None
6965
7097
  requested_visibility = request.visibility
6966
7098
  if requested_visibility is None and isinstance(request.auth, dict) and request.auth:
@@ -7037,6 +7169,25 @@ class AiBuilderFacade:
7037
7169
  if package_edit_outcome.block is not None:
7038
7170
  return package_edit_outcome.block
7039
7171
  permission_outcomes.append(package_edit_outcome)
7172
+ if not sections_requested:
7173
+ unsupported_base_only_keys: list[str] = []
7174
+ if request.hide_copyright is not None:
7175
+ unsupported_base_only_keys.append("hide_copyright")
7176
+ if request.dash_global_config is not None:
7177
+ unsupported_base_only_keys.append("dash_global_config")
7178
+ if request.config:
7179
+ unsupported_base_only_keys.append("config")
7180
+ if unsupported_base_only_keys:
7181
+ return _failed(
7182
+ "PORTAL_SECTIONS_REQUIRED",
7183
+ "editing a portal without sections only supports base-info updates",
7184
+ normalized_args=normalized_args,
7185
+ details={
7186
+ "unsupported_without_sections": unsupported_base_only_keys,
7187
+ "fix_hint": "Pass sections when changing layout or global portal config, or omit those keys for visibility/icon/package updates.",
7188
+ },
7189
+ suggested_next_call={"tool_name": "portal_apply", "arguments": {"profile": profile, **normalized_args}},
7190
+ )
7040
7191
  try:
7041
7192
  if creating:
7042
7193
  create_payload = _build_public_portal_base_payload(
@@ -7062,7 +7213,6 @@ class AiBuilderFacade:
7062
7213
  suggested_next_call={"tool_name": "portal_apply", "arguments": {"profile": profile, **normalized_args}},
7063
7214
  )
7064
7215
  base_payload = self.portals.portal_get(profile=profile, dash_key=dash_key, being_draft=True).get("result") or {}
7065
- component_payload = self._build_portal_components_from_sections(profile=profile, sections=request.sections)
7066
7216
  update_payload = _build_public_portal_base_payload(
7067
7217
  dash_name=request.dash_name or str(base_payload.get("dashName") or "").strip() or "未命名门户",
7068
7218
  package_tag_id=target_package_tag_id,
@@ -7074,8 +7224,10 @@ class AiBuilderFacade:
7074
7224
  config=request.config,
7075
7225
  base_payload=base_payload,
7076
7226
  )
7077
- update_payload["components"] = component_payload
7078
- self.portals.portal_update(profile=profile, dash_key=dash_key, payload=update_payload)
7227
+ if sections_requested:
7228
+ component_payload = self._build_portal_components_from_sections(profile=profile, sections=request.sections)
7229
+ update_payload["components"] = component_payload
7230
+ self.portals.portal_update(profile=profile, dash_key=dash_key, payload=update_payload)
7079
7231
  self.portals.portal_update_base_info(
7080
7232
  profile=profile,
7081
7233
  dash_key=dash_key,
@@ -7112,11 +7264,14 @@ class AiBuilderFacade:
7112
7264
  publish_failed = True
7113
7265
 
7114
7266
  draft_components = draft_result.get("components") if isinstance(draft_result, dict) else None
7115
- expected_count = len(request.sections)
7116
- draft_verified = isinstance(draft_components, list) and len(draft_components) == expected_count
7267
+ expected_count = len(request.sections) if sections_requested else None
7268
+ draft_verified = isinstance(draft_result, dict) and (
7269
+ expected_count is None or (isinstance(draft_components, list) and len(draft_components) == expected_count)
7270
+ )
7117
7271
  draft_meta_verified, draft_meta_mismatches = _verify_portal_readback(
7118
7272
  actual=draft_result,
7119
7273
  expected_payload=update_payload,
7274
+ expected_visibility=requested_visibility.model_dump(mode="json") if requested_visibility is not None else None,
7120
7275
  expected_section_count=expected_count,
7121
7276
  requested_config_keys=set((request.config or {}).keys()),
7122
7277
  verify_dash_name=verify_dash_name,
@@ -7132,12 +7287,18 @@ class AiBuilderFacade:
7132
7287
  if request.publish:
7133
7288
  live_verified = (
7134
7289
  isinstance(live_result, dict)
7135
- and isinstance(live_result.get("components"), list)
7136
- and len(live_result.get("components")) == expected_count
7290
+ and (
7291
+ expected_count is None
7292
+ or (
7293
+ isinstance(live_result.get("components"), list)
7294
+ and len(live_result.get("components")) == expected_count
7295
+ )
7296
+ )
7137
7297
  )
7138
7298
  live_meta_verified, live_meta_mismatches = _verify_portal_readback(
7139
7299
  actual=live_result,
7140
7300
  expected_payload=update_payload,
7301
+ expected_visibility=requested_visibility.model_dump(mode="json") if requested_visibility is not None else None,
7141
7302
  expected_section_count=expected_count,
7142
7303
  requested_config_keys=set((request.config or {}).keys()),
7143
7304
  verify_dash_name=verify_dash_name,
@@ -7171,7 +7332,15 @@ class AiBuilderFacade:
7171
7332
  "status": status,
7172
7333
  "error_code": error_code,
7173
7334
  "recoverable": not verified,
7174
- "message": "applied portal" if verified else "applied portal; draft/live verification pending",
7335
+ "message": (
7336
+ "updated portal base info"
7337
+ if verified and not sections_requested
7338
+ else "applied portal"
7339
+ if verified
7340
+ else "updated portal base info; draft/live verification pending"
7341
+ if not sections_requested
7342
+ else "applied portal; draft/live verification pending"
7343
+ ),
7175
7344
  "normalized_args": normalized_args,
7176
7345
  "missing_fields": [],
7177
7346
  "allowed_values": {"section.source_type": ["chart", "view", "grid", "filter", "text", "link"]},
@@ -7366,17 +7535,35 @@ class AiBuilderFacade:
7366
7535
  sync_result = {**sync_result, "button_config_restored": True}
7367
7536
  return sync_result
7368
7537
 
7369
- def _load_views_result(self, *, profile: str, app_key: str, tolerate_404: bool) -> tuple[Any, bool]:
7538
+ def _load_views_result(
7539
+ self,
7540
+ *,
7541
+ profile: str,
7542
+ app_key: str,
7543
+ tolerate_404: bool,
7544
+ tolerate_permission_restricted: bool = False,
7545
+ ) -> tuple[Any, bool]:
7370
7546
  try:
7371
7547
  views = self.views.view_list_flat(profile=profile, app_key=app_key)
7372
7548
  except (QingflowApiError, RuntimeError) as error:
7373
7549
  api_error = _coerce_api_error(error)
7374
- if api_error.http_status == 404:
7550
+ if api_error.http_status == 404 or (
7551
+ tolerate_permission_restricted and _is_permission_restricted_api_error(api_error)
7552
+ ):
7375
7553
  try:
7376
7554
  legacy_views = self.views.view_list(profile=profile, app_key=app_key)
7377
7555
  except (QingflowApiError, RuntimeError) as legacy_error:
7378
7556
  legacy_api_error = _coerce_api_error(legacy_error)
7379
- if tolerate_404 and legacy_api_error.http_status == 404:
7557
+ if (
7558
+ tolerate_404
7559
+ and (
7560
+ legacy_api_error.http_status == 404
7561
+ or (
7562
+ tolerate_permission_restricted
7563
+ and _is_permission_restricted_api_error(legacy_api_error)
7564
+ )
7565
+ )
7566
+ ):
7380
7567
  return [], True
7381
7568
  raise
7382
7569
  legacy_result = legacy_views.get("result")
@@ -7393,19 +7580,38 @@ class AiBuilderFacade:
7393
7580
  legacy_views = self.views.view_list(profile=profile, app_key=app_key)
7394
7581
  except (QingflowApiError, RuntimeError) as legacy_error:
7395
7582
  legacy_api_error = _coerce_api_error(legacy_error)
7396
- if tolerate_404 and legacy_api_error.http_status == 404:
7583
+ if (
7584
+ tolerate_404
7585
+ and (
7586
+ legacy_api_error.http_status == 404
7587
+ or (
7588
+ tolerate_permission_restricted
7589
+ and _is_permission_restricted_api_error(legacy_api_error)
7590
+ )
7591
+ )
7592
+ ):
7397
7593
  return normalized_views, False
7398
7594
  raise
7399
7595
  legacy_result = legacy_views.get("result")
7400
7596
  legacy_normalized = _normalize_view_collection(legacy_result)
7401
7597
  return legacy_normalized or normalized_views, False
7402
7598
 
7403
- def _load_workflow_result(self, *, profile: str, app_key: str, tolerate_404: bool) -> tuple[Any, bool]:
7599
+ def _load_workflow_result(
7600
+ self,
7601
+ *,
7602
+ profile: str,
7603
+ app_key: str,
7604
+ tolerate_404: bool,
7605
+ tolerate_permission_restricted: bool = False,
7606
+ ) -> tuple[Any, bool]:
7404
7607
  try:
7405
7608
  workflow = self.workflows.workflow_list_nodes(profile=profile, app_key=app_key)
7406
7609
  except (QingflowApiError, RuntimeError) as error:
7407
7610
  api_error = _coerce_api_error(error)
7408
- if tolerate_404 and api_error.http_status == 404:
7611
+ if tolerate_404 and (
7612
+ api_error.http_status == 404
7613
+ or (tolerate_permission_restricted and _is_permission_restricted_api_error(api_error))
7614
+ ):
7409
7615
  return [], True
7410
7616
  raise
7411
7617
  return workflow.get("result"), False
@@ -7850,8 +8056,6 @@ def _serialize_custom_button_payload(payload: CustomButtonPatch) -> dict[str, An
7850
8056
  "buttonIcon": data["button_icon"],
7851
8057
  "triggerAction": data["trigger_action"],
7852
8058
  }
7853
- if str(data.get("icon_color") or "").strip():
7854
- serialized["iconColor"] = data["icon_color"]
7855
8059
  if str(data.get("trigger_link_url") or "").strip():
7856
8060
  serialized["triggerLinkUrl"] = data["trigger_link_url"]
7857
8061
  trigger_add_data_config = data.get("trigger_add_data_config")
@@ -7941,7 +8145,6 @@ def _normalize_custom_button_summary(item: dict[str, Any]) -> dict[str, Any]:
7941
8145
  "button_id": _coerce_positive_int(item.get("button_id") or item.get("buttonId") or item.get("id")),
7942
8146
  "button_text": str(item.get("button_text") or item.get("buttonText") or "").strip() or None,
7943
8147
  "button_icon": str(item.get("button_icon") or item.get("buttonIcon") or "").strip() or None,
7944
- "icon_color": str(item.get("icon_color") or item.get("iconColor") or "").strip() or None,
7945
8148
  "background_color": str(item.get("background_color") or item.get("backgroundColor") or "").strip() or None,
7946
8149
  "text_color": str(item.get("text_color") or item.get("textColor") or "").strip() or None,
7947
8150
  "used_in_chart_count": _coerce_nonnegative_int(item.get("used_in_chart_count") or item.get("userInChartCount")),
@@ -8697,6 +8900,11 @@ def _build_public_chart_config_payload(
8697
8900
  return payload
8698
8901
 
8699
8902
 
8903
+ def _chart_patch_updates_chart_config(patch: ChartUpsertPatch) -> bool:
8904
+ explicit_fields = set(getattr(patch, "model_fields_set", set()) or set())
8905
+ return bool({"dimension_field_ids", "indicator_field_ids", "filters", "config"} & explicit_fields)
8906
+
8907
+
8700
8908
  def _build_public_portal_base_payload(
8701
8909
  *,
8702
8910
  dash_name: str,
@@ -8926,7 +9134,8 @@ def _verify_portal_readback(
8926
9134
  *,
8927
9135
  actual: Any,
8928
9136
  expected_payload: dict[str, Any],
8929
- expected_section_count: int,
9137
+ expected_visibility: dict[str, Any] | None,
9138
+ expected_section_count: int | None,
8930
9139
  requested_config_keys: set[str],
8931
9140
  verify_dash_name: bool,
8932
9141
  verify_dash_icon: bool,
@@ -8939,14 +9148,19 @@ def _verify_portal_readback(
8939
9148
  if not isinstance(actual, dict):
8940
9149
  return False, ["portal readback payload is unavailable"]
8941
9150
  components = actual.get("components")
8942
- if not isinstance(components, list) or len(components) != expected_section_count:
9151
+ if expected_section_count is not None and (not isinstance(components, list) or len(components) != expected_section_count):
8943
9152
  mismatches.append(f"components expected {expected_section_count}, got {len(components) if isinstance(components, list) else 'unavailable'}")
8944
9153
  if verify_dash_name and str(actual.get("dashName") or "").strip() != str(expected_payload.get("dashName") or "").strip():
8945
9154
  mismatches.append("dash_name")
8946
9155
  if verify_dash_icon and str(actual.get("dashIcon") or "") != str(expected_payload.get("dashIcon") or ""):
8947
9156
  mismatches.append("dash_icon")
8948
- if verify_auth and not _mapping_contains(actual.get("auth"), expected_payload.get("auth")):
8949
- mismatches.append("auth")
9157
+ if verify_auth:
9158
+ if expected_visibility is not None:
9159
+ actual_visibility = _public_visibility_from_member_auth(actual.get("auth"))
9160
+ if not _visibility_matches_expected(actual_visibility, expected_visibility):
9161
+ mismatches.append("auth")
9162
+ elif not _mapping_contains(actual.get("auth"), expected_payload.get("auth")):
9163
+ mismatches.append("auth")
8950
9164
  if verify_hide_copyright and bool(actual.get("hideCopyright", False)) != bool(expected_payload.get("hideCopyright", False)):
8951
9165
  mismatches.append("hide_copyright")
8952
9166
  if verify_dash_global_config and not _mapping_contains(actual.get("dashGlobalConfig") or {}, expected_payload.get("dashGlobalConfig") or {}):
@@ -9154,7 +9368,11 @@ def _visibility_matches_expected(actual: Any, expected: Any) -> bool:
9154
9368
  if expected_text and sorted_values(actual_group, text_key) != expected_text:
9155
9369
  return False
9156
9370
 
9157
- if "include_sub_departs" in expected_selectors and actual_selectors.get("include_sub_departs") != expected_selectors.get("include_sub_departs"):
9371
+ if (
9372
+ "include_sub_departs" in expected_selectors
9373
+ and expected_selectors.get("include_sub_departs") is not None
9374
+ and actual_selectors.get("include_sub_departs") != expected_selectors.get("include_sub_departs")
9375
+ ):
9158
9376
  return False
9159
9377
  return True
9160
9378
 
@@ -9611,6 +9829,15 @@ def _apply_relation_target_selection(
9611
9829
  config["refer_field_types"] = [item.get("type") for item in normalized_visible]
9612
9830
  config["auth_field_ids"] = [item.get("field_id") or item.get("name") for item in normalized_visible]
9613
9831
  config["auth_field_que_ids"] = [_coerce_positive_int(item.get("que_id")) or 0 for item in normalized_visible]
9832
+ config["refer_auth_ques"] = [
9833
+ {
9834
+ "queId": _coerce_positive_int(item.get("que_id")) or 0,
9835
+ "queAuth": _REFERENCE_FIELD_VISIBLE_AUTH,
9836
+ "_field_id": item.get("field_id") or item.get("name"),
9837
+ }
9838
+ for item in normalized_visible
9839
+ if (_coerce_positive_int(item.get("que_id")) or 0) > 0
9840
+ ]
9614
9841
  config["field_name_show"] = bool(field.get("field_name_show", True))
9615
9842
  field["target_field_id"] = display_field.get("field_id") or display_field.get("name")
9616
9843
  field["target_field_que_id"] = _coerce_positive_int(display_field.get("que_id")) or 0
@@ -9844,9 +10071,10 @@ def _parse_field(question: dict[str, Any], *, field_id_hint: str | None = None)
9844
10071
  "subfields": [],
9845
10072
  "que_id": que_id,
9846
10073
  "que_type": que_type,
9847
- "default_type": _coerce_positive_int(question.get("queDefaultType")) or 1,
9848
- "default_value": question.get("queDefaultValue"),
10074
+ "default_type": _coerce_positive_int(question.get("queDefaultType")) if "queDefaultType" in question else None,
9849
10075
  }
10076
+ if "queDefaultValue" in question:
10077
+ field["default_value"] = question.get("queDefaultValue")
9850
10078
  if field_type in {FieldType.single_select.value, FieldType.multi_select.value, FieldType.boolean.value}:
9851
10079
  options = question.get("options")
9852
10080
  if isinstance(options, list):
@@ -9870,17 +10098,32 @@ def _parse_field(question: dict[str, Any], *, field_id_hint: str | None = None)
9870
10098
  field["target_app_key"] = reference.get("referAppKey")
9871
10099
  field["relation_mode"] = _relation_mode_from_optional_data_num(reference.get("optionalDataNum"))
9872
10100
  refer_questions = reference.get("referQuestions") if isinstance(reference.get("referQuestions"), list) else []
10101
+ refer_auth_questions = reference.get("referAuthQues") if isinstance(reference.get("referAuthQues"), list) else []
10102
+ refer_auth_by_que_id: dict[int, int] = {}
10103
+ for raw_item in refer_auth_questions:
10104
+ if not isinstance(raw_item, dict):
10105
+ continue
10106
+ que_id = _coerce_nonnegative_int(raw_item.get("queId"))
10107
+ que_auth = _coerce_nonnegative_int(raw_item.get("queAuth"))
10108
+ if que_id is None or que_auth is None or que_id in refer_auth_by_que_id:
10109
+ continue
10110
+ refer_auth_by_que_id[que_id] = que_auth
9873
10111
  visible_fields: list[dict[str, Any]] = []
9874
10112
  display_field_que_id = _coerce_nonnegative_int(reference.get("referQueId"))
9875
10113
  display_field_name: str | None = None
9876
10114
  for item in refer_questions:
9877
10115
  if not isinstance(item, dict):
9878
10116
  continue
10117
+ que_id = _coerce_nonnegative_int(item.get("queId"))
10118
+ que_auth = _coerce_nonnegative_int(item.get("queAuth"))
10119
+ if que_auth is None and que_id is not None:
10120
+ que_auth = refer_auth_by_que_id.get(que_id)
9879
10121
  selector = {
9880
- "que_id": _coerce_nonnegative_int(item.get("queId")),
10122
+ "que_id": que_id,
9881
10123
  "name": str(item.get("queTitle") or "").strip() or None,
9882
10124
  }
9883
- visible_fields.append(selector)
10125
+ if que_auth != _REFERENCE_FIELD_HIDDEN_AUTH:
10126
+ visible_fields.append(selector)
9884
10127
  if display_field_que_id is not None and selector["que_id"] == display_field_que_id:
9885
10128
  display_field_name = selector["name"]
9886
10129
  if display_field_name is None and visible_fields:
@@ -9960,6 +10203,7 @@ def _parse_field(question: dict[str, Any], *, field_id_hint: str | None = None)
9960
10203
  continue
9961
10204
  subfields.append(_parse_field(sub_question))
9962
10205
  field["subfields"] = subfields
10206
+ field["_question_template"] = deepcopy(question)
9963
10207
  return field
9964
10208
 
9965
10209
 
@@ -10505,14 +10749,14 @@ def _parse_code_block_inputs_and_body(code_content: str) -> tuple[list[dict[str,
10505
10749
  return inputs, body
10506
10750
 
10507
10751
 
10508
- def _compact_public_field_read(*, field: dict[str, Any], layout: dict[str, Any]) -> dict[str, Any]:
10752
+ def _compact_public_field_read(*, field: dict[str, Any], layout: dict[str, Any], nested: bool = False) -> dict[str, Any]:
10509
10753
  payload = {
10510
10754
  "field_id": field.get("field_id"),
10511
10755
  "que_id": field.get("que_id"),
10512
10756
  "name": field.get("name"),
10513
10757
  "type": field.get("type"),
10514
10758
  "required": bool(field.get("required")),
10515
- "section_id": _find_field_section_id(layout, str(field.get("name") or "")),
10759
+ "section_id": None if nested else _find_field_section_id(layout, str(field.get("name") or "")),
10516
10760
  }
10517
10761
  if field.get("type") == FieldType.relation.value:
10518
10762
  payload["target_app_key"] = field.get("target_app_key")
@@ -10545,6 +10789,12 @@ def _compact_public_field_read(*, field: dict[str, Any], layout: dict[str, Any])
10545
10789
  payload["custom_button_text"] = field.get("custom_button_text")
10546
10790
  if field.get("metadata_unverified") is not None:
10547
10791
  payload["metadata_unverified"] = bool(field.get("metadata_unverified"))
10792
+ if field.get("type") == FieldType.subtable.value:
10793
+ payload["subfields"] = [
10794
+ _compact_public_field_read(field=subfield, layout=layout, nested=True)
10795
+ for subfield in cast(list[dict[str, Any]], field.get("subfields") or [])
10796
+ if isinstance(subfield, dict)
10797
+ ]
10548
10798
  return payload
10549
10799
 
10550
10800
 
@@ -10840,59 +11090,119 @@ def _code_block_binding_equal(left: Any, right: Any) -> bool:
10840
11090
  return _normalize_code_block_binding(left) == _normalize_code_block_binding(right)
10841
11091
 
10842
11092
 
11093
+ _SAFE_SUBFIELD_MUTATION_KEYS = frozenset({"name", "required", "description", "subfield_updates"})
11094
+
11095
+
11096
+ def _validate_safe_subfield_mutation(*, payload: dict[str, Any], location: str) -> None:
11097
+ unsupported = sorted(key for key in payload if key not in _SAFE_SUBFIELD_MUTATION_KEYS)
11098
+ if unsupported:
11099
+ raise ValueError(
11100
+ f"{location} only supports safe overlay keys: name, required, description, subfield_updates; "
11101
+ f"unsupported keys: {', '.join(unsupported)}"
11102
+ )
11103
+
11104
+
11105
+ def _apply_subfield_updates(field: dict[str, Any], raw_updates: list[Any]) -> None:
11106
+ if str(field.get("type") or "") != FieldType.subtable.value:
11107
+ raise ValueError("subfield_updates can only target subtable fields")
11108
+ subfields = [subfield for subfield in cast(list[dict[str, Any]], field.get("subfields") or []) if isinstance(subfield, dict)]
11109
+ for index, raw_item in enumerate(raw_updates):
11110
+ patch = FieldUpdatePatch.model_validate(raw_item)
11111
+ payload = patch.set.model_dump(mode="json", exclude_none=True)
11112
+ _validate_safe_subfield_mutation(payload=payload, location=f"subfield_updates[{index}].set")
11113
+ target = _resolve_field_selector_with_uniqueness(
11114
+ fields=subfields,
11115
+ selector_payload=patch.selector.model_dump(mode="json", exclude_none=True),
11116
+ location=f"subfield_updates[{index}].selector",
11117
+ )
11118
+ _apply_field_mutation(target, patch.set)
11119
+ field["subfields"] = subfields
11120
+
11121
+
10843
11122
  def _apply_field_mutation(field: dict[str, Any], mutation: Any) -> None:
10844
11123
  payload = mutation.model_dump(mode="json", exclude_none=True)
10845
11124
  relation_config_explicit = (
10846
11125
  payload.get("type") == FieldType.relation.value
10847
11126
  or any(key in payload for key in ("target_app_key", "display_field", "visible_fields", "relation_mode"))
10848
11127
  )
11128
+ question_overlay_keys = set(cast(list[str], field.get("_question_overlay_keys") or []))
11129
+ question_rebuild_required = bool(field.get("_question_rebuild_required"))
10849
11130
  if "name" in payload:
10850
11131
  field["name"] = payload["name"]
11132
+ question_overlay_keys.add("name")
10851
11133
  if "type" in payload:
10852
11134
  field["type"] = payload["type"]
11135
+ question_rebuild_required = True
10853
11136
  if "required" in payload:
10854
11137
  field["required"] = payload["required"]
11138
+ question_overlay_keys.add("required")
10855
11139
  if "description" in payload:
10856
11140
  field["description"] = payload["description"]
11141
+ question_overlay_keys.add("description")
10857
11142
  if "options" in payload:
10858
11143
  field["options"] = list(payload["options"])
11144
+ question_rebuild_required = True
10859
11145
  if "target_app_key" in payload:
10860
11146
  field["target_app_key"] = payload["target_app_key"]
11147
+ question_rebuild_required = True
10861
11148
  if "display_field" in payload:
10862
11149
  field["display_field"] = payload["display_field"]
11150
+ question_rebuild_required = True
10863
11151
  if "visible_fields" in payload:
10864
11152
  field["visible_fields"] = list(payload["visible_fields"])
11153
+ question_rebuild_required = True
10865
11154
  if "relation_mode" in payload:
10866
11155
  field["relation_mode"] = payload["relation_mode"]
11156
+ question_rebuild_required = True
10867
11157
  if "department_scope" in payload:
10868
11158
  field["department_scope"] = payload["department_scope"]
11159
+ question_rebuild_required = True
10869
11160
  if "remote_lookup_config" in payload:
10870
11161
  field["remote_lookup_config"] = payload["remote_lookup_config"]
10871
11162
  field["config"] = deepcopy(payload["remote_lookup_config"])
10872
11163
  field["_explicit_remote_lookup_config"] = True
11164
+ question_rebuild_required = True
10873
11165
  if "q_linker_binding" in payload:
10874
11166
  field["q_linker_binding"] = payload["q_linker_binding"]
10875
11167
  if "remote_lookup_config" not in payload:
10876
11168
  field["_explicit_remote_lookup_config"] = False
11169
+ question_rebuild_required = True
10877
11170
  if "code_block_config" in payload:
10878
11171
  field["code_block_config"] = payload["code_block_config"]
10879
11172
  field["config"] = deepcopy(payload["code_block_config"])
11173
+ question_rebuild_required = True
10880
11174
  if "code_block_binding" in payload:
10881
11175
  field["code_block_binding"] = payload["code_block_binding"]
10882
11176
  field["_explicit_code_block_binding"] = True
11177
+ question_rebuild_required = True
10883
11178
  if "auto_trigger" in payload:
10884
11179
  field["auto_trigger"] = payload["auto_trigger"]
11180
+ question_rebuild_required = True
10885
11181
  if "custom_button_text_enabled" in payload:
10886
11182
  field["custom_button_text_enabled"] = payload["custom_button_text_enabled"]
11183
+ question_rebuild_required = True
10887
11184
  if "custom_button_text" in payload:
10888
11185
  field["custom_button_text"] = payload["custom_button_text"]
11186
+ question_rebuild_required = True
10889
11187
  if "subfields" in payload:
10890
11188
  field["subfields"] = [_field_patch_to_internal(item) for item in payload["subfields"]]
11189
+ question_rebuild_required = True
11190
+ if "subfield_updates" in payload:
11191
+ _apply_subfield_updates(field, payload["subfield_updates"])
10891
11192
  if relation_config_explicit:
10892
11193
  field["_relation_config_explicit"] = True
11194
+ question_rebuild_required = True
10893
11195
  elif payload.get("type") and payload.get("type") != FieldType.relation.value:
10894
11196
  field.pop("_relation_config_explicit", None)
10895
11197
  field.pop("_reference_config_template", None)
11198
+ if question_overlay_keys:
11199
+ field["_question_overlay_keys"] = sorted(question_overlay_keys)
11200
+ else:
11201
+ field.pop("_question_overlay_keys", None)
11202
+ if question_rebuild_required:
11203
+ field["_question_rebuild_required"] = True
11204
+ else:
11205
+ field.pop("_question_rebuild_required", None)
10896
11206
 
10897
11207
 
10898
11208
  def _resolve_field_selector_with_uniqueness(
@@ -12080,6 +12390,7 @@ def _warnings_from_verification_hints(hints: list[str]) -> list[dict[str, Any]]:
12080
12390
  "package attachment not verified": _warning("PACKAGE_ATTACHMENT_UNVERIFIED", "package attachment is not verified"),
12081
12391
  "layout has unplaced fields": _warning("LAYOUT_HAS_UNPLACED_FIELDS", "layout still contains unplaced fields"),
12082
12392
  "no public views detected": _warning("NO_PUBLIC_VIEWS", "no public views were detected"),
12393
+ "schema_read_unavailable": _warning("SCHEMA_READ_UNAVAILABLE", "schema summary readback is unavailable"),
12083
12394
  "views_read_unavailable": _warning("VIEWS_READ_UNAVAILABLE", "views summary readback is unavailable"),
12084
12395
  "workflow_read_unavailable": _warning("WORKFLOW_READ_UNAVAILABLE", "workflow summary readback is unavailable"),
12085
12396
  }
@@ -12380,6 +12691,26 @@ def _public_package_items_from_tag_items(tag_items: Any) -> list[JSONObject]:
12380
12691
  return public_items
12381
12692
 
12382
12693
 
12694
+ def _select_package_layout_tag_items(*, detail: Any, base: Any) -> list[Any] | None:
12695
+ base_tag_items = base.get("tagItems") if isinstance(base, dict) and isinstance(base.get("tagItems"), list) else None
12696
+ detail_tag_items = detail.get("tagItems") if isinstance(detail, dict) and isinstance(detail.get("tagItems"), list) else None
12697
+ if _package_tag_items_include_groups(base_tag_items):
12698
+ return deepcopy(base_tag_items)
12699
+ if _package_tag_items_include_groups(detail_tag_items):
12700
+ return deepcopy(detail_tag_items)
12701
+ if detail_tag_items is not None:
12702
+ return deepcopy(detail_tag_items)
12703
+ if base_tag_items is not None:
12704
+ return deepcopy(base_tag_items)
12705
+ return None
12706
+
12707
+
12708
+ def _package_tag_items_include_groups(tag_items: Any) -> bool:
12709
+ if not isinstance(tag_items, list):
12710
+ return False
12711
+ return any(isinstance(item, dict) and _coerce_positive_int(item.get("itemType")) == 3 for item in tag_items)
12712
+
12713
+
12383
12714
  def _flatten_package_resource_identities(items: Any, *, public: bool) -> set[tuple[str, str]]:
12384
12715
  flattened: set[tuple[str, str]] = set()
12385
12716
 
@@ -12690,119 +13021,1058 @@ def _verify_package_attachment(packages: PackageTools, *, profile: str, tag_id:
12690
13021
  return last_result
12691
13022
 
12692
13023
 
12693
- def _field_to_question(field: dict[str, Any], *, temp_id: int) -> dict[str, Any]:
12694
- question, _next_temp_id = build_question(
12695
- {
12696
- "label": field["name"],
12697
- "type": field["type"],
12698
- "required": field.get("required", False),
12699
- "description": field.get("description"),
12700
- "options": field.get("options", []),
12701
- "target_entity_id": field.get("target_app_key") or "__TARGET_APP_KEY__",
12702
- "target_field_id": field.get("target_field_id"),
12703
- "config": deepcopy(field.get("config") or {}),
12704
- "subfields": [
12705
- {
12706
- "label": subfield["name"],
12707
- "type": subfield["type"],
12708
- "required": subfield.get("required", False),
12709
- "description": subfield.get("description"),
12710
- "options": subfield.get("options", []),
12711
- "target_entity_id": subfield.get("target_app_key") or "__TARGET_APP_KEY__",
12712
- "subfields": subfield.get("subfields", []),
12713
- }
12714
- for subfield in field.get("subfields", [])
12715
- ],
12716
- },
12717
- temp_id,
12718
- )
12719
- if _coerce_nonnegative_int(field.get("que_id")) is not None:
12720
- question["queId"] = field["que_id"]
12721
- else:
12722
- question["queTempId"] = temp_id
12723
- field["que_temp_id"] = temp_id
12724
- if field.get("default_type") is not None:
12725
- question["queDefaultType"] = _coerce_positive_int(field.get("default_type")) or 1
12726
- if "default_value" in field:
12727
- question["queDefaultValue"] = field.get("default_value")
12728
- if field.get("type") == FieldType.relation.value:
12729
- preserved_reference = (
12730
- deepcopy(field.get("_reference_config_template"))
12731
- if not bool(field.get("_relation_config_explicit")) and isinstance(field.get("_reference_config_template"), dict)
12732
- else None
12733
- )
12734
- if preserved_reference is not None:
12735
- preserved_reference["referAppKey"] = field.get("target_app_key")
12736
- preserved_reference["_targetEntityId"] = field.get("target_app_key")
12737
- question["referenceConfig"] = preserved_reference
12738
- else:
12739
- reference = question.get("referenceConfig") if isinstance(question.get("referenceConfig"), dict) else {}
12740
- reference["referAppKey"] = field.get("target_app_key")
12741
- reference["_targetEntityId"] = field.get("target_app_key")
12742
- if field.get("target_field_que_id") is not None:
12743
- reference["referQueId"] = field.get("target_field_que_id")
12744
- reference["optionalDataNum"] = _relation_mode_to_optional_data_num(field.get("relation_mode"))
12745
- question["referenceConfig"] = reference
12746
- if field.get("type") == FieldType.department.value:
12747
- scope_type, scope_payload = _serialize_department_scope_for_question(field.get("department_scope"))
12748
- question["deptSelectScopeType"] = scope_type
12749
- question["deptSelectScope"] = scope_payload
12750
- if field.get("type") == FieldType.code_block.value:
12751
- code_block_config = _normalize_code_block_config(field.get("code_block_config") or field.get("config") or {}) or {
12752
- "config_mode": 1,
12753
- "code_content": "",
12754
- "being_hide_on_form": False,
12755
- "result_alias_path": [],
12756
- }
12757
- question["codeBlockConfig"] = {
12758
- "configMode": code_block_config["config_mode"],
12759
- "codeContent": code_block_config["code_content"],
12760
- "resultAliasPath": [
12761
- _serialize_code_block_alias_path_item(item) for item in code_block_config.get("result_alias_path", [])
12762
- ],
12763
- "beingHideOnForm": code_block_config["being_hide_on_form"],
12764
- }
12765
- question["autoTrigger"] = bool(field.get("auto_trigger", False))
12766
- question["customBtnTextStatus"] = bool(field.get("custom_button_text_enabled", False))
12767
- question["customBtnText"] = str(field.get("custom_button_text") or "")
12768
- if field.get("type") == FieldType.q_linker.value:
12769
- remote_lookup_config = _normalize_remote_lookup_config(field.get("remote_lookup_config") or field.get("config") or {}) or {
12770
- "config_mode": 1,
12771
- "url": "",
12772
- "method": "GET",
12773
- "headers": [],
12774
- "body_type": 1,
12775
- "url_encoded_value": [],
12776
- "json_value": None,
12777
- "xml_value": None,
12778
- "result_type": 1,
12779
- "result_format_path": [],
12780
- "query_params": [],
12781
- "auto_trigger": None,
12782
- "custom_button_text_enabled": None,
12783
- "custom_button_text": None,
12784
- "being_insert_value_directly": None,
12785
- "being_hide_on_form": None,
12786
- }
12787
- question["remoteLookupConfig"] = {
12788
- "configMode": remote_lookup_config["config_mode"],
12789
- "url": remote_lookup_config["url"],
12790
- "method": remote_lookup_config["method"],
12791
- "headers": [_serialize_remote_lookup_key_value_item(item) for item in remote_lookup_config.get("headers", [])],
12792
- "bodyType": remote_lookup_config["body_type"],
12793
- "urlEncodedValue": [_serialize_remote_lookup_key_value_item(item) for item in remote_lookup_config.get("url_encoded_value", [])],
12794
- "jsonValue": remote_lookup_config.get("json_value"),
12795
- "xmlValue": remote_lookup_config.get("xml_value"),
12796
- "resultType": remote_lookup_config["result_type"],
12797
- "resultFormatPath": [_serialize_q_linker_alias_path_item(item) for item in remote_lookup_config.get("result_format_path", [])],
12798
- "queryParams": [_serialize_remote_lookup_key_value_item(item) for item in remote_lookup_config.get("query_params", [])],
12799
- "beingInsertValueDirectly": bool(remote_lookup_config.get("being_insert_value_directly", False)),
12800
- "beingHideOnForm": bool(remote_lookup_config.get("being_hide_on_form", False)),
12801
- }
12802
- if remote_lookup_config["config_mode"] == 1:
12803
- question["remoteLookupConfig"]["openAppConfig"] = {"event": {"eventId": 0, "name": "custom"}}
12804
- question["autoTrigger"] = bool(field.get("auto_trigger", remote_lookup_config.get("auto_trigger", False)))
12805
- question["customBtnTextStatus"] = bool(field.get("custom_button_text_enabled", remote_lookup_config.get("custom_button_text_enabled", False)))
13024
+ def _field_question_overlay_keys(field: dict[str, Any]) -> set[str]:
13025
+ raw_value = field.get("_question_overlay_keys")
13026
+ if isinstance(raw_value, set):
13027
+ return {str(item) for item in raw_value if isinstance(item, str) and item}
13028
+ if isinstance(raw_value, list):
13029
+ return {str(item) for item in raw_value if isinstance(item, str) and item}
13030
+ return set()
13031
+
13032
+
13033
+ def _field_needs_question_rebuild(field: dict[str, Any]) -> bool:
13034
+ return not isinstance(field.get("_question_template"), dict) or bool(field.get("_question_rebuild_required"))
13035
+
13036
+
13037
+ def _extract_template_row_lengths(schema: dict[str, Any]) -> tuple[dict[int, int], dict[str, int]]:
13038
+ lengths_by_que_id: dict[int, int] = {}
13039
+ lengths_by_title: dict[str, int] = {}
13040
+
13041
+ def remember_row(row: Any) -> None:
13042
+ if not isinstance(row, list):
13043
+ return
13044
+ questions = [question for question in row if isinstance(question, dict)]
13045
+ row_length = len(questions)
13046
+ if row_length <= 0:
13047
+ return
13048
+ for question in questions:
13049
+ que_id = _coerce_nonnegative_int(question.get("queId"))
13050
+ if que_id is not None:
13051
+ lengths_by_que_id[que_id] = row_length
13052
+ title = str(question.get("queTitle") or "").strip()
13053
+ if title:
13054
+ lengths_by_title[title] = row_length
13055
+
13056
+ for row in schema.get("formQues", []) or []:
13057
+ if not isinstance(row, list):
13058
+ continue
13059
+ if len(row) == 1 and isinstance(row[0], dict) and _coerce_positive_int(row[0].get("queType")) == 24:
13060
+ for inner_row in row[0].get("innerQuestions", []) or []:
13061
+ remember_row(inner_row)
13062
+ continue
13063
+ remember_row(row)
13064
+ return lengths_by_que_id, lengths_by_title
13065
+
13066
+
13067
+ def _field_template_row_length(
13068
+ field: dict[str, Any],
13069
+ *,
13070
+ lengths_by_que_id: dict[int, int],
13071
+ lengths_by_title: dict[str, int],
13072
+ ) -> int | None:
13073
+ que_id = _coerce_nonnegative_int(field.get("que_id"))
13074
+ if que_id is not None and que_id in lengths_by_que_id:
13075
+ return lengths_by_que_id[que_id]
13076
+ template = field.get("_question_template")
13077
+ if isinstance(template, dict):
13078
+ template_que_id = _coerce_nonnegative_int(template.get("queId"))
13079
+ if template_que_id is not None and template_que_id in lengths_by_que_id:
13080
+ return lengths_by_que_id[template_que_id]
13081
+ template_title = str(template.get("queTitle") or "").strip()
13082
+ if template_title and template_title in lengths_by_title:
13083
+ return lengths_by_title[template_title]
13084
+ field_name = str(field.get("name") or "").strip()
13085
+ if field_name and field_name in lengths_by_title:
13086
+ return lengths_by_title[field_name]
13087
+ return None
13088
+
13089
+
13090
+ def _row_needs_width_reflow(expected_template_lengths: list[int], current_row_length: int) -> bool:
13091
+ if current_row_length <= 0:
13092
+ return False
13093
+ return any(length != current_row_length for length in expected_template_lengths)
13094
+
13095
+
13096
+ _FORM_SAVE_BASE_KEYS = (
13097
+ "formDesc",
13098
+ "formTheme",
13099
+ "formAttach",
13100
+ "formStyle",
13101
+ "serialNumType",
13102
+ "serialNumConfig",
13103
+ "attachVisibleOnlyConfig",
13104
+ "externalLang",
13105
+ "hideCopyright",
13106
+ )
13107
+
13108
+ _QUESTION_RELATION_SAVE_KEYS = (
13109
+ "queId",
13110
+ "relationType",
13111
+ "displayedQueId",
13112
+ "qlinkerAlias",
13113
+ "displayedQueInfo",
13114
+ "aliasConfig",
13115
+ "matchRules",
13116
+ "tableMatchRules",
13117
+ "matchRuleType",
13118
+ "matchRuleFormula",
13119
+ "sortConfig",
13120
+ )
13121
+
13122
+ _RELATION_QUESTION_SAVE_KEYS = (
13123
+ "queId",
13124
+ "queTempId",
13125
+ "queType",
13126
+ "queOriginType",
13127
+ "queTitle",
13128
+ "queWidth",
13129
+ "scanType",
13130
+ "status",
13131
+ "required",
13132
+ "queHint",
13133
+ "linkedQuestions",
13134
+ "logicalShow",
13135
+ "queDefaultType",
13136
+ "queDefaultValue",
13137
+ "queDefaultValues",
13138
+ "subQueWidth",
13139
+ "innerQuestions",
13140
+ "minOpts",
13141
+ "maxOpts",
13142
+ "beingHide",
13143
+ "beingDesensitized",
13144
+ "relationDisplayMode",
13145
+ "customRenderConfig",
13146
+ )
13147
+
13148
+ _REFERENCE_CONFIG_SAVE_KEYS = (
13149
+ "referAppKey",
13150
+ "referQueId",
13151
+ "customButtonText",
13152
+ "beingTableSource",
13153
+ "referMatchRules",
13154
+ "canAddData",
13155
+ "dataAdditionButtonText",
13156
+ "canViewProcessLog",
13157
+ "optionalDataNum",
13158
+ "beingDataLogVisible",
13159
+ "beingDefaultFormulaAutoFillEnabled",
13160
+ "defaultValueMatchRules",
13161
+ "configShowForm",
13162
+ "configSortFieldId",
13163
+ "configAsc",
13164
+ "dataShowForm",
13165
+ "defaultRow",
13166
+ "fieldNameShow",
13167
+ "dataSortFieldId",
13168
+ "dataSortAsc",
13169
+ )
13170
+
13171
+ _REFERENCE_QUESTION_SAVE_KEYS = (
13172
+ "queId",
13173
+ "queTitle",
13174
+ "queType",
13175
+ "queAuth",
13176
+ "ordinal",
13177
+ )
13178
+
13179
+ _REFERENCE_FILL_RULE_SAVE_KEYS = (
13180
+ "queId",
13181
+ "relatedQueId",
13182
+ "queTitle",
13183
+ "relatedQueTitle",
13184
+ )
13185
+
13186
+ _REFERENCE_AUTH_QUESTION_SAVE_KEYS = (
13187
+ "queId",
13188
+ "queAuth",
13189
+ )
13190
+
13191
+
13192
+ def _copy_present_keys(
13193
+ source: dict[str, Any],
13194
+ keys: tuple[str, ...],
13195
+ *,
13196
+ keep_none_keys: tuple[str, ...] = (),
13197
+ ) -> dict[str, Any]:
13198
+ payload: dict[str, Any] = {}
13199
+ keep_none = set(keep_none_keys)
13200
+ for key in keys:
13201
+ if key not in source:
13202
+ continue
13203
+ value = source.get(key)
13204
+ if value is None and key not in keep_none:
13205
+ continue
13206
+ payload[key] = deepcopy(value)
13207
+ return payload
13208
+
13209
+
13210
+ def _looks_like_backend_encoded_formula(value: str) -> bool:
13211
+ if len(value) <= 32:
13212
+ return False
13213
+ encoded = value[16:-16]
13214
+ if not encoded:
13215
+ return False
13216
+ try:
13217
+ decoded = base64.b64decode(encoded, validate=True).decode("utf-8")
13218
+ unquote_plus(decoded)
13219
+ except Exception:
13220
+ return False
13221
+ return True
13222
+
13223
+
13224
+ def _encode_formula_for_backend_save(value: Any) -> Any:
13225
+ if not isinstance(value, str) or not value:
13226
+ return value
13227
+ if _looks_like_backend_encoded_formula(value):
13228
+ return value
13229
+ encoded = quote_plus(value, encoding="utf-8")
13230
+ b64_value = base64.b64encode(encoded.encode("utf-8")).decode("ascii")
13231
+ alphabet = string.ascii_letters + string.digits
13232
+ prefix = "".join(random.choice(alphabet) for _ in range(16))
13233
+ suffix = "".join(random.choice(alphabet) for _ in range(16))
13234
+ return f"{prefix}{b64_value}{suffix}"
13235
+
13236
+
13237
+ def _normalize_formula_defaults_for_save(value: Any) -> None:
13238
+ if isinstance(value, list):
13239
+ for item in value:
13240
+ _normalize_formula_defaults_for_save(item)
13241
+ return
13242
+ if not isinstance(value, dict):
13243
+ return
13244
+ if _coerce_any_int(value.get("queDefaultType")) == DEFAULT_TYPE_FORMULA and value.get("queDefaultValue"):
13245
+ value["queDefaultValue"] = _encode_formula_for_backend_save(value.get("queDefaultValue"))
13246
+ for key in ("subQuestions", "innerQuestions"):
13247
+ nested = value.get(key)
13248
+ if isinstance(nested, (list, dict)):
13249
+ _normalize_formula_defaults_for_save(nested)
13250
+
13251
+
13252
+ def _normalize_reference_question_for_save(value: Any, *, ordinal: int) -> dict[str, Any] | None:
13253
+ if not isinstance(value, dict):
13254
+ return None
13255
+ payload = _copy_present_keys(value, _REFERENCE_QUESTION_SAVE_KEYS)
13256
+ que_id = _coerce_any_int(value.get("queId"))
13257
+ if que_id is not None:
13258
+ payload["queId"] = que_id
13259
+ if "ordinal" not in payload:
13260
+ payload["ordinal"] = _coerce_nonnegative_int(value.get("ordinal"))
13261
+ if payload.get("ordinal") is None:
13262
+ payload["ordinal"] = ordinal
13263
+ if not any(key in payload for key in ("queId", "queTitle", "queType")):
13264
+ return None
13265
+ return payload
13266
+
13267
+
13268
+ def _normalize_reference_fill_rule_for_save(value: Any) -> dict[str, Any] | None:
13269
+ if not isinstance(value, dict):
13270
+ return None
13271
+ payload = _copy_present_keys(value, _REFERENCE_FILL_RULE_SAVE_KEYS)
13272
+ que_id = _coerce_nonnegative_int(value.get("queId"))
13273
+ related_que_id = _coerce_nonnegative_int(value.get("relatedQueId", value.get("referQueId")))
13274
+ if que_id is not None:
13275
+ payload["queId"] = que_id
13276
+ if related_que_id is not None:
13277
+ payload["relatedQueId"] = related_que_id
13278
+ if "relatedQueTitle" not in payload and value.get("referQueTitle") is not None:
13279
+ payload["relatedQueTitle"] = str(value.get("referQueTitle") or "")
13280
+ if "queId" not in payload or "relatedQueId" not in payload:
13281
+ return None
13282
+ return payload
13283
+
13284
+
13285
+ def _normalize_reference_auth_question_for_save(value: Any) -> dict[str, Any] | None:
13286
+ if not isinstance(value, dict):
13287
+ return None
13288
+ payload = _copy_present_keys(value, _REFERENCE_AUTH_QUESTION_SAVE_KEYS)
13289
+ que_id = _coerce_any_int(value.get("queId"))
13290
+ if que_id is not None:
13291
+ payload["queId"] = que_id
13292
+ que_auth = _coerce_nonnegative_int(value.get("queAuth"))
13293
+ if que_auth is not None:
13294
+ payload["queAuth"] = que_auth
13295
+ sub_ques = [
13296
+ item
13297
+ for item in (
13298
+ _normalize_reference_auth_question_for_save(raw_item)
13299
+ for raw_item in cast(list[Any], value.get("subQues") or [])
13300
+ )
13301
+ if item is not None
13302
+ ]
13303
+ inner_ques = [
13304
+ item
13305
+ for item in (
13306
+ _normalize_reference_auth_question_for_save(raw_item)
13307
+ for raw_item in cast(list[Any], value.get("innerQues") or [])
13308
+ )
13309
+ if item is not None
13310
+ ]
13311
+ if sub_ques or "subQues" in value:
13312
+ payload["subQues"] = sub_ques
13313
+ if inner_ques or "innerQues" in value:
13314
+ payload["innerQues"] = inner_ques
13315
+ if "queId" not in payload or "queAuth" not in payload:
13316
+ return None
13317
+ return payload
13318
+
13319
+
13320
+ def _dedupe_reference_auth_questions(auth_questions: list[dict[str, Any]]) -> list[dict[str, Any]]:
13321
+ deduped: list[dict[str, Any]] = []
13322
+ seen_que_ids: set[int] = set()
13323
+ for item in auth_questions:
13324
+ normalized_item = _normalize_reference_auth_question_for_save(item)
13325
+ if normalized_item is None:
13326
+ continue
13327
+ que_id = _coerce_any_int(normalized_item.get("queId"))
13328
+ if que_id is None or que_id in seen_que_ids:
13329
+ continue
13330
+ seen_que_ids.add(que_id)
13331
+ deduped.append(normalized_item)
13332
+ return deduped
13333
+
13334
+
13335
+ _REFERENCE_FIELD_HIDDEN_AUTH = 2
13336
+ _REFERENCE_FIELD_VISIBLE_AUTH = 3
13337
+
13338
+
13339
+ def _synthesize_reference_auth_questions_for_save(
13340
+ *,
13341
+ source: dict[str, Any],
13342
+ field: dict[str, Any],
13343
+ ) -> list[dict[str, Any]]:
13344
+ config = field.get("config") if isinstance(field.get("config"), dict) else {}
13345
+ synthesized: list[dict[str, Any]] = []
13346
+
13347
+ if isinstance(config.get("refer_auth_ques"), list):
13348
+ synthesized.extend(cast(list[dict[str, Any]], config.get("refer_auth_ques") or []))
13349
+ if synthesized:
13350
+ return _dedupe_reference_auth_questions(synthesized)
13351
+
13352
+ refer_question_ids_by_name: dict[str, int] = {}
13353
+ for raw_item in cast(list[Any], source.get("referQuestions") or []):
13354
+ if not isinstance(raw_item, dict):
13355
+ continue
13356
+ que_id = _coerce_any_int(raw_item.get("queId"))
13357
+ name = str(raw_item.get("queTitle") or "").strip()
13358
+ if que_id is None or not name or name in refer_question_ids_by_name:
13359
+ continue
13360
+ refer_question_ids_by_name[name] = que_id
13361
+
13362
+ visible_fields = cast(list[dict[str, Any]], field.get("visible_fields") or [])
13363
+ for item in visible_fields:
13364
+ if not isinstance(item, dict):
13365
+ continue
13366
+ que_id = _coerce_any_int(item.get("que_id"))
13367
+ if que_id is None:
13368
+ name = str(item.get("name") or "").strip()
13369
+ que_id = refer_question_ids_by_name.get(name)
13370
+ if que_id is None:
13371
+ continue
13372
+ synthesized.append({"queId": que_id, "queAuth": _REFERENCE_FIELD_VISIBLE_AUTH})
13373
+ if synthesized:
13374
+ return _dedupe_reference_auth_questions(synthesized)
13375
+
13376
+ auth_field_que_ids = cast(list[Any], config.get("auth_field_que_ids") or [])
13377
+ for raw_que_id in auth_field_que_ids:
13378
+ que_id = _coerce_any_int(raw_que_id)
13379
+ if que_id is None:
13380
+ continue
13381
+ synthesized.append({"queId": que_id, "queAuth": _REFERENCE_FIELD_VISIBLE_AUTH})
13382
+ if synthesized:
13383
+ return _dedupe_reference_auth_questions(synthesized)
13384
+
13385
+ for raw_item in cast(list[Any], source.get("referQuestions") or []):
13386
+ if not isinstance(raw_item, dict):
13387
+ continue
13388
+ que_id = _coerce_any_int(raw_item.get("queId"))
13389
+ if que_id is None:
13390
+ continue
13391
+ synthesized.append(
13392
+ {
13393
+ "queId": que_id,
13394
+ "queAuth": _REFERENCE_FIELD_VISIBLE_AUTH,
13395
+ }
13396
+ )
13397
+ if synthesized:
13398
+ return _dedupe_reference_auth_questions(synthesized)
13399
+
13400
+ fallback_que_id = _coerce_any_int(field.get("target_field_que_id"))
13401
+ if fallback_que_id is not None:
13402
+ synthesized.append({"queId": fallback_que_id, "queAuth": _REFERENCE_FIELD_VISIBLE_AUTH})
13403
+ return _dedupe_reference_auth_questions(synthesized)
13404
+
13405
+
13406
+ def _reference_question_auth_overrides_for_save(
13407
+ *,
13408
+ source: dict[str, Any],
13409
+ field: dict[str, Any],
13410
+ ) -> dict[int, int]:
13411
+ overrides: dict[int, int] = {}
13412
+ visible_que_ids: set[int] = set()
13413
+
13414
+ for item in cast(list[Any], field.get("visible_fields") or []):
13415
+ if not isinstance(item, dict):
13416
+ continue
13417
+ que_id = _coerce_any_int(item.get("que_id"))
13418
+ if que_id is not None:
13419
+ visible_que_ids.add(que_id)
13420
+
13421
+ if not visible_que_ids:
13422
+ refer_auth_ques = _synthesize_reference_auth_questions_for_save(source=source, field=field)
13423
+ for item in refer_auth_ques:
13424
+ que_id = _coerce_any_int(item.get("queId"))
13425
+ que_auth = _coerce_nonnegative_int(item.get("queAuth"))
13426
+ if que_id is None or que_auth is None:
13427
+ continue
13428
+ overrides[que_id] = que_auth
13429
+ if overrides:
13430
+ return overrides
13431
+
13432
+ for raw_item in cast(list[Any], source.get("referQuestions") or []):
13433
+ if not isinstance(raw_item, dict):
13434
+ continue
13435
+ que_id = _coerce_any_int(raw_item.get("queId"))
13436
+ if que_id is None:
13437
+ continue
13438
+ overrides[que_id] = (
13439
+ _REFERENCE_FIELD_VISIBLE_AUTH if que_id in visible_que_ids else _REFERENCE_FIELD_HIDDEN_AUTH
13440
+ )
13441
+ return overrides
13442
+
13443
+
13444
+ def _reference_question_matches_visible_selector(question: dict[str, Any], selector: dict[str, Any]) -> bool:
13445
+ question_que_id = _coerce_any_int(question.get("queId"))
13446
+ selector_que_id = _coerce_any_int(selector.get("que_id"))
13447
+ if question_que_id is not None and selector_que_id is not None and question_que_id == selector_que_id:
13448
+ return True
13449
+ question_name = str(question.get("queTitle") or "").strip()
13450
+ selector_name = str(selector.get("name") or "").strip()
13451
+ return bool(question_name and selector_name and question_name == selector_name)
13452
+
13453
+
13454
+ def _build_reference_question_from_visible_selector(
13455
+ selector: dict[str, Any],
13456
+ *,
13457
+ ordinal: int,
13458
+ ) -> dict[str, Any] | None:
13459
+ return _normalize_reference_question_for_save(
13460
+ {
13461
+ "queId": _coerce_any_int(selector.get("que_id")),
13462
+ "queTitle": str(selector.get("name") or "").strip() or None,
13463
+ "queType": str(selector.get("type") or "2"),
13464
+ "ordinal": ordinal,
13465
+ },
13466
+ ordinal=ordinal,
13467
+ )
13468
+
13469
+
13470
+ def _canonicalize_reference_questions_for_save(
13471
+ *,
13472
+ source: dict[str, Any],
13473
+ field: dict[str, Any],
13474
+ ) -> list[dict[str, Any]]:
13475
+ relation_config_explicit = bool(field.get("_relation_config_explicit"))
13476
+ normalized_source_questions = [
13477
+ item
13478
+ for item in (
13479
+ _normalize_reference_question_for_save(raw_item, ordinal=index)
13480
+ for index, raw_item in enumerate(cast(list[Any], source.get("referQuestions") or []), start=1)
13481
+ )
13482
+ if item is not None
13483
+ ]
13484
+ if not relation_config_explicit:
13485
+ return normalized_source_questions
13486
+
13487
+ display_field = field.get("display_field") if isinstance(field.get("display_field"), dict) else None
13488
+ visible_fields = [item for item in cast(list[Any], field.get("visible_fields") or []) if isinstance(item, dict)]
13489
+ ordered_visible_selectors: list[dict[str, Any]] = []
13490
+ if display_field is not None:
13491
+ ordered_visible_selectors.append(display_field)
13492
+ for item in visible_fields:
13493
+ if any(_relation_target_field_matches(existing, item) for existing in ordered_visible_selectors):
13494
+ continue
13495
+ ordered_visible_selectors.append(item)
13496
+
13497
+ if not ordered_visible_selectors:
13498
+ return normalized_source_questions
13499
+
13500
+ canonical_questions: list[dict[str, Any]] = []
13501
+ used_source_indexes: set[int] = set()
13502
+
13503
+ for ordinal, selector in enumerate(ordered_visible_selectors, start=1):
13504
+ matched_index: int | None = None
13505
+ matched_item: dict[str, Any] | None = None
13506
+ for index, item in enumerate(normalized_source_questions):
13507
+ if index in used_source_indexes:
13508
+ continue
13509
+ if _reference_question_matches_visible_selector(item, selector):
13510
+ matched_index = index
13511
+ matched_item = deepcopy(item)
13512
+ break
13513
+ if matched_item is None:
13514
+ matched_item = _build_reference_question_from_visible_selector(selector, ordinal=ordinal)
13515
+ if matched_item is None:
13516
+ continue
13517
+ matched_item["ordinal"] = ordinal
13518
+ canonical_questions.append(matched_item)
13519
+ if matched_index is not None:
13520
+ used_source_indexes.add(matched_index)
13521
+
13522
+ source_target_app_key = str(source.get("referAppKey") or "").strip()
13523
+ target_app_key = str(field.get("target_app_key") or "").strip()
13524
+ preserve_remaining_source_questions = not source_target_app_key or source_target_app_key == target_app_key
13525
+
13526
+ if preserve_remaining_source_questions:
13527
+ next_ordinal = len(canonical_questions) + 1
13528
+ for index, item in enumerate(normalized_source_questions):
13529
+ if index in used_source_indexes:
13530
+ continue
13531
+ remaining_item = deepcopy(item)
13532
+ remaining_item["ordinal"] = next_ordinal
13533
+ next_ordinal += 1
13534
+ canonical_questions.append(remaining_item)
13535
+
13536
+ return canonical_questions
13537
+
13538
+
13539
+ def _canonicalize_reference_auth_questions_for_save(
13540
+ *,
13541
+ source: dict[str, Any],
13542
+ refer_questions: list[dict[str, Any]],
13543
+ relation_config_explicit: bool,
13544
+ ) -> list[dict[str, Any]]:
13545
+ source_auth_questions = [
13546
+ item
13547
+ for item in (
13548
+ _normalize_reference_auth_question_for_save(raw_item)
13549
+ for raw_item in cast(list[Any], source.get("referAuthQues") or [])
13550
+ )
13551
+ if item is not None
13552
+ ]
13553
+ source_auth_by_que_id: dict[int, dict[str, Any]] = {}
13554
+ for item in source_auth_questions:
13555
+ que_id = _coerce_any_int(item.get("queId"))
13556
+ if que_id is None or que_id in source_auth_by_que_id:
13557
+ continue
13558
+ source_auth_by_que_id[que_id] = item
13559
+
13560
+ if not relation_config_explicit:
13561
+ auth_questions: list[dict[str, Any]] = []
13562
+ seen_que_ids: set[int] = set()
13563
+ refer_question_auth_by_que_id: dict[int, int] = {}
13564
+ for item in refer_questions:
13565
+ que_id = _coerce_any_int(item.get("queId"))
13566
+ que_auth = _coerce_nonnegative_int(item.get("queAuth"))
13567
+ if que_id is None or que_auth is None or que_id in refer_question_auth_by_que_id:
13568
+ continue
13569
+ refer_question_auth_by_que_id[que_id] = que_auth
13570
+
13571
+ for item in source_auth_questions:
13572
+ que_id = _coerce_any_int(item.get("queId"))
13573
+ if que_id is None or que_id in seen_que_ids:
13574
+ continue
13575
+ payload = deepcopy(item)
13576
+ if que_id in refer_question_auth_by_que_id:
13577
+ payload["queAuth"] = refer_question_auth_by_que_id[que_id]
13578
+ auth_questions.append(payload)
13579
+ seen_que_ids.add(que_id)
13580
+
13581
+ for item in refer_questions:
13582
+ que_id = _coerce_any_int(item.get("queId"))
13583
+ que_auth = _coerce_nonnegative_int(item.get("queAuth"))
13584
+ if que_id is None or que_auth is None or que_id in seen_que_ids:
13585
+ continue
13586
+ payload = deepcopy(source_auth_by_que_id.get(que_id) or {"queId": que_id})
13587
+ payload["queId"] = que_id
13588
+ payload["queAuth"] = que_auth
13589
+ auth_questions.append(payload)
13590
+ seen_que_ids.add(que_id)
13591
+
13592
+ return _dedupe_reference_auth_questions(auth_questions)
13593
+
13594
+ auth_questions: list[dict[str, Any]] = []
13595
+ for item in refer_questions:
13596
+ que_id = _coerce_any_int(item.get("queId"))
13597
+ que_auth = _coerce_nonnegative_int(item.get("queAuth"))
13598
+ if que_id is None or que_auth is None:
13599
+ continue
13600
+ payload = deepcopy(source_auth_by_que_id.get(que_id) or {"queId": que_id})
13601
+ payload["queId"] = que_id
13602
+ payload["queAuth"] = que_auth
13603
+ auth_questions.append(payload)
13604
+ return _dedupe_reference_auth_questions(auth_questions)
13605
+
13606
+
13607
+ def _enforce_reference_config_consistency_for_save(
13608
+ payload: dict[str, Any],
13609
+ *,
13610
+ field: dict[str, Any],
13611
+ ) -> dict[str, Any]:
13612
+ relation_config_explicit = bool(field.get("_relation_config_explicit"))
13613
+ refer_questions = [
13614
+ item
13615
+ for item in (
13616
+ _normalize_reference_question_for_save(raw_item, ordinal=index)
13617
+ for index, raw_item in enumerate(cast(list[Any], payload.get("referQuestions") or []), start=1)
13618
+ )
13619
+ if item is not None
13620
+ ]
13621
+ if not refer_questions:
13622
+ return payload
13623
+
13624
+ refer_auth_ques = _dedupe_reference_auth_questions(
13625
+ [
13626
+ item
13627
+ for item in (
13628
+ _normalize_reference_auth_question_for_save(raw_item)
13629
+ for raw_item in cast(list[Any], payload.get("referAuthQues") or [])
13630
+ )
13631
+ if item is not None
13632
+ ]
13633
+ )
13634
+ refer_auth_by_que_id: dict[int, int] = {}
13635
+ for item in refer_auth_ques:
13636
+ que_id = _coerce_any_int(item.get("queId"))
13637
+ que_auth = _coerce_nonnegative_int(item.get("queAuth"))
13638
+ if que_id is None or que_auth is None or que_id in refer_auth_by_que_id:
13639
+ continue
13640
+ refer_auth_by_que_id[que_id] = que_auth
13641
+
13642
+ display_field_que_id = _coerce_any_int(payload.get("referQueId"))
13643
+ if display_field_que_id is None:
13644
+ display_field_que_id = _coerce_any_int(field.get("target_field_que_id"))
13645
+ if display_field_que_id is not None:
13646
+ payload["referQueId"] = display_field_que_id
13647
+
13648
+ if relation_config_explicit:
13649
+ if display_field_que_id is not None and not any(
13650
+ _coerce_any_int(item.get("queId")) == display_field_que_id for item in refer_questions
13651
+ ):
13652
+ display_selector = field.get("display_field") if isinstance(field.get("display_field"), dict) else None
13653
+ display_question = (
13654
+ _build_reference_question_from_visible_selector(display_selector, ordinal=1)
13655
+ if display_selector is not None
13656
+ else None
13657
+ )
13658
+ if display_question is not None:
13659
+ display_question["queId"] = display_field_que_id
13660
+ display_question["queAuth"] = _REFERENCE_FIELD_VISIBLE_AUTH
13661
+ refer_questions = [display_question, *refer_questions]
13662
+
13663
+ if display_field_que_id is not None:
13664
+ display_questions = [
13665
+ item for item in refer_questions if _coerce_any_int(item.get("queId")) == display_field_que_id
13666
+ ]
13667
+ trailing_questions = [
13668
+ item for item in refer_questions if _coerce_any_int(item.get("queId")) != display_field_que_id
13669
+ ]
13670
+ refer_questions = [*display_questions, *trailing_questions]
13671
+
13672
+ for ordinal, item in enumerate(refer_questions, start=1):
13673
+ que_id = _coerce_any_int(item.get("queId"))
13674
+ if que_id is None:
13675
+ continue
13676
+ item["ordinal"] = ordinal
13677
+ item["queAuth"] = refer_auth_by_que_id.get(
13678
+ que_id,
13679
+ _coerce_nonnegative_int(item.get("queAuth")) or _REFERENCE_FIELD_VISIBLE_AUTH,
13680
+ )
13681
+ if display_field_que_id is not None and que_id == display_field_que_id:
13682
+ item["queAuth"] = _REFERENCE_FIELD_VISIBLE_AUTH
13683
+
13684
+ payload["referQuestions"] = refer_questions
13685
+ payload["referAuthQues"] = _canonicalize_reference_auth_questions_for_save(
13686
+ source={"referAuthQues": refer_auth_ques},
13687
+ refer_questions=refer_questions,
13688
+ relation_config_explicit=relation_config_explicit,
13689
+ )
13690
+ return payload
13691
+
13692
+
13693
+ def _normalize_reference_config_for_save(
13694
+ reference: Any,
13695
+ *,
13696
+ field: dict[str, Any],
13697
+ ) -> dict[str, Any]:
13698
+ source = reference if isinstance(reference, dict) else {}
13699
+ payload = _copy_present_keys(source, _REFERENCE_CONFIG_SAVE_KEYS)
13700
+ if str(field.get("target_app_key") or "").strip():
13701
+ payload["referAppKey"] = str(field.get("target_app_key") or "").strip()
13702
+ if field.get("target_field_que_id") is not None:
13703
+ payload["referQueId"] = _coerce_nonnegative_int(field.get("target_field_que_id"))
13704
+ if field.get("field_name_show") is not None:
13705
+ payload["fieldNameShow"] = bool(field.get("field_name_show"))
13706
+
13707
+ refer_question_auth_overrides = _reference_question_auth_overrides_for_save(source=source, field=field)
13708
+ refer_questions = _canonicalize_reference_questions_for_save(source=source, field=field)
13709
+ for index, normalized_item in enumerate(refer_questions, start=1):
13710
+ que_id = _coerce_any_int(normalized_item.get("queId"))
13711
+ if que_id is not None and que_id in refer_question_auth_overrides:
13712
+ normalized_item["queAuth"] = refer_question_auth_overrides[que_id]
13713
+ normalized_item["ordinal"] = index
13714
+ if refer_questions or "referQuestions" in source:
13715
+ payload["referQuestions"] = refer_questions
13716
+
13717
+ refer_fill_rules = [
13718
+ item
13719
+ for item in (
13720
+ _normalize_reference_fill_rule_for_save(raw_item)
13721
+ for raw_item in cast(list[Any], source.get("referFillRules") or [])
13722
+ )
13723
+ if item is not None
13724
+ ]
13725
+ if refer_fill_rules or "referFillRules" in source:
13726
+ payload["referFillRules"] = refer_fill_rules
13727
+
13728
+ refer_auth_ques = _canonicalize_reference_auth_questions_for_save(
13729
+ source=source,
13730
+ refer_questions=refer_questions,
13731
+ relation_config_explicit=bool(field.get("_relation_config_explicit")),
13732
+ )
13733
+ if not refer_auth_ques:
13734
+ refer_auth_ques = _synthesize_reference_auth_questions_for_save(source=source, field=field)
13735
+ if refer_auth_ques or "referAuthQues" in source:
13736
+ payload["referAuthQues"] = refer_auth_ques
13737
+
13738
+ return _enforce_reference_config_consistency_for_save(payload, field=field)
13739
+
13740
+
13741
+ def _normalize_relation_question_for_save(question: dict[str, Any], *, field: dict[str, Any]) -> dict[str, Any]:
13742
+ payload = _copy_present_keys(question, _RELATION_QUESTION_SAVE_KEYS)
13743
+ overlay_keys = _field_question_overlay_keys(field)
13744
+ que_id = _coerce_nonnegative_int(question.get("queId"))
13745
+ if que_id is not None:
13746
+ payload["queId"] = que_id
13747
+ que_temp_id = _coerce_nonnegative_int(question.get("queTempId"))
13748
+ if que_temp_id is not None and "queId" not in payload:
13749
+ payload["queTempId"] = que_temp_id
13750
+ payload["queType"] = _coerce_positive_int(question.get("queType")) or 25
13751
+ payload["queTitle"] = str(field.get("name") or question.get("queTitle") or "")
13752
+ if "required" in overlay_keys or "required" in question or field.get("required") is not None:
13753
+ payload["required"] = bool(field.get("required", question.get("required", False)))
13754
+ if "description" in overlay_keys:
13755
+ payload["queHint"] = "" if field.get("description") is None else str(field.get("description"))
13756
+ elif "queHint" in question and question.get("queHint") is not None:
13757
+ payload["queHint"] = str(question.get("queHint") or "")
13758
+ if field.get("default_type") is not None:
13759
+ payload["queDefaultType"] = _coerce_positive_int(field.get("default_type")) or 1
13760
+ if "default_value" in field:
13761
+ payload["queDefaultValue"] = field.get("default_value")
13762
+ payload["referenceConfig"] = _normalize_reference_config_for_save(question.get("referenceConfig"), field=field)
13763
+ return payload
13764
+
13765
+
13766
+ def _build_form_save_base_payload(current_schema: dict[str, Any], title: str) -> dict[str, Any]:
13767
+ payload: dict[str, Any] = {"formTitle": title}
13768
+ for key in _FORM_SAVE_BASE_KEYS:
13769
+ if key in current_schema:
13770
+ payload[key] = deepcopy(current_schema.get(key))
13771
+ payload["editVersionNo"] = int(current_schema.get("editVersionNo") or 1)
13772
+ payload["formQues"] = []
13773
+ payload["questionRelations"] = []
13774
+ return payload
13775
+
13776
+
13777
+ def _normalize_question_relations_for_save(question_relations: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
13778
+ normalized: list[dict[str, Any]] = []
13779
+ for relation in question_relations or []:
13780
+ if not isinstance(relation, dict):
13781
+ continue
13782
+ item: dict[str, Any] = {}
13783
+ for key in _QUESTION_RELATION_SAVE_KEYS:
13784
+ if key not in relation:
13785
+ continue
13786
+ value = relation.get(key)
13787
+ if value is None:
13788
+ continue
13789
+ if key == "matchRuleFormula":
13790
+ value = _encode_formula_for_backend_save(value)
13791
+ item[key] = deepcopy(value)
13792
+ if item:
13793
+ normalized.append(item)
13794
+ return normalized
13795
+
13796
+
13797
+ def _field_rename_maps(fields: list[dict[str, Any]]) -> tuple[dict[int, str], dict[str, str]]:
13798
+ by_que_id: dict[int, str] = {}
13799
+ by_title: dict[str, str] = {}
13800
+
13801
+ def visit(field: dict[str, Any]) -> None:
13802
+ if not isinstance(field, dict):
13803
+ return
13804
+ template = field.get("_question_template")
13805
+ if not isinstance(template, dict):
13806
+ for subfield in cast(list[dict[str, Any]], field.get("subfields") or []):
13807
+ visit(subfield)
13808
+ return
13809
+ old_title = str(template.get("queTitle") or "").strip()
13810
+ new_title = str(field.get("name") or "").strip()
13811
+ if not old_title or not new_title or old_title == new_title:
13812
+ for subfield in cast(list[dict[str, Any]], field.get("subfields") or []):
13813
+ visit(subfield)
13814
+ return
13815
+ que_id = _coerce_nonnegative_int(field.get("que_id"))
13816
+ if que_id is None:
13817
+ que_id = _coerce_nonnegative_int(template.get("queId"))
13818
+ if que_id is not None:
13819
+ by_que_id[que_id] = new_title
13820
+ by_title[old_title] = new_title
13821
+ for subfield in cast(list[dict[str, Any]], field.get("subfields") or []):
13822
+ visit(subfield)
13823
+
13824
+ for field in fields:
13825
+ visit(field)
13826
+ return by_que_id, by_title
13827
+
13828
+
13829
+ def _sync_question_title_references(value: Any, *, by_que_id: dict[int, str], by_title: dict[str, str]) -> None:
13830
+ if isinstance(value, list):
13831
+ for item in value:
13832
+ _sync_question_title_references(item, by_que_id=by_que_id, by_title=by_title)
13833
+ return
13834
+ if not isinstance(value, dict):
13835
+ return
13836
+
13837
+ title_keys = ("queTitle", "_field_id")
13838
+ que_id = _coerce_nonnegative_int(value.get("queId"))
13839
+ replacement = None
13840
+ if que_id is not None and que_id in by_que_id:
13841
+ replacement = by_que_id[que_id]
13842
+ elif que_id is None:
13843
+ for key in title_keys:
13844
+ current_title = str(value.get(key) or "").strip()
13845
+ if current_title and current_title in by_title:
13846
+ replacement = by_title[current_title]
13847
+ break
13848
+ if replacement is not None:
13849
+ for key in title_keys:
13850
+ current_title = str(value.get(key) or "").strip()
13851
+ if (que_id is not None and que_id in by_que_id and key in value) or (current_title and current_title in by_title):
13852
+ value[key] = replacement
13853
+ sup_id = _coerce_nonnegative_int(value.get("supId"))
13854
+ if sup_id is not None and sup_id in by_que_id and "supQueTitle" in value:
13855
+ value["supQueTitle"] = by_que_id[sup_id]
13856
+
13857
+ for child_value in value.values():
13858
+ if isinstance(child_value, (dict, list)):
13859
+ _sync_question_title_references(child_value, by_que_id=by_que_id, by_title=by_title)
13860
+
13861
+
13862
+ def _materialize_preserved_subtable_question(field: dict[str, Any], *, template: dict[str, Any]) -> dict[str, Any] | None:
13863
+ materialized_subquestions: list[dict[str, Any]] = []
13864
+ for subfield in cast(list[dict[str, Any]], field.get("subfields") or []):
13865
+ if not isinstance(subfield, dict):
13866
+ continue
13867
+ materialized = _materialize_preserved_question(subfield)
13868
+ if materialized is None:
13869
+ return None
13870
+ materialized_subquestions.append(materialized)
13871
+ template["subQuestions"] = materialized_subquestions
13872
+ template["innerQuestions"] = [deepcopy(materialized_subquestions)]
13873
+ return template
13874
+
13875
+
13876
+ def _materialize_preserved_question(field: dict[str, Any]) -> dict[str, Any] | None:
13877
+ template = deepcopy(field.get("_question_template"))
13878
+ if not isinstance(template, dict):
13879
+ return None
13880
+ overlay_keys = _field_question_overlay_keys(field)
13881
+ if "name" in overlay_keys:
13882
+ template["queTitle"] = str(field.get("name") or "")
13883
+ if "required" in overlay_keys:
13884
+ template["required"] = bool(field.get("required", False))
13885
+ if "description" in overlay_keys:
13886
+ description = field.get("description")
13887
+ template["queHint"] = "" if description is None else str(description)
13888
+ if str(field.get("type") or "") == FieldType.subtable.value:
13889
+ return _materialize_preserved_subtable_question(field, template=template)
13890
+ if str(field.get("type") or "") == FieldType.relation.value:
13891
+ return _normalize_relation_question_for_save(template, field=field)
13892
+ return template
13893
+
13894
+
13895
+ def _materialize_edit_question(field: dict[str, Any], *, temp_id: int) -> tuple[dict[str, Any], bool]:
13896
+ if not _field_needs_question_rebuild(field):
13897
+ preserved = _materialize_preserved_question(field)
13898
+ if preserved is not None:
13899
+ return preserved, True
13900
+ return _field_to_question(field, temp_id=temp_id), False
13901
+
13902
+
13903
+ def _field_to_question(field: dict[str, Any], *, temp_id: int) -> dict[str, Any]:
13904
+ built_question, _next_temp_id = build_question(
13905
+ {
13906
+ "label": field["name"],
13907
+ "type": field["type"],
13908
+ "required": field.get("required", False),
13909
+ "description": field.get("description"),
13910
+ "options": field.get("options", []),
13911
+ "target_entity_id": field.get("target_app_key") or "__TARGET_APP_KEY__",
13912
+ "target_field_id": field.get("target_field_id"),
13913
+ "config": deepcopy(field.get("config") or {}),
13914
+ "subfields": [
13915
+ {
13916
+ "label": subfield["name"],
13917
+ "type": subfield["type"],
13918
+ "required": subfield.get("required", False),
13919
+ "description": subfield.get("description"),
13920
+ "options": subfield.get("options", []),
13921
+ "target_entity_id": subfield.get("target_app_key") or "__TARGET_APP_KEY__",
13922
+ "subfields": subfield.get("subfields", []),
13923
+ }
13924
+ for subfield in field.get("subfields", [])
13925
+ ],
13926
+ },
13927
+ temp_id,
13928
+ )
13929
+ relation_config_explicit = bool(field.get("_relation_config_explicit"))
13930
+ relation_question_template = (
13931
+ deepcopy(field.get("_question_template"))
13932
+ if field.get("type") == FieldType.relation.value and isinstance(field.get("_question_template"), dict)
13933
+ else None
13934
+ )
13935
+ question = (
13936
+ relation_question_template
13937
+ if relation_question_template is not None and not relation_config_explicit
13938
+ else built_question
13939
+ )
13940
+ if relation_config_explicit and relation_question_template is not None:
13941
+ for key in ("queOriginType", "relationDisplayMode", "customRenderConfig"):
13942
+ if key in relation_question_template:
13943
+ question[key] = deepcopy(relation_question_template[key])
13944
+ if _coerce_nonnegative_int(field.get("que_id")) is not None:
13945
+ question["queId"] = field["que_id"]
13946
+ question.pop("queTempId", None)
13947
+ else:
13948
+ question["queId"] = 0
13949
+ question["queTempId"] = temp_id
13950
+ field["que_temp_id"] = temp_id
13951
+ question["queType"] = built_question.get("queType", question.get("queType"))
13952
+ question["queTitle"] = built_question.get("queTitle", field["name"])
13953
+ question["required"] = built_question.get("required", bool(field.get("required", False)))
13954
+ question["queHint"] = built_question.get("queHint", field.get("description") or "")
13955
+ if field.get("default_type") is not None:
13956
+ question["queDefaultType"] = _coerce_positive_int(field.get("default_type")) or 1
13957
+ if "default_value" in field:
13958
+ question["queDefaultValue"] = field.get("default_value")
13959
+ if field.get("type") == FieldType.relation.value:
13960
+ preserved_reference = (
13961
+ deepcopy(field.get("_reference_config_template"))
13962
+ if not relation_config_explicit and isinstance(field.get("_reference_config_template"), dict)
13963
+ else None
13964
+ )
13965
+ if preserved_reference is not None:
13966
+ preserved_reference["referAppKey"] = field.get("target_app_key")
13967
+ question["referenceConfig"] = preserved_reference
13968
+ else:
13969
+ existing_reference = (
13970
+ deepcopy(relation_question_template.get("referenceConfig"))
13971
+ if relation_question_template is not None and isinstance(relation_question_template.get("referenceConfig"), dict)
13972
+ else deepcopy(question.get("referenceConfig"))
13973
+ if isinstance(question.get("referenceConfig"), dict)
13974
+ else {}
13975
+ )
13976
+ reference = (
13977
+ existing_reference
13978
+ if relation_config_explicit
13979
+ else deepcopy(question.get("referenceConfig"))
13980
+ if isinstance(question.get("referenceConfig"), dict)
13981
+ else {}
13982
+ )
13983
+ built_reference = (
13984
+ deepcopy(built_question.get("referenceConfig"))
13985
+ if isinstance(built_question.get("referenceConfig"), dict)
13986
+ else {}
13987
+ )
13988
+ original_target_app_key = str(existing_reference.get("referAppKey") or "").strip()
13989
+ next_target_app_key = str(field.get("target_app_key") or "").strip()
13990
+ preserve_existing_reference_questions = (
13991
+ relation_config_explicit
13992
+ and bool(original_target_app_key)
13993
+ and original_target_app_key == next_target_app_key
13994
+ )
13995
+ if relation_config_explicit:
13996
+ for stale_key in ("customButtonText", "customAdvancedSetting", "configShowForm", "dataShowForm"):
13997
+ reference.pop(stale_key, None)
13998
+ for key in (
13999
+ "referQueId",
14000
+ "referQuestions",
14001
+ "referAuthQues",
14002
+ "optionalDataNum",
14003
+ "fieldNameShow",
14004
+ "_targetFieldId",
14005
+ ):
14006
+ if preserve_existing_reference_questions and key in {"referQuestions", "referAuthQues"}:
14007
+ continue
14008
+ if key in built_reference:
14009
+ reference[key] = deepcopy(built_reference[key])
14010
+ reference["referAppKey"] = field.get("target_app_key")
14011
+ reference["_targetEntityId"] = field.get("target_app_key")
14012
+ if field.get("target_field_que_id") is not None:
14013
+ reference["referQueId"] = field.get("target_field_que_id")
14014
+ question["referenceConfig"] = reference
14015
+ question = _normalize_relation_question_for_save(question, field=field)
14016
+ if field.get("type") == FieldType.department.value:
14017
+ scope_type, scope_payload = _serialize_department_scope_for_question(field.get("department_scope"))
14018
+ question["deptSelectScopeType"] = scope_type
14019
+ question["deptSelectScope"] = scope_payload
14020
+ if field.get("type") == FieldType.code_block.value:
14021
+ code_block_config = _normalize_code_block_config(field.get("code_block_config") or field.get("config") or {}) or {
14022
+ "config_mode": 1,
14023
+ "code_content": "",
14024
+ "being_hide_on_form": False,
14025
+ "result_alias_path": [],
14026
+ }
14027
+ question["codeBlockConfig"] = {
14028
+ "configMode": code_block_config["config_mode"],
14029
+ "codeContent": code_block_config["code_content"],
14030
+ "resultAliasPath": [
14031
+ _serialize_code_block_alias_path_item(item) for item in code_block_config.get("result_alias_path", [])
14032
+ ],
14033
+ "beingHideOnForm": code_block_config["being_hide_on_form"],
14034
+ }
14035
+ question["autoTrigger"] = bool(field.get("auto_trigger", False))
14036
+ question["customBtnTextStatus"] = bool(field.get("custom_button_text_enabled", False))
14037
+ question["customBtnText"] = str(field.get("custom_button_text") or "")
14038
+ if field.get("type") == FieldType.q_linker.value:
14039
+ remote_lookup_config = _normalize_remote_lookup_config(field.get("remote_lookup_config") or field.get("config") or {}) or {
14040
+ "config_mode": 1,
14041
+ "url": "",
14042
+ "method": "GET",
14043
+ "headers": [],
14044
+ "body_type": 1,
14045
+ "url_encoded_value": [],
14046
+ "json_value": None,
14047
+ "xml_value": None,
14048
+ "result_type": 1,
14049
+ "result_format_path": [],
14050
+ "query_params": [],
14051
+ "auto_trigger": None,
14052
+ "custom_button_text_enabled": None,
14053
+ "custom_button_text": None,
14054
+ "being_insert_value_directly": None,
14055
+ "being_hide_on_form": None,
14056
+ }
14057
+ question["remoteLookupConfig"] = {
14058
+ "configMode": remote_lookup_config["config_mode"],
14059
+ "url": remote_lookup_config["url"],
14060
+ "method": remote_lookup_config["method"],
14061
+ "headers": [_serialize_remote_lookup_key_value_item(item) for item in remote_lookup_config.get("headers", [])],
14062
+ "bodyType": remote_lookup_config["body_type"],
14063
+ "urlEncodedValue": [_serialize_remote_lookup_key_value_item(item) for item in remote_lookup_config.get("url_encoded_value", [])],
14064
+ "jsonValue": remote_lookup_config.get("json_value"),
14065
+ "xmlValue": remote_lookup_config.get("xml_value"),
14066
+ "resultType": remote_lookup_config["result_type"],
14067
+ "resultFormatPath": [_serialize_q_linker_alias_path_item(item) for item in remote_lookup_config.get("result_format_path", [])],
14068
+ "queryParams": [_serialize_remote_lookup_key_value_item(item) for item in remote_lookup_config.get("query_params", [])],
14069
+ "beingInsertValueDirectly": bool(remote_lookup_config.get("being_insert_value_directly", False)),
14070
+ "beingHideOnForm": bool(remote_lookup_config.get("being_hide_on_form", False)),
14071
+ }
14072
+ if remote_lookup_config["config_mode"] == 1:
14073
+ question["remoteLookupConfig"]["openAppConfig"] = {"event": {"eventId": 0, "name": "custom"}}
14074
+ question["autoTrigger"] = bool(field.get("auto_trigger", remote_lookup_config.get("auto_trigger", False)))
14075
+ question["customBtnTextStatus"] = bool(field.get("custom_button_text_enabled", remote_lookup_config.get("custom_button_text_enabled", False)))
12806
14076
  question["customBtnText"] = str(field.get("custom_button_text") or remote_lookup_config.get("custom_button_text") or "")
12807
14077
  return question
12808
14078
 
@@ -12882,8 +14152,127 @@ def _build_form_payload_from_fields(
12882
14152
  for row in form_rows:
12883
14153
  _apply_row_widths(row)
12884
14154
  payload = default_form_payload(title, form_rows)
14155
+ _normalize_formula_defaults_for_save(payload.get("formQues"))
14156
+ payload["editVersionNo"] = int(current_schema.get("editVersionNo") or 1)
14157
+ payload["questionRelations"] = _normalize_question_relations_for_save(
14158
+ question_relations if question_relations is not None else (current_schema.get("questionRelations") or [])
14159
+ )
14160
+ return payload
14161
+
14162
+
14163
+ def _build_form_payload_for_edit_fields(
14164
+ *,
14165
+ title: str,
14166
+ current_schema: dict[str, Any],
14167
+ fields: list[dict[str, Any]],
14168
+ layout: dict[str, Any],
14169
+ question_relations: list[dict[str, Any]] | None = None,
14170
+ ) -> dict[str, Any]:
14171
+ _, section_templates = _extract_question_templates(current_schema)
14172
+ template_row_lengths_by_que_id, template_row_lengths_by_title = _extract_template_row_lengths(current_schema)
14173
+ fields_by_name = {
14174
+ str(field.get("name") or ""): field
14175
+ for field in fields
14176
+ if isinstance(field, dict) and str(field.get("name") or "").strip()
14177
+ }
14178
+ form_rows: list[list[dict[str, Any]]] = []
14179
+ temp_id = -10000
14180
+
14181
+ for row in layout.get("root_rows", []) or []:
14182
+ questions: list[dict[str, Any]] = []
14183
+ expected_template_lengths: list[int] = []
14184
+ row_preserved = True
14185
+ for name in row:
14186
+ field = fields_by_name.get(str(name))
14187
+ if field is None:
14188
+ continue
14189
+ template_row_length = _field_template_row_length(
14190
+ field,
14191
+ lengths_by_que_id=template_row_lengths_by_que_id,
14192
+ lengths_by_title=template_row_lengths_by_title,
14193
+ )
14194
+ if template_row_length is not None:
14195
+ expected_template_lengths.append(template_row_length)
14196
+ question, preserved = _materialize_edit_question(field, temp_id=temp_id)
14197
+ questions.append(question)
14198
+ row_preserved = row_preserved and preserved
14199
+ temp_id -= 100
14200
+ if not questions:
14201
+ continue
14202
+ if not row_preserved or _row_needs_width_reflow(expected_template_lengths, len(questions)):
14203
+ _apply_row_widths(questions)
14204
+ form_rows.append(questions)
14205
+
14206
+ for section in layout.get("sections", []) or []:
14207
+ inner_rows: list[list[dict[str, Any]]] = []
14208
+ for row in section.get("rows", []) or []:
14209
+ questions: list[dict[str, Any]] = []
14210
+ expected_template_lengths: list[int] = []
14211
+ row_preserved = True
14212
+ for name in row:
14213
+ field = fields_by_name.get(str(name))
14214
+ if field is None:
14215
+ continue
14216
+ template_row_length = _field_template_row_length(
14217
+ field,
14218
+ lengths_by_que_id=template_row_lengths_by_que_id,
14219
+ lengths_by_title=template_row_lengths_by_title,
14220
+ )
14221
+ if template_row_length is not None:
14222
+ expected_template_lengths.append(template_row_length)
14223
+ question, preserved = _materialize_edit_question(field, temp_id=temp_id)
14224
+ questions.append(question)
14225
+ row_preserved = row_preserved and preserved
14226
+ temp_id -= 100
14227
+ if not questions:
14228
+ continue
14229
+ if not row_preserved or _row_needs_width_reflow(expected_template_lengths, len(questions)):
14230
+ _apply_row_widths(questions)
14231
+ inner_rows.append(questions)
14232
+ if not inner_rows:
14233
+ continue
14234
+ template = _select_section_template(section_templates, section)
14235
+ wrapper = deepcopy(template) if isinstance(template, dict) else {
14236
+ "queId": 0,
14237
+ "queTempId": -(20000 + sum(ord(ch) for ch in str(section.get("section_id") or section.get("title") or "section"))),
14238
+ "queType": 24,
14239
+ "queWidth": 100,
14240
+ "scanType": 1,
14241
+ "status": 1,
14242
+ "required": False,
14243
+ "queHint": "",
14244
+ "linkedQuestions": {},
14245
+ "logicalShow": True,
14246
+ "queDefaultValue": None,
14247
+ "queDefaultType": 1,
14248
+ "subQueWidth": 2,
14249
+ "beingHide": False,
14250
+ "beingDesensitized": False,
14251
+ }
14252
+ if section.get("title") is not None:
14253
+ wrapper["queTitle"] = section.get("title") or wrapper.get("queTitle") or "未命名分组"
14254
+ parsed_section_id = _coerce_positive_int(section.get("section_id"))
14255
+ if parsed_section_id is not None:
14256
+ wrapper["sectionId"] = parsed_section_id
14257
+ elif template is None and section.get("section_id") is not None:
14258
+ wrapper["sectionId"] = section.get("section_id")
14259
+ wrapper["innerQuestions"] = inner_rows
14260
+ form_rows.append([wrapper])
14261
+
14262
+ rename_by_que_id, rename_by_title = _field_rename_maps(fields)
14263
+ if rename_by_que_id or rename_by_title:
14264
+ _sync_question_title_references(form_rows, by_que_id=rename_by_que_id, by_title=rename_by_title)
14265
+ normalized_relations = _normalize_question_relations_for_save(
14266
+ question_relations if question_relations is not None else (current_schema.get("questionRelations") or [])
14267
+ )
14268
+ if rename_by_que_id or rename_by_title:
14269
+ _sync_question_title_references(normalized_relations, by_que_id=rename_by_que_id, by_title=rename_by_title)
14270
+
14271
+ payload = _build_form_save_base_payload(current_schema, title)
14272
+ payload["formQues"] = form_rows
14273
+ _normalize_formula_defaults_for_save(payload.get("formQues"))
14274
+ payload["questionRelations"] = normalized_relations
12885
14275
  payload["editVersionNo"] = int(current_schema.get("editVersionNo") or 1)
12886
- payload["questionRelations"] = deepcopy(question_relations if question_relations is not None else (current_schema.get("questionRelations") or []))
12887
14276
  return payload
12888
14277
 
12889
14278
 
@@ -13048,6 +14437,8 @@ def _summarize_views(result: Any) -> list[dict[str, Any]]:
13048
14437
  view_type = _normalize_view_type_name(view.get("viewgraphType") or view.get("type"))
13049
14438
  columns = view.get("columnNames") or view.get("columns") or []
13050
14439
  group_by = view.get("groupBy") or view.get("group_by")
14440
+ if not any((name, view_type, columns, group_by)) and str(view_key or "").isdigit():
14441
+ continue
13051
14442
  if not any((name, view_key, view_type, columns, group_by)):
13052
14443
  continue
13053
14444
  items.append(
@@ -13090,11 +14481,24 @@ def _summarize_views_with_config(views_tool: ViewTools, *, profile: str, views:
13090
14481
  enriched_items.append(item)
13091
14482
  continue
13092
14483
  config = config_response.get("result") if isinstance(config_response.get("result"), dict) else {}
13093
- enriched_items.append(_merge_view_summary_with_config(item, config=config))
14484
+ question_list: list[dict[str, Any]] = []
14485
+ try:
14486
+ question_response = views_tool.view_list_questions(profile=profile, viewgraph_key=view_key)
14487
+ raw_question_list = question_response.get("result")
14488
+ if isinstance(raw_question_list, list):
14489
+ question_list = [deepcopy(entry) for entry in raw_question_list if isinstance(entry, dict)]
14490
+ except (QingflowApiError, RuntimeError):
14491
+ question_list = []
14492
+ enriched_items.append(_merge_view_summary_with_config(item, config=config, question_list=question_list))
13094
14493
  return enriched_items, config_read_errors
13095
14494
 
13096
14495
 
13097
- def _merge_view_summary_with_config(base: dict[str, Any], *, config: dict[str, Any]) -> dict[str, Any]:
14496
+ def _merge_view_summary_with_config(
14497
+ base: dict[str, Any],
14498
+ *,
14499
+ config: dict[str, Any],
14500
+ question_list: list[dict[str, Any]] | None = None,
14501
+ ) -> dict[str, Any]:
13098
14502
  summary = deepcopy(base)
13099
14503
  if not isinstance(config, dict) or not config:
13100
14504
  return summary
@@ -13102,6 +14506,7 @@ def _merge_view_summary_with_config(base: dict[str, Any], *, config: dict[str, A
13102
14506
  summary["visibility_summary"] = _visibility_summary(_public_visibility_from_member_auth(config.get("auth")))
13103
14507
  legacy_columns = [str(value) for value in (summary.get("columns") or []) if str(value or "").strip()]
13104
14508
  question_entries = _extract_view_question_entries(config.get("viewgraphQuestions"))
14509
+ canonical_question_entries = _extract_view_question_entries(question_list)
13105
14510
  question_entries_by_id = {
13106
14511
  field_id: entry
13107
14512
  for entry in question_entries
@@ -13121,19 +14526,20 @@ def _merge_view_summary_with_config(base: dict[str, Any], *, config: dict[str, A
13121
14526
  display_entries = _sort_view_question_entries(
13122
14527
  [entry for entry in question_entries if bool(entry.get("visible", True))],
13123
14528
  )
14529
+ public_display_entries = _filter_public_view_display_entries(display_entries, configured_column_ids=configured_column_ids)
13124
14530
  display_column_ids = [
13125
14531
  field_id
13126
- for field_id in (_coerce_nonnegative_int(entry.get("field_id")) for entry in display_entries)
14532
+ for field_id in (_coerce_nonnegative_int(entry.get("field_id")) for entry in public_display_entries)
13127
14533
  if field_id is not None
13128
14534
  ]
13129
14535
  display_columns = [
13130
14536
  str(entry.get("name") or "").strip()
13131
- for entry in display_entries
14537
+ for entry in public_display_entries
13132
14538
  if str(entry.get("name") or "").strip()
13133
14539
  ]
13134
14540
  apply_entries = [
13135
14541
  entry
13136
- for entry in display_entries
14542
+ for entry in public_display_entries
13137
14543
  if _coerce_nonnegative_int(entry.get("field_id")) is not None
13138
14544
  and str(entry.get("name") or "").strip()
13139
14545
  and str(entry.get("name") or "").strip() not in _KNOWN_SYSTEM_VIEW_COLUMNS
@@ -13180,8 +14586,42 @@ def _merge_view_summary_with_config(base: dict[str, Any], *, config: dict[str, A
13180
14586
  summary["apply_columns"] = apply_columns
13181
14587
  summary["apply_column_ids"] = apply_column_ids
13182
14588
  config_enriched = True
13183
- if question_entries:
13184
- summary["column_details"] = display_entries or _sort_view_question_entries(question_entries)
14589
+ if canonical_question_entries:
14590
+ canonical_display_entries = _sort_view_question_entries(canonical_question_entries)
14591
+ canonical_display_column_ids = [
14592
+ field_id
14593
+ for field_id in (_coerce_nonnegative_int(entry.get("field_id")) for entry in canonical_display_entries)
14594
+ if field_id is not None
14595
+ ]
14596
+ canonical_display_columns = [
14597
+ str(entry.get("name") or "").strip()
14598
+ for entry in canonical_display_entries
14599
+ if str(entry.get("name") or "").strip()
14600
+ ]
14601
+ canonical_apply_entries = [
14602
+ entry
14603
+ for entry in canonical_display_entries
14604
+ if _coerce_nonnegative_int(entry.get("field_id")) is not None
14605
+ and str(entry.get("name") or "").strip()
14606
+ and str(entry.get("name") or "").strip() not in _KNOWN_SYSTEM_VIEW_COLUMNS
14607
+ ]
14608
+ summary["columns"] = canonical_display_columns
14609
+ summary["display_columns"] = canonical_display_columns
14610
+ summary["display_column_ids"] = canonical_display_column_ids
14611
+ summary["column_details"] = canonical_display_entries
14612
+ summary["apply_columns"] = [
14613
+ str(entry.get("name") or "").strip()
14614
+ for entry in canonical_apply_entries
14615
+ if str(entry.get("name") or "").strip()
14616
+ ]
14617
+ summary["apply_column_ids"] = [
14618
+ field_id
14619
+ for field_id in (_coerce_nonnegative_int(entry.get("field_id")) for entry in canonical_apply_entries)
14620
+ if field_id is not None
14621
+ ]
14622
+ config_enriched = True
14623
+ elif question_entries:
14624
+ summary["column_details"] = public_display_entries or _sort_view_question_entries(question_entries)
13185
14625
  config_enriched = True
13186
14626
  display_config = _extract_view_display_config(
13187
14627
  config,
@@ -13206,7 +14646,7 @@ def _merge_view_summary_with_config(base: dict[str, Any], *, config: dict[str, A
13206
14646
  summary["button_read_source"] = button_source
13207
14647
  config_enriched = True
13208
14648
  if config_enriched:
13209
- summary["read_source"] = "view_config"
14649
+ summary["read_source"] = "view_config+question" if canonical_question_entries else "view_config"
13210
14650
  return summary
13211
14651
 
13212
14652
 
@@ -13214,29 +14654,64 @@ def _extract_view_question_entries(questions: Any) -> list[dict[str, Any]]:
13214
14654
  if not isinstance(questions, list):
13215
14655
  return []
13216
14656
  entries: list[dict[str, Any]] = []
13217
- for index, item in enumerate(questions, start=1):
13218
- if not isinstance(item, dict):
13219
- continue
13220
- field_id = _coerce_nonnegative_int(item.get("queId"))
13221
- name = str(item.get("queTitle") or "").strip() or None
13222
- visible_raw = item.get("beingListDisplay")
13223
- if visible_raw is None:
13224
- visible_raw = item.get("beingVisible")
13225
- visible = bool(visible_raw) if visible_raw is not None else True
13226
- display_order = _coerce_positive_int(item.get("displayOrdinal"))
13227
- entry: dict[str, Any] = {
13228
- "field_id": field_id,
13229
- "name": name,
13230
- "visible": visible,
13231
- "display_order": display_order if display_order is not None else index,
13232
- }
13233
- width = _coerce_positive_int(item.get("width"))
13234
- if width is not None:
13235
- entry["width"] = width
13236
- entries.append(entry)
14657
+ fallback_order = 0
14658
+
14659
+ def walk(nodes: Any) -> None:
14660
+ nonlocal fallback_order
14661
+ if not isinstance(nodes, list):
14662
+ return
14663
+ for item in nodes:
14664
+ if not isinstance(item, dict):
14665
+ continue
14666
+ children: list[Any] = []
14667
+ for child_key in ("innerQues", "subQues", "innerQuestions", "subQuestions"):
14668
+ child_value = item.get(child_key)
14669
+ if isinstance(child_value, list) and child_value:
14670
+ children.extend(child_value)
14671
+ if children:
14672
+ walk(children)
14673
+ continue
14674
+ field_id = _coerce_nonnegative_int(item.get("queId"))
14675
+ name = str(item.get("queTitle") or "").strip() or None
14676
+ if field_id is None and name is None:
14677
+ continue
14678
+ visible_raw = item.get("beingListDisplay")
14679
+ if visible_raw is None:
14680
+ visible_raw = item.get("beingVisible")
14681
+ visible = bool(visible_raw) if visible_raw is not None else True
14682
+ display_order = _coerce_positive_int(item.get("displayOrdinal"))
14683
+ fallback_order += 1
14684
+ entry: dict[str, Any] = {
14685
+ "field_id": field_id,
14686
+ "name": name,
14687
+ "visible": visible,
14688
+ "display_order": display_order if display_order is not None else fallback_order,
14689
+ }
14690
+ width = _coerce_positive_int(item.get("width"))
14691
+ if width is not None:
14692
+ entry["width"] = width
14693
+ entries.append(entry)
14694
+
14695
+ walk(questions)
13237
14696
  return entries
13238
14697
 
13239
14698
 
14699
+ def _filter_public_view_display_entries(
14700
+ entries: list[dict[str, Any]],
14701
+ *,
14702
+ configured_column_ids: list[int],
14703
+ ) -> list[dict[str, Any]]:
14704
+ configured_set = set(configured_column_ids)
14705
+ filtered: list[dict[str, Any]] = []
14706
+ for entry in entries:
14707
+ name = str(entry.get("name") or "").strip()
14708
+ field_id = _coerce_nonnegative_int(entry.get("field_id"))
14709
+ if name in _KNOWN_SYSTEM_VIEW_COLUMNS and field_id not in configured_set:
14710
+ continue
14711
+ filtered.append(entry)
14712
+ return filtered or entries
14713
+
14714
+
13240
14715
  def _sort_view_question_entries(entries: list[dict[str, Any]]) -> list[dict[str, Any]]:
13241
14716
  return sorted(
13242
14717
  entries,
@@ -13430,7 +14905,6 @@ def _normalize_view_button_entry(entry: dict[str, Any]) -> dict[str, Any]:
13430
14905
  for public_key, source_key in (
13431
14906
  ("default_button_text", "defaultButtonText"),
13432
14907
  ("button_icon", "buttonIcon"),
13433
- ("icon_color", "iconColor"),
13434
14908
  ("background_color", "backgroundColor"),
13435
14909
  ("text_color", "textColor"),
13436
14910
  ("trigger_link_url", "triggerLinkUrl"),
@@ -13468,7 +14942,6 @@ def _normalize_view_buttons_for_compare(value: Any) -> list[dict[str, Any]]:
13468
14942
  "button_id": normalized.get("button_id") if is_custom else None,
13469
14943
  "button_text": normalized.get("button_text"),
13470
14944
  "button_icon": normalized.get("button_icon"),
13471
- "icon_color": normalized.get("icon_color"),
13472
14945
  "background_color": normalized.get("background_color"),
13473
14946
  "text_color": normalized.get("text_color"),
13474
14947
  "trigger_action": normalized.get("trigger_action"),
@@ -13543,7 +15016,6 @@ def _normalize_expected_view_buttons_for_compare(
13543
15016
  for key in (
13544
15017
  "button_text",
13545
15018
  "button_icon",
13546
- "icon_color",
13547
15019
  "background_color",
13548
15020
  "text_color",
13549
15021
  "trigger_action",
@@ -13726,8 +15198,6 @@ def _serialize_view_button_binding(
13726
15198
  if binding.button_type in {PublicViewButtonType.system, PublicViewButtonType.custom}:
13727
15199
  dto["buttonText"] = binding.button_text
13728
15200
  dto["buttonIcon"] = binding.button_icon
13729
- if str(binding.icon_color or "").strip():
13730
- dto["iconColor"] = binding.icon_color
13731
15201
  dto["backgroundColor"] = binding.background_color
13732
15202
  dto["textColor"] = binding.text_color
13733
15203
  dto["triggerAction"] = binding.trigger_action
@@ -14296,9 +15766,10 @@ def _build_form_payload_from_existing_schema(
14296
15766
  wrapper["queWidth"] = 100
14297
15767
  form_rows.append([wrapper])
14298
15768
 
14299
- payload = deepcopy(current_schema)
15769
+ payload = _build_form_save_base_payload(current_schema, str(current_schema.get("formTitle") or "未命名应用"))
14300
15770
  payload["formQues"] = form_rows
14301
- payload["questionRelations"] = deepcopy(current_schema.get("questionRelations") or [])
15771
+ _normalize_formula_defaults_for_save(payload.get("formQues"))
15772
+ payload["questionRelations"] = _normalize_question_relations_for_save(current_schema.get("questionRelations") or [])
14302
15773
  payload["editVersionNo"] = int(current_schema.get("editVersionNo") or 1)
14303
15774
  payload.setdefault("formTitle", current_schema.get("formTitle") or "未命名应用")
14304
15775
  return payload