@josephyan/qingflow-app-builder-mcp 1.1.26 → 1.1.27

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.
@@ -11,7 +11,7 @@ import re
11
11
  import string
12
12
  import time
13
13
  import tempfile
14
- from typing import Any, cast
14
+ from typing import Any, NoReturn, cast
15
15
  from urllib.parse import quote_plus, unquote_plus
16
16
  from uuid import uuid4
17
17
 
@@ -49,6 +49,7 @@ from .models import (
49
49
  AssociatedResourceUpsertPatch,
50
50
  AssociatedResourceViewConfigPatch,
51
51
  ChartApplyRequest,
52
+ ChartMetricPatch,
52
53
  ChartPartialPatch,
53
54
  ChartUpsertPatch,
54
55
  CustomButtonsApplyRequest,
@@ -5238,6 +5239,20 @@ class AiBuilderFacade:
5238
5239
  result["message"] = "read app chart config"
5239
5240
  return result
5240
5241
 
5242
+ def _chart_filter_field_names_by_id(
5243
+ self,
5244
+ *,
5245
+ profile: str,
5246
+ app_key: str,
5247
+ ) -> tuple[dict[str, str], dict[str, Any] | None]:
5248
+ try:
5249
+ state = self._load_base_schema_state(profile=profile, app_key=app_key)
5250
+ except (QingflowApiError, RuntimeError) as error:
5251
+ api_error = _coerce_api_error(error)
5252
+ return {}, _transport_error_payload(api_error)
5253
+ fields = cast(list[dict[str, Any]], state["parsed"]["fields"])
5254
+ return _chart_field_names_by_id_from_public_fields(app_key=app_key, fields=fields), None
5255
+
5241
5256
  def app_get_buttons(self, *, profile: str, app_key: str) -> JSONObject:
5242
5257
  """Read custom buttons for a single app."""
5243
5258
  self.apps._require_app_key(app_key)
@@ -5796,6 +5811,8 @@ class AiBuilderFacade:
5796
5811
  )
5797
5812
  charts = _summarize_charts(items)
5798
5813
  chart_visibility_read_errors: list[dict[str, Any]] = []
5814
+ chart_config_read_errors: list[dict[str, Any]] = []
5815
+ field_name_by_id, field_name_read_error = self._chart_filter_field_names_by_id(profile=profile, app_key=resolved_app_key)
5799
5816
  for chart in charts:
5800
5817
  chart_id = str(chart.get("chart_id") or "").strip()
5801
5818
  if not chart_id:
@@ -5818,6 +5835,24 @@ class AiBuilderFacade:
5818
5835
  base_info.get("visibleAuth") if isinstance(base_info, dict) else None
5819
5836
  )
5820
5837
  )
5838
+ try:
5839
+ config_response = self.charts.qingbi_report_get_config(profile=profile, chart_id=chart_id)
5840
+ config = config_response.get("result") or {}
5841
+ except (QingflowApiError, RuntimeError) as error:
5842
+ api_error = _coerce_api_error(error)
5843
+ chart_config_read_errors.append(
5844
+ {
5845
+ "chart_id": chart_id,
5846
+ "request_id": api_error.request_id,
5847
+ "http_status": api_error.http_status,
5848
+ "backend_code": api_error.backend_code,
5849
+ }
5850
+ )
5851
+ continue
5852
+ if isinstance(config, dict):
5853
+ chart["group_by"] = _public_chart_group_by_from_qingbi_config(config)
5854
+ chart["metrics"] = _public_chart_metrics_from_qingbi_config(config)
5855
+ chart["filters"] = _public_chart_filter_groups_from_qingbi_config(config, field_name_by_id=field_name_by_id)
5821
5856
  response = AppChartsReadResponse(
5822
5857
  app_key=resolved_app_key,
5823
5858
  charts=charts,
@@ -5831,7 +5866,11 @@ class AiBuilderFacade:
5831
5866
  "normalized_args": {"app_key": resolved_app_key},
5832
5867
  "missing_fields": [],
5833
5868
  "allowed_values": {},
5834
- "details": {"chart_visibility_read_errors": chart_visibility_read_errors} if chart_visibility_read_errors else {},
5869
+ "details": {
5870
+ **({"chart_visibility_read_errors": chart_visibility_read_errors} if chart_visibility_read_errors else {}),
5871
+ **({"chart_config_read_errors": chart_config_read_errors} if chart_config_read_errors else {}),
5872
+ **({"chart_filter_field_name_read_error": field_name_read_error} if field_name_read_error else {}),
5873
+ },
5835
5874
  "request_id": None,
5836
5875
  "suggested_next_call": None,
5837
5876
  "noop": False,
@@ -5842,12 +5881,24 @@ class AiBuilderFacade:
5842
5881
  if chart_visibility_read_errors
5843
5882
  else []
5844
5883
  )
5884
+ + (
5885
+ [_warning("CHART_CONFIG_READ_PARTIAL", "some chart configs could not be read back; metrics/group_by/filters are incomplete for those charts")]
5886
+ if chart_config_read_errors
5887
+ else []
5888
+ )
5889
+ + (
5890
+ [_warning("CHART_FILTER_FIELD_NAMES_UNRESOLVED", "chart configs were read, but form fields could not be loaded to resolve filter field names")]
5891
+ if field_name_read_error
5892
+ else []
5893
+ )
5845
5894
  ),
5846
5895
  "verification": {
5847
5896
  "app_exists": True,
5848
5897
  "chart_order_verified": list_source == "sorted",
5849
5898
  "chart_list_source": list_source,
5850
5899
  "chart_visibility_readback_complete": not chart_visibility_read_errors,
5900
+ "chart_config_readback_complete": not chart_config_read_errors,
5901
+ "chart_filter_field_names_resolved": field_name_read_error is None,
5851
5902
  },
5852
5903
  "verified": True,
5853
5904
  **response.model_dump(mode="json"),
@@ -6877,10 +6928,27 @@ class AiBuilderFacade:
6877
6928
  suggested_next_call={"tool_name": "chart_get", "arguments": {"profile": profile, "chart_id": chart_id}},
6878
6929
  )
6879
6930
 
6931
+ field_name_by_id: dict[str, str] = {}
6932
+ data_source = config.get("dataSource") if isinstance(config.get("dataSource"), dict) else {}
6933
+ data_source_app_key = str(data_source.get("dataSourceId") or config.get("dataSourceId") or "").strip()
6934
+ if data_source_app_key:
6935
+ field_name_by_id, field_name_error = self._chart_filter_field_names_by_id(profile=profile, app_key=data_source_app_key)
6936
+ if field_name_error:
6937
+ warnings.append(
6938
+ _warning(
6939
+ "CHART_FILTER_FIELD_NAMES_UNRESOLVED",
6940
+ "chart config was read, but form fields could not be loaded to resolve filter field names",
6941
+ **field_name_error,
6942
+ )
6943
+ )
6944
+
6880
6945
  response = ChartGetResponse(
6881
6946
  chart_id=chart_id,
6882
6947
  base=deepcopy(base) if isinstance(base, dict) else {},
6883
6948
  visibility=_public_visibility_from_chart_visible_auth(base.get("visibleAuth")),
6949
+ filters=_public_chart_filter_groups_from_qingbi_config(config, field_name_by_id=field_name_by_id) if isinstance(config, dict) else [],
6950
+ group_by=_public_chart_group_by_from_qingbi_config(config) if isinstance(config, dict) else [],
6951
+ metrics=_public_chart_metrics_from_qingbi_config(config) if isinstance(config, dict) else [],
6884
6952
  config=deepcopy(config) if isinstance(config, dict) else {},
6885
6953
  )
6886
6954
  return {
@@ -7380,6 +7448,8 @@ class AiBuilderFacade:
7380
7448
  add_fields: list[FieldPatch],
7381
7449
  update_fields: list[FieldUpdatePatch],
7382
7450
  remove_fields: list[FieldRemovePatch],
7451
+ layout_mode: LayoutApplyMode = LayoutApplyMode.merge,
7452
+ layout_sections: list[LayoutSectionPatch] | None = None,
7383
7453
  ) -> JSONObject:
7384
7454
  normalized_args = {
7385
7455
  "app_key": app_key,
@@ -7394,6 +7464,10 @@ class AiBuilderFacade:
7394
7464
  "update_fields": [patch.model_dump(mode="json") for patch in update_fields],
7395
7465
  "remove_fields": [patch.model_dump(mode="json") for patch in remove_fields],
7396
7466
  }
7467
+ layout_requested = layout_sections is not None
7468
+ requested_layout_sections = [section.model_dump(mode="json", exclude_none=True) for section in (layout_sections or [])]
7469
+ if layout_requested:
7470
+ normalized_args["form"] = {"mode": layout_mode.value, "sections": deepcopy(requested_layout_sections)}
7397
7471
  try:
7398
7472
  desired_auth = (
7399
7473
  self._compile_visibility_to_member_auth(profile=profile, visibility=visibility)
@@ -7409,7 +7483,7 @@ class AiBuilderFacade:
7409
7483
  suggested_next_call=None,
7410
7484
  )
7411
7485
  permission_outcomes: list[PermissionCheckOutcome] = []
7412
- requested_field_changes = bool(add_fields or update_fields or remove_fields)
7486
+ requested_field_changes = bool(add_fields or update_fields or remove_fields or layout_requested)
7413
7487
  resolved: JSONObject
7414
7488
  if app_key:
7415
7489
  resolved = self.app_resolve(profile=profile, app_key=app_key)
@@ -7533,7 +7607,6 @@ class AiBuilderFacade:
7533
7607
  "verification": {
7534
7608
  "fields_verified": True,
7535
7609
  "package_attached": None if package_tag_id is None else package_tag_id in target.tag_ids,
7536
- "relation_field_limit_verified": True,
7537
7610
  "app_visuals_verified": True,
7538
7611
  "app_base_verified": True,
7539
7612
  "publish_skipped": True,
@@ -7573,6 +7646,9 @@ class AiBuilderFacade:
7573
7646
  current_fields = parsed["fields"]
7574
7647
  original_fields = deepcopy(current_fields)
7575
7648
  layout = parsed["layout"]
7649
+ original_layout = deepcopy(layout)
7650
+ layout_write_requested = False
7651
+ applied_layout = deepcopy(layout)
7576
7652
  existing_index = {field["field_id"]: index for index, field in enumerate(current_fields)}
7577
7653
  selector_map = _build_selector_map(current_fields)
7578
7654
  added: list[str] = []
@@ -7727,6 +7803,71 @@ class AiBuilderFacade:
7727
7803
  suggested_next_call={"tool_name": "app_get_fields", "arguments": {"profile": profile, "app_key": target.app_key}},
7728
7804
  )
7729
7805
 
7806
+ if layout_requested:
7807
+ resolved_sections, missing_selectors = _resolve_layout_sections_to_names(requested_layout_sections, current_fields)
7808
+ normalized_args["form"] = {"mode": layout_mode.value, "sections": deepcopy(resolved_sections)}
7809
+ if missing_selectors:
7810
+ return _failed(
7811
+ "UNKNOWN_LAYOUT_FIELD",
7812
+ "form layout references unknown field selectors",
7813
+ normalized_args=normalized_args,
7814
+ details={"unknown_selectors": missing_selectors, "app_key": target.app_key},
7815
+ missing_fields=[str(item) for item in missing_selectors],
7816
+ suggested_next_call={"tool_name": "app_get_fields", "arguments": {"profile": profile, "app_key": target.app_key}},
7817
+ )
7818
+ fields_by_name = {field["name"]: field for field in current_fields}
7819
+ seen_layout_fields: list[str] = []
7820
+ for section in resolved_sections:
7821
+ for row in section.get("rows", []):
7822
+ for field_name in row:
7823
+ if field_name not in fields_by_name:
7824
+ return _failed(
7825
+ "UNKNOWN_LAYOUT_FIELD",
7826
+ f"form layout references unknown field '{field_name}'",
7827
+ normalized_args=normalized_args,
7828
+ details={"field_name": field_name, "app_key": target.app_key},
7829
+ suggested_next_call={"tool_name": "app_get_fields", "arguments": {"profile": profile, "app_key": target.app_key}},
7830
+ )
7831
+ if field_name in seen_layout_fields:
7832
+ return _failed(
7833
+ "DUPLICATE_LAYOUT_FIELD",
7834
+ f"form layout references field '{field_name}' more than once",
7835
+ normalized_args=normalized_args,
7836
+ details={"field_name": field_name, "app_key": target.app_key},
7837
+ suggested_next_call={"tool_name": "app_schema_apply", "arguments": {"profile": profile, **normalized_args}},
7838
+ )
7839
+ seen_layout_fields.append(field_name)
7840
+ expected_layout_fields = {field["name"] for field in current_fields}
7841
+ if layout_mode == LayoutApplyMode.replace and set(seen_layout_fields) != expected_layout_fields:
7842
+ missing = sorted(expected_layout_fields.difference(seen_layout_fields))
7843
+ return _failed(
7844
+ "INCOMPLETE_LAYOUT",
7845
+ "form layout must reference every current field exactly once in replace mode",
7846
+ normalized_args=normalized_args,
7847
+ details={
7848
+ "missing_fields": missing,
7849
+ "auto_fill_preview": _merge_layout(
7850
+ current_layout=layout,
7851
+ requested_sections=resolved_sections,
7852
+ all_field_names=[field["name"] for field in current_fields],
7853
+ )["layout"],
7854
+ },
7855
+ missing_fields=missing,
7856
+ suggested_next_call={"tool_name": "app_schema_apply", "arguments": {"profile": profile, **normalized_args}},
7857
+ )
7858
+ merged_layout = _merge_layout(
7859
+ current_layout=layout,
7860
+ requested_sections=resolved_sections,
7861
+ all_field_names=[field["name"] for field in current_fields],
7862
+ )
7863
+ applied_layout = (
7864
+ {"root_rows": [], "sections": resolved_sections}
7865
+ if layout_mode == LayoutApplyMode.replace
7866
+ else merged_layout["layout"]
7867
+ )
7868
+ layout_write_requested = not _layouts_equal(layout, applied_layout)
7869
+ layout = applied_layout
7870
+
7730
7871
  schema_write_requested = bool(
7731
7872
  added
7732
7873
  or updated
@@ -7734,6 +7875,7 @@ class AiBuilderFacade:
7734
7875
  or normalized_code_block_fields
7735
7876
  or data_display_selection.has_any
7736
7877
  or bool(resolved.get("created"))
7878
+ or layout_write_requested
7737
7879
  )
