@qingflow-tech/qingflow-app-user-mcp 1.0.7 → 1.0.9

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.
@@ -44,17 +44,21 @@ from .models import (
44
44
  AppReadSummaryResponse,
45
45
  AppViewsReadResponse,
46
46
  AssociatedResourcesApplyRequest,
47
+ AssociatedResourcePartialPatch,
47
48
  AssociatedResourceUpsertPatch,
48
49
  AssociatedResourceViewConfigPatch,
49
50
  ChartApplyRequest,
51
+ ChartPartialPatch,
50
52
  ChartUpsertPatch,
51
53
  CustomButtonsApplyRequest,
54
+ CustomButtonPartialPatch,
52
55
  CustomButtonViewButtonBindingPatch,
53
56
  CustomButtonViewConfigPatch,
54
57
  CustomButtonMatchRulePatch,
55
58
  CustomButtonPatch,
56
59
  CustomButtonRemovePatch,
57
60
  CustomButtonUpsertPatch,
61
+ FieldMatchMappingPatch,
58
62
  FieldPatch,
59
63
  FieldRemovePatch,
60
64
  FieldSelector,
@@ -85,6 +89,7 @@ from .models import (
85
89
  VisibilityPatch,
86
90
  ViewAssociatedResourcesPatch,
87
91
  ViewButtonBindingPatch,
92
+ ViewPartialPatch,
88
93
  ViewUpsertPatch,
89
94
  ViewFilterOperator,
90
95
  ViewGetResponse,
@@ -2397,6 +2402,140 @@ class AiBuilderFacade:
2397
2402
  **button_style_catalog_payload(),
2398
2403
  }
2399
2404
 
2405
+ def _expand_custom_button_partial_patches(
2406
+ self,
2407
+ *,
2408
+ profile: str,
2409
+ app_key: str,
2410
+ existing_by_id: dict[int, dict[str, Any]],
2411
+ existing_by_text: dict[str, list[dict[str, Any]]],
2412
+ patch_buttons: list[CustomButtonPartialPatch],
2413
+ ) -> tuple[list[CustomButtonUpsertPatch], list[dict[str, Any]], list[dict[str, Any]]]:
2414
+ expanded: list[CustomButtonUpsertPatch] = []
2415
+ issues: list[dict[str, Any]] = []
2416
+ results: list[dict[str, Any]] = []
2417
+ for index, patch in enumerate(patch_buttons):
2418
+ selector_id = _coerce_positive_int(patch.button_id)
2419
+ selector_text = str(patch.button_text or "").strip()
2420
+ button_id: int | None = None
2421
+ if selector_id is not None:
2422
+ if selector_id not in existing_by_id:
2423
+ issue = {
2424
+ "error_code": "CUSTOM_BUTTON_NOT_FOUND",
2425
+ "reason_path": f"patch_buttons[{index}].button_id",
2426
+ "button_id": selector_id,
2427
+ "message": "button_id does not exist in the current app draft",
2428
+ "next_action": "call app_get and use custom_buttons[].button_id",
2429
+ }
2430
+ issues.append(issue)
2431
+ results.append({"index": index, "status": "failed", **issue})
2432
+ continue
2433
+ button_id = selector_id
2434
+ else:
2435
+ matches = existing_by_text.get(selector_text, [])
2436
+ if len(matches) != 1:
2437
+ issue = {
2438
+ "error_code": "AMBIGUOUS_CUSTOM_BUTTON" if matches else "CUSTOM_BUTTON_NOT_FOUND",
2439
+ "reason_path": f"patch_buttons[{index}].button_text",
2440
+ "button_text": selector_text,
2441
+ "candidate_button_ids": [
2442
+ item.get("button_id")
2443
+ for item in matches
2444
+ if _coerce_positive_int(item.get("button_id")) is not None
2445
+ ],
2446
+ "message": "patch_buttons[] must target a single existing button; use button_id when names are duplicated",
2447
+ }
2448
+ issues.append(issue)
2449
+ results.append({"index": index, "status": "failed", **issue})
2450
+ continue
2451
+ button_id = _coerce_positive_int(matches[0].get("button_id"))
2452
+ if button_id is None:
2453
+ issue = {
2454
+ "error_code": "CUSTOM_BUTTON_ID_MISSING",
2455
+ "reason_path": f"patch_buttons[{index}].button_text",
2456
+ "button_text": selector_text,
2457
+ "message": "matched button has no readable button_id",
2458
+ }
2459
+ issues.append(issue)
2460
+ results.append({"index": index, "status": "failed", **issue})
2461
+ continue
2462
+ try:
2463
+ detail_response = self.buttons.custom_button_get(
2464
+ profile=profile,
2465
+ app_key=app_key,
2466
+ button_id=button_id,
2467
+ being_draft=True,
2468
+ include_raw=False,
2469
+ )
2470
+ detail = _normalize_custom_button_detail(detail_response.get("result") if isinstance(detail_response.get("result"), dict) else {})
2471
+ except (QingflowApiError, RuntimeError) as error:
2472
+ api_error = _coerce_api_error(error)
2473
+ issue = {
2474
+ "error_code": "CUSTOM_BUTTON_PATCH_DETAIL_READ_FAILED",
2475
+ "reason_path": f"patch_buttons[{index}]",
2476
+ "button_id": button_id,
2477
+ "message": api_error.message,
2478
+ "transport_error": _transport_error_payload(api_error),
2479
+ }
2480
+ issues.append(issue)
2481
+ results.append({"index": index, "status": "failed", **issue})
2482
+ continue
2483
+ patch_payload = _custom_button_upsert_payload_from_detail(
2484
+ detail,
2485
+ button_id=button_id,
2486
+ client_key=str(patch.client_key or "").strip() or None,
2487
+ )
2488
+ normalized_set, set_issues = _normalize_custom_button_partial_set(patch.set, reason_path=f"patch_buttons[{index}].set")
2489
+ normalized_unset, unset_issues = _normalize_custom_button_partial_unset(patch.unset, reason_path=f"patch_buttons[{index}].unset")
2490
+ if set_issues or unset_issues:
2491
+ patch_issues = [*set_issues, *unset_issues]
2492
+ issues.extend(patch_issues)
2493
+ results.append({"index": index, "status": "failed", "button_id": button_id, "issues": patch_issues})
2494
+ continue
2495
+ for key, value in normalized_set.items():
2496
+ if key in {"trigger_add_data_config", "external_qrobot_config", "trigger_wings_config"} and isinstance(value, dict):
2497
+ if key == "trigger_add_data_config" and _custom_button_add_data_config_has_semantic_inputs(value):
2498
+ existing_config = patch_payload.get(key) if isinstance(patch_payload.get(key), dict) else {}
2499
+ merged_value: dict[str, Any] = {}
2500
+ for preserve_key in ("related_app_key", "related_app_name"):
2501
+ if preserve_key in existing_config:
2502
+ merged_value[preserve_key] = deepcopy(existing_config[preserve_key])
2503
+ _deep_merge_public_config(merged_value, value)
2504
+ else:
2505
+ merged_value = deepcopy(patch_payload.get(key) if isinstance(patch_payload.get(key), dict) else {})
2506
+ _deep_merge_public_config(merged_value, value)
2507
+ if key == "trigger_add_data_config":
2508
+ merged_value = _normalize_custom_button_add_data_config_for_public(merged_value)
2509
+ patch_payload[key] = merged_value
2510
+ else:
2511
+ patch_payload[key] = value
2512
+ for key in normalized_unset:
2513
+ patch_payload.pop(key, None)
2514
+ try:
2515
+ expanded_patch = CustomButtonUpsertPatch.model_validate(patch_payload)
2516
+ except Exception as error:
2517
+ issue = {
2518
+ "error_code": "CUSTOM_BUTTON_PATCH_HYDRATION_FAILED",
2519
+ "reason_path": f"patch_buttons[{index}]",
2520
+ "button_id": button_id,
2521
+ "message": str(error),
2522
+ "hydrated_payload": _compact_dict(patch_payload),
2523
+ }
2524
+ issues.append(issue)
2525
+ results.append({"index": index, "status": "failed", **issue})
2526
+ continue
2527
+ expanded.append(expanded_patch)
2528
+ results.append(
2529
+ {
2530
+ "index": index,
2531
+ "status": "expanded",
2532
+ "button_id": button_id,
2533
+ "set_paths": sorted(normalized_set),
2534
+ "unset_paths": sorted(normalized_unset),
2535
+ }
2536
+ )
2537
+ return expanded, issues, results
2538
+
2400
2539
  def app_custom_buttons_apply(self, *, profile: str, request: CustomButtonsApplyRequest) -> JSONObject:
2401
2540
  normalized_args = request.model_dump(mode="json")
2402
2541
  app_key = request.app_key
@@ -2437,10 +2576,33 @@ class AiBuilderFacade:
2437
2576
  if text:
2438
2577
  existing_by_text.setdefault(text, []).append(item)
2439
2578
 
2579
+ upsert_buttons = list(request.upsert_buttons)
2580
+ if request.patch_buttons:
2581
+ expanded_buttons, patch_issues, patch_results = self._expand_custom_button_partial_patches(
2582
+ profile=profile,
2583
+ app_key=app_key,
2584
+ existing_by_id=existing_by_id,
2585
+ existing_by_text=existing_by_text,
2586
+ patch_buttons=request.patch_buttons,
2587
+ )
2588
+ if patch_issues:
2589
+ return finalize(
2590
+ _failed(
2591
+ "CUSTOM_BUTTON_PATCH_HYDRATION_FAILED",
2592
+ "one or more custom button partial patches could not be hydrated; no write was executed",
2593
+ normalized_args=normalized_args,
2594
+ details={"patch_results": patch_results, "blocking_issues": patch_issues},
2595
+ suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
2596
+ )
2597
+ )
2598
+ upsert_buttons.extend(expanded_buttons)
2599
+ normalized_args["upsert_buttons"] = [patch.model_dump(mode="json") for patch in upsert_buttons]
2600
+ normalized_args["patch_results"] = patch_results
2601
+
2440
2602
  compiled_add_data_configs, add_data_issues = self._compile_custom_button_semantic_add_data_configs(
2441
2603
  profile=profile,
2442
2604
  app_key=app_key,
2443
- patches=request.upsert_buttons,
2605
+ patches=upsert_buttons,
2444
2606
  )
2445
2607
  upsert_ops: list[dict[str, Any]] = []
2446
2608
  remove_ops: list[dict[str, Any]] = []
@@ -2449,7 +2611,7 @@ class AiBuilderFacade:
2449
2611
  touched_existing_ids: set[int] = set()
2450
2612
  used_client_keys: set[str] = set()
2451
2613
 
2452
- for index, patch in enumerate(request.upsert_buttons):
2614
+ for index, patch in enumerate(upsert_buttons):
2453
2615
  patch_payload = patch.model_dump(mode="json", exclude_none=True)
2454
2616
  client_key = str(patch.client_key or "").strip()
2455
2617
  if client_key:
@@ -2832,6 +2994,10 @@ class AiBuilderFacade:
2832
2994
  "edit_version_no": edit_version_no,
2833
2995
  "button_ids_by_client_key": client_key_map,
2834
2996
  "readback_failed": readback_failed,
2997
+ "compiled_match_rules": {
2998
+ str(index): _summarize_compiled_match_rules(config.get("que_relation") or [])
2999
+ for index, config in compiled_add_data_configs.items()
3000
+ },
2835
3001
  },
2836
3002
  "request_id": None,
2837
3003
  "suggested_next_call": None if verified else {"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
@@ -3234,6 +3400,241 @@ class AiBuilderFacade:
3234
3400
  )
3235
3401
  )
3236
3402
 
3403
+ def _expand_associated_resource_partial_patches(
3404
+ self,
3405
+ *,
3406
+ existing_by_id: dict[int, dict[str, Any]],
3407
+ patch_resources: list[AssociatedResourcePartialPatch],
3408
+ ) -> tuple[list[AssociatedResourceUpsertPatch], list[dict[str, Any]], list[dict[str, Any]]]:
3409
+ expanded: list[AssociatedResourceUpsertPatch] = []
3410
+ issues: list[dict[str, Any]] = []
3411
+ results: list[dict[str, Any]] = []
3412
+ for index, patch in enumerate(patch_resources):
3413
+ item_id = _coerce_positive_int(patch.associated_item_id)
3414
+ if item_id is None or item_id not in existing_by_id:
3415
+ issue = _associated_resource_not_found_issue(f"patch_resources[{index}].associated_item_id", int(patch.associated_item_id), existing_by_id)
3416
+ issues.append(issue)
3417
+ results.append({"index": index, "status": "failed", **issue})
3418
+ continue
3419
+ patch_payload = _associated_resource_upsert_payload_from_existing_item(
3420
+ existing_by_id[item_id],
3421
+ associated_item_id=item_id,
3422
+ client_key=str(patch.client_key or "").strip() or None,
3423
+ )
3424
+ normalized_set, set_issues = _normalize_associated_resource_partial_set(patch.set, reason_path=f"patch_resources[{index}].set")
3425
+ normalized_unset, unset_issues = _normalize_associated_resource_partial_unset(patch.unset, reason_path=f"patch_resources[{index}].unset")
3426
+ if set_issues or unset_issues:
3427
+ patch_issues = [*set_issues, *unset_issues]
3428
+ issues.extend(patch_issues)
3429
+ results.append({"index": index, "status": "failed", "associated_item_id": item_id, "issues": patch_issues})
3430
+ continue
3431
+ for key, value in normalized_set.items():
3432
+ patch_payload[key] = value
3433
+ if key == "match_mappings":
3434
+ patch_payload["match_rules"] = []
3435
+ elif key == "match_rules":
3436
+ patch_payload["match_mappings"] = []
3437
+ for key in normalized_unset:
3438
+ if key == "match_rules":
3439
+ patch_payload["match_rules"] = []
3440
+ patch_payload["match_mappings"] = []
3441
+ if key == "match_mappings":
3442
+ patch_payload["match_rules"] = []
3443
+ patch_payload["match_mappings"] = []
3444
+ try:
3445
+ expanded_patch = AssociatedResourceUpsertPatch.model_validate(patch_payload)
3446
+ except Exception as error:
3447
+ issue = {
3448
+ "error_code": "ASSOCIATED_RESOURCE_PATCH_HYDRATION_FAILED",
3449
+ "reason_path": f"patch_resources[{index}]",
3450
+ "associated_item_id": item_id,
3451
+ "message": str(error),
3452
+ "hydrated_payload": _compact_dict(patch_payload),
3453
+ }
3454
+ issues.append(issue)
3455
+ results.append({"index": index, "status": "failed", **issue})
3456
+ continue
3457
+ validation_issue = _validate_associated_resource_patch(expanded_patch, reason_path=f"patch_resources[{index}]")
3458
+ if validation_issue is not None:
3459
+ issues.append(validation_issue)
3460
+ results.append({"index": index, "status": "failed", "associated_item_id": item_id, "issues": [validation_issue]})
3461
+ continue
3462
+ expanded.append(expanded_patch)
3463
+ results.append(
3464
+ {
3465
+ "index": index,
3466
+ "status": "expanded",
3467
+ "associated_item_id": item_id,
3468
+ "set_paths": sorted(normalized_set),
3469
+ "unset_paths": sorted(normalized_unset),
3470
+ }
3471
+ )
3472
+ return expanded, issues, results
3473
+
3474
+ def _compile_associated_resource_semantic_match_mappings(
3475
+ self,
3476
+ *,
3477
+ profile: str,
3478
+ app_key: str,
3479
+ patches: list[AssociatedResourceUpsertPatch],
3480
+ ) -> tuple[dict[int, list[dict[str, Any]]], list[dict[str, Any]]]:
3481
+ compiled_by_index: dict[int, list[dict[str, Any]]] = {}
3482
+ issues: list[dict[str, Any]] = []
3483
+ semantic_patches = [
3484
+ (index, patch)
3485
+ for index, patch in enumerate(patches)
3486
+ if patch.match_mappings or (patch.match_rules and patch.match_mappings)
3487
+ ]
3488
+ if not semantic_patches:
3489
+ return compiled_by_index, issues
3490
+ try:
3491
+ source_schema, _schema_source = self._read_schema_with_fallback(profile=profile, app_key=app_key)
3492
+ except (QingflowApiError, RuntimeError) as error:
3493
+ api_error = _coerce_api_error(error)
3494
+ return {}, [
3495
+ {
3496
+ "error_code": "ASSOCIATED_RESOURCE_SOURCE_SCHEMA_READ_FAILED",
3497
+ "reason_path": "upsert_resources[].match_mappings",
3498
+ "message": api_error.message,
3499
+ "transport_error": _transport_error_payload(api_error),
3500
+ "next_action": "retry after app schema is readable",
3501
+ }
3502
+ ]
3503
+ source_fields = list(_parse_schema(source_schema).get("fields") or [])
3504
+ target_schema_cache: dict[str, list[dict[str, Any]]] = {}
3505
+ for index, patch in semantic_patches:
3506
+ reason_base = f"upsert_resources[{index}].match_mappings"
3507
+ if patch.match_mappings and patch.match_rules:
3508
+ issues.append(
3509
+ {
3510
+ "_patch_index": index,
3511
+ "error_code": "MIXED_ASSOCIATED_RESOURCE_MAPPING_MODES",
3512
+ "reason_path": f"upsert_resources[{index}]",
3513
+ "message": "match_mappings cannot be used together with raw match_rules",
3514
+ "next_action": "use semantic match_mappings only, or pass legacy match_rules only",
3515
+ }
3516
+ )
3517
+ continue
3518
+ if not patch.match_mappings:
3519
+ continue
3520
+ target_app_key = str(patch.target_app_key or "").strip()
3521
+ if not target_app_key:
3522
+ issues.append(
3523
+ {
3524
+ "_patch_index": index,
3525
+ "error_code": "ASSOCIATED_RESOURCE_TARGET_APP_REQUIRED",
3526
+ "reason_path": f"upsert_resources[{index}].target_app_key",
3527
+ "message": "match_mappings require target_app_key",
3528
+ }
3529
+ )
3530
+ continue
3531
+ if target_app_key not in target_schema_cache:
3532
+ try:
3533
+ target_schema, _target_schema_source = self._read_schema_with_fallback(profile=profile, app_key=target_app_key)
3534
+ target_schema_cache[target_app_key] = list(_parse_schema(target_schema).get("fields") or [])
3535
+ except (QingflowApiError, RuntimeError) as error:
3536
+ api_error = _coerce_api_error(error)
3537
+ issues.append(
3538
+ {
3539
+ "_patch_index": index,
3540
+ "error_code": "ASSOCIATED_RESOURCE_TARGET_SCHEMA_READ_FAILED",
3541
+ "reason_path": f"upsert_resources[{index}].target_app_key",
3542
+ "target_app_key": target_app_key,
3543
+ "message": api_error.message,
3544
+ "transport_error": _transport_error_payload(api_error),
3545
+ "next_action": "verify target_app_key with app_get",
3546
+ }
3547
+ )
3548
+ continue
3549
+ rules, mapping_issues = self._compile_field_match_mappings(
3550
+ profile=profile,
3551
+ source_app_key=app_key,
3552
+ source_fields=source_fields,
3553
+ target_fields=target_schema_cache[target_app_key],
3554
+ mappings=patch.match_mappings,
3555
+ reason_path=reason_base,
3556
+ type_error_code="ASSOCIATED_RESOURCE_MAPPING_TYPE_MISMATCH",
3557
+ context_label="associated resource match mapping",
3558
+ )
3559
+ if mapping_issues:
3560
+ for issue in mapping_issues:
3561
+ issue["_patch_index"] = index
3562
+ issues.extend(mapping_issues)
3563
+ continue
3564
+ compiled_by_index[index] = rules
3565
+ return compiled_by_index, issues
3566
+
3567
+ def _compile_field_match_mappings(
3568
+ self,
3569
+ *,
3570
+ profile: str,
3571
+ source_app_key: str,
3572
+ source_fields: list[dict[str, Any]],
3573
+ target_fields: list[dict[str, Any]],
3574
+ mappings: list[FieldMatchMappingPatch],
3575
+ reason_path: str,
3576
+ type_error_code: str,
3577
+ context_label: str,
3578
+ ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
3579
+ issues: list[dict[str, Any]] = []
3580
+ rules: list[dict[str, Any]] = []
3581
+ for mapping_index, mapping in enumerate(mappings):
3582
+ target_field, target_issue = _resolve_custom_button_schema_field(
3583
+ fields=target_fields,
3584
+ selector=mapping.target_field,
3585
+ reason_path=f"{reason_path}[{mapping_index}].target_field",
3586
+ role="match_target",
3587
+ )
3588
+ if target_issue or target_field is None:
3589
+ if target_issue:
3590
+ issues.append(_retag_associated_resource_match_field_issue(target_issue))
3591
+ continue
3592
+ judge_type, operator_issue = _match_mapping_operator_to_judge_type(
3593
+ mapping.operator,
3594
+ reason_path=f"{reason_path}[{mapping_index}].operator",
3595
+ )
3596
+ if operator_issue:
3597
+ issues.append(operator_issue)
3598
+ continue
3599
+ if mapping.source_field is not None:
3600
+ source_field, source_issue = _resolve_custom_button_schema_field(
3601
+ fields=source_fields,
3602
+ selector=mapping.source_field,
3603
+ reason_path=f"{reason_path}[{mapping_index}].source_field",
3604
+ role="match_source",
3605
+ )
3606
+ if source_issue or source_field is None:
3607
+ if source_issue:
3608
+ issues.append(_retag_associated_resource_match_field_issue(source_issue))
3609
+ continue
3610
+ type_issue = _custom_button_mapping_type_issue(
3611
+ source_field=source_field,
3612
+ target_field=target_field,
3613
+ reason_path=f"{reason_path}[{mapping_index}]",
3614
+ source_app_key=source_app_key,
3615
+ error_code=type_error_code,
3616
+ context_label=context_label,
3617
+ )
3618
+ if type_issue:
3619
+ issues.append(type_issue)
3620
+ continue
3621
+ rule = _custom_button_field_mapping_rule(source_field=source_field, target_field=target_field)
3622
+ rule["judge_type"] = judge_type
3623
+ rules.append(rule)
3624
+ continue
3625
+ rule, value_issue = self._custom_button_default_value_rule(
3626
+ profile=profile,
3627
+ target_field=target_field,
3628
+ value=mapping.value,
3629
+ reason_path=f"{reason_path}[{mapping_index}].value",
3630
+ )
3631
+ if value_issue:
3632
+ issues.append(_retag_associated_resource_static_value_issue(value_issue))
3633
+ continue
3634
+ rule["judge_type"] = judge_type
3635
+ rules.append(rule)
3636
+ return rules, issues
3637
+
3237
3638
  def app_associated_resources_apply(self, *, profile: str, request: AssociatedResourcesApplyRequest) -> JSONObject:
3238
3639
  normalized_args = request.model_dump(mode="json", exclude_none=True)
3239
3640
  app_key = request.app_key
@@ -3252,7 +3653,7 @@ class AiBuilderFacade:
3252
3653
  return _apply_permission_outcomes(response, *permission_outcomes)
3253
3654
 
3254
3655
  try:
3255
- existing_resources = self._load_associated_resources_for_builder(profile=profile, app_key=app_key)
3656
+ existing_resources = self._load_associated_resources_for_builder(profile=profile, app_key=app_key, include_raw=True)
3256
3657
  except (QingflowApiError, RuntimeError) as error:
3257
3658
  api_error = _coerce_api_error(error)
3258
3659
  return finalize(_failed_from_api_error(
@@ -3270,8 +3671,45 @@ class AiBuilderFacade:
3270
3671
  client_key_to_patch: dict[str, AssociatedResourceUpsertPatch] = {}
3271
3672
  client_key_to_id: dict[str, int] = {}
3272
3673
  used_client_keys: set[str] = set()
3674
+ force_update_resource_ids: set[int] = set()
3675
+ resolved_associated_item_refs: dict[str, list[int]] = {}
3676
+
3677
+ upsert_resources = list(request.upsert_resources)
3678
+ if request.patch_resources:
3679
+ expanded_resources, patch_issues, patch_results = self._expand_associated_resource_partial_patches(
3680
+ existing_by_id=existing_by_id,
3681
+ patch_resources=request.patch_resources,
3682
+ )
3683
+ if patch_issues:
3684
+ return finalize(
3685
+ _failed(
3686
+ "ASSOCIATED_RESOURCE_PATCH_HYDRATION_FAILED",
3687
+ "one or more associated resource partial patches could not be hydrated; no write was executed",
3688
+ normalized_args=normalized_args,
3689
+ details={"patch_results": patch_results, "blocking_issues": patch_issues},
3690
+ suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
3691
+ )
3692
+ )
3693
+ upsert_resources.extend(expanded_resources)
3694
+ force_update_resource_ids.update(
3695
+ item_id
3696
+ for item_id in (_coerce_positive_int(patch.associated_item_id) for patch in expanded_resources)
3697
+ if item_id is not None
3698
+ )
3699
+ normalized_args["upsert_resources"] = [patch.model_dump(mode="json") for patch in upsert_resources]
3700
+ normalized_args["patch_results"] = patch_results
3701
+
3702
+ compiled_resource_match_rules, resource_match_issues = self._compile_associated_resource_semantic_match_mappings(
3703
+ profile=profile,
3704
+ app_key=app_key,
3705
+ patches=upsert_resources,
3706
+ )
3707
+ normalized_args["compiled_match_rules"] = {
3708
+ str(index): _summarize_compiled_match_rules(rules)
3709
+ for index, rules in compiled_resource_match_rules.items()
3710
+ }
3273
3711
 
3274
- for index, patch in enumerate(request.upsert_resources):
3712
+ for index, patch in enumerate(upsert_resources):
3275
3713
  client_key = str(patch.client_key or "").strip()
3276
3714
  if client_key:
3277
3715
  if client_key in used_client_keys:
@@ -3282,6 +3720,8 @@ class AiBuilderFacade:
3282
3720
  if validation_issue is not None:
3283
3721
  blocking_issues.append(validation_issue)
3284
3722
  continue
3723
+ if index in {issue.get("_patch_index") for issue in resource_match_issues if isinstance(issue, dict)}:
3724
+ continue
3285
3725
  associated_item_id = _coerce_positive_int(patch.associated_item_id)
3286
3726
  if associated_item_id is not None:
3287
3727
  if associated_item_id not in existing_by_id:
@@ -3293,7 +3733,14 @@ class AiBuilderFacade:
3293
3733
  touched_ids.add(associated_item_id)
3294
3734
  if client_key:
3295
3735
  client_key_to_id[client_key] = associated_item_id
3296
- operation = "unchanged" if _associated_resource_matches_patch(existing_by_id[associated_item_id], patch) else "update"
3736
+ operation = (
3737
+ "update"
3738
+ if associated_item_id in force_update_resource_ids
3739
+ or _associated_resource_patch_has_match_config(patch)
3740
+ else "unchanged"
3741
+ if _associated_resource_matches_patch(existing_by_id[associated_item_id], patch)
3742
+ else "update"
3743
+ )
3297
3744
  upsert_ops.append({"operation": operation, "associated_item_id": associated_item_id, "patch": patch, "index": index})
3298
3745
  continue
3299
3746
  matches = [
@@ -3327,38 +3774,40 @@ class AiBuilderFacade:
3327
3774
  touched_ids.add(matched_id)
3328
3775
  if client_key:
3329
3776
  client_key_to_id[client_key] = matched_id
3330
- upsert_ops.append({"operation": "unchanged", "associated_item_id": matched_id, "patch": patch, "index": index})
3777
+ operation = "update" if _associated_resource_patch_has_match_config(patch) else "unchanged"
3778
+ upsert_ops.append({"operation": operation, "associated_item_id": matched_id, "patch": patch, "index": index})
3331
3779
  else:
3332
3780
  upsert_ops.append({"operation": "create", "associated_item_id": None, "patch": patch, "index": index})
3333
3781
 
3334
- remove_ids = [
3335
- item_id
3336
- for item_id in (_coerce_positive_int(raw_id) for raw_id in request.remove_associated_item_ids)
3337
- if item_id is not None
3338
- ]
3782
+ remove_ids: list[int] = []
3339
3783
  for raw_id in request.remove_associated_item_ids:
3340
- item_id = _coerce_positive_int(raw_id)
3784
+ item_id, issue = _resolve_associated_resource_selector(
3785
+ raw_id,
3786
+ existing_resources=existing_resources,
3787
+ existing_by_id=existing_by_id,
3788
+ reason_path="remove_associated_item_ids",
3789
+ )
3341
3790
  if item_id is None:
3342
- blocking_issues.append({"error_code": "ASSOCIATED_RESOURCE_NOT_FOUND", "reason_path": "remove_associated_item_ids", "received": raw_id})
3791
+ blocking_issues.append(issue or {"error_code": "ASSOCIATED_RESOURCE_NOT_FOUND", "reason_path": "remove_associated_item_ids", "received": raw_id})
3343
3792
  continue
3344
- if item_id not in existing_by_id:
3345
- blocking_issues.append(_associated_resource_not_found_issue("remove_associated_item_ids", item_id, existing_by_id))
3346
- elif item_id in touched_ids:
3793
+ remove_ids.append(item_id)
3794
+ if item_id in touched_ids:
3347
3795
  blocking_issues.append(_duplicate_associated_resource_issue("remove_associated_item_ids", item_id))
3348
3796
  else:
3349
3797
  touched_ids.add(item_id)
3350
3798
 
3351
- reorder_ids = [
3352
- item_id
3353
- for item_id in (_coerce_positive_int(raw_id) for raw_id in request.reorder_associated_item_ids)
3354
- if item_id is not None
3355
- ]
3799
+ reorder_ids: list[int] = []
3356
3800
  for raw_id in request.reorder_associated_item_ids:
3357
- item_id = _coerce_positive_int(raw_id)
3801
+ item_id, issue = _resolve_associated_resource_selector(
3802
+ raw_id,
3803
+ existing_resources=existing_resources,
3804
+ existing_by_id=existing_by_id,
3805
+ reason_path="reorder_associated_item_ids",
3806
+ )
3358
3807
  if item_id is None:
3359
- blocking_issues.append({"error_code": "ASSOCIATED_RESOURCE_NOT_FOUND", "reason_path": "reorder_associated_item_ids", "received": raw_id})
3360
- elif item_id not in existing_by_id:
3361
- blocking_issues.append(_associated_resource_not_found_issue("reorder_associated_item_ids", item_id, existing_by_id))
3808
+ blocking_issues.append(issue or {"error_code": "ASSOCIATED_RESOURCE_NOT_FOUND", "reason_path": "reorder_associated_item_ids", "received": raw_id})
3809
+ continue
3810
+ reorder_ids.append(item_id)
3362
3811
 
3363
3812
  for index, view_config in enumerate(request.view_configs):
3364
3813
  refs = [str(ref or "").strip() for ref in view_config.associated_item_refs if str(ref or "").strip()]
@@ -3372,21 +3821,26 @@ class AiBuilderFacade:
3372
3821
  "message": "associated_item_refs must reference client_key values from upsert_resources in the same apply call",
3373
3822
  }
3374
3823
  )
3375
- invalid_ids = [
3376
- item_id
3377
- for item_id in view_config.associated_item_ids
3378
- if _coerce_positive_int(item_id) is None or _coerce_positive_int(item_id) not in existing_by_id
3379
- ]
3380
- if invalid_ids:
3381
- blocking_issues.append(
3382
- {
3383
- "error_code": "ASSOCIATED_RESOURCE_NOT_FOUND",
3384
- "reason_path": f"view_configs[{index}].associated_item_ids",
3385
- "invalid_associated_item_ids": invalid_ids,
3386
- "available_associated_item_ids": sorted(existing_by_id),
3387
- "message": "view_configs.associated_item_ids must use app-level associated_item_id values from app_get",
3388
- }
3824
+ resolved_ids: list[int] = []
3825
+ for raw_id in view_config.associated_item_ids:
3826
+ item_id, issue = _resolve_associated_resource_selector(
3827
+ raw_id,
3828
+ existing_resources=existing_resources,
3829
+ existing_by_id=existing_by_id,
3830
+ reason_path=f"view_configs[{index}].associated_item_ids",
3389
3831
  )
3832
+ if item_id is None:
3833
+ blocking_issues.append(
3834
+ issue
3835
+ or {
3836
+ "error_code": "ASSOCIATED_RESOURCE_NOT_FOUND",
3837
+ "reason_path": f"view_configs[{index}].associated_item_ids",
3838
+ "received": raw_id,
3839
+ }
3840
+ )
3841
+ continue
3842
+ resolved_ids.append(item_id)
3843
+ resolved_associated_item_refs[f"view_configs[{index}].associated_item_ids"] = resolved_ids
3390
3844
  raw_limit_type = str(view_config.limit_type or ("all" if view_config.visible else "")).strip().lower()
3391
3845
  if view_config.visible and raw_limit_type and raw_limit_type not in {"all", "select"}:
3392
3846
  blocking_issues.append(
@@ -3398,6 +3852,15 @@ class AiBuilderFacade:
3398
3852
  }
3399
3853
  )
3400
3854
 
3855
+ blocking_issues.extend(
3856
+ [
3857
+ {key: value for key, value in issue.items() if key != "_patch_index"}
3858
+ for issue in resource_match_issues
3859
+ ]
3860
+ )
3861
+ if resolved_associated_item_refs:
3862
+ normalized_args["resolved_associated_item_refs"] = deepcopy(resolved_associated_item_refs)
3863
+
3401
3864
  if blocking_issues:
3402
3865
  return finalize(
3403
3866
  _failed(
@@ -3453,7 +3916,7 @@ class AiBuilderFacade:
3453
3916
  "safe_to_retry": True,
3454
3917
  "publish_requested": False,
3455
3918
  "published": False,
3456
- "associated_resources": existing_resources,
3919
+ "associated_resources": _strip_internal_associated_resource_raw(existing_resources),
3457
3920
  }
3458
3921
  return finalize(response)
3459
3922
 
@@ -3485,7 +3948,12 @@ class AiBuilderFacade:
3485
3948
  client_key_to_id[str(patch.client_key)] = item_id
3486
3949
  elif op["operation"] == "create":
3487
3950
  write_executed = True
3488
- self._associated_resource_create(profile=profile, app_key=app_key, patch=patch)
3951
+ self._associated_resource_create(
3952
+ profile=profile,
3953
+ app_key=app_key,
3954
+ patch=patch,
3955
+ match_rules_override=compiled_resource_match_rules.get(int(op["index"])),
3956
+ )
3489
3957
  readback_resources = self._load_associated_resources_for_builder(profile=profile, app_key=app_key)
3490
3958
  matches = [
3491
3959
  item
@@ -3500,7 +3968,14 @@ class AiBuilderFacade:
3500
3968
  else:
3501
3969
  item_id = int(op["associated_item_id"])
3502
3970
  write_executed = True
3503
- self._associated_resource_update(profile=profile, app_key=app_key, associated_item_id=item_id, patch=patch)
3971
+ self._associated_resource_update(
3972
+ profile=profile,
3973
+ app_key=app_key,
3974
+ associated_item_id=item_id,
3975
+ patch=patch,
3976
+ existing_item=existing_by_id.get(item_id),
3977
+ match_rules_override=compiled_resource_match_rules.get(int(op["index"])),
3978
+ )
3504
3979
  updated.append(_associated_resource_result_entry("update", op["index"], patch, associated_item_id=item_id))
3505
3980
  if patch.client_key:
3506
3981
  client_key_to_id[str(patch.client_key)] = item_id
@@ -3542,12 +4017,13 @@ class AiBuilderFacade:
3542
4017
  resources_after = []
3543
4018
 
3544
4019
  for index, view_config in enumerate(request.view_configs):
4020
+ resolved_view_config_ids = resolved_associated_item_refs.get(f"view_configs[{index}].associated_item_ids", [])
3545
4021
  selected_ids = [
3546
4022
  item_id
3547
4023
  for item_id in (
3548
4024
  _coerce_positive_int(raw_id)
3549
4025
  for raw_id in [
3550
- *view_config.associated_item_ids,
4026
+ *resolved_view_config_ids,
3551
4027
  *[client_key_to_id.get(str(ref or "").strip()) for ref in view_config.associated_item_refs],
3552
4028
  ]
3553
4029
  )
@@ -3641,7 +4117,16 @@ class AiBuilderFacade:
3641
4117
  "normalized_args": normalized_args,
3642
4118
  "missing_fields": [],
3643
4119
  "allowed_values": {"graph_type": ["chart", "view"], "report_source": ["app", "dataset"], "view_config.limit_type": ["all", "select"]},
3644
- "details": {"edit_version_no": edit_version_no, "associated_item_ids_by_client_key": client_key_to_id, "readback_failed": readback_failed},
4120
+ "details": {
4121
+ "edit_version_no": edit_version_no,
4122
+ "associated_item_ids_by_client_key": client_key_to_id,
4123
+ "readback_failed": readback_failed,
4124
+ "compiled_match_rules": {
4125
+ str(index): _summarize_compiled_match_rules(rules)
4126
+ for index, rules in compiled_resource_match_rules.items()
4127
+ },
4128
+ "resolved_associated_item_refs": resolved_associated_item_refs,
4129
+ },
3645
4130
  "request_id": None,
3646
4131
  "suggested_next_call": None if verified else {"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
3647
4132
  "noop": False,
@@ -4835,7 +5320,7 @@ class AiBuilderFacade:
4835
5320
  fallback_items = self.charts.qingbi_report_list(profile=profile, app_key=app_key).get("items") or []
4836
5321
  return list(fallback_items) if isinstance(fallback_items, list) else [], "fallback"
4837
5322
 
4838
- def _load_associated_resources_for_builder(self, *, profile: str, app_key: str) -> list[dict[str, Any]]:
5323
+ def _load_associated_resources_for_builder(self, *, profile: str, app_key: str, include_raw: bool = False) -> list[dict[str, Any]]:
4839
5324
  def runner(_: Any, context: BackendRequestContext) -> list[dict[str, Any]]:
4840
5325
  payload = self.apps.backend.request(
4841
5326
  "GET",
@@ -4843,12 +5328,19 @@ class AiBuilderFacade:
4843
5328
  f"/app/{app_key}/asosChart",
4844
5329
  params={"role": 1, "beingDraft": True},
4845
5330
  )
4846
- return _normalize_associated_resources_payload(payload)
5331
+ return _normalize_associated_resources_payload(payload, include_raw=include_raw)
4847
5332
 
4848
5333
  return self.apps._run(profile, runner)
4849
5334
 
4850
- def _associated_resource_create(self, *, profile: str, app_key: str, patch: AssociatedResourceUpsertPatch) -> JSONObject:
4851
- payload = _serialize_associated_resource_create_payload(patch)
5335
+ def _associated_resource_create(
5336
+ self,
5337
+ *,
5338
+ profile: str,
5339
+ app_key: str,
5340
+ patch: AssociatedResourceUpsertPatch,
5341
+ match_rules_override: list[dict[str, Any]] | None = None,
5342
+ ) -> JSONObject:
5343
+ payload = _serialize_associated_resource_create_payload(patch, match_rules_override=match_rules_override)
4852
5344
 
4853
5345
  def runner(_: Any, context: BackendRequestContext) -> JSONObject:
4854
5346
  result = self.apps.backend.request("POST", context, f"/app/{app_key}/asosChart", json_body=payload)
@@ -4863,8 +5355,15 @@ class AiBuilderFacade:
4863
5355
  app_key: str,
4864
5356
  associated_item_id: int,
4865
5357
  patch: AssociatedResourceUpsertPatch,
5358
+ existing_item: dict[str, Any] | None = None,
5359
+ match_rules_override: list[dict[str, Any]] | None = None,
4866
5360
  ) -> JSONObject:
4867
- payload = _serialize_associated_resource_update_payload(patch, associated_item_id=associated_item_id)
5361
+ payload = _serialize_associated_resource_update_payload(
5362
+ patch,
5363
+ associated_item_id=associated_item_id,
5364
+ existing_item=existing_item,
5365
+ match_rules_override=match_rules_override,
5366
+ )
4868
5367
 
4869
5368
  def runner(_: Any, context: BackendRequestContext) -> JSONObject:
4870
5369
  result = self.apps.backend.request("POST", context, f"/app/{app_key}/asosChart/{associated_item_id}", json_body=payload)
@@ -4984,6 +5483,7 @@ class AiBuilderFacade:
4984
5483
  continue
4985
5484
  compiled_config, config_issues = self._compile_custom_button_add_data_config(
4986
5485
  profile=profile,
5486
+ source_app_key=app_key,
4987
5487
  source_fields=list(source_fields),
4988
5488
  target_fields=target_schema_cache[target_app_key],
4989
5489
  target_app_key=target_app_key,
@@ -5001,6 +5501,7 @@ class AiBuilderFacade:
5001
5501
  self,
5002
5502
  *,
5003
5503
  profile: str,
5504
+ source_app_key: str,
5004
5505
  source_fields: list[dict[str, Any]],
5005
5506
  target_fields: list[dict[str, Any]],
5006
5507
  target_app_key: str,
@@ -5035,6 +5536,9 @@ class AiBuilderFacade:
5035
5536
  source_field=source_field,
5036
5537
  target_field=target_field,
5037
5538
  reason_path=f"{reason_path}.field_mappings[{mapping_index}]",
5539
+ source_app_key=source_app_key,
5540
+ error_code="CUSTOM_BUTTON_MAPPING_TYPE_MISMATCH",
5541
+ context_label="addData copy mapping",
5038
5542
  )
5039
5543
  if type_issue:
5040
5544
  issues.append(type_issue)
@@ -5349,19 +5853,19 @@ class AiBuilderFacade:
5349
5853
  except (QingflowApiError, RuntimeError) as error:
5350
5854
  api_error = _coerce_api_error(error)
5351
5855
  has_requested_list_buttons = any(
5352
- _normalize_view_button_config_type(item.get("configType")) == "LIST"
5856
+ _normalize_view_button_config_type(item.get("configType")) == "INSIDE"
5353
5857
  for item in new_dtos
5354
5858
  if isinstance(item, dict)
5355
5859
  )
5356
5860
  fallback_dtos = [
5357
5861
  item
5358
5862
  for item in merged_dtos
5359
- if _normalize_view_button_config_type(item.get("configType")) != "LIST"
5863
+ if _normalize_view_button_config_type(item.get("configType")) != "INSIDE"
5360
5864
  ]
5361
5865
  fallback_new_dtos = [
5362
5866
  item
5363
5867
  for item in new_dtos
5364
- if _normalize_view_button_config_type(item.get("configType")) != "LIST"
5868
+ if _normalize_view_button_config_type(item.get("configType")) != "INSIDE"
5365
5869
  ]
5366
5870
  if has_requested_list_buttons and fallback_new_dtos and fallback_dtos != merged_dtos:
5367
5871
  fallback_payload = _build_view_buttons_only_update_payload(current_config, button_config_dtos=fallback_dtos)
@@ -5373,12 +5877,12 @@ class AiBuilderFacade:
5373
5877
  "index": config_index,
5374
5878
  "operation": "view_config",
5375
5879
  "status": "failed",
5376
- "error_code": "LIST_BUTTON_BACKEND_UNSUPPORTED",
5880
+ "error_code": "INSIDE_BUTTON_BACKEND_UNSUPPORTED",
5377
5881
  "view_key": view_key,
5378
- "message": "backend rejected list-button placement; header/detail placements were retried without list buttons",
5882
+ "message": "backend rejected inside/list-button placement; header/detail placements were retried without inside buttons",
5379
5883
  "backend_message": api_error.message,
5380
5884
  "transport_error": _transport_error_payload(api_error),
5381
- "next_action": "use placement=header/detail for now, or verify the backend list-button endpoint/payload separately",
5885
+ "next_action": "use placement=header/detail for now, or verify the backend inside-button payload separately",
5382
5886
  }
5383
5887
  failed.append(unsupported_list_issue)
5384
5888
  except (QingflowApiError, RuntimeError) as fallback_error:
@@ -5402,12 +5906,12 @@ class AiBuilderFacade:
5402
5906
  "index": config_index,
5403
5907
  "operation": "view_config",
5404
5908
  "status": "failed",
5405
- "error_code": "LIST_BUTTON_BACKEND_UNSUPPORTED",
5909
+ "error_code": "INSIDE_BUTTON_BACKEND_UNSUPPORTED",
5406
5910
  "view_key": view_key,
5407
- "message": "backend rejected list-button placement",
5911
+ "message": "backend rejected inside/list-button placement",
5408
5912
  "backend_message": api_error.message,
5409
5913
  "transport_error": _transport_error_payload(api_error),
5410
- "next_action": "use placement=header/detail for now, or verify the backend list-button endpoint/payload separately",
5914
+ "next_action": "use placement=header/detail for now, or verify the backend inside-button payload separately",
5411
5915
  }
5412
5916
  failed.append(issue)
5413
5917
  results.append(issue)
@@ -5648,6 +6152,7 @@ class AiBuilderFacade:
5648
6152
  config if isinstance(config, dict) else {},
5649
6153
  available_resources=associated_resources,
5650
6154
  )
6155
+ buttons_config = _extract_view_buttons_config(config if isinstance(config, dict) else {})
5651
6156
 
5652
6157
  response = ViewGetResponse(
5653
6158
  view_key=view_key,
@@ -5656,6 +6161,7 @@ class AiBuilderFacade:
5656
6161
  config=deepcopy(config) if isinstance(config, dict) else {},
5657
6162
  questions=questions,
5658
6163
  associations=associations,
6164
+ buttons_config=buttons_config,
5659
6165
  associated_resources_config=associated_resources_config,
5660
6166
  )
5661
6167
  question_entries = _extract_view_question_entries(config.get("viewgraphQuestions"))
@@ -5682,6 +6188,7 @@ class AiBuilderFacade:
5682
6188
  "verification": verification,
5683
6189
  "verified": all(bool(value) for value in verification.values()),
5684
6190
  "query_conditions": query_conditions,
6191
+ "buttons_config": buttons_config,
5685
6192
  "associated_resources_config": associated_resources_config,
5686
6193
  **response.model_dump(mode="json"),
5687
6194
  }
@@ -6165,6 +6672,173 @@ class AiBuilderFacade:
6165
6672
  "verification": {"field_count": len(field_names)},
6166
6673
  }
6167
6674
 
6675
+ def _expand_view_partial_patches(
6676
+ self,
6677
+ *,
6678
+ profile: str,
6679
+ app_key: str,
6680
+ schema: dict[str, Any],
6681
+ existing_by_key: dict[str, dict[str, Any]],
6682
+ existing_by_name: dict[str, list[dict[str, Any]]],
6683
+ patch_views: list[ViewPartialPatch],
6684
+ ) -> tuple[list[ViewUpsertPatch], list[dict[str, Any]], list[dict[str, Any]]]:
6685
+ parsed_schema = _parse_schema(schema)
6686
+ field_names_by_id = {
6687
+ field_id: str(field.get("name") or "").strip()
6688
+ for field in parsed_schema.get("fields") or []
6689
+ if isinstance(field, dict)
6690
+ and (field_id := _coerce_nonnegative_int(field.get("que_id") or field.get("field_id"))) is not None
6691
+ and str(field.get("name") or "").strip()
6692
+ }
6693
+ expanded: list[ViewUpsertPatch] = []
6694
+ issues: list[dict[str, Any]] = []
6695
+ results: list[dict[str, Any]] = []
6696
+ for index, patch in enumerate(patch_views):
6697
+ view_key = str(patch.view_key or "").strip()
6698
+ name = str(patch.name or "").strip()
6699
+ matched_view: dict[str, Any] | None = None
6700
+ if view_key:
6701
+ matched_view = existing_by_key.get(view_key)
6702
+ if matched_view is None:
6703
+ issue = {
6704
+ "error_code": "UNKNOWN_VIEW",
6705
+ "reason_path": f"patch_views[{index}].view_key",
6706
+ "view_key": view_key,
6707
+ "message": "view_key does not exist on this app",
6708
+ }
6709
+ issues.append(issue)
6710
+ results.append({"index": index, "status": "failed", **issue})
6711
+ continue
6712
+ else:
6713
+ matches = existing_by_name.get(name, [])
6714
+ if len(matches) != 1:
6715
+ issue = {
6716
+ "error_code": "AMBIGUOUS_VIEW" if matches else "UNKNOWN_VIEW",
6717
+ "reason_path": f"patch_views[{index}].name",
6718
+ "view_name": name,
6719
+ "matches": [
6720
+ {"name": _extract_view_name(view), "view_key": _extract_view_key(view), "type": _normalize_view_type_name(view.get("viewgraphType") or view.get("type"))}
6721
+ for view in matches
6722
+ ],
6723
+ "message": "patch_views[] must target a single existing view; use view_key when names are duplicated",
6724
+ }
6725
+ issues.append(issue)
6726
+ results.append({"index": index, "status": "failed", **issue})
6727
+ continue
6728
+ matched_view = matches[0]
6729
+ view_key = _extract_view_key(matched_view)
6730
+ try:
6731
+ config_response = self.views.view_get_config(profile=profile, viewgraph_key=view_key)
6732
+ config = config_response.get("result") if isinstance(config_response.get("result"), dict) else {}
6733
+ if not isinstance(config, dict):
6734
+ config = {}
6735
+ except (QingflowApiError, RuntimeError) as error:
6736
+ api_error = _coerce_api_error(error)
6737
+ issue = {
6738
+ "error_code": "VIEW_PATCH_CONFIG_READ_FAILED",
6739
+ "reason_path": f"patch_views[{index}]",
6740
+ "view_key": view_key,
6741
+ "message": api_error.message,
6742
+ "transport_error": _transport_error_payload(api_error),
6743
+ }
6744
+ issues.append(issue)
6745
+ results.append({"index": index, "status": "failed", **issue})
6746
+ continue
6747
+ question_list: list[dict[str, Any]] = []
6748
+ try:
6749
+ question_response = self.views.view_list_questions(profile=profile, viewgraph_key=view_key)
6750
+ raw_questions = question_response.get("result")
6751
+ if isinstance(raw_questions, list):
6752
+ question_list = [deepcopy(item) for item in raw_questions if isinstance(item, dict)]
6753
+ except (QingflowApiError, RuntimeError):
6754
+ question_list = []
6755
+ base_summary = {
6756
+ "name": _extract_view_name(config) or _extract_view_name(matched_view or {}) or name or view_key,
6757
+ "view_key": view_key,
6758
+ "type": _normalize_view_type_name(config.get("viewgraphType") or (matched_view or {}).get("viewgraphType") or (matched_view or {}).get("type")),
6759
+ }
6760
+ summary = _merge_view_summary_with_config(base_summary, config=config, question_list=question_list)
6761
+ current_payload = _view_upsert_payload_from_existing_view(
6762
+ config=config,
6763
+ summary=summary,
6764
+ view_key=view_key,
6765
+ field_names_by_id=field_names_by_id,
6766
+ )
6767
+ normalized_set, set_issues = _normalize_view_partial_set(patch.set, reason_path=f"patch_views[{index}].set")
6768
+ normalized_unset, unset_issues = _normalize_view_partial_unset(patch.unset, reason_path=f"patch_views[{index}].unset")
6769
+ if set_issues or unset_issues:
6770
+ patch_issues = [*set_issues, *unset_issues]
6771
+ issues.extend(patch_issues)
6772
+ results.append({"index": index, "status": "failed", "view_key": view_key, "issues": patch_issues})
6773
+ continue
6774
+ touched_keys = set(normalized_set) | set(normalized_unset)
6775
+ patch_payload = deepcopy(current_payload)
6776
+ for key, value in normalized_set.items():
6777
+ if key in {"query_conditions", "associated_resources", "visibility"} and isinstance(value, dict):
6778
+ merged_value = deepcopy(patch_payload.get(key) if isinstance(patch_payload.get(key), dict) else {})
6779
+ _deep_merge_public_config(merged_value, value)
6780
+ patch_payload[key] = merged_value
6781
+ else:
6782
+ patch_payload[key] = value
6783
+ for key in normalized_unset:
6784
+ if key == "filters":
6785
+ patch_payload["filters"] = []
6786
+ elif key == "buttons":
6787
+ patch_payload["buttons"] = []
6788
+ elif key == "query_conditions":
6789
+ patch_payload["query_conditions"] = {"enabled": False, "exact": False, "hide_before_query": False, "rows": []}
6790
+ elif key == "associated_resources":
6791
+ patch_payload["associated_resources"] = {"visible": False}
6792
+ elif key == "visibility":
6793
+ patch_payload.pop("visibility", None)
6794
+ else:
6795
+ issue = {
6796
+ "error_code": "VIEW_PATCH_UNSET_NOT_SUPPORTED",
6797
+ "reason_path": f"patch_views[{index}].unset",
6798
+ "field": key,
6799
+ "message": f"cannot unset {key}; use set with an explicit replacement value",
6800
+ }
6801
+ issues.append(issue)
6802
+ patch_payload["_partial_update"] = True
6803
+ patch_payload["_preserve_filters"] = "filters" not in touched_keys
6804
+ patch_payload["_preserve_buttons"] = "buttons" not in touched_keys
6805
+ patch_payload["_preserve_query_conditions"] = "query_conditions" not in touched_keys
6806
+ patch_payload["_preserve_associated_resources"] = "associated_resources" not in touched_keys
6807
+ try:
6808
+ expanded_patch = ViewUpsertPatch.model_validate(patch_payload)
6809
+ except Exception as error:
6810
+ issue = {
6811
+ "error_code": "VIEW_PATCH_HYDRATION_FAILED",
6812
+ "reason_path": f"patch_views[{index}]",
6813
+ "view_key": view_key,
6814
+ "message": str(error),
6815
+ "hydrated_payload": _compact_dict({k: v for k, v in patch_payload.items() if not str(k).startswith("_")}),
6816
+ }
6817
+ issues.append(issue)
6818
+ results.append({"index": index, "status": "failed", **issue})
6819
+ continue
6820
+ expanded.append(expanded_patch)
6821
+ results.append(
6822
+ {
6823
+ "index": index,
6824
+ "status": "expanded",
6825
+ "view_key": view_key,
6826
+ "set_paths": sorted(normalized_set),
6827
+ "unset_paths": sorted(normalized_unset),
6828
+ "preserved_paths": sorted(
6829
+ key
6830
+ for key, preserve in {
6831
+ "filters": expanded_patch.preserve_filters,
6832
+ "buttons": expanded_patch.preserve_buttons,
6833
+ "query_conditions": expanded_patch.preserve_query_conditions,
6834
+ "associated_resources": expanded_patch.preserve_associated_resources,
6835
+ }.items()
6836
+ if preserve
6837
+ ),
6838
+ }
6839
+ )
6840
+ return expanded, issues, results
6841
+
6168
6842
  def app_read(self, *, profile: str, app_key: str, include_raw: bool = False) -> JSONObject:
6169
6843
  state = self._load_app_state(profile=profile, app_key=app_key)
6170
6844
  base_result = state["base"]
@@ -7461,16 +8135,19 @@ class AiBuilderFacade:
7461
8135
  profile: str,
7462
8136
  app_key: str,
7463
8137
  upsert_views: list[ViewUpsertPatch],
8138
+ patch_views: list[ViewPartialPatch] | None = None,
7464
8139
  remove_views: list[str],
7465
8140
  publish: bool = True,
7466
8141
  ) -> JSONObject:
8142
+ patch_views = patch_views or []
7467
8143
  normalized_args = {
7468
8144
  "app_key": app_key,
7469
8145
  "upsert_views": [patch.model_dump(mode="json") for patch in upsert_views],
8146
+ "patch_views": [patch.model_dump(mode="json") for patch in patch_views],
7470
8147
  "remove_views": list(remove_views),
7471
8148
  "publish": publish,
7472
8149
  }
7473
- if not upsert_views and not remove_views:
8150
+ if not upsert_views and not patch_views and not remove_views:
7474
8151
  response = {
7475
8152
  "status": "success",
7476
8153
  "error_code": None,
@@ -7535,6 +8212,28 @@ class AiBuilderFacade:
7535
8212
  existing_by_name.setdefault(name, []).append(view)
7536
8213
  parsed_schema = _parse_schema(schema)
7537
8214
  field_names = {field["name"] for field in parsed_schema["fields"]}
8215
+ if patch_views:
8216
+ expanded_views, patch_issues, patch_results = self._expand_view_partial_patches(
8217
+ profile=profile,
8218
+ app_key=app_key,
8219
+ schema=schema,
8220
+ existing_by_key=existing_by_key,
8221
+ existing_by_name=existing_by_name,
8222
+ patch_views=patch_views,
8223
+ )
8224
+ if patch_issues:
8225
+ return finalize(
8226
+ _failed(
8227
+ "VIEW_PATCH_HYDRATION_FAILED",
8228
+ "one or more view partial patches could not be hydrated; no write was executed",
8229
+ normalized_args=normalized_args,
8230
+ details={"patch_results": patch_results, "blocking_issues": patch_issues},
8231
+ suggested_next_call={"tool_name": "view_get", "arguments": {"profile": profile, "view_key": patch_issues[0].get("view_key") or "VIEW_KEY"}},
8232
+ )
8233
+ )
8234
+ upsert_views = [*upsert_views, *expanded_views]
8235
+ normalized_args["upsert_views"] = [patch.model_dump(mode="json") for patch in upsert_views]
8236
+ normalized_args["patch_results"] = patch_results
7538
8237
  current_fields_by_name = {
7539
8238
  str(field.get("name") or ""): field
7540
8239
  for field in parsed_schema["fields"]
@@ -7701,7 +8400,13 @@ class AiBuilderFacade:
7701
8400
  missing_fields=[gantt_field_name],
7702
8401
  suggested_next_call={"tool_name": "app_get_fields", "arguments": {"profile": profile, "app_key": app_key}},
7703
8402
  )
7704
- translated_filters, filter_issues = _build_view_filter_groups(current_fields_by_name=current_fields_by_name, filters=patch.filters)
8403
+ translated_filters: list[list[dict[str, Any]]] | None
8404
+ filter_issues: list[dict[str, Any]]
8405
+ if patch.preserve_filters:
8406
+ translated_filters = None
8407
+ filter_issues = []
8408
+ else:
8409
+ translated_filters, filter_issues = _build_view_filter_groups(current_fields_by_name=current_fields_by_name, filters=patch.filters)
7705
8410
  if filter_issues:
7706
8411
  first_issue = filter_issues[0]
7707
8412
  return _failed(
@@ -7717,10 +8422,15 @@ class AiBuilderFacade:
7717
8422
  allowed_values=first_issue.get("allowed_values") or {"view_types": [member.value for member in PublicViewType], "view.filter.operator": [member.value for member in ViewFilterOperator]},
7718
8423
  suggested_next_call={"tool_name": "app_get_fields", "arguments": {"profile": profile, "app_key": app_key}},
7719
8424
  )
7720
- query_condition_payload, expected_query_conditions, query_condition_issues = _build_view_query_conditions_payload(
7721
- current_fields_by_name=current_fields_by_name,
7722
- query_conditions=patch.query_conditions,
7723
- )
8425
+ if patch.preserve_query_conditions:
8426
+ query_condition_payload = None
8427
+ expected_query_conditions = None
8428
+ query_condition_issues = []
8429
+ else:
8430
+ query_condition_payload, expected_query_conditions, query_condition_issues = _build_view_query_conditions_payload(
8431
+ current_fields_by_name=current_fields_by_name,
8432
+ query_conditions=patch.query_conditions,
8433
+ )
7724
8434
  if query_condition_issues:
7725
8435
  first_issue = query_condition_issues[0]
7726
8436
  return _failed(
@@ -7741,10 +8451,15 @@ class AiBuilderFacade:
7741
8451
  },
7742
8452
  suggested_next_call={"tool_name": "app_get_fields", "arguments": {"profile": profile, "app_key": app_key}},
7743
8453
  )
7744
- associated_resources_payload, expected_associated_resources, associated_resource_issues = _build_view_associated_resources_payload(
7745
- associated_resources=patch.associated_resources,
7746
- available_resources=associated_resources,
7747
- )
8454
+ if patch.preserve_associated_resources:
8455
+ associated_resources_payload = None
8456
+ expected_associated_resources = None
8457
+ associated_resource_issues = []
8458
+ else:
8459
+ associated_resources_payload, expected_associated_resources, associated_resource_issues = _build_view_associated_resources_payload(
8460
+ associated_resources=patch.associated_resources,
8461
+ available_resources=associated_resources,
8462
+ )
7748
8463
  if associated_resource_issues:
7749
8464
  first_issue = associated_resource_issues[0]
7750
8465
  return _failed(
@@ -7765,7 +8480,10 @@ class AiBuilderFacade:
7765
8480
  )
7766
8481
  explicit_button_dtos: list[dict[str, Any]] | None = None
7767
8482
  expected_button_summary: list[dict[str, Any]] | None = None
7768
- if patch.buttons is not None:
8483
+ if patch.preserve_buttons:
8484
+ explicit_button_dtos = None
8485
+ expected_button_summary = None
8486
+ elif patch.buttons is not None:
7769
8487
  explicit_button_dtos, button_issues = _build_view_button_dtos(
7770
8488
  current_fields_by_name=current_fields_by_name,
7771
8489
  bindings=patch.buttons,
@@ -8239,7 +8957,10 @@ class AiBuilderFacade:
8239
8957
  expected_filter_summary = _normalize_view_filter_groups_for_compare(expected_filters)
8240
8958
  expected_data_scope = "CUSTOM" if expected_filter_summary else "ALL"
8241
8959
  actual_data_scope = str(config_result.get("dataScope") or "").strip().upper() or None
8242
- filters_verified = actual_filters == expected_filter_summary and actual_data_scope == expected_data_scope
8960
+ filters_verified = (
8961
+ _view_filter_groups_equivalent(expected_filter_summary, actual_filters)
8962
+ and actual_data_scope == expected_data_scope
8963
+ )
8243
8964
  verification_entry["filters_verified"] = filters_verified
8244
8965
  verification_entry["view_key"] = verification_key
8245
8966
  verification_entry["expected_filters"] = expected_filter_summary
@@ -8737,6 +9458,126 @@ class AiBuilderFacade:
8737
9458
  "verified": verified,
8738
9459
  }
8739
9460
 
9461
+ def _expand_chart_partial_patches(
9462
+ self,
9463
+ *,
9464
+ profile: str,
9465
+ app_key: str,
9466
+ existing_by_id: dict[str, dict[str, Any]],
9467
+ existing_by_name: dict[str, list[dict[str, Any]]],
9468
+ patch_charts: list[ChartPartialPatch],
9469
+ ) -> tuple[list[ChartUpsertPatch], list[dict[str, Any]], list[dict[str, Any]]]:
9470
+ expanded: list[ChartUpsertPatch] = []
9471
+ issues: list[dict[str, Any]] = []
9472
+ results: list[dict[str, Any]] = []
9473
+ for index, patch in enumerate(patch_charts):
9474
+ chart_id = str(patch.chart_id or "").strip()
9475
+ if chart_id:
9476
+ if chart_id not in existing_by_id:
9477
+ issue = {
9478
+ "error_code": "CHART_NOT_FOUND",
9479
+ "reason_path": f"patch_charts[{index}].chart_id",
9480
+ "chart_id": chart_id,
9481
+ "message": "chart_id does not exist under this app",
9482
+ }
9483
+ issues.append(issue)
9484
+ results.append({"index": index, "status": "failed", **issue})
9485
+ continue
9486
+ else:
9487
+ name = str(patch.name or "").strip()
9488
+ matches = existing_by_name.get(name, [])
9489
+ if len(matches) != 1:
9490
+ issue = {
9491
+ "error_code": "AMBIGUOUS_CHART" if matches else "CHART_NOT_FOUND",
9492
+ "reason_path": f"patch_charts[{index}].name",
9493
+ "name": name,
9494
+ "candidate_chart_ids": [
9495
+ _extract_chart_identifier(item)
9496
+ for item in matches
9497
+ if _extract_chart_identifier(item)
9498
+ ],
9499
+ "message": "patch_charts[] must target a single existing chart; use chart_id when names are duplicated",
9500
+ }
9501
+ issues.append(issue)
9502
+ results.append({"index": index, "status": "failed", **issue})
9503
+ continue
9504
+ chart_id = _extract_chart_identifier(matches[0])
9505
+ if not chart_id:
9506
+ issue = {"error_code": "CHART_ID_MISSING", "reason_path": f"patch_charts[{index}].name", "name": name}
9507
+ issues.append(issue)
9508
+ results.append({"index": index, "status": "failed", **issue})
9509
+ continue
9510
+ try:
9511
+ base = self.charts.qingbi_report_get_base(profile=profile, chart_id=chart_id).get("result") or {}
9512
+ config = self.charts.qingbi_report_get_config(profile=profile, chart_id=chart_id).get("result") or {}
9513
+ if not isinstance(base, dict):
9514
+ base = {}
9515
+ if not isinstance(config, dict):
9516
+ config = {}
9517
+ except (QingflowApiError, RuntimeError) as error:
9518
+ api_error = _coerce_api_error(error)
9519
+ issue = {
9520
+ "error_code": "CHART_PATCH_DETAIL_READ_FAILED",
9521
+ "reason_path": f"patch_charts[{index}]",
9522
+ "chart_id": chart_id,
9523
+ "message": api_error.message,
9524
+ "transport_error": _transport_error_payload(api_error),
9525
+ }
9526
+ issues.append(issue)
9527
+ results.append({"index": index, "status": "failed", **issue})
9528
+ continue
9529
+ patch_payload = _chart_upsert_payload_from_existing(chart_id=chart_id, base=base, config=config)
9530
+ normalized_set, set_issues = _normalize_chart_partial_set(patch.set, reason_path=f"patch_charts[{index}].set")
9531
+ normalized_unset, unset_issues = _normalize_chart_partial_unset(patch.unset, reason_path=f"patch_charts[{index}].unset")
9532
+ if set_issues or unset_issues:
9533
+ patch_issues = [*set_issues, *unset_issues]
9534
+ issues.extend(patch_issues)
9535
+ results.append({"index": index, "status": "failed", "chart_id": chart_id, "issues": patch_issues})
9536
+ continue
9537
+ explicit_set_paths = set(normalized_set)
9538
+ for key, value in normalized_set.items():
9539
+ if key in {"config", "visibility"} and isinstance(value, dict):
9540
+ merged_value = deepcopy(patch_payload.get(key) if isinstance(patch_payload.get(key), dict) else {})
9541
+ _deep_merge_public_config(merged_value, value)
9542
+ patch_payload[key] = merged_value
9543
+ else:
9544
+ patch_payload[key] = value
9545
+ for key in normalized_unset:
9546
+ if key == "filters":
9547
+ patch_payload["filters"] = []
9548
+ explicit_set_paths.add("filters")
9549
+ elif key in {"question_config", "user_config"}:
9550
+ patch_payload[key] = []
9551
+ explicit_set_paths.add(key)
9552
+ elif key == "visibility":
9553
+ patch_payload.pop("visibility", None)
9554
+ try:
9555
+ expanded_patch = ChartUpsertPatch.model_validate(patch_payload)
9556
+ except Exception as error:
9557
+ issue = {
9558
+ "error_code": "CHART_PATCH_HYDRATION_FAILED",
9559
+ "reason_path": f"patch_charts[{index}]",
9560
+ "chart_id": chart_id,
9561
+ "message": str(error),
9562
+ "hydrated_payload": _compact_dict(patch_payload),
9563
+ }
9564
+ issues.append(issue)
9565
+ results.append({"index": index, "status": "failed", **issue})
9566
+ continue
9567
+ # Preserve model_fields_set semantics so config generation knows which public fields were explicitly replaced.
9568
+ expanded_patch.__pydantic_fields_set__ = set(explicit_set_paths)
9569
+ expanded.append(expanded_patch)
9570
+ results.append(
9571
+ {
9572
+ "index": index,
9573
+ "status": "expanded",
9574
+ "chart_id": chart_id,
9575
+ "set_paths": sorted(normalized_set),
9576
+ "unset_paths": sorted(normalized_unset),
9577
+ }
9578
+ )
9579
+ return expanded, issues, results
9580
+
8740
9581
  def chart_apply(self, *, profile: str, request: ChartApplyRequest) -> JSONObject:
8741
9582
  normalized_args = request.model_dump(mode="json")
8742
9583
  permission_outcomes: list[PermissionCheckOutcome] = []
@@ -8796,14 +9637,44 @@ class AiBuilderFacade:
8796
9637
  continue
8797
9638
  existing_by_name.setdefault(item_name, []).append(deepcopy(item))
8798
9639
 
9640
+ upsert_charts = list(request.upsert_charts)
9641
+ if request.patch_charts:
9642
+ expanded_charts, patch_issues, patch_results = self._expand_chart_partial_patches(
9643
+ profile=profile,
9644
+ app_key=app_key,
9645
+ existing_by_id=existing_by_id,
9646
+ existing_by_name=existing_by_name,
9647
+ patch_charts=request.patch_charts,
9648
+ )
9649
+ if patch_issues:
9650
+ return finalize(
9651
+ _failed(
9652
+ "CHART_PATCH_HYDRATION_FAILED",
9653
+ "one or more chart partial patches could not be hydrated; no write was executed",
9654
+ normalized_args=normalized_args,
9655
+ details={"patch_results": patch_results, "blocking_issues": patch_issues},
9656
+ suggested_next_call={"tool_name": "chart_get", "arguments": {"profile": profile, "chart_id": patch_issues[0].get("chart_id") or "CHART_ID"}},
9657
+ )
9658
+ )
9659
+ upsert_charts.extend(expanded_charts)
9660
+ normalized_args["upsert_charts"] = [patch.model_dump(mode="json") for patch in upsert_charts]
9661
+ normalized_args["patch_results"] = patch_results
9662
+
8799
9663
  chart_results: list[dict[str, Any]] = []
8800
9664
  created_ids: list[str] = []
8801
9665
  updated_ids: list[str] = []
8802
9666
  removed_ids: list[str] = []
8803
9667
  failed_items: list[dict[str, Any]] = []
8804
9668
 
8805
- for patch in request.upsert_charts:
9669
+ for patch in upsert_charts:
8806
9670
  try:
9671
+ dataset_source = _chart_patch_dataset_source_type(patch)
9672
+ if dataset_source:
9673
+ raise ValueError(
9674
+ "app_charts_apply only creates or edits app-source QingBI reports with dataSourceType=qingflow; "
9675
+ f"dataset report source '{dataset_source}' is not supported for creation/update yet. "
9676
+ "Create the dataset report in QingBI first, then attach it with app_associated_resources_apply using report_source='dataset'."
9677
+ )
8807
9678
  config_update_requested = _chart_patch_updates_chart_config(patch)
8808
9679
  chart_visible_auth = (
8809
9680
  self._compile_visibility_to_chart_visible_auth(profile=profile, visibility=patch.visibility)
@@ -8827,6 +9698,13 @@ class AiBuilderFacade:
8827
9698
  existing_name = str((existing or {}).get("chartName") or "").strip()
8828
9699
  existing_type = _normalize_backend_chart_type((existing or {}).get("chartType"))
8829
9700
  target_type = _map_public_chart_type_to_backend(patch.chart_type)
9701
+ existing_source_type = _chart_item_dataset_source_type(existing or {})
9702
+ if existing_source_type:
9703
+ raise ValueError(
9704
+ "app_charts_apply only creates or edits app-source QingBI reports with dataSourceType=qingflow; "
9705
+ f"existing chart '{chart_id or patch.name}' uses dataset report source '{existing_source_type}' and is not supported for update yet. "
9706
+ "Update it in QingBI directly, then attach the existing report with app_associated_resources_apply using report_source='dataset'."
9707
+ )
8830
9708
  if existing is None:
8831
9709
  temp_chart_id = str(patch.chart_id or f"mcp_{uuid4().hex[:16]}")
8832
9710
  create_payload = {
@@ -10154,6 +11032,94 @@ def _custom_button_add_data_config_has_semantic_inputs(value: dict[str, Any]) ->
10154
11032
  return bool(value.get("field_mappings") or value.get("fieldMappings") or value.get("default_values") or value.get("defaultValues"))
10155
11033
 
10156
11034
 
11035
+ def _normalize_custom_button_add_data_config_for_public(value: dict[str, Any]) -> dict[str, Any]:
11036
+ normalized: dict[str, Any] = {}
11037
+ related_app_key = _first_present(value, "related_app_key", "relatedAppKey", "target_app_key", "targetAppKey")
11038
+ related_app_name = _first_present(value, "related_app_name", "relatedAppName")
11039
+ if related_app_key is not None:
11040
+ normalized["related_app_key"] = related_app_key
11041
+ if related_app_name is not None:
11042
+ normalized["related_app_name"] = related_app_name
11043
+ relation_rules = _first_present(value, "que_relation", "queRelation")
11044
+ if isinstance(relation_rules, list):
11045
+ normalized["que_relation"] = [
11046
+ _normalize_custom_button_match_rule_for_public(rule)
11047
+ for rule in relation_rules
11048
+ if isinstance(rule, dict)
11049
+ ]
11050
+ field_mappings = _first_present(value, "field_mappings", "fieldMappings", "mappings")
11051
+ if isinstance(field_mappings, list):
11052
+ normalized["field_mappings"] = deepcopy(field_mappings)
11053
+ default_values = _first_present(value, "default_values", "defaultValues", "defaults")
11054
+ if isinstance(default_values, dict):
11055
+ normalized["default_values"] = deepcopy(default_values)
11056
+ return _compact_dict(normalized)
11057
+
11058
+
11059
+ def _normalize_custom_button_match_rule_for_public(value: dict[str, Any]) -> dict[str, Any]:
11060
+ normalized: dict[str, Any] = {}
11061
+ key_pairs = {
11062
+ "que_id": ("que_id", "queId"),
11063
+ "que_title": ("que_title", "queTitle"),
11064
+ "que_type": ("que_type", "queType"),
11065
+ "date_type": ("date_type", "dateType"),
11066
+ "judge_type": ("judge_type", "judgeType"),
11067
+ "match_type": ("match_type", "matchType"),
11068
+ "judge_que_type": ("judge_que_type", "judgeQueType"),
11069
+ "judge_que_id": ("judge_que_id", "judgeQueId"),
11070
+ "path_value": ("path_value", "pathValue"),
11071
+ "table_update_type": ("table_update_type", "tableUpdateType"),
11072
+ "multi_value": ("multi_value", "multiValue"),
11073
+ "add_rule": ("add_rule", "addRule"),
11074
+ "field_id_prefix": ("field_id_prefix", "fieldIdPrefix"),
11075
+ }
11076
+ for public_key, aliases in key_pairs.items():
11077
+ raw_value = _first_present(value, *aliases)
11078
+ if raw_value is not None:
11079
+ normalized[public_key] = raw_value
11080
+ judge_values = _first_present(value, "judge_values", "judgeValues")
11081
+ if isinstance(judge_values, list):
11082
+ normalized["judge_values"] = [str(item) for item in judge_values if item is not None]
11083
+ elif judge_values is not None:
11084
+ normalized["judge_values"] = [str(judge_values)]
11085
+ judge_que_detail = _first_present(value, "judge_que_detail", "judgeQueDetail")
11086
+ if isinstance(judge_que_detail, dict):
11087
+ normalized["judge_que_detail"] = _compact_dict(
11088
+ {
11089
+ "que_id": _first_present(judge_que_detail, "que_id", "queId"),
11090
+ "que_title": _first_present(judge_que_detail, "que_title", "queTitle"),
11091
+ "que_type": _first_present(judge_que_detail, "que_type", "queType"),
11092
+ }
11093
+ )
11094
+ judge_value_details = _first_present(value, "judge_value_details", "judgeValueDetails")
11095
+ if isinstance(judge_value_details, list):
11096
+ details: list[dict[str, Any]] = []
11097
+ for item in judge_value_details:
11098
+ if not isinstance(item, dict):
11099
+ continue
11100
+ detail = _compact_dict(
11101
+ {
11102
+ "id": _first_present(item, "id", "opt_id", "optId", "member_id", "memberId"),
11103
+ "value": _first_present(item, "value", "name", "label", "title"),
11104
+ }
11105
+ )
11106
+ if detail:
11107
+ details.append(detail)
11108
+ normalized["judge_value_details"] = details
11109
+ filter_condition = _first_present(value, "filter_condition", "filterCondition")
11110
+ if isinstance(filter_condition, list):
11111
+ normalized["filter_condition"] = [
11112
+ [
11113
+ _normalize_custom_button_match_rule_for_public(item)
11114
+ for item in group
11115
+ if isinstance(item, dict)
11116
+ ]
11117
+ for group in filter_condition
11118
+ if isinstance(group, list)
11119
+ ]
11120
+ return _compact_dict(normalized)
11121
+
11122
+
10157
11123
  def _custom_button_selector_payload(selector: Any) -> dict[str, Any]:
10158
11124
  if isinstance(selector, dict):
10159
11125
  payload = dict(selector)
@@ -10171,11 +11137,83 @@ def _custom_button_selector_payload(selector: Any) -> dict[str, Any]:
10171
11137
  raw = str(selector or "").strip()
10172
11138
  if not raw:
10173
11139
  return {}
10174
- if raw.isdigit():
10175
- return {"que_id": int(raw)}
11140
+ parsed_int = _coerce_any_int(raw)
11141
+ if parsed_int is not None:
11142
+ return {"que_id": parsed_int}
10176
11143
  return {"name": raw}
10177
11144
 
10178
11145
 
11146
+ _SYSTEM_MATCH_FIELD_ALIASES: dict[str, int] = {
11147
+ "数据id": -17,
11148
+ "数据ID": -17,
11149
+ "数据_id": -17,
11150
+ "row_record_id": -17,
11151
+ "data_id": -17,
11152
+ "record_id": -17,
11153
+ "apply_id": -17,
11154
+ "_id": -17,
11155
+ "编号": 0,
11156
+ "数据编号": 0,
11157
+ "record_number": 0,
11158
+ "serial_number": 0,
11159
+ "apply_num": 0,
11160
+ "data_num": 0,
11161
+ }
11162
+
11163
+
11164
+ def _normalize_system_match_alias(value: Any) -> str:
11165
+ raw = str(value or "").strip()
11166
+ if raw.startswith("{{") and raw.endswith("}}"):
11167
+ raw = raw[2:-2].strip()
11168
+ return raw.replace(" ", "").lower()
11169
+
11170
+
11171
+ def _system_match_field_from_selector(selector: Any) -> dict[str, Any] | None:
11172
+ if isinstance(selector, dict):
11173
+ for key in ("field_id", "fieldId", "que_id", "queId"):
11174
+ parsed = _coerce_any_int(selector.get(key))
11175
+ if parsed in {-17, 0}:
11176
+ return _system_match_field(parsed)
11177
+ for key in ("name", "title", "label", "value", "field", "source_field", "target_field"):
11178
+ raw = str(selector.get(key) or "").strip()
11179
+ if raw:
11180
+ field = _system_match_field_from_selector(raw)
11181
+ if field is not None:
11182
+ return field
11183
+ return None
11184
+ parsed = _coerce_any_int(selector)
11185
+ if parsed in {-17, 0}:
11186
+ return _system_match_field(parsed)
11187
+ normalized = _normalize_system_match_alias(selector)
11188
+ if normalized in _SYSTEM_MATCH_FIELD_ALIASES:
11189
+ return _system_match_field(_SYSTEM_MATCH_FIELD_ALIASES[normalized])
11190
+ return None
11191
+
11192
+
11193
+ def _system_match_field(que_id: int) -> dict[str, Any] | None:
11194
+ if que_id == -17:
11195
+ return {
11196
+ "field_id": -17,
11197
+ "que_id": -17,
11198
+ "que_type": 8,
11199
+ "name": "数据ID",
11200
+ "type": "system_record_id",
11201
+ "system_field": True,
11202
+ "system_kind": "row_record_id",
11203
+ }
11204
+ if que_id == 0:
11205
+ return {
11206
+ "field_id": 0,
11207
+ "que_id": 0,
11208
+ "que_type": 8,
11209
+ "name": "编号",
11210
+ "type": "system_record_number",
11211
+ "system_field": True,
11212
+ "system_kind": "record_number",
11213
+ }
11214
+ return None
11215
+
11216
+
10179
11217
  def _resolve_custom_button_schema_field(
10180
11218
  *,
10181
11219
  fields: list[dict[str, Any]],
@@ -10184,6 +11222,11 @@ def _resolve_custom_button_schema_field(
10184
11222
  role: str,
10185
11223
  ) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
10186
11224
  selector_payload = _custom_button_selector_payload(selector)
11225
+ allow_system = role in {"source", "match_source", "match_target"}
11226
+ if allow_system:
11227
+ system_field = _system_match_field_from_selector(selector_payload or selector)
11228
+ if system_field is not None:
11229
+ return system_field, None
10187
11230
  if not selector_payload:
10188
11231
  return None, {
10189
11232
  "error_code": "CUSTOM_BUTTON_MAPPING_FIELD_NOT_FOUND",
@@ -10193,18 +11236,19 @@ def _resolve_custom_button_schema_field(
10193
11236
  }
10194
11237
  matched: list[dict[str, Any]] = []
10195
11238
  field_id = selector_payload.get("field_id")
10196
- que_id = _coerce_nonnegative_int(selector_payload.get("que_id"))
11239
+ que_id = _coerce_any_int(selector_payload.get("que_id"))
10197
11240
  name = str(selector_payload.get("name") or "").strip()
10198
11241
  if field_id is not None:
10199
11242
  field_id_text = str(field_id).strip()
11243
+ field_id_int = _coerce_any_int(field_id_text)
10200
11244
  matched = [
10201
11245
  field
10202
11246
  for field in fields
10203
11247
  if str(field.get("field_id") or "").strip() == field_id_text
10204
- or (_coerce_nonnegative_int(field_id_text) is not None and _coerce_nonnegative_int(field.get("que_id")) == _coerce_nonnegative_int(field_id_text))
11248
+ or (field_id_int is not None and _coerce_any_int(field.get("que_id")) == field_id_int)
10205
11249
  ]
10206
11250
  elif que_id is not None:
10207
- matched = [field for field in fields if _coerce_nonnegative_int(field.get("que_id")) == que_id]
11251
+ matched = [field for field in fields if _coerce_any_int(field.get("que_id")) == que_id]
10208
11252
  elif name:
10209
11253
  matched = [field for field in fields if str(field.get("name") or "").strip() == name]
10210
11254
  if len(matched) == 1:
@@ -10227,40 +11271,239 @@ def _custom_button_mapping_type_issue(
10227
11271
  source_field: dict[str, Any],
10228
11272
  target_field: dict[str, Any],
10229
11273
  reason_path: str,
11274
+ source_app_key: str | None = None,
11275
+ error_code: str = "CUSTOM_BUTTON_MAPPING_TYPE_MISMATCH",
11276
+ context_label: str = "field mapping",
10230
11277
  ) -> dict[str, Any] | None:
10231
11278
  source_type = str(source_field.get("type") or "")
10232
11279
  target_type = str(target_field.get("type") or "")
11280
+ compatible, reason = _match_mapping_types_compatible(
11281
+ source_field=source_field,
11282
+ target_field=target_field,
11283
+ source_app_key=source_app_key,
11284
+ )
11285
+ if compatible:
11286
+ return None
11287
+ if reason == "reference_source_mismatch":
11288
+ target_app_key = _relation_target_app_key(target_field)
11289
+ return {
11290
+ "error_code": "MATCH_RULE_REFERENCE_SOURCE_MISMATCH",
11291
+ "reason_path": reason_path,
11292
+ "source_app_key": source_app_key,
11293
+ "target_relation_app_key": target_app_key,
11294
+ "source_field": _match_field_summary(source_field),
11295
+ "target_field": _match_field_summary(target_field),
11296
+ "message": "数据ID represents the current source record, but the target relation field points to another app",
11297
+ "next_action": "choose a relation field that targets the current source app, or map a compatible non-relation field",
11298
+ }
11299
+ return {
11300
+ "error_code": error_code,
11301
+ "reason_path": reason_path,
11302
+ "source_field": _match_field_summary(source_field),
11303
+ "target_field": _match_field_summary(target_field),
11304
+ "message": f"source and target fields have incompatible types for {context_label}",
11305
+ "compatibility_reason": reason,
11306
+ "next_action": "choose fields with the same type family; use 数据ID only for current-record id/relation matching and use static value/default_values for constants",
11307
+ }
11308
+
11309
+
11310
+ def _retag_associated_resource_match_field_issue(issue: dict[str, Any]) -> dict[str, Any]:
11311
+ retagged = dict(issue)
11312
+ code = str(retagged.get("error_code") or "")
11313
+ if code == "CUSTOM_BUTTON_MAPPING_FIELD_NOT_FOUND":
11314
+ retagged["error_code"] = "ASSOCIATED_RESOURCE_MAPPING_FIELD_NOT_FOUND"
11315
+ elif code == "CUSTOM_BUTTON_MAPPING_FIELD_AMBIGUOUS":
11316
+ retagged["error_code"] = "ASSOCIATED_RESOURCE_MAPPING_FIELD_AMBIGUOUS"
11317
+ return retagged
11318
+
11319
+
11320
+ def _retag_associated_resource_static_value_issue(issue: dict[str, Any]) -> dict[str, Any]:
11321
+ retagged = dict(issue)
11322
+ if str(retagged.get("error_code") or "") == "CUSTOM_BUTTON_DEFAULT_VALUE_UNSUPPORTED":
11323
+ retagged["error_code"] = "ASSOCIATED_RESOURCE_STATIC_VALUE_UNSUPPORTED"
11324
+ retagged["message"] = str(retagged.get("message") or "static associated resource match value is unsupported")
11325
+ retagged["next_action"] = str(
11326
+ retagged.get("next_action")
11327
+ or "retry match_mappings[].value with a literal value supported by the target field type"
11328
+ )
11329
+ return retagged
11330
+
11331
+
11332
+ def _match_field_summary(field: dict[str, Any]) -> dict[str, Any]:
11333
+ return {
11334
+ "title": field.get("name"),
11335
+ "field_id": field.get("field_id"),
11336
+ "que_id": field.get("que_id"),
11337
+ "type": field.get("type"),
11338
+ "system_field": bool(field.get("system_field")),
11339
+ }
11340
+
11341
+
11342
+ def _relation_target_app_key(field: dict[str, Any]) -> str | None:
11343
+ for key in ("target_app_key", "targetAppKey", "refer_app_key", "referAppKey"):
11344
+ value = str(field.get(key) or "").strip()
11345
+ if value:
11346
+ return value
11347
+ for container_key in ("relation", "reference", "reference_config", "referenceConfig", "config"):
11348
+ container = field.get(container_key)
11349
+ if not isinstance(container, dict):
11350
+ continue
11351
+ nested = _relation_target_app_key(container)
11352
+ if nested:
11353
+ return nested
11354
+ template = field.get("_reference_config_template")
11355
+ if isinstance(template, dict):
11356
+ nested = _relation_target_app_key(template)
11357
+ if nested:
11358
+ return nested
11359
+ return None
11360
+
11361
+
11362
+ def _match_mapping_types_compatible(
11363
+ *,
11364
+ source_field: dict[str, Any],
11365
+ target_field: dict[str, Any],
11366
+ source_app_key: str | None = None,
11367
+ ) -> tuple[bool, str | None]:
11368
+ source_type = str(source_field.get("type") or "")
11369
+ target_type = str(target_field.get("type") or "")
11370
+ unsupported = {
11371
+ FieldType.attachment.value,
11372
+ FieldType.subtable.value,
11373
+ FieldType.code_block.value,
11374
+ FieldType.q_linker.value,
11375
+ FieldType.address.value,
11376
+ }
11377
+ if source_type in unsupported or target_type in unsupported:
11378
+ return False, "unsupported_field_type"
11379
+
11380
+ if target_type == FieldType.relation.value:
11381
+ if source_type == "system_record_id":
11382
+ target_app_key = _relation_target_app_key(target_field)
11383
+ if source_app_key and target_app_key and str(target_app_key).strip() != str(source_app_key).strip():
11384
+ return False, "reference_source_mismatch"
11385
+ return True, None
11386
+ if source_type == FieldType.relation.value:
11387
+ source_target = _relation_target_app_key(source_field)
11388
+ target_target = _relation_target_app_key(target_field)
11389
+ if source_target and target_target and source_target != target_target:
11390
+ return False, "relation_target_app_mismatch"
11391
+ return True, None
11392
+ return False, "relation_requires_record_id_or_relation"
11393
+ if source_type == FieldType.relation.value:
11394
+ if target_type == "system_record_id":
11395
+ return True, None
11396
+ return False, "relation_only_matches_relation_or_record_id"
11397
+
11398
+ if source_type == "system_record_id":
11399
+ if target_type in {
11400
+ "system_record_id",
11401
+ FieldType.text.value,
11402
+ FieldType.long_text.value,
11403
+ FieldType.number.value,
11404
+ FieldType.amount.value,
11405
+ }:
11406
+ return True, None
11407
+ return False, "record_id_requires_relation_or_id_compatible_target"
11408
+ if target_type == "system_record_id":
11409
+ if source_type in {
11410
+ "system_record_id",
11411
+ FieldType.text.value,
11412
+ FieldType.long_text.value,
11413
+ FieldType.number.value,
11414
+ FieldType.amount.value,
11415
+ }:
11416
+ return True, None
11417
+ return False, "record_id_requires_id_compatible_source"
11418
+
11419
+ if source_type == "system_record_number":
11420
+ if target_type in {
11421
+ "system_record_number",
11422
+ FieldType.text.value,
11423
+ FieldType.long_text.value,
11424
+ FieldType.number.value,
11425
+ FieldType.amount.value,
11426
+ }:
11427
+ return True, None
11428
+ return False, "record_number_requires_text_or_number_target"
11429
+ if target_type == "system_record_number":
11430
+ if source_type in {
11431
+ "system_record_number",
11432
+ FieldType.text.value,
11433
+ FieldType.long_text.value,
11434
+ FieldType.number.value,
11435
+ FieldType.amount.value,
11436
+ }:
11437
+ return True, None
11438
+ return False, "record_number_requires_text_or_number_source"
11439
+
11440
+ exact_only = {FieldType.member.value, FieldType.department.value}
11441
+ if source_type in exact_only or target_type in exact_only:
11442
+ return (source_type == target_type, None if source_type == target_type else "member_department_require_same_type")
11443
+
10233
11444
  compatible_groups = [
10234
11445
  {FieldType.text.value, FieldType.long_text.value, FieldType.phone.value, FieldType.email.value},
10235
11446
  {FieldType.number.value, FieldType.amount.value},
10236
11447
  {FieldType.date.value, FieldType.datetime.value},
10237
11448
  {FieldType.single_select.value, FieldType.multi_select.value, FieldType.boolean.value},
10238
11449
  ]
10239
- compatible = source_type == target_type or any(source_type in group and target_type in group for group in compatible_groups)
10240
- if source_type == FieldType.relation.value or target_type == FieldType.relation.value:
10241
- compatible = source_type == target_type
10242
- if compatible:
10243
- return None
10244
- return {
10245
- "error_code": "CUSTOM_BUTTON_MAPPING_TYPE_MISMATCH",
11450
+ if source_type == target_type or any(source_type in group and target_type in group for group in compatible_groups):
11451
+ return True, None
11452
+ return False, "different_type_family"
11453
+
11454
+
11455
+ def _match_mapping_operator_to_judge_type(operator: Any, *, reason_path: str) -> tuple[int, dict[str, Any] | None]:
11456
+ normalized = str(operator or "eq").strip().lower()
11457
+ aliases = {
11458
+ "eq": JUDGE_EQUAL,
11459
+ "equal": JUDGE_EQUAL,
11460
+ "=": JUDGE_EQUAL,
11461
+ "==": JUDGE_EQUAL,
11462
+ "neq": JUDGE_UNEQUAL,
11463
+ "ne": JUDGE_UNEQUAL,
11464
+ "!=": JUDGE_UNEQUAL,
11465
+ "not_eq": JUDGE_UNEQUAL,
11466
+ "not_equal": JUDGE_UNEQUAL,
11467
+ "gte": JUDGE_GREATER_OR_EQUAL,
11468
+ "ge": JUDGE_GREATER_OR_EQUAL,
11469
+ ">=": JUDGE_GREATER_OR_EQUAL,
11470
+ "greater_or_equal": JUDGE_GREATER_OR_EQUAL,
11471
+ "lte": JUDGE_LESS_OR_EQUAL,
11472
+ "le": JUDGE_LESS_OR_EQUAL,
11473
+ "<=": JUDGE_LESS_OR_EQUAL,
11474
+ "less_or_equal": JUDGE_LESS_OR_EQUAL,
11475
+ "contains": JUDGE_FUZZY_MATCH,
11476
+ "like": JUDGE_FUZZY_MATCH,
11477
+ "fuzzy": JUDGE_FUZZY_MATCH,
11478
+ "fuzzy_match": JUDGE_FUZZY_MATCH,
11479
+ "in": JUDGE_EQUAL_ANY,
11480
+ "any": JUDGE_EQUAL_ANY,
11481
+ "equal_any": JUDGE_EQUAL_ANY,
11482
+ "include_any": JUDGE_INCLUDE_ANY,
11483
+ "includes_any": JUDGE_INCLUDE_ANY,
11484
+ }
11485
+ if normalized in aliases:
11486
+ return aliases[normalized], None
11487
+ return JUDGE_EQUAL, {
11488
+ "error_code": "INVALID_MATCH_MAPPING_OPERATOR",
10246
11489
  "reason_path": reason_path,
10247
- "source_field": {"title": source_field.get("name"), "field_id": source_field.get("field_id"), "type": source_type},
10248
- "target_field": {"title": target_field.get("name"), "field_id": target_field.get("field_id"), "type": target_type},
10249
- "message": "source and target fields have incompatible types for addData copy mapping",
10250
- "next_action": "choose fields with the same type family or use default_values for static text",
11490
+ "operator": operator,
11491
+ "allowed_values": sorted(aliases),
11492
+ "message": "match_mappings[].operator is not supported",
11493
+ "next_action": "retry with one of the allowed operator values, such as eq, contains, in, gte, or lte",
10251
11494
  }
10252
11495
 
10253
11496
 
10254
11497
  def _custom_button_question_ref(field: dict[str, Any]) -> dict[str, Any]:
10255
11498
  return {
10256
- "que_id": _coerce_nonnegative_int(field.get("que_id")),
11499
+ "que_id": _coerce_any_int(field.get("que_id")),
10257
11500
  "que_title": str(field.get("name") or ""),
10258
11501
  "que_type": _coerce_nonnegative_int(field.get("que_type")),
10259
11502
  }
10260
11503
 
10261
11504
 
10262
11505
  def _custom_button_field_mapping_rule(*, source_field: dict[str, Any], target_field: dict[str, Any]) -> dict[str, Any]:
10263
- source_que_id = _coerce_nonnegative_int(source_field.get("que_id"))
11506
+ source_que_id = _coerce_any_int(source_field.get("que_id"))
10264
11507
  return {
10265
11508
  **_custom_button_question_ref(target_field),
10266
11509
  "match_type": MATCH_TYPE_QUESTION,
@@ -10285,6 +11528,36 @@ def _custom_button_default_value_rule(*, target_field: dict[str, Any], value_det
10285
11528
  }
10286
11529
 
10287
11530
 
11531
+ def _summarize_compiled_match_rules(rules: list[dict[str, Any]]) -> list[dict[str, Any]]:
11532
+ return [
11533
+ _compact_dict(
11534
+ {
11535
+ "target_field": {
11536
+ "title": rule.get("que_title"),
11537
+ "field_id": rule.get("que_id"),
11538
+ "que_id": rule.get("que_id"),
11539
+ "que_type": rule.get("que_type"),
11540
+ },
11541
+ "match_type": rule.get("match_type"),
11542
+ "judge_type": rule.get("judge_type"),
11543
+ "source_field": (
11544
+ {
11545
+ "title": (rule.get("judge_que_detail") or {}).get("que_title"),
11546
+ "field_id": rule.get("judge_que_id"),
11547
+ "que_id": rule.get("judge_que_id"),
11548
+ "que_type": rule.get("judge_que_type"),
11549
+ }
11550
+ if rule.get("match_type") == MATCH_TYPE_QUESTION
11551
+ else None
11552
+ ),
11553
+ "static_values": rule.get("judge_value_details") or rule.get("judge_values") if rule.get("match_type") != MATCH_TYPE_QUESTION else None,
11554
+ }
11555
+ )
11556
+ for rule in rules
11557
+ if isinstance(rule, dict)
11558
+ ]
11559
+
11560
+
10288
11561
  def _resolve_custom_button_option_detail(*, target_field: dict[str, Any], value: Any) -> dict[str, Any] | None:
10289
11562
  option_details = [item for item in target_field.get("option_details") or [] if isinstance(item, dict)]
10290
11563
  value_id = _coerce_positive_int(value.get("id", value.get("opt_id", value.get("optId"))) if isinstance(value, dict) else value)
@@ -10438,30 +11711,172 @@ def _normalize_custom_button_summary(item: dict[str, Any]) -> dict[str, Any]:
10438
11711
  return normalized
10439
11712
 
10440
11713
 
10441
- def _normalize_custom_button_detail(item: dict[str, Any]) -> dict[str, Any]:
10442
- normalized = _normalize_custom_button_summary(item)
10443
- normalized.update(
10444
- {
10445
- "trigger_action": str(item.get("trigger_action") or item.get("triggerAction") or "").strip() or None,
10446
- "trigger_link_url": str(item.get("trigger_link_url") or item.get("triggerLinkUrl") or "").strip() or None,
10447
- }
10448
- )
10449
- trigger_add_data_config = item.get("trigger_add_data_config")
10450
- if not isinstance(trigger_add_data_config, dict):
10451
- trigger_add_data_config = item.get("triggerAddDataConfig")
10452
- if isinstance(trigger_add_data_config, dict):
10453
- normalized["trigger_add_data_config"] = deepcopy(trigger_add_data_config)
10454
- external_qrobot_config = item.get("external_qrobot_config")
10455
- if not isinstance(external_qrobot_config, dict):
10456
- external_qrobot_config = item.get("customButtonExternalQRobotRelationVO")
10457
- if isinstance(external_qrobot_config, dict):
10458
- normalized["external_qrobot_config"] = deepcopy(external_qrobot_config)
10459
- trigger_wings_config = item.get("trigger_wings_config")
10460
- if not isinstance(trigger_wings_config, dict):
10461
- trigger_wings_config = item.get("triggerWingsConfig")
10462
- if isinstance(trigger_wings_config, dict):
10463
- normalized["trigger_wings_config"] = deepcopy(trigger_wings_config)
10464
- return normalized
11714
+ def _normalize_custom_button_detail(item: dict[str, Any]) -> dict[str, Any]:
11715
+ normalized = _normalize_custom_button_summary(item)
11716
+ normalized.update(
11717
+ {
11718
+ "trigger_action": str(item.get("trigger_action") or item.get("triggerAction") or "").strip() or None,
11719
+ "trigger_link_url": str(item.get("trigger_link_url") or item.get("triggerLinkUrl") or "").strip() or None,
11720
+ }
11721
+ )
11722
+ trigger_add_data_config = item.get("trigger_add_data_config")
11723
+ if not isinstance(trigger_add_data_config, dict):
11724
+ trigger_add_data_config = item.get("triggerAddDataConfig")
11725
+ if isinstance(trigger_add_data_config, dict):
11726
+ normalized["trigger_add_data_config"] = _normalize_custom_button_add_data_config_for_public(trigger_add_data_config)
11727
+ external_qrobot_config = item.get("external_qrobot_config")
11728
+ if not isinstance(external_qrobot_config, dict):
11729
+ external_qrobot_config = item.get("customButtonExternalQRobotRelationVO")
11730
+ if isinstance(external_qrobot_config, dict):
11731
+ normalized["external_qrobot_config"] = deepcopy(external_qrobot_config)
11732
+ trigger_wings_config = item.get("trigger_wings_config")
11733
+ if not isinstance(trigger_wings_config, dict):
11734
+ trigger_wings_config = item.get("triggerWingsConfig")
11735
+ if isinstance(trigger_wings_config, dict):
11736
+ normalized["trigger_wings_config"] = deepcopy(trigger_wings_config)
11737
+ return normalized
11738
+
11739
+
11740
+ _CUSTOM_BUTTON_PARTIAL_PATCH_KEY_ALIASES = {
11741
+ "button_text": "button_text",
11742
+ "buttonText": "button_text",
11743
+ "name": "button_text",
11744
+ "text": "button_text",
11745
+ "background_color": "background_color",
11746
+ "backgroundColor": "background_color",
11747
+ "text_color": "text_color",
11748
+ "textColor": "text_color",
11749
+ "button_icon": "button_icon",
11750
+ "buttonIcon": "button_icon",
11751
+ "style_preset": "style_preset",
11752
+ "stylePreset": "style_preset",
11753
+ "trigger_action": "trigger_action",
11754
+ "triggerAction": "trigger_action",
11755
+ "trigger_link_url": "trigger_link_url",
11756
+ "triggerLinkUrl": "trigger_link_url",
11757
+ "trigger_add_data_config": "trigger_add_data_config",
11758
+ "triggerAddDataConfig": "trigger_add_data_config",
11759
+ "add_data_config": "trigger_add_data_config",
11760
+ "addDataConfig": "trigger_add_data_config",
11761
+ "external_qrobot_config": "external_qrobot_config",
11762
+ "externalQrobotConfig": "external_qrobot_config",
11763
+ "customButtonExternalQRobotRelationVO": "external_qrobot_config",
11764
+ "trigger_wings_config": "trigger_wings_config",
11765
+ "triggerWingsConfig": "trigger_wings_config",
11766
+ }
11767
+
11768
+ _CUSTOM_BUTTON_PARTIAL_SET_KEYS = {
11769
+ "button_text",
11770
+ "background_color",
11771
+ "text_color",
11772
+ "button_icon",
11773
+ "style_preset",
11774
+ "trigger_action",
11775
+ "trigger_link_url",
11776
+ "trigger_add_data_config",
11777
+ "external_qrobot_config",
11778
+ "trigger_wings_config",
11779
+ }
11780
+
11781
+ _CUSTOM_BUTTON_PARTIAL_UNSET_KEYS = {
11782
+ "trigger_link_url",
11783
+ "trigger_add_data_config",
11784
+ "external_qrobot_config",
11785
+ "trigger_wings_config",
11786
+ }
11787
+
11788
+
11789
+ def _canonical_custom_button_partial_patch_key(key: Any) -> str:
11790
+ raw = str(key or "").strip()
11791
+ return _CUSTOM_BUTTON_PARTIAL_PATCH_KEY_ALIASES.get(raw, raw)
11792
+
11793
+
11794
+ def _normalize_custom_button_partial_set(raw_set: Any, *, reason_path: str) -> tuple[dict[str, Any], list[dict[str, Any]]]:
11795
+ if not isinstance(raw_set, dict):
11796
+ return {}, [{"error_code": "INVALID_CUSTOM_BUTTON_PATCH_SET", "reason_path": reason_path, "message": "patch_buttons[].set must be an object"}]
11797
+ normalized: dict[str, Any] = {}
11798
+ issues: list[dict[str, Any]] = []
11799
+ for raw_key, value in raw_set.items():
11800
+ key_path = [part for part in str(raw_key or "").strip().split(".") if part]
11801
+ if not key_path:
11802
+ continue
11803
+ root = _canonical_custom_button_partial_patch_key(key_path[0])
11804
+ if root not in _CUSTOM_BUTTON_PARTIAL_SET_KEYS:
11805
+ issues.append(
11806
+ {
11807
+ "error_code": "UNSUPPORTED_CUSTOM_BUTTON_PATCH_FIELD",
11808
+ "reason_path": f"{reason_path}.{raw_key}",
11809
+ "field": raw_key,
11810
+ "allowed_fields": sorted(_CUSTOM_BUTTON_PARTIAL_SET_KEYS),
11811
+ }
11812
+ )
11813
+ continue
11814
+ if len(key_path) == 1:
11815
+ normalized[root] = value
11816
+ continue
11817
+ target = normalized.setdefault(root, {})
11818
+ if not isinstance(target, dict):
11819
+ issues.append(
11820
+ {
11821
+ "error_code": "INVALID_CUSTOM_BUTTON_PATCH_PATH",
11822
+ "reason_path": f"{reason_path}.{raw_key}",
11823
+ "message": "cannot combine a scalar replacement and nested patch for the same field",
11824
+ }
11825
+ )
11826
+ continue
11827
+ cursor = target
11828
+ for part in key_path[1:-1]:
11829
+ next_value = cursor.setdefault(part, {})
11830
+ if not isinstance(next_value, dict):
11831
+ issues.append({"error_code": "INVALID_CUSTOM_BUTTON_PATCH_PATH", "reason_path": f"{reason_path}.{raw_key}"})
11832
+ break
11833
+ cursor = next_value
11834
+ else:
11835
+ cursor[key_path[-1]] = value
11836
+ return normalized, issues
11837
+
11838
+
11839
+ def _normalize_custom_button_partial_unset(raw_unset: Any, *, reason_path: str) -> tuple[set[str], list[dict[str, Any]]]:
11840
+ if not isinstance(raw_unset, list):
11841
+ return set(), [{"error_code": "INVALID_CUSTOM_BUTTON_PATCH_UNSET", "reason_path": reason_path, "message": "patch_buttons[].unset must be a list"}]
11842
+ normalized: set[str] = set()
11843
+ issues: list[dict[str, Any]] = []
11844
+ for index, raw_key in enumerate(raw_unset):
11845
+ root = _canonical_custom_button_partial_patch_key(str(raw_key or "").strip().split(".")[0])
11846
+ if root not in _CUSTOM_BUTTON_PARTIAL_UNSET_KEYS:
11847
+ issues.append(
11848
+ {
11849
+ "error_code": "UNSUPPORTED_CUSTOM_BUTTON_PATCH_UNSET_FIELD",
11850
+ "reason_path": f"{reason_path}[{index}]",
11851
+ "field": raw_key,
11852
+ "allowed_fields": sorted(_CUSTOM_BUTTON_PARTIAL_UNSET_KEYS),
11853
+ }
11854
+ )
11855
+ continue
11856
+ normalized.add(root)
11857
+ return normalized, issues
11858
+
11859
+
11860
+ def _custom_button_upsert_payload_from_detail(
11861
+ detail: dict[str, Any],
11862
+ *,
11863
+ button_id: int,
11864
+ client_key: str | None = None,
11865
+ ) -> dict[str, Any]:
11866
+ payload = {
11867
+ "button_id": button_id,
11868
+ "client_key": client_key,
11869
+ "button_text": detail.get("button_text"),
11870
+ "background_color": detail.get("background_color"),
11871
+ "text_color": detail.get("text_color"),
11872
+ "button_icon": detail.get("button_icon"),
11873
+ "trigger_action": detail.get("trigger_action"),
11874
+ "trigger_link_url": detail.get("trigger_link_url"),
11875
+ "trigger_add_data_config": deepcopy(detail.get("trigger_add_data_config")) if isinstance(detail.get("trigger_add_data_config"), dict) else None,
11876
+ "external_qrobot_config": deepcopy(detail.get("external_qrobot_config")) if isinstance(detail.get("external_qrobot_config"), dict) else None,
11877
+ "trigger_wings_config": deepcopy(detail.get("trigger_wings_config")) if isinstance(detail.get("trigger_wings_config"), dict) else None,
11878
+ }
11879
+ return _compact_dict(payload)
10465
11880
 
10466
11881
 
10467
11882
  def _failed(
@@ -10947,13 +12362,135 @@ def _bi_field_id_for_field(*, app_key: str, field: dict[str, Any], qingbi_fields
10947
12362
 
10948
12363
 
10949
12364
  def _map_public_chart_type_to_backend(chart_type: PublicChartType) -> str:
10950
- return {
12365
+ aliases = {
10951
12366
  PublicChartType.target: "indicator",
12367
+ PublicChartType.indicator: "indicator",
10952
12368
  PublicChartType.pie: "pie",
10953
12369
  PublicChartType.bar: "bar",
10954
12370
  PublicChartType.line: "line",
10955
12371
  PublicChartType.table: "detail",
10956
- }[chart_type]
12372
+ PublicChartType.detail: "detail",
12373
+ }
12374
+ return aliases.get(chart_type, chart_type.value)
12375
+
12376
+
12377
+ _CHART_PARTIAL_PATCH_KEY_ALIASES = {
12378
+ "name": "name",
12379
+ "chart_name": "name",
12380
+ "chartName": "name",
12381
+ "type": "chart_type",
12382
+ "chart_type": "chart_type",
12383
+ "chartType": "chart_type",
12384
+ "dimension_fields": "dimension_field_ids",
12385
+ "dimension_field_ids": "dimension_field_ids",
12386
+ "dimensionFieldIds": "dimension_field_ids",
12387
+ "indicator_fields": "indicator_field_ids",
12388
+ "indicator_field_ids": "indicator_field_ids",
12389
+ "indicatorFieldIds": "indicator_field_ids",
12390
+ "metric_field_ids": "indicator_field_ids",
12391
+ "filters": "filters",
12392
+ "question_config": "question_config",
12393
+ "questionConfig": "question_config",
12394
+ "user_config": "user_config",
12395
+ "userConfig": "user_config",
12396
+ "config": "config",
12397
+ "visibility": "visibility",
12398
+ }
12399
+
12400
+ _CHART_PARTIAL_SET_KEYS = {
12401
+ "name",
12402
+ "chart_type",
12403
+ "dimension_field_ids",
12404
+ "indicator_field_ids",
12405
+ "filters",
12406
+ "question_config",
12407
+ "user_config",
12408
+ "config",
12409
+ "visibility",
12410
+ }
12411
+
12412
+ _CHART_PARTIAL_UNSET_KEYS = {"filters", "question_config", "user_config", "visibility"}
12413
+
12414
+
12415
+ def _canonical_chart_partial_patch_key(key: Any) -> str:
12416
+ raw = str(key or "").strip()
12417
+ return _CHART_PARTIAL_PATCH_KEY_ALIASES.get(raw, raw)
12418
+
12419
+
12420
+ def _normalize_chart_partial_set(raw_set: Any, *, reason_path: str) -> tuple[dict[str, Any], list[dict[str, Any]]]:
12421
+ if not isinstance(raw_set, dict):
12422
+ return {}, [{"error_code": "INVALID_CHART_PATCH_SET", "reason_path": reason_path, "message": "patch_charts[].set must be an object"}]
12423
+ normalized: dict[str, Any] = {}
12424
+ issues: list[dict[str, Any]] = []
12425
+ for raw_key, value in raw_set.items():
12426
+ key_path = [part for part in str(raw_key or "").strip().split(".") if part]
12427
+ if not key_path:
12428
+ continue
12429
+ root = _canonical_chart_partial_patch_key(key_path[0])
12430
+ if root not in _CHART_PARTIAL_SET_KEYS:
12431
+ issues.append(
12432
+ {
12433
+ "error_code": "UNSUPPORTED_CHART_PATCH_FIELD",
12434
+ "reason_path": f"{reason_path}.{raw_key}",
12435
+ "field": raw_key,
12436
+ "allowed_fields": sorted(_CHART_PARTIAL_SET_KEYS),
12437
+ }
12438
+ )
12439
+ continue
12440
+ if len(key_path) == 1:
12441
+ normalized[root] = value
12442
+ continue
12443
+ target = normalized.setdefault(root, {})
12444
+ if not isinstance(target, dict):
12445
+ issues.append({"error_code": "INVALID_CHART_PATCH_PATH", "reason_path": f"{reason_path}.{raw_key}"})
12446
+ continue
12447
+ cursor = target
12448
+ for part in key_path[1:-1]:
12449
+ next_value = cursor.setdefault(part, {})
12450
+ if not isinstance(next_value, dict):
12451
+ issues.append({"error_code": "INVALID_CHART_PATCH_PATH", "reason_path": f"{reason_path}.{raw_key}"})
12452
+ break
12453
+ cursor = next_value
12454
+ else:
12455
+ cursor[key_path[-1]] = value
12456
+ return normalized, issues
12457
+
12458
+
12459
+ def _normalize_chart_partial_unset(raw_unset: Any, *, reason_path: str) -> tuple[set[str], list[dict[str, Any]]]:
12460
+ if not isinstance(raw_unset, list):
12461
+ return set(), [{"error_code": "INVALID_CHART_PATCH_UNSET", "reason_path": reason_path, "message": "patch_charts[].unset must be a list"}]
12462
+ normalized: set[str] = set()
12463
+ issues: list[dict[str, Any]] = []
12464
+ for index, raw_key in enumerate(raw_unset):
12465
+ root = _canonical_chart_partial_patch_key(str(raw_key or "").strip().split(".")[0])
12466
+ if root not in _CHART_PARTIAL_UNSET_KEYS:
12467
+ issues.append(
12468
+ {
12469
+ "error_code": "UNSUPPORTED_CHART_PATCH_UNSET_FIELD",
12470
+ "reason_path": f"{reason_path}[{index}]",
12471
+ "field": raw_key,
12472
+ "allowed_fields": sorted(_CHART_PARTIAL_UNSET_KEYS),
12473
+ }
12474
+ )
12475
+ continue
12476
+ normalized.add(root)
12477
+ return normalized, issues
12478
+
12479
+
12480
+ def _chart_upsert_payload_from_existing(
12481
+ *,
12482
+ chart_id: str,
12483
+ base: dict[str, Any],
12484
+ config: dict[str, Any],
12485
+ ) -> dict[str, Any]:
12486
+ return _compact_dict(
12487
+ {
12488
+ "chart_id": chart_id,
12489
+ "name": str(base.get("chartName") or config.get("chartName") or chart_id).strip() or chart_id,
12490
+ "chart_type": _public_chart_type_from_backend(base.get("chartType") or config.get("chartType")),
12491
+ "config": deepcopy(config),
12492
+ }
12493
+ )
10957
12494
 
10958
12495
 
10959
12496
  def _qingbi_field_type_from_public_field(field_type: str | None) -> str:
@@ -11070,6 +12607,25 @@ def _build_public_metric_fields(
11070
12607
  return metrics or [_default_public_total_metric()]
11071
12608
 
11072
12609
 
12610
+ def _split_axis_metric_fields(metrics: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
12611
+ if not metrics:
12612
+ default_metric = _default_public_total_metric()
12613
+ return [deepcopy(default_metric)], [deepcopy(default_metric)]
12614
+ if len(metrics) == 1:
12615
+ return [deepcopy(metrics[0])], [deepcopy(metrics[0])]
12616
+ return [deepcopy(metrics[0])], [deepcopy(metrics[1])]
12617
+
12618
+
12619
+ def _two_gauge_metric_fields(metrics: list[dict[str, Any]], *, requested_metric_count: int) -> list[dict[str, Any]]:
12620
+ if not metrics:
12621
+ return []
12622
+ if len(metrics) == 1:
12623
+ if requested_metric_count <= 0:
12624
+ return [deepcopy(metrics[0])]
12625
+ return [deepcopy(metrics[0]), _default_public_total_metric()]
12626
+ return [deepcopy(metrics[0]), deepcopy(metrics[1])]
12627
+
12628
+
11073
12629
  def _build_public_chart_filter_matrix(
11074
12630
  rules: list[Any],
11075
12631
  *,
@@ -11117,6 +12673,13 @@ def _build_public_chart_config_payload(
11117
12673
  qingbi_fields_by_id: dict[str, dict[str, Any]],
11118
12674
  ) -> dict[str, Any]:
11119
12675
  config = deepcopy(patch.config)
12676
+ explicit_fields = set(getattr(patch, "model_fields_set", set()) or set())
12677
+ if "dimension_field_ids" in explicit_fields:
12678
+ config.pop("selectedDimensions", None)
12679
+ if "indicator_field_ids" in explicit_fields:
12680
+ config.pop("selectedMetrics", None)
12681
+ if "filters" in explicit_fields:
12682
+ config.pop("beforeAggregationFilterMatrix", None)
11120
12683
  aggregate = str(config.pop("aggregate", "count") or "count").lower()
11121
12684
  before_filters = deepcopy(config.pop("beforeAggregationFilterMatrix", []))
11122
12685
  after_filters = deepcopy(config.pop("afterAggregationFilterMatrix", []))
@@ -11131,23 +12694,28 @@ def _build_public_chart_config_payload(
11131
12694
  for selector in list(config.pop("query_condition_field_ids", []) or []):
11132
12695
  field = _resolve_public_field(selector, field_lookup=field_lookup)
11133
12696
  query_condition_field_ids.append(_bi_field_id_for_field(app_key=app_key, field=field, qingbi_fields_by_id=qingbi_fields_by_id))
12697
+ backend_chart_type = _map_public_chart_type_to_backend(patch.chart_type)
12698
+ if backend_chart_type == "gauge" and not patch.indicator_field_ids and "selectedMetrics" not in config:
12699
+ raise ValueError("gauge charts require at least one indicator_field_ids value; pass one metric and the CLI will pair it with 数据总量")
12700
+ selected_dimensions = _build_public_dimension_fields(
12701
+ patch.dimension_field_ids,
12702
+ app_key=app_key,
12703
+ field_lookup=field_lookup,
12704
+ qingbi_fields_by_id=qingbi_fields_by_id,
12705
+ )
12706
+ selected_metrics = _build_public_metric_fields(
12707
+ patch.indicator_field_ids,
12708
+ app_key=app_key,
12709
+ field_lookup=field_lookup,
12710
+ qingbi_fields_by_id=qingbi_fields_by_id,
12711
+ aggregate=aggregate,
12712
+ )
11134
12713
  payload: dict[str, Any] = {
11135
12714
  "chartName": patch.name,
11136
- "chartType": _map_public_chart_type_to_backend(patch.chart_type),
12715
+ "chartType": backend_chart_type,
11137
12716
  "dataSource": {"dataSourceId": app_key, "dataSourceType": "qingflow"},
11138
- "selectedDimensions": _build_public_dimension_fields(
11139
- patch.dimension_field_ids,
11140
- app_key=app_key,
11141
- field_lookup=field_lookup,
11142
- qingbi_fields_by_id=qingbi_fields_by_id,
11143
- ),
11144
- "selectedMetrics": _build_public_metric_fields(
11145
- patch.indicator_field_ids,
11146
- app_key=app_key,
11147
- field_lookup=field_lookup,
11148
- qingbi_fields_by_id=qingbi_fields_by_id,
11149
- aggregate=aggregate,
11150
- ),
12717
+ "selectedDimensions": selected_dimensions,
12718
+ "selectedMetrics": selected_metrics,
11151
12719
  "beforeAggregationFilterMatrix": before_filters,
11152
12720
  "afterAggregationFilterMatrix": after_filters,
11153
12721
  "chartStyleConfigs": deepcopy(config.pop("chartStyleConfigs", [])),
@@ -11163,6 +12731,23 @@ def _build_public_chart_config_payload(
11163
12731
  "queryConditionStatus": bool(config.pop("queryConditionStatus", bool(query_condition_field_ids))),
11164
12732
  "queryConditionExact": bool(config.pop("queryConditionExact", False)),
11165
12733
  }
12734
+ if backend_chart_type == "summary":
12735
+ payload.pop("selectedDimensions", None)
12736
+ payload.setdefault("xDimensions", deepcopy(selected_dimensions))
12737
+ payload.setdefault("yDimensions", [])
12738
+ elif backend_chart_type == "scatter":
12739
+ x_metrics, y_metrics = _split_axis_metric_fields(selected_metrics)
12740
+ payload.pop("selectedMetrics", None)
12741
+ payload.setdefault("xMetrics", x_metrics)
12742
+ payload.setdefault("yMetrics", y_metrics)
12743
+ elif backend_chart_type == "dualaxes":
12744
+ left_metrics, right_metrics = _split_axis_metric_fields(selected_metrics)
12745
+ payload.pop("selectedMetrics", None)
12746
+ payload.setdefault("leftMetrics", left_metrics)
12747
+ payload.setdefault("rightMetrics", right_metrics)
12748
+ elif backend_chart_type == "gauge":
12749
+ payload["selectedDimensions"] = []
12750
+ payload["selectedMetrics"] = _two_gauge_metric_fields(selected_metrics, requested_metric_count=len(patch.indicator_field_ids or []))
11166
12751
  for key in (
11167
12752
  "selectedTime",
11168
12753
  "xDimensions",
@@ -11185,6 +12770,46 @@ def _chart_patch_updates_chart_config(patch: ChartUpsertPatch) -> bool:
11185
12770
  return bool({"dimension_field_ids", "indicator_field_ids", "filters", "config"} & explicit_fields)
11186
12771
 
11187
12772
 
12773
+ def _chart_patch_dataset_source_type(patch: ChartUpsertPatch) -> str:
12774
+ config = patch.config if isinstance(patch.config, dict) else {}
12775
+ candidates = [
12776
+ config.get("dataSourceType"),
12777
+ config.get("data_source_type"),
12778
+ ]
12779
+ data_source = config.get("dataSource")
12780
+ if isinstance(data_source, dict):
12781
+ candidates.extend([data_source.get("dataSourceType"), data_source.get("data_source_type"), data_source.get("type")])
12782
+ data_source = config.get("data_source")
12783
+ if isinstance(data_source, dict):
12784
+ candidates.extend([data_source.get("dataSourceType"), data_source.get("data_source_type"), data_source.get("type")])
12785
+ for candidate in candidates:
12786
+ normalized = str(candidate or "").strip().lower()
12787
+ if not normalized:
12788
+ continue
12789
+ if normalized not in {"qingflow", "app"}:
12790
+ return normalized
12791
+ return ""
12792
+
12793
+
12794
+ def _chart_item_dataset_source_type(item: dict[str, Any]) -> str:
12795
+ candidates = [
12796
+ item.get("dataSourceType"),
12797
+ item.get("data_source_type"),
12798
+ item.get("sourceType"),
12799
+ item.get("source_type"),
12800
+ ]
12801
+ data_source = item.get("dataSource")
12802
+ if isinstance(data_source, dict):
12803
+ candidates.extend([data_source.get("dataSourceType"), data_source.get("data_source_type"), data_source.get("type")])
12804
+ for candidate in candidates:
12805
+ normalized = str(candidate or "").strip().lower()
12806
+ if not normalized:
12807
+ continue
12808
+ if normalized not in {"qingflow", "app", "bi_qingflow"}:
12809
+ return normalized
12810
+ return ""
12811
+
12812
+
11188
12813
  def _build_public_portal_base_payload(
11189
12814
  *,
11190
12815
  dash_name: str,
@@ -11255,16 +12880,33 @@ def _normalize_backend_chart_type(value: Any) -> str:
11255
12880
  "12": "dualaxes",
11256
12881
  "13": "map",
11257
12882
  "14": "timeline",
12883
+ "15": "area",
12884
+ "16": "stacked_column",
12885
+ "17": "stacked_bar",
12886
+ "18": "rose",
12887
+ "19": "stacked_area",
12888
+ "20": "pct_stack_col",
12889
+ "21": "pct_stack_bar",
12890
+ "22": "pct_stack_area",
12891
+ "23": "waterfall",
12892
+ "24": "gauge",
12893
+ "25": "heatmap",
12894
+ "26": "histogram",
12895
+ "27": "treemap",
12896
+ }
12897
+ aliases = {
12898
+ "percent_stacked_column": "pct_stack_col",
12899
+ "percent_stacked_bar": "pct_stack_bar",
12900
+ "percent_stacked_area": "pct_stack_area",
11258
12901
  }
11259
- return by_code.get(raw, raw.lower())
12902
+ normalized = by_code.get(raw, raw.lower())
12903
+ return aliases.get(normalized, normalized)
11260
12904
 
11261
12905
 
11262
12906
  def _public_chart_type_from_backend(value: Any) -> str:
11263
12907
  normalized = _normalize_backend_chart_type(value)
11264
12908
  return {
11265
12909
  "indicator": PublicChartType.target.value,
11266
- "summary": PublicChartType.target.value,
11267
- "columnar": PublicChartType.bar.value,
11268
12910
  "detail": PublicChartType.table.value,
11269
12911
  }.get(normalized, normalized)
11270
12912
 
@@ -17350,7 +18992,9 @@ def _normalize_view_button_type(value: Any) -> str | None:
17350
18992
 
17351
18993
  def _normalize_view_button_config_type(value: Any) -> str | None:
17352
18994
  normalized = str(value or "").strip().upper()
17353
- if normalized in {"TOP", "DETAIL", "LIST"}:
18995
+ if normalized == "LIST":
18996
+ return "INSIDE"
18997
+ if normalized in {"TOP", "DETAIL", "INSIDE"}:
17354
18998
  return normalized
17355
18999
  return None
17356
19000
 
@@ -17436,7 +19080,7 @@ def _extract_view_button_entries(config: dict[str, Any]) -> tuple[list[dict[str,
17436
19080
  if not isinstance(item, dict):
17437
19081
  continue
17438
19082
  entry = deepcopy(item)
17439
- entry.setdefault("configType", "LIST")
19083
+ entry.setdefault("configType", "INSIDE")
17440
19084
  entries.append(entry)
17441
19085
  return entries, "buttonConfig"
17442
19086
 
@@ -17471,13 +19115,47 @@ def _normalize_view_button_entry(entry: dict[str, Any]) -> dict[str, Any]:
17471
19115
  normalized[public_key] = deepcopy(value)
17472
19116
  trigger_add_data_config = entry.get("triggerAddDataConfig")
17473
19117
  if isinstance(trigger_add_data_config, dict):
17474
- normalized["trigger_add_data_config"] = deepcopy(trigger_add_data_config)
19118
+ normalized["trigger_add_data_config"] = _normalize_custom_button_add_data_config_for_public(trigger_add_data_config)
17475
19119
  trigger_wings_config = entry.get("triggerWingsConfig")
17476
19120
  if isinstance(trigger_wings_config, dict):
17477
19121
  normalized["trigger_wings_config"] = deepcopy(trigger_wings_config)
17478
19122
  return normalized
17479
19123
 
17480
19124
 
19125
+ def _public_view_button_placement(config_type: str | None) -> str | None:
19126
+ normalized = _normalize_view_button_config_type(config_type)
19127
+ if normalized == "TOP":
19128
+ return "header"
19129
+ if normalized == "DETAIL":
19130
+ return "detail"
19131
+ if normalized == "INSIDE":
19132
+ return "list"
19133
+ return None
19134
+
19135
+
19136
+ def _extract_view_buttons_config(config: dict[str, Any]) -> dict[str, Any]:
19137
+ entries, source = _extract_view_button_entries(config)
19138
+ placements: dict[str, list[dict[str, Any]]] = {"header": [], "detail": [], "list": []}
19139
+ items: list[dict[str, Any]] = []
19140
+ for entry in entries:
19141
+ normalized = _normalize_view_button_entry(entry)
19142
+ placement = _public_view_button_placement(normalized.get("config_type"))
19143
+ normalized["placement"] = placement
19144
+ items.append(normalized)
19145
+ if placement:
19146
+ placements.setdefault(placement, []).append(normalized)
19147
+ status = "ok" if items else "none"
19148
+ result: dict[str, Any] = {
19149
+ "status": status,
19150
+ "button_count": len(items),
19151
+ "placements": placements,
19152
+ "items": items,
19153
+ }
19154
+ if source:
19155
+ result["read_source"] = source
19156
+ return result
19157
+
19158
+
17481
19159
  def _normalize_view_buttons_for_compare(value: Any) -> list[dict[str, Any]]:
17482
19160
  if isinstance(value, dict):
17483
19161
  entries, _ = _extract_view_button_entries(value)
@@ -17612,7 +19290,7 @@ def _canonicalize_view_button_summary_order(items: list[dict[str, Any]]) -> list
17612
19290
  detail_main_buttons.append(item)
17613
19291
  elif config_type == "DETAIL":
17614
19292
  detail_more_buttons.append(item)
17615
- elif config_type == "LIST":
19293
+ elif config_type == "INSIDE":
17616
19294
  list_buttons.append(item)
17617
19295
  else:
17618
19296
  other_buttons.append(item)
@@ -17765,7 +19443,7 @@ def _build_grouped_view_button_config(button_config_dtos: list[dict[str, Any]])
17765
19443
  being_main = bool(item.get("beingMain", False))
17766
19444
  if config_type == "TOP":
17767
19445
  grouped["topButtonList"].append(item)
17768
- elif config_type == "LIST":
19446
+ elif config_type == "INSIDE":
17769
19447
  grouped["listButtonList"].append(item)
17770
19448
  elif being_main:
17771
19449
  grouped["mainButtonDetailList"].append(item)
@@ -18577,7 +20255,7 @@ def _build_view_associated_resources_payload(
18577
20255
  "missing_fields": [],
18578
20256
  "received": raw_item_id,
18579
20257
  "message": "associated_item_id must be an app-level associated resource id from app_get.associated_resources",
18580
- "next_action": "call app_get and use associated_resources[].associated_item_id, not chart_id",
20258
+ "next_action": "prefer app_associated_resources_apply for associated report/view display; it can resolve associated_item_id, chart_id/chart_key, or view_key",
18581
20259
  }
18582
20260
  ]
18583
20261
  item_ids.append(item_id)
@@ -18591,7 +20269,7 @@ def _build_view_associated_resources_payload(
18591
20269
  "invalid_associated_item_ids": missing_ids,
18592
20270
  "available_associated_item_ids": sorted(available_by_id),
18593
20271
  "message": "associated_resource references ids that are not in the app-level associated resource pool",
18594
- "next_action": "call app_get and use associated_resources[].associated_item_id, not chart_id",
20272
+ "next_action": "prefer app_associated_resources_apply for associated report/view display; it can resolve associated_item_id, chart_id/chart_key, or view_key",
18595
20273
  }
18596
20274
  ]
18597
20275
  payload = {
@@ -18663,7 +20341,7 @@ def _associated_resources_config_matches(expected: dict[str, Any], actual: dict[
18663
20341
  return True
18664
20342
 
18665
20343
 
18666
- def _normalize_associated_resources_payload(payload: Any) -> list[dict[str, Any]]:
20344
+ def _normalize_associated_resources_payload(payload: Any, *, include_raw: bool = False) -> list[dict[str, Any]]:
18667
20345
  if isinstance(payload, dict):
18668
20346
  raw_items = (
18669
20347
  payload.get("associated_resources")
@@ -18684,7 +20362,7 @@ def _normalize_associated_resources_payload(payload: Any) -> list[dict[str, Any]
18684
20362
  normalized_items: list[dict[str, Any]] = []
18685
20363
  seen_ids: set[int] = set()
18686
20364
  for raw_item in raw_items:
18687
- item = _normalize_associated_resource_item(raw_item)
20365
+ item = _normalize_associated_resource_item(raw_item, include_raw=include_raw)
18688
20366
  item_id = _coerce_positive_int(item.get("associated_item_id"))
18689
20367
  if item_id is None or item_id in seen_ids:
18690
20368
  continue
@@ -18694,7 +20372,7 @@ def _normalize_associated_resources_payload(payload: Any) -> list[dict[str, Any]
18694
20372
  return normalized_items
18695
20373
 
18696
20374
 
18697
- def _normalize_associated_resource_item(raw_item: Any) -> dict[str, Any]:
20375
+ def _normalize_associated_resource_item(raw_item: Any, *, include_raw: bool = False) -> dict[str, Any]:
18698
20376
  if not isinstance(raw_item, dict):
18699
20377
  return {}
18700
20378
  item_id = _first_present(raw_item, "associated_item_id", "associatedItemId", "asosChartId", "id")
@@ -18725,7 +20403,10 @@ def _normalize_associated_resource_item(raw_item: Any) -> dict[str, Any]:
18725
20403
  if chart_type is not None:
18726
20404
  item["chart_type"] = _public_chart_type_from_backend(chart_type)
18727
20405
  item["report_source"] = _public_report_source_from_backend_source(source_type)
18728
- return _compact_dict(item)
20406
+ item = _compact_dict(item)
20407
+ if include_raw:
20408
+ item["_raw"] = deepcopy(raw_item)
20409
+ return item
18729
20410
 
18730
20411
 
18731
20412
  def _normalize_associated_graph_type(raw_item: dict[str, Any], *, chart_key: Any, view_key: Any) -> str:
@@ -18752,6 +20433,14 @@ def _associated_resource_index(resources: list[dict[str, Any]]) -> dict[int, dic
18752
20433
  return indexed
18753
20434
 
18754
20435
 
20436
+ def _strip_internal_associated_resource_raw(resources: list[dict[str, Any]]) -> list[dict[str, Any]]:
20437
+ return [
20438
+ _compact_dict({key: deepcopy(value) for key, value in item.items() if key != "_raw"})
20439
+ for item in resources
20440
+ if isinstance(item, dict)
20441
+ ]
20442
+
20443
+
18755
20444
  def _public_associated_graph_type(value: Any) -> str:
18756
20445
  normalized = str(value or "").strip().lower()
18757
20446
  if normalized in {"view", "viewgraph"}:
@@ -18887,6 +20576,188 @@ def _associated_resource_matches_patch(item: dict[str, Any], patch: AssociatedRe
18887
20576
  return _associated_resource_public_key(item) == _associated_resource_public_key(patch)
18888
20577
 
18889
20578
 
20579
+ _ASSOCIATED_RESOURCE_PARTIAL_PATCH_KEY_ALIASES = {
20580
+ "graph_type": "graph_type",
20581
+ "graphType": "graph_type",
20582
+ "resource_type": "graph_type",
20583
+ "resourceType": "graph_type",
20584
+ "target_app_key": "target_app_key",
20585
+ "targetAppKey": "target_app_key",
20586
+ "app_key": "target_app_key",
20587
+ "appKey": "target_app_key",
20588
+ "view_key": "view_key",
20589
+ "viewKey": "view_key",
20590
+ "viewgraphKey": "view_key",
20591
+ "viewGraphKey": "view_key",
20592
+ "chart_key": "chart_key",
20593
+ "chartKey": "chart_key",
20594
+ "report_source": "report_source",
20595
+ "reportSource": "report_source",
20596
+ "match_rules": "match_rules",
20597
+ "matchRules": "match_rules",
20598
+ "match_mappings": "match_mappings",
20599
+ "matchMappings": "match_mappings",
20600
+ }
20601
+
20602
+ _ASSOCIATED_RESOURCE_PARTIAL_SET_KEYS = {
20603
+ "graph_type",
20604
+ "target_app_key",
20605
+ "view_key",
20606
+ "chart_key",
20607
+ "report_source",
20608
+ "match_rules",
20609
+ "match_mappings",
20610
+ }
20611
+
20612
+ _ASSOCIATED_RESOURCE_PARTIAL_UNSET_KEYS = {"match_rules", "match_mappings"}
20613
+
20614
+
20615
+ def _canonical_associated_resource_partial_patch_key(key: Any) -> str:
20616
+ raw = str(key or "").strip()
20617
+ return _ASSOCIATED_RESOURCE_PARTIAL_PATCH_KEY_ALIASES.get(raw, raw)
20618
+
20619
+
20620
+ def _normalize_associated_resource_partial_set(raw_set: Any, *, reason_path: str) -> tuple[dict[str, Any], list[dict[str, Any]]]:
20621
+ if not isinstance(raw_set, dict):
20622
+ return {}, [{"error_code": "INVALID_ASSOCIATED_RESOURCE_PATCH_SET", "reason_path": reason_path, "message": "patch_resources[].set must be an object"}]
20623
+ normalized: dict[str, Any] = {}
20624
+ issues: list[dict[str, Any]] = []
20625
+ for raw_key, value in raw_set.items():
20626
+ root = _canonical_associated_resource_partial_patch_key(raw_key)
20627
+ if root not in _ASSOCIATED_RESOURCE_PARTIAL_SET_KEYS:
20628
+ issues.append(
20629
+ {
20630
+ "error_code": "UNSUPPORTED_ASSOCIATED_RESOURCE_PATCH_FIELD",
20631
+ "reason_path": f"{reason_path}.{raw_key}",
20632
+ "field": raw_key,
20633
+ "allowed_fields": sorted(_ASSOCIATED_RESOURCE_PARTIAL_SET_KEYS),
20634
+ }
20635
+ )
20636
+ continue
20637
+ normalized[root] = value
20638
+ return normalized, issues
20639
+
20640
+
20641
+ def _normalize_associated_resource_partial_unset(raw_unset: Any, *, reason_path: str) -> tuple[set[str], list[dict[str, Any]]]:
20642
+ if not isinstance(raw_unset, list):
20643
+ return set(), [{"error_code": "INVALID_ASSOCIATED_RESOURCE_PATCH_UNSET", "reason_path": reason_path, "message": "patch_resources[].unset must be a list"}]
20644
+ normalized: set[str] = set()
20645
+ issues: list[dict[str, Any]] = []
20646
+ for index, raw_key in enumerate(raw_unset):
20647
+ root = _canonical_associated_resource_partial_patch_key(raw_key)
20648
+ if root not in _ASSOCIATED_RESOURCE_PARTIAL_UNSET_KEYS:
20649
+ issues.append(
20650
+ {
20651
+ "error_code": "UNSUPPORTED_ASSOCIATED_RESOURCE_PATCH_UNSET_FIELD",
20652
+ "reason_path": f"{reason_path}[{index}]",
20653
+ "field": raw_key,
20654
+ "allowed_fields": sorted(_ASSOCIATED_RESOURCE_PARTIAL_UNSET_KEYS),
20655
+ }
20656
+ )
20657
+ continue
20658
+ normalized.add(root)
20659
+ return normalized, issues
20660
+
20661
+
20662
+ def _associated_resource_upsert_payload_from_existing_item(
20663
+ item: dict[str, Any],
20664
+ *,
20665
+ associated_item_id: int,
20666
+ client_key: str | None = None,
20667
+ ) -> dict[str, Any]:
20668
+ graph_type = str(item.get("graph_type") or item.get("resource_type") or "").strip().lower()
20669
+ payload = {
20670
+ "associated_item_id": associated_item_id,
20671
+ "client_key": client_key,
20672
+ "graph_type": "view" if graph_type == "view" else "chart",
20673
+ "target_app_key": item.get("target_app_key"),
20674
+ "view_key": item.get("view_key"),
20675
+ "chart_key": item.get("chart_key"),
20676
+ "report_source": item.get("report_source"),
20677
+ "match_rules": [],
20678
+ }
20679
+ raw = item.get("_raw") if isinstance(item.get("_raw"), dict) else {}
20680
+ raw_match_rules = raw.get("matchRules") if isinstance(raw, dict) else None
20681
+ if isinstance(raw_match_rules, list):
20682
+ if raw_match_rules and all(isinstance(group, list) for group in raw_match_rules):
20683
+ flattened = [rule for group in raw_match_rules for rule in group if isinstance(rule, dict)]
20684
+ payload["match_rules"] = flattened
20685
+ else:
20686
+ payload["match_rules"] = [rule for rule in raw_match_rules if isinstance(rule, dict)]
20687
+ return _compact_dict(payload)
20688
+
20689
+
20690
+ def _resolve_associated_resource_selector(
20691
+ selector: Any,
20692
+ *,
20693
+ existing_resources: list[dict[str, Any]],
20694
+ existing_by_id: dict[int, dict[str, Any]],
20695
+ reason_path: str,
20696
+ ) -> tuple[int | None, dict[str, Any] | None]:
20697
+ item_id = _coerce_positive_int(selector)
20698
+ if item_id is not None:
20699
+ if item_id in existing_by_id:
20700
+ return item_id, None
20701
+ raw = str(selector or "").strip()
20702
+ if not raw:
20703
+ return None, {
20704
+ "error_code": "ASSOCIATED_RESOURCE_NOT_FOUND",
20705
+ "reason_path": reason_path,
20706
+ "received": selector,
20707
+ "available_associated_item_ids": sorted(existing_by_id),
20708
+ "message": "associated resource selector cannot be empty",
20709
+ }
20710
+ matches: list[dict[str, Any]] = []
20711
+ for resource in existing_resources:
20712
+ if not isinstance(resource, dict):
20713
+ continue
20714
+ candidates = [
20715
+ resource.get("chart_id"),
20716
+ resource.get("chart_key"),
20717
+ resource.get("view_key"),
20718
+ resource.get("associated_item_id"),
20719
+ ]
20720
+ if raw in {str(candidate).strip() for candidate in candidates if candidate not in {None, ""}}:
20721
+ matches.append(resource)
20722
+ if len(matches) == 1:
20723
+ resolved_id = _coerce_positive_int(matches[0].get("associated_item_id"))
20724
+ if resolved_id is not None:
20725
+ return resolved_id, None
20726
+ if len(matches) > 1:
20727
+ return None, {
20728
+ "error_code": "AMBIGUOUS_ASSOCIATED_RESOURCE",
20729
+ "reason_path": reason_path,
20730
+ "received": selector,
20731
+ "candidate_associated_item_ids": [
20732
+ item_id
20733
+ for item_id in (_coerce_positive_int(item.get("associated_item_id")) for item in matches)
20734
+ if item_id is not None
20735
+ ],
20736
+ "message": "selector matches multiple associated resources; pass associated_item_id",
20737
+ }
20738
+ return None, {
20739
+ "error_code": "ASSOCIATED_RESOURCE_NOT_FOUND",
20740
+ "reason_path": reason_path,
20741
+ "received": selector,
20742
+ "available_associated_item_ids": sorted(existing_by_id),
20743
+ "available_chart_ids": sorted(
20744
+ {
20745
+ str(resource.get("chart_id") or resource.get("chart_key") or "").strip()
20746
+ for resource in existing_resources
20747
+ if str(resource.get("chart_id") or resource.get("chart_key") or "").strip()
20748
+ }
20749
+ ),
20750
+ "available_view_keys": sorted(
20751
+ {
20752
+ str(resource.get("view_key") or "").strip()
20753
+ for resource in existing_resources
20754
+ if str(resource.get("view_key") or "").strip()
20755
+ }
20756
+ ),
20757
+ "message": "selector must be an associated_item_id, QingBI chart_id/chart_key, or Qingflow view_key from the app-level associated resource pool",
20758
+ }
20759
+
20760
+
18890
20761
  def _associated_resource_not_found_issue(reason_path: str, associated_item_id: int, existing_by_id: dict[int, dict[str, Any]]) -> dict[str, Any]:
18891
20762
  return {
18892
20763
  "error_code": "ASSOCIATED_RESOURCE_NOT_FOUND",
@@ -18894,7 +20765,7 @@ def _associated_resource_not_found_issue(reason_path: str, associated_item_id: i
18894
20765
  "associated_item_id": associated_item_id,
18895
20766
  "available_associated_item_ids": sorted(existing_by_id),
18896
20767
  "message": "associated_item_id is not in the app-level associated resource pool",
18897
- "next_action": "call app_get and use associated_resources[].associated_item_id; do not pass chart_id/chart_key",
20768
+ "next_action": "call app_get and use associated_resources[].associated_item_id, chart_id/chart_key, or view_key",
18898
20769
  }
18899
20770
 
18900
20771
 
@@ -18907,14 +20778,33 @@ def _duplicate_associated_resource_issue(reason_path: str, associated_item_id: i
18907
20778
  }
18908
20779
 
18909
20780
 
18910
- def _serialize_associated_resource_match_rules(match_rules: list[CustomButtonMatchRulePatch]) -> list[list[dict[str, Any]]]:
20781
+ def _associated_resource_patch_has_match_config(patch: AssociatedResourceUpsertPatch) -> bool:
20782
+ fields_set = getattr(patch, "model_fields_set", set())
20783
+ return bool(patch.match_mappings) or bool(patch.match_rules) or "match_mappings" in fields_set
20784
+
20785
+
20786
+ def _serialize_associated_resource_match_rules(match_rules: list[Any]) -> list[list[dict[str, Any]]]:
18911
20787
  if not match_rules:
18912
20788
  return []
18913
- return [[_serialize_custom_button_match_rule(rule.model_dump(mode="json", exclude_none=True)) for rule in match_rules]]
20789
+ serialized: list[dict[str, Any]] = []
20790
+ for rule in match_rules:
20791
+ if isinstance(rule, CustomButtonMatchRulePatch):
20792
+ payload = rule.model_dump(mode="json", exclude_none=True)
20793
+ elif isinstance(rule, dict):
20794
+ payload = rule
20795
+ else:
20796
+ continue
20797
+ serialized.append(_serialize_custom_button_match_rule(payload))
20798
+ return [serialized] if serialized else []
18914
20799
 
18915
20800
 
18916
- def _serialize_associated_resource_create_payload(patch: AssociatedResourceUpsertPatch) -> dict[str, Any]:
20801
+ def _serialize_associated_resource_create_payload(
20802
+ patch: AssociatedResourceUpsertPatch,
20803
+ *,
20804
+ match_rules_override: list[dict[str, Any]] | None = None,
20805
+ ) -> dict[str, Any]:
18917
20806
  graph_type = _public_associated_graph_type(patch.graph_type)
20807
+ match_rules = match_rules_override if match_rules_override is not None else patch.match_rules
18918
20808
  return {
18919
20809
  "appKey": patch.target_app_key,
18920
20810
  "chartList": [
@@ -18924,22 +20814,32 @@ def _serialize_associated_resource_create_payload(patch: AssociatedResourceUpser
18924
20814
  "graphType": _backend_associated_graph_type(graph_type),
18925
20815
  }
18926
20816
  ],
18927
- "matchRules": _serialize_associated_resource_match_rules(patch.match_rules),
20817
+ "matchRules": _serialize_associated_resource_match_rules(match_rules),
18928
20818
  }
18929
20819
 
18930
20820
 
18931
- def _serialize_associated_resource_update_payload(patch: AssociatedResourceUpsertPatch, *, associated_item_id: int) -> dict[str, Any]:
20821
+ def _serialize_associated_resource_update_payload(
20822
+ patch: AssociatedResourceUpsertPatch,
20823
+ *,
20824
+ associated_item_id: int,
20825
+ existing_item: dict[str, Any] | None = None,
20826
+ match_rules_override: list[dict[str, Any]] | None = None,
20827
+ ) -> dict[str, Any]:
18932
20828
  graph_type = _public_associated_graph_type(patch.graph_type)
18933
- return _compact_dict(
20829
+ raw = existing_item.get("_raw") if isinstance(existing_item, dict) and isinstance(existing_item.get("_raw"), dict) else None
20830
+ payload = deepcopy(raw) if isinstance(raw, dict) else {}
20831
+ match_rules = match_rules_override if match_rules_override is not None else patch.match_rules
20832
+ payload.update(
18934
20833
  {
18935
20834
  "id": associated_item_id,
18936
20835
  "appKey": patch.target_app_key,
18937
20836
  "chartKey": patch.view_key if graph_type == "view" else patch.chart_key,
18938
20837
  "sourceType": _backend_source_type_for_associated_resource_patch(patch),
18939
20838
  "graphType": _backend_associated_graph_type(graph_type),
18940
- "matchRules": _serialize_associated_resource_match_rules(patch.match_rules),
20839
+ "matchRules": _serialize_associated_resource_match_rules(match_rules),
18941
20840
  }
18942
20841
  )
20842
+ return _compact_dict(payload)
18943
20843
 
18944
20844
 
18945
20845
  def _associated_resource_result_entry(
@@ -19014,6 +20914,204 @@ def _build_view_buttons_only_update_payload(config: Any, *, button_config_dtos:
19014
20914
  return payload
19015
20915
 
19016
20916
 
20917
+ _VIEW_PARTIAL_PATCH_KEY_ALIASES = {
20918
+ "view_name": "name",
20919
+ "viewName": "name",
20920
+ "view_key": "view_key",
20921
+ "viewKey": "view_key",
20922
+ "viewgraphKey": "view_key",
20923
+ "type": "type",
20924
+ "view_type": "type",
20925
+ "viewType": "type",
20926
+ "columns": "columns",
20927
+ "column_names": "columns",
20928
+ "columnNames": "columns",
20929
+ "fields": "columns",
20930
+ "group_by": "group_by",
20931
+ "groupBy": "group_by",
20932
+ "filters": "filters",
20933
+ "filter_rules": "filters",
20934
+ "filterRules": "filters",
20935
+ "start_field": "start_field",
20936
+ "startField": "start_field",
20937
+ "end_field": "end_field",
20938
+ "endField": "end_field",
20939
+ "title_field": "title_field",
20940
+ "titleField": "title_field",
20941
+ "buttons": "buttons",
20942
+ "visibility": "visibility",
20943
+ "query_conditions": "query_conditions",
20944
+ "queryConditions": "query_conditions",
20945
+ "query_condition": "query_conditions",
20946
+ "queryCondition": "query_conditions",
20947
+ "associated_resources": "associated_resources",
20948
+ "associatedResources": "associated_resources",
20949
+ "associated_reports": "associated_resources",
20950
+ "associatedReports": "associated_resources",
20951
+ }
20952
+
20953
+
20954
+ def _canonical_view_partial_patch_key(key: Any) -> str:
20955
+ raw = str(key or "").strip()
20956
+ return _VIEW_PARTIAL_PATCH_KEY_ALIASES.get(raw, raw)
20957
+
20958
+
20959
+ def _normalize_view_partial_set(raw_set: Any, *, reason_path: str) -> tuple[dict[str, Any], list[dict[str, Any]]]:
20960
+ if not isinstance(raw_set, dict):
20961
+ return {}, [{"error_code": "INVALID_VIEW_PATCH_SET", "reason_path": reason_path, "message": "patch_views[].set must be an object"}]
20962
+ normalized: dict[str, Any] = {}
20963
+ issues: list[dict[str, Any]] = []
20964
+ for raw_key, value in raw_set.items():
20965
+ key_path = [part for part in str(raw_key or "").strip().split(".") if part]
20966
+ if not key_path:
20967
+ continue
20968
+ root = _canonical_view_partial_patch_key(key_path[0])
20969
+ if root not in _VIEW_PARTIAL_SET_KEYS:
20970
+ issues.append(
20971
+ {
20972
+ "error_code": "UNSUPPORTED_VIEW_PATCH_FIELD",
20973
+ "reason_path": f"{reason_path}.{raw_key}",
20974
+ "field": raw_key,
20975
+ "allowed_fields": sorted(_VIEW_PARTIAL_SET_KEYS),
20976
+ }
20977
+ )
20978
+ continue
20979
+ if len(key_path) == 1:
20980
+ normalized[root] = value
20981
+ continue
20982
+ target = normalized.setdefault(root, {})
20983
+ if not isinstance(target, dict):
20984
+ issues.append(
20985
+ {
20986
+ "error_code": "INVALID_VIEW_PATCH_PATH",
20987
+ "reason_path": f"{reason_path}.{raw_key}",
20988
+ "message": "cannot combine a scalar replacement and nested patch for the same field",
20989
+ }
20990
+ )
20991
+ continue
20992
+ cursor = target
20993
+ for part in key_path[1:-1]:
20994
+ next_value = cursor.setdefault(part, {})
20995
+ if not isinstance(next_value, dict):
20996
+ issues.append({"error_code": "INVALID_VIEW_PATCH_PATH", "reason_path": f"{reason_path}.{raw_key}"})
20997
+ break
20998
+ cursor = next_value
20999
+ else:
21000
+ cursor[key_path[-1]] = value
21001
+ return normalized, issues
21002
+
21003
+
21004
+ def _normalize_view_partial_unset(raw_unset: Any, *, reason_path: str) -> tuple[set[str], list[dict[str, Any]]]:
21005
+ if not isinstance(raw_unset, list):
21006
+ return set(), [{"error_code": "INVALID_VIEW_PATCH_UNSET", "reason_path": reason_path, "message": "patch_views[].unset must be a list"}]
21007
+ normalized: set[str] = set()
21008
+ issues: list[dict[str, Any]] = []
21009
+ for index, raw_key in enumerate(raw_unset):
21010
+ root = _canonical_view_partial_patch_key(str(raw_key or "").strip().split(".")[0])
21011
+ if root not in _VIEW_PARTIAL_UNSET_KEYS:
21012
+ issues.append(
21013
+ {
21014
+ "error_code": "UNSUPPORTED_VIEW_PATCH_UNSET_FIELD",
21015
+ "reason_path": f"{reason_path}[{index}]",
21016
+ "field": raw_key,
21017
+ "allowed_fields": sorted(_VIEW_PARTIAL_UNSET_KEYS),
21018
+ }
21019
+ )
21020
+ continue
21021
+ normalized.add(root)
21022
+ return normalized, issues
21023
+
21024
+
21025
+ _VIEW_PARTIAL_SET_KEYS = {
21026
+ "name",
21027
+ "type",
21028
+ "columns",
21029
+ "group_by",
21030
+ "filters",
21031
+ "start_field",
21032
+ "end_field",
21033
+ "title_field",
21034
+ "buttons",
21035
+ "visibility",
21036
+ "query_conditions",
21037
+ "associated_resources",
21038
+ }
21039
+
21040
+ _VIEW_PARTIAL_UNSET_KEYS = {"filters", "buttons", "visibility", "query_conditions", "associated_resources"}
21041
+
21042
+
21043
+ def _deep_merge_public_config(target: dict[str, Any], patch: dict[str, Any]) -> None:
21044
+ for key, value in patch.items():
21045
+ if isinstance(value, dict) and isinstance(target.get(key), dict):
21046
+ _deep_merge_public_config(cast(dict[str, Any], target[key]), value)
21047
+ else:
21048
+ target[key] = deepcopy(value)
21049
+
21050
+
21051
+ def _field_name_by_id(field_names_by_id: dict[int, str], field_id: Any) -> str | None:
21052
+ normalized_id = _coerce_nonnegative_int(field_id)
21053
+ if normalized_id is None:
21054
+ return None
21055
+ name = str(field_names_by_id.get(normalized_id) or "").strip()
21056
+ return name or None
21057
+
21058
+
21059
+ def _view_upsert_payload_from_existing_view(
21060
+ *,
21061
+ config: dict[str, Any],
21062
+ summary: dict[str, Any],
21063
+ view_key: str,
21064
+ field_names_by_id: dict[int, str],
21065
+ ) -> dict[str, Any]:
21066
+ view_type = _normalize_view_type_name(config.get("viewgraphType") or summary.get("type"))
21067
+ configured_columns = [
21068
+ name
21069
+ for name in (_field_name_by_id(field_names_by_id, item) for item in (config.get("viewgraphQueIds") or []))
21070
+ if name and name not in _KNOWN_SYSTEM_VIEW_COLUMNS
21071
+ ]
21072
+ columns = [
21073
+ str(name or "").strip()
21074
+ for name in (summary.get("apply_columns") or summary.get("columns") or [])
21075
+ if str(name or "").strip() and str(name or "").strip() not in _KNOWN_SYSTEM_VIEW_COLUMNS
21076
+ ]
21077
+ if configured_columns and (not columns or len(configured_columns) >= len(columns)):
21078
+ columns = configured_columns
21079
+ display_config = summary.get("display_config") if isinstance(summary.get("display_config"), dict) else {}
21080
+ group_by = str(summary.get("group_by") or display_config.get("group_by") or "").strip() or _field_name_by_id(field_names_by_id, config.get("groupQueId"))
21081
+ title_field = str(display_config.get("title_field") or "").strip() or _field_name_by_id(field_names_by_id, config.get("titleQue"))
21082
+ gantt_config = config.get("viewgraphGanttConfigVO") if isinstance(config.get("viewgraphGanttConfigVO"), dict) else {}
21083
+ start_field = _field_name_by_id(field_names_by_id, gantt_config.get("startTimeQueId")) if isinstance(gantt_config, dict) else None
21084
+ end_field = _field_name_by_id(field_names_by_id, gantt_config.get("endTimeQueId")) if isinstance(gantt_config, dict) else None
21085
+ gantt_title = _field_name_by_id(field_names_by_id, gantt_config.get("titleQueId")) if isinstance(gantt_config, dict) else None
21086
+ query_conditions = _extract_view_query_conditions_config(config)
21087
+ query_conditions.pop("row_field_ids", None)
21088
+ associated_resources = _extract_view_associated_resources_config(config)
21089
+ payload: dict[str, Any] = {
21090
+ "name": str(summary.get("name") or config.get("viewgraphName") or config.get("viewName") or view_key).strip(),
21091
+ "view_key": view_key,
21092
+ "type": view_type or "table",
21093
+ "columns": columns,
21094
+ "group_by": group_by,
21095
+ "filters": [],
21096
+ "query_conditions": query_conditions,
21097
+ "associated_resources": {
21098
+ "visible": bool(associated_resources.get("visible", False)),
21099
+ "limit_type": associated_resources.get("limit_type"),
21100
+ "associated_item_ids": list(associated_resources.get("configured_associated_item_ids") or []),
21101
+ },
21102
+ }
21103
+ if title_field:
21104
+ payload["title_field"] = title_field
21105
+ if view_type == "gantt":
21106
+ if start_field:
21107
+ payload["start_field"] = start_field
21108
+ if end_field:
21109
+ payload["end_field"] = end_field
21110
+ if gantt_title:
21111
+ payload["title_field"] = gantt_title
21112
+ return payload
21113
+
21114
+
19017
21115
  def _first_present(mapping: dict[str, Any], *keys: str) -> Any:
19018
21116
  for key in keys:
19019
21117
  if key in mapping and mapping[key] is not None:
@@ -19028,7 +21126,7 @@ def _build_view_create_payload(
19028
21126
  schema: dict[str, Any],
19029
21127
  patch: ViewUpsertPatch,
19030
21128
  ordinal: int,
19031
- view_filters: list[list[dict[str, Any]]],
21129
+ view_filters: list[list[dict[str, Any]]] | None,
19032
21130
  current_fields_by_name: dict[str, dict[str, Any]],
19033
21131
  auth_override: dict[str, Any] | None = None,
19034
21132
  explicit_button_dtos: list[dict[str, Any]] | None = None,
@@ -19226,7 +21324,7 @@ def _build_view_update_payload(
19226
21324
  source_viewgraph_key: str,
19227
21325
  schema: dict[str, Any],
19228
21326
  patch: ViewUpsertPatch,
19229
- view_filters: list[list[dict[str, Any]]],
21327
+ view_filters: list[list[dict[str, Any]]] | None,
19230
21328
  current_fields_by_name: dict[str, dict[str, Any]],
19231
21329
  auth_override: dict[str, Any] | None = None,
19232
21330
  explicit_button_dtos: list[dict[str, Any]] | None = None,
@@ -19334,7 +21432,7 @@ def _build_minimal_view_payload(
19334
21432
  schema: dict[str, Any],
19335
21433
  patch: ViewUpsertPatch,
19336
21434
  ordinal: int,
19337
- view_filters: list[list[dict[str, Any]]],
21435
+ view_filters: list[list[dict[str, Any]]] | None,
19338
21436
  current_fields_by_name: dict[str, dict[str, Any]],
19339
21437
  auth_override: dict[str, Any] | None = None,
19340
21438
  explicit_button_dtos: list[dict[str, Any]] | None = None,
@@ -19403,9 +21501,14 @@ def _hydrate_view_backend_payload(
19403
21501
  data.setdefault("beingImageAdaption", False)
19404
21502
  data.setdefault("clippingMode", "default")
19405
21503
  data.setdefault("frontCoverQueId", None)
19406
- data["viewgraphLimitType"] = 1
19407
- data["viewgraphLimit"] = deepcopy(view_filters) if view_filters else []
19408
- data["viewgraphLimitFormula"] = ""
21504
+ if view_filters is None:
21505
+ data.setdefault("viewgraphLimitType", 1)
21506
+ data.setdefault("viewgraphLimit", [])
21507
+ data.setdefault("viewgraphLimitFormula", "")
21508
+ else:
21509
+ data["viewgraphLimitType"] = 1
21510
+ data["viewgraphLimit"] = deepcopy(view_filters)
21511
+ data["viewgraphLimitFormula"] = ""
19409
21512
  data.setdefault("sortType", "defaultSort")
19410
21513
  if not data.get("viewgraphSorts"):
19411
21514
  sort_que_id = visible_que_id_values[0] if visible_que_id_values else 1
@@ -19417,8 +21520,11 @@ def _hydrate_view_backend_payload(
19417
21520
  data.setdefault("printTpls", [])
19418
21521
  data.setdefault("beingCommentStatus", False)
19419
21522
  data.setdefault("usages", [])
19420
- data["dataPermissionType"] = "CUSTOM"
19421
- data["dataScope"] = "CUSTOM" if view_filters else "ALL"
21523
+ data.setdefault("dataPermissionType", "CUSTOM")
21524
+ if view_filters is None:
21525
+ data.setdefault("dataScope", "ALL")
21526
+ else:
21527
+ data["dataScope"] = "CUSTOM" if view_filters else "ALL"
19422
21528
  data.setdefault("needPass", False)
19423
21529
  data.setdefault("beingWorkflowNodeFutureListVisible", True)
19424
21530
  data.setdefault("asosChartConfig", {"limitType": 1, "asosChartIdList": []})
@@ -19713,6 +21819,44 @@ def _normalize_view_filter_groups_for_compare(groups: Any) -> list[list[dict[str
19713
21819
  return normalized_groups
19714
21820
 
19715
21821
 
21822
+ def _view_filter_rule_values_for_signature(rule: dict[str, Any]) -> list[str]:
21823
+ values = [str(value) for value in (rule.get("judgeValues") or [])]
21824
+ if values:
21825
+ return values
21826
+ fallback_values: list[str] = []
21827
+ for detail in rule.get("judgeValueDetails") or []:
21828
+ if not isinstance(detail, dict):
21829
+ continue
21830
+ item_id = detail.get("id")
21831
+ item_value = detail.get("value")
21832
+ if item_id is not None:
21833
+ fallback_values.append(str(item_id))
21834
+ elif item_value is not None:
21835
+ fallback_values.append(str(item_value))
21836
+ return fallback_values
21837
+
21838
+
21839
+ def _view_filter_groups_signature(groups: Any) -> list[list[dict[str, Any]]]:
21840
+ signature: list[list[dict[str, Any]]] = []
21841
+ for group in _normalize_view_filter_groups_for_compare(groups):
21842
+ group_signature: list[dict[str, Any]] = []
21843
+ for rule in group:
21844
+ group_signature.append(
21845
+ {
21846
+ "queId": rule.get("queId"),
21847
+ "judgeType": rule.get("judgeType"),
21848
+ "judgeValues": _view_filter_rule_values_for_signature(rule),
21849
+ }
21850
+ )
21851
+ if group_signature:
21852
+ signature.append(group_signature)
21853
+ return signature
21854
+
21855
+
21856
+ def _view_filter_groups_equivalent(expected: Any, actual: Any) -> bool:
21857
+ return _view_filter_groups_signature(expected) == _view_filter_groups_signature(actual)
21858
+
21859
+
19716
21860
  def _infer_status_field_id(fields: list[dict[str, Any]]) -> str | None:
19717
21861
  preferred_names = {"status", "状态", "订单状态", "审批状态", "流程状态"}
19718
21862
  for field in fields: