@qingflow-tech/qingflow-app-user-mcp 1.0.10 → 1.0.11

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.
@@ -20,7 +20,7 @@ from ..list_type_labels import RECORD_LIST_TYPE_LABELS, SYSTEM_VIEW_ID_TO_LIST_T
20
20
  from ..solution.build_assembly_store import BuildAssemblyStore, default_artifacts, default_manifest
21
21
  from ..solution.compiler.chart_compiler import qingbi_workspace_visible_auth
22
22
  from ..solution.compiler.form_compiler import build_question, default_form_payload, default_member_auth
23
- from ..solution.compiler.icon_utils import encode_workspace_icon_with_defaults
23
+ from ..solution.compiler.icon_utils import encode_workspace_icon_with_defaults, workspace_icon_config
24
24
  from ..solution.compiler.view_compiler import VIEW_TYPE_MAP
25
25
  from ..solution.executor import _build_viewgraph_questions, _compact_dict, extract_field_map
26
26
  from ..solution.spec_models import FieldType, FormLayoutRowSpec, FormLayoutSectionSpec, ViewSpec
@@ -878,14 +878,21 @@ class AiBuilderFacade:
878
878
  "verification": {},
879
879
  }
880
880
 
881
- def package_list(self, *, profile: str, trial_status: str = "all") -> JSONObject:
881
+ def package_list(self, *, profile: str, trial_status: str = "all", query: str = "") -> JSONObject:
882
882
  listed = self.packages.package_list(profile=profile, trial_status=trial_status, include_raw=False)
883
+ raw_items = listed.get("items") if isinstance(listed.get("items"), list) else []
884
+ items = [_publicize_package_list_item(item) for item in raw_items if isinstance(item, dict)]
885
+ normalized_query = str(query or "").strip()
886
+ if normalized_query:
887
+ filtered_items = [item for item in items if _package_list_item_matches_query(item, normalized_query)]
888
+ else:
889
+ filtered_items = items
883
890
  return {
884
891
  "status": "success",
885
892
  "error_code": None,
886
893
  "recoverable": False,
887
894
  "message": "listed packages",
888
- "normalized_args": {"trial_status": trial_status},
895
+ "normalized_args": {"trial_status": trial_status, "query": normalized_query},
889
896
  "missing_fields": [],
890
897
  "allowed_values": {},
891
898
  "details": {},
@@ -894,8 +901,12 @@ class AiBuilderFacade:
894
901
  "noop": False,
895
902
  "verification": {},
896
903
  "trial_status": trial_status,
897
- "items": listed.get("items") if isinstance(listed.get("items"), list) else [],
898
- "count": listed.get("count") or 0,
904
+ "query": normalized_query,
905
+ "items": filtered_items,
906
+ "count": len(filtered_items),
907
+ "matched_count": len(filtered_items),
908
+ "unfiltered_count": len(items),
909
+ "filter_mode": "local_packages",
899
910
  "source_shape": listed.get("source_shape"),
900
911
  "retried": bool(listed.get("retried", False)),
901
912
  }
@@ -2868,13 +2879,29 @@ class AiBuilderFacade:
2868
2879
  try:
2869
2880
  write_executed = True
2870
2881
  self.buttons.custom_button_delete(profile=profile, app_key=app_key, button_id=button_id)
2882
+ delete_readback = self._verify_custom_button_deleted_by_id(profile=profile, app_key=app_key, button_id=button_id)
2871
2883
  removed.append(
2872
2884
  {
2873
2885
  "index": op["index"],
2874
2886
  "operation": "remove",
2875
- "status": "success",
2887
+ "status": delete_readback.get("status") or "readback_pending",
2876
2888
  "button_id": button_id,
2877
2889
  "button_text": selector.button_text or (existing_by_id.get(button_id) or {}).get("button_text"),
2890
+ "delete_executed": True,
2891
+ "readback_status": delete_readback.get("readback_status"),
2892
+ "safe_to_retry_delete": False,
2893
+ **(
2894
+ {
2895
+ "error_code": delete_readback.get("error_code"),
2896
+ "message": delete_readback.get("message"),
2897
+ "request_id": delete_readback.get("request_id"),
2898
+ "backend_code": delete_readback.get("backend_code"),
2899
+ "http_status": delete_readback.get("http_status"),
2900
+ "transport_error": delete_readback.get("transport_error"),
2901
+ }
2902
+ if delete_readback.get("readback_status") != "deleted"
2903
+ else {}
2904
+ ),
2878
2905
  }
2879
2906
  )
2880
2907
  except (QingflowApiError, RuntimeError) as error:
@@ -2892,12 +2919,14 @@ class AiBuilderFacade:
2892
2919
  }
2893
2920
  )
2894
2921
 
2922
+ needs_button_list_readback = bool(created or updated or request.view_configs)
2895
2923
  readback_buttons: list[dict[str, Any]] = []
2896
2924
  readback_failed = False
2897
- try:
2898
- readback_buttons = self._load_custom_buttons_for_builder(profile=profile, app_key=app_key)
2899
- except (QingflowApiError, RuntimeError):
2900
- readback_failed = True
2925
+ if needs_button_list_readback:
2926
+ try:
2927
+ readback_buttons = self._load_custom_buttons_for_builder(profile=profile, app_key=app_key)
2928
+ except (QingflowApiError, RuntimeError):
2929
+ readback_failed = True
2901
2930
  readback_ids = {
2902
2931
  button_id
2903
2932
  for item in readback_buttons
@@ -2918,10 +2947,12 @@ class AiBuilderFacade:
2918
2947
  for item in removed
2919
2948
  if _coerce_positive_int(item.get("button_id")) is not None
2920
2949
  ]
2950
+ removed_verified = all(str(item.get("readback_status") or "") == "deleted" for item in removed)
2951
+ remove_readback_pending = any(str(item.get("readback_status") or "") != "deleted" for item in removed)
2921
2952
  verified = (
2922
2953
  not readback_failed
2923
2954
  and all(button_id in readback_ids for button_id in created_ids + updated_ids)
2924
- and all(button_id not in readback_ids for button_id in removed_ids)
2955
+ and removed_verified
2925
2956
  and not failed
2926
2957
  and all(_coerce_positive_int(item.get("button_id")) is not None for item in created)
2927
2958
  )
@@ -2980,6 +3011,13 @@ class AiBuilderFacade:
2980
3011
  else "custom button writes all failed or produced no confirmed result; application was not published",
2981
3012
  )
2982
3013
  )
3014
+ if remove_readback_pending:
3015
+ warnings.append(
3016
+ _warning(
3017
+ "CUSTOM_BUTTON_DELETE_READBACK_PENDING",
3018
+ "custom button delete was sent, but deletion readback is not fully verified; do not blindly repeat delete",
3019
+ )
3020
+ )
2983
3021
  response = {
2984
3022
  "status": status,
2985
3023
  "error_code": error_code,
@@ -3009,7 +3047,9 @@ class AiBuilderFacade:
3009
3047
  "readback_loaded": not readback_failed,
3010
3048
  "created_verified": not readback_failed and all(button_id in readback_ids for button_id in created_ids),
3011
3049
  "updated_verified": not readback_failed and all(button_id in readback_ids for button_id in updated_ids),
3012
- "removed_verified": not readback_failed and all(button_id not in readback_ids for button_id in removed_ids),
3050
+ "removed_verified": removed_verified,
3051
+ "remove_readback_pending": remove_readback_pending,
3052
+ "removed_readback_results": deepcopy(removed),
3013
3053
  "view_button_bindings_verified": view_config_verified,
3014
3054
  },
3015
3055
  "verified": verified,
@@ -3374,30 +3414,35 @@ class AiBuilderFacade:
3374
3414
  return finalize(edit_context_error)
3375
3415
 
3376
3416
  self.buttons.custom_button_delete(profile=profile, app_key=app_key, button_id=button_id)
3417
+ delete_readback = self._verify_custom_button_deleted_by_id(profile=profile, app_key=app_key, button_id=button_id)
3418
+ verified = delete_readback.get("readback_status") == "deleted"
3377
3419
  return finalize(
3378
3420
  self._append_publish_result(
3379
3421
  profile=profile,
3380
3422
  app_key=app_key,
3381
3423
  publish=True,
3382
3424
  response={
3383
- "status": "success",
3384
- "error_code": None,
3385
- "recoverable": False,
3386
- "message": "deleted custom button",
3425
+ "status": "success" if verified else "partial_success",
3426
+ "error_code": None if verified else delete_readback.get("error_code") or "CUSTOM_BUTTON_DELETE_READBACK_PENDING",
3427
+ "recoverable": not verified,
3428
+ "message": "deleted custom button" if verified else "custom button delete completed; readback pending",
3387
3429
  "normalized_args": normalized_args,
3388
3430
  "missing_fields": [],
3389
3431
  "allowed_values": {},
3390
3432
  "details": {},
3391
- "request_id": None,
3392
- "suggested_next_call": None,
3433
+ "request_id": delete_readback.get("request_id"),
3434
+ "suggested_next_call": None if verified else {"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
3393
3435
  "noop": False,
3394
- "warnings": [],
3395
- "verification": {"custom_button_deleted": True},
3396
- "verified": True,
3436
+ "warnings": [] if verified else [_warning("CUSTOM_BUTTON_DELETE_READBACK_PENDING", "custom button delete was sent, but deletion readback is not fully verified")],
3437
+ "verification": {"custom_button_deleted": verified, "delete_readback": delete_readback},
3438
+ "verified": verified,
3397
3439
  "app_key": app_key,
3398
3440
  "button_id": button_id,
3399
3441
  "edit_version_no": edit_version_no,
3400
- "deleted": True,
3442
+ "deleted": verified,
3443
+ "delete_executed": True,
3444
+ "readback_status": delete_readback.get("readback_status"),
3445
+ "safe_to_retry_delete": False,
3401
3446
  },
3402
3447
  )
3403
3448
  )
@@ -3989,7 +4034,16 @@ class AiBuilderFacade:
3989
4034
  try:
3990
4035
  write_executed = True
3991
4036
  self._associated_resource_delete(profile=profile, app_key=app_key, associated_item_id=item_id)
3992
- removed.append({"associated_item_id": item_id, "status": "success"})
4037
+ removed.append(
4038
+ {
4039
+ "associated_item_id": item_id,
4040
+ "operation": "remove",
4041
+ "status": "readback_pending",
4042
+ "delete_executed": True,
4043
+ "readback_status": "unavailable",
4044
+ "safe_to_retry_delete": False,
4045
+ }
4046
+ )
3993
4047
  except (QingflowApiError, RuntimeError) as error:
3994
4048
  api_error = _coerce_api_error(error)
3995
4049
  failed.append({"operation": "remove", "associated_item_id": item_id, "status": "failed", "error_code": "ASSOCIATED_RESOURCE_WRITE_FAILED", "message": api_error.message, "transport_error": _transport_error_payload(api_error)})
@@ -4003,10 +4057,16 @@ class AiBuilderFacade:
4003
4057
  api_error = _coerce_api_error(error)
4004
4058
  failed.append({"operation": "reorder", "associated_item_ids": reorder_ids, "status": "failed", "error_code": "ASSOCIATED_RESOURCE_REORDER_FAILED", "message": api_error.message, "transport_error": _transport_error_payload(api_error)})
4005
4059
 
4006
- try:
4007
- resources_after = self._load_associated_resources_for_builder(profile=profile, app_key=app_key)
4008
- except (QingflowApiError, RuntimeError):
4009
- resources_after = []
4060
+ resources_after: list[dict[str, Any]] = []
4061
+ resources_after_loaded = False
4062
+ resources_after_readback_failed = False
4063
+ if request.view_configs:
4064
+ try:
4065
+ resources_after = self._load_associated_resources_for_builder(profile=profile, app_key=app_key)
4066
+ resources_after_loaded = True
4067
+ except (QingflowApiError, RuntimeError):
4068
+ resources_after = []
4069
+ resources_after_readback_failed = True
4010
4070
 
4011
4071
  for index, view_config in enumerate(request.view_configs):
4012
4072
  resolved_view_config_ids = resolved_associated_item_refs.get(f"view_configs[{index}].associated_item_ids", [])
@@ -4074,21 +4134,30 @@ class AiBuilderFacade:
4074
4134
  api_error = _coerce_api_error(error)
4075
4135
  failed.append({"operation": "view_config", "index": index, "view_key": view_config.view_key, "view_name": view_name, "status": "failed", "error_code": "VIEW_ASSOCIATED_RESOURCES_WRITE_FAILED", "message": api_error.message, "transport_error": _transport_error_payload(api_error)})
4076
4136
 
4077
- final_resources: list[dict[str, Any]] = []
4078
- readback_failed = False
4079
- try:
4080
- final_resources = self._load_associated_resources_for_builder(profile=profile, app_key=app_key)
4081
- except (QingflowApiError, RuntimeError):
4082
- readback_failed = True
4137
+ final_resources: list[dict[str, Any]] = resources_after if resources_after_loaded else []
4138
+ readback_failed = resources_after_readback_failed
4139
+ if not resources_after_loaded and not resources_after_readback_failed:
4140
+ try:
4141
+ final_resources = self._load_associated_resources_for_builder(profile=profile, app_key=app_key)
4142
+ except (QingflowApiError, RuntimeError):
4143
+ readback_failed = True
4083
4144
  final_by_id = _associated_resource_index(final_resources)
4145
+ if removed:
4146
+ removed = self._verify_associated_resources_deleted_by_pool(
4147
+ deleted_items=removed,
4148
+ resources=final_resources,
4149
+ readback_failed=readback_failed,
4150
+ )
4084
4151
  created_ids = [int(item["associated_item_id"]) for item in created if _coerce_positive_int(item.get("associated_item_id")) is not None]
4085
4152
  updated_ids = [int(item["associated_item_id"]) for item in updated if _coerce_positive_int(item.get("associated_item_id")) is not None]
4086
4153
  unchanged_ids = [int(item["associated_item_id"]) for item in unchanged if _coerce_positive_int(item.get("associated_item_id")) is not None]
4087
4154
  removed_ids = [int(item["associated_item_id"]) for item in removed if _coerce_positive_int(item.get("associated_item_id")) is not None]
4155
+ removed_verified = all(str(item.get("readback_status") or "") == "deleted" for item in removed)
4156
+ remove_readback_pending = any(str(item.get("readback_status") or "") != "deleted" for item in removed)
4088
4157
  pool_verified = (
4089
4158
  not readback_failed
4090
4159
  and all(item_id in final_by_id for item_id in created_ids + updated_ids + unchanged_ids)
4091
- and all(item_id not in final_by_id for item_id in removed_ids)
4160
+ and removed_verified
4092
4161
  and not failed
4093
4162
  and all(_coerce_positive_int(item.get("associated_item_id")) is not None for item in created)
4094
4163
  )
@@ -4123,6 +4192,13 @@ class AiBuilderFacade:
4123
4192
  else "associated resource writes all failed or produced no confirmed result; application was not published",
4124
4193
  )
4125
4194
  )
4195
+ if remove_readback_pending:
4196
+ warnings.append(
4197
+ _warning(
4198
+ "ASSOCIATED_RESOURCE_DELETE_READBACK_PENDING",
4199
+ "associated resource delete was sent, but deletion readback is not fully verified; do not blindly repeat delete",
4200
+ )
4201
+ )
4126
4202
  response = {
4127
4203
  "status": status,
4128
4204
  "error_code": error_code,
@@ -4145,7 +4221,14 @@ class AiBuilderFacade:
4145
4221
  "suggested_next_call": None if verified else {"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
4146
4222
  "noop": False,
4147
4223
  "warnings": warnings,
4148
- "verification": {"associated_resources_verified": pool_verified, "associated_resource_view_configs_verified": view_configs_verified, "readback_loaded": not readback_failed},
4224
+ "verification": {
4225
+ "associated_resources_verified": pool_verified,
4226
+ "associated_resource_view_configs_verified": view_configs_verified,
4227
+ "readback_loaded": not readback_failed,
4228
+ "removed_verified": removed_verified,
4229
+ "remove_readback_pending": remove_readback_pending,
4230
+ "removed_readback_results": deepcopy(removed),
4231
+ },
4149
4232
  "verified": verified,
4150
4233
  "app_key": app_key,
4151
4234
  "app_name": app_name,
@@ -4634,12 +4717,14 @@ class AiBuilderFacade:
4634
4717
  or base_result.get("name")
4635
4718
  or app_key
4636
4719
  ).strip() or app_key
4720
+ app_icon = str(base_result.get("appIcon") or "").strip() or None
4637
4721
  response = AppReadSummaryResponse(
4638
4722
  app_key=app_key,
4639
4723
  app_name=app_name,
4640
4724
  name=app_name,
4641
4725
  title=app_name,
4642
- app_icon=str(base_result.get("appIcon") or "").strip() or None,
4726
+ app_icon=app_icon,
4727
+ icon_config=workspace_icon_config(app_icon),
4643
4728
  visibility=_public_visibility_from_member_auth(base_result.get("auth")),
4644
4729
  tag_ids=_coerce_int_list(base_result.get("tagIds")),
4645
4730
  publish_status=base_result.get("appPublishStatus"),
@@ -5023,10 +5108,32 @@ class AiBuilderFacade:
5023
5108
  suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
5024
5109
  )
5025
5110
  parsed = state["parsed"]
5111
+ warnings: list[dict[str, Any]] = []
5112
+ field_lookup = _build_public_field_lookup(cast(list[dict[str, Any]], parsed["fields"]))
5113
+ chart_fields: list[dict[str, Any]] = []
5114
+ try:
5115
+ qingbi_fields = self.charts.qingbi_report_list_fields(profile=profile, app_key=app_key).get("items") or []
5116
+ chart_fields = _compact_public_chart_fields_read(
5117
+ app_key=app_key,
5118
+ qingbi_fields=[item for item in qingbi_fields if isinstance(item, dict)],
5119
+ field_lookup=field_lookup,
5120
+ )
5121
+ except (QingflowApiError, RuntimeError) as error:
5122
+ api_error = _coerce_api_error(error)
5123
+ warnings.append(
5124
+ _warning(
5125
+ "QINGBI_FIELDS_READ_FAILED",
5126
+ "form fields were read, but QingBI chart fields could not be read; chart configuration should use chart_fields when available",
5127
+ backend_code=api_error.backend_code,
5128
+ request_id=api_error.request_id,
5129
+ )
5130
+ )
5026
5131
  response = AppFieldsReadResponse(
5027
5132
  app_key=app_key,
5028
5133
  fields=[_compact_public_field_read(field=field, layout=parsed["layout"]) for field in parsed["fields"]],
5029
5134
  field_count=len(parsed["fields"]),
5135
+ chart_fields=chart_fields,
5136
+ chart_field_count=len(chart_fields),
5030
5137
  form_settings=_form_settings_from_schema(state["schema"], parsed["fields"]),
5031
5138
  )
5032
5139
  return {
@@ -5041,7 +5148,7 @@ class AiBuilderFacade:
5041
5148
  "request_id": None,
5042
5149
  "suggested_next_call": None,
5043
5150
  "noop": False,
5044
- "warnings": [],
5151
+ "warnings": warnings,
5045
5152
  "verification": {"app_exists": True},
5046
5153
  "verified": True,
5047
5154
  **response.model_dump(mode="json"),
@@ -6034,6 +6141,7 @@ class AiBuilderFacade:
6034
6141
  details={"dash_key": dash_key, "being_draft": being_draft},
6035
6142
  suggested_next_call={"tool_name": "portal_get", "arguments": {"profile": profile, "dash_key": dash_key, "being_draft": being_draft}},
6036
6143
  )
6144
+ dash_icon = str(result.get("dashIcon") or "").strip() or None
6037
6145
  response = PortalGetResponse(
6038
6146
  dash_key=dash_key,
6039
6147
  being_draft=being_draft,
@@ -6047,7 +6155,8 @@ class AiBuilderFacade:
6047
6155
  )
6048
6156
  if tag_id is not None
6049
6157
  ],
6050
- dash_icon=str(result.get("dashIcon") or "").strip() or None,
6158
+ dash_icon=dash_icon,
6159
+ icon_config=workspace_icon_config(dash_icon),
6051
6160
  hide_copyright=bool(result.get("hideCopyright")) if "hideCopyright" in result else None,
6052
6161
  visibility=_public_visibility_from_member_auth(result.get("auth")),
6053
6162
  auth=deepcopy(result.get("auth")) if isinstance(result.get("auth"), dict) else {},
@@ -6086,6 +6195,7 @@ class AiBuilderFacade:
6086
6195
  details={"dash_key": dash_key, "being_draft": being_draft},
6087
6196
  suggested_next_call={"tool_name": "portal_get", "arguments": {"profile": profile, "dash_key": dash_key, "being_draft": being_draft}},
6088
6197
  )
6198
+ dash_icon = str(result.get("dashIcon") or "").strip() or None
6089
6199
  response = PortalReadSummaryResponse(
6090
6200
  dash_key=dash_key,
6091
6201
  being_draft=being_draft,
@@ -6099,7 +6209,8 @@ class AiBuilderFacade:
6099
6209
  )
6100
6210
  if tag_id is not None
6101
6211
  ],
6102
- dash_icon=str(result.get("dashIcon") or "").strip() or None,
6212
+ dash_icon=dash_icon,
6213
+ icon_config=workspace_icon_config(dash_icon),
6103
6214
  hide_copyright=bool(result.get("hideCopyright")) if "hideCopyright" in result else None,
6104
6215
  config_keys=sorted(str(key) for key in (result.get("config") or {}).keys()) if isinstance(result.get("config"), dict) else [],
6105
6216
  dash_global_config_keys=sorted(str(key) for key in (result.get("dashGlobalConfig") or {}).keys()) if isinstance(result.get("dashGlobalConfig"), dict) else [],
@@ -8404,13 +8515,56 @@ class AiBuilderFacade:
8404
8515
  if len(matches) == 1:
8405
8516
  key = _extract_view_key(matches[0])
8406
8517
  removed_name = _extract_view_name(matches[0]) or selector_text
8407
- self.views.view_delete(profile=profile, viewgraph_key=key)
8408
- removed.append(removed_name)
8409
- if key:
8410
- removed_keys.add(key)
8411
- existing_by_key.pop(key, None)
8412
- existing_by_name.pop(removed_name, None)
8413
- view_results.append({"name": removed_name, "view_key": key, "type": None, "status": "removed"})
8518
+ try:
8519
+ self.views.view_delete(profile=profile, viewgraph_key=key)
8520
+ delete_readback = self._verify_view_deleted_by_key(profile=profile, view_key=key)
8521
+ removed.append(removed_name)
8522
+ if key:
8523
+ removed_keys.add(key)
8524
+ existing_by_key.pop(key, None)
8525
+ existing_by_name.pop(removed_name, None)
8526
+ view_results.append(
8527
+ {
8528
+ "name": removed_name,
8529
+ "view_key": key,
8530
+ "type": None,
8531
+ "status": delete_readback.get("status") or "readback_pending",
8532
+ "operation": "delete",
8533
+ "delete_executed": True,
8534
+ "readback_status": delete_readback.get("readback_status"),
8535
+ "safe_to_retry_delete": False,
8536
+ **(
8537
+ {
8538
+ "error_code": delete_readback.get("error_code"),
8539
+ "message": delete_readback.get("message"),
8540
+ "request_id": delete_readback.get("request_id"),
8541
+ "backend_code": delete_readback.get("backend_code"),
8542
+ "http_status": delete_readback.get("http_status"),
8543
+ "transport_error": delete_readback.get("transport_error"),
8544
+ }
8545
+ if delete_readback.get("readback_status") != "deleted"
8546
+ else {}
8547
+ ),
8548
+ }
8549
+ )
8550
+ except (QingflowApiError, RuntimeError) as error:
8551
+ api_error = _coerce_api_error(error)
8552
+ failed_view = {
8553
+ "name": removed_name,
8554
+ "view_key": key,
8555
+ "type": None,
8556
+ "status": "failed",
8557
+ "operation": "delete",
8558
+ "delete_executed": False,
8559
+ "safe_to_retry_delete": True,
8560
+ "error_code": "VIEW_DELETE_FAILED",
8561
+ "message": _public_error_message("VIEW_APPLY_FAILED", api_error),
8562
+ "request_id": api_error.request_id,
8563
+ "backend_code": api_error.backend_code,
8564
+ "http_status": None if api_error.http_status == 404 else api_error.http_status,
8565
+ }
8566
+ failed_views.append(failed_view)
8567
+ view_results.append(deepcopy(failed_view))
8414
8568
  created: list[str] = []
8415
8569
  updated: list[str] = []
8416
8570
  existing_view_list = [
@@ -8950,17 +9104,21 @@ class AiBuilderFacade:
8950
9104
  failed_views.append(failure_entry)
8951
9105
  view_results.append(failure_entry)
8952
9106
  continue
8953
- try:
8954
- verified_view_result, verified_views_unavailable = self._load_views_result(profile=profile, app_key=app_key, tolerate_404=True)
8955
- except (QingflowApiError, RuntimeError) as error:
8956
- api_error = _coerce_api_error(error)
8957
- return finalize(_failed_from_api_error(
8958
- "VIEWS_READ_FAILED",
8959
- api_error,
8960
- normalized_args=normalized_args,
8961
- details=_with_state_read_blocked_details({"app_key": app_key}, resource="views", error=api_error),
8962
- suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
8963
- ))
9107
+ needs_view_list_readback = bool(created or updated)
9108
+ verified_view_result: list[dict[str, Any]] | None = []
9109
+ verified_views_unavailable = False
9110
+ if needs_view_list_readback:
9111
+ try:
9112
+ verified_view_result, verified_views_unavailable = self._load_views_result(profile=profile, app_key=app_key, tolerate_404=True)
9113
+ except (QingflowApiError, RuntimeError) as error:
9114
+ api_error = _coerce_api_error(error)
9115
+ return finalize(_failed_from_api_error(
9116
+ "VIEWS_READ_FAILED",
9117
+ api_error,
9118
+ normalized_args=normalized_args,
9119
+ details=_with_state_read_blocked_details({"app_key": app_key}, resource="views", error=api_error),
9120
+ suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
9121
+ ))
8964
9122
  verified_names = {
8965
9123
  _extract_view_name(item)
8966
9124
  for item in (verified_view_result or [])
@@ -9248,8 +9406,27 @@ class AiBuilderFacade:
9248
9406
  "view_key": item.get("view_key"),
9249
9407
  "type": item.get("type"),
9250
9408
  "status": "removed",
9251
- "present_in_readback": None if verified_views_unavailable else name in verified_names,
9252
- "removed_verified": None if verified_views_unavailable else name not in verified_names,
9409
+ "present_in_readback": False,
9410
+ "removed_verified": True,
9411
+ "delete_executed": bool(item.get("delete_executed")),
9412
+ "readback_status": item.get("readback_status") or "deleted",
9413
+ "safe_to_retry_delete": False,
9414
+ }
9415
+ )
9416
+ elif status == "readback_pending" and item.get("operation") == "delete":
9417
+ readback_status = str(item.get("readback_status") or "unavailable")
9418
+ verification_by_view.append(
9419
+ {
9420
+ "name": name,
9421
+ "view_key": item.get("view_key"),
9422
+ "type": item.get("type"),
9423
+ "status": "readback_pending",
9424
+ "present_in_readback": True if readback_status == "still_exists" else None,
9425
+ "removed_verified": False,
9426
+ "delete_executed": bool(item.get("delete_executed")),
9427
+ "readback_status": readback_status,
9428
+ "safe_to_retry_delete": False,
9429
+ "error_code": item.get("error_code"),
9253
9430
  }
9254
9431
  )
9255
9432
  else:
@@ -9262,10 +9439,17 @@ class AiBuilderFacade:
9262
9439
  "error_code": item.get("error_code"),
9263
9440
  }
9264
9441
  )
9442
+ removed_delete_results = [
9443
+ item
9444
+ for item in view_results
9445
+ if item.get("operation") == "delete" and bool(item.get("delete_executed"))
9446
+ ]
9447
+ removed_verified = all(str(item.get("readback_status") or "") == "deleted" for item in removed_delete_results)
9448
+ delete_readback_pending = any(str(item.get("readback_status") or "") != "deleted" for item in removed_delete_results)
9265
9449
  verified = (
9266
9450
  (not verified_views_unavailable)
9267
9451
  and all(name in verified_names for name in created + updated)
9268
- and all(name not in verified_names for name in removed)
9452
+ and removed_verified
9269
9453
  )
9270
9454
  view_filters_verified = verified and not filter_readback_pending and not filter_mismatches
9271
9455
  view_query_conditions_verified = verified and not query_condition_readback_pending and not query_condition_mismatches
@@ -9360,6 +9544,13 @@ class AiBuilderFacade:
9360
9544
  "system buttons verified, but draft custom button bindings are not fully visible through view readback yet",
9361
9545
  )
9362
9546
  )
9547
+ if delete_readback_pending:
9548
+ warnings.append(
9549
+ _warning(
9550
+ "VIEW_DELETE_READBACK_PENDING",
9551
+ "view delete was sent, but deletion readback is not fully verified; do not blindly repeat delete",
9552
+ )
9553
+ )
9363
9554
  all_verified = verified and view_filters_verified and view_query_conditions_verified and view_associated_resources_verified and view_buttons_verified
9364
9555
  response = {
9365
9556
  "status": "success" if all_verified else "partial_success",
@@ -9407,6 +9598,7 @@ class AiBuilderFacade:
9407
9598
  "query_condition_readback_pending": query_condition_readback_pending,
9408
9599
  "associated_resource_readback_pending": associated_resource_readback_pending,
9409
9600
  "button_readback_pending": button_readback_pending,
9601
+ "delete_readback_pending": delete_readback_pending,
9410
9602
  "custom_button_readback_pending": custom_button_readback_pending,
9411
9603
  "custom_button_readback_pending_entries": deepcopy(custom_button_readback_pending_entries),
9412
9604
  "by_view": verification_by_view,
@@ -9671,6 +9863,159 @@ class AiBuilderFacade:
9671
9863
  )
9672
9864
  return expanded, issues, results
9673
9865
 
9866
+ def _verify_view_deleted_by_key(self, *, profile: str, view_key: str) -> JSONObject:
9867
+ try:
9868
+ self.views.view_get_config(profile=profile, viewgraph_key=view_key)
9869
+ except (QingflowApiError, RuntimeError) as error:
9870
+ api_error = _coerce_api_error(error)
9871
+ if _delete_readback_is_not_found(api_error):
9872
+ return {
9873
+ "view_key": view_key,
9874
+ "operation": "delete",
9875
+ "status": "removed",
9876
+ "delete_executed": True,
9877
+ "readback_status": "deleted",
9878
+ "safe_to_retry_delete": False,
9879
+ }
9880
+ return {
9881
+ "view_key": view_key,
9882
+ "operation": "delete",
9883
+ "status": "readback_pending",
9884
+ "delete_executed": True,
9885
+ "readback_status": "unavailable",
9886
+ "safe_to_retry_delete": False,
9887
+ "error_code": "VIEW_DELETE_READBACK_UNAVAILABLE",
9888
+ "message": "delete request completed, but view existence could not be verified by view_key readback",
9889
+ "request_id": api_error.request_id,
9890
+ "backend_code": api_error.backend_code,
9891
+ "http_status": None if api_error.http_status == 404 else api_error.http_status,
9892
+ "transport_error": _transport_error_payload(api_error),
9893
+ }
9894
+ return {
9895
+ "view_key": view_key,
9896
+ "operation": "delete",
9897
+ "status": "readback_pending",
9898
+ "delete_executed": True,
9899
+ "readback_status": "still_exists",
9900
+ "safe_to_retry_delete": False,
9901
+ "error_code": "VIEW_DELETE_READBACK_STILL_EXISTS",
9902
+ "message": "delete request completed, but the view still exists during view_key readback",
9903
+ }
9904
+
9905
+ def _verify_custom_button_deleted_by_id(self, *, profile: str, app_key: str, button_id: int) -> JSONObject:
9906
+ try:
9907
+ self.buttons.custom_button_get(profile=profile, app_key=app_key, button_id=button_id, being_draft=True, include_raw=False)
9908
+ except (QingflowApiError, RuntimeError) as error:
9909
+ api_error = _coerce_api_error(error)
9910
+ if _delete_readback_is_not_found(api_error):
9911
+ return {
9912
+ "button_id": button_id,
9913
+ "operation": "delete",
9914
+ "status": "removed",
9915
+ "delete_executed": True,
9916
+ "readback_status": "deleted",
9917
+ "safe_to_retry_delete": False,
9918
+ }
9919
+ return {
9920
+ "button_id": button_id,
9921
+ "operation": "delete",
9922
+ "status": "readback_pending",
9923
+ "delete_executed": True,
9924
+ "readback_status": "unavailable",
9925
+ "safe_to_retry_delete": False,
9926
+ "error_code": "CUSTOM_BUTTON_DELETE_READBACK_UNAVAILABLE",
9927
+ "message": "delete request completed, but custom button existence could not be verified by button_id readback",
9928
+ "request_id": api_error.request_id,
9929
+ "backend_code": api_error.backend_code,
9930
+ "http_status": None if api_error.http_status == 404 else api_error.http_status,
9931
+ "transport_error": _transport_error_payload(api_error),
9932
+ }
9933
+ return {
9934
+ "button_id": button_id,
9935
+ "operation": "delete",
9936
+ "status": "readback_pending",
9937
+ "delete_executed": True,
9938
+ "readback_status": "still_exists",
9939
+ "safe_to_retry_delete": False,
9940
+ "error_code": "CUSTOM_BUTTON_DELETE_READBACK_STILL_EXISTS",
9941
+ "message": "delete request completed, but the custom button still exists during button_id readback",
9942
+ }
9943
+
9944
+ def _verify_associated_resources_deleted_by_pool(
9945
+ self,
9946
+ *,
9947
+ deleted_items: list[JSONObject],
9948
+ resources: list[dict[str, Any]],
9949
+ readback_failed: bool,
9950
+ ) -> list[JSONObject]:
9951
+ existing_by_id = _associated_resource_index(resources) if not readback_failed else {}
9952
+ verified_items: list[JSONObject] = []
9953
+ for item in deleted_items:
9954
+ associated_item_id = _coerce_positive_int(item.get("associated_item_id"))
9955
+ verified = deepcopy(item)
9956
+ verified["operation"] = "remove"
9957
+ verified["delete_executed"] = True
9958
+ verified["safe_to_retry_delete"] = False
9959
+ if associated_item_id is None or readback_failed:
9960
+ verified["status"] = "readback_pending"
9961
+ verified["readback_status"] = "unavailable"
9962
+ verified["error_code"] = "ASSOCIATED_RESOURCE_DELETE_READBACK_UNAVAILABLE"
9963
+ verified["message"] = "delete request completed, but associated resource pool readback is unavailable"
9964
+ elif associated_item_id in existing_by_id:
9965
+ verified["status"] = "readback_pending"
9966
+ verified["readback_status"] = "still_exists"
9967
+ verified["error_code"] = "ASSOCIATED_RESOURCE_DELETE_READBACK_STILL_EXISTS"
9968
+ verified["message"] = "delete request completed, but the associated resource still exists in pool readback"
9969
+ else:
9970
+ verified["status"] = "removed"
9971
+ verified["readback_status"] = "deleted"
9972
+ verified.pop("error_code", None)
9973
+ verified.pop("message", None)
9974
+ verified_items.append(verified)
9975
+ return verified_items
9976
+
9977
+ def _verify_chart_deleted_by_id(self, *, profile: str, chart_id: str) -> JSONObject:
9978
+ base_result: dict[str, Any] | None = None
9979
+ try:
9980
+ raw = self.charts.qingbi_report_get_base(profile=profile, chart_id=chart_id).get("result") or {}
9981
+ base_result = raw if isinstance(raw, dict) else {}
9982
+ except (QingflowApiError, RuntimeError) as error:
9983
+ api_error = _coerce_api_error(error)
9984
+ if _chart_delete_readback_is_not_found(api_error):
9985
+ return {
9986
+ "chart_id": chart_id,
9987
+ "operation": "delete",
9988
+ "status": "removed",
9989
+ "delete_executed": True,
9990
+ "readback_status": "deleted",
9991
+ "safe_to_retry_delete": False,
9992
+ }
9993
+ return {
9994
+ "chart_id": chart_id,
9995
+ "operation": "delete",
9996
+ "status": "readback_pending",
9997
+ "delete_executed": True,
9998
+ "readback_status": "unavailable",
9999
+ "safe_to_retry_delete": False,
10000
+ "error_code": "CHART_DELETE_READBACK_UNAVAILABLE",
10001
+ "message": "delete request completed, but chart existence could not be verified by chart_id readback",
10002
+ "request_id": api_error.request_id,
10003
+ "backend_code": api_error.backend_code,
10004
+ "http_status": None if api_error.http_status == 404 else api_error.http_status,
10005
+ "transport_error": _transport_error_payload(api_error),
10006
+ }
10007
+ return {
10008
+ "chart_id": chart_id,
10009
+ "operation": "delete",
10010
+ "status": "readback_pending",
10011
+ "delete_executed": True,
10012
+ "readback_status": "still_exists",
10013
+ "safe_to_retry_delete": False,
10014
+ "error_code": "CHART_DELETE_READBACK_STILL_EXISTS",
10015
+ "message": "delete request completed, but the chart still exists during chart_id readback",
10016
+ "readback_name": base_result.get("chartName") or base_result.get("name") if isinstance(base_result, dict) else None,
10017
+ }
10018
+
9674
10019
  def chart_apply(self, *, profile: str, request: ChartApplyRequest) -> JSONObject:
9675
10020
  normalized_args = request.model_dump(mode="json")
9676
10021
  permission_outcomes: list[PermissionCheckOutcome] = []
@@ -9695,21 +10040,27 @@ class AiBuilderFacade:
9695
10040
  def finalize(response: JSONObject) -> JSONObject:
9696
10041
  return _apply_permission_outcomes(response, *permission_outcomes)
9697
10042
 
9698
- try:
9699
- schema_state = self._load_base_schema_state(profile=profile, app_key=app_key)
9700
- parsed = schema_state.get("parsed") if isinstance(schema_state.get("parsed"), dict) else {}
9701
- fields = parsed.get("fields") if isinstance(parsed.get("fields"), list) else []
9702
- qingbi_fields = self.charts.qingbi_report_list_fields(profile=profile, app_key=app_key).get("items") or []
9703
- existing_chart_items, existing_chart_list_source = self._load_chart_list_for_builder(profile=profile, app_key=app_key)
9704
- except (QingflowApiError, RuntimeError) as error:
9705
- api_error = _coerce_api_error(error)
9706
- return finalize(_failed_from_api_error(
9707
- "CHART_APPLY_FAILED",
9708
- api_error,
9709
- normalized_args=normalized_args,
9710
- details=_with_state_read_blocked_details({"app_key": app_key}, resource="chart", error=api_error),
9711
- suggested_next_call={"tool_name": "app_charts_apply", "arguments": {"profile": profile, **normalized_args}},
9712
- ))
10043
+ fields: list[dict[str, Any]] = []
10044
+ qingbi_fields: list[Any] = []
10045
+ existing_chart_items: list[Any] = []
10046
+ existing_chart_list_source: str | None = None
10047
+ needs_chart_inventory = bool(request.upsert_charts or request.patch_charts or request.reorder_chart_ids)
10048
+ if needs_chart_inventory:
10049
+ try:
10050
+ schema_state = self._load_base_schema_state(profile=profile, app_key=app_key)
10051
+ parsed = schema_state.get("parsed") if isinstance(schema_state.get("parsed"), dict) else {}
10052
+ fields = parsed.get("fields") if isinstance(parsed.get("fields"), list) else []
10053
+ qingbi_fields = self.charts.qingbi_report_list_fields(profile=profile, app_key=app_key).get("items") or []
10054
+ existing_chart_items, existing_chart_list_source = self._load_chart_list_for_builder(profile=profile, app_key=app_key)
10055
+ except (QingflowApiError, RuntimeError) as error:
10056
+ api_error = _coerce_api_error(error)
10057
+ return finalize(_failed_from_api_error(
10058
+ "CHART_APPLY_FAILED",
10059
+ api_error,
10060
+ normalized_args=normalized_args,
10061
+ details=_with_state_read_blocked_details({"app_key": app_key}, resource="chart", error=api_error),
10062
+ suggested_next_call={"tool_name": "app_charts_apply", "arguments": {"profile": profile, **normalized_args}},
10063
+ ))
9713
10064
 
9714
10065
  field_lookup = _build_public_field_lookup(fields)
9715
10066
  qingbi_fields_by_id = {
@@ -9717,6 +10068,11 @@ class AiBuilderFacade:
9717
10068
  for item in qingbi_fields
9718
10069
  if isinstance(item, dict) and item.get("fieldId")
9719
10070
  }
10071
+ chart_field_lookup = _build_qingbi_chart_field_lookup(
10072
+ app_key=app_key,
10073
+ qingbi_fields=[item for item in qingbi_fields if isinstance(item, dict)],
10074
+ field_lookup=field_lookup,
10075
+ )
9720
10076
  existing_by_id = {
9721
10077
  _extract_chart_identifier(item): deepcopy(item)
9722
10078
  for item in existing_chart_items
@@ -9759,8 +10115,12 @@ class AiBuilderFacade:
9759
10115
  updated_ids: list[str] = []
9760
10116
  removed_ids: list[str] = []
9761
10117
  failed_items: list[dict[str, Any]] = []
10118
+ delete_readback_issues: list[dict[str, Any]] = []
9762
10119
 
9763
10120
  for patch in upsert_charts:
10121
+ chart_id = ""
10122
+ target_type = ""
10123
+ config_payload: dict[str, Any] | None = None
9764
10124
  try:
9765
10125
  dataset_source = _chart_patch_dataset_source_type(patch)
9766
10126
  if dataset_source:
@@ -9799,6 +10159,14 @@ class AiBuilderFacade:
9799
10159
  f"existing chart '{chart_id or patch.name}' uses dataset report source '{existing_source_type}' and is not supported for update yet. "
9800
10160
  "Update it in QingBI directly, then attach the existing report with app_associated_resources_apply using report_source='dataset'."
9801
10161
  )
10162
+ if existing is None or config_update_requested:
10163
+ config_payload = _build_public_chart_config_payload(
10164
+ patch=patch,
10165
+ app_key=app_key,
10166
+ field_lookup=field_lookup,
10167
+ chart_field_lookup=chart_field_lookup,
10168
+ qingbi_fields_by_id=qingbi_fields_by_id,
10169
+ )
9802
10170
  if existing is None:
9803
10171
  temp_chart_id = str(patch.chart_id or f"mcp_{uuid4().hex[:16]}")
9804
10172
  create_payload = {
@@ -9882,13 +10250,7 @@ class AiBuilderFacade:
9882
10250
 
9883
10251
  config_updated = False
9884
10252
  if existing is None or config_update_requested:
9885
- config_payload = _build_public_chart_config_payload(
9886
- patch=patch,
9887
- app_key=app_key,
9888
- field_lookup=field_lookup,
9889
- qingbi_fields_by_id=qingbi_fields_by_id,
9890
- )
9891
- self.charts.qingbi_report_update_config(profile=profile, chart_id=chart_id, payload=config_payload)
10253
+ self.charts.qingbi_report_update_config(profile=profile, chart_id=chart_id, payload=config_payload or {})
9892
10254
  config_updated = True
9893
10255
  if existing is not None and chart_id not in updated_ids and config_updated:
9894
10256
  updated_ids.append(chart_id)
@@ -9918,11 +10280,21 @@ class AiBuilderFacade:
9918
10280
  )
9919
10281
  except (QingflowApiError, RuntimeError, ValueError, VisibilityResolutionError) as error:
9920
10282
  api_error = _coerce_api_error(error) if not isinstance(error, ValueError) else None
10283
+ diagnostics = (
10284
+ error.diagnostics
10285
+ if isinstance(error, ChartRuleViolation)
10286
+ else _explain_chart_backend_validation_error(api_error=api_error, chart_type=target_type or patch.chart_type.value, payload=config_payload)
10287
+ if api_error is not None
10288
+ else None
10289
+ )
9921
10290
  failure = {
9922
- "chart_id": str(locals().get("chart_id") or patch.chart_id or ""),
10291
+ "chart_id": str(chart_id or patch.chart_id or ""),
9923
10292
  "name": patch.name,
10293
+ "chart_type": patch.chart_type.value,
9924
10294
  "status": "failed",
9925
- "message": str(error),
10295
+ "error_code": diagnostics.get("rule_code") if isinstance(diagnostics, dict) else "CHART_APPLY_FAILED",
10296
+ "message": str(diagnostics.get("message") if isinstance(diagnostics, dict) and diagnostics.get("message") else error),
10297
+ "diagnostics": diagnostics,
9926
10298
  "request_id": api_error.request_id if api_error else None,
9927
10299
  "backend_code": api_error.backend_code if api_error else None,
9928
10300
  "http_status": None if api_error is None or api_error.http_status == 404 else api_error.http_status,
@@ -9934,12 +10306,18 @@ class AiBuilderFacade:
9934
10306
  try:
9935
10307
  self.charts.qingbi_report_delete(profile=profile, chart_id=chart_id)
9936
10308
  removed_ids.append(chart_id)
9937
- chart_results.append({"chart_id": chart_id, "status": "removed"})
10309
+ delete_result = self._verify_chart_deleted_by_id(profile=profile, chart_id=chart_id)
10310
+ if delete_result.get("readback_status") != "deleted":
10311
+ delete_readback_issues.append(delete_result)
10312
+ chart_results.append(delete_result)
9938
10313
  except (QingflowApiError, RuntimeError) as error:
9939
10314
  api_error = _coerce_api_error(error)
9940
10315
  failure = {
9941
10316
  "chart_id": chart_id,
10317
+ "operation": "delete",
9942
10318
  "status": "failed",
10319
+ "delete_executed": False,
10320
+ "safe_to_retry_delete": True,
9943
10321
  "message": _public_error_message("CHART_APPLY_FAILED", api_error),
9944
10322
  "request_id": api_error.request_id,
9945
10323
  "backend_code": api_error.backend_code,
@@ -9974,30 +10352,37 @@ class AiBuilderFacade:
9974
10352
  chart_results.append(failure)
9975
10353
 
9976
10354
  noop = not created_ids and not updated_ids and not removed_ids and not reordered and not failed_items
9977
- try:
9978
- readback_items, readback_list_source = self._load_chart_list_for_builder(profile=profile, app_key=app_key)
9979
- readback_ids = {
9980
- _extract_chart_identifier(item)
9981
- for item in readback_items
9982
- if isinstance(item, dict) and _extract_chart_identifier(item)
9983
- }
9984
- verified = (
9985
- all(chart_id in readback_ids for chart_id in created_ids + updated_ids)
9986
- and all(chart_id not in readback_ids for chart_id in removed_ids)
9987
- )
9988
- if request.reorder_chart_ids:
9989
- ordered_readback = [
10355
+ needs_list_readback = bool(created_ids or updated_ids or reordered)
10356
+ delete_readback_unavailable = any(item.get("readback_status") == "unavailable" for item in delete_readback_issues)
10357
+ deletes_verified = not delete_readback_issues
10358
+ readback_unavailable = False
10359
+ readback_list_source: str | None = existing_chart_list_source
10360
+ if needs_list_readback:
10361
+ try:
10362
+ readback_items, readback_list_source = self._load_chart_list_for_builder(profile=profile, app_key=app_key)
10363
+ readback_ids = {
9990
10364
  _extract_chart_identifier(item)
9991
10365
  for item in readback_items
9992
10366
  if isinstance(item, dict) and _extract_chart_identifier(item)
9993
- ]
9994
- requested_existing = [chart_id for chart_id in request.reorder_chart_ids if chart_id in ordered_readback]
9995
- verified = verified and readback_list_source == "sorted" and ordered_readback[: len(requested_existing)] == requested_existing
9996
- readback_unavailable = False
9997
- except (QingflowApiError, RuntimeError):
9998
- verified = False
9999
- readback_unavailable = True
10000
- readback_list_source = None
10367
+ }
10368
+ verified = all(chart_id in readback_ids for chart_id in created_ids + updated_ids)
10369
+ if request.reorder_chart_ids:
10370
+ ordered_readback = [
10371
+ _extract_chart_identifier(item)
10372
+ for item in readback_items
10373
+ if isinstance(item, dict) and _extract_chart_identifier(item)
10374
+ ]
10375
+ requested_existing = [chart_id for chart_id in request.reorder_chart_ids if chart_id in ordered_readback]
10376
+ verified = verified and readback_list_source == "sorted" and ordered_readback[: len(requested_existing)] == requested_existing
10377
+ readback_unavailable = False
10378
+ except (QingflowApiError, RuntimeError):
10379
+ verified = False
10380
+ readback_unavailable = True
10381
+ readback_list_source = None
10382
+ else:
10383
+ verified = True
10384
+ verified = verified and deletes_verified
10385
+ any_readback_unavailable = readback_unavailable or delete_readback_unavailable
10001
10386
 
10002
10387
  if failed_items:
10003
10388
  successful_changes = bool(created_ids or updated_ids or removed_ids or reordered)
@@ -10019,11 +10404,13 @@ class AiBuilderFacade:
10019
10404
  failed_items=failed_items,
10020
10405
  readback_unavailable=readback_unavailable,
10021
10406
  verified=False if failed_items else verified,
10407
+ delete_readback_issues=delete_readback_issues,
10022
10408
  ),
10023
10409
  "verification": {
10024
10410
  "charts_verified": False if failed_items else verified,
10025
- "readback_unavailable": readback_unavailable,
10026
- "chart_order_verified": False if request.reorder_chart_ids else (readback_list_source == "sorted"),
10411
+ "readback_unavailable": any_readback_unavailable,
10412
+ "chart_delete_readback_results": [deepcopy(item) for item in chart_results if item.get("operation") == "delete"],
10413
+ "chart_order_verified": False if request.reorder_chart_ids else True,
10027
10414
  "chart_list_source": readback_list_source or existing_chart_list_source,
10028
10415
  },
10029
10416
  "app_key": app_key,
@@ -10032,27 +10419,37 @@ class AiBuilderFacade:
10032
10419
  "verified": False if failed_items else verified,
10033
10420
  })
10034
10421
  result_verified = verified or noop
10422
+ pending_delete = bool(delete_readback_issues)
10423
+ pending_error_code = "CHART_DELETE_READBACK_PENDING" if pending_delete and not readback_unavailable else "CHART_READBACK_PENDING"
10424
+ pending_message = "applied chart operations; delete readback pending" if pending_delete else "applied chart operations; readback pending"
10425
+ pending_suggestion = (
10426
+ {"tool_name": "chart_get", "arguments": {"profile": profile, "chart_id": str(delete_readback_issues[0].get("chart_id") or "CHART_ID")}}
10427
+ if pending_delete
10428
+ else {"tool_name": "app_charts_apply", "arguments": {"profile": profile, **normalized_args}}
10429
+ )
10035
10430
  return finalize({
10036
10431
  "status": "success" if result_verified else "partial_success",
10037
- "error_code": None if result_verified else "CHART_READBACK_PENDING",
10432
+ "error_code": None if result_verified else pending_error_code,
10038
10433
  "recoverable": not result_verified,
10039
- "message": "no chart changes requested" if noop else ("applied chart operations" if verified else "applied chart operations; readback pending"),
10434
+ "message": "no chart changes requested" if noop else ("applied chart operations" if verified else pending_message),
10040
10435
  "normalized_args": normalized_args,
10041
10436
  "missing_fields": [],
10042
10437
  "allowed_values": {"chart.chart_type": [member.value for member in PublicChartType], "chart.filter.operator": [member.value for member in ViewFilterOperator]},
10043
10438
  "details": {},
10044
10439
  "request_id": None,
10045
- "suggested_next_call": None if result_verified else {"tool_name": "app_charts_apply", "arguments": {"profile": profile, **normalized_args}},
10440
+ "suggested_next_call": None if result_verified else pending_suggestion,
10046
10441
  "noop": noop,
10047
10442
  "warnings": _chart_apply_warnings(
10048
10443
  failed_items=[],
10049
10444
  readback_unavailable=False if noop else readback_unavailable,
10050
10445
  verified=result_verified,
10446
+ delete_readback_issues=delete_readback_issues,
10051
10447
  ),
10052
10448
  "verification": {
10053
10449
  "charts_verified": result_verified,
10054
- "readback_unavailable": False if noop else readback_unavailable,
10055
- "chart_order_verified": True if noop and not request.reorder_chart_ids else (readback_list_source == "sorted" and result_verified if request.reorder_chart_ids else readback_list_source == "sorted"),
10450
+ "readback_unavailable": False if noop else any_readback_unavailable,
10451
+ "chart_delete_readback_results": [deepcopy(item) for item in chart_results if item.get("operation") == "delete"],
10452
+ "chart_order_verified": (readback_list_source == "sorted" and result_verified) if request.reorder_chart_ids else True,
10056
10453
  "chart_list_source": existing_chart_list_source if noop else readback_list_source,
10057
10454
  },
10058
10455
  "app_key": app_key,
@@ -10168,6 +10565,7 @@ class AiBuilderFacade:
10168
10565
  suggested_next_call={"tool_name": "portal_apply", "arguments": {"profile": profile, **normalized_args}},
10169
10566
  )
10170
10567
  try:
10568
+ layout_diagnostics: dict[str, Any] = _empty_portal_layout_diagnostics()
10171
10569
  if creating:
10172
10570
  create_payload = _build_public_portal_base_payload(
10173
10571
  dash_name=request.dash_name or "未命名门户",
@@ -10204,7 +10602,12 @@ class AiBuilderFacade:
10204
10602
  base_payload=base_payload,
10205
10603
  )
10206
10604
  if sections_requested:
10207
- component_payload = self._build_portal_components_from_sections(profile=profile, sections=request.sections)
10605
+ component_payload = self._build_portal_components_from_sections(
10606
+ profile=profile,
10607
+ sections=request.sections,
10608
+ layout_preset=request.layout_preset,
10609
+ )
10610
+ layout_diagnostics = _portal_layout_diagnostics(request.sections, component_payload)
10208
10611
  update_payload["components"] = component_payload
10209
10612
  self.portals.portal_update(profile=profile, dash_key=dash_key, payload=update_payload)
10210
10613
  self.portals.portal_update_base_info(
@@ -10307,6 +10710,7 @@ class AiBuilderFacade:
10307
10710
  live_meta_verified=live_meta_verified,
10308
10711
  publish_requested=request.publish,
10309
10712
  )
10713
+ warnings.extend(_portal_layout_warning_items(layout_diagnostics))
10310
10714
  return finalize({
10311
10715
  "status": status,
10312
10716
  "error_code": error_code,
@@ -10333,6 +10737,7 @@ class AiBuilderFacade:
10333
10737
  "suggested_next_call": None if verified else {"tool_name": "portal_apply", "arguments": {"profile": profile, **normalized_args}},
10334
10738
  "noop": False,
10335
10739
  "warnings": warnings,
10740
+ "layout_diagnostics": layout_diagnostics,
10336
10741
  "verification": {
10337
10742
  "draft_verified": draft_verified,
10338
10743
  "draft_metadata_verified": draft_meta_verified,
@@ -10943,6 +11348,7 @@ class AiBuilderFacade:
10943
11348
  *,
10944
11349
  profile: str,
10945
11350
  sections: list[PortalSectionPatch],
11351
+ layout_preset: str | None = None,
10946
11352
  ) -> list[dict[str, Any]]:
10947
11353
  resolved_components: list[dict[str, Any]] = []
10948
11354
  pc_x = 0
@@ -10959,9 +11365,15 @@ class AiBuilderFacade:
10959
11365
  pc_y=pc_y,
10960
11366
  pc_row_height=pc_row_height,
10961
11367
  mobile_y=mobile_y,
11368
+ layout_preset=layout_preset,
10962
11369
  )
10963
11370
  else:
10964
- position_payload = _portal_position_payload(section.position)
11371
+ position_payload = _portal_position_payload(section.position, inferred_mobile_y=mobile_y)
11372
+ mobile = position_payload.get("mobile") if isinstance(position_payload.get("mobile"), dict) else {}
11373
+ mobile_y = max(
11374
+ mobile_y,
11375
+ int(mobile.get("y") or 0) + int(mobile.get("rows") or 0),
11376
+ )
10965
11377
  dash_style = deepcopy(section.dash_style_config) if isinstance(section.dash_style_config, dict) else None
10966
11378
  component: dict[str, Any]
10967
11379
  if section.source_type == "chart":
@@ -12360,6 +12772,28 @@ def _public_error_message(error_code: str, error: QingflowApiError) -> str:
12360
12772
  return mapping.get(error_code, "requested builder resource is unavailable in the current route")
12361
12773
 
12362
12774
 
12775
+ def _chart_delete_readback_is_not_found(error: QingflowApiError) -> bool:
12776
+ return _delete_readback_is_not_found(error)
12777
+
12778
+
12779
+ def _delete_readback_is_not_found(error: QingflowApiError) -> bool:
12780
+ backend_code = int(error.backend_code or 0) if str(error.backend_code or "").isdigit() else error.backend_code
12781
+ if error.http_status == 404 or backend_code in {404, 40038, 81007}:
12782
+ return True
12783
+ message = str(error.message or "").lower()
12784
+ return any(
12785
+ marker in message
12786
+ for marker in (
12787
+ "object not exist",
12788
+ "not found",
12789
+ "not exist",
12790
+ "does not exist",
12791
+ "不存在",
12792
+ "未找到",
12793
+ )
12794
+ )
12795
+
12796
+
12363
12797
  def _extract_edit_lock_owner(message: str) -> JSONObject:
12364
12798
  text = str(message or "").strip()
12365
12799
  if not text:
@@ -12664,18 +13098,20 @@ def _build_public_dimension_fields(
12664
13098
  *,
12665
13099
  app_key: str,
12666
13100
  field_lookup: dict[str, dict[str, Any]],
13101
+ chart_field_lookup: dict[str, Any],
12667
13102
  qingbi_fields_by_id: dict[str, dict[str, Any]],
13103
+ chart_type: str = "chart",
12668
13104
  ) -> list[dict[str, Any]]:
12669
13105
  dimensions: list[dict[str, Any]] = []
12670
13106
  for selector in selectors:
12671
- field = _resolve_public_field(selector, field_lookup=field_lookup)
12672
- field_id = _bi_field_id_for_field(app_key=app_key, field=field, qingbi_fields_by_id=qingbi_fields_by_id)
12673
- qingbi_field = deepcopy(qingbi_fields_by_id.get(field_id, {}))
13107
+ qingbi_field = _resolve_qingbi_chart_field(selector, chart_field_lookup=chart_field_lookup, chart_type=chart_type, role="dimension")
13108
+ field_id = _chart_field_id(qingbi_field)
13109
+ form_field = qingbi_field.get("_public_form_field") if isinstance(qingbi_field.get("_public_form_field"), dict) else {}
12674
13110
  dimensions.append(
12675
13111
  {
12676
13112
  "fieldId": field_id,
12677
- "fieldName": qingbi_field.get("fieldName") or field.get("name"),
12678
- "fieldType": qingbi_field.get("fieldType") or _qingbi_field_type_from_public_field(str(field.get("type") or "")),
13113
+ "fieldName": qingbi_field.get("fieldName") or form_field.get("name") or field_id,
13114
+ "fieldType": qingbi_field.get("fieldType") or _qingbi_field_type_from_public_field(str(form_field.get("type") or "")),
12679
13115
  "orderType": "default",
12680
13116
  "alignType": "left",
12681
13117
  "dateFormat": "yyyy-MM-dd",
@@ -12717,27 +13153,540 @@ def _default_public_total_metric() -> dict[str, Any]:
12717
13153
  }
12718
13154
 
12719
13155
 
13156
+ _QINGBI_TOTAL_FIELD_ID = ":-100"
13157
+ _QINGBI_DECIMAL_FIELD_TYPES = {"decimal", "number", "numeric", "amount", "integer", "int", "long", "double", "float"}
13158
+
13159
+
13160
+ class ChartRuleViolation(ValueError):
13161
+ def __init__(self, diagnostics: dict[str, Any]) -> None:
13162
+ self.diagnostics = diagnostics
13163
+ super().__init__(str(diagnostics.get("message") or diagnostics.get("next_action") or "chart rule violation"))
13164
+
13165
+
13166
+ def _chart_field_id(field: dict[str, Any]) -> str:
13167
+ return str(field.get("fieldId") or field.get("field_id") or "").strip()
13168
+
13169
+
13170
+ def _chart_field_name(field: dict[str, Any]) -> str | None:
13171
+ name = str(field.get("fieldName") or field.get("field_name") or "").strip()
13172
+ return name or None
13173
+
13174
+
13175
+ def _chart_fields(payload: dict[str, Any], key: str) -> list[dict[str, Any]]:
13176
+ value = payload.get(key)
13177
+ if not isinstance(value, list):
13178
+ return []
13179
+ return [item for item in value if isinstance(item, dict)]
13180
+
13181
+
13182
+ def _chart_field_summary(field: dict[str, Any]) -> dict[str, Any]:
13183
+ return _compact_dict(
13184
+ {
13185
+ "field_id": _chart_field_id(field),
13186
+ "field_name": _chart_field_name(field),
13187
+ "field_type": field.get("fieldType") or field.get("field_type"),
13188
+ "field_source": field.get("fieldSource") or field.get("field_source"),
13189
+ "bi_formula_type": field.get("biFormulaType") or field.get("bi_formula_type"),
13190
+ "aggre_field_id": field.get("aggreFieldId") or field.get("aggre_field_id"),
13191
+ }
13192
+ )
13193
+
13194
+
13195
+ def _chart_duplicate_fields(fields: list[dict[str, Any]]) -> list[dict[str, Any]]:
13196
+ seen: dict[str, dict[str, Any]] = {}
13197
+ duplicates: dict[str, dict[str, Any]] = {}
13198
+ for field in fields:
13199
+ field_id = _chart_field_id(field)
13200
+ if not field_id:
13201
+ continue
13202
+ if field_id in seen:
13203
+ duplicates[field_id] = _chart_field_summary(field)
13204
+ else:
13205
+ seen[field_id] = field
13206
+ return list(duplicates.values())
13207
+
13208
+
13209
+ def _chart_rule_diagnostics(
13210
+ *,
13211
+ rule_code: str,
13212
+ chart_type: str,
13213
+ message: str,
13214
+ expected: str,
13215
+ actual: dict[str, Any],
13216
+ next_action: str,
13217
+ offending_fields: list[dict[str, Any]] | None = None,
13218
+ ) -> dict[str, Any]:
13219
+ return _compact_dict(
13220
+ {
13221
+ "rule_code": rule_code,
13222
+ "chart_type": chart_type,
13223
+ "message": message,
13224
+ "expected": expected,
13225
+ "actual": actual,
13226
+ "offending_fields": offending_fields or [],
13227
+ "next_action": next_action,
13228
+ }
13229
+ )
13230
+
13231
+
13232
+ def _raise_chart_rule(
13233
+ *,
13234
+ rule_code: str,
13235
+ chart_type: str,
13236
+ message: str,
13237
+ expected: str,
13238
+ actual: dict[str, Any],
13239
+ next_action: str,
13240
+ offending_fields: list[dict[str, Any]] | None = None,
13241
+ ) -> None:
13242
+ raise ChartRuleViolation(
13243
+ _chart_rule_diagnostics(
13244
+ rule_code=rule_code,
13245
+ chart_type=chart_type,
13246
+ message=message,
13247
+ expected=expected,
13248
+ actual=actual,
13249
+ next_action=next_action,
13250
+ offending_fields=offending_fields,
13251
+ )
13252
+ )
13253
+
13254
+
13255
+ _QINGBI_TOTAL_FIELD_ALIASES = {_QINGBI_TOTAL_FIELD_ID, "数据总量", "data_total", "total", "count"}
13256
+
13257
+
13258
+ def _qingbi_field_que_id(*, app_key: str, field_id: Any) -> int | None:
13259
+ raw = str(field_id or "").strip()
13260
+ if not raw or raw == _QINGBI_TOTAL_FIELD_ID:
13261
+ return None
13262
+ if raw.startswith("field_"):
13263
+ return _coerce_positive_int(raw.removeprefix("field_"))
13264
+ if raw.startswith(f"{app_key}:"):
13265
+ return _coerce_positive_int(raw.split(":", 1)[1])
13266
+ if ":" in raw:
13267
+ return _coerce_positive_int(raw.rsplit(":", 1)[1])
13268
+ return _coerce_positive_int(raw)
13269
+
13270
+
13271
+ def _dedupe_qingbi_fields(fields: list[dict[str, Any]]) -> list[dict[str, Any]]:
13272
+ deduped: list[dict[str, Any]] = []
13273
+ seen: set[str] = set()
13274
+ for field in fields:
13275
+ field_id = _chart_field_id(field)
13276
+ key = field_id or json.dumps(field, sort_keys=True, ensure_ascii=False, default=str)
13277
+ if key in seen:
13278
+ continue
13279
+ seen.add(key)
13280
+ deduped.append(field)
13281
+ return deduped
13282
+
13283
+
13284
+ def _build_qingbi_chart_field_lookup(
13285
+ *,
13286
+ app_key: str,
13287
+ qingbi_fields: list[dict[str, Any]],
13288
+ field_lookup: dict[str, dict[str, Any]],
13289
+ ) -> dict[str, Any]:
13290
+ by_selector: dict[str, list[dict[str, Any]]] = {}
13291
+ form_by_que_id = field_lookup.get("by_que_id") or {}
13292
+
13293
+ def add_selector(key: Any, field: dict[str, Any]) -> None:
13294
+ normalized = str(key or "").strip()
13295
+ if not normalized:
13296
+ return
13297
+ by_selector.setdefault(normalized, []).append(field)
13298
+ lower = normalized.lower()
13299
+ if lower != normalized:
13300
+ by_selector.setdefault(lower, []).append(field)
13301
+
13302
+ for raw_field in qingbi_fields:
13303
+ if not isinstance(raw_field, dict):
13304
+ continue
13305
+ field_id = _chart_field_id(raw_field)
13306
+ if not field_id:
13307
+ continue
13308
+ field = deepcopy(raw_field)
13309
+ que_id = _qingbi_field_que_id(app_key=app_key, field_id=field_id)
13310
+ form_field = form_by_que_id.get(que_id) if que_id is not None else None
13311
+ if isinstance(form_field, dict):
13312
+ field["_public_form_field"] = deepcopy(form_field)
13313
+ if not _chart_field_name(field) and isinstance(form_field, dict) and form_field.get("name"):
13314
+ field["fieldName"] = form_field.get("name")
13315
+
13316
+ add_selector(field_id, field)
13317
+ if que_id is not None:
13318
+ add_selector(que_id, field)
13319
+ add_selector(f"field_{que_id}", field)
13320
+ if isinstance(form_field, dict):
13321
+ add_selector(form_field.get("field_id"), field)
13322
+ title = _chart_field_name(field)
13323
+ if title:
13324
+ add_selector(title, field)
13325
+ return {"by_selector": by_selector}
13326
+
13327
+
13328
+ def _compact_public_chart_fields_read(
13329
+ *,
13330
+ app_key: str,
13331
+ qingbi_fields: list[dict[str, Any]],
13332
+ field_lookup: dict[str, dict[str, Any]],
13333
+ ) -> list[dict[str, Any]]:
13334
+ form_by_que_id = field_lookup.get("by_que_id") or {}
13335
+ compact_fields: list[dict[str, Any]] = []
13336
+ seen: set[str] = set()
13337
+ for field in qingbi_fields:
13338
+ if not isinstance(field, dict):
13339
+ continue
13340
+ bi_field_id = _chart_field_id(field)
13341
+ if not bi_field_id or bi_field_id in seen:
13342
+ continue
13343
+ seen.add(bi_field_id)
13344
+ que_id = _qingbi_field_que_id(app_key=app_key, field_id=bi_field_id)
13345
+ form_field = form_by_que_id.get(que_id) if que_id is not None else None
13346
+ public_field_id = (
13347
+ str(form_field.get("field_id"))
13348
+ if isinstance(form_field, dict) and form_field.get("field_id")
13349
+ else f"field_{que_id}"
13350
+ if que_id is not None
13351
+ else bi_field_id
13352
+ )
13353
+ title = _chart_field_name(field) or (
13354
+ str(form_field.get("name")) if isinstance(form_field, dict) and form_field.get("name") else bi_field_id
13355
+ )
13356
+ compact_fields.append(
13357
+ _compact_dict(
13358
+ {
13359
+ "field_id": public_field_id,
13360
+ "que_id": que_id,
13361
+ "bi_field_id": bi_field_id,
13362
+ "title": title,
13363
+ "field_type": field.get("fieldType") or field.get("field_type"),
13364
+ "system_field": bool(que_id is not None and not isinstance(form_field, dict)),
13365
+ "available_for_charts": True,
13366
+ }
13367
+ )
13368
+ )
13369
+ return compact_fields
13370
+
13371
+
13372
+ def _chart_field_candidates(
13373
+ selector: Any,
13374
+ *,
13375
+ chart_field_lookup: dict[str, Any],
13376
+ ) -> list[dict[str, Any]]:
13377
+ raw = str(selector or "").strip()
13378
+ if not raw:
13379
+ return []
13380
+ by_selector = chart_field_lookup.get("by_selector") if isinstance(chart_field_lookup.get("by_selector"), dict) else {}
13381
+ return _dedupe_qingbi_fields(list(by_selector.get(raw) or by_selector.get(raw.lower()) or []))
13382
+
13383
+
13384
+ def _resolve_qingbi_chart_field(
13385
+ selector: Any,
13386
+ *,
13387
+ chart_field_lookup: dict[str, Any],
13388
+ chart_type: str,
13389
+ role: str,
13390
+ ) -> dict[str, Any]:
13391
+ raw = str(selector or "").strip()
13392
+ if not raw:
13393
+ _raise_chart_rule(
13394
+ rule_code="CHART_FIELD_NOT_IN_QINGBI_SCHEMA",
13395
+ chart_type=chart_type,
13396
+ message="chart field selector cannot be empty",
13397
+ expected="use a field from app_get_fields.chart_fields",
13398
+ actual={"selector": raw, "role": role},
13399
+ next_action="Call app_get_fields and choose a field from chart_fields for chart dimensions, metrics, filters, or query conditions.",
13400
+ )
13401
+ if raw in _QINGBI_TOTAL_FIELD_ALIASES or raw.lower() in _QINGBI_TOTAL_FIELD_ALIASES:
13402
+ if role == "metric":
13403
+ return _default_public_total_metric()
13404
+ _raise_chart_rule(
13405
+ rule_code="CHART_TOTAL_FIELD_NOT_ALLOWED",
13406
+ chart_type=chart_type,
13407
+ message="数据总量 is only valid as a metric field, not as a dimension/filter/query field",
13408
+ expected="use 数据总量 only in indicator_field_ids or omit metrics for count-style charts",
13409
+ actual={"selector": raw, "role": role},
13410
+ next_action="Choose a real QingBI field from app_get_fields.chart_fields for dimensions, filters, and query conditions.",
13411
+ offending_fields=[{"field_id": _QINGBI_TOTAL_FIELD_ID, "field_name": "数据总量"}],
13412
+ )
13413
+ candidates = _chart_field_candidates(raw, chart_field_lookup=chart_field_lookup)
13414
+ if not candidates:
13415
+ _raise_chart_rule(
13416
+ rule_code="CHART_FIELD_NOT_IN_QINGBI_SCHEMA",
13417
+ chart_type=chart_type,
13418
+ message=f"field '{raw}' was not found in QingBI datasource fields for this app",
13419
+ expected="chart fields must come from app_get_fields.chart_fields, not record schema or form-only fields",
13420
+ actual={"selector": raw, "role": role},
13421
+ next_action="Call app_get_fields and choose a field from chart_fields; if the system field is absent there, QingBI cannot use it for this report.",
13422
+ )
13423
+ if len(candidates) > 1:
13424
+ _raise_chart_rule(
13425
+ rule_code="CHART_FIELD_AMBIGUOUS",
13426
+ chart_type=chart_type,
13427
+ message=f"field '{raw}' matched multiple QingBI datasource fields",
13428
+ expected="use an unambiguous field selector such as bi_field_id or field_<queId>",
13429
+ actual={"selector": raw, "role": role, "candidate_count": len(candidates)},
13430
+ next_action="Use one of the returned candidate bi_field_id values or field_<queId> selectors.",
13431
+ offending_fields=[_chart_field_summary(item) for item in candidates],
13432
+ )
13433
+ return deepcopy(candidates[0])
13434
+
13435
+
13436
+ def _check_chart_slot_duplicates(*, chart_type: str, payload: dict[str, Any], slot_names: list[str]) -> None:
13437
+ for slot_name in slot_names:
13438
+ duplicates = _chart_duplicate_fields(_chart_fields(payload, slot_name))
13439
+ if duplicates:
13440
+ _raise_chart_rule(
13441
+ rule_code="CHART_FIELD_ID_REPEAT",
13442
+ chart_type=chart_type,
13443
+ message=f"{chart_type} chart has duplicate field ids in {slot_name}",
13444
+ expected=f"{slot_name} must not contain duplicated fieldId values",
13445
+ actual={"slot": slot_name, "duplicate_count": len(duplicates)},
13446
+ offending_fields=duplicates,
13447
+ next_action="Use different fields for this slot, or remove the duplicated field before retrying.",
13448
+ )
13449
+
13450
+
13451
+ def _histogram_metric_issue(metric: dict[str, Any]) -> dict[str, Any] | None:
13452
+ field_id = _chart_field_id(metric)
13453
+ if field_id == _QINGBI_TOTAL_FIELD_ID:
13454
+ return {
13455
+ "rule_code": "HISTOGRAM_DEFAULT_TOTAL_METRIC_UNSUPPORTED",
13456
+ "message": "histogram cannot use 数据总量 as its metric",
13457
+ "next_action": "Pass one explicit numeric field in indicator_field_ids and set config.aggregate such as sum/avg.",
13458
+ }
13459
+ field_type = str(metric.get("fieldType") or metric.get("field_type") or "").strip().lower()
13460
+ if field_type not in _QINGBI_DECIMAL_FIELD_TYPES:
13461
+ return {
13462
+ "rule_code": "HISTOGRAM_METRIC_FIELD_TYPE_UNSUPPORTED",
13463
+ "message": "histogram metric must be a numeric field",
13464
+ "next_action": "Choose one number/amount field as indicator_field_ids for histogram.",
13465
+ }
13466
+ field_source = str(metric.get("fieldSource") or metric.get("field_source") or "").strip().lower()
13467
+ bi_formula_type = str(metric.get("biFormulaType") or metric.get("bi_formula_type") or "").strip().lower()
13468
+ aggre_field_id = str(metric.get("aggreFieldId") or metric.get("aggre_field_id") or "").strip()
13469
+ if field_source == "formula" and (bi_formula_type in {"chart_agg", "agg"} or aggre_field_id):
13470
+ return {
13471
+ "rule_code": "HISTOGRAM_AGG_FORMULA_METRIC_UNSUPPORTED",
13472
+ "message": "histogram metric cannot be an aggregate formula field",
13473
+ "next_action": "Choose a plain numeric field, not an aggregate formula field.",
13474
+ }
13475
+ return None
13476
+
13477
+
13478
+ def _validate_public_chart_payload_rules(payload: dict[str, Any]) -> None:
13479
+ chart_type = str(payload.get("chartType") or "").strip().lower()
13480
+ dimensions = _chart_fields(payload, "selectedDimensions")
13481
+ metrics = _chart_fields(payload, "selectedMetrics")
13482
+ _check_chart_slot_duplicates(
13483
+ chart_type=chart_type,
13484
+ payload=payload,
13485
+ slot_names=[
13486
+ "selectedDimensions",
13487
+ "selectedMetrics",
13488
+ "xDimensions",
13489
+ "yDimensions",
13490
+ "xMetrics",
13491
+ "yMetrics",
13492
+ "leftMetrics",
13493
+ "rightMetrics",
13494
+ ],
13495
+ )
13496
+
13497
+ if chart_type == "gauge":
13498
+ if dimensions:
13499
+ _raise_chart_rule(
13500
+ rule_code="GAUGE_DIMENSION_NOT_ALLOWED",
13501
+ chart_type=chart_type,
13502
+ message="gauge chart must not have dimensions",
13503
+ expected="0 dimensions",
13504
+ actual={"dimension_count": len(dimensions)},
13505
+ offending_fields=[_chart_field_summary(field) for field in dimensions],
13506
+ next_action="Remove dimension_field_ids for gauge. The CLI clears public dimensions, but custom selectedDimensions in config must also be removed.",
13507
+ )
13508
+ if len(metrics) != 2:
13509
+ _raise_chart_rule(
13510
+ rule_code="GAUGE_METRIC_COUNT_INVALID",
13511
+ chart_type=chart_type,
13512
+ message="gauge chart requires exactly two metrics",
13513
+ expected="exactly 2 non-duplicated metrics; one real metric plus 数据总量 is allowed",
13514
+ actual={"metric_count": len(metrics), "metric_field_ids": [_chart_field_id(field) for field in metrics]},
13515
+ offending_fields=[_chart_field_summary(field) for field in metrics],
13516
+ next_action="Pass two different indicator_field_ids, or pass one explicit real numeric metric so the CLI can pair it with 数据总量.",
13517
+ )
13518
+ elif chart_type == "histogram":
13519
+ if len(dimensions) > 1:
13520
+ _raise_chart_rule(
13521
+ rule_code="HISTOGRAM_DIMENSION_COUNT_INVALID",
13522
+ chart_type=chart_type,
13523
+ message="histogram chart supports at most one dimension",
13524
+ expected="0 or 1 dimension",
13525
+ actual={"dimension_count": len(dimensions)},
13526
+ offending_fields=[_chart_field_summary(field) for field in dimensions],
13527
+ next_action="Keep at most one dimension_field_ids value for histogram.",
13528
+ )
13529
+ if len(metrics) != 1:
13530
+ _raise_chart_rule(
13531
+ rule_code="HISTOGRAM_METRIC_COUNT_INVALID",
13532
+ chart_type=chart_type,
13533
+ message="histogram chart requires exactly one explicit metric",
13534
+ expected="exactly 1 plain numeric metric",
13535
+ actual={"metric_count": len(metrics), "metric_field_ids": [_chart_field_id(field) for field in metrics]},
13536
+ offending_fields=[_chart_field_summary(field) for field in metrics],
13537
+ next_action="Pass exactly one numeric field in indicator_field_ids; histogram cannot rely on the default count metric.",
13538
+ )
13539
+ issue = _histogram_metric_issue(metrics[0])
13540
+ if issue:
13541
+ _raise_chart_rule(
13542
+ rule_code=str(issue["rule_code"]),
13543
+ chart_type=chart_type,
13544
+ message=str(issue["message"]),
13545
+ expected="one plain decimal metric; not 数据总量 and not aggregate formula",
13546
+ actual={"metric": _chart_field_summary(metrics[0])},
13547
+ offending_fields=[_chart_field_summary(metrics[0])],
13548
+ next_action=str(issue["next_action"]),
13549
+ )
13550
+ elif chart_type == "heatmap":
13551
+ if len(dimensions) != 2 or len(metrics) != 1:
13552
+ _raise_chart_rule(
13553
+ rule_code="HEATMAP_FIELD_COUNT_INVALID",
13554
+ chart_type=chart_type,
13555
+ message="heatmap chart requires two dimensions and one metric",
13556
+ expected="2 dimensions and 1 metric",
13557
+ actual={"dimension_count": len(dimensions), "metric_count": len(metrics)},
13558
+ next_action="Pass exactly two dimension_field_ids and one indicator_field_ids value for heatmap.",
13559
+ )
13560
+ elif chart_type == "waterfall":
13561
+ if len(dimensions) != 1 or len(metrics) != 1:
13562
+ _raise_chart_rule(
13563
+ rule_code="WATERFALL_FIELD_COUNT_INVALID",
13564
+ chart_type=chart_type,
13565
+ message="waterfall chart requires one dimension and one metric",
13566
+ expected="1 dimension and 1 metric",
13567
+ actual={"dimension_count": len(dimensions), "metric_count": len(metrics)},
13568
+ next_action="Pass exactly one dimension_field_ids value and one indicator_field_ids value for waterfall.",
13569
+ )
13570
+ elif chart_type == "treemap":
13571
+ if len(dimensions) < 1 or len(dimensions) > 2 or len(metrics) != 1:
13572
+ _raise_chart_rule(
13573
+ rule_code="TREEMAP_FIELD_COUNT_INVALID",
13574
+ chart_type=chart_type,
13575
+ message="treemap chart requires one or two dimensions and one metric",
13576
+ expected="1-2 dimensions and 1 metric",
13577
+ actual={"dimension_count": len(dimensions), "metric_count": len(metrics)},
13578
+ next_action="Pass one or two dimension_field_ids values and exactly one indicator_field_ids value for treemap.",
13579
+ )
13580
+ elif chart_type == "map":
13581
+ if len(dimensions) != 1 or len(metrics) != 1:
13582
+ _raise_chart_rule(
13583
+ rule_code="MAP_FIELD_COUNT_INVALID",
13584
+ chart_type=chart_type,
13585
+ message="map chart requires one dimension and one metric",
13586
+ expected="1 dimension and 1 metric",
13587
+ actual={"dimension_count": len(dimensions), "metric_count": len(metrics)},
13588
+ next_action="Pass exactly one location/address dimension and one metric for map.",
13589
+ )
13590
+ elif chart_type == "scatter":
13591
+ x_metrics = _chart_fields(payload, "xMetrics")
13592
+ y_metrics = _chart_fields(payload, "yMetrics")
13593
+ if not dimensions or len(x_metrics) != 1 or len(y_metrics) != 1:
13594
+ _raise_chart_rule(
13595
+ rule_code="SCATTER_FIELD_COUNT_INVALID",
13596
+ chart_type=chart_type,
13597
+ message="scatter chart requires at least one dimension, one x metric, and one y metric",
13598
+ expected=">=1 dimensions, exactly 1 x metric and 1 y metric",
13599
+ actual={"dimension_count": len(dimensions), "x_metric_count": len(x_metrics), "y_metric_count": len(y_metrics)},
13600
+ next_action="Pass at least one dimension_field_ids value and one or two indicator_field_ids values for scatter.",
13601
+ )
13602
+ elif chart_type == "dualaxes":
13603
+ left_metrics = _chart_fields(payload, "leftMetrics")
13604
+ right_metrics = _chart_fields(payload, "rightMetrics")
13605
+ if not dimensions or (not left_metrics and not right_metrics):
13606
+ _raise_chart_rule(
13607
+ rule_code="DUALAXES_FIELD_COUNT_INVALID",
13608
+ chart_type=chart_type,
13609
+ message="dualaxes chart requires at least one dimension and at least one metric axis",
13610
+ expected=">=1 dimensions and at least one left/right metric",
13611
+ actual={"dimension_count": len(dimensions), "left_metric_count": len(left_metrics), "right_metric_count": len(right_metrics)},
13612
+ next_action="Pass at least one dimension_field_ids value and one or two indicator_field_ids values for dualaxes.",
13613
+ )
13614
+
13615
+
13616
+ def _explain_chart_backend_validation_error(
13617
+ *,
13618
+ api_error: QingflowApiError,
13619
+ chart_type: str,
13620
+ payload: dict[str, Any] | None,
13621
+ ) -> dict[str, Any] | None:
13622
+ backend_code = api_error.backend_code
13623
+ if backend_code not in {81002, 81005}:
13624
+ return None
13625
+ chart_type = str(chart_type or (payload or {}).get("chartType") or "").strip().lower()
13626
+ if isinstance(payload, dict):
13627
+ try:
13628
+ _validate_public_chart_payload_rules(payload)
13629
+ except ChartRuleViolation as violation:
13630
+ return violation.diagnostics
13631
+ if backend_code == 81005:
13632
+ duplicate_fields: list[dict[str, Any]] = []
13633
+ if isinstance(payload, dict):
13634
+ for slot_name in [
13635
+ "selectedDimensions",
13636
+ "selectedMetrics",
13637
+ "xDimensions",
13638
+ "yDimensions",
13639
+ "xMetrics",
13640
+ "yMetrics",
13641
+ "leftMetrics",
13642
+ "rightMetrics",
13643
+ ]:
13644
+ duplicate_fields.extend(_chart_duplicate_fields(_chart_fields(payload, slot_name)))
13645
+ return _chart_rule_diagnostics(
13646
+ rule_code="CHART_FIELD_ID_REPEAT",
13647
+ chart_type=chart_type,
13648
+ message="QingBI rejected the chart because one field id is repeated in a chart slot",
13649
+ expected="field ids must be unique within each dimension/metric slot",
13650
+ actual={"backend_code": backend_code},
13651
+ offending_fields=duplicate_fields,
13652
+ next_action="Remove duplicated fields or pass two different explicit metrics before retrying.",
13653
+ )
13654
+ return _chart_rule_diagnostics(
13655
+ rule_code="WRONG_METRIC_COUNT_OR_TYPE",
13656
+ chart_type=chart_type,
13657
+ message="QingBI rejected the chart because metric count or metric type does not satisfy this chart type",
13658
+ expected="use the chart-type metric count/type rules from builder charts documentation",
13659
+ actual={"backend_code": backend_code},
13660
+ next_action="Check indicator_field_ids count and field types; for histogram use exactly one plain numeric metric, and for gauge use two non-duplicated metrics.",
13661
+ )
13662
+
13663
+
12720
13664
  def _build_public_metric_fields(
12721
13665
  selectors: list[str],
12722
13666
  *,
12723
13667
  app_key: str,
12724
13668
  field_lookup: dict[str, dict[str, Any]],
13669
+ chart_field_lookup: dict[str, Any],
12725
13670
  qingbi_fields_by_id: dict[str, dict[str, Any]],
12726
13671
  aggregate: str,
13672
+ chart_type: str = "chart",
12727
13673
  ) -> list[dict[str, Any]]:
12728
13674
  normalized_aggregate = str(aggregate or "count").strip().lower()
12729
13675
  if normalized_aggregate == "count" or not selectors:
12730
13676
  return [_default_public_total_metric()]
12731
13677
  metrics: list[dict[str, Any]] = []
12732
13678
  for selector in selectors:
12733
- field = _resolve_public_field(selector, field_lookup=field_lookup)
12734
- field_id = _bi_field_id_for_field(app_key=app_key, field=field, qingbi_fields_by_id=qingbi_fields_by_id)
12735
- qingbi_field = deepcopy(qingbi_fields_by_id.get(field_id, {}))
13679
+ qingbi_field = _resolve_qingbi_chart_field(selector, chart_field_lookup=chart_field_lookup, chart_type=chart_type, role="metric")
13680
+ field_id = _chart_field_id(qingbi_field)
13681
+ if field_id == _QINGBI_TOTAL_FIELD_ID:
13682
+ metrics.append(qingbi_field)
13683
+ continue
13684
+ form_field = qingbi_field.get("_public_form_field") if isinstance(qingbi_field.get("_public_form_field"), dict) else {}
12736
13685
  metrics.append(
12737
13686
  {
12738
13687
  "fieldId": field_id,
12739
- "fieldName": qingbi_field.get("fieldName") or field.get("name"),
12740
- "fieldType": qingbi_field.get("fieldType") or _qingbi_field_type_from_public_field(str(field.get("type") or "")),
13688
+ "fieldName": qingbi_field.get("fieldName") or form_field.get("name") or field_id,
13689
+ "fieldType": qingbi_field.get("fieldType") or _qingbi_field_type_from_public_field(str(form_field.get("type") or "")),
12741
13690
  "orderType": "default",
12742
13691
  "alignType": "left",
12743
13692
  "dateFormat": "yyyy-MM-dd",
@@ -12754,6 +13703,8 @@ def _build_public_metric_fields(
12754
13703
  "supId": qingbi_field.get("supId"),
12755
13704
  "beingTable": bool(qingbi_field.get("beingTable", False)),
12756
13705
  "returnType": qingbi_field.get("returnType"),
13706
+ "biFormulaType": qingbi_field.get("biFormulaType"),
13707
+ "aggreFieldId": qingbi_field.get("aggreFieldId"),
12757
13708
  }
12758
13709
  )
12759
13710
  return metrics or [_default_public_total_metric()]
@@ -12783,7 +13734,9 @@ def _build_public_chart_filter_matrix(
12783
13734
  *,
12784
13735
  app_key: str,
12785
13736
  field_lookup: dict[str, dict[str, Any]],
13737
+ chart_field_lookup: dict[str, Any],
12786
13738
  qingbi_fields_by_id: dict[str, dict[str, Any]],
13739
+ chart_type: str = "chart",
12787
13740
  ) -> list[list[dict[str, Any]]]:
12788
13741
  if not rules:
12789
13742
  return []
@@ -12799,16 +13752,21 @@ def _build_public_chart_filter_matrix(
12799
13752
  ViewFilterOperator.not_empty.value: 16,
12800
13753
  }
12801
13754
  for rule in rules:
12802
- field = _resolve_public_field(getattr(rule, "field_name", None), field_lookup=field_lookup)
12803
- field_id = _bi_field_id_for_field(app_key=app_key, field=field, qingbi_fields_by_id=qingbi_fields_by_id)
12804
- qingbi_field = deepcopy(qingbi_fields_by_id.get(field_id, {}))
13755
+ qingbi_field = _resolve_qingbi_chart_field(
13756
+ getattr(rule, "field_name", None),
13757
+ chart_field_lookup=chart_field_lookup,
13758
+ chart_type=chart_type,
13759
+ role="filter",
13760
+ )
13761
+ field_id = _chart_field_id(qingbi_field)
13762
+ form_field = qingbi_field.get("_public_form_field") if isinstance(qingbi_field.get("_public_form_field"), dict) else {}
12805
13763
  operator = str(getattr(rule, "operator", ViewFilterOperator.eq.value).value if hasattr(getattr(rule, "operator", None), "value") else getattr(rule, "operator", ViewFilterOperator.eq.value))
12806
13764
  values = list(getattr(rule, "values", []) or [])
12807
13765
  group.append(
12808
13766
  {
12809
13767
  "fieldId": field_id,
12810
- "fieldName": qingbi_field.get("fieldName") or field.get("name"),
12811
- "fieldType": qingbi_field.get("fieldType") or _qingbi_field_type_from_public_field(str(field.get("type") or "")),
13768
+ "fieldName": qingbi_field.get("fieldName") or form_field.get("name") or field_id,
13769
+ "fieldType": qingbi_field.get("fieldType") or _qingbi_field_type_from_public_field(str(form_field.get("type") or "")),
12812
13770
  "judgeType": judge_map.get(operator, 0),
12813
13771
  "judgeValues": values,
12814
13772
  "matchType": 1,
@@ -12822,6 +13780,7 @@ def _build_public_chart_config_payload(
12822
13780
  patch: ChartUpsertPatch,
12823
13781
  app_key: str,
12824
13782
  field_lookup: dict[str, dict[str, Any]],
13783
+ chart_field_lookup: dict[str, Any],
12825
13784
  qingbi_fields_by_id: dict[str, dict[str, Any]],
12826
13785
  ) -> dict[str, Any]:
12827
13786
  config = deepcopy(patch.config)
@@ -12840,12 +13799,19 @@ def _build_public_chart_config_payload(
12840
13799
  patch.filters,
12841
13800
  app_key=app_key,
12842
13801
  field_lookup=field_lookup,
13802
+ chart_field_lookup=chart_field_lookup,
12843
13803
  qingbi_fields_by_id=qingbi_fields_by_id,
13804
+ chart_type=patch.chart_type.value,
12844
13805
  )
12845
13806
  query_condition_field_ids = []
12846
13807
  for selector in list(config.pop("query_condition_field_ids", []) or []):
12847
- field = _resolve_public_field(selector, field_lookup=field_lookup)
12848
- query_condition_field_ids.append(_bi_field_id_for_field(app_key=app_key, field=field, qingbi_fields_by_id=qingbi_fields_by_id))
13808
+ field = _resolve_qingbi_chart_field(
13809
+ selector,
13810
+ chart_field_lookup=chart_field_lookup,
13811
+ chart_type=patch.chart_type.value,
13812
+ role="query_condition",
13813
+ )
13814
+ query_condition_field_ids.append(_chart_field_id(field))
12849
13815
  backend_chart_type = _map_public_chart_type_to_backend(patch.chart_type)
12850
13816
  if backend_chart_type == "gauge" and not patch.indicator_field_ids and "selectedMetrics" not in config:
12851
13817
  raise ValueError("gauge charts require at least one indicator_field_ids value; pass one metric and the CLI will pair it with 数据总量")
@@ -12853,14 +13819,18 @@ def _build_public_chart_config_payload(
12853
13819
  patch.dimension_field_ids,
12854
13820
  app_key=app_key,
12855
13821
  field_lookup=field_lookup,
13822
+ chart_field_lookup=chart_field_lookup,
12856
13823
  qingbi_fields_by_id=qingbi_fields_by_id,
13824
+ chart_type=patch.chart_type.value,
12857
13825
  )
12858
13826
  selected_metrics = _build_public_metric_fields(
12859
13827
  patch.indicator_field_ids,
12860
13828
  app_key=app_key,
12861
13829
  field_lookup=field_lookup,
13830
+ chart_field_lookup=chart_field_lookup,
12862
13831
  qingbi_fields_by_id=qingbi_fields_by_id,
12863
13832
  aggregate=aggregate,
13833
+ chart_type=patch.chart_type.value,
12864
13834
  )
12865
13835
  payload: dict[str, Any] = {
12866
13836
  "chartName": patch.name,
@@ -12914,6 +13884,7 @@ def _build_public_chart_config_payload(
12914
13884
  if key in config:
12915
13885
  payload[key] = deepcopy(config.pop(key))
12916
13886
  payload.update(config)
13887
+ _validate_public_chart_payload_rules(payload)
12917
13888
  return payload
12918
13889
 
12919
13890
 
@@ -13089,7 +14060,9 @@ def _find_chart_by_name(items: Any, *, chart_name: str, chart_type: str | None =
13089
14060
  return deepcopy(candidates[-1])
13090
14061
 
13091
14062
 
13092
- def _portal_position_payload(position: Any) -> dict[str, Any]:
14063
+ def _portal_position_payload(position: Any, *, inferred_mobile_y: int = 0) -> dict[str, Any]:
14064
+ mobile_provided = bool(getattr(position, "mobile_provided", False))
14065
+ mobile_rows = int(getattr(position, "mobile_h", 8)) if mobile_provided else int(getattr(position, "pc_h", 8))
13093
14066
  return {
13094
14067
  "pc": {
13095
14068
  "x": int(getattr(position, "pc_x", 0)),
@@ -13098,10 +14071,10 @@ def _portal_position_payload(position: Any) -> dict[str, Any]:
13098
14071
  "rows": int(getattr(position, "pc_h", 8)),
13099
14072
  },
13100
14073
  "mobile": {
13101
- "x": int(getattr(position, "mobile_x", 0)),
13102
- "y": int(getattr(position, "mobile_y", 0)),
13103
- "cols": int(getattr(position, "mobile_w", 12)),
13104
- "rows": int(getattr(position, "mobile_h", 8)),
14074
+ "x": int(getattr(position, "mobile_x", 0)) if mobile_provided else 0,
14075
+ "y": int(getattr(position, "mobile_y", 0)) if mobile_provided else int(inferred_mobile_y),
14076
+ "cols": int(getattr(position, "mobile_w", 6)) if mobile_provided else 6,
14077
+ "rows": mobile_rows,
13105
14078
  },
13106
14079
  }
13107
14080
 
@@ -13113,6 +14086,7 @@ def _portal_component_position_public(
13113
14086
  pc_y: int,
13114
14087
  pc_row_height: int,
13115
14088
  mobile_y: int,
14089
+ layout_preset: str | None = None,
13116
14090
  ) -> tuple[dict[str, Any], int, int, int, int]:
13117
14091
  source_name = str(source_type or "").lower()
13118
14092
  if source_name == "filter":
@@ -13131,8 +14105,11 @@ def _portal_component_position_public(
13131
14105
  cols = 12
13132
14106
  rows = 2
13133
14107
  else:
13134
- cols = 8
13135
- rows = 4
14108
+ if layout_preset == "dashboard_2col":
14109
+ cols = 12
14110
+ else:
14111
+ cols = 8
14112
+ rows = 6
13136
14113
  if cols == 24:
13137
14114
  if pc_x != 0:
13138
14115
  pc_y += pc_row_height
@@ -13155,6 +14132,73 @@ def _portal_component_position_public(
13155
14132
  return position, next_pc_x, next_pc_y, next_row_height, mobile_y + rows
13156
14133
 
13157
14134
 
14135
+ def _empty_portal_layout_diagnostics() -> dict[str, Any]:
14136
+ return {
14137
+ "pc_grid_columns": 24,
14138
+ "mobile_grid_columns": 6,
14139
+ "section_count": 0,
14140
+ "explicit_position_count": 0,
14141
+ "max_pc_right": None,
14142
+ "safe_for_display": True,
14143
+ "warnings": [],
14144
+ }
14145
+
14146
+
14147
+ def _portal_layout_diagnostics(sections: list[PortalSectionPatch], components: list[dict[str, Any]]) -> dict[str, Any]:
14148
+ diagnostics = _empty_portal_layout_diagnostics()
14149
+ diagnostics["section_count"] = len(sections)
14150
+ explicit_count = sum(1 for section in sections if section.position is not None)
14151
+ diagnostics["explicit_position_count"] = explicit_count
14152
+ pc_positions: list[dict[str, Any]] = []
14153
+ warnings: list[dict[str, Any]] = []
14154
+ for index, component in enumerate(components):
14155
+ if not isinstance(component, dict):
14156
+ continue
14157
+ position = component.get("position") if isinstance(component.get("position"), dict) else {}
14158
+ pc = position.get("pc") if isinstance(position.get("pc"), dict) else {}
14159
+ if pc:
14160
+ pc_positions.append(pc)
14161
+ section = sections[index] if index < len(sections) else None
14162
+ source_type = str(getattr(section, "source_type", "") or "").lower() if section is not None else ""
14163
+ title = str(getattr(section, "title", "") or "").strip() if section is not None else None
14164
+ cols = int(pc.get("cols") or 0)
14165
+ rows = int(pc.get("rows") or 0)
14166
+ if source_type == "chart" and (cols < 8 or rows < 5):
14167
+ warnings.append(_warning(
14168
+ "PORTAL_CHART_CARD_TOO_SMALL",
14169
+ "chart portal card is too small; use at least pc.cols >= 8 and pc.rows >= 5, preferably rows >= 6",
14170
+ section_index=index,
14171
+ title=title,
14172
+ pc=deepcopy(pc),
14173
+ ))
14174
+ if section is not None and section.position is not None and not bool(getattr(section.position, "mobile_provided", False)):
14175
+ warnings.append(_warning(
14176
+ "PORTAL_MOBILE_POSITION_MISSING",
14177
+ "pc position was provided without mobile position; mobile layout was generated with 6-column grid",
14178
+ section_index=index,
14179
+ title=title,
14180
+ ))
14181
+ max_right = None
14182
+ if pc_positions:
14183
+ max_right = max(int(position.get("x") or 0) + int(position.get("cols") or 0) for position in pc_positions)
14184
+ diagnostics["max_pc_right"] = max_right
14185
+ if len(pc_positions) > 1 and max_right is not None and max_right <= 12:
14186
+ warnings.append(_warning(
14187
+ "PORTAL_LAYOUT_HALF_WIDTH",
14188
+ "portal components only occupy the left half of the 24-column pc grid; this looks like a 12-column layout was used",
14189
+ max_pc_right=max_right,
14190
+ fix_hint="Use x=0/12 with cols=12 for two columns, x=0/8/16 with cols=8 for three columns, or omit position/use layout_preset.",
14191
+ ))
14192
+ diagnostics["warnings"] = warnings
14193
+ diagnostics["safe_for_display"] = not any(item.get("code") in {"PORTAL_LAYOUT_HALF_WIDTH", "PORTAL_CHART_CARD_TOO_SMALL"} for item in warnings)
14194
+ return diagnostics
14195
+
14196
+
14197
+ def _portal_layout_warning_items(layout_diagnostics: dict[str, Any]) -> list[dict[str, Any]]:
14198
+ warnings = layout_diagnostics.get("warnings") if isinstance(layout_diagnostics, dict) else None
14199
+ return [deepcopy(item) for item in warnings if isinstance(item, dict)] if isinstance(warnings, list) else []
14200
+
14201
+
13158
14202
  def _resolve_chart_reference(*, charts: QingbiReportTools, profile: str, ref: Any) -> dict[str, Any]:
13159
14203
  app_key = str(getattr(ref, "app_key", "") or "").strip()
13160
14204
  chart_id = str(getattr(ref, "chart_id", "") or "").strip()
@@ -16768,13 +17812,38 @@ def _publish_verify_warnings(*, package_attached: bool | None, views_unavailable
16768
17812
  return warnings
16769
17813
 
16770
17814
 
16771
- def _chart_apply_warnings(*, failed_items: list[dict[str, Any]], readback_unavailable: bool, verified: bool) -> list[dict[str, Any]]:
17815
+ def _chart_apply_warnings(
17816
+ *,
17817
+ failed_items: list[dict[str, Any]],
17818
+ readback_unavailable: bool,
17819
+ verified: bool,
17820
+ delete_readback_issues: list[dict[str, Any]] | None = None,
17821
+ ) -> list[dict[str, Any]]:
16772
17822
  warnings: list[dict[str, Any]] = []
17823
+ delete_readback_issues = delete_readback_issues or []
16773
17824
  if failed_items:
16774
17825
  warnings.append(_warning("CHART_OPERATION_FAILED", "one or more chart operations failed", failed_count=len(failed_items)))
17826
+ still_exists = [item for item in delete_readback_issues if item.get("readback_status") == "still_exists"]
17827
+ unavailable = [item for item in delete_readback_issues if item.get("readback_status") == "unavailable"]
17828
+ if still_exists:
17829
+ warnings.append(
17830
+ _warning(
17831
+ "CHART_DELETE_READBACK_STILL_EXISTS",
17832
+ "one or more delete requests completed, but chart_id readback still found the chart",
17833
+ chart_ids=[item.get("chart_id") for item in still_exists if item.get("chart_id")],
17834
+ )
17835
+ )
17836
+ if unavailable:
17837
+ warnings.append(
17838
+ _warning(
17839
+ "CHART_DELETE_READBACK_UNAVAILABLE",
17840
+ "one or more delete requests completed, but chart_id readback was unavailable",
17841
+ chart_ids=[item.get("chart_id") for item in unavailable if item.get("chart_id")],
17842
+ )
17843
+ )
16775
17844
  if readback_unavailable:
16776
17845
  warnings.append(_warning("CHART_READBACK_PENDING", "chart readback is unavailable after apply"))
16777
- elif not verified and not failed_items:
17846
+ elif not verified and not failed_items and not delete_readback_issues:
16778
17847
  warnings.append(_warning("CHART_VERIFICATION_INCOMPLETE", "chart apply completed but verification is incomplete"))
16779
17848
  return warnings
16780
17849
 
@@ -17273,6 +18342,56 @@ def _package_resource_signature(items: Any, *, public: bool) -> tuple[tuple[str,
17273
18342
  return tuple(sorted(_flatten_package_resource_identities(items, public=public)))
17274
18343
 
17275
18344
 
18345
+ def _publicize_package_list_item(item: dict[str, Any]) -> JSONObject:
18346
+ package_id = _coerce_positive_int(item.get("package_id") or item.get("packageId") or item.get("tag_id") or item.get("tagId"))
18347
+ raw_package_id = package_id if package_id is not None else item.get("package_id") or item.get("packageId") or item.get("tag_id") or item.get("tagId")
18348
+ package_name = str(item.get("package_name") or item.get("packageName") or item.get("tag_name") or item.get("tagName") or "").strip()
18349
+ raw_items = item.get("tagItems") if isinstance(item.get("tagItems"), list) else None
18350
+ item_count = None
18351
+ raw_item_count = item.get("item_count") if "item_count" in item else item.get("itemCount")
18352
+ try:
18353
+ if raw_item_count is not None:
18354
+ coerced_count = int(raw_item_count)
18355
+ if coerced_count >= 0:
18356
+ item_count = coerced_count
18357
+ except (TypeError, ValueError):
18358
+ item_count = None
18359
+ if item_count is None and raw_items is not None:
18360
+ item_count = len(raw_items)
18361
+ tag_icon = item.get("tag_icon") if "tag_icon" in item else item.get("tagIcon")
18362
+ return {
18363
+ "package_id": raw_package_id,
18364
+ "package_name": package_name,
18365
+ "tag_id": raw_package_id,
18366
+ "tag_name": package_name,
18367
+ "publish_status": item.get("publish_status") if "publish_status" in item else item.get("publishStatus"),
18368
+ "being_trial": item.get("being_trial") if "being_trial" in item else item.get("beingTrial"),
18369
+ "item_count": item_count,
18370
+ "item_preview": deepcopy(item.get("item_preview") if "item_preview" in item else item.get("itemPreview") or []),
18371
+ "tag_icon": tag_icon,
18372
+ "icon_config": workspace_icon_config(str(tag_icon).strip() if tag_icon not in (None, "") else None),
18373
+ "permissions": {
18374
+ "can_add_app": item.get("can_add_app") if "can_add_app" in item else item.get("addAppStatus"),
18375
+ "can_edit_app": item.get("can_edit_app") if "can_edit_app" in item else item.get("editAppStatus"),
18376
+ "can_delete_app": item.get("can_delete_app") if "can_delete_app" in item else item.get("delAppStatus"),
18377
+ "can_edit_package": item.get("can_edit_package") if "can_edit_package" in item else item.get("editTagStatus"),
18378
+ },
18379
+ }
18380
+
18381
+
18382
+ def _package_list_item_matches_query(item: dict[str, Any], query: str) -> bool:
18383
+ needle = str(query or "").strip().casefold()
18384
+ if not needle:
18385
+ return True
18386
+ haystacks = (
18387
+ item.get("package_id"),
18388
+ item.get("tag_id"),
18389
+ item.get("package_name"),
18390
+ item.get("tag_name"),
18391
+ )
18392
+ return any(needle in str(value or "").casefold() for value in haystacks)
18393
+
18394
+
17276
18395
  def _backend_package_items_from_public_items(items: list[dict[str, Any]], group_ids_by_path: dict[tuple[int, ...], int], *, path: tuple[int, ...] = ()) -> list[JSONObject]:
17277
18396
  backend_items: list[JSONObject] = []
17278
18397
  for index, item in enumerate(items):
@@ -19785,6 +20904,7 @@ def _normalize_portal_list_items(raw_items: Any) -> list[dict[str, Any]]:
19785
20904
  "dash_key": dash_key or None,
19786
20905
  "dash_name": dash_name or None,
19787
20906
  "dash_icon": dash_icon,
20907
+ "icon_config": workspace_icon_config(dash_icon),
19788
20908
  "package_tag_ids": package_tag_ids,
19789
20909
  }
19790
20910
  )