7738
7880
  if schema_write_requested:
7739
7881
  try:
@@ -7756,12 +7898,6 @@ class AiBuilderFacade:
7756
7898
  )
7757
7899
 
7758
7900
  relation_field_count = _count_relation_fields(current_fields)
7759
- relation_limit_verified = relation_field_count <= 1
7760
- relation_warnings = (
7761
- [_warning("RELATION_FIELD_LIMIT_RISK", "multiple relation fields are not a stable backend capability", relation_field_count=relation_field_count)]
7762
- if not relation_limit_verified
7763
- else []
7764
- )
7765
7901
  code_block_normalization_warnings = (
7766
7902
  [
7767
7903
  _warning(
@@ -7774,7 +7910,15 @@ class AiBuilderFacade:
7774
7910
  else []
7775
7911
  )
7776
7912
 
7777
- if not added and not updated and not removed and not normalized_code_block_fields and not data_display_selection.has_any and not bool(resolved.get("created")):
7913
+ if (
7914
+ not added
7915
+ and not updated
7916
+ and not removed
7917
+ and not normalized_code_block_fields
7918
+ and not data_display_selection.has_any
7919
+ and not bool(resolved.get("created"))
7920
+ and not layout_write_requested
7921
+ ):
7778
7922
  base_info = self.apps.app_get_base(profile=profile, app_key=target.app_key, include_raw=True).get("result") or {}
7779
7923
  tag_ids_after = _coerce_int_list(base_info.get("tagIds"))
7780
7924
  package_attached = None if package_tag_id is None else package_tag_id in tag_ids_after
@@ -7796,10 +7940,10 @@ class AiBuilderFacade:
7796
7940
  "request_id": None,
7797
7941
  "suggested_next_call": None if package_attached is not False else {"tool_name": "package_attach_app", "arguments": {"profile": profile, "tag_id": package_tag_id, "app_key": target.app_key}},
7798
7942
  "noop": not bool(visual_result.get("updated")),
7799
- "warnings": relation_warnings + code_block_normalization_warnings,
7943
+ "warnings": code_block_normalization_warnings,
7800
7944
  "verification": {
7801
7945
  "fields_verified": True,
7802
- "relation_field_limit_verified": relation_limit_verified,
7946
+ "form_layout_verified": True,
7803
7947
  "app_visuals_verified": app_base_verified,
7804
7948
  "app_base_verified": app_base_verified,
7805
7949
  "relation_target_metadata_verified": relation_target_metadata_verified,
@@ -7817,6 +7961,12 @@ class AiBuilderFacade:
7817
7961
  "package_attached": package_attached,
7818
7962
  }
7819
7963
  response["details"]["relation_field_count"] = relation_field_count
7964
+ if layout_requested:
7965
+ response["details"]["form_layout_diff"] = {
7966
+ "mode": layout_mode.value,
7967
+ "changed": False,
7968
+ "requested_section_count": len(requested_layout_sections),
7969
+ }
7820
7970
  if normalized_code_block_fields:
7821
7971
  response["normalized_code_block_output_assignment"] = True
7822
7972
  response["normalized_code_block_fields"] = normalized_code_block_fields
@@ -7850,26 +8000,6 @@ class AiBuilderFacade:
7850
8000
  self.apps.app_update_form_schema(profile=profile, app_key=target.app_key, payload=payload)
7851
8001
  except (QingflowApiError, RuntimeError) as error:
7852
8002
  api_error = _coerce_api_error(error)
7853
- if api_error.backend_code == 49614:
7854
- return _failed(
7855
- "MULTIPLE_RELATION_FIELDS_UNSUPPORTED",
7856
- "backend currently rejects apps with more than one relation field; keep one real relation field and use text/reference summary fields for additional cross-object links.",
7857
- normalized_args=normalized_args,
7858
- details={
7859
- "app_key": target.app_key,
7860
- "field_diff": {"added": added, "updated": updated, "removed": removed},
7861
- "relation_field_count": relation_field_count,
7862
- "transport_error": {
7863
- "http_status": api_error.http_status,
7864
- "backend_code": api_error.backend_code,
7865
- "category": api_error.category,
7866
- },
7867
- },
7868
- request_id=api_error.request_id,
7869
- backend_code=api_error.backend_code,
7870
- http_status=None if api_error.http_status == 404 else api_error.http_status,
7871
- suggested_next_call={"tool_name": "app_get_fields", "arguments": {"profile": profile, "app_key": target.app_key}},
7872
- )
7873
8003
  return _failed_from_api_error(
7874
8004
  "SCHEMA_APPLY_FAILED",
7875
8005
  api_error,
@@ -7993,13 +8123,13 @@ class AiBuilderFacade:
7993
8123
  "request_id": None,
7994
8124
  "suggested_next_call": None,
7995
8125
  "noop": False,
7996
- "warnings": relation_warnings + code_block_normalization_warnings,
8126
+ "warnings": code_block_normalization_warnings,
7997
8127
  "verification": {
7998
8128
  "fields_verified": False,
7999
8129
  "package_attached": None,
8000
8130
  "app_visuals_verified": True,
8001
8131
  "app_base_verified": True,
8002
- "relation_field_limit_verified": relation_limit_verified,
8132
+ "form_layout_verified": not layout_requested,
8003
8133
  "relation_target_metadata_verified": relation_target_metadata_verified,
8004
8134
  },
8005
8135
  "app_key": target.app_key,
@@ -8026,6 +8156,12 @@ class AiBuilderFacade:
8026
8156
  "package_attached": None,
8027
8157
  }
8028
8158
  response["details"]["relation_field_count"] = relation_field_count
8159
+ if layout_requested:
8160
+ response["details"]["form_layout_diff"] = {
8161
+ "mode": layout_mode.value,
8162
+ "changed": layout_write_requested,
8163
+ "requested_section_count": len(requested_layout_sections),
8164
+ }
8029
8165
  if normalized_code_block_fields:
8030
8166
  response["normalized_code_block_output_assignment"] = True
8031
8167
  response["normalized_code_block_fields"] = normalized_code_block_fields
@@ -8048,6 +8184,11 @@ class AiBuilderFacade:
8048
8184
  after_fields=cast(list[dict[str, Any]], verified["schema"]["fields"]),
8049
8185
  )
8050
8186
  verification_ok = all(name in verified_field_names for name in added + updated) and all(name not in verified_field_names for name in removed)
8187
+ if layout_requested:
8188
+ verified_layout = cast(dict[str, Any], verified.get("layout") or {"root_rows": [], "sections": []})
8189
+ layout_verified = _layouts_equal(verified_layout, applied_layout) or _layouts_semantically_equal(verified_layout, applied_layout)
8190
+ response["verification"]["form_layout_verified"] = layout_verified
8191
+ verification_ok = verification_ok and layout_verified
8051
8192
  data_display_verification = _verify_data_display_readback(
8052
8193
  form_settings=verified.get("form_settings"),
8053
8194
  selection=data_display_selection,
@@ -8091,7 +8232,6 @@ class AiBuilderFacade:
8091
8232
  response["verification"]["package_attached"] = package_attached
8092
8233
  response["verification"]["app_visuals_verified"] = app_base_verified
8093
8234
  response["verification"]["app_base_verified"] = app_base_verified
8094
- response["verification"]["relation_field_limit_verified"] = relation_limit_verified
8095
8235
  response["verified"] = verification_ok and app_base_verified
8096
8236
  response["tag_ids_after"] = tag_ids_after
8097
8237
  response["package_attached"] = package_attached
@@ -10304,6 +10444,7 @@ class AiBuilderFacade:
10304
10444
  )
10305
10445
  if existing is None:
10306
10446
  temp_chart_id = str(patch.chart_id or f"mcp_{uuid4().hex[:16]}")
10447
+ chart_id = temp_chart_id
10307
10448
  create_payload = {
10308
10449
  "chartId": temp_chart_id,
10309
10450
  "chartName": patch.name,
@@ -13693,7 +13834,18 @@ def _build_qingbi_chart_field_lookup(
13693
13834
  field_lookup: dict[str, dict[str, Any]],
13694
13835
  ) -> dict[str, Any]:
13695
13836
  by_selector: dict[str, list[dict[str, Any]]] = {}
13696
- form_by_que_id = field_lookup.get("by_que_id") or {}
13837
+ form_by_que_id = dict(field_lookup.get("by_que_id") or {})
13838
+ if not form_by_que_id:
13839
+ for bucket_name in ("by_name", "by_field_id"):
13840
+ bucket = field_lookup.get(bucket_name) or {}
13841
+ if not isinstance(bucket, dict):
13842
+ continue
13843
+ for form_field in bucket.values():
13844
+ if not isinstance(form_field, dict):
13845
+ continue
13846
+ que_id = _coerce_any_int(form_field.get("que_id"))
13847
+ if que_id is not None:
13848
+ form_by_que_id.setdefault(que_id, form_field)
13697
13849
 
13698
13850
  def add_selector(key: Any, field: dict[str, Any]) -> None:
13699
13851
  normalized = str(key or "").strip()
@@ -14082,37 +14234,80 @@ def _build_public_metric_fields(
14082
14234
  metrics: list[dict[str, Any]] = []
14083
14235
  for selector in selectors:
14084
14236
  qingbi_field = _resolve_qingbi_chart_field(selector, chart_field_lookup=chart_field_lookup, chart_type=chart_type, role="metric")
14085
- field_id = _chart_field_id(qingbi_field)
14086
- if field_id == _QINGBI_TOTAL_FIELD_ID:
14087
- metrics.append(qingbi_field)
14237
+ metrics.append(_public_qingbi_metric_field(qingbi_field, aggregate=normalized_aggregate))
14238
+ return metrics or [_default_public_total_metric()]
14239
+
14240
+
14241
+ def _public_qingbi_metric_field(qingbi_field: dict[str, Any], *, aggregate: str) -> dict[str, Any]:
14242
+ field_id = _chart_field_id(qingbi_field)
14243
+ if field_id == _QINGBI_TOTAL_FIELD_ID:
14244
+ return deepcopy(qingbi_field)
14245
+ form_field = qingbi_field.get("_public_form_field") if isinstance(qingbi_field.get("_public_form_field"), dict) else {}
14246
+ normalized_aggregate = str(aggregate or "sum").strip().lower()
14247
+ aggre_type = {"sum": "sum", "avg": "avg", "average": "avg", "max": "max", "min": "min"}.get(normalized_aggregate, "sum")
14248
+ return {
14249
+ "fieldId": field_id,
14250
+ "fieldName": qingbi_field.get("fieldName") or form_field.get("name") or field_id,
14251
+ "fieldType": qingbi_field.get("fieldType") or _qingbi_field_type_from_public_field(str(form_field.get("type") or "")),
14252
+ "orderType": "default",
14253
+ "alignType": "left",
14254
+ "dateFormat": "yyyy-MM-dd",
14255
+ "numberFormat": "default",
14256
+ "numberConfig": {"format": "splitter", "unit": "DEFAULT", "prefix": "", "suffix": "", "digit": None},
14257
+ "digit": None,
14258
+ "aggreType": aggre_type,
14259
+ "orderPriority": None,
14260
+ "width": None,
14261
+ "verticalAlign": "middle",
14262
+ "formula": qingbi_field.get("formula"),
14263
+ "fieldSource": qingbi_field.get("fieldSource") or "default",
14264
+ "status": qingbi_field.get("status"),
14265
+ "supId": qingbi_field.get("supId"),
14266
+ "beingTable": bool(qingbi_field.get("beingTable", False)),
14267
+ "returnType": qingbi_field.get("returnType"),
14268
+ "biFormulaType": qingbi_field.get("biFormulaType"),
14269
+ "aggreFieldId": qingbi_field.get("aggreFieldId"),
14270
+ }
14271
+
14272
+
14273
+ def _build_public_semantic_metric_fields(
14274
+ metrics: list[ChartMetricPatch],
14275
+ *,
14276
+ app_key: str,
14277
+ field_lookup: dict[str, dict[str, Any]],
14278
+ chart_field_lookup: dict[str, Any],
14279
+ qingbi_fields_by_id: dict[str, dict[str, Any]],
14280
+ chart_type: str = "chart",
14281
+ ) -> list[dict[str, Any]]:
14282
+ if not metrics:
14283
+ return [_default_public_total_metric()]
14284
+ selected_metrics: list[dict[str, Any]] = []
14285
+ for metric in metrics:
14286
+ op = str(metric.op or "count").strip().lower()
14287
+ field_name = str(metric.field_name or "").strip()
14288
+ if op == "count":
14289
+ if field_name:
14290
+ _raise_chart_rule(
14291
+ rule_code="CHART_COUNT_FIELD_UNSUPPORTED",
14292
+ chart_type=chart_type,
14293
+ message="count metric currently supports count(*) only",
14294
+ expected='Use metric: "count(*)" or {"op": "count"} for record count.',
14295
+ actual={"metric": metric.model_dump(mode="json")},
14296
+ next_action='Use count(*) for count cards; use sum(field), avg(field), max(field), or min(field) for field aggregation.',
14297
+ )
14298
+ selected_metrics.append(_default_public_total_metric())
14088
14299
  continue
14089
- form_field = qingbi_field.get("_public_form_field") if isinstance(qingbi_field.get("_public_form_field"), dict) else {}
14090
- metrics.append(
14091
- {
14092
- "fieldId": field_id,
14093
- "fieldName": qingbi_field.get("fieldName") or form_field.get("name") or field_id,
14094
- "fieldType": qingbi_field.get("fieldType") or _qingbi_field_type_from_public_field(str(form_field.get("type") or "")),
14095
- "orderType": "default",
14096
- "alignType": "left",
14097
- "dateFormat": "yyyy-MM-dd",
14098
- "numberFormat": "default",
14099
- "numberConfig": {"format": "splitter", "unit": "DEFAULT", "prefix": "", "suffix": "", "digit": None},
14100
- "digit": None,
14101
- "aggreType": {"sum": "sum", "avg": "avg", "average": "avg", "max": "max", "min": "min", "count": "sum", "distinct_count": "sum"}.get(normalized_aggregate, "sum"),
14102
- "orderPriority": None,
14103
- "width": None,
14104
- "verticalAlign": "middle",
14105
- "formula": qingbi_field.get("formula"),
14106
- "fieldSource": qingbi_field.get("fieldSource") or "default",
14107
- "status": qingbi_field.get("status"),
14108
- "supId": qingbi_field.get("supId"),
14109
- "beingTable": bool(qingbi_field.get("beingTable", False)),
14110
- "returnType": qingbi_field.get("returnType"),
14111
- "biFormulaType": qingbi_field.get("biFormulaType"),
14112
- "aggreFieldId": qingbi_field.get("aggreFieldId"),
14113
- }
14300
+ qingbi_field = _resolve_qingbi_chart_field(
14301
+ field_name,
14302
+ chart_field_lookup=chart_field_lookup,
14303
+ chart_type=chart_type,
14304
+ role="metric",
14114
14305
  )
14115
- return metrics or [_default_public_total_metric()]
14306
+ metric_payload = _public_qingbi_metric_field(qingbi_field, aggregate=op)
14307
+ if metric.alias:
14308
+ metric_payload["fieldName"] = metric.alias
14309
+ selected_metrics.append(metric_payload)
14310
+ return selected_metrics or [_default_public_total_metric()]
14116
14311
 
14117
14312
 
14118
14313
  def _split_axis_metric_fields(metrics: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
@@ -14134,73 +14329,6 @@ def _two_gauge_metric_fields(metrics: list[dict[str, Any]], *, requested_metric_
14134
14329
  return [deepcopy(metrics[0]), deepcopy(metrics[1])]
14135
14330
 
14136
14331
 
14137
- def _qingbi_filter_judge_type(operator: str) -> str:
14138
- return {
14139
- ViewFilterOperator.eq.value: "equal",
14140
- ViewFilterOperator.neq.value: "notEqual",
14141
- ViewFilterOperator.gte.value: "greaterOrEqual",
14142
- ViewFilterOperator.lte.value: "lessOrEqual",
14143
- ViewFilterOperator.in_.value: "anyMatch",
14144
- ViewFilterOperator.contains.value: "contains",
14145
- ViewFilterOperator.is_empty.value: "isNull",
14146
- ViewFilterOperator.not_empty.value: "isNotNull",
14147
- }.get(operator, "equal")
14148
-
14149
-
14150
- def _qingbi_option_label_lookup(*, form_field: dict[str, Any], qingbi_field: dict[str, Any]) -> dict[str, str]:
14151
- labels: dict[str, str] = {}
14152
-
14153
- def add_option(raw_option: Any) -> None:
14154
- if isinstance(raw_option, dict):
14155
- label = raw_option.get("value")
14156
- if label is None:
14157
- label = raw_option.get("label") or raw_option.get("name") or raw_option.get("title") or raw_option.get("optValue")
14158
- label_text = str(label).strip() if label is not None else ""
14159
- for key in ("id", "optionId", "option_id", "optId", "value", "label", "name", "title", "optValue"):
14160
- raw_key = raw_option.get(key)
14161
- if raw_key is not None and str(raw_key).strip() and label_text:
14162
- labels[str(raw_key).strip()] = label_text
14163
- elif raw_option is not None:
14164
- label_text = str(raw_option).strip()
14165
- if label_text:
14166
- labels[label_text] = label_text
14167
-
14168
- for option in form_field.get("option_details") or []:
14169
- add_option(option)
14170
- for option in form_field.get("options") or []:
14171
- add_option(option)
14172
- for option in qingbi_field.get("options") or qingbi_field.get("option_details") or []:
14173
- add_option(option)
14174
- return labels
14175
-
14176
-
14177
- def _qingbi_filter_text_values(values: list[Any], *, form_field: dict[str, Any], qingbi_field: dict[str, Any]) -> list[str]:
14178
- labels = _qingbi_option_label_lookup(form_field=form_field, qingbi_field=qingbi_field)
14179
- normalized: list[str] = []
14180
- for value in values:
14181
- if isinstance(value, dict):
14182
- raw_label = value.get("value") or value.get("label") or value.get("name") or value.get("title") or value.get("optValue")
14183
- if raw_label is not None and str(raw_label).strip():
14184
- normalized.append(str(raw_label).strip())
14185
- continue
14186
- raw_id = value.get("id") or value.get("optionId") or value.get("option_id") or value.get("optId")
14187
- raw_text = str(raw_id).strip() if raw_id is not None else ""
14188
- else:
14189
- raw_text = str(value).strip()
14190
- if not raw_text:
14191
- continue
14192
- normalized.append(labels.get(raw_text, raw_text))
14193
- return normalized
14194
-
14195
-
14196
- def _qingbi_filter_judge_value(values: list[str]) -> Any:
14197
- if not values:
14198
- return ""
14199
- if len(values) == 1:
14200
- return values[0]
14201
- return values
14202
-
14203
-
14204
14332
  def _build_public_chart_filter_matrix(
14205
14333
  rules: list[Any],
14206
14334
  *,
@@ -14213,6 +14341,16 @@ def _build_public_chart_filter_matrix(
14213
14341
  if not rules:
14214
14342
  return []
14215
14343
  group: list[dict[str, Any]] = []
14344
+ judge_map = {
14345
+ ViewFilterOperator.eq.value: "equal",
14346
+ ViewFilterOperator.neq.value: "unequal",
14347
+ ViewFilterOperator.gte.value: "greaterOrEqual",
14348
+ ViewFilterOperator.lte.value: "lessOrEqual",
14349
+ ViewFilterOperator.in_.value: "anyMatch",
14350
+ ViewFilterOperator.contains.value: "include",
14351
+ ViewFilterOperator.is_empty.value: "isNull",
14352
+ ViewFilterOperator.not_empty.value: "notNull",
14353
+ }
14216
14354
  for rule in rules:
14217
14355
  qingbi_field = _resolve_qingbi_chart_field(
14218
14356
  getattr(rule, "field_name", None),
@@ -14224,21 +14362,301 @@ def _build_public_chart_filter_matrix(
14224
14362
  form_field = qingbi_field.get("_public_form_field") if isinstance(qingbi_field.get("_public_form_field"), dict) else {}
14225
14363
  operator = str(getattr(rule, "operator", ViewFilterOperator.eq.value).value if hasattr(getattr(rule, "operator", None), "value") else getattr(rule, "operator", ViewFilterOperator.eq.value))
14226
14364
  values = list(getattr(rule, "values", []) or [])
14227
- text_values = _qingbi_filter_text_values(values, form_field=form_field, qingbi_field=qingbi_field)
14228
14365
  group.append(
14229
14366
  {
14230
14367
  "fieldId": field_id,
14231
14368
  "fieldName": qingbi_field.get("fieldName") or form_field.get("name") or field_id,
14232
14369
  "fieldType": qingbi_field.get("fieldType") or _qingbi_field_type_from_public_field(str(form_field.get("type") or "")),
14233
- "judgeType": _qingbi_filter_judge_type(operator),
14234
- "judgeValue": _qingbi_filter_judge_value(text_values),
14235
- "judgeValues": text_values,
14370
+ "judgeType": judge_map.get(operator, "equal"),
14371
+ "judgeValue": _build_qingbi_chart_filter_judge_value(
14372
+ operator=operator,
14373
+ values=values,
14374
+ form_field=form_field,
14375
+ chart_type=chart_type,
14376
+ ),
14377
+ "judgeValueDetailList": [],
14236
14378
  "matchType": 1,
14237
14379
  }
14238
14380
  )
14239
14381
  return [group] if group else []
14240
14382
 
14241
14383
 
14384
+ def _build_qingbi_chart_filter_judge_value(
14385
+ *,
14386
+ operator: str,
14387
+ values: list[Any],
14388
+ form_field: dict[str, Any],
14389
+ chart_type: str,
14390
+ ) -> str | None:
14391
+ if operator in {ViewFilterOperator.is_empty.value, ViewFilterOperator.not_empty.value}:
14392
+ return None
14393
+ if operator == ViewFilterOperator.in_.value:
14394
+ return "<&&>".join(
14395
+ _qingbi_chart_filter_value_to_text(value=value, form_field=form_field, chart_type=chart_type) for value in values
14396
+ )
14397
+ if not values:
14398
+ return ""
14399
+ return _qingbi_chart_filter_value_to_text(value=values[0], form_field=form_field, chart_type=chart_type)
14400
+
14401
+
14402
+ def _qingbi_chart_filter_value_to_text(*, value: Any, form_field: dict[str, Any], chart_type: str) -> str:
14403
+ option_details = [
14404
+ item
14405
+ for item in (form_field.get("option_details") or [])
14406
+ if isinstance(item, dict) and item.get("id") is not None and item.get("value") is not None
14407
+ ]
14408
+ if not option_details:
14409
+ if isinstance(value, dict):
14410
+ for key in ("value", "label", "name", "title"):
14411
+ raw = str(value.get(key) or "").strip()
14412
+ if raw:
14413
+ return raw
14414
+ if value.get("id") is not None:
14415
+ return str(value.get("id"))
14416
+ return _stringify_condition_value(value)
14417
+
14418
+ option_by_value = {str(item.get("value") or "").strip(): item for item in option_details if str(item.get("value") or "").strip()}
14419
+ option_by_id = {str(item.get("id")): item for item in option_details if item.get("id") is not None}
14420
+
14421
+ def resolve(raw: Any) -> str | None:
14422
+ text = _stringify_condition_value(raw).strip()
14423
+ if not text:
14424
+ return None
14425
+ matched = option_by_value.get(text) or option_by_id.get(text)
14426
+ if not matched:
14427
+ return None
14428
+ return str(matched.get("value") or "").strip()
14429
+
14430
+ def reject(raw: Any) -> NoReturn:
14431
+ allowed = [
14432
+ {"id": str(item.get("id")), "value": str(item.get("value"))}
14433
+ for item in option_details
14434
+ ]
14435
+ _raise_chart_rule(
14436
+ rule_code="CHART_FILTER_OPTION_VALUE_UNSUPPORTED",
14437
+ chart_type=chart_type,
14438
+ message="chart filter value is not in the field option list",
14439
+ expected="Use an existing option label or option id from the target field schema.",
14440
+ actual={
14441
+ "field_name": form_field.get("name"),
14442
+ "field_id": form_field.get("que_id"),
14443
+ "value": raw,
14444
+ "allowed_values": allowed,
14445
+ },
14446
+ offending_fields=[{"field_name": form_field.get("name"), "field_id": form_field.get("que_id"), "options": allowed}],
14447
+ next_action="Read schema first, then pass one of the allowed option labels or option ids.",
14448
+ )
14449
+
14450
+ if isinstance(value, dict):
14451
+ for key in ("value", "label", "name", "title"):
14452
+ if value.get(key) is not None:
14453
+ resolved = resolve(value.get(key))
14454
+ if resolved is not None:
14455
+ return resolved
14456
+ reject(value.get(key))
14457
+ if value.get("id") is not None:
14458
+ resolved = resolve(value.get("id"))
14459
+ if resolved is not None:
14460
+ return resolved
14461
+ reject(value.get("id"))
14462
+ reject(value)
14463
+ if isinstance(value, int):
14464
+ resolved = resolve(value)
14465
+ if resolved is not None:
14466
+ return resolved
14467
+ reject(value)
14468
+ resolved = resolve(value)
14469
+ if resolved is not None:
14470
+ return resolved
14471
+ reject(value)
14472
+
14473
+
14474
+ def _chart_field_names_by_id_from_public_fields(*, app_key: str, fields: list[dict[str, Any]]) -> dict[str, str]:
14475
+ field_name_by_id: dict[str, str] = {}
14476
+ for field in fields:
14477
+ if not isinstance(field, dict):
14478
+ continue
14479
+ name = str(field.get("name") or "").strip()
14480
+ if not name:
14481
+ continue
14482
+ que_id = field.get("que_id")
14483
+ field_id = str(field.get("field_id") or "").strip()
14484
+ for raw_key in (
14485
+ que_id,
14486
+ field_id,
14487
+ f"{app_key}:{que_id}" if que_id is not None else None,
14488
+ f"{app_key}:{field_id}" if field_id else None,
14489
+ ):
14490
+ key = str(raw_key or "").strip()
14491
+ if key:
14492
+ field_name_by_id.setdefault(key, name)
14493
+ return field_name_by_id
14494
+
14495
+
14496
+ def _public_chart_filter_groups_from_qingbi_config(
14497
+ config: dict[str, Any],
14498
+ *,
14499
+ field_name_by_id: dict[str, str] | None = None,
14500
+ ) -> list[list[dict[str, Any]]]:
14501
+ groups: list[list[dict[str, Any]]] = []
14502
+ raw_groups = config.get("beforeAggregationFilterMatrix")
14503
+ if not isinstance(raw_groups, list):
14504
+ return groups
14505
+ resolved_field_name_by_id = field_name_by_id or {}
14506
+ for raw_group in raw_groups:
14507
+ if not isinstance(raw_group, list):
14508
+ continue
14509
+ group: list[dict[str, Any]] = []
14510
+ for raw_rule in raw_group:
14511
+ if not isinstance(raw_rule, dict):
14512
+ continue
14513
+ operator = _public_chart_filter_operator_from_judge_type(raw_rule.get("judgeType"))
14514
+ field_id = raw_rule.get("fieldId") or raw_rule.get("field_id")
14515
+ field_id_text = _stringify_condition_value(field_id).strip() if field_id is not None else ""
14516
+ raw_field_name = (
14517
+ raw_rule.get("fieldName")
14518
+ or raw_rule.get("field_name")
14519
+ or raw_rule.get("queTitle")
14520
+ or raw_rule.get("title")
14521
+ )
14522
+ raw_field_name_text = _stringify_condition_value(raw_field_name).strip()
14523
+ field_name = (
14524
+ resolved_field_name_by_id.get(raw_field_name_text)
14525
+ or resolved_field_name_by_id.get(field_id_text)
14526
+ or raw_field_name_text
14527
+ or field_id_text
14528
+ )
14529
+ public_rule: dict[str, Any] = {
14530
+ "field_name": field_name,
14531
+ "operator": operator,
14532
+ }
14533
+ if field_id is not None:
14534
+ public_rule["field_id"] = field_id_text
14535
+ values = _public_chart_filter_values_from_rule(raw_rule, operator=operator)
14536
+ if values:
14537
+ public_rule["values"] = values
14538
+ group.append(public_rule)
14539
+ if group:
14540
+ groups.append(group)
14541
+ return groups
14542
+
14543
+
14544
+ def _public_chart_group_by_from_qingbi_config(config: dict[str, Any]) -> list[str]:
14545
+ fields: list[dict[str, Any]] = []
14546
+ for key in ("selectedDimensions", "xDimensions", "yDimensions", "selectedTime"):
14547
+ fields.extend(_chart_fields(config, key))
14548
+ group_by: list[str] = []
14549
+ seen: set[str] = set()
14550
+ for field in fields:
14551
+ name = _stringify_condition_value(
14552
+ field.get("fieldName")
14553
+ or field.get("field_name")
14554
+ or field.get("queTitle")
14555
+ or field.get("title")
14556
+ or field.get("fieldId")
14557
+ or field.get("field_id")
14558
+ ).strip()
14559
+ if not name or name in seen:
14560
+ continue
14561
+ seen.add(name)
14562
+ group_by.append(name)
14563
+ return group_by
14564
+
14565
+
14566
+ def _public_chart_metrics_from_qingbi_config(config: dict[str, Any]) -> list[dict[str, Any]]:
14567
+ fields: list[dict[str, Any]] = []
14568
+ for key in ("selectedMetrics", "xMetrics", "yMetrics", "leftMetrics", "rightMetrics"):
14569
+ fields.extend(_chart_fields(config, key))
14570
+ metrics: list[dict[str, Any]] = []
14571
+ seen: set[tuple[str, str]] = set()
14572
+ for field in fields:
14573
+ field_id = _chart_field_id(field)
14574
+ if field_id == _QINGBI_TOTAL_FIELD_ID:
14575
+ metric = {"op": "count", "expr": "count(*)"}
14576
+ else:
14577
+ op = str(field.get("aggreType") or field.get("aggregate") or "sum").strip().lower()
14578
+ if op == "average":
14579
+ op = "avg"
14580
+ field_name = _stringify_condition_value(
14581
+ field.get("fieldName")
14582
+ or field.get("field_name")
14583
+ or field.get("queTitle")
14584
+ or field.get("title")
14585
+ or field_id
14586
+ ).strip()
14587
+ metric = {"op": op or "sum", "field_name": field_name}
14588
+ if field_id:
14589
+ metric["field_id"] = field_id
14590
+ metric["expr"] = f"{metric['op']}({field_name})" if field_name else metric["op"]
14591
+ identity = (str(metric.get("op") or ""), str(metric.get("field_id") or metric.get("field_name") or metric.get("expr") or ""))
14592
+ if identity in seen:
14593
+ continue
14594
+ seen.add(identity)
14595
+ metrics.append(metric)
14596
+ return metrics
14597
+
14598
+
14599
+ def _public_chart_filter_operator_from_judge_type(judge_type: Any) -> str:
14600
+ normalized = _stringify_condition_value(judge_type).strip()
14601
+ mapping = {
14602
+ "equal": "eq",
14603
+ "equals": "eq",
14604
+ "=": "eq",
14605
+ "unequal": "neq",
14606
+ "notEqual": "neq",
14607
+ "!=": "neq",
14608
+ "anyMatch": "in",
14609
+ "oneOf": "in",
14610
+ "include": "contains",
14611
+ "contains": "contains",
14612
+ "greaterOrEqual": "gte",
14613
+ "gte": "gte",
14614
+ "lessOrEqual": "lte",
14615
+ "lte": "lte",
14616
+ "isNull": "is_empty",
14617
+ "empty": "is_empty",
14618
+ "notNull": "not_empty",
14619
+ "not_empty": "not_empty",
14620
+ }
14621
+ if normalized in mapping:
14622
+ return mapping[normalized]
14623
+ numeric = _coerce_nonnegative_int(judge_type)
14624
+ if numeric is not None:
14625
+ return _public_view_filter_operator_from_judge_type(numeric)
14626
+ return f"judge_type:{judge_type}"
14627
+
14628
+
14629
+ def _public_chart_filter_values_from_rule(rule: dict[str, Any], *, operator: str) -> list[str]:
14630
+ if operator in {"is_empty", "not_empty"}:
14631
+ return []
14632
+ raw_value = rule.get("judgeValue")
14633
+ if raw_value is None and "judgeValues" in rule:
14634
+ raw_value = rule.get("judgeValues")
14635
+ values: list[str] = []
14636
+ if isinstance(raw_value, list):
14637
+ values = [_stringify_condition_value(value).strip() for value in raw_value]
14638
+ elif isinstance(raw_value, str) and "<&&>" in raw_value:
14639
+ values = [value.strip() for value in raw_value.split("<&&>")]
14640
+ elif raw_value is not None:
14641
+ values = [_stringify_condition_value(raw_value).strip()]
14642
+ values = [value for value in values if value]
14643
+ if values:
14644
+ return values
14645
+ for detail_key in ("judgeValueDetailList", "judgeValueDetails"):
14646
+ details = rule.get(detail_key)
14647
+ if not isinstance(details, list):
14648
+ continue
14649
+ for detail in details:
14650
+ if not isinstance(detail, dict):
14651
+ continue
14652
+ for value_key in ("value", "label", "name", "title", "dataValue", "id"):
14653
+ value = _stringify_condition_value(detail.get(value_key)).strip()
14654
+ if value:
14655
+ values.append(value)
14656
+ break
14657
+ return values
14658
+
14659
+
14242
14660
  def _build_public_chart_config_payload(
14243
14661
  *,
14244
14662
  patch: ChartUpsertPatch,
@@ -14249,9 +14667,14 @@ def _build_public_chart_config_payload(
14249
14667
  ) -> dict[str, Any]:
14250
14668
  config = deepcopy(patch.config)
14251
14669
  explicit_fields = set(getattr(patch, "model_fields_set", set()) or set())
14252
- if "dimension_field_ids" in explicit_fields:
14670
+ semantic_dimension_fields = bool({"dimension_field_ids", "group_by", "rows", "columns"} & explicit_fields)
14671
+ semantic_metric_fields = bool(
14672
+ {"indicator_field_ids", "metric", "metrics", "x_metric", "y_metric", "left_metric", "right_metric", "value_metric", "target_metric"}
14673
+ & explicit_fields
14674
+ )
14675
+ if semantic_dimension_fields:
14253
14676
  config.pop("selectedDimensions", None)
14254
- if "indicator_field_ids" in explicit_fields:
14677
+ if semantic_metric_fields:
14255
14678
  config.pop("selectedMetrics", None)
14256
14679
  if "filters" in explicit_fields:
14257
14680
  config.pop("beforeAggregationFilterMatrix", None)
@@ -14277,8 +14700,8 @@ def _build_public_chart_config_payload(
14277
14700
  )
14278
14701
  query_condition_field_ids.append(_chart_field_id(field))
14279
14702
  backend_chart_type = _map_public_chart_type_to_backend(patch.chart_type)
14280
- if backend_chart_type == "gauge" and not patch.indicator_field_ids and "selectedMetrics" not in config:
14281
- raise ValueError("gauge charts require at least one indicator_field_ids value; pass one metric and the CLI will pair it with 数据总量")
14703
+ if backend_chart_type == "gauge" and not patch.indicator_field_ids and not patch.metrics and "selectedMetrics" not in config:
14704
+ raise ValueError("gauge charts require at least one metric; pass value_metric or metric and the CLI will pair it with 数据总量")
14282
14705
  selected_dimensions = _build_public_dimension_fields(
14283
14706
  patch.dimension_field_ids,
14284
14707
  app_key=app_key,
@@ -14287,15 +14710,25 @@ def _build_public_chart_config_payload(
14287
14710
  qingbi_fields_by_id=qingbi_fields_by_id,
14288
14711
  chart_type=patch.chart_type.value,
14289
14712
  )
14290
- selected_metrics = _build_public_metric_fields(
14291
- patch.indicator_field_ids,
14292
- app_key=app_key,
14293
- field_lookup=field_lookup,
14294
- chart_field_lookup=chart_field_lookup,
14295
- qingbi_fields_by_id=qingbi_fields_by_id,
14296
- aggregate=aggregate,
14297
- chart_type=patch.chart_type.value,
14298
- )
14713
+ if patch.metrics:
14714
+ selected_metrics = _build_public_semantic_metric_fields(
14715
+ patch.metrics,
14716
+ app_key=app_key,
14717
+ field_lookup=field_lookup,
14718
+ chart_field_lookup=chart_field_lookup,
14719
+ qingbi_fields_by_id=qingbi_fields_by_id,
14720
+ chart_type=patch.chart_type.value,
14721
+ )
14722
+ else:
14723
+ selected_metrics = _build_public_metric_fields(
14724
+ patch.indicator_field_ids,
14725
+ app_key=app_key,
14726
+ field_lookup=field_lookup,
14727
+ chart_field_lookup=chart_field_lookup,
14728
+ qingbi_fields_by_id=qingbi_fields_by_id,
14729
+ aggregate=aggregate,
14730
+ chart_type=patch.chart_type.value,
14731
+ )
14299
14732
  payload: dict[str, Any] = {
14300
14733
  "chartName": patch.name,
14301
14734
  "chartType": backend_chart_type,
@@ -14320,7 +14753,15 @@ def _build_public_chart_config_payload(
14320
14753
  if backend_chart_type == "summary":
14321
14754
  payload.pop("selectedDimensions", None)
14322
14755
  payload.setdefault("xDimensions", deepcopy(selected_dimensions))
14323
- payload.setdefault("yDimensions", [])
14756
+ y_dimensions = _build_public_dimension_fields(
14757
+ patch.columns,
14758
+ app_key=app_key,
14759
+ field_lookup=field_lookup,
14760
+ chart_field_lookup=chart_field_lookup,
14761
+ qingbi_fields_by_id=qingbi_fields_by_id,
14762
+ chart_type=patch.chart_type.value,
14763
+ )
14764
+ payload.setdefault("yDimensions", y_dimensions)
14324
14765
  elif backend_chart_type == "scatter":
14325
14766
  x_metrics, y_metrics = _split_axis_metric_fields(selected_metrics)
14326
14767
  payload.pop("selectedMetrics", None)
@@ -14354,7 +14795,26 @@ def _build_public_chart_config_payload(
14354
14795
 
14355
14796
  def _chart_patch_updates_chart_config(patch: ChartUpsertPatch) -> bool:
14356
14797
  explicit_fields = set(getattr(patch, "model_fields_set", set()) or set())
14357
- return bool({"dimension_field_ids", "indicator_field_ids", "filters", "config"} & explicit_fields)
14798
+ return bool(
14799
+ {
14800
+ "dimension_field_ids",
14801
+ "indicator_field_ids",
14802
+ "group_by",
14803
+ "rows",
14804
+ "columns",
14805
+ "metric",
14806
+ "metrics",
14807
+ "x_metric",
14808
+ "y_metric",
14809
+ "left_metric",
14810
+ "right_metric",
14811
+ "value_metric",
14812
+ "target_metric",
14813
+ "filters",
14814
+ "config",
14815
+ }
14816
+ & explicit_fields
14817
+ )
14358
14818
 
14359
14819
 
14360
14820
  def _chart_patch_dataset_source_type(patch: ChartUpsertPatch) -> str: