@qingflow-tech/qingflow-app-builder-mcp 1.0.7 → 1.0.8

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,44 @@ 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()
3273
3675
 
3274
- for index, patch in enumerate(request.upsert_resources):
3676
+ upsert_resources = list(request.upsert_resources)
3677
+ if request.patch_resources:
3678
+ expanded_resources, patch_issues, patch_results = self._expand_associated_resource_partial_patches(
3679
+ existing_by_id=existing_by_id,
3680
+ patch_resources=request.patch_resources,
3681
+ )
3682
+ if patch_issues:
3683
+ return finalize(
3684
+ _failed(
3685
+ "ASSOCIATED_RESOURCE_PATCH_HYDRATION_FAILED",
3686
+ "one or more associated resource partial patches could not be hydrated; no write was executed",
3687
+ normalized_args=normalized_args,
3688
+ details={"patch_results": patch_results, "blocking_issues": patch_issues},
3689
+ suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
3690
+ )
3691
+ )
3692
+ upsert_resources.extend(expanded_resources)
3693
+ force_update_resource_ids.update(
3694
+ item_id
3695
+ for item_id in (_coerce_positive_int(patch.associated_item_id) for patch in expanded_resources)
3696
+ if item_id is not None
3697
+ )
3698
+ normalized_args["upsert_resources"] = [patch.model_dump(mode="json") for patch in upsert_resources]
3699
+ normalized_args["patch_results"] = patch_results
3700
+
3701
+ compiled_resource_match_rules, resource_match_issues = self._compile_associated_resource_semantic_match_mappings(
3702
+ profile=profile,
3703
+ app_key=app_key,
3704
+ patches=upsert_resources,
3705
+ )
3706
+ normalized_args["compiled_match_rules"] = {
3707
+ str(index): _summarize_compiled_match_rules(rules)
3708
+ for index, rules in compiled_resource_match_rules.items()
3709
+ }
3710
+
3711
+ for index, patch in enumerate(upsert_resources):
3275
3712
  client_key = str(patch.client_key or "").strip()
3276
3713
  if client_key:
3277
3714
  if client_key in used_client_keys:
@@ -3282,6 +3719,8 @@ class AiBuilderFacade:
3282
3719
  if validation_issue is not None:
3283
3720
  blocking_issues.append(validation_issue)
3284
3721
  continue
3722
+ if index in {issue.get("_patch_index") for issue in resource_match_issues if isinstance(issue, dict)}:
3723
+ continue
3285
3724
  associated_item_id = _coerce_positive_int(patch.associated_item_id)
3286
3725
  if associated_item_id is not None:
3287
3726
  if associated_item_id not in existing_by_id:
@@ -3293,7 +3732,14 @@ class AiBuilderFacade:
3293
3732
  touched_ids.add(associated_item_id)
3294
3733
  if client_key:
3295
3734
  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"
3735
+ operation = (
3736
+ "update"
3737
+ if associated_item_id in force_update_resource_ids
3738
+ or _associated_resource_patch_has_match_config(patch)
3739
+ else "unchanged"
3740
+ if _associated_resource_matches_patch(existing_by_id[associated_item_id], patch)
3741
+ else "update"
3742
+ )
3297
3743
  upsert_ops.append({"operation": operation, "associated_item_id": associated_item_id, "patch": patch, "index": index})
3298
3744
  continue
3299
3745
  matches = [
@@ -3327,7 +3773,8 @@ class AiBuilderFacade:
3327
3773
  touched_ids.add(matched_id)
3328
3774
  if client_key:
3329
3775
  client_key_to_id[client_key] = matched_id
3330
- upsert_ops.append({"operation": "unchanged", "associated_item_id": matched_id, "patch": patch, "index": index})
3776
+ operation = "update" if _associated_resource_patch_has_match_config(patch) else "unchanged"
3777
+ upsert_ops.append({"operation": operation, "associated_item_id": matched_id, "patch": patch, "index": index})
3331
3778
  else:
3332
3779
  upsert_ops.append({"operation": "create", "associated_item_id": None, "patch": patch, "index": index})
3333
3780
 
@@ -3398,6 +3845,13 @@ class AiBuilderFacade:
3398
3845
  }
3399
3846
  )
3400
3847
 
3848
+ blocking_issues.extend(
3849
+ [
3850
+ {key: value for key, value in issue.items() if key != "_patch_index"}
3851
+ for issue in resource_match_issues
3852
+ ]
3853
+ )
3854
+
3401
3855
  if blocking_issues:
3402
3856
  return finalize(
3403
3857
  _failed(
@@ -3453,7 +3907,7 @@ class AiBuilderFacade:
3453
3907
  "safe_to_retry": True,
3454
3908
  "publish_requested": False,
3455
3909
  "published": False,
3456
- "associated_resources": existing_resources,
3910
+ "associated_resources": _strip_internal_associated_resource_raw(existing_resources),
3457
3911
  }
3458
3912
  return finalize(response)
3459
3913
 
@@ -3485,7 +3939,12 @@ class AiBuilderFacade:
3485
3939
  client_key_to_id[str(patch.client_key)] = item_id
3486
3940
  elif op["operation"] == "create":
3487
3941
  write_executed = True
3488
- self._associated_resource_create(profile=profile, app_key=app_key, patch=patch)
3942
+ self._associated_resource_create(
3943
+ profile=profile,
3944
+ app_key=app_key,
3945
+ patch=patch,
3946
+ match_rules_override=compiled_resource_match_rules.get(int(op["index"])),
3947
+ )
3489
3948
  readback_resources = self._load_associated_resources_for_builder(profile=profile, app_key=app_key)
3490
3949
  matches = [
3491
3950
  item
@@ -3500,7 +3959,14 @@ class AiBuilderFacade:
3500
3959
  else:
3501
3960
  item_id = int(op["associated_item_id"])
3502
3961
  write_executed = True
3503
- self._associated_resource_update(profile=profile, app_key=app_key, associated_item_id=item_id, patch=patch)
3962
+ self._associated_resource_update(
3963
+ profile=profile,
3964
+ app_key=app_key,
3965
+ associated_item_id=item_id,
3966
+ patch=patch,
3967
+ existing_item=existing_by_id.get(item_id),
3968
+ match_rules_override=compiled_resource_match_rules.get(int(op["index"])),
3969
+ )
3504
3970
  updated.append(_associated_resource_result_entry("update", op["index"], patch, associated_item_id=item_id))
3505
3971
  if patch.client_key:
3506
3972
  client_key_to_id[str(patch.client_key)] = item_id
@@ -3641,7 +4107,15 @@ class AiBuilderFacade:
3641
4107
  "normalized_args": normalized_args,
3642
4108
  "missing_fields": [],
3643
4109
  "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},
4110
+ "details": {
4111
+ "edit_version_no": edit_version_no,
4112
+ "associated_item_ids_by_client_key": client_key_to_id,
4113
+ "readback_failed": readback_failed,
4114
+ "compiled_match_rules": {
4115
+ str(index): _summarize_compiled_match_rules(rules)
4116
+ for index, rules in compiled_resource_match_rules.items()
4117
+ },
4118
+ },
3645
4119
  "request_id": None,
3646
4120
  "suggested_next_call": None if verified else {"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
3647
4121
  "noop": False,
@@ -4835,7 +5309,7 @@ class AiBuilderFacade:
4835
5309
  fallback_items = self.charts.qingbi_report_list(profile=profile, app_key=app_key).get("items") or []
4836
5310
  return list(fallback_items) if isinstance(fallback_items, list) else [], "fallback"
4837
5311
 
4838
- def _load_associated_resources_for_builder(self, *, profile: str, app_key: str) -> list[dict[str, Any]]:
5312
+ def _load_associated_resources_for_builder(self, *, profile: str, app_key: str, include_raw: bool = False) -> list[dict[str, Any]]:
4839
5313
  def runner(_: Any, context: BackendRequestContext) -> list[dict[str, Any]]:
4840
5314
  payload = self.apps.backend.request(
4841
5315
  "GET",
@@ -4843,12 +5317,19 @@ class AiBuilderFacade:
4843
5317
  f"/app/{app_key}/asosChart",
4844
5318
  params={"role": 1, "beingDraft": True},
4845
5319
  )
4846
- return _normalize_associated_resources_payload(payload)
5320
+ return _normalize_associated_resources_payload(payload, include_raw=include_raw)
4847
5321
 
4848
5322
  return self.apps._run(profile, runner)
4849
5323
 
4850
- def _associated_resource_create(self, *, profile: str, app_key: str, patch: AssociatedResourceUpsertPatch) -> JSONObject:
4851
- payload = _serialize_associated_resource_create_payload(patch)
5324
+ def _associated_resource_create(
5325
+ self,
5326
+ *,
5327
+ profile: str,
5328
+ app_key: str,
5329
+ patch: AssociatedResourceUpsertPatch,
5330
+ match_rules_override: list[dict[str, Any]] | None = None,
5331
+ ) -> JSONObject:
5332
+ payload = _serialize_associated_resource_create_payload(patch, match_rules_override=match_rules_override)
4852
5333
 
4853
5334
  def runner(_: Any, context: BackendRequestContext) -> JSONObject:
4854
5335
  result = self.apps.backend.request("POST", context, f"/app/{app_key}/asosChart", json_body=payload)
@@ -4863,8 +5344,15 @@ class AiBuilderFacade:
4863
5344
  app_key: str,
4864
5345
  associated_item_id: int,
4865
5346
  patch: AssociatedResourceUpsertPatch,
5347
+ existing_item: dict[str, Any] | None = None,
5348
+ match_rules_override: list[dict[str, Any]] | None = None,
4866
5349
  ) -> JSONObject:
4867
- payload = _serialize_associated_resource_update_payload(patch, associated_item_id=associated_item_id)
5350
+ payload = _serialize_associated_resource_update_payload(
5351
+ patch,
5352
+ associated_item_id=associated_item_id,
5353
+ existing_item=existing_item,
5354
+ match_rules_override=match_rules_override,
5355
+ )
4868
5356
 
4869
5357
  def runner(_: Any, context: BackendRequestContext) -> JSONObject:
4870
5358
  result = self.apps.backend.request("POST", context, f"/app/{app_key}/asosChart/{associated_item_id}", json_body=payload)
@@ -4984,6 +5472,7 @@ class AiBuilderFacade:
4984
5472
  continue
4985
5473
  compiled_config, config_issues = self._compile_custom_button_add_data_config(
4986
5474
  profile=profile,
5475
+ source_app_key=app_key,
4987
5476
  source_fields=list(source_fields),
4988
5477
  target_fields=target_schema_cache[target_app_key],
4989
5478
  target_app_key=target_app_key,
@@ -5001,6 +5490,7 @@ class AiBuilderFacade:
5001
5490
  self,
5002
5491
  *,
5003
5492
  profile: str,
5493
+ source_app_key: str,
5004
5494
  source_fields: list[dict[str, Any]],
5005
5495
  target_fields: list[dict[str, Any]],
5006
5496
  target_app_key: str,
@@ -5035,6 +5525,9 @@ class AiBuilderFacade:
5035
5525
  source_field=source_field,
5036
5526
  target_field=target_field,
5037
5527
  reason_path=f"{reason_path}.field_mappings[{mapping_index}]",
5528
+ source_app_key=source_app_key,
5529
+ error_code="CUSTOM_BUTTON_MAPPING_TYPE_MISMATCH",
5530
+ context_label="addData copy mapping",
5038
5531
  )
5039
5532
  if type_issue:
5040
5533
  issues.append(type_issue)
@@ -5349,19 +5842,19 @@ class AiBuilderFacade:
5349
5842
  except (QingflowApiError, RuntimeError) as error:
5350
5843
  api_error = _coerce_api_error(error)
5351
5844
  has_requested_list_buttons = any(
5352
- _normalize_view_button_config_type(item.get("configType")) == "LIST"
5845
+ _normalize_view_button_config_type(item.get("configType")) == "INSIDE"
5353
5846
  for item in new_dtos
5354
5847
  if isinstance(item, dict)
5355
5848
  )
5356
5849
  fallback_dtos = [
5357
5850
  item
5358
5851
  for item in merged_dtos
5359
- if _normalize_view_button_config_type(item.get("configType")) != "LIST"
5852
+ if _normalize_view_button_config_type(item.get("configType")) != "INSIDE"
5360
5853
  ]
5361
5854
  fallback_new_dtos = [
5362
5855
  item
5363
5856
  for item in new_dtos
5364
- if _normalize_view_button_config_type(item.get("configType")) != "LIST"
5857
+ if _normalize_view_button_config_type(item.get("configType")) != "INSIDE"
5365
5858
  ]
5366
5859
  if has_requested_list_buttons and fallback_new_dtos and fallback_dtos != merged_dtos:
5367
5860
  fallback_payload = _build_view_buttons_only_update_payload(current_config, button_config_dtos=fallback_dtos)
@@ -5373,12 +5866,12 @@ class AiBuilderFacade:
5373
5866
  "index": config_index,
5374
5867
  "operation": "view_config",
5375
5868
  "status": "failed",
5376
- "error_code": "LIST_BUTTON_BACKEND_UNSUPPORTED",
5869
+ "error_code": "INSIDE_BUTTON_BACKEND_UNSUPPORTED",
5377
5870
  "view_key": view_key,
5378
- "message": "backend rejected list-button placement; header/detail placements were retried without list buttons",
5871
+ "message": "backend rejected inside/list-button placement; header/detail placements were retried without inside buttons",
5379
5872
  "backend_message": api_error.message,
5380
5873
  "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",
5874
+ "next_action": "use placement=header/detail for now, or verify the backend inside-button payload separately",
5382
5875
  }
5383
5876
  failed.append(unsupported_list_issue)
5384
5877
  except (QingflowApiError, RuntimeError) as fallback_error:
@@ -5402,12 +5895,12 @@ class AiBuilderFacade:
5402
5895
  "index": config_index,
5403
5896
  "operation": "view_config",
5404
5897
  "status": "failed",
5405
- "error_code": "LIST_BUTTON_BACKEND_UNSUPPORTED",
5898
+ "error_code": "INSIDE_BUTTON_BACKEND_UNSUPPORTED",
5406
5899
  "view_key": view_key,
5407
- "message": "backend rejected list-button placement",
5900
+ "message": "backend rejected inside/list-button placement",
5408
5901
  "backend_message": api_error.message,
5409
5902
  "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",
5903
+ "next_action": "use placement=header/detail for now, or verify the backend inside-button payload separately",
5411
5904
  }
5412
5905
  failed.append(issue)
5413
5906
  results.append(issue)
@@ -5648,6 +6141,7 @@ class AiBuilderFacade:
5648
6141
  config if isinstance(config, dict) else {},
5649
6142
  available_resources=associated_resources,
5650
6143
  )
6144
+ buttons_config = _extract_view_buttons_config(config if isinstance(config, dict) else {})
5651
6145
 
5652
6146
  response = ViewGetResponse(
5653
6147
  view_key=view_key,
@@ -5656,6 +6150,7 @@ class AiBuilderFacade:
5656
6150
  config=deepcopy(config) if isinstance(config, dict) else {},
5657
6151
  questions=questions,
5658
6152
  associations=associations,
6153
+ buttons_config=buttons_config,
5659
6154
  associated_resources_config=associated_resources_config,
5660
6155
  )
5661
6156
  question_entries = _extract_view_question_entries(config.get("viewgraphQuestions"))
@@ -5682,6 +6177,7 @@ class AiBuilderFacade:
5682
6177
  "verification": verification,
5683
6178
  "verified": all(bool(value) for value in verification.values()),
5684
6179
  "query_conditions": query_conditions,
6180
+ "buttons_config": buttons_config,
5685
6181
  "associated_resources_config": associated_resources_config,
5686
6182
  **response.model_dump(mode="json"),
5687
6183
  }
@@ -6165,6 +6661,173 @@ class AiBuilderFacade:
6165
6661
  "verification": {"field_count": len(field_names)},
6166
6662
  }
6167
6663
 
6664
+ def _expand_view_partial_patches(
6665
+ self,
6666
+ *,
6667
+ profile: str,
6668
+ app_key: str,
6669
+ schema: dict[str, Any],
6670
+ existing_by_key: dict[str, dict[str, Any]],
6671
+ existing_by_name: dict[str, list[dict[str, Any]]],
6672
+ patch_views: list[ViewPartialPatch],
6673
+ ) -> tuple[list[ViewUpsertPatch], list[dict[str, Any]], list[dict[str, Any]]]:
6674
+ parsed_schema = _parse_schema(schema)
6675
+ field_names_by_id = {
6676
+ field_id: str(field.get("name") or "").strip()
6677
+ for field in parsed_schema.get("fields") or []
6678
+ if isinstance(field, dict)
6679
+ and (field_id := _coerce_nonnegative_int(field.get("que_id") or field.get("field_id"))) is not None
6680
+ and str(field.get("name") or "").strip()
6681
+ }
6682
+ expanded: list[ViewUpsertPatch] = []
6683
+ issues: list[dict[str, Any]] = []
6684
+ results: list[dict[str, Any]] = []
6685
+ for index, patch in enumerate(patch_views):
6686
+ view_key = str(patch.view_key or "").strip()
6687
+ name = str(patch.name or "").strip()
6688
+ matched_view: dict[str, Any] | None = None
6689
+ if view_key:
6690
+ matched_view = existing_by_key.get(view_key)
6691
+ if matched_view is None:
6692
+ issue = {
6693
+ "error_code": "UNKNOWN_VIEW",
6694
+ "reason_path": f"patch_views[{index}].view_key",
6695
+ "view_key": view_key,
6696
+ "message": "view_key does not exist on this app",
6697
+ }
6698
+ issues.append(issue)
6699
+ results.append({"index": index, "status": "failed", **issue})
6700
+ continue
6701
+ else:
6702
+ matches = existing_by_name.get(name, [])
6703
+ if len(matches) != 1:
6704
+ issue = {
6705
+ "error_code": "AMBIGUOUS_VIEW" if matches else "UNKNOWN_VIEW",
6706
+ "reason_path": f"patch_views[{index}].name",
6707
+ "view_name": name,
6708
+ "matches": [
6709
+ {"name": _extract_view_name(view), "view_key": _extract_view_key(view), "type": _normalize_view_type_name(view.get("viewgraphType") or view.get("type"))}
6710
+ for view in matches
6711
+ ],
6712
+ "message": "patch_views[] must target a single existing view; use view_key when names are duplicated",
6713
+ }
6714
+ issues.append(issue)
6715
+ results.append({"index": index, "status": "failed", **issue})
6716
+ continue
6717
+ matched_view = matches[0]
6718
+ view_key = _extract_view_key(matched_view)
6719
+ try:
6720
+ config_response = self.views.view_get_config(profile=profile, viewgraph_key=view_key)
6721
+ config = config_response.get("result") if isinstance(config_response.get("result"), dict) else {}
6722
+ if not isinstance(config, dict):
6723
+ config = {}
6724
+ except (QingflowApiError, RuntimeError) as error:
6725
+ api_error = _coerce_api_error(error)
6726
+ issue = {
6727
+ "error_code": "VIEW_PATCH_CONFIG_READ_FAILED",
6728
+ "reason_path": f"patch_views[{index}]",
6729
+ "view_key": view_key,
6730
+ "message": api_error.message,
6731
+ "transport_error": _transport_error_payload(api_error),
6732
+ }
6733
+ issues.append(issue)
6734
+ results.append({"index": index, "status": "failed", **issue})
6735
+ continue
6736
+ question_list: list[dict[str, Any]] = []
6737
+ try:
6738
+ question_response = self.views.view_list_questions(profile=profile, viewgraph_key=view_key)
6739
+ raw_questions = question_response.get("result")
6740
+ if isinstance(raw_questions, list):
6741
+ question_list = [deepcopy(item) for item in raw_questions if isinstance(item, dict)]
6742
+ except (QingflowApiError, RuntimeError):
6743
+ question_list = []
6744
+ base_summary = {
6745
+ "name": _extract_view_name(config) or _extract_view_name(matched_view or {}) or name or view_key,
6746
+ "view_key": view_key,
6747
+ "type": _normalize_view_type_name(config.get("viewgraphType") or (matched_view or {}).get("viewgraphType") or (matched_view or {}).get("type")),
6748
+ }
6749
+ summary = _merge_view_summary_with_config(base_summary, config=config, question_list=question_list)
6750
+ current_payload = _view_upsert_payload_from_existing_view(
6751
+ config=config,
6752
+ summary=summary,
6753
+ view_key=view_key,
6754
+ field_names_by_id=field_names_by_id,
6755
+ )
6756
+ normalized_set, set_issues = _normalize_view_partial_set(patch.set, reason_path=f"patch_views[{index}].set")
6757
+ normalized_unset, unset_issues = _normalize_view_partial_unset(patch.unset, reason_path=f"patch_views[{index}].unset")
6758
+ if set_issues or unset_issues:
6759
+ patch_issues = [*set_issues, *unset_issues]
6760
+ issues.extend(patch_issues)
6761
+ results.append({"index": index, "status": "failed", "view_key": view_key, "issues": patch_issues})
6762
+ continue
6763
+ touched_keys = set(normalized_set) | set(normalized_unset)
6764
+ patch_payload = deepcopy(current_payload)
6765
+ for key, value in normalized_set.items():
6766
+ if key in {"query_conditions", "associated_resources", "visibility"} and isinstance(value, dict):
6767
+ merged_value = deepcopy(patch_payload.get(key) if isinstance(patch_payload.get(key), dict) else {})
6768
+ _deep_merge_public_config(merged_value, value)
6769
+ patch_payload[key] = merged_value
6770
+ else:
6771
+ patch_payload[key] = value
6772
+ for key in normalized_unset:
6773
+ if key == "filters":
6774
+ patch_payload["filters"] = []
6775
+ elif key == "buttons":
6776
+ patch_payload["buttons"] = []
6777
+ elif key == "query_conditions":
6778
+ patch_payload["query_conditions"] = {"enabled": False, "exact": False, "hide_before_query": False, "rows": []}
6779
+ elif key == "associated_resources":
6780
+ patch_payload["associated_resources"] = {"visible": False}
6781
+ elif key == "visibility":
6782
+ patch_payload.pop("visibility", None)
6783
+ else:
6784
+ issue = {
6785
+ "error_code": "VIEW_PATCH_UNSET_NOT_SUPPORTED",
6786
+ "reason_path": f"patch_views[{index}].unset",
6787
+ "field": key,
6788
+ "message": f"cannot unset {key}; use set with an explicit replacement value",
6789
+ }
6790
+ issues.append(issue)
6791
+ patch_payload["_partial_update"] = True
6792
+ patch_payload["_preserve_filters"] = "filters" not in touched_keys
6793
+ patch_payload["_preserve_buttons"] = "buttons" not in touched_keys
6794
+ patch_payload["_preserve_query_conditions"] = "query_conditions" not in touched_keys
6795
+ patch_payload["_preserve_associated_resources"] = "associated_resources" not in touched_keys
6796
+ try:
6797
+ expanded_patch = ViewUpsertPatch.model_validate(patch_payload)
6798
+ except Exception as error:
6799
+ issue = {
6800
+ "error_code": "VIEW_PATCH_HYDRATION_FAILED",
6801
+ "reason_path": f"patch_views[{index}]",
6802
+ "view_key": view_key,
6803
+ "message": str(error),
6804
+ "hydrated_payload": _compact_dict({k: v for k, v in patch_payload.items() if not str(k).startswith("_")}),
6805
+ }
6806
+ issues.append(issue)
6807
+ results.append({"index": index, "status": "failed", **issue})
6808
+ continue
6809
+ expanded.append(expanded_patch)
6810
+ results.append(
6811
+ {
6812
+ "index": index,
6813
+ "status": "expanded",
6814
+ "view_key": view_key,
6815
+ "set_paths": sorted(normalized_set),
6816
+ "unset_paths": sorted(normalized_unset),
6817
+ "preserved_paths": sorted(
6818
+ key
6819
+ for key, preserve in {
6820
+ "filters": expanded_patch.preserve_filters,
6821
+ "buttons": expanded_patch.preserve_buttons,
6822
+ "query_conditions": expanded_patch.preserve_query_conditions,
6823
+ "associated_resources": expanded_patch.preserve_associated_resources,
6824
+ }.items()
6825
+ if preserve
6826
+ ),
6827
+ }
6828
+ )
6829
+ return expanded, issues, results
6830
+
6168
6831
  def app_read(self, *, profile: str, app_key: str, include_raw: bool = False) -> JSONObject:
6169
6832
  state = self._load_app_state(profile=profile, app_key=app_key)
6170
6833
  base_result = state["base"]
@@ -7461,16 +8124,19 @@ class AiBuilderFacade:
7461
8124
  profile: str,
7462
8125
  app_key: str,
7463
8126
  upsert_views: list[ViewUpsertPatch],
8127
+ patch_views: list[ViewPartialPatch] | None = None,
7464
8128
  remove_views: list[str],
7465
8129
  publish: bool = True,
7466
8130
  ) -> JSONObject:
8131
+ patch_views = patch_views or []
7467
8132
  normalized_args = {
7468
8133
  "app_key": app_key,
7469
8134
  "upsert_views": [patch.model_dump(mode="json") for patch in upsert_views],
8135
+ "patch_views": [patch.model_dump(mode="json") for patch in patch_views],
7470
8136
  "remove_views": list(remove_views),
7471
8137
  "publish": publish,
7472
8138
  }
7473
- if not upsert_views and not remove_views:
8139
+ if not upsert_views and not patch_views and not remove_views:
7474
8140
  response = {
7475
8141
  "status": "success",
7476
8142
  "error_code": None,
@@ -7535,6 +8201,28 @@ class AiBuilderFacade:
7535
8201
  existing_by_name.setdefault(name, []).append(view)
7536
8202
  parsed_schema = _parse_schema(schema)
7537
8203
  field_names = {field["name"] for field in parsed_schema["fields"]}
8204
+ if patch_views:
8205
+ expanded_views, patch_issues, patch_results = self._expand_view_partial_patches(
8206
+ profile=profile,
8207
+ app_key=app_key,
8208
+ schema=schema,
8209
+ existing_by_key=existing_by_key,
8210
+ existing_by_name=existing_by_name,
8211
+ patch_views=patch_views,
8212
+ )
8213
+ if patch_issues:
8214
+ return finalize(
8215
+ _failed(
8216
+ "VIEW_PATCH_HYDRATION_FAILED",
8217
+ "one or more view partial patches could not be hydrated; no write was executed",
8218
+ normalized_args=normalized_args,
8219
+ details={"patch_results": patch_results, "blocking_issues": patch_issues},
8220
+ suggested_next_call={"tool_name": "view_get", "arguments": {"profile": profile, "view_key": patch_issues[0].get("view_key") or "VIEW_KEY"}},
8221
+ )
8222
+ )
8223
+ upsert_views = [*upsert_views, *expanded_views]
8224
+ normalized_args["upsert_views"] = [patch.model_dump(mode="json") for patch in upsert_views]
8225
+ normalized_args["patch_results"] = patch_results
7538
8226
  current_fields_by_name = {
7539
8227
  str(field.get("name") or ""): field
7540
8228
  for field in parsed_schema["fields"]
@@ -7701,7 +8389,13 @@ class AiBuilderFacade:
7701
8389
  missing_fields=[gantt_field_name],
7702
8390
  suggested_next_call={"tool_name": "app_get_fields", "arguments": {"profile": profile, "app_key": app_key}},
7703
8391
  )
7704
- translated_filters, filter_issues = _build_view_filter_groups(current_fields_by_name=current_fields_by_name, filters=patch.filters)
8392
+ translated_filters: list[list[dict[str, Any]]] | None
8393
+ filter_issues: list[dict[str, Any]]
8394
+ if patch.preserve_filters:
8395
+ translated_filters = None
8396
+ filter_issues = []
8397
+ else:
8398
+ translated_filters, filter_issues = _build_view_filter_groups(current_fields_by_name=current_fields_by_name, filters=patch.filters)
7705
8399
  if filter_issues:
7706
8400
  first_issue = filter_issues[0]
7707
8401
  return _failed(
@@ -7717,10 +8411,15 @@ class AiBuilderFacade:
7717
8411
  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
8412
  suggested_next_call={"tool_name": "app_get_fields", "arguments": {"profile": profile, "app_key": app_key}},
7719
8413
  )
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
- )
8414
+ if patch.preserve_query_conditions:
8415
+ query_condition_payload = None
8416
+ expected_query_conditions = None
8417
+ query_condition_issues = []
8418
+ else:
8419
+ query_condition_payload, expected_query_conditions, query_condition_issues = _build_view_query_conditions_payload(
8420
+ current_fields_by_name=current_fields_by_name,
8421
+ query_conditions=patch.query_conditions,
8422
+ )
7724
8423
  if query_condition_issues:
7725
8424
  first_issue = query_condition_issues[0]
7726
8425
  return _failed(
@@ -7741,10 +8440,15 @@ class AiBuilderFacade:
7741
8440
  },
7742
8441
  suggested_next_call={"tool_name": "app_get_fields", "arguments": {"profile": profile, "app_key": app_key}},
7743
8442
  )
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
- )
8443
+ if patch.preserve_associated_resources:
8444
+ associated_resources_payload = None
8445
+ expected_associated_resources = None
8446
+ associated_resource_issues = []
8447
+ else:
8448
+ associated_resources_payload, expected_associated_resources, associated_resource_issues = _build_view_associated_resources_payload(
8449
+ associated_resources=patch.associated_resources,
8450
+ available_resources=associated_resources,
8451
+ )
7748
8452
  if associated_resource_issues:
7749
8453
  first_issue = associated_resource_issues[0]
7750
8454
  return _failed(
@@ -7765,7 +8469,10 @@ class AiBuilderFacade:
7765
8469
  )
7766
8470
  explicit_button_dtos: list[dict[str, Any]] | None = None
7767
8471
  expected_button_summary: list[dict[str, Any]] | None = None
7768
- if patch.buttons is not None:
8472
+ if patch.preserve_buttons:
8473
+ explicit_button_dtos = None
8474
+ expected_button_summary = None
8475
+ elif patch.buttons is not None:
7769
8476
  explicit_button_dtos, button_issues = _build_view_button_dtos(
7770
8477
  current_fields_by_name=current_fields_by_name,
7771
8478
  bindings=patch.buttons,
@@ -8239,7 +8946,10 @@ class AiBuilderFacade:
8239
8946
  expected_filter_summary = _normalize_view_filter_groups_for_compare(expected_filters)
8240
8947
  expected_data_scope = "CUSTOM" if expected_filter_summary else "ALL"
8241
8948
  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
8949
+ filters_verified = (
8950
+ _view_filter_groups_equivalent(expected_filter_summary, actual_filters)
8951
+ and actual_data_scope == expected_data_scope
8952
+ )
8243
8953
  verification_entry["filters_verified"] = filters_verified
8244
8954
  verification_entry["view_key"] = verification_key
8245
8955
  verification_entry["expected_filters"] = expected_filter_summary
@@ -8737,6 +9447,126 @@ class AiBuilderFacade:
8737
9447
  "verified": verified,
8738
9448
  }
8739
9449
 
9450
+ def _expand_chart_partial_patches(
9451
+ self,
9452
+ *,
9453
+ profile: str,
9454
+ app_key: str,
9455
+ existing_by_id: dict[str, dict[str, Any]],
9456
+ existing_by_name: dict[str, list[dict[str, Any]]],
9457
+ patch_charts: list[ChartPartialPatch],
9458
+ ) -> tuple[list[ChartUpsertPatch], list[dict[str, Any]], list[dict[str, Any]]]:
9459
+ expanded: list[ChartUpsertPatch] = []
9460
+ issues: list[dict[str, Any]] = []
9461
+ results: list[dict[str, Any]] = []
9462
+ for index, patch in enumerate(patch_charts):
9463
+ chart_id = str(patch.chart_id or "").strip()
9464
+ if chart_id:
9465
+ if chart_id not in existing_by_id:
9466
+ issue = {
9467
+ "error_code": "CHART_NOT_FOUND",
9468
+ "reason_path": f"patch_charts[{index}].chart_id",
9469
+ "chart_id": chart_id,
9470
+ "message": "chart_id does not exist under this app",
9471
+ }
9472
+ issues.append(issue)
9473
+ results.append({"index": index, "status": "failed", **issue})
9474
+ continue
9475
+ else:
9476
+ name = str(patch.name or "").strip()
9477
+ matches = existing_by_name.get(name, [])
9478
+ if len(matches) != 1:
9479
+ issue = {
9480
+ "error_code": "AMBIGUOUS_CHART" if matches else "CHART_NOT_FOUND",
9481
+ "reason_path": f"patch_charts[{index}].name",
9482
+ "name": name,
9483
+ "candidate_chart_ids": [
9484
+ _extract_chart_identifier(item)
9485
+ for item in matches
9486
+ if _extract_chart_identifier(item)
9487
+ ],
9488
+ "message": "patch_charts[] must target a single existing chart; use chart_id when names are duplicated",
9489
+ }
9490
+ issues.append(issue)
9491
+ results.append({"index": index, "status": "failed", **issue})
9492
+ continue
9493
+ chart_id = _extract_chart_identifier(matches[0])
9494
+ if not chart_id:
9495
+ issue = {"error_code": "CHART_ID_MISSING", "reason_path": f"patch_charts[{index}].name", "name": name}
9496
+ issues.append(issue)
9497
+ results.append({"index": index, "status": "failed", **issue})
9498
+ continue
9499
+ try:
9500
+ base = self.charts.qingbi_report_get_base(profile=profile, chart_id=chart_id).get("result") or {}
9501
+ config = self.charts.qingbi_report_get_config(profile=profile, chart_id=chart_id).get("result") or {}
9502
+ if not isinstance(base, dict):
9503
+ base = {}
9504
+ if not isinstance(config, dict):
9505
+ config = {}
9506
+ except (QingflowApiError, RuntimeError) as error:
9507
+ api_error = _coerce_api_error(error)
9508
+ issue = {
9509
+ "error_code": "CHART_PATCH_DETAIL_READ_FAILED",
9510
+ "reason_path": f"patch_charts[{index}]",
9511
+ "chart_id": chart_id,
9512
+ "message": api_error.message,
9513
+ "transport_error": _transport_error_payload(api_error),
9514
+ }
9515
+ issues.append(issue)
9516
+ results.append({"index": index, "status": "failed", **issue})
9517
+ continue
9518
+ patch_payload = _chart_upsert_payload_from_existing(chart_id=chart_id, base=base, config=config)
9519
+ normalized_set, set_issues = _normalize_chart_partial_set(patch.set, reason_path=f"patch_charts[{index}].set")
9520
+ normalized_unset, unset_issues = _normalize_chart_partial_unset(patch.unset, reason_path=f"patch_charts[{index}].unset")
9521
+ if set_issues or unset_issues:
9522
+ patch_issues = [*set_issues, *unset_issues]
9523
+ issues.extend(patch_issues)
9524
+ results.append({"index": index, "status": "failed", "chart_id": chart_id, "issues": patch_issues})
9525
+ continue
9526
+ explicit_set_paths = set(normalized_set)
9527
+ for key, value in normalized_set.items():
9528
+ if key in {"config", "visibility"} and isinstance(value, dict):
9529
+ merged_value = deepcopy(patch_payload.get(key) if isinstance(patch_payload.get(key), dict) else {})
9530
+ _deep_merge_public_config(merged_value, value)
9531
+ patch_payload[key] = merged_value
9532
+ else:
9533
+ patch_payload[key] = value
9534
+ for key in normalized_unset:
9535
+ if key == "filters":
9536
+ patch_payload["filters"] = []
9537
+ explicit_set_paths.add("filters")
9538
+ elif key in {"question_config", "user_config"}:
9539
+ patch_payload[key] = []
9540
+ explicit_set_paths.add(key)
9541
+ elif key == "visibility":
9542
+ patch_payload.pop("visibility", None)
9543
+ try:
9544
+ expanded_patch = ChartUpsertPatch.model_validate(patch_payload)
9545
+ except Exception as error:
9546
+ issue = {
9547
+ "error_code": "CHART_PATCH_HYDRATION_FAILED",
9548
+ "reason_path": f"patch_charts[{index}]",
9549
+ "chart_id": chart_id,
9550
+ "message": str(error),
9551
+ "hydrated_payload": _compact_dict(patch_payload),
9552
+ }
9553
+ issues.append(issue)
9554
+ results.append({"index": index, "status": "failed", **issue})
9555
+ continue
9556
+ # Preserve model_fields_set semantics so config generation knows which public fields were explicitly replaced.
9557
+ expanded_patch.__pydantic_fields_set__ = set(explicit_set_paths)
9558
+ expanded.append(expanded_patch)
9559
+ results.append(
9560
+ {
9561
+ "index": index,
9562
+ "status": "expanded",
9563
+ "chart_id": chart_id,
9564
+ "set_paths": sorted(normalized_set),
9565
+ "unset_paths": sorted(normalized_unset),
9566
+ }
9567
+ )
9568
+ return expanded, issues, results
9569
+
8740
9570
  def chart_apply(self, *, profile: str, request: ChartApplyRequest) -> JSONObject:
8741
9571
  normalized_args = request.model_dump(mode="json")
8742
9572
  permission_outcomes: list[PermissionCheckOutcome] = []
@@ -8796,13 +9626,36 @@ class AiBuilderFacade:
8796
9626
  continue
8797
9627
  existing_by_name.setdefault(item_name, []).append(deepcopy(item))
8798
9628
 
9629
+ upsert_charts = list(request.upsert_charts)
9630
+ if request.patch_charts:
9631
+ expanded_charts, patch_issues, patch_results = self._expand_chart_partial_patches(
9632
+ profile=profile,
9633
+ app_key=app_key,
9634
+ existing_by_id=existing_by_id,
9635
+ existing_by_name=existing_by_name,
9636
+ patch_charts=request.patch_charts,
9637
+ )
9638
+ if patch_issues:
9639
+ return finalize(
9640
+ _failed(
9641
+ "CHART_PATCH_HYDRATION_FAILED",
9642
+ "one or more chart partial patches could not be hydrated; no write was executed",
9643
+ normalized_args=normalized_args,
9644
+ details={"patch_results": patch_results, "blocking_issues": patch_issues},
9645
+ suggested_next_call={"tool_name": "chart_get", "arguments": {"profile": profile, "chart_id": patch_issues[0].get("chart_id") or "CHART_ID"}},
9646
+ )
9647
+ )
9648
+ upsert_charts.extend(expanded_charts)
9649
+ normalized_args["upsert_charts"] = [patch.model_dump(mode="json") for patch in upsert_charts]
9650
+ normalized_args["patch_results"] = patch_results
9651
+
8799
9652
  chart_results: list[dict[str, Any]] = []
8800
9653
  created_ids: list[str] = []
8801
9654
  updated_ids: list[str] = []
8802
9655
  removed_ids: list[str] = []
8803
9656
  failed_items: list[dict[str, Any]] = []
8804
9657
 
8805
- for patch in request.upsert_charts:
9658
+ for patch in upsert_charts:
8806
9659
  try:
8807
9660
  config_update_requested = _chart_patch_updates_chart_config(patch)
8808
9661
  chart_visible_auth = (
@@ -10154,6 +11007,94 @@ def _custom_button_add_data_config_has_semantic_inputs(value: dict[str, Any]) ->
10154
11007
  return bool(value.get("field_mappings") or value.get("fieldMappings") or value.get("default_values") or value.get("defaultValues"))
10155
11008
 
10156
11009
 
11010
+ def _normalize_custom_button_add_data_config_for_public(value: dict[str, Any]) -> dict[str, Any]:
11011
+ normalized: dict[str, Any] = {}
11012
+ related_app_key = _first_present(value, "related_app_key", "relatedAppKey", "target_app_key", "targetAppKey")
11013
+ related_app_name = _first_present(value, "related_app_name", "relatedAppName")
11014
+ if related_app_key is not None:
11015
+ normalized["related_app_key"] = related_app_key
11016
+ if related_app_name is not None:
11017
+ normalized["related_app_name"] = related_app_name
11018
+ relation_rules = _first_present(value, "que_relation", "queRelation")
11019
+ if isinstance(relation_rules, list):
11020
+ normalized["que_relation"] = [
11021
+ _normalize_custom_button_match_rule_for_public(rule)
11022
+ for rule in relation_rules
11023
+ if isinstance(rule, dict)
11024
+ ]
11025
+ field_mappings = _first_present(value, "field_mappings", "fieldMappings", "mappings")
11026
+ if isinstance(field_mappings, list):
11027
+ normalized["field_mappings"] = deepcopy(field_mappings)
11028
+ default_values = _first_present(value, "default_values", "defaultValues", "defaults")
11029
+ if isinstance(default_values, dict):
11030
+ normalized["default_values"] = deepcopy(default_values)
11031
+ return _compact_dict(normalized)
11032
+
11033
+
11034
+ def _normalize_custom_button_match_rule_for_public(value: dict[str, Any]) -> dict[str, Any]:
11035
+ normalized: dict[str, Any] = {}
11036
+ key_pairs = {
11037
+ "que_id": ("que_id", "queId"),
11038
+ "que_title": ("que_title", "queTitle"),
11039
+ "que_type": ("que_type", "queType"),
11040
+ "date_type": ("date_type", "dateType"),
11041
+ "judge_type": ("judge_type", "judgeType"),
11042
+ "match_type": ("match_type", "matchType"),
11043
+ "judge_que_type": ("judge_que_type", "judgeQueType"),
11044
+ "judge_que_id": ("judge_que_id", "judgeQueId"),
11045
+ "path_value": ("path_value", "pathValue"),
11046
+ "table_update_type": ("table_update_type", "tableUpdateType"),
11047
+ "multi_value": ("multi_value", "multiValue"),
11048
+ "add_rule": ("add_rule", "addRule"),
11049
+ "field_id_prefix": ("field_id_prefix", "fieldIdPrefix"),
11050
+ }
11051
+ for public_key, aliases in key_pairs.items():
11052
+ raw_value = _first_present(value, *aliases)
11053
+ if raw_value is not None:
11054
+ normalized[public_key] = raw_value
11055
+ judge_values = _first_present(value, "judge_values", "judgeValues")
11056
+ if isinstance(judge_values, list):
11057
+ normalized["judge_values"] = [str(item) for item in judge_values if item is not None]
11058
+ elif judge_values is not None:
11059
+ normalized["judge_values"] = [str(judge_values)]
11060
+ judge_que_detail = _first_present(value, "judge_que_detail", "judgeQueDetail")
11061
+ if isinstance(judge_que_detail, dict):
11062
+ normalized["judge_que_detail"] = _compact_dict(
11063
+ {
11064
+ "que_id": _first_present(judge_que_detail, "que_id", "queId"),
11065
+ "que_title": _first_present(judge_que_detail, "que_title", "queTitle"),
11066
+ "que_type": _first_present(judge_que_detail, "que_type", "queType"),
11067
+ }
11068
+ )
11069
+ judge_value_details = _first_present(value, "judge_value_details", "judgeValueDetails")
11070
+ if isinstance(judge_value_details, list):
11071
+ details: list[dict[str, Any]] = []
11072
+ for item in judge_value_details:
11073
+ if not isinstance(item, dict):
11074
+ continue
11075
+ detail = _compact_dict(
11076
+ {
11077
+ "id": _first_present(item, "id", "opt_id", "optId", "member_id", "memberId"),
11078
+ "value": _first_present(item, "value", "name", "label", "title"),
11079
+ }
11080
+ )
11081
+ if detail:
11082
+ details.append(detail)
11083
+ normalized["judge_value_details"] = details
11084
+ filter_condition = _first_present(value, "filter_condition", "filterCondition")
11085
+ if isinstance(filter_condition, list):
11086
+ normalized["filter_condition"] = [
11087
+ [
11088
+ _normalize_custom_button_match_rule_for_public(item)
11089
+ for item in group
11090
+ if isinstance(item, dict)
11091
+ ]
11092
+ for group in filter_condition
11093
+ if isinstance(group, list)
11094
+ ]
11095
+ return _compact_dict(normalized)
11096
+
11097
+
10157
11098
  def _custom_button_selector_payload(selector: Any) -> dict[str, Any]:
10158
11099
  if isinstance(selector, dict):
10159
11100
  payload = dict(selector)
@@ -10171,11 +11112,83 @@ def _custom_button_selector_payload(selector: Any) -> dict[str, Any]:
10171
11112
  raw = str(selector or "").strip()
10172
11113
  if not raw:
10173
11114
  return {}
10174
- if raw.isdigit():
10175
- return {"que_id": int(raw)}
11115
+ parsed_int = _coerce_any_int(raw)
11116
+ if parsed_int is not None:
11117
+ return {"que_id": parsed_int}
10176
11118
  return {"name": raw}
10177
11119
 
10178
11120
 
11121
+ _SYSTEM_MATCH_FIELD_ALIASES: dict[str, int] = {
11122
+ "数据id": -17,
11123
+ "数据ID": -17,
11124
+ "数据_id": -17,
11125
+ "row_record_id": -17,
11126
+ "data_id": -17,
11127
+ "record_id": -17,
11128
+ "apply_id": -17,
11129
+ "_id": -17,
11130
+ "编号": 0,
11131
+ "数据编号": 0,
11132
+ "record_number": 0,
11133
+ "serial_number": 0,
11134
+ "apply_num": 0,
11135
+ "data_num": 0,
11136
+ }
11137
+
11138
+
11139
+ def _normalize_system_match_alias(value: Any) -> str:
11140
+ raw = str(value or "").strip()
11141
+ if raw.startswith("{{") and raw.endswith("}}"):
11142
+ raw = raw[2:-2].strip()
11143
+ return raw.replace(" ", "").lower()
11144
+
11145
+
11146
+ def _system_match_field_from_selector(selector: Any) -> dict[str, Any] | None:
11147
+ if isinstance(selector, dict):
11148
+ for key in ("field_id", "fieldId", "que_id", "queId"):
11149
+ parsed = _coerce_any_int(selector.get(key))
11150
+ if parsed in {-17, 0}:
11151
+ return _system_match_field(parsed)
11152
+ for key in ("name", "title", "label", "value", "field", "source_field", "target_field"):
11153
+ raw = str(selector.get(key) or "").strip()
11154
+ if raw:
11155
+ field = _system_match_field_from_selector(raw)
11156
+ if field is not None:
11157
+ return field
11158
+ return None
11159
+ parsed = _coerce_any_int(selector)
11160
+ if parsed in {-17, 0}:
11161
+ return _system_match_field(parsed)
11162
+ normalized = _normalize_system_match_alias(selector)
11163
+ if normalized in _SYSTEM_MATCH_FIELD_ALIASES:
11164
+ return _system_match_field(_SYSTEM_MATCH_FIELD_ALIASES[normalized])
11165
+ return None
11166
+
11167
+
11168
+ def _system_match_field(que_id: int) -> dict[str, Any] | None:
11169
+ if que_id == -17:
11170
+ return {
11171
+ "field_id": -17,
11172
+ "que_id": -17,
11173
+ "que_type": 8,
11174
+ "name": "数据ID",
11175
+ "type": "system_record_id",
11176
+ "system_field": True,
11177
+ "system_kind": "row_record_id",
11178
+ }
11179
+ if que_id == 0:
11180
+ return {
11181
+ "field_id": 0,
11182
+ "que_id": 0,
11183
+ "que_type": 8,
11184
+ "name": "编号",
11185
+ "type": "system_record_number",
11186
+ "system_field": True,
11187
+ "system_kind": "record_number",
11188
+ }
11189
+ return None
11190
+
11191
+
10179
11192
  def _resolve_custom_button_schema_field(
10180
11193
  *,
10181
11194
  fields: list[dict[str, Any]],
@@ -10184,6 +11197,11 @@ def _resolve_custom_button_schema_field(
10184
11197
  role: str,
10185
11198
  ) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
10186
11199
  selector_payload = _custom_button_selector_payload(selector)
11200
+ allow_system = role in {"source", "match_source", "match_target"}
11201
+ if allow_system:
11202
+ system_field = _system_match_field_from_selector(selector_payload or selector)
11203
+ if system_field is not None:
11204
+ return system_field, None
10187
11205
  if not selector_payload:
10188
11206
  return None, {
10189
11207
  "error_code": "CUSTOM_BUTTON_MAPPING_FIELD_NOT_FOUND",
@@ -10193,18 +11211,19 @@ def _resolve_custom_button_schema_field(
10193
11211
  }
10194
11212
  matched: list[dict[str, Any]] = []
10195
11213
  field_id = selector_payload.get("field_id")
10196
- que_id = _coerce_nonnegative_int(selector_payload.get("que_id"))
11214
+ que_id = _coerce_any_int(selector_payload.get("que_id"))
10197
11215
  name = str(selector_payload.get("name") or "").strip()
10198
11216
  if field_id is not None:
10199
11217
  field_id_text = str(field_id).strip()
11218
+ field_id_int = _coerce_any_int(field_id_text)
10200
11219
  matched = [
10201
11220
  field
10202
11221
  for field in fields
10203
11222
  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))
11223
+ or (field_id_int is not None and _coerce_any_int(field.get("que_id")) == field_id_int)
10205
11224
  ]
10206
11225
  elif que_id is not None:
10207
- matched = [field for field in fields if _coerce_nonnegative_int(field.get("que_id")) == que_id]
11226
+ matched = [field for field in fields if _coerce_any_int(field.get("que_id")) == que_id]
10208
11227
  elif name:
10209
11228
  matched = [field for field in fields if str(field.get("name") or "").strip() == name]
10210
11229
  if len(matched) == 1:
@@ -10222,45 +11241,244 @@ def _resolve_custom_button_schema_field(
10222
11241
  }
10223
11242
 
10224
11243
 
10225
- def _custom_button_mapping_type_issue(
11244
+ def _custom_button_mapping_type_issue(
11245
+ *,
11246
+ source_field: dict[str, Any],
11247
+ target_field: dict[str, Any],
11248
+ reason_path: str,
11249
+ source_app_key: str | None = None,
11250
+ error_code: str = "CUSTOM_BUTTON_MAPPING_TYPE_MISMATCH",
11251
+ context_label: str = "field mapping",
11252
+ ) -> dict[str, Any] | None:
11253
+ source_type = str(source_field.get("type") or "")
11254
+ target_type = str(target_field.get("type") or "")
11255
+ compatible, reason = _match_mapping_types_compatible(
11256
+ source_field=source_field,
11257
+ target_field=target_field,
11258
+ source_app_key=source_app_key,
11259
+ )
11260
+ if compatible:
11261
+ return None
11262
+ if reason == "reference_source_mismatch":
11263
+ target_app_key = _relation_target_app_key(target_field)
11264
+ return {
11265
+ "error_code": "MATCH_RULE_REFERENCE_SOURCE_MISMATCH",
11266
+ "reason_path": reason_path,
11267
+ "source_app_key": source_app_key,
11268
+ "target_relation_app_key": target_app_key,
11269
+ "source_field": _match_field_summary(source_field),
11270
+ "target_field": _match_field_summary(target_field),
11271
+ "message": "数据ID represents the current source record, but the target relation field points to another app",
11272
+ "next_action": "choose a relation field that targets the current source app, or map a compatible non-relation field",
11273
+ }
11274
+ return {
11275
+ "error_code": error_code,
11276
+ "reason_path": reason_path,
11277
+ "source_field": _match_field_summary(source_field),
11278
+ "target_field": _match_field_summary(target_field),
11279
+ "message": f"source and target fields have incompatible types for {context_label}",
11280
+ "compatibility_reason": reason,
11281
+ "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",
11282
+ }
11283
+
11284
+
11285
+ def _retag_associated_resource_match_field_issue(issue: dict[str, Any]) -> dict[str, Any]:
11286
+ retagged = dict(issue)
11287
+ code = str(retagged.get("error_code") or "")
11288
+ if code == "CUSTOM_BUTTON_MAPPING_FIELD_NOT_FOUND":
11289
+ retagged["error_code"] = "ASSOCIATED_RESOURCE_MAPPING_FIELD_NOT_FOUND"
11290
+ elif code == "CUSTOM_BUTTON_MAPPING_FIELD_AMBIGUOUS":
11291
+ retagged["error_code"] = "ASSOCIATED_RESOURCE_MAPPING_FIELD_AMBIGUOUS"
11292
+ return retagged
11293
+
11294
+
11295
+ def _retag_associated_resource_static_value_issue(issue: dict[str, Any]) -> dict[str, Any]:
11296
+ retagged = dict(issue)
11297
+ if str(retagged.get("error_code") or "") == "CUSTOM_BUTTON_DEFAULT_VALUE_UNSUPPORTED":
11298
+ retagged["error_code"] = "ASSOCIATED_RESOURCE_STATIC_VALUE_UNSUPPORTED"
11299
+ retagged["message"] = str(retagged.get("message") or "static associated resource match value is unsupported")
11300
+ retagged["next_action"] = str(
11301
+ retagged.get("next_action")
11302
+ or "retry match_mappings[].value with a literal value supported by the target field type"
11303
+ )
11304
+ return retagged
11305
+
11306
+
11307
+ def _match_field_summary(field: dict[str, Any]) -> dict[str, Any]:
11308
+ return {
11309
+ "title": field.get("name"),
11310
+ "field_id": field.get("field_id"),
11311
+ "que_id": field.get("que_id"),
11312
+ "type": field.get("type"),
11313
+ "system_field": bool(field.get("system_field")),
11314
+ }
11315
+
11316
+
11317
+ def _relation_target_app_key(field: dict[str, Any]) -> str | None:
11318
+ for key in ("target_app_key", "targetAppKey", "refer_app_key", "referAppKey"):
11319
+ value = str(field.get(key) or "").strip()
11320
+ if value:
11321
+ return value
11322
+ for container_key in ("relation", "reference", "reference_config", "referenceConfig", "config"):
11323
+ container = field.get(container_key)
11324
+ if not isinstance(container, dict):
11325
+ continue
11326
+ nested = _relation_target_app_key(container)
11327
+ if nested:
11328
+ return nested
11329
+ template = field.get("_reference_config_template")
11330
+ if isinstance(template, dict):
11331
+ nested = _relation_target_app_key(template)
11332
+ if nested:
11333
+ return nested
11334
+ return None
11335
+
11336
+
11337
+ def _match_mapping_types_compatible(
10226
11338
  *,
10227
11339
  source_field: dict[str, Any],
10228
11340
  target_field: dict[str, Any],
10229
- reason_path: str,
10230
- ) -> dict[str, Any] | None:
11341
+ source_app_key: str | None = None,
11342
+ ) -> tuple[bool, str | None]:
10231
11343
  source_type = str(source_field.get("type") or "")
10232
11344
  target_type = str(target_field.get("type") or "")
11345
+ unsupported = {
11346
+ FieldType.attachment.value,
11347
+ FieldType.subtable.value,
11348
+ FieldType.code_block.value,
11349
+ FieldType.q_linker.value,
11350
+ FieldType.address.value,
11351
+ }
11352
+ if source_type in unsupported or target_type in unsupported:
11353
+ return False, "unsupported_field_type"
11354
+
11355
+ if target_type == FieldType.relation.value:
11356
+ if source_type == "system_record_id":
11357
+ target_app_key = _relation_target_app_key(target_field)
11358
+ if source_app_key and target_app_key and str(target_app_key).strip() != str(source_app_key).strip():
11359
+ return False, "reference_source_mismatch"
11360
+ return True, None
11361
+ if source_type == FieldType.relation.value:
11362
+ source_target = _relation_target_app_key(source_field)
11363
+ target_target = _relation_target_app_key(target_field)
11364
+ if source_target and target_target and source_target != target_target:
11365
+ return False, "relation_target_app_mismatch"
11366
+ return True, None
11367
+ return False, "relation_requires_record_id_or_relation"
11368
+ if source_type == FieldType.relation.value:
11369
+ if target_type == "system_record_id":
11370
+ return True, None
11371
+ return False, "relation_only_matches_relation_or_record_id"
11372
+
11373
+ if source_type == "system_record_id":
11374
+ if target_type in {
11375
+ "system_record_id",
11376
+ FieldType.text.value,
11377
+ FieldType.long_text.value,
11378
+ FieldType.number.value,
11379
+ FieldType.amount.value,
11380
+ }:
11381
+ return True, None
11382
+ return False, "record_id_requires_relation_or_id_compatible_target"
11383
+ if target_type == "system_record_id":
11384
+ if source_type in {
11385
+ "system_record_id",
11386
+ FieldType.text.value,
11387
+ FieldType.long_text.value,
11388
+ FieldType.number.value,
11389
+ FieldType.amount.value,
11390
+ }:
11391
+ return True, None
11392
+ return False, "record_id_requires_id_compatible_source"
11393
+
11394
+ if source_type == "system_record_number":
11395
+ if target_type in {
11396
+ "system_record_number",
11397
+ FieldType.text.value,
11398
+ FieldType.long_text.value,
11399
+ FieldType.number.value,
11400
+ FieldType.amount.value,
11401
+ }:
11402
+ return True, None
11403
+ return False, "record_number_requires_text_or_number_target"
11404
+ if target_type == "system_record_number":
11405
+ if source_type in {
11406
+ "system_record_number",
11407
+ FieldType.text.value,
11408
+ FieldType.long_text.value,
11409
+ FieldType.number.value,
11410
+ FieldType.amount.value,
11411
+ }:
11412
+ return True, None
11413
+ return False, "record_number_requires_text_or_number_source"
11414
+
11415
+ exact_only = {FieldType.member.value, FieldType.department.value}
11416
+ if source_type in exact_only or target_type in exact_only:
11417
+ return (source_type == target_type, None if source_type == target_type else "member_department_require_same_type")
11418
+
10233
11419
  compatible_groups = [
10234
11420
  {FieldType.text.value, FieldType.long_text.value, FieldType.phone.value, FieldType.email.value},
10235
11421
  {FieldType.number.value, FieldType.amount.value},
10236
11422
  {FieldType.date.value, FieldType.datetime.value},
10237
11423
  {FieldType.single_select.value, FieldType.multi_select.value, FieldType.boolean.value},
10238
11424
  ]
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",
11425
+ if source_type == target_type or any(source_type in group and target_type in group for group in compatible_groups):
11426
+ return True, None
11427
+ return False, "different_type_family"
11428
+
11429
+
11430
+ def _match_mapping_operator_to_judge_type(operator: Any, *, reason_path: str) -> tuple[int, dict[str, Any] | None]:
11431
+ normalized = str(operator or "eq").strip().lower()
11432
+ aliases = {
11433
+ "eq": JUDGE_EQUAL,
11434
+ "equal": JUDGE_EQUAL,
11435
+ "=": JUDGE_EQUAL,
11436
+ "==": JUDGE_EQUAL,
11437
+ "neq": JUDGE_UNEQUAL,
11438
+ "ne": JUDGE_UNEQUAL,
11439
+ "!=": JUDGE_UNEQUAL,
11440
+ "not_eq": JUDGE_UNEQUAL,
11441
+ "not_equal": JUDGE_UNEQUAL,
11442
+ "gte": JUDGE_GREATER_OR_EQUAL,
11443
+ "ge": JUDGE_GREATER_OR_EQUAL,
11444
+ ">=": JUDGE_GREATER_OR_EQUAL,
11445
+ "greater_or_equal": JUDGE_GREATER_OR_EQUAL,
11446
+ "lte": JUDGE_LESS_OR_EQUAL,
11447
+ "le": JUDGE_LESS_OR_EQUAL,
11448
+ "<=": JUDGE_LESS_OR_EQUAL,
11449
+ "less_or_equal": JUDGE_LESS_OR_EQUAL,
11450
+ "contains": JUDGE_FUZZY_MATCH,
11451
+ "like": JUDGE_FUZZY_MATCH,
11452
+ "fuzzy": JUDGE_FUZZY_MATCH,
11453
+ "fuzzy_match": JUDGE_FUZZY_MATCH,
11454
+ "in": JUDGE_EQUAL_ANY,
11455
+ "any": JUDGE_EQUAL_ANY,
11456
+ "equal_any": JUDGE_EQUAL_ANY,
11457
+ "include_any": JUDGE_INCLUDE_ANY,
11458
+ "includes_any": JUDGE_INCLUDE_ANY,
11459
+ }
11460
+ if normalized in aliases:
11461
+ return aliases[normalized], None
11462
+ return JUDGE_EQUAL, {
11463
+ "error_code": "INVALID_MATCH_MAPPING_OPERATOR",
10246
11464
  "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",
11465
+ "operator": operator,
11466
+ "allowed_values": sorted(aliases),
11467
+ "message": "match_mappings[].operator is not supported",
11468
+ "next_action": "retry with one of the allowed operator values, such as eq, contains, in, gte, or lte",
10251
11469
  }
10252
11470
 
10253
11471
 
10254
11472
  def _custom_button_question_ref(field: dict[str, Any]) -> dict[str, Any]:
10255
11473
  return {
10256
- "que_id": _coerce_nonnegative_int(field.get("que_id")),
11474
+ "que_id": _coerce_any_int(field.get("que_id")),
10257
11475
  "que_title": str(field.get("name") or ""),
10258
11476
  "que_type": _coerce_nonnegative_int(field.get("que_type")),
10259
11477
  }
10260
11478
 
10261
11479
 
10262
11480
  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"))
11481
+ source_que_id = _coerce_any_int(source_field.get("que_id"))
10264
11482
  return {
10265
11483
  **_custom_button_question_ref(target_field),
10266
11484
  "match_type": MATCH_TYPE_QUESTION,
@@ -10285,6 +11503,36 @@ def _custom_button_default_value_rule(*, target_field: dict[str, Any], value_det
10285
11503
  }
10286
11504
 
10287
11505
 
11506
+ def _summarize_compiled_match_rules(rules: list[dict[str, Any]]) -> list[dict[str, Any]]:
11507
+ return [
11508
+ _compact_dict(
11509
+ {
11510
+ "target_field": {
11511
+ "title": rule.get("que_title"),
11512
+ "field_id": rule.get("que_id"),
11513
+ "que_id": rule.get("que_id"),
11514
+ "que_type": rule.get("que_type"),
11515
+ },
11516
+ "match_type": rule.get("match_type"),
11517
+ "judge_type": rule.get("judge_type"),
11518
+ "source_field": (
11519
+ {
11520
+ "title": (rule.get("judge_que_detail") or {}).get("que_title"),
11521
+ "field_id": rule.get("judge_que_id"),
11522
+ "que_id": rule.get("judge_que_id"),
11523
+ "que_type": rule.get("judge_que_type"),
11524
+ }
11525
+ if rule.get("match_type") == MATCH_TYPE_QUESTION
11526
+ else None
11527
+ ),
11528
+ "static_values": rule.get("judge_value_details") or rule.get("judge_values") if rule.get("match_type") != MATCH_TYPE_QUESTION else None,
11529
+ }
11530
+ )
11531
+ for rule in rules
11532
+ if isinstance(rule, dict)
11533
+ ]
11534
+
11535
+
10288
11536
  def _resolve_custom_button_option_detail(*, target_field: dict[str, Any], value: Any) -> dict[str, Any] | None:
10289
11537
  option_details = [item for item in target_field.get("option_details") or [] if isinstance(item, dict)]
10290
11538
  value_id = _coerce_positive_int(value.get("id", value.get("opt_id", value.get("optId"))) if isinstance(value, dict) else value)
@@ -10450,7 +11698,7 @@ def _normalize_custom_button_detail(item: dict[str, Any]) -> dict[str, Any]:
10450
11698
  if not isinstance(trigger_add_data_config, dict):
10451
11699
  trigger_add_data_config = item.get("triggerAddDataConfig")
10452
11700
  if isinstance(trigger_add_data_config, dict):
10453
- normalized["trigger_add_data_config"] = deepcopy(trigger_add_data_config)
11701
+ normalized["trigger_add_data_config"] = _normalize_custom_button_add_data_config_for_public(trigger_add_data_config)
10454
11702
  external_qrobot_config = item.get("external_qrobot_config")
10455
11703
  if not isinstance(external_qrobot_config, dict):
10456
11704
  external_qrobot_config = item.get("customButtonExternalQRobotRelationVO")
@@ -10464,6 +11712,148 @@ def _normalize_custom_button_detail(item: dict[str, Any]) -> dict[str, Any]:
10464
11712
  return normalized
10465
11713
 
10466
11714
 
11715
+ _CUSTOM_BUTTON_PARTIAL_PATCH_KEY_ALIASES = {
11716
+ "button_text": "button_text",
11717
+ "buttonText": "button_text",
11718
+ "name": "button_text",
11719
+ "text": "button_text",
11720
+ "background_color": "background_color",
11721
+ "backgroundColor": "background_color",
11722
+ "text_color": "text_color",
11723
+ "textColor": "text_color",
11724
+ "button_icon": "button_icon",
11725
+ "buttonIcon": "button_icon",
11726
+ "style_preset": "style_preset",
11727
+ "stylePreset": "style_preset",
11728
+ "trigger_action": "trigger_action",
11729
+ "triggerAction": "trigger_action",
11730
+ "trigger_link_url": "trigger_link_url",
11731
+ "triggerLinkUrl": "trigger_link_url",
11732
+ "trigger_add_data_config": "trigger_add_data_config",
11733
+ "triggerAddDataConfig": "trigger_add_data_config",
11734
+ "add_data_config": "trigger_add_data_config",
11735
+ "addDataConfig": "trigger_add_data_config",
11736
+ "external_qrobot_config": "external_qrobot_config",
11737
+ "externalQrobotConfig": "external_qrobot_config",
11738
+ "customButtonExternalQRobotRelationVO": "external_qrobot_config",
11739
+ "trigger_wings_config": "trigger_wings_config",
11740
+ "triggerWingsConfig": "trigger_wings_config",
11741
+ }
11742
+
11743
+ _CUSTOM_BUTTON_PARTIAL_SET_KEYS = {
11744
+ "button_text",
11745
+ "background_color",
11746
+ "text_color",
11747
+ "button_icon",
11748
+ "style_preset",
11749
+ "trigger_action",
11750
+ "trigger_link_url",
11751
+ "trigger_add_data_config",
11752
+ "external_qrobot_config",
11753
+ "trigger_wings_config",
11754
+ }
11755
+
11756
+ _CUSTOM_BUTTON_PARTIAL_UNSET_KEYS = {
11757
+ "trigger_link_url",
11758
+ "trigger_add_data_config",
11759
+ "external_qrobot_config",
11760
+ "trigger_wings_config",
11761
+ }
11762
+
11763
+
11764
+ def _canonical_custom_button_partial_patch_key(key: Any) -> str:
11765
+ raw = str(key or "").strip()
11766
+ return _CUSTOM_BUTTON_PARTIAL_PATCH_KEY_ALIASES.get(raw, raw)
11767
+
11768
+
11769
+ def _normalize_custom_button_partial_set(raw_set: Any, *, reason_path: str) -> tuple[dict[str, Any], list[dict[str, Any]]]:
11770
+ if not isinstance(raw_set, dict):
11771
+ return {}, [{"error_code": "INVALID_CUSTOM_BUTTON_PATCH_SET", "reason_path": reason_path, "message": "patch_buttons[].set must be an object"}]
11772
+ normalized: dict[str, Any] = {}
11773
+ issues: list[dict[str, Any]] = []
11774
+ for raw_key, value in raw_set.items():
11775
+ key_path = [part for part in str(raw_key or "").strip().split(".") if part]
11776
+ if not key_path:
11777
+ continue
11778
+ root = _canonical_custom_button_partial_patch_key(key_path[0])
11779
+ if root not in _CUSTOM_BUTTON_PARTIAL_SET_KEYS:
11780
+ issues.append(
11781
+ {
11782
+ "error_code": "UNSUPPORTED_CUSTOM_BUTTON_PATCH_FIELD",
11783
+ "reason_path": f"{reason_path}.{raw_key}",
11784
+ "field": raw_key,
11785
+ "allowed_fields": sorted(_CUSTOM_BUTTON_PARTIAL_SET_KEYS),
11786
+ }
11787
+ )
11788
+ continue
11789
+ if len(key_path) == 1:
11790
+ normalized[root] = value
11791
+ continue
11792
+ target = normalized.setdefault(root, {})
11793
+ if not isinstance(target, dict):
11794
+ issues.append(
11795
+ {
11796
+ "error_code": "INVALID_CUSTOM_BUTTON_PATCH_PATH",
11797
+ "reason_path": f"{reason_path}.{raw_key}",
11798
+ "message": "cannot combine a scalar replacement and nested patch for the same field",
11799
+ }
11800
+ )
11801
+ continue
11802
+ cursor = target
11803
+ for part in key_path[1:-1]:
11804
+ next_value = cursor.setdefault(part, {})
11805
+ if not isinstance(next_value, dict):
11806
+ issues.append({"error_code": "INVALID_CUSTOM_BUTTON_PATCH_PATH", "reason_path": f"{reason_path}.{raw_key}"})
11807
+ break
11808
+ cursor = next_value
11809
+ else:
11810
+ cursor[key_path[-1]] = value
11811
+ return normalized, issues
11812
+
11813
+
11814
+ def _normalize_custom_button_partial_unset(raw_unset: Any, *, reason_path: str) -> tuple[set[str], list[dict[str, Any]]]:
11815
+ if not isinstance(raw_unset, list):
11816
+ return set(), [{"error_code": "INVALID_CUSTOM_BUTTON_PATCH_UNSET", "reason_path": reason_path, "message": "patch_buttons[].unset must be a list"}]
11817
+ normalized: set[str] = set()
11818
+ issues: list[dict[str, Any]] = []
11819
+ for index, raw_key in enumerate(raw_unset):
11820
+ root = _canonical_custom_button_partial_patch_key(str(raw_key or "").strip().split(".")[0])
11821
+ if root not in _CUSTOM_BUTTON_PARTIAL_UNSET_KEYS:
11822
+ issues.append(
11823
+ {
11824
+ "error_code": "UNSUPPORTED_CUSTOM_BUTTON_PATCH_UNSET_FIELD",
11825
+ "reason_path": f"{reason_path}[{index}]",
11826
+ "field": raw_key,
11827
+ "allowed_fields": sorted(_CUSTOM_BUTTON_PARTIAL_UNSET_KEYS),
11828
+ }
11829
+ )
11830
+ continue
11831
+ normalized.add(root)
11832
+ return normalized, issues
11833
+
11834
+
11835
+ def _custom_button_upsert_payload_from_detail(
11836
+ detail: dict[str, Any],
11837
+ *,
11838
+ button_id: int,
11839
+ client_key: str | None = None,
11840
+ ) -> dict[str, Any]:
11841
+ payload = {
11842
+ "button_id": button_id,
11843
+ "client_key": client_key,
11844
+ "button_text": detail.get("button_text"),
11845
+ "background_color": detail.get("background_color"),
11846
+ "text_color": detail.get("text_color"),
11847
+ "button_icon": detail.get("button_icon"),
11848
+ "trigger_action": detail.get("trigger_action"),
11849
+ "trigger_link_url": detail.get("trigger_link_url"),
11850
+ "trigger_add_data_config": deepcopy(detail.get("trigger_add_data_config")) if isinstance(detail.get("trigger_add_data_config"), dict) else None,
11851
+ "external_qrobot_config": deepcopy(detail.get("external_qrobot_config")) if isinstance(detail.get("external_qrobot_config"), dict) else None,
11852
+ "trigger_wings_config": deepcopy(detail.get("trigger_wings_config")) if isinstance(detail.get("trigger_wings_config"), dict) else None,
11853
+ }
11854
+ return _compact_dict(payload)
11855
+
11856
+
10467
11857
  def _failed(
10468
11858
  error_code: str,
10469
11859
  message: str,
@@ -10956,6 +12346,125 @@ def _map_public_chart_type_to_backend(chart_type: PublicChartType) -> str:
10956
12346
  }[chart_type]
10957
12347
 
10958
12348
 
12349
+ _CHART_PARTIAL_PATCH_KEY_ALIASES = {
12350
+ "name": "name",
12351
+ "chart_name": "name",
12352
+ "chartName": "name",
12353
+ "type": "chart_type",
12354
+ "chart_type": "chart_type",
12355
+ "chartType": "chart_type",
12356
+ "dimension_fields": "dimension_field_ids",
12357
+ "dimension_field_ids": "dimension_field_ids",
12358
+ "dimensionFieldIds": "dimension_field_ids",
12359
+ "indicator_fields": "indicator_field_ids",
12360
+ "indicator_field_ids": "indicator_field_ids",
12361
+ "indicatorFieldIds": "indicator_field_ids",
12362
+ "metric_field_ids": "indicator_field_ids",
12363
+ "filters": "filters",
12364
+ "question_config": "question_config",
12365
+ "questionConfig": "question_config",
12366
+ "user_config": "user_config",
12367
+ "userConfig": "user_config",
12368
+ "config": "config",
12369
+ "visibility": "visibility",
12370
+ }
12371
+
12372
+ _CHART_PARTIAL_SET_KEYS = {
12373
+ "name",
12374
+ "chart_type",
12375
+ "dimension_field_ids",
12376
+ "indicator_field_ids",
12377
+ "filters",
12378
+ "question_config",
12379
+ "user_config",
12380
+ "config",
12381
+ "visibility",
12382
+ }
12383
+
12384
+ _CHART_PARTIAL_UNSET_KEYS = {"filters", "question_config", "user_config", "visibility"}
12385
+
12386
+
12387
+ def _canonical_chart_partial_patch_key(key: Any) -> str:
12388
+ raw = str(key or "").strip()
12389
+ return _CHART_PARTIAL_PATCH_KEY_ALIASES.get(raw, raw)
12390
+
12391
+
12392
+ def _normalize_chart_partial_set(raw_set: Any, *, reason_path: str) -> tuple[dict[str, Any], list[dict[str, Any]]]:
12393
+ if not isinstance(raw_set, dict):
12394
+ return {}, [{"error_code": "INVALID_CHART_PATCH_SET", "reason_path": reason_path, "message": "patch_charts[].set must be an object"}]
12395
+ normalized: dict[str, Any] = {}
12396
+ issues: list[dict[str, Any]] = []
12397
+ for raw_key, value in raw_set.items():
12398
+ key_path = [part for part in str(raw_key or "").strip().split(".") if part]
12399
+ if not key_path:
12400
+ continue
12401
+ root = _canonical_chart_partial_patch_key(key_path[0])
12402
+ if root not in _CHART_PARTIAL_SET_KEYS:
12403
+ issues.append(
12404
+ {
12405
+ "error_code": "UNSUPPORTED_CHART_PATCH_FIELD",
12406
+ "reason_path": f"{reason_path}.{raw_key}",
12407
+ "field": raw_key,
12408
+ "allowed_fields": sorted(_CHART_PARTIAL_SET_KEYS),
12409
+ }
12410
+ )
12411
+ continue
12412
+ if len(key_path) == 1:
12413
+ normalized[root] = value
12414
+ continue
12415
+ target = normalized.setdefault(root, {})
12416
+ if not isinstance(target, dict):
12417
+ issues.append({"error_code": "INVALID_CHART_PATCH_PATH", "reason_path": f"{reason_path}.{raw_key}"})
12418
+ continue
12419
+ cursor = target
12420
+ for part in key_path[1:-1]:
12421
+ next_value = cursor.setdefault(part, {})
12422
+ if not isinstance(next_value, dict):
12423
+ issues.append({"error_code": "INVALID_CHART_PATCH_PATH", "reason_path": f"{reason_path}.{raw_key}"})
12424
+ break
12425
+ cursor = next_value
12426
+ else:
12427
+ cursor[key_path[-1]] = value
12428
+ return normalized, issues
12429
+
12430
+
12431
+ def _normalize_chart_partial_unset(raw_unset: Any, *, reason_path: str) -> tuple[set[str], list[dict[str, Any]]]:
12432
+ if not isinstance(raw_unset, list):
12433
+ return set(), [{"error_code": "INVALID_CHART_PATCH_UNSET", "reason_path": reason_path, "message": "patch_charts[].unset must be a list"}]
12434
+ normalized: set[str] = set()
12435
+ issues: list[dict[str, Any]] = []
12436
+ for index, raw_key in enumerate(raw_unset):
12437
+ root = _canonical_chart_partial_patch_key(str(raw_key or "").strip().split(".")[0])
12438
+ if root not in _CHART_PARTIAL_UNSET_KEYS:
12439
+ issues.append(
12440
+ {
12441
+ "error_code": "UNSUPPORTED_CHART_PATCH_UNSET_FIELD",
12442
+ "reason_path": f"{reason_path}[{index}]",
12443
+ "field": raw_key,
12444
+ "allowed_fields": sorted(_CHART_PARTIAL_UNSET_KEYS),
12445
+ }
12446
+ )
12447
+ continue
12448
+ normalized.add(root)
12449
+ return normalized, issues
12450
+
12451
+
12452
+ def _chart_upsert_payload_from_existing(
12453
+ *,
12454
+ chart_id: str,
12455
+ base: dict[str, Any],
12456
+ config: dict[str, Any],
12457
+ ) -> dict[str, Any]:
12458
+ return _compact_dict(
12459
+ {
12460
+ "chart_id": chart_id,
12461
+ "name": str(base.get("chartName") or config.get("chartName") or chart_id).strip() or chart_id,
12462
+ "chart_type": _public_chart_type_from_backend(base.get("chartType") or config.get("chartType")),
12463
+ "config": deepcopy(config),
12464
+ }
12465
+ )
12466
+
12467
+
10959
12468
  def _qingbi_field_type_from_public_field(field_type: str | None) -> str:
10960
12469
  return {
10961
12470
  "single_select": "singleSelect",
@@ -11117,6 +12626,13 @@ def _build_public_chart_config_payload(
11117
12626
  qingbi_fields_by_id: dict[str, dict[str, Any]],
11118
12627
  ) -> dict[str, Any]:
11119
12628
  config = deepcopy(patch.config)
12629
+ explicit_fields = set(getattr(patch, "model_fields_set", set()) or set())
12630
+ if "dimension_field_ids" in explicit_fields:
12631
+ config.pop("selectedDimensions", None)
12632
+ if "indicator_field_ids" in explicit_fields:
12633
+ config.pop("selectedMetrics", None)
12634
+ if "filters" in explicit_fields:
12635
+ config.pop("beforeAggregationFilterMatrix", None)
11120
12636
  aggregate = str(config.pop("aggregate", "count") or "count").lower()
11121
12637
  before_filters = deepcopy(config.pop("beforeAggregationFilterMatrix", []))
11122
12638
  after_filters = deepcopy(config.pop("afterAggregationFilterMatrix", []))
@@ -17350,7 +18866,9 @@ def _normalize_view_button_type(value: Any) -> str | None:
17350
18866
 
17351
18867
  def _normalize_view_button_config_type(value: Any) -> str | None:
17352
18868
  normalized = str(value or "").strip().upper()
17353
- if normalized in {"TOP", "DETAIL", "LIST"}:
18869
+ if normalized == "LIST":
18870
+ return "INSIDE"
18871
+ if normalized in {"TOP", "DETAIL", "INSIDE"}:
17354
18872
  return normalized
17355
18873
  return None
17356
18874
 
@@ -17436,7 +18954,7 @@ def _extract_view_button_entries(config: dict[str, Any]) -> tuple[list[dict[str,
17436
18954
  if not isinstance(item, dict):
17437
18955
  continue
17438
18956
  entry = deepcopy(item)
17439
- entry.setdefault("configType", "LIST")
18957
+ entry.setdefault("configType", "INSIDE")
17440
18958
  entries.append(entry)
17441
18959
  return entries, "buttonConfig"
17442
18960
 
@@ -17471,13 +18989,47 @@ def _normalize_view_button_entry(entry: dict[str, Any]) -> dict[str, Any]:
17471
18989
  normalized[public_key] = deepcopy(value)
17472
18990
  trigger_add_data_config = entry.get("triggerAddDataConfig")
17473
18991
  if isinstance(trigger_add_data_config, dict):
17474
- normalized["trigger_add_data_config"] = deepcopy(trigger_add_data_config)
18992
+ normalized["trigger_add_data_config"] = _normalize_custom_button_add_data_config_for_public(trigger_add_data_config)
17475
18993
  trigger_wings_config = entry.get("triggerWingsConfig")
17476
18994
  if isinstance(trigger_wings_config, dict):
17477
18995
  normalized["trigger_wings_config"] = deepcopy(trigger_wings_config)
17478
18996
  return normalized
17479
18997
 
17480
18998
 
18999
+ def _public_view_button_placement(config_type: str | None) -> str | None:
19000
+ normalized = _normalize_view_button_config_type(config_type)
19001
+ if normalized == "TOP":
19002
+ return "header"
19003
+ if normalized == "DETAIL":
19004
+ return "detail"
19005
+ if normalized == "INSIDE":
19006
+ return "list"
19007
+ return None
19008
+
19009
+
19010
+ def _extract_view_buttons_config(config: dict[str, Any]) -> dict[str, Any]:
19011
+ entries, source = _extract_view_button_entries(config)
19012
+ placements: dict[str, list[dict[str, Any]]] = {"header": [], "detail": [], "list": []}
19013
+ items: list[dict[str, Any]] = []
19014
+ for entry in entries:
19015
+ normalized = _normalize_view_button_entry(entry)
19016
+ placement = _public_view_button_placement(normalized.get("config_type"))
19017
+ normalized["placement"] = placement
19018
+ items.append(normalized)
19019
+ if placement:
19020
+ placements.setdefault(placement, []).append(normalized)
19021
+ status = "ok" if items else "none"
19022
+ result: dict[str, Any] = {
19023
+ "status": status,
19024
+ "button_count": len(items),
19025
+ "placements": placements,
19026
+ "items": items,
19027
+ }
19028
+ if source:
19029
+ result["read_source"] = source
19030
+ return result
19031
+
19032
+
17481
19033
  def _normalize_view_buttons_for_compare(value: Any) -> list[dict[str, Any]]:
17482
19034
  if isinstance(value, dict):
17483
19035
  entries, _ = _extract_view_button_entries(value)
@@ -17612,7 +19164,7 @@ def _canonicalize_view_button_summary_order(items: list[dict[str, Any]]) -> list
17612
19164
  detail_main_buttons.append(item)
17613
19165
  elif config_type == "DETAIL":
17614
19166
  detail_more_buttons.append(item)
17615
- elif config_type == "LIST":
19167
+ elif config_type == "INSIDE":
17616
19168
  list_buttons.append(item)
17617
19169
  else:
17618
19170
  other_buttons.append(item)
@@ -17765,7 +19317,7 @@ def _build_grouped_view_button_config(button_config_dtos: list[dict[str, Any]])
17765
19317
  being_main = bool(item.get("beingMain", False))
17766
19318
  if config_type == "TOP":
17767
19319
  grouped["topButtonList"].append(item)
17768
- elif config_type == "LIST":
19320
+ elif config_type == "INSIDE":
17769
19321
  grouped["listButtonList"].append(item)
17770
19322
  elif being_main:
17771
19323
  grouped["mainButtonDetailList"].append(item)
@@ -18663,7 +20215,7 @@ def _associated_resources_config_matches(expected: dict[str, Any], actual: dict[
18663
20215
  return True
18664
20216
 
18665
20217
 
18666
- def _normalize_associated_resources_payload(payload: Any) -> list[dict[str, Any]]:
20218
+ def _normalize_associated_resources_payload(payload: Any, *, include_raw: bool = False) -> list[dict[str, Any]]:
18667
20219
  if isinstance(payload, dict):
18668
20220
  raw_items = (
18669
20221
  payload.get("associated_resources")
@@ -18684,7 +20236,7 @@ def _normalize_associated_resources_payload(payload: Any) -> list[dict[str, Any]
18684
20236
  normalized_items: list[dict[str, Any]] = []
18685
20237
  seen_ids: set[int] = set()
18686
20238
  for raw_item in raw_items:
18687
- item = _normalize_associated_resource_item(raw_item)
20239
+ item = _normalize_associated_resource_item(raw_item, include_raw=include_raw)
18688
20240
  item_id = _coerce_positive_int(item.get("associated_item_id"))
18689
20241
  if item_id is None or item_id in seen_ids:
18690
20242
  continue
@@ -18694,7 +20246,7 @@ def _normalize_associated_resources_payload(payload: Any) -> list[dict[str, Any]
18694
20246
  return normalized_items
18695
20247
 
18696
20248
 
18697
- def _normalize_associated_resource_item(raw_item: Any) -> dict[str, Any]:
20249
+ def _normalize_associated_resource_item(raw_item: Any, *, include_raw: bool = False) -> dict[str, Any]:
18698
20250
  if not isinstance(raw_item, dict):
18699
20251
  return {}
18700
20252
  item_id = _first_present(raw_item, "associated_item_id", "associatedItemId", "asosChartId", "id")
@@ -18725,7 +20277,10 @@ def _normalize_associated_resource_item(raw_item: Any) -> dict[str, Any]:
18725
20277
  if chart_type is not None:
18726
20278
  item["chart_type"] = _public_chart_type_from_backend(chart_type)
18727
20279
  item["report_source"] = _public_report_source_from_backend_source(source_type)
18728
- return _compact_dict(item)
20280
+ item = _compact_dict(item)
20281
+ if include_raw:
20282
+ item["_raw"] = deepcopy(raw_item)
20283
+ return item
18729
20284
 
18730
20285
 
18731
20286
  def _normalize_associated_graph_type(raw_item: dict[str, Any], *, chart_key: Any, view_key: Any) -> str:
@@ -18752,6 +20307,14 @@ def _associated_resource_index(resources: list[dict[str, Any]]) -> dict[int, dic
18752
20307
  return indexed
18753
20308
 
18754
20309
 
20310
+ def _strip_internal_associated_resource_raw(resources: list[dict[str, Any]]) -> list[dict[str, Any]]:
20311
+ return [
20312
+ _compact_dict({key: deepcopy(value) for key, value in item.items() if key != "_raw"})
20313
+ for item in resources
20314
+ if isinstance(item, dict)
20315
+ ]
20316
+
20317
+
18755
20318
  def _public_associated_graph_type(value: Any) -> str:
18756
20319
  normalized = str(value or "").strip().lower()
18757
20320
  if normalized in {"view", "viewgraph"}:
@@ -18887,6 +20450,117 @@ def _associated_resource_matches_patch(item: dict[str, Any], patch: AssociatedRe
18887
20450
  return _associated_resource_public_key(item) == _associated_resource_public_key(patch)
18888
20451
 
18889
20452
 
20453
+ _ASSOCIATED_RESOURCE_PARTIAL_PATCH_KEY_ALIASES = {
20454
+ "graph_type": "graph_type",
20455
+ "graphType": "graph_type",
20456
+ "resource_type": "graph_type",
20457
+ "resourceType": "graph_type",
20458
+ "target_app_key": "target_app_key",
20459
+ "targetAppKey": "target_app_key",
20460
+ "app_key": "target_app_key",
20461
+ "appKey": "target_app_key",
20462
+ "view_key": "view_key",
20463
+ "viewKey": "view_key",
20464
+ "viewgraphKey": "view_key",
20465
+ "viewGraphKey": "view_key",
20466
+ "chart_key": "chart_key",
20467
+ "chartKey": "chart_key",
20468
+ "report_source": "report_source",
20469
+ "reportSource": "report_source",
20470
+ "match_rules": "match_rules",
20471
+ "matchRules": "match_rules",
20472
+ "match_mappings": "match_mappings",
20473
+ "matchMappings": "match_mappings",
20474
+ }
20475
+
20476
+ _ASSOCIATED_RESOURCE_PARTIAL_SET_KEYS = {
20477
+ "graph_type",
20478
+ "target_app_key",
20479
+ "view_key",
20480
+ "chart_key",
20481
+ "report_source",
20482
+ "match_rules",
20483
+ "match_mappings",
20484
+ }
20485
+
20486
+ _ASSOCIATED_RESOURCE_PARTIAL_UNSET_KEYS = {"match_rules", "match_mappings"}
20487
+
20488
+
20489
+ def _canonical_associated_resource_partial_patch_key(key: Any) -> str:
20490
+ raw = str(key or "").strip()
20491
+ return _ASSOCIATED_RESOURCE_PARTIAL_PATCH_KEY_ALIASES.get(raw, raw)
20492
+
20493
+
20494
+ def _normalize_associated_resource_partial_set(raw_set: Any, *, reason_path: str) -> tuple[dict[str, Any], list[dict[str, Any]]]:
20495
+ if not isinstance(raw_set, dict):
20496
+ return {}, [{"error_code": "INVALID_ASSOCIATED_RESOURCE_PATCH_SET", "reason_path": reason_path, "message": "patch_resources[].set must be an object"}]
20497
+ normalized: dict[str, Any] = {}
20498
+ issues: list[dict[str, Any]] = []
20499
+ for raw_key, value in raw_set.items():
20500
+ root = _canonical_associated_resource_partial_patch_key(raw_key)
20501
+ if root not in _ASSOCIATED_RESOURCE_PARTIAL_SET_KEYS:
20502
+ issues.append(
20503
+ {
20504
+ "error_code": "UNSUPPORTED_ASSOCIATED_RESOURCE_PATCH_FIELD",
20505
+ "reason_path": f"{reason_path}.{raw_key}",
20506
+ "field": raw_key,
20507
+ "allowed_fields": sorted(_ASSOCIATED_RESOURCE_PARTIAL_SET_KEYS),
20508
+ }
20509
+ )
20510
+ continue
20511
+ normalized[root] = value
20512
+ return normalized, issues
20513
+
20514
+
20515
+ def _normalize_associated_resource_partial_unset(raw_unset: Any, *, reason_path: str) -> tuple[set[str], list[dict[str, Any]]]:
20516
+ if not isinstance(raw_unset, list):
20517
+ return set(), [{"error_code": "INVALID_ASSOCIATED_RESOURCE_PATCH_UNSET", "reason_path": reason_path, "message": "patch_resources[].unset must be a list"}]
20518
+ normalized: set[str] = set()
20519
+ issues: list[dict[str, Any]] = []
20520
+ for index, raw_key in enumerate(raw_unset):
20521
+ root = _canonical_associated_resource_partial_patch_key(raw_key)
20522
+ if root not in _ASSOCIATED_RESOURCE_PARTIAL_UNSET_KEYS:
20523
+ issues.append(
20524
+ {
20525
+ "error_code": "UNSUPPORTED_ASSOCIATED_RESOURCE_PATCH_UNSET_FIELD",
20526
+ "reason_path": f"{reason_path}[{index}]",
20527
+ "field": raw_key,
20528
+ "allowed_fields": sorted(_ASSOCIATED_RESOURCE_PARTIAL_UNSET_KEYS),
20529
+ }
20530
+ )
20531
+ continue
20532
+ normalized.add(root)
20533
+ return normalized, issues
20534
+
20535
+
20536
+ def _associated_resource_upsert_payload_from_existing_item(
20537
+ item: dict[str, Any],
20538
+ *,
20539
+ associated_item_id: int,
20540
+ client_key: str | None = None,
20541
+ ) -> dict[str, Any]:
20542
+ graph_type = str(item.get("graph_type") or item.get("resource_type") or "").strip().lower()
20543
+ payload = {
20544
+ "associated_item_id": associated_item_id,
20545
+ "client_key": client_key,
20546
+ "graph_type": "view" if graph_type == "view" else "chart",
20547
+ "target_app_key": item.get("target_app_key"),
20548
+ "view_key": item.get("view_key"),
20549
+ "chart_key": item.get("chart_key"),
20550
+ "report_source": item.get("report_source"),
20551
+ "match_rules": [],
20552
+ }
20553
+ raw = item.get("_raw") if isinstance(item.get("_raw"), dict) else {}
20554
+ raw_match_rules = raw.get("matchRules") if isinstance(raw, dict) else None
20555
+ if isinstance(raw_match_rules, list):
20556
+ if raw_match_rules and all(isinstance(group, list) for group in raw_match_rules):
20557
+ flattened = [rule for group in raw_match_rules for rule in group if isinstance(rule, dict)]
20558
+ payload["match_rules"] = flattened
20559
+ else:
20560
+ payload["match_rules"] = [rule for rule in raw_match_rules if isinstance(rule, dict)]
20561
+ return _compact_dict(payload)
20562
+
20563
+
18890
20564
  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
20565
  return {
18892
20566
  "error_code": "ASSOCIATED_RESOURCE_NOT_FOUND",
@@ -18907,14 +20581,33 @@ def _duplicate_associated_resource_issue(reason_path: str, associated_item_id: i
18907
20581
  }
18908
20582
 
18909
20583
 
18910
- def _serialize_associated_resource_match_rules(match_rules: list[CustomButtonMatchRulePatch]) -> list[list[dict[str, Any]]]:
20584
+ def _associated_resource_patch_has_match_config(patch: AssociatedResourceUpsertPatch) -> bool:
20585
+ fields_set = getattr(patch, "model_fields_set", set())
20586
+ return bool(patch.match_mappings) or bool(patch.match_rules) or "match_mappings" in fields_set
20587
+
20588
+
20589
+ def _serialize_associated_resource_match_rules(match_rules: list[Any]) -> list[list[dict[str, Any]]]:
18911
20590
  if not match_rules:
18912
20591
  return []
18913
- return [[_serialize_custom_button_match_rule(rule.model_dump(mode="json", exclude_none=True)) for rule in match_rules]]
20592
+ serialized: list[dict[str, Any]] = []
20593
+ for rule in match_rules:
20594
+ if isinstance(rule, CustomButtonMatchRulePatch):
20595
+ payload = rule.model_dump(mode="json", exclude_none=True)
20596
+ elif isinstance(rule, dict):
20597
+ payload = rule
20598
+ else:
20599
+ continue
20600
+ serialized.append(_serialize_custom_button_match_rule(payload))
20601
+ return [serialized] if serialized else []
18914
20602
 
18915
20603
 
18916
- def _serialize_associated_resource_create_payload(patch: AssociatedResourceUpsertPatch) -> dict[str, Any]:
20604
+ def _serialize_associated_resource_create_payload(
20605
+ patch: AssociatedResourceUpsertPatch,
20606
+ *,
20607
+ match_rules_override: list[dict[str, Any]] | None = None,
20608
+ ) -> dict[str, Any]:
18917
20609
  graph_type = _public_associated_graph_type(patch.graph_type)
20610
+ match_rules = match_rules_override if match_rules_override is not None else patch.match_rules
18918
20611
  return {
18919
20612
  "appKey": patch.target_app_key,
18920
20613
  "chartList": [
@@ -18924,22 +20617,32 @@ def _serialize_associated_resource_create_payload(patch: AssociatedResourceUpser
18924
20617
  "graphType": _backend_associated_graph_type(graph_type),
18925
20618
  }
18926
20619
  ],
18927
- "matchRules": _serialize_associated_resource_match_rules(patch.match_rules),
20620
+ "matchRules": _serialize_associated_resource_match_rules(match_rules),
18928
20621
  }
18929
20622
 
18930
20623
 
18931
- def _serialize_associated_resource_update_payload(patch: AssociatedResourceUpsertPatch, *, associated_item_id: int) -> dict[str, Any]:
20624
+ def _serialize_associated_resource_update_payload(
20625
+ patch: AssociatedResourceUpsertPatch,
20626
+ *,
20627
+ associated_item_id: int,
20628
+ existing_item: dict[str, Any] | None = None,
20629
+ match_rules_override: list[dict[str, Any]] | None = None,
20630
+ ) -> dict[str, Any]:
18932
20631
  graph_type = _public_associated_graph_type(patch.graph_type)
18933
- return _compact_dict(
20632
+ raw = existing_item.get("_raw") if isinstance(existing_item, dict) and isinstance(existing_item.get("_raw"), dict) else None
20633
+ payload = deepcopy(raw) if isinstance(raw, dict) else {}
20634
+ match_rules = match_rules_override if match_rules_override is not None else patch.match_rules
20635
+ payload.update(
18934
20636
  {
18935
20637
  "id": associated_item_id,
18936
20638
  "appKey": patch.target_app_key,
18937
20639
  "chartKey": patch.view_key if graph_type == "view" else patch.chart_key,
18938
20640
  "sourceType": _backend_source_type_for_associated_resource_patch(patch),
18939
20641
  "graphType": _backend_associated_graph_type(graph_type),
18940
- "matchRules": _serialize_associated_resource_match_rules(patch.match_rules),
20642
+ "matchRules": _serialize_associated_resource_match_rules(match_rules),
18941
20643
  }
18942
20644
  )
20645
+ return _compact_dict(payload)
18943
20646
 
18944
20647
 
18945
20648
  def _associated_resource_result_entry(
@@ -19014,6 +20717,204 @@ def _build_view_buttons_only_update_payload(config: Any, *, button_config_dtos:
19014
20717
  return payload
19015
20718
 
19016
20719
 
20720
+ _VIEW_PARTIAL_PATCH_KEY_ALIASES = {
20721
+ "view_name": "name",
20722
+ "viewName": "name",
20723
+ "view_key": "view_key",
20724
+ "viewKey": "view_key",
20725
+ "viewgraphKey": "view_key",
20726
+ "type": "type",
20727
+ "view_type": "type",
20728
+ "viewType": "type",
20729
+ "columns": "columns",
20730
+ "column_names": "columns",
20731
+ "columnNames": "columns",
20732
+ "fields": "columns",
20733
+ "group_by": "group_by",
20734
+ "groupBy": "group_by",
20735
+ "filters": "filters",
20736
+ "filter_rules": "filters",
20737
+ "filterRules": "filters",
20738
+ "start_field": "start_field",
20739
+ "startField": "start_field",
20740
+ "end_field": "end_field",
20741
+ "endField": "end_field",
20742
+ "title_field": "title_field",
20743
+ "titleField": "title_field",
20744
+ "buttons": "buttons",
20745
+ "visibility": "visibility",
20746
+ "query_conditions": "query_conditions",
20747
+ "queryConditions": "query_conditions",
20748
+ "query_condition": "query_conditions",
20749
+ "queryCondition": "query_conditions",
20750
+ "associated_resources": "associated_resources",
20751
+ "associatedResources": "associated_resources",
20752
+ "associated_reports": "associated_resources",
20753
+ "associatedReports": "associated_resources",
20754
+ }
20755
+
20756
+
20757
+ def _canonical_view_partial_patch_key(key: Any) -> str:
20758
+ raw = str(key or "").strip()
20759
+ return _VIEW_PARTIAL_PATCH_KEY_ALIASES.get(raw, raw)
20760
+
20761
+
20762
+ def _normalize_view_partial_set(raw_set: Any, *, reason_path: str) -> tuple[dict[str, Any], list[dict[str, Any]]]:
20763
+ if not isinstance(raw_set, dict):
20764
+ return {}, [{"error_code": "INVALID_VIEW_PATCH_SET", "reason_path": reason_path, "message": "patch_views[].set must be an object"}]
20765
+ normalized: dict[str, Any] = {}
20766
+ issues: list[dict[str, Any]] = []
20767
+ for raw_key, value in raw_set.items():
20768
+ key_path = [part for part in str(raw_key or "").strip().split(".") if part]
20769
+ if not key_path:
20770
+ continue
20771
+ root = _canonical_view_partial_patch_key(key_path[0])
20772
+ if root not in _VIEW_PARTIAL_SET_KEYS:
20773
+ issues.append(
20774
+ {
20775
+ "error_code": "UNSUPPORTED_VIEW_PATCH_FIELD",
20776
+ "reason_path": f"{reason_path}.{raw_key}",
20777
+ "field": raw_key,
20778
+ "allowed_fields": sorted(_VIEW_PARTIAL_SET_KEYS),
20779
+ }
20780
+ )
20781
+ continue
20782
+ if len(key_path) == 1:
20783
+ normalized[root] = value
20784
+ continue
20785
+ target = normalized.setdefault(root, {})
20786
+ if not isinstance(target, dict):
20787
+ issues.append(
20788
+ {
20789
+ "error_code": "INVALID_VIEW_PATCH_PATH",
20790
+ "reason_path": f"{reason_path}.{raw_key}",
20791
+ "message": "cannot combine a scalar replacement and nested patch for the same field",
20792
+ }
20793
+ )
20794
+ continue
20795
+ cursor = target
20796
+ for part in key_path[1:-1]:
20797
+ next_value = cursor.setdefault(part, {})
20798
+ if not isinstance(next_value, dict):
20799
+ issues.append({"error_code": "INVALID_VIEW_PATCH_PATH", "reason_path": f"{reason_path}.{raw_key}"})
20800
+ break
20801
+ cursor = next_value
20802
+ else:
20803
+ cursor[key_path[-1]] = value
20804
+ return normalized, issues
20805
+
20806
+
20807
+ def _normalize_view_partial_unset(raw_unset: Any, *, reason_path: str) -> tuple[set[str], list[dict[str, Any]]]:
20808
+ if not isinstance(raw_unset, list):
20809
+ return set(), [{"error_code": "INVALID_VIEW_PATCH_UNSET", "reason_path": reason_path, "message": "patch_views[].unset must be a list"}]
20810
+ normalized: set[str] = set()
20811
+ issues: list[dict[str, Any]] = []
20812
+ for index, raw_key in enumerate(raw_unset):
20813
+ root = _canonical_view_partial_patch_key(str(raw_key or "").strip().split(".")[0])
20814
+ if root not in _VIEW_PARTIAL_UNSET_KEYS:
20815
+ issues.append(
20816
+ {
20817
+ "error_code": "UNSUPPORTED_VIEW_PATCH_UNSET_FIELD",
20818
+ "reason_path": f"{reason_path}[{index}]",
20819
+ "field": raw_key,
20820
+ "allowed_fields": sorted(_VIEW_PARTIAL_UNSET_KEYS),
20821
+ }
20822
+ )
20823
+ continue
20824
+ normalized.add(root)
20825
+ return normalized, issues
20826
+
20827
+
20828
+ _VIEW_PARTIAL_SET_KEYS = {
20829
+ "name",
20830
+ "type",
20831
+ "columns",
20832
+ "group_by",
20833
+ "filters",
20834
+ "start_field",
20835
+ "end_field",
20836
+ "title_field",
20837
+ "buttons",
20838
+ "visibility",
20839
+ "query_conditions",
20840
+ "associated_resources",
20841
+ }
20842
+
20843
+ _VIEW_PARTIAL_UNSET_KEYS = {"filters", "buttons", "visibility", "query_conditions", "associated_resources"}
20844
+
20845
+
20846
+ def _deep_merge_public_config(target: dict[str, Any], patch: dict[str, Any]) -> None:
20847
+ for key, value in patch.items():
20848
+ if isinstance(value, dict) and isinstance(target.get(key), dict):
20849
+ _deep_merge_public_config(cast(dict[str, Any], target[key]), value)
20850
+ else:
20851
+ target[key] = deepcopy(value)
20852
+
20853
+
20854
+ def _field_name_by_id(field_names_by_id: dict[int, str], field_id: Any) -> str | None:
20855
+ normalized_id = _coerce_nonnegative_int(field_id)
20856
+ if normalized_id is None:
20857
+ return None
20858
+ name = str(field_names_by_id.get(normalized_id) or "").strip()
20859
+ return name or None
20860
+
20861
+
20862
+ def _view_upsert_payload_from_existing_view(
20863
+ *,
20864
+ config: dict[str, Any],
20865
+ summary: dict[str, Any],
20866
+ view_key: str,
20867
+ field_names_by_id: dict[int, str],
20868
+ ) -> dict[str, Any]:
20869
+ view_type = _normalize_view_type_name(config.get("viewgraphType") or summary.get("type"))
20870
+ configured_columns = [
20871
+ name
20872
+ for name in (_field_name_by_id(field_names_by_id, item) for item in (config.get("viewgraphQueIds") or []))
20873
+ if name and name not in _KNOWN_SYSTEM_VIEW_COLUMNS
20874
+ ]
20875
+ columns = [
20876
+ str(name or "").strip()
20877
+ for name in (summary.get("apply_columns") or summary.get("columns") or [])
20878
+ if str(name or "").strip() and str(name or "").strip() not in _KNOWN_SYSTEM_VIEW_COLUMNS
20879
+ ]
20880
+ if configured_columns and (not columns or len(configured_columns) >= len(columns)):
20881
+ columns = configured_columns
20882
+ display_config = summary.get("display_config") if isinstance(summary.get("display_config"), dict) else {}
20883
+ 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"))
20884
+ title_field = str(display_config.get("title_field") or "").strip() or _field_name_by_id(field_names_by_id, config.get("titleQue"))
20885
+ gantt_config = config.get("viewgraphGanttConfigVO") if isinstance(config.get("viewgraphGanttConfigVO"), dict) else {}
20886
+ start_field = _field_name_by_id(field_names_by_id, gantt_config.get("startTimeQueId")) if isinstance(gantt_config, dict) else None
20887
+ end_field = _field_name_by_id(field_names_by_id, gantt_config.get("endTimeQueId")) if isinstance(gantt_config, dict) else None
20888
+ gantt_title = _field_name_by_id(field_names_by_id, gantt_config.get("titleQueId")) if isinstance(gantt_config, dict) else None
20889
+ query_conditions = _extract_view_query_conditions_config(config)
20890
+ query_conditions.pop("row_field_ids", None)
20891
+ associated_resources = _extract_view_associated_resources_config(config)
20892
+ payload: dict[str, Any] = {
20893
+ "name": str(summary.get("name") or config.get("viewgraphName") or config.get("viewName") or view_key).strip(),
20894
+ "view_key": view_key,
20895
+ "type": view_type or "table",
20896
+ "columns": columns,
20897
+ "group_by": group_by,
20898
+ "filters": [],
20899
+ "query_conditions": query_conditions,
20900
+ "associated_resources": {
20901
+ "visible": bool(associated_resources.get("visible", False)),
20902
+ "limit_type": associated_resources.get("limit_type"),
20903
+ "associated_item_ids": list(associated_resources.get("configured_associated_item_ids") or []),
20904
+ },
20905
+ }
20906
+ if title_field:
20907
+ payload["title_field"] = title_field
20908
+ if view_type == "gantt":
20909
+ if start_field:
20910
+ payload["start_field"] = start_field
20911
+ if end_field:
20912
+ payload["end_field"] = end_field
20913
+ if gantt_title:
20914
+ payload["title_field"] = gantt_title
20915
+ return payload
20916
+
20917
+
19017
20918
  def _first_present(mapping: dict[str, Any], *keys: str) -> Any:
19018
20919
  for key in keys:
19019
20920
  if key in mapping and mapping[key] is not None:
@@ -19028,7 +20929,7 @@ def _build_view_create_payload(
19028
20929
  schema: dict[str, Any],
19029
20930
  patch: ViewUpsertPatch,
19030
20931
  ordinal: int,
19031
- view_filters: list[list[dict[str, Any]]],
20932
+ view_filters: list[list[dict[str, Any]]] | None,
19032
20933
  current_fields_by_name: dict[str, dict[str, Any]],
19033
20934
  auth_override: dict[str, Any] | None = None,
19034
20935
  explicit_button_dtos: list[dict[str, Any]] | None = None,
@@ -19226,7 +21127,7 @@ def _build_view_update_payload(
19226
21127
  source_viewgraph_key: str,
19227
21128
  schema: dict[str, Any],
19228
21129
  patch: ViewUpsertPatch,
19229
- view_filters: list[list[dict[str, Any]]],
21130
+ view_filters: list[list[dict[str, Any]]] | None,
19230
21131
  current_fields_by_name: dict[str, dict[str, Any]],
19231
21132
  auth_override: dict[str, Any] | None = None,
19232
21133
  explicit_button_dtos: list[dict[str, Any]] | None = None,
@@ -19334,7 +21235,7 @@ def _build_minimal_view_payload(
19334
21235
  schema: dict[str, Any],
19335
21236
  patch: ViewUpsertPatch,
19336
21237
  ordinal: int,
19337
- view_filters: list[list[dict[str, Any]]],
21238
+ view_filters: list[list[dict[str, Any]]] | None,
19338
21239
  current_fields_by_name: dict[str, dict[str, Any]],
19339
21240
  auth_override: dict[str, Any] | None = None,
19340
21241
  explicit_button_dtos: list[dict[str, Any]] | None = None,
@@ -19403,9 +21304,14 @@ def _hydrate_view_backend_payload(
19403
21304
  data.setdefault("beingImageAdaption", False)
19404
21305
  data.setdefault("clippingMode", "default")
19405
21306
  data.setdefault("frontCoverQueId", None)
19406
- data["viewgraphLimitType"] = 1
19407
- data["viewgraphLimit"] = deepcopy(view_filters) if view_filters else []
19408
- data["viewgraphLimitFormula"] = ""
21307
+ if view_filters is None:
21308
+ data.setdefault("viewgraphLimitType", 1)
21309
+ data.setdefault("viewgraphLimit", [])
21310
+ data.setdefault("viewgraphLimitFormula", "")
21311
+ else:
21312
+ data["viewgraphLimitType"] = 1
21313
+ data["viewgraphLimit"] = deepcopy(view_filters)
21314
+ data["viewgraphLimitFormula"] = ""
19409
21315
  data.setdefault("sortType", "defaultSort")
19410
21316
  if not data.get("viewgraphSorts"):
19411
21317
  sort_que_id = visible_que_id_values[0] if visible_que_id_values else 1
@@ -19417,8 +21323,11 @@ def _hydrate_view_backend_payload(
19417
21323
  data.setdefault("printTpls", [])
19418
21324
  data.setdefault("beingCommentStatus", False)
19419
21325
  data.setdefault("usages", [])
19420
- data["dataPermissionType"] = "CUSTOM"
19421
- data["dataScope"] = "CUSTOM" if view_filters else "ALL"
21326
+ data.setdefault("dataPermissionType", "CUSTOM")
21327
+ if view_filters is None:
21328
+ data.setdefault("dataScope", "ALL")
21329
+ else:
21330
+ data["dataScope"] = "CUSTOM" if view_filters else "ALL"
19422
21331
  data.setdefault("needPass", False)
19423
21332
  data.setdefault("beingWorkflowNodeFutureListVisible", True)
19424
21333
  data.setdefault("asosChartConfig", {"limitType": 1, "asosChartIdList": []})
@@ -19713,6 +21622,44 @@ def _normalize_view_filter_groups_for_compare(groups: Any) -> list[list[dict[str
19713
21622
  return normalized_groups
19714
21623
 
19715
21624
 
21625
+ def _view_filter_rule_values_for_signature(rule: dict[str, Any]) -> list[str]:
21626
+ values = [str(value) for value in (rule.get("judgeValues") or [])]
21627
+ if values:
21628
+ return values
21629
+ fallback_values: list[str] = []
21630
+ for detail in rule.get("judgeValueDetails") or []:
21631
+ if not isinstance(detail, dict):
21632
+ continue
21633
+ item_id = detail.get("id")
21634
+ item_value = detail.get("value")
21635
+ if item_id is not None:
21636
+ fallback_values.append(str(item_id))
21637
+ elif item_value is not None:
21638
+ fallback_values.append(str(item_value))
21639
+ return fallback_values
21640
+
21641
+
21642
+ def _view_filter_groups_signature(groups: Any) -> list[list[dict[str, Any]]]:
21643
+ signature: list[list[dict[str, Any]]] = []
21644
+ for group in _normalize_view_filter_groups_for_compare(groups):
21645
+ group_signature: list[dict[str, Any]] = []
21646
+ for rule in group:
21647
+ group_signature.append(
21648
+ {
21649
+ "queId": rule.get("queId"),
21650
+ "judgeType": rule.get("judgeType"),
21651
+ "judgeValues": _view_filter_rule_values_for_signature(rule),
21652
+ }
21653
+ )
21654
+ if group_signature:
21655
+ signature.append(group_signature)
21656
+ return signature
21657
+
21658
+
21659
+ def _view_filter_groups_equivalent(expected: Any, actual: Any) -> bool:
21660
+ return _view_filter_groups_signature(expected) == _view_filter_groups_signature(actual)
21661
+
21662
+
19716
21663
  def _infer_status_field_id(fields: list[dict[str, Any]]) -> str | None:
19717
21664
  preferred_names = {"status", "状态", "订单状态", "审批状态", "流程状态"}
19718
21665
  for field in fields: