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

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 (45) 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/builder_facade/models.py +14 -4
  16. package/src/qingflow_mcp/builder_facade/service.py +1582 -124
  17. package/src/qingflow_mcp/cli/commands/auth.py +63 -0
  18. package/src/qingflow_mcp/cli/commands/builder.py +4 -3
  19. package/src/qingflow_mcp/cli/commands/record.py +5 -5
  20. package/src/qingflow_mcp/cli/commands/task.py +74 -22
  21. package/src/qingflow_mcp/cli/commands/workspace.py +22 -0
  22. package/src/qingflow_mcp/cli/formatters.py +287 -48
  23. package/src/qingflow_mcp/cli/main.py +6 -1
  24. package/src/qingflow_mcp/cli/qingflow_login.py +116 -0
  25. package/src/qingflow_mcp/config.py +1 -1
  26. package/src/qingflow_mcp/errors.py +2 -2
  27. package/src/qingflow_mcp/id_utils.py +49 -0
  28. package/src/qingflow_mcp/public_surface.py +11 -1
  29. package/src/qingflow_mcp/response_trim.py +380 -9
  30. package/src/qingflow_mcp/server.py +4 -0
  31. package/src/qingflow_mcp/server_app_builder.py +11 -1
  32. package/src/qingflow_mcp/server_app_user.py +24 -0
  33. package/src/qingflow_mcp/session_store.py +69 -15
  34. package/src/qingflow_mcp/solution/compiler/form_compiler.py +2 -2
  35. package/src/qingflow_mcp/solution/executor.py +2 -2
  36. package/src/qingflow_mcp/tools/ai_builder_tools.py +48 -18
  37. package/src/qingflow_mcp/tools/app_tools.py +1 -0
  38. package/src/qingflow_mcp/tools/auth_tools.py +217 -9
  39. package/src/qingflow_mcp/tools/base.py +6 -2
  40. package/src/qingflow_mcp/tools/code_block_tools.py +2 -2
  41. package/src/qingflow_mcp/tools/import_tools.py +36 -2
  42. package/src/qingflow_mcp/tools/record_tools.py +410 -156
  43. package/src/qingflow_mcp/tools/resource_read_tools.py +114 -32
  44. package/src/qingflow_mcp/tools/task_context_tools.py +899 -141
  45. 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
@@ -143,6 +147,7 @@ JUDGE_EQUAL_ANY = 9
143
147
  JUDGE_FUZZY_MATCH = 19
144
148
  JUDGE_INCLUDE_ANY = 20
145
149
  DEFAULT_TYPE_RELATION = 2
150
+ DEFAULT_TYPE_FORMULA = 3
146
151
  RELATION_TYPE_Q_LINKER = 2
147
152
  RELATION_TYPE_CODE_BLOCK = 3
148
153
 
@@ -440,6 +445,7 @@ class AiBuilderFacade:
440
445
  base = base_result.get("result") if isinstance(base_result.get("result"), dict) else {}
441
446
  summary = detail_result.get("summary") if isinstance(detail_result, dict) and isinstance(detail_result.get("summary"), dict) else {}
442
447
  source = detail if detail else base
448
+ layout_tag_items = _select_package_layout_tag_items(detail=detail, base=base)
443
449
  warnings: list[JSONObject] = []
444
450
  if detail_read_error is not None:
445
451
  warnings.append(
@@ -450,7 +456,7 @@ class AiBuilderFacade:
450
456
  "http_status": detail_read_error.http_status,
451
457
  }
452
458
  )
453
- public_items = _public_package_items_from_tag_items(source.get("tagItems") or base.get("tagItems"))
459
+ public_items = _public_package_items_from_tag_items(layout_tag_items)
454
460
  item_count = summary.get("itemCount")
455
461
  if not isinstance(item_count, int) or item_count < 0 or (item_count == 0 and public_items):
456
462
  item_count = len(public_items)
@@ -508,6 +514,8 @@ class AiBuilderFacade:
508
514
  }
509
515
  effective_package_id = _coerce_positive_int(package_id)
510
516
  created = False
517
+ create_result: JSONObject | None = None
518
+ update_result: JSONObject | None = None
511
519
  permission_outcomes: list[PermissionCheckOutcome] = []
512
520
 
513
521
  if effective_package_id is None:
@@ -598,11 +606,36 @@ class AiBuilderFacade:
598
606
  )
599
607
  except VisibilityResolutionError:
600
608
  expected_visibility = None
609
+ metadata_verified = True
610
+ if metadata_requested and update_result is not None:
611
+ metadata_verified = bool(update_result.get("verified"))
612
+ elif created and create_result is not None:
613
+ metadata_verified = bool(create_result.get("verified"))
614
+ layout_verified = True
615
+ if items is not None and layout_result is not None:
616
+ layout_verified = bool(layout_result.get("verified"))
617
+ response_verification: JSONObject = {
618
+ "package_exists": True,
619
+ "package_created": created,
620
+ "layout_applied": items is not None,
621
+ "metadata_verified": metadata_verified,
622
+ "layout_verified": layout_verified,
623
+ "visibility_verified": None
624
+ if expected_visibility is None
625
+ else _visibility_matches_expected(verification.get("visibility"), expected_visibility),
626
+ }
627
+ if isinstance(update_result, dict):
628
+ update_verification = update_result.get("verification")
629
+ if isinstance(update_verification, dict):
630
+ for key in ("package_name_verified", "package_icon_verified", "visibility_verified"):
631
+ if key in update_verification:
632
+ response_verification[key] = deepcopy(update_verification.get(key))
633
+ response_verified = metadata_verified and layout_verified and response_verification.get("visibility_verified") is not False
601
634
  response: JSONObject = {
602
- "status": "success",
635
+ "status": "success" if response_verified else "partial_success",
603
636
  "error_code": None,
604
637
  "recoverable": False,
605
- "message": "applied package",
638
+ "message": "applied package" if response_verified else "applied package with unverified readback",
606
639
  "normalized_args": normalized_args,
607
640
  "missing_fields": [],
608
641
  "allowed_values": {},
@@ -611,15 +644,8 @@ class AiBuilderFacade:
611
644
  "suggested_next_call": None,
612
645
  "noop": not (created or metadata_requested or items is not None),
613
646
  "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,
647
+ "verification": response_verification,
648
+ "verified": response_verified,
623
649
  **{
624
650
  key: deepcopy(value)
625
651
  for key, value in verification.items()
@@ -677,7 +703,7 @@ class AiBuilderFacade:
677
703
  )
678
704
  raw_current = current.get("result") if isinstance(current.get("result"), dict) else {}
679
705
  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
706
+ current_name = str(raw_current.get("tagName") or raw_current_base.get("tagName") or "").strip() or None
681
707
  desired_name = str(package_name or current_name or "").strip() or current_name or "未命名应用包"
682
708
  desired_icon = encode_workspace_icon_with_defaults(
683
709
  icon=icon,
@@ -718,27 +744,33 @@ class AiBuilderFacade:
718
744
  verification = self.package_get(profile=profile, package_id=tag_id)
719
745
  if verification.get("status") != "success":
720
746
  return verification
747
+ package_name_verified = str(verification.get("package_name") or "").strip() == desired_name
748
+ package_icon_verified = str(verification.get("icon") or "").strip() == desired_icon
749
+ visibility_verified = _visibility_matches_expected(
750
+ verification.get("visibility"),
751
+ _public_visibility_from_member_auth(desired_auth),
752
+ )
753
+ verified = package_name_verified and package_icon_verified and visibility_verified
721
754
  return {
722
- "status": "success",
755
+ "status": "success" if verified else "partial_success",
723
756
  "error_code": None,
724
757
  "recoverable": False,
725
- "message": "updated package",
758
+ "message": "updated package" if verified else "updated package with unverified readback",
726
759
  "normalized_args": normalized_args,
727
760
  "missing_fields": [],
728
761
  "allowed_values": {},
729
762
  "details": {},
730
763
  "request_id": update_result.get("request_id") if isinstance(update_result, dict) else None,
731
- "suggested_next_call": None,
764
+ "suggested_next_call": None if verified else {"tool_name": "package_get", "arguments": {"profile": profile, "package_id": tag_id}},
732
765
  "noop": False,
733
766
  "warnings": [],
734
767
  "verification": {
735
768
  "package_exists": True,
736
- "visibility_verified": _visibility_matches_expected(
737
- verification.get("visibility"),
738
- _public_visibility_from_member_auth(desired_auth),
739
- ),
769
+ "package_name_verified": package_name_verified,
770
+ "package_icon_verified": package_icon_verified,
771
+ "visibility_verified": visibility_verified,
740
772
  },
741
- "verified": True,
773
+ "verified": verified,
742
774
  **{
743
775
  key: deepcopy(value)
744
776
  for key, value in verification.items()
@@ -881,9 +913,7 @@ class AiBuilderFacade:
881
913
  if isinstance(current_base_result, dict) and isinstance(current_base_result.get("result"), dict)
882
914
  else {}
883
915
  )
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
916
+ raw_tag_items = _select_package_layout_tag_items(detail=detail_raw, base=base_raw)
887
917
  if not isinstance(raw_tag_items, list):
888
918
  return _failed(
889
919
  "PACKAGE_LAYOUT_UNREADABLE",
@@ -1409,6 +1439,8 @@ class AiBuilderFacade:
1409
1439
  issues: list[dict[str, Any]] = []
1410
1440
  resolved: list[dict[str, Any]] = []
1411
1441
  seen_ids: set[int] = set()
1442
+ if not dept_ids and not dept_names:
1443
+ return {"department_entries": resolved, "issues": issues}
1412
1444
  listed = self.directory.directory_list_all_departments(
1413
1445
  profile=profile,
1414
1446
  parent_dept_id=None,
@@ -2780,6 +2812,19 @@ class AiBuilderFacade:
2780
2812
  "can_copy_app": _coerce_optional_bool(base.get("copyAppStatus")),
2781
2813
  }
2782
2814
 
2815
+ def _derive_can_edit_app_base(self, *, profile: str, permission_summary: JSONObject) -> bool:
2816
+ if permission_summary.get("can_edit_app") is not True:
2817
+ return False
2818
+ tag_ids = _coerce_int_list(permission_summary.get("tag_ids"))
2819
+ for tag_id in tag_ids:
2820
+ try:
2821
+ package_permission = self._read_package_permission_summary(profile=profile, tag_id=tag_id)
2822
+ except (QingflowApiError, RuntimeError):
2823
+ return False
2824
+ if package_permission.get("can_edit_tag") is not True:
2825
+ return False
2826
+ return True
2827
+
2783
2828
  def _read_portal_permission_summary(self, *, dash_key: str, portal_result: dict[str, Any]) -> JSONObject:
2784
2829
  tag_ids = _coerce_int_list(portal_result.get("tagIds"))
2785
2830
  if not tag_ids:
@@ -2993,7 +3038,7 @@ class AiBuilderFacade:
2993
3038
 
2994
3039
  def app_read_summary(self, *, profile: str, app_key: str) -> JSONObject:
2995
3040
  try:
2996
- state = self._load_base_schema_state(profile=profile, app_key=app_key)
3041
+ base = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=True)
2997
3042
  except (QingflowApiError, RuntimeError) as error:
2998
3043
  api_error = _coerce_api_error(error)
2999
3044
  return _failed_from_api_error(
@@ -3003,26 +3048,55 @@ class AiBuilderFacade:
3003
3048
  details={"app_key": app_key},
3004
3049
  suggested_next_call={"tool_name": "app_resolve", "arguments": {"profile": profile, "app_key": app_key}},
3005
3050
  )
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"]
3051
+ base_result = base.get("result") if isinstance(base.get("result"), dict) else {}
3052
+ schema_unavailable = False
3053
+ try:
3054
+ schema_result, _schema_source = self._read_schema_with_fallback(profile=profile, app_key=app_key)
3055
+ parsed = _parse_schema(schema_result)
3056
+ except (QingflowApiError, RuntimeError) as error:
3057
+ api_error = _coerce_api_error(error)
3058
+ if api_error.http_status == 404 or _is_permission_restricted_api_error(api_error):
3059
+ schema_unavailable = True
3060
+ parsed = {"fields": [], "layout": {"sections": []}}
3061
+ else:
3062
+ return _failed_from_api_error(
3063
+ "APP_READ_FAILED",
3064
+ api_error,
3065
+ normalized_args={"app_key": app_key},
3066
+ details={"app_key": app_key},
3067
+ suggested_next_call={"tool_name": "app_resolve", "arguments": {"profile": profile, "app_key": app_key}},
3068
+ )
3069
+ views, views_unavailable = self._load_views_result(
3070
+ profile=profile,
3071
+ app_key=app_key,
3072
+ tolerate_404=True,
3073
+ tolerate_permission_restricted=True,
3074
+ )
3075
+ workflow, workflow_unavailable = self._load_workflow_result(
3076
+ profile=profile,
3077
+ app_key=app_key,
3078
+ tolerate_404=True,
3079
+ tolerate_permission_restricted=True,
3080
+ )
3009
3081
  verification_hints = _build_verification_hints(
3010
- tag_ids=_coerce_int_list(state["base"].get("tagIds")),
3082
+ tag_ids=_coerce_int_list(base_result.get("tagIds")),
3011
3083
  fields=parsed["fields"],
3012
3084
  layout=parsed["layout"],
3013
3085
  views=_summarize_views(views),
3014
3086
  )
3087
+ if schema_unavailable:
3088
+ verification_hints.append("schema_read_unavailable")
3015
3089
  if views_unavailable:
3016
3090
  verification_hints.append("views_read_unavailable")
3017
3091
  if workflow_unavailable:
3018
3092
  verification_hints.append("workflow_read_unavailable")
3019
3093
  response = AppReadSummaryResponse(
3020
3094
  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"),
3095
+ title=base_result.get("formTitle"),
3096
+ app_icon=str(base_result.get("appIcon") or "").strip() or None,
3097
+ visibility=_public_visibility_from_member_auth(base_result.get("auth")),
3098
+ tag_ids=_coerce_int_list(base_result.get("tagIds")),
3099
+ publish_status=base_result.get("appPublishStatus"),
3026
3100
  field_count=len(parsed["fields"]),
3027
3101
  layout_section_count=len(parsed["layout"].get("sections", [])),
3028
3102
  view_count=len(_summarize_views(views)),
@@ -3044,10 +3118,11 @@ class AiBuilderFacade:
3044
3118
  "warnings": _warnings_from_verification_hints(verification_hints),
3045
3119
  "verification": {
3046
3120
  "app_exists": True,
3121
+ "schema_read_unavailable": schema_unavailable,
3047
3122
  "views_read_unavailable": views_unavailable,
3048
3123
  "workflow_read_unavailable": workflow_unavailable,
3049
3124
  },
3050
- "verified": not views_unavailable and not workflow_unavailable,
3125
+ "verified": not schema_unavailable and not views_unavailable and not workflow_unavailable,
3051
3126
  **response.model_dump(mode="json"),
3052
3127
  }
3053
3128
 
@@ -3060,8 +3135,9 @@ class AiBuilderFacade:
3060
3135
  permission_summary = self._read_app_permission_summary(profile=profile, app_key=app_key)
3061
3136
  result["message"] = "read app config summary"
3062
3137
  result["editability"] = {
3138
+ "can_edit_app_base": self._derive_can_edit_app_base(profile=profile, permission_summary=permission_summary),
3063
3139
  "can_edit_form": permission_summary.get("can_edit_app"),
3064
- "can_edit_flow": permission_summary.get("can_edit_app"),
3140
+ "can_edit_flow": permission_summary.get("can_manage_data"),
3065
3141
  "can_edit_views": permission_summary.get("can_manage_data"),
3066
3142
  "can_edit_charts": permission_summary.get("can_manage_data"),
3067
3143
  }
@@ -4630,7 +4706,19 @@ class AiBuilderFacade:
4630
4706
  )
4631
4707
  field = current_fields[matched]
4632
4708
  previous_name = field["name"]
4633
- _apply_field_mutation(field, patch.set)
4709
+ try:
4710
+ _apply_field_mutation(field, patch.set)
4711
+ except ValueError as error:
4712
+ return _failed(
4713
+ "VALIDATION_ERROR",
4714
+ str(error),
4715
+ normalized_args=normalized_args,
4716
+ details={
4717
+ "selector": patch.selector.model_dump(mode="json"),
4718
+ "app_key": target.app_key,
4719
+ },
4720
+ suggested_next_call={"tool_name": "app_get_fields", "arguments": {"profile": profile, "app_key": target.app_key}},
4721
+ )
4634
4722
  current_fields[matched] = field
4635
4723
  layout = _rename_field_in_layout(layout, previous_name, field["name"])
4636
4724
  updated.append(field["name"])
@@ -4789,12 +4877,22 @@ class AiBuilderFacade:
4789
4877
  response = _apply_permission_outcomes(response, relation_permission_outcome)
4790
4878
  return finalize(self._append_publish_result(profile=profile, app_key=target.app_key, publish=publish, response=response))
4791
4879
 
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,
4880
+ payload = (
4881
+ _build_form_payload_from_fields(
4882
+ title=effective_app_name,
4883
+ current_schema=schema_result,
4884
+ fields=current_fields,
4885
+ layout=layout,
4886
+ question_relations=compiled_question_relations,
4887
+ )
4888
+ if bool(resolved.get("created"))
4889
+ else _build_form_payload_for_edit_fields(
4890
+ title=effective_app_name,
4891
+ current_schema=schema_result,
4892
+ fields=current_fields,
4893
+ layout=layout,
4894
+ question_relations=compiled_question_relations,
4895
+ )
4798
4896
  )
4799
4897
  payload["editVersionNo"] = self._resolve_form_edit_version(
4800
4898
  profile=profile,
@@ -4897,12 +4995,22 @@ class AiBuilderFacade:
4897
4995
  },
4898
4996
  suggested_next_call={"tool_name": "app_get_fields", "arguments": {"profile": profile, "app_key": target.app_key}},
4899
4997
  )
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,
4998
+ rebound_payload = (
4999
+ _build_form_payload_from_fields(
5000
+ title=effective_app_name,
5001
+ current_schema=rebound_schema,
5002
+ fields=rebound_fields,
5003
+ layout=rebound_layout,
5004
+ question_relations=compiled_question_relations,
5005
+ )
5006
+ if bool(resolved.get("created"))
5007
+ else _build_form_payload_for_edit_fields(
5008
+ title=effective_app_name,
5009
+ current_schema=rebound_schema,
5010
+ fields=rebound_fields,
5011
+ layout=rebound_layout,
5012
+ question_relations=compiled_question_relations,
5013
+ )
4906
5014
  )
4907
5015
  rebound_payload["editVersionNo"] = self._resolve_form_edit_version(
4908
5016
  profile=profile,
@@ -6670,6 +6778,7 @@ class AiBuilderFacade:
6670
6778
 
6671
6779
  for patch in request.upsert_charts:
6672
6780
  try:
6781
+ config_update_requested = _chart_patch_updates_chart_config(patch)
6673
6782
  chart_visible_auth = (
6674
6783
  self._compile_visibility_to_chart_visible_auth(profile=profile, visibility=patch.visibility)
6675
6784
  if patch.visibility is not None
@@ -6773,18 +6882,17 @@ class AiBuilderFacade:
6773
6882
  existing_by_name.pop(old_name, None)
6774
6883
  existing_by_name.setdefault(patch.name, []).append(deepcopy(updated_chart))
6775
6884
 
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:
6885
+ config_updated = False
6886
+ if existing is None or config_update_requested:
6887
+ config_payload = _build_public_chart_config_payload(
6888
+ patch=patch,
6889
+ app_key=app_key,
6890
+ field_lookup=field_lookup,
6891
+ qingbi_fields_by_id=qingbi_fields_by_id,
6892
+ )
6893
+ self.charts.qingbi_report_update_config(profile=profile, chart_id=chart_id, payload=config_payload)
6894
+ config_updated = True
6895
+ if existing is not None and chart_id not in updated_ids and config_updated:
6788
6896
  updated_ids.append(chart_id)
6789
6897
  if patch.question_config:
6790
6898
  self._request_backend(
@@ -6800,6 +6908,8 @@ class AiBuilderFacade:
6800
6908
  path=f"/chart/{chart_id}/user/config",
6801
6909
  json_body=patch.user_config,
6802
6910
  )
6911
+ if existing is not None and chart_id not in updated_ids and (patch.question_config or patch.user_config):
6912
+ updated_ids.append(chart_id)
6803
6913
  chart_results.append(
6804
6914
  {
6805
6915
  "chart_id": chart_id,
@@ -6956,11 +7066,12 @@ class AiBuilderFacade:
6956
7066
  permission_outcomes: list[PermissionCheckOutcome] = []
6957
7067
  dash_key = str(request.dash_key or "").strip()
6958
7068
  creating = not dash_key
7069
+ sections_requested = creating or bool(request.sections)
6959
7070
  verify_dash_name = creating or request.dash_name is not None
6960
7071
  verify_dash_icon = bool(request.icon or request.color)
6961
7072
  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
7073
+ verify_hide_copyright = request.hide_copyright is not None and sections_requested
7074
+ verify_dash_global_config = request.dash_global_config is not None and sections_requested
6964
7075
  verify_tags = creating or request.package_tag_id is not None
6965
7076
  requested_visibility = request.visibility
6966
7077
  if requested_visibility is None and isinstance(request.auth, dict) and request.auth:
@@ -7037,6 +7148,25 @@ class AiBuilderFacade:
7037
7148
  if package_edit_outcome.block is not None:
7038
7149
  return package_edit_outcome.block
7039
7150
  permission_outcomes.append(package_edit_outcome)
7151
+ if not sections_requested:
7152
+ unsupported_base_only_keys: list[str] = []
7153
+ if request.hide_copyright is not None:
7154
+ unsupported_base_only_keys.append("hide_copyright")
7155
+ if request.dash_global_config is not None:
7156
+ unsupported_base_only_keys.append("dash_global_config")
7157
+ if request.config:
7158
+ unsupported_base_only_keys.append("config")
7159
+ if unsupported_base_only_keys:
7160
+ return _failed(
7161
+ "PORTAL_SECTIONS_REQUIRED",
7162
+ "editing a portal without sections only supports base-info updates",
7163
+ normalized_args=normalized_args,
7164
+ details={
7165
+ "unsupported_without_sections": unsupported_base_only_keys,
7166
+ "fix_hint": "Pass sections when changing layout or global portal config, or omit those keys for visibility/icon/package updates.",
7167
+ },
7168
+ suggested_next_call={"tool_name": "portal_apply", "arguments": {"profile": profile, **normalized_args}},
7169
+ )
7040
7170
  try:
7041
7171
  if creating:
7042
7172
  create_payload = _build_public_portal_base_payload(
@@ -7062,7 +7192,6 @@ class AiBuilderFacade:
7062
7192
  suggested_next_call={"tool_name": "portal_apply", "arguments": {"profile": profile, **normalized_args}},
7063
7193
  )
7064
7194
  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
7195
  update_payload = _build_public_portal_base_payload(
7067
7196
  dash_name=request.dash_name or str(base_payload.get("dashName") or "").strip() or "未命名门户",
7068
7197
  package_tag_id=target_package_tag_id,
@@ -7074,8 +7203,10 @@ class AiBuilderFacade:
7074
7203
  config=request.config,
7075
7204
  base_payload=base_payload,
7076
7205
  )
7077
- update_payload["components"] = component_payload
7078
- self.portals.portal_update(profile=profile, dash_key=dash_key, payload=update_payload)
7206
+ if sections_requested:
7207
+ component_payload = self._build_portal_components_from_sections(profile=profile, sections=request.sections)
7208
+ update_payload["components"] = component_payload
7209
+ self.portals.portal_update(profile=profile, dash_key=dash_key, payload=update_payload)
7079
7210
  self.portals.portal_update_base_info(
7080
7211
  profile=profile,
7081
7212
  dash_key=dash_key,
@@ -7112,11 +7243,14 @@ class AiBuilderFacade:
7112
7243
  publish_failed = True
7113
7244
 
7114
7245
  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
7246
+ expected_count = len(request.sections) if sections_requested else None
7247
+ draft_verified = isinstance(draft_result, dict) and (
7248
+ expected_count is None or (isinstance(draft_components, list) and len(draft_components) == expected_count)
7249
+ )
7117
7250
  draft_meta_verified, draft_meta_mismatches = _verify_portal_readback(
7118
7251
  actual=draft_result,
7119
7252
  expected_payload=update_payload,
7253
+ expected_visibility=requested_visibility.model_dump(mode="json") if requested_visibility is not None else None,
7120
7254
  expected_section_count=expected_count,
7121
7255
  requested_config_keys=set((request.config or {}).keys()),
7122
7256
  verify_dash_name=verify_dash_name,
@@ -7132,12 +7266,18 @@ class AiBuilderFacade:
7132
7266
  if request.publish:
7133
7267
  live_verified = (
7134
7268
  isinstance(live_result, dict)
7135
- and isinstance(live_result.get("components"), list)
7136
- and len(live_result.get("components")) == expected_count
7269
+ and (
7270
+ expected_count is None
7271
+ or (
7272
+ isinstance(live_result.get("components"), list)
7273
+ and len(live_result.get("components")) == expected_count
7274
+ )
7275
+ )
7137
7276
  )
7138
7277
  live_meta_verified, live_meta_mismatches = _verify_portal_readback(
7139
7278
  actual=live_result,
7140
7279
  expected_payload=update_payload,
7280
+ expected_visibility=requested_visibility.model_dump(mode="json") if requested_visibility is not None else None,
7141
7281
  expected_section_count=expected_count,
7142
7282
  requested_config_keys=set((request.config or {}).keys()),
7143
7283
  verify_dash_name=verify_dash_name,
@@ -7171,7 +7311,15 @@ class AiBuilderFacade:
7171
7311
  "status": status,
7172
7312
  "error_code": error_code,
7173
7313
  "recoverable": not verified,
7174
- "message": "applied portal" if verified else "applied portal; draft/live verification pending",
7314
+ "message": (
7315
+ "updated portal base info"
7316
+ if verified and not sections_requested
7317
+ else "applied portal"
7318
+ if verified
7319
+ else "updated portal base info; draft/live verification pending"
7320
+ if not sections_requested
7321
+ else "applied portal; draft/live verification pending"
7322
+ ),
7175
7323
  "normalized_args": normalized_args,
7176
7324
  "missing_fields": [],
7177
7325
  "allowed_values": {"section.source_type": ["chart", "view", "grid", "filter", "text", "link"]},
@@ -7366,17 +7514,35 @@ class AiBuilderFacade:
7366
7514
  sync_result = {**sync_result, "button_config_restored": True}
7367
7515
  return sync_result
7368
7516
 
7369
- def _load_views_result(self, *, profile: str, app_key: str, tolerate_404: bool) -> tuple[Any, bool]:
7517
+ def _load_views_result(
7518
+ self,
7519
+ *,
7520
+ profile: str,
7521
+ app_key: str,
7522
+ tolerate_404: bool,
7523
+ tolerate_permission_restricted: bool = False,
7524
+ ) -> tuple[Any, bool]:
7370
7525
  try:
7371
7526
  views = self.views.view_list_flat(profile=profile, app_key=app_key)
7372
7527
  except (QingflowApiError, RuntimeError) as error:
7373
7528
  api_error = _coerce_api_error(error)
7374
- if api_error.http_status == 404:
7529
+ if api_error.http_status == 404 or (
7530
+ tolerate_permission_restricted and _is_permission_restricted_api_error(api_error)
7531
+ ):
7375
7532
  try:
7376
7533
  legacy_views = self.views.view_list(profile=profile, app_key=app_key)
7377
7534
  except (QingflowApiError, RuntimeError) as legacy_error:
7378
7535
  legacy_api_error = _coerce_api_error(legacy_error)
7379
- if tolerate_404 and legacy_api_error.http_status == 404:
7536
+ if (
7537
+ tolerate_404
7538
+ and (
7539
+ legacy_api_error.http_status == 404
7540
+ or (
7541
+ tolerate_permission_restricted
7542
+ and _is_permission_restricted_api_error(legacy_api_error)
7543
+ )
7544
+ )
7545
+ ):
7380
7546
  return [], True
7381
7547
  raise
7382
7548
  legacy_result = legacy_views.get("result")
@@ -7393,19 +7559,38 @@ class AiBuilderFacade:
7393
7559
  legacy_views = self.views.view_list(profile=profile, app_key=app_key)
7394
7560
  except (QingflowApiError, RuntimeError) as legacy_error:
7395
7561
  legacy_api_error = _coerce_api_error(legacy_error)
7396
- if tolerate_404 and legacy_api_error.http_status == 404:
7562
+ if (
7563
+ tolerate_404
7564
+ and (
7565
+ legacy_api_error.http_status == 404
7566
+ or (
7567
+ tolerate_permission_restricted
7568
+ and _is_permission_restricted_api_error(legacy_api_error)
7569
+ )
7570
+ )
7571
+ ):
7397
7572
  return normalized_views, False
7398
7573
  raise
7399
7574
  legacy_result = legacy_views.get("result")
7400
7575
  legacy_normalized = _normalize_view_collection(legacy_result)
7401
7576
  return legacy_normalized or normalized_views, False
7402
7577
 
7403
- def _load_workflow_result(self, *, profile: str, app_key: str, tolerate_404: bool) -> tuple[Any, bool]:
7578
+ def _load_workflow_result(
7579
+ self,
7580
+ *,
7581
+ profile: str,
7582
+ app_key: str,
7583
+ tolerate_404: bool,
7584
+ tolerate_permission_restricted: bool = False,
7585
+ ) -> tuple[Any, bool]:
7404
7586
  try:
7405
7587
  workflow = self.workflows.workflow_list_nodes(profile=profile, app_key=app_key)
7406
7588
  except (QingflowApiError, RuntimeError) as error:
7407
7589
  api_error = _coerce_api_error(error)
7408
- if tolerate_404 and api_error.http_status == 404:
7590
+ if tolerate_404 and (
7591
+ api_error.http_status == 404
7592
+ or (tolerate_permission_restricted and _is_permission_restricted_api_error(api_error))
7593
+ ):
7409
7594
  return [], True
7410
7595
  raise
7411
7596
  return workflow.get("result"), False
@@ -8697,6 +8882,11 @@ def _build_public_chart_config_payload(
8697
8882
  return payload
8698
8883
 
8699
8884
 
8885
+ def _chart_patch_updates_chart_config(patch: ChartUpsertPatch) -> bool:
8886
+ explicit_fields = set(getattr(patch, "model_fields_set", set()) or set())
8887
+ return bool({"dimension_field_ids", "indicator_field_ids", "filters", "config"} & explicit_fields)
8888
+
8889
+
8700
8890
  def _build_public_portal_base_payload(
8701
8891
  *,
8702
8892
  dash_name: str,
@@ -8926,7 +9116,8 @@ def _verify_portal_readback(
8926
9116
  *,
8927
9117
  actual: Any,
8928
9118
  expected_payload: dict[str, Any],
8929
- expected_section_count: int,
9119
+ expected_visibility: dict[str, Any] | None,
9120
+ expected_section_count: int | None,
8930
9121
  requested_config_keys: set[str],
8931
9122
  verify_dash_name: bool,
8932
9123
  verify_dash_icon: bool,
@@ -8939,14 +9130,19 @@ def _verify_portal_readback(
8939
9130
  if not isinstance(actual, dict):
8940
9131
  return False, ["portal readback payload is unavailable"]
8941
9132
  components = actual.get("components")
8942
- if not isinstance(components, list) or len(components) != expected_section_count:
9133
+ if expected_section_count is not None and (not isinstance(components, list) or len(components) != expected_section_count):
8943
9134
  mismatches.append(f"components expected {expected_section_count}, got {len(components) if isinstance(components, list) else 'unavailable'}")
8944
9135
  if verify_dash_name and str(actual.get("dashName") or "").strip() != str(expected_payload.get("dashName") or "").strip():
8945
9136
  mismatches.append("dash_name")
8946
9137
  if verify_dash_icon and str(actual.get("dashIcon") or "") != str(expected_payload.get("dashIcon") or ""):
8947
9138
  mismatches.append("dash_icon")
8948
- if verify_auth and not _mapping_contains(actual.get("auth"), expected_payload.get("auth")):
8949
- mismatches.append("auth")
9139
+ if verify_auth:
9140
+ if expected_visibility is not None:
9141
+ actual_visibility = _public_visibility_from_member_auth(actual.get("auth"))
9142
+ if not _visibility_matches_expected(actual_visibility, expected_visibility):
9143
+ mismatches.append("auth")
9144
+ elif not _mapping_contains(actual.get("auth"), expected_payload.get("auth")):
9145
+ mismatches.append("auth")
8950
9146
  if verify_hide_copyright and bool(actual.get("hideCopyright", False)) != bool(expected_payload.get("hideCopyright", False)):
8951
9147
  mismatches.append("hide_copyright")
8952
9148
  if verify_dash_global_config and not _mapping_contains(actual.get("dashGlobalConfig") or {}, expected_payload.get("dashGlobalConfig") or {}):
@@ -9154,7 +9350,11 @@ def _visibility_matches_expected(actual: Any, expected: Any) -> bool:
9154
9350
  if expected_text and sorted_values(actual_group, text_key) != expected_text:
9155
9351
  return False
9156
9352
 
9157
- if "include_sub_departs" in expected_selectors and actual_selectors.get("include_sub_departs") != expected_selectors.get("include_sub_departs"):
9353
+ if (
9354
+ "include_sub_departs" in expected_selectors
9355
+ and expected_selectors.get("include_sub_departs") is not None
9356
+ and actual_selectors.get("include_sub_departs") != expected_selectors.get("include_sub_departs")
9357
+ ):
9158
9358
  return False
9159
9359
  return True
9160
9360
 
@@ -9611,6 +9811,15 @@ def _apply_relation_target_selection(
9611
9811
  config["refer_field_types"] = [item.get("type") for item in normalized_visible]
9612
9812
  config["auth_field_ids"] = [item.get("field_id") or item.get("name") for item in normalized_visible]
9613
9813
  config["auth_field_que_ids"] = [_coerce_positive_int(item.get("que_id")) or 0 for item in normalized_visible]
9814
+ config["refer_auth_ques"] = [
9815
+ {
9816
+ "queId": _coerce_positive_int(item.get("que_id")) or 0,
9817
+ "queAuth": _REFERENCE_FIELD_VISIBLE_AUTH,
9818
+ "_field_id": item.get("field_id") or item.get("name"),
9819
+ }
9820
+ for item in normalized_visible
9821
+ if (_coerce_positive_int(item.get("que_id")) or 0) > 0
9822
+ ]
9614
9823
  config["field_name_show"] = bool(field.get("field_name_show", True))
9615
9824
  field["target_field_id"] = display_field.get("field_id") or display_field.get("name")
9616
9825
  field["target_field_que_id"] = _coerce_positive_int(display_field.get("que_id")) or 0
@@ -9844,9 +10053,10 @@ def _parse_field(question: dict[str, Any], *, field_id_hint: str | None = None)
9844
10053
  "subfields": [],
9845
10054
  "que_id": que_id,
9846
10055
  "que_type": que_type,
9847
- "default_type": _coerce_positive_int(question.get("queDefaultType")) or 1,
9848
- "default_value": question.get("queDefaultValue"),
10056
+ "default_type": _coerce_positive_int(question.get("queDefaultType")) if "queDefaultType" in question else None,
9849
10057
  }
10058
+ if "queDefaultValue" in question:
10059
+ field["default_value"] = question.get("queDefaultValue")
9850
10060
  if field_type in {FieldType.single_select.value, FieldType.multi_select.value, FieldType.boolean.value}:
9851
10061
  options = question.get("options")
9852
10062
  if isinstance(options, list):
@@ -9870,17 +10080,32 @@ def _parse_field(question: dict[str, Any], *, field_id_hint: str | None = None)
9870
10080
  field["target_app_key"] = reference.get("referAppKey")
9871
10081
  field["relation_mode"] = _relation_mode_from_optional_data_num(reference.get("optionalDataNum"))
9872
10082
  refer_questions = reference.get("referQuestions") if isinstance(reference.get("referQuestions"), list) else []
10083
+ refer_auth_questions = reference.get("referAuthQues") if isinstance(reference.get("referAuthQues"), list) else []
10084
+ refer_auth_by_que_id: dict[int, int] = {}
10085
+ for raw_item in refer_auth_questions:
10086
+ if not isinstance(raw_item, dict):
10087
+ continue
10088
+ que_id = _coerce_nonnegative_int(raw_item.get("queId"))
10089
+ que_auth = _coerce_nonnegative_int(raw_item.get("queAuth"))
10090
+ if que_id is None or que_auth is None or que_id in refer_auth_by_que_id:
10091
+ continue
10092
+ refer_auth_by_que_id[que_id] = que_auth
9873
10093
  visible_fields: list[dict[str, Any]] = []
9874
10094
  display_field_que_id = _coerce_nonnegative_int(reference.get("referQueId"))
9875
10095
  display_field_name: str | None = None
9876
10096
  for item in refer_questions:
9877
10097
  if not isinstance(item, dict):
9878
10098
  continue
10099
+ que_id = _coerce_nonnegative_int(item.get("queId"))
10100
+ que_auth = _coerce_nonnegative_int(item.get("queAuth"))
10101
+ if que_auth is None and que_id is not None:
10102
+ que_auth = refer_auth_by_que_id.get(que_id)
9879
10103
  selector = {
9880
- "que_id": _coerce_nonnegative_int(item.get("queId")),
10104
+ "que_id": que_id,
9881
10105
  "name": str(item.get("queTitle") or "").strip() or None,
9882
10106
  }
9883
- visible_fields.append(selector)
10107
+ if que_auth != _REFERENCE_FIELD_HIDDEN_AUTH:
10108
+ visible_fields.append(selector)
9884
10109
  if display_field_que_id is not None and selector["que_id"] == display_field_que_id:
9885
10110
  display_field_name = selector["name"]
9886
10111
  if display_field_name is None and visible_fields:
@@ -9960,6 +10185,7 @@ def _parse_field(question: dict[str, Any], *, field_id_hint: str | None = None)
9960
10185
  continue
9961
10186
  subfields.append(_parse_field(sub_question))
9962
10187
  field["subfields"] = subfields
10188
+ field["_question_template"] = deepcopy(question)
9963
10189
  return field
9964
10190
 
9965
10191
 
@@ -10505,14 +10731,14 @@ def _parse_code_block_inputs_and_body(code_content: str) -> tuple[list[dict[str,
10505
10731
  return inputs, body
10506
10732
 
10507
10733
 
10508
- def _compact_public_field_read(*, field: dict[str, Any], layout: dict[str, Any]) -> dict[str, Any]:
10734
+ def _compact_public_field_read(*, field: dict[str, Any], layout: dict[str, Any], nested: bool = False) -> dict[str, Any]:
10509
10735
  payload = {
10510
10736
  "field_id": field.get("field_id"),
10511
10737
  "que_id": field.get("que_id"),
10512
10738
  "name": field.get("name"),
10513
10739
  "type": field.get("type"),
10514
10740
  "required": bool(field.get("required")),
10515
- "section_id": _find_field_section_id(layout, str(field.get("name") or "")),
10741
+ "section_id": None if nested else _find_field_section_id(layout, str(field.get("name") or "")),
10516
10742
  }
10517
10743
  if field.get("type") == FieldType.relation.value:
10518
10744
  payload["target_app_key"] = field.get("target_app_key")
@@ -10545,6 +10771,12 @@ def _compact_public_field_read(*, field: dict[str, Any], layout: dict[str, Any])
10545
10771
  payload["custom_button_text"] = field.get("custom_button_text")
10546
10772
  if field.get("metadata_unverified") is not None:
10547
10773
  payload["metadata_unverified"] = bool(field.get("metadata_unverified"))
10774
+ if field.get("type") == FieldType.subtable.value:
10775
+ payload["subfields"] = [
10776
+ _compact_public_field_read(field=subfield, layout=layout, nested=True)
10777
+ for subfield in cast(list[dict[str, Any]], field.get("subfields") or [])
10778
+ if isinstance(subfield, dict)
10779
+ ]
10548
10780
  return payload
10549
10781
 
10550
10782
 
@@ -10840,59 +11072,119 @@ def _code_block_binding_equal(left: Any, right: Any) -> bool:
10840
11072
  return _normalize_code_block_binding(left) == _normalize_code_block_binding(right)
10841
11073
 
10842
11074
 
11075
+ _SAFE_SUBFIELD_MUTATION_KEYS = frozenset({"name", "required", "description", "subfield_updates"})
11076
+
11077
+
11078
+ def _validate_safe_subfield_mutation(*, payload: dict[str, Any], location: str) -> None:
11079
+ unsupported = sorted(key for key in payload if key not in _SAFE_SUBFIELD_MUTATION_KEYS)
11080
+ if unsupported:
11081
+ raise ValueError(
11082
+ f"{location} only supports safe overlay keys: name, required, description, subfield_updates; "
11083
+ f"unsupported keys: {', '.join(unsupported)}"
11084
+ )
11085
+
11086
+
11087
+ def _apply_subfield_updates(field: dict[str, Any], raw_updates: list[Any]) -> None:
11088
+ if str(field.get("type") or "") != FieldType.subtable.value:
11089
+ raise ValueError("subfield_updates can only target subtable fields")
11090
+ subfields = [subfield for subfield in cast(list[dict[str, Any]], field.get("subfields") or []) if isinstance(subfield, dict)]
11091
+ for index, raw_item in enumerate(raw_updates):
11092
+ patch = FieldUpdatePatch.model_validate(raw_item)
11093
+ payload = patch.set.model_dump(mode="json", exclude_none=True)
11094
+ _validate_safe_subfield_mutation(payload=payload, location=f"subfield_updates[{index}].set")
11095
+ target = _resolve_field_selector_with_uniqueness(
11096
+ fields=subfields,
11097
+ selector_payload=patch.selector.model_dump(mode="json", exclude_none=True),
11098
+ location=f"subfield_updates[{index}].selector",
11099
+ )
11100
+ _apply_field_mutation(target, patch.set)
11101
+ field["subfields"] = subfields
11102
+
11103
+
10843
11104
  def _apply_field_mutation(field: dict[str, Any], mutation: Any) -> None:
10844
11105
  payload = mutation.model_dump(mode="json", exclude_none=True)
10845
11106
  relation_config_explicit = (
10846
11107
  payload.get("type") == FieldType.relation.value
10847
11108
  or any(key in payload for key in ("target_app_key", "display_field", "visible_fields", "relation_mode"))
10848
11109
  )
11110
+ question_overlay_keys = set(cast(list[str], field.get("_question_overlay_keys") or []))
11111
+ question_rebuild_required = bool(field.get("_question_rebuild_required"))
10849
11112
  if "name" in payload:
10850
11113
  field["name"] = payload["name"]
11114
+ question_overlay_keys.add("name")
10851
11115
  if "type" in payload:
10852
11116
  field["type"] = payload["type"]
11117
+ question_rebuild_required = True
10853
11118
  if "required" in payload:
10854
11119
  field["required"] = payload["required"]
11120
+ question_overlay_keys.add("required")
10855
11121
  if "description" in payload:
10856
11122
  field["description"] = payload["description"]
11123
+ question_overlay_keys.add("description")
10857
11124
  if "options" in payload:
10858
11125
  field["options"] = list(payload["options"])
11126
+ question_rebuild_required = True
10859
11127
  if "target_app_key" in payload:
10860
11128
  field["target_app_key"] = payload["target_app_key"]
11129
+ question_rebuild_required = True
10861
11130
  if "display_field" in payload:
10862
11131
  field["display_field"] = payload["display_field"]
11132
+ question_rebuild_required = True
10863
11133
  if "visible_fields" in payload:
10864
11134
  field["visible_fields"] = list(payload["visible_fields"])
11135
+ question_rebuild_required = True
10865
11136
  if "relation_mode" in payload:
10866
11137
  field["relation_mode"] = payload["relation_mode"]
11138
+ question_rebuild_required = True
10867
11139
  if "department_scope" in payload:
10868
11140
  field["department_scope"] = payload["department_scope"]
11141
+ question_rebuild_required = True
10869
11142
  if "remote_lookup_config" in payload:
10870
11143
  field["remote_lookup_config"] = payload["remote_lookup_config"]
10871
11144
  field["config"] = deepcopy(payload["remote_lookup_config"])
10872
11145
  field["_explicit_remote_lookup_config"] = True
11146
+ question_rebuild_required = True
10873
11147
  if "q_linker_binding" in payload:
10874
11148
  field["q_linker_binding"] = payload["q_linker_binding"]
10875
11149
  if "remote_lookup_config" not in payload:
10876
11150
  field["_explicit_remote_lookup_config"] = False
11151
+ question_rebuild_required = True
10877
11152
  if "code_block_config" in payload:
10878
11153
  field["code_block_config"] = payload["code_block_config"]
10879
11154
  field["config"] = deepcopy(payload["code_block_config"])
11155
+ question_rebuild_required = True
10880
11156
  if "code_block_binding" in payload:
10881
11157
  field["code_block_binding"] = payload["code_block_binding"]
10882
11158
  field["_explicit_code_block_binding"] = True
11159
+ question_rebuild_required = True
10883
11160
  if "auto_trigger" in payload:
10884
11161
  field["auto_trigger"] = payload["auto_trigger"]
11162
+ question_rebuild_required = True
10885
11163
  if "custom_button_text_enabled" in payload:
10886
11164
  field["custom_button_text_enabled"] = payload["custom_button_text_enabled"]
11165
+ question_rebuild_required = True
10887
11166
  if "custom_button_text" in payload:
10888
11167
  field["custom_button_text"] = payload["custom_button_text"]
11168
+ question_rebuild_required = True
10889
11169
  if "subfields" in payload:
10890
11170
  field["subfields"] = [_field_patch_to_internal(item) for item in payload["subfields"]]
11171
+ question_rebuild_required = True
11172
+ if "subfield_updates" in payload:
11173
+ _apply_subfield_updates(field, payload["subfield_updates"])
10891
11174
  if relation_config_explicit:
10892
11175
  field["_relation_config_explicit"] = True
11176
+ question_rebuild_required = True
10893
11177
  elif payload.get("type") and payload.get("type") != FieldType.relation.value:
10894
11178
  field.pop("_relation_config_explicit", None)
10895
11179
  field.pop("_reference_config_template", None)
11180
+ if question_overlay_keys:
11181
+ field["_question_overlay_keys"] = sorted(question_overlay_keys)
11182
+ else:
11183
+ field.pop("_question_overlay_keys", None)
11184
+ if question_rebuild_required:
11185
+ field["_question_rebuild_required"] = True
11186
+ else:
11187
+ field.pop("_question_rebuild_required", None)
10896
11188
 
10897
11189
 
10898
11190
  def _resolve_field_selector_with_uniqueness(
@@ -12080,6 +12372,7 @@ def _warnings_from_verification_hints(hints: list[str]) -> list[dict[str, Any]]:
12080
12372
  "package attachment not verified": _warning("PACKAGE_ATTACHMENT_UNVERIFIED", "package attachment is not verified"),
12081
12373
  "layout has unplaced fields": _warning("LAYOUT_HAS_UNPLACED_FIELDS", "layout still contains unplaced fields"),
12082
12374
  "no public views detected": _warning("NO_PUBLIC_VIEWS", "no public views were detected"),
12375
+ "schema_read_unavailable": _warning("SCHEMA_READ_UNAVAILABLE", "schema summary readback is unavailable"),
12083
12376
  "views_read_unavailable": _warning("VIEWS_READ_UNAVAILABLE", "views summary readback is unavailable"),
12084
12377
  "workflow_read_unavailable": _warning("WORKFLOW_READ_UNAVAILABLE", "workflow summary readback is unavailable"),
12085
12378
  }
@@ -12380,6 +12673,26 @@ def _public_package_items_from_tag_items(tag_items: Any) -> list[JSONObject]:
12380
12673
  return public_items
12381
12674
 
12382
12675
 
12676
+ def _select_package_layout_tag_items(*, detail: Any, base: Any) -> list[Any] | None:
12677
+ base_tag_items = base.get("tagItems") if isinstance(base, dict) and isinstance(base.get("tagItems"), list) else None
12678
+ detail_tag_items = detail.get("tagItems") if isinstance(detail, dict) and isinstance(detail.get("tagItems"), list) else None
12679
+ if _package_tag_items_include_groups(base_tag_items):
12680
+ return deepcopy(base_tag_items)
12681
+ if _package_tag_items_include_groups(detail_tag_items):
12682
+ return deepcopy(detail_tag_items)
12683
+ if detail_tag_items is not None:
12684
+ return deepcopy(detail_tag_items)
12685
+ if base_tag_items is not None:
12686
+ return deepcopy(base_tag_items)
12687
+ return None
12688
+
12689
+
12690
+ def _package_tag_items_include_groups(tag_items: Any) -> bool:
12691
+ if not isinstance(tag_items, list):
12692
+ return False
12693
+ return any(isinstance(item, dict) and _coerce_positive_int(item.get("itemType")) == 3 for item in tag_items)
12694
+
12695
+
12383
12696
  def _flatten_package_resource_identities(items: Any, *, public: bool) -> set[tuple[str, str]]:
12384
12697
  flattened: set[tuple[str, str]] = set()
12385
12698
 
@@ -12690,8 +13003,887 @@ def _verify_package_attachment(packages: PackageTools, *, profile: str, tag_id:
12690
13003
  return last_result
12691
13004
 
12692
13005
 
13006
+ def _field_question_overlay_keys(field: dict[str, Any]) -> set[str]:
13007
+ raw_value = field.get("_question_overlay_keys")
13008
+ if isinstance(raw_value, set):
13009
+ return {str(item) for item in raw_value if isinstance(item, str) and item}
13010
+ if isinstance(raw_value, list):
13011
+ return {str(item) for item in raw_value if isinstance(item, str) and item}
13012
+ return set()
13013
+
13014
+
13015
+ def _field_needs_question_rebuild(field: dict[str, Any]) -> bool:
13016
+ return not isinstance(field.get("_question_template"), dict) or bool(field.get("_question_rebuild_required"))
13017
+
13018
+
13019
+ def _extract_template_row_lengths(schema: dict[str, Any]) -> tuple[dict[int, int], dict[str, int]]:
13020
+ lengths_by_que_id: dict[int, int] = {}
13021
+ lengths_by_title: dict[str, int] = {}
13022
+
13023
+ def remember_row(row: Any) -> None:
13024
+ if not isinstance(row, list):
13025
+ return
13026
+ questions = [question for question in row if isinstance(question, dict)]
13027
+ row_length = len(questions)
13028
+ if row_length <= 0:
13029
+ return
13030
+ for question in questions:
13031
+ que_id = _coerce_nonnegative_int(question.get("queId"))
13032
+ if que_id is not None:
13033
+ lengths_by_que_id[que_id] = row_length
13034
+ title = str(question.get("queTitle") or "").strip()
13035
+ if title:
13036
+ lengths_by_title[title] = row_length
13037
+
13038
+ for row in schema.get("formQues", []) or []:
13039
+ if not isinstance(row, list):
13040
+ continue
13041
+ if len(row) == 1 and isinstance(row[0], dict) and _coerce_positive_int(row[0].get("queType")) == 24:
13042
+ for inner_row in row[0].get("innerQuestions", []) or []:
13043
+ remember_row(inner_row)
13044
+ continue
13045
+ remember_row(row)
13046
+ return lengths_by_que_id, lengths_by_title
13047
+
13048
+
13049
+ def _field_template_row_length(
13050
+ field: dict[str, Any],
13051
+ *,
13052
+ lengths_by_que_id: dict[int, int],
13053
+ lengths_by_title: dict[str, int],
13054
+ ) -> int | None:
13055
+ que_id = _coerce_nonnegative_int(field.get("que_id"))
13056
+ if que_id is not None and que_id in lengths_by_que_id:
13057
+ return lengths_by_que_id[que_id]
13058
+ template = field.get("_question_template")
13059
+ if isinstance(template, dict):
13060
+ template_que_id = _coerce_nonnegative_int(template.get("queId"))
13061
+ if template_que_id is not None and template_que_id in lengths_by_que_id:
13062
+ return lengths_by_que_id[template_que_id]
13063
+ template_title = str(template.get("queTitle") or "").strip()
13064
+ if template_title and template_title in lengths_by_title:
13065
+ return lengths_by_title[template_title]
13066
+ field_name = str(field.get("name") or "").strip()
13067
+ if field_name and field_name in lengths_by_title:
13068
+ return lengths_by_title[field_name]
13069
+ return None
13070
+
13071
+
13072
+ def _row_needs_width_reflow(expected_template_lengths: list[int], current_row_length: int) -> bool:
13073
+ if current_row_length <= 0:
13074
+ return False
13075
+ return any(length != current_row_length for length in expected_template_lengths)
13076
+
13077
+
13078
+ _FORM_SAVE_BASE_KEYS = (
13079
+ "formDesc",
13080
+ "formTheme",
13081
+ "formAttach",
13082
+ "formStyle",
13083
+ "serialNumType",
13084
+ "serialNumConfig",
13085
+ "attachVisibleOnlyConfig",
13086
+ "externalLang",
13087
+ "hideCopyright",
13088
+ )
13089
+
13090
+ _QUESTION_RELATION_SAVE_KEYS = (
13091
+ "queId",
13092
+ "relationType",
13093
+ "displayedQueId",
13094
+ "qlinkerAlias",
13095
+ "displayedQueInfo",
13096
+ "aliasConfig",
13097
+ "matchRules",
13098
+ "tableMatchRules",
13099
+ "matchRuleType",
13100
+ "matchRuleFormula",
13101
+ "sortConfig",
13102
+ )
13103
+
13104
+ _RELATION_QUESTION_SAVE_KEYS = (
13105
+ "queId",
13106
+ "queTempId",
13107
+ "queType",
13108
+ "queOriginType",
13109
+ "queTitle",
13110
+ "queWidth",
13111
+ "scanType",
13112
+ "status",
13113
+ "required",
13114
+ "queHint",
13115
+ "linkedQuestions",
13116
+ "logicalShow",
13117
+ "queDefaultType",
13118
+ "queDefaultValue",
13119
+ "queDefaultValues",
13120
+ "subQueWidth",
13121
+ "innerQuestions",
13122
+ "minOpts",
13123
+ "maxOpts",
13124
+ "beingHide",
13125
+ "beingDesensitized",
13126
+ "relationDisplayMode",
13127
+ "customRenderConfig",
13128
+ )
13129
+
13130
+ _REFERENCE_CONFIG_SAVE_KEYS = (
13131
+ "referAppKey",
13132
+ "referQueId",
13133
+ "customButtonText",
13134
+ "beingTableSource",
13135
+ "referMatchRules",
13136
+ "canAddData",
13137
+ "dataAdditionButtonText",
13138
+ "canViewProcessLog",
13139
+ "optionalDataNum",
13140
+ "beingDataLogVisible",
13141
+ "beingDefaultFormulaAutoFillEnabled",
13142
+ "defaultValueMatchRules",
13143
+ "configShowForm",
13144
+ "configSortFieldId",
13145
+ "configAsc",
13146
+ "dataShowForm",
13147
+ "defaultRow",
13148
+ "fieldNameShow",
13149
+ "dataSortFieldId",
13150
+ "dataSortAsc",
13151
+ )
13152
+
13153
+ _REFERENCE_QUESTION_SAVE_KEYS = (
13154
+ "queId",
13155
+ "queTitle",
13156
+ "queType",
13157
+ "queAuth",
13158
+ "ordinal",
13159
+ )
13160
+
13161
+ _REFERENCE_FILL_RULE_SAVE_KEYS = (
13162
+ "queId",
13163
+ "relatedQueId",
13164
+ "queTitle",
13165
+ "relatedQueTitle",
13166
+ )
13167
+
13168
+ _REFERENCE_AUTH_QUESTION_SAVE_KEYS = (
13169
+ "queId",
13170
+ "queAuth",
13171
+ )
13172
+
13173
+
13174
+ def _copy_present_keys(
13175
+ source: dict[str, Any],
13176
+ keys: tuple[str, ...],
13177
+ *,
13178
+ keep_none_keys: tuple[str, ...] = (),
13179
+ ) -> dict[str, Any]:
13180
+ payload: dict[str, Any] = {}
13181
+ keep_none = set(keep_none_keys)
13182
+ for key in keys:
13183
+ if key not in source:
13184
+ continue
13185
+ value = source.get(key)
13186
+ if value is None and key not in keep_none:
13187
+ continue
13188
+ payload[key] = deepcopy(value)
13189
+ return payload
13190
+
13191
+
13192
+ def _looks_like_backend_encoded_formula(value: str) -> bool:
13193
+ if len(value) <= 32:
13194
+ return False
13195
+ encoded = value[16:-16]
13196
+ if not encoded:
13197
+ return False
13198
+ try:
13199
+ decoded = base64.b64decode(encoded, validate=True).decode("utf-8")
13200
+ unquote_plus(decoded)
13201
+ except Exception:
13202
+ return False
13203
+ return True
13204
+
13205
+
13206
+ def _encode_formula_for_backend_save(value: Any) -> Any:
13207
+ if not isinstance(value, str) or not value:
13208
+ return value
13209
+ if _looks_like_backend_encoded_formula(value):
13210
+ return value
13211
+ encoded = quote_plus(value, encoding="utf-8")
13212
+ b64_value = base64.b64encode(encoded.encode("utf-8")).decode("ascii")
13213
+ alphabet = string.ascii_letters + string.digits
13214
+ prefix = "".join(random.choice(alphabet) for _ in range(16))
13215
+ suffix = "".join(random.choice(alphabet) for _ in range(16))
13216
+ return f"{prefix}{b64_value}{suffix}"
13217
+
13218
+
13219
+ def _normalize_formula_defaults_for_save(value: Any) -> None:
13220
+ if isinstance(value, list):
13221
+ for item in value:
13222
+ _normalize_formula_defaults_for_save(item)
13223
+ return
13224
+ if not isinstance(value, dict):
13225
+ return
13226
+ if _coerce_any_int(value.get("queDefaultType")) == DEFAULT_TYPE_FORMULA and value.get("queDefaultValue"):
13227
+ value["queDefaultValue"] = _encode_formula_for_backend_save(value.get("queDefaultValue"))
13228
+ for key in ("subQuestions", "innerQuestions"):
13229
+ nested = value.get(key)
13230
+ if isinstance(nested, (list, dict)):
13231
+ _normalize_formula_defaults_for_save(nested)
13232
+
13233
+
13234
+ def _normalize_reference_question_for_save(value: Any, *, ordinal: int) -> dict[str, Any] | None:
13235
+ if not isinstance(value, dict):
13236
+ return None
13237
+ payload = _copy_present_keys(value, _REFERENCE_QUESTION_SAVE_KEYS)
13238
+ que_id = _coerce_any_int(value.get("queId"))
13239
+ if que_id is not None:
13240
+ payload["queId"] = que_id
13241
+ if "ordinal" not in payload:
13242
+ payload["ordinal"] = _coerce_nonnegative_int(value.get("ordinal"))
13243
+ if payload.get("ordinal") is None:
13244
+ payload["ordinal"] = ordinal
13245
+ if not any(key in payload for key in ("queId", "queTitle", "queType")):
13246
+ return None
13247
+ return payload
13248
+
13249
+
13250
+ def _normalize_reference_fill_rule_for_save(value: Any) -> dict[str, Any] | None:
13251
+ if not isinstance(value, dict):
13252
+ return None
13253
+ payload = _copy_present_keys(value, _REFERENCE_FILL_RULE_SAVE_KEYS)
13254
+ que_id = _coerce_nonnegative_int(value.get("queId"))
13255
+ related_que_id = _coerce_nonnegative_int(value.get("relatedQueId", value.get("referQueId")))
13256
+ if que_id is not None:
13257
+ payload["queId"] = que_id
13258
+ if related_que_id is not None:
13259
+ payload["relatedQueId"] = related_que_id
13260
+ if "relatedQueTitle" not in payload and value.get("referQueTitle") is not None:
13261
+ payload["relatedQueTitle"] = str(value.get("referQueTitle") or "")
13262
+ if "queId" not in payload or "relatedQueId" not in payload:
13263
+ return None
13264
+ return payload
13265
+
13266
+
13267
+ def _normalize_reference_auth_question_for_save(value: Any) -> dict[str, Any] | None:
13268
+ if not isinstance(value, dict):
13269
+ return None
13270
+ payload = _copy_present_keys(value, _REFERENCE_AUTH_QUESTION_SAVE_KEYS)
13271
+ que_id = _coerce_any_int(value.get("queId"))
13272
+ if que_id is not None:
13273
+ payload["queId"] = que_id
13274
+ que_auth = _coerce_nonnegative_int(value.get("queAuth"))
13275
+ if que_auth is not None:
13276
+ payload["queAuth"] = que_auth
13277
+ sub_ques = [
13278
+ item
13279
+ for item in (
13280
+ _normalize_reference_auth_question_for_save(raw_item)
13281
+ for raw_item in cast(list[Any], value.get("subQues") or [])
13282
+ )
13283
+ if item is not None
13284
+ ]
13285
+ inner_ques = [
13286
+ item
13287
+ for item in (
13288
+ _normalize_reference_auth_question_for_save(raw_item)
13289
+ for raw_item in cast(list[Any], value.get("innerQues") or [])
13290
+ )
13291
+ if item is not None
13292
+ ]
13293
+ if sub_ques or "subQues" in value:
13294
+ payload["subQues"] = sub_ques
13295
+ if inner_ques or "innerQues" in value:
13296
+ payload["innerQues"] = inner_ques
13297
+ if "queId" not in payload or "queAuth" not in payload:
13298
+ return None
13299
+ return payload
13300
+
13301
+
13302
+ def _dedupe_reference_auth_questions(auth_questions: list[dict[str, Any]]) -> list[dict[str, Any]]:
13303
+ deduped: list[dict[str, Any]] = []
13304
+ seen_que_ids: set[int] = set()
13305
+ for item in auth_questions:
13306
+ normalized_item = _normalize_reference_auth_question_for_save(item)
13307
+ if normalized_item is None:
13308
+ continue
13309
+ que_id = _coerce_any_int(normalized_item.get("queId"))
13310
+ if que_id is None or que_id in seen_que_ids:
13311
+ continue
13312
+ seen_que_ids.add(que_id)
13313
+ deduped.append(normalized_item)
13314
+ return deduped
13315
+
13316
+
13317
+ _REFERENCE_FIELD_HIDDEN_AUTH = 2
13318
+ _REFERENCE_FIELD_VISIBLE_AUTH = 3
13319
+
13320
+
13321
+ def _synthesize_reference_auth_questions_for_save(
13322
+ *,
13323
+ source: dict[str, Any],
13324
+ field: dict[str, Any],
13325
+ ) -> list[dict[str, Any]]:
13326
+ config = field.get("config") if isinstance(field.get("config"), dict) else {}
13327
+ synthesized: list[dict[str, Any]] = []
13328
+
13329
+ if isinstance(config.get("refer_auth_ques"), list):
13330
+ synthesized.extend(cast(list[dict[str, Any]], config.get("refer_auth_ques") or []))
13331
+ if synthesized:
13332
+ return _dedupe_reference_auth_questions(synthesized)
13333
+
13334
+ refer_question_ids_by_name: dict[str, int] = {}
13335
+ for raw_item in cast(list[Any], source.get("referQuestions") or []):
13336
+ if not isinstance(raw_item, dict):
13337
+ continue
13338
+ que_id = _coerce_any_int(raw_item.get("queId"))
13339
+ name = str(raw_item.get("queTitle") or "").strip()
13340
+ if que_id is None or not name or name in refer_question_ids_by_name:
13341
+ continue
13342
+ refer_question_ids_by_name[name] = que_id
13343
+
13344
+ visible_fields = cast(list[dict[str, Any]], field.get("visible_fields") or [])
13345
+ for item in visible_fields:
13346
+ if not isinstance(item, dict):
13347
+ continue
13348
+ que_id = _coerce_any_int(item.get("que_id"))
13349
+ if que_id is None:
13350
+ name = str(item.get("name") or "").strip()
13351
+ que_id = refer_question_ids_by_name.get(name)
13352
+ if que_id is None:
13353
+ continue
13354
+ synthesized.append({"queId": que_id, "queAuth": _REFERENCE_FIELD_VISIBLE_AUTH})
13355
+ if synthesized:
13356
+ return _dedupe_reference_auth_questions(synthesized)
13357
+
13358
+ auth_field_que_ids = cast(list[Any], config.get("auth_field_que_ids") or [])
13359
+ for raw_que_id in auth_field_que_ids:
13360
+ que_id = _coerce_any_int(raw_que_id)
13361
+ if que_id is None:
13362
+ continue
13363
+ synthesized.append({"queId": que_id, "queAuth": _REFERENCE_FIELD_VISIBLE_AUTH})
13364
+ if synthesized:
13365
+ return _dedupe_reference_auth_questions(synthesized)
13366
+
13367
+ for raw_item in cast(list[Any], source.get("referQuestions") or []):
13368
+ if not isinstance(raw_item, dict):
13369
+ continue
13370
+ que_id = _coerce_any_int(raw_item.get("queId"))
13371
+ if que_id is None:
13372
+ continue
13373
+ synthesized.append(
13374
+ {
13375
+ "queId": que_id,
13376
+ "queAuth": _REFERENCE_FIELD_VISIBLE_AUTH,
13377
+ }
13378
+ )
13379
+ if synthesized:
13380
+ return _dedupe_reference_auth_questions(synthesized)
13381
+
13382
+ fallback_que_id = _coerce_any_int(field.get("target_field_que_id"))
13383
+ if fallback_que_id is not None:
13384
+ synthesized.append({"queId": fallback_que_id, "queAuth": _REFERENCE_FIELD_VISIBLE_AUTH})
13385
+ return _dedupe_reference_auth_questions(synthesized)
13386
+
13387
+
13388
+ def _reference_question_auth_overrides_for_save(
13389
+ *,
13390
+ source: dict[str, Any],
13391
+ field: dict[str, Any],
13392
+ ) -> dict[int, int]:
13393
+ overrides: dict[int, int] = {}
13394
+ visible_que_ids: set[int] = set()
13395
+
13396
+ for item in cast(list[Any], field.get("visible_fields") or []):
13397
+ if not isinstance(item, dict):
13398
+ continue
13399
+ que_id = _coerce_any_int(item.get("que_id"))
13400
+ if que_id is not None:
13401
+ visible_que_ids.add(que_id)
13402
+
13403
+ if not visible_que_ids:
13404
+ refer_auth_ques = _synthesize_reference_auth_questions_for_save(source=source, field=field)
13405
+ for item in refer_auth_ques:
13406
+ que_id = _coerce_any_int(item.get("queId"))
13407
+ que_auth = _coerce_nonnegative_int(item.get("queAuth"))
13408
+ if que_id is None or que_auth is None:
13409
+ continue
13410
+ overrides[que_id] = que_auth
13411
+ if overrides:
13412
+ return overrides
13413
+
13414
+ for raw_item in cast(list[Any], source.get("referQuestions") or []):
13415
+ if not isinstance(raw_item, dict):
13416
+ continue
13417
+ que_id = _coerce_any_int(raw_item.get("queId"))
13418
+ if que_id is None:
13419
+ continue
13420
+ overrides[que_id] = (
13421
+ _REFERENCE_FIELD_VISIBLE_AUTH if que_id in visible_que_ids else _REFERENCE_FIELD_HIDDEN_AUTH
13422
+ )
13423
+ return overrides
13424
+
13425
+
13426
+ def _reference_question_matches_visible_selector(question: dict[str, Any], selector: dict[str, Any]) -> bool:
13427
+ question_que_id = _coerce_any_int(question.get("queId"))
13428
+ selector_que_id = _coerce_any_int(selector.get("que_id"))
13429
+ if question_que_id is not None and selector_que_id is not None and question_que_id == selector_que_id:
13430
+ return True
13431
+ question_name = str(question.get("queTitle") or "").strip()
13432
+ selector_name = str(selector.get("name") or "").strip()
13433
+ return bool(question_name and selector_name and question_name == selector_name)
13434
+
13435
+
13436
+ def _build_reference_question_from_visible_selector(
13437
+ selector: dict[str, Any],
13438
+ *,
13439
+ ordinal: int,
13440
+ ) -> dict[str, Any] | None:
13441
+ return _normalize_reference_question_for_save(
13442
+ {
13443
+ "queId": _coerce_any_int(selector.get("que_id")),
13444
+ "queTitle": str(selector.get("name") or "").strip() or None,
13445
+ "queType": str(selector.get("type") or "2"),
13446
+ "ordinal": ordinal,
13447
+ },
13448
+ ordinal=ordinal,
13449
+ )
13450
+
13451
+
13452
+ def _canonicalize_reference_questions_for_save(
13453
+ *,
13454
+ source: dict[str, Any],
13455
+ field: dict[str, Any],
13456
+ ) -> list[dict[str, Any]]:
13457
+ relation_config_explicit = bool(field.get("_relation_config_explicit"))
13458
+ normalized_source_questions = [
13459
+ item
13460
+ for item in (
13461
+ _normalize_reference_question_for_save(raw_item, ordinal=index)
13462
+ for index, raw_item in enumerate(cast(list[Any], source.get("referQuestions") or []), start=1)
13463
+ )
13464
+ if item is not None
13465
+ ]
13466
+ if not relation_config_explicit:
13467
+ return normalized_source_questions
13468
+
13469
+ display_field = field.get("display_field") if isinstance(field.get("display_field"), dict) else None
13470
+ visible_fields = [item for item in cast(list[Any], field.get("visible_fields") or []) if isinstance(item, dict)]
13471
+ ordered_visible_selectors: list[dict[str, Any]] = []
13472
+ if display_field is not None:
13473
+ ordered_visible_selectors.append(display_field)
13474
+ for item in visible_fields:
13475
+ if any(_relation_target_field_matches(existing, item) for existing in ordered_visible_selectors):
13476
+ continue
13477
+ ordered_visible_selectors.append(item)
13478
+
13479
+ if not ordered_visible_selectors:
13480
+ return normalized_source_questions
13481
+
13482
+ canonical_questions: list[dict[str, Any]] = []
13483
+ used_source_indexes: set[int] = set()
13484
+
13485
+ for ordinal, selector in enumerate(ordered_visible_selectors, start=1):
13486
+ matched_index: int | None = None
13487
+ matched_item: dict[str, Any] | None = None
13488
+ for index, item in enumerate(normalized_source_questions):
13489
+ if index in used_source_indexes:
13490
+ continue
13491
+ if _reference_question_matches_visible_selector(item, selector):
13492
+ matched_index = index
13493
+ matched_item = deepcopy(item)
13494
+ break
13495
+ if matched_item is None:
13496
+ matched_item = _build_reference_question_from_visible_selector(selector, ordinal=ordinal)
13497
+ if matched_item is None:
13498
+ continue
13499
+ matched_item["ordinal"] = ordinal
13500
+ canonical_questions.append(matched_item)
13501
+ if matched_index is not None:
13502
+ used_source_indexes.add(matched_index)
13503
+
13504
+ source_target_app_key = str(source.get("referAppKey") or "").strip()
13505
+ target_app_key = str(field.get("target_app_key") or "").strip()
13506
+ preserve_remaining_source_questions = not source_target_app_key or source_target_app_key == target_app_key
13507
+
13508
+ if preserve_remaining_source_questions:
13509
+ next_ordinal = len(canonical_questions) + 1
13510
+ for index, item in enumerate(normalized_source_questions):
13511
+ if index in used_source_indexes:
13512
+ continue
13513
+ remaining_item = deepcopy(item)
13514
+ remaining_item["ordinal"] = next_ordinal
13515
+ next_ordinal += 1
13516
+ canonical_questions.append(remaining_item)
13517
+
13518
+ return canonical_questions
13519
+
13520
+
13521
+ def _canonicalize_reference_auth_questions_for_save(
13522
+ *,
13523
+ source: dict[str, Any],
13524
+ refer_questions: list[dict[str, Any]],
13525
+ relation_config_explicit: bool,
13526
+ ) -> list[dict[str, Any]]:
13527
+ source_auth_questions = [
13528
+ item
13529
+ for item in (
13530
+ _normalize_reference_auth_question_for_save(raw_item)
13531
+ for raw_item in cast(list[Any], source.get("referAuthQues") or [])
13532
+ )
13533
+ if item is not None
13534
+ ]
13535
+ source_auth_by_que_id: dict[int, dict[str, Any]] = {}
13536
+ for item in source_auth_questions:
13537
+ que_id = _coerce_any_int(item.get("queId"))
13538
+ if que_id is None or que_id in source_auth_by_que_id:
13539
+ continue
13540
+ source_auth_by_que_id[que_id] = item
13541
+
13542
+ if not relation_config_explicit:
13543
+ auth_questions: list[dict[str, Any]] = []
13544
+ seen_que_ids: set[int] = set()
13545
+ refer_question_auth_by_que_id: dict[int, int] = {}
13546
+ for item in refer_questions:
13547
+ que_id = _coerce_any_int(item.get("queId"))
13548
+ que_auth = _coerce_nonnegative_int(item.get("queAuth"))
13549
+ if que_id is None or que_auth is None or que_id in refer_question_auth_by_que_id:
13550
+ continue
13551
+ refer_question_auth_by_que_id[que_id] = que_auth
13552
+
13553
+ for item in source_auth_questions:
13554
+ que_id = _coerce_any_int(item.get("queId"))
13555
+ if que_id is None or que_id in seen_que_ids:
13556
+ continue
13557
+ payload = deepcopy(item)
13558
+ if que_id in refer_question_auth_by_que_id:
13559
+ payload["queAuth"] = refer_question_auth_by_que_id[que_id]
13560
+ auth_questions.append(payload)
13561
+ seen_que_ids.add(que_id)
13562
+
13563
+ for item in refer_questions:
13564
+ que_id = _coerce_any_int(item.get("queId"))
13565
+ que_auth = _coerce_nonnegative_int(item.get("queAuth"))
13566
+ if que_id is None or que_auth is None or que_id in seen_que_ids:
13567
+ continue
13568
+ payload = deepcopy(source_auth_by_que_id.get(que_id) or {"queId": que_id})
13569
+ payload["queId"] = que_id
13570
+ payload["queAuth"] = que_auth
13571
+ auth_questions.append(payload)
13572
+ seen_que_ids.add(que_id)
13573
+
13574
+ return _dedupe_reference_auth_questions(auth_questions)
13575
+
13576
+ auth_questions: list[dict[str, Any]] = []
13577
+ for item in refer_questions:
13578
+ que_id = _coerce_any_int(item.get("queId"))
13579
+ que_auth = _coerce_nonnegative_int(item.get("queAuth"))
13580
+ if que_id is None or que_auth is None:
13581
+ continue
13582
+ payload = deepcopy(source_auth_by_que_id.get(que_id) or {"queId": que_id})
13583
+ payload["queId"] = que_id
13584
+ payload["queAuth"] = que_auth
13585
+ auth_questions.append(payload)
13586
+ return _dedupe_reference_auth_questions(auth_questions)
13587
+
13588
+
13589
+ def _enforce_reference_config_consistency_for_save(
13590
+ payload: dict[str, Any],
13591
+ *,
13592
+ field: dict[str, Any],
13593
+ ) -> dict[str, Any]:
13594
+ relation_config_explicit = bool(field.get("_relation_config_explicit"))
13595
+ refer_questions = [
13596
+ item
13597
+ for item in (
13598
+ _normalize_reference_question_for_save(raw_item, ordinal=index)
13599
+ for index, raw_item in enumerate(cast(list[Any], payload.get("referQuestions") or []), start=1)
13600
+ )
13601
+ if item is not None
13602
+ ]
13603
+ if not refer_questions:
13604
+ return payload
13605
+
13606
+ refer_auth_ques = _dedupe_reference_auth_questions(
13607
+ [
13608
+ item
13609
+ for item in (
13610
+ _normalize_reference_auth_question_for_save(raw_item)
13611
+ for raw_item in cast(list[Any], payload.get("referAuthQues") or [])
13612
+ )
13613
+ if item is not None
13614
+ ]
13615
+ )
13616
+ refer_auth_by_que_id: dict[int, int] = {}
13617
+ for item in refer_auth_ques:
13618
+ que_id = _coerce_any_int(item.get("queId"))
13619
+ que_auth = _coerce_nonnegative_int(item.get("queAuth"))
13620
+ if que_id is None or que_auth is None or que_id in refer_auth_by_que_id:
13621
+ continue
13622
+ refer_auth_by_que_id[que_id] = que_auth
13623
+
13624
+ display_field_que_id = _coerce_any_int(payload.get("referQueId"))
13625
+ if display_field_que_id is None:
13626
+ display_field_que_id = _coerce_any_int(field.get("target_field_que_id"))
13627
+ if display_field_que_id is not None:
13628
+ payload["referQueId"] = display_field_que_id
13629
+
13630
+ if relation_config_explicit:
13631
+ if display_field_que_id is not None and not any(
13632
+ _coerce_any_int(item.get("queId")) == display_field_que_id for item in refer_questions
13633
+ ):
13634
+ display_selector = field.get("display_field") if isinstance(field.get("display_field"), dict) else None
13635
+ display_question = (
13636
+ _build_reference_question_from_visible_selector(display_selector, ordinal=1)
13637
+ if display_selector is not None
13638
+ else None
13639
+ )
13640
+ if display_question is not None:
13641
+ display_question["queId"] = display_field_que_id
13642
+ display_question["queAuth"] = _REFERENCE_FIELD_VISIBLE_AUTH
13643
+ refer_questions = [display_question, *refer_questions]
13644
+
13645
+ if display_field_que_id is not None:
13646
+ display_questions = [
13647
+ item for item in refer_questions if _coerce_any_int(item.get("queId")) == display_field_que_id
13648
+ ]
13649
+ trailing_questions = [
13650
+ item for item in refer_questions if _coerce_any_int(item.get("queId")) != display_field_que_id
13651
+ ]
13652
+ refer_questions = [*display_questions, *trailing_questions]
13653
+
13654
+ for ordinal, item in enumerate(refer_questions, start=1):
13655
+ que_id = _coerce_any_int(item.get("queId"))
13656
+ if que_id is None:
13657
+ continue
13658
+ item["ordinal"] = ordinal
13659
+ item["queAuth"] = refer_auth_by_que_id.get(
13660
+ que_id,
13661
+ _coerce_nonnegative_int(item.get("queAuth")) or _REFERENCE_FIELD_VISIBLE_AUTH,
13662
+ )
13663
+ if display_field_que_id is not None and que_id == display_field_que_id:
13664
+ item["queAuth"] = _REFERENCE_FIELD_VISIBLE_AUTH
13665
+
13666
+ payload["referQuestions"] = refer_questions
13667
+ payload["referAuthQues"] = _canonicalize_reference_auth_questions_for_save(
13668
+ source={"referAuthQues": refer_auth_ques},
13669
+ refer_questions=refer_questions,
13670
+ relation_config_explicit=relation_config_explicit,
13671
+ )
13672
+ return payload
13673
+
13674
+
13675
+ def _normalize_reference_config_for_save(
13676
+ reference: Any,
13677
+ *,
13678
+ field: dict[str, Any],
13679
+ ) -> dict[str, Any]:
13680
+ source = reference if isinstance(reference, dict) else {}
13681
+ payload = _copy_present_keys(source, _REFERENCE_CONFIG_SAVE_KEYS)
13682
+ if str(field.get("target_app_key") or "").strip():
13683
+ payload["referAppKey"] = str(field.get("target_app_key") or "").strip()
13684
+ if field.get("target_field_que_id") is not None:
13685
+ payload["referQueId"] = _coerce_nonnegative_int(field.get("target_field_que_id"))
13686
+ if field.get("field_name_show") is not None:
13687
+ payload["fieldNameShow"] = bool(field.get("field_name_show"))
13688
+
13689
+ refer_question_auth_overrides = _reference_question_auth_overrides_for_save(source=source, field=field)
13690
+ refer_questions = _canonicalize_reference_questions_for_save(source=source, field=field)
13691
+ for index, normalized_item in enumerate(refer_questions, start=1):
13692
+ que_id = _coerce_any_int(normalized_item.get("queId"))
13693
+ if que_id is not None and que_id in refer_question_auth_overrides:
13694
+ normalized_item["queAuth"] = refer_question_auth_overrides[que_id]
13695
+ normalized_item["ordinal"] = index
13696
+ if refer_questions or "referQuestions" in source:
13697
+ payload["referQuestions"] = refer_questions
13698
+
13699
+ refer_fill_rules = [
13700
+ item
13701
+ for item in (
13702
+ _normalize_reference_fill_rule_for_save(raw_item)
13703
+ for raw_item in cast(list[Any], source.get("referFillRules") or [])
13704
+ )
13705
+ if item is not None
13706
+ ]
13707
+ if refer_fill_rules or "referFillRules" in source:
13708
+ payload["referFillRules"] = refer_fill_rules
13709
+
13710
+ refer_auth_ques = _canonicalize_reference_auth_questions_for_save(
13711
+ source=source,
13712
+ refer_questions=refer_questions,
13713
+ relation_config_explicit=bool(field.get("_relation_config_explicit")),
13714
+ )
13715
+ if not refer_auth_ques:
13716
+ refer_auth_ques = _synthesize_reference_auth_questions_for_save(source=source, field=field)
13717
+ if refer_auth_ques or "referAuthQues" in source:
13718
+ payload["referAuthQues"] = refer_auth_ques
13719
+
13720
+ return _enforce_reference_config_consistency_for_save(payload, field=field)
13721
+
13722
+
13723
+ def _normalize_relation_question_for_save(question: dict[str, Any], *, field: dict[str, Any]) -> dict[str, Any]:
13724
+ payload = _copy_present_keys(question, _RELATION_QUESTION_SAVE_KEYS)
13725
+ overlay_keys = _field_question_overlay_keys(field)
13726
+ que_id = _coerce_nonnegative_int(question.get("queId"))
13727
+ if que_id is not None:
13728
+ payload["queId"] = que_id
13729
+ que_temp_id = _coerce_nonnegative_int(question.get("queTempId"))
13730
+ if que_temp_id is not None and "queId" not in payload:
13731
+ payload["queTempId"] = que_temp_id
13732
+ payload["queType"] = _coerce_positive_int(question.get("queType")) or 25
13733
+ payload["queTitle"] = str(field.get("name") or question.get("queTitle") or "")
13734
+ if "required" in overlay_keys or "required" in question or field.get("required") is not None:
13735
+ payload["required"] = bool(field.get("required", question.get("required", False)))
13736
+ if "description" in overlay_keys:
13737
+ payload["queHint"] = "" if field.get("description") is None else str(field.get("description"))
13738
+ elif "queHint" in question and question.get("queHint") is not None:
13739
+ payload["queHint"] = str(question.get("queHint") or "")
13740
+ if field.get("default_type") is not None:
13741
+ payload["queDefaultType"] = _coerce_positive_int(field.get("default_type")) or 1
13742
+ if "default_value" in field:
13743
+ payload["queDefaultValue"] = field.get("default_value")
13744
+ payload["referenceConfig"] = _normalize_reference_config_for_save(question.get("referenceConfig"), field=field)
13745
+ return payload
13746
+
13747
+
13748
+ def _build_form_save_base_payload(current_schema: dict[str, Any], title: str) -> dict[str, Any]:
13749
+ payload: dict[str, Any] = {"formTitle": title}
13750
+ for key in _FORM_SAVE_BASE_KEYS:
13751
+ if key in current_schema:
13752
+ payload[key] = deepcopy(current_schema.get(key))
13753
+ payload["editVersionNo"] = int(current_schema.get("editVersionNo") or 1)
13754
+ payload["formQues"] = []
13755
+ payload["questionRelations"] = []
13756
+ return payload
13757
+
13758
+
13759
+ def _normalize_question_relations_for_save(question_relations: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
13760
+ normalized: list[dict[str, Any]] = []
13761
+ for relation in question_relations or []:
13762
+ if not isinstance(relation, dict):
13763
+ continue
13764
+ item: dict[str, Any] = {}
13765
+ for key in _QUESTION_RELATION_SAVE_KEYS:
13766
+ if key not in relation:
13767
+ continue
13768
+ value = relation.get(key)
13769
+ if value is None:
13770
+ continue
13771
+ if key == "matchRuleFormula":
13772
+ value = _encode_formula_for_backend_save(value)
13773
+ item[key] = deepcopy(value)
13774
+ if item:
13775
+ normalized.append(item)
13776
+ return normalized
13777
+
13778
+
13779
+ def _field_rename_maps(fields: list[dict[str, Any]]) -> tuple[dict[int, str], dict[str, str]]:
13780
+ by_que_id: dict[int, str] = {}
13781
+ by_title: dict[str, str] = {}
13782
+
13783
+ def visit(field: dict[str, Any]) -> None:
13784
+ if not isinstance(field, dict):
13785
+ return
13786
+ template = field.get("_question_template")
13787
+ if not isinstance(template, dict):
13788
+ for subfield in cast(list[dict[str, Any]], field.get("subfields") or []):
13789
+ visit(subfield)
13790
+ return
13791
+ old_title = str(template.get("queTitle") or "").strip()
13792
+ new_title = str(field.get("name") or "").strip()
13793
+ if not old_title or not new_title or old_title == new_title:
13794
+ for subfield in cast(list[dict[str, Any]], field.get("subfields") or []):
13795
+ visit(subfield)
13796
+ return
13797
+ que_id = _coerce_nonnegative_int(field.get("que_id"))
13798
+ if que_id is None:
13799
+ que_id = _coerce_nonnegative_int(template.get("queId"))
13800
+ if que_id is not None:
13801
+ by_que_id[que_id] = new_title
13802
+ by_title[old_title] = new_title
13803
+ for subfield in cast(list[dict[str, Any]], field.get("subfields") or []):
13804
+ visit(subfield)
13805
+
13806
+ for field in fields:
13807
+ visit(field)
13808
+ return by_que_id, by_title
13809
+
13810
+
13811
+ def _sync_question_title_references(value: Any, *, by_que_id: dict[int, str], by_title: dict[str, str]) -> None:
13812
+ if isinstance(value, list):
13813
+ for item in value:
13814
+ _sync_question_title_references(item, by_que_id=by_que_id, by_title=by_title)
13815
+ return
13816
+ if not isinstance(value, dict):
13817
+ return
13818
+
13819
+ title_keys = ("queTitle", "_field_id")
13820
+ que_id = _coerce_nonnegative_int(value.get("queId"))
13821
+ replacement = None
13822
+ if que_id is not None and que_id in by_que_id:
13823
+ replacement = by_que_id[que_id]
13824
+ elif que_id is None:
13825
+ for key in title_keys:
13826
+ current_title = str(value.get(key) or "").strip()
13827
+ if current_title and current_title in by_title:
13828
+ replacement = by_title[current_title]
13829
+ break
13830
+ if replacement is not None:
13831
+ for key in title_keys:
13832
+ current_title = str(value.get(key) or "").strip()
13833
+ 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):
13834
+ value[key] = replacement
13835
+ sup_id = _coerce_nonnegative_int(value.get("supId"))
13836
+ if sup_id is not None and sup_id in by_que_id and "supQueTitle" in value:
13837
+ value["supQueTitle"] = by_que_id[sup_id]
13838
+
13839
+ for child_value in value.values():
13840
+ if isinstance(child_value, (dict, list)):
13841
+ _sync_question_title_references(child_value, by_que_id=by_que_id, by_title=by_title)
13842
+
13843
+
13844
+ def _materialize_preserved_subtable_question(field: dict[str, Any], *, template: dict[str, Any]) -> dict[str, Any] | None:
13845
+ materialized_subquestions: list[dict[str, Any]] = []
13846
+ for subfield in cast(list[dict[str, Any]], field.get("subfields") or []):
13847
+ if not isinstance(subfield, dict):
13848
+ continue
13849
+ materialized = _materialize_preserved_question(subfield)
13850
+ if materialized is None:
13851
+ return None
13852
+ materialized_subquestions.append(materialized)
13853
+ template["subQuestions"] = materialized_subquestions
13854
+ template["innerQuestions"] = [deepcopy(materialized_subquestions)]
13855
+ return template
13856
+
13857
+
13858
+ def _materialize_preserved_question(field: dict[str, Any]) -> dict[str, Any] | None:
13859
+ template = deepcopy(field.get("_question_template"))
13860
+ if not isinstance(template, dict):
13861
+ return None
13862
+ overlay_keys = _field_question_overlay_keys(field)
13863
+ if "name" in overlay_keys:
13864
+ template["queTitle"] = str(field.get("name") or "")
13865
+ if "required" in overlay_keys:
13866
+ template["required"] = bool(field.get("required", False))
13867
+ if "description" in overlay_keys:
13868
+ description = field.get("description")
13869
+ template["queHint"] = "" if description is None else str(description)
13870
+ if str(field.get("type") or "") == FieldType.subtable.value:
13871
+ return _materialize_preserved_subtable_question(field, template=template)
13872
+ if str(field.get("type") or "") == FieldType.relation.value:
13873
+ return _normalize_relation_question_for_save(template, field=field)
13874
+ return template
13875
+
13876
+
13877
+ def _materialize_edit_question(field: dict[str, Any], *, temp_id: int) -> tuple[dict[str, Any], bool]:
13878
+ if not _field_needs_question_rebuild(field):
13879
+ preserved = _materialize_preserved_question(field)
13880
+ if preserved is not None:
13881
+ return preserved, True
13882
+ return _field_to_question(field, temp_id=temp_id), False
13883
+
13884
+
12693
13885
  def _field_to_question(field: dict[str, Any], *, temp_id: int) -> dict[str, Any]:
12694
- question, _next_temp_id = build_question(
13886
+ built_question, _next_temp_id = build_question(
12695
13887
  {
12696
13888
  "label": field["name"],
12697
13889
  "type": field["type"],
@@ -12716,11 +13908,32 @@ def _field_to_question(field: dict[str, Any], *, temp_id: int) -> dict[str, Any]
12716
13908
  },
12717
13909
  temp_id,
12718
13910
  )
13911
+ relation_config_explicit = bool(field.get("_relation_config_explicit"))
13912
+ relation_question_template = (
13913
+ deepcopy(field.get("_question_template"))
13914
+ if field.get("type") == FieldType.relation.value and isinstance(field.get("_question_template"), dict)
13915
+ else None
13916
+ )
13917
+ question = (
13918
+ relation_question_template
13919
+ if relation_question_template is not None and not relation_config_explicit
13920
+ else built_question
13921
+ )
13922
+ if relation_config_explicit and relation_question_template is not None:
13923
+ for key in ("queOriginType", "relationDisplayMode", "customRenderConfig"):
13924
+ if key in relation_question_template:
13925
+ question[key] = deepcopy(relation_question_template[key])
12719
13926
  if _coerce_nonnegative_int(field.get("que_id")) is not None:
12720
13927
  question["queId"] = field["que_id"]
13928
+ question.pop("queTempId", None)
12721
13929
  else:
13930
+ question["queId"] = 0
12722
13931
  question["queTempId"] = temp_id
12723
13932
  field["que_temp_id"] = temp_id
13933
+ question["queType"] = built_question.get("queType", question.get("queType"))
13934
+ question["queTitle"] = built_question.get("queTitle", field["name"])
13935
+ question["required"] = built_question.get("required", bool(field.get("required", False)))
13936
+ question["queHint"] = built_question.get("queHint", field.get("description") or "")
12724
13937
  if field.get("default_type") is not None:
12725
13938
  question["queDefaultType"] = _coerce_positive_int(field.get("default_type")) or 1
12726
13939
  if "default_value" in field:
@@ -12728,21 +13941,60 @@ def _field_to_question(field: dict[str, Any], *, temp_id: int) -> dict[str, Any]
12728
13941
  if field.get("type") == FieldType.relation.value:
12729
13942
  preserved_reference = (
12730
13943
  deepcopy(field.get("_reference_config_template"))
12731
- if not bool(field.get("_relation_config_explicit")) and isinstance(field.get("_reference_config_template"), dict)
13944
+ if not relation_config_explicit and isinstance(field.get("_reference_config_template"), dict)
12732
13945
  else None
12733
13946
  )
12734
13947
  if preserved_reference is not None:
12735
13948
  preserved_reference["referAppKey"] = field.get("target_app_key")
12736
- preserved_reference["_targetEntityId"] = field.get("target_app_key")
12737
13949
  question["referenceConfig"] = preserved_reference
12738
13950
  else:
12739
- reference = question.get("referenceConfig") if isinstance(question.get("referenceConfig"), dict) else {}
13951
+ existing_reference = (
13952
+ deepcopy(relation_question_template.get("referenceConfig"))
13953
+ if relation_question_template is not None and isinstance(relation_question_template.get("referenceConfig"), dict)
13954
+ else deepcopy(question.get("referenceConfig"))
13955
+ if isinstance(question.get("referenceConfig"), dict)
13956
+ else {}
13957
+ )
13958
+ reference = (
13959
+ existing_reference
13960
+ if relation_config_explicit
13961
+ else deepcopy(question.get("referenceConfig"))
13962
+ if isinstance(question.get("referenceConfig"), dict)
13963
+ else {}
13964
+ )
13965
+ built_reference = (
13966
+ deepcopy(built_question.get("referenceConfig"))
13967
+ if isinstance(built_question.get("referenceConfig"), dict)
13968
+ else {}
13969
+ )
13970
+ original_target_app_key = str(existing_reference.get("referAppKey") or "").strip()
13971
+ next_target_app_key = str(field.get("target_app_key") or "").strip()
13972
+ preserve_existing_reference_questions = (
13973
+ relation_config_explicit
13974
+ and bool(original_target_app_key)
13975
+ and original_target_app_key == next_target_app_key
13976
+ )
13977
+ if relation_config_explicit:
13978
+ for stale_key in ("customButtonText", "customAdvancedSetting", "configShowForm", "dataShowForm"):
13979
+ reference.pop(stale_key, None)
13980
+ for key in (
13981
+ "referQueId",
13982
+ "referQuestions",
13983
+ "referAuthQues",
13984
+ "optionalDataNum",
13985
+ "fieldNameShow",
13986
+ "_targetFieldId",
13987
+ ):
13988
+ if preserve_existing_reference_questions and key in {"referQuestions", "referAuthQues"}:
13989
+ continue
13990
+ if key in built_reference:
13991
+ reference[key] = deepcopy(built_reference[key])
12740
13992
  reference["referAppKey"] = field.get("target_app_key")
12741
13993
  reference["_targetEntityId"] = field.get("target_app_key")
12742
13994
  if field.get("target_field_que_id") is not None:
12743
13995
  reference["referQueId"] = field.get("target_field_que_id")
12744
- reference["optionalDataNum"] = _relation_mode_to_optional_data_num(field.get("relation_mode"))
12745
13996
  question["referenceConfig"] = reference
13997
+ question = _normalize_relation_question_for_save(question, field=field)
12746
13998
  if field.get("type") == FieldType.department.value:
12747
13999
  scope_type, scope_payload = _serialize_department_scope_for_question(field.get("department_scope"))
12748
14000
  question["deptSelectScopeType"] = scope_type
@@ -12882,8 +14134,127 @@ def _build_form_payload_from_fields(
12882
14134
  for row in form_rows:
12883
14135
  _apply_row_widths(row)
12884
14136
  payload = default_form_payload(title, form_rows)
14137
+ _normalize_formula_defaults_for_save(payload.get("formQues"))
14138
+ payload["editVersionNo"] = int(current_schema.get("editVersionNo") or 1)
14139
+ payload["questionRelations"] = _normalize_question_relations_for_save(
14140
+ question_relations if question_relations is not None else (current_schema.get("questionRelations") or [])
14141
+ )
14142
+ return payload
14143
+
14144
+
14145
+ def _build_form_payload_for_edit_fields(
14146
+ *,
14147
+ title: str,
14148
+ current_schema: dict[str, Any],
14149
+ fields: list[dict[str, Any]],
14150
+ layout: dict[str, Any],
14151
+ question_relations: list[dict[str, Any]] | None = None,
14152
+ ) -> dict[str, Any]:
14153
+ _, section_templates = _extract_question_templates(current_schema)
14154
+ template_row_lengths_by_que_id, template_row_lengths_by_title = _extract_template_row_lengths(current_schema)
14155
+ fields_by_name = {
14156
+ str(field.get("name") or ""): field
14157
+ for field in fields
14158
+ if isinstance(field, dict) and str(field.get("name") or "").strip()
14159
+ }
14160
+ form_rows: list[list[dict[str, Any]]] = []
14161
+ temp_id = -10000
14162
+
14163
+ for row in layout.get("root_rows", []) or []:
14164
+ questions: list[dict[str, Any]] = []
14165
+ expected_template_lengths: list[int] = []
14166
+ row_preserved = True
14167
+ for name in row:
14168
+ field = fields_by_name.get(str(name))
14169
+ if field is None:
14170
+ continue
14171
+ template_row_length = _field_template_row_length(
14172
+ field,
14173
+ lengths_by_que_id=template_row_lengths_by_que_id,
14174
+ lengths_by_title=template_row_lengths_by_title,
14175
+ )
14176
+ if template_row_length is not None:
14177
+ expected_template_lengths.append(template_row_length)
14178
+ question, preserved = _materialize_edit_question(field, temp_id=temp_id)
14179
+ questions.append(question)
14180
+ row_preserved = row_preserved and preserved
14181
+ temp_id -= 100
14182
+ if not questions:
14183
+ continue
14184
+ if not row_preserved or _row_needs_width_reflow(expected_template_lengths, len(questions)):
14185
+ _apply_row_widths(questions)
14186
+ form_rows.append(questions)
14187
+
14188
+ for section in layout.get("sections", []) or []:
14189
+ inner_rows: list[list[dict[str, Any]]] = []
14190
+ for row in section.get("rows", []) or []:
14191
+ questions: list[dict[str, Any]] = []
14192
+ expected_template_lengths: list[int] = []
14193
+ row_preserved = True
14194
+ for name in row:
14195
+ field = fields_by_name.get(str(name))
14196
+ if field is None:
14197
+ continue
14198
+ template_row_length = _field_template_row_length(
14199
+ field,
14200
+ lengths_by_que_id=template_row_lengths_by_que_id,
14201
+ lengths_by_title=template_row_lengths_by_title,
14202
+ )
14203
+ if template_row_length is not None:
14204
+ expected_template_lengths.append(template_row_length)
14205
+ question, preserved = _materialize_edit_question(field, temp_id=temp_id)
14206
+ questions.append(question)
14207
+ row_preserved = row_preserved and preserved
14208
+ temp_id -= 100
14209
+ if not questions:
14210
+ continue
14211
+ if not row_preserved or _row_needs_width_reflow(expected_template_lengths, len(questions)):
14212
+ _apply_row_widths(questions)
14213
+ inner_rows.append(questions)
14214
+ if not inner_rows:
14215
+ continue
14216
+ template = _select_section_template(section_templates, section)
14217
+ wrapper = deepcopy(template) if isinstance(template, dict) else {
14218
+ "queId": 0,
14219
+ "queTempId": -(20000 + sum(ord(ch) for ch in str(section.get("section_id") or section.get("title") or "section"))),
14220
+ "queType": 24,
14221
+ "queWidth": 100,
14222
+ "scanType": 1,
14223
+ "status": 1,
14224
+ "required": False,
14225
+ "queHint": "",
14226
+ "linkedQuestions": {},
14227
+ "logicalShow": True,
14228
+ "queDefaultValue": None,
14229
+ "queDefaultType": 1,
14230
+ "subQueWidth": 2,
14231
+ "beingHide": False,
14232
+ "beingDesensitized": False,
14233
+ }
14234
+ if section.get("title") is not None:
14235
+ wrapper["queTitle"] = section.get("title") or wrapper.get("queTitle") or "未命名分组"
14236
+ parsed_section_id = _coerce_positive_int(section.get("section_id"))
14237
+ if parsed_section_id is not None:
14238
+ wrapper["sectionId"] = parsed_section_id
14239
+ elif template is None and section.get("section_id") is not None:
14240
+ wrapper["sectionId"] = section.get("section_id")
14241
+ wrapper["innerQuestions"] = inner_rows
14242
+ form_rows.append([wrapper])
14243
+
14244
+ rename_by_que_id, rename_by_title = _field_rename_maps(fields)
14245
+ if rename_by_que_id or rename_by_title:
14246
+ _sync_question_title_references(form_rows, by_que_id=rename_by_que_id, by_title=rename_by_title)
14247
+ normalized_relations = _normalize_question_relations_for_save(
14248
+ question_relations if question_relations is not None else (current_schema.get("questionRelations") or [])
14249
+ )
14250
+ if rename_by_que_id or rename_by_title:
14251
+ _sync_question_title_references(normalized_relations, by_que_id=rename_by_que_id, by_title=rename_by_title)
14252
+
14253
+ payload = _build_form_save_base_payload(current_schema, title)
14254
+ payload["formQues"] = form_rows
14255
+ _normalize_formula_defaults_for_save(payload.get("formQues"))
14256
+ payload["questionRelations"] = normalized_relations
12885
14257
  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
14258
  return payload
12888
14259
 
12889
14260
 
@@ -13048,6 +14419,8 @@ def _summarize_views(result: Any) -> list[dict[str, Any]]:
13048
14419
  view_type = _normalize_view_type_name(view.get("viewgraphType") or view.get("type"))
13049
14420
  columns = view.get("columnNames") or view.get("columns") or []
13050
14421
  group_by = view.get("groupBy") or view.get("group_by")
14422
+ if not any((name, view_type, columns, group_by)) and str(view_key or "").isdigit():
14423
+ continue
13051
14424
  if not any((name, view_key, view_type, columns, group_by)):
13052
14425
  continue
13053
14426
  items.append(
@@ -13090,11 +14463,24 @@ def _summarize_views_with_config(views_tool: ViewTools, *, profile: str, views:
13090
14463
  enriched_items.append(item)
13091
14464
  continue
13092
14465
  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))
14466
+ question_list: list[dict[str, Any]] = []
14467
+ try:
14468
+ question_response = views_tool.view_list_questions(profile=profile, viewgraph_key=view_key)
14469
+ raw_question_list = question_response.get("result")
14470
+ if isinstance(raw_question_list, list):
14471
+ question_list = [deepcopy(entry) for entry in raw_question_list if isinstance(entry, dict)]
14472
+ except (QingflowApiError, RuntimeError):
14473
+ question_list = []
14474
+ enriched_items.append(_merge_view_summary_with_config(item, config=config, question_list=question_list))
13094
14475
  return enriched_items, config_read_errors
13095
14476
 
13096
14477
 
13097
- def _merge_view_summary_with_config(base: dict[str, Any], *, config: dict[str, Any]) -> dict[str, Any]:
14478
+ def _merge_view_summary_with_config(
14479
+ base: dict[str, Any],
14480
+ *,
14481
+ config: dict[str, Any],
14482
+ question_list: list[dict[str, Any]] | None = None,
14483
+ ) -> dict[str, Any]:
13098
14484
  summary = deepcopy(base)
13099
14485
  if not isinstance(config, dict) or not config:
13100
14486
  return summary
@@ -13102,6 +14488,7 @@ def _merge_view_summary_with_config(base: dict[str, Any], *, config: dict[str, A
13102
14488
  summary["visibility_summary"] = _visibility_summary(_public_visibility_from_member_auth(config.get("auth")))
13103
14489
  legacy_columns = [str(value) for value in (summary.get("columns") or []) if str(value or "").strip()]
13104
14490
  question_entries = _extract_view_question_entries(config.get("viewgraphQuestions"))
14491
+ canonical_question_entries = _extract_view_question_entries(question_list)
13105
14492
  question_entries_by_id = {
13106
14493
  field_id: entry
13107
14494
  for entry in question_entries
@@ -13121,19 +14508,20 @@ def _merge_view_summary_with_config(base: dict[str, Any], *, config: dict[str, A
13121
14508
  display_entries = _sort_view_question_entries(
13122
14509
  [entry for entry in question_entries if bool(entry.get("visible", True))],
13123
14510
  )
14511
+ public_display_entries = _filter_public_view_display_entries(display_entries, configured_column_ids=configured_column_ids)
13124
14512
  display_column_ids = [
13125
14513
  field_id
13126
- for field_id in (_coerce_nonnegative_int(entry.get("field_id")) for entry in display_entries)
14514
+ for field_id in (_coerce_nonnegative_int(entry.get("field_id")) for entry in public_display_entries)
13127
14515
  if field_id is not None
13128
14516
  ]
13129
14517
  display_columns = [
13130
14518
  str(entry.get("name") or "").strip()
13131
- for entry in display_entries
14519
+ for entry in public_display_entries
13132
14520
  if str(entry.get("name") or "").strip()
13133
14521
  ]
13134
14522
  apply_entries = [
13135
14523
  entry
13136
- for entry in display_entries
14524
+ for entry in public_display_entries
13137
14525
  if _coerce_nonnegative_int(entry.get("field_id")) is not None
13138
14526
  and str(entry.get("name") or "").strip()
13139
14527
  and str(entry.get("name") or "").strip() not in _KNOWN_SYSTEM_VIEW_COLUMNS
@@ -13180,8 +14568,42 @@ def _merge_view_summary_with_config(base: dict[str, Any], *, config: dict[str, A
13180
14568
  summary["apply_columns"] = apply_columns
13181
14569
  summary["apply_column_ids"] = apply_column_ids
13182
14570
  config_enriched = True
13183
- if question_entries:
13184
- summary["column_details"] = display_entries or _sort_view_question_entries(question_entries)
14571
+ if canonical_question_entries:
14572
+ canonical_display_entries = _sort_view_question_entries(canonical_question_entries)
14573
+ canonical_display_column_ids = [
14574
+ field_id
14575
+ for field_id in (_coerce_nonnegative_int(entry.get("field_id")) for entry in canonical_display_entries)
14576
+ if field_id is not None
14577
+ ]
14578
+ canonical_display_columns = [
14579
+ str(entry.get("name") or "").strip()
14580
+ for entry in canonical_display_entries
14581
+ if str(entry.get("name") or "").strip()
14582
+ ]
14583
+ canonical_apply_entries = [
14584
+ entry
14585
+ for entry in canonical_display_entries
14586
+ if _coerce_nonnegative_int(entry.get("field_id")) is not None
14587
+ and str(entry.get("name") or "").strip()
14588
+ and str(entry.get("name") or "").strip() not in _KNOWN_SYSTEM_VIEW_COLUMNS
14589
+ ]
14590
+ summary["columns"] = canonical_display_columns
14591
+ summary["display_columns"] = canonical_display_columns
14592
+ summary["display_column_ids"] = canonical_display_column_ids
14593
+ summary["column_details"] = canonical_display_entries
14594
+ summary["apply_columns"] = [
14595
+ str(entry.get("name") or "").strip()
14596
+ for entry in canonical_apply_entries
14597
+ if str(entry.get("name") or "").strip()
14598
+ ]
14599
+ summary["apply_column_ids"] = [
14600
+ field_id
14601
+ for field_id in (_coerce_nonnegative_int(entry.get("field_id")) for entry in canonical_apply_entries)
14602
+ if field_id is not None
14603
+ ]
14604
+ config_enriched = True
14605
+ elif question_entries:
14606
+ summary["column_details"] = public_display_entries or _sort_view_question_entries(question_entries)
13185
14607
  config_enriched = True
13186
14608
  display_config = _extract_view_display_config(
13187
14609
  config,
@@ -13206,7 +14628,7 @@ def _merge_view_summary_with_config(base: dict[str, Any], *, config: dict[str, A
13206
14628
  summary["button_read_source"] = button_source
13207
14629
  config_enriched = True
13208
14630
  if config_enriched:
13209
- summary["read_source"] = "view_config"
14631
+ summary["read_source"] = "view_config+question" if canonical_question_entries else "view_config"
13210
14632
  return summary
13211
14633
 
13212
14634
 
@@ -13214,29 +14636,64 @@ def _extract_view_question_entries(questions: Any) -> list[dict[str, Any]]:
13214
14636
  if not isinstance(questions, list):
13215
14637
  return []
13216
14638
  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)
14639
+ fallback_order = 0
14640
+
14641
+ def walk(nodes: Any) -> None:
14642
+ nonlocal fallback_order
14643
+ if not isinstance(nodes, list):
14644
+ return
14645
+ for item in nodes:
14646
+ if not isinstance(item, dict):
14647
+ continue
14648
+ children: list[Any] = []
14649
+ for child_key in ("innerQues", "subQues", "innerQuestions", "subQuestions"):
14650
+ child_value = item.get(child_key)
14651
+ if isinstance(child_value, list) and child_value:
14652
+ children.extend(child_value)
14653
+ if children:
14654
+ walk(children)
14655
+ continue
14656
+ field_id = _coerce_nonnegative_int(item.get("queId"))
14657
+ name = str(item.get("queTitle") or "").strip() or None
14658
+ if field_id is None and name is None:
14659
+ continue
14660
+ visible_raw = item.get("beingListDisplay")
14661
+ if visible_raw is None:
14662
+ visible_raw = item.get("beingVisible")
14663
+ visible = bool(visible_raw) if visible_raw is not None else True
14664
+ display_order = _coerce_positive_int(item.get("displayOrdinal"))
14665
+ fallback_order += 1
14666
+ entry: dict[str, Any] = {
14667
+ "field_id": field_id,
14668
+ "name": name,
14669
+ "visible": visible,
14670
+ "display_order": display_order if display_order is not None else fallback_order,
14671
+ }
14672
+ width = _coerce_positive_int(item.get("width"))
14673
+ if width is not None:
14674
+ entry["width"] = width
14675
+ entries.append(entry)
14676
+
14677
+ walk(questions)
13237
14678
  return entries
13238
14679
 
13239
14680
 
14681
+ def _filter_public_view_display_entries(
14682
+ entries: list[dict[str, Any]],
14683
+ *,
14684
+ configured_column_ids: list[int],
14685
+ ) -> list[dict[str, Any]]:
14686
+ configured_set = set(configured_column_ids)
14687
+ filtered: list[dict[str, Any]] = []
14688
+ for entry in entries:
14689
+ name = str(entry.get("name") or "").strip()
14690
+ field_id = _coerce_nonnegative_int(entry.get("field_id"))
14691
+ if name in _KNOWN_SYSTEM_VIEW_COLUMNS and field_id not in configured_set:
14692
+ continue
14693
+ filtered.append(entry)
14694
+ return filtered or entries
14695
+
14696
+
13240
14697
  def _sort_view_question_entries(entries: list[dict[str, Any]]) -> list[dict[str, Any]]:
13241
14698
  return sorted(
13242
14699
  entries,
@@ -14296,9 +15753,10 @@ def _build_form_payload_from_existing_schema(
14296
15753
  wrapper["queWidth"] = 100
14297
15754
  form_rows.append([wrapper])
14298
15755
 
14299
- payload = deepcopy(current_schema)
15756
+ payload = _build_form_save_base_payload(current_schema, str(current_schema.get("formTitle") or "未命名应用"))
14300
15757
  payload["formQues"] = form_rows
14301
- payload["questionRelations"] = deepcopy(current_schema.get("questionRelations") or [])
15758
+ _normalize_formula_defaults_for_save(payload.get("formQues"))
15759
+ payload["questionRelations"] = _normalize_question_relations_for_save(current_schema.get("questionRelations") or [])
14302
15760
  payload["editVersionNo"] = int(current_schema.get("editVersionNo") or 1)
14303
15761
  payload.setdefault("formTitle", current_schema.get("formTitle") or "未命名应用")
14304
15762
  return payload